weavatrix 0.2.3 → 0.2.5

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 (36) hide show
  1. package/README.md +91 -20
  2. package/SECURITY.md +18 -0
  3. package/package.json +3 -1
  4. package/skill/SKILL.md +49 -13
  5. package/src/analysis/dead-check.js +48 -2
  6. package/src/analysis/dead-code-review.js +22 -8
  7. package/src/analysis/duplicates.compute.js +11 -6
  8. package/src/analysis/git-ref-graph.js +10 -2
  9. package/src/analysis/hot-path-review.js +228 -0
  10. package/src/analysis/internal-audit.run.js +36 -0
  11. package/src/analysis/source-complexity.report.js +5 -0
  12. package/src/analysis/source-complexity.walk.js +64 -10
  13. package/src/build-graph.js +49 -10
  14. package/src/graph/build-worker.js +21 -1
  15. package/src/graph/builder/lang-java.js +1 -0
  16. package/src/graph/builder/lang-js.js +24 -0
  17. package/src/graph/builder/lang-python.js +161 -4
  18. package/src/graph/freshness-probe.js +3 -3
  19. package/src/graph/incremental-refresh.js +1 -1
  20. package/src/graph/internal-builder.barrels.js +33 -4
  21. package/src/graph/internal-builder.build.js +52 -8
  22. package/src/mcp/catalog.mjs +14 -11
  23. package/src/mcp/graph-context.mjs +143 -14
  24. package/src/mcp/sync-payload.mjs +2 -1
  25. package/src/mcp/tools-actions.mjs +59 -20
  26. package/src/mcp/tools-company.mjs +11 -3
  27. package/src/mcp/tools-graph.mjs +12 -6
  28. package/src/mcp/tools-health.mjs +112 -11
  29. package/src/mcp/tools-impact.mjs +24 -6
  30. package/src/mcp/tools-source.mjs +237 -0
  31. package/src/mcp-server.mjs +99 -11
  32. package/src/path-classification.js +2 -2
  33. package/src/precision/lsp-client.js +682 -0
  34. package/src/precision/lsp-overlay.js +872 -0
  35. package/src/precision/symbol-query.js +137 -0
  36. package/src/precision/typescript-lsp-provider.js +682 -0
@@ -1,7 +1,7 @@
1
1
  // Health tools: clone detection, the internal audit, community/module overviews, coverage mapping
2
2
  // and the HTTP endpoint inventory. Hot-reloadable (re-imported by catalog.mjs on change).
3
3
  import {spawnSync} from 'node:child_process'
4
- import {degreeOf, rawGraph} from './graph-context.mjs'
4
+ import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
5
5
  import {computeDuplicates} from '../analysis/duplicates.js'
6
6
  import {runInternalAudit} from '../analysis/internal-audit.js'
7
7
  import {classifyChangeImpact} from '../analysis/change-classification.js'
@@ -11,14 +11,16 @@ import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.j
11
11
  import {detectEndpoints} from '../analysis/endpoints.js'
12
12
  import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
13
13
  import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
14
+ import {computeHotPathReview} from '../analysis/hot-path-review.js'
14
15
  import {collectNonRuntimeRoots, collectPackageScopes, collectSourceTexts, readRepoJson} from '../analysis/internal-audit.collect.js'
15
16
  import {entryFiles} from '../analysis/internal-audit.reach.js'
16
17
  import {buildInternalGraph} from '../graph/internal-builder.js'
17
18
  import {resolveGitCommit, withGitRefCheckout} from '../analysis/git-ref-graph.js'
18
19
  import {childProcessEnv} from '../child-env.js'
19
20
  import {toolResult} from './tool-result.mjs'
20
- import {createPathClassifier} from '../path-classification.js'
21
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
21
22
  import {createRepoBoundary} from '../repo-path.js'
23
+ import {filterGraphForMode} from '../graph/graph-filter.js'
22
24
 
23
25
  const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
24
26
  const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
