weavatrix 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +91 -20
  2. package/SECURITY.md +18 -0
  3. package/package.json +3 -1
  4. package/skill/SKILL.md +49 -13
  5. package/src/analysis/dead-check.js +48 -2
  6. package/src/analysis/dead-code-review.js +22 -8
  7. package/src/analysis/duplicates.compute.js +11 -6
  8. package/src/analysis/git-ref-graph.js +10 -2
  9. package/src/analysis/hot-path-review.js +228 -0
  10. package/src/analysis/internal-audit.run.js +36 -0
  11. package/src/analysis/source-complexity.report.js +5 -0
  12. package/src/analysis/source-complexity.walk.js +64 -10
  13. package/src/build-graph.js +49 -10
  14. package/src/graph/build-worker.js +21 -1
  15. package/src/graph/builder/lang-java.js +1 -0
  16. package/src/graph/builder/lang-js.js +24 -0
  17. package/src/graph/builder/lang-python.js +161 -4
  18. package/src/graph/freshness-probe.js +3 -3
  19. package/src/graph/incremental-refresh.js +1 -1
  20. package/src/graph/internal-builder.barrels.js +33 -4
  21. package/src/graph/internal-builder.build.js +52 -8
  22. package/src/mcp/catalog.mjs +14 -11
  23. package/src/mcp/graph-context.mjs +143 -14
  24. package/src/mcp/sync-payload.mjs +2 -1
  25. package/src/mcp/tools-actions.mjs +59 -20
  26. package/src/mcp/tools-company.mjs +11 -3
  27. package/src/mcp/tools-graph.mjs +12 -6
  28. package/src/mcp/tools-health.mjs +112 -11
  29. package/src/mcp/tools-impact.mjs +24 -6
  30. package/src/mcp/tools-source.mjs +237 -0
  31. package/src/mcp-server.mjs +99 -11
  32. package/src/path-classification.js +2 -2
  33. package/src/precision/lsp-client.js +682 -0
  34. package/src/precision/lsp-overlay.js +872 -0
  35. package/src/precision/symbol-query.js +137 -0
  36. package/src/precision/typescript-lsp-provider.js +682 -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)) 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",
