From 64150a98cf2999f5415dda91813a3189e5fa0538 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Thu, 6 Aug 2026 19:54:46 +0300 Subject: [PATCH] feat(scope-resolution): index literals behind identity-preserving wrappers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export const INERT_EXIT_CONTRACT = Object.freeze({ ... })` minted no `Property` node for any of its keys. The object-literal rule matches `variable_declarator > value: (object)` as a DIRECT child, and freezing puts a call expression in between — so the shape whose fields are most worth querying was the one shape the rule could not see. Freezing a config object is how JS publishes an immutable contract, which is why this reads as a confident zero on exactly the fields a reader cares about. The allowlist is three functions, not "any call". `Object.freeze`, `seal` and `preventExtensions` RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes the literal's keys members of the bound name. For `const x = compute({ a: 1 })` the literal is an argument and `x` holds compute's return value, so attributing `a` to `x` would be a fabrication. Two negative controls, because the obvious one is vacuous: a bare-identifier callee is rejected structurally and would pass with no allowlist at all, so the assertion that actually pins the predicate uses `Object.entries` — identical shape, differing only by name. Verified load-bearing by adding `entries` to the allowlist and watching that test alone fail. SCHEMA_BUMP 46 -> 47: parse-time emission, so a warm cache replays the pre-fix capture set. Observed as a false negative first — `analyze --force` returned the old node set until the on-disk cache was removed by hand. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/core/ingestion/tree-sitter-queries.ts | 27 +++++++++++++ gitnexus/src/storage/parse-cache.ts | 13 ++++++- .../javascript-object-properties/frozen.js | 38 +++++++++++++++++++ .../javascript-object-properties.test.ts | 35 +++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 4 +- 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 94f495cec..d3f8ed297 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -877,6 +877,33 @@ export const JAVASCRIPT_QUERIES = ` (pair key: (property_identifier) @name) @definition.property)) +; Same named shape, behind an IDENTITY-PRESERVING wrapper (R2-1a): +; +; export const INERT_EXIT_CONTRACT = Object.freeze({ exitModel: 'bracket', ... }); +; +; Freezing a config object is the idiomatic way to publish an immutable +; contract, so the fields most worth querying are exactly the ones a bare +; "value: (object)" pattern cannot see — one call expression sits between the +; declarator and the literal. +; +; The allowlist is deliberately three functions rather than "any call". Only +; these RETURN THE ARGUMENT THEY WERE GIVEN, which is what makes the literal's +; keys members of the bound name. For an arbitrary "const x = compute({a: 1})" +; the literal is an argument and x is compute's return value, so attributing +; "a" to x would be a fabrication. +(variable_declarator + name: (identifier) + value: (call_expression + function: (member_expression + object: (identifier) @_identity.obj + property: (property_identifier) @_identity.fn) + arguments: (arguments + (object + (pair + key: (property_identifier) @name) @definition.property))) + (#eq? @_identity.obj "Object") + (#match? @_identity.fn "^(freeze|seal|preventExtensions)$")) + ; Closure-valued class fields (#2693) — see the TypeScript block for why these ; are Method rather than Property. (field_definition diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 90cc24b4f..a567b4962 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -266,7 +266,18 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // 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; +// +// 46 -> 47 for the round-2 capture work: object literals behind an +// identity-preserving wrapper (`const X = Object.freeze({ ... })`) now mint +// `@definition.property` for their keys. Parse-time like every entry above, and +// this one was ALSO observed as a false negative first: `analyze --force` +// against a fixture carrying the new shape returned the pre-change node set and +// read as "the query does not match", until the on-disk cache was removed by +// hand and the same run produced the node. `--force` re-runs the pipeline but +// still serves ParsedFiles from the durable store, so it does not substitute +// for this bump. +// RE-CHECK AGAINST origin/main IMMEDIATELY BEFORE MERGING. +const SCHEMA_BUMP = 47; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js new file mode 100644 index 000000000..3b6c6da3a --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js @@ -0,0 +1,38 @@ +// R2-1a: a named shape published behind an IDENTITY-PRESERVING wrapper. +// +// Freezing a config object is the idiomatic way to publish an immutable +// contract, so the fields most worth querying are exactly the ones the bare +// `value: (object)` rule cannot see — one call expression sits between the +// declarator and the literal. +export const INERT_EXIT_CONTRACT = Object.freeze({ + frozenExitModel: 'bracket', + frozenMaxHoldMs: 0, +}); + +export const SEALED_LIMITS = Object.seal({ + sealedMaxNotional: 100, +}); + +export function readsFrozen() { + return INERT_EXIT_CONTRACT.frozenMaxHoldMs; +} + +// NEGATIVE CONTROL. `buildRules` returns a value of its OWN making, so the +// literal here is an argument, not the thing `derivedRules` is bound to. +// Attributing `notAMemberOfDerived` to `derivedRules` would be a fabrication, +// which is why the wrapper allowlist is three identity functions and not +// "any call expression". +function buildRules(seed) { + return { ...seed, extra: true }; +} + +export const derivedRules = buildRules({ notAMemberOfDerived: 1 }); + +// SECOND NEGATIVE CONTROL, and the one that actually exercises the allowlist. +// The control above is rejected STRUCTURALLY (a bare identifier callee never +// matches `function: (member_expression ...)`), so it would pass even if the +// wrapper predicate were dropped entirely. `Object.entries` has the same shape +// as `Object.freeze` and differs ONLY by name — it transforms its argument +// into an array of pairs rather than returning it — so this is the case that +// fails the moment the name check stops being enforced. +export const entryPairs = Object.entries({ notAMemberOfEntries: 1 }); diff --git a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts index 0c142f279..48e6387db 100644 --- a/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts +++ b/gitnexus/test/integration/resolvers/javascript-object-properties.test.ts @@ -103,6 +103,41 @@ describe('JavaScript plain-object property access (A1/A5)', () => { expect(inferred.length).toBeGreaterThan(0); for (const e of inferred) expect(e.rel.confidence).toBeLessThan(0.85); }); + + // R2-1a. Reported as the cheapest remaining win and it is: freezing a config + // object is how JS publishes an immutable contract, so the shape whose fields + // are most worth querying was the one shape the rule could not see. + describe('identity-preserving wrappers (R2-1a)', () => { + it('indexes keys of a literal wrapped in Object.freeze', () => { + const props = propertyNames(); + expect(props).toContain('frozenExitModel'); + expect(props).toContain('frozenMaxHoldMs'); + }); + + it('indexes keys wrapped in Object.seal', () => { + expect(propertyNames()).toContain('sealedMaxNotional'); + }); + + it('resolves a read through the frozen binding', () => { + expect(readersOf('frozenMaxHoldMs')).toContain('readsFrozen'); + }); + + // The bound of the fix. Only freeze/seal/preventExtensions return the + // argument they were given; for any other call the literal is an argument + // and the binding holds the callee's return value, so minting members here + // would attribute fields to an object that does not have them. + it('does NOT index a literal passed to a non-identity call', () => { + expect(propertyNames()).not.toContain('notAMemberOfDerived'); + }); + + // The case above is rejected structurally (identifier callee), so it holds + // even with no allowlist at all. `Object.entries` differs from + // `Object.freeze` by name alone, so this is the assertion that actually + // pins the predicate. + it('does NOT index Object.entries — same shape, non-identity name', () => { + expect(propertyNames()).not.toContain('notAMemberOfEntries'); + }); + }); }); interface PropNode { diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index d23a35c5e..7cb83ff7a 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -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 46 so concurrent bumps cannot silently collide (#2766)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(46); + it('pins SCHEMA_BUMP to 47 so concurrent bumps cannot silently collide (#2766)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(47); }); it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {