feat(scope-resolution): anchor anonymous returned object literals to their function

The last gap round 3 named, and the dominant shape in idiomatic JS: 437
`return {` sites in a single backend directory of the reporting repo, including
the ~25-field payload of its entire signal pipeline. The literal binds to
nothing, so its keys could not even be named — "who reads wickRatio?" had no
symbol to ask about.

The enclosing FUNCTION is the owner: the literal is that function's return
shape, a contract its callers consume. Keys qualify as `<function>.<key>`, so
two functions returning the same name stay two shapes rather than one merged
symbol, and multiple returns in one function stay distinct by position.

RECONCILING THIS WITH R2-1b, which deliberately modelled returned keys as WRITES
to avoid adding same-named competitors to narrowing. These are definitions, but
narrowing now ranks DECLARED anchors — named literals, class fields, interface
and alias members — strictly above return shapes. A name that already resolved
keeps resolving to what it resolved to before, so the competitor problem R2-1b
was avoiding cannot come back. Mutation-checked: dropping that ranking breaks
five pre-existing R2 resolutions.

That also required an R2-1b assertion to change, and the change is a
strengthening rather than a concession. It asserted `toHaveLength(1)` — no new
definition — as a proxy for "adding definitions must not move an existing
answer". The proxy is now false while the property still holds, so the property
itself is asserted directly.

No `HAS_PROPERTY` edge from the function: that would be a `Function|Property`
relation pair the schema does not declare, and an undeclared pair does not
degrade — it throws and kills the whole analyze. That already shipped once in
this PR.

Two things found by dumping rather than assuming, both fixed here:

SHORTHAND keys were not matched at all. `return { symbol, interval, score }` is
the commonest spelling and the reporting repo's own payload is mostly this form,
but tree-sitter models it as `shorthand_property_identifier`, which `(pair)`
does not match. Caught by dumping the golden fixture and seeing a literal
returning `{ level, message, timestamp: Date.now() }` had indexed only
`timestamp`. Now covered in return position AND in the variable-bound rule,
which had the same gap.

Provenance was flagged by owner-presence, which mislabelled the anonymous case:
a callback's return shape yields no name to qualify by, so it looked like a
DECLARED anchor and would have outranked real declarations. Flagged by position
now — a different question from whether a name could be derived.

SCHEMA_BUMP 48 -> 49. Within one PR the version only has to differ from main's,
but a build stamped 48 was installed and used to analyze before these captures
existed, so caches stamped 48 carry none of them — the intermediate-build hazard
this ledger already records for 33/34.

Golden regenerated after verifying the drift: exactly +10 Property and +10
DEFINES, every pre-existing count unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-07 03:43:19 +03:00
parent 8972d223a9
commit af5eec5c05
11 changed files with 348 additions and 22 deletions

View file

