weavatrix 0.2.5 → 0.2.7
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 +77 -14
- package/SECURITY.md +14 -0
- package/docs/agent-task-benchmark.md +58 -0
- package/docs/releases/v0.2.7.md +93 -0
- package/package.json +6 -1
- package/scripts/run-agent-task-benchmark.mjs +119 -0
- package/skill/SKILL.md +31 -10
- package/src/analysis/allowed-test-runner.js +59 -0
- package/src/analysis/data-flow-evidence.js +114 -0
- package/src/analysis/dead-code-review.js +51 -0
- package/src/analysis/dep-check.js +42 -3
- package/src/analysis/duplicate-groups.js +84 -0
- package/src/analysis/graph-analysis.aggregate.js +3 -1
- package/src/analysis/graph-analysis.refs.js +19 -11
- package/src/analysis/hot-path-review.js +20 -2
- package/src/analysis/internal-audit.run.js +6 -1
- package/src/analysis/task-retrieval.js +116 -0
- package/src/bounds.js +4 -0
- package/src/graph/builder/lang-js.js +52 -17
- package/src/graph/freshness-probe.js +4 -2
- package/src/graph/incremental-refresh.js +4 -2
- package/src/graph/internal-builder.barrels.js +39 -2
- package/src/graph/internal-builder.build.js +58 -12
- package/src/mcp/catalog.mjs +28 -6
- package/src/mcp/graph-context.mjs +5 -1
- package/src/mcp/graph-diff.mjs +1 -1
- package/src/mcp/sync-payload.mjs +1 -1
- package/src/mcp/tools-actions.mjs +3 -1
- package/src/mcp/tools-context.mjs +164 -0
- package/src/mcp/tools-graph.mjs +48 -3
- package/src/mcp/tools-health.mjs +15 -28
- package/src/mcp/tools-impact-change.mjs +1 -0
- package/src/mcp/tools-source.mjs +7 -6
- package/src/mcp/tools-verified-change.mjs +169 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +1 -5
- package/src/precision/symbol-query.js +1 -5
- 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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {existsSync, readFileSync} from 'node:fs'
|
|
2
|
+
import {join} from 'node:path'
|
|
3
|
+
import {createRepoBoundary} from '../repo-path.js'
|
|
4
|
+
import {runCommand} from '../process.js'
|
|
5
|
+
|
|
6
|
+
const SAFE_SCRIPT = /^(?:test(?::|$)|(?:check|verify)(?::|$)|[^:]+:(?:test|check|verify)(?::|$))/i
|
|
7
|
+
const UNSAFE_SHELL_ARG = /[\0\r\n&|<>^%!`\"]/
|
|
8
|
+
const tail = (value, limit = 8000) => String(value || '').slice(-limit)
|
|
9
|
+
|
|
10
|
+
function manifestAt(repoRoot) {
|
|
11
|
+
const resolved = createRepoBoundary(repoRoot).resolve('package.json')
|
|
12
|
+
if (!resolved.ok) return null
|
|
13
|
+
try { return JSON.parse(readFileSync(resolved.path, 'utf8')) } catch { return null }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function packageManager(repoRoot) {
|
|
17
|
+
if (existsSync(join(repoRoot, 'pnpm-lock.yaml'))) return 'pnpm'
|
|
18
|
+
if (existsSync(join(repoRoot, 'yarn.lock'))) return 'yarn'
|
|
19
|
+
return 'npm'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateTestRequests(repoRoot, requests = []) {
|
|
23
|
+
const manifest = manifestAt(repoRoot)
|
|
24
|
+
if (!manifest) return {ok: false, reason: 'package.json is missing or unreadable', tests: []}
|
|
25
|
+
const tests = []
|
|
26
|
+
for (const request of requests.slice(0, 5)) {
|
|
27
|
+
const script = String(request?.script || '')
|
|
28
|
+
const args = Array.isArray(request?.args) ? request.args.slice(0, 40).map(String) : []
|
|
29
|
+
if (!SAFE_SCRIPT.test(script)) return {ok: false, reason: `script ${script || '(missing)'} is outside the test/check/verify allowlist`, tests}
|
|
30
|
+
if (!Object.hasOwn(manifest.scripts || {}, script)) return {ok: false, reason: `package.json has no script named ${script}`, tests}
|
|
31
|
+
if (args.some((arg) => arg.length > 300 || UNSAFE_SHELL_ARG.test(arg))) return {ok: false, reason: `script ${script} has an invalid or shell-sensitive argument`, tests}
|
|
32
|
+
tests.push({script, args})
|
|
33
|
+
}
|
|
34
|
+
return {ok: true, tests, packageManager: packageManager(repoRoot)}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runAllowedTests(repoRoot, requests = [], {enabled = false, timeoutMs = 60_000} = {}) {
|
|
38
|
+
const checked = validateTestRequests(repoRoot, requests)
|
|
39
|
+
if (!checked.ok) return {state: 'BLOCKED', reason: checked.reason, results: []}
|
|
40
|
+
if (!checked.tests.length) return {state: 'NOT_REQUESTED', reason: 'no package scripts were requested', results: []}
|
|
41
|
+
if (!enabled || process.env.WEAVATRIX_ALLOW_TEST_RUNS !== '1') return {
|
|
42
|
+
state: 'DISABLED', reason: 'set WEAVATRIX_ALLOW_TEST_RUNS=1 and pass run_tests:true to execute allowlisted package scripts',
|
|
43
|
+
plan: checked.tests, results: [],
|
|
44
|
+
}
|
|
45
|
+
const results = []
|
|
46
|
+
const timeout = Math.max(1000, Math.min(300_000, Number(timeoutMs) || 60_000))
|
|
47
|
+
for (const test of checked.tests) {
|
|
48
|
+
const start = Date.now()
|
|
49
|
+
const separator = checked.packageManager === 'yarn' ? [] : ['--']
|
|
50
|
+
try {
|
|
51
|
+
const run = await runCommand(checked.packageManager, ['run', test.script, ...separator, ...test.args], {cwd: repoRoot, timeoutMs: timeout})
|
|
52
|
+
results.push({script: test.script, status: run.exitCode === 0 ? 'PASS' : 'FAIL', exitCode: run.exitCode, durationMs: Date.now() - start, stdoutTail: tail(run.stdout), stderrTail: tail(run.stderr)})
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
55
|
+
results.push({script: test.script, status: /timed out/i.test(message) ? 'TIMEOUT' : 'FAIL', exitCode: null, durationMs: Date.now() - start, stdoutTail: '', stderrTail: tail(message)})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {state: results.every((result) => result.status === 'PASS') ? 'PASS' : 'FAIL', packageManager: checked.packageManager, results}
|
|
59
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs'
|
|
2
|
+
import {extname} from 'node:path'
|
|
3
|
+
import ts from 'typescript'
|
|
4
|
+
import {createRepoBoundary} from '../repo-path.js'
|
|
5
|
+
|
|
6
|
+
const lineOfNode = (node) => Number(String(node?.source_location || '').match(/^L(\d+)/i)?.[1] || String(node?.id || '').match(/@(\d+)$/)?.[1] || 0)
|
|
7
|
+
const fileOfNode = (node) => String(node?.source_file || String(node?.id || '').split('#')[0]).replace(/\\/g, '/')
|
|
8
|
+
const cleanName = (value) => String(value || '').replace(/\(\)$/, '').split('.').pop()
|
|
9
|
+
const callName = (node) => node.expression?.name?.text || node.expression?.text || node.expression?.escapedText || ''
|
|
10
|
+
|
|
11
|
+
function scriptKind(file) {
|
|
12
|
+
const ext = extname(file).toLowerCase()
|
|
13
|
+
if (ext === '.tsx') return ts.ScriptKind.TSX
|
|
14
|
+
if (ext === '.jsx') return ts.ScriptKind.JSX
|
|
15
|
+
if (ext === '.ts' || ext === '.mts' || ext === '.cts') return ts.ScriptKind.TS
|
|
16
|
+
return ts.ScriptKind.JS
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parse(cache, boundary, file) {
|
|
20
|
+
if (cache.has(file)) return cache.get(file)
|
|
21
|
+
if (!/\.[cm]?[jt]sx?$/i.test(file)) return null
|
|
22
|
+
const resolved = boundary.resolve(file)
|
|
23
|
+
if (!resolved.ok) return null
|
|
24
|
+
try {
|
|
25
|
+
const source = ts.createSourceFile(file, readFileSync(resolved.path, 'utf8'), ts.ScriptTarget.Latest, true, scriptKind(file))
|
|
26
|
+
cache.set(file, source)
|
|
27
|
+
return source
|
|
28
|
+
} catch { return null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parametersFor(source, target) {
|
|
32
|
+
const expected = cleanName(target?.label)
|
|
33
|
+
const expectedLine = lineOfNode(target)
|
|
34
|
+
const choices = []
|
|
35
|
+
const visit = (node) => {
|
|
36
|
+
if (Array.isArray(node.parameters) && node.name) {
|
|
37
|
+
const name = cleanName(node.name.text || node.name.getText?.(source))
|
|
38
|
+
if (!expected || name === expected) {
|
|
39
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
|
|
40
|
+
choices.push({line, parameters: node.parameters.map((item) => item.name.getText(source).slice(0, 80))})
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ts.forEachChild(node, visit)
|
|
44
|
+
}
|
|
45
|
+
visit(source)
|
|
46
|
+
choices.sort((a, b) => Math.abs(a.line - expectedLine) - Math.abs(b.line - expectedLine))
|
|
47
|
+
return choices[0]?.parameters || []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function callsAt(source, line, expectedName) {
|
|
51
|
+
const found = []
|
|
52
|
+
const visit = (node) => {
|
|
53
|
+
if (ts.isCallExpression(node)) {
|
|
54
|
+
const at = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
|
|
55
|
+
if ((!line || at === line) && (!expectedName || cleanName(callName(node)) === expectedName)) found.push({node, line: at})
|
|
56
|
+
}
|
|
57
|
+
ts.forEachChild(node, visit)
|
|
58
|
+
}
|
|
59
|
+
visit(source)
|
|
60
|
+
return found
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Bounded interprocedural evidence: maps arguments at graph callsites to the callee's declared
|
|
64
|
+
// parameters. This is not a control-flow graph, value propagation, or taint-analysis claim.
|
|
65
|
+
export function extractCallArgumentEvidence({graph, repoRoot, seedIds = [], depth = 2, maxEdges = 40} = {}) {
|
|
66
|
+
const boundary = createRepoBoundary(repoRoot)
|
|
67
|
+
const cache = new Map()
|
|
68
|
+
const queue = (seedIds || []).map((id) => ({id: String(id), hop: 0}))
|
|
69
|
+
const visited = new Set()
|
|
70
|
+
const seenCalls = new Set()
|
|
71
|
+
const edges = []
|
|
72
|
+
let unsupported = 0
|
|
73
|
+
while (queue.length && edges.length < maxEdges) {
|
|
74
|
+
const current = queue.shift()
|
|
75
|
+
if (visited.has(current.id) || current.hop >= depth) continue
|
|
76
|
+
visited.add(current.id)
|
|
77
|
+
const calls = [
|
|
78
|
+
...(graph.out.get(current.id) || []).filter((edge) => edge.relation === 'calls')
|
|
79
|
+
.map((edge) => ({fromId: current.id, toId: String(edge.id), line: Number(edge.line) || 0, nextId: String(edge.id)})),
|
|
80
|
+
...(graph.inn.get(current.id) || []).filter((edge) => edge.relation === 'calls')
|
|
81
|
+
.map((edge) => ({fromId: String(edge.id), toId: current.id, line: Number(edge.line) || 0, nextId: String(edge.id)})),
|
|
82
|
+
]
|
|
83
|
+
for (const callEdge of calls) {
|
|
84
|
+
const callKey = `${callEdge.fromId}\0${callEdge.toId}\0${callEdge.line}`
|
|
85
|
+
if (seenCalls.has(callKey)) continue
|
|
86
|
+
seenCalls.add(callKey)
|
|
87
|
+
const from = graph.byId.get(callEdge.fromId)
|
|
88
|
+
const to = graph.byId.get(callEdge.toId)
|
|
89
|
+
const callerSource = parse(cache, boundary, fileOfNode(from))
|
|
90
|
+
const targetSource = parse(cache, boundary, fileOfNode(to))
|
|
91
|
+
if (!from || !to || !callerSource || !targetSource) { unsupported++; continue }
|
|
92
|
+
const params = parametersFor(targetSource, to)
|
|
93
|
+
const callsites = callsAt(callerSource, callEdge.line, cleanName(to.label))
|
|
94
|
+
if (!callsites.length) { unsupported++; continue }
|
|
95
|
+
for (const call of callsites.slice(0, 3)) {
|
|
96
|
+
edges.push({
|
|
97
|
+
from: callEdge.fromId, to: callEdge.toId, hop: current.hop + 1,
|
|
98
|
+
file: fileOfNode(from), line: call.line,
|
|
99
|
+
arguments: call.node.arguments.slice(0, 12).map((argument, index) => ({
|
|
100
|
+
index, expression: argument.getText(callerSource).slice(0, 160), parameter: params[index] || null,
|
|
101
|
+
})),
|
|
102
|
+
state: 'EXTRACTED',
|
|
103
|
+
})
|
|
104
|
+
if (edges.length >= maxEdges) break
|
|
105
|
+
}
|
|
106
|
+
if (current.hop + 1 < depth) queue.push({id: callEdge.nextId, hop: current.hop + 1})
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
model: 'bounded call-argument-to-parameter evidence (not CFG or taint analysis)',
|
|
111
|
+
status: edges.length ? (unsupported ? 'PARTIAL' : 'COMPLETE') : 'UNAVAILABLE',
|
|
112
|
+
edges, unsupportedEdges: unsupported, capped: edges.length >= maxEdges,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -84,6 +84,18 @@ function symbolCandidate(item, node, context) {
|
|
|
84
84
|
caveats.push("Reflection is present and may invoke names without a resolvable static edge.");
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
const evidenceTier = confidence === "high" && exactNoReference
|
|
88
|
+
? "STRONG_STATIC_EVIDENCE"
|
|
89
|
+
: confidence === "low" ? "HIGH_UNCERTAINTY" : "BOUNDED_STATIC_EVIDENCE";
|
|
90
|
+
const remainingChecks = [
|
|
91
|
+
...(!exactNoReference ? ["Run an exact language-server reference query for this declaration."] : []),
|
|
92
|
+
...(publicSurface ? ["Check downstream/external consumers of the public API."] : []),
|
|
93
|
+
...(externalEntry || framework ? ["Inspect framework registration and externally entered call paths."] : []),
|
|
94
|
+
...(dynamicFile ? ["Resolve dynamic import/require targets and name-based dispatch."] : []),
|
|
95
|
+
...(reflectionFile || (publicSurface && context.repoSignals.reflection) ? ["Inspect reflection/annotation/configuration consumers."] : []),
|
|
96
|
+
"Run targeted tests after any removal.",
|
|
97
|
+
];
|
|
98
|
+
|
|
87
99
|
return {
|
|
88
100
|
id: String(item.id),
|
|
89
101
|
kind,
|
|
@@ -95,6 +107,8 @@ function symbolCandidate(item, node, context) {
|
|
|
95
107
|
symbolKind: node?.symbol_kind || null,
|
|
96
108
|
visibility: node?.visibility || (node?.exported ? "exported" : "internal"),
|
|
97
109
|
confidence,
|
|
110
|
+
evidenceTier,
|
|
111
|
+
actionability: evidenceTier === "STRONG_STATIC_EVIDENCE" ? "PRIORITY_MANUAL_REVIEW" : "MANUAL_REVIEW",
|
|
98
112
|
reason: item.reason,
|
|
99
113
|
evidence: [
|
|
100
114
|
...(testOnly ? [{kind: item.evidence || "graph", fact: `Only test/e2e consumers were found${item.testConsumerFiles?.length ? `: ${item.testConsumerFiles.join(", ")}` : "."}`}]
|
|
@@ -106,6 +120,18 @@ function symbolCandidate(item, node, context) {
|
|
|
106
120
|
externallyEnteredFile: externalEntry,
|
|
107
121
|
pathClasses: pathInfo.classes || [],
|
|
108
122
|
matchedPathRule: pathInfo.matchedRule || null,
|
|
123
|
+
verification: {
|
|
124
|
+
graphInboundRuntimeEdge: "NOT_FOUND",
|
|
125
|
+
indexedSecondOccurrence: testOnly ? "TEST_ONLY" : "NOT_FOUND",
|
|
126
|
+
exactLanguageServerReferences: exactNoReference ? "ZERO_CONFIRMED" : "NOT_CHECKED_OR_INCOMPLETE",
|
|
127
|
+
recognizedEntryPoint: externalEntry ? "FOUND" : "NOT_FOUND",
|
|
128
|
+
dynamicLoadingRisk: dynamicFile ? "PRESENT" : "NOT_OBSERVED_IN_DECLARING_FILE",
|
|
129
|
+
reflectionRisk: reflectionFile || (publicSurface && context.repoSignals.reflection) ? "PRESENT" : "NOT_OBSERVED",
|
|
130
|
+
publicApi: publicSurface ? "YES" : "NO",
|
|
131
|
+
decision: "MANUAL_REVIEW_REQUIRED",
|
|
132
|
+
},
|
|
133
|
+
remainingChecks,
|
|
134
|
+
autoDelete: false,
|
|
109
135
|
reviewAction: testOnly
|
|
110
136
|
? "Confirm that no production/config/framework consumer exists; decide whether the declaration is intentional test support or removable with its tests. Never auto-delete."
|
|
111
137
|
: "Confirm with read_source, get_dependents, exact search, framework/config inspection and tests; never auto-delete.",
|
|
@@ -135,6 +161,13 @@ function fileCandidate(item, symbols, context) {
|
|
|
135
161
|
confidence = "low";
|
|
136
162
|
caveats.push("Reflection is present in this file.");
|
|
137
163
|
}
|
|
164
|
+
const remainingChecks = [
|
|
165
|
+
"Inspect package scripts, manifests, framework/plugin discovery and deployment configuration.",
|
|
166
|
+
"Check external launchers and consumers outside the indexed repository.",
|
|
167
|
+
...(dynamicFile ? ["Resolve dynamic import/require targets."] : []),
|
|
168
|
+
...(reflectionFile ? ["Inspect reflection/name-based consumers."] : []),
|
|
169
|
+
"Run targeted tests after any removal.",
|
|
170
|
+
];
|
|
138
171
|
return {
|
|
139
172
|
id: `file:${file}`,
|
|
140
173
|
kind: "file",
|
|
@@ -146,6 +179,8 @@ function fileCandidate(item, symbols, context) {
|
|
|
146
179
|
symbolKind: null,
|
|
147
180
|
visibility: null,
|
|
148
181
|
confidence,
|
|
182
|
+
evidenceTier: confidence === "low" ? "HIGH_UNCERTAINTY" : "BOUNDED_STATIC_EVIDENCE",
|
|
183
|
+
actionability: "MANUAL_REVIEW",
|
|
149
184
|
reason: item.reason,
|
|
150
185
|
evidence: [
|
|
151
186
|
{ kind: "graph", fact: "No indexed module imports this file." },
|
|
@@ -156,6 +191,17 @@ function fileCandidate(item, symbols, context) {
|
|
|
156
191
|
externallyEnteredFile: false,
|
|
157
192
|
pathClasses: pathInfo.classes || [],
|
|
158
193
|
matchedPathRule: pathInfo.matchedRule || null,
|
|
194
|
+
verification: {
|
|
195
|
+
graphInboundModuleEdge: "NOT_FOUND",
|
|
196
|
+
indexedSymbolsReferenced: "NONE",
|
|
197
|
+
recognizedEntryPoint: "NOT_FOUND",
|
|
198
|
+
dynamicLoadingRisk: dynamicFile ? "PRESENT" : "NOT_OBSERVED",
|
|
199
|
+
reflectionRisk: reflectionFile ? "PRESENT" : "NOT_OBSERVED",
|
|
200
|
+
externalConsumerCheck: "NOT_POSSIBLE_FROM_REPOSITORY_GRAPH",
|
|
201
|
+
decision: "MANUAL_REVIEW_REQUIRED",
|
|
202
|
+
},
|
|
203
|
+
remainingChecks,
|
|
204
|
+
autoDelete: false,
|
|
159
205
|
reviewAction: "Verify package scripts, manifests, framework discovery, dynamic loading and external consumers; never auto-delete.",
|
|
160
206
|
};
|
|
161
207
|
}
|
|
@@ -247,6 +293,11 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
|
|
|
247
293
|
medium: candidates.filter((candidate) => candidate.confidence === "medium").length,
|
|
248
294
|
low: candidates.filter((candidate) => candidate.confidence === "low").length,
|
|
249
295
|
},
|
|
296
|
+
byEvidenceTier: {
|
|
297
|
+
strongStatic: candidates.filter((candidate) => candidate.evidenceTier === "STRONG_STATIC_EVIDENCE").length,
|
|
298
|
+
boundedStatic: candidates.filter((candidate) => candidate.evidenceTier === "BOUNDED_STATIC_EVIDENCE").length,
|
|
299
|
+
highUncertainty: candidates.filter((candidate) => candidate.evidenceTier === "HIGH_UNCERTAINTY").length,
|
|
300
|
+
},
|
|
250
301
|
},
|
|
251
302
|
policy: {
|
|
252
303
|
verdict: "REVIEW_REQUIRED",
|
|
@@ -167,11 +167,24 @@ export function computeDepFindings({
|
|
|
167
167
|
severity: dev ? "info" : "low",
|
|
168
168
|
confidence: dev || ecosystem ? "low" : "medium",
|
|
169
169
|
title: `Unused ${section === "dependencies" ? "dependency" : section.replace(/ies$/, "y")}: ${name}`,
|
|
170
|
-
reason: "No
|
|
171
|
-
detail: `"${name}" is declared in ${section} but
|
|
170
|
+
reason: "No indexed package import, package-script command, recognized config mention, framework peer contract, or implicit style-compiler input uses this declaration.",
|
|
171
|
+
detail: `"${name}" is declared in ${section}, but no usage was found in the indexed source scope, package scripts, or ${configTexts.size} known config file(s). Dynamic/plugin/config-convention usage is not proven absent — review before removing.`,
|
|
172
172
|
package: name,
|
|
173
173
|
source: "internal",
|
|
174
|
-
|
|
174
|
+
actionability: "MANIFEST_REVIEW_REQUIRED",
|
|
175
|
+
autoRemove: false,
|
|
176
|
+
verification: {
|
|
177
|
+
evidenceModel: "MANIFEST_PLUS_INDEXED_SOURCE",
|
|
178
|
+
manifestDeclaration: { status: "FOUND", file: manifest, section, version: String(deps[name] || "") },
|
|
179
|
+
indexedSourceImports: { status: "ZERO_FOUND", completeness: "COMPLETE_FOR_GRAPH_SCOPE", count: 0 },
|
|
180
|
+
packageScripts: { status: "CHECKED", matched: false },
|
|
181
|
+
knownConfigs: { status: "CHECKED", filesScanned: configTexts.size, matched: false },
|
|
182
|
+
frameworkPeerContract: "NOT_FOUND",
|
|
183
|
+
implicitCompilerInput: "NOT_FOUND",
|
|
184
|
+
dynamicOrPluginUsage: "NOT_PROVEN_ABSENT",
|
|
185
|
+
decision: "REVIEW_REQUIRED",
|
|
186
|
+
},
|
|
187
|
+
fixHint: `Review manifest/config/plugin usage; if confirmed unused, remove ${name} with the package manager and run targeted tests`,
|
|
175
188
|
...meta,
|
|
176
189
|
}));
|
|
177
190
|
}
|
|
@@ -202,6 +215,25 @@ export function computeDepFindings({
|
|
|
202
215
|
line: use.lines.get(files[0]) || 0,
|
|
203
216
|
evidence: files.slice(0, 5).map((f) => ({ file: f, line: use.lines.get(f) || 0, snippet: "" })),
|
|
204
217
|
source: "internal",
|
|
218
|
+
actionability: "STRONG_MANIFEST_MISMATCH",
|
|
219
|
+
autoInstall: false,
|
|
220
|
+
verification: {
|
|
221
|
+
evidenceModel: "MANIFEST_PLUS_INDEXED_SOURCE",
|
|
222
|
+
manifestDeclaration: { status: "NOT_FOUND", file: manifest, checkedSections: Object.keys(sections) },
|
|
223
|
+
indexedSourceImports: {
|
|
224
|
+
status: "FOUND",
|
|
225
|
+
completeness: "COMPLETE_FOR_GRAPH_SCOPE",
|
|
226
|
+
count: files.length,
|
|
227
|
+
files: files.slice(0, 20),
|
|
228
|
+
truncated: files.length > 20,
|
|
229
|
+
typeOnly: use.typeOnly,
|
|
230
|
+
stylesheetOnly,
|
|
231
|
+
testOnly,
|
|
232
|
+
},
|
|
233
|
+
packageScope: meta.scope,
|
|
234
|
+
transitiveResolutionRisk: "PRESENT",
|
|
235
|
+
decision: "DECLARE_AFTER_SCOPE_REVIEW",
|
|
236
|
+
},
|
|
205
237
|
fixHint: `npm install ${testOnly ? "--save-dev " : ""}${name}`,
|
|
206
238
|
...meta,
|
|
207
239
|
}));
|
|
@@ -223,6 +255,13 @@ export function computeDepFindings({
|
|
|
223
255
|
detail: `"${name}" is declared in ${ss.join(" + ")} — npm resolves one of them; keep a single section.`,
|
|
224
256
|
package: name,
|
|
225
257
|
source: "internal",
|
|
258
|
+
actionability: "MANIFEST_REVIEW_REQUIRED",
|
|
259
|
+
autoRemove: false,
|
|
260
|
+
verification: {
|
|
261
|
+
evidenceModel: "MANIFEST_ONLY",
|
|
262
|
+
manifestDeclaration: { status: "DUPLICATE", file: manifest, sections: ss },
|
|
263
|
+
decision: "REVIEW_REQUIRED",
|
|
264
|
+
},
|
|
226
265
|
...meta,
|
|
227
266
|
}));
|
|
228
267
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto'
|
|
2
|
+
import {writeFileSync} from 'node:fs'
|
|
3
|
+
import {join} from 'node:path'
|
|
4
|
+
import {computeDuplicates} from './duplicates.js'
|
|
5
|
+
import {withGitRefCheckout} from './git-ref-graph.js'
|
|
6
|
+
import {buildInternalGraph} from '../graph/internal-builder.js'
|
|
7
|
+
import {filterGraphForMode} from '../graph/graph-filter.js'
|
|
8
|
+
|
|
9
|
+
const NON_PRODUCT = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
10
|
+
const eligible = (fragment, settings) => {
|
|
11
|
+
const classes = new Set(fragment.classes || [])
|
|
12
|
+
if (fragment.n < settings.tokMin) return false
|
|
13
|
+
if (settings.skipTests && (fragment.test || classes.has('test') || classes.has('e2e'))) return false
|
|
14
|
+
return settings.includeClassified || !(fragment.excluded || [...classes].some((name) => NON_PRODUCT.has(name)))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function groupPairs(data, settings) {
|
|
18
|
+
const fragments = data.frags || []
|
|
19
|
+
const pairs = (data.modes?.[settings.mode] || []).filter(([a, b, similarity]) => similarity >= settings.simMin && eligible(fragments[a], settings) && eligible(fragments[b], settings))
|
|
20
|
+
const parent = new Map()
|
|
21
|
+
const find = (value) => { let root = value; while (parent.get(root) !== root) root = parent.get(root); return root }
|
|
22
|
+
for (const [a, b] of pairs) {
|
|
23
|
+
if (!parent.has(a)) parent.set(a, a)
|
|
24
|
+
if (!parent.has(b)) parent.set(b, b)
|
|
25
|
+
parent.set(find(a), find(b))
|
|
26
|
+
}
|
|
27
|
+
const groups = new Map()
|
|
28
|
+
for (const [a, b, similarity] of pairs) {
|
|
29
|
+
const root = find(a)
|
|
30
|
+
if (!groups.has(root)) groups.set(root, {members: new Set(), maxSim: 0})
|
|
31
|
+
const group = groups.get(root)
|
|
32
|
+
group.members.add(a); group.members.add(b); group.maxSim = Math.max(group.maxSim, similarity)
|
|
33
|
+
}
|
|
34
|
+
return [...groups.values()].map((group) => {
|
|
35
|
+
const members = [...group.members].map((index) => fragments[index]).sort((a, b) => b.n - a.n)
|
|
36
|
+
return {members, maxSim: group.maxSim, tokens: members.reduce((sum, item) => sum + item.n, 0)}
|
|
37
|
+
}).sort((a, b) => b.tokens - a.tokens)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function analyzeDuplicateGroups(repoRoot, graphPath, args = {}) {
|
|
41
|
+
const settings = {
|
|
42
|
+
simMin: Math.min(100, Math.max(50, Number(args.min_similarity) || 80)),
|
|
43
|
+
tokMin: Math.min(400, Math.max(12, Number(args.min_tokens) || 50)),
|
|
44
|
+
mode: args.mode === 'strict' ? 'strict' : 'renamed',
|
|
45
|
+
skipTests: args.include_tests !== true,
|
|
46
|
+
includeClassified: args.include_classified === true || args.include_non_product === true,
|
|
47
|
+
}
|
|
48
|
+
const data = computeDuplicates(repoRoot, graphPath, {includeStrings: args.include_strings === true, minTokens: settings.tokMin})
|
|
49
|
+
const groups = groupPairs(data, settings)
|
|
50
|
+
const suppressed = (data.frags || []).filter((fragment) => !eligible(fragment, settings)).length
|
|
51
|
+
return {settings, groups, suppressed}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex').slice(0, 20)
|
|
55
|
+
function groupKey(group, mode) {
|
|
56
|
+
const members = group.members.map((fragment) => {
|
|
57
|
+
const fingerprints = Array.isArray(fragment.fp?.[mode]) ? [...fragment.fp[mode]].sort() : []
|
|
58
|
+
return `${String(fragment.file).replace(/\\/g, '/')}\0${fragment.label || ''}\0${digest(JSON.stringify(fingerprints))}`
|
|
59
|
+
}).sort()
|
|
60
|
+
return digest(members.join('\n'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function compareDuplicateGroups({repoRoot, graphPath, currentGraph, baseRef, changedFiles = [], args = {}}) {
|
|
64
|
+
const current = analyzeDuplicateGroups(repoRoot, graphPath, args)
|
|
65
|
+
const mode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode) ? currentGraph.graphBuildMode : 'full'
|
|
66
|
+
const baseline = await withGitRefCheckout(repoRoot, baseRef, async (checkout) => {
|
|
67
|
+
let graph = await buildInternalGraph(checkout)
|
|
68
|
+
if (mode !== 'full') graph = filterGraphForMode(graph, mode, {repoRoot: checkout})
|
|
69
|
+
graph.graphBuildMode = mode
|
|
70
|
+
const baselineGraphPath = join(checkout, '.weavatrix-verified-graph.json')
|
|
71
|
+
writeFileSync(baselineGraphPath, JSON.stringify(graph))
|
|
72
|
+
return analyzeDuplicateGroups(checkout, baselineGraphPath, args)
|
|
73
|
+
})
|
|
74
|
+
if (!baseline.ok) return {state: 'UNKNOWN', reason: baseline.error, currentGroups: current.groups.length}
|
|
75
|
+
const baselineKeys = new Set(baseline.value.groups.map((group) => groupKey(group, current.settings.mode)))
|
|
76
|
+
const changed = new Set(changedFiles.map((file) => String(file).replace(/\\/g, '/')))
|
|
77
|
+
const added = current.groups.filter((group) => !baselineKeys.has(groupKey(group, current.settings.mode)))
|
|
78
|
+
const scoped = added.filter((group) => group.members.some((member) => changed.has(String(member.file).replace(/\\/g, '/'))))
|
|
79
|
+
return {
|
|
80
|
+
state: scoped.length ? 'BLOCKED' : 'PASS', baseline: {ref: baseline.ref, commit: baseline.commit},
|
|
81
|
+
currentGroups: current.groups.length, baselineGroups: baseline.value.groups.length, newGroups: added.length,
|
|
82
|
+
scopedNewGroups: scoped.slice(0, 20).map((group) => ({similarity: group.maxSim, tokens: group.tokens, members: group.members.slice(0, 8).map((item) => ({file: item.file, start: item.start, end: item.end, label: item.label}))})),
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -63,7 +63,9 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
63
63
|
line: String(node.source_location || "").replace(/^L/, ""),
|
|
64
64
|
endLine: String(node.source_end || "").replace(/^L/, ""),
|
|
65
65
|
...(node.complexity ? { complexity: node.complexity } : {}),
|
|
66
|
-
...(node.decorated ? { decorated: true } : {})
|
|
66
|
+
...(node.decorated ? { decorated: true } : {}),
|
|
67
|
+
...(node.symbol_kind ? {symbolKind: node.symbol_kind} : {}),
|
|
68
|
+
...(node.symbol_space ? {symbolSpace: node.symbol_space} : {})
|
|
67
69
|
});
|
|
68
70
|
fileSymbols.set(fid, list);
|
|
69
71
|
}
|
|
@@ -63,7 +63,7 @@ function importCandidates(fromFile, spec) {
|
|
|
63
63
|
function stripModuleStatements(text) {
|
|
64
64
|
return String(text || "")
|
|
65
65
|
.replace(/\bimport\s+[\s\S]*?\s+from\s*['"][^'"]+['"]\s*;?/g, "")
|
|
66
|
-
.replace(/\bexport\s
|
|
66
|
+
.replace(/\bexport\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s*['"][^'"]+['"]\s*;?/g, "")
|
|
67
67
|
.replace(/\b(?:const|let|var)\s+\{[\s\S]*?\}\s*=\s*require\(\s*['"][^'"]+['"]\s*\)\s*;?/g, "");
|
|
68
68
|
}
|
|
69
69
|
|
|
@@ -73,8 +73,10 @@ function parseNamedSpecifiers(raw) {
|
|
|
73
73
|
.map((part) => part.trim())
|
|
74
74
|
.filter(Boolean)
|
|
75
75
|
.map((part) => {
|
|
76
|
-
const
|
|
77
|
-
|
|
76
|
+
const typeOnly = /^type\s+/.test(part);
|
|
77
|
+
const clean = part.replace(/^type\s+/, "").trim();
|
|
78
|
+
const m = clean.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
79
|
+
return m ? { imported: m[1], local: m[2] || m[1], typeOnly } : null;
|
|
78
80
|
})
|
|
79
81
|
.filter(Boolean);
|
|
80
82
|
}
|
|
@@ -93,17 +95,20 @@ export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
|
|
|
93
95
|
const name = bareSymbolName(sym.label);
|
|
94
96
|
if (!isIdentifierName(name)) continue;
|
|
95
97
|
const ids = byName.get(name) || [];
|
|
96
|
-
ids.push(sym.id);
|
|
98
|
+
ids.push({id: sym.id, space: sym.symbolSpace || "value"});
|
|
97
99
|
byName.set(name, ids);
|
|
98
100
|
}
|
|
99
101
|
symbolIdsByFileAndName.set(fid, byName);
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
const symbolExternalRefs = new Map();
|
|
103
|
-
const addExternalRefs = (targetFid, importedName, refs) => {
|
|
105
|
+
const addExternalRefs = (targetFid, importedName, refs, typeOnly = false) => {
|
|
104
106
|
if (!targetFid || refs <= 0 || !isIdentifierName(importedName)) return;
|
|
105
107
|
const ids = symbolIdsByFileAndName.get(targetFid)?.get(importedName) || [];
|
|
106
|
-
for (const
|
|
108
|
+
for (const entry of ids) {
|
|
109
|
+
const matches = entry.space === "both" || (typeOnly ? entry.space === "type" : entry.space !== "type");
|
|
110
|
+
if (matches) symbolExternalRefs.set(entry.id, (symbolExternalRefs.get(entry.id) || 0) + refs);
|
|
111
|
+
}
|
|
107
112
|
};
|
|
108
113
|
const resolveImportedFid = (fromPath, spec) => {
|
|
109
114
|
for (const candidate of importCandidates(fromPath, spec)) {
|
|
@@ -123,18 +128,21 @@ export function computeSymbolExternalRefs(filePath, fileSymbols, fileText) {
|
|
|
123
128
|
if (!targetFid) continue;
|
|
124
129
|
const named = String(m[1] || "").match(/\{([\s\S]*?)\}/);
|
|
125
130
|
if (named) {
|
|
126
|
-
|
|
131
|
+
const statementTypeOnly = /^\s*type\b/.test(String(m[1] || ""));
|
|
132
|
+
for (const spec of parseNamedSpecifiers(named[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local), statementTypeOnly || spec.typeOnly);
|
|
127
133
|
}
|
|
128
134
|
const ns = String(m[1] || "").match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
129
135
|
if (ns) {
|
|
130
136
|
const byName = symbolIdsByFileAndName.get(targetFid) || new Map();
|
|
131
|
-
|
|
137
|
+
const statementTypeOnly = /^\s*type\b/.test(String(m[1] || ""));
|
|
138
|
+
for (const name of byName.keys()) addExternalRefs(targetFid, name, countMemberAccess(scrubbed, ns[1], name), statementTypeOnly);
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
|
-
for (const m of String(txt).matchAll(/\bexport\s
|
|
135
|
-
const targetFid = resolveImportedFid(importerPath, m[
|
|
141
|
+
for (const m of String(txt).matchAll(/\bexport\s+(type\s+)?\{([\s\S]*?)\}\s+from\s*['"]([^'"]+)['"]\s*;?/g)) {
|
|
142
|
+
const targetFid = resolveImportedFid(importerPath, m[3]);
|
|
136
143
|
if (!targetFid) continue;
|
|
137
|
-
|
|
144
|
+
const statementTypeOnly = Boolean(m[1]);
|
|
145
|
+
for (const spec of parseNamedSpecifiers(m[2])) addExternalRefs(targetFid, spec.imported, 1, statementTypeOnly || spec.typeOnly);
|
|
138
146
|
}
|
|
139
147
|
for (const m of String(txt).matchAll(/\b(?:const|let|var)\s+\{([\s\S]*?)\}\s*=\s*require\(\s*['"]([^'"]+)['"]\s*\)\s*;?/g)) {
|
|
140
148
|
const targetFid = resolveImportedFid(importerPath, m[2]);
|
|
@@ -109,8 +109,9 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
109
109
|
calls: boundedInt(options.callThreshold, 12, 1, 10000),
|
|
110
110
|
loopDepth: boundedInt(options.loopDepthThreshold, 2, 1, 10),
|
|
111
111
|
timeRank: boundedInt(options.timeRankThreshold, 2, 0, 5),
|
|
112
|
-
minScore: boundedInt(options.minScore,
|
|
112
|
+
minScore: boundedInt(options.minScore, 85, 0, 100),
|
|
113
113
|
}
|
|
114
|
+
const defaultFocus = options.minScore == null
|
|
114
115
|
const topN = boundedInt(options.topN, 20, 1, 100)
|
|
115
116
|
const classifier = createPathClassifier(options.repoRoot || null)
|
|
116
117
|
const knownFiles = [...new Set((graph?.nodes || []).map((node) => normalize(node?.source_file)).filter(Boolean))]
|
|
@@ -152,8 +153,19 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
152
153
|
const nearest = staticByFile.get(file)?.nearestTests?.[0] || null
|
|
153
154
|
const coverageRisk = actualCoverage == null ? 0 : (1 - actualCoverage) * 100
|
|
154
155
|
const score = round(clamp(syntaxScore * 0.72 + graphScore * 0.2 + coverageRisk * 0.08))
|
|
155
|
-
if (score < thresholds.minScore) continue
|
|
156
156
|
const directHotEvidence = Array.isArray(complexity.hotEvidence) ? complexity.hotEvidence.slice(0, 12) : []
|
|
157
|
+
// The default queue is intentionally narrow. A small, locally expensive function can still be
|
|
158
|
+
// important even with little graph fan-in, so retain only a bounded strong-local fallback. An
|
|
159
|
+
// explicit minScore disables this fallback and gives the caller a strict numeric gate.
|
|
160
|
+
const bodyLines = startLine > 0 && endLine >= startLine ? endLine - startLine + 1 : Number.POSITIVE_INFINITY
|
|
161
|
+
const strongLocalEvidence = defaultFocus && directHotEvidence.length > 0 && bodyLines <= 80 && (
|
|
162
|
+
Number(complexity.recursionInLoops || 0) > 0
|
|
163
|
+
|| (bodyLines <= 40 && (
|
|
164
|
+
Number(complexity.sortsInLoops || 0) > 0
|
|
165
|
+
|| (Number(complexity.timeRank || 0) >= 4 && Number(complexity.maxLoopDepth || 0) >= 2)
|
|
166
|
+
))
|
|
167
|
+
)
|
|
168
|
+
if (score < thresholds.minScore && !strongLocalEvidence) continue
|
|
157
169
|
const confidence = directHotEvidence.length ? 'HIGH' : complexity.recursion ? 'LOW' : 'MEDIUM'
|
|
158
170
|
candidates.push({
|
|
159
171
|
id: String(node.id),
|
|
@@ -164,6 +176,7 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
164
176
|
endLine,
|
|
165
177
|
classification: entry.classification,
|
|
166
178
|
score,
|
|
179
|
+
selection: score >= thresholds.minScore ? 'SCORE_THRESHOLD' : 'STRONG_LOCAL_EVIDENCE',
|
|
167
180
|
confidence,
|
|
168
181
|
localSyntax: {
|
|
169
182
|
score: syntaxScore,
|
|
@@ -207,6 +220,11 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
207
220
|
includeClassified: options.includeClassified === true,
|
|
208
221
|
},
|
|
209
222
|
thresholds,
|
|
223
|
+
selectionPolicy: {
|
|
224
|
+
mode: defaultFocus ? 'FOCUSED_DEFAULT' : 'EXPLICIT_SCORE_THRESHOLD',
|
|
225
|
+
strongLocalFallback: defaultFocus,
|
|
226
|
+
broadenWith: 'Set min_score lower (0 restores the full diagnostic candidate set).',
|
|
227
|
+
},
|
|
210
228
|
analyzedSymbols: eligible.length,
|
|
211
229
|
candidateSymbols: candidates.length,
|
|
212
230
|
coverage: measuredCoverage.size
|
|
@@ -300,15 +300,20 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
300
300
|
findings: sorted,
|
|
301
301
|
dependencyReport: {
|
|
302
302
|
status: dependencyStatus,
|
|
303
|
+
evidenceModel: "MANIFEST_PLUS_INDEXED_SOURCE",
|
|
304
|
+
perFindingVerification: true,
|
|
305
|
+
verificationCoverage: { npm: "COMPLETE_FOR_GRAPH_SCOPE", go: "SUMMARY_ONLY", python: "SUMMARY_ONLY" },
|
|
303
306
|
declared: dep.declared.size + goDep.declared.size + pyDep.declared.size,
|
|
304
307
|
importedPackages: importedPackages.size,
|
|
305
308
|
importRecords: externalImports.length,
|
|
306
309
|
unused: dependencyFindings.filter((finding) => finding.rule === "unused-dep").length,
|
|
307
310
|
missing: dependencyFindings.filter((finding) => finding.rule === "missing-dep").length,
|
|
308
311
|
duplicateDeclarations: dependencyFindings.filter((finding) => finding.rule === "duplicate-dep").length,
|
|
312
|
+
unusedRequiringReview: dependencyFindings.filter((finding) => finding.rule === "unused-dep" && finding.verification?.decision === "REVIEW_REQUIRED").length,
|
|
313
|
+
missingWithSourceEvidence: dependencyFindings.filter((finding) => finding.rule === "missing-dep" && finding.verification?.indexedSourceImports?.status === "FOUND").length,
|
|
309
314
|
packageScopes: packageScopes.length,
|
|
310
315
|
reason: dependencyStatus === "COMPLETE"
|
|
311
|
-
? "All discovered dependency manifests were compared with the complete indexed import set."
|
|
316
|
+
? "All discovered dependency manifests were compared with the complete indexed import set; every npm unused/missing/duplicate finding carries manifest and source/config verification state."
|
|
312
317
|
: "The dependency result is scoped to a partial graph and is not a repository-wide clean bill.",
|
|
313
318
|
},
|
|
314
319
|
deadReport: {
|