weavatrix 0.2.7 → 0.2.9

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.
@@ -9,7 +9,7 @@ import {classifyChangeImpact} from '../analysis/change-classification.js'
9
9
  import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
10
10
  import {summarizeFindings} from '../analysis/findings.js'
11
11
  import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.js'
12
- import {detectEndpoints} from '../analysis/endpoints.js'
12
+ import {analyzeEndpointInventory} from '../analysis/endpoints.js'
13
13
  import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
14
14
  import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
15
15
  import {computeHotPathReview} from '../analysis/hot-path-review.js'
@@ -22,6 +22,7 @@ import {toolResult} from './tool-result.mjs'
22
22
  import {createPathClassifier, hasPathClass} from '../path-classification.js'
23
23
  import {createRepoBoundary} from '../repo-path.js'
24
24
  import {filterGraphForMode} from '../graph/graph-filter.js'
25
+ import {readCachedSymbolPrecisionEvidence} from '../precision/symbol-query.js'
25
26
 
26
27
  const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
27
28
  const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
@@ -85,7 +86,10 @@ export function tFindDuplicates(g, args, ctx) {
85
86
  const suppressionNote = suppressed && !includeClassified
86
87
  ? ` ${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.`
87
88
  : ''
88
- if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}`
89
+ const boilerplateNote = analysis.boilerplateSuppressed
90
+ ? ` ${analysis.boilerplateSuppressed} all-router framework boilerplate group(s) were suppressed; pass include_boilerplate:true to inspect them.`
91
+ : ''
92
+ if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}${boilerplateNote}`
89
93
  const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
90
94
  const lines = top.map((grp, k) => {
91
95
  const isStr = grp.members.some((f) => f.kind === 'string')
@@ -93,7 +97,7 @@ export function tFindDuplicates(g, args, ctx) {
93
97
  const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
94
98
  return [head, ...sites].join('\n')
95
99
  })
96
- 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}`
100
+ 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}${boilerplateNote}`
97
101
  }
98
102
 
99
103
  // Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
