weavatrix 0.1.4 → 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.
- package/README.md +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
4
|
import {
|
|
5
5
|
isSymbol, degreeOf, labelOf, connList,
|
|
6
|
-
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, undirectedNeighbors,
|
|
6
|
+
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors,
|
|
7
7
|
graphStaleness, fileStalenessNote,
|
|
8
8
|
} from './graph-context.mjs'
|
|
9
9
|
|
|
@@ -40,6 +40,7 @@ export function tGraphStats(g, ctx) {
|
|
|
40
40
|
`- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
|
|
41
41
|
`- Edges: ${g.links.length}`,
|
|
42
42
|
g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
|
|
43
|
+
g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
|
|
43
44
|
`- Relations: ${fmt(relCount)}`,
|
|
44
45
|
Object.keys(confCount).length ? `- Confidence: ${fmt(confCount)}` : null,
|
|
45
46
|
`- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
|
|
@@ -58,6 +59,8 @@ export function tGetNode(g, {label} = {}, ctx) {
|
|
|
58
59
|
const drift = ctx ? fileStalenessNote(ctx, n.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
|
|
59
60
|
const outs = g.out.get(id) || []
|
|
60
61
|
const ins = g.inn.get(id) || []
|
|
62
|
+
const semanticOuts = connList(outs)
|
|
63
|
+
const semanticIns = connList(ins)
|
|
61
64
|
const sample = (list, dir) =>
|
|
62
65
|
list
|
|
63
66
|
.slice(0, 12)
|
|
@@ -70,7 +73,7 @@ export function tGetNode(g, {label} = {}, ctx) {
|
|
|
70
73
|
`- kind: ${isSymbol(id) ? 'symbol' : 'file'}${n.file_type ? ` (${n.file_type})` : ''}`,
|
|
71
74
|
n.source_file ? `- source: ${n.source_file}${n.source_location ? ` ${n.source_location}` : ''}` : null,
|
|
72
75
|
n.community != null ? `- community: ${n.community}` : null,
|
|
73
|
-
`- degree: ${
|
|
76
|
+
`- semantic degree: ${semanticOuts.length + semanticIns.length} (out ${semanticOuts.length}, in ${semanticIns.length})${outs.length + ins.length !== semanticOuts.length + semanticIns.length ? `; ${outs.length + ins.length} physical/structural edges retained` : ''}`,
|
|
74
77
|
`Outgoing:\n${sample(outs, 'out')}`,
|
|
75
78
|
`Incoming:\n${sample(ins, 'in')}`,
|
|
76
79
|
drift,
|
|
@@ -151,8 +154,14 @@ export function tGetCommunity(g, {community_id} = {}) {
|
|
|
151
154
|
// A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
|
|
152
155
|
// Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
|
|
153
156
|
// slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
|
|
154
|
-
export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, token_budget = 2000} = {}) {
|
|
155
|
-
const
|
|
157
|
+
export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false, token_budget = 2000} = {}) {
|
|
158
|
+
const pinned = resolveSeedFiles(g, seed_files)
|
|
159
|
+
// Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
|
|
160
|
+
// Callers can opt back into augmentation when they explicitly want both behaviors.
|
|
161
|
+
const automatic = pinned.seeds.length && augment_seeds !== true
|
|
162
|
+
? []
|
|
163
|
+
: findSeeds(g, question, Math.max(0, 8 - pinned.seeds.length))
|
|
164
|
+
const seeds = [...pinned.seeds, ...automatic.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
|
|
156
165
|
if (!seeds.length) return `No nodes matched "${question}".`
|
|
157
166
|
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
|
158
167
|
const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
|
|
@@ -161,7 +170,6 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
161
170
|
// node budget scales gently with the token budget; edges follow the surviving nodes.
|
|
162
171
|
const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
|
|
163
172
|
const depthOf = new Map() // id -> shortest distance from any seed
|
|
164
|
-
const edges = []
|
|
165
173
|
const start = seeds.map((s) => String(s.id))
|
|
166
174
|
if (mode === 'dfs') {
|
|
167
175
|
const stack = start.map((id) => ({id, d: 0}))
|
|
@@ -174,7 +182,6 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
174
182
|
if (d >= maxDepth) continue
|
|
175
183
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
176
184
|
if (!relOk(rel)) continue
|
|
177
|
-
edges.push([id, rel, nid])
|
|
178
185
|
if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
|
|
179
186
|
}
|
|
180
187
|
}
|
|
@@ -186,7 +193,6 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
186
193
|
for (const id of frontier)
|
|
187
194
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
188
195
|
if (!relOk(rel)) continue
|
|
189
|
-
edges.push([id, rel, nid])
|
|
190
196
|
if (!depthOf.has(nid)) {
|
|
191
197
|
depthOf.set(nid, d + 1)
|
|
192
198
|
next.push(nid)
|
|
@@ -203,24 +209,29 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
203
209
|
const shownIds = new Set(shown.map((n) => n.id))
|
|
204
210
|
const edgeSeen = new Set()
|
|
205
211
|
const shownEdges = []
|
|
206
|
-
for (const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
+
for (const source of shownIds) {
|
|
213
|
+
for (const edge of g.out.get(source) || []) {
|
|
214
|
+
const target = String(edge.id)
|
|
215
|
+
if (edge.barrelProxy === true || !shownIds.has(target) || !relOk(edge.relation)) continue
|
|
216
|
+
const key = `${source}|${edge.relation}|${target}`
|
|
217
|
+
if (edgeSeen.has(key)) continue
|
|
218
|
+
edgeSeen.add(key)
|
|
219
|
+
shownEdges.push([source, edge.relation, target])
|
|
220
|
+
if (shownEdges.length >= 160) break
|
|
221
|
+
}
|
|
212
222
|
if (shownEdges.length >= 160) break
|
|
213
223
|
}
|
|
214
224
|
const head = [
|
|
215
225
|
`Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
|
|
216
226
|
`Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
|
|
227
|
+
pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
|
|
217
228
|
`Reached ${depthOf.size} nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
|
|
218
229
|
``,
|
|
219
230
|
`Nodes:`,
|
|
220
231
|
]
|
|
221
232
|
const nodeLines = shown.map((n) => ` [d${n.d}] ${labelOf(g, n.id)} (deg ${n.deg}) [${n.id}]`)
|
|
222
233
|
const edgeLines = ['', 'Edges:', ...shownEdges.map(([s, r, t]) => ` ${labelOf(g, s)} --${r || 'rel'}--> ${labelOf(g, t)}`)]
|
|
223
|
-
let text = [...head, ...nodeLines, ...edgeLines].join('\n')
|
|
234
|
+
let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
|
|
224
235
|
if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
|
|
225
236
|
return text
|
|
226
237
|
}
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -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]
|
|
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) => (
|
|
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
|
-
|
|
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
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(
|
|
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
|
}
|
|
@@ -177,14 +481,32 @@ export function tCoverageMap(g, args, ctx) {
|
|
|
177
481
|
const allFiles = agg.modules.flatMap((m) => m.files.filter((f) => inScope(f.path)))
|
|
178
482
|
const measured = allFiles.filter((f) => f.coverage != null)
|
|
179
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)
|
|
180
488
|
return [
|
|
181
|
-
`
|
|
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:',
|
|
182
504
|
'Generate one with the repo\'s own test runner, then call coverage_map again:',
|
|
183
505
|
' JS/TS: npx vitest run --coverage (or jest --coverage)',
|
|
184
506
|
' Python: pytest --cov --cov-report=json',
|
|
185
507
|
' Go: go test ./... -coverprofile=coverage.out',
|
|
186
508
|
'Read locations: coverage/coverage-summary.json, coverage/coverage-final.json, (coverage/)lcov.info, coverage.json, coverage.out.',
|
|
187
|
-
].join('\n')
|
|
509
|
+
].filter((line) => line != null).join('\n')
|
|
188
510
|
}
|
|
189
511
|
const pctStr = (v) => (v == null ? 'n/a' : `${Math.round(v * 100)}%`)
|
|
190
512
|
const sources = [...new Set(measured.map((f) => f.coverageSource).filter(Boolean))]
|
|
@@ -225,7 +547,7 @@ export function tCoverageMap(g, args, ctx) {
|
|
|
225
547
|
].join('\n')
|
|
226
548
|
}
|
|
227
549
|
|
|
228
|
-
// HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go
|
|
550
|
+
// HTTP endpoint inventory: Express/Fastify/Nest/Flask/FastAPI/Go/Rust/Spring route definitions.
|
|
229
551
|
export function tListEndpoints(g, args, ctx) {
|
|
230
552
|
if (!ctx.repoRoot) return 'Endpoint detection needs the repo root (not provided to this server).'
|
|
231
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
|
+
}
|