weavatrix 0.2.13 → 0.2.14

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 (130) hide show
  1. package/README.md +83 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.14.md +93 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +10 -57
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  20. package/src/analysis/endpoints/common.js +62 -0
  21. package/src/analysis/endpoints/extract.js +107 -0
  22. package/src/analysis/endpoints/inventory.js +84 -0
  23. package/src/analysis/endpoints/mounts.js +129 -0
  24. package/src/analysis/endpoints-java.js +80 -3
  25. package/src/analysis/endpoints.js +3 -448
  26. package/src/analysis/git-history/analytics.js +177 -0
  27. package/src/analysis/git-history/collector.js +109 -0
  28. package/src/analysis/git-history/options.js +68 -0
  29. package/src/analysis/git-history.js +128 -577
  30. package/src/analysis/health-capabilities.js +82 -0
  31. package/src/analysis/http-contracts/analysis.js +169 -0
  32. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  33. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  34. package/src/analysis/http-contracts/graph-context.js +143 -0
  35. package/src/analysis/http-contracts/matching.js +61 -0
  36. package/src/analysis/http-contracts/shared.js +86 -0
  37. package/src/analysis/http-contracts.js +6 -822
  38. package/src/analysis/internal-audit/dependency-health.js +111 -0
  39. package/src/analysis/internal-audit/python-manifests.js +65 -0
  40. package/src/analysis/internal-audit/repo-files.js +55 -0
  41. package/src/analysis/internal-audit/supply-chain.js +132 -0
  42. package/src/analysis/internal-audit.collect.js +5 -106
  43. package/src/analysis/internal-audit.run.js +113 -200
  44. package/src/analysis/jvm-dependency-evidence.js +69 -0
  45. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  46. package/src/analysis/source-correctness.js +225 -0
  47. package/src/analysis/structure/dependency-graph.js +156 -0
  48. package/src/analysis/structure/findings.js +142 -0
  49. package/src/graph/builder/go-receiver-resolution.js +112 -0
  50. package/src/graph/builder/js/queries.js +48 -0
  51. package/src/graph/builder/lang-go.js +89 -4
  52. package/src/graph/builder/lang-js.js +4 -44
  53. package/src/graph/builder/pass2-resolution.js +68 -0
  54. package/src/graph/freshness-probe.js +2 -1
  55. package/src/graph/incremental-refresh.js +3 -2
  56. package/src/graph/internal-builder.build.js +22 -183
  57. package/src/graph/internal-builder.pass2.js +161 -0
  58. package/src/graph/internal-builder.resolvers.js +2 -105
  59. package/src/graph/resolvers/rust.js +117 -0
  60. package/src/mcp/actions/advisories.mjs +18 -0
  61. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  62. package/src/mcp/actions/graph-sync.mjs +195 -0
  63. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  64. package/src/mcp/architecture-bootstrap.mjs +168 -0
  65. package/src/mcp/architecture-starter.mjs +234 -0
  66. package/src/mcp/catalog.mjs +20 -6
  67. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  68. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  69. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  70. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  71. package/src/mcp/graph/context-core.mjs +111 -0
  72. package/src/mcp/graph/context-seeds.mjs +208 -0
  73. package/src/mcp/graph/context-state.mjs +86 -0
  74. package/src/mcp/graph/tools-core.mjs +143 -0
  75. package/src/mcp/graph/tools-query.mjs +223 -0
  76. package/src/mcp/graph-context.mjs +4 -496
  77. package/src/mcp/health/audit-format.mjs +172 -0
  78. package/src/mcp/health/audit.mjs +171 -0
  79. package/src/mcp/health/dead-code.mjs +110 -0
  80. package/src/mcp/health/duplicates.mjs +83 -0
  81. package/src/mcp/health/endpoints.mjs +42 -0
  82. package/src/mcp/health/structure.mjs +219 -0
  83. package/src/mcp/runtime-version.mjs +32 -0
  84. package/src/mcp/server/auto-refresh.mjs +66 -0
  85. package/src/mcp/server/runtime-config.mjs +43 -0
  86. package/src/mcp/server/shutdown.mjs +40 -0
  87. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  88. package/src/mcp/sync/evidence-common.mjs +88 -0
  89. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  90. package/src/mcp/sync/evidence-health.mjs +56 -0
  91. package/src/mcp/sync/evidence-packages.mjs +95 -0
  92. package/src/mcp/sync/payload-common.mjs +97 -0
  93. package/src/mcp/sync/payload-v2.mjs +53 -0
  94. package/src/mcp/sync/payload-v3.mjs +79 -0
  95. package/src/mcp/sync-evidence.mjs +7 -402
  96. package/src/mcp/sync-payload.mjs +10 -244
  97. package/src/mcp/tools-actions.mjs +18 -414
  98. package/src/mcp/tools-architecture.mjs +18 -70
  99. package/src/mcp/tools-context.mjs +56 -15
  100. package/src/mcp/tools-endpoints.mjs +4 -1
  101. package/src/mcp/tools-graph.mjs +17 -331
  102. package/src/mcp/tools-health.mjs +24 -705
  103. package/src/mcp-server.mjs +35 -146
  104. package/src/precision/lsp-client/constants.js +12 -0
  105. package/src/precision/lsp-client/environment.js +20 -0
  106. package/src/precision/lsp-client/errors.js +15 -0
  107. package/src/precision/lsp-client/lifecycle.js +127 -0
  108. package/src/precision/lsp-client/message-parser.js +81 -0
  109. package/src/precision/lsp-client/protocol.js +212 -0
  110. package/src/precision/lsp-client/registry.js +58 -0
  111. package/src/precision/lsp-client/repo-uri.js +60 -0
  112. package/src/precision/lsp-client/stdio-client.js +151 -0
  113. package/src/precision/lsp-client.js +10 -682
  114. package/src/precision/lsp-overlay/build.js +238 -0
  115. package/src/precision/lsp-overlay/contract.js +61 -0
  116. package/src/precision/lsp-overlay/reference-results.js +108 -0
  117. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  118. package/src/precision/lsp-overlay/source-session.js +134 -0
  119. package/src/precision/lsp-overlay/store.js +150 -0
  120. package/src/precision/lsp-overlay/target-index.js +147 -0
  121. package/src/precision/lsp-overlay/target-query.js +55 -0
  122. package/src/precision/lsp-overlay.js +11 -868
  123. package/src/precision/typescript-lsp-provider.js +12 -682
  124. package/src/precision/typescript-provider/client.js +69 -0
  125. package/src/precision/typescript-provider/discovery.js +124 -0
  126. package/src/precision/typescript-provider/project-host.js +249 -0
  127. package/src/precision/typescript-provider/project-safety.js +161 -0
  128. package/src/precision/typescript-provider/reference-usage.js +53 -0
  129. package/src/version.js +7 -0
  130. package/docs/releases/v0.2.13.md +0 -48
