weavatrix 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -11
- package/package.json +1 -1
- package/skill/SKILL.md +18 -7
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +9 -5
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/graph-analysis.aggregate.js +3 -1
- package/src/analysis/graph-analysis.refs.js +19 -11
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/bounds.js +4 -0
- package/src/graph/builder/lang-js.js +71 -14
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +5 -3
- package/src/graph/incremental-refresh.js +4 -2
- package/src/graph/internal-builder.barrels.js +71 -5
- package/src/graph/internal-builder.build.js +77 -16
- package/src/mcp/catalog.mjs +12 -7
- package/src/mcp/graph-context.mjs +4 -0
- package/src/mcp/graph-diff.mjs +1 -1
- package/src/mcp/sync-payload.mjs +3 -2
- package/src/mcp/tools-actions.mjs +3 -1
- package/src/mcp/tools-context.mjs +164 -0
- package/src/mcp/tools-graph.mjs +2 -0
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +238 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +29 -8
- package/src/precision/symbol-query.js +133 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {boundedInteger} from '../bounds.js'
|
|
3
|
+
import {resolveRepoPath} from '../repo-path.js'
|
|
4
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
5
|
+
import {querySymbolPrecision} from '../precision/symbol-query.js'
|
|
6
|
+
import {toolResult} from './tool-result.mjs'
|
|
7
|
+
import {degreeOf, isSymbol, labelOf, resolveNodeInfo} from './graph-context.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_EXCERPT_CHARS = 4_000
|
|
10
|
+
const MAX_LOCATION_SAMPLES = 5
|
|
11
|
+
const MAX_GRAPH_OCCURRENCES = 100
|
|
12
|
+
|
|
13
|
+
const sourceLine = (node) => {
|
|
14
|
+
const match = /L(\d+)/.exec(String(node?.source_location || ''))
|
|
15
|
+
return match ? Number(match[1]) : 1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function candidateNodes(g, query, limit = 20) {
|
|
19
|
+
const text = String(query || '').trim().toLowerCase()
|
|
20
|
+
if (!text) return []
|
|
21
|
+
const exact = g.byLabel.get(text)
|
|
22
|
+
const nodes = exact?.length ? exact : g.nodes.filter((node) =>
|
|
23
|
+
String(node.id).toLowerCase().includes(text) || String(node.label || '').toLowerCase().includes(text))
|
|
24
|
+
return nodes.slice(0, limit).map((node) => ({
|
|
25
|
+
id: String(node.id), label: String(node.label || node.id), file: node.source_file || null,
|
|
26
|
+
...(node.symbol_space ? {space: node.symbol_space} : {}),
|
|
27
|
+
}))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function excerpt(repoRoot, file, focusLine, contextLines) {
|
|
31
|
+
const resolved = resolveRepoPath(repoRoot, file)
|
|
32
|
+
if (!resolved.ok) return null
|
|
33
|
+
let size
|
|
34
|
+
try { size = statSync(resolved.path).size } catch { return null }
|
|
35
|
+
if (size > 2 * 1024 * 1024) return null
|
|
36
|
+
let lines
|
|
37
|
+
try { lines = readFileSync(resolved.path, 'utf8').split(/\r?\n/) } catch { return null }
|
|
38
|
+
const focus = Math.max(1, Math.min(lines.length, Number(focusLine) || 1))
|
|
39
|
+
const startLine = Math.max(1, focus - contextLines)
|
|
40
|
+
const endLine = Math.min(lines.length, focus + contextLines)
|
|
41
|
+
const rawText = lines.slice(startLine - 1, endLine).join('\n')
|
|
42
|
+
return {
|
|
43
|
+
file,
|
|
44
|
+
focusLine: focus,
|
|
45
|
+
startLine,
|
|
46
|
+
endLine,
|
|
47
|
+
text: rawText.length > MAX_EXCERPT_CHARS ? `${rawText.slice(0, MAX_EXCERPT_CHARS)}\n… [excerpt truncated]` : rawText,
|
|
48
|
+
truncated: rawText.length > MAX_EXCERPT_CHARS,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function reverseImpact(g, targetId, depth = 3, cap = 40) {
|
|
53
|
+
const seen = new Map([[targetId, 0]])
|
|
54
|
+
const queue = [targetId]
|
|
55
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
56
|
+
const current = queue[cursor]
|
|
57
|
+
const currentDepth = seen.get(current)
|
|
58
|
+
if (currentDepth >= depth) continue
|
|
59
|
+
for (const edge of g.inn.get(current) || []) {
|
|
60
|
+
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
61
|
+
const id = String(edge.id)
|
|
62
|
+
if (seen.has(id)) continue
|
|
63
|
+
seen.set(id, currentDepth + 1)
|
|
64
|
+
queue.push(id)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...seen.entries()]
|
|
68
|
+
.filter(([id]) => id !== targetId)
|
|
69
|
+
.map(([id, distance]) => ({id, label: labelOf(g, id), distance, degree: degreeOf(g, id)}))
|
|
70
|
+
.sort((left, right) => left.distance - right.distance || right.degree - left.degree)
|
|
71
|
+
.slice(0, cap)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function graphOccurrences(g, targetId) {
|
|
75
|
+
const all = (g.inn.get(targetId) || [])
|
|
76
|
+
.filter((edge) => !isStructuralRelation(edge.relation) && edge.barrelProxy !== true)
|
|
77
|
+
.map((edge) => ({
|
|
78
|
+
source: String(edge.id),
|
|
79
|
+
label: labelOf(g, edge.id),
|
|
80
|
+
relation: edge.relation || 'references',
|
|
81
|
+
provenance: edge.provenance || 'UNKNOWN',
|
|
82
|
+
...(Number.isInteger(edge.line) ? {line: edge.line} : {}),
|
|
83
|
+
...(edge.typeOnly === true ? {typeOnly: true} : {}),
|
|
84
|
+
...(edge.compileOnly === true ? {compileOnly: true} : {}),
|
|
85
|
+
}))
|
|
86
|
+
return {total: all.length, shown: all.slice(0, MAX_GRAPH_OCCURRENCES)}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function groupLocations(g, locations, cap) {
|
|
90
|
+
const groups = new Map()
|
|
91
|
+
for (const location of locations) {
|
|
92
|
+
const source = String(location.source || location.file || '')
|
|
93
|
+
if (!source) continue
|
|
94
|
+
const node = g.byId.get(source)
|
|
95
|
+
const file = String(location.file || node?.source_file || (isSymbol(source) ? source.split('#')[0] : source))
|
|
96
|
+
let group = groups.get(source)
|
|
97
|
+
if (!group) {
|
|
98
|
+
group = {id: source, label: String(node?.label || source), file, count: 0, classifications: {}, locations: []}
|
|
99
|
+
groups.set(source, group)
|
|
100
|
+
}
|
|
101
|
+
group.count++
|
|
102
|
+
const classification = String(location.classification || 'unknown')
|
|
103
|
+
group.classifications[classification] = (group.classifications[classification] || 0) + 1
|
|
104
|
+
if (group.locations.length < MAX_LOCATION_SAMPLES) group.locations.push({
|
|
105
|
+
line: location.line,
|
|
106
|
+
character: location.character,
|
|
107
|
+
...(Number.isInteger(location.endLine) ? {endLine: location.endLine} : {}),
|
|
108
|
+
...(Number.isInteger(location.endCharacter) ? {endCharacter: location.endCharacter} : {}),
|
|
109
|
+
classification,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
const all = [...groups.values()].sort((left, right) => right.count - left.count || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
|
|
113
|
+
return {all, shown: all.slice(0, cap)}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function textFor(result) {
|
|
117
|
+
if (result.status === 'NOT_FOUND') return `No symbol found matching "${result.query}".`
|
|
118
|
+
if (result.status === 'AMBIGUOUS') return [
|
|
119
|
+
`Symbol "${result.query}" is ambiguous (${result.candidates.length} candidate(s) shown). Supply an exact node ID:`,
|
|
120
|
+
...result.candidates.map((candidate) => ` ${candidate.label} [${candidate.id}]`),
|
|
121
|
+
].join('\n')
|
|
122
|
+
const definition = result.definition
|
|
123
|
+
const lines = [
|
|
124
|
+
`Symbol inspection: ${definition.label} [${definition.id}]`,
|
|
125
|
+
`Definition: ${definition.file}:${definition.line}`,
|
|
126
|
+
`Evidence: ${result.evidence.state}${result.evidence.cached ? ' (cache hit)' : ''}; ${result.exact.occurrences} exact reference occurrence(s) in ${result.exact.files} file(s) / ${result.exact.containers} container(s).`,
|
|
127
|
+
`Graph: ${result.graph.occurrenceTotal} direct occurrence edge(s) (${result.graph.occurrences.length} shown); ${result.graph.impact.length} dependent(s) shown.`,
|
|
128
|
+
]
|
|
129
|
+
if (result.evidence.reason) lines.push(`Completeness: ${result.evidence.reason}`)
|
|
130
|
+
if (result.exact.zeroReferences) lines.push('Exact result: zero non-declaration references in the completely covered project universe.')
|
|
131
|
+
if (result.exact.groups.length) {
|
|
132
|
+
lines.push('Reference containers:')
|
|
133
|
+
for (const group of result.exact.groups) lines.push(` ${group.count} site(s) ${group.label} [${group.id}]`)
|
|
134
|
+
}
|
|
135
|
+
if (result.source.definition) {
|
|
136
|
+
const source = result.source.definition
|
|
137
|
+
lines.push('', `Definition source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
|
|
138
|
+
}
|
|
139
|
+
for (const source of result.source.callers) {
|
|
140
|
+
lines.push('', `Caller source (${source.file}:${source.startLine}-${source.endLine}, focus ${source.focusLine}):`, source.text)
|
|
141
|
+
}
|
|
142
|
+
return lines.join('\n')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function tInspectSymbol(g, args = {}, ctx = {}) {
|
|
146
|
+
const query = String(args.label || '').trim()
|
|
147
|
+
const info = resolveNodeInfo(g, query)
|
|
148
|
+
if (!info.node) return toolResult(textFor({status: 'NOT_FOUND', query}), {status: 'NOT_FOUND', query})
|
|
149
|
+
if (info.matches > 1 && !g.byId.has(query)) {
|
|
150
|
+
const result = {status: 'AMBIGUOUS', query, candidates: candidateNodes(g, query)}
|
|
151
|
+
return toolResult(textFor(result), result, {completeness: {status: 'blocked', reason: 'ambiguous symbol'}})
|
|
152
|
+
}
|
|
153
|
+
const node = info.node
|
|
154
|
+
const targetId = String(node.id)
|
|
155
|
+
if (!isSymbol(targetId)) {
|
|
156
|
+
const result = {status: 'UNSUPPORTED', query, candidates: [{id: targetId, label: node.label || targetId, file: node.source_file || targetId}]}
|
|
157
|
+
return toolResult('inspect_symbol requires a symbol node, not a file node.', result)
|
|
158
|
+
}
|
|
159
|
+
const maxReferences = boundedInteger(args.max_references, 1_000, 1, 5_000)
|
|
160
|
+
const maxContainers = boundedInteger(args.max_containers, 15, 1, 50)
|
|
161
|
+
const contextLines = boundedInteger(args.context_lines, 8, 0, 40)
|
|
162
|
+
const timeoutMs = boundedInteger(args.timeout_ms, 30_000, 1_000, 60_000)
|
|
163
|
+
const precision = ['auto', 'graph', 'lsp'].includes(args.precision) ? args.precision : 'auto'
|
|
164
|
+
const shouldQuery = precision === 'lsp' || (precision === 'auto' && g.graphPrecisionMode !== 'off')
|
|
165
|
+
let overlay = null
|
|
166
|
+
let cached = false
|
|
167
|
+
let elapsedMs = 0
|
|
168
|
+
let queryError = null
|
|
169
|
+
if (shouldQuery) {
|
|
170
|
+
try {
|
|
171
|
+
const queryResult = await querySymbolPrecision({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, targetId, maxReferences, timeoutMs})
|
|
172
|
+
overlay = queryResult.overlay
|
|
173
|
+
cached = queryResult.cached
|
|
174
|
+
elapsedMs = queryResult.elapsedMs
|
|
175
|
+
} catch (error) {
|
|
176
|
+
queryError = error?.message || 'exact symbol query failed'
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const locations = Array.isArray(overlay?.locations) ? overlay.locations : []
|
|
180
|
+
const grouped = groupLocations(g, locations, maxContainers)
|
|
181
|
+
const files = new Set(locations.map((location) => String(location.file || '')).filter(Boolean))
|
|
182
|
+
let state = 'GRAPH_ONLY'
|
|
183
|
+
if (overlay?.state === 'COMPLETE') state = 'EXACT'
|
|
184
|
+
else if (overlay?.state === 'PARTIAL') state = 'PARTIAL'
|
|
185
|
+
else if (overlay?.state === 'OFF') state = 'OFF'
|
|
186
|
+
else if (shouldQuery) state = 'UNAVAILABLE'
|
|
187
|
+
const reason = queryError || overlay?.reason || (shouldQuery ? null : 'semantic precision was not requested')
|
|
188
|
+
const graphRefs = graphOccurrences(g, targetId)
|
|
189
|
+
const impact = reverseImpact(g, targetId)
|
|
190
|
+
const definition = {
|
|
191
|
+
id: targetId,
|
|
192
|
+
label: String(node.label || targetId),
|
|
193
|
+
kind: String(node.symbol_kind || 'symbol'),
|
|
194
|
+
space: String(node.symbol_space || 'value'),
|
|
195
|
+
exported: node.exported === true,
|
|
196
|
+
file: String(node.source_file || targetId.split('#')[0]),
|
|
197
|
+
line: sourceLine(node),
|
|
198
|
+
...(node.source_range ? {range: node.source_range} : {}),
|
|
199
|
+
...(node.complexity ? {complexity: node.complexity} : {}),
|
|
200
|
+
}
|
|
201
|
+
const definitionSource = excerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
|
|
202
|
+
const callerSources = grouped.shown.slice(0, 5).map((group) => excerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
|
|
203
|
+
const result = {
|
|
204
|
+
status: 'OK',
|
|
205
|
+
definition,
|
|
206
|
+
evidence: {state, cached, elapsedMs, reason, provider: overlay?.engines?.[0]?.provider || null},
|
|
207
|
+
exact: {
|
|
208
|
+
occurrences: locations.length,
|
|
209
|
+
occurrencesWithDefinition: locations.length + 1,
|
|
210
|
+
files: files.size,
|
|
211
|
+
containers: grouped.all.length,
|
|
212
|
+
groups: grouped.shown,
|
|
213
|
+
zeroReferences: overlay?.noReferenceSymbols?.includes(targetId) === true,
|
|
214
|
+
capped: overlay?.coverage?.truncated === true || grouped.all.length > grouped.shown.length,
|
|
215
|
+
},
|
|
216
|
+
graph: {
|
|
217
|
+
occurrences: graphRefs.shown,
|
|
218
|
+
occurrenceTotal: graphRefs.total,
|
|
219
|
+
occurrencesCapped: graphRefs.total > graphRefs.shown.length,
|
|
220
|
+
impact,
|
|
221
|
+
},
|
|
222
|
+
source: {definition: definitionSource, callers: callerSources},
|
|
223
|
+
}
|
|
224
|
+
const warnings = state === 'PARTIAL' || state === 'UNAVAILABLE'
|
|
225
|
+
? [{code: 'SYMBOL_PRECISION_INCOMPLETE', message: reason || `semantic precision is ${state.toLowerCase()}`}]
|
|
226
|
+
: []
|
|
227
|
+
return toolResult(textFor(result), result, {
|
|
228
|
+
warnings,
|
|
229
|
+
completeness: {
|
|
230
|
+
status: state === 'EXACT' && !result.exact.capped ? 'complete' : state.toLowerCase(),
|
|
231
|
+
referenceLimit: maxReferences,
|
|
232
|
+
containerLimit: maxContainers,
|
|
233
|
+
returnedContainers: grouped.shown.length,
|
|
234
|
+
locationSamplesPerContainer: MAX_LOCATION_SAMPLES,
|
|
235
|
+
graphOccurrenceLimit: MAX_GRAPH_OCCURRENCES,
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
}
|
|
@@ -425,7 +425,7 @@ export class StdioLspClient {
|
|
|
425
425
|
await this.writeMessage({jsonrpc: JSON_RPC_VERSION, method, params})
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
-
async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.
|
|
428
|
+
async initialize({capabilities = {}, initializationOptions, clientInfo = {name: 'weavatrix', version: '0.2.6'}} = {}) {
|
|
429
429
|
if (this.state !== 'running') throw new Error(`LSP initialize is invalid in state=${this.state}`)
|
|
430
430
|
const result = await this.request('initialize', {
|
|
431
431
|
processId: process.pid,
|
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { boundedInteger } from "../bounds.js";
|
|
5
6
|
import { atomicWriteFileSync } from "../graph/file-lock.js";
|
|
6
7
|
import { edgeProvenance } from "../graph/edge-provenance.js";
|
|
7
8
|
import { isStructuralRelation } from "../graph/relations.js";
|
|
@@ -274,8 +275,16 @@ function sourceAt(index, file, position) {
|
|
|
274
275
|
return index.files.has(file) ? file : null;
|
|
275
276
|
}
|
|
276
277
|
|
|
277
|
-
function eligibleTargets(graph, limit) {
|
|
278
|
+
function eligibleTargets(graph, limit, requestedIds = null) {
|
|
278
279
|
const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
280
|
+
if (Array.isArray(requestedIds) && requestedIds.length) {
|
|
281
|
+
const ids = [...new Set(requestedIds.map(String))];
|
|
282
|
+
const targets = ids
|
|
283
|
+
.map((id) => byId.get(id))
|
|
284
|
+
.filter((node) => node?.selection_start && JS_TS_FILE.test(String(node.source_file || "")))
|
|
285
|
+
.slice(0, limit);
|
|
286
|
+
return {targets, total: ids.length, orphanIds: new Set()};
|
|
287
|
+
}
|
|
279
288
|
const ranked = new Map();
|
|
280
289
|
const inbound = new Set();
|
|
281
290
|
for (const link of graph.links || []) {
|
|
@@ -442,11 +451,6 @@ function publicSemanticSafetyReason(reason) {
|
|
|
442
451
|
: "TypeScript project configuration could not be verified safely";
|
|
443
452
|
}
|
|
444
453
|
|
|
445
|
-
function boundedInteger(value, fallback, minimum, maximum) {
|
|
446
|
-
const number = Number(value);
|
|
447
|
-
return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback));
|
|
448
|
-
}
|
|
449
|
-
|
|
450
454
|
export async function buildLspPrecisionOverlay({
|
|
451
455
|
repoRoot,
|
|
452
456
|
graph,
|
|
@@ -456,6 +460,7 @@ export async function buildLspPrecisionOverlay({
|
|
|
456
460
|
maxReferences = Number(process.env.WEAVATRIX_PRECISION_MAX_REFERENCES) || 2_048,
|
|
457
461
|
maxLinks = Number(process.env.WEAVATRIX_PRECISION_MAX_LINKS) || 2_048,
|
|
458
462
|
timeoutMs = Number(process.env.WEAVATRIX_PRECISION_TIMEOUT_MS) || 45_000,
|
|
463
|
+
targetIds,
|
|
459
464
|
clientFactory,
|
|
460
465
|
} = {}) {
|
|
461
466
|
if (!graph || !repoRoot) throw new Error("precision overlay requires repoRoot and graph");
|
|
@@ -501,7 +506,7 @@ export async function buildLspPrecisionOverlay({
|
|
|
501
506
|
&& precisionOverlayMatches(cached, graph, {request})
|
|
502
507
|
&& cached.semanticInputFingerprint === semanticInputs.fingerprint) return cached;
|
|
503
508
|
}
|
|
504
|
-
const eligible = eligibleTargets(graph, boundedMax);
|
|
509
|
+
const eligible = eligibleTargets(graph, boundedMax, targetIds);
|
|
505
510
|
const targets = eligible.targets;
|
|
506
511
|
if (!targets.length) {
|
|
507
512
|
const overlay = baseOverlay(graph, "COMPLETE", {
|
|
@@ -525,6 +530,8 @@ export async function buildLspPrecisionOverlay({
|
|
|
525
530
|
let truncated = eligible.total > boundedMax;
|
|
526
531
|
const noReferenceSymbols = [];
|
|
527
532
|
const referenceEvidence = [];
|
|
533
|
+
const exactLocations = [];
|
|
534
|
+
const collectLocations = Array.isArray(targetIds) && targetIds.length > 0;
|
|
528
535
|
const opened = new Set();
|
|
529
536
|
const openedTexts = new Map();
|
|
530
537
|
const classificationTexts = new Map();
|
|
@@ -708,7 +715,6 @@ export async function buildLspPrecisionOverlay({
|
|
|
708
715
|
const start = locationStart(location);
|
|
709
716
|
if (!file || !start || !Number.isInteger(start.line) || !Number.isInteger(start.character)) continue;
|
|
710
717
|
const source = sourceAt(index, file, start);
|
|
711
|
-
if (!source || source === String(target.id)) continue;
|
|
712
718
|
const targetId = String(target.id);
|
|
713
719
|
const exactLine = start.line + 1;
|
|
714
720
|
const exactCharacter = start.character;
|
|
@@ -727,6 +733,20 @@ export async function buildLspPrecisionOverlay({
|
|
|
727
733
|
: classifyTypeScriptReferenceUsage(file, sourceText, start);
|
|
728
734
|
if (usage === "unknown" && moduleDependency?.typeOnly === true) usage = "type";
|
|
729
735
|
if (usage === "unknown" && moduleDependency?.compileOnly === true) usage = "compile";
|
|
736
|
+
if (collectLocations) exactLocations.push({
|
|
737
|
+
file,
|
|
738
|
+
source: source || file,
|
|
739
|
+
target: targetId,
|
|
740
|
+
line: exactLine,
|
|
741
|
+
character: exactCharacter,
|
|
742
|
+
...(Number.isInteger(location?.range?.end?.line) ? {endLine: location.range.end.line + 1} : {}),
|
|
743
|
+
...(Number.isInteger(location?.range?.end?.character) ? {endCharacter: location.range.end.character} : {}),
|
|
744
|
+
classification: usage,
|
|
745
|
+
});
|
|
746
|
+
if (!source || source === targetId) {
|
|
747
|
+
if (usage === "unknown") unclassifiedReferences++;
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
730
750
|
if (usage === "unknown") {
|
|
731
751
|
const evidenceKey = `${source}\0${targetId}\0${line}\0${exactCharacter}`;
|
|
732
752
|
if (!evidenceSeen.has(evidenceKey)) {
|
|
@@ -798,6 +818,7 @@ export async function buildLspPrecisionOverlay({
|
|
|
798
818
|
coverage: coverage(),
|
|
799
819
|
links,
|
|
800
820
|
referenceEvidence,
|
|
821
|
+
...(collectLocations ? {locations: exactLocations} : {}),
|
|
801
822
|
noReferenceSymbols,
|
|
802
823
|
...(errors ? {reason: `${errors} semantic request(s) failed or were refused`}
|
|
803
824
|
: truncated ? {reason: "semantic precision stopped at a configured safety limit"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {existsSync, readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {dirname, resolve} from 'node:path'
|
|
3
|
+
import {boundedInteger} from '../bounds.js'
|
|
4
|
+
import {atomicWriteFileSync, withFileLock} from '../graph/file-lock.js'
|
|
5
|
+
import {
|
|
6
|
+
buildLspPrecisionOverlay,
|
|
7
|
+
precisionOverlayMatches,
|
|
8
|
+
precisionSemanticInputsMatch,
|
|
9
|
+
} from './lsp-overlay.js'
|
|
10
|
+
|
|
11
|
+
export const SYMBOL_PRECISION_CACHE_V = 1
|
|
12
|
+
export const SYMBOL_PRECISION_CACHE_FILE = 'precision-symbols.json'
|
|
13
|
+
|
|
14
|
+
const MAX_CACHE_ENTRIES = 32
|
|
15
|
+
const MAX_CACHE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
const MAX_CACHE_READ_BYTES = 16 * 1024 * 1024
|
|
17
|
+
const inFlight = new Map()
|
|
18
|
+
|
|
19
|
+
export function symbolPrecisionCachePath(graphPath) {
|
|
20
|
+
return resolve(dirname(graphPath), SYMBOL_PRECISION_CACHE_FILE)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadRawGraph(graphPath) {
|
|
24
|
+
const graph = JSON.parse(readFileSync(graphPath, 'utf8'))
|
|
25
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.links)) {
|
|
26
|
+
throw new Error('the active graph is not a valid Weavatrix graph')
|
|
27
|
+
}
|
|
28
|
+
return graph
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function emptyCache() {
|
|
32
|
+
return {symbolPrecisionCacheV: SYMBOL_PRECISION_CACHE_V, entries: []}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function readCache(path) {
|
|
36
|
+
if (!existsSync(path)) return emptyCache()
|
|
37
|
+
try {
|
|
38
|
+
if (statSync(path).size > MAX_CACHE_READ_BYTES) return emptyCache()
|
|
39
|
+
const value = JSON.parse(readFileSync(path, 'utf8'))
|
|
40
|
+
if (Number(value?.symbolPrecisionCacheV) !== SYMBOL_PRECISION_CACHE_V || !Array.isArray(value.entries)) return emptyCache()
|
|
41
|
+
return {symbolPrecisionCacheV: SYMBOL_PRECISION_CACHE_V, entries: value.entries.slice(0, MAX_CACHE_ENTRIES)}
|
|
42
|
+
} catch {
|
|
43
|
+
return emptyCache()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writeBoundedCache(path, entries) {
|
|
48
|
+
const kept = entries
|
|
49
|
+
.filter((entry) => entry && typeof entry === 'object')
|
|
50
|
+
.sort((left, right) => Number(right.usedAt) - Number(left.usedAt))
|
|
51
|
+
.slice(0, MAX_CACHE_ENTRIES)
|
|
52
|
+
let body
|
|
53
|
+
do {
|
|
54
|
+
body = JSON.stringify({symbolPrecisionCacheV: SYMBOL_PRECISION_CACHE_V, entries: kept})
|
|
55
|
+
if (Buffer.byteLength(body) <= MAX_CACHE_BYTES || !kept.length) break
|
|
56
|
+
kept.pop()
|
|
57
|
+
} while (true)
|
|
58
|
+
atomicWriteFileSync(path, body, 'utf8')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cacheMatch(entry, {targetId, graph, repoRoot, request}) {
|
|
62
|
+
if (String(entry?.targetId || '') !== targetId || !entry.overlay) return false
|
|
63
|
+
if (!precisionOverlayMatches(entry.overlay, graph, {request})) return false
|
|
64
|
+
return precisionSemanticInputsMatch(entry.overlay, repoRoot, graph)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function storeEntry(path, entry) {
|
|
68
|
+
await withFileLock(`${path}.lock`, async () => {
|
|
69
|
+
const current = readCache(path)
|
|
70
|
+
const entries = current.entries.filter((item) => String(item?.targetId || '') !== entry.targetId)
|
|
71
|
+
entries.unshift(entry)
|
|
72
|
+
writeBoundedCache(path, entries)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function cacheable(overlay) {
|
|
77
|
+
if (overlay?.state === 'COMPLETE') return true
|
|
78
|
+
return overlay?.state === 'PARTIAL'
|
|
79
|
+
&& overlay?.reason === 'semantic precision stopped at a configured safety limit'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function querySymbolPrecision({
|
|
83
|
+
repoRoot,
|
|
84
|
+
graphPath,
|
|
85
|
+
targetId,
|
|
86
|
+
maxReferences = 1_000,
|
|
87
|
+
timeoutMs = 30_000,
|
|
88
|
+
clientFactory,
|
|
89
|
+
} = {}) {
|
|
90
|
+
if (!repoRoot || !graphPath || !targetId) throw new Error('symbol precision requires repoRoot, graphPath, and targetId')
|
|
91
|
+
const boundedReferences = boundedInteger(maxReferences, 1_000, 1, 5_000)
|
|
92
|
+
const boundedTimeout = boundedInteger(timeoutMs, 30_000, 1_000, 60_000)
|
|
93
|
+
const request = {maxSymbols: 1, maxReferences: boundedReferences, maxLinks: boundedReferences}
|
|
94
|
+
const rawGraph = loadRawGraph(graphPath)
|
|
95
|
+
const graph = rawGraph.graphPrecisionMode === 'off' ? {...rawGraph, graphPrecisionMode: 'lsp'} : rawGraph
|
|
96
|
+
const target = graph.nodes.find((node) => String(node?.id || '') === String(targetId))
|
|
97
|
+
if (!target) throw new Error('the selected symbol is not present in the active raw graph')
|
|
98
|
+
if (!target.selection_start || !/\.(?:[cm]?[jt]sx?)$/i.test(String(target.source_file || ''))) {
|
|
99
|
+
throw new Error('exact symbol precision currently supports JavaScript and TypeScript symbols with source selections')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const id = String(targetId)
|
|
103
|
+
const cachePath = symbolPrecisionCachePath(graphPath)
|
|
104
|
+
const startedAt = Date.now()
|
|
105
|
+
const cached = readCache(cachePath).entries.find((entry) => cacheMatch(entry, {targetId: id, graph, repoRoot, request}))
|
|
106
|
+
if (cached) return {overlay: cached.overlay, cached: true, elapsedMs: Date.now() - startedAt, cachePath}
|
|
107
|
+
|
|
108
|
+
const flightKey = `${graphPath}\0${graph.graphRevision || ''}\0${id}\0${boundedReferences}`
|
|
109
|
+
if (inFlight.has(flightKey)) return inFlight.get(flightKey)
|
|
110
|
+
const operation = (async () => {
|
|
111
|
+
const overlay = await buildLspPrecisionOverlay({
|
|
112
|
+
repoRoot,
|
|
113
|
+
graph,
|
|
114
|
+
mode: 'lsp',
|
|
115
|
+
maxSymbols: 1,
|
|
116
|
+
maxReferences: boundedReferences,
|
|
117
|
+
maxLinks: boundedReferences,
|
|
118
|
+
timeoutMs: boundedTimeout,
|
|
119
|
+
targetIds: [id],
|
|
120
|
+
clientFactory,
|
|
121
|
+
})
|
|
122
|
+
if (cacheable(overlay)) {
|
|
123
|
+
await storeEntry(cachePath, {targetId: id, usedAt: Date.now(), overlay})
|
|
124
|
+
}
|
|
125
|
+
return {overlay, cached: false, elapsedMs: Date.now() - startedAt, cachePath}
|
|
126
|
+
})()
|
|
127
|
+
inFlight.set(flightKey, operation)
|
|
128
|
+
try {
|
|
129
|
+
return await operation
|
|
130
|
+
} finally {
|
|
131
|
+
inFlight.delete(flightKey)
|
|
132
|
+
}
|
|
133
|
+
}
|