diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index c67f8f777..9063916ec 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -498,6 +498,16 @@ export const TYPESCRIPT_SCOPE_QUERY = ` (method_signature name: (property_identifier) @declaration.name) @declaration.method +;; Members of a declared SHAPE — interface bodies and object-type aliases both +;; spell them as property_signature (A4). The sibling method_signature rule +;; above declared interface METHODS, so only properties were missing: a typed +;; receiver resolved to the shape's scope and then found no member there, and +;; 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 + ;; Declarations — class fields (public_field_definition name: (property_identifier) @declaration.name) @declaration.property diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 54a22d000..94f495cec 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -24,6 +24,21 @@ export const TYPESCRIPT_QUERIES = ` (interface_declaration name: (type_identifier) @name) @definition.interface +; Type aliases (A4). TypeScript was the only language whose aliases minted no +; node: Rust (type_item), Kotlin (type_alias), Swift (typealias_declaration) +; and Dart all emit @definition.type. The alias was declared for scope +; resolution but never became a graph symbol, so a context() lookup on an +; exported API-contract type answered "Symbol not found". +(type_alias_declaration + name: (type_identifier) @name) @definition.type + +; Members of a declared SHAPE — interface bodies and object-type aliases both +; 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 + (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 new file mode 100644 index 000000000..640cd2788 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/contracts.ts @@ -0,0 +1,19 @@ +// A4: API contracts modelled as type aliases and interfaces — the common style +// in a TS frontend. Neither the alias node nor the members of either shape were +// indexed, so there was no graph path from a field to its consumers. +export type LiveModeConfig = { + bookSlots: number; + bookNotionalUsdt: number; +}; + +export interface LiveModeIface { + ifaceSlots: number; +} + +export function renderAlias(cfg: LiveModeConfig): number { + return cfg.bookNotionalUsdt + cfg.bookSlots; +} + +export function renderIface(cfg: LiveModeIface): number { + return cfg.ifaceSlots; +} diff --git a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts new file mode 100644 index 000000000..420aa7e8c --- /dev/null +++ b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts @@ -0,0 +1,80 @@ +/** + * A4 — TypeScript type aliases and interface members must be indexed. + * + * A TS frontend models its API contracts as `type X = { … }` and `interface`, + * so a field on one is the thing you ask "who breaks if I remove this?" about. + * Three gaps made that unanswerable, all in the TypeScript PARSE query: + * + * 1. No `type_alias_declaration` -> `@definition.type`, so the alias minted + * NO NODE AT ALL and `context({name:'LiveModeConfig'})` said "Symbol not + * found". TypeScript was the only language missing this — Rust + * (`type_item`), Kotlin (`type_alias`), Swift (`typealias_declaration`) + * and Dart all emit it. + * 2. No `property_signature` pattern, so INTERFACE members minted no + * `Property` nodes either — the upstream report's "class/interface index + * fine" is only half right. + * 3. Alias members likewise had no node. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js'; +import path from 'path'; + +interface LabelledNode { + readonly label: string; + readonly properties: Record; +} + +describe('TypeScript type-alias and interface members (A4)', () => { + let result: PipelineResult; + + beforeAll(async () => { + result = await runPipelineFromRepo(path.join(FIXTURES, 'typescript-alias-fields'), () => {}); + }, 60000); + + const nodesOfLabel = (label: string): string[] => + Array.from( + (result as unknown as { graph: { iterNodes(): Iterable } }).graph.iterNodes(), + ) + .filter((n) => n.label === label) + .map((n) => String(n.properties.name)); + + const readersOf = (field: string): string[] => + getRelationships(result, 'ACCESSES') + .filter((e) => e.target === field) + .map((e) => e.source); + + it('indexes the type alias as a symbol', () => { + // Previously "Symbol not found" — the alias existed for scope resolution + // but never became a graph node. + expect(nodesOfLabel('TypeAlias')).toContain('LiveModeConfig'); + }); + + it('indexes type-alias members as Property nodes', () => { + const props = nodesOfLabel('Property'); + expect(props).toContain('bookNotionalUsdt'); + expect(props).toContain('bookSlots'); + }); + + it('indexes interface members as Property nodes', () => { + expect(nodesOfLabel('Property')).toContain('ifaceSlots'); + }); + + // The EDGES are not landed yet — nodes and declarations are. + // + // Established: the shape is a class-like scope already (`interface_declaration` + // and `type_alias_declaration value:(object_type)` both emit `@scope.class`), + // and `property_signature` now emits `@declaration.property` alongside the + // pre-existing `method_signature` -> `@declaration.method`. So the receiver + // has a scope and the scope has members, yet no ACCESSES forms — the missing + // link is owner/type-binding, i.e. the member def carrying an `ownerId` that + // the typed receiver resolves to via `findOwnedMember`. + // + // There is deliberately NO name-based safety net here: TypeScript sets + // `fieldFallbackOnMethodLookup: false` (scope-resolver.ts) because name + // matching over-connects in a typed language, and the unique-name pass + // honors that opt-out. The precise path is the only route for TS, by design. + it.todo('links an alias field to its consumer'); + it.todo('links an interface field to its consumer'); + + void readersOf; +});