@ -101,6 +101,19 @@ const OVERSATURATED = null;
interface PropertyCandidate {
readonly id: string;
readonly filePath: string;
/**
* True when this definition is the RETURN SHAPE of a function (R3-4) rather
* than a declared surface a named object literal, a class field, an
* interface or alias member.
*
* Return shapes are the weaker anchor and are ranked below declared ones, so
* adding them cannot change an answer that already resolved. That is what
* reconciles this with R2-1b, which deliberately modelled returned keys as
* WRITES to avoid adding same-named competitors to narrowing: they are
* definitions now, but they never outrank a real declaration, so the
* competitor problem it was avoiding does not come back.
*/
readonly fromReturnShape: boolean;
}
/**
@ -186,13 +199,18 @@ export function buildPropertyNameIndex(graph: KnowledgeGraph): PropertyNameIndex
if (typeof name !== 'string' || name.length === 0) continue;
const filePath = node.properties.filePath;
if (typeof filePath !== 'string') continue;
const candidate: PropertyCandidate = {
id: node.id,
filePath,
fromReturnShape: node.properties.fromReturnShape === true,
};
const existing = byName.get(name);
if (existing === undefined) {
byName.set(name, [{ id: node.id, filePath }]);
byName.set(name, [candidate]);
continue;
}
if (existing.some((c) => c.id === node.id)) continue;
existing.push({ id: node.id, filePath });
existing.push(candidate);
}
return byName;
}
@ -281,13 +299,24 @@ function buildDirectImportMap(
* imported third would answer a question the reader's own file contradicts.
*/
function narrowToSingleCandidate(
candidates: readonly PropertyCandidate[],
candidatesIn: readonly PropertyCandidate[],
readingFile: string,
importedFiles: ReadonlySet<string> | undefined,
): { readonly id: string; readonly tier: string } | null {
if (candidates.length === 1) {
return { id: candidates[0]!.id, tier: 'workspace-unique' };
// DECLARED ANCHORS FIRST. A return shape is a real definition but a weaker
// one: it says "some function builds an object with this key", where a named
// literal or a class/interface member says "this IS the field". Whenever both
// exist, the declared one is what a reader means — and ranking it first is
// what guarantees R3-4 cannot change an answer that already resolved before
// return shapes were indexed at all.
let candidates = candidatesIn;
const declared = candidates.filter((c) => !c.fromReturnShape);
const ranked = declared.length > 0 ? declared : candidates;
if (ranked.length === 1) {
return { id: ranked[0]!.id, tier: 'workspace-unique' };
}
candidates = ranked;
const sameFile = candidates.filter((c) => c.filePath === readingFile);
if (sameFile.length > 0) {

View file

@ -424,6 +424,43 @@ export const TYPESCRIPT_QUERIES = `
(pair
key: (property_identifier) @name) @definition.property))
; Keys of an ANONYMOUS object literal in RETURN position (R3-4). The dominant
; shape in idiomatic JS: 437 sites in one backend directory of the reporting
; repo, including the ~25-field payload of its whole signal pipeline, none of
; which could be named because the literal binds to nothing.
;
; The enclosing function is the owner -- the literal is that function's return
; shape, a contract its callers consume -- so the key qualifies as
; <function>.<key> and two functions returning the same key stay distinct.
;
; DEFINITIONS, unlike the record-construction writes of R2-1b, and the
; difference is deliberate: there a definition already existed elsewhere and a
; construction site was a USE of it, while here nothing else names the field at
; all. To keep that from regressing R2-1b's case, narrowing ranks declared
; anchors ABOVE return shapes, so a name that already resolves keeps resolving
; to what it resolved to before.
(return_statement
(object
(pair
key: (property_identifier) @name) @definition.property))
; SHORTHAND keys of the same literal. "return { symbol, interval, score }" is
; the commonest spelling of all -- the reporting repo's own alert payload is
; mostly shorthand -- and (pair) does not match it: tree-sitter models it as
; shorthand_property_identifier, where the key IS the value. Found by dumping
; the golden fixture and noticing that a literal returning
; { level, message, timestamp: Date.now() } had indexed only timestamp.
(return_statement
(object
(shorthand_property_identifier) @name @definition.property))
; Shorthand keys of a named object literal -- same gap, same reason as the
; return-position rule above.
(variable_declarator
name: (identifier)
value: (object
(shorthand_property_identifier) @name @definition.property))
(variable_declarator
name: (identifier)
value: (call_expression
@ -932,6 +969,43 @@ export const JAVASCRIPT_QUERIES = `
(pair
key: (property_identifier) @name) @definition.property))
; Keys of an ANONYMOUS object literal in RETURN position (R3-4). The dominant
; shape in idiomatic JS: 437 sites in one backend directory of the reporting
; repo, including the ~25-field payload of its whole signal pipeline, none of
; which could be named because the literal binds to nothing.
;
; The enclosing function is the owner -- the literal is that function's return
; shape, a contract its callers consume -- so the key qualifies as
; <function>.<key> and two functions returning the same key stay distinct.
;
; DEFINITIONS, unlike the record-construction writes of R2-1b, and the
; difference is deliberate: there a definition already existed elsewhere and a
; construction site was a USE of it, while here nothing else names the field at
; all. To keep that from regressing R2-1b's case, narrowing ranks declared
; anchors ABOVE return shapes, so a name that already resolves keeps resolving
; to what it resolved to before.
(return_statement
(object
(pair
key: (property_identifier) @name) @definition.property))
; SHORTHAND keys of the same literal. "return { symbol, interval, score }" is
; the commonest spelling of all -- the reporting repo's own alert payload is
; mostly shorthand -- and (pair) does not match it: tree-sitter models it as
; shorthand_property_identifier, where the key IS the value. Found by dumping
; the golden fixture and noticing that a literal returning
; { level, message, timestamp: Date.now() } had indexed only timestamp.
(return_statement
(object
(shorthand_property_identifier) @name @definition.property))
; Shorthand keys of a named object literal -- same gap, same reason as the
; return-position rule above.
(variable_declarator
name: (identifier)
value: (object
(shorthand_property_identifier) @name @definition.property))
; Same named shape, behind an IDENTITY-PRESERVING wrapper (R2-1a):
;
; export const INERT_EXIT_CONTRACT = Object.freeze({ exitModel: 'bracket', ... });

View file

@ -1177,6 +1177,91 @@ const BLOCK_SCOPE_BOUNDARY_TYPES = new Set([
* ancestor also returns null (catches block-scoped declarations inside
* top-level `if`/`for`/`try`/etc., which cannot be imported).
*/
/**
* Owner for the keys of an ANONYMOUS object literal in return position (R3-4).
*
* `return { symbol, score, wickRatio, … }` binds to nothing, so its keys had no
* anchor and could not be qualified which on the reporting repo left the
* central payload of the signal pipeline, ~25 fields, entirely unqueryable.
* There are 437 such sites in one backend directory, so this is the dominant
* shape, not an edge case.
*
* The enclosing FUNCTION is the honest owner: the literal is that function's
* return shape, which is a contract its callers consume. Qualifying by it keeps
* two functions returning the same key name as two distinct nodes, exactly as
* `ownerName` does for variable-bound literals.
*
* Returns null when the literal is not DIRECTLY returned (a nested literal, or
* one inside a callback several frames down), because then the enclosing
* function is not what the object describes.
*/
/**
* True when this definition node is a key of a literal in RETURN position.
*
* Deliberately independent of whether an OWNER NAME could be derived. The two
* are different questions, and conflating them mislabels the anonymous case:
* `[function (row) { return { k: row.x }; }]` yields no name to qualify by, so
* the owner lookup returns null but the key is still a return shape, and
* flagging it by owner-presence would leave it looking like a DECLARED anchor
* and let it outrank a real declaration during narrowing.
*/
export const isReturnShapeProperty = (node: SyntaxNode): boolean => {
let current: SyntaxNode | null = node;
let objectDepth = 0;
while (current && objectDepth === 0) {
if (current.type === 'object') objectDepth = 1;
else if (FUNCTION_NODE_TYPES.has(current.type)) return false;
else current = current.parent;
}
return current?.parent?.type === 'return_statement';
};
export const findReturnShapeOwnerInfo = (
node: SyntaxNode,
filePath: string,
// NO `ownerId`, deliberately, and the union's optional field is what says so.
// An owner id would emit `HAS_PROPERTY` from the FUNCTION, a `Function|Property`
// relation pair that the schema does not declare — and an undeclared pair does
// not degrade, it throws `UndeclaredRelationPairError` and kills the entire
// analyze. That already shipped once in this PR. The qualifier alone is what
// this needs: it makes the key nameable and keeps two functions' same-named
// keys distinct, without asserting a containment edge nothing consumes.
): { readonly ownerId?: string; readonly ownerName: string } | null => {
// Walk to the literal this key belongs to; bail if it is nested inside
// another object, whose shape it describes instead.
let current: SyntaxNode | null = node;
let objectDepth = 0;
while (current && objectDepth === 0) {
if (current.type === 'object') objectDepth = 1;
else if (FUNCTION_NODE_TYPES.has(current.type)) return null;
else current = current.parent;
}
if (!current) return null;
const literal = current;
if (literal.parent?.type !== 'return_statement') return null;
// The nearest enclosing function-like, and its name. An anonymous function
// (a callback, an IIFE) gives nothing to qualify by, so those stay
// unanchored rather than colliding on a shared empty owner.
let fn: SyntaxNode | null = literal.parent.parent;
while (fn && !FUNCTION_NODE_TYPES.has(fn.type)) fn = fn.parent;
if (!fn) return null;
const nameNode = fn.childForFieldName?.('name');
if (nameNode?.type === 'identifier' || nameNode?.type === 'property_identifier') {
return { ownerName: nameNode.text };
}
// `const formatAlert = (…) => ({ … })` and `const f = function () {}`: the
// name is on the declarator, not the function.
const declarator = fn.parent;
if (declarator?.type === 'variable_declarator') {
const declName = declarator.childForFieldName?.('name');
if (declName?.type === 'identifier') return { ownerName: declName.text };
}
void filePath;
return null;
};
export const findObjectLiteralBindingInfo = (
node: SyntaxNode,
filePath: string,

View file

@ -91,6 +91,8 @@ import {
getDefinitionNodeFromCaptures,
findEnclosingClassInfo,
findObjectLiteralBindingInfo,
findReturnShapeOwnerInfo,
isReturnShapeProperty,
findMemberAssignmentOwnerInfo,
isCjsDefaultExportAssignment,
type EnclosingClassInfo,
@ -2361,8 +2363,19 @@ const processFileGroup = (
// byte-identical or every object-literal method in every indexed
// repo changes id.
includeOwnerName: nodeLabel === 'Property',
}))
}) ??
// R3-4: an anonymous literal in return position is owned by the
// function whose shape it is. Last in the chain so a variable-bound
// literal keeps its existing owner and its existing id.
(nodeLabel === 'Property' ? findReturnShapeOwnerInfo(definitionNode, file.path) : null))
: null;
// Provenance for narrowing (R3-4). A return shape is a real definition but
// the weaker one, and the unique-name pass ranks declared anchors above it
// so indexing these cannot change an answer that already resolved.
const returnShapeProperty =
nodeLabel === 'Property' && definitionNode !== undefined && definitionNode !== null
? isReturnShapeProperty(definitionNode)
: false;
// #1978: hoisted ABOVE qualifiedName/node-id (load-bearing order) so a
// class-like node can key its id by its fully-qualified path. Derived from
@ -2809,6 +2822,7 @@ const processFileGroup = (
...(description !== undefined ? { description } : {}),
...methodProps,
...(declaredType !== undefined ? { declaredType } : {}),
...(returnShapeProperty ? { fromReturnShape: true } : {}),
},
});

