weavatrix 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +91 -20
  2. package/SECURITY.md +18 -0
  3. package/package.json +3 -1
  4. package/skill/SKILL.md +49 -13
  5. package/src/analysis/dead-check.js +48 -2
  6. package/src/analysis/dead-code-review.js +22 -8
  7. package/src/analysis/duplicates.compute.js +11 -6
  8. package/src/analysis/git-ref-graph.js +10 -2
  9. package/src/analysis/hot-path-review.js +228 -0
  10. package/src/analysis/internal-audit.run.js +36 -0
  11. package/src/analysis/source-complexity.report.js +5 -0
  12. package/src/analysis/source-complexity.walk.js +64 -10
  13. package/src/build-graph.js +49 -10
  14. package/src/graph/build-worker.js +21 -1
  15. package/src/graph/builder/lang-java.js +1 -0
  16. package/src/graph/builder/lang-js.js +24 -0
  17. package/src/graph/builder/lang-python.js +161 -4
  18. package/src/graph/freshness-probe.js +3 -3
  19. package/src/graph/incremental-refresh.js +1 -1
  20. package/src/graph/internal-builder.barrels.js +33 -4
  21. package/src/graph/internal-builder.build.js +52 -8
  22. package/src/mcp/catalog.mjs +14 -11
  23. package/src/mcp/graph-context.mjs +143 -14
  24. package/src/mcp/sync-payload.mjs +2 -1
  25. package/src/mcp/tools-actions.mjs +59 -20
  26. package/src/mcp/tools-company.mjs +11 -3
  27. package/src/mcp/tools-graph.mjs +12 -6
  28. package/src/mcp/tools-health.mjs +112 -11
  29. package/src/mcp/tools-impact.mjs +24 -6
  30. package/src/mcp/tools-source.mjs +237 -0
  31. package/src/mcp-server.mjs +99 -11
  32. package/src/path-classification.js +2 -2
  33. package/src/precision/lsp-client.js +682 -0
  34. package/src/precision/lsp-overlay.js +872 -0
  35. package/src/precision/symbol-query.js +137 -0
  36. package/src/precision/typescript-lsp-provider.js +682 -0
@@ -9,10 +9,17 @@ import {childProcessEnv} from '../child-env.js'
9
9
  import {resolveRepoPath} from '../repo-path.js'
10
10
  import {isStructuralRelation} from '../graph/relations.js'
11
11
  import {edgeProvenance} from '../graph/edge-provenance.js'
12
+ import {mergePrecisionOverlay, precisionSemanticInputsMatch, readPrecisionOverlay} from '../precision/lsp-overlay.js'
13
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
12
14
 
13
15
  // ---- graph load + indexes -----------------------------------------------------------------------
14
- export function loadGraph(path) {
15
- const raw = JSON.parse(readFileSync(path, 'utf8'))
16
+ export function loadGraph(path, {repoRoot = null} = {}) {
17
+ const saved = JSON.parse(readFileSync(path, 'utf8'))
18
+ const overlay = readPrecisionOverlay(path, saved)
19
+ const safeOverlay = repoRoot && typeof overlay?.semanticInputFingerprint === 'string'
20
+ && !precisionSemanticInputsMatch(overlay, repoRoot, saved)
21
+ ? null : overlay
22
+ const raw = mergePrecisionOverlay(saved, safeOverlay)
16
23
  const nodes = Array.isArray(raw.nodes) ? raw.nodes : []
17
24
  const links = Array.isArray(raw.links) ? raw.links : []
18
25
  const byId = new Map()
@@ -66,6 +73,9 @@ export function loadGraph(path) {
66
73
  repositoryFreshnessBuilderVersion: typeof raw.repositoryFreshnessBuilderVersion === 'string' ? raw.repositoryFreshnessBuilderVersion : null,
67
74
  repositoryFreshnessProbe: typeof raw.repositoryFreshnessProbe === 'string' ? raw.repositoryFreshnessProbe : null,
68
75
  repositoryFreshnessMode: typeof raw.repositoryFreshnessMode === 'string' ? raw.repositoryFreshnessMode : null,
76
+ graphPrecisionMode: raw.graphPrecisionMode === 'off' ? 'off' : 'lsp',
77
+ precisionOverlayV: Number(raw.precisionOverlayV) || 0,
78
+ precision: raw.precision || null,
69
79
  }
70
80
  }
71
81
 
@@ -121,22 +131,113 @@ const bestByDegree = (g, list) =>
121
131
 
122
132
  const QUERY_STOP = new Set('a an and are around architecture code do does explain find for from how in is me of or project repository show the through to trace what where which with'.split(' '))
123
133
  const QUERY_INTENTS = [
124
- ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'index']],
134
+ ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'application', 'applications', 'index', 'server', 'cli']],
135
+ ['tool-execution', ['tool', 'tools', 'tooling', 'mcp', 'execution', 'execute', 'invocation', 'invoke', 'dispatch', 'dispatcher', 'handler', 'catalog', 'registry']],
125
136
  ['auth', ['auth', 'authentication', 'authorization', 'login', 'session', 'authgate']],
