fix(typescript): anchor property_signature to declared shapes

Review blocker, and it reproduces end to end. `property_signature` occurs in
EVERY object_type in the TS grammar, not only in an interface body or an
alias's object type, so inline parameter types, inline return types and nested
object types all matched — and the enclosing-container walk hung each one off
the nearest class, interface or alias. Measured against the unanchored rule,
all four appeared as members of shapes that do not have them:

  Property:contracts.ts:Svc.inlineParamOnlyKey
  Property:contracts.ts:Repo.inlineQueryOnlyKey
  Property:contracts.ts:NestedConfig.nestedOnlyKey
  Property:contracts.ts:buildInline.inlineReturnOnlyKey@46:33

When the inline member shares a name with a real one — `run(opts: { retries:
number })` inside a class that declares `retries` — `addNode` is
first-write-wins and the two distinct symbols merge onto one node, so every
context()/impact()/rename() answer about that field describes the merge. 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 nested object types are excluded by the
same anchor rather than by a second rule.

The first version of these tests was VACUOUS and is recorded here because the
reason generalizes: a collision and a correct exclusion both leave exactly one
node behind, so counting ids cannot distinguish them. Every inline member in
the fixture is now uniquely named, which is the only thing that discriminates —
verified by restoring the unanchored rule and watching exactly those four
assertions fail. A fifth test asserts anchoring costs no real member.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 21:34:40 +03:00
parent fb11102689
commit 2ed6504dbd
4 changed files with 124 additions and 4 deletions

View file

@ -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

View file

@ -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

View file

@ -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 };
}

View file

@ -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<PropNode> } }).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<string, unknown>;
}