diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 8a1521ca7..2a530e77a 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -509,8 +509,18 @@ export const TYPESCRIPT_SCOPE_QUERY = ` ;; the field's consumers were unreachable. TypeScript sets ;; fieldFallbackOnMethodLookup:false, so there is no name-based safety net ;; here — the precise path is the only one, and it needs the declaration. -(property_signature - name: (property_identifier) @declaration.name) @declaration.property +;; ANCHORED to declared shapes — see the matching rule in TYPESCRIPT_QUERIES +;; for why. Unanchored this matched inline parameter and return types and +;; nested object types, whose members then collided onto the enclosing +;; class/interface/alias. +(interface_body + (property_signature + name: (property_identifier) @declaration.name) @declaration.property) + +(type_alias_declaration + value: (object_type + (property_signature + name: (property_identifier) @declaration.name) @declaration.property)) ;; Declarations — class fields (public_field_definition diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index d3f8ed297..6929415e2 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -36,8 +36,30 @@ export const TYPESCRIPT_QUERIES = ` ; spell them as property_signature, so one pattern covers both. A TS frontend ; models its API contracts this way, and without these there is no graph path ; from a contract field to the code that reads it. -(property_signature - name: (property_identifier) @name) @definition.property +; ANCHORED to declared shapes. Unanchored, property_signature matches every +; object_type in the grammar — an inline parameter type, an inline return +; type, a nested object type — and the enclosing-container walk then hangs the +; node off the nearest class/interface/alias. class Svc { retries = 1; +; run(opts: { retries: number }) {} } minted Property:a.ts:Svc.retries twice, +; and graph.addNode is first-write-wins, so two distinct symbols merged into +; one and every context()/impact()/rename() answer about that field described +; the merge. It also emitted the outright false Svc HAS_PROPERTY retries for a +; field belonging to an anonymous parameter type. +; +; The sibling JS object-literal rule in this same PR is anchored for exactly +; this reason; this is the TypeScript half of the same fix. +; +; (A (B)) matches DIRECT children, so a nested object type +; (type Config = { host: string; db: { host: string } }) is excluded here as +; well — its members are not direct children of the alias's own object_type. +(interface_body + (property_signature + name: (property_identifier) @name) @definition.property) + +(type_alias_declaration + value: (object_type + (property_signature + name: (property_identifier) @name) @definition.property)) (function_declaration name: (identifier) @name) @definition.function diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts index e9382cb7f..a7689757d 100644 --- a/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts @@ -18,3 +18,32 @@ export function renderIface(cfg: LiveModeIface): number { return cfg.ifaceSlots; } + +// RV-4: the shapes that made an unanchored `property_signature` rule collide. +// Every inline object type below declares a UNIQUELY-named member, because a +// collision and a correct exclusion both leave exactly one node behind — +// counting ids cannot tell them apart, so the discriminator has to be a name +// that only the unanchored rule could ever produce. +export class Svc { + retries = 1; + + run(opts: { retries: number; inlineParamOnlyKey: number }): number { + return opts.retries + opts.inlineParamOnlyKey + this.retries; + } +} + +export interface Repo { + retries: number; + find(q: { retries: number; inlineQueryOnlyKey: number }): void; +} + +// Nested object type: its members are not members of the alias. +export type NestedConfig = { + host: string; + db: { nestedOnlyKey: string }; +}; + +// Inline RETURN type — the third position the unanchored rule reached. +export function buildInline(): { inlineReturnOnlyKey: number } { + return { inlineReturnOnlyKey: 1 }; +} diff --git a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts index 8b9f22100..74e839917 100644 --- a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts @@ -87,6 +87,59 @@ describe('TypeScript type-alias and interface members (A4)', () => { // answer — had nothing to walk. Measured on the reporting repo, all 324 // TypeAlias nodes and every Interface node had DEFINES as their ONLY // incoming edge, because TypeScript captured no type references at all. + // RV-4. `property_signature` occurs in EVERY object_type, not only in a + // declared shape, so inline parameter types, inline return types and nested + // object types matched too and the enclosing-container walk hung them off the + // nearest class/interface/alias. `addNode` is first-write-wins, so when the + // inline member shared a name with a real one the two symbols merged onto a + // single node and every answer about that field described the merge. + // + // Each inline member below is UNIQUELY named on purpose. A merge and a + // correct exclusion both leave exactly one node behind, so counting ids + // cannot tell them apart — the only discriminator is a name that the + // unanchored rule alone could produce. Measured against it, all four appeared: + // `Svc.inlineParamOnlyKey`, `Repo.inlineQueryOnlyKey`, + // `NestedConfig.nestedOnlyKey` and `buildInline.inlineReturnOnlyKey`. + describe('shape anchoring (RV-4)', () => { + const propertyIds = (): string[] => + Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === 'Property') + .map((n) => String(n.id)); + + it('does not attribute an inline PARAMETER type member to the class', () => { + expect(propertyIds().some((id) => id.includes('inlineParamOnlyKey'))).toBe(false); + }); + + it('does not attribute an inline parameter type member to the interface', () => { + expect(propertyIds().some((id) => id.includes('inlineQueryOnlyKey'))).toBe(false); + }); + + it('does not attribute a NESTED object type member to the alias', () => { + expect(propertyIds().some((id) => id.includes('nestedOnlyKey'))).toBe(false); + }); + + it('does not mint a member for an inline RETURN type', () => { + expect(propertyIds().some((id) => id.includes('inlineReturnOnlyKey'))).toBe(false); + }); + + // The other half: anchoring must not cost real members. + it('still indexes every member of a declared shape', () => { + const ids = propertyIds(); + for (const expected of [ + 'LiveModeConfig.bookSlots', + 'LiveModeConfig.bookNotionalUsdt', + 'LiveModeIface.ifaceSlots', + 'NestedConfig.host', + 'Svc.retries', + 'Repo.retries', + ]) { + expect(ids.some((id) => id.endsWith(expected))).toBe(true); + } + }); + }); + describe('type consumers (R2-2)', () => { const usersOf = (typeName: string): string[] => getRelationships(result, 'USES') @@ -102,3 +155,9 @@ describe('TypeScript type-alias and interface members (A4)', () => { }); }); }); + +interface PropNode { + readonly id: string; + readonly label: string; + readonly properties: Record; +}