weavatrix 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -0
  2. package/docs/releases/v0.3.10.md +76 -0
  3. package/docs/releases/v0.3.9.md +36 -0
  4. package/package.json +4 -2
  5. package/scripts/run-agent-task-benchmark.mjs +0 -1
  6. package/skill/SKILL.md +13 -6
  7. package/src/analysis/change-classification.js +9 -19
  8. package/src/analysis/dead-check.js +8 -6
  9. package/src/analysis/dead-code-review.js +3 -0
  10. package/src/analysis/dep-rules.js +1 -1
  11. package/src/analysis/git-history/collector.js +1 -43
  12. package/src/analysis/git-history.js +2 -1
  13. package/src/analysis/git-ref-graph.js +4 -10
  14. package/src/analysis/hot-path-review.js +1 -1
  15. package/src/analysis/http-contracts/analysis.js +9 -1
  16. package/src/analysis/internal-audit/repo-files.js +2 -6
  17. package/src/analysis/structure/findings.js +21 -1
  18. package/src/analysis/task-retrieval.js +2 -0
  19. package/src/build-graph.js +9 -1
  20. package/src/git-exec.js +67 -0
  21. package/src/graph/builder/lang-rust.js +36 -0
  22. package/src/graph/freshness-probe.js +3 -6
  23. package/src/graph/graph-filter.js +5 -3
  24. package/src/graph/incremental-refresh.js +1 -1
  25. package/src/graph/internal-builder.build.js +2 -1
  26. package/src/graph/internal-builder.langs.js +11 -24
  27. package/src/mcp/actions/graph-lifecycle.mjs +3 -3
  28. package/src/mcp/catalog.mjs +2 -2
  29. package/src/mcp/evidence-snapshot.structure.mjs +5 -2
  30. package/src/mcp/git-output.mjs +2 -5
  31. package/src/mcp/graph/context-seeds.mjs +1 -0
  32. package/src/mcp/graph/context-state.mjs +4 -5
  33. package/src/mcp/graph/tools-query.mjs +28 -2
  34. package/src/mcp/health/audit-format.mjs +2 -5
  35. package/src/mcp/health/endpoints.mjs +34 -5
  36. package/src/mcp/sync/payload-v2.mjs +1 -0
  37. package/src/mcp/tools-company.mjs +9 -0
  38. package/src/mcp/tools-endpoints.mjs +54 -12
  39. package/src/mcp/tools-graph-hubs.mjs +1 -0
  40. package/src/mcp/tools-impact-change.mjs +2 -3
  41. package/src/mcp/tools-impact.mjs +9 -1
  42. package/src/mcp-rg.mjs +2 -4
  43. package/src/mcp-source-tools.mjs +4 -5
  44. package/src/precision/lsp-overlay/build.js +9 -4
  45. package/src/precision/lsp-overlay/contract.js +1 -1
  46. package/src/precision/lsp-overlay/target-index.js +8 -3
  47. package/src/precision/symbol-query.js +2 -1
  48. package/src/process.js +18 -1
@@ -59,6 +59,37 @@ function pathAttribute(modNode) {
59
59
  return "";
60
60
  }
61
61
 
