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.
- package/README.md +91 -20
- package/SECURITY.md +18 -0
- package/package.json +3 -1
- package/skill/SKILL.md +49 -13
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +22 -8
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/git-ref-graph.js +10 -2
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/build-graph.js +49 -10
- package/src/graph/build-worker.js +21 -1
- package/src/graph/builder/lang-java.js +1 -0
- package/src/graph/builder/lang-js.js +24 -0
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +3 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.barrels.js +33 -4
- package/src/graph/internal-builder.build.js +52 -8
- package/src/mcp/catalog.mjs +14 -11
- package/src/mcp/graph-context.mjs +143 -14
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +59 -20
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph.mjs +12 -6
- package/src/mcp/tools-health.mjs +112 -11
- package/src/mcp/tools-impact.mjs +24 -6
- package/src/mcp/tools-source.mjs +237 -0
- package/src/mcp-server.mjs +99 -11
- package/src/path-classification.js +2 -2
- package/src/precision/lsp-client.js +682 -0
- package/src/precision/lsp-overlay.js +872 -0
- package/src/precision/symbol-query.js +137 -0
- 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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
177
|
-
|
|
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'
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -95,7 +95,8 @@ const COMPLEXITY_NUMBERS = [
|
|
|
95
95
|
'startLine', 'endLine', 'loc', 'params', 'objectFields', 'branches', 'cyclomatic',
|
|
96
96
|
'loops', 'maxLoopDepth', 'returns', 'awaits', 'callCount', 'externalCalls',
|
|
97
97
|
'asyncBoundaries', 'allocations', 'objectLiterals', 'spreadCopies', 'sorts',
|
|
98
|
-
'linearOps', '
|
|
98
|
+
'linearOps', 'allocationsInLoops', 'copiesInLoops', 'linearOpsInLoops',
|
|
99
|
+
'sortsInLoops', 'recursionInLoops', 'timeRank', 'timeScore', 'memoryRank', 'memoryScore',
|
|
99
100
|
];
|
|
100
101
|
|
|
101
102
|
function sanitizeComplexity(value) {
|
|
@@ -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)
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
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 =
|
|
89
|
-
const
|
|
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
|
|
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:
|
|
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
|
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -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)
|
|
96
|
-
|
|
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))
|