mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-10 03:27:59 +00:00
feat(search): give the index a notion of DETAIL symbols, and re-land R3-4/R3-5
Reverts the revert. The return-shape work was correct and measured — 1,410
precise edges, and all six fields round 3 verified out-of-sample going 0/6 to
6/6 — and it was dropped for a regression that was really a MISSING LAYER: the
search index had no way to say "this symbol is queryable but is not a concept a
text search should surface on its own".
Indexing the keys of anonymous returned literals adds many nodes whose names are
ordinary words (`message`, `value`, `timestamp`). Without that notion they
compete on equal terms in FTS, push the CALLABLES named after the same concept
past the search's row cap, and `query('message')` returned two processes before
and none after.
The layer, rather than a workaround:
- `Property.isDetail`, persisted. A Property-only column, which that table
already precedents with `declaredType`, set where the key is minted.
- `buildFtsQueryCypher` filters on it for the Property table, BEFORE the row
cap. That placement is the whole point: rows crowded out never reach the
caller, so no downstream re-ranking can recover them. Two downstream fixes
were tried first — a tie-break and a partition of the merge window — and
recovered nothing, which is what located the real seam.
- `IS NULL`-tolerant, so an index written before the column existed still
answers instead of returning nothing.
Verified by the A/B that found the regression: the query's result order is now
byte-identical to the pre-R3-4 baseline —
`proc_0_processrequest, proc_2_errormiddleware, Function:createLogEntry,
Property:LogEntry.message` — with the return-shape coverage retained.
The determinism guard then caught prose in the new DDL comment containing the
token this repo scans for, which would have read as an unordered query. Reworded;
that suite is doing exactly its job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2927586812
commit
2e7b4a217f
19 changed files with 716 additions and 29 deletions
|
|
@ -0,0 +1,151 @@
|
|||
/**
|
||||
* PRECISE member resolution through a call result's RETURN SHAPE (R3-5).
|
||||
*
|
||||
* The last unanswered question from three rounds of blind-spot reports was
|
||||
* "who reads `wickRatio`?", where the field is produced by several functions
|
||||
* that each return an anonymous object containing it. Name inference must
|
||||
* refuse that — a read of `spike.wickRatio` could mean any producer, and a
|
||||
* wrong edge in the pre-edit safety gate is worse than a missing one — so no
|
||||
* amount of narrowing gets there. It needs EVIDENCE instead of inference.
|
||||
*
|
||||
* The evidence already exists in two halves that had never been joined:
|
||||
*
|
||||
* 1. The call-result type binding. `const alert = formatSpikeAlert(row)`
|
||||
* binds `alert` to a `TypeRef` whose `rawName` is the callee. That
|
||||
* machinery predates this work; it simply had nothing to resolve to when
|
||||
* the callee returned an anonymous literal, because an anonymous literal
|
||||
* named nothing.
|
||||
* 2. R3-4 gave it a name. A returned literal's keys are now owned by the
|
||||
* producing function, so `formatSpikeAlert.wickRatio` is a real symbol.
|
||||
*
|
||||
* Joining them turns a refusal into a precise answer:
|
||||
*
|
||||
* const alert = formatSpikeAlert(row);
|
||||
* alert.wickRatio → Property:…:formatSpikeAlert.wickRatio
|
||||
*
|
||||
* and it works for exactly the case narrowing cannot: several producers sharing
|
||||
* a field name are no longer competitors, because the receiver says WHICH one.
|
||||
* That is why this runs before the unique-name fallback and registers its sites
|
||||
* as handled — a precise answer must never be second-guessed by a name match.
|
||||
*
|
||||
* BOUND, deliberately. This only fires where the value is BOUND to a name the
|
||||
* type binding could attach to. A field read off a bare parameter
|
||||
* (`function f(spike) { return spike.wickRatio }`) still has no receiver type
|
||||
* here, because typing it requires the CALLER's type to flow in — that is
|
||||
* inter-procedural and genuinely larger. Those reads keep falling through to
|
||||
* name inference, and keep being reported when it declines.
|
||||
*/
|
||||
|
||||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { KnowledgeGraph } from '../../../graph/types.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
|
||||
import { findReceiverTypeBinding } from '../scope/walkers.js';
|
||||
import { callableFlowSiteKey } from './callable-value-flow.js';
|
||||
import type { PropertyNameIndex } from './unique-name-properties.js';
|
||||
|
||||
/**
|
||||
* Confidence for a return-shape member. This is a PRECISE resolution — the
|
||||
* receiver's binding names the producing function and the member is owned by
|
||||
* it — so it carries the ordinary emission confidence, not the reduced tier
|
||||
* name inference uses. Nothing here is guessed.
|
||||
*/
|
||||
const RETURN_SHAPE_CONFIDENCE = 0.9;
|
||||
|
||||
const EDGE_REASON = 'scope-resolution: return-shape member';
|
||||
|
||||
export interface ReturnShapeMemberStats {
|
||||
/** ACCESSES edges resolved through a call result's return shape. */
|
||||
readonly emitted: number;
|
||||
/**
|
||||
* Sites where the receiver WAS typed to a producer but that producer owns no
|
||||
* member of this name. Reported rather than dropped: it means the read and
|
||||
* the shape disagree, which is either a stale field name or a producer this
|
||||
* pass mis-attributed, and both are worth seeing.
|
||||
*/
|
||||
readonly memberNotOnShape: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this Property node id name `<owner>.<member>`?
|
||||
*
|
||||
* Ids carry an optional position suffix for function-local symbols
|
||||
* (`…:buildFlat.field@33:4`), so the owner segment is matched up to a `@` or
|
||||
* the end rather than by equality.
|
||||
*/
|
||||
function idNamesMember(id: string, owner: string, member: string): boolean {
|
||||
const needle = `:${owner}.${member}`;
|
||||
const at = id.indexOf(needle);
|
||||
if (at === -1) return false;
|
||||
const after = id.slice(at + needle.length);
|
||||
return after.length === 0 || after.startsWith('@');
|
||||
}
|
||||
|
||||
export function emitReturnShapeMemberAccesses(
|
||||
graph: KnowledgeGraph,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: GraphNodeLookup,
|
||||
/** Sites a precise pass already owns — never re-resolved here. */
|
||||
skipSites: ReadonlySet<string>,
|
||||
propertyNameIndex: PropertyNameIndex,
|
||||
/** Sites this pass resolves, so the name fallback leaves them alone. */
|
||||
handledSink: Set<string>,
|
||||
): ReturnShapeMemberStats {
|
||||
let emitted = 0;
|
||||
let memberNotOnShape = 0;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const site of parsed.referenceSites) {
|
||||
if (site.kind !== 'read' && site.kind !== 'write') continue;
|
||||
const receiver = site.explicitReceiver?.name;
|
||||
if (receiver === undefined || receiver.length === 0) continue;
|
||||
const siteKey = callableFlowSiteKey(parsed.filePath, site.atRange);
|
||||
if (skipSites.has(siteKey)) continue;
|
||||
|
||||
// The receiver's binding names the PRODUCER, not a class. That is the
|
||||
// whole point: `formatSpikeAlert` is a function, and before R3-4 there
|
||||
// was nothing named after it to look a member up on.
|
||||
const typeRef = findReceiverTypeBinding(site.inScope, receiver, indexes);
|
||||
const producer = typeRef?.rawName;
|
||||
if (producer === undefined || producer.length === 0) continue;
|
||||
|
||||
const candidates = propertyNameIndex.get(site.name);
|
||||
if (candidates === undefined) continue;
|
||||
const owned = candidates.filter((c) => idNamesMember(c.id, producer, site.name));
|
||||
// Exactly one, or nothing. Two nodes claiming `<producer>.<member>` would
|
||||
// mean the id qualifier failed to separate them, and picking between them
|
||||
// would be the guess this pass exists to avoid.
|
||||
if (owned.length !== 1) {
|
||||
if (owned.length === 0) memberNotOnShape++;
|
||||
continue;
|
||||
}
|
||||
const target = owned[0]!;
|
||||
|
||||
const callerGraphId = resolveCallerGraphId(site.inScope, indexes, nodeLookup, site.atRange);
|
||||
if (callerGraphId === undefined) continue;
|
||||
if (callerGraphId === target.id) continue;
|
||||
|
||||
const dedupKey = `ACCESSES:${callerGraphId}->${target.id}:${site.atRange.startLine}:${site.atRange.startCol}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
|
||||
graph.addRelationship({
|
||||
id: `rel:${dedupKey}`,
|
||||
sourceId: callerGraphId,
|
||||
targetId: target.id,
|
||||
type: 'ACCESSES',
|
||||
confidence: RETURN_SHAPE_CONFIDENCE,
|
||||
reason: `${EDGE_REASON}: ${site.kind}`,
|
||||
evidence: [],
|
||||
});
|
||||
// Claim the site so the name fallback cannot re-answer it differently.
|
||||
handledSink.add(siteKey);
|
||||
emitted++;
|
||||
}
|
||||
}
|
||||
|
||||
return { emitted, memberNotOnShape };
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexe
|
|||
import type { GraphNodeLookup } from '../graph-bridge/node-lookup.js';
|
||||
import { resolveCallerGraphId } from '../graph-bridge/ids.js';
|
||||
import { callableFlowSiteKey } from './callable-value-flow.js';
|
||||
import { isTestFile } from '../../entry-point-scoring.js';
|
||||
import { getLanguageFromFilename } from 'gitnexus-shared';
|
||||
|
||||
/** Language a definition lives in, for reporting which anchor a reader cannot reach. */
|
||||
|
|
@ -101,6 +102,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 +200,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,14 +300,40 @@ 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;
|
||||
|
||||
// PRODUCTION CODE FIRST. A test constructs throwaway shapes with the same
|
||||
// field names as the thing it exercises — measured on the reporting repo,
|
||||
// four of the seven JavaScript anchors for `wickRatio` are in `tests/` — and
|
||||
// a read in production code cannot mean any of them. Applied before the
|
||||
// declared/return-shape split because "is this the shipped program" is the
|
||||
// stronger signal: a declaration in a test fixture is still a test fixture.
|
||||
//
|
||||
// Only when the READER is production. A read inside a test legitimately means
|
||||
// the test's own shape, so this must not fire there.
|
||||
if (!isTestFile(readingFile)) {
|
||||
const production = candidates.filter((c) => !isTestFile(c.filePath));
|
||||
if (production.length > 0) candidates = production;
|
||||
}
|
||||
|
||||
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) {
|
||||
return sameFile.length === 1 ? { id: sameFile[0]!.id, tier: 'same-file' } : null;
|
||||
|
|
|
|||
|
|
@ -77,9 +77,11 @@ import {
|
|||
} from '../passes/property-dispatch.js';
|
||||
import { emitReferencesViaLookup } from '../graph-bridge/references-to-edges.js';
|
||||
import {
|
||||
buildPropertyNameIndex,
|
||||
emitUniqueNamePropertyAccesses,
|
||||
type PropertyNameIndex,
|
||||
} from '../passes/unique-name-properties.js';
|
||||
import { emitReturnShapeMemberAccesses } from '../passes/return-shape-members.js';
|
||||
import { emitImportedValueReferences } from '../passes/imported-value-refs.js';
|
||||
import {
|
||||
createCalleeIdAccumulator,
|
||||
|
|
@ -1053,6 +1055,23 @@ export function runScopeResolution(
|
|||
// TS-anchor case R3-1 was filed about — the identical defect, mirrored.
|
||||
// `reportOnly` counts without emitting: no edge, no inference, no change to
|
||||
// what the opt-out protects.
|
||||
// PRECISE first (R3-5). A call result's return shape names WHICH producer a
|
||||
// receiver holds, so it answers exactly the case name inference must refuse:
|
||||
// several functions returning the same field name. Sites it resolves are
|
||||
// added to the skip set, so the fallback below never second-guesses them.
|
||||
const sharedPropertyIndex = input.prebuiltPropertyNameIndex ?? buildPropertyNameIndex(graph);
|
||||
const returnShapeMembers = callableFlowOnly
|
||||
? { emitted: 0, memberNotOnShape: 0 }
|
||||
: emitReturnShapeMemberAccesses(
|
||||
graph,
|
||||
indexes,
|
||||
emitParsedFiles,
|
||||
postHeritageNodeLookup,
|
||||
uniqueNameSkipSites,
|
||||
sharedPropertyIndex,
|
||||
uniqueNameSkipSites,
|
||||
);
|
||||
|
||||
const nameFallbackDisabled = provider.fieldFallbackOnMethodLookup === false;
|
||||
const uniqueNameProperties = callableFlowOnly
|
||||
? {
|
||||
|
|
@ -1070,7 +1089,7 @@ export function runScopeResolution(
|
|||
postHeritageNodeLookup,
|
||||
uniqueNameSkipSites,
|
||||
finalized,
|
||||
input.prebuiltPropertyNameIndex,
|
||||
sharedPropertyIndex,
|
||||
nameFallbackDisabled,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', ... });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, isDetail: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -516,7 +516,8 @@ export const streamAllCSVsToDisk = async (
|
|||
'Template',
|
||||
'Module',
|
||||
] as const;
|
||||
const propertyHeader = 'id,name,filePath,startLine,endLine,content,description,declaredType';
|
||||
const propertyHeader =
|
||||
'id,name,filePath,startLine,endLine,content,description,declaredType,isDetail';
|
||||
const multiLangWriters = new Map<string, BufferedCSVWriter>();
|
||||
for (const t of MULTI_LANG_TYPES) {
|
||||
multiLangWriters.set(
|
||||
|
|
@ -709,7 +710,13 @@ export const streamAllCSVsToDisk = async (
|
|||
escapeCSVField(content),
|
||||
escapeCSVField(formatFtsDescription(node.properties.description || '')),
|
||||
...(node.label === 'Property'
|
||||
? [escapeCSVField(node.properties.declaredType || '')]
|
||||
? [
|
||||
escapeCSVField(node.properties.declaredType || ''),
|
||||
// R3-4 detail symbols — see PROPERTY_SCHEMA. Written as
|
||||
// an explicit boolean so the column is never empty; an
|
||||
// empty BOOLEAN cell fails the COPY.
|
||||
node.properties.isDetail === true ? 'true' : 'false',
|
||||
]
|
||||
: []),
|
||||
].join(','),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1539,7 +1539,7 @@ export const getCopyQuery = (table: NodeTableName, filePath: string): string =>
|
|||
return `COPY ${t}(id, name, filePath, startLine, endLine, isExported, content, description, parameterCount, returnType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
if (table === 'Property') {
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
return `COPY ${t}(id, name, filePath, startLine, endLine, content, description, declaredType, isDetail) FROM "${filePath}" ${COPY_CSV_OPTS}`;
|
||||
}
|
||||
// TypeScript/JS code element tables have isExported; multi-language tables do not
|
||||
if (TABLES_WITH_EXPORTED.has(table)) {
|
||||
|
|
@ -1602,7 +1602,7 @@ export const insertNodeToLbug = async (
|
|||
const descPart = properties.description
|
||||
? `, description: ${formatCypherValue(properties.description)}`
|
||||
: '';
|
||||
query = `CREATE (n:${t} {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, declaredType: ${formatCypherValue(properties.declaredType || '')}})`;
|
||||
query = `CREATE (n:${t} {id: ${formatCypherValue(properties.id)}, name: ${formatCypherValue(properties.name)}, filePath: ${formatCypherValue(properties.filePath)}, startLine: ${properties.startLine || 0}, endLine: ${properties.endLine || 0}, content: ${formatCypherValue(properties.content || '')}${descPart}, declaredType: ${formatCypherValue(properties.declaredType || '')}, isDetail: ${properties.isDetail === true}})`;
|
||||
} else {
|
||||
// Multi-language tables (Struct, Impl, Trait, Macro, etc.) — no isExported
|
||||
const descPart = properties.description
|
||||
|
|
@ -1692,7 +1692,7 @@ export const batchInsertNodesToLbug = async (
|
|||
const descPart = properties.description
|
||||
? `, n.description = ${formatCypherValue(properties.description)}`
|
||||
: '';
|
||||
query = `MERGE (n:${t} {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.declaredType = ${formatCypherValue(properties.declaredType || '')}`;
|
||||
query = `MERGE (n:${t} {id: ${formatCypherValue(properties.id)}}) SET n.name = ${formatCypherValue(properties.name)}, n.filePath = ${formatCypherValue(properties.filePath)}, n.startLine = ${properties.startLine || 0}, n.endLine = ${properties.endLine || 0}, n.content = ${formatCypherValue(properties.content || '')}${descPart}, n.declaredType = ${formatCypherValue(properties.declaredType || '')}, n.isDetail = ${properties.isDetail === true}`;
|
||||
} else {
|
||||
const descPart = properties.description
|
||||
? `, n.description = ${formatCypherValue(properties.description)}`
|
||||
|
|
@ -3459,13 +3459,37 @@ export const classifyFtsQueryError = (message: string): FtsQueryFailureClass =>
|
|||
* ORDER BY tiebreak for #2787 being the latest. Lives beside
|
||||
* {@link classifyFtsQueryError}, which was already shared for exactly this call.
|
||||
*/
|
||||
/**
|
||||
* DETAIL SYMBOLS DO NOT COMPETE IN TEXT SEARCH.
|
||||
*
|
||||
* `Property.isDetail` marks the keys of an anonymous literal returned from a
|
||||
* function (R3-4): real symbols, worth walking and worth an impact analysis,
|
||||
* but not concepts a text search should surface on their own. Their names are
|
||||
* ordinary words (`message`, `value`, `timestamp`) and there are many of them,
|
||||
* so without this they consume the FTS call's own LIMIT and push out the
|
||||
* CALLABLES named after the same concept — measured, `query('message')` went
|
||||
* from two processes to none on the mini-repo fixture.
|
||||
*
|
||||
* Filtered HERE rather than after the call, because that is the only place it
|
||||
* works: rows crowded out by the LIMIT never reach the caller, so no amount of
|
||||
* re-ranking downstream can recover them. (Tried, and it recovered nothing.)
|
||||
*
|
||||
* Property-only, since no other table has the column, and `IS NULL`-tolerant so
|
||||
* an index written before this column existed still answers.
|
||||
*/
|
||||
const FTS_DETAIL_FILTER = `
|
||||
WITH node, score
|
||||
WHERE node.isDetail IS NULL OR node.isDetail = false`;
|
||||
|
||||
export const buildFtsQueryCypher = (
|
||||
tableName: string,
|
||||
indexName: string,
|
||||
limit: number,
|
||||
conjunctive: boolean = false,
|
||||
): string => `
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})
|
||||
CALL QUERY_FTS_INDEX('${tableName}', '${indexName}', $query, conjunctive := ${conjunctive})${
|
||||
tableName === 'Property' ? FTS_DETAIL_FILTER : ''
|
||||
}
|
||||
RETURN node, score
|
||||
ORDER BY score DESC, node.id
|
||||
LIMIT ${limit}
|
||||
|
|
|
|||
|
|
@ -183,6 +183,25 @@ CREATE NODE TABLE \`Property\` (
|
|||
content STRING,
|
||||
description STRING,
|
||||
declaredType STRING,
|
||||
/*
|
||||
* DETAIL SYMBOL — true when this property is a member of a shape that has no
|
||||
* independent identity: the keys of an anonymous literal returned from a
|
||||
* function (R3-4).
|
||||
*
|
||||
* It exists because indexing those keys is right for the GRAPH and wrong for
|
||||
* TEXT SEARCH. They are ordinary words (message, value, timestamp) and
|
||||
* there are many of them, so letting them into the FTS result set pushes the
|
||||
* CALLABLES named after the same concept past the row cap the search applies
|
||||
* — measured: query('message') went from two processes to none on the
|
||||
* mini-repo fixture. A ranking tweak cannot fix that, because the rows never
|
||||
* come back from the FTS call in the first place.
|
||||
*
|
||||
* So the search layer gained a notion it did not have — a symbol that is
|
||||
* queryable, walkable and impact-analysable, but not a concept a text search
|
||||
* should surface on its own. buildFtsQueryCypher excludes these for the
|
||||
* Property table only; every other consumer sees them normally.
|
||||
*/
|
||||
isDetail BOOLEAN,
|
||||
PRIMARY KEY (id)
|
||||
)`;
|
||||
export const RECORD_SCHEMA = CODE_ELEMENT_BASE('Record');
|
||||
|
|
|
|||
|
|
@ -289,7 +289,22 @@ 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.
|
||||
// 49 -> 50 is NOT needed for R3-5: that pass is scope-resolution, not
|
||||
// parse-time capture, so a warm cache replays ParsedFiles that already carry
|
||||
// everything it reads. Recorded because the reflex on this branch has been to
|
||||
// bump, and a bump nobody needs still forces every user a full re-parse.
|
||||
const SCHEMA_BUMP = 49;
|
||||
const GITNEXUS_PKG_VERSION = (() => {
|
||||
try {
|
||||
// package.json sits at gitnexus/package.json — two levels up from
|
||||
|
|
|
|||
9
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/fixture-shapes.test.js
vendored
Normal file
9
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/fixture-shapes.test.js
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// A TEST file that constructs a throwaway shape with a production field name.
|
||||
// Measured on the reporting repo: four of seven JavaScript anchors for one
|
||||
// field lived in `tests/`, competing with the three real ones and making every
|
||||
// production read ambiguous. A read in production cannot mean any of these.
|
||||
export function buildTestFixture() {
|
||||
return {
|
||||
productionAndTestField: 'fixture',
|
||||
};
|
||||
}
|
||||
8
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/production-reader.js
vendored
Normal file
8
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/production-reader.js
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// The reader lives in its OWN file and imports neither anchor, so no same-file
|
||||
// or direct-import tier can decide this. What is left is production-vs-test,
|
||||
// which is exactly the tier under test — with a reader beside the production
|
||||
// anchor, the same-file tier resolves it either way and the assertion proves
|
||||
// nothing.
|
||||
export function readsProductionShape(bag) {
|
||||
return bag.productionAndTestField;
|
||||
}
|
||||
32
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape-typed.js
vendored
Normal file
32
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape-typed.js
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// R3-5: TWO producers returning the same field name — the case name inference
|
||||
// must refuse, because `x.ambiguousProducedField` alone cannot say which shape
|
||||
// is meant. The receiver's binding says which, so this resolves precisely.
|
||||
export function producerAlpha(row) {
|
||||
return {
|
||||
ambiguousProducedField: row.a,
|
||||
};
|
||||
}
|
||||
|
||||
export function producerBeta(row) {
|
||||
return {
|
||||
ambiguousProducedField: row.b,
|
||||
};
|
||||
}
|
||||
|
||||
// BOUND to the call result, so the type binding attaches.
|
||||
export function readsAlpha(row) {
|
||||
const shaped = producerAlpha(row);
|
||||
return shaped.ambiguousProducedField;
|
||||
}
|
||||
|
||||
export function readsBeta(row) {
|
||||
const shaped = producerBeta(row);
|
||||
return shaped.ambiguousProducedField;
|
||||
}
|
||||
|
||||
// THE BOUND of the mechanism: a bare parameter has no binding here, because
|
||||
// typing it needs the CALLER's type to flow in. This must stay unresolved and
|
||||
// fall through to name inference, which will refuse it (two producers).
|
||||
export function readsUnbound(shaped) {
|
||||
return shaped.ambiguousProducedField;
|
||||
}
|
||||
55
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape.js
vendored
Normal file
55
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/return-shape.js
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// 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 };
|
||||
},
|
||||
];
|
||||
|
||||
// The production anchor for a name a test fixture also constructs. A read here
|
||||
// must resolve to THIS one, not to the fixture's.
|
||||
export function buildProductionShape(row) {
|
||||
return {
|
||||
productionAndTestField: row.real,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
"capture": "initial capture (U8, post-U1–U7)",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,118 @@ 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);
|
||||
});
|
||||
|
||||
// Production code outranks test fixtures. Measured on the reporting repo:
|
||||
// four of the seven JavaScript anchors for one field were in `tests/`,
|
||||
// competing with the three real ones and making every production read
|
||||
// ambiguous. A test builds throwaway shapes with production field names; a
|
||||
// read in shipped code cannot mean one.
|
||||
it('does not let a test fixture compete with the production anchor', () => {
|
||||
expect(readersOf('productionAndTestField')).toContain('readsProductionShape');
|
||||
const ids = Array.from(
|
||||
(result as unknown as { graph: { iterNodes(): Iterable<PropNode> } }).graph.iterNodes(),
|
||||
)
|
||||
.filter((n) => n.label === 'Property')
|
||||
.map((n) => String(n.id));
|
||||
// Both anchors exist — it is the RANKING that differs, not the indexing.
|
||||
expect(ids.some((id) => id.includes('buildProductionShape.productionAndTestField'))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(ids.some((id) => id.includes('buildTestFixture.productionAndTestField'))).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);
|
||||
});
|
||||
});
|
||||
|
||||
// R3-5. The question three rounds of reports could not answer: a field
|
||||
// produced by SEVERAL functions. Name inference must refuse it — the name
|
||||
// alone cannot say which shape is meant — so this replaces inference with
|
||||
// evidence, joining the call-result type binding (which already existed) to
|
||||
// the return-shape owner (which R3-4 created).
|
||||
describe('return-shape members via the call result (R3-5)', () => {
|
||||
const targetOf = (source: string): string[] =>
|
||||
getRelationships(result, 'ACCESSES')
|
||||
.filter((e) => e.target === 'ambiguousProducedField' && e.source === source)
|
||||
.map((e) => String((e.rel as { targetId?: string }).targetId ?? ''));
|
||||
|
||||
it('resolves to the producer the receiver actually holds', () => {
|
||||
expect(targetOf('readsAlpha').some((id) => id.includes('producerAlpha.'))).toBe(true);
|
||||
expect(targetOf('readsBeta').some((id) => id.includes('producerBeta.'))).toBe(true);
|
||||
});
|
||||
|
||||
// The discriminator. Both producers own a field of this name, so a name
|
||||
// match cannot tell them apart — getting the RIGHT one is only possible
|
||||
// because the receiver's binding names the producer.
|
||||
it('does not cross the two producers', () => {
|
||||
expect(targetOf('readsAlpha').some((id) => id.includes('producerBeta.'))).toBe(false);
|
||||
expect(targetOf('readsBeta').some((id) => id.includes('producerAlpha.'))).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the edge as precise, not as name inference', () => {
|
||||
const reasons = getRelationships(result, 'ACCESSES')
|
||||
.filter((e) => e.target === 'ambiguousProducedField')
|
||||
.map((e) => String(e.rel.reason ?? ''));
|
||||
expect(reasons.every((r) => r.includes('return-shape member'))).toBe(true);
|
||||
for (const e of getRelationships(result, 'ACCESSES').filter(
|
||||
(x) => x.target === 'ambiguousProducedField',
|
||||
)) {
|
||||
expect(e.rel.confidence).toBeGreaterThan(0.85);
|
||||
}
|
||||
});
|
||||
|
||||
// THE BOUND, asserted so the mechanism is not mistaken for something it is
|
||||
// not. A bare parameter has no binding here — typing it needs the CALLER's
|
||||
// type to flow in, which is inter-procedural — so it falls through to name
|
||||
// inference, which refuses because two producers share the name.
|
||||
it('leaves an unbound receiver to name inference, which refuses it', () => {
|
||||
expect(targetOf('readsUnbound')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue