weavatrix 0.2.6 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -10
- package/SECURITY.md +14 -0
- package/docs/agent-task-benchmark.md +58 -0
- package/docs/releases/v0.2.8.md +82 -0
- package/package.json +6 -1
- package/scripts/run-agent-task-benchmark.mjs +119 -0
- package/skill/SKILL.md +27 -7
- package/src/analysis/allowed-test-runner.js +59 -0
- package/src/analysis/data-flow-evidence.js +114 -0
- package/src/analysis/dead-check.js +24 -2
- package/src/analysis/dead-code-review.js +51 -0
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/dep-check.js +42 -3
- package/src/analysis/duplicate-groups.js +84 -0
- package/src/analysis/endpoints.js +47 -13
- package/src/analysis/hot-path-review.js +20 -2
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/analysis/internal-audit.run.js +6 -1
- package/src/analysis/task-retrieval.js +116 -0
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/catalog.mjs +26 -6
- package/src/mcp/graph-context.mjs +37 -2
- package/src/mcp/tools-graph.mjs +46 -3
- package/src/mcp/tools-health.mjs +37 -29
- package/src/mcp/tools-impact-change.mjs +1 -0
- package/src/mcp/tools-verified-change.mjs +185 -0
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/src/security/malware-heuristics.exclusions.js +3 -3
- package/src/security/malware-heuristics.roots.js +4 -1
- package/src/security/malware-heuristics.scan.js +4 -2
- package/src/security/malware-scoring.js +22 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
144
|
+
if (rust) extractRustEndpoints(scanText, add);
|
|
112
145
|
if (java) {
|
|
113
|
-
out.push(...extractSpringEndpoints(
|
|
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(
|
|
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 <
|
|
128
|
-
const body =
|
|
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 =
|
|
169
|
+
const tail = scanText.slice(objKeyRe.lastIndex, objKeyRe.lastIndex + 200);
|
|
137
170
|
const em = /^([^,\n}]+)/.exec(tail);
|
|
138
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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
|
}
|