View file

@ -289,7 +289,18 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// clashes. It is NOT on this branch, so until that one merges the re-check
// below is still manual.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 48;
// 48 -> 49 for the return-shape and shorthand captures (R3-4): keys of an
// anonymous literal in return position, and shorthand keys in both that and the
// variable-bound form. Parse-time again.
//
// The v34 hazard, and this branch has already tripped it: a build stamped 48
// was installed and used to analyze two repos BEFORE these captures existed, so
// caches stamped 48 exist that carry none of them. Within one PR the version
// only has to differ from main's, but an INTERMEDIATE build of the same series
// is a different capture set wearing the same number — which is exactly what
// the note above records for 33/34.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 49;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,47 @@
// R3-4: an anonymous literal in return position — the dominant shape in
// idiomatic JS (437 sites in one backend directory of the reporting repo),
// including the ~25-field payload of its entire signal pipeline. It binds to
// nothing, so its keys had no anchor and could not be named at all.
export function formatAlert(row) {
const shorthandOnlyField = row.shorthand;
return {
returnShapeOnlyField: row.raw,
sharedWithDeclared: row.other,
// SHORTHAND — the commonest spelling, and the one `(pair)` cannot match.
// The reporting repo's own alert payload is mostly this form.
shorthandOnlyField,
};
}
// A SECOND function returning a same-named key. Two distinct shapes, so two
// distinct nodes — qualifying by the owning function is what keeps them apart.
export function formatSummary(row) {
return {
summaryOnlyField: row.summary,
};
}
// The reader. Untyped receiver, so this is the name-inference path.
export function readsReturnShape(alert) {
return alert.returnShapeOnlyField;
}
// The R2-1b GUARANTEE, as a fixture: a DECLARED anchor for the same name.
// `sharedWithDeclared` is both a named-object key and a return-shape key, and a
// read of it must keep resolving to the DECLARED one — otherwise indexing
// return shapes would silently move existing answers.
export const declaredHome = {
sharedWithDeclared: 1,
};
export function readsShared(bag) {
return bag.sharedWithDeclared;
}
// Anonymous functions give nothing to qualify by, so their return shapes stay
// unanchored rather than colliding on a shared empty owner.
export const anonHolder = [
function (row) {
return { anonReturnKey: row.x };
},
];

