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.
@@ -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)) stats.allocations++;
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) stats.recursion = true;
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
- } else if (LINEAR_CALLS.has(facts.normalizedCall) && !facts.iteratorCall) stats.linearOps++;
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
@@ -0,0 +1,4 @@
1
+ export function boundedInteger(value, fallback, minimum, maximum) {
2
+ const number = Number(value);
3
+ return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback));
4
+ }
@@ -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 ? methodMetadata(cap.node) : { symbolKind: cap.name === "class" ? "class" : "function", moduleDeclaration: isModuleDeclaration(cap.node) })
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({ kind: "local", exported: nameNode.text, local: nameNode.text, typeOnly: false });
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 typedDeclaration = text.match(/^export\s+(?:declare\s+)?(type|interface|enum)\s+([A-Za-z_$][\w$]*)\b/);
247
- if (typedDeclaration) recordJsExport({
248
- kind: "local",
249
- exported: typedDeclaration[2],
250
- local: typedDeclaration[2],
251
- typeOnly: typedDeclaration[1] !== "enum",
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 ----