weavatrix 0.2.6 → 0.2.8
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 +76 -10
- package/SECURITY.md +14 -0
- package/docs/agent-task-benchmark.md +58 -0
- package/docs/releases/v0.2.8.md +82 -0
- package/package.json +6 -1
- package/scripts/run-agent-task-benchmark.mjs +119 -0
- package/skill/SKILL.md +27 -7
- package/src/analysis/allowed-test-runner.js +59 -0
- package/src/analysis/data-flow-evidence.js +114 -0
- package/src/analysis/dead-check.js +24 -2
- package/src/analysis/dead-code-review.js +51 -0
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/dep-check.js +42 -3
- package/src/analysis/duplicate-groups.js +84 -0
- package/src/analysis/endpoints.js +47 -13
- package/src/analysis/hot-path-review.js +20 -2
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/analysis/internal-audit.run.js +6 -1
- package/src/analysis/task-retrieval.js +116 -0
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/catalog.mjs +26 -6
- package/src/mcp/graph-context.mjs +37 -2
- package/src/mcp/tools-graph.mjs +46 -3
- package/src/mcp/tools-health.mjs +37 -29
- package/src/mcp/tools-impact-change.mjs +1 -0
- package/src/mcp/tools-verified-change.mjs +185 -0
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/src/security/malware-heuristics.exclusions.js +3 -3
- package/src/security/malware-heuristics.roots.js +4 -1
- package/src/security/malware-heuristics.scan.js +4 -2
- package/src/security/malware-scoring.js +22 -5
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
4
|
import {
|
|
5
5
|
isSymbol, degreeOf, labelOf, connList,
|
|
6
|
-
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors,
|
|
6
|
+
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors, requestedPathClasses,
|
|
7
7
|
graphStaleness, fileStalenessNote,
|
|
8
8
|
} from './graph-context.mjs'
|
|
9
9
|
import {summarizeEdgeProvenance} from '../graph/edge-provenance.js'
|
|
10
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
10
11
|
|
|
11
12
|
const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
|
|
12
13
|
|
|
@@ -165,7 +166,15 @@ export function tGetCommunity(g, {community_id} = {}) {
|
|
|
165
166
|
// A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
|
|
166
167
|
// Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
|
|
167
168
|
// slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
|
|
168
|
-
|
|
169
|
+
const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
170
|
+
const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
|
|
171
|
+
const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
|
|
172
|
+
const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
|
|
173
|
+
|
|
174
|
+
export function tQueryGraph(g, {
|
|
175
|
+
question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false,
|
|
176
|
+
include_classified = false, include_low_signal = false, token_budget = 2000,
|
|
177
|
+
} = {}, toolCtx = {}) {
|
|
169
178
|
const pinned = resolveSeedFiles(g, seed_files)
|
|
170
179
|
// Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
|
|
171
180
|
// Callers can opt back into augmentation when they explicitly want both behaviors.
|
|
@@ -177,6 +186,32 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
177
186
|
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
|
178
187
|
const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
|
|
179
188
|
const relOk = (rel) => !ctx || ctx.has(String(rel ?? '').toLowerCase())
|
|
189
|
+
const requestedClasses = requestedPathClasses(question)
|
|
190
|
+
const classifier = createPathClassifier(toolCtx.repoRoot || null)
|
|
191
|
+
const classificationCache = new Map()
|
|
192
|
+
const pinnedFiles = new Set(pinned.seeds.map(querySourceFile))
|
|
193
|
+
const classifiedSuppressed = new Set()
|
|
194
|
+
const pathPolicy = (id) => {
|
|
195
|
+
const node = g.byId.get(String(id))
|
|
196
|
+
const file = querySourceFile(node)
|
|
197
|
+
if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
|
|
198
|
+
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
199
|
+
const info = classificationCache.get(file)
|
|
200
|
+
const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
|
|
201
|
+
if (!classes.length && !info?.excluded) return {ok: true}
|
|
202
|
+
if (classes.some((name) => requestedClasses.has(name))) return {ok: true}
|
|
203
|
+
classifiedSuppressed.add(String(id))
|
|
204
|
+
return {ok: false, bucket: 'classified'}
|
|
205
|
+
}
|
|
206
|
+
const questionTerms = queryWords(question)
|
|
207
|
+
const isLowSignal = (id) => {
|
|
208
|
+
if (include_low_signal === true || start.includes(String(id))) return false
|
|
209
|
+
const node = g.byId.get(String(id))
|
|
210
|
+
if (!node || !isSymbol(node.id) || !LOW_SIGNAL_SYMBOL_RE.test(String(node.symbol_kind || ''))) return false
|
|
211
|
+
const labelTerms = queryWords(node.label || String(node.id || '').split('#').pop() || '')
|
|
212
|
+
if ([...questionTerms].some((term) => labelTerms.has(term))) return false
|
|
213
|
+
return degreeOf(g, id) === 0
|
|
214
|
+
}
|
|
180
215
|
const charBudget = Math.max(400, (Number(token_budget) || 2000) * 4)
|
|
181
216
|
// node budget scales gently with the token budget; edges follow the surviving nodes.
|
|
182
217
|
const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
|
|
@@ -193,6 +228,7 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
193
228
|
if (d >= maxDepth) continue
|
|
194
229
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
195
230
|
if (!relOk(rel)) continue
|
|
231
|
+
if (!pathPolicy(nid).ok) continue
|
|
196
232
|
if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
|
|
197
233
|
}
|
|
198
234
|
}
|
|
@@ -204,6 +240,7 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
204
240
|
for (const id of frontier)
|
|
205
241
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
206
242
|
if (!relOk(rel)) continue
|
|
243
|
+
if (!pathPolicy(nid).ok) continue
|
|
207
244
|
if (!depthOf.has(nid)) {
|
|
208
245
|
depthOf.set(nid, d + 1)
|
|
209
246
|
next.push(nid)
|
|
@@ -213,7 +250,10 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
213
250
|
}
|
|
214
251
|
}
|
|
215
252
|
// rank reached nodes: seeds first, then by proximity (depth asc), then connectivity (degree desc)
|
|
253
|
+
const reachedBeforeSignalFilter = depthOf.size
|
|
254
|
+
const lowSignalSuppressed = [...depthOf.keys()].filter(isLowSignal).length
|
|
216
255
|
const ranked = [...depthOf.entries()]
|
|
256
|
+
.filter(([id]) => !isLowSignal(id))
|
|
217
257
|
.map(([id, d]) => ({id, d, deg: degreeOf(g, id)}))
|
|
218
258
|
.sort((a, b) => a.d - b.d || b.deg - a.deg)
|
|
219
259
|
const shown = ranked.slice(0, nodeBudget)
|
|
@@ -236,7 +276,10 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
236
276
|
`Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
|
|
237
277
|
`Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
|
|
238
278
|
pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
|
|
239
|
-
`Reached ${
|
|
279
|
+
`Reached ${reachedBeforeSignalFilter} policy-eligible nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
|
|
280
|
+
classifiedSuppressed.size ? `Suppressed ${classifiedSuppressed.size} classified/non-product traversal node(s); ask for that class or pass include_classified:true.` : null,
|
|
281
|
+
lowSignalSuppressed ? `Suppressed ${lowSignalSuppressed} unreferenced constant/field node(s) with no query-term match; pass include_low_signal:true to inspect them.` : null,
|
|
282
|
+
include_classified === true ? 'Path policy: classified/non-product traversal explicitly enabled.' : `Path policy: production-first${requestedClasses.size ? `; explicit question classes enabled: ${[...requestedClasses].join(', ')}` : ''}.`,
|
|
240
283
|
``,
|
|
241
284
|
`Nodes:`,
|
|
242
285
|
]
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {spawnSync} from 'node:child_process'
|
|
4
4
|
import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
|
|
5
5
|
import {computeDuplicates} from '../analysis/duplicates.js'
|
|
6
|
+
import {analyzeDuplicateGroups} from '../analysis/duplicate-groups.js'
|
|
6
7
|
import {runInternalAudit} from '../analysis/internal-audit.js'
|
|
7
8
|
import {classifyChangeImpact} from '../analysis/change-classification.js'
|
|
8
9
|
import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
|
|
@@ -21,6 +22,7 @@ import {toolResult} from './tool-result.mjs'
|
|
|
21
22
|
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
22
23
|
import {createRepoBoundary} from '../repo-path.js'
|
|
23
24
|
import {filterGraphForMode} from '../graph/graph-filter.js'
|
|
25
|
+
import {readCachedSymbolPrecisionEvidence} from '../precision/symbol-query.js'
|
|
24
26
|
|
|
25
27
|
const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
26
28
|
const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
|
|
@@ -31,26 +33,6 @@ const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
|
|
|
31
33
|
return true
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
// Group clone pairs into union-find families.
|
|
35
|
-
function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified}) {
|
|
36
|
-
const frags = data.frags || []
|
|
37
|
-
const elig = (i) => fragmentEligible(frags[i], {tokMin, skipTests, includeClassified})
|
|
38
|
-
const pairs = (data.modes?.[mode] || []).filter(([i, j, s]) => s >= simMin && elig(i) && elig(j))
|
|
39
|
-
const parent = new Map()
|
|
40
|
-
const find = (x) => { let r = x; while (parent.has(r) && parent.get(r) !== r) r = parent.get(r); return r }
|
|
41
|
-
for (const [i, j] of pairs) { if (!parent.has(i)) parent.set(i, i); if (!parent.has(j)) parent.set(j, j); parent.set(find(i), find(j)) }
|
|
42
|
-
const groups = new Map()
|
|
43
|
-
for (const [i, j, s] of pairs) {
|
|
44
|
-
const r = find(i)
|
|
45
|
-
if (!groups.has(r)) groups.set(r, {members: new Set(), maxSim: 0})
|
|
46
|
-
const g = groups.get(r); g.members.add(i); g.members.add(j); g.maxSim = Math.max(g.maxSim, s)
|
|
47
|
-
}
|
|
48
|
-
return [...groups.values()].map((g) => {
|
|
49
|
-
const members = [...g.members].sort((a, b) => frags[b].n - frags[a].n)
|
|
50
|
-
return {members: members.map((i) => frags[i]), maxSim: g.maxSim, tokens: members.reduce((n, i) => n + frags[i].n, 0)}
|
|
51
|
-
}).sort((a, b) => b.tokens - a.tokens)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
36
|
export function tFindDuplicates(g, args, ctx) {
|
|
55
37
|
if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
|
|
56
38
|
const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
|
|
@@ -97,10 +79,10 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
97
79
|
})
|
|
98
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.`
|
|
99
81
|
}
|
|
100
|
-
const
|
|
101
|
-
const groups =
|
|
82
|
+
const analysis = analyzeDuplicateGroups(ctx.repoRoot, ctx.graphPath, args)
|
|
83
|
+
const groups = analysis.groups
|
|
102
84
|
const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
|
|
103
|
-
const suppressed =
|
|
85
|
+
const suppressed = analysis.suppressed
|
|
104
86
|
const suppressionNote = suppressed && !includeClassified
|
|
105
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.`
|
|
106
88
|
: ''
|
|
@@ -120,7 +102,27 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
120
102
|
// candidates. It never returns an automatic-delete verdict.
|
|
121
103
|
export function tFindDeadCode(g, args, ctx) {
|
|
122
104
|
if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
|
|
123
|
-
const
|
|
105
|
+
const effectiveGraph = effectiveRawGraph(ctx)
|
|
106
|
+
const pointEvidence = readCachedSymbolPrecisionEvidence({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, graph: effectiveGraph})
|
|
107
|
+
const graph = {
|
|
108
|
+
...effectiveGraph,
|
|
109
|
+
precisionReferenceSymbols: [...new Set([
|
|
110
|
+
...(effectiveGraph.precisionReferenceSymbols || []),
|
|
111
|
+
...pointEvidence.referenceSymbols,
|
|
112
|
+
])],
|
|
113
|
+
precisionProductionReferenceSymbols: [...new Set([
|
|
114
|
+
...(effectiveGraph.precisionProductionReferenceSymbols || []),
|
|
115
|
+
...pointEvidence.productionReferenceSymbols,
|
|
116
|
+
])],
|
|
117
|
+
precisionTestReferenceSymbols: [...new Set([
|
|
118
|
+
...(effectiveGraph.precisionTestReferenceSymbols || []),
|
|
119
|
+
...pointEvidence.testReferenceSymbols,
|
|
120
|
+
])],
|
|
121
|
+
precisionNoReferenceSymbols: [...new Set([
|
|
122
|
+
...(effectiveGraph.precisionNoReferenceSymbols || []),
|
|
123
|
+
...pointEvidence.noReferenceSymbols,
|
|
124
|
+
])],
|
|
125
|
+
}
|
|
124
126
|
const boundary = createRepoBoundary(ctx.repoRoot)
|
|
125
127
|
const pkg = readRepoJson(boundary, 'package.json') || {}
|
|
126
128
|
const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
|
|
@@ -162,13 +164,15 @@ export function tFindDeadCode(g, args, ctx) {
|
|
|
162
164
|
: `${candidate.owner ? `${candidate.owner}.` : ''}${candidate.symbol || candidate.id}`
|
|
163
165
|
const where = `${candidate.file}${candidate.line ? `:${candidate.line}` : ''}`
|
|
164
166
|
return [
|
|
165
|
-
`${index + 1}. [${candidate.confidence}/${candidate.classification}] ${subject} (${where})`,
|
|
167
|
+
`${index + 1}. [${candidate.confidence}/${candidate.evidenceTier}/${candidate.classification}] ${subject} (${where})`,
|
|
166
168
|
` evidence: ${candidate.evidence.map((item) => item.fact).join(' ')}`,
|
|
167
169
|
candidate.caveats.length ? ` caution: ${candidate.caveats.join(' ')}` : null,
|
|
170
|
+
` remaining: ${candidate.remainingChecks.join(' ')}`,
|
|
168
171
|
].filter(Boolean).join('\n')
|
|
169
172
|
})
|
|
170
173
|
const text = [
|
|
171
174
|
`Dead-code review: ${shown.length} of ${review.candidates.length} candidate(s) shown (high ${counts.high}, medium ${counts.medium}, low ${counts.low}).`,
|
|
175
|
+
`Evidence tiers: strong static ${review.totals.byEvidenceTier.strongStatic}, bounded static ${review.totals.byEvidenceTier.boundedStatic}, high uncertainty ${review.totals.byEvidenceTier.highUncertainty}.`,
|
|
172
176
|
`Verdict: REVIEW_REQUIRED. This is static evidence, never permission to auto-delete or bulk-delete.`,
|
|
173
177
|
suppression ? `Suppressed by current filters: ${suppression}.` : null,
|
|
174
178
|
review.suppressed.confidence ? 'Use min_confidence=low only when public/framework/dynamic candidates need explicit review.' : null,
|
|
@@ -196,7 +200,10 @@ const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
|
|
|
196
200
|
|
|
197
201
|
export function formatAuditFinding(f) {
|
|
198
202
|
const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
|
|
199
|
-
|
|
203
|
+
const verification = f.verification?.evidenceModel
|
|
204
|
+
? `\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'}`
|
|
205
|
+
: ''
|
|
206
|
+
return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${verification}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
const auditFilter = (audit, args, findings = audit.findings) => {
|
|
@@ -234,7 +241,7 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
|
|
|
234
241
|
heading,
|
|
235
242
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
236
243
|
...auditConventionLines(audit),
|
|
237
|
-
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
|
|
244
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
|
|
238
245
|
`Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
239
246
|
`Repository-level ${auditChecksLine(audit)}`,
|
|
240
247
|
'',
|
|
@@ -428,7 +435,7 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
428
435
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
429
436
|
`Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
430
437
|
`Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
|
|
431
|
-
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
|
|
438
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
|
|
432
439
|
...auditConventionLines(audit),
|
|
433
440
|
`Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
|
|
434
441
|
``,
|
|
@@ -540,6 +547,7 @@ export function tHotPathReview(g, args, ctx) {
|
|
|
540
547
|
const text = [
|
|
541
548
|
`Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
|
|
542
549
|
`Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
|
|
550
|
+
`Selection: ${review.selectionPolicy.mode}${review.selectionPolicy.strongLocalFallback ? '; strong local sort/recursion/deep-loop evidence can pass below the blended score gate' : '; strict explicit score gate'}.`,
|
|
543
551
|
coverageLine,
|
|
544
552
|
'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
|
|
545
553
|
'',
|
|
@@ -557,7 +565,7 @@ export function tHotPathReview(g, args, ctx) {
|
|
|
557
565
|
` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
|
|
558
566
|
]
|
|
559
567
|
}) : [' (none at the selected thresholds)']),
|
|
560
|
-
review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n, min_score
|
|
568
|
+
review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n to display more, raise min_score or narrow path to tighten; lower min_score to broaden).` : null,
|
|
561
569
|
'',
|
|
562
570
|
'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
|
|
563
571
|
].filter((line) => line != null).join('\n')
|
|
@@ -244,6 +244,7 @@ export function tChangeImpactV2(g, args = {}, ctx = {}) {
|
|
|
244
244
|
},
|
|
245
245
|
testEvidence: {
|
|
246
246
|
actualCoverage: hasCoverage ? 'AVAILABLE' : 'NOT_AVAILABLE',
|
|
247
|
+
changedFiles: changed.slice(0, 500).map((file) => ({file, ...testEvidenceFor(file)})),
|
|
247
248
|
staticTestReachability: {
|
|
248
249
|
kind: staticTests.kind,
|
|
249
250
|
testFiles: staticTests.testFiles,
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import {findSeeds, rawGraph} from './graph-context.mjs'
|
|
2
|
+
import {toolResult} from './tool-result.mjs'
|
|
3
|
+
import {expandTaskQuery, retrieveTaskContext} from '../analysis/task-retrieval.js'
|
|
4
|
+
import {extractCallArgumentEvidence} from '../analysis/data-flow-evidence.js'
|
|
5
|
+
import {runAllowedTests, validateTestRequests} from '../analysis/allowed-test-runner.js'
|
|
6
|
+
import {compareDuplicateGroups} from '../analysis/duplicate-groups.js'
|
|
7
|
+
import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
|
|
8
|
+
import {diffGraphs, formatGraphDiff} from './graph-diff.mjs'
|
|
9
|
+
|
|
10
|
+
const richResult = (value) => value?.__weavatrixToolResult === true ? value.result : null
|
|
11
|
+
const normalizeFile = (value) => String(value || '').replace(/\\/g, '/')
|
|
12
|
+
const changedFilesOf = (impact) => [...new Set((impact?.changes || []).flatMap((change) => [change.path, change.oldPath, change.newPath]).map(normalizeFile).filter((file) => file && file !== '(diff unavailable)'))]
|
|
13
|
+
const targetTests = (impact) => [...new Set([
|
|
14
|
+
...(impact?.testEvidence?.changedFiles || []).map((item) => item.staticTestReachability?.test),
|
|
15
|
+
...(impact?.blastRadius?.nodes || []).map((node) => node.testEvidence?.staticTestReachability?.test),
|
|
16
|
+
].filter(Boolean))].slice(0, 30)
|
|
17
|
+
|
|
18
|
+
function testCoverage(proof, requests, suggested) {
|
|
19
|
+
if (!suggested.length) return {state: 'NOT_APPLICABLE', covered: [], missing: []}
|
|
20
|
+
if (proof.state !== 'PASS') return {state: 'PENDING', covered: [], missing: suggested}
|
|
21
|
+
if ((requests || []).some((request) => request?.script === 'test' && (!request.args || !request.args.length))) {
|
|
22
|
+
return {state: 'COMPLETE', kind: 'full-test-script', covered: suggested, missing: []}
|
|
23
|
+
}
|
|
24
|
+
const args = (requests || []).flatMap((request) => request?.args || []).map(normalizeFile)
|
|
25
|
+
const covered = suggested.filter((file) => args.some((arg) => arg === file || arg.endsWith(`/${file}`)))
|
|
26
|
+
return {state: covered.length === suggested.length ? 'COMPLETE' : 'PARTIAL', kind: 'explicit-test-paths', covered, missing: suggested.filter((file) => !covered.includes(file))}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function graphProof(diff) {
|
|
30
|
+
const runtimeCycles = diff?.cycles?.runtime?.introduced || []
|
|
31
|
+
return {
|
|
32
|
+
state: runtimeCycles.length ? 'BLOCKED' : diff.schemaMigration ? 'UNKNOWN' : 'PASS',
|
|
33
|
+
...(diff.schemaMigration ? {reason: 'graph extractor/schema versions differ, so structural ratchets are not comparable'} : {}),
|
|
34
|
+
summary: formatGraphDiff(diff),
|
|
35
|
+
counts: {
|
|
36
|
+
nodesAdded: diff.nodes.added.length, nodesRemoved: diff.nodes.removed.length,
|
|
37
|
+
edgesAdded: diff.edges.added, edgesRemoved: diff.edges.removed,
|
|
38
|
+
moduleDependenciesAdded: diff.moduleEdges.added.length, orphaned: diff.orphaned.length,
|
|
39
|
+
runtimeCyclesIntroduced: runtimeCycles.length,
|
|
40
|
+
},
|
|
41
|
+
runtimeCycles: runtimeCycles.slice(0, 20), moduleDependenciesAdded: diff.moduleEdges.added.slice(0, 20),
|
|
42
|
+
orphaned: diff.orphaned.slice(0, 20), schemaMigration: diff.schemaMigration,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function baselineProof(ctx, baseRef, currentGraph) {
|
|
47
|
+
const mode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode) ? currentGraph.graphBuildMode : 'full'
|
|
48
|
+
const built = await buildGraphAtGitRef(ctx.repoRoot, baseRef, {mode})
|
|
49
|
+
if (!built.ok) return {state: 'UNKNOWN', reason: built.error}
|
|
50
|
+
return {...graphProof(diffGraphs(built.graph, currentGraph)), baseline: {ref: built.ref, commit: built.commit}}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function apiState(result) {
|
|
54
|
+
if (!result || result.status !== 'COMPLETE' || result.completeness?.complete !== true) return 'UNKNOWN'
|
|
55
|
+
if (['HTTP_METHOD_MISMATCH', 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'].includes(result.verdict?.code)) return 'BLOCKED'
|
|
56
|
+
return 'UNKNOWN'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function architectureState(result) {
|
|
60
|
+
if (!result || result.state === 'NOT_CONFIGURED' || result.state === 'ERROR') return 'UNKNOWN'
|
|
61
|
+
if (!result.verification) return result.state === 'READY' ? 'PASS' : 'UNKNOWN'
|
|
62
|
+
return result.verification.new?.length || String(result.verification.status).toUpperCase() === 'FAIL' ? 'BLOCKED' : 'PASS'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function exactUsageLines(contexts, g) {
|
|
66
|
+
if (!contexts.length) return []
|
|
67
|
+
return contexts.map((context) => {
|
|
68
|
+
const node = g.byId.get(String(context.symbol || context.definition?.id || ''))
|
|
69
|
+
const label = String(context.definition?.label || node?.label || context.symbol || 'symbol')
|
|
70
|
+
if (context.status !== 'OK') return `Exact usage: ${label} — unavailable (${context.status || 'UNKNOWN'}).`
|
|
71
|
+
const exact = context.evidence?.state === 'EXACT'
|
|
72
|
+
const occurrences = Number(context.references?.occurrences) || 0
|
|
73
|
+
const files = Number(context.references?.files) || 0
|
|
74
|
+
const inbound = Array.isArray(context.inbound?.shown) ? context.inbound.shown.slice(0, 3) : []
|
|
75
|
+
const callers = inbound.map((item) => `${item.label || item.id}${item.file ? ` [${item.file}]` : ''}`).join(', ')
|
|
76
|
+
return `${exact ? 'Exact usage' : 'Bounded usage'}: ${label} — ${occurrences} reference occurrence(s)${files ? ` in ${files} file(s)` : ''}; ${Number(context.inbound?.total) || 0} inbound container(s)${callers ? `: ${callers}` : ''}.`
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function decide({phase, impact, graph, architecture, duplicates, api, tests, suggestedTests, testCoverageState}) {
|
|
81
|
+
if (phase === 'plan') return tests.state === 'BLOCKED'
|
|
82
|
+
? {verdict: 'BLOCKED', blockers: [`targeted test plan was rejected: ${tests.reason || 'invalid request'}`], unknowns: []}
|
|
83
|
+
: {verdict: 'UNKNOWN', blockers: [], unknowns: ['verification has not run; apply the edit and call verified_change with phase=verify']}
|
|
84
|
+
if (impact.status === 'COMPLETE' && !impact.changes?.length) return {verdict: 'PASS', blockers: [], unknowns: []}
|
|
85
|
+
const blockers = [], unknowns = []
|
|
86
|
+
if (impact.status !== 'COMPLETE') unknowns.push('change impact is partial or has unmapped evidence')
|
|
87
|
+
if (graph.state === 'BLOCKED') blockers.push('the change introduces a runtime dependency cycle')
|
|
88
|
+
if (graph.state === 'UNKNOWN') unknowns.push(`Git graph baseline is unavailable: ${graph.reason || 'unknown reason'}`)
|
|
89
|
+
if (architecture.state === 'BLOCKED') blockers.push('new architecture-contract violations were found')
|
|
90
|
+
if (architecture.state === 'UNKNOWN') unknowns.push('architecture contract is not configured or verification is incomplete')
|
|
91
|
+
if (duplicates.state === 'BLOCKED') blockers.push('new duplicate groups intersect the changed files')
|
|
92
|
+
if (duplicates.state === 'UNKNOWN') unknowns.push(`duplicate ratchet is incomplete: ${duplicates.reason || 'health capability unavailable'}`)
|
|
93
|
+
if (api.state === 'BLOCKED') blockers.push('cross-repository API evidence contains HTTP method mismatches')
|
|
94
|
+
if (api.state === 'UNKNOWN') unknowns.push(`API contract evidence is incomplete: ${api.reason || 'no bounded proof'}`)
|
|
95
|
+
if (tests.state === 'FAIL' || tests.state === 'BLOCKED') {
|
|
96
|
+
const failed = (tests.results || []).filter((result) => result.status !== 'PASS').map((result) => `${result.script} (${result.status}${result.exitCode == null ? '' : `, exit ${result.exitCode}`})`)
|
|
97
|
+
blockers.push(`targeted tests failed or were rejected: ${tests.reason || failed.join(', ') || 'unknown failure'}`)
|
|
98
|
+
}
|
|
99
|
+
if (tests.state === 'DISABLED') unknowns.push('targeted test execution was requested but runtime permission is disabled')
|
|
100
|
+
if (tests.state === 'NOT_REQUESTED' && suggestedTests.length) unknowns.push('affected tests were identified but no allowlisted package test was requested')
|
|
101
|
+
if (tests.state === 'PASS' && testCoverageState.state === 'PARTIAL') unknowns.push(`targeted test run did not cover ${testCoverageState.missing.length} suggested test path(s)`)
|
|
102
|
+
return {verdict: blockers.length ? 'BLOCKED' : unknowns.length ? 'UNKNOWN' : 'PASS', blockers, unknowns}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permissions = {}) {
|
|
106
|
+
const phase = args.phase === 'verify' ? 'verify' : 'plan'
|
|
107
|
+
const currentGraph = rawGraph(ctx)
|
|
108
|
+
const impactValue = await tools.impact(g, {base: args.base_ref, diff: args.diff, files: args.files, depth: args.impact_depth, max_nodes: args.max_impact_nodes}, ctx)
|
|
109
|
+
const impact = richResult(impactValue) || {status: 'PARTIAL', changes: [], seeds: {ids: []}, blastRadius: {nodes: []}}
|
|
110
|
+
const changedFiles = changedFilesOf(impact)
|
|
111
|
+
const retrieval = retrieveTaskContext(g, {
|
|
112
|
+
task: args.task, semanticSeeds: findSeeds(g, expandTaskQuery(args.task), 12, {repoRoot: ctx.repoRoot}),
|
|
113
|
+
changedSeedIds: impact.seeds?.ids, maxSymbols: args.max_symbols, repoRoot: ctx.repoRoot,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
let contexts = []
|
|
117
|
+
if (permissions.source) contexts = await Promise.all(retrieval.selected.map(async (symbol) => {
|
|
118
|
+
const value = await tools.context(g, {label: symbol.id, precision: args.precision, max_related: 8, max_source_files: 3, context_lines: 4}, ctx, tools.inspect)
|
|
119
|
+
const result = richResult(value)
|
|
120
|
+
return result ? {symbol: symbol.id, status: result.status, definition: result.definition, evidence: result.evidence, references: result.references, inbound: result.inbound, outbound: result.outbound, reExports: result.reExports, source: result.source} : {symbol: symbol.id, status: 'UNKNOWN'}
|
|
121
|
+
}))
|
|
122
|
+
const dataFlow = permissions.source ? extractCallArgumentEvidence({
|
|
123
|
+
graph: g, repoRoot: ctx.repoRoot, seedIds: retrieval.selected.map((item) => item.id),
|
|
124
|
+
depth: Math.max(1, Math.min(3, Number(args.data_flow_depth) || 2)),
|
|
125
|
+
maxEdges: Math.max(1, Math.min(60, Number(args.max_data_flow_edges) || 30)),
|
|
126
|
+
})
|
|
127
|
+
: {model: 'bounded call-argument-to-parameter evidence (not CFG or taint analysis)', status: 'UNAVAILABLE', reason: 'source capability is not enabled', edges: [], unsupportedEdges: 0, capped: false}
|
|
128
|
+
const suggestedTests = targetTests(impact)
|
|
129
|
+
const checkedTests = validateTestRequests(ctx.repoRoot, args.tests || [])
|
|
130
|
+
const testProof = phase === 'verify'
|
|
131
|
+
? await runAllowedTests(ctx.repoRoot, args.tests || [], {enabled: args.run_tests === true, timeoutMs: args.test_timeout_ms})
|
|
132
|
+
: {state: checkedTests.ok ? 'PLANNED' : 'BLOCKED', reason: checkedTests.reason, plan: checkedTests.tests || [], results: []}
|
|
133
|
+
const testCoverageState = testCoverage(testProof, args.tests || [], suggestedTests)
|
|
134
|
+
|
|
135
|
+
const architectureValue = phase === 'verify'
|
|
136
|
+
? (permissions.health ? tools.verifyArchitecture(g, {}, ctx) : null)
|
|
137
|
+
: tools.prepareChange(g, {intent: args.task, files: changedFiles}, ctx)
|
|
138
|
+
const architectureResult = richResult(architectureValue)
|
|
139
|
+
const architecture = {state: architectureState(architectureResult), evidence: architectureResult}
|
|
140
|
+
const baseRef = String(args.base_ref || 'HEAD').trim()
|
|
141
|
+
const graph = phase === 'verify' ? await baselineProof(ctx, baseRef, currentGraph) : {state: 'PLANNED', baseline: baseRef}
|
|
142
|
+
|
|
143
|
+
let duplicates = {state: 'SKIPPED', reason: 'duplicate ratchet disabled'}
|
|
144
|
+
if (phase === 'verify' && args.duplicate_ratchet !== false) duplicates = permissions.health
|
|
145
|
+
? await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})
|
|
146
|
+
: {state: 'UNKNOWN', reason: 'health capability is not enabled'}
|
|
147
|
+
|
|
148
|
+
let api = {state: 'SKIPPED', reason: 'no api_contract scope was requested'}
|
|
149
|
+
if (args.api_contract) {
|
|
150
|
+
if (!permissions.crossrepo) api = {state: 'UNKNOWN', reason: 'crossrepo capability is not enabled'}
|
|
151
|
+
else {
|
|
152
|
+
const result = richResult(await tools.traceApi(g, {
|
|
153
|
+
...args.api_contract, changed_files: args.api_contract.changed_files || changedFiles,
|
|
154
|
+
max_endpoints: Math.min(100, Number(args.api_contract.max_endpoints) || 100),
|
|
155
|
+
max_matches: Math.min(500, Number(args.api_contract.max_matches) || 500),
|
|
156
|
+
max_affected_files: Math.min(100, Number(args.api_contract.max_affected_files) || 100),
|
|
157
|
+
top_n: Math.min(10, Number(args.api_contract.top_n) || 10),
|
|
158
|
+
}, ctx))
|
|
159
|
+
api = {state: apiState(result), evidence: result}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const decision = decide({phase, impact, graph, architecture, duplicates, api, tests: testProof, suggestedTests, testCoverageState})
|
|
164
|
+
if (!permissions.source && retrieval.selected.length) decision.unknowns.push('source capability is disabled; exact LSP/source edit contexts were not collected')
|
|
165
|
+
if (phase === 'verify' && contexts.some((item) => item.status !== 'OK' || item.evidence?.state !== 'EXACT' || item.references?.capped)) {
|
|
166
|
+
decision.unknowns.push('one or more exact edit contexts are incomplete')
|
|
167
|
+
}
|
|
168
|
+
if (phase === 'verify' && args.diff) decision.unknowns.push('a supplied diff was classified, but equivalence between that patch and the active graph is not proven; verify on a checked-out change without diff')
|
|
169
|
+
if (decision.verdict === 'PASS' && decision.unknowns.length) decision.verdict = 'UNKNOWN'
|
|
170
|
+
const result = {
|
|
171
|
+
schemaVersion: 'weavatrix.verified-change.v1', verdict: decision.verdict, phase, task: String(args.task),
|
|
172
|
+
blockers: decision.blockers, unknowns: decision.unknowns,
|
|
173
|
+
retrieval, editContexts: contexts, dataFlow, changeImpact: impact, graphBaseline: graph,
|
|
174
|
+
architecture, duplicates, apiContract: api, tests: {...testProof, suggestedFiles: suggestedTests, coverage: testCoverageState},
|
|
175
|
+
}
|
|
176
|
+
const text = [
|
|
177
|
+
`${decision.verdict} — verified_change ${phase}`, `Task: ${String(args.task).slice(0, 500)}`,
|
|
178
|
+
`Change: ${changedFiles.length} file(s), ${impact.seeds?.ids?.length || 0} exact seed(s), blast radius ${impact.blastRadius?.impacted || 0}.`,
|
|
179
|
+
`Edit context: ${retrieval.selected.length} symbol(s); ${contexts.length} exact bundle(s); data-flow ${dataFlow.status} (${dataFlow.edges.length} call edge(s)).`,
|
|
180
|
+
...exactUsageLines(contexts, g),
|
|
181
|
+
`Ratchets: graph ${graph.state}; architecture ${architecture.state}; duplicates ${duplicates.state}; API ${api.state}; tests ${testProof.state}.`,
|
|
182
|
+
...decision.blockers.map((item) => `BLOCKER: ${item}`), ...decision.unknowns.map((item) => `UNKNOWN: ${item}`),
|
|
183
|
+
].join('\n')
|
|
184
|
+
return toolResult(text, result, {completeness: {status: decision.verdict === 'UNKNOWN' ? 'PARTIAL' : 'COMPLETE'}})
|
|
185
|
+
}
|
|
@@ -46,7 +46,7 @@ const DEFAULT_RULES = [
|
|
|
46
46
|
{
|
|
47
47
|
category: "test",
|
|
48
48
|
pattern: "test/spec roots and conventional test filenames",
|
|
49
|
-
regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)
|
|
49
|
+
regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)test(?:_[^/]*)?\.py$/i,
|
|
50
50
|
},
|
|
51
51
|
{
|
|
52
52
|
category: "generated",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
precisionOverlayMatches,
|
|
8
8
|
precisionSemanticInputsMatch,
|
|
9
9
|
} from './lsp-overlay.js'
|
|
10
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
10
11
|
|
|
11
12
|
export const SYMBOL_PRECISION_CACHE_V = 1
|
|
12
13
|
export const SYMBOL_PRECISION_CACHE_FILE = 'precision-symbols.json'
|
|
@@ -79,6 +80,56 @@ function cacheable(overlay) {
|
|
|
79
80
|
&& overlay?.reason === 'semantic precision stopped at a configured safety limit'
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// Point-query results intentionally live outside the broad precision overlay. Health tools still
|
|
84
|
+
// need to consume revision-bound positive evidence: once an exact query found a reference, the
|
|
85
|
+
// same symbol must not remain in a dead-code queue. This reader is synchronous and fail-closed;
|
|
86
|
+
// stale, malformed, incomplete no-reference, or semantically changed entries contribute nothing.
|
|
87
|
+
export function readCachedSymbolPrecisionEvidence({repoRoot, graphPath, graph} = {}) {
|
|
88
|
+
const empty = {referenceSymbols: [], productionReferenceSymbols: [], testReferenceSymbols: [], noReferenceSymbols: []}
|
|
89
|
+
if (!repoRoot || !graphPath || !graph) return empty
|
|
90
|
+
const precisionGraph = graph.graphPrecisionMode === 'off' ? {...graph, graphPrecisionMode: 'lsp'} : graph
|
|
91
|
+
const nodesById = new Map((precisionGraph.nodes || []).map((node) => [String(node.id), node]))
|
|
92
|
+
const classifier = createPathClassifier(repoRoot)
|
|
93
|
+
const referenced = new Set()
|
|
94
|
+
const production = new Set()
|
|
95
|
+
const tests = new Set()
|
|
96
|
+
const noReference = new Set()
|
|
97
|
+
const semanticMatches = new Map()
|
|
98
|
+
for (const entry of readCache(symbolPrecisionCachePath(graphPath)).entries) {
|
|
99
|
+
const overlay = entry?.overlay
|
|
100
|
+
if (!overlay || !precisionOverlayMatches(overlay, precisionGraph, {request: overlay.request})) continue
|
|
101
|
+
const fingerprint = String(overlay.semanticInputFingerprint || '')
|
|
102
|
+
if (!semanticMatches.has(fingerprint)) semanticMatches.set(
|
|
103
|
+
fingerprint,
|
|
104
|
+
precisionSemanticInputsMatch(overlay, repoRoot, precisionGraph),
|
|
105
|
+
)
|
|
106
|
+
if (!semanticMatches.get(fingerprint)) continue
|
|
107
|
+
const targetId = String(entry.targetId || '')
|
|
108
|
+
if (!nodesById.has(targetId)) continue
|
|
109
|
+
const locations = Array.isArray(overlay.locations)
|
|
110
|
+
? overlay.locations.filter((location) => String(location?.target || '') === targetId)
|
|
111
|
+
: []
|
|
112
|
+
if (locations.length) {
|
|
113
|
+
referenced.add(targetId)
|
|
114
|
+
for (const location of locations) {
|
|
115
|
+
const file = String(location?.file || nodesById.get(String(location?.source || ''))?.source_file || '')
|
|
116
|
+
if (!file) continue
|
|
117
|
+
const info = classifier.explain(file, {content: ''})
|
|
118
|
+
if (hasPathClass(info, 'test', 'e2e')) tests.add(targetId)
|
|
119
|
+
else production.add(targetId)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (overlay.state === 'COMPLETE' && Array.isArray(overlay.noReferenceSymbols)
|
|
123
|
+
&& overlay.noReferenceSymbols.some((id) => String(id) === targetId)) noReference.add(targetId)
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
referenceSymbols: [...referenced],
|
|
127
|
+
productionReferenceSymbols: [...production],
|
|
128
|
+
testReferenceSymbols: [...tests],
|
|
129
|
+
noReferenceSymbols: [...noReference],
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
82
133
|
export async function querySymbolPrecision({
|
|
83
134
|
repoRoot,
|
|
84
135
|
graphPath,
|
|
@@ -8,7 +8,7 @@ const URL_RE = /https?:\/\/[^\s'"`)\\<>]+/gi;
|
|
|
8
8
|
const PACKAGE_JSON_SCRIPT_RE = /["']?(preinstall|install|postinstall|prepare|prepack|postpack|prepublish|prepublishonly)["']?\s*:/i;
|
|
9
9
|
const PACKAGE_JSON_METADATA_URL_RE = /["']?(bugs|homepage|repository|funding|author|contributors|maintainers|publishConfig|license)["']?\s*:|["']?url["']?\s*:/i;
|
|
10
10
|
const NETWORK_CALL_RE = /\b(fetch|axios\.|XMLHttpRequest|sendBeacon|https?\.request|request\(|curl\b|wget\b|iwr\b|invoke-webrequest)\b/i;
|
|
11
|
-
const
|
|
11
|
+
const INSTALL_SCRIPT_HOOKS = ["preinstall", "install", "postinstall"];
|
|
12
12
|
|
|
13
13
|
function cleanUrlToken(value) {
|
|
14
14
|
return String(value || "").trim().replace(/[.,;:!?]+$/g, "").replace(/[)\]}]+$/g, "");
|
|
@@ -102,8 +102,8 @@ export function isManifestUrlNoise(file, signal) {
|
|
|
102
102
|
return /(^|\/)(pkg-info|metadata)$/.test(f);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function
|
|
106
|
-
for (const hook of
|
|
105
|
+
export function installScriptSnippet(scripts = {}) {
|
|
106
|
+
for (const hook of INSTALL_SCRIPT_HOOKS) {
|
|
107
107
|
const s = String(scripts?.[hook] || "").trim();
|
|
108
108
|
if (s) return `${hook}: ${s}`.slice(0, 200);
|
|
109
109
|
}
|
|
@@ -109,7 +109,10 @@ export function buildContentRoots(repoPath, installed) {
|
|
|
109
109
|
const roots = [];
|
|
110
110
|
const nmDir = join(repoPath, "node_modules");
|
|
111
111
|
const jsPkgDirs = existingDir(nmDir) ? listPkgDirs(nmDir) : [];
|
|
112
|
-
|
|
112
|
+
// Scan installed package roots, not the whole node_modules container. Package-manager caches,
|
|
113
|
+
// hosted release snapshots and other hidden metadata are not installed dependencies and may
|
|
114
|
+
// legitimately contain malware-signature fixtures (including Weavatrix's own rule source).
|
|
115
|
+
for (const item of jsPkgDirs) roots.push({ kind: "npm", root: item.dir, packages: 1, pathToPkg: pkgOfNodeModulesPath });
|
|
113
116
|
|
|
114
117
|
for (const sp of sitePackageRoots(repoPath)) {
|
|
115
118
|
const topMap = pyTopLevelMap(sp);
|
|
@@ -6,7 +6,7 @@ import { resolveRg } from "../scan/search.js";
|
|
|
6
6
|
import { classifyInstallScript, classifyResolvedUrl, isCloudSdkMetadataUse } from "./registry-sig.js";
|
|
7
7
|
import { fileHeuristicSweep } from "./malware-file-heuristics.js";
|
|
8
8
|
import { buildMalwareFindings } from "./malware-scoring.js";
|
|
9
|
-
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded,
|
|
9
|
+
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded, installScriptSnippet } from "./malware-heuristics.exclusions.js";
|
|
10
10
|
import { buildContentRoots, listPkgDirs } from "./malware-heuristics.roots.js";
|
|
11
11
|
import { rgSweep, nodeSweep } from "./malware-heuristics.sweep.js";
|
|
12
12
|
|
|
@@ -37,7 +37,9 @@ export async function scanMalware(repoPath, { installed = [], importedPkgs = new
|
|
|
37
37
|
for (const { pkg, dir } of pkgDirs) {
|
|
38
38
|
try {
|
|
39
39
|
const pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
40
|
-
|
|
40
|
+
// package-lock hasInstallScript describes install-time hooks. Development and
|
|
41
|
+
// publication hooks such as prepare do not constitute drift from that flag.
|
|
42
|
+
const scriptSnippet = installScriptSnippet(pj.scripts);
|
|
41
43
|
if (scriptSnippet && lockScriptMeta.get(`${pkg}@${pj.version || ""}`) === false) {
|
|
42
44
|
add(pkg, {
|
|
43
45
|
key: "install-script-drift",
|