View file

@ -44,6 +44,11 @@ export type NestedConfig = {
};
// Inline RETURN type — the third position the unanchored rule reached.
export function buildInline(): { inlineReturnOnlyKey: number } {
return { inlineReturnOnlyKey: 1 };
// The TYPE annotation's member and the returned VALUE's key are named
// differently ON PURPOSE. They are separate rules with opposite expectations —
// an inline return TYPE must mint nothing (RV-4), while a returned literal's
// keys are a function's return shape and must mint (R3-4) — and sharing a name
// left the RV-4 assertion unable to tell which rule produced the node.
export function buildInline(): { inlineReturnTypeOnlyKey: number } {
return { inlineReturnValueOnlyKey: 1 } as never;
}

View file

@ -2,8 +2,8 @@
"capture": "initial capture (U8, post-U1U7)",
"fixture": "mini-repo",
"totalFileCount": 7,
"symbols": 41,
"relationships": 85,
"symbols": 51,
"relationships": 95,
"processes": 4,
"byType": {
"Class": 1,
@ -14,13 +14,13 @@
"Interface": 3,
"Method": 1,
"Process": 4,
"Property": 8
"Property": 18
},
"byRelType": {
"ACCESSES": 3,
"CALLS": 9,
"CONTAINS": 7,
"DEFINES": 16,
"DEFINES": 26,
"HAS_METHOD": 1,
"HAS_PROPERTY": 8,
"IMPORTS": 12,
@ -28,5 +28,5 @@
"STEP_IN_PROCESS": 12,
"USES": 5
},
"edgeDigest": "c55bd8307a5fccbfd23d5aa93f241e26ef0f45c1a9e57691f2bf4f6602e8e4ab"
"edgeDigest": "5dddd1f466deeda1197eb61b480a4f3a5da67dfc0d00ace22377158366e87d00"
}

