feat(extraction): index string-literal names in generic tuple type aliases (#634) (#740)

TypeScript service/RPC contracts written as a tuple of generic types —
`type List = [Service<'query_apply_record', Req, Resp>, …]` — carry their
names only as string-literal type arguments, so static extraction never
indexed them and `codegraph query query_apply_record` returned nothing.

Add a narrow TS/TSX type-alias pass that emits each tuple entry's
string-literal name as a `method` node under the alias (qualifiedName
`List::query_apply_record`), making it searchable. Scope is limited to a
direct literal arg of a generic that is a direct tuple element, with a
valid-identifier filter — so utility types (Pick/Omit/Record), deeper
nested generics, and route paths produce no noise.

Bumps EXTRACTION_VERSION so existing indexes get a re-index hint.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-08 19:05:36 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 1983590533
commit 636d9fcb7d
4 changed files with 127 additions and 1 deletions
+53
View File
@@ -451,6 +451,59 @@ type Internal = string;
expect(exported).toHaveLength(2);
expect(exported.map((n) => n.name).sort()).toEqual(['DateFormat', 'UnitSystem']);
});
// A service/contract registry written as a tuple of generic instantiations —
// the names are string-literal type arguments, not declarations, so static
// extraction otherwise never indexes them (issue #634).
it('extracts string-literal contract names from a generic tuple type alias (#634)', () => {
const code = `
interface Service<Name extends string, Req, Resp> { name: Name; }
export type MyServiceList = [
Service<'query_apply_record', { pageNo: number }, { ok: boolean }>,
Service<'apply_confirm', { code: string }, { ok: boolean }>
];
`;
const result = extractFromSource('services/api.ts', code);
const names = result.nodes.filter(
(n) => n.kind === 'method' && n.qualifiedName.startsWith('MyServiceList::')
);
expect(names.map((n) => n.name).sort()).toEqual(['apply_confirm', 'query_apply_record']);
const queryNode = names.find((n) => n.name === 'query_apply_record');
expect(queryNode?.qualifiedName).toBe('MyServiceList::query_apply_record');
// Signature carries the full contract entry so search results show context.
expect(queryNode?.signature).toContain("Service<'query_apply_record'");
// The string-literal name is contained by the type alias.
const alias = result.nodes.find((n) => n.kind === 'type_alias' && n.name === 'MyServiceList');
const containsEdge = result.edges.find(
(e) => e.kind === 'contains' && e.source === alias?.id && e.target === queryNode?.id
);
expect(containsEdge).toBeDefined();
});
it('does not extract string literals from utility types or nested generics (#634)', () => {
const code = `
interface User { id: string; name: string; }
interface Service<Name extends string, Req, Resp> { name: Name; }
export type Picked = Pick<User, 'id' | 'name'>;
export type Rec = Record<'foo' | 'bar', number>;
// Tuple entry, but the name is a non-identifier route path; the nested Pick's
// 'id' must also stay out (only DIRECT literal args of a tuple's generic count).
export type Routes = [Service<'/api/users', Pick<User, 'id'>, {}>];
// Bare string-literal tuple — not generic type arguments.
export type Names = ['alpha', 'beta'];
`;
const result = extractFromSource('noise.ts', code);
const leaked = result.nodes.filter(
(n) =>
(n.kind === 'method' || n.kind === 'property') &&
['id', 'name', 'foo', 'bar', 'alpha', 'beta'].includes(n.name)
);
expect(leaked).toEqual([]);
});
});
describe('Exported Variable Extraction', () => {