diff --git a/CHANGELOG.md b/CHANGELOG.md index a94dadb..54bfc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ASP.NET Razor (`.cshtml`) and Blazor (`.razor`) markup are now parsed for code relationships. A `@model` / `@inherits` / `@inject` directive links the view to the C# view-model, base type, or service it names; a Blazor `` tag (plus `@typeof(...)` and generic `TItem="..."` arguments) links to the component class; and the C# inside `@code { }` / `@functions { }` / `@{ }` blocks is analyzed too, so services and types used in component logic are linked. A view-model, component, or service referenced only from markup is no longer reported as having no dependents, and editing it surfaces the views that use it. (ASP.NET, Blazor) - A Razor/Blazor type reference now resolves through the component's `@using` namespaces — including the folder's cascading `_Imports.razor` — so a simple name that exists in several namespaces lands on the right one. A `@model` / `` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (ASP.NET, Blazor) - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) +- TypeScript service/RPC contracts defined as a tuple of generic types — `type MyServiceList = [Service<'query_apply_record', …>, Service<'apply_confirm', …>]` — now index each entry's string-literal name as a searchable symbol. Previously these names existed only as type arguments, so `codegraph query query_apply_record` found nothing even though the names are the app's primary API surface. The pattern is common in typed RPC / BFF clients and mock servers where the types are the source of truth for a runtime proxy object. Utility types (`Pick`, `Omit`, `Record`) and route paths are deliberately left out to avoid noise. Thanks @jiezhiyong. (#634) (TypeScript) ### Fixes diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index 6d9af60..89918c5 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -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: 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: Name; } +export type Picked = Pick; +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, {}>]; +// 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', () => { diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 5ca8f2d..e216292 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 1; +export const EXTRACTION_VERSION = 2; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index f237efc..6febee6 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1677,6 +1677,9 @@ export class TreeSitterExtractor { // an unrelated class method picked by path-proximity (#359). if (this.language === 'typescript' || this.language === 'tsx') { this.extractTsTypeAliasMembers(value, typeAliasNode); + // `type List = [ Service<'name', Req, Resp>, … ]` — surface each + // entry's string-literal name as a searchable member (issue #634). + this.extractTsTupleContractNames(value, typeAliasNode); } } } @@ -1763,6 +1766,75 @@ export class TreeSitterExtractor { this.nodeStack.pop(); } + /** + * Surface the string-literal "names" of a TypeScript service/contract + * registry written as a tuple of generic instantiations: + * + * type MyServiceList = [ + * Service<'query_apply_record', Req, Resp>, + * Service<'apply_confirm', Req, Resp>, + * ]; + * + * Each `Service<'name', …>` tags an entry with a string-literal name that a + * dynamic factory (`createService()`) turns into a callable + * property (`api.query_apply_record(…)`). Static extraction otherwise never + * sees that name — it's a type argument, not a declaration — so + * `codegraph query query_apply_record` returned nothing (issue #634). We emit + * each name as a `method` node under the type alias (qualifiedName + * `MyServiceList::query_apply_record`) so it's searchable and resolvable as a + * symbol. (A call through the proxy, `api.query_apply_record(…)`, still + * resolves to the imported `api` binding — the receiver's type isn't known — + * so this fixes discoverability, not the per-method call edge.) + * + * Scope is deliberately narrow to avoid noise: only a string literal that is + * a DIRECT type argument of a `generic_type` that is itself a DIRECT element + * of a `tuple_type`. This excludes utility types (`Pick`/`Omit`/`Record` are + * never written as tuples) and string args nested deeper + * (`Service<'a', Pick>` yields only `a`, never `id`). Names must be + * valid identifiers, which also rules out route paths / arbitrary strings. + */ + private extractTsTupleContractNames(value: SyntaxNode, typeAliasNode: Node): void { + const tuples: SyntaxNode[] = []; + const collectTuples = (n: SyntaxNode, depth: number): void => { + if (depth > 6) return; // a type expression is shallow; cap defensively + if (n.type === 'tuple_type') tuples.push(n); + for (let i = 0; i < n.namedChildCount; i++) { + const c = n.namedChild(i); + if (c) collectTuples(c, depth + 1); + } + }; + collectTuples(value, 0); + if (tuples.length === 0) return; + + this.nodeStack.push(typeAliasNode.id); + for (const tuple of tuples) { + for (let i = 0; i < tuple.namedChildCount; i++) { + const entry = tuple.namedChild(i); + if (!entry || entry.type !== 'generic_type') continue; + const typeArgs = getChildByField(entry, 'type_arguments'); + if (!typeArgs) continue; + for (let j = 0; j < typeArgs.namedChildCount; j++) { + const arg = typeArgs.namedChild(j); + if (!arg || arg.type !== 'literal_type') continue; + // literal_type wraps the actual literal; only a string is a name. + const strNode = arg.namedChild(0); + if (!strNode || strNode.type !== 'string') continue; + const name = getNodeText(strNode, this.source) + .trim() + .replace(/^['"`]/, '') + .replace(/['"`]$/, ''); + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) continue; + const signature = getNodeText(entry, this.source).replace(/\s+/g, ' ').trim().slice(0, 120); + this.createNode('method', name, entry, { + signature, + qualifiedName: `${typeAliasNode.name}::${name}`, + }); + } + } + } + this.nodeStack.pop(); + } + /** * `foo: () => T` → property_signature whose type_annotation contains a * `function_type`. Treat that as a method-shaped contract member, since