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.
@@ -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
+ }
@@ -126,14 +126,29 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
126
126
 
127
127
  const symbolNames = new Set([...symById.values()].map((node) => bareName(node.label)).filter(Boolean));
128
128
  const occurrenceFiles = new Map();
129
+ const occurrenceCounts = new Map();
129
130
  for (const [file, text] of sources) for (const match of String(text || "").matchAll(IDENT_RE)) {
130
131
  const name = match[0];
131
132
  if (!symbolNames.has(name)) continue;
132
133
  const files = occurrenceFiles.get(name) || new Set();
133
134
  files.add(file);
134
135
  occurrenceFiles.set(name, files);
136
+ const counts = occurrenceCounts.get(name) || new Map();
137
+ counts.set(file, (counts.get(file) || 0) + 1);
138
+ occurrenceCounts.set(name, counts);
135
139
  }
136
140
 
141
+ const declarationCounts = new Map();
142
+ for (const node of symById.values()) {
143
+ const name = bareName(node.label);
144
+ if (!name) continue;
145
+ const key = `${node.source_file}\0${name}`;
146
+ declarationCounts.set(key, (declarationCounts.get(key) || 0) + 1);
147
+ }
148
+ const exactReferenceIds = new Set(graph.precisionReferenceSymbols || []);
149
+ const exactProductionReferenceIds = new Set(graph.precisionProductionReferenceSymbols || []);
150
+ const exactTestReferenceIds = new Set(graph.precisionTestReferenceSymbols || []);
151
+
137
152
  // decorated defs (@app.route/@app.event/@pytest.fixture…) are entered by the framework: trust the
138
153
  // builder's flag when present, else walk the source line(s) above the definition (graph-builder graphs).
139
154
  const lineCache = new Map();
@@ -155,6 +170,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
155
170
 
156
171
  const isReferenced = (n) => {
157
172
  if (inbound.has(n.id)) return true; // a real graph edge targets it
173
+ if (exactReferenceIds.has(String(n.id))) return true; // revision-bound point-query evidence found a caller
158
174
  const name = bareName(n.label);
159
175
  if (!name || !/^[A-Za-z_$]/.test(name)) return true; // selectors/odd labels → don't flag
160
176
  if (/^__\w+__$/.test(name)) return true; // dunders are invoked implicitly (with/str/==/iter…), never spelled
@@ -182,8 +198,14 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
182
198
  const name = bareName(n.label);
183
199
  const occurrenceSet = occurrenceFiles.get(name) || new Set();
184
200
  const externalOccurrences = [...occurrenceSet].filter((file) => file !== n.source_file);
201
+ const localOccurrences = occurrenceCounts.get(name)?.get(n.source_file) || 0;
202
+ const localDeclarations = declarationCounts.get(`${n.source_file}\0${name}`) || 0;
203
+ const hasLocalProductionUse = localOccurrences > localDeclarations;
185
204
  const lexicalTestOnly = externalOccurrences.length > 0 && externalOccurrences.every((file) => isTestFile(file));
186
- if (hasProductionInbound || (!hasTestInbound && !lexicalTestOnly)) continue;
205
+ const hasExactProductionInbound = exactProductionReferenceIds.has(String(n.id));
206
+ const hasExactTestInbound = exactTestReferenceIds.has(String(n.id));
207
+ if (hasProductionInbound || hasExactProductionInbound || hasLocalProductionUse
208
+ || (!hasTestInbound && !hasExactTestInbound && !lexicalTestOnly)) continue;
187
209
  testOnlySymbols.push({
188
210
  id: n.id,
189
211
  file: n.source_file,
@@ -191,7 +213,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
191
213
  test: false,
192
214
  reason: "referenced only from test/e2e code; no production consumer was found",
193
215
  testConsumerFiles: [...new Set(sourceFiles.filter((file) => file && isTestFile(file)))].sort(),
194
- evidence: hasTestInbound ? "graph" : "lexical",
216
+ evidence: hasTestInbound ? "graph" : hasExactTestInbound ? "exact-semantic" : "lexical",
195
217
  publicApi: n.exported === true || ["public", "protected"].includes(String(n.visibility || "").toLowerCase()),
196
218
  });
197
219
  }
@@ -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",
@@ -1,6 +1,6 @@
1
1
  import { makeFinding } from "./findings.js";
2
2
 
3
- const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$/i;
3
+ const TEST_PATH_RE = /(^|[/\\])(test|tests|__tests__|spec|e2e|__mocks__)([/\\]|$)|[._-](test|spec)\.[a-z0-9]+$|_test\.go$|(^|[/\\])test(?:_[^/\\]*)?\.py$/i;
4
4
  const escRe = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5
5
  const mentioned = (blob, name) => new RegExp(`(^|[^\\w@.-])${escRe(name)}($|[^\\w.-])`).test(blob);
6
6
 
@@ -79,7 +79,7 @@ const PY_TOOL_DISTS = new Set(("pytest tox nox black ruff flake8 pylint mypy pyr
79
79
  "pip-tools uv virtualenv pipenv gunicorn uwsgi supervisor ipython jupyter jupyterlab notebook ipykernel codecov autopep8 yapf commitizen detect-secrets safety pip-audit hatchling flit flit-core pdm").split(" "));
80
80
  const pyNorm = (n) => String(n || "").toLowerCase().replace(/[-_.]+/g, "-");
81
81
 
82
- export function computePyDepFindings({
82
+ function computePyDepFindingsFlat({
83
83
  externalImports = [], pyManifest = null, configTexts = new Map(),
84
84
  managedDependencies = [], ignoredDependencies = [], nonRuntimeRoots = [],
85
85
  } = {}) {
@@ -161,3 +161,42 @@ export function computePyDepFindings({
161
161
  }
162
162
  return { findings, declared, managed };
163
163
  }
164
+
165
+ const normPyScope = (root) => String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
166
+ const pyScopeOwns = (root, file) => !root || file === root || String(file || "").replace(/\\/g, "/").startsWith(`${root}/`);
167
+
168
+ export function computePyDepFindings(options = {}) {
169
+ const scopes = Array.isArray(options.pyManifest?.scopes) ? options.pyManifest.scopes : [];
170
+ if (!scopes.length) return computePyDepFindingsFlat(options);
171
+ const normalized = scopes.map((scope) => ({ ...scope, root: normPyScope(scope.root) }))
172
+ .sort((left, right) => right.root.length - left.root.length);
173
+ if (!normalized.some((scope) => !scope.root)) normalized.push({ root: "", present: false, deps: [], manifests: [] });
174
+ const importsByScope = new Map(normalized.map((scope) => [scope, []]));
175
+ const configByScope = new Map(normalized.map((scope) => [scope, new Map()]));
176
+ for (const entry of options.externalImports || []) {
177
+ const owner = normalized.find((scope) => pyScopeOwns(scope.root, entry.file)) || normalized.at(-1);
178
+ importsByScope.get(owner).push(entry);
179
+ }
180
+ for (const [file, text] of options.configTexts || new Map()) {
181
+ const owner = normalized.find((scope) => pyScopeOwns(scope.root, file)) || normalized.at(-1);
182
+ configByScope.get(owner).set(file, text);
183
+ }
184
+ const findings = [];
185
+ const declared = new Set();
186
+ const managed = new Set();
187
+ for (const scope of normalized) {
188
+ const result = computePyDepFindingsFlat({
189
+ ...options,
190
+ externalImports: importsByScope.get(scope),
191
+ configTexts: configByScope.get(scope),
192
+ pyManifest: {present: scope.present, deps: scope.deps || []},
193
+ });
194
+ findings.push(...result.findings.map((finding) => ({
195
+ ...finding,
196
+ ...(scope.manifests?.length ? {manifest: scope.manifests[0]} : {}),
197
+ })));
198
+ for (const name of result.declared) declared.add(`${scope.root || "."}:${name}`);
199
+ for (const name of result.managed) managed.add(name);
200
+ }
201
+ return { findings, declared, managed };
202
+ }
@@ -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
+ }
@@ -27,6 +27,38 @@ function lineAt(text, index) {
27
27
  return line;
28
28
  }
29
29
 
30
+ // Regex extractors must never see commented-out routes. Preserve string literals and every source
31
+ // offset, but replace comment bodies with spaces so endpoint line numbers still refer to the original
32
+ // file. Python `#` comments are enabled only for .py files; Rust attributes such as #[get] stay intact.
33
+ function maskComments(text, { hashComments = false } = {}) {
34
+ const chars = String(text || "").split("");
35
+ let quote = "", escaped = false, lineComment = false, blockComment = false;
36
+ for (let i = 0; i < chars.length; i++) {
37
+ const ch = chars[i], next = chars[i + 1];
38
+ if (lineComment) {
39
+ if (ch === "\n" || ch === "\r") lineComment = false;
40
+ else chars[i] = " ";
41
+ continue;
42
+ }
43
+ if (blockComment) {
44
+ if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; blockComment = false; }
45
+ else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
46
+ continue;
47
+ }
48
+ if (quote) {
49
+ if (escaped) escaped = false;
50
+ else if (ch === "\\") escaped = true;
51
+ else if (ch === quote) quote = "";
52
+ continue;
53
+ }
54
+ if (ch === '"' || ch === "'" || ch === "`") { quote = ch; continue; }
55
+ if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; lineComment = true; continue; }
56
+ if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; blockComment = true; continue; }
57
+ if (hashComments && ch === "#") { chars[i] = " "; lineComment = true; }
58
+ }
59
+ return chars.join("");
60
+ }
61
+
30
62
  // best-effort bare handler name from a value expression: the LAST identifier, unwrapping wrappers like
