weavatrix 0.2.4 → 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 +36 -10
- package/package.json +1 -1
- package/skill/SKILL.md +17 -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/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/graph/builder/lang-js.js +22 -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 +20 -5
- package/src/mcp/catalog.mjs +10 -7
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +1 -1
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +237 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +28 -3
- package/src/precision/symbol-query.js +137 -0
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -95,7 +95,8 @@ const COMPLEXITY_NUMBERS = [
|
|
|
95
95
|
'startLine', 'endLine', 'loc', 'params', 'objectFields', 'branches', 'cyclomatic',
|
|
96
96
|
'loops', 'maxLoopDepth', 'returns', 'awaits', 'callCount', 'externalCalls',
|
|
97
97
|
'asyncBoundaries', 'allocations', 'objectLiterals', 'spreadCopies', 'sorts',
|
|
98
|
-
'linearOps', '
|
|
98
|
+
'linearOps', 'allocationsInLoops', 'copiesInLoops', 'linearOpsInLoops',
|
|
99
|
+
'sortsInLoops', 'recursionInLoops', 'timeRank', 'timeScore', 'memoryRank', 'memoryScore',
|
|
99
100
|
];
|
|
100
101
|
|
|
101
102
|
function sanitizeComplexity(value) {
|
|
@@ -91,7 +91,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
91
91
|
savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
|
|
92
92
|
schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
|
|
93
93
|
|| !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
|
|
94
|
-
|| !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV <
|
|
94
|
+
|| !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 4
|
|
95
95
|
if (savedPrecision === 'lsp') {
|
|
96
96
|
const overlay = readPrecisionOverlay(graphPath, saved)
|
|
97
97
|
precisionUpgrade = !overlay || (typeof overlay.semanticInputFingerprint === 'string'
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.j
|
|
|
11
11
|
import {detectEndpoints} from '../analysis/endpoints.js'
|
|
12
12
|
import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
|
|
13
13
|
import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
|
|
14
|
+
import {computeHotPathReview} from '../analysis/hot-path-review.js'
|
|
14
15
|
import {collectNonRuntimeRoots, collectPackageScopes, collectSourceTexts, readRepoJson} from '../analysis/internal-audit.collect.js'
|
|
15
16
|
import {entryFiles} from '../analysis/internal-audit.reach.js'
|
|
16
17
|
import {buildInternalGraph} from '../graph/internal-builder.js'
|
|
@@ -53,7 +54,7 @@ function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
|
|
|
53
54
|
export function tFindDuplicates(g, args, ctx) {
|
|
54
55
|
if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
|
|
55
56
|
const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
|
|
56
|
-
const tokMin = Math.min(400, Math.max(
|
|
57
|
+
const tokMin = Math.min(400, Math.max(12, Number(args.min_tokens) || 50))
|
|
57
58
|
const mode = args.mode === 'strict' ? 'strict' : 'renamed'
|
|
58
59
|
const skipTests = args.include_tests ? false : true
|
|
59
60
|
const includeClassified = args.include_classified === true || args.include_non_product === true
|
|
@@ -61,7 +62,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
61
62
|
// semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
|
|
62
63
|
// (same name, drifted behavior). Token-clone pairing is skipped entirely.
|
|
63
64
|
if (args.mode === 'semantic') {
|
|
64
|
-
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true})
|
|
65
|
+
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true, minTokens: tokMin})
|
|
65
66
|
const frags = data.frags
|
|
66
67
|
const candidates = []
|
|
67
68
|
for (const twin of data.nameTwins || []) {
|
|
@@ -96,13 +97,14 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
96
97
|
})
|
|
97
98
|
return `Found ${candidates.length} actionable same-name pair(s) across files (semantic mode; one closest clone and/or farthest collision per name). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nThese are review candidates, not automatic refactors. Use read_source on both sites before changing code.`
|
|
98
99
|
}
|
|
99
|
-
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
|
|
100
|
+
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings, minTokens: tokMin})
|
|
100
101
|
const groups = groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
|
|
102
|
+
const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
|
|
101
103
|
const suppressed = data.frags.filter((fragment) => !fragmentEligible(fragment, {tokMin, skipTests, includeClassified})).length
|
|
102
104
|
const suppressionNote = suppressed && !includeClassified
|
|
103
105
|
? ` ${suppressed} fragment(s) classified as tests/e2e/generated/mock/story/docs/benchmark/temp or matched by .weavatrix.json exclude were suppressed; pass include_classified:true (and include_tests:true for tests) to inspect them explicitly.`
|
|
104
106
|
: ''
|
|
105
|
-
if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${suppressionNote}`
|
|
107
|
+
if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}`
|
|
106
108
|
const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
|
|
107
109
|
const lines = top.map((grp, k) => {
|
|
108
110
|
const isStr = grp.members.some((f) => f.kind === 'string')
|
|
@@ -110,7 +112,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
110
112
|
const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
|
|
111
113
|
return [head, ...sites].join('\n')
|
|
112
114
|
})
|
|
113
|
-
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${suppressionNote}`
|
|
115
|
+
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}`
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
// Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
|
|
@@ -227,10 +229,12 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
|
|
|
227
229
|
const summary = summarizeFindings(findings)
|
|
228
230
|
const sev = summary.bySeverity
|
|
229
231
|
const bycat = summary.byCategory
|
|
232
|
+
const deps = audit.dependencyReport || {}
|
|
230
233
|
return [
|
|
231
234
|
heading,
|
|
232
235
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
233
236
|
...auditConventionLines(audit),
|
|
237
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
|
|
234
238
|
`Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
235
239
|
`Repository-level ${auditChecksLine(audit)}`,
|
|
236
240
|
'',
|
|
@@ -418,11 +422,13 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
418
422
|
const shown = filtered.slice(0, max)
|
|
419
423
|
const sev = audit.summary.bySeverity
|
|
420
424
|
const bycat = audit.summary.byCategory
|
|
425
|
+
const deps = audit.dependencyReport || {}
|
|
421
426
|
const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
|
|
422
427
|
return [
|
|
423
428
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
424
429
|
`Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
425
430
|
`Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
|
|
431
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
|
|
426
432
|
...auditConventionLines(audit),
|
|
427
433
|
`Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
|
|
428
434
|
``,
|
|
@@ -509,6 +515,61 @@ export function tModuleMap(g, args, ctx) {
|
|
|
509
515
|
].filter((line) => line != null).join('\n')
|
|
510
516
|
}
|
|
511
517
|
|
|
518
|
+
// Parser-backed local cost review. This keeps syntax, graph coupling and measured/static test
|
|
519
|
+
// evidence as separate fields so callers cannot mistake a ranking heuristic for profiler output.
|
|
520
|
+
export function tHotPathReview(g, args, ctx) {
|
|
521
|
+
const review = computeHotPathReview(rawGraph(ctx), {
|
|
522
|
+
repoRoot: ctx?.repoRoot || null,
|
|
523
|
+
path: args.path,
|
|
524
|
+
includeTests: args.include_tests === true,
|
|
525
|
+
includeClassified: args.include_classified === true,
|
|
526
|
+
topN: args.top_n,
|
|
527
|
+
cyclomaticThreshold: args.cyclomatic_threshold,
|
|
528
|
+
callThreshold: args.call_threshold,
|
|
529
|
+
loopDepthThreshold: args.loop_depth_threshold,
|
|
530
|
+
timeRankThreshold: args.time_rank_threshold,
|
|
531
|
+
minScore: args.min_score,
|
|
532
|
+
})
|
|
533
|
+
if (!review.ok) return toolResult(`Hot-path review refused: ${review.error}.`, review)
|
|
534
|
+
const pct = (value) => typeof value === 'number' ? `${Math.round(value * 100)}%` : String(value || 'NOT_AVAILABLE')
|
|
535
|
+
const coverageLine = review.coverage.actualCoverage === 'AVAILABLE'
|
|
536
|
+
? `Measured coverage: ${review.coverage.measuredFiles} file(s) from ${review.coverage.sources.join(', ') || 'coverage report'}.`
|
|
537
|
+
: review.coverage.staticReachability
|
|
538
|
+
? `actualCoverage: NOT_AVAILABLE; static test reachability ${review.coverage.staticReachability.reachableFiles}/${review.coverage.staticReachability.productFiles} product file(s).`
|
|
539
|
+
: 'actualCoverage: NOT_AVAILABLE; no measured or static test evidence was available.'
|
|
540
|
+
const text = [
|
|
541
|
+
`Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
|
|
542
|
+
`Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
|
|
543
|
+
coverageLine,
|
|
544
|
+
'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
|
|
545
|
+
'',
|
|
546
|
+
...(review.hotspots.length ? review.hotspots.flatMap((item, index) => {
|
|
547
|
+
const tests = item.testEvidence.actualCoverage === 'NOT_AVAILABLE'
|
|
548
|
+
? item.testEvidence.staticReachable ? `static-test d${item.testEvidence.distance}` : 'no-static-test-path'
|
|
549
|
+
: `coverage ${pct(item.testEvidence.actualCoverage)}`
|
|
550
|
+
const pointer = `${item.file}${item.startLine ? `:${item.startLine}${item.endLine > item.startLine ? `-${item.endLine}` : ''}` : ''}`
|
|
551
|
+
const evidence = item.sourceEvidence.length
|
|
552
|
+
? `\n evidence: ${item.sourceEvidence.map((entry) => `${entry.kind}@L${entry.line || '?'}${entry.detail ? ` (${entry.detail})` : ''}`).join('; ')}`
|
|
553
|
+
: ''
|
|
554
|
+
return [
|
|
555
|
+
` ${String(index + 1).padStart(2)}. score ${String(item.score).padStart(5)} syntax ${String(item.localSyntax.score).padStart(5)} graph ${String(item.graphRisk.score).padStart(5)} ${item.confidence}`,
|
|
556
|
+
` ${item.label} (${pointer}; fan-in ${item.graphRisk.fanIn}, fan-out ${item.graphRisk.fanOut}; ${tests})`,
|
|
557
|
+
` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
|
|
558
|
+
]
|
|
559
|
+
}) : [' (none at the selected thresholds)']),
|
|
560
|
+
review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n, min_score, or narrow path).` : null,
|
|
561
|
+
'',
|
|
562
|
+
'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
|
|
563
|
+
].filter((line) => line != null).join('\n')
|
|
564
|
+
return toolResult(text, review, {
|
|
565
|
+
completeness: {
|
|
566
|
+
symbols: 'COMPLETE_FOR_INDEXED_GRAPH',
|
|
567
|
+
output: review.bounds.truncated ? 'BOUNDED' : 'COMPLETE',
|
|
568
|
+
coverage: review.coverage.actualCoverage,
|
|
569
|
+
},
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
512
573
|
// Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
|
|
513
574
|
// tests are never executed here) onto files and symbols, then rank refactor risk as
|
|
514
575
|
// connectivity × uncovered share. Pairs with get_dependents: many dependents + low coverage ⇒ write
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -59,7 +59,7 @@ const impactKind = (entry) => {
|
|
|
59
59
|
// Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
|
|
60
60
|
// (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
|
|
61
61
|
// containing file, because importers depend on the file rather than the individual symbol.
|
|
62
|
-
export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
62
|
+
export function tGetDependents(g, {label, depth = 3, max_nodes = 40, include_container_importers = false} = {}) {
|
|
63
63
|
const info = resolveNodeInfo(g, label)
|
|
64
64
|
const n = info.node
|
|
65
65
|
if (!n) return `No node found matching "${label}".`
|
|
@@ -69,7 +69,7 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
69
69
|
const id = String(n.id)
|
|
70
70
|
const seeds = new Set([id])
|
|
71
71
|
let containingFile = null
|
|
72
|
-
if (isSymbol(id)) {
|
|
72
|
+
if (include_container_importers === true && isSymbol(id)) {
|
|
73
73
|
const container = (g.inn.get(id) || []).find((e) => e.relation === 'contains')
|
|
74
74
|
if (container) {
|
|
75
75
|
containingFile = String(container.id)
|
|
@@ -88,7 +88,7 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
88
88
|
return [
|
|
89
89
|
note,
|
|
90
90
|
`Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
|
|
91
|
-
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
|
|
91
|
+
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)} by explicit request.` : null,
|
|
92
92
|
...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} [${r.entry.provenance || 'UNKNOWN'}] ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
93
93
|
].filter(Boolean).join('\n')
|
|
94
94
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {resolveRepoPath} from '../repo-path.js'
|
|
3
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
4
|
+
import {querySymbolPrecision} from '../precision/symbol-query.js'
|
|
5
|
+
import {toolResult} from './tool-result.mjs'
|
|
6
|
+
import {degreeOf, isSymbol, labelOf, resolveNodeInfo} from './graph-context.mjs'
|
|
7
|
+
|
|
8
|
+
const MAX_EXCERPT_CHARS = 4_000
|
|
9
|
+
const MAX_LOCATION_SAMPLES = 5
|
|
10
|
+
const MAX_GRAPH_OCCURRENCES = 100
|
|
11
|
+
|
|
12
|
+
const boundedInteger = (value, fallback, minimum, maximum) => {
|
|
13
|
+
const number = Number(value)
|
|
14
|
+
return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sourceLine = (node) => {
|
|
18
|
+
const match = /L(\d+)/.exec(String(node?.source_location || ''))
|
|
19
|
+
return match ? Number(match[1]) : 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function candidateNodes(g, query, limit = 20) {
|
|
23
|
+
const text = String(query || '').trim().toLowerCase()
|
|
24
|
+
if (!text) return []
|
|
25
|
+
const exact = g.byLabel.get(text)
|
|
26
|
+
const nodes = exact?.length ? exact : g.nodes.filter((node) =>
|
|
27
|
+
String(node.id).toLowerCase().includes(text) || String(node.label || '').toLowerCase().includes(text))
|
|
28
|
+
return nodes.slice(0, limit).map((node) => ({id: String(node.id), label: String(node.label || node.id), file: node.source_file || null}))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function excerpt(repoRoot, file, focusLine, contextLines) {
|
|
32
|
+
const resolved = resolveRepoPath(repoRoot, file)
|
|
33
|
+
if (!resolved.ok) return null
|
|
34
|
+
let size
|
|
35
|
+
try { size = statSync(resolved.path).size } catch { return null }
|
|
36
|
+
if (size > 2 * 1024 * 1024) return null
|
|
37
|
+
let lines
|
|
38
|
+
try { lines = readFileSync(resolved.path, 'utf8').split(/\r?\n/) } catch { return null }
|
|
39
|
+
const focus = Math.max(1, Math.min(lines.length, Number(focusLine) || 1))
|
|
40
|
+
const startLine = Math.max(1, focus - contextLines)
|
|
41
|
+
const endLine = Math.min(lines.length, focus + contextLines)
|
|
42
|
+
const rawText = lines.slice(startLine - 1, endLine).join('\n')
|
|
43
|
+
return {
|
|
44
|
+
file,
|
|
45
|
+
focusLine: focus,
|
|
46
|
+
startLine,
|
|
47
|
+
endLine,
|
|
48
|
+
text: rawText.length > MAX_EXCERPT_CHARS ? `${rawText.slice(0, MAX_EXCERPT_CHARS)}\n… [excerpt truncated]` : rawText,
|
|
49
|
+
truncated: rawText.length > MAX_EXCERPT_CHARS,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reverseImpact(g, targetId, depth = 3, cap = 40) {
|
|
54
|
+
const seen = new Map([[targetId, 0]])
|
|
55
|
+
const queue = [targetId]
|
|
56
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
57
|
+
const current = queue[cursor]
|
|
58
|
+
const currentDepth = seen.get(current)
|
|
59
|
+
if (currentDepth >= depth) continue
|
|
60
|
+
for (const edge of g.inn.get(current) || []) {
|
|
61
|
+
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
62
|
+
const id = String(edge.id)
|
|
63
|
+
if (seen.has(id)) continue
|
|
64
|
+
seen.set(id, currentDepth + 1)
|
|
65
|
+
queue.push(id)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...seen.entries()]
|
|
69
|
+
.filter(([id]) => id !== targetId)
|
|
70
|
+
.map(([id, distance]) => ({id, label: labelOf(g, id), distance, degree: degreeOf(g, id)}))
|
|
71
|
+
.sort((left, right) => left.distance - right.distance || right.degree - left.degree)
|
|
72
|
+
.slice(0, cap)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function graphOccurrences(g, targetId) {
|
|
76
|
+
const all = (g.inn.get(targetId) || [])
|
|
77
|
+
.filter((edge) => !isStructuralRelation(edge.relation) && edge.barrelProxy !== true)
|
|
78
|
+
.map((edge) => ({
|
|
79
|
+
source: String(edge.id),
|
|
80
|
+
label: labelOf(g, edge.id),
|
|
81
|
+
relation: edge.relation || 'references',
|
|
82
|
+
provenance: edge.provenance || 'UNKNOWN',
|
|
83
|
+
...(Number.isInteger(edge.line) ? {line: edge.line} : {}),
|
|
84
|
+
...(edge.typeOnly === true ? {typeOnly: true} : {}),
|
|
85
|
+
...(edge.compileOnly === true ? {compileOnly: true} : {}),
|
|
86
|
+
}))
|
|
87
|
+
return {total: all.length, shown: all.slice(0, MAX_GRAPH_OCCURRENCES)}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function groupLocations(g, locations, cap) {
|
|
91
|
+
const groups = new Map()
|
|
92
|
+
for (const location of locations) {
|
|
93
|
+
const source = String(location.source || location.file || '')
|
|
94
|
+
if (!source) continue
|
|
95
|
+
const node = g.byId.get(source)
|
|
96
|
+
const file = String(location.file || node?.source_file || (isSymbol(source) ? source.split('#')[0] : source))
|
|
97
|
+
let group = groups.get(source)
|
|
98
|
+
if (!group) {
|
|
99
|
+
group = {id: source, label: String(node?.label || source), file, count: 0, classifications: {}, locations: []}
|
|
100
|
+
groups.set(source, group)
|
|
101
|
+
}
|
|
102
|
+
group.count++
|
|
103
|
+
const classification = String(location.classification || 'unknown')
|
|
104
|
+
group.classifications[classification] = (group.classifications[classification] || 0) + 1
|
|
105
|
+
if (group.locations.length < MAX_LOCATION_SAMPLES) group.locations.push({
|
|
106
|
+
line: location.line,
|
|
107
|
+
character: location.character,
|
|
108
|
+
...(Number.isInteger(location.endLine) ? {endLine: location.endLine} : {}),
|
|
109
|
+
...(Number.isInteger(location.endCharacter) ? {endCharacter: location.endCharacter} : {}),
|
|
110
|
+
classification,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
const all = [...groups.values()].sort((left, right) => right.count - left.count || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
|
|
114
|
+
return {all, shown: all.slice(0, cap)}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function textFor(result) {
|
|
118
|
+
if (result.status === 'NOT_FOUND') return `No symbol found matching "${result.query}".`
|
|
119
|
+
if (result.status === 'AMBIGUOUS') return [
|
|
120
|
+
`Symbol "${result.query}" is ambiguous (${result.candidates.length} candidate(s) shown). Supply an exact node ID:`,
|
|
121
|
+
...result.candidates.map((candidate) => ` ${candidate.label} [${candidate.id}]`),
|
|
122
|
+
].join('\n')
|
|
123
|
+
const definition = result.definition
|
|
124
|
+
const lines = [
|
|
125
|
+
`Symbol inspection: ${definition.label} [${definition.id}]`,
|
|
126
|
+
`Definition: ${definition.file}:${definition.line}`,
|
|
127
|
+
`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).`,
|
|
128
|
+
`Graph: ${result.graph.occurrenceTotal} direct occurrence edge(s) (${result.graph.occurrences.length} shown); ${result.graph.impact.length} dependent(s) shown.`,
|
|
129
|
+
]
|
|
130
|
+
if (result.evidence.reason) lines.push(`Completeness: ${result.evidence.reason}`)
|
|
131
|
+
if (result.exact.zeroReferences) lines.push('Exact result: zero non-declaration references in the completely covered project universe.')
|
|
132
|
+
if (result.exact.groups.length) {
|
|
133
|
+
lines.push('Reference containers:')
|
|
134
|
+
for (const group of result.exact.groups) lines.push(` ${group.count} site(s) ${group.label} [${group.id}]`)
|
|
135
|
+
}
|
|
136
|
+
if (result.source.definition) {
|
|
137
|
+
const source = result.source.definition
|
|
138
|
+
lines.push('', `Definition source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
|
|
139
|
+
}
|
|
140
|
+
for (const source of result.source.callers) {
|
|
141
|
+
lines.push('', `Caller source (${source.file}:${source.startLine}-${source.endLine}, focus ${source.focusLine}):`, source.text)
|
|
142
|
+
}
|
|
143
|
+
return lines.join('\n')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function tInspectSymbol(g, args = {}, ctx = {}) {
|
|
147
|
+
const query = String(args.label || '').trim()
|
|
148
|
+
const info = resolveNodeInfo(g, query)
|
|
149
|
+
if (!info.node) return toolResult(textFor({status: 'NOT_FOUND', query}), {status: 'NOT_FOUND', query})
|
|
150
|
+
if (info.matches > 1 && !g.byId.has(query)) {
|
|
151
|
+
const result = {status: 'AMBIGUOUS', query, candidates: candidateNodes(g, query)}
|
|
152
|
+
return toolResult(textFor(result), result, {completeness: {status: 'blocked', reason: 'ambiguous symbol'}})
|
|
153
|
+
}
|
|
154
|
+
const node = info.node
|
|
155
|
+
const targetId = String(node.id)
|
|
156
|
+
if (!isSymbol(targetId)) {
|
|
157
|
+
const result = {status: 'UNSUPPORTED', query, candidates: [{id: targetId, label: node.label || targetId, file: node.source_file || targetId}]}
|
|
158
|
+
return toolResult('inspect_symbol requires a symbol node, not a file node.', result)
|
|
159
|
+
}
|
|
160
|
+
const maxReferences = boundedInteger(args.max_references, 1_000, 1, 5_000)
|
|
161
|
+
const maxContainers = boundedInteger(args.max_containers, 15, 1, 50)
|
|
162
|
+
const contextLines = boundedInteger(args.context_lines, 8, 0, 40)
|
|
163
|
+
const timeoutMs = boundedInteger(args.timeout_ms, 30_000, 1_000, 60_000)
|
|
164
|
+
const precision = ['auto', 'graph', 'lsp'].includes(args.precision) ? args.precision : 'auto'
|
|
165
|
+
const shouldQuery = precision === 'lsp' || (precision === 'auto' && g.graphPrecisionMode !== 'off')
|
|
166
|
+
let overlay = null
|
|
167
|
+
let cached = false
|
|
168
|
+
let elapsedMs = 0
|
|
169
|
+
let queryError = null
|
|
170
|
+
if (shouldQuery) {
|
|
171
|
+
try {
|
|
172
|
+
const queryResult = await querySymbolPrecision({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, targetId, maxReferences, timeoutMs})
|
|
173
|
+
overlay = queryResult.overlay
|
|
174
|
+
cached = queryResult.cached
|
|
175
|
+
elapsedMs = queryResult.elapsedMs
|
|
176
|
+
} catch (error) {
|
|
177
|
+
queryError = error?.message || 'exact symbol query failed'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const locations = Array.isArray(overlay?.locations) ? overlay.locations : []
|
|
181
|
+
const grouped = groupLocations(g, locations, maxContainers)
|
|
182
|
+
const files = new Set(locations.map((location) => String(location.file || '')).filter(Boolean))
|
|
183
|
+
let state = 'GRAPH_ONLY'
|
|
184
|
+
if (overlay?.state === 'COMPLETE') state = 'EXACT'
|
|
185
|
+
else if (overlay?.state === 'PARTIAL') state = 'PARTIAL'
|
|
186
|
+
else if (overlay?.state === 'OFF') state = 'OFF'
|
|
187
|
+
else if (shouldQuery) state = 'UNAVAILABLE'
|
|
188
|
+
const reason = queryError || overlay?.reason || (shouldQuery ? null : 'semantic precision was not requested')
|
|
189
|
+
const graphRefs = graphOccurrences(g, targetId)
|
|
190
|
+
const impact = reverseImpact(g, targetId)
|
|
191
|
+
const definition = {
|
|
192
|
+
id: targetId,
|
|
193
|
+
label: String(node.label || targetId),
|
|
194
|
+
kind: String(node.symbol_kind || 'symbol'),
|
|
195
|
+
file: String(node.source_file || targetId.split('#')[0]),
|
|
196
|
+
line: sourceLine(node),
|
|
197
|
+
...(node.source_range ? {range: node.source_range} : {}),
|
|
198
|
+
...(node.complexity ? {complexity: node.complexity} : {}),
|
|
199
|
+
}
|
|
200
|
+
const definitionSource = excerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
|
|
201
|
+
const callerSources = grouped.shown.slice(0, 5).map((group) => excerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
|
|
202
|
+
const result = {
|
|
203
|
+
status: 'OK',
|
|
204
|
+
definition,
|
|
205
|
+
evidence: {state, cached, elapsedMs, reason, provider: overlay?.engines?.[0]?.provider || null},
|
|
206
|
+
exact: {
|
|
207
|
+
occurrences: locations.length,
|
|
208
|
+
occurrencesWithDefinition: locations.length + 1,
|
|
209
|
+
files: files.size,
|
|
210
|
+
containers: grouped.all.length,
|
|
211
|
+
groups: grouped.shown,
|
|
212
|
+
zeroReferences: overlay?.noReferenceSymbols?.includes(targetId) === true,
|
|
213
|
+
capped: overlay?.coverage?.truncated === true || grouped.all.length > grouped.shown.length,
|
|
214
|
+
},
|
|
215
|
+
graph: {
|
|
216
|
+
occurrences: graphRefs.shown,
|
|
217
|
+
occurrenceTotal: graphRefs.total,
|
|
218
|
+
occurrencesCapped: graphRefs.total > graphRefs.shown.length,
|
|
219
|
+
impact,
|
|
220
|
+
},
|
|
221
|
+
source: {definition: definitionSource, callers: callerSources},
|
|
222
|
+
}
|
|
223
|
+
const warnings = state === 'PARTIAL' || state === 'UNAVAILABLE'
|
|
224
|
+
? [{code: 'SYMBOL_PRECISION_INCOMPLETE', message: reason || `semantic precision is ${state.toLowerCase()}`}]
|
|
225
|
+
: []
|
|
226
|
+
return toolResult(textFor(result), result, {
|
|
227
|
+
warnings,
|
|
228
|
+
completeness: {
|
|
229
|
+
status: state === 'EXACT' && !result.exact.capped ? 'complete' : state.toLowerCase(),
|
|
230
|
+
referenceLimit: maxReferences,
|
|
231
|
+
containerLimit: maxContainers,
|
|
232
|
+
returnedContainers: grouped.shown.length,
|
|
233
|
+
locationSamplesPerContainer: MAX_LOCATION_SAMPLES,
|
|
234
|
+
graphOccurrenceLimit: MAX_GRAPH_OCCURRENCES,
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
}
|
|
@@ -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.5'}} = {}) {
|
|
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,
|
|
@@ -274,8 +274,16 @@ function sourceAt(index, file, position) {
|
|
|
274
274
|
return index.files.has(file) ? file : null;
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
function eligibleTargets(graph, limit) {
|
|
277
|
+
function eligibleTargets(graph, limit, requestedIds = null) {
|
|
278
278
|
const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
279
|
+
if (Array.isArray(requestedIds) && requestedIds.length) {
|
|
280
|
+
const ids = [...new Set(requestedIds.map(String))];
|
|
281
|
+
const targets = ids
|
|
282
|
+
.map((id) => byId.get(id))
|
|
283
|
+
.filter((node) => node?.selection_start && JS_TS_FILE.test(String(node.source_file || "")))
|
|
284
|
+
.slice(0, limit);
|
|
285
|
+
return {targets, total: ids.length, orphanIds: new Set()};
|
|
286
|
+
}
|
|
279
287
|
const ranked = new Map();
|
|
280
288
|
const inbound = new Set();
|
|
281
289
|
for (const link of graph.links || []) {
|
|
@@ -456,6 +464,7 @@ export async function buildLspPrecisionOverlay({
|
|
|
456
464
|
maxReferences = Number(process.env.WEAVATRIX_PRECISION_MAX_REFERENCES) || 2_048,
|
|
457
465
|
maxLinks = Number(process.env.WEAVATRIX_PRECISION_MAX_LINKS) || 2_048,
|
|
458
466
|
timeoutMs = Number(process.env.WEAVATRIX_PRECISION_TIMEOUT_MS) || 45_000,
|
|
467
|
+
targetIds,
|
|
459
468
|
clientFactory,
|
|
460
469
|
} = {}) {
|
|
461
470
|
if (!graph || !repoRoot) throw new Error("precision overlay requires repoRoot and graph");
|
|
@@ -501,7 +510,7 @@ export async function buildLspPrecisionOverlay({
|
|
|
501
510
|
&& precisionOverlayMatches(cached, graph, {request})
|
|
502
511
|
&& cached.semanticInputFingerprint === semanticInputs.fingerprint) return cached;
|
|
503
512
|
}
|
|
504
|
-
const eligible = eligibleTargets(graph, boundedMax);
|
|
513
|
+
const eligible = eligibleTargets(graph, boundedMax, targetIds);
|
|
505
514
|
const targets = eligible.targets;
|
|
506
515
|
if (!targets.length) {
|
|
507
516
|
const overlay = baseOverlay(graph, "COMPLETE", {
|
|
@@ -525,6 +534,8 @@ export async function buildLspPrecisionOverlay({
|
|
|
525
534
|
let truncated = eligible.total > boundedMax;
|
|
526
535
|
const noReferenceSymbols = [];
|
|
527
536
|
const referenceEvidence = [];
|
|
537
|
+
const exactLocations = [];
|
|
538
|
+
const collectLocations = Array.isArray(targetIds) && targetIds.length > 0;
|
|
528
539
|
const opened = new Set();
|
|
529
540
|
const openedTexts = new Map();
|
|
530
541
|
const classificationTexts = new Map();
|
|
@@ -708,7 +719,6 @@ export async function buildLspPrecisionOverlay({
|
|
|
708
719
|
const start = locationStart(location);
|
|
709
720
|
if (!file || !start || !Number.isInteger(start.line) || !Number.isInteger(start.character)) continue;
|
|
710
721
|
const source = sourceAt(index, file, start);
|
|
711
|
-
if (!source || source === String(target.id)) continue;
|
|
712
722
|
const targetId = String(target.id);
|
|
713
723
|
const exactLine = start.line + 1;
|
|
714
724
|
const exactCharacter = start.character;
|
|
@@ -727,6 +737,20 @@ export async function buildLspPrecisionOverlay({
|
|
|
727
737
|
: classifyTypeScriptReferenceUsage(file, sourceText, start);
|
|
728
738
|
if (usage === "unknown" && moduleDependency?.typeOnly === true) usage = "type";
|
|
729
739
|
if (usage === "unknown" && moduleDependency?.compileOnly === true) usage = "compile";
|
|
740
|
+
if (collectLocations) exactLocations.push({
|
|
741
|
+
file,
|
|
742
|
+
source: source || file,
|
|
743
|
+
target: targetId,
|
|
744
|
+
line: exactLine,
|
|
745
|
+
character: exactCharacter,
|
|
746
|
+
...(Number.isInteger(location?.range?.end?.line) ? {endLine: location.range.end.line + 1} : {}),
|
|
747
|
+
...(Number.isInteger(location?.range?.end?.character) ? {endCharacter: location.range.end.character} : {}),
|
|
748
|
+
classification: usage,
|
|
749
|
+
});
|
|
750
|
+
if (!source || source === targetId) {
|
|
751
|
+
if (usage === "unknown") unclassifiedReferences++;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
730
754
|
if (usage === "unknown") {
|
|
731
755
|
const evidenceKey = `${source}\0${targetId}\0${line}\0${exactCharacter}`;
|
|
732
756
|
if (!evidenceSeen.has(evidenceKey)) {
|
|
@@ -798,6 +822,7 @@ export async function buildLspPrecisionOverlay({
|
|
|
798
822
|
coverage: coverage(),
|
|
799
823
|
links,
|
|
800
824
|
referenceEvidence,
|
|
825
|
+
...(collectLocations ? {locations: exactLocations} : {}),
|
|
801
826
|
noReferenceSymbols,
|
|
802
827
|
...(errors ? {reason: `${errors} semantic request(s) failed or were refused`}
|
|
803
828
|
: truncated ? {reason: "semantic precision stopped at a configured safety limit"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import {existsSync, readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {dirname, resolve} from 'node:path'
|
|
3
|
+
import {atomicWriteFileSync, withFileLock} from '../graph/file-lock.js'
|
|
4
|
+
import {
|
|
5
|
+
buildLspPrecisionOverlay,
|
|
6
|
+
precisionOverlayMatches,
|
|
7
|
+
precisionSemanticInputsMatch,
|
|
8
|
+
} from './lsp-overlay.js'
|
|
9
|
+
|
|
10
|
+
export const SYMBOL_PRECISION_CACHE_V = 1
|
|
11
|
+
export const SYMBOL_PRECISION_CACHE_FILE = 'precision-symbols.json'
|
|
12
|
+
|
|
13
|
+
const MAX_CACHE_ENTRIES = 32
|
|
14
|
+
const MAX_CACHE_BYTES = 8 * 1024 * 1024
|
|
15
|
+
const MAX_CACHE_READ_BYTES = 16 * 1024 * 1024
|
|
16
|
+
const inFlight = new Map()
|
|
17
|
+
|
|
18
|
+
const boundedInteger = (value, fallback, minimum, maximum) => {
|
|
19
|
+
const number = Number(value)
|
|
20
|
+
return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function symbolPrecisionCachePath(graphPath) {
|
|
24
|
+
return resolve(dirname(graphPath), SYMBOL_PRECISION_CACHE_FILE)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadRawGraph(graphPath) {
|
|
28
|
+
const graph = JSON.parse(readFileSync(graphPath, 'utf8'))
|
|
29
|
+
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(graph.links)) {
|
|
30
|
+
throw new Error('the active graph is not a valid Weavatrix graph')
|
|
31
|
+
}
|
|
32
|
+
return graph
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function emptyCache() {
|
|
36
|
+
return {symbolPrecisionCacheV: SYMBOL_PRECISION_CACHE_V, entries: []}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readCache(path) {
|
|
40
|
+
if (!existsSync(path)) return emptyCache()
|
|
41
|
+
try {
|
|
42
|
+
if (statSync(path).size > MAX_CACHE_READ_BYTES) return emptyCache()
|
|
43
|
+
const value = JSON.parse(readFileSync(path, 'utf8'))
|
|
44
|
+
if (Number(value?.symbolPrecisionCacheV) !== SYMBOL_PRECISION_CACHE_V || !Array.isArray(value.entries)) return emptyCache()
|
|
45
|
+
return {symbolPrecisionCacheV: SYMBOL_PRECISION_CACHE_V, entries: value.entries.slice(0, MAX_CACHE_ENTRIES)}
|
|
46
|
+
} catch {
|
|
47
|
+
return emptyCache()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function writeBoundedCache(path, entries) {
|
|
52
|
+
const kept = entries
|
|
53
|
+
.filter((entry) => entry && typeof entry === 'object')
|
|
54
|
+
.sort((left, right) => Number(right.usedAt) - Number(left.usedAt))
|
|
55
|
+
.slice(0, MAX_CACHE_ENTRIES)
|
|
56
|
+
let body
|
|
57
|
+
do {
|
|
58
|
+
body = JSON.stringify({symbolPrecisionCacheV: SYMBOL_PRECISION_CACHE_V, entries: kept})
|
|
59
|
+
if (Buffer.byteLength(body) <= MAX_CACHE_BYTES || !kept.length) break
|
|
60
|
+
kept.pop()
|
|
61
|
+
} while (true)
|
|
62
|
+
atomicWriteFileSync(path, body, 'utf8')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cacheMatch(entry, {targetId, graph, repoRoot, request}) {
|
|
66
|
+
if (String(entry?.targetId || '') !== targetId || !entry.overlay) return false
|
|
67
|
+
if (!precisionOverlayMatches(entry.overlay, graph, {request})) return false
|
|
68
|
+
return precisionSemanticInputsMatch(entry.overlay, repoRoot, graph)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function storeEntry(path, entry) {
|
|
72
|
+
await withFileLock(`${path}.lock`, async () => {
|
|
73
|
+
const current = readCache(path)
|
|
74
|
+
const entries = current.entries.filter((item) => String(item?.targetId || '') !== entry.targetId)
|
|
75
|
+
entries.unshift(entry)
|
|
76
|
+
writeBoundedCache(path, entries)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function cacheable(overlay) {
|
|
81
|
+
if (overlay?.state === 'COMPLETE') return true
|
|
82
|
+
return overlay?.state === 'PARTIAL'
|
|
83
|
+
&& overlay?.reason === 'semantic precision stopped at a configured safety limit'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function querySymbolPrecision({
|
|
87
|
+
repoRoot,
|
|
88
|
+
graphPath,
|
|
89
|
+
targetId,
|
|
90
|
+
maxReferences = 1_000,
|
|
91
|
+
timeoutMs = 30_000,
|
|
92
|
+
clientFactory,
|
|
93
|
+
} = {}) {
|
|
94
|
+
if (!repoRoot || !graphPath || !targetId) throw new Error('symbol precision requires repoRoot, graphPath, and targetId')
|
|
95
|
+
const boundedReferences = boundedInteger(maxReferences, 1_000, 1, 5_000)
|
|
96
|
+
const boundedTimeout = boundedInteger(timeoutMs, 30_000, 1_000, 60_000)
|
|
97
|
+
const request = {maxSymbols: 1, maxReferences: boundedReferences, maxLinks: boundedReferences}
|
|
98
|
+
const rawGraph = loadRawGraph(graphPath)
|
|
99
|
+
const graph = rawGraph.graphPrecisionMode === 'off' ? {...rawGraph, graphPrecisionMode: 'lsp'} : rawGraph
|
|
100
|
+
const target = graph.nodes.find((node) => String(node?.id || '') === String(targetId))
|
|
101
|
+
if (!target) throw new Error('the selected symbol is not present in the active raw graph')
|
|
102
|
+
if (!target.selection_start || !/\.(?:[cm]?[jt]sx?)$/i.test(String(target.source_file || ''))) {
|
|
103
|
+
throw new Error('exact symbol precision currently supports JavaScript and TypeScript symbols with source selections')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const id = String(targetId)
|
|
107
|
+
const cachePath = symbolPrecisionCachePath(graphPath)
|
|
108
|
+
const startedAt = Date.now()
|
|
109
|
+
const cached = readCache(cachePath).entries.find((entry) => cacheMatch(entry, {targetId: id, graph, repoRoot, request}))
|
|
110
|
+
if (cached) return {overlay: cached.overlay, cached: true, elapsedMs: Date.now() - startedAt, cachePath}
|
|
111
|
+
|
|
112
|
+
const flightKey = `${graphPath}\0${graph.graphRevision || ''}\0${id}\0${boundedReferences}`
|
|
113
|
+
if (inFlight.has(flightKey)) return inFlight.get(flightKey)
|
|
114
|
+
const operation = (async () => {
|
|
115
|
+
const overlay = await buildLspPrecisionOverlay({
|
|
116
|
+
repoRoot,
|
|
117
|
+
graph,
|
|
118
|
+
mode: 'lsp',
|
|
119
|
+
maxSymbols: 1,
|
|
120
|
+
maxReferences: boundedReferences,
|
|
121
|
+
maxLinks: boundedReferences,
|
|
122
|
+
timeoutMs: boundedTimeout,
|
|
123
|
+
targetIds: [id],
|
|
124
|
+
clientFactory,
|
|
125
|
+
})
|
|
126
|
+
if (cacheable(overlay)) {
|
|
127
|
+
await storeEntry(cachePath, {targetId: id, usedAt: Date.now(), overlay})
|
|
128
|
+
}
|
|
129
|
+
return {overlay, cached: false, elapsedMs: Date.now() - startedAt, cachePath}
|
|
130
|
+
})()
|
|
131
|
+
inFlight.set(flightKey, operation)
|
|
132
|
+
try {
|
|
133
|
+
return await operation
|
|
134
|
+
} finally {
|
|
135
|
+
inFlight.delete(flightKey)
|
|
136
|
+
}
|
|
137
|
+
}
|