From 16539a629b9d42181c53eb87f3439e2d73e88706 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:29:42 -0700 Subject: [PATCH 1/6] parser: a bare import of a package the repository declares binds to its source A monorepo imports a sibling package by its name, and that package's main / types / exports name build output that is not in the source tree. tsc then resolved nothing, or a dist file no program walks, so every call, type and const across the package boundary was matched by name only. The package-entry rows already map a dist target back to the source it is built from; import resolution now uses the same mapping (subpaths and patterns included) for any bare specifier whose package.json lives under the walked tree, in the TypeScript and JavaScript front ends. A package tsc resolves inside node_modules is left alone. impact: asked of an interface method, the implementations the engine dispatches to are listed under "must change with it". Without an override table they had no row at all, and one only surfaced through a wrong by-name edge that the resolved import removed. The hook's SQL summary gets the same leg and now drops shape-only pairs as the rules do. Tests: new TypeScript case 85-workspace-package-import (scoped package with exports, subpath, pattern, unscoped deep import, a field-typed receiver, and a third-party control); the three JavaScript alias cases fold into 70-bare-specifier-to-project-source with a workspace-package table row; the dispatch-base CLI case checks the base side. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../src/bundler-alias}/legacy/app.js | 0 .../src/bundler-alias}/legacy/lib/extra.js | 0 .../src/bundler-alias}/legacy/lib/index.js | 0 .../bundler-alias}/legacy/lib/util/trim.js | 0 .../bundler-alias}/legacy/webpack.config.cjs | 0 .../src/bundler-alias}/mapped/jsconfig.json | 0 .../src/bundler-alias}/mapped/own/date.js | 0 .../src/bundler-alias}/mapped/probe.js | 0 .../src/bundler-alias}/package.json | 0 .../src/bundler-alias}/shared/fmt.js | 0 .../src/bundler-alias}/src/pages/Home.js | 0 .../src/bundler-alias}/src/utils/date.js | 0 .../src/bundler-alias}/vite.config.js | 0 .../src/framework-alias}/kit/jsconfig.json | 0 .../src/framework-alias}/kit/src/lib/api.js | 0 .../framework-alias}/kit/src/routes/page.js | 0 .../framework-alias}/nuxt/server/api/price.js | 0 .../src/framework-alias}/nuxt/tsconfig.json | 0 .../src/framework-alias}/nuxt/utils/price.js | 0 .../framework-alias}/nuxt4/app/pages/index.js | 0 .../framework-alias}/nuxt4/app/utils/date.js | 0 .../src/framework-alias}/nuxt4/tsconfig.json | 0 .../src/framework-alias}/other/jsconfig.json | 0 .../src/framework-alias}/other/probe.js | 0 .../framework-alias}/other/src/lib/helper.js | 0 .../src/framework-alias}/own/jsconfig.json | 0 .../src/framework-alias}/own/probe.js | 0 .../src/framework-alias}/own/shared/util.js | 0 .../src/framework-alias}/own/src/lib/util.js | 0 .../src/framework-alias}/package.json | 0 .../src/jsconfig-paths}/app.js | 0 .../src/jsconfig-paths}/jsconfig.json | 0 .../src/jsconfig-paths}/lib/check.js | 0 .../src/jsconfig-paths}/noalias/jsconfig.json | 0 .../src/jsconfig-paths}/noalias/probe.js | 0 .../src/jsconfig-paths}/package.json | 0 .../remix/app/models/note.server.js | 0 .../src/jsconfig-paths}/remix/app/route.js | 0 .../src/jsconfig-paths}/remix/tsconfig.json | 0 .../workspace-package/apps/app/package.json | 1 + .../workspace-package/apps/app/src/main.js | 13 ++ .../packages/lib/package.json | 8 ++ .../packages/lib/src/index.js | 9 ++ .../packages/lib/src/sub/index.js | 3 + .../packages/tools/package.json | 1 + .../packages/tools/src/deep.js | 3 + .../70-bare-specifier-to-project-source.diag | 35 ++++++ .../70-bare-specifier-to-project-source.edges | 35 ++++++ ...70-bare-specifier-to-project-source.oracle | 9 ++ .../expected/70-jsconfig-path-alias.diag | 3 - .../expected/70-jsconfig-path-alias.edges | 4 - .../expected/70-jsconfig-path-alias.oracle | 2 - .../expected/71-bundler-config-alias.diag | 21 ---- .../expected/71-bundler-config-alias.edges | 18 --- .../expected/71-bundler-config-alias.oracle | 8 -- .../71-framework-generated-config-alias.diag | 3 - .../71-framework-generated-config-alias.edges | 7 -- ...71-framework-generated-config-alias.oracle | 1 - .../src/apps/app/package.json | 1 + .../src/apps/app/src/local.ts | 1 + .../src/apps/app/src/main.ts | 25 ++++ .../src/apps/app/types/zod.ts | 4 + .../src/package.json | 1 + .../src/packages/lib/package.json | 10 ++ .../src/packages/lib/src/feature/flags.ts | 3 + .../src/packages/lib/src/index.ts | 7 ++ .../src/packages/lib/src/sub/index.ts | 1 + .../src/packages/tools/package.json | 1 + .../src/packages/tools/src/deep.ts | 1 + .../src/packages/tools/src/index.ts | 1 + .../src/pnpm-workspace.yaml | 3 + .../85-workspace-package-import.edges | 8 ++ .../85-workspace-package-import.entries | 3 + .../85-workspace-package-import.fields | 1 + .../85-workspace-package-import.fields-oracle | 7 ++ .../85-workspace-package-import.oracle | 1 + .../85-workspace-package-import.type-use | 1 + .../85-workspace-package-import.types-oracle | 7 ++ .../typescript/ground-truth/tsc-program.mjs | 26 ++++ .../imports/TsImportResolutionKind.ts | 5 + .../extractors/js-fact-extractor.ts | 6 + .../extractors/js-module-edge-extractor.ts | 22 +++- .../extractors/ts-fact-extractor.ts | 22 ++-- .../extractors/ts-import-extractor.ts | 35 +++++- .../typescript/ts-package-entry-extractor.ts | 64 +++++++++- .../parsers/typescript/workspace-packages.ts | 111 ++++++++++++++++++ .../javascript/javascript-project-analyzer.ts | 19 +++ .../typescript/typescript-project-analyzer.ts | 43 +++++++ .../skills/axiomcode/scripts/dl/impact.dl | 6 + .../skills/axiomcode/scripts/graph_sql.py | 18 +++ .../dispatch-base-is-a-contract/case.json | 6 +- 91 files changed, 567 insertions(+), 87 deletions(-) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/app.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/lib/extra.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/lib/index.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/lib/util/trim.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/legacy/webpack.config.cjs (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/mapped/jsconfig.json (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/mapped/own/date.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/mapped/probe.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/package.json (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/shared/fmt.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/src/pages/Home.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/src/utils/date.js (100%) rename graph/test/javascript/cases/{71-bundler-config-alias/src => 70-bare-specifier-to-project-source/src/bundler-alias}/vite.config.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/kit/jsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/kit/src/lib/api.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/kit/src/routes/page.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt/server/api/price.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt/tsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt/utils/price.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt4/app/pages/index.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt4/app/utils/date.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/nuxt4/tsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/other/jsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/other/probe.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/other/src/lib/helper.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/jsconfig.json (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/probe.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/shared/util.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/own/src/lib/util.js (100%) rename graph/test/javascript/cases/{71-framework-generated-config-alias/src => 70-bare-specifier-to-project-source/src/framework-alias}/package.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/app.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/jsconfig.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/lib/check.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/noalias/jsconfig.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/noalias/probe.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/package.json (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/remix/app/models/note.server.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/remix/app/route.js (100%) rename graph/test/javascript/cases/{70-jsconfig-path-alias/src => 70-bare-specifier-to-project-source/src/jsconfig-paths}/remix/tsconfig.json (100%) create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json create mode 100644 graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js create mode 100644 graph/test/javascript/expected/70-bare-specifier-to-project-source.diag create mode 100644 graph/test/javascript/expected/70-bare-specifier-to-project-source.edges create mode 100644 graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle delete mode 100644 graph/test/javascript/expected/70-jsconfig-path-alias.diag delete mode 100644 graph/test/javascript/expected/70-jsconfig-path-alias.edges delete mode 100644 graph/test/javascript/expected/70-jsconfig-path-alias.oracle delete mode 100644 graph/test/javascript/expected/71-bundler-config-alias.diag delete mode 100644 graph/test/javascript/expected/71-bundler-config-alias.edges delete mode 100644 graph/test/javascript/expected/71-bundler-config-alias.oracle delete mode 100644 graph/test/javascript/expected/71-framework-generated-config-alias.diag delete mode 100644 graph/test/javascript/expected/71-framework-generated-config-alias.edges delete mode 100644 graph/test/javascript/expected/71-framework-generated-config-alias.oracle create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts create mode 100644 graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml create mode 100644 graph/test/typescript/expected/85-workspace-package-import.edges create mode 100644 graph/test/typescript/expected/85-workspace-package-import.entries create mode 100644 graph/test/typescript/expected/85-workspace-package-import.fields create mode 100644 graph/test/typescript/expected/85-workspace-package-import.fields-oracle create mode 100644 graph/test/typescript/expected/85-workspace-package-import.oracle create mode 100644 graph/test/typescript/expected/85-workspace-package-import.type-use create mode 100644 graph/test/typescript/expected/85-workspace-package-import.types-oracle create mode 100644 parser/src/parsers/typescript/workspace-packages.ts diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/app.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/app.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/app.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/extra.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/extra.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/extra.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/index.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/index.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/index.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/util/trim.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/lib/util/trim.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/lib/util/trim.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/webpack.config.cjs similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/legacy/webpack.config.cjs rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/legacy/webpack.config.cjs diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/mapped/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/jsconfig.json diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/own/date.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/mapped/own/date.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/own/date.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/probe.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/mapped/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/mapped/probe.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/package.json similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/package.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/package.json diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/shared/fmt.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/shared/fmt.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/shared/fmt.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/pages/Home.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/src/pages/Home.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/pages/Home.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/utils/date.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/src/utils/date.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/src/utils/date.js diff --git a/graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/vite.config.js similarity index 100% rename from graph/test/javascript/cases/71-bundler-config-alias/src/vite.config.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/bundler-alias/vite.config.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/jsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/lib/api.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/lib/api.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/lib/api.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/lib/api.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/routes/page.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/routes/page.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/kit/src/routes/page.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/kit/src/routes/page.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/server/api/price.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/server/api/price.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/server/api/price.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/server/api/price.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/tsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/tsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/tsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/tsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/utils/price.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/utils/price.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt/utils/price.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt/utils/price.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/pages/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/pages/index.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/pages/index.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/pages/index.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/utils/date.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/utils/date.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/app/utils/date.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/app/utils/date.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/tsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/tsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/nuxt4/tsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/nuxt4/tsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/other/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/other/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/jsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/other/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/probe.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/other/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/probe.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/other/src/lib/helper.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/src/lib/helper.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/other/src/lib/helper.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/other/src/lib/helper.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/jsconfig.json diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/probe.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/probe.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/shared/util.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/shared/util.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/shared/util.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/shared/util.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/own/src/lib/util.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/src/lib/util.js similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/own/src/lib/util.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/own/src/lib/util.js diff --git a/graph/test/javascript/cases/71-framework-generated-config-alias/src/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/package.json similarity index 100% rename from graph/test/javascript/cases/71-framework-generated-config-alias/src/package.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/framework-alias/package.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/app.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/app.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/app.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/jsconfig.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/lib/check.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/lib/check.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/lib/check.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/jsconfig.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/jsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/jsconfig.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/probe.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/noalias/probe.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/noalias/probe.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/package.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/package.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/package.json diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/models/note.server.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/models/note.server.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/models/note.server.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/route.js similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/app/route.js rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/app/route.js diff --git a/graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/tsconfig.json similarity index 100% rename from graph/test/javascript/cases/70-jsconfig-path-alias/src/remix/tsconfig.json rename to graph/test/javascript/cases/70-bare-specifier-to-project-source/src/jsconfig-paths/remix/tsconfig.json diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json new file mode 100644 index 00000000..6eba3270 --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/package.json @@ -0,0 +1 @@ +{ "name": "@ws/app", "type": "module" } diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js new file mode 100644 index 00000000..dbef4e0f --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/apps/app/src/main.js @@ -0,0 +1,13 @@ +import { f, Bus } from '@ws/lib'; +import { g } from '@ws/lib/sub'; +import { deep } from 'ws-tools/deep'; +// Control: a package this repository does not declare stays unresolved. +import { outside } from 'not-in-this-repo'; + +export function run() { + f(); + g(); + deep(); + new Bus().publish('k'); + return outside(); +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json new file mode 100644 index 00000000..4388cd84 --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "@ws/lib", + "main": "./dist/index.js", + "exports": { + ".": { "import": "./dist/index.mjs", "require": "./dist/index.js" }, + "./sub": "./dist/sub/index.js" + } +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js new file mode 100644 index 00000000..b4154a53 --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/index.js @@ -0,0 +1,9 @@ +export function f() { + return 1; +} + +export class Bus { + publish(key) { + return key; + } +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js new file mode 100644 index 00000000..ac3e27ac --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/lib/src/sub/index.js @@ -0,0 +1,3 @@ +export function g() { + return 2; +} diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json new file mode 100644 index 00000000..c1cd873a --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/package.json @@ -0,0 +1 @@ +{ "name": "ws-tools", "main": "dist/index.js" } diff --git a/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js new file mode 100644 index 00000000..8539777a --- /dev/null +++ b/graph/test/javascript/cases/70-bare-specifier-to-project-source/src/workspace-package/packages/tools/src/deep.js @@ -0,0 +1,3 @@ +export function deep() { + return 3; +} diff --git a/graph/test/javascript/expected/70-bare-specifier-to-project-source.diag b/graph/test/javascript/expected/70-bare-specifier-to-project-source.diag new file mode 100644 index 00000000..fe9810d3 --- /dev/null +++ b/graph/test/javascript/expected/70-bare-specifier-to-project-source.diag @@ -0,0 +1,35 @@ +import_cause bundler-alias/legacy/app.js:4:10 Lib/extra not_staged +import_cause bundler-alias/legacy/app.js:5:10 Loose/extra not_staged +import_cause bundler-alias/legacy/webpack.config.cjs:1:14 path builtin +import_cause bundler-alias/src/pages/Home.js:6:10 @utils/date not_staged +import_cause bundler-alias/vite.config.js:1:10 vite not_staged +import_cause bundler-alias/vite.config.js:2:1 path builtin +import_cause bundler-alias/vite.config.js:3:10 node:url builtin +import_cause bundler-alias/vite.config.js:3:25 node:url builtin +import_cause framework-alias/other/probe.js:2:10 $lib/helper not_staged +import_cause jsconfig-paths/noalias/probe.js:2:10 @/check not_staged +import_cause workspace-package/apps/app/src/main.js:5:10 not-in-this-repo not_staged +package_entry @ws/app . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry @ws/lib . [] MAIN dist/index.js MISSING_FILE -> - +package_entry @ws/lib . [import] EXPORTS dist/index.mjs MISSING_FILE -> - +package_entry @ws/lib . [require] EXPORTS dist/index.js MISSING_FILE -> - +package_entry @ws/lib ./sub [] EXPORTS dist/sub/index.js MISSING_FILE -> - +package_entry bundler-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry framework-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry path-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - +package_entry ws-tools . [] MAIN dist/index.js MISSING_FILE -> - +unresolved bundler-alias/legacy/app.js:10:3 FUNCTION_CALL loose callee_untyped +unresolved bundler-alias/legacy/app.js:9:3 FUNCTION_CALL extra callee_untyped +unresolved bundler-alias/legacy/lib/util/trim.js:2:10 METHOD_CALL trim receiver_untyped +unresolved bundler-alias/legacy/webpack.config.cjs:7:13 METHOD_CALL resolve no_target +unresolved bundler-alias/legacy/webpack.config.cjs:8:13 METHOD_CALL join no_target +unresolved bundler-alias/shared/fmt.js:2:10 METHOD_CALL toFixed receiver_untyped +unresolved bundler-alias/src/pages/Home.js:10:3 FUNCTION_CALL scoped callee_untyped +unresolved bundler-alias/src/utils/date.js:2:10 FUNCTION_CALL String no_target +unresolved bundler-alias/vite.config.js:10:18 FUNCTION_CALL fileURLToPath no_target +unresolved bundler-alias/vite.config.js:10:32 CONSTRUCTOR_CALL URL no_target +unresolved bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig callee_untyped +unresolved bundler-alias/vite.config.js:9:12 METHOD_CALL resolve receiver_untyped +unresolved framework-alias/other/probe.js:5:10 FUNCTION_CALL helper callee_untyped +unresolved jsconfig-paths/noalias/probe.js:5:10 FUNCTION_CALL checkA callee_untyped +unresolved workspace-package/apps/app/src/main.js:12:10 FUNCTION_CALL outside callee_untyped diff --git a/graph/test/javascript/expected/70-bare-specifier-to-project-source.edges b/graph/test/javascript/expected/70-bare-specifier-to-project-source.edges new file mode 100644 index 00000000..6dc90ef1 --- /dev/null +++ b/graph/test/javascript/expected/70-bare-specifier-to-project-source.edges @@ -0,0 +1,35 @@ +bundler-alias/legacy/app.js:10:3 FUNCTION_CALL loose -> ambiguous_unknown - +bundler-alias/legacy/app.js:11:10 FUNCTION_CALL trim -> known_edge bundler-alias/legacy/lib/util/trim.js:1:1 trim +bundler-alias/legacy/app.js:8:3 FUNCTION_CALL boot -> known_edge bundler-alias/legacy/lib/index.js:1:1 boot +bundler-alias/legacy/app.js:9:3 FUNCTION_CALL extra -> ambiguous_unknown - +bundler-alias/legacy/lib/util/trim.js:2:10 METHOD_CALL s.trim -> ambiguous_unknown - +bundler-alias/legacy/webpack.config.cjs:7:13 METHOD_CALL path.resolve -> ambient_terminal - +bundler-alias/legacy/webpack.config.cjs:8:13 METHOD_CALL path.join -> ambient_terminal - +bundler-alias/mapped/probe.js:5:10 FUNCTION_CALL fmtDate -> known_edge bundler-alias/mapped/own/date.js:1:1 fmtDate +bundler-alias/shared/fmt.js:2:10 METHOD_CALL n.toFixed -> ambiguous_unknown - +bundler-alias/src/pages/Home.js:10:3 FUNCTION_CALL scoped -> ambiguous_unknown - +bundler-alias/src/pages/Home.js:11:10 FUNCTION_CALL fmtDate -> known_edge bundler-alias/src/utils/date.js:1:1 fmtDate +bundler-alias/src/pages/Home.js:9:3 FUNCTION_CALL fmtMoney -> known_edge bundler-alias/shared/fmt.js:1:1 fmtMoney +bundler-alias/src/utils/date.js:2:10 FUNCTION_CALL String -> ambient_terminal - +bundler-alias/vite.config.js:10:18 FUNCTION_CALL fileURLToPath -> ambient_terminal - +bundler-alias/vite.config.js:10:32 CONSTRUCTOR_CALL URL -> ambient_terminal - +bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig -> ambiguous_unknown - +bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig -> callback_registered bundler-alias/vite.config.js:6:29 +bundler-alias/vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal - +framework-alias/kit/src/routes/page.js:10:10 FUNCTION_CALL post -> known_edge framework-alias/kit/src/lib/api.js:5:1 post +framework-alias/kit/src/routes/page.js:6:10 METHOD_CALL api.get -> known_edge framework-alias/kit/src/lib/api.js:1:1 get +framework-alias/nuxt/server/api/price.js:6:10 FUNCTION_CALL formatPrice -> known_edge framework-alias/nuxt/utils/price.js:1:1 formatPrice +framework-alias/nuxt/server/api/price.js:6:27 FUNCTION_CALL viaAt -> known_edge framework-alias/nuxt/utils/price.js:1:1 formatPrice +framework-alias/nuxt4/app/pages/index.js:5:10 FUNCTION_CALL formatDate -> known_edge framework-alias/nuxt4/app/utils/date.js:1:1 formatDate +framework-alias/other/probe.js:5:10 FUNCTION_CALL helper -> ambiguous_unknown - +framework-alias/own/probe.js:5:10 FUNCTION_CALL pick -> known_edge framework-alias/own/shared/util.js:1:1 pick +jsconfig-paths/app.js:11:10 FUNCTION_CALL checkB -> known_edge jsconfig-paths/lib/check.js:5:1 checkB +jsconfig-paths/app.js:7:10 FUNCTION_CALL checkA -> known_edge jsconfig-paths/lib/check.js:1:1 checkA +jsconfig-paths/noalias/probe.js:5:10 FUNCTION_CALL checkA -> ambiguous_unknown - +jsconfig-paths/remix/app/route.js:5:10 FUNCTION_CALL getNote -> known_edge jsconfig-paths/remix/app/models/note.server.js:1:1 getNote +workspace-package/apps/app/src/main.js:10:3 FUNCTION_CALL deep -> known_edge workspace-package/packages/tools/src/deep.js:1:1 deep +workspace-package/apps/app/src/main.js:11:3 CONSTRUCTOR_CALL Bus -> implicit_constructor - +workspace-package/apps/app/src/main.js:11:3 METHOD_CALL new Bus().publish -> known_edge workspace-package/packages/lib/src/index.js:6:3 publish +workspace-package/apps/app/src/main.js:12:10 FUNCTION_CALL outside -> ambiguous_unknown - +workspace-package/apps/app/src/main.js:8:3 FUNCTION_CALL f -> known_edge workspace-package/packages/lib/src/index.js:1:1 f +workspace-package/apps/app/src/main.js:9:3 FUNCTION_CALL g -> known_edge workspace-package/packages/lib/src/sub/index.js:1:1 g diff --git a/graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle b/graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle new file mode 100644 index 00000000..c5403e62 --- /dev/null +++ b/graph/test/javascript/expected/70-bare-specifier-to-project-source.oracle @@ -0,0 +1,9 @@ +bundler-alias/legacy/webpack.config.cjs:7:13 METHOD_CALL resolve LIB_AMBIENT_OK +bundler-alias/legacy/webpack.config.cjs:8:13 METHOD_CALL join LIB_AMBIENT_OK +bundler-alias/src/utils/date.js:2:10 FUNCTION_CALL String LIB_AMBIENT_OK +bundler-alias/vite.config.js:10:18 FUNCTION_CALL fileURLToPath LIB_AMBIENT_OK +bundler-alias/vite.config.js:10:32 CONSTRUCTOR_CALL URL LIB_AMBIENT_OK +bundler-alias/vite.config.js:6:16 FUNCTION_CALL defineConfig LIB_MISSED +bundler-alias/vite.config.js:9:12 METHOD_CALL resolve LIB_AMBIENT_OK +jsconfig-paths/app.js:11:10 FUNCTION_CALL checkB EXACT jsconfig-paths/lib/check.js:5:1 +# defects: 0 diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.diag b/graph/test/javascript/expected/70-jsconfig-path-alias.diag deleted file mode 100644 index 74d4aa41..00000000 --- a/graph/test/javascript/expected/70-jsconfig-path-alias.diag +++ /dev/null @@ -1,3 +0,0 @@ -import_cause noalias/probe.js:2:10 @/check not_staged -package_entry path-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - -unresolved noalias/probe.js:5:10 FUNCTION_CALL checkA callee_untyped diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.edges b/graph/test/javascript/expected/70-jsconfig-path-alias.edges deleted file mode 100644 index 09a8c77c..00000000 --- a/graph/test/javascript/expected/70-jsconfig-path-alias.edges +++ /dev/null @@ -1,4 +0,0 @@ -app.js:11:10 FUNCTION_CALL checkB -> known_edge lib/check.js:5:1 checkB -app.js:7:10 FUNCTION_CALL checkA -> known_edge lib/check.js:1:1 checkA -noalias/probe.js:5:10 FUNCTION_CALL checkA -> ambiguous_unknown - -remix/app/route.js:5:10 FUNCTION_CALL getNote -> known_edge remix/app/models/note.server.js:1:1 getNote diff --git a/graph/test/javascript/expected/70-jsconfig-path-alias.oracle b/graph/test/javascript/expected/70-jsconfig-path-alias.oracle deleted file mode 100644 index 16df3e53..00000000 --- a/graph/test/javascript/expected/70-jsconfig-path-alias.oracle +++ /dev/null @@ -1,2 +0,0 @@ -app.js:11:10 FUNCTION_CALL checkB EXACT lib/check.js:5:1 -# defects: 0 diff --git a/graph/test/javascript/expected/71-bundler-config-alias.diag b/graph/test/javascript/expected/71-bundler-config-alias.diag deleted file mode 100644 index d46236f8..00000000 --- a/graph/test/javascript/expected/71-bundler-config-alias.diag +++ /dev/null @@ -1,21 +0,0 @@ -import_cause legacy/app.js:4:10 Lib/extra not_staged -import_cause legacy/app.js:5:10 Loose/extra not_staged -import_cause legacy/webpack.config.cjs:1:14 path builtin -import_cause src/pages/Home.js:6:10 @utils/date not_staged -import_cause vite.config.js:1:10 vite not_staged -import_cause vite.config.js:2:1 path builtin -import_cause vite.config.js:3:10 node:url builtin -import_cause vite.config.js:3:25 node:url builtin -package_entry bundler-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - -unresolved legacy/app.js:10:3 FUNCTION_CALL loose callee_untyped -unresolved legacy/app.js:9:3 FUNCTION_CALL extra callee_untyped -unresolved legacy/lib/util/trim.js:2:10 METHOD_CALL trim receiver_untyped -unresolved legacy/webpack.config.cjs:7:13 METHOD_CALL resolve no_target -unresolved legacy/webpack.config.cjs:8:13 METHOD_CALL join no_target -unresolved shared/fmt.js:2:10 METHOD_CALL toFixed receiver_untyped -unresolved src/pages/Home.js:10:3 FUNCTION_CALL scoped callee_untyped -unresolved src/utils/date.js:2:10 FUNCTION_CALL String no_target -unresolved vite.config.js:10:18 FUNCTION_CALL fileURLToPath no_target -unresolved vite.config.js:10:32 CONSTRUCTOR_CALL URL no_target -unresolved vite.config.js:6:16 FUNCTION_CALL defineConfig callee_untyped -unresolved vite.config.js:9:12 METHOD_CALL resolve receiver_untyped diff --git a/graph/test/javascript/expected/71-bundler-config-alias.edges b/graph/test/javascript/expected/71-bundler-config-alias.edges deleted file mode 100644 index db2535ff..00000000 --- a/graph/test/javascript/expected/71-bundler-config-alias.edges +++ /dev/null @@ -1,18 +0,0 @@ -legacy/app.js:10:3 FUNCTION_CALL loose -> ambiguous_unknown - -legacy/app.js:11:10 FUNCTION_CALL trim -> known_edge legacy/lib/util/trim.js:1:1 trim -legacy/app.js:8:3 FUNCTION_CALL boot -> known_edge legacy/lib/index.js:1:1 boot -legacy/app.js:9:3 FUNCTION_CALL extra -> ambiguous_unknown - -legacy/lib/util/trim.js:2:10 METHOD_CALL s.trim -> ambiguous_unknown - -legacy/webpack.config.cjs:7:13 METHOD_CALL path.resolve -> ambient_terminal - -legacy/webpack.config.cjs:8:13 METHOD_CALL path.join -> ambient_terminal - -mapped/probe.js:5:10 FUNCTION_CALL fmtDate -> known_edge mapped/own/date.js:1:1 fmtDate -shared/fmt.js:2:10 METHOD_CALL n.toFixed -> ambiguous_unknown - -src/pages/Home.js:10:3 FUNCTION_CALL scoped -> ambiguous_unknown - -src/pages/Home.js:11:10 FUNCTION_CALL fmtDate -> known_edge src/utils/date.js:1:1 fmtDate -src/pages/Home.js:9:3 FUNCTION_CALL fmtMoney -> known_edge shared/fmt.js:1:1 fmtMoney -src/utils/date.js:2:10 FUNCTION_CALL String -> ambient_terminal - -vite.config.js:10:18 FUNCTION_CALL fileURLToPath -> ambient_terminal - -vite.config.js:10:32 CONSTRUCTOR_CALL URL -> ambient_terminal - -vite.config.js:6:16 FUNCTION_CALL defineConfig -> ambiguous_unknown - -vite.config.js:6:16 FUNCTION_CALL defineConfig -> callback_registered vite.config.js:6:29 -vite.config.js:9:12 METHOD_CALL path.resolve -> ambient_terminal - diff --git a/graph/test/javascript/expected/71-bundler-config-alias.oracle b/graph/test/javascript/expected/71-bundler-config-alias.oracle deleted file mode 100644 index 2d722071..00000000 --- a/graph/test/javascript/expected/71-bundler-config-alias.oracle +++ /dev/null @@ -1,8 +0,0 @@ -legacy/webpack.config.cjs:7:13 METHOD_CALL resolve LIB_AMBIENT_OK -legacy/webpack.config.cjs:8:13 METHOD_CALL join LIB_AMBIENT_OK -src/utils/date.js:2:10 FUNCTION_CALL String LIB_AMBIENT_OK -vite.config.js:10:18 FUNCTION_CALL fileURLToPath LIB_AMBIENT_OK -vite.config.js:10:32 CONSTRUCTOR_CALL URL LIB_AMBIENT_OK -vite.config.js:6:16 FUNCTION_CALL defineConfig LIB_MISSED -vite.config.js:9:12 METHOD_CALL resolve LIB_AMBIENT_OK -# defects: 0 diff --git a/graph/test/javascript/expected/71-framework-generated-config-alias.diag b/graph/test/javascript/expected/71-framework-generated-config-alias.diag deleted file mode 100644 index 008e2a73..00000000 --- a/graph/test/javascript/expected/71-framework-generated-config-alias.diag +++ /dev/null @@ -1,3 +0,0 @@ -import_cause other/probe.js:2:10 $lib/helper not_staged -package_entry framework-alias-fixture . [] DEFAULT_INDEX index.js MISSING_FILE -> - -unresolved other/probe.js:5:10 FUNCTION_CALL helper callee_untyped diff --git a/graph/test/javascript/expected/71-framework-generated-config-alias.edges b/graph/test/javascript/expected/71-framework-generated-config-alias.edges deleted file mode 100644 index 916bc000..00000000 --- a/graph/test/javascript/expected/71-framework-generated-config-alias.edges +++ /dev/null @@ -1,7 +0,0 @@ -kit/src/routes/page.js:10:10 FUNCTION_CALL post -> known_edge kit/src/lib/api.js:5:1 post -kit/src/routes/page.js:6:10 METHOD_CALL api.get -> known_edge kit/src/lib/api.js:1:1 get -nuxt/server/api/price.js:6:10 FUNCTION_CALL formatPrice -> known_edge nuxt/utils/price.js:1:1 formatPrice -nuxt/server/api/price.js:6:27 FUNCTION_CALL viaAt -> known_edge nuxt/utils/price.js:1:1 formatPrice -nuxt4/app/pages/index.js:5:10 FUNCTION_CALL formatDate -> known_edge nuxt4/app/utils/date.js:1:1 formatDate -other/probe.js:5:10 FUNCTION_CALL helper -> ambiguous_unknown - -own/probe.js:5:10 FUNCTION_CALL pick -> known_edge own/shared/util.js:1:1 pick diff --git a/graph/test/javascript/expected/71-framework-generated-config-alias.oracle b/graph/test/javascript/expected/71-framework-generated-config-alias.oracle deleted file mode 100644 index 8675b52d..00000000 --- a/graph/test/javascript/expected/71-framework-generated-config-alias.oracle +++ /dev/null @@ -1 +0,0 @@ -# defects: 0 diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json new file mode 100644 index 00000000..d23a4a6c --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/package.json @@ -0,0 +1 @@ +{ "name": "@x/app", "private": true, "dependencies": { "@x/lib": "workspace:*", "tools": "workspace:*", "zod": "^3.0.0" } } diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts new file mode 100644 index 00000000..e21076ca --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/local.ts @@ -0,0 +1 @@ +export function local(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts new file mode 100644 index 00000000..74be7308 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/src/main.ts @@ -0,0 +1,25 @@ +import { f, Bus, K } from '@x/lib'; +import { g } from '@x/lib/sub'; +import { isOn } from '@x/lib/feature/flags'; +import { tool } from 'tools'; +import { deep } from 'tools/deep'; +// Control: a third-party package no workspace declares stays a library import. +import { z } from 'zod'; + +import { local } from './local'; + +export class S { + constructor(private bus: Bus) {} + + run(): void { + f(); + g(); + this.bus.publish(K); + if (isOn('x')) { + tool(); + } + deep(); + local(); + z.string(); + } +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts new file mode 100644 index 00000000..0e1757b6 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/apps/app/types/zod.ts @@ -0,0 +1,4 @@ +// A third-party package, declared the way its own types would declare it. +declare module 'zod' { + export const z: { string(): unknown }; +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/package.json new file mode 100644 index 00000000..43c63464 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/package.json @@ -0,0 +1 @@ +{ "name": "ws-root", "private": true } diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json new file mode 100644 index 00000000..aa2d2aff --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/package.json @@ -0,0 +1,10 @@ +{ + "name": "@x/lib", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./sub": { "types": "./dist/sub/index.d.ts", "default": "./dist/sub/index.js" }, + "./feature/*": { "types": "./dist/feature/*.d.ts", "default": "./dist/feature/*.js" } + } +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts new file mode 100644 index 00000000..817e02d5 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/feature/flags.ts @@ -0,0 +1,3 @@ +export function isOn(name: string): boolean { + return name !== ''; +} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts new file mode 100644 index 00000000..87c7fa1a --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/index.ts @@ -0,0 +1,7 @@ +export function f(): void {} + +export class Bus { + publish(key: string): void {} +} + +export const K = 'k'; diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts new file mode 100644 index 00000000..41f7f92b --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/lib/src/sub/index.ts @@ -0,0 +1 @@ +export function g(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json new file mode 100644 index 00000000..92a5cc83 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/package.json @@ -0,0 +1 @@ +{ "name": "tools", "main": "dist/index.js" } diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts new file mode 100644 index 00000000..50bbcc3f --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/deep.ts @@ -0,0 +1 @@ +export function deep(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts new file mode 100644 index 00000000..d53b1d0a --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/packages/tools/src/index.ts @@ -0,0 +1 @@ +export function tool(): void {} diff --git a/graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml b/graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml new file mode 100644 index 00000000..4e708bd3 --- /dev/null +++ b/graph/test/typescript/cases/85-workspace-package-import/src/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'packages/*' + - 'apps/*' diff --git a/graph/test/typescript/expected/85-workspace-package-import.edges b/graph/test/typescript/expected/85-workspace-package-import.edges new file mode 100644 index 00000000..d8ac1ca7 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.edges @@ -0,0 +1,8 @@ +known_edge FUNCTION_CALL S#run() @L15 -> packages/lib/src/index#f() +known_edge FUNCTION_CALL S#run() @L16 -> packages/lib/src/sub/index#g() +known_edge FUNCTION_CALL S#run() @L18 -> packages/lib/src/feature/flags#isOn(string) +known_edge FUNCTION_CALL S#run() @L19 -> packages/tools/src/index#tool() +known_edge FUNCTION_CALL S#run() @L21 -> packages/tools/src/deep#deep() +known_edge FUNCTION_CALL S#run() @L22 -> apps/app/src/local#local() +known_edge METHOD_CALL S#run() @L17 -> Bus#publish(string) +known_edge METHOD_CALL S#run() @L23 -> apps/app/types/zod#string() diff --git a/graph/test/typescript/expected/85-workspace-package-import.entries b/graph/test/typescript/expected/85-workspace-package-import.entries new file mode 100644 index 00000000..79185187 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.entries @@ -0,0 +1,3 @@ +── entry_point (2) ── + unimported_module apps/app/src/main# main.ts:1 + unimported_module apps/app/types/zod# zod.ts:1 diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields b/graph/test/typescript/expected/85-workspace-package-import.fields new file mode 100644 index 00000000..565a7ee6 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.fields @@ -0,0 +1 @@ +known_edge read S#run() -> S#bus diff --git a/graph/test/typescript/expected/85-workspace-package-import.fields-oracle b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle new file mode 100644 index 00000000..44b1f6d7 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.fields-oracle @@ -0,0 +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 diff --git a/graph/test/typescript/expected/85-workspace-package-import.oracle b/graph/test/typescript/expected/85-workspace-package-import.oracle new file mode 100644 index 00000000..00f772ed --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.oracle @@ -0,0 +1 @@ +oracle=8 engine=8 agree=8 missing=0 (known 0, NEW 0) extra=0 diff --git a/graph/test/typescript/expected/85-workspace-package-import.type-use b/graph/test/typescript/expected/85-workspace-package-import.type-use new file mode 100644 index 00000000..28bd1b65 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.type-use @@ -0,0 +1 @@ +known_edge METHOD_PARAM 0 S [METHOD_PARAM] -> Bus diff --git a/graph/test/typescript/expected/85-workspace-package-import.types-oracle b/graph/test/typescript/expected/85-workspace-package-import.types-oracle new file mode 100644 index 00000000..ef6d2236 --- /dev/null +++ b/graph/test/typescript/expected/85-workspace-package-import.types-oracle @@ -0,0 +1,7 @@ +85-workspace-package-import [types] + 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 + contexts METHOD_PARAM=1 + not scored: 0 rows whose target is not a client declaration diff --git a/graph/test/typescript/ground-truth/tsc-program.mjs b/graph/test/typescript/ground-truth/tsc-program.mjs index 907c664c..020f6614 100644 --- a/graph/test/typescript/ground-truth/tsc-program.mjs +++ b/graph/test/typescript/ground-truth/tsc-program.mjs @@ -105,6 +105,32 @@ export function loadProgram(srcDir, libDir, toolName, programDir) { } } + // ── a WORKSPACE PACKAGE below the case root, imported by its name ─────────── + // In a real monorepo the compiler reaches a sibling package through a node_modules + // link and the declarations its build wrote. A case holds neither, so the program is + // told where each named package's source is by the plainest convention there is: the + // package is `/src/index.ts`, and `/x` is `/src/x`. Deliberately not + // the parser's rule (which reads main / types / exports): the two must agree on the + // answer without sharing the reasoning. A case with no nested package.json is unchanged. + const workspacePaths = {}; + (function walk(d) { + for (const e of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, e.name); + if (e.isDirectory() && e.name !== 'node_modules') { + walk(p); + } else if (e.name === 'package.json' && d !== root) { + const name = JSON.parse(fs.readFileSync(p, 'utf-8')).name; + if (typeof name === 'string' && name !== '') { + workspacePaths[name] = [path.join(d, 'src', 'index.ts')]; + workspacePaths[`${name}/*`] = [path.join(d, 'src', '*'), path.join(d, 'src', '*', 'index.ts')]; + } + } + } + })(root); + if (Object.keys(workspacePaths).length > 0) { + options.paths = { ...workspacePaths, ...(options.paths ?? {}) }; + } + const program = ts.createProgram(files, options); const checker = program.getTypeChecker(); // CALLERS come only from the client. TARGETS may be either, which is what makes the diff --git a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts index d2d32d53..bc36e385 100644 --- a/parser/src/enums/typescript/imports/TsImportResolutionKind.ts +++ b/parser/src/enums/typescript/imports/TsImportResolutionKind.ts @@ -41,6 +41,11 @@ export enum TsImportResolutionKind { NODE_MODULES_SOURCE = 'NODE_MODULES_SOURCE', /** Resolved through a package's `exports` map. */ PACKAGE_EXPORTS = 'PACKAGE_EXPORTS', + /** + * A package this repository declares, imported by its name: bound to the walked + * source its `main` / `types` / `exports` entry is built from. + */ + WORKSPACE_PACKAGE = 'WORKSPACE_PACKAGE', /** Matched a `declare module "x"` in this analysis. No file, and none needed. */ AMBIENT_MODULE = 'AMBIENT_MODULE', /** A Node builtin, with or without the `node:` prefix. */ diff --git a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts index 63933436..b2a41e37 100644 --- a/parser/src/parsers/javascript/extractors/js-fact-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-fact-extractor.ts @@ -111,6 +111,11 @@ export interface JsFileExtractionOptions { readonly projectModuleHashes: ReadonlyMap; /** Absolute path -> project-relative path, extension stripped. */ readonly toProjectRelative: (absolutePath: string) => string; + /** + * A bare specifier naming a package this repository declares -> the absolute path of + * the walked source module its entry is built from (`WorkspacePackages`). + */ + readonly resolveWorkspaceModule?: (specifier: string) => string | undefined; /** * How `sourceText` parses, when the file's extension cannot say: a `.vue` * component's virtual script is JS or JSX by its `lang`, not by its name. @@ -384,6 +389,7 @@ export function extractJavaScriptFile(options: JsFileExtractionOptions): JsFileF typeHashByNode: declarations.typeHashByNode, moduleInitMethodHash: declarations.moduleInitMethodHash, toProjectRelative: options.toProjectRelative, + resolveWorkspaceModule: options.resolveWorkspaceModule, projectModuleHashes: options.projectModuleHashes, declarationTargetByName, }); diff --git a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts index de97820e..50bd20c4 100644 --- a/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts +++ b/parser/src/parsers/javascript/extractors/js-module-edge-extractor.ts @@ -88,6 +88,8 @@ export interface ModuleEdgeExtractionOptions { readonly toProjectRelative: (absolutePath: string) => string; /** Absolute paths the analysis covers, for `RESOLVED_PROJECT`. */ readonly projectModuleHashes: ReadonlyMap; + /** A bare specifier naming a package this repository declares -> its walked source module. */ + readonly resolveWorkspaceModule?: (specifier: string) => string | undefined; /** Declarations by name, so an export can point at what it exports. */ /** * Every declaration under a name, in source order, with its offset. @@ -1214,21 +1216,33 @@ class JsModuleEdgeExtractor { // component in this program is looked up by path. const resolved = resolveIn(mode) ?? (mode === ts.ModuleKind.ESNext ? resolveIn(ts.ModuleKind.CommonJS) : undefined) ?? resolveVueSpecifier(specifier, this.options.absoluteFilePath, this.options.compilerOptions); - if (resolved === undefined) { - return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; - } // BOTH SIDES CANONICAL. `projectModuleHashes` is keyed by the files the analyzer // walked from a root it has already resolved through its symlinks; the resolver // answers with the real path for a package found under `node_modules` but does NOT // realpath a relative specifier, so the two sides are compared as real paths and the // spelling of the root cannot decide the outcome any more (#795). - const absolute = realPathOfResolved(path.normalize(resolved)); + const absolute = resolved === undefined ? '' : realPathOfResolved(path.normalize(resolved)); if (this.options.projectModuleHashes.has(absolute)) { return { filePath: this.options.toProjectRelative(absolute), outcome: JsImportResolutionOutcome.RESOLVED_PROJECT, }; } + // A package this repository declares, imported by its name, whose entry names build + // output that was not walked (or not built): the walked source it is built from is + // what the import means. Never for a package installed under `node_modules`. + if (!absolute.includes(`${path.sep}node_modules${path.sep}`)) { + const workspace = this.options.resolveWorkspaceModule?.(specifier); + if (workspace !== undefined) { + return { + filePath: this.options.toProjectRelative(workspace), + outcome: JsImportResolutionOutcome.RESOLVED_PROJECT, + }; + } + } + if (resolved === undefined) { + return { filePath: '', outcome: JsImportResolutionOutcome.UNRESOLVED_MISSING }; + } return { filePath: absolute.split(path.sep).join('/'), outcome: JsImportResolutionOutcome.RESOLVED_EXTERNAL, diff --git a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts index da67a6b4..9f312e3c 100644 --- a/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-fact-extractor.ts @@ -36,7 +36,7 @@ import { extractExports } from '@/parsers/typescript/extractors/ts-export-extrac import { TsExpressionExtractor } from '@/parsers/typescript/extractors/ts-expression-extractor'; import { TsExpressionWalker } from '@/parsers/typescript/extractors/ts-expression-walker'; -import { TsImportExtractor } from '@/parsers/typescript/extractors/ts-import-extractor'; +import { TsImportExtractor, WorkspaceModule } from '@/parsers/typescript/extractors/ts-import-extractor'; import { extractModules } from '@/parsers/typescript/extractors/ts-module-extractor'; import { EngineHandoff, @@ -92,6 +92,8 @@ export interface TsFileExtractionOptions { readonly projectModuleHashes: ReadonlyMap; /** Absolute path -> project-relative path, extension stripped. */ readonly toProjectRelative: (absolutePath: string) => string; + /** A bare specifier naming a package this repository declares -> its walked source module. */ + readonly resolveWorkspaceModule?: (specifier: string) => WorkspaceModule | undefined; /** * How `sourceText` parses, when the file's extension cannot say: a `.vue` * component's virtual script is TS or TSX by its `lang`, not by its name. @@ -181,15 +183,18 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF ); const fileName = resolved.resolvedModule?.resolvedFileName ?? resolveVueSpecifier(specifier, options.absoluteFilePath); - if (!fileName) { - return undefined; - } - const absolute = path.normalize(fileName); + const absolute = fileName ? path.normalize(fileName) : ''; const moduleHash = options.projectModuleHashes.get(absolute); - if (!moduleHash) { - return undefined; + if (moduleHash) { + return { moduleHash, relativePath: options.toProjectRelative(absolute) }; } - return { moduleHash, relativePath: options.toProjectRelative(absolute) }; + // `declare module '@scope/lib'` augmenting a package this repository declares. + const workspace = absolute.includes(`${path.sep}node_modules${path.sep}`) + ? undefined + : options.resolveWorkspaceModule?.(specifier); + return workspace === undefined + ? undefined + : { moduleHash: workspace.moduleHash, relativePath: options.toProjectRelative(workspace.absolutePath) }; }, ambientModuleHashes: modules.ambientModuleHashes, }); @@ -203,6 +208,7 @@ export function extractTypeScriptFile(options: TsFileExtractionOptions): TsFileF serviceVersionLinkHash: options.serviceVersionLinkHash, projectModuleHashes: options.projectModuleHashes, toProjectRelative: options.toProjectRelative, + resolveWorkspaceModule: options.resolveWorkspaceModule, }); const importResult = importExtractor.run(); diff --git a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts index 8c7e8952..5536a84f 100644 --- a/parser/src/parsers/typescript/extractors/ts-import-extractor.ts +++ b/parser/src/parsers/typescript/extractors/ts-import-extractor.ts @@ -39,6 +39,17 @@ export interface ImportExtractorOptions { /** Absolute resolved path -> `ts_module` hash, for project-internal targets. */ readonly projectModuleHashes: ReadonlyMap; readonly toProjectRelative: (absolutePath: string) => string; + /** + * The walked source module a bare specifier names when its package is one this + * repository declares (`TsWorkspacePackages`), or `undefined`. + */ + readonly resolveWorkspaceModule?: (specifier: string) => WorkspaceModule | undefined; +} + +export interface WorkspaceModule { + readonly absolutePath: string; + readonly moduleHash: string; + readonly packageName: string; } export interface ImportExtractionResult { @@ -455,6 +466,27 @@ export class TsImportExtractor { packageName: '', }; } + const absolute = module ? path.normalize(module.resolvedFileName) : ''; + const moduleHash = this.options.projectModuleHashes.get(absolute) ?? ''; + const isNodeModules = absolute.includes(`${path.sep}node_modules${path.sep}`); + // A package this repository declares, imported by its name. Its entry names + // build output, so tsc found nothing, or a `dist` file no program walks; the + // source that output is built from is walked, and is what the import means. + // Asked only when tsc landed on no walked module and not inside `node_modules`: + // a real installed package keeps its own resolution. + if (moduleHash === '' && !isNodeModules) { + const workspace = this.options.resolveWorkspaceModule?.(specifier); + if (workspace !== undefined) { + return { + absolutePath: workspace.absolutePath, + relativePath: this.options.toProjectRelative(workspace.absolutePath), + moduleHash: workspace.moduleHash, + kind: TsImportResolutionKind.WORKSPACE_PACKAGE, + extension: path.extname(workspace.absolutePath), + packageName: workspace.packageName, + }; + } + } if (!module) { // `undefined` is an honest answer, not a failure to try. It is also the // right answer for a wildcard ambient specifier like `"*.svg"`, which @@ -468,9 +500,6 @@ export class TsImportExtractor { // is filled here whether resolution succeeded or not. return { ...UNRESOLVED, packageName: packageNameOf(specifier) }; } - const absolute = path.normalize(module.resolvedFileName); - const moduleHash = this.options.projectModuleHashes.get(absolute) ?? ''; - const isNodeModules = absolute.includes(`${path.sep}node_modules${path.sep}`); return { absolutePath: absolute, relativePath: this.options.toProjectRelative(absolute), diff --git a/parser/src/parsers/typescript/ts-package-entry-extractor.ts b/parser/src/parsers/typescript/ts-package-entry-extractor.ts index bda21609..dfa851f0 100644 --- a/parser/src/parsers/typescript/ts-package-entry-extractor.ts +++ b/parser/src/parsers/typescript/ts-package-entry-extractor.ts @@ -70,18 +70,19 @@ function sourceStemsFor(targetPath: string): string[] { * * @returns `[moduleHash, exact]` — `exact` when the target itself is the walked file */ -function sourceModuleFor( +function sourceModuleFor( packageDir: string, targetPath: string, - moduleHashOf: (absolutePath: string) => string | undefined -): [string, boolean] | undefined { + moduleHashOf: (absolutePath: string) => T | undefined, + extensions: readonly string[] = SOURCE_EXTENSIONS +): [T, boolean] | undefined { const exact = moduleHashOf(path.normalize(path.resolve(packageDir, targetPath))); if (exact !== undefined) { return [exact, true]; } for (const stem of sourceStemsFor(targetPath)) { for (const candidate of [stem, `${stem}/index`]) { - for (const extension of SOURCE_EXTENSIONS) { + for (const extension of extensions) { const hash = moduleHashOf(path.normalize(path.resolve(packageDir, candidate + extension))); if (hash !== undefined) { return [hash, false]; @@ -138,6 +139,61 @@ function sourceModulesForPattern( return []; } +/** + * The walked source module a consumer's import of `subpath` (`"."`, `"./sub"`) binds + * to, by the same convention as the entry rows: the package's own entry for that + * subpath, mapped from build output back to source. + * + * `source` and `types` count for `"."` as they do for the rows. A subpath pattern + * substitutes its `*` into the target, as Node does. A package with no `exports` + * publishes every file, so a deep subpath (`"./sub"`, `"./dist/sub"`) is its own + * target. With `exports` present, a subpath it does not list is blocked for Node and + * binds to nothing here either. `extensions` are the source extensions looked for, in + * order: TypeScript's by default, JavaScript's for a JavaScript workspace. + */ +export function sourceModuleForSubpath( + facts: PackageJsonFacts, + subpath: string, + moduleHashOf: (absolutePath: string) => T | undefined, + extensions: readonly string[] = SOURCE_EXTENSIONS +): T | undefined { + const packageDir = path.dirname(facts.path); + const strip = (target: string): string => target.replace(/^\.\//, ''); + const entries = packageEntriesOf(facts); + const targets: string[] = []; + if (subpath === '.' && facts.source !== undefined && facts.source !== '') { + targets.push(strip(facts.source)); + } + for (const entry of entries) { + if (entry.subpath === subpath && entry.targetPath !== '' && !entry.targetPath.includes('*')) { + targets.push(entry.targetPath); + } + } + for (const entry of entries) { + const [before, after, ...more] = entry.subpath.split('*'); + if (after === undefined || more.length > 0 || entry.targetPath === '' + || !subpath.startsWith(before!) || !subpath.endsWith(after) + || subpath.length < before!.length + after.length) { + continue; + } + const match = subpath.slice(before!.length, subpath.length - after.length); + targets.push(entry.targetPath.split('*').join(match)); + } + if (subpath === '.' && facts.types !== undefined && facts.types !== '') { + targets.push(strip(facts.types)); + } + if (subpath !== '.' && facts.exports === undefined) { + targets.push(strip(subpath)); + } + for (const target of targets) { + const found = sourceModuleFor(packageDir, target, moduleHashOf, extensions); + if (found !== undefined) { + return found[0]; + } + } + return undefined; +} + const SOURCE_OF: Record = { [JsPackageEntrySource.MAIN]: TsPackageEntrySource.MAIN, [JsPackageEntrySource.MODULE]: TsPackageEntrySource.MODULE, diff --git a/parser/src/parsers/typescript/workspace-packages.ts b/parser/src/parsers/typescript/workspace-packages.ts new file mode 100644 index 00000000..449f2e4f --- /dev/null +++ b/parser/src/parsers/typescript/workspace-packages.ts @@ -0,0 +1,111 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { PackageJsonFacts, PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; +import { sourceModuleForSubpath } from '@/parsers/typescript/ts-package-entry-extractor'; + +/** + * The packages a repository declares itself, by name: every named `package.json` + * under the walked tree. Shared by the TypeScript and JavaScript front ends. + * + * ## Why import resolution needs this + * + * In a monorepo, app code imports a sibling package by its NAME (`'@scope/lib'`, + * `'@scope/lib/sub'`), and that package's `main` / `types` / `exports` name build + * output (`./dist/index.d.ts`) that is not in the source tree. `ts.resolveModuleName` + * then finds nothing (no `node_modules`), or a `dist` file no program walks (a + * `node_modules` symlink into the repository after a build). Either way the import + * bound to no module, and every function, class and const used across the package + * boundary was matched by name only. + * + * The package is in the repository, though, and its entry names the source it is + * built from by the same convention the entry rows already use + * (`sourceModuleForSubpath`). So a bare specifier whose package is one of these binds + * to that walked source module. + * + * ## What it does not claim + * + * A name two `package.json` files share is ambiguous and binds nothing. A specifier + * tsc resolved into `node_modules` is a real installed package and is never asked + * here, so a dependency that happens to share a name with a fixture stays external. + */ +export class WorkspacePackages { + private constructor(private readonly byName: ReadonlyMap) {} + + /** Every named package at or under `rootDir`, skipping `skipDirectories` and dot directories. */ + static discover(rootDir: string, skipDirectories: ReadonlySet): WorkspacePackages { + const reader = new PackageJsonResolver(); + const byName = new Map(); + const visit = (directory: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isFile() && entry.name === 'package.json') { + const facts = reader.packageAt(directory); + if (facts !== undefined && facts.name !== '') { + byName.set(facts.name, byName.has(facts.name) ? null : facts); + } + } else if (entry.isDirectory() && !entry.name.startsWith('.') + && !skipDirectories.has(entry.name)) { + visit(path.join(directory, entry.name)); + } + } + }; + visit(rootDir); + const unique = new Map(); + for (const [name, facts] of byName) { + if (facts !== null) { + unique.set(name, facts); + } + } + return new WorkspacePackages(unique); + } + + get size(): number { + return this.byName.size; + } + + /** + * The walked source module a bare specifier names, or `undefined` when its package + * is not one of these or its entry maps to no walked source. + * + * @param moduleHashOf the module hash of an absolute, normalised source path, or + * `undefined` when no program walks it + */ + resolve( + specifier: string, + moduleHashOf: (absolutePath: string) => string | undefined, + extensions?: readonly string[] + ): { readonly absolutePath: string; readonly moduleHash: string; readonly packageName: string } | undefined { + const name = bareSpecifierPackageName(specifier); + const facts = name === '' ? undefined : this.byName.get(name); + if (facts === undefined) { + return undefined; + } + const rest = specifier.slice(name.length); + const subpath = rest === '' ? '.' : `.${rest}`; + const found = sourceModuleForSubpath(facts, subpath, + (absolutePath) => { + const moduleHash = moduleHashOf(absolutePath); + return moduleHash === undefined ? undefined : { absolutePath, moduleHash }; + }, extensions); + return found === undefined ? undefined : { ...found, packageName: name }; + } +} + +/** `@scope/name` of `@scope/name/deep`, `name` of `name/deep`; `''` for anything not a bare package specifier. */ +function bareSpecifierPackageName(specifier: string): string { + if (specifier === '' || specifier.startsWith('.') || specifier.startsWith('/') + || specifier.includes('*') || specifier.includes(':')) { + return ''; + } + const segments = specifier.split('/'); + if (specifier.startsWith('@')) { + return segments.length >= 2 && segments[1] !== '' ? `${segments[0]}/${segments[1]}` : ''; + } + return segments[0] ?? ''; +} diff --git a/parser/src/workflows/javascript/javascript-project-analyzer.ts b/parser/src/workflows/javascript/javascript-project-analyzer.ts index 18e3e6ae..47a90e4a 100644 --- a/parser/src/workflows/javascript/javascript-project-analyzer.ts +++ b/parser/src/workflows/javascript/javascript-project-analyzer.ts @@ -23,6 +23,7 @@ import { import { moduleHashFor } from '@/parsers/javascript/extractors/js-module-extractor'; import { BUNDLER_CONFIG_NAMES, readBundlerAliases } from '@/parsers/javascript/bundler-alias-reader'; import { PackageJsonResolver } from '@/parsers/javascript/package-json-resolver'; +import { WorkspacePackages } from '@/parsers/typescript/workspace-packages'; import { buildOutputDirectoriesNamedBy, extractPackageEntries, @@ -58,6 +59,9 @@ import { scriptTextOf } from '@/utils/vue-sfc'; * its columns. `getCsvHeader` reads no instance state — the header is a * 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; + const HEADER_BY_FILE: Readonly> = { [JAVASCRIPT_CSV_FILES.MODULES]: JsModuleRegistry.prototype.getCsvHeader(), [JAVASCRIPT_CSV_FILES.SCOPES]: JsScopeRegistry.prototype.getCsvHeader(), @@ -348,6 +352,20 @@ export class JavaScriptProjectAnalyzer { } const toProjectRelative = (absolutePath: string): string => stripExtension(toRelative(pathAnchor, absolutePath)); + // A sibling package imported by its name binds to the walked source its entry is + // built from, by the same convention as TypeScript's (`WorkspacePackages`). + const workspacePackages = WorkspacePackages.discover(pathAnchor, excludes); + const workspaceResolutions = new Map(); + const resolveWorkspaceModule = (specifier: string): string | undefined => { + 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); + }; // Every package this parse touched: each walk root's own `package.json`, and // the governing config of every file. What each exposes is a fact of the @@ -462,6 +480,7 @@ export class JavaScriptProjectAnalyzer { compilerOptions: compilerOptionsFor(governing.moduleSystem, pathAliases.aliasesFor(file)), projectModuleHashes, toProjectRelative, + resolveWorkspaceModule, }); } catch (error) { // An extraction error is a DEFECT, never a decision. Counted apart diff --git a/parser/src/workflows/typescript/typescript-project-analyzer.ts b/parser/src/workflows/typescript/typescript-project-analyzer.ts index ea62a178..447c6fcd 100644 --- a/parser/src/workflows/typescript/typescript-project-analyzer.ts +++ b/parser/src/workflows/typescript/typescript-project-analyzer.ts @@ -27,6 +27,8 @@ import { import { moduleHashFor } from '@/parsers/typescript/extractors/ts-module-extractor'; 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 { TsRelationWriter } from './ts-relation-writer'; import { EntityUtils } from '@/utils/entity-utils'; @@ -265,6 +267,34 @@ export class TypeScriptProjectAnalyzer { } 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 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); + }; // Skips accumulate across the programs one analyzePrograms call drives; a // standalone analyze starts its own list. @@ -332,6 +362,7 @@ export class TypeScriptProjectAnalyzer { packageName: '', projectModuleHashes, toProjectRelative, + resolveWorkspaceModule, }); } catch (error) { // An extraction error is a DEFECT, never a decision. Counted apart from @@ -537,6 +568,18 @@ export class TypeScriptProjectAnalyzer { /** Distinguishes concurrent writes within one process; the pid does the rest. */ private writeSequence = 0; + /** The packages declared under each path anchor, found once for every program under it. */ + private readonly workspacePackages = new Map(); + + private workspacePackagesAt(anchor: string): WorkspacePackages { + let found = this.workspacePackages.get(anchor); + if (found === undefined) { + found = WorkspacePackages.discover(anchor, new Set(TS_SKIP_DIRECTORIES)); + this.workspacePackages.set(anchor, found); + } + return found; + } + private async exportSkippedFilesCsv(outputDir: string): Promise { const header = ['filePath', 'baseMservPath', 'serviceVersionLinkHash', 'reason', 'detail'] .join('\t'); diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 600cf0fe..282818b6 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -251,6 +251,12 @@ contract(q, b, "it overrides this") :- target(q, "method", m, _), override(b, m) .decl implements_pair(b:symbol, m:symbol) .input implements_pair contract(q, b, "it implements this — the engine records a dispatch candidate here and no override row") :- target(q, "method", m, _), implements_pair(b, m), !value_pair(b, m), !override(b, m), !override(m, b), !target(q, _, b, _). +// …and the other direction, asked of the BASE. Java's override row puts an implementation under "overrides it"; +// a structurally typed language has no such row, so an interface method's own implementations were missing from +// its answer entirely: nothing calls them through it (the dispatch edge runs candidate -> base) and no contract +// named them, though a change to the interface method's signature breaks every one of them. +contract(q, m, "implements it — the engine records a dispatch candidate here and no override row") :- + target(q, "method", b, _), implements_pair(b, m), !value_pair(b, m), !override(b, m), !override(m, b), !target(q, _, m, _). // A FUNCTION STORED IN A FIELD (#1206). `value_pair(b, m)`: m flows into a field whose declared type is the // function type b — `this.getPath = options.getPath ?? getPath`, `fetch: T = (req) => …`. A call through the field // is typed to b, so its caller is a DIRECT user of m: without this row the answer said "directly touches it: diff --git a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py index 549f6ebd..f8b33678 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/graph_sql.py @@ -171,6 +171,15 @@ def _at(f_, l_): ({r[0] for r in q( f"""SELECT DISTINCT s.display FROM dispatch_candidates dc JOIN symbols s ON s.method_id = dc.base_method_id WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id AND s.id NOT IN ({ph}) + AND dc.basis <> 'structural' + AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) + OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", + ids + ids)} if 'dispatch_candidates' in _tables(con) else set()) | + # …and, asked of the base, the implementations it dispatches to ("implements it") + ({r[0] for r in q( + f"""SELECT DISTINCT s.display FROM dispatch_candidates dc JOIN symbols s ON s.method_id = dc.candidate_method_id + WHERE dc.base_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id AND s.id NOT IN ({ph}) + AND dc.basis NOT IN ('structural', 'value') AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", ids + ids)} if 'dispatch_candidates' in _tables(con) else set())) @@ -278,6 +287,7 @@ def _at(f_, l_): if dispatch: contract_ids |= {r[0] for r in q(f"""SELECT DISTINCT dc.base_method_id FROM dispatch_candidates dc WHERE dc.candidate_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis <> 'structural' AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", ids)} seen_ids -= contract_ids @@ -1755,6 +1765,14 @@ def contract_for_method(q, ids): AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): if b not in ids: out.append((b, 'it implements this — the engine records a dispatch candidate here and no override row')) + # …and asked of the BASE, the implementations it dispatches to: an interface method's own implementers had + # no row at all where the engine keeps no override table. `value` pairs are excluded as the rules exclude them. + for (m,) in q(f"""SELECT DISTINCT dc.candidate_method_id FROM dispatch_candidates dc + WHERE dc.base_method_id IN ({ph}) AND dc.base_method_id <> dc.candidate_method_id + AND dc.basis NOT IN ('structural', 'value') + AND NOT EXISTS (SELECT 1 FROM overrides o WHERE (o.method_id = dc.base_method_id AND o.overriding_method_id = dc.candidate_method_id) + OR (o.overriding_method_id = dc.base_method_id AND o.method_id = dc.candidate_method_id))""", *ids): + if m not in ids: out.append((m, 'implements it — the engine records a dispatch candidate here and no override row')) return sorted(set(out)) # a set, for the same reason diff --git a/tests/cases/typescript/dispatch-base-is-a-contract/case.json b/tests/cases/typescript/dispatch-base-is-a-contract/case.json index 0afd462a..4133344c 100644 --- a/tests/cases/typescript/dispatch-base-is-a-contract/case.json +++ b/tests/cases/typescript/dispatch-base-is-a-contract/case.json @@ -15,5 +15,9 @@ {"why": "a class that only matches the interface's SHAPE is a dispatch candidate, not a declared contract: its callers are still reached through the base, but the base is never 'must change - it implements this'", "run": ["impact", "DuckRouter.add"], "want": ["App.mount"], - "avoid": ["it implements this", "must change with it"]} + "avoid": ["it implements this", "must change with it"]}, + {"why": "asked of the BASE, the same contract names its implementations: with no override row an interface method's implementers had no row at all, though a change to its signature breaks each of them. The shape-only DuckRouter stays out, as above", + "run": ["impact", "Router.add"], + "want": ["must change with it (2: bound by a contract", "LinearRouter.add", "TrieRouter.add", "implements it"], + "avoid": ["must change with it (3"]} ]} From dada661b737d56b372369db7bea5645e45f3269d Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:56:31 -0700 Subject: [PATCH 2/6] impact, path: a handler table keyed by an event type joins the publishers of that type [by key] A service publishes an event by its type string, often through a constant (publish(TOPICS.CREATED, doc)); another holds a handler table keyed by the same string ({ [TOPICS.CREATED]: onCreated }, { 'doc.created'(e) {} }, {"doc.created": on_created}) that a consumer dispatches by the message's type. Nothing joined the two ends. - ax_registration: table entries are registrations (kind table); string constants resolve to their value; a key written as a dotted literal or through a constant is a write, never the table's own key position or the constant's declaration. - impact.dl: impact of a publisher lists each handler of its type [by key]; tests that publish the type reach the handler. Only production writers count against the key cap, and a handler writing its own type is no writer. - path and the SQL port's key join read the same writes. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- plugins/axiomcode/skills/axiomcode/SKILL.md | 2 +- .../axiomcode/scripts/ax_registration.py | 174 +++++++++++++++++- .../skills/axiomcode/scripts/axiomcode-impact | 21 ++- .../skills/axiomcode/scripts/axiomcode-path | 3 +- .../skills/axiomcode/scripts/dl/impact.dl | 22 ++- skills/axiomcode/SKILL.md | 2 +- .../event-type-handler-table/case.json | 24 +++ .../src/audit/audit.js | 11 ++ .../src/pkg/topics.js | 4 + .../src/producer/service.js | 14 ++ .../src/search/consumer.js | 4 + .../src/search/handlers.js | 7 + .../src/search/indexer.js | 2 + .../test/drop.test.js | 7 + .../test/service.test.js | 7 + .../event-type-handler-table/app/__init__.py | 0 .../event-type-handler-table/app/handlers.py | 21 +++ .../event-type-handler-table/app/service.py | 6 + .../event-type-handler-table/app/topics.py | 3 + .../python/event-type-handler-table/case.json | 11 ++ .../tests/__init__.py | 0 .../tests/test_other.py | 4 + .../tests/test_service.py | 10 + 23 files changed, 343 insertions(+), 16 deletions(-) create mode 100644 tests/cases/javascript/event-type-handler-table/case.json create mode 100644 tests/cases/javascript/event-type-handler-table/src/audit/audit.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/pkg/topics.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/producer/service.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/search/consumer.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/search/handlers.js create mode 100644 tests/cases/javascript/event-type-handler-table/src/search/indexer.js create mode 100644 tests/cases/javascript/event-type-handler-table/test/drop.test.js create mode 100644 tests/cases/javascript/event-type-handler-table/test/service.test.js create mode 100644 tests/cases/python/event-type-handler-table/app/__init__.py create mode 100644 tests/cases/python/event-type-handler-table/app/handlers.py create mode 100644 tests/cases/python/event-type-handler-table/app/service.py create mode 100644 tests/cases/python/event-type-handler-table/app/topics.py create mode 100644 tests/cases/python/event-type-handler-table/case.json create mode 100644 tests/cases/python/event-type-handler-table/tests/__init__.py create mode 100644 tests/cases/python/event-type-handler-table/tests/test_other.py create mode 100644 tests/cases/python/event-type-handler-table/tests/test_service.py diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 6a7b7a22..29f06c81 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -71,7 +71,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — not an edge | | `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py index c0d82607..2f892c57 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_registration.py @@ -16,6 +16,7 @@ here: the reference alone says it is passed as a value (`valueref` in dl/impact.dl), and naming the receiving call as one that "calls it where the graph cannot follow" was wrong for every synchronous collection operation (#1166). """ +import collections import re ROUTE_VERB = {'get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace', 'connect', 'all', 'use', 'route'} @@ -990,8 +991,150 @@ def route_candidates(written, registered_keys): def all_registrations(q, site_file=None): - """Every (decl, file, line, kind, key, why) this module can derive, from all three sources.""" - return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file))) + """Every (decl, file, line, kind, key, why) this module can derive, from all four sources.""" + return sorted(set(registrations(q, site_file) + decoration_keys(q, site_file) + value_route_registrations(q, site_file) + + table_registrations(q, site_file))) + + +# ── a HANDLER TABLE: a declaration registered under the KEY of the entry that holds it ────────────────────────── +# One service publishes an event by its type (`bus.publish(TOPICS.CREATED, doc)`, `emit("doc.created", …)`); another +# holds a table of handlers keyed by the same string (`{ [TOPICS.CREATED]: onCreated }`, `{ 'doc.created'(env) {…} }`, +# `{"doc.created": on_created}`) and a consumer looks the handler up by the message's type (`handlers[type](env)`). +# The graph has both ends and the lookup is a computed member, so nothing joined the publisher to the handler: impact +# of the producing method missed every consumer, and the tests that publish the type never reached the handler. +# It is a registration like a route: the entry's key is what the dispatcher dispatches on. Two facts are read here: +# the table entry a callable DECLARED on the entry's own line, right after its key (`[K]: function …`, `[K]: (e) =>`, +# `'k'(e) {`, `"k": lambda e: …`), or a callable NAMED as the entry's whole value (`[K]: onCreated,`). +# A key is a string literal or a constant reference resolved to one; an entry whose value is an +# array, a call's result or a schema is data, not a handler, and registers nothing. +# the constant `TOPICS.CREATED` is the string its declaration gives it (`export const TOPICS = { CREATED: +# 'doc.created' }`, `class Topics: CREATED = "doc.created"`, `static final String CREATED = …`), +# kept only when every declaration of that name agrees. A key written through a constant is a +# write of the string; the constant's own declaration and another table's key position are not. +_IDENT = r'[A-Za-z_$][\w$]*' +_CONST_REF = rf'{_IDENT}(?:\.{_IDENT})+' +_KEY_STR = r"""(?P['"])([^'"\\\s]{1,120})(?P=qt)""" # named: its group number differs in each pattern +# the start of a function value: `function`, `async (e) =>`, `e =>`, `(e) =>`, a Python `lambda` +_FN_START = rf'(?:async\s+)?(?:function\b|lambda\b|\(|{_IDENT}\s*=>)' +# the key of an entry that DECLARES its handler on this line: `[K]: `, `[K](…) {`, `'k': `, `'k'(…) {`, and a +# Python dict's `Topics.K: lambda …`. Method shorthand may carry `async` / `static` / `*`. +_ENTRY_DECL = re.compile(rf"""^\s*(?:(?:async|static|get|set)\s+|\*\s*)*(?:(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR})\s*(?::\s*{_FN_START}|\()|({_CONST_REF})\s*:\s*{_FN_START})""") +# an entry whose WHOLE value names a handler: `[K]: onCreated,` / `'k': handlers.onCreated,` / `"k": on_created,` +_ENTRY_REF = re.compile(rf"""^\s*(?:\[\s*({_CONST_REF}|{_IDENT})\s*\]|{_KEY_STR}|({_CONST_REF}))\s*:\s*(?:this\.|self\.)?({_IDENT}(?:\.{_IDENT})*)\s*,?\s*(?:\}}\s*[,;)]*\s*)?$""") +# a string constant: an object literal's `K: 'v'` (one per line or several on one), and a declaration `K = 'v'` +_CONST_ENTRY = re.compile(rf"""(?:^|[{{,])\s*({_IDENT})\s*:\s*{_KEY_STR}\s*(?=,|\}}|$)""") +_CONST_DECL = re.compile(rf"""(?:^|\s)({_IDENT})\s*(?::\s*[\w.<>\[\]]+\s*)?=\s*{_KEY_STR}\s*[;,]?\s*$""") +# a key POSITION, not a write: the quoted key or the constant is followed by `:` (an entry, a `case`), `(` (a method +# shorthand) or `]` and then `:` / `(` / `=` (a computed key, a C# index initializer) +_KEY_POS = re.compile(r'\s*(?::(?!:)|\(|\]\s*[:(=])') + + +def string_constants(q, read=None): + """({'TOPICS.CREATED': 'doc.created', 'CREATED_TYPE': 'doc.created', …}, {(file, line)}): the string each constant + name denotes, where every declaration of the name agrees, and the lines that declare them (a literal there is the + constant's definition, not a write of its value).""" + if not _has(q, 'symbols'): + return {}, set() + read = read or _source_reader(q) + seen = collections.defaultdict(set) + pos = set() + types = {i: n for i, n in q("SELECT id, name FROM symbols WHERE id IS NOT NULL AND method_id IS NULL AND type_id IS NOT NULL")} + for n, f, a, b, owner, kind in q("""SELECT name, file, line, end_line, owner, kind FROM symbols + WHERE method_id IS NULL AND name IS NOT NULL AND file IS NOT NULL AND line > 0 + AND kind NOT IN ('class', 'interface', 'enum', 'record', 'struct', 'module', 'type', 'namespace')"""): + L = read(f) + if not L or not re.fullmatch(_IDENT, n): continue + b = max(a, min(b or a, a + 400, len(L))) + oname = (types.get(owner) or (owner or '').split('.')[-1]) if owner else '' + m = _CONST_DECL.search(L[a - 1]) if a <= len(L) else None + if m and m.group(1) == n and b == a: + seen[f'{oname}.{n}' if oname else n].add(m.group(3)); pos.add((f, a)) + continue + for ln in range(a, b + 1): + for k, _qt, v in _CONST_ENTRY.findall(L[ln - 1]): + seen[f'{n}.{k}'].add(v); pos.add((f, ln)) + return {k: next(iter(vs)) for k, vs in seen.items() if len(vs) == 1}, pos + + +def _entry_key(m, consts): + """the string a matched entry is keyed by: its literal, or the constant it names resolved; None when unknown""" + ref, lit, cref = m.group(1), m.group(3), m.group(4) + if lit: return lit + return consts.get(ref or cref) + + +def table_registrations(q, site_file=None, consts=None): + """[(decl, file, line, 'table', key, why)] — a callable registered in a handler table under the entry's key. + An entry in a test file is a fixture's table, and is not what the application dispatches on.""" + if not _has(q, 'symbols'): + return [] + sf = site_file or (lambda x: x) + read = _source_reader(q) + consts = string_constants(q, read)[0] if consts is None else consts + why = lambda key: f'registered in a handler table under "{key}" here — whoever dispatches the table by that key calls it, no call site does' + out = set() + by_line = collections.defaultdict(list) + for i, f, l in q("""SELECT id, file, line FROM symbols WHERE method_id IS NOT NULL AND file IS NOT NULL AND line > 0 + AND (is_test IS NULL OR is_test = 0) AND kind NOT IN ('module', 'constructor')"""): + by_line[(f, l)].append(i) + for (f, l), ids in by_line.items(): + L = read(f) + if not L or l > len(L) or len(ids) != 1: continue + m = _ENTRY_DECL.match(L[l - 1]) + key = _entry_key(m, consts) if m else None + if key: out.add((ids[0], sf(f), l, 'table', key, why(key))) + # an entry whose value NAMES the handler: the declaration a name identifies uniquely, as `registrations()` requires + if _has(q, 'refs'): + once = {} + for n, i, c in q("""SELECT name, min(id), count(*) FROM symbols WHERE method_id IS NOT NULL AND name IS NOT NULL + AND name NOT LIKE '<%' GROUP BY name"""): + if c == 1: once[n] = i + tf = {x for (x,) in q("SELECT DISTINCT file FROM symbols WHERE is_test = 1 AND file IS NOT NULL")} + for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"): + if n not in once or f in tf: continue + L = read(f) + if not L or l > len(L): continue + m = _ENTRY_REF.match(L[l - 1]) + if not m or m.group(5).split('.')[-1] != n: continue + key = _entry_key(m, consts) + if key: out.add((once[n], sf(f), l, 'table', key, why(key))) + return sorted(out) + + +def table_key_writes(q, keys, consts=None, cpos=None): + """[(value, file, line)] — where a handler-table key in `keys` is WRITTEN: a literal of it, or a constant that + resolves to it, outside a key position and outside the constant's own declaration. What a table's key is joined to.""" + if not keys: + return [] + read = _source_reader(q) + if consts is None or cpos is None: + consts, cpos = string_constants(q, read) + out = set() + def written(text, token): + for mm in re.finditer(re.escape(token), text): + # a constant is not the tail of a longer name (`MY_TOPICS.X`) or the head of a longer chain (`TOPICS.X.y`) + if token[0] not in '\'"`' and (re.match(r'[\w$]', text[mm.start() - 1:mm.start()] or ' ') + or re.match(r'[\w$.]', text[mm.end():mm.end() + 1] or ' ')): continue + 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"): + 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 + if text is None or written(text, f"'{v}'") or written(text, f'"{v}"') or written(text, f'`{v}`'): + out.add((v, f, l)) + names = collections.defaultdict(set) # last segment -> the constants it may end + for c, v in consts.items(): + if v in keys: names[c.split('.')[-1]].add(c) + if names and _has(q, 'refs'): + for n, f, l in q("SELECT DISTINCT name, file, line FROM refs WHERE line > 0"): + if n not in names or (f, l) in cpos: continue + L = read(f) + if not L or l > len(L): continue + for c in names[n]: + if written(L[l - 1], c): out.add((consts[c], f, l)) + return sorted(out) # ── the same join the rules make, for a caller that has no Datalog ─────────────────────────────────────────── @@ -1012,16 +1155,22 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): if not _has(q, 'literals'): return [] reg = collections.defaultdict(set) - for decl, _f, _l, _kind, key, _why in all_registrations(q, site_file): - if decl and key: reg[key].add(decl) + table = collections.defaultdict(set) # a handler table's key -> the declarations it registers + for decl, _f, _l, kind, key, _why in all_registrations(q, site_file): + if decl and key: + reg[key].add(decl) + if kind == 'table': table[key].add(decl) if not reg: return [] writes = collections.defaultdict(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 key_writes(q, set(table)): if not isinstance(v, str) or len(v) > 160: continue c = at(f, l) - if c: writes[v].add(c) - capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() if len(cs) > use_cap} + if c and c not in table.get(v, ()): writes[v].add(c) + # the rules' table_key: a table key's writers in test files drive its handler and are not counted against it + tests = {i for (i,) in q("SELECT id FROM symbols WHERE is_test = 1 AND method_id IS NOT NULL")} if table else set() + capped = {k for k, ds in reg.items() if len(ds) > cap} | {k for k, cs in writes.items() + if len(cs - tests if k in table else cs) > use_cap} out = set() for v, callers in writes.items(): for key in route_candidates(v, reg): @@ -1030,3 +1179,14 @@ def key_edges(q, at, site_file=None, cap=None, use_cap=None): for d in reg[key]: if c != d: out.add((c, d, key)) return sorted(out) + + +def key_writes(q, table_keys=None): + """[(value, file, line)] — every string a callable writes that a registration key may be joined to: the literals, + except that a handler table's key is written where table_key_writes says (a dotted literal or a constant, never + 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") + if v not in table_keys] if _has(q, 'literals') else [] + return rows + table_key_writes(q, table_keys) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index a2b15125..8d2b5ef4 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact @@ -153,8 +153,10 @@ def ckey(k): # framework's own registration, so it outranks every name match, and it is not a call to the declaration. # `stubs it` (a call inside a mock's stub or verification) is the engine's resolution, so it outranks every name match, # and runs nothing, so it sits below every hop that does: a rename breaks it, a body change never does. +# `by key` (a handler table's entry under the type a publisher writes, dl/impact.dl) is joined on a string both ends +# spell and nothing the engine resolved: the weakest hop after a name match, as it is in the closure. CERT = {'resolved': 0, 'one of a set': 1, 'registered': 2, 'capped set': 3, 'remote': 4, 'framework': 5, - 'stubs it': 6, 'in scope': 7, 'by name': 8, 'text': 9, 'alongside': 10} + 'stubs it': 6, 'in scope': 7, 'by name': 8, 'by key': 8.5, 'text': 9, 'alongside': 10} def also_text(whys, shown=2): """the other reasons a row's callable has, said on the same line: `; also: ` (one row per dependent)""" if not whys: return '' @@ -1099,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 = '55' # 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 = '56' # 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 @@ -1385,6 +1387,16 @@ class Impact: if ROUTEISH.fullmatch(r['value']): c = self.at(r['file'], r['line']) if c: lits.append((c, r['value'], r['file'], r['line'])) + # A HANDLER TABLE'S KEY is written as whatever string the dispatcher reads: usually dotted (`doc.created`), and + # usually through a constant (`TOPICS.CREATED`), so neither shape above carries it. Its writes come from + # ax_registration, which also leaves out the table's own key positions and the constant's declaration. + tregs = ax_registration.table_registrations(g.q, g.site_file) + tkeys = {r[4] for r in tregs} + if tkeys: + lits = [x for x in lits if x[1] not in tkeys] + for v, f, l in ax_registration.table_key_writes(g.q, tkeys): + c = self.at(f, l) + if c: lits.append((c, v, f, l)) W('literal', sorted(set(lits))) # a string written inside a decoration (@Listener(topics = "topicOne"), @RequestMapping("/a/{b}")) is in no # other table: literals does not carry it, and it is how a topic, a queue, a route or a bean qualifier binds @@ -1408,9 +1420,12 @@ class Impact: regk = [] # `rd` and not `decl`: `decl` is the dec_literal list above for rd, _f, _l, kind, key, _why in (ax_registration.decoration_keys(g.q, g.site_file) + ax_registration.value_route_registrations(g.q, g.site_file) - + ax_registration.registrations(g.q, g.site_file)): + + ax_registration.registrations(g.q, g.site_file) + tregs): if rd in g.sym and key: regk.append((rd, kind, key)) W('reg_key_fact', sorted(set(regk))) + # 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'))) # 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/axiomcode-path b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path index 98ac6f2a..b44dbe53 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-path @@ -1306,7 +1306,8 @@ def framework_note(g, ids, writers=()): if ff == f and a <= l <= b and (best is None or (b - a) < best[0]): best = (b - a, i) return best[1] if best else None hit = [] - for v, f, l in g.q("SELECT value, file, line FROM literals WHERE line > 0 AND value IS NOT NULL"): + # the literals, and a handler table's key where it is written through a constant (`publish(TOPICS.CREATED)`) + for v, f, l in ax_registration.key_writes(g.q, {r[4] for r in every if r[3] == 'table'}): if not isinstance(v, str): continue for key in ax_registration.route_candidates(v, registered): if key not in keys: continue diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 57d46319..1c15fb72 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -903,8 +903,18 @@ reg_key_fan(k, n) :- reg_key(_, _, k), n = count : { reg_key(_, _, k) }. // decoration argument is not always a registration (`@ValueSource(strings = {"p"})` is test DATA). A key written // everywhere identifies nothing either, whichever side the fan is on, and this needs no catalogue of which // decorations register and which do not. +// A HANDLER TABLE'S KEY (kind "table": `{ [TOPICS.CREATED]: onCreated }`, ax_registration.table_registrations) is a +// message type. Its writers are the services that publish it AND the tests that publish it to drive the consumer, and a +// suite that exercises one event type from ten tests has not made the type identify nothing: only the PRODUCTION +// writers count against the cap. And a handler registered under the key that writes it again (to log it, to pass it +// on) consumes that type; it is not a producer of it. +// test_code(c) c is declared in a test file +.decl test_code(c:symbol) .input test_code +.decl table_key(k:symbol) +table_key(k) :- reg_key(_, "table", k). .decl key_written(c:symbol, k:symbol) -key_written(c, k) :- literal(c, k, _, _). +key_written(c, k) :- literal(c, k, _, _), !table_key(k). +key_written(c, k) :- literal(c, k, _, _), table_key(k), !test_code(c), !reg_key(c, "table", k). .decl key_use_fan(k:symbol, n:number) key_use_fan(k, n) :- key_written(_, k), n = count : { key_written(_, k) }. .decl key_capped(k:symbol) @@ -921,7 +931,7 @@ key_capped(k) :- key_use_fan(k, n), key_use_cap(c), n > c. .decl reg_verb(b:symbol, k:symbol, v:symbol) .input reg_verb .decl lit_verb(a:symbol, k:symbol, v:symbol) .input lit_verb .decl key_join(a:symbol, b:symbol, k:symbol, w:symbol) -key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b. +key_join(a, b, k, k) :- literal(a, k, _, _), reg_key(b, _, k), a != b, !reg_key(a, "table", k). // the same, where the two spellings of the path differ: /orders/o-1/price written, /orders/{order_id}/price registered key_join(a, b, r, w) :- literal(a, w, _, _), route_alias(w, r), reg_key(b, _, r), a != b. .decl verb_fits(a:symbol, b:symbol, k:symbol, w:symbol) @@ -933,11 +943,17 @@ reg_capped(k) :- reg_key_fan(k, n), key_cap(c), n > c. // the writers of the key as registered (an alias spelling is not counted, as before). A helper relation, not // `count : { verb_fits(_, b, k, k) }`: Soufflé 2.5 counts an aggregate atom with a repeated variable as 1. .decl exact_fit(a:symbol, b:symbol, k:symbol) -exact_fit(a, b, k) :- verb_fits(a, b, k, k). +exact_fit(a, b, k) :- verb_fits(a, b, k, k), !table_key(k). +exact_fit(a, b, k) :- verb_fits(a, b, k, k), table_key(k), !test_code(a). .decl use_fan_for(b:symbol, k:symbol, n:number) use_fan_for(b, k, n) :- reg_key(b, _, k), n = count : { exact_fit(_, b, k) }. .decl fw_edge(a:symbol, b:symbol, how:symbol) fw_edge(a, b, "by key") :- verb_fits(a, b, k, _), !reg_capped(k), use_fan_for(b, k, n), key_use_cap(c), n <= c. +// and the other direction for a HANDLER TABLE, as for `remote`: what a publisher writes is what the handler registered +// under its type receives, so a change to the publisher's payload is a change to the handler's input. A direct row +// only, never a seed: whoever else reaches the handler does not thereby depend on this publisher. +direct(q, c, "uses", cat("handles what this publishes: registered in a handler table under \"", cat(k, "\" — whatever dispatches the table by that key calls it, no call does")), "by key", "", 0) + :- target(q, "method", m, _), fw_edge(m, c, "by key"), verb_fits(m, c, k, _), table_key(k), c != m. // a decorator that REBINDS THE NAME: `@audited def summarise` leaves `summarise` denoting the wrapper, so every // caller written with that name runs the wrapper. This one is the engine's own resolution, not a name match. fw_edge(a, b, "decorator") :- calls(a, m, _, _, _), decorated_name(m, b), a != b. diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 9ded5b67..9456d8e1 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -71,7 +71,7 @@ An answer's label is the **worst** rung on its route. Read it before acting on t | `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | | `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | | `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command) — not an edge | +| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — not an edge | | `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | | `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | | `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | diff --git a/tests/cases/javascript/event-type-handler-table/case.json b/tests/cases/javascript/event-type-handler-table/case.json new file mode 100644 index 00000000..16591715 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/case.json @@ -0,0 +1,24 @@ +{"lang": "javascript", "src": ".", + "checks": [ + {"why": "a publisher writes an event type through a constant (TOPICS.CREATED) and another service's handler table is keyed by the same constant, or by the same string as a method shorthand: impact of the publisher lists each handler registered under that type [by key], and not the handler registered under another type or an arrow inside a table of data", + "run": ["impact", "DocService.create", "--grep"], + "want": ["src/search/handlers.js:5", "src/audit/audit.js:5", "[by key]"], + "avoid": ["src/search/handlers.js:6", "src/audit/audit.js:10"]}, + {"why": "the tests that publish the type (through the service) reach the handler registered under it; a test that publishes another type does not", + "run": ["impact", "src/search/handlers.js:5", "--tests-only"], + "want": ["test/service.test.js", "by key"], + "avoid": ["test/drop.test.js"]}, + {"why": "the other handler's tests are the ones that publish ITS type, written through the same constant table", + "run": ["impact", "src/search/handlers.js:6", "--tests-only"], + "want": ["test/drop.test.js"], + "avoid": ["test/service.test.js"]}, + {"why": "a type written as a dotted string literal joins a handler NAMED as a table entry's value; path says a framework connects them instead of calling the two independent", + "run": ["path", "DocService.archive", "onArchived"], + "want": ["registered in a handler table under \"doc.archived\"", "NOT independent"], + "expect_error": true}, + {"why": "control: a publisher of another type stays independent of that handler", + "run": ["path", "DocService.create", "onArchived"], + "want": ["independent in this graph"], + "avoid": ["NOT independent"], + "expect_error": true} + ]} diff --git a/tests/cases/javascript/event-type-handler-table/src/audit/audit.js b/tests/cases/javascript/event-type-handler-table/src/audit/audit.js new file mode 100644 index 00000000..acc9d5a9 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/audit/audit.js @@ -0,0 +1,11 @@ +function record(env) { return [env.type, env.payload]; } +function onArchived(env) { return record(env); } + +export const auditHandlers = { + 'doc.created'(env) { return record(env); }, + 'doc.archived': onArchived, +}; + +export const LABELS = { + 'doc.created': ['created', (env) => record(env)], +}; diff --git a/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js b/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js new file mode 100644 index 00000000..41f3cdab --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/pkg/topics.js @@ -0,0 +1,4 @@ +export const TOPICS = Object.freeze({ + CREATED: 'doc.created', + DELETED: 'doc.deleted', +}); diff --git a/tests/cases/javascript/event-type-handler-table/src/producer/service.js b/tests/cases/javascript/event-type-handler-table/src/producer/service.js new file mode 100644 index 00000000..a7f7cd40 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/producer/service.js @@ -0,0 +1,14 @@ +import { TOPICS } from '../pkg/topics.js'; + +export class DocService { + constructor(bus) { this.bus = bus; } + + create(doc) { + this.bus.publish(TOPICS.CREATED, doc); + return doc; + } + + archive(doc) { + this.bus.publish('doc.archived', doc); + } +} diff --git a/tests/cases/javascript/event-type-handler-table/src/search/consumer.js b/tests/cases/javascript/event-type-handler-table/src/search/consumer.js new file mode 100644 index 00000000..3d999bd5 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/consumer.js @@ -0,0 +1,4 @@ +export async function consume(handlers, msg) { + const handler = handlers[msg.headers.type]; + if (handler) await handler(msg); +} diff --git a/tests/cases/javascript/event-type-handler-table/src/search/handlers.js b/tests/cases/javascript/event-type-handler-table/src/search/handlers.js new file mode 100644 index 00000000..3bb08d29 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/handlers.js @@ -0,0 +1,7 @@ +import { TOPICS } from '../pkg/topics.js'; +import { index, drop } from './indexer.js'; + +export const handlers = { + [TOPICS.CREATED]: async (env) => index(env), + [TOPICS.DELETED]: async (env) => drop(env), +}; diff --git a/tests/cases/javascript/event-type-handler-table/src/search/indexer.js b/tests/cases/javascript/event-type-handler-table/src/search/indexer.js new file mode 100644 index 00000000..d33b5d43 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/src/search/indexer.js @@ -0,0 +1,2 @@ +export function index(env) { return env.payload; } +export function drop(env) { return env.payload.id; } diff --git a/tests/cases/javascript/event-type-handler-table/test/drop.test.js b/tests/cases/javascript/event-type-handler-table/test/drop.test.js new file mode 100644 index 00000000..b728e234 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/test/drop.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { TOPICS } from '../src/pkg/topics.js'; + +test('a delete is published by its type', () => { + const bus = { publish() {} }; + bus.publish(TOPICS.DELETED, { id: 'd1' }); +}); diff --git a/tests/cases/javascript/event-type-handler-table/test/service.test.js b/tests/cases/javascript/event-type-handler-table/test/service.test.js new file mode 100644 index 00000000..67b37d53 --- /dev/null +++ b/tests/cases/javascript/event-type-handler-table/test/service.test.js @@ -0,0 +1,7 @@ +import { test } from 'node:test'; +import { DocService } from '../src/producer/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/python/event-type-handler-table/app/__init__.py b/tests/cases/python/event-type-handler-table/app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/event-type-handler-table/app/handlers.py b/tests/cases/python/event-type-handler-table/app/handlers.py new file mode 100644 index 00000000..5d93dba9 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/handlers.py @@ -0,0 +1,21 @@ +from app.topics import Topics + + +def on_created(env): + return env["payload"] + + +def on_deleted(env): + return env["payload"]["id"] + + +HANDLERS = { + Topics.CREATED: on_created, + "doc.deleted": on_deleted, +} + + +def consume(msg): + handler = HANDLERS.get(msg["headers"]["type"]) + if handler: + handler(msg) diff --git a/tests/cases/python/event-type-handler-table/app/service.py b/tests/cases/python/event-type-handler-table/app/service.py new file mode 100644 index 00000000..2a15d92c --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/service.py @@ -0,0 +1,6 @@ +from app.topics import Topics + + +def create(bus, doc): + bus.publish(Topics.CREATED, doc) + return doc diff --git a/tests/cases/python/event-type-handler-table/app/topics.py b/tests/cases/python/event-type-handler-table/app/topics.py new file mode 100644 index 00000000..aa19fb58 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/app/topics.py @@ -0,0 +1,3 @@ +class Topics: + CREATED = "doc.created" + DELETED = "doc.deleted" diff --git a/tests/cases/python/event-type-handler-table/case.json b/tests/cases/python/event-type-handler-table/case.json new file mode 100644 index 00000000..338e20cd --- /dev/null +++ b/tests/cases/python/event-type-handler-table/case.json @@ -0,0 +1,11 @@ +{"lang": "python", "src": ".", + "checks": [ + {"why": "a dict of type -> handler, keyed by a class constant or by the string itself: impact of the publisher lists the handler registered under the type it publishes [by key], and not the handler of another type", + "run": ["impact", "create", "--grep"], + "want": ["app/handlers.py:4", "[by key]"], + "avoid": ["app/handlers.py:8"]}, + {"why": "the test that publishes the type through the service reaches the handler; a test that writes another type does not", + "run": ["impact", "on_created", "--tests-only"], + "want": ["tests/test_service.py", "by key"], + "avoid": ["tests/test_other.py"]} + ]} diff --git a/tests/cases/python/event-type-handler-table/tests/__init__.py b/tests/cases/python/event-type-handler-table/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/python/event-type-handler-table/tests/test_other.py b/tests/cases/python/event-type-handler-table/tests/test_other.py new file mode 100644 index 00000000..a1eb8187 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/tests/test_other.py @@ -0,0 +1,4 @@ +def test_delete_is_published_by_its_type(): + sent = [] + sent.append(("doc.deleted", {"id": "d1"})) + assert sent diff --git a/tests/cases/python/event-type-handler-table/tests/test_service.py b/tests/cases/python/event-type-handler-table/tests/test_service.py new file mode 100644 index 00000000..451d7a49 --- /dev/null +++ b/tests/cases/python/event-type-handler-table/tests/test_service.py @@ -0,0 +1,10 @@ +from app.service import create + + +class Bus: + def publish(self, topic, doc): + pass + + +def test_create_publishes(): + create(Bus(), {"id": "d1"}) From a2c47e2a77c824209fe952b91a4812adf11d59db Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 01:36:39 -0700 Subject: [PATCH 3/6] impact: a TypeScript object literal key survives a bound access of a same-named field on its line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What was wrong - `const meta: EventMeta = { eventId: options.eventId ?? … }` stopped listing its function under `impact EventMeta.eventId`. The per-line rule that drops a name match on a line where the engine bound a field access (to this field or to another field of that name) drops every ref of that name on the line. Refs carry no column, and TypeScript stored the literal key `eventId` as a plain UNKNOWN identifier, so the key went with the bound `options.eventId` read of PublishOptions.eventId. field_access has no row for an object literal key, so nothing else reported the write. The change - axiomcode-index: a TypeScript expression in the OBJECT_PROPERTY_KEY role is stored with entity kind OBJECT_PROPERTY_KEY instead of UNKNOWN. - dl/impact.dl: fref keeps a ref of that kind past fa_line. A bound access on another line, and the bound access itself, are still not this field's readers. - IMPACT_VERSION 56. tests/cases/typescript/object-key-beside-a-bound-access: red before, green after, with two controls (a line that only reads the other type's field stays out; the bound read stays the other field's resolved reader). tests/run.py --lang typescript: 204 of 204. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../skills/axiomcode/scripts/axiomcode-impact | 2 +- .../skills/axiomcode/scripts/axiomcode-index | 5 +++++ .../skills/axiomcode/scripts/dl/impact.dl | 5 ++++- .../object-key-beside-a-bound-access/case.json | 14 ++++++++++++++ .../object-key-beside-a-bound-access/src/bus.ts | 8 ++++++++ .../src/recorder.ts | 7 +++++++ .../src/stamper.ts | 7 +++++++ .../object-key-beside-a-bound-access/src/types.ts | 8 ++++++++ 8 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/case.json create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts create mode 100644 tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-impact index 8d2b5ef4..4e300359 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 = '56' # 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 = '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 # 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 diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index index 2bbd460a..830ba9a5 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-index @@ -94,6 +94,9 @@ A = { # a property's name (`this.pending`, `row.setX`) is in literalValue with an empty potentialQualifiedName — 36k of 60k references in the parser repo expr=dict(file='all-typescript-expressions.csv', kind='kind', name='potentialQualifiedName', nameFallback='literalValue', line='startLine', fileVia=('modules', 'tsModuleLinkHash'), refKinds={'IDENTIFIER_REFERENCE', 'PROPERTY_ACCESS'}, entityKind='referencedEntityKind', + # 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'), 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')), @@ -573,6 +576,7 @@ c.executemany("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", sym) e = A['expr']; refs = []; lits = [] namecol = e['name'] if isinstance(e['name'], str) else dict(x.split(':') for x in e['name']) mname = e.get('memberName'); child_ek = {} +kr = e.get('keyRole') if mname: # the member-name child's entity kind, for the access that stands for it (the parent's own is UNKNOWN until resolved) for r in rows(e['file']): if r.get(mname['role']) == mname['value'] and r.get(mname['parent']): child_ek[r[mname['parent']]] = r.get(e['entityKind'], '') @@ -591,6 +595,7 @@ for r in rows(e['file']): n = n.rsplit('.', 1)[-1] ek = r.get(e['entityKind'], '') 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]: v = (r.get(e['litValue']) or '') diff --git a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl index 1c15fb72..c351aa14 100644 --- a/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl +++ b/plugins/axiomcode/skills/axiomcode/scripts/dl/impact.dl @@ -462,7 +462,7 @@ 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). + (!fa_line(q, f, l) ; ek = "OBJECT_PROPERTY_KEY"). // 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) @@ -533,6 +533,9 @@ direct(q, c, "uses", "reads it", "resolved", f, l) :- target(q, "field", fl // more, whichever callable the line is attributed to. fa_known works per caller, and a lambda written on the same line // as the access (`m.GetOrAdd(T.Culture + k, x => ...)`) is a different callable, so its name match on T.Culture came // back as a second, [in scope] reader that reads nothing (#1445). +// An object literal's KEY on that line is not the access the engine bound, and no field_access row ever covers one: +// `const meta: EventMeta = { eventId: options.eventId }` binds `options.eventId` to PublishOptions.eventId, and the key +// `eventId` is EventMeta's. fref keeps a ref of entity kind OBJECT_PROPERTY_KEY (TypeScript) past fa_line. .decl fa_line(q:symbol, f:symbol, l:number) fa_line(q, f, l) :- target(q, "field", fl, _), fa_bound(_, fl, _, f, l). fa_line(q, f, l) :- target(q, "field", fl, _), field(fl, _, n, _, _), fa_bound(_, fl2, _, f, l), fl2 != fl, field(fl2, _, n, _, _). diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/case.json b/tests/cases/typescript/object-key-beside-a-bound-access/case.json new file mode 100644 index 00000000..58ec1090 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/case.json @@ -0,0 +1,14 @@ +{"lang": "typescript", "src": "src", + "checks": [ + {"why": "an object literal key on the same line as a bound access of another type's same-named field is a separate reference: `{ eventId: options.eventId }` typed EventMeta writes EventMeta.eventId, and the engine binding `options.eventId` to PublishOptions.eventId must not hide it", + "run": ["impact", "EventMeta.eventId"], + "want": ["change: field EventMeta.eventId", "Bus.publish", "Recorder.wrap"], + "avoid": ["Stamper.stamp"]}, + {"why": "control: a line that only reads another type's same-named field, bound by the engine, is still not this field's reader", + "run": ["impact", "EventMeta.eventId"], + "avoid": ["src/stamper.ts"]}, + {"why": "control: the bound access is the other field's resolved read", + "run": ["impact", "PublishOptions.eventId"], + "want": ["[resolved] Bus.publish src/bus.ts:5 — reads it", "[resolved] Stamper.stamp src/stamper.ts:5 — reads it"], + "avoid": ["Recorder.wrap"]} + ]} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts new file mode 100644 index 00000000..09e9a5b3 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/bus.ts @@ -0,0 +1,8 @@ +import { EventMeta, PublishOptions } from './types'; + +export class Bus { + publish(name: string, options: PublishOptions = {}): EventMeta { + const meta: EventMeta = { eventId: options.eventId ?? name, name }; + return meta; + } +} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts new file mode 100644 index 00000000..8af5d765 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/recorder.ts @@ -0,0 +1,7 @@ +import { EventMeta } from './types'; + +export class Recorder { + wrap(meta: EventMeta): string { + return meta.eventId; + } +} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts new file mode 100644 index 00000000..6a0c0b0a --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/stamper.ts @@ -0,0 +1,7 @@ +import { PublishOptions } from './types'; + +export class Stamper { + stamp(options: PublishOptions): string { + return options.eventId ?? 'none'; + } +} diff --git a/tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts b/tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts new file mode 100644 index 00000000..c0cbcec2 --- /dev/null +++ b/tests/cases/typescript/object-key-beside-a-bound-access/src/types.ts @@ -0,0 +1,8 @@ +export interface EventMeta { + readonly eventId: string; + readonly name: string; +} + +export interface PublishOptions { + readonly eventId?: string; +} From 5b857c1a62a1fef69b27f0ee9c226e5d14b25505 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 01:19:01 -0700 Subject: [PATCH 4/6] A small surface: find, impact, path and tests, each answered as places with their code An agent was offered 8 MCP tools with 69 parameters and ~10,000 characters of descriptions, and the CLI 10-37 flags per verb; in practice agents asked two questions with no options, and after every located answer read the file. The offering is now four questions and a setup verb, the same on the CLI and over MCP, with no options: - find "" / find(question): where the code for a task lives (context underneath); a name the task writes that the code calls and nothing declares is listed with its call sites - impact / impact(name): callers, what a change reaches, the tests; with no name, the same for the declarations the uncommitted edits changed - path / path(start, end): the call chain - tests / tests(): the tests the uncommitted edits reach, and the command that runs exactly those - index: build the graph (--lang, --src, --library) Each answer is numbered places, each with the enclosing function's code (whole when short, else its header and a window around the lines that matter), one block per function, at most 10, word-match filler and module-scope rows dropped (ax_blocks.py). The shape applies at the front doors (the installed command, the MCP server) when no flag is passed; the dispatcher called directly, AXIOMCODE_RAW, or any flag gives the verb's own answer, so hooks, suites and scripts are unchanged. Old verbs and flags still work and are no longer advertised. Help, SKILL.md, AGENTS.md, the Cursor rule, the Gemini copy, README and the hooks' hints teach only the four. MCP answers drop any clause that names an option the tools refuse; code blocks are never touched. CI: engine () runs tests/run.py --lang after the engine suite (every case must pass; a pending case that passes fails until its mark is removed), and the python leg runs tests/front_door.py, which checks the shape through the installed command and MCP and that direct calls and flags keep the old answers. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 21 +- README.md | 133 ++++---- bin/axiomcode | 35 +- packaging/copies.py | 5 +- plugins/axiomcode/AGENTS.md | 33 +- plugins/axiomcode/hooks/_graphline.py | 4 +- plugins/axiomcode/hooks/changes.py | 2 +- plugins/axiomcode/hooks/direct.py | 9 +- plugins/axiomcode/hooks/enrich.py | 4 +- plugins/axiomcode/hooks/orient.py | 17 +- plugins/axiomcode/mcp/server.py | 86 ++--- plugins/axiomcode/rules/axiomcode.mdc | 33 +- plugins/axiomcode/skills/axiomcode/SKILL.md | 185 ++++------ .../axiomcode/reference/changed-and-tests.md | 135 -------- .../skills/axiomcode/reference/context.md | 69 ---- .../skills/axiomcode/reference/diff.md | 68 ---- .../skills/axiomcode/reference/impact.md | 322 ------------------ .../skills/axiomcode/reference/path.md | 130 ------- .../skills/axiomcode/reference/schema.md | 106 ------ .../skills/axiomcode/scripts/ax_blocks.py | 162 +++++++++ .../skills/axiomcode/scripts/axiomcode | 54 ++- .../axiomcode/scripts/axiomcode-install | 24 +- skills/axiomcode/SKILL.md | 185 ++++------ .../axiomcode/reference/changed-and-tests.md | 135 -------- skills/axiomcode/reference/context.md | 69 ---- skills/axiomcode/reference/diff.md | 68 ---- skills/axiomcode/reference/impact.md | 322 ------------------ skills/axiomcode/reference/path.md | 130 ------- skills/axiomcode/reference/schema.md | 106 ------ tests/directive.py | 4 +- tests/freshness.py | 31 +- tests/front_door.py | 150 ++++++++ tests/graph_verb.py | 4 +- tests/hooks_from_path.py | 4 +- tests/latency.py | 8 +- tests/manifests.py | 8 +- tests/mcp.py | 114 +++---- tests/mcp_docs.py | 54 +-- tests/mcp_first.py | 22 +- tests/repo_arg.py | 26 +- tests/surfaces.py | 161 ++++++--- 41 files changed, 914 insertions(+), 2324 deletions(-) delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/context.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/diff.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/impact.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/path.md delete mode 100644 plugins/axiomcode/skills/axiomcode/reference/schema.md create mode 100644 plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py delete mode 100644 skills/axiomcode/reference/changed-and-tests.md delete mode 100644 skills/axiomcode/reference/context.md delete mode 100644 skills/axiomcode/reference/diff.md delete mode 100644 skills/axiomcode/reference/impact.md delete mode 100644 skills/axiomcode/reference/path.md delete mode 100644 skills/axiomcode/reference/schema.md create mode 100644 tests/front_door.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c675ed1..5987254c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -253,7 +253,7 @@ jobs: needs: [changes] if: needs.changes.outputs.code == 'true' runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 90 env: # the engine is compiled for any x86-64 runner, so a cached one can be restored # on whichever runner this job lands (see the engine cache step below) @@ -448,6 +448,25 @@ jobs: # AXIOM_SUITE_JOBS=1 here would run them one at a time, as they used to. run: bash .github/scripts/run-suite.sh ${{ matrix.lang }} ${{ matrix.oracle }} + # THE QUERY LAYER'S CASES (tests/run.py): what impact, path, context, changed and test-impact answer on small + # projects written for one behaviour each. Every case must pass, and a case marked pending that now passes fails + # the run until the mark is removed, so a fix for one shape cannot quietly break another's answer. The suite + # calls the verbs directly, so it checks their own answers, not the front-door rendering (tests/front_door.py). + - name: ${{ matrix.lang }} query cases + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + run: python3 tests/run.py --lang ${{ matrix.lang }} + + # the small surface as users and agents get it: find / impact / path / tests through the installed command and + # the MCP server, answered as places with their code, and the direct calls and flags that keep the old answers + - name: the front door answers as places with their code + if: matrix.lang == 'python' + env: + AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js + AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache + run: python3 tests/front_door.py + # The hooks answer impact from SQL (graph_sql.impact_shaped) and fall back to the rules (dl/impact.dl) only when # it declines, so the two must list the same rows for the same edit: tests/fastpath.py indexes a small case, asks # both on each target shape, and runs hooks/changes.py on one edit through each path. Same parser and engine diff --git a/README.md b/README.md index 23ca5395..166aaf3d 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ That matters because an agent follows edges several hops deep, and one missed li axiomcode graph of an open-source TypeScript web framework, 366 files and 8,657 call edges. Source files form the inner ring, test files the outer ring. A change to basicAuth reaches 7 test files through resolved calls (solid blue); the other 130 test files have no chain to it (dashed red). basicAuth calls a shared compare function (green) that 11 other files also reach (gold).

-*`axiomcode graph` on an open-source TypeScript web framework, asked which tests a change to `basicAuth` can +*The call graph of an open-source TypeScript web framework, drawn as a page and asked which tests a change to `basicAuth` can affect. Source files form the inner ring and test files the outer one. The solid blue paths are chains of resolved calls from `basicAuth` to the 7 test files that must run; the dashed red ones mark the other 130, which have no chain to it and can be skipped. Green is the shared `compare` that `basicAuth` calls, and gold the 11 other files @@ -123,7 +123,7 @@ established relationships that could lead to false positives. AxiomCode Graph is two parts. The **engine** (`@axiomcode/code-graph` on npm) parses a repository and builds its graph; it also provides the `axiomcode` command and an MCP server. The **plugin** (`plugins/axiomcode/`) is the -agent-facing frontend: a skill, seven MCP tools, and hooks. Install the engine first. +agent-facing frontend: a skill, four MCP tools, and hooks. Install the engine first. Requirements: **Node ≥ 22.5** and **Python 3** (`python3`, or `python` / `py` on Windows). On Windows, also [Git for Windows](https://git-scm.com/download/win): the CLI runs under its bash. The engine ships as a prebuilt @@ -200,55 +200,62 @@ axiomcode path main Ledger.put axiomcode path main SqlStore.put ``` -``` -main → Ledger.put: 1 of 1 target(s) reached through resolved calls; nearest at 2 hop(s) - 2 call(s): - main src/main.ts:6 - → [known_edge · call @ src/main.ts:9] OrderService.place src/orders/orderService.ts:7 - → [known_edge · call @ src/orders/orderService.ts:8] Ledger.put src/ledger/ledger.ts:4 - verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length - -main → SqlStore.put: 1 of 1 target(s) reached through resolved calls; nearest at 2 hop(s) - 2 call(s): - main src/main.ts:6 - → [known_edge · call @ src/main.ts:9] OrderService.place src/orders/orderService.ts:7 - → [multi_inferred · call @ src/orders/orderService.ts:9] SqlStore.put src/storage/sqlStore.ts:6 - verified: every printed hop is an edge in the graph and a second, independent traversal finds the same length - what the hops are: - [known_edge] resolved to one declaration - [multi_inferred] several declarations fit; each is a real candidate -``` - -The first chain is `known_edge` all the way: each call has exactly one target. The second ends in -`multi_inferred`, because `store.put` can run `SqlStore.put` or `MemoryStore.put`, depending on which store `main` -built; the graph keeps both as candidates instead of picking one. +Every answer is a numbered list of places, each with the code of the function it sits in and the line that matters +marked `→`: + +```` +1. src/main.ts:9 [resolved · hop 1/2 main → OrderService.place] + ```typescript + 6 export function main(useSql: boolean): void { + 7 const store = useSql ? new SqlStore("orders") : new MemoryStore(); + 8 const service = new OrderService(new Ledger(), store); + → 9 service.place("A-1", 42); + 10 } + ``` +2. src/orders/orderService.ts:8 [resolved · hop 2/2 OrderService.place → Ledger.put] + ```typescript + 7 place(id: string, amount: number): void { + → 8 this.ledger.put(`order ${id}: ${amount}`); + 9 this.store.put(id, amount); + 10 } + ``` +verified: ✓ (2 edge(s) looked up again) +```` + +The second chain ends the same way, at `src/orders/orderService.ts:9`, tagged `one of a set`: `store.put` can run +`SqlStore.put` or `MemoryStore.put`, depending on which store `main` built, and the graph keeps both as candidates +instead of picking one. From an agent, ask in plain words. The skill tells the agent to query the graph instead of grepping: -``` +```` > What breaks if I change SqlStore.put? - axiomcode_impact("SqlStore.put") - must change with it (1: bound by a contract the engine resolved): - Store.put src/storage/store.ts:2 — it implements this - reads or uses it (3 callable(s): 1 one of a set, 2 alongside): - [one of a set] OrderService.place src/orders/orderService.ts:9 — calls it - ... - reaches those through resolved calls: 4 more callable(s) in 3 file(s) - src/main.ts: main → OrderService.place - tests: 1 of 1 test method(s) reach the change - test files: test/orderService.test.ts (1) - verified: 2 printed edge(s) looked up again in the graph, all present -``` - -The change reaches the entry point and the test through a call that never names `SqlStore`. - -Each hop carries the line the call is on, how certain the edge is, and what kind of call it is. Every printed -edge is looked up again in the graph before you see it; the `verified:` line is that check reporting. + impact("SqlStore.put") + 1. src/storage/store.ts:2 [must change · it implements this] + ```typescript + → 2 put(key: string, value: number): void; + ``` + 2. src/orders/orderService.ts:9 [one of a set · OrderService.place] + ```typescript + 7 place(id: string, amount: number): void { + 8 this.ledger.put(`order ${id}: ${amount}`); + → 9 this.store.put(id, amount); + 10 } + ``` + 3. src/main.ts:6 [hop 2] + ... + 4. test/orderService.test.ts:3 [test · one of a set · hop 3] + ... + verified: ✓ (3 edge(s) looked up again) +```` + +The change reaches the entry point and the test through a call that never names `SqlStore`. Every printed edge is +looked up again in the graph before you see it; the `verified:` line is that check reporting. ### Support for agents -Every agent below gets the seven MCP tools and the skill; the hooks, which add the graph's edges to the agent's +Every agent below gets the four MCP tools and the skill; the hooks, which add the graph's edges to the agent's own file reads and searches, run where the last column says so. | Agent | Install | Uninstall | Hooks | @@ -301,27 +308,28 @@ about one change had to read 95 of 43,793 methods, and every true direct caller ## CLI commands -| command | what it does | +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()`. + +| command | what it answers | |---|---| -| `axiomcode path
` | the chain of calls from A to B, hop by hop. `'*'` as one end gives the whole closure | -| `axiomcode impact ` | everything that has to be looked at again when a declaration changes, each labelled with how certain it is. `--tests` adds the tests that reach it | -| `axiomcode test-impact` | which tests have to run for the current edit, with the chain that reaches each | -| `axiomcode changed` | which declarations an edit changed, and how (signature, type, body, added, removed). `--impact` adds what that reaches | -| `axiomcode context ""` | where a task's words land in the code, when you have a problem statement and not yet a name | -| `axiomcode graph` | the whole graph as one self-contained HTML page, at `.axiomcode/graph/graph.html`, drawn from the existing graph (rebuilt first only when stale, with the flags it was indexed with) | -| `axiomcode index` | build or rebuild the graph explicitly; `--lang`, `--src` and `--library` narrow it | -| `axiomcode mcp` | serve the graph to an agent as MCP tools over stdio | - -A target is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or -`file.py:123`. It is resolved exactly; a miss lists the nearest names. `--range ..` compares two commits. The -query commands take `--json`. `axiomcode help ` prints one command's usage. +| `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 | +| `axiomcode tests` | the tests your uncommitted edits reach, and a last `run:` line with the command that runs them | +| `axiomcode index` | build the graph explicitly (the first query builds it too); `--lang`, `--src` and `--library` narrow it | + +A name is written the way it appears in the code: `Owner.method`, `method`, `Type`, `Owner.field`, or +`file.py:123`. It is resolved exactly; a miss lists the nearest names. `axiomcode help ` prints one +command's usage. > [!NOTE] -> `changed` and `test-impact` compare the working tree with a **baseline**: the last commit (right after an explicit -> `axiomcode index`, the tree it indexed). The background refresh (below) resets it whenever HEAD moves (a commit, a -> merge, a pull, a checkout), so committed edits drop out and nothing accumulates; the two commands wait up to 30 s -> for that. While edits are uncommitted, they read the baseline's own graph, kept in `.axiomcode/base`, so a removed -> method still shows all its callers. +> `impact` with no name and `tests` compare the working tree with a **baseline**: the last commit (right after an +> explicit `axiomcode index`, the tree it indexed). The background refresh (below) resets it whenever HEAD moves (a +> commit, a merge, a pull, a checkout), so committed edits drop out and nothing accumulates. While edits are +> uncommitted, they read the baseline's own graph, kept in `.axiomcode/base`, so a removed method still shows all its +> callers. The graph stays current on its own. Every file the parser reads is recorded with its hash at build time; after an edit, a shell command, a finished turn, at session start, and before a query, anything that differs starts one @@ -334,8 +342,7 @@ a session sits idle. The graph records when and why it was built in `index_meta` `AXIOMCODE_NO_REFRESH=1` turns the rebuilds off, not the check: an answer from a graph older than an edit still ends with a `graph refresh: OFF` line naming the files it predates. When a name asked about finds nothing and an edit since the graph was built writes that name, the line says so, since the declaration may simply be too new -for the graph. `--no-refresh` on any query verb (MCP `refresh=false`) does the same for one query: a read-only answer -from the graph as it is. A query that does start a rebuild says so on its answer's first line, with the reason. The +for the graph. A query that does start a rebuild says so on its answer's first line, with the reason. The hooks and the MCP server's timer never rebuild a graph another axiomcode built (another engine, other rules or another `IMPACT_VERSION` in its build stamp); a hook says so once per session. The log is `.axiomcode/refresh.log`. diff --git a/bin/axiomcode b/bin/axiomcode index 01c25295..f5a73ca2 100755 --- a/bin/axiomcode +++ b/bin/axiomcode @@ -1,6 +1,10 @@ #!/usr/bin/env bash # ───────────────────────────────────────────────────────────────────────────── -# axiomcode — build a call graph from a source tree. +# 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. +# ───────────────────────────────────────────────────────────────────────────── +# INTERNAL COMMANDS, not advertised: the build, the engine suites and the MCP server, which +# the dispatcher, the test suites and the agent manifests call. # # Usage: # bin/axiomcode [--library [,…]] [--exclude-tests] [--version V] @@ -88,9 +92,8 @@ PARSER="${AXIOM_PARSER:-$ROOT/parser/dist/index.js}" # (`--verbs`, derived from its own dispatch table) so the two entry points cannot drift: a new # capability is a new row in one case statement, not a new copy here. QUERY="$ROOT/plugins/axiomcode/skills/axiomcode/scripts/axiomcode" -# `tests` is the frontend's alias for test-impact; `test` is THIS command's engine suite. One letter -# apart, entirely different jobs, and putting both on one command is a trap — so the alias is not -# routed here, and the name is refused below with both readings spelled out. +# `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. @@ -100,19 +103,18 @@ load_verbs(){ local l v x re='^ ([a-z|-]*)\) *exec ' while IFS= read -r l || [ -n "$l" ]; do [[ $l =~ $re ]] || continue; v="${BASH_REMATCH[1]}" - for x in ${v//|/ }; do [ "$x" = tests ] || QVERBS+=("$x"); done + for x in ${v//|/ }; do QVERBS+=("$x"); done done < "$QUERY" } query_verbs(){ load_verbs; [ ${#QVERBS[@]} -gt 0 ] && printf '%s\n' "${QVERBS[@]}"; return 0; } is_query_verb(){ load_verbs; local x; for x in ${QVERBS[@]+"${QVERBS[@]}"}; do [ "$x" = "$1" ] && return 0; done; return 1; } usage(){ - awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//' - if [ -f "$QUERY" ]; then - echo - echo 'ASKING THE GRAPH — `axiomcode help ` for any one of them:' - bash "$QUERY" --help | sed -n 's/^ \(axiomcode [a-z].*\)$/ \1/p' - fi + # the query surface is the dispatcher's own help, so the two cannot drift; the build commands above are internal + if [ -f "$QUERY" ]; then bash "$QUERY" --help + else awk 'NR > 2 && /^# ─/ {exit} NR > 2' "$0" | sed 's/^# \{0,1\}//'; fi } +# the verbs the help advertises (` axiomcode ` lines), for the message a typo gets +public_verbs(){ [ -f "$QUERY" ] && bash "$QUERY" --help | sed -n 's/^ axiomcode \([a-z][a-z-]*\).*/\1/p'; return 0; } die(){ echo "axiomcode: $*" >&2; exit 2; } need_parser(){ [ -f "$PARSER" ] || { echo "axiomcode: parser not built at $PARSER — run: npm install && npm run build" >&2; exit 1; }; } @@ -129,15 +131,14 @@ case "$cmd" in if [ $# -gt 1 ] && is_query_verb "$2"; then exec bash "$QUERY" help "$2"; fi ;; "") ;; # THE MCP SERVER IS THE PLUGIN'S, NOT A SECOND ONE. The package already ships plugins/axiomcode/, - # so an agent that is not Claude Code gets the same seven tools from one line of MCP config and + # so an agent that is not Claude Code gets the same four tools from one line of MCP config and # no plugin install. launch.js picks the interpreter and falls back to a built-in protocol # implementation, so nothing beyond the python3 the query verbs already need has to be installed. mcp) shift export AXIOMCODE_PLUGIN_ROOT="$ROOT/plugins/axiomcode" exec node "$ROOT/plugins/axiomcode/mcp/launch.js" "$@" ;; - tests) echo "axiomcode: 'tests' is ambiguous here — 'axiomcode test-impact' for the tests an edit needs," >&2 - echo " 'axiomcode test' for this package's own engine suite." >&2; exit 2 ;; - *) if is_query_verb "$cmd"; then shift; exec bash "$QUERY" "$cmd" "$@"; fi ;; + # the installed command is a front door: a query asked here with no flags answers as places with their code + *) if is_query_verb "$cmd"; then shift; export AXIOMCODE_FRONT=1; exec bash "$QUERY" "$cmd" "$@"; fi ;; esac # A NAME THAT IS NOT A VERB AND NOT A DIRECTORY IS A TYPO, NOT A BUILD. `*) cmd=all` is convenient # for `axiomcode ./src out/` and wrong for everything else: a misspelled verb used to be parsed as a @@ -145,9 +146,7 @@ esac case "$cmd" in parser|engine|all|test|-h|--help|help|"") [ $# -gt 0 ] && shift;; *) [ -d "$cmd" ] || { echo "axiomcode: '$cmd' is neither a verb nor a directory." >&2 - echo " build: parser engine all test (or: axiomcode )" >&2 - echo " serve: mcp" >&2 - echo " ask: $(query_verbs | tr '\n' ' ')" >&2 + echo " ask: $(public_verbs | tr '\n' ' ')" >&2 echo " \`axiomcode help\` for what each one does." >&2; exit 2; } cmd=all;; esac diff --git a/packaging/copies.py b/packaging/copies.py index b503878a..9440eff4 100755 --- a/packaging/copies.py +++ b/packaging/copies.py @@ -11,7 +11,7 @@ the install: Gemini clones into a temporary directory, copies it with fs.cp, which rewrites a relative link into an absolute one inside that directory, and then deletes the directory. -So skills/axiomcode/ holds a copy of SKILL.md and reference/, and nothing else. The scripts stay in the +So skills/axiomcode/ holds a copy of SKILL.md (and reference/, when the skill has one), and nothing else. The scripts stay in the plugin: the copy's fallback command is rewritten to reach them from the repository root, which Gemini installs whole. @@ -48,7 +48,8 @@ def expected(): if SCRIPTS not in text: sys.exit(f"copies: SKILL.md no longer names {SCRIPTS}…; update the rewrite in {__file__}") files['SKILL.md'] = text.replace(SCRIPTS, FROM_ROOT) - for name in sorted(os.listdir(os.path.join(SOURCE, 'reference'))): + ref = os.path.join(SOURCE, 'reference') + for name in sorted(os.listdir(ref)) if os.path.isdir(ref) else []: with open(os.path.join(SOURCE, 'reference', name)) as f: files[os.path.join('reference', name)] = f.read() return files diff --git a/plugins/axiomcode/AGENTS.md b/plugins/axiomcode/AGENTS.md index c99dc574..ac48bfde 100644 --- a/plugins/axiomcode/AGENTS.md +++ b/plugins/axiomcode/AGENTS.md @@ -1,25 +1,20 @@ # axiomcode -For any why, what or where question about code — how it works, where something lives, who calls it, -what a change breaks, which tests an edit reaches, whether something is safe to delete — ask the -repository's call graph FIRST, through the `axiomcode_*` MCP tools: +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: - axiomcode_context where the work is, when you have a task in words and no name yet; for "how does X - work", explain=True, source=True (and from_=) returns the call - flow with each step's code - axiomcode_impact what a change reaches: must-change-with-it, users, tests - axiomcode_path how A reaches B, each hop verified - axiomcode_changed which declarations an edit changed, and how - axiomcode_test_impact which tests the edit in front of you has to run - axiomcode_index build the graph, when .axiomcode/out/graph.sqlite is absent - axiomcode_graph draw the graph as one interactive HTML page, for a person - axiomcode_diff what changed between two graphs of one tree (before/after), by name and line + 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 -**Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again -in the graph (the `verified:` line) — do not re-derive it by grepping. Every answer ends with `next:`, the -one step to take. For a CHANGE, read only the lines you will cite or change. To EXPLAIN how something -works, the graph gives the reading order: answer from the flow's code, and read further only where a step's -body was cut or a `⚠` marks a call the graph lost. -`[by name]` / `[text]` rows are leads, not facts. An unresolved call means *unknown*, not *absent*. +Without the tools, the same from the shell: `axiomcode find ""`, `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 +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` / +`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. diff --git a/plugins/axiomcode/hooks/_graphline.py b/plugins/axiomcode/hooks/_graphline.py index 7755c85d..be03b5bd 100644 --- a/plugins/axiomcode/hooks/_graphline.py +++ b/plugins/axiomcode/hooks/_graphline.py @@ -133,7 +133,7 @@ def body_line(db, results, repo='.'): cl = sorted(_concrete(db, cl)) # before the cut, so the command and the "+N more" count the same classes cmd = _command_for(lang, fl[:SHOWN], cl[:SHOWN], None, repo) more = (len({c.split('.')[-1] for c in cl}) if lang in ('java', 'csharp') and cl else len(fl)) - SHOWN - tail = (f"; run: {cmd}" + (f" (+{more} more: axiomcode test-impact)" if more > 0 else '')) if cmd else "; axiomcode test-impact gives the command" + tail = (f"; run: {cmd}" + (f" (+{more} more: tests(), `axiomcode tests`)" if more > 0 else '')) if cmd else "; tests() (`axiomcode tests`) gives the command" return f"graph: body edit of {what}: {n} test(s) reach it{tail}" @@ -202,4 +202,4 @@ def base_moved_line(repo, st): n = git('rev-list', '--count', '--right-only', '--cherry-pick', f'{prev}...{h}').stdout.strip() return (f"graph: the base moved: HEAD is {h[:10]}, was {prev[:10]}" + (f" ({n} commit(s) it did not have)" if n.isdigit() else '') + " — a rebase, a pull, a checkout, a reset or a commit. What those commits changed is not reported as an edit;" - " edits are read against the file before each one. `axiomcode changed --range ..HEAD` reads committed work.") + " edits are read against the file before each one. impact(name) (`axiomcode impact `) answers for a declaration they touched.") diff --git a/plugins/axiomcode/hooks/changes.py b/plugins/axiomcode/hooks/changes.py index a547f630..42856045 100644 --- a/plugins/axiomcode/hooks/changes.py +++ b/plugins/axiomcode/hooks/changes.py @@ -121,7 +121,7 @@ def impact(d): dd = x['evidence']['decider'] lines.append(f" decided: [{x['certainty']}] {x['at'].split('/')[-1]} ← {dd['at'].split('/')[-1]}: {dd['text'][:100]} [{dd['kind']}]") lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) - if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: axiomcode changed --impact") + if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more: impact() with no name (`axiomcode impact`) answers for every edit") if bodies: lines.append(_graphline.body_line(os.path.join(os.environ.get('AXIOMCODE_GRAPH') or os.path.join(cwd, '.axiomcode'), 'out', 'graph.sqlite'), bodies + [(d, {}) for d in body[3:]], cwd)) diff --git a/plugins/axiomcode/hooks/direct.py b/plugins/axiomcode/hooks/direct.py index 37eaf8d3..7a4285b8 100755 --- a/plugins/axiomcode/hooks/direct.py +++ b/plugins/axiomcode/hooks/direct.py @@ -121,10 +121,11 @@ def directive(hits): named = ', '.join(f"`{h[1]}` ({h[2]}:{h[3]})" for h in hits[:3]) # short on purpose: it is read once and then re-read on every later turn return ( - f"graph: this search is for {named}. Who calls it and what a change breaks,\n" - f" with the callers that never spell the name (an interface, an override, a callback, DI):\n" - f" axiomcode_impact targets=[\"{at}\"] (`axiomcode impact {at}`). Also axiomcode_path (how A reaches B,\n" - f" `axiomcode path A B`), axiomcode_context (a task in words, `axiomcode context \"\"`). Said once this session." + 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." ) diff --git a/plugins/axiomcode/hooks/enrich.py b/plugins/axiomcode/hooks/enrich.py index 6bd2d80a..0b49ea84 100755 --- a/plugins/axiomcode/hooks/enrich.py +++ b/plugins/axiomcode/hooks/enrich.py @@ -243,7 +243,7 @@ def names(xs, k=4): return ', '.join(f"[{x['certainty']}] {x['display']} {x['at' # 0 tests where the rules report ~1800 and ~1470, and there was no way to tell from the block whether that # was the fast path answering, the rules answering, or the CLI having given up. lines.append(f" [{'fast path' if j.get('_sql') else 'rules'}] reaches {len(rc)} more callable(s) through resolved calls within 12 hops; {len(ts)} test(s) reach the change" + (": " + ', '.join(f"{t['owner'] or (t.get('at') or '').rsplit('/', 1)[-1].split(':')[0] or 'test'}::{t['name']}" for t in ts[:3]) + (' …' if len(ts) > 3 else '') if ts else '') + (f"; {j['unresolved_inside']} unresolved call(s) inside — a lower bound" if j.get('unresolved_inside') else '')) - if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more changed declaration(s): axiomcode changed --impact") + if len(decls) > 3: lines.append(f" … +{len(decls) - 3} more changed declaration(s): impact() with no name (`axiomcode impact`) answers for every edit") if decls: for n in ch.get('notes', [])[:2]: lines.append(f" added: {n}") if bodies: @@ -512,7 +512,7 @@ def lookup(n): if key in seen: lines = [] # the same range, or the same search, is annotated once elif spent >= ENRICH_BUDGET: - lines = [] if st.get('budget_said') else [f"graph: this session's enrichment budget ({ENRICH_BUDGET} characters) is spent, so reads and searches get no more of these blocks; ask `axiomcode impact` / `path` directly for a declaration's edges"] + lines = [] if st.get('budget_said') else [f"graph: this session's enrichment budget ({ENRICH_BUDGET} characters) is spent, so reads and searches get no more of these blocks; ask impact(name) / path(start, end) directly (mcp__plugin_axiomcode_axiomcode__impact / __path; shell `axiomcode impact` / `axiomcode path`) for a declaration's edges"] st['budget_said'] = True elif not novel and spent >= ENRICH_BUDGET // 2: lines = [] # only edges into files already opened: the rest is kept for new ones diff --git a/plugins/axiomcode/hooks/orient.py b/plugins/axiomcode/hooks/orient.py index 2755a551..316cfc7b 100755 --- a/plugins/axiomcode/hooks/orient.py +++ b/plugins/axiomcode/hooks/orient.py @@ -196,8 +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(' the axiomcode_context tool with in_path= ranks the files and declarations inside it; call it ' - 'directly, no skill needs loading first (without that tool: `axiomcode context "" --in `).') + 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 ""`).') else: print("graph: where this task's own words land in the index —") for l in lines[:MAX_LINES]: @@ -209,10 +210,10 @@ 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: the axiomcode_context tool with source=True, from_= returns the call flow with each " - "step's code; call it directly, no skill needs loading first (without that tool: " - '`axiomcode context "" --source --from `).') + 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 ""`).') else: - print(' a starting point, not a conclusion: next, the axiomcode_impact tool with targets=[], in_path= ' - 'for what a change reaches; call it directly, no skill needs loading first (without that tool: ' - '`axiomcode impact --in `).') + 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 ' + 'needs loading first (without that tool: `axiomcode impact `).') diff --git a/plugins/axiomcode/mcp/server.py b/plugins/axiomcode/mcp/server.py index 8a91153d..2f30e142 100755 --- a/plugins/axiomcode/mcp/server.py +++ b/plugins/axiomcode/mcp/server.py @@ -300,67 +300,45 @@ 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 +# 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: - """Build (or refresh) the call graph of a repository: parser → engine → /.axiomcode/out/graph.sqlite. Run once before path/impact/graph. lang: java|typescript|python|javascript|csharp when the repo mixes languages; src: subtree to analyse (e.g. src); library: comma-separated dependency roots so calls into them resolve.""" - need_repo(repo) - a = ['index', repo] + (['--lang', lang] if lang else []) + (['--src', src] if src else []) + (['--library', library] if library else []) - return run(a) +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() -@_doc -def axiomcode_context(task: str, repo: str = ".", in_path: str = '', budget: int = 0, source: bool = False, page: Page = 1, explain: bool = False, from_: str = '', fresh: bool = False, full: bool = False, limit: int = 0, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: - """[resolved]/[sound] rows are verified against the graph; the answer ends with `next:`, the one step to take. START HERE when you have a task in words and no name to ask about yet. A task that asks HOW something works ("how does X …", "explain …", or explain=True) also gets the call FLOW — every step in the order the calls are written, with ⚠ where the graph lost a call; from_ (comma-separated names) starts the flow where you choose. Pass source=True with it: each step then carries its code, so answer from that and open a file only for a step whose body was cut or a ⚠ call. Otherwise it returns the files and callables that task touches, from the problem statement alone. 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_path accepts SEVERAL paths, comma-separated: they are combined rather than intersected, so a change spanning two roots comes back in one call. budget is how many files are listed (default 12; the ranking is the same at any budget); source=True includes the code. A long answer comes in pages; ask for page=2 only if page 1's files are not enough. Ends by saying what it could not see. Without source/explain/from_ the answer is one site per line (`path:line: code [tag]`), capped with a count of the rest; limit=N lists more, full=True gives the prose. After an edit the answer comes at once from the last graph, rows in edited files marked (may be out of date); fresh=True waits for the rebuild. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - flow = source or explain or from_.strip() or _paged(page) or budget - a = ['context', task, repo] + grep(full or flow, limit) + (['--fresh'] if fresh else []) + (['--in', in_path] if in_path else []) + (['--budget', str(budget)] if budget else []) + (['--source'] if source else []) + _pg(page) + (['--explain'] if explain else []) + [x for n in from_.split(',') if n.strip() for x in ('--from', n.strip())] - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) +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 + calls it, what depends on it further out, and which tests exercise it, each with its code. With no name: the same + for the declarations your uncommitted edits changed.""" + return plain(run(['impact'] + ([name] if name.strip() else []) + [os.getcwd()])) @srv.tool() -@_doc -def axiomcode_path(from_: str, to: str, repo: str = ".", every: bool = False, in_path: str = '', depth: int = 0, limit: int = 0, page: Page = 1, fresh: bool = False, full: bool = False, why: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: - """[resolved]/[sound] rows are verified against the graph, so a change need not re-derive them by reading (to explain how something works, read each hop's body); the answer ends with `next:`, the one step to take. A chain of calls from A to B in the graph, each hop verified, or why there is none. When you have ONE concept word you can name, a bare fragment resolves to every declaration containing it, so path('decrypt', '*') answers "what is the decryption code and what does it touch". For a whole task in words, with no name at all, use axiomcode_context first. Endpoints otherwise as written in the code: Owner.method, method, Type, Outer$Inner.m, file.java:123, file.py, @Decoration, a library call as written (new File, Files.readAllBytes). '*' on one side = everything that reaches B / everything A reaches. every=True lists every route; in_path restricts to files containing it; depth bounds a closure. A long answer comes in pages, nearest routes first, with the whole answer's counts on every page; ask for page=2 only if page 1 is not enough. fresh=True: after an edit, wait for the rebuild instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, each hop at the line its call is written on (`path:line: code [resolved · hop 1/3 → B]`); full=True gives the prose, which also says why when there is no chain. why=True adds, after the endpoint line, how each endpoint name was resolved: the lookup step that matched it (exact declaration, qualified suffix, simple name, a type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing (it gives the prose). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - paged = _paged(page); full = full or why - a = ['path', from_, to, repo] + grep(full or paged, limit) + (['--why'] if why else []) + (['--fresh'] if fresh else []) + (['--every'] if every else []) + (['--in', in_path] if in_path else []) + (['--depth', str(depth)] if depth else []) + (['--limit', str(limit)] if limit and (full or paged) else []) + (['--page', str(page)] if paged else []) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) +def path(start: str, end: str) -> str: + """How one declaration reaches another: every hop of the call chain with the code at the line the call is + written on. start / end as written in the code (Owner.method, function, Type).""" + return plain(run(['path', start, end, os.getcwd()])) @srv.tool() -@_doc -def axiomcode_impact(targets: list[str], repo: str = ".", tests: bool = False, why: bool = False, tests_in: str = '', depth: int = 0, in_path: str = '', kind: str = '', page: Page = 1, budget: int = 0, limit: int = 0, delete: bool = False, fresh: bool = False, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: - """Trust it: [resolved]/[sound] rows are verified against the graph, so do not re-derive them by reading; the answer ends with `next:`, the one step to take. What has to be looked at again when a declaration changes: must-change-with-it (overrides, subtypes), everything that directly uses it (with how sure each is), everything that reaches those, and the bound (unresolved calls). The tests are always counted, by rung, with the strong-route ones named and the top test files. Ask for the full list SECOND, only if you need it: tests=True returns ONLY the tests, grouped by rung and test file (the CLI's --tests-only); why=True adds each test's route, and after each `change:` line how its target name was resolved (the lookup step that matched, the declarations weighed with file:line, why that one won or why nothing matched); tests_in narrows that listing to test files containing it. Long answers come in pages of ~2000 tokens: every page carries the counts of the WHOLE answer and the rows come strongest first, so page 1 is usually enough; page=2 continues with the rows page 1 did not print (a one-page answer says there is no page 2), page="all" gives every row. budget changes the page size. Targets as written: Owner.method, Owner.field, Type, Owner.method(param), Type, Owner.method:local, or file.ts:123 (the declaration at that line). When you know where the declaration is, target it by file:line: a bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one answer. kind: method|field|type|param|typeparam|var when a name is declared as several kinds. limit: rows shown per section (the `… +N (limit=N)` lines); delete=True adds a verdict on whether it is safe to delete. fresh=True: after an edit, wait for the rebuild (use it before a delete or a rename) instead of answering from the last graph with rows in edited files marked (may be out of date). The answer is one site per line, surest first (`path:line: code [resolved | one of a set | by name | text | hop N | test]`), capped with a count of the rest; full=True gives the sectioned prose (why, delete, budget and page give it too). refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - prose = full or why or delete or _paged(page) or budget - a = ['impact', *targets, repo] + grep(prose, limit) + (['--fresh'] if fresh else []) + (['--tests-only'] if tests else []) + (['--why'] if why else []) + (['--tests-in', tests_in] if tests_in else []) + (['--depth', str(depth)] if depth else []) + (['--in', in_path] if in_path else []) + (['--kind', kind] if kind else []) + _pg(page) + (['--budget', str(budget)] if budget else []) + (['--limit', str(limit)] if limit and prose else []) + (['--delete'] if delete else []) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) - -@srv.tool() -@_doc -def axiomcode_changed(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, impact: bool = False, page: Page = 1, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: - """Which declarations an edit changed and HOW — signature (parameters added / removed / retyped, return type), field (its type, name, initializer), type header, body only, removed, added (a new file is one `added` line) — the working tree against the commit the graph was built from (default), your branch's commits (range='a..b': read from `git merge-base a b`, so commits a received after you branched are not yours; a note says so when a has moved), or the index (staged=True); each with the target impact takes. When the working tree is clean but HEAD has commits of its own, it says which range=... to ask. files=[...] limits it to those files; on a copy without git (which it refuses otherwise) every declaration in a named file counts as changed. Changed files outside every indexed language (fixtures, case data, a schema) are named, never dropped. impact=True runs impact on all of them as one change set and returns its answer. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - a = ['changed', repo, *files] + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--impact'] if impact else []) + _pg(page) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) - -@srv.tool() -@_doc -def axiomcode_test_impact(repo: str = ".", files: list[str] = [], range: str = '', staged: bool = False, in_path: str = '', limit: int = 0, why: bool = False, page: Page = 1, full: bool = False, refresh: bool = True, evidence: str = '', drop: list[str] = [], exact: bool = False, alongside: bool = False) -> str: - """Which tests actually have to run for the edit in front of you: the test files that reach any changed declaration, with the chain, so the selection can be checked rather than trusted, and the command that runs them. Working tree by default; range='a..b' for your branch's commits (from `git merge-base a b`, so a base branch that moved on is not counted as your change); staged=True for the index; files=[...] for named files (a named file with no edit, or any on a copy without git, counts whole: the tests of everything in it). An edited test file is itself listed to run. Changed files outside every indexed language (fixtures, case data) are named with the test files that name them in their text. Conservative by design — a test reached only through an edge the graph does not encode (reflection, a service loader, a subprocess, a runtime-built case) will NOT appear, so it is a lower bound. why=True prints the chain for each. The answer is one test per line (`path:line: code [test · resolved · hop N]`), capped with a count of the rest, and the command that runs them; limit=N lists more, full=True gives the prose. refresh=False: read-only, answers from the graph as it is and never starts a rebuild (an answer that did start one says so on its first line).""" - need_repo(repo) - prose = full or why or _paged(page) - a = ['test-impact', repo, *files] + grep(prose, limit) + (['--range', range] if range else []) + (['--staged'] if staged else []) + (['--in', in_path] if in_path else []) + (['--limit', str(limit)] if limit and prose else []) + (['--why'] if why else []) + _pg(page) - return run(a + ev(evidence, drop, exact, alongside) + NOREF(refresh)) - -@srv.tool() -def axiomcode_graph(repo: str = ".", out: str = '', refresh: bool = True) -> str: - """Draw the graph as one interactive HTML page, for a person: every language the repository was indexed in, at /.axiomcode/graph/graph.html or out=. Drawn from the existing graph when it is up to date (seconds, no engine run); a graph that is out of date is rebuilt first with the --lang, --src and --library it was indexed with, never for a language the index left out; with no graph yet the repository is indexed first. Answers with what it drew, in prose, and the page's absolute path. refresh=False: drawn from the graph as it is, never rebuilt first.""" - need_repo(repo) - return run(['graph', repo] + (['--out', out] if out else []) + NOREF(refresh)) - -@srv.tool() -def axiomcode_diff(graph_a: str, graph_b: str, file: str = '', lang: str = '', limit: int = 40, as_json: bool = False) -> str: - """What changed between two graphs of the SAME tree, e.g. one tree copied and indexed before and after an engine or rules change: call edges added, removed, retiered (same callee, another tier) or re-targeted (a site whose callees changed), entry points with their reason, remote and framework edges, config bindings and symbols, and the call edges per tier (A -> B). graph_a / graph_b: a graph.sqlite, or an indexed directory (every language graph in it, paired by language). Rows are matched by file, line, column, qualified name and callee, never by id (ids hash the index directory), so one tree indexed at two paths diffs to nothing. Neither graph is rebuilt. file keeps the rows with a file containing it; limit: rows per section (default 40, 0 for all; the counts are always of the whole diff); as_json=True gives every row.""" - return run(['diff', graph_a, graph_b] + (['--file', file] if file else []) + (['--lang', lang] if lang else []) + ['--limit', str(limit)] + (['--json'] if as_json else [])) +def tests() -> str: + """The tests your uncommitted edits reach, each with its code, and the command that runs exactly those.""" + return plain(run(['tests', os.getcwd()])) if __name__ == '__main__': # catch up on whatever changed while no session was running (#1305): started, never waited on diff --git a/plugins/axiomcode/rules/axiomcode.mdc b/plugins/axiomcode/rules/axiomcode.mdc index 7762608c..d77c4ea5 100644 --- a/plugins/axiomcode/rules/axiomcode.mdc +++ b/plugins/axiomcode/rules/axiomcode.mdc @@ -5,26 +5,21 @@ alwaysApply: true # axiomcode -For any why, what or where question about code — how it works, where something lives, who calls it, -what a change breaks, which tests an edit reaches, whether something is safe to delete — ask the -repository's call graph FIRST, through the `axiomcode_*` MCP tools: +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: - axiomcode_context where the work is, when you have a task in words and no name yet; for "how does X - work", explain=True, source=True (and from_=) returns the call - flow with each step's code - axiomcode_impact what a change reaches: must-change-with-it, users, tests - axiomcode_path how A reaches B, each hop verified - axiomcode_changed which declarations an edit changed, and how - axiomcode_test_impact which tests the edit in front of you has to run - axiomcode_index build the graph, when .axiomcode/out/graph.sqlite is absent - axiomcode_graph draw the graph as one interactive HTML page, for a person - axiomcode_diff what changed between two graphs of one tree (before/after), by name and line + 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 -**Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again -in the graph (the `verified:` line) — do not re-derive it by grepping. Every answer ends with `next:`, the -one step to take. For a CHANGE, read only the lines you will cite or change. To EXPLAIN how something -works, the graph gives the reading order: answer from the flow's code, and read further only where a step's -body was cut or a `⚠` marks a call the graph lost. -`[by name]` / `[text]` rows are leads, not facts. An unresolved call means *unknown*, not *absent*. +Without the tools, the same from the shell: `axiomcode find ""`, `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 +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` / +`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. diff --git a/plugins/axiomcode/skills/axiomcode/SKILL.md b/plugins/axiomcode/skills/axiomcode/SKILL.md index 29f06c81..3628a493 100644 --- a/plugins/axiomcode/skills/axiomcode/SKILL.md +++ b/plugins/axiomcode/skills/axiomcode/SKILL.md @@ -1,131 +1,74 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. 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?", "Is this safe to delete?", "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 — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_= when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "" --source`, `axiomcode impact `, `axiomcode path `. 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. 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 -Prefer the MCP tools (`axiomcode_`; in Claude Code, `mcp__plugin_axiomcode_axiomcode__axiomcode_`) when they -are in your tool list; otherwise run `/scripts/axiomcode …` from the repository root. Same code, same -verified output. `` defaults to the current directory. In Claude Code, a hook adds the graph's edges to your own -Read / Grep results as `graph: …` lines. - -**Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line): do not re-derive it by grepping. Each answer ends with `next:` — the one step to take. For a CHANGE (who calls it, what breaks, which tests), read only the lines you will cite or change. To EXPLAIN how something works, the graph gives the reading order, not the explanation: read each step's body, and continue through every `⚠` (a call the graph lost). `[by name]` / `[text]` / `[approx]` rows are leads, not facts. - -**A list of sites comes the way grep prints it.** The MCP `impact`, `path`, `test_impact` and `context` (without -`source` / `explain` / `from_`) answer one site per line: `path:line: [resolved · hop 2 · test …]`, -surest first, capped with a count of the rest; `limit=N` lists more, `full=True` gives the sectioned answer with `next:`. -From the shell the same shape is `--grep` (`--grep-limit N`); without it the answer is the prose. - -## Start here - -| the question in front of you | the call | -|---|---| -| **`.axiomcode/out/graph.sqlite` already exists** | **query it — do NOT run `index`** | -| no graph at all | `axiomcode index` | -| a task in words, no name to ask about yet | `axiomcode context ""` — then `--in ` it names | -| "who calls X" / "what breaks if X changes" | `axiomcode impact X` | -| "who writes this field" / "is it safe under concurrent access" | `axiomcode impact .` — ask of the FIELD | -| one concept you can name ("the decryption code") | `axiomcode path decrypt '*'` | -| "how does X work" · "explain / walk through X" | `axiomcode context "" --source` — the call flow in order with each step's code; answer from it, and open a file only for a step whose body was cut or a `⚠` call. `--from ` when you know where it begins | -| "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | -| "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | -| "is it safe to delete X" | `axiomcode impact X --delete` | -| what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with, or drawn as it is with `--no-refresh`; prints the page's absolute path) · `axiomcode install` | - -Rules that decide whether an answer means anything: - -- **Never re-run `index` on an existing graph** "to make sure" or after your own edit. The graph refreshes itself in - the background after edits, with the flags it was built with. A query does not wait for it: it answers from the last - graph, names the edited files on a `graph refresh:` line, and marks every row that lies in one `(may be out of date)` - (`"stale": true` in `--json`); unmarked rows are current. Read a marked row's file for its current text. It waits - briefly on its own only when the answer touches an edited file and the rebuild is nearly done. -- **Before a delete or a rename, ask with `--fresh`** (MCP `impact`, `path` or `context` with `fresh=True`): it waits for the rebuild, printing its - progress, and answers from a graph that includes every edit. - A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background - refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. -- **To read without rebuilding, pass `--no-refresh`** on any query verb (MCP `context`, `path`, `impact`, - `changed`, `test_impact`, `graph`: `refresh=false`; `AXIOMCODE_NO_REFRESH=1` for a whole shell): the answer comes - from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. Use it on a graph you built - on purpose (another engine, a measured baseline): without it, a query on a graph that is out of date starts a - background rebuild with this axiomcode's engine, and the answer's FIRST line says so - (`graph refresh: this query started a background rebuild ...`) with the reason. The hooks never rebuild a graph - another axiomcode built; they say so once per session. -- A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls - are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies - resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). -- An unresolved call is *unknown, not absent* — **never report it as "no callers"**. -- Every answer ends with `verified:` and `bound:` (the unresolved calls inside it — a lower bound). A `✗` on - `verified:` means the answer is wrong: report it, do not use it. - -## How certain is each row - -An answer's label is the **worst** rung on its route. Read it before acting on the row. - -| rung | claims | -|---|---| -| `[sound]` / `[resolved]` | an edge the engine resolved: a single-target call, an override, a subtype, a constructor | -| `[one of a set]` · `[dispatch]` | one of a sound target set · an instantiated override reached through its base | -| `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | -| `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | -| `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — not an edge | -| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | -| `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | -| `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | -| `[approx]` | a text match placed in the declaration that holds it (a message it raises, a table in its query, a script or file it runs or reads, through a constant one step), with that declaration's callers; comments, docstrings and tests are never placed. For a name no graph declares and a file no graph reads (`.sh`, `.sql`, templates, config): `impact build.sh`, `context "which code raises 'x'"` | - -Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges -connect, not that a test exercises the change. - -## context — a problem statement, no name yet - -`axiomcode context "" [--in [,]] [--budget N] [--source]`: the files and callables the task's -words land in, nearest first, 12 files by default. Scopes you pass restrict and are combined; a scope it offers -does not restrict. Detail: `reference/context.md`. - -## impact — what a change to a declaration reaches - -`axiomcode impact … [--depth N] [--in ] [--delete] [--why]`. Targets as written in the code: -`Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or -`file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, -`src.util.square` and `src/util#square` are one name. **When you know where the declaration is, target it by `file:line`**: a -bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one. -Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. `--why` (MCP `why=True`) also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact declaration, qualified suffix, simple name, field, type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config -keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. - -## changed · test-impact — from an edit - -`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the -command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base -that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other -files no graph reads are named, with the tests whose text names them; a case directory's or fixture tree's files map to the runner or test that reads them, with its command, never to pytest or JUnit on the fixture itself. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection -and service loaders are invisible. Detail: `reference/changed-and-tests.md`. - -## path — asking the graph - -`axiomcode path [--every] [--in ] [--why]`: one shortest verified chain per target, or why there is none -(with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, -`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. When an endpoint came out as something you did not mean, `--why` (MCP `path`: -`why=True`) adds after the endpoint line how each name was resolved: the step that matched, up to five candidates with -file:line, and why that one won or why the name fell to "nothing named". Detail: `reference/path.md`. - -## diff: two graphs of the same tree - -`axiomcode diff [--file ] [--json]`: what changed between two graphs of one tree, each a -`graph.sqlite` or an indexed directory (copy the tree, index each copy, e.g. before and after an engine change). Call -edges added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config -bindings and symbols, with the call edges per tier. Rows match 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. Use it instead of hand SQL for a -before/after. Detail: `reference/diff.md`. - -A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. +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. + +| 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 ` | +| which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | + +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. + +## What an answer looks like + +A numbered list of places, most relevant first, each with the code of the function it sits in. `→` marks the line +that matters; a short function is shown whole. + + 1. shop/pricing.py:6 [resolved · total] + ```python + 4 def total(prices): + 5 net = sum(prices) + → 6 return net * (1 + vat_rate()) + ``` + verified: ✓ (4 edge(s) looked up again) + +Answer from the code shown; open a file only for a place whose body was cut (`…`). The tag says how sure the place +is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do not re-derive it by grepping; +`one of a set` is one of several real targets; `by name` and `text` are leads, not facts; `test` marks a test; +`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: +`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. + +## path + +How one declaration reaches another: every hop of the call chain, with the code at the line each call is written on. +Example: `path(start="main", end="Ledger.put")`. + +## tests + +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. + +## index + +`axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an +existing graph: the graph rebuilds itself after edits, and an answer given before that finishes says so on a +`graph refresh:` line. ## What it cannot see — say so instead of guessing -Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; -what a decoration turns on (proxy, transaction, cache); code outside `--src`. Each is counted in `bound:`. +Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a +decoration turns on (proxy, transaction, cache). Text search is still right for a string, a comment or a config value. diff --git a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md b/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md deleted file mode 100644 index cb924da2..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/changed-and-tests.md +++ /dev/null @@ -1,135 +0,0 @@ -# changed, test-impact, and the edit hooks - -**Read-only:** `changed` and `test-impact` take `--no-refresh` too (MCP `refresh=false`): when HEAD moved since the -baseline was set they then answer against the baseline as it is instead of starting a rebuild to move it. The edit -hooks never rebuild a graph another axiomcode built (a build stamp naming another engine, other rules or another -IMPACT_VERSION): they keep it and say so once per session; `axiomcode index` or a query without `--no-refresh` -rebuilds it, the query saying so on its first line. - - -`axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: -`signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), -`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer; `variable` for a name a -script's top-level code assigns; a line of several statements or declarations (`a = 1; b = 2`, `int a = 1, b = 2;`, -`a, b = 1, 2`) is compared one statement at a time, so only the one whose own statement changed is named), `type` (a header: name, -extends / implements, type parameters), `removed`, and `added` lines outside any known declaration (listed, not analysed — -nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the -build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two -commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file, `--against-head` the working tree against HEAD (what the -edit hooks ask after a rebase or a pull the baseline has not followed yet, so the commits that came in are not counted as -edits). Each line ends with the target `impact` takes for it: the declaration edited, as `file:line` (a name answers for -every declaration carrying it: eight `main`s, two overloads), and `file:line(param)` for a signature with one parameter -changed. `--impact` runs impact on all of them as one change set. - -The graph's line numbers are in the text it was indexed from, and the text an edit is read against can be a later one (an -edit made before the background refresh caught up, a range). Each declaration is carried onto that text by a line diff, and -one whose own line was rewritten is found again by what it declares, nearest first. A declaration still written elsewhere -in the new text is not `removed`: a moved one is `body` (moved), and one found only by name, or a field whose line went -while it is still assigned, says `may have changed`. Read that as "look at it", not as a verdict. When the graph's rows and -the text it records disagree (a refresh raced an edit), a `note:` says the declarations were placed by name. - -What to pass, and what the answer says when the question cannot be answered the way it was asked: - -| situation | ask | what comes back | -|---|---|---| -| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | -| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | -| after a rebase, a pull, a checkout or a reset | `changed` · `test-impact` | read against the NEW HEAD at once, even before the background refresh has caught up: a `note: the base moved …` line names the move, and what the new commits changed is never counted as your edit. `--range ..HEAD` where the local `` is behind the remote you rebased onto reads from that remote's fork, with a note; name a commit to read exactly from it | -| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | -| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | -| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | -| a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | -| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | -| a file under a case runner's `cases/` (a script beside `cases/` that walks it: `tests/run.py`, `graph/test//run-tests.sh`), a golden named for a case, a rule file under the tree a runner's directory mirrors (`graph//` for `graph/test//`) | (any) | `case data and rules`: the runner's command for that one case, as its usage line spells it (`python3 tests/run.py --lang `), or the whole runner for a rule file; a fixture's own `test_*.py` there is data, never handed to pytest | -| a file in a FIXTURE TREE under a test root, whatever its name (`fixtures/`, `testdata/`, `TestData/`, `src/test/resources/`, a directory of goldens): a directory a runner or a test names by path, one that holds goldens and no test of its own, a project no build around it includes | (any) | `case data for `: the script or the tests that name that path (the file, or the nearest directory above it), with their command (`python3 tests/fast.py --lang python`, `pytest tests/test_report.py`, `mvn test -Dtest=...`); a helper that reads it (a conftest.py, a resource reader) stands for the tests beside it. Never a pytest or JUnit line on the fixture, never a test named like the file. `changed` says `case data (...): read by ; run ; an input, not a test to run` | -| a data file whose file name other files share (`case.json`, `settings.json`) | (any) | that name is no test-name match: only its path (two parts or more) is looked for in test text | - -**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (``), so it is never -the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a -field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body` -change, named by where it is, `module.` or `Owner.method.`, and its target is `file:line`; that -name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged -header is never reported as a parameter change. `impact :` on a field, a property, a constant or a type -header line answers for that declaration; a callable written on the line still wins. - -`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a -program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts -it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is -a lower bound for it. - -Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports -against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact -agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining -disagreement was read in the diff: the scanner charges an `@Override` line above an *added* method to `` where this -names the method; an anonymous class added inside a method body is "that method's body changed" here (the scanner names -the new anonymous methods from the fixed tree); a renamed method is reported under its OLD name (what callers reference); a -new nested type is named as well as its members; one miss stands — a method extracted from an existing body whose header -lands in a replaced region. Nothing in the tool's answers was bent toward the benchmark: where the two differ, the diff -was the judge. - -The plugin's hooks do this without being asked, at every moment an edit can happen (`hooks/enrich.py`, `hooks/changes.py`): -**PreToolUse on Edit / Write / MultiEdit** applies the edit to a copy and, when it changes a signature, a field's type, a type -header or removes a declaration, gives the blast radius *before* the file changes; **PostToolUse on Edit / Write / MultiEdit** -reports every changed declaration after it lands (a body-only edit included); **PostToolUse on Bash** re-reads the working -tree after a command that can modify sources (`sed -i`, `patch`, `git apply / checkout / pull / merge / stash pop`, a redirect -into a source file, a script run); **UserPromptSubmit** is the safety net — whatever changed the tree since the graph's commit -by any means and was not reported yet. Each declaration is reported once per session; each report is `changed` (which -declaration, how) and `impact` (up to three declarations in parallel, a few lines each: what must change with it — for a -signature, a field, a type or a removal —, who produces or writes it, who reads it, how many callables and tests reach it, -the unresolved-call bound). That is where the agent that changed `String zipCode` to `Integer` is told, before the edit -lands, about the five `getZipCode().length()` uses in another service, the generated constructor call in a controller, and -the four repositories that deserialize a holder. - -**One edit, not the branch.** The PostToolUse report compares the file just before the tool call with the file after it (the host's `originalFile`, else the PreToolUse copy, else the edit undone), never with the baseline, so a rebase or a pull the refresher has not caught up with does not turn upstream's changes into "this edit changed". When HEAD moves, the next report says so once: `graph: the base moved: HEAD is …, was … (N commit(s) it did not have)`. - -**Does it find what it says it finds?** `tests/run.py` at the repository root: a synthetic project per behaviour under -`tests/cases///`, each with the claim it checks, what must appear in the answer and what must not. It -covers the shapes that used to be answered wrongly: a `this.field` write in an unrelated class, an enum member against a -nested type of the same name, an overload written by its parameter type (`Store.get(String)`), a Java text block and a -JavaScript regex literal, `holds` scoped to the declaring type, a subtype contract where the engine emits no override -rows, a Python `@property` as a private field's door, a house decorator that wraps `dataclass`, and a local variable -that must not carry the method's blast radius. Java, Python, TypeScript, JavaScript and C#. - -**Is what the hooks put in context true?** `hooks/validate.py ` generates events (Reads of whole files and ranges, Greps of -declared identifiers, edits that change a body, a signature, a field's type — before and after landing) or replays recorded -ones (every hook block is logged in full with its input in `.axiomcode/hooks.jsonl`), and checks every stated fact against -`graph.sqlite` and the source: each callable named is declared at that line in that file (or the block says the file changed -since the graph was built — the Read block now says so), each caller / callee named has an edge, each count is the table's, -each changed declaration spans a changed line, each name under must-change / produces / reads is in `impact`'s answer with -that role. On a multi-module Java system 1,036 facts, 0 wrong; on a JVM parser 2,693 facts, 0 wrong — after it found two real errors: an -enum's synthesised `values()` / `valueOf()` listed as callables "at L3", and a field named like its fluent accessor handed to -`impact` without its kind. What the hook cannot vouch for is what the graph cannot: an edge the engine did not resolve is -absent, never wrong, and the `? n` count says how many. - -## test-impact — which tests this edit reaches - -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the -declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the -runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline -rather than for a reader. - -**What it costs and what it saves, measured end to end** on a TypeScript library of 311 source files whose suite is -130 files and 5,193 tests: a one-line body edit to one function → the answer in **0.74 s**, naming 8 files / 503 -tests, and running exactly those took **2.2 s against 17.3 s for the whole suite — 7.9× faster**. Against the -behavioural truth for that method (break it, run the suite, record which files newly fail) the selection contained -**every failing file**, with 2 extra. Over 16 such methods: recall 0.778, precision 0.636, mean 4.1 files of 130. - -**It is a lower bound and the wording says so, because the two questions want opposite things.** For "what must be -looked at again", recall is the product and a wide answer is safe. For "what can CI skip", precision is the product -and a wide answer is worthless — and the same answer cannot be tuned for both: on a Python web framework the -registration-key hop takes recall 0.564 → 0.727 and precision 0.527 → 0.310 at the same time. So the rungs are -reported separately and `--json` carries `certainty` per test, and a pipeline can price them: on the TypeScript -library a `[sound]` route (every hop a single resolved target) was right **29 times in 30**, `[one of a set]` 1 in 8, -`[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and -about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with -the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear -here (the `[text]` tier above recovers the ones whose test names the file it loads). - -A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not -selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the -change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a -framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the -search that finds it, unless a `[by key]` route already joined it. - diff --git a/plugins/axiomcode/skills/axiomcode/reference/context.md b/plugins/axiomcode/skills/axiomcode/reference/context.md deleted file mode 100644 index 742356c7..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/context.md +++ /dev/null @@ -1,69 +0,0 @@ -# context — from a problem statement, when there is no name yet - -Every other verb needs a name you already have: a method, a type, a `file:line`. That is the wrong first -question on an unfamiliar repository, and it is where a run gives up — asked once, the word resolved to -nothing usable, the graph never touched again. - -``` -axiomcode context "" [] [--in ] [--budget N] [--source] - [--explain | --no-explain] [--from ]… [--no-refresh] -``` - -**Read-only:** `--no-refresh` (MCP `context`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. 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 this axiomcode's engine and -says so on the answer's first line, with the reason. - -Deterministic — no model, no embedding index, no network. The task text is split into content terms -(stopwords dropped, camelCase and snake_case split); every symbol is scored against them — exact name, -prefix, substring, then file path — each weighted by inverse document frequency over the graph's own -vocabulary, so a rare term outweighs a common one. A test or benchmark declaration is demoted, not -dropped. The best seed per term is kept, so a multi-concept task gets several entry points; the closure -is walked from those seeds and ranked by nearest hop, then by how many of the task's terms the file -matches — not by how many methods it happens to contain. - -`--budget N` is how many files are listed (12 by default). The ranking does not depend on it: a larger -budget only shows more of the same tail, and the footer always says how many were withheld. - -`--in` is **repeatable and takes a list**: `--in a --in b` or `--in a,b`. A path you supply is knowledge — -a stack frame, the file you just read, the package named in the issue — so it does restrict the answer; -several are **combined, not intersected**, which is what makes a change spanning two roots answerable in -one call. A scope this program offered comes back marked `--in-offered` and does not restrict at all, -because that one is its guess and not your knowledge. - -It ends by saying what it could not see. A partial list that reads as complete is what turns a five-file -change into a one-file patch. - -## How something works: the call flow - -A task that asks how something works ("how does …", "explain …", "walk through …", "what happens when …", or -`--explain`) also gets the call flow. It starts at `--from ` (repeatable) when you know where the mechanism -begins, and otherwise at the entry points above. The steps are chosen breadth-first, so the entry point's own -calls come before any call of a call, and they print as a tree in the order the calls are written. Each step shows -its edge's certainty (`→` resolved, `⇢` one of a set) and the line that makes the call. A `⚠` marks a call in the -step's body that the graph could not resolve, when the project declares that name, so the reader continues -through it instead of stopping. A one-of-a-set site with many candidates is not a step. - -Where the flow leaves the graph it says so on that step, rather than ending silently: `⚠ leaves the graph: Send() L11` -for a library call that hands the work on (send, publish, dispatch, persist, execute, a client stub's `…Async`), and -`⚠ no body in the graph (interface/abstract)` for a step with no code to follow, naming the mapper XML statement bound -to it when there is one. Read from there by hand; a leaf whose library calls hand nothing on is not marked. - -## What the question names - -Entry points start with what the question NAMES: a declaration it spells out (`IRouter.RouteAsync`, `loadByNumber`) -and a route it quotes (`GET /api/widgets`, at the handler registered for it). Then the symbols matching several of -its terms together, then each term left over. Words about code rather than about the subject (`code`, `tests`, -`call`) take no seed, and an inflected word (`validated`) meets the declaration (`Validate`, `…Validator`). - -A file, directory or language the question names that no graph here holds is said FIRST, as -`not indexed: () -- this answer cannot see it; grep it directly`, and `next:` points at it. In a -repository with a graph per language, a question naming one language is answered by that graph alone. - -A question about SQL, configuration or templates lists the text files that name the declarations found (a MyBatis -mapper XML whose namespace is the declaring type ranks first), marked as text bindings, not call paths. `--in` on -a directory that holds no source (`src/main/resources`) is accepted: it says so and lists what under it binds. - -With `--source`, the earliest steps carry their code within a budget and the later ones are named only, so the -answer comes back on one page. Answer from that code, and open a file only for a step whose body was cut or at a -`⚠`. A question that does not ask how something works gets the ranked answer above, unchanged. diff --git a/plugins/axiomcode/skills/axiomcode/reference/diff.md b/plugins/axiomcode/skills/axiomcode/reference/diff.md deleted file mode 100644 index ef2e356b..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/diff.md +++ /dev/null @@ -1,68 +0,0 @@ -# diff: what changed between two graphs of one tree - -```sh -axiomcode diff [--file ] [--lang ] [--limit N] [--json] -``` - -Each side is a `graph.sqlite`, or a directory holding one: an indexed repository (every language graph under its -`.axiomcode` is compared, paired by language) or an `out` directory. Neither graph is rebuilt or refreshed. - -## The before/after recipe - -```sh -rsync -a --exclude .axiomcode / /tmp/before/ ; rsync -a --exclude .axiomcode / /tmp/after/ -AXIOMCODE_ENGINE= axiomcode index /tmp/before --lang python -AXIOMCODE_ENGINE= axiomcode index /tmp/after --lang python -cp /tmp/before/.axiomcode/out/graph.sqlite /tmp/before.sqlite # a later query may refresh a graph with another engine -cp /tmp/after/.axiomcode/out/graph.sqlite /tmp/after.sqlite -axiomcode diff /tmp/before.sqlite /tmp/after.sqlite -``` - -Copy each graph out right after its index: a query on a graph built by another engine starts a background rebuild -with the installed one, which overwrites the graph under test. The diff itself never does. - -## How rows are matched - -By what stays the same when one tree is indexed at another path, never by id: an id hashes the index directory, so -two indexes of one tree share none, and a join on ids says everything changed. - -| kind | matched on | -|---|---| -| call edge | the site (file, line, column, caller's qualified name) and the callee (qualified name and file:line, or the label a library callee carries, `external:…`, `builtin:…`) | -| entry point | the method (qualified name, file:line) and the reason | -| reachable | the method | -| remote edge | transport, destination, sender, handler, confidence | -| framework edge (Python) | mechanism, name, from, to, certainty | -| config binding (Java) | key, mechanism, target kind, target (a parameter is named by its owner type) | -| symbol | kind, qualified name, file, line; the same declaration with another signature is a `~` row (a parameter added, a type changed) | - -Absolute paths (Java's `methods`, `call_sites`) are made relative to the tree each graph was built from, so the same -tree indexed at two paths, by the same engine, diffs to nothing. An edit that moves lines moves every row below it: -compare graphs of one tree, not of two commits. - -## Reading the answer - -``` -python: A /tmp/before/.axiomcode/out/python/graph.sqlite (engine 637532ae) - B /tmp/after/.axiomcode/out/python/graph.sqlite (engine 37466bd6) -summary: call edges +0 -0 ~0 >18 · entry points +0 -0 · reachable from an entry point +5 -0 · … · symbols +0 -0 ~0 -call edges per tier: ambiguous_unknown 10152 -> 10134 (-18) · known_edge 2504 -> 2522 (+18) · boundary_lib 4721 (=) · … - -call edges (…): - + file:line:col Caller -> Callee [tier, kind] a site that had no edge, or a new callee at a new site - - file:line:col Caller -> Callee [tier, kind] the reverse - ~ file:line:col Caller -> Callee a/kind => b/kind the same callee at another tier or call kind - > file:line:col Caller a site whose callees changed: the old set, then the new - - Callee [tier, kind] - + Callee [tier, kind] -``` - -The summary counts are of the whole diff; `--limit N` caps the rows per section (default 40, 0 for all). -`--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. `--json` prints every row with the same counts, under -`languages..{counts, tiers, calls, entry_points, reachable, remote, framework, config, symbols}`. - -## Not compared - -`refs`, `literals`, `type_use`, `field_access` and the `ext_*` diagnostics other than the four above: open both -graphs with `reference/schema.md` for those. A language in only one of the two is named and skipped. diff --git a/plugins/axiomcode/skills/axiomcode/reference/impact.md b/plugins/axiomcode/skills/axiomcode/reference/impact.md deleted file mode 100644 index 026784d9..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/impact.md +++ /dev/null @@ -1,322 +0,0 @@ -# impact — what a change to a declaration reaches - -The full rules behind `axiomcode impact`. `SKILL.md` has the calling convention and an example; this is why each row says what it says, and what it is measured at. - -**Read-only:** `--no-refresh` (MCP `impact`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. 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 this axiomcode's engine and -says so on the answer's first line, with the reason. - - -`axiomcode impact `, the target written as it appears in the code and its kind read from the index, never guessed: -`Owner.method` · `method` · `file.java:123` (a method), `Owner.field` · `CONSTANT` · `Enum.MEMBER` (a field), `Type` (a class / -interface / enum), `Owner.method(param)` (one parameter), `Type` · `Owner.method` (a type parameter — a generic, or a -bound on it), `Owner.method:name` (a local), `Type.` (its construction) / `Type.` (its static initialization: whoever -first uses the type). Several targets in one call are one change set. A name declared as more than one kind stops and asks for -`--kind`. The same answer shape for every kind and language: - -Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, -extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the -line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's -hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode -index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when -there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a -caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. -Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and -the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: -every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). - -- **a configuration key is a target** — `axiomcode impact server.error.path`: the methods the container binds it into - (`@Value`, `@ConfigurationProperties`, a `.yml` / `.properties` key), from the engine's framework facts, then everything - that reaches them. No call site carries these edges, so nothing else finds them. A key the engine never saw **stops with - that sentence** — its impact is unknown, not empty — and a graph with no configuration facts at all says so; a key is - never answered as a by-name match on code, which is what made a wrong answer look like an answer. -- **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the - container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it - over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. - A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` - or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected - with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). -- **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / - `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a - `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed - as `[framework]`, with the mechanism, what joined the ends and the engine's confidence: `framework-mediated, not a call: - task_dispatch via delay [registered]`. It ranks below `[remote]` and above every name match, and like `[remote]` it is a - direct row that does not seed the closure. An unrelated method that shares the name (`Animation.delay`) gains nothing. -- **a handler nothing calls is still used** — a declaration handed over as a *value* (`app.get('/orders/:id', getOrder)`, - `background.add_task(send_receipt, id)`, `setTimeout(flush, 1000)`, `handlers = {"x": handle_x}`) has no call site - anywhere: the call happens inside the framework, or later, or never. Every other rule here is about call sites, so this - used to answer *"the declaration is used only where it is declared"* — and `--delete` said **no dependent at any - certainty** — for a live HTTP handler. The reference the parser recorded is read instead, and the site says what will do - the calling: `registered as a GET route "/orders/:id" here — the router calls it, no call site does` when the call is a - route registration (a router verb *and* a string argument that begins with `/` — `get`/`set`/`delete` alone are Map, Set, - Headers and every cache in this ecosystem, so the verb is never matched by itself), otherwise `handed to add_task(…) as a - callback`. It is `[by name]`: the parser says the identifier binds to a callable, not that it binds to *this* one. - Where the engine already resolved the registration to an edge — a JavaScript `app.get('/pads', listPads)` is a resolved - call in that engine — the row stays `[resolved]` and only the sentence changes, so the reader learns that what they are - changing is `GET /pads` rather than that some module calls it. **JavaScript gets the wording and no name-matched rows:** - its `refs` carry the access mode (`IDENTIFIER|READ`) and no entity kind, so nothing there distinguishes a reference to the - declaration from a parameter of the same name. A site-keyed version was written for it and measured on a 124-file Express - application: eleven rows over 30 sampled targets, and all eleven were wrong (seven a parameter named `callback` inside - `forEach(function (callback) {…})`, four a `settle` being *called* inside the `.then(…)` span it sits in). It is not - shipped. The rule needs the parser to say that an identifier binds to a callable, which TypeScript, Python and Java do - and JavaScript does not. -- **must change with it** — declarations bound to the target by a contract the engine resolved: the overrides of a method (and what - it overrides), the subtypes of a type. A signature change reaches these first. -### What breaks a build, and what does not - -The sections are relations, not severities, and reading them top-down as "most to least urgent" is wrong. -Nothing under `produces or writes it` necessarily fails a build: those rows are dataflow — who makes a value of -this shape, including deserialization that writes it reflectively. A `[text]` row under `bound from outside the -source` can never fail a build; the compiler does not read that file at all, which is exactly why it is printed -last and says so. - -For a field, the rows that stop a build are usually in neither list. Changing a field's TYPE changes the -signature of whatever is generated from it — an all-args constructor, a setter, a copy/`with` — and it is the -callers of THOSE that break, at the argument they pass. They touch the generated member, not the field, so no -rule puts them under the field's own relations. The answer now says this directly under the generated-members -line and names the constructor query to run; take that suggestion before acting on the first list. - -- **produces or writes it** — the blast radius read top-down starts where a value of the new shape has to be *made*: setter and - builder calls, constructor calls (declared or generated), and the **holders** — a type with a field of the target's type, where - that holder is constructed or deserialized (`Holder.class` handed to a deserializer or a framework: reflection produces the - field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. -- **why nothing in the graph calls it**: printed where no production caller was found: every reason, strongest first, from - the one reader the hooks' `← ?` label and path's empty-upstream note use (`graph_sql.no_caller_reasons`): an entry point, a - test, a decoration that registers it under a key, a decoration a framework reads (a wrapper such as a cache, a permission - check or a decorator the repository declares is never one), a library method it overrides, the call sites that write its - name on an untyped receiver, a library base of its type, a decoration on its type. `next:` follows the same order. -- **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, - names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call - — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against - the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the - base class)`, and `[resolved]` only when nothing else can run there; the Read and Grep hooks count the same callers); `[in scope]` a reference by - that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was - not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no - owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java - type references in signatures), comments and strings stripped. A bare name inside a type that declares its own member of that - name is that member, not the target; a qualified `X.name` is confirmed when `X` is the owner and dropped when `X` is another - type. For a field, a **declared accessor** in the owner (`getF` / `setF` / `isF` / `f()`) is its door: the accessor's callers are - listed as reading or writing the field through it. A **generating decoration** — Lombok `@Data` / `@Getter` / `@Setter` / - `@Value` / `@Builder` / `@AllArgsConstructor` / `@With`, a record, a dataclass — declares members the source never spells, so a - call to `getZipCode()` or `new Address(…)` is an unresolved site; the unresolved sites written with the generated name are listed - as calling the generated getter / setter / constructor `[by name]`, with the decoration that generates it. Where the ENGINE - synthesises the member instead of leaving the site unresolved (Java's Lombok and records, C#'s auto-properties: a `methods` - row with provenance `generated`), the call site resolves to it and the caller is named `[resolved]` — *reads it through - getName()* — which is the same answer with a stronger claim behind it. A string literal - equal to the field's name (a map key, a serialized name, a request parameter) is listed `[text]`. -- **the upstream answer is measured against behaviour, not against itself** — `validate/upstream.py ` takes a tree with - `.axiomcode/mutation.json` (a method broken, the test files that then failed), asks `impact --tests` which test files - reach it, and classifies every miss from the graph. a JVM HTML parser, 24 methods, 244 (method, test file) pairs: recall 0.795 → **0.988**, - precision 0.328 → 0.338, after the three rules the misses named — a test class that *extends* a reached one runs its tests - (its HTTP-client test classes declare almost nothing: 20 of the 27 misses), a call site written with the target's name - that the engine could not resolve (`import static Outer.Inner` left `res.prepareResponse(…)` untyped: 8 more), and a test - file's import-time code (a class body, a fixture). a Python validation library, 14 methods, 180 pairs: 0.678 → 0.717 — what remains - is dispatch a static graph cannot see (`__eq__` and the other protocol methods the interpreter calls, a method reached - through `getattr(self, f"_{kind}_schema")`), and the answer now says that instead of printing nothing. A TypeScript web framework - (16 methods, 96 pairs, `validate/mutants.py` builds the truth: break a method, run the suite, record - which test FILES newly fail): 0.000 → 0.790. It was zero because a vitest test is an anonymous callback handed to - `it(…)` — 6,661 of its 7,723 callables in test files are `` and two carried a name the old rule accepted, - so the test universe was empty and every answer named no test file at all. A callable registered by `it` / `test` / - `bench` on its own line is a test, and a helper declared beside them carries them. The PARAMETERISED form needs the - call site rather than the line: `test.each` + a template table writes the arrow after the closing backtick, on a - line naming no registrar at all (18 of them in that library), so a callable inside the span of a `TAGGED_TEMPLATE_CALL` - to `each` — its first line read to confirm the receiver the call site does not carry — is a test too. That shape is - vitest / jest / mocha's alone: a pytest test is found by its name however deep the decorator stack, so nothing there - depends on which line the registrar is written on. - **What a selection costs and buys, on that same TypeScript library, measured again with the rungs separated** (a - fresh clone, 311 source files, 130 test files, 5,193 tests in 17 s; 16 methods broken one at a time, 54 (method, - test file) pairs of behavioural truth): recall **0.778**, precision 0.636, and the answer names **4.1 test files of - 130** for a change — 3 % of the suite. Per rung, against that truth: a `[sound]` route (every hop a single resolved - target) is right **29 times in 30**; `[one of a set]` is right 1 in 8; `[by name]` 0 in 1. By distance: 1 hop 0.667, - 2 hops 0.900, 3 or more 1.000 — the far pairs are few and all real. So a pipeline that runs the sound rung first is - almost never wasting a run, and the waste is concentrated in exactly one rung, which is why the rungs are reported - separately rather than blended. Every remaining miss is `no-edge` — a handler the graph has no resolved caller for - (an adapter, a JSX intrinsic element) — not a rule this tool could tighten. - A library is not a service, and the number differs by population: on a Python SERVICE driven through its frameworks - (two Python web frameworks + CLI routes, a pytest suite with conftest fixtures, a decorator registry, a signal loop, 53 - functions broken one at a time, 95 (method, test file) pairs) recall was **0.216** — 26 of 40 answers named no test - file at all — because the suite reaches the code the way the outside world does: through the framework. The - registration-key hop and the injected-fixture rules take it to **0.695** at precision 0.930, and what is still - missing is named rather than guessed: a function reached only through a table or list of functions dispatched by - index (`TRANSFORMS = [strip, upper]`, `EXPORTERS[kind](x)`), a decorator that wraps a callable in an object whose - method calls it (`@shared_task` … `.delay()`), and a closure defined in one method and returned to another. - Held out, on a subject nothing was tuned against (the Python web framework's own 491-test suite, 40 functions broken, 172 pairs): - 0.564 → **0.727**, precision 0.527 → 0.310. Both halves of that trade are real and neither is free — the recall is - routes and fixtures the answer could not see before; the precision is the fan-in of a framework whose every test - builds an app. A key that identifies MANY declarations identifies none: the Python web framework's own suite registers `"/"` from 236 - places and asks for it from 200 more, so a key registering more than `AXIOMCODE_KEY_CAP` (4) declarations is - REFUSED rather than joined — the engine's `fan_capped` judgement one layer up. Uncapped that subject reads 0.791 - recall at 0.248 precision. The same cap applies to the other side (`AXIOMCODE_KEY_USE_CAP`, 4): on the JVM parser the keys - that survive the registration cap are `p`, `b`, `table`, `em` — HTML tag names, written by 356 callables and - "registered" by two, because a decoration argument is not always a registration (`@ValueSource(strings = {"p"})` - is test DATA). One Java method went from naming 1 test file to naming 61 until that cap was added, and 6 after it. - Neither cap needs a catalogue of which decorations register and which do not, which is the point of them. - **A cap and a kind guard answer different questions, and the second is invisible to the first.** A cap says *this - key is too wide to mean anything*; it cannot say *this was never a dispatch key at all*. A test's own decoration - carries its INPUTS — `@ValueSource(strings = {"/htmltests/large.html"})`, `@CsvSource`, `@pytest.mark.parametrize` - — one declaration, a handful of writers, under every cap, and entirely meaningless as a key; and a route mounted - inside a test file is a fixture, not the application's dispatch table (on one TypeScript router library **every** - route registration line, 6,128 of 6,128, is in a test file). So a decoration on a test declaration is not read as - a registration at all, and a route registered in a test file keeps its dependent row and its sentence but is given - no joinable key. -- **precision is not a bug to fix, it is a property to report** — `validate/precision.py ` places every predicted - (method, test file) pair by the worst hop on its best route and by distance, against the same truth. On the JVM parser: a route of - single-target resolved calls is right 0.765 of the time, one through a call resolved to a SET 0.301, through an override - reached from its base 0.170; within 3 hops 0.70, beyond 5 hops 0.24; a sound route within 3 hops 0.889 — but that keeps - only 48 of 219 true pairs. The split that explains the 0.34 overall is fan-in, not error: 10 of the 24 methods are hubs - every test reaches (it parses HTML in every suite) — those answers are 60 of 98 test files at precision 0.285 with - recall 1.000, while the 14 narrow methods score 0.642 with 7 of them exactly right. A test that *reaches* a change and - does not fail is not a wrong edge: it runs the code and does not observe the change. So `--tests` answers "which tests - CAN observe this" and says how sure each route is (`[sound]`, `[one of a set]`, `[dispatch]`, `[by name]`, nearest and - surest first) and, when most of the suite reaches the method, that at this fan-in reaching says little about failing. - It is a ranking, not a test selection; a narrow answer can be used as one. -- **reaches those through resolved calls** — the transitive impact: everything that can reach a touched callable, by hop and by - file, with the entry points among the reached callables *and* the direct dependents (a `@PostMapping` handler that reads the - field is where the change is observed from, though nothing resolved calls it). The tests are always counted by rung, with - the strong-route ones (`[sound]`, `[one of a set]`) named and the top test files; `--tests` lists every one by rung and test - file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change (and, under each `change:` line, how the target name was resolved: - the lookup step, the declarations weighed with file:line, and why that one won or why nothing matched; `--json` gains a - `why` list), and `--tests-in ` narrows - the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k - characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when - its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static - initializer, `@Before*`, `@After*`, `setUp`, `tearDown`, MSTest's `[TestInitialize]` / `[TestCleanup]`: a convention table, - printed as such; a teardown that throws fails the test too), **or it names the key the change is registered under** (below). - A `test*` method that overrides a supertype's (a `TestWatcher`'s `testFailed`) is a callback, not a test. - `--in ` and `--depth N` bound it; `--json` is the same answer as data. -- **a registration key is a hop** — a route handler, a signal receiver, a CLI command and a table entry are one shape: the - declaration is registered under a STRING and whoever wants it writes that string, not its name. `@router.post("/orders")` - and `client.post("/orders")`; `@receiver("order_created")` and `emit("order_created", …)`; `@cli.command("price")` and - `invoke(cli, ["price", "4"])`; `@exporter("csv")` and `export(order, "csv")`; a a Python web framework `add_url_rule("/quote/", - view_func=legacy_quote)`, where the declaration is handed over as a value and no call site names it at all. Both ends are - in the graph and nothing joined them, so a test that drove the app through its framework reached nothing — which is most - of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against - `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never - shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. Only what the decoration registers - under is a key: a positional string, or a keyword that names it (`path=`, `name=`, `topics=`, `queues=` ...). A configuring - keyword (`mode="before"`, `methods=["GET"]`), a suppression (`@SuppressWarnings("unchecked")`) and a string naming a member - of a type the same decoration names (`@SelectProvider(type = Sql.class, method = "byShelf")`) are not keys. -- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, - `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine - resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver - is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in - `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body - change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; - it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call - in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the - class under test with a mock injected still counts through the class under test: the graph cannot see which object - is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a - `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` - route joins them. -- **a test that runs a script as a child process is a hop** — `execFileSync(node, [path.join(__dirname, '..', 'bin', - 'cli.js')])`, `spawn(process.execPath, [require.resolve('../bin/tool')])`, `subprocess.run([sys.executable, SCRIPT])` - with `SCRIPT = os.path.join(HERE, '..', 'scripts', 'report.py')`: the script's module body runs in another process, and - no call site or import says so. When a call that starts a process names, among its arguments, a file this graph indexed - (a literal, a join of literals, or a constant holding one), the test — or the helper beside the tests that makes the - call — is joined to that file's module entry, so everything the script reaches gains the test. The hop is `[spawns]`: - a key (the path), not a call. Reading the same path (`fs.readFileSync`, `open`) starts no process and is not joined, - and a file of another language is in no graph of this one, so it is never joined across languages. -- **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what - `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own - resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` - wrapper — retry, cache, login_required, a task — has no caller at all and a change to it reaches nothing. What the - graph still cannot say is the OTHER decorator shape, where the decorator returns an object rather than a function - (`@shared_task` … `.delay()`): there the name denotes an instance, and the engine says so rather than guessing. -- **a fixture the framework injects** — pytest matches a test's PARAMETER NAME against the fixtures visible from its file: - those beside it and those in a `conftest.py` of any ancestor directory, which is not the test's file and is imported by - nothing. A `@pytest.mark.usefixtures` marker names one instead, and an `autouse=True` fixture runs before every test in - its scope without being named anywhere. A fixture may request another fixture, and then both run. None of that is a call. - A route that runs a fixture first is reported as `[fixture]`, and it is the weakest rung above `[by name]`: the - framework does run it and it does reach the change, but the test's own body may never touch it. How often each rung - is right, measured against mutation truth on three Python subjects (`n` is the pairs the rung named, and a rung with - a handful of pairs says nothing — it is printed so you can discount it, not so you can rank on it): - - | rung | small framework service | web framework | CLI library | - |---|---|---|---| - | `[sound]` | 1.000 (n=19) | 0.895 (n=86) | 0.561 (n=132) | - | `[at import]` | 1.000 (n=17) | — | — | - | `[defines]` | — | 1.000 (n=1) | 0.875 (n=8) | - | `[one of a set]` | 1.000 (n=2) | 0.659 (n=44) | 0.657 (n=99) | - | `[by key]` | 0.926 (n=27) | 0.342 (n=73) | 0.000 (n=3) | - | `[decorator by name]` | 1.000 (n=8) | 0.667 (n=3) | — | - | `[protocol]` | 1.000 (n=4) | 0.882 (n=17) | 0.400 (n=5) | - | `[fixture]` | 1.000 (n=27) | 0.382 (n=102) | 0.536 (n=112) | - | `[by name]` | 0.333 (n=3) | 0.531 (n=32) | 0.475 (n=61) | - - `[protocol]` is the newest row and the one to read carefully: its only substantial sample, 17 pairs on the web framework, - puts it at 0.882 — second to `[sound]` on that subject and well above the two rungs printed ABOVE it. That is not - enough to re-rank a ladder on, for the reason the rest of this paragraph gives, but it is enough that a reader - should not discount a `[protocol]` route for its position. - - And read what a rung CLAIMS, not only how often it holds: `[sound]` means a resolved single-target call chain - within three hops — a fact about the edges — and never that the test exercises the change. `[at import]` is the - one rung that is about the test rather than the edge: the module raised while being imported, the file never - loaded, and the test was never collected, so its body is irrelevant. Read that table before trusting the order - the answer prints. The TOP of the ladder holds: `[sound]` and - `[one of a set]` are the best rungs on the subjects with enough pairs to say. BELOW that the order is not stable - across subjects and the printed ranking is a tie-break of what KIND of evidence a hop is, not a measured ordering: - `[by key]` is the best rung on one subject (0.926) and the worst on another (0.342), and `[by name]` is printed - last while measuring above `[by key]` on both of the two large subjects. An answer's label is still the WORST rung - on its route, so it remains a floor — but a `[by name]` route on a library-shaped codebase is not the near-worthless - thing its position suggests. And `[sound]` at 0.561 on the CLI library is the plainest statement of the whole limit: reaching - is not failing, and on a codebase whose tests drive one hub, a resolved call within three hops is right barely more - than half the time. A test - reached BOTH by its own body and through a fixture is reported as the body: the same distance, the stronger claim, - and it moves 37 of the CLI library's pairs off the fixture rung. And what the rules add is a POPULATION effect, not a general - one — on a third held-out subject (a CLI library, 2,058 tests, 40 functions, 293 pairs) they move four - targets and carry 0.802 recall at 0.566 precision, against 0.792 / 0.569 with every framework hop turned off, - because its tests reach its code by CALLING it. The framework hops pay where a framework is in between and very - nearly cancel where it is not: on the CLI library the decorator hop alone adds 3 true pairs and 4 false ones. -- **verified** — every printed edge looked up again in the graph; **bound** counts the unresolved calls inside the impacted - set, so the set is a lower bound on the real one; a **note** counts the entries matched by name or text. - -`--delete` adds a verdict: **is it safe to delete** — the callers and contracts that say no, or, when there are none, exactly -what the graph cannot vouch for (by-name matches, string literals equal to the name — a reflective call, a bean name, a config -key —, the decorations a framework may dispatch on, the unresolved calls inside, the tests that reach it). With **several -targets** (a PR touching many files) each row says which target it came from — `[for Owner.method]` — so a combined radius is -still attributable per change. - -**The unit of change is a declaration in the graph, and half of real Java commits change something else** (592 commits over -five projects: 47 % touch no Java file at all, 30 % touch Java plus a build or resource file). Three of those kinds now have a -target of their own: `@Transactional` (an annotation — every declaration carrying it, and their dependents), `Enum.` (a -constant that does not exist yet — the switches that need a new arm), and a configuration key. A method target also reports -its **throws** contract: adding a checked exception reaches *every* resolved caller, and the answer says how many of them -already catch or declare the ones it has. Still outside the unit, and said rather than guessed: a build file or a dependency -bump, an added overload's rebinding of existing call sites, and what a framework does with an annotation (the proxy, the -transaction, the cache) — `changed` says that in the same line as the decoration change. - -What it cannot see, by construction — say so instead of guessing: a callable that touches a type only through a value it never -names (`t.asStartTag().normalName()` where the engine resolved `normalName` to the inherited `Tag.normalName`) — the graph keeps -no receiver type at a call site, so the compiler sees that dependency and this tool does not; the `[one of a set]` callers are -the engine's over-approximation and most of them will not compile against the change; a bound change on a type parameter -reaches the sites that instantiate `Type<…>`, listed, but nothing checks the argument against the bound; the transitive layer -is the call graph's, so everything `path` cannot find (callbacks handed to a library, reflection, framework dispatch) is a -missing chain here too and is counted in `bound:`, never guessed. What a **decoration turns on** is not in the graph either — -`changed` reports `@Transactional` / `@Cacheable` / a route as a decoration change and says in the same line that the proxying, -the transaction or the cache behind it is invisible; only the code that names it is. Still **not expressible today**, and said -so rather than answered: which `switch` arms an added enum constant breaks, who must catch an added `throws`, which call sites -an added overload rebinds (no argument types per call site), and what a dependency bump reaches (one graph, no library diff). -Test selection from a body change is sound but wide — 41–87 % of a suite on a hub graph — because every path through the hub -is real; narrowing it is ranking, not reachability, and is not attempted here. - -Measured two ways, Java first. (1) A Java defect benchmark: the methods each fix changed as the change set, `--tests` against the tests -it observed failing on the buggy tree — 273 bugs of 17 projects, every triggering test found in 266, trigger recall 0.929, -mean selection 50 % of the suite, and the same verdict as the benchmark's own independent reading of the same graphs in 252 of -256 bugs (better in 3, worse in 1 — a method the fix *added*, absent from the buggy tree); every remaining miss is an engine gap -(an overload set, a callback through `Function.apply`), not a tool loss. (2) The compiler: on five of those projects, 412 sampled -declarations, one edit each — rename a field, a method (all its overloads), a type (plus an empty stub with the old name, so member -uses fail too), a type parameter; remove a parameter — and `javac` over the whole tree names the dependents. Recall: fields 0.997, -methods 1.000, types 0.962, parameters 0.944, type parameters 0.977. Precision by certainty, all kinds: `[resolved]` 528/585, -`[in scope]` 249/256, `[text]` 683/773, `[by name]` 157/291, `[one of a set]` 126/351, contract 69/144 (the compiler confirms -only the override direction that breaks). The harnesses are `impact-arena.py` and `oracle-b.py` next to the arena. (3) By hand, on a -multi-module Spring / SOFA-RPC / Lombok `@Data` system where every model is generated accessors: a `String zipCode` field on a -shared `Address` → the three places an `Integer` breaks (the owner's formatter, the five `getZipCode().length()` / `.trim()` uses -in another service, the generated all-args constructor call in a web controller) and nothing else; a facade method called -through `@SofaReference` fields in two other services → the override, the three callers, the three REST entry points; an enum -member → its one use, with `PaymentStatus.PENDING` and `ShipmentStatus.PENDING` correctly excluded; a shared value type → all six -files, including a chained `product.getPrice().getAmount()` a grep for the type cannot see; a field with declared accessors → -every accessor caller across three services plus the `"stockQuantity"` map key. Other languages share every code path except -the static-import rule (Java syntax) and are not yet measured. - diff --git a/plugins/axiomcode/skills/axiomcode/reference/path.md b/plugins/axiomcode/skills/axiomcode/reference/path.md deleted file mode 100644 index e20eeeef..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/path.md +++ /dev/null @@ -1,130 +0,0 @@ -# path — the endpoint grammar and what it cannot find - -**Read-only:** `--no-refresh` (MCP `path`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. 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 this axiomcode's engine and -says so on the answer's first line, with the reason. - - -- **Start here when you do not have a name yet.** A bare word — one that names nothing exactly, with `'*'` at the - other end — is every declaration CONTAINING it, listed with the count so a wide word is visibly wide, so - `path decrypt '*'` answers "where is the decryption code and what does it touch" — 12 declarations, what they - reach, by hop and by file — without knowing a single exact name first. `path '*' ` is the same in reverse. - This is the way into an unfamiliar repository: get the real names out of the answer, then ask the precise - question with one of them. There is no separate search verb, and none is needed — a name you half remember stops - with the exact names that are close, which is the same lookup. -- **Endpoints are names as written in the code**, never guesses: `Owner.method`, `Outer.Inner.method`, `method` (a free - function, or that name under any owner), `Type` (every method it declares), `file.ts:123` (the callable at that - line, top-level code included), `file.py` (every method in the file). `Outer$Inner.m`, `Outer.Inner#m`, `m(int,String)` - and package-qualified `pkg.Outer.Inner.m` are the same name; a Java nested type is found whether or not the outer is - written (the parser drops it, #667). A name that does not exist stops with the exact names that are close — use one - of those, or a `file:line` from the issue or a stack trace. Built and self-tested for Java, TypeScript, Python - and C#; JavaScript works but the engine's JavaScript output is still moving. -- **`--why` says how each endpoint name was read** (MCP `path`: `why=True`). A block of at most eight lines per endpoint, - right after the answer's first line (or after the refusal when a name matched nothing): the lookup step that matched, - in the order they are tried (a `file:line`, a decoration, a file, then for a name: exact declaration, qualified suffix - (leading segments dropped when they match nothing, or the last segments of a longer qualified name), simple name, - library method, call as written at unresolved sites, type used by name, fragment), the steps that ran before it and - found nothing, up to five candidates with file:line, and why the winner won or why the name fell to "nothing named" - (a qualifier that is a declared type with no such member, a last segment declared under another owner). Use it when - an endpoint is not the declaration you meant. Without `--why` the answer is unchanged; `--json` gains a `why` list. -- **By default the answer is ONE SHORTEST chain per reached target** — it says so on its last line. Other routes exist - and are not listed. `--every` adds all of them: first the complete set of methods and calls that lie on *any* chain - from a source to a target (from Datalog, polynomial — `301 methods and 935 calls` for `Parser.parse → Lexer.emit`), - by file, then the simple paths through it, shortest first, up to `--paths N` (default 20; the count is exponential, - so the set is the complete answer and the list is a sample of it). The `verified:` line means every hop was looked up - again in the graph and a second, independent traversal found the same length; a `✗` means the answer is wrong — report it, do not use it. -- **Every hop reads `[tier · kind @ file:line]`.** The *tier* is how certain the edge is; the *kind* is what sort of - call it is, in one vocabulary that means the same thing in all five languages (`call` · `new` · `ctor` · `super` · - `decorator` · `property` · `method-ref` · `with` · `import` · `eval` · `dynamic`); and the *line* is where the call - is WRITTEN, which is where you check it — the name after the arrow already tells you the callee, and its own - declaration line follows it. The tiers an answer used are legended beneath it, so none of them has to be looked up: - `known_edge` resolved to one declaration, `multi_inferred` several fit and each is real, `dispatch` a base method to - an override the project instantiates, `callback_registered` handed over as a value and invoked by whoever holds it, - `boundary_lib` / `ambient_terminal` into a dependency or the platform, `defines` **not a call at all** — the callee - is written inside that body, so it runs only after it. The engine emits eleven tiers and thirty kinds across the - five languages and they do not share a vocabulary; `scripts/ax_edges.py` is the single table that normalises them, - and an unrecognised tier ranks LAST there rather than being silently treated as certain. -- **The hop count counts calls.** A chain's header says `7 call(s)` — containment hops (`defines`) are listed - separately (`+2 containment hop(s)`) and excluded, because "A reaches B in 11 calls" is false when five of the - eleven are a closure sitting inside a body. -- **`--json`** gives the same answer as one document — every hop with its tier, kind, call site, callee declaration - and whether it is a call — with the prose carried alongside it, so nothing is lost by asking for the machine shape. -- **No chain is an answer with a bound.** "no chain of resolved calls" is followed by whether unresolved sites *would* - connect the two by name, and at which `file:line` — that is the site to read, not a path to claim. The `bound:` line - counts unresolved calls on the chain shown: other chains may exist that the graph cannot see. -- **A hop no call site makes is a hop of the chain, labelled as one.** A request that crosses a process to the handler - that serves it (`[remote · grpc at (exact) · no call site]`) and a hand-over a framework makes (a Python - `.delay()` and the task it enqueues, a signal `send` and its `@receiver`, a test and the fixture it names, a C# - endpoint filter and the endpoint it wraps: `[framework · via () · no call site]`) - are the same hops `impact` lists as `[remote]` / `[framework]` dependents, and the chain walks them, so a client - reaches what its handler calls and a test what its fixture calls. The count says how many hops are calls - (`1 call(s) + 1 hop(s) no call site makes`) and a note under the chain names each such hop's two ends. `path '*' X` - counts the callers reached this way apart from the exact calls. Every language whose engine writes the two relations - gets them; a hop never joins two languages' graphs. -- **A call into a library is an endpoint too — with or without `--library`.** `path '*' 'new ArrayList'`, - `path '*' Files.readAllBytes`, `path '*' readAllBytes`, `path '*' 'Collections.*'`, `path '*' open`: the name as the parser - wrote it at the call site (kind `new` or method, and the receiver written before it), matched at every unresolved site, - in any language. With `--library` staged the same call is a resolved library method and matches by qualified name. A - client declaration always wins over both. The node has in-edges only — nothing is inferred about the library body — and - the answer says how many sites were matched and where. -- **A type the code uses but does not declare is an endpoint**: `path Foo.run File` — every place `File` is touched, - as one target: `new File` at unresolved sites, the library methods of `java.io.File` when staged, - and the methods whose body references the name where the parser gives a line. The answer says which of those it - matched (Java type references carry no line, so there it is the constructor calls and identifier uses). -- **A decoration is an endpoint**: `path '@GetMapping' 'new File'`, `path '@*Mapping' Files.readAllBytes`, `path '@Test' X`, - `path '@Get' '*'`, `path '@Controller' Svc.load` — every method carrying it, so "from any method with this decoration to X" - is one call. A decoration on the **type** is carried by every method that type declares, which is what the class-level form - of every framework needs (`@RestController`, `@Controller`, `@Injectable`, `@Component`, `@Entity`); on one Spring service that is 78 methods for `@*Mapping` where the method-level rows alone are 29. The decorations come from the index's - decorations table **or, where a front end records a decorator as a call and not as a decoration, from those call sites** — - a TypeScript or JavaScript graph has an empty decorations table and its `@Get(':sku')` sitting in `call_sites` as a - `DECORATOR_CALL`, so Nest, Angular and TypeORM used to answer `no method carries @Get` with an empty list of decorations, - which reads as "this repository has no such handler". The owner is the narrowest declaration whose span holds the decorator - line, so `@Get` lands on the method and `@Controller`, which the call site charges to the module, lands on the class. - A graph that records no decoration at all now says so, instead of printing an empty list. -- **End to end, any shape:** `path Type1 method4` asks whether *any* method of Type1 reaches *any* declaration named - method4 — a type on either end is all its methods, a bare name is every declaration under any owner (a free function - in Python/TS/JS has its file as owner). The same rule in every language; nothing is forced to be typed. -- **A name under many owners** (`close`, `run`, `toString`): the closure is computed once from the sources, so a - thousand targets cost nothing; the answer is which owners' declarations are reached and how far, nearest first, - then the nearest chains. Narrow with `Owner.close`, `--in ` (both endpoints restricted to files - containing it), `--limit N`, or `--all` for every chain. -- `Outer$Inner.m` and `Outer$1.m` are looked up through the nesting table, not by string: Inner at any depth inside - Outer; `$N` the N-th anonymous class in source order (javac's numbering — checked against `javap` on a JVM parser's traversal tests, 10/10) or, for an enum, the N-th constant with a body. A miss says which part is wrong: no such - outer / no nested type X (lists them) / only k anonymous classes (with lines) / no method m (lists the methods). -- **One endpoint = a closure, not a chain.** `path '*' X` is everything that can reach X — by hop, by file, and the - *entry points* among them, nearest first. An entry point is decided by one language-neutral fact — nothing resolved - calls it (the caller is outside the graph: a framework, a runner, reflection) or it is a test; a decoration on it is - shown as information, never used to decide. `path X '*'` is everything X reaches, and the library calls X makes itself - (the platform methods where the client graph ends), listed but never traversed. `path '*' X` also lists, apart, the - call sites written with X's name on a receiver the engine could not type (`[by name] `): the - callers `impact X` lists as `[by name]`, so the two verbs name the same direct callers. They are leads, never walked, - and when nothing resolved calls X they are the answer's `next:`. `--in src/main` keeps only the part - under that path; `--depth N` bounds the hops. Each closure is cross-checked against a second, independent traversal (the `verified:` line) - and bounded by the unresolved calls inside it. -- **An empty answer names the framework that owns it.** `path '*' ` for a live route used to print "0 - method(s)", which is true of calls and false of the program. When the upstream closure is empty the registration is - named instead — *create_order is registered as a route "/orders" by @post (app/api.py:43)* — and when two endpoints - have no chain, a key that connects them is reported with the line that writes it, including the two spellings of one - path (`/orders/o-1/price` written against `/orders/{order_id}/price` registered). It is reported, never walked: a - chain here means control reaches B from A *through these calls*, and a registration is not a call. `impact` is the - verb that follows the hop, and the answer says so rather than ending at a dead end. It also says WHY nothing in the - graph calls it, the first two reasons from the same reader the hooks' `← ?` label and impact's `why nothing in the - graph calls` line use: an entry point, a registration, a decoration a framework reads (a wrapper such as a cache is - not one), a library method it overrides, the call sites that write its name, a library base of its type, a - decoration on its type. A caller through an interface or base method the closure does not walk is named there too. The conventions come from the - one module both tools read (`scripts/ax_registration.py`). -- **What it cannot find, by construction** — say so instead of guessing: a call whose receiver the engine could not type - (DI-injected, unbound generic, a parameter in a dynamic language) stops the chain and is counted in `bound:`; callbacks - handed to a library (`executor.submit(task)`, `list.forEach(fn)`) are reached from their definer (`[defines]`) but never - from the library that invokes them; calls the framework makes (HTTP dispatch, JUnit, `main`) have no edge — the callee - is an entry point; reflection / string dispatch / event buses / config-wired beans are invisible; overloads sharing a - name are all resolved together (a signature in the query is stripped); a method overriding a library method is called - by the library, so its upstream ends there; code outside `--src` or in another language is not in the graph; a - by-name or written match can be a same-named other thing. A chain says control can reach B from A through these - calls — nothing about the values that travel it. -- Both directions are tried; the reverse is labelled. -- `axiomcode path --selftest ` replays the engine's own expected edges through the tool and separates engine gaps - from tool losses; run it after touching `dl/path.dl` or the exporter. Needs `souffle` on PATH. - -`scripts/` holds `axiomcode` (the entry) and what it dispatches to: `axiomcode-build` (the pipeline), `axiomcode-index`, `axiomcode-graph`, `viewer.html`, `axiomcode-path` with `dl/path.dl`, `axiomcode-impact` with `dl/impact.dl` (the path tool's resolver and edge facts, its own rules and fact export), `axiomcode-changed` (an edit → the declarations it touched, with the kind of change). diff --git a/plugins/axiomcode/skills/axiomcode/reference/schema.md b/plugins/axiomcode/skills/axiomcode/reference/schema.md deleted file mode 100644 index 09039f8f..00000000 --- a/plugins/axiomcode/skills/axiomcode/reference/schema.md +++ /dev/null @@ -1,106 +0,0 @@ -# schema — which table holds X, per language - -Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact -lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded -at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. - -## Which graph - -| | | -|---|---| -| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | -| every other language | `.axiomcode/lang//out/graph.sqlite` | -| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | -| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | - -Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` -holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other -tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. - -## Fact by language - -| fact | Python | Java | C# | -|---|---|---|---| -| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | -| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | -| base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | -| base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | -| entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | -| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields and consts only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`). In `symbols` every field, const, property and enum member has `owner` = its declaring type (`Outer.Inner` when nested) and `qualified_name` `..` | -| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field and const: **not recorded** (`field_access` empty; `refs` `MEMBER_ACCESS` and `NAME_REFERENCE` by name and line, with no field id) | -| call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | -| why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | -| strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | -| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | -| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | -| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | - -`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, -`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty -in Python: its dispatch set is `dispatch_candidates` (basis `mro`). - -## The verb for each fact - -| fact | verb | -|---|---| -| methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | -| subtypes of a type | `impact `: "must change with it" | -| who writes a Java field | `impact .`: "produces or writes it" | -| who writes a C# property | `impact .` (or `:`): readers and writers `[resolved]` through its accessors | -| who reads a C# field or const | `impact .`: readers `[in scope]` inside the type, `[by name]` elsewhere, since no C# field access is resolved; `:` of a field answers nothing (no callable spans it), so ask by name | -| a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | -| text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | -| a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | -| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | -| entry points by reason | no verb: SQL below | - -## Queries - -```sh -G=.axiomcode/out/graph.sqlite -# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) -sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d - LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" -# [all] entry points by reason, then one reason's methods -sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" -sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e - JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" -# [all] resolved ancestors (Java: library ones too, provenance 'external') -sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id - JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" -# [python] library bases, full text as written -sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" -# [csharp] library bases: the owner is the innermost type whose span holds the base-list line -sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file - AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner - FROM type_refs r WHERE r.context = 'BASE_LIST'" -# [java] field writers -sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a - JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id - WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" -# [csharp] property writers (property_read for readers) -sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e - JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id - JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" -# [all] tiers in this graph; [python] what the unresolved sites are waiting on -sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" -sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" -``` - -## Traps - -- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, - `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with - `LIKE '%/rel/path.java'`. -- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java - base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below - `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built - before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on - its `class Meta:` line. -- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in - `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. - JavaScript `symbols`: a field (`this.x = …` in a constructor or constructor function, a class field) has its class as - `owner` (`Store.items`); a member with a computed key is named by the key as written (`Tagged.[Symbol.hasInstance]`); - an anonymous class expression takes the name it is bound to (`static Inner = class {…}` → `Outer.Inner`). -- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` - names them. diff --git a/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py new file mode 100644 index 00000000..731e1d73 --- /dev/null +++ b/plugins/axiomcode/skills/axiomcode/scripts/ax_blocks.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""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 + 4 def total(items): + 5 net = sum(i.price for i in items) + → 6 return apply_discount(net) * (1 + vat_rate()) + ``` + +An agent that is given a location reads the file next, so each place carries the function that encloses it: the whole +function when it is short, else its header and the lines around the one that matters. The verb runs with --json and +its sites are taken in the grep view's order (ax_grep), so the three answers rank exactly as the verbs do; at most CAP +places are shown and the rest counted. A verb that refuses, or finds no place, is printed as the verb said it. +""" +import json, os, re, sqlite3, subprocess, sys +H = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, H) +import ax_grep + +CAP = 10 # places shown; the rest are counted +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', + '.mjs': 'javascript', '.cjs': 'javascript', '.cs': 'csharp', '.kt': 'kotlin', '.scala': 'scala'} +SITE = re.compile(r'^(?P[^\s:][^:]*):(?P\d+): ?(?P.*?)(?:\s+\[(?P[^\]]*)\])?$') + + +class Graphs: + """the enclosing callable of a line, from every graph the repository holds (one per language)""" + def __init__(self, repo): + out = os.path.join(repo, '.axiomcode', 'out') + self.cons = [] + for d in sorted(os.listdir(out)) if os.path.isdir(out) else []: + p = os.path.join(out, d, 'graph.sqlite') + if os.path.isfile(p): + try: self.cons.append(sqlite3.connect(f"file:{p}?mode=ro", uri=True)) + except sqlite3.Error: pass + + def enclosing(self, f, n): + best = None + for c in self.cons: + try: + r = c.execute("SELECT display, line, end_line FROM symbols WHERE (file = ? OR file LIKE ?) AND line <= ? AND end_line >= ? " + "AND method_id IS NOT NULL AND display NOT LIKE '%%' ORDER BY end_line - line LIMIT 1", + (f, '%/' + f, n, n)).fetchone() + except sqlite3.Error: + continue + if r and (best is None or r[2] - r[1] < best[2] - best[1]): best = r + return best + + +def block(repo, f, marks, span): + """the lines to show for the marked lines of file f: the enclosing callable (whole, or header + a window around + each mark), numbered, every mark flagged""" + try: + with open(os.path.join(repo, f), encoding='utf-8', errors='replace') as h: L = h.read().split('\n') + except OSError: + return [] + marks = sorted(n for n in marks if 0 < n <= len(L)) + if not marks: return [] + lo, hi = (span[1], span[2]) if span else (marks[0], marks[-1]) + lo, hi = max(1, min(lo, marks[0])), min(len(L), max(hi, marks[-1])) + if hi - lo + 1 <= WHOLE: + keep = list(range(lo, hi + 1)) + else: + keep = {lo} + for n in marks: keep |= set(range(max(lo, n - AROUND), min(hi, n + AROUND) + 1)) + keep = sorted(keep) + w = len(str(keep[-1])); out = []; prev = None + for i in keep: + if prev is not None and i != prev + 1: out.append(' ' * (w + 4) + '…') + out.append(f"{'→' if i in marks else ' '} {str(i).rjust(w)} {L[i - 1].rstrip()}") + prev = i + return out + + +# rows that add nothing an agent acts on: a word match offered only because nothing better was found (dropped when a +# better row exists), and a module's own scope (its import lines) +FILLER = 'best overall match' +NOISE = ('module scope',) + + +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) + sites = [] + for _k, line in rows: + m = SITE.match(line) + if m: sites.append((m.group('file'), int(m.group('line')), m.group('tag') or '')) + sites = [x for x in sites if not any(w in x[2] for w in NOISE)] + if any(FILLER not in t for _f, _n, t in sites): sites = [x for x in sites if FILLER not in x[2]] + # ONE PLACE PER FUNCTION: two relevant lines of one function are one block with both marked, in the order the + # verb ranked the first of them + graphs = Graphs(repo); places = {} + for f, n, t in sites: + span = graphs.enclosing(f, n) + key = (f, span[1], span[2]) if span else (f, n, n) + p = places.setdefault(key, {'f': f, 'span': span, 'marks': [], 'tags': []}) + 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) + out = [] + for i, p in enumerate(list(places.values())[:CAP], 1): + where = f"{p['f']}:{','.join(map(str, sorted(p['marks'])))}" + out.append(f"{i}. {where}" + (f" [{' | '.join(p['tags'][:2])}]" if p['tags'] else '')) + body = block(repo, p['f'], p['marks'], p['span']) + if body: + out.append(f" ```{FENCE.get(os.path.splitext(p['f'])[1], '')}") + out += [' ' + b for b in body] + 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] + return out + + +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') + try: doc = json.loads(r.stdout) + except ValueError: doc = None + return r, doc + + +def edits(repo): + """impact with no name: what the working tree's edits changed, then what depends on those declarations, with code""" + r, doc = verb_json(['python3', os.path.join(H, 'axiomcode-changed'), repo]) + if not isinstance(doc, dict): + sys.stdout.write(r.stdout); return r.returncode + ch = doc.get('changed') or [] + targets = list(dict.fromkeys(c['target'] for c in ch if c.get('target'))) + head = ["your edits: " + (', '.join(f"{c.get('kind')} {c.get('shown_target') or c.get('symbol')}" for c in ch[:8]) or 'none') + + (f" (+{len(ch) - 8} more)" if len(ch) > 8 else '')] + if not targets: + print('\n'.join(head + ["nothing edited is a declaration other code depends on" if ch else + "no edits against the commit the graph was built from"])) + return 0 + r, doc = verb_json(['python3', os.path.join(H, 'axiomcode-impact')] + targets + [repo, '--tests']) + lines = render('impact', doc, repo) if isinstance(doc, dict) else None + print('\n'.join(head + (lines or [x for x in (doc or {}).get('prose', [])] or [r.stdout.strip()]))) + return 0 + + +def main(argv): + verb, repo = argv[0], argv[1] + if verb == 'edits': return edits(repo) + cmd = argv[3:] if len(argv) > 2 and argv[2] == '--' else argv[2:] + r, doc = verb_json(cmd) + if not isinstance(doc, dict): + sys.stdout.write(r.stdout); return r.returncode + lines = render(verb, doc, repo) if r.returncode in (0, 1) or doc.get('called_undeclared') else None + if lines is None: + # a refusal or an answer with no place in it: the verb's own words are the answer + print('\n'.join(doc.get('prose') or []) or r.stdout.strip()); return r.returncode + print('\n'.join(lines)) + return 0 + + +if __name__ == '__main__': + if len(sys.argv) < 3 or (sys.argv[1] != 'edits' and len(sys.argv) < 4): sys.exit(__doc__) + sys.exit(main(sys.argv[1:])) diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode index c503f33d..cfcaf9ff 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode @@ -1,5 +1,27 @@ #!/bin/bash -# axiomcode — the one entry point. Every capability is a subcommand here; a new skill is a new subcommand, never a new tool. +# 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. +# axiomcode path +# how A reaches B: every hop of the call chain, with the code at each call. +# axiomcode tests +# the tests your uncommitted edits reach, and the command that runs exactly those. +# axiomcode index [] [--lang [,…]] [--src ] [--library [,…]] +# build the graph (the first query builds it too). defaults to the current directory. +# +# Names are written as in the code: Owner.method, function, Type, or file.py:123. `axiomcode help ` for one verb. + +# 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. # # axiomcode index [] [--lang java|typescript|python|javascript|csharp] [--src ] [--library [,…]] # the pipeline: parser → engine → .axiomcode/out/graph.sqlite (+ index). defaults to the current directory. @@ -110,7 +132,12 @@ helptext(){ awk 'NR>1 && /^#/ {sub(/^# ?/, ""); print; next} NR>1 {exit}' "$0"; # 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(){ - local f="$H/axiomcode-$1"; [ "$1" = index ] && f="$H/axiomcode-build"; [ "$1" = tests ] && f="$H/axiomcode-test-impact" + # 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) + 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; } 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 @@ -176,7 +203,7 @@ 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) if [ "${#POS[@]}" -gt 1 ] && [ ! -d "${POS[1]}" ]; then gone "${POS[1]}"; fi ;; + context|find) 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 ;; @@ -191,6 +218,25 @@ 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 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, +# 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. +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 + named=""; for a in ${ARGS[@]+"${ARGS[@]}"}; do [ -d "$a" ] || named=1; done + [ -z "$named" ] && exec python3 "$H/ax_blocks.py" edits "$FR" + ARGS+=(--tests); B=impact + fi ;; +esac +[ -n "$B" ] && G=(python3 "$H/ax_blocks.py" "$B" "$FR" --) # A REPOSITORY IN SEVERAL LANGUAGES has one graph per language (.axiomcode/lang/ beside the main one): a query # asks every one of them (ax_langs.py), so no language's code is left out of an answer. A graph named in # AXIOMCODE_GRAPH was chosen by the caller and is asked alone. @@ -204,7 +250,7 @@ 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) exec ${G[@]+"${G[@]}"} python3 ${Q:+"$Q" "$FR"} "$H/axiomcode-context" ${ARGS[@]+"${ARGS[@]}"} ;; + context|find) 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[@]}"} ;; diff --git a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install index 111cfa64..c562c060 100755 --- a/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install +++ b/plugins/axiomcode/skills/axiomcode/scripts/axiomcode-install @@ -35,23 +35,23 @@ END = '' BLOCK = """ ## Finding code in this repository -This repository has a resolved call graph. Ask it FIRST, through the -`mcp__plugin_axiomcode_axiomcode__axiomcode_*` tools; no skill needs loading: +This repository has a resolved call graph. Ask it FIRST, through the axiomcode MCP tools +(`mcp__plugin_axiomcode_axiomcode__*`); no skill needs loading: - axiomcode_context task="" # where the work is, when you have no name yet - axiomcode_impact targets=[""] # what a change reaches: contract, users, tests - axiomcode_path from_="" to="" # how A reaches B + 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 context ""`, -`axiomcode impact `, `axiomcode path `. +Only when those tools are not in your list, the same from the shell: `axiomcode find ""`, +`axiomcode impact `, `axiomcode path `, `axiomcode tests`. -**Trust the answer.** A `[resolved]` / `[sound]` row has already been looked up again in the graph -(the `verified:` line) — do not re-derive it by grepping or opening the other files it names. Every -answer ends with `next:`, the one step to take: read only the lines you will cite or change. -`[by name]` / `[text]` rows are leads, not facts. An unresolved call means *unknown*, not *absent*. +**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. -`axiomcode index` builds the graph if `.axiomcode/out/graph.sqlite` is absent. """ diff --git a/skills/axiomcode/SKILL.md b/skills/axiomcode/SKILL.md index 9456d8e1..ff9eb64f 100644 --- a/skills/axiomcode/SKILL.md +++ b/skills/axiomcode/SKILL.md @@ -1,131 +1,74 @@ --- name: axiomcode description: >- - Use for any why, what or where question about code — how a codebase works or what a change to it would do: architecture, execution flow, where something lives, who calls it, what depends on it, what breaks if it changes, which tests cover an edit, whether it is safe to delete. 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?", "Is this safe to delete?", "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 — each labelled with how certain it is. Call it directly, no need to load this skill first: the `axiomcode_context` MCP tool with source=True for how something works (the call flow with each step's code; from_= when you know where it begins), `axiomcode_impact` for what a change reaches, `axiomcode_path` for how A reaches B. Only when those tools are not in your list, the same from the shell: `axiomcode context "" --source`, `axiomcode impact `, `axiomcode path `. 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. 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 -Prefer the MCP tools (`axiomcode_`; in Claude Code, `mcp__plugin_axiomcode_axiomcode__axiomcode_`) when they -are in your tool list; otherwise run `/../../plugins/axiomcode/skills/axiomcode/scripts/axiomcode …` from the repository root. Same code, same -verified output. `` defaults to the current directory. In Claude Code, a hook adds the graph's edges to your own -Read / Grep results as `graph: …` lines. - -**Trust the answer, and know what it is.** A `[resolved]` / `[sound]` row has already been looked up again in the graph (the `verified:` line): do not re-derive it by grepping. Each answer ends with `next:` — the one step to take. For a CHANGE (who calls it, what breaks, which tests), read only the lines you will cite or change. To EXPLAIN how something works, the graph gives the reading order, not the explanation: read each step's body, and continue through every `⚠` (a call the graph lost). `[by name]` / `[text]` / `[approx]` rows are leads, not facts. - -**A list of sites comes the way grep prints it.** The MCP `impact`, `path`, `test_impact` and `context` (without -`source` / `explain` / `from_`) answer one site per line: `path:line: [resolved · hop 2 · test …]`, -surest first, capped with a count of the rest; `limit=N` lists more, `full=True` gives the sectioned answer with `next:`. -From the shell the same shape is `--grep` (`--grep-limit N`); without it the answer is the prose. - -## Start here - -| the question in front of you | the call | -|---|---| -| **`.axiomcode/out/graph.sqlite` already exists** | **query it — do NOT run `index`** | -| no graph at all | `axiomcode index` | -| a task in words, no name to ask about yet | `axiomcode context ""` — then `--in ` it names | -| "who calls X" / "what breaks if X changes" | `axiomcode impact X` | -| "who writes this field" / "is it safe under concurrent access" | `axiomcode impact .` — ask of the FIELD | -| one concept you can name ("the decryption code") | `axiomcode path decrypt '*'` | -| "how does X work" · "explain / walk through X" | `axiomcode context "" --source` — the call flow in order with each step's code; answer from it, and open a file only for a step whose body was cut or a `⚠` call. `--from ` when you know where it begins | -| "how does A reach B" · "everything that reaches X" | `axiomcode path A B` · `axiomcode path '*' X` | -| "what did my edit touch" · "which tests do I run" | `axiomcode changed --impact` · `axiomcode test-impact` | -| "is it safe to delete X" | `axiomcode impact X --delete` | -| what an engine or rules change did to a graph · a before/after of one tree | `axiomcode diff ` (two indexed copies, or two graph.sqlite) | -| the graph as a page for a human · this repo should prefer the graph, once | `axiomcode graph` (drawn from the existing graph in seconds; a stale one is rebuilt first with the flags it was indexed with, or drawn as it is with `--no-refresh`; prints the page's absolute path) · `axiomcode install` | - -Rules that decide whether an answer means anything: - -- **Never re-run `index` on an existing graph** "to make sure" or after your own edit. The graph refreshes itself in - the background after edits, with the flags it was built with. A query does not wait for it: it answers from the last - graph, names the edited files on a `graph refresh:` line, and marks every row that lies in one `(may be out of date)` - (`"stale": true` in `--json`); unmarked rows are current. Read a marked row's file for its current text. It waits - briefly on its own only when the answer touches an edited file and the rebuild is nearly done. -- **Before a delete or a rename, ask with `--fresh`** (MCP `impact`, `path` or `context` with `fresh=True`): it waits for the rebuild, printing its - progress, and answers from a graph that includes every edit. - A manual `index` with different flags rebuilds a worse graph over the good one. A bare `index`, the background - refresh and `graph` keep the `--lang` (and `--src`, `--library`) the graph was indexed with; pass `--lang` to change it. -- **To read without rebuilding, pass `--no-refresh`** on any query verb (MCP `context`, `path`, `impact`, - `changed`, `test_impact`, `graph`: `refresh=false`; `AXIOMCODE_NO_REFRESH=1` for a whole shell): the answer comes - from the graph as it is, nothing is rebuilt, and rows in edited files are still marked. Use it on a graph you built - on purpose (another engine, a measured baseline): without it, a query on a graph that is out of date starts a - background rebuild with this axiomcode's engine, and the answer's FIRST line says so - (`graph refresh: this query started a background rebuild ...`) with the reason. The hooks never rebuild a graph - another axiomcode built; they say so once per session. -- A repo in several languages is indexed in all of them, one graph each, and every query asks each graph; calls - are not followed from one language to another. `--lang` restricts it, `--src src` narrows it; `--library ` so calls into dependencies - resolve (without it they are `ambiguous_unknown` — do not quote that resolution rate). -- An unresolved call is *unknown, not absent* — **never report it as "no callers"**. -- Every answer ends with `verified:` and `bound:` (the unresolved calls inside it — a lower bound). A `✗` on - `verified:` means the answer is wrong: report it, do not use it. - -## How certain is each row - -An answer's label is the **worst** rung on its route. Read it before acting on the row. - -| rung | claims | -|---|---| -| `[sound]` / `[resolved]` | an edge the engine resolved: a single-target call, an override, a subtype, a constructor | -| `[one of a set]` · `[dispatch]` | one of a sound target set · an instantiated override reached through its base | -| `[defines]` · `[protocol]` · `[decorator by name]` | closure from its definer · interpreter-called method · wrapper rebinding the name | -| `[fixture]` · `[at import]` | injected before the test body · module raised on import, test never collected | -| `[spawns]` | the test runs the script as a child process, joined through the **path** it names — not an edge | -| `[by key]` | joined through a registration **string** (route, signal, CLI command, the event type a handler table is keyed by) — not an edge | -| `[stubs it]` | a call written inside a mock's stub or verification (`when(m.f())`, `verify(m).f()`, `Setup(x => x.F())`, `Received().F()`): names it, runs none of it — never a test route, listed apart | -| `[in scope]` · `[by name]` · `[text]` | same name in the owner's scope · same name elsewhere (may be another thing) · text only | -| `[alongside]` | declared in the same type or file — no call, no reference; its own section (`alongside` in `--json`), never a dependent | -| `[approx]` | a text match placed in the declaration that holds it (a message it raises, a table in its query, a script or file it runs or reads, through a constant one step), with that declaration's callers; comments, docstrings and tests are never placed. For a name no graph declares and a file no graph reads (`.sh`, `.sql`, templates, config): `impact build.sh`, `context "which code raises 'x'"` | - -Below `[sound]` / `[one of a set]` the order is a tie-break, not a measured ranking. `[sound]` means the edges -connect, not that a test exercises the change. - -## context — a problem statement, no name yet - -`axiomcode context "" [--in [,]] [--budget N] [--source]`: the files and callables the task's -words land in, nearest first, 12 files by default. Scopes you pass restrict and are combined; a scope it offers -does not restrict. Detail: `reference/context.md`. - -## impact — what a change to a declaration reaches - -`axiomcode impact … [--depth N] [--in ] [--delete] [--why]`. Targets as written in the code: -`Owner.method`, `Owner.field`, `Type`, `Owner.method(param)`, `Type`, `Owner.method:local`, a config key, or -`file.ts:123` — the declaration at that line. Separators are interchangeable in every language: `util.square`, -`src.util.square` and `src/util#square` are one name. **When you know where the declaration is, target it by `file:line`**: a -bare name answers for EVERY declaration of that name, and two unrelated functions in different files come back as one. -Sections: **must change with it** · **produces or writes it** · **reads or uses it** (by rung) · **reaches those** -(transitively: what can reach a user, not where the value goes) · tests, counted by rung with the strong ones named · `verified:` · `bound:`. For the full test list ask second: `--tests-only` (grouped by rung and file), `--why` for routes, `--tests-in ` to narrow. `--why` (MCP `why=True`) also prints, under each `change:` line, how the target name was resolved: the lookup step that matched (exact declaration, qualified suffix, simple name, field, type used by name, ...), the declarations it weighed with file:line, and why that one won or why the name matched nothing. A long answer comes in pages of ~2000 tokens with the whole answer's counts on every page; `--page 2` (MCP `page=2`) continues with the rows page 1 did not print, and says so when there is no page 2; `--page all` (MCP `page="all"`) prints every row. Ask for it only when page 1's strongest rows are not enough. It finds config -keys, injected beans and handlers registered as values — none has a call site. Detail: `reference/impact.md`. - -## changed · test-impact — from an edit - -`axiomcode changed [--impact] [--staged | --range a..b] […]` says how each declaration changed (`signature`, `body`, -`field`, `type`, `removed`, `added`). `axiomcode test-impact [--why] […]` lists the tests the edit reaches and the -command to run them. For your branch's commits ask `--range ..HEAD`: it reads from the merge-base, so a base -that moved on is not counted as yours. On a copy without git, name the files you edited. Changed fixtures and other -files no graph reads are named, with the tests whose text names them; a case directory's or fixture tree's files map to the runner or test that reads them, with its command, never to pytest or JUnit on the fixture itself. It is a **lower bound**: skipping what it does not name is your risk decision, since reflection -and service loaders are invisible. Detail: `reference/changed-and-tests.md`. - -## path — asking the graph - -`axiomcode path [--every] [--in ] [--why]`: one shortest verified chain per target, or why there is none -(with the unresolved sites that might connect them). Endpoints as written: `Owner.method`, `Type`, `file.ts:123`, -`'new File'`, `'@GetMapping'`, `'*'`, or a bare word. A misspelt name stops with the close ones. When an endpoint came out as something you did not mean, `--why` (MCP `path`: -`why=True`) adds after the endpoint line how each name was resolved: the step that matched, up to five candidates with -file:line, and why that one won or why the name fell to "nothing named". Detail: `reference/path.md`. - -## diff: two graphs of the same tree - -`axiomcode diff [--file ] [--json]`: what changed between two graphs of one tree, each a -`graph.sqlite` or an indexed directory (copy the tree, index each copy, e.g. before and after an engine change). Call -edges added, removed, retiered or re-targeted, entry points with their reason, remote and framework edges, config -bindings and symbols, with the call edges per tier. Rows match 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. Use it instead of hand SQL for a -before/after. Detail: `reference/diff.md`. - -A fact no verb prints (decorations, bases, entry points by reason, field writers): `reference/schema.md` names the table per language. +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. + +| 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 ` | +| which tests do my edits need, and how do I run them? | `tests()` | `axiomcode tests` | + +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. + +## What an answer looks like + +A numbered list of places, most relevant first, each with the code of the function it sits in. `→` marks the line +that matters; a short function is shown whole. + + 1. shop/pricing.py:6 [resolved · total] + ```python + 4 def total(prices): + 5 net = sum(prices) + → 6 return net * (1 + vat_rate()) + ``` + verified: ✓ (4 edge(s) looked up again) + +Answer from the code shown; open a file only for a place whose body was cut (`…`). The tag says how sure the place +is: `resolved` is an edge the engine resolved and re-checked (`verified:`), do not re-derive it by grepping; +`one of a set` is one of several real targets; `by name` and `text` are leads, not facts; `test` marks a test; +`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: +`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. + +## path + +How one declaration reaches another: every hop of the call chain, with the code at the line each call is written on. +Example: `path(start="main", end="Ledger.put")`. + +## tests + +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. + +## index + +`axiomcode index` builds the graph explicitly; `--lang`, `--src` and `--library` narrow it. Never re-run it on an +existing graph: the graph rebuilds itself after edits, and an answer given before that finishes says so on a +`graph refresh:` line. ## What it cannot see — say so instead of guessing -Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; -what a decoration turns on (proxy, transaction, cache); code outside `--src`. Each is counted in `bound:`. +Reflection, string dispatch, event buses; receivers the engine could not type; callbacks invoked by a library; what a +decoration turns on (proxy, transaction, cache). Text search is still right for a string, a comment or a config value. diff --git a/skills/axiomcode/reference/changed-and-tests.md b/skills/axiomcode/reference/changed-and-tests.md deleted file mode 100644 index cb924da2..00000000 --- a/skills/axiomcode/reference/changed-and-tests.md +++ /dev/null @@ -1,135 +0,0 @@ -# changed, test-impact, and the edit hooks - -**Read-only:** `changed` and `test-impact` take `--no-refresh` too (MCP `refresh=false`): when HEAD moved since the -baseline was set they then answer against the baseline as it is instead of starting a rebuild to move it. The edit -hooks never rebuild a graph another axiomcode built (a build stamp naming another engine, other rules or another -IMPACT_VERSION): they keep it and say so once per session; `axiomcode index` or a query without `--no-refresh` -rebuilds it, the query saying so on its first line. - - -`axiomcode changed` maps a change onto the graph's declarations and says *how* each changed, in every language from the text: -`signature` (parameters added / removed / renamed / retyped — `+reason`, `-x`, `zip: String → Integer` —, the return type), -`body` (only lines inside a method), `field` (its type `String → Integer`, its name, its initializer; `variable` for a name a -script's top-level code assigns; a line of several statements or declarations (`a = 1; b = 2`, `int a = 1, b = 2;`, -`a, b = 1, 2`) is compared one statement at a time, so only the one whose own statement changed is named), `type` (a header: name, -extends / implements, type parameters), `removed`, and `added` lines outside any known declaration (listed, not analysed — -nothing depends on new code yet). By default it reads the working tree against **the commit the graph was built from** (the -build stamps it), so an uncommitted edit is always measured against the tree the graph describes; `--range a..b` reads two -commits (when the graph is at the newer side, the declarations are the new text's and the direction is turned around), -`--staged` the index, `--old/--new/--file` two texts of one file, `--against-head` the working tree against HEAD (what the -edit hooks ask after a rebase or a pull the baseline has not followed yet, so the commits that came in are not counted as -edits). Each line ends with the target `impact` takes for it: the declaration edited, as `file:line` (a name answers for -every declaration carrying it: eight `main`s, two overloads), and `file:line(param)` for a signature with one parameter -changed. `--impact` runs impact on all of them as one change set. - -The graph's line numbers are in the text it was indexed from, and the text an edit is read against can be a later one (an -edit made before the background refresh caught up, a range). Each declaration is carried onto that text by a line diff, and -one whose own line was rewritten is found again by what it declares, nearest first. A declaration still written elsewhere -in the new text is not `removed`: a moved one is `body` (moved), and one found only by name, or a field whose line went -while it is still assigned, says `may have changed`. Read that as "look at it", not as a verdict. When the graph's rows and -the text it records disagree (a refresh raced an edit), a `note:` says the declarations were placed by name. - -What to pass, and what the answer says when the question cannot be answered the way it was asked: - -| situation | ask | what comes back | -|---|---|---| -| uncommitted edits | `changed` · `test-impact` | the edits against the baseline | -| your branch's commits | `changed --range ..HEAD` (MCP `range='..HEAD'`) | read from `git merge-base HEAD`, not from ``'s tip: commits the base branch received after you branched are not yours and are left out. A `note: range base: merge-base …` line says so whenever `` has moved. `a...b` means the same; `a` alone is `a..HEAD` | -| after a rebase, a pull, a checkout or a reset | `changed` · `test-impact` | read against the NEW HEAD at once, even before the background refresh has caught up: a `note: the base moved …` line names the move, and what the new commits changed is never counted as your edit. `--range ..HEAD` where the local `` is behind the remote you rebased onto reads from that remote's fork, with a note; name a commit to read exactly from it | -| committed work, clean tree | `changed` | `no change …` followed by `next: … HEAD is N commit(s) ahead of — ask --range ..HEAD` | -| a copy without git | `changed` | a refusal: no base to diff against. Name the files instead | -| named files | `changed …` · `test-impact …` (MCP `files=[…]`) | each file's edit; a named file with no edit (or any named file on a copy without git) counts **whole**: every callable declared in it is `named`, and test-impact selects the tests of all of them | -| a file the base does not have | (any) | one line, `added — new file, N declaration(s)`, plus each new declaration something outside the file already calls, with its impact target. Never its parameters or docstring words | -| fixtures, case data, a schema | (any) | named as `outside every indexed language`, never "no change"; test-impact lists the test files whose text names them (the path, the file name, or a quoted directory), as a `[text]` tier, and says when no test names them | -| a file under a case runner's `cases/` (a script beside `cases/` that walks it: `tests/run.py`, `graph/test//run-tests.sh`), a golden named for a case, a rule file under the tree a runner's directory mirrors (`graph//` for `graph/test//`) | (any) | `case data and rules`: the runner's command for that one case, as its usage line spells it (`python3 tests/run.py --lang `), or the whole runner for a rule file; a fixture's own `test_*.py` there is data, never handed to pytest | -| a file in a FIXTURE TREE under a test root, whatever its name (`fixtures/`, `testdata/`, `TestData/`, `src/test/resources/`, a directory of goldens): a directory a runner or a test names by path, one that holds goldens and no test of its own, a project no build around it includes | (any) | `case data for `: the script or the tests that name that path (the file, or the nearest directory above it), with their command (`python3 tests/fast.py --lang python`, `pytest tests/test_report.py`, `mvn test -Dtest=...`); a helper that reads it (a conftest.py, a resource reader) stands for the tests beside it. Never a pytest or JUnit line on the fixture, never a test named like the file. `changed` says `case data (...): read by ; run ; an input, not a test to run` | -| a data file whose file name other files share (`case.json`, `settings.json`) | (any) | that name is no test-name match: only its path (two parts or more) is looked for in test text | - -**A lambda is part of what encloses it.** Every lambda a front end declares carries one name (``), so it is never -the declaration an edit is charged to: an edit inside a lambda in a method is that method's `body` change, and one inside a -field's initializer is that field's. A lambda nothing encloses (an entry in a module-level table) is its own `body` -change, named by where it is, `module.` or `Owner.method.`, and its target is `file:line`; that -name is also a target `impact` and `path` accept. Its parameter list is read from the lambda's own header, so an unchanged -header is never reported as a parameter change. `impact :` on a field, a property, a constant or a type -header line answers for that declaration; a callable written on the line still wins. - -`test-impact` also lists an edited or new **test file** as one to run, and adds it to the command. Code that is also run as a -program (`if __name__ == '__main__'`, `static void main`, `Main`) is looked for by name in the tests, since a test that starts -it as a subprocess or drives it from case data has no call edge to it; when no test names it the answer says the selection is -a lower bound for it. - -Measured against 270 real fixes (a Java defect-benchmark arena: the fix applied to the buggy files, the declarations it reports -against the benchmark's own scanner's reading of the same hunks, its class-level state expansion taken out): exact -agreement on 255, 465 declarations reported for the scanner's 473 — recall 0.968, precision 0.985. Every remaining -disagreement was read in the diff: the scanner charges an `@Override` line above an *added* method to `` where this -names the method; an anonymous class added inside a method body is "that method's body changed" here (the scanner names -the new anonymous methods from the fixed tree); a renamed method is reported under its OLD name (what callers reference); a -new nested type is named as well as its members; one miss stands — a method extracted from an existing body whose header -lands in a replaced region. Nothing in the tool's answers was bent toward the benchmark: where the two differ, the diff -was the judge. - -The plugin's hooks do this without being asked, at every moment an edit can happen (`hooks/enrich.py`, `hooks/changes.py`): -**PreToolUse on Edit / Write / MultiEdit** applies the edit to a copy and, when it changes a signature, a field's type, a type -header or removes a declaration, gives the blast radius *before* the file changes; **PostToolUse on Edit / Write / MultiEdit** -reports every changed declaration after it lands (a body-only edit included); **PostToolUse on Bash** re-reads the working -tree after a command that can modify sources (`sed -i`, `patch`, `git apply / checkout / pull / merge / stash pop`, a redirect -into a source file, a script run); **UserPromptSubmit** is the safety net — whatever changed the tree since the graph's commit -by any means and was not reported yet. Each declaration is reported once per session; each report is `changed` (which -declaration, how) and `impact` (up to three declarations in parallel, a few lines each: what must change with it — for a -signature, a field, a type or a removal —, who produces or writes it, who reads it, how many callables and tests reach it, -the unresolved-call bound). That is where the agent that changed `String zipCode` to `Integer` is told, before the edit -lands, about the five `getZipCode().length()` uses in another service, the generated constructor call in a controller, and -the four repositories that deserialize a holder. - -**One edit, not the branch.** The PostToolUse report compares the file just before the tool call with the file after it (the host's `originalFile`, else the PreToolUse copy, else the edit undone), never with the baseline, so a rebase or a pull the refresher has not caught up with does not turn upstream's changes into "this edit changed". When HEAD moves, the next report says so once: `graph: the base moved: HEAD is …, was … (N commit(s) it did not have)`. - -**Does it find what it says it finds?** `tests/run.py` at the repository root: a synthetic project per behaviour under -`tests/cases///`, each with the claim it checks, what must appear in the answer and what must not. It -covers the shapes that used to be answered wrongly: a `this.field` write in an unrelated class, an enum member against a -nested type of the same name, an overload written by its parameter type (`Store.get(String)`), a Java text block and a -JavaScript regex literal, `holds` scoped to the declaring type, a subtype contract where the engine emits no override -rows, a Python `@property` as a private field's door, a house decorator that wraps `dataclass`, and a local variable -that must not carry the method's blast radius. Java, Python, TypeScript, JavaScript and C#. - -**Is what the hooks put in context true?** `hooks/validate.py ` generates events (Reads of whole files and ranges, Greps of -declared identifiers, edits that change a body, a signature, a field's type — before and after landing) or replays recorded -ones (every hook block is logged in full with its input in `.axiomcode/hooks.jsonl`), and checks every stated fact against -`graph.sqlite` and the source: each callable named is declared at that line in that file (or the block says the file changed -since the graph was built — the Read block now says so), each caller / callee named has an edge, each count is the table's, -each changed declaration spans a changed line, each name under must-change / produces / reads is in `impact`'s answer with -that role. On a multi-module Java system 1,036 facts, 0 wrong; on a JVM parser 2,693 facts, 0 wrong — after it found two real errors: an -enum's synthesised `values()` / `valueOf()` listed as callables "at L3", and a field named like its fluent accessor handed to -`impact` without its kind. What the hook cannot vouch for is what the graph cannot: an edge the engine did not resolve is -absent, never wrong, and the `? n` count says how many. - -## test-impact — which tests this edit reaches - -`axiomcode test-impact` takes the edit (the working tree by default, `--range a..b`, `--staged`, or named files), maps it onto the -declarations through `changed`, asks `impact` which tests reach any of them, and prints the test files with the -runner command that runs exactly those. It is `changed` + `impact --tests` with the answer shaped for a pipeline -rather than for a reader. - -**What it costs and what it saves, measured end to end** on a TypeScript library of 311 source files whose suite is -130 files and 5,193 tests: a one-line body edit to one function → the answer in **0.74 s**, naming 8 files / 503 -tests, and running exactly those took **2.2 s against 17.3 s for the whole suite — 7.9× faster**. Against the -behavioural truth for that method (break it, run the suite, record which files newly fail) the selection contained -**every failing file**, with 2 extra. Over 16 such methods: recall 0.778, precision 0.636, mean 4.1 files of 130. - -**It is a lower bound and the wording says so, because the two questions want opposite things.** For "what must be -looked at again", recall is the product and a wide answer is safe. For "what can CI skip", precision is the product -and a wide answer is worthless — and the same answer cannot be tuned for both: on a Python web framework the -registration-key hop takes recall 0.564 → 0.727 and precision 0.527 → 0.310 at the same time. So the rungs are -reported separately and `--json` carries `certainty` per test, and a pipeline can price them: on the TypeScript -library a `[sound]` route (every hop a single resolved target) was right **29 times in 30**, `[one of a set]` 1 in 8, -`[by name]` 0 in 1; a `[fixture]` route is right 30 times in 30 on a service where a fixture is the only way in and -about 1 in 4 on a framework where every test builds an app. Run the sound rung first, and decide about the rest with -the number in front of you. Skipping what it does not name is a decision about risk that this tool cannot make for -you: a test reached only through reflection, a service loader, a subprocess, or a case built at runtime does not appear -here (the `[text]` tier above recovers the ones whose test names the file it loads). - -A test that only **stubs** a changed declaration on a mock (`when(repo.find(1))`, `mock.Setup(r => r.Find(1))`) is not -selected for a body edit: it runs none of the body. It is named on a `not selected:` line, and it is selected when the -change is a signature change or a removal, which breaks the stub. A test that reaches the change only through a -framework-entered entry point (an HTTP route, an event, a mediator send) is named on a `NOT COUNTED` line, with the -search that finds it, unless a `[by key]` route already joined it. - diff --git a/skills/axiomcode/reference/context.md b/skills/axiomcode/reference/context.md deleted file mode 100644 index 742356c7..00000000 --- a/skills/axiomcode/reference/context.md +++ /dev/null @@ -1,69 +0,0 @@ -# context — from a problem statement, when there is no name yet - -Every other verb needs a name you already have: a method, a type, a `file:line`. That is the wrong first -question on an unfamiliar repository, and it is where a run gives up — asked once, the word resolved to -nothing usable, the graph never touched again. - -``` -axiomcode context "" [] [--in ] [--budget N] [--source] - [--explain | --no-explain] [--from ]… [--no-refresh] -``` - -**Read-only:** `--no-refresh` (MCP `context`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. 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 this axiomcode's engine and -says so on the answer's first line, with the reason. - -Deterministic — no model, no embedding index, no network. The task text is split into content terms -(stopwords dropped, camelCase and snake_case split); every symbol is scored against them — exact name, -prefix, substring, then file path — each weighted by inverse document frequency over the graph's own -vocabulary, so a rare term outweighs a common one. A test or benchmark declaration is demoted, not -dropped. The best seed per term is kept, so a multi-concept task gets several entry points; the closure -is walked from those seeds and ranked by nearest hop, then by how many of the task's terms the file -matches — not by how many methods it happens to contain. - -`--budget N` is how many files are listed (12 by default). The ranking does not depend on it: a larger -budget only shows more of the same tail, and the footer always says how many were withheld. - -`--in` is **repeatable and takes a list**: `--in a --in b` or `--in a,b`. A path you supply is knowledge — -a stack frame, the file you just read, the package named in the issue — so it does restrict the answer; -several are **combined, not intersected**, which is what makes a change spanning two roots answerable in -one call. A scope this program offered comes back marked `--in-offered` and does not restrict at all, -because that one is its guess and not your knowledge. - -It ends by saying what it could not see. A partial list that reads as complete is what turns a five-file -change into a one-file patch. - -## How something works: the call flow - -A task that asks how something works ("how does …", "explain …", "walk through …", "what happens when …", or -`--explain`) also gets the call flow. It starts at `--from ` (repeatable) when you know where the mechanism -begins, and otherwise at the entry points above. The steps are chosen breadth-first, so the entry point's own -calls come before any call of a call, and they print as a tree in the order the calls are written. Each step shows -its edge's certainty (`→` resolved, `⇢` one of a set) and the line that makes the call. A `⚠` marks a call in the -step's body that the graph could not resolve, when the project declares that name, so the reader continues -through it instead of stopping. A one-of-a-set site with many candidates is not a step. - -Where the flow leaves the graph it says so on that step, rather than ending silently: `⚠ leaves the graph: Send() L11` -for a library call that hands the work on (send, publish, dispatch, persist, execute, a client stub's `…Async`), and -`⚠ no body in the graph (interface/abstract)` for a step with no code to follow, naming the mapper XML statement bound -to it when there is one. Read from there by hand; a leaf whose library calls hand nothing on is not marked. - -## What the question names - -Entry points start with what the question NAMES: a declaration it spells out (`IRouter.RouteAsync`, `loadByNumber`) -and a route it quotes (`GET /api/widgets`, at the handler registered for it). Then the symbols matching several of -its terms together, then each term left over. Words about code rather than about the subject (`code`, `tests`, -`call`) take no seed, and an inflected word (`validated`) meets the declaration (`Validate`, `…Validator`). - -A file, directory or language the question names that no graph here holds is said FIRST, as -`not indexed: () -- this answer cannot see it; grep it directly`, and `next:` points at it. In a -repository with a graph per language, a question naming one language is answered by that graph alone. - -A question about SQL, configuration or templates lists the text files that name the declarations found (a MyBatis -mapper XML whose namespace is the declaring type ranks first), marked as text bindings, not call paths. `--in` on -a directory that holds no source (`src/main/resources`) is accepted: it says so and lists what under it binds. - -With `--source`, the earliest steps carry their code within a budget and the later ones are named only, so the -answer comes back on one page. Answer from that code, and open a file only for a step whose body was cut or at a -`⚠`. A question that does not ask how something works gets the ranked answer above, unchanged. diff --git a/skills/axiomcode/reference/diff.md b/skills/axiomcode/reference/diff.md deleted file mode 100644 index ef2e356b..00000000 --- a/skills/axiomcode/reference/diff.md +++ /dev/null @@ -1,68 +0,0 @@ -# diff: what changed between two graphs of one tree - -```sh -axiomcode diff [--file ] [--lang ] [--limit N] [--json] -``` - -Each side is a `graph.sqlite`, or a directory holding one: an indexed repository (every language graph under its -`.axiomcode` is compared, paired by language) or an `out` directory. Neither graph is rebuilt or refreshed. - -## The before/after recipe - -```sh -rsync -a --exclude .axiomcode / /tmp/before/ ; rsync -a --exclude .axiomcode / /tmp/after/ -AXIOMCODE_ENGINE= axiomcode index /tmp/before --lang python -AXIOMCODE_ENGINE= axiomcode index /tmp/after --lang python -cp /tmp/before/.axiomcode/out/graph.sqlite /tmp/before.sqlite # a later query may refresh a graph with another engine -cp /tmp/after/.axiomcode/out/graph.sqlite /tmp/after.sqlite -axiomcode diff /tmp/before.sqlite /tmp/after.sqlite -``` - -Copy each graph out right after its index: a query on a graph built by another engine starts a background rebuild -with the installed one, which overwrites the graph under test. The diff itself never does. - -## How rows are matched - -By what stays the same when one tree is indexed at another path, never by id: an id hashes the index directory, so -two indexes of one tree share none, and a join on ids says everything changed. - -| kind | matched on | -|---|---| -| call edge | the site (file, line, column, caller's qualified name) and the callee (qualified name and file:line, or the label a library callee carries, `external:…`, `builtin:…`) | -| entry point | the method (qualified name, file:line) and the reason | -| reachable | the method | -| remote edge | transport, destination, sender, handler, confidence | -| framework edge (Python) | mechanism, name, from, to, certainty | -| config binding (Java) | key, mechanism, target kind, target (a parameter is named by its owner type) | -| symbol | kind, qualified name, file, line; the same declaration with another signature is a `~` row (a parameter added, a type changed) | - -Absolute paths (Java's `methods`, `call_sites`) are made relative to the tree each graph was built from, so the same -tree indexed at two paths, by the same engine, diffs to nothing. An edit that moves lines moves every row below it: -compare graphs of one tree, not of two commits. - -## Reading the answer - -``` -python: A /tmp/before/.axiomcode/out/python/graph.sqlite (engine 637532ae) - B /tmp/after/.axiomcode/out/python/graph.sqlite (engine 37466bd6) -summary: call edges +0 -0 ~0 >18 · entry points +0 -0 · reachable from an entry point +5 -0 · … · symbols +0 -0 ~0 -call edges per tier: ambiguous_unknown 10152 -> 10134 (-18) · known_edge 2504 -> 2522 (+18) · boundary_lib 4721 (=) · … - -call edges (…): - + file:line:col Caller -> Callee [tier, kind] a site that had no edge, or a new callee at a new site - - file:line:col Caller -> Callee [tier, kind] the reverse - ~ file:line:col Caller -> Callee a/kind => b/kind the same callee at another tier or call kind - > file:line:col Caller a site whose callees changed: the old set, then the new - - Callee [tier, kind] - + Callee [tier, kind] -``` - -The summary counts are of the whole diff; `--limit N` caps the rows per section (default 40, 0 for all). -`--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. `--json` prints every row with the same counts, under -`languages..{counts, tiers, calls, entry_points, reachable, remote, framework, config, symbols}`. - -## Not compared - -`refs`, `literals`, `type_use`, `field_access` and the `ext_*` diagnostics other than the four above: open both -graphs with `reference/schema.md` for those. A language in only one of the two is named and skipped. diff --git a/skills/axiomcode/reference/impact.md b/skills/axiomcode/reference/impact.md deleted file mode 100644 index 026784d9..00000000 --- a/skills/axiomcode/reference/impact.md +++ /dev/null @@ -1,322 +0,0 @@ -# impact — what a change to a declaration reaches - -The full rules behind `axiomcode impact`. `SKILL.md` has the calling convention and an example; this is why each row says what it says, and what it is measured at. - -**Read-only:** `--no-refresh` (MCP `impact`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. 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 this axiomcode's engine and -says so on the answer's first line, with the reason. - - -`axiomcode impact `, the target written as it appears in the code and its kind read from the index, never guessed: -`Owner.method` · `method` · `file.java:123` (a method), `Owner.field` · `CONSTANT` · `Enum.MEMBER` (a field), `Type` (a class / -interface / enum), `Owner.method(param)` (one parameter), `Type` · `Owner.method` (a type parameter — a generic, or a -bound on it), `Owner.method:name` (a local), `Type.` (its construction) / `Type.` (its static initialization: whoever -first uses the type). Several targets in one call are one change set. A name declared as more than one kind stops and asks for -`--kind`. The same answer shape for every kind and language: - -Every judgement is a rule in `dl/impact.dl`: the Python side exports facts from graph.sqlite once per graph (members, owners, -extends, nesting, decorations, overrides, resolved and unresolved call sites, references with the qualifier written on the -line, type references, string literals, tests and fixtures), writes the target and the few text-level facts for the query, and -runs one Soufflé program, compiled to a native binary once per machine (45-140 s for impact.dl), cached by the program's -hash under `~/.cache/axiomcode/queries/` and shared by every repository and every plugin copy with the same rules. `axiomcode -index` starts that compile in the background when the build starts; a query never waits for it: until it is done, and when -there is no `c++`, the same program runs in the Soufflé interpreter, with the same answer. The compile runs detached, so a -caller with a timeout (`hooks/changes.py` runs impact with `timeout=14` on every edit) cannot kill it half-way. -Direct dependents, the contract, the seeds, the closure, the chains (`parent_up`) and -the tests are all derived in the same run; nothing is recomputed a second way. What is verified afterwards is the export: -every printed chain hop and every `[resolved]` entry is looked up again in `graph.sqlite` (the `verified:` line). - -- **a configuration key is a target** — `axiomcode impact server.error.path`: the methods the container binds it into - (`@Value`, `@ConfigurationProperties`, a `.yml` / `.properties` key), from the engine's framework facts, then everything - that reaches them. No call site carries these edges, so nothing else finds them. A key the engine never saw **stops with - that sentence** — its impact is unknown, not empty — and a graph with no configuration facts at all says so; a key is - never answered as a by-name match on code, which is what made a wrong answer look like an answer. -- **what the container injects** — a type registered as a bean, or a method that defines one, lists the callables the - container hands it to (`ctor_param`, a field injection): `receives it by dependency injection — the container hands it - over, no call site`. Swapping a `@Bean` implementation reaches its consumers this way. - A class that registers the type from another class (`@EnableConfigurationProperties({T.class})`, a `@MapperScan` - or properties package scan) is listed as `registers it as a bean`, and a configuration class lists who is injected - with the beans its own `@Bean` methods define (`is injected with a bean this class defines`). -- **what a framework hands over (Python)**: the engine's `framework_edge` joins a task body to its `.delay()` / - `.apply_async()` producer, a `@receiver` to the `send` of the same signal object, a view to its route table, a - `Depends()` provider to the handler declaring it, and a fixture to the test naming it. The end that hands over is listed - as `[framework]`, with the mechanism, what joined the ends and the engine's confidence: `framework-mediated, not a call: - task_dispatch via delay [registered]`. It ranks below `[remote]` and above every name match, and like `[remote]` it is a - direct row that does not seed the closure. An unrelated method that shares the name (`Animation.delay`) gains nothing. -- **a handler nothing calls is still used** — a declaration handed over as a *value* (`app.get('/orders/:id', getOrder)`, - `background.add_task(send_receipt, id)`, `setTimeout(flush, 1000)`, `handlers = {"x": handle_x}`) has no call site - anywhere: the call happens inside the framework, or later, or never. Every other rule here is about call sites, so this - used to answer *"the declaration is used only where it is declared"* — and `--delete` said **no dependent at any - certainty** — for a live HTTP handler. The reference the parser recorded is read instead, and the site says what will do - the calling: `registered as a GET route "/orders/:id" here — the router calls it, no call site does` when the call is a - route registration (a router verb *and* a string argument that begins with `/` — `get`/`set`/`delete` alone are Map, Set, - Headers and every cache in this ecosystem, so the verb is never matched by itself), otherwise `handed to add_task(…) as a - callback`. It is `[by name]`: the parser says the identifier binds to a callable, not that it binds to *this* one. - Where the engine already resolved the registration to an edge — a JavaScript `app.get('/pads', listPads)` is a resolved - call in that engine — the row stays `[resolved]` and only the sentence changes, so the reader learns that what they are - changing is `GET /pads` rather than that some module calls it. **JavaScript gets the wording and no name-matched rows:** - its `refs` carry the access mode (`IDENTIFIER|READ`) and no entity kind, so nothing there distinguishes a reference to the - declaration from a parameter of the same name. A site-keyed version was written for it and measured on a 124-file Express - application: eleven rows over 30 sampled targets, and all eleven were wrong (seven a parameter named `callback` inside - `forEach(function (callback) {…})`, four a `settle` being *called* inside the `.then(…)` span it sits in). It is not - shipped. The rule needs the parser to say that an identifier binds to a callable, which TypeScript, Python and Java do - and JavaScript does not. -- **must change with it** — declarations bound to the target by a contract the engine resolved: the overrides of a method (and what - it overrides), the subtypes of a type. A signature change reaches these first. -### What breaks a build, and what does not - -The sections are relations, not severities, and reading them top-down as "most to least urgent" is wrong. -Nothing under `produces or writes it` necessarily fails a build: those rows are dataflow — who makes a value of -this shape, including deserialization that writes it reflectively. A `[text]` row under `bound from outside the -source` can never fail a build; the compiler does not read that file at all, which is exactly why it is printed -last and says so. - -For a field, the rows that stop a build are usually in neither list. Changing a field's TYPE changes the -signature of whatever is generated from it — an all-args constructor, a setter, a copy/`with` — and it is the -callers of THOSE that break, at the argument they pass. They touch the generated member, not the field, so no -rule puts them under the field's own relations. The answer now says this directly under the generated-members -line and names the constructor query to run; take that suggestion before acting on the first list. - -- **produces or writes it** — the blast radius read top-down starts where a value of the new shape has to be *made*: setter and - builder calls, constructor calls (declared or generated), and the **holders** — a type with a field of the target's type, where - that holder is constructed or deserialized (`Holder.class` handed to a deserializer or a framework: reflection produces the - field's value there, through the generated setters). A field's declared or generated setter, a generated constructor. -- **why nothing in the graph calls it**: printed where no production caller was found: every reason, strongest first, from - the one reader the hooks' `← ?` label and path's empty-upstream note use (`graph_sql.no_caller_reasons`): an entry point, a - test, a decoration that registers it under a key, a decoration a framework reads (a wrapper such as a cache, a permission - check or a decorator the repository declares is never one), a library method it overrides, the call sites that write its - name on an untyped receiver, a library base of its type, a decoration on its type. `next:` follows the same order. -- **reads or uses it** — every callable whose text uses the declaration, grouped by *why* (calls it, reads it, instantiates it, - names it in a signature, uses a member imported from it, …) and by *how sure*: `[resolved]` an edge the engine resolved (a call - — `[one of a set]` when it is a multi_inferred target set —, an override, a subtype, a constructor; a call written against - the interface or base method this one implements is a direct row too, worded `calls it (via the interface)` or `(via the - base class)`, and `[resolved]` only when nothing else can run there; the Read and Grep hooks count the same callers); `[in scope]` a reference by - that name inside the owner type, a subtype or a nested type; `[by name]` a reference by that name elsewhere — the receiver was - not typed, so it may be a same-named other thing — including a read written through a variable from a callable with no - owner type at all, which is what a module-level function in Python or JavaScript is; `[text]` the name found in the source where the parser records no line (Java - type references in signatures), comments and strings stripped. A bare name inside a type that declares its own member of that - name is that member, not the target; a qualified `X.name` is confirmed when `X` is the owner and dropped when `X` is another - type. For a field, a **declared accessor** in the owner (`getF` / `setF` / `isF` / `f()`) is its door: the accessor's callers are - listed as reading or writing the field through it. A **generating decoration** — Lombok `@Data` / `@Getter` / `@Setter` / - `@Value` / `@Builder` / `@AllArgsConstructor` / `@With`, a record, a dataclass — declares members the source never spells, so a - call to `getZipCode()` or `new Address(…)` is an unresolved site; the unresolved sites written with the generated name are listed - as calling the generated getter / setter / constructor `[by name]`, with the decoration that generates it. Where the ENGINE - synthesises the member instead of leaving the site unresolved (Java's Lombok and records, C#'s auto-properties: a `methods` - row with provenance `generated`), the call site resolves to it and the caller is named `[resolved]` — *reads it through - getName()* — which is the same answer with a stronger claim behind it. A string literal - equal to the field's name (a map key, a serialized name, a request parameter) is listed `[text]`. -- **the upstream answer is measured against behaviour, not against itself** — `validate/upstream.py ` takes a tree with - `.axiomcode/mutation.json` (a method broken, the test files that then failed), asks `impact --tests` which test files - reach it, and classifies every miss from the graph. a JVM HTML parser, 24 methods, 244 (method, test file) pairs: recall 0.795 → **0.988**, - precision 0.328 → 0.338, after the three rules the misses named — a test class that *extends* a reached one runs its tests - (its HTTP-client test classes declare almost nothing: 20 of the 27 misses), a call site written with the target's name - that the engine could not resolve (`import static Outer.Inner` left `res.prepareResponse(…)` untyped: 8 more), and a test - file's import-time code (a class body, a fixture). a Python validation library, 14 methods, 180 pairs: 0.678 → 0.717 — what remains - is dispatch a static graph cannot see (`__eq__` and the other protocol methods the interpreter calls, a method reached - through `getattr(self, f"_{kind}_schema")`), and the answer now says that instead of printing nothing. A TypeScript web framework - (16 methods, 96 pairs, `validate/mutants.py` builds the truth: break a method, run the suite, record - which test FILES newly fail): 0.000 → 0.790. It was zero because a vitest test is an anonymous callback handed to - `it(…)` — 6,661 of its 7,723 callables in test files are `` and two carried a name the old rule accepted, - so the test universe was empty and every answer named no test file at all. A callable registered by `it` / `test` / - `bench` on its own line is a test, and a helper declared beside them carries them. The PARAMETERISED form needs the - call site rather than the line: `test.each` + a template table writes the arrow after the closing backtick, on a - line naming no registrar at all (18 of them in that library), so a callable inside the span of a `TAGGED_TEMPLATE_CALL` - to `each` — its first line read to confirm the receiver the call site does not carry — is a test too. That shape is - vitest / jest / mocha's alone: a pytest test is found by its name however deep the decorator stack, so nothing there - depends on which line the registrar is written on. - **What a selection costs and buys, on that same TypeScript library, measured again with the rungs separated** (a - fresh clone, 311 source files, 130 test files, 5,193 tests in 17 s; 16 methods broken one at a time, 54 (method, - test file) pairs of behavioural truth): recall **0.778**, precision 0.636, and the answer names **4.1 test files of - 130** for a change — 3 % of the suite. Per rung, against that truth: a `[sound]` route (every hop a single resolved - target) is right **29 times in 30**; `[one of a set]` is right 1 in 8; `[by name]` 0 in 1. By distance: 1 hop 0.667, - 2 hops 0.900, 3 or more 1.000 — the far pairs are few and all real. So a pipeline that runs the sound rung first is - almost never wasting a run, and the waste is concentrated in exactly one rung, which is why the rungs are reported - separately rather than blended. Every remaining miss is `no-edge` — a handler the graph has no resolved caller for - (an adapter, a JSX intrinsic element) — not a rule this tool could tighten. - A library is not a service, and the number differs by population: on a Python SERVICE driven through its frameworks - (two Python web frameworks + CLI routes, a pytest suite with conftest fixtures, a decorator registry, a signal loop, 53 - functions broken one at a time, 95 (method, test file) pairs) recall was **0.216** — 26 of 40 answers named no test - file at all — because the suite reaches the code the way the outside world does: through the framework. The - registration-key hop and the injected-fixture rules take it to **0.695** at precision 0.930, and what is still - missing is named rather than guessed: a function reached only through a table or list of functions dispatched by - index (`TRANSFORMS = [strip, upper]`, `EXPORTERS[kind](x)`), a decorator that wraps a callable in an object whose - method calls it (`@shared_task` … `.delay()`), and a closure defined in one method and returned to another. - Held out, on a subject nothing was tuned against (the Python web framework's own 491-test suite, 40 functions broken, 172 pairs): - 0.564 → **0.727**, precision 0.527 → 0.310. Both halves of that trade are real and neither is free — the recall is - routes and fixtures the answer could not see before; the precision is the fan-in of a framework whose every test - builds an app. A key that identifies MANY declarations identifies none: the Python web framework's own suite registers `"/"` from 236 - places and asks for it from 200 more, so a key registering more than `AXIOMCODE_KEY_CAP` (4) declarations is - REFUSED rather than joined — the engine's `fan_capped` judgement one layer up. Uncapped that subject reads 0.791 - recall at 0.248 precision. The same cap applies to the other side (`AXIOMCODE_KEY_USE_CAP`, 4): on the JVM parser the keys - that survive the registration cap are `p`, `b`, `table`, `em` — HTML tag names, written by 356 callables and - "registered" by two, because a decoration argument is not always a registration (`@ValueSource(strings = {"p"})` - is test DATA). One Java method went from naming 1 test file to naming 61 until that cap was added, and 6 after it. - Neither cap needs a catalogue of which decorations register and which do not, which is the point of them. - **A cap and a kind guard answer different questions, and the second is invisible to the first.** A cap says *this - key is too wide to mean anything*; it cannot say *this was never a dispatch key at all*. A test's own decoration - carries its INPUTS — `@ValueSource(strings = {"/htmltests/large.html"})`, `@CsvSource`, `@pytest.mark.parametrize` - — one declaration, a handful of writers, under every cap, and entirely meaningless as a key; and a route mounted - inside a test file is a fixture, not the application's dispatch table (on one TypeScript router library **every** - route registration line, 6,128 of 6,128, is in a test file). So a decoration on a test declaration is not read as - a registration at all, and a route registered in a test file keeps its dependent row and its sentence but is given - no joinable key. -- **precision is not a bug to fix, it is a property to report** — `validate/precision.py ` places every predicted - (method, test file) pair by the worst hop on its best route and by distance, against the same truth. On the JVM parser: a route of - single-target resolved calls is right 0.765 of the time, one through a call resolved to a SET 0.301, through an override - reached from its base 0.170; within 3 hops 0.70, beyond 5 hops 0.24; a sound route within 3 hops 0.889 — but that keeps - only 48 of 219 true pairs. The split that explains the 0.34 overall is fan-in, not error: 10 of the 24 methods are hubs - every test reaches (it parses HTML in every suite) — those answers are 60 of 98 test files at precision 0.285 with - recall 1.000, while the 14 narrow methods score 0.642 with 7 of them exactly right. A test that *reaches* a change and - does not fail is not a wrong edge: it runs the code and does not observe the change. So `--tests` answers "which tests - CAN observe this" and says how sure each route is (`[sound]`, `[one of a set]`, `[dispatch]`, `[by name]`, nearest and - surest first) and, when most of the suite reaches the method, that at this fan-in reaching says little about failing. - It is a ranking, not a test selection; a narrow answer can be used as one. -- **reaches those through resolved calls** — the transitive impact: everything that can reach a touched callable, by hop and by - file, with the entry points among the reached callables *and* the direct dependents (a `@PostMapping` handler that reads the - field is where the change is observed from, though nothing resolved calls it). The tests are always counted by rung, with - the strong-route ones (`[sound]`, `[one of a set]`) named and the top test files; `--tests` lists every one by rung and test - file, `--tests-only` prints only that, `--why` adds each test's shortest chain to the change (and, under each `change:` line, how the target name was resolved: - the lookup step, the declarations weighed with file:line, and why that one won or why nothing matched; `--json` gains a - `why` list), and `--tests-in ` narrows - the listing (not the closure) to test files containing it. Listing all of them with their chains by default was 169k - characters for a hub method — 435 tests, 433 of them on weak routes (#1194). `--json` carries the full list. A test counts when - its own body reaches the change **or a fixture its framework runs before or after it does** (a constructor, a static - initializer, `@Before*`, `@After*`, `setUp`, `tearDown`, MSTest's `[TestInitialize]` / `[TestCleanup]`: a convention table, - printed as such; a teardown that throws fails the test too), **or it names the key the change is registered under** (below). - A `test*` method that overrides a supertype's (a `TestWatcher`'s `testFailed`) is a callback, not a test. - `--in ` and `--depth N` bound it; `--json` is the same answer as data. -- **a registration key is a hop** — a route handler, a signal receiver, a CLI command and a table entry are one shape: the - declaration is registered under a STRING and whoever wants it writes that string, not its name. `@router.post("/orders")` - and `client.post("/orders")`; `@receiver("order_created")` and `emit("order_created", …)`; `@cli.command("price")` and - `invoke(cli, ["price", "4"])`; `@exporter("csv")` and `export(order, "csv")`; a a Python web framework `add_url_rule("/quote/", - view_func=legacy_quote)`, where the declaration is handed over as a value and no call site names it at all. Both ends are - in the graph and nothing joined them, so a test that drove the app through its framework reached nothing — which is most - of what a service's suite does. The two spellings of a path are matched segment by segment (`/orders/o-1/price` against - `/orders/{order_id}/price`, ``, `:id`), never normalised. It is **not** an edge the engine resolved and is never - shown as one: the hop is `[by key]`, and a literal can be a same-valued other thing. Only what the decoration registers - under is a key: a positional string, or a keyword that names it (`path=`, `name=`, `topics=`, `queues=` ...). A configuring - keyword (`mode="before"`, `methods=["GET"]`), a suppression (`@SuppressWarnings("unchecked")`) and a string naming a member - of a type the same decoration names (`@SelectProvider(type = Sql.class, method = "byShelf")`) are not keys. -- **a stub on a mock is NOT a hop** — `when(repo.find(1))`, `verify(repo).save(x)`, `doReturn(v).when(repo).find(1)`, - `mock.Setup(r => r.Find(1))`, `mock.Verify(...)`, `sub.Received().Find(1)`, `sub.Find(1).Returns(v)`: the engine - resolves the call to the declared method, which is right about the name and wrong about execution, since the receiver - is a mock. Such a site is marked by its position against the mocking library's own call (a knob table per language in - `scripts/ax_edges.py`, `STUB_WRAPPERS`), and it is a `[stubs it]` row: a rename or a new parameter breaks it, a body - change never does. It is kept out of the closure, so a test whose only contact is a stub is not counted under `tests:`; - it is listed on its own `[stubs it]` line, and `test-impact` selects it only for a signature change or a removal. A call - in the stub's ARGUMENT list (`when(repo.find(Ids.first()))`) runs for real and stays a route. A test that drives the - class under test with a mock injected still counts through the class under test: the graph cannot see which object - is injected. An entry point of the change that a framework enters (a route handler, a listener) is named on a - `NOT COUNTED` line with the search that finds the tests driving it, since those are counted only where a `[by key]` - route joins them. -- **a test that runs a script as a child process is a hop** — `execFileSync(node, [path.join(__dirname, '..', 'bin', - 'cli.js')])`, `spawn(process.execPath, [require.resolve('../bin/tool')])`, `subprocess.run([sys.executable, SCRIPT])` - with `SCRIPT = os.path.join(HERE, '..', 'scripts', 'report.py')`: the script's module body runs in another process, and - no call site or import says so. When a call that starts a process names, among its arguments, a file this graph indexed - (a literal, a join of literals, or a constant holding one), the test — or the helper beside the tests that makes the - call — is joined to that file's module entry, so everything the script reaches gains the test. The hop is `[spawns]`: - a key (the path), not a call. Reading the same path (`fs.readFileSync`, `open`) starts no process and is not joined, - and a file of another language is in no graph of this one, so it is never joined across languages. -- **a decorator that rebinds the name is a hop** — `@audited def summarise(…)` leaves `summarise` denoting what - `audited(summarise)` RETURNED, so every caller written with that name runs the wrapper. That is the engine's own - resolution (`ext_decorated_name_target`), not a name match, so the hop is `[sound]`; without it a `functools.wraps` - wrapper — retry, cache, login_required, a task — has no caller at all and a change to it reaches nothing. What the - graph still cannot say is the OTHER decorator shape, where the decorator returns an object rather than a function - (`@shared_task` … `.delay()`): there the name denotes an instance, and the engine says so rather than guessing. -- **a fixture the framework injects** — pytest matches a test's PARAMETER NAME against the fixtures visible from its file: - those beside it and those in a `conftest.py` of any ancestor directory, which is not the test's file and is imported by - nothing. A `@pytest.mark.usefixtures` marker names one instead, and an `autouse=True` fixture runs before every test in - its scope without being named anywhere. A fixture may request another fixture, and then both run. None of that is a call. - A route that runs a fixture first is reported as `[fixture]`, and it is the weakest rung above `[by name]`: the - framework does run it and it does reach the change, but the test's own body may never touch it. How often each rung - is right, measured against mutation truth on three Python subjects (`n` is the pairs the rung named, and a rung with - a handful of pairs says nothing — it is printed so you can discount it, not so you can rank on it): - - | rung | small framework service | web framework | CLI library | - |---|---|---|---| - | `[sound]` | 1.000 (n=19) | 0.895 (n=86) | 0.561 (n=132) | - | `[at import]` | 1.000 (n=17) | — | — | - | `[defines]` | — | 1.000 (n=1) | 0.875 (n=8) | - | `[one of a set]` | 1.000 (n=2) | 0.659 (n=44) | 0.657 (n=99) | - | `[by key]` | 0.926 (n=27) | 0.342 (n=73) | 0.000 (n=3) | - | `[decorator by name]` | 1.000 (n=8) | 0.667 (n=3) | — | - | `[protocol]` | 1.000 (n=4) | 0.882 (n=17) | 0.400 (n=5) | - | `[fixture]` | 1.000 (n=27) | 0.382 (n=102) | 0.536 (n=112) | - | `[by name]` | 0.333 (n=3) | 0.531 (n=32) | 0.475 (n=61) | - - `[protocol]` is the newest row and the one to read carefully: its only substantial sample, 17 pairs on the web framework, - puts it at 0.882 — second to `[sound]` on that subject and well above the two rungs printed ABOVE it. That is not - enough to re-rank a ladder on, for the reason the rest of this paragraph gives, but it is enough that a reader - should not discount a `[protocol]` route for its position. - - And read what a rung CLAIMS, not only how often it holds: `[sound]` means a resolved single-target call chain - within three hops — a fact about the edges — and never that the test exercises the change. `[at import]` is the - one rung that is about the test rather than the edge: the module raised while being imported, the file never - loaded, and the test was never collected, so its body is irrelevant. Read that table before trusting the order - the answer prints. The TOP of the ladder holds: `[sound]` and - `[one of a set]` are the best rungs on the subjects with enough pairs to say. BELOW that the order is not stable - across subjects and the printed ranking is a tie-break of what KIND of evidence a hop is, not a measured ordering: - `[by key]` is the best rung on one subject (0.926) and the worst on another (0.342), and `[by name]` is printed - last while measuring above `[by key]` on both of the two large subjects. An answer's label is still the WORST rung - on its route, so it remains a floor — but a `[by name]` route on a library-shaped codebase is not the near-worthless - thing its position suggests. And `[sound]` at 0.561 on the CLI library is the plainest statement of the whole limit: reaching - is not failing, and on a codebase whose tests drive one hub, a resolved call within three hops is right barely more - than half the time. A test - reached BOTH by its own body and through a fixture is reported as the body: the same distance, the stronger claim, - and it moves 37 of the CLI library's pairs off the fixture rung. And what the rules add is a POPULATION effect, not a general - one — on a third held-out subject (a CLI library, 2,058 tests, 40 functions, 293 pairs) they move four - targets and carry 0.802 recall at 0.566 precision, against 0.792 / 0.569 with every framework hop turned off, - because its tests reach its code by CALLING it. The framework hops pay where a framework is in between and very - nearly cancel where it is not: on the CLI library the decorator hop alone adds 3 true pairs and 4 false ones. -- **verified** — every printed edge looked up again in the graph; **bound** counts the unresolved calls inside the impacted - set, so the set is a lower bound on the real one; a **note** counts the entries matched by name or text. - -`--delete` adds a verdict: **is it safe to delete** — the callers and contracts that say no, or, when there are none, exactly -what the graph cannot vouch for (by-name matches, string literals equal to the name — a reflective call, a bean name, a config -key —, the decorations a framework may dispatch on, the unresolved calls inside, the tests that reach it). With **several -targets** (a PR touching many files) each row says which target it came from — `[for Owner.method]` — so a combined radius is -still attributable per change. - -**The unit of change is a declaration in the graph, and half of real Java commits change something else** (592 commits over -five projects: 47 % touch no Java file at all, 30 % touch Java plus a build or resource file). Three of those kinds now have a -target of their own: `@Transactional` (an annotation — every declaration carrying it, and their dependents), `Enum.` (a -constant that does not exist yet — the switches that need a new arm), and a configuration key. A method target also reports -its **throws** contract: adding a checked exception reaches *every* resolved caller, and the answer says how many of them -already catch or declare the ones it has. Still outside the unit, and said rather than guessed: a build file or a dependency -bump, an added overload's rebinding of existing call sites, and what a framework does with an annotation (the proxy, the -transaction, the cache) — `changed` says that in the same line as the decoration change. - -What it cannot see, by construction — say so instead of guessing: a callable that touches a type only through a value it never -names (`t.asStartTag().normalName()` where the engine resolved `normalName` to the inherited `Tag.normalName`) — the graph keeps -no receiver type at a call site, so the compiler sees that dependency and this tool does not; the `[one of a set]` callers are -the engine's over-approximation and most of them will not compile against the change; a bound change on a type parameter -reaches the sites that instantiate `Type<…>`, listed, but nothing checks the argument against the bound; the transitive layer -is the call graph's, so everything `path` cannot find (callbacks handed to a library, reflection, framework dispatch) is a -missing chain here too and is counted in `bound:`, never guessed. What a **decoration turns on** is not in the graph either — -`changed` reports `@Transactional` / `@Cacheable` / a route as a decoration change and says in the same line that the proxying, -the transaction or the cache behind it is invisible; only the code that names it is. Still **not expressible today**, and said -so rather than answered: which `switch` arms an added enum constant breaks, who must catch an added `throws`, which call sites -an added overload rebinds (no argument types per call site), and what a dependency bump reaches (one graph, no library diff). -Test selection from a body change is sound but wide — 41–87 % of a suite on a hub graph — because every path through the hub -is real; narrowing it is ranking, not reachability, and is not attempted here. - -Measured two ways, Java first. (1) A Java defect benchmark: the methods each fix changed as the change set, `--tests` against the tests -it observed failing on the buggy tree — 273 bugs of 17 projects, every triggering test found in 266, trigger recall 0.929, -mean selection 50 % of the suite, and the same verdict as the benchmark's own independent reading of the same graphs in 252 of -256 bugs (better in 3, worse in 1 — a method the fix *added*, absent from the buggy tree); every remaining miss is an engine gap -(an overload set, a callback through `Function.apply`), not a tool loss. (2) The compiler: on five of those projects, 412 sampled -declarations, one edit each — rename a field, a method (all its overloads), a type (plus an empty stub with the old name, so member -uses fail too), a type parameter; remove a parameter — and `javac` over the whole tree names the dependents. Recall: fields 0.997, -methods 1.000, types 0.962, parameters 0.944, type parameters 0.977. Precision by certainty, all kinds: `[resolved]` 528/585, -`[in scope]` 249/256, `[text]` 683/773, `[by name]` 157/291, `[one of a set]` 126/351, contract 69/144 (the compiler confirms -only the override direction that breaks). The harnesses are `impact-arena.py` and `oracle-b.py` next to the arena. (3) By hand, on a -multi-module Spring / SOFA-RPC / Lombok `@Data` system where every model is generated accessors: a `String zipCode` field on a -shared `Address` → the three places an `Integer` breaks (the owner's formatter, the five `getZipCode().length()` / `.trim()` uses -in another service, the generated all-args constructor call in a web controller) and nothing else; a facade method called -through `@SofaReference` fields in two other services → the override, the three callers, the three REST entry points; an enum -member → its one use, with `PaymentStatus.PENDING` and `ShipmentStatus.PENDING` correctly excluded; a shared value type → all six -files, including a chained `product.getPrice().getAmount()` a grep for the type cannot see; a field with declared accessors → -every accessor caller across three services plus the `"stockQuantity"` map key. Other languages share every code path except -the static-import rule (Java syntax) and are not yet measured. - diff --git a/skills/axiomcode/reference/path.md b/skills/axiomcode/reference/path.md deleted file mode 100644 index e20eeeef..00000000 --- a/skills/axiomcode/reference/path.md +++ /dev/null @@ -1,130 +0,0 @@ -# path — the endpoint grammar and what it cannot find - -**Read-only:** `--no-refresh` (MCP `path`: `refresh=false`, or `AXIOMCODE_NO_REFRESH=1`) answers from the graph as it is and -never starts a rebuild; rows in files edited since are still marked. 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 this axiomcode's engine and -says so on the answer's first line, with the reason. - - -- **Start here when you do not have a name yet.** A bare word — one that names nothing exactly, with `'*'` at the - other end — is every declaration CONTAINING it, listed with the count so a wide word is visibly wide, so - `path decrypt '*'` answers "where is the decryption code and what does it touch" — 12 declarations, what they - reach, by hop and by file — without knowing a single exact name first. `path '*' ` is the same in reverse. - This is the way into an unfamiliar repository: get the real names out of the answer, then ask the precise - question with one of them. There is no separate search verb, and none is needed — a name you half remember stops - with the exact names that are close, which is the same lookup. -- **Endpoints are names as written in the code**, never guesses: `Owner.method`, `Outer.Inner.method`, `method` (a free - function, or that name under any owner), `Type` (every method it declares), `file.ts:123` (the callable at that - line, top-level code included), `file.py` (every method in the file). `Outer$Inner.m`, `Outer.Inner#m`, `m(int,String)` - and package-qualified `pkg.Outer.Inner.m` are the same name; a Java nested type is found whether or not the outer is - written (the parser drops it, #667). A name that does not exist stops with the exact names that are close — use one - of those, or a `file:line` from the issue or a stack trace. Built and self-tested for Java, TypeScript, Python - and C#; JavaScript works but the engine's JavaScript output is still moving. -- **`--why` says how each endpoint name was read** (MCP `path`: `why=True`). A block of at most eight lines per endpoint, - right after the answer's first line (or after the refusal when a name matched nothing): the lookup step that matched, - in the order they are tried (a `file:line`, a decoration, a file, then for a name: exact declaration, qualified suffix - (leading segments dropped when they match nothing, or the last segments of a longer qualified name), simple name, - library method, call as written at unresolved sites, type used by name, fragment), the steps that ran before it and - found nothing, up to five candidates with file:line, and why the winner won or why the name fell to "nothing named" - (a qualifier that is a declared type with no such member, a last segment declared under another owner). Use it when - an endpoint is not the declaration you meant. Without `--why` the answer is unchanged; `--json` gains a `why` list. -- **By default the answer is ONE SHORTEST chain per reached target** — it says so on its last line. Other routes exist - and are not listed. `--every` adds all of them: first the complete set of methods and calls that lie on *any* chain - from a source to a target (from Datalog, polynomial — `301 methods and 935 calls` for `Parser.parse → Lexer.emit`), - by file, then the simple paths through it, shortest first, up to `--paths N` (default 20; the count is exponential, - so the set is the complete answer and the list is a sample of it). The `verified:` line means every hop was looked up - again in the graph and a second, independent traversal found the same length; a `✗` means the answer is wrong — report it, do not use it. -- **Every hop reads `[tier · kind @ file:line]`.** The *tier* is how certain the edge is; the *kind* is what sort of - call it is, in one vocabulary that means the same thing in all five languages (`call` · `new` · `ctor` · `super` · - `decorator` · `property` · `method-ref` · `with` · `import` · `eval` · `dynamic`); and the *line* is where the call - is WRITTEN, which is where you check it — the name after the arrow already tells you the callee, and its own - declaration line follows it. The tiers an answer used are legended beneath it, so none of them has to be looked up: - `known_edge` resolved to one declaration, `multi_inferred` several fit and each is real, `dispatch` a base method to - an override the project instantiates, `callback_registered` handed over as a value and invoked by whoever holds it, - `boundary_lib` / `ambient_terminal` into a dependency or the platform, `defines` **not a call at all** — the callee - is written inside that body, so it runs only after it. The engine emits eleven tiers and thirty kinds across the - five languages and they do not share a vocabulary; `scripts/ax_edges.py` is the single table that normalises them, - and an unrecognised tier ranks LAST there rather than being silently treated as certain. -- **The hop count counts calls.** A chain's header says `7 call(s)` — containment hops (`defines`) are listed - separately (`+2 containment hop(s)`) and excluded, because "A reaches B in 11 calls" is false when five of the - eleven are a closure sitting inside a body. -- **`--json`** gives the same answer as one document — every hop with its tier, kind, call site, callee declaration - and whether it is a call — with the prose carried alongside it, so nothing is lost by asking for the machine shape. -- **No chain is an answer with a bound.** "no chain of resolved calls" is followed by whether unresolved sites *would* - connect the two by name, and at which `file:line` — that is the site to read, not a path to claim. The `bound:` line - counts unresolved calls on the chain shown: other chains may exist that the graph cannot see. -- **A hop no call site makes is a hop of the chain, labelled as one.** A request that crosses a process to the handler - that serves it (`[remote · grpc at (exact) · no call site]`) and a hand-over a framework makes (a Python - `.delay()` and the task it enqueues, a signal `send` and its `@receiver`, a test and the fixture it names, a C# - endpoint filter and the endpoint it wraps: `[framework · via () · no call site]`) - are the same hops `impact` lists as `[remote]` / `[framework]` dependents, and the chain walks them, so a client - reaches what its handler calls and a test what its fixture calls. The count says how many hops are calls - (`1 call(s) + 1 hop(s) no call site makes`) and a note under the chain names each such hop's two ends. `path '*' X` - counts the callers reached this way apart from the exact calls. Every language whose engine writes the two relations - gets them; a hop never joins two languages' graphs. -- **A call into a library is an endpoint too — with or without `--library`.** `path '*' 'new ArrayList'`, - `path '*' Files.readAllBytes`, `path '*' readAllBytes`, `path '*' 'Collections.*'`, `path '*' open`: the name as the parser - wrote it at the call site (kind `new` or method, and the receiver written before it), matched at every unresolved site, - in any language. With `--library` staged the same call is a resolved library method and matches by qualified name. A - client declaration always wins over both. The node has in-edges only — nothing is inferred about the library body — and - the answer says how many sites were matched and where. -- **A type the code uses but does not declare is an endpoint**: `path Foo.run File` — every place `File` is touched, - as one target: `new File` at unresolved sites, the library methods of `java.io.File` when staged, - and the methods whose body references the name where the parser gives a line. The answer says which of those it - matched (Java type references carry no line, so there it is the constructor calls and identifier uses). -- **A decoration is an endpoint**: `path '@GetMapping' 'new File'`, `path '@*Mapping' Files.readAllBytes`, `path '@Test' X`, - `path '@Get' '*'`, `path '@Controller' Svc.load` — every method carrying it, so "from any method with this decoration to X" - is one call. A decoration on the **type** is carried by every method that type declares, which is what the class-level form - of every framework needs (`@RestController`, `@Controller`, `@Injectable`, `@Component`, `@Entity`); on one Spring service that is 78 methods for `@*Mapping` where the method-level rows alone are 29. The decorations come from the index's - decorations table **or, where a front end records a decorator as a call and not as a decoration, from those call sites** — - a TypeScript or JavaScript graph has an empty decorations table and its `@Get(':sku')` sitting in `call_sites` as a - `DECORATOR_CALL`, so Nest, Angular and TypeORM used to answer `no method carries @Get` with an empty list of decorations, - which reads as "this repository has no such handler". The owner is the narrowest declaration whose span holds the decorator - line, so `@Get` lands on the method and `@Controller`, which the call site charges to the module, lands on the class. - A graph that records no decoration at all now says so, instead of printing an empty list. -- **End to end, any shape:** `path Type1 method4` asks whether *any* method of Type1 reaches *any* declaration named - method4 — a type on either end is all its methods, a bare name is every declaration under any owner (a free function - in Python/TS/JS has its file as owner). The same rule in every language; nothing is forced to be typed. -- **A name under many owners** (`close`, `run`, `toString`): the closure is computed once from the sources, so a - thousand targets cost nothing; the answer is which owners' declarations are reached and how far, nearest first, - then the nearest chains. Narrow with `Owner.close`, `--in ` (both endpoints restricted to files - containing it), `--limit N`, or `--all` for every chain. -- `Outer$Inner.m` and `Outer$1.m` are looked up through the nesting table, not by string: Inner at any depth inside - Outer; `$N` the N-th anonymous class in source order (javac's numbering — checked against `javap` on a JVM parser's traversal tests, 10/10) or, for an enum, the N-th constant with a body. A miss says which part is wrong: no such - outer / no nested type X (lists them) / only k anonymous classes (with lines) / no method m (lists the methods). -- **One endpoint = a closure, not a chain.** `path '*' X` is everything that can reach X — by hop, by file, and the - *entry points* among them, nearest first. An entry point is decided by one language-neutral fact — nothing resolved - calls it (the caller is outside the graph: a framework, a runner, reflection) or it is a test; a decoration on it is - shown as information, never used to decide. `path X '*'` is everything X reaches, and the library calls X makes itself - (the platform methods where the client graph ends), listed but never traversed. `path '*' X` also lists, apart, the - call sites written with X's name on a receiver the engine could not type (`[by name] `): the - callers `impact X` lists as `[by name]`, so the two verbs name the same direct callers. They are leads, never walked, - and when nothing resolved calls X they are the answer's `next:`. `--in src/main` keeps only the part - under that path; `--depth N` bounds the hops. Each closure is cross-checked against a second, independent traversal (the `verified:` line) - and bounded by the unresolved calls inside it. -- **An empty answer names the framework that owns it.** `path '*' ` for a live route used to print "0 - method(s)", which is true of calls and false of the program. When the upstream closure is empty the registration is - named instead — *create_order is registered as a route "/orders" by @post (app/api.py:43)* — and when two endpoints - have no chain, a key that connects them is reported with the line that writes it, including the two spellings of one - path (`/orders/o-1/price` written against `/orders/{order_id}/price` registered). It is reported, never walked: a - chain here means control reaches B from A *through these calls*, and a registration is not a call. `impact` is the - verb that follows the hop, and the answer says so rather than ending at a dead end. It also says WHY nothing in the - graph calls it, the first two reasons from the same reader the hooks' `← ?` label and impact's `why nothing in the - graph calls` line use: an entry point, a registration, a decoration a framework reads (a wrapper such as a cache is - not one), a library method it overrides, the call sites that write its name, a library base of its type, a - decoration on its type. A caller through an interface or base method the closure does not walk is named there too. The conventions come from the - one module both tools read (`scripts/ax_registration.py`). -- **What it cannot find, by construction** — say so instead of guessing: a call whose receiver the engine could not type - (DI-injected, unbound generic, a parameter in a dynamic language) stops the chain and is counted in `bound:`; callbacks - handed to a library (`executor.submit(task)`, `list.forEach(fn)`) are reached from their definer (`[defines]`) but never - from the library that invokes them; calls the framework makes (HTTP dispatch, JUnit, `main`) have no edge — the callee - is an entry point; reflection / string dispatch / event buses / config-wired beans are invisible; overloads sharing a - name are all resolved together (a signature in the query is stripped); a method overriding a library method is called - by the library, so its upstream ends there; code outside `--src` or in another language is not in the graph; a - by-name or written match can be a same-named other thing. A chain says control can reach B from A through these - calls — nothing about the values that travel it. -- Both directions are tried; the reverse is labelled. -- `axiomcode path --selftest ` replays the engine's own expected edges through the tool and separates engine gaps - from tool losses; run it after touching `dl/path.dl` or the exporter. Needs `souffle` on PATH. - -`scripts/` holds `axiomcode` (the entry) and what it dispatches to: `axiomcode-build` (the pipeline), `axiomcode-index`, `axiomcode-graph`, `viewer.html`, `axiomcode-path` with `dl/path.dl`, `axiomcode-impact` with `dl/impact.dl` (the path tool's resolver and edge facts, its own rules and fact export), `axiomcode-changed` (an edit → the declarations it touched, with the kind of change). diff --git a/skills/axiomcode/reference/schema.md b/skills/axiomcode/reference/schema.md deleted file mode 100644 index 09039f8f..00000000 --- a/skills/axiomcode/reference/schema.md +++ /dev/null @@ -1,106 +0,0 @@ -# schema — which table holds X, per language - -Ask a verb first; open the graph only for a fact no verb prints. Every graph holds ONE language, and the same fact -lives in a different table per language. This page says where, for Python, Java and C#, and what is not recorded -at all, so you stop looking. Measured on a Django app, a Spring Boot app and an ASP.NET app, one fresh index each. - -## Which graph - -| | | -|---|---| -| the main language (most files) | `.axiomcode/out/graph.sqlite`, a symlink to `.axiomcode/out//graph.sqlite` | -| every other language | `.axiomcode/lang//out/graph.sqlite` | -| which one you opened | `sqlite3 -readonly "SELECT value FROM run WHERE key='language'"` | -| tested SQL, caveats, value meanings | the `schema_queries`, `schema_notes`, `schema_vocab` tables in the same file | - -Ids are opaque (`PY_METHOD_…`, `METHOD_REGISTRY_…`, `CS_PROPERTY_…`): join on them, never parse them. `symbols` -holds every declaration of every kind with `file`, `line`, `owner`, `is_test`; `symbols.id` is the id the other -tables use, and `symbols.method_id` / `type_id` join it to `methods` / `types`. - -## Fact by language - -| fact | Python | Java | C# | -|---|---|---|---| -| decoration / annotation / attribute | `decorations`; owner is a method or type | `decorations`; owner is a method, type, field or a **parameter** (`METHOD_PARAMETER_…`, joins nothing) | `decorations`; owner is a method, type or property (`CS_PROPERTY_…`) | -| its name and text | `name` = last dotted segment (`@admin.register(X)` → `register`); `text` = as written, args included | `name` as written after `@`; `text` with args, string quotes tripled (`"""/articles"""`) | `name` as written; `text` = `@Name` **only**, even for `[Endpoint(Name = "x")]`: the arguments are in `literals` at the same file:line | -| base types, resolved | `type_ancestors` (transitive) | `type_ancestors`, library bases included as `types.provenance='external'` | `type_ancestors` (transitive) | -| base types, library / unresolved | **not** in `type_ancestors`: `type_refs` `context='BASE_CLASS'` (last segment only, `Model`) and `ext_type_base_unresolved` (c1 = type id, c3 = text, `models.Model`) | as above; also `type_use` `context='SUPER_TYPE'` with `owner_type_id` | **not** in `type_ancestors`: `type_refs` `context='BASE_LIST'` (name without type args); `ext_type_base_unresolved` c3 = name, but c1 is a declaration group, not a `types.id` | -| entry points | `entry_points(method_id, reason)`: `url`, `orm_hook` seen; rules also emit `http`, `task`, `signal_receiver`, `fixture`, `di_provider`, `grpc_service`. **No** `test` or `main` reason | `test`, `http`, `bean_ctor`, `factory`, `main` seen; also `cli`, `queue`, `scheduled`, `lifecycle`, `spring_factories`; config keys in `ext_config_entry_point` | `test`, `http`, `orm_hook`, `framework_hook`, `main` seen; also `queue`, `grpc_service` | -| field declarations | `symbols` `kind='field'` (`PY_FIELD_…`, `owner` `Form` or `Form.Meta`); `fields` is **empty** | `fields` | `fields` = true fields and consts only; a property is `symbols` `kind='field'` with a `CS_PROPERTY_…` id, and its accessors are `methods` `kind` `PROPERTY_GET` / `PROPERTY_SET` / `PROPERTY_INIT` (`get_X`, `set_X`). In `symbols` every field, const, property and enum member has `owner` = its declaring type (`Outer.Inner` when nested) and `qualified_name` `..` | -| who writes / reads a field | **not recorded**: `field_access` is empty; `refs` `ATTRIBUTE_ACCESS` / `FIELD` is every mention by name and line, read and write alike, with no field id | `field_access` (`access` read / write, `tier`, `caller_id`) | property: `call_edges` `kind` `property_write` / `property_read` to the accessor. Field and const: **not recorded** (`field_access` empty; `refs` `MEMBER_ACCESS` and `NAME_REFERENCE` by name and line, with no field id) | -| call edges | `call_edges`; tiers `known_edge`, `multi_inferred`, `boundary_lib`, `ambiguous_unknown`; kinds `METHOD_CALL`, `SELF_CALL`, `DECORATOR_*`, `PROPERTY_READ`, … | tiers add `ambiguous_anon`; kinds `method`, `new`, `anon_new`, `ctor_delegate`, `ref` | tiers add `known_builtin_operator`, `known_implicit_ctor`; kinds add `property_read`/`_write`, `operator`, `conversion`, `indexer`, `delegate` | -| why a call is unresolved | `ext_call_site_unresolved` (c0 site, c1 caller, c2 reason, c3 call kind) | `unresolved_sites` only, no reason | `ext_site_unresolved_named` (c0 site, c1 receiver type or ``, c2 name) | -| strings in source | `literals(value, file, line)` | `literals`; config keys: `ext_config_binding` (key, mechanism, target kind, target id, owner), `ext_config_class_ref` | `literals` | -| text outside the source (XML, YAML, SQL, …) | **not in the graph**: scanned per query, cached in `.axiomcode/out/dl/nonsource.sqlite` (`files(id, rel)`, `tok(tok, fid)`) | same | same | -| tests | `symbols.is_test` (by file path); no test entry point | `is_test` + `entry_points` `reason='test'` | `is_test` + `entry_points` `reason='test'` | -| test rungs (`[sound]`, `[fixture]`, `[at import]`, …) | **not stored**: computed per query | same | same | - -`field_access`, `type_use` and `type_instantiated` are empty in some languages (`type_use` in Python and C#, -`type_instantiated` in C#): run `SELECT count(*)` before reading an empty answer as "nothing". `overrides` is empty -in Python: its dispatch set is `dispatch_candidates` (basis `mro`). - -## The verb for each fact - -| fact | verb | -|---|---| -| methods carrying a decoration | `path '@login_required' '*'` (or `'@GetMapping'`): the decorated methods and what they reach | -| subtypes of a type | `impact `: "must change with it" | -| who writes a Java field | `impact .`: "produces or writes it" | -| who writes a C# property | `impact .` (or `:`): readers and writers `[resolved]` through its accessors | -| who reads a C# field or const | `impact .`: readers `[in scope]` inside the type, `[by name]` elsewhere, since no C# field access is resolved; `:` of a field answers nothing (no callable spans it), so ask by name | -| a Python field | `impact .` lists readers `[in scope]` / `[by name]` only; there is no writer section, because no writer relation exists | -| text files naming a declaration | `impact X`: "bound from outside the source"; `context ""`: "text files that name these" | -| a config key or a quoted string | `impact app.cache.ttl` · `impact '"some-string"'` | -| test rungs and routes | `impact X --tests-only --why` · `test-impact --why` | -| entry points by reason | no verb: SQL below | - -## Queries - -```sh -G=.axiomcode/out/graph.sqlite -# [all] decorations, with the owner whatever its kind (a Java parameter's owner comes back NULL) -sqlite3 -readonly $G "SELECT d.text, s.kind, s.qualified_name, d.file, d.line FROM decorations d - LEFT JOIN symbols s ON s.id = d.owner_id WHERE d.name = 'GetMapping'" -# [all] entry points by reason, then one reason's methods -sqlite3 -readonly $G "SELECT reason, count(*) FROM entry_points GROUP BY 1" -sqlite3 -readonly $G "SELECT m.qualified_name, m.file_path, m.start_line FROM entry_points e - JOIN methods m ON m.id = e.method_id WHERE e.reason = 'http'" -# [all] resolved ancestors (Java: library ones too, provenance 'external') -sqlite3 -readonly $G "SELECT a.qualified_name, a.provenance FROM type_ancestors x JOIN types t ON t.id = x.type_id - JOIN types a ON a.id = x.ancestor_type_id WHERE t.name = ''" -# [python] library bases, full text as written -sqlite3 -readonly $G "SELECT t.qualified_name, u.c3 FROM ext_type_base_unresolved u JOIN types t ON t.id = u.c1" -# [csharp] library bases: the owner is the innermost type whose span holds the base-list line -sqlite3 -readonly $G "SELECT r.name, (SELECT t.qualified_name FROM types t WHERE t.file_path = r.file - AND r.line BETWEEN t.start_line AND t.end_line ORDER BY t.start_line DESC LIMIT 1) AS owner - FROM type_refs r WHERE r.context = 'BASE_LIST'" -# [java] field writers -sqlite3 -readonly $G "SELECT m.qualified_name, a.file_path, a.start_line, a.tier FROM field_access a - JOIN fields f ON f.id = a.field_id JOIN methods m ON m.id = a.caller_id - WHERE f.owner_qualified_name LIKE '%.' AND f.name = '' AND a.access = 'write'" -# [csharp] property writers (property_read for readers) -sqlite3 -readonly $G "SELECT c.qualified_name, s.file_path, s.start_line FROM call_edges e - JOIN methods t ON t.id = e.callee_method_id JOIN methods c ON c.id = e.caller_id - JOIN call_sites s ON s.id = e.call_site_id WHERE e.kind = 'property_write' AND t.name = 'set_'" -# [all] tiers in this graph; [python] what the unresolved sites are waiting on -sqlite3 -readonly $G "SELECT tier, count(*) FROM call_edges GROUP BY 1" -sqlite3 -readonly $G "SELECT c2, count(*) FROM ext_call_site_unresolved GROUP BY 1 ORDER BY 2 DESC" -``` - -## Traps - -- **Paths.** Java `methods`, `types`, `fields`, `call_sites` and `field_access` hold ABSOLUTE paths; its `symbols`, - `decorations` and `type_refs` hold repo-relative ones, as every Python and C# table does. Match Java with - `LIKE '%/rel/path.java'`. -- **Lines.** Java `type_refs` rows carry `line = 0` in every context but the two annotation ones: locate a Java - base through `type_use` or the type. A C# `BASE_LIST` line is where the base list is written, which is below - `types.start_line` when attributes or a line break come first: join by span, not by equal line. In a graph built - before the field-line fix, every Python field line is one early (0-based): a nested class's first field sits on - its `class Meta:` line. -- **Names.** Python `type_refs` and `decorations` keep only the last dotted segment; the full text is in - `ext_type_base_unresolved.c3` and `decorations.text`. String cells are CSV-escaped: match with `LIKE '%x%'`. - JavaScript `symbols`: a field (`this.x = …` in a constructor or constructor function, a class field) has its class as - `owner` (`Store.items`); a member with a computed key is named by the key as written (`Tagged.[Symbol.hasInstance]`); - an anonymous class expression takes the name it is bound to (`static Inner = class {…}` → `Outer.Inner`). -- **`ext_*` tables** have positional columns `c0…cN`; `SELECT description FROM schema_tables WHERE name = ''` - names them. diff --git a/tests/directive.py b/tests/directive.py index 7c572f16..76576227 100644 --- a/tests/directive.py +++ b/tests/directive.py @@ -74,7 +74,7 @@ def check(why, cond, detail=''): rc, out, _ = fire(repo, 'Grep', {'pattern': r'doWork\('}) first = ctx(out) check('a search for a declared method is told that declaration, where it is, and the impact call for it', - rc == 0 and first and 'Worker.doWork' in first and 'src/Worker.java:7' in first and 'axiomcode_impact' in first + rc == 0 and first and 'Worker.doWork' in first and 'src/Worker.java:7' in first and 'impact(name="src/Worker.java:7")' in first and 'never spell the name' in first, f'out={out[:300]}') rc, out, _ = fire(repo, 'Grep', {'pattern': 'Worker'}) @@ -133,7 +133,7 @@ def check(why, cond, detail=''): check('the declaration inside the searched path is the one named, not the first in the repository', rc == 0 and ctx(out) and 'lib/view.py:5' in ctx(out) and 'src/' not in ctx(out), f'out={out[:200]}') - rc, out, _ = fire(repo, 'mcp__plugin_axiomcode_axiomcode__axiomcode_impact', {'targets': ['Worker.doWork']}, session='s4') + rc, out, _ = fire(repo, 'mcp__plugin_axiomcode_axiomcode__impact', {'name': 'Worker.doWork'}, session='s4') rc2, out2, _ = fire(repo, 'Grep', {'pattern': 'doWork'}, session='s4') check('an agent that already called the graph through MCP is not told about it afterwards', rc == 0 and out == '' and rc2 == 0 and out2 == '', f'out={out2[:120]}') diff --git a/tests/freshness.py b/tests/freshness.py index d2c7d82d..c106676f 100644 --- a/tests/freshness.py +++ b/tests/freshness.py @@ -813,33 +813,22 @@ def mcp_checks(): m = importlib.util.module_from_spec(spec) import io, contextlib with contextlib.redirect_stderr(io.StringIO()): spec.loader.exec_module(m) - check("mcp: context, path and impact take fresh", all('fresh' in m.PARAMS.get(t, []) for t in ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact')), - {t: m.PARAMS.get(t) for t in ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact')}) + # 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') + 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 '' - fn = lambda name: getattr(m, name) - try: - fn('axiomcode_impact')(['X'], repo='.', fresh=True); fn('axiomcode_path')('A', 'B', fresh=True); fn('axiomcode_context')('t', fresh=True) - fn('axiomcode_impact')(['X'], repo='.') - except TypeError as e: - seen.append(str(e)) - check("mcp: fresh=true passes --fresh to the CLI, and only when asked", - len(seen) == 4 and all('--fresh' in s for s in seen[:3]) and '--fresh' not in seen[3], seen) + 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) + 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')) - tools = ('axiomcode_context', 'axiomcode_path', 'axiomcode_impact', 'axiomcode_changed', 'axiomcode_test_impact', 'axiomcode_graph') - check("mcp: every query tool takes refresh", all('refresh' in m.PARAMS.get(t, []) for t in tools), {t: m.PARAMS.get(t) for t in tools}) - seen.clear() - fn('axiomcode_impact')(['X'], refresh=False); fn('axiomcode_path')('A', 'B', refresh=False); fn('axiomcode_context')('t', refresh=False) - fn('axiomcode_changed')(refresh=False); fn('axiomcode_test_impact')(refresh=False); fn('axiomcode_graph')(refresh=False) - fn('axiomcode_impact')(['X']); fn('axiomcode_changed')(); fn('axiomcode_graph')() - check("mcp: refresh=false passes --no-refresh to the CLI, and only when asked", - len(seen) == 9 and all('--no-refresh' in s for s in seen[:6]) and not any('--no-refresh' in s for s in seen[6:]), seen) 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) - check("mcp: the CLI's no_refresh is refused, naming refresh", 'refresh' in (m.unknown_arguments('axiomcode_impact', {'no_refresh': True}) or ''), - m.unknown_arguments('axiomcode_impact', {'no_refresh': True})) - 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 new file mode 100644 index 00000000..73f2d034 --- /dev/null +++ b/tests/front_door.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""tests/front_door.py — the four 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, +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. + + 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 + 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. + + python3 tests/front_door.py indexes one small Python repository, so it needs the engine +""" +import json, os, re, shutil, subprocess, sys, tempfile, threading + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CLI = os.path.join(ROOT, 'bin', 'axiomcode') +AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') +SERVER = os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp', 'server.py') +FILES = { + 'shop/__init__.py': '', + 'shop/rates.py': 'def vat_rate():\n return 0.2\n', + 'shop/pricing.py': ('from shop.rates import vat_rate\n\n\n' + 'def total(prices):\n net = sum(prices)\n return net * (1 + vat_rate())\n\n\n' + 'def invoice(prices):\n return {"total": total(prices), "net": sum(prices)}\n'), + 'tests/__init__.py': '', + 'tests/test_pricing.py': ('from shop.pricing import invoice\n\n\n' + 'def test_invoice_total():\n assert abs(invoice([10])["total"] - 12) < 1e-9\n'), +} +# the caller's own settings must not choose the engine or the shape +ENV = {k: v for k, v in os.environ.items() if k not in ('AXIOMCODE_ENGINE', 'AXIOMCODE_RAW', 'AXIOMCODE_FRONT', 'AXIOMCODE_SURFACE')} +ENV['AXIOMCODE_REFRESH_INTERVAL'] = '0' +PLACE = re.compile(r'^\d+\. \S+:\d+', re.M) +FENCE = re.compile(r'^\s*```python\s*$', re.M) + +fails, checked = [], [] +def check(why, cond, detail=''): + checked.append(why) + print(('ok ' if cond else 'FAIL ') + why + (f'\n {detail}' if not cond and detail else '')) + if not cond: fails.append(why) + + +def cli(repo, *args, env=None): + r = subprocess.run(['bash', CLI, *args], cwd=repo, capture_output=True, text=True, timeout=600, env=env or ENV) + return r.returncode, r.stdout, r.stderr + + +def places(out): + return bool(PLACE.search(out)) and bool(FENCE.search(out)) and '→' in out + + +def mcp(repo, calls): + """tools/list, then each (name, arguments) as tools/call, on the SDK-free server started in repo""" + frames = [{'jsonrpc': '2.0', 'id': 1, 'method': 'initialize', + 'params': {'protocolVersion': '2025-06-18', 'capabilities': {}, 'clientInfo': {'name': 'tests', 'version': '0'}}}, + {'jsonrpc': '2.0', 'method': 'notifications/initialized'}, + {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list'}] + frames += [{'jsonrpc': '2.0', 'id': 3 + i, 'method': 'tools/call', 'params': {'name': n, 'arguments': a}} + for i, (n, a) in enumerate(calls)] + p = subprocess.Popen([sys.executable, '-S', SERVER], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, cwd=repo, text=True, env=ENV) + timer = threading.Timer(600, p.kill); timer.start() + got = {} + try: + for f in frames: + p.stdin.write(json.dumps(f) + '\n'); p.stdin.flush() + if 'id' not in f: continue + while f['id'] not in got: + line = p.stdout.readline() + if not line: return got + try: m = json.loads(line) + except ValueError: continue + if 'id' in m: got[m['id']] = m.get('result') or {} + return got + finally: + timer.cancel(); p.stdin.close(); p.wait() + + +def main(): + work = tempfile.mkdtemp(prefix='ax-front-door-') + try: + repo = os.path.join(work, 'shop') + for rel, text in FILES.items(): + os.makedirs(os.path.dirname(os.path.join(repo, rel)), exist_ok=True) + with open(os.path.join(repo, rel), 'w') as f: f.write(text) + git = lambda *a: subprocess.run(['git', '-c', 'user.name=t', '-c', 'user.email=t@t', *a], cwd=repo, capture_output=True, text=True) + git('init', '-q'); git('add', '-A'); git('commit', '-qm', 'init') + rc, out, err = cli(repo, 'index', '--lang', 'python') + check('the repository indexes', rc == 0 and os.path.exists(os.path.join(repo, '.axiomcode', 'out', 'graph.sqlite')), (out + err)[-400:]) + 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:]) + 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 : 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) + and 'shop/pricing.py:10' in out and 'shop/pricing.py:6' in out, out[:600] + err[-300:]) + + with open(os.path.join(repo, 'shop', 'rates.py'), 'w') as f: f.write('def vat_rate():\n return 0.25\n') + rc, out, err = cli(repo, 'impact') + 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]) + 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'})]) + 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: 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]) + + # ── 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) + check('CONTROL: the dispatcher run directly gives the old answer, no fenced block', + r.returncode == 0 and '```' not in r.stdout and 'reads or uses it' in r.stdout, r.stdout[:600]) + rc, out, err = cli(repo, 'impact', 'vat_rate', '--json') + try: doc = json.loads(out) + except ValueError: doc = None + check('CONTROL: bin/axiomcode with --json gives the old answer, the JSON document, no fenced block', + isinstance(doc, dict) and '```' not in out, out[:400]) + rc, out, err = cli(repo, 'impact', 'vat_rate', env=dict(ENV, AXIOMCODE_RAW='1')) + check('CONTROL: AXIOMCODE_RAW=1 at the installed command gives the old answer, no fenced block', + rc == 0 and '```' not in out and 'reads or uses it' in out, out[:600]) + rc, out, err = cli(repo, 'tests', '--why') + check('CONTROL: tests with a flag gives the old answer, no fenced block', '```' not in out and bool(out.strip()), out[:600]) + finally: + shutil.rmtree(work, ignore_errors=True) + print(f'\n{len(checked) - len(fails)} of {len(checked)} check(s) held') + return 1 if fails else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/graph_verb.py b/tests/graph_verb.py index 2f680ffa..594cf387 100644 --- a/tests/graph_verb.py +++ b/tests/graph_verb.py @@ -173,8 +173,8 @@ def check(ok, why, detail=''): 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) - doc = open(MCP).read(); i = doc.index('def axiomcode_graph('); doc = doc[i:i + 1200] - check('it was indexed with' in doc and 'absolute path' in doc, 'the MCP axiomcode_graph description says the same', doc) + # 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') diff --git a/tests/hooks_from_path.py b/tests/hooks_from_path.py index a771dd73..a52833a3 100644 --- a/tests/hooks_from_path.py +++ b/tests/hooks_from_path.py @@ -121,13 +121,13 @@ def write(base, files): # it speaks once per session (stamped in the temp directory), so each check gets a session no earlier run used sid = lambda s: f'{s}-{os.getpid()}-{int(time.time() * 1000)}' d = fire(ws, sid('d1'), 'Grep', {'pattern': 'findById', 'path': app}, hook='direct.py', event='PreToolUse') - check('the directive speaks before a Grep by absolute path from a directory with no graph', 'axiomcode_impact' in d and 'OrderStore.java' in d, d) + check('the directive speaks before a Grep by absolute path from a directory with no graph', 'impact(name=' in d and 'OrderStore.java' in d, d) check('control: the directive is silent before a Grep of a tree with no graph', fire(ws, sid('d2'), 'Grep', {'pattern': 'findById', 'path': plain}, hook='direct.py', event='PreToolUse') == '') check('the directive is silent on a shell grep of a file that is not source', fire(app, sid('d3'), 'Bash', {'command': 'grep -n findById notes.md'}, hook='direct.py', event='PreToolUse') == '') d = fire(app, sid('d4'), 'Bash', {'command': f'grep -n findById {os.path.relpath(store, app)}'}, hook='direct.py', event='PreToolUse') - check('control: the directive speaks on a shell grep of a source file for a declared method', 'axiomcode_impact' in d, d) + check('control: the directive speaks on a shell grep of a source file for a declared method', 'impact(name=' in d, d) # ── orientation ────────────────────────────────────────────────────────────────────────────────────── for i in range(30): diff --git a/tests/latency.py b/tests/latency.py index 99f2abdb..ad406498 100644 --- a/tests/latency.py +++ b/tests/latency.py @@ -32,7 +32,7 @@ python3 tests/latency.py [--no-engine] """ -import builtins, importlib.util, json, os, shutil, subprocess, sys, tempfile, time +import builtins, importlib.util, json, os, re, shutil, subprocess, sys, tempfile, time ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SCRIPTS = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts') @@ -154,8 +154,10 @@ def verbs_checks(): front = subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), '--verbs'], capture_output=True, text=True).stdout.split() r = subprocess.run(['bash', os.path.join(ROOT, 'bin', 'axiomcode'), 'no-such-verb'], capture_output=True, text=True) listed = next((l.split(':', 1)[1].split() for l in r.stderr.splitlines() if l.strip().startswith('ask:')), []) - check("verbs: bin/axiomcode knows exactly the verbs the frontend dispatches (less its `tests` alias)", - listed == [v for v in front if v != 'tests'], (listed, front)) + public = re.findall(r'^\s*axiomcode ([a-z][a-z-]*)\b', subprocess.run(['bash', os.path.join(SCRIPTS, 'axiomcode'), '--help'], + capture_output=True, text=True).stdout, re.M) + check("verbs: bin/axiomcode offers exactly the verbs the frontend's help advertises, and the frontend dispatches each", + sorted(listed) == sorted(public) and bool(public) and set(public) <= set(front), (listed, public, front)) work = tempfile.mkdtemp(prefix='axiomcode-verbs-') try: log = os.path.join(work, 'spawned') diff --git a/tests/manifests.py b/tests/manifests.py index 679f9105..24310091 100644 --- a/tests/manifests.py +++ b/tests/manifests.py @@ -172,11 +172,13 @@ def gemini_path(value): if re.search(r'(? 2): + bad.append(f"{label}: {name} takes {params}, want {TOOLS[name]} (at most two)") content = replies.get(3, {}).get('result', {}).get('content', []) if not any(c.get('type') == 'text' and c.get('text') for c in content): bad.append(f"{label}: tools/call returned no text: {replies.get(3)}") @@ -379,7 +343,7 @@ def main(): # 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_grep_default() + bad += check_front_door() if os.name != 'nt': bad += check_install_move(work) bad += check('symlinked axiomcode mcp', [link, 'mcp'], repo) diff --git a/tests/mcp_docs.py b/tests/mcp_docs.py index 45c67f3a..c6da2d9b 100644 --- a/tests/mcp_docs.py +++ b/tests/mcp_docs.py @@ -1,16 +1,16 @@ #!/usr/bin/env python3 """tests/mcp_docs.py: every MCP argument the skill documents is one the tool it names accepts. -SKILL.md and reference/*.md tell an agent which MCP arguments to pass (`full=True`, `limit=N`, `page="all"`, -`range='a..b'`, `files=[…]`). An argument the tool's schema does not take is refused, and the agent that followed the -docs is left with an error and no answer. The docs name the arguments in prose, so this reads them the way an agent -does: in each paragraph, list item or table row that speaks of MCP, every `name=value` belongs to the nearest tool named -before it (`axiomcode_impact`, MCP `impact`, `changed --range …`; a run like "`impact`, `path` and `context`" is one -group, and every tool in it must take the argument). The schemas are the server's own tools/list, from the SDK-free +SKILL.md (and reference/*.md, when there is one) tells an agent which MCP arguments to pass (`find(question="…")`, +`impact(name="…")`, `path(start="…", end="…")`). An argument the tool's schema does not take is refused, and the agent +that followed the docs is left with an error and no answer. The docs name the arguments in prose, so this reads them the +way an agent does: in each paragraph, list item or table row that speaks of a tool, every `name=value` belongs to the +nearest tool named before it (`impact(`, MCP `impact`, `mcp__plugin_axiomcode_axiomcode__impact`; a run like "`impact` +and `path`" is one group, and every tool in it must take the argument). The schemas are the server's own tools/list, from the SDK-free fallback, so what is checked is what a client is offered. Both skill copies are read (plugins/axiomcode/skills/axiomcode and the root skills/axiomcode), and -plugins/axiomcode/AGENTS.md and rules/axiomcode.mdc, which name the same tools. An argument a schema +plugins/axiomcode/AGENTS.md and rules/axiomcode.mdc, which name the same tools, and the block `axiomcode install` writes. An argument a schema takes and the docs never mention is fine. A documented argument with no tool named before it is a failure too: the agent cannot tell which tool takes it. @@ -25,11 +25,13 @@ 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'(index|context|impact|path|changed|test[-_]impact|graph)' -# a tool named: `axiomcode_impact`, bare axiomcode_impact, MCP `impact`, or a command in backticks (`changed --range x`); -# `path:line: …` is an answer's shape, not the tool -MENTION = re.compile(r'`(?:axiomcode[ _])?' + VERB + r'(?:[ \t][^`\n]*)?`|\baxiomcode_' + VERB + r'\b') +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 +# in backticks (`axiomcode impact x`); `path:line: …` is an answer's shape, not the tool +MENTION = re.compile(r'`(?:axiomcode )?' + VERB + r'(?:[ \t][^`\n]*)?`|\bmcp__plugin_axiomcode_axiomcode__' + VERB + r'\b' + r'|(?= 0, text[:200]) check(f'{surface}: {v} keeps its shell form, for a host without the MCP server', s >= 0, text[:200]) if t >= 0 and s >= 0: @@ -58,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()), ('context', 'impact', 'path')) + tool_first('SKILL.md description', ' '.join(m.group(1).split()), ('find', '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, ('context', 'impact', 'path')) +tool_first('install block', r.stdout, ('find', 'impact', 'path', 'tests')) # 3. the directive before the first search for a name the graph declares with tempfile.TemporaryDirectory() as repo: @@ -77,8 +79,8 @@ def fire(hook, ev): rc, said = fire('direct.py', {'hook_event_name': 'PreToolUse', 'tool_name': 'Grep', '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: changed / test-impact are about an edit, not about what a grep looks for - tool_first('direct', said, ('impact', 'path', 'context')) + # 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')) # 4. the orientation on the first prompt, both branches it can reach: a change question and a how-question with tempfile.TemporaryDirectory() as work: @@ -95,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, ('context',)) + tool_first('orient (how)', said, ('find',)) # 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("the axiomcode_context tool with in_path=") +i = src.find("find(question=\"\") (mcp__plugin_axiomcode_axiomcode__find) ranks") 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('\n', src.find("')", i))], ('context',)) + tool_first('orient (scope refused)', src[i:src.find("')", src.find('`axiomcode find', i))], ('find',)) 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 d47a84ce..3a18427b 100644 --- a/tests/repo_arg.py +++ b/tests/repo_arg.py @@ -65,30 +65,20 @@ def no_graph(where, what): if r.returncode == 0 or missing not in r.stderr: bad.append(f"{verb} {args}: not refused (exit {r.returncode}): {r.stderr.strip()[:200]!r}") no_graph(cwd, verb) - # 3. the MCP tools: a missing repo= is refused before anything runs; an existing one, and the default, pass through + # 3. the MCP tools take no repository: each asks about the session's own directory, and a repo= is refused as an + # unknown argument before anything runs sys.path.insert(0, os.path.join(ROOT, 'plugins', 'axiomcode', 'mcp')) import server seen = [] real, server.run = server.run, (lambda args, *a, **k: seen.append(args) or 'ran') try: - calls = {'axiomcode_index': lambda r: server.axiomcode_index(repo=r), - 'axiomcode_context': lambda r: server.axiomcode_context('how', repo=r), - 'axiomcode_path': lambda r: server.axiomcode_path('a', 'b', repo=r), - 'axiomcode_impact': lambda r: server.axiomcode_impact(['foo'], repo=r), - 'axiomcode_changed': lambda r: server.axiomcode_changed(repo=r), - 'axiomcode_test_impact': lambda r: server.axiomcode_test_impact(repo=r), - 'axiomcode_graph': lambda r: server.axiomcode_graph(repo=r)} + calls = {'find': lambda: server.find('how'), 'path': lambda: server.path('a', 'b'), + 'impact': lambda: server.impact('foo'), 'tests': lambda: server.tests()} for name, call in calls.items(): - seen.clear() - try: - call(missing); bad.append(f"{name}(repo=): not refused") - except Exception as e: - if missing not in str(e): bad.append(f"{name}(repo=): the error does not name the path: {e}") - if seen: bad.append(f"{name}(repo=): ran {seen[0]} anyway") - seen.clear(); call(repo) - if not seen or repo not in seen[0]: bad.append(f"{name}(repo=): did not run with it: {seen}") - seen.clear(); call('.') - if not seen: bad.append(f"{name}(repo='.'): did not run") + seen.clear(); call() + if not seen or seen[0][-1] != os.getcwd(): bad.append(f"{name}(): did not ask about the working directory: {seen}") + if not server.unknown_arguments(name, {'repo': repo}): + bad.append(f"{name}(repo=…): not refused") finally: server.run = real diff --git a/tests/surfaces.py b/tests/surfaces.py index 30234cc9..ecc8e356 100644 --- a/tests/surfaces.py +++ b/tests/surfaces.py @@ -1,100 +1,159 @@ #!/usr/bin/env python3 -"""tests/surfaces.py — every verb the dispatcher dispatches is documented on every caller-facing surface. +"""tests/surfaces.py — the public verbs are on every caller-facing surface, and nothing else is. -The defect this exists for (#1034): `context` and `test-impact` both worked, both documented themselves -properly under their own `--help`, and appeared on NONE of the surfaces a caller actually looks at. The -cause was three separate hand-maintained lists, none derived from the `case` statement that dispatches. -`axiomcode --help` is now derived from the dispatcher's own comment block, so it cannot drift; SKILL.md -and the MCP server still cannot be, and this is what says so out loud when one of them falls behind. +The product offers a small surface: `index` to set up, then four questions — `find`, `impact`, `path`, `tests` — +answered as numbered places with the code of the function each sits in. Every public verb must be: -The fourth surface is `bin/axiomcode` — the command an install actually puts on $PATH (#1107). Every -query verb was implemented, shipped and unreachable from it, and `axiomcode path A B` was silently -taken for a build of a directory called `path`. That surface is checked BY RUNNING IT, not by reading -it: the verb must dispatch, not merely be mentioned. + · in `axiomcode --help` (the dispatcher's own comment block) and in `bin/axiomcode --help`, the command an install + puts on $PATH (#1107). That surface is checked BY RUNNING IT: the installed command and the frontend are given the + same argv and must produce the same bytes, so a verb listed and not dispatched is caught; + · a section of SKILL.md; + · an MCP tool of the same name (index excepted: the first query builds the graph). - python3 tests/surfaces.py +Every other verb the dispatcher still dispatches (the hooks, the suites and scripts call them with their flags) is +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. -A verb that is deliberately not exposed on a surface goes in EXEMPT with the reason, so the exemption is -a written decision rather than a silent gap. + python3 tests/surfaces.py """ import os, re, subprocess, sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PLUG = os.path.join(ROOT, 'plugins', 'axiomcode') -AX = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts', 'axiomcode') +SCRIPTS = os.path.join(PLUG, 'skills', 'axiomcode', 'scripts') +AX = os.path.join(SCRIPTS, 'axiomcode') SKILL = os.path.join(PLUG, 'skills', 'axiomcode', 'SKILL.md') MCP = os.path.join(PLUG, 'mcp', 'server.py') CLI = os.path.join(ROOT, 'bin', 'axiomcode') # the command an install puts on $PATH -# verb -> surfaces it is deliberately absent from, and why -EXEMPT = { - 'install': {'mcp', 'skill_section'}, # writes CLAUDE.md once at setup; not a query an agent issues per turn - 'index': {'skill_section'}, # covered by the Start here table and the four rules, not its own section - 'graph': {'skill_section'}, # produces a page for a human, documented in Reference - 'changed': set(), +PUBLIC = ['index', 'find', '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', + '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'} + + +def dispatched(): + """the verbs of the dispatch table, as the dispatcher itself lists them (`--verbs`)""" + return subprocess.run(['bash', AX, '--verbs'], capture_output=True, text=True).stdout.split() + + +def advertised(help_text): + return re.findall(r'^\s*axiomcode ([a-z][a-z-]*)\b', help_text, re.M) + + +def readme_cli(): + text = open(os.path.join(ROOT, 'README.md'), encoding='utf-8').read() + i = text.index('## CLI commands') + return text[i:text.index('\n## ', i + 1)] -def verbs(): - """the dispatch table is the source of truth: the verbs of `case "$cmd" in`, aliases split out""" - src = open(AX).read() - body = src[src.index('case "$cmd" in'):src.index('\nesac')] + +def docs(): + """(label, text) of every agent-facing text that teaches the surface""" out = [] - for m in re.finditer(r'^\s{2}([a-z][a-z|-]*)\)', body, re.M): - out += [v for v in m.group(1).split('|')] - return [v for v in out if v not in ('build', 'tests')] # aliases of index / test-impact + 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 + + +def doc_findings(label, text): + bad = [] + for m in OLD_TOOLS.finditer(text): + bad.append(f"{label}: names the old MCP tool {m.group(0)}") + for flag in sorted(set(re.findall(r'(?` is NOT the check: it has - # its own branch, and it kept answering while the verb dispatch beneath it was broken — which - # is exactly the shape of #1107. So the installed command and the frontend are given the same - # argv and must produce the same bytes; if bin/axiomcode handles the verb itself (or falls - # through to a build) they differ. + if v not in NO_MCP and v not in tools: + bad.append(f"{v}: no MCP tool {v}") + # RUN IT, THROUGH THE VERB'S OWN CASE: `axiomcode help ` has its own branch and kept answering while the + # dispatch beneath it was broken (#1107), so the installed command and the frontend get the same argv direct = subprocess.run(['bash', AX, v, '--help'], capture_output=True, text=True) viacli = subprocess.run(['bash', CLI, v, '--help'], capture_output=True, text=True) if (viacli.stdout, viacli.stderr) != (direct.stdout, direct.stderr): bad.append(f"{v}: `axiomcode {v}` does not reach the frontend — the installed command answers it itself") if len(direct.stdout.strip()) < 20: bad.append(f"{v}: the frontend prints no usage for it, so the comparison above proves nothing") + for v in INTERNAL: + if v in tools: bad.append(f"{v}: internal, and still an MCP tool") + if re.search(r'^## .*\b%s\b' % re.escape(v), skill, re.M): bad.append(f"{v}: internal, and still a SKILL.md section") + if tools != {v for v in PUBLIC if v not in NO_MCP}: + bad.append(f"the MCP tools are {sorted(tools)}, want {sorted(v for v in PUBLIC if v not in NO_MCP)}") + for label, text in docs(): + bad += doc_findings(label, text) - # a typo must not be taken for a source tree (#1107): `*) cmd=all` used to make it one + # a typo must not be taken for a source tree (#1107), and the verbs it offers are the public ones r = subprocess.run(['bash', CLI, 'impackt'], capture_output=True, text=True) if r.returncode == 0 or 'neither a verb nor a directory' not in r.stderr: bad.append("an unknown verb is not refused — it is still being taken for a build") + offered = next((l.split(':', 1)[1].split() for l in r.stderr.splitlines() if l.strip().startswith('ask:')), []) + if sorted(offered) != sorted(PUBLIC): + bad.append(f"a typo is offered {offered}, want the public verbs {PUBLIC}") # THE FRONTMATTER IS YAML, AND NOT EVERY READER IS LENIENT. A plain scalar may not contain `: ` or ` #`: # strict parsers read the first as a nested mapping and the second as a comment, so the description - # that decides when the skill fires fails to load ("mapping values are not allowed here") wherever the - # file is parsed properly, though the harness that loads it accepted it. Checked without PyYAML, which - # a plain checkout does not have: a value that is quoted or a block scalar (`>`, `|`) is left alone. + # that decides when the skill fires fails to load wherever the file is parsed properly. Checked without PyYAML: + # a value that is quoted or a block scalar (`>`, `|`) is left alone. fm = skill.split('---', 2)[1] if skill.startswith('---') else '' for line in fm.splitlines(): m = re.match(r'^([A-Za-z_-]+):[ \t]+(.*)$', line) if m and not m.group(2).startswith(('"', "'", '>', '|')) and re.search(r': | #', m.group(2)): bad.append(f"SKILL.md frontmatter: `{m.group(1)}` is a plain YAML scalar containing ': ' or ' #' — " f"quote it or make it a block scalar (`{m.group(1)}: >-`)") - print(f"dispatched verbs: {', '.join(vs)} (surfaces: bin/axiomcode --help, its dispatch, skill --help, SKILL.md, MCP)") + print(f"dispatched verbs: {', '.join(vs)}; public: {', '.join(PUBLIC)}; MCP tools: {', '.join(sorted(tools))}") for b in bad: print("FAIL " + b) if bad: - print(f"\n{len(bad)} surface(s) behind the dispatcher — document the verb, or add it to EXEMPT with the reason.") + print(f"\n{len(bad)} failure(s)") return 1 - print(f"ok — {len(vs)} verbs, every surface present (exemptions: " + - ", ".join(f"{k}:{'/'.join(sorted(s))}" for k, s in EXEMPT.items() if s) + ")") + print(f"ok — {len(PUBLIC)} public verbs on every surface, {len(INTERNAL)} internal verbs on none") return 0 if __name__ == '__main__': From b79147e4aed46db11dae6b01fa2c03480be4688d Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 02:39:29 -0700 Subject: [PATCH 5/6] tests/run.py --jobs N runs cases side by side; CI runs the query cases four at a time The query-case step made engine (typescript) a 28-minute job (19 of them the cases, one after another). Each case indexes its own directory and shares nothing but the compiled rules, so the first case runs alone (it compiles and caches them) and the rest run N at a time; each case's output is printed as one block, in case order. TypeScript locally: 175 s -> 41 s with --jobs 6, the same 204/204 checks and the same case order and outcomes. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- tests/run.py | 27 +++++++++++++++++++++------ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5987254c..cb6710f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,7 @@ jobs: env: AXIOM_PARSER: ${{ github.workspace }}/parser/dist/index.js AXIOM_SOUFFLE_CACHE: ${{ github.workspace }}/.souffle-cache - run: python3 tests/run.py --lang ${{ matrix.lang }} + run: python3 tests/run.py --lang ${{ matrix.lang }} --jobs 4 # the small surface as users and agents get it: find / impact / path / tests through the installed command and # the MCP server, answered as places with their code, and the direct calls and flags that keep the old answers diff --git a/tests/run.py b/tests/run.py index a35f564a..863a7c06 100755 --- a/tests/run.py +++ b/tests/run.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""tests/run.py [ …] [--lang java|python|typescript|javascript] [--keep] [-v] +"""tests/run.py [ …] [--lang java|python|typescript|javascript] [--keep] [-v] [--jobs N] What the plugin CLAIMS to find, checked on code that is small enough to read. Each case is a directory under tests/cases/// holding a tiny synthetic project and a case.json: @@ -32,26 +32,29 @@ and neither announces itself as a want/avoid mismatch. If anyone ever "simplifies" `pending` into a skip, that is the property they will have removed. """ -import json, os, re, shutil, subprocess, sys +import builtins, concurrent.futures, io, json, os, re, shutil, subprocess, sys HERE = os.path.dirname(os.path.abspath(__file__)); ROOT = os.path.dirname(HERE) AX = os.path.join(ROOT, 'plugins', 'axiomcode', 'skills', 'axiomcode', 'scripts', 'axiomcode') args = sys.argv[1:]; keep = '--keep' in args; verbose = '-v' in args lang = args[args.index('--lang') + 1] if '--lang' in args else None -only = [a for a in args if not a.startswith('-') and a not in (lang,)] +jobs = int(args[args.index('--jobs') + 1]) if '--jobs' in args else 1 +only = [a for a in args if not a.startswith('-') and a not in (lang, str(jobs))] cases = [] for l in sorted(os.listdir(os.path.join(HERE, 'cases'))): if lang and l != lang: continue d = os.path.join(HERE, 'cases', l) for c in sorted(os.listdir(d)): if os.path.isfile(os.path.join(d, c, 'case.json')) and (not only or c in only or l in only): cases.append((l, c, os.path.join(d, c))) -fail = tot = pend = 0 -for l, name, path in cases: +def run_case(case): + """one case: index it, run its checks; its output as one block and its counts, so cases can run side by side""" + l, name, path = case; buf = io.StringIO(); fail = tot = pend = 0 + def print(*a, flush=False, **k): builtins.print(*a, file=buf, **k) print(f"… {l}/{name}", flush=True) spec = json.load(open(os.path.join(path, 'case.json'))) build = ['bash', AX, 'index', path, '--lang', spec.get('lang', l)] + (['--src', spec['src']] if spec.get('src') else []) \ + (['--library', os.path.join(path, spec['library'])] if spec.get('library') else []) # a staged dependency root, relative to the case r = subprocess.run(build, capture_output=True, text=True) - if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); fail += 1; continue + if r.returncode: print(f"FAIL {l}/{name}: index failed: {(r.stderr or r.stdout)[-300:]}"); return buf.getvalue(), 0, 1, 0 for stmt in spec.get('sql', []): # facts a framework extension would have written subprocess.run(['sqlite3', os.path.join(path, '.axiomcode', 'out', 'graph.sqlite'), stmt], capture_output=True, text=True) for ch in spec['checks']: @@ -97,5 +100,17 @@ print(' ' + '\n '.join(text.strip().split('\n')[:14])) elif verbose: print(f"ok {l}/{name}: {ch['why']}") if not keep: shutil.rmtree(os.path.join(path, '.axiomcode'), ignore_errors=True) + return buf.getvalue(), tot, fail, pend + + +# CASES RUN SIDE BY SIDE with --jobs N: each indexes its own directory and shares nothing but the compiled rules, so the +# first case runs alone (it compiles and caches them) and the rest run N at a time. Output is printed in case order. +fail = tot = pend = 0 +def report(res): + global fail, tot, pend + text, t, f, p = res; sys.stdout.write(text); sys.stdout.flush(); tot += t; fail += f; pend += p +if cases: report(run_case(cases[0])) +with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, jobs)) as ex: + for res in ex.map(run_case, cases[1:]): report(res) print(f"\n{tot - fail - pend} of {tot} check(s) passed in {len(cases)} case(s)" + (f" - {pend} PENDING" if pend else '') + ('' if not fail else f" - {fail} FAILED")) sys.exit(1 if fail else 0) From 1d50368e31470ddd27144b5667748ce340b5eb35 Mon Sep 17 00:00:00 2001 From: swapnil Date: Wed, 30 Sep 2026 02:55:39 -0700 Subject: [PATCH 6/6] tests: the uncertain-row evidence case runs in each language's own leg typescript/evidence-for-uncertain-rows indexed five languages, so the typescript leg compiled the java, csharp, python and javascript engines it has no cache for: 964 s of the leg's 19-minute query-case step, where the median case takes 4 s. The case is now one per language (ts 9 checks, js 1, python 1, java 2, csharp 1), each indexed in the leg whose engine is already built; the 14 checks are unchanged. Co-authored-by: axiomcode-bot[bot] <334110751+axiomcode-bot[bot]@users.noreply.github.com> --- .../evidence-for-uncertain-rows/case.json | 21 +++ .../evidence-for-uncertain-rows/cs/Stock.cs | 0 .../evidence-for-uncertain-rows/case.json | 31 ++++ .../java/app/Cache.java | 0 .../java/app/Pipeline.java | 0 .../java/app/Store.java | 0 .../evidence-for-uncertain-rows/case.json | 22 +++ .../evidence-for-uncertain-rows/js/client.js | 0 .../evidence-for-uncertain-rows/js/store.js | 0 .../evidence-for-uncertain-rows/case.json | 22 +++ .../evidence-for-uncertain-rows/py/billing.py | 0 .../evidence-for-uncertain-rows/py/gateway.py | 0 .../evidence-for-uncertain-rows/case.json | 171 ++++++++++++------ 13 files changed, 213 insertions(+), 54 deletions(-) create mode 100644 tests/cases/csharp/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => csharp}/evidence-for-uncertain-rows/cs/Stock.cs (100%) create mode 100644 tests/cases/java/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => java}/evidence-for-uncertain-rows/java/app/Cache.java (100%) rename tests/cases/{typescript => java}/evidence-for-uncertain-rows/java/app/Pipeline.java (100%) rename tests/cases/{typescript => java}/evidence-for-uncertain-rows/java/app/Store.java (100%) create mode 100644 tests/cases/javascript/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => javascript}/evidence-for-uncertain-rows/js/client.js (100%) rename tests/cases/{typescript => javascript}/evidence-for-uncertain-rows/js/store.js (100%) create mode 100644 tests/cases/python/evidence-for-uncertain-rows/case.json rename tests/cases/{typescript => python}/evidence-for-uncertain-rows/py/billing.py (100%) rename tests/cases/{typescript => python}/evidence-for-uncertain-rows/py/gateway.py (100%) diff --git a/tests/cases/csharp/evidence-for-uncertain-rows/case.json b/tests/cases/csharp/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..f745109f --- /dev/null +++ b/tests/cases/csharp/evidence-for-uncertain-rows/case.json @@ -0,0 +1,21 @@ +{ + "lang": "csharp", + "src": ".", + "checks": [ + { + "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", + "run": [ + "impact", + "cs/Stock.cs:5", + "--evidence", + "--grep" + ], + "want": [ + "decided L11: private readonly dynamic _meter; [field \u00b7 type dynamic]" + ], + "avoid": [ + "decided L10" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs b/tests/cases/csharp/evidence-for-uncertain-rows/cs/Stock.cs similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/cs/Stock.cs rename to tests/cases/csharp/evidence-for-uncertain-rows/cs/Stock.cs diff --git a/tests/cases/java/evidence-for-uncertain-rows/case.json b/tests/cases/java/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..4ba282c0 --- /dev/null +++ b/tests/cases/java/evidence-for-uncertain-rows/case.json @@ -0,0 +1,31 @@ +{ + "lang": "java", + "src": ".", + "checks": [ + { + "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", + "run": [ + "impact", + "java/app/Pipeline.java:7", + "--evidence", + "--grep" + ], + "want": [ + "decided L6: private final Function loader; [field \u00b7 type Function]" + ] + }, + { + "why": "Java: path's hop through the same call carries the same decider", + "run": [ + "path", + "Cache.load", + "Pipeline.apply", + "--evidence", + "--grep" + ], + "want": [ + "private final Function loader;" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java b/tests/cases/java/evidence-for-uncertain-rows/java/app/Cache.java similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/java/app/Cache.java rename to tests/cases/java/evidence-for-uncertain-rows/java/app/Cache.java diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java b/tests/cases/java/evidence-for-uncertain-rows/java/app/Pipeline.java similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/java/app/Pipeline.java rename to tests/cases/java/evidence-for-uncertain-rows/java/app/Pipeline.java diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java b/tests/cases/java/evidence-for-uncertain-rows/java/app/Store.java similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/java/app/Store.java rename to tests/cases/java/evidence-for-uncertain-rows/java/app/Store.java diff --git a/tests/cases/javascript/evidence-for-uncertain-rows/case.json b/tests/cases/javascript/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..e5351252 --- /dev/null +++ b/tests/cases/javascript/evidence-for-uncertain-rows/case.json @@ -0,0 +1,22 @@ +{ + "lang": "javascript", + "src": ".", + "checks": [ + { + "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", + "run": [ + "impact", + "js/store.js:2", + "--evidence", + "--grep" + ], + "want": [ + "decided L11: cached(res) { [param]", + "decided L16: export function remote(api) { [param]" + ], + "avoid": [ + "this.shelf = new Shelf()" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/client.js b/tests/cases/javascript/evidence-for-uncertain-rows/js/client.js similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/js/client.js rename to tests/cases/javascript/evidence-for-uncertain-rows/js/client.js diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/js/store.js b/tests/cases/javascript/evidence-for-uncertain-rows/js/store.js similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/js/store.js rename to tests/cases/javascript/evidence-for-uncertain-rows/js/store.js diff --git a/tests/cases/python/evidence-for-uncertain-rows/case.json b/tests/cases/python/evidence-for-uncertain-rows/case.json new file mode 100644 index 00000000..d449c094 --- /dev/null +++ b/tests/cases/python/evidence-for-uncertain-rows/case.json @@ -0,0 +1,22 @@ +{ + "lang": "python", + "src": ".", + "checks": [ + { + "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", + "run": [ + "impact", + "py/gateway.py:2", + "--evidence", + "--grep" + ], + "want": [ + "py/billing.py:33: return self.gw.charge(-amount)", + "decided L30: self.gw = gw [assigned]" + ], + "avoid": [ + "def __init__(self, gw: Gateway): [constructor parameter" + ] + } + ] +} diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py b/tests/cases/python/evidence-for-uncertain-rows/py/billing.py similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/py/billing.py rename to tests/cases/python/evidence-for-uncertain-rows/py/billing.py diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py b/tests/cases/python/evidence-for-uncertain-rows/py/gateway.py similarity index 100% rename from tests/cases/typescript/evidence-for-uncertain-rows/py/gateway.py rename to tests/cases/python/evidence-for-uncertain-rows/py/gateway.py diff --git a/tests/cases/typescript/evidence-for-uncertain-rows/case.json b/tests/cases/typescript/evidence-for-uncertain-rows/case.json index c301fdb6..bbad3d4d 100644 --- a/tests/cases/typescript/evidence-for-uncertain-rows/case.json +++ b/tests/cases/typescript/evidence-for-uncertain-rows/case.json @@ -1,88 +1,151 @@ { - "lang": "typescript,javascript,python,java,csharp", + "lang": "typescript", "src": ".", "checks": [ { "why": "a [by name] row carries the line that decides its receiver: a Map field and an `any` parameter show the name-match is another type; the resolved row gets no evidence", - "run": ["impact", "ts/orders.ts:2", "--evidence", "--grep"], - "want": ["ts/callers.ts:16: return this.seen.get(key);", "decided L14: private readonly seen = new Map(); [field · type new Map(…)]", - "decided L20: export function viaAny(box: any, id: string): string { [param · type any]", "only through it:"], - "avoid": ["decided L7"] + "run": [ + "impact", + "ts/orders.ts:2", + "--evidence", + "--grep" + ], + "want": [ + "ts/callers.ts:16: return this.seen.get(key);", + "decided L14: private readonly seen = new Map(); [field \u00b7 type new Map(\u2026)]", + "decided L20: export function viaAny(box: any, id: string): string { [param \u00b7 type any]", + "only through it:" + ], + "avoid": [ + "decided L7" + ] }, { "why": "a dispatch-key row decides on where the key comes from, and a registration row on the constant it registers under, followed into the module that defines it", - "run": ["impact", "ts/bus.ts:16", "--evidence", "--grep"], - "want": ["decided L10: emit(topic: string, id: string): void { [dispatch key topic]", - "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]"] + "run": [ + "impact", + "ts/bus.ts:16", + "--evidence", + "--grep" + ], + "want": [ + "decided L10: emit(topic: string, id: string): void { [dispatch key topic]", + "decided ts/callers.ts:4: export const ORDER_PLACED = 'order.placed'; [registration key ORDER_PLACED]" + ] }, { "why": "--json rows gain evidence {call, decider} and only_through {callables, tests}; alongside rows become a count", - "run": ["impact", "ts/orders.ts:2", "--evidence", "--json"], + "run": [ + "impact", + "ts/orders.ts:2", + "--evidence", + "--json" + ], "stdout_json": true, - "want": ["\"evidence\"", "\"decider\"", "\"only_through\"", "\"callables\"", "\"alongside_count\""] + "want": [ + "\"evidence\"", + "\"decider\"", + "\"only_through\"", + "\"callables\"", + "\"alongside_count\"" + ] }, { "why": "the prose answer says the same, in one section after the rows it qualifies", - "run": ["impact", "ts/orders.ts:2", "--evidence"], - "want": ["evidence for the 2 strongest non-exact rows", "call L16: return this.seen.get(key);", "only through it: 1 callable(s), 0 test(s)", "--drop asks again without that row"] + "run": [ + "impact", + "ts/orders.ts:2", + "--evidence" + ], + "want": [ + "evidence for the 2 strongest non-exact rows", + "call L16: return this.seen.get(key);", + "only through it: 1 callable(s), 0 test(s)", + "--drop asks again without that row" + ] }, { "why": "--exact asks again with exact edges only: the name-matches and what stands on them go, the resolved caller stays", - "run": ["impact", "ts/orders.ts:2", "--exact", "--grep"], - "want": ["return this.store.get(id);", "asked again with exact edges only"], - "avoid": ["box.get(id)", "this.seen.get(key)"] + "run": [ + "impact", + "ts/orders.ts:2", + "--exact", + "--grep" + ], + "want": [ + "return this.store.get(id);", + "asked again with exact edges only" + ], + "avoid": [ + "box.get(id)", + "this.seen.get(key)" + ] }, { "why": "--drop asks again without that one row", - "run": ["impact", "ts/orders.ts:2", "--drop", "ts/callers.ts:21", "--grep"], - "want": ["this.seen.get(key)", "without ts/callers.ts:21"], - "avoid": ["box.get(id)"] + "run": [ + "impact", + "ts/orders.ts:2", + "--drop", + "ts/callers.ts:21", + "--grep" + ], + "want": [ + "this.seen.get(key)", + "without ts/callers.ts:21" + ], + "avoid": [ + "box.get(id)" + ] }, { "why": "control: an answer with only [resolved] rows is byte-identical with evidence on and off", - "run": ["impact", "ts/ledger.ts:2", "--evidence"], - "same_as": ["impact", "ts/ledger.ts:2", "--no-evidence"], - "want": ["[resolved] sum"] + "run": [ + "impact", + "ts/ledger.ts:2", + "--evidence" + ], + "same_as": [ + "impact", + "ts/ledger.ts:2", + "--no-evidence" + ], + "want": [ + "[resolved] sum" + ] }, { "why": "control: the same resolved-only answer as --json and as grep rows is byte-identical too", - "run": ["impact", "ts/ledger.ts:2", "--evidence", "--json"], - "same_as": ["impact", "ts/ledger.ts:2", "--json"], + "run": [ + "impact", + "ts/ledger.ts:2", + "--evidence", + "--json" + ], + "same_as": [ + "impact", + "ts/ledger.ts:2", + "--json" + ], "stdout_json": true }, { "why": "control: evidence is off by default, so an answer with uncertain rows is the answer it was", - "run": ["impact", "ts/orders.ts:2", "--grep"], - "same_as": ["impact", "ts/orders.ts:2", "--no-evidence", "--grep"], - "avoid": ["decided ", "only through it"] - }, - { - "why": "JavaScript: an untyped parameter is the decider of a name-match; a field assigned in the constructor decides nothing that is resolved", - "run": ["impact", "js/store.js:2", "--evidence", "--grep"], - "want": ["decided L11: cached(res) { [param]", "decided L16: export function remote(api) { [param]"], - "avoid": ["this.shelf = new Shelf()"] - }, - { - "why": "Python: the receiver's type is set from an __init__ parameter of its OWN class, not the same-named attribute another class in the file annotates", - "run": ["impact", "py/gateway.py:2", "--evidence", "--grep"], - "want": ["py/billing.py:33: return self.gw.charge(-amount)", "decided L30: self.gw = gw [assigned]"], - "avoid": ["def __init__(self, gw: Gateway): [constructor parameter"] - }, - { - "why": "Java: a one-of-a-set call through a JDK functional interface decides on the field that holds it", - "run": ["impact", "java/app/Pipeline.java:7", "--evidence", "--grep"], - "want": ["decided L6: private final Function loader; [field · type Function]"] - }, - { - "why": "Java: path's hop through the same call carries the same decider", - "run": ["path", "Cache.load", "Pipeline.apply", "--evidence", "--grep"], - "want": ["private final Function loader;"] - }, - { - "why": "C#: a dynamic field is why the call is a name-match; the resolved call on the typed field beside it gets nothing", - "run": ["impact", "cs/Stock.cs:5", "--evidence", "--grep"], - "want": ["decided L11: private readonly dynamic _meter; [field · type dynamic]"], - "avoid": ["decided L10"] + "run": [ + "impact", + "ts/orders.ts:2", + "--grep" + ], + "same_as": [ + "impact", + "ts/orders.ts:2", + "--no-evidence", + "--grep" + ], + "avoid": [ + "decided ", + "only through it" + ] } ] }