From 8972d223a9823671fa44c719c162c6096fe36939 Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Fri, 7 Aug 2026 02:46:56 +0300 Subject: [PATCH] feat(typescript): index object-literal keys, as JavaScript already did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ingestion/languages/typescript/query.ts | 22 ++++++++++++ .../src/core/ingestion/tree-sitter-queries.ts | 33 ++++++++++++++++++ gitnexus/src/storage/parse-cache.ts | 14 +++++++- .../typescript-alias-fields/config.ts | 24 +++++++++++++ .../resolvers/typescript-alias-fields.test.ts | 34 +++++++++++++++++++ .../test/unit/incremental-parse-cache.test.ts | 4 +-- 6 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts diff --git a/gitnexus/src/core/ingestion/languages/typescript/query.ts b/gitnexus/src/core/ingestion/languages/typescript/query.ts index 2a530e77a..afa1150a3 100644 --- a/gitnexus/src/core/ingestion/languages/typescript/query.ts +++ b/gitnexus/src/core/ingestion/languages/typescript/query.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 6929415e2..f2655c4a8 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index a567b4962..c798f97e5 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -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 diff --git a/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts new file mode 100644 index 000000000..4e9dd1b39 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/typescript-alias-fields/config.ts @@ -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 }); diff --git a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts index 80e68e4bf..52bcbc9df 100644 --- a/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts +++ b/gitnexus/test/integration/resolvers/typescript-alias-fields.test.ts @@ -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 } }).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') diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index 7cb83ff7a..c8c22b5e4 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 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)', () => {