62
+ // Test-only classification must mean "compiled exclusively under `cargo test`": #[cfg(test)] and
63
+ // #[cfg(all(test, ...))] guarantee that; #[cfg(any(test, ...))] also compiles into production builds
64
+ // and deliberately stays unclassified. #[test]/#[bench] (incl. #[tokio::test]-style harness paths)
65
+ // mark the annotated function itself.
66
+ const CFG_TEST_RE = /#\s*!?\s*\[\s*cfg\s*\(\s*(?:test\s*\)|all\s*\(\s*test\b)/;
67
+ const TEST_FN_ATTR_RE = /#\s*\[\s*(?:[\w]+(?:\s*::\s*[\w]+)*\s*::\s*)?(?:test|bench)\s*(?:\]|\()/;
68
+ const CFG_TEST_CARRIERS = new Set(["mod_item", "impl_item", "trait_item", "function_item"]);
69
+ const COMMENT_TYPES = new Set(["line_comment", "block_comment"]);
70
+ const itemAttributeText = (item) => {
71
+ // Comments are NAMED siblings in tree-sitter-rust — `#[test]` + `// note` + `fn` must still classify.
72
+ let text = "";
73
+ for (let prev = item?.previousNamedSibling; prev; prev = prev.previousNamedSibling) {
74
+ if (COMMENT_TYPES.has(prev.type)) continue;
75
+ if (prev.type !== "attribute_item") break;
76
+ text += prev.text + "\n";
77
+ }
78
+ return text;
79
+ };
80
+ // `#![cfg(test)]` at the top of a file or module body gates everything below it. tree-sitter-rust
81
+ // tokenizes a file-leading `#!...` as `shebang`; a real shebang line never matches CFG_TEST_RE.
82
+ const innerCfgTest = (body) => (body?.namedChildren || [])
83
+ .some((child) => (child.type === "inner_attribute_item" || child.type === "shebang") && CFG_TEST_RE.test(child.text));
84
+ const underCfgTest = (node) => {
85
+ for (let parent = node?.parent; parent; parent = parent.parent) {
86
+ if (parent.type === "declaration_list" && innerCfgTest(parent)) return true;
87
+ if (CFG_TEST_CARRIERS.has(parent.type) && CFG_TEST_RE.test(itemAttributeText(parent))) return true;
88
+ if (!parent.parent && innerCfgTest(parent)) return true; // source_file root
89
+ }
90
+ return false;
91
+ };
92
+
62
93
  function inlineAncestors(node) {
63
94
  const result = [];
64
95
  for (let p = node?.parent; p; p = p.parent) {
@@ -129,10 +160,15 @@ export default {
129
160
  const memberOf = ownerName(owner, field);
130
161
  const symbolKind = cap.name === "function" ? (memberOf ? "method" : "function") : cap.name;
131
162
  const visibility = publicVisibility(declaration, owner);
163
+ const attrs = itemAttributeText(declaration);
164
+ const testSurface = CFG_TEST_RE.test(attrs)
165
+ || (cap.name === "function" && TEST_FN_ATTR_RE.test(attrs))
166
+ || underCfgTest(declaration);
132
167
  const id = addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "function", {
133
168
  sourceNode: declaration,
134
169
  selectionNode: cap.node,
135
170
  symbolKind,
171
+ ...(testSurface ? { testSurface: true } : {}),
136
172
  ...(memberOf ? { memberOf, visibility } : { moduleDeclaration: true }),
137
173
  ...(!memberOf && visibility === "public" ? { exported: true } : {}),
138
174
  });
@@ -2,10 +2,9 @@
2
2
  // authority when something changed; this exact Git/worktree token is safe to cache in-process and in
3
3
  // versioned graph metadata when HEAD + dirty/untracked/control-file content remain unchanged.
4
4
  import {createHash} from 'node:crypto'
5
- import {spawnSync} from 'node:child_process'
6
5
  import {readFileSync, statSync} from 'node:fs'
7
6
  import {createRequire} from 'node:module'
8
- import {childProcessEnv} from '../child-env.js'
7
+ import {runGit} from '../git-exec.js'
9
8
  import {createRepoBoundary} from '../repo-path.js'
10
9
 
11
10
  const REPOSITORY_FRESHNESS_PROBE_V = 1
@@ -28,15 +27,13 @@ const CURRENT_GRAPH_SCHEMA = Object.freeze({
28
27
  barrelResolutionV: 1,
29
28
  reExportOccurrencesV: 1,
30
29
  symbolSpacesV: 1,
31
- extractorSchemaV: 5,
30
+ extractorSchemaV: 6,
32
31
  })
33
32
  const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
34
33
  const MAX_CONTROL_BYTES = 1_000_000
35
34
 
36
35
  function git(repoRoot, args) {
37
- const result = spawnSync('git', ['-C', repoRoot, ...args], {
38
- encoding: 'buffer', timeout: 8000, windowsHide: true, env: childProcessEnv(), maxBuffer: 16 * 1024 * 1024,
39
- })
36
+ const result = runGit(repoRoot, args, {encoding: 'buffer', maxBuffer: 16 * 1024 * 1024})
40
37
  return result.status === 0 ? Buffer.from(result.stdout || '') : null
41
38
  }
42
39
 
@@ -22,12 +22,14 @@ export function filterGraphForMode(graph, mode, { repoRoot = null } = {}) {
22
22
  const links = graph.links || [];
23
23
  const endpoint = (value) => (value && typeof value === "object" ? value.id : value);
24
24
  const classifier = createPathClassifier(repoRoot);
25
- const isTest = (path) => hasPathClass(classifier.explain(path), "test", "e2e");
25
+ // A node is a test surface when its file is test-classified OR the extractor proved the symbol
26
+ // itself is compiled only under test (Rust #[cfg(test)] inline modules live in production files).
27
+ const isTest = (node) => node.test_surface === true || hasPathClass(classifier.explain(node.source_file), "test", "e2e");
26
28
  let keep;
27
29
  if (mode === "no-tests") {
28
- keep = new Set(nodes.filter((node) => !isTest(node.source_file)).map((node) => node.id));
30
+ keep = new Set(nodes.filter((node) => !isTest(node)).map((node) => node.id));
29
31
  } else {
30
- const testIds = new Set(nodes.filter((node) => isTest(node.source_file)).map((node) => node.id));
32
+ const testIds = new Set(nodes.filter((node) => isTest(node)).map((node) => node.id));
31
33
  keep = new Set(testIds);
32
34
  for (const link of links) {
33
35
  if (testIds.has(endpoint(link.source))) keep.add(endpoint(link.target)); // a test's dependency
@@ -184,7 +184,7 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
184
184
 
185
185
  if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
186
186
  || !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
187
- || Number(existingGraph.extractorSchemaV) < 5 || Number(existingGraph.physicalFileLocV) < 1
187
+ || Number(existingGraph.extractorSchemaV) < 6 || Number(existingGraph.physicalFileLocV) < 1
188
188
  || Number(existingGraph.reExportOccurrencesV) < 1
189
189
  || Number(existingGraph.symbolSpacesV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
190
190
  return full("incremental-baseline-unavailable");
@@ -148,6 +148,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
148
148
  } : {}),
149
149
  ...(complexity ? { complexity } : {}),
150
150
  ...(extra && extra.exported ? { exported: true } : {}),
151
+ ...(extra && extra.testSurface ? { test_surface: true } : {}),
151
152
  ...(extra && extra.decorated ? { decorated: true } : {}),
152
153
  ...(extra && extra.symbolKind ? { symbol_kind: extra.symbolKind } : {}),
153
154
  ...(extra && extra.symbolSpace ? { symbol_space: extra.symbolSpace } : {}),
@@ -265,7 +266,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
265
266
  barrelResolutionV: 1,
266
267
  reExportOccurrencesV: 1,
267
268
  symbolSpacesV: 1,
268
- extractorSchemaV: 5,
269
+ extractorSchemaV: 6,
269
270
  reExportOccurrences,
270
271
  jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
271
272
  fileHashes: snapshot.fileHashes,
@@ -5,10 +5,9 @@
5
5
  // build works — and Electron main runs Node, so this needs no external runtime.
6
6
  import { readdirSync, statSync, realpathSync } from "node:fs";
7
7
  import { join, extname, dirname } from "node:path";
8
- import { execFileSync } from "node:child_process";
9
8
  import { createRequire } from "node:module";
10
9
  import { isPathInside } from "../repo-path.js";
11
- import { childProcessEnv } from "../child-env.js";
10
+ import { runGit } from "../git-exec.js";
12
11
  import { filterWeavatrixIgnored } from "../path-ignore.js";
13
12
  import {trustedGrammarWasm, trustedRuntimeWasm} from './parser-artifact-boundary.js'
14
13
  import LANG_JS from "./builder/lang-js.js";
@@ -112,17 +111,11 @@ function walkFallback(dir, acc = [], seen = new Set(), depth = 0, rootReal = nul
112
111
  // A repository may be opened without Git (or Git may be unavailable), so failure is deliberately a signal to
113
112
  // use the boundary-safe walker above rather than a build failure.
114
113
  function gitFileUniverse(dir) {
115
- let raw;
116
- try {
117
- raw = execFileSync("git", ["-C", dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
118
- encoding: "utf8",
119
- windowsHide: true,
120
- stdio: ["ignore", "pipe", "ignore"],
121
- timeout: 15_000,
122
- maxBuffer: 64 * 1024 * 1024,
123
- env: childProcessEnv(),
124
- });
125
- } catch { return null; }
114
+ const lsFiles = runGit(dir, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
115
+ stdio: ["ignore", "pipe", "ignore"], timeout: 15_000, maxBuffer: 64 * 1024 * 1024,
116
+ });
117
+ if (lsFiles.status !== 0 || lsFiles.error) return null;
118
+ const raw = lsFiles.stdout;
126
119
 
127
120
  // `git -C <dir>` also succeeds when <dir> is merely nested under some parent repository. If that
128
121
  // nested directory is ignored by the parent, `ls-files` is empty even though <dir> can be a valid
@@ -130,17 +123,11 @@ function gitFileUniverse(dir) {
130
123
  // that one ambiguous case, fall back to the boundary-safe walker. Preserve an empty universe for
131
124
  // an actual Git root so ignored files do not leak back into its graph.
132
125
  if (!raw) {
133
- try {
134
- const top = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
135
- encoding: "utf8",
136
- windowsHide: true,
137
- stdio: ["ignore", "pipe", "ignore"],
138
- timeout: 15_000,
139
- maxBuffer: 1024 * 1024,
140
- env: childProcessEnv(),
141
- }).trim();
142
- if (realpathSync.native(top) !== realpathSync.native(dir)) return null;
143
- } catch { return null; }
126
+ const top = runGit(dir, ["rev-parse", "--show-toplevel"], {
127
+ stdio: ["ignore", "pipe", "ignore"], timeout: 15_000, maxBuffer: 1024 * 1024,
128
+ });
129
+ if (top.status !== 0 || top.error) return null;
130
+ if (realpathSync.native(String(top.stdout).trim()) !== realpathSync.native(dir)) return null;
144
131
  }
145
132
 
146
133
  let rootReal;
@@ -1,7 +1,7 @@
1
1
  import {existsSync, readFileSync, realpathSync, statSync, writeFileSync} from 'node:fs'
2
2
  import {dirname, isAbsolute, join} from 'node:path'
3
3
  import {diffGraphs, formatGraphDiff, prevGraphPathFor} from '../graph-context.mjs'
4
- import {buildGraphForRepo, defaultPrecisionMode} from '../../build-graph.js'
4
+ import {buildGraphForRepo, defaultPrecisionMode, precisionStatusLine} from '../../build-graph.js'
5
5
  import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../../graph/layout.js'
6
6
  import {liveRepositoryRecords, registerRepository} from '../../graph/repo-registry.js'
7
7
  import {precisionSemanticInputsMatch, readPrecisionOverlay} from '../../precision/lsp-overlay.js'
@@ -76,7 +76,7 @@ export async function tOpenRepo(g, args, ctx) {
76
76
  savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
77
77
  schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
78
78
  || !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
79
- || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 5
79
+ || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 6
80
80
  || !Number.isInteger(saved.reExportOccurrencesV) || saved.reExportOccurrencesV < 1
81
81
  || !Number.isInteger(saved.symbolSpacesV) || saved.symbolSpacesV < 1
82
82
  || !Number.isInteger(saved.physicalFileLocV) || saved.physicalFileLocV < 1
@@ -128,7 +128,7 @@ export async function tOpenRepo(g, args, ctx) {
128
128
  `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`,
129
129
  `Graph: ${graphPath}`,
130
130
  `Build mode: ${loaded.graphBuildMode || 'full'}`,
131
- `Semantic precision: ${loaded.precision?.state || 'UNAVAILABLE'} (${loaded.precision?.verifiedEdges || 0} EXACT_LSP edge(s))`,
131
+ precisionStatusLine(loaded.precision),
132
132
  ].join('\n')
133
133
  }
134
134
 
@@ -128,8 +128,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
128
128
  {cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
129
129
  {cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
130
130
  {cap: 'graph', name: 'module_map', description: 'First orientation view for understanding an unfamiliar application with little context: a production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
131
- {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, file:line, and Spring conditional/default-active state.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
132
- {cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
131
+ {cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, file:line, and Spring conditional/default-active state.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
132
+ {cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, handler_file: {type: 'string', maxLength: 1024, description: 'Repo-relative file path (or unambiguous path suffix) declaring the handler; use after an AMBIGUOUS_HANDLER result.'}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
133
133
  {cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
134
134
  {cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
135
135
  {cap: 'graph', name: 'get_architecture_contract', description: 'Read the owner-approved architecture target or safely bootstrap one. With action=preview, returns an adaptive candidate, observed-but-not-enforced dependency directions, verification, exact file content/hash and a short-lived confirmation token. action=approve creates the local contract only after explicit token confirmation and never overwrites an active target.', inputSchema: {type: 'object', properties: {action: {type: 'string', enum: ['preview', 'approve'], description: 'Omit to read; preview is dry-run only; approve requires the preview token'}, candidate_contract: {type: 'object', description: 'Optional reviewed candidate to normalize and verify during preview'}, baseline_mode: {type: 'string', enum: ['none', 'accept-current'], default: 'none', description: 'Whether preview should materialize current violations as an explicit ratchet baseline'}, confirm_token: {type: 'string', description: 'One-time token returned by preview; required for approve'}}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
@@ -1,6 +1,6 @@
1
1
  import {createHash} from 'node:crypto'
2
2
  import {readFileSync} from 'node:fs'
3
- import {buildFileImportGraph, checkBoundaries, findSccs, representativeCycle} from '../analysis/dep-rules.js'
3
+ import {buildFileImportGraph, checkBoundaries, findSccs, isRustModuleTreeComponent, representativeCycle} from '../analysis/dep-rules.js'
4
4
  import {createRepoBoundary} from '../repo-path.js'
5
5
  import {CAPS, bounded, compareText, repoRelativePath, safeToken} from './evidence-snapshot.common.mjs'
6
6
 
@@ -59,7 +59,10 @@ export function buildStructureEvidence(graph, repoRoot) {
59
59
  const imports = buildFileImportGraph(graph)
60
60
  const runtimeSccs = sortedSccs(imports.runtimeAdj)
61
61
  const runtimeKeys = new Set(runtimeSccs.map((members) => members.join('\0')))
62
- const compileSccs = sortedSccs(imports.allAdj).filter((members) => !runtimeKeys.has(members.join('\0')))
62
+ // Same suppression as computeStructureFindings: a synced snapshot must not contradict the
63
+ // local structure findings on idiomatic Rust module trees.
64
+ const compileSccs = sortedSccs(imports.allAdj)
65
+ .filter((members) => !runtimeKeys.has(members.join('\0')) && !isRustModuleTreeComponent(members))
63
66
  const allCycles = [
64
67
  ...runtimeSccs.map((members) => cycleFact('runtime', imports.runtimeAdj, members)),
65
68
  ...compileSccs.map((members) => cycleFact('compile-time', imports.allAdj, members)),
@@ -1,10 +1,7 @@
1
- import {spawnSync} from 'node:child_process'
2
- import {childProcessEnv} from '../child-env.js'
1
+ import {runGit} from '../git-exec.js'
3
2
 
4
3
  export function gitLines(repoRoot, args) {
5
- const result = spawnSync('git', ['-C', repoRoot, ...args], {
6
- encoding: 'utf8', timeout: 8000, env: childProcessEnv(), windowsHide: true,
7
- })
4
+ const result = runGit(repoRoot, args)
8
5
  if (result.status !== 0) return null
9
6
  return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
10
7
  }
@@ -52,6 +52,7 @@ export function requestedPathClasses(query) {
52
52
  }
53
53
 
54
54
  function isQueryEligible(node, requestedClasses, classificationCache, classifier) {
55
+ if (node?.test_surface === true && !requestedClasses.has('test')) return false
55
56
  const source = sourceFileOf(node)
56
57
  if (!source) return true
57
58
  if (!classificationCache.has(source)) classificationCache.set(source, classifier.explain(source, {content: ''}))
@@ -1,7 +1,6 @@
1
1
  import {readFileSync, statSync} from 'node:fs'
2
2
  import {join} from 'node:path'
3
- import {spawnSync} from 'node:child_process'
4
- import {childProcessEnv} from '../../child-env.js'
3
+ import {runGit} from '../../git-exec.js'
5
4
  import {resolveRepoPath} from '../../repo-path.js'
6
5
  import {mergePrecisionOverlay, precisionSemanticInputsMatch, readPrecisionOverlay} from '../../precision/lsp-overlay.js'
7
6
 
@@ -14,19 +13,19 @@ export function graphStaleness(ctx) {
14
13
  try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file */ }
15
14
  if (ctx.repoRoot && info.builtAt) {
16
15
  try {
17
- const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
16
+ const head = runGit(ctx.repoRoot, ['log', '-1', '--format=%cI'], {timeout: 4000})
18
17
  const iso = (head.stdout || '').trim()
19
18
  if (head.status === 0 && iso) {
20
19
  info.headAt = new Date(iso)
21
20
  if (info.headAt > info.builtAt) {
22
21
  info.stale = true
23
- const count = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
22
+ const count = runGit(ctx.repoRoot, ['rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {timeout: 4000})
24
23
  if (count.status === 0) info.behind = Number(count.stdout.trim()) || null
25
24
  }
26
25
  }
27
26
  } catch { /* git unavailable */ }
28
27
  try {
29
- const status = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
28
+ const status = runGit(ctx.repoRoot, ['status', '--porcelain'], {timeout: 4000})
30
29
  if (status.status === 0) {
31
30
  let newer = 0
32
31
  for (const line of String(status.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
@@ -6,6 +6,7 @@ import {createPathClassifier, hasPathClass} from '../../path-classification.js'
6
6
 
7
7
  const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
8
8
  const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
9
+ const AMBIGUOUS_FILE_BASENAME_RE = /^(mod|lib|main)\.rs$|^index\.[a-z0-9]+$|^__init__\.py$/
9
10
  const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
10
11
  const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
11
12
  const exactSymbolName = (node) => {
@@ -34,6 +35,23 @@ function resolveExactSeedSymbols(g, requested, limit = 12) {
34
35
  return {seeds, missing, ambiguous}
35
36
  }
36
37
 
38
+ // Edge/hop lines print bare labels without the [id] suffix node lines carry, so repeated or
39
+ // conventionally-generic file basenames (mod.rs, index.ts, __init__.py) become indistinguishable —
40
+ // widen those, scoped to the shown ids, to the last two path segments.
41
+ const makeEdgeLabel = (g, shownIds) => {
42
+ const counts = new Map()
43
+ for (const id of shownIds) {
44
+ const label = labelOf(g, id)
45
+ counts.set(label, (counts.get(label) || 0) + 1)
46
+ }
47
+ return (id) => {
48
+ const label = labelOf(g, id)
49
+ const isFile = String(id).includes('/') && !String(id).includes('#')
50
+ if (isFile && ((counts.get(label) || 0) > 1 || AMBIGUOUS_FILE_BASENAME_RE.test(label))) return String(id).split('/').slice(-2).join('/')
51
+ return label
52
+ }
53
+ }
54
+
37
55
  const relationSet = (relationFilter, legacyFilter) => {
38
56
  const raw = relationFilter ?? legacyFilter
39
57
  const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
@@ -89,6 +107,12 @@ export function tQueryGraph(g, {
89
107
  const node = g.byId.get(String(id))
90
108
  const file = querySourceFile(node)
91
109
  if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
110
+ // Extractor-proven test symbols (Rust #[cfg(test)]) live in production files; suppress them
111
+ // under the same policy as path-classified tests unless the question asks about tests.
112
+ if (node?.test_surface === true && !requestedClasses.has('test')) {
113
+ classifiedSuppressed.add(String(id))
114
+ return {ok: false, bucket: 'classified'}
115
+ }
92
116
  if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
93
117
  const info = classificationCache.get(file)
94
118
  const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
@@ -180,7 +204,8 @@ export function tQueryGraph(g, {
180
204
  'Nodes:',
181
205
  ]
182
206
  const nodeLines = shown.map((node) => ` [d${node.distance}] ${labelOf(g, node.id)} (deg ${node.degree}) [${node.id}]`)
183
- const edgeLines = ['', 'Edges:', ...shownEdges.map(([source, relation, target]) => ` ${labelOf(g, source)} --${relation || 'rel'}--> ${labelOf(g, target)}`)]
207
+ const edgeLabel = makeEdgeLabel(g, shownIds)
208
+ const edgeLines = ['', 'Edges:', ...shownEdges.map(([source, relation, target]) => ` ${edgeLabel(source)} --${relation || 'rel'}--> ${edgeLabel(target)}`)]
184
209
  let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
185
210
  if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
186
211
  return text
@@ -218,6 +243,7 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
218
243
  if (!previous.has(targetId)) return `No path found between "${sourceNode.label ?? sourceId}" and "${targetNode.label ?? targetId}" within ${limit} hops.`
219
244
  const path = []
220
245
  for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
221
- const lines = path.map((id, index) => index === 0 ? ` ${labelOf(g, id)}` : ` --${relationTo.get(id) || 'rel'}--> ${labelOf(g, id)}`)
246
+ const edgeLabel = makeEdgeLabel(g, path)
247
+ const lines = path.map((id, index) => index === 0 ? ` ${edgeLabel(id)}` : ` --${relationTo.get(id) || 'rel'}--> ${edgeLabel(id)}`)
222
248
  return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
223
249
  }
@@ -1,6 +1,5 @@
1
- import {spawnSync} from 'node:child_process'
2
1
  import {summarizeFindings} from '../../analysis/findings.js'
3
- import {childProcessEnv} from '../../child-env.js'
2
+ import {runGit} from '../../git-exec.js'
4
3
  import {createPathClassifier, hasPathClass} from '../../path-classification.js'
5
4
 
6
5
  const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
@@ -179,9 +178,7 @@ export const formatOrdinaryAudit = (audit, args, findings = audit.findings, head
179
178
  }
180
179
 
181
180
  export const gitUntracked = (repoRoot) => {
182
- const result = spawnSync('git', ['-C', repoRoot, 'ls-files', '--others', '--exclude-standard'], {
183
- encoding: 'utf8', timeout: 8000, maxBuffer: 2 * 1024 * 1024, env: childProcessEnv(), windowsHide: true,
184
- })
181
+ const result = runGit(repoRoot, ['ls-files', '--others', '--exclude-standard'], {maxBuffer: 2 * 1024 * 1024})
185
182
  if (result.status !== 0) return {
186
183
  ok: false,
187
184
  files: [],
@@ -1,5 +1,6 @@
1
1
  import {rawGraph} from '../graph-context.mjs'
2
2
  import {analyzeEndpointInventory} from '../../analysis/endpoints.js'
3
+ import {PATH_CLASS_NAMES, createPathClassifier, hasPathClass} from '../../path-classification.js'
3
4
  import {toolResult} from '../tool-result.mjs'
4
5
 
5
6
  export function tListEndpoints(g, args, ctx) {
@@ -16,12 +17,34 @@ export function tListEndpoints(g, args, ctx) {
16
17
  const path = args.path ? String(args.path) : null
17
18
  if (method) eps = eps.filter((endpoint) => endpoint.method === method)
18
19
  if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
19
- if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
20
+ // Production-first: classified endpoints are suppressed by default; a tests-only graph would
21
+ // suppress everything, so that build mode auto-includes them (same precedent as module_map).
22
+ const includeClassified = args.include_classified === true || graph.graphBuildMode === 'tests-only'
23
+ const classifier = createPathClassifier(ctx.repoRoot)
24
+ const byFile = new Map()
25
+ const classesOf = (file) => {
26
+ const key = String(file || '')
27
+ if (!byFile.has(key)) {
28
+ const info = classifier.explain(key, {content: ''})
29
+ byFile.set(key, {classified: info.excluded || hasPathClass(info, ...PATH_CLASS_NAMES), pathClasses: info.classes})
30
+ }
31
+ return byFile.get(key)
32
+ }
33
+ const production = eps.filter((endpoint) => !classesOf(endpoint.file).classified)
34
+ const classified = eps.filter((endpoint) => classesOf(endpoint.file).classified)
35
+ const suppressed = includeClassified ? 0 : classified.length
36
+ eps = includeClassified ? [...production, ...classified] : production
37
+ const suppressionNote = suppressed
38
+ ? `${suppressed} endpoint(s) in classified test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded paths were suppressed; pass include_classified:true to inspect them.`
39
+ : ''
40
+ if (!eps.length) return suppressed
41
+ ? `No production HTTP endpoints detected; ${suppressionNote}`
42
+ : 'No HTTP endpoints detected in the indexed code files.'
20
43
  const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
21
44
  const shown = eps.slice(0, max)
22
45
  const stats = inventory.stats
23
46
  const text = [
24
- `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.`,
47
+ `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.${suppressed ? ` ${suppressionNote}` : ''}`,
25
48
  ...shown.map((e) => {
26
49
  const via = e.mountChain?.length
27
50
  ? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
@@ -29,14 +52,20 @@ export function tListEndpoints(g, args, ctx) {
29
52
  const activation = e.conditional
30
53
  ? `; conditional default ${e.defaultActive === false ? 'inactive' : e.defaultActive === true ? 'active' : 'unknown'}`
31
54
  : ''
32
- return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence}${activation})${via}`
55
+ const info = classesOf(e.file)
56
+ const tag = info.classified ? ` [classified${info.pathClasses.length ? `:${info.pathClasses.join('+')}` : ''}]` : ''
57
+ return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence}${activation})${tag}${via}`
33
58
  }),
34
59
  ].join('\n')
35
60
  return toolResult(text, {
36
61
  filters: {method, path},
37
62
  stats,
38
- endpoints: shown,
63
+ pathPolicy: includeClassified ? 'all' : 'production-first',
64
+ suppressed,
65
+ endpoints: shown.map((e) => {
66
+ const info = classesOf(e.file)
67
+ return info.classified ? {...e, classified: true, pathClasses: info.pathClasses} : e
68
+ }),
39
69
  page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
40
70
  }, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
41
71
  }
42
-
@@ -19,6 +19,7 @@ export function sanitizeNode(value) {
19
19
  if (community !== undefined && Number.isInteger(community) && community >= 0) out.community = community;
20
20
  if (typeof value.exported === 'boolean') out.exported = value.exported;
21
21
  if (typeof value.decorated === 'boolean') out.decorated = value.decorated;
22
+ if (value.test_surface === true) out.test_surface = true;
22
23
  setIf(out, 'complexity', sanitizeComplexity(value.complexity));
23
24
  return out;
24
25
  }
@@ -231,6 +231,15 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
231
231
  ...screens,
232
232
  ]
233
233
  }),
234
+ // Mismatch-only endpoints rarely rank into top_n (zero callsites), yet they drive the
235
+ // *_METHOD_MISMATCH verdicts — always surface them, citing the verdict's own total.
236
+ ...(analysis.totals.methodMismatches > 0 ? [
237
+ ` Method mismatches (${verdict.methodMismatches} call(s)):`,
238
+ ...analysis.endpoints.filter((endpoint) => endpoint.methodMismatches > 0).slice(0, 5).flatMap((endpoint) => [
239
+ ` ${endpoint.method} ${endpoint.path} — ${endpoint.methodMismatches} call(s) use a different method`,
240
+ ...(endpoint.methodMismatchSites || []).slice(0, 2).map((site) => ` caller ${site.clientRepo}:${site.file}:${site.line} uses ${site.method}`),
241
+ ]),
242
+ ] : []),
234
243
  ...transportAnalysis.contracts.filter((contract) => contract.callsites.length).slice(0, topN).map((contract) =>
235
244
  ` ${contract.transport.toUpperCase()} ${contract.service ? `${contract.service}.` : contract.operation ? `${contract.operation} ` : ''}${contract.name} (${contract.file}:${contract.line}) [${contract.liveness}] → ${contract.callsites.length} callsite(s), ${contract.affected.files.length} affected file(s)`),
236
245
  completeness.complete
@@ -29,17 +29,47 @@ function endpointCandidates(inventory, args) {
29
29
  return exact.length ? exact : eligible.filter((endpoint) => endpoint.path.endsWith(path))
30
30
  }
31
31
 
32
- function handlerCandidates(graph, endpoint) {
32
+ // A handler_file hint narrows the FULL same-named pool before any scoring, so it can rescue a
33
+ // candidate the ranking would otherwise collapse away. An exact repo-relative match beats suffix
34
+ // matches; comparison is case-insensitive (Windows paths and human-typed hints disagree on casing).
35
+ function matchesHandlerHint(file, hint) {
36
+ if (!hint) return true
37
+ const lower = file.toLowerCase()
38
+ return lower === hint || lower.endsWith(`/${hint}`)
39
+ }
40
+
41
+ function handlerCandidates(graph, endpoint, hint = '') {
33
42
  if (!endpoint.handler) return []
34
43
  const wanted = endpoint.handler.toLowerCase()
35
44
  const qualifier = String(endpoint.handlerRef || '').split('.').slice(-2, -1)[0]?.toLowerCase() || ''
36
45
  const routeDir = posix.dirname(endpoint.file)
37
- const scored = (graph.nodes || [])
46
+ // File-level import/re-export bindings at the route file break same-name ties between otherwise
47
+ // unranked candidates. The bonus deliberately sits BELOW same-directory proximity (+40): a
48
+ // file-level edge proves coupling, not symbol identity, and must never outrank the conventional
49
+ // same-package/same-dir handler (Go same-package files have no import edge at all).
50
+ const importBound = new Set()
51
+ for (const edge of graph.out.get(endpoint.file) || []) {
52
+ if (edge.relation !== 'imports' && edge.relation !== 're_exports') continue
53
+ if (edge.typeOnly === true || edge.barrelProxy === true) continue
54
+ const targetId = String(edge.id)
55
+ if (targetId.includes('#')) continue
56
+ const target = graph.byId.get(targetId)
57
+ importBound.add(String(target?.source_file || targetId).replace(/\\/g, '/'))
58
+ }
59
+ let pool = (graph.nodes || [])
38
60
  .filter((node) => String(node.id).includes('#') && node.source_file && symbolName(node).toLowerCase() === wanted)
61
+ if (hint) {
62
+ pool = pool.filter((node) => matchesHandlerHint(String(node.source_file).replace(/\\/g, '/').toLowerCase(), hint))
63
+ const exact = pool.filter((node) => String(node.source_file).replace(/\\/g, '/').toLowerCase() === hint)
64
+ if (exact.length) pool = exact
65
+ }
66
+ const scored = pool
39
67
  .map((node) => {
40
68
  const file = String(node.source_file).replace(/\\/g, '/')
41
69
  let score = 0
70
+ const importBoundAtRoute = importBound.has(file)
42
71
  if (file === endpoint.file) score += 100
72
+ if (importBoundAtRoute) score += 35
43
73
  if (posix.dirname(file) === routeDir) score += 40
44
74
  if (file.startsWith(`${routeDir}/`)) score += 15
45
75
  if (qualifier) {
@@ -48,16 +78,15 @@ function handlerCandidates(graph, endpoint) {
48
78
  }
49
79
  const line = symbolLine(node)
50
80
  if (file === endpoint.file && line >= endpoint.line && line - endpoint.line <= 250) score += 20
51
- return {node, score}
81
+ return {node, score, importBoundAtRoute}
52
82
  })
53
83
  .sort((a, b) => b.score - a.score || String(a.node.id).localeCompare(String(b.node.id)))
54
84
  if (!scored.length) return []
55
85
  const best = scored[0].score
56
- return scored.filter((item) => item.score === best).map((item) => item.node)
86
+ return scored.filter((item) => item.score === best).map(({node, importBoundAtRoute}) => ({node, importBoundAtRoute}))
57
87
  }
58
88
 
59
- function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, repoRoot}) {
60
- const classifier = createPathClassifier(repoRoot)
89
+ function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, classifier}) {
61
90
  const allowed = (node) => {
62
91
  if (!node?.source_file) return false
63
92
  if (includeClassified) return true
@@ -125,12 +154,25 @@ export function tTraceEndpoint(graph, args, ctx) {
125
154
  completeness: {status: 'PARTIAL', reason: 'ambiguous endpoint'},
126
155
  })
127
156
  const endpoint = candidates[0]
128
- const handlers = handlerCandidates(graph, endpoint)
157
+ const classifier = createPathClassifier(ctx.repoRoot)
158
+ const classify = (file) => {
159
+ const info = classifier.explain(file, {content: ''})
160
+ return {classified: info.excluded || NON_PRODUCT.some((name) => hasPathClass(info, name)), pathClasses: info.classes}
161
+ }
162
+ const hint = String(args.handler_file || '').trim().replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
163
+ let handlers = handlerCandidates(graph, endpoint, hint)
164
+ .map((item) => ({...item, ...classify(String(item.node.source_file).replace(/\\/g, '/'))}))
165
+ // Production-first: a classified twin (tests/mocks/…) never ties with a production candidate,
166
+ // but an all-classified candidate set is kept rather than emptied.
167
+ if (args.include_classified !== true && handlers.some((item) => !item.classified)) {
168
+ handlers = handlers.filter((item) => !item.classified)
169
+ }
129
170
  if (handlers.length !== 1) return toolResult([
130
- `Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : 'not present in the graph'}:`,
171
+ `Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : `not present in the graph${hint ? ` (no candidate matched handler_file "${hint}")` : ''}`}:`,
131
172
  ` ${endpointLine(endpoint)}`,
132
- ...handlers.slice(0, 12).map((node) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}) [${node.id}]`),
133
- ].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers}, {
173
+ ...handlers.slice(0, 12).map(({node, importBoundAtRoute, pathClasses}) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}; import-bound at route: ${importBoundAtRoute ? 'yes' : 'no'}${pathClasses.length ? `; classified:${pathClasses.join('+')}` : ''}) [${node.id}]`),
174
+ ...(handlers.length > 1 ? [' Next: pass handler_file with the repo-relative path (or an unambiguous path suffix) of the file that declares the intended handler.'] : []),
175
+ ].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers: handlers.map(({node, importBoundAtRoute, pathClasses}) => ({...node, importBoundAtRoute, pathClasses}))}, {
134
176
  completeness: {status: 'PARTIAL', reason: handlers.length ? 'ambiguous handler symbol' : 'handler symbol not found'},
135
177
  })
136
178
 
@@ -138,9 +180,9 @@ export function tTraceEndpoint(graph, args, ctx) {
138
180
  const maxNodes = Math.max(2, Math.min(40, Number(args.max_nodes) || 20))
139
181
  const contextLines = Math.max(0, Math.min(6, Number(args.context_lines) || 2))
140
182
  const maxExcerpts = Math.max(0, Math.min(12, Number(args.max_excerpts) || 6))
141
- const handler = handlers[0]
183
+ const handler = handlers[0].node
142
184
  const traced = traceCalls(graph, handler, {
143
- maxDepth, maxNodes, includeClassified: args.include_classified === true, repoRoot: ctx.repoRoot,
185
+ maxDepth, maxNodes, includeClassified: args.include_classified === true, classifier,
144
186
  })
145
187
  const excerpts = traced.edges.slice(0, maxExcerpts).map((edge) => {
146
188
  const source = graph.byId.get(edge.from)