@@ -52,7 +54,7 @@ function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
52
54
  export function tFindDuplicates(g, args, ctx) {
53
55
  if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
54
56
  const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
55
- const tokMin = Math.min(400, Math.max(30, Number(args.min_tokens) || 50))
57
+ const tokMin = Math.min(400, Math.max(12, Number(args.min_tokens) || 50))
56
58
  const mode = args.mode === 'strict' ? 'strict' : 'renamed'
57
59
  const skipTests = args.include_tests ? false : true
58
60
  const includeClassified = args.include_classified === true || args.include_non_product === true
@@ -60,7 +62,7 @@ export function tFindDuplicates(g, args, ctx) {
60
62
  // semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
61
63
  // (same name, drifted behavior). Token-clone pairing is skipped entirely.
62
64
  if (args.mode === 'semantic') {
63
- const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true})
65
+ const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true, minTokens: tokMin})
64
66
  const frags = data.frags
65
67
  const candidates = []
66
68
  for (const twin of data.nameTwins || []) {
@@ -95,13 +97,14 @@ export function tFindDuplicates(g, args, ctx) {
95
97
  })
96
98
  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.`
97
99
  }
98
- const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
100
+ const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings, minTokens: tokMin})
99
101
  const groups = groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
102
+ const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
100
103
  const suppressed = data.frags.filter((fragment) => !fragmentEligible(fragment, {tokMin, skipTests, includeClassified})).length
101
104
  const suppressionNote = suppressed && !includeClassified
102
105
  ? ` ${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.`
103
106
  : ''
104
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${suppressionNote}`
107
+ if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}`
105
108
  const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
106
109
  const lines = top.map((grp, k) => {
107
110
  const isStr = grp.members.some((f) => f.kind === 'string')
@@ -109,7 +112,7 @@ export function tFindDuplicates(g, args, ctx) {
109
112
  const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
110
113
  return [head, ...sites].join('\n')
111
114
  })
112
- return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${suppressionNote}`
115
+ return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}`
113
116
  }
114
117
 
115
118
  // Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
@@ -117,7 +120,7 @@ export function tFindDuplicates(g, args, ctx) {
117
120
  // candidates. It never returns an automatic-delete verdict.
118
121
  export function tFindDeadCode(g, args, ctx) {
119
122
  if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
120
- const graph = rawGraph(ctx)
123
+ const graph = effectiveRawGraph(ctx)
121
124
  const boundary = createRepoBoundary(ctx.repoRoot)
122
125
  const pkg = readRepoJson(boundary, 'package.json') || {}
123
126
  const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
@@ -226,10 +229,12 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
226
229
  const summary = summarizeFindings(findings)
227
230
  const sev = summary.bySeverity
228
231
  const bycat = summary.byCategory
232
+ const deps = audit.dependencyReport || {}
229
233
  return [
230
234
  heading,
231
235
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
232
236
  ...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'}.`,
233
238
  `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}.`,
234
239
  `Repository-level ${auditChecksLine(audit)}`,
235
240
  '',
@@ -323,7 +328,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
323
328
  }
324
329
 
325
330
  const baseline = await withGitRefCheckout(ctx.repoRoot, resolved.commit, async (checkout) => {
326
- const graph = await buildInternalGraph(checkout)
331
+ let graph = await buildInternalGraph(checkout)
332
+ const currentMode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode)
333
+ ? currentGraph.graphBuildMode : 'full'
334
+ if (currentMode !== 'full') graph = filterGraphForMode(graph, currentMode, {repoRoot: checkout})
335
+ graph.graphBuildMode = currentMode
336
+ graph.graphBuildScope = ''
327
337
  return runInternalAudit(checkout, {graph, skipMalwareScan: true})
328
338
  })
