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.
- package/README.md +91 -20
- package/SECURITY.md +18 -0
- package/package.json +3 -1
- package/skill/SKILL.md +49 -13
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +22 -8
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/git-ref-graph.js +10 -2
- 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/build-graph.js +49 -10
- package/src/graph/build-worker.js +21 -1
- package/src/graph/builder/lang-java.js +1 -0
- package/src/graph/builder/lang-js.js +24 -0
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +3 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.barrels.js +33 -4
- package/src/graph/internal-builder.build.js +52 -8
- package/src/mcp/catalog.mjs +14 -11
- package/src/mcp/graph-context.mjs +143 -14
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +59 -20
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph.mjs +12 -6
- package/src/mcp/tools-health.mjs +112 -11
- package/src/mcp/tools-impact.mjs +24 -6
- package/src/mcp/tools-source.mjs +237 -0
- package/src/mcp-server.mjs +99 -11
- package/src/path-classification.js +2 -2
- package/src/precision/lsp-client.js +682 -0
- package/src/precision/lsp-overlay.js +872 -0
- package/src/precision/symbol-query.js +137 -0
- 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))
|
|
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/build-graph.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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 ----
|