diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields index 4fea0459..4ff36cd9 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) -> 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 4b531045..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 @@ -ambiguous_unknown read local#drive() -> - -ambiguous_unknown read local#trimName() -> - -ambiguous_unknown read use#callNested() -> - +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/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/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. diff --git a/graph/typescript/engine/call-edge-generation/field_access.dl b/graph/typescript/engine/call-edge-generation/field_access.dl index d7d0057f..d25646d2 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,12 +149,39 @@ 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 != "". +// ...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, _). +// 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_from(e, m) :- ts_field_access_site(e), expr_enclosing_method(e, m), m != "". -// ...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_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), !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 467f2e70..c380b26e 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -631,12 +631,21 @@ .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_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/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts b/parser/src/parsers/typescript/extractors/ts-declaration-extractor.ts index 4a816c7f..134b2b2b 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,91 @@ 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 — + * 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, + 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 && !declaresNoMember(declaration.type))) { + return undefined; + } + const scope = variableScopeKindOf(context, declaration); + if (scope !== TsVariableScopeKind.MODULE_SCOPE && scope !== TsVariableScopeKind.NAMESPACE_SCOPE) { + return undefined; + } + 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 { + 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-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/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 10a7bfec..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'), @@ -563,7 +564,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') 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..1b6f8e13 --- /dev/null +++ b/tests/cases/typescript/const-object-table/case.json @@ -0,0 +1,68 @@ +{"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 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:"], + "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": "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"]}, + {"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: "], + "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..bff542fd --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/c.ts @@ -0,0 +1,17 @@ +import { ANNOTATED, CHECKED, DIGITS } from './settings'; + +export function annotatedMode() { + return ANNOTATED.mode; +} + +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/d.ts b/tests/cases/typescript/const-object-table/src/d.ts new file mode 100644 index 00000000..841f1a47 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/d.ts @@ -0,0 +1,28 @@ +import { ROUTES } from './k'; + +function Route(_path: string) { + return (_target: object, _key: string) => undefined; +} + +export class Handlers { + @Route(ROUTES.one) + one() { + 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) {} +} + +export class Publisher { + constructor(private readonly path: string = ROUTES.list) {} +} + +export function withLookup(lookup = () => ROUTES.list) { + return lookup(); +} 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 new file mode 100644 index 00000000..1f6e3dd1 --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/k.ts @@ -0,0 +1,21 @@ +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; + +export const TOKENS = { Relay: Symbol('Relay') } as const; 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..23975a6d --- /dev/null +++ b/tests/cases/typescript/const-object-table/src/settings.ts @@ -0,0 +1,11 @@ +export interface Settings { + mode: string; +} + +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 };