mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-09 03:17:54 +00:00
fix(objective-c): harden provider integration
This commit is contained in:
parent
545537e3c1
commit
c80193e1a3
22 changed files with 519974 additions and 518380 deletions
|
|
@ -39,7 +39,7 @@ Commands and gotchas live under **Repo reference** below and in **[CONTRIBUTING.
|
|||
## Reference docs
|
||||
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)**, **[CONTRIBUTING.md](CONTRIBUTING.md)**, **[GUARDRAILS.md](GUARDRAILS.md)**
|
||||
- **Fork-specific work:** read **[docs/fork/README.md](docs/fork/README.md)** before changing the Objective-C provider, external index storage, or content-retention behavior. These documents define fork contracts; ForgeMate runner and version orchestration remain outside this repository.
|
||||
- **Fork-specific work:** read **[docs/fork/README.md](docs/fork/README.md)** before changing the Objective-C provider. This document defines the fork contract; ForgeMate runner and version orchestration remain outside this repository.
|
||||
- **Call & inheritance resolution (RFC #909 Ring 3):** See ARCHITECTURE.md § Scope-Resolution Pipeline. All languages resolve calls and inheritance through the scope-resolution pipeline (`Registry.lookup`, `preEmitInheritanceEdges`, `emitHeritageEdges`, `buildMro` → `MethodDispatchIndex`). **Shared code in `gitnexus/src/core/ingestion/` must not name languages** — plug language behavior in via `LanguageProvider` / `ScopeResolver` hooks. A language plugs in by implementing `ScopeResolver` (`scope-resolution/contract/scope-resolver.ts`) and registering it in `SCOPE_RESOLVERS`. (The legacy call-resolution DAG + `@heritage` capture path were removed in RING4-1 #942.)
|
||||
- **Cursor:** `.cursor/index.mdc` (always-on); `.cursor/rules/*.mdc` (glob-scoped). Legacy `.cursorrules` deprecated.
|
||||
- **GitNexus:** standard skills in `.claude/skills/gitnexus-*/`; MCP rules in `gitnexus:start` block below.
|
||||
|
|
|
|||
|
|
@ -59,14 +59,9 @@ npx gitnexus setup
|
|||
|
||||
That's it. `analyze` indexes the codebase, installs agent skills, registers Claude Code hooks, and creates `AGENTS.md` / `CLAUDE.md` context files — all in one command. `setup` writes the MCP config so your AI agent can use the graph.
|
||||
|
||||
## ForgeMate Fork Extensions
|
||||
## ForgeMate Fork Extension
|
||||
|
||||
This fork keeps upstream GitNexus behavior as the default. Objective-C semantic indexing is implemented as an MVP on the fork's `dev` branch; it is not part of upstream `main` or the published upstream package. The following other fork-specific capabilities remain **planned**:
|
||||
|
||||
- An external per-repository index directory via `GITNEXUS_STORAGE_PATH`.
|
||||
- Configurable source-content retention via `GITNEXUS_CONTENT_RETENTION`.
|
||||
|
||||
The implementation contracts, compatibility requirements, and acceptance criteria live in [docs/fork/README.md](docs/fork/README.md). Do not rely on the planned storage and retention variables until their corresponding implementations and README environment-variable entries are released.
|
||||
This fork keeps upstream GitNexus behavior as the default. Objective-C semantic indexing is implemented as an MVP on the fork's `objective-c_support` branch; it is not part of upstream `main` or the published upstream package. Its compatibility requirements and acceptance criteria live in [docs/fork/README.md](docs/fork/README.md).
|
||||
|
||||
<details>
|
||||
<summary><strong>Install problems?</strong> npm 11 crash · slow cold install · no C++ toolchain</summary>
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
# External Storage and Content Retention
|
||||
|
||||
Status: planned
|
||||
|
||||
## Goal
|
||||
|
||||
Add two opt-in environment variables while preserving the current repository-local behavior by default:
|
||||
|
||||
```sh
|
||||
GITNEXUS_STORAGE_PATH=/absolute/path/to/index-slot
|
||||
GITNEXUS_CONTENT_RETENTION=full|symbol|none
|
||||
```
|
||||
|
||||
`GITNEXUS_STORAGE_PATH` lets an orchestrator choose the exact directory for one repository index. `GITNEXUS_CONTENT_RETENTION` controls source-derived text written into LadybugDB. Neither option creates a shared graph database or makes GitNexus responsible for an external system's version lifecycle.
|
||||
|
||||
## Storage path contract
|
||||
|
||||
### Inputs and default
|
||||
|
||||
- A missing `GITNEXUS_STORAGE_PATH` keeps the upstream storage location: `<repo>/.gitnexus/`.
|
||||
- A non-empty value must be an absolute, NUL-free path whose parent can be created and written. Relative, invalid, or unusable values fail before analysis begins.
|
||||
- The supplied value is the complete directory for one index, not a common root. The caller owns repository identity and chooses a collision-free slot name.
|
||||
- When external storage is selected, GitNexus must not create a `.gitnexus/` placeholder, symlink, or metadata duplicate in the source worktree.
|
||||
|
||||
### What moves
|
||||
|
||||
Every repository-index artifact must resolve through one storage resolver: LadybugDB (`lbug`), index metadata, GitNexus metadata, parse caches, parsed-file caches, lock files, branch placement, runner files, cleanup targets, and any future sibling artifacts. Redirecting only the database is incorrect because later CLI, MCP, server, or cleanup operations would still look in the temporary checkout.
|
||||
|
||||
Resolution order is:
|
||||
|
||||
```text
|
||||
1. Explicit GITNEXUS_STORAGE_PATH
|
||||
2. storagePath recorded in the registered repository entry
|
||||
3. <repository>/.gitnexus/ compatibility default
|
||||
```
|
||||
|
||||
Registry records may add an optional `storagePath`. On a successful analysis with an explicit path, GitNexus records the resolved absolute path so `status`, `serve`, and MCP can open the index after the source worktree has been removed. Existing registry entries without this field remain valid and use the local default.
|
||||
|
||||
### Concurrency and lifecycle
|
||||
|
||||
One storage slot permits one writer under the existing lock discipline. Distinct slots may analyze concurrently. GitNexus may know the checkout path used for analysis as diagnostic metadata, but it must not require that path to still exist for graph-only MCP/CLI operations.
|
||||
|
||||
An external caller may write to a staging slot and atomically promote it to its own `current` location, retaining a previous slot for rollback. That naming, retention, deletion, source checkout, source revision selection, and authorization are external orchestration concerns, not GitNexus behavior.
|
||||
|
||||
## Content retention contract
|
||||
|
||||
### Profiles
|
||||
|
||||
| Value | File `content` | Symbol/section snippets | Graph and identities | Intended effect |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `full` | Retained | Retained | Complete | Current upstream-compatible behavior. |
|
||||
| `symbol` | Omitted | Retained | Complete | Preserve symbol-level evidence without full eligible file text. |
|
||||
| `none` | Omitted | Omitted | Complete | Preserve structural graph only. |
|
||||
|
||||
Missing or empty `GITNEXUS_CONTENT_RETENTION` means `full`. An explicitly invalid value fails clearly; it must not silently select another profile.
|
||||
|
||||
`File.content` is normalized text used for retrieval, not a byte-faithful source archive. It may have normalized whitespace or CJK segmentation and cannot become a source viewer substitute.
|
||||
|
||||
### Query behavior
|
||||
|
||||
| Capability | `full` | `symbol` | `none` |
|
||||
| --- | --- | --- | --- |
|
||||
| Symbol name/selector search | Available | Available | Available |
|
||||
| `context`, `impact`, `trace`, structural Cypher | Available | Available | Available |
|
||||
| Arbitrary file-body keyword retrieval | Available | Only if retained in a symbol snippet | Unavailable |
|
||||
| Content-bearing query/context response | File and symbol content where supported | Symbol snippets only | Clear capability absence |
|
||||
| Filesystem preview, grep, rename, detect-changes | Requires source worktree | Requires source worktree | Requires source worktree |
|
||||
|
||||
The `symbol` and `none` profiles do not change provider parsing, node IDs, line ranges, relationships, or call-graph correctness. They reduce text-retrieval evidence. `symbol` is not equivalent to `full` for natural-language searches that rely on arbitrary comments or method bodies; `none` intentionally removes source text as evidence.
|
||||
|
||||
No profile may fabricate an empty source result as though it were a valid source snippet. Commands and MCP tools must expose capability absence or omit content fields deliberately. Tools that require a live worktree remain unavailable after that worktree is deleted regardless of retained profile.
|
||||
|
||||
### FTS and stored text
|
||||
|
||||
Under `symbol` and `none`, GitNexus must not build file-content FTS indexes. Other FTS indexes may cover only fields allowed by the selected profile. Future FTS allocation improvements, such as skipping unused label indexes, are compatible optimizations but are not required for the first implementation.
|
||||
|
||||
`none` removes all source-derived body text, including source comments/descriptions and PDG basic-block text. It retains stable IDs, names, paths, line ranges, node/edge types, relation structure, resolution confidence, and non-text diagnostics.
|
||||
|
||||
## Metadata, compatibility, and rebuilds
|
||||
|
||||
Persist at least:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentRetention": "symbol",
|
||||
"contentRetentionSchemaVersion": 1,
|
||||
"ftsProfile": "symbol-no-file-content",
|
||||
"storagePath": "/absolute/path/to/index-slot"
|
||||
}
|
||||
```
|
||||
|
||||
Incremental reuse must compare source revision/file hashes, GitNexus build and schema, language-provider and grammar versions, retention profile/schema, FTS profile, and include/exclude/max-file-size configuration. A mismatch requires a clean full rebuild into a new database. Clearing `content` values in place is not a valid conversion because prior FTS and database page allocation may remain.
|
||||
|
||||
Indexes written before these fields existed are interpreted as `full` for read compatibility. The next successful full rebuild writes the new metadata.
|
||||
|
||||
## Web UI and missing source files
|
||||
|
||||
The existing Web UI's file preview and grep operations read the repository filesystem. They must not assume that database `File.content` is valid raw code. With an external index and deleted checkout, the UI must report full-file preview and filesystem grep as unavailable, while continuing to show graph data, symbol snippets permitted by the retention profile, file paths, line ranges, source revision, and resolution confidence. A future raw-code browser needs a separate, explicit byte-faithful retention design.
|
||||
|
||||
## Required regression coverage
|
||||
|
||||
- No option: storage, metadata, CLI, MCP, and query results remain compatible with `<repo>/.gitnexus/` and `full` content.
|
||||
- External storage: all artifacts reside in the selected directory; none appears in the source worktree; a deleted worktree does not prevent graph-only `status` and MCP queries through the registry.
|
||||
- Storage failures: relative, invalid, unwritable, and conflicting paths fail cleanly without a usable partial index.
|
||||
- Concurrency: two repositories with distinct external slots do not share locks, caches, metadata, or data.
|
||||
- Retention: full/symbol/none keep identical structural node and edge counts for a fixture; only allowed text fields and FTS indexes differ.
|
||||
- Rebuild: changing profile, provider/grammar version, schema, or indexing configuration forces full rebuild and produces a physically new index.
|
||||
- MCP/CLI: content requests honor each profile and do not return misleading empty source text.
|
||||
- Web UI: missing checkout, `symbol`, and `none` present explicit unavailable states rather than broken previews.
|
||||
|
|
@ -14,30 +14,24 @@ This directory records behavior that belongs to the `mengkaka/GitNexus` fork. It
|
|||
| Capability | Status | Contract |
|
||||
| --- | --- | --- |
|
||||
| Objective-C Provider | Implemented | [OBJECTIVE_C_PROVIDER.md](OBJECTIVE_C_PROVIDER.md) |
|
||||
| External index storage | Planned | [EXTERNAL_STORAGE_AND_CONTENT_RETENTION.md](EXTERNAL_STORAGE_AND_CONTENT_RETENTION.md) |
|
||||
| Content retention profiles | Planned | [EXTERNAL_STORAGE_AND_CONTENT_RETENTION.md](EXTERNAL_STORAGE_AND_CONTENT_RETENTION.md) |
|
||||
|
||||
`Implemented` means the documented MVP, regression tests, metadata contract, and package/runtime wiring are present on the fork's `dev` branch. It does not expand the provider into full Objective-C runtime dispatch. `Planned` means no CLI, MCP, Web UI, or environment-variable behavior may claim support yet. Each implementation PR must update this table, its related design document, tests, and the public README environment-variable table where applicable.
|
||||
`Implemented` means the documented MVP, regression tests, metadata contract, and package/runtime wiring are present on the fork's `objective-c_support` branch. It does not expand the provider into full Objective-C runtime dispatch. Each implementation PR must update this table, its related design document, and tests.
|
||||
|
||||
## Reading order
|
||||
|
||||
1. Read this file for scope and compatibility boundaries.
|
||||
2. Read [OBJECTIVE_C_PROVIDER.md](OBJECTIVE_C_PROVIDER.md) before adding Objective-C parsing or resolution.
|
||||
3. Read [EXTERNAL_STORAGE_AND_CONTENT_RETENTION.md](EXTERNAL_STORAGE_AND_CONTENT_RETENTION.md) before changing repository storage, metadata, content fields, FTS, CLI, MCP, or Web UI behavior.
|
||||
4. Read `ARCHITECTURE.md`, `AGENTS.md`, and the affected implementation code before editing.
|
||||
3. Read `ARCHITECTURE.md`, `AGENTS.md`, and the affected implementation code before editing.
|
||||
|
||||
## Ownership boundary
|
||||
|
||||
GitNexus fork responsibilities:
|
||||
|
||||
- Parse and model Objective-C with explicit confidence and unresolved cases.
|
||||
- Persist one repository index at a caller-selected directory while preserving the existing default location.
|
||||
- Control which source-derived text is stored in that index and expose the resulting capabilities honestly.
|
||||
|
||||
ForgeMate responsibilities, intentionally not implemented here:
|
||||
|
||||
- Temporary checkout lifecycle, repository identity, index slot naming, and `current` / `previous` retention.
|
||||
- Source SHA selection, Wiki publication, job status, access control, and cross-repository authorization.
|
||||
- Repository lifecycle, source SHA selection, Wiki publication, job status, access control, and cross-repository authorization.
|
||||
- Combining Wiki and code evidence in its own MCP facade.
|
||||
|
||||
The fork must not turn GitNexus into ForgeMate's authoritative version database or a multi-repository shared graph database. A GitNexus index remains a rebuildable per-repository artifact.
|
||||
|
|
@ -45,9 +39,6 @@ The fork must not turn GitNexus into ForgeMate's authoritative version database
|
|||
## Change rules
|
||||
|
||||
- Keep default, no-option behavior byte-for-byte compatible where practical.
|
||||
- Persist every option that changes index semantics in repository metadata and reject unsafe incremental reuse.
|
||||
- Treat an index as valid only for its recorded source revision, parser/provider versions, schema, retention profile, and indexing configuration.
|
||||
- Do not silently substitute filesystem source content for stored content, or vice versa.
|
||||
- Do not add ForgeMate-specific paths, project IDs, credentials, or runner behavior to GitNexus core.
|
||||
|
||||
## Documentation lifecycle
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@
|
|||
*
|
||||
* Thin wrapper over `lookupCore`, specialized for class kinds:
|
||||
*
|
||||
* - `acceptedKinds` = Class / Interface / Enum / Struct / Union /
|
||||
* Trait / TypeAlias / Typedef / Record / Delegate / Annotation /
|
||||
* Template / Namespace.
|
||||
* - `acceptedKinds` = Class / Protocol / Category / Interface / Enum /
|
||||
* Struct / Union / Trait / TypeAlias / Typedef / Record / Delegate /
|
||||
* Annotation / Template / Namespace.
|
||||
* - `useReceiverTypeBinding` is **false** — classes are resolved by
|
||||
* name through the lexical chain + global qualified fallback, not
|
||||
* via a receiver type.
|
||||
|
|
|
|||
|
|
@ -100,6 +100,8 @@ export const findDeclarationNode = (root: any): any | null => {
|
|||
'struct_item',
|
||||
'interface_declaration',
|
||||
'interface_definition',
|
||||
'protocol_declaration', // Objective-C protocol
|
||||
'class_interface', // Objective-C class, category, or extension
|
||||
'enum_declaration',
|
||||
'enum_item',
|
||||
'type_declaration', // Go: type X struct
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ const DECLARATION_BODY_NODE_TYPES = new Set([
|
|||
'interface_body',
|
||||
]);
|
||||
|
||||
const DIRECT_MEMBER_DECLARATION_TYPES = new Set(['protocol_declaration', 'class_interface']);
|
||||
|
||||
const DIRECT_MEMBER_HEADER_NODE_TYPES = new Set(['identifier', 'protocol_reference_list']);
|
||||
|
||||
const FIELD_LIKE_MEMBER_TYPES = new Set([
|
||||
'field_definition',
|
||||
'public_field_definition',
|
||||
|
|
@ -159,6 +163,8 @@ const FIELD_LIKE_MEMBER_TYPES = new Set([
|
|||
'enum_assignment',
|
||||
]);
|
||||
|
||||
const DECLARATION_MEMBER_WRAPPER_TYPES = new Set(['qualified_protocol_interface_declaration']);
|
||||
|
||||
const declarationChunk = async (
|
||||
content: string,
|
||||
filePath: string,
|
||||
|
|
@ -337,6 +343,8 @@ const getDeclarationBodyNode = (node: any): any | null => {
|
|||
const bodyNode = node.childForFieldName?.('body');
|
||||
if (bodyNode) return bodyNode;
|
||||
|
||||
if (DIRECT_MEMBER_DECLARATION_TYPES.has(node.type)) return node;
|
||||
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
|
|
@ -352,15 +360,34 @@ const collectDeclarationUnits = (
|
|||
): Array<{ startIndex: number; endIndex: number }> => {
|
||||
const members: Array<{ startIndex: number; endIndex: number; groupable: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < bodyNode.namedChildCount; i++) {
|
||||
const child = bodyNode.namedChild(i);
|
||||
if (!child) continue;
|
||||
members.push({
|
||||
startIndex: child.startIndex,
|
||||
endIndex: child.endIndex,
|
||||
groupable: groupFields && FIELD_LIKE_MEMBER_TYPES.has(child.type),
|
||||
});
|
||||
}
|
||||
const collectMembers = (
|
||||
node: any,
|
||||
skipHeaderChildren: boolean,
|
||||
includeNodePrefixOnFirstMember = false,
|
||||
): void => {
|
||||
const firstMemberIndex = members.length;
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (!child) continue;
|
||||
if (DECLARATION_MEMBER_WRAPPER_TYPES.has(child.type)) {
|
||||
collectMembers(child, false, true);
|
||||
continue;
|
||||
}
|
||||
if (skipHeaderChildren && DIRECT_MEMBER_HEADER_NODE_TYPES.has(child.type)) continue;
|
||||
members.push({
|
||||
startIndex: child.startIndex,
|
||||
endIndex: child.endIndex,
|
||||
groupable: groupFields && FIELD_LIKE_MEMBER_TYPES.has(child.type),
|
||||
});
|
||||
}
|
||||
|
||||
const firstMember = members[firstMemberIndex];
|
||||
if (includeNodePrefixOnFirstMember && firstMember) {
|
||||
firstMember.startIndex = node.startIndex;
|
||||
}
|
||||
};
|
||||
|
||||
collectMembers(bodyNode, DIRECT_MEMBER_DECLARATION_TYPES.has(bodyNode.type));
|
||||
|
||||
if (members.length === 0) return [];
|
||||
|
||||
|
|
|
|||
|
|
@ -124,8 +124,6 @@ export const STRUCTURAL_LABELS: ReadonlySet<string> = new Set([
|
|||
export const LABELS_WITH_EXPORTED = new Set([
|
||||
LABEL_FUNCTION,
|
||||
LABEL_CLASS,
|
||||
LABEL_PROTOCOL,
|
||||
LABEL_CATEGORY,
|
||||
LABEL_INTERFACE,
|
||||
LABEL_METHOD,
|
||||
LABEL_CODE_ELEMENT,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export interface ManifestExtractResult {
|
|||
// #2325 integration test can run the EXACT production query against a real
|
||||
// LadybugDB — a hand-copied query string in the test would silently drift
|
||||
// from this allowlist. Uses the `labels(n) IN [...]` allowlist form rather
|
||||
// than a `MATCH (n:A|B)` disjunction: this 21-label list contains the
|
||||
// than a `MATCH (n:A|B)` disjunction: this 23-label list contains the
|
||||
// reserved-keyword labels `Macro` and `Union`, and LadybugDB's parser rejects
|
||||
// a disjunction that names a reserved keyword (#2325) — which the resolver's
|
||||
// try/catch then swallowed. `labels(n) IN` has no such collision.
|
||||
|
|
|
|||
|
|
@ -149,11 +149,16 @@ export interface ProviderSemanticNode {
|
|||
};
|
||||
}
|
||||
|
||||
export type ProviderSemanticRelationshipType = Extract<
|
||||
RelationshipType,
|
||||
'DECLARES' | 'DEFINES' | 'HAS_METHOD' | 'HAS_PROPERTY'
|
||||
>;
|
||||
|
||||
export interface ProviderSemanticRelationship {
|
||||
readonly id: string;
|
||||
readonly sourceId: string;
|
||||
readonly targetId: string;
|
||||
readonly type: RelationshipType;
|
||||
readonly type: ProviderSemanticRelationshipType;
|
||||
readonly confidence: number;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,12 +121,17 @@ function interpretObjectiveCImport(captures: CaptureMatch): ParsedImport | null
|
|||
if (source === undefined || source.text.trim().length === 0) return null;
|
||||
const targetRaw = source.text.trim();
|
||||
const kind = captures['@import.kind']?.text.trim();
|
||||
const isSystemHeader = targetRaw.startsWith('<') && targetRaw.endsWith('>');
|
||||
return {
|
||||
kind: 'side-effect',
|
||||
// Scope resolution needs to distinguish a quoted header path from a bare
|
||||
// @import module name, while the semantic graph retains the original raw
|
||||
// import spelling in ObjCImportFact.
|
||||
targetRaw: kind === 'module' || targetRaw.startsWith('./') ? targetRaw : `./${targetRaw}`,
|
||||
// @import module name and an angle-bracket system header. The latter stays
|
||||
// wrapped so the Objective-C resolver can fail closed instead of resolving
|
||||
// a framework header to a same-named local file.
|
||||
targetRaw:
|
||||
isSystemHeader || kind === 'module' || targetRaw.startsWith('./')
|
||||
? targetRaw
|
||||
: `./${targetRaw}`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -251,6 +251,11 @@ function stripQuotes(raw: string): string {
|
|||
return trimmed;
|
||||
}
|
||||
|
||||
function isSystemHeaderSpelling(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.startsWith('<') && trimmed.endsWith('>');
|
||||
}
|
||||
|
||||
function cleanType(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const text = raw
|
||||
|
|
@ -293,6 +298,15 @@ function methodTypeText(node: SyntaxNode): string | undefined {
|
|||
|
||||
function methodSelector(node: SyntaxNode): string {
|
||||
const children = directChildren(node);
|
||||
const keywordDeclarators = children.filter((child) => child.type === 'keyword_declarator');
|
||||
if (keywordDeclarators.length > 0) {
|
||||
return keywordDeclarators
|
||||
.map((declarator) => directIdentifiers(declarator)[0]?.text)
|
||||
.filter((name): name is string => name !== undefined)
|
||||
.map((name) => `${name}:`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
const pieces: string[] = [];
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
|
|
@ -329,7 +343,7 @@ function methodParameterInfo(node: SyntaxNode): {
|
|||
const parameterNames: string[] = [];
|
||||
const typeBindings = new Map<string, ObjCTypeInfo>();
|
||||
for (const child of directNamedChildren(node)) {
|
||||
if (child.type !== 'method_parameter') continue;
|
||||
if (child.type !== 'method_parameter' && child.type !== 'keyword_declarator') continue;
|
||||
const named = directNamedChildren(child);
|
||||
const typeNode = named.find((n) => n.type === 'method_type');
|
||||
const nameNode = [...named].reverse().find((n) => n.type === 'identifier');
|
||||
|
|
@ -669,6 +683,23 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj
|
|||
const macroNames = new Set<string>();
|
||||
const classNames = new Set<string>();
|
||||
|
||||
const addFunctionFact = (node: SyntaxNode): void => {
|
||||
const info = functionInfo(node);
|
||||
if (info === null) return;
|
||||
const qualifiedName = objcFunctionQualifiedName(info.name);
|
||||
const { startLine, endLine } = range(node);
|
||||
functions.push({
|
||||
name: info.name,
|
||||
qualifiedName,
|
||||
nodeId: graphNodeId('Function', qualifiedName),
|
||||
filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
...(info.returnType !== undefined ? { returnType: info.returnType } : {}),
|
||||
parameterTypes: info.parameterTypes,
|
||||
});
|
||||
};
|
||||
|
||||
walkNamedTree(tree.rootNode, (node) => {
|
||||
if (node.type !== 'preproc_def' && node.type !== 'preproc_function_def') return;
|
||||
const name = macroName(node);
|
||||
|
|
@ -695,6 +726,37 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj
|
|||
byName.set(name, typeInfo);
|
||||
};
|
||||
|
||||
const collectContainerMemberTypes = (
|
||||
containerNode: Parser.SyntaxNode,
|
||||
container: ObjCContainerFact,
|
||||
): void => {
|
||||
for (const inner of directNamedChildren(containerNode)) {
|
||||
if (inner.type === 'property_declaration') {
|
||||
const prop = propertyInfo(inner);
|
||||
if (prop === null) continue;
|
||||
addMemberType(container.qualifiedName, prop.name, prop.type);
|
||||
if (container.hostClass !== undefined) {
|
||||
addMemberType(objcClassQualifiedName(container.hostClass), prop.name, prop.type);
|
||||
}
|
||||
} else if (inner.type === 'instance_variables') {
|
||||
walkNamedTree(inner, (ivarNode) => {
|
||||
if (ivarNode.type !== 'instance_variable') return;
|
||||
const ivar = declarationNameAndType(ivarNode);
|
||||
if (ivar === null) return;
|
||||
addMemberType(container.qualifiedName, ivar.name, ivar.type);
|
||||
if (container.hostClass !== undefined) {
|
||||
addMemberType(objcClassQualifiedName(container.hostClass), ivar.name, ivar.type);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const child of directNamedChildren(tree.rootNode)) {
|
||||
const container = parseContainer(child, filePath);
|
||||
if (container !== null) collectContainerMemberTypes(child, container);
|
||||
}
|
||||
|
||||
for (const child of directNamedChildren(tree.rootNode)) {
|
||||
if (child.type === 'preproc_include') {
|
||||
const rawNode = directNamedChildren(child).find(
|
||||
|
|
@ -827,10 +889,6 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj
|
|||
startLine,
|
||||
endLine,
|
||||
});
|
||||
addMemberType(container.qualifiedName, prop.name, prop.type);
|
||||
if (container.hostClass !== undefined) {
|
||||
addMemberType(objcClassQualifiedName(container.hostClass), prop.name, prop.type);
|
||||
}
|
||||
} else if (inner.type === 'instance_variables') {
|
||||
walkNamedTree(inner, (ivarNode) => {
|
||||
if (ivarNode.type !== 'instance_variable') return;
|
||||
|
|
@ -853,12 +911,15 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj
|
|||
startLine,
|
||||
endLine,
|
||||
});
|
||||
addMemberType(container.qualifiedName, ivar.name, ivar.type);
|
||||
if (container.hostClass !== undefined) {
|
||||
addMemberType(objcClassQualifiedName(container.hostClass), ivar.name, ivar.type);
|
||||
}
|
||||
});
|
||||
} else if (inner.type === 'implementation_definition') {
|
||||
const functionNode = directNamedChildren(inner).find(
|
||||
(node) => node.type === 'function_definition',
|
||||
);
|
||||
if (functionNode !== undefined) {
|
||||
addFunctionFact(functionNode);
|
||||
continue;
|
||||
}
|
||||
const methodNode = directNamedChildren(inner).find((n) => n.type === 'method_definition');
|
||||
if (methodNode !== undefined) {
|
||||
const selector = methodSelector(methodNode);
|
||||
|
|
@ -941,20 +1002,7 @@ export function collectObjectiveCFacts(tree: Parser.Tree, filePath: string): Obj
|
|||
}
|
||||
|
||||
if (child.type === 'function_definition' || child.type === 'declaration') {
|
||||
const info = functionInfo(child);
|
||||
if (info === null) continue;
|
||||
const qualifiedName = objcFunctionQualifiedName(info.name);
|
||||
const { startLine, endLine } = range(child);
|
||||
functions.push({
|
||||
name: info.name,
|
||||
qualifiedName,
|
||||
nodeId: graphNodeId('Function', qualifiedName),
|
||||
filePath,
|
||||
startLine,
|
||||
endLine,
|
||||
...(info.returnType !== undefined ? { returnType: info.returnType } : {}),
|
||||
parameterTypes: info.parameterTypes,
|
||||
});
|
||||
addFunctionFact(child);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1359,14 +1407,15 @@ export function buildObjectiveCScopeCaptures(
|
|||
|
||||
for (const imp of facts.imports) {
|
||||
const anchor = captureAt('@import.statement', imp.raw, imp.startLine, imp.endLine);
|
||||
const sourceText = isSystemHeaderSpelling(imp.raw) ? imp.raw : imp.targetRaw;
|
||||
captures.push({
|
||||
'@import.statement': anchor,
|
||||
'@import.source': {
|
||||
...anchor,
|
||||
name: '@import.source',
|
||||
text: imp.targetRaw,
|
||||
text: sourceText,
|
||||
},
|
||||
'@import.name': { ...anchor, name: '@import.name', text: imp.targetRaw },
|
||||
'@import.name': { ...anchor, name: '@import.name', text: sourceText },
|
||||
'@import.kind': { ...anchor, name: '@import.kind', text: imp.kind },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -539,6 +539,7 @@ function resolveObjectiveCImportTarget(
|
|||
const importIndex = getObjectiveCImportIndex(allFilePaths);
|
||||
const target = targetRaw.trim();
|
||||
if (target.length === 0) return null;
|
||||
if (target.startsWith('<') && target.endsWith('>')) return null;
|
||||
const looksLikeFileImport =
|
||||
target.startsWith('.') || target.includes('/') || path.posix.extname(target).length > 0;
|
||||
if (!looksLikeFileImport) return null;
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ export interface RegistrationTableDeps {
|
|||
* registry (if any) receives the symbol write during `SymbolTable.add()`:
|
||||
*
|
||||
* - `dispatch` — owner-scoped registry write via the dispatch table
|
||||
* (Class/Struct/Interface/Enum/Record/Trait → types.registerClass,
|
||||
* (Class/Protocol/Category/Struct/Interface/Enum/Record/Trait → types.registerClass,
|
||||
* Method/Constructor → methods.register,
|
||||
* Property → fields.register,
|
||||
* Impl → types.registerImpl)
|
||||
|
|
@ -273,8 +273,8 @@ export const createRegistrationTable = (
|
|||
): Map<NodeLabel, RegistrationHook> => {
|
||||
const { types, methods, fields } = deps;
|
||||
|
||||
// Hook 1: class-like — Class, Struct, Interface, Enum, Record, Trait.
|
||||
// Shared reference — six table entries point at this one closure.
|
||||
// Hook 1: class-like — Class, Protocol, Category, Struct, Interface, Enum, Record, Trait.
|
||||
// Shared reference — eight table entries point at this one closure.
|
||||
const classLikeHook: RegistrationHook = (name, def) => {
|
||||
const qualifiedKey = def.qualifiedName ?? name;
|
||||
types.registerClass(name, qualifiedKey, def);
|
||||
|
|
@ -313,7 +313,7 @@ export const createRegistrationTable = (
|
|||
// classified as 'dispatch'. This is the compile-time twin of the
|
||||
// runtime taxonomy — no drift possible.
|
||||
const dispatchByLabel = {
|
||||
// class-like — six labels share the single `classLikeHook` closure,
|
||||
// class-like — eight labels share the single `classLikeHook` closure,
|
||||
// kept in lockstep with `CLASS_TYPES_TUPLE` via the
|
||||
// `Record<ClassLikeLabel, 'dispatch'>` cross-invariant on
|
||||
// `LABEL_BEHAVIOR`.
|
||||
|
|
|
|||
|
|
@ -61,7 +61,13 @@ import {
|
|||
createParserForLanguage,
|
||||
} from '../../tree-sitter/parser-loader.js';
|
||||
import { parseSourceSafe } from '../../tree-sitter/safe-parse.js';
|
||||
import { getProvider, getProviderForFile, providers } from '../languages/index.js';
|
||||
import {
|
||||
getLanguageForFileContent,
|
||||
getProvider,
|
||||
getProviderForFile,
|
||||
needsContentLanguageClassification,
|
||||
providers,
|
||||
} from '../languages/index.js';
|
||||
import { SCOPE_RESOLVERS } from '../scope-resolution/pipeline/registry.js';
|
||||
import { DATA_ROUTE_TABLE_SOURCE } from '../route-extractors/data-route-table.js';
|
||||
import type Parser from 'tree-sitter';
|
||||
|
|
@ -474,15 +480,28 @@ export async function runChunkedParseAndResolve(
|
|||
const model = createSemanticModel();
|
||||
const symbolTable = model.symbols;
|
||||
|
||||
const contentClassifiedPaths = scannedFiles
|
||||
.map((file) => file.path)
|
||||
.filter(needsContentLanguageClassification);
|
||||
const contentClassificationMap =
|
||||
contentClassifiedPaths.length > 0
|
||||
? await readFileContents(repoPath, contentClassifiedPaths)
|
||||
: new Map<string, string>();
|
||||
const languageForScannedFile = (file: (typeof scannedFiles)[number]) => {
|
||||
const content = contentClassificationMap.get(file.path);
|
||||
return content === undefined
|
||||
? getLanguageFromFilename(file.path)
|
||||
: getLanguageForFileContent(file.path, content);
|
||||
};
|
||||
const parseableScanned = scannedFiles.filter((f) => {
|
||||
const lang = getLanguageFromFilename(f.path);
|
||||
const lang = languageForScannedFile(f);
|
||||
return lang && isLanguageAvailable(lang);
|
||||
});
|
||||
|
||||
// Warn about files skipped due to unavailable parsers
|
||||
const skippedByLang = new Map<string, number>();
|
||||
for (const f of scannedFiles) {
|
||||
const lang = getLanguageFromFilename(f.path);
|
||||
const lang = languageForScannedFile(f);
|
||||
const provider = lang === null ? undefined : getProvider(lang);
|
||||
if (lang && provider?.parseStrategy !== 'standalone' && !isLanguageAvailable(lang)) {
|
||||
skippedByLang.set(lang, (skippedByLang.get(lang) || 0) + 1);
|
||||
|
|
|
|||
|
|
@ -4,22 +4,24 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { characterChunk } from '../../src/core/embeddings/character-chunk.js';
|
||||
|
||||
const { createParserForLanguage } = vi.hoisted(() => ({
|
||||
const { createParserForLanguage, resolveLanguageKey } = vi.hoisted(() => ({
|
||||
createParserForLanguage: vi.fn(),
|
||||
resolveLanguageKey: vi.fn((language: string, filePath?: string) =>
|
||||
language === 'typescript' && filePath?.endsWith('.tsx') ? 'typescript:tsx' : language,
|
||||
),
|
||||
}));
|
||||
|
||||
const { getLanguageFromFilename } = vi.hoisted(() => ({
|
||||
getLanguageFromFilename: vi.fn((filePath: string) =>
|
||||
filePath.endsWith('.rs') ? 'rust' : 'typescript',
|
||||
),
|
||||
getLanguageFromFilename: vi.fn((filePath: string) => {
|
||||
if (filePath.endsWith('.m') || filePath.endsWith('.mm')) return 'objective-c';
|
||||
return filePath.endsWith('.rs') ? 'rust' : 'typescript';
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/tree-sitter/parser-loader.js', () => ({
|
||||
createParserForLanguage,
|
||||
isLanguageAvailable: vi.fn().mockReturnValue(true),
|
||||
resolveLanguageKey: vi.fn((language: string, filePath?: string) =>
|
||||
language === 'typescript' && filePath?.endsWith('.tsx') ? 'typescript:tsx' : language,
|
||||
),
|
||||
resolveLanguageKey,
|
||||
}));
|
||||
|
||||
// Partial mock: `ast-utils` now resolves the LanguageProvider registry to apply
|
||||
|
|
@ -120,6 +122,28 @@ const makeDeclarationTree = (
|
|||
};
|
||||
};
|
||||
|
||||
const makeObjectiveCDeclarationTree = (
|
||||
nodeType: 'protocol_declaration' | 'class_interface',
|
||||
content: string,
|
||||
memberTexts: string[],
|
||||
) => {
|
||||
const headerName = nodeType === 'protocol_declaration' ? 'Worker' : 'Worker (Tracing)';
|
||||
const headerNode = makeFakeNode(
|
||||
'identifier',
|
||||
content.indexOf(headerName),
|
||||
content.indexOf(headerName) + headerName.length,
|
||||
);
|
||||
let searchFrom = 0;
|
||||
const memberNodes = memberTexts.map((text) => {
|
||||
const startIndex = content.indexOf(text, searchFrom);
|
||||
if (startIndex < 0) throw new Error(`Unable to locate member text: ${text}`);
|
||||
searchFrom = startIndex + text.length;
|
||||
return makeFakeNode('method_declaration', startIndex, startIndex + text.length);
|
||||
});
|
||||
const declNode = makeFakeNode(nodeType, 0, content.length, [headerNode, ...memberNodes]);
|
||||
return { rootNode: makeFakeNode('program', 0, content.length, [declNode]) };
|
||||
};
|
||||
|
||||
describe('characterChunk', () => {
|
||||
it('returns single chunk when content fits', () => {
|
||||
const result = characterChunk('short content', 1, 5, 1200, 120);
|
||||
|
|
@ -177,9 +201,14 @@ describe('characterChunk', () => {
|
|||
describe('chunkNode', () => {
|
||||
beforeEach(() => {
|
||||
createParserForLanguage.mockReset();
|
||||
getLanguageFromFilename.mockImplementation((filePath: string) =>
|
||||
filePath.endsWith('.rs') ? 'rust' : 'typescript',
|
||||
resolveLanguageKey.mockReset();
|
||||
resolveLanguageKey.mockImplementation(
|
||||
(language: string, filePath?: string) => `${language}:${filePath ?? ''}`,
|
||||
);
|
||||
getLanguageFromFilename.mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith('.m') || filePath.endsWith('.mm')) return 'objective-c';
|
||||
return filePath.endsWith('.rs') ? 'rust' : 'typescript';
|
||||
});
|
||||
});
|
||||
|
||||
it('returns single chunk for short content', async () => {
|
||||
|
|
@ -279,6 +308,136 @@ describe('chunkNode', () => {
|
|||
expect(result[0].startLine).toBe(40);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'Protocol',
|
||||
nodeType: 'protocol_declaration' as const,
|
||||
filePath: 'Worker.m',
|
||||
content: [
|
||||
'@protocol Worker',
|
||||
'- (void)startWithConfiguration:(id)configuration;',
|
||||
'- (void)stopWithCompletion:(id)completion;',
|
||||
'- (void)reloadWithOptions:(id)options;',
|
||||
'@end',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
label: 'Category',
|
||||
nodeType: 'class_interface' as const,
|
||||
filePath: 'Worker.mm',
|
||||
content: [
|
||||
'@interface Worker (Tracing)',
|
||||
'- (void)startWithConfiguration:(id)configuration;',
|
||||
'- (void)stopWithCompletion:(id)completion;',
|
||||
'- (void)reloadWithOptions:(id)options;',
|
||||
'@end',
|
||||
].join('\n'),
|
||||
},
|
||||
])(
|
||||
'chunks Objective-C $label declarations at member boundaries',
|
||||
async ({ label, nodeType, filePath, content }) => {
|
||||
const members = [
|
||||
'- (void)startWithConfiguration:(id)configuration;',
|
||||
'- (void)stopWithCompletion:(id)completion;',
|
||||
'- (void)reloadWithOptions:(id)options;',
|
||||
];
|
||||
createParserForLanguage.mockResolvedValue({
|
||||
parse: vi.fn().mockReturnValue(makeObjectiveCDeclarationTree(nodeType, content, members)),
|
||||
});
|
||||
|
||||
const result = await chunkNode(label, content, filePath, 1, 5, 90, 0);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].text).toContain(members[0]);
|
||||
expect(result.slice(1).every((chunk) => chunk.text.startsWith('- (void)'))).toBe(true);
|
||||
expect(createParserForLanguage).toHaveBeenCalledWith('objective-c', filePath);
|
||||
},
|
||||
);
|
||||
|
||||
it('expands Objective-C protocol optional and required sections', async () => {
|
||||
const content = [
|
||||
'@protocol P',
|
||||
'@optional',
|
||||
'- (void)first;',
|
||||
'- (void)second;',
|
||||
'@required',
|
||||
'- (void)third;',
|
||||
'- (void)fourth;',
|
||||
'@end',
|
||||
].join('\n');
|
||||
const members = ['- (void)first;', '- (void)second;', '- (void)third;', '- (void)fourth;'];
|
||||
let searchFrom = 0;
|
||||
const methodNodes = members.map((text) => {
|
||||
const startIndex = content.indexOf(text, searchFrom);
|
||||
searchFrom = startIndex + text.length;
|
||||
return makeFakeNode('method_declaration', startIndex, startIndex + text.length);
|
||||
});
|
||||
const optionalStart = content.indexOf('@optional');
|
||||
const requiredStart = content.indexOf('@required');
|
||||
const optional = makeFakeNode(
|
||||
'qualified_protocol_interface_declaration',
|
||||
optionalStart,
|
||||
methodNodes[1].endIndex,
|
||||
methodNodes.slice(0, 2),
|
||||
);
|
||||
const required = makeFakeNode(
|
||||
'qualified_protocol_interface_declaration',
|
||||
requiredStart,
|
||||
methodNodes[3].endIndex,
|
||||
methodNodes.slice(2),
|
||||
);
|
||||
const header = makeFakeNode('identifier', content.indexOf('P'), content.indexOf('P') + 1);
|
||||
const declaration = makeFakeNode('protocol_declaration', 0, content.length, [
|
||||
header,
|
||||
optional,
|
||||
required,
|
||||
]);
|
||||
createParserForLanguage.mockResolvedValue({
|
||||
parse: vi.fn().mockReturnValue({
|
||||
rootNode: makeFakeNode('program', 0, content.length, [declaration]),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await chunkNode('Protocol', content, 'ProtocolSections.m', 1, 8, 36, 0);
|
||||
const combined = result.map((chunk) => chunk.text).join('\n');
|
||||
const requiredChunk = result.find((chunk) => chunk.text.includes(members[2]));
|
||||
|
||||
expect(result.length).toBeGreaterThan(1);
|
||||
for (const member of members) expect(combined).toContain(member);
|
||||
expect(
|
||||
result.some((chunk) => chunk.text.includes(members[0]) && chunk.text.includes(members[1])),
|
||||
).toBe(false);
|
||||
expect(requiredChunk?.text).toContain('@required');
|
||||
expect(requiredChunk?.text).not.toContain(members[1]);
|
||||
expect(createParserForLanguage).toHaveBeenCalledWith('objective-c', 'ProtocolSections.m');
|
||||
});
|
||||
|
||||
it('keeps Objective-C protocol inheritance in the declaration prefix', async () => {
|
||||
const content = ['@protocol Worker <Runnable, Observable>', '- (void)run;', '@end'].join('\n');
|
||||
const protocolNameStart = content.indexOf('Worker');
|
||||
const inheritanceStart = content.indexOf('<Runnable, Observable>');
|
||||
const methodStart = content.indexOf('- (void)run;');
|
||||
const declaration = makeFakeNode('protocol_declaration', 0, content.length, [
|
||||
makeFakeNode('identifier', protocolNameStart, protocolNameStart + 'Worker'.length),
|
||||
makeFakeNode(
|
||||
'protocol_reference_list',
|
||||
inheritanceStart,
|
||||
inheritanceStart + '<Runnable, Observable>'.length,
|
||||
),
|
||||
makeFakeNode('method_declaration', methodStart, methodStart + '- (void)run;'.length),
|
||||
]);
|
||||
createParserForLanguage.mockResolvedValue({
|
||||
parse: vi.fn().mockReturnValue({
|
||||
rootNode: makeFakeNode('program', 0, content.length, [declaration]),
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await chunkNode('Protocol', content, 'Worker.m', 1, 3, 50, 0);
|
||||
|
||||
expect(result[0].text).toContain('- (void)');
|
||||
expect(result[0].text).not.toBe('@protocol Worker <Runnable, Observable>');
|
||||
});
|
||||
|
||||
it('splits a function into multiple AST-aware chunks using snippet offsets', async () => {
|
||||
const content = [
|
||||
'function example() {',
|
||||
|
|
|
|||
|
|
@ -7,12 +7,25 @@ import {
|
|||
} from '../../src/core/embeddings/embedding-pipeline.js';
|
||||
import { generateEmbeddingText } from '../../src/core/embeddings/text-generator.js';
|
||||
import type { EmbeddableNode, EmbeddingProgress } from '../../src/core/embeddings/types.js';
|
||||
import { DEFAULT_EMBEDDING_CONFIG, EMBEDDABLE_LABELS } from '../../src/core/embeddings/types.js';
|
||||
import {
|
||||
DEFAULT_EMBEDDING_CONFIG,
|
||||
EMBEDDABLE_LABELS,
|
||||
LABEL_CATEGORY,
|
||||
LABEL_PROTOCOL,
|
||||
LABELS_WITH_EXPORTED,
|
||||
} from '../../src/core/embeddings/types.js';
|
||||
import { STALE_HASH_SENTINEL } from '../../src/core/lbug/schema.js';
|
||||
|
||||
const CLASS_CHUNK_SIZE = 90;
|
||||
const CLASS_OVERLAP = 10;
|
||||
|
||||
describe('embedding schema column contracts', () => {
|
||||
it('does not query Objective-C protocol/category tables for an isExported column', () => {
|
||||
expect(LABELS_WITH_EXPORTED.has(LABEL_PROTOCOL)).toBe(false);
|
||||
expect(LABELS_WITH_EXPORTED.has(LABEL_CATEGORY)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// resolveEmbeddingInstallPolicy (offline-first, #1153)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ vi.mock('../../src/core/tree-sitter/parser-loader.js', async (importOriginal) =>
|
|||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { createKnowledgeGraph } from '../../src/core/graph/graph.js';
|
||||
import { runChunkedParseAndResolve } from '../../src/core/ingestion/pipeline-phases/parse-impl.js';
|
||||
import * as parserLoader from '../../src/core/tree-sitter/parser-loader.js';
|
||||
|
|
@ -63,6 +64,21 @@ describe('native parser availability — unavailable language is skipped, not cr
|
|||
);
|
||||
};
|
||||
|
||||
const runWithObjectiveCHeader = () => {
|
||||
const rel = 'App.h';
|
||||
fs.writeFileSync(path.join(repoDir, rel), '@interface App : NSObject\n@end\n');
|
||||
const scanned = [{ path: rel, size: fs.statSync(path.join(repoDir, rel)).size }];
|
||||
return runChunkedParseAndResolve(
|
||||
createKnowledgeGraph(),
|
||||
scanned,
|
||||
[rel],
|
||||
1,
|
||||
repoDir,
|
||||
Date.now(),
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
it('skips the Swift file without crashing (and without spawning a pool) when its parser is unavailable', async () => {
|
||||
vi.mocked(parserLoader.isLanguageAvailable).mockReturnValue(false);
|
||||
// The only file is filtered out before dispatch, so the parse phase
|
||||
|
|
@ -86,4 +102,14 @@ describe('native parser availability — unavailable language is skipped, not cr
|
|||
);
|
||||
expect(warned).toBe(true);
|
||||
});
|
||||
|
||||
it('admits a content-classified Objective-C header when only C++ is unavailable', async () => {
|
||||
vi.mocked(parserLoader.isLanguageAvailable).mockImplementation(
|
||||
(language) => language === SupportedLanguages.ObjectiveC,
|
||||
);
|
||||
|
||||
const result = await runWithObjectiveCHeader();
|
||||
|
||||
expect(result.usedWorkerPool).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ import {
|
|||
SupportedLanguages,
|
||||
} from 'gitnexus-shared';
|
||||
import { getLanguageForFileContent } from '../../src/core/ingestion/languages/index.js';
|
||||
import { classifyObjectiveCFileContent } from '../../src/core/ingestion/languages/objective-c.js';
|
||||
import {
|
||||
classifyObjectiveCFileContent,
|
||||
objectiveCProvider,
|
||||
} from '../../src/core/ingestion/languages/objective-c.js';
|
||||
import {
|
||||
buildObjectiveCScopeCaptures,
|
||||
buildObjectiveCSemanticGraph,
|
||||
collectObjectiveCFacts,
|
||||
objcCategoryQualifiedName,
|
||||
|
|
@ -126,6 +130,23 @@ int (*callback)(int value);
|
|||
expect(facts.functions.map((fn) => fn.name)).not.toContain('callback');
|
||||
});
|
||||
|
||||
it('extracts C helper functions declared inside an Objective-C implementation', () => {
|
||||
const facts = collectObjectiveCFacts(
|
||||
parseSource(`
|
||||
@implementation Worker
|
||||
static int helper(void) { return 1; }
|
||||
@end
|
||||
`),
|
||||
'Worker.m',
|
||||
);
|
||||
|
||||
expect(facts.functions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ name: 'helper', returnType: 'int', parameterTypes: ['void'] }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat protocol-qualified parameter types as conformance', () => {
|
||||
const facts = collectObjectiveCFacts(
|
||||
parseSource(`
|
||||
|
|
@ -184,6 +205,36 @@ int (*callback)(int value);
|
|||
);
|
||||
});
|
||||
|
||||
it('resolves a property declared after its caller in a class extension', () => {
|
||||
const facts = collectObjectiveCFacts(
|
||||
parseSource(`
|
||||
@interface LaterOwner
|
||||
@end
|
||||
@implementation LaterOwner
|
||||
- (void)run {
|
||||
[self.helper performWork];
|
||||
}
|
||||
@end
|
||||
@interface LaterOwner (Private)
|
||||
@property (nonatomic, strong) Worker *helper;
|
||||
@end
|
||||
`),
|
||||
'LaterOwner.m',
|
||||
);
|
||||
|
||||
expect(facts.messages).toContainEqual(
|
||||
expect.objectContaining({
|
||||
receiverText: 'self.helper',
|
||||
selector: 'performWork',
|
||||
receiverKind: 'property',
|
||||
receiverType: { kind: 'class', name: 'Worker', raw: 'Worker' },
|
||||
}),
|
||||
);
|
||||
expect(facts.unresolvedMessages).not.toContainEqual(
|
||||
expect.objectContaining({ receiverText: 'self.helper' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves extensionless local imports to Objective-C source/header files', () => {
|
||||
expect(
|
||||
objectiveCScopeResolver.resolveImportTarget(
|
||||
|
|
@ -208,6 +259,27 @@ int (*callback)(int value);
|
|||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps angle-bracket system headers out of local import resolution', () => {
|
||||
const tree = parseSource('#import "Local.h"\n#import <Foundation/Foundation.h>\n');
|
||||
const facts = collectObjectiveCFacts(tree, 'src/Caller.m');
|
||||
const captures = buildObjectiveCScopeCaptures(facts, tree.rootNode).filter(
|
||||
(capture) => capture['@import.source'] !== undefined,
|
||||
);
|
||||
const parsed = captures.map((capture) => objectiveCProvider.interpretImport?.(capture));
|
||||
|
||||
expect(parsed.map((entry) => entry?.targetRaw)).toEqual([
|
||||
'./Local.h',
|
||||
'<Foundation/Foundation.h>',
|
||||
]);
|
||||
expect(
|
||||
objectiveCScopeResolver.resolveImportTarget(
|
||||
parsed[1]?.targetRaw ?? '',
|
||||
'src/Caller.m',
|
||||
new Set(['src/Foundation/Foundation.h']),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts first-version Objective-C semantic facts and unresolved evidence', () => {
|
||||
const facts = collectObjectiveCFacts(parseFixture(), 'SYModuleCaller.m');
|
||||
|
||||
|
|
|
|||
4
gitnexus/vendor/tree-sitter-objc/grammar.js
vendored
4
gitnexus/vendor/tree-sitter-objc/grammar.js
vendored
|
|
@ -32,6 +32,7 @@ module.exports = grammar(C, {
|
|||
[$.extension_expression, $.range_expression],
|
||||
[$.abstract_array_declarator, $.array_type_specifier],
|
||||
[$._type_definition_type],
|
||||
[$.keyword_declarator, $.method_parameter],
|
||||
]),
|
||||
|
||||
inline: ($, original) => original.concat([
|
||||
|
|
@ -616,6 +617,7 @@ module.exports = grammar(C, {
|
|||
'@compatibility_alias',
|
||||
field('class', $.identifier),
|
||||
field('alias', $.identifier),
|
||||
';',
|
||||
),
|
||||
|
||||
interface_declaration: $ => choice(
|
||||
|
|
@ -708,7 +710,7 @@ module.exports = grammar(C, {
|
|||
|
||||
keyword_declarator: $ => seq(
|
||||
optional($.identifier),
|
||||
';',
|
||||
':',
|
||||
optional($.method_type),
|
||||
$.identifier,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -13611,6 +13611,10 @@
|
|||
"type": "SYMBOL",
|
||||
"name": "identifier"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "STRING",
|
||||
"value": ";"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -14337,7 +14341,7 @@
|
|||
},
|
||||
{
|
||||
"type": "STRING",
|
||||
"value": ";"
|
||||
"value": ":"
|
||||
},
|
||||
{
|
||||
"type": "CHOICE",
|
||||
|
|
@ -16331,6 +16335,10 @@
|
|||
],
|
||||
[
|
||||
"_type_definition_type"
|
||||
],
|
||||
[
|
||||
"keyword_declarator",
|
||||
"method_parameter"
|
||||
]
|
||||
],
|
||||
"precedences": [],
|
||||
|
|
|
|||
1037705
gitnexus/vendor/tree-sitter-objc/src/parser.c
vendored
1037705
gitnexus/vendor/tree-sitter-objc/src/parser.c
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue