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:
co-authored by
Claude Opus 4.8
parent
1983590533
commit
636d9fcb7d
@@ -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 `<MyComponent/>` 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)
|
- 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 `<MyComponent/>` 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` / `<MyComponent>` / `@code` reference to `CatalogBrand` resolves to the `@using`'d DTO (`BlazorShared.Models.CatalogBrand`) rather than a same-named domain entity. (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` / `<MyComponent>` / `@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)
|
- `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
|
### Fixes
|
||||||
|
|
||||||
|
|||||||
@@ -451,6 +451,59 @@ type Internal = string;
|
|||||||
expect(exported).toHaveLength(2);
|
expect(exported).toHaveLength(2);
|
||||||
expect(exported.map((n) => n.name).sort()).toEqual(['DateFormat', 'UnitSystem']);
|
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', () => {
|
describe('Exported Variable Extraction', () => {
|
||||||
|
|||||||
@@ -21,4 +21,4 @@
|
|||||||
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
* turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty
|
||||||
* in the product is load-bearing").
|
* in the product is load-bearing").
|
||||||
*/
|
*/
|
||||||
export const EXTRACTION_VERSION = 1;
|
export const EXTRACTION_VERSION = 2;
|
||||||
|
|||||||
@@ -1677,6 +1677,9 @@ export class TreeSitterExtractor {
|
|||||||
// an unrelated class method picked by path-proximity (#359).
|
// an unrelated class method picked by path-proximity (#359).
|
||||||
if (this.language === 'typescript' || this.language === 'tsx') {
|
if (this.language === 'typescript' || this.language === 'tsx') {
|
||||||
this.extractTsTypeAliasMembers(value, typeAliasNode);
|
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();
|
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<MyServiceList>()`) 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<U, 'id'>>` 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
|
* `foo: () => T` → property_signature whose type_annotation contains a
|
||||||
* `function_type`. Treat that as a method-shaped contract member, since
|
* `function_type`. Treat that as a method-shaped contract member, since
|
||||||
|
|||||||
Reference in New Issue
Block a user