View file

@ -145,11 +145,21 @@ describe('JavaScript plain-object property access (A1/A5)', () => {
expect(writersOf('destructuredOnlyField')).toContain('buildFlat');
});
// The point of modelling these as writes rather than definitions: more
// definitions would add same-named competitors to the narrowing that makes
// these fields resolvable in the first place.
it('mints NO new definition for a constructed record', () => {
expect(propertyNames().filter((n) => n === 'destructuredOnlyField')).toHaveLength(1);
// This asserted `toHaveLength(1)` — no new definition — until R3-4 began
// anchoring returned literals, which mints exactly one here (`buildFlat`'s
// return shape). The assertion was the right instinct expressed as the
// wrong invariant: what R2-1b actually protects is that adding definitions
// must not move an answer that already resolved, and node count was a proxy
// for that. The property itself is now asserted directly, and it holds
// because narrowing ranks declared anchors above return shapes.
it('keeps the DECLARED definition winning despite a return-shape sibling', () => {
const nodes = propertyNames().filter((n) => n === 'destructuredOnlyField');
expect(nodes.length).toBeGreaterThan(1);
// Every reader still resolves, and to the declared home — a read that had
// dropped to ambiguous would show up as a missing edge here.
for (const reader of ['appliesDestructured', 'appliesShorthand', 'appliesRenamed']) {
expect(readersOf('destructuredOnlyField')).toContain(reader);
}
});
it('leaves an inline call-argument prop bag alone', () => {
@ -158,6 +168,54 @@ describe('JavaScript plain-object property access (A1/A5)', () => {
});
});
// R3-4. The dominant shape in idiomatic JS and the one with no anchor at all:
// 437 `return {` sites in a single backend directory of the reporting repo,
// including the ~25-field payload of its whole signal pipeline. The literal
// binds to nothing, so its keys could not even be named.
describe('anonymous returned object literals (R3-4)', () => {
it('indexes keys of a literal returned from a named function', () => {
expect(propertyNames()).toContain('returnShapeOnlyField');
});
it('resolves a read of a return-shape key', () => {
expect(readersOf('returnShapeOnlyField')).toContain('readsReturnShape');
});
// `{ symbol, interval, score }` is the commonest spelling of all and
// `(pair)` does not match it — tree-sitter models it as
// `shorthand_property_identifier`, where the key IS the value. Caught by
// dumping the golden fixture and seeing that a literal returning
// `{ level, message, timestamp: Date.now() }` had indexed only `timestamp`.
it('indexes SHORTHAND keys, not just explicit pairs', () => {
expect(propertyNames()).toContain('shorthandOnlyField');
});
// Qualified by the owning function, so two functions returning the same key
// are two shapes rather than one merged symbol — the same collision
// `ownerName` prevents for variable-bound literals.
it('qualifies by the owning function', () => {
const ids = Array.from(
(result as unknown as { graph: { iterNodes(): Iterable<PropNode> } }).graph.iterNodes(),
)
.filter((n) => n.label === 'Property')
.map((n) => String(n.id));
expect(ids.some((id) => id.includes('formatAlert.returnShapeOnlyField'))).toBe(true);
expect(ids.some((id) => id.includes('formatSummary.summaryOnlyField'))).toBe(true);
});
// THE GUARANTEE that reconciles this with R2-1b. `sharedWithDeclared` is
// both a named-object key and a return-shape key; a read must still resolve
// to the DECLARED one, or indexing return shapes would silently move
// answers that already worked.
it('never outranks a declared anchor', () => {
expect(readersOf('sharedWithDeclared')).toContain('readsShared');
const declaredWins = getRelationships(result, 'ACCESSES').filter(
(e) => e.target === 'sharedWithDeclared' && e.source === 'readsShared',
);
expect(declaredWins.length).toBeGreaterThan(0);
});
});
// R2. Strict workspace uniqueness was measurably too blunt: in the reporting
// repo `exitMinAtrMult` had 26 definitions, 16 of them in one-off scripts the
// backend has no relationship with, so every backend read was refused because

View file

@ -117,7 +117,10 @@ describe('TypeScript type-alias and interface members (A4)', () => {
});
it('does not mint a member for an inline RETURN type', () => {
expect(propertyIds().some((id) => id.includes('inlineReturnOnlyKey'))).toBe(false);
// The TYPE's member, not the returned value's key — those are different
// rules with opposite expectations, and the fixture names them apart so
// this assertion cannot be satisfied by the wrong one.
expect(propertyIds().some((id) => id.includes('inlineReturnTypeOnlyKey'))).toBe(false);
});
// The other half: anchoring must not cost real members.

View file

@ -141,8 +141,8 @@ describe('PARSE_CACHE_VERSION', () => {
// a row. Same lesson as the note above — the pin cannot detect the tie, since
// both sides asserted `toBe(45)` and that passes while main is already 45.
// Only the merge-time diff against origin/main surfaces it.
it('pins SCHEMA_BUMP to 48 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(48);
it('pins SCHEMA_BUMP to 49 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(49);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {