feat(scope-resolution): index literals behind identity-preserving wrappers

`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) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-06 19:54:46 +03:00
parent 152a0ee201
commit 64150a98cf
5 changed files with 114 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

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 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)', () => {