weavatrix 0.2.4 → 0.2.6
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 +53 -11
- package/package.json +1 -1
- package/skill/SKILL.md +18 -7
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +9 -5
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/graph-analysis.aggregate.js +3 -1
- package/src/analysis/graph-analysis.refs.js +19 -11
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/bounds.js +4 -0
- package/src/graph/builder/lang-js.js +71 -14
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +5 -3
- package/src/graph/incremental-refresh.js +4 -2
- package/src/graph/internal-builder.barrels.js +71 -5
- package/src/graph/internal-builder.build.js +77 -16
- package/src/mcp/catalog.mjs +12 -7
- package/src/mcp/graph-context.mjs +4 -0
- package/src/mcp/graph-diff.mjs +1 -1
- package/src/mcp/sync-payload.mjs +3 -2
- package/src/mcp/tools-actions.mjs +3 -1
- package/src/mcp/tools-context.mjs +164 -0
- package/src/mcp/tools-graph.mjs +2 -0
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +238 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +29 -8
- package/src/precision/symbol-query.js +133 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Bounded local hot-path review over parser-derived complexity facts. This deliberately keeps
|
|
2
|
+
// local syntax cost, graph coupling and test evidence separate: it is a review queue, not a runtime
|
|
3
|
+
// profiler or an interprocedural Big-O proof.
|
|
4
|
+
import {readCoverageForRepo, normalizeRepoParts} from './coverage-reports.js'
|
|
5
|
+
import {computeStaticTestReachability} from './static-test-reachability.js'
|
|
6
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
7
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
8
|
+
|
|
9
|
+
const NON_PRODUCT = ['generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
10
|
+
const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value || '')
|
|
11
|
+
const normalize = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
12
|
+
const boundedInt = (value, fallback, min, max) => {
|
|
13
|
+
const number = Number(value)
|
|
14
|
+
return Number.isInteger(number) ? Math.max(min, Math.min(max, number)) : fallback
|
|
15
|
+
}
|
|
16
|
+
const round = (value) => Math.round(Number(value || 0) * 100) / 100
|
|
17
|
+
const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, Number(value) || 0))
|
|
18
|
+
|
|
19
|
+
function normalizeScope(value) {
|
|
20
|
+
const path = normalize(value)
|
|
21
|
+
if (!path) return {ok: true, path: ''}
|
|
22
|
+
if (path.includes('\0') || /^[a-z]:\//i.test(path) || path.startsWith('/') || path.split('/').includes('..')) {
|
|
23
|
+
return {ok: false, error: 'path must be repository-relative and cannot contain traversal segments'}
|
|
24
|
+
}
|
|
25
|
+
return {ok: true, path}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function coverageForRange(record, startLine, endLine) {
|
|
29
|
+
if (!record) return null
|
|
30
|
+
if (!(record.lines instanceof Map) || !record.lines.size) return Number.isFinite(record.pct) ? record.pct : null
|
|
31
|
+
let total = 0
|
|
32
|
+
let covered = 0
|
|
33
|
+
for (let line = Math.max(1, startLine); line <= Math.max(startLine, endLine); line++) {
|
|
34
|
+
if (!record.lines.has(line)) continue
|
|
35
|
+
total++
|
|
36
|
+
if (Number(record.lines.get(line)) > 0) covered++
|
|
37
|
+
}
|
|
38
|
+
return total ? covered / total : Number.isFinite(record.pct) ? record.pct : null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function graphRiskBySymbol(graph, symbolIds) {
|
|
42
|
+
const state = new Map([...symbolIds].map((id) => [id, {
|
|
43
|
+
incoming: 0, outgoing: 0, callers: new Set(), callees: new Set(),
|
|
44
|
+
}]))
|
|
45
|
+
for (const link of graph?.links || []) {
|
|
46
|
+
if (!link || link.typeOnly === true || link.compileOnly === true || link.barrelProxy === true || isStructuralRelation(link.relation)) continue
|
|
47
|
+
const source = endpoint(link.source)
|
|
48
|
+
const target = endpoint(link.target)
|
|
49
|
+
if (!source || !target || source === target) continue
|
|
50
|
+
if (state.has(source)) {
|
|
51
|
+
const item = state.get(source)
|
|
52
|
+
item.outgoing++
|
|
53
|
+
item.callees.add(target)
|
|
54
|
+
}
|
|
55
|
+
if (state.has(target)) {
|
|
56
|
+
const item = state.get(target)
|
|
57
|
+
item.incoming++
|
|
58
|
+
item.callers.add(source)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return new Map([...state].map(([id, item]) => [id, {
|
|
62
|
+
fanIn: item.callers.size,
|
|
63
|
+
fanOut: item.callees.size,
|
|
64
|
+
incomingEdges: item.incoming,
|
|
65
|
+
outgoingEdges: item.outgoing,
|
|
66
|
+
}]))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function localSyntaxScore(complexity, thresholds) {
|
|
70
|
+
const insideLoop = Number(complexity.allocationsInLoops || 0)
|
|
71
|
+
+ Number(complexity.copiesInLoops || 0)
|
|
72
|
+
+ Number(complexity.linearOpsInLoops || 0)
|
|
73
|
+
+ Number(complexity.sortsInLoops || 0) * 2
|
|
74
|
+
+ Number(complexity.recursionInLoops || 0) * 2
|
|
75
|
+
const score = Number(complexity.timeRank || 0) * 12
|
|
76
|
+
+ Number(complexity.memoryRank || 0) * 5
|
|
77
|
+
+ Math.min(18, Number(complexity.cyclomatic || 1) / thresholds.cyclomatic * 10)
|
|
78
|
+
+ Math.min(12, Number(complexity.callCount || 0) / thresholds.calls * 6)
|
|
79
|
+
+ Math.min(18, Number(complexity.maxLoopDepth || 0) * 5)
|
|
80
|
+
+ Math.min(24, insideLoop * 5)
|
|
81
|
+
+ (complexity.recursion ? 6 : 0)
|
|
82
|
+
return round(clamp(score))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function couplingScore(risk) {
|
|
86
|
+
return round(clamp(Math.log2(1 + risk.fanIn) * 20 + Math.log2(1 + risk.fanOut) * 10))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function reasonsFor(complexity, thresholds) {
|
|
90
|
+
const reasons = []
|
|
91
|
+
if (Number(complexity.timeRank || 0) >= thresholds.timeRank) reasons.push(String(complexity.timeLabel || `local time rank ${complexity.timeRank}`))
|
|
92
|
+
if (Number(complexity.cyclomatic || 0) >= thresholds.cyclomatic) reasons.push(`cyclomatic ${complexity.cyclomatic} >= ${thresholds.cyclomatic}`)
|
|
93
|
+
if (Number(complexity.callCount || 0) >= thresholds.calls) reasons.push(`${complexity.callCount} local call sites >= ${thresholds.calls}`)
|
|
94
|
+
if (Number(complexity.maxLoopDepth || 0) >= thresholds.loopDepth) reasons.push(`loop depth ${complexity.maxLoopDepth} >= ${thresholds.loopDepth}`)
|
|
95
|
+
if (complexity.allocationsInLoops) reasons.push(`${complexity.allocationsInLoops} allocation(s) inside iteration`)
|
|
96
|
+
if (complexity.copiesInLoops) reasons.push(`${complexity.copiesInLoops} copy/copies inside iteration`)
|
|
97
|
+
if (complexity.linearOpsInLoops) reasons.push(`${complexity.linearOpsInLoops} linear operation(s) inside iteration`)
|
|
98
|
+
if (complexity.sortsInLoops) reasons.push(`${complexity.sortsInLoops} sort(s) inside iteration`)
|
|
99
|
+
if (complexity.recursionInLoops) reasons.push(`${complexity.recursionInLoops} recursive call(s) inside iteration`)
|
|
100
|
+
if (complexity.recursion) reasons.push('direct recursion; bound remains unknown')
|
|
101
|
+
return [...new Set(reasons)].slice(0, 12)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function computeHotPathReview(graph, options = {}) {
|
|
105
|
+
const scope = normalizeScope(options.path)
|
|
106
|
+
if (!scope.ok) return {ok: false, error: scope.error}
|
|
107
|
+
const thresholds = {
|
|
108
|
+
cyclomatic: boundedInt(options.cyclomaticThreshold, 8, 2, 1000),
|
|
109
|
+
calls: boundedInt(options.callThreshold, 12, 1, 10000),
|
|
110
|
+
loopDepth: boundedInt(options.loopDepthThreshold, 2, 1, 10),
|
|
111
|
+
timeRank: boundedInt(options.timeRankThreshold, 2, 0, 5),
|
|
112
|
+
minScore: boundedInt(options.minScore, 0, 0, 100),
|
|
113
|
+
}
|
|
114
|
+
const topN = boundedInt(options.topN, 20, 1, 100)
|
|
115
|
+
const classifier = createPathClassifier(options.repoRoot || null)
|
|
116
|
+
const knownFiles = [...new Set((graph?.nodes || []).map((node) => normalize(node?.source_file)).filter(Boolean))]
|
|
117
|
+
const measuredCoverage = options.repoRoot ? readCoverageForRepo(options.repoRoot, knownFiles) : new Map()
|
|
118
|
+
const coverageSources = [...new Set([...measuredCoverage.values()].map((item) => item?.source).filter(Boolean))].sort()
|
|
119
|
+
const staticTests = measuredCoverage.size || !options.repoRoot
|
|
120
|
+
? null
|
|
121
|
+
: computeStaticTestReachability(graph, {repoRoot: options.repoRoot, path: scope.path})
|
|
122
|
+
const staticByFile = new Map((staticTests?.reachable || []).map((item) => [normalize(item.file), item]))
|
|
123
|
+
|
|
124
|
+
const eligible = []
|
|
125
|
+
const excluded = {tests: 0, classified: 0, outOfScope: 0}
|
|
126
|
+
for (const node of graph?.nodes || []) {
|
|
127
|
+
const complexity = node?.complexity
|
|
128
|
+
const file = normalize(node?.source_file)
|
|
129
|
+
if (!complexity || !file || !String(node?.id || '').includes('#')) continue
|
|
130
|
+
if (scope.path && file !== scope.path && !file.startsWith(`${scope.path}/`)) { excluded.outOfScope++; continue }
|
|
131
|
+
const classification = classifier.explain(file)
|
|
132
|
+
const test = hasPathClass(classification, 'test', 'e2e')
|
|
133
|
+
const classified = classification.excluded || hasPathClass(classification, ...NON_PRODUCT)
|
|
134
|
+
if (test && options.includeTests !== true) { excluded.tests++; continue }
|
|
135
|
+
if (classified && options.includeClassified !== true) { excluded.classified++; continue }
|
|
136
|
+
eligible.push({node, complexity, file, classification: test ? 'test' : classified ? 'classified' : 'production'})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const riskById = graphRiskBySymbol(graph, new Set(eligible.map(({node}) => String(node.id))))
|
|
140
|
+
const candidates = []
|
|
141
|
+
for (const entry of eligible) {
|
|
142
|
+
const {node, complexity, file} = entry
|
|
143
|
+
const reasons = reasonsFor(complexity, thresholds)
|
|
144
|
+
if (!reasons.length) continue
|
|
145
|
+
const risk = riskById.get(String(node.id)) || {fanIn: 0, fanOut: 0, incomingEdges: 0, outgoingEdges: 0}
|
|
146
|
+
const syntaxScore = localSyntaxScore(complexity, thresholds)
|
|
147
|
+
const graphScore = couplingScore(risk)
|
|
148
|
+
const startLine = Number(complexity.startLine) || Number(String(node.source_location || '').replace(/^L/, '')) || 0
|
|
149
|
+
const endLine = Number(complexity.endLine) || Number(String(node.source_end || '').replace(/^L/, '')) || startLine
|
|
150
|
+
const coverageRecord = measuredCoverage.get(normalizeRepoParts(file)) || null
|
|
151
|
+
const actualCoverage = coverageForRange(coverageRecord, startLine, endLine)
|
|
152
|
+
const nearest = staticByFile.get(file)?.nearestTests?.[0] || null
|
|
153
|
+
const coverageRisk = actualCoverage == null ? 0 : (1 - actualCoverage) * 100
|
|
154
|
+
const score = round(clamp(syntaxScore * 0.72 + graphScore * 0.2 + coverageRisk * 0.08))
|
|
155
|
+
if (score < thresholds.minScore) continue
|
|
156
|
+
const directHotEvidence = Array.isArray(complexity.hotEvidence) ? complexity.hotEvidence.slice(0, 12) : []
|
|
157
|
+
const confidence = directHotEvidence.length ? 'HIGH' : complexity.recursion ? 'LOW' : 'MEDIUM'
|
|
158
|
+
candidates.push({
|
|
159
|
+
id: String(node.id),
|
|
160
|
+
label: String(node.label || node.norm_label || node.id),
|
|
161
|
+
kind: String(node.symbol_kind || 'symbol'),
|
|
162
|
+
file,
|
|
163
|
+
startLine,
|
|
164
|
+
endLine,
|
|
165
|
+
classification: entry.classification,
|
|
166
|
+
score,
|
|
167
|
+
confidence,
|
|
168
|
+
localSyntax: {
|
|
169
|
+
score: syntaxScore,
|
|
170
|
+
timeRank: Number(complexity.timeRank || 0),
|
|
171
|
+
timeLabel: String(complexity.timeLabel || ''),
|
|
172
|
+
memoryRank: Number(complexity.memoryRank || 0),
|
|
173
|
+
memoryLabel: String(complexity.memoryLabel || ''),
|
|
174
|
+
cyclomatic: Number(complexity.cyclomatic || 0),
|
|
175
|
+
calls: Number(complexity.callCount || 0),
|
|
176
|
+
loops: Number(complexity.loops || 0),
|
|
177
|
+
maxLoopDepth: Number(complexity.maxLoopDepth || 0),
|
|
178
|
+
allocationsInLoops: Number(complexity.allocationsInLoops || 0),
|
|
179
|
+
copiesInLoops: Number(complexity.copiesInLoops || 0),
|
|
180
|
+
linearOpsInLoops: Number(complexity.linearOpsInLoops || 0),
|
|
181
|
+
sortsInLoops: Number(complexity.sortsInLoops || 0),
|
|
182
|
+
recursionInLoops: Number(complexity.recursionInLoops || 0),
|
|
183
|
+
recursion: complexity.recursion === true,
|
|
184
|
+
},
|
|
185
|
+
graphRisk: {...risk, score: graphScore},
|
|
186
|
+
testEvidence: actualCoverage != null
|
|
187
|
+
? {actualCoverage, source: coverageRecord?.source || 'coverage report'}
|
|
188
|
+
: nearest
|
|
189
|
+
? {actualCoverage: 'NOT_AVAILABLE', staticReachable: true, nearestTest: nearest.test, distance: nearest.distance, confidence: nearest.confidence}
|
|
190
|
+
: {actualCoverage: 'NOT_AVAILABLE', staticReachable: false},
|
|
191
|
+
reasons,
|
|
192
|
+
sourceEvidence: directHotEvidence,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
candidates.sort((left, right) => right.score - left.score
|
|
196
|
+
|| right.localSyntax.score - left.localSyntax.score
|
|
197
|
+
|| right.graphRisk.fanIn - left.graphRisk.fanIn
|
|
198
|
+
|| left.id.localeCompare(right.id))
|
|
199
|
+
const hotspots = candidates.slice(0, topN)
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
modelVersion: 1,
|
|
203
|
+
complexityVersion: Number(graph?.complexityV) || 0,
|
|
204
|
+
scope: {
|
|
205
|
+
path: scope.path || null,
|
|
206
|
+
includeTests: options.includeTests === true,
|
|
207
|
+
includeClassified: options.includeClassified === true,
|
|
208
|
+
},
|
|
209
|
+
thresholds,
|
|
210
|
+
analyzedSymbols: eligible.length,
|
|
211
|
+
candidateSymbols: candidates.length,
|
|
212
|
+
coverage: measuredCoverage.size
|
|
213
|
+
? {actualCoverage: 'AVAILABLE', measuredFiles: measuredCoverage.size, sources: coverageSources}
|
|
214
|
+
: {actualCoverage: 'NOT_AVAILABLE', staticReachability: staticTests ? {
|
|
215
|
+
reachableFiles: staticTests.reachableFiles,
|
|
216
|
+
productFiles: staticTests.productFiles,
|
|
217
|
+
truncated: staticTests.bounds.truncated,
|
|
218
|
+
} : null},
|
|
219
|
+
excluded,
|
|
220
|
+
hotspots,
|
|
221
|
+
bounds: {topN, returned: hotspots.length, totalCandidates: candidates.length, truncated: candidates.length > hotspots.length},
|
|
222
|
+
caveats: [
|
|
223
|
+
'Scores rank parser-derived local syntax cost; they are not profiler measurements.',
|
|
224
|
+
'Graph risk is reported separately and does not propagate loop complexity through callees.',
|
|
225
|
+
'Recursion bounds, CFG reachability, dead stores and taint flow are not inferred by this model.',
|
|
226
|
+
],
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -139,6 +139,21 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
139
139
|
}));
|
|
140
140
|
unusedExportCount++;
|
|
141
141
|
}
|
|
142
|
+
for (const symbol of dead.testOnlySymbols || []) {
|
|
143
|
+
if (isNonProductPath(symbol.file)) continue;
|
|
144
|
+
findings.push(makeFinding({
|
|
145
|
+
category: "unused",
|
|
146
|
+
rule: "test-only-symbol",
|
|
147
|
+
severity: "low",
|
|
148
|
+
confidence: symbol.publicApi ? "low" : "medium",
|
|
149
|
+
title: `Production symbol used only by tests: ${symbol.label}`,
|
|
150
|
+
detail: `${symbol.reason}. Static analysis cannot rule out external or reflective consumers, so review before removal.`,
|
|
151
|
+
file: symbol.file,
|
|
152
|
+
graphNodeId: symbol.id,
|
|
153
|
+
source: "internal",
|
|
154
|
+
fixHint: "verify production/config consumers, then keep as intentional test support or remove it together with obsolete tests",
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
142
157
|
|
|
143
158
|
// ---- supply-chain: installed packages × cached OSV advisories. 100% OFFLINE here — the cache is
|
|
144
159
|
// refreshed only by the explicit repos:advisory-refresh action. Never blocks the rest of the audit.
|
|
@@ -247,6 +262,13 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
247
262
|
}
|
|
248
263
|
|
|
249
264
|
const sorted = sortFindings(findings);
|
|
265
|
+
const dependencyFindings = sorted.filter((finding) => ["unused-dep", "missing-dep", "duplicate-dep"].includes(finding.rule));
|
|
266
|
+
const dependencyStatus = (graph.graphBuildMode && graph.graphBuildMode !== "full") || graph.graphBuildScope
|
|
267
|
+
? "PARTIAL"
|
|
268
|
+
: "COMPLETE";
|
|
269
|
+
const importedPackages = new Set(externalImports
|
|
270
|
+
.filter((entry) => entry?.pkg && !entry.builtin && !entry.unresolved)
|
|
271
|
+
.map((entry) => `${entry.ecosystem || "npm"}:${entry.pkg}`));
|
|
250
272
|
return {
|
|
251
273
|
ok: true,
|
|
252
274
|
engine: "internal",
|
|
@@ -276,10 +298,24 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
276
298
|
},
|
|
277
299
|
summary: summarizeFindings(sorted),
|
|
278
300
|
findings: sorted,
|
|
301
|
+
dependencyReport: {
|
|
302
|
+
status: dependencyStatus,
|
|
303
|
+
declared: dep.declared.size + goDep.declared.size + pyDep.declared.size,
|
|
304
|
+
importedPackages: importedPackages.size,
|
|
305
|
+
importRecords: externalImports.length,
|
|
306
|
+
unused: dependencyFindings.filter((finding) => finding.rule === "unused-dep").length,
|
|
307
|
+
missing: dependencyFindings.filter((finding) => finding.rule === "missing-dep").length,
|
|
308
|
+
duplicateDeclarations: dependencyFindings.filter((finding) => finding.rule === "duplicate-dep").length,
|
|
309
|
+
packageScopes: packageScopes.length,
|
|
310
|
+
reason: dependencyStatus === "COMPLETE"
|
|
311
|
+
? "All discovered dependency manifests were compared with the complete indexed import set."
|
|
312
|
+
: "The dependency result is scoped to a partial graph and is not a repository-wide clean bill.",
|
|
313
|
+
},
|
|
279
314
|
deadReport: {
|
|
280
315
|
deadSymbols: dead.deadSymbols.filter((symbol) => !isNonProductPath(symbol.file)).length,
|
|
281
316
|
deadFiles: actionableDeadFiles.length,
|
|
282
317
|
unusedExports: unusedExportCount,
|
|
318
|
+
testOnlySymbols: (dead.testOnlySymbols || []).filter((symbol) => !isNonProductPath(symbol.file)).length,
|
|
283
319
|
},
|
|
284
320
|
conventionReachability: {
|
|
285
321
|
count: conventionEvidence.length,
|
|
@@ -68,6 +68,11 @@ export function buildEvidence(stats) {
|
|
|
68
68
|
if (stats.maxLoopDepth > 1) out.push(`loop depth ${stats.maxLoopDepth}`);
|
|
69
69
|
if (stats.sorts) out.push(plural(stats.sorts, "sort", "sorts"));
|
|
70
70
|
if (stats.spreadCopies) out.push(plural(stats.spreadCopies, "shallow copy", "shallow copies"));
|
|
71
|
+
if (stats.allocationsInLoops) out.push(`${plural(stats.allocationsInLoops, "allocation", "allocations")} inside iteration`);
|
|
72
|
+
if (stats.copiesInLoops) out.push(`${plural(stats.copiesInLoops, "copy", "copies")} inside iteration`);
|
|
73
|
+
if (stats.linearOpsInLoops) out.push(`${plural(stats.linearOpsInLoops, "linear operation", "linear operations")} inside iteration`);
|
|
74
|
+
if (stats.sortsInLoops) out.push(`${plural(stats.sortsInLoops, "sort", "sorts")} inside iteration`);
|
|
75
|
+
if (stats.recursionInLoops) out.push(`${plural(stats.recursionInLoops, "recursive call", "recursive calls")} inside iteration`);
|
|
71
76
|
if (stats.branches) out.push(plural(stats.branches, "branch point", "branch points"));
|
|
72
77
|
if (stats.awaits) out.push(plural(stats.awaits, "await boundary", "await boundaries"));
|
|
73
78
|
if (stats.callCount) out.push(plural(stats.callCount, "call", "calls"));
|
|
@@ -32,10 +32,23 @@ function createStats(parameters, family) {
|
|
|
32
32
|
producerCalls: 0,
|
|
33
33
|
recursion: false,
|
|
34
34
|
maxSortLoopDepth: 0,
|
|
35
|
-
maxVariableAllocationDepth: 0
|
|
35
|
+
maxVariableAllocationDepth: 0,
|
|
36
|
+
allocationsInLoops: 0,
|
|
37
|
+
copiesInLoops: 0,
|
|
38
|
+
linearOpsInLoops: 0,
|
|
39
|
+
sortsInLoops: 0,
|
|
40
|
+
recursionInLoops: 0,
|
|
41
|
+
hotEvidence: []
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
function recordHotEvidence(stats, node, kind, detail) {
|
|
46
|
+
if (stats.hotEvidence.length >= 24) return;
|
|
47
|
+
const line = node?.startPosition ? node.startPosition.row + 1 : 0;
|
|
48
|
+
if (stats.hotEvidence.some((item) => item.kind === kind && item.line === line)) return;
|
|
49
|
+
stats.hotEvidence.push({ kind, line, detail });
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
function shouldSkipNode(root, node, state) {
|
|
40
53
|
if (sameNode(node, root)) return false;
|
|
41
54
|
const type = String(node.type || "");
|
|
@@ -70,39 +83,74 @@ function recordBasicSignals(stats, node, facts) {
|
|
|
70
83
|
if (RETURN_NODES.has(type)) stats.returns++;
|
|
71
84
|
if (AWAIT_NODES.has(type)) { stats.awaits++; stats.asyncBoundaries++; }
|
|
72
85
|
if (OBJECT_NODES.has(type)) stats.objectLiterals++;
|
|
73
|
-
if (FIXED_ALLOCATION_NODES.has(type))
|
|
86
|
+
if (FIXED_ALLOCATION_NODES.has(type)) {
|
|
87
|
+
stats.allocations++;
|
|
88
|
+
if (currentDepth > 0) {
|
|
89
|
+
stats.allocationsInLoops++;
|
|
90
|
+
recordHotEvidence(stats, node, "allocation-in-loop", type);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
74
93
|
if (!VARIABLE_ALLOCATION_NODES.has(type)) return;
|
|
75
94
|
stats.producerCalls++;
|
|
76
95
|
stats.maxVariableAllocationDepth = Math.max(stats.maxVariableAllocationDepth, currentDepth + 1);
|
|
77
96
|
}
|
|
78
97
|
|
|
79
|
-
function recordSpread(stats, facts, parent) {
|
|
98
|
+
function recordSpread(stats, node, facts, parent) {
|
|
80
99
|
if (!SPREAD_NODES.has(facts.type) || /parameters|parameter_list/.test(String(parent?.type || ""))) return;
|
|
81
100
|
stats.spreadCopies++;
|
|
82
101
|
stats.linearOps++;
|
|
83
102
|
stats.maxVariableAllocationDepth = Math.max(stats.maxVariableAllocationDepth, facts.currentDepth + 1);
|
|
103
|
+
if (facts.currentDepth > 0) {
|
|
104
|
+
stats.copiesInLoops++;
|
|
105
|
+
stats.linearOpsInLoops++;
|
|
106
|
+
recordHotEvidence(stats, node, "copy-in-loop", facts.type);
|
|
107
|
+
}
|
|
84
108
|
}
|
|
85
109
|
|
|
86
|
-
function recordCall(stats, facts, state, targetName) {
|
|
110
|
+
function recordCall(stats, node, facts, state, targetName) {
|
|
87
111
|
if (!facts.isCall) return;
|
|
88
112
|
stats.callCount++;
|
|
89
113
|
if (state.awaited || looksLikeIoCall(facts.nameAtCall)) stats.externalCalls++;
|
|
90
|
-
if (targetName && facts.normalizedCall === targetName)
|
|
114
|
+
if (targetName && facts.normalizedCall === targetName) {
|
|
115
|
+
stats.recursion = true;
|
|
116
|
+
if (facts.currentDepth > 0) {
|
|
117
|
+
stats.recursionInLoops++;
|
|
118
|
+
recordHotEvidence(stats, node, "recursion-in-loop", facts.nameAtCall || targetName);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
91
121
|
if (facts.sortCall) {
|
|
92
122
|
stats.sorts++;
|
|
93
123
|
stats.maxSortLoopDepth = Math.max(stats.maxSortLoopDepth, facts.currentDepth);
|
|
94
|
-
|
|
124
|
+
if (facts.currentDepth > 0) {
|
|
125
|
+
stats.sortsInLoops++;
|
|
126
|
+
recordHotEvidence(stats, node, "sort-in-loop", facts.nameAtCall || "sort");
|
|
127
|
+
}
|
|
128
|
+
} else if (LINEAR_CALLS.has(facts.normalizedCall) && !facts.iteratorCall) {
|
|
129
|
+
stats.linearOps++;
|
|
130
|
+
if (facts.currentDepth > 0) {
|
|
131
|
+
stats.linearOpsInLoops++;
|
|
132
|
+
recordHotEvidence(stats, node, "linear-scan-in-loop", facts.nameAtCall || "collection operation");
|
|
133
|
+
}
|
|
134
|
+
} else if (facts.iteratorCall && facts.currentDepth > 0) {
|
|
135
|
+
stats.linearOpsInLoops++;
|
|
136
|
+
recordHotEvidence(stats, node, "linear-scan-in-loop", facts.nameAtCall || "iterator");
|
|
137
|
+
}
|
|
95
138
|
if (!facts.producerCall) return;
|
|
96
139
|
stats.producerCalls++;
|
|
97
140
|
stats.allocations++;
|
|
98
141
|
stats.maxVariableAllocationDepth = Math.max(stats.maxVariableAllocationDepth, facts.currentDepth + 1);
|
|
142
|
+
if (facts.currentDepth > 0) {
|
|
143
|
+
stats.allocationsInLoops++;
|
|
144
|
+
recordHotEvidence(stats, node, "allocation-in-loop", facts.nameAtCall || "collection producer");
|
|
145
|
+
}
|
|
99
146
|
}
|
|
100
147
|
|
|
101
|
-
function recordLoop(stats, facts) {
|
|
148
|
+
function recordLoop(stats, node, facts) {
|
|
102
149
|
if (!facts.isLoop && !facts.iteratorCall) return facts.currentDepth;
|
|
103
150
|
const nextDepth = facts.currentDepth + 1;
|
|
104
151
|
stats.loops++;
|
|
105
152
|
stats.maxLoopDepth = Math.max(stats.maxLoopDepth, nextDepth);
|
|
153
|
+
if (nextDepth > 1) recordHotEvidence(stats, node, "nested-iteration", `depth ${nextDepth}`);
|
|
106
154
|
return nextDepth;
|
|
107
155
|
}
|
|
108
156
|
|
|
@@ -121,9 +169,9 @@ function walkSyntax(root, node, state, context) {
|
|
|
121
169
|
const callbackContext = state.callbackContext || ARGUMENT_NODES.has(String(parent?.type || ""));
|
|
122
170
|
const facts = nodeFacts(node, state);
|
|
123
171
|
recordBasicSignals(context.stats, node, facts);
|
|
124
|
-
recordSpread(context.stats, facts, parent);
|
|
125
|
-
recordCall(context.stats, facts, state, context.targetName);
|
|
126
|
-
const nextDepth = recordLoop(context.stats, facts);
|
|
172
|
+
recordSpread(context.stats, node, facts, parent);
|
|
173
|
+
recordCall(context.stats, node, facts, state, context.targetName);
|
|
174
|
+
const nextDepth = recordLoop(context.stats, node, facts);
|
|
127
175
|
for (const child of children(node)) {
|
|
128
176
|
const childIsArgs = ARGUMENT_NODES.has(String(child.type || ""));
|
|
129
177
|
walkSyntax(root, child, {
|
|
@@ -165,6 +213,12 @@ export function analyzeSyntaxComplexity(root, { family = "", name = "" } = {}) {
|
|
|
165
213
|
sorts: stats.sorts,
|
|
166
214
|
linearOps: stats.linearOps,
|
|
167
215
|
recursion: stats.recursion,
|
|
216
|
+
allocationsInLoops: stats.allocationsInLoops,
|
|
217
|
+
copiesInLoops: stats.copiesInLoops,
|
|
218
|
+
linearOpsInLoops: stats.linearOpsInLoops,
|
|
219
|
+
sortsInLoops: stats.sortsInLoops,
|
|
220
|
+
recursionInLoops: stats.recursionInLoops,
|
|
221
|
+
hotEvidence: stats.hotEvidence,
|
|
168
222
|
...labels,
|
|
169
223
|
scope: "local",
|
|
170
224
|
complexityScope: "local",
|
package/src/bounds.js
ADDED
|
@@ -14,6 +14,10 @@ const TOPVARS = `
|
|
|
14
14
|
(program (variable_declaration (variable_declarator) @decl))
|
|
15
15
|
(program (export_statement (lexical_declaration (variable_declarator) @decl)))
|
|
16
16
|
(program (export_statement (variable_declaration (variable_declarator) @decl)))`;
|
|
17
|
+
const TYPES = `
|
|
18
|
+
(interface_declaration name: (_) @interface)
|
|
19
|
+
(type_alias_declaration name: (_) @type)
|
|
20
|
+
(enum_declaration name: (_) @enum)`;
|
|
17
21
|
const REQUIRE = `(variable_declarator name: (_) @lhs value: (call_expression function: (identifier) @req arguments: (arguments (string (string_fragment) @src))))`;
|
|
18
22
|
|
|
19
23
|
function parseExportSpecifiers(raw) {
|
|
@@ -95,7 +99,13 @@ export default {
|
|
|
95
99
|
sourceNode: cap.node.parent,
|
|
96
100
|
selectionNode: cap.node,
|
|
97
101
|
...(exportStatement ? { exported: true } : {}),
|
|
98
|
-
...(isMethod
|
|
102
|
+
...(isMethod
|
|
103
|
+
? {...methodMetadata(cap.node), symbolSpace: "value"}
|
|
104
|
+
: {
|
|
105
|
+
symbolKind: cap.name === "class" ? "class" : "function",
|
|
106
|
+
symbolSpace: cap.name === "class" ? "both" : "value",
|
|
107
|
+
moduleDeclaration: isModuleDeclaration(cap.node),
|
|
108
|
+
})
|
|
99
109
|
});
|
|
100
110
|
if (exportStatement) {
|
|
101
111
|
recordJsExport({
|
|
@@ -103,6 +113,7 @@ export default {
|
|
|
103
113
|
exported: /^\s*export\s+default\b/.test(exportStatement.text) ? "default" : cap.node.text,
|
|
104
114
|
local: cap.node.text,
|
|
105
115
|
typeOnly: false,
|
|
116
|
+
line: exportStatement.startPosition.row + 1,
|
|
106
117
|
});
|
|
107
118
|
}
|
|
108
119
|
}
|
|
@@ -115,9 +126,40 @@ export default {
|
|
|
115
126
|
selectionNode: nameNode,
|
|
116
127
|
...(exported ? { exported: true } : {}),
|
|
117
128
|
symbolKind: "variable",
|
|
129
|
+
symbolSpace: "value",
|
|
118
130
|
moduleDeclaration: true
|
|
119
131
|
});
|
|
120
|
-
if (exported) recordJsExport({
|
|
132
|
+
if (exported) recordJsExport({
|
|
133
|
+
kind: "local", exported: nameNode.text, local: nameNode.text, typeOnly: false,
|
|
134
|
+
line: exportStatementOf(cap.node)?.startPosition.row + 1 || nameNode.startPosition.row + 1,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// TypeScript has separate type and value namespaces. Keep interface/type declarations as
|
|
139
|
+
// first-class type-space nodes instead of folding them into a same-named runtime declaration.
|
|
140
|
+
// Enums and classes intentionally occupy both spaces because TypeScript emits runtime values.
|
|
141
|
+
if (grammar !== "javascript") for (const cap of caps(grammar, TYPES, tree.rootNode)) {
|
|
142
|
+
const declaration = cap.node.parent;
|
|
143
|
+
const exportStatement = exportStatementOf(cap.node);
|
|
144
|
+
const symbolKind = cap.name === "interface" ? "interface" : cap.name === "type" ? "type" : "enum";
|
|
145
|
+
const symbolSpace = cap.name === "enum" ? "both" : "type";
|
|
146
|
+
addSym(cap.node.text, cap.node.startPosition.row + 1, false, {
|
|
147
|
+
sourceNode: declaration,
|
|
148
|
+
selectionNode: cap.node,
|
|
149
|
+
idSuffix: symbolSpace === "type" ? ":type" : "",
|
|
150
|
+
...(exportStatement ? {exported: true} : {}),
|
|
151
|
+
symbolKind,
|
|
152
|
+
symbolSpace,
|
|
153
|
+
moduleDeclaration: isModuleDeclaration(cap.node),
|
|
154
|
+
});
|
|
155
|
+
if (exportStatement) recordJsExport({
|
|
156
|
+
kind: "local",
|
|
157
|
+
exported: cap.node.text,
|
|
158
|
+
local: cap.node.text,
|
|
159
|
+
typeOnly: symbolSpace === "type",
|
|
160
|
+
line: exportStatement.startPosition.row + 1,
|
|
161
|
+
symbolSpace,
|
|
162
|
+
});
|
|
121
163
|
}
|
|
122
164
|
|
|
123
165
|
const importTypeOnly = (node) => {
|
|
@@ -218,12 +260,12 @@ export default {
|
|
|
218
260
|
const namespace = text.match(/^export\s+(type\s+)?\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\b/);
|
|
219
261
|
const clause = text.match(/^export\s+(type\s+)?\{([\s\S]*?)\}\s+from\b/);
|
|
220
262
|
if (namespace) {
|
|
221
|
-
recordJsExport({ kind: "namespace", exported: namespace[2], targetFile: tgt, typeOnly: !!namespace[1] });
|
|
263
|
+
recordJsExport({ kind: "namespace", exported: namespace[2], imported: "*", targetFile: tgt, typeOnly: !!namespace[1], line, specifier: rawSpec });
|
|
222
264
|
} else if (star) {
|
|
223
|
-
recordJsExport({ kind: "star", targetFile: tgt, typeOnly: !!star[1] });
|
|
265
|
+
recordJsExport({ kind: "star", exported: "*", imported: "*", targetFile: tgt, typeOnly: !!star[1], line, specifier: rawSpec });
|
|
224
266
|
} else if (clause) {
|
|
225
267
|
for (const spec of parseExportSpecifiers(clause[2])) {
|
|
226
|
-
recordJsExport({ kind: "named", exported: spec.exported, imported: spec.imported, targetFile: tgt, typeOnly: !!clause[1] || spec.typeOnly });
|
|
268
|
+
recordJsExport({ kind: "named", exported: spec.exported, imported: spec.imported, targetFile: tgt, typeOnly: !!clause[1] || spec.typeOnly, line, specifier: rawSpec });
|
|
227
269
|
}
|
|
228
270
|
}
|
|
229
271
|
}
|
|
@@ -239,17 +281,32 @@ export default {
|
|
|
239
281
|
const text = node.text.trim();
|
|
240
282
|
const clause = text.match(/^export\s+(type\s+)?\{([\s\S]*?)\}/);
|
|
241
283
|
if (clause) for (const spec of parseExportSpecifiers(clause[2])) {
|
|
242
|
-
recordJsExport({ kind: "local", exported: spec.exported, local: spec.imported, typeOnly: !!clause[1] || spec.typeOnly });
|
|
284
|
+
recordJsExport({ kind: "local", exported: spec.exported, local: spec.imported, typeOnly: !!clause[1] || spec.typeOnly, line: node.startPosition.row + 1 });
|
|
243
285
|
}
|
|
244
286
|
const identifierDefault = text.match(/^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?$/);
|
|
245
|
-
if (identifierDefault) recordJsExport({ kind: "local", exported: "default", local: identifierDefault[1], typeOnly: false });
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
287
|
+
if (identifierDefault) recordJsExport({ kind: "local", exported: "default", local: identifierDefault[1], typeOnly: false, line: node.startPosition.row + 1 });
|
|
288
|
+
const defaultValue = field(node, "value");
|
|
289
|
+
if (defaultValue?.type === "object") {
|
|
290
|
+
// Service facades commonly expose local helpers through `export default { getSchema,
|
|
291
|
+
// save: persist }`. Record the public member -> local binding instead of treating the
|
|
292
|
+
// object as an opaque default export.
|
|
293
|
+
recordJsExport({kind: "facade-root", exported: "default", local: "default", typeOnly: false, line: node.startPosition.row + 1});
|
|
294
|
+
for (const property of defaultValue.namedChildren || []) {
|
|
295
|
+
if (["shorthand_property_identifier", "shorthand_property_identifier_pattern"].includes(property.type)) {
|
|
296
|
+
recordJsExport({kind: "facade-member", member: property.text, local: property.text, typeOnly: false, line: node.startPosition.row + 1});
|
|
297
|
+
markExported(property.text);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (property.type !== "pair") continue;
|
|
301
|
+
const key = field(property, "key");
|
|
302
|
+
const value = field(property, "value");
|
|
303
|
+
if (!key || value?.type !== "identifier") continue;
|
|
304
|
+
const member = key.text.replace(/^['"`]|['"`]$/g, "");
|
|
305
|
+
if (!/^[A-Za-z_$][\w$]*$/.test(member)) continue;
|
|
306
|
+
recordJsExport({kind: "facade-member", member, local: value.text, typeOnly: false, line: node.startPosition.row + 1});
|
|
307
|
+
markExported(value.text);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
253
310
|
}
|
|
254
311
|
|
|
255
312
|
// ---- export markers beyond declarations: `export { a, b }`, `export default X`, CJS module.exports ----
|