@@ -101,7 +105,27 @@ export function tFindDuplicates(g, args, ctx) {
101
105
  // candidates. It never returns an automatic-delete verdict.
102
106
  export function tFindDeadCode(g, args, ctx) {
103
107
  if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
104
- const graph = effectiveRawGraph(ctx)
108
+ const effectiveGraph = effectiveRawGraph(ctx)
109
+ const pointEvidence = readCachedSymbolPrecisionEvidence({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, graph: effectiveGraph})
110
+ const graph = {
111
+ ...effectiveGraph,
112
+ precisionReferenceSymbols: [...new Set([
113
+ ...(effectiveGraph.precisionReferenceSymbols || []),
114
+ ...pointEvidence.referenceSymbols,
115
+ ])],
116
+ precisionProductionReferenceSymbols: [...new Set([
117
+ ...(effectiveGraph.precisionProductionReferenceSymbols || []),
118
+ ...pointEvidence.productionReferenceSymbols,
119
+ ])],
120
+ precisionTestReferenceSymbols: [...new Set([
121
+ ...(effectiveGraph.precisionTestReferenceSymbols || []),
122
+ ...pointEvidence.testReferenceSymbols,
123
+ ])],
124
+ precisionNoReferenceSymbols: [...new Set([
125
+ ...(effectiveGraph.precisionNoReferenceSymbols || []),
126
+ ...pointEvidence.noReferenceSymbols,
127
+ ])],
128
+ }
105
129
  const boundary = createRepoBoundary(ctx.repoRoot)
106
130
  const pkg = readRepoJson(boundary, 'package.json') || {}
107
131
  const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
@@ -176,6 +200,7 @@ export function tFindDeadCode(g, args, ctx) {
176
200
  }
177
201
 
178
202
  const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
203
+ const AUDIT_NON_PRODUCT_CLASSES = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
179
204
 
180
205
  export function formatAuditFinding(f) {
181
206
  const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
@@ -185,12 +210,40 @@ export function formatAuditFinding(f) {
185
210
  return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${verification}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
186
211
  }
187
212
 
188
- const auditFilter = (audit, args, findings = audit.findings) => {
213
+ const auditFindingPaths = (finding) => {
214
+ const paths = []
215
+ if (finding?.file) paths.push(String(finding.file))
216
+ if (Array.isArray(finding?.files)) paths.push(...finding.files.map(String))
217
+ if (finding?.cycleRoute) paths.push(...String(finding.cycleRoute).split(/\s*(?:→|→|->)\s*/))
218
+ return [...new Set(paths.map((file) => file.replace(/\\/g, '/').trim()).filter(Boolean))]
219
+ }
220
+
221
+ export function auditFindingPathScope(findings, {includeClassified = false, repoRoot = null} = {}) {
222
+ const all = Array.isArray(findings) ? findings : []
223
+ if (includeClassified) return {findings: all, suppressed: 0}
224
+ const classifier = createPathClassifier(repoRoot)
225
+ const cache = new Map()
226
+ const classified = (file) => {
227
+ if (!cache.has(file)) cache.set(file, classifier.explain(file, {content: ''}))
228
+ const info = cache.get(file)
229
+ return info?.excluded || AUDIT_NON_PRODUCT_CLASSES.some((name) => hasPathClass(info, name))
230
+ }
231
+ const kept = all.filter((finding) => {
232
+ const paths = auditFindingPaths(finding)
233
+ return !paths.length || paths.some((file) => !classified(file))
234
+ })
235
+ return {findings: kept, suppressed: all.length - kept.length}
236
+ }
237
+
238
+ const DEPENDENCY_AUDIT_RULES = new Set(['unused-dep', 'missing-dep', 'duplicate-dep', 'unresolved-import', 'lockfile-drift'])
239
+ export const isDependencyAuditFinding = (finding) => DEPENDENCY_AUDIT_RULES.has(String(finding?.rule || ''))
240
+
241
+ const auditFilter = (audit, args, findings = audit.findings, repoRoot = null) => {
189
242
  const minSev = SEVERITY_RANK[args.min_severity] ?? 4
190
243
  const category = args.category ? String(args.category) : null
191
- return findings
244
+ return auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot}).findings
192
245
  .filter((finding) => (SEVERITY_RANK[finding.severity] ?? 4) <= minSev)
193
- .filter((finding) => !category || finding.category === category)
246
+ .filter((finding) => !category || (category === 'dependencies' ? isDependencyAuditFinding(finding) : finding.category === category))
194
247
  }
195
248
 
196
249
  const auditChecksLine = (audit) => {
@@ -208,11 +261,12 @@ const auditConventionLines = (audit) => {
208
261
  ].filter((line) => line != null)
209
262
  }
210
263
 
211
- const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null) => {
264
+ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = null, repoRoot = null) => {
212
265
  const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
213
- const filtered = auditFilter(audit, args, findings)
266
+ const pathScope = auditFindingPathScope(findings, {includeClassified: args.include_classified === true, repoRoot})
267
+ const filtered = auditFilter(audit, args, pathScope.findings, repoRoot)
214
268
  const shown = filtered.slice(0, max)
215
- const summary = summarizeFindings(findings)
269
+ const summary = summarizeFindings(pathScope.findings)
216
270
  const sev = summary.bySeverity
217
271
  const bycat = summary.byCategory
218
272
  const deps = audit.dependencyReport || {}
@@ -220,8 +274,9 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
220
274
  heading,
221
275
  `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
222
276
  ...auditConventionLines(audit),
223
- `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'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
277
+ `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'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
224
278
  `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}.`,
279
+ pathScope.suppressed ? `Path policy: production-first; suppressed ${pathScope.suppressed} finding(s) whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded. Pass include_classified:true to include them.` : 'Path policy: production-first; no classified-only findings were suppressed.',
225
280
  `Repository-level ${auditChecksLine(audit)}`,
226
281
  '',
227
282
  `Showing ${shown.length} of ${filtered.length} finding(s)${args.category ? ` in category "${args.category}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
@@ -332,12 +387,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
332
387
  const comparison = compareAuditDebt(currentAudit, baseline.value, changedFiles, {completeChangeSet})
333
388
  const mode = ['new', 'existing', 'all'].includes(args.debt) ? args.debt : 'new'
334
389
  const selectedRaw = mode === 'new' ? comparison.new : mode === 'existing' ? comparison.existing : comparison.all
335
- const selected = auditFilter(currentAudit, args, selectedRaw)
390
+ const selected = auditFilter(currentAudit, args, selectedRaw, ctx.repoRoot)
336
391
  const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
337
392
  const shown = selected.slice(0, max)
338
393
  const optional = comparison.optional.checks.map((check) => `${check.name.toUpperCase()} UNCOMPARABLE (current ${check.current}; baseline ${check.baseline})`).join('; ')
339
394
  const stateOf = (finding) => mode === 'all' ? finding.debtState : mode
340
- const fixedShown = auditFilter(baseline.value, args, comparison.fixed).slice(0, Math.min(10, max))
395
+ const fixedShown = auditFilter(baseline.value, args, comparison.fixed, ctx.repoRoot).slice(0, Math.min(10, max))
341
396
  const text = [
342
397
  `${mode.toUpperCase()} DEBT — deterministic internal audit vs ${resolved.ref} (${resolved.commit.slice(0, 12)})`,
343
398
  `Changed scope: ${changedFiles.length} file(s) from ${scopeSource}${changedFiles.length ? ` — ${changedFiles.slice(0, 12).join(', ')}${changedFiles.length > 12 ? ', …' : ''}` : ' — working tree matches the baseline'}.`,
@@ -390,38 +445,16 @@ export async function tRunAudit(g, args, ctx) {
390
445
  if (!normalized.ok) return `Changed-scope audit invalid: ${normalized.error}.`
391
446
  const scoped = scopeAuditFindings(audit.findings, normalized.files)
392
447
  const text = formatOrdinaryAudit(audit, args, scoped,
393
- `CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed.`)
448
+ `CHANGED-SCOPE ONLY — ${normalized.files.length} explicitly supplied file(s); no baseline was provided, so these findings are not classified as new, existing, or fixed.`, ctx.repoRoot)
394
449
  return toolResult(text, {
395
450
  status: 'COMPLETE',
396
451
  mode: 'changed-scope',
397
452
  scope: {files: normalized.files, source: 'explicit changed_files'},
398
453
  comparison: {status: 'UNAVAILABLE', reason: 'base_ref was not provided; changed-scope is not a new-debt claim'},
399
- findings: auditFilter(audit, args, scoped),
454
+ findings: auditFilter(audit, args, scoped, ctx.repoRoot),
400
455
  }, {completeness: {status: 'COMPLETE'}})
401
456
  }
402
- const minSev = SEVERITY_RANK[args.min_severity] ?? 4
403
- const cat = args.category ? String(args.category) : null
404
- const max = Math.max(1, Math.min(100, Number(args.max_findings) || 30))
405
- const filtered = audit.findings
406
- .filter((f) => (SEVERITY_RANK[f.severity] ?? 4) <= minSev)
407
- .filter((f) => !cat || f.category === cat)
408
- const shown = filtered.slice(0, max)
409
- const sev = audit.summary.bySeverity
410
- const bycat = audit.summary.byCategory
411
- const deps = audit.dependencyReport || {}
412
- const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
413
- return [
414
- `Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
415
- `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}.`,
416
- `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).`,
417
- `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'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
418
- ...auditConventionLines(audit),
419
- `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.`,
420
- ``,
421
- `Showing ${shown.length} of ${filtered.length} finding(s)${cat ? ` in category "${cat}"` : ''}${args.min_severity ? ` at ≥${args.min_severity}` : ''}:`,
422
- ...shown.map(formatAuditFinding),
423
- filtered.length > shown.length ? ` … +${filtered.length - shown.length} more (raise max_findings or filter by category/min_severity)` : null,
424
- ].filter((x) => x != null).join('\n')
457
+ return formatOrdinaryAudit(audit, args, audit.findings, null, ctx.repoRoot)
425
458
  }
426
459
 
427
460
  // Named module clusters: graph communities labeled by their dominant folder instead of bare numbers.
@@ -644,12 +677,29 @@ export function tListEndpoints(g, args, ctx) {
644
677
  .filter((n) => !String(n.id).includes('#') && n.source_file && n.file_type === 'code')
645
678
  .map((n) => n.source_file)
646
679
  )]
647
- const eps = detectEndpoints(ctx.repoRoot, codeFiles)
680
+ const inventory = analyzeEndpointInventory(ctx.repoRoot, codeFiles)
681
+ let eps = inventory.endpoints
682
+ const method = args.method ? String(args.method).toUpperCase() : null
683
+ const path = args.path ? String(args.path) : null
684
+ if (method) eps = eps.filter((endpoint) => endpoint.method === method)
685
+ if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
648
686
  if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
649
687
  const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
650
688
  const shown = eps.slice(0, max)
651
- return [
652
- `${eps.length} endpoint(s) detected${eps.length > shown.length ? `, showing ${shown.length}` : ''}:`,
653
- ...shown.map((e) => ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''})`),
689
+ const stats = inventory.stats
690
+ const text = [
691
+ `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.`,
692
+ ...shown.map((e) => {
693
+ const via = e.mountChain?.length
694
+ ? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
695
+ : ''
696
+ return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence})${via}`
697
+ }),
654
698
  ].join('\n')
699
+ return toolResult(text, {
700
+ filters: {method, path},
701
+ stats,
702
+ endpoints: shown,
703
+ page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
704
+ }, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
655
705
  }
@@ -27,7 +27,7 @@ function candidateNodes(g, query, limit = 20) {
27
27
  }))
28
28
  }
29
29
 
30
- function excerpt(repoRoot, file, focusLine, contextLines) {
30
+ export function sourceExcerpt(repoRoot, file, focusLine, contextLines) {
31
31
  const resolved = resolveRepoPath(repoRoot, file)
32
32
  if (!resolved.ok) return null
33
33
  let size
@@ -198,8 +198,8 @@ export async function tInspectSymbol(g, args = {}, ctx = {}) {
198
198
  ...(node.source_range ? {range: node.source_range} : {}),
199
199
  ...(node.complexity ? {complexity: node.complexity} : {}),
200
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)
201
+ const definitionSource = sourceExcerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
202
+ const callerSources = grouped.shown.slice(0, 5).map((group) => sourceExcerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
203
203
  const result = {
204
204
  status: 'OK',
205
205
  definition,
@@ -62,6 +62,21 @@ function architectureState(result) {
62
62
  return result.verification.new?.length || String(result.verification.status).toUpperCase() === 'FAIL' ? 'BLOCKED' : 'PASS'
63
63
  }
64
64
 
65
+ function exactUsageLines(contexts, g) {
66
+ if (!contexts.length) return []
67
+ return contexts.map((context) => {
68
+ const node = g.byId.get(String(context.symbol || context.definition?.id || ''))
69
+ const label = String(context.definition?.label || node?.label || context.symbol || 'symbol')
70
+ if (context.status !== 'OK') return `Exact usage: ${label} — unavailable (${context.status || 'UNKNOWN'}).`
71
+ const exact = context.evidence?.state === 'EXACT'
72
+ const occurrences = Number(context.references?.occurrences) || 0
73
+ const files = Number(context.references?.files) || 0
74
+ const inbound = Array.isArray(context.inbound?.shown) ? context.inbound.shown.slice(0, 3) : []
75
+ const callers = inbound.map((item) => `${item.label || item.id}${item.file ? ` [${item.file}]` : ''}`).join(', ')
76
+ return `${exact ? 'Exact usage' : 'Bounded usage'}: ${label} — ${occurrences} reference occurrence(s)${files ? ` in ${files} file(s)` : ''}; ${Number(context.inbound?.total) || 0} inbound container(s)${callers ? `: ${callers}` : ''}.`
77
+ })
78
+ }
79
+
65
80
  function decide({phase, impact, graph, architecture, duplicates, api, tests, suggestedTests, testCoverageState}) {
66
81
  if (phase === 'plan') return tests.state === 'BLOCKED'
67
82
  ? {verdict: 'BLOCKED', blockers: [`targeted test plan was rejected: ${tests.reason || 'invalid request'}`], unknowns: []}
@@ -162,6 +177,7 @@ export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permis
162
177
  `${decision.verdict} — verified_change ${phase}`, `Task: ${String(args.task).slice(0, 500)}`,
163
178
  `Change: ${changedFiles.length} file(s), ${impact.seeds?.ids?.length || 0} exact seed(s), blast radius ${impact.blastRadius?.impacted || 0}.`,
164
179
  `Edit context: ${retrieval.selected.length} symbol(s); ${contexts.length} exact bundle(s); data-flow ${dataFlow.status} (${dataFlow.edges.length} call edge(s)).`,
180
+ ...exactUsageLines(contexts, g),
165
181
  `Ratchets: graph ${graph.state}; architecture ${architecture.state}; duplicates ${duplicates.state}; API ${api.state}; tests ${testProof.state}.`,
166
182
  ...decision.blockers.map((item) => `BLOCKER: ${item}`), ...decision.unknowns.map((item) => `UNKNOWN: ${item}`),
167
183
  ].join('\n')
@@ -270,6 +270,7 @@ async function main() {
270
270
  const graphSnapshot = graph
271
271
  const callCtx = mutatesTarget ? ctx : {...ctx}
272
272
  const execute = async () => {
273
+ const startedAt = performance.now()
273
274
  try {
274
275
  // A queued rebuild must see a preceding retarget's graph, while non-mutating calls stay
275
276
  // pinned to the graph that was active when their request arrived.
@@ -302,12 +303,39 @@ async function main() {
302
303
  if (schemaWarning) text += `\n\nWarning: ${schemaWarning.message}`
303
304
  if (staleLine && staleNotices.shouldShow({line: staleLine, graphPath: callCtx.graphPath, force: tool.name === 'graph_stats'})) text += `\n\n${staleLine}`
304
305
  }
305
- const response = {content: [{type: 'text', text}]}
306
+ const structuredBytes = normalized.structured ? Buffer.byteLength(JSON.stringify(normalized.structured)) : 0
307
+ const textBytes = Buffer.byteLength(text)
308
+ const durationMs = Math.max(0, performance.now() - startedAt)
309
+ const response = {
310
+ content: [{type: 'text', text}],
311
+ _meta: {
312
+ 'weavatrix/metrics': {
313
+ schemaVersion: 'weavatrix.metrics.v1',
314
+ durationMs: Math.round(durationMs * 10) / 10,
315
+ textBytes,
316
+ structuredBytes,
317
+ estimatedOutputTokens: Math.ceil((textBytes + structuredBytes) / 4),
318
+ graphFreshness: staleLine ? 'stale' : (refresh?.error ? 'stale' : 'fresh'),
319
+ graphUpdate: refresh?.kind || 'none',
320
+ graphRevision: refresh?.revision || callGraph?.graphRevision || null,
321
+ cache: refreshesGraph ? (refresh?.kind === 'none' ? 'graph-hit' : 'graph-refreshed') : 'not-applicable',
322
+ },
323
+ },
324
+ }
306
325
  if (normalized.structured) response.structuredContent = normalized.structured
307
326
  return reply(id, response)
308
327
  } catch (e) {
309
328
  log(`tool ${params?.name} threw: ${e.stack || e.message}`)
310
- return reply(id, {content: [{type: 'text', text: `Tool error: ${e.message}`}], isError: true})
329
+ const text = `Tool error: ${e.message}`
330
+ return reply(id, {
331
+ content: [{type: 'text', text}], isError: true,
332
+ _meta: {'weavatrix/metrics': {
333
+ schemaVersion: 'weavatrix.metrics.v1', durationMs: Math.round(Math.max(0, performance.now() - startedAt) * 10) / 10,
334
+ textBytes: Buffer.byteLength(text), structuredBytes: 0,
335
+ estimatedOutputTokens: Math.ceil(Buffer.byteLength(text) / 4), graphFreshness: 'unknown',
336
+ graphUpdate: 'none', graphRevision: graphSnapshot?.graphRevision || null, cache: 'not-applicable',
337
+ }},
338
+ })
311
339
  }
312
340
  }
313
341
  if (!mutatesTarget && !refreshesGraph) return execute()
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
2
  import { spawnSync } from 'node:child_process'
3
- import { extname, join, relative } from 'node:path'
3
+ import { extname, isAbsolute, join, relative, resolve } from 'node:path'
4
4
  import { resolveRepoPath } from './repo-path.js'
5
5
  import { childProcessEnv } from './child-env.js'
6
6
  import { toolResult } from './mcp/tool-result.mjs'
@@ -11,21 +11,24 @@ const MAX_SEARCH_FILE_BYTES = 1024 * 1024
11
11
  const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024
12
12
  const MAX_SOURCE_CONTEXT_LINES = 1000
13
13
 
14
- function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }) {
14
+ function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spawnRg = spawnSync) {
15
15
  const rg = resolveRg()
16
16
  if (!rg) return null
17
17
  const args = ['--line-number', '--no-heading', '--color', 'never', '--hidden', '-g', '!.git/**', '-m', '100', '-i']
18
18
  if (!isRegex) args.push('--fixed-strings')
19
19
  if (glob) args.push('-g', glob)
20
- args.push('--', query, repoRoot)
21
- const res = spawnSync(rg, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000, env: childProcessEnv() })
20
+ // Run from the repository root and search `.` so path globs such as `src/**` are evaluated
21
+ // against repository-relative paths on Windows too. Passing an absolute search root makes rg
22
+ // compare the glob with a drive-prefixed path and silently return no matches.
23
+ args.push('--', query, '.')
24
+ const res = spawnRg(rg, args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000, env: childProcessEnv() })
22
25
  if (res.status !== 0 && res.status !== 1) return null
23
26
  const out = []
24
27
  for (const line of (res.stdout || '').split(/\r?\n/)) {
25
28
  const match = line.match(/^(.*?):(\d+):(.*)$/)
26
29
  if (!match) continue
27
30
  out.push({
28
- file: relative(repoRoot, match[1]).replace(/\\/g, '/'),
31
+ file: relative(repoRoot, isAbsolute(match[1]) ? match[1] : resolve(repoRoot, match[1])).replace(/\\/g, '/'),
29
32
  line: Number(match[2]),
30
33
  text: match[3].trim().slice(0, 300),
31
34
  })
@@ -87,12 +90,12 @@ function nodeGrep(repoRoot, query, { isRegex, glob, maxResults }) {
87
90
  return out
88
91
  }
89
92
 
90
- export function searchCode({ repoRoot, resolveRg }, { query, is_regex = false, max_results = 40, glob } = {}) {
93
+ export function searchCode({ repoRoot, resolveRg, spawnRg = spawnSync }, { query, is_regex = false, max_results = 40, glob } = {}) {
91
94
  if (!query) return 'Provide a "query" string.'
92
95
  if (!repoRoot || !existsSync(repoRoot)) return 'Source search unavailable: repo root not provided to this MCP server.'
93
96
  const max = Math.max(1, Math.min(200, Number(max_results) || 40))
94
97
  const opts = { isRegex: !!is_regex, glob: glob || null, maxResults: max }
95
- let matches = rgSearch(repoRoot, resolveRg, query, opts)
98
+ let matches = rgSearch(repoRoot, resolveRg, query, opts, spawnRg)
96
99
  const engine = matches ? 'ripgrep' : 'node'
97
100
  if (!matches) matches = nodeGrep(repoRoot, query, opts)
98
101
  const what = is_regex ? `/${query}/i` : `"${query}"`
@@ -46,7 +46,7 @@ const DEFAULT_RULES = [
46
46
  {
47
47
  category: "test",
48
48
  pattern: "test/spec roots and conventional test filenames",
49
- regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)test_[^/]*\.py$/i,
49
+ regex: /(^|\/)(?:__tests?__|tests?|spec)(\/|$)|\.(?:test|itest|spec)\.[^.\/]+$|_test\.go$|(^|\/)test(?:_[^/]*)?\.py$/i,
50
50
  },
51
51
  {
52
52
  category: "generated",
@@ -7,6 +7,7 @@ import {
7
7
  precisionOverlayMatches,
8
8
  precisionSemanticInputsMatch,
9
9
  } from './lsp-overlay.js'
10
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
10
11
 
11
12
  export const SYMBOL_PRECISION_CACHE_V = 1
12
13
  export const SYMBOL_PRECISION_CACHE_FILE = 'precision-symbols.json'
@@ -79,6 +80,56 @@ function cacheable(overlay) {
79
80
  && overlay?.reason === 'semantic precision stopped at a configured safety limit'
80
81
  }
81
82
 
83
+ // Point-query results intentionally live outside the broad precision overlay. Health tools still
84
+ // need to consume revision-bound positive evidence: once an exact query found a reference, the
85
+ // same symbol must not remain in a dead-code queue. This reader is synchronous and fail-closed;
86
+ // stale, malformed, incomplete no-reference, or semantically changed entries contribute nothing.
87
+ export function readCachedSymbolPrecisionEvidence({repoRoot, graphPath, graph} = {}) {
88
+ const empty = {referenceSymbols: [], productionReferenceSymbols: [], testReferenceSymbols: [], noReferenceSymbols: []}
89
+ if (!repoRoot || !graphPath || !graph) return empty
90
+ const precisionGraph = graph.graphPrecisionMode === 'off' ? {...graph, graphPrecisionMode: 'lsp'} : graph
91
+ const nodesById = new Map((precisionGraph.nodes || []).map((node) => [String(node.id), node]))
92
+ const classifier = createPathClassifier(repoRoot)
93
+ const referenced = new Set()
94
+ const production = new Set()
95
+ const tests = new Set()
96
+ const noReference = new Set()
97
+ const semanticMatches = new Map()
98
+ for (const entry of readCache(symbolPrecisionCachePath(graphPath)).entries) {
99
+ const overlay = entry?.overlay
100
+ if (!overlay || !precisionOverlayMatches(overlay, precisionGraph, {request: overlay.request})) continue
101
+ const fingerprint = String(overlay.semanticInputFingerprint || '')
102
+ if (!semanticMatches.has(fingerprint)) semanticMatches.set(
103
+ fingerprint,
104
+ precisionSemanticInputsMatch(overlay, repoRoot, precisionGraph),
105
+ )
106
+ if (!semanticMatches.get(fingerprint)) continue
107
+ const targetId = String(entry.targetId || '')
108
+ if (!nodesById.has(targetId)) continue
109
+ const locations = Array.isArray(overlay.locations)
110
+ ? overlay.locations.filter((location) => String(location?.target || '') === targetId)
111
+ : []
112
+ if (locations.length) {
113
+ referenced.add(targetId)
114
+ for (const location of locations) {
115
+ const file = String(location?.file || nodesById.get(String(location?.source || ''))?.source_file || '')
116
+ if (!file) continue
117
+ const info = classifier.explain(file, {content: ''})
118
+ if (hasPathClass(info, 'test', 'e2e')) tests.add(targetId)
119
+ else production.add(targetId)
120
+ }
121
+ }
122
+ if (overlay.state === 'COMPLETE' && Array.isArray(overlay.noReferenceSymbols)
123
+ && overlay.noReferenceSymbols.some((id) => String(id) === targetId)) noReference.add(targetId)
124
+ }
125
+ return {
126
+ referenceSymbols: [...referenced],
127
+ productionReferenceSymbols: [...production],
128
+ testReferenceSymbols: [...tests],
129
+ noReferenceSymbols: [...noReference],
130
+ }
131
+ }
132
+
82
133
  export async function querySymbolPrecision({
83
134
  repoRoot,
84
135
  graphPath,
@@ -1,93 +0,0 @@
1
- # Weavatrix 0.2.7
2
-
3
- ## Proof-carrying changes
4
-
5
- `verified_change` is a new high-level MCP workflow for planning and verifying a code change. It
6
- accepts a natural-language task, an optional unified diff or changed-file hints, and an immutable Git
7
- baseline. Its stable result is one of:
8
-
9
- - `PASS`: every applicable enabled proof completed and no ratchet failed;
10
- - `BLOCKED`: a test failed, a new duplicate intersects changed code, a runtime cycle appeared, an
11
- architecture ratchet failed, or bounded API evidence found an HTTP method mismatch;
12
- - `UNKNOWN`: required evidence is missing, partial, disabled, or not configured.
13
-
14
- The structured result contains intent-expanded task retrieval combined with exact changed-symbol
15
- seeds, up to five compact LSP/graph edit contexts, symbol-aware blast radius, affected-test paths,
16
- bounded JS/TS call-argument-to-parameter evidence, Git graph drift, architecture verification, a
17
- new-duplicate baseline comparison, and optional cross-repository HTTP contract evidence.
18
-
19
- The workflow respects the existing capability boundary. Exact source/LSP context requires `source`,
20
- duplicate comparison and architecture verification require `health`, and API tracing requires
21
- `crossrepo`. A narrow custom profile is never silently bypassed.
22
-
23
- ## Safe targeted tests
24
-
25
- The workflow does not accept shell commands. It can execute at most five existing `package.json`
26
- scripts whose names match the test/check/verify allowlist. Execution requires both
27
- `run_tests:true` on the tool call and `WEAVATRIX_ALLOW_TEST_RUNS=1` in the server environment. The
28
- default remains read-only and returns the test plan or `UNKNOWN` when runtime evidence was requested
29
- but not authorized.
30
-
31
- ## Malware scanner correction
32
-
33
- The npm content sweep now starts at actual installed package roots instead of the entire
34
- `node_modules` container. Hidden package-manager caches and hosted release snapshots are therefore
35
- not attributed as dependencies and cannot make Weavatrix flag its own malware-signature source.
36
-
37
- Heuristic remediation no longer says to treat a package as compromised or rotate all reachable
38
- secrets. It asks the operator to quarantine and inspect the package, verify origin and lockfile
39
- integrity, and rotate secrets only after execution or credential exposure is confirmed.
40
-
41
- Static heuristic findings now have a hard `high` severity ceiling. They expose
42
- `confirmedExecution:false` plus explicit runtime/origin/lockfile/exposure verification states;
43
- `critical` is reserved for independently confirmed malicious advisories or runtime evidence.
44
-
45
- Package-lock install-script drift now compares only `preinstall`, `install`, and `postinstall`.
46
- Development/publication hooks such as `prepare` no longer create false high-severity drift findings.
47
-
48
- ## Signal-quality corrections
49
-
50
- - `query_graph` applies production-first path classification to the traversal, not only seed
51
- selection. Tests, generated code, fixtures, docs, benchmarks and excluded paths require an
52
- explicit question class or `include_classified:true`. Exact non-product seed files remain usable.
53
- - `verified_change` task retrieval uses the same production-first policy while always retaining
54
- exact changed symbols. The bundled routing microbenchmark moved from one test-symbol false
55
- positive (`0.25`) to zero (`0`) without losing any of its three expected production targets.
56
- - Unreferenced constant/field leaves that do not match the query are hidden by default; use
57
- `include_low_signal:true` for diagnostic expansion.
58
- - `hot_path_review` now defaults to a focused score gate of 85 plus a narrow strong-local fallback.
59
- On the 0.2.7 source tree this reduced the default queue from 717 diagnostic candidates to 116 of
60
- 1,043 analyzed symbols; `min_score:0` restores the full diagnostic set.
61
- - `find_dead_code` adds `STRONG_STATIC_EVIDENCE`, `BOUNDED_STATIC_EVIDENCE`, and
62
- `HIGH_UNCERTAINTY` tiers, structured completed/remaining checks, and `autoDelete:false` on every
63
- candidate. Strong static evidence means exact zero LSP references, not permission to delete.
64
- - npm unused/missing/duplicate dependency findings now carry manifest section, indexed-source,
65
- script/config, package-scope and unresolved dynamic/plugin verification state. Unused dependencies
66
- remain `REVIEW_REQUIRED`; missing dependencies retain the exact importing-file evidence.
67
-
68
- ## Benchmark contract
69
-
70
- `npm run benchmark:agent` runs a deterministic task-to-symbol routing microbenchmark and reports task
71
- success, false-positive rate, estimated output tokens, and duration. This is intentionally labelled
72
- as a routing microbenchmark, not an autonomous end-to-end change benchmark.
73
-
74
- Independent Codebase Memory and Serena results may be supplied with
75
- `--independent-results <json>` using the blind evaluator contract in
76
- [`docs/agent-task-benchmark.md`](../agent-task-benchmark.md). Without same-task results for all three
77
- systems, comparison status is `INCOMPLETE`; `npm run benchmark:agent:release` fails closed. The
78
- repository does not manufacture competitor scores.
79
-
80
- ## Scope and limitations
81
-
82
- - The interprocedural evidence maps call arguments to declared callee parameters for bounded JS/TS
83
- graph call edges. It is not a CFG, value-propagation engine, or taint analysis.
84
- - An absent architecture contract produces `UNKNOWN`; provisional budgets are guidance, not policy.
85
- - A static path to a test is not proof that the test executed. A `PASS` that depends on runtime
86
- behavior requires explicitly authorized test execution.
87
- - API proof is optional and registry-scoped. No client match is `UNKNOWN`, never proof of no external
88
- consumers.
89
-
90
- ## Tool catalog
91
-
92
- 0.2.7 exposes 36 MCP tools. The default offline profile exposes 33; pinned exposes 30. Network tools
93
- remain absent from the offline and pinned profiles.