weavatrix 0.2.5 → 0.2.7

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 (41) hide show
  1. package/README.md +77 -14
  2. package/SECURITY.md +14 -0
  3. package/docs/agent-task-benchmark.md +58 -0
  4. package/docs/releases/v0.2.7.md +93 -0
  5. package/package.json +6 -1
  6. package/scripts/run-agent-task-benchmark.mjs +119 -0
  7. package/skill/SKILL.md +31 -10
  8. package/src/analysis/allowed-test-runner.js +59 -0
  9. package/src/analysis/data-flow-evidence.js +114 -0
  10. package/src/analysis/dead-code-review.js +51 -0
  11. package/src/analysis/dep-check.js +42 -3
  12. package/src/analysis/duplicate-groups.js +84 -0
  13. package/src/analysis/graph-analysis.aggregate.js +3 -1
  14. package/src/analysis/graph-analysis.refs.js +19 -11
  15. package/src/analysis/hot-path-review.js +20 -2
  16. package/src/analysis/internal-audit.run.js +6 -1
  17. package/src/analysis/task-retrieval.js +116 -0
  18. package/src/bounds.js +4 -0
  19. package/src/graph/builder/lang-js.js +52 -17
  20. package/src/graph/freshness-probe.js +4 -2
  21. package/src/graph/incremental-refresh.js +4 -2
  22. package/src/graph/internal-builder.barrels.js +39 -2
  23. package/src/graph/internal-builder.build.js +58 -12
  24. package/src/mcp/catalog.mjs +28 -6
  25. package/src/mcp/graph-context.mjs +5 -1
  26. package/src/mcp/graph-diff.mjs +1 -1
  27. package/src/mcp/sync-payload.mjs +1 -1
  28. package/src/mcp/tools-actions.mjs +3 -1
  29. package/src/mcp/tools-context.mjs +164 -0
  30. package/src/mcp/tools-graph.mjs +48 -3
  31. package/src/mcp/tools-health.mjs +15 -28
  32. package/src/mcp/tools-impact-change.mjs +1 -0
  33. package/src/mcp/tools-source.mjs +7 -6
  34. package/src/mcp/tools-verified-change.mjs +169 -0
  35. package/src/precision/lsp-client.js +1 -1
  36. package/src/precision/lsp-overlay.js +1 -5
  37. package/src/precision/symbol-query.js +1 -5
  38. package/src/security/malware-heuristics.exclusions.js +3 -3
  39. package/src/security/malware-heuristics.roots.js +4 -1
  40. package/src/security/malware-heuristics.scan.js +4 -2
  41. package/src/security/malware-scoring.js +22 -5
