weavatrix 0.2.3 → 0.2.4
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 +56 -11
- package/SECURITY.md +18 -0
- package/package.json +3 -1
- package/skill/SKILL.md +36 -10
- package/src/analysis/dead-code-review.js +13 -3
- package/src/analysis/git-ref-graph.js +10 -2
- 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 +2 -0
- package/src/graph/freshness-probe.js +2 -2
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.build.js +33 -4
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +143 -14
- 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 +46 -6
- package/src/mcp/tools-impact.mjs +21 -3
- 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 +847 -0
- package/src/precision/typescript-lsp-provider.js +682 -0
package/src/mcp/catalog.mjs
CHANGED
|
@@ -33,7 +33,7 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
|
33
33
|
{cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
|
|
34
34
|
{cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
|
|
35
35
|
{cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
|
|
36
|
-
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
|
|
36
|
+
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
37
37
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
38
38
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
39
39
|
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). For a symbol, also follows importers of its containing file. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
@@ -90,16 +90,16 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
|
90
90
|
{cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
|
|
91
91
|
{cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
|
|
92
92
|
{cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
|
|
93
|
-
{cap: 'graph', name: 'module_map', description: '
|
|
93
|
+
{cap: 'graph', name: 'module_map', description: 'Production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
|
|
94
94
|
{cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
95
|
-
{cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. With scope, build an isolated diagnostic graph without replacing or diffing the full graph
|
|
95
|
+
{cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
|
|
96
96
|
{cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
|
|
97
97
|
{cap: 'graph', name: 'get_architecture_contract', description: 'Return the executable target-architecture contract, quality budgets and ratchet mode that an agent must follow before editing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
|
|
98
98
|
{cap: 'graph', name: 'prepare_change', description: 'Select active target-architecture rules for an intended set of changed files. Run before a non-trivial edit.', inputSchema: {type: 'object', properties: {intent: {type: 'string'}, files: {type: 'array', items: {type: 'string'}, maxItems: 200}}, required: ['files']}, run: (g, a, ctx) => tar.tPrepareChange(g, a, ctx)},
|
|
99
99
|
{cap: 'health', name: 'verify_architecture', description: 'Verify the fresh graph against the active target contract and ratchet; separates new, existing, fixed and excepted debt.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tVerifyArchitecture(g, a, ctx)},
|
|
100
100
|
{cap: 'health', name: 'explain_architecture_violation', description: 'Explain one active architecture violation and the governing rule.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}}, required: ['fingerprint']}, run: (g, a, ctx) => tar.tExplainArchitectureViolation(g, a, ctx)},
|
|
101
101
|
{cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
|
|
102
|
-
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'],
|
|
102
|
+
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
103
103
|
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
104
104
|
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
105
105
|
{cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
|
|
@@ -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'
|
|
@@ -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 < 3
|
|
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))
|