@@ -1,705 +1,24 @@
1
- // Health tools: clone detection, the internal audit, community/module overviews, coverage mapping
2
- // and the HTTP endpoint inventory. Hot-reloadable (re-imported by catalog.mjs on change).
3
- import {spawnSync} from 'node:child_process'
4
- import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
5
- import {computeDuplicates} from '../analysis/duplicates.js'
6
- import {analyzeDuplicateGroups} from '../analysis/duplicate-groups.js'
7
- import {runInternalAudit} from '../analysis/internal-audit.js'
8
- import {classifyChangeImpact} from '../analysis/change-classification.js'
9
- import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
10
- import {summarizeFindings} from '../analysis/findings.js'
11
- import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.js'
12
- import {analyzeEndpointInventory} from '../analysis/endpoints.js'
13
- import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
14
- import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
15
- import {computeHotPathReview} from '../analysis/hot-path-review.js'
16
- import {collectNonRuntimeRoots, collectPackageScopes, collectSourceTexts, readRepoJson} from '../analysis/internal-audit.collect.js'
17
- import {entryFiles} from '../analysis/internal-audit.reach.js'
18
- import {buildInternalGraph} from '../graph/internal-builder.js'
19
- import {resolveGitCommit, withGitRefCheckout} from '../analysis/git-ref-graph.js'
20
- import {childProcessEnv} from '../child-env.js'
21
- import {toolResult} from './tool-result.mjs'
22
- import {createPathClassifier, hasPathClass} from '../path-classification.js'
23
- import {createRepoBoundary} from '../repo-path.js'
24
- import {filterGraphForMode} from '../graph/graph-filter.js'
25
- import {readCachedSymbolPrecisionEvidence} from '../precision/symbol-query.js'
26
-
27
- const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
28
- const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
29
- if (fragment.n < tokMin) return false
30
- const classes = new Set(fragment.classes || [])
31
- if (skipTests && (fragment.test || classes.has('test') || classes.has('e2e'))) return false
32
- if (!includeClassified && (fragment.excluded || [...classes].some((name) => NON_PRODUCT_DUPLICATE_CLASSES.has(name)))) return false
33
- return true
34
- }
35
-
36
- export function tFindDuplicates(g, args, ctx) {
37
- if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
38
- const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
39
- const tokMin = Math.min(400, Math.max(12, Number(args.min_tokens) || 50))
40
- const mode = args.mode === 'strict' ? 'strict' : 'renamed'
41
- const skipTests = args.include_tests ? false : true
42
- const includeClassified = args.include_classified === true || args.include_non_product === true
43
- const includeStrings = !!args.include_strings
44
- // semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
45
- // (same name, drifted behavior). Token-clone pairing is skipped entirely.
46
- if (args.mode === 'semantic') {
47
- const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true, minTokens: tokMin})
48
- const frags = data.frags
49
- const candidates = []
50
- for (const twin of data.nameTwins || []) {
51
- const allowed = new Set(twin.members.filter((i) => fragmentEligible(frags[i], {tokMin, skipTests, includeClassified})))
52
- const pairs = (twin.pairs || []).filter((p) => allowed.has(p.a) && allowed.has(p.b))
53
- if (!pairs.length) continue
54
- const closest = pairs.slice().sort((a, b) => b.similarity - a.similarity)[0]
55
- const farthest = pairs.slice().sort((a, b) => a.similarity - b.similarity)[0]
56
- if (closest.similarity >= 85) candidates.push({kind: 'clone', label: twin.label, pair: closest})
57
- if (farthest.similarity <= 45) candidates.push({kind: 'collision', label: twin.label, pair: farthest})
58
- }
59
- for (const item of candidates) item.tokens = frags[item.pair.a].n + frags[item.pair.b].n
60
- candidates.sort((a, b) => {
61
- if (a.kind !== b.kind) return a.kind === 'clone' ? -1 : 1
62
- return a.kind === 'clone'
63
- ? b.pair.similarity - a.pair.similarity || b.tokens - a.tokens
64
- : b.tokens - a.tokens || a.pair.similarity - b.pair.similarity
65
- })
66
- if (!candidates.length) return 'No actionable same-name pairs across files (semantic mode; ambiguous middle-similarity pairs are suppressed).'
67
- const top = candidates.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
68
- const lines = top.map((item, k) => {
69
- const a = frags[item.pair.a]
70
- const b = frags[item.pair.b]
71
- const verdict = item.kind === 'clone'
72
- ? 'near-identical duplicate candidate — review, then extract shared logic if the contract is truly shared'
73
- : 'name collision, not a duplicate — inspect only if these definitions should share a contract'
74
- return [
75
- `${k + 1}. "${item.label}" — ${item.pair.similarity}% similar; ${verdict}`,
76
- ` ${a.file}:${a.start}-${a.end} (${a.n} tok)`,
77
- ` ${b.file}:${b.start}-${b.end} (${b.n} tok)`,
78
- ].join('\n')
79
- })
80
- 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.`
81
- }
82
- const analysis = analyzeDuplicateGroups(ctx.repoRoot, ctx.graphPath, args)
83
- const groups = analysis.groups
84
- const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
85
- const suppressed = analysis.suppressed
86
- const suppressionNote = suppressed && !includeClassified
87
- ? ` ${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.`
88
- : ''
89
- const boilerplateNote = analysis.boilerplateSuppressed
90
- ? ` ${analysis.boilerplateSuppressed} all-router framework boilerplate group(s) were suppressed; pass include_boilerplate:true to inspect them.`
91
- : ''
92
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}`
93
- const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
94
- const lines = top.map((grp, k) => {
95
- const isStr = grp.members.some((f) => f.kind === 'string')
96
- const head = `${k + 1}. ${grp.members.length}× "${grp.members[0].label}"${isStr ? ' [string literal]' : ''} — ≤${grp.maxSim}% similar, ${grp.tokens} duplicated tokens`
97
- const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
98
- return [head, ...sites].join('\n')
99
- })
100
- 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}${boilerplateNote}`
101
- }
102
-
103
- // Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
104
- // methods with bounded source-free evidence, and explicitly demotes framework/dynamic/public API
105
- // candidates. It never returns an automatic-delete verdict.
106
- export function tFindDeadCode(g, args, ctx) {
107
- if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
108
- const effectiveGraph = effectiveRawGraph(ctx)
109
- const pointEvidence = readCachedSymbolPrecisionEvidence({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, graph: effectiveGraph})
110
- const graph = {
111
- ...effectiveGraph,
112
- precisionReferenceSymbols: [...new Set([
113
- ...(effectiveGraph.precisionReferenceSymbols || []),
114
- ...pointEvidence.referenceSymbols,
115
- ])],
116
- precisionProductionReferenceSymbols: [...new Set([
117
- ...(effectiveGraph.precisionProductionReferenceSymbols || []),
118
- ...pointEvidence.productionReferenceSymbols,
119
- ])],
120
- precisionTestReferenceSymbols: [...new Set([
121
- ...(effectiveGraph.precisionTestReferenceSymbols || []),
122
- ...pointEvidence.testReferenceSymbols,
123
- ])],
124
- precisionNoReferenceSymbols: [...new Set([
125
- ...(effectiveGraph.precisionNoReferenceSymbols || []),
126
- ...pointEvidence.noReferenceSymbols,
127
- ])],
128
- }
129
- const boundary = createRepoBoundary(ctx.repoRoot)
130
- const pkg = readRepoJson(boundary, 'package.json') || {}
131
- const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
132
- const sources = collectSourceTexts(ctx.repoRoot, graph)
133
- const dynamicTargets = new Set((graph.externalImports || [])
134
- .filter((entry) => entry?.dynamic && entry?.target)
135
- .map((entry) => String(entry.target).replace(/\\/g, '/')))
136
- const frameworkEvidence = []
137
- const entries = entryFiles(graph, collectPackageScopes(ctx.repoRoot, pkg), dynamicTargets, {
138
- declaredEntries: rules.entrypoints || rules.entries || [],
139
- sources,
140
- conventionEvidence: frameworkEvidence,
141
- })
142
- for (const root of collectNonRuntimeRoots(ctx.repoRoot, rules)) {
143
- for (const file of sources.keys()) if (file === root || file.startsWith(`${root}/`)) entries.add(file)
144
- }
145
-
146
- const review = computeDeadCodeReview(graph, sources, {
147
- entrySet: entries,
148
- dynamicTargets,
149
- frameworkEvidence,
150
- pathClassifier: createPathClassifier(ctx.repoRoot),
151
- includeTests: args.include_tests === true,
152
- includeClassified: args.include_classified === true,
153
- minConfidence: args.min_confidence,
154
- path: args.path,
155
- kinds: args.kinds,
156
- })
157
- const max = Math.max(1, Math.min(100, Number(args.top_n) || 30))
158
- const shown = review.candidates.slice(0, max)
159
- const counts = review.totals.byConfidence
160
- const suppression = Object.entries(review.suppressed)
161
- .filter(([, count]) => count)
162
- .map(([name, count]) => `${name} ${count}`)
163
- .join(', ')
164
- const lines = shown.map((candidate, index) => {
165
- const subject = candidate.kind === 'file'
166
- ? candidate.file
167
- : `${candidate.owner ? `${candidate.owner}.` : ''}${candidate.symbol || candidate.id}`
168
- const where = `${candidate.file}${candidate.line ? `:${candidate.line}` : ''}`
169
- return [
170
- `${index + 1}. [${candidate.confidence}/${candidate.evidenceTier}/${candidate.classification}] ${subject} (${where})`,
171
- ` evidence: ${candidate.evidence.map((item) => item.fact).join(' ')}`,
172
- candidate.caveats.length ? ` caution: ${candidate.caveats.join(' ')}` : null,
173
- ` remaining: ${candidate.remainingChecks.join(' ')}`,
174
- ].filter(Boolean).join('\n')
175
- })
176
- const text = [
177
- `Dead-code review: ${shown.length} of ${review.candidates.length} candidate(s) shown (high ${counts.high}, medium ${counts.medium}, low ${counts.low}).`,
178
- `Evidence tiers: strong static ${review.totals.byEvidenceTier.strongStatic}, bounded static ${review.totals.byEvidenceTier.boundedStatic}, high uncertainty ${review.totals.byEvidenceTier.highUncertainty}.`,
179
- `Verdict: REVIEW_REQUIRED. This is static evidence, never permission to auto-delete or bulk-delete.`,
180
- suppression ? `Suppressed by current filters: ${suppression}.` : null,
181
- review.suppressed.confidence ? 'Use min_confidence=low only when public/framework/dynamic candidates need explicit review.' : null,
182
- '',
183
- ...(lines.length ? lines : ['No candidates matched the current production/path/kind/confidence filters.']),
184
- '',
185
- 'Before removal: read_source, get_dependents, exact search, framework/config/manifest inspection, and the repository tests.',
186
- ].filter((line) => line != null).join('\n')
187
- return toolResult(text, {
188
- status: 'COMPLETE',
189
- verdict: 'REVIEW_REQUIRED',
190
- candidates: shown,
191
- totals: review.totals,
192
- suppressed: review.suppressed,
193
- repoSignals: review.repoSignals,
194
- policy: review.policy,
195
- }, {
196
- warnings: review.warnings,
197
- page: {shown: shown.length, total: review.candidates.length, capped: shown.length < review.candidates.length},
198
- completeness: {status: 'COMPLETE'},
199
- })
200
- }
201
-
202
- const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
203
- const AUDIT_NON_PRODUCT_CLASSES = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
204
-
205
- export function formatAuditFinding(f) {
206
- const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
207
- const verification = f.verification?.evidenceModel
208
- ? `\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'}`
209
- : ''
210
- 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}` : ''}`
211
- }
212
-
213
- const auditFindingPaths = (finding) => {
214
- const paths = []
215
- if (finding?.file) paths.push(String(finding.file))
216
- if (Array.isArray(finding?.files)) paths.push(...finding.files.map(String))
217
- if (finding?.cycleRoute) paths.push(...String(finding.cycleRoute).split(/\s*(?:→|→|->)\s*/))
218
- return [...new Set(paths.map((file) => file.replace(/\\/g, '/').trim()).filter(Boolean))]
219
- }
220
-
221
- export function auditFindingPathScope(findings, {includeClassified = false, repoRoot = null} = {}) {
222
- const all = Array.isArray(findings) ? findings : []
223
- if (includeClassified) return {findings: all, suppressed: 0}
224
- const classifier = createPathClassifier(repoRoot)
225
- const cache = new Map()
226
- const classified = (file) => {
227
- if (!cache.has(file)) cache.set(file, classifier.explain(file, {content: ''}))
228
- const info = cache.get(file)
229
- return info?.excluded || AUDIT_NON_PRODUCT_CLASSES.some((name) => hasPathClass(info, name))
230
- }
231
- const kept = all.filter((finding) => {
232
- const paths = auditFindingPaths(finding)
233
- return !paths.length || paths.some((file) => !classified(file))
234
- })
235
- return {findings: kept, suppressed: all.length - kept.length}
236
- }
237
-
238
- const DEPENDENCY_AUDIT_RULES = new Set(['unused-dep', 'missing-dep', 'duplicate-dep', 'unresolved-import', 'lockfile-drift'])
239
- export const isDependencyAuditFinding = (finding) => DEPENDENCY_AUDIT_RULES.has(String(finding?.rule || ''))
240
-
241
- const auditFilter = (audit, args, findings = audit.findings, repoRoot = null) => {
242
- const minSev = SEVERITY_RANK[args.min_severity] ?? 4
243
- const category = args.category ? String(args.category) : null
244
- return auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot}).findings
245
- .filter((finding) => (SEVERITY_RANK[finding.severity] ?? 4) <= minSev)
246
- .filter((finding) => !category || (category === 'dependencies' ? isDependencyAuditFinding(finding) : finding.category === category))
247
- }
248
-
249
- const auditChecksLine = (audit) => {
250
- const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
251
- 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.`
252
- }
253
-
254
- const auditConventionLines = (audit) => {
255
- const entries = audit.conventionReachability?.entries || []
256
- if (!entries.length) return []
257
- return [
258
- `Convention reachability: ${audit.conventionReachability.count} framework-managed file(s) are external entry points, not orphan/dead findings${audit.conventionReachability.truncated ? ' (evidence capped)' : ''}:`,
259
- ...entries.slice(0, 5).map((entry) => ` [${entry.confidence}] ${entry.file} — ${entry.marker}: ${entry.reason}`),
260
- audit.conventionReachability.count > 5 ? ` … +${audit.conventionReachability.count - 5} more in bounded JSON result data` : null,
261
- ].filter((line) => line != null)
262
- }
263
-
264
- const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null, repoRoot = null) => {
265
- const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
266
- const pathScope = auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot})
267
- const filtered = auditFilter(audit, args, pathScope.findings, repoRoot)
268
- const shown = filtered.slice(0, max)
269
- const summary = summarizeFindings(pathScope.findings)
270
- const sev = summary.bySeverity
271
- const bycat = summary.byCategory
272
- const deps = audit.dependencyReport || {}
273
- return [
274
- heading,
275
- `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
276
- ...auditConventionLines(audit),
277
- `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'}.`,
278
- `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}.`,
279
- pathScope.suppressed ? `Path policy: production-first; suppressed ${pathScope.suppressed} finding(s) whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded. Pass include_classified:true to include them.` : 'Path policy: production-first; no classified-only findings were suppressed.',
280
- `Repository-level ${auditChecksLine(audit)}`,
281
- '',
282
- `Showing ${shown.length} of ${filtered.length} finding(s)${args.category ? ` in category "${args.category}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
283
- ...shown.map(formatAuditFinding),
284
- filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
285
- ].filter((line) => line != null).join('\n')
286
- }
287
-
288
- const gitUntracked = (repoRoot) => {
289
- const result = spawnSync('git', ['-C', repoRoot, 'ls-files', '--others', '--exclude-standard'], {
290
- encoding: 'utf8', timeout: 8000, maxBuffer: 2 * 1024 * 1024, env: childProcessEnv(), windowsHide: true,
291
- })
292
- if (result.status !== 0) return {
293
- ok: false,
294
- files: [],
295
- error: String(result.stderr || result.error?.message || 'git ls-files failed').trim(),
296
- }
297
- return {
298
- ok: true,
299
- files: String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
300
- error: null,
301
- }
302
- }
303
-
304
- const pathsFromClassification = (classification) => [...new Set(classification.files
305
- .flatMap((file) => [file.oldPath, file.newPath])
306
- .filter((file) => file && file !== '(diff unavailable)'))]
307
- .sort((left, right) => left.localeCompare(right))
308
-
309
- const formatDebtFinding = (finding, state) => ` [${state}] ${formatAuditFinding(finding).trimStart()}`
310
-
311
- async function runAuditWithBaseline(args, ctx, currentGraph) {
312
- const resolved = resolveGitCommit(ctx.repoRoot, args.base_ref)
313
- if (!resolved.ok) return toolResult(`Audit baseline unavailable: ${resolved.error}.`, {
314
- status: 'INVALID', comparison: {status: 'UNAVAILABLE', reason: resolved.error}, findings: [],
315
- }, {completeness: {status: 'PARTIAL', reason: resolved.error}})
316
-
317
- let currentAudit
318
- try {
319
- currentAudit = await runInternalAudit(ctx.repoRoot, {
320
- graph: currentGraph,
321
- skipMalwareScan: !args.include_malware_scan,
322
- })
323
- } catch (error) {
324
- const reason = error instanceof Error ? error.message : String(error)
325
- return toolResult(`Audit failed while building the current graph: ${reason}`, {
326
- status: 'ERROR', comparison: {status: 'UNAVAILABLE', reason}, findings: [],
327
- }, {completeness: {status: 'PARTIAL', reason}})
328
- }
329
- if (!currentAudit.ok) return toolResult(`Audit failed: ${currentAudit.error}`, {
330
- status: 'ERROR', comparison: {status: 'UNAVAILABLE', reason: currentAudit.error}, findings: [],
331
- }, {completeness: {status: 'PARTIAL', reason: currentAudit.error}})
332
-
333
- const normalized = normalizeAuditScopeFiles(args.changed_files)
334
- if (!normalized.ok) return toolResult(`Audit scope invalid: ${normalized.error}.`, {
335
- status: 'INVALID', comparison: {status: 'UNAVAILABLE', reason: normalized.error}, findings: [],
336
- }, {completeness: {status: 'PARTIAL', reason: normalized.error}})
337
- let changedFiles = normalized.files
338
- let completeChangeSet = false
339
- let scopeSource = 'explicit changed_files'
340
- let changeEvidence = null
341
- if (changedFiles == null) {
342
- completeChangeSet = true
343
- const untracked = gitUntracked(ctx.repoRoot)
344
- if (!untracked.ok) {
345
- return toolResult(`Audit comparison stopped: untracked-file discovery failed (${untracked.error}). Pass changed_files explicitly to establish the scope.`, {
346
- status: 'PARTIAL', comparison: {status: 'UNAVAILABLE', reason: untracked.error}, findings: [],
347
- }, {completeness: {status: 'PARTIAL', reason: untracked.error}})
348
- }
349
- changeEvidence = classifyChangeImpact({
350
- repoRoot: ctx.repoRoot,
351
- graph: currentGraph,
352
- base: resolved.commit,
353
- files: untracked.files,
354
- })
355
- if (!changeEvidence.ok || changeEvidence.bounds?.truncated) {
356
- const reason = changeEvidence.reasons.join(' ')
357
- return toolResult(`Audit comparison stopped: changed-file derivation against ${resolved.ref} was incomplete. ${reason} Pass changed_files explicitly to establish a complete scope.`, {
358
- status: 'PARTIAL', comparison: {status: 'UNAVAILABLE', reason}, findings: [], changeEvidence,
359
- }, {completeness: {status: 'PARTIAL', reason}})
360
- }
361
- changedFiles = pathsFromClassification(changeEvidence)
362
- if (changedFiles.length > 500) {
363
- const reason = `derived changed-file scope contains ${changedFiles.length} files, above the 500-file comparison bound`
364
- return toolResult(`Audit comparison stopped: ${reason}. Pass a narrower explicit changed_files scope.`, {
365
- status: 'PARTIAL', comparison: {status: 'UNAVAILABLE', reason}, findings: [], changeEvidence,
366
- }, {completeness: {status: 'PARTIAL', reason}})
367
- }
368
- scopeSource = `git diff ${resolved.commit.slice(0, 12)}…working-tree`
369
- }
370
-
371
- const baseline = await withGitRefCheckout(ctx.repoRoot, resolved.commit, async (checkout) => {
372
- let graph = await buildInternalGraph(checkout)
373
- const currentMode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode)
374
- ? currentGraph.graphBuildMode : 'full'
375
- if (currentMode !== 'full') graph = filterGraphForMode(graph, currentMode, {repoRoot: checkout})
376
- graph.graphBuildMode = currentMode
377
- graph.graphBuildScope = ''
378
- return runInternalAudit(checkout, {graph, skipMalwareScan: true})
379
- })
380
- if (!baseline.ok || !baseline.value?.ok) {
381
- const reason = baseline.error || baseline.value?.error || 'baseline audit failed'
382
- return toolResult(`Audit baseline unavailable: ${reason}.`, {
383
- status: 'ERROR', comparison: {status: 'UNAVAILABLE', reason}, findings: [],
384
- }, {completeness: {status: 'PARTIAL', reason}})
385
- }
386
-
387
- const comparison = compareAuditDebt(currentAudit, baseline.value, changedFiles, {completeChangeSet})
388
- const mode = ['new', 'existing', 'all'].includes(args.debt) ? args.debt : 'new'
389
- const selectedRaw = mode === 'new' ? comparison.new : mode === 'existing' ? comparison.existing : comparison.all
390
- const selected = auditFilter(currentAudit, args, selectedRaw, ctx.repoRoot)
391
- const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
392
- const shown = selected.slice(0, max)
393
- const optional = comparison.optional.checks.map((check) => `${check.name.toUpperCase()} UNCOMPARABLE (current ${check.current}; baseline ${check.baseline})`).join('; ')
394
- const stateOf = (finding) => mode === 'all' ? finding.debtState : mode
395
- const fixedShown = auditFilter(baseline.value, args, comparison.fixed, ctx.repoRoot).slice(0, Math.min(10, max))
396
- const text = [
397
- `${mode.toUpperCase()} DEBT — deterministic internal audit vs ${resolved.ref} (${resolved.commit.slice(0, 12)})`,
398
- `Changed scope: ${changedFiles.length} file(s) from ${scopeSource}${changedFiles.length ? ` — ${changedFiles.slice(0, 12).join(', ')}${changedFiles.length > 12 ? ', …' : ''}` : ' — working tree matches the baseline'}.`,
399
- `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.`,
400
- `Optional checks: ${optional}. Supply-chain findings are not assigned new/existing/fixed state from a source-only checkout.`,
401
- '',
402
- `Showing ${shown.length} of ${selected.length} ${mode} finding(s) after filters:`,
403
- ...shown.map((finding) => formatDebtFinding(finding, stateOf(finding))),
404
- selected.length > shown.length ? ` … +${selected.length - shown.length} more` : null,
405
- '',
406
- `Fixed in this changed scope: ${comparison.fixed.length}${fixedShown.length ? ' (sample below)' : ''}.`,
407
- ...fixedShown.map((finding) => formatDebtFinding(finding, 'fixed')),
408
- ].filter((line) => line != null).join('\n')
409
- return toolResult(text, {
410
- status: 'COMPLETE',
411
- mode: 'baseline-comparison',
412
- debt: mode,
413
- baseline: {ref: resolved.ref, commit: resolved.commit},
414
- scope: {files: changedFiles, source: scopeSource},
415
- comparison: {
416
- status: 'COMPLETE',
417
- scope: comparison.scope,
418
- totals: comparison.totals,
419
- new: comparison.new,
420
- existing: comparison.existing,
421
- fixed: comparison.fixed,
422
- optional: comparison.optional,
423
- },
424
- findings: selected,
425
- changeEvidence,
426
- }, {
427
- page: {shown: shown.length, total: selected.length, capped: shown.length < selected.length},
428
- completeness: {status: 'COMPLETE'},
429
- })
430
- }
431
-
432
- // Full internal health audit: dead code + unused exports, dependency findings (npm/go/py missing &
433
- // unused deps), structure (import cycles / orphans / boundary rules), supply-chain (offline OSV
434
- // advisories, typosquat, lockfile drift), optional malware heuristics.
435
- export async function tRunAudit(g, args, ctx) {
436
- if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
437
- if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
438
- const audit = await runInternalAudit(ctx.repoRoot, {
439
- graph: effectiveRawGraph(ctx),
440
- skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
441
- })
442
- if (!audit.ok) return `Audit failed: ${audit.error}`
443
- if (Array.isArray(args.changed_files)) {
444
- const normalized = normalizeAuditScopeFiles(args.changed_files)
445
- if (!normalized.ok) return `Changed-scope audit invalid: ${normalized.error}.`
446
- const scoped = scopeAuditFindings(audit.findings, normalized.files)
447
- const text = formatOrdinaryAudit(audit, args, scoped,
448
- `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.`, ctx.repoRoot)
449
- return toolResult(text, {
450
- status: 'COMPLETE',
451
- mode: 'changed-scope',
452
- scope: {files: normalized.files, source: 'explicit changed_files'},
453
- comparison: {status: 'UNAVAILABLE', reason: 'base_ref was not provided; changed-scope is not a new-debt claim'},
454
- findings: auditFilter(audit, args, scoped, ctx.repoRoot),
455
- }, {completeness: {status: 'COMPLETE'}})
456
- }
457
- return formatOrdinaryAudit(audit, args, audit.findings, null, ctx.repoRoot)
458
- }
459
-
460
- // Named module clusters: graph communities labeled by their dominant folder instead of bare numbers.
461
- export function tListCommunities(g, args, ctx) {
462
- const max = Math.max(1, Math.min(100, Number(args.top_n) || 20))
463
- const list = summarizeCommunities(ctx.graphPath, max)
464
- if (!list.length) return 'No communities found in the graph.'
465
- return [
466
- `Communities, largest first (list position = community_id for get_community):`,
467
- ...list.map((c, i) => `${String(i).padStart(3)}. ${c.name} — ${c.size} nodes (raw id ${c.id}; e.g. ${[...new Set(c.files)].join(', ')})`),
468
- ].join('\n')
469
- }
470
-
471
- // Folder-level architecture map: modules (top-two path segments) with file/symbol counts and the
472
- // strongest module→module dependencies. Pure graph aggregation — no filesystem reads.
473
- export function tModuleMap(g, args, ctx) {
474
- const graph = rawGraph(ctx)
475
- const testsOnly = graph.graphBuildMode === 'tests-only'
476
- const includeNonProduct = args.include_non_product === true || testsOnly
477
- const classifier = createPathClassifier(ctx?.repoRoot || null)
478
- const nonProductFiles = new Set()
479
- const classifiedFiles = new Set()
480
- if (!includeNonProduct) {
481
- for (const node of graph.nodes || []) {
482
- if (!node?.source_file) continue
483
- const sourceFile = String(node.source_file)
484
- if (classifiedFiles.has(sourceFile)) continue
485
- classifiedFiles.add(sourceFile)
486
- const explanation = classifier.explain(sourceFile)
487
- if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
488
- nonProductFiles.add(sourceFile)
489
- }
490
- }
491
- }
492
- const visibleGraph = includeNonProduct || nonProductFiles.size === 0 ? graph : (() => {
493
- const keep = new Set((graph.nodes || [])
494
- .filter((node) => !node?.source_file || !nonProductFiles.has(String(node.source_file)))
495
- .map((node) => String(node.id)))
496
- const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
497
- return {
498
- ...graph,
499
- nodes: (graph.nodes || []).filter((node) => keep.has(String(node.id))),
500
- links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
501
- }
502
- })()
503
- const agg = aggregateGraph(visibleGraph, null)
504
- const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
505
- const mods = agg.modules.slice(0, topN)
506
- const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
507
- const compileEdges = new Map()
508
- const collectCompileEdges = (list, kind) => {
509
- for (const edge of list || []) {
510
- const key = `${edge.from}\0${edge.to}`
511
- const current = compileEdges.get(key) || {from: edge.from, to: edge.to, count: 0, typeOnly: 0, compileOnly: 0}
512
- current.count += edge.count
513
- current[kind] += edge.count
514
- compileEdges.set(key, current)
515
- }
516
- }
517
- collectCompileEdges(agg.typeOnlyModuleEdges, 'typeOnly')
518
- collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
519
- const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
520
- return [
521
- testsOnly
522
- ? 'Scope: tests-only graph; classified test/e2e/fixture surfaces are retained automatically.'
523
- : includeNonProduct
524
- ? 'Scope: all indexed files, including classified non-product surfaces.'
525
- : `Scope: production-only (default); excluded ${nonProductFiles.size} classified test/fixture/benchmark/generated/docs file(s). Pass include_non_product:true for the complete graph.`,
526
- `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}:`,
527
- ...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
528
- ``,
529
- `Strongest runtime module dependencies:`,
530
- ...edges.map((e) => ` ${e.from} → ${e.to} (${e.count})`),
531
- compiled.length ? `` : null,
532
- compiled.length ? `Compile-time module dependencies (not runtime coupling):` : null,
533
- ...compiled.map((e) => ` ${e.from} → ${e.to} (${e.count}; ${e.typeOnly} type-only, ${e.compileOnly} compile-only)`),
534
- ].filter((line) => line != null).join('\n')
535
- }
536
-
537
- // Parser-backed local cost review. This keeps syntax, graph coupling and measured/static test
538
- // evidence as separate fields so callers cannot mistake a ranking heuristic for profiler output.
539
- export function tHotPathReview(g, args, ctx) {
540
- const review = computeHotPathReview(rawGraph(ctx), {
541
- repoRoot: ctx?.repoRoot || null,
542
- path: args.path,
543
- includeTests: args.include_tests === true,
544
- includeClassified: args.include_classified === true,
545
- topN: args.top_n,
546
- cyclomaticThreshold: args.cyclomatic_threshold,
547
- callThreshold: args.call_threshold,
548
- loopDepthThreshold: args.loop_depth_threshold,
549
- timeRankThreshold: args.time_rank_threshold,
550
- minScore: args.min_score,
551
- })
552
- if (!review.ok) return toolResult(`Hot-path review refused: ${review.error}.`, review)
553
- const pct = (value) => typeof value === 'number' ? `${Math.round(value * 100)}%` : String(value || 'NOT_AVAILABLE')
554
- const coverageLine = review.coverage.actualCoverage === 'AVAILABLE'
555
- ? `Measured coverage: ${review.coverage.measuredFiles} file(s) from ${review.coverage.sources.join(', ') || 'coverage report'}.`
556
- : review.coverage.staticReachability
557
- ? `actualCoverage: NOT_AVAILABLE; static test reachability ${review.coverage.staticReachability.reachableFiles}/${review.coverage.staticReachability.productFiles} product file(s).`
558
- : 'actualCoverage: NOT_AVAILABLE; no measured or static test evidence was available.'
559
- const text = [
560
- `Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
561
- `Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
562
- `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'}.`,
563
- coverageLine,
564
- 'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
565
- '',
566
- ...(review.hotspots.length ? review.hotspots.flatMap((item, index) => {
567
- const tests = item.testEvidence.actualCoverage === 'NOT_AVAILABLE'
568
- ? item.testEvidence.staticReachable ? `static-test d${item.testEvidence.distance}` : 'no-static-test-path'
569
- : `coverage ${pct(item.testEvidence.actualCoverage)}`
570
- const pointer = `${item.file}${item.startLine ? `:${item.startLine}${item.endLine > item.startLine ? `-${item.endLine}` : ''}` : ''}`
571
- const evidence = item.sourceEvidence.length
572
- ? `\n evidence: ${item.sourceEvidence.map((entry) => `${entry.kind}@L${entry.line || '?'}${entry.detail ? ` (${entry.detail})` : ''}`).join('; ')}`
573
- : ''
574
- return [
575
- ` ${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}`,
576
- ` ${item.label} (${pointer}; fan-in ${item.graphRisk.fanIn}, fan-out ${item.graphRisk.fanOut}; ${tests})`,
577
- ` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
578
- ]
579
- }) : [' (none at the selected thresholds)']),
580
- 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,
581
- '',
582
- 'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
583
- ].filter((line) => line != null).join('\n')
584
- return toolResult(text, review, {
585
- completeness: {
586
- symbols: 'COMPLETE_FOR_INDEXED_GRAPH',
587
- output: review.bounds.truncated ? 'BOUNDED' : 'COMPLETE',
588
- coverage: review.coverage.actualCoverage,
589
- },
590
- })
591
- }
592
-
593
- // Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
594
- // tests are never executed here) onto files and symbols, then rank refactor risk as
595
- // connectivity × uncovered share. Pairs with get_dependents: many dependents + low coverage ⇒ write
596
- // tests before changing. Coverage pcts in this layer are fractions (0..1).
597
- export function tCoverageMap(g, args, ctx) {
598
- if (!ctx.repoRoot) return 'Coverage mapping needs the repo root (not provided to this server).'
599
- const agg = aggregateGraph(rawGraph(ctx), ctx.repoRoot)
600
- const pathFilter = args.path ? String(args.path).replace(/\\/g, '/').replace(/\/+$/, '') : null
601
- const inScope = (p) => !pathFilter || p === pathFilter || String(p).startsWith(`${pathFilter}/`)
602
- const allFiles = agg.modules.flatMap((m) => m.files.filter((f) => inScope(f.path)))
603
- const measured = allFiles.filter((f) => f.coverage != null)
604
- if (!measured.length) {
605
- const fallback = computeStaticTestReachability(rawGraph(ctx), {repoRoot: ctx.repoRoot, path: pathFilter || ''})
606
- const topN = Math.max(1, Math.min(50, Number(args.top_n) || 15))
607
- const reachable = fallback.reachable.slice(0, topN)
608
- const unreachable = fallback.unreachable.slice(0, topN)
609
- return [
610
- `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).`,
611
- `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.`,
612
- 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}.`,
613
- '',
614
- 'Nearest runtime paths from tests:',
615
- ...(reachable.length ? reachable.map((entry) => {
616
- const nearest = entry.nearestTests[0]
617
- return ` ${nearest.confidence.padStart(6)} d${nearest.distance} ${entry.file} ← ${nearest.test}\n path: ${nearest.path.join(' → ')}`
618
- }) : [' (none)']),
619
- '',
620
- `No runtime path from an indexed test (${fallback.unreachableFiles}; not proof of no tests):`,
621
- ...(unreachable.length ? unreachable.map((file) => ` ${file}`) : [' (none)']),
622
- fallback.unreachableFiles > unreachable.length ? ` … +${fallback.unreachableFiles - unreachable.length} more (raise top_n or narrow path)` : null,
623
- '',
624
- 'No coverage report found — generate one for measured coverage:',
625
- 'Generate one with the repo\'s own test runner, then call coverage_map again:',
626
- ' JS/TS: npx vitest run --coverage (or jest --coverage)',
627
- ' Python: pytest --cov --cov-report=json',
628
- ' Go: go test ./... -coverprofile=coverage.out',
629
- 'Read locations: coverage/coverage-summary.json, coverage/coverage-final.json, (coverage/)lcov.info, coverage.json, coverage.out.',
630
- ].filter((line) => line != null).join('\n')
631
- }
632
- const pctStr = (v) => (v == null ? 'n/a' : `${Math.round(v * 100)}%`)
633
- const sources = [...new Set(measured.map((f) => f.coverageSource).filter(Boolean))]
634
- const avg = measured.reduce((s, f) => s + f.coverage, 0) / measured.length
635
- const rollup = agg.modules
636
- .map((m) => {
637
- const withCov = m.files.filter((f) => f.coverage != null && inScope(f.path))
638
- if (!withCov.length) return null
639
- return {
640
- name: m.name,
641
- measured: withCov.length,
642
- total: m.files.filter((f) => inScope(f.path)).length,
643
- avg: withCov.reduce((s, f) => s + f.coverage, 0) / withCov.length,
644
- }
645
- })
646
- .filter(Boolean)
647
- .sort((a, b) => a.avg - b.avg)
648
- const topN = Math.max(1, Math.min(50, Number(args.top_n) || 15))
649
- // risk = graph degree × uncovered share; only symbols below 80% matter
650
- const risky = agg.symbols
651
- .filter((s) => s.coverage != null && s.coverage < 0.8 && inScope(s.file))
652
- .map((s) => ({...s, degree: degreeOf(g, s.id)}))
653
- .filter((s) => s.degree > 0)
654
- .sort((a, b) => b.degree * (1 - b.coverage) - a.degree * (1 - a.coverage))
655
- .slice(0, topN)
656
- return [
657
- `Coverage map (${measured.length}/${allFiles.length} files measured, avg ${pctStr(avg)}; report: ${sources.join(', ') || 'unknown'}${pathFilter ? `; filter ${pathFilter}` : ''}).`,
658
- ``,
659
- `Modules by average coverage (worst first):`,
660
- ...rollup.slice(0, 20).map((m) => ` ${pctStr(m.avg).padStart(5)} ${m.name} (${m.measured}/${m.total} files measured)`),
661
- ``,
662
- `Refactor-risk hotspots — connected symbols with low coverage (ranked by degree × uncovered):`,
663
- ...(risky.length
664
- ? risky.map((s) => ` ${pctStr(s.coverage).padStart(5)} deg ${String(s.degree).padStart(3)} ${s.label} (${s.file}${s.line ? `:${s.line}` : ''})`)
665
- : [' (none — every connected symbol is ≥80% covered or unmeasured)']),
666
- ``,
667
- `Tip: before refactoring a hotspot, run get_dependents on it — low coverage × many dependents means write tests first.`,
668
- ].join('\n')
669
- }
670
-
671
- // HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go/Rust/Spring route definitions.
672
- export function tListEndpoints(g, args, ctx) {
673
- if (!ctx.repoRoot) return 'Endpoint detection needs the repo root (not provided to this server).'
674
- const graph = rawGraph(ctx)
675
- const codeFiles = [...new Set(
676
- (graph.nodes || [])
677
- .filter((n) => !String(n.id).includes('#') && n.source_file && n.file_type === 'code')
678
- .map((n) => n.source_file)
679
- )]
680
- const inventory = analyzeEndpointInventory(ctx.repoRoot, codeFiles)
681
- let eps = inventory.endpoints
682
- const method = args.method ? String(args.method).toUpperCase() : null
683
- const path = args.path ? String(args.path) : null
684
- if (method) eps = eps.filter((endpoint) => endpoint.method === method)
685
- if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
686
- if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
687
- const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
688
- const shown = eps.slice(0, max)
689
- const stats = inventory.stats
690
- const text = [
691
- `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.`,
692
- ...shown.map((e) => {
693
- const via = e.mountChain?.length
694
- ? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
695
- : ''
696
- return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence})${via}`
697
- }),
698
- ].join('\n')
699
- return toolResult(text, {
700
- filters: {method, path},
701
- stats,
702
- endpoints: shown,
703
- page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
704
- }, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
705
- }
1
+ // Propagate catalog.mjs's cache-busting query to owner modules so development hot reload remains
2
+ // behaviorally identical to the former single-file implementation.
3
+ const version = new URL(import.meta.url).search
4
+ const load = (path) => import(new URL(`${path}${version}`, import.meta.url).href)
5
+ const [duplicates, deadCode, auditFormat, audit, structure, endpoints] = await Promise.all([
6
+ load('./health/duplicates.mjs'),
7
+ load('./health/dead-code.mjs'),
8
+ load('./health/audit-format.mjs'),
9
+ load('./health/audit.mjs'),
10
+ load('./health/structure.mjs'),
11
+ load('./health/endpoints.mjs'),
12
+ ])
13
+
14
+ export const tFindDuplicates = duplicates.tFindDuplicates
15
+ export const tFindDeadCode = deadCode.tFindDeadCode
16
+ export const auditFindingPathScope = auditFormat.auditFindingPathScope
17
+ export const formatAuditFinding = auditFormat.formatAuditFinding
18
+ export const isDependencyAuditFinding = auditFormat.isDependencyAuditFinding
19
+ export const tRunAudit = audit.tRunAudit
20
+ export const tCoverageMap = structure.tCoverageMap
21
+ export const tHotPathReview = structure.tHotPathReview
22
+ export const tListCommunities = structure.tListCommunities
23
+ export const tModuleMap = structure.tModuleMap
24
+ export const tListEndpoints = endpoints.tListEndpoints