@@ -26,19 +26,38 @@ const PROFILE_CAPS = Object.freeze({
26
26
 
27
27
  // The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
28
28
  // loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
29
- export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
29
+ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
30
30
 
31
- function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
31
+ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
32
32
  const tools = [
33
33
  {cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
34
34
  {cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
35
35
  {cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
36
- {cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph the closest, most-connected nodes near the matched seeds, with edges among them not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
36
+ {cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
37
37
  {cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
38
38
  {cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
39
39
  {cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
40
40
  {cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
41
41
  {cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
42
+ {
43
+ cap: 'graph', refreshGraph: true, name: 'verified_change',
44
+ description: 'Proof-carrying change workflow. Given a natural-language task and current diff/files, returns compact edit contexts, bounded call-argument data-flow, blast radius, graph/architecture/duplicate/API ratchets, affected tests, and one PASS/BLOCKED/UNKNOWN verdict. Package tests run only when explicitly requested and WEAVATRIX_ALLOW_TEST_RUNS=1.',
45
+ inputSchema: {type: 'object', additionalProperties: false, properties: {
46
+ task: {type: 'string', minLength: 1, maxLength: 4000}, phase: {type: 'string', enum: ['plan', 'verify'], default: 'plan'},
47
+ base_ref: {type: 'string', maxLength: 200, default: 'HEAD'}, diff: {type: 'string', maxLength: 2097152}, files: {type: 'array', items: {type: 'string'}, maxItems: 500},
48
+ precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_symbols: {type: 'integer', minimum: 1, maximum: 5, default: 3},
49
+ impact_depth: {type: 'integer', minimum: 1, maximum: 4, default: 2}, max_impact_nodes: {type: 'integer', minimum: 5, maximum: 120, default: 40},
50
+ data_flow_depth: {type: 'integer', minimum: 1, maximum: 3, default: 2}, max_data_flow_edges: {type: 'integer', minimum: 1, maximum: 60, default: 30},
51
+ duplicate_ratchet: {type: 'boolean', default: true},
52
+ api_contract: {type: 'object', additionalProperties: true, properties: {backend: {type: 'string'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, path: {type: 'string', maxLength: 2048}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500}}, required: ['backend', 'clients']},
53
+ tests: {type: 'array', maxItems: 5, items: {type: 'object', additionalProperties: false, properties: {script: {type: 'string', maxLength: 120}, args: {type: 'array', maxItems: 40, items: {type: 'string', maxLength: 300}}}, required: ['script']}},
54
+ run_tests: {type: 'boolean', default: false}, test_timeout_ms: {type: 'integer', minimum: 1000, maximum: 300000, default: 60000},
55
+ }, required: ['task']},
56
+ run: (g, a, ctx) => tv.tVerifiedChange(g, a, ctx, {
57
+ impact: ti.tChangeImpact, context: tb.tContextBundle, inspect: ts.tInspectSymbol,
58
+ prepareChange: tar.tPrepareChange, verifyArchitecture: tar.tVerifyArchitecture, traceApi: tc.tTraceApiContract,
59
+ }, {source: caps.has('source'), health: caps.has('health'), crossrepo: caps.has('crossrepo')}),
60
+ },
42
61
  {
43
62
  cap: 'crossrepo',
44
63
  name: 'trace_api_contract',
@@ -86,11 +105,12 @@ function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
86
105
  {cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
87
106
  {cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
88
107
  {cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
108
+ {cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, aggregated inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and a few focused source excerpts. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
89
109
  {cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
90
110
  {cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
91
111
  {cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
92
112
  {cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
93
- {cap: 'health', name: 'hot_path_review', description: 'Rank bounded production-symbol hot paths 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. Local syntax cost stays separate from graph risk; 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: 0}, 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)},
113
+ {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)},
94
114
  {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)},
95
115
  {cap: 'graph', name: 'module_map', description: '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)},
96
116
  {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): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
@@ -133,21 +153,23 @@ function buildTools({tg, ti, th, ts, ta, tar, thi, tc}) {
133
153
  // present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
134
154
  export async function loadHotApi(version, capsArg) {
135
155
  const v = version ? `?v=${version}` : ''
136
- const [tg, ti, th, ts, ta, tar, thi, tc] = await Promise.all([
156
+ const [tg, ti, th, ts, tb, ta, tar, thi, tc, tv] = await Promise.all([
137
157
  import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
138
158
  import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
139
159
  import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
140
160
  import(new URL(`./tools-source.mjs${v}`, import.meta.url).href),
161
+ import(new URL(`./tools-context.mjs${v}`, import.meta.url).href),
141
162
  import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
142
163
  import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
143
164
  import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
144
165
  import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
166
+ import(new URL(`./tools-verified-change.mjs${v}`, import.meta.url).href),
145
167
  ])
146
- const all = buildTools({tg, ti, th, ts, ta, tar, thi, tc})
147
168
  const raw = capsArg == null ? 'offline' : String(capsArg).trim()
148
169
  const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
149
170
  .flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
150
171
  const caps = new Set(selected)
172
+ const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps})
151
173
  const tools = all.filter((t) => caps.has(t.cap))
152
174
  return {
153
175
  tools,
@@ -62,6 +62,10 @@ export function loadGraph(path, {repoRoot = null} = {}) {
62
62
  edgeTypesV: Number(raw.edgeTypesV) || 0,
63
63
  edgeProvenanceV: Number(raw.edgeProvenanceV) || 0,
64
64
  barrelResolutionV: Number(raw.barrelResolutionV) || 0,
65
+ reExportOccurrencesV: Number(raw.reExportOccurrencesV) || 0,
66
+ symbolSpacesV: Number(raw.symbolSpacesV) || 0,
67
+ reExportOccurrences: Array.isArray(raw.reExportOccurrences) ? raw.reExportOccurrences : [],
68
+ jsExportRecords: raw.jsExportRecords && typeof raw.jsExportRecords === 'object' ? raw.jsExportRecords : {},
65
69
  extractorSchemaV: Number(raw.extractorSchemaV) || 0,
66
70
  extImportsV: Number(raw.extImportsV) || 0,
67
71
  complexityV: Number(raw.complexityV) || 0,
@@ -165,7 +169,7 @@ const sourceFileOf = (node) => {
165
169
  return normPath(source)
166
170
  }
167
171
 
168
- function requestedPathClasses(query) {
172
+ export function requestedPathClasses(query) {
169
173
  const words = new Set(wordsOf(query))
170
174
  const requested = new Set()
171
175
  for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) {
@@ -35,7 +35,7 @@ function stableNodeIndex(graph) {
35
35
  if (!raw) continue
36
36
  const stem = terminalLineSuffix(raw)
37
37
  const params = Number.isInteger(node?.complexity?.params) ? node.complexity.params : ''
38
- const signature = [stem, node?.symbol_kind || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
38
+ const signature = [stem, node?.symbol_kind || '', node?.symbol_space || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
39
39
  if (!groups.has(signature)) groups.set(signature, [])
40
40
  groups.get(signature).push(node)
41
41
  }
@@ -180,7 +180,7 @@ function sanitizeNodeV3(value) {
180
180
  else delete out.file_type;
181
181
  if (sourceFile) out.source_file = sourceFile;
182
182
  else delete out.source_file;
183
- for (const key of ['symbol_kind', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
183
+ for (const key of ['symbol_kind', 'symbol_space', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
184
184
  if (out.complexity) {
185
185
  for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
186
186
  const safe = safeToken(value.complexity?.[key], 32);
@@ -91,7 +91,9 @@ export async function tOpenRepo(g, args, ctx) {
91
91
  savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
92
92
  schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
93
93
  || !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
94
- || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 4
94
+ || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 5
95
+ || !Number.isInteger(saved.reExportOccurrencesV) || saved.reExportOccurrencesV < 1
96
+ || !Number.isInteger(saved.symbolSpacesV) || saved.symbolSpacesV < 1
95
97
  if (savedPrecision === 'lsp') {
96
98
  const overlay = readPrecisionOverlay(graphPath, saved)
97
99
  precisionUpgrade = !overlay || (typeof overlay.semanticInputFingerprint === 'string'
@@ -0,0 +1,164 @@
1
+ import {isStructuralRelation} from '../graph/relations.js'
2
+ import {boundedInteger} from '../bounds.js'
3
+ import {isSymbol, labelOf} from './graph-context.mjs'
4
+ import {toolResult} from './tool-result.mjs'
5
+
6
+ const MAX_LINE_SAMPLES = 5
7
+
8
+ const fileOf = (g, id) => {
9
+ const node = g.byId.get(String(id))
10
+ return String(node?.source_file || (isSymbol(id) ? String(id).split('#')[0] : id))
11
+ }
12
+
13
+ function aggregateEdges(g, edges, cap) {
14
+ const groups = new Map()
15
+ for (const edge of edges || []) {
16
+ if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
17
+ const id = String(edge.id)
18
+ const relation = String(edge.relation || 'references')
19
+ const key = `${id}\0${relation}`
20
+ let group = groups.get(key)
21
+ if (!group) {
22
+ group = {id, label: labelOf(g, id), file: fileOf(g, id), relation, count: 0, lines: []}
23
+ groups.set(key, group)
24
+ }
25
+ group.count++
26
+ if (Number.isInteger(edge.line) && group.lines.length < MAX_LINE_SAMPLES && !group.lines.includes(edge.line)) group.lines.push(edge.line)
27
+ if (edge.typeOnly === true) group.typeOnly = true
28
+ if (edge.compileOnly === true) group.compileOnly = true
29
+ }
30
+ const all = [...groups.values()].sort((left, right) => right.count - left.count
31
+ || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
32
+ return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
33
+ }
34
+
35
+ const sameOrigin = (occurrence, definition) => occurrence.originId === definition.id
36
+ || (occurrence.originFile === definition.file && occurrence.originName === definition.name)
37
+
38
+ function exactReExportSites(g, definition, cap) {
39
+ const occurrences = Array.isArray(g.reExportOccurrences) ? g.reExportOccurrences : []
40
+ const exposures = new Map()
41
+ if (definition.exported) exposures.set(definition.file, new Set([definition.name]))
42
+ const shown = new Map()
43
+ const add = (occurrence, exported = occurrence.exported) => {
44
+ const key = `${occurrence.file}\0${occurrence.line}\0${exported}\0${occurrence.kind}`
45
+ if (shown.has(key)) return false
46
+ shown.set(key, {
47
+ file: occurrence.file,
48
+ line: occurrence.line,
49
+ kind: occurrence.kind,
50
+ exported,
51
+ imported: occurrence.imported,
52
+ targetFile: occurrence.targetFile,
53
+ typeOnly: occurrence.typeOnly === true,
54
+ ...(occurrence.specifier ? {specifier: occurrence.specifier} : {}),
55
+ ...(occurrence.originFile ? {originFile: occurrence.originFile, originName: occurrence.originName} : {}),
56
+ })
57
+ return true
58
+ }
59
+ for (let pass = 0; pass <= occurrences.length; pass++) {
60
+ let changed = false
61
+ for (const occurrence of occurrences) {
62
+ if (occurrence.kind === 'star') {
63
+ const names = exposures.get(occurrence.targetFile)
64
+ if (!names) continue
65
+ for (const name of names) {
66
+ if (name === 'default') continue
67
+ changed = add(occurrence, name) || changed
68
+ let exported = exposures.get(occurrence.file)
69
+ if (!exported) exposures.set(occurrence.file, (exported = new Set()))
70
+ const before = exported.size
71
+ exported.add(name)
72
+ changed = exported.size !== before || changed
73
+ }
74
+ continue
75
+ }
76
+ if (!sameOrigin(occurrence, definition)) continue
77
+ changed = add(occurrence) || changed
78
+ if (occurrence.kind !== 'namespace') {
79
+ let exported = exposures.get(occurrence.file)
80
+ if (!exported) exposures.set(occurrence.file, (exported = new Set()))
81
+ const before = exported.size
82
+ exported.add(occurrence.exported)
83
+ changed = exported.size !== before || changed
84
+ }
85
+ }
86
+ if (!changed) break
87
+ }
88
+ const all = [...shown.values()].sort((left, right) => left.file.localeCompare(right.file)
89
+ || left.line - right.line || left.exported.localeCompare(right.exported))
90
+ return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
91
+ }
92
+
93
+ function linesForGroups(title, groups) {
94
+ if (!groups.shown.length) return [`${title}: none`]
95
+ const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}`]
96
+ for (const group of groups.shown) {
97
+ const sites = group.lines.length ? `:${group.lines.join(',')}` : ''
98
+ lines.push(` ${group.count}× ${group.relation} ${group.label} [${group.file}${sites}]`)
99
+ }
100
+ return lines
101
+ }
102
+
103
+ function textFor(result) {
104
+ if (result.status !== 'OK') return result.text || `Context bundle: ${result.status}`
105
+ const definition = result.definition
106
+ const lines = [
107
+ `Context bundle: ${definition.label} [${definition.id}]`,
108
+ `Definition: ${definition.file}:${definition.line} (${definition.kind}, ${definition.space} space)`,
109
+ `Evidence: ${result.evidence.state}; ${result.references.occurrences} exact reference occurrence(s) in ${result.references.files} file(s).`,
110
+ ...linesForGroups('Inbound', result.inbound),
111
+ ...linesForGroups('Outbound', result.outbound),
112
+ `Re-export sites: ${result.reExports.total}${result.reExports.capped ? ` (${result.reExports.shown.length} shown)` : ''}`,
113
+ ]
114
+ for (const site of result.reExports.shown) lines.push(` ${site.file}:${site.line} ${site.kind} ${site.imported} → ${site.exported}${site.typeOnly ? ' [type]' : ''}`)
115
+ for (const source of result.source) lines.push('', `${source.role} source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
116
+ return lines.join('\n')
117
+ }
118
+
119
+ export async function tContextBundle(g, args = {}, ctx = {}, inspectSymbol) {
120
+ if (typeof inspectSymbol !== 'function') throw new Error('context_bundle requires inspect_symbol support')
121
+ const maxRelated = boundedInteger(args.max_related, 10, 1, 30)
122
+ const maxReExports = boundedInteger(args.max_reexports, 20, 1, 100)
123
+ const maxSourceFiles = boundedInteger(args.max_source_files, 4, 1, 8)
124
+ const inspected = await inspectSymbol(g, {
125
+ ...args,
126
+ max_containers: Math.min(maxRelated, boundedInteger(args.max_containers, 10, 1, 30)),
127
+ context_lines: boundedInteger(args.context_lines, 4, 0, 12),
128
+ }, ctx)
129
+ const inspection = inspected?.result
130
+ if (!inspection || inspection.status !== 'OK') return inspected
131
+ const definition = {
132
+ ...inspection.definition,
133
+ name: String(g.byId.get(inspection.definition.id)?.label || '').replace(/\(\)$/, ''),
134
+ }
135
+ const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated)
136
+ const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated)
137
+ const reExports = exactReExportSites(g, definition, maxReExports)
138
+ const source = []
139
+ if (inspection.source.definition) source.push({role: 'Definition', ...inspection.source.definition})
140
+ for (const excerpt of inspection.source.callers || []) {
141
+ if (source.length >= maxSourceFiles) break
142
+ if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) continue
143
+ source.push({role: 'Caller', ...excerpt})
144
+ }
145
+ const result = {
146
+ status: 'OK', definition, evidence: inspection.evidence,
147
+ references: {
148
+ occurrences: inspection.exact.occurrences,
149
+ files: inspection.exact.files,
150
+ containers: inspection.exact.containers,
151
+ capped: inspection.exact.capped,
152
+ },
153
+ inbound, outbound, reExports, source,
154
+ }
155
+ return toolResult(textFor(result), result, {
156
+ warnings: inspected.warnings,
157
+ completeness: {
158
+ status: inspection.evidence.state === 'EXACT' && !inspection.exact.capped && !inbound.capped && !outbound.capped && !reExports.capped ? 'complete' : 'bounded',
159
+ relatedLimit: maxRelated,
160
+ reExportLimit: maxReExports,
161
+ sourceFileLimit: maxSourceFiles,
162
+ },
163
+ })
164
+ }
@@ -3,10 +3,11 @@
3
3
  // staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
4
4
  import {
5
5
  isSymbol, degreeOf, labelOf, connList,
6
- resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors,
6
+ resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors, requestedPathClasses,
7
7
  graphStaleness, fileStalenessNote,
8
8
  } from './graph-context.mjs'
9
9
  import {summarizeEdgeProvenance} from '../graph/edge-provenance.js'
10
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
10
11
 
11
12
  const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
12
13
 
@@ -48,6 +49,8 @@ export function tGraphStats(g, ctx) {
48
49
  g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${fmt(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : `- Edge provenance: unavailable (rebuild_graph required)`,
49
50
  `- Semantic precision: ${precision.state}${precision.provider ? ` via ${precision.provider}${precision.providerVersion ? ` ${precision.providerVersion}` : ''}${precision.typescriptVersion ? ` (TypeScript ${precision.typescriptVersion})` : ''}` : ''}; ${precision.verifiedEdges || 0} EXACT_LSP edge(s), ${precision.queried || 0}/${precision.candidates || 0} bounded target(s) queried${precision.truncated ? ' (partial/truncated)' : ''}${precision.reason ? `; ${precision.reason}` : ''}`,
50
51
  g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
52
+ g.reExportOccurrencesV ? `- Re-export occurrences: v${g.reExportOccurrencesV} (${g.reExportOccurrences.length} exact site(s))` : `- Re-export occurrences: unavailable (rebuild_graph required)`,
53
+ g.symbolSpacesV ? `- TypeScript symbol spaces: v${g.symbolSpacesV} (type/value identities separated)` : `- TypeScript symbol spaces: unavailable (rebuild_graph required)`,
51
54
  `- Relations: ${fmt(relCount)}`,
52
55
  Object.keys(confCount).length ? `- Legacy confidence: ${fmt(confCount)}` : null,
53
56
  `- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
@@ -163,7 +166,15 @@ export function tGetCommunity(g, {community_id} = {}) {
163
166
  // A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
164
167
  // Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
165
168
  // slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
166
- export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false, token_budget = 2000} = {}, toolCtx = {}) {
169
+ const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
170
+ const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
171
+ const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
172
+ const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
173
+
174
+ export function tQueryGraph(g, {
175
+ question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false,
176
+ include_classified = false, include_low_signal = false, token_budget = 2000,
177
+ } = {}, toolCtx = {}) {
167
178
  const pinned = resolveSeedFiles(g, seed_files)
168
179
  // Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
169
180
  // Callers can opt back into augmentation when they explicitly want both behaviors.
@@ -175,6 +186,32 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
175
186
  const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
176
187
  const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
177
188
  const relOk = (rel) => !ctx || ctx.has(String(rel ?? '').toLowerCase())
189
+ const requestedClasses = requestedPathClasses(question)
190
+ const classifier = createPathClassifier(toolCtx.repoRoot || null)
191
+ const classificationCache = new Map()
192
+ const pinnedFiles = new Set(pinned.seeds.map(querySourceFile))
193
+ const classifiedSuppressed = new Set()
194
+ const pathPolicy = (id) => {
195
+ const node = g.byId.get(String(id))
196
+ const file = querySourceFile(node)
197
+ if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
198
+ if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
199
+ const info = classificationCache.get(file)
200
+ const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
201
+ if (!classes.length && !info?.excluded) return {ok: true}
202
+ if (classes.some((name) => requestedClasses.has(name))) return {ok: true}
203
+ classifiedSuppressed.add(String(id))
204
+ return {ok: false, bucket: 'classified'}
205
+ }
206
+ const questionTerms = queryWords(question)
207
+ const isLowSignal = (id) => {
208
+ if (include_low_signal === true || start.includes(String(id))) return false
209
+ const node = g.byId.get(String(id))
210
+ if (!node || !isSymbol(node.id) || !LOW_SIGNAL_SYMBOL_RE.test(String(node.symbol_kind || ''))) return false
211
+ const labelTerms = queryWords(node.label || String(node.id || '').split('#').pop() || '')
212
+ if ([...questionTerms].some((term) => labelTerms.has(term))) return false
213
+ return degreeOf(g, id) === 0
214
+ }
178
215
  const charBudget = Math.max(400, (Number(token_budget) || 2000) * 4)
179
216
  // node budget scales gently with the token budget; edges follow the surviving nodes.
180
217
  const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
@@ -191,6 +228,7 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
191
228
  if (d >= maxDepth) continue
192
229
  for (const [nid, rel] of undirectedNeighbors(g, id)) {
193
230
  if (!relOk(rel)) continue
231
+ if (!pathPolicy(nid).ok) continue
194
232
  if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
195
233
  }
196
234
  }
@@ -202,6 +240,7 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
202
240
  for (const id of frontier)
203
241
  for (const [nid, rel] of undirectedNeighbors(g, id)) {
204
242
  if (!relOk(rel)) continue
243
+ if (!pathPolicy(nid).ok) continue
205
244
  if (!depthOf.has(nid)) {
206
245
  depthOf.set(nid, d + 1)
207
246
  next.push(nid)
@@ -211,7 +250,10 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
211
250
  }
212
251
  }
213
252
  // rank reached nodes: seeds first, then by proximity (depth asc), then connectivity (degree desc)
253
+ const reachedBeforeSignalFilter = depthOf.size
254
+ const lowSignalSuppressed = [...depthOf.keys()].filter(isLowSignal).length
214
255
  const ranked = [...depthOf.entries()]
256
+ .filter(([id]) => !isLowSignal(id))
215
257
  .map(([id, d]) => ({id, d, deg: degreeOf(g, id)}))
216
258
  .sort((a, b) => a.d - b.d || b.deg - a.deg)
217
259
  const shown = ranked.slice(0, nodeBudget)
@@ -234,7 +276,10 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
234
276
  `Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
235
277
  `Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
236
278
  pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
237
- `Reached ${depthOf.size} nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
279
+ `Reached ${reachedBeforeSignalFilter} policy-eligible nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
280
+ classifiedSuppressed.size ? `Suppressed ${classifiedSuppressed.size} classified/non-product traversal node(s); ask for that class or pass include_classified:true.` : null,
281
+ lowSignalSuppressed ? `Suppressed ${lowSignalSuppressed} unreferenced constant/field node(s) with no query-term match; pass include_low_signal:true to inspect them.` : null,
282
+ include_classified === true ? 'Path policy: classified/non-product traversal explicitly enabled.' : `Path policy: production-first${requestedClasses.size ? `; explicit question classes enabled: ${[...requestedClasses].join(', ')}` : ''}.`,
238
283
  ``,
239
284
  `Nodes:`,
240
285
  ]
@@ -3,6 +3,7 @@
3
3
  import {spawnSync} from 'node:child_process'
4
4
  import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
5
5
  import {computeDuplicates} from '../analysis/duplicates.js'
6
+ import {analyzeDuplicateGroups} from '../analysis/duplicate-groups.js'
6
7
  import {runInternalAudit} from '../analysis/internal-audit.js'
7
8
  import {classifyChangeImpact} from '../analysis/change-classification.js'
8
9
  import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
@@ -31,26 +32,6 @@ const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
31
32
  return true
32
33
  }
33
34
 
34
- // Group clone pairs into union-find families.
35
- function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified}) {
36
- const frags = data.frags || []
37
- const elig = (i) => fragmentEligible(frags[i], {tokMin, skipTests, includeClassified})
38
- const pairs = (data.modes?.[mode] || []).filter(([i, j, s]) => s >= simMin && elig(i) && elig(j))
39
- const parent = new Map()
40
- const find = (x) => { let r = x; while (parent.has(r) && parent.get(r) !== r) r = parent.get(r); return r }
41
- for (const [i, j] of pairs) { if (!parent.has(i)) parent.set(i, i); if (!parent.has(j)) parent.set(j, j); parent.set(find(i), find(j)) }
42
- const groups = new Map()
43
- for (const [i, j, s] of pairs) {
44
- const r = find(i)
45
- if (!groups.has(r)) groups.set(r, {members: new Set(), maxSim: 0})
46
- const g = groups.get(r); g.members.add(i); g.members.add(j); g.maxSim = Math.max(g.maxSim, s)
47
- }
48
- return [...groups.values()].map((g) => {
49
- const members = [...g.members].sort((a, b) => frags[b].n - frags[a].n)
50
- return {members: members.map((i) => frags[i]), maxSim: g.maxSim, tokens: members.reduce((n, i) => n + frags[i].n, 0)}
51
- }).sort((a, b) => b.tokens - a.tokens)
52
- }
53
-
54
35
  export function tFindDuplicates(g, args, ctx) {
55
36
  if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
56
37
  const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
@@ -97,10 +78,10 @@ export function tFindDuplicates(g, args, ctx) {
97
78
  })
98
79
  return `Found ${candidates.length} actionable same-name pair(s) across files (semantic mode; one closest clone and/or farthest collision per name). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nThese are review candidates, not automatic refactors. Use read_source on both sites before changing code.`
99
80
  }
100
- const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings, minTokens: tokMin})
101
- const groups = groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
81
+ const analysis = analyzeDuplicateGroups(ctx.repoRoot, ctx.graphPath, args)
82
+ const groups = analysis.groups
102
83
  const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
103
- const suppressed = data.frags.filter((fragment) => !fragmentEligible(fragment, {tokMin, skipTests, includeClassified})).length
84
+ const suppressed = analysis.suppressed
104
85
  const suppressionNote = suppressed && !includeClassified
105
86
  ? ` ${suppressed} fragment(s) classified as tests/e2e/generated/mock/story/docs/benchmark/temp or matched by .weavatrix.json exclude were suppressed; pass include_classified:true (and include_tests:true for tests) to inspect them explicitly.`
106
87
  : ''
@@ -162,13 +143,15 @@ export function tFindDeadCode(g, args, ctx) {
162
143
  : `${candidate.owner ? `${candidate.owner}.` : ''}${candidate.symbol || candidate.id}`
163
144
  const where = `${candidate.file}${candidate.line ? `:${candidate.line}` : ''}`
164
145
  return [
165
- `${index + 1}. [${candidate.confidence}/${candidate.classification}] ${subject} (${where})`,
146
+ `${index + 1}. [${candidate.confidence}/${candidate.evidenceTier}/${candidate.classification}] ${subject} (${where})`,
166
147
  ` evidence: ${candidate.evidence.map((item) => item.fact).join(' ')}`,
167
148
  candidate.caveats.length ? ` caution: ${candidate.caveats.join(' ')}` : null,
149
+ ` remaining: ${candidate.remainingChecks.join(' ')}`,
168
150
  ].filter(Boolean).join('\n')
169
151
  })
170
152
  const text = [
171
153
  `Dead-code review: ${shown.length} of ${review.candidates.length} candidate(s) shown (high ${counts.high}, medium ${counts.medium}, low ${counts.low}).`,
154
+ `Evidence tiers: strong static ${review.totals.byEvidenceTier.strongStatic}, bounded static ${review.totals.byEvidenceTier.boundedStatic}, high uncertainty ${review.totals.byEvidenceTier.highUncertainty}.`,
172
155
  `Verdict: REVIEW_REQUIRED. This is static evidence, never permission to auto-delete or bulk-delete.`,
173
156
  suppression ? `Suppressed by current filters: ${suppression}.` : null,
174
157
  review.suppressed.confidence ? 'Use min_confidence=low only when public/framework/dynamic candidates need explicit review.' : null,
@@ -196,7 +179,10 @@ const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
196
179
 
197
180
  export function formatAuditFinding(f) {
198
181
  const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
199
- return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
182
+ const verification = f.verification?.evidenceModel
183
+ ? `\n verification: ${f.verification.evidenceModel}; manifest ${f.verification.manifestDeclaration?.status || 'N/A'}; indexed imports ${f.verification.indexedSourceImports?.status || 'N/A'}; decision ${f.verification.decision || 'REVIEW_REQUIRED'}`
184
+ : ''
185
+ return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${verification}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
200
186
  }
201
187
 
202
188
  const auditFilter = (audit, args, findings = audit.findings) => {
@@ -234,7 +220,7 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
234
220
  heading,
235
221
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
236
222
  ...auditConventionLines(audit),
237
- `Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
223
+ `Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
238
224
  `Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
239
225
  `Repository-level ${auditChecksLine(audit)}`,
240
226
  '',
@@ -428,7 +414,7 @@ export async function tRunAudit(g, args, ctx) {
428
414
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
429
415
  `Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
430
416
  `Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
431
- `Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
417
+ `Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
432
418
  ...auditConventionLines(audit),
433
419
  `Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
434
420
  ``,
@@ -540,6 +526,7 @@ export function tHotPathReview(g, args, ctx) {
540
526
  const text = [
541
527
  `Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
542
528
  `Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
529
+ `Selection: ${review.selectionPolicy.mode}${review.selectionPolicy.strongLocalFallback ? '; strong local sort/recursion/deep-loop evidence can pass below the blended score gate' : '; strict explicit score gate'}.`,
543
530
  coverageLine,
544
531
  'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
545
532
  '',
@@ -557,7 +544,7 @@ export function tHotPathReview(g, args, ctx) {
557
544
  ` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
558
545
  ]
559
546
  }) : [' (none at the selected thresholds)']),
560
- review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n, min_score, or narrow path).` : null,
547
+ review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n to display more, raise min_score or narrow path to tighten; lower min_score to broaden).` : null,
561
548
  '',
562
549
  'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
563
550
  ].filter((line) => line != null).join('\n')
@@ -244,6 +244,7 @@ export function tChangeImpactV2(g, args = {}, ctx = {}) {
244
244
  },
245
245
  testEvidence: {
246
246
  actualCoverage: hasCoverage ? 'AVAILABLE' : 'NOT_AVAILABLE',
247
+ changedFiles: changed.slice(0, 500).map((file) => ({file, ...testEvidenceFor(file)})),
247
248
  staticTestReachability: {
248
249
  kind: staticTests.kind,
249
250
  testFiles: staticTests.testFiles,
@@ -1,4 +1,5 @@
1
1
  import {readFileSync, statSync} from 'node:fs'
2
+ import {boundedInteger} from '../bounds.js'
2
3
  import {resolveRepoPath} from '../repo-path.js'
3
4
  import {isStructuralRelation} from '../graph/relations.js'
4
5
  import {querySymbolPrecision} from '../precision/symbol-query.js'
@@ -9,11 +10,6 @@ const MAX_EXCERPT_CHARS = 4_000
9
10
  const MAX_LOCATION_SAMPLES = 5
10
11
  const MAX_GRAPH_OCCURRENCES = 100
11
12
 
12
- const boundedInteger = (value, fallback, minimum, maximum) => {
13
- const number = Number(value)
14
- return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
15
- }
16
-
17
13
  const sourceLine = (node) => {
18
14
  const match = /L(\d+)/.exec(String(node?.source_location || ''))
19
15
  return match ? Number(match[1]) : 1
@@ -25,7 +21,10 @@ function candidateNodes(g, query, limit = 20) {
25
21
  const exact = g.byLabel.get(text)
26
22
  const nodes = exact?.length ? exact : g.nodes.filter((node) =>
27
23
  String(node.id).toLowerCase().includes(text) || String(node.label || '').toLowerCase().includes(text))
28
- return nodes.slice(0, limit).map((node) => ({id: String(node.id), label: String(node.label || node.id), file: node.source_file || null}))
24
+ return nodes.slice(0, limit).map((node) => ({
25
+ id: String(node.id), label: String(node.label || node.id), file: node.source_file || null,
26
+ ...(node.symbol_space ? {space: node.symbol_space} : {}),
27
+ }))
29
28
  }
30
29
 
31
30
  function excerpt(repoRoot, file, focusLine, contextLines) {
@@ -192,6 +191,8 @@ export async function tInspectSymbol(g, args = {}, ctx = {}) {
192
191
  id: targetId,
193
192
  label: String(node.label || targetId),
194
193
  kind: String(node.symbol_kind || 'symbol'),
194
+ space: String(node.symbol_space || 'value'),
195
+ exported: node.exported === true,
195
196
  file: String(node.source_file || targetId.split('#')[0]),
196
197
  line: sourceLine(node),
197
198
  ...(node.source_range ? {range: node.source_range} : {}),