329
339
  if (!baseline.ok || !baseline.value?.ok) {
@@ -385,7 +395,7 @@ export async function tRunAudit(g, args, ctx) {
385
395
  if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
386
396
  if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
387
397
  const audit = await runInternalAudit(ctx.repoRoot, {
388
- graph: rawGraph(ctx),
398
+ graph: effectiveRawGraph(ctx),
389
399
  skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
390
400
  })
391
401
  if (!audit.ok) return `Audit failed: ${audit.error}`
@@ -412,11 +422,13 @@ export async function tRunAudit(g, args, ctx) {
412
422
  const shown = filtered.slice(0, max)
413
423
  const sev = audit.summary.bySeverity
414
424
  const bycat = audit.summary.byCategory
425
+ const deps = audit.dependencyReport || {}
415
426
  const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
416
427
  return [
417
428
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
418
429
  `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}.`,
419
430
  `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'}.`,
420
432
  ...auditConventionLines(audit),
421
433
  `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.`,
422
434
  ``,
@@ -440,7 +452,36 @@ export function tListCommunities(g, args, ctx) {
440
452
  // Folder-level architecture map: modules (top-two path segments) with file/symbol counts and the
441
453
  // strongest module→module dependencies. Pure graph aggregation — no filesystem reads.
442
454
  export function tModuleMap(g, args, ctx) {
443
- const agg = aggregateGraph(rawGraph(ctx), null)
455
+ const graph = rawGraph(ctx)
456
+ const testsOnly = graph.graphBuildMode === 'tests-only'
457
+ const includeNonProduct = args.include_non_product === true || testsOnly
458
+ const classifier = createPathClassifier(ctx?.repoRoot || null)
459
+ const nonProductFiles = new Set()
460
+ const classifiedFiles = new Set()
461
+ if (!includeNonProduct) {
462
+ for (const node of graph.nodes || []) {
463
+ if (!node?.source_file) continue
464
+ const sourceFile = String(node.source_file)
465
+ if (classifiedFiles.has(sourceFile)) continue
466
+ classifiedFiles.add(sourceFile)
467
+ const explanation = classifier.explain(sourceFile)
468
+ if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
469
+ nonProductFiles.add(sourceFile)
470
+ }
471
+ }
472
+ }
473
+ const visibleGraph = includeNonProduct || nonProductFiles.size === 0 ? graph : (() => {
474
+ const keep = new Set((graph.nodes || [])
475
+ .filter((node) => !node?.source_file || !nonProductFiles.has(String(node.source_file)))
476
+ .map((node) => String(node.id)))
477
+ const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
478
+ return {
479
+ ...graph,
480
+ nodes: (graph.nodes || []).filter((node) => keep.has(String(node.id))),
481
+ links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
482
+ }
483
+ })()
484
+ const agg = aggregateGraph(visibleGraph, null)
444
485
  const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
445
486
  const mods = agg.modules.slice(0, topN)
446
487
  const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
@@ -458,6 +499,11 @@ export function tModuleMap(g, args, ctx) {
458
499
  collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
459
500
  const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
460
501
  return [
502
+ testsOnly
503
+ ? 'Scope: tests-only graph; classified test/e2e/fixture surfaces are retained automatically.'
504
+ : includeNonProduct
505
+ ? 'Scope: all indexed files, including classified non-product surfaces.'
506
+ : `Scope: production-only (default); excluded ${nonProductFiles.size} classified test/fixture/benchmark/generated/docs file(s). Pass include_non_product:true for the complete graph.`,
461
507
  `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.compileTimeModuleEdges || 0} compile-time dependencies (${agg.totals.typeOnlyModuleEdges || 0} type-only, ${agg.totals.compileOnlyModuleEdges || 0} compile-only). Top ${mods.length}:`,
462
508
  ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
463
509
  ``,
@@ -469,6 +515,61 @@ export function tModuleMap(g, args, ctx) {
469
515
  ].filter((line) => line != null).join('\n')
470
516
  }
471
517
 
518
+ // Parser-backed local cost review. This keeps syntax, graph coupling and measured/static test
519
+ // evidence as separate fields so callers cannot mistake a ranking heuristic for profiler output.
520
+ export function tHotPathReview(g, args, ctx) {
521
+ const review = computeHotPathReview(rawGraph(ctx), {
522
+ repoRoot: ctx?.repoRoot || null,
523
+ path: args.path,
524
+ includeTests: args.include_tests === true,
525
+ includeClassified: args.include_classified === true,
526
+ topN: args.top_n,
527
+ cyclomaticThreshold: args.cyclomatic_threshold,
528
+ callThreshold: args.call_threshold,
529
+ loopDepthThreshold: args.loop_depth_threshold,
530
+ timeRankThreshold: args.time_rank_threshold,
531
+ minScore: args.min_score,
532
+ })
533
+ if (!review.ok) return toolResult(`Hot-path review refused: ${review.error}.`, review)
534
+ const pct = (value) => typeof value === 'number' ? `${Math.round(value * 100)}%` : String(value || 'NOT_AVAILABLE')
535
+ const coverageLine = review.coverage.actualCoverage === 'AVAILABLE'
536
+ ? `Measured coverage: ${review.coverage.measuredFiles} file(s) from ${review.coverage.sources.join(', ') || 'coverage report'}.`
537
+ : review.coverage.staticReachability
538
+ ? `actualCoverage: NOT_AVAILABLE; static test reachability ${review.coverage.staticReachability.reachableFiles}/${review.coverage.staticReachability.productFiles} product file(s).`
539
+ : 'actualCoverage: NOT_AVAILABLE; no measured or static test evidence was available.'
540
+ const text = [
541
+ `Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
542
+ `Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
543
+ coverageLine,
544
+ 'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
545
+ '',
546
+ ...(review.hotspots.length ? review.hotspots.flatMap((item, index) => {
547
+ const tests = item.testEvidence.actualCoverage === 'NOT_AVAILABLE'
548
+ ? item.testEvidence.staticReachable ? `static-test d${item.testEvidence.distance}` : 'no-static-test-path'
549
+ : `coverage ${pct(item.testEvidence.actualCoverage)}`
550
+ const pointer = `${item.file}${item.startLine ? `:${item.startLine}${item.endLine > item.startLine ? `-${item.endLine}` : ''}` : ''}`
551
+ const evidence = item.sourceEvidence.length
552
+ ? `\n evidence: ${item.sourceEvidence.map((entry) => `${entry.kind}@L${entry.line || '?'}${entry.detail ? ` (${entry.detail})` : ''}`).join('; ')}`
553
+ : ''
554
+ return [
555
+ ` ${String(index + 1).padStart(2)}. score ${String(item.score).padStart(5)} syntax ${String(item.localSyntax.score).padStart(5)} graph ${String(item.graphRisk.score).padStart(5)} ${item.confidence}`,
556
+ ` ${item.label} (${pointer}; fan-in ${item.graphRisk.fanIn}, fan-out ${item.graphRisk.fanOut}; ${tests})`,
557
+ ` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
558
+ ]
559
+ }) : [' (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,
561
+ '',
562
+ 'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
563
+ ].filter((line) => line != null).join('\n')
564
+ return toolResult(text, review, {
565
+ completeness: {
566
+ symbols: 'COMPLETE_FOR_INDEXED_GRAPH',
567
+ output: review.bounds.truncated ? 'BOUNDED' : 'COMPLETE',
568
+ coverage: review.coverage.actualCoverage,
569
+ },
570
+ })
571
+ }
572
+
472
573
  // Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
473
574
  // tests are never executed here) onto files and symbols, then rank refactor risk as
474
575
  // connectivity × uncovered share. Pairs with get_dependents: many dependents + low coverage ⇒ write
@@ -31,9 +31,11 @@ function reverseReach(g, seeds, maxDepth) {
31
31
  }
32
32
  const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
33
33
  const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
34
+ const provenanceKey = compileOnly ? 'compileProvenance' : 'runtimeProvenance'
34
35
  if (entry[depthKey] != null && entry[depthKey] <= depth) continue
35
36
  entry[depthKey] = depth
36
37
  entry[relationKey] = e.relation || 'rel'
38
+ entry[provenanceKey] = e.provenance || 'UNKNOWN'
37
39
  states.set(id, entry)
38
40
  frontier.push({id, depth, compileOnly})
39
41
  }
@@ -43,6 +45,7 @@ function reverseReach(g, seeds, maxDepth) {
43
45
  depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
44
46
  compileOnly: entry.runtimeDepth == null,
45
47
  relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
48
+ provenance: entry.runtimeDepth != null ? entry.runtimeProvenance : entry.compileProvenance,
46
49
  }]))
47
50
  }
48
51
 
@@ -56,7 +59,7 @@ const impactKind = (entry) => {
56
59
  // Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
57
60
  // (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
58
61
  // containing file, because importers depend on the file rather than the individual symbol.
59
- export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
62
+ export function tGetDependents(g, {label, depth = 3, max_nodes = 40, include_container_importers = false} = {}) {
60
63
  const info = resolveNodeInfo(g, label)
61
64
  const n = info.node
62
65
  if (!n) return `No node found matching "${label}".`
@@ -66,7 +69,7 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
66
69
  const id = String(n.id)
67
70
  const seeds = new Set([id])
68
71
  let containingFile = null
69
- if (isSymbol(id)) {
72
+ if (include_container_importers === true && isSymbol(id)) {
70
73
  const container = (g.inn.get(id) || []).find((e) => e.relation === 'contains')
71
74
  if (container) {
72
75
  containingFile = String(container.id)
@@ -85,18 +88,22 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
85
88
  return [
86
89
  note,
87
90
  `Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
88
- containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
89
- ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
91
+ containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)} by explicit request.` : null,
92
+ ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} [${r.entry.provenance || 'UNKNOWN'}] ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
90
93
  ].filter(Boolean).join('\n')
91
94
  }
92
95
 
93
96
  // Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
94
97
  export async function tGraphDiff(g, args, ctx) {
98
+ const current = rawGraph(ctx)
99
+ const currentMode = ['full', 'no-tests', 'tests-only'].includes(current?.graphBuildMode)
100
+ ? current.graphBuildMode
101
+ : 'full'
95
102
  let prev
96
103
  let baselineLabel = 'previous rebuild state'
97
104
  if (args.base_ref) {
98
105
  if (!ctx.repoRoot) return 'A Git-ref graph diff needs the active repository root.'
99
- const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref)
106
+ const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref, {mode: currentMode})
100
107
  if (!built.ok) return `Could not build the baseline graph: ${built.error}`
101
108
  prev = built.graph
102
109
  baselineLabel = `${built.ref} (${built.commit.slice(0, 12)})`
@@ -106,7 +113,17 @@ export async function tGraphDiff(g, args, ctx) {
106
113
  return `No previous graph state at ${prevPath} — pass base_ref (for example HEAD~1 or main), or run rebuild_graph to save one automatically.`
107
114
  }
108
115
  }
109
- const current = rawGraph(ctx)
116
+ if (!args.base_ref) {
117
+ const previousMode = ['full', 'no-tests', 'tests-only'].includes(prev?.graphBuildMode)
118
+ ? prev.graphBuildMode
119
+ : 'full'
120
+ if (previousMode !== currentMode) {
121
+ return [
122
+ `Graph diff unavailable: previous graph mode is ${previousMode}, current graph mode is ${currentMode}.`,
123
+ 'Those node/edge universes are not comparable. Run rebuild_graph once more in the same mode, or pass base_ref to build a mode-matched immutable baseline.',
124
+ ].join('\n')
125
+ }
126
+ }
110
127
  const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
111
128
  const scope = (graph) => filter ? {
112
129
  nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
@@ -118,6 +135,7 @@ export async function tGraphDiff(g, args, ctx) {
118
135
  } : graph
119
136
  return [
120
137
  `Graph diff (${baselineLabel} → current)${filter ? `, scoped to ${filter}` : ''}:`,
138
+ `Build mode: ${currentMode}`,
121
139
  formatGraphDiff(diffGraphs(scope(prev), scope(current)))
122
140
  ].join('\n')
123
141
  }
@@ -0,0 +1,237 @@
1
+ import {readFileSync, statSync} from 'node:fs'
2
+ import {resolveRepoPath} from '../repo-path.js'
3
+ import {isStructuralRelation} from '../graph/relations.js'
4
+ import {querySymbolPrecision} from '../precision/symbol-query.js'
5
+ import {toolResult} from './tool-result.mjs'
6
+ import {degreeOf, isSymbol, labelOf, resolveNodeInfo} from './graph-context.mjs'
7
+
8
+ const MAX_EXCERPT_CHARS = 4_000
9
+ const MAX_LOCATION_SAMPLES = 5
10
+ const MAX_GRAPH_OCCURRENCES = 100
11
+
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
+ const sourceLine = (node) => {
18
+ const match = /L(\d+)/.exec(String(node?.source_location || ''))
19
+ return match ? Number(match[1]) : 1
20
+ }
21
+
22
+ function candidateNodes(g, query, limit = 20) {
23
+ const text = String(query || '').trim().toLowerCase()
24
+ if (!text) return []
25
+ const exact = g.byLabel.get(text)
26
+ const nodes = exact?.length ? exact : g.nodes.filter((node) =>
27
+ 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}))
29
+ }
30
+
31
+ function excerpt(repoRoot, file, focusLine, contextLines) {
32
+ const resolved = resolveRepoPath(repoRoot, file)
33
+ if (!resolved.ok) return null
34
+ let size
35
+ try { size = statSync(resolved.path).size } catch { return null }
36
+ if (size > 2 * 1024 * 1024) return null
37
+ let lines
38
+ try { lines = readFileSync(resolved.path, 'utf8').split(/\r?\n/) } catch { return null }
39
+ const focus = Math.max(1, Math.min(lines.length, Number(focusLine) || 1))
40
+ const startLine = Math.max(1, focus - contextLines)
41
+ const endLine = Math.min(lines.length, focus + contextLines)
42
+ const rawText = lines.slice(startLine - 1, endLine).join('\n')
43
+ return {
44
+ file,
45
+ focusLine: focus,
46
+ startLine,
47
+ endLine,
48
+ text: rawText.length > MAX_EXCERPT_CHARS ? `${rawText.slice(0, MAX_EXCERPT_CHARS)}\n… [excerpt truncated]` : rawText,
49
+ truncated: rawText.length > MAX_EXCERPT_CHARS,
50
+ }
51
+ }
52
+
53
+ function reverseImpact(g, targetId, depth = 3, cap = 40) {
54
+ const seen = new Map([[targetId, 0]])
55
+ const queue = [targetId]
56
+ for (let cursor = 0; cursor < queue.length; cursor++) {
57
+ const current = queue[cursor]
58
+ const currentDepth = seen.get(current)
59
+ if (currentDepth >= depth) continue
60
+ for (const edge of g.inn.get(current) || []) {
61
+ if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
62
+ const id = String(edge.id)
63
+ if (seen.has(id)) continue
64
+ seen.set(id, currentDepth + 1)
65
+ queue.push(id)
66
+ }
67
+ }
68
+ return [...seen.entries()]
69
+ .filter(([id]) => id !== targetId)
70
+ .map(([id, distance]) => ({id, label: labelOf(g, id), distance, degree: degreeOf(g, id)}))
71
+ .sort((left, right) => left.distance - right.distance || right.degree - left.degree)
72
+ .slice(0, cap)
73
+ }
74
+
75
+ function graphOccurrences(g, targetId) {
76
+ const all = (g.inn.get(targetId) || [])
77
+ .filter((edge) => !isStructuralRelation(edge.relation) && edge.barrelProxy !== true)
78
+ .map((edge) => ({
79
+ source: String(edge.id),
80
+ label: labelOf(g, edge.id),
81
+ relation: edge.relation || 'references',
82
+ provenance: edge.provenance || 'UNKNOWN',
83
+ ...(Number.isInteger(edge.line) ? {line: edge.line} : {}),
84
+ ...(edge.typeOnly === true ? {typeOnly: true} : {}),
85
+ ...(edge.compileOnly === true ? {compileOnly: true} : {}),
86
+ }))
87
+ return {total: all.length, shown: all.slice(0, MAX_GRAPH_OCCURRENCES)}
88
+ }
89
+
90
+ function groupLocations(g, locations, cap) {
91
+ const groups = new Map()
92
+ for (const location of locations) {
93
+ const source = String(location.source || location.file || '')
94
+ if (!source) continue
95
+ const node = g.byId.get(source)
96
+ const file = String(location.file || node?.source_file || (isSymbol(source) ? source.split('#')[0] : source))
97
+ let group = groups.get(source)
98
+ if (!group) {
99
+ group = {id: source, label: String(node?.label || source), file, count: 0, classifications: {}, locations: []}
100
+ groups.set(source, group)
101
+ }
102
+ group.count++
103
+ const classification = String(location.classification || 'unknown')
104
+ group.classifications[classification] = (group.classifications[classification] || 0) + 1
105
+ if (group.locations.length < MAX_LOCATION_SAMPLES) group.locations.push({
106
+ line: location.line,
107
+ character: location.character,
108
+ ...(Number.isInteger(location.endLine) ? {endLine: location.endLine} : {}),
109
+ ...(Number.isInteger(location.endCharacter) ? {endCharacter: location.endCharacter} : {}),
110
+ classification,
111
+ })
112
+ }
113
+ const all = [...groups.values()].sort((left, right) => right.count - left.count || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
114
+ return {all, shown: all.slice(0, cap)}
115
+ }
116
+
117
+ function textFor(result) {
118
+ if (result.status === 'NOT_FOUND') return `No symbol found matching "${result.query}".`
119
+ if (result.status === 'AMBIGUOUS') return [
120
+ `Symbol "${result.query}" is ambiguous (${result.candidates.length} candidate(s) shown). Supply an exact node ID:`,
121
+ ...result.candidates.map((candidate) => ` ${candidate.label} [${candidate.id}]`),
122
+ ].join('\n')
123
+ const definition = result.definition
124
+ const lines = [
125
+ `Symbol inspection: ${definition.label} [${definition.id}]`,
126
+ `Definition: ${definition.file}:${definition.line}`,
127
+ `Evidence: ${result.evidence.state}${result.evidence.cached ? ' (cache hit)' : ''}; ${result.exact.occurrences} exact reference occurrence(s) in ${result.exact.files} file(s) / ${result.exact.containers} container(s).`,
128
+ `Graph: ${result.graph.occurrenceTotal} direct occurrence edge(s) (${result.graph.occurrences.length} shown); ${result.graph.impact.length} dependent(s) shown.`,
129
+ ]
130
+ if (result.evidence.reason) lines.push(`Completeness: ${result.evidence.reason}`)
131
+ if (result.exact.zeroReferences) lines.push('Exact result: zero non-declaration references in the completely covered project universe.')
132
+ if (result.exact.groups.length) {
133
+ lines.push('Reference containers:')
134
+ for (const group of result.exact.groups) lines.push(` ${group.count} site(s) ${group.label} [${group.id}]`)
135
+ }
136
+ if (result.source.definition) {
137
+ const source = result.source.definition
138
+ lines.push('', `Definition source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
139
+ }
140
+ for (const source of result.source.callers) {
141
+ lines.push('', `Caller source (${source.file}:${source.startLine}-${source.endLine}, focus ${source.focusLine}):`, source.text)
142
+ }
143
+ return lines.join('\n')
144
+ }
145
+
146
+ export async function tInspectSymbol(g, args = {}, ctx = {}) {
147
+ const query = String(args.label || '').trim()
148
+ const info = resolveNodeInfo(g, query)
149
+ if (!info.node) return toolResult(textFor({status: 'NOT_FOUND', query}), {status: 'NOT_FOUND', query})
150
+ if (info.matches > 1 && !g.byId.has(query)) {
151
+ const result = {status: 'AMBIGUOUS', query, candidates: candidateNodes(g, query)}
152
+ return toolResult(textFor(result), result, {completeness: {status: 'blocked', reason: 'ambiguous symbol'}})
153
+ }
154
+ const node = info.node
155
+ const targetId = String(node.id)
156
+ if (!isSymbol(targetId)) {
157
+ const result = {status: 'UNSUPPORTED', query, candidates: [{id: targetId, label: node.label || targetId, file: node.source_file || targetId}]}
158
+ return toolResult('inspect_symbol requires a symbol node, not a file node.', result)
159
+ }
160
+ const maxReferences = boundedInteger(args.max_references, 1_000, 1, 5_000)
161
+ const maxContainers = boundedInteger(args.max_containers, 15, 1, 50)
162
+ const contextLines = boundedInteger(args.context_lines, 8, 0, 40)
163
+ const timeoutMs = boundedInteger(args.timeout_ms, 30_000, 1_000, 60_000)
164
+ const precision = ['auto', 'graph', 'lsp'].includes(args.precision) ? args.precision : 'auto'
165
+ const shouldQuery = precision === 'lsp' || (precision === 'auto' && g.graphPrecisionMode !== 'off')
166
+ let overlay = null
167
+ let cached = false
168
+ let elapsedMs = 0
169
+ let queryError = null
170
+ if (shouldQuery) {
171
+ try {
172
+ const queryResult = await querySymbolPrecision({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, targetId, maxReferences, timeoutMs})
173
+ overlay = queryResult.overlay
174
+ cached = queryResult.cached
175
+ elapsedMs = queryResult.elapsedMs
176
+ } catch (error) {
177
+ queryError = error?.message || 'exact symbol query failed'
178
+ }
179
+ }
180
+ const locations = Array.isArray(overlay?.locations) ? overlay.locations : []
181
+ const grouped = groupLocations(g, locations, maxContainers)
182
+ const files = new Set(locations.map((location) => String(location.file || '')).filter(Boolean))
183
+ let state = 'GRAPH_ONLY'
184
+ if (overlay?.state === 'COMPLETE') state = 'EXACT'
185
+ else if (overlay?.state === 'PARTIAL') state = 'PARTIAL'
186
+ else if (overlay?.state === 'OFF') state = 'OFF'
187
+ else if (shouldQuery) state = 'UNAVAILABLE'
188
+ const reason = queryError || overlay?.reason || (shouldQuery ? null : 'semantic precision was not requested')
189
+ const graphRefs = graphOccurrences(g, targetId)
190
+ const impact = reverseImpact(g, targetId)
191
+ const definition = {
192
+ id: targetId,
193
+ label: String(node.label || targetId),
194
+ kind: String(node.symbol_kind || 'symbol'),
195
+ file: String(node.source_file || targetId.split('#')[0]),
196
+ line: sourceLine(node),
197
+ ...(node.source_range ? {range: node.source_range} : {}),
198
+ ...(node.complexity ? {complexity: node.complexity} : {}),
199
+ }
200
+ const definitionSource = excerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
201
+ const callerSources = grouped.shown.slice(0, 5).map((group) => excerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
202
+ const result = {
203
+ status: 'OK',
204
+ definition,
205
+ evidence: {state, cached, elapsedMs, reason, provider: overlay?.engines?.[0]?.provider || null},
206
+ exact: {
207
+ occurrences: locations.length,
208
+ occurrencesWithDefinition: locations.length + 1,
209
+ files: files.size,
210
+ containers: grouped.all.length,
211
+ groups: grouped.shown,
212
+ zeroReferences: overlay?.noReferenceSymbols?.includes(targetId) === true,
213
+ capped: overlay?.coverage?.truncated === true || grouped.all.length > grouped.shown.length,
214
+ },
215
+ graph: {
216
+ occurrences: graphRefs.shown,
217
+ occurrenceTotal: graphRefs.total,
218
+ occurrencesCapped: graphRefs.total > graphRefs.shown.length,
219
+ impact,
220
+ },
221
+ source: {definition: definitionSource, callers: callerSources},
222
+ }
223
+ const warnings = state === 'PARTIAL' || state === 'UNAVAILABLE'
224
+ ? [{code: 'SYMBOL_PRECISION_INCOMPLETE', message: reason || `semantic precision is ${state.toLowerCase()}`}]
225
+ : []
226
+ return toolResult(textFor(result), result, {
227
+ warnings,
228
+ completeness: {
229
+ status: state === 'EXACT' && !result.exact.capped ? 'complete' : state.toLowerCase(),
230
+ referenceLimit: maxReferences,
231
+ containerLimit: maxContainers,
232
+ returnedContainers: grouped.shown.length,
233
+ locationSamplesPerContainer: MAX_LOCATION_SAMPLES,
234
+ graphOccurrenceLimit: MAX_GRAPH_OCCURRENCES,
235
+ },
236
+ })
237
+ }