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.
@@ -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', 'timeRank', 'timeScore', 'memoryRank', 'memoryScore',
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 < 3
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'
@@ -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(30, Number(args.min_tokens) || 50))
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
@@ -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.4'}} = {}) {
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
+ }