weavatrix 0.1.3 → 0.2.0

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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
@@ -1,15 +1,38 @@
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
+ import {spawnSync} from 'node:child_process'
3
4
  import {degreeOf, rawGraph} from './graph-context.mjs'
4
5
  import {computeDuplicates} from '../analysis/duplicates.js'
5
6
  import {runInternalAudit} from '../analysis/internal-audit.js'
7
+ import {classifyChangeImpact} from '../analysis/change-classification.js'
8
+ import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
9
+ import {summarizeFindings} from '../analysis/findings.js'
6
10
  import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.js'
7
11
  import {detectEndpoints} from '../analysis/endpoints.js'
12
+ import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
13
+ import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
14
+ import {collectNonRuntimeRoots, collectPackageScopes, collectSourceTexts, readRepoJson} from '../analysis/internal-audit.collect.js'
15
+ import {entryFiles} from '../analysis/internal-audit.reach.js'
16
+ import {buildInternalGraph} from '../graph/internal-builder.js'
17
+ import {resolveGitCommit, withGitRefCheckout} from '../analysis/git-ref-graph.js'
18
+ import {childProcessEnv} from '../child-env.js'
19
+ import {toolResult} from './tool-result.mjs'
20
+ import {createPathClassifier} from '../path-classification.js'
21
+ import {createRepoBoundary} from '../repo-path.js'
22
+
23
+ const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
24
+ const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
25
+ if (fragment.n < tokMin) return false
26
+ const classes = new Set(fragment.classes || [])
27
+ if (skipTests && (fragment.test || classes.has('test') || classes.has('e2e'))) return false
28
+ if (!includeClassified && (fragment.excluded || [...classes].some((name) => NON_PRODUCT_DUPLICATE_CLASSES.has(name)))) return false
29
+ return true
30
+ }
8
31
 
9
32
  // Group clone pairs into union-find families.