31
63
  // executionRoute(queryHandlers.executeQuery) → executeQuery, asyncHandler(fn) → fn, a.b.c → c.
32
64
  // An INLINE handler (arrow / function literal) has no named method to join to, so it returns "" (the
@@ -75,6 +107,7 @@ export function extractEndpointsFromText(text, file) {
75
107
  const py = /\.py$/i.test(file);
76
108
  const rust = /\.rs$/i.test(file);
77
109
  const java = /\.java$/i.test(file);
110
+ const scanText = maskComments(text, { hashComments: py });
78
111
  const add = (method, path, expr, idx) => {
79
112
  const p = cleanPath(path);
80
113
  if (!looksLikePath(p)) return;
@@ -90,13 +123,13 @@ export function extractEndpointsFromText(text, file) {
90
123
  const seen = new Set();
91
124
  const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
92
125
  let nm;
93
- while ((nm = direct.exec(text))) {
126
+ while ((nm = direct.exec(scanText))) {
94
127
  const method = nm[1].toUpperCase();
95
128
  if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
96
129
  }
97
130
  const lists = /\bexport\s*\{([^}]+)\}/g;
98
131
  let lm;
99
- while ((lm = lists.exec(text))) {
132
+ while ((lm = lists.exec(scanText))) {
100
133
  for (const item of lm[1].split(",")) {
101
134
  const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
102
135
  if (!mm) continue;
@@ -108,9 +141,9 @@ export function extractEndpointsFromText(text, file) {
108
141
  }
109
142
  }
110
143
 
111
- if (rust) extractRustEndpoints(text, add);
144
+ if (rust) extractRustEndpoints(scanText, add);
112
145
  if (java) {
113
- out.push(...extractSpringEndpoints(text, file));
146
+ out.push(...extractSpringEndpoints(scanText, file));
114
147
  return out; // generic JS-style method calls would turn Java HTTP clients into fake server routes
115
148
  }
116
149
 
@@ -118,14 +151,14 @@ export function extractEndpointsFromText(text, file) {
118
151
  // find each "…": { or "…": expr, where the key looks like a path
119
152
  const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
120
153
  let m;
121
- while ((m = objKeyRe.exec(text))) {
154
+ while ((m = objKeyRe.exec(scanText))) {
122
155
  const path = m[2], keyIdx = m.index;
123
156
  if (m[3]) {
124
157
  // object of METHOD: handler — scan to the matching close brace (routes objects are shallow)
125
158
  let i = objKeyRe.lastIndex, depth = 1;
126
159
  const start = i;
127
- while (i < text.length && depth > 0) { const c = text[i]; if (c === "{") depth++; else if (c === "}") depth--; i++; }
128
- const body = text.slice(start, i - 1);
160
+ while (i < scanText.length && depth > 0) { const c = scanText[i]; if (c === "{") depth++; else if (c === "}") depth--; i++; }
161
+ const body = scanText.slice(start, i - 1);
129
162
  objKeyRe.lastIndex = i;
130
163
  if (OPENAPI_BLOCK.test(body)) continue; // documentation, not a route table
131
164
  const methodRe = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b\s*:\s*([^,\n}]+)/gi;
@@ -133,9 +166,10 @@ export function extractEndpointsFromText(text, file) {
133
166
  while ((mm = methodRe.exec(body))) add(mm[1], path, mm[2], keyIdx);
134
167
  } else {
135
168
  // "/path": handlerExpr — a direct handler (any method); grab up to the next , or }
136
- const tail = text.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
169
+ const tail = scanText.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
137
170
  const em = /^([^,\n}]+)/.exec(tail);
138
- if (em && !/^\s*\{/.test(em[1])) add("ANY", path, em[1], keyIdx);
171
+ // A string/number/array value is an ordinary path/name lookup, not an executable handler.
172
+ if (em && !/^\s*(?:\{|["'`\[]|[-+]?\d|true\b|false\b|null\b|undefined\b)/i.test(em[1])) add("ANY", path, em[1], keyIdx);
139
173
  }
140
174
  }
141
175
 
@@ -146,7 +180,7 @@ export function extractEndpointsFromText(text, file) {
146
180
  // FRONTEND code, not server routes. A server route also REQUIRES a handler arg (an identifier/function),
147
181
  // so a bare `client.get("/x")` or one whose 2nd arg is a config object literal `{…}` is skipped.
148
182
  const callRe = /(?<!@)\b([\w$]+)\s*\.\s*(get|post|put|patch|delete|head|options|all)\s*\(\s*(["'`])(\/[^"'`]*)\3\s*(?:,\s*([\s\S]{0,160}?))?\)/gi;
149
- while ((m = callRe.exec(text))) {
183
+ while ((m = callRe.exec(scanText))) {
150
184
  const caller = m[1], arg2 = String(m[5] || "").trim();
151
185
  if (HTTP_CLIENT_CALLER.test(caller)) continue; // axios/http/fetch/apiClient… → a client request
152
186
  if (!arg2 || arg2[0] === "{") continue; // no handler, or a config object → not a route def
@@ -155,14 +189,14 @@ export function extractEndpointsFromText(text, file) {
155
189
 
156
190
  // ---- Go net/http: mux.HandleFunc("/path", handler) / http.Handle("/path", h) ------------------
157
191
  const goRe = /\.\s*(?:HandleFunc|Handle)\s*\(\s*(["'`])(\/[^"'`]*)\1\s*,\s*([\s\S]{0,120}?)\)/g;
158
- while ((m = goRe.exec(text))) add("ANY", m[2], m[3], m.index);
192
+ while ((m = goRe.exec(scanText))) add("ANY", m[2], m[3], m.index);
159
193
 
160
194
  // ---- decorators: @app.get("/path") / @router.post("/path") / @Get("/path") -------------------
161
195
  if (py || /\.(ts|js|tsx|jsx|cjs|mjs)$/i.test(file)) {
162
196
  const decoRe = /@[\w$]*\.?\s*(get|post|put|patch|delete|head|options)\s*\(\s*(["'`])(\/[^"'`]*)\2/gi;
163
- while ((m = decoRe.exec(text))) {
197
+ while ((m = decoRe.exec(scanText))) {
164
198
  // the handler is the def/function on a following line — best-effort: next def name
165
- const after = text.slice(decoRe.lastIndex, decoRe.lastIndex + 200);
199
+ const after = scanText.slice(decoRe.lastIndex, decoRe.lastIndex + 200);
166
200
  const fn = /\b(?:def|async\s+def|function|const|export\s+function)\s+([A-Za-z_$][\w$]*)/.exec(after);
167
201
  add(m[1], m[3], fn ? fn[1] : "", m.index);
168
202
  }