@@ -14,7 +14,8 @@ import { graphHomeDir, graphOutDirForRepo, graphStorageKey, repoTopFolders, summ
14
14
  import { registerRepository } from "./graph/repo-registry.js";
15
15
  import { refreshGraphIncrementally, snapshotRepository } from "./graph/incremental-refresh.js";
16
16
  import { atomicWriteFileSync, withFileLock } from "./graph/file-lock.js";
17
- import { repositoryFreshnessProbe, stampRepositoryFreshness } from "./graph/freshness-probe.js";
17
+ import { graphSchemaIsCurrent, repositoryFreshnessProbe, stampRepositoryFreshness } from "./graph/freshness-probe.js";
18
+ import { buildLspPrecisionOverlay, invalidatePrecisionOverlay, precisionSummary } from "./precision/lsp-overlay.js";
18
19
 
19
20
  // The worker path deadlocks web-tree-sitter's WASM in Electron's worker threads (fine in plain Node) — off
20
21
  // until that's Electron-safe. In-process + event-loop yielding keeps the window responsive without it.
@@ -26,6 +27,10 @@ const USE_BUILD_WORKER = false;
26
27
  // freeze the whole app on the main thread). 4 min is generous for very large repos.
27
28
  const BUILD_WORKER_TIMEOUT_MS = 4 * 60 * 1000;
28
29
 
30
+ export function defaultPrecisionMode(env = process.env) {
31
+ return env?.WEAVATRIX_PRECISION === "off" ? "off" : "lsp";
32
+ }
33
+
29
34
  function buildGraphInWorker(payload) {
30
35
  return new Promise((resolve, reject) => {
31
36
  let worker;
@@ -56,7 +61,7 @@ function buildGraphInWorker(payload) {
56
61
  });
57
62
  }
58
63
 
59
- async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central }) {
64
+ async function buildAndWriteInProcess(repoPath, { mode, scope, precision, graphJson, central }) {
60
65
  const { buildInternalGraph } = await import("./graph/internal-builder.js");
61
66
  // Capture the cheap Git state on both sides of the authoritative build. Only an unchanged pair is
62
67
  // safe to persist: if the working tree moves while parsing, the next process must take the slow path.
@@ -66,7 +71,7 @@ async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, centra
66
71
  if (mode === "full" && !scope && existsSync(graphJson)) {
67
72
  try {
68
73
  const existing = JSON.parse(readFileSync(graphJson, "utf8"));
69
- if (existing.graphBuildMode === "full" && !existing.graphBuildScope) {
74
+ if (existing.graphBuildMode === "full" && !existing.graphBuildScope && graphSchemaIsCurrent(existing)) {
70
75
  refresh = await refreshGraphIncrementally(repoPath, existing, { buildGraph: buildInternalGraph });
71
76
  graph = refresh.graph;
72
77
  }
@@ -75,7 +80,7 @@ async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, centra
75
80
  if (!graph && mode !== "full" && !scope && existsSync(graphJson)) {
76
81
  try {
77
82
  const existing = JSON.parse(readFileSync(graphJson, "utf8"));
78
- if (existing.graphBuildMode === mode && existing.graphRevision) {
83
+ if (existing.graphBuildMode === mode && existing.graphRevision && graphSchemaIsCurrent(existing)) {
79
84
  const snapshot = snapshotRepository(repoPath);
80
85
  if (snapshot.revision === existing.graphRevision) {
81
86
  graph = existing;
@@ -93,6 +98,9 @@ async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, centra
93
98
  if (scope) graph = filterGraphByScope(graph, scope);
94
99
  graph.graphBuildMode = mode;
95
100
  graph.graphBuildScope = scope || "";
101
+ const requestedPrecision = precision === "off" ? "off" : "lsp";
102
+ const precisionModeChanged = graph.graphPrecisionMode !== requestedPrecision;
103
+ graph.graphPrecisionMode = requestedPrecision;
96
104
  const probeAfter = scope ? null : repositoryFreshnessProbe(repoPath);
97
105
  const stableProbe = probeBefore && probeAfter === probeBefore ? probeAfter : null;
98
106
  const freshnessMetadataChanged = stampRepositoryFreshness(graph, stableProbe, mode);
@@ -100,20 +108,50 @@ async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, centra
100
108
  // multi-megabyte graph merely to answer that nothing changed (or manufacture a newer mtime).
101
109
  // A one-time metadata-only write is intentional for legacy graphs so the next MCP process can use
102
110
  // the persisted probe instead of repeating a full repository snapshot.
103
- if (refresh?.kind !== "none" || freshnessMetadataChanged) {
111
+ if (refresh?.kind !== "none" || freshnessMetadataChanged || precisionModeChanged) {
104
112
  mkdirSync(central, { recursive: true });
105
113
  atomicWriteFileSync(graphJson, JSON.stringify(graph), "utf8");
106
114
  }
115
+ mkdirSync(central, { recursive: true });
116
+ let precisionOverlay = await buildLspPrecisionOverlay({
117
+ repoRoot: repoPath,
118
+ graph,
119
+ graphPath: graphJson,
120
+ mode: requestedPrecision,
121
+ });
122
+ if (requestedPrecision === "lsp") {
123
+ try {
124
+ // LSP deliberately runs after graph serialization and can outlive the initial snapshot. A
125
+ // complete second content snapshot catches source/config add-delete races (including Git
126
+ // assume-unchanged paths) that a status-only token cannot prove away.
127
+ if (snapshotRepository(repoPath).revision !== graph.graphRevision) {
128
+ precisionOverlay = invalidatePrecisionOverlay(graphJson, graph);
129
+ }
130
+ } catch {
131
+ precisionOverlay = invalidatePrecisionOverlay(
132
+ graphJson,
133
+ graph,
134
+ "repository freshness could not be verified after semantic precision",
135
+ );
136
+ }
137
+ }
107
138
  return {
108
139
  nodes: graph.nodes.length,
109
140
  links: graph.links.length,
110
141
  communities: summarizeCommunities(graphJson),
111
142
  hotspots: summarizeHotspots(graphJson),
112
143
  refresh,
144
+ precision: precisionSummary(precisionOverlay),
113
145
  };
114
146
  }
115
147
 
116
- export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", outDir, graphHome } = {}) {
148
+ export async function buildGraphForRepo(repoPath, {
149
+ mode = "full",
150
+ scope = "",
151
+ precision = defaultPrecisionMode(),
152
+ outDir,
153
+ graphHome,
154
+ } = {}) {
117
155
  if (!existsSync(repoPath)) return { ok: false, error: "Repo path not found", builder: "internal" };
118
156
  const registryHome = graphHome || graphHomeDir();
119
157
  const canonicalDir = graphHome ? join(registryHome, graphStorageKey(repoPath)) : graphOutDirForRepo(repoPath);
@@ -126,11 +164,11 @@ export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", o
126
164
  // between file chunks, so the main-thread parse no longer freezes the window — the reason the worker was
127
165
  // introduced. buildGraphInWorker/USE_BUILD_WORKER are kept for a future Electron-safe re-enable.
128
166
  const build = () => USE_BUILD_WORKER
129
- ? buildGraphInWorker({ repoPath, mode, scope, graphJson, central }).catch((e) => {
130
- if (e && e.workerStartFailed) return buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
167
+ ? buildGraphInWorker({ repoPath, mode, scope, precision, graphJson, central }).catch((e) => {
168
+ if (e && e.workerStartFailed) return buildAndWriteInProcess(repoPath, { mode, scope, precision, graphJson, central });
131
169
  throw e;
132
170
  })
133
- : buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
171
+ : buildAndWriteInProcess(repoPath, { mode, scope, precision, graphJson, central });
134
172
  // Canonical graphs are shared by all local MCP clients. Serialize the complete read/refresh/write
135
173
  // transaction so an older process cannot overwrite a newer incremental result.
136
174
  const canonical = !scope && resolve(central) === resolve(canonicalDir);
@@ -154,7 +192,8 @@ export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", o
154
192
  communities: built.communities,
155
193
  hotspots: built.hotspots,
156
194
  refresh: built.refresh,
157
- log: `built-in builder: ${built.nodes} nodes, ${built.links} links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"})`
195
+ precision: built.precision,
196
+ log: `built-in builder: ${built.nodes} nodes, ${built.links} static links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"}); semantic precision ${built.precision?.state || "UNAVAILABLE"}, ${built.precision?.verifiedEdges || 0} EXACT_LSP edge(s)`
158
197
  };
159
198
  } catch (error) {
160
199
  return { ok: false, error: `graph build failed: ${error.message}`, builder: "internal" };
@@ -8,20 +8,40 @@ import { parentPort, workerData } from "node:worker_threads";
8
8
  import { buildInternalGraph } from "./internal-builder.js";
9
9
  import { filterGraphForMode, filterGraphByScope, summarizeCommunities, summarizeHotspots } from "./layout.js";
10
10
  import { atomicWriteFileSync } from "./file-lock.js";
11
+ import { snapshotRepository } from "./incremental-refresh.js";
12
+ import { buildLspPrecisionOverlay, invalidatePrecisionOverlay, precisionSummary } from "../precision/lsp-overlay.js";
11
13
 
12
14
  (async () => {
13
- const { repoPath, mode, scope, graphJson, central } = workerData || {};
15
+ const { repoPath, mode, scope, precision, graphJson, central } = workerData || {};
14
16
  try {
15
17
  let graph = await buildInternalGraph(repoPath);
16
18
  if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode, { repoRoot: repoPath });
17
19
  if (scope) graph = filterGraphByScope(graph, scope);
20
+ graph.graphBuildMode = mode;
21
+ graph.graphBuildScope = scope || "";
22
+ graph.graphPrecisionMode = precision === "off" ? "off" : "lsp";
18
23
  atomicWriteFileSync(graphJson, JSON.stringify(graph), "utf8");
24
+ let overlay = await buildLspPrecisionOverlay({repoRoot: repoPath, graph, graphPath: graphJson, mode: graph.graphPrecisionMode});
25
+ if (graph.graphPrecisionMode === "lsp") {
26
+ try {
27
+ if (snapshotRepository(repoPath).revision !== graph.graphRevision) {
28
+ overlay = invalidatePrecisionOverlay(graphJson, graph);
29
+ }
30
+ } catch {
31
+ overlay = invalidatePrecisionOverlay(
32
+ graphJson,
33
+ graph,
34
+ "repository freshness could not be verified after semantic precision",
35
+ );
36
+ }
37
+ }
19
38
  parentPort.postMessage({
20
39
  ok: true,
21
40
  nodes: graph.nodes.length,
22
41
  links: graph.links.length,
23
42
  communities: summarizeCommunities(graphJson),
24
43
  hotspots: summarizeHotspots(graphJson),
44
+ precision: precisionSummary(overlay),
25
45
  });
26
46
  } catch (e) {
27
47
  parentPort.postMessage({ ok: false, error: (e && e.message) || String(e) });
@@ -83,6 +83,7 @@ export default {
83
83
  const id = nodeIds.has(base) ? `${base}:c${nameNode.startPosition.column + 1}` : base;
84
84
  addSym(nameNode.text, lineOf(nameNode), callable, {
85
85
  ...extra,
86
+ selectionNode: nameNode,
86
87
  ...(id === base ? {} : { idSuffix: id.slice(base.length) }),
87
88
  });
88
89
  return nodeIds.has(id) ? id : null;
@@ -93,6 +93,7 @@ export default {
93
93
  const exportStatement = !isMethod && exportStatementOf(cap.node);
94
94
  addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name !== "class", {
95
95
  sourceNode: cap.node.parent,
96
+ selectionNode: cap.node,
96
97
  ...(exportStatement ? { exported: true } : {}),
97
98
  ...(isMethod ? methodMetadata(cap.node) : { symbolKind: cap.name === "class" ? "class" : "function", moduleDeclaration: isModuleDeclaration(cap.node) })
98
99
  });
@@ -111,6 +112,7 @@ export default {
111
112
  const exported = isExportedDecl(cap.node);
112
113
  addSym(nameNode.text, nameNode.startPosition.row + 1, !!(val && CALLABLE.test(val.type)), {
113
114
  sourceNode: val || cap.node,
115
+ selectionNode: nameNode,
114
116
  ...(exported ? { exported: true } : {}),
115
117
  symbolKind: "variable",
116
118
  moduleDeclaration: true
@@ -248,6 +250,28 @@ export default {
248
250
  local: typedDeclaration[2],
249
251
  typeOnly: typedDeclaration[1] !== "enum",
250
252
  });
253
+ const defaultValue = field(node, "value");
254
+ if (defaultValue?.type === "object") {
255
+ // Service facades commonly expose local helpers through `export default { getSchema,
256
+ // save: persist }`. Record the public member -> local binding instead of treating the
257
+ // object as an opaque default export.
258
+ recordJsExport({kind: "facade-root", exported: "default", local: "default", typeOnly: false});
259
+ for (const property of defaultValue.namedChildren || []) {
260
+ if (["shorthand_property_identifier", "shorthand_property_identifier_pattern"].includes(property.type)) {
261
+ recordJsExport({kind: "facade-member", member: property.text, local: property.text, typeOnly: false});
262
+ markExported(property.text);
263
+ continue;
264
+ }
265
+ if (property.type !== "pair") continue;
266
+ const key = field(property, "key");
267
+ const value = field(property, "value");
268
+ if (!key || value?.type !== "identifier") continue;
269
+ const member = key.text.replace(/^['"`]|['"`]$/g, "");
270
+ if (!/^[A-Za-z_$][\w$]*$/.test(member)) continue;
271
+ recordJsExport({kind: "facade-member", member, local: value.text, typeOnly: false});
272
+ markExported(value.text);
273
+ }
274
+ }
251
275
  }
252
276
 
253
277
  // ---- export markers beyond declarations: `export { a, b }`, `export default X`, CJS module.exports ----