10
- function groupClones(data, {simMin, tokMin, mode, skipTests}) {
33
+ function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified}) {
11
34
  const frags = data.frags || []
12
- const elig = (i) => frags[i].n >= tokMin && (!skipTests || !frags[i].test)
35
+ const elig = (i) => fragmentEligible(frags[i], {tokMin, skipTests, includeClassified})
13
36
  const pairs = (data.modes?.[mode] || []).filter(([i, j, s]) => s >= simMin && elig(i) && elig(j))
14
37
  const parent = new Map()
15
38
  const find = (x) => { let r = x; while (parent.has(r) && parent.get(r) !== r) r = parent.get(r); return r }
@@ -32,6 +55,7 @@ export function tFindDuplicates(g, args, ctx) {
32
55
  const tokMin = Math.min(400, Math.max(30, Number(args.min_tokens) || 50))
33
56
  const mode = args.mode === 'strict' ? 'strict' : 'renamed'
34
57
  const skipTests = args.include_tests ? false : true
58
+ const includeClassified = args.include_classified === true || args.include_non_product === true
35
59
  const includeStrings = !!args.include_strings
36
60
  // semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
37
61
  // (same name, drifted behavior). Token-clone pairing is skipped entirely.
@@ -40,7 +64,7 @@ export function tFindDuplicates(g, args, ctx) {
40
64
  const frags = data.frags
41
65
  const candidates = []
42
66
  for (const twin of data.nameTwins || []) {
43
- const allowed = new Set(twin.members.filter((i) => (!skipTests || !frags[i].test) && frags[i].n >= tokMin))
67
+ const allowed = new Set(twin.members.filter((i) => fragmentEligible(frags[i], {tokMin, skipTests, includeClassified})))
44
68
  const pairs = (twin.pairs || []).filter((p) => allowed.has(p.a) && allowed.has(p.b))
45
69
  if (!pairs.length) continue
46
70
  const closest = pairs.slice().sort((a, b) => b.similarity - a.similarity)[0]
@@ -72,8 +96,12 @@ export function tFindDuplicates(g, args, ctx) {
72
96
  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.`
73
97
  }
74
98
  const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
75
- const groups = groupClones(data, {simMin, tokMin, mode, skipTests})
76
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.`
99
+ const groups = groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
100
+ const suppressed = data.frags.filter((fragment) => !fragmentEligible(fragment, {tokMin, skipTests, includeClassified})).length
101
+ const suppressionNote = suppressed && !includeClassified
102
+ ? ` ${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
+ : ''
104
+ if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${suppressionNote}`
77
105
  const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
78
106
  const lines = top.map((grp, k) => {
79
107
  const isStr = grp.members.some((f) => f.kind === 'string')
@@ -81,21 +109,300 @@ export function tFindDuplicates(g, args, ctx) {
81
109
  const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
82
110
  return [head, ...sites].join('\n')
83
111
  })
84
- 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.`
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}`
113
+ }
114
+
115
+ // Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
116
+ // methods with bounded source-free evidence, and explicitly demotes framework/dynamic/public API
117
+ // candidates. It never returns an automatic-delete verdict.
118
+ export function tFindDeadCode(g, args, ctx) {
119
+ if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
120
+ const graph = rawGraph(ctx)
121
+ const boundary = createRepoBoundary(ctx.repoRoot)
122
+ const pkg = readRepoJson(boundary, 'package.json') || {}
123
+ const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
124
+ const sources = collectSourceTexts(ctx.repoRoot, graph)
125
+ const dynamicTargets = new Set((graph.externalImports || [])
126
+ .filter((entry) => entry?.dynamic && entry?.target)
127
+ .map((entry) => String(entry.target).replace(/\\/g, '/')))
128
+ const frameworkEvidence = []
129
+ const entries = entryFiles(graph, collectPackageScopes(ctx.repoRoot, pkg), dynamicTargets, {
130
+ declaredEntries: rules.entrypoints || rules.entries || [],
131
+ sources,
132
+ conventionEvidence: frameworkEvidence,
133
+ })
134
+ for (const root of collectNonRuntimeRoots(ctx.repoRoot, rules)) {
135
+ for (const file of sources.keys()) if (file === root || file.startsWith(`${root}/`)) entries.add(file)
136
+ }
137
+
138
+ const review = computeDeadCodeReview(graph, sources, {
139
+ entrySet: entries,
140
+ dynamicTargets,
141
+ frameworkEvidence,
142
+ pathClassifier: createPathClassifier(ctx.repoRoot),
143
+ includeTests: args.include_tests === true,
144
+ includeClassified: args.include_classified === true,
145
+ minConfidence: args.min_confidence,
146
+ path: args.path,
147
+ kinds: args.kinds,
148
+ })
149
+ const max = Math.max(1, Math.min(100, Number(args.top_n) || 30))
150
+ const shown = review.candidates.slice(0, max)
151
+ const counts = review.totals.byConfidence
152
+ const suppression = Object.entries(review.suppressed)
153
+ .filter(([, count]) => count)
154
+ .map(([name, count]) => `${name} ${count}`)
155
+ .join(', ')
156
+ const lines = shown.map((candidate, index) => {
157
+ const subject = candidate.kind === 'file'
158
+ ? candidate.file
159
+ : `${candidate.owner ? `${candidate.owner}.` : ''}${candidate.symbol || candidate.id}`
160
+ const where = `${candidate.file}${candidate.line ? `:${candidate.line}` : ''}`
161
+ return [
162
+ `${index + 1}. [${candidate.confidence}/${candidate.classification}] ${subject} (${where})`,
163
+ ` evidence: ${candidate.evidence.map((item) => item.fact).join(' ')}`,
164
+ candidate.caveats.length ? ` caution: ${candidate.caveats.join(' ')}` : null,
165
+ ].filter(Boolean).join('\n')
166
+ })
167
+ const text = [
168
+ `Dead-code review: ${shown.length} of ${review.candidates.length} candidate(s) shown (high ${counts.high}, medium ${counts.medium}, low ${counts.low}).`,
169
+ `Verdict: REVIEW_REQUIRED. This is static evidence, never permission to auto-delete or bulk-delete.`,
170
+ suppression ? `Suppressed by current filters: ${suppression}.` : null,
171
+ review.suppressed.confidence ? 'Use min_confidence=low only when public/framework/dynamic candidates need explicit review.' : null,
172
+ '',
173
+ ...(lines.length ? lines : ['No candidates matched the current production/path/kind/confidence filters.']),
174
+ '',
175
+ 'Before removal: read_source, get_dependents, exact search, framework/config/manifest inspection, and the repository tests.',
176
+ ].filter((line) => line != null).join('\n')
177
+ return toolResult(text, {
178
+ status: 'COMPLETE',
179
+ verdict: 'REVIEW_REQUIRED',
180
+ candidates: shown,
181
+ totals: review.totals,
182
+ suppressed: review.suppressed,
183
+ repoSignals: review.repoSignals,
184
+ policy: review.policy,
185
+ }, {
186
+ warnings: review.warnings,
187
+ page: {shown: shown.length, total: review.candidates.length, capped: shown.length < review.candidates.length},
188
+ completeness: {status: 'COMPLETE'},
189
+ })
85
190
  }
86
191
 
87
192
  const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
88
193
 
194
+ export function formatAuditFinding(f) {
195
+ const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
196
+ 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}` : ''}`
197
+ }
198
+
199
+ const auditFilter = (audit, args, findings = audit.findings) => {
200
+ const minSev = SEVERITY_RANK[args.min_severity] ?? 4
201
+ const category = args.category ? String(args.category) : null
202
+ return findings
203
+ .filter((finding) => (SEVERITY_RANK[finding.severity] ?? 4) <= minSev)
204
+ .filter((finding) => !category || finding.category === category)
205
+ }
206
+
207
+ const auditChecksLine = (audit) => {
208
+ const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
209
+ return `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.`
210
+ }
211
+
212
+ const auditConventionLines = (audit) => {
213
+ const entries = audit.conventionReachability?.entries || []
214
+ if (!entries.length) return []
215
+ return [
216
+ `Convention reachability: ${audit.conventionReachability.count} framework-managed file(s) are external entry points, not orphan/dead findings${audit.conventionReachability.truncated ? ' (evidence capped)' : ''}:`,
217
+ ...entries.slice(0, 5).map((entry) => ` [${entry.confidence}] ${entry.file} — ${entry.marker}: ${entry.reason}`),
218
+ audit.conventionReachability.count > 5 ? ` … +${audit.conventionReachability.count - 5} more in bounded JSON result data` : null,
219
+ ].filter((line) => line != null)
220
+ }
221
+
222
+ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null) => {
223
+ const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
224
+ const filtered = auditFilter(audit, args, findings)
225
+ const shown = filtered.slice(0, max)
226
+ const summary = summarizeFindings(findings)
227
+ const sev = summary.bySeverity
228
+ const bycat = summary.byCategory
229
+ return [
230
+ heading,
231
+ `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
232
+ ...auditConventionLines(audit),
233
+ `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
+ `Repository-level ${auditChecksLine(audit)}`,
235
+ '',
236
+ `Showing ${shown.length} of ${filtered.length} finding(s)${args.category ? ` in category "${args.category}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
237
+ ...shown.map(formatAuditFinding),
238
+ filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
239
+ ].filter((line) => line != null).join('\n')
240
+ }
241
+
242
+ const gitUntracked = (repoRoot) => {
243
+ const result = spawnSync('git', ['-C', repoRoot, 'ls-files', '--others', '--exclude-standard'], {
244
+ encoding: 'utf8', timeout: 8000, maxBuffer: 2 * 1024 * 1024, env: childProcessEnv(), windowsHide: true,
245
+ })
246
+ if (result.status !== 0) return {
247
+ ok: false,
248
+ files: [],
249
+ error: String(result.stderr || result.error?.message || 'git ls-files failed').trim(),
250
+ }
251
+ return {
252
+ ok: true,
253
+ files: String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
254
+ error: null,
255
+ }
256
+ }
257
+
258
+ const pathsFromClassification = (classification) => [...new Set(classification.files
259
+ .flatMap((file) => [file.oldPath, file.newPath])
260
+ .filter((file) => file && file !== '(diff unavailable)'))]
261
+ .sort((left, right) => left.localeCompare(right))
262
+
263
+ const formatDebtFinding = (finding, state) => ` [${state}] ${formatAuditFinding(finding).trimStart()}`
264
+
265
+ async function runAuditWithBaseline(args, ctx, currentGraph) {
266
+ const resolved = resolveGitCommit(ctx.repoRoot, args.base_ref)
267
+ if (!resolved.ok) return toolResult(`Audit baseline unavailable: ${resolved.error}.`, {
268
+ status: 'INVALID', comparison: {status: 'UNAVAILABLE', reason: resolved.error}, findings: [],
269
+ }, {completeness: {status: 'PARTIAL', reason: resolved.error}})
270
+
271
+ let currentAudit
272
+ try {
273
+ currentAudit = await runInternalAudit(ctx.repoRoot, {
274
+ graph: currentGraph,
275
+ skipMalwareScan: !args.include_malware_scan,
276
+ })
277
+ } catch (error) {
278
+ const reason = error instanceof Error ? error.message : String(error)
279
+ return toolResult(`Audit failed while building the current graph: ${reason}`, {
280
+ status: 'ERROR', comparison: {status: 'UNAVAILABLE', reason}, findings: [],
281
+ }, {completeness: {status: 'PARTIAL', reason}})
282
+ }
283
+ if (!currentAudit.ok) return toolResult(`Audit failed: ${currentAudit.error}`, {
284
+ status: 'ERROR', comparison: {status: 'UNAVAILABLE', reason: currentAudit.error}, findings: [],
285
+ }, {completeness: {status: 'PARTIAL', reason: currentAudit.error}})
286
+
287
+ const normalized = normalizeAuditScopeFiles(args.changed_files)
288
+ if (!normalized.ok) return toolResult(`Audit scope invalid: ${normalized.error}.`, {
289
+ status: 'INVALID', comparison: {status: 'UNAVAILABLE', reason: normalized.error}, findings: [],
290
+ }, {completeness: {status: 'PARTIAL', reason: normalized.error}})
291
+ let changedFiles = normalized.files
292
+ let completeChangeSet = false
293
+ let scopeSource = 'explicit changed_files'
294
+ let changeEvidence = null
295
+ if (changedFiles == null) {
296
+ completeChangeSet = true
297
+ const untracked = gitUntracked(ctx.repoRoot)
298
+ if (!untracked.ok) {
299
+ return toolResult(`Audit comparison stopped: untracked-file discovery failed (${untracked.error}). Pass changed_files explicitly to establish the scope.`, {
300
+ status: 'PARTIAL', comparison: {status: 'UNAVAILABLE', reason: untracked.error}, findings: [],
301
+ }, {completeness: {status: 'PARTIAL', reason: untracked.error}})
302
+ }
303
+ changeEvidence = classifyChangeImpact({
304
+ repoRoot: ctx.repoRoot,
305
+ graph: currentGraph,
306
+ base: resolved.commit,
307
+ files: untracked.files,
308
+ })
309
+ if (!changeEvidence.ok || changeEvidence.bounds?.truncated) {
310
+ const reason = changeEvidence.reasons.join(' ')
311
+ return toolResult(`Audit comparison stopped: changed-file derivation against ${resolved.ref} was incomplete. ${reason} Pass changed_files explicitly to establish a complete scope.`, {
312
+ status: 'PARTIAL', comparison: {status: 'UNAVAILABLE', reason}, findings: [], changeEvidence,
313
+ }, {completeness: {status: 'PARTIAL', reason}})
314
+ }
315
+ changedFiles = pathsFromClassification(changeEvidence)
316
+ if (changedFiles.length > 500) {
317
+ const reason = `derived changed-file scope contains ${changedFiles.length} files, above the 500-file comparison bound`
318
+ return toolResult(`Audit comparison stopped: ${reason}. Pass a narrower explicit changed_files scope.`, {
319
+ status: 'PARTIAL', comparison: {status: 'UNAVAILABLE', reason}, findings: [], changeEvidence,
320
+ }, {completeness: {status: 'PARTIAL', reason}})
321
+ }
322
+ scopeSource = `git diff ${resolved.commit.slice(0, 12)}…working-tree`
323
+ }
324
+
325
+ const baseline = await withGitRefCheckout(ctx.repoRoot, resolved.commit, async (checkout) => {
326
+ const graph = await buildInternalGraph(checkout)
327
+ return runInternalAudit(checkout, {graph, skipMalwareScan: true})
328
+ })
329
+ if (!baseline.ok || !baseline.value?.ok) {
330
+ const reason = baseline.error || baseline.value?.error || 'baseline audit failed'
331
+ return toolResult(`Audit baseline unavailable: ${reason}.`, {
332
+ status: 'ERROR', comparison: {status: 'UNAVAILABLE', reason}, findings: [],
333
+ }, {completeness: {status: 'PARTIAL', reason}})
334
+ }
335
+
336
+ const comparison = compareAuditDebt(currentAudit, baseline.value, changedFiles, {completeChangeSet})
337
+ const mode = ['new', 'existing', 'all'].includes(args.debt) ? args.debt : 'new'
338
+ const selectedRaw = mode === 'new' ? comparison.new : mode === 'existing' ? comparison.existing : comparison.all
339
+ const selected = auditFilter(currentAudit, args, selectedRaw)
340
+ const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
341
+ const shown = selected.slice(0, max)
342
+ const optional = comparison.optional.checks.map((check) => `${check.name.toUpperCase()} UNCOMPARABLE (current ${check.current}; baseline ${check.baseline})`).join('; ')
343
+ const stateOf = (finding) => mode === 'all' ? finding.debtState : mode
344
+ const fixedShown = auditFilter(baseline.value, args, comparison.fixed).slice(0, Math.min(10, max))
345
+ const text = [
346
+ `${mode.toUpperCase()} DEBT — deterministic internal audit vs ${resolved.ref} (${resolved.commit.slice(0, 12)})`,
347
+ `Changed scope: ${changedFiles.length} file(s) from ${scopeSource}${changedFiles.length ? ` — ${changedFiles.slice(0, 12).join(', ')}${changedFiles.length > 12 ? ', …' : ''}` : ' — working tree matches the baseline'}.`,
348
+ `Scope comparison: ${comparison.totals.scope.new} new, ${comparison.totals.scope.existing} existing, ${comparison.totals.scope.fixed} fixed deterministic finding(s). Repository totals: ${comparison.totals.repository.new} new, ${comparison.totals.repository.existing} existing, ${comparison.totals.repository.fixed} fixed.`,
349
+ `Optional checks: ${optional}. Supply-chain findings are not assigned new/existing/fixed state from a source-only checkout.`,
350
+ '',
351
+ `Showing ${shown.length} of ${selected.length} ${mode} finding(s) after filters:`,
352
+ ...shown.map((finding) => formatDebtFinding(finding, stateOf(finding))),
353
+ selected.length > shown.length ? ` … +${selected.length - shown.length} more` : null,
354
+ '',
355
+ `Fixed in this changed scope: ${comparison.fixed.length}${fixedShown.length ? ' (sample below)' : ''}.`,
356
+ ...fixedShown.map((finding) => formatDebtFinding(finding, 'fixed')),
357
+ ].filter((line) => line != null).join('\n')
358
+ return toolResult(text, {
359
+ status: 'COMPLETE',
360
+ mode: 'baseline-comparison',
361
+ debt: mode,
362
+ baseline: {ref: resolved.ref, commit: resolved.commit},
363
+ scope: {files: changedFiles, source: scopeSource},
364
+ comparison: {
365
+ status: 'COMPLETE',
366
+ scope: comparison.scope,
367
+ totals: comparison.totals,
368
+ new: comparison.new,
369
+ existing: comparison.existing,
370
+ fixed: comparison.fixed,
371
+ optional: comparison.optional,
372
+ },
373
+ findings: selected,
374
+ changeEvidence,
375
+ }, {
376
+ page: {shown: shown.length, total: selected.length, capped: shown.length < selected.length},
377
+ completeness: {status: 'COMPLETE'},
378
+ })
379
+ }
380
+
89
381
  // Full internal health audit: dead code + unused exports, dependency findings (npm/go/py missing &
90
382
  // unused deps), structure (import cycles / orphans / boundary rules), supply-chain (offline OSV
91
383
  // advisories, typosquat, lockfile drift), optional malware heuristics.
92
384
  export async function tRunAudit(g, args, ctx) {
93
385
  if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
386
+ if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
94
387
  const audit = await runInternalAudit(ctx.repoRoot, {
95
388
  graph: rawGraph(ctx),
96
389
  skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
97
390
  })
98
391
  if (!audit.ok) return `Audit failed: ${audit.error}`
392
+ if (Array.isArray(args.changed_files)) {
393
+ const normalized = normalizeAuditScopeFiles(args.changed_files)
394
+ if (!normalized.ok) return `Changed-scope audit invalid: ${normalized.error}.`
395
+ const scoped = scopeAuditFindings(audit.findings, normalized.files)
396
+ const text = formatOrdinaryAudit(audit, args, scoped,
397
+ `CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed.`)
398
+ return toolResult(text, {
399
+ status: 'COMPLETE',
400
+ mode: 'changed-scope',
401
+ scope: {files: normalized.files, source: 'explicit changed_files'},
402
+ comparison: {status: 'UNAVAILABLE', reason: 'base_ref was not provided; changed-scope is not a new-debt claim'},
403
+ findings: auditFilter(audit, args, scoped),
404
+ }, {completeness: {status: 'COMPLETE'}})
405
+ }
99
406
  const minSev = SEVERITY_RANK[args.min_severity] ?? 4
100
407
  const cat = args.category ? String(args.category) : null
101
408
  const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
@@ -105,19 +412,16 @@ export async function tRunAudit(g, args, ctx) {
105
412
  const shown = filtered.slice(0, max)
106
413
  const sev = audit.summary.bySeverity
107
414
  const bycat = audit.summary.byCategory
108
- const line = (f) => {
109
- const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
110
- return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
111
- }
112
415
  const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
113
416
  return [
114
417
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
115
418
  `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}.`,
116
- `Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.typeCouplings ?? 0} type-induced 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. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
419
+ `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).`,
420
+ ...auditConventionLines(audit),
117
421
  `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.`,
118
422
  ``,
119
423
  `Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
120
- ...shown.map(line),
424
+ ...shown.map(formatAuditFinding),
121
425
  filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
122
426
  ].filter((x) => x != null).join('\n')
123
427
  }
@@ -140,16 +444,28 @@ export function tModuleMap(g, args, ctx) {
140
444
  const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
141
445
  const mods = agg.modules.slice(0, topN)
142
446
  const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
143
- const typeEdges = (agg.typeOnlyModuleEdges || []).slice(0, Math.min(50, topN * 2))
447
+ const compileEdges = new Map()
448
+ const collectCompileEdges = (list, kind) => {
449
+ for (const edge of list || []) {
450
+ const key = `${edge.from}\0${edge.to}`
451
+ const current = compileEdges.get(key) || {from: edge.from, to: edge.to, count: 0, typeOnly: 0, compileOnly: 0}
452
+ current.count += edge.count
453
+ current[kind] += edge.count
454
+ compileEdges.set(key, current)
455
+ }
456
+ }
457
+ collectCompileEdges(agg.typeOnlyModuleEdges, 'typeOnly')
458
+ collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
459
+ const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
144
460
  return [
145
- `Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.typeOnlyModuleEdges || 0} type-only dependencies. Top ${mods.length}:`,
461
+ `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}:`,
146
462
  ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
147
463
  ``,
148
464
  `Strongest runtime module dependencies:`,
149
465
  ...edges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
150
- typeEdges.length ? `` : null,
151
- typeEdges.length ? `Type-only module dependencies (compile-time contracts, not runtime coupling):` : null,
152
- ...typeEdges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
466
+ compiled.length ? `` : null,
467
+ compiled.length ? `Compile-time module dependencies (not runtime coupling):` : null,
468
+ ...compiled.map((e) => ` ${e.from} → ${e.to} (${e.count}; ${e.typeOnly} type-only, ${e.compileOnly} compile-only)`),
153
469
  ].filter((line) => line != null).join('\n')
154
470
  }
155
471
 
@@ -165,14 +481,32 @@ export function tCoverageMap(g, args, ctx) {
165
481
  const allFiles = agg.modules.flatMap((m) => m.files.filter((f) => inScope(f.path)))
166
482
  const measured = allFiles.filter((f) => f.coverage != null)
167
483
  if (!measured.length) {
484
+ const fallback = computeStaticTestReachability(rawGraph(ctx), {repoRoot: ctx.repoRoot, path: pathFilter || ''})
485
+ const topN = Math.max(1, Math.min(50, Number(args.top_n) || 15))
486
+ const reachable = fallback.reachable.slice(0, topN)
487
+ const unreachable = fallback.unreachable.slice(0, topN)
168
488
  return [
169
- `No coverage report found${pathFilter ? ` for ${pathFilter}` : ''} this tool reads existing reports, it does not run tests.`,
489
+ `Static test reachability${pathFilter ? ` for ${pathFilter}` : ''}: ${fallback.reachableFiles}/${fallback.productFiles} product file(s) have a runtime graph path from ${fallback.testFiles} indexed test file(s).`,
490
+ `actualCoverage: ${fallback.actualCoverage}. This is NOT coverage: imports/calls only show that a test can statically reach a file, never that a line, branch or symbol executed.`,
491
+ fallback.bounds.truncated ? `Traversal was bounded/truncated (${fallback.bounds.traversedStates}/${fallback.bounds.maxStates} states, depth ≤${fallback.bounds.maxDepth}, ${fallback.testFiles}/${fallback.totalTestFiles} test files).` : `Traversal: ${fallback.bounds.traversedStates} bounded state(s), depth ≤${fallback.bounds.maxDepth}.`,
492
+ '',
493
+ 'Nearest runtime paths from tests:',
494
+ ...(reachable.length ? reachable.map((entry) => {
495
+ const nearest = entry.nearestTests[0]
496
+ return ` ${nearest.confidence.padStart(6)} d${nearest.distance} ${entry.file} ← ${nearest.test}\n path: ${nearest.path.join(' → ')}`
497
+ }) : [' (none)']),
498
+ '',
499
+ `No runtime path from an indexed test (${fallback.unreachableFiles}; not proof of no tests):`,
500
+ ...(unreachable.length ? unreachable.map((file) => ` ${file}`) : [' (none)']),
501
+ fallback.unreachableFiles > unreachable.length ? ` … +${fallback.unreachableFiles - unreachable.length} more (raise top_n or narrow path)` : null,
502
+ '',
503
+ 'No coverage report found — generate one for measured coverage:',
170
504
  'Generate one with the repo\'s own test runner, then call coverage_map again:',
171
505
  ' JS/TS: npx vitest run --coverage (or jest --coverage)',
172
506
  ' Python: pytest --cov --cov-report=json',
173
507
  ' Go: go test ./... -coverprofile=coverage.out',
174
508
  'Read locations: coverage/coverage-summary.json, coverage/coverage-final.json, (coverage/)lcov.info, coverage.json, coverage.out.',
175
- ].join('\n')
509
+ ].filter((line) => line != null).join('\n')
176
510
  }
177
511
  const pctStr = (v) => (v == null ? 'n/a' : `${Math.round(v * 100)}%`)
178
512
  const sources = [...new Set(measured.map((f) => f.coverageSource).filter(Boolean))]
@@ -213,7 +547,7 @@ export function tCoverageMap(g, args, ctx) {
213
547
  ].join('\n')
214
548
  }
215
549
 
216
- // HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go-mux style route definitions.
550
+ // HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go/Rust/Spring route definitions.
217
551
  export function tListEndpoints(g, args, ctx) {
218
552
  if (!ctx.repoRoot) return 'Endpoint detection needs the repo root (not provided to this server).'
219
553
  const graph = rawGraph(ctx)
@@ -0,0 +1,22 @@
1
+ // Behavioral architecture evidence from bounded, local git history.
2
+ import {analyzeGitHistory, formatGitHistoryAnalytics} from '../analysis/git-history.js'
3
+ import {toolResult} from './tool-result.mjs'
4
+
5
+ export async function tGitHistory(g, args, ctx) {
6
+ if (!ctx?.repoRoot) return toolResult('Git history intelligence is unavailable: no repository root is active.', {status: 'unavailable'})
7
+ const result = await analyzeGitHistory({
8
+ repoRoot: ctx.repoRoot,
9
+ graph: g,
10
+ months: args.months,
11
+ maxCommits: args.max_commits,
12
+ maxPairs: args.max_pairs,
13
+ minPairCount: args.min_pair_count,
14
+ })
15
+ return toolResult(formatGitHistoryAnalytics(result, {topN: args.top_n}), result, {
16
+ completeness: {
17
+ status: result.status,
18
+ complete: result.completeness?.complete === true,
19
+ reasons: result.completeness?.reasons || [],
20
+ },
21
+ })
22
+ }