feat(typescript): index object-literal keys, as JavaScript already did

The sibling recorded in `0c5a4f64` and deliberately left out of it. Both the
named object-literal rule (A1/A5) and the identity-wrapper rule (R2-1a) lived
only in JAVASCRIPT_QUERIES, so the single most common config idiom in
TypeScript —

    export const tsRuntimeConfig = { tsConfigRetries: 3 };

— minted no node for any key. `context()` answered "Symbol not found" and a
precise read through the holding variable had nothing to resolve to.

TypeScript sets `fieldFallbackOnMethodLookup: false`, so these gain no
name-based inference. What they gain is the PRECISE path, which is the route
TypeScript is meant to use: `tsRuntimeConfig.tsConfigRetries` has a typeable
receiver and now resolves. A read through an untyped receiver stays unresolved
and, since `0c5a4f64`, is reported as such rather than answering an empty set.

Scoped exactly as the JavaScript rules are — bound to a variable, and for the
wrapper only the three functions that return the argument they were given —
with the same `Object.entries` negative control pinning the allowlist.

Found by fixture, not by report: the first draft of the `reportOnly` test used
a TS `const CONFIG = { ... }` as its discriminator and could not discriminate,
because the shape mints nothing. That is the whole argument for sweeping a
class instead of waiting for each instance to be filed.

SCHEMA_BUMP 47 -> 48: parse-time, so a warm cache replays ParsedFiles carrying
none of these matches and the keys stay invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ReidenXerx 2026-08-07 02:46:56 +03:00
parent 0c5a4f64bf
commit 8972d223a9
6 changed files with 128 additions and 3 deletions

View file

@ -517,6 +517,28 @@ export const TYPESCRIPT_SCOPE_QUERY = `
(property_signature
name: (property_identifier) @declaration.name) @declaration.property)
;; Object-literal keys of a NAMED object the scope-resolution half of the
;; matching rule in TYPESCRIPT_QUERIES. The parse query mints the Property NODE;
;; this mints the DEF a precise read can resolve to.
(variable_declarator
name: (identifier)
value: (object
(pair
key: (property_identifier) @declaration.name) @declaration.property))
(variable_declarator
name: (identifier)
value: (call_expression
function: (member_expression
object: (identifier) @_ts.identity.obj
property: (property_identifier) @_ts.identity.fn)
arguments: (arguments
(object
(pair
key: (property_identifier) @declaration.name) @declaration.property)))
(#eq? @_ts.identity.obj "Object")
(#match? @_ts.identity.fn "^(freeze|seal|preventExtensions)$"))
(type_alias_declaration
value: (object_type
(property_signature

View file

@ -404,6 +404,39 @@ export const TYPESCRIPT_QUERIES = `
(public_field_definition
name: (property_identifier) @name) @definition.property
; Object-literal keys of a NAMED object, and the same shape behind an
; identity-preserving wrapper. Both rules existed only in JAVASCRIPT_QUERIES, so
; a .ts file writing the single most common config idiom in the language
; const CONFIG = { retries: 3 } minted no node for any key: context() answered
; "Symbol not found" and a precise read through the holding variable had nothing
; to resolve to.
;
; TypeScript sets fieldFallbackOnMethodLookup:false, so these do NOT gain
; name-based inference; they gain the PRECISE path, which is the one TypeScript
; is supposed to use. A read through an untyped receiver stays unresolved, and
; is now reported as such rather than answering an empty set.
;
; Scoped exactly as the JavaScript rules are: bound to a variable, and for the
; wrapper only the three functions that return the argument they were given.
(variable_declarator
name: (identifier)
value: (object
(pair
key: (property_identifier) @name) @definition.property))
(variable_declarator
name: (identifier)
value: (call_expression
function: (member_expression
object: (identifier) @_ts.identity.obj
property: (property_identifier) @_ts.identity.fn)
arguments: (arguments
(object
(pair
key: (property_identifier) @name) @definition.property)))
(#eq? @_ts.identity.obj "Object")
(#match? @_ts.identity.fn "^(freeze|seal|preventExtensions)$"))
; Private class fields: #address: Address
(public_field_definition
name: (private_property_identifier) @name) @definition.property

View file

@ -277,7 +277,19 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j
// 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;
//
// 47 -> 48 for the TypeScript object-literal captures (R3-3): named
// object-literal keys and the identity-wrapper form now mint `@definition.property`
// in TYPESCRIPT_QUERIES, as they already did for JavaScript. Parse-time, so a
// warm cache would replay ParsedFiles carrying none of those matches and the
// keys would stay invisible.
//
// #2860 adds a CI check comparing this against the base branch — the merge-time
// re-check this ledger has asked for by hand across ten entries and four exact
// 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;
const GITNEXUS_PKG_VERSION = (() => {
try {
// package.json sits at gitnexus/package.json — two levels up from

View file

@ -0,0 +1,24 @@
// R3-3: the single most common config idiom in TypeScript. Both the named
// object-literal rule and the identity-wrapper rule were JAVASCRIPT_QUERIES
// only, so none of these keys minted a node: `context()` answered "Symbol not
// found" and a precise read through the holding variable had nothing to
// resolve to.
export const tsRuntimeConfig = {
tsConfigRetries: 3,
tsConfigTimeoutMs: 500,
};
export const TS_FROZEN_LIMITS = Object.freeze({
tsFrozenMaxNotional: 100,
});
// The PRECISE path — the one TypeScript is supposed to use. The receiver is
// the holding variable, so this needs no name inference.
export function readsTsConfig(): number {
return tsRuntimeConfig.tsConfigRetries + TS_FROZEN_LIMITS.tsFrozenMaxNotional;
}
// NEGATIVE CONTROL, same allowlist bound as the JavaScript rule: a non-identity
// call returns a value of its own, so the literal's keys are arguments rather
// than members of the binding.
export const tsMapped = Object.entries({ tsNotAMember: 1 });

View file

@ -136,6 +136,40 @@ describe('TypeScript type-alias and interface members (A4)', () => {
});
});
// R3-3. Found while building a fixture for the opt-out reporting change, not
// from a report: the object-literal rules were JavaScript-only, so the most
// common config idiom in TypeScript had invisible keys.
describe('object-literal keys in TypeScript (R3-3)', () => {
const names = (): string[] =>
Array.from(
(result as unknown as { graph: { iterNodes(): Iterable<PropNode> } }).graph.iterNodes(),
)
.filter((n) => n.label === 'Property')
.map((n) => String(n.properties.name));
it('indexes keys of a named object literal', () => {
expect(names()).toContain('tsConfigRetries');
expect(names()).toContain('tsConfigTimeoutMs');
});
it('indexes keys behind an identity-preserving wrapper', () => {
expect(names()).toContain('tsFrozenMaxNotional');
});
// The half that matters for TypeScript specifically. It opts out of
// name inference, so the value of minting these nodes is that the PRECISE
// path — a read through the holding variable — now has something to
// resolve to.
it('resolves a precise read through the holding variable', () => {
expect(readersOf('tsConfigRetries')).toContain('readsTsConfig');
expect(readersOf('tsFrozenMaxNotional')).toContain('readsTsConfig');
});
it('keeps the same allowlist bound as the JavaScript rule', () => {
expect(names()).not.toContain('tsNotAMember');
});
});
describe('type consumers (R2-2)', () => {
const usersOf = (typeName: string): string[] =>
getRelationships(result, 'USES')

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 47 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(47);
it('pins SCHEMA_BUMP to 48 so concurrent bumps cannot silently collide (#2766)', () => {
expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(48);
});
it('embeds the gitnexus package version (so upgrades invalidate the cache)', () => {