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.
Files changed (41) hide show
  1. package/README.md +77 -14
  2. package/SECURITY.md +14 -0
  3. package/docs/agent-task-benchmark.md +58 -0
  4. package/docs/releases/v0.2.7.md +93 -0
  5. package/package.json +6 -1
  6. package/scripts/run-agent-task-benchmark.mjs +119 -0
  7. package/skill/SKILL.md +31 -10
  8. package/src/analysis/allowed-test-runner.js +59 -0
  9. package/src/analysis/data-flow-evidence.js +114 -0
  10. package/src/analysis/dead-code-review.js +51 -0
  11. package/src/analysis/dep-check.js +42 -3
  12. package/src/analysis/duplicate-groups.js +84 -0
  13. package/src/analysis/graph-analysis.aggregate.js +3 -1
  14. package/src/analysis/graph-analysis.refs.js +19 -11
  15. package/src/analysis/hot-path-review.js +20 -2
  16. package/src/analysis/internal-audit.run.js +6 -1
  17. package/src/analysis/task-retrieval.js +116 -0
  18. package/src/bounds.js +4 -0
  19. package/src/graph/builder/lang-js.js +52 -17
  20. package/src/graph/freshness-probe.js +4 -2
  21. package/src/graph/incremental-refresh.js +4 -2
  22. package/src/graph/internal-builder.barrels.js +39 -2
  23. package/src/graph/internal-builder.build.js +58 -12
  24. package/src/mcp/catalog.mjs +28 -6
  25. package/src/mcp/graph-context.mjs +5 -1
  26. package/src/mcp/graph-diff.mjs +1 -1
  27. package/src/mcp/sync-payload.mjs +1 -1
  28. package/src/mcp/tools-actions.mjs +3 -1
  29. package/src/mcp/tools-context.mjs +164 -0
  30. package/src/mcp/tools-graph.mjs +48 -3
  31. package/src/mcp/tools-health.mjs +15 -28
  32. package/src/mcp/tools-impact-change.mjs +1 -0
  33. package/src/mcp/tools-source.mjs +7 -6
  34. package/src/mcp/tools-verified-change.mjs +169 -0
  35. package/src/precision/lsp-client.js +1 -1
  36. package/src/precision/lsp-overlay.js +1 -5
  37. package/src/precision/symbol-query.js +1 -5
  38. package/src/security/malware-heuristics.exclusions.js +3 -3
  39. package/src/security/malware-heuristics.roots.js +4 -1
  40. package/src/security/malware-heuristics.scan.js +4 -2
  41. 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 recorded 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 never imported in source, never referenced by a script, and not mentioned in any known config file. Dynamic/config-convention usage can't be fully ruled out — review before removing.`,
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
- fixHint: `npm uninstall ${name} (after confirming no config/CLI usage)`,
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+\{[\s\S]*?\}\s+from\s*['"][^'"]+['"]\s*;?/g, "")
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 m = part.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
77
- return m ? { imported: m[1], local: m[2] || m[1] } : null;
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 id of ids) symbolExternalRefs.set(id, (symbolExternalRefs.get(id) || 0) + refs);
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
- for (const spec of parseNamedSpecifiers(named[1])) addExternalRefs(targetFid, spec.imported, countIdentifierInText(scrubbed, spec.local));
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
- for (const name of byName.keys()) addExternalRefs(targetFid, name, countMemberAccess(scrubbed, ns[1], name));
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+\{([\s\S]*?)\}\s+from\s*['"]([^'"]+)['"]\s*;?/g)) {
135
- const targetFid = resolveImportedFid(importerPath, m[2]);
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
- for (const spec of parseNamedSpecifiers(m[1])) addExternalRefs(targetFid, spec.imported, 1);
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, 0, 0, 100),
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: {