weavatrix 0.3.9 → 0.3.10
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 +17 -0
- package/docs/releases/v0.3.10.md +76 -0
- package/package.json +3 -2
- package/skill/SKILL.md +13 -6
- package/src/analysis/dead-check.js +8 -6
- package/src/analysis/dead-code-review.js +3 -0
- package/src/analysis/dep-rules.js +1 -1
- package/src/analysis/hot-path-review.js +1 -1
- package/src/analysis/http-contracts/analysis.js +9 -1
- package/src/analysis/structure/findings.js +21 -1
- package/src/analysis/task-retrieval.js +2 -0
- package/src/build-graph.js +9 -1
- package/src/graph/builder/lang-rust.js +36 -0
- package/src/graph/freshness-probe.js +1 -1
- package/src/graph/graph-filter.js +5 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.build.js +2 -1
- package/src/mcp/actions/graph-lifecycle.mjs +3 -3
- package/src/mcp/catalog.mjs +2 -2
- package/src/mcp/evidence-snapshot.structure.mjs +5 -2
- package/src/mcp/graph/context-seeds.mjs +1 -0
- package/src/mcp/graph/tools-query.mjs +28 -2
- package/src/mcp/health/endpoints.mjs +34 -5
- package/src/mcp/sync/payload-v2.mjs +1 -0
- package/src/mcp/tools-company.mjs +9 -0
- package/src/mcp/tools-endpoints.mjs +54 -12
- package/src/mcp/tools-graph-hubs.mjs +1 -0
- package/src/mcp/tools-impact.mjs +9 -1
- package/src/precision/lsp-overlay/build.js +9 -4
- package/src/precision/lsp-overlay/contract.js +1 -1
- package/src/precision/lsp-overlay/target-index.js +8 -3
- package/src/precision/symbol-query.js +2 -1
package/README.md
CHANGED
|
@@ -131,6 +131,18 @@ Every finding is review evidence, never an auto-delete verdict: `find_dead_code`
|
|
|
131
131
|
`run_audit category=unused` always return `REVIEW_REQUIRED` with `autoDelete:false`. Typecheck, tests
|
|
132
132
|
and runtime checks remain the release authority.
|
|
133
133
|
|
|
134
|
+
## Always-fresh graph
|
|
135
|
+
|
|
136
|
+
There is no watcher daemon to run and no manual refresh step: every graph/health call reconciles the
|
|
137
|
+
graph before answering. A Git-token freshness probe (HEAD + dirty/untracked content, debounced 2 s)
|
|
138
|
+
decides whether anything changed; when it did, a bounded incremental refresh reparses only the changed
|
|
139
|
+
files plus their reverse importers (≤ 24 changed / ≤ 80 reparsed JS/TS files) and merges the scoped
|
|
140
|
+
result into the previous graph under a file lock. Config/lockfile edits, export-surface changes,
|
|
141
|
+
barrel files and non-JS/TS languages fall back to a full rebuild — correctness always wins over speed.
|
|
142
|
+
Each refreshed answer carries a structured `refresh` record (`none` / `incremental` / `full`, changed
|
|
143
|
+
file count), so an agent can tell exactly which repository state it is reasoning about. The same
|
|
144
|
+
guarantees hold across concurrent MCP clients sharing one canonical graph.
|
|
145
|
+
|
|
134
146
|
## Benchmarks
|
|
135
147
|
|
|
136
148
|
Two gates ship in the repository:
|
|
@@ -178,6 +190,11 @@ JavaScript · TypeScript · TSX · Python · Go · Java · C# · Rust · HTML ·
|
|
|
178
190
|
[web-tree-sitter](https://github.com/tree-sitter/tree-sitter) WASM grammars; no Python install and no
|
|
179
191
|
native compilation.
|
|
180
192
|
|
|
193
|
+
Test surfaces are classified per file (path conventions plus `.weavatrix.json` overrides) and, for
|
|
194
|
+
Rust, per symbol: `#[cfg(test)]` modules and `#[test]`/`#[bench]` items inside production `.rs` files
|
|
195
|
+
carry a node-level `test_surface` flag, so dead-code, query, hot-path and hub tools treat them as
|
|
196
|
+
tests rather than production code.
|
|
197
|
+
|
|
181
198
|
## Development
|
|
182
199
|
|
|
183
200
|
```sh
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Weavatrix 0.3.10
|
|
2
|
+
|
|
3
|
+
0.3.10 closes the honesty gaps a production evaluation surfaced: Rust inline
|
|
4
|
+
tests are no longer reported as production code, endpoint tools are
|
|
5
|
+
production-first everywhere, and semantic-precision statuses say exactly how
|
|
6
|
+
much was actually proven. Every fix landed with regression tests and survived
|
|
7
|
+
an adversarial multi-agent review of the full changeset.
|
|
8
|
+
|
|
9
|
+
## Node-level Rust test classification
|
|
10
|
+
|
|
11
|
+
Rust puts unit tests inside production files, so path classification alone
|
|
12
|
+
misreported them everywhere. The extractor now proves test-only compilation —
|
|
13
|
+
`#[cfg(test)]` / `#[cfg(all(test, ...))]` on items, impl blocks and modules,
|
|
14
|
+
`#[test]`/`#[tokio::test]`-style harness attributes and `#[bench]` functions,
|
|
15
|
+
inner `#![cfg(test)]` attributes at file and module-body scope, and attributes
|
|
16
|
+
separated from their item by comments — and stamps a `test_surface` flag on the
|
|
17
|
+
node. Deliberately conservative: `#[cfg(any(test, ...))]` also ships in
|
|
18
|
+
production builds and stays unclassified.
|
|
19
|
+
|
|
20
|
+
Every consumer honors the flag like a path-classified test: `find_dead_code`
|
|
21
|
+
(candidates and test-only consumer resolution), `query_graph` seeds and
|
|
22
|
+
traversal, `hot_path_review`, `god_nodes`, `verified_change` task anchors,
|
|
23
|
+
`no-tests`/`tests-only` build modes, hosted sync payloads and evidence
|
|
24
|
+
snapshots. A test-term question or `include_tests`/`include_classified` opts
|
|
25
|
+
back in. `extractorSchemaV` 5 → 6; cached graphs rebuild once, automatically.
|
|
26
|
+
|
|
27
|
+
## Production-first endpoint tools
|
|
28
|
+
|
|
29
|
+
- `list_endpoints` suppresses endpoints declared in classified
|
|
30
|
+
test/e2e/generated/mock/story/docs/benchmark/temp paths by default, with a
|
|
31
|
+
count note and `include_classified` opt-in (auto-enabled on tests-only
|
|
32
|
+
graphs). Opted-in rows carry `[classified:...]` tags.
|
|
33
|
+
- `trace_endpoint` filters classified handler twins before its ambiguity
|
|
34
|
+
check, ranks remaining same-named candidates by the import binding at the
|
|
35
|
+
route file (below same-directory proximity — a file-level edge proves
|
|
36
|
+
coupling, not identity), and reports `importBoundAtRoute`/`pathClasses` per
|
|
37
|
+
candidate when it still fails closed.
|
|
38
|
+
- New `handler_file` parameter resolves genuinely symmetric candidates: it
|
|
39
|
+
narrows the full candidate pool before scoring, prefers exact repo-relative
|
|
40
|
+
matches over path-suffix matches, and compares case-insensitively.
|
|
41
|
+
|
|
42
|
+
## Honest semantic-precision statuses
|
|
43
|
+
|
|
44
|
+
`COMPLETE` previously meant only "the bounded request finished", producing
|
|
45
|
+
`COMPLETE (0 EXACT_LSP edges)` on Rust repositories and `COMPLETE; exact
|
|
46
|
+
absence was not proven` in `get_dependents`. Now:
|
|
47
|
+
|
|
48
|
+
- A new `NONE` state covers the zero-eligible-targets outcome; `UNAVAILABLE`
|
|
49
|
+
stays reserved for provider failure. `PRECISION_OVERLAY_V` 4 → 5 discards
|
|
50
|
+
old-vocabulary sidecars.
|
|
51
|
+
- `COMPLETE` requires every selected target actually probed.
|
|
52
|
+
- `open_repo` and the build log print the `graph_stats`-style coverage
|
|
53
|
+
fraction: `PARTIAL — 32/1373 bounded target(s) queried (truncated)`.
|
|
54
|
+
- `get_dependents` never pairs a state word with a contradicting note, and
|
|
55
|
+
keeps `NONE`/`UNAVAILABLE` truthful instead of collapsing them into
|
|
56
|
+
`PARTIAL`.
|
|
57
|
+
|
|
58
|
+
## Output fidelity
|
|
59
|
+
|
|
60
|
+
- `trace_api_contract` records per-endpoint method mismatches with sample
|
|
61
|
+
callers and always shows a mismatch block, so `*_WITH_METHOD_MISMATCHES`
|
|
62
|
+
verdicts stay backed by visible rows under any `top_n`.
|
|
63
|
+
- Structure findings and hosted evidence snapshots stop reporting idiomatic
|
|
64
|
+
Rust `mod.rs`/`lib.rs`/`main.rs` parent-child module-tree cycles as
|
|
65
|
+
compile-time coupling (counted as `rustModuleTreeCouplings`; genuine
|
|
66
|
+
cross-directory cycles still report).
|
|
67
|
+
- `query_graph` edge lines and `shortest_path` hops widen repeated or generic
|
|
68
|
+
file basenames (`mod.rs`, `index.*`, `__init__.py`) to their last two path
|
|
69
|
+
segments.
|
|
70
|
+
|
|
71
|
+
## Documentation
|
|
72
|
+
|
|
73
|
+
The always-fresh graph contract — debounced Git freshness probe plus bounded
|
|
74
|
+
incremental refresh on every graph/health call, no watcher process required —
|
|
75
|
+
is now documented in the README and the agent skill, together with the Rust
|
|
76
|
+
test-surface classification rules.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Local repository intelligence MCP for AI coding agents:
|
|
5
|
+
"description": "Local repository intelligence MCP for AI coding agents: an always-fresh architecture graph with production-first evidence — blast radius, dead code, endpoints, clones, history, Health and architecture safeguards, with honestly labeled precision.",
|
|
6
6
|
"author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"homepage": "https://weavatrix.com",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"docs/releases/v0.3.7.md",
|
|
44
44
|
"docs/releases/v0.3.8.md",
|
|
45
45
|
"docs/releases/v0.3.9.md",
|
|
46
|
+
"docs/releases/v0.3.10.md",
|
|
46
47
|
"docs/releases/v0.2.19.md",
|
|
47
48
|
"README.md",
|
|
48
49
|
"SECURITY.md",
|
package/skill/SKILL.md
CHANGED
|
@@ -142,12 +142,15 @@ interface dispatch, reflection, or runtime behavior.
|
|
|
142
142
|
- **Evidence, not verdicts**: treat audit, hub, orphan and duplicate output as hypotheses. Confirm a
|
|
143
143
|
finding in source and check framework/runtime conventions before deleting, merging or redesigning
|
|
144
144
|
code. A same-name/different-body pair is a divergence candidate, not proof of duplication.
|
|
145
|
-
- **Freshness**: graph
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
`
|
|
145
|
+
- **Freshness**: the graph is always fresh at answer time — no watcher process and no manual refresh
|
|
146
|
+
step exists or is needed. Every graph/health call runs a debounced Git freshness probe and, when the
|
|
147
|
+
repository changed, refreshes before answering (bounded incremental reparse of changed files plus
|
|
148
|
+
reverse importers for JS/TS; full rebuild for config/export-surface/barrel changes and other
|
|
149
|
+
languages). Cross-repository tracing reconciles every selected registered graph the same way. Read
|
|
150
|
+
the structured `refresh` / `graphReconciliation` status: `none`, `incremental`, `full`, or
|
|
151
|
+
explicitly `PARTIAL`. Use `rebuild_graph` only when automatic reconciliation reports a
|
|
152
|
+
fallback/error or when intentionally changing build mode. A normal `open_repo` builds missing graphs
|
|
153
|
+
and upgrades legacy schemas; `build:false` deliberately refuses that upgrade.
|
|
151
154
|
- **Ambiguity**: `get_node`/`get_neighbors`/`get_dependents` disclose `matched N nodes; using the
|
|
152
155
|
best-connected` — read that note before trusting the answer; pass an exact node id to pin it.
|
|
153
156
|
- **Runtime versus compile time**: keep runtime cycles separate from TypeScript type-only and
|
|
@@ -169,6 +172,10 @@ interface dispatch, reflection, or runtime behavior.
|
|
|
169
172
|
roots. Dead-code/clone/audit review also suppresses `benchmarks/**` and `**/__temp/**` as classified
|
|
170
173
|
non-production surfaces. A verified production benchmark can opt back in narrowly through
|
|
171
174
|
`.weavatrix.json` `classify.product`, for example `{"classify":{"product":["benchmarks/core/**"]}}`.
|
|
175
|
+
Rust inline tests (`#[cfg(test)]` modules, `#[test]`/`#[bench]` items) are classified per symbol via
|
|
176
|
+
the node-level `test_surface` flag even though they live in production `.rs` files; dead-code,
|
|
177
|
+
query, hot-path and hub tools treat them like path-classified tests (`include_tests` /
|
|
178
|
+
`include_classified` and a test-term question opt back in).
|
|
172
179
|
- **Architectural queries**: broad bootstrap/tool-execution/routing questions rank conventional
|
|
173
180
|
production and graph-declared entry points ahead of classified docs, sites, benchmarks and
|
|
174
181
|
fixtures. Production-first classification also applies during traversal. A class term in the
|
|
@@ -201,7 +201,8 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
201
201
|
const deadSymbols = [];
|
|
202
202
|
for (const n of symById.values()) {
|
|
203
203
|
if (isReferenced(n)) continue;
|
|
204
|
-
|
|
204
|
+
// test_surface: extractor-proven test-only symbols (Rust #[cfg(test)]) live in production paths.
|
|
205
|
+
const test = isTestFile(n.source_file) || n.test_surface === true;
|
|
205
206
|
deadSymbols.push({ id: n.id, file: n.source_file, label: n.label, test, reason: "no inbound edge and name unreferenced outside its file" });
|
|
206
207
|
}
|
|
207
208
|
const deadSet = new Set(deadSymbols.map((s) => s.id));
|
|
@@ -209,12 +210,13 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
209
210
|
// A production declaration consumed only by tests is live to the raw graph but dead to production.
|
|
210
211
|
// Keep it in a separate review class: this is useful evidence, never an automatic delete verdict.
|
|
211
212
|
const testOnlySymbols = [];
|
|
213
|
+
const consumerFileOf = (id) => nodesById.get(id)?.source_file || (id.includes("#") ? id.split("#")[0] : id);
|
|
214
|
+
const isTestConsumer = (id) => nodesById.get(id)?.test_surface === true || isTestFile(consumerFileOf(id));
|
|
212
215
|
for (const n of symById.values()) {
|
|
213
|
-
if (deadSet.has(n.id) || isTestFile(n.source_file) || isDecorated(n)) continue;
|
|
216
|
+
if (deadSet.has(n.id) || isTestFile(n.source_file) || n.test_surface === true || isDecorated(n)) continue;
|
|
214
217
|
const sourcesForSymbol = inboundSources.get(String(n.id)) || [];
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
const hasProductionInbound = sourceFiles.some((file) => file && !isTestFile(file));
|
|
218
|
+
const hasTestInbound = sourcesForSymbol.some(isTestConsumer);
|
|
219
|
+
const hasProductionInbound = sourcesForSymbol.some((id) => consumerFileOf(id) && !isTestConsumer(id));
|
|
218
220
|
const name = bareName(n.label);
|
|
219
221
|
const occurrenceSet = occurrenceFiles.get(name) || new Set();
|
|
220
222
|
const externalOccurrences = [...occurrenceSet].filter((file) => file !== n.source_file);
|
|
@@ -232,7 +234,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
|
|
|
232
234
|
label: n.label,
|
|
233
235
|
test: false,
|
|
234
236
|
reason: "referenced only from test/e2e code; no production consumer was found",
|
|
235
|
-
testConsumerFiles: [...new Set(
|
|
237
|
+
testConsumerFiles: [...new Set(sourcesForSymbol.filter(isTestConsumer).map(consumerFileOf).filter(Boolean))].sort(),
|
|
236
238
|
evidence: hasTestInbound ? "graph" : hasExactTestInbound ? "exact-semantic" : "lexical",
|
|
237
239
|
publicApi: n.exported === true || ["public", "protected"].includes(String(n.visibility || "").toLowerCase()),
|
|
238
240
|
});
|
|
@@ -51,6 +51,9 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
|
|
|
51
51
|
const suppressed = { tests: 0, classified: 0, confidence: 0, path: 0, kind: 0 };
|
|
52
52
|
const candidates = [];
|
|
53
53
|
for (const candidate of raw) {
|
|
54
|
+
// Node-level test surfaces (Rust #[cfg(test)] symbols in production files) follow the same
|
|
55
|
+
// include_tests policy as path-classified test files.
|
|
56
|
+
if (!includeTests && nodesById.get(String(candidate.id))?.test_surface === true) { suppressed.tests += 1; continue; }
|
|
54
57
|
const info = classify(candidate.file, sources.get(candidate.file));
|
|
55
58
|
const allowed = pathAllowed(info, { includeTests, includeClassified });
|
|
56
59
|
if (!allowed.ok) { suppressed[allowed.bucket] += 1; continue; }
|
|
@@ -128,7 +128,7 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
128
128
|
if (!complexity || !file || !String(node?.id || '').includes('#')) continue
|
|
129
129
|
if (scope.path && file !== scope.path && !file.startsWith(`${scope.path}/`)) { excluded.outOfScope++; continue }
|
|
130
130
|
const classification = classifier.explain(file)
|
|
131
|
-
const test = hasPathClass(classification, 'test', 'e2e')
|
|
131
|
+
const test = node?.test_surface === true || hasPathClass(classification, 'test', 'e2e')
|
|
132
132
|
const classified = classification.excluded || hasPathClass(classification, ...NON_PRODUCT)
|
|
133
133
|
if (test && options.includeTests !== true) { excluded.tests++; continue }
|
|
134
134
|
if (classified && options.includeClassified !== true) { excluded.classified++; continue }
|
|
@@ -85,12 +85,18 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
85
85
|
for (const backend of backends) {
|
|
86
86
|
for (const endpoint of backend.endpoints) {
|
|
87
87
|
const callsites = [];
|
|
88
|
+
let endpointMismatches = 0;
|
|
89
|
+
const methodMismatchSites = [];
|
|
88
90
|
for (const client of clients) {
|
|
89
91
|
for (const call of client.calls) {
|
|
90
92
|
if (call.path && !methodMatches(endpoint.method, call.method)) {
|
|
91
93
|
const expected = pathSegments(normalizeHttpContractPath(endpoint.path));
|
|
92
94
|
const actual = pathSegments(call.path);
|
|
93
|
-
if (routeShapeMatches(expected, actual) || suffixShapeMatch(expected, actual))
|
|
95
|
+
if (routeShapeMatches(expected, actual) || suffixShapeMatch(expected, actual)) {
|
|
96
|
+
methodMismatches++;
|
|
97
|
+
endpointMismatches++;
|
|
98
|
+
if (methodMismatchSites.length < 3) methodMismatchSites.push({ clientRepo: client.id, file: call.file, line: call.line, method: call.method });
|
|
99
|
+
}
|
|
94
100
|
continue;
|
|
95
101
|
}
|
|
96
102
|
const match = matchHttpContract(endpoint, call);
|
|
@@ -124,6 +130,8 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
124
130
|
file: normalizeContractFile(endpoint.file) || null,
|
|
125
131
|
line: Number(endpoint.line) || null,
|
|
126
132
|
callsites,
|
|
133
|
+
methodMismatches: endpointMismatches,
|
|
134
|
+
methodMismatchSites,
|
|
127
135
|
liveness: externalUseLiveness(callsites, handlerEvidence),
|
|
128
136
|
affected: affectedForEndpoint(callsites, clients, limits),
|
|
129
137
|
});
|
|
@@ -63,6 +63,23 @@ function compileTimeFinding(adjacency, component, runtimeComponents, edges) {
|
|
|
63
63
|
})
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// Idiomatic Rust module trees close compile-time SCCs by construction: the parent (mod.rs, lib.rs,
|
|
67
|
+
// main.rs, or a 2018-edition foo.rs) declares `mod child` while children reach back via super::/crate::.
|
|
68
|
+
// Suppress only when every member is a .rs file sitting under one anchor's directory; genuine
|
|
69
|
+
// cross-directory .rs cycles keep their findings.
|
|
70
|
+
export function isRustModuleTreeComponent(component) {
|
|
71
|
+
if (!component.every((file) => String(file).endsWith('.rs'))) return false
|
|
72
|
+
return component.some((anchor) => {
|
|
73
|
+
const path = String(anchor)
|
|
74
|
+
const slash = path.lastIndexOf('/')
|
|
75
|
+
const base = path.slice(slash + 1)
|
|
76
|
+
const anchorDir = ['mod.rs', 'lib.rs', 'main.rs'].includes(base)
|
|
77
|
+
? (slash >= 0 ? path.slice(0, slash) : '')
|
|
78
|
+
: path.slice(0, -'.rs'.length)
|
|
79
|
+
return anchorDir !== '' && component.every((member) => member === anchor || String(member).startsWith(`${anchorDir}/`))
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(), externalImportFiles = new Set()} = {}) {
|
|
67
84
|
const imports = buildFileImportGraph(graph)
|
|
68
85
|
const findings = []
|
|
@@ -73,7 +90,9 @@ export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(
|
|
|
73
90
|
|
|
74
91
|
const runtimeKeys = new Set(runtimeComponents.map((component) => [...component].sort().join('\0')))
|
|
75
92
|
const allComponents = findSccs(imports.allAdj).sort((a, b) => b.length - a.length)
|
|
76
|
-
const
|
|
93
|
+
const compileTimeCandidates = allComponents.filter((component) => !runtimeKeys.has([...component].sort().join('\0')))
|
|
94
|
+
const rustModuleTreeComponents = compileTimeCandidates.filter((component) => isRustModuleTreeComponent(component))
|
|
95
|
+
const compileTimeCouplings = compileTimeCandidates.filter((component) => !isRustModuleTreeComponent(component))
|
|
77
96
|
for (const component of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
|
|
78
97
|
findings.push(compileTimeFinding(imports.allAdj, component, runtimeComponents, {
|
|
79
98
|
runtime: imports.edges,
|
|
@@ -135,6 +154,7 @@ export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(
|
|
|
135
154
|
largestTypeCoupling: compileTimeCouplings[0]?.length || 0,
|
|
136
155
|
compileTimeCouplings: compileTimeCouplings.length,
|
|
137
156
|
largestCompileTimeCoupling: compileTimeCouplings[0]?.length || 0,
|
|
157
|
+
rustModuleTreeCouplings: rustModuleTreeComponents.length,
|
|
138
158
|
orphans: findings.filter((finding) => finding.rule === 'orphan-file').length,
|
|
139
159
|
boundaryViolations: violations.length,
|
|
140
160
|
},
|
|
@@ -64,6 +64,8 @@ export function retrieveTaskContext(g, {
|
|
|
64
64
|
const pathAllowed = (node) => {
|
|
65
65
|
const file = fileOf(node)
|
|
66
66
|
if (!file || changedFiles.has(file) || includeClassified === true) return true
|
|
67
|
+
// Node-level test surfaces (Rust #[cfg(test)]) follow the same production-first policy.
|
|
68
|
+
if (node?.test_surface === true && !requestedClasses.has('test')) return false
|
|
67
69
|
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
68
70
|
const info = classificationCache.get(file)
|
|
69
71
|
const classes = PATH_CLASS_NAMES.filter((name) => hasPathClass(info, name))
|
package/src/build-graph.js
CHANGED
|
@@ -31,6 +31,14 @@ export function defaultPrecisionMode(env = process.env) {
|
|
|
31
31
|
return env?.WEAVATRIX_PRECISION === "off" ? "off" : "lsp";
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// One-line human status for a precision summary (precisionSummary shape) — the graph_stats
|
|
35
|
+
// composition, shared by the build log and open_repo so every surface reads the same fraction.
|
|
36
|
+
export function precisionStatusLine(precision) {
|
|
37
|
+
const p = precision || {};
|
|
38
|
+
if (p.state === "NONE") return `Semantic precision: NONE — ${p.reason || "no eligible JavaScript/TypeScript semantic targets"}`;
|
|
39
|
+
return `Semantic precision: ${p.state || "UNAVAILABLE"} — ${p.queried || 0}/${p.candidates || 0} bounded target(s) queried${p.truncated ? " (truncated)" : ""}; ${p.verifiedEdges || 0} EXACT_LSP edge(s)${p.state !== "COMPLETE" && p.reason ? `; ${p.reason}` : ""}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
34
42
|
function buildGraphInWorker(payload) {
|
|
35
43
|
return new Promise((resolve, reject) => {
|
|
36
44
|
let worker;
|
|
@@ -193,7 +201,7 @@ export async function buildGraphForRepo(repoPath, {
|
|
|
193
201
|
hotspots: built.hotspots,
|
|
194
202
|
refresh: built.refresh,
|
|
195
203
|
precision: built.precision,
|
|
196
|
-
log: `built-in builder: ${built.nodes} nodes, ${built.links} static links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"});
|
|
204
|
+
log: `built-in builder: ${built.nodes} nodes, ${built.links} static links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"}); ${precisionStatusLine(built.precision)}`
|
|
197
205
|
};
|
|
198
206
|
} catch (error) {
|
|
199
207
|
return { ok: false, error: `graph build failed: ${error.message}`, builder: "internal" };
|
|
@@ -59,6 +59,37 @@ function pathAttribute(modNode) {
|
|
|
59
59
|
return "";
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Test-only classification must mean "compiled exclusively under `cargo test`": #[cfg(test)] and
|
|
63
|
+
// #[cfg(all(test, ...))] guarantee that; #[cfg(any(test, ...))] also compiles into production builds
|
|
64
|
+
// and deliberately stays unclassified. #[test]/#[bench] (incl. #[tokio::test]-style harness paths)
|
|
65
|
+
// mark the annotated function itself.
|
|
66
|
+
const CFG_TEST_RE = /#\s*!?\s*\[\s*cfg\s*\(\s*(?:test\s*\)|all\s*\(\s*test\b)/;
|
|
67
|
+
const TEST_FN_ATTR_RE = /#\s*\[\s*(?:[\w]+(?:\s*::\s*[\w]+)*\s*::\s*)?(?:test|bench)\s*(?:\]|\()/;
|
|
68
|
+
const CFG_TEST_CARRIERS = new Set(["mod_item", "impl_item", "trait_item", "function_item"]);
|
|
69
|
+
const COMMENT_TYPES = new Set(["line_comment", "block_comment"]);
|
|
70
|
+
const itemAttributeText = (item) => {
|
|
71
|
+
// Comments are NAMED siblings in tree-sitter-rust — `#[test]` + `// note` + `fn` must still classify.
|
|
72
|
+
let text = "";
|
|
73
|
+
for (let prev = item?.previousNamedSibling; prev; prev = prev.previousNamedSibling) {
|
|
74
|
+
if (COMMENT_TYPES.has(prev.type)) continue;
|
|
75
|
+
if (prev.type !== "attribute_item") break;
|
|
76
|
+
text += prev.text + "\n";
|
|
77
|
+
}
|
|
78
|
+
return text;
|
|
79
|
+
};
|
|
80
|
+
// `#![cfg(test)]` at the top of a file or module body gates everything below it. tree-sitter-rust
|
|
81
|
+
// tokenizes a file-leading `#!...` as `shebang`; a real shebang line never matches CFG_TEST_RE.
|
|
82
|
+
const innerCfgTest = (body) => (body?.namedChildren || [])
|
|
83
|
+
.some((child) => (child.type === "inner_attribute_item" || child.type === "shebang") && CFG_TEST_RE.test(child.text));
|
|
84
|
+
const underCfgTest = (node) => {
|
|
85
|
+
for (let parent = node?.parent; parent; parent = parent.parent) {
|
|
86
|
+
if (parent.type === "declaration_list" && innerCfgTest(parent)) return true;
|
|
87
|
+
if (CFG_TEST_CARRIERS.has(parent.type) && CFG_TEST_RE.test(itemAttributeText(parent))) return true;
|
|
88
|
+
if (!parent.parent && innerCfgTest(parent)) return true; // source_file root
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
};
|
|
92
|
+
|
|
62
93
|
function inlineAncestors(node) {
|
|
63
94
|
const result = [];
|
|
64
95
|
for (let p = node?.parent; p; p = p.parent) {
|
|
@@ -129,10 +160,15 @@ export default {
|
|
|
129
160
|
const memberOf = ownerName(owner, field);
|
|
130
161
|
const symbolKind = cap.name === "function" ? (memberOf ? "method" : "function") : cap.name;
|
|
131
162
|
const visibility = publicVisibility(declaration, owner);
|
|
163
|
+
const attrs = itemAttributeText(declaration);
|
|
164
|
+
const testSurface = CFG_TEST_RE.test(attrs)
|
|
165
|
+
|| (cap.name === "function" && TEST_FN_ATTR_RE.test(attrs))
|
|
166
|
+
|| underCfgTest(declaration);
|
|
132
167
|
const id = addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "function", {
|
|
133
168
|
sourceNode: declaration,
|
|
134
169
|
selectionNode: cap.node,
|
|
135
170
|
symbolKind,
|
|
171
|
+
...(testSurface ? { testSurface: true } : {}),
|
|
136
172
|
...(memberOf ? { memberOf, visibility } : { moduleDeclaration: true }),
|
|
137
173
|
...(!memberOf && visibility === "public" ? { exported: true } : {}),
|
|
138
174
|
});
|
|
@@ -27,7 +27,7 @@ const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
|
27
27
|
barrelResolutionV: 1,
|
|
28
28
|
reExportOccurrencesV: 1,
|
|
29
29
|
symbolSpacesV: 1,
|
|
30
|
-
extractorSchemaV:
|
|
30
|
+
extractorSchemaV: 6,
|
|
31
31
|
})
|
|
32
32
|
const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
|
|
33
33
|
const MAX_CONTROL_BYTES = 1_000_000
|
|
@@ -22,12 +22,14 @@ export function filterGraphForMode(graph, mode, { repoRoot = null } = {}) {
|
|
|
22
22
|
const links = graph.links || [];
|
|
23
23
|
const endpoint = (value) => (value && typeof value === "object" ? value.id : value);
|
|
24
24
|
const classifier = createPathClassifier(repoRoot);
|
|
25
|
-
|
|
25
|
+
// A node is a test surface when its file is test-classified OR the extractor proved the symbol
|
|
26
|
+
// itself is compiled only under test (Rust #[cfg(test)] inline modules live in production files).
|
|
27
|
+
const isTest = (node) => node.test_surface === true || hasPathClass(classifier.explain(node.source_file), "test", "e2e");
|
|
26
28
|
let keep;
|
|
27
29
|
if (mode === "no-tests") {
|
|
28
|
-
keep = new Set(nodes.filter((node) => !isTest(node
|
|
30
|
+
keep = new Set(nodes.filter((node) => !isTest(node)).map((node) => node.id));
|
|
29
31
|
} else {
|
|
30
|
-
const testIds = new Set(nodes.filter((node) => isTest(node
|
|
32
|
+
const testIds = new Set(nodes.filter((node) => isTest(node)).map((node) => node.id));
|
|
31
33
|
keep = new Set(testIds);
|
|
32
34
|
for (const link of links) {
|
|
33
35
|
if (testIds.has(endpoint(link.source))) keep.add(endpoint(link.target)); // a test's dependency
|
|
@@ -184,7 +184,7 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
|
|
|
184
184
|
|
|
185
185
|
if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
|
|
186
186
|
|| !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
|
|
187
|
-
|| Number(existingGraph.extractorSchemaV) <
|
|
187
|
+
|| Number(existingGraph.extractorSchemaV) < 6 || Number(existingGraph.physicalFileLocV) < 1
|
|
188
188
|
|| Number(existingGraph.reExportOccurrencesV) < 1
|
|
189
189
|
|| Number(existingGraph.symbolSpacesV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
|
|
190
190
|
return full("incremental-baseline-unavailable");
|
|
@@ -148,6 +148,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
148
148
|
} : {}),
|
|
149
149
|
...(complexity ? { complexity } : {}),
|
|
150
150
|
...(extra && extra.exported ? { exported: true } : {}),
|
|
151
|
+
...(extra && extra.testSurface ? { test_surface: true } : {}),
|
|
151
152
|
...(extra && extra.decorated ? { decorated: true } : {}),
|
|
152
153
|
...(extra && extra.symbolKind ? { symbol_kind: extra.symbolKind } : {}),
|
|
153
154
|
...(extra && extra.symbolSpace ? { symbol_space: extra.symbolSpace } : {}),
|
|
@@ -265,7 +266,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
265
266
|
barrelResolutionV: 1,
|
|
266
267
|
reExportOccurrencesV: 1,
|
|
267
268
|
symbolSpacesV: 1,
|
|
268
|
-
extractorSchemaV:
|
|
269
|
+
extractorSchemaV: 6,
|
|
269
270
|
reExportOccurrences,
|
|
270
271
|
jsExportRecords: Object.fromEntries([...jsExports.entries()].sort(([a], [b]) => a.localeCompare(b))),
|
|
271
272
|
fileHashes: snapshot.fileHashes,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {existsSync, readFileSync, realpathSync, statSync, writeFileSync} from 'node:fs'
|
|
2
2
|
import {dirname, isAbsolute, join} from 'node:path'
|
|
3
3
|
import {diffGraphs, formatGraphDiff, prevGraphPathFor} from '../graph-context.mjs'
|
|
4
|
-
import {buildGraphForRepo, defaultPrecisionMode} from '../../build-graph.js'
|
|
4
|
+
import {buildGraphForRepo, defaultPrecisionMode, precisionStatusLine} from '../../build-graph.js'
|
|
5
5
|
import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../../graph/layout.js'
|
|
6
6
|
import {liveRepositoryRecords, registerRepository} from '../../graph/repo-registry.js'
|
|
7
7
|
import {precisionSemanticInputsMatch, readPrecisionOverlay} from '../../precision/lsp-overlay.js'
|
|
@@ -76,7 +76,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
76
76
|
savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
|
|
77
77
|
schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
|
|
78
78
|
|| !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
|
|
79
|
-
|| !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV <
|
|
79
|
+
|| !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 6
|
|
80
80
|
|| !Number.isInteger(saved.reExportOccurrencesV) || saved.reExportOccurrencesV < 1
|
|
81
81
|
|| !Number.isInteger(saved.symbolSpacesV) || saved.symbolSpacesV < 1
|
|
82
82
|
|| !Number.isInteger(saved.physicalFileLocV) || saved.physicalFileLocV < 1
|
|
@@ -128,7 +128,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
128
128
|
`Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`,
|
|
129
129
|
`Graph: ${graphPath}`,
|
|
130
130
|
`Build mode: ${loaded.graphBuildMode || 'full'}`,
|
|
131
|
-
|
|
131
|
+
precisionStatusLine(loaded.precision),
|
|
132
132
|
].join('\n')
|
|
133
133
|
}
|
|
134
134
|
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -128,8 +128,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
128
128
|
{cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
|
|
129
129
|
{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)},
|
|
130
130
|
{cap: 'graph', name: 'module_map', description: 'First orientation view for understanding an unfamiliar application with little context: a 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)},
|
|
131
|
-
{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): declared and reachable composed paths, static mount provenance, confidence, handler, file:line, and Spring conditional/default-active state.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
132
|
-
{cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
|
|
131
|
+
{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): declared and reachable composed paths, static mount provenance, confidence, handler, file:line, and Spring conditional/default-active state.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
132
|
+
{cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, handler_file: {type: 'string', maxLength: 1024, description: 'Repo-relative file path (or unambiguous path suffix) declaring the handler; use after an AMBIGUOUS_HANDLER result.'}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
|
|
133
133
|
{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)},
|
|
134
134
|
{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)},
|
|
135
135
|
{cap: 'graph', name: 'get_architecture_contract', description: 'Read the owner-approved architecture target or safely bootstrap one. With action=preview, returns an adaptive candidate, observed-but-not-enforced dependency directions, verification, exact file content/hash and a short-lived confirmation token. action=approve creates the local contract only after explicit token confirmation and never overwrites an active target.', inputSchema: {type: 'object', properties: {action: {type: 'string', enum: ['preview', 'approve'], description: 'Omit to read; preview is dry-run only; approve requires the preview token'}, candidate_contract: {type: 'object', description: 'Optional reviewed candidate to normalize and verify during preview'}, baseline_mode: {type: 'string', enum: ['none', 'accept-current'], default: 'none', description: 'Whether preview should materialize current violations as an explicit ratchet baseline'}, confirm_token: {type: 'string', description: 'One-time token returned by preview; required for approve'}}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {createHash} from 'node:crypto'
|
|
2
2
|
import {readFileSync} from 'node:fs'
|
|
3
|
-
import {buildFileImportGraph, checkBoundaries, findSccs, representativeCycle} from '../analysis/dep-rules.js'
|
|
3
|
+
import {buildFileImportGraph, checkBoundaries, findSccs, isRustModuleTreeComponent, representativeCycle} from '../analysis/dep-rules.js'
|
|
4
4
|
import {createRepoBoundary} from '../repo-path.js'
|
|
5
5
|
import {CAPS, bounded, compareText, repoRelativePath, safeToken} from './evidence-snapshot.common.mjs'
|
|
6
6
|
|
|
@@ -59,7 +59,10 @@ export function buildStructureEvidence(graph, repoRoot) {
|
|
|
59
59
|
const imports = buildFileImportGraph(graph)
|
|
60
60
|
const runtimeSccs = sortedSccs(imports.runtimeAdj)
|
|
61
61
|
const runtimeKeys = new Set(runtimeSccs.map((members) => members.join('\0')))
|
|
62
|
-
|
|
62
|
+
// Same suppression as computeStructureFindings: a synced snapshot must not contradict the
|
|
63
|
+
// local structure findings on idiomatic Rust module trees.
|
|
64
|
+
const compileSccs = sortedSccs(imports.allAdj)
|
|
65
|
+
.filter((members) => !runtimeKeys.has(members.join('\0')) && !isRustModuleTreeComponent(members))
|
|
63
66
|
const allCycles = [
|
|
64
67
|
...runtimeSccs.map((members) => cycleFact('runtime', imports.runtimeAdj, members)),
|
|
65
68
|
...compileSccs.map((members) => cycleFact('compile-time', imports.allAdj, members)),
|
|
@@ -52,6 +52,7 @@ export function requestedPathClasses(query) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function isQueryEligible(node, requestedClasses, classificationCache, classifier) {
|
|
55
|
+
if (node?.test_surface === true && !requestedClasses.has('test')) return false
|
|
55
56
|
const source = sourceFileOf(node)
|
|
56
57
|
if (!source) return true
|
|
57
58
|
if (!classificationCache.has(source)) classificationCache.set(source, classifier.explain(source, {content: ''}))
|
|
@@ -6,6 +6,7 @@ import {createPathClassifier, hasPathClass} from '../../path-classification.js'
|
|
|
6
6
|
|
|
7
7
|
const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
8
8
|
const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
|
|
9
|
+
const AMBIGUOUS_FILE_BASENAME_RE = /^(mod|lib|main)\.rs$|^index\.[a-z0-9]+$|^__init__\.py$/
|
|
9
10
|
const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
|
|
10
11
|
const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
|
|
11
12
|
const exactSymbolName = (node) => {
|
|
@@ -34,6 +35,23 @@ function resolveExactSeedSymbols(g, requested, limit = 12) {
|
|
|
34
35
|
return {seeds, missing, ambiguous}
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
// Edge/hop lines print bare labels without the [id] suffix node lines carry, so repeated or
|
|
39
|
+
// conventionally-generic file basenames (mod.rs, index.ts, __init__.py) become indistinguishable —
|
|
40
|
+
// widen those, scoped to the shown ids, to the last two path segments.
|
|
41
|
+
const makeEdgeLabel = (g, shownIds) => {
|
|
42
|
+
const counts = new Map()
|
|
43
|
+
for (const id of shownIds) {
|
|
44
|
+
const label = labelOf(g, id)
|
|
45
|
+
counts.set(label, (counts.get(label) || 0) + 1)
|
|
46
|
+
}
|
|
47
|
+
return (id) => {
|
|
48
|
+
const label = labelOf(g, id)
|
|
49
|
+
const isFile = String(id).includes('/') && !String(id).includes('#')
|
|
50
|
+
if (isFile && ((counts.get(label) || 0) > 1 || AMBIGUOUS_FILE_BASENAME_RE.test(label))) return String(id).split('/').slice(-2).join('/')
|
|
51
|
+
return label
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
37
55
|
const relationSet = (relationFilter, legacyFilter) => {
|
|
38
56
|
const raw = relationFilter ?? legacyFilter
|
|
39
57
|
const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
|
|
@@ -89,6 +107,12 @@ export function tQueryGraph(g, {
|
|
|
89
107
|
const node = g.byId.get(String(id))
|
|
90
108
|
const file = querySourceFile(node)
|
|
91
109
|
if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
|
|
110
|
+
// Extractor-proven test symbols (Rust #[cfg(test)]) live in production files; suppress them
|
|
111
|
+
// under the same policy as path-classified tests unless the question asks about tests.
|
|
112
|
+
if (node?.test_surface === true && !requestedClasses.has('test')) {
|
|
113
|
+
classifiedSuppressed.add(String(id))
|
|
114
|
+
return {ok: false, bucket: 'classified'}
|
|
115
|
+
}
|
|
92
116
|
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
93
117
|
const info = classificationCache.get(file)
|
|
94
118
|
const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
|
|
@@ -180,7 +204,8 @@ export function tQueryGraph(g, {
|
|
|
180
204
|
'Nodes:',
|
|
181
205
|
]
|
|
182
206
|
const nodeLines = shown.map((node) => ` [d${node.distance}] ${labelOf(g, node.id)} (deg ${node.degree}) [${node.id}]`)
|
|
183
|
-
const
|
|
207
|
+
const edgeLabel = makeEdgeLabel(g, shownIds)
|
|
208
|
+
const edgeLines = ['', 'Edges:', ...shownEdges.map(([source, relation, target]) => ` ${edgeLabel(source)} --${relation || 'rel'}--> ${edgeLabel(target)}`)]
|
|
184
209
|
let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
|
|
185
210
|
if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
|
|
186
211
|
return text
|
|
@@ -218,6 +243,7 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
|
|
|
218
243
|
if (!previous.has(targetId)) return `No path found between "${sourceNode.label ?? sourceId}" and "${targetNode.label ?? targetId}" within ${limit} hops.`
|
|
219
244
|
const path = []
|
|
220
245
|
for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
|
|
221
|
-
const
|
|
246
|
+
const edgeLabel = makeEdgeLabel(g, path)
|
|
247
|
+
const lines = path.map((id, index) => index === 0 ? ` ${edgeLabel(id)}` : ` --${relationTo.get(id) || 'rel'}--> ${edgeLabel(id)}`)
|
|
222
248
|
return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
|
|
223
249
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {rawGraph} from '../graph-context.mjs'
|
|
2
2
|
import {analyzeEndpointInventory} from '../../analysis/endpoints.js'
|
|
3
|
+
import {PATH_CLASS_NAMES, createPathClassifier, hasPathClass} from '../../path-classification.js'
|
|
3
4
|
import {toolResult} from '../tool-result.mjs'
|
|
4
5
|
|
|
5
6
|
export function tListEndpoints(g, args, ctx) {
|
|
@@ -16,12 +17,34 @@ export function tListEndpoints(g, args, ctx) {
|
|
|
16
17
|
const path = args.path ? String(args.path) : null
|
|
17
18
|
if (method) eps = eps.filter((endpoint) => endpoint.method === method)
|
|
18
19
|
if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
|
|
19
|
-
|
|
20
|
+
// Production-first: classified endpoints are suppressed by default; a tests-only graph would
|
|
21
|
+
// suppress everything, so that build mode auto-includes them (same precedent as module_map).
|
|
22
|
+
const includeClassified = args.include_classified === true || graph.graphBuildMode === 'tests-only'
|
|
23
|
+
const classifier = createPathClassifier(ctx.repoRoot)
|
|
24
|
+
const byFile = new Map()
|
|
25
|
+
const classesOf = (file) => {
|
|
26
|
+
const key = String(file || '')
|
|
27
|
+
if (!byFile.has(key)) {
|
|
28
|
+
const info = classifier.explain(key, {content: ''})
|
|
29
|
+
byFile.set(key, {classified: info.excluded || hasPathClass(info, ...PATH_CLASS_NAMES), pathClasses: info.classes})
|
|
30
|
+
}
|
|
31
|
+
return byFile.get(key)
|
|
32
|
+
}
|
|
33
|
+
const production = eps.filter((endpoint) => !classesOf(endpoint.file).classified)
|
|
34
|
+
const classified = eps.filter((endpoint) => classesOf(endpoint.file).classified)
|
|
35
|
+
const suppressed = includeClassified ? 0 : classified.length
|
|
36
|
+
eps = includeClassified ? [...production, ...classified] : production
|
|
37
|
+
const suppressionNote = suppressed
|
|
38
|
+
? `${suppressed} endpoint(s) in classified test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded paths were suppressed; pass include_classified:true to inspect them.`
|
|
39
|
+
: ''
|
|
40
|
+
if (!eps.length) return suppressed
|
|
41
|
+
? `No production HTTP endpoints detected; ${suppressionNote}`
|
|
42
|
+
: 'No HTTP endpoints detected in the indexed code files.'
|
|
20
43
|
const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
|
|
21
44
|
const shown = eps.slice(0, max)
|
|
22
45
|
const stats = inventory.stats
|
|
23
46
|
const text = [
|
|
24
|
-
`${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}
|
|
47
|
+
`${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.${suppressed ? ` ${suppressionNote}` : ''}`,
|
|
25
48
|
...shown.map((e) => {
|
|
26
49
|
const via = e.mountChain?.length
|
|
27
50
|
? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
|
|
@@ -29,14 +52,20 @@ export function tListEndpoints(g, args, ctx) {
|
|
|
29
52
|
const activation = e.conditional
|
|
30
53
|
? `; conditional default ${e.defaultActive === false ? 'inactive' : e.defaultActive === true ? 'active' : 'unknown'}`
|
|
31
54
|
: ''
|
|
32
|
-
|
|
55
|
+
const info = classesOf(e.file)
|
|
56
|
+
const tag = info.classified ? ` [classified${info.pathClasses.length ? `:${info.pathClasses.join('+')}` : ''}]` : ''
|
|
57
|
+
return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence}${activation})${tag}${via}`
|
|
33
58
|
}),
|
|
34
59
|
].join('\n')
|
|
35
60
|
return toolResult(text, {
|
|
36
61
|
filters: {method, path},
|
|
37
62
|
stats,
|
|
38
|
-
|
|
63
|
+
pathPolicy: includeClassified ? 'all' : 'production-first',
|
|
64
|
+
suppressed,
|
|
65
|
+
endpoints: shown.map((e) => {
|
|
66
|
+
const info = classesOf(e.file)
|
|
67
|
+
return info.classified ? {...e, classified: true, pathClasses: info.pathClasses} : e
|
|
68
|
+
}),
|
|
39
69
|
page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
|
|
40
70
|
}, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
|
|
41
71
|
}
|
|
42
|
-
|
|
@@ -19,6 +19,7 @@ export function sanitizeNode(value) {
|
|
|
19
19
|
if (community !== undefined && Number.isInteger(community) && community >= 0) out.community = community;
|
|
20
20
|
if (typeof value.exported === 'boolean') out.exported = value.exported;
|
|
21
21
|
if (typeof value.decorated === 'boolean') out.decorated = value.decorated;
|
|
22
|
+
if (value.test_surface === true) out.test_surface = true;
|
|
22
23
|
setIf(out, 'complexity', sanitizeComplexity(value.complexity));
|
|
23
24
|
return out;
|
|
24
25
|
}
|
|
@@ -231,6 +231,15 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
231
231
|
...screens,
|
|
232
232
|
]
|
|
233
233
|
}),
|
|
234
|
+
// Mismatch-only endpoints rarely rank into top_n (zero callsites), yet they drive the
|
|
235
|
+
// *_METHOD_MISMATCH verdicts — always surface them, citing the verdict's own total.
|
|
236
|
+
...(analysis.totals.methodMismatches > 0 ? [
|
|
237
|
+
` Method mismatches (${verdict.methodMismatches} call(s)):`,
|
|
238
|
+
...analysis.endpoints.filter((endpoint) => endpoint.methodMismatches > 0).slice(0, 5).flatMap((endpoint) => [
|
|
239
|
+
` ${endpoint.method} ${endpoint.path} — ${endpoint.methodMismatches} call(s) use a different method`,
|
|
240
|
+
...(endpoint.methodMismatchSites || []).slice(0, 2).map((site) => ` caller ${site.clientRepo}:${site.file}:${site.line} uses ${site.method}`),
|
|
241
|
+
]),
|
|
242
|
+
] : []),
|
|
234
243
|
...transportAnalysis.contracts.filter((contract) => contract.callsites.length).slice(0, topN).map((contract) =>
|
|
235
244
|
` ${contract.transport.toUpperCase()} ${contract.service ? `${contract.service}.` : contract.operation ? `${contract.operation} ` : ''}${contract.name} (${contract.file}:${contract.line}) [${contract.liveness}] → ${contract.callsites.length} callsite(s), ${contract.affected.files.length} affected file(s)`),
|
|
236
245
|
completeness.complete
|
|
@@ -29,17 +29,47 @@ function endpointCandidates(inventory, args) {
|
|
|
29
29
|
return exact.length ? exact : eligible.filter((endpoint) => endpoint.path.endsWith(path))
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// A handler_file hint narrows the FULL same-named pool before any scoring, so it can rescue a
|
|
33
|
+
// candidate the ranking would otherwise collapse away. An exact repo-relative match beats suffix
|
|
34
|
+
// matches; comparison is case-insensitive (Windows paths and human-typed hints disagree on casing).
|
|
35
|
+
function matchesHandlerHint(file, hint) {
|
|
36
|
+
if (!hint) return true
|
|
37
|
+
const lower = file.toLowerCase()
|
|
38
|
+
return lower === hint || lower.endsWith(`/${hint}`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function handlerCandidates(graph, endpoint, hint = '') {
|
|
33
42
|
if (!endpoint.handler) return []
|
|
34
43
|
const wanted = endpoint.handler.toLowerCase()
|
|
35
44
|
const qualifier = String(endpoint.handlerRef || '').split('.').slice(-2, -1)[0]?.toLowerCase() || ''
|
|
36
45
|
const routeDir = posix.dirname(endpoint.file)
|
|
37
|
-
|
|
46
|
+
// File-level import/re-export bindings at the route file break same-name ties between otherwise
|
|
47
|
+
// unranked candidates. The bonus deliberately sits BELOW same-directory proximity (+40): a
|
|
48
|
+
// file-level edge proves coupling, not symbol identity, and must never outrank the conventional
|
|
49
|
+
// same-package/same-dir handler (Go same-package files have no import edge at all).
|
|
50
|
+
const importBound = new Set()
|
|
51
|
+
for (const edge of graph.out.get(endpoint.file) || []) {
|
|
52
|
+
if (edge.relation !== 'imports' && edge.relation !== 're_exports') continue
|
|
53
|
+
if (edge.typeOnly === true || edge.barrelProxy === true) continue
|
|
54
|
+
const targetId = String(edge.id)
|
|
55
|
+
if (targetId.includes('#')) continue
|
|
56
|
+
const target = graph.byId.get(targetId)
|
|
57
|
+
importBound.add(String(target?.source_file || targetId).replace(/\\/g, '/'))
|
|
58
|
+
}
|
|
59
|
+
let pool = (graph.nodes || [])
|
|
38
60
|
.filter((node) => String(node.id).includes('#') && node.source_file && symbolName(node).toLowerCase() === wanted)
|
|
61
|
+
if (hint) {
|
|
62
|
+
pool = pool.filter((node) => matchesHandlerHint(String(node.source_file).replace(/\\/g, '/').toLowerCase(), hint))
|
|
63
|
+
const exact = pool.filter((node) => String(node.source_file).replace(/\\/g, '/').toLowerCase() === hint)
|
|
64
|
+
if (exact.length) pool = exact
|
|
65
|
+
}
|
|
66
|
+
const scored = pool
|
|
39
67
|
.map((node) => {
|
|
40
68
|
const file = String(node.source_file).replace(/\\/g, '/')
|
|
41
69
|
let score = 0
|
|
70
|
+
const importBoundAtRoute = importBound.has(file)
|
|
42
71
|
if (file === endpoint.file) score += 100
|
|
72
|
+
if (importBoundAtRoute) score += 35
|
|
43
73
|
if (posix.dirname(file) === routeDir) score += 40
|
|
44
74
|
if (file.startsWith(`${routeDir}/`)) score += 15
|
|
45
75
|
if (qualifier) {
|
|
@@ -48,16 +78,15 @@ function handlerCandidates(graph, endpoint) {
|
|
|
48
78
|
}
|
|
49
79
|
const line = symbolLine(node)
|
|
50
80
|
if (file === endpoint.file && line >= endpoint.line && line - endpoint.line <= 250) score += 20
|
|
51
|
-
return {node, score}
|
|
81
|
+
return {node, score, importBoundAtRoute}
|
|
52
82
|
})
|
|
53
83
|
.sort((a, b) => b.score - a.score || String(a.node.id).localeCompare(String(b.node.id)))
|
|
54
84
|
if (!scored.length) return []
|
|
55
85
|
const best = scored[0].score
|
|
56
|
-
return scored.filter((item) => item.score === best).map((
|
|
86
|
+
return scored.filter((item) => item.score === best).map(({node, importBoundAtRoute}) => ({node, importBoundAtRoute}))
|
|
57
87
|
}
|
|
58
88
|
|
|
59
|
-
function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified,
|
|
60
|
-
const classifier = createPathClassifier(repoRoot)
|
|
89
|
+
function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, classifier}) {
|
|
61
90
|
const allowed = (node) => {
|
|
62
91
|
if (!node?.source_file) return false
|
|
63
92
|
if (includeClassified) return true
|
|
@@ -125,12 +154,25 @@ export function tTraceEndpoint(graph, args, ctx) {
|
|
|
125
154
|
completeness: {status: 'PARTIAL', reason: 'ambiguous endpoint'},
|
|
126
155
|
})
|
|
127
156
|
const endpoint = candidates[0]
|
|
128
|
-
const
|
|
157
|
+
const classifier = createPathClassifier(ctx.repoRoot)
|
|
158
|
+
const classify = (file) => {
|
|
159
|
+
const info = classifier.explain(file, {content: ''})
|
|
160
|
+
return {classified: info.excluded || NON_PRODUCT.some((name) => hasPathClass(info, name)), pathClasses: info.classes}
|
|
161
|
+
}
|
|
162
|
+
const hint = String(args.handler_file || '').trim().replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
|
|
163
|
+
let handlers = handlerCandidates(graph, endpoint, hint)
|
|
164
|
+
.map((item) => ({...item, ...classify(String(item.node.source_file).replace(/\\/g, '/'))}))
|
|
165
|
+
// Production-first: a classified twin (tests/mocks/…) never ties with a production candidate,
|
|
166
|
+
// but an all-classified candidate set is kept rather than emptied.
|
|
167
|
+
if (args.include_classified !== true && handlers.some((item) => !item.classified)) {
|
|
168
|
+
handlers = handlers.filter((item) => !item.classified)
|
|
169
|
+
}
|
|
129
170
|
if (handlers.length !== 1) return toolResult([
|
|
130
|
-
`Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' :
|
|
171
|
+
`Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : `not present in the graph${hint ? ` (no candidate matched handler_file "${hint}")` : ''}`}:`,
|
|
131
172
|
` ${endpointLine(endpoint)}`,
|
|
132
|
-
...handlers.slice(0, 12).map((node) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}) [${node.id}]`),
|
|
133
|
-
|
|
173
|
+
...handlers.slice(0, 12).map(({node, importBoundAtRoute, pathClasses}) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}; import-bound at route: ${importBoundAtRoute ? 'yes' : 'no'}${pathClasses.length ? `; classified:${pathClasses.join('+')}` : ''}) [${node.id}]`),
|
|
174
|
+
...(handlers.length > 1 ? [' Next: pass handler_file with the repo-relative path (or an unambiguous path suffix) of the file that declares the intended handler.'] : []),
|
|
175
|
+
].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers: handlers.map(({node, importBoundAtRoute, pathClasses}) => ({...node, importBoundAtRoute, pathClasses}))}, {
|
|
134
176
|
completeness: {status: 'PARTIAL', reason: handlers.length ? 'ambiguous handler symbol' : 'handler symbol not found'},
|
|
135
177
|
})
|
|
136
178
|
|
|
@@ -138,9 +180,9 @@ export function tTraceEndpoint(graph, args, ctx) {
|
|
|
138
180
|
const maxNodes = Math.max(2, Math.min(40, Number(args.max_nodes) || 20))
|
|
139
181
|
const contextLines = Math.max(0, Math.min(6, Number(args.context_lines) || 2))
|
|
140
182
|
const maxExcerpts = Math.max(0, Math.min(12, Number(args.max_excerpts) || 6))
|
|
141
|
-
const handler = handlers[0]
|
|
183
|
+
const handler = handlers[0].node
|
|
142
184
|
const traced = traceCalls(graph, handler, {
|
|
143
|
-
maxDepth, maxNodes, includeClassified: args.include_classified === true,
|
|
185
|
+
maxDepth, maxNodes, includeClassified: args.include_classified === true, classifier,
|
|
144
186
|
})
|
|
145
187
|
const excerpts = traced.edges.slice(0, maxExcerpts).map((edge) => {
|
|
146
188
|
const source = graph.byId.get(edge.from)
|
|
@@ -12,6 +12,7 @@ export function tGodNodes(g, {top_n = 10, include_classified = false} = {}, ctx
|
|
|
12
12
|
const classificationByFile = new Map()
|
|
13
13
|
const isNonProduct = (node) => {
|
|
14
14
|
if (include_classified === true) return false
|
|
15
|
+
if (node?.test_surface === true) return true
|
|
15
16
|
const file = sourceFileOf(node)
|
|
16
17
|
if (!file) return false
|
|
17
18
|
if (!classificationByFile.has(file)) classificationByFile.set(file, classifier.explain(file))
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -87,10 +87,18 @@ export function tGetDependents(g, args = {}, ctx = {}) {
|
|
|
87
87
|
`Semantic precision: EXACT_LSP point query${result.cached ? ' (cache hit)' : ''}; ${count} classified direct reference edge(s).`,
|
|
88
88
|
)
|
|
89
89
|
}
|
|
90
|
+
// Never echo COMPLETE here ('COMPLETE; exact absence was not proven' is a contradiction),
|
|
91
|
+
// but keep NONE/UNAVAILABLE truthful — PARTIAL implies probing actually began.
|
|
92
|
+
const fallbackState = result.overlay?.state === 'NONE' ? 'NONE'
|
|
93
|
+
: result.overlay?.state === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'PARTIAL'
|
|
90
94
|
return getDependentsFromGraph(
|
|
91
95
|
g,
|
|
92
96
|
args,
|
|
93
|
-
|
|
97
|
+
fallbackState === 'NONE'
|
|
98
|
+
? `Semantic precision: NONE; exact references are not available for this language/target, so graph edges are retained (${result.overlay?.reason || 'no eligible JavaScript/TypeScript semantic targets'}).`
|
|
99
|
+
: fallbackState === 'UNAVAILABLE'
|
|
100
|
+
? `Semantic precision: UNAVAILABLE; graph evidence retained (${result.overlay?.reason || 'semantic point query unavailable'}).`
|
|
101
|
+
: `Semantic precision: PARTIAL; exact absence was not proven, so graph edges are retained (${result.overlay?.reason || 'incomplete project coverage'}).`,
|
|
94
102
|
)
|
|
95
103
|
} catch (error) {
|
|
96
104
|
return getDependentsFromGraph(
|
|
@@ -39,7 +39,9 @@ function coverage(session, verifiedEdges = session.links.length) {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function completedOverlay(session) {
|
|
42
|
-
|
|
42
|
+
// Coherence guard: COMPLETE must mean every selected target was actually probed.
|
|
43
|
+
const underQueried = session.queried < session.targets.length
|
|
44
|
+
const state = session.errors || session.truncated || session.unclassifiedReferences || underQueried
|
|
43
45
|
? 'PARTIAL' : 'COMPLETE'
|
|
44
46
|
return baseOverlay(session.graph, state, {
|
|
45
47
|
request: session.request,
|
|
@@ -67,7 +69,9 @@ function completedOverlay(session) {
|
|
|
67
69
|
...(session.errors ? {reason: `${session.errors} semantic request(s) failed or were refused`}
|
|
68
70
|
: session.truncated ? {reason: 'semantic precision stopped at a configured safety limit'}
|
|
69
71
|
: session.unclassifiedReferences
|
|
70
|
-
? {reason: 'some exact references could not be classified as runtime or type-only'}
|
|
72
|
+
? {reason: 'some exact references could not be classified as runtime or type-only'}
|
|
73
|
+
: underQueried
|
|
74
|
+
? {reason: 'semantic precision stopped before probing every selected target'} : {}),
|
|
71
75
|
})
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -185,14 +189,15 @@ export async function buildLspPrecisionOverlay({
|
|
|
185
189
|
}
|
|
186
190
|
if (graphPath) {
|
|
187
191
|
const cached = readPrecisionOverlay(graphPath, graph)
|
|
188
|
-
|
|
192
|
+
// NONE (no eligible targets) is as stable as COMPLETE for an unchanged fingerprint.
|
|
193
|
+
if ((cached?.state === 'COMPLETE' || cached?.state === 'NONE')
|
|
189
194
|
&& precisionOverlayMatches(cached, graph, {request: session.request})
|
|
190
195
|
&& cached.semanticInputFingerprint === session.semanticInputs.fingerprint) return cached
|
|
191
196
|
}
|
|
192
197
|
session.eligible = eligibleTargets(graph, session.boundedMax, targetIds)
|
|
193
198
|
session.targets = session.eligible.targets
|
|
194
199
|
if (!session.targets.length) {
|
|
195
|
-
return persist(session, baseOverlay(graph, '
|
|
200
|
+
return persist(session, baseOverlay(graph, 'NONE', {
|
|
196
201
|
request: session.request,
|
|
197
202
|
semanticInputFingerprint: session.semanticInputs.fingerprint,
|
|
198
203
|
pluginPolicy: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {typeScriptLspContract} from '../typescript-lsp-provider.js'
|
|
2
2
|
|
|
3
|
-
export const PRECISION_OVERLAY_V =
|
|
3
|
+
export const PRECISION_OVERLAY_V = 5
|
|
4
4
|
export const PRECISION_FILE = 'precision.json'
|
|
5
5
|
export const JS_TS_FILE = /\.(?:[cm]?[jt]sx?)$/i
|
|
6
6
|
export const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
|
|
@@ -99,10 +99,15 @@ export function eligibleTargets(graph, limit, requestedIds = null) {
|
|
|
99
99
|
const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]))
|
|
100
100
|
if (Array.isArray(requestedIds) && requestedIds.length) {
|
|
101
101
|
const ids = [...new Set(requestedIds.map(String))]
|
|
102
|
-
const
|
|
102
|
+
const eligible = ids.map((id) => byId.get(id))
|
|
103
103
|
.filter((node) => node?.selection_start && JS_TS_FILE.test(String(node.source_file || '')))
|
|
104
|
-
|
|
105
|
-
return {
|
|
104
|
+
// total counts only eligible targets so coverage fractions stay honest when ids were dropped.
|
|
105
|
+
return {
|
|
106
|
+
targets: eligible.slice(0, limit),
|
|
107
|
+
total: eligible.length,
|
|
108
|
+
droppedRequested: ids.length - eligible.length,
|
|
109
|
+
orphanIds: new Set(),
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
const ranked = new Map()
|
|
108
113
|
const inbound = new Set()
|
|
@@ -75,7 +75,8 @@ async function storeEntry(path, entry) {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
function cacheable(overlay) {
|
|
78
|
-
|
|
78
|
+
// NONE is stable for the fingerprint, but only COMPLETE ever proves absence (see reader below).
|
|
79
|
+
if (overlay?.state === 'COMPLETE' || overlay?.state === 'NONE') return true
|
|
79
80
|
return overlay?.state === 'PARTIAL'
|
|
80
81
|
&& overlay?.reason === 'semantic precision stopped at a configured safety limit'
|
|
81
82
|
}
|