mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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:
parent
152a0ee201
commit
64150a98cf
5 changed files with 114 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
38
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js
vendored
Normal file
38
gitnexus/test/fixtures/lang-resolution/javascript-object-properties/frozen.js
vendored
Normal 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 });
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue