feat(javascript): emit ACCESSES for bare-identifier reads of module-scope consts

A constant read only as a bare identifier — `Math.max(LIMIT, n)`, a default
parameter value, `return LIMIT` — minted no reference site at all, because
JS captured only `@reference.read.member`, which requires a receiver a bare
identifier does not have. So "who uses this constant?", the question behind
every dead-code trim and constants refactor, answered with a confident zero
in both directions.

The rest of the machinery was already in place: `FIELD_KINDS` accepts
`Const`, the scope query already declares it via `@declaration.const`, and
`read` maps to ACCESSES for any resolved target. This adds the missing
capture in VALUE POSITIONS ONLY (call arguments, default-parameter values,
return statements) — a blanket `(identifier)` rule would mint a site for
every token in the file, which is unaffordable at repo scale and would keep
alive the block-local symbols `pruneLocalSymbols` exists to drop.

Cross-file readers are NOT yet covered: the site exists and a call through
the same import statement resolves, but a value-kind def does not link
across the import edge. Recorded as a todo with the investigation.

PARSE_CACHE_VERSION bumped 44 -> 45: this is parse-time capture emission, so
a warm cache replays the pre-change capture set and the new edges never
appear — observed directly, a full `analyze --force` produced a
byte-identical graph until the cache was cleared by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 00:00:51 +03:00
parent 8ddb9c1d2a
commit be75ee373f
5 changed files with 125 additions and 1 deletions

View file

@ -589,6 +589,17 @@ export const JAVASCRIPT_SCOPE_QUERY = `
(object
(shorthand_property_identifier) @reference.name @reference.property-key @reference.value-ref)
;; Bare-identifier reads (A2). VALUE POSITIONS ONLY — a blanket
;; \`(identifier)\` rule would mint a site for every token in the file.
(arguments
(identifier) @reference.name @reference.read.identifier)
(assignment_pattern
right: (identifier) @reference.name @reference.read.identifier)
(return_statement
(identifier) @reference.name @reference.read.identifier)
`;
/** JSX-only suffix — appended when compiling against the JSX grammar for .jsx files. */

View file

@ -248,7 +248,25 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// them. Only comparing against origin/main at MERGE time surfaces it.
// PR #2840 (Objective-C, draft) still claims 44 as well — it must move too.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 45;
// 45 -> 46 for the JavaScript bare-identifier read captures (A2), which emit
// `@reference.read.identifier` in value positions (call arguments,
// default-parameter values, return statements) so a module-scope `const` read
// only by bare name finally mints a reference site, plus the object-literal
// `@definition.property` rule and the TypeScript shape-member captures. All
// PARSE-TIME emission, so a warm cache serves entries carrying none of those
// matches and the new nodes and edges never appear — observed directly while
// developing: a full `analyze --force` produced a byte-identical graph and read
// as a failed hypothesis until the cache was cleared by hand.
//
// This branch originally took 45 and it COLLIDED: #2837 above merged first and
// claimed it. The TENTH entry in this ledger and the FOURTH exact clash, caught
// exactly as the note above says it must be — by comparing against origin/main
// at merge time, not at review time. The pin test cannot catch it: both sides
// asserted `toBe(45)`, which passes while main is already 45, so two capture
// schemas would have shared one PARSE_CACHE_VERSION and the durable ParsedFile
// store would have replayed pre-fix ParsedFiles verbatim for one of them.
// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING.
const SCHEMA_BUMP = 46;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,15 @@
// A2: a module-scope const referenced only as a bare identifier.
const DEFAULT_FETCH_LIMIT = 500;
// Inline-exported form — the common spelling.
export const INLINE_LIMIT = 250;
export function fetchAll(limit = DEFAULT_FETCH_LIMIT) {
return Math.max(DEFAULT_FETCH_LIMIT, limit);
}
export function pageSize() {
return DEFAULT_FETCH_LIMIT;
}
export { DEFAULT_FETCH_LIMIT };

View file

@ -0,0 +1,16 @@
import { DEFAULT_FETCH_LIMIT, INLINE_LIMIT, pageSize } from './config.js';
// A2 cross-file: a named-import reference to a module-scope const.
export function consumerLimit() {
return DEFAULT_FETCH_LIMIT;
}
// Control: same shape, but the const was exported inline.
export function consumerInline() {
return INLINE_LIMIT;
}
// Control: a cross-file CALL through the same import statement resolves today.
export function consumerCall() {
return pageSize();
}

View file

@ -0,0 +1,64 @@
/**
* A2 — references to a module-scope `const` must produce edges.
*
* A constant read only as a BARE IDENTIFIER (`Math.max(LIMIT, n)`, a default
* parameter value, `return LIMIT`) produced no reference site at all, so
* "who uses this constant?" — the question behind every dead-code trim and
* constants refactor — answered with a confident zero rather than "unknown".
*
* The registries already accept it (`FIELD_KINDS` includes `Const`) and the
* scope query already declares it (`@declaration.const`), so this is about the
* reference SITE existing: JS/TS captured only `@reference.read.member`, which
* requires a receiver a bare identifier does not have.
*/
import { describe, it, expect, beforeAll } from 'vitest';
import path from 'path';
import { FIXTURES, getRelationships, runPipelineFromRepo, type PipelineResult } from './helpers.js';
describe('JavaScript module-scope const references (A2)', () => {
let result: PipelineResult;
beforeAll(async () => {
result = await runPipelineFromRepo(
path.join(FIXTURES, 'javascript-const-references'),
() => {},
);
}, 60000);
const readersOfConst = (): Set<string> =>
new Set(
getRelationships(result, 'ACCESSES')
.filter((e) => e.target === 'DEFAULT_FETCH_LIMIT')
.map((e) => e.source),
);
it('emits ACCESSES from same-file readers of the const', () => {
const readers = readersOfConst();
// fetchAll reads it twice (default param + Math.max); pageSize returns it.
expect(readers).toContain('fetchAll');
expect(readers).toContain('pageSize');
});
// Cross-file is NOT yet covered. The reference site exists (the capture
// fires on `return DEFAULT_FETCH_LIMIT` in consumer.js, verified against the
// raw query) and a CALL through the very same import statement resolves
// (`consumerCall → pageSize`, reason `import-resolved`), so the gap is
// specific to linking a value-kind def across the import edge. Prime
// suspect: exported-def resolution is callable-only — `findExportedDefByName`
// returns a def only when `def.type` is `Function`/`Method`
// (scope/walkers.ts:1323) and its workspace fallback index is
// `exportedCallableByName`. Both export spellings (`export const X` and
// `export { X }`) fail identically, so it is not the export syntax.
it.todo('emits an edge for the cross-file named-import reader');
it('targets the Const node itself, not a same-named local', () => {
const toConst = getRelationships(result, 'ACCESSES').filter(
(e) => e.target === 'DEFAULT_FETCH_LIMIT',
);
expect(toConst.length).toBeGreaterThan(0);
for (const e of toConst) {
expect(e.targetLabel).toBe('Const');
expect(e.targetFilePath).toContain('config.js');
}
});
});