126
137
  ['routing', ['routing', 'router', 'routes', 'route', 'navigation']],
127
138
  ['layout', ['layout', 'layouts', 'shell']],
128
139
  ['api', ['api', 'apis', 'endpoint', 'endpoints', 'client']],
129
140
  ['state', ['state', 'store', 'stores', 'reducer', 'context']],
130
141
  ]
131
- const INTENT_BY_TERM = new Map(QUERY_INTENTS.flatMap(([id, terms]) => terms.map((term) => [term, {id, terms}])))
142
+ const INTENT_BY_TERM = new Map(QUERY_INTENTS
143
+ .filter(([id]) => id !== 'tool-execution')
144
+ .flatMap(([id, terms]) => terms.map((term) => [term, {id, terms}])))
145
+ const TOOL_EXECUTION_TERMS = QUERY_INTENTS.find(([id]) => id === 'tool-execution')[1]
146
+ const TOOL_EXECUTION_TRIGGERS = new Set(['tool', 'tools', 'tooling', 'mcp'])
132
147
  const wordsOf = (value) => String(value ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
133
148
  const normPath = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
149
+ const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
150
+ const CLASS_QUERY_TERMS = Object.freeze({
151
+ test: ['test', 'tests', 'testing', 'spec', 'specs', 'unit'],
152
+ e2e: ['e2e', 'playwright', 'cypress'],
153
+ generated: ['generated', 'autogenerated', 'dist'],
154
+ mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
155
+ story: ['story', 'stories', 'storybook'],
156
+ docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
157
+ benchmark: ['benchmark', 'benchmarks', 'bench'],
158
+ temp: ['temp', 'temporary', 'tmp'],
159
+ })
160
+ const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
161
+ const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
162
+
163
+ const sourceFileOf = (node) => {
164
+ const source = node?.source_file || String(node?.id ?? '').split('#', 1)[0]
165
+ return normPath(source)
166
+ }
167
+
168
+ function requestedPathClasses(query) {
169
+ const words = new Set(wordsOf(query))
170
+ const requested = new Set()
171
+ for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) {
172
+ if (terms.some((term) => words.has(term))) requested.add(category)
173
+ }
174
+ // E2E is also classified as test. Conversely, a broad request for tests should be allowed to
175
+ // surface all test kinds instead of silently hiding integration/e2e files.
176
+ if (requested.has('test')) requested.add('e2e')
177
+ if (requested.has('e2e')) requested.add('test')
178
+ return requested
179
+ }
180
+
181
+ function isQueryEligible(node, requestedClasses, classificationCache, classifier) {
182
+ const source = sourceFileOf(node)
183
+ if (!source) return true
184
+ // Query ranking needs path/config classes, not generated-header inspection. Supplying bounded
185
+ // content avoids opening every source file in a large repository merely to choose a few seeds.
186
+ if (!classificationCache.has(source)) classificationCache.set(source, classifier.explain(source, {content: ''}))
187
+ const info = classificationCache.get(source)
188
+ const classified = NON_PRODUCT_CLASSES.filter((category) => hasPathClass(info, category))
189
+ return classified.length === 0 || classified.some((category) => requestedClasses.has(category))
190
+ }
191
+
192
+ // A graph query has no repository filesystem context, so it cannot safely execute package scripts or
193
+ // guess a package.json beside an arbitrary graph path. Prefer evidence already present in the graph:
194
+ // conventional executable paths, explicit entry metadata when a builder provides it, and entry-like
195
+ // topology (few importers, real outgoing runtime links).
196
+ function entrypointSignal(g, node, source, stem) {
197
+ if (isSymbol(node.id) || !CODE_FILE_RE.test(source)) return 0
198
+ const segments = source.split('/')
199
+ const depth = segments.length
200
+ let score = 0
201
+ if (node.entrypoint === true || node.is_entrypoint === true || node.declared_entry === true) score = 72
202
+ if (/^bin\//.test(source) || /\/(?:bin|cmd)\//.test(source)) score = Math.max(score, 62)
203
+ if (depth <= 2 && /^(?:index|main|app|server|cli|bootstrap|entry|run)$/.test(stem)) score = Math.max(score, 60)
204
+ if (depth <= 2 && /(?:^|[-_.])(?:main|server|cli|bootstrap|entry)(?:$|[-_.])/.test(stem)) score = Math.max(score, 57)
205
+ if (depth <= 3 && /^(?:main|app|server|cli|bootstrap|entry|run)$/.test(stem)) score = Math.max(score, 52)
206
+ const incoming = uniqueConnCount(g.inn.get(String(node.id)))
207
+ const outgoing = uniqueConnCount(g.out.get(String(node.id)))
208
+ if (score && incoming === 0 && outgoing > 0) score += Math.min(7, 2 + outgoing)
209
+ return score
210
+ }
211
+
212
+ function toolExecutionSignal(node, source, words, stem) {
213
+ if (!CODE_FILE_RE.test(source)) return 0
214
+ let score = 0
215
+ if (/(^|\/)(?:mcp(?:[-_.][^/]*)?|tools?)(?:\/|[-_.])/.test(source)) score = Math.max(score, 51)
216
+ // Dispatch/catalog files explain how the tool set is assembled and invoked; individual
217
+ // `tools-*` modules explain only one capability. Prefer the control plane for broad questions.
218
+ if (/^(?:catalog|dispatch(?:er)?|registry)$/.test(stem)) score = Math.max(score, 68)
219
+ if (/^(?:tool[-_.]?(?:handler|runner|executor)|tools?[-_.])/.test(stem)) score = Math.max(score, 55)
220
+ if ([...words].some((word) => /^(?:dispatch|dispatcher|toolcall|toolhandler|executetool|invoketool|calltool)$/.test(word))) score = Math.max(score, 64)
221
+ if (source.includes('/mcp/') || stem.includes('mcp-server')) score = Math.max(score, 48)
222
+ return score
223
+ }
134
224
 
135
225
  function queryConcepts(query) {
226
+ const tokens = wordsOf(query)
227
+ const toolExecution = tokens.some((token) => TOOL_EXECUTION_TRIGGERS.has(token))
228
+ const explanatoryWork = tokens.some((token) => token === 'how' || token === 'explain')
136
229
  const seen = new Set()
137
230
  const concepts = []
138
- for (const raw of wordsOf(query)) {
231
+ for (const raw of tokens) {
139
232
  if (raw.length < 2 || QUERY_STOP.has(raw)) continue
233
+ if (explanatoryWork && (raw === 'work' || raw === 'works' || raw === 'working')) continue
234
+ if (toolExecution && TOOL_EXECUTION_TERMS.includes(raw)) {
235
+ if (seen.has('tool-execution')) continue
236
+ const trigger = tokens.find((token) => TOOL_EXECUTION_TRIGGERS.has(token)) || raw
237
+ seen.add('tool-execution')
238
+ concepts.push({id: 'tool-execution', raw: trigger, terms: [trigger, ...TOOL_EXECUTION_TERMS.filter((term) => term !== trigger)]})
239
+ continue
240
+ }
140
241
  const intent = INTENT_BY_TERM.get(raw)
141
242
  const id = intent?.id || raw
142
243
  if (seen.has(id)) continue
@@ -146,10 +247,10 @@ function queryConcepts(query) {
146
247
  return concepts
147
248
  }
148
249
 
149
- function conceptScore(g, node, concept) {
250
+ function conceptScore(g, node, concept, queryContext) {
150
251
  const id = normPath(node.id)
151
252
  const label = String(node.label ?? '').toLowerCase()
152
- const source = normPath(node.source_file)
253
+ const source = sourceFileOf(node)
153
254
  const stem = (label.split('/').pop() || '').replace(/\.[^.]+$/, '')
154
255
  const words = new Set(wordsOf(`${node.id} ${node.label ?? ''} ${node.source_file ?? ''}`))
155
256
  const segments = new Set(source.split('/').flatMap((part) => wordsOf(part.replace(/\.[^.]+$/, ''))))
@@ -159,22 +260,38 @@ function conceptScore(g, node, concept) {
159
260
  if (label === term || stem === term) match = Math.max(match, primary ? 60 : 42)
160
261
  else if (segments.has(term)) match = Math.max(match, primary ? 48 : 36)
161
262
  else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
162
- else if (term.length >= 4 && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
263
+ else if (term.length >= 4 && term !== 'tool' && term !== 'tools' && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
163
264
  })
164
- if (!match) return 0
165
265
  const fileNode = !isSymbol(node.id)
166
266
  const depth = source ? source.split('/').length : 9
167
- const entryBoost = concept.id === 'bootstrap' && /^(bootstrap|main|app|index|root)$/.test(stem) ? 10 : 0
168
- return match + (fileNode ? 7 : 0) + Math.max(0, 4 - depth) + entryBoost + Math.min(2, degreeOf(g, node.id) / 40)
267
+ if (concept.id === 'bootstrap') match = Math.max(match, entrypointSignal(g, node, source, stem))
268
+ if (concept.id === 'tool-execution') match = Math.max(match, toolExecutionSignal(node, source, words, stem))
269
+ if (!match) return 0
270
+ let score = match + (fileNode ? 7 : 0) + Math.max(0, 4 - depth) + Math.min(2, degreeOf(g, node.id) / 40)
271
+ if ((concept.id === 'bootstrap' || concept.id === 'tool-execution') && DATA_OR_PROSE_RE.test(source)) score -= 34
272
+ if ((concept.id === 'bootstrap' || concept.id === 'tool-execution') && !fileNode) score -= 18
273
+ if (queryContext.runtimeIntent && !queryContext.maintenanceIntent && /^(?:scripts?|tools?\/scripts?)\//.test(source)) score -= 32
274
+ // A web/demo index can be a legitimate entry point, but it is not the server/tool entry point of
275
+ // a backend/tool-execution question. Keep it queryable for explicit UI/site questions while
276
+ // preventing it from beating the executable and dispatcher merely because its basename is index.
277
+ if (queryContext.runtimeIntent && /^(?:site|website|public|static|assets)\//.test(source)) score -= 28
278
+ return Math.max(0, score)
169
279
  }
170
280
 
171
281
  // Natural-language graph search keeps one strong candidate per concept before filling by aggregate
172
282
  // score. This prevents a broad architecture question from spending every seed on one dense API area.
173
- export function findSeeds(g, query, limit = 8) {
283
+ export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
174
284
  const concepts = queryConcepts(query)
175
285
  if (!concepts.length || limit <= 0) return []
176
- const rows = g.nodes.map((node) => {
177
- const scores = concepts.map((concept) => conceptScore(g, node, concept))
286
+ const requestedClasses = requestedPathClasses(query)
287
+ const queryContext = {
288
+ runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'),
289
+ maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)),
290
+ }
291
+ const classifier = createPathClassifier(repoRoot)
292
+ const classificationCache = new Map()
293
+ const rows = g.nodes.filter((node) => isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
294
+ const scores = concepts.map((concept) => conceptScore(g, node, concept, queryContext))
178
295
  return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
179
296
  })
180
297
  const chosen = []
@@ -291,4 +408,16 @@ export function rawGraph(ctx) {
291
408
  return rawGraphCache.data
292
409
  }
293
410
 
411
+ // Consumers that need semantic precision use the revision-matched effective graph. Structural Git
412
+ // baselines and graph_diff deliberately keep using rawGraph so provider availability cannot fabricate
413
+ // architecture drift.
414
+ export function effectiveRawGraph(ctx) {
415
+ const raw = rawGraph(ctx)
416
+ const overlay = readPrecisionOverlay(ctx.graphPath, raw)
417
+ const safeOverlay = ctx?.repoRoot && typeof overlay?.semanticInputFingerprint === 'string'
418
+ && !precisionSemanticInputsMatch(overlay, ctx.repoRoot, raw)
419
+ ? null : overlay
420
+ return mergePrecisionOverlay(raw, safeOverlay)
421
+ }
422
+
294
423
  export {prevGraphPathFor, edgeEndpoint, fileOfId, diffGraphs, formatGraphDiff} from './graph-diff.mjs'
@@ -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) {
@@ -4,7 +4,7 @@
4
4
  import {readFileSync, writeFileSync, existsSync, statSync, realpathSync} from 'node:fs'
5
5
  import {dirname, join, isAbsolute} from 'node:path'
6
6
  import {prevGraphPathFor, diffGraphs, formatGraphDiff, graphStaleness} from './graph-context.mjs'
7
- import {buildGraphForRepo} from '../build-graph.js'
7
+ import {buildGraphForRepo, defaultPrecisionMode} from '../build-graph.js'
8
8
  import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../graph/layout.js'
9
9
  import {liveRepositoryRecords, registerRepository, repositoryRecord} from '../graph/repo-registry.js'
10
10
  import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
@@ -12,6 +12,7 @@ import {collectInstalled} from '../security/installed.js'
12
12
  import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from './sync-payload.mjs'
13
13
  import {createEvidenceSnapshot} from './evidence-snapshot.mjs'
14
14
  import {writeCachedArchitectureContract} from '../analysis/architecture-contract.js'
15
+ import {precisionSemanticInputsMatch, readPrecisionOverlay} from '../precision/lsp-overlay.js'
15
16
 
16
17
  const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
17
18
 
@@ -23,11 +24,14 @@ function syncRepoLabel(repoRoot) {
23
24
 
24
25
  export async function tRebuildGraph(g, args, ctx) {
25
26
  if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
26
- const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
27
+ const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode)
28
+ ? args.mode
29
+ : ['no-tests', 'tests-only', 'full'].includes(g?.graphBuildMode) ? g.graphBuildMode : 'full'
30
+ const precision = args.precision === 'off' ? 'off' : args.precision === 'lsp' ? 'lsp' : (g?.graphPrecisionMode || defaultPrecisionMode())
27
31
  const scope = String(args.scope || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
28
32
  if (scope) {
29
33
  const scopedDir = graphOutDirForModule(ctx.repoRoot, scope)
30
- const scoped = await buildGraphForRepo(ctx.repoRoot, {mode, scope, outDir: scopedDir})
34
+ const scoped = await buildGraphForRepo(ctx.repoRoot, {mode, scope, precision, outDir: scopedDir})
31
35
  if (!scoped || !scoped.ok) return `Scoped graph build failed: ${(scoped && scoped.error) || 'unknown error'}`
32
36
  return [
33
37
  `Built an isolated scoped graph for ${scope} (${mode}). ${scoped.log || ''}.`,
@@ -38,18 +42,21 @@ export async function tRebuildGraph(g, args, ctx) {
38
42
  // snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
39
43
  let prevBytes = null
40
44
  try { prevBytes = readFileSync(ctx.graphPath) } catch { /* first build — nothing to diff against */ }
41
- const before = g?.nodes ? {
42
- nodes: g.nodes, links: g.links,
43
- edgeTypesV: g.edgeTypesV || 0,
44
- edgeProvenanceV: g.edgeProvenanceV || 0,
45
- barrelResolutionV: g.barrelResolutionV || 0,
46
- extractorSchemaV: g.extractorSchemaV || 0,
47
- } : null
48
- const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: '', outDir: ctx.graphPath ? dirname(ctx.graphPath) : undefined})
45
+ let before = null
46
+ try { before = prevBytes ? JSON.parse(prevBytes.toString('utf8')) : null } catch { before = null }
47
+ const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: '', precision, outDir: ctx.graphPath ? dirname(ctx.graphPath) : undefined})
49
48
  if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
50
49
  if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
51
50
  const fresh = ctx.reload() // refresh THIS server's in-memory graph so subsequent tool calls see the new graph
52
- const delta = before && fresh ? formatGraphDiff(diffGraphs(before, fresh)) : null
51
+ let afterStatic = null
52
+ try { afterStatic = JSON.parse(readFileSync(ctx.graphPath, 'utf8')) } catch { /* reload already reports the failure */ }
53
+ const beforeMode = ['full', 'no-tests', 'tests-only'].includes(before?.graphBuildMode) ? before.graphBuildMode : 'full'
54
+ const afterMode = ['full', 'no-tests', 'tests-only'].includes(afterStatic?.graphBuildMode) ? afterStatic.graphBuildMode : 'full'
55
+ const delta = before && afterStatic
56
+ ? beforeMode === afterMode
57
+ ? formatGraphDiff(diffGraphs(before, afterStatic))
58
+ : `Structural delta not computed: build mode changed from ${beforeMode} to ${afterMode}, so the node/edge universes are not comparable.`
59
+ : null
53
60
  return [
54
61
  `Rebuilt the graph (${mode}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
55
62
  delta
@@ -68,25 +75,52 @@ export async function tOpenRepo(g, args, ctx) {
68
75
  try { if (!statSync(repoPath).isDirectory()) return `Not a directory: ${requestedPath}` } catch { return `Path not found: ${requestedPath}` }
69
76
  if (!existsSync(join(repoPath, '.git'))) return `Not a Git repository: ${requestedPath}`
70
77
  const graphPath = join(graphOutDirForRepo(repoPath), 'graph.json')
78
+ const graphExists = existsSync(graphPath)
79
+ const requestedMode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : null
80
+ const requestedPrecision = ['lsp', 'off'].includes(args.precision) ? args.precision : null
71
81
  let built = false
72
82
  let upgrade = false
73
- if (existsSync(graphPath)) {
83
+ let schemaUpgrade = false
84
+ let precisionUpgrade = false
85
+ let savedMode = 'full'
86
+ let savedPrecision = defaultPrecisionMode()
87
+ if (graphExists) {
74
88
  try {
75
89
  const saved = JSON.parse(readFileSync(graphPath, 'utf8'))
76
- upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
90
+ savedMode = ['no-tests', 'tests-only', 'full'].includes(saved.graphBuildMode) ? saved.graphBuildMode : 'full'
91
+ savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
92
+ schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
77
93
  || !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
94
+ || !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 4
95
+ if (savedPrecision === 'lsp') {
96
+ const overlay = readPrecisionOverlay(graphPath, saved)
97
+ precisionUpgrade = !overlay || (typeof overlay.semanticInputFingerprint === 'string'
98
+ && !precisionSemanticInputsMatch(overlay, repoPath, saved))
99
+ }
100
+ upgrade = schemaUpgrade || precisionUpgrade
78
101
  } catch {
79
102
  upgrade = true
80
103
  }
81
104
  }
82
- if (!existsSync(graphPath) || upgrade) {
105
+ const modeMismatch = graphExists && requestedMode != null && requestedMode !== savedMode
106
+ const precisionMismatch = graphExists && requestedPrecision != null && requestedPrecision !== savedPrecision
107
+ if (!graphExists || upgrade || modeMismatch || precisionMismatch) {
83
108
  if (args.build === false) {
109
+ if (modeMismatch && !upgrade) {
110
+ return `The existing graph for ${repoPath} was built in ${savedMode}, but ${requestedMode} was requested. Re-call without build:false to rebuild it before switching.`
111
+ }
112
+ if (precisionMismatch && !upgrade) {
113
+ return `The existing graph for ${repoPath} uses semantic precision ${savedPrecision}, but ${requestedPrecision} was requested. Re-call without build:false to rebuild it before switching.`
114
+ }
84
115
  return upgrade
85
- ? `The existing graph for ${repoPath} predates current typed-edge/provenance metadata. Re-call without build:false to upgrade it before switching.`
116
+ ? precisionUpgrade && !schemaUpgrade
117
+ ? `The existing graph for ${repoPath} lacks current revision-matched semantic precision evidence. Re-call without build:false to refresh it before switching.`
118
+ : `The existing graph for ${repoPath} predates current typed-edge/provenance metadata. Re-call without build:false to upgrade it before switching.`
86
119
  : `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
87
120
  }
88
- const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
89
- const res = await buildGraphForRepo(repoPath, {mode, scope: ''})
121
+ const mode = requestedMode || (graphExists ? savedMode : 'full')
122
+ const precision = requestedPrecision || (graphExists ? savedPrecision : defaultPrecisionMode())
123
+ const res = await buildGraphForRepo(repoPath, {mode, scope: '', precision})
90
124
  if (!res || !res.ok) return `Graph build failed for ${repoPath}: ${(res && res.error) || 'unknown error'}`
91
125
  built = true
92
126
  }
@@ -101,8 +135,13 @@ export async function tOpenRepo(g, args, ctx) {
101
135
  return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
102
136
  }
103
137
  registerRepository({repoPath, graphDir: graphOutDirForRepo(repoPath), graphHome: graphHomeDir()})
104
- const buildNote = built ? (upgrade ? ' (graph upgraded to current edge metadata)' : ' (graph built fresh)') : ''
105
- return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
138
+ const buildNote = built ? (upgrade ? ' (graph upgraded to current edge/precision metadata)' : modeMismatch ? ' (graph rebuilt in the requested mode)' : precisionMismatch ? ' (semantic precision mode updated)' : ' (graph built fresh)') : ''
139
+ return [
140
+ `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`,
141
+ `Graph: ${graphPath}`,
142
+ `Build mode: ${loaded.graphBuildMode || 'full'}`,
143
+ `Semantic precision: ${loaded.precision?.state || 'UNAVAILABLE'} (${loaded.precision?.verifiedEdges || 0} EXACT_LSP edge(s))`,
144
+ ].join('\n')
106
145
  }
107
146
 
108
147
  // Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
@@ -48,10 +48,17 @@ function safeRefreshReason(record, error) {
48
48
  }
49
49
 
50
50
  async function reconcileGraph(record, alias, role, graphHome) {
51
+ let registeredGraph = null
52
+ try { registeredGraph = loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}) } catch { /* refresh may repair it */ }
53
+ const buildMode = ['full', 'no-tests', 'tests-only'].includes(registeredGraph?.graphBuildMode)
54
+ ? registeredGraph.graphBuildMode
55
+ : 'full'
56
+ const precision = registeredGraph?.graphPrecisionMode === 'off' ? 'off' : 'lsp'
51
57
  let build
52
58
  try {
53
59
  build = await buildGraphForRepo(record.repoPath, {
54
- mode: 'full',
60
+ mode: buildMode,
61
+ precision,
55
62
  scope: '',
56
63
  outDir: record.graphDir,
57
64
  graphHome,
@@ -64,6 +71,7 @@ async function reconcileGraph(record, alias, role, graphHome) {
64
71
  const publicStatus = {
65
72
  role,
66
73
  repository: publicRecord(record, alias),
74
+ buildMode,
67
75
  status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
68
76
  refresh: build?.ok ? {
69
77
  kind: refreshKind || 'full',
@@ -73,7 +81,7 @@ async function reconcileGraph(record, alias, role, graphHome) {
73
81
  }
74
82
  if (build?.ok) {
75
83
  try {
76
- return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json')), publicStatus}
84
+ return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}), publicStatus}
77
85
  } catch (error) {
78
86
  const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
79
87
  return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
@@ -85,7 +93,7 @@ async function reconcileGraph(record, alias, role, graphHome) {
85
93
  return {
86
94
  record,
87
95
  alias,
88
- graph: loadGraph(join(record.graphDir, 'graph.json')),
96
+ graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}),
89
97
  reason: `${reason}; stale registered graph used`,
90
98
  publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
91
99
  }
@@ -36,13 +36,17 @@ export function tGraphStats(g, ctx) {
36
36
  .join(', ')
37
37
  const freshness = ctx ? graphStaleness(ctx) : null
38
38
  const provenance = summarizeEdgeProvenance(g.links)
39
+ const precision = g.precision || {state: 'UNAVAILABLE', verifiedEdges: 0, candidates: 0, queried: 0, reason: 'no revision-matched precision overlay'}
39
40
  return [
40
41
  `Graph summary`,
41
42
  ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
43
+ ctx?.graphPath ? `- Graph: ${ctx.graphPath}` : null,
44
+ `- Build mode: ${g.graphBuildMode || 'full'}`,
42
45
  `- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
43
46
  `- Edges: ${g.links.length}`,
44
47
  g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
45
48
  g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${fmt(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : `- Edge provenance: unavailable (rebuild_graph required)`,
49
+ `- Semantic precision: ${precision.state}${precision.provider ? ` via ${precision.provider}${precision.providerVersion ? ` ${precision.providerVersion}` : ''}${precision.typescriptVersion ? ` (TypeScript ${precision.typescriptVersion})` : ''}` : ''}; ${precision.verifiedEdges || 0} EXACT_LSP edge(s), ${precision.queried || 0}/${precision.candidates || 0} bounded target(s) queried${precision.truncated ? ' (partial/truncated)' : ''}${precision.reason ? `; ${precision.reason}` : ''}`,
46
50
  g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
47
51
  `- Relations: ${fmt(relCount)}`,
48
52
  Object.keys(confCount).length ? `- Legacy confidence: ${fmt(confCount)}` : null,
@@ -67,7 +71,7 @@ export function tGetNode(g, {label} = {}, ctx) {
67
71
  const sample = (list, dir) =>
68
72
  list
69
73
  .slice(0, 12)
70
- .map((e) => ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]`)
74
+ .map((e) => ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} [${e.provenance || 'UNKNOWN'}] ${labelOf(g, e.id)} [${e.id}]`)
71
75
  .join('\n') || ' (none)'
72
76
  return [
73
77
  note,
@@ -92,8 +96,10 @@ function dedupeEdges(list) {
92
96
  for (const e of list) {
93
97
  const key = `${e.relation || 'rel'}|${compileKind(e) || 'runtime'}|${e.id}`
94
98
  const cur = grouped.get(key)
95
- if (cur) cur.count += 1
96
- else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, compileOnly: e.compileOnly === true, count: 1})
99
+ if (cur) {
100
+ cur.count += 1
101
+ cur.provenance.add(e.provenance || 'UNKNOWN')
102
+ } else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, compileOnly: e.compileOnly === true, provenance: new Set([e.provenance || 'UNKNOWN']), count: 1})
97
103
  }
98
104
  return [...grouped.values()]
99
105
  }
@@ -112,7 +118,7 @@ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
112
118
  const outs = dedupeEdges(outsRaw)
113
119
  const ins = dedupeEdges(insRaw)
114
120
  const line = (e, dir) =>
115
- ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
121
+ ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} [${[...e.provenance].sort().join('+')}] ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
116
122
  return [
117
123
  note,
118
124
  `Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
@@ -157,13 +163,13 @@ export function tGetCommunity(g, {community_id} = {}) {
157
163
  // A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
158
164
  // Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
159
165
  // slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
160
- export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false, token_budget = 2000} = {}) {
166
+ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false, token_budget = 2000} = {}, toolCtx = {}) {
161
167
  const pinned = resolveSeedFiles(g, seed_files)
162
168
  // Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
163
169
  // Callers can opt back into augmentation when they explicitly want both behaviors.
164
170
  const automatic = pinned.seeds.length && augment_seeds !== true
165
171
  ? []
166
- : findSeeds(g, question, Math.max(0, 8 - pinned.seeds.length))
172
+ : findSeeds(g, question, Math.max(0, 8 - pinned.seeds.length), {repoRoot: toolCtx.repoRoot || null})
167
173
  const seeds = [...pinned.seeds, ...automatic.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
168
174
  if (!seeds.length) return `No nodes matched "${question}".`
169
175
  const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))