From b6375b349133819639b4d92782515645d783c4dc Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 04:45:22 -0700 Subject: [PATCH 1/6] TypeScript: each key of a module-level const object literal is a declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export const TOPICS = { orderEvents: '…' } as const` names topics, routes, event types and DI tokens, and `TOPICS.orderEvents` is how every user reads one. The keys were declared nowhere: `impact TOPICS.orderEvents` answered "nothing named", and the readers of one key could not be told from another's. - parser: an unannotated module-level `const` whose value is an object literal (through `as const`, `satisfies`, parentheses, `Object.freeze`) emits each data key as an OBJECT_LITERAL_PROPERTY field owned by the variable; a nested literal's keys are owned by the key that holds it. Function-valued keys stay methods. - field_access.dl: `X.a` resolves to that field when X is the variable, an import of it, a namespace import's export of it, or a key of an outer table, and only when the typed lookup found nothing (`satisfies T` keeps T's member). A read inside a method's decorator is that method's. - index: a field whose owner is no type is displayed after the module (`ROUTES.admin.users`). - impact.dl: for a module-owned key, a line declaring another field of the same name is not a read (two route tables in one file with the same key). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../78-cross-process-destinations.fields | 2 +- .../80-object-literal-member-receivers.fields | 6 +- .../call-edge-generation/field_access.dl | 42 ++++- graph/typescript/souffle/decls_all.dl | 5 + .../extractors/ts-declaration-extractor.ts | 157 ++++++++++++++++++ .../skills/axiomcode/scripts/axiomcode-index | 9 +- .../skills/axiomcode/scripts/dl/impact.dl | 7 +- .../typescript/const-object-table/case.json | 48 ++++++ .../typescript/const-object-table/src/a.ts | 13 ++ .../typescript/const-object-table/src/b.ts | 9 + .../typescript/const-object-table/src/c.ts | 9 + .../typescript/const-object-table/src/d.ts | 12 ++ .../typescript/const-object-table/src/k.ts | 19 +++ .../const-object-table/src/settings.ts | 7 + 14 files changed, 335 insertions(+), 10 deletions(-) create mode 100644 tests/cases/typescript/const-object-table/case.json create mode 100644 tests/cases/typescript/const-object-table/src/a.ts create mode 100644 tests/cases/typescript/const-object-table/src/b.ts create mode 100644 tests/cases/typescript/const-object-table/src/c.ts create mode 100644 tests/cases/typescript/const-object-table/src/d.ts create mode 100644 tests/cases/typescript/const-object-table/src/k.ts create mode 100644 tests/cases/typescript/const-object-table/src/settings.ts diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields index 4fea0459..d624a5ae 100644 --- a/graph/test/typescript/expected/78-cross-process-destinations.fields +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields @@ -1 +1 @@ -ambiguous_unknown read client/client#dailyReport(string) -> - +known_edge read client/client#dailyReport(string) -> config#reportsUrl diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields b/graph/test/typescript/expected/80-object-literal-member-receivers.fields index 4b531045..179c701f 100644 --- a/graph/test/typescript/expected/80-object-literal-member-receivers.fields +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields @@ -1,3 +1,3 @@ -ambiguous_unknown read local#drive() -> - -ambiguous_unknown read local#trimName() -> - -ambiguous_unknown read use#callNested() -> - +known_edge read local#drive() -> handlers#jobs +known_edge read local#trimName() -> cfg#name +known_edge read use#callNested() -> api#users diff --git a/graph/typescript/engine/call-edge-generation/field_access.dl b/graph/typescript/engine/call-edge-generation/field_access.dl index d7d0057f..6ea4406d 100644 --- a/graph/typescript/engine/call-edge-generation/field_access.dl +++ b/graph/typescript/engine/call-edge-generation/field_access.dl @@ -86,18 +86,43 @@ field_site_excluded(e, "method_value") :- ts_field_site_is_method(e), !expr_kind("client", "PROPERTY_ACCESS", "METHOD_NAME", e). // ── RESOLUTION — the three receiver-typing paths expr-type.dl already has ─── -ts_field_access_target(e, f) :- property_access_name(e, n), +ts_field_access_typed(e, f) :- property_access_name(e, n), expr_child("client", e, "RECEIVER", _, q), expr_type(q, _, qt), field_in_scope(qt, n, "false", f). -ts_field_access_target(e, f) :- property_access_name(e, n), +ts_field_access_typed(e, f) :- property_access_name(e, n), expr_child("client", e, "RECEIVER", _, q), expr_static_type(q, _, qt), field_in_scope(qt, n, "true", f). -ts_field_access_target(e, f) :- property_access_name(e, n), +ts_field_access_typed(e, f) :- property_access_name(e, n), expr_child("client", e, "RECEIVER", _, q), expr_shape(q, qs), field_in_scope(qs, n, _, f). +ts_field_access_target(e, f) :- ts_field_access_typed(e, f). +ts_field_access_is_typed(e) :- ts_field_access_typed(e, _). + +// ── A KEY OF A MODULE-LEVEL CONST TABLE — `TOPICS.orderEvents` ────────────── +// `export const TOPICS = { orderEvents: '…' } as const`: the parser declares each key +// as an OBJECT_LITERAL_PROPERTY owned by the VARIABLE (a nested literal's keys by the +// property holding it). The owner is no type, so none of the three joins above can +// reach it; the receiver names the owner instead — the variable itself, an import of +// it, a namespace import's export of it, or a key of an outer table. Only where the +// typed lookup found nothing: `{ … } satisfies Config` is read as Config's member. +ts_objlit_owner(q, v) :- expr_referenced("client", "VARIABLE", v, q). +ts_objlit_owner(q, v) :- expr_referenced("client", "IMPORT_BINDING", ih, q), + import_binds(ih, _, "VARIABLE", v). +ts_objlit_owner(q, v) :- property_access_name(q, n), + expr_child("client", q, "RECEIVER", _, r), + expr_namespace(r, _, m), + module_export(_, m, n, "VARIABLE", v). +ts_objlit_owner(q, pf) :- ts_field_access_target(q, pf), + field_kind("client", "OBJECT_LITERAL_PROPERTY", _, _, pf). +ts_field_access_target(e, f) :- property_access_name(e, n), + expr_child("client", e, "RECEIVER", _, q), + ts_objlit_owner(q, o), + field_decl("client", n, _, o, f), + field_kind("client", "OBJECT_LITERAL_PROPERTY", _, _, f), + !ts_field_access_is_typed(e). // ── ACCESS DIRECTION ──────────────────────────────────────────────────────── // property_written and property_write_only are accessors.dl's, and are reused rather @@ -124,8 +149,17 @@ ts_field_access_class(e, "ambiguous_unknown") :- ts_field_access_count(e, 0). // The enclosing function of the ACCESS, by expr_enclosing_method, exactly as accessors.dl // attributes an accessor invocation: there is no call site to attribute through. Top-level // code is the module initializer, which that relation already supplies. +// A read inside a METHOD'S DECORATOR is that method's: `@EventPattern(TOPICS.orderEvents)` +// binds the handler to the topic, and the module initializer that evaluates the +// decorator is no user of it. A decorator's expressions carry no owner hash, so the +// subtree is walked down from the decorator's own expression. +ts_deco_expr_method(e, m) :- decorator_expr("client", e, d), decorator_target("client", _, m, d), m != "". +ts_deco_expr_method(c, m) :- ts_deco_expr_method(p, m), expr_child("client", p, _, _, c). +ts_field_access_in_decorator(e) :- ts_deco_expr_method(e, _). ts_field_access_named(e) :- expr_enclosing_method(e, m), m != "". -ts_field_access_from(e, m) :- ts_field_access_site(e), expr_enclosing_method(e, m), m != "". +ts_field_access_from(e, m) :- ts_field_access_site(e), ts_deco_expr_method(e, m). +ts_field_access_from(e, m) :- ts_field_access_site(e), expr_enclosing_method(e, m), m != "", + !ts_field_access_in_decorator(e). // ...and the MODULE itself for the residue expr_enclosing_method cannot place, which is // the marker call_sites.caller_id already uses in TypeScript rather than a blank. Never // NULL, so a site can always be attributed to the code that contains it. diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 467f2e70..570577c0 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -631,12 +631,17 @@ .decl ts_field_site_is_method(c0:symbol) .decl field_site_excluded(c0:symbol,c1:symbol) .decl ts_field_access_target(c0:symbol,c1:symbol) +.decl ts_field_access_typed(c0:symbol,c1:symbol) +.decl ts_field_access_is_typed(c0:symbol) +.decl ts_objlit_owner(c0:symbol,c1:symbol) .decl ts_field_access_kind(c0:symbol,c1:symbol) .decl ts_field_access_count(c0:symbol,c1:number) .decl ts_field_prov(c0:symbol,c1:symbol) .decl ts_field_site_leaves_client(c0:symbol) .decl ts_field_access_class(c0:symbol,c1:symbol) .decl ts_field_access_named(c0:symbol) +.decl ts_deco_expr_method(c0:symbol,c1:symbol) +.decl ts_field_access_in_decorator(c0:symbol) .decl ts_field_access_from(c0:symbol,c1:symbol) .decl ts_field_access_has_row(c0:symbol) .decl field_access(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) diff --git a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts index 4a816c7f..ee852230 100644 --- a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts @@ -2116,6 +2116,86 @@ export class TsDeclarationExtractor { } } + /** + * Each data property of a module-level const's object literal, as a field of + * the const: `OBJECT_LITERAL_PROPERTY`, owned by the VARIABLE row (or, for a + * nested literal, by the property that holds it). The owner FK's prefix is + * neither `TS_TYPE_` nor `TS_TYPE_REFERENCE_`, so no member-lookup rule joins + * it; field_access.dl reaches it through the receiver's variable instead. + * + * A function-valued property and a method are left alone: they are already + * methods, named `TOPICS.f` through the variable they were assigned into. + */ + private emitObjectLiteralMembers( + literal: ts.ObjectLiteralExpression, + ownerHash: string, + ownerName: string, + ownerQualifiedName: string, + isReadonly: boolean, + context: EmitContext + ): void { + for (const prop of literal.properties) { + if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) { + continue; + } + const name = literalKeyOf(prop.name); + if (name === undefined) { + continue; + } + const value = ts.isPropertyAssignment(prop) ? prop.initializer : undefined; + if (value !== undefined && isFunctionValue(value)) { + continue; + } + const startPos = this.sf.getLineAndCharacterOfPosition(prop.getStart(this.sf)); + const endPos = this.sf.getLineAndCharacterOfPosition(prop.end); + const row = new TsFieldRegistry({ + name, + fieldTypeName: '', + fieldBaseType: '', + potentialQualifiedName: '', + isAmbiguous: false, + filePath: this.options.filePath, + startLine: startPos.line + 1, + endLine: endPos.line + 1, + tsTypeLinkHash: ownerHash, + ownerTypeName: ownerName, + ownerQualifiedName, + fieldAccess: TsFieldAccess.PUBLIC_ACCESS, + fieldModifiers: new Set(isReadonly ? [TsFieldModifier.READONLY] : []), + memberKind: TsMemberKind.OBJECT_LITERAL_PROPERTY, + tsModuleLinkHash: context.moduleHash, + isOptional: false, + hasDefiniteAssignment: false, + isReadonly, + isStatic: false, + indexKeyTypeName: '', + isTypeOnly: false, + memberGroupKey: EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.TS_DECLARATION_GROUP, + `${ownerHash}||${name}||false` + ), + startColumn: startPos.character + 1, + endColumn: endPos.character + 1, + serviceVersionLinkHash: this.options.serviceVersionLinkHash, + }); + this.fields.push(row); + this.recordFieldPosition(ownerHash, row.getHash()); + if (value === undefined) { + continue; + } + this.pendingExpressionLinks.push({ + node: value, + link: (hash) => row.setInitializerExpressionLinkHash(hash), + }); + // `ROUTES.orders.list`: a literal inside the table is a table of its own + const inner = objectTableOf(value); + if (inner !== undefined) { + this.emitObjectLiteralMembers(inner.literal, row.getHash(), name, + `${ownerQualifiedName}.${name}`, isReadonly || inner.isReadonly, context); + } + } + } + emitVariable( declaration: ts.VariableDeclaration, list: ts.VariableDeclarationList | undefined, @@ -2192,6 +2272,17 @@ export class TsDeclarationExtractor { node: initializer, link: (hash) => row.setInitializerExpressionLinkHash(hash), }); + // `export const TOPICS = { orderEvents: 'orders.v1', … } as const` is how a + // program names its topics, routes and event types, and `TOPICS.orderEvents` + // is how every user reads one. Its keys were declared nowhere, so the users of + // one key could not be told from the users of another. + const table = constObjectTableOf(declaration, list, context); + if (table !== undefined) { + const name = (declaration.name as ts.Identifier).text; + this.emitObjectLiteralMembers(table.literal, row.getHash(), name, + `${context.moduleQualifiedName}#${[...context.namePath, name].join('.')}`, + table.isReadonly, context); + } // Each bound name gets the SAME initializer, because it is the same value: // `const { a } = ctx()` reads `a` out of what `ctx()` returned. Without it a // bound name is a declaration with a property name and nothing to apply it @@ -3533,6 +3624,72 @@ function variableScopeKindOf( return TsVariableScopeKind.MODULE_SCOPE; } +/** + * The object literal a module-level `const X = …` holds as a table of named + * values, through `as const`, `satisfies T`, parentheses and `Object.freeze(…)`. + * Only a `const` bound to a plain name: a `let` can be reassigned, and a + * destructuring has no one name to own the keys. Not an annotated one either: + * `const c: Config = { … }` has Config's members, which the compiler names. + */ +function constObjectTableOf( + declaration: ts.VariableDeclaration, + list: ts.VariableDeclarationList | undefined, + context: EmitContext +): { literal: ts.ObjectLiteralExpression; isReadonly: boolean } | undefined { + if (!list || (list.flags & ts.NodeFlags.Const) === 0 || !ts.isIdentifier(declaration.name) + || declaration.initializer === undefined || declaration.type !== undefined) { + return undefined; + } + const scope = variableScopeKindOf(context, declaration); + if (scope !== TsVariableScopeKind.MODULE_SCOPE && scope !== TsVariableScopeKind.NAMESPACE_SCOPE) { + return undefined; + } + return objectTableOf(declaration.initializer); +} + +function objectTableOf( + node: ts.Expression +): { literal: ts.ObjectLiteralExpression; isReadonly: boolean } | undefined { + let isReadonly = false; + for (;;) { + if (ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) { + node = node.expression; + } else if (ts.isAsExpression(node) && ts.isConstTypeReference(node.type)) { + isReadonly = true; + node = node.expression; + } else if (ts.isCallExpression(node) && node.arguments.length === 1 + && ts.isPropertyAccessExpression(node.expression) + && ts.isIdentifier(node.expression.expression) + && node.expression.expression.text === 'Object' && node.expression.name.text === 'freeze') { + isReadonly = true; + node = node.arguments[0]!; + } else { + break; + } + } + return ts.isObjectLiteralExpression(node) ? { literal: node, isReadonly } : undefined; +} + +/** A key written as a name, a string or a number; a computed key is not a declaration. */ +function literalKeyOf(name: ts.PropertyName): string | undefined { + if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name) + || ts.isNoSubstitutionTemplateLiteral(name)) { + return name.text; + } + if (ts.isComputedPropertyName(name) && ts.isStringLiteralLike(name.expression)) { + return name.expression.text; + } + return undefined; +} + +function isFunctionValue(node: ts.Expression): boolean { + while (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) + || ts.isSatisfiesExpression(node)) { + node = node.expression; + } + return ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isClassExpression(node); +} + /** * The arrow or function expression an initializer holds under parentheses and type * assertions (`as`, `satisfies`, ``, `!`), or `undefined`. None of those changes diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 10a7bfec..bb6dd526 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -563,7 +563,11 @@ tq2id = {} for t in types.values(): tq2id.setdefault(t['qualified_name'], t['id']) def owner_display(qn): tid = tq2id.get(qn) - return tdisplay(tid) if tid else (qn.rsplit('.', 1)[-1] if qn else None) + if tid: return tdisplay(tid) + # an owner that is no type — a TypeScript const table, `mod/path#ROUTES.orders` — is named by what follows the + # module, so its key is `ROUTES.orders.list` rather than `path#ROUTES.orders.list` or `orders.list` + if qn and '#' in qn: return qn.rsplit('#', 1)[-1] + return qn.rsplit('.', 1)[-1] if qn else None def file_of(r, d): if d.get('filePath'): return rel(r.get(d['filePath'], '')) via = d.get('fileVia') @@ -618,9 +622,12 @@ for d in A['decls']: sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) # the data keys of module-level const objects (collected with the member names): a field of the object, owned by the key # chain it is written under, so `TOPICS.CREATED` and `LIMITS.nested.depth` are names `impact` takes +# A key the parser already declared (TypeScript emits each key of a const table as a field) is not declared twice +declared_at = {(s[6], s[7], s[1]) for s in sym if s[3] == 'field'} if data_keys else set() for name, od, v, k in data_keys: fp = rel(modules.get(k.get('ownerModuleLinkHash', ''), '')) or rel(v.get('filePath', '')) ln = int(k.get('startLine') or 0) + if (fp, ln, name) in declared_at: continue vq = v.get('qualifiedName') or v.get('potentialQualifiedName') qn = f"{vq}{od[len(v['name']):]}.{name}" if vq and od.startswith(v['name']) else f"{fp}#{od}.{name}" sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index bdf2971a..b106e9d7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -470,10 +470,15 @@ direct(q, c, "uses", why, "by name", f, l) :- valueref(q, c, f, l), registered(q // a FIELD: references by name, judged by where they are and how they are written .decl fref(q:symbol, c:symbol, rk:symbol, f:symbol, l:number) fref(q, c, rk, f, l) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), ref(c, n, rk, ek, f, l), !local_kind(ek), !type_or_call_kind(ek), (f != ff ; l != fll), - (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"), (ek != "OBJECT_PROPERTY_KEY" ; !key_decl_at(n, f, l)). + (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"), (ek != "OBJECT_PROPERTY_KEY" ; !key_decl_at(n, f, l)), !other_field_line(q, f, l). // the key that DECLARES another object's same-named key (`QUEUES = { CREATED: … }`) is that declaration, not a use of this one .decl key_decl_at(n:symbol, f:symbol, l:number) key_decl_at(n, f, l) :- field_holder(fl, _), field(fl, _, n, f, l). +// a key of a module-level const table: the line that DECLARES another table's key of the same name writes that name +// as its own key, not a read of this one — `GATEWAY_ROUTES = { products: … }` beside `CATALOG_ROUTES = { products: … }`. +// Only for a target owned by its module: a type's field keeps the keys written for it (`{ eventId: … } satisfies T`). +.decl other_field_line(q:symbol, f:symbol, l:number) +other_field_line(q, f, l) :- target(q, "field", fl, _), field(fl, t, n, _, _), kind(t, "module"), field(fl2, _, n, f, l), fl2 != fl. // an enum member is written like a type, so the parser labels the genuine reference TYPE: keep those, but only in a // file that can see the enum — its own directory, or a file that names the enum type somewhere .decl enum_member_target(q:symbol, fl:symbol) diff --git a/tests/cases/typescript/const-object-table/case.json b/tests/cases/typescript/const-object-table/case.json new file mode 100644 index 00000000..7fc884e1 --- /dev/null +++ b/tests/cases/typescript/const-object-table/case.json @@ -0,0 +1,48 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a key of a module-level const object literal is a declaration: its readers are the functions that read THAT key, resolved through the import", + "run": ["impact", "ROUTES.list", "--grep"], + "want": ["src/a.ts:4:", "[resolved"], + "avoid": ["src/a.ts:8:", "src/b.ts:4:", "nothing named"]}, + {"why": "control: another table in the same file declaring a key of the same name is neither this key nor a reader of it", + "run": ["impact", "src/k.ts:2", "--grep"], + "want": ["src/a.ts:4:"], + "avoid": ["src/k.ts:15:"]}, + {"why": "another key of the same table has its own readers, including one through a namespace import", + "run": ["impact", "ROUTES.one", "--grep"], + "want": ["src/a.ts:8:", "src/b.ts:4:", "src/d.ts:8:"], + "avoid": ["src/a.ts:4:", "src/a.ts:12:"]}, + {"why": "a key read in a method's decorator is read by that method, not by the module that evaluates the decorator", + "run": ["impact", "ROUTES.one"], + "want": ["[resolved] Handlers.one"], + "avoid": ["[resolved] d."]}, + {"why": "a nested literal's key is a declaration under the key that holds it", + "run": ["impact", "ROUTES.admin.users", "--grep"], + "want": ["src/a.ts:12:"], + "avoid": ["src/a.ts:4:", "src/a.ts:8:", "nothing named"]}, + {"why": "the table as a whole still has every reader", + "run": ["impact", "ROUTES", "--kind", "field", "--grep"], + "want": ["src/a.ts:4:", "src/a.ts:8:", "src/a.ts:12:", "src/b.ts:4:"]}, + {"why": "control: a const annotated with an interface, or checked against one with satisfies, is read as the interface's member, one target each", + "run": ["impact", "Settings.mode", "--grep"], + "want": ["src/c.ts:4:", "src/c.ts:8:", "[resolved"]}, + {"why": "control: a read through the satisfies-checked const has ONE target, the interface's member, not also the literal's key", + "run": ["impact", "CHECKED.mode", "--grep"], + "avoid": ["src/c.ts:8:"]}, + {"why": "control: the annotated const's keys are not declared a second time", + "run": ["impact", "ANNOTATED.mode"], + "avoid": ["change: "], + "expect_error": true}, + {"why": "control: a key the literal never wrote (assigned later) is not declared", + "run": ["impact", "bag.late"], + "avoid": ["change: ", "[field]", "[resolved"], + "expect_error": true}, + {"why": "control: an unannotated const mutated later declares only the keys its literal wrote", + "run": ["impact", "open.late"], + "avoid": ["change: ", "[field]", "[resolved"], + "expect_error": true}, + {"why": "control: a function-valued key stays the method it was, not a field beside it", + "run": ["impact", "handlers.run", "--kind", "field"], + "avoid": ["[field]"], + "expect_error": true} + ]} diff --git a/tests/cases/typescript/const-object-table/src/a.ts b/tests/cases/typescript/const-object-table/src/a.ts new file mode 100644 index 00000000..e6477fa6 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/a.ts @@ -0,0 +1,13 @@ +import { ROUTES } from './k'; + +export function listRoute() { + return ROUTES.list; +} + +export function oneRoute() { + return ROUTES.one; +} + +export function usersRoute() { + return ROUTES.admin.users; +} diff --git a/tests/cases/typescript/const-object-table/src/b.ts b/tests/cases/typescript/const-object-table/src/b.ts new file mode 100644 index 00000000..c3294f16 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/b.ts @@ -0,0 +1,9 @@ +import * as K from './k'; + +export function oneViaNamespace() { + return K.ROUTES.one; +} + +export function lateKey() { + return K.bag.late; +} diff --git a/tests/cases/typescript/const-object-table/src/c.ts b/tests/cases/typescript/const-object-table/src/c.ts new file mode 100644 index 00000000..709b7c8d --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/c.ts @@ -0,0 +1,9 @@ +import { ANNOTATED, CHECKED } from './settings'; + +export function annotatedMode() { + return ANNOTATED.mode; +} + +export function checkedMode() { + return CHECKED.mode; +} diff --git a/tests/cases/typescript/const-object-table/src/d.ts b/tests/cases/typescript/const-object-table/src/d.ts new file mode 100644 index 00000000..9c8cbe49 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/d.ts @@ -0,0 +1,12 @@ +import { ROUTES } from './k'; + +function Route(_path: string) { + return (_target: object, _key: string) => undefined; +} + +export class Handlers { + @Route(ROUTES.one) + one() { + return 1; + } +} diff --git a/tests/cases/typescript/const-object-table/src/k.ts b/tests/cases/typescript/const-object-table/src/k.ts new file mode 100644 index 00000000..436609c0 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/k.ts @@ -0,0 +1,19 @@ +export const ROUTES = { + list: '/items', + one: '/items/:id', + admin: { users: '/admin/users', roles: '/admin/roles' }, +} as const; + +export const handlers = { + run: () => 1, +}; + +export const bag: Record = {}; +bag.late = 1; + +export const ADMIN_ROUTES = { + list: '/admin/items', +} as const; + +export const open = { seen: 0 }; +(open as Record).late = 1; diff --git a/tests/cases/typescript/const-object-table/src/settings.ts b/tests/cases/typescript/const-object-table/src/settings.ts new file mode 100644 index 00000000..92e9f3bc --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/settings.ts @@ -0,0 +1,7 @@ +export interface Settings { + mode: string; +} + +export const ANNOTATED: Settings = { mode: 'a' }; + +export const CHECKED = { mode: 'b' } satisfies Settings; From 0f207ca625411b878a17c3a7755b2ca52ba7d016 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:54:07 -0700 Subject: [PATCH 2/6] impact: a const table's key spelled like a class answers as the key `TOKENS.Relay` (a key of a module-level const table) also matched class Relay by dropping the table's name, and impact answered 'more than one kind'. A field whose display is the spelling now wins over a type found only by dropping the leading segment, as a method already did. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 5 +++++ tests/cases/typescript/const-object-table/case.json | 7 +++++++ tests/cases/typescript/const-object-table/src/e.ts | 11 +++++++++++ tests/cases/typescript/const-object-table/src/k.ts | 2 ++ 4 files changed, 25 insertions(+) create mode 100644 tests/cases/typescript/const-object-table/src/e.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e8186697..69214bde 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -557,6 +557,11 @@ class Impact: tids = self.types(base, soft=True) if tids: self.WHY['type'] = self._why_t.get(base) if tids: out.append(('type', f"{self.g.sym[tids[0]]['kind']} {self.g.disp(tids[0])}" + (f" (+{len(tids)-1})" if len(tids) > 1 else ''), tids)) + # the same rule as a method below: a field whose display IS the spelling (`TOKENS.OutboxRelay`, a key of a + # const table) wins over a type types() found only by dropping the leading segment (class OutboxRelay) + if tids and out[0][0] == 'field' and any(r.get('display') == base for r in out[0][2]) \ + and not any(self.g.sym[t]['display'] == base for t in tids): + out = [o for o in out if o[0] != 'type'] # a const declared ON the line is the declaration written there: the enclosing module spans the line and a # function in its initializer (`const h = wrap(async (req, res) => …)`) starts on it, and neither is what was asked # — unless that function IS the const (`const h = async (req, res) => …` is named `h`), which stays the method. diff --git a/tests/cases/typescript/const-object-table/case.json b/tests/cases/typescript/const-object-table/case.json index 7fc884e1..54b1e562 100644 --- a/tests/cases/typescript/const-object-table/case.json +++ b/tests/cases/typescript/const-object-table/case.json @@ -23,6 +23,13 @@ {"why": "the table as a whole still has every reader", "run": ["impact", "ROUTES", "--kind", "field", "--grep"], "want": ["src/a.ts:4:", "src/a.ts:8:", "src/a.ts:12:", "src/b.ts:4:"]}, + {"why": "a key spelled like a class is the key: the class matched only by dropping the table's name is not a second kind", + "run": ["impact", "TOKENS.Relay", "--grep"], + "want": ["src/e.ts:6:", "[resolved"], + "avoid": ["more than one kind", "src/e.ts:10:"]}, + {"why": "control: the class's own name still answers for the class", + "run": ["impact", "Relay", "--kind", "type", "--grep"], + "want": ["src/e.ts:10:"]}, {"why": "control: a const annotated with an interface, or checked against one with satisfies, is read as the interface's member, one target each", "run": ["impact", "Settings.mode", "--grep"], "want": ["src/c.ts:4:", "src/c.ts:8:", "[resolved"]}, diff --git a/tests/cases/typescript/const-object-table/src/e.ts b/tests/cases/typescript/const-object-table/src/e.ts new file mode 100644 index 00000000..6fb78b93 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/e.ts @@ -0,0 +1,11 @@ +import { TOKENS } from './k'; + +export class Relay {} + +export function relayToken() { + return TOKENS.Relay; +} + +export function makeRelay() { + return new Relay(); +} diff --git a/tests/cases/typescript/const-object-table/src/k.ts b/tests/cases/typescript/const-object-table/src/k.ts index 436609c0..1f6e3dd1 100644 --- a/tests/cases/typescript/const-object-table/src/k.ts +++ b/tests/cases/typescript/const-object-table/src/k.ts @@ -17,3 +17,5 @@ export const ADMIN_ROUTES = { export const open = { seen: 0 }; (open as Record).late = 1; + +export const TOKENS = { Relay: Symbol('Relay') } as const; From d0cb0d7ec920871139df9285a96b6183850dd650 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:44:18 -0700 Subject: [PATCH 3/6] TypeScript: a key read in a parameter's decorator is read by its method `constructor(@inject(TOKENS.Store) store)` binds the constructor to the token, but the read was credited to the module that evaluates the decorator, so `impact TOKENS.Store` named the file instead of the class. A parameter decorator's owner is the parameter; its method is the reader, as for a method's own decorator. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/field_access.dl | 5 +++++ tests/cases/typescript/const-object-table/case.json | 3 +++ tests/cases/typescript/const-object-table/src/d.ts | 8 ++++++++ 3 files changed, 16 insertions(+) diff --git a/graph/typescript/engine/call-edge-generation/field_access.dl b/graph/typescript/engine/call-edge-generation/field_access.dl index 6ea4406d..5040350b 100644 --- a/graph/typescript/engine/call-edge-generation/field_access.dl +++ b/graph/typescript/engine/call-edge-generation/field_access.dl @@ -154,6 +154,11 @@ ts_field_access_class(e, "ambiguous_unknown") :- ts_field_access_count(e, 0). // decorator is no user of it. A decorator's expressions carry no owner hash, so the // subtree is walked down from the decorator's own expression. ts_deco_expr_method(e, m) :- decorator_expr("client", e, d), decorator_target("client", _, m, d), m != "". +// ...and so is a read in a PARAMETER'S decorator: `constructor(@inject(TOKENS.Store) s)` +// binds the constructor to the token. The decorator's owner is the parameter. +ts_deco_expr_method(e, m) :- decorator_expr("client", e, d), + ts_decorator(_, _, "PARAMETER_DECLARATION", p, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, d), + param_decl("client", _, _, _, m, p), m != "". ts_deco_expr_method(c, m) :- ts_deco_expr_method(p, m), expr_child("client", p, _, _, c). ts_field_access_in_decorator(e) :- ts_deco_expr_method(e, _). ts_field_access_named(e) :- expr_enclosing_method(e, m), m != "". diff --git a/tests/cases/typescript/const-object-table/case.json b/tests/cases/typescript/const-object-table/case.json index 54b1e562..b0651435 100644 --- a/tests/cases/typescript/const-object-table/case.json +++ b/tests/cases/typescript/const-object-table/case.json @@ -16,6 +16,9 @@ "run": ["impact", "ROUTES.one"], "want": ["[resolved] Handlers.one"], "avoid": ["[resolved] d."]}, + {"why": "a key read in a constructor parameter's decorator (an injection token) is read by that constructor", + "run": ["impact", "ROUTES.list", "--json"], + "want": ["Consumer."]}, {"why": "a nested literal's key is a declaration under the key that holds it", "run": ["impact", "ROUTES.admin.users", "--grep"], "want": ["src/a.ts:12:"], diff --git a/tests/cases/typescript/const-object-table/src/d.ts b/tests/cases/typescript/const-object-table/src/d.ts index 9c8cbe49..c10615f3 100644 --- a/tests/cases/typescript/const-object-table/src/d.ts +++ b/tests/cases/typescript/const-object-table/src/d.ts @@ -10,3 +10,11 @@ export class Handlers { return 1; } } + +function Inject(_token: string) { + return (_target: object, _key: string | undefined, _index: number) => undefined; +} + +export class Consumer { + constructor(@Inject(ROUTES.list) private readonly path: string) {} +} From 982dd5cc7841dcaabe77dc68cd421a800c2af02e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:24:25 -0700 Subject: [PATCH 4/6] TypeScript: a key read in a parameter's default is read by its method `constructor(topic = TOPICS.payments)` evaluates the default on every call that omits it, but the read was placed in the module initializer, so the constructor was missing from the key's readers. A parameter's default expression is now its method's, unless a function written as the default already owns the read. Reads in a decorator no longer also add a module row. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../call-edge-generation/field_access.dl | 23 +++++++++++++++---- graph/typescript/souffle/decls_all.dl | 4 ++++ .../typescript/const-object-table/case.json | 6 +++++ .../typescript/const-object-table/src/d.ts | 8 +++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/graph/typescript/engine/call-edge-generation/field_access.dl b/graph/typescript/engine/call-edge-generation/field_access.dl index 5040350b..d25646d2 100644 --- a/graph/typescript/engine/call-edge-generation/field_access.dl +++ b/graph/typescript/engine/call-edge-generation/field_access.dl @@ -161,14 +161,27 @@ ts_deco_expr_method(e, m) :- decorator_expr("client", e, d), param_decl("client", _, _, _, m, p), m != "". ts_deco_expr_method(c, m) :- ts_deco_expr_method(p, m), expr_child("client", p, _, _, c). ts_field_access_in_decorator(e) :- ts_deco_expr_method(e, _). +// A read in a parameter's DEFAULT is its method's too: `constructor(topic = TOPICS.payments)` +// is evaluated on every call that omits the argument, and the extractor places it in the +// module initializer. Only there: a function written as the default keeps its own reads. +ts_param_default_expr(e, m) :- ts_method_parameter(_, _, m, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e, _, _, _, _), + e != "", m != "". +ts_param_default_expr(c, m) :- ts_param_default_expr(p, m), expr_child("client", p, _, _, c). +ts_field_access_in_function(e) :- expr_enclosing_method(e, m), m != "", !module_init("client", m, _). +ts_param_default_method(e, m) :- ts_param_default_expr(e, m), + !ts_field_access_in_function(e), !ts_field_access_in_decorator(e). ts_field_access_named(e) :- expr_enclosing_method(e, m), m != "". +ts_field_access_placed(e) :- ts_field_access_named(e). +ts_field_access_placed(e) :- ts_field_access_in_decorator(e). +ts_field_access_placed(e) :- ts_param_default_method(e, _). ts_field_access_from(e, m) :- ts_field_access_site(e), ts_deco_expr_method(e, m). +ts_field_access_from(e, m) :- ts_field_access_site(e), ts_param_default_method(e, m). ts_field_access_from(e, m) :- ts_field_access_site(e), expr_enclosing_method(e, m), m != "", - !ts_field_access_in_decorator(e). -// ...and the MODULE itself for the residue expr_enclosing_method cannot place, which is -// the marker call_sites.caller_id already uses in TypeScript rather than a blank. Never -// NULL, so a site can always be attributed to the code that contains it. -ts_field_access_from(e, mod) :- ts_field_access_site(e), !ts_field_access_named(e), + !ts_field_access_in_decorator(e), !ts_param_default_method(e, _). +// ...and the MODULE itself for the residue none of those can place, which is the marker +// call_sites.caller_id already uses in TypeScript rather than a blank. Never NULL, so a +// site can always be attributed to the code that contains it. +ts_field_access_from(e, mod) :- ts_field_access_site(e), !ts_field_access_placed(e), expr_module("client", mod, e), mod != "". // ── THE EXPORTED ROW — field_access(Site, Caller, Field, Prov, Tier, Access) ─ diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 570577c0..c380b26e 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -642,6 +642,10 @@ .decl ts_field_access_named(c0:symbol) .decl ts_deco_expr_method(c0:symbol,c1:symbol) .decl ts_field_access_in_decorator(c0:symbol) +.decl ts_param_default_method(c0:symbol,c1:symbol) +.decl ts_param_default_expr(c0:symbol,c1:symbol) +.decl ts_field_access_in_function(c0:symbol) +.decl ts_field_access_placed(c0:symbol) .decl ts_field_access_from(c0:symbol,c1:symbol) .decl ts_field_access_has_row(c0:symbol) .decl field_access(c0:symbol,c1:symbol,c2:symbol,c3:symbol,c4:symbol,c5:symbol) diff --git a/tests/cases/typescript/const-object-table/case.json b/tests/cases/typescript/const-object-table/case.json index b0651435..86a987bf 100644 --- a/tests/cases/typescript/const-object-table/case.json +++ b/tests/cases/typescript/const-object-table/case.json @@ -19,6 +19,12 @@ {"why": "a key read in a constructor parameter's decorator (an injection token) is read by that constructor", "run": ["impact", "ROUTES.list", "--json"], "want": ["Consumer."]}, + {"why": "a key read in a parameter's default is read by that method, not the module", + "run": ["impact", "ROUTES.list", "--json"], + "want": ["Publisher."]}, + {"why": "control: a function written as the default keeps its own read", + "run": ["impact", "ROUTES.list", "--json"], + "want": ["\"display\": \"\""]}, {"why": "a nested literal's key is a declaration under the key that holds it", "run": ["impact", "ROUTES.admin.users", "--grep"], "want": ["src/a.ts:12:"], diff --git a/tests/cases/typescript/const-object-table/src/d.ts b/tests/cases/typescript/const-object-table/src/d.ts index c10615f3..841f1a47 100644 --- a/tests/cases/typescript/const-object-table/src/d.ts +++ b/tests/cases/typescript/const-object-table/src/d.ts @@ -18,3 +18,11 @@ function Inject(_token: string) { export class Consumer { constructor(@Inject(ROUTES.list) private readonly path: string) {} } + +export class Publisher { + constructor(private readonly path: string = ROUTES.list) {} +} + +export function withLookup(lookup = () => ROUTES.list) { + return lookup(); +} From 673ccc56a80259b8fe438be7d46002940a750b6b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:24:18 -0700 Subject: [PATCH 5/6] TypeScript suite: a const table's key is scored under its module, as the compiler names it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fields scorer labelled a key of a module-level `const X = { a: … }` as `X#a` (its owner is the variable, which is no type). The compiler side names the nearest enclosing named declaration and the module otherwise, so every correct read scored as one wrong plus one missing. Label it by its module. - 78-cross-process-destinations: fields 0/1 -> 1/1 correct, 0 wrong - 80-object-literal-member-receivers: fields 0/9 -> 3/9 correct, 0 wrong Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../expected/78-cross-process-destinations.fields | 2 +- .../78-cross-process-destinations.fields-oracle | 11 +++++------ .../80-object-literal-member-receivers.fields | 4 ++-- ...80-object-literal-member-receivers.fields-oracle | 13 +++++-------- graph/test/typescript/tools/normalize_members.py | 11 ++++++++++- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields index d624a5ae..4ff36cd9 100644 --- a/graph/test/typescript/expected/78-cross-process-destinations.fields +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields @@ -1 +1 @@ -known_edge read client/client#dailyReport(string) -> config#reportsUrl +known_edge read client/client#dailyReport(string) -> shared/config#reportsUrl diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle index bd6298b8..0539a6dc 100644 --- a/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields-oracle @@ -1,8 +1,7 @@ 78-cross-process-destinations [fields] - precision 0.0000 (0 correct, 0 wrong) - recall 0.0000 (0 of 1 the compiler resolved) - sites 1 resolved 0 (0.0%) - tiers ambiguous_unknown=1 + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 1 resolved 1 (100.0%) + tiers known_edge=1 access read=1 - not scored: 1 rows whose target is not a client declaration - MISSING client/client#dailyReport(string) READ shared/config#reportsUrl + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields b/graph/test/typescript/expected/80-object-literal-member-receivers.fields index 179c701f..5903cc81 100644 --- a/graph/test/typescript/expected/80-object-literal-member-receivers.fields +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields @@ -1,3 +1,3 @@ -known_edge read local#drive() -> handlers#jobs -known_edge read local#trimName() -> cfg#name +known_edge read local#drive() -> local#jobs +known_edge read local#trimName() -> local#name known_edge read use#callNested() -> api#users diff --git a/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle b/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle index 66475c28..15e91dc5 100644 --- a/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle +++ b/graph/test/typescript/expected/80-object-literal-member-receivers.fields-oracle @@ -1,16 +1,13 @@ 80-object-literal-member-receivers [fields] - precision 0.0000 (0 correct, 0 wrong) - recall 0.0000 (0 of 9 the compiler resolved) - sites 6 resolved 0 (0.0%) - tiers ambiguous_unknown=6 + precision 1.0000 (3 correct, 0 wrong) + recall 0.3333 (3 of 9 the compiler resolved) + sites 6 resolved 6 (100.0%) + tiers known_edge=6 access read=6 - not scored: 6 rows whose target is not a client declaration - MISSING local#drive() READ local#jobs + not scored: 0 rows whose target is not a client declaration MISSING local#drive() READ local#run - MISSING local#trimName() READ local#name MISSING use#callNested() READ api#byName MISSING use#callNested() READ api#get - MISSING use#callNested() READ api#users MISSING use#callStore() READ api#reset MISSING use#callTop() READ api#find MISSING use#callTop() READ api#remove diff --git a/graph/test/typescript/tools/normalize_members.py b/graph/test/typescript/tools/normalize_members.py index 3a47baf3..f4a41d79 100644 --- a/graph/test/typescript/tools/normalize_members.py +++ b/graph/test/typescript/tools/normalize_members.py @@ -25,8 +25,17 @@ def field_names(ir, prefix='typescript'): out = {} types = {t['tsTypeUniqueHash']: (t.get('name') or t.get('qualifiedName') or '?') for t in rows(f'{ir}/all-{prefix}-types.csv')} + mods = {m['tsModuleUniqueHash']: m['qualifiedName'] or m['filePath'] + for m in rows(f'{ir}/all-{prefix}-modules.csv')} for r in rows(f'{ir}/all-{prefix}-fields.csv'): - owner = types.get(r.get('tsTypeLinkHash')) or r.get('ownerTypeName') or '?' + owner = types.get(r.get('tsTypeLinkHash')) + # A key of a module-level `const X = { a: … }` is owned by the variable X (or by the + # key holding a nested literal), which is no type. The compiler side names the nearest + # enclosing named declaration and the module otherwise, so `X.a` is `module#a` there; + # naming it `X#a` here scored every correct read as one wrong plus one missing. + if owner is None and r.get('memberKind') == 'OBJECT_LITERAL_PROPERTY': + owner = mods.get(r.get('tsModuleLinkHash')) + owner = owner or r.get('ownerTypeName') or '?' # `static ` prefixes the NAME, as normalize_edges.Names does for a static method and # as the compiler side's labelOf does. Without it a static member is named one way on # each side and every access to it scores as both a miss and a false positive. From 47bcadcbfc1b73dd65e8958bf00e97e906507190 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:51:11 -0700 Subject: [PATCH 6/6] TypeScript: one owner for a const table's keys after the index-side declaration landed The index declared data keys of TypeScript const literals too, so after merging, an annotated const's keys were declared a second time beside the type's member. The parser now owns TypeScript's keys; the index keeps them for JavaScript. - index: no TypeScript consts predicate (the parser declares those keys) - parser: a const annotated with a type that names no member (Record, through Readonly/Partial/Required, or index signatures only) declares its literal's keys, as an unannotated one does - impact.dl: keep both the key-declaration-line and other-table-line rules - suite: fixture 86's const-table reads now resolve (fields recall 0.50 -> 0.92) Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../86-keyed-callback-registry.fields | 9 ++++---- .../86-keyed-callback-registry.fields-oracle | 15 ++++-------- .../extractors/ts-declaration-extractor.ts | 23 +++++++++++++++++-- .../skills/axiomcode/scripts/axiomcode-index | 8 +++---- .../typescript/const-object-table/case.json | 4 ++++ .../typescript/const-object-table/src/c.ts | 10 +++++++- .../const-object-table/src/settings.ts | 4 ++++ 7 files changed, 51 insertions(+), 22 deletions(-) diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields b/graph/test/typescript/expected/86-keyed-callback-registry.fields index 02ad1620..905b11ec 100644 --- a/graph/test/typescript/expected/86-keyed-callback-registry.fields +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields @@ -1,12 +1,13 @@ ambiguous_unknown read Bus#count(string) -> - -ambiguous_unknown read Indexer#start() -> - -ambiguous_unknown read app#() -> - -ambiguous_unknown read app#create() -> - -ambiguous_unknown read app#size() -> - known_edge read Bus#count(string) -> Bus#handlers known_edge read Bus#dispatch(string,unknown) -> Bus#handlers known_edge read Bus#subscribe(string,Handler) -> Bus#handlers known_edge read Indexer#start() -> Indexer#b +known_edge read Indexer#start() -> topics#created +known_edge read Indexer#start() -> topics#removed known_edge read MiniBus#fire(string,unknown) -> MiniBus#h known_edge read MiniBus#on(string,(p: unknown) =) -> MiniBus#h +known_edge read app#() -> topics#created +known_edge read app#create() -> topics#created known_edge read app#relay({ type: string }) -> { type: string }#type +known_edge read app#size() -> topics#created diff --git a/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle index 9148ff26..161a25a4 100644 --- a/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle +++ b/graph/test/typescript/expected/86-keyed-callback-registry.fields-oracle @@ -1,14 +1,9 @@ 86-keyed-callback-registry [fields] - precision 0.8571 (6 correct, 1 wrong) - recall 0.5000 (6 of 12 the compiler resolved) - sites 17 resolved 11 (64.7%) - tiers ambiguous_unknown=6 known_edge=11 + precision 0.9167 (11 correct, 1 wrong) + recall 0.9167 (11 of 12 the compiler resolved) + sites 17 resolved 16 (94.1%) + tiers ambiguous_unknown=1 known_edge=16 access read=17 - not scored: 6 rows whose target is not a client declaration + not scored: 1 rows whose target is not a client declaration WRONG app#relay({ type: string }) READ { type: string }#type - MISSING Indexer#start() READ topics#created - MISSING Indexer#start() READ topics#removed - MISSING app#() READ topics#created - MISSING app#create() READ topics#created MISSING app#relay({ type: string }) READ app#type - MISSING app#size() READ topics#created diff --git a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts index ee852230..134b2b2b 100644 --- a/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts @@ -3629,7 +3629,9 @@ function variableScopeKindOf( * values, through `as const`, `satisfies T`, parentheses and `Object.freeze(…)`. * Only a `const` bound to a plain name: a `let` can be reassigned, and a * destructuring has no one name to own the keys. Not an annotated one either: - * `const c: Config = { … }` has Config's members, which the compiler names. + * `const c: Config = { … }` has Config's members, which the compiler names — + * unless the annotation names no member at all (`Record`, + * `{ [k: string]: T }`), where the literal's keys are the only declarations. */ function constObjectTableOf( declaration: ts.VariableDeclaration, @@ -3637,7 +3639,8 @@ function constObjectTableOf( context: EmitContext ): { literal: ts.ObjectLiteralExpression; isReadonly: boolean } | undefined { if (!list || (list.flags & ts.NodeFlags.Const) === 0 || !ts.isIdentifier(declaration.name) - || declaration.initializer === undefined || declaration.type !== undefined) { + || declaration.initializer === undefined + || (declaration.type !== undefined && !declaresNoMember(declaration.type))) { return undefined; } const scope = variableScopeKindOf(context, declaration); @@ -3647,6 +3650,22 @@ function constObjectTableOf( return objectTableOf(declaration.initializer); } +/** A map type that names no member of its own: `Record` (through `Readonly`, + * `Partial`, `Required`) or a type literal of index signatures only. */ +function declaresNoMember(type: ts.TypeNode): boolean { + if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName)) { + const name = type.typeName.text; + if (name === 'Record') { + return true; + } + if ((name === 'Readonly' || name === 'Partial' || name === 'Required') && type.typeArguments?.length === 1) { + return declaresNoMember(type.typeArguments[0]!); + } + return false; + } + return ts.isTypeLiteralNode(type) && type.members.length > 0 && type.members.every(ts.isIndexSignatureDeclaration); +} + function objectTableOf( node: ts.Expression ): { literal: ts.ObjectLiteralExpression; isReadonly: boolean } | undefined { diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index bb6dd526..777a8caf 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -115,8 +115,9 @@ A = { # `obj.x = function …`, `{ all: (p) => … }`: named for the property, as JavaScript's are (#1585). The rows are # read through `ts_member_rows`, which gives them JavaScript's expression shape memberNames=dict(file='all-typescript-expressions.csv', id='jsExpressionUniqueHash', method='introducesDeclarationLinkHash', - vars='all-typescript-variables.csv', shape='typescript', - consts=lambda r: r.get('scopeKind') == 'MODULE_SCOPE' and r.get('isConst') == 'true'), + # no `consts`: the parser declares a const table's data keys itself, and leaves an annotated + # const's keys to the type it names (`const S: Settings = {…}` reads Settings.mode) + vars='all-typescript-variables.csv', shape='typescript'), skipped='skipped-typescript-files.csv'), 'python': dict( modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), @@ -622,12 +623,9 @@ for d in A['decls']: sym.append((r.get(d['id']) if d.get('id') else None, name, (od + '.' if od else '') + name, d['kind'](r), qn, None, fp, ln, en, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) # the data keys of module-level const objects (collected with the member names): a field of the object, owned by the key # chain it is written under, so `TOPICS.CREATED` and `LIMITS.nested.depth` are names `impact` takes -# A key the parser already declared (TypeScript emits each key of a const table as a field) is not declared twice -declared_at = {(s[6], s[7], s[1]) for s in sym if s[3] == 'field'} if data_keys else set() for name, od, v, k in data_keys: fp = rel(modules.get(k.get('ownerModuleLinkHash', ''), '')) or rel(v.get('filePath', '')) ln = int(k.get('startLine') or 0) - if (fp, ln, name) in declared_at: continue vq = v.get('qualifiedName') or v.get('potentialQualifiedName') qn = f"{vq}{od[len(v['name']):]}.{name}" if vq and od.startswith(v['name']) else f"{fp}#{od}.{name}" sym.append((None, name, f"{od}.{name}", 'field', qn, None, fp, ln, ln, od, 1 if fp and TESTRE.search(fp) else 0, None, None)) diff --git a/tests/cases/typescript/const-object-table/case.json b/tests/cases/typescript/const-object-table/case.json index 86a987bf..1b6f8e13 100644 --- a/tests/cases/typescript/const-object-table/case.json +++ b/tests/cases/typescript/const-object-table/case.json @@ -45,6 +45,10 @@ {"why": "control: a read through the satisfies-checked const has ONE target, the interface's member, not also the literal's key", "run": ["impact", "CHECKED.mode", "--grep"], "avoid": ["src/c.ts:8:"]}, + {"why": "a const annotated with a map type that names no member (Record) declares its literal's keys: each key has only its own reader", + "run": ["impact", "DIGITS.USD", "--grep"], + "want": ["src/c.ts:12:", "[resolved"], + "avoid": ["src/c.ts:16:"]}, {"why": "control: the annotated const's keys are not declared a second time", "run": ["impact", "ANNOTATED.mode"], "avoid": ["change: "], diff --git a/tests/cases/typescript/const-object-table/src/c.ts b/tests/cases/typescript/const-object-table/src/c.ts index 709b7c8d..bff542fd 100644 --- a/tests/cases/typescript/const-object-table/src/c.ts +++ b/tests/cases/typescript/const-object-table/src/c.ts @@ -1,4 +1,4 @@ -import { ANNOTATED, CHECKED } from './settings'; +import { ANNOTATED, CHECKED, DIGITS } from './settings'; export function annotatedMode() { return ANNOTATED.mode; @@ -7,3 +7,11 @@ export function annotatedMode() { export function checkedMode() { return CHECKED.mode; } + +export function usdDigits() { + return DIGITS.USD; +} + +export function jpyDigits() { + return DIGITS.JPY; +} diff --git a/tests/cases/typescript/const-object-table/src/settings.ts b/tests/cases/typescript/const-object-table/src/settings.ts index 92e9f3bc..23975a6d 100644 --- a/tests/cases/typescript/const-object-table/src/settings.ts +++ b/tests/cases/typescript/const-object-table/src/settings.ts @@ -5,3 +5,7 @@ export interface Settings { export const ANNOTATED: Settings = { mode: 'a' }; export const CHECKED = { mode: 'b' } satisfies Settings; + +export type Currency = 'USD' | 'JPY'; + +export const DIGITS: Readonly> = { USD: 2, JPY: 0 };