From f115f5d8893c5dcb980551afaf65a5145a0441cc Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:25:40 -0700 Subject: [PATCH 001/133] engine: resolve reads of module-level consts through their import binding A module-scope const used as `X.m`, `f(X)`, in a template or under `typeof X` had no data edge to its declaration, so impact matched it by name, including same-named consts in sibling packages whose files import their own. - TypeScript and JavaScript engines export field_access rows for an identifier the binder ties to a module variable, directly or through an import binding (across `export *` barrels); `typeof X` type queries bind through module scope, with a same-named parameter or local shadowing it. - The bundle positions a field-access site that is a type reference. - impact: a const the engine bound on a route line is still a registration. - Goldens re-blessed: module consts read by name now carry known_edge reads. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/bundle/build.ts | 15 ++++ graph/bundle/languages.ts | 10 +++ .../call-edge-generation/field_access.dl | 60 +++++++++++++ graph/javascript/souffle/decls_all.dl | 12 +++ graph/javascript/souffle/export_manifest.tsv | 1 + .../86-module-variable-reads/src/a/src/cfg.ts | 21 +++++ .../src/a/src/index.ts | 1 + .../86-module-variable-reads/src/a/test/t.ts | 47 ++++++++++ .../src/b/src/settings.ts | 3 + .../src/b/test/sibling.ts | 5 ++ .../src/tsconfig.json | 10 +++ .../expected/17-constrained-generics.fields | 4 + .../17-constrained-generics.fields-oracle | 8 +- .../36-function-value-containers.fields | 1 + ...36-function-value-containers.fields-oracle | 8 +- .../expected/49-indexed-access-return.fields | 3 + .../49-indexed-access-return.fields-oracle | 8 +- .../62-field-access-and-type-use.fields | 1 + ...62-field-access-and-type-use.fields-oracle | 8 +- .../63-annotated-callable-member.fields | 1 + ...63-annotated-callable-member.fields-oracle | 8 +- .../expected/64-array-dispatch.fields | 3 + .../expected/64-array-dispatch.fields-oracle | 8 +- ...annotated-callable-fewer-parameters.fields | 3 + ...ed-callable-fewer-parameters.fields-oracle | 8 +- .../69-named-and-inline-handlers.fields | 3 + ...69-named-and-inline-handlers.fields-oracle | 8 +- ...ject-literal-and-expression-callees.fields | 1 + ...teral-and-expression-callees.fields-oracle | 8 +- .../expected/74-jsx-component-forms.fields | 5 ++ .../74-jsx-component-forms.fields-oracle | 8 +- .../expected/75-jsx-wrapper-guards.fields | 10 +++ .../75-jsx-wrapper-guards.fields-oracle | 8 +- .../76-jsx-default-nested-wrappers.fields | 4 + ...-jsx-default-nested-wrappers.fields-oracle | 8 +- .../77-package-published-entries.fields | 1 + ...77-package-published-entries.fields-oracle | 8 +- .../expected/78-callable-collections.fields | 15 ++++ .../78-callable-collections.fields-oracle | 8 +- .../78-cross-process-destinations.fields | 13 +++ ...8-cross-process-destinations.fields-oracle | 8 +- ...78-hof-callback-at-library-boundary.fields | 12 +++ ...callback-at-library-boundary.fields-oracle | 8 +- .../79-package-published-members.fields | 3 + ...79-package-published-members.fields-oracle | 8 +- .../80-generic-return-receiver.fields | 8 ++ .../80-generic-return-receiver.fields-oracle | 8 +- .../80-object-literal-member-receivers.fields | 6 ++ ...ect-literal-member-receivers.fields-oracle | 8 +- .../80-vue-definecomponent-jsx.fields | 9 ++ .../80-vue-definecomponent-jsx.fields-oracle | 8 +- .../typescript/expected/80-vue-sfc.fields | 2 + .../expected/80-vue-sfc.fields-oracle | 8 +- .../expected/81-vue-component-tag.fields | 1 + .../81-vue-component-tag.fields-oracle | 8 +- .../expected/83-closed-world-dispatch.fields | 2 + .../83-closed-world-dispatch.fields-oracle | 8 +- .../85-workspace-package-import.fields | 2 + .../85-workspace-package-import.fields-oracle | 8 +- .../expected/86-module-variable-reads.edges | 7 ++ .../expected/86-module-variable-reads.entries | 15 ++++ .../expected/86-module-variable-reads.fields | 20 +++++ .../86-module-variable-reads.fields-oracle | 15 ++++ .../expected/86-module-variable-reads.oracle | 1 + .../86-module-variable-reads.type-use | 5 ++ .../86-module-variable-reads.types-oracle | 7 ++ .../typescript/tools/normalize_members.py | 13 ++- .../call-edge-generation/field_access.dl | 85 +++++++++++++++++++ graph/typescript/souffle/decls_all.dl | 15 ++++ .../skills/axiomcode/scripts/axiomcode-index | 6 +- .../skills/axiomcode/scripts/dl/impact.dl | 6 +- .../javascript/module-level-const/case.json | 18 ++++ .../module-level-const/src/barrelUser.js | 3 + .../module-level-const/src/consts.js | 5 ++ .../module-level-const/src/index.js | 1 + .../module-level-const/src/sibling/consts.js | 3 + .../module-level-const/src/sibling/reader.js | 3 + .../javascript/module-level-const/src/user.js | 9 ++ .../wrapped-handler-route/case.json | 6 +- .../typescript/module-level-const/case.json | 30 ++++++- .../module-level-const/src/consts.ts | 4 + .../module-level-const/src/sibling/consts.ts | 3 + .../module-level-const/src/sibling/reader.ts | 3 + .../typescript/module-level-const/src/user.ts | 12 +++ .../wrapped-handler-route/case.json | 2 +- 85 files changed, 680 insertions(+), 107 deletions(-) create mode 100644 graph/javascript/engine/call-edge-generation/field_access.dl create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts create mode 100644 graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json create mode 100644 graph/test/typescript/expected/86-module-variable-reads.edges create mode 100644 graph/test/typescript/expected/86-module-variable-reads.entries create mode 100644 graph/test/typescript/expected/86-module-variable-reads.fields create mode 100644 graph/test/typescript/expected/86-module-variable-reads.fields-oracle create mode 100644 graph/test/typescript/expected/86-module-variable-reads.oracle create mode 100644 graph/test/typescript/expected/86-module-variable-reads.type-use create mode 100644 graph/test/typescript/expected/86-module-variable-reads.types-oracle create mode 100644 tests/cases/javascript/module-level-const/case.json create mode 100644 tests/cases/javascript/module-level-const/src/barrelUser.js create mode 100644 tests/cases/javascript/module-level-const/src/consts.js create mode 100644 tests/cases/javascript/module-level-const/src/index.js create mode 100644 tests/cases/javascript/module-level-const/src/sibling/consts.js create mode 100644 tests/cases/javascript/module-level-const/src/sibling/reader.js create mode 100644 tests/cases/javascript/module-level-const/src/user.js create mode 100644 tests/cases/typescript/module-level-const/src/sibling/consts.ts create mode 100644 tests/cases/typescript/module-level-const/src/sibling/reader.ts diff --git a/graph/bundle/build.ts b/graph/bundle/build.ts index 072e9594..84c9fcb9 100644 --- a/graph/bundle/build.ts +++ b/graph/bundle/build.ts @@ -529,6 +529,21 @@ export async function buildCore(inp: BuildInputs): Promise { } } } + // a field access whose site is no expression: a module variable read in a type (`typeof X`) + if (A.ir.fieldSites && fieldSites.size > 0) { + const L = A.ir.fieldSites; + const src = await clientSource(inp.clientIrDir, L.file); + if (src) { + const h = src.header; + const ci = h.col(L.id), cl = h.col(L.startLine), cel = h.col(L.endLine); + for await (const r of rowsOf(src)) { + const fa = fieldSites.get(r[ci] ?? ''); + if (!fa) continue; + const file = fileOf(L.fileVia, h, r); + for (const fr of fa) { fill(fr, 6, file); fill(fr, 7, int(r[cl])); fill(fr, 9, int(r[cel])); } + } + } + } // a site the IR writes no name for, where the engine derived the accessor it calls (#1441) if (A.raw.siteNames) { for (const [site, name] of await readSource(rawDir, A.raw.siteNames)) { diff --git a/graph/bundle/languages.ts b/graph/bundle/languages.ts index 286ec18a..e4ec00d9 100644 --- a/graph/bundle/languages.ts +++ b/graph/bundle/languages.ts @@ -166,6 +166,8 @@ export interface LanguageAdapter { callSites?: CallSitesIR; decorators?: DecoratorsIR; localSites?: LocalSitesIR; + /** field access sites that are not expressions (TypeScript: a `typeof X` type reference), positioned from this table */ + fieldSites?: LocalSitesIR; /** absent where the front end writes no skipped-files report */ skipped?: SkippedIR; }; @@ -277,6 +279,11 @@ const TYPESCRIPT: LanguageAdapter = { startLine: 'startLine', startColumn: 'startColumn', fileVia: { column: 'tsModuleLinkHash', through: 'modules' }, }, + // a module variable read in a TYPE (`typeof X`): the field_access site is the type reference + fieldSites: { + file: 'all-typescript-type-references.csv', id: 'tsTypeReferenceUniqueHash', startLine: 'startLine', endLine: 'endLine', + fileVia: { column: 'tsModuleLinkHash', through: 'modules' }, + }, skipped: { file: 'skipped-typescript-files.csv', filePath: 'filePath', reason: 'reason', detail: 'detail' }, }, }; @@ -337,6 +344,9 @@ const JAVASCRIPT: LanguageAdapter = { typeAncestors: { file: 'resolution-type-ancestor.csv', columns: [0, 1] }, entryPoints: { file: 'entry-point.csv', columns: [0, 1] }, entryReachable: { file: 'entry-reachable.csv', columns: [0] }, + // site, caller, variable, provenance, tier, access: a module variable an identifier reads, + // directly or through an import binding (the field is the variable's own hash) + fieldAccess: { file: 'field-access.csv', columns: [0, 1, 2, 3, 4, 5] }, }, ir: { // No signature and no owner qualified name: JavaScript declares neither. diff --git a/graph/javascript/engine/call-edge-generation/field_access.dl b/graph/javascript/engine/call-edge-generation/field_access.dl new file mode 100644 index 00000000..066a5aee --- /dev/null +++ b/graph/javascript/engine/call-edge-generation/field_access.dl @@ -0,0 +1,60 @@ +// ============================================================================ +// CALL-EDGE-GEN · A MODULE VARIABLE READ BY NAME, AS A DATA EDGE +// +// `CFG.name`, `use(SCHEMA)`, `${TOKENS.store}`: the identifier is bound by the binder +// (expr_binding) to a variable -- the module's own top-level one, or an import binding. +// This engine follows an import to the VALUE the export holds (import_value), which is +// what a call needs, and never to the exported VARIABLE, which is what "who reads this +// const" needs. So a read of an imported const was answered by name, and the name +// matched every same-named const in every sibling package. +// +// The export surface is walked here once more, keeping the variable: export_decl names +// it (`export const x` and `export { x }` carry target kind VARIABLE), and a re-export +// (`export * from`, `export { x } from`) passes it on exactly as module_export_value +// passes a value on. A variable holding a function or a class is left out: its uses +// are calls and constructions, which call edges already carry. +// +// field_access(Site, Caller, Variable, "client", "known_edge", Access), the shape the +// Java and TypeScript engines export, so the bundle reads one relation for all three. +// ============================================================================ + +js_module_var(v) :- var_decl("client", _, reg, _, _, _, v), !var_owner_method("client", _, v), + !js_not_a_data_binding(reg), + !var_init("client", "FUNCTION", _, v), !var_init("client", "CLASS", _, v), + !var_import("client", _, v). +js_not_a_data_binding("IMPORT_BINDING"). +js_not_a_data_binding("FUNCTION_DECLARATION_HOISTED"). +js_not_a_data_binding("CLASS_TDZ"). +js_not_a_data_binding("CATCH_PARAMETER"). + +// ── export_var(Module, ExportedName, Variable) — the surface, by variable ─── +export_var(mod, n, v) :- export_decl("client", n, _, _, _, "VARIABLE", v, _, mod, _), n != "", js_module_var(v). +export_var(mod, n, v) :- export_decl("client", en, _, "EXPORT_ALL", _, _, _, _, mod, x), export_all_is_spread(en), + export_reexport(_, _, imp, x), import_module(imp, src), + export_var(src, n, v), n != "default". +export_var(mod, en, v) :- export_decl("client", en, ln, form, _, _, _, _, mod, x), form != "EXPORT_ALL", + export_reexport(_, _, imp, x), import_module(imp, src), ln != "", + export_var(src, ln, v). +// ── import_var(ImportHash, Variable) — what a named import binding stands for ─ +import_var(imp, v) :- import_decl("client", _, _, bf, n, _, _, _, imp), import_binding_is_named(bf), n != "", + !contains(".", n), import_module(imp, mod), export_var(mod, n, v). + +// ── the site ──────────────────────────────────────────────────────────────── +js_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER", _, e), expr_binding("client", v, e), js_module_var(v). +js_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER", _, e), expr_binding("client", lv, e), + var_import("client", imp, lv), import_var(imp, v). +// A plain `=` never reads the old value; `+=` and `++` read and then write. +js_var_written(e) :- js_var_read_target(e, _), expr_kind(_, "ASSIGNMENT", _, a), expr_child(_, a, "ASSIGNMENT_TARGET", _, e). +js_var_written(e) :- js_var_read_target(e, _), expr_kind(_, "UNARY", _, u), expr_operator(_, op, u), update_operator(op), + expr_child(_, u, _, _, e). +js_var_write_only(e) :- js_var_read_target(e, _), expr_kind(_, "ASSIGNMENT", _, a), expr_operator(_, "=", a), + expr_child(_, a, "ASSIGNMENT_TARGET", _, e). +js_var_access(e, "write") :- js_var_written(e), js_var_write_only(e). +js_var_access(e, "readwrite") :- js_var_written(e), !js_var_write_only(e). +js_var_access(e, "read") :- js_var_read_target(e, _), !js_var_written(e). +// the caller: the callable the expression is written in, else the module initializer +js_var_read_from(e, m) :- js_var_read_target(e, _), expr_owner("client", m, _, e), m != "". +js_var_read_from(e, init) :- js_var_read_target(e, _), expr_owner("client", "", mod, e), module_init(_, init, mod). + +field_access(e, caller, v, "client", "known_edge", acc) :- js_var_read_target(e, v), + js_var_read_from(e, caller), js_var_access(e, acc). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 7f73c459..66bbbbab 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -232,6 +232,18 @@ .decl var_owner_method(c0:symbol, c1:symbol, c2:symbol) .decl var_type_name(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl var_pattern_root(c0:symbol, c1:symbol, c2:symbol) + +// ── call-edge-generation/field_access.dl ── +.decl js_module_var(c0:symbol) +.decl js_not_a_data_binding(c0:symbol) +.decl export_var(c0:symbol, c1:symbol, c2:symbol) +.decl import_var(c0:symbol, c1:symbol) +.decl js_var_read_target(c0:symbol, c1:symbol) +.decl js_var_written(c0:symbol) +.decl js_var_write_only(c0:symbol) +.decl js_var_access(c0:symbol, c1:symbol) +.decl js_var_read_from(c0:symbol, c1:symbol) +.decl field_access(c0:symbol, c1:symbol, c2:symbol, c3:symbol, c4:symbol, c5:symbol) .decl var_binding_form(c0:symbol, c1:symbol, c2:symbol) // ── resolution/module-graph.dl ── diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index 1e8b5aac..cd4d00c1 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -42,3 +42,4 @@ package_entry package-entry.csv import_staged_package_unreached import-staged-package-unreached.csv member_write_refused member-write-refused.csv jsx_renders jsx-renders.csv +field_access field-access.csv diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts new file mode 100644 index 00000000..f78a28dc --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/cfg.ts @@ -0,0 +1,21 @@ +// A module-scope const read through member access, as a call argument, in a template and +// under a type query: every use of CFG / S / TOKENS below binds to THIS file's declaration. +export const CFG = { name: 'a', port: 1 } as const; +export const S = mk({}); +export const TOKENS = { Store: 'store' } as const; +export let counter = 0; +// a const that holds a function is a function: its uses are calls, not reads (control) +export const make = (n: number) => n + 1; + +export function mk(o: object): object { + return o; +} + +export function localUse(): string { + return CFG.name; +} + +export function bump(): void { + counter += 1; + counter = 0; +} diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts new file mode 100644 index 00000000..348509d6 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/a/src/index.ts @@ -0,0 +1 @@ +export * from './cfg'; diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts b/graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts new file mode 100644 index 00000000..97b17b45 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/a/test/t.ts @@ -0,0 +1,47 @@ +import { CFG, S, TOKENS, make } from '../src'; + +function use(x: unknown): void {} + +export function viaMember(): void { + use(CFG.name); +} + +export function asArgument(): void { + use(S); +} + +export function inTemplate(): string { + return `${CFG.name}-${TOKENS.Store}`; +} + +export function callsTheFunction(): number { + return make(1); +} + +export function shadowed(): string { + // a local of the same name: the read is the local's, not the module's (control) + const CFG = { name: 'local' }; + return CFG.name; +} + +// a read in a type: at module level it is the module's, in a signature the function's +type T = typeof S; + +export function annotated(x: typeof CFG): string { + return x.name; +} + +// a parameter of the same name: `typeof TOKENS` here is the parameter's type (control) +export function shadowedInType(TOKENS: number): typeof TOKENS { + return TOKENS; +} + +function Route(x: unknown): MethodDecorator { return () => {}; } +function Body(x: unknown): ParameterDecorator { return () => {}; } +class Pipe { constructor(readonly s: unknown) {} } + +export class Handler { + // a read in a decorator is the decorated method's: on the method, and on its parameter + @Route(TOKENS.Store) + handle(@Body(new Pipe(S)) body: unknown): void {} +} diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts b/graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts new file mode 100644 index 00000000..74ad08dd --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/b/src/settings.ts @@ -0,0 +1,3 @@ +// The same names, declared again in a sibling package: reads here bind here (control). +export const CFG = { name: 'b', port: 2 } as const; +export const S = {}; diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts b/graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts new file mode 100644 index 00000000..90a49c65 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/b/test/sibling.ts @@ -0,0 +1,5 @@ +import { CFG, S } from '../src/settings'; + +export function siblingRead(): unknown { + return [CFG.port, S]; +} diff --git a/graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json b/graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json new file mode 100644 index 00000000..65ed3564 --- /dev/null +++ b/graph/test/typescript/cases/86-module-variable-reads/src/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "experimentalDecorators": true, + "noEmit": true + } +} diff --git a/graph/test/typescript/expected/17-constrained-generics.fields b/graph/test/typescript/expected/17-constrained-generics.fields index 51e56615..f0dbf2a4 100644 --- a/graph/test/typescript/expected/17-constrained-generics.fields +++ b/graph/test/typescript/expected/17-constrained-generics.fields @@ -1,3 +1,7 @@ ambiguous_unknown read consumer#ownConstraint(T) -> - ambiguous_unknown read consumer#ownConstraintNamed(T) -> - ambiguous_unknown read curried-local#widest(unknown) -> - +known_edge read consumer#dataFirst() -> consumer#rows +known_edge read consumer#localDataFirst() -> consumer#rows +known_edge read consumer#localWidest() -> consumer#rows +known_edge read consumer#widestFirst() -> consumer#rows diff --git a/graph/test/typescript/expected/17-constrained-generics.fields-oracle b/graph/test/typescript/expected/17-constrained-generics.fields-oracle index 320aafb9..d6d71ba2 100644 --- a/graph/test/typescript/expected/17-constrained-generics.fields-oracle +++ b/graph/test/typescript/expected/17-constrained-generics.fields-oracle @@ -1,7 +1,7 @@ 17-constrained-generics [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 3 resolved 0 (0.0%) - tiers ambiguous_unknown=3 - access read=3 - not scored: 3 rows whose target is not a client declaration + sites 7 resolved 4 (57.1%) + tiers ambiguous_unknown=3 known_edge=4 + access read=7 + not scored: 7 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/36-function-value-containers.fields b/graph/test/typescript/expected/36-function-value-containers.fields index e69de29b..741ac6a1 100644 --- a/graph/test/typescript/expected/36-function-value-containers.fields +++ b/graph/test/typescript/expected/36-function-value-containers.fields @@ -0,0 +1 @@ +known_edge read r229#callObjectLiteral(number) -> r229#handlers diff --git a/graph/test/typescript/expected/36-function-value-containers.fields-oracle b/graph/test/typescript/expected/36-function-value-containers.fields-oracle index 6fb4ee24..152dfcc5 100644 --- a/graph/test/typescript/expected/36-function-value-containers.fields-oracle +++ b/graph/test/typescript/expected/36-function-value-containers.fields-oracle @@ -1,9 +1,9 @@ 36-function-value-containers [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 2 rows whose target is not a client declaration MISSING r229#callClassMembers(number) READ Holder#viaField MISSING r229#callObjectLiteral(number) READ r229#viaArrow diff --git a/graph/test/typescript/expected/49-indexed-access-return.fields b/graph/test/typescript/expected/49-indexed-access-return.fields index 64f7525f..3f375c0b 100644 --- a/graph/test/typescript/expected/49-indexed-access-return.fields +++ b/graph/test/typescript/expected/49-indexed-access-return.fields @@ -1,2 +1,5 @@ known_edge read main#getDog() -> Zoo#dog +known_edge read main#getDog() -> main#zoo known_edge read main#getDogPlain() -> Zoo#dog +known_edge read main#getDogPlain() -> main#zoo +known_edge read main#pick(T) -> main#zoo diff --git a/graph/test/typescript/expected/49-indexed-access-return.fields-oracle b/graph/test/typescript/expected/49-indexed-access-return.fields-oracle index 2ff6d466..198a9f5b 100644 --- a/graph/test/typescript/expected/49-indexed-access-return.fields-oracle +++ b/graph/test/typescript/expected/49-indexed-access-return.fields-oracle @@ -1,7 +1,7 @@ 49-indexed-access-return [fields] precision 1.0000 (2 correct, 0 wrong) recall 1.0000 (2 of 2 the compiler resolved) - sites 2 resolved 2 (100.0%) - tiers known_edge=2 - access read=2 - not scored: 0 rows whose target is not a client declaration + sites 5 resolved 5 (100.0%) + tiers known_edge=5 + access read=5 + not scored: 3 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/62-field-access-and-type-use.fields b/graph/test/typescript/expected/62-field-access-and-type-use.fields index 32b614fb..5659500c 100644 --- a/graph/test/typescript/expected/62-field-access-and-type-use.fields +++ b/graph/test/typescript/expected/62-field-access-and-type-use.fields @@ -8,6 +8,7 @@ known_edge read Box#run() -> Box#label known_edge read Box#staticRead() -> Box#static total known_edge read Box#viaInterface(Shaped) -> Shaped#width known_edge read Narrow#own() -> Narrow#width +known_edge read members#Shaped2() -> members#Marker2 known_edge readwrite Box#compound(number) -> Box#width known_edge readwrite Box#decrement() -> Box#hidden known_edge readwrite Box#increment() -> Box#hidden diff --git a/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle b/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle index 5964a26b..e23a07d7 100644 --- a/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle +++ b/graph/test/typescript/expected/62-field-access-and-type-use.fields-oracle @@ -1,7 +1,7 @@ 62-field-access-and-type-use [fields] precision 1.0000 (20 correct, 0 wrong) recall 1.0000 (20 of 20 the compiler resolved) - sites 19 resolved 18 (94.7%) - tiers ambiguous_unknown=1 known_edge=18 - access read=11 readwrite=3 write=5 - not scored: 1 rows whose target is not a client declaration + sites 20 resolved 19 (95.0%) + tiers ambiguous_unknown=1 known_edge=19 + access read=12 readwrite=3 write=5 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/63-annotated-callable-member.fields b/graph/test/typescript/expected/63-annotated-callable-member.fields index e69de29b..fe88d80b 100644 --- a/graph/test/typescript/expected/63-annotated-callable-member.fields +++ b/graph/test/typescript/expected/63-annotated-callable-member.fields @@ -0,0 +1 @@ +known_edge read members#callVarAmbient() -> members#varAmbient diff --git a/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle b/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle index 8e35ea27..146bd47f 100644 --- a/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle +++ b/graph/test/typescript/expected/63-annotated-callable-member.fields-oracle @@ -1,9 +1,9 @@ 63-annotated-callable-member [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + 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 members#callPropInline(PropInline) READ PropInline#run MISSING members#callPropNamed(PropNamed) READ PropNamed#run diff --git a/graph/test/typescript/expected/64-array-dispatch.fields b/graph/test/typescript/expected/64-array-dispatch.fields index b1252eaf..12ff9d9f 100644 --- a/graph/test/typescript/expected/64-array-dispatch.fields +++ b/graph/test/typescript/expected/64-array-dispatch.fields @@ -1 +1,4 @@ +known_edge read pipeline#runByName(Node) -> pipeline#byName +known_edge read pipeline#runIndexed(Node) -> pipeline#steps +known_edge read pipeline#runIterated(Node) -> pipeline#steps known_edge read steps#measure(Node) -> { width: number }#width diff --git a/graph/test/typescript/expected/64-array-dispatch.fields-oracle b/graph/test/typescript/expected/64-array-dispatch.fields-oracle index cedeaacf..86bafa38 100644 --- a/graph/test/typescript/expected/64-array-dispatch.fields-oracle +++ b/graph/test/typescript/expected/64-array-dispatch.fields-oracle @@ -1,10 +1,10 @@ 64-array-dispatch [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 3 rows whose target is not a client declaration WRONG steps#measure(Node) READ { width: number }#width MISSING pipeline#runByName(Node) READ pipeline#square MISSING steps#measure(Node) READ steps#width diff --git a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields index e69de29b..5310006c 100644 --- a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields +++ b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields @@ -0,0 +1,3 @@ +known_edge read fewer#callAmbient() -> fewer#ambient +known_edge read fewer#callSiblingsOne() -> fewer#siblings +known_edge read fewer#callSiblingsTwo() -> fewer#siblings diff --git a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle index 0d95ec28..a5e21475 100644 --- a/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle +++ b/graph/test/typescript/expected/65-annotated-callable-fewer-parameters.fields-oracle @@ -1,8 +1,8 @@ 65-annotated-callable-fewer-parameters [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 3 resolved 3 (100.0%) + tiers known_edge=3 + access read=3 + not scored: 3 rows whose target is not a client declaration MISSING fewer#callHolderRun(Holder) READ Holder#run diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.fields b/graph/test/typescript/expected/69-named-and-inline-handlers.fields index e69de29b..ff8c71ee 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.fields +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.fields @@ -0,0 +1,3 @@ +known_edge read routes#() -> routes#app +known_edge read routes#() -> routes#cache +known_edge read routes#() -> routes#headers diff --git a/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle b/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle index ba7a4326..f63426fd 100644 --- a/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle +++ b/graph/test/typescript/expected/69-named-and-inline-handlers.fields-oracle @@ -1,7 +1,7 @@ 69-named-and-inline-handlers [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 4 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields index 97e25270..c12de5c7 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields @@ -1 +1,2 @@ known_edge read fetcher#get(string,Options) -> Options#fetch +known_edge read pool#main() -> pool#fixed diff --git a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle index 91b3cf30..7a227b92 100644 --- a/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle +++ b/graph/test/typescript/expected/72-object-literal-and-expression-callees.fields-oracle @@ -1,8 +1,8 @@ 72-object-literal-and-expression-callees [fields] precision 1.0000 (1 correct, 0 wrong) recall 0.5000 (1 of 2 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 1 rows whose target is not a client declaration MISSING registry#price(number) READ Registry#static format diff --git a/graph/test/typescript/expected/74-jsx-component-forms.fields b/graph/test/typescript/expected/74-jsx-component-forms.fields index 1aa6da98..6dcde21d 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.fields +++ b/graph/test/typescript/expected/74-jsx-component-forms.fields @@ -1,6 +1,11 @@ ambiguous_unknown read parts#Badge(?) -> - known_edge read Panel#render() -> Panel#props known_edge read Panel#render() -> { title: string }#title +known_edge read app#App() -> app#Settings +known_edge read app#App() -> parts#Field +known_edge read app#App() -> parts#UserCard +known_edge read app#App() -> parts#ui +known_edge read app#useFactory() -> parts#factory known_edge read parts#Avatar({ src: string }) -> { src: string }#src known_edge read parts#Chip({ text: string }) -> { text: string }#text known_edge read parts#FieldImpl({ label: string },unknown) -> { label: string }#label diff --git a/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle b/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle index da9a8ee2..e2cf577f 100644 --- a/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle +++ b/graph/test/typescript/expected/74-jsx-component-forms.fields-oracle @@ -1,10 +1,10 @@ 74-jsx-component-forms [fields] precision 0.2857 (2 correct, 5 wrong) recall 0.2500 (2 of 8 the compiler resolved) - sites 8 resolved 7 (87.5%) - tiers ambiguous_unknown=1 known_edge=7 - access read=7 write=1 - not scored: 1 rows whose target is not a client declaration + sites 13 resolved 12 (92.3%) + tiers ambiguous_unknown=1 known_edge=12 + access read=12 write=1 + not scored: 6 rows whose target is not a client declaration WRONG Panel#render() READ { title: string }#title WRONG parts#Avatar({ src: string }) READ { src: string }#src WRONG parts#Chip({ text: string }) READ { text: string }#text diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields index 3da15a75..82a233a4 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields @@ -2,4 +2,14 @@ ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#() -> - +known_edge read app#Page() -> app#DynAwait +known_edge read app#Page() -> app#DynDefault +known_edge read app#Page() -> app#DynNamed +known_edge read app#Page() -> app#LazyBlock +known_edge read app#Page() -> app#LazyDefault +known_edge read app#Page() -> app#LazyNamed +known_edge read app#Page() -> app#Made +known_edge read app#Page() -> app#Memoed +known_edge read app#Page() -> app#Observed +known_edge read app#Page() -> app#Thing known_edge read ui/Button#Button({ label: string }) -> { label: string }#label diff --git a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle index 3617bfdb..aaddbd64 100644 --- a/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle +++ b/graph/test/typescript/expected/75-jsx-wrapper-guards.fields-oracle @@ -1,9 +1,9 @@ 75-jsx-wrapper-guards [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 5 resolved 1 (20.0%) - tiers ambiguous_unknown=4 known_edge=1 - access read=5 - not scored: 4 rows whose target is not a client declaration + sites 15 resolved 11 (73.3%) + tiers ambiguous_unknown=4 known_edge=11 + access read=15 + not scored: 14 rows whose target is not a client declaration WRONG ui/Button#Button({ label: string }) READ { label: string }#label MISSING ui/Button#Button({ label: string }) READ ui/Button#label diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields index ce8e4343..da638d2c 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields @@ -1,2 +1,6 @@ ambiguous_unknown read app#(?) -> - ambiguous_unknown read app#useName() -> - +known_edge read app#App() -> app#LazyDefault +known_edge read app#App() -> app#LazyNamedObj +known_edge read app#App() -> app#MemoPicked +known_edge read app#App() -> parts#MemoFwd diff --git a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle index c4ca47b6..371b7582 100644 --- a/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle +++ b/graph/test/typescript/expected/76-jsx-default-nested-wrappers.fields-oracle @@ -1,7 +1,7 @@ 76-jsx-default-nested-wrappers [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 2 resolved 0 (0.0%) - tiers ambiguous_unknown=2 - access read=2 - not scored: 2 rows whose target is not a client declaration + sites 6 resolved 4 (66.7%) + tiers ambiguous_unknown=2 known_edge=4 + access read=6 + not scored: 6 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/77-package-published-entries.fields b/graph/test/typescript/expected/77-package-published-entries.fields index 0233e076..ccc83f66 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields +++ b/graph/test/typescript/expected/77-package-published-entries.fields @@ -1,2 +1,3 @@ ambiguous_unknown read index#useStore(number) -> - ambiguous_unknown read plugins/logger#() -> - +known_edge read vanilla#() -> vanilla#DEFAULT_INITIAL diff --git a/graph/test/typescript/expected/77-package-published-entries.fields-oracle b/graph/test/typescript/expected/77-package-published-entries.fields-oracle index 85d59b99..72c9bcde 100644 --- a/graph/test/typescript/expected/77-package-published-entries.fields-oracle +++ b/graph/test/typescript/expected/77-package-published-entries.fields-oracle @@ -1,8 +1,8 @@ 77-package-published-entries [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 2 resolved 0 (0.0%) - tiers ambiguous_unknown=2 - access read=2 - not scored: 2 rows whose target is not a client declaration + sites 3 resolved 1 (33.3%) + tiers ambiguous_unknown=2 known_edge=1 + access read=3 + not scored: 3 rows whose target is not a client declaration MISSING index#useStore(number) READ vanilla#value diff --git a/graph/test/typescript/expected/78-callable-collections.fields b/graph/test/typescript/expected/78-callable-collections.fields index b5e742ff..a43f3a76 100644 --- a/graph/test/typescript/expected/78-callable-collections.fields +++ b/graph/test/typescript/expected/78-callable-collections.fields @@ -1,2 +1,17 @@ ambiguous_unknown read pipeline#countIdle() -> - +known_edge read pipeline#() -> pipeline#registry +known_edge read pipeline#() -> pipeline#steps +known_edge read pipeline#countIdle() -> pipeline#idle +known_edge read pipeline#keysOnly() -> pipeline#table +known_edge read pipeline#runByKey(Node,'grow' | 'shrink') -> pipeline#table +known_edge read pipeline#runByName(Node) -> pipeline#table +known_edge read pipeline#runForEach(Node) -> pipeline#steps +known_edge read pipeline#runFrozen(Node) -> pipeline#frozen +known_edge read pipeline#runImported(Node) -> steps#exportedSteps +known_edge read pipeline#runIndexed(Node,number) -> pipeline#steps +known_edge read pipeline#runIterated(Node) -> pipeline#steps +known_edge read pipeline#runReduce(Node) -> pipeline#frozen +known_edge read pipeline#runRegistry(Node) -> pipeline#extended +known_edge read pipeline#runRegistry(Node) -> pipeline#registry +known_edge read pipeline#runValues(Node) -> pipeline#table known_edge read steps#measure(Node) -> { width: number }#width diff --git a/graph/test/typescript/expected/78-callable-collections.fields-oracle b/graph/test/typescript/expected/78-callable-collections.fields-oracle index 8fc5cf9a..0fe6772f 100644 --- a/graph/test/typescript/expected/78-callable-collections.fields-oracle +++ b/graph/test/typescript/expected/78-callable-collections.fields-oracle @@ -1,10 +1,10 @@ 78-callable-collections [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 2 the compiler resolved) - sites 2 resolved 1 (50.0%) - tiers ambiguous_unknown=1 known_edge=1 - access read=2 - not scored: 1 rows whose target is not a client declaration + sites 18 resolved 17 (94.4%) + tiers ambiguous_unknown=1 known_edge=17 + access read=18 + not scored: 17 rows whose target is not a client declaration WRONG steps#measure(Node) READ { width: number }#width MISSING pipeline#runByName(Node) READ pipeline#shrink MISSING steps#measure(Node) READ steps#width diff --git a/graph/test/typescript/expected/78-cross-process-destinations.fields b/graph/test/typescript/expected/78-cross-process-destinations.fields index 4fea0459..ea9edbd9 100644 --- a/graph/test/typescript/expected/78-cross-process-destinations.fields +++ b/graph/test/typescript/expected/78-cross-process-destinations.fields @@ -1 +1,14 @@ ambiguous_unknown read client/client#dailyReport(string) -> - +known_edge read client/axios#() -> axios.d#axios +known_edge read client/client#() -> axios.d#axios +known_edge read client/client#audit() -> axios.d#axios +known_edge read client/client#cached() -> client#cache +known_edge read client/client#dailyReport(string) -> axios.d#axios +known_edge read client/client#dailyReport(string) -> config#config +known_edge read client/client#invoice(string) -> client#billing +known_edge read client/client#listAll() -> paths#ORDERS +known_edge read client/client#placeOrder(unknown) -> axios.d#axios +known_edge read client/client#placeOrder(unknown) -> paths#ORDERS +known_edge read server/orders#() -> orders#app +known_edge read server/orders#() -> orders#router +known_edge read server/orders#() -> paths#ORDERS 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..1a7206f1 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,8 @@ 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 - access read=1 - not scored: 1 rows whose target is not a client declaration + sites 18 resolved 17 (94.4%) + tiers ambiguous_unknown=1 known_edge=17 + access read=18 + not scored: 18 rows whose target is not a client declaration MISSING client/client#dailyReport(string) READ shared/config#reportsUrl diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields index e69de29b..ca48bcea 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields @@ -0,0 +1,12 @@ +known_edge read app#(?) -> rate#RATE +known_edge read app#() -> rate#RATE +known_edge read app#(?) -> rate#RATE +known_edge read app#boot() -> app#host +known_edge read app#boot() -> plugin#plugin +known_edge read app#bootBare() -> app#host +known_edge read app#bootSettings() -> app#host +known_edge read app#bootSettings() -> plugin#settings +known_edge read app#record(number) -> app#seen +known_edge read store#known() -> store#subs +known_edge read store#store() -> store#handlers +known_edge read store#store() -> store#subs diff --git a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle index be4f8be6..8c2412a1 100644 --- a/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle +++ b/graph/test/typescript/expected/78-hof-callback-at-library-boundary.fields-oracle @@ -1,7 +1,7 @@ 78-hof-callback-at-library-boundary [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 12 resolved 12 (100.0%) + tiers known_edge=12 + access read=12 + not scored: 12 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/79-package-published-members.fields b/graph/test/typescript/expected/79-package-published-members.fields index e69de29b..ec69ab45 100644 --- a/graph/test/typescript/expected/79-package-published-members.fields +++ b/graph/test/typescript/expected/79-package-published-members.fields @@ -0,0 +1,3 @@ +known_edge read src/bound#() -> bound#engine +known_edge read src/index.test#() -> api#api +known_edge read src/index.test#() -> bound#produce diff --git a/graph/test/typescript/expected/79-package-published-members.fields-oracle b/graph/test/typescript/expected/79-package-published-members.fields-oracle index bf1e0d9b..f5234231 100644 --- a/graph/test/typescript/expected/79-package-published-members.fields-oracle +++ b/graph/test/typescript/expected/79-package-published-members.fields-oracle @@ -1,7 +1,7 @@ 79-package-published-members [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 5 resolved 5 (100.0%) + tiers known_edge=5 + access read=5 + not scored: 5 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-generic-return-receiver.fields b/graph/test/typescript/expected/80-generic-return-receiver.fields index b7d27851..e6f9deac 100644 --- a/graph/test/typescript/expected/80-generic-return-receiver.fields +++ b/graph/test/typescript/expected/80-generic-return-receiver.fields @@ -1,2 +1,10 @@ known_edge read Circ#area() -> Circ#r known_edge read Sq#area() -> Sq#s +known_edge read use#() -> use#WARRIOR +known_edge read use#() -> use#container +known_edge read use#ctlAnyIndex(string) -> use#anyBag +known_edge read use#indexValue(string) -> use#shapes +known_edge read use#recordArea(string,number) -> use#makers +known_edge read use#run() -> use#WARRIOR +known_edge read use#run() -> use#container +known_edge read use#totalArea(string,number) -> use#factories diff --git a/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle b/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle index f770da18..09fc58b0 100644 --- a/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle +++ b/graph/test/typescript/expected/80-generic-return-receiver.fields-oracle @@ -1,7 +1,7 @@ 80-generic-return-receiver [fields] precision 1.0000 (2 correct, 0 wrong) recall 1.0000 (2 of 2 the compiler resolved) - sites 4 resolved 4 (100.0%) - tiers known_edge=4 - access read=4 - not scored: 0 rows whose target is not a client declaration + sites 12 resolved 12 (100.0%) + tiers known_edge=12 + access read=12 + not scored: 8 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..f2c88fe3 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,9 @@ ambiguous_unknown read local#drive() -> - ambiguous_unknown read local#trimName() -> - ambiguous_unknown read use#callNested() -> - +known_edge read local#drive() -> local#handlers +known_edge read local#trimName() -> local#cfg +known_edge read use#callGreeter() -> api#greeter +known_edge read use#callNested() -> api#api +known_edge read use#callTop() -> api#api +known_edge read use#keepUnrelated() -> use#unrelated 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..70242926 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,10 +1,10 @@ 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 - access read=6 - not scored: 6 rows whose target is not a client declaration + sites 17 resolved 11 (64.7%) + tiers ambiguous_unknown=6 known_edge=11 + access read=17 + not scored: 17 rows whose target is not a client declaration MISSING local#drive() READ local#jobs MISSING local#drive() READ local#run MISSING local#trimName() READ local#name diff --git a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields index e69de29b..d5c739df 100644 --- a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields +++ b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields @@ -0,0 +1,9 @@ +known_edge read app#() -> parts#Child +known_edge read app#() -> parts#FnForm +known_edge read app#() -> parts#NuxtCard +known_edge read app#() -> parts#Panel +known_edge read app#() -> parts#Store +known_edge read app#() -> reg#Button +known_edge read app#() -> reg#Local +known_edge read card#render() -> parts#Child +known_edge read reg#() -> reg#_Local diff --git a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle index 7aa79d05..cc14c676 100644 --- a/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle +++ b/graph/test/typescript/expected/80-vue-definecomponent-jsx.fields-oracle @@ -1,7 +1,7 @@ 80-vue-definecomponent-jsx [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 9 resolved 9 (100.0%) + tiers known_edge=9 + access read=9 + not scored: 9 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/80-vue-sfc.fields b/graph/test/typescript/expected/80-vue-sfc.fields index 2c586263..7fae20e7 100644 --- a/graph/test/typescript/expected/80-vue-sfc.fields +++ b/graph/test/typescript/expected/80-vue-sfc.fields @@ -1 +1,3 @@ +known_edge read Comp.vue#() -> Comp#total known_edge read child#Child({ msg: string }) -> { msg: string }#msg +known_edge read shims-vue#() -> shims-vue.d#component diff --git a/graph/test/typescript/expected/80-vue-sfc.fields-oracle b/graph/test/typescript/expected/80-vue-sfc.fields-oracle index 706b5d61..4288e38b 100644 --- a/graph/test/typescript/expected/80-vue-sfc.fields-oracle +++ b/graph/test/typescript/expected/80-vue-sfc.fields-oracle @@ -1,9 +1,9 @@ 80-vue-sfc [fields] precision 0.0000 (0 correct, 1 wrong) recall 0.0000 (0 of 1 the compiler resolved) - sites 1 resolved 1 (100.0%) - tiers known_edge=1 - access read=1 - not scored: 0 rows whose target is not a client declaration + sites 4 resolved 4 (100.0%) + tiers known_edge=4 + access read=4 + not scored: 3 rows whose target is not a client declaration WRONG child#Child({ msg: string }) READ { msg: string }#msg MISSING child#Child({ msg: string }) READ child#msg diff --git a/graph/test/typescript/expected/81-vue-component-tag.fields b/graph/test/typescript/expected/81-vue-component-tag.fields index e69de29b..d2a02654 100644 --- a/graph/test/typescript/expected/81-vue-component-tag.fields +++ b/graph/test/typescript/expected/81-vue-component-tag.fields @@ -0,0 +1 @@ +known_edge read shims-vue#() -> shims-vue.d#component diff --git a/graph/test/typescript/expected/81-vue-component-tag.fields-oracle b/graph/test/typescript/expected/81-vue-component-tag.fields-oracle index 7a65896b..21219347 100644 --- a/graph/test/typescript/expected/81-vue-component-tag.fields-oracle +++ b/graph/test/typescript/expected/81-vue-component-tag.fields-oracle @@ -1,7 +1,7 @@ 81-vue-component-tag [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 1 resolved 1 (100.0%) + tiers known_edge=1 + access read=1 + not scored: 1 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields b/graph/test/typescript/expected/83-closed-world-dispatch.fields index e69de29b..aa725fb8 100644 --- a/graph/test/typescript/expected/83-closed-world-dispatch.fields +++ b/graph/test/typescript/expected/83-closed-world-dispatch.fields @@ -0,0 +1,2 @@ +known_edge read handlers#main() -> handlers#registry +known_edge read handlers#register(HandlerClass) -> handlers#registry diff --git a/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle index 3355e740..966f7948 100644 --- a/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle +++ b/graph/test/typescript/expected/83-closed-world-dispatch.fields-oracle @@ -1,7 +1,7 @@ 83-closed-world-dispatch [fields] precision 0.0000 (0 correct, 0 wrong) recall 0.0000 (0 of 0 the compiler resolved) - sites 0 resolved 0 - tiers - access - not scored: 0 rows whose target is not a client declaration + sites 2 resolved 2 (100.0%) + tiers known_edge=2 + access read=2 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields b/graph/test/typescript/expected/85-workspace-package-import.fields index 565a7ee6..be580e37 100644 --- a/graph/test/typescript/expected/85-workspace-package-import.fields +++ b/graph/test/typescript/expected/85-workspace-package-import.fields @@ -1 +1,3 @@ known_edge read S#run() -> S#bus +known_edge read S#run() -> index#K +known_edge read S#run() -> zod#z diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields-oracle b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle index 44b1f6d7..346e6652 100644 --- a/graph/test/typescript/expected/85-workspace-package-import.fields-oracle +++ b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle @@ -1,7 +1,7 @@ 85-workspace-package-import [fields] 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: 0 rows whose target is not a client declaration + sites 3 resolved 3 (100.0%) + tiers known_edge=3 + access read=3 + not scored: 2 rows whose target is not a client declaration diff --git a/graph/test/typescript/expected/86-module-variable-reads.edges b/graph/test/typescript/expected/86-module-variable-reads.edges new file mode 100644 index 00000000..c94f0255 --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.edges @@ -0,0 +1,7 @@ +known_edge CONSTRUCTOR_CALL Handler#handle(unknown) @L46 -> Pipe#(unknown) +known_edge DECORATOR_CALL Handler#handle(unknown) @L45 -> a/test/t#Route(unknown) +known_edge DECORATOR_CALL Handler#handle(unknown) @L46 -> a/test/t#Body(unknown) +known_edge FUNCTION_CALL a/src/cfg#() @L4 -> a/src/cfg#mk(object) +known_edge FUNCTION_CALL a/test/t#asArgument() @L10 -> a/test/t#use(unknown) +known_edge FUNCTION_CALL a/test/t#callsTheFunction() @L18 -> a/src/cfg#make(number) +known_edge FUNCTION_CALL a/test/t#viaMember() @L6 -> a/test/t#use(unknown) diff --git a/graph/test/typescript/expected/86-module-variable-reads.entries b/graph/test/typescript/expected/86-module-variable-reads.entries new file mode 100644 index 00000000..9b291f2d --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.entries @@ -0,0 +1,15 @@ +── entry_point (14) ── + exported_from_entry_module a/src/cfg#bump cfg.ts:18 + exported_from_entry_module a/src/cfg#localUse cfg.ts:14 + exported_from_entry_module a/src/cfg#mk cfg.ts:10 + exported_from_entry_module a/test/t#annotated t.ts:30 + exported_from_entry_module a/test/t#asArgument t.ts:9 + exported_from_entry_module a/test/t#callsTheFunction t.ts:17 + exported_from_entry_module a/test/t#inTemplate t.ts:13 + exported_from_entry_module a/test/t#shadowed t.ts:21 + exported_from_entry_module a/test/t#shadowedInType t.ts:35 + exported_from_entry_module a/test/t#viaMember t.ts:5 + exported_from_entry_module b/test/sibling#siblingRead sibling.ts:3 + unimported_module a/src/cfg# cfg.ts:1 + unimported_module a/test/t# t.ts:1 + unimported_module b/test/sibling# sibling.ts:1 diff --git a/graph/test/typescript/expected/86-module-variable-reads.fields b/graph/test/typescript/expected/86-module-variable-reads.fields new file mode 100644 index 00000000..b5727e5d --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.fields @@ -0,0 +1,20 @@ +ambiguous_unknown read a/src/cfg#localUse() -> - +ambiguous_unknown read a/test/t#() -> - +ambiguous_unknown read a/test/t#annotated(typeof CFG) -> - +ambiguous_unknown read a/test/t#inTemplate() -> - +ambiguous_unknown read a/test/t#shadowed() -> - +ambiguous_unknown read a/test/t#viaMember() -> - +ambiguous_unknown read b/test/sibling#siblingRead() -> - +known_edge read Handler#handle(unknown) -> cfg#S +known_edge read Handler#handle(unknown) -> cfg#TOKENS +known_edge read a/src/cfg#localUse() -> cfg#CFG +known_edge read a/test/t#() -> cfg#S +known_edge read a/test/t#annotated(typeof CFG) -> cfg#CFG +known_edge read a/test/t#asArgument() -> cfg#S +known_edge read a/test/t#inTemplate() -> cfg#CFG +known_edge read a/test/t#inTemplate() -> cfg#TOKENS +known_edge read a/test/t#viaMember() -> cfg#CFG +known_edge read b/test/sibling#siblingRead() -> settings#CFG +known_edge read b/test/sibling#siblingRead() -> settings#S +known_edge readwrite a/src/cfg#bump() -> cfg#counter +known_edge write a/src/cfg#bump() -> cfg#counter diff --git a/graph/test/typescript/expected/86-module-variable-reads.fields-oracle b/graph/test/typescript/expected/86-module-variable-reads.fields-oracle new file mode 100644 index 00000000..70fbca4e --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.fields-oracle @@ -0,0 +1,15 @@ +86-module-variable-reads [fields] + precision 0.0000 (0 correct, 0 wrong) + recall 0.0000 (0 of 8 the compiler resolved) + sites 21 resolved 13 (61.9%) + tiers ambiguous_unknown=8 known_edge=13 + access read=19 readwrite=1 write=1 + not scored: 21 rows whose target is not a client declaration + MISSING Handler#handle(unknown) READ a/src/cfg#Store + MISSING a/src/cfg#localUse() READ a/src/cfg#name + MISSING a/test/t#annotated(typeof CFG) READ a/src/cfg#name + MISSING a/test/t#inTemplate() READ a/src/cfg#Store + MISSING a/test/t#inTemplate() READ a/src/cfg#name + MISSING a/test/t#shadowed() READ a/test/t#name + MISSING a/test/t#viaMember() READ a/src/cfg#name + MISSING b/test/sibling#siblingRead() READ b/src/settings#port diff --git a/graph/test/typescript/expected/86-module-variable-reads.oracle b/graph/test/typescript/expected/86-module-variable-reads.oracle new file mode 100644 index 00000000..fa4000b6 --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.oracle @@ -0,0 +1 @@ +oracle=7 engine=7 agree=7 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/86-module-variable-reads.type-use b/graph/test/typescript/expected/86-module-variable-reads.type-use new file mode 100644 index 00000000..a403269b --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.type-use @@ -0,0 +1,5 @@ +ambiguous_unknown AS_TARGET 0 a/src/cfg [EXPRESSION] -> - +ambiguous_unknown AS_TARGET 0 b/src/settings [EXPRESSION] -> - +ambiguous_unknown DECORATOR_TYPE 0 Handler [DECORATOR] -> - +ambiguous_unknown METHOD_RETURN 0 a/test/t [METHOD] -> - +known_edge OBJECT_CREATION_TYPE 0 Handler [EXPRESSION] -> Pipe diff --git a/graph/test/typescript/expected/86-module-variable-reads.types-oracle b/graph/test/typescript/expected/86-module-variable-reads.types-oracle new file mode 100644 index 00000000..e0d90aa4 --- /dev/null +++ b/graph/test/typescript/expected/86-module-variable-reads.types-oracle @@ -0,0 +1,7 @@ +86-module-variable-reads [types] + precision 1.0000 (1 correct, 0 wrong) + recall 1.0000 (1 of 1 the compiler resolved) + sites 8 resolved 1 (12.5%) + tiers ambiguous_unknown=7 known_edge=1 + contexts AS_TARGET=3 DECORATOR_TYPE=2 METHOD_RETURN=2 OBJECT_CREATION_TYPE=1 + not scored: 7 rows whose target is not a client declaration diff --git a/graph/test/typescript/tools/normalize_members.py b/graph/test/typescript/tools/normalize_members.py index 3a47baf3..a969547c 100644 --- a/graph/test/typescript/tools/normalize_members.py +++ b/graph/test/typescript/tools/normalize_members.py @@ -35,6 +35,14 @@ def field_names(ir, prefix='typescript'): return out +def module_var_names(ir, prefix='typescript'): + """variable hash -> (module label, name), for a module-scope variable read by name: its owner is its + module, labelled by the file's stem as the module initializer is (`members#()`).""" + return {r['tsVariableUniqueHash']: (os.path.splitext(os.path.basename(r.get('filePath') or '?'))[0], r['name']) + for r in rows(f'{ir}/all-{prefix}-variables.csv') + if r.get('scopeKind') in ('MODULE_SCOPE', 'GLOBAL_SCOPE', 'AMBIENT_SCOPE', 'NAMESPACE_SCOPE')} + + def type_names(ir, prefix='typescript'): return {t['tsTypeUniqueHash']: (t.get('name') or t.get('qualifiedName') or '?') for t in rows(f'{ir}/all-{prefix}-types.csv')} @@ -53,6 +61,9 @@ def main(): ir, out = args[0], args[1] n = Names(ir, lib_ir) fields = field_names(ir) + # the compiler oracle scores member accesses only: a module variable's read is golden-checked, not oracle-paired + mvars = module_var_names(ir) + fields.update({k: v for k, v in mvars.items() if k not in fields}) types = type_names(ir) if lib_ir and os.path.isdir(lib_ir): fields.update({k: v for k, v in field_names(lib_ir).items() if k not in fields}) @@ -72,7 +83,7 @@ def main(): f"{fields[field][0]}#{fields[field][1]}" if field in fields else f"") if pairs: - if field == '-' or field not in fields: + if field == '-' or field not in fields or field in mvars: continue for d in (['READ'] if access == 'read' else ['WRITE'] if access == 'write' else ['READ', 'WRITE']): diff --git a/graph/typescript/engine/call-edge-generation/field_access.dl b/graph/typescript/engine/call-edge-generation/field_access.dl index d7d0057f..ea00e4c9 100644 --- a/graph/typescript/engine/call-edge-generation/field_access.dl +++ b/graph/typescript/engine/call-edge-generation/field_access.dl @@ -150,3 +150,88 @@ ts_field_access_has_row(e) :- ts_field_access_target(e, f), ts_field_prov(_, f). field_access(e, caller, "-", "-", "ambiguous_unknown", acc) :- ts_field_access_site(e), !ts_field_access_has_row(e), ts_field_access_from(e, caller), ts_field_access_kind(e, acc). + +// ── A MODULE-SCOPE VARIABLE, READ BY NAME ─────────────────────────────────── +// `SERVICE.name`, `f(EnvSchema)`, `TOKENS.IntentStore`: the receiver or argument is an +// IDENTIFIER_REFERENCE that the binder already tied to one declaration -- a module-scope +// variable of its own file (VARIABLE), or an import whose binding import_binds follows, +// through every re-export, to the exported variable (IMPORT_BINDING). Nothing recorded +// that tie as a data edge, so "who reads this exported const" was answered by name, and +// the name matched every same-named const in every sibling package. +// +// The variable is the field of the row: a module is the owner of its variables the way a +// class is the owner of its fields, and consumers read one relation for both. A variable +// that holds a function is a function (`const f = () => ...`): its uses are calls, which +// call_edges already carries, so it is left out here. +ts_module_var(v) :- var_decl("client", _, _, sk, _, _, v), scope_is_module_level(sk), + !var_is_function(v, _). +ts_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER_REFERENCE", _, e), + expr_referenced("client", "VARIABLE", v, e), ts_module_var(v). +ts_var_read_target(e, v) :- expr_kind("client", "IDENTIFIER_REFERENCE", _, e), + expr_referenced("client", "IMPORT_BINDING", ih, e), + import_binds(ih, "client", "VARIABLE", v), ts_module_var(v). +// the direction, by the same law property_written applies to a member +ts_var_written(e) :- ts_var_read_target(e, _), expr_child("client", asg, "LEFT_OPERAND", _, e), + expr_kind("client", ak, _, asg), assignment_kind(ak). +ts_var_written(e) :- ts_var_read_target(e, _), expr_child("client", u, "UNARY_OPERAND", _, e), + expr_kind("client", "UNARY_EXPRESSION", _, u), expr_operator("client", op, u), update_operator(op). +ts_var_write_only(e) :- ts_var_read_target(e, _), expr_child("client", asg, "LEFT_OPERAND", _, e), + expr_kind("client", "ASSIGNMENT_EXPRESSION", _, asg). +ts_var_read_kind(e, "write") :- ts_var_written(e), ts_var_write_only(e). +ts_var_read_kind(e, "readwrite") :- ts_var_written(e), !ts_var_write_only(e). +ts_var_read_kind(e, "read") :- ts_var_read_target(e, _), !ts_var_written(e). +// the caller, as for a property access: the enclosing function, else the module. +// A DECORATOR on a method or on one of its parameters is that method's: `place(@Payload(new +// Pipe(CommandSchema)) c)` reads CommandSchema for place(). The parser gives an expression +// inside a decorator no owner hash, and expr_enclosing_method routes it to the module +// initializer, so the read is found by walking down from the decorator's own expression. +ts_decorated_method(dx, m) :- decorator_expr("client", dx, d), + annotation_on("client", _, _, "METHOD_DECLARATION", m, d), m != "". +ts_decorated_method(dx, m) :- decorator_expr("client", dx, d), + annotation_on("client", _, _, "PARAMETER_DECLARATION", p, d), param_decl("client", _, _, _, m, p), m != "". +ts_under_decorator(dx, dx) :- ts_decorated_method(dx, _). +ts_under_decorator(dx, c) :- ts_under_decorator(dx, p), expr_child("client", p, _, _, c). +ts_var_read_decorated(e, m) :- ts_var_read_target(e, _), expr_owner("client", "DECORATOR", _, _, e), + ts_under_decorator(dx, e), ts_decorated_method(dx, m). +ts_var_read_named(e) :- ts_var_read_decorated(e, _). +ts_var_read_named(e) :- ts_var_read_target(e, _), expr_enclosing_method(e, m), m != "". +ts_var_read_from(e, m) :- ts_var_read_decorated(e, m). +ts_var_read_from(e, m) :- ts_var_read_target(e, _), !ts_var_read_decorated(e, _), + expr_enclosing_method(e, m), m != "". +ts_var_read_from(e, mod) :- ts_var_read_target(e, _), !ts_var_read_named(e), + expr_module("client", mod, e), mod != "". +// one declaration, bound by the binder: known_edge +field_access(e, caller, v, "client", "known_edge", acc) :- ts_var_read_target(e, v), + ts_var_read_from(e, caller), ts_var_read_kind(e, acc). + +// ── THE SAME VARIABLE, READ IN A TYPE: `typeof X` ─────────────────────────── +// `type OrderDto = z.infer` depends on OrderSchema exactly as +// `f(OrderSchema)` does, but the name sits in a TYPE_QUERY type reference, not in an +// expression, so the binder's expression tie above never sees it. The name is bound +// through the module's own scope (name_binds_any: a declaration of this file, or a named +// import followed to the exported variable). Only the bare form `typeof X`: in +// `typeof A.b` the variable read is A, and the reference names b. A parameter or a local +// of the same name in the enclosing callable shadows the module's (control). +// The site is the type reference; the bundle positions it from the type-reference table. +ts_type_query_site(ref, tn, mod) :- type_ref("client", "TYPE_QUERY", _, tn, ctn, _, _, ref), + tn != "", ctn = cat("typeof ", tn), type_ref_module("client", mod, ref). +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, m, "METHOD", ref). +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, p, "METHOD_PARAM", ref), + param_decl("client", _, _, _, m, p). +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, lv, "VARIABLE", ref), + var_decl("client", _, _, _, m, _, lv), m != "". +ts_type_query_method(ref, m) :- ts_type_query_site(ref, _, _), type_ref("client", _, _, _, _, x, "EXPRESSION", ref), + expr_enclosing_method(x, m), m != "". +ts_type_query_shadowed(ref) :- ts_type_query_site(ref, tn, _), ts_type_query_method(ref, m), + param_decl("client", tn, _, _, m, _). +ts_type_query_shadowed(ref) :- ts_type_query_site(ref, tn, _), ts_type_query_method(ref, m), + var_decl("client", tn, _, _, m, _, _). +ts_type_query_read(ref, v) :- ts_type_query_site(ref, tn, mod), !ts_type_query_shadowed(ref), + name_binds_any(mod, tn, "client", "VARIABLE", v), ts_module_var(v). +ts_type_query_has_method(ref) :- ts_type_query_method(ref, _). +field_access(ref, m, v, "client", "known_edge", "read") :- ts_type_query_read(ref, v), + ts_type_query_method(ref, m). +// a type alias, an interface, a class member's annotation: the module's initializer, as for +// any module-level read +field_access(ref, init, v, "client", "known_edge", "read") :- ts_type_query_read(ref, v), + !ts_type_query_has_method(ref), ts_type_query_site(ref, _, mod), module_init("client", init, mod). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 467f2e70..e7ef8989 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -640,6 +640,21 @@ .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) +.decl ts_module_var(c0:symbol) +.decl ts_var_read_target(c0:symbol,c1:symbol) +.decl ts_var_read_from(c0:symbol,c1:symbol) +.decl ts_var_read_named(c0:symbol) +.decl ts_var_written(c0:symbol) +.decl ts_var_write_only(c0:symbol) +.decl ts_var_read_kind(c0:symbol,c1:symbol) +.decl ts_decorated_method(c0:symbol,c1:symbol) +.decl ts_under_decorator(c0:symbol,c1:symbol) +.decl ts_var_read_decorated(c0:symbol,c1:symbol) +.decl ts_type_query_site(c0:symbol,c1:symbol,c2:symbol) +.decl ts_type_query_method(c0:symbol,c1:symbol) +.decl ts_type_query_shadowed(c0:symbol) +.decl ts_type_query_read(c0:symbol,c1:symbol) +.decl ts_type_query_has_method(c0:symbol) // ── TYPE USE (#663) — call-edge-generation/type_use.dl .decl ts_type_use_kind_names_a_type(c0:symbol) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 830ba9a5..7578fa70 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -100,7 +100,9 @@ A = { litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')), - decls=[dict(file='all-typescript-variables.csv', only=lambda r: r.get('scopeKind') == 'MODULE_SCOPE', + # the variable's own hash: the engine's field_access names a module variable by it when an identifier the binder + # tied to it (directly, or through an import binding) reads it + decls=[dict(file='all-typescript-variables.csv', id='tsVariableUniqueHash', only=lambda r: r.get('scopeKind') == 'MODULE_SCOPE', kind=lambda r: 'const' if r.get('isConst') == 'true' else 'variable', name='name', owner=None, filePath='filePath', line='startLine', end='endLine'), dict(file='all-typescript-fields.csv', id='tsFieldUniqueHash', kind=lambda r: 'field', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine'), dict(file='all-typescript-enum-members.csv', kind=lambda r: 'enum_member', name='name', owner='ownerQualifiedName', filePath='filePath', line='startLine', end='endLine')], @@ -137,7 +139,7 @@ A = { # a `function f` or `class C` is also a binding (FUNCTION_DECLARATION_HOISTED, CLASS_TDZ), and `const { C } = # require('./m')` is an import in all but syntax (it carries an importLinkHash): none of them is a variable, and # each made the function or class it names look declared as a second kind, so `impact C` refused as ambiguous - decls=[dict(file='all-javascript-variables.csv', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') not in ('IMPORT_BINDING', 'FUNCTION_DECLARATION_HOISTED', 'CLASS_TDZ') and not r.get('importLinkHash'), + decls=[dict(file='all-javascript-variables.csv', id='jsVariableUniqueHash', only=lambda r: not r.get('ownerMethodLinkHash') and r.get('bindingRegime') not in ('IMPORT_BINDING', 'FUNCTION_DECLARATION_HOISTED', 'CLASS_TDZ') and not r.get('importLinkHash'), kind=lambda r: 'const' if r.get('bindingRegime', '').startswith('CONST') else 'variable', name='name', owner=None, fileVia=('modules', 'ownerModuleLinkHash'), line='startLine', end='endLine'), # a field's owner is its class by hash (`this.x = …` in a constructor, a class field, `F.prototype.x = …`), so it # displays as `Store.items`, not `items`; a computed key (`[Symbol.iterator] = …`) is named by its key expression diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..2b336d60 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -509,6 +509,10 @@ const_holds(ff, fll, m) :- init_alias(ff, fll, n), named(n, m). .decl const_handed(q:symbol, c:symbol, f:symbol, l:number, ff:symbol, fll:number) const_handed(q, c, f, l, ff, fll) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), callable_const(ff, fll), fref(q, c, _, f, l), route_site(f, l), route_arg(c, f, l, n). +// the same where the engine bound the name to this const (an imported or module-level const read through its binding): +// fref leaves such a line to fa_bound, and the hand-off is still a registration, not a read +const_handed(q, c, f, l, ff, fll) :- target(q, "field", fl, _), field(fl, _, n, ff, fll), callable_const(ff, fll), + fa_bound(c, fl, _, f, l), route_site(f, l), route_arg(c, f, l, n). .decl const_route_edge(q:symbol, c:symbol, m:symbol, f:symbol, l:number) const_route_edge(q, c, m, f, l) :- const_handed(q, c, f, l, ff, fll), const_holds(ff, fll, m), handoff_at(c, m, f, l). .decl const_route(q:symbol, c:symbol, cert:symbol, f:symbol, l:number) @@ -530,7 +534,7 @@ const_routed(q, c, f, l) :- const_route_byname(q, c, f, l). fa_bound(c, fl, acc, f, l) :- faccess(c, fl, acc, tier, f, l), tier != "ambiguous_unknown". direct(q, c, "produces", "writes it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "write", f, l). direct(q, c, "produces", "writes it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "readwrite", f, l). -direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "read", f, l). +direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "read", f, l), !const_routed(q, c, f, l). direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl, _), fa_bound(c, fl, "readwrite", f, l). // a caller the engine bound — to this field, or to a DIFFERENT field of the same name. Either way its // name matches say nothing further: the first is already reported above with its direction, and the diff --git a/tests/cases/javascript/module-level-const/case.json b/tests/cases/javascript/module-level-const/case.json new file mode 100644 index 00000000..85110780 --- /dev/null +++ b/tests/cases/javascript/module-level-const/case.json @@ -0,0 +1,18 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "an imported const read through a member access, as an argument, or through an `export *` barrel is resolved to the declaration the import binds, not matched by name", + "run": ["impact", "src/consts.js:2"], + "want": ["[resolved] memberRead", "[resolved] throughBarrel"], + "avoid": ["[by name"]}, + {"why": "the same for a const that holds a call's result, handed to a call", + "run": ["impact", "src/consts.js:3"], + "want": ["[resolved] argRead"], + "avoid": ["[by name"]}, + {"why": "a bare read of an imported const", + "run": ["impact", "src/consts.js:1"], + "want": ["[resolved] topLevelUse"], + "avoid": ["siblingRead"]}, + {"why": "control: a file that imports the same name from ANOTHER declaration is not a reader of this one, and that declaration keeps its own reader", + "run": ["impact", "src/sibling/consts.js:3"], + "want": ["[resolved] siblingRead"], + "avoid": ["memberRead", "throughBarrel"]}]} diff --git a/tests/cases/javascript/module-level-const/src/barrelUser.js b/tests/cases/javascript/module-level-const/src/barrelUser.js new file mode 100644 index 00000000..455a0720 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/barrelUser.js @@ -0,0 +1,3 @@ +import { CFG } from './index.js'; + +export function throughBarrel() { return CFG.port; } diff --git a/tests/cases/javascript/module-level-const/src/consts.js b/tests/cases/javascript/module-level-const/src/consts.js new file mode 100644 index 00000000..ea851142 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/consts.js @@ -0,0 +1,5 @@ +export const LIMIT = 10; +export const CFG = { name: 'a', port: 1 }; +export const SCHEMA = shape({ id: 'string' }); + +export function shape(o) { return o; } diff --git a/tests/cases/javascript/module-level-const/src/index.js b/tests/cases/javascript/module-level-const/src/index.js new file mode 100644 index 00000000..83aef343 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/index.js @@ -0,0 +1 @@ +export * from './consts.js'; diff --git a/tests/cases/javascript/module-level-const/src/sibling/consts.js b/tests/cases/javascript/module-level-const/src/sibling/consts.js new file mode 100644 index 00000000..d1a12e29 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/sibling/consts.js @@ -0,0 +1,3 @@ +// the same names, declared again: a reader here reads THIS file's declarations +export const LIMIT = 3; +export const CFG = { name: 'b', port: 2 }; diff --git a/tests/cases/javascript/module-level-const/src/sibling/reader.js b/tests/cases/javascript/module-level-const/src/sibling/reader.js new file mode 100644 index 00000000..7d41995c --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/sibling/reader.js @@ -0,0 +1,3 @@ +import { CFG, LIMIT } from './consts.js'; + +export function siblingRead() { return CFG.port + LIMIT; } diff --git a/tests/cases/javascript/module-level-const/src/user.js b/tests/cases/javascript/module-level-const/src/user.js new file mode 100644 index 00000000..18914f01 --- /dev/null +++ b/tests/cases/javascript/module-level-const/src/user.js @@ -0,0 +1,9 @@ +import { LIMIT, CFG, SCHEMA } from './consts.js'; + +function use(x) { return x; } + +export function topLevelUse() { return LIMIT + 1; } + +export function memberRead() { return use(CFG.name); } + +export function argRead() { return use(SCHEMA); } diff --git a/tests/cases/javascript/wrapped-handler-route/case.json b/tests/cases/javascript/wrapped-handler-route/case.json index 6b329131..e8dd8894 100644 --- a/tests/cases/javascript/wrapped-handler-route/case.json +++ b/tests/cases/javascript/wrapped-handler-route/case.json @@ -10,7 +10,7 @@ "avoid": ["reads it"]}, {"why": "a file:line on a const targets the const. It used to resolve to the enclosing (other.js:2) or to the arrow in the initializer (adminController.js:2), so a const could only be asked by its bare name, which answers for every const so named", "run": ["impact", "src/routes/other.js:2"], - "want": ["const listAdmins (at src/routes/other.js:2) [field]", "[in scope] other. src/routes/other.js:3"], + "want": ["const listAdmins (at src/routes/other.js:2) [field]", "[resolved] other. src/routes/other.js:3"], "avoid": ["other. (at src/routes/other.js:2)", "registered as a GET route"]}, {"why": "and a file that declares its own const of the name reads its own, not the wrapped handler's: other.js's `['root']` is not a reader of the controller's listAdmins", "run": ["impact", "src/controllers/adminController.js:2"], @@ -73,7 +73,7 @@ "avoid": ["registered as a"]}, {"why": "a const passed to a validation middleware on a route line (`validate(bodySchema)`) is an argument of that call, not a handler the router calls; and a schema built by a library (`Joi.object({…})`) is not a function even where it sits in a handler position (`.post('/schema-direct', bodySchema, h)`)", "run": ["impact", "bodySchema"], - "want": ["[in scope] validated. src/routes/validated.js:8 — reads it"], + "want": ["[resolved] validated. src/routes/validated.js:8 — reads it"], "avoid": ["registered as a"]}, {"why": "an options object passed positionally (`router.post('/opts', routeOptions, h)`) is not callable: read, not registered", "run": ["impact", "routeOptions"], @@ -95,7 +95,7 @@ "avoid": ["registered as a"]}, {"why": "a router module's own router const, the receiver of each route line (`router.get('/admins', listAdmins)`), is read on those lines, not registered on them, here or in the other files that declare a `router` of their own", "run": ["impact", "src/routes/admin.js:4"], - "want": ["[in scope] admin. src/routes/admin.js:5 — reads it"], + "want": ["[resolved] admin. src/routes/admin.js:5 — reads it"], "avoid": ["registered as a"]}, {"why": "a wrapped handler handed over inside an array of handlers (`.get('/array', [validate(s), ctrl.listAdmins])`) is registered: a router flattens the array", "run": ["impact", "src/controllers/adminController.js:2"], diff --git a/tests/cases/typescript/module-level-const/case.json b/tests/cases/typescript/module-level-const/case.json index 74c67b03..113ffec2 100644 --- a/tests/cases/typescript/module-level-const/case.json +++ b/tests/cases/typescript/module-level-const/case.json @@ -1,10 +1,34 @@ {"lang": "typescript", "src": "src", "checks": [ {"why": "a module-level exported const has the modules that import it as dependents, instead of nothing at all", - "run": ["impact", "LIMIT", "--kind", "field"], + "run": ["impact", "src/consts.ts:1"], "want": ["topLevelUse", "Holder", "arrowUse"], "avoid": ["nothing the graph can see"]}, {"why": "and --delete does not call it safe", - "run": ["impact", "LIMIT", "--kind", "field", "--delete"], + "run": ["impact", "src/consts.ts:1", "--delete"], "want": ["NOT SAFE"], - "avoid": ["no dependent at any certainty"]}]} + "avoid": ["no dependent at any certainty"]}, + {"why": "an imported const read through a member access or passed as an argument is resolved to the declaration the import binds, not matched by name", + "run": ["impact", "src/consts.ts:2"], + "want": ["[resolved] memberRead"], + "avoid": ["[by name"]}, + {"why": "the same for a const that holds a call's result, handed to a call", + "run": ["impact", "src/consts.ts:3"], + "want": ["[resolved] argRead"], + "avoid": ["[by name"]}, + {"why": "a const read in a type (`typeof X`): in a parameter's type it is the function's read, in a type alias the module's, each at its own line", + "run": ["impact", "src/consts.ts:3"], + "want": ["[resolved] typedRead", "src/user.ts:17"], + "avoid": ["[by name"]}, + {"why": "and at module level", + "run": ["impact", "src/consts.ts:2"], + "want": ["src/user.ts:19"], + "avoid": ["[by name"]}, + {"why": "control: a file that imports the same name from ANOTHER declaration is not a reader of this one", + "run": ["impact", "src/consts.ts:2"], + "want": [], + "avoid": ["siblingRead", "sibling/reader.ts"]}, + {"why": "control: and the sibling declaration keeps its own reader", + "run": ["impact", "src/sibling/consts.ts:3"], + "want": ["[resolved] siblingRead"], + "avoid": ["memberRead", "src/user.ts"]}]} diff --git a/tests/cases/typescript/module-level-const/src/consts.ts b/tests/cases/typescript/module-level-const/src/consts.ts index 4a3dc15e..28a387c8 100644 --- a/tests/cases/typescript/module-level-const/src/consts.ts +++ b/tests/cases/typescript/module-level-const/src/consts.ts @@ -1 +1,5 @@ export const LIMIT = 10; +export const CFG = { name: 'a', port: 1 } as const; +export const SCHEMA = shape({ id: 'string' }); + +export function shape(o: object): object { return o; } diff --git a/tests/cases/typescript/module-level-const/src/sibling/consts.ts b/tests/cases/typescript/module-level-const/src/sibling/consts.ts new file mode 100644 index 00000000..f1d033d2 --- /dev/null +++ b/tests/cases/typescript/module-level-const/src/sibling/consts.ts @@ -0,0 +1,3 @@ +// the same names, declared again: a reader here reads THIS file's declarations +export const LIMIT = 3; +export const CFG = { name: 'b', port: 2 } as const; diff --git a/tests/cases/typescript/module-level-const/src/sibling/reader.ts b/tests/cases/typescript/module-level-const/src/sibling/reader.ts new file mode 100644 index 00000000..ea3fbcf3 --- /dev/null +++ b/tests/cases/typescript/module-level-const/src/sibling/reader.ts @@ -0,0 +1,3 @@ +import { CFG, LIMIT } from './consts.js'; + +export function siblingRead(): number { return CFG.port + LIMIT; } diff --git a/tests/cases/typescript/module-level-const/src/user.ts b/tests/cases/typescript/module-level-const/src/user.ts index 8be77cbf..4d1263ae 100644 --- a/tests/cases/typescript/module-level-const/src/user.ts +++ b/tests/cases/typescript/module-level-const/src/user.ts @@ -5,3 +5,15 @@ export function topLevelUse(): number { return LIMIT + 1; } export class Holder { cap(): number { return LIMIT * 2; } } export const arrowUse = () => LIMIT - 1; + +import { CFG, SCHEMA } from './consts.js'; + +function use(x: unknown): void {} + +export function memberRead(): void { use(CFG.name); } + +export function argRead(): void { use(SCHEMA); } + +export function typedRead(s: typeof SCHEMA): void { use(s); } + +export type CfgShape = typeof CFG; diff --git a/tests/cases/typescript/wrapped-handler-route/case.json b/tests/cases/typescript/wrapped-handler-route/case.json index 73fff518..cb82b081 100644 --- a/tests/cases/typescript/wrapped-handler-route/case.json +++ b/tests/cases/typescript/wrapped-handler-route/case.json @@ -24,7 +24,7 @@ "avoid": ["registered as a"]}, {"why": "a router mounted with `app.use('/api', api)` is a mount, and the app is the receiver: neither is registered as a route", "run": ["impact", "src/app.ts:4"], - "want": ["[by name] server. src/server.ts:3 — reads it"], + "want": ["[resolved] server. src/server.ts:3 — reads it"], "avoid": ["registered as a"]}, {"why": "…nor the app", "run": ["impact", "src/app.ts:3"], From 699d90e7f6453a2eed986e8332ede0f60c118510 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:57:40 -0700 Subject: [PATCH 002/133] impact: a callback or dependency given to one instance reaches callers only through that instance - JavaScript engine: a new expression of a class whose instance code calls a value carries its allocation beside the instance; instance-state.dl names the edges that hold only for instances given the callee (constructor option, subscription, injected dependency) and the allocations each caller's receiver may be - a callee that also reaches the class another way (named inside it, returned to it, written onto it from outside, a class hierarchy, an entry with an unknown receiver) is not gated - impact walks a gated edge through the class's own code and leaves it only for a caller whose receiver may be an allocation given the callee, or is unknown; the SQL port does the same Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- graph/javascript/README.md | 1 + .../engine/resolution/instance-state.dl | 129 ++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 23 ++++ graph/javascript/souffle/export_manifest.tsv | 5 + .../skills/axiomcode/scripts/axiomcode-impact | 11 +- .../skills/axiomcode/scripts/dl/impact.dl | 45 +++++- .../skills/axiomcode/scripts/graph_sql.py | 52 +++++-- .../per-instance-registration/case.json | 106 ++++++++++++++ .../per-instance-registration/package.json | 1 + .../per-instance-registration/src/a.js | 6 + .../per-instance-registration/src/bus.js | 13 ++ .../per-instance-registration/src/c.js | 3 + .../per-instance-registration/src/d.js | 23 ++++ .../per-instance-registration/src/deps.js | 13 ++ .../per-instance-registration/test/a.test.js | 3 + .../per-instance-registration/test/c.test.js | 3 + .../per-instance-registration/test/r.test.js | 3 + 17 files changed, 426 insertions(+), 14 deletions(-) create mode 100644 graph/javascript/engine/resolution/instance-state.dl create mode 100644 tests/cases/javascript/per-instance-registration/case.json create mode 100644 tests/cases/javascript/per-instance-registration/package.json create mode 100644 tests/cases/javascript/per-instance-registration/src/a.js create mode 100644 tests/cases/javascript/per-instance-registration/src/bus.js create mode 100644 tests/cases/javascript/per-instance-registration/src/c.js create mode 100644 tests/cases/javascript/per-instance-registration/src/d.js create mode 100644 tests/cases/javascript/per-instance-registration/src/deps.js create mode 100644 tests/cases/javascript/per-instance-registration/test/a.test.js create mode 100644 tests/cases/javascript/per-instance-registration/test/c.test.js create mode 100644 tests/cases/javascript/per-instance-registration/test/r.test.js diff --git a/graph/javascript/README.md b/graph/javascript/README.md index 6a26b2ab..1336eecd 100644 --- a/graph/javascript/README.md +++ b/graph/javascript/README.md @@ -103,6 +103,7 @@ arrays. That is the engine, entirely. | module graph | `resolution/module-graph.dl` | one export surface for both systems, keyed by name with `default` for `module.exports = X`; a CommonJS default value's properties ARE its members | | hierarchy | `resolution/type-hierarchy.dl` | ONE closure — every heritage form inherits members, there is no `implements` | | value flow | `resolution/value-flow.dl` | the may-analysis above | +| instance state | `resolution/instance-state.dl` | an `("alloc", new-expression)` value beside `("inst", T)`, and the facts that let impact walk a callback or dependency given to ONE instance (a constructor option, a subscription) only from callers whose receiver may be that instance | | arrays | `resolution/arrays.dl` | the one platform type modelled: `push`, `[i]`, `map`, `forEach`, `for..of`, `T[]`; `Map` / `Set` as collections, including an instance of a class that extends one (#619) | | ambient | `resolution/ambient.dl` | platform names as values, so a site reached through one is classified from the value, not the syntax | | JSDoc types | `resolution/reference-types.dl` | `@param`/`@type`/`@returns`, `import()` types, typedef aliases, wrappers | diff --git a/graph/javascript/engine/resolution/instance-state.dl b/graph/javascript/engine/resolution/instance-state.dl new file mode 100644 index 00000000..95409a3c --- /dev/null +++ b/graph/javascript/engine/resolution/instance-state.dl @@ -0,0 +1,129 @@ +// ============================================================================ +// Resolution · INSTANCE STATE (what ONE object was given, not what its class was) +// +// `new Bus({ validate: check })` and `b.on(handleA)` hand a callback to ONE bus. The +// flow layer keys an instance by its class, ("inst", Bus), so `this.v` inside +// `Bus.emit` holds every callback any bus was ever given, and the call edge +// emit -> check is right for SOME bus. The edge is right; what is wrong is reading it +// as "every caller of emit reaches check": a caller that emits on a bus built without +// `validate` does not. A context-insensitive call graph cannot say that, so this file +// says it beside the graph, as facts the impact walk reads (dl/impact.dl): +// +// state_world(M, T) callable M runs with an instance of T as its `this`: +// an instance member of T, or a function nested in one +// state_gate(M, F, T) every call M makes to F reaches F only through what +// the receiver instance was given +// state_gate_alloc(T, F, S) the allocation S (a `new T(...)` expression) is one +// that was given F +// state_call_alloc(C, M, S) C calls the T member M on a receiver that may be S +// state_call_open(C, M) C calls M on a receiver whose allocation is unknown +// +// ── THE ALLOCATION VALUE ──────────────────────────────────────────────────── +// ("alloc", NewExpr) is a value of its own, carried BESIDE ("inst", T) wherever the +// instance goes (a variable, a parameter, a field, a return, `x ?? new T()`), exactly +// as ("wrap", site) travels beside a wrapper's closure (value-flow.dl). Nothing +// resolves through it: members are still read off ("inst", T). It is minted only for +// a class whose instance code CALLS A VALUE — a parameter (`f(e)`, `handler(env)`) or +// a property of `this` that the class does not declare as a method (`this.v?.(e)`) — +// which is the shape whose targets depend on what the instance was given. Every +// other class carries no allocation, and so costs nothing here. +// +// ── WHEN A CALLBACK IS GATED ──────────────────────────────────────────────── +// F is gated in T when the only way F enters T's instance code is as an argument of +// a call made from OUTSIDE that code on a T member (the constructor or a method) — +// an ENTRY — and every such entry's receiver has a known allocation. It is not gated +// (every caller keeps it, as before) when F: +// · is declared inside T's instance code; +// · is read inside that code through a name bound outside it (an import, a +// module-level variable, a function declaration), or returned to it by a call +// to a function declared outside it; +// · is written onto an instance of T, or onto the static side of T, from outside; +// · reaches T whose hierarchy has a client class above or below it, or whose own +// code builds another T (`new T()`, `new this.constructor()`): what one instance +// holds could then flow into another; +// · enters through an entry whose receiver's allocation is unknown. +// The allocations are the closed-world answer the engine gives for parameters too: a +// receiver's allocations are the `new` expressions that reach it through resolved +// flow. A receiver with none (a documented `@param {Bus}`, a library's return) is open. +// ============================================================================ + +// ── the instance code of a class ──────────────────────────────────────────── +state_world(m, t) :- method_decl("client", _, k, _, "false", t, _, m), t != "", k != "STATIC_BLOCK". +state_world(m, t) :- type_ctor("client", m, t). +state_world(m, t) :- method_enclosing(m, e), state_world(e, t). +state_type_declares(t, n) :- method_decl(_, n, _, _, _, t, _, _), n != "". + +// ── the classes whose instance code calls a value ─────────────────────────── +state_type(t) :- state_world(em, t), call_site("client", ck, n, "SYNTACTIC", _, em, ce, _, _), call_kind_is_member_form(ck), + expr_child(_, ce, "RECEIVER", _, r), expr_kind(_, "THIS", _, r), n != "", !state_type_declares(t, n). +state_type(t) :- state_world(em, t), call_site("client", ck, _, _, _, em, ce, _, _), call_kind_is_callee_form(ck), + expr_child(_, ce, "CALLEE", _, c), expr_param(_, _, c). + +// ── the allocation value, minted at `new T(...)` ──────────────────────────── +expr_value(e, "alloc", e) :- expr_kind("client", "NEW", _, e), new_constructs(e, t), state_type(t). +state_alloc_type(s, t) :- expr_value(s, "alloc", s), new_constructs(s, t). + +// ── eligibility: nothing one instance holds can move into another ─────────── +state_type_mixed(t) :- state_type(t), type_super(t, s), type_decl("client", _, _, _, _, s). +state_type_mixed(t) :- state_type(t), type_super(u, t), type_decl("client", _, _, _, _, u). +state_type_mixed(t) :- state_type(t), state_world(em, t), expr_kind(_, "NEW", _, e), expr_owner(_, em, _, e), new_constructs(e, t). +state_type_mixed(t) :- state_type(t), state_world(em, t), own_class_new(em, _). +state_type_ok(t) :- state_type(t), !state_type_mixed(t). + +// ── entries: a call from outside the instance code onto a member of T ─────── +state_entry(t, ce) :- state_type_ok(t), expr_resolves_to_method(ce, m), state_world(m, t), + call_site("client", _, _, _, _, em, ce, _, _), !state_world(em, t). +state_entry_alloc(ce, s) :- state_entry(t, ce), expr_kind(_, "NEW", _, ce), expr_value(ce, "alloc", s), state_alloc_type(s, t). +state_entry_alloc(ce, s) :- state_entry(t, ce), expr_child(_, ce, "RECEIVER", _, r), expr_value(r, "alloc", s), state_alloc_type(s, t). +state_entry_open(ce) :- state_entry(_, ce), !state_entry_alloc(ce, _). + +// What an entry's arguments carry: the values themselves and, a few levels down, what +// their properties and elements hold (`{ validate: check }`, `[a, b]`). Depth-bounded: +// a carry is evidence FOR gating, so a callback found deeper than this is simply not +// counted as entering through the entry, and then it is not gated at all. +state_carry(ce, k, i, 0) :- state_entry(_, ce), call_arg(ce, _, a), expr_value(a, k, i), k != "alloc". +state_carry(ce, k2, i2, d + 1) :- state_carry(ce, k, i, d), d < 3, k != "func", prop_value(k, i, _, k2, i2), k2 != "alloc". +state_carry(ce, k2, i2, d + 1) :- state_carry(ce, k, i, d), d < 3, (k = "arr" ; k = "coll"), elem_value(i, k2, i2), k2 != "alloc". +state_enters(t, f, ce) :- state_entry(t, ce), state_carry(ce, "func", f, _). + +// ── leaks: F reaches the instance code some other way ─────────────────────── +// declared inside it +state_leak(t, f) :- state_enters(t, f, _), state_world(f, t). +// a value the instance code has from anywhere but an entry: a name bound outside it +// (an import, a module-level variable or function, a class it constructs itself: `dep ?? +// new DefaultDep()` names DefaultDep, whose prototype holds `run`), or what a function +// declared outside it returns. What those hold, a few levels down, is F leaking in. A +// literal the code writes itself needs no rule: a function in it is either named (above) +// or written inline, and then it is declared inside the instance code. +state_outer_ref(t, e) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, e), expr_binding(_, v, e), + var_owner_method(_, vm, v), !state_world(vm, t). +state_outer_ref(t, e) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, e), expr_binding(_, v, e), + !var_owner_method(_, _, v). +state_outer_ref(t, ce) :- state_type_ok(t), state_world(em, t), expr_owner(_, em, _, ce), expr_kind(_, "CALL", _, ce), + expr_resolves_to_method(ce, g), !state_world(g, t). +state_outer_carry(t, k, i, 0) :- state_outer_ref(t, e), expr_value(e, k, i), k != "alloc". +state_outer_carry(t, k2, i2, d + 1) :- state_outer_carry(t, k, i, d), d < 3, k != "func", prop_value(k, i, _, k2, i2), k2 != "alloc". +state_outer_carry(t, k2, i2, d + 1) :- state_outer_carry(t, k, i, d), d < 3, (k = "arr" ; k = "coll"), elem_value(i, k2, i2), k2 != "alloc". +state_leak(t, f) :- state_enters(t, f, _), state_outer_carry(t, "func", f, _). +// written onto an instance of T or onto T itself from outside its instance code +state_leak(t, f) :- state_enters(t, f, _), expr_kind(_, "ASSIGNMENT", _, a), expr_owner(_, em, _, a), !state_world(em, t), + expr_child(_, a, "ASSIGNMENT_TARGET", _, tgt), expr_child(_, tgt, "ACCESS_TARGET", _, r), + expr_value(r, k, t), (k = "inst" ; k = "ctor"), + expr_child(_, a, "ASSIGNMENT_VALUE", _, val), expr_value(val, "func", f). +state_leak(t, f) :- state_enters(t, f, _), prop_value("ctor", t, _, "func", f). +// an entry that says nothing about which instance it gives F to +state_leak(t, f) :- state_enters(t, f, ce), state_entry_open(ce). + +// ── the gate ──────────────────────────────────────────────────────────────── +state_gated(t, f) :- state_enters(t, f, _), !state_leak(t, f). +state_gate_alloc(t, f, s) :- state_gated(t, f), state_enters(t, f, ce), state_entry_alloc(ce, s). +state_gate(m, f, t) :- state_gated(t, f), state_world(m, t), expr_resolves_to_method(ce, f), + call_site("client", _, _, _, _, m, ce, _, _). + +// ── who calls the instance code, on which allocation ──────────────────────── +state_gated_type(t) :- state_gated(t, _). +state_call_alloc(c, m, s) :- state_gated_type(t), state_entry(t, ce), expr_resolves_to_method(ce, m), + call_site("client", _, _, _, _, c, ce, _, _), state_entry_alloc(ce, s). +state_call_open(c, m) :- state_gated_type(t), state_entry(t, ce), expr_resolves_to_method(ce, m), + call_site("client", _, _, _, _, c, ce, _, _), state_entry_open(ce). +state_world_of_gated(m, t) :- state_gated_type(t), state_world(m, t). diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 7f73c459..0faf2204 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -569,3 +569,26 @@ // ── resolution/ambient.dl (the global object) ── .decl global_object_name(c0:symbol) .decl global_name_resolution(c0:symbol) + +// ── resolution/instance-state.dl ── +.decl state_world(c0:symbol, c1:symbol) +.decl state_type_declares(c0:symbol, c1:symbol) +.decl state_type(c0:symbol) +.decl state_alloc_type(c0:symbol, c1:symbol) +.decl state_type_mixed(c0:symbol) +.decl state_type_ok(c0:symbol) +.decl state_entry(c0:symbol, c1:symbol) +.decl state_entry_alloc(c0:symbol, c1:symbol) +.decl state_entry_open(c0:symbol) +.decl state_carry(c0:symbol, c1:symbol, c2:symbol, c3:number) +.decl state_enters(c0:symbol, c1:symbol, c2:symbol) +.decl state_leak(c0:symbol, c1:symbol) +.decl state_outer_ref(c0:symbol, c1:symbol) +.decl state_outer_carry(c0:symbol, c1:symbol, c2:symbol, c3:number) +.decl state_gated(c0:symbol, c1:symbol) +.decl state_gate_alloc(c0:symbol, c1:symbol, c2:symbol) +.decl state_gate(c0:symbol, c1:symbol, c2:symbol) +.decl state_gated_type(c0:symbol) +.decl state_call_alloc(c0:symbol, c1:symbol, c2:symbol) +.decl state_call_open(c0:symbol, c1:symbol) +.decl state_world_of_gated(c0:symbol, c1:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index 1e8b5aac..cf9a9c0f 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -42,3 +42,8 @@ package_entry package-entry.csv import_staged_package_unreached import-staged-package-unreached.csv member_write_refused member-write-refused.csv jsx_renders jsx-renders.csv +state_gate state-gate.csv +state_gate_alloc state-gate-alloc.csv +state_call_alloc state-call-alloc.csv +state_call_open state-call-open.csv +state_world_of_gated state-world.csv diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 4e300359..96fa09ff 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1101,7 +1101,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '57' # 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '58' # 58: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -1426,6 +1426,15 @@ class Impact: # the callables in test files: a test that publishes a handler table's key drives the handler, and is not # counted against the key the way a production writer is (dl/impact.dl, table_key) W('test_code', sorted((i,) for i, s in g.sym.items() if s['is_test'] and s.get('method_id'))) + # WHAT ONE INSTANCE WAS GIVEN (JavaScript, engine resolution/instance-state.dl): a callback handed to the + # constructor or subscribed through a method of ONE object is reached from the class's code only for callers + # whose receiver may be that object. The engine names the gated edges, the allocations that were given each + # callback, and the allocation (or none known) of each receiver that calls into the class; the walk in + # dl/impact.dl carries the gate through the class's own code and applies it where a caller leaves it. + for rel, t, cols in (('state_gate', 'ext_state_gate', 'c0, c1, c2'), ('state_gate_alloc', 'ext_state_gate_alloc', 'c0, c1, c2'), + ('state_call_alloc', 'ext_state_call_alloc', 'c0, c1, c2'), ('state_call_open', 'ext_state_call_open', 'c0, c1'), + ('state_world', 'ext_state_world_of_gated', 'c0, c1')): + W(rel, sorted(tuple(r) for r in g.q(f"SELECT DISTINCT {cols} FROM {t}")) if g.has(t) else []) # the HTTP method each side names, where it names one (ax_registration.route_verbs / literal_verbs) W('reg_verb', sorted(x for x in ax_registration.route_verbs(g.q, g.site_file) if x[0] in g.sym)) W('lit_verb', sorted(ax_registration.literal_verbs(g.q, self.at, g.site_file))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index f46bbbbb..4a5d463b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -845,10 +845,44 @@ seed_byname(q, c) :- valueref(q, c, _, _), !seed(q, c). .decl up(q:symbol, m:symbol, d:number) up(q, m, 0) :- seed(q, m). up(q, c, 1) :- seed_byname(q, c). -up(q, a, d+1) :- up(q, b, d), edge(a, b, _), d < 40. +up(q, a, d+1) :- up(q, b, d), edge(a, b, _), !state_gate(a, b, _), d < 40. up(q, a, d+1) :- up(q, b, d), fw_edge(a, b, _), d < 40. +// ── WHAT ONE INSTANCE WAS GIVEN: the edge holds for some receivers only ────────────────────────────────────────── +// `new Bus({ validate: check })` and `b.on(handleA)` give ONE bus a callback, and `Bus.emit` calls it through what +// that bus holds. The engine's edge emit -> check is one edge for every bus, so the closure used to hand check to +// every caller of emit: a test that emits on a bus built without `validate` reached it. The engine now says which +// edges are of that kind and which allocations were given the callback (JavaScript resolution/instance-state.dl): +// state_gate(a, f, T) every call a makes to f reaches f only through what the receiver instance holds +// state_world(m, T) m runs with an instance of T as `this` (T's instance code, and what it nests) +// state_gate_alloc(T, f, s) the allocation s (a `new T(...)`) was given f +// state_call_alloc(c, m, s) c calls the T member m on a receiver that may be s +// state_call_open(c, m) c calls m on a receiver whose allocation is unknown: it keeps every callback +// The gate travels with the walk through T's own code (`this.deliver()` -> `this.#invoke()` -> `handler()`), and is +// applied where the walk leaves it: a caller outside T keeps the route when one of its receivers may be an +// allocation given f, or when the engine knows none. An edge with no call-site record (a dispatch choice) keeps it. +.decl state_gate(a:symbol, f:symbol, t:symbol) .input state_gate +.decl state_world(m:symbol, t:symbol) .input state_world +.decl state_gate_alloc(t:symbol, f:symbol, s:symbol) .input state_gate_alloc +.decl state_call_alloc(c:symbol, m:symbol, s:symbol) .input state_call_alloc +.decl state_call_open(c:symbol, m:symbol) .input state_call_open +.decl state_call_known(c:symbol, m:symbol) +state_call_known(c, m) :- state_call_alloc(c, m, _). +state_call_known(c, m) :- state_call_open(c, m). +.decl state_exit(c:symbol, a:symbol, t:symbol, f:symbol) +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_gate_alloc(t, f, _), state_call_open(c, a). +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_call_alloc(c, a, s), state_gate_alloc(t, f, s). +state_exit(c, a, t, f) :- edge(c, a, _), state_world(a, t), !state_world(c, t), state_gate_alloc(t, f, _), !state_call_known(c, a). +// upg(q, m, T, f, d): m is reached only on the instances of T that were given f +.decl upg(q:symbol, m:symbol, t:symbol, f:symbol, d:number) +upg(q, a, t, f, d+1) :- up(q, f, d), state_gate(a, f, t), edge(a, f, _), d < 40. +upg(q, c, t, f, d+1) :- upg(q, a, t, f, d), edge(c, a, _), state_world(c, t), d < 40. +up(q, c, d+1) :- upg(q, a, t, f, d), state_exit(c, a, t, f), d < 40. +up(q, a, d+1) :- upg(q, b, _, _, d), fw_edge(a, b, _), d < 40. +.decl up_all(q:symbol, m:symbol, d:number) +up_all(q, m, d) :- up(q, m, d). +up_all(q, m, d) :- upg(q, m, _, _, d). .decl reach(q:symbol, m:symbol, d:number) -reach(q, m, d) :- up(q, m, d), d = min x : up(q, m, x). +reach(q, m, d) :- up_all(q, m, d), d = min x : up_all(q, m, x). // the chain read-back: a is one hop further from the change than b, through edge a → b .decl parent_up(q:symbol, a:symbol, b:symbol, t:symbol) parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, edge(a, b, t). @@ -862,7 +896,12 @@ parent_up(q, a, b, t) :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, fw_ // shape — which is the whole argument for keeping cases beside a corpus. .decl up_running(q:symbol, m:symbol, d:number) up_running(q, m, 0) :- seed(q, m). -up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), d < 40. +up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_gate(a, b, _), d < 40. +// the same gate as `up` (above): a caller that leaves the instance code keeps the route only on an instance given f +.decl up_running_g(q:symbol, m:symbol, t:symbol, f:symbol, d:number) +up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. +up_running_g(q, c, t, f, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_world(c, t), d < 40. +up_running(q, c, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_exit(c, a, t, f), d < 40. .decl import_hop(q:symbol, a:symbol, b:symbol) import_hop(q, a, mod) :- up_running(q, mod, _), kind(mod, "module"), decl_file(mod, g), imports_file(f, g), decl_file(a, f), kind(a, "module"), a != mod. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index db50a1a0..344c7f0a 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -585,25 +585,57 @@ def _rev(edges): return r -def reach_from(rev, seeds, byname=(), cap=40): +def reach_from(rev, seeds, byname=(), cap=40, gate=None): """up/reach: everything that can reach a seed, at its SHORTEST hop count. `up(q,m,0) :- seed(q,m)` · `up(q,c,1) :- seed_byname(q,c)` · `up(q,a,d+1) :- up(q,b,d), edge(a,b,_), d0, reach(q,b,d-1), edge(a,b,t)` — a is one hop further from the change than b, so following it from any reached node walks down to a seed. @@ -3563,7 +3595,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non out = {k: [] for k in ('contract', 'direct', 'direct_edge', 'seed', 'seed_byname', 'reach', 'reach_sure', 'parent_up', 'test_near', 'test_hit', 'test_stub', 'inherited_test', 'extbind', 'gen_fired', 'caller_handles', 'caller_unhandled', 'target_throws')} - E = _edges(q) + _spawn_edges(q, lines, at); rev = _rev(E); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) + E = _edges(q) + _spawn_edges(q, lines, at); rev = _rev(E); gate = state_gate(q); sets = _test_sets(q, lines, rel); stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, *p_)) for qq in QS: # A query can carry SEVERAL target kinds at once: a name match that hits both a method and a field # resolves to both, and the rules simply union what each kind derives. Dispatch per kind and union here @@ -3803,7 +3835,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non _sde.add((c, m)); _de.append((c, m)) out['direct_edge'] += [[c, m, qq] for c, m in _de] out['seed_byname'] += [[c, qq] for c in byname] - depth = reach_from(rev, seeds, byname) + depth = reach_from(rev, seeds, byname, gate=gate) out['reach'] += [[m, str(d), qq] for m, d in depth.items()] # reach_sure: the same closure from the seeds that are an exact edge only — a seed reached ONLY through a # by-name / text / one-of-a-set dependent is weak, and the answer says how much of itself rests on those diff --git a/tests/cases/javascript/per-instance-registration/case.json b/tests/cases/javascript/per-instance-registration/case.json new file mode 100644 index 00000000..9a7d70b6 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/case.json @@ -0,0 +1,106 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "a callback given to ONE bus's constructor is called by emit only on that bus: impact of it lists the caller that emits on the bus built with it, not the caller that emits on a bus built without it", + "run": [ + "impact", + "check", + "--grep" + ], + "want": [ + "src/bus.js:5:", + "src/a.js:6:", + "test/a.test.js" + ], + "avoid": [ + "src/c.js:3:", + "test/c.test.js" + ] + }, + { + "why": "a handler subscribed on one bus is reached from emit on that bus; a bus it was never subscribed on does not reach it", + "run": [ + "impact", + "handleA", + "--grep" + ], + "want": [ + "src/bus.js:5:", + "src/a.js:6:", + "test/a.test.js" + ], + "avoid": [ + "src/c.js:3:", + "test/c.test.js" + ] + }, + { + "why": "control: the registration still reaches callers whose bus is its own allocation through another object's field, through an argument, or whose allocation the graph does not know", + "run": [ + "impact", + "handleA", + "--grep" + ], + "want": [ + "src/d.js:11:", + "src/d.js:17:", + "src/d.js:18:", + "src/d.js:6:" + ] + }, + { + "why": "control: a handler the class writes for itself is the same on every instance, so every caller reaches it", + "run": [ + "impact", + "defaultHandler", + "--grep" + ], + "want": [ + "src/d.js:22:", + "src/d.js:23:", + "test/r.test.js" + ] + }, + { + "why": "a test that emits on a bus built without a callback does not run it", + "run": [ + "impact", + "src/c.js:3", + "--tests-only" + ], + "want": [ + "test/c.test.js" + ] + }, + { + "why": "a dependency handed to one holder's constructor is called only for that holder: impact of it lists the caller of the holder built with it, not the callers of holders built with another", + "run": [ + "impact", + "OtherDep.run", + "--grep" + ], + "want": [ + "src/deps.js:6:", + "src/deps.js:13:" + ], + "avoid": [ + "src/deps.js:11:", + "src/deps.js:12:" + ] + }, + { + "why": "control: a dependency the class builds itself when given none serves every holder built without one: its callers are kept, not narrowed to the holder given it explicitly", + "run": [ + "impact", + "DefaultDep.run", + "--grep" + ], + "want": [ + "src/deps.js:11:", + "src/deps.js:12:" + ] + } + ] +} diff --git a/tests/cases/javascript/per-instance-registration/package.json b/tests/cases/javascript/per-instance-registration/package.json new file mode 100644 index 00000000..18a0b90c --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/package.json @@ -0,0 +1 @@ +{"name":"per-instance-registration","type":"module"} diff --git a/tests/cases/javascript/per-instance-registration/src/a.js b/tests/cases/javascript/per-instance-registration/src/a.js new file mode 100644 index 00000000..b3dfc7f8 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/a.js @@ -0,0 +1,6 @@ +import { Bus } from './bus.js'; +export function check(e) { return e; } +export function handleA(e) { return e; } +const b = new Bus({ validate: check }); +b.on(handleA); +export const runA = () => b.emit(1); diff --git a/tests/cases/javascript/per-instance-registration/src/bus.js b/tests/cases/javascript/per-instance-registration/src/bus.js new file mode 100644 index 00000000..c5cb0a29 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/bus.js @@ -0,0 +1,13 @@ +// A bus holds what it was built with and what was subscribed on it. +export class Bus { + constructor({ validate } = {}) { this.v = validate; this.subs = []; } + on(f) { this.subs.push(f); } + emit(e) { this.v?.(e); this.subs.forEach((f) => f(e)); } +} + +// control: a registry whose handler is written inside the class serves every instance. +export function defaultHandler(x) { return x; } +export class Registry { + constructor() { this.h = defaultHandler; } + run(x) { return this.h(x); } +} diff --git a/tests/cases/javascript/per-instance-registration/src/c.js b/tests/cases/javascript/per-instance-registration/src/c.js new file mode 100644 index 00000000..8bc790a6 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/c.js @@ -0,0 +1,3 @@ +import { Bus } from './bus.js'; +const c = new Bus(); +export const runC = () => c.emit(2); diff --git a/tests/cases/javascript/per-instance-registration/src/d.js b/tests/cases/javascript/per-instance-registration/src/d.js new file mode 100644 index 00000000..3be98815 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/d.js @@ -0,0 +1,23 @@ +import { Bus, Registry } from './bus.js'; +import { handleA } from './a.js'; + +// control: a bus whose allocation the graph does not know (a documented parameter) keeps every registration. +/** @param {Bus} bus */ +export const runD = (bus) => bus.emit(3); + +// control: the same allocation reached through a field of another object. +export class Svc { + constructor() { this.bus = new Bus(); this.bus.on(handleA); } + run() { this.bus.emit(4); } +} + +// control: an allocation passed on as an argument is still that allocation. +const shared = new Bus(); +shared.on(handleA); +function relay(bus) { bus.emit(5); } +export const runE = () => relay(shared); + +const r1 = new Registry(); +const r2 = new Registry(); +export const runR1 = () => r1.run(1); +export const runR2 = () => r2.run(2); diff --git a/tests/cases/javascript/per-instance-registration/src/deps.js b/tests/cases/javascript/per-instance-registration/src/deps.js new file mode 100644 index 00000000..3aa90a5e --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/src/deps.js @@ -0,0 +1,13 @@ +// A holder calls the dependency it was built with, or the one it builds when given none. +export class DefaultDep { run() { return 1; } } +export class OtherDep { run() { return 2; } } +export class Holder { + constructor(dep) { this.dep = dep ?? new DefaultDep(); } + go(f) { f(); return this.dep.run(); } +} +const h1 = new Holder(); +const h2 = new Holder(new DefaultDep()); +const h3 = new Holder(new OtherDep()); +export const goDefault = () => h1.go(() => 0); +export const goExplicit = () => h2.go(() => 0); +export const goOther = () => h3.go(() => 0); diff --git a/tests/cases/javascript/per-instance-registration/test/a.test.js b/tests/cases/javascript/per-instance-registration/test/a.test.js new file mode 100644 index 00000000..0d4d0ad3 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/a.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runA } from '../src/a.js'; +test('runA emits on the validated bus', () => { runA(); }); diff --git a/tests/cases/javascript/per-instance-registration/test/c.test.js b/tests/cases/javascript/per-instance-registration/test/c.test.js new file mode 100644 index 00000000..65a948b9 --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/c.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runC } from '../src/c.js'; +test('runC emits on a bare bus', () => { runC(); }); diff --git a/tests/cases/javascript/per-instance-registration/test/r.test.js b/tests/cases/javascript/per-instance-registration/test/r.test.js new file mode 100644 index 00000000..addb356b --- /dev/null +++ b/tests/cases/javascript/per-instance-registration/test/r.test.js @@ -0,0 +1,3 @@ +import { test } from 'node:test'; +import { runR2 } from '../src/d.js'; +test('the second registry runs its handler', () => { runR2(); }); From 0548acdfc1de49f747b0546ccf7e0d194e419d7e Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 05:06:07 -0700 Subject: [PATCH 003/133] context/impact: lockfiles and manifest package lists are never text leads; --in narrows text files A context question with a common word (debug, optional, string) listed package-lock.json and package.json lines as "text files that name these declarations" and made one of them the next step, even when --in named a directory holding neither file. - ax_nonsource: lockfiles of every package manager (npm, pnpm, yarn, NuGet, Gradle, Poetry, uv, Composer, Cargo, Go) are out of scope; a manifest's metadata and dependency lists (package.json, pyproject.toml, PackageReference) and package.json keys are skipped by line. Script commands and tool configuration values are still searched. - context: the caller's --in narrows text bindings as it narrows declarations; a mapper XML whose namespace names the declaring type stays (bound by the declaration, not the word). - ax_pages: a --source answer with no flow and no text row now ends with a next step. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../case.json | 18 ++++++++++++++++++ .../config/app.json | 3 +++ .../package.json | 15 +++++++++++++++ .../packages.lock.json | 8 ++++++++ .../src/a/app.js | 14 ++++++++++++++ .../src/a/routes.json | 3 +++ .../src/b/worker.js | 3 +++ 7 files changed, 64 insertions(+) create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/case.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/package.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json new file mode 100644 index 00000000..3f88d68a --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json @@ -0,0 +1,18 @@ +{"lang": "javascript", "src": "src", + "checks": [ + {"why": "--in narrows the text files a context answer lists as it narrows the declarations: a JSON under the scope that names a found function is listed, a lockfile, a manifest and a config file outside it are not, and the next step stays inside the scope", + "run": ["context", "app wiring: createApp, config, debug output", "--in", "src/a"], + "want": ["text files that name these declarations", "src/a/routes.json:2", "next: read src/a/"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:", "config/app.json"]}, + {"why": "--source prints each file's declarations as code instead of a name list; the answer still ends with a next step, on the first file shown", + "run": ["context", "app wiring: createApp and its output", "--in", "src/a", "--source"], + "want": ["next: answer from the code of src/a/app.js shown first above"], + "avoid": ["package-lock.json", "package.json:"]}, + {"why": "without --in, a lockfile is never a text lead and a manifest's description, dependency lists and script names are not either; a manifest script command and a config file that name the function are (control: the manifest is filtered by line, not dropped)", + "run": ["context", "app wiring: createApp, config, debug output"], + "want": ["src/a/routes.json:2", "config/app.json:2", "package.json:6"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:4", "package.json:7", "package.json:10", "package.json:13"]}, + {"why": "impact lists the same text files as bound from outside the source: the lockfiles and the manifest's dependency lists are not among them", + "run": ["impact", "debug"], + "want": ["src/a/routes.json", "config/app.json"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:7", "package.json:10"]}]} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json new file mode 100644 index 00000000..6741e398 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/config/app.json @@ -0,0 +1,3 @@ +{ + "startup": "debug" +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/package.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/package.json new file mode 100644 index 00000000..62b94574 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/package.json @@ -0,0 +1,15 @@ +{ + "name": "wiring-sample", + "version": "1.0.0", + "description": "Sample app: turns debug (verbose) output on from config", + "scripts": { + "trace": "debug", + "debug": "node src/a/app.js" + }, + "dependencies": { + "debug": "^4.1.0" + }, + "optionalDependencies": { + "optional": "^0.1.4" + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json new file mode 100644 index 00000000..c7a71061 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages.lock.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "dependencies": { + "net8.0": { + "optional": { "type": "Direct", "requested": "[1.0.0, )" } + } + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js new file mode 100644 index 00000000..6662599a --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/app.js @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; + +export function debug(config) { + return config.verbose === true; +} + +export function optional(value, fallback) { + return value === undefined ? fallback : value; +} + +export function createApp(config) { + const routes = JSON.parse(readFileSync(new URL('./routes.json', import.meta.url), 'utf8')); + return { routes, verbose: debug(config), port: optional(config.port, 8080) }; +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json new file mode 100644 index 00000000..67dbe729 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/a/routes.json @@ -0,0 +1,3 @@ +{ + "/health": { "handler": "debug" } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js new file mode 100644 index 00000000..856ee0d5 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/src/b/worker.js @@ -0,0 +1,3 @@ +export function startWorker(config) { + return { queue: config.queue }; +} From 5e2290d800a6aa5ac9e8438c0b94482161bd758c Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:59:05 -0700 Subject: [PATCH 004/133] context/impact: manifest package lists are never text leads, in any manifest; --in text dirs stay in scope The previous commit on this branch added the case but not the code it checks, and its npm lockfile fixture was gitignored, so CI ran the checks against the old scripts. - ax_nonsource: one manifest filter decided by where a word sits (entry, table, block, element), not by line: package.json, bower.json, deno.json, composer.json by JSON entry (a one-line manifest keeps its scripts); pnpm-workspace.yaml and conda environment.yml by block; pyproject.toml and Pipfile by table; requirements files whole; pom.xml, MSBuild, packages.config and .nuspec by package element. More lockfiles recognised. - context: the word-matched rows of a text-only --in use the same filter; a directory the main graph holds is no longer text to another language's graph; with several graphs, a text-only --in is answered once and never by a package outside it. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_nonsource.py | 143 +++++++++++++++++- .../skills/axiomcode/scripts/ax_pages.py | 13 +- .../axiomcode/scripts/axiomcode-context | 63 +++++--- .../context-answers-what-was-asked/case.json | 4 + .../bower.json | 1 + .../case.json | 12 +- .../deno.json | 4 + .../package-lock.json | 19 +++ .../packages/min/package.json | 1 + .../pnpm-workspace.yaml | 2 + .../requirements.txt | 1 + .../tools/lint/package.json | 6 + .../tools/lint/rules.json | 3 + tests/multi_language.py | 13 +- 14 files changed, 257 insertions(+), 28 deletions(-) create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json create mode 100644 tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py index 10b1e929..2e127ec7 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_nonsource.py @@ -65,16 +65,151 @@ def _classify(fp): # callable: called (`note(`), quoted as a value (`"note"`, `'note'`), or qualified with `#`, `::` or `->` # (`Owner#note`; a CSS `#note` selector is not one). A dotted `Owner.note` is matched as the qualified name itself and never reaches this rule. The # rest are PROSE: counted and grep-able, not listed as places a rename breaks. -OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl'} +# +# LOCKFILES AND MANIFEST LISTS. A lockfile is written by a package manager, never by hand, and every word in it is a +# package name, a version or a flag: `"optional": true`, `"debug": "^4.1.0"`. A manifest's metadata (name, +# description, keywords) and its dependency lists are the same: they name packages, not callables. A method named +# `debug`, `optional` or `string` matched there is a package or a word, never a binding, and in a JavaScript +# repository it outnumbered every real one (a context question's next step became a line of package-lock.json). +# The rest of a manifest (scripts, tasks, tool configuration) is kept: that is where a name can be referred to. A +# JSON manifest's KEYS are not: `"start":` names an npm script and `"testEnvironment":` an option, so only the values +# are searched. What is skipped is decided by where the word sits in the file's structure (the entry, table, block or +# element that holds it), not by the line alone: a one-line manifest holds its scripts and its dependencies together. +OUT_OF_SCOPE_EXT = {'.sh', '.bash', '.zsh', '.ksh', '.dl', '.lock', '.lockfile'} SHELL_SHEBANG = re.compile(r'#!\s*\S*(?:/|\s)(?:env\s+)?(?:ba|z|k|da)?sh\b') +LOCKFILES = {'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'deno.lock', + 'packages.lock.json', 'project.assets.json', 'paket.lock', 'gradle.lockfile', 'poetry.lock', 'uv.lock', + 'pipfile.lock', 'pdm.lock', 'conda-lock.yml', 'composer.lock', 'gemfile.lock', 'cargo.lock', 'go.sum'} +# JSON manifests: the top-level keys that describe the package or list other packages +_JSON_META = r'name|version|description|keywords|authors?|contributors|maintainers|license|homepage|repository|bugs|funding|private' +JSON_LIST_KEY = { + 'package.json': re.compile(r'(?i)^(' + _JSON_META + r'|type|engines|os|cpu|publishConfig|workspaces|packageManager|' + r'overrides|resolutions|pnpm|\w*dependencies(Meta)?)$'), + 'bower.json': re.compile(r'(?i)^(' + _JSON_META + r'|ignore|resolutions|\w*dependencies)$'), + 'deno.json': re.compile(r'^(name|version|imports|scopes|importMap|lock|nodeModulesDir|vendor|workspace|patch|links)$'), + 'composer.json': re.compile(r'(?i)^(' + _JSON_META + r'|type|support|require(-dev)?|conflict|replace|provide|suggest|' + r'repositories|minimum-stability|prefer-stable)$')} +JSON_LIST_KEY['deno.jsonc'] = JSON_LIST_KEY['deno.json'] +# YAML manifests: the top-level blocks that list packages (a conda environment, a pnpm workspace and its catalog) +YAML_LIST_KEY = { + 'environment.yml': re.compile(r'^(name|channels|dependencies|prefix)$'), + 'pnpm-workspace.yaml': re.compile(r'^(packages|catalogs?|overrides|patchedDependencies|\w*BuiltDependencies|' + r'peerDependencyRules|allowedDeprecatedVersions|packageExtensions)$')} +YAML_LIST_KEY['environment.yaml'] = YAML_LIST_KEY['environment.yml'] +# TOML manifests: the tables and keys that list packages +PY_DEP_TABLE = re.compile(r'^\[\s*(dependency-groups|project\.optional-dependencies|tool\.poetry(\.group\.[^\]]+)?\.(dev-)?dependencies|' + r'tool\.pdm\.dev-dependencies|tool\.uv)\s*\]') +PIPFILE_TABLE = re.compile(r'^\[\s*(packages|dev-packages|requires|source|[\w-]+-packages)\s*\]') +PY_DEP_KEY = re.compile(r'^\s*(dependencies|requires|dev-dependencies|optional-dependencies)\s*=') +# a file that is nothing but a list of packages +REQUIREMENTS = re.compile(r'^(requirements|constraints)[\w.-]*\.(txt|in)$') +# XML manifests: the elements that name a package (MSBuild, packages.config, .nuspec) and a POM's dependency blocks +XML_PKG_LINE = re.compile(r'<\s*(PackageReference|PackageVersion|package|dependency)\b[^>]*\b(Include|Update|id)\s*=') +POM_BLOCK = re.compile(r'<(/?)(dependencies|dependencyManagement|parent|exclusions)>') +POM_COORD = re.compile(r'^\s*<(groupId|artifactId|version|packaging|name|description|url|scope|type|classifier|optional|' + r'modelVersion|id|tags|authors|owners)>[^<]*\s*$') + + +def is_lockfile(rel): + """a file a package manager writes: every word in it is a package, a version or a flag""" + return os.path.basename(rel).lower() in LOCKFILES + + +def _json_spans(text, list_key): + """{line: [(start col, end col)]} of the strings a word is not matched in: each string under a top-level key that + `list_key` matches, and each key at any depth. A string-aware scan, so a minified manifest is split by entry too.""" + out, depth, want_key, listed, line, bol, i, n = {}, 0, False, False, 1, 0, 0, len(text) + while i < n: + c = text[i] + if c == '"': + j = i + 1 + while j < n and text[j] != '"': j += 2 if text[j] == '\\' else 1 + k = j + 1 + while k < n and text[k] in ' \t\r\n': k += 1 + if depth == 1 and want_key: want_key, listed = False, bool(list_key.match(text[i + 1:j])) + if (k < n and text[k] == ":") or (depth >= 1 and listed): + out.setdefault(line, []).append((i - bol, j + 1 - bol)) + nl = text.count('\n', i, j) + if nl: line += nl; bol = text.rindex('\n', i, j) + 1 + i = j + 1 + continue + if text.startswith('//', i): # a JSONC comment (deno.jsonc) + j = text.find('\n', i); i = n if j < 0 else j + continue + if c in '{[': + depth += 1 + if depth == 1: want_key = c == '{' + elif c in '}]': depth -= 1 + elif c == ',' and depth == 1: want_key = True + elif c == '\n': line += 1; bol = i + 1 + i += 1 + return out + + +def _unquoted(s): + """a TOML line without its strings and its comment: what is left are the brackets that open and close a list""" + return re.sub(r'"(?:\\.|[^"\\])*"|\'[^\']*\'', '""', s).split('#', 1)[0] + + +def _toml_lines(text, table, key=None): + """the lines inside a table `table` matches, and those of a `key = [ ... ]` list outside one""" + out, in_table, open_brackets = set(), False, 0 + for i, ln in enumerate(text.split('\n'), 1): + if open_brackets > 0: # inside a `dependencies = [ ... ]` spread over lines + out.add(i); s = _unquoted(ln); open_brackets += s.count('[') - s.count(']'); continue + if ln.lstrip().startswith('['): in_table = bool(table.match(ln.strip())); continue + if in_table: out.add(i); continue + if key and key.match(ln): + out.add(i); s = _unquoted(ln); open_brackets = s.count('[') - s.count(']') + return out + + +def _yaml_lines(text, block): + """the lines of the top-level YAML blocks `block` matches: the key's line and every indented or list line under it""" + out, inside = set(), False + for i, ln in enumerate(text.split('\n'), 1): + m = re.match(r'([\w.-]+)\s*:', ln) + if m: inside = bool(block.match(m.group(1))) + elif ln[:1] not in ('', ' ', '\t', '-', '#'): inside = False + if inside: out.add(i) + return out + + +def _xml_lines(base, text): + """the lines of an XML manifest that name a package: a POM's dependency blocks and coordinates, a NuGet element""" + lines, out, depth = text.split('\n'), set(), 0 + for i, ln in enumerate(lines, 1): + if base == 'pom.xml': + opened = depth > 0 + for m in POM_BLOCK.finditer(ln): depth += -1 if m.group(1) else 1 + if opened or depth > 0 or POM_COORD.match(ln): out.add(i) + elif XML_PKG_LINE.search(ln) or (base.endswith('.nuspec') and POM_COORD.match(ln)): out.add(i) + return out + + +def manifest_skip(rel, text): + """-> skip(line, col): True where a word written there sits in a lockfile, a manifest's metadata or one of its + dependency lists (see LOCKFILES AND MANIFEST LISTS); None for any other file""" + base = os.path.basename(rel).lower() + if is_lockfile(rel) or REQUIREMENTS.match(base): return lambda _l, _c: True + if base in JSON_LIST_KEY: + spans = _json_spans(text, JSON_LIST_KEY[base]) + return lambda l, c: any(a <= c < b for a, b in spans.get(l, ())) + if base in YAML_LIST_KEY: lines = _yaml_lines(text, YAML_LIST_KEY[base]) + elif base == 'pyproject.toml': lines = _toml_lines(text, PY_DEP_TABLE, PY_DEP_KEY) + elif base == 'pipfile': lines = _toml_lines(text, PIPFILE_TABLE) + elif base in ('pom.xml', 'packages.config') or base.endswith(('.csproj', '.fsproj', '.vbproj', '.props', '.targets', '.nuspec')): + lines = _xml_lines(base, text) + else: return None + return lambda l, _c: l in lines COMMON = re.compile(r'[a-z]+') _QUOTES = '"\'`' QUALIFIER = re.compile(r'[\w$)\]>](?:#|::|->)$') # `Owner#note`, `Owner::note`, `$obj->note`; not a CSS `#note` selector def out_of_scope(rel, text): - """a shell script or a Datalog file: a name matched there is never a binding (owner's rule)""" - if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT: return True + """a shell script, a Datalog file or a lockfile: a name matched there is never a binding (owner's rule)""" + if os.path.splitext(rel)[1].lower() in OUT_OF_SCOPE_EXT or is_lockfile(rel): return True return not os.path.splitext(rel)[1] and bool(SHELL_SHEBANG.match(text[:120])) @@ -211,9 +346,11 @@ def hits(self, names): try: text = open(os.path.join(self.repo, rel), errors='replace').read() except OSError: continue if not any(n in text for n in names) or out_of_scope(rel, text): continue + skip = manifest_skip(rel, text) for i, line in enumerate(text.split('\n'), 1): shaped = {} for m in pat.finditer(line): + if skip and skip(i, m.start(1)): continue n = m.group(1); hits.append((n, rel, i)) shaped[n] = shaped.get(n, False) or not is_common(n) or code_shaped(line, m.start(1), m.end(1)) self.prose.update((n, rel, i) for n, ok in shaped.items() if not ok) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py index 94915f7f..28aabf2e 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_pages.py @@ -300,10 +300,17 @@ def next_context(text): "step's BODY, not only the line shown, since the body is the explanation and the flow is only its spine " "(`--source` prints it)." + gap) m = re.search(r'^\s+(?:hop \d+|name only, no call path)\s+(\S+)\s+\(\d+ symbol\(s\)\)[^\n]*\n\s+-> ([^\n]+)', text, re.M) + if m: + f = m.group(1); syms = [x.strip() for x in m.group(2).split(',') if x.strip()][:2] + return (f"next: read {f} first — it holds {' and '.join(syms)}; then `impact ` for what a change " + "to it reaches. The other files are ranked context, not a reading list") + # --source prints each file's declarations as code (`name (file:line)` and its lines) instead of the `->` list + m = re.search(r'^\s+(?:hop \d+|name only, no call path)\s+(\S+)\s+\(\d+ symbol\(s\)\)[^\n]*\n((?:\s+\S+ \(\S+:\d+\)\n(?:\s+(?:\d+|) \| [^\n]*\n)*)+)', + text, re.M) if not m: return '' - f = m.group(1); syms = [x.strip() for x in m.group(2).split(',') if x.strip()][:2] - return (f"next: read {f} first — it holds {' and '.join(syms)}; then `impact ` for what a change " - "to it reaches. The other files are ranked context, not a reading list") + syms = re.findall(r'^\s+(\S+) \(\S+:\d+\)$', m.group(2), re.M)[:2] + return (f"next: answer from the code of {m.group(1)} shown first above — {' and '.join(syms)}; then `impact ` for what a change to it reaches. The other files are ranked context, not a reading list") def next_changed(text): if re.search(r'^(no change|no git base)', text, re.M): return '' diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index d7077e55..9bd4561d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -482,11 +482,12 @@ def graph_langs(g): def indexed_files(g, langs): - """every file some graph of this repository holds""" + """every file some graph of this repository holds. The main graph's dir is '' in `langs` (the default the verbs + read); asked from another language's graph, it is read from its place, or a directory only it holds reads as text""" import sqlite3 held = {(sy.get('file') or '') for sy in g.all_sym.values()} for l, d in langs.items(): - if not d: continue + if not d: d = os.path.join(g.repo, '.axiomcode') try: con = sqlite3.connect(f"file:{os.path.join(d, 'out', 'graph.sqlite')}?mode=ro", uri=True) held |= {r[0] for r in con.execute("SELECT DISTINCT file FROM symbols")} @@ -578,23 +579,39 @@ def nonsource(g): def text_term_hits(g, dirs, terms, cap=200): - """[(file, first line, [terms])] -- files under `dirs` whose text uses the task's words, most words first""" + """[(file, first line, [terms])] -- files under `dirs` whose text uses the task's words, most words first. A word + in a lockfile or in a manifest's package lists is never one (ax_nonsource.py: it names a package, not the task).""" + import ax_nonsource pats = {t: re.compile(r'(?i)(?= 3} out = [] for d in dirs: for root, _ds, fs in os.walk(os.path.join(g.repo, d)): for fn in fs: rel = os.path.relpath(os.path.join(root, fn), g.repo).replace(os.sep, '/') + if ax_nonsource.is_lockfile(rel): continue ln = source_lines(g.repo, rel) + skip =ax_nonsource.manifest_skip(rel, '\n'.join(ln)) or (lambda _l, _c: False) got, first = [], 0 for t, pt in pats.items(): - k = next((i for i, x in enumerate(ln, 1) if pt.search(x)), 0) + k = next((i for i, x in enumerate(ln, 1) for m in pt.finditer(x) if not skip(i, m.start())), 0) if k: got.append(t); first = min(first or k, k) if got: out.append((rel, first, got)) if len(out) >= cap: break return sorted(out, key=lambda r: (-len(r[2]), r[0])) +def print_text_hits(g, dirs, terms): + """print the text files under `dirs` that use the task's words; -> how many there are""" + hits = text_term_hits(g, dirs, terms) + if hits: + print(f"\ntext files under {', '.join(d + '/' for d in dirs)} that use the task's words (not source; " + "matched by word, not by any declaration):") + for f, l, ts in hits[:BINDINGS_SHOWN]: + print(f" {f + ':' + str(l):56} {', '.join(ts)}") + RESULT['text_bindings'] = [{'file': f, 'line': l, 'terms': ts} for f, l, ts in hits] + return len(hits) + + ASK_EXT = [(re.compile(r'\b(sql|quer(y|ies)|statements?|mapper|mappings?|migrations?|schema)\b', re.I), ('.xml', '.sql')), (re.compile(r'\b(config(uration|ured)?|propert(y|ies)|settings?|ya?ml|toml|ini|env)\b', re.I), ('.properties', '.yml', '.yaml', '.toml', '.ini', '.conf', '.cfg', '.env')), @@ -607,10 +624,12 @@ def asked_exts(text): return tuple(e for rx, es in ASK_EXT if rx.search(text or '') for e in es) -def text_bindings(g, sids, under=(), want_ext=()): +def text_bindings(g, sids, under=(), want_ext=(), scope=None): """[(file, line, declaration, name written there)] -- a non-source file that writes the name of one of `sids` as a whole word. A mapper XML with a namespace binds only the type that namespace names (a statement id repeated - in another mapper's XML is not this method's SQL).""" + in another mapper's XML is not this method's SQL). `scope` (the caller's --in) keeps only the files under it, as + it keeps only the declarations under it, except a mapper whose namespace names the type: that binding is proved + by the declaration, not by the word, wherever the file sits.""" want = collections.defaultdict(set) # name -> {sid} decl_count = collections.Counter(sy.get('name') for sy in g.sym.values() if not (sy.get('file') or '').startswith('<')) for sid in sids: @@ -642,6 +661,7 @@ def text_bindings(g, sids, under=(), want_ext=()): if nm in common and not ns: continue elif nm in common: continue else: cands = sorted(want[nm]) + if scope and not scope(f) and not (f.endswith('.xml') and ns_of.get(f)): continue key = (f, nm) if key in seen: continue seen.add(key) @@ -653,7 +673,7 @@ def text_bindings(g, sids, under=(), want_ext=()): return [r for _k, r in sorted(out)] -def asked_bound(g, scored, text, under=(), cap=300): +def asked_bound(g, scored, text, under=(), cap=300, scope=None): """{symbol id} among the multi-term matches that a text file of the kind the question asks about binds by name. Empty unless the question asks about such a file (SQL, configuration, a template): only then is the tree walked, and only then does it break a tie between equally scored entry points (pick_seeds).""" @@ -662,7 +682,7 @@ def asked_bound(g, scored, text, under=(), cap=300): cands = sorted((sid for sid, (_sc, m) in scored.items() if len(m) >= 2), key=lambda s: -scored[s][0])[:cap] by_disp = collections.defaultdict(set) for sid in cands: by_disp[g.disp(sid)].add(sid) - return frozenset(sid for f, _l, d, _n in text_bindings(g, cands, under=under, want_ext=kinds) + return frozenset(sid for f, _l, d, _n in text_bindings(g, cands, under=under, want_ext=kinds, scope=scope) if f.endswith(kinds) for sid in by_disp.get(d, ())) @@ -1199,8 +1219,14 @@ def main(argv): RESULT['not_indexed'] = list(notice) for ln in notice: print(ln) if notice: print() + text_only_in = False if text_dirs: + asked_in = bool(scopes) scopes = [x for x in scopes if x.strip('/').lstrip('./') not in text_dirs] + text_only_in = asked_in and not scopes and not scope_offered + # every --in names text no graph holds: one graph lists it (the one that says so, above), and another has + # nothing inside the caller's scope to add — its only package is outside it and would become the next step + if fan and text_only_in and not notice: return 3 # AN --in NO GRAPH HOLDS AND NO DIRECTORY IS was refused with a menu, once per language graph, and no answer: the # agent then searched by hand. A typo is not a question about nothing; the task is answered at the root (or under # the scopes that are real) and the line says what was dropped and what is close. A scope another language's graph @@ -1288,12 +1314,21 @@ def main(argv): # is naming a change that spans them, and intersecting those two paths answers nothing (#1029) specs = [scope_spec(sc, g.repo) for sc in scopes] in_scope = lambda f: any(under_scope(f, sp) for sp in specs) + # the text files are narrowed by the CALLER's --in only: the sole package this graph indexes is not a statement + # about where its SQL or configuration lives, and a guessed scope narrows nothing + fixed_specs = [scope_spec(sc, g.repo) for sc in fixed] + text_scope = (lambda f: any(under_scope(f, sp) for sp in fixed_specs)) if fixed_specs else None if scopes and not scope_offered: scored = {sid: v for sid, v in scored.items() if in_scope(g.sym.get(sid, {}).get('file'))} # what the question NAMES comes before what its words match: a declaration spelled out, a route quoted named = named_declarations(g, body) + route_seeds(g, body) if scopes and not scope_offered: named = [(sid, why) for sid, why in named if in_scope(g.sym.get(sid, {}).get('file'))] + if not scored and not named and not have_from and text_only_in: + # the caller's --in holds only text: its files that use the task's words are the answer, and the package this + # graph put in its place holding none of them is no reason to refuse it + if not print_text_hits(g, text_dirs, terms): print(f"no file under {', '.join(d + '/' for d in text_dirs)} uses the task's words") + return 0 if not scored and not named and not have_from: # the scope is real (require_scope proved it) but holds nothing matching — offer what is under it, # ranked the same way the scope-less menu is: by where the words land, never by how big a directory is @@ -1307,7 +1342,7 @@ def main(argv): # themselves, and on Python every class has one, so they can be a third of this list while # naming nowhere to go. scored = {sid: v for sid, v in scored.items() if not is_synthetic(g.sym.get(sid, {}).get('name'))} - seeds = pick_seeds(g, scored, terms, seeds_wanted, named, prefer=asked_bound(g, scored, body, text_dirs)) + seeds = pick_seeds(g, scored, terms, seeds_wanted, named, prefer=asked_bound(g, scored, body, text_dirs, scope=text_scope)) RESULT['terms'] = list(terms) RESULT['scope'], RESULT['scope_offered'] = (scopes[0] if scopes else None), bool(scope_offered) @@ -1525,7 +1560,7 @@ def main(argv): depth[x] = d + 1 near = list(seed_ids) + sorted(ring, key=lambda x: (not bodiless(g, x), depth[x], g.disp(x) or '', x))[:300] kinds = asked_exts(body) - binds = text_bindings(g, near, under=text_dirs, want_ext=kinds) + binds = text_bindings(g, near, under=text_dirs, want_ext=kinds, scope=text_scope) # the question named a kind of file (SQL: mapper XML or .sql): when some binding is of that kind, the others # (a Postman collection, a README table that spells the method) are not what was asked if kinds and any(f.endswith(kinds) for f, _l, _d, _n in binds): @@ -1538,13 +1573,7 @@ def main(argv): if len(binds) > BINDINGS_SHOWN: print(f" … +{len(binds) - BINDINGS_SHOWN} more") RESULT['text_bindings'] = [{'file': f, 'line': l, 'declaration': d, 'name': nm} for f, l, d, nm in binds] elif text_dirs: - hits = text_term_hits(g, text_dirs, terms) - if hits: - print(f"\ntext files under {', '.join(d + '/' for d in text_dirs)} that use the task's words (not source; " - "matched by word, not by any declaration):") - for f, l, ts in hits[:BINDINGS_SHOWN]: - print(f" {f + ':' + str(l):56} {', '.join(ts)}") - RESULT['text_bindings'] = [{'file': f, 'line': l, 'terms': ts} for f, l, ts in hits] + print_text_hits(g, text_dirs, terms) print("\n" + BOUND) print(" narrow with `impact --in ` or `path '*' --in `.") diff --git a/tests/cases/java/context-answers-what-was-asked/case.json b/tests/cases/java/context-answers-what-was-asked/case.json index 333ae910..777f3f3e 100644 --- a/tests/cases/java/context-answers-what-was-asked/case.json +++ b/tests/cases/java/context-answers-what-was-asked/case.json @@ -5,6 +5,10 @@ "want": ["text files that name these declarations", "src/main/resources/mapper/OrderMapper.xml:3", "names findByNumber (OrderMapper.findByNumber)", "next: read src/main/resources/mapper/OrderMapper.xml:3"], "avoid": ["InvoiceMapper.xml"]}, + {"why": "control for --in narrowing text files: a mapper XML whose namespace names the declaring type is bound by that declaration, so it is listed even when --in names only the source tree", + "run": ["context", "which SQL runs when an order is loaded by number", "--in", "src/main/java"], + "want": ["src/main/resources/mapper/OrderMapper.xml:3", "names findByNumber (OrderMapper.findByNumber)"], + "avoid": ["InvoiceMapper.xml"]}, {"why": "--in on a directory that holds no source is accepted: it says the graph cannot see it and lists the text files under it bound to what the question names", "run": ["context", "which SQL runs for findByNumber", "--in", "src/main/resources"], "want": ["not indexed: src/main/resources/", "named in the task (findByNumber)", "src/main/resources/mapper/OrderMapper.xml:3"], diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json new file mode 100644 index 00000000..5721bd26 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/bower.json @@ -0,0 +1 @@ +{ "name": "wiring-sample", "dependencies": { "debug": "1.0.0" } } diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json index 3f88d68a..720b5f37 100644 --- a/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/case.json @@ -8,11 +8,15 @@ "run": ["context", "app wiring: createApp and its output", "--in", "src/a", "--source"], "want": ["next: answer from the code of src/a/app.js shown first above"], "avoid": ["package-lock.json", "package.json:"]}, + {"why": "--in a directory no graph holds lists its text files by word, and a manifest's dependency line there is not one; its rule file is (control)", + "run": ["context", "lint tooling: which debug rule the linter loads", "--in", "tools/lint"], + "want": ["not indexed: tools/lint/", "tools/lint/rules.json:2", "next: read tools/lint/"], + "avoid": ["tools/lint/package.json", "package-lock.json"]}, {"why": "without --in, a lockfile is never a text lead and a manifest's description, dependency lists and script names are not either; a manifest script command and a config file that name the function are (control: the manifest is filtered by line, not dropped)", "run": ["context", "app wiring: createApp, config, debug output"], - "want": ["src/a/routes.json:2", "config/app.json:2", "package.json:6"], + "want": ["config/app.json:2", "package.json:6"], "avoid": ["package-lock.json", "packages.lock.json", "package.json:4", "package.json:7", "package.json:10", "package.json:13"]}, - {"why": "impact lists the same text files as bound from outside the source: the lockfiles and the manifest's dependency lists are not among them", + {"why": "impact lists the same text files as bound from outside the source: lockfiles, the manifest's dependency lists and every other manifest's package list (a pnpm catalog, bower and deno imports, a requirements file) are not among them; a script or task value that names the function is, in a one-line manifest too (control)", "run": ["impact", "debug"], - "want": ["src/a/routes.json", "config/app.json"], - "avoid": ["package-lock.json", "packages.lock.json", "package.json:7", "package.json:10"]}]} + "want": ["src/a/routes.json", "config/app.json", "deno.json:3", "packages/min/package.json:1"], + "avoid": ["package-lock.json", "packages.lock.json", "package.json:7", "package.json:10", "pnpm-workspace.yaml", "bower.json", "deno.json:2", "requirements.txt"]}]} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json new file mode 100644 index 00000000..817764be --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/deno.json @@ -0,0 +1,4 @@ +{ + "imports": { "debug": "npm:debug@4.1.0" }, + "tasks": { "trace": "debug" } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json new file mode 100644 index 00000000..c25c2e2c --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "wiring-sample", + "lockfileVersion": 3, + "packages": { + "node_modules/debug": { + "version": "4.3.4", + "dependencies": { + "ms": "2.1.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "optional": true + } + }, + "dependencies": { + "debug": "^4.1.0" + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json new file mode 100644 index 00000000..16825bfa --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/packages/min/package.json @@ -0,0 +1 @@ +{"name":"min","dependencies":{"debug":"^4"},"scripts":{"trace":"debug"}} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml b/tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml new file mode 100644 index 00000000..b91fa7b9 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +catalog: + debug: ^4.1.0 diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt b/tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt new file mode 100644 index 00000000..8058cbc2 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/requirements.txt @@ -0,0 +1 @@ +debug==0.1 diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json new file mode 100644 index 00000000..36702d1f --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/package.json @@ -0,0 +1,6 @@ +{ + "name": "lint-tools", + "devDependencies": { + "debug": "^4.1.0" + } +} diff --git a/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json new file mode 100644 index 00000000..64d7d3b4 --- /dev/null +++ b/tests/cases/javascript/context-text-leads-skip-lockfiles/tools/lint/rules.json @@ -0,0 +1,3 @@ +{ + "rules": { "no-debug": "warn" } +} diff --git a/tests/multi_language.py b/tests/multi_language.py index 7f0f644d..39e2d5b2 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -50,7 +50,8 @@ 'lib/pytools/probe.py': 'def emit(x):\n return [x]\n', 'src/cli.ts': 'import { run } from \'./main\'\n\nexport function main(): number {\n return run()\n}\n', 'jslib/package.json': '{ "name": "jslib", "version": "1.0.0", "main": "index.js" }\n', - 'jslib/index.js': 'function helper(a) {\n return a + 1\n}\n\nfunction api(a) {\n return helper(a) * 2\n}\n\nmodule.exports = { api }\n', + 'notes/steps.json': '{ "steps": ["total"] }\n', + 'jslib/index.js':'function helper(a) {\n return a + 1\n}\n\nfunction api(a) {\n return helper(a) * 2\n}\n\nmodule.exports = { api }\n', } # a Maven project whose build wrote javadoc: target/ beside the pom.xml, and a copy committed for a docs site @@ -212,6 +213,16 @@ def check(ok, why, detail=''): s = sh(repo, AX, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) check(s.returncode == 0 and 'tools/gen/make.py' in s.stdout and 'lib/pytools' not in s.stdout, 'scope: context --in tools keeps lib/pytools/ out too', s.stdout + s.stderr) + # a directory only the MAIN graph holds, asked from every graph: another language's graph once read it as text + # no graph holds, listed its source files as text rows and ended on a next step outside the scope + s = sh(repo, AX, 'context', 'compute the area of a shape and add up a total', '.', '--in', 'src', env=quiet) + check(s.returncode == 0 and 'not indexed: src/' not in s.stdout and 'tools/' not in s.stdout and 'src/shape.ts' in s.stdout, + 'scope: a directory the main graph holds is not text to the other graphs', s.stdout + s.stderr) + # a directory no graph holds, named by --in: its text files are listed once, and every next step stays in it + s = sh(repo, AX, 'context', 'which steps add up a total', '.', '--in', 'notes', env=quiet) + nexts = [l for l in s.stdout.splitlines() if l.startswith('next:')] + check(s.returncode == 0 and s.stdout.count('notes/steps.json') == 1 and nexts and all('notes/' in l for l in nexts), + 'scope: --in a text-only directory is listed by one graph, and no next step leaves it', s.stdout + s.stderr) # ── --from ──────────────────────────────────────────────────────────────────────────────────────────── # `main` is declared in the typescript graph and twice in the python one: the flow starts where the task's 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 005/133] 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 006/133] 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 007/133] 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 008/133] 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 009/133] 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 010/133] 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 }; From a627e1dde25364ca1f2cf6e01f452438a8537209 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:50:23 -0700 Subject: [PATCH 011/133] changed: charge an edit inside a multi-line field initializer to that field An edit, insertion or comment on a later line of a const or field whose initializer spans several lines was reported as a body change of the module (or 'inside' the type), so test-impact seeded from the whole module. It is now the field's 'initializer changed'. A callable written inside the initializer keeps its own body; a Python module-level name is measured by its statement, not its first-to-last binding span. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-changed | 31 +++- .../csharp/one-line-statements/case.json | 21 +++ .../csharp/one-line-statements/new_conf.cs | 19 ++ .../csharp/one-line-statements/src/Conf.cs | 19 ++ .../java/one-line-statements/New_conf.java | 15 ++ .../cases/java/one-line-statements/case.json | 21 +++ .../src/main/java/a/Conf.java | 15 ++ .../cases/javascript/edits-in-place/case.json | 21 +++ .../javascript/edits-in-place/new-conf.js | 8 + .../javascript/edits-in-place/src/conf.js | 8 + .../python/one-line-statements/case.json | 60 +++++++ .../one-line-statements/new_conf_cls.py | 16 ++ .../one-line-statements/new_conf_mod.py | 15 ++ .../python/one-line-statements/new_script.py | 4 + .../python/one-line-statements/src/conf.py | 15 ++ .../python/one-line-statements/src/script.py | 4 + .../multi-line-initializer/case.json | 170 ++++++++++++++++++ .../new_call_argument.ts | 24 +++ .../multi-line-initializer/new_comment.ts | 23 +++ .../multi-line-initializer/new_function.ts | 23 +++ .../new_member_added.ts | 24 +++ .../new_member_edited.ts | 23 +++ .../new_member_removed.ts | 22 +++ .../new_method_in_object.ts | 23 +++ .../multi-line-initializer/new_one_line.ts | 23 +++ .../multi-line-initializer/src/config.ts | 23 +++ .../multi-line-initializer/src/use.test.ts | 2 + 27 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 tests/cases/csharp/one-line-statements/new_conf.cs create mode 100644 tests/cases/csharp/one-line-statements/src/Conf.cs create mode 100644 tests/cases/java/one-line-statements/New_conf.java create mode 100644 tests/cases/java/one-line-statements/src/main/java/a/Conf.java create mode 100644 tests/cases/javascript/edits-in-place/new-conf.js create mode 100644 tests/cases/javascript/edits-in-place/src/conf.js create mode 100644 tests/cases/python/one-line-statements/new_conf_cls.py create mode 100644 tests/cases/python/one-line-statements/new_conf_mod.py create mode 100644 tests/cases/python/one-line-statements/new_script.py create mode 100644 tests/cases/python/one-line-statements/src/conf.py create mode 100644 tests/cases/python/one-line-statements/src/script.py create mode 100644 tests/cases/typescript/multi-line-initializer/case.json create mode 100644 tests/cases/typescript/multi-line-initializer/new_call_argument.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_comment.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_function.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_member_added.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_member_edited.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_member_removed.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_method_in_object.ts create mode 100644 tests/cases/typescript/multi-line-initializer/new_one_line.ts create mode 100644 tests/cases/typescript/multi-line-initializer/src/config.ts create mode 100644 tests/cases/typescript/multi-line-initializer/src/use.test.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 03c0b597..0bb793e9 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -769,6 +769,29 @@ class Changed: if not lam or (m and m[3] != 'module' and m[0] <= lam[0] and lam[1] <= m[1]): return None if any(k in ('field', 'const', 'enum_member', 'variable') and a <= lam[0] <= max(b, a) for a, b, i, k, d, n in decls): return None return lam + FIELD_KINDS = ('field', 'const', 'enum_member', 'variable') + def field_spanning(ln, first=False): + """A FIELD'S INITIALIZER IS THE FIELD'S, however many lines it spans. Only a field's first line was charged to + it, so an edit to the second line of `export const A = {\n x: 1,\n};` (a schema object, a token table, a + multi-line static initializer) was "body " or "inside ", and test-impact seeded from every + declaration of the module instead of the const. The narrowest field whose span holds ln after its first line + (from it too with first=True, for an insertion after that line); None when a callable written inside the + initializer (an object literal's method) holds ln itself: that callable's body is its own. + A Python module-level name is recorded from its first binding to its last (`x = 0` at the top, `x = 5` at the + bottom spans the file), not by its statement: there the span is the statement's own lines, while a bracket + it opens is still open or a line ends in a backslash""" + def end(x): + if not is_py: return x[1] + depth, j = 0, x[0] + while j <= len(OS): + t = OS[j - 1]; depth += sum(t.count(c) for c in '([{') - sum(t.count(c) for c in ')]}') + if depth <= 0 and not t.rstrip().endswith('\\'): return j + j += 1 + return x[0] + inside = lambda x: x[0] <= ln < end(x) if first else x[0] < ln <= end(x) + fx = min((x for x in decls if x[3] in FIELD_KINDS and inside(x)), key=lambda x: end(x) - x[0], default=None) + inner = narrowest(ln, {'method', 'function', 'constructor'}) if fx else None + return None if inner and fx[0] <= inner[0] and inner[1] <= end(fx) else fx def decl_after(ln, L): """the declaration whose header starts within 3 lines after ln (an annotation / decorator line belongs to it)""" for a, b, i, k, d, n in decls: @@ -785,6 +808,10 @@ class Changed: m = narrowest(after, {'method', 'function', 'constructor'}) if after else None if m and m[0] <= after < self.header_end(OL, m[0]): hits.setdefault(('signature', m), set()).add(after); header_ins.add((after, j1, j2)) + # and a line inserted into a field's initializer (a member added to a multi-line object) is that field's + for ins in list(added): + fx = field_spanning(ins[0], first=True) if ins[0] and ins not in header_ins else None + if fx: hits.setdefault(('field', fx), set()).add(ins[0]); added.remove(ins) def counterpart(ln): """the new line an old changed line became: the most similar line of its replaced block; None when deleted""" if ln in removed_lines: return None @@ -816,7 +843,7 @@ class Changed: f = None # a lambda's lines inside a field's initializer are the field's elif not f: f = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if k in ('field', 'const', 'enum_member', 'variable') and a < ln - and any(is_lam(x) and x[0] <= ln <= x[1] and a <= x[0] <= max(b, a) for x in decls)), None) + and any(is_lam(x) and x[0] <= ln <= x[1] and a <= x[0] <= max(b, a) for x in decls)), None) or field_spanning(ln) m = narrowest(ln, {'method', 'function', 'constructor', 'module'}) # A FUNCTION WRITTEN IN ANOTHER'S PARAMETER LIST IS PART OF THAT HEADER. A default value # (`clock = { millis: () => Date.now() }`) holds a callable the graph records on its own; an edit inside the @@ -1061,7 +1088,7 @@ class Changed: # holds it. Never a signature, never nothing; a comment in a module's top level belongs to no declaration for ln in sorted(comment_only): x = next(((a, b, i, k, d, n) for a, b, i, k, d, n in decls if a == ln and k in ('field', 'const', 'enum_member', 'variable')), None) or \ - next((m for m in [narrowest(ln, {'method', 'function', 'constructor'})] if m), None) or \ + field_spanning(ln) or next((m for m in [narrowest(ln, {'method', 'function', 'constructor'})] if m), None) or \ narrowest(ln, {'class', 'interface', 'enum', 'type'}) if not x or any(e.get('id') == x[2] for e in out): continue a, b, i, k, d, n = x diff --git a/tests/cases/csharp/one-line-statements/case.json b/tests/cases/csharp/one-line-statements/case.json index d363cc13..6dad10ce 100644 --- a/tests/cases/csharp/one-line-statements/case.json +++ b/tests/cases/csharp/one-line-statements/case.json @@ -81,6 +81,27 @@ "avoid": [ " field " ] + }, + { + "why": "an edit inside a static readonly initializer written over several lines (a collection initializer) is that field's, not 'inside Conf'", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/Conf.cs", + "--new", + "{repo}/new_conf.cs", + "--file", + "src/Conf.cs" + ], + "want": [ + "field Conf.Settings", + "initializer" + ], + "avoid": [ + "inside Conf", + "Conf.Limit" + ] } ] } \ No newline at end of file diff --git a/tests/cases/csharp/one-line-statements/new_conf.cs b/tests/cases/csharp/one-line-statements/new_conf.cs new file mode 100644 index 00000000..f4b0f8ad --- /dev/null +++ b/tests/cases/csharp/one-line-statements/new_conf.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace A +{ + public class Conf + { + static readonly Dictionary Settings = new Dictionary + { + ["retries"] = 4, + ["timeout"] = 10, + }; + static readonly int Limit = 1; + + public int Get() + { + return Settings["retries"] + Limit; + } + } +} diff --git a/tests/cases/csharp/one-line-statements/src/Conf.cs b/tests/cases/csharp/one-line-statements/src/Conf.cs new file mode 100644 index 00000000..8f11af76 --- /dev/null +++ b/tests/cases/csharp/one-line-statements/src/Conf.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace A +{ + public class Conf + { + static readonly Dictionary Settings = new Dictionary + { + ["retries"] = 3, + ["timeout"] = 10, + }; + static readonly int Limit = 1; + + public int Get() + { + return Settings["retries"] + Limit; + } + } +} diff --git a/tests/cases/java/one-line-statements/New_conf.java b/tests/cases/java/one-line-statements/New_conf.java new file mode 100644 index 00000000..ed99655a --- /dev/null +++ b/tests/cases/java/one-line-statements/New_conf.java @@ -0,0 +1,15 @@ +package a; + +import java.util.List; +import java.util.Map; + +public class Conf { + static final Map SETTINGS = Map.of( + "retries", 4, + "timeout", 10); + static final int LIMIT = 1; + + int get() { + return SETTINGS.get("retries") + LIMIT; + } +} diff --git a/tests/cases/java/one-line-statements/case.json b/tests/cases/java/one-line-statements/case.json index a021ad92..991a9f99 100644 --- a/tests/cases/java/one-line-statements/case.json +++ b/tests/cases/java/one-line-statements/case.json @@ -143,6 +143,27 @@ "Box.lo", "added Box.high" ] + }, + { + "why": "an edit on a later line of a static final initializer written over several lines is that field's, not 'inside Conf'", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/main/java/a/Conf.java", + "--new", + "{repo}/New_conf.java", + "--file", + "src/main/java/a/Conf.java" + ], + "want": [ + "field Conf.SETTINGS", + "initializer" + ], + "avoid": [ + "inside Conf", + "Conf.LIMIT" + ] } ] } \ No newline at end of file diff --git a/tests/cases/java/one-line-statements/src/main/java/a/Conf.java b/tests/cases/java/one-line-statements/src/main/java/a/Conf.java new file mode 100644 index 00000000..214da4cd --- /dev/null +++ b/tests/cases/java/one-line-statements/src/main/java/a/Conf.java @@ -0,0 +1,15 @@ +package a; + +import java.util.List; +import java.util.Map; + +public class Conf { + static final Map SETTINGS = Map.of( + "retries", 3, + "timeout", 10); + static final int LIMIT = 1; + + int get() { + return SETTINGS.get("retries") + LIMIT; + } +} diff --git a/tests/cases/javascript/edits-in-place/case.json b/tests/cases/javascript/edits-in-place/case.json index b595e6e8..8b6ce905 100644 --- a/tests/cases/javascript/edits-in-place/case.json +++ b/tests/cases/javascript/edits-in-place/case.json @@ -200,6 +200,27 @@ "signature ", "removed " ] + }, + { + "why": "an edit on a later line of a const whose object initializer spans lines is that const's, not the module's body", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/conf.js", + "--new", + "{repo}/new-conf.js", + "--file", + "src/conf.js" + ], + "want": [ + "field Settings", + "initializer" + ], + "avoid": [ + "", + "LIMIT" + ] } ] } \ No newline at end of file diff --git a/tests/cases/javascript/edits-in-place/new-conf.js b/tests/cases/javascript/edits-in-place/new-conf.js new file mode 100644 index 00000000..6c9bd63b --- /dev/null +++ b/tests/cases/javascript/edits-in-place/new-conf.js @@ -0,0 +1,8 @@ +export const Settings = { + retries: 4, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export function total() { + return Settings.retries + LIMIT.max; +} diff --git a/tests/cases/javascript/edits-in-place/src/conf.js b/tests/cases/javascript/edits-in-place/src/conf.js new file mode 100644 index 00000000..63bae002 --- /dev/null +++ b/tests/cases/javascript/edits-in-place/src/conf.js @@ -0,0 +1,8 @@ +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export function total() { + return Settings.retries + LIMIT.max; +} diff --git a/tests/cases/python/one-line-statements/case.json b/tests/cases/python/one-line-statements/case.json index 678bb2dc..a2052746 100644 --- a/tests/cases/python/one-line-statements/case.json +++ b/tests/cases/python/one-line-statements/case.json @@ -167,6 +167,66 @@ "variable lo", "variable j" ] + }, + { + "why": "an edit inside a module-level dict written over several lines is that name's, not the module's body", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/conf.py", + "--new", + "{repo}/new_conf_mod.py", + "--file", + "src/conf.py" + ], + "want": [ + "field SETTINGS", + "initializer" + ], + "avoid": [ + "", + "LIMIT" + ] + }, + { + "why": "and a list item added to a class attribute written over several lines is that attribute's, not the class body's", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/conf.py", + "--new", + "{repo}/new_conf_cls.py", + "--file", + "src/conf.py" + ], + "want": [ + "field Store.ROUTES", + "initializer" + ], + "avoid": [ + "" + ] + }, + { + "why": "control: a statement between two bindings of a module-level name is not that name's (its recorded span runs from the first binding to the last)", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/script.py", + "--new", + "{repo}/new_script.py", + "--file", + "src/script.py" + ], + "want": [ + "body script." + ], + "avoid": [ + "count" + ] } ] } \ No newline at end of file diff --git a/tests/cases/python/one-line-statements/new_conf_cls.py b/tests/cases/python/one-line-statements/new_conf_cls.py new file mode 100644 index 00000000..cdf0f602 --- /dev/null +++ b/tests/cases/python/one-line-statements/new_conf_cls.py @@ -0,0 +1,16 @@ +SETTINGS = { + "retries": 3, + "timeout": 10, +} +LIMIT = {"max": 1} + + +class Store: + ROUTES = [ + "a", + "b", + "c", + ] + + def get(self): + return SETTINGS["retries"] + LIMIT["max"] diff --git a/tests/cases/python/one-line-statements/new_conf_mod.py b/tests/cases/python/one-line-statements/new_conf_mod.py new file mode 100644 index 00000000..9273a097 --- /dev/null +++ b/tests/cases/python/one-line-statements/new_conf_mod.py @@ -0,0 +1,15 @@ +SETTINGS = { + "retries": 4, + "timeout": 10, +} +LIMIT = {"max": 1} + + +class Store: + ROUTES = [ + "a", + "b", + ] + + def get(self): + return SETTINGS["retries"] + LIMIT["max"] diff --git a/tests/cases/python/one-line-statements/new_script.py b/tests/cases/python/one-line-statements/new_script.py new file mode 100644 index 00000000..da6e1e13 --- /dev/null +++ b/tests/cases/python/one-line-statements/new_script.py @@ -0,0 +1,4 @@ +count = 0 +print("start") +print("the middle") +count = 5 diff --git a/tests/cases/python/one-line-statements/src/conf.py b/tests/cases/python/one-line-statements/src/conf.py new file mode 100644 index 00000000..315fe390 --- /dev/null +++ b/tests/cases/python/one-line-statements/src/conf.py @@ -0,0 +1,15 @@ +SETTINGS = { + "retries": 3, + "timeout": 10, +} +LIMIT = {"max": 1} + + +class Store: + ROUTES = [ + "a", + "b", + ] + + def get(self): + return SETTINGS["retries"] + LIMIT["max"] diff --git a/tests/cases/python/one-line-statements/src/script.py b/tests/cases/python/one-line-statements/src/script.py new file mode 100644 index 00000000..7a5620c2 --- /dev/null +++ b/tests/cases/python/one-line-statements/src/script.py @@ -0,0 +1,4 @@ +count = 0 +print("start") +print("middle") +count = 5 diff --git a/tests/cases/typescript/multi-line-initializer/case.json b/tests/cases/typescript/multi-line-initializer/case.json new file mode 100644 index 00000000..276bacd7 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/case.json @@ -0,0 +1,170 @@ +{ + "lang": "typescript", + "src": "src", + "checks": [ + { + "why": "an edit on the second line of a const whose object initializer spans lines is that const's initializer, not the module's body", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_member_edited.ts", + "--file", + "src/config.ts" + ], + "want": [ + "field Settings", + "initializer" + ], + "avoid": [ + "" + ] + }, + { + "why": "a member inserted into a multi-line `as const` object is that const's change", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_member_added.ts", + "--file", + "src/config.ts" + ], + "want": [ + "field Tokens", + "initializer" + ], + "avoid": [ + "", + "new field" + ] + }, + { + "why": "a member removed from it too", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_member_removed.ts", + "--file", + "src/config.ts" + ], + "want": [ + "field Tokens", + "initializer" + ], + "avoid": [ + "", + "removed" + ] + }, + { + "why": "a member added to an object handed to a call (a schema builder) is the const the call initializes", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_call_argument.ts", + "--file", + "src/config.ts" + ], + "want": [ + "field OrderShape", + "initializer" + ], + "avoid": [ + "" + ] + }, + { + "why": "a comment added inside the initializer is that const's, as a comment only", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_comment.ts", + "--file", + "src/config.ts" + ], + "want": [ + "OrderShape", + "comment only" + ], + "avoid": [ + "" + ] + }, + { + "why": "control: a one-line const is named as before", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_one_line.ts", + "--file", + "src/config.ts" + ], + "want": [ + "field LIMIT", + "initializer" + ], + "avoid": [ + "", + "Settings" + ] + }, + { + "why": "control: a method written in an object literal keeps its own body", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_method_in_object.ts", + "--file", + "src/config.ts" + ], + "want": [ + "body run src/config.ts:17" + ], + "avoid": [ + "", + "field api" + ] + }, + { + "why": "control: a function below the consts is its own body, not a const above it", + "run": [ + "changed", + "{repo}", + "--old", + "{repo}/src/config.ts", + "--new", + "{repo}/new_function.ts", + "--file", + "src/config.ts" + ], + "want": [ + "body total" + ], + "avoid": [ + "", + "field Settings", + "field LIMIT" + ] + } + ] +} \ No newline at end of file diff --git a/tests/cases/typescript/multi-line-initializer/new_call_argument.ts b/tests/cases/typescript/multi-line-initializer/new_call_argument.ts new file mode 100644 index 00000000..40b7f1c8 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_call_argument.ts @@ -0,0 +1,24 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', + channel: 'string', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_comment.ts b/tests/cases/typescript/multi-line-initializer/new_comment.ts new file mode 100644 index 00000000..fb659a71 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_comment.ts @@ -0,0 +1,23 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', // the order's key + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_function.ts b/tests/cases/typescript/multi-line-initializer/new_function.ts new file mode 100644 index 00000000..6433f153 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_function.ts @@ -0,0 +1,23 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs - LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_member_added.ts b/tests/cases/typescript/multi-line-initializer/new_member_added.ts new file mode 100644 index 00000000..7281b874 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_member_added.ts @@ -0,0 +1,24 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', + Cache: 'Cache', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_member_edited.ts b/tests/cases/typescript/multi-line-initializer/new_member_edited.ts new file mode 100644 index 00000000..c724701d --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_member_edited.ts @@ -0,0 +1,23 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 4, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_member_removed.ts b/tests/cases/typescript/multi-line-initializer/new_member_removed.ts new file mode 100644 index 00000000..d52d9352 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_member_removed.ts @@ -0,0 +1,22 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_method_in_object.ts b/tests/cases/typescript/multi-line-initializer/new_method_in_object.ts new file mode 100644 index 00000000..cced1ac6 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_method_in_object.ts @@ -0,0 +1,23 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries + 1; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/new_one_line.ts b/tests/cases/typescript/multi-line-initializer/new_one_line.ts new file mode 100644 index 00000000..db8e6479 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/new_one_line.ts @@ -0,0 +1,23 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 2 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/src/config.ts b/tests/cases/typescript/multi-line-initializer/src/config.ts new file mode 100644 index 00000000..a574aedd --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/src/config.ts @@ -0,0 +1,23 @@ +function shape(fields: T): T { return fields; } + +export const Settings = { + retries: 3, + timeoutMs: 1000, +}; +export const LIMIT = { max: 1 }; +export const Tokens = { + Clock: 'Clock', + Store: 'Store', +} as const; +export const OrderShape = shape({ + id: 'string', + total: 'number', +}); +export const api = { + run(): number { + return Settings.retries; + }, +}; +export function total(): number { + return Settings.timeoutMs + LIMIT.max; +} diff --git a/tests/cases/typescript/multi-line-initializer/src/use.test.ts b/tests/cases/typescript/multi-line-initializer/src/use.test.ts new file mode 100644 index 00000000..25d6e778 --- /dev/null +++ b/tests/cases/typescript/multi-line-initializer/src/use.test.ts @@ -0,0 +1,2 @@ +import { total, Tokens, OrderShape } from './config'; +test('total', () => { expect(total()).toBe(1001); expect(Tokens.Clock).toBe('Clock'); expect(OrderShape.id).toBe('string'); }); From d2afa19d755abfe77f91c7559a89d409c7a26b86 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:02:54 -0700 Subject: [PATCH 012/133] changed: a const object's data key edit is its initializer; a table's lambda keeps its body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since data keys of const objects are declarations, an edit to a key's value was read as a typed field: 'type 3, → 4,', and on a one-line const 'LIMIT.max renamed → LIMIT'. It is now the key's 'initializer changed'. A lambda on a later line of a multi-line initializer is its own body again, as a method written there already was. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-changed | 10 +++++-- .../multi-line-initializer/case.json | 28 +++++++++---------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 0bb793e9..2800a20c 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -776,7 +776,7 @@ class Changed: multi-line static initializer) was "body " or "inside ", and test-impact seeded from every declaration of the module instead of the const. The narrowest field whose span holds ln after its first line (from it too with first=True, for an insertion after that line); None when a callable written inside the - initializer (an object literal's method) holds ln itself: that callable's body is its own. + initializer (an object literal's method, a table's lambda) holds ln itself: that callable's body is its own. A Python module-level name is recorded from its first binding to its last (`x = 0` at the top, `x = 5` at the bottom spans the file), not by its statement: there the span is the statement's own lines, while a bracket it opens is still open or a line ends in a backslash""" @@ -790,7 +790,7 @@ class Changed: return x[0] inside = lambda x: x[0] <= ln < end(x) if first else x[0] < ln <= end(x) fx = min((x for x in decls if x[3] in FIELD_KINDS and inside(x)), key=lambda x: end(x) - x[0], default=None) - inner = narrowest(ln, {'method', 'function', 'constructor'}) if fx else None + inner = (narrowest(ln, {'method', 'function', 'constructor'}) or narrowest(ln, None, lambdas=True)) if fx else None return None if inner and fx[0] <= inner[0] and inner[1] <= end(fx) else fx def decl_after(ln, L): """the declaration whose header starts within 3 lines after ln (an annotation / decorator line belongs to it)""" @@ -1069,6 +1069,12 @@ class Changed: detail = (f'may have changed: still assigned at line {again}' if again else f'may have changed: no assignment of it near its line, but the new text still names it' if any(re.search(rf'\b{re.escape(n)}\b', x) for x in NS) else 'removed or renamed') + # A DATA KEY OF A CONST OBJECT (`Settings.retries` in `const Settings = { retries: 3 }`) is written `key: value`: + # read as a typed field, `retries: 3` → `retries: 4` was "type 3, → 4,", and on a one-line const the key + # line's declared name (`LIMIT`) made `LIMIT.max` "renamed → LIMIT". Its value is its initializer + elif '.' in d and any(kk in FIELD_KINDS and dd == d.split('.')[0] for aa, bb, ii, kk, dd, nn in decls): + detail = ('initializer changed' if re.search(rf'(?:^|[{{,\s])["\']?{re.escape(n)}["\']?\s*:', nline) else + 'removed or renamed') elif ot and nt and ot != nt: detail = f'type {ot} → {nt}' elif nn_ and nn_ != n: detail = f'renamed → {nn_}' else: detail = 'initializer / modifiers changed' diff --git a/tests/cases/typescript/multi-line-initializer/case.json b/tests/cases/typescript/multi-line-initializer/case.json index 276bacd7..88ae0ed5 100644 --- a/tests/cases/typescript/multi-line-initializer/case.json +++ b/tests/cases/typescript/multi-line-initializer/case.json @@ -3,7 +3,7 @@ "src": "src", "checks": [ { - "why": "an edit on the second line of a const whose object initializer spans lines is that const's initializer, not the module's body", + "why": "an edit on the second line of a const whose object initializer spans lines is that const's (here its declared key's) initializer, not the module's body, and a key's value is no type", "run": [ "changed", "{repo}", @@ -15,11 +15,12 @@ "src/config.ts" ], "want": [ - "field Settings", - "initializer" + "field Settings.retries", + "initializer changed" ], "avoid": [ - "" + "", + "type 3" ] }, { @@ -44,7 +45,7 @@ ] }, { - "why": "a member removed from it too", + "why": "a member removed from it is that key's removal, not the module's body", "run": [ "changed", "{repo}", @@ -56,12 +57,10 @@ "src/config.ts" ], "want": [ - "field Tokens", - "initializer" + "removed Tokens.Store" ], "avoid": [ - "", - "removed" + "" ] }, { @@ -105,7 +104,7 @@ ] }, { - "why": "control: a one-line const is named as before", + "why": "control: an edit to a one-line const's value is still its initializer, never a rename of its key to the const's name", "run": [ "changed", "{repo}", @@ -117,12 +116,13 @@ "src/config.ts" ], "want": [ - "field LIMIT", - "initializer" + "LIMIT", + "initializer changed" ], "avoid": [ "", - "Settings" + "Settings", + "renamed" ] }, { @@ -167,4 +167,4 @@ ] } ] -} \ No newline at end of file +} From 49e15121ad29f916608bad56c5358d0bfdc3266f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:24:44 -0700 Subject: [PATCH 013/133] impact: a call on a package's value is not a by-name caller of a project method `impact Repo.get` listed every `x.get(...)` whose receiver the engine could not type, and among them the calls on what an unstaged package returned or constructed (`request(app).get('/x')`, `new grpc.Metadata().get(k)`). Those filled the first page ahead of the real leads, and seeded the by-name closure, so tests that only issue HTTP requests counted as tests of the method. - JavaScript and TypeScript engines: `library_receiver(site, specifier)`, an unresolved member call whose receiver is rooted in a bare-specifier import no staged IR declares (a binding, a member of one, a call or `new` on one, an await of one, a never-reassigned const holding one). A call handed a project instance is left out, since a wrapper or mock helper may return it. - impact: such a site is the unresolved kind "library": its own by-name row, sorted last and grouped as [library receiver] in --grep, no closure seed, no param row; path's by-name group and the SQL fast path agree. - Goldens: case 28 gains the shapes and controls; other JS cases gain their library_receiver diagnostics (edges unchanged). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/call-edge-generation/calls.dl | 30 +++++++++++++++++++ graph/javascript/souffle/decls_all.dl | 4 +++ graph/javascript/souffle/export_manifest.tsv | 1 + .../src/receivers.js | 27 +++++++++++++++++ .../expected/08-library-dependency.diag | 3 ++ .../expected/28-uninstalled-dependency.diag | 28 +++++++++++++++++ .../expected/28-uninstalled-dependency.edges | 14 +++++++++ .../28-uninstalled-dependency.lib.diag | 23 ++++++++++++++ .../28-uninstalled-dependency.lib.edges | 14 +++++++++ .../expected/28-uninstalled-dependency.oracle | 1 + .../expected/30-subpath-pattern-export.diag | 3 ++ .../30-subpath-pattern-export.lib.diag | 2 ++ .../expected/34-options-object-callbacks.diag | 2 ++ .../34-options-object-callbacks.lib.diag | 2 ++ .../javascript/expected/48-umd-library.diag | 2 ++ .../expected/60-ts-emit-helpers.diag | 1 + .../expected/63-jsx-wrapper-lazy.diag | 1 + .../70-single-file-component-scripts.diag | 1 + .../expected/74-orm-model-factory.diag | 4 +++ .../javascript/tools/normalize_diagnostics.py | 4 +++ .../engine/call-edge-generation/calls.dl | 27 +++++++++++++++++ graph/typescript/souffle/decls_all.dl | 4 +++ graph/typescript/souffle/export_manifest.tsv | 1 + .../skills/axiomcode/scripts/ax_edges.py | 15 ++++++++++ .../skills/axiomcode/scripts/ax_grep.py | 6 ++++ .../skills/axiomcode/scripts/axiomcode-impact | 11 ++++--- .../skills/axiomcode/scripts/axiomcode-path | 6 +++- .../skills/axiomcode/scripts/dl/impact.dl | 11 ++++--- .../skills/axiomcode/scripts/graph_sql.py | 25 +++++++++++----- .../javascript/untyped-receiver/case.json | 6 +++- .../untyped-receiver/src/package.json | 1 + .../javascript/untyped-receiver/src/store.js | 12 ++++++++ .../untyped-receiver/src/store.test.js | 9 ++++++ .../typescript/library-receiver/case.json | 6 ++++ .../library-receiver/src/package.json | 1 + .../library-receiver/src/store.test.ts | 5 ++++ .../typescript/library-receiver/src/store.ts | 12 ++++++++ .../library-receiver/src/tsconfig.json | 1 + 38 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 graph/test/javascript/cases/28-uninstalled-dependency/src/receivers.js create mode 100644 tests/cases/javascript/untyped-receiver/src/package.json create mode 100644 tests/cases/javascript/untyped-receiver/src/store.js create mode 100644 tests/cases/javascript/untyped-receiver/src/store.test.js create mode 100644 tests/cases/typescript/library-receiver/case.json create mode 100644 tests/cases/typescript/library-receiver/src/package.json create mode 100644 tests/cases/typescript/library-receiver/src/store.test.ts create mode 100644 tests/cases/typescript/library-receiver/src/store.ts create mode 100644 tests/cases/typescript/library-receiver/src/tsconfig.json diff --git a/graph/javascript/engine/call-edge-generation/calls.dl b/graph/javascript/engine/call-edge-generation/calls.dl index a257ba73..39dbe789 100644 --- a/graph/javascript/engine/call-edge-generation/calls.dl +++ b/graph/javascript/engine/call-edge-generation/calls.dl @@ -72,6 +72,36 @@ unresolved_covered(ce) :- call_site("client", ck, _, _, _, _, ce, _, _), call_ki unresolved_receiver(ce, cn, ck, "other_form") :- call_site("client", ck, cn, _, _, _, ce, _, _), !call_resolved(ce), !unresolved_covered(ce). +// ── library_receiver(CallExpr, Specifier) ─────────────────────────────────── +// An unresolved member call whose receiver is a PACKAGE'S value: a binding imported +// from a package no staged IR declares, a member of one, what calling or constructing +// one returns, and a const holding any of those — `request(app).get('/x').expect(200)`, +// `new grpc.Metadata().get(k)`, `const md = new Metadata(); md.get(k)`. The receiver's +// origin is known, so the site is not an untyped `x.get()`: a project method of the +// same name is not what it calls. It stays unresolved (the package is not staged), and +// consumers list it apart from the name matches on unknown receivers rather than +// among them. Read off the source, never the value flow, so it cannot invent a value. +// A call handed a project INSTANCE is left out: a wrapper, a proxy or a mock helper +// may return the very object it was given (`mocked(repo).get(1)`). +// A package is named by a bare specifier: a relative or absolute path that reached no +// file (`require('./not-here')`) is a missing PROJECT module, and an alias (`@/x`, `~/x`, +// `#x`) may map onto the project's own source. +package_import(imp, spec) :- import_decl(_, spec, _, _, _, _, out, _, imp), import_outcome_is_package(out), + !match("[.#~/].*", spec), !match("@/.*", spec). +library_value(e, spec) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), var_import(_, imp, v), + package_import(imp, spec). +library_value(e, spec) :- expr_kind(_, "MODULE_EDGE_CALL", _, e), expr_module_edge(_, imp, e), package_import(imp, spec). +library_value(e, spec) :- expr_kind(_, k, _, e), access_kind_reads_member(k), expr_child(_, e, "ACCESS_TARGET", _, r), + library_value(r, spec). +library_value(e, spec) :- expr_kind(_, k, _, e), (k = "CALL" ; k = "NEW"), expr_child(_, e, role, _, c), + (role = "CALLEE" ; role = "RECEIVER"), library_value(c, spec), !call_passes_project_instance(e). +library_value(e, spec) :- expr_kind(_, "AWAIT", _, e), expr_child(_, e, _, _, c), library_value(c, spec). +library_value(e, spec) :- expr_kind(_, "IDENTIFIER", _, e), expr_binding(_, v, e), !var_import(_, _, v), + var_init("client", _, i, v), !variable_reassigned(v), library_value(i, spec). +call_passes_project_instance(e) :- call_arg(e, _, a), expr_value(a, "inst", _). +library_receiver(ce, spec) :- call_site("client", ck, _, "SYNTACTIC", _, _, ce, _, _), call_kind_is_member_form(ck), + !call_resolved(ce), expr_child(_, ce, "RECEIVER", _, r), library_value(r, spec). + // ── unresolved_value_callee(CallExpr, Binding) ────────────────────────────── // An unresolved call whose callee is a VALUE, not a name: `cb()` on a parameter, // `f()` on a loop variable or a local, `table[k]()`, `eval(s)`. Nothing about its diff --git a/graph/javascript/souffle/decls_all.dl b/graph/javascript/souffle/decls_all.dl index 7f73c459..571cc8f2 100644 --- a/graph/javascript/souffle/decls_all.dl +++ b/graph/javascript/souffle/decls_all.dl @@ -87,6 +87,10 @@ .decl call_resolved(c0:symbol) .decl unresolved_receiver(c0:symbol, c1:symbol, c2:symbol, c3:symbol) .decl unresolved_covered(c0:symbol) +.decl package_import(c0:symbol, c1:symbol) +.decl library_value(c0:symbol, c1:symbol) +.decl call_passes_project_instance(c0:symbol) +.decl library_receiver(c0:symbol, c1:symbol) .decl value_callee_open(c0:symbol, c1:symbol) .decl unresolved_value_callee(c0:symbol, c1:symbol) .decl callee_builds_code(c0:symbol) diff --git a/graph/javascript/souffle/export_manifest.tsv b/graph/javascript/souffle/export_manifest.tsv index 1e8b5aac..ddb0765b 100644 --- a/graph/javascript/souffle/export_manifest.tsv +++ b/graph/javascript/souffle/export_manifest.tsv @@ -4,6 +4,7 @@ client_calls_client client-to-client-calls.csv client_calls_lib client-to-lib-calls.csv client_calls_lib_detail client-to-lib-navigation.csv unresolved_receiver unresolved-receiver.csv +library_receiver library-receiver.csv method_has_unknown_call method-has-unknown-call.csv unresolved_value_callee unresolved-value-callee.csv entry_point entry-point.csv diff --git a/graph/test/javascript/cases/28-uninstalled-dependency/src/receivers.js b/graph/test/javascript/cases/28-uninstalled-dependency/src/receivers.js new file mode 100644 index 00000000..e6d3397f --- /dev/null +++ b/graph/test/javascript/cases/28-uninstalled-dependency/src/receivers.js @@ -0,0 +1,27 @@ +'use strict'; +// A member call whose receiver is a value of a package no IR declares: what calling it, +// constructing from it, or chaining on it returns is the package's object, so the +// project's own `get` is not what these call (library_receiver in the diagnostics). +const request = require('never-staged'); +const wire = require('alpha/missing'); +class Repo { get(id) { return id; } } +class Service { + constructor(repo) { this.repo = repo; } + load(id) { return this.repo.get(id); } +} +async function probe(app) { + await request(app).get('/x').expect(200); + const res = await request(app).get('/y'); + const md = new wire.Metadata(); + md.get('k'); + return res.body; +} +// controls: none of these is a package's value +function untyped(obj) { return obj.get(1); } +function wrapped(repo) { return request.mocked(new Repo()).get(1); } +function reassigned() { + let c = request.agent(); + c = untyped; + return c.get(2); +} +module.exports = { Repo, Service, probe, untyped, wrapped, reassigned }; diff --git a/graph/test/javascript/expected/08-library-dependency.diag b/graph/test/javascript/expected/08-library-dependency.diag index aaefacd3..38b34560 100644 --- a/graph/test/javascript/expected/08-library-dependency.diag +++ b/graph/test/javascript/expected/08-library-dependency.diag @@ -1,6 +1,9 @@ import_cause app.js:2:13 dep not_staged import_cause app.js:3:17 dep not_staged import_cause app.js:3:9 dep not_staged +library_receiver app.js:5:1 c.get dep +library_receiver app.js:6:1 new Client().request dep +library_receiver app.js:8:1 dep.helper dep package_entry probe6-app . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved app.js:4:11 FUNCTION_CALL dep callee_untyped unresolved app.js:5:1 METHOD_CALL get receiver_untyped diff --git a/graph/test/javascript/expected/28-uninstalled-dependency.diag b/graph/test/javascript/expected/28-uninstalled-dependency.diag index 2a015d3b..b3376dc9 100644 --- a/graph/test/javascript/expected/28-uninstalled-dependency.diag +++ b/graph/test/javascript/expected/28-uninstalled-dependency.diag @@ -4,6 +4,21 @@ import_cause app.js:6:14 alpha/missing not_staged import_cause app.js:7:14 never-staged not_staged import_cause app.mjs:1:10 alpha not_staged import_cause app.mjs:2:17 alpha not_staged +import_cause receivers.js:5:17 never-staged not_staged +import_cause receivers.js:6:14 alpha/missing not_staged +library_receiver app.js:10:3 alpha.createClient alpha +library_receiver app.js:10:3 alpha.createClient().run alpha +library_receiver app.js:13:3 gone.anything alpha/missing +library_receiver app.js:14:3 nope.thing never-staged +library_receiver app.mjs:3:1 createClient().run alpha +library_receiver app.mjs:4:1 m.createClient alpha +library_receiver app.mjs:4:1 m.createClient().run alpha +library_receiver receivers.js:13:9 request(app).get never-staged +library_receiver receivers.js:13:9 request(app).get('/x').expect never-staged +library_receiver receivers.js:14:21 request(app).get never-staged +library_receiver receivers.js:16:3 md.get alpha/missing +library_receiver receivers.js:21:33 request.mocked never-staged +library_receiver receivers.js:23:11 request.agent never-staged package_entry case26-app . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved app.js:10:3 METHOD_CALL createClient receiver_untyped unresolved app.js:10:3 METHOD_CALL run receiver_untyped @@ -16,3 +31,16 @@ unresolved app.mjs:3:1 FUNCTION_CALL createClient callee_untyped unresolved app.mjs:3:1 METHOD_CALL run receiver_untyped unresolved app.mjs:4:1 METHOD_CALL createClient receiver_untyped unresolved app.mjs:4:1 METHOD_CALL run receiver_untyped +unresolved receivers.js:10:21 METHOD_CALL get receiver_untyped +unresolved receivers.js:13:9 FUNCTION_CALL request callee_untyped +unresolved receivers.js:13:9 METHOD_CALL expect receiver_untyped +unresolved receivers.js:13:9 METHOD_CALL get receiver_untyped +unresolved receivers.js:14:21 FUNCTION_CALL request callee_untyped +unresolved receivers.js:14:21 METHOD_CALL get receiver_untyped +unresolved receivers.js:15:14 CONSTRUCTOR_CALL Metadata receiver_untyped +unresolved receivers.js:16:3 METHOD_CALL get receiver_untyped +unresolved receivers.js:20:32 METHOD_CALL get receiver_untyped +unresolved receivers.js:21:33 METHOD_CALL get receiver_untyped +unresolved receivers.js:21:33 METHOD_CALL mocked receiver_untyped +unresolved receivers.js:23:11 METHOD_CALL agent receiver_untyped +unresolved receivers.js:25:10 METHOD_CALL get member_absent diff --git a/graph/test/javascript/expected/28-uninstalled-dependency.edges b/graph/test/javascript/expected/28-uninstalled-dependency.edges index cb072a1a..11563c90 100644 --- a/graph/test/javascript/expected/28-uninstalled-dependency.edges +++ b/graph/test/javascript/expected/28-uninstalled-dependency.edges @@ -11,3 +11,17 @@ app.mjs:3:1 FUNCTION_CALL createClient -> ambiguous_unknown - app.mjs:3:1 METHOD_CALL createClient().run -> ambiguous_unknown - app.mjs:4:1 METHOD_CALL m.createClient -> ambiguous_unknown - app.mjs:4:1 METHOD_CALL m.createClient().run -> ambiguous_unknown - +receivers.js:10:21 METHOD_CALL this.repo.get -> ambiguous_unknown - +receivers.js:13:9 FUNCTION_CALL request -> ambiguous_unknown - +receivers.js:13:9 METHOD_CALL request(app).get -> ambiguous_unknown - +receivers.js:13:9 METHOD_CALL request(app).get('/x').expect -> ambiguous_unknown - +receivers.js:14:21 FUNCTION_CALL request -> ambiguous_unknown - +receivers.js:14:21 METHOD_CALL request(app).get -> ambiguous_unknown - +receivers.js:15:14 CONSTRUCTOR_CALL wire.Metadata -> ambiguous_unknown - +receivers.js:16:3 METHOD_CALL md.get -> ambiguous_unknown - +receivers.js:20:32 METHOD_CALL obj.get -> ambiguous_unknown - +receivers.js:21:33 METHOD_CALL request.mocked -> ambiguous_unknown - +receivers.js:21:33 METHOD_CALL request.mocked(new Repo()).get -> ambiguous_unknown - +receivers.js:21:48 CONSTRUCTOR_CALL Repo -> implicit_constructor - +receivers.js:23:11 METHOD_CALL request.agent -> ambiguous_unknown - +receivers.js:25:10 METHOD_CALL c.get -> ambiguous_unknown - diff --git a/graph/test/javascript/expected/28-uninstalled-dependency.lib.diag b/graph/test/javascript/expected/28-uninstalled-dependency.lib.diag index 1e4b56db..cb5c2a22 100644 --- a/graph/test/javascript/expected/28-uninstalled-dependency.lib.diag +++ b/graph/test/javascript/expected/28-uninstalled-dependency.lib.diag @@ -1,5 +1,15 @@ import_cause app.js:6:14 alpha/missing staged_unreached import_cause app.js:7:14 never-staged not_staged +import_cause receivers.js:5:17 never-staged not_staged +import_cause receivers.js:6:14 alpha/missing staged_unreached +library_receiver app.js:13:3 gone.anything alpha/missing +library_receiver app.js:14:3 nope.thing never-staged +library_receiver receivers.js:13:9 request(app).get never-staged +library_receiver receivers.js:13:9 request(app).get('/x').expect never-staged +library_receiver receivers.js:14:21 request(app).get never-staged +library_receiver receivers.js:16:3 md.get alpha/missing +library_receiver receivers.js:21:33 request.mocked never-staged +library_receiver receivers.js:23:11 request.agent never-staged package_entry alpha . [] MAIN lib/index.js RESOLVED -> lib:lib/index.js package_entry alpha . [import] EXPORTS lib/index.mjs RESOLVED -> lib:lib/index.mjs package_entry alpha . [require] EXPORTS lib/index.js RESOLVED -> lib:lib/index.js @@ -9,3 +19,16 @@ package_entry case26-app . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved app.js:13:3 METHOD_CALL anything receiver_untyped unresolved app.js:14:3 METHOD_CALL thing receiver_untyped unresolved app.mjs:2:17 DYNAMIC_IMPORT_CALL dynamic +unresolved receivers.js:10:21 METHOD_CALL get receiver_untyped +unresolved receivers.js:13:9 FUNCTION_CALL request callee_untyped +unresolved receivers.js:13:9 METHOD_CALL expect receiver_untyped +unresolved receivers.js:13:9 METHOD_CALL get receiver_untyped +unresolved receivers.js:14:21 FUNCTION_CALL request callee_untyped +unresolved receivers.js:14:21 METHOD_CALL get receiver_untyped +unresolved receivers.js:15:14 CONSTRUCTOR_CALL Metadata receiver_untyped +unresolved receivers.js:16:3 METHOD_CALL get receiver_untyped +unresolved receivers.js:20:32 METHOD_CALL get receiver_untyped +unresolved receivers.js:21:33 METHOD_CALL get receiver_untyped +unresolved receivers.js:21:33 METHOD_CALL mocked receiver_untyped +unresolved receivers.js:23:11 METHOD_CALL agent receiver_untyped +unresolved receivers.js:25:10 METHOD_CALL get member_absent diff --git a/graph/test/javascript/expected/28-uninstalled-dependency.lib.edges b/graph/test/javascript/expected/28-uninstalled-dependency.lib.edges index 6dfe1d1b..952c1e2c 100644 --- a/graph/test/javascript/expected/28-uninstalled-dependency.lib.edges +++ b/graph/test/javascript/expected/28-uninstalled-dependency.lib.edges @@ -11,3 +11,17 @@ app.mjs:3:1 FUNCTION_CALL createClient -> boundary_lib lib:lib/index.mjs:2:1 app.mjs:3:1 METHOD_CALL createClient().run -> boundary_lib lib:lib/index.mjs:1:21 run app.mjs:4:1 METHOD_CALL m.createClient -> boundary_lib lib:lib/index.mjs:2:1 createClient app.mjs:4:1 METHOD_CALL m.createClient().run -> boundary_lib lib:lib/index.mjs:1:21 run +receivers.js:10:21 METHOD_CALL this.repo.get -> ambiguous_unknown - +receivers.js:13:9 FUNCTION_CALL request -> ambiguous_unknown - +receivers.js:13:9 METHOD_CALL request(app).get -> ambiguous_unknown - +receivers.js:13:9 METHOD_CALL request(app).get('/x').expect -> ambiguous_unknown - +receivers.js:14:21 FUNCTION_CALL request -> ambiguous_unknown - +receivers.js:14:21 METHOD_CALL request(app).get -> ambiguous_unknown - +receivers.js:15:14 CONSTRUCTOR_CALL wire.Metadata -> ambiguous_unknown - +receivers.js:16:3 METHOD_CALL md.get -> ambiguous_unknown - +receivers.js:20:32 METHOD_CALL obj.get -> ambiguous_unknown - +receivers.js:21:33 METHOD_CALL request.mocked -> ambiguous_unknown - +receivers.js:21:33 METHOD_CALL request.mocked(new Repo()).get -> ambiguous_unknown - +receivers.js:21:48 CONSTRUCTOR_CALL Repo -> implicit_constructor - +receivers.js:23:11 METHOD_CALL request.agent -> ambiguous_unknown - +receivers.js:25:10 METHOD_CALL c.get -> ambiguous_unknown - diff --git a/graph/test/javascript/expected/28-uninstalled-dependency.oracle b/graph/test/javascript/expected/28-uninstalled-dependency.oracle index 6d77f120..d56dda00 100644 --- a/graph/test/javascript/expected/28-uninstalled-dependency.oracle +++ b/graph/test/javascript/expected/28-uninstalled-dependency.oracle @@ -1,3 +1,4 @@ app.js:11:3 CONSTRUCTOR_CALL Mine SYNTHESIZED_OK app.js:16:1 FUNCTION_CALL main EXACT app.js:9:1 +receivers.js:21:48 CONSTRUCTOR_CALL Repo SYNTHESIZED_OK # defects: 0 diff --git a/graph/test/javascript/expected/30-subpath-pattern-export.diag b/graph/test/javascript/expected/30-subpath-pattern-export.diag index ce53dcce..9a41da01 100644 --- a/graph/test/javascript/expected/30-subpath-pattern-export.diag +++ b/graph/test/javascript/expected/30-subpath-pattern-export.diag @@ -6,6 +6,9 @@ import_cause app.js:7:18 kit/internal/secret not_staged import_cause app.js:8:16 kit/feat/deep/none not_staged import_cause app.mjs:1:10 kit/mjs/gamma not_staged import_cause app.mjs:2:10 kit/feat/alpha not_staged +library_receiver app.js:9:19 createApp().start kit +library_receiver app.js:9:66 internal.secret kit/internal/secret +library_receiver app.js:9:85 nested.x kit/feat/deep/none package_entry case30-app . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved app.js:9:19 FUNCTION_CALL createApp callee_untyped unresolved app.js:9:19 METHOD_CALL start receiver_untyped diff --git a/graph/test/javascript/expected/30-subpath-pattern-export.lib.diag b/graph/test/javascript/expected/30-subpath-pattern-export.lib.diag index 3f4e3746..4844004a 100644 --- a/graph/test/javascript/expected/30-subpath-pattern-export.lib.diag +++ b/graph/test/javascript/expected/30-subpath-pattern-export.lib.diag @@ -1,5 +1,7 @@ import_cause app.js:7:18 kit/internal/secret staged_unreached import_cause app.js:8:16 kit/feat/deep/none staged_unreached +library_receiver app.js:9:66 internal.secret kit/internal/secret +library_receiver app.js:9:85 nested.x kit/feat/deep/none package_entry case30-app . [] DEFAULT_INDEX index.js MISSING_FILE -> - package_entry kit . [default] EXPORTS src/index.js RESOLVED -> lib:src/index.js package_entry kit . [node.require] EXPORTS src/index.js RESOLVED -> lib:src/index.js diff --git a/graph/test/javascript/expected/34-options-object-callbacks.diag b/graph/test/javascript/expected/34-options-object-callbacks.diag index 2260affa..1d54eea4 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.diag @@ -3,6 +3,8 @@ import_cause main.js:2:14 walker not_staged import_cause main.js:37:136 events builtin import_cause main.js:3:15 https builtin import_cause main.js:4:9 stream builtin +library_receiver main.js:43:25 walk.register walker +library_receiver main.js:43:48 walk.register walker package_entry options-callbacks . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved main.js:10:28 FUNCTION_CALL walk callee_untyped unresolved main.js:11:89 FUNCTION_CALL walk callee_untyped diff --git a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag index ed5868cc..e30f6720 100644 --- a/graph/test/javascript/expected/34-options-object-callbacks.lib.diag +++ b/graph/test/javascript/expected/34-options-object-callbacks.lib.diag @@ -2,6 +2,8 @@ import_cause main.js:17:16 assert builtin import_cause main.js:37:136 events builtin import_cause main.js:3:15 https builtin import_cause main.js:4:9 stream builtin +library_receiver main.js:43:25 walk.register walker +library_receiver main.js:43:48 walk.register walker package_entry options-callbacks . [] DEFAULT_INDEX index.js MISSING_FILE -> - package_entry walker . [] MAIN index.js RESOLVED -> lib:index.js unresolved main.js:12:86 METHOD_CALL destroy no_target diff --git a/graph/test/javascript/expected/48-umd-library.diag b/graph/test/javascript/expected/48-umd-library.diag index 7f81443e..dd637fad 100644 --- a/graph/test/javascript/expected/48-umd-library.diag +++ b/graph/test/javascript/expected/48-umd-library.diag @@ -1,5 +1,7 @@ import_cause app.js:3:13 umdlib not_staged import_cause app.js:4:9 umdlib not_staged +library_receiver app.js:5:1 umd.format umdlib +library_receiver app.js:7:1 new umd.Formatter().render umdlib package_entry case48-app . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved app.js:5:1 METHOD_CALL format receiver_untyped unresolved app.js:6:1 FUNCTION_CALL format callee_untyped diff --git a/graph/test/javascript/expected/60-ts-emit-helpers.diag b/graph/test/javascript/expected/60-ts-emit-helpers.diag index 9cb642a2..f4182214 100644 --- a/graph/test/javascript/expected/60-ts-emit-helpers.diag +++ b/graph/test/javascript/expected/60-ts-emit-helpers.diag @@ -1,4 +1,5 @@ import_cause main.js:5:9 tspkg not_staged +library_receiver main.js:7:26 new Client().send tspkg package_entry 60-ts-emit-helpers . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved main.js:7:26 CONSTRUCTOR_CALL Client callee_untyped unresolved main.js:7:26 METHOD_CALL send receiver_untyped diff --git a/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag b/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag index ce31a3fe..cc4ccc71 100644 --- a/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag +++ b/graph/test/javascript/expected/63-jsx-wrapper-lazy.diag @@ -6,6 +6,7 @@ import_cause app.jsx:3:10 thing-kit not_staged import_cause defmemo.jsx:1:10 react not_staged import_cause parts.jsx:1:10 react not_staged import_cause parts.jsx:1:16 react not_staged +library_receiver app.jsx:11:16 React.memo react package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - unresolved app.jsx:10:18 FUNCTION_CALL observer callee_untyped unresolved app.jsx:11:16 METHOD_CALL memo receiver_untyped diff --git a/graph/test/javascript/expected/70-single-file-component-scripts.diag b/graph/test/javascript/expected/70-single-file-component-scripts.diag index f01b3a10..55bf1e5c 100644 --- a/graph/test/javascript/expected/70-single-file-component-scripts.diag +++ b/graph/test/javascript/expected/70-single-file-component-scripts.diag @@ -1,6 +1,7 @@ import_cause components/List.svelte:2:12 svelte not_staged import_cause components/Price.vue:4:10 vue not_staged import_cause main.js:2:10 vue not_staged +library_receiver main.js:5:1 createApp(Price).mount vue package_entry single-file-component-scripts . [] DEFAULT_INDEX index.js MISSING_FILE -> - unresolved components/Legacy.vue:7:26 METHOD_CALL $emit member_absent unresolved components/List.svelte:5:3 FUNCTION_CALL onMount callee_untyped diff --git a/graph/test/javascript/expected/74-orm-model-factory.diag b/graph/test/javascript/expected/74-orm-model-factory.diag index f191f565..f55347e0 100644 --- a/graph/test/javascript/expected/74-orm-model-factory.diag +++ b/graph/test/javascript/expected/74-orm-model-factory.diag @@ -1,5 +1,9 @@ import_cause models.js:21:17 other-orm not_staged import_cause models.js:7:18 mongoose not_staged +library_receiver models.js:12:1 docSchema.static mongoose +library_receiver models.js:13:1 docSchema.static mongoose +library_receiver models.js:15:1 docSchema.method mongoose +library_receiver models.js:28:10 mongoose.model mongoose package_entry @axiomcode/code-graph . [] MAIN dist/reason.js NOT_STAGED -> - unresolved models.js:10:19 CONSTRUCTOR_CALL Schema callee_untyped unresolved models.js:11:51 METHOD_CALL find receiver_untyped diff --git a/graph/test/javascript/tools/normalize_diagnostics.py b/graph/test/javascript/tools/normalize_diagnostics.py index 242a8bfd..d7cdfdd3 100644 --- a/graph/test/javascript/tools/normalize_diagnostics.py +++ b/graph/test/javascript/tools/normalize_diagnostics.py @@ -89,6 +89,10 @@ def raw(path, width): for f in raw(os.path.join(out, 'unresolved-value-callee.csv'), 2): pos, _, text = site.get(f[0], ('?', '?', '?')) lines.add('value_callee %s %s %s' % (pos, text, f[1])) +# library_receiver(CallExpr, Specifier): the receiver is a package's value, so a same-named project method is not the callee +for f in raw(os.path.join(out, 'library-receiver.csv'), 2): + pos, _, text = site.get(f[0], ('?', '?', '?')) + lines.add('library_receiver %s %s %s' % (pos, text, f[1])) # package_entry(Prov, Package, Subpath, Condition, Source, Target, Outcome, ModuleHash) for f in raw(os.path.join(out, 'package-entry.csv'), 8): lines.add('package_entry %s %s [%s] %s %s %s -> %s' % ( diff --git a/graph/typescript/engine/call-edge-generation/calls.dl b/graph/typescript/engine/call-edge-generation/calls.dl index 959c3c48..f90275c3 100644 --- a/graph/typescript/engine/call-edge-generation/calls.dl +++ b/graph/typescript/engine/call-edge-generation/calls.dl @@ -126,3 +126,30 @@ unresolved_covered(ce) :- call_site("client", ck, _, "NONE", _, ce, _), unresolved_receiver(ce, cn, rk, "other_form") :- call_site("client", _, cn, rk, _, ce, _), !call_resolved(ce), !unresolved_covered(ce). + +// ── library_receiver(CallExpr, Specifier) ─────────────────────────────────── +// An unresolved member call whose receiver is a PACKAGE'S value: a binding imported +// from a package no staged IR declares, a member of one, what calling or constructing +// one returns, and a const holding any of those — `request(app).get('/x').expect(200)`, +// `new grpc.Metadata().get(k)`. The receiver's origin is known, so the site is not an +// untyped `x.get()`: a project method of the same name is not what it calls. It stays +// unresolved (the package is not staged), and consumers list it apart from the name +// matches on unknown receivers rather than among them. Read off the source, as the +// JavaScript rule is. A call handed a value of a project type is left out: a wrapper, +// a proxy or a mock helper may return the very object it was given. +package_import(imp, spec) :- import_specifier("client", spec, imp), !import_resolved_module("client", _, imp), + !import_is_type_only("client", imp), import_resolution_kind("client", rk, imp), + rk != "RELATIVE_FILE", rk != "PATHS_ALIAS", !match("[.#~/].*", spec), !match("@/.*", spec). +library_value(e, spec) :- expr_referenced("client", "IMPORT_BINDING", imp, e), package_import(imp, spec). +library_value(e, spec) :- expr_kind("client", k, _, e), (k = "PROPERTY_ACCESS" ; k = "ELEMENT_ACCESS"), + expr_child("client", e, role, _, r), (role = "RECEIVER" ; role = "QUALIFIER"), library_value(r, spec). +library_value(e, spec) :- expr_kind("client", k, _, e), (k = "CALL_EXPRESSION" ; k = "NEW_EXPRESSION"), + expr_child("client", e, "METHOD_NAME", _, c), library_value(c, spec), !call_passes_project_instance(e). +library_value(e, spec) :- expr_kind("client", k, _, e), + (k = "AWAIT_EXPRESSION" ; k = "NON_NULL_EXPRESSION" ; k = "AS_EXPRESSION" ; k = "SATISFIES_EXPRESSION"), + expr_child("client", e, _, _, c), library_value(c, spec). +library_value(e, spec) :- expr_referenced("client", "VARIABLE", v, e), var_initializer("client", _, i, v), + !var_reassigned(v), library_value(i, spec). +call_passes_project_instance(e) :- expr_child("client", e, "ARGUMENT", _, a), expr_type(a, "client", _). +library_receiver(ce, spec) :- call_site("client", _, _, rk, recv, ce, _), receiver_kind_is_value(rk), + !call_resolved(ce), library_value(recv, spec). diff --git a/graph/typescript/souffle/decls_all.dl b/graph/typescript/souffle/decls_all.dl index 467f2e70..5d908d2f 100644 --- a/graph/typescript/souffle/decls_all.dl +++ b/graph/typescript/souffle/decls_all.dl @@ -603,6 +603,10 @@ .decl unary_yields(c0:symbol,c1:symbol) .decl universal_param_type_name(c0:symbol) .decl unresolved_covered(c0:symbol) +.decl package_import(c0:symbol,c1:symbol) +.decl library_value(c0:symbol,c1:symbol) +.decl call_passes_project_instance(c0:symbol) +.decl library_receiver(c0:symbol,c1:symbol) .decl unresolved_receiver(c0:symbol,c1:symbol,c2:symbol,c3:symbol) .decl var_binding_method(c0:symbol,c1:symbol) .decl var_binding_target(c0:symbol,c1:symbol,c2:symbol) diff --git a/graph/typescript/souffle/export_manifest.tsv b/graph/typescript/souffle/export_manifest.tsv index f319cddf..f9e9ca38 100644 --- a/graph/typescript/souffle/export_manifest.tsv +++ b/graph/typescript/souffle/export_manifest.tsv @@ -7,6 +7,7 @@ client_calls_lib client-to-lib-calls.csv client_calls_lib_detail client-to-lib-navigation.csv lib_boundary_reason lib-boundary-reason.csv unresolved_receiver unresolved-receiver.csv +library_receiver library-receiver.csv method_has_unknown_call method-has-unknown-call.csv entry_point entry-point.csv entry_reachable entry-reachable.csv diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py index 95416066..f5283b76 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_edges.py @@ -497,6 +497,21 @@ def nearest(cands): # the candidates enclosing no other candidat return out +# ── a LIBRARY RECEIVER: an unresolved call on a package's value is not a name match for a project method ─────────── +# `request(app).get('/x')`, `new Metadata().get(k)`: the engine traced the receiver to what a package no IR declares +# returned or constructed (ext_library_receiver). The site stays unresolved, since what the package's `get` runs is +# unknown, but a project `get` is not what it calls. Like a stub it keeps a row, apart from the untyped-receiver name +# matches, and it seeds no closure: listed among them it filled the first page of `impact Repo.get`. +LIBRARY_RECEIVER_KIND = 'library' +LIBRARY_BYNAME_WHY = 'calls a method of this name on a value a package returned or constructed: not this method, unless the package hands it back' + + +def library_receiver_sites(q): + """the ids of the unresolved call sites whose receiver is a package's value. q(sql, params) -> rows.""" + if not list(q("SELECT 1 FROM sqlite_master WHERE name = 'ext_library_receiver'", ())): return set() + return {r[0] for r in q("SELECT DISTINCT c0 FROM ext_library_receiver", ())} + + # ── a MOCKED TYPE: a test class that holds a mock of T never runs T's methods ──────────────────────────────────── # `@MockBean OrderService orders` in a web test replaces the bean the controller is handed, so a request the test sends # reaches the controller and stops at the mock: a change to OrderService's body cannot fail that test, however the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index a5da4aaa..33c4e80b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -117,10 +117,15 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n rung = {} for i, r in enumerate(direct): rung.setdefault(r.get('certainty'), i) direct = sorted(direct, key=lambda r: (rung[r.get('certainty')], not r.get('evidence'))) + # a name match on a package's value (`request(app).get(…)`, ax_edges.library_receiver_sites) is the weakest lead: + # it goes after every other row, so the first page holds the callers that may really be the target's + libs = [] for r in direct: cert = r.get('certainty') or 'resolved' if cert == 'alongside': more('alongside (no call, no reference)'); continue seen.add(r['id']) + if r.get('why') == ax_edges.LIBRARY_BYNAME_WHY: + libs.append(('library receiver', site(code, r['at'], f"{TAG.get(cert, cert)} · library receiver{n_sites(r)}{stale(r)}", r['display']))); continue rows.append((cert, ev(site(code, r['at'], f"{TAG.get(cert, cert)}{n_sites(r)}{stale(r)}", r['display']), r))) # a mapper method's own statement (its namespace and id bind it, ax_nonsource.mapper_elements) is the method's SQL: # a dependent, listed with the direct rows, not a [text] lead after every test @@ -152,6 +157,7 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n if r.get('prose'): more('plain-word mentions'); continue if shown >= TEXT_ROWS: more('[text]'); continue rows.append(('text', site(code, r['at'], f"text · {r.get('how') or 'names it'}{stale(r)}"))); shown += 1 + rows += libs foot = [] nt = len(d.get('tests', [])) if 'test_universe' in d: foot.append(f"tests: {nt} of {d['test_universe']} reach it" + ("; `axiomcode test-impact` runs them" if nt else '')) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index e8186697..ad863f54 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -1480,8 +1480,11 @@ class Impact: W('returns_fn', sorted(ax_registration.returned_functions(g.q))) CTOR = ('new', 'anon_new', 'CONSTRUCTOR_CALL') # an unresolved site inside a mock's stub or verification (a Moq lambda whose parameter the engine did not type) - # is the kind "stub": still a name match for the rows, never a by-name seed of the closure - W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else ax_edges.STUB_TIER if r['id'] in stubs else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) + # is the kind "stub": still a name match for the rows, never a by-name seed of the closure. One whose receiver is a + # package's value is the kind "library" (ax_edges.library_receiver_sites), for the same two reasons + libsites = ax_edges.library_receiver_sites(lambda s, p: g.q(s, *p)) + W('unresolved', [(r['caller_id'], (r['callee_name'] or '').split('.')[-1], 'new' if r['kind'] in CTOR else ax_edges.STUB_TIER if r['id'] in stubs + else ax_edges.LIBRARY_RECEIVER_KIND if r['id'] in libsites else 'method', g.site_file(r['file_path']) if r['file_path'] else '', r['start_line'] or 0) for r in g.q("SELECT s.id, s.caller_id, s.callee_name, s.kind, s.file_path, s.start_line FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''")]) # a call site with a name that the engine did NOT bind to a client declaration: unresolved, or sent to a library # because the callee is generated (a dataclass constructor is a call to the class itself). The generated-member @@ -2938,7 +2941,7 @@ def main(argv): # AN `alongside` ROW IS NOT A DEPENDENT, so it never shares `direct` or a role with one. It sat # there as role 'uses', and a reader keyed on role or section counted a same-file sibling as a # user: a check passed on it, then failed when the type moved to another file with no edge changed. - 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[1], g.disp(x[0]), x[2], x[4], x[0])) if cert != 'alongside'], + 'direct': [{'id': c, 'display': g.disp(c), 'role': role, 'why': why, 'also': direct_also.get((c, _grp(role)), []), 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'sites': n, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (CERT[x[3]], x[2] == ax_edges.LIBRARY_BYNAME_WHY, x[1], g.disp(x[0]), x[2], x[4], x[0])) if cert != 'alongside'], 'alongside': [{'id': c, 'display': g.disp(c), 'role': 'co-located', 'why': why, 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (g.disp(x[0]), x[2], x[4], x[0])) if cert == 'alongside'], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in test_route(m, fx)], @@ -2986,7 +2989,7 @@ def main(argv): # every letter, and in a callback-heavy language they are most of the list, so the file that breaks is pushed into # the hidden tail. Surest first, then named before anonymous, then the entries with the most sites anon = lambda c: g.disp(c).split('.')[-1].startswith('<') - D.sort(key=lambda x: (CERT[x[3]], anon(x[0]), -(x[5] or 1), x[2], g.disp(x[0]))) + D.sort(key=lambda x: (CERT[x[3]], x[2] == ax_edges.LIBRARY_BYNAME_WHY, anon(x[0]), -(x[5] or 1), x[2], g.disp(x[0]))) # `alongside` rows are their own section, never counted under `reads or uses it`: no call, no reference, so no dependent groups = [('produces or writes it', [x for x in D if x[1] in ('produces', 'writes') and x[3] != 'alongside']), ('reads or uses it', [x for x in D if x[1] in ('reads', 'uses') and x[3] != 'alongside']), diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 99210564..ef352e20 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -986,7 +986,9 @@ class G: have = {(a, b) for a, b, _ in edges} edges += [(r[0], r[1], 'dispatch') for r in self.q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call") if (r[0], r[1]) not in have] self.write('edge', edges) - self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs})) + # a call on a package's value (`request(app).get(…)`) is no by-name caller of a project method: impact's seed_byname + libsites = ax_edges.library_receiver_sites(lambda s, p: self.q(s, *p)) + self.write('byname', sorted({(r[1], r[2]) for r in self.q("SELECT s.id, s.caller_id, s.callee_name FROM call_sites s JOIN unresolved_sites u ON u.call_site_id = s.id WHERE s.callee_name IS NOT NULL AND s.callee_name <> ''") if r[0] not in stubs and r[0] not in libsites})) self.write('named', [(r['name'], r['id']) for r in self.sym.values() if r['method_id'] and r['kind'] != 'module' and r['name']]) t = f'{stamp}.{os.getpid()}.tmp'; open(t, 'w').write(want); replace_file(t, stamp) def write(self, name, rows, d=None): @@ -1438,6 +1440,7 @@ def byname_sites(g, ids): and g.sym[i]['kind'] not in ('library', 'written', 'module') and g.sym[i].get('name')}) if not names or not g.has('unresolved_sites'): return [], set() stubs = ax_edges.stub_sites(lambda s, p: g.q(s, *p)) if g.has('call_sites') else set() + libsites = ax_edges.library_receiver_sites(lambda s, p: g.q(s, *p)) own = set(ids); out = []; stubbed = set() for n in names: for r in g.q("SELECT s.id sid, s.caller_id c, s.callee_name cn, s.kind k, s.file_path f, s.start_line ln FROM call_sites s" @@ -1445,6 +1448,7 @@ def byname_sites(g, ids): if (r['cn'] or '').split('.')[-1] != n or r['k'] in CTOR_SITE_KINDS or r['c'] in own: continue if r['c'] not in g.sym or (g.IN and not g.under_in(g.sym[r['c']]['file'])): continue if r['sid'] in stubs: stubbed.add(r['c']); continue # impact's "stubs it" rows: named on a mock, never run + if r['sid'] in libsites: continue # a package's value: impact lists it apart, last out.append((r['c'], n, g.site_file(r['f']) if r['f'] else g.sym[r['c']]['file'], r['ln'] or g.sym[r['c']]['line'])) return sorted(set(out), key=lambda x: (g.sym[x[0]]['is_test'], x[2] or '', x[3] or 0, g.sym[x[0]]['display'])), stubbed - {c for c, *_ in out} diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index bdf2971a..3fe5f6cc 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -5,7 +5,7 @@ // edge(a, b, t) a calls b (the path finder's facts: resolved client edges, dispatch, defines) // named(n, m) a client callable m declared with simple name n // calls(c, m, tier, f, l) a resolved call site in c to client method m, at f:l -// unresolved(c, n, kind, f, l) an unresolved site in c written n (kind: new | method), at f:l +// unresolved(c, n, kind, f, l) an unresolved site in c written n (kind: new | method | stub | library), at f:l // member(T, s, n, k) symbol s named n of kind k (method, constructor, field, const, enum_member …) declared in type T // owner(c, T) callable c is declared in type T // typ(T, n, k) type T has simple name n and kind k (class | interface | enum | record) @@ -420,8 +420,11 @@ direct(q, c, "uses", "calls it", "one of a set", f, l) :- target(q, "method", m, // change its own [by name] dependent, so `weak_seed` held for it and the whole answer said "12 of those 12 are // reached ONLY through a dependent that is not an exact edge": everything downstream looked untrustworthy because of // one self-match. A recursive call needs no row either — the body is what is changing. -direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", !is_target_decl(q, c). +direct(q, c, "uses", "calls a method of this name (receiver not typed)", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "stub", k != "library", !is_target_decl(q, c). direct(q, c, "uses", "stubs a method of this name on a mock (receiver not typed): the real method does not run there", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "stub", f, l), !is_target_decl(q, c). +// A site whose receiver the engine traced to a package's value (`request(app).get('/x')`, ax_edges.library_receiver_sites): +// the kind "library". A row of its own, listed after the untyped-receiver matches, and no seed of the closure. +direct(q, c, "uses", "calls a method of this name on a value a package returned or constructed: not this method, unless the package hands it back", "by name", f, l) :- target(q, "method", m, _), named(n, m), unresolved(c, n, "library", f, l), !is_target_decl(q, c). // ── a method handed over as a VALUE, never called where it is named ────────────────────────────────────────── // `app.get('/orders/:id', getOrder)`, `useEffect(load)`, `items.map(format)`, `handlers = {"x": handle_x}`: the @@ -718,7 +721,7 @@ direct(q, c, role, cat(why, " — first use runs the static initializer"), cert, // a PARAMETER: the method itself, its callers, (the overrides are the contract) direct(q, m, "uses", "declares it", "resolved", "", 0) :- target(q, "param", m, _). direct(q, c, "uses", "passes an argument for it", "resolved", f, l) :- target(q, "param", m, _), calls(c, m, _, f, l). -direct(q, c, "uses", "calls a method of this name (receiver not typed) — its argument list must match", "by name", f, l) :- target(q, "param", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k). +direct(q, c, "uses", "calls a method of this name (receiver not typed) — its argument list must match", "by name", f, l) :- target(q, "param", m, _), named(n, m), unresolved(c, n, k, f, l), !ctor_kind(k), k != "library". // a TYPE PARAMETER of a type: every callable inside the type that spells it; of a method: the method. A bound reaches the uses of Type<…> too direct(q, c, "uses", cat("uses ", x, " in its body or signature"), "text", f, l) :- target(q, "typeparam", _, x), textuse(c, x, f, l). @@ -853,7 +856,7 @@ seed_of(q, c) :- target(q, "string", _, _), direct(q, c, _, _, cert, _, _), cert // a nested type reached through `import static Outer.Inner`, a dynamic receiver) MAY be a call to the target. It is // not a resolved edge, so it is kept out of seed / direct and enters only here, marked, so the answer can say so. .decl seed_byname(q:symbol, c:symbol) -seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", !seed(q, c). +seed_byname(q, c) :- target(q, "method", m, _), named(n, m), unresolved(c, n, k, _, _), k != "stub", k != "library", !seed(q, c). // and a declaration handed over as a VALUE — `Depends(get_settings)`, `add_task(send_receipt, id)`, an element of // a pipeline list. `valueref` already produces the DIRECT row for these ("names it as a value — passed, stored or // registered"), and the closure stopped there: the answer named the callable that hands it over and then said the diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 4e508824..9a0718af 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -196,6 +196,9 @@ def _at(f_, l_): stubs = ax_edges.stub_sites(lambda s_, p_: q(s_, p_).fetchall()) con.execute("CREATE TEMP TABLE _stub(id TEXT PRIMARY KEY)") con.executemany("INSERT OR IGNORE INTO _stub VALUES(?)", [(x,) for x in stubs]) + # …and so is an unresolved call on a package's value (ax_edges.library_receiver_sites): it has no edge to walk, + # and it is no by-name reader of a project method, so the by-name count below leaves it out too + con.executemany("INSERT OR IGNORE INTO _stub VALUES(?)", [(x,) for x in ax_edges.library_receiver_sites(lambda s_, p_: q(s_, p_).fetchall())]) # A CALLER IS ITS ID, NOT ITS DISPLAY. Every unnamed function of a file carries one display (``, # ``), and so does every module body of one basename (`app.`): keyed by display, the arrow in a # test and the arrow in the source that really calls this were one row, located at whichever came first in the @@ -328,8 +331,9 @@ def _at(q, ids): def hook_direct(j): - """the `direct` rows of an impact answer (either path's dict) that a hook lists: all but the HOOK_HIDDEN tiers.""" - return [x for x in (j or {}).get('direct', []) if x.get('certainty') not in HOOK_HIDDEN] + """the `direct` rows of an impact answer (either path's dict) that a hook lists: all but the HOOK_HIDDEN tiers, and + but a name match on a package's value (ax_edges.LIBRARY_BYNAME_WHY), which the fast path does not count either.""" + return [x for x in (j or {}).get('direct', []) if x.get('certainty') not in HOOK_HIDDEN and x.get('why') != ax_edges.LIBRARY_BYNAME_WHY] def impact_shaped(repo, target, depth=DEPTH, tests_shown=3, file=None): @@ -1290,8 +1294,10 @@ def in_repo_decorator(name): if s.get('name') and has['unresolved_sites'] and has['call_sites']: # joined on the caller too: unresolved_sites is keyed (caller_id, call_site_id), and on the site alone the join # scanned the whole table for every method (3.5 s for 10 methods on a 200 MB graph) + # a call on a package's value is no by-name caller (ax_edges.library_receiver_sites) + lib = " AND cs.id NOT IN (SELECT c0 FROM ext_library_receiver)" if _has(q, 'ext_library_receiver') else '' n = q("""SELECT count(*) FROM call_sites cs JOIN unresolved_sites u ON u.caller_id = cs.caller_id AND u.call_site_id = cs.id - WHERE cs.callee_name = ? AND cs.kind NOT IN ('new', 'anon_new', 'DECORATOR_APPLICATION')""", s['name'])[0][0] + WHERE cs.callee_name = ? AND cs.kind NOT IN ('new', 'anon_new', 'DECORATOR_APPLICATION')""" + lib, s['name'])[0][0] if n: rs.append(('by name', str(n), '')) order = {k: i for i, k in enumerate(NO_CALLER_KINDS)} out[mid] = sorted(dict.fromkeys(rs), key=lambda r: order[r[0]]) # stable within a kind: source order @@ -1439,12 +1445,14 @@ def direct_for_method(q, ids, code=None, rel=None, at=None, lines=None, via=None rows += [(c, 'uses', why, cert, f, l) for c, m, why, cert, f, l, _e in via if m in idset and c not in idset] names = {r[0] for r in q(f"SELECT name FROM symbols WHERE id IN ({ph})", *ids) if r[0]} seen = {r[0] for r in rows} + libsites = ax_edges.library_receiver_sites(lambda s_, p_: q(s_, *p_)) # the kind "library" in the rules for n in names: for sid, c, f, l, kind in q("""SELECT s.id, s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s JOIN unresolved_sites u ON u.call_site_id=s.id WHERE s.callee_name=?""", n): if kind in ('new', 'anon_new', 'CONSTRUCTOR_CALL'): continue # !ctor_kind(k) if c in ids: continue # !is_target_decl(q, c) - rows.append((c, 'uses', STUB_BYNAME_WHY if sid in stubs else 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) + rows.append((c, 'uses', STUB_BYNAME_WHY if sid in stubs else ax_edges.LIBRARY_BYNAME_WHY if sid in libsites + else 'calls a method of this name (receiver not typed)', 'by name', f or '', l or 0)) # the declaration handed over as a VALUE — a route registration, a callback — which has no call site at all # (the valueref / registered rules). The convention table is shared with the rules, in ax_registration.py, so # the two backends cannot disagree about what a registration is. @@ -1800,7 +1808,7 @@ def direct_for_param(q, ids): direct(q,m,"uses","declares it","resolved","",0) :- target(q,"param",m,_) direct(q,c,"uses","passes an argument for it","resolved",f,l) :- calls(c,m,_,f,l) direct(q,c,"uses","calls a method of this name (receiver not typed) — its argument list must match", - "by name",f,l) :- unresolved(c,n,k,f,l), !ctor_kind(k) + "by name",f,l) :- unresolved(c,n,k,f,l), !ctor_kind(k), k != "library" Rule 284 takes EVERY call site with no tier test and no `!bean_call` guard: an argument list is a contract the container's proxy has nothing to do with, so the bean layer that splits `calls it` three ways is absent here. @@ -1816,10 +1824,11 @@ def direct_for_param(q, ids): ORDER BY s.start_line""", *ids): rows.append((c, 'uses', 'passes an argument for it', 'resolved', f or '', l or 0)) names = {r[0] for r in q(f"SELECT name FROM symbols WHERE id IN ({ph})", *ids) if r[0]} + libsites = ax_edges.library_receiver_sites(lambda s_, p_: q(s_, *p_)) for n in sorted(names): - for c, f, l, kind in q("""SELECT s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s + for sid, c, f, l, kind in q("""SELECT s.id, s.caller_id, s.file_path, s.start_line, s.kind FROM call_sites s JOIN unresolved_sites u ON u.call_site_id=s.id WHERE s.callee_name=?""", n): - if kind in CTOR_KINDS: continue + if kind in CTOR_KINDS or sid in libsites: continue # !ctor_kind(k), k != "library" rows.append((c, 'uses', 'calls a method of this name (receiver not typed) — its argument list must ' 'match', 'by name', f or '', l or 0)) rows += [r for r in direct_for_method(q, ids) if r[3] == 'alongside'] @@ -3603,7 +3612,7 @@ def solve_from_targets(q, T, QS, site_file=None, nonsource=(), code=None, at=Non # direct_edge(q,c,e) :- target(q,"method",m,_), via_base(c,m,_,_,_,_,e), !is_target_decl(q,c) de += sorted({(c, e) for c, m, _w, _c, _f, _l, e in via[0] if m in set(mids) and c not in set(mids)}) byname = sorted({c for c, _r, _w, cert, _f, _l in d if cert == 'by name' and not ax_registration.is_value_why(_w) - and _w != STUB_BYNAME_WHY} - seeds) + and _w != STUB_BYNAME_WHY and _w != ax_edges.LIBRARY_BYNAME_WHY} - seeds) if 'type' in by_kind: tids = sorted(by_kind['type']) diff --git a/tests/cases/javascript/untyped-receiver/case.json b/tests/cases/javascript/untyped-receiver/case.json index 97cae27d..d611db5d 100644 --- a/tests/cases/javascript/untyped-receiver/case.json +++ b/tests/cases/javascript/untyped-receiver/case.json @@ -3,4 +3,8 @@ {"why": "a close() on an untyped receiver, with two classes declaring close, is one of a set and never presented as an exact edge", "run": ["impact", "Session.close"], "want": ["shutdown", "one of a set"], - "avoid": ["1 resolved"]}]} + "avoid": ["1 resolved"]}, + {"why": "a get() on what a package returned or constructed (request(app).get, new grpc.Metadata().get) is listed apart and last, never among the name matches, and its test is not a test of Store.get; an untyped receiver stays a plain [by name] row", + "run": ["impact", "Store.get", "--grep"], + "want": ["anything.get(1); [by name", "[by name · library receiver", "tests: 0 of"], + "avoid": ["expect(200); [by name]", "[test · by name"]}]} diff --git a/tests/cases/javascript/untyped-receiver/src/package.json b/tests/cases/javascript/untyped-receiver/src/package.json new file mode 100644 index 00000000..ba7a6f27 --- /dev/null +++ b/tests/cases/javascript/untyped-receiver/src/package.json @@ -0,0 +1 @@ +{ "name": "untyped-receiver", "type": "module", "dependencies": { "supertest": "*", "@grpc/grpc-js": "*" } } diff --git a/tests/cases/javascript/untyped-receiver/src/store.js b/tests/cases/javascript/untyped-receiver/src/store.js new file mode 100644 index 00000000..c5d00b92 --- /dev/null +++ b/tests/cases/javascript/untyped-receiver/src/store.js @@ -0,0 +1,12 @@ +export class Store { + get(id) { return id; } +} + +export class Reader { + constructor() { this.store = new Store(); } + read(id) { return this.store.get(id); } +} + +export function lookup(anything) { + return anything.get(1); +} diff --git a/tests/cases/javascript/untyped-receiver/src/store.test.js b/tests/cases/javascript/untyped-receiver/src/store.test.js new file mode 100644 index 00000000..954e4750 --- /dev/null +++ b/tests/cases/javascript/untyped-receiver/src/store.test.js @@ -0,0 +1,9 @@ +import request from 'supertest'; +import * as grpc from '@grpc/grpc-js'; +import { test } from 'node:test'; + +test('health over http', async () => { + await request({}).get('/healthz').expect(200); + const md = new grpc.Metadata(); + md.get('k'); +}); diff --git a/tests/cases/typescript/library-receiver/case.json b/tests/cases/typescript/library-receiver/case.json new file mode 100644 index 00000000..d048cf5e --- /dev/null +++ b/tests/cases/typescript/library-receiver/case.json @@ -0,0 +1,6 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a get() on what an uninstalled package returned (request(app).get) is listed apart and last, never among the name matches; an `any` receiver stays a plain [by name] row", + "run": ["impact", "Store.get", "--grep"], + "want": ["anything.get(1); [by name", "[by name · library receiver"], + "avoid": ["expect(200); [by name]"]}]} diff --git a/tests/cases/typescript/library-receiver/src/package.json b/tests/cases/typescript/library-receiver/src/package.json new file mode 100644 index 00000000..d450ccef --- /dev/null +++ b/tests/cases/typescript/library-receiver/src/package.json @@ -0,0 +1 @@ +{ "name": "library-receiver", "dependencies": { "supertest": "*" } } diff --git a/tests/cases/typescript/library-receiver/src/store.test.ts b/tests/cases/typescript/library-receiver/src/store.test.ts new file mode 100644 index 00000000..46cb0acb --- /dev/null +++ b/tests/cases/typescript/library-receiver/src/store.test.ts @@ -0,0 +1,5 @@ +import request from 'supertest'; + +export async function healthOverHttp(): Promise { + await request({}).get('/healthz').expect(200); +} diff --git a/tests/cases/typescript/library-receiver/src/store.ts b/tests/cases/typescript/library-receiver/src/store.ts new file mode 100644 index 00000000..945cd885 --- /dev/null +++ b/tests/cases/typescript/library-receiver/src/store.ts @@ -0,0 +1,12 @@ +export class Store { + get(id: string): string { return id; } +} + +export class Reader { + store = new Store(); + read(id: string): string { return this.store.get(id); } +} + +export function lookup(anything: any): unknown { + return anything.get(1); +} diff --git a/tests/cases/typescript/library-receiver/src/tsconfig.json b/tests/cases/typescript/library-receiver/src/tsconfig.json new file mode 100644 index 00000000..b95923d7 --- /dev/null +++ b/tests/cases/typescript/library-receiver/src/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "strict": false, "module": "esnext", "target": "es2022" } } From c1dbec45c90bea41422b4225919a11c655faeafe Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:22:18 -0700 Subject: [PATCH 014/133] impact: word interface dispatch as certain, and say when the answer is complete A caller reached through an interface was explained with the precision figure for target sets the engine could not narrow, so the only correct answer read as a guess and agents re-read the source to check it. Dispatch rows now get their own legend, and a `complete:` line is printed when no row is by-name, text or a capped set and nothing inside the impacted set is unresolved. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index dcdc1854..9c0fc0a0 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -3706,10 +3706,31 @@ def main(argv): REGISTERED_NOTE = ("[registered] is a callable handed over as a value, or a handler a framework runs for a request or event " "sent to it — the engine recorded the hand-off or the send, not a call to it, and cannot say it ever runs") used_registered = 'registered' in set(test_cert.values()) + # A DISPATCH ROW IS NOT A GUESS. `calls it (via the interface)` is a call typed on a base whose target set is that + # base and its overrides, so the caller is certain and only which override runs is open. Explaining it with the + # oracle figure for sets the engine could not narrow told agents to distrust the one right answer, and they + # re-read every file to check it. + dispatch_why = set(ax_edges.VIA_BASE_WHY.values()) + open_set = [x for x in byname if x[3] == 'one of a set' and x[2] not in dispatch_why] + dispatched = [x for x in byname if x[3] == 'one of a set' and x[2] in dispatch_why] if byname: - legend = "[one of a set] is one member of a target set the engine could not narrow (its own oracle: right 126 of 351 times, against 528 of 585 for a resolved edge), [by name] / [text] may be a same-named other thing" + parts = [] + if dispatched: + parts.append("[one of a set] … (via the interface / base class) is dispatch: the call is typed on the base, so the caller is " + "certain and only which override runs is open — a dependent, not a guess") + if open_set: + parts.append("[one of a set] is one member of a target set the engine could not narrow (its own oracle: right 126 of 351 times, against 528 of 585 for a resolved edge)") + if any(x[3] in ('by name', 'text') for x in byname): + parts.append("[by name] / [text] may be a same-named other thing") + legend = ", ".join(parts) if used_registered: legend += ", " + REGISTERED_NOTE print(f"note: {len(byname)} entr{'y' if len(byname) == 1 else 'ies'} are not an exact edge — " + legend) + # COMPLETE, SAID ONLY WHEN NOTHING ABOVE IS OPEN. Without it an answer that is all resolved calls and dispatch reads + # like a partial one, and the reader re-derives it from the source. + if D and not open_set and not u and not byname_seeds and not outside and not dunder and not shapes and not unmod \ + and not any(x[3] in ('by name', 'text', 'capped set') for x in D): + print("complete: every dependent above is a resolved call or a dispatch through a base; no caller reaches it by name " + "only and no call inside the impacted set is unresolved — read source to make the edit, not to re-check this list") elif used_registered: # the note above is counted in by-name entries, and a `registered` route need not produce one: on a # target reached ONLY through hand-offs there were no by-name entries at all, so the label the whole From ca3a86de099345538c400e5d33cb1739d8fb1303 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:27:43 -0700 Subject: [PATCH 015/133] impact: keep internal oracle figures out of the answer Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 9c0fc0a0..9886d442 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -3719,7 +3719,7 @@ def main(argv): parts.append("[one of a set] … (via the interface / base class) is dispatch: the call is typed on the base, so the caller is " "certain and only which override runs is open — a dependent, not a guess") if open_set: - parts.append("[one of a set] is one member of a target set the engine could not narrow (its own oracle: right 126 of 351 times, against 528 of 585 for a resolved edge)") + parts.append("[one of a set] is one member of a target set the engine could not narrow: any one of them may be the one that runs") if any(x[3] in ('by name', 'text') for x in byname): parts.append("[by name] / [text] may be a same-named other thing") legend = ", ".join(parts) From 37ece4c0f7df35223462d8d5df7b5444e54ceb5d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:51:18 -0700 Subject: [PATCH 016/133] impact: word the complete line without the by-name label Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index ec4299c8..c4697456 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -3738,8 +3738,8 @@ def main(argv): # like a partial one, and the reader re-derives it from the source. if D and not open_set and not u and not byname_seeds and not outside and not dunder and not shapes and not unmod \ and not any(x[3] in ('by name', 'text', 'capped set') for x in D): - print("complete: every dependent above is a resolved call or a dispatch through a base; no caller reaches it by name " - "only and no call inside the impacted set is unresolved — read source to make the edit, not to re-check this list") + print("complete: every dependent above is a resolved call or a dispatch through a base; none is a name-only match " + "and no call inside the impacted set is unresolved — read source to make the edit, not to re-check this list") elif used_registered: # the note above is counted in by-name entries, and a `registered` route need not produce one: on a # target reached ONLY through hand-offs there were no by-name entries at all, so the label the whole From 7ce9d88014244db9ed9a9ff30f727fad1ccf392b Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 23:43:34 -0700 Subject: [PATCH 017/133] Aid grep instead of replacing it: a grep note says only what grep cannot know; impact shows code only where no text search reaches; find leaves MCP The hook after a grep now names, for each identifier, the declarations its matched lines reach when they differ, and the callers whose call never spells the name; it is silent when grep was complete. impact lists the places a word grep already finds by location only, names places further than one hop and the tests without code, and keeps the code for the direct places grep cannot see. MCP offers impact, path and tests; find stays a shell verb. AXIOMCODE_GREP_AID=0 restores the previous answers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/AGENTS.md | 8 +- plugins/axiomcode/hooks/direct.py | 3 +- plugins/axiomcode/hooks/enrich.py | 72 +++++++++++++++- plugins/axiomcode/hooks/orient.py | 11 ++- plugins/axiomcode/mcp/server.py | 9 +- plugins/axiomcode/rules/axiomcode.mdc | 8 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 10 +-- .../skills/axiomcode/scripts/ax_blocks.py | 83 +++++++++++++++++++ .../axiomcode/scripts/axiomcode-install | 5 +- skills/axiomcode/SKILL.md | 8 +- tests/front_door.py | 14 ++-- tests/mcp.py | 10 +-- tests/mcp_docs.py | 4 +- tests/mcp_first.py | 12 +-- tests/repo_arg.py | 2 +- tests/surfaces.py | 3 +- 16 files changed, 202 insertions(+), 60 deletions(-) diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index ac48bfde..1f6b1fcf 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,15 +1,15 @@ # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change -breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: +Search with grep and Read as usual: after a grep, the call graph adds only what grep cannot know (which +declaration each match reaches, the callers that never spell the name). Ask it directly, through the axiomcode +MCP tools, for what no text search answers: - find(question) where the code for a task lives, when you have a task in words and no name yet impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them -Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +Without the tools, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 7a4285b8..58ca74d4 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -124,8 +124,7 @@ def directive(hits): f"graph: this search is for {named}. Who calls it and what a change breaks, each with its code,\n" f" including the callers that never spell the name (an interface, an override, a callback, DI):\n" f" impact(name=\"{at}\") (mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact {at}`). Also\n" - f" path(start, end) for how A reaches B (`axiomcode path A B`), find(question) for a task in words\n" - f" (`axiomcode find \"\"`). Said once this session." + f" path(start, end) for how A reaches B (`axiomcode path A B`). Said once this session." ) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 036e7f4a..64b3f04c 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -132,6 +132,74 @@ def outside(p): # Seeded from one method the same closure is small, and the cap keeps a hub method flat. Each edge table joins in # its OWN recursive branch so SQLite drives them by index; one combined edge CTE rescans every edge per call. REACH_DEPTH = 6 +GREP_HIT = re.compile(r'(?:^|[\s"\'])((?:[\w.@-]+/)*[\w.@-]+\.\w+)(?::(\d+))?') +WORD = lambda n: re.compile(r'(? 'module'", n) + if not decls: continue + mids = {d['method_id']: d for d in decls} + ph = ','.join('?' * len(mids)) + edges = q(f"SELECT cs.file_path f, cs.start_line ln, coalesce(cs.end_line, cs.start_line) eln, cs.callee_name cn, e.callee_method_id m, cr.display who FROM call_edges e " + f"JOIN call_sites cs ON cs.id = e.call_site_id JOIN symbols cr ON cr.id = e.caller_id " + f"WHERE e.callee_method_id IN ({ph})", *mids) + # 1. the calls grep matched, split by the declaration they reach — only when the name is declared more than once + # A call through an interface reaches the base and every implementation: that is one target set, not an ambiguity. + # Only lines that reach DIFFERENT sets are worth telling apart, because that is what grep's lines cannot show. + split = '' + if len(decls) > 1: + mine = [x for x in edges if (x['f'], x['ln']) in hit_lines] if hit_lines else [x for x in edges if x['f'] in hit_files and x['cn'] == n] + per_line = collections.defaultdict(set) + for x in mine: per_line[(x['f'], x['ln'])].add(mids[x['m']]['display']) + groups = collections.defaultdict(list) + for k, ds in per_line.items(): groups[frozenset(ds)].append(k) + if len(groups) > 1: + def name(ds): return min(ds, key=len) + (f" (+{len(ds) - 1} implementation(s))" if len(ds) > 1 else '') + def at(ks): + ks = sorted(ks); fs = collections.OrderedDict() + for f, ln in ks: fs.setdefault(f.split('/')[-1], []).append(str(ln)) + return ' '.join(f"{f}:{','.join(v[:3])}" for f, v in list(fs.items())[:2]) + (' …' if len(fs) > 2 else '') + split = '; '.join(f"{len(ks)} → {name(ds)} [{at(ks)}]" for ds, ks in sorted(groups.items(), key=lambda kv: -len(kv[1]))[:3]) + # 2. the callers no text search finds: the call's line does not spell the name + hidden = [] + for x in edges: + # a call spans lines (`rows\n .sort(byPath)` starts a line above its name): the whole span is checked + src = [linecache_line(x['f'], i) for i in range(x['ln'], max(x['ln'], x['eln']) + 1)] + if None not in src and not any(WORD(n).search(l) for l in src) and (x['f'], x['ln']) not in hit_lines: + hidden.append(f"{x['f']}:{x['ln']} ({x['who'].split('.')[-1]})") + hidden = list(dict.fromkeys(hidden)) + if not split and not hidden: continue + parts = [] + if split: parts.append(f"your matches reach different declarations: {split}") + if hidden: parts.append(f"{len(hidden)} caller(s) grep cannot see (the line never names it): " + ', '.join(hidden[:4]) + (f" +{len(hidden) - 4}" if len(hidden) > 4 else '')) + out.append(f"graph on `{n}`: " + ' | '.join(parts)) + return out + + +_LINES = {} +def linecache_line(f, ln): + if f not in _LINES: + try: + with open(os.path.join(cwd, f), encoding='utf-8', errors='replace') as h: _LINES[f] = h.read().split('\n') + except OSError: _LINES[f] = None + L = _LINES[f] + return L[ln - 1] if L and 0 < ln <= len(L) else None + + def reach_counts(mid): try: tot = q("SELECT count(*) n FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")[0]['n'] @@ -477,7 +545,9 @@ def lookup(n): if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})" if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)") return n, rows, out - if idents: + if idents and os.environ.get('AXIOMCODE_GREP_AID', '1') != '0': + lines += grep_aid(idents, ev.get('tool_response')) + elif idents: with concurrent.futures.ThreadPoolExecutor(max_workers=min(6, len(idents))) as ex: found = list(ex.map(lookup, idents)) found = [(n, rows, out) for n, rows, out in found if rows] opened = set(load_state().get('opened', [])) diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index 316cfc7b..c355b850 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -196,9 +196,9 @@ def names_code(prompt, db): # rather than restating that there was a match, which told the reader nothing about WHICH match _, _, hits = rest.partition('<- ') print(f" {path}" + (f" <- {hits.strip()}" if hits.strip() else '')) - print(' find(question="") (mcp__plugin_axiomcode_axiomcode__find) ranks the functions the task lands in, ' - 'each with its code; call it directly, no skill needs loading first (without that tool: ' - '`axiomcode find ""`).') + print(' search these with grep as usual: after each grep the graph says only what grep cannot see (which declaration ' + 'a match reaches, callers that never spell the name). For a name: impact(name="") ' + '(mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact `).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: @@ -210,9 +210,8 @@ def names_code(prompt, db): if 'how it runs —' in out: # a how-question: the flow is the answer's spine, and the call that returns it with each step's code is the # one to make — named here so no turn goes to loading the skill or the tool schemas first - print(' next: find(question="") (mcp__plugin_axiomcode_axiomcode__find) returns the functions ' - 'the flow runs through, each with its code; call it directly, no skill needs loading first (without that ' - 'tool: `axiomcode find ""`).') + print(' next: grep the names above as usual; for how one reaches another, path(start="", end="") ' + '(mcp__plugin_axiomcode_axiomcode__path; shell `axiomcode path `) returns every hop with its code.') else: print(' a starting point, not a conclusion: next, impact(name="") (mcp__plugin_axiomcode_axiomcode__impact) ' 'for who calls it, what a change reaches and its tests, each with its code; call it directly, no skill ' diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 2f30e142..4a9821ff 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -313,15 +313,8 @@ def plain(text): return '\n'.join(out) -# THE SMALL SURFACE. Three questions, each answered as numbered places with the code of the function each sits in, so +# THE SMALL SURFACE. Search is grep's job; the graph answers what grep cannot. Three questions, each answered as numbered places with the code of the function each sits in, so # a place is understood without opening its file. No options: the repository is the one the session works in. -@srv.tool() -def find(question: str) -> str: - """Where the code for a task lives. Describe what you need in words (the feature, the behaviour, a name you saw); - get the functions involved, each with its code, most relevant first. A name the code calls but nothing declares - is listed with its call sites: that is code you have to write.""" - return plain(run(['find', question, os.getcwd()])) - @srv.tool() def impact(name: str = '') -> str: """What a change reaches. With a name (as written in the code: Owner.method, function, Type, or file.py:123): who diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index d77c4ea5..05b30d8a 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,16 +5,16 @@ alwaysApply: true # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change -breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: +Search with grep and Read as usual: after a grep, the call graph adds only what grep cannot know (which +declaration each match reaches, the callers that never spell the name). Ask it directly, through the axiomcode +MCP tools, for what no text search answers: - find(question) where the code for a task lives, when you have a task in words and no name yet impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them -Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +Without the tools, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 3628a493..9deac574 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,18 +1,18 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual: after a grep the graph adds only what grep cannot know — which declaration each match reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode -Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run +Search with grep as usual; the graph answers what grep cannot. Use the MCP tools when they are in your list (in Claude Code +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run `/scripts/axiomcode ` from the repository root. Same answer either way. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `axiomcode find ""` | +| where is the code for this task? | grep; or, shell only | `axiomcode find ""` | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -44,7 +44,7 @@ from an empty answer. Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Example: `find(question="how is the invoice total computed")`. +code you have to write. Shell only: `axiomcode find "how is the invoice total computed"`. ## impact diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 731e1d73..09574d9c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -19,6 +19,7 @@ import ax_grep CAP = 10 # places shown; the rest are counted +FAR = 8 # places more than one hop away, named without code WHOLE = 14 # a function this short is shown whole AROUND = 3 # else: its header, then this many lines either side of the line that matters FENCE = {'.py': 'python', '.java': 'java', '.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx', @@ -100,6 +101,25 @@ def render(verb, doc, repo): if n not in p['marks']: p['marks'].append(n) t = t.split(' — ')[0].strip() # the tag's short form: what it is, not the explanation after the dash if t and t not in p['tags']: p['tags'].append(t) + # GREP AIDS, IT IS NOT REPLACED. For impact, a place whose lines spell the target's name is one the agent's own + # `grep -nw NAME` finds: it is listed by location only, and the code goes to the places no text search reaches + # (a function passed as a value, a framework or DI registration, an alias). Off with AXIOMCODE_GREP_AID=0. + plain = [] + names = target_names(doc) if verb == 'impact' and os.environ.get('AXIOMCODE_GREP_AID', '1') != '0' else [] + if names: + spelled = lambda p: all(any(re.search(r'(? FAR: out.append(f" … +{len(far) - FAR} more") + if tests: + out.append(f"tests that reach it ({len(tests)}; no code shown):") + for p in tests[:FAR]: + out.append(f" {p['f']}:{','.join(map(str, sorted(p['marks'])))} {p['span'][0] if p['span'] else '?'}") + if len(tests) > FAR: out.append(f" … +{len(tests) - FAR} more") + if plain: + refs = [f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}" for p in plain] + g = ' -e '.join(names) + out.append(f"{'' if out else 'every place spells the name, so grep finds them all — '}+{len(plain)} place(s) `grep -nw {g}` also finds " + f"(confirmed callers; no code shown): " + ', '.join(refs[:8]) + (f" +{len(refs) - 8}" if len(refs) > 8 else '')) + other = grep_others(repo, names, {(p['f'], n) for p in plain for n in p['marks']}) + if other: out.append(f" {other} other line(s) grep matches for that name are NOT this declaration (another symbol of the same name, or text)") if not out: return None if len(places) > CAP: out.append(f"… {len(places) - CAP} more place(s) not shown — ask a narrower question to see them") out += [x for x in foot if x.startswith(('run:', 'verified'))][:2] return out +def target_names(doc): + """the short names of the declarations impact was asked about: `isOrderable (4 declarations)`, `Shop.Cart#total` -> total""" + out = [] + for t in doc.get('targets') or []: + lab = str(t.get('label') or t.get('display') or '').split(' (')[0].strip() + nm = re.split(r'[.#:]', lab)[-1].split('(')[0].strip() + if re.fullmatch(r'[A-Za-z_$][\w$]*', nm or '') and nm not in out: out.append(nm) + return out + + +_SRC = {} +def source_line(repo, f, n): + if f not in _SRC: + try: + with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as h: _SRC[f] = h.read().split('\n') + except OSError: _SRC[f] = [] + L = _SRC[f] + return L[n - 1] if 0 < n <= len(L) else '' + + +def statement(repo, f, n): + """the statement a call starts on: up to the line that ends it (`rows\\n .sort(byPath);`), at most 5 lines""" + out = [] + for i in range(n, n + 5): + l = source_line(repo, f, i); out.append(l) + if l.rstrip().endswith((';', '{', '}')) or not l.strip(): break + return '\n'.join(out) + + +def grep_others(repo, names, confirmed): + """how many lines a word grep for the name matches that are neither a confirmed place nor a declaration of it""" + try: + r = subprocess.run(['git', 'grep', '-nw'] + sum((['-e', nm] for nm in names), []), cwd=repo, capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return 0 + n = 0 + for ln in r.stdout.splitlines(): + m = re.match(r'([^:]+):(\d+):(.*)', ln) + if not m or (m.group(1), int(m.group(2))) in confirmed: continue + if re.search(r'\b(def|function|class|interface|async|public|private|protected|static|const|let|var)\b[^=(]*\b(' + '|'.join(map(re.escape, names)) + r')\b', m.group(3)): continue + if re.match(r'\s*(' + '|'.join(map(re.escape, names)) + r')\s*\(.*\)\s*[:{]', m.group(3)): continue # a method declaration + n += 1 + return n + + def verb_json(cmd): import ax_exec r = subprocess.run(ax_exec.program(cmd + ['--json']), stdout=subprocess.PIPE, text=True, encoding='utf-8', errors='replace') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index c562c060..a3a9f80b 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -35,10 +35,9 @@ END = '' BLOCK = """ ## Finding code in this repository -This repository has a resolved call graph. Ask it FIRST, through the axiomcode MCP tools -(`mcp__plugin_axiomcode_axiomcode__*`); no skill needs loading: +This repository has a resolved call graph. Search with grep as usual; after a grep it adds what grep cannot know. +Ask it directly, through the axiomcode MCP tools (`mcp__plugin_axiomcode_axiomcode__*`; no skill needs loading): - find(question="") # where the code for a task lives impact(name="") # who calls it, what a change reaches, its tests impact() # the same for your uncommitted edits path(start="", end="") # how A reaches B diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ff9eb64f..bbc24624 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,18 +1,18 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". Search with grep as usual: after a grep the graph adds only what grep cannot know — which declaration each match reaches and the callers that never spell the name. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run `/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `axiomcode find ""` | +| where is the code for this task? | grep; or, shell only | `axiomcode find ""` | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -44,7 +44,7 @@ from an empty answer. Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Example: `find(question="how is the invoice total computed")`. +code you have to write. Shell only: `axiomcode find "how is the invoice total computed"`. ## impact diff --git a/tests/front_door.py b/tests/front_door.py index 73f2d034..58ff997a 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -99,8 +99,8 @@ def main(): rc, out, err = cli(repo, 'find', 'how is the invoice total computed') check('find: numbered places, each with its code in a fenced block', rc == 0 and places(out) and 'def invoice' in out, out[:600] + err[-300:]) rc, out, err = cli(repo, 'impact', 'vat_rate') - check('impact : its caller as a numbered place with its code', rc == 0 and places(out) and 'shop/pricing.py:6' in out - and 'return net * (1 + vat_rate())' in out, out[:600] + err[-300:]) + check('impact : its caller, which spells the name, by location on the grep line (no code)', rc == 0 and 'grep -nw vat_rate' in out and 'shop/pricing.py:6' in out + and 'return net * (1 + vat_rate())' not in out, out[:600] + err[-300:]) check('impact : the test that reaches it is one of the places', 'tests/test_pricing.py' in out, out[:800]) rc, out, err = cli(repo, 'path', 'invoice', 'vat_rate') check('path: every hop a numbered place with the code at the call', rc == 0 and places(out) @@ -111,20 +111,20 @@ def main(): first = out.lstrip().split('\n', 1)[0] check('impact with no name: the answer starts with "your edits:" and names the edited declaration', rc == 0 and first.startswith('your edits:') and 'vat_rate' in first, out[:600] + err[-300:]) - check('impact with no name: then what the edit reaches, as places with their code', places(out) and 'shop/pricing.py:6' in out, out[:600]) + check('impact with no name: then what the edit reaches, its grep-visible caller by location', 'shop/pricing.py:6' in out, out[:600]) rc, out, err = cli(repo, 'tests') last = [l for l in out.splitlines() if l.strip()][-1:] or [''] check('tests: the reached test as a numbered place with its code', rc == 0 and places(out) and 'tests/test_pricing.py' in out, out[:600] + err[-300:]) check('tests: the answer ends with the "run:" line', last[0].startswith('run:') and 'test_pricing' in last[0], last) # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── - got = mcp(repo, [('find', {'question': 'how is the invoice total computed'}), ('impact', {'name': 'vat_rate'})]) + got = mcp(repo, [('path', {'start': 'total', 'end': 'vat_rate'}), ('impact', {'name': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly find, impact, path and tests', set(tools) == {'find', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly impact, path and tests (search is grep\'s)', set(tools) == {'impact', 'path', 'tests'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) - check('MCP find answers as numbered places with their code', places(text(3)), text(3)[:600]) - check('MCP impact answers as numbered places with their code', places(text(4)) and 'shop/pricing.py:6' in text(4), text(4)[:600]) + check('MCP path answers as numbered places with their code', places(text(3)), text(3)[:600]) + check('MCP impact names the caller grep also finds, by location', 'shop/pricing.py:6' in text(4) and 'grep -nw vat_rate' in text(4), text(4)[:600]) # ── c. controls: the same question anywhere else gets the verb's own answer ────────────────────────────────── r = subprocess.run(['bash', AX, 'impact', 'vat_rate', repo], cwd=repo, capture_output=True, text=True, timeout=600, env=ENV) diff --git a/tests/mcp.py b/tests/mcp.py index 91f08c17..7412ab72 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -31,7 +31,7 @@ SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') # THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped # at ten places with the rest counted, so no tool is paged. -TOOLS = {'find': ['question'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} +TOOLS = {'impact': ['name'], 'path': ['start', 'end'], 'tests': []} def exchange(cmd, cwd, env=None, workdir=None): @@ -112,10 +112,9 @@ def check_arguments(label, cmd, cwd, lax=False): wrong = [('impact', {'name': ['Excluder.excludeClass']}, 'name'), ('path', {'start': 'a'}, 'end'), ('path', {'start': 'a', 'end': 'b', 'nope': 1}, 'nope'), - ('find', {}, 'question'), # the options the old tools took are refused, not dropped so that an unnarrowed answer comes back as if # it had been narrowed (#1567): the repository is the session's, and there are no flags - ('find', {'question': 'x', 'in_path': 'src'}, 'in_path: unexpected argument'), + ('path', {'start': 'a', 'end': 'b', 'in_path': 'src'}, 'in_path: unexpected argument'), ('impact', {'name': 'A.f', 'repo': cwd}, 'repo: unexpected argument'), ('tests', {'why': True}, 'why: unexpected argument')] for name, args, field in wrong: @@ -125,7 +124,7 @@ def check_arguments(label, cmd, cwd, lax=False): if not res.get('isError') or field not in text or not ('invalid arguments' in text or 'validation error' in text): bad.append(f"{label}: {name}({json.dumps(args)}) was not refused naming {field!r}: {res}") # the control: every parameter a tool declares still passes, including the ones the CLI's hints name - right = [('find', {'question': 'x'}), ('impact', {'name': 'A.f'}), ('impact', {}), + right = [('impact', {'name': 'A.f'}), ('impact', {}), ('path', {'start': 'a', 'end': 'b'}), ('tests', {})] for name, args in right: res = call(cmd, cwd, name, args) @@ -181,8 +180,7 @@ def check_front_door(): cwd = os.getcwd() try: bad = [] - for call, want in ((lambda: server.find('how is a total computed'), ['find', 'how is a total computed', cwd]), - (lambda: server.impact('A.f'), ['impact', 'A.f', cwd]), + for call, want in ((lambda: server.impact('A.f'), ['impact', 'A.f', cwd]), (lambda: server.impact(''), ['impact', cwd]), (lambda: server.impact(), ['impact', cwd]), (lambda: server.path('a', 'b'), ['path', 'a', 'b', cwd]), diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index c6da2d9b..e2925fb8 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -131,7 +131,7 @@ def controls(tools): got = len(mismatches(documented(text), fake, 'control')) if got != want: bad.append(f"control {text!r}: {got} mismatch(es), want {want}") - if set(tools) != {'find', 'impact', 'path', 'tests'}: + if set(tools) != {'impact', 'path', 'tests'}: bad.append(f"the server listed {sorted(tools)}; the MCP tools were not read") return bad @@ -147,7 +147,7 @@ def main(): seen |= {(t, a) for ts, a, _v, _u in rows if ts for t in ts} bad += mismatches(rows, tools, rel) # not a vacuous pass: the arguments the docs are known to teach were found and checked - for want in (('find', 'question'), ('impact', 'name'), ('path', 'start'), ('path', 'end')): + for want in (('impact', 'name'), ('path', 'start'), ('path', 'end')): if want not in seen: bad.append(f"the docs' {want[0]} {want[1]}= was not found, so the reader missed it") print(f"{len(DOCS)} doc(s), {len(seen)} documented (tool, argument) pair(s) checked against {len(tools)} tool schema(s)") diff --git a/tests/mcp_first.py b/tests/mcp_first.py index addad02c..230abb0e 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -60,13 +60,13 @@ def fire(hook, ev): m = re.search(r'^description: >-\n(.*?)\n---', skill, re.S | re.M) check('SKILL.md has a description block', bool(m)) if m: - tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('find', 'impact', 'path', 'tests')) + tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('impact', 'path', 'tests')) # 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True, timeout=30) check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) -tool_first('install block', r.stdout, ('find', 'impact', 'path', 'tests')) +tool_first('install block', r.stdout, ('impact', 'path', 'tests')) # 3. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: @@ -80,7 +80,7 @@ def fire(hook, ev): 'tool_input': {'pattern': 'findOrder'}, 'cwd': repo, 'session_id': 'm1'}) check('direct: the first search for a declared name hears the directive', rc == 0 and bool(said), f'rc={rc}') # only the verbs that answer a search: tests is about an edit, not about what a grep looks for - tool_first('direct', said, ('impact', 'path', 'find')) + tool_first('direct', said, ('impact', 'path')) # 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: @@ -97,15 +97,15 @@ def fire(hook, ev): rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o2', 'prompt': 'How does Consumer.go work, step by step?'}) check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) - tool_first('orient (how)', said, ('find',)) + tool_first('orient (how)', said, ('path',)) # 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be # fired; the order is checked in the source line that prints it. src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() -i = src.find("find(question=\"\") (mcp__plugin_axiomcode_axiomcode__find) ranks") +i = src.find("search these with grep as usual") check('orient (scope refused): its hint is still in the source', i >= 0) if i >= 0: - tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode find', i))], ('find',)) + tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode impact', i))], ('impact',)) print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') sys.exit(1 if fails else 0) diff --git a/tests/repo_arg.py b/tests/repo_arg.py index 3a18427b..006565c6 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -72,7 +72,7 @@ def no_graph(where, what): seen = [] real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or 'ran') try: - calls = {'find': lambda: server.find('how'), 'path': lambda: server.path('a', 'b'), + calls = {'path': lambda: server.path('a', 'b'), 'impact': lambda: server.impact('foo'), 'tests': lambda: server.tests()} for name, call in calls.items(): seen.clear(); call() diff --git a/tests/surfaces.py b/tests/surfaces.py index ecc8e356..2ff43816 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -30,7 +30,8 @@ CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH PUBLIC = ['index', 'find', 'impact', 'path', 'tests'] -NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} +NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself', + 'find': 'search is the agent\'s own grep, which a hook annotates; find is a shell verb for a person'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', From c8754cb49a4b228efcd0ce9aca326ffed07b5229 Mon Sep 17 00:00:00 2001 From: swapnil Date: Thu, 1 Oct 2026 00:08:09 -0700 Subject: [PATCH 018/133] grep aid: name who enters a declaration nothing in the code calls; tests cover both notes A grep that finds only a route handler's declaration reads as dead code; the note now says the runtime or a framework enters it, and stays silent for a plain uncalled method. The previous note's checks run under AXIOMCODE_GREP_AID=0, and the grep-aid checks (who enters it, silence when grep was complete) run beside them. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/enrich.py | 13 +++++++++++- tests/enrich_lines.py | 33 +++++++++++++++++++++++++++++-- tests/hooks_from_path.py | 4 ++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 64b3f04c..72d36cc9 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -182,8 +182,19 @@ def at(ks): if None not in src and not any(WORD(n).search(l) for l in src) and (x['f'], x['ln']) not in hit_lines: hidden.append(f"{x['f']}:{x['ln']} ({x['who'].split('.')[-1]})") hidden = list(dict.fromkeys(hidden)) - if not split and not hidden: continue + # 3. a declaration nothing in the code calls, that the runtime enters (a route, a schedule, a framework + # annotation): grep shows no caller and reads as dead code; this says who calls it. A plain `0` stays unsaid, + # since grep's lines already show it + entered = [] + if not edges: + for d in decls: + is_test = (q("SELECT is_test FROM symbols WHERE method_id = ? LIMIT 1", d['method_id']) or [{'is_test': 0}])[0]['is_test'] + lab = _graphline.zero_label(con, d['method_id'], None, is_test or 0) + if lab.startswith(('entry', '?')) and lab != 'entry (test)': + entered.append(f"{d['display']} {lab.replace('? framework', 'by the framework').replace('entry', 'entered by the runtime')}") + if not split and not hidden and not entered: continue parts = [] + if entered: parts.append("nothing in the code calls " + '; '.join(entered[:3])) if split: parts.append(f"your matches reach different declarations: {split}") if hidden: parts.append(f"{len(hidden)} caller(s) grep cannot see (the line never names it): " + ', '.join(hidden[:4]) + (f" +{len(hidden) - 4}" if len(hidden) > 4 else '')) out.append(f"graph on `{n}`: " + ' | '.join(parts)) diff --git a/tests/enrich_lines.py b/tests/enrich_lines.py index 22bc568a..27a1dafc 100644 --- a/tests/enrich_lines.py +++ b/tests/enrich_lines.py @@ -88,11 +88,31 @@ def fire(repo, session, tool, inp, hook='enrich.py', event='PostToolUse'): def grep(repo, pattern, session=None): - return fire(repo, session or f'g-{pattern}', 'Grep', {'pattern': pattern}) + """the previous grep note (declarations with caller counts), still served with AXIOMCODE_GREP_AID=0""" + os.environ['AXIOMCODE_GREP_AID'] = '0' + try: return fire(repo, session or f'g-{pattern}', 'Grep', {'pattern': pattern}) + finally: os.environ.pop('AXIOMCODE_GREP_AID', None) + + +def grep_aid(repo, pattern, session=None): + """the grep note as served: only what the agent's own grep lines cannot show""" + lines = subprocess.run(['git', 'grep', '-nw', pattern], cwd=repo, capture_output=True, text=True).stdout + return _fire_with(repo, session or f'ga-{pattern}', pattern, lines) + + +def _fire_with(repo, session, pattern, lines): + ev = {'hook_event_name': 'PostToolUse', 'tool_name': 'Grep', 'tool_input': {'pattern': pattern, 'output_mode': 'content'}, + 'tool_response': {'mode': 'content', 'content': lines}, 'cwd': repo, 'session_id': session} + r = subprocess.run([sys.executable, os.path.join(HOOKS, 'enrich.py')], input=json.dumps(ev), capture_output=True, text=True, timeout=120) + try: return json.loads(r.stdout)['hookSpecificOutput']['additionalContext'] if r.stdout.strip() else '' + except (ValueError, KeyError, TypeError): return r.stdout.strip() def bash(repo, command, session=None): - return fire(repo, session or f'b-{command}', 'Bash', {'command': command}) + """a grep run through the shell, with the previous note (AXIOMCODE_GREP_AID=0), like grep()""" + os.environ['AXIOMCODE_GREP_AID'] = '0' + try: return fire(repo, session or f'b-{command}', 'Bash', {'command': command}) + finally: os.environ.pop('AXIOMCODE_GREP_AID', None) def line_of(block, name): @@ -121,6 +141,15 @@ def git(repo, *a): '← 0 resolved, 1 by name' in line_of(g, 'Jobs.nudgeAll'), g) check('a method of a framework-annotated class says which annotation', '← ? framework (@RestController on Jobs)' in line_of(g, 'Jobs.pokeAll'), g) check('control: an undecorated method nothing calls still reads 0', '← 0 →' in line_of(g, 'Plain.unusedCount'), g) + # the grep-aid note: grep shows no caller for these; it says who enters them, and nothing for the plain one + for nm, want in (('listOrders', 'entered by the runtime (http)'), ('sweepStale', 'entered by the runtime (scheduled)'), + ('pokeAll', 'by the framework (@RestController on Jobs)')): + a = grep_aid(repo, nm) + check(f'grep aid: {nm}, which nothing in the code calls, says who enters it', want in a, a) + a = grep_aid(repo, 'unusedCount') + check('grep aid control: a method nothing calls and nothing enters adds nothing (grep already shows no caller)', a == '', a) + a = grep_aid(repo, 'findById') + check('grep aid control: a name grep found complete and unambiguous adds nothing', a == '', a) r = fire(repo, 'r1', 'Read', {'file_path': os.path.join(repo, P + 'Ticker.java')}) check('a Read labels a library override the same way', 'run ←? framework (overrides a library method)' in r, r) check('a method of a class with a library base that it may not override names the base', 'tickAll ←? framework (extends Runnable)' in r, r) diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index a52833a3..22906553 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -23,6 +23,10 @@ python3 tests/hooks_from_path.py """ +# this suite checks WHERE a hook finds its graph; the previous grep note always speaks when it does, so it is the proof. +# The grep-aid note is silent whenever grep was complete, which proves nothing about the lookup. +import os as _os +_os.environ['AXIOMCODE_GREP_AID'] = '0' import json, os, shutil, subprocess, sys, tempfile, time # the directive's once-per-session stamp lives in the temp directory, keyed on the session: a run of its own, or a # second run of this script reuses the first run's session ids and hears nothing From 1568508580431381020688e4badf0f5b059dfd2a Mon Sep 17 00:00:00 2001 From: swapnil Date: Thu, 1 Oct 2026 02:05:28 -0700 Subject: [PATCH 019/133] grep aid: off, false and 0 all switch it off Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/hooks/enrich.py | 2 +- plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 72d36cc9..f99c67cc 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -556,7 +556,7 @@ def lookup(n): if total > len(rows) and out: out[-1] += f" (+{total - len(rows)} more declaration(s){'' if total < 40 else ' or more'})" if unres: out.append(f" ({ctx_names.get(unres[0]['caller_id'], '?')}, which you just read, calls a `{n}` at L{unres[0]['start_line']} whose receiver is not typed — it may be any of the above)") return n, rows, out - if idents and os.environ.get('AXIOMCODE_GREP_AID', '1') != '0': + if idents and os.environ.get('AXIOMCODE_GREP_AID', '1').lower() not in ('0', 'off', 'false'): lines += grep_aid(idents, ev.get('tool_response')) elif idents: with concurrent.futures.ThreadPoolExecutor(max_workers=min(6, len(idents))) as ex: found = list(ex.map(lookup, idents)) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 09574d9c..78559617 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -105,7 +105,7 @@ def render(verb, doc, repo): # `grep -nw NAME` finds: it is listed by location only, and the code goes to the places no text search reaches # (a function passed as a value, a framework or DI registration, an alias). Off with AXIOMCODE_GREP_AID=0. plain = [] - names = target_names(doc) if verb == 'impact' and os.environ.get('AXIOMCODE_GREP_AID', '1') != '0' else [] + names = target_names(doc) if verb == 'impact' and os.environ.get('AXIOMCODE_GREP_AID', '1').lower() not in ('0', 'off', 'false') else [] if names: spelled = lambda p: all(any(re.search(r'(? Date: Thu, 1 Oct 2026 07:05:31 -0700 Subject: [PATCH 020/133] impact: keep the direct callers and every link grep cannot make, each saying what it is Agents on the trial build read the collapsed form as a missing direct section: an implementation listed among grep's confirmed callers, a call across a process boundary folded into one line, 'reached through the ones above' with nothing above. A direct place now drops to the grep line only when it is a plain resolved call, read or write that spells the name, and the first three direct callers keep their code even then; an override, an implementation, an injection, a hand-off as a value or a cross-process call keeps its code and carries its reason in the tag. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_blocks.py | 31 +++++++++++++++---- tests/front_door.py | 8 ++--- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 78559617..127cab8b 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -20,6 +20,8 @@ CAP = 10 # places shown; the rest are counted FAR = 8 # places more than one hop away, named without code +DIRECT_CODE = 3 # direct callers shown with code even when a word grep also finds them +PLAIN_WHY = ('calls it', 'reads it', 'writes it', 'writes/reads it', 'references it', 'instantiates it') WHOLE = 14 # a function this short is shown whole AROUND = 3 # else: its header, then this many lines either side of the line that matters FENCE = {'.py': 'python', '.java': 'java', '.ts': 'typescript', '.tsx': 'tsx', '.js': 'javascript', '.jsx': 'jsx', @@ -107,10 +109,27 @@ def render(verb, doc, repo): plain = [] names = target_names(doc) if verb == 'impact' and os.environ.get('AXIOMCODE_GREP_AID', '1').lower() not in ('0', 'off', 'false') else [] if names: - spelled = lambda p: all(any(re.search(r'(? 8 else '')) other = grep_others(repo, names, {(p['f'], n) for p in plain for n in p['marks']}) if other: out.append(f" {other} other line(s) grep matches for that name are NOT this declaration (another symbol of the same name, or text)") diff --git a/tests/front_door.py b/tests/front_door.py index 58ff997a..c175b99b 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -99,8 +99,8 @@ def main(): rc, out, err = cli(repo, 'find', 'how is the invoice total computed') check('find: numbered places, each with its code in a fenced block', rc == 0 and places(out) and 'def invoice' in out, out[:600] + err[-300:]) rc, out, err = cli(repo, 'impact', 'vat_rate') - check('impact : its caller, which spells the name, by location on the grep line (no code)', rc == 0 and 'grep -nw vat_rate' in out and 'shop/pricing.py:6' in out - and 'return net * (1 + vat_rate())' not in out, out[:600] + err[-300:]) + check('impact : its direct caller as a numbered place with its code', rc == 0 and places(out) and 'shop/pricing.py:6' in out + and 'return net * (1 + vat_rate())' in out, out[:600] + err[-300:]) check('impact : the test that reaches it is one of the places', 'tests/test_pricing.py' in out, out[:800]) rc, out, err = cli(repo, 'path', 'invoice', 'vat_rate') check('path: every hop a numbered place with the code at the call', rc == 0 and places(out) @@ -111,7 +111,7 @@ def main(): first = out.lstrip().split('\n', 1)[0] check('impact with no name: the answer starts with "your edits:" and names the edited declaration', rc == 0 and first.startswith('your edits:') and 'vat_rate' in first, out[:600] + err[-300:]) - check('impact with no name: then what the edit reaches, its grep-visible caller by location', 'shop/pricing.py:6' in out, out[:600]) + check('impact with no name: then what the edit reaches, as places with their code', places(out) and 'shop/pricing.py:6' in out, out[:600]) rc, out, err = cli(repo, 'tests') last = [l for l in out.splitlines() if l.strip()][-1:] or [''] check('tests: the reached test as a numbered place with its code', rc == 0 and places(out) and 'tests/test_pricing.py' in out, out[:600] + err[-300:]) @@ -124,7 +124,7 @@ def main(): check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) check('MCP path answers as numbered places with their code', places(text(3)), text(3)[:600]) - check('MCP impact names the caller grep also finds, by location', 'shop/pricing.py:6' in text(4) and 'grep -nw vat_rate' in text(4), text(4)[:600]) + check('MCP impact answers as numbered places with their code', places(text(4)) and 'shop/pricing.py:6' in text(4), text(4)[:600]) # ── c. controls: the same question anywhere else gets the verb's own answer ────────────────────────────────── r = subprocess.run(['bash', AX, 'impact', 'vat_rate', repo], cwd=repo, capture_output=True, text=True, timeout=600, env=ENV) From 9f127c5378e1c6d961a1ebc717559478a52f1592 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:44:01 -0700 Subject: [PATCH 021/133] test-impact: a helper beside the tests is the route, not a test; far tests listed apart - A JavaScript / TypeScript function is never a test by its name (runners collect the callback handed to test()/it()), and a module-level Python test_* function counts only in a module pytest collects: a helper in test/helpers.js or tests/helpers.py was "1 test" in a file no runner collects. - impact marks each test with loads_change: whether its file (or the fixture / conftest files its runner loads) imports the changed file, through the import closure. - test-impact lists first the files that load the change within 3 hops, then "further out" the rest (a key or registration joins them, or the route is long), with its own run command. - imports_fact resolves an ES module import written with its extension and a workspace package's specifier (package.json exports / main); IMPACT_VERSION 63. - The import-time hop no longer fires from the module that merely declares a const asked about: its readers resolve, and every importer's module was listed again (exposed once .js imports resolved). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_grep.py | 17 +++- .../skills/axiomcode/scripts/axiomcode-impact | 95 ++++++++++++++++++- .../axiomcode/scripts/axiomcode-test-impact | 38 +++++++- .../skills/axiomcode/scripts/dl/impact.dl | 9 +- .../skills/axiomcode/scripts/graph_sql.py | 16 ++++ .../case.json | 18 ++++ .../package.json | 1 + .../packages/topics/index.js | 1 + .../packages/topics/package.json | 1 + .../services/api/package.json | 1 + .../services/api/src/api.js | 3 + .../services/api/test/api.test.js | 6 ++ .../services/producer/package.json | 1 + .../services/producer/src/service.js | 10 ++ .../services/producer/test/service.test.js | 7 ++ .../services/search/package.json | 2 + .../services/search/src/handlers.js | 6 ++ .../services/search/src/index.js | 10 ++ .../services/search/src/indexer.js | 1 + .../services/search/src/pipeline.js | 7 ++ .../services/search/test/helpers.js | 7 ++ .../services/search/test/indexer.test.js | 6 ++ .../services/search/test/pipeline.test.js | 6 ++ .../services/search/test/search.test.js | 6 ++ tests/cases/python/conftest-fixture/case.json | 9 +- tests/cases/python/conftest-fixture/lib.py | 4 + .../python/conftest-fixture/tests/helpers.py | 6 ++ .../conftest-fixture/tests/test_total.py | 5 + 28 files changed, 284 insertions(+), 15 deletions(-) create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/case.json create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/package.json create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/index.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/package.json create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/package.json create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/src/api.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/test/api.test.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/package.json create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/src/service.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/test/service.test.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/package.json create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/handlers.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/index.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/indexer.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/pipeline.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/helpers.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/indexer.test.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/pipeline.test.js create mode 100644 tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/search.test.js create mode 100644 tests/cases/python/conftest-fixture/tests/helpers.py create mode 100644 tests/cases/python/conftest-fixture/tests/test_total.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index 33c4e80b..5e4d5638 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -142,8 +142,13 @@ def more(k, n=1): rest[k] = rest.get(k, 0) + n row = ('module' if mod else 'reached', site(code, r['at'], f"hop {r['hops']}" + (' · module scope' if mod else '') + (' · test' if r.get('test') else '') + stale(r), r['display'])) (modules if mod else rows).append(row) - for r, k in per_test_file(d.get('tests', [])): - rows.append(('test', site(code, r['at'], f"test · {TAG.get(r.get('certainty'), r.get('certainty'))} · hop {r['hops']}" + more_in_file(k) + stale(r), r['display']))) + # a test file that loads the changed file before one that never imports it (impact's loads_change) + far_files = {f for f in {(t.get('at') or '').rpartition(':')[0] for t in d.get('tests', [])} + if all(t.get('loads_change') is False for t in d.get('tests', []) if (t.get('at') or '').rpartition(':')[0] == f)} + near_some = any(t.get('loads_change') is not False for t in d.get('tests', [])) + for r, k in sorted(per_test_file(d.get('tests', [])), key=lambda rk: near_some and rk[0]['at'].rpartition(':')[0] in far_files): + far = ' · further out: never imports the change' if near_some and r['at'].rpartition(':')[0] in far_files else '' + rows.append(('test', site(code, r['at'], f"test · {TAG.get(r.get('certainty'), r.get('certainty'))} · hop {r['hops']}" + far + more_in_file(k) + stale(r), r['display']))) for r in d.get('stub_tests', []): rows.append(('stub', site(code, r['at'], f"test · stubs it{stale(r)}", r['display']))) # a test file's top level is already its test row above @@ -238,8 +243,13 @@ def test_impact(d, code): rows = [] for f in d.get('edited_test_files', []): rows.append(('test', f"{f}:1: (edited test file) [test · edited]")) + # the tests that load the changed file first; a file further out (it never imports the change, or reaches it only + # many hops away) after them, said so + further = set(d.get('further_test_files') or []) for t, k in per_test_file(d.get('tests', [])): - rows.append(('test', ev(site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{more_in_file(k)}{stale(t)}", t['display']), t))) + far = (' · further out: never imports the change' if t.get('loads_change') is False else ' · further out') \ + if (t.get('at') or '').rpartition(':')[0] in further else '' + rows.append(('test', ev(site(code, t['at'], f"test · {TAG.get(t.get('certainty'), t.get('certainty'))} · hop {t['hops']}{far}{more_in_file(k)}{stale(t)}", t['display']), t))) # a changed file no graph follows (a script, a fixture) is run by the test files that name it in their text names = {} for f, v in (d.get('named_in_test_text') or {}).items(): @@ -249,6 +259,7 @@ def test_impact(d, code): rows.append(('text test', f"{t}:1: (names {', '.join(dict.fromkeys(ns))}) [test · text]")) foot = ev_foot(d) if d.get('command'): foot.append(f"run: {d['command']}") + if d.get('command_further'): foot.append(f"run: (then, further out) {d['command_further']}") if d.get('bound'): foot.append(f"bound: {d['bound']}") return rows, {}, foot diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index c4697456..a7fd4f7a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -827,14 +827,56 @@ class Impact: # member of it, so every prefix is tried, longest first. parts = cand.split('.') tgt = next((mod[p] for p in ('.'.join(parts[:k]) for k in range(len(parts), 0, -1)) if p in mod), None) + elif raw.startswith('.'): + tgt = self._js_file(os.path.normpath(os.path.join(os.path.dirname(f), raw)), byfile) else: - if not raw.startswith('.'): continue # a package, not a file in this repository - base = os.path.normpath(os.path.join(os.path.dirname(f), raw)) - tgt = next((c for c in (base + e for e in ('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', - '/index.ts', '/index.tsx', '/index.js')) if c in byfile), None) + tgt = self._workspace_import(raw, byfile) # a package of this repository (a workspace), else none if tgt and tgt != f: out.add((f, tgt)) return sorted(out) + JS_RESOLVE = ('', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '/index.ts', '/index.tsx', '/index.js', '/index.mjs') + + def _js_file(self, base, byfile): + # an ES module names the file with its extension (`./a.js`), and TypeScript writes `./a.js` for `./a.ts` + stems = [base] + ([base[:-len(x)] for x in ('.js', '.mjs', '.cjs', '.jsx') if base.endswith(x)][:1]) + return next((c for s in stems for c in (s + e for e in self.JS_RESOLVE) if c in byfile), None) + + def _workspace_import(self, spec, byfile): + """the file a bare specifier names when it is a PACKAGE OF THIS REPOSITORY (a monorepo workspace: `@acme/bus`, + `@acme/bus/kafka`), read the way node reads it: the package.json `exports` entry for the subpath, else `main` / + `module`, else its index. A package installed from a registry has no package.json here and names no file.""" + if not hasattr(self, '_pkgs'): + self._pkgs = {} + for d in sorted({os.path.dirname(f) for f in byfile}): + while d and d not in self._pkgs: + try: + with open(os.path.join(self.g.repo, d, 'package.json'), encoding='utf-8', errors='replace') as h: pj = json.load(h) + except (OSError, ValueError): + pj = None + self._pkgs[d] = pj if isinstance(pj, dict) and isinstance(pj.get('name'), str) else None + d = os.path.dirname(d) + self._pkg_by_name = {pj['name']: (d, pj) for d, pj in sorted(self._pkgs.items()) if pj} + name = '/'.join(spec.split('/')[:2 if spec.startswith('@') else 1]) + if name not in self._pkg_by_name: return None + d, pj = self._pkg_by_name[name] + sub = '.' + spec[len(name):] + def pick(v): # a conditional export: the first condition that names a path + if isinstance(v, str): return v + if isinstance(v, dict): + for k in ('import', 'node', 'require', 'default', *v.keys()): + p = pick(v.get(k)) if k in v else None + if p: return p + return None + ex = pj.get('exports') + if isinstance(ex, dict) and any(k.startswith('.') for k in ex): + entry = pick(ex.get(sub)) + elif sub == '.': + entry = pick(ex) or pj.get('module') or pj.get('main') or 'index' + else: + entry = sub + if not entry: return None + return self._js_file(os.path.normpath(os.path.join(d, entry)), byfile) + def _nonsource(self): """the non-source scan, cached per graph (ax_nonsource.py): a query opens only the files that changed since the last one read them, and searches only those whose words include a name asked for. AXIOMCODE_NO_SCAN_CACHE=1 @@ -1269,7 +1311,7 @@ class Impact: W('cs_fixture_type', sorted(x for x in fixt if not x[0].startswith('collection:'))) # ── facts: the graph, exported once (reused while graph.sqlite is unchanged) ──────────────────────────────── - IMPACT_VERSION = '62' # 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key + IMPACT_VERSION = '63' # 63: imports_fact resolves an import written with its extension and a workspace package's specifier, and test_method takes no JavaScript / TypeScript helper by its name; 62: state_gate, state_gate_alloc, state_call_alloc, state_call_open, state_world, the callbacks one instance was given and the allocation each caller's receiver may be (JavaScript instance-state.dl); 61: filter_run, a servlet filter a test's context holds (added to HttpSecurity in a configuration the test loads, or a component) and the test methods that send a request through it; 57: a TypeScript object literal key is a ref of entity kind OBJECT_PROPERTY_KEY, kept past a bound access on its line; 56: reg_key_fact carries a handler table's entries (kind table), literal a table key written as a dotted string or through a constant, and test_code; 55: cs_data_source, cs_data_type, cs_fixture_type, the C# test links a runner makes from a data attribute or a class/collection fixture (#1498, #1499); 53: implicit_new, the type a C# `new T()` constructs where T writes no constructor (#1473); 52: test_method holds a method under a composed or derived test marker declared in the repository (a Java annotation meta-annotated @Test, a C# attribute derived from FactAttribute: #1418, #1497; 51 was the C# test-links branch's number, landed as 55); 48: sigtype, a parameter / return position type_use resolves to a type, read before the textuse grep (#1422), and persist_field, the properties a persistence query reads (#1461); 47: test_method / fixture from graph_sql's one classification (a tear-down such as @AfterEach or [TestCleanup] is a fixture, [TestInitialize] is no test, an @Override is no named test: #1417 #1419 #1502), and reg_key_fact drops a string a decoration does not register under (#1413); 46: accessor carries the wither and an isX boolean's setX / withX, and a generated builder or fluent setter the engine resolved is a writer (#1404, #1409); 45: runs_before, a C# set-up an NUnit [SetUpFixture] or an MSTest assembly initializer runs for tests outside its type (#1501), stub rows for a member a Moq Protected() setup names by string (#1540), cs_config_literal for a Section:Key configuration key (#1443), and lex_parent puts a lambda under the declaration on its own line (#1556); 44: a C# MEMBER_ACCESS ref is qualified, so its qualifier decides (#1445); 41: spawns_fact, a test that runs a script by its path (ax_spawn.py); 40: test_method holds a script test's module (a test-tree file run as a program, no framework: graph_sql.script_tests); 39: a chained route link's `calls` row and `registration` label sit on the link's own line, with its own verb and path; 38: reg_key_fact drops a decoration string with a space in it (a description, not a key); 37: via_base / via_site, a caller that reaches a declaration through a base it is override-equivalent to (#1542), and injected_bean, the bean an injection point was wired to (#1384); 36: handoff_at, route_arg, callable_const, init_wrapper, init_alias, returns_fn — a const holding a wrapped handler registered at a route; 35: 0.1.5's 33 (#1598, the defines edges the path export links) joined 0.1.6's 33, two different fact sets under one number; 33 (0.1.6): calls carries the tier "stub" for a call inside a mock's stub or verification, reg_verb / lit_verb join a route by its HTTP method, and a handler's route joins its type's prefix; 32: cert_tier's why is worded per tier (an event_dispatch row says it sends the request or event), and the route facts #1633 changed (#1510), which merged without a bump; 31: event_dispatch edges (a published event reaches its listeners, #1391) and the pytest fixture_injection reading (#1631) change impact's facts; 30: registers, a bean another class's annotation registers (#1396, #1414); 29: the edges it links from the path export changed (#1402), and a cache written before it must not survive; 28: reexport_from, the file an `export *` line re-exports; 27: framework, the engine's framework_edge (#1509); 24: the test* naming convention requires a test class as owner (#1181); 23: owner/member disambiguated by file, two classes of one name no longer merging (#1188); 22: lex_parent, the innermost declaration enclosing each one by span (#1183); 21: cert_tier, the tier -> certainty table the call rules join on (#1131); 20: faccess, the engine's resolved field accesses (#1071); 3: decl_file facts (the import-time test-file rule); 14: the registration-key # layer; 15: the registration facts (two 14s landed independently, which is exactly the collision this # guards); 16: regsite folded into ax_registration's reg_key_fact; 20: implements_pair (#1011); 17/18: the tagged-template test registrar # (it.each`…`) and its table span @@ -2736,6 +2778,43 @@ def main(argv): # framework's, not an edge, which is why `chains` (verified hop by hop below) keeps the test's own chain. def test_route(m, fx): return [m] + I.chain(fx, parent) if fx else chains.get(m, []) + # DOES THE TEST LOAD THE CHANGE? A test runs code in its own process only once its file, or the fixture's file the + # runner loads for it (and in Python the conftest.py files above it), imports the changed file, directly or through + # other files. A route that never does -- a key join from a test that only writes the topic string, a dispatch + # resolved to a table in a service the test never starts -- is a lead further out, not a test of the change: on + # one monorepo 19 test files were selected for a handler table's module and 2 of them import it. None where the + # language has no import graph here (imports_fact covers Python, JavaScript and TypeScript). + _rev = None; _importers = {} + def loads_change(m, fx): + nonlocal _rev + tf = g.sym[m].get('file') or '' + route = test_route(m, fx) + seed_file = (g.sym.get(route[-1]) or {}).get('file') if route else None + if not tf or not seed_file or os.path.splitext(tf)[1].lower() not in I.IMPORT_RE: return None + if tf == seed_file or test_cert.get(m) in ('spawns', 'at import'): return True + if _rev is None: + _rev = collections.defaultdict(set) + try: + for ln in open(os.path.join(g.facts, 'impact', 'imports_fact.facts'), encoding='utf-8', errors='replace'): + a, _, b = ln.rstrip('\n').partition('\t') + if a and b: _rev[b].add(a) + except OSError: + _rev = {} # no import facts: nothing can be said either way + if not _rev: return None + if seed_file not in _importers: + seen, todo = {seed_file}, [seed_file] + while todo: + for a in _rev.get(todo.pop(), ()): + if a not in seen: seen.add(a); todo.append(a) + _importers[seed_file] = seen + loaders = {tf} | ({g.sym[fx].get('file')} if fx and fx in g.sym else set()) + if tf.endswith('.py'): + d_ = os.path.dirname(tf) + while True: + loaders.add(f"{d_}/conftest.py" if d_ else 'conftest.py') + if not d_: break + d_ = os.path.dirname(d_) + return bool(loaders & _importers[seed_file]) # a MODULE counted as a test is a script test (graph_sql.script_tests): it is labelled with the command it runs by script_ids = {m for m in tests if g.sym[m]['kind'] == 'module'} _runs = {} @@ -2959,6 +3038,7 @@ def main(argv): 'alongside': [{'id': c, 'display': g.disp(c), 'role': 'co-located', 'why': why, 'reasons': direct_reasons.get((c, _grp(role)), []), 'certainty': cert, 'at': loc, 'for': sorted(direct_for[(c, _grp(role))])} for c, role, why, cert, loc, n in sorted(D, key=lambda x: (g.disp(x[0]), x[2], x[4], x[0])) if cert == 'alongside'], 'reached': [{'id': m, 'display': g.disp(m), 'hops': d, 'for': sorted(reach_from[m]), 'at': g.loc(m), 'test': bool(g.sym[m]['is_test'])} for m, d in sorted(reached.items(), key=lambda x: (x[1], g.disp(x[0]), g.loc(x[0]), x[0]))], 'tests': [{'id': m, 'display': g.disp(m), 'owner': g.sym[m]['owner'], 'name': g.sym[m]['name'], 'hops': d, 'via': g.disp(fx) if fx else None, 'at': g.loc(m), 'certainty': test_cert.get(m), 'chain': [g.disp(x) for x in test_route(m, fx)], + **({'loads_change': lc} if (lc := loads_change(m, fx)) is not None else {}), **({'script': True, 'run': script_cmd(m)} if m in script_ids else {})} for m, (d, fx) in sorted(tests.items(), key=lambda kv: (kv[1][0], g.disp(kv[0]), g.loc(kv[0]), kv[0]))], 'framework_entries': [{'id': m, 'display': g.disp(m), 'signal': sig, 'at': g.loc(m)} for m, sig in fw_ent], 'framework_grep': fw_grep, @@ -3334,6 +3414,11 @@ def main(argv): # a callable in a file whose test is counted runs when that file does (`main` under a script's main guard, the # describe block around a counted `it`): the count already credits it test_rows = [m for m in test_rows if g.sym[m]['file'] not in tfiles] + # a helper beside the tests (test/helpers.js, tests/helpers.py) that a counted test calls on its way to the change is + # that test's route, not an uncredited test: the count already runs it + if test_rows: + on_route = {x for t, v in tests.items() for x in test_route(t, v[1] if isinstance(v, tuple) and len(v) > 1 else None)} + test_rows = [m for m in test_rows if m not in on_route] if test_rows: print(f" {'and ' if tests else 'but '}{len(test_rows)} callable(s) in test code listed above reach it by a route this count does not credit " "(a runner entry it does not collect, a remote or framework hop) — treat them as tests to run: " diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact index d086a90d..29e9f97e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-test-impact @@ -62,6 +62,8 @@ is not the same as a change that does nothing, and the rung on each row is where import importlib.machinery, importlib.util, json, os, re, subprocess, sys, collections, functools, posixpath, shlex TESTY = re.compile(r'(^|[/_.-])(test|tests|spec|specs|__tests__|it)([/_.-]|$)', re.I) +# a test that loads the change and reaches it within this many hops is listed first; beyond it, further out +NEAR_HOPS = 3 HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) @@ -2034,6 +2036,19 @@ def main(argv): case_data_tests = {i for i, r in tests.items() if r.get('at') and ax_caserun.in_case_dir(real, r['at'].split(':')[0])} for i in case_data_tests: tests.pop(i, None) files = sorted({r.get('at', '').split(':')[0] for r in tests.values() if r.get('at')}) + # NEAREST FIRST, AND FURTHER OUT APART: a test whose file (or the fixture file its runner loads) never imports the + # changed file reaches it only through a key join, a dispatch resolved across services, or a registration the + # process it runs in never makes (impact's loads_change). Still listed, after the tests that load the change. + # One that loads it but reaches it only beyond NEAR_HOPS (a whole service started for an unrelated flow, whose + # route crosses a bus and a handler table) is further out too: impact's own measurement puts a sound route within + # 3 hops at about 9 in 10 right, and a hub answer at 3 in 10. Where the import graph is unknown (no loads_change), + # nothing moves. + near = lambda r: r.get('loads_change') is None or (r['loads_change'] and (r.get('hops') or 0) <= NEAR_HOPS) + further = sorted({f for f in files} - {(r.get('at') or '').split(':')[0] for r in tests.values() if near(r)}) + if further and len(further) < len(files): + files = [f for f in files if f not in set(further)] + else: + further = [] # nothing loads it, or everything does: one list, as before # A selected test carrying no location drops out of `files`, and `files` is what the command PRINTS and # what the run command is built from — so the answer names it nowhere and the suite runs without it. # This is the third instance of one bug: a row discarded for being malformed in one field. The other two @@ -2060,13 +2075,18 @@ def main(argv): if classes and lang in ('java', 'csharp') else classes replaced = concrete_test_classes(db, run_classes)[1] if lang in ('java', 'csharp') else {} cmd_all = command_for(lang, run_files, run_classes + ([os.path.splitext(os.path.basename(f))[0] for f in edited] if classes else []), db, repo) + cmd_further = command_for(lang, further, [], db, repo) if further else None + in_file = lambda f: sum(1 for r in tests.values() if r.get('at', '').startswith(f + ':')) import ax_evidence if as_json: print(json.dumps(ax_evidence.annotate('test-impact', {'changed': [{'symbol': by_target[t]['symbol'], 'kind': by_target[t]['kind'], 'target': t} for t in targets], 'test_files': files, 'test_classes': classes, - 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in tests.items()], + 'further_test_files': further, 'command_further': cmd_further, + # the tests that load the change first, then the ones further out, each nearest first + 'tests': [dict(r, pulled_in_by=sorted(pulled[i])) for i, r in + sorted(tests.items(), key=lambda kv: (kv[1].get('at', '').split(':')[0] in further,))], 'edited_test_files': edited, 'command': cmd_all, 'abstract_replaced': replaced, @@ -2141,12 +2161,20 @@ def main(argv): print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) elif tests: - print(f"\ntests to run: {len(tests)} test(s) in {len(files)} file(s)") + print(f"\ntests to run: {sum(in_file(f) for f in files)} test(s) in {len(files)} file(s)" + + (" that load the changed file" if further else "")) for f in files[:limit]: - n = sum(1 for r in tests.values() if r.get('at', '').startswith(f + ':')) - print(f" {f} ({n} test(s))") + print(f" {f} ({in_file(f)} test(s))") if len(files) > limit: print(f" … +{len(files)-limit} more file(s) (--limit N)") + if further: + print(f"\nfurther out: {sum(in_file(f) for f in further)} test(s) in {len(further)} file(s) whose file never imports the" + f" changed one (a key, a dispatch or a registration joins them to it) or reaches it only beyond {NEAR_HOPS} hops;" + " run after the ones above:") + for f in further[:limit]: + print(f" {f} ({in_file(f)} test(s))") + if len(further) > limit: + print(f" … +{len(further)-limit} more file(s) (--limit N)") if why: print("\n why each one is here:") for i, r in sorted(tests.items(), key=lambda kv: (kv[1].get('hops') or 99, kv[1].get('display', '')))[:limit]: @@ -2167,6 +2195,8 @@ def main(argv): if cmd_all: print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) + if cmd_further: + print("\n then, further out: " + cmd_further.replace("\n", "\n ")) elif edited and cmd_all: print("\n " + cmd_all.replace("\n", "\n ")) print_unrun(lang, repo, run_files) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 83d418e3..f8d70e36 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -943,9 +943,14 @@ up_running(q, a, d+1) :- up_running(q, b, d), edge(a, b, "known_edge"), !state_g up_running_g(q, a, t, f, d+1) :- up_running(q, f, d), state_gate(a, f, t), edge(a, f, "known_edge"), d < 40. up_running_g(q, c, t, f, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_world(c, t), d < 40. up_running(q, c, d+1) :- up_running_g(q, a, t, f, d), edge(c, a, "known_edge"), state_exit(c, a, t, f), d < 40. +// A module-level CONST asked about is reached by its module body only because that body declares it: its value is +// read by the callables that import it (resolved reads, listed as such), and importing the module runs nothing new +// for them. Hopping on from that module named every importer's module as broken at import, the readers again. +.decl declares_seed_const(q:symbol, mod:symbol) +declares_seed_const(q, mod) :- target(q, "field", fl, _), field(fl, mod, _, _, _), kind(mod, "module"). .decl import_hop(q:symbol, a:symbol, b:symbol) -import_hop(q, a, mod) :- up_running(q, mod, _), kind(mod, "module"), decl_file(mod, g), imports_file(f, g), - decl_file(a, f), kind(a, "module"), a != mod. +import_hop(q, a, mod) :- up_running(q, mod, _), kind(mod, "module"), !declares_seed_const(q, mod), decl_file(mod, g), + imports_file(f, g), decl_file(a, f), kind(a, "module"), a != mod. up(q, a, d+1) :- up_running(q, b, d), import_hop(q, a, b), d < 40. parent_up(q, a, b, "at import") :- reach(q, a, d), d > 0, reach(q, b, d1), d1 = d - 1, import_hop(q, a, b). .output reach diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 538ca7e0..db0458a3 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -685,6 +685,21 @@ def parent_up(edges, depth): # a file the runner imports for its fixtures and hooks and never collects tests from NON_TEST_FILE = _re.compile(r'(^|/)conftest\.py$') _RET_TYPE = _re.compile(r'\)\s*:\s*(.+)$') +# NO RUNNER OF THESE LANGUAGES COLLECTS A FUNCTION BY ITS NAME: node:test, jest, vitest and mocha run the callback handed +# to test(…) / it(…), which the registrar rule finds on the declaration's own line. Read by name, a helper beside the +# tests (`export function testApp()` in test/harness.js) was a test, counted as "1 test" in a file no runner collects. +_JS_FILE = _re.compile(r'\.(?:[cm]?[jt]sx?)$') +# a module-level Python function is collected only from a file the runner collects (pytest's python_files, unittest's +# test*.py): a `def test_client()` in tests/helpers.py is a helper the tests import +_PY_COLLECTED = _re.compile(r'(^|/)(test[^/]*|[^/]*_tests?)\.py$') + + +def _module_level_helper(name, owner, file): + """a callable named like a test that no runner of its language collects by that name""" + f = file or '' + if _JS_FILE.search(f): return True + if f.endswith('.py') and not owner: return not _PY_COLLECTED.search(f) or not (name or '').startswith('test') + return False def _short_decoration(d): @@ -708,6 +723,7 @@ def named_test(name, decs, owner, file, signature): if not (name or '').startswith(('test', 'it')): return False if any(NON_TEST_DECOR.match(_short_decoration(d)) for d in decs or ()): return False if NON_TEST_FILE.search(file or ''): return False + if _module_level_helper(name, owner, file): return False own = (owner or '').split('.')[-1] if own and not TEST_OWNER.search(own): return False # JUnit 3 reads the convention on `public void testX()`. A method that DECLARES a return diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/case.json b/tests/cases/javascript/a-helper-is-the-route-not-the-test/case.json new file mode 100644 index 00000000..d429fc0a --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/case.json @@ -0,0 +1,18 @@ +{"lang": "javascript", "src": ".", + "checks": [ + {"why": "a helper beside the tests (test/helpers.js, a function named like a test) is the route to the change, not a test: no runner collects it, so the test that calls it is listed and the helper is not", + "run": ["test-impact", "{repo}", "services/search/src/indexer.js"], + "want": ["services/search/test/search.test.js (1 test(s))", "services/search/test/indexer.test.js (1 test(s))"], + "avoid": ["helpers.js (", "helpers.js)"]}, + {"why": "a test whose file never imports the changed one (joined to it only by a topic key and a handler table) is listed apart, after the tests that load it, with its own command", + "run": ["test-impact", "{repo}", "services/search/src/indexer.js"], + "want": ["tests to run: 3 test(s) in 3 file(s) that load the changed file", "further out: 2 test(s) in 2 file(s)", "then, further out: (cd services/producer && node --test test/service.test.js)"]}, + {"why": "a test that loads the change but reaches it only beyond 3 hops (a pipeline of four calls) is further out too; CONTROL: a test that imports the change through a workspace package's export subpath (@demo/search/indexer) loads it, and stays with the near tests", + "run": ["test-impact", "{repo}", "services/search/src/indexer.js", "--json"], + "want": ["\"further_test_files\": [\n \"services/producer/test/service.test.js\",\n \"services/search/test/pipeline.test.js\"\n ]"], + "avoid": ["\"services/api/test/api.test.js\"\n ],\n \"command_further\""]}, + {"why": "CONTROL: impact still counts every test that reaches the change, the key-joined one included", + "run": ["impact", "index", "--tests-only"], + "want": ["services/producer/test/service.test.js (1)", "services/api/test/api.test.js (1)", "services/search/test/search.test.js (1)"], + "avoid": ["treat them as tests to run"]} + ]} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/package.json b/tests/cases/javascript/a-helper-is-the-route-not-the-test/package.json new file mode 100644 index 00000000..d0426ba5 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/package.json @@ -0,0 +1 @@ +{"name": "demo", "private": true, "workspaces": ["packages/*", "services/*"]} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/index.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/index.js new file mode 100644 index 00000000..0ea6e071 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/index.js @@ -0,0 +1 @@ +export const TOPICS = Object.freeze({ CREATED: 'doc.created' }); diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/package.json b/tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/package.json new file mode 100644 index 00000000..968e6c38 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/packages/topics/package.json @@ -0,0 +1 @@ +{"name": "@demo/topics", "type": "module", "main": "index.js"} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/package.json b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/package.json new file mode 100644 index 00000000..acdbe729 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/package.json @@ -0,0 +1 @@ +{"name": "@demo/api", "type": "module", "main": "src/api.js", "scripts": {"test": "node --test test/*.test.js"}} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/src/api.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/src/api.js new file mode 100644 index 00000000..2e1d5584 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/src/api.js @@ -0,0 +1,3 @@ +import { index } from '@demo/search/indexer'; + +export function reindex(doc) { return index({ payload: doc }); } diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/test/api.test.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/test/api.test.js new file mode 100644 index 00000000..dffd5d01 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/api/test/api.test.js @@ -0,0 +1,6 @@ +import { test } from 'node:test'; +import { reindex } from '@demo/api'; + +test('reindex goes through the search package', () => { + reindex({ id: 'd1' }); +}); diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/package.json b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/package.json new file mode 100644 index 00000000..f540c122 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/package.json @@ -0,0 +1 @@ +{"name": "@demo/producer", "type": "module", "main": "src/service.js", "scripts": {"test": "node --test test/*.test.js"}} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/src/service.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/src/service.js new file mode 100644 index 00000000..98543c0c --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/src/service.js @@ -0,0 +1,10 @@ +import { TOPICS } from '@demo/topics'; + +export class DocService { + constructor(bus) { this.bus = bus; } + + create(doc) { + this.bus.publish(TOPICS.CREATED, doc); + return doc; + } +} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/test/service.test.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/test/service.test.js new file mode 100644 index 00000000..6ef201c1 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/producer/test/service.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { DocService } from '../src/service.js'; + +test('create publishes the created event', () => { + const sent = []; + new DocService({ publish: (type, doc) => sent.push([type, doc]) }).create({ id: 'd1' }); +}); diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/package.json b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/package.json new file mode 100644 index 00000000..791f28c1 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/package.json @@ -0,0 +1,2 @@ +{"name": "@demo/search", "type": "module", "exports": {".": "./src/index.js", "./indexer": "./src/indexer.js"}, + "scripts": {"test": "node --test test/*.test.js"}} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/handlers.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/handlers.js new file mode 100644 index 00000000..69a88e46 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/handlers.js @@ -0,0 +1,6 @@ +import { TOPICS } from '@demo/topics'; +import { index } from './indexer.js'; + +export const handlers = { + [TOPICS.CREATED]: async (env) => index(env), +}; diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/index.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/index.js new file mode 100644 index 00000000..111c5894 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/index.js @@ -0,0 +1,10 @@ +import { handlers } from './handlers.js'; + +export async function consume(table, msg) { + const handler = table[msg.type]; + if (handler) return handler(msg); +} + +export function start() { + return (msg) => consume(handlers, msg); +} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/indexer.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/indexer.js new file mode 100644 index 00000000..917635b5 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/indexer.js @@ -0,0 +1 @@ +export function index(env) { return env.payload; } diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/pipeline.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/pipeline.js new file mode 100644 index 00000000..e75f8af5 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/src/pipeline.js @@ -0,0 +1,7 @@ +import { index } from './indexer.js'; + +function store(env) { return index(env); } +function enrich(env) { return store(env); } +function validate(env) { return enrich(env); } + +export function ingest(env) { return validate(env); } diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/helpers.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/helpers.js new file mode 100644 index 00000000..e5984401 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/helpers.js @@ -0,0 +1,7 @@ +import { start } from '../src/index.js'; + +// a helper the tests call, named like a test: no runner collects it +export function testApp() { + const app = start(); + return app({ type: 'doc.created', payload: 1 }); +} diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/indexer.test.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/indexer.test.js new file mode 100644 index 00000000..4d47fbc1 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/indexer.test.js @@ -0,0 +1,6 @@ +import { test } from 'node:test'; +import { index } from '../src/indexer.js'; + +test('index returns the payload', () => { + index({ payload: 1 }); +}); diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/pipeline.test.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/pipeline.test.js new file mode 100644 index 00000000..708c9413 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/pipeline.test.js @@ -0,0 +1,6 @@ +import { test } from 'node:test'; +import { ingest } from '../src/pipeline.js'; + +test('ingest walks the whole pipeline', () => { + ingest({ payload: 1 }); +}); diff --git a/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/search.test.js b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/search.test.js new file mode 100644 index 00000000..8021bfa3 --- /dev/null +++ b/tests/cases/javascript/a-helper-is-the-route-not-the-test/services/search/test/search.test.js @@ -0,0 +1,6 @@ +import { test } from 'node:test'; +import { testApp } from './helpers.js'; + +test('a created document is indexed through the app', async () => { + await testApp(); +}); diff --git a/tests/cases/python/conftest-fixture/case.json b/tests/cases/python/conftest-fixture/case.json index e334c16a..6a32efee 100644 --- a/tests/cases/python/conftest-fixture/case.json +++ b/tests/cases/python/conftest-fixture/case.json @@ -6,4 +6,11 @@ "avoid": ["test_other.py"]}, {"why": "an autouse fixture runs before every test in its scope, named by nothing at all", "run": ["impact", "reset", "--tests"], - "want": ["test_widget.py", "test_other.py"]}]} + "want": ["test_widget.py", "test_other.py"]}, + {"why": "a module-level function named like a test in a module pytest does not collect (tests/helpers.py) is the route to the change, not a test: the test that calls it is selected, the helper is not", + "run": ["impact", "total", "--tests"], + "want": ["tests: 1 of 3 test method(s)", "test_total.py"], + "avoid": ["treat them as tests to run"]}, + {"why": "CONTROL: a module-level test_ function in a collected test module is still a test", + "run": ["impact", "reset", "--tests"], + "want": ["test_other_is_true"]}]} diff --git a/tests/cases/python/conftest-fixture/lib.py b/tests/cases/python/conftest-fixture/lib.py index 1318ec7f..ce7b9165 100644 --- a/tests/cases/python/conftest-fixture/lib.py +++ b/tests/cases/python/conftest-fixture/lib.py @@ -4,3 +4,7 @@ def make_widget(): def reset(): return 0 + + +def total(): + return 2 diff --git a/tests/cases/python/conftest-fixture/tests/helpers.py b/tests/cases/python/conftest-fixture/tests/helpers.py new file mode 100644 index 00000000..cfaf3034 --- /dev/null +++ b/tests/cases/python/conftest-fixture/tests/helpers.py @@ -0,0 +1,6 @@ +from lib import total + + +# a helper the tests import, named like a test: pytest collects nothing from helpers.py +def testing_app(): + return total() diff --git a/tests/cases/python/conftest-fixture/tests/test_total.py b/tests/cases/python/conftest-fixture/tests/test_total.py new file mode 100644 index 00000000..e87039f3 --- /dev/null +++ b/tests/cases/python/conftest-fixture/tests/test_total.py @@ -0,0 +1,5 @@ +from helpers import testing_app + + +def test_total_is_two(): + assert testing_app() == 2 From 8dfdb4d86474e5f7893b1e64d9c3ec1c40e61df6 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:33:28 -0700 Subject: [PATCH 022/133] changed: a declaration longer or gone in the base than in the graph's text keeps its own lines - a span carried from a later graph text onto the base runs on over the lines only the base has while they are still inside it (braces open, or deeper indentation): statements a later text deleted were charged to the method below or dropped - a deleted field line the graph never held is named as that field, removed, not an anonymous 'inside ' - a range read with the graph at its newer side turns 'added' into 'removed', like 'removed' into 'added' Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../axiomcode/scripts/axiomcode-changed | 40 +++++++++++++++++-- .../case.json | 10 +++++ .../later.java | 22 ++++++++++ .../grown-file-read-against-its-base/new.java | 22 ++++++++++ .../grown-file-read-against-its-base/old.java | 26 ++++++++++++ .../src/pkg/Writer.java | 22 ++++++++++ .../case.json | 10 ++++- .../later-writer.py | 11 +++++ .../new-writer.py | 11 +++++ .../old-writer.py | 15 +++++++ .../src/writer.py | 11 +++++ 11 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 tests/cases/java/grown-file-read-against-its-base/case.json create mode 100644 tests/cases/java/grown-file-read-against-its-base/later.java create mode 100644 tests/cases/java/grown-file-read-against-its-base/new.java create mode 100644 tests/cases/java/grown-file-read-against-its-base/old.java create mode 100644 tests/cases/java/grown-file-read-against-its-base/src/pkg/Writer.java create mode 100644 tests/cases/python/grown-file-read-against-its-base/later-writer.py create mode 100644 tests/cases/python/grown-file-read-against-its-base/new-writer.py create mode 100644 tests/cases/python/grown-file-read-against-its-base/old-writer.py create mode 100644 tests/cases/python/grown-file-read-against-its-base/src/writer.py diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed index 2800a20c..c367928e 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-changed @@ -365,6 +365,27 @@ class Changed: elif tag == 'replace': near[j1 + k_ + 1] = i1 + min(k_ + 1, i2 - i1) # a rewritten line: the line in its position else: near[j1 + k_ + 1] = max(1, i1) # a line this text lacks: the one before it taken = {exact[a] for a, b, i, k, d, n in spans if a in exact} + # A DECLARATION CAN BE LONGER IN THIS TEXT THAN IN THE GRAPH'S. Its end is carried to the line the graph's end + # line became, so the lines this text has after it and the graph's text does not (statements a later text + # deleted) fell outside it: removed from a method, they were charged to the declaration below or to nothing. + # The span runs on over lines only this text has while they are still inside it: its braces not yet closed, or + # (Python) indented deeper than its header + mine = set(exact.values()) + def grow(a2, b2, k): + if k not in ('method', 'function', 'constructor', 'class', 'interface', 'enum', 'type') or b2 >= len(OL): return b2 + if is_py: + ind = len(OL[a2 - 1]) - len(OL[a2 - 1].lstrip()); x = b2 + while x < len(OL) and x + 1 not in mine and (not OL[x].strip() or len(OL[x]) - len(OL[x].lstrip()) > ind): + x += 1 + if OL[x - 1].strip(): b2 = x + return b2 + depth = sum(OS[x - 1].count('{') - OS[x - 1].count('}') for x in range(a2, b2 + 1)) + if depth <= 0 or '{' not in ''.join(OS[a2 - 1:b2]): return b2 + x = b2 + while x < len(OL) and x + 1 not in mine: + x += 1; depth += OS[x - 1].count('{') - OS[x - 1].count('}') + if depth <= 0: return x + return b2 def renamed_line(a, d, n, k): """A HEADER THE GRAPH HOLDS UNDER A NEW NAME is the same declaration when this text's line in its place declares another name with the same parameters (a rename the refresh already indexed). Dropped as "not declared here", @@ -388,7 +409,7 @@ class Changed: b2 = a2 + (b - a) if exact.get(b) is None else exact[b] else: b2 = exact.get(b) or near.get(b) or a2 + (b - a) - out.append((a2, max(a2, b2), i, k, d, n)) + out.append((a2, grow(a2, max(a2, b2), k), i, k, d, n)) out.sort(); return out def rel(self, f): f = os.path.realpath(f) if os.path.exists(f) else f @@ -797,7 +818,7 @@ class Changed: for a, b, i, k, d, n in decls: if ln < a <= ln + 3 and all(not L[x - 1].strip() or L[x - 1].strip().startswith('@') for x in range(ln + 1, a)): return (a, b, i, k, d, n) return None - decorated = {}; stmt_pair = {} + decorated = {}; stmt_pair = {}; gone_fields = [] OK = strip_code(old, strings=False, hash_comments=is_py).split('\n'); NK = strip_code(new, strings=False, hash_comments=is_py).split('\n') # A LINE INSERTED INTO A HEADER CHANGES THE SIGNATURE. A parameter list written one parameter a line gains a # parameter as an inserted line, which no old line was changed for; charged to the callable as an insertion it @@ -866,12 +887,20 @@ class Changed: elif m and m[3] != 'module' and m[0] <= ln <= self.header_end(OL, m[0]): key = ('signature', m) elif m: key = ('body', m) elif t and t[0] <= ln <= self.header_end(OL, t[0]): key = ('type', t) + # A FIELD THE GRAPH NEVER HELD is still named by its line. Read against a graph from a later text (a range, a + # refresh), a field that text no longer declares has no span, and its deleted line came back as an anonymous + # "inside ": the mirror of a new field, named from its inserted line (added) + elif t and not is_py and (fp := self.field_parts(OS[ln - 1]))[0] and OS[ln - 1].rstrip().endswith(';') \ + and '(' not in OS[ln - 1].split('=')[0] and self.declares(OS[ln - 1], fp[1], 'field', False) \ + and not still_declared(fp[1], 'field', new_of.get(ln) or ln): + gone_fields.append((ln, t, fp[1])) elif t: key = ('inside', t) # between members: a field the graph did not record, a comment if key: hits.setdefault(key, set()).add(ln) nd = [0] * (len(NL) + 2) for ii, text in enumerate(strip_code(new).split('\n'), 1): nd[ii] = nd[ii - 1] + text.count('{') - text.count('}') ndepth_ok = lambda j: nd[j - 1] <= 2 - out = [] + out = [dict(kind='field', symbol=f"{t[4]}.{nm}", id=None, file=rel, line=ln, end=ln, old_lines=[ln], target_kind='field', + detail='removed or renamed', target=None) for ln, t, nm in gone_fields] KIND_OF = lambda k: 'field' if k in ('field', 'const', 'enum_member', 'variable') else 'type' if k in ('class', 'interface', 'enum', 'type', 'namespace') else 'signature' KIND = lambda k: 'field' if k in ('field', 'const', 'enum_member', 'variable') else 'type' if k in ('class', 'interface', 'enum', 'type', 'namespace') else 'method' for (a, b, i, k, d, n), decs in decorated.items(): @@ -1568,6 +1597,9 @@ def main(argv): for e in r: if e.get('new_file'): e['kind'] = 'removed'; e['detail'] = 'the file is gone at the newer side'; continue if e['kind'] == 'removed': e['kind'] = 'added (declaration)'; e['target'] = None + # and one the newer text lacks, read as new in the swapped pair, is one the range removed + elif e['kind'] == 'added': + e.update(kind='removed', detail=re.sub(r'^new ', '', e.get('detail') or '') + ' gone at the newer side', target=None); continue if e.get('detail'): e['detail'] = ' → '.join(reversed(e['detail'].split(' → '))) if ' → ' in e['detail'] and ':' not in e['detail'] else re.sub(r'(\w+): (.+?) → (.+?)(,|$)', r'\1: \3 → \2\4', e['detail']) e['detail'] = re.sub(r'(^|, )([+-])(\w+)', lambda m: m.group(1) + ('-' if m.group(2) == '+' else '+') + m.group(3), e['detail']) @@ -1626,7 +1658,7 @@ def main(argv): + ', '.join(e['symbol'] for e in es[:10]) + (f" … +{len(es) - 10}" if len(es) > 10 else '')) # a note with a file is an addition in that file; one without is a caveat about the whole answer, and printing it # under `added` made "the graph is built at the newer commit …" read as a new declaration - for f, n, _ in notes: print(f" {'added':<10} {n}" if f else f"note: {n}") + for f, n, _ in notes: print(f" {'removed' if ' line(s) gone at ' in n else 'added':<10} {n}" if f else f"note: {n}") # CASE DATA IS NOT A TEST: a changed file in a case directory or a fixture tree is an input its runner reads cd = case_data(C.repo, {e['file'] for e in results if e.get('file') and not e.get('new_file')} | set(outside)) if cd: diff --git a/tests/cases/java/grown-file-read-against-its-base/case.json b/tests/cases/java/grown-file-read-against-its-base/case.json new file mode 100644 index 00000000..a6958202 --- /dev/null +++ b/tests/cases/java/grown-file-read-against-its-base/case.json @@ -0,0 +1,10 @@ +{"lang": "java", "src": "src", + "checks": [ + {"why": "a graph from a later text than both sides: lines removed from a method that the later text has shorter are that method's body, and a field and a constructor parameter removed above it are the field and the constructor's signature; the unchanged method below, whose lines shifted, is not listed", + "run": ["changed", "{repo}", "--old", "{repo}/old.java", "--new", "{repo}/new.java", "--file", "src/pkg/Writer.java"], + "want": ["field Writer.codec", "signature Writer.Writer", "body Writer.write"], + "avoid": ["Writer.priority", "inside Writer", "added", "signature Writer.write"]}, + {"why": "control: an edit only inside the method below is that method's body and nothing else", + "run": ["changed", "{repo}", "--old", "{repo}/new.java", "--new", "{repo}/later.java", "--file", "src/pkg/Writer.java"], + "want": ["body Writer.priority"], + "avoid": ["Writer.write", "Writer.Writer", "Writer.codec"]}]} diff --git a/tests/cases/java/grown-file-read-against-its-base/later.java b/tests/cases/java/grown-file-read-against-its-base/later.java new file mode 100644 index 00000000..98fcaee7 --- /dev/null +++ b/tests/cases/java/grown-file-read-against-its-base/later.java @@ -0,0 +1,22 @@ +package pkg; + +class Writer { + private final Store store; + + Writer(Store store) { + this.store = store; + } + + void write(Event e) { + store.append(e); + } + + static String priority(Event e) { + return "high-" + e.id(); + } +} + +class Store { void save(Record r) {} void append(Event e) {} } +class Codec { String encode(Event e) { return ""; } } +class Record { Record(String id, String body) {} } +class Event { String id() { return ""; } } diff --git a/tests/cases/java/grown-file-read-against-its-base/new.java b/tests/cases/java/grown-file-read-against-its-base/new.java new file mode 100644 index 00000000..6c36fe21 --- /dev/null +++ b/tests/cases/java/grown-file-read-against-its-base/new.java @@ -0,0 +1,22 @@ +package pkg; + +class Writer { + private final Store store; + + Writer(Store store) { + this.store = store; + } + + void write(Event e) { + store.append(e); + } + + static String priority(Event e) { + return "normal"; + } +} + +class Store { void save(Record r) {} void append(Event e) {} } +class Codec { String encode(Event e) { return ""; } } +class Record { Record(String id, String body) {} } +class Event { String id() { return ""; } } diff --git a/tests/cases/java/grown-file-read-against-its-base/old.java b/tests/cases/java/grown-file-read-against-its-base/old.java new file mode 100644 index 00000000..ee9a9b1b --- /dev/null +++ b/tests/cases/java/grown-file-read-against-its-base/old.java @@ -0,0 +1,26 @@ +package pkg; + +class Writer { + private final Store store; + private final Codec codec; + + Writer(Store store, Codec codec) { + this.store = store; + this.codec = codec; + } + + void write(Event e) { + store.append(e); + String body = codec.encode(e); + store.save(new Record(e.id(), body)); + } + + static String priority(Event e) { + return "normal"; + } +} + +class Store { void save(Record r) {} void append(Event e) {} } +class Codec { String encode(Event e) { return ""; } } +class Record { Record(String id, String body) {} } +class Event { String id() { return ""; } } diff --git a/tests/cases/java/grown-file-read-against-its-base/src/pkg/Writer.java b/tests/cases/java/grown-file-read-against-its-base/src/pkg/Writer.java new file mode 100644 index 00000000..98fcaee7 --- /dev/null +++ b/tests/cases/java/grown-file-read-against-its-base/src/pkg/Writer.java @@ -0,0 +1,22 @@ +package pkg; + +class Writer { + private final Store store; + + Writer(Store store) { + this.store = store; + } + + void write(Event e) { + store.append(e); + } + + static String priority(Event e) { + return "high-" + e.id(); + } +} + +class Store { void save(Record r) {} void append(Event e) {} } +class Codec { String encode(Event e) { return ""; } } +class Record { Record(String id, String body) {} } +class Event { String id() { return ""; } } diff --git a/tests/cases/python/grown-file-read-against-its-base/case.json b/tests/cases/python/grown-file-read-against-its-base/case.json index 53b228d9..dcbcbecc 100644 --- a/tests/cases/python/grown-file-read-against-its-base/case.json +++ b/tests/cases/python/grown-file-read-against-its-base/case.json @@ -47,6 +47,14 @@ "body Service.get", "inside Service" ] - } + }, + {"why": "a graph from a later text than both sides: lines removed from a method that the later text has shorter are that method's body, and a parameter removed above it is the signature of `__init__`; the unchanged method below, whose lines shifted, is not listed", + "run": ["changed", "{repo}", "--old", "{repo}/old-writer.py", "--new", "{repo}/new-writer.py", "--file", "src/writer.py"], + "want": ["signature Writer.__init__", "body Writer.write"], + "avoid": ["Writer.priority", "added"]}, + {"why": "control: an edit only inside the method below is that method's body and nothing else", + "run": ["changed", "{repo}", "--old", "{repo}/new-writer.py", "--new", "{repo}/later-writer.py", "--file", "src/writer.py"], + "want": ["body Writer.priority"], + "avoid": ["Writer.write", "Writer.__init__"]} ] } \ No newline at end of file diff --git a/tests/cases/python/grown-file-read-against-its-base/later-writer.py b/tests/cases/python/grown-file-read-against-its-base/later-writer.py new file mode 100644 index 00000000..dff5a30d --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/later-writer.py @@ -0,0 +1,11 @@ +class Writer: + + def __init__(self, store): + self.store = store + + def write(self, e): + self.store.append(e) + + @staticmethod + def priority(e): + return "high-" + e.id diff --git a/tests/cases/python/grown-file-read-against-its-base/new-writer.py b/tests/cases/python/grown-file-read-against-its-base/new-writer.py new file mode 100644 index 00000000..12985a4c --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/new-writer.py @@ -0,0 +1,11 @@ +class Writer: + + def __init__(self, store): + self.store = store + + def write(self, e): + self.store.append(e) + + @staticmethod + def priority(e): + return "normal" diff --git a/tests/cases/python/grown-file-read-against-its-base/old-writer.py b/tests/cases/python/grown-file-read-against-its-base/old-writer.py new file mode 100644 index 00000000..9467d22e --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/old-writer.py @@ -0,0 +1,15 @@ +class Writer: + codec = None + + def __init__(self, store, codec): + self.store = store + self.codec = codec + + def write(self, e): + self.store.append(e) + body = self.codec.encode(e) + self.store.save((e.id, body)) + + @staticmethod + def priority(e): + return "normal" diff --git a/tests/cases/python/grown-file-read-against-its-base/src/writer.py b/tests/cases/python/grown-file-read-against-its-base/src/writer.py new file mode 100644 index 00000000..dff5a30d --- /dev/null +++ b/tests/cases/python/grown-file-read-against-its-base/src/writer.py @@ -0,0 +1,11 @@ +class Writer: + + def __init__(self, store): + self.store = store + + def write(self, e): + self.store.append(e) + + @staticmethod + def priority(e): + return "high-" + e.id From 8ba21f17df71dbdedfd80050fe7b8df8fd361e68 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:23:38 -0700 Subject: [PATCH 023/133] The MCP surface is impact, path and tests: search is the agent's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find(question) is no longer offered as an MCP tool or advertised on any agent-facing surface (SKILL.md, AGENTS.md, the rules file, the install block, the CLI help, the hooks' hints). Search belongs to the agent; the graph's job is what search cannot do — callers, blast radius, call chains, test selection — and the hooks keep feeding that in where a search falls short. The verb stays dispatched (find is context underneath) for the hooks, the suites and anyone who already calls it; it is internal now, not public. The orientation hook's two find hints become impact and path hints, and the pre-search directive drops its find clause. The suites that pin the surface (surfaces, mcp, mcp_docs, mcp_first, manifests, front_door, repo_arg, freshness) pin the three-tool surface instead. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- README.md | 6 +++--- bin/axiomcode | 2 +- plugins/axiomcode/AGENTS.md | 7 ++++--- plugins/axiomcode/hooks/direct.py | 3 +-- plugins/axiomcode/hooks/orient.py | 12 ++++++------ plugins/axiomcode/mcp/server.py | 10 ++-------- plugins/axiomcode/rules/axiomcode.mdc | 7 ++++--- plugins/axiomcode/skills/axiomcode/SKILL.md | 16 +++++----------- .../axiomcode/skills/axiomcode/scripts/axiomcode | 8 +++----- .../skills/axiomcode/scripts/axiomcode-install | 5 +++-- skills/axiomcode/SKILL.md | 16 +++++----------- tests/freshness.py | 6 +++--- tests/front_door.py | 8 ++++---- tests/manifests.py | 2 +- tests/mcp.py | 11 +++++------ tests/mcp_docs.py | 12 ++++++------ tests/mcp_first.py | 12 ++++++------ tests/repo_arg.py | 2 +- tests/surfaces.py | 5 +++-- 19 files changed, 66 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 166aaf3d..2ff85549 100644 --- a/README.md +++ b/README.md @@ -308,12 +308,12 @@ about one change had to read 95 of 43,793 methods, and every true direct caller ## CLI commands -Four questions, each answered as numbered places with the code of the function each one sits in. The MCP server -offers the same four as tools: `find(question)`, `impact(name)`, `path(start, end)` and `tests()`. +Three questions, each answered as numbered places with the code of the function each one sits in. The MCP server +offers the same three as tools: `impact(name)`, `path(start, end)` and `tests()`. Finding where code lives is +left to your own search: bring the name you found to these commands. | command | what it answers | |---|---| -| `axiomcode find ""` | where the code for a task lives, when you have it in words and not yet a name | | `axiomcode impact ` | who calls it, what a change to it reaches, and the tests that exercise it | | `axiomcode impact` | the same for the declarations your uncommitted edits changed; the answer starts with `your edits:` | | `axiomcode path ` | how A reaches B: every hop of the call chain, with the code at each call | diff --git a/bin/axiomcode b/bin/axiomcode index 6b902cf0..873252f4 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # axiomcode — ask a repository's call graph. `axiomcode --help` prints the dispatcher's help -# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, impact, path and tests. +# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, impact, path and tests. # ───────────────────────────────────────────────────────────────────────────── # INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which # the dispatcher, the test suites and the agent manifests call. diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index ac48bfde..1da578d8 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,17 +1,18 @@ # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change +For any question about how code connects — who calls it, what a change breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: - find(question) where the code for a task lives, when you have a task in words and no name yet impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them -Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +Without the tools, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. +Finding where code lives is yours: search as you normally would, then bring the name you found to these tools. + Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has already been re-checked in the graph (the `verified:` line); do not re-derive it by grepping. `by name` / diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 7a4285b8..58ca74d4 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -124,8 +124,7 @@ def directive(hits): f"graph: this search is for {named}. Who calls it and what a change breaks, each with its code,\n" f" including the callers that never spell the name (an interface, an override, a callback, DI):\n" f" impact(name=\"{at}\") (mcp__plugin_axiomcode_axiomcode__impact; shell `axiomcode impact {at}`). Also\n" - f" path(start, end) for how A reaches B (`axiomcode path A B`), find(question) for a task in words\n" - f" (`axiomcode find \"\"`). Said once this session." + f" path(start, end) for how A reaches B (`axiomcode path A B`). Said once this session." ) diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index 316cfc7b..1fcddcf5 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -196,9 +196,9 @@ def names_code(prompt, db): # rather than restating that there was a match, which told the reader nothing about WHICH match _, _, hits = rest.partition('<- ') print(f" {path}" + (f" <- {hits.strip()}" if hits.strip() else '')) - print(' find(question="") (mcp__plugin_axiomcode_axiomcode__find) ranks the functions the task lands in, ' - 'each with its code; call it directly, no skill needs loading first (without that tool: ' - '`axiomcode find ""`).') + print(' search under these for a name, then impact(name="") (mcp__plugin_axiomcode_axiomcode__impact) returns ' + 'who calls it and what a change reaches, each with its code; call it directly, no skill needs loading first ' + '(without that tool: `axiomcode impact `).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: @@ -210,9 +210,9 @@ def names_code(prompt, db): if 'how it runs —' in out: # a how-question: the flow is the answer's spine, and the call that returns it with each step's code is the # one to make — named here so no turn goes to loading the skill or the tool schemas first - print(' next: find(question="") (mcp__plugin_axiomcode_axiomcode__find) returns the functions ' - 'the flow runs through, each with its code; call it directly, no skill needs loading first (without that ' - 'tool: `axiomcode find ""`).') + print(' next: path(start="", end="") (mcp__plugin_axiomcode_axiomcode__path) returns every hop ' + 'of the call chain between two of these, each with its code; call it directly, no skill needs loading ' + 'first (without that tool: `axiomcode path `).') else: print(' a starting point, not a conclusion: next, impact(name="") (mcp__plugin_axiomcode_axiomcode__impact) ' 'for who calls it, what a change reaches and its tests, each with its code; call it directly, no skill ' diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 2f30e142..74d327b8 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -300,7 +300,7 @@ def _doc(f): f.__doc__ = (f.__doc__ or '') + EV_DOC return f -# THE FOUR TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale +# THE THREE TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale # graph, a refresh in flight) are kept for what they say; a clause that names a flag or a parameter to set is dropped. _OPTION = re.compile(r"(? str: - """Where the code for a task lives. Describe what you need in words (the feature, the behaviour, a name you saw); - get the functions involved, each with its code, most relevant first. A name the code calls but nothing declares - is listed with its call sites: that is code you have to write.""" - return plain(run(['find', question, os.getcwd()])) - +# Finding WHERE code lives is left to the agent's own search; the hooks feed the graph in where the search falls short. @srv.tool() def impact(name: str = '') -> str: """What a change reaches. With a name (as written in the code: Owner.method, function, Type, or file.py:123): who diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index d77c4ea5..e3c5a549 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,18 +5,19 @@ alwaysApply: true # axiomcode -For any why, what or where question about code — where something lives, who calls it, what a change +For any question about how code connects — who calls it, what a change breaks, which tests an edit reaches — ask the repository's call graph FIRST, through the axiomcode MCP tools: - find(question) where the code for a task lives, when you have a task in words and no name yet impact(name) who calls it, what a change to it reaches, and its tests; impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them -Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, +Without the tools, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. +Finding where code lives is yours: search as you normally would, then bring the name you found to these tools. + Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has already been re-checked in the graph (the `verified:` line); do not re-derive it by grepping. `by name` / diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 3628a493..a9934b2f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,18 +1,18 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any question about how code connects — who calls it, what a change to it breaks, how one piece reaches another, which tests cover an edit. Examples: "What calls this?", "What breaks if I change Z?", "How does A reach B?", "Which tests do I run?". Mandatory when .axiomcode/out/graph.sqlite exists — ask it before reasoning about callers or blast radius, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Finding where code lives is yours: search as you normally would, then bring the name you found here. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode -Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run -`/scripts/axiomcode ` from the repository root. Same answer either way. +Three questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run +`/scripts/axiomcode ` from the repository root. Same answer either way. Finding where code lives +is yours: search as you normally would, then bring the name you found here. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `axiomcode find ""` | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -40,12 +40,6 @@ is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do n `hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" from an empty answer. -## find - -Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most -relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Example: `find(question="how is the invoice total computed")`. - ## impact With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index cfcaf9ff..445a508d 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -2,8 +2,6 @@ # axiomcode — ask the repository's call graph. Each answer is a numbered list of places, each with the code of the # function it sits in. # -# axiomcode find "" -# where the code for a task lives: the functions involved, most relevant first. # axiomcode impact [] # who calls it, what a change to it reaches, and the tests that exercise it. # With no name: the same for the declarations your uncommitted edits changed. @@ -133,7 +131,7 @@ helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ # a verb of the small surface is explained by its own entry in the help above: what it answers, no options - case "$1" in find|impact|path|tests) + case "$1" in impact|path|tests) helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac @@ -218,9 +216,9 @@ done # shapes are one answer. Without it the answer is the verb's own, unchanged. G=() case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(python3 "$H/ax_grep.py" "$cmd" "$FR" --limit "${GREP_LIMIT:-30}" --) ;; esac -# THE SMALL SURFACE: find, impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and +# THE SMALL SURFACE: impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and # the MCP server set AXIOMCODE_FRONT), answer as numbered places, each with the code of the function it sits in -# (ax_blocks.py), so a place needs no read to be understood. find is context; impact is impact with its tests, +# (ax_blocks.py), so a place needs no read to be understood. impact is impact with its tests, # and impact with no name answers for the declarations the working tree has edited; path is path; tests is # test-impact. A flag, AXIOMCODE_RAW, or a caller that runs this script directly (the hooks, the suites) gets the # verb's own answer. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index c562c060..339a41b7 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -38,15 +38,16 @@ BLOCK = """ This repository has a resolved call graph. Ask it FIRST, through the axiomcode MCP tools (`mcp__plugin_axiomcode_axiomcode__*`); no skill needs loading: - find(question="") # where the code for a task lives impact(name="") # who calls it, what a change reaches, its tests impact() # the same for your uncommitted edits path(start="", end="") # how A reaches B tests() # the tests your edits reach, and how to run them -Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, +Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. +Finding where code lives is yours: search as you normally would, then bring the name you found here. + **Trust the answer.** Each place comes with the code of the function it sits in: answer from it. A `resolved` place has already been looked up again in the graph (the `verified:` line) — do not re-derive it by grepping. `by name` / `text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ff9eb64f..c6bc0277 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,18 +1,18 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any question about how code connects — who calls it, what a change to it breaks, how one piece reaches another, which tests cover an edit. Examples: "What calls this?", "What breaks if I change Z?", "How does A reach B?", "Which tests do I run?". Mandatory when .axiomcode/out/graph.sqlite exists — ask it before reasoning about callers or blast radius, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Finding where code lives is yours: search as you normally would, then bring the name you found here. Call the MCP tools directly, no need to load this skill first: impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode -Four questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code -`mcp__plugin_axiomcode_axiomcode__find`, `__impact`, `__path`, `__tests`); otherwise run -`/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. +Three questions, asked of the repository's call graph. Use the MCP tools when they are in your list (in Claude Code +`mcp__plugin_axiomcode_axiomcode__impact`, `__path`, `__tests`); otherwise run +`/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode ` from the repository root. Same answer either way. Finding where code lives +is yours: search as you normally would, then bring the name you found here. | the question | MCP tool | shell | |---|---|---| -| where is the code for this task? | `find(question)` | `axiomcode find ""` | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | @@ -40,12 +40,6 @@ is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do n `hop N` is how far out it is. A call the graph could not resolve is *unknown*, not absent: never report "no callers" from an empty answer. -## find - -Where the code for a task lives, when you have a task in words and no name yet: the functions involved, most -relevant first, each with its code. A name the code calls but nothing declares is listed with its call sites — that is -code you have to write. Example: `find(question="how is the invoice total computed")`. - ## impact With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: diff --git a/tests/freshness.py b/tests/freshness.py index 08bcd425..819e0a44 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -824,13 +824,13 @@ def mcp_checks(): with contextlib.redirect_stderr(io.StringIO()): spec.loader.exec_module(m) # THE SMALL SURFACE takes no options: freshness is the dispatcher's own (a query waits briefly, or answers from the # last graph and says so), so no tool takes fresh or refresh, and none passes --fresh or --no-refresh - tools = ('find', 'impact', 'path', 'tests') + tools = ('impact', 'path', 'tests') check("mcp: no tool takes fresh or refresh", not any(p in m.PARAMS.get(t, []) for t in tools for p in ('fresh', 'refresh')) and all(t in m.PARAMS for t in tools), {t: m.PARAMS.get(t) for t in tools}) seen = [] m.run = lambda args, *a, **k: seen.append(args) or '' - m.find('t'); m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() - check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 5 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) + m.impact('X'); m.impact(); m.path('A', 'B'); m.tests() + check("mcp: no tool passes --fresh or --no-refresh", len(seen) == 4 and not any(a in s for s in seen for a in ('--fresh', '--no-refresh')), seen) check("mcp: fresh=true is refused as an unknown argument, not dropped", 'fresh: unexpected argument' in (m.unknown_arguments('impact', {'name': 'X', 'fresh': True}) or ''), m.unknown_arguments('impact', {'name': 'X', 'fresh': True})) diff --git a/tests/front_door.py b/tests/front_door.py index 73f2d034..c9471cf2 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -118,13 +118,13 @@ def main(): check('tests: the answer ends with the "run:" line', last[0].startswith('run:') and 'test_pricing' in last[0], last) # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── - got = mcp(repo, [('find', {'question': 'how is the invoice total computed'}), ('impact', {'name': 'vat_rate'})]) + got = mcp(repo, [('impact', {'name': 'vat_rate'}), ('path', {'start': 'invoice', 'end': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly find, impact, path and tests', set(tools) == {'find', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly impact, path and tests', set(tools) == {'impact', 'path', 'tests'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) - check('MCP find answers as numbered places with their code', places(text(3)), text(3)[:600]) - check('MCP impact answers as numbered places with their code', places(text(4)) and 'shop/pricing.py:6' in text(4), text(4)[:600]) + check('MCP impact answers as numbered places with their code', places(text(3)) and 'shop/pricing.py:6' in text(3), text(3)[:600]) + check('MCP path answers as numbered places with their code', places(text(4)), text(4)[:600]) # ── c. controls: the same question anywhere else gets the verb's own answer ────────────────────────────────── r = subprocess.run(['bash', AX, 'impact', 'vat_rate', repo], cwd=repo, capture_output=True, text=True, timeout=600, env=ENV) diff --git a/tests/manifests.py b/tests/manifests.py index 24310091..59a15329 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -172,7 +172,7 @@ def gemini_path(value): if re.search(r'(?-\n(.*?)\n---', skill, re.S | re.M) check('SKILL.md has a description block', bool(m)) if m: - tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('find', 'impact', 'path', 'tests')) + tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('impact', 'path', 'tests')) # 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True, timeout=30) check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) -tool_first('install block', r.stdout, ('find', 'impact', 'path', 'tests')) +tool_first('install block', r.stdout, ('impact', 'path', 'tests')) # 3. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: @@ -80,7 +80,7 @@ def fire(hook, ev): 'tool_input': {'pattern': 'findOrder'}, 'cwd': repo, 'session_id': 'm1'}) check('direct: the first search for a declared name hears the directive', rc == 0 and bool(said), f'rc={rc}') # only the verbs that answer a search: tests is about an edit, not about what a grep looks for - tool_first('direct', said, ('impact', 'path', 'find')) + tool_first('direct', said, ('impact', 'path')) # 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: @@ -97,15 +97,15 @@ def fire(hook, ev): rc, said = fire('orient.py', {'hook_event_name': 'UserPromptSubmit', 'cwd': repo, 'session_id': 'o2', 'prompt': 'How does Consumer.go work, step by step?'}) check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) - tool_first('orient (how)', said, ('find',)) + tool_first('orient (how)', said, ('path',)) # 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be # fired; the order is checked in the source line that prints it. src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() -i = src.find("find(question=\"\") (mcp__plugin_axiomcode_axiomcode__find) ranks") +i = src.find("impact(name=\"\") (mcp__plugin_axiomcode_axiomcode__impact) returns") check('orient (scope refused): its hint is still in the source', i >= 0) if i >= 0: - tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode find', i))], ('find',)) + tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode impact', i))], ('impact',)) print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') sys.exit(1 if fails else 0) diff --git a/tests/repo_arg.py b/tests/repo_arg.py index 3a18427b..006565c6 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -72,7 +72,7 @@ def no_graph(where, what): seen = [] real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or 'ran') try: - calls = {'find': lambda: server.find('how'), 'path': lambda: server.path('a', 'b'), + calls = {'path': lambda: server.path('a', 'b'), 'impact': lambda: server.impact('foo'), 'tests': lambda: server.tests()} for name, call in calls.items(): seen.clear(); call() diff --git a/tests/surfaces.py b/tests/surfaces.py index ecc8e356..a0005a88 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -29,12 +29,13 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'find', 'impact', 'path', 'tests'] +PUBLIC = ['index', 'impact', 'path', 'tests'] NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'what find runs; its flags (--in, --source, --from, …) serve the hooks and the suites', + 'context': 'search by task words; search is the agent\'s own, the hooks feed the graph in where it falls short', + 'find': 'the front-door spelling of context; dispatched for compatibility, no longer advertised', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', 'graph': 'draws the graph as a page for a person; not one of the four questions', From 966ed8554045806f60b8289452604108a9250651 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:44:17 -0700 Subject: [PATCH 024/133] perf(python): read the parsed tree through a one-pass plain-JS mirror The Python stages each re-walked the tree-sitter tree through the JS<->C++ boundary, so a node's properties were marshalled once per stage; the parse stage of a large repository spent over half its time in those getters while the parse itself was negligible. materializePyTree now mirrors the tree in one cursor pass and every stage reads plain JS properties. Tree-sitter still parses every file. Two lookups in linkProject ran as linear scans inside loops (modules by hash per import record, bindings by hash per alias) and are now prebuilt maps with the same first-match semantics. Python analysis on two large corpus subjects drops 3.2x and 2.6x with byte-identical IR; the mirror is also A/B-asserted against the real tree node by node, which surfaced the one subtlety: an ERROR node absorbed during recovery is extra, so ERROR nodes read that flag from the real node. Parser version to 0.1.3 for the behaviour-neutral rebuild. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/package.json | 4 +- .../extractors/python-resolution-linker.ts | 18 +- .../extractors/python-scope-extractor.ts | 6 +- parser/src/parsers/python/py-mirror-tree.ts | 205 ++++++++++++++++++ 4 files changed, 228 insertions(+), 5 deletions(-) create mode 100644 parser/src/parsers/python/py-mirror-tree.ts diff --git a/parser/package.json b/parser/package.json index eac08e04..175cb019 100644 --- a/parser/package.json +++ b/parser/package.json @@ -1,7 +1,7 @@ { "name": "@axiomcode/parser", - "version": "0.1.2", - "description": "AxiomCode Parser — compiles source code and build configuration into a relational intermediate representation.", + "version": "0.1.3", + "description": "AxiomCode Parser \u2014 compiles source code and build configuration into a relational intermediate representation.", "main": "dist/extract.js", "types": "dist/extract.d.ts", "scripts": { diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 3361359a..798ac85b 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -349,6 +349,14 @@ export class PythonResolutionLinker { // ---- step 2: bases, now that imports are resolved // Per-module views of what each module's imports brought into scope. + // Modules keyed by hash once, FIRST occurrence kept — the lookup below ran as a + // linear scan per import record, which is quadratic over the project. + const moduleByHash = new Map(); + for (const module of modules) { + if (!moduleByHash.has(module.moduleHash)) { + moduleByHash.set(module.moduleHash, module); + } + } const importedTypeByName = new Map>(); const importedModuleByName = new Map>(); for (const module of modules) { @@ -365,7 +373,7 @@ export class PythonResolutionLinker { // The bound name refers to a module. Find which one by matching the // resolved module hash, so `from . import protocols` and // `import pkg.protocols` are handled by the same lookup. - const target = modules.find(m => m.moduleHash === record.getResolvedModuleLinkHash()); + const target = moduleByHash.get(record.getResolvedModuleLinkHash()); if (target) { mods.set(record.getSimpleName(), target); } @@ -383,8 +391,14 @@ export class PythonResolutionLinker { const aliasByModule = new Map>(); for (const module of modules) { const scoped = new Map(); + // Bindings keyed by hash, FIRST occurrence kept, matching the linear + // `.find` this replaces; built in the pass that already walks them. + const bindingByHash = new Map(); for (const binding of module.bindings) { scoped.set(`${binding.getPyScopeLinkHash()}::${binding.getName()}`, binding); + if (!bindingByHash.has(binding.getHash())) { + bindingByHash.set(binding.getHash(), binding); + } } const parents = new Map(); for (const scope of module.scopes) { @@ -411,7 +425,7 @@ export class PythonResolutionLinker { }); const byName = new Map(); for (const [bindingHash, entity] of aliases) { - const binding = module.bindings.find(b => b.getHash() === bindingHash); + const binding = bindingByHash.get(bindingHash); if (binding) { byName.set(binding.getName(), entity); } diff --git a/parser/src/parsers/python/extractors/python-scope-extractor.ts b/parser/src/parsers/python/extractors/python-scope-extractor.ts index cd379ac5..8d8abc5f 100644 --- a/parser/src/parsers/python/extractors/python-scope-extractor.ts +++ b/parser/src/parsers/python/extractors/python-scope-extractor.ts @@ -30,6 +30,7 @@ import { SymbolFlags, SymbolScope, } from '@/parsers/python/extractors/python-symbol-table'; +import { materializePyTree } from '@/parsers/python/py-mirror-tree'; import { Python2Finding, SymbolBlock } from '@/parsers/python/types'; import { PythonSourcePositions } from '@/utils/python'; @@ -144,7 +145,10 @@ export class PythonScopeExtractor { } const tree = this.parser.parse(input.sourceCode); - const rootNode = this.parser.getRootNode(tree); + // One cursor pass mirrors the tree into plain JS; every stage after this + // line reads JS properties instead of re-crossing the tree-sitter FFI. + // Tree-sitter itself still parses every file — see py-mirror-tree.ts. + const rootNode = materializePyTree(tree, input.sourceCode) as unknown as Parser.SyntaxNode; const detection = this.detector.detect(rootNode, input.sourceCode); if (detection.dialect !== PythonDialect.PY3) { diff --git a/parser/src/parsers/python/py-mirror-tree.ts b/parser/src/parsers/python/py-mirror-tree.ts new file mode 100644 index 00000000..1525e6f9 --- /dev/null +++ b/parser/src/parsers/python/py-mirror-tree.ts @@ -0,0 +1,205 @@ +/** + * A plain-JS mirror of a tree-sitter tree, built in ONE cursor pass. + * + * Tree-sitter still parses every file; what this removes is the reading cost. + * Every property access on a tree-sitter SyntaxNode crosses the JS↔C++ + * boundary and re-marshals the node handle, and the Python extraction stages + * each walk the same tree, so one node's properties are fetched once per + * stage. The mirror pays the boundary once per node, during the cursor walk, + * and every later read is a JS property. + * + * The surface is exactly what the Python stages use (verified by grep over + * extractors, detector and soft-keywords): type, text, children/namedChildren, + * child(i)/namedChild(i), childForFieldName, counts, spans, parent, id, + * isNamed/isMissing/isExtra/hasError. Anything outside it throws at the call + * site rather than answering wrongly. + * + * `text` is sliced lazily from the one source string, so the mirror holds no + * copies. `isExtra` is derived from the node type: tree-sitter-python's extras + * are exactly `comment` and `line_continuation` (whitespace produces no node). + * `hasError` is computed bottom-up with tree-sitter's own meaning: an ERROR or + * missing node anywhere in the subtree. + */ +import type Parser from 'tree-sitter'; + +/** tree-sitter-python `extras`: the only node types that parse as extra. */ +const PY_EXTRA_TYPES = new Set(['comment', 'line_continuation']); + +/** Never reset: `HashByNodeId` maps must not collide across files. */ +let nextId = 1; + +export class PyMirrorNode { + readonly id: number; + readonly type: string; + readonly isNamed: boolean; + readonly isMissing: boolean; + readonly startIndex: number; + readonly endIndex: number; + readonly startPosition: Parser.Point; + readonly endPosition: Parser.Point; + parent: PyMirrorNode | null = null; + readonly children: PyMirrorNode[] = []; + namedChildren: PyMirrorNode[] = []; + /** First child per field name — the pick childForFieldName makes. */ + private fields: Map | null = null; + private errorInSubtree = false; + private readonly source: string; + + constructor(cursor: Parser.TreeCursor, source: string) { + this.id = nextId++; + this.type = cursor.nodeType; + // An ERROR node can be either: tree-sitter marks an ERROR it absorbed + // during recovery as EXTRA (siblings' named counts then skip it), while a + // plain ERROR is not. The type cannot tell them apart, so this is the one + // place the real node is consulted — ERROR nodes exist only in files that + // failed to parse, so the boundary crossing stays off the healthy path. + if (this.type === 'ERROR') { + this._extraOverride = cursor.currentNode.isExtra; + } + this.isNamed = cursor.nodeIsNamed; + this.isMissing = cursor.nodeIsMissing; + this.startIndex = cursor.startIndex; + this.endIndex = cursor.endIndex; + this.startPosition = cursor.startPosition; + this.endPosition = cursor.endPosition; + this.source = source; + } + + get text(): string { + return this.source.slice(this.startIndex, this.endIndex); + } + + /** Set at build time only for ERROR nodes — see materializePyTree. */ + _extraOverride: boolean | null = null; + + get isExtra(): boolean { + if (this._extraOverride !== null) { + return this._extraOverride; + } + return PY_EXTRA_TYPES.has(this.type); + } + + get hasError(): boolean { + return this.errorInSubtree; + } + + get childCount(): number { + return this.children.length; + } + + get namedChildCount(): number { + return this.namedChildren.length; + } + + child(index: number): PyMirrorNode | null { + return this.children[index] ?? null; + } + + namedChild(index: number): PyMirrorNode | null { + return this.namedChildren[index] ?? null; + } + + /** + * First IMMEDIATE child carrying the field, which is what every extractor + * asks for. Tree-sitter's own lookup additionally pierces one visible level + * on `match_statement` (its `alternative` case clauses sit inside the match + * `block`), a quirk nothing in the Python stages uses: the match consumers + * iterate the block's namedChildren by type instead (block-extractor, + * expression-extractor), and `alternative` is read only on if/for/while, + * where it is an immediate child. + */ + childForFieldName(fieldName: string): PyMirrorNode | null { + return this.fields?.get(fieldName) ?? null; + } + + /** @internal build-time wiring, called only by materializePyTree. */ + _addChild(child: PyMirrorNode, fieldName: string | null): void { + child.parent = this; + this.children.push(child); + if (child.isNamed) { + this.namedChildren.push(child); + } + if (fieldName !== null && fieldName !== '') { + if (this.fields === null) { + this.fields = new Map(); + } + if (!this.fields.has(fieldName)) { + this.fields.set(fieldName, child); + } + } + } + + /** @internal */ + _markError(): void { + this.errorInSubtree = true; + } +} + +/** + * One depth-first cursor pass over the freshly parsed tree. + * + * The result is handed to the stages as a `Parser.SyntaxNode`: the stages are + * typed against tree-sitter's interface and use only the mirrored subset, so + * the cast is confined to the one call site that builds the mirror. + */ +export function materializePyTree(tree: Parser.Tree, source: string): PyMirrorNode { + const cursor = tree.walk(); + const root = new PyMirrorNode(cursor, source); + // hasError at the ROOT is read from tree-sitter itself (one boundary call per + // file): an error can live in a HIDDEN node — a file whose syntax error is + // swallowed shows no visible ERROR/missing child anywhere, yet + // ts_node_has_error is true, and the module row's grammar column + // (TS_PYTHON3_PARTIAL) depends on exactly that. The bottom-up propagation + // below still covers every VISIBLE error for the deeper nodes. + if (tree.rootNode.hasError) { + root._markError(); + } + const stack: PyMirrorNode[] = [root]; + let current = root; + + // gotoFirstChild / gotoNextSibling / gotoParent, no recursion: a deeply + // nested file must not overflow the JS stack when the C parser handled it. + let descending = true; + for (;;) { + if (descending && cursor.gotoFirstChild()) { + const child = new PyMirrorNode(cursor, source); + current._addChild(child, cursor.currentFieldName); + stack.push(child); + current = child; + continue; + } + // finishing `current`: fold its error state into the parent + if ( + current.type === 'ERROR' || + current.isMissing || + current.hasError + ) { + const parent = stack[stack.length - 2]; + if (parent !== undefined) { + parent._markError(); + } + current._markError(); + } + if (cursor.gotoNextSibling()) { + stack.pop(); + const parent = stack[stack.length - 1]; + if (parent === undefined) { + // the root has no siblings; the cursor cannot get here + return root; + } + const sibling = new PyMirrorNode(cursor, source); + parent._addChild(sibling, cursor.currentFieldName); + stack.push(sibling); + current = sibling; + descending = true; + continue; + } + stack.pop(); + const above = stack[stack.length - 1]; + if (!cursor.gotoParent() || above === undefined) { + return root; + } + current = above; + descending = false; + } +} From f4242c3a1767457857d22d7efa0fba6c85863672 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:17:59 -0700 Subject: [PATCH 025/133] perf(java,csharp): read parsed trees through the shared one-pass mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mirror moves to parsers/mirror-tree.ts, parameterized by each grammar's extras set; the Python module now only pins its own. Java and C# route their getRootNode through it (the real root remains available without a source string). JavaScript and TypeScript are untouched: their extractors run on the compiler AST, not tree-sitter. The port surfaced four tree-sitter subtleties the mirror now reproduces: named-sibling getters answer from anonymous nodes too; fieldNameForChild labels an extra on a field position while childForFieldName skips extras (field layout is re-read from the real node wherever an extra child makes the cursor's reporting untrustworthy); a file whose root carries an error reads every node's hasError from the real node, because a bare directive can report an error on itself with no visible ERROR child; and the C# mirror slices text from the BOM-stripped string parse() actually parsed. One deliberate behaviour change: Java's nested-annotation arguments were linked through a Map keyed by node OBJECT, and tree-sitter hands out a fresh wrapper per access, so the join hit only when a wrapper happened to be reused — 159 of 267 nested-annotation arguments linked on a large corpus subject. The mirror's stable identities link 260 of 267; the rest of the IR is byte-identical there, as it is on both Python subjects and the two C# subjects. Parse-stage wall time on large subjects: Java 66s -> 13s, C# 73s -> 18s. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/parsers/csharp/csharp-parser.ts | 26 +- .../csharp/extractors/cs-fact-extractor.ts | 4 +- .../java/extractors/import-extractor.ts | 2 +- .../extractors/type-registry-extractor.ts | 2 +- parser/src/parsers/java/java-parser.ts | 18 +- parser/src/parsers/mirror-tree.ts | 307 ++++++++++++++++++ parser/src/parsers/python/py-mirror-tree.ts | 206 +----------- 7 files changed, 361 insertions(+), 204 deletions(-) create mode 100644 parser/src/parsers/mirror-tree.ts diff --git a/parser/src/parsers/csharp/csharp-parser.ts b/parser/src/parsers/csharp/csharp-parser.ts index f37143af..2e1a4306 100644 --- a/parser/src/parsers/csharp/csharp-parser.ts +++ b/parser/src/parsers/csharp/csharp-parser.ts @@ -6,6 +6,7 @@ import Parser from 'tree-sitter'; import CSharp from 'tree-sitter-c-sharp'; import { FILE_EXTENSIONS } from '@/constants/consts'; +import { materializeTree } from '@/parsers/mirror-tree'; import { CSHARP_CALLBACK_PARSE_THRESHOLD, CSHARP_PARSE_CHUNK_SIZE, @@ -81,6 +82,20 @@ export function stripUtf8Bom(sourceCode: string): string { return sourceCode.startsWith(UTF8_BOM) ? sourceCode.slice(UTF8_BOM.length) : sourceCode; } +/** tree-sitter-c-sharp `extras`: the only node types that parse as extra. */ +const CS_EXTRA_TYPES: ReadonlySet = new Set([ + 'comment', + 'preproc_region', + 'preproc_endregion', + 'preproc_line', + 'preproc_pragma', + 'preproc_nullable', + 'preproc_error', + 'preproc_warning', + 'preproc_define', + 'preproc_undef', +]); + export class CSharpParser implements LanguageParser { readonly language = ProjectLanguage.CSHARP; readonly fileExtension = FILE_EXTENSIONS.CSHARP; @@ -142,7 +157,16 @@ export class CSharpParser implements LanguageParser { return parseWithRetry(source); } - getRootNode(tree: Parser.Tree): Parser.SyntaxNode { + getRootNode(tree: Parser.Tree, sourceCode?: string): Parser.SyntaxNode { + // With `sourceCode`, the root is a one-pass plain-JS mirror of the tree + // (see `../mirror-tree`): every stage after it reads JS properties + // instead of re-crossing the tree-sitter FFI per property access. + // Tree-sitter still parses every file. + if (sourceCode !== undefined) { + // parse() strips a leading BOM, so the mirror slices text from the SAME + // string the tree's byte offsets are relative to, whatever was passed. + return materializeTree(tree, stripUtf8Bom(sourceCode), CS_EXTRA_TYPES) as unknown as Parser.SyntaxNode; + } return tree.rootNode; } diff --git a/parser/src/parsers/csharp/extractors/cs-fact-extractor.ts b/parser/src/parsers/csharp/extractors/cs-fact-extractor.ts index 31fc4fe4..aea82ba5 100644 --- a/parser/src/parsers/csharp/extractors/cs-fact-extractor.ts +++ b/parser/src/parsers/csharp/extractors/cs-fact-extractor.ts @@ -216,11 +216,11 @@ export class CsFactExtractor { // is. Length-preserving to the character; the receiver the blanking // removes is returned in a side table. See cs-extension-block.ts. const flattened = flattenExtensionBlocks(rewritten, (source) => - this.parser.getRootNode(this.parser.parse(source)) + this.parser.getRootNode(this.parser.parse(source), source) ); const parseText = flattened.text.endsWith('\n') ? flattened.text : `${flattened.text}\n`; const tree = this.parser.parse(parseText); - const root = this.parser.getRootNode(tree); + const root = this.parser.getRootNode(tree, parseText); // The symbol set this emission is compiled under: what the caller supplied, // plus the implicit framework symbols the SDK injects and no `.csproj` diff --git a/parser/src/parsers/java/extractors/import-extractor.ts b/parser/src/parsers/java/extractors/import-extractor.ts index d6d98f02..6323cb42 100644 --- a/parser/src/parsers/java/extractors/import-extractor.ts +++ b/parser/src/parsers/java/extractors/import-extractor.ts @@ -54,7 +54,7 @@ export class ImportExtractor implements BaseExtractor { } const tree = this.javaParser.parse(fileContent); - const rootNode = this.javaParser.getRootNode(tree); + const rootNode = this.javaParser.getRootNode(tree, fileContent); this.extractImportsFromRoot(rootNode, filePath, serviceVersionHash, imports); } catch (error) { diff --git a/parser/src/parsers/java/extractors/type-registry-extractor.ts b/parser/src/parsers/java/extractors/type-registry-extractor.ts index 993630bf..680ae654 100644 --- a/parser/src/parsers/java/extractors/type-registry-extractor.ts +++ b/parser/src/parsers/java/extractors/type-registry-extractor.ts @@ -203,7 +203,7 @@ export class TypeRegistryExtractor implements BaseExtractor { } const tree = this.javaParser.parse(fileContent); - const rootNode = this.javaParser.getRootNode(tree); + const rootNode = this.javaParser.getRootNode(tree, fileContent); const basePath = this.extractBasePath(filePath); const fileName = path.basename(filePath); diff --git a/parser/src/parsers/java/java-parser.ts b/parser/src/parsers/java/java-parser.ts index 1ec23cc5..e444c02f 100644 --- a/parser/src/parsers/java/java-parser.ts +++ b/parser/src/parsers/java/java-parser.ts @@ -3,9 +3,13 @@ import Java from 'tree-sitter-java'; import { FILE_EXTENSIONS } from '@/constants/consts'; import { LanguageParser } from '@/parsers/language-parser'; +import { materializeTree } from '@/parsers/mirror-tree'; import { ProjectLanguage } from '@/types/ProjectInfo'; import { withRetry } from '@/utils/retry-decorator'; +/** tree-sitter-java `extras`: the only node types that parse as extra. */ +const JAVA_EXTRA_TYPES: ReadonlySet = new Set(['line_comment', 'block_comment']); + /** * Java-specific tree-sitter parser implementation */ @@ -68,11 +72,21 @@ export class JavaParser implements LanguageParser { } /** - * Gets the root node of a parsed tree + * Gets the root node of a parsed tree. + * + * With `sourceCode`, the root is a one-pass plain-JS mirror of the tree + * (see `../mirror-tree`): every stage after it reads JS properties instead + * of re-crossing the tree-sitter FFI per property access. Tree-sitter still + * parses every file. Without it, the real tree-sitter root is returned. + * * @param tree Parsed syntax tree + * @param sourceCode The exact string the tree was parsed from * @returns Root syntax node */ - getRootNode(tree: Parser.Tree): Parser.SyntaxNode { + getRootNode(tree: Parser.Tree, sourceCode?: string): Parser.SyntaxNode { + if (sourceCode !== undefined) { + return materializeTree(tree, sourceCode, JAVA_EXTRA_TYPES) as unknown as Parser.SyntaxNode; + } return tree.rootNode; } diff --git a/parser/src/parsers/mirror-tree.ts b/parser/src/parsers/mirror-tree.ts new file mode 100644 index 00000000..ad88b17f --- /dev/null +++ b/parser/src/parsers/mirror-tree.ts @@ -0,0 +1,307 @@ +/** + * A plain-JS mirror of a tree-sitter tree, built in ONE cursor pass — the + * shared machinery behind each language's `materializeTree`. + * + * Tree-sitter still parses every file; what this removes is the reading cost. + * Every property access on a tree-sitter SyntaxNode crosses the JS↔C++ + * boundary and re-marshals the node handle, and the extraction stages each + * walk the same tree, so one node's properties are fetched once per stage. + * The mirror pays the boundary once per node, during the cursor walk, and + * every later read is a JS property. + * + * The surface is the union of what the tree-sitter-reading stages use + * (verified by grep per language): type, text, children/namedChildren, + * child(i)/namedChild(i), childForFieldName, fieldNameForChild, counts, + * spans, parent, named siblings, id, isNamed/isMissing/isExtra/hasError. + * Anything outside it throws at the call site rather than answering wrongly. + * + * `text` is sliced lazily from the one source string, so the mirror holds no + * copies. `isExtra` is derived from the node type against the language's own + * `extras` set, with one exception read from the real node (see constructor). + * `hasError` is computed bottom-up with tree-sitter's meaning — an ERROR or + * missing node anywhere in the subtree — and the ROOT's flag is copied from + * tree-sitter itself, because an error can live in a HIDDEN node that no + * visible child betrays. + */ +import type Parser from 'tree-sitter'; + +/** Never reset: `HashByNodeId` maps must not collide across files. */ +let nextId = 1; + +export class MirrorNode { + readonly id: number; + readonly type: string; + readonly isNamed: boolean; + readonly isMissing: boolean; + readonly startIndex: number; + readonly endIndex: number; + readonly startPosition: Parser.Point; + readonly endPosition: Parser.Point; + parent: MirrorNode | null = null; + readonly children: MirrorNode[] = []; + namedChildren: MirrorNode[] = []; + /** First child per field name — the pick childForFieldName makes. */ + private fields: Map | null = null; + /** Field name per child index, for fieldNameForChild; null when none has one. */ + private childFields: (string | null)[] | null = null; + /** Index within parent.children, for the named-sibling getters. */ + private childIndex = -1; + private errorInSubtree = false; + /** Set at build time only for ERROR nodes — see the constructor. */ + private extraOverride: boolean | null = null; + private readonly source: string; + private readonly extraTypes: ReadonlySet; + + constructor(cursor: Parser.TreeCursor, source: string, extraTypes: ReadonlySet) { + this.id = nextId++; + this.type = cursor.nodeType; + // An ERROR node can be either: tree-sitter marks an ERROR it absorbed + // during recovery as EXTRA (siblings' named counts then skip it), while a + // plain ERROR is not. The type cannot tell them apart, so this is the one + // place the real node is consulted — ERROR nodes exist only in files that + // failed to parse, so the boundary crossing stays off the healthy path. + if (this.type === 'ERROR') { + this.extraOverride = cursor.currentNode.isExtra; + } + this.isNamed = cursor.nodeIsNamed; + this.isMissing = cursor.nodeIsMissing; + this.startIndex = cursor.startIndex; + this.endIndex = cursor.endIndex; + this.startPosition = cursor.startPosition; + this.endPosition = cursor.endPosition; + this.source = source; + this.extraTypes = extraTypes; + } + + get text(): string { + return this.source.slice(this.startIndex, this.endIndex); + } + + get isExtra(): boolean { + if (this.extraOverride !== null) { + return this.extraOverride; + } + return this.extraTypes.has(this.type); + } + + get hasError(): boolean { + return this.errorInSubtree; + } + + get childCount(): number { + return this.children.length; + } + + get namedChildCount(): number { + return this.namedChildren.length; + } + + child(index: number): MirrorNode | null { + return this.children[index] ?? null; + } + + namedChild(index: number): MirrorNode | null { + return this.namedChildren[index] ?? null; + } + + // The named-sibling getters answer from ANY node, anonymous ones included + // (tree-sitter scans the parent's children positionally), so they scan from + // this node's position rather than indexing namedChildren. They are read a + // handful of times per file (comment attachment), never in a hot loop. + get previousNamedSibling(): MirrorNode | null { + if (this.parent === null) { + return null; + } + for (let i = this.childIndex - 1; i >= 0; i--) { + const sibling = this.parent.children[i]; + if (sibling !== undefined && sibling.isNamed) { + return sibling; + } + } + return null; + } + + get nextNamedSibling(): MirrorNode | null { + if (this.parent === null) { + return null; + } + for (let i = this.childIndex + 1; i < this.parent.children.length; i++) { + const sibling = this.parent.children[i]; + if (sibling !== undefined && sibling.isNamed) { + return sibling; + } + } + return null; + } + + /** + * First IMMEDIATE child carrying the field, which is what every extractor + * asks for. Tree-sitter's own lookup can additionally pierce one visible + * level where a grammar attaches a field inside a hidden rule (Python's + * `match_statement` reaches its case clauses' `alternative` through the + * match block), a quirk no stage uses: the consumers iterate those children + * by type instead. + */ + childForFieldName(fieldName: string): MirrorNode | null { + return this.fields?.get(fieldName) ?? null; + } + + fieldNameForChild(index: number): string | null { + return this.childFields?.[index] ?? null; + } + + /** @internal set when any direct child is an extra — see _repairFieldsFrom. */ + _needsFieldRepair = false; + + /** @internal build-time wiring, called only by materializeTree. */ + _addChild(child: MirrorNode, fieldName: string | null): void { + child.parent = this; + child.childIndex = this.children.length; + this.children.push(child); + if (child.isNamed) { + this.namedChildren.push(child); + } + if (child.isExtra) { + this._needsFieldRepair = true; + } + if (fieldName !== null && fieldName !== '' && fieldName !== undefined) { + if (this.fields === null) { + this.fields = new Map(); + } + if (!this.fields.has(fieldName)) { + this.fields.set(fieldName, child); + } + if (this.childFields === null) { + this.childFields = []; + } + this.childFields[this.children.length - 1] = fieldName; + } + } + + /** + * @internal Re-reads this node's field layout from the real node. + * + * Around an EXTRA child (a comment inside the construct) the cursor's field + * reporting diverges from the node API in two ways: on a chunk-parsed file + * (over tree-sitter's string-length ceiling) the field of the sibling after + * the extra can come back empty, and the node API itself labels the extra + * with the preceding field. A node with an extra child therefore copies the + * layout wholesale — these are only the comment-bearing nodes, so the + * boundary crossings stay rare. + */ + _repairFieldsFrom(real: Parser.SyntaxNode): void { + this.fields = null; + this.childFields = null; + for (let i = 0; i < this.children.length; i++) { + const fieldName = real.fieldNameForChild(i) ?? null; + if (fieldName === null || fieldName === '') { + continue; + } + const child = this.children[i]; + if (child === undefined) { + continue; + } + // The two node APIs disagree around extras, and the mirror keeps both + // behaviours: fieldNameForChild labels an extra sitting on a field + // position, while childForFieldName SKIPS extras and answers the first + // non-extra carrier. + if (!child.isExtra) { + if (this.fields === null) { + this.fields = new Map(); + } + if (!this.fields.has(fieldName)) { + this.fields.set(fieldName, child); + } + } + if (this.childFields === null) { + this.childFields = []; + } + this.childFields[i] = fieldName; + } + } + + /** @internal */ + _markError(): void { + this.errorInSubtree = true; + } +} + +/** One depth-first cursor pass over the freshly parsed tree. */ +export function materializeTree( + tree: Parser.Tree, + source: string, + extraTypes: ReadonlySet +): MirrorNode { + const cursor = tree.walk(); + const root = new MirrorNode(cursor, source, extraTypes); + // hasError at the ROOT is read from tree-sitter itself (one boundary call + // per file): an error can live in a HIDDEN node — a file whose syntax error + // is swallowed shows no visible ERROR/missing child anywhere, yet + // ts_node_has_error is true, and module-level "partial grammar" columns + // depend on exactly that. When the root does carry an error, every node's + // flag is read from the real node instead of propagated bottom-up: the + // parse-gap stages walk for the DEEPEST hasError node, and a node can + // report it on itself with no visible ERROR child (a bare preproc_pragma + // does). The per-node boundary crossings are confined to the files that + // failed to parse; a healthy file pays one. + const exactErrors = tree.rootNode.hasError; + if (exactErrors) { + root._markError(); + } + const stack: MirrorNode[] = [root]; + let current = root; + + // gotoFirstChild / gotoNextSibling / gotoParent, no recursion: a deeply + // nested file must not overflow the JS stack when the C parser handled it. + let descending = true; + for (;;) { + if (descending && cursor.gotoFirstChild()) { + const child = new MirrorNode(cursor, source, extraTypes); + if (exactErrors && cursor.currentNode.hasError) { + child._markError(); + } + current._addChild(child, cursor.currentFieldName); + stack.push(child); + current = child; + continue; + } + // finishing `current`: the cursor sits on it, so repair its fields here + // if an extra child made the cursor's reporting untrustworthy… + if (current._needsFieldRepair) { + current._repairFieldsFrom(cursor.currentNode); + current._needsFieldRepair = false; + } + // …and fold its error state into the parent + if (current.type === 'ERROR' || current.isMissing || current.hasError) { + const parent = stack[stack.length - 2]; + if (parent !== undefined) { + parent._markError(); + } + current._markError(); + } + if (cursor.gotoNextSibling()) { + stack.pop(); + const parent = stack[stack.length - 1]; + if (parent === undefined) { + // the root has no siblings; the cursor cannot get here + return root; + } + const sibling = new MirrorNode(cursor, source, extraTypes); + if (exactErrors && cursor.currentNode.hasError) { + sibling._markError(); + } + parent._addChild(sibling, cursor.currentFieldName); + stack.push(sibling); + current = sibling; + descending = true; + continue; + } + stack.pop(); + const above = stack[stack.length - 1]; + if (!cursor.gotoParent() || above === undefined) { + return root; + } + current = above; + descending = false; + } +} diff --git a/parser/src/parsers/python/py-mirror-tree.ts b/parser/src/parsers/python/py-mirror-tree.ts index 1525e6f9..de940184 100644 --- a/parser/src/parsers/python/py-mirror-tree.ts +++ b/parser/src/parsers/python/py-mirror-tree.ts @@ -1,205 +1,17 @@ /** - * A plain-JS mirror of a tree-sitter tree, built in ONE cursor pass. - * - * Tree-sitter still parses every file; what this removes is the reading cost. - * Every property access on a tree-sitter SyntaxNode crosses the JS↔C++ - * boundary and re-marshals the node handle, and the Python extraction stages - * each walk the same tree, so one node's properties are fetched once per - * stage. The mirror pays the boundary once per node, during the cursor walk, - * and every later read is a JS property. - * - * The surface is exactly what the Python stages use (verified by grep over - * extractors, detector and soft-keywords): type, text, children/namedChildren, - * child(i)/namedChild(i), childForFieldName, counts, spans, parent, id, - * isNamed/isMissing/isExtra/hasError. Anything outside it throws at the call - * site rather than answering wrongly. - * - * `text` is sliced lazily from the one source string, so the mirror holds no - * copies. `isExtra` is derived from the node type: tree-sitter-python's extras - * are exactly `comment` and `line_continuation` (whitespace produces no node). - * `hasError` is computed bottom-up with tree-sitter's own meaning: an ERROR or - * missing node anywhere in the subtree. + * Python's one-pass plain-JS mirror of the tree-sitter tree. The machinery + * and the reasoning live in `../mirror-tree`; this module only pins the + * language's `extras`: tree-sitter-python's are exactly `comment` and + * `line_continuation` (whitespace produces no node). */ import type Parser from 'tree-sitter'; -/** tree-sitter-python `extras`: the only node types that parse as extra. */ -const PY_EXTRA_TYPES = new Set(['comment', 'line_continuation']); +import { MirrorNode, materializeTree } from '@/parsers/mirror-tree'; -/** Never reset: `HashByNodeId` maps must not collide across files. */ -let nextId = 1; +const PY_EXTRA_TYPES: ReadonlySet = new Set(['comment', 'line_continuation']); -export class PyMirrorNode { - readonly id: number; - readonly type: string; - readonly isNamed: boolean; - readonly isMissing: boolean; - readonly startIndex: number; - readonly endIndex: number; - readonly startPosition: Parser.Point; - readonly endPosition: Parser.Point; - parent: PyMirrorNode | null = null; - readonly children: PyMirrorNode[] = []; - namedChildren: PyMirrorNode[] = []; - /** First child per field name — the pick childForFieldName makes. */ - private fields: Map | null = null; - private errorInSubtree = false; - private readonly source: string; +export type PyMirrorNode = MirrorNode; - constructor(cursor: Parser.TreeCursor, source: string) { - this.id = nextId++; - this.type = cursor.nodeType; - // An ERROR node can be either: tree-sitter marks an ERROR it absorbed - // during recovery as EXTRA (siblings' named counts then skip it), while a - // plain ERROR is not. The type cannot tell them apart, so this is the one - // place the real node is consulted — ERROR nodes exist only in files that - // failed to parse, so the boundary crossing stays off the healthy path. - if (this.type === 'ERROR') { - this._extraOverride = cursor.currentNode.isExtra; - } - this.isNamed = cursor.nodeIsNamed; - this.isMissing = cursor.nodeIsMissing; - this.startIndex = cursor.startIndex; - this.endIndex = cursor.endIndex; - this.startPosition = cursor.startPosition; - this.endPosition = cursor.endPosition; - this.source = source; - } - - get text(): string { - return this.source.slice(this.startIndex, this.endIndex); - } - - /** Set at build time only for ERROR nodes — see materializePyTree. */ - _extraOverride: boolean | null = null; - - get isExtra(): boolean { - if (this._extraOverride !== null) { - return this._extraOverride; - } - return PY_EXTRA_TYPES.has(this.type); - } - - get hasError(): boolean { - return this.errorInSubtree; - } - - get childCount(): number { - return this.children.length; - } - - get namedChildCount(): number { - return this.namedChildren.length; - } - - child(index: number): PyMirrorNode | null { - return this.children[index] ?? null; - } - - namedChild(index: number): PyMirrorNode | null { - return this.namedChildren[index] ?? null; - } - - /** - * First IMMEDIATE child carrying the field, which is what every extractor - * asks for. Tree-sitter's own lookup additionally pierces one visible level - * on `match_statement` (its `alternative` case clauses sit inside the match - * `block`), a quirk nothing in the Python stages uses: the match consumers - * iterate the block's namedChildren by type instead (block-extractor, - * expression-extractor), and `alternative` is read only on if/for/while, - * where it is an immediate child. - */ - childForFieldName(fieldName: string): PyMirrorNode | null { - return this.fields?.get(fieldName) ?? null; - } - - /** @internal build-time wiring, called only by materializePyTree. */ - _addChild(child: PyMirrorNode, fieldName: string | null): void { - child.parent = this; - this.children.push(child); - if (child.isNamed) { - this.namedChildren.push(child); - } - if (fieldName !== null && fieldName !== '') { - if (this.fields === null) { - this.fields = new Map(); - } - if (!this.fields.has(fieldName)) { - this.fields.set(fieldName, child); - } - } - } - - /** @internal */ - _markError(): void { - this.errorInSubtree = true; - } -} - -/** - * One depth-first cursor pass over the freshly parsed tree. - * - * The result is handed to the stages as a `Parser.SyntaxNode`: the stages are - * typed against tree-sitter's interface and use only the mirrored subset, so - * the cast is confined to the one call site that builds the mirror. - */ -export function materializePyTree(tree: Parser.Tree, source: string): PyMirrorNode { - const cursor = tree.walk(); - const root = new PyMirrorNode(cursor, source); - // hasError at the ROOT is read from tree-sitter itself (one boundary call per - // file): an error can live in a HIDDEN node — a file whose syntax error is - // swallowed shows no visible ERROR/missing child anywhere, yet - // ts_node_has_error is true, and the module row's grammar column - // (TS_PYTHON3_PARTIAL) depends on exactly that. The bottom-up propagation - // below still covers every VISIBLE error for the deeper nodes. - if (tree.rootNode.hasError) { - root._markError(); - } - const stack: PyMirrorNode[] = [root]; - let current = root; - - // gotoFirstChild / gotoNextSibling / gotoParent, no recursion: a deeply - // nested file must not overflow the JS stack when the C parser handled it. - let descending = true; - for (;;) { - if (descending && cursor.gotoFirstChild()) { - const child = new PyMirrorNode(cursor, source); - current._addChild(child, cursor.currentFieldName); - stack.push(child); - current = child; - continue; - } - // finishing `current`: fold its error state into the parent - if ( - current.type === 'ERROR' || - current.isMissing || - current.hasError - ) { - const parent = stack[stack.length - 2]; - if (parent !== undefined) { - parent._markError(); - } - current._markError(); - } - if (cursor.gotoNextSibling()) { - stack.pop(); - const parent = stack[stack.length - 1]; - if (parent === undefined) { - // the root has no siblings; the cursor cannot get here - return root; - } - const sibling = new PyMirrorNode(cursor, source); - parent._addChild(sibling, cursor.currentFieldName); - stack.push(sibling); - current = sibling; - descending = true; - continue; - } - stack.pop(); - const above = stack[stack.length - 1]; - if (!cursor.gotoParent() || above === undefined) { - return root; - } - current = above; - descending = false; - } +export function materializePyTree(tree: Parser.Tree, source: string): MirrorNode { + return materializeTree(tree, source, PY_EXTRA_TYPES); } From 76716000543b5fb94ca996a44fcd38266a223602 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:48:00 -0700 Subject: [PATCH 026/133] perf(typescript,build): verify CSV rows at append; reuse closure-pass reads; solve non-main languages concurrently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent costs, one commit per stage touched: ts-relation-writer verified each finished relation by re-reading and re-decoding the whole file. The same rules — the header's field count and the consumer line-break alphabet — now run on each row's string as it is appended, in one allocation-free pass, and publish() proves the bytes arrived by comparing the byte count write() reported against the file's size, which is the defect the read-back existed to catch. The streaming verifier stays exported for the gate that exercises it. The root-program closure walk read and cheap-parsed every file to follow imports, then the extraction pass read every file again; the walk now hands its text over (consumed and released per file), and module resolution gets a ts.createModuleResolutionCache instead of re-probing node_modules per specifier. The all verb solved languages one after another although their solves share nothing. The largest language still solves alone first — its graph is the one a --progress caller publishes first — and the rest run in waves (AXIOMCODE_SOLVE_JOBS wide, default 2, 1 restores the strict line), each into its own intermediate, because run-souffle writes fixed names there. On a 1.1M-LOC TypeScript package, warm: stage 57.2s -> 50.9s with byte-identical IR; the removed double read was 12% of a cold parse. Serial and concurrent solves produce row-identical relations across all tables on a three-language tree. End to end on this repository (five languages), index wall time drops 37%. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 61 +++++++++++++++--- .../typescript/ts-relation-writer.ts | 62 ++++++++++++++++++- .../typescript/typescript-project-analyzer.ts | 25 ++++++-- 3 files changed, 133 insertions(+), 15 deletions(-) diff --git a/bin/axiomcode b/bin/axiomcode index 6b902cf0..946f2c38 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -324,16 +324,61 @@ case "$cmd" in # a caller that checks it is no worse off. Only the languages that produced a graph are # listed at the end, so the output stays a list of files that exist. solved=(); failed=() - for l in "${langs[@]}"; do - echo "▶ solving $l → $out/$l/graph.sqlite" - if "$0" engine --language "$l" --client-ir "$ir/$l" --out "$out/$l" --intermediate "$int" --library "$libroots" \ - --meta "source_version=$version" --meta "source_dir=$(cd "$src" && pwd)" ${rest[@]+"${rest[@]}"}; then - solved+=("$l"); [ -z "$progress" ] || echo "$l ok $out/$l/graph.sqlite" >> "$progress" + # Each language gets its OWN intermediate: run-souffle writes fixed names there + # (souffle-program.cpp, .souffle-gen.log), so two concurrent solves into one + # directory would overwrite each other's program mid-compile. + solve_one(){ local sl="$1" + "$0" engine --language "$sl" --client-ir "$ir/$sl" --out "$out/$sl" --intermediate "$int/$sl" --library "$libroots" \ + --meta "source_version=$version" --meta "source_dir=$(cd "$src" && pwd)" ${rest[@]+"${rest[@]}"} + } + record(){ local rl="$1" rrc="$2" + if [ "$rrc" -eq 0 ]; then + solved+=("$rl"); [ -z "$progress" ] || echo "$rl ok $out/$rl/graph.sqlite" >> "$progress" else - failed+=("$l"); echo "❌ $l failed; continuing with the remaining languages" >&2 - [ -z "$progress" ] || echo "$l failed" >> "$progress" + failed+=("$rl"); echo "❌ $rl failed; continuing with the remaining languages" >&2 + [ -z "$progress" ] || echo "$rl failed" >> "$progress" fi - done + } + # THE FIRST (LARGEST) LANGUAGE SOLVES ALONE, streaming, exactly as before: its graph is the one a + # --progress caller publishes first (#1555), and nothing may compete with it for cores or + # interleave its log. Only the languages AFTER it run concurrently — they used to wait in line + # behind each other for no reason: their solves share nothing (own IR, own output, own + # intermediate). Memory is the tradeoff (each is its own Soufflé process), so the width is + # modest by default and AXIOMCODE_SOLVE_JOBS raises or lowers it; 1 restores the strict line. + l="${langs[0]}" + echo "▶ solving $l → $out/$l/graph.sqlite" + rc=0; solve_one "$l" || rc=$?; record "$l" "$rc" + if [ ${#langs[@]} -gt 1 ]; then + others=("${langs[@]:1}") + JOBS="${AXIOMCODE_SOLVE_JOBS:-2}" + case "$JOBS" in (*[!0-9]*|'') JOBS=2;; esac; [ "$JOBS" -ge 1 ] || JOBS=1 + if [ "$JOBS" -eq 1 ]; then + for l in "${others[@]}"; do + echo "▶ solving $l → $out/$l/graph.sqlite" + rc=0; solve_one "$l" || rc=$?; record "$l" "$rc" + done + else + # Waves of $JOBS (bash 3.2 has no `wait -n`): start a wave, wait for all of it, replay each + # log whole so the build log never interleaves, then record in the wave's order. + i=0 + while [ $i -lt ${#others[@]} ]; do + wave=("${others[@]:$i:$JOBS}"); i=$((i + JOBS)) + wavepids=() + for l in "${wave[@]}"; do + echo "▶ solving $l → $out/$l/graph.sqlite (concurrent)" + { solve_one "$l" > "$int/solve-$l.log" 2>&1; echo $? > "$int/solve-$l.rc"; } & + wavepids+=($!) + done + for wp in "${wavepids[@]}"; do wait "$wp" || true; done + for l in "${wave[@]}"; do + cat "$int/solve-$l.log" 2>/dev/null + rc="$(cat "$int/solve-$l.rc" 2>/dev/null || echo 1)" + rm -f "$int/solve-$l.log" "$int/solve-$l.rc" + record "$l" "$rc" + done + done + fi + fi for l in ${solved[@]+"${solved[@]}"}; do echo "$out/$l/graph.sqlite"; done if [ ${#failed[@]} -gt 0 ]; then echo "❌ ${#failed[@]} of ${#langs[@]} languages failed: ${failed[*]}" >&2 diff --git a/parser/src/workflows/typescript/ts-relation-writer.ts b/parser/src/workflows/typescript/ts-relation-writer.ts index da00771e..f431759d 100644 --- a/parser/src/workflows/typescript/ts-relation-writer.ts +++ b/parser/src/workflows/typescript/ts-relation-writer.ts @@ -43,7 +43,9 @@ export class TsRelationWriter { private readonly outputPath: string; private buffer: string[] = []; private header = ''; + private width = 0; private rows = 0; + private bytesWritten = 0; private closed = false; constructor(outputDir: string, filename: string, uniqueSuffix: string) { @@ -71,10 +73,18 @@ export class TsRelationWriter { if (this.handle === undefined) { this.handle = await fsp.open(this.temporaryPath, 'w'); this.header = rows[0]!.getCsvHeader(); + this.width = countTabs(this.header) + 1; this.buffer.push(this.header + '\n'); } for (const row of rows) { - this.buffer.push(row.toCsv() + '\n'); + const line = row.toCsv(); + // The row is checked HERE, on the string that is about to be written, + // instead of decoding the finished file a second time: same width rule, + // same line-break alphabet, no re-read. What this no longer re-checks — + // that the bytes reached the disk whole — publish() covers by comparing + // the byte count it wrote against what the file system reports. + verifyRow(line, this.width, this.outputPath, this.rows + 2); + this.buffer.push(line + '\n'); this.rows += 1; } if (this.buffer.length >= TS_CSV_CHUNK_SIZE) { @@ -90,7 +100,8 @@ export class TsRelationWriter { // streaming cost the same as the whole-file writer did. const text = this.buffer.join(''); this.buffer = []; - await this.handle.write(text, null, 'utf-8'); + const { bytesWritten } = await this.handle.write(text, null, 'utf-8'); + this.bytesWritten += bytesWritten; } /** Flushes, verifies, and renames into place. */ @@ -107,9 +118,17 @@ export class TsRelationWriter { } await this.flush(); await this.handle.sync(); + // Every row was verified as it was appended (verifyRow); what remains to + // prove is that the bytes all arrived. The file's size must equal the sum + // of what write() reported — a mismatch is a torn write, the exact defect + // the old whole-file read-back existed to catch. + const onDisk = (await this.handle.stat()).size; await this.handle.close(); this.handle = undefined; - verifyRelationFileStreaming(this.temporaryPath, this.outputPath, this.header); + if (onDisk !== this.bytesWritten) { + throw new Error(`${path.basename(this.outputPath)}: wrote ${this.bytesWritten} byte(s) but the ` + + `file holds ${onDisk} — the write is torn`); + } await fsp.rename(this.temporaryPath, this.outputPath); } @@ -139,6 +158,43 @@ export class TsRelationWriter { */ const CONSUMER_LINE_BREAKS = /[\u000A\u000B\u000C\u000D\u001C\u001D\u001E\u0085\u2028\u2029]/; +function countTabs(line: string): number { + let tabs = 0; + for (let i = 0; i < line.length; i++) { + if (line.charCodeAt(i) === 0x09) { + tabs += 1; + } + } + return tabs; +} + +/** + * One row holds exactly the header's field count and no code point a consumer + * would break a line on \u2014 the same rules {@link verifyRelationFileStreaming} + * applies, checked on the in-memory string in one allocation-free pass. + */ +function verifyRow(line: string, width: number, outputPath: string, lineNumber: number): void { + if (line === '') { + // the streamed read-back skipped blank lines rather than calling them torn + return; + } + let tabs = 0; + for (let i = 0; i < line.length; i++) { + const c = line.charCodeAt(i); + if (c === 0x09) { + tabs += 1; + } else if ((c >= 0x0a && c <= 0x0d) || (c >= 0x1c && c <= 0x1e) || c === 0x85 + || c === 0x2028 || c === 0x2029) { + throw new Error(`${path.basename(outputPath)}: line ${lineNumber} carries a line-break code ` + + `point inside a value \u2014 the row would read torn: ${JSON.stringify(line.slice(0, 60))}`); + } + } + if (tabs + 1 !== width) { + throw new Error(`${path.basename(outputPath)}: line ${lineNumber} has ${tabs + 1} field(s) where ` + + `the header has ${width} \u2014 the row is torn: ${JSON.stringify(line.slice(0, 60))}`); + } +} + /** * Every row has exactly the header's field count, checked without holding the * file in memory. diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index 447c6fcd..26287df5 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -331,8 +331,13 @@ export class TypeScriptProjectAnalyzer { for (const file of files) { let sourceText: string; + // the closure walk read this file already; take its text and release it + const prefetched = rootProgram?.texts.get(file); + if (prefetched !== undefined) { + rootProgram!.texts.delete(file); + } try { - sourceText = await fsp.readFile(file, 'utf-8'); + sourceText = prefetched ?? await fsp.readFile(file, 'utf-8'); } catch (error) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, SkippedFileReason.READ_ERROR, String(error)); @@ -609,7 +614,13 @@ export class TypeScriptProjectAnalyzer { function filesOfRootProgram( rootDir: string, configResolver: TsConfigResolver -): { readonly files: string[]; readonly others: string[]; readonly orphans: string[] } | undefined { +): { + readonly files: string[]; + readonly others: string[]; + readonly orphans: string[]; + /** What the closure walk already read, so the extraction pass reads nothing twice. */ + readonly texts: Map; +} | undefined { const configPath = path.join(rootDir, 'tsconfig.json'); if (!fs.existsSync(configPath)) { return undefined; @@ -660,6 +671,11 @@ function filesOfRootProgram( // by a nested tsconfig stays in that program, which is what keeps a nested // project's separate global scope separate. const rootOptions = configResolver.resolve(claimed[0] ?? configPath).options; + // One resolution cache for the whole walk: ts.resolveModuleName with a bare + // ts.sys re-probes the same node_modules directories for every specifier, + // and the probing (statSync/readdirSync) was most of this pass's cost. + const resolutionCache = ts.createModuleResolutionCache(rootDir, (f) => f, rootOptions); + const texts = new Map(); const included = new Set(claimed.map((f) => path.normalize(f))); const available = new Map(unclaimed.map((f) => [path.normalize(f), f])); const queue = [...claimed]; @@ -671,13 +687,14 @@ function filesOfRootProgram( } catch { continue; } + texts.set(current, text); // No parent pointers and no type nodes needed: this pass only reads // specifiers, so the cheapest possible parse is the right one. const script = scriptTextOf(current, text); const sf = ts.createSourceFile(current, script.text, ts.ScriptTarget.Latest, false, script.scriptKind ?? (current.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS)); for (const specifier of importSpecifiersOf(sf)) { - const resolved = ts.resolveModuleName(specifier, current, rootOptions, ts.sys) + const resolved = ts.resolveModuleName(specifier, current, rootOptions, ts.sys, resolutionCache) .resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, current); if (resolved === undefined) { continue; @@ -702,7 +719,7 @@ function filesOfRootProgram( orphans.push(f); } } - return { files, others, orphans }; + return { files, others, orphans, texts }; } /** From e6fb608505a9788585890681ee7761f484cb3543 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:40:09 -0700 Subject: [PATCH 027/133] Only the supported surface is dispatched: find, graph, diff and install are gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher still carried every verb the product no longer offers. The installed command now delegates only the verbs the public help teaches (impact, path, tests, index — read from the frontend's own leading help, so the two cannot drift) and tells a typo exactly that list. find, graph, diff and install leave the dispatcher, and their scripts (axiomcode-graph, axiomcode-diff, axiomcode-install, viewer.html) leave the tree; git history keeps them. context, changed and test-impact stay dispatched, undocumented: the orient and edit hooks and the suites call them (now through the dispatcher or the verb scripts, never the installed command). --grep and the evidence switches stay for the same callers — ax_grep.py orders the front door's places, and the sites-one-per-line and evidence-for-uncertain-rows cases run both through the dispatcher in CI. The MCP server already offered only impact, path and tests; it now also drops the old eight-tool surface's plumbing — the flag-to-parameter rewriting (mcp_words) and the page/refresh/evidence/repo helpers — since the three tools take no options and plain() already drops any clause that names one. An unknown argument is still refused by name. front_door checks that a verb off the surface is refused with the supported list; surfaces audits the four-entry internal list; graph_verb and diff_verb go with their verbs; the suites that drove internal verbs through the installed command ask the dispatcher instead. Suites: front_door 21/21, surfaces, mcp, mcp_docs, mcp_first 30/30, freshness 118/118, latency 27/27, manifests, no_exec 7/7, repo_arg, mixed_separators, python_names, and the python cases sites-one-per-line + evidence-for-uncertain-rows 13/13. case_runner and multi_language fail only on checks that fail identically on the base commit (multi_language: one base failure passes now). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 13 +- plugins/axiomcode/mcp/server.py | 112 +---- .../skills/axiomcode/scripts/ax_blocks.py | 4 +- .../skills/axiomcode/scripts/ax_fresh.py | 2 +- .../skills/axiomcode/scripts/axiomcode | 79 ++-- .../skills/axiomcode/scripts/axiomcode-diff | 396 ------------------ .../skills/axiomcode/scripts/axiomcode-graph | 308 -------------- .../axiomcode/scripts/axiomcode-install | 147 ------- .../skills/axiomcode/scripts/viewer.html | 344 --------------- tests/case_runner.py | 8 +- tests/diff_verb.py | 146 ------- tests/freshness.py | 13 +- tests/front_door.py | 21 +- tests/graph_verb.py | 185 -------- tests/mcp.py | 37 -- tests/mcp_docs.py | 4 +- tests/mcp_first.py | 12 +- tests/multi_language.py | 32 +- tests/python_names.py | 8 +- tests/repo_arg.py | 4 +- tests/surfaces.py | 14 +- 21 files changed, 100 insertions(+), 1789 deletions(-) delete mode 100755 plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff delete mode 100755 plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph delete mode 100755 plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install delete mode 100644 plugins/axiomcode/skills/axiomcode/scripts/viewer.html delete mode 100644 tests/diff_verb.py delete mode 100644 tests/graph_verb.py diff --git a/bin/axiomcode b/bin/axiomcode index df482ec1..7cc39dd1 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -98,16 +98,17 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode" # `tests` is a query verb (the tests an edit reaches); `test` is THIS command's engine suite. The query # surface advertises `tests`; `test` is internal. -# The list is read from the frontend's own dispatch table, the lines ` [|]) exec …` that its `--verbs` -# prints, so the two entry points still cannot drift -- but read here, in this shell, instead of by a second bash, a -# sed, a tr and two greps on every query. +# THE LIST IS THE SUPPORTED SURFACE: the `# axiomcode ` lines of the frontend's own leading help (what its +# `--verbs` prints), so the two entry points cannot drift -- read here, in this shell, instead of by a second bash. +# The frontend still dispatches internal verbs (context, changed, test-impact) for the hooks and the suites, which +# call it directly; this command refuses them, so the installed surface is only what the help teaches. QVERBS=() load_verbs(){ [ ${#QVERBS[@]} -gt 0 ] && return 0; [ -f "$QUERY" ] || return 0 - local l v x re='^ ([a-z|-]*)\) *exec ' + local l re='^# axiomcode ([a-z][a-z-]*)' while IFS= read -r l || [ -n "$l" ]; do - [[ $l =~ $re ]] || continue; v="${BASH_REMATCH[1]}" - for x in ${v//|/ }; do QVERBS+=("$x"); done + [ "${l:0:1}" = '#' ] || { [ -n "${QVERBS[*]:-}" ] && break; continue; } + [[ $l =~ $re ]] && QVERBS+=("${BASH_REMATCH[1]}") done < "$QUERY" } query_verbs(){ load_verbs; [ ${#QVERBS[@]} -gt 0 ] && printf '%s\n' "${QVERBS[@]}"; return 0; } diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 4a9821ff..b7e72a62 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """The axiomcode entry as MCP tools — one tool per subcommand, each a thin shell-out to scripts/axiomcode so the answer is exactly what the CLI prints (and stays verified there). Descriptions are short on purpose: they sit in the agent's context every turn.""" -import inspect, os, re, subprocess, sys, typing +import inspect, os, re, subprocess, sys try: from mcp.server.mcpserver import MCPServer except ImportError: @@ -113,56 +113,18 @@ def scripts_module(name): _LOADED[0] = sig; _LOADED[1].add(d) return importlib.import_module(name) -# THE CLI'S WORDS, SPELLED AS THIS SURFACE SPELLS THEM (#1567). The answers are the CLI's, so their hints name CLI -# flags (`--in `, `--tests-only`, `--limit N`); an agent that sent those back as `in=`, `tests_only=` had them -# dropped without a word by the SDK, which ignores an argument it does not know, and got the unnarrowed answer as if -# it had been narrowed. So an argument no tool parameter answers to is refused, naming the parameter the CLI flag is -# here, and every flag in an answer that is a parameter here is written as that parameter. Flags only the CLI has -# (--json, --lang on a query) are left as they are. -PARAM = {'--in': 'in_path', '--tests-only': 'tests', '--tests': 'tests', '--tests-in': 'tests_in', '--from': 'from_', - '--why': 'why', '--source': 'source', '--explain': 'explain', '--every': 'every', '--staged': 'staged', - '--impact': 'impact', '--delete': 'delete', '--depth': 'depth', '--limit': 'limit', '--page': 'page', - '--budget': 'budget', '--kind': 'kind', '--range': 'range', '--fresh': 'fresh', '--no-refresh': 'refresh', - '--drop': 'drop', '--exact': 'exact', '--alongside': 'alongside'} -# a CLI switch that turns a parameter OFF: `--no-refresh` is refresh=False here -NEGATED = {'--no-refresh'} -SWITCH = {'--tests-only', '--tests', '--why', '--source', '--explain', '--every', '--staged', '--impact', '--delete', '--fresh', - '--exact', '--alongside'} +# AN ARGUMENT NO TOOL PARAMETER ANSWERS TO IS REFUSED (#1567): the SDK ignores an argument it does not know, so a +# call that sent one got the unnarrowed answer as if it had been narrowed. The tools take no options, so anything +# beyond their declared parameters is named back to the caller. PARAMS = {} # tool name -> its parameter names, filled as the tools are declared -# a flag, and its value when what follows looks like one (, 'x', N, 2, a.b, src/x) rather than prose ("no --in was given") -_FLAG = re.compile(r"(?]*>|'[^']*'|N(?:\|all)?(?![\w])|\d+(?![\w])|all(?![\w])|[a-z](?![\w.])|[\w*-]*[/.:*][^\s`'\"(),;\]]*))?") -# a line of quoted source (context --source), or a site of a grep-shaped answer (`path:line: code [tag]`, whose tag -# names no flag): never rewritten, since the code on it is the file's own text -_CODE = re.compile(r'^\s*(\d+ )?\| |^[^\s:]+:\d+: ') - -def mcp_words(text): - """An answer with each CLI flag that is an MCP parameter written as that parameter: `--in ` -> `in_path=`, - `--tests-only` -> `tests=True`, `--limit N` -> `limit=N`. Lines of quoted code are left alone.""" - def one(m): - flag, sep, val = m.groups() - p = PARAM.get(flag) - if not p: return m.group(0) - if flag in SWITCH: return f"{p}=True" + (sep + val if val else '') - if flag in NEGATED: return f"{p}=False" + (sep + val if val else '') - if flag == '--drop' and val: return f'{p}=["{val}"]' # a list of rows: `--drop a.ts:3` is drop=["a.ts:3"] - if val == 'all': return f'{p}="all"' # `--page all` is page="all", a string, not a name - if val == 'N|all': return f'{p}=N or {p}="all"' - return f"{p}={val}" if val else p - return '\n'.join(l if _CODE.match(l) else _FLAG.sub(one, l) for l in text.split('\n')) def unknown_arguments(name, arguments): - """Why a call names an argument the tool does not take, with the parameter meant when it is a CLI flag's name - (in -> in_path, tests_only -> tests, from -> from_), or None when every argument is a parameter.""" + """Why a call names an argument the tool does not take, or None when every argument is a parameter.""" params = PARAMS.get(name) extra = [k for k in (arguments or {}) if params is not None and k not in params] if not extra: return None - said = [] - for k in extra: - meant = PARAM.get('--' + k.lstrip('-').replace('_', '-')) - said.append(f"{k}: unexpected argument" + (f" (the CLI's --{k.lstrip('-').replace('_', '-')} is {meant}= here)" - if meant in params else '')) - return f"invalid arguments for {name}: " + '; '.join(said) + f". {name} takes: {', '.join(params)}" + said = [f"{k}: unexpected argument" for k in extra] + return f"invalid arguments for {name}: " + '; '.join(said) + f". {name} takes: {', '.join(params) or 'no arguments'}" try: import importlib @@ -242,65 +204,9 @@ def run(args, cwd=None, timeout=900): # an answer given from a graph that predates some edit says so, and names the files (#1305); one given from a graph a # fallback engine built, in place of the checkout's own, names that engine if not r.returncode: out += ''.join('\n' + l for l in (r.stderr or '').splitlines() if l.startswith(('graph refresh:', 'graph built by:'))) - return head + (mcp_words(out.strip()) or f"(no output, exit {r.returncode})") - -# SITES, ONE PER LINE, BY DEFAULT. When the answer is a list of sites (who uses it, the hops of a chain, where a task -# lands, the tests to run) it comes the way grep prints: `path:line: code [resolved | one of a set | text | hop N]`, -# capped, the rest counted (scripts/ax_grep.py). The prose answer's sections, headers and explanations were most of what -# an agent read, and the fan-out it complained of. full=True, or asking for what only the prose carries (the code of a -# flow, test routes, a delete verdict, a later page), gives the verb's own answer, unchanged. -# A PAGE IS A NUMBER OR "all". The answers say `--page all` for the whole answer; the parameter took only an integer, so -# the hint could not be followed here, and an agent that sent page="all" was refused. page=2 and page="2" are page 2. -Page = typing.Union[int, str] - -def _page_arg(page): - p = str(page).strip().lower() if page is not None else '1' - if p == 'all': return 'all' - if not p.lstrip('-').isdigit(): - raise ToolError(f'page: expected a page number or "all", got {page!r}') - return None if int(p) == 1 else str(int(p)) - -def _paged(page): - return _page_arg(page) is not None - -def _pg(page): - v = _page_arg(page) - return ['--page', v] if v else [] - -def NOREF(refresh): - """refresh=False is the CLI's --no-refresh: a read-only query, which starts no rebuild of the graph""" - return [] if refresh else ['--no-refresh'] - -def grep(full, limit=0): - return [] if full else ['--grep'] + (['--grep-limit', str(limit)] if limit else []) - -# A REPOSITORY THAT IS NOT THERE IS REFUSED, NOT REPLACED. The dispatcher took the last argument that was a directory, -# else the working directory, so a repo= naming nothing answered for the server's working directory instead, and on one -# with no graph started a full build of it. The repo parameter is always the repository, so any value that is not a -# directory is an error that names it, and nothing is run. -def need_repo(repo): - if repo and not os.path.isdir(repo): - raise ToolError(f"repo: no such directory: {repo}. Nothing was built or asked; pass a directory that exists " - f"(an absolute path), or leave repo out to use {os.getcwd()}.") - -# EVIDENCE FOR THE UNCERTAIN ROWS (scripts/ax_evidence.py): evidence="on" gives each of the five strongest rows that is -# not an exact edge the line that decides it and what stands on it; "off" turns it off; empty leaves AXIOMCODE_EVIDENCE -# (off by default) to decide. drop=[file:line] asks again without those rows, exact=True with exact edges only. -EV_DOC = (' evidence="on"|"off": each of the 5 strongest rows that is not an exact edge ([by name], [one of a set], ' - '[registered] …) carries the line that decides it (where its receiver or key gets its value) and how many ' - 'callables and tests are reached only through it; drop=["file:line"] asks again without those rows, exact=True ' - 'with exact edges only, alongside=True lists the `alongside` rows the evidence view counts.') - -def ev(evidence='', drop=(), exact=False, alongside=False): - e = str(evidence or '').strip().lower() - a = (['--evidence'] if e in ('on', '1', 'true', 'yes') else ['--no-evidence'] if e in ('off', '0', 'false', 'no') else []) - return a + [x for d in (drop or []) if str(d).strip() for x in ('--drop', str(d).strip())] + (['--exact'] if exact else []) + (['--alongside'] if alongside else []) - -def _doc(f): - f.__doc__ = (f.__doc__ or '') + EV_DOC - return f + return head + (out.strip() or f"(no output, exit {r.returncode})") -# THE FOUR TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale +# THE TOOLS TAKE NO OPTIONS, so an answer never tells the agent to pass one. The notes the verbs add (a stale # graph, a refresh in flight) are kept for what they say; a clause that names a flag or a parameter to set is dropped. _OPTION = re.compile(r"(? -- · ax_blocks.py edits — an answer as numbered places, each with its code. +"""ax_blocks.py -- · ax_blocks.py edits — an answer as numbered places, each with its code. 1. src/shop/pricing.py:6 [by name · in total] ```python @@ -86,7 +86,7 @@ def block(repo, f, marks, span): def render(verb, doc, repo): code = ax_grep.Code(repo) - rows, _rest, foot = ax_grep.VERBS[{'find': 'context', 'tests': 'test-impact'}.get(verb, verb)](doc, code) + rows, _rest, foot = ax_grep.VERBS[{'tests': 'test-impact'}.get(verb, verb)](doc, code) sites = [] for _k, line in rows: m = SITE.match(line) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py index cce1ea3c..ee2a4a1e 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_fresh.py @@ -1322,7 +1322,7 @@ def mark_answer(out, stale, as_json): def query_names(verb, args, repo): """the names a query asks about (impact's targets, path's endpoints, context's --from), as written""" names, i, pos = [], 0, [] - if '--' in args: args = args[args.index('--') + 1:] # --grep runs the verb under ax_grep.py … --: not names + if '--' in args: args = args[args.index('--') + 1:] # the front door runs the verb under ax_blocks.py --: not names while i < len(args): a = args[i] if a in _FLAG_VALUE: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index 445a508d..680a356a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -16,27 +16,18 @@ # THE HELP ABOVE IS THE PUBLIC SURFACE: helptext() prints the comment block up to the blank line above. The verbs below # are still dispatched and keep every flag -- the hooks, the test suites and scripts call them -- but they are internal -# and are not advertised on any user- or agent-facing surface. find is context and tests is test-impact underneath; -# at the front door (bin/axiomcode sets AXIOMCODE_FRONT, the MCP server AXIOMCODE_SURFACE=mcp) a query with no flag -# answers as places with their code (ax_blocks.py), and any flag, AXIOMCODE_RAW=1 or a direct call gives the verb's -# own answer. +# and are not advertised on any user- or agent-facing surface, and the installed command (bin/axiomcode) refuses them. +# tests is test-impact underneath; at the front door (bin/axiomcode sets AXIOMCODE_FRONT, the MCP server +# AXIOMCODE_SURFACE=mcp) a query with no flag answers as places with their code (ax_blocks.py), and any flag, +# AXIOMCODE_RAW=1 or a direct call gives the verb's own answer. # # axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]] # the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory. -# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] [--no-refresh] [--grep] -# THE FRONT DOOR: the files and callables a task touches, from the problem statement — when there is no name -# to ask about yet. Deterministic: task terms scored against the graph's vocabulary by inverse document -# frequency, tests demoted, the closure walked from the best seed per term and ranked by nearest hop. -# --in is repeatable and takes a list, and several are COMBINED, not intersected, so a change that -# spans two roots is answerable in one call. -# Ends by saying what it could not see. Every other verb needs a name you already have; this one does not. -# axiomcode graph [] [--out ] [--no-refresh] -# the graph as one page, every language the repository was indexed in. Drawn from the existing graph when it is up -# to date (seconds, no engine run); rebuilt first only when a source file changed, and then with the --lang, --src -# and --library it was indexed with, never for a language the index left out; indexed first when there is none. -# Prints what it drew and the page's absolute path: /.axiomcode/graph/graph.html, or --out (a folder gets -# /.html, a .html path is used as given). The page embeds the sources, so a node opens its code. -# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--why] [--fresh] [--no-refresh] [--grep] +# axiomcode context "" [] [--in [,…]]… [--budget N] [--source] [--json] [--fresh] [--no-refresh] +# internal (the orient hook, the suites): the files and callables a task touches, from the problem statement. +# Deterministic: task terms scored against the graph's vocabulary by inverse document frequency, tests demoted, +# the closure walked from the best seed per term and ranked by nearest hop. +# axiomcode path [] [--every | --paths N] [--all] [--limit N] [--in ] [--json] [--why] [--fresh] [--no-refresh] # the shortest chain of calls from A to B per reached target, and with --every all the routes; through what? # '*' as one endpoint: path '*' X = everything that can reach X, with the entry points among them; path X '*' = everything X reaches. Endpoints exactly as written in the code: Owner.method, # method (free function or any owner), Type (all its methods), file.ts:123, file.py. Datalog over the graph; every @@ -46,7 +37,7 @@ # reference — in one vocabulary across all five languages. A hop that is not a call is marked and not counted. # --why (path and impact) says after the endpoint line how each name was resolved: the lookup step that matched, # up to five candidates with file:line, and why that one won or why the name matched nothing. -# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--why] [--fresh] [--no-refresh] [--grep] +# axiomcode impact […] [] [--tests] [--depth N] [--in ] [--limit N] [--json] [--kind k] [--why] [--fresh] [--no-refresh] # what has to be looked at again when a declaration changes: a method, a field / constant / enum member, a type, a # parameter (Owner.m(p)), a type parameter (Type), a local (Owner.m:v), Type. / Type.. Prints what # must change with it (overrides, subtypes), what directly touches it with WHY and how sure ([resolved] / [in scope] / @@ -58,38 +49,22 @@ # name, initializer; type header; body only; removed; added) — the working tree against the commit the graph was built # from, or a branch's commits (--range a..b reads from `git merge-base a b`) — each with the target `impact` takes; # --impact runs impact on all of them as one change set. Without git, name the files. -# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] [--no-refresh] [--grep] +# axiomcode test-impact [] […] [--range .. | --staged] [--in ] [--limit N] [--json] [--why] [--no-refresh] # which tests actually have to run for this edit: the test files that reach any changed declaration, with the # chain, so a selection can be checked rather than trusted. Conservative by design — a test reached only # through an edge the graph does not encode (reflection, a service loader) will NOT appear. -# axiomcode diff [--file ] [--lang ] [--limit N] [--json] -# what changed between two graphs of the same tree (graph.sqlite paths, or two indexed copies of it): call edges -# added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config -# bindings and symbols, with a summary line of counts per tier. Rows are matched by file, line, qualified name and -# callee, never by id (ids hash the index directory), so one tree indexed at two paths diffs to nothing. Reads both -# graphs as they are: neither is rebuilt or refreshed. # axiomcode help [] # every verb with what it does; with a verb, that verb's own usage, printed from the script that implements it. -# axiomcode install [] [--remove] [--print] -# write the preference into the repository's CLAUDE.md as one marked block: for a question about how code connects, -# prefer this over grep/Glob. States a preference, blocks nothing; re-running replaces, --remove takes it out. # # A repo in several languages is indexed in all of them, one graph each, and the query verbs ask every graph (the main # language's, the one with the most files, first). --lang restricts it to one language or a comma list, the first the main one; # --src limits the analysed tree (e.g. src); --library names dependency roots. --fresh (context, path, impact) waits for # a refresh in flight and answers from the new graph; without it an answer from a graph older than an edit marks its rows. -# --no-refresh (every query verb: context, path, impact, changed, test-impact, graph; MCP refresh=false) is a READ-ONLY +# --no-refresh (every query verb: context, path, impact, changed, test-impact; MCP refresh=false) is a READ-ONLY # query: the answer comes from the graph as it is and no rebuild is started, as AXIOMCODE_NO_REFRESH=1 does for a whole # shell; it still says which edits the graph predates. Without it a query on a graph that is out of date (files edited # since, or built by another axiomcode) starts a background rebuild with the engine this axiomcode uses, and says so on # the answer's first line. The hooks never rebuild a graph another axiomcode built; they say so once per session. -# --grep (context, path, impact, test-impact) prints the answer's sites one per line, as grep does: `path:line: [resolved | one of a set | by name | text | hop N | test]`, the first 30 (--grep-limit N) and a count of -# the rest, then whether it was verified and its bound. The MCP tools answer this way by default; full=True is the prose. -# --evidence / --no-evidence (or AXIOMCODE_EVIDENCE=on|off; off by default): each of the five strongest rows that is not an -# exact edge ([by name], [one of a set], [registered], …) carries the line that decides it — where its receiver or key -# gets its value — and how much of the answer is reached only through it. --drop (repeatable) asks again -# without that row, --exact with exact edges only; --alongside lists the `alongside` rows the evidence view counts. # ON WINDOWS $0 CAN MIX SEPARATORS: the MCP server joins its script path onto AXIOMCODE_PLUGIN_ROOT with os.path.join, # C:/.../plugins/axiomcode\skills\axiomcode\scripts\axiomcode. Splitting that on '/' alone lands on .../plugins, and # every verb then runs a helper that is not there. `dirname` split on either; so does this, as bin/axiomcode does. @@ -127,6 +102,8 @@ if [ -z "${AXIOMCODE_BASH:-}" ] && command -v cygpath >/dev/null 2>&1; then AXIO # a hardcoded verb list is how `bin/axiomcode` would drift from it next. verbs(){ sed -n 's/^ \([a-z|-]*\)) *exec .*/\1/p' "$0" | tr '|' '\n'; } helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; } +# the verbs the help advertises: THE SUPPORTED SURFACE, what bin/axiomcode delegates and an unknown verb is told about +public_verbs(){ helptext | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; } # one verb's own usage, from the script that implements it: a python docstring, or a bash file's # leading comment block. The verb documents itself once, where it is implemented. verbhelp(){ @@ -135,8 +112,8 @@ verbhelp(){ helptext | awk -v v="$1" '$0 ~ "^ axiomcode "v"( |$)" {on=1; print; next} on && /^ axiomcode / {exit} on && /^$/ {exit} on {print}' return 0 ;; esac - local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact"; [ "$1" = find ] && f="$H/axiomcode-context" - [ -f "$f" ] || { echo "axiomcode: no such verb '$1' — try: $(verbs | tr '\n' ' ')" >&2; return 2; } + local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact" + [ -f "$f" ] || { echo "axiomcode: no such verb '$1' — try: $(public_verbs | tr '\n' ' ')" >&2; return 2; } if head -1 "$f" | grep -q python; then python3 -c 'import ast,sys; print(ast.get_docstring(ast.parse(open(sys.argv[1]).read())) or "")' "$f" else awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$f"; fi } @@ -175,15 +152,14 @@ case "${ARGS[0]:-}" in -h|--help) if [ -n "$cmd" ] && [ "${#ARGS[@]}" = 1 ]; the # check and an exec. The repository is the last argument that is a directory, as the verbs themselves take it, else the # working directory. No graph: nothing here, the verb builds its first one. # `changed` and `test-impact` ask about an edit, against the graph of the baseline (kept aside by a refresh), so they do -# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. `graph` checks the graph -# itself: it draws from it when it is current, and rebuilds it with its own recorded flags only when it is not. +# not wait for the graph; they wait only when HEAD moved and the baseline has to follow it. # A flag's VALUE is never the repository: `--in plugins/core` names a directory under it, and taking that for the # repository found no .axiomcode/lang there, so a repository in several languages was asked in its main graph alone and # a scope that only another language's graph holds was refused as absent. # A REPOSITORY THAT IS NOT THERE IS AN ERROR, NOT THE WORKING DIRECTORY. A repository argument naming no directory was # taken for a target (impact) or passed over, the working directory answered instead, and on one with no graph a full # build of it started: a parent of many projects, for minutes. The repository sits at a fixed place for most verbs -# (index and graph: the first argument; context: after the task; path: after the two endpoints), so anything there that +# (index: the first argument; context: after the task; path: after the two endpoints), so anything there that # is not a directory is refused. impact takes several targets and changed/test-impact take files, so there only an # argument WRITTEN as a directory is taken for one: absolute, ./ ../ ~, a trailing slash, or a path whose last part has # no extension, and no :line. `src/a.py`, `a.py:12` and `Owner.m` are still targets and files. The hooks call the verb @@ -200,8 +176,7 @@ done LASTPOS=""; [ "${#POS[@]}" -gt 0 ] && LASTPOS="${POS[${#POS[@]}-1]}" case "$cmd" in index|build) if [ "${#POS[@]}" -gt 0 ] && [ ! -d "${POS[0]}" ]; then gone "${POS[0]}"; fi ;; - graph) case "${POS[0]:-}" in ""|build|export|draw) ;; *) [ -d "${POS[0]}" ] || gone "${POS[0]}" ;; esac ;; - context|find) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; + context) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; path) if [ "${#POS[@]}" -gt 2 ] && [ ! -d "${POS[2]}" ]; then gone "${POS[2]}"; fi ;; impact) if [ "${#POS[@]}" -gt 1 ] && [ ! -e "$LASTPOS" ] && dirlike "$LASTPOS"; then gone "$LASTPOS"; fi ;; changed|test-impact|tests) if [ "${#POS[@]}" -gt 0 ] && [ ! -e "${POS[0]}" ] && dirlike "${POS[0]}"; then gone "${POS[0]}"; fi ;; @@ -211,9 +186,9 @@ FR=.; skip=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do case "$a" in --in|--from|--budget|--seeds|--depth|--limit|--tests-in|--kind|--range|--old|--new|--file|--page|--page-budget|--out) skip=1; continue ;; esac [ -d "$a" ] && FR="$a" done -# --grep (context, path, impact, test-impact): the answer's sites one per line, `path:line: code [tag]`, the way grep -# prints them (ax_grep.py). The verb answers with --json and the sites are printed from that document, so the two -# shapes are one answer. Without it the answer is the verb's own, unchanged. +# --grep (internal: the suites and their cases): the answer's sites one per line, `path:line: code [tag]`, the way +# grep prints them (ax_grep.py, which also orders the front door's places). The verb answers with --json and the +# sites are printed from that document, so the two shapes are one answer. G=() case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(python3 "$H/ax_grep.py" "$cmd" "$FR" --limit "${GREP_LIMIT:-30}" --) ;; esac # THE SMALL SURFACE: impact, path and tests, asked with no flags at the front door (the installed `axiomcode` and @@ -225,7 +200,6 @@ case "$cmd" in context|path|impact|test-impact|tests) [ -n "${GREP:-}" ] && G=(p B=""; FRONT="${AXIOMCODE_FRONT:-}"; [ "${AXIOMCODE_SURFACE:-}" = mcp ] && FRONT=1; [ -n "${AXIOMCODE_RAW:-}${GREP:-}" ] && FRONT="" if [ -n "$FRONT" ]; then for a in ${ARGS[@]+"${ARGS[@]}"}; do case "$a" in -*) FRONT="" ;; esac; done; fi case "$cmd" in - find) cmd=context; [ -n "$FRONT" ] && B=find ;; path) [ -n "$FRONT" ] && B=path ;; tests|test-impact) [ -n "$FRONT" ] && B=tests ;; impact) if [ -n "$FRONT" ]; then @@ -248,16 +222,13 @@ if [ -f "$FR/.axiomcode/out/graph.sqlite" ] || [ -L "$FR/.axiomcode/out/graph.sq fi case "$cmd" in index|build) exec bash "$H/axiomcode-build" ${ARGS[@]+"${ARGS[@]}"} ;; - context|find) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; + context) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; path) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-path" ${ARGS[@]+"${ARGS[@]}"} ;; impact) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-impact" ${ARGS[@]+"${ARGS[@]}"} ;; changed) exec python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-changed" ${ARGS[@]+"${ARGS[@]}"} ;; test-impact|tests) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-test-impact" ${ARGS[@]+"${ARGS[@]}"} ;; - graph) exec python3 "$H/axiomcode-graph" ${ARGS[@]+"${ARGS[@]}"} ;; - diff) exec python3 "$H/axiomcode-diff" ${ARGS[@]+"${ARGS[@]}"} ;; - install) exec python3 "$H/axiomcode-install" ${ARGS[@]+"${ARGS[@]}"} ;; - --verbs) verbs ;; # what this dispatcher answers to; bin/axiomcode asks, so the two cannot drift + --verbs) verbs ;; # the dispatch table, internal verbs included; tests/surfaces.py audits it ""|-h|--help|help) if [ ${#ARGS[@]} -gt 0 ]; then verbhelp "${ARGS[0]}"; else helptext; fi ;; - *) echo "axiomcode: unknown subcommand '$cmd' — the entry point has: $(verbs | tr '\n' ' ')" >&2 + *) echo "axiomcode: unknown subcommand '$cmd' — the entry point has: $(public_verbs | tr '\n' ' ')" >&2 echo " \`axiomcode help\` for what each one does, \`axiomcode help \` for one of them" >&2; exit 2 ;; esac diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff deleted file mode 100755 index b0563bb8..00000000 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-diff +++ /dev/null @@ -1,396 +0,0 @@ -#!/usr/bin/env python3 -"""axiomcode diff: what changed between two graphs of the same tree. - - axiomcode diff [--file ] [--lang ] [--limit N] [--json] - - and are each a graph.sqlite, or a directory that holds one: a repository indexed with -`axiomcode index` (every language graph under its .axiomcode is compared, paired by language), or an out directory. -The usual pair is one tree indexed twice, before and after a change to the engine, the rules or the source, at two -paths (copy the tree, index each copy). - -Rows are matched by what stays the same when the same tree is indexed somewhere else: a file path relative to the -tree, a line and column, a qualified name, a callee as written. Never by id: an id hashes the index directory, so two -indexes of one tree share none. Two indexes of the same tree with the same engine therefore diff to nothing. - -Printed per language, A to B: - summary one line of counts per kind, and the call edges per tier (A -> B, the change) - call edges + added, - removed, ~ the same callee at another tier or call kind, > a site whose callees changed - (old set => new set), each at the line and column the call is written on, with its caller - entry points + / - a method the runtime calls, with the reason - reachable + / - a method reachable from an entry point - remote edges + / - a hop across a process (transport, destination, sender -> handler) - framework + / - an in-process framework hop (Python: a task, a signal, a dependency provider) - config + / - a configuration key bound into a declaration (Java) - symbols + / - a declaration (kind, qualified name, file:line) - ---file keeps the rows with a file containing the fragment (the site's, the caller's, the callee's or the -declaration's), and counts only those. --limit N rows per section (default 40, 0 for all); the counts are always of -the whole diff. --json prints every row. Neither graph is rebuilt or refreshed: each is read as it is. -""" -import argparse, collections, json, os, sqlite3, sys - -KINDS = ('calls', 'entry_points', 'reachable', 'remote', 'framework', 'config', 'symbols') -TITLE = {'calls': 'call edges', 'entry_points': 'entry points', 'reachable': 'reachable from an entry point', - 'remote': 'remote edges', 'framework': 'framework edges', 'config': 'config bindings', 'symbols': 'symbols'} - - -def die(msg): - print(f"axiomcode diff: {msg}", file=sys.stderr) - sys.exit(2) - - -def language_of(path): - try: - con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) - r = con.execute("SELECT value FROM run WHERE key = 'language'").fetchone() - con.close() - return r[0] if r else '?' - except sqlite3.Error as e: - die(f"{path} is not a graph ({e})") - - -def graphs_of(arg): - """{language: graph.sqlite} for one argument""" - p = os.path.abspath(arg) - if os.path.isfile(p): - return {language_of(p): p} - if not os.path.isdir(p): - die(f"{arg}: no such file or directory") - found = [] - ax = os.path.join(p, '.axiomcode') - if os.path.isdir(ax): - found.append(os.path.join(ax, 'out', 'graph.sqlite')) - ld = os.path.join(ax, 'lang') - if os.path.isdir(ld): - found += [os.path.join(ld, l, 'out', 'graph.sqlite') for l in sorted(os.listdir(ld))] - else: - found.append(os.path.join(p, 'graph.sqlite')) - found.append(os.path.join(p, 'out', 'graph.sqlite')) - out = {} - for f in found: - if os.path.isfile(f): - out.setdefault(language_of(f), os.path.realpath(f)) - if not out: - die(f"{arg}: no graph.sqlite in it (index it with `axiomcode index {arg}`, or name the graph.sqlite)") - return out - - -class Graph: - def __init__(self, path): - self.path = path - self.con = sqlite3.connect(f"file:{path}?mode=ro", uri=True) - self.tables = {r[0] for r in self.con.execute("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')")} - self.run = dict(self.q("SELECT key, value FROM run")) if 'run' in self.tables else {} - # the prefixes an absolute path in this graph starts with: the tree it was built from, and the directory - # holding .axiomcode (a --src build records the subtree). Longest first. - roots = {self.run.get('source_dir', '')} - d = os.path.dirname(os.path.realpath(path)) - while d and d != os.path.dirname(d): - if os.path.basename(d) == '.axiomcode': - roots.add(os.path.dirname(d)) - break - d = os.path.dirname(d) - self.roots = sorted((r.rstrip('/\\') + '/' for r in roots if r), key=len, reverse=True) - self.paths = dict(self.q("SELECT raw, rel FROM paths")) if 'paths' in self.tables else {} - self._decl() - - def q(self, sql, *p): - return self.con.execute(sql, p).fetchall() - - def has(self, t): - return t in self.tables - - def rel(self, f): - if not f: - return '' - f = f.replace('\\', '/') - if f in self.paths and self.paths[f]: - return self.paths[f] - for r in self.roots: - if f.startswith(r): - return f[len(r):] - return f - - def _decl(self): - """id -> (kind, qualified name, file, line), from every table that declares one""" - self.decl = {} - put = self.decl.setdefault - if self.has('symbols'): - for i, mid, tid, kind, qn, f, line in self.q( - "SELECT id, method_id, type_id, kind, qualified_name, file, line FROM symbols"): - d = (kind or '', qn or '', self.rel(f), line or 0) - for k in (i, mid, tid): - if k: put(k, d) - if self.has('methods'): - for i, qn, f, line in self.q("SELECT id, qualified_name, file_path, start_line FROM methods"): - put(i, ('method', qn or '', self.rel(f), line or 0)) - if self.has('types'): - for i, qn, f, line in self.q("SELECT id, qualified_name, file_path, start_line FROM types"): - put(i, ('type', qn or '', self.rel(f), line or 0)) - if self.has('fields'): - for i, qn, n, f, line in self.q("SELECT id, owner_qualified_name, name, file_path, start_line FROM fields"): - put(i, ('field', f"{qn}.{n}" if qn else n, self.rel(f), line or 0)) - - def name(self, i, label=''): - """a declaration by its stable key: `qualified.name file:line`; a library callee by its label""" - d = self.decl.get(i) if i else None - if d: - return f"{d[1]} {d[2]}:{d[3]}" if d[2] else d[1] - if label: - return label - return f"" if i else '?' - - def files_of(self, i): - d = self.decl.get(i) - return [d[2]] if d and d[2] else [] - - # ---- the facts, each as {stable key: (row for display, files it touches)} ------------------------------------- - - def calls(self): - """{site: {(callee, tier, kind)}} and the site's files""" - if not self.has('call_edges'): - return {}, {} - view = 'sites' if self.has('sites') else 'call_sites' - fcol = 'file' if view == 'sites' else 'file_path' - sites = {} - for i, f, line, col in self.q(f"SELECT id, {fcol}, start_line, start_column FROM {view}"): - sites[i] = (self.rel(f), line or 0, col or 0) - out = collections.defaultdict(set); files = {} - for sid, caller, callee, label, tier, kind in self.q( - "SELECT call_site_id, caller_id, callee_method_id, callee_label, tier, kind FROM call_edges"): - f, line, col = sites.get(sid) or ((self.files_of(caller) or [''])[0], 0, 0) - key = (f, line, col, self.name(caller)) - out[key].add((self.name(callee, label), tier, kind)) - files[key] = {f, *self.files_of(caller)} - files.setdefault(('callee', key), set()).update(self.files_of(callee)) - return out, files - - def entry_points(self): - if not self.has('entry_points'): - return {} - return {(self.name(m), reason): self.files_of(m) for m, reason in self.q("SELECT method_id, reason FROM entry_points")} - - def reachable(self): - if not self.has('entry_reachable'): - return {} - return {(self.name(m),): self.files_of(m) for (m,) in self.q("SELECT method_id FROM entry_reachable")} - - def _hops(self, table): - if not self.has(table): - return {} - return {(r[2] or '', r[3] or '', self.name(r[0]), self.name(r[1]), r[4] or ''): self.files_of(r[0]) + self.files_of(r[1]) - for r in self.q(f"SELECT c0, c1, c2, c3, c4 FROM {table}")} - - def remote(self): - return self._hops('ext_remote_edge') - - def framework(self): - return self._hops('ext_framework_edge') - - def config(self): - if not self.has('ext_config_binding'): - return {} - out = {} - for key, mech, tkind, target, owner in self.q("SELECT c0, c1, c2, c3, c4 FROM ext_config_binding"): - # a parameter's id joins nothing: it is named by its owner type instead, which is stable - t = self.name(target) if target in self.decl else f"a {tkind} of {self.name(owner)}" - out[(key, mech, tkind, t)] = self.files_of(target) + self.files_of(owner) - return out - - def symbols(self): - if not self.has('symbols'): - return {} - out, self.sigs = {}, {} - for k, qn, f, line, sig in self.q("SELECT kind, qualified_name, file, line, signature FROM symbols"): - key = (k or '', qn or '', self.rel(f), line or 0) - out[key] = [key[2]] - self.sigs.setdefault(key, set()).add(sig or '') - return out - - -def keep(files, frag): - return not frag or any(frag in (f or '') for f in files) - - -def diff_calls(A, B, frag): - (ea, fa), (eb, fb) = A.calls(), B.calls() - rows = {'added': [], 'removed': [], 'retiered': [], 'changed': []} - tiers = collections.defaultdict(lambda: [0, 0]) - for side, edges, files in ((0, ea, fa), (1, eb, fb)): - for site, cs in edges.items(): - if keep(files[site] | files.get(('callee', site), set()), frag): - for _c, tier, _k in cs: - tiers[tier][side] += 1 - for site in sorted(set(ea) | set(eb)): - a, b = ea.get(site, set()), eb.get(site, set()) - if a == b: - continue - fs = fa.get(site, set()) | fb.get(site, set()) | fa.get(('callee', site), set()) | fb.get(('callee', site), set()) - if not keep(fs, frag): - continue - f, line, col, caller = site - at = {'file': f, 'line': line, 'column': col, 'caller': caller} - if not a or not b: - for c, tier, kind in sorted(b or a): - rows['added' if b else 'removed'].append(dict(at, callee=c, tier=tier, kind=kind)) - continue - ca, cb = collections.defaultdict(set), collections.defaultdict(set) - for c, tier, kind in a: ca[c].add((tier, kind)) - for c, tier, kind in b: cb[c].add((tier, kind)) - if set(ca) == set(cb): - for c in sorted(ca): - if ca[c] != cb[c]: - rows['retiered'].append(dict(at, callee=c, before=sorted(ca[c]), after=sorted(cb[c]))) - else: - rows['changed'].append(dict(at, before=sorted(a - b), after=sorted(b - a))) - return rows, {t: v for t, v in sorted(tiers.items())} - - -def diff_set(ka, kb, frag, fields): - add = [dict(zip(fields, k)) for k in sorted(set(kb) - set(ka)) if keep(kb[k], frag)] - rem = [dict(zip(fields, k)) for k in sorted(set(ka) - set(kb)) if keep(ka[k], frag)] - return {'added': add, 'removed': rem} - - -FIELDS = {'entry_points': ('method', 'reason'), 'reachable': ('method',), - 'remote': ('transport', 'destination', 'from', 'to', 'confidence'), - 'framework': ('mechanism', 'name', 'from', 'to', 'certainty'), - 'config': ('key', 'mechanism', 'target_kind', 'target'), - 'symbols': ('kind', 'qualified_name', 'file', 'line')} - - -def diff_lang(pa, pb, frag): - A, B = Graph(pa), Graph(pb) - calls, tiers = diff_calls(A, B, frag) - res = {'calls': calls, 'tiers': tiers} - for k in KINDS[1:]: - ka, kb = getattr(A, k)(), getattr(B, k)() - res[k] = diff_set(ka, kb, frag, FIELDS[k]) - # a declaration at the same place under the same name whose signature changed (a parameter added, a type changed) - res['symbols']['changed'] = [dict(zip(FIELDS['symbols'], key), before=sorted(A.sigs[key]), after=sorted(B.sigs[key])) - for key in sorted(set(A.sigs) & set(B.sigs)) - if A.sigs[key] != B.sigs[key] and keep([key[2]], frag)] - meta = lambda g: {'graph': g.path, 'engine': g.run.get('engine_commit', '')[:8], 'built': g.run.get('created_at', ''), - 'source_dir': g.run.get('source_dir', '')} - res['a'], res['b'] = meta(A), meta(B) - return res - - -def counts(res): - c = res['calls'] - out = {'calls': {k: len(v) for k, v in c.items()}} - for k in KINDS[1:]: - out[k] = {'added': len(res[k]['added']), 'removed': len(res[k]['removed'])} - if 'changed' in res[k]: out[k]['changed'] = len(res[k]['changed']) - return out - - -def fmt_row(kind, r): - if kind == 'entry_points': - return f"{r['method']} [{r['reason']}]" - if kind == 'reachable': - return r['method'] - if kind == 'remote': - return f"{r['transport']} {r['destination']} {r['from']} -> {r['to']} ({r['confidence']})" - if kind == 'framework': - return f"{r['mechanism']} {r['name']} {r['from']} -> {r['to']} ({r['certainty']})" - if kind == 'config': - return f"{r['key']} {r['mechanism']} -> {r['target']}" - return f"{r['kind']} {r['qualified_name']} {r['file']}:{r['line']}" - - -def edge(c): - return f"{c[0]} [{c[1]}, {c[2]}]" - - -def show(lang, res, limit): - n = counts(res) - c = n['calls'] - parts = [f"call edges +{c['added']} -{c['removed']} ~{c['retiered']} >{c['changed']}"] - parts += [f"{TITLE[k]} +{n[k]['added']} -{n[k]['removed']}" + (f" ~{n[k]['changed']}" if 'changed' in n[k] else '') - for k in KINDS[1:]] - print(f"{lang}: A {res['a']['graph']} (engine {res['a']['engine'] or '?'})") - print(f"{' ' * len(lang)} B {res['b']['graph']} (engine {res['b']['engine'] or '?'})") - print("summary: " + " · ".join(parts)) - # every tier, the changed ones first: a count that did not move is as much the answer as one that did - tl = [f"{t} {a} -> {b} ({b - a:+d})" for t, (a, b) in res['tiers'].items() if a != b] - tl += [f"{t} {a} (=)" for t, (a, b) in res['tiers'].items() if a == b] - print("call edges per tier: " + (" · ".join(tl) if tl else "none in either graph")) - if not any(c.values()) and not any(any(v.values()) for k, v in n.items() if k != 'calls'): - print("no difference") - return - - def cap(rows): - return rows if not limit else rows[:limit] - - calls = res['calls'] - if any(c.values()): - print(f"\ncall edges ({c['added']} added, {c['removed']} removed, {c['retiered']} retiered, {c['changed']} sites re-targeted):") - for sign, k in (('+', 'added'), ('-', 'removed')): - for r in cap(calls[k]): - print(f" {sign} {r['file']}:{r['line']}:{r['column']} {r['caller']} -> {edge((r['callee'], r['tier'], r['kind']))}") - if limit and len(calls[k]) > limit: print(f" … +{len(calls[k]) - limit} more {k} (--limit 0 for all)") - for r in cap(calls['retiered']): - print(f" ~ {r['file']}:{r['line']}:{r['column']} {r['caller']} -> {r['callee']} " - f"{', '.join('/'.join(x) for x in r['before'])} => {', '.join('/'.join(x) for x in r['after'])}") - if limit and len(calls['retiered']) > limit: print(f" … +{len(calls['retiered']) - limit} more retiered (--limit 0 for all)") - for r in cap(calls['changed']): - print(f" > {r['file']}:{r['line']}:{r['column']} {r['caller']}") - for x in r['before']: print(f" - {edge(x)}") - for x in r['after']: print(f" + {edge(x)}") - if limit and len(calls['changed']) > limit: print(f" … +{len(calls['changed']) - limit} more re-targeted sites (--limit 0 for all)") - for k in KINDS[1:]: - d = res[k] - if not d['added'] and not d['removed'] and not d.get('changed'): - continue - print(f"\n{TITLE[k]} ({len(d['added'])} added, {len(d['removed'])} removed" - + (f", {len(d['changed'])} with another signature" if 'changed' in d else '') + "):") - for sign, s in (('+', 'added'), ('-', 'removed')): - for r in cap(d[s]): - print(f" {sign} {fmt_row(k, r)}") - if limit and len(d[s]) > limit: print(f" … +{len(d[s]) - limit} more {s} (--limit 0 for all)") - for r in cap(d.get('changed', [])): - print(f" ~ {fmt_row(k, r)} {' | '.join(r['before'])} => {' | '.join(r['after'])}") - if limit and len(d.get('changed', [])) > limit: print(f" … +{len(d['changed']) - limit} more changed (--limit 0 for all)") - - -def main(): - ap = argparse.ArgumentParser(prog='axiomcode diff', add_help=True, description=__doc__.split('\n')[0]) - ap.add_argument('a'); ap.add_argument('b') - ap.add_argument('--file', default='') - ap.add_argument('--lang', default=os.environ.get('AXIOMCODE_LANG', '')) - ap.add_argument('--limit', type=int, default=40) - ap.add_argument('--json', action='store_true') - o = ap.parse_args() - ga, gb = graphs_of(o.a), graphs_of(o.b) - want = [l for l in o.lang.split(',') if l] if o.lang else None - langs = [l for l in sorted(set(ga) | set(gb)) if not want or l in want] - if not langs: - die(f"no graph in language {o.lang} (A has {', '.join(sorted(ga))}; B has {', '.join(sorted(gb))})") - out, notes = {}, [] - for l in langs: - if l not in ga or l not in gb: - notes.append(f"{l}: only in {'A' if l in ga else 'B'} ({(ga.get(l) or gb.get(l))}), not compared") - continue - if os.path.realpath(ga[l]) == os.path.realpath(gb[l]): - notes.append(f"{l}: A and B are the same file ({ga[l]}); nothing to compare") - out[l] = diff_lang(ga[l], gb[l], o.file) - if o.json: - print(json.dumps({'file': o.file, 'languages': {l: dict(r, counts=counts(r)) for l, r in out.items()}, - 'notes': notes}, indent=1, default=list)) - return 0 - first = True - for l, r in out.items(): - if not first: print() - first = False - show(l, r, o.limit) - if o.file: print(f"\nscoped to rows with a file containing '{o.file}'") - for n in notes: print(n) - return 0 - - -if __name__ == '__main__': - try: - sys.exit(main()) - except BrokenPipeError: - sys.exit(0) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph deleted file mode 100755 index af4b1b6c..00000000 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-graph +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env python3 -"""axiomcode graph — the graph of a codebase as one interactive page. - - axiomcode graph [] [--out ] [--no-refresh] - axiomcode graph [] --lang [,…] [--src ] [--library [,…]] [--out …] - -With no flags the page is drawn from the graph `axiomcode index` built, and the engine does not run: - - the graph is up to date (no source file differs from the ones it was built from): the page is written from it - in seconds. - - it is out of date: it is rebuilt first exactly as it was indexed, with the same --lang, --src and --library, so - only the languages the index chose are solved and no graph appears for a language it left out. - - there is no graph yet: the repository is indexed first (every language present, or the flags given). -Flags given here are a request for that graph, as `axiomcode index` takes them. ---no-refresh (MCP refresh=false, or AXIOMCODE_NO_REFRESH=1) is read-only: the page is drawn from the graph that is there -even when it is out of date, and the graph is never rebuilt; the answer says how many files it predates. - -A repository indexed in several languages is drawn in all of them, on one page. The command prints what it drew as -prose, and the page's absolute path last. `axiomcode graph export []` writes the page from the graph as it is, -with no freshness check. - ---library names the dependency roots (for Java the JDK and the third-party sources/IR), and is given to -`axiomcode index`. With them a call into a dependency resolves to a BLACK BOX (one per library type, drawn outside the -disc in its own colour); without them it is unresolved, and the resolution rate is understated. The rate the page -reports is over calls into client code; external calls are counted separately. - -The page (default /.axiomcode/graph/graph.html; one file, no network) draws the codebase as a disc: -directories in the centre, their files on the next ring, the types in those files, and every method on the -outside, each placed under its parent — and the engine's call edges across it, each with its tier. Hover a -node to light up what it calls and what calls it; click it for the detail panel, its impact (everything that -can reach it through resolved calls) or the chain from it. A call the engine could not resolve is a count on -the caller, never a drawn edge. -""" -import os, re, sys, json, time, sqlite3, collections, subprocess - -HERE = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, HERE); import ax_contract, ax_fresh -TESTRE = re.compile(r'(^|/)(tests?|testing|__tests__|test-data|fixtures?)(/|$)|(^|/)test_|conftest\.py$|/src/test/|Tests?\.java$|\.(spec|test)\.[cm]?[tj]sx?$') -METHOD_KINDS = ('function', 'method', 'constructor', 'module') -TYPE_KINDS = ('class', 'interface', 'enum', 'type', 'namespace') - - -def _b36(n): - s = '' - while n: n, r = divmod(n, 36); s = '0123456789abcdefghijklmnopqrstuvwxyz'[r] + s - return s - - -def graph_dbs(repo, gdir): - """every graph to draw: the main language's, then each other language's (.axiomcode/lang/), as the query verbs - ask them. A graph named in AXIOMCODE_GRAPH was chosen by the caller and is drawn alone.""" - dbs = [os.path.join(gdir, 'out', 'graph.sqlite')] - if not os.environ.get('AXIOMCODE_GRAPH'): - ld = os.path.join(repo, '.axiomcode', 'lang') - for l in sorted(os.listdir(ld)) if os.path.isdir(ld) else (): - d = os.path.join(ld, l, 'out', 'graph.sqlite') - if not os.path.exists(d): continue - why = ax_fresh.graph_corrupt(d) - # another language's corrupt graph is left out of the page, said, rather than ending the page in a traceback - if why: print(f"graph refresh: the {l} graph at {d} is corrupt ({why}) and is left out of this page; `axiomcode index` rebuilds it", file=sys.stderr) - else: dbs.append(d) - return dbs - - -def export(repo, page=None, how=''): - repo = os.path.realpath(repo or '.') - gdir = ax_contract.usable_graph(repo, os.environ.get('AXIOMCODE_GRAPH') or os.path.join(repo, '.axiomcode')) - db = os.path.join(gdir, 'out', 'graph.sqlite') - # the same door every other verb goes through: built on demand under the CLI, refused in words - # that name the one command that helps under anything else. - if not os.path.exists(db) and not ax_contract.ensure_graph(repo, db): sys.exit(ax_contract.no_graph(repo, db)) - # A REPOSITORY IN SEVERAL LANGUAGES is drawn in all of them: one page, each language's graph merged into it (their ids - # carry the language, so none collide; a directory holding two languages' files is one node). No edge joins two - # languages, as no query follows one. - nodes, owners, lib, nesting, anc, langs, runs = {}, {}, {}, {}, collections.defaultdict(set), [], [] - E, unresolved, terminal, LE = {}, collections.Counter(), collections.Counter(), {} - boxes = collections.defaultdict(lambda: dict(sites=0, callers=set(), methods=collections.Counter())) # one black box per library type - def box(c, owner, name): - b = boxes[owner]; b['sites'] += 1; b['callers'].add(c); b['methods'][name] += 1 - le = LE.get((c, owner)); LE[(c, owner)] = dict(s=c, t='L:' + owner, w=(le['w'] + 1) if le else 1) - sites = collections.defaultdict(list) # per caller: [line, written name, target id ('' = none), tier] — what makes a call in the source a link - def site(c, r, target): - if r['line'] and len(sites[c]) < 400: sites[c].append([r['line'], r['written'] or '', target, r['tier']]) - skipped, tiers = None, collections.Counter() - for db in graph_dbs(repo, gdir): - con = sqlite3.connect(db); con.row_factory = sqlite3.Row; ax_contract.on_corrupt(repo, db) - q = lambda sql: con.execute(sql).fetchall() - has = lambda t: bool(con.execute("SELECT 1 FROM sqlite_master WHERE name=?", (t,)).fetchone()) - run = {r['key']: r['value'] for r in q("SELECT key, value FROM run")}; runs.append(run) - if run.get('language') and run['language'] not in langs: langs.append(run['language']) - src = run.get('source_dir') or repo - def rel(p, src=src): - a = p if os.path.isabs(p) else os.path.join(src, p) - a = os.path.realpath(a) if os.path.exists(a) else os.path.normpath(a) - r = os.path.relpath(a, repo).replace(os.sep, '/'); return p.lstrip('/') if r.startswith('..') else r - - # ── declarations: types and methods, with the display names the query skill uses when the index is there ── - owners.update({r['id']: r['owner_type_id'] for r in q("SELECT id, owner_type_id FROM methods WHERE provenance='client'")}) - if has('symbols'): - for r in q("SELECT * FROM symbols WHERE (method_id IS NOT NULL OR type_id IS NOT NULL)"): - if r['method_id'] and r['kind'] in METHOD_KINDS: - nodes[r['method_id']] = dict(id=r['method_id'], k='method', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test'])), sig=r['signature'] or '') - elif r['type_id'] and r['kind'] in TYPE_KINDS: - nodes[r['type_id']] = dict(id=r['type_id'], k='type', sk=r['kind'], l=r['display'], f=r['file'], a=r['line'], b=r['end_line'], t=int(bool(r['is_test']))) - else: - cat = lambda c: 'interface' if 'INTERFACE' in c else 'enum' if 'ENUM' in c else 'namespace' if 'NAMESPACE' in c else 'type' if 'ALIAS' in c or 'TYPEDEF' in c else 'class' - for r in q("SELECT * FROM types WHERE provenance='client'"): - f = rel(r['file_path']); nodes[r['id']] = dict(id=r['id'], k='type', sk=cat(r['category']), l=r['name'].replace('$', '.'), f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f)))) - for r in q("SELECT * FROM methods WHERE provenance='client'"): - f = rel(r['file_path']); o = (r['owner_qualified_name'] or '').replace('#', '.').split('.')[-1].replace('$', '.') - sk = 'constructor' if 'CONSTRUCTOR' in r['kind'] else 'module' if 'MODULE' in r['kind'] else 'method' if r['owner_type_id'] else 'function' - nodes[r['id']] = dict(id=r['id'], k='method', sk=sk, l=f"{o}.{r['name']}" if o else r['name'], f=f, a=r['start_line'], b=r['end_line'], t=int(bool(TESTRE.search(f))), sig=r['signature'] or '') - lib.update({r['id']: (r['owner_qualified_name'] or r['qualified_name'].rsplit('.', 1)[0], r['name']) for r in q("SELECT id, qualified_name, name, owner_qualified_name FROM methods WHERE provenance<>'client'")}) - if has('nesting'): nesting.update({r['type_id']: r['outer_type_id'] for r in q("SELECT type_id, outer_type_id FROM nesting")}) - - # ── calls: one edge per (caller, callee) with the best tier and the site count; unresolved → a count on the caller ── - # only an ambiguous_* tier is unresolved. boundary_lib is a call into a library; the *_terminal, implicit_constructor and - # fan_capped tiers are correct ends (the platform, a synthesised constructor, a computed target) — the same rule as the - # query skill, so the two never disagree about what is a blind spot - for r in q("SELECT e.caller_id, e.callee_method_id, e.tier, e.callee_label, s.start_line AS line, s.callee_name AS written FROM call_edges e LEFT JOIN call_sites s ON s.id = e.call_site_id"): - c, t = r['caller_id'], r['callee_method_id'] - if c not in nodes: continue - if not t or t not in nodes: - if t in lib: box(c, *lib[t]); site(c, r, 'L:' + lib[t][0]) - elif r['tier'] == 'boundary_lib' and r['callee_label']: - # Python / JavaScript: the library target is a label — builtin:len, external:asyncio.get_running_loop — not a method row - prov, _, name = r['callee_label'].partition(':'); owner, _, meth = name.rpartition('.') - owner = owner or (('builtins' if prov == 'builtin' else name)); box(c, owner, meth or name); site(c, r, 'L:' + owner) - elif (r['tier'] or '').startswith('ambiguous'): unresolved[c] += 1; site(c, r, '') - else: terminal[c] += 1 - continue - site(c, r, t) - e = E.get((c, t)) - if e is None: E[(c, t)] = dict(s=c, t=t, tier=r['tier'], w=1) - else: - e['w'] += 1 - if r['tier'] == 'known_edge': e['tier'] = 'known_edge' - # a call through a holder the engine could name (#1206): the caller reaches every function the holder may hold. The call - # site itself resolves to the holder's bodiless base (Function.apply), so without these the stored function has no caller - if has('ext_fn_value_call'): - for r in q("SELECT DISTINCT c0, c1 FROM ext_fn_value_call"): - c, t = r[0], r[1] - if c in nodes and t in nodes and (c, t) not in E: E[(c, t)] = dict(s=c, t=t, tier='fn_value', w=1) - for r in q("SELECT method_id, reason FROM entry_points"): - if r['method_id'] in nodes: nodes[r['method_id']].setdefault('e', []).append(r['reason']) - for r in q("SELECT type_id, ancestor_type_id FROM type_ancestors"): anc[r['type_id']].add(r['ancestor_type_id']) - # two shapes share the name: the bundle's (file_path, reason, construct, start_line, …) and the index's (file, reason), which - # the index writes over the bundle's — read whichever is there - if has('skipped'): - cols = {r[1] for r in q("PRAGMA table_info(skipped)")}; col = lambda c: c if c in cols else 'NULL' - fc = 'file_path' if 'file_path' in cols else 'file' - skipped = (skipped or []) + [dict(f=rel(r[0]), r=r[1] or '', a=r[2], d=r[3] or '') for r in q(f"SELECT {fc}, reason, {col('start_line')}, {col('detail')} FROM skipped WHERE {fc} <> '' ORDER BY reason, {fc}")] - tiers.update(r[0] for r in q("SELECT tier FROM call_edges")) - con.close() - - # ── containment: dir → file → type → method (nested types under their outer; free functions under the file) ── - for n in list(nodes.values()): - fid = 'F:' + n['f'] - if fid not in nodes: - d = os.path.dirname(n['f']) or '.' - nodes[fid] = dict(id=fid, k='file', l=n['f'], f=n['f'], t=int(bool(TESTRE.search(n['f']))), p='D:' + d) - if 'D:' + d not in nodes: nodes['D:' + d] = dict(id='D:' + d, k='dir', l=d, f='', t=int(bool(TESTRE.search(d + '/')))) - if n['k'] == 'type': n['p'] = nesting[n['id']] if nesting.get(n['id']) in nodes else fid - elif n['k'] == 'method': o = owners.get(n['id']); n['p'] = o if o in nodes else fid - for m, n in unresolved.items(): nodes[m]['u'] = n - for m, ss in sites.items(): nodes[m]['cs'] = ss - for owner, b in boxes.items(): - nodes['L:' + owner] = dict(id='L:' + owner, k='lib', l=owner, f='', sites=b['sites'], callers=len(b['callers']), methods=[f"{k}×{v}" if v > 1 else k for k, v in b['methods'].most_common(8)]) - X = [] # extends / implements: direct ancestors only (type_ancestors is a closure) - for t, A in anc.items(): - if t in nodes: - for a in A: - if a in nodes and not any(b != a and a in anc.get(b, ()) for b in A): X.append(dict(s=t, t=a)) - deg = collections.Counter() - for e in E.values(): deg[e['s']] += 1; deg[e['t']] += 1 - for e in LE.values(): deg[e['t']] += 1 - for n in nodes.values(): - if deg[n['id']]: n['d'] = deg[n['id']] - - sites = sum(tiers.values()); unres = sum(v for t, v in tiers.items() if t.startswith('ambiguous')) - client = tiers.get('known_edge', 0) + tiers.get('multi_inferred', 0); external = sum(b['sites'] for b in boxes.values()) - terminal_n = sites - unres - client - external - data = dict(meta=dict(repo=os.path.basename(repo), language=', '.join(langs) or '?', built=time.strftime('%Y-%m-%d %H:%M'), sites=sites, - client_sites=client, external_sites=external, terminal_sites=terminal_n, unresolved=unres, boxes=len(boxes), - client_rate=round(100 * client / max(1, sites - external - terminal_n)), overall_rate=round(100 * (sites - unres) / max(1, sites)), - tiers=dict(tiers), library_roots=runs[0].get('library_roots', '') or ('java' not in langs and 'n/a') or '', counts=dict(collections.Counter(n['k'] for n in nodes.values())), edges=len(E)), - nodes=list(nodes.values()), calls=list(E.values()), libcalls=list(LE.values()), extends=X, skipped=skipped) - data['meta']['fn_value_edges'] = sum(1 for e in E.values() if e['tier'] == 'fn_value') - short = {} - def key(i): - k = short.get(i) - if k is None: n = len(short); k = short[i] = ('L:' if i.startswith('L:') else '') + (_b36(n) or '0') - return k - for n in data['nodes']: - n['id'] = key(n['id']) - if n.get('p'): n['p'] = key(n['p']) - if n['k'] == 'file': n.pop('l', None) # a file's label is its path, already in f - for s in n.get('cs', ()): s[2] = key(s[2]) if s[2] else '' - for e in data['calls'] + data['libcalls'] + data['extends']: e['s'], e['t'] = key(e['s']), key(e['t']) - if page and (os.path.isdir(page) or not page.lower().endswith('.html')): page = os.path.join(page, os.path.basename(repo) + '.html') # --out → /.html - page = page or os.path.join(gdir, 'graph', 'graph.html'); os.makedirs(os.path.dirname(page), exist_ok=True) - # the sources, so the page can show the code a node points at (one file, still no network); skipped past 40 MB - files, size = {}, 0 - for f in sorted({n['f'] for n in nodes.values() if n.get('f')}): - try: - t = open(os.path.join(repo, f), encoding='utf-8', errors='replace').read(); size += len(t) - if size > 40_000_000: files = {}; break - files[f] = t - except OSError: pass - data['files'] = files; data['meta']['repo_path'] = repo; data['meta']['sources'] = bool(files) - tpl = open(os.path.join(HERE, 'viewer.html'), encoding='utf-8').read() - open(page, 'w', encoding='utf-8').write(tpl.replace('/*__DATA__*/null', json.dumps(data, separators=(',', ':')).replace(',") - print(f"page: {os.path.abspath(page)}") - - -def run_build(repo, env=None): - """axiomcode-build on repo, its progress on this process's stdout; exits with its status when it fails""" - cands = [os.path.join(HERE, 'axiomcode-build'), os.path.join(os.environ.get('AXIOMCODE_ENGINE', ''), 'skills', 'axiomcode', 'scripts', 'axiomcode-build')] - b = next((os.path.realpath(c) for c in cands if os.path.isfile(c)), None) - if not b: sys.exit("axiomcode-build not found beside this script (set AXIOMCODE_ENGINE to the engine checkout)") - sys.stdout.flush() - r = subprocess.run([os.environ.get('AXIOMCODE_BASH') or 'bash', b, repo], env=env) # a bash script: Windows cannot exec it itself - if r.returncode: sys.exit(r.returncode) - - -def recorded(repo): - """the file table of the graph that is there: the languages, --src and --library it was indexed with. A graph from - before the table has its main language, --src and --library in its run table; its other languages are the graphs - beside it. None when there is nothing to read them from.""" - import ax_fresh - t = ax_fresh.load_table(repo) - if t: return t - t = ax_fresh.legacy_params(repo) - if not t: return None - ld = os.path.join(repo, '.axiomcode', 'lang') - others = sorted(l for l in (os.listdir(ld) if os.path.isdir(ld) else ()) if os.path.exists(os.path.join(ld, l, 'out', 'graph.sqlite'))) - return dict(t, lang=','.join([t['lang']] + [l for l in others if l != t['lang']]), lang_auto=False, legacy=True) - - -def build(repo, page, library=None): - """the page, from the graph that is there when it is current, else from a rebuild of it with the flags it was indexed - with. THE ENGINE RUNS ONLY WHEN IT HAS TO, AND ONLY FOR THE LANGUAGES THE INDEX CHOSE. This called axiomcode-build with - no flags, which detects every language in the tree: on a repository indexed with --lang python its answer never matched - the recorded graph, so every call rebuilt the graph from scratch in every language present, a JavaScript or TypeScript - compile for a few stray files included, and left a graph for each of them that later queries answered from.""" - import ax_fresh - repo = os.path.realpath(repo or '.') - if library: os.environ['AXIOMCODE_LIBRARY'] = ','.join(os.path.abspath(os.path.expanduser(x)) for x in library.split(',')) - db = os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite') - asked = [f"{k}={os.environ[v]}" for k, v in (('--lang', 'AXIOMCODE_LANG'), ('--src', 'AXIOMCODE_SRC'), ('--library', 'AXIOMCODE_LIBRARY')) if os.environ.get(v)] - if os.environ.get('AXIOMCODE_GRAPH'): return export(repo, page) # a graph someone else built and placed: drawn as it is - ax_fresh.relink(repo) # never another checkout's graph (#1605) - if not os.path.lexists(db): - # no graph yet: the first index, with what was asked for (every language present, when nothing was) - t0 = time.time(); run_build(repo) - return export(repo, page, how=f"no graph yet: indexed {repo}{' with ' + ' '.join(asked) if asked else ''} in {time.time() - t0:.0f} s.") - if asked: - # flags given here are a request for that graph: axiomcode-build keeps the one there when it already is that graph - t0 = time.time(); run_build(repo) - return export(repo, page, how=f"graph built with {' '.join(asked)} ({time.time() - t0:.0f} s).") - t = recorded(repo) - if t is None: - return export(repo, page, how="drawn from the graph as it is: it records no index flags to rebuild it with.") - c = None if t.get('legacy') else ax_fresh.changes(repo, t) - langs = (t.get('lang') or '?').replace(',', ', ') - if c is not None and not any(c) and not ax_fresh.graph_broken(repo): - return export(repo, page, how=f"graph up to date ({langs}): drawn from it, no rebuild.") - n = sum(len(x) for x in c) if c else 0 - if os.environ.get('AXIOMCODE_NO_REFRESH') and not ax_fresh.graph_broken(repo): - # READ-ONLY (--no-refresh, MCP refresh=false, AXIOMCODE_NO_REFRESH=1): the graph there is drawn as it is, and said - return export(repo, page, how=f"refresh off (--no-refresh): drawn from the graph as it is, not rebuilt ({langs})" - + (f"; it predates edits to {n} file(s) since it was built" if n else '') + ".") - flags = ' '.join(f for f in (('--lang ' + t['lang']) if not t.get('lang_auto', True) else 'every language present', - ('--src ' + t['src_arg']) if t.get('src_arg') else '', ('--library ' + t['library']) if t.get('library') else '') if f) - print(f"the graph is out of date ({n} file(s) changed since it was built): rebuilding it as it was indexed ({flags}) …" if n else - f"rebuilding the graph as it was indexed ({flags}) …", flush=True) - # a REFRESH, not an index: the baseline `changed` and `test-impact` measure edits against stays where it was, as the - # background refresh keeps it. Rebuilt as an explicit index it moved to the edited tree, and the edits it held dropped - # out of both ("no changed declaration" over a real edit) - t0 = time.time(); run_build(repo, ax_fresh.rebuild_env(t, AXIOMCODE_REFRESH_REASON='axiomcode graph', AXIOMCODE_KEEP_BASE='1')) - return export(repo, page, how=f"graph rebuilt ({flags}) in {time.time() - t0:.0f} s.") - - -if __name__ == '__main__': - a = sys.argv[1:]; page = None; library = None - if '--out' in a: i = a.index('--out'); page = os.path.abspath(os.path.expanduser(a[i + 1])); del a[i:i + 2] - if '--library' in a: i = a.index('--library'); library = a[i + 1]; del a[i:i + 2] - if a and a[0] in ('-h', '--help'): print(__doc__.strip()); sys.exit(0) - # `axiomcode graph []` is the verb; `build` and `export` are the older spellings, still taken. A directory - # named `build` or `export` is a repository, not a subcommand - verb = a[0] if a and a[0] in ('build', 'export', 'draw') and not os.path.isdir(a[0]) else 'draw' - pos = a[1:] if a and verb == a[0] else a - if len(pos) > 1 or any(x.startswith('-') for x in pos): print(__doc__.strip(), file=sys.stderr); sys.exit(2) - if verb == 'export': export(pos[0] if pos else '.', page, how="drawn from the graph as it is (export: no freshness check, no rebuild).") - else: build(pos[0] if pos else '.', page, library) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install deleted file mode 100755 index 918e5d7e..00000000 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env python3 -"""axiomcode install [] [--remove] [--print] - -Write the preference into the repository's CLAUDE.md, once, as a marked block. - -Why this exists. A skill is offered through its own description and nothing else, so whether it is -used is decided per session by whether that description matched the question in front of the caller. -A repository that has already decided it wants structural answers should be able to say so once, -in the file it already keeps for that purpose, instead of relying on the match every time. - -What it deliberately does NOT do. It does not block or redirect any tool. A directive is a stated -preference the caller can weigh against the task; a gate is a decision taken away from it, and the -one measurement this project has of forcing graph context showed patches touching FEWER files, not -more. So this writes prose and nothing else. - -It is not neutral, and that has to travel with any number measured under it: a repository carrying -this block is a different condition from one that does not, and a comparison that installs it on one -side must say so. - -The block is delimited, so re-running replaces rather than appends, and --remove takes it out -cleanly. Nothing outside the markers is ever touched. -""" -import json, os, sys - -# The MCP tools this plugin exposes. A permission prompt at the moment of FIRST use is a barrier exactly -# where the tool has not yet earned trust, and a caller that has to stop and ask is a caller that greps -# instead. Pre-authorising is a change to the user's settings, so it happens here, in an explicit install -# step that prints what it did and can undo it -- never silently from a hook or a manifest (a plugin -# manifest cannot declare permissions at all; nothing in the official marketplace does). -MCP_GLOB = 'mcp__plugin_axiomcode_axiomcode__*' - -BEGIN = '' -END = '' - -BLOCK = """ -## Finding code in this repository - -This repository has a resolved call graph. Search with grep as usual; after a grep it adds what grep cannot know. -Ask it directly, through the axiomcode MCP tools (`mcp__plugin_axiomcode_axiomcode__*`; no skill needs loading): - - impact(name="") # who calls it, what a change reaches, its tests - impact() # the same for your uncommitted edits - path(start="", end="") # how A reaches B - tests() # the tests your edits reach, and how to run them - -Only when those tools are not in your list, the same from the shell: -`axiomcode impact `, `axiomcode path `, `axiomcode tests`. - -Finding where code lives is yours: search as you normally would, then bring the name you found here. - -**Trust the answer.** Each place comes with the code of the function it sits in: answer from it. A -`resolved` place has already been looked up again in the graph (the `verified:` line) — do not re-derive it -by grepping. `by name` / `text` places are leads, not facts. An unresolved call means *unknown*, not *absent*. - -Text search is still right for a string, a comment, a config value, or a file you already know. -""" - - -def allow_mcp(repo, remove=False): - """Add (or remove) the plugin's MCP tools in /.claude/settings.json, merging with what is there. - - Written per repository rather than globally: a benchmark, a colleague's checkout and a sandbox should - not inherit a trust decision made once somewhere else. Existing entries are preserved -- this file is - the user's, and an installer that rewrites it wholesale is worse than one that does nothing. - """ - path = os.path.join(repo, '.claude', 'settings.json') - data = {} - if os.path.exists(path): - try: - with open(path, encoding='utf-8') as fh: - data = json.load(fh) - except Exception: - return f" {path} is not valid JSON; left alone -- add {MCP_GLOB!r} to permissions.allow by hand" - allow = data.setdefault('permissions', {}).setdefault('allow', []) - if remove: - if MCP_GLOB not in allow: - return f" no axiomcode entry in {path}" - allow.remove(MCP_GLOB) - if not allow: data['permissions'].pop('allow', None) - if not data['permissions']: data.pop('permissions', None) - else: - if MCP_GLOB in allow: - return f" {path} already allows {MCP_GLOB}" - allow.append(MCP_GLOB) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'w', encoding='utf-8') as fh: - json.dump(data, fh, indent=2); fh.write('\n') - return f" {'removed' if remove else 'added'} {MCP_GLOB} in {path}" - - -def main(argv): - repo, remove, show = os.getcwd(), False, False - for a in argv: - if a == '--remove': - remove = True - elif a == '--print': - show = True - elif a.startswith('-'): - sys.exit(f"axiomcode install: unknown option {a}") - else: - repo = a - if show: - print(BLOCK) - return 0 - if not os.path.isdir(repo): - sys.exit(f"axiomcode install: not a directory: {repo}") - - path = os.path.join(repo, 'CLAUDE.md') - old = '' - if os.path.exists(path): - with open(path, encoding='utf-8') as fh: - old = fh.read() - - if BEGIN in old and END in old: - head, _, rest = old.partition(BEGIN) - _, _, tail = rest.partition(END) - stripped = head.rstrip('\n') + ('\n' + tail.lstrip('\n') if tail.strip() else '\n') - elif BEGIN in old or END in old: - sys.exit("axiomcode install: CLAUDE.md has one marker but not the other; fix it by hand " - "rather than let this guess where the block ends") - else: - stripped = old - - if remove: - if BEGIN not in old: - print(f"axiomcode install: no block in {path}, nothing to remove") - return 0 - with open(path, 'w', encoding='utf-8') as fh: - fh.write(stripped) - print(f"removed the axiomcode block from {path}") - print(allow_mcp(repo, remove=True)) - return 0 - - body = (stripped.rstrip('\n') + '\n\n' if stripped.strip() else '') - with open(path, 'w', encoding='utf-8') as fh: - fh.write(body + BLOCK + '\n') - print(f"{'replaced' if BEGIN in old else 'wrote'} the axiomcode block in {path}") - print(" it states a preference; it blocks nothing. Disclose it when reporting a measurement " - "taken with it installed.") - print(allow_mcp(repo)) - print(" that pre-authorises this plugin's MCP tools in THIS repository, so the first call does not " - "stop for a prompt. `--remove` takes both back out.") - return 0 - - -if __name__ == '__main__': - sys.exit(main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html b/plugins/axiomcode/skills/axiomcode/scripts/viewer.html deleted file mode 100644 index 4ca6ef0f..00000000 --- a/plugins/axiomcode/skills/axiomcode/scripts/viewer.html +++ /dev/null @@ -1,344 +0,0 @@ - - - - -axiomcode graph - - - - -

axiomcode graph

-
-
-
a call in the code is a link: click opens the callee below · ⌘/Ctrl-click goes there · grey = one target · cyan = a sound set · orange = library · amber = the engine could not resolve it
-
-
-

node types · click to hide/show

-

only · click to narrow

-

edges

-

focus depth · hops from the selection

-
-
-
-
scroll to zoom · drag to pan · hover a dot · click to select · double-click to zoom in
- -
- - - diff --git a/tests/case_runner.py b/tests/case_runner.py index 7d370643..4a3e93c2 100644 --- a/tests/case_runner.py +++ b/tests/case_runner.py @@ -29,6 +29,8 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) AX = os.path.join(ROOT, 'bin', 'axiomcode') +# internal verbs (context, changed, test-impact) left the installed command's surface: ask the dispatcher +DISP = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') RUNNER_PY = '''#!/usr/bin/env python3 """tests/run.py [ ...] [--lang python|java] @@ -203,7 +205,7 @@ def check(ok, why, detail=''): for rel, (old, new) in edits.items(): p = os.path.join(repo, rel); t = open(p).read() check(old in t, f'{why}: the edit applies to {rel}'); open(p, 'w').write(t.replace(old, new, 1)) - r = sh(repo, AX, 'test-impact', '.', *extra, env=env) + r = sh(repo, DISP, 'test-impact', '.', *extra, env=env) out = r.stdout + r.stderr check(r.returncode == 0 and 'Traceback' not in out, f'{why}: test-impact answers', out) for w in want: check(w in out, f'{why}: names {w.strip()!r}', out) @@ -213,14 +215,14 @@ def check(ok, why, detail=''): sh(repo, 'git', 'checkout', '-q', '--', '.') p = os.path.join(repo, 'tests', 'fastcases', 'python', 'shop', 'test_new.py') open(p, 'w').write('def test_new():\n assert 1\n') - r = sh(repo, AX, 'changed', '.', env=env) + r = sh(repo, DISP, 'changed', '.', env=env) out = r.stdout + r.stderr check('case data read by tests/fast.py' in out and 'a test file: run it' not in out, 'changed: a new file in a fixture tree is case data for its runner', out) os.remove(p) p = os.path.join(repo, 'tests', 'test_added.py') open(p, 'w').write('def test_added():\n assert 1\n') - r = sh(repo, AX, 'changed', '.', env=env) + r = sh(repo, DISP, 'changed', '.', env=env) out = r.stdout + r.stderr check('a test file: run it' in out and 'case data' not in out, 'control: changed: a new real test file is a test to run', out) os.remove(p) diff --git a/tests/diff_verb.py b/tests/diff_verb.py deleted file mode 100644 index 74cca032..00000000 --- a/tests/diff_verb.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/usr/bin/env python3 -"""tests/diff_verb.py: `axiomcode diff` compares two graphs of one tree by what stays put, never by id. - -Every engine change was measured before and after by hand SQL over call_edges joined to sites and methods, because -ids hash the index directory: two graphs of one tree, built at two paths, share no id, so a join on ids says that -everything changed. The verb joins on file, line, column, qualified name and callee as written. - -Per language (Python, Java, C#), one small tree indexed three times: - - here the tree - there the same tree copied to another path (Java records absolute paths, so this also checks they are made - relative before they are compared) - added `there` with one more call written on an existing line, so no other line moves - (no tree has an entry point, so the new callee does not also become reachable: that would be a - second, correct row) - - control here vs there: no difference in any kind, while both graphs hold call edges (a diff of two empty graphs - would pass this vacuously, so the edge count is asserted too) - one row there vs added: exactly one call edge added, the new callee, and no other row of any kind - --file the same pair scoped to a file that holds no change: nothing - --json the same counts as the text - dirs the index directories and the graph.sqlite paths answer the same - - python3 tests/diff_verb.py [-v] [--lang python,java,csharp] -""" -import json, os, re, shutil, subprocess, sys, tempfile - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') - -TREES = { - 'python': ({ - 'shop/__init__.py': '', - 'shop/orders.py': 'def helper_one():\n return 1\n\n\ndef helper_two():\n return 2\n\n\n' - 'def total():\n a = helper_one(); return a\n', - 'shop/cli.py': 'from shop.orders import total\n\n\ndef main():\n return total()\n', - }, ('shop/orders.py', 'a = helper_one(); return a', 'a = helper_one(); helper_two(); return a'), 'helper_two', 'shop/cli.py'), - 'java': ({ - 'src/main/java/shop/Orders.java': 'package shop;\n\npublic class Orders {\n int helperOne() { return 1; }\n\n' - ' int helperTwo() { return 2; }\n\n' - ' public int total() { int a = helperOne(); return a; }\n}\n', - 'src/main/java/shop/Cli.java': 'package shop;\n\npublic class Cli {\n public int run() {\n' - ' return new Orders().total();\n }\n}\n', - }, ('src/main/java/shop/Orders.java', 'int a = helperOne(); return a;', 'int a = helperOne(); helperTwo(); return a;'), 'helperTwo', 'Cli.java'), - 'csharp': ({ - 'App/App.csproj': '\n net8.0\n\n', - 'App/Orders.cs': 'namespace App;\n\npublic class Orders\n{\n int HelperOne() { return 1; }\n\n int HelperTwo() { return 2; }\n\n' - ' public int Total() { int a = HelperOne(); return a; }\n}\n', - 'App/Cli.cs': 'namespace App;\n\npublic static class Cli\n{\n public static int Run() { return new Orders().Total(); }\n}\n', - }, ('App/Orders.cs', 'int a = HelperOne(); return a;', 'int a = HelperOne(); HelperTwo(); return a;'), 'HelperTwo', 'Cli.cs'), -} -KINDS = ('entry_points', 'reachable', 'remote', 'framework', 'config', 'symbols') - - -def make(root, files): - for rel, text in files.items(): - p = os.path.join(root, rel) - os.makedirs(os.path.dirname(p), exist_ok=True) - open(p, 'w').write(text) - - -def main(argv): - verbose = '-v' in argv - langs = list(TREES) - if '--lang' in argv: - langs = argv[argv.index('--lang') + 1].split(',') - fails = [] - - def check(ok, why, detail=''): - print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) - if not ok: fails.append(why) - - env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') - for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) - - def ax(*a): - return subprocess.run(['bash', AX, *a], capture_output=True, text=True, env=env) - - work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-diffverb-')) - try: - for lang in langs: - files, (edit_file, old, new), callee, other = TREES[lang] - here, there, added = (os.path.join(work, lang, n) for n in ('here', 'there', 'added')) - make(here, files); make(there, files) - make(added, dict(files, **{edit_file: files[edit_file].replace(old, new)})) - assert files[edit_file].count(old) == 1 - built = True - for d in (here, there, added): - r = ax('index', d, '--lang', lang) - if not os.path.isfile(os.path.join(d, '.axiomcode', 'out', 'graph.sqlite')): - check(False, f'{lang}: index {os.path.basename(d)}', r.stdout + r.stderr); built = False; break - if not built: - continue - - # ── control: one tree at two paths ───────────────────────────────────────────────────────────── - r = ax('diff', here, there, '--json') - doc = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {} - res = doc.get('languages', {}).get(lang, {}) - n = res.get('counts', {}) - edges = sum(a for a, _b in res.get('tiers', {}).values()) - check(edges >= 1, f'{lang}: control is not vacuous (the graphs hold {edges} call edge(s))', r.stdout + r.stderr) - check(bool(n) and not any(n['calls'].values()) and not any(any(v.values()) for k, v in n.items() if k != 'calls'), - f'{lang}: the same tree indexed at two paths diffs to nothing', json.dumps(n) + r.stderr) - t = ax('diff', here, there) - check('no difference' in t.stdout, f'{lang}: and the text says so', t.stdout + t.stderr) - - # ── one added call ───────────────────────────────────────────────────────────────────────────── - r = ax('diff', there, added, '--json') - doc = json.loads(r.stdout) if r.returncode == 0 and r.stdout.strip() else {} - res = doc.get('languages', {}).get(lang, {}) - n = res.get('counts', {}) - rows = res.get('calls', {}).get('added', []) - check(n.get('calls') == {'added': 1, 'removed': 0, 'retiered': 0, 'changed': 0} - and len(rows) == 1 and callee in rows[0]['callee'] and rows[0]['file'].endswith(edit_file), - f'{lang}: one call added on an existing line shows exactly one call-edge row, to {callee}', r.stdout[-1500:] + r.stderr) - check(bool(n) and not any(any(v.values()) for k, v in n.items() if k != 'calls'), - f'{lang}: and no row of any other kind', json.dumps(n)) - t = ax('diff', there, added) - plus = [l for l in t.stdout.splitlines() if re.match(r'\s+[+\-~>] ', l)] - check(len(plus) == 1 and callee in plus[0] and 'call edges +1 -0 ~0 >0' in t.stdout, - f'{lang}: the text has the same one row and summary', t.stdout + t.stderr) - # the graph.sqlite paths answer as the directories do - g1, g2 = (os.path.join(d, '.axiomcode', 'out', 'graph.sqlite') for d in (there, added)) - t2 = ax('diff', g1, g2) - strip = lambda s: [l for l in s.splitlines() if not re.match(r'^\S+: A |^\s+B ', l)] - check(strip(t2.stdout) == strip(t.stdout), f'{lang}: two graph.sqlite paths answer as the two directories do', t2.stdout + t2.stderr) - # --file: a file with no change in it scopes the diff to nothing - t3 = ax('diff', there, added, '--file', other, '--json') - n3 = json.loads(t3.stdout)['languages'][lang]['counts'] if t3.returncode == 0 else {} - check(bool(n3) and not any(n3['calls'].values()), f'{lang}: --file {other} (no change there) scopes it to nothing', t3.stdout[-800:] + t3.stderr) - t4 = ax('diff', there, added, '--file', edit_file.split('/')[-1], '--json') - n4 = json.loads(t4.stdout)['languages'][lang]['counts'] if t4.returncode == 0 else {} - check(n4.get('calls', {}).get('added') == 1, f'{lang}: --file {edit_file.split("/")[-1]} keeps the row', t4.stdout[-800:] + t4.stderr) - - # a directory with no graph is refused, and says how to make one - empty = os.path.join(work, 'empty'); os.makedirs(empty) - r = ax('diff', empty, empty) - check(r.returncode != 0 and 'axiomcode index' in r.stderr, 'a directory with no graph is refused with the command that makes one', r.stderr) - finally: - shutil.rmtree(work, ignore_errors=True) - print(('\nFAIL' if fails else '\nok') + f': {len(fails)} failure(s)') - return 1 if fails else 0 - - -if __name__ == '__main__': - sys.exit(main(sys.argv[1:])) diff --git a/tests/freshness.py b/tests/freshness.py index 819e0a44..6924fe28 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -722,12 +722,12 @@ def query(repo, *extra, **env): open(os.path.join(shim, 'python3'), 'w').write('#!/bin/sh\necho "NO_REFRESH=${AXIOMCODE_NO_REFRESH:-} $*"\n'); os.chmod(os.path.join(shim, 'python3'), 0o755) env = {k: v for k, v in os.environ.items() if k != 'AXIOMCODE_NO_REFRESH'}; env['PATH'] = shim + os.pathsep + env.get('PATH', '') said = {} - for verb in ('context', 'path', 'impact', 'changed', 'test-impact', 'graph'): + for verb in ('context', 'path', 'impact', 'changed', 'test-impact'): args = {'context': ['a task'], 'path': ['A', 'B'], 'impact': ['X']}.get(verb, []) on = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), verb, *args, repo, '--no-refresh'], capture_output=True, text=True, env=env).stdout off = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), verb, *args, repo], capture_output=True, text=True, env=env).stdout said[verb] = (on, off) - check("read-only: `--no-refresh` on each query verb (context, path, impact, changed, test-impact, graph) sets AXIOMCODE_NO_REFRESH and is not passed on as an argument", + check("read-only: `--no-refresh` on each query verb (context, path, impact, changed, test-impact) sets AXIOMCODE_NO_REFRESH and is not passed on as an argument", all('NO_REFRESH=1 ' in on and '--no-refresh' not in on for on, _ in said.values()), said) check("read-only: control: without it the verbs run with refresh on", all(o and 'NO_REFRESH=1' not in o for _, o in said.values()), said) @@ -834,10 +834,11 @@ def mcp_checks(): check("mcp: fresh=true is refused as an unknown argument, not dropped", 'fresh: unexpected argument' in (m.unknown_arguments('impact', {'name': 'X', 'fresh': True}) or ''), m.unknown_arguments('impact', {'name': 'X', 'fresh': True})) - check("mcp: an answer's --fresh is written as the parameter", 'fresh=True' in m.mcp_words('ask again with --fresh to wait'), - m.mcp_words('ask again with --fresh to wait')) - w = m.mcp_words('pass --no-refresh (MCP refresh=false) to query without rebuilding') - check("mcp: an answer's --no-refresh is written as refresh=False", 'refresh=False' in w and '--no-refresh' not in w, w) + w = m.plain('stale rows are marked below.\nask again with --fresh to wait') + check("mcp: an answer's clause naming --fresh is dropped at the tool layer (the tools take no options)", + '--fresh' not in w and 'stale rows are marked below.' in w, w) + w = m.plain('pass --no-refresh (MCP refresh=false) to query without rebuilding') + check("mcp: an answer's clause naming --no-refresh is dropped at the tool layer", '--no-refresh' not in w, w) if __name__ == '__main__': prune_checks(); marks_checks(); wait_checks(); engine_checks(); per_language_checks(); newer_checks(); read_only_checks(); cap_checks(); lock_checks(); named_checks(); mcp_checks() diff --git a/tests/front_door.py b/tests/front_door.py index c175b99b..57fef9c2 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -1,16 +1,18 @@ #!/usr/bin/env python3 -"""tests/front_door.py — the four questions answer as numbered places with their code, at the front door only. +"""tests/front_door.py — the supported questions answer as numbered places with their code, at the front door only. -The product's surface is `find`, `impact`, `path` and `tests` (plus `index`), each answered as a numbered list of places, +The product's surface is `impact`, `path` and `tests` (plus `index`), each answered as a numbered list of places, every place with the code of the function it sits in, in a fenced block. That shape is given at the front doors — the installed command (bin/axiomcode sets AXIOMCODE_FRONT) and the MCP server (AXIOMCODE_SURFACE=mcp) — when no flag is passed. Everything that calls the dispatcher directly (the hooks, the case suite, loops) or passes a flag gets the verb's -own answer, unchanged. +own answer, unchanged; a verb outside the surface (find, context, graph, diff, install) is refused by the installed +command. - a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): find, impact and + a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): impact and path answer with numbered places and a fenced code block; after an edit, impact with no name starts with - `your edits:`, and tests lists the test with its code and ends with a `run:` line. - b. the MCP server lists exactly find, impact, path and tests, each with at most two parameters, and a call to one + `your edits:`, and tests lists the test with its code and ends with a `run:` line. A verb off the surface is + refused with the supported list. + b. the MCP server lists exactly impact, path and tests, each with at most two parameters, and a call to one answers in the same shape. c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no fenced block — for the same question. @@ -96,8 +98,11 @@ def main(): if fails: return 1 # ── a. the installed command, no flags ─────────────────────────────────────────────────────────────────── - rc, out, err = cli(repo, 'find', 'how is the invoice total computed') - check('find: numbered places, each with its code in a fenced block', rc == 0 and places(out) and 'def invoice' in out, out[:600] + err[-300:]) + for verb, args in (('find', ('how is the invoice total computed',)), ('context', ('the invoice total',)), + ('graph', ()), ('diff', ()), ('install', ())): + rc, out, err = cli(repo, verb, *args) + check(f'{verb}: off the surface, the installed command refuses it and names the supported verbs', + rc != 0 and 'impact' in err and 'path' in err and 'tests' in err, (out + err)[:400]) rc, out, err = cli(repo, 'impact', 'vat_rate') check('impact : its direct caller as a numbered place with its code', rc == 0 and places(out) and 'shop/pricing.py:6' in out and 'return net * (1 + vat_rate())' in out, out[:600] + err[-300:]) diff --git a/tests/graph_verb.py b/tests/graph_verb.py deleted file mode 100644 index 594cf387..00000000 --- a/tests/graph_verb.py +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env python3 -"""tests/graph_verb.py — `axiomcode graph` draws the graph the index built, and rebuilds it only as it was indexed. - -It used to run axiomcode-build with no flags. That detects every language in the tree, so on a repository indexed with ---lang python the build never matched the recorded graph: every call rebuilt it from scratch in every language -present, a JavaScript or TypeScript compile for a few stray files included, and left a graph for each of them that -later queries answered from. Its output was a raw Python dict and a relative ../../ page path. - -Two throwaway repositories: - - indexed Python under app/, with a stray JavaScript file beside it, indexed with --lang python --src app - current the page is drawn from the graph: no engine run (the graph file is untouched), no graph for another - language, prose counts and the page's absolute path - stale after an edit the graph is rebuilt with --lang python --src app, the page shows the edit, and still no - JavaScript graph appears - control Python and Java, indexed with no --lang (every language): the page draws both, and both graphs stay; - detected languages are not pinned, so a rebuild still detects them - rebuilds C# with a stray TypeScript front end, indexed with --lang csharp: an edit plus a commit (the background - refresh), a bare `index` and a query that repairs a broken graph each solve C# alone - surfaces `axiomcode help graph` and the MCP tool's description describe this, not `axiomcode-graph build` - - python3 tests/graph_verb.py [-v] -""" -import os, shutil, subprocess, sys, tempfile, time - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') -MCP = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') -FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') - -INDEXED = { - 'app/shop/__init__.py': '', - 'app/shop/orders.py': 'def price(n):\n return n * 3\n\n\ndef order_total(n):\n return price(n) + 1\n', - 'app/shop/cli.py': 'from shop.orders import order_total\n\n\ndef main():\n return order_total(2)\n', - 'app/static/widget.js': 'function strayWidget(a) {\n return a + 1\n}\n\nmodule.exports = { strayWidget }\n', - 'tools/other.py': 'def outside_src():\n return 1\n', -} -CSHARP = { - 'App/App.csproj': '\n net8.0\n\n', - 'App/Orders.cs': 'namespace App;\n\npublic class OrderService\n{\n public decimal Total(int widgets) => Price(widgets) * 2;\n\n private decimal Price(int widgets) => widgets * 3m;\n}\n', - 'App/ClientApp/tsconfig.json': '{ "compilerOptions": { "strict": true } }\n', - 'App/ClientApp/main.ts': 'export function strayFrontEnd(): number {\n return 1\n}\n', -} -MIXED = { - 'pom.xml': '\n 4.0.0\n example\n mixed\n 1.0\n\n', - 'src/main/java/app/Billing.java': 'package app;\n\npublic class Billing {\n public int chargeAccount(int n) {\n return fee(n) + n;\n }\n\n int fee(int n) {\n return 2;\n }\n}\n', - 'src/main/java/app/Invoice.java': 'package app;\n\npublic class Invoice {\n public int issueInvoice() {\n return new Billing().chargeAccount(3);\n }\n}\n', - 'scripts/report/__init__.py': '', - 'scripts/report/make.py': 'def render_report(x):\n return str(x)\n\n\ndef main():\n return render_report(1)\n', -} - - -def sh(cwd, *cmd, env=None): - return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, env=env) - - -def make(root, files): - for rel, text in files.items(): - os.makedirs(os.path.dirname(os.path.join(root, rel)), exist_ok=True) - open(os.path.join(root, rel), 'w').write(text) - for cmd in (('git', 'init', '-q'), ('git', 'add', '-A'), ('git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'base')): - sh(root, *cmd) - - -def main(argv): - verbose = '-v' in argv - fails = [] - - def check(ok, why, detail=''): - print(('ok ' if ok else 'FAIL ') + why + ('' if ok and not verbose or not detail else '\n ' + detail.strip()[-1500:].replace('\n', '\n '))) - if not ok: fails.append(why) - - work = os.path.realpath(tempfile.mkdtemp(prefix='axiomcode-graphverb-')) # macOS: /var is /private/var, and the page is named by its real path - # the engine is this checkout's, unless the caller names a built one (a worktree whose parser is not built) - env = dict(os.environ, AXIOMCODE_ENGINE=os.environ.get('AXIOMCODE_ENGINE') or ROOT, AXIOMCODE_NO_REFRESH='1') - for k in ('AXIOMCODE_LANG', 'AXIOMCODE_SRC', 'AXIOMCODE_LIBRARY', 'AXIOMCODE_GRAPH'): env.pop(k, None) - try: - # ── indexed with --lang python --src app ────────────────────────────────────────────────────────────── - repo = os.path.join(work, 'indexed'); make(repo, INDEXED) - ax = os.path.join(repo, '.axiomcode'); db = os.path.join(ax, 'out', 'graph.sqlite') - b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) - check(b.returncode == 0 and os.path.exists(db), 'setup: the repository indexes with --lang python --src app', b.stdout + b.stderr) - if b.returncode: return 1 - before = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) - - t0 = time.time(); g = sh(repo, 'bash', AX, 'graph', repo, env=env); took = time.time() - t0 - page = os.path.join(ax, 'graph', 'graph.html') - check(g.returncode == 0 and os.path.exists(page), 'current: the page is written', g.stdout + g.stderr) - check((os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == before and 'no rebuild' in g.stdout and 'building' not in g.stdout, - f'current: the page is drawn from the graph there, the engine does not run ({took:.1f} s)', g.stdout + g.stderr) - check(not os.path.exists(os.path.join(ax, 'lang')) and not os.path.exists(os.path.join(ax, 'out', 'javascript')), - 'current: no graph appears for the stray JavaScript file the index left out', g.stdout) - check(f'page: {page}' in g.stdout and '../' not in g.stdout and "{'" not in g.stdout and ' functions and methods' in g.stdout, - 'current: the output is prose, and names the page by its absolute path', g.stdout) - check('axiomcode-graph build' not in g.stdout + g.stderr, 'current: it advises no command an agent does not have', g.stdout + g.stderr) - html = open(page).read() if os.path.exists(page) else '' - check('order_total' in html and 'strayWidget' not in html and 'outside_src' not in html, - 'current: the page holds the indexed Python and nothing outside --lang / --src', '') - - # an edit: the graph is stale, and is rebuilt as it was indexed - with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef refund_order(n):\n return -order_total(n)\n') - # read-only first: --no-refresh (and AXIOMCODE_NO_REFRESH=1, which this env sets) draws the stale graph as it is - stamp = (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) - g = sh(repo, 'bash', AX, 'graph', repo, '--no-refresh', env=env) - check(g.returncode == 0 and 'refresh off (--no-refresh): drawn from the graph as it is' in g.stdout - and 'predates edits to 1 file(s)' in g.stdout and (os.path.realpath(db), os.stat(os.path.realpath(db)).st_mtime_ns) == stamp, - 'stale: --no-refresh draws the graph as it is, says it is out of date, and never rebuilds it', g.stdout + g.stderr) - # the near-miss: asked without it (and with no AXIOMCODE_NO_REFRESH), the stale graph is rebuilt as it was indexed - g = sh(repo, 'bash', AX, 'graph', repo, env={k: v for k, v in env.items() if k != 'AXIOMCODE_NO_REFRESH'}) - html = open(page).read() if os.path.exists(page) else '' - check(g.returncode == 0 and 'refund_order' in html and 'rebuilt (--lang python --src app)' in g.stdout, - 'stale: the graph is rebuilt with the --lang and --src it was indexed with, and the page shows the edit', g.stdout + g.stderr) - check(not os.path.exists(os.path.join(ax, 'lang')) and 'building python graph' in g.stdout and 'javascript graph' not in g.stdout and '+ javascript' not in g.stdout, - 'stale: the rebuild solves Python alone; no JavaScript graph appears', g.stdout) - check('outside_src' not in html, 'stale: the rebuild keeps --src (a file outside it is not drawn)', '') - # the rebuild is a refresh: the baseline stays HEAD's tree, so the edit is still an edit to `changed`/`test-impact` - c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) - check('refund_order' in c.stdout and 'baseline moved' not in c.stdout + ti.stdout, - 'stale: the rebuild keeps the baseline: `changed` still names the edit, and nothing says the baseline moved', - c.stdout + c.stderr + ti.stdout) - # control: an explicit index of the edited tree DOES move the baseline (#1222), and now says so where the edit vanished - with open(os.path.join(repo, 'app', 'shop', 'orders.py'), 'a') as f: f.write('\n\ndef void_order(n):\n return 0\n') - b = sh(repo, 'bash', AX, 'index', repo, '--lang', 'python', '--src', 'app', env=env) - c = sh(repo, 'bash', AX, 'changed', repo, env=env); ti = sh(repo, 'bash', AX, 'test-impact', repo, env=env) - check(b.returncode == 0 and 'refund_order' not in c.stdout and 'void_order' not in c.stdout - and 'baseline moved' in c.stdout and 'shop/orders.py' in c.stdout and 'axiomcode index' in c.stdout and 'baseline moved' in ti.stdout, - 'control: an explicit index of an edited tree moves the baseline, and `changed` and `test-impact` say so, naming the file', - b.stdout[-300:] + c.stdout + c.stderr + ti.stdout) - - # ── control: indexed with no --lang, every language present ────────────────────────────────────────── - mixed = os.path.join(work, 'mixed'); make(mixed, MIXED) - b = sh(mixed, 'bash', AX, 'index', mixed, env=env) - mx = os.path.join(mixed, '.axiomcode') - both = [os.path.exists(os.path.join(mx, 'out', 'graph.sqlite')), os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite'))] - check(b.returncode == 0 and all(both), 'control: a Java and Python repository indexed with no --lang has both graphs', b.stdout + b.stderr) - g = sh(mixed, 'bash', AX, 'graph', mixed, env=env) - mpage = os.path.join(mx, 'graph', 'graph.html') - html = open(mpage).read() if os.path.exists(mpage) else '' - check(g.returncode == 0 and 'chargeAccount' in html and 'render_report' in html and any(l.startswith('graph of ') and 'java' in l and 'python' in l for l in g.stdout.splitlines()), - 'control: the page draws every language the repository was indexed in', g.stdout + g.stderr) - check(os.path.exists(os.path.join(mx, 'lang', 'python', 'out', 'graph.sqlite')) and 'no rebuild' in g.stdout, - 'control: drawing it keeps both graphs and rebuilds neither', g.stdout) - c = sh(mixed, sys.executable, FRESH, 'chosen', mixed, env=env) - check(c.returncode == 0 and c.stdout.strip() == '', 'control: languages that were detected are not pinned; a rebuild detects them again', c.stdout) - - # ── every rebuild path: C# indexed with --lang csharp, a stray TypeScript front end beside it ───────────── - cs = os.path.join(work, 'csharp'); make(cs, CSHARP); cx = os.path.join(cs, '.axiomcode') - b = sh(cs, 'bash', AX, 'index', cs, '--lang', 'csharp', env=env) - check(b.returncode == 0 and os.path.exists(os.path.join(cx, 'out', 'graph.sqlite')) and not os.path.exists(os.path.join(cx, 'lang')), - 'rebuilds: a C# repository indexes with --lang csharp and no TypeScript graph', b.stdout + b.stderr) - c = sh(cs, sys.executable, FRESH, 'chosen', cs, env=env) - check(c.stdout.strip() == 'csharp', 'rebuilds: the file table records csharp as chosen', c.stdout) - # an edit, committed, then the background refresh: the path a hook or the MCP server's timer starts - with open(os.path.join(cs, 'App', 'Orders.cs'), 'a') as f: f.write('\n// committed edit\n') - sh(cs, 'git', '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qam', 'edit') - w = sh(cs, sys.executable, FRESH, 'worker', cs, env=dict(env, AXIOMCODE_REFRESH_DEBOUNCE='0.3')) - stamp = lambda: open(os.path.join(cx, 'out', 'stamp')).read().strip() if os.path.exists(os.path.join(cx, 'out', 'stamp')) else '' - no_ts = lambda: not os.path.exists(os.path.join(cx, 'lang')) and not os.path.exists(os.path.join(cx, 'out', 'typescript')) - check(w.returncode == 0 and 'rebuilding' in w.stdout and '-csharp-' in stamp() and no_ts(), - 'rebuilds: an edit plus a commit refreshes C# alone; no TypeScript graph, no TypeScript solve', w.stdout + stamp()) - # a bare index, as an agent re-runs one "to make sure" - b = sh(cs, 'bash', AX, 'index', cs, env=env) - check(b.returncode == 0 and 'keeping the languages this graph was indexed with (--lang csharp)' in b.stdout and '-csharp-' in stamp() and no_ts(), - 'rebuilds: a bare index keeps --lang csharp and says so', b.stdout + b.stderr) - # a query that finds the graph pointer broken repairs it, with the same languages - ptr = os.path.join(cx, 'out', 'graph.sqlite'); tgt = os.path.realpath(ptr) - os.rename(tgt, tgt + '.gone') - q = sh(cs, 'bash', AX, 'impact', 'OrderService.Total', cs, env=env) - check(q.returncode == 0 and os.path.exists(ptr) and '-csharp-' in stamp() and no_ts(), - 'rebuilds: a query repairing a broken graph builds C# alone', q.stdout[-600:] + q.stderr[-600:]) - - # ── surfaces ────────────────────────────────────────────────────────────────────────────────────────── - h = sh(ROOT, 'bash', AX, 'help', 'graph', env=env) - check(h.returncode == 0 and 'axiomcode graph []' in h.stdout and 'axiomcode-graph build' not in h.stdout and 'as it was indexed' in h.stdout, - '`axiomcode help graph` names the verb agents call and says a stale graph is rebuilt as it was indexed', h.stdout) - # graph is internal: not an MCP tool (tests/surfaces.py holds the list of public verbs) - check('def graph(' not in open(MCP).read(), 'graph is not offered as an MCP tool', '') - finally: - shutil.rmtree(work, ignore_errors=True) - print(f"\n{'FAIL' if fails else 'ok'}: {len(fails)} of the checks above failed" if fails else '\nok: every check passed') - return 1 if fails else 0 - - -if __name__ == '__main__': - sys.exit(main(sys.argv)) diff --git a/tests/mcp.py b/tests/mcp.py index 7412ab72..ac05f5e0 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -134,42 +134,6 @@ def check_arguments(label, cmd, cwd, lax=False): return bad -def check_words(): - """An answer's CLI flags are written as the MCP parameters they are (#1567), and nothing else is touched: quoted - code, flags only the CLI has, a flag's name inside a longer word, and a flag followed by prose rather than a value.""" - sys.path.insert(0, os.path.dirname(SERVER)) - import server - cases = [("pass --in to narrow", "pass in_path= to narrow"), - (" … +12 (--limit N)", " … +12 (limit=N)"), - (" --tests-only lists all 22 by rung and file; --why adds each one's route", - " tests=True lists all 22 by rung and file; why=True adds each one's route"), - ("ask for --page 2", "ask for page=2"), - # `--page all` is the string "all" here, and the footer names one spelling per surface, never both - ("ask for the next with --page 2, or all of it with --page all; --budget N changes the page size", - 'ask for the next with page=2, or all of it with page="all"; budget=N changes the page size'), - ("narrow instead with --in , --depth N or --tests-only", "narrow instead with in_path=, depth=N or tests=True"), - ("--page N|all", 'page=N or page="all"'), - ("narrow with `impact --in ` or `path '*' --in parser/src`.", - "narrow with `impact in_path=` or `path '*' in_path=parser/src`."), - ("start at --from ", "start at from_="), - ("no --in was given, so", "no in_path was given, so"), - # the controls: these must come back unchanged - (" --in parser/src --in-offered 11302 symbol(s)", - " in_path=parser/src --in-offered 11302 symbol(s)"), - ("print it with --json", "print it with --json"), - (" 49 | args = ['--in', path, '--tests-only']", " 49 | args = ['--in', path, '--tests-only']"), - (" | … +23 more line(s) --limit", " | … +23 more line(s) --limit"), - ("a pre-built --lang java graph", "a pre-built --lang java graph"), - ('grep -rnw "all" . lists them', 'grep -rnw "all" . lists them'), - # a site of a grep-shaped answer is the file's own text: a flag written in that code stays as written - ("tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]", - "tests/freshness.py:294: fn(['--in', p, '--fresh']) [by name ×2 · mcp_checks]"), - # and the footer under the sites is prose, rewritten as ever - ("… +3 more not listed: 3 [text] — pass --in to narrow", "… +3 more not listed: 3 [text] — pass in_path= to narrow")] - return [f"mcp_words({src!r}) gave {server.mcp_words(src)!r}, want {want!r}" - for src, want in cases if server.mcp_words(src) != want] - - def check_front_door(): """Each tool asks its verb with no flag, in the session's own directory, so the dispatcher answers as places with their code (it sees AXIOMCODE_SURFACE=mcp); impact with no name asks about the working tree's edits.""" @@ -340,7 +304,6 @@ def main(): bad += check('bin/axiomcode mcp', ['bash', CLI, 'mcp'], repo) # the SDK when the launcher finds one, which ignored an argument it did not know (#1567); else the fallback again bad += check_arguments('bin/axiomcode mcp', ['bash', CLI, 'mcp'], repo, lax=True) - bad += check_words() bad += check_front_door() if os.name != 'nt': bad += check_install_move(work) diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index 27b171af..90092481 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -25,7 +25,6 @@ for p in [os.path.join(d, 'SKILL.md')] + glob.glob(os.path.join(d, 'reference', '*.md'))) + \ [os.path.join(ROOT, 'plugins', 'axiomcode', 'AGENTS.md')] + \ sorted(glob.glob(os.path.join(ROOT, 'plugins', 'axiomcode', 'rules', '*.mdc'))) -INSTALL = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-install') VERB = r'(find|impact|path|tests)' # a tool named: a call `impact(`, the Claude Code name mcp__plugin_axiomcode_axiomcode__impact, MCP `impact`, or a command @@ -140,8 +139,7 @@ def main(): tools = schemas() bad = controls(tools) seen = set() - block = subprocess.run([sys.executable, INSTALL, '--print'], capture_output=True, text=True).stdout - for path, text in [(p, open(p, encoding='utf-8').read()) for p in DOCS] + [('the install block', block)]: + for path, text in [(p, open(p, encoding='utf-8').read()) for p in DOCS]: rel = os.path.relpath(path, ROOT) if os.path.isabs(path) else path rows = documented(text) seen |= {(t, a) for ts, a, _v, _u in rows if ts for t in ts} diff --git a/tests/mcp_first.py b/tests/mcp_first.py index 230abb0e..20ba03d0 100644 --- a/tests/mcp_first.py +++ b/tests/mcp_first.py @@ -62,13 +62,7 @@ def fire(hook, ev): if m: tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('impact', 'path', 'tests')) -# 2. the block `axiomcode install` writes into CLAUDE.md, beside the permission it grants -r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], - capture_output=True, text=True, timeout=30) -check('install --print prints the block', r.returncode == 0 and 'BEGIN axiomcode' in r.stdout, r.stderr[-200:]) -tool_first('install block', r.stdout, ('impact', 'path', 'tests')) - -# 3. the directive before the first search for a name the graph declares +# 2. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: import sqlite3 os.makedirs(os.path.join(repo, '.axiomcode', 'out')) @@ -82,7 +76,7 @@ def fire(hook, ev): # only the verbs that answer a search: tests is about an edit, not about what a grep looks for tool_first('direct', said, ('impact', 'path')) -# 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question +# 3. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: repo = os.path.join(work, 'case') shutil.copytree(CASE, repo) @@ -99,7 +93,7 @@ def fire(hook, ev): check('orient: a how-question is oriented to the flow', rc == 0 and 'next:' in said, said[:300]) tool_first('orient (how)', said, ('path',)) -# 5. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be +# 4. orient's third hint, for a verb that refuses without a scope: no verb refuses that way today, so it cannot be # fired; the order is checked in the source line that prints it. src = open(os.path.join(HOOKS, 'orient.py'), encoding='utf-8').read() i = src.find("search these with grep as usual") diff --git a/tests/multi_language.py b/tests/multi_language.py index 39e2d5b2..bd94f231 100644 --- a/tests/multi_language.py +++ b/tests/multi_language.py @@ -33,6 +33,8 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) AX = os.path.join(ROOT, 'bin', 'axiomcode') +# internal verbs (context, changed, test-impact) left the installed command's surface: ask the dispatcher +DISP = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') FRESH = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'ax_fresh.py') BUILD = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode-build') @@ -171,24 +173,24 @@ def check(ok, why, detail=''): # a scope only another language's graph holds is honoured, not refused because the main graph lacks it. The # scope is a real directory and comes after the repository: the dispatcher once took it for the repository # and asked the main graph alone - s = sh(repo, AX, 'context', 'add up a total', '.', '--in', 'tools/pkg', env=quiet) + s = sh(repo, DISP, 'context', 'add up a total', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode == 0 and '══ python graph' in s.stdout and 'no indexed file' not in s.stdout, 'scope: context --in a directory only the python graph holds answers from that graph', s.stdout + s.stderr) s = sh(repo, AX, 'impact', 'add', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode == 0 and 'total' in s.stdout, 'scope: impact --in it too', s.stdout + s.stderr) s = sh(repo, AX, 'path', 'total', 'add', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode == 0 and 'verified' in s.stdout, 'scope: and path --in it', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'compute the area of a shape', '.', '--in', 'src', env=quiet) + s = sh(repo, DISP, 'context', 'compute the area of a shape', '.', '--in', 'src', env=quiet) check(s.returncode == 0 and '══' not in s.stdout and 'src/' in s.stdout, 'scope (control): --in the main graph\'s directory answers as the main graph alone', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'add up a total', '.', '--in', 'tools/nosuch', env=quiet) + s = sh(repo, DISP, 'context', 'add up a total', '.', '--in', 'tools/nosuch', env=quiet) # a directory no graph holds is a typo, not a question about nothing: answered at the root, said ONCE, never one # refusal menu per language (the scope another graph holds, above, is still answered from that graph) check(s.returncode == 0 and s.stdout.count("no indexed file in any graph has 'tools/nosuch'") == 1 and 'answering at the repository root' in s.stdout and 'tools/pkg/calc.py' in s.stdout and 're-run with one of these' not in s.stdout, 'scope: a directory no graph holds is answered at the repository root, and said once', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'compute the area of a shape', '.', '--in', 'tools/pkg', env=quiet) + s = sh(repo, DISP, 'context', 'compute the area of a shape', '.', '--in', 'tools/pkg', env=quiet) check(s.returncode != 0 and 'none of these words appear under it' in s.stdout and 'no indexed file' not in s.stdout, 'scope (control): a scope one graph holds, with nothing under it matching, is refused by that graph alone', s.stdout + s.stderr) # #1584: `main` is declared in the typescript graph (src/cli.ts) and twice in the python one. With a scope, only @@ -210,16 +212,16 @@ def check(ok, why, detail=''): s = sh(repo, AX, 'impact', 'emit', '.', '--in', 'pytools', env=quiet) check(s.returncode == 0 and 'lib/pytools/probe.py' in s.stdout and 'tools/gen/make.py' not in s.stdout, 'scope (control): a scope that names no path of the repository still matches anywhere', s.stdout + s.stderr) - s = sh(repo, AX, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) + s = sh(repo, DISP, 'context', 'emit a value', '.', '--in', 'tools', env=quiet) check(s.returncode == 0 and 'tools/gen/make.py' in s.stdout and 'lib/pytools' not in s.stdout, 'scope: context --in tools keeps lib/pytools/ out too', s.stdout + s.stderr) # a directory only the MAIN graph holds, asked from every graph: another language's graph once read it as text # no graph holds, listed its source files as text rows and ended on a next step outside the scope - s = sh(repo, AX, 'context', 'compute the area of a shape and add up a total', '.', '--in', 'src', env=quiet) + s = sh(repo, DISP, 'context', 'compute the area of a shape and add up a total', '.', '--in', 'src', env=quiet) check(s.returncode == 0 and 'not indexed: src/' not in s.stdout and 'tools/' not in s.stdout and 'src/shape.ts' in s.stdout, 'scope: a directory the main graph holds is not text to the other graphs', s.stdout + s.stderr) # a directory no graph holds, named by --in: its text files are listed once, and every next step stays in it - s = sh(repo, AX, 'context', 'which steps add up a total', '.', '--in', 'notes', env=quiet) + s = sh(repo, DISP, 'context', 'which steps add up a total', '.', '--in', 'notes', env=quiet) nexts = [l for l in s.stdout.splitlines() if l.startswith('next:')] check(s.returncode == 0 and s.stdout.count('notes/steps.json') == 1 and nexts and all('notes/' in l for l in nexts), 'scope: --in a text-only directory is listed by one graph, and no next step leaves it', s.stdout + s.stderr) @@ -227,19 +229,19 @@ def check(ok, why, detail=''): # ── --from ──────────────────────────────────────────────────────────────────────────────────────────── # `main` is declared in the typescript graph and twice in the python one: the flow starts where the task's # words land, and that language comes first - f = sh(repo, AX, 'context', 'how does the calc tool total its numbers', '.', '--from', 'main', env=quiet) + f = sh(repo, DISP, 'context', 'how does the calc tool total its numbers', '.', '--from', 'main', env=quiet) first = f.stdout.split('══')[1] if f.stdout.count('══') >= 2 else f.stdout check(f.returncode == 0 and first.strip().startswith('python graph') and 'tools/pkg/cli.py' in f.stdout and 'tools/gen/make.py' not in f.stdout and 'axiomcode-from-landing' not in f.stdout + f.stderr, '--from: a common name starts in the language and the file the task\'s words land in', f.stdout + f.stderr) - f = sh(repo, AX, 'context', 'how does the calc tool total its numbers', '.', '--in', 'tools/gen', '--from', 'main', env=quiet) + f = sh(repo, DISP, 'context', 'how does the calc tool total its numbers', '.', '--in', 'tools/gen', '--from', 'main', env=quiet) check(f.returncode == 0 and 'tools/gen/make.py' in f.stdout and 'tools/pkg/cli.py' not in f.stdout, '--from: a scope the caller gives picks the declaration under it', f.stdout + f.stderr) - f = sh(repo, AX, 'context', 'how does the shape area get run', '.', '--from', 'main', env=quiet) + f = sh(repo, DISP, 'context', 'how does the shape area get run', '.', '--from', 'main', env=quiet) first = f.stdout.split('══')[1] if f.stdout.count('══') >= 2 else f.stdout check(f.returncode == 0 and 'src/cli.ts' in f.stdout and not first.strip().startswith('python graph'), '--from (control): a task about the typescript code starts there', f.stdout + f.stderr) - f = sh(repo, AX, 'context', 'how does it work', '.', '--from', 'total', env=quiet) + f = sh(repo, DISP, 'context', 'how does it work', '.', '--from', 'total', env=quiet) check(f.returncode == 0 and 'tools/pkg/calc.py' in f.stdout and '--from total:' not in f.stdout, '--from (control): a name declared once starts there, with nothing narrowed', f.stdout + f.stderr) @@ -258,19 +260,19 @@ def check(ok, why, detail=''): calc = os.path.join(repo, 'tools/pkg/calc.py'); util = os.path.join(repo, 'src/util.ts') open(calc, 'w').write(FILES['tools/pkg/calc.py'].replace('return a + b', 'return b + a')) open(util, 'w').write(FILES['src/util.ts'].replace('return x * x', 'return x * x * 1')) - c = sh(repo, AX, 'changed', '.', env=quiet) + c = sh(repo, DISP, 'changed', '.', env=quiet) check(c.returncode == 0 and c.stdout.count('add ') == 1 and c.stdout.count('square ') == 1 and 'no declarations known here' not in c.stdout and '══ javascript graph' not in c.stdout, 'changed: a Python edit and a TypeScript edit are each reported once, by the graph of their language', c.stdout + c.stderr) - cj = sh(repo, AX, 'changed', '.', '--json', env=quiet) + cj = sh(repo, DISP, 'changed', '.', '--json', env=quiet) try: d = json.loads(cj.stdout) except ValueError: d = {} syms = sorted([e['symbol'] for e in d.get('changed', [])] + [e['symbol'] for o in d.get('other_languages', {}).values() for e in o.get('changed', [])]) check(syms == ['add', 'square'], 'changed --json: both edits, the other language\'s under other_languages', cj.stdout[-800:]) - t = sh(repo, AX, 'test-impact', '.', env=quiet) + t = sh(repo, DISP, 'test-impact', '.', env=quiet) check(t.returncode == 0 and 'add [body]' in t.stdout and 'square' in t.stdout, 'test-impact: starts from the edits in both languages', t.stdout + t.stderr) sh(repo, 'git', 'checkout', '-q', '--', '.') - c = sh(repo, AX, 'changed', '.', env=quiet) + c = sh(repo, DISP, 'changed', '.', env=quiet) check(c.returncode == 0 and c.stdout.count('no change to a declaration') == 1 and '══' not in c.stdout, 'changed: a clean tree is one "no change", as in a repository of one language', c.stdout + c.stderr) diff --git a/tests/python_names.py b/tests/python_names.py index e2283796..ff3965d7 100644 --- a/tests/python_names.py +++ b/tests/python_names.py @@ -54,9 +54,9 @@ def calls(): open(log, 'w').close() # the command npm links: bash's python3 is the placeholder until the launcher puts its own first - r = subprocess.run(['node', AXJS, 'help', 'context'], env=placeholder, capture_output=True, text=True, timeout=60) - check('`axiomcode help context` answers when python3 is the Store placeholder', - r.returncode == 0 and 'context' in r.stdout and 'Python was not found' not in r.stderr, + r = subprocess.run(['node', AXJS, 'help', 'impact'], env=placeholder, capture_output=True, text=True, timeout=60) + check('`axiomcode help impact` answers when python3 is the Store placeholder', + r.returncode == 0 and 'impact' in r.stdout and 'Python was not found' not in r.stderr, f'rc={r.returncode} err={r.stderr[-300:]}') c = calls() check('...and the Python bash ran is the one `python` named, not the placeholder', @@ -74,7 +74,7 @@ def calls(): # AXIOMCODE_PYTHON comes first, as it does for the MCP server chosen = os.path.join(tmp, 'mine') script(chosen, f'#!/bin/sh\necho "mine $*" >> "{log}"\nexec "{sys.executable}" "$@"\n') - r = subprocess.run(['node', AXJS, 'help', 'context'], env=dict(placeholder, AXIOMCODE_PYTHON=chosen), + r = subprocess.run(['node', AXJS, 'help', 'impact'], env=dict(placeholder, AXIOMCODE_PYTHON=chosen), capture_output=True, text=True, timeout=60) check('AXIOMCODE_PYTHON is the Python bash runs', r.returncode == 0 and 'mine' in calls(), f'rc={r.returncode}') diff --git a/tests/repo_arg.py b/tests/repo_arg.py index 006565c6..eff39118 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -7,7 +7,7 @@ path onto the repository, a tree that is not there. Checks, each run from a working directory with no graph, so a fall-back to it would build one there: - every verb (index, context, path, impact, changed, test-impact, graph) given a missing repository exits non-zero, + every verb (index, context, path, impact, changed, test-impact) given a missing repository exits non-zero, names the path, and builds nothing: no .axiomcode appears in the working directory index --src and --src are refused the same way a verb script run directly (not through the dispatcher) with a missing repository is refused and builds nothing @@ -47,7 +47,7 @@ def no_graph(where, what): for args in (['impact', 'foo', missing], ['impact', 'foo', 'bar', missing], ['impact', 'foo', './not-there'], ['context', 'how does foo work', missing], ['context', 'how does foo work', 'not-there'], ['path', 'bar', 'foo', missing], ['path', 'bar', 'foo', 'not-there'], - ['changed', missing], ['test-impact', missing], ['graph', missing], + ['changed', missing], ['test-impact', missing], ['index', missing], ['index', 'not-there'], ['index', '--src', missing], ['index', '--src', 'not-there'], ['index', '--lang', 'python', '--src', missing]): r = ax(args, cwd) diff --git a/tests/surfaces.py b/tests/surfaces.py index 7e550df0..d7e5cb58 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """tests/surfaces.py — the public verbs are on every caller-facing surface, and nothing else is. -The product offers a small surface: `index` to set up, then four questions — `find`, `impact`, `path`, `tests` — +The product offers a small surface: `index` to set up, then three questions — `impact`, `path`, `tests` — answered as numbered places with the code of the function each sits in. Every public verb must be: · in `axiomcode --help` (the dispatcher's own comment block) and in `bin/axiomcode --help`, the command an install @@ -14,8 +14,8 @@ INTERNAL, with the reason written down, and must appear on none of those surfaces. A verb added to the dispatch table that is in neither list fails, so exposing one is a decision rather than an accident (#1034). -The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, the block `axiomcode install` writes, and -the README's CLI section) name no old MCP tool (`axiomcode_context` …) and no flag other than index's. +The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, and the README's CLI section) name no old +MCP tool (`axiomcode_context` …) and no flag other than index's. python3 tests/surfaces.py """ @@ -34,13 +34,9 @@ # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'search by task words; search is the agent\'s own grep, which a hook annotates', - 'find': 'the front-door spelling of context; dispatched for compatibility, no longer advertised', + 'context': 'search by task words; the orient hook and the suites call it, no caller-facing surface does', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', - 'graph': 'draws the graph as a page for a person; not one of the four questions', - 'diff': 'compares two graphs of one tree; a tool for checking an engine change', - 'install': 'writes the CLAUDE.md block once, at setup', } OLD_TOOLS = re.compile(r'\baxiomcode_(context|impact|path|changed|test_impact|graph|index|diff)\b') INDEX_FLAGS = {'--lang', '--src', '--library'} @@ -67,8 +63,6 @@ def docs(): for p in (SKILL, os.path.join(ROOT, 'skills', 'axiomcode', 'SKILL.md'), os.path.join(PLUG, 'AGENTS.md'), os.path.join(PLUG, 'rules', 'axiomcode.mdc')): out.append((os.path.relpath(p, ROOT), open(p, encoding='utf-8').read())) - r = subprocess.run([sys.executable, os.path.join(SCRIPTS, 'axiomcode-install'), '--print'], capture_output=True, text=True) - out.append(('the install block', r.stdout)) out.append(('README.md CLI section', readme_cli())) return out From 679d3575cb6fb74e862c6d55131e7773a619fb65 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:11:33 -0700 Subject: [PATCH 028/133] perf(python): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Profiling showed no single hot stage in a Python parse: read, tree-sitter parse, mirror, extract and hash together are the cost, and all of it is independent between files — everything cross-module sits in linkProject. So the per-file work now runs on worker threads (python-parse-pool.ts, python-parse-worker.ts): each worker runs the same extractor the serial loop runs, and the main thread consumes every outcome in sorted file order through the same body, so skip order, accumulation order and the output bytes do not depend on which worker finishes first. AXIOMCODE_PARSE_JOBS picks the width (default min(4, cores-1); 1 is the strict serial path), and a source-tree run with no compiled worker falls back to serial instead of failing. The byte gate caught a real defect before it caught anything else: the expression extractor's assignedValueByTargetRange was never reset between files, and byte ranges repeat across files, so a pairing left by an earlier file fed a later file's receiver lookup — which call sites resolved depended on extraction order (15 of 35,626 call sites on one corpus subject). It is reset per file now, with returnIndexByMethod beside it, and the field extractor's annotationByField, which pinned every file's syntax tree for the length of the run. Two things decided whether a 1.9 GB subject fit in the default heap: - results stream: a worker's outcome is thawed, consumed and dropped the moment its file's turn comes, and a dispatch window (jobs * 6) caps what can wait out of order — buffering them all held the project's rows twice; - rows rehydrate through a compiled object literal with __proto__, cached per table: Object.create plus one store per property lands in V8's dictionary mode, and 1M 25-field rows measured 1.6 GB that way against 230 MB constructor-built. The literal gets the constructor's shape and the constructor's cost. On a 2,932-file subject the extract phase runs 18.5 s serial, 17.4 s at 2 jobs, 12.9 s at 4, 10.9 s at 6; past 4 the subject's whole run stops improving (GC against more worker heaps), so the default stops at 4. Whole-subject walls: 7 s -> 5 s, 6 s -> 5 s, 6 s -> 5 s on the three smaller corpus subjects. What remains of the big subject's python phase is linkProject (~16 s) and CSV export (~12 s), both untouched serial work. IR is byte-identical to serial on all four corpus subjects at 4 and 6 jobs; tests/run.py --lang python 294/294; tests/front_door.py 21/21. AXIOMCODE_PARSE_DEBUG=1 prints the pool / consume / linkProject split. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../extractors/python-expression-extractor.ts | 6 + .../extractors/python-field-extractor.ts | 4 + .../src/workflows/python/python-parse-pool.ts | 305 ++++++++++++++++++ .../workflows/python/python-parse-worker.ts | 50 +++ .../python/python-project-analyzer.ts | 107 ++++-- 5 files changed, 448 insertions(+), 24 deletions(-) create mode 100644 parser/src/workflows/python/python-parse-pool.ts create mode 100644 parser/src/workflows/python/python-parse-worker.ts diff --git a/parser/src/parsers/python/extractors/python-expression-extractor.ts b/parser/src/parsers/python/extractors/python-expression-extractor.ts index 8405aad5..d6a8763d 100644 --- a/parser/src/parsers/python/extractors/python-expression-extractor.ts +++ b/parser/src/parsers/python/extractors/python-expression-extractor.ts @@ -240,6 +240,12 @@ export class PythonExpressionExtractor { this.callSites = []; this.worklist = []; this.expressionByByteRange = new Map(); + // Both are PER-FILE: byte ranges repeat across files, so a pairing left + // from an earlier file satisfied a later file's receiver lookup with the + // wrong value expression — which file won depended on extraction order, + // and a worker that had seen different files answered differently. + this.assignedValueByTargetRange = new Map(); + this.returnIndexByMethod = new Map(); this.pendingReceiverLinks = []; const moduleScopeHash = input.scopeHashByNodeId.get(input.rootNode.id) ?? ''; diff --git a/parser/src/parsers/python/extractors/python-field-extractor.ts b/parser/src/parsers/python/extractors/python-field-extractor.ts index 08b87a90..f05b4267 100644 --- a/parser/src/parsers/python/extractors/python-field-extractor.ts +++ b/parser/src/parsers/python/extractors/python-field-extractor.ts @@ -178,6 +178,10 @@ export class PythonFieldExtractor { extract(input: PythonFieldInput): PythonFieldExtraction { this.input = input; this.methodByNodeId = new Map(); + // Field hashes are globally unique, so stale entries never answered a + // lookup — but each holds a SyntaxNode, so an unreset map pinned every + // file's whole tree for the length of the run. + this.annotationByField = new Map(); const methodByHash = new Map(); for (const method of input.methods) { diff --git a/parser/src/workflows/python/python-parse-pool.ts b/parser/src/workflows/python/python-parse-pool.ts new file mode 100644 index 00000000..c3792f1c --- /dev/null +++ b/parser/src/workflows/python/python-parse-pool.ts @@ -0,0 +1,305 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { Worker } from 'worker_threads'; + +import { + PyBindingRegistry, + PyBlockRegistry, + PyCallSiteRegistry, + PyCommentRegistry, + PyDecoratorArgumentRegistry, + PyDecoratorRegistry, + PyExpressionRegistry, + PyFieldPositionRegistry, + PyFieldRegistry, + PyImportRegistry, + PyMethodParameterRegistry, + PyMethodRegistry, + PyModuleRegistry, + PyParseGapRegistry, + PyScopeRegistry, + PyTypeBaseRegistry, + PyTypeParameterRegistry, + PyTypeRegistry, + PyTypeReferenceRegistry, +} from '@/analysis-types/python'; +import { PythonFactSet } from '@/parsers/python/extractors/python-fact-extractor'; + +/** + * The per-file parse work (read, tree-sitter parse, mirror, extract, hash) is + * independent between files — everything cross-module happens later, in + * `linkProject` — so it runs on a pool of worker threads. The MAIN thread still + * consumes the results in sorted file order, through the same code the serial + * loop runs, so the accumulated rows, the skip records and therefore the output + * bytes are identical whatever order the workers finish in. + * + * ## What crosses the thread boundary + * + * A worker cannot post class instances: structured clone keeps own properties + * and drops the prototype. The registry rows are flat value holders — every + * field a string, number or enum — so the worker posts `{...row}` and `thaw` + * reattaches the one prototype each table's rows share. The four `Map`s in a + * fact set clone natively. Nothing is re-hashed and nothing is re-parsed: the + * bytes that cross are the bytes the extractor produced. + * + * `linkProject` MUTATES rows after this (that is why it runs before export), + * which is exactly why rows must come back as real instances before it runs — + * a plain snapshot would take the mutation and lose the `toCsv`. + */ +const TABLE_PROTOTYPES = { + scopes: PyScopeRegistry.prototype, + bindings: PyBindingRegistry.prototype, + types: PyTypeRegistry.prototype, + typeBases: PyTypeBaseRegistry.prototype, + methods: PyMethodRegistry.prototype, + methodParameters: PyMethodParameterRegistry.prototype, + imports: PyImportRegistry.prototype, + expressions: PyExpressionRegistry.prototype, + callSites: PyCallSiteRegistry.prototype, + typeReferences: PyTypeReferenceRegistry.prototype, + fields: PyFieldRegistry.prototype, + fieldPositions: PyFieldPositionRegistry.prototype, + blocks: PyBlockRegistry.prototype, + comments: PyCommentRegistry.prototype, + parseGaps: PyParseGapRegistry.prototype, + typeParameters: PyTypeParameterRegistry.prototype, + decorators: PyDecoratorRegistry.prototype, + decoratorArguments: PyDecoratorArgumentRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** What the analyzer sends a worker for one file: strings only. */ +export interface PythonParseDispatch { + i: number; + /** Absolute path, read inside the worker. */ + filePath: string; + /** The path recorded on rows — `recordedFilePath`, computed by the caller. */ + recordedFilePath: string; + moduleQualifiedName?: string; + baseMservPath: string; + serviceVersionLinkHash: string; +} + +/** One file's outcome, the same three cases the serial loop distinguishes. */ +export interface PythonParseOutcome { + readError?: string; + extractError?: string; + facts?: PythonFactSet; +} + +/** The worker's reply: `facts` is the frozen (prototype-less) snapshot. */ +export interface PythonParseReply { + i: number; + readError?: string; + extractError?: string; + facts?: Record; +} + +/** + * Worker side: a fact set as COLUMNS structured clone can carry cheaply. A + * per-object snapshot encodes every property name once per row; a table's + * million rows share one class, so the names go once per table and each row + * crosses as a value array. The difference decided whether a 1.9 GB subject + * fit: the main thread's accumulated rows already peak near V8's default old + * space, and per-object clones of the same rows pushed it over. + */ +interface FrozenTable { + keys: string[]; + rows: unknown[][]; +} + +function freezeTable(rows: object[]): FrozenTable { + if (rows.length === 0) return { keys: [], rows: [] }; + // One pass, no per-row key arrays: a first cut did a union prepass with + // Object.keys per row and it was a third of the worker's CPU. Columns are + // discovered as rows mention them (a conditional assignment can leave a + // property off an instance), so an early row encoded before a late column + // existed is short, and thaw reads the tail as undefined — which is what + // the absent property read as everywhere it is used. + const index = new Map(); + const keys: string[] = []; + const out: unknown[][] = new Array(rows.length); + for (let r = 0; r < rows.length; r++) { + const row = rows[r] as Record; + const vals: unknown[] = []; + for (const key in row) { + let i = index.get(key); + if (i === undefined) { + i = keys.length; + index.set(key, i); + keys.push(key); + } + vals[i] = row[key]; + } + out[r] = vals; + } + return { keys, rows: out }; +} + +// A row built by `Object.create` plus one store per property lands in V8's +// dictionary mode: measured on a 25-field row, 1M of them cost 1.6 GB against +// 230 MB constructor-built — the difference WAS the main thread's OOM on a +// large subject. An object literal with `__proto__` gets the same fast shape +// the constructor makes, so each table gets a compiled literal, cached by its +// key list. Keys come from our own row classes, but they cross a thread as +// data, so anything that is not a plain identifier falls back to the slow +// shape instead of reaching the compiled source. +const IDENT = /^[A-Za-z_$][\w$]*$/; +const factories = new Map object>(); + +function rowFactory(keys: string[]): ((v: unknown[], proto: object) => object) | null { + const signature = keys.join('\t'); + const cached = factories.get(signature); + if (cached) return cached; + if (!keys.every(k => IDENT.test(k))) return null; + const body = + 'return {__proto__: proto,' + keys.map((k, i) => `${k}: v[${i}]`).join(',') + '};'; + const made = new Function('v', 'proto', body) as (v: unknown[], proto: object) => object; + factories.set(signature, made); + return made; +} + +function thawTable(frozen: FrozenTable, proto: object): object[] { + const { keys, rows } = frozen; + const make = rowFactory(keys); + if (make) return rows.map(values => make(values, proto)); + return rows.map(values => { + const out = Object.create(proto) as Record; + for (let i = 0; i < keys.length; i++) out[keys[i] as string] = values[i]; + return out; + }); +} + +export function freezeFactSet(facts: PythonFactSet): Record { + const out: Record = { + module: facts.module ? { ...facts.module } : undefined, + fieldHashByTypeAndName: facts.fieldHashByTypeAndName, + receiverNameByMethodHash: facts.receiverNameByMethodHash, + assignedValueByTargetRange: facts.assignedValueByTargetRange, + expressionByByteRange: facts.expressionByByteRange, + dialect: facts.dialect, + skippedReason: facts.skippedReason, + python2Findings: facts.python2Findings, + }; + for (const key of TABLE_KEYS) out[key] = freezeTable(facts[key]); + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFactSet(frozen: Record): PythonFactSet { + const out = { ...frozen } as unknown as PythonFactSet; + if (frozen.module) { + out.module = Object.assign(Object.create(PyModuleRegistry.prototype), frozen.module); + } + for (const key of TABLE_KEYS) { + (out as unknown as Record)[key] = thawTable( + frozen[key] as FrozenTable, + TABLE_PROTOTYPES[key] + ); + } + return out; +} + +/** + * How many parse workers to run. `AXIOMCODE_PARSE_JOBS` decides; `1` restores + * the strict serial path (the two produce identical bytes — `1` exists for + * memory-tight hosts and for bisecting). The default leaves a core for the + * main thread and caps at 4: consume runs single-threaded on the main thread, + * so workers saturate it — on a 2,932-file subject the extract phase went + * 18.5s serial / 17.4s at 2 / 12.9s at 4 / 10.9s at 6 jobs, but past 4 the + * subject's whole run stopped improving (GC against more worker heaps), so 4 + * is where the default stops. + */ +export function parsePoolJobs(fileCount: number): number { + const env = Number(process.env.AXIOMCODE_PARSE_JOBS || ''); + const cores = typeof os.availableParallelism === 'function' + ? os.availableParallelism() + : os.cpus().length; + const jobs = Number.isFinite(env) && env >= 1 + ? Math.floor(env) + : Math.max(1, Math.min(4, cores - 1)); + // Under ~2 files per worker the pool's startup (a thread, a module graph, a + // tree-sitter instance each) costs more than it hides. + return fileCount >= jobs * 2 ? jobs : 1; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN FILE + * ORDER as results become available — never after collecting them all. The + * rows of a repository already fill the main thread's heap once, in + * `accumulated`; buffering every worker's snapshot beside them held the whole + * project TWICE and took a 1.9 GB subject over the default heap. So a result + * is thawed, consumed and dropped the moment its turn comes, and the DISPATCH + * WINDOW below caps what can wait out of order: one slow file holds back at + * most `jobs * 6` finished snapshots, not the rest of the repository. + * + * Returns `false` when the compiled worker is not there (a source-tree run + * under a TS test runner has no `dist/`): the caller falls back to the serial + * loop rather than fail, so an environment that cannot pool still answers. + */ +export async function parseFilesInPool( + dispatches: PythonParseDispatch[], + jobs: number, + consume: (i: number, outcome: PythonParseOutcome) => void +): Promise { + const workerPath = path.join(__dirname, 'python-parse-worker.js'); + if (!fs.existsSync(workerPath)) return false; + if (dispatches.length === 0) return true; + + const window = jobs * 6; + const ready = new Map(); + const workers: Worker[] = []; + let nextToDispatch = 0; + let nextToConsume = 0; + const idle: Worker[] = []; + + await new Promise((resolve, reject) => { + const drain = () => { + for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { + ready.delete(nextToConsume); + consume( + nextToConsume, + reply.facts + ? { facts: thawFactSet(reply.facts) } + : { readError: reply.readError, extractError: reply.extractError } + ); + nextToConsume += 1; + } + }; + const feed = (worker: Worker) => { + if (nextToDispatch >= dispatches.length) { + idle.push(worker); + if (nextToConsume >= dispatches.length) resolve(); + return; + } + if (nextToDispatch - nextToConsume >= window) { + idle.push(worker); // drain() wakes it once its result's turn has come + return; + } + worker.postMessage(dispatches[nextToDispatch]); + nextToDispatch += 1; + }; + for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { + const worker = new Worker(workerPath); + workers.push(worker); + worker.on('message', (reply: PythonParseReply) => { + ready.set(reply.i, reply); + drain(); + feed(worker); + while (idle.length > 0 && nextToDispatch - nextToConsume < window + && nextToDispatch < dispatches.length) { + feed(idle.pop() as Worker); + } + if (nextToConsume >= dispatches.length) resolve(); + }); + worker.on('error', reject); + feed(worker); + } + }).finally(() => { + for (const worker of workers) void worker.terminate(); + }); + return true; +} diff --git a/parser/src/workflows/python/python-parse-worker.ts b/parser/src/workflows/python/python-parse-worker.ts new file mode 100644 index 00000000..1261c5df --- /dev/null +++ b/parser/src/workflows/python/python-parse-worker.ts @@ -0,0 +1,50 @@ +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { PythonEmissionRegime } from '@/enums/python/modules'; +import { PythonFactExtractor } from '@/parsers/python/extractors/python-fact-extractor'; +import { + freezeFactSet, + PythonParseDispatch, + PythonParseReply, +} from '@/workflows/python/python-parse-pool'; + +/** + * One parse worker: reads a file, runs the SAME extractor the serial loop + * runs, and posts the fact set back as a prototype-less snapshot + * (`freezeFactSet`). The two error cases mirror the serial loop's two catch + * blocks exactly — a read failure and an extractor throw are different facts, + * and the analyzer records them under different reasons. + * + * One extractor per worker, reused across files, as the analyzer reuses its + * one extractor across the whole project: its only cross-call state is a + * per-file scratch field the next `extract` overwrites. + */ +const extractor = new PythonFactExtractor(); +const port = parentPort; +if (!port) throw new Error('python-parse-worker must run as a worker thread'); + +port.on('message', (job: PythonParseDispatch) => { + void (async () => { + let sourceCode: string; + try { + sourceCode = await fsp.readFile(job.filePath, 'utf-8'); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies PythonParseReply); + return; + } + try { + const facts = extractor.extract({ + sourceCode, + filePath: job.recordedFilePath, + baseMservPath: job.baseMservPath, + moduleQualifiedName: job.moduleQualifiedName, + serviceVersionLinkHash: job.serviceVersionLinkHash, + emissionRegime: PythonEmissionRegime.PY3_0_11, + }); + port.postMessage({ i: job.i, facts: freezeFactSet(facts) } satisfies PythonParseReply); + } catch (error) { + port.postMessage({ i: job.i, extractError: String(error) } satisfies PythonParseReply); + } + })(); +}); diff --git a/parser/src/workflows/python/python-project-analyzer.ts b/parser/src/workflows/python/python-project-analyzer.ts index 4a62a860..0c07497a 100644 --- a/parser/src/workflows/python/python-project-analyzer.ts +++ b/parser/src/workflows/python/python-project-analyzer.ts @@ -21,6 +21,7 @@ import { } from '@/parsers/python/extractors/python-resolution-linker'; import { Python2Finding } from '@/parsers/python/types'; import { isGitIgnoredDir } from '@/utils/git-ignored'; +import { parseFilesInPool, parsePoolJobs } from '@/workflows/python/python-parse-pool'; /** One rejected or unanalysable file. */ interface SkippedPythonFile { @@ -242,38 +243,40 @@ export class PythonProjectAnalyzer { // been parsed, and file order is not a dependency order. const perModule: ProjectModuleFacts[] = []; + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (python-parse-pool.ts): read, parse, mirror, extract and hash are + // independent between files, and profiling shows no single stage dominates + // — the whole per-file pipeline does. The loop below still CONSUMES every + // outcome in sorted file order through the unchanged body, so the + // accumulated rows, the skip order and the output bytes are byte-identical + // to the serial path (AXIOMCODE_PARSE_JOBS=1), whichever order workers + // finish in. Everything cross-module stays down in `linkProject`. let analysed = 0; - for (const filePath of files) { - const moduleQualifiedName = this.moduleQualifiedNameFor(options.rootDir, filePath); - - let sourceCode: string; - try { - sourceCode = await fsp.readFile(filePath, 'utf-8'); - } catch (error) { - this.recordSkip(filePath, options, SkippedFileReason.READ_ERROR, [], String(error)); - continue; - } - let facts; - try { - facts = this.extractor.extract({ - sourceCode, - filePath: this.recordedFilePath(filePath, options.rootDir, options.baseMservPath), - baseMservPath: options.baseMservPath, - moduleQualifiedName, - serviceVersionLinkHash, - emissionRegime: PythonEmissionRegime.PY3_0_11, - }); - } catch (error) { + // One file's outcome, consumed the same way whichever thread produced it. + // The pool calls this in file order as results arrive (never after + // buffering them all — a project's rows fill the heap once, not twice), + // and the serial loop calls it inline, so skip order, accumulation order + // and therefore output bytes are identical across the two paths. + const consumeOutcome = ( + filePath: string, + outcome: { readError?: string; extractError?: string; facts?: ReturnType } + ): void => { + if (outcome.readError !== undefined) { + this.recordSkip(filePath, options, SkippedFileReason.READ_ERROR, [], outcome.readError); + return; + } + if (outcome.extractError !== undefined || !outcome.facts) { this.recordSkip( filePath, options, SkippedFileReason.EXTRACTION_ERROR, [], - String(error) + outcome.extractError ?? 'worker returned no facts' ); - continue; + return; } + const facts = outcome.facts; if (facts.dialect !== PythonDialect.PY3 || !facts.module) { this.recordSkip( @@ -289,7 +292,7 @@ export class PythonProjectAnalyzer { // facts in it. The skipped-files CSV records the DECISION; these record // WHAT could not be represented and where. accumulated.parseGaps.push(...facts.parseGaps); - continue; + return; } analysed += 1; @@ -338,11 +341,67 @@ export class PythonProjectAnalyzer { [...facts.expressionByByteRange].map(([range, hash]) => [hash, range]) ), }); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (python-parse-pool.ts): read, parse, mirror, extract and hash are + // independent between files, and profiling shows no single stage dominates + // — the whole per-file pipeline does. Everything cross-module stays down + // in `linkProject`. AXIOMCODE_PARSE_JOBS=1 restores the strict serial + // path; the pool declining (no compiled worker beside this file) falls + // back to it too. + const debugT = process.env.AXIOMCODE_PARSE_DEBUG ? Date.now() : 0; + const mark = (what: string) => { + if (debugT) process.stderr.write(`[parse-pool] ${what} +${((Date.now() - debugT) / 1000).toFixed(1)}s\n`); + }; + const jobs = parsePoolJobs(files.length); + let pooled = false; + if (jobs > 1) { + pooled = await parseFilesInPool( + files.map((filePath, i) => ({ + i, + filePath, + recordedFilePath: this.recordedFilePath(filePath, options.rootDir, options.baseMservPath), + moduleQualifiedName: this.moduleQualifiedNameFor(options.rootDir, filePath), + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + })), + jobs, + (i, outcome) => consumeOutcome(files[i] as string, outcome) + ); + } + mark(`pool done (${jobs} jobs, ${files.length} files)`); + if (!pooled) { + for (const filePath of files) { + let outcome; + try { + const sourceCode = await fsp.readFile(filePath, 'utf-8'); + try { + outcome = { + facts: this.extractor.extract({ + sourceCode, + filePath: this.recordedFilePath(filePath, options.rootDir, options.baseMservPath), + baseMservPath: options.baseMservPath, + moduleQualifiedName: this.moduleQualifiedNameFor(options.rootDir, filePath), + serviceVersionLinkHash, + emissionRegime: PythonEmissionRegime.PY3_0_11, + }), + }; + } catch (error) { + outcome = { extractError: String(error) }; + } + } catch (error) { + outcome = { readError: String(error) }; + } + consumeOutcome(filePath, outcome); + } } // The cross-module pass mutates rows already in `accumulated` — they are the // same objects — so it must run BEFORE export. + mark('consume done'); const resolution = this.resolutionLinker.linkProject(perModule); + mark('linkProject done'); await fsp.mkdir(options.outputDir, { recursive: true }); await this.exportCsv(accumulated.modules, options.outputDir, PYTHON_CSV_FILES.MODULES); From 941a913daea7265d52e12f6a8d973fab3a6c8082 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:28:35 -0700 Subject: [PATCH 029/133] parse pool: the thread machinery moves to parse-pool-core, for every language to share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The columnar freeze/thaw, the compiled-literal rehydration, the in-order streaming pool and the jobs default are language-independent; python-parse-pool keeps only what is Python's — the prototype per table and its fact-set shape. Behaviour is unchanged: the corpus subject re-gates byte-identical between AXIOMCODE_PARSE_JOBS=1 and the pool through the refactored path. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/parse-pool-core.ts | 174 +++++++++++++++ .../src/workflows/python/python-parse-pool.ts | 211 +++--------------- 2 files changed, 200 insertions(+), 185 deletions(-) create mode 100644 parser/src/workflows/parse-pool-core.ts diff --git a/parser/src/workflows/parse-pool-core.ts b/parser/src/workflows/parse-pool-core.ts new file mode 100644 index 00000000..1bd162e8 --- /dev/null +++ b/parser/src/workflows/parse-pool-core.ts @@ -0,0 +1,174 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import { Worker } from 'worker_threads'; + +/** + * The language-independent half of a parallel parse stage. A language's + * analyzer keeps its own loop body; what every language shares is: + * + * - rows cross the thread boundary as COLUMNS (`freezeTable` / `thawTable`): + * a per-object snapshot encodes every property name once per row, a + * table's rows share one class, so the names go once per table and each + * row crosses as a value array; + * - rows rehydrate through a compiled object literal with `__proto__` + * (`rowFactory`): `Object.create` plus one store per property lands in + * V8's dictionary mode — measured on a 25-field row, 1M of them cost + * 1.6 GB against 230 MB constructor-built, which was the difference + * between fitting the default heap and not; + * - results stream IN FILE ORDER (`runParsePool`): a worker's outcome is + * consumed and dropped the moment its file's turn comes, and the dispatch + * window caps what can wait out of order, so the project's rows fill the + * main thread's heap once, not twice; + * - `parsePoolJobs` reads AXIOMCODE_PARSE_JOBS, where 1 is the strict + * serial path and the default caps at 4 — consume runs single-threaded on + * the main thread, and past 4 workers it is what saturates (measured on a + * 2,932-file Python subject: extract 18.5s serial, 17.4s at 2, 12.9s at + * 4, 10.9s at 6 jobs, with the whole run flat past 4). + * + * The language half is small: a prototype per table, a freeze/thaw of its + * fact-set shape built on these helpers, and a worker entry that runs the + * same extractor the serial loop runs. + */ +export interface FrozenTable { + keys: string[]; + rows: unknown[][]; +} + +export function freezeTable(rows: object[]): FrozenTable { + if (rows.length === 0) return { keys: [], rows: [] }; + // One pass, no per-row key arrays: a first cut did a union prepass with + // Object.keys per row and it was a third of the worker's CPU. Columns are + // discovered as rows mention them (a conditional assignment can leave a + // property off an instance), so an early row encoded before a late column + // existed is short, and thaw reads the tail as undefined — which is what + // the absent property read as everywhere it is used. + const index = new Map(); + const keys: string[] = []; + const out: unknown[][] = new Array(rows.length); + for (let r = 0; r < rows.length; r++) { + const row = rows[r] as Record; + const vals: unknown[] = []; + for (const key in row) { + let i = index.get(key); + if (i === undefined) { + i = keys.length; + index.set(key, i); + keys.push(key); + } + vals[i] = row[key]; + } + out[r] = vals; + } + return { keys, rows: out }; +} + +// Keys come from our own row classes, but they cross a thread as data, so +// anything that is not a plain identifier falls back to the slow shape +// instead of reaching the compiled source. +const IDENT = /^[A-Za-z_$][\w$]*$/; +const factories = new Map object>(); + +function rowFactory(keys: string[]): ((v: unknown[], proto: object) => object) | null { + const signature = keys.join('\t'); + const cached = factories.get(signature); + if (cached) return cached; + if (!keys.every(k => IDENT.test(k))) return null; + const body = + 'return {__proto__: proto,' + keys.map((k, i) => `${k}: v[${i}]`).join(',') + '};'; + const made = new Function('v', 'proto', body) as (v: unknown[], proto: object) => object; + factories.set(signature, made); + return made; +} + +export function thawTable(frozen: FrozenTable, proto: object): object[] { + const { keys, rows } = frozen; + const make = rowFactory(keys); + if (make) return rows.map(values => make(values, proto)); + return rows.map(values => { + const out = Object.create(proto) as Record; + for (let i = 0; i < keys.length; i++) out[keys[i] as string] = values[i]; + return out; + }); +} + +/** `1` restores the strict serial path; the two produce identical bytes. */ +export function parsePoolJobs(fileCount: number): number { + const env = Number(process.env.AXIOMCODE_PARSE_JOBS || ''); + const cores = + typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length; + const jobs = + Number.isFinite(env) && env >= 1 ? Math.floor(env) : Math.max(1, Math.min(4, cores - 1)); + // Under ~2 files per worker the pool's startup (a thread, a module graph, a + // parser instance each) costs more than it hides. + return fileCount >= jobs * 2 ? jobs : 1; +} + +/** + * Runs every dispatch on `jobs` workers and calls `consume` once per index, + * in index order, as results arrive. Replies must carry the dispatch's `i`. + * + * Returns `false` when the compiled worker is not there (a source-tree run + * under a TS test runner has no `dist/`): the caller falls back to its serial + * loop rather than fail, so an environment that cannot pool still answers. + */ +export async function runParsePool( + workerPath: string, + dispatches: D[], + jobs: number, + consume: (reply: R) => void +): Promise { + if (!fs.existsSync(workerPath)) return false; + if (dispatches.length === 0) return true; + + const window = jobs * 6; + const ready = new Map(); + const workers: Worker[] = []; + let nextToDispatch = 0; + let nextToConsume = 0; + const idle: Worker[] = []; + + await new Promise((resolve, reject) => { + const drain = () => { + for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { + ready.delete(nextToConsume); + consume(reply); + nextToConsume += 1; + } + }; + const feed = (worker: Worker) => { + if (nextToDispatch >= dispatches.length) { + idle.push(worker); + if (nextToConsume >= dispatches.length) resolve(); + return; + } + if (nextToDispatch - nextToConsume >= window) { + idle.push(worker); // drain() wakes it once its result's turn has come + return; + } + worker.postMessage(dispatches[nextToDispatch]); + nextToDispatch += 1; + }; + for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { + const worker = new Worker(workerPath); + workers.push(worker); + worker.on('message', (reply: R) => { + ready.set(reply.i, reply); + drain(); + feed(worker); + while ( + idle.length > 0 && + nextToDispatch - nextToConsume < window && + nextToDispatch < dispatches.length + ) { + feed(idle.pop() as Worker); + } + if (nextToConsume >= dispatches.length) resolve(); + }); + worker.on('error', reject); + feed(worker); + } + }).finally(() => { + for (const worker of workers) void worker.terminate(); + }); + return true; +} diff --git a/parser/src/workflows/python/python-parse-pool.ts b/parser/src/workflows/python/python-parse-pool.ts index c3792f1c..cf1946ce 100644 --- a/parser/src/workflows/python/python-parse-pool.ts +++ b/parser/src/workflows/python/python-parse-pool.ts @@ -1,7 +1,4 @@ -import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; -import { Worker } from 'worker_threads'; import { PyBindingRegistry, @@ -25,23 +22,18 @@ import { PyTypeReferenceRegistry, } from '@/analysis-types/python'; import { PythonFactSet } from '@/parsers/python/extractors/python-fact-extractor'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; /** - * The per-file parse work (read, tree-sitter parse, mirror, extract, hash) is - * independent between files — everything cross-module happens later, in - * `linkProject` — so it runs on a pool of worker threads. The MAIN thread still - * consumes the results in sorted file order, through the same code the serial - * loop runs, so the accumulated rows, the skip records and therefore the output - * bytes are identical whatever order the workers finish in. - * - * ## What crosses the thread boundary - * - * A worker cannot post class instances: structured clone keeps own properties - * and drops the prototype. The registry rows are flat value holders — every - * field a string, number or enum — so the worker posts `{...row}` and `thaw` - * reattaches the one prototype each table's rows share. The four `Map`s in a - * fact set clone natively. Nothing is re-hashed and nothing is re-parsed: the - * bytes that cross are the bytes the extractor produced. + * The Python half of the parallel parse stage: which prototype each table's + * rows get back, and the shape of a dispatch and a reply. Everything thread- + * and shape-related lives in parse-pool-core.ts. * * `linkProject` MUTATES rows after this (that is why it runs before export), * which is exactly why rows must come back as real instances before it runs — @@ -98,81 +90,7 @@ export interface PythonParseReply { facts?: Record; } -/** - * Worker side: a fact set as COLUMNS structured clone can carry cheaply. A - * per-object snapshot encodes every property name once per row; a table's - * million rows share one class, so the names go once per table and each row - * crosses as a value array. The difference decided whether a 1.9 GB subject - * fit: the main thread's accumulated rows already peak near V8's default old - * space, and per-object clones of the same rows pushed it over. - */ -interface FrozenTable { - keys: string[]; - rows: unknown[][]; -} - -function freezeTable(rows: object[]): FrozenTable { - if (rows.length === 0) return { keys: [], rows: [] }; - // One pass, no per-row key arrays: a first cut did a union prepass with - // Object.keys per row and it was a third of the worker's CPU. Columns are - // discovered as rows mention them (a conditional assignment can leave a - // property off an instance), so an early row encoded before a late column - // existed is short, and thaw reads the tail as undefined — which is what - // the absent property read as everywhere it is used. - const index = new Map(); - const keys: string[] = []; - const out: unknown[][] = new Array(rows.length); - for (let r = 0; r < rows.length; r++) { - const row = rows[r] as Record; - const vals: unknown[] = []; - for (const key in row) { - let i = index.get(key); - if (i === undefined) { - i = keys.length; - index.set(key, i); - keys.push(key); - } - vals[i] = row[key]; - } - out[r] = vals; - } - return { keys, rows: out }; -} - -// A row built by `Object.create` plus one store per property lands in V8's -// dictionary mode: measured on a 25-field row, 1M of them cost 1.6 GB against -// 230 MB constructor-built — the difference WAS the main thread's OOM on a -// large subject. An object literal with `__proto__` gets the same fast shape -// the constructor makes, so each table gets a compiled literal, cached by its -// key list. Keys come from our own row classes, but they cross a thread as -// data, so anything that is not a plain identifier falls back to the slow -// shape instead of reaching the compiled source. -const IDENT = /^[A-Za-z_$][\w$]*$/; -const factories = new Map object>(); - -function rowFactory(keys: string[]): ((v: unknown[], proto: object) => object) | null { - const signature = keys.join('\t'); - const cached = factories.get(signature); - if (cached) return cached; - if (!keys.every(k => IDENT.test(k))) return null; - const body = - 'return {__proto__: proto,' + keys.map((k, i) => `${k}: v[${i}]`).join(',') + '};'; - const made = new Function('v', 'proto', body) as (v: unknown[], proto: object) => object; - factories.set(signature, made); - return made; -} - -function thawTable(frozen: FrozenTable, proto: object): object[] { - const { keys, rows } = frozen; - const make = rowFactory(keys); - if (make) return rows.map(values => make(values, proto)); - return rows.map(values => { - const out = Object.create(proto) as Record; - for (let i = 0; i < keys.length; i++) out[keys[i] as string] = values[i]; - return out; - }); -} - +/** Worker side: a fact set as columns structured clone can carry cheaply. */ export function freezeFactSet(facts: PythonFactSet): Record { const out: Record = { module: facts.module ? { ...facts.module } : undefined, @@ -204,102 +122,25 @@ export function thawFactSet(frozen: Record): PythonFactSet { } /** - * How many parse workers to run. `AXIOMCODE_PARSE_JOBS` decides; `1` restores - * the strict serial path (the two produce identical bytes — `1` exists for - * memory-tight hosts and for bisecting). The default leaves a core for the - * main thread and caps at 4: consume runs single-threaded on the main thread, - * so workers saturate it — on a 2,932-file subject the extract phase went - * 18.5s serial / 17.4s at 2 / 12.9s at 4 / 10.9s at 6 jobs, but past 4 the - * subject's whole run stopped improving (GC against more worker heaps), so 4 - * is where the default stops. - */ -export function parsePoolJobs(fileCount: number): number { - const env = Number(process.env.AXIOMCODE_PARSE_JOBS || ''); - const cores = typeof os.availableParallelism === 'function' - ? os.availableParallelism() - : os.cpus().length; - const jobs = Number.isFinite(env) && env >= 1 - ? Math.floor(env) - : Math.max(1, Math.min(4, cores - 1)); - // Under ~2 files per worker the pool's startup (a thread, a module graph, a - // tree-sitter instance each) costs more than it hides. - return fileCount >= jobs * 2 ? jobs : 1; -} - -/** - * Parses every file on `jobs` workers, calling `consume` once per file IN FILE - * ORDER as results become available — never after collecting them all. The - * rows of a repository already fill the main thread's heap once, in - * `accumulated`; buffering every worker's snapshot beside them held the whole - * project TWICE and took a 1.9 GB subject over the default heap. So a result - * is thawed, consumed and dropped the moment its turn comes, and the DISPATCH - * WINDOW below caps what can wait out of order: one slow file holds back at - * most `jobs * 6` finished snapshots, not the rest of the repository. - * - * Returns `false` when the compiled worker is not there (a source-tree run - * under a TS test runner has no `dist/`): the caller falls back to the serial - * loop rather than fail, so an environment that cannot pool still answers. + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. */ export async function parseFilesInPool( dispatches: PythonParseDispatch[], jobs: number, consume: (i: number, outcome: PythonParseOutcome) => void ): Promise { - const workerPath = path.join(__dirname, 'python-parse-worker.js'); - if (!fs.existsSync(workerPath)) return false; - if (dispatches.length === 0) return true; - - const window = jobs * 6; - const ready = new Map(); - const workers: Worker[] = []; - let nextToDispatch = 0; - let nextToConsume = 0; - const idle: Worker[] = []; - - await new Promise((resolve, reject) => { - const drain = () => { - for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { - ready.delete(nextToConsume); - consume( - nextToConsume, - reply.facts - ? { facts: thawFactSet(reply.facts) } - : { readError: reply.readError, extractError: reply.extractError } - ); - nextToConsume += 1; - } - }; - const feed = (worker: Worker) => { - if (nextToDispatch >= dispatches.length) { - idle.push(worker); - if (nextToConsume >= dispatches.length) resolve(); - return; - } - if (nextToDispatch - nextToConsume >= window) { - idle.push(worker); // drain() wakes it once its result's turn has come - return; - } - worker.postMessage(dispatches[nextToDispatch]); - nextToDispatch += 1; - }; - for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { - const worker = new Worker(workerPath); - workers.push(worker); - worker.on('message', (reply: PythonParseReply) => { - ready.set(reply.i, reply); - drain(); - feed(worker); - while (idle.length > 0 && nextToDispatch - nextToConsume < window - && nextToDispatch < dispatches.length) { - feed(idle.pop() as Worker); - } - if (nextToConsume >= dispatches.length) resolve(); - }); - worker.on('error', reject); - feed(worker); - } - }).finally(() => { - for (const worker of workers) void worker.terminate(); - }); - return true; + return runParsePool( + path.join(__dirname, 'python-parse-worker.js'), + dispatches, + jobs, + reply => + consume( + reply.i, + reply.facts + ? { facts: thawFactSet(reply.facts) } + : { readError: reply.readError, extractError: reply.extractError } + ) + ); } From f86f0e0350a664723232d051dee0268f7467befe Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:33:08 -0700 Subject: [PATCH 030/133] path: the methods behind a [by name] caller are counted, with named nearest examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method that reaches a by-name caller through resolved edges reaches the target too whenever the by-name site is real. The upstream closure listed the by-name callers (#1421) but what reaches THEM was absent and uncounted under the verified: line — on "only X can trigger this" exactly the triggers a spec answer silently dropped. One reverse walk from the by-name caller set, counted as its own bound: line with up-to-3 nearest NAMED examples (an placeholder names nothing a reader can look up). 907 behind a hot name on an engine-sized graph, ~1s. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/ax_grep.py | 7 +++ .../skills/axiomcode/scripts/axiomcode-path | 47 ++++++++++++++++--- .../app/jobs.py | 10 ++++ .../case.json | 6 +++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py index a5da4aaa..31c3162c 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_grep.py @@ -185,6 +185,13 @@ def path(d, code): if ans: v = all(not a.get('unverified_hops') for a in ans) and v is not False foot = ev_foot(d) + [verified(v, sum(len(a.get('hops', [])) for a in ans) if ans else None)] if d.get('bound'): foot.append(f"bound: {d['bound']}") + # what reaches a [by name] caller reaches the target too whenever the by-name site is real: counted, or the + # upstream closure reads complete while every chain behind a by-name row is missing from it + bb = d.get('by_name_behind') + if bb: + foot.append(f"bound: {bb['methods']} more method(s) in {bb['files']} file(s) reach a [by name] caller above through" + " the graph's edges — callers of the target too if that by-name site is real; nearest: " + + ', '.join(f"{x['name']} ({x['at']})" for x in bb.get('nearest', []))) return rows, {}, foot diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 55b02956..749516e6 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1485,9 +1485,11 @@ def byname_sites(g, ids): on a receiver the engine could not type: `impact X` lists its caller as a lead, and the closure, which walks resolved edges only, left it out, so the two verbs gave different sets of direct callers and `path` gave no hint of the second one. The same rule as dl/impact.dl's `direct(... "by name")`: a callable target, a site that is not - a construction and not inside a mock's stub or verification, and not in the target's own body. Listed apart and - never walked: the name may belong to another method, and the by-name closure stays the search `path A B` runs - only when nothing resolved connects its two ends.""" + a construction and not inside a mock's stub or verification, and not in the target's own body. Listed apart, + not folded into the closure: the name may belong to another method, and the by-name closure stays the search + `path A B` runs only when nothing resolved connects its two ends. What reaches THEM is counted (byname_behind): + a method that reaches a by-name caller reaches the target too whenever the by-name site is real, and left + uncounted those callers-of-callers were missing from "everything that can reach X" with no line saying so.""" names = sorted({g.sym[i]['name'] for i in ids if i in g.sym and g.sym[i].get('method_id') and g.sym[i]['kind'] not in ('library', 'written', 'module') and g.sym[i].get('name')}) if not names or not g.has('unresolved_sites'): return [], set() @@ -1503,7 +1505,29 @@ def byname_sites(g, ids): return sorted(set(out), key=lambda x: (g.sym[x[0]]['is_test'], x[2] or '', x[3] or 0, g.sym[x[0]]['display'])), stubbed - {c for c, *_ in out} -def print_byname(g, found, limit): +def byname_behind(g, named, exclude, depth=40): + """the methods that reach a by-name caller through the graph's edges and are in neither the printed closure nor + the by-name list itself. Each one reaches the target exactly when the by-name site is real, so an upstream + closure that lists the by-name callers but not these is short by every chain behind them — on a spec question + ("only X can trigger this") they are the triggers the answer silently dropped. Counted and named nearest-first, + not folded in: their certainty is the by-name site's, not an edge's.""" + callers = {c for c, *_ in named} + if not callers: return [] + radj = collections.defaultdict(set) + for a, b, _ in g.edges(): radj[b].add(a) + seen = {c: 0 for c in callers}; fr = list(callers); d = 0 + while fr and d < depth: + d += 1; nxt = [] + for x in fr: + for y in radj.get(x, ()): + if y not in seen: seen[y] = d; nxt.append(y) + fr = nxt + out = [(m, dd) for m, dd in seen.items() if dd > 0 and m not in exclude and m in g.sym + and (not g.IN or g.under_in(g.sym[m]['file']))] + return sorted(out, key=lambda x: (x[1], g.sym[x[0]]['display'], x[0])) + + +def print_byname(g, found, limit, sel=None, behind=()): named, stubbed = found if stubbed: print(f" +{len(stubbed)} caller(s) only stub a method of this name on a mock (receiver not typed): they run none of it;" @@ -1516,6 +1540,16 @@ def print_byname(g, found, limit): for c, n, f, ln in named[:limit]: print(f" [by name] {g.disp(c)} {f}:{ln} — calls `{n}` (receiver not typed)") if len(named) > limit: print(f" … +{len(named) - limit} (--limit N)") + if behind: + # the examples: nearest NAMED methods first — an `` placeholder names nothing a reader can look up + show = sorted(behind, key=lambda x: ('<' in g.disp(x[0]), x[1], g.sym[x[0]]['display'], x[0]))[:3] + RESULT['by_name_behind'] = {'methods': len(behind), 'files': len({g.sym[m]['file'] for m, _ in behind}), + 'nearest': [{'name': g.disp(m), 'at': g.loc(m), 'hops': dd} for m, dd in show]} + tgt = f" is really `{sel}`" if sel else " resolves the way its name suggests" + print(f" behind them: {len(behind)} more method(s) in {len({g.sym[m]['file'] for m, _ in behind})} file(s)" + f" reach these by-name caller(s) through the graph's edges — callers of the target too if a by-name site{tgt};" + " nearest: " + ', '.join(f"{g.disp(m)} ({g.loc(m)})" for m, _ in show) + + (f" … +{len(behind) - 3}" if len(behind) > 3 else '')) def closure(g, sel, upstream, limit=40, depth=40): @@ -1576,11 +1610,12 @@ def closure(g, sel, upstream, limit=40, depth=40): if not upstream: print_boundary(g, ids, "it calls into libraries directly", "it also makes") named = byname_sites(g, ids) if upstream else ([], set()) + behind = byname_behind(g, named[0], {m for m, _ in rows} | set(ids) | {c for c, *_ in named[0]}, depth) if named[0] else [] if not rows: u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(ids))})", *ids)[0]['n'] if not upstream else 0 print(" none — " + ("its body has %d unresolved call(s), so what it reaches is unknown, not nothing" % u if not upstream and u else "no resolved call " + ("into it; " if upstream else "out of it; ") + (f"{len(named[0])} unresolved site(s) write its name (below)" if named[0] else "an unresolved site elsewhere may still " + ("call it" if upstream else "be it")))) - print_byname(g, named, limit) + print_byname(g, named, limit, sel=sel, behind=behind) # AN EMPTY UPSTREAM CLOSURE IS THE MOST MISLEADING LINE THIS COMMAND CAN PRINT. For a live route handler, # a signal receiver or a CLI command the answer "0 methods reach it" is true of calls and false of the # program: the framework reaches it. Name the registration rather than leave the reader at a dead end. @@ -1655,7 +1690,7 @@ def closure(g, sel, upstream, limit=40, depth=40): print(f" {d:2} hop(s) {g.disp(m)} {g.loc(m)}{at}") if len(np_) > limit: print(f" … +{len(np_) - limit} production (--limit N)") if nt: print(f" +{len(nt)} test caller(s) within 2 hop(s) — `axiomcode test-impact` names them and the command that runs them") - print_byname(g, named, limit) + print_byname(g, named, limit, sel=sel, behind=behind) byhop = collections.Counter(d for _, d in rows); print(" by hop: " + ', '.join(f"{d}:{n}" for d, n in sorted(byhop.items()))) # most_common breaks a tie by insertion order, which is the order the closure rows arrived in: two files with the # same count then swap depending on which engine answered. Sort the tie by name so the line is stable either way. diff --git a/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py b/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py index e6007ef3..3aea44d5 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py +++ b/tests/cases/python/path-crosses-framework-and-byname/app/jobs.py @@ -12,3 +12,13 @@ def replay(source): def rewind(source): return source.reopen(3) + + +def scheduler(source): + # reaches settle only through replay's by-name site + return replay(source) + + +def rollback(source): + # reaches only rewind, whose by-name site names reopen, not settle + return rewind(source) diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json index 609428ed..3aef6c65 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/case.json +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -19,6 +19,12 @@ {"why": "#1421: path '*' lists the untyped-receiver call impact lists as [by name]", "run": ["path", "*", "Ledger.settle"], "want": ["1 hop(s) nightly", "[by name] replay app/jobs.py:10 — calls `settle` (receiver not typed)"]}, + {"why": "a method that reaches a by-name caller through resolved calls is counted behind it: it reaches the target too whenever the by-name site is real, and uncounted it was missing from the closure with no line saying so", + "run": ["path", "*", "Ledger.settle"], + "want": ["behind them: 1 more method(s) in 1 file(s)", "scheduler"]}, + {"why": "CONTROL: a caller of a method whose by-name site names ANOTHER method is not behind the target's by-name callers", + "run": ["path", "*", "Ledger.settle"], + "avoid": ["rollback"]}, {"why": "impact names the same by-name caller", "run": ["impact", "Ledger.settle"], "want": ["[by name] replay", "[resolved] nightly"]}, From e1b6d44183c2e4a7e5e5b285f94ce9bc09567bf9 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:33:39 -0700 Subject: [PATCH 031/133] surface: a bound: line survives to the front door MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The numbered-places surface kept only run: and verified: foot lines, so every bound: honesty line — the closure's lower-bound count and the by-name behind count — was invisible exactly where agents and the CLI read the answer. An agent writing "only X reaches this" from these places needs the bound as much as the verified line. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py index 731e1d73..0f52f873 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -111,7 +111,11 @@ def render(verb, doc, repo): out.append(' ```') if not out: return None if len(places) > CAP: out.append(f"… {len(places) - CAP} more place(s) not shown — ask a narrower question to see them") - out += [x for x in foot if x.startswith(('run:', 'verified'))][:2] + # a bound: line is the answer saying where it stops being complete — an agent writing "only X reaches this" + # from these places needs it as much as the verified: line, so it is never tidied away here. + # run: stays LAST: the answer ends with the command to run, whatever else the foot carries. + kept = [x for x in foot if x.startswith(('verified', 'bound:'))][:3] + out += kept + [x for x in foot if x.startswith('run:')][:1] return out From 26839a28345eaf6be8e43cea14cc0d7e24912e83 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:45:26 -0700 Subject: [PATCH 032/133] perf(csharp): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same move Python made, on the machinery parse-pool-core already shares: the C# per-file work — read, blank the dead #if arms, parse, extract, hash — is independent between files, so it now runs on worker threads (cs-parse-pool.ts, cs-parse-worker.ts). Each worker constructs one CsFactExtractor (one grammar gate per worker, at startup) and runs the same extraction the serial loop runs, once per target-framework emission in the dispatch's order; the main thread consumes every outcome in sorted file order through the same body, so the writers receive every relation's rows exactly as the serial loop wrote them. AXIOMCODE_PARSE_JOBS picks the width (1 is the strict serial path), and a source-tree run with no compiled worker falls back to serial instead of failing. Two things are C#'s rather than Python's: - the governing project's configuration is resolved ON THE MAIN THREAD, before dispatch: cs-project-config's caches stay on one thread, and a worker receives its emissions — module context, define set, implicit usings — as plain data, so pooled and serial extraction start from identical inputs; - C# streams rows to relation writers instead of accumulating them, and the writers are async while the pool's consume callback is not — so each file's appends are CHAINED onto the previous file's, which preserves the serial row order, and the first failure is kept and rethrown after the chain drains rather than rejecting a promise nothing has a handler on yet. Rows cross the thread as columns and rehydrate with each table's prototype, as everywhere; the one new shape is a Set on a row (typeModifiers, fieldModifiers, methodModifiers, xmlDocTags), which structured clone carries natively. The byte gate found NO cross-file state in the C# extractor stack — unlike Python's port, where it caught a byte-range-keyed map leaking between files. Everything per-file in CsFactExtractor.extractFile is minted inside the call, and the only module-level mutable state (the project-config caches) never leaves the main thread. Measured, C# phase alone: an 801-file subject extracts in 5.8 s serial, 2.7 s at 4 jobs, 2.9 s at 6; a 1,145-file subject in 13.2 s serial, 7.3 s at 4, 7.9 s at 6. Whole CLI wall on the first subject: 16.9 s -> 5.0 s, the pool also freeing the main thread for the other analyzers in the same Promise.all. IR is byte-identical to serial on both subjects at the default width and on the first also at 6 jobs. Pooled runs fit the default heap: 1.26 GB and 1.11 GB max RSS with no NODE_OPTIONS. tests/run.py --lang csharp: 208 of 208 checks in 43 cases. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/csharp/cs-parse-pool.ts | 179 ++++++++++++ .../src/workflows/csharp/cs-parse-worker.ts | 63 +++++ .../csharp/csharp-project-analyzer.ts | 264 +++++++++++------- 3 files changed, 409 insertions(+), 97 deletions(-) create mode 100644 parser/src/workflows/csharp/cs-parse-pool.ts create mode 100644 parser/src/workflows/csharp/cs-parse-worker.ts diff --git a/parser/src/workflows/csharp/cs-parse-pool.ts b/parser/src/workflows/csharp/cs-parse-pool.ts new file mode 100644 index 00000000..b8c3b153 --- /dev/null +++ b/parser/src/workflows/csharp/cs-parse-pool.ts @@ -0,0 +1,179 @@ +import * as path from 'path'; + +import { CsAttributeArgumentRegistry } from '@/analysis-types/csharp/CsAttributeArgumentRegistry'; +import { CsAttributeRegistry } from '@/analysis-types/csharp/CsAttributeRegistry'; +import { CsBlockRegistry } from '@/analysis-types/csharp/CsBlockRegistry'; +import { CsCallSiteRegistry } from '@/analysis-types/csharp/CsCallSiteRegistry'; +import { CsCommentRegistry } from '@/analysis-types/csharp/CsCommentRegistry'; +import { CsEnumMemberRegistry } from '@/analysis-types/csharp/CsEnumMemberRegistry'; +import { CsEventRegistry } from '@/analysis-types/csharp/CsEventRegistry'; +import { CsExpressionRegistry } from '@/analysis-types/csharp/CsExpressionRegistry'; +import { CsFieldRegistry } from '@/analysis-types/csharp/CsFieldRegistry'; +import { CsMethodParameterRegistry } from '@/analysis-types/csharp/CsMethodParameterRegistry'; +import { CsMethodRegistry } from '@/analysis-types/csharp/CsMethodRegistry'; +import { CsModuleRegistry } from '@/analysis-types/csharp/CsModuleRegistry'; +import { CsParseGapRegistry } from '@/analysis-types/csharp/CsParseGapRegistry'; +import { CsPreprocRegionRegistry } from '@/analysis-types/csharp/CsPreprocRegionRegistry'; +import { CsPropertyRegistry } from '@/analysis-types/csharp/CsPropertyRegistry'; +import { CsQueryClauseRegistry } from '@/analysis-types/csharp/CsQueryClauseRegistry'; +import { CsTypeHeritageRegistry } from '@/analysis-types/csharp/CsTypeHeritageRegistry'; +import { CsTypeParameterRegistry } from '@/analysis-types/csharp/CsTypeParameterRegistry'; +import { CsTypeReferenceRegistry } from '@/analysis-types/csharp/CsTypeReferenceRegistry'; +import { CsTypeRegistry } from '@/analysis-types/csharp/CsTypeRegistry'; +import { CsUsingRegistry } from '@/analysis-types/csharp/CsUsingRegistry'; +import { CsVariableRegistry } from '@/analysis-types/csharp/CsVariableRegistry'; +import { CsFileFacts } from '@/parsers/csharp/extractors/cs-fact-extractor'; +import { CsModuleContext } from '@/parsers/csharp/extractors/cs-module-extractor'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The C# half of the parallel parse stage: which prototype each table's rows + * get back, and the shape of a dispatch and a reply. Everything thread- and + * shape-related lives in parse-pool-core.ts. + * + * C# has no cross-file linking pass — rows go straight from a file's + * extraction to the relation writers — but the writers call `toCsv()` and + * `getCsvHeader()` on every row, so rows still have to come back as REAL + * class instances, prototype reattached, before the main thread appends them. + * + * The one shape Python's tables do not have: a C# row can carry a `Set` + * (`typeModifiers`, `fieldModifiers`, `methodModifiers`, `xmlDocTags`). + * Structured clone carries a Set natively, so it crosses inside the frozen + * column values untouched and `toCsv` reads it as the set it was. + */ +const TABLE_PROTOTYPES = { + modules: CsModuleRegistry.prototype, + types: CsTypeRegistry.prototype, + heritages: CsTypeHeritageRegistry.prototype, + typeParameters: CsTypeParameterRegistry.prototype, + methods: CsMethodRegistry.prototype, + methodParameters: CsMethodParameterRegistry.prototype, + properties: CsPropertyRegistry.prototype, + events: CsEventRegistry.prototype, + typeReferences: CsTypeReferenceRegistry.prototype, + usings: CsUsingRegistry.prototype, + parseGaps: CsParseGapRegistry.prototype, + fields: CsFieldRegistry.prototype, + enumMembers: CsEnumMemberRegistry.prototype, + expressions: CsExpressionRegistry.prototype, + callSites: CsCallSiteRegistry.prototype, + queryClauses: CsQueryClauseRegistry.prototype, + blocks: CsBlockRegistry.prototype, + variables: CsVariableRegistry.prototype, + attributes: CsAttributeRegistry.prototype, + attributeArguments: CsAttributeArgumentRegistry.prototype, + comments: CsCommentRegistry.prototype, + preprocRegions: CsPreprocRegionRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** + * Everything one emission of one file needs beyond the file itself, resolved + * ON THE MAIN THREAD before dispatch: the governing project's configuration + * comes from cs-project-config's process-wide caches, and resolving it once + * on one thread keeps one cache — and one answer — however many workers + * parse. Plain data throughout, so it crosses a thread as-is. + */ +export interface CsEmissionInputs { + readonly context: CsModuleContext; + readonly defineConstants: readonly string[]; + readonly implicitUsings?: readonly string[]; + readonly implicitFrameworkDefines: boolean; +} + +/** What the analyzer sends a worker for one file: strings and flags only. */ +export interface CsParseDispatch { + i: number; + /** Absolute path, read inside the worker. */ + absoluteFilePath: string; + /** The path recorded on rows — relative to the mserv, computed by the caller. */ + filePath: string; + baseMservPath: string; + serviceVersionLinkHash: string; + /** One per target framework, in the serial loop's framework order. */ + emissions: readonly CsEmissionInputs[]; +} + +/** + * One emission's outcome — the same two cases the serial loop's inner + * try/catch distinguishes. `extractError` is the thrown error's `.message`, + * exactly the string the serial loop prints. + */ +export interface CsEmissionOutcome { + extractError?: string; + facts?: CsFileFacts; +} + +/** + * One file's outcome. `readError` set means the file never reached the + * extractor (the serial loop's outer catch: one `filesRejected`, no + * emissions); otherwise `emissions` has one entry per dispatched emission, + * in order. + */ +export interface CsParseOutcome { + readError?: string; + emissions?: CsEmissionOutcome[]; +} + +/** The worker's reply: each emission's `facts` is the frozen snapshot. */ +export interface CsParseReply { + i: number; + readError?: string; + emissions?: { extractError?: string; facts?: Record }[]; +} + +/** Worker side: one emission's fact set as columns structured clone carries cheaply. */ +export function freezeFileFacts(facts: CsFileFacts): Record { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = freezeTable(facts[key] as unknown as object[]); + } + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFileFacts(frozen: Record): CsFileFacts { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = thawTable(frozen[key] as FrozenTable, TABLE_PROTOTYPES[key]); + } + return out as unknown as CsFileFacts; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. + */ +export async function parseCsFilesInPool( + dispatches: CsParseDispatch[], + jobs: number, + consume: (i: number, outcome: CsParseOutcome) => void +): Promise { + return runParsePool( + path.join(__dirname, 'cs-parse-worker.js'), + dispatches, + jobs, + (reply) => + consume( + reply.i, + reply.emissions + ? { + emissions: reply.emissions.map((emission) => + emission.facts + ? { facts: thawFileFacts(emission.facts) } + : { extractError: emission.extractError } + ), + } + : { readError: reply.readError ?? 'worker returned no emissions' } + ) + ); +} diff --git a/parser/src/workflows/csharp/cs-parse-worker.ts b/parser/src/workflows/csharp/cs-parse-worker.ts new file mode 100644 index 00000000..7d2550d7 --- /dev/null +++ b/parser/src/workflows/csharp/cs-parse-worker.ts @@ -0,0 +1,63 @@ +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { CsFactExtractor } from '@/parsers/csharp/extractors/cs-fact-extractor'; +import { + CsParseDispatch, + CsParseReply, + freezeFileFacts, +} from '@/workflows/csharp/cs-parse-pool'; + +/** + * One C# parse worker: reads a file once, runs the SAME extractor the serial + * loop runs — once per emission, in the dispatch's framework order — and + * posts the fact sets back as prototype-less snapshots (`freezeFileFacts`). + * + * The two error cases mirror the serial loop exactly. A read failure is the + * OUTER catch: the whole file is one `filesRejected` and no emission runs. An + * extractor throw is the INNER catch, per emission: it carries the error's + * `.message` — the string the serial loop prints — and the other emissions of + * the same file still run, as they do serially. + * + * One extractor per worker, reused across files, as the analyzer reuses its + * one extractor across the whole project: constructing a CsFactExtractor runs + * the grammar gate, and sharing the instance keeps that a startup cost. The + * governing project's configuration is NOT read here — it arrives resolved in + * the dispatch, so cs-project-config's caches stay on the main thread. + */ +const extractor = new CsFactExtractor(); +const port = parentPort; +if (!port) throw new Error('cs-parse-worker must run as a worker thread'); + +port.on('message', (job: CsParseDispatch) => { + void (async () => { + let sourceText: string; + try { + sourceText = await fsp.readFile(job.absoluteFilePath, 'utf-8'); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies CsParseReply); + return; + } + const emissions = job.emissions.map((emission) => { + try { + const facts = extractor.extractFile({ + absoluteFilePath: job.absoluteFilePath, + filePath: job.filePath, + baseMservPath: job.baseMservPath, + sourceText, + serviceVersionLinkHash: job.serviceVersionLinkHash, + context: emission.context, + defineConstants: emission.defineConstants, + implicitUsings: emission.implicitUsings, + implicitFrameworkDefines: emission.implicitFrameworkDefines, + }); + return { facts: freezeFileFacts(facts) }; + } catch (error) { + // `.message`, not String(error): the serial loop's console.error + // interpolates exactly this, and the two paths must print the same. + return { extractError: `${(error as Error).message}` }; + } + }); + port.postMessage({ i: job.i, emissions } satisfies CsParseReply); + })(); +}); diff --git a/parser/src/workflows/csharp/csharp-project-analyzer.ts b/parser/src/workflows/csharp/csharp-project-analyzer.ts index 81b0836d..71b81934 100644 --- a/parser/src/workflows/csharp/csharp-project-analyzer.ts +++ b/parser/src/workflows/csharp/csharp-project-analyzer.ts @@ -1,39 +1,23 @@ import * as fsp from 'fs/promises'; import * as path from 'path'; -import { CsAttributeArgumentRegistry } from '@/analysis-types/csharp/CsAttributeArgumentRegistry'; -import { CsAttributeRegistry } from '@/analysis-types/csharp/CsAttributeRegistry'; -import { CsBlockRegistry } from '@/analysis-types/csharp/CsBlockRegistry'; -import { CsCommentRegistry } from '@/analysis-types/csharp/CsCommentRegistry'; -import { CsPreprocRegionRegistry } from '@/analysis-types/csharp/CsPreprocRegionRegistry'; -import { CsCallSiteRegistry } from '@/analysis-types/csharp/CsCallSiteRegistry'; -import { CsEnumMemberRegistry } from '@/analysis-types/csharp/CsEnumMemberRegistry'; -import { CsExpressionRegistry } from '@/analysis-types/csharp/CsExpressionRegistry'; -import { CsQueryClauseRegistry } from '@/analysis-types/csharp/CsQueryClauseRegistry'; -import { CsFieldRegistry } from '@/analysis-types/csharp/CsFieldRegistry'; -import { CsModuleRegistry } from '@/analysis-types/csharp/CsModuleRegistry'; -import { CsParseGapRegistry } from '@/analysis-types/csharp/CsParseGapRegistry'; -import { CsUsingRegistry } from '@/analysis-types/csharp/CsUsingRegistry'; -import { CsEventRegistry } from '@/analysis-types/csharp/CsEventRegistry'; -import { CsMethodParameterRegistry } from '@/analysis-types/csharp/CsMethodParameterRegistry'; -import { CsMethodRegistry } from '@/analysis-types/csharp/CsMethodRegistry'; -import { CsPropertyRegistry } from '@/analysis-types/csharp/CsPropertyRegistry'; -import { CsTypeHeritageRegistry } from '@/analysis-types/csharp/CsTypeHeritageRegistry'; -import { CsTypeParameterRegistry } from '@/analysis-types/csharp/CsTypeParameterRegistry'; -import { CsTypeReferenceRegistry } from '@/analysis-types/csharp/CsTypeReferenceRegistry'; -import { CsVariableRegistry } from '@/analysis-types/csharp/CsVariableRegistry'; -import { CsTypeRegistry } from '@/analysis-types/csharp/CsTypeRegistry'; import { CSHARP_DEFAULT_TARGET_FRAMEWORK, } from '@/constants/csharp-constants'; import { CsNullableContext } from '@/enums/csharp/modules'; import { CSharpParser } from '@/parsers/csharp/csharp-parser'; -import { CsFactExtractor } from '@/parsers/csharp/extractors/cs-fact-extractor'; +import { CsFactExtractor, CsFileFacts } from '@/parsers/csharp/extractors/cs-fact-extractor'; import { CsModuleContext } from '@/parsers/csharp/extractors/cs-module-extractor'; import { implicitFrameworkSymbols } from '@/parsers/csharp/extractors/preproc-context'; import { EntityUtils } from '@/utils/entity-utils'; import { CsRelationWriter } from '@/workflows/csharp/cs-relation-writer'; import { governingProject, readProjectConfig } from '@/workflows/csharp/cs-project-config'; +import { + CsEmissionInputs, + CsParseOutcome, + parseCsFilesInPool, + parsePoolJobs, +} from '@/workflows/csharp/cs-parse-pool'; import { isGitIgnoredDir } from '@/utils/git-ignored'; /** @@ -225,6 +209,14 @@ export class CSharpProjectAnalyzer { let extractionErrors = 0; try { + // EVERY PER-FILE INPUT IS RESOLVED HERE, on the main thread, before any + // file is read — for the pooled path and the serial one alike. The + // governing project comes from cs-project-config's process-wide caches, + // and resolving it once on one thread keeps one cache and one walk, + // however many workers parse. What remains per file — read, blank, + // parse, extract — is independent between files and is what the pool + // distributes. + const prepared: PreparedCsFile[] = []; for (const absoluteFilePath of files) { if (context.seen.has(absoluteFilePath)) { // Reached from another root this run; its rows are already written. @@ -232,13 +224,6 @@ export class CSharpProjectAnalyzer { } context.seen.add(absoluteFilePath); const relativePath = path.relative(options.baseMservPath, absoluteFilePath); - let sourceText: string; - try { - sourceText = await fsp.readFile(absoluteFilePath, 'utf-8'); - } catch { - filesRejected += 1; - continue; - } // THE GOVERNING PROJECT decides the framework and the symbols, unless the // caller fixed them. See cs-project-config.ts for why this is read at all. @@ -250,7 +235,7 @@ export class CSharpProjectAnalyzer { const fileDefines = project !== undefined ? project.defineConstants : defineConstants; const implicitFrameworkDefines = project === undefined || project.implicitFrameworkDefines; - for (const targetFramework of fileFrameworks) { + const emissions = fileFrameworks.map((targetFramework): CsEmissionInputs => { // The RESOLVED set, per framework: what the caller supplied plus what // the SDK injects. Two frameworks therefore differ in the key even // when the .csproj lists the same constants for both, which is what @@ -260,7 +245,7 @@ export class CSharpProjectAnalyzer { ...fileDefines, ...(implicitFrameworkDefines ? implicitFrameworkSymbols(targetFramework) : []), ]; - const context: CsModuleContext = { + const moduleContext: CsModuleContext = { targetFramework, defineConstantsKey: defineConstantsKeyOf(activeSymbols), langVersion: options.langVersion ?? project?.langVersion ?? '', @@ -276,81 +261,125 @@ export class CSharpProjectAnalyzer { assemblyName: '', implicitUsingsEnabled: project?.implicitUsings ?? false, }; + return { + context: moduleContext, + defineConstants: fileDefines, + implicitUsings: options.implicitUsings ?? project?.usings, + implicitFrameworkDefines, + }; + }); + prepared.push({ absoluteFilePath, relativePath, emissions }); + } - try { - const facts = this.extractor.extractFile({ - absoluteFilePath, - filePath: relativePath, - baseMservPath: options.baseMservPath, - sourceText, - serviceVersionLinkHash, - context, - defineConstants: fileDefines, - implicitUsings: options.implicitUsings ?? project?.usings, - implicitFrameworkDefines, - }); - await writers.modules.append(facts.modules as readonly CsModuleRegistry[]); - await writers.types.append(facts.types as readonly CsTypeRegistry[]); - await writers.heritages.append( - facts.heritages as readonly CsTypeHeritageRegistry[] - ); - await writers.typeParameters.append( - facts.typeParameters as readonly CsTypeParameterRegistry[] - ); - await writers.methods.append(facts.methods as readonly CsMethodRegistry[]); - await writers.methodParameters.append( - facts.methodParameters as readonly CsMethodParameterRegistry[] - ); - await writers.properties.append( - facts.properties as readonly CsPropertyRegistry[] - ); - await writers.events.append(facts.events as readonly CsEventRegistry[]); - await writers.typeReferences.append( - facts.typeReferences as readonly CsTypeReferenceRegistry[] - ); - await writers.usings.append(facts.usings as readonly CsUsingRegistry[]); - await writers.parseGaps.append( - facts.parseGaps as readonly CsParseGapRegistry[] - ); - await writers.fields.append(facts.fields as readonly CsFieldRegistry[]); - await writers.enumMembers.append( - facts.enumMembers as readonly CsEnumMemberRegistry[] - ); - await writers.expressions.append( - facts.expressions as readonly CsExpressionRegistry[] - ); - await writers.callSites.append( - facts.callSites as readonly CsCallSiteRegistry[] - ); - await writers.queryClauses.append( - facts.queryClauses as readonly CsQueryClauseRegistry[] - ); - await writers.blocks.append(facts.blocks as readonly CsBlockRegistry[]); - await writers.variables.append( - facts.variables as readonly CsVariableRegistry[] - ); - await writers.attributes.append( - facts.attributes as readonly CsAttributeRegistry[] - ); - await writers.attributeArguments.append( - facts.attributeArguments as readonly CsAttributeArgumentRegistry[] - ); - await writers.comments.append(facts.comments as readonly CsCommentRegistry[]); - await writers.preprocRegions.append( - facts.preprocRegions as readonly CsPreprocRegionRegistry[] - ); - } catch (error) { + // One file's outcome, consumed the same way whichever thread produced + // it. The pool calls this in file order as results arrive and the + // serial loop calls it inline, so the writers receive every relation's + // rows in the same order on both paths — which is what makes the two + // paths byte-identical. + const consumeOutcome = async ( + file: PreparedCsFile, + outcome: CsParseOutcome + ): Promise => { + if (outcome.readError !== undefined) { + filesRejected += 1; + return; + } + const emissionOutcomes = outcome.emissions ?? []; + for (let e = 0; e < file.emissions.length; e++) { + const emission = emissionOutcomes[e]; + if (emission?.facts !== undefined) { + await appendFileFacts(writers, emission.facts); + } else { // An extraction error is always a defect, and it is counted rather // than swallowed. A caller that cannot tell a clean run from a // parser that threw on every file cannot tell anything. extractionErrors += 1; console.error( - `[CSharpProjectAnalyzer] ${relativePath} (${targetFramework}): ` + - `${(error as Error).message}` + `[CSharpProjectAnalyzer] ${file.relativePath} ` + + `(${file.emissions[e]!.context.targetFramework}): ` + + `${emission?.extractError}` ); } } filesAnalysed += 1; + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (cs-parse-pool.ts): each worker runs the same extractor this loop + // runs, and every outcome is consumed in sorted file order through + // `consumeOutcome` above. AXIOMCODE_PARSE_JOBS=1 restores the strict + // serial path; the pool declining (no compiled worker beside this + // file) falls back to it too. + const jobs = parsePoolJobs(prepared.length); + let pooled = false; + if (jobs > 1) { + // The pool's consume callback is synchronous and the writers are not, + // so appends are CHAINED: each file's writes start only when the + // previous file's have finished, preserving the serial row order. The + // chain never rejects — the first failure is kept and rethrown after + // the chain drains, because a rejection parked on `chainTail` with no + // handler attached yet would take the process down from under the + // pool. + let consumeError: unknown; + let chainTail = Promise.resolve(); + try { + pooled = await parseCsFilesInPool( + prepared.map((file, i) => ({ + i, + absoluteFilePath: file.absoluteFilePath, + filePath: file.relativePath, + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + emissions: file.emissions, + })), + jobs, + (i, outcome) => { + chainTail = chainTail.then(async () => { + if (consumeError !== undefined) return; + try { + await consumeOutcome(prepared[i]!, outcome); + } catch (error) { + consumeError = error; + } + }); + } + ); + } finally { + await chainTail; + } + if (consumeError !== undefined) throw consumeError; + } + if (!pooled) { + for (const file of prepared) { + let outcome: CsParseOutcome; + try { + const sourceText = await fsp.readFile(file.absoluteFilePath, 'utf-8'); + outcome = { + emissions: file.emissions.map((emission) => { + try { + return { + facts: this.extractor.extractFile({ + absoluteFilePath: file.absoluteFilePath, + filePath: file.relativePath, + baseMservPath: options.baseMservPath, + sourceText, + serviceVersionLinkHash, + context: emission.context, + defineConstants: emission.defineConstants, + implicitUsings: emission.implicitUsings, + implicitFrameworkDefines: emission.implicitFrameworkDefines, + }), + }; + } catch (error) { + return { extractError: `${(error as Error).message}` }; + } + }), + }; + } catch (error) { + outcome = { readError: String(error) }; + } + await consumeOutcome(file, outcome); + } } if (shared === undefined) { @@ -377,6 +406,47 @@ export class CSharpProjectAnalyzer { } } +/** + * One file, every input its extraction needs already resolved: the recorded + * path, and one {@link CsEmissionInputs} per target framework. Built on the + * main thread for both paths, so the governing-project caches are read from + * one thread and the pooled and serial runs extract from identical inputs. + */ +interface PreparedCsFile { + readonly absoluteFilePath: string; + readonly relativePath: string; + readonly emissions: readonly CsEmissionInputs[]; +} + +/** One emission's rows to the writers, in the serial loop's relation order. */ +async function appendFileFacts( + writers: Record, + facts: CsFileFacts +): Promise { + await writers.modules.append(facts.modules); + await writers.types.append(facts.types); + await writers.heritages.append(facts.heritages); + await writers.typeParameters.append(facts.typeParameters); + await writers.methods.append(facts.methods); + await writers.methodParameters.append(facts.methodParameters); + await writers.properties.append(facts.properties); + await writers.events.append(facts.events); + await writers.typeReferences.append(facts.typeReferences); + await writers.usings.append(facts.usings); + await writers.parseGaps.append(facts.parseGaps); + await writers.fields.append(facts.fields); + await writers.enumMembers.append(facts.enumMembers); + await writers.expressions.append(facts.expressions); + await writers.callSites.append(facts.callSites); + await writers.queryClauses.append(facts.queryClauses); + await writers.blocks.append(facts.blocks); + await writers.variables.append(facts.variables); + await writers.attributes.append(facts.attributes); + await writers.attributeArguments.append(facts.attributeArguments); + await writers.comments.append(facts.comments); + await writers.preprocRegions.append(facts.preprocRegions); +} + function countsOf(writers: Record): Record { return { cs_module: writers.modules.rowCount, From 6939e2421ef4c8625bfafbb6c30f9e0084ce9710 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:48:26 -0700 Subject: [PATCH 033/133] perf(java): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Java per-file work — read, tree-sitter parse, the type-registry extract with its fifteen side-channel drains, and the import extract — is independent between files, so it now runs on worker threads (java-parse-pool.ts, java-parse-worker.ts) on the machinery parse-pool-core.ts already gives every language. Each worker wires a CodeExtractor and ImportExtractor exactly as the analyzer wires its own and runs the body the serial loop ran (extractJavaFileFacts — the loop's drain sequence, factored out so both sides call the same code), and the main thread thaws each reply and appends it in file order, so accumulation order and the output bytes do not depend on which worker finishes first. AXIOMCODE_PARSE_JOBS picks the width (default min(4, cores-1); 1 is the strict serial path), and a run with no compiled worker beside the pool falls back to serial instead of failing. In the pooled path a worker reads its own file and applies readFiles' three pre-extraction rejections (unreadable, empty, oversized) itself; the serial path keeps reading everything up front, unchanged. Unlike the Python port, the byte gate found no cross-file extractor state to reset: the one candidate, the enum-constant hash map keyed by byte range and reset per enum rather than per file, is only ever read under an enum_constant node, which the current type's own extractFromEnum has just repopulated — so no stale entry can be consulted across files. One Java-specific wrinkle: analyzeJavaProjects runs projects through Promise.all, and the serial loop is order-safe only because it is synchronous once started. The pooled section awaits between consumes, so each project takes a turn on a chain (poolTurn) and its appends stay as atomic as the serial loop's. Across projects the append order was always timing-emergent (read-completion order) and stays so. On a 969-file single-project subject the Java stage runs 5.2–6.9 s serial and 2.7–2.9 s at 4 jobs (2.4 s best at 6), inside the default heap (max RSS 1.1 GB pooled against 0.8 GB serial). IR is byte-identical to serial at 4 and at 6 jobs on that subject. On a 1,625-file eight-module tree every Java relation is content-identical with cross-project row order differing — an order serial itself does not pin (two serial runs there already reorder the config relations). tests/run.py --lang java: 333/333. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/java/java-parse-pool.ts | 264 ++++++++++++++++++ .../src/workflows/java/java-parse-worker.ts | 77 +++++ .../workflows/java/java-project-analyzer.ts | 229 +++++++-------- 3 files changed, 458 insertions(+), 112 deletions(-) create mode 100644 parser/src/workflows/java/java-parse-pool.ts create mode 100644 parser/src/workflows/java/java-parse-worker.ts diff --git a/parser/src/workflows/java/java-parse-pool.ts b/parser/src/workflows/java/java-parse-pool.ts new file mode 100644 index 00000000..deba226d --- /dev/null +++ b/parser/src/workflows/java/java-parse-pool.ts @@ -0,0 +1,264 @@ +import * as path from 'path'; + +import { ImportRegistry } from '@/analysis-imports/java/ImportRegistry'; +import { MethodParameter } from '@/analysis-methods/java/MethodParameter'; +import { MethodRegistry } from '@/analysis-methods/java/MethodRegistry'; +import { MethodTypeParameter } from '@/analysis-methods/java/MethodTypeParameter'; +import { AnnotationArgumentReference } from '@/analysis-types/java/AnnotationArgumentReference'; +import { BlockRegistry } from '@/analysis-types/java/BlockRegistry'; +import { CommentRegistry } from '@/analysis-types/java/CommentRegistry'; +import { EnumConstant } from '@/analysis-types/java/EnumConstant'; +import { ExpressionReference } from '@/analysis-types/java/ExpressionReference'; +import { FieldRegistry } from '@/analysis-types/java/FieldRegistry'; +import { LocalVariableRegistry } from '@/analysis-types/java/LocalVariableRegistry'; +import { ModuleDirective } from '@/analysis-types/java/ModuleDirective'; +import { ModuleRegistry } from '@/analysis-types/java/ModuleRegistry'; +import { TypeAnnotation } from '@/analysis-types/java/TypeAnnotation'; +import { TypeParameter } from '@/analysis-types/java/TypeParameter'; +import { TypeReference } from '@/analysis-types/java/TypeReference'; +import { TypeRegistry } from '@/analysis-types/java/TypeRegistry'; +import { JAVA_ENTITY_TYPES } from '@/constants/consts'; +import { SkippedFileReason } from '@/enums/SkippedFileReason'; +import { CodeExtractor } from '@/parsers/code-extractor'; +import { ImportExtractor } from '@/parsers/java/extractors'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The Java half of the parallel parse stage: which prototype each table's + * rows get back, the shape of a dispatch and a reply, and the one-file + * extraction (`extractJavaFileFacts`) that the serial loop and the worker + * both run. Everything thread- and shape-related lives in parse-pool-core.ts. + * + * Rows must come back as real instances, not snapshots: the analyzer's + * export path calls `toCsv`/`getCsvHeader` on every row, and the field + * position export calls `getTypeRegistryLinkHash`/`getHash` — all prototype + * methods. + */ +const TABLE_PROTOTYPES = { + typeRegistries: TypeRegistry.prototype, + typeParameters: TypeParameter.prototype, + typeReferences: TypeReference.prototype, + annotations: TypeAnnotation.prototype, + annotationArguments: AnnotationArgumentReference.prototype, + methods: MethodRegistry.prototype, + methodParameters: MethodParameter.prototype, + methodTypeParameters: MethodTypeParameter.prototype, + enumConstants: EnumConstant.prototype, + modules: ModuleRegistry.prototype, + moduleDirectives: ModuleDirective.prototype, + fields: FieldRegistry.prototype, + imports: ImportRegistry.prototype, + expressions: ExpressionReference.prototype, + localVariables: LocalVariableRegistry.prototype, + blocks: BlockRegistry.prototype, + comments: CommentRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** Everything one Java file contributes: the type rows plus every side-channel table. */ +export interface JavaFileFacts { + typeRegistries: TypeRegistry[]; + typeParameters: TypeParameter[]; + typeReferences: TypeReference[]; + annotations: TypeAnnotation[]; + annotationArguments: AnnotationArgumentReference[]; + methods: MethodRegistry[]; + methodParameters: MethodParameter[]; + methodTypeParameters: MethodTypeParameter[]; + enumConstants: EnumConstant[]; + modules: ModuleRegistry[]; + moduleDirectives: ModuleDirective[]; + fields: FieldRegistry[]; + imports: ImportRegistry[]; + expressions: ExpressionReference[]; + localVariables: LocalVariableRegistry[]; + blocks: BlockRegistry[]; + comments: CommentRegistry[]; +} + +/** What the analyzer sends a worker for one file: strings only. */ +export interface JavaParseDispatch { + i: number; + /** Absolute path; the worker reads it AND records it on rows, exactly as the serial loop does. */ + filePath: string; + serviceVersionHash: string; +} + +/** + * One file's outcome. `skipReason` mirrors the three rejections the serial + * `readFiles` applies before extraction ever runs — an unreadable, empty or + * oversized file is skipped, never extracted. `facts` is everything else. + */ +export interface JavaParseOutcome { + skipReason?: SkippedFileReason; + /** For the FILE_TOO_LARGE log line, which names the line count. */ + lineCount?: number; + /** For the READ_ERROR log line, which names the error. */ + readErrorDetail?: string; + facts?: JavaFileFacts; +} + +/** The worker's reply: `facts` is the frozen (prototype-less) snapshot. */ +export interface JavaParseReply { + i: number; + skipReason?: SkippedFileReason; + lineCount?: number; + readErrorDetail?: string; + facts?: Record; +} + +/** + * Runs the extraction for ONE file: the type-registry extract, the drain of + * every per-file side channel the extractor exposes, and the import extract. + * This is the serial loop's body verbatim, factored out so the worker runs + * literally the same code against its own extractor pair — one per worker, + * reused across files, matching the analyzer's single-instance semantics. + * + * The `'getExtracted*' in extractor` guards are kept from the serial loop: + * a caller-supplied CodeExtractor may have a different extractor registered, + * and that extractor contributes only what it exposes. + */ +export function extractJavaFileFacts( + codeExtractor: CodeExtractor, + importExtractor: ImportExtractor, + filePath: string, + fileContent: string, + serviceVersionHash: string +): JavaFileFacts { + const facts: JavaFileFacts = { + typeRegistries: [], + typeParameters: [], + typeReferences: [], + annotations: [], + annotationArguments: [], + methods: [], + methodParameters: [], + methodTypeParameters: [], + enumConstants: [], + modules: [], + moduleDirectives: [], + fields: [], + imports: [], + expressions: [], + localVariables: [], + blocks: [], + comments: [], + }; + + const extractor = codeExtractor.getExtractor( + ProjectLanguage.JAVA, + JAVA_ENTITY_TYPES.TYPE_REGISTRY + ) as any; + + facts.typeRegistries = codeExtractor.extract( + ProjectLanguage.JAVA, + JAVA_ENTITY_TYPES.TYPE_REGISTRY, + filePath, + fileContent, + serviceVersionHash + ); + + if (extractor && 'getExtractedTypeParameters' in extractor) { + facts.typeParameters = extractor.getExtractedTypeParameters(); + } + if (extractor && 'getExtractedTypeReferences' in extractor) { + facts.typeReferences = extractor.getExtractedTypeReferences(); + } + if (extractor && 'getExtractedAnnotations' in extractor) { + facts.annotations = extractor.getExtractedAnnotations(); + } + if (extractor && 'getExtractedAnnotationArguments' in extractor) { + facts.annotationArguments = extractor.getExtractedAnnotationArguments(); + } + if (extractor && 'getExtractedMethods' in extractor) { + facts.methods = extractor.getExtractedMethods(); + } + if (extractor && 'getExtractedMethodParameters' in extractor) { + facts.methodParameters = extractor.getExtractedMethodParameters(); + } + if (extractor && 'getExtractedMethodTypeParameters' in extractor) { + facts.methodTypeParameters = extractor.getExtractedMethodTypeParameters(); + } + if (extractor && 'getExtractedEnumConstants' in extractor) { + facts.enumConstants = extractor.getExtractedEnumConstants(); + } + if (extractor && 'getExtractedModules' in extractor) { + facts.modules = extractor.getExtractedModules(); + } + if (extractor && 'getExtractedModuleDirectives' in extractor) { + facts.moduleDirectives = extractor.getExtractedModuleDirectives(); + } + if (extractor && 'getExtractedFields' in extractor) { + facts.fields = extractor.getExtractedFields(); + } + + facts.imports = importExtractor.extract(filePath, fileContent, serviceVersionHash); + + if (extractor && 'getExtractedExpressions' in extractor) { + facts.expressions = extractor.getExtractedExpressions(); + } + if (extractor && 'getExtractedLocalVariables' in extractor) { + facts.localVariables = extractor.getExtractedLocalVariables(); + } + if (extractor && 'getExtractedBlocks' in extractor) { + facts.blocks = extractor.getExtractedBlocks(); + } + if (extractor && 'getExtractedComments' in extractor) { + facts.comments = extractor.getExtractedComments(); + } + + return facts; +} + +/** Worker side: a fact set as columns structured clone can carry cheaply. */ +export function freezeFileFacts(facts: JavaFileFacts): Record { + const out: Record = {}; + for (const key of TABLE_KEYS) out[key] = freezeTable(facts[key]); + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFileFacts(frozen: Record): JavaFileFacts { + const out = {} as Record; + for (const key of TABLE_KEYS) { + out[key] = thawTable(frozen[key] as FrozenTable, TABLE_PROTOTYPES[key]); + } + return out as unknown as JavaFileFacts; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. + */ +export async function parseFilesInPool( + dispatches: JavaParseDispatch[], + jobs: number, + consume: (i: number, outcome: JavaParseOutcome) => void +): Promise { + return runParsePool( + path.join(__dirname, 'java-parse-worker.js'), + dispatches, + jobs, + reply => + consume( + reply.i, + reply.facts + ? { facts: thawFileFacts(reply.facts) } + : { + skipReason: reply.skipReason, + lineCount: reply.lineCount, + readErrorDetail: reply.readErrorDetail, + } + ) + ); +} diff --git a/parser/src/workflows/java/java-parse-worker.ts b/parser/src/workflows/java/java-parse-worker.ts new file mode 100644 index 00000000..e473fb4b --- /dev/null +++ b/parser/src/workflows/java/java-parse-worker.ts @@ -0,0 +1,77 @@ +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { JAVA_ENTITY_TYPES, LARGE_FILE_LINE_THRESHOLD } from '@/constants/consts'; +import { SkippedFileReason } from '@/enums/SkippedFileReason'; +import { CodeExtractor } from '@/parsers/code-extractor'; +import { ImportExtractor, TypeRegistryExtractor } from '@/parsers/java/extractors'; +import { ProjectLanguage } from '@/types/ProjectInfo'; +import { + extractJavaFileFacts, + freezeFileFacts, + JavaParseDispatch, + JavaParseReply, +} from '@/workflows/java/java-parse-pool'; + +/** + * One parse worker: reads a file, applies the SAME three pre-extraction + * rejections the analyzer's `readFiles` applies (unreadable, empty, + * oversized), runs the same extractor stack the serial loop runs, and posts + * the file's tables back as prototype-less column snapshots + * (`freezeFileFacts`). + * + * One CodeExtractor + ImportExtractor pair per worker, wired exactly as the + * analyzer wires its own (`registerExtractors`), reused across files — the + * extractor resets its per-file arrays at the top of every `extract`, so + * reuse matches the analyzer's single-instance semantics. + */ +const codeExtractor = new CodeExtractor(); +codeExtractor.registerExtractor( + ProjectLanguage.JAVA, + JAVA_ENTITY_TYPES.TYPE_REGISTRY, + new TypeRegistryExtractor() +); +const importExtractor = new ImportExtractor(); + +const port = parentPort; +if (!port) throw new Error('java-parse-worker must run as a worker thread'); + +port.on('message', (job: JavaParseDispatch) => { + void (async () => { + let content: string; + try { + content = await fsp.readFile(job.filePath, 'utf-8'); + } catch (error) { + port.postMessage({ + i: job.i, + skipReason: SkippedFileReason.READ_ERROR, + readErrorDetail: String(error), + } satisfies JavaParseReply); + return; + } + if (!content || content.trim().length === 0) { + port.postMessage({ + i: job.i, + skipReason: SkippedFileReason.EMPTY_CONTENT, + } satisfies JavaParseReply); + return; + } + const lineCount = content.split('\n').length; + if (lineCount > LARGE_FILE_LINE_THRESHOLD) { + port.postMessage({ + i: job.i, + skipReason: SkippedFileReason.FILE_TOO_LARGE, + lineCount, + } satisfies JavaParseReply); + return; + } + const facts = extractJavaFileFacts( + codeExtractor, + importExtractor, + job.filePath, + content, + job.serviceVersionHash + ); + port.postMessage({ i: job.i, facts: freezeFileFacts(facts) } satisfies JavaParseReply); + })(); +}); diff --git a/parser/src/workflows/java/java-project-analyzer.ts b/parser/src/workflows/java/java-project-analyzer.ts index 625e8880..4b1168b4 100644 --- a/parser/src/workflows/java/java-project-analyzer.ts +++ b/parser/src/workflows/java/java-project-analyzer.ts @@ -27,6 +27,13 @@ import { TypeRegistryExtractor, ImportExtractor } from '@/parsers/java/extractor import { ProjectInfo, ProjectLanguage } from '@/types/ProjectInfo'; import { EntityUtils } from '@/utils/entity-utils'; import { isGitIgnoredDir } from '@/utils/git-ignored'; +import { + extractJavaFileFacts, + JavaFileFacts, + JavaParseOutcome, + parseFilesInPool, + parsePoolJobs, +} from '@/workflows/java/java-parse-pool'; export class JavaProjectAnalyzer { private codeExtractor: CodeExtractor; @@ -50,6 +57,19 @@ export class JavaProjectAnalyzer { private skippedFiles: { filePath: string; baseMservPath: string; serviceVersionHash: string; reason: SkippedFileReason; uniqueFileHash: string }[] = []; private importExtractor: ImportExtractor; private outputDir: string; + /** + * Serializes the pooled extract+consume section across projects. + * + * `analyzeJavaProjects` runs projects through `Promise.all`, and in the + * SERIAL path that is safe for the `all*` arrays because each project's + * per-file loop is synchronous — once it starts, it runs to completion + * before any other project can append. The pooled path awaits between + * consumes, so without this gate two projects' appends would interleave by + * worker timing and the output bytes would change run to run. Each + * project's pooled section therefore takes its turn on this chain, keeping + * a project's appends as atomic as the serial loop's. + */ + private poolTurn: Promise = Promise.resolve(); constructor(codeExtractor?: CodeExtractor, outputDir?: string) { this.codeExtractor = codeExtractor || new CodeExtractor(); @@ -181,128 +201,113 @@ export class JavaProjectAnalyzer { console.log(` 🔍 Found ${javaFiles.length} Java file(s), extracting types...`); - const fileContents = await this.readFiles(javaFiles, projectPath, serviceVersionHash); - - // Extract from each file and collect type parameters after each file const typeRegistries: TypeRegistry[] = []; - const extractor = this.codeExtractor.getExtractor( - ProjectLanguage.JAVA, - JAVA_ENTITY_TYPES.TYPE_REGISTRY - ) as any; - - for (const fileData of fileContents) { - const typesFromFile = this.codeExtractor.extract( - ProjectLanguage.JAVA, - JAVA_ENTITY_TYPES.TYPE_REGISTRY, - fileData.path, - fileData.content, - serviceVersionHash - ); - typeRegistries.push(...typesFromFile); - - // Collect type parameters from this file immediately - if (extractor && 'getExtractedTypeParameters' in extractor) { - const typeParams = extractor.getExtractedTypeParameters(); - this.allTypeParameters.push(...typeParams); - } - - // Collect type references from this file immediately - if (extractor && 'getExtractedTypeReferences' in extractor) { - const typeRefs = extractor.getExtractedTypeReferences(); - this.allTypeReferences.push(...typeRefs); - } - - // Collect annotations from this file immediately - if (extractor && 'getExtractedAnnotations' in extractor) { - const annotations = extractor.getExtractedAnnotations(); - this.allAnnotations.push(...annotations); - } - - // Collect annotation arguments from this file immediately - if (extractor && 'getExtractedAnnotationArguments' in extractor) { - const annotationArgs = extractor.getExtractedAnnotationArguments(); - this.allAnnotationArguments.push(...annotationArgs); - } - - // Collect methods from this file immediately - if (extractor && 'getExtractedMethods' in extractor) { - const methods = extractor.getExtractedMethods(); - this.allMethods.push(...methods); - } - - // Collect method parameters from this file immediately - if (extractor && 'getExtractedMethodParameters' in extractor) { - const methodParams = extractor.getExtractedMethodParameters(); - this.allMethodParameters.push(...methodParams); - } - - // Collect method type parameters from this file immediately - if (extractor && 'getExtractedMethodTypeParameters' in extractor) { - const methodTypeParams = extractor.getExtractedMethodTypeParameters(); - this.allMethodTypeParameters.push(...methodTypeParams); - } - - // Collect enum constants from this file immediately - if (extractor && 'getExtractedEnumConstants' in extractor) { - const enumConstants = extractor.getExtractedEnumConstants(); - this.allEnumConstants.push(...enumConstants); - } - // Collect the module declaration from this file, if it was a module-info.java - if (extractor && 'getExtractedModules' in extractor) { - const modules = extractor.getExtractedModules(); - this.allModules.push(...modules); + // One file's outcome, appended the same way whichever thread produced it. + // The pool calls this in file order as results arrive, and the serial + // loop calls it inline, so the accumulation order — and therefore the + // output bytes — is identical across the two paths. + const consumeOutcome = (filePath: string, outcome: JavaParseOutcome): void => { + if (outcome.skipReason !== undefined) { + this.recordPoolSkip(filePath, projectPath, serviceVersionHash, outcome); + return; } - - // Collect module directives from this file immediately - if (extractor && 'getExtractedModuleDirectives' in extractor) { - const moduleDirectives = extractor.getExtractedModuleDirectives(); - this.allModuleDirectives.push(...moduleDirectives); - } - - // Collect fields from this file immediately - if (extractor && 'getExtractedFields' in extractor) { - const fields = extractor.getExtractedFields(); - this.allFields.push(...fields); + if (!outcome.facts) return; + this.appendFileFacts(typeRegistries, outcome.facts); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (java-parse-pool.ts): read, parse, extract and hash are independent + // between files. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path; + // the pool declining (no compiled worker beside this file) falls back to + // it too. In the pooled path the worker reads its own file and applies + // `readFiles`' three rejections itself; the serial path below keeps + // reading everything up front, exactly as before. + const jobs = parsePoolJobs(javaFiles.length); + let pooled = false; + if (jobs > 1) { + // Take this project's turn on the pool chain — see `poolTurn`. + const myTurn = this.poolTurn; + let release!: () => void; + this.poolTurn = new Promise(resolve => (release = resolve)); + await myTurn; + try { + pooled = await parseFilesInPool( + javaFiles.map((filePath, i) => ({ i, filePath, serviceVersionHash })), + jobs, + (i, outcome) => consumeOutcome(javaFiles[i] as string, outcome) + ); + } finally { + release(); } - - // Extract imports from this file - const importsFromFile = this.importExtractor.extract( - fileData.path, - fileData.content, - serviceVersionHash - ); - this.allImports.push(...importsFromFile); - - // Collect expressions from this file immediately - if (extractor && 'getExtractedExpressions' in extractor) { - const expressions = extractor.getExtractedExpressions(); - this.allExpressions.push(...expressions); - } - - // Collect local variables from this file immediately - if (extractor && 'getExtractedLocalVariables' in extractor) { - const localVariables = extractor.getExtractedLocalVariables(); - this.allLocalVariables.push(...localVariables); - } - - // Collect blocks from this file immediately - if (extractor && 'getExtractedBlocks' in extractor) { - const blocks = extractor.getExtractedBlocks(); - this.allBlocks.push(...blocks); - } - - // Collect comments from this file immediately - if (extractor && 'getExtractedComments' in extractor) { - const comments = extractor.getExtractedComments(); - this.allComments.push(...comments); + } + if (!pooled) { + const fileContents = await this.readFiles(javaFiles, projectPath, serviceVersionHash); + for (const fileData of fileContents) { + consumeOutcome(fileData.path, { + facts: extractJavaFileFacts( + this.codeExtractor, + this.importExtractor, + fileData.path, + fileData.content, + serviceVersionHash + ), + }); } - } return typeRegistries; } + /** + * Appends one file's tables to the project's accumulators — the body the + * serial loop ran inline, applied identically to a worker's thawed reply. + */ + private appendFileFacts(typeRegistries: TypeRegistry[], facts: JavaFileFacts): void { + typeRegistries.push(...facts.typeRegistries); + this.allTypeParameters.push(...facts.typeParameters); + this.allTypeReferences.push(...facts.typeReferences); + this.allAnnotations.push(...facts.annotations); + this.allAnnotationArguments.push(...facts.annotationArguments); + this.allMethods.push(...facts.methods); + this.allMethodParameters.push(...facts.methodParameters); + this.allMethodTypeParameters.push(...facts.methodTypeParameters); + this.allEnumConstants.push(...facts.enumConstants); + this.allModules.push(...facts.modules); + this.allModuleDirectives.push(...facts.moduleDirectives); + this.allFields.push(...facts.fields); + this.allImports.push(...facts.imports); + this.allExpressions.push(...facts.expressions); + this.allLocalVariables.push(...facts.localVariables); + this.allBlocks.push(...facts.blocks); + this.allComments.push(...facts.comments); + } + + /** + * Records a skip reported by a worker, with the same row and the same log + * line `readFiles` produces for that rejection in the serial path. + */ + private recordPoolSkip( + filePath: string, + baseMservPath: string, + serviceVersionHash: string, + outcome: JavaParseOutcome + ): void { + const reason = outcome.skipReason as SkippedFileReason; + if (reason === SkippedFileReason.READ_ERROR) { + console.error(`Error reading file ${filePath}:`, outcome.readErrorDetail); + } else if (reason === SkippedFileReason.FILE_TOO_LARGE) { + console.log(` ⏭️ Skipping very large file (${outcome.lineCount} lines): ${filePath}`); + } else { + console.warn(`Skipping ${filePath}: empty or invalid content`); + } + const uniqueFileHash = EntityUtils.generateEntityHash( + ENTITY_IDENTIFIERS.SKIPPED_FILE, + `${filePath}||${baseMservPath}||${serviceVersionHash}||${reason}` + ); + this.skippedFiles.push({ filePath, baseMservPath, serviceVersionHash, reason, uniqueFileHash }); + } + /** * Recursively finds all Java files in a directory */ From d9b7bbb6fd06432a6f1adca382eabc34787d1e94 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:45 -0700 Subject: [PATCH 034/133] perf(typescript): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript extraction is per-file by design — cross-file call resolution is the engine's, and the module link passes run after every file — so a worker can run extractTypeScriptFile exactly as the serial loop does. The closures extraction needs (toProjectRelative, sourceModuleHashOf, resolveWorkspaceModule) move to ts-resolution-context.ts, built from plain values, so the analyzer and ts-parse-worker construct the same three functions from the same inputs and cannot drift; the worker's own TsConfigResolver reads the same tsconfigs from disk, and the closure walk's prefetched text rides the dispatch so a file is still read once. What crosses the boundary is not the fact set. A first cut shipped rows as columns, like Python, and was SLOWER than serial (18.4s -> 26.0s on a 1,754-file subject): TS rows are cheap to make and expensive to ship, and ts_expression is over half the output. So seventeen of the twenty relations — everything the analyzer streams and never reads back — cross as RENDERED CSV LINES (toCsv runs beside the extraction, on the worker's core; TsRelationWriter.appendRendered runs the same verifyRow on the same strings), and the worker runs the per-file completeness measurement itself, sending counters, gaps and deferred verdicts. Only modules, imports and exports cross as rows, because the link passes mutate them and the deferred verdicts are read against those same rows: identity is kept by sending row indexes and re-binding on the main thread. parse-pool-core's consume may now be async (the writers are awaited per file, in file order), and workers take a workerData init. On the 1,754-file subject the TypeScript phase runs 18.4s serial, 15.5s pooled, and the subject's whole run drops 35.5s -> 20.6s real — the pool also frees the main thread for the other analyzers running in the same extract. IR is byte-identical between AXIOMCODE_PARSE_JOBS=1 and the pool at 4 and 6 jobs, except all-property-keys, all-property-value-segments and all-xml-elements, whose row order differs between two SERIAL runs of the same build too (sorted contents equal): the properties and XML analyzers emit in nondeterministic order today, which tests/run.py does not read and a follow-up should pin. tests/run.py --lang typescript: 233 of 258 — the 25 failures (const-object-table, module-level-const, const-object-data-keys, multi-line-initializer, wrapped-handler-route, library-receiver) fail IDENTICALLY on the base commit with this change stashed, and the fixture IR of the worst case is byte-identical base vs this change: they are the integration branch's own, from merges no CI run validated, not this commit's. The python corpus subject re-gates byte-identical through the reworked core (async pump), and tests/run.py --lang python passed on the same core earlier today. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- parser/src/workflows/parse-pool-core.ts | 63 ++-- .../src/workflows/typescript/ts-parse-pool.ts | 307 ++++++++++++++++++ .../workflows/typescript/ts-parse-worker.ts | 78 +++++ .../typescript/ts-relation-writer.ts | 31 ++ .../typescript/ts-resolution-context.ts | 83 +++++ .../typescript/typescript-project-analyzer.ts | 225 ++++++++----- 6 files changed, 693 insertions(+), 94 deletions(-) create mode 100644 parser/src/workflows/typescript/ts-parse-pool.ts create mode 100644 parser/src/workflows/typescript/ts-parse-worker.ts create mode 100644 parser/src/workflows/typescript/ts-resolution-context.ts diff --git a/parser/src/workflows/parse-pool-core.ts b/parser/src/workflows/parse-pool-core.ts index 1bd162e8..6e7000f7 100644 --- a/parser/src/workflows/parse-pool-core.ts +++ b/parser/src/workflows/parse-pool-core.ts @@ -115,7 +115,12 @@ export async function runParsePool void + // may be async (a consumer that streams rows to disk awaits its writes); + // the pool never runs two consumes at once and never out of order + consume: (reply: R) => void | Promise, + // handed to every worker at start (workerData): the shared, clonable inputs + // a language's extraction needs beyond the per-file dispatch + workerData?: Record ): Promise { if (!fs.existsSync(workerPath)) return false; if (dispatches.length === 0) return true; @@ -125,16 +130,10 @@ export async function runParsePool((resolve, reject) => { - const drain = () => { - for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { - ready.delete(nextToConsume); - consume(reply); - nextToConsume += 1; - } - }; const feed = (worker: Worker) => { if (nextToDispatch >= dispatches.length) { idle.push(worker); @@ -142,27 +141,53 @@ export async function runParsePool= window) { - idle.push(worker); // drain() wakes it once its result's turn has come + idle.push(worker); // the pump wakes it once its result's turn has come return; } worker.postMessage(dispatches[nextToDispatch]); nextToDispatch += 1; }; + const wakeIdle = () => { + while ( + idle.length > 0 && + nextToDispatch - nextToConsume < window && + nextToDispatch < dispatches.length + ) { + feed(idle.pop() as Worker); + } + }; + // ONE pump at a time: a consume may await, replies landing meanwhile only + // set `ready` — the running pump picks them up, or the re-check below + // restarts it for a reply that arrived exactly as it finished. + const pump = async () => { + if (pumping) return; + pumping = true; + try { + for (let reply = ready.get(nextToConsume); reply; reply = ready.get(nextToConsume)) { + ready.delete(nextToConsume); + await consume(reply); + nextToConsume += 1; + wakeIdle(); + } + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))); + return; + } finally { + pumping = false; + } + if (ready.has(nextToConsume)) { + void pump(); + } else if (nextToConsume >= dispatches.length) { + resolve(); + } + }; for (let w = 0; w < Math.min(jobs, dispatches.length); w++) { - const worker = new Worker(workerPath); + const worker = new Worker(workerPath, workerData === undefined ? undefined : { workerData }); workers.push(worker); worker.on('message', (reply: R) => { ready.set(reply.i, reply); - drain(); + void pump(); feed(worker); - while ( - idle.length > 0 && - nextToDispatch - nextToConsume < window && - nextToDispatch < dispatches.length - ) { - feed(idle.pop() as Worker); - } - if (nextToConsume >= dispatches.length) resolve(); }); worker.on('error', reject); feed(worker); diff --git a/parser/src/workflows/typescript/ts-parse-pool.ts b/parser/src/workflows/typescript/ts-parse-pool.ts new file mode 100644 index 00000000..aefacec9 --- /dev/null +++ b/parser/src/workflows/typescript/ts-parse-pool.ts @@ -0,0 +1,307 @@ +import * as path from 'path'; + +import { TsExportRegistry } from '@/analysis-types/typescript/TsExportRegistry'; +import { TsImportRegistry } from '@/analysis-types/typescript/TsImportRegistry'; +import { TsModuleRegistry } from '@/analysis-types/typescript/TsModuleRegistry'; +import { TYPESCRIPT_CSV_FILES } from '@/constants/typescript-constants'; +import { TsFileFacts } from '@/parsers/typescript/extractors/ts-fact-extractor'; +import { + accumulateFileCompleteness, + CompletenessAccumulator, + DeferredVerdict, + IrGap, + newCompletenessAccumulator, +} from '@/parsers/typescript/extractors/ts-ir-completeness'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The TypeScript half of the parallel parse stage. Extraction here is + * per-file by DESIGN — cross-file call resolution was retracted to the + * engine, and the module link passes run after every file on the main + * thread — so a worker runs `extractTypeScriptFile` exactly as the serial + * loop does (ts-parse-worker.ts rebuilds the shared closures from plain data + * through the same `tsResolutionContext` the analyzer uses). + * + * WHAT CROSSES THE BOUNDARY IS NOT THE FACT SET. Seventeen of the twenty + * relations are write-only after extraction — the analyzer streams them to + * disk and nothing reads them back — so the worker renders their CSV LINES + * (`toCsv` runs beside the extraction, on the worker's core) and the main + * thread appends strings. The worker also runs the per-file completeness + * measurement itself and sends the counters, gaps and deferred verdicts. + * Only three tables cross as rows — `modules`, `imports`, `exports` — because + * the module link passes MUTATE them after every file is parsed, and the + * deferred verdicts are read against those same mutated rows: identity is + * kept by sending row INDEXES and re-binding on the main thread. + */ +export const STREAMED_TABLES = { + types: TYPESCRIPT_CSV_FILES.TYPES, + heritages: TYPESCRIPT_CSV_FILES.TYPE_HERITAGES, + typeParameters: TYPESCRIPT_CSV_FILES.TYPE_PARAMETERS, + typeReferences: TYPESCRIPT_CSV_FILES.TYPE_REFERENCES, + methods: TYPESCRIPT_CSV_FILES.METHODS, + methodParameters: TYPESCRIPT_CSV_FILES.METHOD_PARAMETERS, + fields: TYPESCRIPT_CSV_FILES.FIELDS, + variables: TYPESCRIPT_CSV_FILES.VARIABLES, + expressions: TYPESCRIPT_CSV_FILES.EXPRESSIONS, + callSites: TYPESCRIPT_CSV_FILES.CALL_SITES, + blocks: TYPESCRIPT_CSV_FILES.BLOCKS, + decorators: TYPESCRIPT_CSV_FILES.DECORATORS, + decoratorArguments: TYPESCRIPT_CSV_FILES.DECORATOR_ARGUMENTS, + enumMembers: TYPESCRIPT_CSV_FILES.ENUM_MEMBERS, + fieldPositions: TYPESCRIPT_CSV_FILES.FIELD_POSITIONS, + comments: TYPESCRIPT_CSV_FILES.COMMENTS, + parseGaps: TYPESCRIPT_CSV_FILES.PARSE_GAPS, +} as const; + +export type StreamedTableKey = keyof typeof STREAMED_TABLES; +export const STREAMED_KEYS = Object.keys(STREAMED_TABLES) as StreamedTableKey[]; + +const HELD_PROTOTYPES = { + modules: TsModuleRegistry.prototype, + imports: TsImportRegistry.prototype, + exports: TsExportRegistry.prototype, +} as const; +type HeldKey = keyof typeof HELD_PROTOTYPES; +const HELD_KEYS = Object.keys(HELD_PROTOTYPES) as HeldKey[]; + +/** What the analyzer sends a worker for one file: strings only. */ +export interface TsParseDispatch { + i: number; + /** Absolute path; the worker reads it unless `sourceText` rode along. */ + file: string; + /** The closure walk's prefetched text, so the file is still read once. */ + sourceText?: string; + filePath: string; + moduleQualifiedName: string; +} + +export interface RenderedTable { + header: string; + lines: string[]; +} + +interface FrozenDeferred { + shape: string; + where: string; + calleeName: string; + missingSoFar: string[]; + hop: DeferredVerdict['hop']; + /** DYNAMIC_IMPORT: index into the file's imports, or -1 for undefined. */ + importRowIndex?: number; + /** IMPORTED_NAME / RECEIVER_TYPE: the file's import map as (name, index). */ + importEntries?: [string, number][]; + localName?: string; + localTypeNames?: string[]; + typeName?: string; +} + +export interface FrozenCompleteness { + report: Record; + gaps: IrGap[]; + deferred: FrozenDeferred[]; +} + +export interface TsParseReply { + i: number; + readError?: string; + extractError?: string; + rendered?: Partial>; + held?: Record; + completeness?: FrozenCompleteness; + filePath?: string; +} + +/** What the main thread consumes per file once a worker reply is re-bound. */ +export interface TsPooledFile { + rendered: Partial>; + modules: TsModuleRegistry[]; + imports: TsImportRegistry[]; + exports: TsExportRegistry[]; + completeness: { gaps: IrGap[]; deferred: DeferredVerdict[]; report: Record }; + filePath: string; +} + +export interface TsParseOutcome { + readError?: string; + extractError?: string; + file?: TsPooledFile; +} + +/** Worker side: render the streamed tables, measure, freeze the held rows. */ +export function freezeTsReply(i: number, facts: TsFileFacts): TsParseReply { + const rendered: Partial> = {}; + for (const key of STREAMED_KEYS) { + const rows = facts[key] as readonly { toCsv(): string; getCsvHeader(): string }[]; + if (rows.length === 0) continue; + rendered[key] = { + header: (rows[0] as { getCsvHeader(): string }).getCsvHeader(), + lines: rows.map(r => r.toCsv()), + }; + } + + // The same call the serial loop makes, against this worker's live facts. + const accumulator = newCompletenessAccumulator(); + accumulateFileCompleteness(facts, accumulator); + + const importIndex = new Map(); + facts.imports.forEach((row, idx) => importIndex.set(row, idx)); + const freezeImportMap = (m: ReadonlyMap): [string, number][] => + [...m].map(([name, row]) => [name, importIndex.get(row) ?? -1]); + + const deferred: FrozenDeferred[] = accumulator.deferred.map(d => { + const base = { + shape: d.shape, + where: d.where, + calleeName: d.calleeName, + missingSoFar: [...d.missingSoFar], + hop: d.hop, + }; + switch (d.hop) { + case 'DYNAMIC_IMPORT': + return { + ...base, + importRowIndex: d.importRow === undefined ? -1 : (importIndex.get(d.importRow) ?? -1), + }; + case 'IMPORTED_NAME': + return { ...base, importEntries: freezeImportMap(d.imports), localName: d.localName }; + case 'RECEIVER_TYPE': + return { + ...base, + importEntries: freezeImportMap(d.imports), + localTypeNames: [...d.localTypeNames], + typeName: d.typeName, + }; + } + }); + + return { + i, + rendered, + held: { + modules: freezeTable(facts.modules as unknown as object[]), + imports: freezeTable(facts.imports as unknown as object[]), + exports: freezeTable(facts.exports as unknown as object[]), + }, + completeness: { + report: accumulator.report as unknown as Record, + gaps: accumulator.gaps, + deferred, + }, + filePath: facts.filePath, + }; +} + +/** Main-thread side: held rows with their prototypes, verdicts re-bound. */ +export function thawTsReply(reply: TsParseReply): TsPooledFile { + const held = reply.held as Record; + const tables = {} as Record; + for (const key of HELD_KEYS) tables[key] = thawTable(held[key], HELD_PROTOTYPES[key]); + const imports = tables.imports as TsImportRegistry[]; + + const thawImportMap = (entries: [string, number][]): Map => + new Map(entries.map(([name, idx]) => [name, imports[idx] as TsImportRegistry])); + + const completeness = reply.completeness as FrozenCompleteness; + const deferred: DeferredVerdict[] = completeness.deferred.map(d => { + const base = { + shape: d.shape, + where: d.where, + calleeName: d.calleeName, + missingSoFar: d.missingSoFar, + }; + switch (d.hop) { + case 'DYNAMIC_IMPORT': + return { + ...base, + hop: d.hop, + importRow: d.importRowIndex === -1 ? undefined : imports[d.importRowIndex as number], + }; + case 'IMPORTED_NAME': + return { + ...base, + hop: d.hop, + imports: thawImportMap(d.importEntries ?? []), + localName: d.localName as string, + }; + case 'RECEIVER_TYPE': + return { + ...base, + hop: d.hop, + imports: thawImportMap(d.importEntries ?? []), + localTypeNames: new Set(d.localTypeNames ?? []), + typeName: d.typeName as string, + }; + } + }); + + return { + rendered: reply.rendered ?? {}, + modules: tables.modules as TsModuleRegistry[], + imports, + exports: tables.exports as TsExportRegistry[], + completeness: { gaps: completeness.gaps, deferred, report: completeness.report }, + filePath: reply.filePath as string, + }; +} + +/** + * Folds one file's completeness measurement into the shared accumulator, in + * file order, exactly as the serial loop's `accumulateFileCompleteness` call + * would have: counters add, per-shape counters add shape by shape, and the + * ordered lists concatenate. + */ +export function mergeCompleteness( + accumulator: CompletenessAccumulator, + delta: { gaps: IrGap[]; deferred: DeferredVerdict[]; report: Record } +): void { + const report = accumulator.report as unknown as Record; + for (const [key, value] of Object.entries(delta.report)) { + if (typeof value === 'number') { + report[key] = ((report[key] as number) ?? 0) + value; + } else if (key === 'byReceiverKind') { + const into = report[key] as Record>; + for (const [shape, counts] of Object.entries(value as Record>)) { + const bucket = into[shape] ?? (into[shape] = Object.fromEntries( + Object.keys(counts).map(k => [k, 0]) + ) as Record); + for (const [k, n] of Object.entries(counts)) bucket[k] = (bucket[k] ?? 0) + n; + } + } else if (Array.isArray(value)) { + (report[key] as unknown[]).push(...value); + } + } + accumulator.gaps.push(...delta.gaps); + accumulator.deferred.push(...delta.deferred); +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available; `consume` may await its writes. + * `false` means no compiled worker: the caller falls back to its serial loop. + */ +export async function parseTsFilesInPool( + workerData: Record, + dispatches: TsParseDispatch[], + jobs: number, + consume: (i: number, outcome: TsParseOutcome) => Promise +): Promise { + return runParsePool( + path.join(__dirname, 'ts-parse-worker.js'), + dispatches, + jobs, + reply => + consume( + reply.i, + reply.held + ? { file: thawTsReply(reply) } + : { readError: reply.readError, extractError: reply.extractError } + ), + workerData + ); +} diff --git a/parser/src/workflows/typescript/ts-parse-worker.ts b/parser/src/workflows/typescript/ts-parse-worker.ts new file mode 100644 index 00000000..d7c268f2 --- /dev/null +++ b/parser/src/workflows/typescript/ts-parse-worker.ts @@ -0,0 +1,78 @@ +import * as fsp from 'fs/promises'; +import { parentPort, workerData } from 'worker_threads'; + +import { TS_SKIP_DIRECTORIES } from '@/constants/typescript-constants'; +import { extractTypeScriptFile, TsFileFacts } from '@/parsers/typescript/extractors/ts-fact-extractor'; +import { TsConfigResolver } from '@/parsers/typescript/tsconfig-resolver'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; +import { scriptTextOf } from '@/utils/vue-sfc'; +import { freezeTsReply, TsParseDispatch, TsParseReply } from '@/workflows/typescript/ts-parse-pool'; +import { toRelative, tsResolutionContext } from '@/workflows/typescript/ts-resolution-context'; + +/** + * One TypeScript parse worker: the same per-file extraction the serial loop + * runs, from the same shared inputs. The closures extraction needs are + * rebuilt here from the plain values in `workerData` through the SAME + * `tsResolutionContext` the analyzer uses, and the per-file tsconfig comes + * from this worker's own `TsConfigResolver`, which reads the same files from + * disk the analyzer's did. The two error cases mirror the serial loop's two + * catch blocks exactly. + */ +const init = workerData as { + pathAnchor: string; + baseMservPath: string; + serviceVersionLinkHash: string; + excludes: string[]; + projectModuleHashes: Map; +}; + +const configResolver = new TsConfigResolver(); +const workspacePackages = WorkspacePackages.discover( + init.pathAnchor, + new Set(TS_SKIP_DIRECTORIES) +); +const { toProjectRelative, resolveWorkspaceModule } = tsResolutionContext( + init, + workspacePackages +); + +const port = parentPort; +if (!port) throw new Error('ts-parse-worker must run as a worker thread'); + +port.on('message', (job: TsParseDispatch) => { + void (async () => { + let sourceText: string; + try { + sourceText = job.sourceText ?? (await fsp.readFile(job.file, 'utf-8')); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies TsParseReply); + return; + } + try { + const governing = configResolver.resolve(job.file); + const script = scriptTextOf(job.file, sourceText); + const facts: TsFileFacts = extractTypeScriptFile({ + absoluteFilePath: job.file, + filePath: job.filePath, + baseMservPath: init.baseMservPath, + moduleQualifiedName: job.moduleQualifiedName, + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash: init.serviceVersionLinkHash, + tsConfigPath: governing.configPath === '' + ? '' + : toRelative(init.pathAnchor, governing.configPath), + moduleResolutionMode: governing.moduleResolutionMode, + decoratorSystem: governing.decoratorSystem, + compilerOptions: governing.options, + packageName: '', + projectModuleHashes: init.projectModuleHashes, + toProjectRelative, + resolveWorkspaceModule, + }); + port.postMessage(freezeTsReply(job.i, facts)); + } catch (error) { + port.postMessage({ i: job.i, extractError: String(error) } satisfies TsParseReply); + } + })(); +}); diff --git a/parser/src/workflows/typescript/ts-relation-writer.ts b/parser/src/workflows/typescript/ts-relation-writer.ts index f431759d..59c31fcf 100644 --- a/parser/src/workflows/typescript/ts-relation-writer.ts +++ b/parser/src/workflows/typescript/ts-relation-writer.ts @@ -92,6 +92,37 @@ export class TsRelationWriter { } } + /** + * Appends one file's rows ALREADY RENDERED — a parse worker runs `toCsv` + * beside the extraction so the main thread writes strings instead of + * re-walking rows. Every line passes the same `verifyRow` the object path + * runs, on the same string that is written; the header must be the one the + * rows' class renders, and the first appender's header wins exactly as the + * object path's first row does. + */ + async appendRendered(header: string, lines: readonly string[]): Promise { + if (this.closed) { + throw new Error(`${path.basename(this.outputPath)}: appended after the file was published`); + } + if (lines.length === 0) { + return; + } + if (this.handle === undefined) { + this.handle = await fsp.open(this.temporaryPath, 'w'); + this.header = header; + this.width = countTabs(this.header) + 1; + this.buffer.push(this.header + '\n'); + } + for (const line of lines) { + verifyRow(line, this.width, this.outputPath, this.rows + 2); + this.buffer.push(line + '\n'); + this.rows += 1; + } + if (this.buffer.length >= TS_CSV_CHUNK_SIZE) { + await this.flush(); + } + } + private async flush(): Promise { if (this.handle === undefined || this.buffer.length === 0) { return; diff --git a/parser/src/workflows/typescript/ts-resolution-context.ts b/parser/src/workflows/typescript/ts-resolution-context.ts new file mode 100644 index 00000000..37d66130 --- /dev/null +++ b/parser/src/workflows/typescript/ts-resolution-context.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extractor'; +import { stripTsExtension } from '@/parsers/typescript/ts-module-paths'; +import { WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; + +/** + * The shared inputs a TypeScript file's extraction closes over, built from + * PLAIN DATA so the analyzer's loop and a parse worker construct the same + * three functions from the same values and cannot drift. The parts are pure + * (`toProjectRelative`), derived from paths alone (`sourceModuleHashOf` — the + * design that lets a module augmentation key under a file that has not been + * parsed), or memoised pure lookups (`resolveWorkspaceModule`), which is what + * makes per-file extraction order-free and therefore poolable at all. + */ +export interface TsResolutionInputs { + pathAnchor: string; + baseMservPath: string; + serviceVersionLinkHash: string; + /** Directory NAMES excluded from the walk, as the analyzer resolved them. */ + excludes: ReadonlySet | readonly string[]; + /** Every in-program file's module hash, keyed by normalized absolute path. */ + projectModuleHashes: Map; +} + +export interface TsResolutionContext { + toProjectRelative: (absolutePath: string) => string; + sourceModuleHashOf: (absolutePath: string) => string | undefined; + resolveWorkspaceModule: (specifier: string) => WorkspaceModule | undefined; +} + +export function toRelative(rootDir: string, file: string): string { + return path.relative(rootDir, file).split(path.sep).join('/') || path.basename(file); +} + +export function stripExtension(relativePath: string): string { + return stripTsExtension(relativePath); +} + +export function tsResolutionContext( + inputs: TsResolutionInputs, + workspacePackages: WorkspacePackages +): TsResolutionContext { + const { pathAnchor, baseMservPath, serviceVersionLinkHash, projectModuleHashes } = inputs; + const excludes = inputs.excludes instanceof Set ? inputs.excludes : new Set(inputs.excludes); + + const toProjectRelative = (absolutePath: string): string => + stripExtension(toRelative(pathAnchor, absolutePath)); + + // A sibling package imported by its name binds to the source its entry is + // built from. That source may belong to another program under the same + // anchor, whose module hash is the same pure function of its path; a source + // file outside the anchor or under a skipped directory is walked by no + // program and binds nothing. + const sourceModuleHashOf = (absolutePath: string): string | undefined => { + const inProgram = projectModuleHashes.get(absolutePath); + if (inProgram !== undefined) { + return inProgram; + } + const relative = path.relative(pathAnchor, absolutePath); + if (relative.startsWith('..') || path.isAbsolute(relative) || /\.d\.(m|c)?ts$/.test(relative) + || relative.split(path.sep).some((segment) => excludes.has(segment)) + || !fs.existsSync(absolutePath)) { + return undefined; + } + return moduleHashFor(toRelative(pathAnchor, absolutePath), baseMservPath, serviceVersionLinkHash); + }; + + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): WorkspaceModule | undefined => { + if (workspacePackages.size === 0) { + return undefined; + } + if (!workspaceResolutions.has(specifier)) { + workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, sourceModuleHashOf)); + } + return workspaceResolutions.get(specifier); + }; + + return { toProjectRelative, sourceModuleHashOf, resolveWorkspaceModule }; +} diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index 26287df5..f47f68d6 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -28,12 +28,18 @@ import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extract import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; import { extractTsPackageEntries } from '@/parsers/typescript/ts-package-entry-extractor'; import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; -import { WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; import { TsConfigResolver } from '@/parsers/typescript/tsconfig-resolver'; +import { + mergeCompleteness as mergeFileCompleteness, + parsePoolJobs, + parseTsFilesInPool, + STREAMED_KEYS, + STREAMED_TABLES, +} from '@/workflows/typescript/ts-parse-pool'; +import { tsResolutionContext } from '@/workflows/typescript/ts-resolution-context'; import { TsRelationWriter } from './ts-relation-writer'; import { EntityUtils } from '@/utils/entity-utils'; import { isGeneratedOutputDirectory } from '@/utils/generated-output'; -import { stripTsExtension } from '@/parsers/typescript/ts-module-paths'; import { isGitIgnoredDir } from '@/utils/git-ignored'; import { isVueFile, @@ -265,36 +271,21 @@ export class TypeScriptProjectAnalyzer { moduleHashFor(toRelative(pathAnchor, file), options.baseMservPath, serviceVersionLinkHash) ); } - const toProjectRelative = (absolutePath: string): string => - stripExtension(toRelative(pathAnchor, absolutePath)); - // A sibling package imported by its name binds to the source its entry is built - // from. That source may belong to another program under the same anchor, whose - // module hash is the same pure function of its path; a source file outside the - // anchor or under a skipped directory is walked by no program and binds nothing. + // The three closures extraction needs, built from plain values through + // ts-resolution-context.ts — the SAME constructor a parse worker uses, so + // the serial loop and the pool cannot drift. const workspacePackages = this.workspacePackagesAt(pathAnchor); - const sourceModuleHashOf = (absolutePath: string): string | undefined => { - const inProgram = projectModuleHashes.get(absolutePath); - if (inProgram !== undefined) { - return inProgram; - } - const relative = path.relative(pathAnchor, absolutePath); - if (relative.startsWith('..') || path.isAbsolute(relative) || /\.d\.(m|c)?ts$/.test(relative) - || relative.split(path.sep).some((segment) => excludes.has(segment)) - || !fs.existsSync(absolutePath)) { - return undefined; - } - return moduleHashFor(toRelative(pathAnchor, absolutePath), options.baseMservPath, serviceVersionLinkHash); - }; - const workspaceResolutions = new Map(); - const resolveWorkspaceModule = (specifier: string): WorkspaceModule | undefined => { - if (workspacePackages.size === 0) { - return undefined; - } - if (!workspaceResolutions.has(specifier)) { - workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, sourceModuleHashOf)); - } - return workspaceResolutions.get(specifier); + const resolutionInputs = { + pathAnchor, + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + excludes, + projectModuleHashes, }; + const { toProjectRelative, resolveWorkspaceModule } = tsResolutionContext( + resolutionInputs, + workspacePackages + ); // Skips accumulate across the programs one analyzePrograms call drives; a // standalone analyze starts its own list. @@ -329,54 +320,29 @@ export class TypeScriptProjectAnalyzer { const completenessAccumulator = newCompletenessAccumulator(); let analysed = 0; - for (const file of files) { - let sourceText: string; - // the closure walk read this file already; take its text and release it - const prefetched = rootProgram?.texts.get(file); - if (prefetched !== undefined) { - rootProgram!.texts.delete(file); - } - try { - sourceText = prefetched ?? await fsp.readFile(file, 'utf-8'); - } catch (error) { + // One file's outcome on the SERIAL path (AXIOMCODE_PARSE_JOBS=1, or no + // compiled worker): the loop below calls it inline. The pooled path's + // consume mirrors this body over worker-rendered rows, in the same file + // order, so skip order, writer order and output bytes match. + const consumeOutcome = async ( + file: string, + outcome: { readError?: string; extractError?: string; facts?: TsFileFacts } + ): Promise => { + if (outcome.readError !== undefined) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.READ_ERROR, String(error)); - continue; + SkippedFileReason.READ_ERROR, outcome.readError); + return; } - const governing = configResolver.resolve(file); - const script = scriptTextOf(file, sourceText); - let facts: TsFileFacts; - try { - facts = extractTypeScriptFile({ - absoluteFilePath: file, - filePath: toRelative(pathAnchor, file), - baseMservPath: options.baseMservPath, - moduleQualifiedName: toProjectRelative(file), - sourceText: script.text, - scriptKind: script.scriptKind, - serviceVersionLinkHash, - tsConfigPath: governing.configPath === '' - ? '' - : toRelative(pathAnchor, governing.configPath), - moduleResolutionMode: governing.moduleResolutionMode, - // Per file, from the config that actually claims it. `legacy/` in the - // fixture corpus compiles under experimentalDecorators while its - // siblings do not, and the source is identical either way. - decoratorSystem: governing.decoratorSystem, - compilerOptions: governing.options, - packageName: '', - projectModuleHashes, - toProjectRelative, - resolveWorkspaceModule, - }); - } catch (error) { + if (outcome.extractError !== undefined || !outcome.facts) { // An extraction error is a DEFECT, never a decision. Counted apart from // anything else so a parser that throws on every file cannot report a // clean run with empty relations. this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.EXTRACTION_ERROR, String(error)); - continue; + SkippedFileReason.EXTRACTION_ERROR, + outcome.extractError ?? 'worker returned no facts'); + return; } + const facts = outcome.facts; analysed += 1; // Measured HERE, before the row is let go. The measurement was already // per-file: the two indexes it called cross-file were keyed by a file's @@ -405,6 +371,119 @@ export class TypeScriptProjectAnalyzer { await writerFor(TYPESCRIPT_CSV_FILES.FIELD_POSITIONS).append(facts.fieldPositions); await writerFor(TYPESCRIPT_CSV_FILES.COMMENTS).append(facts.comments); await writerFor(TYPESCRIPT_CSV_FILES.PARSE_GAPS).append(facts.parseGaps); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files: + // extraction is per-file by design (cross-file call resolution is the + // engine's; the module link passes run after every file, below), so the + // pool only changes WHERE a file is parsed, never what order its rows are + // consumed in. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path; + // the pool declining (no compiled worker beside this file) falls back to + // it too. + const jobs = parsePoolJobs(files.length); + let pooled = false; + if (jobs > 1) { + pooled = await parseTsFilesInPool( + { + pathAnchor, + baseMservPath: options.baseMservPath, + serviceVersionLinkHash, + excludes: [...excludes], + projectModuleHashes, + }, + files.map((file, i) => { + // the closure walk read this file already; hand its text over + const prefetched = rootProgram?.texts.get(file); + if (prefetched !== undefined) { + rootProgram!.texts.delete(file); + } + return { + i, + file, + sourceText: prefetched, + filePath: toRelative(pathAnchor, file), + moduleQualifiedName: toProjectRelative(file), + }; + }), + jobs, + async (i, outcome) => { + const file = files[i] as string; + if (outcome.readError !== undefined) { + this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, + SkippedFileReason.READ_ERROR, outcome.readError); + return; + } + if (outcome.extractError !== undefined || !outcome.file) { + this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, + SkippedFileReason.EXTRACTION_ERROR, + outcome.extractError ?? 'worker returned no facts'); + return; + } + const pooledFile = outcome.file; + analysed += 1; + // The worker already measured this file; fold its counters, gaps and + // deferred verdicts in, in file order, as the serial call would have. + mergeFileCompleteness(completenessAccumulator, pooledFile.completeness); + moduleGraph.push({ + filePath: pooledFile.filePath, + modules: pooledFile.modules, + imports: pooledFile.imports, + exports: pooledFile.exports, + }); + for (const key of STREAMED_KEYS) { + const rendered = pooledFile.rendered[key]; + if (rendered) { + await writerFor(STREAMED_TABLES[key]).appendRendered(rendered.header, rendered.lines); + } + } + } + ); + } + if (!pooled) { + for (const file of files) { + // the closure walk read this file already; take its text and release it + const prefetched = rootProgram?.texts.get(file); + if (prefetched !== undefined) { + rootProgram!.texts.delete(file); + } + let outcome: { readError?: string; extractError?: string; facts?: TsFileFacts }; + try { + const sourceText = prefetched ?? await fsp.readFile(file, 'utf-8'); + try { + const governing = configResolver.resolve(file); + const script = scriptTextOf(file, sourceText); + outcome = { + facts: extractTypeScriptFile({ + absoluteFilePath: file, + filePath: toRelative(pathAnchor, file), + baseMservPath: options.baseMservPath, + moduleQualifiedName: toProjectRelative(file), + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash, + tsConfigPath: governing.configPath === '' + ? '' + : toRelative(pathAnchor, governing.configPath), + moduleResolutionMode: governing.moduleResolutionMode, + // Per file, from the config that actually claims it. `legacy/` in the + // fixture corpus compiles under experimentalDecorators while its + // siblings do not, and the source is identical either way. + decoratorSystem: governing.decoratorSystem, + compilerOptions: governing.options, + packageName: '', + projectModuleHashes, + toProjectRelative, + resolveWorkspaceModule, + }), + }; + } catch (error) { + outcome = { extractError: String(error) }; + } + } catch (error) { + outcome = { readError: String(error) }; + } + await consumeOutcome(file, outcome); + } } // The MODULE graph is the parser's, and it needs every file: an import of @@ -881,10 +960,6 @@ function pathAnchorFor(rootDir: string, baseMservPath: string): string { return contained ? base : root; } -function stripExtension(relativePath: string): string { - return stripTsExtension(relativePath); -} - /** Re-exported so a caller can create a source file the same way the extractor does. */ export const TYPESCRIPT_SCRIPT_TARGET = ts.ScriptTarget.Latest; From be91d28b78c7619214481a6ae685b107d433cfc0 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:53:47 -0700 Subject: [PATCH 035/133] perf(javascript): the parse stage runs on a pool of worker threads, byte-identical to serial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-file work — read, SFC script split, ts.createSourceFile, the whole extraction spine — now runs on worker threads (js-parse-pool.ts, js-parse-worker.ts), the same pattern Python landed on parse-pool-core. The loop body became consumeOutcome, called in sorted file order by the pool and inline by the serial loop, so skip order, write order and the output bytes do not depend on which worker finishes first. The three skip cases cross as three distinct facts (read error, component with nothing to read, extractor throw), exactly as the serial loop records them. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path, and a run with no compiled worker falls back to it. Two things Python's half did not need. JavaScript extraction consumes a project-wide input, projectModuleHashes — minted from paths alone before any file is parsed, so it is fixed input, not cross-file state — and cloning it into every dispatch would copy the whole map once per file; it crosses once per run instead, through a temporary JSON file whose path rides on each dispatch. And the serial loop's resolver caches (path aliases, workspace packages, the workspace-resolution memo) are pure functions of the filesystem, so each worker rebuilds its own rather than being seeded from main-thread state — seeding would hide an order dependence instead of proving there is none. The governing package.json verdict does ride each dispatch, because the main thread already resolved it for the module-hash mint and the two answers must not be able to disagree. The writers are async where Python's accumulation was not, so the pool path chains outcomes on a tail promise: file order is preserved, the first error is rethrown before the relations publish, and nothing is buffered beyond the dispatch window. No leak this time: the byte gate held on the first run. The one module-level cache in the JavaScript extractor stack is a realpath memo, and it is order-independent, so unlike Python (whose gate caught a byte-range map never reset between files) no extractor state needed resetting and no rows changed: old serial, new serial and pooled IR are identical. On a 6,340-file subject the javascript stage runs 14.4 s serial, 11.1 s at the default 4 jobs, 10.8 s at 6 (medians of three interleaved reps); a 638-file subject runs 4.65 s to 2.96 s. The gain is smaller than Python's because the main thread still thaws and streams every row — the big subject's expressions relation alone is 190 MB — work the serial loop writes without a thread crossing. Pooled max RSS is 1.28 GB / 1.38 GB on the two subjects, inside the default heap with no NODE_OPTIONS. IR is byte-identical between AXIOMCODE_PARSE_JOBS=1 and the pool on both subjects at the default width and at 6 jobs, and across serial reruns. tests/run.py --lang javascript: 296 of 307, and the pristine base commit scores the same 296 with the identical 11 failures (four pre-existing cases about resolution semantics, none touching parse order), so the pool changes no case. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../javascript/javascript-project-analyzer.ts | 160 ++++++++++---- .../src/workflows/javascript/js-parse-pool.ts | 195 ++++++++++++++++++ .../workflows/javascript/js-parse-worker.ts | 133 ++++++++++++ 3 files changed, 445 insertions(+), 43 deletions(-) create mode 100644 parser/src/workflows/javascript/js-parse-pool.ts create mode 100644 parser/src/workflows/javascript/js-parse-worker.ts diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 47a90e4a..eba9bb35 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -12,7 +12,6 @@ import { import { SkippedFileReason } from '@/enums/SkippedFileReason'; import { extractJavaScriptFile, - JsFileFacts, } from '@/parsers/javascript/extractors/js-fact-extractor'; import { accumulateFileCompleteness, @@ -53,6 +52,11 @@ import { JsTypeRegistry } from '@/analysis-types/javascript/JsTypeRegistry'; import { JsVariableRegistry } from '@/analysis-types/javascript/JsVariableRegistry'; import { isGitIgnoredDir } from '@/utils/git-ignored'; import { scriptTextOf } from '@/utils/vue-sfc'; +import { + JsParseOutcome, + parseFilesInPool, + parsePoolJobs, +} from '@/workflows/javascript/js-parse-pool'; /** * Each relation's header, from its registry, so an EMPTY relation still writes @@ -60,7 +64,7 @@ import { scriptTextOf } from '@/utils/vue-sfc'; * constant list — which is why the prototype can answer without a row. */ /** Source extensions a workspace package's entry is mapped back to, in the order they are looked for. */ -const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const; +export const JS_SOURCE_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] as const; const HEADER_BY_FILE: Readonly> = { [JAVASCRIPT_CSV_FILES.MODULES]: JsModuleRegistry.prototype.getCsvHeader(), @@ -442,54 +446,36 @@ export class JavaScriptProjectAnalyzer { try { await writerFor(JAVASCRIPT_CSV_FILES.PACKAGE_ENTRIES).append(packageEntries); - for (const file of files) { - let sourceText: string; - try { - sourceText = await fsp.readFile(file, 'utf-8'); - } catch (error) { + + // One file's outcome, consumed the same way whichever thread produced it. + // The pool calls this in file order as results arrive (never after + // buffering them all — a project's rows fill the heap once, not twice), + // and the serial loop calls it inline, so skip order, write order and + // therefore output bytes are identical across the two paths. + const consumeOutcome = async (file: string, outcome: JsParseOutcome): Promise => { + if (outcome.readError !== undefined) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.READ_ERROR, String(error)); - continue; + SkippedFileReason.READ_ERROR, outcome.readError); + return; } // A component is read once, by `scriptTextOf`: a `.vue` as its virtual // script, a `.svelte`/`.astro` as its JavaScript blocks. One with nothing // this analyzer can read (a lang="ts" Vue script is the TypeScript one's) is a recorded skip. - const script = scriptTextOf(file, sourceText); - if (script.unread !== undefined) { + if (outcome.unread !== undefined) { this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.EMPTY_CONTENT, script.unread); - continue; + SkippedFileReason.EMPTY_CONTENT, outcome.unread); + return; } - const governing = governingByFile.get(file)!; - let facts: JsFileFacts; - try { - facts = extractJavaScriptFile({ - absoluteFilePath: file, - filePath: toRelative(pathAnchor, file), - baseMservPath: baseMservPath, - moduleQualifiedName: toProjectRelative(file), - sourceText: script.text, - scriptKind: script.scriptKind, - serviceVersionLinkHash, - moduleSystem: governing.moduleSystem, - moduleSystemSource: governing.moduleSystemSource, - governingPackageJsonPath: governing.packageJsonPath === '' - ? '' - : toRelative(pathAnchor, governing.packageJsonPath), - packageName: governing.packageName, - compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)), - projectModuleHashes, - toProjectRelative, - resolveWorkspaceModule, - }); - } catch (error) { + if (outcome.extractError !== undefined || outcome.facts === undefined) { // An extraction error is a DEFECT, never a decision. Counted apart // from anything else so a parser that throws on every file cannot // report a clean run with empty relations. this.recordSkip(file, pathAnchor, options, serviceVersionLinkHash, - SkippedFileReason.EXTRACTION_ERROR, String(error)); - continue; + SkippedFileReason.EXTRACTION_ERROR, + outcome.extractError ?? 'worker returned no facts'); + return; } + const facts = outcome.facts; analysed += 1; const module = facts.modules[0]!; if (module.sourceProvenance !== 'PROJECT') { @@ -523,6 +509,94 @@ export class JavaScriptProjectAnalyzer { // measure folds this file into running totals and the objects become // garbage on the next iteration. accumulateFileCompleteness(completeness, facts); + }; + + // THE PER-FILE WORK RUNS ON WORKER THREADS when there are enough files + // (js-parse-pool.ts): read, parse, extract and hash are independent + // between files — `projectModuleHashes` and every resolver cache the + // loop shares were derived from paths alone before any file was parsed, + // so no file's extraction reads another's results. The loop below still + // CONSUMES every outcome in sorted file order through the unchanged + // body. AXIOMCODE_PARSE_JOBS=1 restores the strict serial path; the + // pool declining (no compiled worker) falls back to it too. + const jobs = parsePoolJobs(files.length); + let pooled = false; + if (jobs > 1) { + // The pool's consume callback is synchronous; the body above awaits + // its writers. Outcomes are chained in the file order the pool + // guarantees, so writes land in exactly the serial order, and the + // chain is awaited (and its first error rethrown) before the + // relations publish. After a failure the remaining outcomes are + // dropped unconsumed, as the serial loop drops the files after a + // throw. + let consumeError: unknown; + let tail: Promise = Promise.resolve(); + pooled = await parseFilesInPool( + { + pathAnchor, + baseMservPath, + serviceVersionLinkHash, + excludeDirs: [...excludes], + moduleHashes: [...projectModuleHashes], + }, + files.map((filePath) => ({ filePath, governing: governingByFile.get(filePath)! })), + jobs, + (i, outcome) => { + tail = tail + .then(() => (consumeError === undefined + ? consumeOutcome(files[i]!, outcome) + : undefined)) + .catch((error) => { consumeError = consumeError ?? error; }); + } + ); + await tail; + if (consumeError !== undefined) { + throw consumeError; + } + } + if (!pooled) { + for (const file of files) { + let sourceText: string; + try { + sourceText = await fsp.readFile(file, 'utf-8'); + } catch (error) { + await consumeOutcome(file, { readError: String(error) }); + continue; + } + const script = scriptTextOf(file, sourceText); + if (script.unread !== undefined) { + await consumeOutcome(file, { unread: script.unread }); + continue; + } + const governing = governingByFile.get(file)!; + let outcome: JsParseOutcome; + try { + outcome = { + facts: extractJavaScriptFile({ + absoluteFilePath: file, + filePath: toRelative(pathAnchor, file), + baseMservPath: baseMservPath, + moduleQualifiedName: toProjectRelative(file), + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash, + moduleSystem: governing.moduleSystem, + moduleSystemSource: governing.moduleSystemSource, + governingPackageJsonPath: governing.packageJsonPath === '' + ? '' + : toRelative(pathAnchor, governing.packageJsonPath), + packageName: governing.packageName, + compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)), + projectModuleHashes, + toProjectRelative, + resolveWorkspaceModule, + }), + }; + } catch (error) { + outcome = { extractError: String(error) }; + } + await consumeOutcome(file, outcome); + } } for (const filename of Object.values(JAVASCRIPT_CSV_FILES)) { @@ -631,7 +705,7 @@ export class JavaScriptProjectAnalyzer { * here. A parser running two resolvers and comparing them is doing resolution * work, which is exactly what `js_import.resolverAgreement` was deleted for. */ -function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.CompilerOptions { +export function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.CompilerOptions { return { allowJs: true, target: ts.ScriptTarget.ESNext, @@ -642,7 +716,7 @@ function compilerOptionsFor(moduleSystem: string, aliases: PathAliases): ts.Comp } /** The alias half of a `jsconfig.json` / `tsconfig.json`: nothing else of it is read. */ -type PathAliases = Partial>; +export type PathAliases = Partial>; /** * The `compilerOptions.paths` / `baseUrl` that govern a file, from the nearest @@ -659,7 +733,7 @@ type PathAliases = Partial(); private readonly bundlerByDirectory = new Map(); @@ -844,7 +918,7 @@ function pathAnchorFor(rootDir: string, baseMservPath: string): string { return rootIsInsideBase ? base : rootDir; } -function toRelative(anchor: string, absolutePath: string): string { +export function toRelative(anchor: string, absolutePath: string): string { return path.relative(anchor, absolutePath).split(path.sep).join('/'); } @@ -855,7 +929,7 @@ function toRelative(anchor: string, absolutePath: string): string { * name carried an extension. The directory part is preserved, which is why the * strip is applied to the basename and rejoined rather than to the whole path. */ -function stripExtension(relativePath: string): string { +export function stripExtension(relativePath: string): string { const slash = relativePath.lastIndexOf('/'); const directory = slash < 0 ? '' : relativePath.slice(0, slash + 1); return directory + stripJsExtension(relativePath.slice(slash + 1)); diff --git a/parser/src/workflows/javascript/js-parse-pool.ts b/parser/src/workflows/javascript/js-parse-pool.ts new file mode 100644 index 00000000..d7a99b70 --- /dev/null +++ b/parser/src/workflows/javascript/js-parse-pool.ts @@ -0,0 +1,195 @@ +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { JsBlockRegistry } from '@/analysis-types/javascript/JsBlockRegistry'; +import { JsCallSiteRegistry } from '@/analysis-types/javascript/JsCallSiteRegistry'; +import { JsCommentRegistry } from '@/analysis-types/javascript/JsCommentRegistry'; +import { JsExportRegistry } from '@/analysis-types/javascript/JsExportRegistry'; +import { JsExpressionRegistry } from '@/analysis-types/javascript/JsExpressionRegistry'; +import { JsFieldRegistry } from '@/analysis-types/javascript/JsFieldRegistry'; +import { JsImportRegistry } from '@/analysis-types/javascript/JsImportRegistry'; +import { JsMethodParameterRegistry } from '@/analysis-types/javascript/JsMethodParameterRegistry'; +import { JsMethodRegistry } from '@/analysis-types/javascript/JsMethodRegistry'; +import { JsModuleRegistry } from '@/analysis-types/javascript/JsModuleRegistry'; +import { JsParseGapRegistry } from '@/analysis-types/javascript/JsParseGapRegistry'; +import { JsScopeRegistry } from '@/analysis-types/javascript/JsScopeRegistry'; +import { JsTypeHeritageRegistry } from '@/analysis-types/javascript/JsTypeHeritageRegistry'; +import { JsTypeReferenceRegistry } from '@/analysis-types/javascript/JsTypeReferenceRegistry'; +import { JsTypeRegistry } from '@/analysis-types/javascript/JsTypeRegistry'; +import { JsVariableRegistry } from '@/analysis-types/javascript/JsVariableRegistry'; +import { JsFileFacts } from '@/parsers/javascript/extractors/js-fact-extractor'; +import { GoverningPackageJson } from '@/parsers/javascript/package-json-resolver'; +import { + FrozenTable, + freezeTable, + runParsePool, + thawTable, +} from '@/workflows/parse-pool-core'; +export { parsePoolJobs } from '@/workflows/parse-pool-core'; + +/** + * The JavaScript half of the parallel parse stage: which prototype each + * table's rows get back, and the shape of a dispatch and a reply. Everything + * thread- and shape-related lives in parse-pool-core.ts. + * + * Rows must come back as REAL instances, not snapshots: the writers call + * `toCsv()` on every row, and the completeness measure calls `getHash()` on + * imports and `importLinkHashValue()` on call sites — all prototype methods. + * + * ## Two things Python's half does not have + * + * 1. **A project-wide input.** `extractJavaScriptFile` consumes + * `projectModuleHashes` — one entry per file in the whole analysis, minted + * from paths alone BEFORE any file is parsed, so it is fixed input to the + * pool, not cross-file state. Cloning it into every dispatch would copy the + * whole map once per FILE; instead it crosses once per RUN, through a + * temporary JSON file whose path rides on each dispatch and that each + * worker reads a single time (`JsParseSharedState`). + * 2. **Derived-from-disk inputs.** The alias configs, workspace packages and + * the governing `package.json` are all functions of the filesystem, which + * every worker shares — so each worker rebuilds its own resolver caches + * rather than shipping closures across the thread boundary. The per-file + * CONCLUSION of `PackageJsonResolver` does ride on the dispatch, because + * the main thread has already computed it for the module-hash mint and two + * computations of one answer is one more than needed. + */ +const TABLE_PROTOTYPES = { + modules: JsModuleRegistry.prototype, + scopes: JsScopeRegistry.prototype, + types: JsTypeRegistry.prototype, + heritages: JsTypeHeritageRegistry.prototype, + methods: JsMethodRegistry.prototype, + methodParameters: JsMethodParameterRegistry.prototype, + fields: JsFieldRegistry.prototype, + variables: JsVariableRegistry.prototype, + blocks: JsBlockRegistry.prototype, + expressions: JsExpressionRegistry.prototype, + callSites: JsCallSiteRegistry.prototype, + imports: JsImportRegistry.prototype, + exports: JsExportRegistry.prototype, + comments: JsCommentRegistry.prototype, + typeReferences: JsTypeReferenceRegistry.prototype, + parseGaps: JsParseGapRegistry.prototype, +} as const; + +type TableKey = keyof typeof TABLE_PROTOTYPES; +const TABLE_KEYS = Object.keys(TABLE_PROTOTYPES) as TableKey[]; + +/** + * The run-wide input every file's extraction consumes, written ONCE to a + * temporary JSON file rather than cloned into every dispatch. Everything in + * it is either a scalar of the run or derived from paths alone before any + * file was parsed — nothing in it depends on another file's extraction, which + * is what lets the files parse in any order. + */ +export interface JsParseSharedState { + /** Canonical anchor every emitted path hangs off — see `pathAnchorFor`. */ + pathAnchor: string; + baseMservPath: string; + serviceVersionLinkHash: string; + /** The walk's directory excludes, for the worker's workspace discovery. */ + excludeDirs: string[]; + /** Absolute path -> `js_module` hash, for every file in the analysis. */ + moduleHashes: [string, string][]; +} + +/** What the analyzer sends a worker for one file: strings and one small record. */ +export interface JsParseDispatch { + i: number; + /** Absolute path, read inside the worker. */ + filePath: string; + /** Where this run's `JsParseSharedState` sits; identical on every dispatch. */ + sharedPath: string; + /** + * The governing `package.json`'s verdict, as the main thread resolved it + * for the module-hash mint. Dispatched rather than re-resolved so the hash + * a worker emits and the hash the mint produced cannot disagree. + */ + governing: GoverningPackageJson; +} + +/** One file's outcome: the same four cases the serial loop distinguishes. */ +export interface JsParseOutcome { + readError?: string; + /** `scriptTextOf` found nothing this analyzer can read (a lang="ts" Vue script). */ + unread?: string; + extractError?: string; + facts?: JsFileFacts; +} + +/** The worker's reply: `facts` is the frozen (prototype-less) snapshot. */ +export interface JsParseReply { + i: number; + readError?: string; + unread?: string; + extractError?: string; + facts?: Record; +} + +/** + * Worker side: a fact set as columns structured clone can carry cheaply. + * + * Only the sixteen row tables cross. The linking fields (`declarations`, + * `binder`, `sourceFile`, …) are dropped deliberately: they hold extractor + * instances and `ts.SourceFile`s, which structured clone cannot carry, and + * `JsFileFacts` documents that nothing outside the extractor consumes them — + * the analyzer reads exactly the tables and the module row's own columns. + */ +export function freezeFactSet(facts: JsFileFacts): Record { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = freezeTable(facts[key] as unknown as object[]); + } + return out; +} + +/** Main-thread side: the columns back as rows with each table's prototype. */ +export function thawFactSet(frozen: Record): JsFileFacts { + const out: Record = {}; + for (const key of TABLE_KEYS) { + out[key] = thawTable(frozen[key] as FrozenTable, TABLE_PROTOTYPES[key]); + } + return out as unknown as JsFileFacts; +} + +/** + * Parses every file on `jobs` workers, calling `consume` once per file IN + * FILE ORDER as results become available. `false` means no compiled worker: + * the caller falls back to its serial loop. + */ +export async function parseFilesInPool( + shared: JsParseSharedState, + files: { filePath: string; governing: GoverningPackageJson }[], + jobs: number, + consume: (i: number, outcome: JsParseOutcome) => void +): Promise { + const workerPath = path.join(__dirname, 'js-parse-worker.js'); + // Checked here as well as in the core, because the shared file should not + // be written for a run that is about to decline the pool. + if (!fs.existsSync(workerPath)) { + return false; + } + const sharedPath = path.join( + os.tmpdir(), + `axiomcode-js-parse-${process.pid}-${Date.now()}-${Math.floor(Math.random() * 1e9)}.json` + ); + await fsp.writeFile(sharedPath, JSON.stringify(shared), 'utf-8'); + try { + return await runParsePool( + workerPath, + files.map((file, i) => ({ i, filePath: file.filePath, sharedPath, governing: file.governing })), + jobs, + reply => + consume( + reply.i, + reply.facts + ? { facts: thawFactSet(reply.facts) } + : { readError: reply.readError, unread: reply.unread, extractError: reply.extractError } + ) + ); + } finally { + await fsp.rm(sharedPath, { force: true }); + } +} diff --git a/parser/src/workflows/javascript/js-parse-worker.ts b/parser/src/workflows/javascript/js-parse-worker.ts new file mode 100644 index 00000000..9b0f6981 --- /dev/null +++ b/parser/src/workflows/javascript/js-parse-worker.ts @@ -0,0 +1,133 @@ +import * as fs from 'fs'; +import * as fsp from 'fs/promises'; +import { parentPort } from 'worker_threads'; + +import { extractJavaScriptFile } from '@/parsers/javascript/extractors/js-fact-extractor'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; +import { scriptTextOf } from '@/utils/vue-sfc'; +import { + compilerOptionsFor, + JS_SOURCE_EXTENSIONS, + PathAliasResolver, + stripExtension, + toRelative, +} from '@/workflows/javascript/javascript-project-analyzer'; +import { + freezeFactSet, + JsParseDispatch, + JsParseReply, + JsParseSharedState, +} from '@/workflows/javascript/js-parse-pool'; + +/** + * One parse worker: reads a file, runs the SAME extraction the serial loop + * runs, and posts the fact set back as a prototype-less snapshot + * (`freezeFactSet`). The error cases mirror the serial loop's exactly — a + * read failure, a component with nothing to read, and an extractor throw are + * three different facts, and the analyzer records them under three reasons. + * + * ## The worker's caches are rebuilt from disk, not shipped from the thread + * + * The serial loop reuses one `PathAliasResolver`, one `WorkspacePackages` + * discovery and one memo of workspace resolutions across the whole project. + * Every one of those is a pure function of the filesystem and of + * `projectModuleHashes` — which the main thread minted from paths alone, + * before any file was parsed — so a per-worker rebuild answers identically + * whatever order files reach whichever worker. Seeding them from main-thread + * state instead would HIDE an order dependence rather than prove there is + * none; the byte-gate against the serial run is what proves it. + */ +interface RunContext { + sharedPath: string; + shared: JsParseSharedState; + projectModuleHashes: Map; + pathAliases: PathAliasResolver; + toProjectRelative: (absolutePath: string) => string; + resolveWorkspaceModule: (specifier: string) => string | undefined; +} + +let context: RunContext | undefined; + +/** The run-wide state, loaded once per run (the path is identical on every dispatch). */ +function contextFor(sharedPath: string): RunContext { + if (context !== undefined && context.sharedPath === sharedPath) { + return context; + } + const shared = JSON.parse(fs.readFileSync(sharedPath, 'utf-8')) as JsParseSharedState; + const projectModuleHashes = new Map(shared.moduleHashes); + const excludes = new Set(shared.excludeDirs); + const toProjectRelative = (absolutePath: string): string => + stripExtension(toRelative(shared.pathAnchor, absolutePath)); + // Discovered lazily, as the serial loop's is built once up front: the walk + // only happens at all when a file holds a bare specifier to resolve. + let workspacePackages: WorkspacePackages | undefined; + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): string | undefined => { + workspacePackages ??= WorkspacePackages.discover(shared.pathAnchor, excludes); + if (workspacePackages.size === 0) { + return undefined; + } + if (!workspaceResolutions.has(specifier)) { + workspaceResolutions.set(specifier, workspacePackages.resolve(specifier, + (absolutePath) => projectModuleHashes.get(absolutePath), JS_SOURCE_EXTENSIONS)?.absolutePath); + } + return workspaceResolutions.get(specifier); + }; + context = { + sharedPath, + shared, + projectModuleHashes, + pathAliases: new PathAliasResolver(), + toProjectRelative, + resolveWorkspaceModule, + }; + return context; +} + +const port = parentPort; +if (!port) throw new Error('js-parse-worker must run as a worker thread'); + +port.on('message', (job: JsParseDispatch) => { + void (async () => { + const run = contextFor(job.sharedPath); + let sourceText: string; + try { + sourceText = await fsp.readFile(job.filePath, 'utf-8'); + } catch (error) { + port.postMessage({ i: job.i, readError: String(error) } satisfies JsParseReply); + return; + } + const script = scriptTextOf(job.filePath, sourceText); + if (script.unread !== undefined) { + port.postMessage({ i: job.i, unread: script.unread } satisfies JsParseReply); + return; + } + try { + const facts = extractJavaScriptFile({ + absoluteFilePath: job.filePath, + filePath: toRelative(run.shared.pathAnchor, job.filePath), + baseMservPath: run.shared.baseMservPath, + moduleQualifiedName: run.toProjectRelative(job.filePath), + sourceText: script.text, + scriptKind: script.scriptKind, + serviceVersionLinkHash: run.shared.serviceVersionLinkHash, + moduleSystem: job.governing.moduleSystem, + moduleSystemSource: job.governing.moduleSystemSource, + governingPackageJsonPath: job.governing.packageJsonPath === '' + ? '' + : toRelative(run.shared.pathAnchor, job.governing.packageJsonPath), + packageName: job.governing.packageName, + compilerOptions: compilerOptionsFor( + job.governing.moduleSystem, + run.pathAliases.aliasesFor(job.filePath) + ), + projectModuleHashes: run.projectModuleHashes, + toProjectRelative: run.toProjectRelative, + resolveWorkspaceModule: run.resolveWorkspaceModule, + }); + port.postMessage({ i: job.i, facts: freezeFactSet(facts) } satisfies JsParseReply); + } catch (error) { + port.postMessage({ i: job.i, extractError: String(error) } satisfies JsParseReply); + } + })(); +}); From 93950055aa8eee45983c737e0dabf443f54eb00b Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:58:59 -0700 Subject: [PATCH 036/133] python: an imported module-level instance types its receiver across the module boundary (#1140) Three pieces, one shape: `order_service = OrderService()` in one module, `from svc.order_service import order_service; order_service.cancel(oid)` in another, and the call fell to a name match while the identical call next to the assignment resolved. - parser, import step: when the imported member's name collides with the module's own last segment -- the ordinary singleton idiom, the value named after its module -- the member-vs-submodule fallback suffix-matched the TARGET MODULE ITSELF and resolved the import as MODULE with an empty hash, so the VARIABLE branch (#1143) never ran. A module-level variable now counts as a declared member ahead of the submodule fallback, and a "submodule" that is the target module itself is rejected as the suffix collision it is. - parser, call sites: the per-module local type index is now built for every module before any module resolves call sites, and a binding created by a VARIABLE import copies the exporting binding's inferred type. The ordinary NAME-receiver lookup then resolves calls through the imported instance. - engine: binding_value_type gains the mirror clause -- a name bound by a VARIABLE import takes the type of the binding it names, write-count trade-offs inherited from the exporting side. Suite: 43/43 green; new case 43-module-singleton-import pins both shapes (colliding and non-colliding names) plus a genuine-submodule control, all known_edge. Cases 21 and 42 re-blessed: +1 and +3 known_edge, the untyped_receiver:local_untyped reason disappears, nothing demoted. On three corpus projects the parse-level A/B gains 621 resolved call sites and loses zero; engine A/B on one subject replaces 18 placeholder rows with 24 known_edge rows, solve time unchanged (53s -> 42s). Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 13 +++ .../43-module-singleton-import/src/api.py | 7 ++ .../src/order_service.py | 20 ++++ .../src/pkg/__init__.py | 0 .../src/pkg/tools.py | 5 + .../src/publisher.py | 5 + .../43-module-singleton-import/src/signals.py | 9 ++ .../43-module-singleton-import/src/use_pkg.py | 5 + .../expected/21-url-and-signal-dispatch.edges | 2 +- .../expected/21-url-and-signal-dispatch.tiers | 9 +- .../python/expected/42-signal-forms.edges | 6 +- .../python/expected/42-signal-forms.tiers | 9 +- .../expected/43-module-singleton-import.edges | 6 ++ .../43-module-singleton-import.entries | 1 + .../43-module-singleton-import.framework | 6 ++ .../expected/43-module-singleton-import.tiers | 23 +++++ .../extractors/python-resolution-linker.ts | 93 ++++++++++++++++++- 17 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 graph/test/python/cases/43-module-singleton-import/src/api.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/order_service.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/pkg/__init__.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/publisher.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/signals.py create mode 100644 graph/test/python/cases/43-module-singleton-import/src/use_pkg.py create mode 100644 graph/test/python/expected/43-module-singleton-import.edges create mode 100644 graph/test/python/expected/43-module-singleton-import.entries create mode 100644 graph/test/python/expected/43-module-singleton-import.framework create mode 100644 graph/test/python/expected/43-module-singleton-import.tiers diff --git a/graph/python/engine/expression-resolution/expr-type.dl b/graph/python/engine/expression-resolution/expr-type.dl index 8385f783..30df9f4d 100644 --- a/graph/python/engine/expression-resolution/expr-type.dl +++ b/graph/python/engine/expression-resolution/expr-type.dl @@ -430,6 +430,19 @@ binding_value_type(p, b, t) :- assign_pair(p, tgt, val), expr_type(p, val, t). +// ── AN IMPORTED MODULE-LEVEL VALUE IS THE BINDING IT NAMES (#1140) ─────────── +// `from order_service import order_service` resolves to the exporting module's +// own binding row (import_resolved_target kind VARIABLE), and that binding's +// value type is derived above from its assignment. Without this clause the +// type stopped at the module boundary: the same `order_service.cancel()` was a +// known edge next to the assignment and a name match one import away. The +// write-count trade-offs are inherited, not re-decided -- a multi-write export +// arrives as the union and the tier follows from the target count as usual. +binding_value_type(p, b, t) :- + import_binding(p, b, i), + import_resolved_target(p, "VARIABLE", b2, i), + binding_value_type(p, b2, t). + // ── A CONDITIONAL EXPRESSION IS EITHER BRANCH ──────────────────────────────── // `w = HtmlWriter() if flag else PlainWriter()`. CONDITIONAL_EXPRESSION was present in // the IR with BODY / CONDITION / ORELSE children and NO rule read it, so a ternary had diff --git a/graph/test/python/cases/43-module-singleton-import/src/api.py b/graph/test/python/cases/43-module-singleton-import/src/api.py new file mode 100644 index 00000000..a169f808 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/api.py @@ -0,0 +1,7 @@ +"""Imports the singleton whose name collides with its module's last segment.""" + +from order_service import order_service + + +def cancel_endpoint(oid): + return order_service.cancel(oid) diff --git a/graph/test/python/cases/43-module-singleton-import/src/order_service.py b/graph/test/python/cases/43-module-singleton-import/src/order_service.py new file mode 100644 index 00000000..a3ba1433 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/order_service.py @@ -0,0 +1,20 @@ +"""A module-level singleton named after its own module — the ordinary idiom. + +`from order_service import order_service` must bind the VALUE, not suffix-match +back to this module and report a module import (#1140). +""" + + +class OrderService: + def cancel(self, oid): + return oid + + def refund(self, oid): + return oid + + +order_service = OrderService() + + +def same_module_caller(oid): + return order_service.cancel(oid) diff --git a/graph/test/python/cases/43-module-singleton-import/src/pkg/__init__.py b/graph/test/python/cases/43-module-singleton-import/src/pkg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py b/graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py new file mode 100644 index 00000000..a02ce3d5 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/pkg/tools.py @@ -0,0 +1,5 @@ +"""Control: a GENUINE submodule import must keep resolving as a module.""" + + +def helper(): + return 1 diff --git a/graph/test/python/cases/43-module-singleton-import/src/publisher.py b/graph/test/python/cases/43-module-singleton-import/src/publisher.py new file mode 100644 index 00000000..4466e43e --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/publisher.py @@ -0,0 +1,5 @@ +from signals import order_placed + + +def publish(sender): + return order_placed.send(sender) diff --git a/graph/test/python/cases/43-module-singleton-import/src/signals.py b/graph/test/python/cases/43-module-singleton-import/src/signals.py new file mode 100644 index 00000000..b5bdb0e8 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/signals.py @@ -0,0 +1,9 @@ +"""A module-level value whose name does NOT collide with the module name.""" + + +class Signal: + def send(self, sender): + return sender + + +order_placed = Signal() diff --git a/graph/test/python/cases/43-module-singleton-import/src/use_pkg.py b/graph/test/python/cases/43-module-singleton-import/src/use_pkg.py new file mode 100644 index 00000000..027d3e32 --- /dev/null +++ b/graph/test/python/cases/43-module-singleton-import/src/use_pkg.py @@ -0,0 +1,5 @@ +from pkg import tools + + +def run(): + return tools.helper() diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.edges b/graph/test/python/expected/21-url-and-signal-dispatch.edges index 63aeda15..9d07c714 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.edges +++ b/graph/test/python/expected/21-url-and-signal-dispatch.edges @@ -1,13 +1,13 @@ ambiguous_unknown DECORATOR_APPLICATION handlers. -> - ambiguous_unknown DECORATOR_APPLICATION main. -> - ambiguous_unknown METHOD_CALL main.not_a_signal_send -> - -ambiguous_unknown METHOD_CALL ship.ship_order -> - boundary_lib SIMPLE_CALL main. -> builtin:object.__init__ boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ known_edge DECORATOR_CALL handlers. -> handlers.receiver known_edge DECORATOR_CALL main. -> main.receiver known_edge METHOD_CALL main. -> main.Signal.connect known_edge METHOD_CALL main.place_order -> main.Signal.send +known_edge METHOD_CALL ship.ship_order -> signals.Signal.send known_edge SIMPLE_CALL handlers.on_never_shipped -> handlers._record known_edge SIMPLE_CALL handlers.on_shipped -> handlers._record known_edge SIMPLE_CALL main. -> main.path diff --git a/graph/test/python/expected/21-url-and-signal-dispatch.tiers b/graph/test/python/expected/21-url-and-signal-dispatch.tiers index dff4f56f..56e70052 100644 --- a/graph/test/python/expected/21-url-and-signal-dispatch.tiers +++ b/graph/test/python/expected/21-url-and-signal-dispatch.tiers @@ -1,9 +1,9 @@ distinct call sites emitted: 32 --- by tier: edge ROWS, and the distinct SITES they cover --- - 6 rows 6 sites ambiguous_unknown + 5 rows 5 sites ambiguous_unknown 4 rows 4 sites boundary_lib - 22 rows 22 sites known_edge + 23 rows 23 sites known_edge --- edge rows by call kind --- 4 DECORATOR_APPLICATION @@ -13,14 +13,13 @@ distinct call sites emitted: 32 --- unresolved reasons --- 4 decorator_factory_result_untyped - 1 untyped_receiver:local_untyped 1 untyped_receiver:parameter --- the engine's own conservation ledger --- 32 _total_sites - 6 ambiguous_unknown + 5 ambiguous_unknown 4 boundary_lib - 22 known_edge + 23 known_edge --- reconciling rows against the conserved site count --- edge rows 32 diff --git a/graph/test/python/expected/42-signal-forms.edges b/graph/test/python/expected/42-signal-forms.edges index c42f783d..456f0118 100644 --- a/graph/test/python/expected/42-signal-forms.edges +++ b/graph/test/python/expected/42-signal-forms.edges @@ -1,11 +1,11 @@ ambiguous_unknown DECORATOR_APPLICATION handlers. -> - ambiguous_unknown METHOD_CALL handlers. -> - ambiguous_unknown METHOD_CALL orders.close -> - -ambiguous_unknown METHOD_CALL orders.pay -> - -ambiguous_unknown METHOD_CALL orders.place -> - ambiguous_unknown METHOD_CALL orders.ship -> - -ambiguous_unknown METHOD_CALL orders.void -> - boundary_lib DECORATOR_CALL handlers. -> external:receiver boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ known_edge DECORATOR_CALL handlers. -> handlers.remember known_edge METHOD_CALL orders.not_a_signal -> orders.Outbox.asend +known_edge METHOD_CALL orders.pay -> signals.Signal.asend +known_edge METHOD_CALL orders.place -> signals.Signal.send +known_edge METHOD_CALL orders.void -> signals.Signal.send diff --git a/graph/test/python/expected/42-signal-forms.tiers b/graph/test/python/expected/42-signal-forms.tiers index e28d1d9d..e780b18a 100644 --- a/graph/test/python/expected/42-signal-forms.tiers +++ b/graph/test/python/expected/42-signal-forms.tiers @@ -1,9 +1,9 @@ distinct call sites emitted: 22 --- by tier: edge ROWS, and the distinct SITES they cover --- - 11 rows 11 sites ambiguous_unknown + 8 rows 8 sites ambiguous_unknown 9 rows 9 sites boundary_lib - 2 rows 2 sites known_edge + 5 rows 5 sites known_edge --- edge rows by call kind --- 5 DECORATOR_APPLICATION @@ -14,13 +14,12 @@ distinct call sites emitted: 22 --- unresolved reasons --- 5 decorator_factory_result_untyped 3 untyped_receiver:attribute_object_untyped - 3 untyped_receiver:local_untyped --- the engine's own conservation ledger --- 22 _total_sites - 11 ambiguous_unknown + 8 ambiguous_unknown 9 boundary_lib - 2 known_edge + 5 known_edge --- reconciling rows against the conserved site count --- edge rows 22 diff --git a/graph/test/python/expected/43-module-singleton-import.edges b/graph/test/python/expected/43-module-singleton-import.edges new file mode 100644 index 00000000..c35a14c7 --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.edges @@ -0,0 +1,6 @@ +boundary_lib SIMPLE_CALL order_service. -> builtin:object.__init__ +boundary_lib SIMPLE_CALL signals. -> builtin:object.__init__ +known_edge METHOD_CALL api.cancel_endpoint -> order_service.OrderService.cancel +known_edge METHOD_CALL order_service.same_module_caller -> order_service.OrderService.cancel +known_edge METHOD_CALL publisher.publish -> signals.Signal.send +known_edge METHOD_CALL use_pkg.run -> pkg.tools.helper diff --git a/graph/test/python/expected/43-module-singleton-import.entries b/graph/test/python/expected/43-module-singleton-import.entries new file mode 100644 index 00000000..276e39fa --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.entries @@ -0,0 +1 @@ +── entry_point (0) ── diff --git a/graph/test/python/expected/43-module-singleton-import.framework b/graph/test/python/expected/43-module-singleton-import.framework new file mode 100644 index 00000000..29577b75 --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.framework @@ -0,0 +1,6 @@ +── framework_edge (0) ── +── framework_unjoined (1) ── + 1 signal_dispatch no_receiver +── remote_edge (0) ── +── remote_unserved (0) ── +── remote_unsent (0) ── diff --git a/graph/test/python/expected/43-module-singleton-import.tiers b/graph/test/python/expected/43-module-singleton-import.tiers new file mode 100644 index 00000000..3f658c74 --- /dev/null +++ b/graph/test/python/expected/43-module-singleton-import.tiers @@ -0,0 +1,23 @@ +distinct call sites emitted: 6 + +--- by tier: edge ROWS, and the distinct SITES they cover --- + 2 rows 2 sites boundary_lib + 4 rows 4 sites known_edge + +--- edge rows by call kind --- + 4 METHOD_CALL + 2 SIMPLE_CALL + +--- unresolved reasons --- + (none — every site resolved) + +--- the engine's own conservation ledger --- + 6 _total_sites + 2 boundary_lib + 4 known_edge + +--- reconciling rows against the conserved site count --- + edge rows 6 + minus extra rows from multi-target sites 0 + = tier/site pairs 6 + engine's conserved site total 6 diff --git a/parser/src/parsers/python/extractors/python-resolution-linker.ts b/parser/src/parsers/python/extractors/python-resolution-linker.ts index 798ac85b..0d5c5485 100644 --- a/parser/src/parsers/python/extractors/python-resolution-linker.ts +++ b/parser/src/parsers/python/extractors/python-resolution-linker.ts @@ -283,12 +283,27 @@ export class PythonResolutionLinker { ? undefined : exportsByModule.get(targetModule.qualifiedName)?.get(member) ?? this.followReExport(member, targetModule, exportsByModule, moduleByQualifiedName); - if (declared === undefined || declared === null) { + // A module-level VARIABLE is a member too, and the interpreter's + // member-first order applies to it the same as to a def or a class. + // Without this check, `from svc.order_service import order_service` — + // the ordinary singleton idiom, where the value is named after its + // module — fell into the submodule fallback below, whose findModule + // matches by SUFFIX and so handed back svc.order_service ITSELF: the + // import resolved to MODULE with an empty hash and the VARIABLE + // branch further down never ran (#1140). + const variableMember = + targetModule === undefined + ? undefined + : moduleVariablesByModule.get(targetModule.qualifiedName)?.get(member); + if ((declared === undefined || declared === null) && variableMember === undefined) { const asModule = targetName === null || targetName === '' ? member : `${targetName}.${member}`; const memberModule = this.findModule(asModule, moduleByQualifiedName); - if (memberModule) { + // The module found by suffix must not be the target module itself: + // `from X import Y` never binds X, so a "submodule" that IS X is a + // suffix collision, not an answer. + if (memberModule && memberModule !== targetModule) { record.setResolution(memberModule.moduleHash, PythonImportTargetKind.MODULE, ''); stats.importsResolved += 1; continue; @@ -688,6 +703,20 @@ export class PythonResolutionLinker { } } + // Per-module resolution context, built for EVERY module before ANY module + // resolves its call sites. The split matters for one reason: an imported + // module-level value's type lives in the EXPORTING module's local type + // index, and module order is arbitrary, so typing and resolution cannot + // share one sweep. + const resolutionCtxByModuleHash = new Map; + bindingByScopeAndName: Map; + parentScopeOf: Map; + boundNames: Set; + importedModuleNames: Set; + typesByName: Map; + localTypeByBinding: Map; + }>(); for (const module of modules) { const entityByBinding = new Map(); for (const method of module.methods) { @@ -779,6 +808,66 @@ export class PythonResolutionLinker { mroCache, }); + resolutionCtxByModuleHash.set(module.moduleHash, { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + boundNames, + importedModuleNames, + typesByName, + localTypeByBinding, + }); + } + + // A from-import of a module-level VALUE carries the binding it names + // (#1140, PythonImportTargetKind.VARIABLE) — but the IMPORTING module's + // local type index knew nothing about that binding, so + // `order_service.cancel()` still fell to a name match whenever + // `order_service = OrderService()` lives in another module, while the same + // call in the exporting module resolved. The exporter's own index has + // already typed that binding on the same three grounds any local uses; + // copy the answer onto the import's binding so the ordinary NAME-receiver + // lookup finds it. One hop only, by construction: a re-exported value + // resolves to an import binding, which is never an assigned module-scope + // binding, so it was not given VARIABLE kind in the first place. + for (const module of modules) { + const own = resolutionCtxByModuleHash.get(module.moduleHash); + if (!own) { + continue; + } + for (const record of module.imports) { + if (record.getResolvedTargetKind() !== PythonImportTargetKind.VARIABLE) { + continue; + } + const importBinding = record.getBindingLinkHash(); + const exportedBinding = record.getResolvedTargetHash(); + // An entry that already exists wins: the name is also assigned in this + // module, and that assignment (or its refusal, null) is the local truth. + if (importBinding === '' || exportedBinding === '' || own.localTypeByBinding.has(importBinding)) { + continue; + } + const exporter = resolutionCtxByModuleHash.get(record.getResolvedModuleLinkHash()); + const type = exporter?.localTypeByBinding.get(exportedBinding); + if (type) { + own.localTypeByBinding.set(importBinding, type); + } + } + } + + for (const module of modules) { + const ctx = resolutionCtxByModuleHash.get(module.moduleHash); + if (!ctx) { + continue; + } + const { + entityByBinding, + bindingByScopeAndName, + parentScopeOf, + boundNames, + importedModuleNames, + typesByName, + localTypeByBinding, + } = ctx; for (const callSite of module.callSites) { // Retry anything WITHOUT A HASH, not merely anything UNRESOLVED. The // single-file pass has no module graph, so it can only say IMPORTED for From 1c41ba25183fe9f7d9f97a13040419f834b878d4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:00:53 -0700 Subject: [PATCH 037/133] =?UTF-8?q?path:=20--json=20says=20what=20the=20pr?= =?UTF-8?q?ose=20says=20=E2=80=94=20verified=20and=20the=20unresolved=20co?= =?UTF-8?q?unts=20are=20typed=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two successful path --json answers carried verified: null and bound: null beside prose lines stating the chain was verified and counting its unresolved calls, so an automation had to parse prose. verified is now true/false once hops were checked (null stays 'nothing was checked'); unresolved_inside and unresolved_closure are counted numbers where 0 is a counted zero, distinguishable from missing; bound is the same text the prose prints. Checks compare both formats on one query, at zero and at a nonzero count. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-path | 18 ++++++++++++++---- .../case.json | 9 +++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 749516e6..cd576280 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1736,13 +1736,16 @@ def closure(g, sel, upstream, limit=40, depth=40): # figures on a large tree, which reads as "unknown" and is skipped. The reader can only check the rows # actually in front of them, so the actionable number is the unresolved calls inside THOSE; the closure-wide # figure stays, in parentheses, as the honest total. + RESULT['unresolved_closure'] = u # 0 is a counted zero, not a missing value if u: shown_ids = [m for m, _ in (np_ if upstream else sorted(rows, key=lambda x: (x[1], g.sym[x[0]]['display'], x[0])))[:limit]] un = g.q("SELECT count(*) n FROM unresolved_sites WHERE caller_id IN (%s)" % ','.join('?' * (len(shown_ids) + len(ids))), *shown_ids, *ids)[0]['n'] if shown_ids or ids else 0 - print(f" bound: {un} unresolved call(s) inside the {len(shown_ids)} method(s) printed above" - + (f" ({u} across the whole closure)" if u != un else '') - + f" — the set is a lower bound; `path {sel}` for the chain") + RESULT['unresolved_inside'] = un + RESULT['bound'] = (f"{un} unresolved call(s) inside the {len(shown_ids)} method(s) printed above" + + (f" ({u} across the whole closure)" if u != un else '') + + f" — the set is a lower bound; `path {sel}` for the chain") + print(f" bound: {RESULT['bound']}") print(f" (chain from any one of them: path {sel}" + (")" if upstream else " — or the reverse)")) return 0 @@ -1919,13 +1922,20 @@ def path(g, a, b, show_all=False, limit=10, every=False, max_paths=20): if shown >= limit and not show_all: print(f" … +{len(hits) - shown} more targets (--all)"); break blind = {n for n, _ in (read_back(res['parent'], q, hits[0][0], srcs) or [])} u = g.q(f"SELECT count(*) n FROM unresolved_sites WHERE caller_id IN ({','.join('?' * len(blind))})", *blind)[0]['n'] if blind else 0 + # the JSON says what the prose says: verified true/false once hops were checked (null stays "nothing was + # checked"), and the unresolved count as a number, where 0 is a counted zero. An automation was parsing the + # prose to learn both because the fields sat at null beside a prose line stating them. + RESULT['verified'] = not (vbad or vlen) + RESULT['unresolved_inside'] = u print(f" verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length" if not (vbad or vlen) else f" ✗ verification failed on {vbad} hop(s) / {vlen} length(s) — report this") if seen_tiers: print(" what the hops are:"); [print(l) for l in ax_edges.legend(seen_tiers)] print_boundary(g, [m for m, _ in hits[:limit]], "the target(s) call into libraries", "the target(s) also make") if every: every_route(g, res, q, srcs, dsts, max_paths, adj) else: print(f" (one shortest chain per reached target; --every for all the routes and every method on any of them)") - if u: print(f" bound: the methods on the nearest chain contain {u} unresolved call(s) — other chains may exist that the graph cannot see") + if u: + RESULT['bound'] = f"the methods on the nearest chain contain {u} unresolved call(s) — other chains may exist that the graph cannot see" + print(f" bound: {RESULT['bound']}") return 0 # nothing resolved either way: say so, then whether unresolved sites would connect them print(f"no chain of resolved calls connects {la} and {lb} in either direction (searched {len(g.edges())} edges, depth ≤ 40)") diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json index 3aef6c65..9d30644c 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/case.json +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -25,6 +25,15 @@ {"why": "CONTROL: a caller of a method whose by-name site names ANOTHER method is not behind the target's by-name callers", "run": ["path", "*", "Ledger.settle"], "avoid": ["rollback"]}, + {"why": "--json says what the prose says: verification and the unresolved count are typed fields, 0 a counted zero, never null beside a prose line stating them", + "run": ["path", "nightly", "Ledger.settle", "--json"], "stdout_json": true, + "want": ["\"verified\": true", "\"unresolved_inside\": 0"]}, + {"why": "a chain through a method with an unresolved call types the count and the bound the prose prints", + "run": ["path", "scheduler", "replay", "--json"], "stdout_json": true, + "want": ["\"verified\": true", "\"unresolved_inside\": 1", "\"bound\": \"the methods on the nearest chain contain 1 unresolved call(s)"]}, + {"why": "the closure answer types its verification, the closure-wide unresolved count and the by-name groups", + "run": ["path", "*", "Ledger.settle", "--json"], "stdout_json": true, + "want": ["\"verified\": true", "\"unresolved_closure\": 0", "\"by_name\"", "\"by_name_behind\""]}, {"why": "impact names the same by-name caller", "run": ["impact", "Ledger.settle"], "want": ["[by name] replay", "[resolved] nightly"]}, From a662d1aedcbf4f20f1813f48ac77227f65356cde Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:00:53 -0700 Subject: [PATCH 038/133] =?UTF-8?q?mcp:=20context=20is=20back=20on=20the?= =?UTF-8?q?=20surface=20=E2=80=94=20the=20narrative=20verb=20beside=20find?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface trim folded context into find(question), which answers as ranked places; the flow answer (how does X work, every step in the order the calls are written, source=True with each step's code) had no tool left. context(task, source) joins the surface: five tools, each still at most two parameters and no options. The roster docs, the argument checks and the tools/list assertions carry it. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- bin/axiomcode | 2 +- plugins/axiomcode/AGENTS.md | 4 +++- plugins/axiomcode/mcp/server.py | 13 +++++++++++-- plugins/axiomcode/rules/axiomcode.mdc | 4 +++- plugins/axiomcode/skills/axiomcode/SKILL.md | 3 ++- tests/front_door.py | 4 ++-- tests/mcp.py | 15 ++++++++++----- 7 files changed, 32 insertions(+), 13 deletions(-) diff --git a/bin/axiomcode b/bin/axiomcode index 6b902cf0..6c5b6c94 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── # axiomcode — ask a repository's call graph. `axiomcode --help` prints the dispatcher's help -# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, impact, path and tests. +# (plugins/axiomcode/skills/axiomcode/scripts/axiomcode): index, find, context, impact, path and tests. # ───────────────────────────────────────────────────────────────────────────── # INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which # the dispatcher, the test suites and the agent manifests call. diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index ac48bfde..196e7e41 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -8,9 +8,11 @@ breaks, which tests an edit reaches — ask the repository's call graph FIRST, t impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + context(task) how something works, as a narrative: the call flow step by step; + context(task, source=True) carries each step's code Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, -`axiomcode path
`, `axiomcode tests`. +`axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 2f30e142..d88a1f25 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -313,8 +313,9 @@ def plain(text): return '\n'.join(out) -# THE SMALL SURFACE. Three questions, each answered as numbered places with the code of the function each sits in, so -# a place is understood without opening its file. No options: the repository is the one the session works in. +# THE SMALL SURFACE. Four questions answered as numbered places with the code of the function each sits in, so +# a place is understood without opening its file, plus context, the one narrative verb: a task in words answered +# as the verb's own flow. No options beyond context's source: the repository is the one the session works in. @srv.tool() def find(question: str) -> str: """Where the code for a task lives. Describe what you need in words (the feature, the behaviour, a name you saw); @@ -322,6 +323,14 @@ def find(question: str) -> str: is listed with its call sites: that is code you have to write.""" return plain(run(['find', question, os.getcwd()])) +@srv.tool() +def context(task: str, source: bool = False) -> str: + """How something works, from a task in words: the files and callables the task touches, and for a "how does X + work" question the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a + call. source=True asks for the flow with each step's code, so it is read without opening files. find() answers + the same question as ranked places; this is the verb for the narrative.""" + return plain(run(['context', task] + (['--source'] if source else []) + [os.getcwd()])) + @srv.tool() def impact(name: str = '') -> str: """What a change reaches. With a name (as written in the code: Owner.method, function, Type, or file.py:123): who diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index d77c4ea5..d7226cba 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -13,9 +13,11 @@ breaks, which tests an edit reaches — ask the repository's call graph FIRST, t impact() with no name: the same for your uncommitted edits path(start, end) how A reaches B, every hop of the call chain tests() the tests your uncommitted edits reach, and the command that runs them + context(task) how something works, as a narrative: the call flow step by step; + context(task, source=True) carries each step's code Without the tools, the same from the shell: `axiomcode find ""`, `axiomcode impact `, -`axiomcode path `, `axiomcode tests`. +`axiomcode path `, `axiomcode tests`, `axiomcode context "" --source`. Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters marked `→`: answer from that code, and open a file only where a body was cut. A `resolved` place has diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 3628a493..fb55a99f 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,7 +1,7 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. + Use for any why, what or where question about code — how a codebase works, where something lives, who calls it, what a change to it breaks, which tests cover an edit. Also use when resolving an issue or bug report, which names a symptom rather than a file. Examples: "How does X work?", "Where do I change Y?", "What calls this?", "What breaks if I change Z?", "Which tests do I run?", "Fix this issue". No task is too small: if you are about to grep for a name, call this instead. Mandatory when .axiomcode/out/graph.sqlite exists — start here rather than grep, even when you already know the code. Answers come from a resolved call graph, so they include callers that never spell the name — through an interface, an override, a callback, dependency injection or a config key — and every place comes with the code of the function it sits in. Call the MCP tools directly, no need to load this skill first: find(question) for where the code for a task lives, impact(name) for who calls it and what a change reaches (with no name: your uncommitted edits), path(start, end) for how A reaches B, tests() for the tests your edits reach, context(task, source=True) for how something works as a step-by-step call flow with each step's code. Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, `axiomcode impact `, `axiomcode path `, `axiomcode tests`. Java, TypeScript, Python, JavaScript, C#. --- # axiomcode @@ -17,6 +17,7 @@ Four questions, asked of the repository's call graph. Use the MCP tools when the | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | +| how does this work, start to finish? | `context(task, source=True)` | `axiomcode context "" --source` | Names are written as in the code: `Owner.method`, `function`, `Type`, or `file.py:123` for the declaration at that line. There is no setup step: the first question builds the graph, and it refreshes itself after every edit. diff --git a/tests/front_door.py b/tests/front_door.py index 73f2d034..1855f435 100644 --- a/tests/front_door.py +++ b/tests/front_door.py @@ -10,7 +10,7 @@ a. bin/axiomcode on a small repository (copied to a temporary directory, committed, indexed): find, impact and path answer with numbered places and a fenced code block; after an edit, impact with no name starts with `your edits:`, and tests lists the test with its code and ends with a `run:` line. - b. the MCP server lists exactly find, impact, path and tests, each with at most two parameters, and a call to one + b. the MCP server lists exactly find, context, impact, path and tests, each with at most two parameters, and a call to one answers in the same shape. c. CONTROLS: the dispatcher run directly, bin/axiomcode with --json, and AXIOMCODE_RAW=1 give the old answer — no fenced block — for the same question. @@ -120,7 +120,7 @@ def main(): # ── b. the MCP server ────────────────────────────────────────────────────────────────────────────────────── got = mcp(repo, [('find', {'question': 'how is the invoice total computed'}), ('impact', {'name': 'vat_rate'})]) tools = {t['name']: list((t.get('inputSchema') or {}).get('properties', {})) for t in got.get(2, {}).get('tools', [])} - check('MCP tools/list is exactly find, impact, path and tests', set(tools) == {'find', 'impact', 'path', 'tests'}, tools) + check('MCP tools/list is exactly find, context, impact, path and tests', set(tools) == {'find', 'context', 'impact', 'path', 'tests'}, tools) check('MCP: every tool takes at most two parameters', bool(tools) and all(len(p) <= 2 for p in tools.values()), tools) text = lambda i: ''.join(c.get('text', '') for c in got.get(i, {}).get('content', [])) check('MCP find answers as numbered places with their code', places(text(3)), text(3)[:600]) diff --git a/tests/mcp.py b/tests/mcp.py index 91f08c17..367fc778 100644 --- a/tests/mcp.py +++ b/tests/mcp.py @@ -29,9 +29,10 @@ CLI = os.path.join(ROOT, 'bin', 'axiomcode') LAUNCHER = os.path.join(ROOT, 'bin', 'axiomcode.js') SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') -# THE SMALL SURFACE: four questions, each with at most two parameters and no options. The front-door answer is capped -# at ten places with the rest counted, so no tool is paged. -TOOLS = {'find': ['question'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} +# THE SMALL SURFACE: five questions, each with at most two parameters and no options. The front-door answer is capped +# at ten places with the rest counted, so no tool is paged. context is the one narrative verb: a task in words, +# answered as the verb's own flow rather than as places. +TOOLS = {'find': ['question'], 'context': ['task', 'source'], 'impact': ['name'], 'path': ['start', 'end'], 'tests': []} def exchange(cmd, cwd, env=None, workdir=None): @@ -117,7 +118,9 @@ def check_arguments(label, cmd, cwd, lax=False): # it had been narrowed (#1567): the repository is the session's, and there are no flags ('find', {'question': 'x', 'in_path': 'src'}, 'in_path: unexpected argument'), ('impact', {'name': 'A.f', 'repo': cwd}, 'repo: unexpected argument'), - ('tests', {'why': True}, 'why: unexpected argument')] + ('tests', {'why': True}, 'why: unexpected argument'), + ('context', {}, 'task'), + ('context', {'task': 'x', 'budget': 3}, 'budget: unexpected argument')] for name, args, field in wrong: res = call(cmd, cwd, name, args) text = ' '.join(c.get('text', '') for c in res.get('content', [])) @@ -126,7 +129,8 @@ def check_arguments(label, cmd, cwd, lax=False): bad.append(f"{label}: {name}({json.dumps(args)}) was not refused naming {field!r}: {res}") # the control: every parameter a tool declares still passes, including the ones the CLI's hints name right = [('find', {'question': 'x'}), ('impact', {'name': 'A.f'}), ('impact', {}), - ('path', {'start': 'a', 'end': 'b'}), ('tests', {})] + ('path', {'start': 'a', 'end': 'b'}), ('tests', {}), + ('context', {'task': 'x'}), ('context', {'task': 'x', 'source': True})] for name, args in right: res = call(cmd, cwd, name, args) text = ' '.join(c.get('text', '') for c in res.get('content', [])) @@ -182,6 +186,7 @@ def check_front_door(): try: bad = [] for call, want in ((lambda: server.find('how is a total computed'), ['find', 'how is a total computed', cwd]), + (lambda: server.context('how is a total computed'), ['context', 'how is a total computed', cwd]), (lambda: server.impact('A.f'), ['impact', 'A.f', cwd]), (lambda: server.impact(''), ['impact', cwd]), (lambda: server.impact(), ['impact', cwd]), From 8986b777e6aeb0aabf2b3b09e4aab1e0c5ce4c1f Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:10:27 -0700 Subject: [PATCH 039/133] context: a task in another language names the lexical limit, not a parsing fault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task written in a non-Latin script yielded 'nothing to search for in that task description', which reads as a fault in the task rather than as the stated limit it is: the graph's vocabulary is the code's own identifiers, which are English words and names. The refusal now says only English task words are supported and names the working escape — keep the language and include one identifier as written in the code; a mixed task lands. An English task with no content words keeps the plain line. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-context | 11 ++++++++++- .../path-crosses-framework-and-byname/case.json | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index d7077e55..6b003d54 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -1178,7 +1178,16 @@ def main(argv): print(f" {n} — {len(rs)} call site(s)") for f, l, c, code in rs: print(f" {f}:{l}: {code}" + (f" [in {c}]" if c else '')) terms = task_terms(body) - if not terms: die("nothing to search for in that task description") + if not terms: + # the graph's vocabulary is the code's own identifiers, which are English words and names: a task + # written in another script matches nothing, and "nothing to search for" read as a parsing fault + # rather than a stated limit. A mixed task works — one identifier is enough to land. + if any(ord(c) > 127 and c.isalpha() for c in body or ''): + die("this task is written in a language the index cannot search: only English task words are supported," + " because the graph's vocabulary is the code's own identifiers." + " Rephrase the task in English, or keep your language and include one identifier as written in the" + " code (a mixed task works: the identifier lands it).") + die("nothing to search for in that task description") # what the question names that no graph here holds is said FIRST (#1571), and a scope that exists on disk but holds # no indexed file is a text scope, not a typo (#1382). Only a scope the index does not know is checked on disk. def holds(x): diff --git a/tests/cases/python/path-crosses-framework-and-byname/case.json b/tests/cases/python/path-crosses-framework-and-byname/case.json index 9d30644c..62f0e0e5 100644 --- a/tests/cases/python/path-crosses-framework-and-byname/case.json +++ b/tests/cases/python/path-crosses-framework-and-byname/case.json @@ -34,6 +34,18 @@ {"why": "the closure answer types its verification, the closure-wide unresolved count and the by-name groups", "run": ["path", "*", "Ledger.settle", "--json"], "stdout_json": true, "want": ["\"verified\": true", "\"unresolved_closure\": 0", "\"by_name\"", "\"by_name_behind\""]}, + {"why": "a task written in another language names the lexical limit, not a parsing fault", + "run": ["context", "钱包转账的金额是怎么结算的"], "expect_error": true, + "want": ["only English task words are supported", "a mixed task works"], + "avoid": ["nothing to search for"]}, + {"why": "CONTROL: one identifier lands a task kept in another language", + "run": ["context", "订单 Ledger 怎么结算"], + "want": ["task terms:"], + "avoid": ["only English task words"]}, + {"why": "CONTROL: an English task with no content words keeps the plain empty-terms line", + "run": ["context", "it is"], "expect_error": true, + "want": ["nothing to search for in that task description"], + "avoid": ["only English task words"]}, {"why": "impact names the same by-name caller", "run": ["impact", "Ledger.settle"], "want": ["[by name] replay", "[resolved] nightly"]}, From f030c39c2d65542157c2b1aa48ce59a04a02af23 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:21:01 -0700 Subject: [PATCH 040/133] index: literals carry numbers and booleans, and a constant answers with its value A spec's factual claims are disproportionately numbers -- a retry count, a page size, a timeout, a default -- and the graph could locate every one of them and supply none: the index kept literalType=STRING rows only, and even a string literal was stored as (value, file, line) with no link to the constant it initializes. The parsers were never the gap -- all five emit numeric and boolean literals, and TS/JS emit object keys and values (OBJECT_PROPERTY_KEY / PROPERTY_VALUE roles) -- the rows were dropped at indexing. - literals gains kind ('string' | 'number' | 'bool', normalized across the five per-language enums) and name: the constant a scalar initializes, linked when strictly one literal sits on the declaration line of strictly one const/variable/field/enum_member symbol, with no call on the line, the literal in the language's VALUE position (litLinkRoles -- a subscript's key and a call's argument never link), and no sign-hiding parent (JavaScript's UNARY around -5). Dict passes, not correlated subqueries: the UPDATE form ran 67s against 4.8s on a mid-sized project because the literals indexes do not exist yet at that point; the dict form indexes the same project in 3.8s. - impact: a const target's first line states its value -- `change: const MAX_ITEMS = 5` -- so a limit is read off the answer, not the file. - the consumers that join literals as KEYS (ax_registration, graph_sql's literal fact, context's route sweep) filter kind='string', so a numeric "1" never joins a retry count to a topic; each tolerates a pre-v8 index. - INDEX_VERSION 7 -> 8; freshness re-indexes existing graphs. Verified: new constant-value cases (python + typescript, 8/8, with a call-initializer control); suites python 298/298, java 333/333, csharp 208/208; TS/JS at exact parity with the integration baseline's pre-existing failures (one refresh-banner race reproduced on both sides, passes isolated). On a mid-sized corpus project: 22,155 -> 27,806 literal rows, 793 named, a 10/10 random sample audit against source, and one subscript-key mislink (d["timeout_no_item"] as a field's value) caught and excluded by the role whitelist. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/SKILL.md | 4 +- .../axiomcode/scripts/ax_registration.py | 28 ++++++--- .../axiomcode/scripts/axiomcode-context | 5 +- .../skills/axiomcode/scripts/axiomcode-impact | 24 +++++-- .../skills/axiomcode/scripts/axiomcode-index | 63 ++++++++++++++----- .../skills/axiomcode/scripts/graph_sql.py | 10 ++- tests/cases/python/constant-value/case.json | 16 +++++ .../cases/python/constant-value/src/limits.py | 13 ++++ .../cases/typescript/constant-value/case.json | 16 +++++ .../typescript/constant-value/src/limits.ts | 9 +++ 10 files changed, 154 insertions(+), 34 deletions(-) create mode 100644 tests/cases/python/constant-value/case.json create mode 100644 tests/cases/python/constant-value/src/limits.py create mode 100644 tests/cases/typescript/constant-value/case.json create mode 100644 tests/cases/typescript/constant-value/src/limits.ts diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 2516f40b..ff11bb7b 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -14,6 +14,7 @@ Search with grep as usual; the graph answers what grep cannot. Use the MCP tools |---|---|---| | where is the code for this task? | your own search (grep), then bring the name here | — | | who calls X, what does changing it reach, which tests? | `impact(name)` | `axiomcode impact ` | +| what is the value of constant X, and who reads it? | `impact(name)` | `axiomcode impact ` | | what do my uncommitted edits reach? | `impact()` | `axiomcode impact` | | how does A reach B? | `path(start, end)` | `axiomcode path ` | | which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | @@ -44,7 +45,8 @@ from an empty answer. With a name: who calls it, what depends on it further out, and the tests that exercise it. Example: `impact(name="PriceService.total")`. With no name: the first line is `your edits:` (each declaration you changed and -how), then the same answer for all of them. +how), then the same answer for all of them. A constant answers with its value — `change: const MAX_ITEMS = 5` — +so a limit, a default or a threshold is read off the first line rather than from the file. ## path diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index db6b6a19..6eb56111 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -63,7 +63,7 @@ def registrations(q, site_file=None): sf = site_file or (lambda x: x) lits = {} if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): lits.setdefault((f, l), []).append(v) # the declarations a name identifies uniquely: only those can be named as the registered declaration, because # a site names a VALUE by identifier and two callables of one name would each claim the other's registration @@ -107,7 +107,7 @@ def route_site_lines(q, site_file=None): return {} lits = {} if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): lits.setdefault((f, l), []).append(v) return _route_links(q, site_file or (lambda x: x), lits, set())[2] @@ -628,6 +628,20 @@ def _has(q, t): return bool(q("SELECT 1 FROM sqlite_master WHERE name=?", t)) +def _span_string_literals(q, f, a, b): + """the STRING literals inside one file span, kind-filtered the same way _string_literals is""" + try: return q("SELECT value, file, line FROM literals WHERE file = ? AND line BETWEEN ? AND ? AND kind = 'string'", f, a, b) + except Exception: return q("SELECT value, file, line FROM literals WHERE file = ? AND line BETWEEN ? AND ?", f, a, b) + + +def _string_literals(q): + """the literals rows that are STRINGS, as (value, file, line). A v8 index also carries numbers and + booleans, which are never registration keys and would join everything (`"1"` matches every retry + count); a pre-v8 or degraded index has no kind column, and there every row is a string.""" + try: return q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL AND kind = 'string'") + except Exception: return q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + + # ── the DECORATION path: the key is written at the `@`, and the owner is recorded ──────────────────────────── # `registrations()` above skips DECORATOR_CALL sites deliberately, because a decoration is not a call that hands a # value over. It is the other half of the same idea and it carries BETTER evidence: the index records which @@ -814,7 +828,7 @@ def literal_verbs(q, at, site_file=None): short = re.sub(r'Async$', '', (n or '').split('.')[-1].split('<')[0]).upper() if short in _VERBS: calls[sf(f) if f else ''].append((a, b or a, short)) out = set() - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if not (isinstance(v, str) and v.startswith('/')): continue f2 = sf(f) if f else '' hold = [(b - a, -a, verb) for a, b, verb in calls.get(f2, ()) if a <= l <= b] @@ -857,7 +871,7 @@ def value_route_registrations(q, site_file=None): names_at.setdefault((f, l), set()).update(routed[(f, n)]) import re out = [] - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if not (isinstance(v, str) and 0 < len(v) < 160): continue # A RESOURCE IS NOT A ROUTE. Measured on the JVM parser: the pair fired on @@ -1118,7 +1132,7 @@ def written(text, token): if not _KEY_POS.match(text, mm.end()): return True return False if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + for v, f, l in _string_literals(q): if v not in keys or (f, l) in cpos: continue L = read(f) text = L[l - 1] if L and l <= len(L) else None @@ -1187,7 +1201,7 @@ def key_writes(q, table_keys=None): the table's own key position or the constant's declaration).""" if table_keys is None: table_keys = {r[4] for r in table_registrations(q)} - rows = [(v, f, l) for v, f, l in q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL") + rows = [(v, f, l) for v, f, l in _string_literals(q) if v not in table_keys] if _has(q, 'literals') else [] return rows + table_key_writes(q, table_keys) @@ -1313,6 +1327,6 @@ def _sends_request(q, m): ph = ','.join('?' * len(REQUEST_CALLS)) if q(f"SELECT 1 FROM call_sites WHERE caller_id = ? AND callee_name IN ({ph}) LIMIT 1", m, *sorted(REQUEST_CALLS)): return True if _has(q, 'literals'): - for (v,) in q("SELECT value FROM literals WHERE file = ? AND line BETWEEN ? AND ?", f, a, b): + for v, _f, _l in _span_string_literals(q, f, a, b): if isinstance(v, str) and re.fullmatch(r'/[\w\-./{}:%]*', v): return True return False diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context index 9bd4561d..d75c967a 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-context @@ -411,7 +411,10 @@ def route_seeds(g, text): for r in g.q("SELECT owner_id, text, file, line FROM decorations WHERE text LIKE '%/%' OR text LIKE '%\"%'"): for v in re.findall(r'"([^"]*)"', r['text'] or ''): lits.append((v, r['file'], r['line'], r['owner_id'])) if asked and g.has('literals'): - for r in g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40"): + # strings only: a route is never a number, and the v8 index carries numbers and booleans too + try: lit_rows = g.q("SELECT value, file, line FROM literals WHERE (value LIKE '%/%' OR length(value) < 40) AND kind = 'string'") + except Exception: lit_rows = g.q("SELECT value, file, line FROM literals WHERE value LIKE '%/%' OR length(value) < 40") + for r in lit_rows: lits.append(((r['value'] or '').strip('"'), r['file'], r['line'], None)) by_file = collections.defaultdict(set) for v, f, _l, _o in lits: by_file[f].add(tuple(_route_parts(v))) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 2e030faa..7a8dc195 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -464,7 +464,7 @@ class Impact: if d and d[0] == 'field' and kind in (None, 'field'): row = self.fields.get(d[1]['rowid']) or d[1] self.WHY['field'] = {'step': 'file and line: a field declared there', 'won': f"the line declares {row['display']} and no callable of its own", 'ids': [], 'rows': [(row['display'], f"{row['file']}:{row['line']}")]} - return [('field', f"{row['kind']} {row['display']} (at {s})", [row])] + return [('field', f"{row['kind']} {row['display']}{self.const_value(row)} (at {s})", [row])] if d and d[0] == 'type' and kind in (None, 'type'): self.WHY['type'] = {'step': 'file and line: a type declared there', 'won': f"the line is the header of type {d[1]['display']}", 'ids': [d[1]['id']], 'rows': None} return [('type', f"{d[1]['kind']} {d[1]['display']} (at {s})", [d[1]['id']])] @@ -532,7 +532,7 @@ class Impact: if hit: return [('method', verb + (f" ({len(hit)} declarations)" if len(hit) > 1 else ''), [x['id'] for x in hit])] # …and a private FIELD (`Vault.#secret`, `#secret`) the same way: rewritten, it asked for `Vault..secret` rows = self.field_rows(verb) if kind in (None, 'field') and re.search(r'(^|\.)#[A-Za-z_$]', verb) else [] - if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] + if rows: return [('field', f"{rows[0]['kind']} {rows[0]['display']}{self.const_value(rows[0])}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)] base = re.sub(r'\(.*\)$', '', s).replace('#', '.').strip('.') if not (self.field_rows(base) or self.types(base, soft=True) or self.methods(base, soft=True)): base = base.replace('$', '.') # Outer$Inner — unless $ is part of the name ($Gson$Types) m0 = re.match(r'^(.+)\.$', base) @@ -552,7 +552,7 @@ class Impact: if kind in (None, 'field'): rows = self.field_rows(base) if rows: self.WHY['field'] = self.why_field(base, rows) - if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) + if rows: out.append(('field', f"{rows[0]['kind']} {rows[0]['display']}{self.const_value(rows[0])}" + (f" (+{len(rows)-1} declarations of that name)" if len(rows) > 1 else ''), rows)) if kind in (None, 'type'): tids = self.types(base, soft=True) if tids: self.WHY['type'] = self._why_t.get(base) @@ -692,6 +692,16 @@ class Impact: f['file'], r['line'], f['line'], f['line']): continue return True return False + def const_value(self, row): + """`` = 5`` — the scalar the v8 index linked to this declaration's line (literals.name), so the + answer to `impact MAX_ITEMS` states the value a spec would print, not only who reads it. Empty + on a pre-v8 index, a non-scalar initializer, or any line the linker refused as ambiguous.""" + try: + for v, k in self.g.q("SELECT value, kind FROM literals WHERE name = ? AND file = ? AND line = ? LIMIT 1", + row['display'], row['file'], row['line']): + return f" = '{v}'" if k == 'string' else f" = {v}" + except Exception: pass + return '' def field_rows(self, s): # `file.js:12` — the field or const DECLARED on that line. Split on `.` it named a field `js:12`, so a const was # the one declaration a file:line could not target, and its bare name answered for every const so named @@ -3077,8 +3087,12 @@ def main(argv): if tests_only: sys.stdout = io.StringIO() for k, lab, pay in targets: if k in ('method', 'field', 'type'): - nm = lab.split()[-1].split('(')[0].split('.')[-1] - n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) if g.has('literals') else 0 + # a const's label carries its value (`const MAX_ITEMS = 5`): the name is the word before `=` + nm = lab.split(' = ')[0].split()[-1].split('(')[0].split('.')[-1] + if g.has('literals'): + try: n = len(g.q("SELECT 1 FROM literals WHERE value = ? AND kind = 'string'", nm)) + except Exception: n = len(g.q("SELECT 1 FROM literals WHERE value = ?", nm)) + else: n = 0 d = len(g.q("SELECT 1 FROM decorations WHERE text LIKE ?", f'%"{nm}"%')) if g.has('decorations') else 0 if n + d > 1: print(f" the name {nm} is also written as a string in {n + d} place(s) ({STRING_BINDS.get(target_ext(g, pay), STRING_BINDS[''])}): ask for it quoted, `impact '\"{nm}\"'`, to get those") break diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 2f08ca83..c3905612 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -7,7 +7,9 @@ Adds to /.axiomcode/out/graph.sqlite, idempotently: function | method | constructor | class | interface | enum | enum_member | type | namespace | const | variable | field | module refs every place an identifier or member is USED (the question grep answers) - literals string literals; comments comments and docstrings + literals string, number and boolean literals, with kind ('string'|'number'|'bool') and, when exactly + one scalar sits on a declaration's line, the NAME of the const/field it initializes — so + "what is MAX_ITEMS" is answerable; comments comments and docstrings nesting (type, outer type) recovered by line containment — Java's qualified_name drops the outer skipped files the parser skipped (so `find` can say "exists, not indexed" instead of nothing) index_meta what was indexed, from where, and whether the IR was available @@ -20,10 +22,12 @@ come from methods/types alone, refs/literals/comments are empty, and index_meta """ import csv, os, re, sqlite3, sys, time, glob, collections, functools csv.field_size_limit(10**9) -INDEX_VERSION = '7' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; +INDEX_VERSION = '8' # bump when the tables' CONTENT changes shape (v2: JavaScript arrows named after their variable; v3: paths table, sites view, no variable row for a bound function; # v5: JavaScript fields owned by their class, computed-key members named by their key, anonymous class expressions named by their binding; # v6: TypeScript class-property arrows named after their field; - # v7: data keys of module-level const objects declared, Object.freeze seen through); the query + # v7: data keys of module-level const objects declared, Object.freeze seen through; + # v8: literals carry numbers and booleans with a kind column, and the name of the constant + # a scalar initializes — a spec's numbers were the one thing the graph could not answer); the query # frontend re-indexes an older graph when the IR is still there REPO = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 and not sys.argv[1].startswith('-') else os.environ.get('AXIOMCODE_REPO') or '.') @@ -80,7 +84,7 @@ A = { modules=None, expr=dict(file='all-expressions.csv', kind='kind', name='literalValue', line='startLine', fileVia=('types', 'typeRegistryLinkHash'), refKinds={'IDENTIFIER_REFERENCE', 'FIELD_ACCESS', 'CLASS_LITERAL'}, entityKind='referencedEntityKind', - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'INTEGER': 'number', 'LONG': 'number', 'FLOAT': 'number', 'DOUBLE': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('types', 'typeRegistryLinkHash')), # @Annotation(args) on a method or type: the arguments live in a second file keyed by the annotation hash @@ -98,7 +102,7 @@ A = { # the key of an object literal (`{ eventId: … }`) is stored with the entity kind OBJECT_PROPERTY_KEY, not # UNKNOWN: it is never a property ACCESS, so an access the engine bound on the same line is not this name keyRole=dict(role='edgeRole', value='OBJECT_PROPERTY_KEY'), - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'NUMBER': 'number', 'BIGINT': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-typescript-comments.csv', text='commentText', kind='commentKind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-typescript-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'tsModuleLinkHash')), # the variable's own hash: the engine's field_access names a module variable by it when an identifier the binder @@ -125,7 +129,7 @@ A = { modules=dict(file='all-python-modules.csv', id='pyModuleUniqueHash', filePath='filePath'), expr=dict(file='all-python-expressions.csv', kind='kind', name=('NAME_REFERENCE:literalValue', 'ATTRIBUTE_ACCESS:dottedPath'), line='startLine', fileVia=('modules', 'pyModuleLinkHash'), refKinds={'NAME_REFERENCE', 'ATTRIBUTE_ACCESS'}, entityKind='referencedEntityKind', - litKinds={'LITERAL'}, litType=('literalType', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalType', {'STRING': 'string', 'INTEGER': 'number', 'FLOAT': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ASSIGNMENT_VALUE'}), comments=dict(file='all-python-comments.csv', text='text', kind='kind', line='startLine', filePath='filePath'), typeRefs=dict(file='all-python-type-references.csv', name='typeName', context='context', ownerKind='referenceOwnerKind', line='startLine', fileVia=('modules', 'pyModuleLinkHash')), decorations=dict(file='all-python-decorators.csv', name='decoratorName', owner='ownerHash', line='startLine', fileVia=('modules', 'pyModuleLinkHash'), text='fullText'), @@ -137,7 +141,7 @@ A = { modules=dict(file='all-javascript-modules.csv', id='jsModuleUniqueHash', filePath='filePath'), expr=dict(file='all-javascript-expressions.csv', kind='expressionKind', name='name', line='startLine', fileVia=('modules', 'ownerModuleLinkHash'), refKinds={'IDENTIFIER', 'PROPERTY_ACCESS'}, entityKind='referenceKind', - litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='text'), + litKinds={'LITERAL'}, litType=('literalKind', {'STRING': 'string', 'NUMBER': 'number', 'BIGINT': 'number', 'BOOLEAN': 'bool'}), litValue='text', litLinkRoles={'OPERAND', 'ASSIGNMENT_VALUE'}, litLinkBlockKinds={'UNARY'}), comments=dict(file='all-javascript-comments.csv', text='text', kind='commentKind', line='startLine', fileVia=('modules', 'ownerModuleLinkHash')), # a `function f` or `class C` is also a binding (FUNCTION_DECLARATION_HOISTED, CLASS_TDZ), and `const { C } = # require('./m')` is an import in all but syntax (it carries an importLinkHash): none of them is a variable, and @@ -175,7 +179,7 @@ A = { # name elsewhere is a by-name reader whatever its qualifier says, so `_r.Limit` was listed under AuditOptions.Limit memberName=dict(role='edgeRole', value='MEMBER_NAME', parent='parentExpressionHash', id='csExpressionUniqueHash'), # the literal TYPE column is literalKind here, not literalType as in java/typescript/python - litKinds={'LITERAL'}, litType=('literalKind', 'STRING'), litValue='literalValue'), + litKinds={'LITERAL'}, litType=('literalKind', {'STRING': 'string', 'INTEGER': 'number', 'REAL': 'number', 'BOOLEAN': 'bool'}), litValue='literalValue', litLinkRoles={'ROOT'}), comments=dict(file='all-csharp-comments.csv', text='commentText', kind='commentKind', line='startLine', fileVia=('modules', 'csModuleLinkHash')), # type references carry no module link of their own. The owner is a type only for a base-list reference; for # `M()`, `new T()`, a parameter, a return or a field it is the expression, method, parameter or field, so @@ -247,7 +251,7 @@ DROP TABLE IF EXISTS nesting; DROP TABLE IF EXISTS type_refs; DROP TABLE IF EXIS DROP VIEW IF EXISTS callers; DROP VIEW IF EXISTS callees; DROP VIEW IF EXISTS source; DROP VIEW IF EXISTS sites; CREATE TABLE symbols(id TEXT, name TEXT, display TEXT, kind TEXT, qualified_name TEXT, signature TEXT, file TEXT, line INT, end_line INT, owner TEXT, is_test INT, method_id TEXT, type_id TEXT); CREATE TABLE refs(name TEXT, file TEXT, line INT, kind TEXT, entity_kind TEXT); -CREATE TABLE literals(value TEXT, file TEXT, line INT); +CREATE TABLE literals(value TEXT, file TEXT, line INT, kind TEXT, name TEXT); CREATE TABLE comments(text TEXT, file TEXT, line INT, kind TEXT); CREATE TABLE nesting(type_id TEXT, outer_type_id TEXT); CREATE TABLE type_refs(name TEXT, file TEXT, line INT, context TEXT, owner_kind TEXT); @@ -658,15 +662,40 @@ for r in rows(e['file']): if mname and ek in ('', 'UNKNOWN'): ek = child_ek.get(r.get(mname['id'], ''), ek) or ek if kr and ek in ('', 'UNKNOWN') and r.get(kr['role']) == kr['value']: ek = kr['value'] refs.append((n, file_of(r, e), int(r.get(e['line']) or 0), k, ek)) - elif k in e['litKinds'] and r.get(e['litType'][0]) == e['litType'][1]: + elif k in e['litKinds'] and r.get(e['litType'][0]) in e['litType'][1]: + kind = e['litType'][1][r.get(e['litType'][0])] # 'string' | 'number' | 'bool' across every language v = (r.get(e['litValue']) or '') - # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived - # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched - if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') - if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" - if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes - if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0))) -c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?)", lits) + if kind == 'string': + # C# keeps the source token, and the IR writer RFC4180-quotes a field holding a double quote, so `"Fee"` arrived + # as `"""Fee"""` and was stored `""Fee""`: no key, route or member name written in a C# string ever matched + if LANG == 'csharp' and len(v) > 1 and v[0] == '"' == v[-1] and '""' in v: v = v[1:-1].replace('""', '"') + if LANG == 'csharp' and len(v) > 2 and v[0] in '@$' and v[-1] == '"': v = v.lstrip('@$') # @"verbatim", $"interpolated" + if len(v) >= 2 and v[0] in '\'"`' and v[-1] == v[0]: v = v[1:-1] # JavaScript keeps the quotes + if v: lits.append((v[:200], file_of(r, e), int(r.get(e['line']) or 0), kind, r.get('edgeRole', ''))) +# a scalar's NAME: `MAX_ITEMS = 5` — the one literal on a const/variable/field declaration's line IS its value, +# so the row gets the symbol's display and "what is MAX_ITEMS" has an answer with a source location. Strictly: +# one literal, one such symbol, no call on the line (`RETRY = max(3, env())` names no value), the literal in the +# language's VALUE position — litLinkRoles, which keeps a subscript's key (`d["timeout"]`, INDEX_ARGUMENT) and a +# call's argument out — and, where an initializer's sign hides in a parent node (JavaScript's UNARY around `-5`), +# no such node on the line either (litLinkBlockKinds). +# Dict passes, not correlated subqueries: the UPDATE form of this join ran minutes against seconds on a mid-sized +# repository, because at this point the literals indexes do not exist yet. +lit_count = collections.Counter((f, l) for _v, f, l, _k, _r in lits) +decl_at = {} # (file, line) -> the ONE declared name there, or None once a second appears +for d, f, l in c.execute("SELECT display, file, line FROM symbols WHERE kind IN ('const', 'variable', 'field', 'enum_member') AND line > 0"): + decl_at[(f, l)] = None if (f, l) in decl_at else d +call_at = {(rel(f), l) for f, l in c.execute("SELECT DISTINCT file_path, start_line FROM call_sites WHERE file_path IS NOT NULL")} +link_roles = e.get('litLinkRoles') or set() +block_kinds = e.get('litLinkBlockKinds') or set() +blocked_at = set() +if block_kinds: + for r in rows(e['file']): + if r.get(e['kind'], '') in block_kinds: blocked_at.add((file_of(r, e), int(r.get(e['line']) or 0))) +lits = [(v, f, l, k, + (decl_at.get((f, l)) if role in link_roles and l > 0 and lit_count[(f, l)] == 1 + and (f, l) not in call_at and (f, l) not in blocked_at else None)) + for v, f, l, k, role in lits] +c.executemany("INSERT INTO refs VALUES (?,?,?,?,?)", refs); c.executemany("INSERT INTO literals VALUES (?,?,?,?,?)", lits) cm = A['comments'] c.executemany("INSERT INTO comments VALUES (?,?,?,?)", ((r.get(cm['text'], '')[:400], file_of(r, cm), int(r.get(cm['line']) or 0), r.get(cm['kind'], '')) for r in rows(cm['file']) if r.get(cm['text']))) # where a TYPE is used in a declaration or expression — field type, parameter, return, generic argument, `new` — diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index db0458a3..ecf87c75 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -1990,7 +1990,10 @@ def direct_for_string(q, vals, at, rel): """ rows = [] if _has(q, 'literals'): - for v, f, l in q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64"): + # strings only: the v8 index also carries numbers and booleans, and `True` is identifier-shaped + try: lit_rows = q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64 AND kind = 'string'") + except Exception: lit_rows = q("SELECT value, file, line FROM literals WHERE value GLOB '[A-Za-z_]*' AND length(value) < 64") + for v, f, l in lit_rows: if v not in vals or not re.fullmatch(r'[A-Za-z_]\w*', v): continue c = at(f, l) if c: rows.append((c, 'uses', 'names it in a string literal', 'text', rel(f) if f else '', l or 0)) @@ -2473,8 +2476,9 @@ def discriminants(q): def keyed_literals(q, code, at, values): """`keyed_literal(c, k, v, f, l)`: an object literal inside c writes the property `k: 'v'` — the discriminant of a - type it builds without naming it. Read from the literals table, which holds string EXPRESSIONS only (a literal - type `kind: 'X'` in an interface is not there), then confirmed on the line: `node.kind === 'X'` compares and + type it builds without naming it. Read from the literals table — only rows whose value is one of the known + discriminant strings can match, so the numbers a v8 index adds never join (a literal + type `kind: 'X'` in an interface is still not there), then confirmed on the line: `node.kind === 'X'` compares and builds nothing, so it is not a row.""" rows = [] if not values or not _has(q, 'literals'): return rows diff --git a/tests/cases/python/constant-value/case.json b/tests/cases/python/constant-value/case.json new file mode 100644 index 00000000..be534a24 --- /dev/null +++ b/tests/cases/python/constant-value/case.json @@ -0,0 +1,16 @@ +{"lang": "python", "src": "src", + "checks": [ + {"why": "a numeric module constant answers with its value: the number a spec would print", + "run": ["impact", "MAX_ITEMS"], + "want": ["const MAX_ITEMS = 5"]}, + {"why": "a string constant answers with its value, quoted as a string", + "run": ["impact", "TIER_NAME"], + "want": ["const TIER_NAME = 'gold'"]}, + {"why": "a boolean constant answers with its value", + "run": ["impact", "ENABLED"], + "want": ["const ENABLED = True"]}, + {"why": "control: a constant initialized through a CALL names no value — the 3 on that line is an argument, not what RETRY_LIMIT holds", + "run": ["impact", "RETRY_LIMIT"], + "want": ["const RETRY_LIMIT"], + "avoid": ["RETRY_LIMIT = 3", "RETRY_LIMIT = "]} + ]} diff --git a/tests/cases/python/constant-value/src/limits.py b/tests/cases/python/constant-value/src/limits.py new file mode 100644 index 00000000..14202567 --- /dev/null +++ b/tests/cases/python/constant-value/src/limits.py @@ -0,0 +1,13 @@ +MAX_ITEMS = 5 +TIER_NAME = 'gold' +ENABLED = True + + +def pick(n): + return min(n, MAX_ITEMS) + + +RETRY_LIMIT = pick(3) + +WINDOW = ( + 60) diff --git a/tests/cases/typescript/constant-value/case.json b/tests/cases/typescript/constant-value/case.json new file mode 100644 index 00000000..3441f810 --- /dev/null +++ b/tests/cases/typescript/constant-value/case.json @@ -0,0 +1,16 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "a numeric module constant answers with its value: the number a spec would print", + "run": ["impact", "MAX_ITEMS"], + "want": ["const MAX_ITEMS = 5"]}, + {"why": "a string constant answers with its value, quoted as a string", + "run": ["impact", "TIER_NAME"], + "want": ["const TIER_NAME = 'gold'"]}, + {"why": "a boolean constant answers with its value", + "run": ["impact", "ENABLED"], + "want": ["const ENABLED = true"]}, + {"why": "control: a constant initialized through a CALL names no value — the 3 on that line is an argument, not what RETRY_LIMIT holds", + "run": ["impact", "RETRY_LIMIT"], + "want": ["const RETRY_LIMIT"], + "avoid": ["RETRY_LIMIT = 3", "RETRY_LIMIT = "]} + ]} diff --git a/tests/cases/typescript/constant-value/src/limits.ts b/tests/cases/typescript/constant-value/src/limits.ts new file mode 100644 index 00000000..9abed5da --- /dev/null +++ b/tests/cases/typescript/constant-value/src/limits.ts @@ -0,0 +1,9 @@ +export const MAX_ITEMS = 5; +export const TIER_NAME = 'gold'; +export const ENABLED = true; + +export function pick(n: number): number { + return Math.min(n, MAX_ITEMS); +} + +export const RETRY_LIMIT = pick(3); From 95a86dc621b38115a794c78f1bf0d4cd7a419592 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:20:28 -0700 Subject: [PATCH 041/133] surfaces: context is the fifth public verb, with its one option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit pinned the trimmed surface (context internal, no MCP tool, no flag taught anywhere but index's). context moves to PUBLIC — dispatcher help, SKILL.md section in both copies, MCP tool — and --source joins the flags the surface may teach. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/SKILL.md | 8 ++++++++ skills/axiomcode/SKILL.md | 8 ++++++++ tests/surfaces.py | 8 ++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 9cb4c269..04ca8a04 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -57,6 +57,14 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## context + +How something works, from a task in your own words: the files and callables the task touches and, for a +how-does-X-work question, the call flow step by step. Example: `context(task="how is an invoice settled", +source=True)` — source carries each step's code, so the flow is read without opening files. Only English task +words land (the graph's vocabulary is the code's identifiers); any language works once the task includes one +identifier as written in the code. + ## index `axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index ae477b92..38950cb1 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -56,6 +56,14 @@ Example: `path(start="main", end="Ledger.put")`. The tests your uncommitted edits reach, each with its code, and a last line `run: ` that runs exactly those. Example: `tests()`. It is a lower bound: a test reached only through reflection or a service loader is not listed. +## context + +How something works, from a task in your own words: the files and callables the task touches and, for a +how-does-X-work question, the call flow step by step. Example: `context(task="how is an invoice settled", +source=True)` — source carries each step's code, so the flow is read without opening files. Only English task +words land (the graph's vocabulary is the code's identifiers); any language works once the task includes one +identifier as written in the code. + ## index `axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an diff --git a/tests/surfaces.py b/tests/surfaces.py index d7e5cb58..ea2e81f7 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -15,7 +15,7 @@ that is in neither list fails, so exposing one is a decision rather than an accident (#1034). The agent-facing docs (both copies of SKILL.md, AGENTS.md, the Cursor rule, and the README's CLI section) name no old -MCP tool (`axiomcode_context` …) and no flag other than index's. +MCP tool (`axiomcode_context` …) and no flag other than index's setup flags and context's --source. python3 tests/surfaces.py """ @@ -29,17 +29,17 @@ MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -PUBLIC = ['index', 'impact', 'path', 'tests'] +PUBLIC = ['index', 'impact', 'path', 'tests', 'context'] NO_MCP = {'index': 'setup, not a question: the first query through the MCP server builds the graph itself'} # dispatched, not advertised: verb -> why INTERNAL = { 'build': 'the old name of index', - 'context': 'search by task words; the orient hook and the suites call it, no caller-facing surface does', 'changed': 'impact with no name answers the same question at the front door; the edit hooks read it with --json', 'test-impact': 'what tests runs; its flags (--range, --staged, --why, …) serve scripts and the suites', } OLD_TOOLS = re.compile(r'\baxiomcode_(context|impact|path|changed|test_impact|graph|index|diff)\b') -INDEX_FLAGS = {'--lang', '--src', '--library'} +# the flags the surface may teach: index's setup flags, and context's one option (the flow with each step's code) +INDEX_FLAGS = {'--lang', '--src', '--library', '--source'} def dispatched(): From a3ab44a170ae2e3c0ce60b1fd50289b310022cdd Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:32:27 -0700 Subject: [PATCH 042/133] =?UTF-8?q?engine:=20the=20solve=20runs=20in=20par?= =?UTF-8?q?allel=20where=20it=20is=20safe=20=E2=80=94=20linux-x64=20by=20d?= =?UTF-8?q?efault,=202.5x=20on=20a=20large=20subject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Soufflé emits parallel loops only when the program is GENERATED with -j; without it the C++ holds zero parallel sections, so every engine built or shipped before this was sequential by construction. Generation now always asks for the loops, and OpenMP at compile time decides the flavor: a platform without it compiles the same code and runs it serially, byte-for-byte as before. Measured on a 6,139-file Java subject (1.83M expression facts): 147s serial -> 54-59s at -j8, and the outputs are equal as sets — row order shifts between flavors, and the bundle loads rows into sqlite, which keeps no order. The parallel flavor is part of the cache name (-par) and of the engine id (+seqlock-fix-1), so a serial binary is never taken for a parallel one, and no pre-fix cache entry or package survives: every binary from here is built from a PATCHED OptimisticReadWriteLock. The patch (souffle_overlay in run-souffle.sh, the same sed in build-engines.yml): the lock entered its write phase with fetch_or(memory_order_acquire), which lets the write section's data stores become visible BEFORE the version turns odd on a weakly-ordered CPU — a reader then reads a half-mutated node and still passes validate() against the stale even version. On x86 stores never reorder, so the hole is invisible there; on arm64 it produced nondeterministic segfaults in a different rule each run (ThreadSanitizer pinned the races to the btree insert paths). seq_cst on the entry RMW pins the odd version first and costs nothing on x86, where a locked RMW is already a full barrier. What ships parallel: linux-x64 (prebuilt engines carry a .parallel marker; locally compiled ones probe -fopenmp; libgomp links statically so no gcc runtime is required). What stays serial by default: arm64 everywhere, darwin and windows — with the entry fix in, a quiet arm64 machine ran 5/5 clean where it crashed before, but a residual crash mode under memory pressure remains (2/5 under a concurrent compile), so weak-memory CPUs wait for that fix; AXIOM_SOLVE_PARALLEL=1 opts any machine in for experiments and =0 forces serial anywhere. -j is passed on every run (a serial binary ignores it; AXIOMCODE_SOLVE_THREADS caps it, default min(cores, 8)). Gates: the patched parallel binary, 10 runs on the Java subject, every run's output sort-equal to serial; the default (serial) path on darwin re-runs the pipeline with the new id and passes an engine case end to end; goldens compare normalized (sorted, deduplicated), so CI's linux legs validate the parallel flavor continuously. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/build-engines.yml | 21 +++++- graph/pipeline/run-souffle.sh | 103 ++++++++++++++++++++++++++-- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-engines.yml b/.github/workflows/build-engines.yml index bd8b39bf..18f72b14 100644 --- a/.github/workflows/build-engines.yml +++ b/.github/workflows/build-engines.yml @@ -73,7 +73,9 @@ jobs: id="$(bash graph/pipeline/run-souffle.sh --language "$lang" --print-engine-id)" echo "$lang: $id"; printf '%s' "$id" > "gen/$lang.id" bash graph/pipeline/run-souffle.sh --language "$lang" --emit-program "gen/$lang.dl" - souffle -I graph -g "gen/$lang.cpp" "gen/$lang.dl" 2> "gen/$lang.gen.log" || { cat "gen/$lang.gen.log"; exit 1; } + # -j makes souffle EMIT the parallel loops (without it the C++ has none); whether a + # platform's binary runs them in parallel is decided at compile time below. + souffle -I graph -j 8 -g "gen/$lang.cpp" "gen/$lang.dl" 2> "gen/$lang.gen.log" || { cat "gen/$lang.gen.log"; exit 1; } awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "gen/$lang.gen.log" done # the query programs: self-contained (no #include), keyed by dl_program.py itself @@ -89,6 +91,13 @@ jobs: bash .github/scripts/query-smoke.sh expect gen/queries cp .github/scripts/query-smoke.sh gen/queries/smoke.sh cp -r /usr/include/souffle gen/souffle + # the seqlock fix (see souffle_overlay in run-souffle.sh): the write-entry RMW + # must be seq_cst or a weakly-ordered CPU lets data stores pass the version-odd + # store and readers validate garbage. Patched here so every platform's binary + # is built from the same fixed header; the engine id carries +seqlock-fix-1. + sed -i 's/version\.fetch_or(0x1, std::memory_order_acquire)/version.fetch_or(0x1, std::memory_order_seq_cst)/g' gen/souffle/souffle/utility/ParallelUtil.h + grep -q 'fetch_or(0x1, std::memory_order_seq_cst)' gen/souffle/souffle/utility/ParallelUtil.h + ! grep -q 'fetch_or(0x1, std::memory_order_acquire)' gen/souffle/souffle/utility/ParallelUtil.h # one key for the whole set; a partial match restores the previous set echo "key=$(cat gen/*.id gen/queries/*.id | sha256sum | cut -c1-16)" >> "$GITHUB_OUTPUT" # The compile flags live in THIS file and ENGINE_ID does not cover them, so its @@ -135,7 +144,15 @@ jobs: for lang in $LANGUAGES; do if cmp -s "gen/$lang.id" "engines/$lang/ENGINE_ID"; then echo "$lang: cached, rules unchanged"; continue; fi mkdir -p "engines/$lang" - c++ -std=c++17 -O3 -w -static-libstdc++ -static-libgcc -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" + # OpenMP on linux-x64 only: x86-TSO is where the optimistic btree's design + # assumptions hold (a residual arm64 crash mode under memory pressure survives + # the seqlock entry fix — see souffle_overlay in run-souffle.sh), and libgomp + # links STATICALLY so the binary runs on machines with no gcc runtime. The + # .parallel marker beside the binary is what run-souffle.sh reads to pass a + # real -j at run time; arm64 compiles the same patched code sequentially. + OMP=""; case "$(uname -m)" in x86_64) OMP="-fopenmp -Wl,-Bstatic,-lgomp,-Bdynamic";; esac + c++ -std=c++17 -O3 -w $OMP -static-libstdc++ -static-libgcc -I gen "gen/$lang.cpp" -o "engines/$lang/axiomcode-engine-$lang" + [ -n "$OMP" ] && touch "engines/$lang/axiomcode-engine-$lang.parallel" cp "gen/$lang.id" "engines/$lang/ENGINE_ID" done mkdir -p engines/queries diff --git a/graph/pipeline/run-souffle.sh b/graph/pipeline/run-souffle.sh index c26c8774..5bfb4461 100755 --- a/graph/pipeline/run-souffle.sh +++ b/graph/pipeline/run-souffle.sh @@ -318,14 +318,14 @@ engine_id_of(){ done < "$prog" hin="$(mktemp "${TMPDIR:-/tmp}/axiom-engine-id.XXXXXX")" && [ -f "$hin" ] \ || { echo "❌ engine id: mktemp failed" >&2; return 1; } - if ! printf 'souffle=%s\n' "$SOUFFLE_VERSION" > "$hin" || ! cat "$prog" ${incs[@]+"${incs[@]}"} >> "$hin"; then + if ! printf 'souffle=%s+seqlock-fix-1\n' "$SOUFFLE_VERSION" > "$hin" || ! cat "$prog" ${incs[@]+"${incs[@]}"} >> "$hin"; then echo "❌ engine id: writing the hash input failed" >&2; rm -f "$hin"; return 1 fi # The expected size comes from the SOURCE files (wc's last line is their total), not from a # second read through cat, so a cat that loses bytes cannot agree with itself. want="$(wc -c "$prog" ${incs[@]+"${incs[@]}"})"; have="$(wc -c < "$hin")" want="${want##*$'\n'}"; want="${want#"${want%%[![:space:]]*}"}"; want="${want%% *}" - case "$want" in ""|*[!0-9]*) want=-1;; *) want=$(( want + ${#SOUFFLE_VERSION} + 9 ));; esac # 9 = "souffle=" + "\n" + case "$want" in ""|*[!0-9]*) want=-1;; *) want=$(( want + ${#SOUFFLE_VERSION} + 23 ));; esac # 23 = "souffle=" + "+seqlock-fix-1" + "\n" if [ "${have//[[:space:]]/}" != "$want" ]; then echo "❌ engine id: the hash input is ${have//[[:space:]]/} bytes, expected $want (short write)" >&2; rm -f "$hin"; return 1 fi @@ -379,8 +379,87 @@ case "${AXIOM_ENGINE_MARCH:-native}" in portable) MARCH_FLAG=();; *) MARCH_FLAG=("-march=${AXIOM_ENGINE_MARCH:-native}");; esac +# ── PARALLEL SOLVE ──────────────────────────────────────────────────────────── +# Soufflé emits parallel loops only when the program is GENERATED with -j — without it +# the C++ holds zero parallel sections, which is why every engine before this was +# sequential by construction. Compiled WITHOUT OpenMP the same generated code runs +# serially (pfor degrades to for), so generation always asks for the parallel loops and +# OpenMP at COMPILE time decides the flavor. Measured on a 6,139-file Java subject: +# 147s serial -> 59s at -j8; the outputs are equal as sets (row order shifts between +# flavors; the bundle loads rows into sqlite, which keeps no order). +# GATED PER PLATFORM. On darwin-arm64 the parallel RUNTIME segfaults nondeterministically +# — a different rule each crash, g++/libgomp and apple-clang/libomp alike, Soufflé 2.5 +# and master f53dab8 — so Darwin stays serial until upstream fixes it. Linux enables +# OpenMP when its toolchain takes -fopenmp. AXIOM_SOLVE_PARALLEL=0 forces serial +# anywhere; =1 forces the attempt anywhere (still needs a toolchain with -fopenmp). +# The flavor is part of the CACHE NAME, never shared between flavors: the two binaries +# answer with different row orders, and a cache hit must reproduce the flavor that ran +# yesterday, not whichever compiled first. +# THE SEQLOCK FIX (overlay). Soufflé's OptimisticReadWriteLock enters its write phase +# with fetch_or(..., memory_order_acquire): the version-odd store may become visible +# AFTER the write section's data stores on a weakly-ordered CPU, so a reader can read a +# half-mutated node and still pass validate() against the stale even version. On x86's +# TSO stores never reorder, which is why this only ever fired on arm64 (nondeterministic +# segfaults in a different rule each run, any toolchain, Soufflé 2.5 and master alike). +# seq_cst on the entry RMW pins the odd version BEFORE any data store; on x86 a locked +# RMW is already a full barrier, so the change costs nothing there. The engine id carries +# "+seqlock-fix-1", so no unpatched cache entry or package is ever taken for a patched one. +souffle_overlay(){ + local inner="$1" overlay="$CACHE_ROOT/include-seqlock-fix-1" + local hdr="$overlay/souffle/utility/ParallelUtil.h" + if [ ! -f "$hdr" ]; then + rm -rf "$overlay.tmp.$$" + mkdir -p "$overlay.tmp.$$" + cp -R "$inner/." "$overlay.tmp.$$/" || return 1 + local h="$overlay.tmp.$$/souffle/utility/ParallelUtil.h" + [ -f "$h" ] || return 1 + # three write-entry RMWs: start_write (two), try_start_write, try_upgrade_to_write + sed -i.bak 's/version\.fetch_or(0x1, std::memory_order_acquire)/version.fetch_or(0x1, std::memory_order_seq_cst)/g' "$h" && rm -f "$h.bak" + grep -q 'fetch_or(0x1, std::memory_order_seq_cst)' "$h" || return 1 + grep -q 'fetch_or(0x1, std::memory_order_acquire)' "$h" && return 1 + mv "$overlay.tmp.$$" "$overlay" 2>/dev/null || true # a concurrent run may have won; theirs is identical + rm -rf "$overlay.tmp.$$" + fi + [ -f "$hdr" ] && printf '%s' "$overlay" +} +OMP_FLAG=(); PAR_SUFFIX="" +probe_openmp(){ + # the flags this platform needs for a working OpenMP compile, or nothing. + # Linux: -fopenmp everywhere. Darwin: Apple clang only lowers the pragmas with the + # frontend flag plus Homebrew's libomp (-Xpreprocessor defines _OPENMP without + # lowering anything — a silently sequential binary, which is how this stayed hidden). + case "$(uname -s)" in + Linux) + if printf 'int main(){return 0;}' | c++ -fopenmp -x c++ -o /dev/null - 2>/dev/null; then + printf '%s' "-fopenmp"; return 0 + fi ;; + Darwin) + local omp + for omp in /opt/homebrew/opt/libomp /usr/local/opt/libomp; do + [ -f "$omp/lib/libomp.dylib" ] || continue + if printf 'int main(){return 0;}' | c++ -Xclang -fopenmp -I "$omp/include" -L "$omp/lib" -lomp -x c++ -o /dev/null - 2>/dev/null; then + printf '%s' "-Xclang -fopenmp -I $omp/include -L $omp/lib -lomp"; return 0 + fi + done ;; + esac + return 1 +} +# Default: Linux x86_64 only. The seqlock entry fix above repairs the diagnosed +# ordering hole (quiet-machine runs went clean), but a residual crash mode remains on +# arm64 under memory pressure, so weakly-ordered CPUs stay serial by default until it +# is found; AXIOM_SOLVE_PARALLEL=1 opts any machine in for experiments. +case "${AXIOM_SOLVE_PARALLEL:-}" in + 0) ;; + 1) if _OMP="$(probe_openmp)"; then + OMP_FLAG=($_OMP); PAR_SUFFIX="-par" + fi ;; + "") if [ "$(uname -s)" = "Linux" ] && [ "$(uname -m)" = "x86_64" ] && _OMP="$(probe_openmp)"; then + # shellcheck disable=SC2206 — the probe emits simple flags, split wanted + OMP_FLAG=($_OMP); PAR_SUFFIX="-par" + fi ;; +esac EXE=""; case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) EXE=".exe";; esac -BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$EXE" +BIN="$CACHE_DIR/souffle-engine-$LANG_ARG-$ENGINE_ID$PAR_SUFFIX$EXE" # The platform string, in npm's spelling (process.platform-process.arch), because that is # how the engine packages are named: darwin-arm64, linux-x64, linux-arm64, win32-x64. @@ -456,14 +535,16 @@ elif [ -z "$PACKAGED" ] && [ -n "${COMPILE_LOCK:-}" ]; then # line blocks from stderr; on a real failure, dump the full log and fail. c++ -w # silences the deprecation warnings in souffle's own headers. Compile to a .tmp then # atomically rename, so a concurrent/aborted run never leaves a half-written binary. - if ! souffle -I "$SRC" -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then + if ! souffle -I "$SRC" -j 8 -g "$INT/souffle-program.cpp" "$PROG" 2> "$INT/.souffle-gen.log"; then cat "$INT/.souffle-gen.log" >&2; exit 1 fi awk '/No rules\/facts defined/{skip=2;next} skip>0{skip--;next} {print}' "$INT/.souffle-gen.log" >&2 [ -s "$INT/souffle-program.cpp" ] || { echo "❌ souffle wrote no C++ for $PROG" >&2; exit 1; } CXX_PLATFORM="" case "$(uname -s)" in CYGWIN*) CXX_PLATFORM="-Wa,-mbig-obj";; esac - if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} -w $CXX_PLATFORM -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then + OVERLAY="$(souffle_overlay "$INNER" || true)" + [ -n "$OVERLAY" ] || { echo "❌ could not prepare the patched soufflé headers (seqlock fix)" >&2; exit 1; } + if ! c++ -std=c++17 -O3 ${MARCH_FLAG[@]+"${MARCH_FLAG[@]}"} ${OMP_FLAG[@]+"${OMP_FLAG[@]}"} -w $CXX_PLATFORM -I "$OVERLAY" -I "$INNER" "$INT/souffle-program.cpp" -o "$BIN.tmp.$$"; then rm -f "$BIN.tmp.$$"; echo "❌ compiling the engine failed" >&2; exit 1 fi # VERIFY, THEN PUBLISH. The cache entry is trusted by name alone from now on, so nothing may @@ -771,7 +852,15 @@ while [ "$iter" -lt 50 ]; do # the instructions it uses (a shared cache, a CI cache keyed too coarsely), it dies # with SIGILL (exit 132) before solving anything. Never leave it there to kill every # later run the same way: drop the cache entry, so the next run recompiles, and say so. - rc=0; "$BIN" -F "$FACTS" -D "$RAW" || rc=$? + # -j is passed ALWAYS (a serial binary ignores it silently — verified); more than one + # thread only for a binary of the parallel flavor: one this run compiled with OpenMP, + # or a packaged/cached one whose builder left a .parallel marker beside it. + SOLVE_J=1 + if [ -n "$PAR_SUFFIX" ] || [ -f "$BIN.parallel" ]; then + cores="$( (command -v nproc >/dev/null 2>&1 && nproc) || sysctl -n hw.ncpu 2>/dev/null || echo 4 )" + SOLVE_J="${AXIOMCODE_SOLVE_THREADS:-$(( cores < 8 ? cores : 8 ))}" + fi + rc=0; "$BIN" -j "$SOLVE_J" -F "$FACTS" -D "$RAW" || rc=$? if [ "$rc" -ne 0 ]; then if [ "$rc" -eq 132 ] && [ -z "$PACKAGED" ]; then rm -f "$BIN" @@ -810,7 +899,7 @@ done # here (it's in the shared cache), and facts must stay per-run (never shared) so concurrent # analyses of different projects don't collide. Runs only on success (set -e bails earlier # on failure, leaving the facts for debugging). -rm -rf "$FACTS" "$INT/souffle-program.cpp" +[ "${AXIOM_KEEP_FACTS:-0}" = "1" ] || rm -rf "$FACTS" "$INT/souffle-program.cpp" SOLVE_EPOCH=$(date +%s) echo "Elapsed (solve): $((SOLVE_EPOCH-START_EPOCH))s" From 243a4b294db6fc017ddda78a3ec49f2927332938 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:57:30 -0700 Subject: [PATCH 043/133] java rules: a local-variable use resolves to its declaration once, below the typing fixpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five recursive rules carried the same four-atom prefix inline — the LOCAL_VARIABLE reference scan over java_expression, the enclosing method, the (name, method) join to java_local_variable, and the local's initializer. Each sat inside the typing fixpoint, so semi-naive evaluation re-ran that join every iteration: 163 iterations on a large subject, with the planner anchoring on the 1.8M-row expression scan rather than the per-iteration delta. The souffle profile put ~40% of the whole solve inside those five rules, recomputing an answer that is pure syntax and never changes. local_use(use, local) and local_use_init(use, init) now materialize that join once (local-flow.dl; both sit below the fixpoint, since expr_ultimate_method's own small SCC completes first), and the five rules — recv_decl_ref's two var-initializer clauses, generic-chain's expr_arg_binding propagation, external-types' effectively-final flow, and local-flow's own receiver propagation — start from their recursive delta and probe the table. No semantic change, and the gate proves it: every output relation of the old and new engines on a 6,139-file Java subject is BYTE-identical, not merely equal as sets. Measured on that subject, same machine, serial: solve 132.9s -> 94.5s (-29%); this is platform-independent, so it is the serial-platform (darwin, windows, linux-arm64) counterpart of the linux-x64 -j gain, and the two stack. End to end, full index: the subject 191s (parse 35s + solve 114s + bundle 40s + its second language); an 8,576-file multi-module subject 73s all in. tests/run.py --lang java: 333 of 333. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../engine/expression-resolution/expr-type.dl | 10 ++-------- graph/java/engine/resolution/external-types.dl | 5 ++--- graph/java/engine/resolution/generic-chain.dl | 5 +---- graph/java/engine/resolution/local-flow.dl | 18 +++++++++++++++--- graph/java/souffle/decls_all.dl | 2 ++ 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/graph/java/engine/expression-resolution/expr-type.dl b/graph/java/engine/expression-resolution/expr-type.dl index 94366240..054c50df 100644 --- a/graph/java/engine/expression-resolution/expr-type.dl +++ b/graph/java/engine/expression-resolution/expr-type.dl @@ -649,16 +649,10 @@ recv_decl_ref(recv, retRef, recvType) :- applicable_candidate(recv, innerCallee) // declared-type clause above yields a reference carrying no arguments. The initializer's callee // return type carries them. The field analogue is unnecessary: Java has no `var` fields, so a // field always writes its type. -recv_decl_ref(recv, retRef, t) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), - java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr), +recv_decl_ref(recv, retRef, t) :- local_use_init(recv, initExpr), applicable_candidate(initExpr, callee), method_return_type_resolves(_, callee, retRef, t). -recv_decl_ref(recv, retRef, t) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), - java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr), +recv_decl_ref(recv, retRef, t) :- local_use_init(recv, initExpr), applicable_candidate(initExpr, callee), lib_type_reference(_, "METHOD_RETURN", _, _, _, _, _, "0", _, _, _, _, _, _, _, callee, "METHOD", retRef), lib_type_ref_resolves(_, retRef, t). diff --git a/graph/java/engine/resolution/external-types.dl b/graph/java/engine/resolution/external-types.dl index 77697803..4b83b6a1 100644 --- a/graph/java/engine/resolution/external-types.dl +++ b/graph/java/engine/resolution/external-types.dl @@ -178,9 +178,8 @@ expr_type("external", e, x) :- java_expression(_, _, _, _, _, _, _, _, _, _, nam // with `Format source()` unstaged), the local is that external type exactly as `Format f` would // be. Without it the receiver has no external type at all and the call is a declared unknown. // java_local_variable (19): 11 isVarInferred 13 method 18 hash -expr_type("external", e, x) :- java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, e), - expr_ultimate_method("client", e, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, "true", _, m, _, _, _, _, local), +expr_type("external", e, x) :- local_use(e, local), + java_local_variable(_, _, _, _, _, _, _, _, _, _, _, "true", _, _, _, _, _, _, local), local_init_expr(local, init), expr_type("external", init, x). expr_type("external", e, x) :- java_expression("CAST_EXPRESSION", _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e), diff --git a/graph/java/engine/resolution/generic-chain.dl b/graph/java/engine/resolution/generic-chain.dl index 5cf2ba5f..a9e35fae 100644 --- a/graph/java/engine/resolution/generic-chain.dl +++ b/graph/java/engine/resolution/generic-chain.dl @@ -100,10 +100,7 @@ expr_arg_binding(call, nested, nestedParam, arg) :- call_site(call, _, recv), // the method it actually appears in — same guard the recv_decl_ref clauses in expr-type.dl use. // java_expression (25): 11 name 15 referenceKind 25 hash. java_local_variable (19): 1 name 14 method 19 hash. expr_arg_binding(recv, bt, param, arg) :- - java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), - java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr), + local_use_init(recv, initExpr), expr_arg_binding(initExpr, bt, param, arg). // ── INHERITED generics with a CONCRETE super-arg (c9): `class DogBox extends Box`. diff --git a/graph/java/engine/resolution/local-flow.dl b/graph/java/engine/resolution/local-flow.dl index 0d935863..8680ccab 100644 --- a/graph/java/engine/resolution/local-flow.dl +++ b/graph/java/engine/resolution/local-flow.dl @@ -33,6 +33,20 @@ // java_local_variable (19): 0 name 13 method 18 hash // ============================================================================ +// A USE of a local: the LOCAL_VARIABLE reference joined to its declaration, ONCE, below the +// typing fixpoint. These same three atoms sat inline in five recursive rules, and semi-naive +// evaluation re-ran the full java_expression scan every iteration — 163 iterations on a large +// subject, ~40% of the whole solve between them. Hoisted, each of those rules starts from its +// recursive delta and probes this table. +local_use(use, local) :- + java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, use), + expr_ultimate_method("client", use, m), + java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local). +// ...and the uses whose local is initialized by a call, with that initializer. +local_use_init(use, initExpr) :- + local_use(use, local), + java_expression("METHOD_INVOCATION", _, "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, initExpr). + // The initializer expression of a local (LOCAL_VAR_INITIALIZER root, owned by the local). local_init_expr(local, e) :- java_expression(_, "ROOT", "LOCAL_VAR_INITIALIZER", "LOCAL_VARIABLE", _, local, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, _, e). @@ -53,7 +67,5 @@ local_flow_type(local, t) :- local_assign_value(local, e), expr_type(_, e, t). // PROPAGATE: a LOCAL_VARIABLE receiver reference takes the flow-type(s) of its local // (POSITIVE — resolves the concrete method directly; the cap handles the residual fan). expr_type("client", recv, t) :- - java_expression(_, _, _, _, _, _, _, _, _, _, name, _, _, _, "LOCAL_VARIABLE", _, _, _, _, _, _, _, _, _, recv), - expr_ultimate_method("client", recv, m), - java_local_variable(name, _, _, _, _, _, _, _, _, _, _, _, _, m, _, _, _, _, local), + local_use(recv, local), local_flow_type(local, t). diff --git a/graph/java/souffle/decls_all.dl b/graph/java/souffle/decls_all.dl index 57f51bdd..605f86b2 100644 --- a/graph/java/souffle/decls_all.dl +++ b/graph/java/souffle/decls_all.dl @@ -475,6 +475,8 @@ .decl cha_wide_base(c0:symbol) .decl cha_override_low(c0:symbol,c1:symbol) .decl local_init_expr(c0:symbol,c1:symbol) +.decl local_use(c0:symbol,c1:symbol) +.decl local_use_init(c0:symbol,c1:symbol) .decl local_assign_value(c0:symbol,c1:symbol) .decl local_flow_type(c0:symbol,c1:symbol) .decl param_flow_type(c0:symbol,c1:symbol) From 054187edd13487eba682e215578e3150942e42b3 Mon Sep 17 00:00:00 2001 From: Haolei Zhang Date: Wed, 7 Oct 2026 12:51:54 -0700 Subject: [PATCH 044/133] parser: an HTML and CSS front end on tree-sitter, with its specification and torture suites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One web front end reads `.html`/`.htm`/`.xhtml` and `.css` into seventeen relations (`all-html-*.csv`, `all-css-*.csv`), the way the configuration formats are read: every scan target, structurally, nothing rendered. tree-sitter-html 0.23.2 and tree-sitter-css 0.23.0 build the trees; what either grammar rejects but the language allows (an unquoted `url(../x)`, `[a="b" i]`, `@container name`, `& &`, `! important`, `10%, 20%`, `col || td`, `:nth-child(2n of S)`, uppercase end tags, Jinja tags inside a start tag, …) is re-read from the source text and marked recovered, not reported as a gap. Pages: documents with doctype and template dialects, elements as written with XPath-like paths, attributes by kind, class tokens, every URL a page names classified and resolved to a file (honouring ``, for the page's own ` + + + + +
+

Hello

unclosed paragraph + + hero +

+ + + +
+ toggle + template handler + + + + +
+ + + + + diff --git a/parser/src/test-data/web/fixture/site/js/app.js b/parser/src/test-data/web/fixture/site/js/app.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/js/vendor.js b/parser/src/test-data/web/fixture/site/js/vendor.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/page.html b/parser/src/test-data/web/fixture/site/page.html new file mode 100644 index 00000000..9595250c --- /dev/null +++ b/parser/src/test-data/web/fixture/site/page.html @@ -0,0 +1,18 @@ + + +Second + + + + + + +

Second

+ + +self +

A page with an unclosed tag and a stray end tag.

+
+
implied tbody
+ + diff --git a/parser/src/test-data/web/fixture/site/partials/frame.html b/parser/src/test-data/web/fixture/site/partials/frame.html new file mode 100644 index 00000000..323136fb --- /dev/null +++ b/parser/src/test-data/web/fixture/site/partials/frame.html @@ -0,0 +1,6 @@ +
+

{% block title %}Partial{% endblock %}

+ Home + bad style + +
diff --git a/parser/src/test-data/web/fixture/site/static/css/app.css b/parser/src/test-data/web/fixture/site/static/css/app.css new file mode 100644 index 00000000..dd2d582c --- /dev/null +++ b/parser/src/test-data/web/fixture/site/static/css/app.css @@ -0,0 +1,20 @@ +@charset "utf-8"; +/* theme tokens */ +@import url("base.css") layer(base); +@import "missing.css"; +@layer base, components; +:root { --gap: 8px; --brand: #09f; } +.nav > li.item:not(.hidden) a[href^="http" i]:hover::after { color: var(--brand, red); gap: var(--gap) } +#main .card, .card--wide, ul li:nth-child(2n of .item) { background: url(../img/bg.png) no-repeat; animation: spin 1s linear infinite; font-family: "Inter", Segoe UI, sans-serif } +@media (min-width: 40em) { .card { padding: calc(var(--gap) * 2) !important } } +@keyframes spin { from { transform: rotate(0) } 50% { opacity: .5 } to { transform: rotate(1turn) } } +@font-face { font-family: "Inter"; src: url(/fonts/inter.woff2) format("woff2") } +@supports (display: grid) { .grid { display: grid } } +@container sidebar (min-width: 400px) { .card { gap: 1rem } } +.sidebar { container-name: sidebar; container-type: inline-size } +.a { color: blue; .b & { color: red } &:hover { color: green } } +.broken { color: ; } +.title::before, .title:before { content: "" } +* { box-sizing: border-box } +h1 + p ~ em { margin: 0 } +col || td { margin: 0 } diff --git a/parser/src/test-data/web/fixture/site/static/css/base.css b/parser/src/test-data/web/fixture/site/static/css/base.css new file mode 100644 index 00000000..7493ecf6 --- /dev/null +++ b/parser/src/test-data/web/fixture/site/static/css/base.css @@ -0,0 +1 @@ +body { margin: 0; -webkit-font-smoothing: antialiased } diff --git a/parser/src/test-data/web/fixture/site/static/css/empty.css b/parser/src/test-data/web/fixture/site/static/css/empty.css new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/static/css/vendor.min.css b/parser/src/test-data/web/fixture/site/static/css/vendor.min.css new file mode 100644 index 00000000..d030db28 --- /dev/null +++ b/parser/src/test-data/web/fixture/site/static/css/vendor.min.css @@ -0,0 +1 @@ +.a{color:red}.b{color:blue}.c{margin:0;padding:0} diff --git a/parser/src/test-data/web/fixture/site/static/img/hero.png b/parser/src/test-data/web/fixture/site/static/img/hero.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/static/img/logo.png b/parser/src/test-data/web/fixture/site/static/img/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/static/img/sprite.svg b/parser/src/test-data/web/fixture/site/static/img/sprite.svg new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/fixture/site/vendor/sassy.css b/parser/src/test-data/web/fixture/site/vendor/sassy.css new file mode 100644 index 00000000..14a04a99 --- /dev/null +++ b/parser/src/test-data/web/fixture/site/vendor/sassy.css @@ -0,0 +1,3 @@ +$primary: #333; +@mixin flex { display: flex; } +.x { color: $primary; @include flex; } diff --git a/parser/src/test-data/web/torture/css/alt.css b/parser/src/test-data/web/torture/css/alt.css new file mode 100644 index 00000000..97121e94 --- /dev/null +++ b/parser/src/test-data/web/torture/css/alt.css @@ -0,0 +1 @@ +.alt-only { color: red } diff --git a/parser/src/test-data/web/torture/css/app.css b/parser/src/test-data/web/torture/css/app.css new file mode 100644 index 00000000..04e4d5d5 --- /dev/null +++ b/parser/src/test-data/web/torture/css/app.css @@ -0,0 +1,237 @@ +@charset "utf-8"; +/* C01 import chain: app -> tokens -> theme; layered, conditional, missing, absolute, in a layer and with media */ +@import url("tokens.css") layer(tokens); +@import "layers.css"; +@import url(nested.css) supports(selector(&)) screen and (min-width: 1px); +@import "missing.css"; +@import url("https://fonts.example.com/inter.css"); +@import "strings.css" layer(vendor.strings); +@import "fonts.css"; +@import 'selectors.css'; +@import url( "gaps.css" ); +@import "app.css"; /* self import: a cycle for the recursive join */ + +@layer tokens, base, components, utilities; +@namespace svg url(http://www.w3.org/2000/svg); +@namespace url(http://www.w3.org/1999/xhtml); + +/* C02 the class joins the HTML fixtures are built around */ +.card { color: var(--brand); } +.card--wide { max-width: var(--wide, 60rem); } +.is-active { outline: 1px solid var(--brand, var(--fallback-brand, red)); } +.nav > a[href^="#"] { color: var(--anchor); } +.upper { color: red } /* HTML has Upper: no join by case */ +.SHOUT { color: red } /* HTML has SHOUT via CLASS="SHOUT": joins */ +#loud { color: red } /* HTML has ID="LOUD": no join by case */ +#LOUD { color: red } +#padded-id { color: red } /* HTML id=" padded-id ": trimmed on the element row */ +.dup { color: red } +.a\&b { color: red } /* HTML class a&b decodes to a&b */ +.tpl-class, .hbs-class, .ko-class, .srcdoc-class { color: red } /* classes written only inside script/srcdoc text */ +.in-template, .in-template-style { color: red } +.noscript-class, .ie-only, .commented-out, .in-jinja-comment { color: red } +.js-ready, .open { color: red } /* classes only a script adds */ +.never-used-anywhere { color: red } /* dead CSS */ +.div-in-p, .li-one, .li-two, .cell, .after-self-closing-div, .unclosed-at-eof { color: red } +.custom, .customized-builtin, .hidden-attr, .dup-attr { color: red } +.static-class, .item, .wrapper, .ng-static, .vue-static, .alpine-static, .htmx-static { color: red } +.xhtml-class, .cdata-class, .after-xml-self-closing { color: red } +.in-foreign, .in-annotation, .math-class, .nested-rect, .svg-rect, .use-class, .symbol-class { color: red } +.popover, .light-card, .shadow-only, .closed-only, .slotted-item, .default-slotted, .inert-class, .deep { color: red } + +/* C03 structural selectors: the HTML writes no tbody, no html, no head, no body */ +table > tbody > tr > td.cell { color: red } +table tr td.cell { color: blue } +html > body > div.card { color: red } +body > .card { color: red } +ul > li.li-two { color: red } +p > div.div-in-p { color: red } +div > span.after-self-closing-div { color: red } +a a { color: red } + +/* C04 functional pseudo-classes: parts at depth > 0 */ +:is(.card, .nav) .item { color: red } +:where(.card) .item { color: red } +.card:not(.is-active, #section-one) { color: red } +.card:has(> .item:hover, + .nav) { color: red } +li:nth-child(2n + 1 of .li-one, .li-two) { color: red } +:nth-last-child(-n+2) { color: red } +:not(:is(.a, .b)) { color: red } +.card:is(:hover, :focus-visible):not([disabled]) { color: red } +a:not([href]) { color: red } +:host(.featured) { color: red } +:host-context(.dark) .card { color: red } +::slotted(.slotted-item) { color: red } +my-card::part(title) { color: red } +input::placeholder, ::selection, ::backdrop, ::marker, ::file-selector-button, ::highlight(h), ::view-transition-group(root), ::cue(v[voice="a"]) { color: red } +:lang(en), :dir(rtl), :state(checked), :focus-within, :target, :empty, :root, :scope, :defined, :popover-open, :modal, :user-invalid, :placeholder-shown, :indeterminate, :read-write, :autofill, :fullscreen, :picture-in-picture, :playing, :paused, :link, :any-link, :visited, :local-link, :target-within, :current, :past, :future, :active-view-transition { color: red } + +/* C05 attribute selectors: every matcher, flags, quoted/unquoted, namespaced */ +[data-page] { color: red } +[data-page="index"] { color: red } +[data-page=index] { color: red } +[class~="card"] { color: red } +[class*="card"] { color: red } +[class^=card] { color: red } +[class$="wide" i] { color: red } +[class$="wide" s] { color: red } +[id|="section"] { color: red } +[href$=".pdf" i] { color: red } +[xlink|href] { color: red } +[*|href] { color: red } +[|href] { color: red } +a[target][href][rel] { color: red } +[aria-labelledby~="section-one"] { color: red } +[style*="var("] { color: red } +input[type="IMAGE" i] { color: red } + +/* C06 combinators and universal/namespace selectors */ +* { box-sizing: border-box } +*|* { color: red } +svg|* { color: red } +|rect { color: red } +svg|rect.svg-rect { color: red } +h2 + p ~ em > b { color: red } +.a>.b+.c~.d { color: red } +col || td { color: red } +.a .b .c +.d { color: red } +.card/* comment in a selector */.is-active { color: red } +.card, /* comment between selectors */ .nav, { color: red } +.card,, .nav { color: red } +.card . broken { color: red } +.123 { color: red } +.-valid-dash { color: red } +.--double-dash { color: red } +._underscore { color: red } +#1digit { color: red } +.a.b.c.d.e.f.g.h.i.j.k { color: red } +.a#b.c#d { color: red } +div#b.c[x]:hover::before { color: red } +DIV.Card:HOVER::BEFORE { color: red } +.before:before, .after::after, ::before, :after, ::first-line, ::first-letter, :first-letter { color: red } + +/* C07 the cascade torture: same element, same property, many sources */ +#section-one { color: rgb(1, 1, 1) } /* (1,0,0) */ +.card.card--wide.is-active { color: rgb(2, 2, 2) } /* (0,3,0) */ +section.card { color: rgb(3, 3, 3) !important } /* important beats everything unlayered */ +@layer base { .card { color: rgb(4, 4, 4) } section#section-one { color: rgb(5, 5, 5) !important } } +@layer utilities { .card { color: rgb(6, 6, 6) } } +@layer { .card { color: rgb(7, 7, 7) } } /* anonymous layer */ +.card { color: rgb(8, 8, 8) } +.card { color: rgb(9, 9, 9) } /* later in source order wins over the one above */ +:where(.card.card--wide.is-active#section-one) { color: rgb(10, 10, 10) } /* (0,0,0) */ +:is(#section-one, .card) { color: rgb(11, 11, 11) } /* (1,0,0) via :is */ +.card:not(#nope) { color: rgb(12, 12, 12) } /* (1,1,0) via :not */ +[id="section-one"] { color: rgb(13, 13, 13) } /* (0,1,0) attribute, not id */ +@media screen { .card { color: rgb(14, 14, 14) } } +@media print { .card { color: rgb(15, 15, 15) } } +@supports (color: red) { .card { color: rgb(16, 16, 16) } } +@supports not (display: grid) { .card { color: rgb(17, 17, 17) } } +@container sidebar (min-width: 1px) { .card { color: rgb(18, 18, 18) } } +@scope (.card) to (.item) { :scope { color: rgb(19, 19, 19) } .is-active { color: rgb(20, 20, 20) } } +@media (400px <= width <= 700px) { .card { color: rgb(21, 21, 21) } } +@media only screen and (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { .card { color: rgb(22, 22, 22) } } +@media (prefers-color-scheme: dark) { :root { --brand: black } .card { color: var(--brand) } } +@starting-style { .card { opacity: 0 } } +@page :first { margin: 1in } +@page { @top-center { content: "x" } } +@font-feature-values Inter { @styleset { nice-style: 12 } } +@counter-style thumbs { system: cyclic; symbols: "👍"; suffix: " " } +@position-try --fallback { top: anchor(bottom) } +@view-transition { navigation: auto } +@-moz-document url-prefix() { .card { color: red } } +@-webkit-keyframes vendor-spin { from { opacity: 0 } to { opacity: 1 } } +@media screen { @supports (display: grid) { @layer components { .card .item { color: rgb(23, 23, 23) } } } } + +/* C08 properties: custom, vendor, hacks, important variants, duplicates, uppercase, empty */ +.props { + --Brand: red; /* custom properties are case-sensitive: --Brand is not --brand */ + --brand: blue; + --empty:; + --with-brace: { a: b }; + --json: [1, 2, 3]; + --url: url(img/bg.png); + --nested: var(--brand, var(--Brand, var(--missing, red))); + color: var(--Brand); + background: var(--url); + -webkit-transition: all 1s; + -moz-transition: all 1s; + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#80000000'); + *zoom: 1; + _height: 1px; + color: red\9; + width: expression(document.body.clientWidth > 800 ? "800px" : "auto"); + margin: 0 ! important; + padding: 0!important; + color: red !IMPORTANT; + color: red;; + color: ; + : red; + color red; + COLOR: RED; + Color: Red; + display: grid; display: -ms-grid; + grid-template-areas: "a b" "c d"; + content: "a; } b { c: d }"; /* string with declaration-ending characters */ + content: "var(--not-a-var) url(not-a-url.png)"; /* string that looks like functions */ + background: url( "img/bg.png" ); + background: url('img/bg.png?v=1#frag'); + background: url(img/bg.png?v=1#frag); + background: url( img/bg.png ); + background: url("img/space name.png"); + background: url(img/space\ name.png); + background: url(data:image/png;base64,iVBORw0KGgo=); + background: url(//cdn.example.com/x.png); + background: url(/img/bg.png); + background: url(../img/bg.png); + background: url(#inline-ref); + background: url(); + background: url(""), url(img/bg.png), url(img/missing.png); + background: image-set("img/hero.png" 1x, "img/hero@2x.png" 2x); + background: -webkit-image-set(url(img/hero.png) 1x); + background: src("img/bg.png"); + mask: url(img/sprite.svg#mask); + cursor: url(img/cursor.cur) 2 2, url(img/cursor.cur), pointer; + list-style: url(img/bg.png); + border-image: url(img/bg.png) 30 round; + background: URL(img/bg.png); + color: color-mix(in srgb, var(--brand) 50%, var(--Brand)); + width: calc(100% - var(--gap, 8px) * 2); + padding: env(safe-area-inset-top, var(--pad)); + font: italic bold 12px/30px Inter, "Segoe UI", serif; /* font SHORTHAND: family names at the end */ + font: 12px system-ui; + font: menu; + font-family: Inter; + font-family: "Inter", 'Roboto Mono', Segoe UI, ui-sans-serif, sans-serif; + font-family: inherit; + font-family: var(--font); + animation: spin 1s linear infinite, fade 2s; + animation: 1s ease-in-out infinite reverse spin; + animation: 2s steps(4, end) slide; + animation: "quoted-name" 1s; + animation: none; + animation: linear 1s; /* linear is a keyword, not a name */ + animation: var(--anim) 1s; + animation-name: spin, fade, missing-keyframes, vendor-spin; + animation-name: none; + animation-name: ease; /* a keyframes named like a keyword */ + container: sidebar / inline-size; + container: a b / size; + container-name: sidebar other; + container-name: none; + container-type: inline-size; + transition: color var(--duration) var(--easing, ease); + will-change: transform; + content: counter(item) ". "; + grid-area: header; + position-try-fallbacks: --fallback; + anchor-name: --anchor; + position-anchor: --anchor; + view-transition-name: hero; + offset-path: url(#motion-path); + clip-path: url(img/sprite.svg#clip); + shape-outside: url(img/bg.png); + src: url(fonts/inter.woff2); /* src outside @font-face */ +} diff --git a/parser/src/test-data/web/torture/css/base.css b/parser/src/test-data/web/torture/css/base.css new file mode 100644 index 00000000..9a0e91a3 --- /dev/null +++ b/parser/src/test-data/web/torture/css/base.css @@ -0,0 +1,2 @@ +.base-class { color: red } +.card { color: rgb(100,100,100) } diff --git a/parser/src/test-data/web/torture/css/big.css b/parser/src/test-data/web/torture/css/big.css new file mode 100644 index 00000000..217950d0 --- /dev/null +++ b/parser/src/test-data/web/torture/css/big.css @@ -0,0 +1,1500 @@ +.big-0 { color: var(--brand); background: url(img/bg.png) } +.big-1 { color: var(--brand); background: url(img/bg.png) } +.big-2 { color: var(--brand); background: url(img/bg.png) } +.big-3 { color: var(--brand); background: url(img/bg.png) } +.big-4 { color: var(--brand); background: url(img/bg.png) } +.big-5 { color: var(--brand); background: url(img/bg.png) } +.big-6 { color: var(--brand); background: url(img/bg.png) } +.big-7 { color: var(--brand); background: url(img/bg.png) } +.big-8 { color: var(--brand); background: url(img/bg.png) } +.big-9 { color: var(--brand); background: url(img/bg.png) } +.big-10 { color: var(--brand); background: url(img/bg.png) } +.big-11 { color: var(--brand); background: url(img/bg.png) } +.big-12 { color: var(--brand); background: url(img/bg.png) } +.big-13 { color: var(--brand); background: url(img/bg.png) } +.big-14 { color: var(--brand); background: url(img/bg.png) } +.big-15 { color: var(--brand); background: url(img/bg.png) } +.big-16 { color: var(--brand); background: url(img/bg.png) } +.big-17 { color: var(--brand); background: url(img/bg.png) } +.big-18 { color: var(--brand); background: url(img/bg.png) } +.big-19 { color: var(--brand); background: url(img/bg.png) } +.big-20 { color: var(--brand); background: url(img/bg.png) } +.big-21 { color: var(--brand); background: url(img/bg.png) } +.big-22 { color: var(--brand); background: url(img/bg.png) } +.big-23 { color: var(--brand); background: url(img/bg.png) } +.big-24 { color: var(--brand); background: url(img/bg.png) } +.big-25 { color: var(--brand); background: url(img/bg.png) } +.big-26 { color: var(--brand); background: url(img/bg.png) } +.big-27 { color: var(--brand); background: url(img/bg.png) } +.big-28 { color: var(--brand); background: url(img/bg.png) } +.big-29 { color: var(--brand); background: url(img/bg.png) } +.big-30 { color: var(--brand); background: url(img/bg.png) } +.big-31 { color: var(--brand); background: url(img/bg.png) } +.big-32 { color: var(--brand); background: url(img/bg.png) } +.big-33 { color: var(--brand); background: url(img/bg.png) } +.big-34 { color: var(--brand); background: url(img/bg.png) } +.big-35 { color: var(--brand); background: url(img/bg.png) } +.big-36 { color: var(--brand); background: url(img/bg.png) } +.big-37 { color: var(--brand); background: url(img/bg.png) } +.big-38 { color: var(--brand); background: url(img/bg.png) } +.big-39 { color: var(--brand); background: url(img/bg.png) } +.big-40 { color: var(--brand); background: url(img/bg.png) } +.big-41 { color: var(--brand); background: url(img/bg.png) } +.big-42 { color: var(--brand); background: url(img/bg.png) } +.big-43 { color: var(--brand); background: url(img/bg.png) } +.big-44 { color: var(--brand); background: url(img/bg.png) } +.big-45 { color: var(--brand); background: url(img/bg.png) } +.big-46 { color: var(--brand); background: url(img/bg.png) } +.big-47 { color: var(--brand); background: url(img/bg.png) } +.big-48 { color: var(--brand); background: url(img/bg.png) } +.big-49 { color: var(--brand); background: url(img/bg.png) } +.big-50 { color: var(--brand); background: url(img/bg.png) } +.big-51 { color: var(--brand); background: url(img/bg.png) } +.big-52 { color: var(--brand); background: url(img/bg.png) } +.big-53 { color: var(--brand); background: url(img/bg.png) } +.big-54 { color: var(--brand); background: url(img/bg.png) } +.big-55 { color: var(--brand); background: url(img/bg.png) } +.big-56 { color: var(--brand); background: url(img/bg.png) } +.big-57 { color: var(--brand); background: url(img/bg.png) } +.big-58 { color: var(--brand); background: url(img/bg.png) } +.big-59 { color: var(--brand); background: url(img/bg.png) } +.big-60 { color: var(--brand); background: url(img/bg.png) } +.big-61 { color: var(--brand); background: url(img/bg.png) } +.big-62 { color: var(--brand); background: url(img/bg.png) } +.big-63 { color: var(--brand); background: url(img/bg.png) } +.big-64 { color: var(--brand); background: url(img/bg.png) } +.big-65 { color: var(--brand); background: url(img/bg.png) } +.big-66 { color: var(--brand); background: url(img/bg.png) } +.big-67 { color: var(--brand); background: url(img/bg.png) } +.big-68 { color: var(--brand); background: url(img/bg.png) } +.big-69 { color: var(--brand); background: url(img/bg.png) } +.big-70 { color: var(--brand); background: url(img/bg.png) } +.big-71 { color: var(--brand); background: url(img/bg.png) } +.big-72 { color: var(--brand); background: url(img/bg.png) } +.big-73 { color: var(--brand); background: url(img/bg.png) } +.big-74 { color: var(--brand); background: url(img/bg.png) } +.big-75 { color: var(--brand); background: url(img/bg.png) } +.big-76 { color: var(--brand); background: url(img/bg.png) } +.big-77 { color: var(--brand); background: url(img/bg.png) } +.big-78 { color: var(--brand); background: url(img/bg.png) } +.big-79 { color: var(--brand); background: url(img/bg.png) } +.big-80 { color: var(--brand); background: url(img/bg.png) } +.big-81 { color: var(--brand); background: url(img/bg.png) } +.big-82 { color: var(--brand); background: url(img/bg.png) } +.big-83 { color: var(--brand); background: url(img/bg.png) } +.big-84 { color: var(--brand); background: url(img/bg.png) } +.big-85 { color: var(--brand); background: url(img/bg.png) } +.big-86 { color: var(--brand); background: url(img/bg.png) } +.big-87 { color: var(--brand); background: url(img/bg.png) } +.big-88 { color: var(--brand); background: url(img/bg.png) } +.big-89 { color: var(--brand); background: url(img/bg.png) } +.big-90 { color: var(--brand); background: url(img/bg.png) } +.big-91 { color: var(--brand); background: url(img/bg.png) } +.big-92 { color: var(--brand); background: url(img/bg.png) } +.big-93 { color: var(--brand); background: url(img/bg.png) } +.big-94 { color: var(--brand); background: url(img/bg.png) } +.big-95 { color: var(--brand); background: url(img/bg.png) } +.big-96 { color: var(--brand); background: url(img/bg.png) } +.big-97 { color: var(--brand); background: url(img/bg.png) } +.big-98 { color: var(--brand); background: url(img/bg.png) } +.big-99 { color: var(--brand); background: url(img/bg.png) } +.big-100 { color: var(--brand); background: url(img/bg.png) } +.big-101 { color: var(--brand); background: url(img/bg.png) } +.big-102 { color: var(--brand); background: url(img/bg.png) } +.big-103 { color: var(--brand); background: url(img/bg.png) } +.big-104 { color: var(--brand); background: url(img/bg.png) } +.big-105 { color: var(--brand); background: url(img/bg.png) } +.big-106 { color: var(--brand); background: url(img/bg.png) } +.big-107 { color: var(--brand); background: url(img/bg.png) } +.big-108 { color: var(--brand); background: url(img/bg.png) } +.big-109 { color: var(--brand); background: url(img/bg.png) } +.big-110 { color: var(--brand); background: url(img/bg.png) } +.big-111 { color: var(--brand); background: url(img/bg.png) } +.big-112 { color: var(--brand); background: url(img/bg.png) } +.big-113 { color: var(--brand); background: url(img/bg.png) } +.big-114 { color: var(--brand); background: url(img/bg.png) } +.big-115 { color: var(--brand); background: url(img/bg.png) } +.big-116 { color: var(--brand); background: url(img/bg.png) } +.big-117 { color: var(--brand); background: url(img/bg.png) } +.big-118 { color: var(--brand); background: url(img/bg.png) } +.big-119 { color: var(--brand); background: url(img/bg.png) } +.big-120 { color: var(--brand); background: url(img/bg.png) } +.big-121 { color: var(--brand); background: url(img/bg.png) } +.big-122 { color: var(--brand); background: url(img/bg.png) } +.big-123 { color: var(--brand); background: url(img/bg.png) } +.big-124 { color: var(--brand); background: url(img/bg.png) } +.big-125 { color: var(--brand); background: url(img/bg.png) } +.big-126 { color: var(--brand); background: url(img/bg.png) } +.big-127 { color: var(--brand); background: url(img/bg.png) } +.big-128 { color: var(--brand); background: url(img/bg.png) } +.big-129 { color: var(--brand); background: url(img/bg.png) } +.big-130 { color: var(--brand); background: url(img/bg.png) } +.big-131 { color: var(--brand); background: url(img/bg.png) } +.big-132 { color: var(--brand); background: url(img/bg.png) } +.big-133 { color: var(--brand); background: url(img/bg.png) } +.big-134 { color: var(--brand); background: url(img/bg.png) } +.big-135 { color: var(--brand); background: url(img/bg.png) } +.big-136 { color: var(--brand); background: url(img/bg.png) } +.big-137 { color: var(--brand); background: url(img/bg.png) } +.big-138 { color: var(--brand); background: url(img/bg.png) } +.big-139 { color: var(--brand); background: url(img/bg.png) } +.big-140 { color: var(--brand); background: url(img/bg.png) } +.big-141 { color: var(--brand); background: url(img/bg.png) } +.big-142 { color: var(--brand); background: url(img/bg.png) } +.big-143 { color: var(--brand); background: url(img/bg.png) } +.big-144 { color: var(--brand); background: url(img/bg.png) } +.big-145 { color: var(--brand); background: url(img/bg.png) } +.big-146 { color: var(--brand); background: url(img/bg.png) } +.big-147 { color: var(--brand); background: url(img/bg.png) } +.big-148 { color: var(--brand); background: url(img/bg.png) } +.big-149 { color: var(--brand); background: url(img/bg.png) } +.big-150 { color: var(--brand); background: url(img/bg.png) } +.big-151 { color: var(--brand); background: url(img/bg.png) } +.big-152 { color: var(--brand); background: url(img/bg.png) } +.big-153 { color: var(--brand); background: url(img/bg.png) } +.big-154 { color: var(--brand); background: url(img/bg.png) } +.big-155 { color: var(--brand); background: url(img/bg.png) } +.big-156 { color: var(--brand); background: url(img/bg.png) } +.big-157 { color: var(--brand); background: url(img/bg.png) } +.big-158 { color: var(--brand); background: url(img/bg.png) } +.big-159 { color: var(--brand); background: url(img/bg.png) } +.big-160 { color: var(--brand); background: url(img/bg.png) } +.big-161 { color: var(--brand); background: url(img/bg.png) } +.big-162 { color: var(--brand); background: url(img/bg.png) } +.big-163 { color: var(--brand); background: url(img/bg.png) } +.big-164 { color: var(--brand); background: url(img/bg.png) } +.big-165 { color: var(--brand); background: url(img/bg.png) } +.big-166 { color: var(--brand); background: url(img/bg.png) } +.big-167 { color: var(--brand); background: url(img/bg.png) } +.big-168 { color: var(--brand); background: url(img/bg.png) } +.big-169 { color: var(--brand); background: url(img/bg.png) } +.big-170 { color: var(--brand); background: url(img/bg.png) } +.big-171 { color: var(--brand); background: url(img/bg.png) } +.big-172 { color: var(--brand); background: url(img/bg.png) } +.big-173 { color: var(--brand); background: url(img/bg.png) } +.big-174 { color: var(--brand); background: url(img/bg.png) } +.big-175 { color: var(--brand); background: url(img/bg.png) } +.big-176 { color: var(--brand); background: url(img/bg.png) } +.big-177 { color: var(--brand); background: url(img/bg.png) } +.big-178 { color: var(--brand); background: url(img/bg.png) } +.big-179 { color: var(--brand); background: url(img/bg.png) } +.big-180 { color: var(--brand); background: url(img/bg.png) } +.big-181 { color: var(--brand); background: url(img/bg.png) } +.big-182 { color: var(--brand); background: url(img/bg.png) } +.big-183 { color: var(--brand); background: url(img/bg.png) } +.big-184 { color: var(--brand); background: url(img/bg.png) } +.big-185 { color: var(--brand); background: url(img/bg.png) } +.big-186 { color: var(--brand); background: url(img/bg.png) } +.big-187 { color: var(--brand); background: url(img/bg.png) } +.big-188 { color: var(--brand); background: url(img/bg.png) } +.big-189 { color: var(--brand); background: url(img/bg.png) } +.big-190 { color: var(--brand); background: url(img/bg.png) } +.big-191 { color: var(--brand); background: url(img/bg.png) } +.big-192 { color: var(--brand); background: url(img/bg.png) } +.big-193 { color: var(--brand); background: url(img/bg.png) } +.big-194 { color: var(--brand); background: url(img/bg.png) } +.big-195 { color: var(--brand); background: url(img/bg.png) } +.big-196 { color: var(--brand); background: url(img/bg.png) } +.big-197 { color: var(--brand); background: url(img/bg.png) } +.big-198 { color: var(--brand); background: url(img/bg.png) } +.big-199 { color: var(--brand); background: url(img/bg.png) } +.big-200 { color: var(--brand); background: url(img/bg.png) } +.big-201 { color: var(--brand); background: url(img/bg.png) } +.big-202 { color: var(--brand); background: url(img/bg.png) } +.big-203 { color: var(--brand); background: url(img/bg.png) } +.big-204 { color: var(--brand); background: url(img/bg.png) } +.big-205 { color: var(--brand); background: url(img/bg.png) } +.big-206 { color: var(--brand); background: url(img/bg.png) } +.big-207 { color: var(--brand); background: url(img/bg.png) } +.big-208 { color: var(--brand); background: url(img/bg.png) } +.big-209 { color: var(--brand); background: url(img/bg.png) } +.big-210 { color: var(--brand); background: url(img/bg.png) } +.big-211 { color: var(--brand); background: url(img/bg.png) } +.big-212 { color: var(--brand); background: url(img/bg.png) } +.big-213 { color: var(--brand); background: url(img/bg.png) } +.big-214 { color: var(--brand); background: url(img/bg.png) } +.big-215 { color: var(--brand); background: url(img/bg.png) } +.big-216 { color: var(--brand); background: url(img/bg.png) } +.big-217 { color: var(--brand); background: url(img/bg.png) } +.big-218 { color: var(--brand); background: url(img/bg.png) } +.big-219 { color: var(--brand); background: url(img/bg.png) } +.big-220 { color: var(--brand); background: url(img/bg.png) } +.big-221 { color: var(--brand); background: url(img/bg.png) } +.big-222 { color: var(--brand); background: url(img/bg.png) } +.big-223 { color: var(--brand); background: url(img/bg.png) } +.big-224 { color: var(--brand); background: url(img/bg.png) } +.big-225 { color: var(--brand); background: url(img/bg.png) } +.big-226 { color: var(--brand); background: url(img/bg.png) } +.big-227 { color: var(--brand); background: url(img/bg.png) } +.big-228 { color: var(--brand); background: url(img/bg.png) } +.big-229 { color: var(--brand); background: url(img/bg.png) } +.big-230 { color: var(--brand); background: url(img/bg.png) } +.big-231 { color: var(--brand); background: url(img/bg.png) } +.big-232 { color: var(--brand); background: url(img/bg.png) } +.big-233 { color: var(--brand); background: url(img/bg.png) } +.big-234 { color: var(--brand); background: url(img/bg.png) } +.big-235 { color: var(--brand); background: url(img/bg.png) } +.big-236 { color: var(--brand); background: url(img/bg.png) } +.big-237 { color: var(--brand); background: url(img/bg.png) } +.big-238 { color: var(--brand); background: url(img/bg.png) } +.big-239 { color: var(--brand); background: url(img/bg.png) } +.big-240 { color: var(--brand); background: url(img/bg.png) } +.big-241 { color: var(--brand); background: url(img/bg.png) } +.big-242 { color: var(--brand); background: url(img/bg.png) } +.big-243 { color: var(--brand); background: url(img/bg.png) } +.big-244 { color: var(--brand); background: url(img/bg.png) } +.big-245 { color: var(--brand); background: url(img/bg.png) } +.big-246 { color: var(--brand); background: url(img/bg.png) } +.big-247 { color: var(--brand); background: url(img/bg.png) } +.big-248 { color: var(--brand); background: url(img/bg.png) } +.big-249 { color: var(--brand); background: url(img/bg.png) } +.big-250 { color: var(--brand); background: url(img/bg.png) } +.big-251 { color: var(--brand); background: url(img/bg.png) } +.big-252 { color: var(--brand); background: url(img/bg.png) } +.big-253 { color: var(--brand); background: url(img/bg.png) } +.big-254 { color: var(--brand); background: url(img/bg.png) } +.big-255 { color: var(--brand); background: url(img/bg.png) } +.big-256 { color: var(--brand); background: url(img/bg.png) } +.big-257 { color: var(--brand); background: url(img/bg.png) } +.big-258 { color: var(--brand); background: url(img/bg.png) } +.big-259 { color: var(--brand); background: url(img/bg.png) } +.big-260 { color: var(--brand); background: url(img/bg.png) } +.big-261 { color: var(--brand); background: url(img/bg.png) } +.big-262 { color: var(--brand); background: url(img/bg.png) } +.big-263 { color: var(--brand); background: url(img/bg.png) } +.big-264 { color: var(--brand); background: url(img/bg.png) } +.big-265 { color: var(--brand); background: url(img/bg.png) } +.big-266 { color: var(--brand); background: url(img/bg.png) } +.big-267 { color: var(--brand); background: url(img/bg.png) } +.big-268 { color: var(--brand); background: url(img/bg.png) } +.big-269 { color: var(--brand); background: url(img/bg.png) } +.big-270 { color: var(--brand); background: url(img/bg.png) } +.big-271 { color: var(--brand); background: url(img/bg.png) } +.big-272 { color: var(--brand); background: url(img/bg.png) } +.big-273 { color: var(--brand); background: url(img/bg.png) } +.big-274 { color: var(--brand); background: url(img/bg.png) } +.big-275 { color: var(--brand); background: url(img/bg.png) } +.big-276 { color: var(--brand); background: url(img/bg.png) } +.big-277 { color: var(--brand); background: url(img/bg.png) } +.big-278 { color: var(--brand); background: url(img/bg.png) } +.big-279 { color: var(--brand); background: url(img/bg.png) } +.big-280 { color: var(--brand); background: url(img/bg.png) } +.big-281 { color: var(--brand); background: url(img/bg.png) } +.big-282 { color: var(--brand); background: url(img/bg.png) } +.big-283 { color: var(--brand); background: url(img/bg.png) } +.big-284 { color: var(--brand); background: url(img/bg.png) } +.big-285 { color: var(--brand); background: url(img/bg.png) } +.big-286 { color: var(--brand); background: url(img/bg.png) } +.big-287 { color: var(--brand); background: url(img/bg.png) } +.big-288 { color: var(--brand); background: url(img/bg.png) } +.big-289 { color: var(--brand); background: url(img/bg.png) } +.big-290 { color: var(--brand); background: url(img/bg.png) } +.big-291 { color: var(--brand); background: url(img/bg.png) } +.big-292 { color: var(--brand); background: url(img/bg.png) } +.big-293 { color: var(--brand); background: url(img/bg.png) } +.big-294 { color: var(--brand); background: url(img/bg.png) } +.big-295 { color: var(--brand); background: url(img/bg.png) } +.big-296 { color: var(--brand); background: url(img/bg.png) } +.big-297 { color: var(--brand); background: url(img/bg.png) } +.big-298 { color: var(--brand); background: url(img/bg.png) } +.big-299 { color: var(--brand); background: url(img/bg.png) } +.big-300 { color: var(--brand); background: url(img/bg.png) } +.big-301 { color: var(--brand); background: url(img/bg.png) } +.big-302 { color: var(--brand); background: url(img/bg.png) } +.big-303 { color: var(--brand); background: url(img/bg.png) } +.big-304 { color: var(--brand); background: url(img/bg.png) } +.big-305 { color: var(--brand); background: url(img/bg.png) } +.big-306 { color: var(--brand); background: url(img/bg.png) } +.big-307 { color: var(--brand); background: url(img/bg.png) } +.big-308 { color: var(--brand); background: url(img/bg.png) } +.big-309 { color: var(--brand); background: url(img/bg.png) } +.big-310 { color: var(--brand); background: url(img/bg.png) } +.big-311 { color: var(--brand); background: url(img/bg.png) } +.big-312 { color: var(--brand); background: url(img/bg.png) } +.big-313 { color: var(--brand); background: url(img/bg.png) } +.big-314 { color: var(--brand); background: url(img/bg.png) } +.big-315 { color: var(--brand); background: url(img/bg.png) } +.big-316 { color: var(--brand); background: url(img/bg.png) } +.big-317 { color: var(--brand); background: url(img/bg.png) } +.big-318 { color: var(--brand); background: url(img/bg.png) } +.big-319 { color: var(--brand); background: url(img/bg.png) } +.big-320 { color: var(--brand); background: url(img/bg.png) } +.big-321 { color: var(--brand); background: url(img/bg.png) } +.big-322 { color: var(--brand); background: url(img/bg.png) } +.big-323 { color: var(--brand); background: url(img/bg.png) } +.big-324 { color: var(--brand); background: url(img/bg.png) } +.big-325 { color: var(--brand); background: url(img/bg.png) } +.big-326 { color: var(--brand); background: url(img/bg.png) } +.big-327 { color: var(--brand); background: url(img/bg.png) } +.big-328 { color: var(--brand); background: url(img/bg.png) } +.big-329 { color: var(--brand); background: url(img/bg.png) } +.big-330 { color: var(--brand); background: url(img/bg.png) } +.big-331 { color: var(--brand); background: url(img/bg.png) } +.big-332 { color: var(--brand); background: url(img/bg.png) } +.big-333 { color: var(--brand); background: url(img/bg.png) } +.big-334 { color: var(--brand); background: url(img/bg.png) } +.big-335 { color: var(--brand); background: url(img/bg.png) } +.big-336 { color: var(--brand); background: url(img/bg.png) } +.big-337 { color: var(--brand); background: url(img/bg.png) } +.big-338 { color: var(--brand); background: url(img/bg.png) } +.big-339 { color: var(--brand); background: url(img/bg.png) } +.big-340 { color: var(--brand); background: url(img/bg.png) } +.big-341 { color: var(--brand); background: url(img/bg.png) } +.big-342 { color: var(--brand); background: url(img/bg.png) } +.big-343 { color: var(--brand); background: url(img/bg.png) } +.big-344 { color: var(--brand); background: url(img/bg.png) } +.big-345 { color: var(--brand); background: url(img/bg.png) } +.big-346 { color: var(--brand); background: url(img/bg.png) } +.big-347 { color: var(--brand); background: url(img/bg.png) } +.big-348 { color: var(--brand); background: url(img/bg.png) } +.big-349 { color: var(--brand); background: url(img/bg.png) } +.big-350 { color: var(--brand); background: url(img/bg.png) } +.big-351 { color: var(--brand); background: url(img/bg.png) } +.big-352 { color: var(--brand); background: url(img/bg.png) } +.big-353 { color: var(--brand); background: url(img/bg.png) } +.big-354 { color: var(--brand); background: url(img/bg.png) } +.big-355 { color: var(--brand); background: url(img/bg.png) } +.big-356 { color: var(--brand); background: url(img/bg.png) } +.big-357 { color: var(--brand); background: url(img/bg.png) } +.big-358 { color: var(--brand); background: url(img/bg.png) } +.big-359 { color: var(--brand); background: url(img/bg.png) } +.big-360 { color: var(--brand); background: url(img/bg.png) } +.big-361 { color: var(--brand); background: url(img/bg.png) } +.big-362 { color: var(--brand); background: url(img/bg.png) } +.big-363 { color: var(--brand); background: url(img/bg.png) } +.big-364 { color: var(--brand); background: url(img/bg.png) } +.big-365 { color: var(--brand); background: url(img/bg.png) } +.big-366 { color: var(--brand); background: url(img/bg.png) } +.big-367 { color: var(--brand); background: url(img/bg.png) } +.big-368 { color: var(--brand); background: url(img/bg.png) } +.big-369 { color: var(--brand); background: url(img/bg.png) } +.big-370 { color: var(--brand); background: url(img/bg.png) } +.big-371 { color: var(--brand); background: url(img/bg.png) } +.big-372 { color: var(--brand); background: url(img/bg.png) } +.big-373 { color: var(--brand); background: url(img/bg.png) } +.big-374 { color: var(--brand); background: url(img/bg.png) } +.big-375 { color: var(--brand); background: url(img/bg.png) } +.big-376 { color: var(--brand); background: url(img/bg.png) } +.big-377 { color: var(--brand); background: url(img/bg.png) } +.big-378 { color: var(--brand); background: url(img/bg.png) } +.big-379 { color: var(--brand); background: url(img/bg.png) } +.big-380 { color: var(--brand); background: url(img/bg.png) } +.big-381 { color: var(--brand); background: url(img/bg.png) } +.big-382 { color: var(--brand); background: url(img/bg.png) } +.big-383 { color: var(--brand); background: url(img/bg.png) } +.big-384 { color: var(--brand); background: url(img/bg.png) } +.big-385 { color: var(--brand); background: url(img/bg.png) } +.big-386 { color: var(--brand); background: url(img/bg.png) } +.big-387 { color: var(--brand); background: url(img/bg.png) } +.big-388 { color: var(--brand); background: url(img/bg.png) } +.big-389 { color: var(--brand); background: url(img/bg.png) } +.big-390 { color: var(--brand); background: url(img/bg.png) } +.big-391 { color: var(--brand); background: url(img/bg.png) } +.big-392 { color: var(--brand); background: url(img/bg.png) } +.big-393 { color: var(--brand); background: url(img/bg.png) } +.big-394 { color: var(--brand); background: url(img/bg.png) } +.big-395 { color: var(--brand); background: url(img/bg.png) } +.big-396 { color: var(--brand); background: url(img/bg.png) } +.big-397 { color: var(--brand); background: url(img/bg.png) } +.big-398 { color: var(--brand); background: url(img/bg.png) } +.big-399 { color: var(--brand); background: url(img/bg.png) } +.big-400 { color: var(--brand); background: url(img/bg.png) } +.big-401 { color: var(--brand); background: url(img/bg.png) } +.big-402 { color: var(--brand); background: url(img/bg.png) } +.big-403 { color: var(--brand); background: url(img/bg.png) } +.big-404 { color: var(--brand); background: url(img/bg.png) } +.big-405 { color: var(--brand); background: url(img/bg.png) } +.big-406 { color: var(--brand); background: url(img/bg.png) } +.big-407 { color: var(--brand); background: url(img/bg.png) } +.big-408 { color: var(--brand); background: url(img/bg.png) } +.big-409 { color: var(--brand); background: url(img/bg.png) } +.big-410 { color: var(--brand); background: url(img/bg.png) } +.big-411 { color: var(--brand); background: url(img/bg.png) } +.big-412 { color: var(--brand); background: url(img/bg.png) } +.big-413 { color: var(--brand); background: url(img/bg.png) } +.big-414 { color: var(--brand); background: url(img/bg.png) } +.big-415 { color: var(--brand); background: url(img/bg.png) } +.big-416 { color: var(--brand); background: url(img/bg.png) } +.big-417 { color: var(--brand); background: url(img/bg.png) } +.big-418 { color: var(--brand); background: url(img/bg.png) } +.big-419 { color: var(--brand); background: url(img/bg.png) } +.big-420 { color: var(--brand); background: url(img/bg.png) } +.big-421 { color: var(--brand); background: url(img/bg.png) } +.big-422 { color: var(--brand); background: url(img/bg.png) } +.big-423 { color: var(--brand); background: url(img/bg.png) } +.big-424 { color: var(--brand); background: url(img/bg.png) } +.big-425 { color: var(--brand); background: url(img/bg.png) } +.big-426 { color: var(--brand); background: url(img/bg.png) } +.big-427 { color: var(--brand); background: url(img/bg.png) } +.big-428 { color: var(--brand); background: url(img/bg.png) } +.big-429 { color: var(--brand); background: url(img/bg.png) } +.big-430 { color: var(--brand); background: url(img/bg.png) } +.big-431 { color: var(--brand); background: url(img/bg.png) } +.big-432 { color: var(--brand); background: url(img/bg.png) } +.big-433 { color: var(--brand); background: url(img/bg.png) } +.big-434 { color: var(--brand); background: url(img/bg.png) } +.big-435 { color: var(--brand); background: url(img/bg.png) } +.big-436 { color: var(--brand); background: url(img/bg.png) } +.big-437 { color: var(--brand); background: url(img/bg.png) } +.big-438 { color: var(--brand); background: url(img/bg.png) } +.big-439 { color: var(--brand); background: url(img/bg.png) } +.big-440 { color: var(--brand); background: url(img/bg.png) } +.big-441 { color: var(--brand); background: url(img/bg.png) } +.big-442 { color: var(--brand); background: url(img/bg.png) } +.big-443 { color: var(--brand); background: url(img/bg.png) } +.big-444 { color: var(--brand); background: url(img/bg.png) } +.big-445 { color: var(--brand); background: url(img/bg.png) } +.big-446 { color: var(--brand); background: url(img/bg.png) } +.big-447 { color: var(--brand); background: url(img/bg.png) } +.big-448 { color: var(--brand); background: url(img/bg.png) } +.big-449 { color: var(--brand); background: url(img/bg.png) } +.big-450 { color: var(--brand); background: url(img/bg.png) } +.big-451 { color: var(--brand); background: url(img/bg.png) } +.big-452 { color: var(--brand); background: url(img/bg.png) } +.big-453 { color: var(--brand); background: url(img/bg.png) } +.big-454 { color: var(--brand); background: url(img/bg.png) } +.big-455 { color: var(--brand); background: url(img/bg.png) } +.big-456 { color: var(--brand); background: url(img/bg.png) } +.big-457 { color: var(--brand); background: url(img/bg.png) } +.big-458 { color: var(--brand); background: url(img/bg.png) } +.big-459 { color: var(--brand); background: url(img/bg.png) } +.big-460 { color: var(--brand); background: url(img/bg.png) } +.big-461 { color: var(--brand); background: url(img/bg.png) } +.big-462 { color: var(--brand); background: url(img/bg.png) } +.big-463 { color: var(--brand); background: url(img/bg.png) } +.big-464 { color: var(--brand); background: url(img/bg.png) } +.big-465 { color: var(--brand); background: url(img/bg.png) } +.big-466 { color: var(--brand); background: url(img/bg.png) } +.big-467 { color: var(--brand); background: url(img/bg.png) } +.big-468 { color: var(--brand); background: url(img/bg.png) } +.big-469 { color: var(--brand); background: url(img/bg.png) } +.big-470 { color: var(--brand); background: url(img/bg.png) } +.big-471 { color: var(--brand); background: url(img/bg.png) } +.big-472 { color: var(--brand); background: url(img/bg.png) } +.big-473 { color: var(--brand); background: url(img/bg.png) } +.big-474 { color: var(--brand); background: url(img/bg.png) } +.big-475 { color: var(--brand); background: url(img/bg.png) } +.big-476 { color: var(--brand); background: url(img/bg.png) } +.big-477 { color: var(--brand); background: url(img/bg.png) } +.big-478 { color: var(--brand); background: url(img/bg.png) } +.big-479 { color: var(--brand); background: url(img/bg.png) } +.big-480 { color: var(--brand); background: url(img/bg.png) } +.big-481 { color: var(--brand); background: url(img/bg.png) } +.big-482 { color: var(--brand); background: url(img/bg.png) } +.big-483 { color: var(--brand); background: url(img/bg.png) } +.big-484 { color: var(--brand); background: url(img/bg.png) } +.big-485 { color: var(--brand); background: url(img/bg.png) } +.big-486 { color: var(--brand); background: url(img/bg.png) } +.big-487 { color: var(--brand); background: url(img/bg.png) } +.big-488 { color: var(--brand); background: url(img/bg.png) } +.big-489 { color: var(--brand); background: url(img/bg.png) } +.big-490 { color: var(--brand); background: url(img/bg.png) } +.big-491 { color: var(--brand); background: url(img/bg.png) } +.big-492 { color: var(--brand); background: url(img/bg.png) } +.big-493 { color: var(--brand); background: url(img/bg.png) } +.big-494 { color: var(--brand); background: url(img/bg.png) } +.big-495 { color: var(--brand); background: url(img/bg.png) } +.big-496 { color: var(--brand); background: url(img/bg.png) } +.big-497 { color: var(--brand); background: url(img/bg.png) } +.big-498 { color: var(--brand); background: url(img/bg.png) } +.big-499 { color: var(--brand); background: url(img/bg.png) } +.big-500 { color: var(--brand); background: url(img/bg.png) } +.big-501 { color: var(--brand); background: url(img/bg.png) } +.big-502 { color: var(--brand); background: url(img/bg.png) } +.big-503 { color: var(--brand); background: url(img/bg.png) } +.big-504 { color: var(--brand); background: url(img/bg.png) } +.big-505 { color: var(--brand); background: url(img/bg.png) } +.big-506 { color: var(--brand); background: url(img/bg.png) } +.big-507 { color: var(--brand); background: url(img/bg.png) } +.big-508 { color: var(--brand); background: url(img/bg.png) } +.big-509 { color: var(--brand); background: url(img/bg.png) } +.big-510 { color: var(--brand); background: url(img/bg.png) } +.big-511 { color: var(--brand); background: url(img/bg.png) } +.big-512 { color: var(--brand); background: url(img/bg.png) } +.big-513 { color: var(--brand); background: url(img/bg.png) } +.big-514 { color: var(--brand); background: url(img/bg.png) } +.big-515 { color: var(--brand); background: url(img/bg.png) } +.big-516 { color: var(--brand); background: url(img/bg.png) } +.big-517 { color: var(--brand); background: url(img/bg.png) } +.big-518 { color: var(--brand); background: url(img/bg.png) } +.big-519 { color: var(--brand); background: url(img/bg.png) } +.big-520 { color: var(--brand); background: url(img/bg.png) } +.big-521 { color: var(--brand); background: url(img/bg.png) } +.big-522 { color: var(--brand); background: url(img/bg.png) } +.big-523 { color: var(--brand); background: url(img/bg.png) } +.big-524 { color: var(--brand); background: url(img/bg.png) } +.big-525 { color: var(--brand); background: url(img/bg.png) } +.big-526 { color: var(--brand); background: url(img/bg.png) } +.big-527 { color: var(--brand); background: url(img/bg.png) } +.big-528 { color: var(--brand); background: url(img/bg.png) } +.big-529 { color: var(--brand); background: url(img/bg.png) } +.big-530 { color: var(--brand); background: url(img/bg.png) } +.big-531 { color: var(--brand); background: url(img/bg.png) } +.big-532 { color: var(--brand); background: url(img/bg.png) } +.big-533 { color: var(--brand); background: url(img/bg.png) } +.big-534 { color: var(--brand); background: url(img/bg.png) } +.big-535 { color: var(--brand); background: url(img/bg.png) } +.big-536 { color: var(--brand); background: url(img/bg.png) } +.big-537 { color: var(--brand); background: url(img/bg.png) } +.big-538 { color: var(--brand); background: url(img/bg.png) } +.big-539 { color: var(--brand); background: url(img/bg.png) } +.big-540 { color: var(--brand); background: url(img/bg.png) } +.big-541 { color: var(--brand); background: url(img/bg.png) } +.big-542 { color: var(--brand); background: url(img/bg.png) } +.big-543 { color: var(--brand); background: url(img/bg.png) } +.big-544 { color: var(--brand); background: url(img/bg.png) } +.big-545 { color: var(--brand); background: url(img/bg.png) } +.big-546 { color: var(--brand); background: url(img/bg.png) } +.big-547 { color: var(--brand); background: url(img/bg.png) } +.big-548 { color: var(--brand); background: url(img/bg.png) } +.big-549 { color: var(--brand); background: url(img/bg.png) } +.big-550 { color: var(--brand); background: url(img/bg.png) } +.big-551 { color: var(--brand); background: url(img/bg.png) } +.big-552 { color: var(--brand); background: url(img/bg.png) } +.big-553 { color: var(--brand); background: url(img/bg.png) } +.big-554 { color: var(--brand); background: url(img/bg.png) } +.big-555 { color: var(--brand); background: url(img/bg.png) } +.big-556 { color: var(--brand); background: url(img/bg.png) } +.big-557 { color: var(--brand); background: url(img/bg.png) } +.big-558 { color: var(--brand); background: url(img/bg.png) } +.big-559 { color: var(--brand); background: url(img/bg.png) } +.big-560 { color: var(--brand); background: url(img/bg.png) } +.big-561 { color: var(--brand); background: url(img/bg.png) } +.big-562 { color: var(--brand); background: url(img/bg.png) } +.big-563 { color: var(--brand); background: url(img/bg.png) } +.big-564 { color: var(--brand); background: url(img/bg.png) } +.big-565 { color: var(--brand); background: url(img/bg.png) } +.big-566 { color: var(--brand); background: url(img/bg.png) } +.big-567 { color: var(--brand); background: url(img/bg.png) } +.big-568 { color: var(--brand); background: url(img/bg.png) } +.big-569 { color: var(--brand); background: url(img/bg.png) } +.big-570 { color: var(--brand); background: url(img/bg.png) } +.big-571 { color: var(--brand); background: url(img/bg.png) } +.big-572 { color: var(--brand); background: url(img/bg.png) } +.big-573 { color: var(--brand); background: url(img/bg.png) } +.big-574 { color: var(--brand); background: url(img/bg.png) } +.big-575 { color: var(--brand); background: url(img/bg.png) } +.big-576 { color: var(--brand); background: url(img/bg.png) } +.big-577 { color: var(--brand); background: url(img/bg.png) } +.big-578 { color: var(--brand); background: url(img/bg.png) } +.big-579 { color: var(--brand); background: url(img/bg.png) } +.big-580 { color: var(--brand); background: url(img/bg.png) } +.big-581 { color: var(--brand); background: url(img/bg.png) } +.big-582 { color: var(--brand); background: url(img/bg.png) } +.big-583 { color: var(--brand); background: url(img/bg.png) } +.big-584 { color: var(--brand); background: url(img/bg.png) } +.big-585 { color: var(--brand); background: url(img/bg.png) } +.big-586 { color: var(--brand); background: url(img/bg.png) } +.big-587 { color: var(--brand); background: url(img/bg.png) } +.big-588 { color: var(--brand); background: url(img/bg.png) } +.big-589 { color: var(--brand); background: url(img/bg.png) } +.big-590 { color: var(--brand); background: url(img/bg.png) } +.big-591 { color: var(--brand); background: url(img/bg.png) } +.big-592 { color: var(--brand); background: url(img/bg.png) } +.big-593 { color: var(--brand); background: url(img/bg.png) } +.big-594 { color: var(--brand); background: url(img/bg.png) } +.big-595 { color: var(--brand); background: url(img/bg.png) } +.big-596 { color: var(--brand); background: url(img/bg.png) } +.big-597 { color: var(--brand); background: url(img/bg.png) } +.big-598 { color: var(--brand); background: url(img/bg.png) } +.big-599 { color: var(--brand); background: url(img/bg.png) } +.big-600 { color: var(--brand); background: url(img/bg.png) } +.big-601 { color: var(--brand); background: url(img/bg.png) } +.big-602 { color: var(--brand); background: url(img/bg.png) } +.big-603 { color: var(--brand); background: url(img/bg.png) } +.big-604 { color: var(--brand); background: url(img/bg.png) } +.big-605 { color: var(--brand); background: url(img/bg.png) } +.big-606 { color: var(--brand); background: url(img/bg.png) } +.big-607 { color: var(--brand); background: url(img/bg.png) } +.big-608 { color: var(--brand); background: url(img/bg.png) } +.big-609 { color: var(--brand); background: url(img/bg.png) } +.big-610 { color: var(--brand); background: url(img/bg.png) } +.big-611 { color: var(--brand); background: url(img/bg.png) } +.big-612 { color: var(--brand); background: url(img/bg.png) } +.big-613 { color: var(--brand); background: url(img/bg.png) } +.big-614 { color: var(--brand); background: url(img/bg.png) } +.big-615 { color: var(--brand); background: url(img/bg.png) } +.big-616 { color: var(--brand); background: url(img/bg.png) } +.big-617 { color: var(--brand); background: url(img/bg.png) } +.big-618 { color: var(--brand); background: url(img/bg.png) } +.big-619 { color: var(--brand); background: url(img/bg.png) } +.big-620 { color: var(--brand); background: url(img/bg.png) } +.big-621 { color: var(--brand); background: url(img/bg.png) } +.big-622 { color: var(--brand); background: url(img/bg.png) } +.big-623 { color: var(--brand); background: url(img/bg.png) } +.big-624 { color: var(--brand); background: url(img/bg.png) } +.big-625 { color: var(--brand); background: url(img/bg.png) } +.big-626 { color: var(--brand); background: url(img/bg.png) } +.big-627 { color: var(--brand); background: url(img/bg.png) } +.big-628 { color: var(--brand); background: url(img/bg.png) } +.big-629 { color: var(--brand); background: url(img/bg.png) } +.big-630 { color: var(--brand); background: url(img/bg.png) } +.big-631 { color: var(--brand); background: url(img/bg.png) } +.big-632 { color: var(--brand); background: url(img/bg.png) } +.big-633 { color: var(--brand); background: url(img/bg.png) } +.big-634 { color: var(--brand); background: url(img/bg.png) } +.big-635 { color: var(--brand); background: url(img/bg.png) } +.big-636 { color: var(--brand); background: url(img/bg.png) } +.big-637 { color: var(--brand); background: url(img/bg.png) } +.big-638 { color: var(--brand); background: url(img/bg.png) } +.big-639 { color: var(--brand); background: url(img/bg.png) } +.big-640 { color: var(--brand); background: url(img/bg.png) } +.big-641 { color: var(--brand); background: url(img/bg.png) } +.big-642 { color: var(--brand); background: url(img/bg.png) } +.big-643 { color: var(--brand); background: url(img/bg.png) } +.big-644 { color: var(--brand); background: url(img/bg.png) } +.big-645 { color: var(--brand); background: url(img/bg.png) } +.big-646 { color: var(--brand); background: url(img/bg.png) } +.big-647 { color: var(--brand); background: url(img/bg.png) } +.big-648 { color: var(--brand); background: url(img/bg.png) } +.big-649 { color: var(--brand); background: url(img/bg.png) } +.big-650 { color: var(--brand); background: url(img/bg.png) } +.big-651 { color: var(--brand); background: url(img/bg.png) } +.big-652 { color: var(--brand); background: url(img/bg.png) } +.big-653 { color: var(--brand); background: url(img/bg.png) } +.big-654 { color: var(--brand); background: url(img/bg.png) } +.big-655 { color: var(--brand); background: url(img/bg.png) } +.big-656 { color: var(--brand); background: url(img/bg.png) } +.big-657 { color: var(--brand); background: url(img/bg.png) } +.big-658 { color: var(--brand); background: url(img/bg.png) } +.big-659 { color: var(--brand); background: url(img/bg.png) } +.big-660 { color: var(--brand); background: url(img/bg.png) } +.big-661 { color: var(--brand); background: url(img/bg.png) } +.big-662 { color: var(--brand); background: url(img/bg.png) } +.big-663 { color: var(--brand); background: url(img/bg.png) } +.big-664 { color: var(--brand); background: url(img/bg.png) } +.big-665 { color: var(--brand); background: url(img/bg.png) } +.big-666 { color: var(--brand); background: url(img/bg.png) } +.big-667 { color: var(--brand); background: url(img/bg.png) } +.big-668 { color: var(--brand); background: url(img/bg.png) } +.big-669 { color: var(--brand); background: url(img/bg.png) } +.big-670 { color: var(--brand); background: url(img/bg.png) } +.big-671 { color: var(--brand); background: url(img/bg.png) } +.big-672 { color: var(--brand); background: url(img/bg.png) } +.big-673 { color: var(--brand); background: url(img/bg.png) } +.big-674 { color: var(--brand); background: url(img/bg.png) } +.big-675 { color: var(--brand); background: url(img/bg.png) } +.big-676 { color: var(--brand); background: url(img/bg.png) } +.big-677 { color: var(--brand); background: url(img/bg.png) } +.big-678 { color: var(--brand); background: url(img/bg.png) } +.big-679 { color: var(--brand); background: url(img/bg.png) } +.big-680 { color: var(--brand); background: url(img/bg.png) } +.big-681 { color: var(--brand); background: url(img/bg.png) } +.big-682 { color: var(--brand); background: url(img/bg.png) } +.big-683 { color: var(--brand); background: url(img/bg.png) } +.big-684 { color: var(--brand); background: url(img/bg.png) } +.big-685 { color: var(--brand); background: url(img/bg.png) } +.big-686 { color: var(--brand); background: url(img/bg.png) } +.big-687 { color: var(--brand); background: url(img/bg.png) } +.big-688 { color: var(--brand); background: url(img/bg.png) } +.big-689 { color: var(--brand); background: url(img/bg.png) } +.big-690 { color: var(--brand); background: url(img/bg.png) } +.big-691 { color: var(--brand); background: url(img/bg.png) } +.big-692 { color: var(--brand); background: url(img/bg.png) } +.big-693 { color: var(--brand); background: url(img/bg.png) } +.big-694 { color: var(--brand); background: url(img/bg.png) } +.big-695 { color: var(--brand); background: url(img/bg.png) } +.big-696 { color: var(--brand); background: url(img/bg.png) } +.big-697 { color: var(--brand); background: url(img/bg.png) } +.big-698 { color: var(--brand); background: url(img/bg.png) } +.big-699 { color: var(--brand); background: url(img/bg.png) } +.big-700 { color: var(--brand); background: url(img/bg.png) } +.big-701 { color: var(--brand); background: url(img/bg.png) } +.big-702 { color: var(--brand); background: url(img/bg.png) } +.big-703 { color: var(--brand); background: url(img/bg.png) } +.big-704 { color: var(--brand); background: url(img/bg.png) } +.big-705 { color: var(--brand); background: url(img/bg.png) } +.big-706 { color: var(--brand); background: url(img/bg.png) } +.big-707 { color: var(--brand); background: url(img/bg.png) } +.big-708 { color: var(--brand); background: url(img/bg.png) } +.big-709 { color: var(--brand); background: url(img/bg.png) } +.big-710 { color: var(--brand); background: url(img/bg.png) } +.big-711 { color: var(--brand); background: url(img/bg.png) } +.big-712 { color: var(--brand); background: url(img/bg.png) } +.big-713 { color: var(--brand); background: url(img/bg.png) } +.big-714 { color: var(--brand); background: url(img/bg.png) } +.big-715 { color: var(--brand); background: url(img/bg.png) } +.big-716 { color: var(--brand); background: url(img/bg.png) } +.big-717 { color: var(--brand); background: url(img/bg.png) } +.big-718 { color: var(--brand); background: url(img/bg.png) } +.big-719 { color: var(--brand); background: url(img/bg.png) } +.big-720 { color: var(--brand); background: url(img/bg.png) } +.big-721 { color: var(--brand); background: url(img/bg.png) } +.big-722 { color: var(--brand); background: url(img/bg.png) } +.big-723 { color: var(--brand); background: url(img/bg.png) } +.big-724 { color: var(--brand); background: url(img/bg.png) } +.big-725 { color: var(--brand); background: url(img/bg.png) } +.big-726 { color: var(--brand); background: url(img/bg.png) } +.big-727 { color: var(--brand); background: url(img/bg.png) } +.big-728 { color: var(--brand); background: url(img/bg.png) } +.big-729 { color: var(--brand); background: url(img/bg.png) } +.big-730 { color: var(--brand); background: url(img/bg.png) } +.big-731 { color: var(--brand); background: url(img/bg.png) } +.big-732 { color: var(--brand); background: url(img/bg.png) } +.big-733 { color: var(--brand); background: url(img/bg.png) } +.big-734 { color: var(--brand); background: url(img/bg.png) } +.big-735 { color: var(--brand); background: url(img/bg.png) } +.big-736 { color: var(--brand); background: url(img/bg.png) } +.big-737 { color: var(--brand); background: url(img/bg.png) } +.big-738 { color: var(--brand); background: url(img/bg.png) } +.big-739 { color: var(--brand); background: url(img/bg.png) } +.big-740 { color: var(--brand); background: url(img/bg.png) } +.big-741 { color: var(--brand); background: url(img/bg.png) } +.big-742 { color: var(--brand); background: url(img/bg.png) } +.big-743 { color: var(--brand); background: url(img/bg.png) } +.big-744 { color: var(--brand); background: url(img/bg.png) } +.big-745 { color: var(--brand); background: url(img/bg.png) } +.big-746 { color: var(--brand); background: url(img/bg.png) } +.big-747 { color: var(--brand); background: url(img/bg.png) } +.big-748 { color: var(--brand); background: url(img/bg.png) } +.big-749 { color: var(--brand); background: url(img/bg.png) } +.big-750 { color: var(--brand); background: url(img/bg.png) } +.big-751 { color: var(--brand); background: url(img/bg.png) } +.big-752 { color: var(--brand); background: url(img/bg.png) } +.big-753 { color: var(--brand); background: url(img/bg.png) } +.big-754 { color: var(--brand); background: url(img/bg.png) } +.big-755 { color: var(--brand); background: url(img/bg.png) } +.big-756 { color: var(--brand); background: url(img/bg.png) } +.big-757 { color: var(--brand); background: url(img/bg.png) } +.big-758 { color: var(--brand); background: url(img/bg.png) } +.big-759 { color: var(--brand); background: url(img/bg.png) } +.big-760 { color: var(--brand); background: url(img/bg.png) } +.big-761 { color: var(--brand); background: url(img/bg.png) } +.big-762 { color: var(--brand); background: url(img/bg.png) } +.big-763 { color: var(--brand); background: url(img/bg.png) } +.big-764 { color: var(--brand); background: url(img/bg.png) } +.big-765 { color: var(--brand); background: url(img/bg.png) } +.big-766 { color: var(--brand); background: url(img/bg.png) } +.big-767 { color: var(--brand); background: url(img/bg.png) } +.big-768 { color: var(--brand); background: url(img/bg.png) } +.big-769 { color: var(--brand); background: url(img/bg.png) } +.big-770 { color: var(--brand); background: url(img/bg.png) } +.big-771 { color: var(--brand); background: url(img/bg.png) } +.big-772 { color: var(--brand); background: url(img/bg.png) } +.big-773 { color: var(--brand); background: url(img/bg.png) } +.big-774 { color: var(--brand); background: url(img/bg.png) } +.big-775 { color: var(--brand); background: url(img/bg.png) } +.big-776 { color: var(--brand); background: url(img/bg.png) } +.big-777 { color: var(--brand); background: url(img/bg.png) } +.big-778 { color: var(--brand); background: url(img/bg.png) } +.big-779 { color: var(--brand); background: url(img/bg.png) } +.big-780 { color: var(--brand); background: url(img/bg.png) } +.big-781 { color: var(--brand); background: url(img/bg.png) } +.big-782 { color: var(--brand); background: url(img/bg.png) } +.big-783 { color: var(--brand); background: url(img/bg.png) } +.big-784 { color: var(--brand); background: url(img/bg.png) } +.big-785 { color: var(--brand); background: url(img/bg.png) } +.big-786 { color: var(--brand); background: url(img/bg.png) } +.big-787 { color: var(--brand); background: url(img/bg.png) } +.big-788 { color: var(--brand); background: url(img/bg.png) } +.big-789 { color: var(--brand); background: url(img/bg.png) } +.big-790 { color: var(--brand); background: url(img/bg.png) } +.big-791 { color: var(--brand); background: url(img/bg.png) } +.big-792 { color: var(--brand); background: url(img/bg.png) } +.big-793 { color: var(--brand); background: url(img/bg.png) } +.big-794 { color: var(--brand); background: url(img/bg.png) } +.big-795 { color: var(--brand); background: url(img/bg.png) } +.big-796 { color: var(--brand); background: url(img/bg.png) } +.big-797 { color: var(--brand); background: url(img/bg.png) } +.big-798 { color: var(--brand); background: url(img/bg.png) } +.big-799 { color: var(--brand); background: url(img/bg.png) } +.big-800 { color: var(--brand); background: url(img/bg.png) } +.big-801 { color: var(--brand); background: url(img/bg.png) } +.big-802 { color: var(--brand); background: url(img/bg.png) } +.big-803 { color: var(--brand); background: url(img/bg.png) } +.big-804 { color: var(--brand); background: url(img/bg.png) } +.big-805 { color: var(--brand); background: url(img/bg.png) } +.big-806 { color: var(--brand); background: url(img/bg.png) } +.big-807 { color: var(--brand); background: url(img/bg.png) } +.big-808 { color: var(--brand); background: url(img/bg.png) } +.big-809 { color: var(--brand); background: url(img/bg.png) } +.big-810 { color: var(--brand); background: url(img/bg.png) } +.big-811 { color: var(--brand); background: url(img/bg.png) } +.big-812 { color: var(--brand); background: url(img/bg.png) } +.big-813 { color: var(--brand); background: url(img/bg.png) } +.big-814 { color: var(--brand); background: url(img/bg.png) } +.big-815 { color: var(--brand); background: url(img/bg.png) } +.big-816 { color: var(--brand); background: url(img/bg.png) } +.big-817 { color: var(--brand); background: url(img/bg.png) } +.big-818 { color: var(--brand); background: url(img/bg.png) } +.big-819 { color: var(--brand); background: url(img/bg.png) } +.big-820 { color: var(--brand); background: url(img/bg.png) } +.big-821 { color: var(--brand); background: url(img/bg.png) } +.big-822 { color: var(--brand); background: url(img/bg.png) } +.big-823 { color: var(--brand); background: url(img/bg.png) } +.big-824 { color: var(--brand); background: url(img/bg.png) } +.big-825 { color: var(--brand); background: url(img/bg.png) } +.big-826 { color: var(--brand); background: url(img/bg.png) } +.big-827 { color: var(--brand); background: url(img/bg.png) } +.big-828 { color: var(--brand); background: url(img/bg.png) } +.big-829 { color: var(--brand); background: url(img/bg.png) } +.big-830 { color: var(--brand); background: url(img/bg.png) } +.big-831 { color: var(--brand); background: url(img/bg.png) } +.big-832 { color: var(--brand); background: url(img/bg.png) } +.big-833 { color: var(--brand); background: url(img/bg.png) } +.big-834 { color: var(--brand); background: url(img/bg.png) } +.big-835 { color: var(--brand); background: url(img/bg.png) } +.big-836 { color: var(--brand); background: url(img/bg.png) } +.big-837 { color: var(--brand); background: url(img/bg.png) } +.big-838 { color: var(--brand); background: url(img/bg.png) } +.big-839 { color: var(--brand); background: url(img/bg.png) } +.big-840 { color: var(--brand); background: url(img/bg.png) } +.big-841 { color: var(--brand); background: url(img/bg.png) } +.big-842 { color: var(--brand); background: url(img/bg.png) } +.big-843 { color: var(--brand); background: url(img/bg.png) } +.big-844 { color: var(--brand); background: url(img/bg.png) } +.big-845 { color: var(--brand); background: url(img/bg.png) } +.big-846 { color: var(--brand); background: url(img/bg.png) } +.big-847 { color: var(--brand); background: url(img/bg.png) } +.big-848 { color: var(--brand); background: url(img/bg.png) } +.big-849 { color: var(--brand); background: url(img/bg.png) } +.big-850 { color: var(--brand); background: url(img/bg.png) } +.big-851 { color: var(--brand); background: url(img/bg.png) } +.big-852 { color: var(--brand); background: url(img/bg.png) } +.big-853 { color: var(--brand); background: url(img/bg.png) } +.big-854 { color: var(--brand); background: url(img/bg.png) } +.big-855 { color: var(--brand); background: url(img/bg.png) } +.big-856 { color: var(--brand); background: url(img/bg.png) } +.big-857 { color: var(--brand); background: url(img/bg.png) } +.big-858 { color: var(--brand); background: url(img/bg.png) } +.big-859 { color: var(--brand); background: url(img/bg.png) } +.big-860 { color: var(--brand); background: url(img/bg.png) } +.big-861 { color: var(--brand); background: url(img/bg.png) } +.big-862 { color: var(--brand); background: url(img/bg.png) } +.big-863 { color: var(--brand); background: url(img/bg.png) } +.big-864 { color: var(--brand); background: url(img/bg.png) } +.big-865 { color: var(--brand); background: url(img/bg.png) } +.big-866 { color: var(--brand); background: url(img/bg.png) } +.big-867 { color: var(--brand); background: url(img/bg.png) } +.big-868 { color: var(--brand); background: url(img/bg.png) } +.big-869 { color: var(--brand); background: url(img/bg.png) } +.big-870 { color: var(--brand); background: url(img/bg.png) } +.big-871 { color: var(--brand); background: url(img/bg.png) } +.big-872 { color: var(--brand); background: url(img/bg.png) } +.big-873 { color: var(--brand); background: url(img/bg.png) } +.big-874 { color: var(--brand); background: url(img/bg.png) } +.big-875 { color: var(--brand); background: url(img/bg.png) } +.big-876 { color: var(--brand); background: url(img/bg.png) } +.big-877 { color: var(--brand); background: url(img/bg.png) } +.big-878 { color: var(--brand); background: url(img/bg.png) } +.big-879 { color: var(--brand); background: url(img/bg.png) } +.big-880 { color: var(--brand); background: url(img/bg.png) } +.big-881 { color: var(--brand); background: url(img/bg.png) } +.big-882 { color: var(--brand); background: url(img/bg.png) } +.big-883 { color: var(--brand); background: url(img/bg.png) } +.big-884 { color: var(--brand); background: url(img/bg.png) } +.big-885 { color: var(--brand); background: url(img/bg.png) } +.big-886 { color: var(--brand); background: url(img/bg.png) } +.big-887 { color: var(--brand); background: url(img/bg.png) } +.big-888 { color: var(--brand); background: url(img/bg.png) } +.big-889 { color: var(--brand); background: url(img/bg.png) } +.big-890 { color: var(--brand); background: url(img/bg.png) } +.big-891 { color: var(--brand); background: url(img/bg.png) } +.big-892 { color: var(--brand); background: url(img/bg.png) } +.big-893 { color: var(--brand); background: url(img/bg.png) } +.big-894 { color: var(--brand); background: url(img/bg.png) } +.big-895 { color: var(--brand); background: url(img/bg.png) } +.big-896 { color: var(--brand); background: url(img/bg.png) } +.big-897 { color: var(--brand); background: url(img/bg.png) } +.big-898 { color: var(--brand); background: url(img/bg.png) } +.big-899 { color: var(--brand); background: url(img/bg.png) } +.big-900 { color: var(--brand); background: url(img/bg.png) } +.big-901 { color: var(--brand); background: url(img/bg.png) } +.big-902 { color: var(--brand); background: url(img/bg.png) } +.big-903 { color: var(--brand); background: url(img/bg.png) } +.big-904 { color: var(--brand); background: url(img/bg.png) } +.big-905 { color: var(--brand); background: url(img/bg.png) } +.big-906 { color: var(--brand); background: url(img/bg.png) } +.big-907 { color: var(--brand); background: url(img/bg.png) } +.big-908 { color: var(--brand); background: url(img/bg.png) } +.big-909 { color: var(--brand); background: url(img/bg.png) } +.big-910 { color: var(--brand); background: url(img/bg.png) } +.big-911 { color: var(--brand); background: url(img/bg.png) } +.big-912 { color: var(--brand); background: url(img/bg.png) } +.big-913 { color: var(--brand); background: url(img/bg.png) } +.big-914 { color: var(--brand); background: url(img/bg.png) } +.big-915 { color: var(--brand); background: url(img/bg.png) } +.big-916 { color: var(--brand); background: url(img/bg.png) } +.big-917 { color: var(--brand); background: url(img/bg.png) } +.big-918 { color: var(--brand); background: url(img/bg.png) } +.big-919 { color: var(--brand); background: url(img/bg.png) } +.big-920 { color: var(--brand); background: url(img/bg.png) } +.big-921 { color: var(--brand); background: url(img/bg.png) } +.big-922 { color: var(--brand); background: url(img/bg.png) } +.big-923 { color: var(--brand); background: url(img/bg.png) } +.big-924 { color: var(--brand); background: url(img/bg.png) } +.big-925 { color: var(--brand); background: url(img/bg.png) } +.big-926 { color: var(--brand); background: url(img/bg.png) } +.big-927 { color: var(--brand); background: url(img/bg.png) } +.big-928 { color: var(--brand); background: url(img/bg.png) } +.big-929 { color: var(--brand); background: url(img/bg.png) } +.big-930 { color: var(--brand); background: url(img/bg.png) } +.big-931 { color: var(--brand); background: url(img/bg.png) } +.big-932 { color: var(--brand); background: url(img/bg.png) } +.big-933 { color: var(--brand); background: url(img/bg.png) } +.big-934 { color: var(--brand); background: url(img/bg.png) } +.big-935 { color: var(--brand); background: url(img/bg.png) } +.big-936 { color: var(--brand); background: url(img/bg.png) } +.big-937 { color: var(--brand); background: url(img/bg.png) } +.big-938 { color: var(--brand); background: url(img/bg.png) } +.big-939 { color: var(--brand); background: url(img/bg.png) } +.big-940 { color: var(--brand); background: url(img/bg.png) } +.big-941 { color: var(--brand); background: url(img/bg.png) } +.big-942 { color: var(--brand); background: url(img/bg.png) } +.big-943 { color: var(--brand); background: url(img/bg.png) } +.big-944 { color: var(--brand); background: url(img/bg.png) } +.big-945 { color: var(--brand); background: url(img/bg.png) } +.big-946 { color: var(--brand); background: url(img/bg.png) } +.big-947 { color: var(--brand); background: url(img/bg.png) } +.big-948 { color: var(--brand); background: url(img/bg.png) } +.big-949 { color: var(--brand); background: url(img/bg.png) } +.big-950 { color: var(--brand); background: url(img/bg.png) } +.big-951 { color: var(--brand); background: url(img/bg.png) } +.big-952 { color: var(--brand); background: url(img/bg.png) } +.big-953 { color: var(--brand); background: url(img/bg.png) } +.big-954 { color: var(--brand); background: url(img/bg.png) } +.big-955 { color: var(--brand); background: url(img/bg.png) } +.big-956 { color: var(--brand); background: url(img/bg.png) } +.big-957 { color: var(--brand); background: url(img/bg.png) } +.big-958 { color: var(--brand); background: url(img/bg.png) } +.big-959 { color: var(--brand); background: url(img/bg.png) } +.big-960 { color: var(--brand); background: url(img/bg.png) } +.big-961 { color: var(--brand); background: url(img/bg.png) } +.big-962 { color: var(--brand); background: url(img/bg.png) } +.big-963 { color: var(--brand); background: url(img/bg.png) } +.big-964 { color: var(--brand); background: url(img/bg.png) } +.big-965 { color: var(--brand); background: url(img/bg.png) } +.big-966 { color: var(--brand); background: url(img/bg.png) } +.big-967 { color: var(--brand); background: url(img/bg.png) } +.big-968 { color: var(--brand); background: url(img/bg.png) } +.big-969 { color: var(--brand); background: url(img/bg.png) } +.big-970 { color: var(--brand); background: url(img/bg.png) } +.big-971 { color: var(--brand); background: url(img/bg.png) } +.big-972 { color: var(--brand); background: url(img/bg.png) } +.big-973 { color: var(--brand); background: url(img/bg.png) } +.big-974 { color: var(--brand); background: url(img/bg.png) } +.big-975 { color: var(--brand); background: url(img/bg.png) } +.big-976 { color: var(--brand); background: url(img/bg.png) } +.big-977 { color: var(--brand); background: url(img/bg.png) } +.big-978 { color: var(--brand); background: url(img/bg.png) } +.big-979 { color: var(--brand); background: url(img/bg.png) } +.big-980 { color: var(--brand); background: url(img/bg.png) } +.big-981 { color: var(--brand); background: url(img/bg.png) } +.big-982 { color: var(--brand); background: url(img/bg.png) } +.big-983 { color: var(--brand); background: url(img/bg.png) } +.big-984 { color: var(--brand); background: url(img/bg.png) } +.big-985 { color: var(--brand); background: url(img/bg.png) } +.big-986 { color: var(--brand); background: url(img/bg.png) } +.big-987 { color: var(--brand); background: url(img/bg.png) } +.big-988 { color: var(--brand); background: url(img/bg.png) } +.big-989 { color: var(--brand); background: url(img/bg.png) } +.big-990 { color: var(--brand); background: url(img/bg.png) } +.big-991 { color: var(--brand); background: url(img/bg.png) } +.big-992 { color: var(--brand); background: url(img/bg.png) } +.big-993 { color: var(--brand); background: url(img/bg.png) } +.big-994 { color: var(--brand); background: url(img/bg.png) } +.big-995 { color: var(--brand); background: url(img/bg.png) } +.big-996 { color: var(--brand); background: url(img/bg.png) } +.big-997 { color: var(--brand); background: url(img/bg.png) } +.big-998 { color: var(--brand); background: url(img/bg.png) } +.big-999 { color: var(--brand); background: url(img/bg.png) } +.big-1000 { color: var(--brand); background: url(img/bg.png) } +.big-1001 { color: var(--brand); background: url(img/bg.png) } +.big-1002 { color: var(--brand); background: url(img/bg.png) } +.big-1003 { color: var(--brand); background: url(img/bg.png) } +.big-1004 { color: var(--brand); background: url(img/bg.png) } +.big-1005 { color: var(--brand); background: url(img/bg.png) } +.big-1006 { color: var(--brand); background: url(img/bg.png) } +.big-1007 { color: var(--brand); background: url(img/bg.png) } +.big-1008 { color: var(--brand); background: url(img/bg.png) } +.big-1009 { color: var(--brand); background: url(img/bg.png) } +.big-1010 { color: var(--brand); background: url(img/bg.png) } +.big-1011 { color: var(--brand); background: url(img/bg.png) } +.big-1012 { color: var(--brand); background: url(img/bg.png) } +.big-1013 { color: var(--brand); background: url(img/bg.png) } +.big-1014 { color: var(--brand); background: url(img/bg.png) } +.big-1015 { color: var(--brand); background: url(img/bg.png) } +.big-1016 { color: var(--brand); background: url(img/bg.png) } +.big-1017 { color: var(--brand); background: url(img/bg.png) } +.big-1018 { color: var(--brand); background: url(img/bg.png) } +.big-1019 { color: var(--brand); background: url(img/bg.png) } +.big-1020 { color: var(--brand); background: url(img/bg.png) } +.big-1021 { color: var(--brand); background: url(img/bg.png) } +.big-1022 { color: var(--brand); background: url(img/bg.png) } +.big-1023 { color: var(--brand); background: url(img/bg.png) } +.big-1024 { color: var(--brand); background: url(img/bg.png) } +.big-1025 { color: var(--brand); background: url(img/bg.png) } +.big-1026 { color: var(--brand); background: url(img/bg.png) } +.big-1027 { color: var(--brand); background: url(img/bg.png) } +.big-1028 { color: var(--brand); background: url(img/bg.png) } +.big-1029 { color: var(--brand); background: url(img/bg.png) } +.big-1030 { color: var(--brand); background: url(img/bg.png) } +.big-1031 { color: var(--brand); background: url(img/bg.png) } +.big-1032 { color: var(--brand); background: url(img/bg.png) } +.big-1033 { color: var(--brand); background: url(img/bg.png) } +.big-1034 { color: var(--brand); background: url(img/bg.png) } +.big-1035 { color: var(--brand); background: url(img/bg.png) } +.big-1036 { color: var(--brand); background: url(img/bg.png) } +.big-1037 { color: var(--brand); background: url(img/bg.png) } +.big-1038 { color: var(--brand); background: url(img/bg.png) } +.big-1039 { color: var(--brand); background: url(img/bg.png) } +.big-1040 { color: var(--brand); background: url(img/bg.png) } +.big-1041 { color: var(--brand); background: url(img/bg.png) } +.big-1042 { color: var(--brand); background: url(img/bg.png) } +.big-1043 { color: var(--brand); background: url(img/bg.png) } +.big-1044 { color: var(--brand); background: url(img/bg.png) } +.big-1045 { color: var(--brand); background: url(img/bg.png) } +.big-1046 { color: var(--brand); background: url(img/bg.png) } +.big-1047 { color: var(--brand); background: url(img/bg.png) } +.big-1048 { color: var(--brand); background: url(img/bg.png) } +.big-1049 { color: var(--brand); background: url(img/bg.png) } +.big-1050 { color: var(--brand); background: url(img/bg.png) } +.big-1051 { color: var(--brand); background: url(img/bg.png) } +.big-1052 { color: var(--brand); background: url(img/bg.png) } +.big-1053 { color: var(--brand); background: url(img/bg.png) } +.big-1054 { color: var(--brand); background: url(img/bg.png) } +.big-1055 { color: var(--brand); background: url(img/bg.png) } +.big-1056 { color: var(--brand); background: url(img/bg.png) } +.big-1057 { color: var(--brand); background: url(img/bg.png) } +.big-1058 { color: var(--brand); background: url(img/bg.png) } +.big-1059 { color: var(--brand); background: url(img/bg.png) } +.big-1060 { color: var(--brand); background: url(img/bg.png) } +.big-1061 { color: var(--brand); background: url(img/bg.png) } +.big-1062 { color: var(--brand); background: url(img/bg.png) } +.big-1063 { color: var(--brand); background: url(img/bg.png) } +.big-1064 { color: var(--brand); background: url(img/bg.png) } +.big-1065 { color: var(--brand); background: url(img/bg.png) } +.big-1066 { color: var(--brand); background: url(img/bg.png) } +.big-1067 { color: var(--brand); background: url(img/bg.png) } +.big-1068 { color: var(--brand); background: url(img/bg.png) } +.big-1069 { color: var(--brand); background: url(img/bg.png) } +.big-1070 { color: var(--brand); background: url(img/bg.png) } +.big-1071 { color: var(--brand); background: url(img/bg.png) } +.big-1072 { color: var(--brand); background: url(img/bg.png) } +.big-1073 { color: var(--brand); background: url(img/bg.png) } +.big-1074 { color: var(--brand); background: url(img/bg.png) } +.big-1075 { color: var(--brand); background: url(img/bg.png) } +.big-1076 { color: var(--brand); background: url(img/bg.png) } +.big-1077 { color: var(--brand); background: url(img/bg.png) } +.big-1078 { color: var(--brand); background: url(img/bg.png) } +.big-1079 { color: var(--brand); background: url(img/bg.png) } +.big-1080 { color: var(--brand); background: url(img/bg.png) } +.big-1081 { color: var(--brand); background: url(img/bg.png) } +.big-1082 { color: var(--brand); background: url(img/bg.png) } +.big-1083 { color: var(--brand); background: url(img/bg.png) } +.big-1084 { color: var(--brand); background: url(img/bg.png) } +.big-1085 { color: var(--brand); background: url(img/bg.png) } +.big-1086 { color: var(--brand); background: url(img/bg.png) } +.big-1087 { color: var(--brand); background: url(img/bg.png) } +.big-1088 { color: var(--brand); background: url(img/bg.png) } +.big-1089 { color: var(--brand); background: url(img/bg.png) } +.big-1090 { color: var(--brand); background: url(img/bg.png) } +.big-1091 { color: var(--brand); background: url(img/bg.png) } +.big-1092 { color: var(--brand); background: url(img/bg.png) } +.big-1093 { color: var(--brand); background: url(img/bg.png) } +.big-1094 { color: var(--brand); background: url(img/bg.png) } +.big-1095 { color: var(--brand); background: url(img/bg.png) } +.big-1096 { color: var(--brand); background: url(img/bg.png) } +.big-1097 { color: var(--brand); background: url(img/bg.png) } +.big-1098 { color: var(--brand); background: url(img/bg.png) } +.big-1099 { color: var(--brand); background: url(img/bg.png) } +.big-1100 { color: var(--brand); background: url(img/bg.png) } +.big-1101 { color: var(--brand); background: url(img/bg.png) } +.big-1102 { color: var(--brand); background: url(img/bg.png) } +.big-1103 { color: var(--brand); background: url(img/bg.png) } +.big-1104 { color: var(--brand); background: url(img/bg.png) } +.big-1105 { color: var(--brand); background: url(img/bg.png) } +.big-1106 { color: var(--brand); background: url(img/bg.png) } +.big-1107 { color: var(--brand); background: url(img/bg.png) } +.big-1108 { color: var(--brand); background: url(img/bg.png) } +.big-1109 { color: var(--brand); background: url(img/bg.png) } +.big-1110 { color: var(--brand); background: url(img/bg.png) } +.big-1111 { color: var(--brand); background: url(img/bg.png) } +.big-1112 { color: var(--brand); background: url(img/bg.png) } +.big-1113 { color: var(--brand); background: url(img/bg.png) } +.big-1114 { color: var(--brand); background: url(img/bg.png) } +.big-1115 { color: var(--brand); background: url(img/bg.png) } +.big-1116 { color: var(--brand); background: url(img/bg.png) } +.big-1117 { color: var(--brand); background: url(img/bg.png) } +.big-1118 { color: var(--brand); background: url(img/bg.png) } +.big-1119 { color: var(--brand); background: url(img/bg.png) } +.big-1120 { color: var(--brand); background: url(img/bg.png) } +.big-1121 { color: var(--brand); background: url(img/bg.png) } +.big-1122 { color: var(--brand); background: url(img/bg.png) } +.big-1123 { color: var(--brand); background: url(img/bg.png) } +.big-1124 { color: var(--brand); background: url(img/bg.png) } +.big-1125 { color: var(--brand); background: url(img/bg.png) } +.big-1126 { color: var(--brand); background: url(img/bg.png) } +.big-1127 { color: var(--brand); background: url(img/bg.png) } +.big-1128 { color: var(--brand); background: url(img/bg.png) } +.big-1129 { color: var(--brand); background: url(img/bg.png) } +.big-1130 { color: var(--brand); background: url(img/bg.png) } +.big-1131 { color: var(--brand); background: url(img/bg.png) } +.big-1132 { color: var(--brand); background: url(img/bg.png) } +.big-1133 { color: var(--brand); background: url(img/bg.png) } +.big-1134 { color: var(--brand); background: url(img/bg.png) } +.big-1135 { color: var(--brand); background: url(img/bg.png) } +.big-1136 { color: var(--brand); background: url(img/bg.png) } +.big-1137 { color: var(--brand); background: url(img/bg.png) } +.big-1138 { color: var(--brand); background: url(img/bg.png) } +.big-1139 { color: var(--brand); background: url(img/bg.png) } +.big-1140 { color: var(--brand); background: url(img/bg.png) } +.big-1141 { color: var(--brand); background: url(img/bg.png) } +.big-1142 { color: var(--brand); background: url(img/bg.png) } +.big-1143 { color: var(--brand); background: url(img/bg.png) } +.big-1144 { color: var(--brand); background: url(img/bg.png) } +.big-1145 { color: var(--brand); background: url(img/bg.png) } +.big-1146 { color: var(--brand); background: url(img/bg.png) } +.big-1147 { color: var(--brand); background: url(img/bg.png) } +.big-1148 { color: var(--brand); background: url(img/bg.png) } +.big-1149 { color: var(--brand); background: url(img/bg.png) } +.big-1150 { color: var(--brand); background: url(img/bg.png) } +.big-1151 { color: var(--brand); background: url(img/bg.png) } +.big-1152 { color: var(--brand); background: url(img/bg.png) } +.big-1153 { color: var(--brand); background: url(img/bg.png) } +.big-1154 { color: var(--brand); background: url(img/bg.png) } +.big-1155 { color: var(--brand); background: url(img/bg.png) } +.big-1156 { color: var(--brand); background: url(img/bg.png) } +.big-1157 { color: var(--brand); background: url(img/bg.png) } +.big-1158 { color: var(--brand); background: url(img/bg.png) } +.big-1159 { color: var(--brand); background: url(img/bg.png) } +.big-1160 { color: var(--brand); background: url(img/bg.png) } +.big-1161 { color: var(--brand); background: url(img/bg.png) } +.big-1162 { color: var(--brand); background: url(img/bg.png) } +.big-1163 { color: var(--brand); background: url(img/bg.png) } +.big-1164 { color: var(--brand); background: url(img/bg.png) } +.big-1165 { color: var(--brand); background: url(img/bg.png) } +.big-1166 { color: var(--brand); background: url(img/bg.png) } +.big-1167 { color: var(--brand); background: url(img/bg.png) } +.big-1168 { color: var(--brand); background: url(img/bg.png) } +.big-1169 { color: var(--brand); background: url(img/bg.png) } +.big-1170 { color: var(--brand); background: url(img/bg.png) } +.big-1171 { color: var(--brand); background: url(img/bg.png) } +.big-1172 { color: var(--brand); background: url(img/bg.png) } +.big-1173 { color: var(--brand); background: url(img/bg.png) } +.big-1174 { color: var(--brand); background: url(img/bg.png) } +.big-1175 { color: var(--brand); background: url(img/bg.png) } +.big-1176 { color: var(--brand); background: url(img/bg.png) } +.big-1177 { color: var(--brand); background: url(img/bg.png) } +.big-1178 { color: var(--brand); background: url(img/bg.png) } +.big-1179 { color: var(--brand); background: url(img/bg.png) } +.big-1180 { color: var(--brand); background: url(img/bg.png) } +.big-1181 { color: var(--brand); background: url(img/bg.png) } +.big-1182 { color: var(--brand); background: url(img/bg.png) } +.big-1183 { color: var(--brand); background: url(img/bg.png) } +.big-1184 { color: var(--brand); background: url(img/bg.png) } +.big-1185 { color: var(--brand); background: url(img/bg.png) } +.big-1186 { color: var(--brand); background: url(img/bg.png) } +.big-1187 { color: var(--brand); background: url(img/bg.png) } +.big-1188 { color: var(--brand); background: url(img/bg.png) } +.big-1189 { color: var(--brand); background: url(img/bg.png) } +.big-1190 { color: var(--brand); background: url(img/bg.png) } +.big-1191 { color: var(--brand); background: url(img/bg.png) } +.big-1192 { color: var(--brand); background: url(img/bg.png) } +.big-1193 { color: var(--brand); background: url(img/bg.png) } +.big-1194 { color: var(--brand); background: url(img/bg.png) } +.big-1195 { color: var(--brand); background: url(img/bg.png) } +.big-1196 { color: var(--brand); background: url(img/bg.png) } +.big-1197 { color: var(--brand); background: url(img/bg.png) } +.big-1198 { color: var(--brand); background: url(img/bg.png) } +.big-1199 { color: var(--brand); background: url(img/bg.png) } +.big-1200 { color: var(--brand); background: url(img/bg.png) } +.big-1201 { color: var(--brand); background: url(img/bg.png) } +.big-1202 { color: var(--brand); background: url(img/bg.png) } +.big-1203 { color: var(--brand); background: url(img/bg.png) } +.big-1204 { color: var(--brand); background: url(img/bg.png) } +.big-1205 { color: var(--brand); background: url(img/bg.png) } +.big-1206 { color: var(--brand); background: url(img/bg.png) } +.big-1207 { color: var(--brand); background: url(img/bg.png) } +.big-1208 { color: var(--brand); background: url(img/bg.png) } +.big-1209 { color: var(--brand); background: url(img/bg.png) } +.big-1210 { color: var(--brand); background: url(img/bg.png) } +.big-1211 { color: var(--brand); background: url(img/bg.png) } +.big-1212 { color: var(--brand); background: url(img/bg.png) } +.big-1213 { color: var(--brand); background: url(img/bg.png) } +.big-1214 { color: var(--brand); background: url(img/bg.png) } +.big-1215 { color: var(--brand); background: url(img/bg.png) } +.big-1216 { color: var(--brand); background: url(img/bg.png) } +.big-1217 { color: var(--brand); background: url(img/bg.png) } +.big-1218 { color: var(--brand); background: url(img/bg.png) } +.big-1219 { color: var(--brand); background: url(img/bg.png) } +.big-1220 { color: var(--brand); background: url(img/bg.png) } +.big-1221 { color: var(--brand); background: url(img/bg.png) } +.big-1222 { color: var(--brand); background: url(img/bg.png) } +.big-1223 { color: var(--brand); background: url(img/bg.png) } +.big-1224 { color: var(--brand); background: url(img/bg.png) } +.big-1225 { color: var(--brand); background: url(img/bg.png) } +.big-1226 { color: var(--brand); background: url(img/bg.png) } +.big-1227 { color: var(--brand); background: url(img/bg.png) } +.big-1228 { color: var(--brand); background: url(img/bg.png) } +.big-1229 { color: var(--brand); background: url(img/bg.png) } +.big-1230 { color: var(--brand); background: url(img/bg.png) } +.big-1231 { color: var(--brand); background: url(img/bg.png) } +.big-1232 { color: var(--brand); background: url(img/bg.png) } +.big-1233 { color: var(--brand); background: url(img/bg.png) } +.big-1234 { color: var(--brand); background: url(img/bg.png) } +.big-1235 { color: var(--brand); background: url(img/bg.png) } +.big-1236 { color: var(--brand); background: url(img/bg.png) } +.big-1237 { color: var(--brand); background: url(img/bg.png) } +.big-1238 { color: var(--brand); background: url(img/bg.png) } +.big-1239 { color: var(--brand); background: url(img/bg.png) } +.big-1240 { color: var(--brand); background: url(img/bg.png) } +.big-1241 { color: var(--brand); background: url(img/bg.png) } +.big-1242 { color: var(--brand); background: url(img/bg.png) } +.big-1243 { color: var(--brand); background: url(img/bg.png) } +.big-1244 { color: var(--brand); background: url(img/bg.png) } +.big-1245 { color: var(--brand); background: url(img/bg.png) } +.big-1246 { color: var(--brand); background: url(img/bg.png) } +.big-1247 { color: var(--brand); background: url(img/bg.png) } +.big-1248 { color: var(--brand); background: url(img/bg.png) } +.big-1249 { color: var(--brand); background: url(img/bg.png) } +.big-1250 { color: var(--brand); background: url(img/bg.png) } +.big-1251 { color: var(--brand); background: url(img/bg.png) } +.big-1252 { color: var(--brand); background: url(img/bg.png) } +.big-1253 { color: var(--brand); background: url(img/bg.png) } +.big-1254 { color: var(--brand); background: url(img/bg.png) } +.big-1255 { color: var(--brand); background: url(img/bg.png) } +.big-1256 { color: var(--brand); background: url(img/bg.png) } +.big-1257 { color: var(--brand); background: url(img/bg.png) } +.big-1258 { color: var(--brand); background: url(img/bg.png) } +.big-1259 { color: var(--brand); background: url(img/bg.png) } +.big-1260 { color: var(--brand); background: url(img/bg.png) } +.big-1261 { color: var(--brand); background: url(img/bg.png) } +.big-1262 { color: var(--brand); background: url(img/bg.png) } +.big-1263 { color: var(--brand); background: url(img/bg.png) } +.big-1264 { color: var(--brand); background: url(img/bg.png) } +.big-1265 { color: var(--brand); background: url(img/bg.png) } +.big-1266 { color: var(--brand); background: url(img/bg.png) } +.big-1267 { color: var(--brand); background: url(img/bg.png) } +.big-1268 { color: var(--brand); background: url(img/bg.png) } +.big-1269 { color: var(--brand); background: url(img/bg.png) } +.big-1270 { color: var(--brand); background: url(img/bg.png) } +.big-1271 { color: var(--brand); background: url(img/bg.png) } +.big-1272 { color: var(--brand); background: url(img/bg.png) } +.big-1273 { color: var(--brand); background: url(img/bg.png) } +.big-1274 { color: var(--brand); background: url(img/bg.png) } +.big-1275 { color: var(--brand); background: url(img/bg.png) } +.big-1276 { color: var(--brand); background: url(img/bg.png) } +.big-1277 { color: var(--brand); background: url(img/bg.png) } +.big-1278 { color: var(--brand); background: url(img/bg.png) } +.big-1279 { color: var(--brand); background: url(img/bg.png) } +.big-1280 { color: var(--brand); background: url(img/bg.png) } +.big-1281 { color: var(--brand); background: url(img/bg.png) } +.big-1282 { color: var(--brand); background: url(img/bg.png) } +.big-1283 { color: var(--brand); background: url(img/bg.png) } +.big-1284 { color: var(--brand); background: url(img/bg.png) } +.big-1285 { color: var(--brand); background: url(img/bg.png) } +.big-1286 { color: var(--brand); background: url(img/bg.png) } +.big-1287 { color: var(--brand); background: url(img/bg.png) } +.big-1288 { color: var(--brand); background: url(img/bg.png) } +.big-1289 { color: var(--brand); background: url(img/bg.png) } +.big-1290 { color: var(--brand); background: url(img/bg.png) } +.big-1291 { color: var(--brand); background: url(img/bg.png) } +.big-1292 { color: var(--brand); background: url(img/bg.png) } +.big-1293 { color: var(--brand); background: url(img/bg.png) } +.big-1294 { color: var(--brand); background: url(img/bg.png) } +.big-1295 { color: var(--brand); background: url(img/bg.png) } +.big-1296 { color: var(--brand); background: url(img/bg.png) } +.big-1297 { color: var(--brand); background: url(img/bg.png) } +.big-1298 { color: var(--brand); background: url(img/bg.png) } +.big-1299 { color: var(--brand); background: url(img/bg.png) } +.big-1300 { color: var(--brand); background: url(img/bg.png) } +.big-1301 { color: var(--brand); background: url(img/bg.png) } +.big-1302 { color: var(--brand); background: url(img/bg.png) } +.big-1303 { color: var(--brand); background: url(img/bg.png) } +.big-1304 { color: var(--brand); background: url(img/bg.png) } +.big-1305 { color: var(--brand); background: url(img/bg.png) } +.big-1306 { color: var(--brand); background: url(img/bg.png) } +.big-1307 { color: var(--brand); background: url(img/bg.png) } +.big-1308 { color: var(--brand); background: url(img/bg.png) } +.big-1309 { color: var(--brand); background: url(img/bg.png) } +.big-1310 { color: var(--brand); background: url(img/bg.png) } +.big-1311 { color: var(--brand); background: url(img/bg.png) } +.big-1312 { color: var(--brand); background: url(img/bg.png) } +.big-1313 { color: var(--brand); background: url(img/bg.png) } +.big-1314 { color: var(--brand); background: url(img/bg.png) } +.big-1315 { color: var(--brand); background: url(img/bg.png) } +.big-1316 { color: var(--brand); background: url(img/bg.png) } +.big-1317 { color: var(--brand); background: url(img/bg.png) } +.big-1318 { color: var(--brand); background: url(img/bg.png) } +.big-1319 { color: var(--brand); background: url(img/bg.png) } +.big-1320 { color: var(--brand); background: url(img/bg.png) } +.big-1321 { color: var(--brand); background: url(img/bg.png) } +.big-1322 { color: var(--brand); background: url(img/bg.png) } +.big-1323 { color: var(--brand); background: url(img/bg.png) } +.big-1324 { color: var(--brand); background: url(img/bg.png) } +.big-1325 { color: var(--brand); background: url(img/bg.png) } +.big-1326 { color: var(--brand); background: url(img/bg.png) } +.big-1327 { color: var(--brand); background: url(img/bg.png) } +.big-1328 { color: var(--brand); background: url(img/bg.png) } +.big-1329 { color: var(--brand); background: url(img/bg.png) } +.big-1330 { color: var(--brand); background: url(img/bg.png) } +.big-1331 { color: var(--brand); background: url(img/bg.png) } +.big-1332 { color: var(--brand); background: url(img/bg.png) } +.big-1333 { color: var(--brand); background: url(img/bg.png) } +.big-1334 { color: var(--brand); background: url(img/bg.png) } +.big-1335 { color: var(--brand); background: url(img/bg.png) } +.big-1336 { color: var(--brand); background: url(img/bg.png) } +.big-1337 { color: var(--brand); background: url(img/bg.png) } +.big-1338 { color: var(--brand); background: url(img/bg.png) } +.big-1339 { color: var(--brand); background: url(img/bg.png) } +.big-1340 { color: var(--brand); background: url(img/bg.png) } +.big-1341 { color: var(--brand); background: url(img/bg.png) } +.big-1342 { color: var(--brand); background: url(img/bg.png) } +.big-1343 { color: var(--brand); background: url(img/bg.png) } +.big-1344 { color: var(--brand); background: url(img/bg.png) } +.big-1345 { color: var(--brand); background: url(img/bg.png) } +.big-1346 { color: var(--brand); background: url(img/bg.png) } +.big-1347 { color: var(--brand); background: url(img/bg.png) } +.big-1348 { color: var(--brand); background: url(img/bg.png) } +.big-1349 { color: var(--brand); background: url(img/bg.png) } +.big-1350 { color: var(--brand); background: url(img/bg.png) } +.big-1351 { color: var(--brand); background: url(img/bg.png) } +.big-1352 { color: var(--brand); background: url(img/bg.png) } +.big-1353 { color: var(--brand); background: url(img/bg.png) } +.big-1354 { color: var(--brand); background: url(img/bg.png) } +.big-1355 { color: var(--brand); background: url(img/bg.png) } +.big-1356 { color: var(--brand); background: url(img/bg.png) } +.big-1357 { color: var(--brand); background: url(img/bg.png) } +.big-1358 { color: var(--brand); background: url(img/bg.png) } +.big-1359 { color: var(--brand); background: url(img/bg.png) } +.big-1360 { color: var(--brand); background: url(img/bg.png) } +.big-1361 { color: var(--brand); background: url(img/bg.png) } +.big-1362 { color: var(--brand); background: url(img/bg.png) } +.big-1363 { color: var(--brand); background: url(img/bg.png) } +.big-1364 { color: var(--brand); background: url(img/bg.png) } +.big-1365 { color: var(--brand); background: url(img/bg.png) } +.big-1366 { color: var(--brand); background: url(img/bg.png) } +.big-1367 { color: var(--brand); background: url(img/bg.png) } +.big-1368 { color: var(--brand); background: url(img/bg.png) } +.big-1369 { color: var(--brand); background: url(img/bg.png) } +.big-1370 { color: var(--brand); background: url(img/bg.png) } +.big-1371 { color: var(--brand); background: url(img/bg.png) } +.big-1372 { color: var(--brand); background: url(img/bg.png) } +.big-1373 { color: var(--brand); background: url(img/bg.png) } +.big-1374 { color: var(--brand); background: url(img/bg.png) } +.big-1375 { color: var(--brand); background: url(img/bg.png) } +.big-1376 { color: var(--brand); background: url(img/bg.png) } +.big-1377 { color: var(--brand); background: url(img/bg.png) } +.big-1378 { color: var(--brand); background: url(img/bg.png) } +.big-1379 { color: var(--brand); background: url(img/bg.png) } +.big-1380 { color: var(--brand); background: url(img/bg.png) } +.big-1381 { color: var(--brand); background: url(img/bg.png) } +.big-1382 { color: var(--brand); background: url(img/bg.png) } +.big-1383 { color: var(--brand); background: url(img/bg.png) } +.big-1384 { color: var(--brand); background: url(img/bg.png) } +.big-1385 { color: var(--brand); background: url(img/bg.png) } +.big-1386 { color: var(--brand); background: url(img/bg.png) } +.big-1387 { color: var(--brand); background: url(img/bg.png) } +.big-1388 { color: var(--brand); background: url(img/bg.png) } +.big-1389 { color: var(--brand); background: url(img/bg.png) } +.big-1390 { color: var(--brand); background: url(img/bg.png) } +.big-1391 { color: var(--brand); background: url(img/bg.png) } +.big-1392 { color: var(--brand); background: url(img/bg.png) } +.big-1393 { color: var(--brand); background: url(img/bg.png) } +.big-1394 { color: var(--brand); background: url(img/bg.png) } +.big-1395 { color: var(--brand); background: url(img/bg.png) } +.big-1396 { color: var(--brand); background: url(img/bg.png) } +.big-1397 { color: var(--brand); background: url(img/bg.png) } +.big-1398 { color: var(--brand); background: url(img/bg.png) } +.big-1399 { color: var(--brand); background: url(img/bg.png) } +.big-1400 { color: var(--brand); background: url(img/bg.png) } +.big-1401 { color: var(--brand); background: url(img/bg.png) } +.big-1402 { color: var(--brand); background: url(img/bg.png) } +.big-1403 { color: var(--brand); background: url(img/bg.png) } +.big-1404 { color: var(--brand); background: url(img/bg.png) } +.big-1405 { color: var(--brand); background: url(img/bg.png) } +.big-1406 { color: var(--brand); background: url(img/bg.png) } +.big-1407 { color: var(--brand); background: url(img/bg.png) } +.big-1408 { color: var(--brand); background: url(img/bg.png) } +.big-1409 { color: var(--brand); background: url(img/bg.png) } +.big-1410 { color: var(--brand); background: url(img/bg.png) } +.big-1411 { color: var(--brand); background: url(img/bg.png) } +.big-1412 { color: var(--brand); background: url(img/bg.png) } +.big-1413 { color: var(--brand); background: url(img/bg.png) } +.big-1414 { color: var(--brand); background: url(img/bg.png) } +.big-1415 { color: var(--brand); background: url(img/bg.png) } +.big-1416 { color: var(--brand); background: url(img/bg.png) } +.big-1417 { color: var(--brand); background: url(img/bg.png) } +.big-1418 { color: var(--brand); background: url(img/bg.png) } +.big-1419 { color: var(--brand); background: url(img/bg.png) } +.big-1420 { color: var(--brand); background: url(img/bg.png) } +.big-1421 { color: var(--brand); background: url(img/bg.png) } +.big-1422 { color: var(--brand); background: url(img/bg.png) } +.big-1423 { color: var(--brand); background: url(img/bg.png) } +.big-1424 { color: var(--brand); background: url(img/bg.png) } +.big-1425 { color: var(--brand); background: url(img/bg.png) } +.big-1426 { color: var(--brand); background: url(img/bg.png) } +.big-1427 { color: var(--brand); background: url(img/bg.png) } +.big-1428 { color: var(--brand); background: url(img/bg.png) } +.big-1429 { color: var(--brand); background: url(img/bg.png) } +.big-1430 { color: var(--brand); background: url(img/bg.png) } +.big-1431 { color: var(--brand); background: url(img/bg.png) } +.big-1432 { color: var(--brand); background: url(img/bg.png) } +.big-1433 { color: var(--brand); background: url(img/bg.png) } +.big-1434 { color: var(--brand); background: url(img/bg.png) } +.big-1435 { color: var(--brand); background: url(img/bg.png) } +.big-1436 { color: var(--brand); background: url(img/bg.png) } +.big-1437 { color: var(--brand); background: url(img/bg.png) } +.big-1438 { color: var(--brand); background: url(img/bg.png) } +.big-1439 { color: var(--brand); background: url(img/bg.png) } +.big-1440 { color: var(--brand); background: url(img/bg.png) } +.big-1441 { color: var(--brand); background: url(img/bg.png) } +.big-1442 { color: var(--brand); background: url(img/bg.png) } +.big-1443 { color: var(--brand); background: url(img/bg.png) } +.big-1444 { color: var(--brand); background: url(img/bg.png) } +.big-1445 { color: var(--brand); background: url(img/bg.png) } +.big-1446 { color: var(--brand); background: url(img/bg.png) } +.big-1447 { color: var(--brand); background: url(img/bg.png) } +.big-1448 { color: var(--brand); background: url(img/bg.png) } +.big-1449 { color: var(--brand); background: url(img/bg.png) } +.big-1450 { color: var(--brand); background: url(img/bg.png) } +.big-1451 { color: var(--brand); background: url(img/bg.png) } +.big-1452 { color: var(--brand); background: url(img/bg.png) } +.big-1453 { color: var(--brand); background: url(img/bg.png) } +.big-1454 { color: var(--brand); background: url(img/bg.png) } +.big-1455 { color: var(--brand); background: url(img/bg.png) } +.big-1456 { color: var(--brand); background: url(img/bg.png) } +.big-1457 { color: var(--brand); background: url(img/bg.png) } +.big-1458 { color: var(--brand); background: url(img/bg.png) } +.big-1459 { color: var(--brand); background: url(img/bg.png) } +.big-1460 { color: var(--brand); background: url(img/bg.png) } +.big-1461 { color: var(--brand); background: url(img/bg.png) } +.big-1462 { color: var(--brand); background: url(img/bg.png) } +.big-1463 { color: var(--brand); background: url(img/bg.png) } +.big-1464 { color: var(--brand); background: url(img/bg.png) } +.big-1465 { color: var(--brand); background: url(img/bg.png) } +.big-1466 { color: var(--brand); background: url(img/bg.png) } +.big-1467 { color: var(--brand); background: url(img/bg.png) } +.big-1468 { color: var(--brand); background: url(img/bg.png) } +.big-1469 { color: var(--brand); background: url(img/bg.png) } +.big-1470 { color: var(--brand); background: url(img/bg.png) } +.big-1471 { color: var(--brand); background: url(img/bg.png) } +.big-1472 { color: var(--brand); background: url(img/bg.png) } +.big-1473 { color: var(--brand); background: url(img/bg.png) } +.big-1474 { color: var(--brand); background: url(img/bg.png) } +.big-1475 { color: var(--brand); background: url(img/bg.png) } +.big-1476 { color: var(--brand); background: url(img/bg.png) } +.big-1477 { color: var(--brand); background: url(img/bg.png) } +.big-1478 { color: var(--brand); background: url(img/bg.png) } +.big-1479 { color: var(--brand); background: url(img/bg.png) } +.big-1480 { color: var(--brand); background: url(img/bg.png) } +.big-1481 { color: var(--brand); background: url(img/bg.png) } +.big-1482 { color: var(--brand); background: url(img/bg.png) } +.big-1483 { color: var(--brand); background: url(img/bg.png) } +.big-1484 { color: var(--brand); background: url(img/bg.png) } +.big-1485 { color: var(--brand); background: url(img/bg.png) } +.big-1486 { color: var(--brand); background: url(img/bg.png) } +.big-1487 { color: var(--brand); background: url(img/bg.png) } +.big-1488 { color: var(--brand); background: url(img/bg.png) } +.big-1489 { color: var(--brand); background: url(img/bg.png) } +.big-1490 { color: var(--brand); background: url(img/bg.png) } +.big-1491 { color: var(--brand); background: url(img/bg.png) } +.big-1492 { color: var(--brand); background: url(img/bg.png) } +.big-1493 { color: var(--brand); background: url(img/bg.png) } +.big-1494 { color: var(--brand); background: url(img/bg.png) } +.big-1495 { color: var(--brand); background: url(img/bg.png) } +.big-1496 { color: var(--brand); background: url(img/bg.png) } +.big-1497 { color: var(--brand); background: url(img/bg.png) } +.big-1498 { color: var(--brand); background: url(img/bg.png) } +.big-1499 { color: var(--brand); background: url(img/bg.png) } diff --git a/parser/src/test-data/web/torture/css/bom.css b/parser/src/test-data/web/torture/css/bom.css new file mode 100644 index 00000000..53fd637b --- /dev/null +++ b/parser/src/test-data/web/torture/css/bom.css @@ -0,0 +1 @@ +.bom-class { color: red } diff --git a/parser/src/test-data/web/torture/css/disabled.css b/parser/src/test-data/web/torture/css/disabled.css new file mode 100644 index 00000000..bc3d0dc6 --- /dev/null +++ b/parser/src/test-data/web/torture/css/disabled.css @@ -0,0 +1 @@ +.disabled-only { color: red } diff --git a/parser/src/test-data/web/torture/css/fonts.css b/parser/src/test-data/web/torture/css/fonts.css new file mode 100644 index 00000000..d4d5fefe --- /dev/null +++ b/parser/src/test-data/web/torture/css/fonts.css @@ -0,0 +1,38 @@ +/* F01 font faces: multiple src, local(), format(), tech(), unicode-range; and the families that use them */ +@font-face { + font-family: "Inter"; + src: local("Inter"), local(Inter-Regular), + url(../fonts/inter.woff2) format("woff2") tech(variations), + url("../fonts/inter.woff") format("woff"), + url(../fonts/missing.woff) format("woff"), + url(https://fonts.example.com/inter.woff2); + font-weight: 100 900; + unicode-range: U+0000-00FF, U+0131; + font-display: swap; +} +@font-face { font-family: Inter; src: url(../fonts/inter.woff2); font-weight: bold } +@font-face { font-family: 'Roboto Mono'; src: url(../fonts/roboto.woff2) } +@font-face { font-family: "Unused Face"; src: url(../fonts/unused.woff2) } +@font-face { src: url(../fonts/nameless.woff2) } +@font-face { font-family: var(--font); src: url(../fonts/x.woff2) } +@font-face { font-family: "Segoe UI"; src: local("Segoe UI") } +.f1 { font-family: Inter } +.f2 { font-family: "Inter" } +.f3 { font-family: 'Inter' } +.f4 { font-family: inter } /* case-insensitive match in browsers */ +.f5 { font-family: Roboto Mono, monospace } +.f6 { font-family: "Roboto Mono", monospace } +.f7 { font-family: Segoe UI, Inter, Arial, sans-serif } +.f8 { font: 12px Inter } +.f9 { font: bold 12px/1 "Roboto Mono", monospace } +.f10 { font: italic small-caps bold condensed 16px/2 cursive } +.f11 { font: caption } +.f12 { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto } +.f13 { font-family: "Nonexistent Face" } +.f14 { font-family: Inter !important } +.f15 { font-family: var(--font, Inter) } +.f16 { font-family: "Inter", } +.f17 { font-family: , Inter } +.f18 { font-family: "Inter"Inter } +.f19 { font-family: Inter Inter } +.f20 { font-family: "Inter" "Inter" } diff --git a/parser/src/test-data/web/torture/css/gaps-many.css b/parser/src/test-data/web/torture/css/gaps-many.css new file mode 100644 index 00000000..c9582e34 --- /dev/null +++ b/parser/src/test-data/web/torture/css/gaps-many.css @@ -0,0 +1,300 @@ +.k0 { color: rgb(1, } +.k1 { color: rgb(1, } +.k2 { color: rgb(1, } +.k3 { color: rgb(1, } +.k4 { color: rgb(1, } +.k5 { color: rgb(1, } +.k6 { color: rgb(1, } +.k7 { color: rgb(1, } +.k8 { color: rgb(1, } +.k9 { color: rgb(1, } +.k10 { color: rgb(1, } +.k11 { color: rgb(1, } +.k12 { color: rgb(1, } +.k13 { color: rgb(1, } +.k14 { color: rgb(1, } +.k15 { color: rgb(1, } +.k16 { color: rgb(1, } +.k17 { color: rgb(1, } +.k18 { color: rgb(1, } +.k19 { color: rgb(1, } +.k20 { color: rgb(1, } +.k21 { color: rgb(1, } +.k22 { color: rgb(1, } +.k23 { color: rgb(1, } +.k24 { color: rgb(1, } +.k25 { color: rgb(1, } +.k26 { color: rgb(1, } +.k27 { color: rgb(1, } +.k28 { color: rgb(1, } +.k29 { color: rgb(1, } +.k30 { color: rgb(1, } +.k31 { color: rgb(1, } +.k32 { color: rgb(1, } +.k33 { color: rgb(1, } +.k34 { color: rgb(1, } +.k35 { color: rgb(1, } +.k36 { color: rgb(1, } +.k37 { color: rgb(1, } +.k38 { color: rgb(1, } +.k39 { color: rgb(1, } +.k40 { color: rgb(1, } +.k41 { color: rgb(1, } +.k42 { color: rgb(1, } +.k43 { color: rgb(1, } +.k44 { color: rgb(1, } +.k45 { color: rgb(1, } +.k46 { color: rgb(1, } +.k47 { color: rgb(1, } +.k48 { color: rgb(1, } +.k49 { color: rgb(1, } +.k50 { color: rgb(1, } +.k51 { color: rgb(1, } +.k52 { color: rgb(1, } +.k53 { color: rgb(1, } +.k54 { color: rgb(1, } +.k55 { color: rgb(1, } +.k56 { color: rgb(1, } +.k57 { color: rgb(1, } +.k58 { color: rgb(1, } +.k59 { color: rgb(1, } +.k60 { color: rgb(1, } +.k61 { color: rgb(1, } +.k62 { color: rgb(1, } +.k63 { color: rgb(1, } +.k64 { color: rgb(1, } +.k65 { color: rgb(1, } +.k66 { color: rgb(1, } +.k67 { color: rgb(1, } +.k68 { color: rgb(1, } +.k69 { color: rgb(1, } +.k70 { color: rgb(1, } +.k71 { color: rgb(1, } +.k72 { color: rgb(1, } +.k73 { color: rgb(1, } +.k74 { color: rgb(1, } +.k75 { color: rgb(1, } +.k76 { color: rgb(1, } +.k77 { color: rgb(1, } +.k78 { color: rgb(1, } +.k79 { color: rgb(1, } +.k80 { color: rgb(1, } +.k81 { color: rgb(1, } +.k82 { color: rgb(1, } +.k83 { color: rgb(1, } +.k84 { color: rgb(1, } +.k85 { color: rgb(1, } +.k86 { color: rgb(1, } +.k87 { color: rgb(1, } +.k88 { color: rgb(1, } +.k89 { color: rgb(1, } +.k90 { color: rgb(1, } +.k91 { color: rgb(1, } +.k92 { color: rgb(1, } +.k93 { color: rgb(1, } +.k94 { color: rgb(1, } +.k95 { color: rgb(1, } +.k96 { color: rgb(1, } +.k97 { color: rgb(1, } +.k98 { color: rgb(1, } +.k99 { color: rgb(1, } +.k100 { color: rgb(1, } +.k101 { color: rgb(1, } +.k102 { color: rgb(1, } +.k103 { color: rgb(1, } +.k104 { color: rgb(1, } +.k105 { color: rgb(1, } +.k106 { color: rgb(1, } +.k107 { color: rgb(1, } +.k108 { color: rgb(1, } +.k109 { color: rgb(1, } +.k110 { color: rgb(1, } +.k111 { color: rgb(1, } +.k112 { color: rgb(1, } +.k113 { color: rgb(1, } +.k114 { color: rgb(1, } +.k115 { color: rgb(1, } +.k116 { color: rgb(1, } +.k117 { color: rgb(1, } +.k118 { color: rgb(1, } +.k119 { color: rgb(1, } +.k120 { color: rgb(1, } +.k121 { color: rgb(1, } +.k122 { color: rgb(1, } +.k123 { color: rgb(1, } +.k124 { color: rgb(1, } +.k125 { color: rgb(1, } +.k126 { color: rgb(1, } +.k127 { color: rgb(1, } +.k128 { color: rgb(1, } +.k129 { color: rgb(1, } +.k130 { color: rgb(1, } +.k131 { color: rgb(1, } +.k132 { color: rgb(1, } +.k133 { color: rgb(1, } +.k134 { color: rgb(1, } +.k135 { color: rgb(1, } +.k136 { color: rgb(1, } +.k137 { color: rgb(1, } +.k138 { color: rgb(1, } +.k139 { color: rgb(1, } +.k140 { color: rgb(1, } +.k141 { color: rgb(1, } +.k142 { color: rgb(1, } +.k143 { color: rgb(1, } +.k144 { color: rgb(1, } +.k145 { color: rgb(1, } +.k146 { color: rgb(1, } +.k147 { color: rgb(1, } +.k148 { color: rgb(1, } +.k149 { color: rgb(1, } +.k150 { color: rgb(1, } +.k151 { color: rgb(1, } +.k152 { color: rgb(1, } +.k153 { color: rgb(1, } +.k154 { color: rgb(1, } +.k155 { color: rgb(1, } +.k156 { color: rgb(1, } +.k157 { color: rgb(1, } +.k158 { color: rgb(1, } +.k159 { color: rgb(1, } +.k160 { color: rgb(1, } +.k161 { color: rgb(1, } +.k162 { color: rgb(1, } +.k163 { color: rgb(1, } +.k164 { color: rgb(1, } +.k165 { color: rgb(1, } +.k166 { color: rgb(1, } +.k167 { color: rgb(1, } +.k168 { color: rgb(1, } +.k169 { color: rgb(1, } +.k170 { color: rgb(1, } +.k171 { color: rgb(1, } +.k172 { color: rgb(1, } +.k173 { color: rgb(1, } +.k174 { color: rgb(1, } +.k175 { color: rgb(1, } +.k176 { color: rgb(1, } +.k177 { color: rgb(1, } +.k178 { color: rgb(1, } +.k179 { color: rgb(1, } +.k180 { color: rgb(1, } +.k181 { color: rgb(1, } +.k182 { color: rgb(1, } +.k183 { color: rgb(1, } +.k184 { color: rgb(1, } +.k185 { color: rgb(1, } +.k186 { color: rgb(1, } +.k187 { color: rgb(1, } +.k188 { color: rgb(1, } +.k189 { color: rgb(1, } +.k190 { color: rgb(1, } +.k191 { color: rgb(1, } +.k192 { color: rgb(1, } +.k193 { color: rgb(1, } +.k194 { color: rgb(1, } +.k195 { color: rgb(1, } +.k196 { color: rgb(1, } +.k197 { color: rgb(1, } +.k198 { color: rgb(1, } +.k199 { color: rgb(1, } +.k200 { color: rgb(1, } +.k201 { color: rgb(1, } +.k202 { color: rgb(1, } +.k203 { color: rgb(1, } +.k204 { color: rgb(1, } +.k205 { color: rgb(1, } +.k206 { color: rgb(1, } +.k207 { color: rgb(1, } +.k208 { color: rgb(1, } +.k209 { color: rgb(1, } +.k210 { color: rgb(1, } +.k211 { color: rgb(1, } +.k212 { color: rgb(1, } +.k213 { color: rgb(1, } +.k214 { color: rgb(1, } +.k215 { color: rgb(1, } +.k216 { color: rgb(1, } +.k217 { color: rgb(1, } +.k218 { color: rgb(1, } +.k219 { color: rgb(1, } +.k220 { color: rgb(1, } +.k221 { color: rgb(1, } +.k222 { color: rgb(1, } +.k223 { color: rgb(1, } +.k224 { color: rgb(1, } +.k225 { color: rgb(1, } +.k226 { color: rgb(1, } +.k227 { color: rgb(1, } +.k228 { color: rgb(1, } +.k229 { color: rgb(1, } +.k230 { color: rgb(1, } +.k231 { color: rgb(1, } +.k232 { color: rgb(1, } +.k233 { color: rgb(1, } +.k234 { color: rgb(1, } +.k235 { color: rgb(1, } +.k236 { color: rgb(1, } +.k237 { color: rgb(1, } +.k238 { color: rgb(1, } +.k239 { color: rgb(1, } +.k240 { color: rgb(1, } +.k241 { color: rgb(1, } +.k242 { color: rgb(1, } +.k243 { color: rgb(1, } +.k244 { color: rgb(1, } +.k245 { color: rgb(1, } +.k246 { color: rgb(1, } +.k247 { color: rgb(1, } +.k248 { color: rgb(1, } +.k249 { color: rgb(1, } +.k250 { color: rgb(1, } +.k251 { color: rgb(1, } +.k252 { color: rgb(1, } +.k253 { color: rgb(1, } +.k254 { color: rgb(1, } +.k255 { color: rgb(1, } +.k256 { color: rgb(1, } +.k257 { color: rgb(1, } +.k258 { color: rgb(1, } +.k259 { color: rgb(1, } +.k260 { color: rgb(1, } +.k261 { color: rgb(1, } +.k262 { color: rgb(1, } +.k263 { color: rgb(1, } +.k264 { color: rgb(1, } +.k265 { color: rgb(1, } +.k266 { color: rgb(1, } +.k267 { color: rgb(1, } +.k268 { color: rgb(1, } +.k269 { color: rgb(1, } +.k270 { color: rgb(1, } +.k271 { color: rgb(1, } +.k272 { color: rgb(1, } +.k273 { color: rgb(1, } +.k274 { color: rgb(1, } +.k275 { color: rgb(1, } +.k276 { color: rgb(1, } +.k277 { color: rgb(1, } +.k278 { color: rgb(1, } +.k279 { color: rgb(1, } +.k280 { color: rgb(1, } +.k281 { color: rgb(1, } +.k282 { color: rgb(1, } +.k283 { color: rgb(1, } +.k284 { color: rgb(1, } +.k285 { color: rgb(1, } +.k286 { color: rgb(1, } +.k287 { color: rgb(1, } +.k288 { color: rgb(1, } +.k289 { color: rgb(1, } +.k290 { color: rgb(1, } +.k291 { color: rgb(1, } +.k292 { color: rgb(1, } +.k293 { color: rgb(1, } +.k294 { color: rgb(1, } +.k295 { color: rgb(1, } +.k296 { color: rgb(1, } +.k297 { color: rgb(1, } +.k298 { color: rgb(1, } +.k299 { color: rgb(1, } diff --git a/parser/src/test-data/web/torture/css/gaps.css b/parser/src/test-data/web/torture/css/gaps.css new file mode 100644 index 00000000..5f8559ef --- /dev/null +++ b/parser/src/test-data/web/torture/css/gaps.css @@ -0,0 +1,34 @@ +/* G01 syntax the grammar must survive, with rows still emitted for the readable rest */ +.before-gaps { color: red } +.g1 { color: red +.g2 { color: blue } +.g3 { color: red }} +} +.g4 { ; color: red } +.g5 { color: red; ; ; } +.g6 { color } +.g7 { : } +.g8 { @media (x) { color: red } } +.g9 { color: red } .g10 +.g11 { color: red } } +@media { .g12 { color: red } } +@media screen { .g13 { color: red } +.g14 { color: red } +@unknown-rule foo { .g15 { color: red } } +@unknown-statement foo bar; +@import "late.css"; /* @import after rules is ignored by browsers */ +@charset "utf-8"; /* @charset not first is ignored by browsers */ +.g16 { color: red } /* unclosed comment +.g17 { color: red } +*/ +.g18 { color: red } + +.g20 { color: red } +.g21 { background: url(img/bg.png } +.g22 { color: rgb(1, 2 } +.g23 { width: calc(1px + } +.g24 { content: "x } +.g25 { color: red } +.g26 [ { color: red } +.g27 ( { color: red } +.g28 { color: red diff --git a/parser/src/test-data/web/torture/css/imported-by-style.css b/parser/src/test-data/web/torture/css/imported-by-style.css new file mode 100644 index 00000000..09a3be8f --- /dev/null +++ b/parser/src/test-data/web/torture/css/imported-by-style.css @@ -0,0 +1,2 @@ +.imported-by-style { color: red } +@import "tokens.css"; diff --git a/parser/src/test-data/web/torture/css/layers.css b/parser/src/test-data/web/torture/css/layers.css new file mode 100644 index 00000000..91288b03 --- /dev/null +++ b/parser/src/test-data/web/torture/css/layers.css @@ -0,0 +1,17 @@ +/* L01 cascade layers: statement, block, nested, dotted, anonymous, imported-into, media-wrapped */ +@layer reset, base, components.buttons, components.cards, utilities; +@layer reset { * { margin: 0 } } +@layer base { .card { color: red } @layer typography { h1 { font-size: 2rem } } } +@layer components { @layer buttons { .btn { color: red } } @layer cards { .card { color: blue } } } +@layer components.cards { .card .item { color: green } } +@layer base.typography { h2 { font-size: 1rem } } +@layer { .anonymous { color: red } } +@media screen { @layer utilities { .u-hide { display: none } } } +@layer utilities { @media print { .u-hide { display: block } } } +@import "print.css" layer(print-layer); +@import "alt.css" layer; +@layer sub.sub.sub { .deep-layer { color: red } } +@layer a, b, a; +@layer reset; +@layer spaced , names ; +.unlayered { color: red } diff --git a/parser/src/test-data/web/torture/css/long-line.css b/parser/src/test-data/web/torture/css/long-line.css new file mode 100644 index 00000000..af1eb44d --- /dev/null +++ b/parser/src/test-data/web/torture/css/long-line.css @@ -0,0 +1 @@ +.m1{color:red}.m2{color:red}.m3{color:red}.m4{color:red}.m5{color:red}.m6{color:red}.m7{color:red}.m8{color:red}.m9{color:red}.m10{color:red}.m11{color:red}.m12{color:red}.m13{color:red}.m14{color:red}.m15{color:red}.m16{color:red}.m17{color:red}.m18{color:red}.m19{color:red}.m20{color:red}.m21{color:red}.m22{color:red}.m23{color:red}.m24{color:red}.m25{color:red}.m26{color:red}.m27{color:red}.m28{color:red}.m29{color:red}.m30{color:red}.m31{color:red}.m32{color:red}.m33{color:red}.m34{color:red}.m35{color:red}.m36{color:red}.m37{color:red}.m38{color:red}.m39{color:red}.m40{color:red}.m41{color:red}.m42{color:red}.m43{color:red}.m44{color:red}.m45{color:red}.m46{color:red}.m47{color:red}.m48{color:red}.m49{color:red}.m50{color:red}.m51{color:red}.m52{color:red}.m53{color:red}.m54{color:red}.m55{color:red}.m56{color:red}.m57{color:red}.m58{color:red}.m59{color:red}.m60{color:red}.m61{color:red}.m62{color:red}.m63{color:red}.m64{color:red}.m65{color:red}.m66{color:red}.m67{color:red}.m68{color:red}.m69{color:red}.m70{color:red}.m71{color:red}.m72{color:red}.m73{color:red}.m74{color:red}.m75{color:red}.m76{color:red}.m77{color:red}.m78{color:red}.m79{color:red}.m80{color:red}.m81{color:red}.m82{color:red}.m83{color:red}.m84{color:red}.m85{color:red}.m86{color:red}.m87{color:red}.m88{color:red}.m89{color:red}.m90{color:red}.m91{color:red}.m92{color:red}.m93{color:red}.m94{color:red}.m95{color:red}.m96{color:red}.m97{color:red}.m98{color:red}.m99{color:red}.m100{color:red}.m101{color:red}.m102{color:red}.m103{color:red}.m104{color:red}.m105{color:red}.m106{color:red}.m107{color:red}.m108{color:red}.m109{color:red}.m110{color:red}.m111{color:red}.m112{color:red}.m113{color:red}.m114{color:red}.m115{color:red}.m116{color:red}.m117{color:red}.m118{color:red}.m119{color:red}.m120{color:red}.m121{color:red}.m122{color:red}.m123{color:red}.m124{color:red}.m125{color:red}.m126{color:red}.m127{color:red}.m128{color:red}.m129{color:red}.m130{color:red}.m131{color:red}.m132{color:red}.m133{color:red}.m134{color:red}.m135{color:red}.m136{color:red}.m137{color:red}.m138{color:red}.m139{color:red}.m140{color:red}.m141{color:red}.m142{color:red}.m143{color:red}.m144{color:red}.m145{color:red}.m146{color:red}.m147{color:red}.m148{color:red}.m149{color:red}.m150{color:red}.m151{color:red}.m152{color:red}.m153{color:red}.m154{color:red}.m155{color:red}.m156{color:red}.m157{color:red}.m158{color:red}.m159{color:red}.m160{color:red}.m161{color:red}.m162{color:red}.m163{color:red}.m164{color:red}.m165{color:red}.m166{color:red}.m167{color:red}.m168{color:red}.m169{color:red}.m170{color:red}.m171{color:red}.m172{color:red}.m173{color:red}.m174{color:red}.m175{color:red}.m176{color:red}.m177{color:red}.m178{color:red}.m179{color:red}.m180{color:red}.m181{color:red}.m182{color:red}.m183{color:red}.m184{color:red}.m185{color:red}.m186{color:red}.m187{color:red}.m188{color:red}.m189{color:red}.m190{color:red}.m191{color:red}.m192{color:red}.m193{color:red}.m194{color:red}.m195{color:red}.m196{color:red}.m197{color:red}.m198{color:red}.m199{color:red}.m200{color:red}.m201{color:red}.m202{color:red}.m203{color:red}.m204{color:red}.m205{color:red}.m206{color:red}.m207{color:red}.m208{color:red}.m209{color:red}.m210{color:red}.m211{color:red}.m212{color:red}.m213{color:red}.m214{color:red}.m215{color:red}.m216{color:red}.m217{color:red}.m218{color:red}.m219{color:red}.m220{color:red}.m221{color:red}.m222{color:red}.m223{color:red}.m224{color:red}.m225{color:red}.m226{color:red}.m227{color:red}.m228{color:red}.m229{color:red}.m230{color:red}.m231{color:red}.m232{color:red}.m233{color:red}.m234{color:red}.m235{color:red}.m236{color:red}.m237{color:red}.m238{color:red}.m239{color:red}.m240{color:red}.m241{color:red}.m242{color:red}.m243{color:red}.m244{color:red}.m245{color:red}.m246{color:red}.m247{color:red}.m248{color:red}.m249{color:red}.m250{color:red}.m251{color:red}.m252{color:red}.m253{color:red}.m254{color:red}.m255{color:red}.m256{color:red}.m257{color:red}.m258{color:red}.m259{color:red}.m260{color:red}.m261{color:red}.m262{color:red}.m263{color:red}.m264{color:red}.m265{color:red}.m266{color:red}.m267{color:red}.m268{color:red}.m269{color:red}.m270{color:red}.m271{color:red}.m272{color:red}.m273{color:red}.m274{color:red}.m275{color:red}.m276{color:red}.m277{color:red}.m278{color:red}.m279{color:red}.m280{color:red}.m281{color:red}.m282{color:red}.m283{color:red}.m284{color:red}.m285{color:red}.m286{color:red}.m287{color:red}.m288{color:red}.m289{color:red}.m290{color:red}.m291{color:red}.m292{color:red}.m293{color:red}.m294{color:red}.m295{color:red}.m296{color:red}.m297{color:red}.m298{color:red}.m299{color:red}.m300{color:red}.m301{color:red}.m302{color:red}.m303{color:red}.m304{color:red}.m305{color:red}.m306{color:red}.m307{color:red}.m308{color:red}.m309{color:red}.m310{color:red}.m311{color:red}.m312{color:red}.m313{color:red}.m314{color:red}.m315{color:red}.m316{color:red}.m317{color:red}.m318{color:red}.m319{color:red}.m320{color:red}.m321{color:red}.m322{color:red}.m323{color:red}.m324{color:red}.m325{color:red}.m326{color:red}.m327{color:red}.m328{color:red}.m329{color:red}.m330{color:red}.m331{color:red}.m332{color:red}.m333{color:red}.m334{color:red}.m335{color:red}.m336{color:red}.m337{color:red}.m338{color:red}.m339{color:red}.m340{color:red}.m341{color:red}.m342{color:red}.m343{color:red}.m344{color:red}.m345{color:red}.m346{color:red}.m347{color:red}.m348{color:red}.m349{color:red}.m350{color:red}.m351{color:red}.m352{color:red}.m353{color:red}.m354{color:red}.m355{color:red}.m356{color:red}.m357{color:red}.m358{color:red}.m359{color:red}.m360{color:red}.m361{color:red}.m362{color:red}.m363{color:red}.m364{color:red}.m365{color:red}.m366{color:red}.m367{color:red}.m368{color:red}.m369{color:red}.m370{color:red}.m371{color:red}.m372{color:red}.m373{color:red}.m374{color:red}.m375{color:red}.m376{color:red}.m377{color:red}.m378{color:red}.m379{color:red}.m380{color:red}.m381{color:red}.m382{color:red}.m383{color:red}.m384{color:red}.m385{color:red}.m386{color:red}.m387{color:red}.m388{color:red}.m389{color:red}.m390{color:red}.m391{color:red}.m392{color:red}.m393{color:red}.m394{color:red}.m395{color:red}.m396{color:red}.m397{color:red}.m398{color:red}.m399{color:red}.m400{color:red} \ No newline at end of file diff --git a/parser/src/test-data/web/torture/css/nested.css b/parser/src/test-data/web/torture/css/nested.css new file mode 100644 index 00000000..8832cf59 --- /dev/null +++ b/parser/src/test-data/web/torture/css/nested.css @@ -0,0 +1,45 @@ +/* N01 CSS nesting: every & position, implicit nesting, nested at-rules with bare declarations */ +.card { + color: red; + &:hover { color: blue } + & .item { color: green } + .item & { color: yellow } + & + & { margin: 0 } + &.is-active { outline: 1px } + &#section-one { outline: 2px } + & > .nav, & ~ .nav { color: red } + .item, .nav { color: red } /* implicit & descendant, two selectors */ + > .direct { color: red } /* leading combinator */ + + .sib { color: red } + ~ .gen { color: red } + :hover { color: red } /* implicit: &:hover? no, & :hover descendant */ + .a &, .b & { color: red } + &, &.x { color: red } + :is(&, .alias) .item { color: red } + &:not(.is-active) .item { color: red } + .parent & .child { color: red } + && { color: red } + & & { color: red } + @media (min-width: 1px) { color: red; & .item { color: blue } .nested-in-media { color: green } } + @supports (display: grid) { display: grid } + @container sidebar (min-width: 1px) { color: red } + @layer nested-layer { color: red } + @scope (.card) { .item { color: red } } + @starting-style { opacity: 0 } + --nested-var: 1; + .deep { .deeper { .deepest { color: var(--nested-var) } } } + @media (a) { @media (b) { @supports (c) { .x { color: red } } } } + & .item { & .leaf { color: red } } + .item { .leaf { color: red } } + @nest .parent & { color: red } /* pre-standard @nest */ + color: blue; /* declaration after nested rules */ +} +.card.is-active { & .item { color: red } } +.a, .b { & .c { color: red } .d & { color: red } } +@media (x) { .outer { .inner { color: red } } } +@layer l { .outer { & .inner { color: red } } } +.p { &__elem { color: red } &--mod { color: red } } /* BEM-style: invalid in CSS nesting, valid in Sass */ +.q { &-suffix { color: red } } +.r { &:is(.s) { color: red } } +.t { .u & .v & { color: red } } +.w { &.x.y > &.z { color: red } } diff --git a/parser/src/test-data/web/torture/css/norel.css b/parser/src/test-data/web/torture/css/norel.css new file mode 100644 index 00000000..867918fd --- /dev/null +++ b/parser/src/test-data/web/torture/css/norel.css @@ -0,0 +1 @@ +.norel-only { color: red } diff --git a/parser/src/test-data/web/torture/css/print.css b/parser/src/test-data/web/torture/css/print.css new file mode 100644 index 00000000..8c1f9cba --- /dev/null +++ b/parser/src/test-data/web/torture/css/print.css @@ -0,0 +1,2 @@ +@media print { .print-rule { color: red } } +.print-only { color: red } diff --git a/parser/src/test-data/web/torture/css/sassy.css b/parser/src/test-data/web/torture/css/sassy.css new file mode 100644 index 00000000..8bb5165e --- /dev/null +++ b/parser/src/test-data/web/torture/css/sassy.css @@ -0,0 +1,5 @@ +$primary: red; +@mixin m { color: $primary } +.sassy { @include m; &__elem { color: red } } +// line comment +.x { .y { color: red } } diff --git a/parser/src/test-data/web/torture/css/selectors.css b/parser/src/test-data/web/torture/css/selectors.css new file mode 100644 index 00000000..d1bf6329 --- /dev/null +++ b/parser/src/test-data/web/torture/css/selectors.css @@ -0,0 +1,34 @@ +/* E01 escaped identifiers: every form the CSS syntax admits */ +.md\:flex { display: flex } +.md\3A flex { display: flex } +.md\00003Aflex { display: flex } +.w-1\/2 { width: 50% } +.\31 23 { order: 1 } +.\31 0 { order: 10 } +.\-mt-2 { margin: 0 } +.a\ b { color: red } +.\@media { color: red } +.\#hash { color: red } +.\.dot { color: red } +.\\backslash { color: red } +.caf\e9 { color: red } +.café { color: red } +.\1F680 { color: red } +.🚀 { color: red } +#\31 23 { color: red } +#id\:colon { color: red } +[data-x\:y] { color: red } +[data-x="a\"b"] { color: red } +svg|rect\:x { color: red } +.a\,b { color: red } +.a\>b { color: red } +.a\&b { color: red } +.\[\&_svg\]\:size-4 svg { color: red } +.\!font-bold { color: red } +.\32 xl\:grid-cols-\[repeat\(auto-fill\2c minmax\(200px\2c 1fr\)\)\] { color: red } +.group:hover .group-hover\:visible { color: red } +.peer:checked ~ .peer-checked\:block { color: red } +.data-\[state\=open\]\:bg-red[data-state="open"] { color: red } +.aria-\[checked\]\:bg-red[aria-checked] { color: red } +.supports-\[display\:grid\]\:grid { color: red } +.has-\[\>img\]\:p-0:has(> img) { color: red } diff --git a/parser/src/test-data/web/torture/css/strings.css b/parser/src/test-data/web/torture/css/strings.css new file mode 100644 index 00000000..a87c6197 --- /dev/null +++ b/parser/src/test-data/web/torture/css/strings.css @@ -0,0 +1,36 @@ +/* S01 strings and comments that contain CSS syntax */ +.s1 { content: "}" } +.s2 { content: "{" } +.s3 { content: ";" } +.s4 { content: "a; color: red" } +.s5 { content: "/* not a comment */" } +.s6 { content: "url(not.png)" } +.s7 { content: 'var(--not)' } +.s8 { content: "\"" } +.s9 { content: '\'' } +.s10 { content: "\201C \201D" } +.s11 { content: "line\ +continued" } +.s12 { content: "unterminated } +.s13 { color: red } +.s14 { content: "\"; } .injected { color: red }" } +.s15 { background: url("a)b.png") } +.s16 { background: url('a"b.png') } +.s17 { background: url(a\)b.png) } +.s18 { font-family: "Font; Name" } +.s19 { font-family: "Font } Name" } +.s20 { quotes: "«" "»" } +.s21 { content: attr(data-page) } +.s22 { content: counter(a) " / " counter(b) } +.s23 { grid-template-areas: "header header" "sidebar main" } +.s24 { content: "{{ not_a_template }}" } +.s25 { content: "<%= not_erb %>" } +.s26 { content: "${not_dollar}" } +.s27[data-x="}"] { color: red } +.s28[data-x=";"] { color: red } +.s29[data-x="url(x)"] { color: red } +.s30:not([data-x="{"]) { color: red } +/* .s31 { color: red } */ +/* a comment with url(img/bg.png) and var(--x) and .commented-class */ +/* unterminated comment at the end of the file +.s32 { color: red } diff --git a/parser/src/test-data/web/torture/css/theme.css b/parser/src/test-data/web/torture/css/theme.css new file mode 100644 index 00000000..f6f83199 --- /dev/null +++ b/parser/src/test-data/web/torture/css/theme.css @@ -0,0 +1,14 @@ +/* K02 the end of the import chain: app -> tokens -> theme; imports tokens back (cycle) */ +@import "tokens.css"; +.theme-light { --brand: #0af } +.theme-dark { --brand: #a0f } +@keyframes fade { from { opacity: 0 } to { opacity: 1 } } +@keyframes spin { from { transform: rotate(0) } 50% { opacity: .5 } to { transform: rotate(1turn) } } +@keyframes spin { to { transform: rotate(2turn) } } /* redefinition: the last one wins */ +@keyframes "quoted-name" { to { opacity: 0 } } +@keyframes ease { to { opacity: 0 } } /* a keyframes named like a keyword */ +@keyframes none { to { opacity: 0 } } /* invalid name, written anyway */ +@keyframes slide { 0%, 50% { left: 0 } 100% { left: 1px } 33.3% { left: 2px } from, to { left: 3px } } +@keyframes var-inside { to { color: var(--brand); background: url(img/bg.png) } } +@-webkit-keyframes spin { to { transform: rotate(0) } } +@-moz-keyframes spin { to { transform: rotate(0) } } diff --git a/parser/src/test-data/web/torture/css/tokens.css b/parser/src/test-data/web/torture/css/tokens.css new file mode 100644 index 00000000..7be1084f --- /dev/null +++ b/parser/src/test-data/web/torture/css/tokens.css @@ -0,0 +1,35 @@ +/* K01 custom properties: defined at :root, overridden per theme, registered, used in fallbacks and shorthands */ +@import "theme.css"; +:root { + --brand: #09f; + --gap: 8px; + --anchor: var(--brand); + --fallback-brand: pink; + --font: Inter, sans-serif; + --anim: spin; + --duration: 1s; + --easing: ease-in; + --pad: 1px; + --wide: 70rem; +} +:root.dark, .dark, [data-theme="dark"], html[data-theme=dark] body { --brand: #fff; } +@media (prefers-color-scheme: dark) { :root { --brand: #000 } } +@property --brand { syntax: ''; inherits: true; initial-value: red } +@property --registered-never-used { syntax: '*'; inherits: false } +.scoped { --gap: 4px; } +.scoped .child { gap: var(--gap); } +.uses-undefined { color: var(--undefined-anywhere); } +.uses-undefined-with-fallback { color: var(--undefined-anywhere, red); } +.defined-in-style-attr { padding: var(--local); } /* --local is defined only in a style="" attribute of index.html */ +.var-in-selector-arg:is(.x) { --in-is: 1 } +.var-name-cases { color: var(--BRAND); color: var( --brand ); color: var(--brand,); color: var(--brand , 1px 2px); color: var(--brand, url(img/bg.png)); color: var(--brand, var(--gap)) } +.var-not-a-var { color: var(brand); color: var(); color: var(--); } +.var-in-shorthand { margin: var(--gap) calc(var(--gap) * 2) 0 var(--gap, var(--pad)); } +.var-in-important { color: var(--brand) !important; } +.var-in-url { background: url(var(--url)); } /* not valid CSS, but written */ +.var-in-string { content: "var(--brand)"; } +.var-in-comment { /* color: var(--commented) */ color: red; } +.var-in-custom { --alias: var(--brand); --chain: var(--alias); color: var(--chain); } +.var-in-animation { animation: var(--anim) var(--duration); } +.var-in-font { font-family: var(--font); font: 12px var(--font); } +.var-in-container { container-name: var(--container-name); } diff --git a/parser/src/test-data/web/torture/css/upper.css b/parser/src/test-data/web/torture/css/upper.css new file mode 100644 index 00000000..f31c0364 --- /dev/null +++ b/parser/src/test-data/web/torture/css/upper.css @@ -0,0 +1 @@ +.upper-only { color: red } diff --git a/parser/src/test-data/web/torture/css/vendor.min.css b/parser/src/test-data/web/torture/css/vendor.min.css new file mode 100644 index 00000000..154c5ba5 --- /dev/null +++ b/parser/src/test-data/web/torture/css/vendor.min.css @@ -0,0 +1 @@ +.vendor{color:red}.card{color:rgb(200,200,200)} \ No newline at end of file diff --git a/parser/src/test-data/web/torture/fonts/inter.woff b/parser/src/test-data/web/torture/fonts/inter.woff new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/fonts/inter.woff2 b/parser/src/test-data/web/torture/fonts/inter.woff2 new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/forms.html b/parser/src/test-data/web/torture/forms.html new file mode 100644 index 00000000..b95f4594 --- /dev/null +++ b/parser/src/test-data/web/torture/forms.html @@ -0,0 +1,51 @@ + + + + Torture: id references + + + + + + + + +
+ + + + + + + + +
nameage
x
+
+ + +
pop
+ + + + legacy named anchor + to a name, not an id + to an id + case-different fragment + bare hash + implicit top + to the other page + to an uppercase id on the other page + cross-page fragment, dangling + cross-page fragment, page missing + + targets frame-b + targets a keyword +
+
+
+
+
+ + duplicate id with the first input + + diff --git a/parser/src/test-data/web/torture/img/bg.png b/parser/src/test-data/web/torture/img/bg.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/cursor.cur b/parser/src/test-data/web/torture/img/cursor.cur new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/favicon.ico b/parser/src/test-data/web/torture/img/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/hero.png b/parser/src/test-data/web/torture/img/hero.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/hero.webp b/parser/src/test-data/web/torture/img/hero.webp new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/logo.png b/parser/src/test-data/web/torture/img/logo.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/logo@2x.png b/parser/src/test-data/web/torture/img/logo@2x.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/space name.png b/parser/src/test-data/web/torture/img/space name.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/img/sprite.svg b/parser/src/test-data/web/torture/img/sprite.svg new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/index.html b/parser/src/test-data/web/torture/index.html new file mode 100644 index 00000000..6dd32a5a --- /dev/null +++ b/parser/src/test-data/web/torture/index.html @@ -0,0 +1,208 @@ + + + + + Torture: the main page + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
whitespace, tab entity, newline, duplicate token, padded id
+
valueless class attribute
+
empty class attribute
+
entity-bearing class tokens
+
case variants: CSS .upper must NOT match Upper
+
uppercase tag and attribute names
+
duplicate class attribute: first wins, second is a gap
+ + + + +
+

duplicate id on the page

+ +

var in style attribute

+

custom property defined in a style attribute

+

urls in style attribute, relative to the page

+

root-relative url in style attribute

+

important and font shorthand

+

template in style attribute

+

broken declaration list, second declaration must survive

+

a rule where a declaration list belongs

+

empty style

+

keyframes, font, container refs in a style attribute

+

brace in a style attribute

+

uppercase property names

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "; trailingText(); + + + + comma inside a srcset url + + + + + + + + + + + + +
cite
+ + + + + + +
+ a browser makes me a CHILD of the div above +

a

div inside a p closes the p
trailing

+
  • one
  • two
+ +
no tbody written
+
c
h
body row after thead
+ outer nested anchor +
t
d
t2
+
+ + + +
+ + +

entities:   & <tag> ' ' &unknown; ©

+

aria idrefs

+ +
styled only by base.css, which this page does not load (its link is under the base)
+
+ + + diff --git a/parser/src/test-data/web/torture/js/app.js b/parser/src/test-data/web/torture/js/app.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/js/lib.js b/parser/src/test-data/web/torture/js/lib.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/js/module.js b/parser/src/test-data/web/torture/js/module.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/js/vendor.js b/parser/src/test-data/web/torture/js/vendor.js new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/media/clip.mp4 b/parser/src/test-data/web/torture/media/clip.mp4 new file mode 100644 index 00000000..93db0574 --- /dev/null +++ b/parser/src/test-data/web/torture/media/clip.mp4 @@ -0,0 +1 @@ +a{color:red} \ No newline at end of file diff --git a/parser/src/test-data/web/torture/media/clip.vtt b/parser/src/test-data/web/torture/media/clip.vtt new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/media/poster.png b/parser/src/test-data/web/torture/media/poster.png new file mode 100644 index 00000000..e69de29b diff --git a/parser/src/test-data/web/torture/public/css/dup.css b/parser/src/test-data/web/torture/public/css/dup.css new file mode 100644 index 00000000..9a7bd6df --- /dev/null +++ b/parser/src/test-data/web/torture/public/css/dup.css @@ -0,0 +1 @@ +body{margin:0} diff --git a/parser/src/test-data/web/torture/quirks.html b/parser/src/test-data/web/torture/quirks.html new file mode 100644 index 00000000..8b505279 --- /dev/null +++ b/parser/src/test-data/web/torture/quirks.html @@ -0,0 +1,265 @@ + + +Quirks + + + +
+

crlf

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+

+

+ + \ No newline at end of file diff --git a/parser/src/test-data/web/torture/shadow/shadow.html b/parser/src/test-data/web/torture/shadow/shadow.html new file mode 100644 index 00000000..72f1a5d9 --- /dev/null +++ b/parser/src/test-data/web/torture/shadow/shadow.html @@ -0,0 +1,70 @@ + + + + Torture: scoping boundaries + + + + +
outside any shadow root
+ + + + + slotted from the light DOM + default slot + + + + + + + + + + + + + +
zero levels in: no rule reaches me
+ + +
+ + + diff --git a/parser/src/test-data/web/torture/static/css/dup.css b/parser/src/test-data/web/torture/static/css/dup.css new file mode 100644 index 00000000..fb939513 --- /dev/null +++ b/parser/src/test-data/web/torture/static/css/dup.css @@ -0,0 +1 @@ +body{margin:1px} diff --git a/parser/src/test-data/web/torture/svg.html b/parser/src/test-data/web/torture/svg.html new file mode 100644 index 00000000..5cec2740 --- /dev/null +++ b/parser/src/test-data/web/torture/svg.html @@ -0,0 +1,43 @@ + + + + Torture: foreign content + + + + + + + + + + + + + + + svg anchor + + +
html again: tag and attribute names lowercase here
+ +
+ svg title must not become the document title + + + +
+ x
+ + + diff --git a/parser/src/test-data/web/torture/templates/jinja.html b/parser/src/test-data/web/torture/templates/jinja.html new file mode 100644 index 00000000..08b80ea0 --- /dev/null +++ b/parser/src/test-data/web/torture/templates/jinja.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% block head %} + + + +{% endblock %} +{% block body %} +
+ {% for item in items %} + {{ item.name }} + {% endfor %} + conditional class + +

style with template value

+

custom property from the template

+ {# a jinja comment with
#} + +
+ +{% endblock %} diff --git a/parser/src/test-data/web/torture/templates/mixed.html b/parser/src/test-data/web/torture/templates/mixed.html new file mode 100644 index 00000000..0d7eb298 --- /dev/null +++ b/parser/src/test-data/web/torture/templates/mixed.html @@ -0,0 +1,19 @@ + + + + + + +
x
+
thymeleaf inline
+
angular
+
vue
+
alpine
+
htmx
+
livewire, liveview, hyperscript, knockout
+
<%= body %>
+
+
    @foreach (var i in Model.Items) {
  • @i.Name
  • }
+

${name} and #{expr} and @{link} and {#block}

+ + diff --git a/parser/src/test-data/web/torture/templates/tailwind.html b/parser/src/test-data/web/torture/templates/tailwind.html new file mode 100644 index 00000000..6fe4638b --- /dev/null +++ b/parser/src/test-data/web/torture/templates/tailwind.html @@ -0,0 +1,13 @@ +
+ utility classes whose CSS selectors are escaped + stacked variants +
+ diff --git a/parser/src/test-data/web/torture/xhtml/page.xhtml b/parser/src/test-data/web/torture/xhtml/page.xhtml new file mode 100644 index 00000000..37a76c3d --- /dev/null +++ b/parser/src/test-data/web/torture/xhtml/page.xhtml @@ -0,0 +1,21 @@ + + + + + Torture: XHTML + + " } + //]]> + + + + +
+
+
+ a sibling in XML, a child in HTML + + + diff --git a/parser/src/test/web-tests.ts b/parser/src/test/web-tests.ts new file mode 100644 index 00000000..316994ff --- /dev/null +++ b/parser/src/test/web-tests.ts @@ -0,0 +1,1099 @@ +/** + * WEB (HTML + CSS) TESTS — one file, mirroring services-tests.ts. + * + * npx tsx src/test/web-tests.ts # everything + * npx tsx src/test/web-tests.ts --list # what runs, and what it proves + * npx tsx src/test/web-tests.ts --bless # rewrite the goldens + * + * NO BROWSER, NO NETWORK. Every check runs the parser and compares the result + * with expectations in this file or checked into src/test-data/web. + * + * ## What this suite can and cannot do + * + * The golden is drift detection: it was produced by this parser and agrees with + * whatever the parser currently does, mistakes included. + * + * The format checks are the part that can find a defect. Each is written from + * the HTML or CSS specification (the parsing algorithm, the Selectors specificity + * rules, the URL syntax) rather than from parser output, and each names the + * plausible wrong implementation it rules out — a reader that splits `class` on + * spaces only, a specificity that counts `:where()`, a resolver that follows + * `../..` out of the project. A check no plausible implementation fails proves + * nothing, so the `rules out` line is part of the check. + * + * The structural checks — primary-key uniqueness, foreign-key integrity across + * the sixteen relations, arity against the frozen schema, enum domains, and + * determinism across two runs — are the ones that catch what a fixture cannot + * foresee. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { CssStylesheet } from '@/analysis-types/css/CssStylesheet'; +import { WEB_CSV_FILES, WEB_PARSE_GAP_LIMIT } from '@/constants/web-constants'; +import { CssSourceProvenance, CssStylesheetSource } from '@/enums/css/CssStylesheetSource'; +import { CssParseGapKind } from '@/enums/css/CssParseGapKind'; +import { CssSelectorPartKind, CssCombinator } from '@/enums/css/CssSelectorPartKind'; +import { CssValueReferenceKind } from '@/enums/css/CssValueReferenceKind'; +import { HtmlAttributeKind } from '@/enums/html/HtmlAttributeKind'; +import { HtmlDocumentKind } from '@/enums/html/HtmlDocumentKind'; +import { HtmlHandlerSource } from '@/enums/html/HtmlHandlerSource'; +import { HtmlNamespace } from '@/enums/html/HtmlNamespace'; +import { HtmlParseGapKind } from '@/enums/html/HtmlParseGapKind'; +import { HtmlScriptKind, HtmlScriptType } from '@/enums/html/HtmlScriptKind'; +import { HtmlTemplateDialect } from '@/enums/html/HtmlTemplateDialect'; +import { WebUrlKind } from '@/enums/web/WebUrlKind'; +import * as CssEnums from '@/enums/css'; +import * as HtmlEnums from '@/enums/html'; +import * as WebEnums from '@/enums/web'; +import { CssExtraction, CssParser } from '@/parsers/css/css-parser'; +import { HtmlExtraction, HtmlParser } from '@/parsers/html/html-parser'; +import { WebProjectAnalyzer } from '@/workflows/web/web-project-analyzer'; + +const DATA = 'src/test-data/web'; +const GOLDEN = path.join(DATA, '_golden'); +const SCHEMA = JSON.parse(fs.readFileSync('src/schema/web/schema.json', 'utf-8')) as { + relations: Record; commaSets?: string[] }>; +}; + +/** The values a cell holds: one, or every member of a comma-set column. */ +function cellValues(relation: string, column: string, cell: string): string[] { + if (cell === '') return []; + return (SCHEMA.relations[relation]?.commaSets ?? []).includes(column) ? cell.split(',') : [cell]; +} +const BLESS = process.argv.includes('--bless'); + +/** Columns that cannot be compared across machines: absolute paths. */ +const VOLATILE = /^(baseMservPath|filePath|resolvedFilePath)$/; +const HASH_COLUMN = /(Hash|hash)$/; + +type Row = Record; + +const fail = (m: string): number => { console.log(' ✗ ' + m); return 1; }; + +// --------------------------------------------------------------------------- +// format checks — from the specifications, in memory, no fixture tree +// --------------------------------------------------------------------------- + +/** A throwaway directory for checks that need real files to resolve against. */ +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'axiom-web-')); +function file(rel: string, content = ''): string { + const p = path.join(TMP, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + return p; +} + +function parseHtml(content: string, rel = 'site/page.html'): HtmlExtraction { + const p = file(rel, content); + return new HtmlParser().parse(content, p, path.join(TMP, 'site'), 'WEB_FIXTURE_VERSION'); +} + +function parseCss(content: string, rel = 'site/css/x.css', origin = { line: 1, column: 1 }): CssExtraction & { sheet: CssStylesheet } { + const p = file(rel, content); + const sheet = new CssStylesheet({ + name: 'x', fileName: path.basename(p), filePath: p, baseMservPath: path.join(TMP, 'site'), relativePath: rel, + sourceKind: CssStylesheetSource.FILE, sourceProvenance: CssSourceProvenance.PROJECT, ownerHtmlElementLinkHash: '', + htmlDocumentLinkHash: '', startLine: 1, startColumn: 1, endLine: 1, serviceVersionLinkHash: 'WEB_FIXTURE_VERSION', + }); + const x = new CssParser().parseStylesheet(content, { + stylesheet: sheet, line: origin.line, column: origin.column, filePath: p, projectRoot: path.join(TMP, 'site'), + serviceVersionLinkHash: 'WEB_FIXTURE_VERSION', + }); + return { ...x, sheet }; +} + +/** (a,b,c) of the first selector of the first rule. */ +function specificity(selector: string): string { + const s = parseCss(`${selector} { color: red }`).selectors[0]!; + return `${s.specificityA},${s.specificityB},${s.specificityC}`; +} + +function col(text: string, needle: string, occurrence = 1): { line: number; column: number } { + let from = 0; + let at = -1; + for (let i = 0; i < occurrence; i += 1) { + at = text.indexOf(needle, from); + from = at + 1; + } + const before = text.slice(0, at); + const line = before.split('\n').length; + const column = at - (before.lastIndexOf('\n') + 1) + 1; + return { line, column }; +} + +interface FormatCheck { + name: string; + /** The clause of the specification this check comes from. */ + spec: string; + /** The plausible wrong implementation this check fails. */ + rulesOut: string; + run: () => number; +} + +const formatChecks: FormatCheck[] = [ + // ── HTML: the tree ────────────────────────────────────────────────────── + { + name: 'a-fragment-is-the-elements-written-and-nothing-implied', + spec: 'the grammar builds the elements written; the html, head and body a browser implies are not in the source', + rulesOut: 'a reader that invents wrapper rows, or that calls a snippet a whole page', + run: () => { + const x = parseHtml('

only a paragraph

'); + let bad = 0; + const tags = x.elements.map((e) => e.tagName).join(' '); + if (tags !== 'p') bad += fail(`elements=${tags}, expected p alone`); + if (x.document.documentKind !== HtmlDocumentKind.FRAGMENT) bad += fail(`documentKind=${x.document.documentKind}`); + const p = x.elements[0]!; + if (p.parentElementLinkHash !== '' || p.path !== '/p[1]' || p.depth !== 0) bad += fail(`a top-level element reads as ${p.path} at depth ${p.depth} with parent ${JSON.stringify(p.parentElementLinkHash)}`); + const whole = parseHtml('

x

'); + if (whole.document.documentKind !== HtmlDocumentKind.DOCUMENT || whole.document.doctype !== 'html') { + bad += fail(`a full document reads as ${whole.document.documentKind} with doctype ${JSON.stringify(whole.document.doctype)}`); + } + if (whole.elements.map((e) => e.path).join(' ') !== '/html[1] /html[1]/body[1] /html[1]/body[1]/p[1]') bad += fail(`paths=${whole.elements.map((e) => e.path).join(' ')}`); + const noDoctype = parseHtml(''); + if (noDoctype.document.documentKind !== HtmlDocumentKind.DOCUMENT) bad += fail('a page with and no doctype reads as a fragment'); + return bad; + }, + }, + { + name: 'a-paragraph-is-closed-by-the-next-one', + spec: 'a p element\'s end tag may be omitted if it is immediately followed by another p', + rulesOut: 'a reader that nests the second p inside the first', + run: () => { + const text = '
\n

one\n

two\n

'; + const x = parseHtml(text); + const ps = x.elements.filter((e) => e.tagName === 'p'); + let bad = 0; + if (ps.length !== 2) return fail(`${ps.length} p elements, expected 2`); + if (ps[0]!.path !== '/div[1]/p[1]' || ps[1]!.path !== '/div[1]/p[2]') { + bad += fail(`paths ${ps.map((p) => p.path).join(' ')}: the second p is a sibling, not a child`); + } + if (ps[0]!.endLine !== 3) bad += fail(`first p ends on line ${ps[0]!.endLine}, expected 3 where the second opens`); + if (ps[0]!.textContent !== 'one' || ps[1]!.textContent !== 'two') bad += fail('text content crossed the implicit close'); + if (ps[0]!.position !== 0 || ps[1]!.position !== 1) bad += fail('positions are not the sibling ordinals'); + return bad; + }, + }, + { + name: 'template-content-is-walked', + spec: 'a template element\'s contents are a DocumentFragment, not children of the element', + rulesOut: 'a walker that reads childNodes and finds the template empty', + run: () => { + const x = parseHtml(''); + const span = x.elements.find((e) => e.tagName === 'span'); + if (span === undefined) return fail('no span inside the template'); + let bad = 0; + if (span.path !== '/template[1]/span[1]') bad += fail(`path=${span.path}`); + if (!x.classReferences.some((c) => c.className === 'tpl')) bad += fail('the class inside the template was not read'); + return bad; + }, + }, + { + name: 'a-void-element-has-no-end-tag-and-no-children', + spec: 'void elements have no end tag and never have contents', + rulesOut: 'a reader that swallows the following text into the img', + run: () => { + const x = parseHtml('

after

'); + const img = x.elements.find((e) => e.tagName === 'img')!; + const p = x.elements.find((e) => e.tagName === 'p')!; + let bad = 0; + if (!img.isVoid) bad += fail('img is not void'); + const expectedEnd = '

'.length + 1; + if (img.endColumn !== expectedEnd) bad += fail(`img ends at column ${img.endColumn}, expected ${expectedEnd} (just past its start tag)`); + if (p.textContent !== 'after') bad += fail(`p text=${JSON.stringify(p.textContent)}`); + return bad; + }, + }, + { + name: 'foreign-content-keeps-its-namespace-and-prefixed-attributes', + spec: 'svg and MathML elements are in their own namespaces; xlink:href is a prefixed attribute', + rulesOut: 'a reader that lowercases viewBox or loses the xlink prefix', + run: () => { + const x = parseHtml(''); + const svg = x.elements.find((e) => e.tagName === 'svg')!; + let bad = 0; + if (svg.namespace !== HtmlNamespace.SVG) bad += fail(`svg namespace=${svg.namespace}`); + if (!x.attributes.some((a) => a.name === 'viewBox')) bad += fail('viewBox lost its case'); + const xlink = x.attributes.find((a) => a.prefix === 'xlink'); + if (xlink === undefined || xlink.name !== 'href' || xlink.attributeKind !== HtmlAttributeKind.URL) { + bad += fail('xlink:href is not a prefixed URL attribute'); + } + const ref = x.references.find((r) => r.attributeName === 'xlink:href'); + if (ref === undefined || ref.urlKind !== WebUrlKind.FRAGMENT || ref.fragment !== 'a') bad += fail('xlink:href="#a" is not a FRAGMENT reference to a'); + return bad; + }, + }, + // ── HTML: attributes ──────────────────────────────────────────────────── + { + name: 'a-value-less-attribute-is-distinguished-from-an-empty-one', + spec: 'an attribute without a value has the empty string as its value', + rulesOut: 'a reader that records both as value="" and cannot tell disabled from disabled=""', + run: () => { + const x = parseHtml(''); + const by = Object.fromEntries(x.attributes.map((a) => [a.name, a])); + let bad = 0; + if (by['disabled']?.hasValue !== false) bad += fail('disabled reports a value'); + if (by['required']?.hasValue !== true) bad += fail('required="" reports no value'); + if (by['value']?.hasValue !== true) bad += fail("value='' reports no value"); + if (by['data-x']?.value !== 'y' || by['data-x']?.attributeKind !== HtmlAttributeKind.DATA) bad += fail('data-x = "y" with spaces around = was misread'); + return bad; + }, + }, + { + name: 'a-duplicate-attribute-keeps-the-first-and-records-a-gap', + spec: 'the first attribute with a name wins; a repeat is a parse error', + rulesOut: 'a reader that emits two rows for one attribute name and collides their keys (the grammar reports no duplicate)', + run: () => { + const x = parseHtml('

'); + const ids = x.attributes.filter((a) => a.name === 'id'); + let bad = 0; + if (ids.length !== 1 || ids[0]!.value !== 'a') bad += fail(`id rows=${ids.map((a) => a.value).join(',')}, expected the first, a`); + if (!x.parseGaps.some((g) => g.gapKind === HtmlParseGapKind.PARSE_ERROR && g.detail === 'duplicate-attribute')) { + bad += fail('no duplicate-attribute gap'); + } + if (x.elements.find((e) => e.tagName === 'div')!.id !== 'a') bad += fail('element id is not the first'); + return bad; + }, + }, + { + name: 'class-tokens-split-on-ascii-whitespace-and-keep-repeats', + spec: 'the class attribute is a set of space-separated tokens; the tokenizer splits on space, tab, LF, FF, CR', + rulesOut: 'a reader that splits on a single space or deduplicates', + run: () => { + const x = parseHtml('
'); + const got = x.classReferences.map((c) => `${c.position}:${c.className}`).join(' '); + let bad = 0; + if (got !== '0:a 1:b 2:c 3:a') bad += fail(`tokens=${got}`); + if (new Set(x.classReferences.map((c) => c.getHash())).size !== 4) bad += fail('two occurrences of one class share a key'); + const div = x.elements.find((e) => e.tagName === 'div')!; + if (div.toCsv().split('\t')[5] !== 'a,b,c,a') bad += fail(`classNames column=${div.toCsv().split('\t')[5]}`); + return bad; + }, + }, + { + name: 'attribute-kinds-follow-the-name', + spec: 'on* attributes are event handler content attributes; data-* and aria-* are reserved prefixes', + rulesOut: 'a classifier that calls a Vue @click or an Alpine x-on an event handler', + run: () => { + const x = parseHtml(''); + const by = Object.fromEntries(x.attributes.map((a) => [a.name, a.attributeKind])); + const want: Record = { + onclick: HtmlAttributeKind.EVENT_HANDLER, '@click': HtmlAttributeKind.TEMPLATE_DIRECTIVE, 'v-if': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + 'x-data': HtmlAttributeKind.TEMPLATE_DIRECTIVE, 'hx-get': HtmlAttributeKind.TEMPLATE_DIRECTIVE, 'th:text': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + 'data-q': HtmlAttributeKind.DATA, 'aria-hidden': HtmlAttributeKind.ARIA, role: HtmlAttributeKind.ARIA, for: HtmlAttributeKind.FOR, + form: HtmlAttributeKind.ID_REFERENCE, rel: HtmlAttributeKind.REL, type: HtmlAttributeKind.TYPE, name: HtmlAttributeKind.NAME, + style: HtmlAttributeKind.STYLE, 'ng-if': HtmlAttributeKind.TEMPLATE_DIRECTIVE, '[prop]': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + '(ev)': HtmlAttributeKind.TEMPLATE_DIRECTIVE, + }; + let bad = 0; + for (const [name, kind] of Object.entries(want)) { + if (by[name] !== kind) bad += fail(`${name}: ${by[name]}, expected ${kind}`); + } + // `@click="b"` is a template event: Vue calls the handler it names, and the row says so by its source. + const b = x.handlerCalls.find((h) => h.calleeName === 'b'); + if (b === undefined || b.handlerSource !== HtmlHandlerSource.TEMPLATE_EVENT || b.argumentCount !== 0) bad += fail('a template event directive is not a TEMPLATE_EVENT handler call'); + if (x.handlerCalls.some((h) => h.handlerSource === HtmlHandlerSource.EVENT_ATTRIBUTE && h.calleeName !== 'a')) bad += fail('a template directive was read as an on* handler'); + const dialects = [...x.document.templateDialects].sort().join(','); + if (!dialects.includes('ALPINE') || !dialects.includes('HTMX') || !dialects.includes('THYMELEAF') || !dialects.includes('VUE') || !dialects.includes('ANGULAR')) { + bad += fail(`dialects=${dialects}`); + } + return bad; + }, + }, + // ── HTML: URLs ────────────────────────────────────────────────────────── + { + name: 'url-kinds-are-decided-from-the-text', + spec: 'URL parsing: scheme, host-relative, path-absolute, path-relative and fragment-only forms', + rulesOut: 'a classifier that treats mailto: as a relative path or #x as a file', + run: () => { + const cases: Array<[string, WebUrlKind, string?]> = [ + ['a/b.css', WebUrlKind.RELATIVE], ['./a.js', WebUrlKind.RELATIVE], ['../x', WebUrlKind.RELATIVE], + ['/static/a.js', WebUrlKind.ROOT_RELATIVE], ['https://h/x.js', WebUrlKind.ABSOLUTE], ['HTTP://h/x', WebUrlKind.ABSOLUTE], + ['//cdn/x.js', WebUrlKind.PROTOCOL_RELATIVE], ['#team', WebUrlKind.FRAGMENT, 'team'], ['data:image/png;base64,AAA', WebUrlKind.DATA_URI], + ['javascript:go()', WebUrlKind.JAVASCRIPT_URI], ['mailto:a@b', WebUrlKind.OTHER_SCHEME], ['tel:123', WebUrlKind.OTHER_SCHEME], + ['{{ url_for("x") }}', WebUrlKind.TEMPLATE_EXPRESSION], ['/static/{{ name }}.js', WebUrlKind.TEMPLATE_EXPRESSION], + ['<%= path %>', WebUrlKind.TEMPLATE_EXPRESSION], ['${ctx}/x', WebUrlKind.TEMPLATE_EXPRESSION], ['', WebUrlKind.EMPTY], + [' ', WebUrlKind.EMPTY], + ]; + let bad = 0; + for (const [url, kind, fragment] of cases) { + const x = parseHtml(`x`); + const r = x.references[0]; + if (r === undefined) { bad += fail(`${JSON.stringify(url)}: no reference`); continue; } + if (r.urlKind !== kind) bad += fail(`${JSON.stringify(url)}: ${r.urlKind}, expected ${kind}`); + if (fragment !== undefined && r.fragment !== fragment) bad += fail(`${JSON.stringify(url)}: fragment=${r.fragment}`); + } + const split = parseHtml('x').references[0]!; + if (split.path !== 'page.html' || split.query !== 'x=1&y=2' || split.fragment !== 'top') { + bad += fail(`split: path=${split.path} query=${split.query} fragment=${split.fragment}`); + } + return bad; + }, + }, + { + name: 'a-relative-url-resolves-against-the-file-and-never-outside-the-root', + spec: 'a relative reference resolves against the base URL, which defaults to the document\'s', + rulesOut: 'a resolver that follows ../.. out of the project, or resolves to a file that is not there', + run: () => { + file('site/css/present.css', 'a{}'); + file('outside.css', 'b{}'); + const x = parseHtml('', 'site/page.html'); + const by = Object.fromEntries(x.references.map((r) => [r.urlAsWritten, r])); + let bad = 0; + if (by['css/present.css']?.resolvedFilePath !== path.join(TMP, 'site/css/present.css') || !by['css/present.css']?.isResolved) { + bad += fail('a present file was not resolved'); + } + if (by['css/absent.css']?.isResolved !== false) bad += fail('an absent file reads as resolved'); + if (by['../outside.css']?.isResolved !== false) bad += fail('a file outside the project root was resolved'); + if (x.document.toCsv().split('\t')[14] !== '3') bad += fail(`stylesheetReferenceCount=${x.document.toCsv().split('\t')[14]}, expected 3`); + return bad; + }, + }, + { + name: 'a-root-relative-url-resolves-only-when-one-served-root-holds-it', + spec: 'a path-absolute URL resolves against the origin, which the repository does not record', + rulesOut: 'a resolver that guesses between two candidate roots, or never resolves /static/x', + run: () => { + file('app/public/static/one.js', ''); + file('app/pages/deep/page.html', ''); + file('app/public/static/two.js', ''); + file('app/pages/static/two.js', ''); + const content = ''; + const p = file('app/pages/deep/page.html', content); + const x = new HtmlParser().parse(content, p, path.join(TMP, 'app'), 'V'); + const by = Object.fromEntries(x.references.map((r) => [r.urlAsWritten, r])); + let bad = 0; + if (by['/static/one.js']?.resolvedFilePath !== path.join(TMP, 'app/public/static/one.js')) { + bad += fail(`one candidate: resolved to ${JSON.stringify(by['/static/one.js']?.resolvedFilePath)}`); + } + if (by['/static/two.js']?.isResolved !== false) bad += fail('two candidates were not refused'); + if (by['/static/none.js']?.isResolved !== false) bad += fail('no candidate reads as resolved'); + const script = x.scripts.find((s) => s.src === '/static/one.js')!; + if (script.resolvedFilePath !== by['/static/one.js']!.resolvedFilePath || script.referenceLinkHash !== by['/static/one.js']!.getHash()) { + bad += fail('the script row does not carry its reference\'s resolution'); + } + return bad; + }, + }, + { + name: 'reference-kinds-follow-the-element', + spec: 'link rel=stylesheet is a stylesheet, source in picture is an image, elsewhere media', + rulesOut: 'a classifier that keys on the attribute name alone', + run: () => { + const x = parseHtml( + '' + + '' + + '
' + + '' + + '' + ); + const got = x.references.map((r) => `${r.referenceKind}:${r.urlAsWritten}`).sort().join(' '); + const want = [ + 'STYLESHEET:a.css', 'LINK_RESOURCE:i.png', 'STYLESHEET:b.css', 'IMAGE:p.webp', 'IMAGE:p.png', 'MEDIA:v.mp4', 'MEDIA:v.png', + 'MEDIA:v.webm', 'IMAGE:btn.png', 'OTHER:x', 'FORM_ACTION:/f', 'FORM_ACTION:/g', 'FRAME:f.html', 'FRAME:o.swf', 'BASE:/', + 'ANCHOR:l', 'ANCHOR:m', 'META_REFRESH:next.html', + ].sort().join(' '); + return got === want ? 0 : fail(`got ${got}\n want ${want}`); + }, + }, + { + name: 'srcset-candidates-are-separate-references', + spec: 'a srcset is a comma-separated list of candidates, each a URL and an optional descriptor', + rulesOut: 'a reader that records the whole attribute as one URL', + run: () => { + const x = parseHtml(''); + const got = x.references.map((r) => `${r.position}:${r.urlAsWritten}`).join(' '); + return got === '0:a.png 1:b.png 2:c.png 0:d.png' ? 0 : fail(`got ${got}`); + }, + }, + // ── HTML: handlers and scripts ────────────────────────────────────────── + { + name: 'every-call-in-a-handler-is-a-row-at-its-column', + spec: 'an event handler content attribute\'s value is a FunctionBody', + rulesOut: 'a reader that keeps the first call only, or cites the attribute instead of the call', + run: () => { + const text = '

\n x'; + const x = parseHtml(text); + const got = x.handlerCalls.map((h) => `${h.handlerSource === 'JAVASCRIPT_URL' ? 'url' : h.eventName}:${h.calleeText}/${h.calleeName}/${h.receiverText}/${h.argumentCount}${h.isNew ? '/new' : ''}`).join(' '); + const want = 'url:toggle/toggle//0 click:track/track//2 click:confirm/confirm//1 click:app.ui.remove/remove/app.ui/1 click:Audio/Audio//1/new'; + let bad = 0; + if (got !== want) bad += fail(`got ${got}\n want ${want}`); + const remove = x.handlerCalls.find((h) => h.calleeName === 'remove')!; + const at = col(text, 'app.ui.remove'); + if (remove.startLine !== at.line || remove.startColumn !== at.column) { + bad += fail(`remove cited at ${remove.startLine}:${remove.startColumn}, written at ${at.line}:${at.column}`); + } + const toggle = x.handlerCalls.find((h) => h.calleeName === 'toggle')!; + const tat = col(text, 'toggle()'); + if (toggle.startLine !== tat.line || toggle.startColumn !== tat.column) bad += fail(`toggle cited at ${toggle.startLine}:${toggle.startColumn}, written at ${tat.line}:${tat.column}`); + if (x.parseGaps.length !== 0) bad += fail(`gaps on well-formed handlers: ${x.parseGaps.map((g) => g.detail).join('; ')}`); + return bad; + }, + }, + { + name: 'a-handler-that-is-not-javascript-is-a-gap-not-a-call', + spec: 'a template placeholder in a handler is not a FunctionBody until rendered', + rulesOut: 'a reader that silently drops the attribute, or invents a call named handler', + run: () => { + const x = parseHtml(''); + let bad = 0; + const gaps = x.parseGaps.filter((g) => g.gapKind === HtmlParseGapKind.HANDLER_SYNTAX); + if (gaps.length !== 2) bad += fail(`${gaps.length} handler gaps, expected 2: ${x.parseGaps.map((g) => g.detail).join('; ')}`); + if (x.handlerCalls.some((h) => h.calleeName === 'handler')) bad += fail('a placeholder became a call'); + if (!x.handlerCalls.some((h) => h.calleeName === 'save')) bad += fail('the call the recovered tree still holds was dropped'); + return bad; + }, + }, + { + name: 'script-type-decides-what-the-browser-does-with-the-body', + spec: 'the type attribute: absent or a JavaScript MIME type runs; module; importmap; anything else is a data block', + rulesOut: 'a reader that runs every script or none', + run: () => { + const types: Array<[string, HtmlScriptType]> = [ + ['', HtmlScriptType.CLASSIC], ['text/javascript', HtmlScriptType.CLASSIC], ['application/javascript', HtmlScriptType.CLASSIC], + ['module', HtmlScriptType.MODULE], ['importmap', HtmlScriptType.IMPORTMAP], ['speculationrules', HtmlScriptType.SPECULATION_RULES], + ['application/json', HtmlScriptType.JSON], ['application/ld+json', HtmlScriptType.JSON], ['text/x-template', HtmlScriptType.TEMPLATE], + ['text/x-handlebars-template', HtmlScriptType.TEMPLATE], ['text/babel', HtmlScriptType.TRANSPILED], ['text/x-mathjax-config', HtmlScriptType.DATA_BLOCK], + ]; + let bad = 0; + for (const [type, want] of types) { + const x = parseHtml(`1`); + if (x.scripts[0]?.scriptType !== want) bad += fail(`type=${JSON.stringify(type)}: ${x.scripts[0]?.scriptType}, expected ${want}`); + } + return bad; + }, + }, + { + name: 'an-inline-script-body-is-located-exactly', + spec: 'script content is raw text between the start tag and the end tag', + rulesOut: 'a reader that trims the body or counts from the start tag', + run: () => { + const text = '\n\n \n'; + const x = parseHtml(text); + const [inline, external] = x.scripts; + let bad = 0; + if (inline === undefined || external === undefined) return fail(`${x.scripts.length} scripts`); + if (inline.scriptKind !== HtmlScriptKind.INLINE || !inline.isDefer) bad += fail('inline/defer misread'); + const bodyStart = text.indexOf(''); + const s = col(text, text.slice(bodyStart, bodyEnd)); + if (inline.bodyStartLine !== s.line || inline.bodyStartColumn !== s.column) bad += fail(`body starts ${inline.bodyStartLine}:${inline.bodyStartColumn}, expected ${s.line}:${s.column}`); + if (inline.bodyLength !== bodyEnd - bodyStart) bad += fail(`bodyLength=${inline.bodyLength}, expected ${bodyEnd - bodyStart}`); + if (inline.bodyEndLine !== 5 || inline.bodyEndColumn !== 3) bad += fail(`body ends ${inline.bodyEndLine}:${inline.bodyEndColumn}, expected 5:3`); + if (external.scriptKind !== HtmlScriptKind.EXTERNAL || external.bodyLength !== 0) bad += fail('an external script\'s body was read'); + if (x.document.toCsv().split('\t')[12] !== '2' || x.document.toCsv().split('\t')[13] !== '1') bad += fail('script counts are wrong'); + return bad; + }, + }, + // ── HTML: inline CSS ──────────────────────────────────────────────────── + { + name: 'a-style-element-is-a-stylesheet-at-the-page-s-lines', + spec: 'a style element\'s content is a CSS stylesheet', + rulesOut: 'a reader that cites line 1 of the CSS for a rule on line 4 of the page', + run: () => { + const text = '\n \n'; + const x = parseHtml(text); + let bad = 0; + if (x.stylesheets.length !== 1) return fail(`${x.stylesheets.length} stylesheets`); + const sheet = x.stylesheets[0]!; + const style = x.elements.find((e) => e.tagName === 'style')!; + if (sheet.sourceKind !== CssStylesheetSource.HTML_STYLE_ELEMENT || sheet.ownerHtmlElementLinkHash !== style.getHash()) bad += fail('the sheet does not chain to its element'); + if (sheet.startLine !== 2 || sheet.startColumn !== 10 || sheet.endLine !== 5) bad += fail(`sheet spans ${sheet.startLine}:${sheet.startColumn}-${sheet.endLine}`); + const b = x.css.rules.find((r) => r.preludeText === '.b')!; + if (b.startLine !== 4 || b.startColumn !== 5) bad += fail(`.b at ${b.startLine}:${b.startColumn}, expected 4:5`); + const gap = x.css.declarations.find((d) => d.property === 'gap')!; + if (gap.startLine !== 4 || gap.startColumn !== 10 || gap.stylesheetLinkHash !== sheet.getHash()) bad += fail(`gap at ${gap.startLine}:${gap.startColumn}`); + if (!x.css.valueReferences.some((v) => v.name === '--g' && v.startLine === 4)) bad += fail('var(--g) not read from the inline sheet'); + if (sheet.getRuleCount() !== 2 || sheet.getDeclarationCount() !== 2) bad += fail('counts not back-patched'); + const other = parseHtml(''); + if (other.stylesheets.length !== 0) bad += fail('a non-CSS style type was parsed as CSS'); + return bad; + }, + }, + { + name: 'a-style-attribute-is-a-declaration-list-owned-by-the-attribute', + spec: 'the style attribute\'s value is a ', + rulesOut: 'a reader that needs a rule to own a declaration, or drops the good half of a bad list', + run: () => { + const text = '

x
'; + const x = parseHtml(text); + const div = x.attributes.find((a) => a.name === 'style' && a.value.startsWith('color: red'))!; + const mine = x.css.declarations.filter((d) => d.htmlAttributeLinkHash === div.getHash()); + let bad = 0; + if (mine.map((d) => `${d.property}=${d.valueText}`).join(' ') !== 'color=red margin=0') bad += fail(`declarations=${mine.map((d) => d.property).join(',')}`); + if (mine.some((d) => d.ruleLinkHash !== '' || d.stylesheetLinkHash !== '')) bad += fail('a style attribute declaration claims a rule or a sheet'); + const at = col(text, 'margin'); + if (mine[1]!.startLine !== at.line || mine[1]!.startColumn !== at.column) bad += fail(`margin at ${mine[1]!.startLine}:${mine[1]!.startColumn}, written at ${at.line}:${at.column}`); + if (!x.css.declarations.some((d) => d.property === 'top')) bad += fail('the readable declaration after a bad one was dropped'); + if (!x.parseGaps.some((g) => g.gapKind === HtmlParseGapKind.STYLE_ATTRIBUTE_SYNTAX)) bad += fail('no STYLE_ATTRIBUTE_SYNTAX gap'); + return bad; + }, + }, + { + name: 'template-dialects-are-read-from-their-markers', + spec: 'the file is a template of the family whose delimiters it uses', + rulesOut: 'a detector keyed on the file extension, which is .html for all of them', + run: () => { + const cases: Array<[string, HtmlTemplateDialect[]]> = [ + ['

{{ a }}

', [HtmlTemplateDialect.MUSTACHE]], + ['{% if a %}

{{ a }}

{% endif %}', [HtmlTemplateDialect.JINJA, HtmlTemplateDialect.MUSTACHE]], + ['{{#each items}}
  • {{this}}
  • {{/each}}', [HtmlTemplateDialect.HANDLEBARS, HtmlTemplateDialect.MUSTACHE]], + ['<% if (u) { %>

    <%= u %>

    <% } %>', [HtmlTemplateDialect.ERB]], + ['', [HtmlTemplateDialect.PHP]], + ['@model Foo\n

    @Model.Name

    ', [HtmlTemplateDialect.RAZOR]], + ['

    ${name}

    ', [HtmlTemplateDialect.DOLLAR_BRACE]], + ['

    x

    ', [HtmlTemplateDialect.DOLLAR_BRACE, HtmlTemplateDialect.THYMELEAF]], + ['

    plain

    ', []], + ]; + let bad = 0; + for (const [text, want] of cases) { + const got = [...parseHtml(text).document.templateDialects].sort().join(','); + if (got !== [...want].sort().join(',')) bad += fail(`${JSON.stringify(text)}: ${got || '(none)'}, expected ${want.join(',') || '(none)'}`); + } + return bad; + }, + }, + { + name: 'gaps-are-capped-with-a-count-of-the-rest', + spec: 'a parse error per attribute on a file with thousands of them is a count, not a table', + rulesOut: 'an extractor that emits ten thousand gap rows, or drops them with no trace', + run: () => { + const attrs = Array.from({ length: WEB_PARSE_GAP_LIMIT + 50 }, (_, i) => `a${i}="1" a${i}="2"`).join(' '); + const x = parseHtml(`
    `); + const limit = x.parseGaps.find((g) => g.gapKind === HtmlParseGapKind.GAP_LIMIT_REACHED); + let bad = 0; + if (x.parseGaps.length !== WEB_PARSE_GAP_LIMIT + 1) bad += fail(`${x.parseGaps.length} gaps, expected ${WEB_PARSE_GAP_LIMIT} + 1`); + if (limit === undefined || !limit.detail.startsWith('50 ')) bad += fail(`limit row=${limit?.detail}`); + if (x.document.getParseGapCount() !== x.parseGaps.length) bad += fail('document gap count disagrees'); + return bad; + }, + }, + // ── CSS: selectors ────────────────────────────────────────────────────── + { + name: 'specificity-follows-selectors-level-4', + spec: 'count ids (a); classes, attributes and pseudo-classes (b); types and pseudo-elements (c); :where() adds nothing; :is()/:not()/:has() add their most specific argument', + rulesOut: 'a counter that counts :where(), ignores :not()\'s argument, or counts *', + run: () => { + const cases: Array<[string, string]> = [ + ['#a .b c::before', '1,1,2'], ['*', '0,0,0'], ['ul li', '0,0,2'], [':root', '0,1,0'], + [':is(#x, .y) p', '1,0,1'], [':where(.x) p', '0,0,1'], ['div:not(.a .b)', '0,2,1'], [':has(> img)', '0,0,1'], + ['li:nth-child(2n of .x)', '0,2,1'], ['a:hover', '0,1,1'], ['a:before', '0,0,2'], ['input[type="text"]', '0,1,1'], + [':host(.a)', '0,2,0'], ['.a.b.c', '0,3,0'], ['svg|rect', '0,0,1'], ['&:hover', '0,1,0'], + ]; + let bad = 0; + for (const [selector, want] of cases) { + const got = specificity(selector); + if (got !== want) bad += fail(`${selector}: ${got}, expected ${want}`); + } + return bad; + }, + }, + { + name: 'selector-parts-keep-compounds-combinators-and-nesting', + spec: 'a complex selector is compounds separated by combinators; a functional pseudo-class takes selectors as arguments', + rulesOut: 'a flattener that loses which compound a class is in, or whether it was negated', + run: () => { + const x = parseCss('.nav > li.item:not(.hidden) + a[href^="http" i] ~ b::before, #x { color: red }'); + const got = x.selectorParts.map((p) => `${p.position}:${p.partKind}:${p.name}${p.value ? '=' + p.value : ''}${p.attributeMatcher}${p.attributeFlags}@${p.compoundIndex}${p.combinatorBefore === CssCombinator.NONE ? '' : '<' + p.combinatorBefore}${p.depth ? '^' + p.depth : ''}`).join(' '); + const want = '0:CLASS:nav@0 1:TYPE:li@1