weavatrix 0.2.1 → 0.2.3
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 +98 -10
- package/package.json +5 -1
- package/skill/SKILL.md +21 -3
- package/src/analysis/http-contract-wrappers.js +241 -0
- package/src/analysis/http-contracts.js +203 -14
- package/src/graph/edge-provenance.js +31 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +2 -2
- package/src/graph/internal-builder.build.js +4 -0
- package/src/graph/internal-builder.resolvers.js +18 -3
- package/src/mcp/catalog.mjs +44 -2
- package/src/mcp/graph-context.mjs +3 -0
- package/src/mcp/sync-payload.mjs +7 -0
- package/src/mcp/tools-actions.mjs +4 -2
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph.mjs +4 -1
- package/src/mcp/tools-impact.mjs +1 -0
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph
|
|
|
95
95
|
With the default `offline` profile (or an explicit capability set containing `retarget`),
|
|
96
96
|
`open_repo` can change the active repository
|
|
97
97
|
and builds a missing graph automatically. A normal
|
|
98
|
-
`open_repo`
|
|
98
|
+
`open_repo` also upgrades graphs that predate current typed-edge/provenance metadata; `build:false`
|
|
99
99
|
probes without building and refuses a legacy graph. Retargeting is offline but intentionally changes
|
|
100
100
|
the filesystem boundary for subsequent tools; select `pinned` when that boundary must not move.
|
|
101
101
|
|
|
@@ -111,6 +111,11 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
|
|
|
111
111
|
compile-only edges (Rust module/use, Java imports) are reported separately where that distinction
|
|
112
112
|
changes the result.
|
|
113
113
|
|
|
114
|
+
Every current edge also carries versioned provenance: `EXTRACTED`, `RESOLVED`, or `INFERRED` today,
|
|
115
|
+
with `EXACT_LSP` and `CONFLICT` reserved for the optional precision overlay. `graph_stats` reports
|
|
116
|
+
the complete breakdown; provenance describes how the relationship was established, while the older
|
|
117
|
+
`confidence` field remains for compatibility.
|
|
118
|
+
|
|
114
119
|
**search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
|
|
115
120
|
symbol's actual code in one hop), `list_endpoints` (HTTP route inventory:
|
|
116
121
|
Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
|
|
@@ -168,6 +173,33 @@ disclosed instead of silently guessed; and the server **hot-reloads its watched
|
|
|
168
173
|
modules and catalog** when those files change — other MCP helpers and analysis engines require a
|
|
169
174
|
reconnect.
|
|
170
175
|
|
|
176
|
+
### 0.2.3 real-wrapper patch
|
|
177
|
+
|
|
178
|
+
- Auto-discovery recognizes wrappers that pass a fixed HTTP client method and argument array to a
|
|
179
|
+
shared transport helper, such as `api(axios.get, [url, options])`.
|
|
180
|
+
- Ambiguous handler names can resolve to the unique matching symbol in a module directly imported
|
|
181
|
+
by the route file, allowing proven external frontend calls to suppress only that exact backend
|
|
182
|
+
dead-code candidate.
|
|
183
|
+
- Missing, ambiguous, low-confidence and capped evidence remains review-only; the patch does not
|
|
184
|
+
turn absence of a client match into a dead-code verdict.
|
|
185
|
+
|
|
186
|
+
Full patch notes: [docs/releases/v0.2.3.md](docs/releases/v0.2.3.md).
|
|
187
|
+
|
|
188
|
+
### 0.2.2 regression and cross-repository evidence
|
|
189
|
+
|
|
190
|
+
- Permanent TS/JS/Python/Go/Java/Rust regression fixtures now gate graph correctness, output size,
|
|
191
|
+
latency, freshness, reconnect behavior and repository-target stability.
|
|
192
|
+
- Every current graph edge carries versioned `EXTRACTED`, `RESOLVED`, or `INFERRED` provenance;
|
|
193
|
+
`EXACT_LSP` and `CONFLICT` are reserved for the optional precision overlay.
|
|
194
|
+
- `trace_api_contract` recognizes configured and conservatively auto-discovered HTTP wrappers,
|
|
195
|
+
resolves bounded dynamic URL prefixes and can mark an unambiguous backend handler
|
|
196
|
+
`NOT_DEAD_EXTERNAL_USE` when another registered repository supplies medium/high-confidence use.
|
|
197
|
+
- Real-repository verification records explicit `MISSING`, `UNBASELINED`, and `STALE` gaps instead
|
|
198
|
+
of converting absent Java/Rust or source-checkout evidence into a green result.
|
|
199
|
+
- No mandatory runtime dependency was added.
|
|
200
|
+
|
|
201
|
+
Full release notes: [docs/releases/v0.2.2.md](docs/releases/v0.2.2.md).
|
|
202
|
+
|
|
171
203
|
### 0.2.1 bounded-output patch
|
|
172
204
|
|
|
173
205
|
- `git_history top_n=N` is a hard per-collection MCP cap, including churn, hotspots and every
|
|
@@ -234,6 +266,34 @@ Java graphs:
|
|
|
234
266
|
agent review queue. Public/exported methods, framework entries, decorators, dynamic loading and
|
|
235
267
|
reflection lower confidence; the result always says `REVIEW_REQUIRED` and `autoDelete:false`.
|
|
236
268
|
|
|
269
|
+
### HTTP clients and wrappers
|
|
270
|
+
|
|
271
|
+
`trace_api_contract` recognizes built-in object clients such as `axios.get(...)`, explicit bare or
|
|
272
|
+
object/member wrappers, and simple auto-discovered functions that forward a URL parameter directly
|
|
273
|
+
to a known HTTP client. Auto-discovered wrappers are restricted to their bounded reverse-import
|
|
274
|
+
scope; ambiguous same-name definitions are skipped and reported as incomplete evidence.
|
|
275
|
+
|
|
276
|
+
Persistent per-client-repository configuration lives in `.weavatrix.json`:
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"httpContracts": {
|
|
281
|
+
"clientNames": ["internalHttp"],
|
|
282
|
+
"wrappers": [
|
|
283
|
+
{ "call": "get", "method": "GET", "urlArgument": 0 },
|
|
284
|
+
{ "object": "transport", "member": "send", "method": "POST", "urlArgument": 1 }
|
|
285
|
+
],
|
|
286
|
+
"autoDiscoverWrappers": true
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The MCP call exposes the same ad-hoc controls as `client_names`, `client_wrappers` and
|
|
292
|
+
`auto_discover_wrappers`. A medium/high-confidence client match marks the backend endpoint/handler
|
|
293
|
+
`NOT_DEAD_EXTERNAL_USE`; a low-confidence match is `POSSIBLE_EXTERNAL_USE`; no match is `UNKNOWN`.
|
|
294
|
+
Only an unambiguously resolved handler node can suppress a method-level dead-code candidate. The tool
|
|
295
|
+
never turns missing static evidence into a `DEAD` verdict.
|
|
296
|
+
|
|
237
297
|
`run_audit` makes incomplete security coverage explicit. OSV state is `OK` only after every
|
|
238
298
|
supported pinned package/version for this repository was queried successfully. `PARTIAL` means
|
|
239
299
|
some queries failed, the response was incomplete, the dependency fingerprint changed, or the cache
|
|
@@ -290,7 +350,8 @@ Weavatrix itself initiates outbound HTTP only from three tools; all are absent f
|
|
|
290
350
|
hosted list. The endpoint is **yours**, configured through `WEAVATRIX_SYNC_URL` and the optional
|
|
291
351
|
`WEAVATRIX_SYNC_TOKEN`; the feature is off by default. Pass `payload_version: 2` only for an
|
|
292
352
|
intentional graph-only compatibility sync—there is no silent downgrade that discards evidence.
|
|
293
|
-
Graphs
|
|
353
|
+
Graphs that predate current typed-edge/provenance metadata must be rebuilt once before V3 sync;
|
|
354
|
+
V3 also refuses a stale graph so
|
|
294
355
|
source-derived evidence cannot be mixed with old topology.
|
|
295
356
|
|
|
296
357
|
Evidence sections carry independent `state` (`COMPLETE`, `PARTIAL`, `NOT_CHECKED`,
|
|
@@ -344,21 +405,48 @@ Graphs are derived data and never live inside your repo: the global per-user reg
|
|
|
344
405
|
|
|
345
406
|
```sh
|
|
346
407
|
npm install
|
|
347
|
-
npm test
|
|
408
|
+
npm test # unit/integration tests plus the quick golden benchmark
|
|
409
|
+
npm run benchmark # full TS/JS/Python/Go/Java/Rust + MCP lifecycle gate
|
|
410
|
+
npm run benchmark:real # locally available real repos vs source-free 0.2.1 baselines
|
|
348
411
|
```
|
|
349
412
|
|
|
350
|
-
|
|
351
|
-
|
|
413
|
+
The benchmark checks representative graph correctness, complete edge provenance, cross-repository
|
|
414
|
+
HTTP tracing, output bytes, latency, freshness, reconnect and active-target stability. Its budgets, semantics and intentionally
|
|
415
|
+
limited claims are documented in [docs/benchmarking.md](docs/benchmarking.md).
|
|
416
|
+
|
|
417
|
+
Refactoring target: keep focused implementation modules near 300 lines and split larger concerns
|
|
418
|
+
into dotted-suffix modules behind a slim facade (`foo.js` re-exports `foo.parse.js`,
|
|
419
|
+
`foo.report.js`, …). A few older orchestration modules remain above that target; new work should
|
|
420
|
+
reduce them instead of growing new monoliths. The MCP layer lives
|
|
352
421
|
in `src/mcp/` (graph context, tool entry modules, focused helpers, and the catalog/hot-reload
|
|
353
422
|
loader) behind the thin stdio entry `src/mcp-server.mjs`.
|
|
354
423
|
|
|
355
424
|
## Roadmap
|
|
356
425
|
|
|
357
|
-
- **
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
426
|
+
- **Public 0.2.2 regression foundation** now has the permanent six-language golden corpus,
|
|
427
|
+
cross-repository wrapper/liveness fixture, framework/convention fixture, full MCP lifecycle gate
|
|
428
|
+
and a portable real-repository runner. Five source-free 0.2.1 real-repository baselines are
|
|
429
|
+
recorded; edge provenance is gated end-to-end. The unavailable Rust source checkout is the only
|
|
430
|
+
explicit local gap preventing the strict six-repository release command from passing here.
|
|
431
|
+
- **Wrapper-aware API contracts** shipped in 0.2.2: persistent/ad-hoc configuration,
|
|
432
|
+
conservative discovery, cross-repository handler liveness and explicit unknown states. The next
|
|
433
|
+
hosted increment joins privacy-safe contract identities across separately synced services.
|
|
434
|
+
- **Hosted architecture workbench** is live at
|
|
435
|
+
[app.weavatrix.com](https://app.weavatrix.com): explicit source-free sync,
|
|
436
|
+
immutable history, Flow/DSM/Map, target architecture and bounded review
|
|
437
|
+
evidence. Hosted use remains optional and owner-authenticated.
|
|
438
|
+
- **Semantic precision bridge** — optional local language-server validation for
|
|
439
|
+
ambiguous impact, reference and dead-code evidence; the current parser graph
|
|
440
|
+
remains the offline fallback.
|
|
441
|
+
- **Git-native architecture history** — bounded tag/ref timelines and branch
|
|
442
|
+
reports built outside the worktree; graph artifacts stay out of Git.
|
|
443
|
+
- **Cross-repository company evidence** — endpoints, events and internal
|
|
444
|
+
packages joined to affected consumers and ownership without uploading source.
|
|
445
|
+
- **CI blast radius** — bounded `change_impact` and architecture-ratchet evidence
|
|
446
|
+
as a PR check/comment.
|
|
447
|
+
|
|
448
|
+
The public alignment note for the fixed cross-product release sequence is in
|
|
449
|
+
[docs/product-roadmap.md](docs/product-roadmap.md).
|
|
362
450
|
|
|
363
451
|
## License
|
|
364
452
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weavatrix",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weavatrix — code graph & blast-radius MCP server for AI coding agents: change impact, transitive dependents, health audit, duplicate detection, and coverage mapping over any local repository.",
|
|
6
6
|
"author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
32
|
"test": "node --test",
|
|
33
|
+
"benchmark": "node scripts/run-benchmark.mjs",
|
|
34
|
+
"benchmark:quick": "node scripts/run-benchmark.mjs --quick",
|
|
35
|
+
"benchmark:real": "node scripts/run-real-benchmark.mjs",
|
|
36
|
+
"benchmark:real:release": "node scripts/run-real-benchmark.mjs --require-all",
|
|
33
37
|
"build:mcpb": "node scripts/build-mcpb.mjs",
|
|
34
38
|
"verify:release": "node scripts/verify-release.mjs"
|
|
35
39
|
},
|
package/skill/SKILL.md
CHANGED
|
@@ -47,6 +47,11 @@ named profiles for new registrations.
|
|
|
47
47
|
`change_impact` and `graph_diff` label the distinction; `god_nodes` ranks unique connectivity and
|
|
48
48
|
reports repeated references separately. Do not schedule a runtime-cycle refactor from
|
|
49
49
|
compile-time-only edges.
|
|
50
|
+
- **Edge provenance**: distinguish how an edge was established from legacy confidence. Current
|
|
51
|
+
graphs use `EXTRACTED`, `RESOLVED`, and `INFERRED`; `EXACT_LSP` is reserved for a bounded local
|
|
52
|
+
precision overlay and `CONFLICT` means evidence disagrees. Treat `INFERRED` and `CONFLICT` as
|
|
53
|
+
review signals rather than compiler-exact facts; an `UNKNOWN` count in `graph_stats` requires a
|
|
54
|
+
rebuild before precision-sensitive work.
|
|
50
55
|
- **Repository universe**: in Git repositories, graph and duplicate scans include tracked plus
|
|
51
56
|
non-ignored untracked files. If an old graph still contains packaged/generated output, rebuild it
|
|
52
57
|
before interpreting the result. Repository-root `.weavatrixignore` applies the same tracked-file
|
|
@@ -117,8 +122,21 @@ named profiles for new registrations.
|
|
|
117
122
|
- **Cross-repository API impact**: ensure both repositories are in `list_known_repos`, then call
|
|
118
123
|
`trace_api_contract backend=<uuid-or-label> clients=[<uuid-or-label>]`; narrow with `method`, `path`,
|
|
119
124
|
or backend `changed_files`. `path` may be a segment-aligned fragment (`/query` can select
|
|
120
|
-
`/edgeAnalytics/query/...`), and bounded constant prefixes in template URLs are resolved.
|
|
121
|
-
|
|
125
|
+
`/edgeAnalytics/query/...`), and bounded constant prefixes in template URLs are resolved. The tool
|
|
126
|
+
recognizes configured and conservatively auto-discovered HTTP wrappers; use per-repository
|
|
127
|
+
`.weavatrix.json` `httpContracts` configuration for durable custom client names/wrappers, or
|
|
128
|
+
`client_names` / `client_wrappers` for one trace. `NOT_DEAD_EXTERNAL_USE` requires medium/high
|
|
129
|
+
confidence; only an unambiguously resolved handler node can suppress a dead-method candidate.
|
|
130
|
+
`POSSIBLE_EXTERNAL_USE`, `UNKNOWN`, and remaining unresolved/dynamic URLs are incomplete evidence,
|
|
131
|
+
never proof that a method is dead.
|
|
132
|
+
- **Release regression gate**: for a Weavatrix source checkout, use `npm test` for the quick
|
|
133
|
+
six-language golden corpus and `npm run benchmark` before release for the real MCP
|
|
134
|
+
`full -> incremental -> none -> reconnect/none` lifecycle, bounded output and active-target checks.
|
|
135
|
+
Use `npm run benchmark:real` for available manifest repositories and
|
|
136
|
+
`npm run benchmark:real:release` only when all six source checkouts are present; `MISSING`,
|
|
137
|
+
`UNBASELINED` and `STALE` are explicit incomplete states, not green results.
|
|
138
|
+
A green Java/Rust fixture proves only its declared representative signals, not compiler-exact
|
|
139
|
+
coverage of arbitrary repositories.
|
|
122
140
|
- **Target architecture before editing**: `get_architecture_contract` → `prepare_change` with the
|
|
123
141
|
intended files → edit and rebuild → `verify_architecture`. A missing contract returns a starter
|
|
124
142
|
proposal from `get_architecture_contract output_format:"json"`, not an automatically approved
|
|
@@ -170,7 +188,7 @@ runtime/type-only/compile-only module dependencies. Package evidence contains a
|
|
|
170
188
|
graph with direct/transitive runtime, dev, optional and peer edges plus explicit resolution counts.
|
|
171
189
|
Duplicate evidence contains stable, source-free clone/divergence candidates; it never sends method
|
|
172
190
|
bodies or snippets. Use `payload_version: 2` only when the user explicitly wants graph-only
|
|
173
|
-
compatibility—Weavatrix never silently downgrades. A graph
|
|
191
|
+
compatibility—Weavatrix never silently downgrades. A graph predating current provenance metadata, or one stale against
|
|
174
192
|
the working tree, must be rebuilt first. Sync remains unavailable until the user selects `hosted`
|
|
175
193
|
(or the exact `hosted` capability) and configures `WEAVATRIX_SYNC_URL`.
|
|
176
194
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Bounded HTTP-wrapper configuration and conservative auto-discovery. A wrapper is accepted only
|
|
2
|
+
// when its URL position and HTTP method are statically known; no source is evaluated.
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
5
|
+
import { safeRead } from "../util.js";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_HTTP_CLIENT_NAMES = Object.freeze([
|
|
8
|
+
"axios", "http", "https", "$http", "httpClient", "apiClient", "restClient",
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
12
|
+
const MAX_CONFIG_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_WRAPPERS = 100;
|
|
14
|
+
const MAX_CLIENT_NAMES = 40;
|
|
15
|
+
const MAX_DISCOVERY_BODY = 2_000;
|
|
16
|
+
const identifier = (value) => {
|
|
17
|
+
const text = String(value || "").trim();
|
|
18
|
+
return /^[A-Za-z_$][\w$]{0,127}$/.test(text) ? text : null;
|
|
19
|
+
};
|
|
20
|
+
const method = (value) => {
|
|
21
|
+
const text = String(value || "").trim().toUpperCase();
|
|
22
|
+
return HTTP_METHODS.has(text) ? text : null;
|
|
23
|
+
};
|
|
24
|
+
const argumentIndex = (value) => {
|
|
25
|
+
const number = Number(value ?? 0);
|
|
26
|
+
return Number.isInteger(number) && number >= 0 && number <= 5 ? number : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function normalizeHttpClientNames(values) {
|
|
30
|
+
return [...new Set((Array.isArray(values) ? values : [])
|
|
31
|
+
.slice(0, MAX_CLIENT_NAMES)
|
|
32
|
+
.map(identifier)
|
|
33
|
+
.filter(Boolean))];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function normalizeHttpWrapperDescriptors(values, source = "input") {
|
|
37
|
+
const wrappers = [];
|
|
38
|
+
for (const raw of (Array.isArray(values) ? values : []).slice(0, MAX_WRAPPERS)) {
|
|
39
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
40
|
+
const urlArgument = argumentIndex(raw.urlArgument ?? raw.url_argument);
|
|
41
|
+
const fixedMethod = method(raw.method);
|
|
42
|
+
if (urlArgument == null || !fixedMethod) continue;
|
|
43
|
+
const call = identifier(raw.call);
|
|
44
|
+
const object = identifier(raw.object);
|
|
45
|
+
const member = identifier(raw.member);
|
|
46
|
+
if (call && !object && !member) {
|
|
47
|
+
wrappers.push({ kind: "function", call, method: fixedMethod, urlArgument, source });
|
|
48
|
+
} else if (!call && object && member) {
|
|
49
|
+
wrappers.push({ kind: "member", object, member, method: fixedMethod, urlArgument, source });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
return wrappers.filter((wrapper) => {
|
|
54
|
+
const key = `${wrapper.kind}\0${wrapper.call || wrapper.object}\0${wrapper.member || ""}\0${wrapper.method}\0${wrapper.urlArgument}`;
|
|
55
|
+
if (seen.has(key)) return false;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadHttpContractConfig(repoRoot) {
|
|
62
|
+
const empty = { clientNames: [], wrappers: [], autoDiscoverWrappers: true, loaded: false, warnings: [] };
|
|
63
|
+
const resolved = createRepoBoundary(repoRoot).resolve(".weavatrix.json");
|
|
64
|
+
if (!resolved.ok) return empty;
|
|
65
|
+
try {
|
|
66
|
+
if (statSync(resolved.path).size > MAX_CONFIG_BYTES) return { ...empty, error: "config-too-large" };
|
|
67
|
+
const raw = JSON.parse(safeRead(resolved.path));
|
|
68
|
+
const config = raw?.httpContracts;
|
|
69
|
+
if (config == null) return { ...empty, loaded: true };
|
|
70
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) return { ...empty, error: "invalid-http-contract-config" };
|
|
71
|
+
const rawNames = Array.isArray(config.clientNames) ? config.clientNames : [];
|
|
72
|
+
const rawWrappers = Array.isArray(config.wrappers) ? config.wrappers : [];
|
|
73
|
+
const clientNames = normalizeHttpClientNames(rawNames);
|
|
74
|
+
const wrappers = normalizeHttpWrapperDescriptors(rawWrappers, "config");
|
|
75
|
+
const warnings = [];
|
|
76
|
+
if (config.clientNames != null && !Array.isArray(config.clientNames)) warnings.push("httpContracts.clientNames must be an array");
|
|
77
|
+
if (rawNames.length > MAX_CLIENT_NAMES) warnings.push("httpContracts.clientNames cap reached");
|
|
78
|
+
if (clientNames.length < Math.min(rawNames.length, MAX_CLIENT_NAMES)) warnings.push("invalid httpContracts.clientNames entries skipped");
|
|
79
|
+
if (config.wrappers != null && !Array.isArray(config.wrappers)) warnings.push("httpContracts.wrappers must be an array");
|
|
80
|
+
if (rawWrappers.length > MAX_WRAPPERS) warnings.push("httpContracts.wrappers cap reached");
|
|
81
|
+
if (wrappers.length < Math.min(rawWrappers.length, MAX_WRAPPERS)) warnings.push("invalid or duplicate httpContracts.wrappers entries skipped");
|
|
82
|
+
if (config.autoDiscoverWrappers != null && typeof config.autoDiscoverWrappers !== "boolean") warnings.push("httpContracts.autoDiscoverWrappers must be boolean");
|
|
83
|
+
return {
|
|
84
|
+
clientNames,
|
|
85
|
+
wrappers,
|
|
86
|
+
autoDiscoverWrappers: config.autoDiscoverWrappers !== false,
|
|
87
|
+
loaded: true,
|
|
88
|
+
warnings,
|
|
89
|
+
};
|
|
90
|
+
} catch {
|
|
91
|
+
return { ...empty, error: "invalid-config" };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Masks comments and strings while retaining indices and newlines. Wrapper discovery needs only
|
|
96
|
+
// declarations and a direct `client.verb(urlParam)` delegation, never literal contents.
|
|
97
|
+
function codeMask(text) {
|
|
98
|
+
const chars = String(text || "").split("");
|
|
99
|
+
let index = 0;
|
|
100
|
+
while (index < chars.length) {
|
|
101
|
+
const char = chars[index], next = chars[index + 1];
|
|
102
|
+
if (char === "/" && next === "/") {
|
|
103
|
+
chars[index++] = " "; chars[index++] = " ";
|
|
104
|
+
while (index < chars.length && chars[index] !== "\n") chars[index++] = " ";
|
|
105
|
+
} else if (char === "/" && next === "*") {
|
|
106
|
+
chars[index++] = " "; chars[index++] = " ";
|
|
107
|
+
while (index < chars.length) {
|
|
108
|
+
if (chars[index] === "*" && chars[index + 1] === "/") { chars[index++] = " "; chars[index++] = " "; break; }
|
|
109
|
+
if (chars[index] !== "\n") chars[index] = " ";
|
|
110
|
+
index += 1;
|
|
111
|
+
}
|
|
112
|
+
} else if (char === "'" || char === '"' || char === "`") {
|
|
113
|
+
const quote = char;
|
|
114
|
+
chars[index++] = " ";
|
|
115
|
+
while (index < chars.length) {
|
|
116
|
+
if (chars[index] === "\\") {
|
|
117
|
+
chars[index++] = " ";
|
|
118
|
+
if (index < chars.length && chars[index] !== "\n") chars[index] = " ";
|
|
119
|
+
index += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const closes = chars[index] === quote;
|
|
123
|
+
if (chars[index] !== "\n") chars[index] = " ";
|
|
124
|
+
index += 1;
|
|
125
|
+
if (closes) break;
|
|
126
|
+
}
|
|
127
|
+
} else index += 1;
|
|
128
|
+
}
|
|
129
|
+
return chars.join("");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function matchingBrace(mask, open) {
|
|
133
|
+
let depth = 0;
|
|
134
|
+
for (let index = open; index < Math.min(mask.length, open + MAX_DISCOVERY_BODY); index += 1) {
|
|
135
|
+
if (mask[index] === "{") depth += 1;
|
|
136
|
+
else if (mask[index] === "}" && --depth === 0) return index + 1;
|
|
137
|
+
}
|
|
138
|
+
return Math.min(mask.length, open + MAX_DISCOVERY_BODY);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parameterNames(raw) {
|
|
142
|
+
const parts = [];
|
|
143
|
+
let start = 0, round = 0, square = 0, curly = 0, angle = 0;
|
|
144
|
+
const text = String(raw || "");
|
|
145
|
+
for (let index = 0; index <= text.length; index += 1) {
|
|
146
|
+
const char = text[index];
|
|
147
|
+
if (char === "(") round += 1;
|
|
148
|
+
else if (char === ")") round -= 1;
|
|
149
|
+
else if (char === "[") square += 1;
|
|
150
|
+
else if (char === "]") square -= 1;
|
|
151
|
+
else if (char === "{") curly += 1;
|
|
152
|
+
else if (char === "}") curly -= 1;
|
|
153
|
+
else if (char === "<") angle += 1;
|
|
154
|
+
else if (char === ">") angle = Math.max(0, angle - 1);
|
|
155
|
+
if ((char === "," && round === 0 && square === 0 && curly === 0 && angle === 0) || index === text.length) {
|
|
156
|
+
parts.push(text.slice(start, index));
|
|
157
|
+
start = index + 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const names = [];
|
|
161
|
+
for (const part of parts.slice(0, 6)) {
|
|
162
|
+
const match = /^\s*([A-Za-z_$][\w$]*)(?=\s*(?:[?:=]|$))/.exec(part);
|
|
163
|
+
if (!match) return [];
|
|
164
|
+
names.push(match[1]);
|
|
165
|
+
}
|
|
166
|
+
return names;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escaped(value) {
|
|
170
|
+
return String(value).replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function delegatedWrapper(mask, definition, clientNames) {
|
|
174
|
+
const body = mask.slice(definition.bodyStart, definition.bodyEnd);
|
|
175
|
+
const clients = clientNames.map(escaped).join("|");
|
|
176
|
+
if (!clients) return null;
|
|
177
|
+
const delegate = new RegExp(`\\b(?:${clients})\\s*(?:\\?\\.|\\.)\\s*(get|post|put|patch|delete|head|options)\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(\\s*([A-Za-z_$][\\w$]*)\\s*(?=[,)])`, "gi");
|
|
178
|
+
const matches = [];
|
|
179
|
+
let found;
|
|
180
|
+
while ((found = delegate.exec(body)) && matches.length < 3) {
|
|
181
|
+
const urlArgument = definition.parameters.indexOf(found[2]);
|
|
182
|
+
if (urlArgument >= 0) matches.push({ method: found[1].toUpperCase(), urlArgument });
|
|
183
|
+
}
|
|
184
|
+
// Common typed clients keep response/error handling in one helper and pass the concrete
|
|
185
|
+
// axios/http method plus its argument array: api(axios.get, [url, options]). The HTTP method and
|
|
186
|
+
// first transport argument are still statically fixed, so this is as deterministic as a direct
|
|
187
|
+
// axios.get(url) delegation without evaluating the helper.
|
|
188
|
+
const methodReference = new RegExp(`\\b[A-Za-z_$][\\w$]*\\s*\\(\\s*(?:${clients})\\s*(?:\\?\\.|\\.)\\s*(get|post|put|patch|delete|head|options)\\s*,\\s*\\[\\s*([A-Za-z_$][\\w$]*)\\s*(?=[,\\]])`, "gi");
|
|
189
|
+
while ((found = methodReference.exec(body)) && matches.length < 3) {
|
|
190
|
+
const urlArgument = definition.parameters.indexOf(found[2]);
|
|
191
|
+
if (urlArgument >= 0) matches.push({ method: found[1].toUpperCase(), urlArgument });
|
|
192
|
+
}
|
|
193
|
+
const unique = [...new Map(matches.map((item) => [`${item.method}\0${item.urlArgument}`, item])).values()];
|
|
194
|
+
return unique.length === 1 ? unique[0] : null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function discoverHttpWrappers(sources, clientNames = []) {
|
|
198
|
+
const allowedClients = [...new Set([...DEFAULT_HTTP_CLIENT_NAMES, ...normalizeHttpClientNames(clientNames)])];
|
|
199
|
+
const candidates = [];
|
|
200
|
+
let truncated = false;
|
|
201
|
+
for (const source of Array.isArray(sources) ? sources : []) {
|
|
202
|
+
if (candidates.length >= MAX_WRAPPERS) { truncated = true; break; }
|
|
203
|
+
const mask = codeMask(source.text);
|
|
204
|
+
const definitions = [];
|
|
205
|
+
const functions = /\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]{0,300})\)\s*(?::[^\n{]{1,200})?\s*\{/g;
|
|
206
|
+
let match;
|
|
207
|
+
while ((match = functions.exec(mask)) && definitions.length < MAX_WRAPPERS) {
|
|
208
|
+
const open = functions.lastIndex - 1;
|
|
209
|
+
definitions.push({ call: match[1], parameters: parameterNames(match[2]), bodyStart: open + 1, bodyEnd: matchingBrace(mask, open) - 1 });
|
|
210
|
+
}
|
|
211
|
+
const arrows = /\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:<[^>\n]{1,200}>\s*)?(?:\(([^)]{0,300})\)\s*(?::[^\n=]{1,200})?|([A-Za-z_$][\w$]*))\s*=>\s*/g;
|
|
212
|
+
while ((match = arrows.exec(mask)) && definitions.length < MAX_WRAPPERS) {
|
|
213
|
+
const parameters = parameterNames(match[2] ?? match[3]);
|
|
214
|
+
const open = mask.indexOf("{", arrows.lastIndex);
|
|
215
|
+
const block = open >= 0 && /^\s*\{/.test(mask.slice(arrows.lastIndex, open + 1));
|
|
216
|
+
const bodyStart = block ? open + 1 : arrows.lastIndex;
|
|
217
|
+
const bodyEnd = block ? matchingBrace(mask, open) - 1 : Math.min(mask.length, bodyStart + MAX_DISCOVERY_BODY, mask.indexOf(";", bodyStart) >= 0 ? mask.indexOf(";", bodyStart) : mask.length);
|
|
218
|
+
definitions.push({ call: match[1], parameters, bodyStart, bodyEnd });
|
|
219
|
+
}
|
|
220
|
+
for (const definition of definitions) {
|
|
221
|
+
if (!definition.parameters.length) continue;
|
|
222
|
+
const delegated = delegatedWrapper(mask, definition, allowedClients);
|
|
223
|
+
if (delegated && candidates.length < MAX_WRAPPERS) candidates.push({ kind: "function", call: definition.call, ...delegated, source: "auto", definitionFile: source.file });
|
|
224
|
+
else if (delegated) truncated = true;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const byCall = new Map();
|
|
229
|
+
for (const candidate of candidates) {
|
|
230
|
+
const entries = byCall.get(candidate.call) || [];
|
|
231
|
+
entries.push(candidate);
|
|
232
|
+
byCall.set(candidate.call, entries);
|
|
233
|
+
}
|
|
234
|
+
const wrappers = [], ambiguous = [];
|
|
235
|
+
for (const [call, entries] of [...byCall.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
236
|
+
const unique = [...new Map(entries.map((entry) => [`${entry.method}\0${entry.urlArgument}\0${entry.definitionFile}`, entry])).values()];
|
|
237
|
+
if (unique.length === 1) wrappers.push(unique[0]);
|
|
238
|
+
else ambiguous.push({ call, definitions: unique.length });
|
|
239
|
+
}
|
|
240
|
+
return { wrappers, ambiguous, truncated };
|
|
241
|
+
}
|
|
@@ -8,11 +8,17 @@ import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
|
8
8
|
import { isWeavatrixIgnored, loadWeavatrixIgnore } from "../path-ignore.js";
|
|
9
9
|
import { createRepoBoundary } from "../repo-path.js";
|
|
10
10
|
import { safeRead } from "../util.js";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_HTTP_CLIENT_NAMES,
|
|
13
|
+
discoverHttpWrappers,
|
|
14
|
+
loadHttpContractConfig,
|
|
15
|
+
normalizeHttpClientNames,
|
|
16
|
+
normalizeHttpWrapperDescriptors,
|
|
17
|
+
} from "./http-contract-wrappers.js";
|
|
11
18
|
|
|
12
|
-
export const HTTP_CONTRACTS_V =
|
|
19
|
+
export const HTTP_CONTRACTS_V = 2;
|
|
13
20
|
|
|
14
21
|
const METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
15
|
-
const DEFAULT_CLIENT_NAMES = Object.freeze(["axios", "http", "https", "$http", "httpClient", "apiClient", "restClient"]);
|
|
16
22
|
const DEFAULTS = Object.freeze({
|
|
17
23
|
maxBackendFiles: 3_000,
|
|
18
24
|
maxClientFiles: 3_000,
|
|
@@ -235,8 +241,37 @@ function extractStaticStringConstants(text) {
|
|
|
235
241
|
return constants;
|
|
236
242
|
}
|
|
237
243
|
|
|
238
|
-
function
|
|
239
|
-
let
|
|
244
|
+
function argumentStart(text, openParen, target) {
|
|
245
|
+
let current = 0, round = 0, square = 0, curly = 0;
|
|
246
|
+
for (let index = openParen + 1; index < text.length; index += 1) {
|
|
247
|
+
const char = text[index];
|
|
248
|
+
if (current === target && !/\s|,/.test(char)) return index;
|
|
249
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
250
|
+
const quote = char;
|
|
251
|
+
index += 1;
|
|
252
|
+
while (index < text.length) {
|
|
253
|
+
if (text[index] === "\\") index += 2;
|
|
254
|
+
else if (text[index] === quote) break;
|
|
255
|
+
else index += 1;
|
|
256
|
+
}
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (char === "(" ) round += 1;
|
|
260
|
+
else if (char === "[" ) square += 1;
|
|
261
|
+
else if (char === "{" ) curly += 1;
|
|
262
|
+
else if (char === ")") {
|
|
263
|
+
if (round === 0 && square === 0 && curly === 0) return null;
|
|
264
|
+
round -= 1;
|
|
265
|
+
} else if (char === "]") square -= 1;
|
|
266
|
+
else if (char === "}") curly -= 1;
|
|
267
|
+
else if (char === "," && round === 0 && square === 0 && curly === 0) current += 1;
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function parseUrlArgument(text, openParen, constants, argument = 0) {
|
|
273
|
+
let start = argumentStart(text, openParen, argument);
|
|
274
|
+
if (start == null) return { path: null, endIndex: openParen, kind: "dynamic", dynamic: true, unknownPrefix: false, partialDynamic: true, reason: `URL argument ${argument} is missing` };
|
|
240
275
|
while (/\s/.test(text[start] || "")) start += 1;
|
|
241
276
|
const quote = text[start];
|
|
242
277
|
if (quote === "'" || quote === '"') {
|
|
@@ -272,22 +307,30 @@ function fetchMethod(text, argumentEnd) {
|
|
|
272
307
|
}
|
|
273
308
|
|
|
274
309
|
function normalizedClientNames(values) {
|
|
275
|
-
return new Set([...
|
|
310
|
+
return new Set([...DEFAULT_HTTP_CLIENT_NAMES, ...normalizeHttpClientNames(values)]
|
|
276
311
|
.map((value) => String(value || "").trim().toLowerCase())
|
|
277
312
|
.filter((value) => /^[a-z_$][\w$]*$/i.test(value)));
|
|
278
313
|
}
|
|
279
314
|
|
|
315
|
+
const escapeRegex = (value) => String(value).replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");
|
|
316
|
+
|
|
280
317
|
export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
281
318
|
const source = String(text || "");
|
|
282
319
|
const mask = maskNonCode(source);
|
|
283
320
|
const constants = extractStaticStringConstants(source);
|
|
284
321
|
const allowed = normalizedClientNames(options.clientNames);
|
|
322
|
+
const wrappers = normalizeHttpWrapperDescriptors(options.wrappers, "input")
|
|
323
|
+
.concat((Array.isArray(options.normalizedWrappers) ? options.normalizedWrappers : []));
|
|
285
324
|
const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
|
|
286
325
|
const calls = [];
|
|
326
|
+
const seen = new Set();
|
|
287
327
|
let truncated = false;
|
|
288
|
-
const add = (clientName, method, openParen, fetch = false) => {
|
|
328
|
+
const add = (clientName, method, openParen, fetch = false, urlArgument = 0, detector = "builtin", wrapper = null) => {
|
|
329
|
+
const key = `${openParen}\0${method}\0${urlArgument}`;
|
|
330
|
+
if (seen.has(key)) return;
|
|
331
|
+
seen.add(key);
|
|
289
332
|
if (calls.length >= maxCalls) { truncated = true; return; }
|
|
290
|
-
const parsed = parseUrlArgument(source, openParen, constants);
|
|
333
|
+
const parsed = parseUrlArgument(source, openParen, constants, urlArgument);
|
|
291
334
|
const fetchInfo = fetch ? fetchMethod(source, parsed.endIndex) : { method: method.toUpperCase(), uncertain: false };
|
|
292
335
|
calls.push({
|
|
293
336
|
file: normalizeFile(file),
|
|
@@ -300,10 +343,12 @@ export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
|
300
343
|
unknownPrefix: Boolean(parsed.unknownPrefix),
|
|
301
344
|
partialDynamic: Boolean(parsed.partialDynamic || fetchInfo.uncertain),
|
|
302
345
|
reason: fetchInfo.uncertain ? "HTTP method is dynamic" : parsed.reason,
|
|
346
|
+
detector,
|
|
347
|
+
wrapper,
|
|
303
348
|
});
|
|
304
349
|
};
|
|
305
350
|
|
|
306
|
-
const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*\(/gim;
|
|
351
|
+
const member = /(^|[^\w$])([A-Za-z_$][\w$]*)\s*(?:\?\.|\.)\s*(get|post|put|patch|delete|head|options)\s*(?:<[^>\n]{1,200}>)?\s*\(/gim;
|
|
307
352
|
let match;
|
|
308
353
|
while ((match = member.exec(mask))) {
|
|
309
354
|
if (!allowed.has(match[2].toLowerCase())) continue;
|
|
@@ -311,6 +356,24 @@ export function extractHttpClientCallsFromText(text, file, options = {}) {
|
|
|
311
356
|
}
|
|
312
357
|
const fetchCall = /(^|[^\w$])fetch\s*\(/gim;
|
|
313
358
|
while ((match = fetchCall.exec(mask))) add("fetch", "GET", fetchCall.lastIndex - 1, true);
|
|
359
|
+
for (const wrapper of wrappers) {
|
|
360
|
+
if (wrapper.allowedFiles instanceof Set && !wrapper.allowedFiles.has(normalizeFile(file))) continue;
|
|
361
|
+
if (wrapper.kind === "function") {
|
|
362
|
+
const bare = new RegExp(`(^|[^\\w$.?])${escapeRegex(wrapper.call)}\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(`, "gim");
|
|
363
|
+
while ((match = bare.exec(mask))) {
|
|
364
|
+
const nameAt = bare.lastIndex - match[0].length + match[1].length;
|
|
365
|
+
if (/\bfunction\s*$/i.test(mask.slice(Math.max(0, nameAt - 30), nameAt))) continue;
|
|
366
|
+
add(wrapper.call, wrapper.method, bare.lastIndex - 1, false, wrapper.urlArgument, `${wrapper.source}-wrapper`, {
|
|
367
|
+
kind: wrapper.kind, call: wrapper.call, definitionFile: wrapper.definitionFile || null,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
} else if (wrapper.kind === "member") {
|
|
371
|
+
const memberCall = new RegExp(`(^|[^\\w$])${escapeRegex(wrapper.object)}\\s*(?:\\?\\.|\\.)\\s*${escapeRegex(wrapper.member)}\\s*(?:<[^>\\n]{1,200}>)?\\s*\\(`, "gim");
|
|
372
|
+
while ((match = memberCall.exec(mask))) add(`${wrapper.object}.${wrapper.member}`, wrapper.method, memberCall.lastIndex - 1, false, wrapper.urlArgument, `${wrapper.source}-wrapper`, {
|
|
373
|
+
kind: wrapper.kind, object: wrapper.object, member: wrapper.member, definitionFile: wrapper.definitionFile || null,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
314
377
|
calls.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.method.localeCompare(right.method) || String(left.path).localeCompare(String(right.path)));
|
|
315
378
|
return { calls, truncated };
|
|
316
379
|
}
|
|
@@ -321,7 +384,7 @@ function filesFromGraph(graph) {
|
|
|
321
384
|
|
|
322
385
|
export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
323
386
|
const boundary = createRepoBoundary(repoRoot);
|
|
324
|
-
if (!boundary.root) return { calls: [], truncated: false, filesScanned: 0 };
|
|
387
|
+
if (!boundary.root) return { calls: [], truncated: false, filesScanned: 0, discovery: { enabled: false, configured: 0, discovered: 0, ambiguous: [] }, reasons: [] };
|
|
325
388
|
const maxFiles = boundedInteger(options.maxFiles, DEFAULTS.maxClientFiles, 1, HARD.maxClientFiles);
|
|
326
389
|
const maxCalls = boundedInteger(options.maxCalls, DEFAULTS.maxCallsPerClient, 1, HARD.maxCallsPerClient);
|
|
327
390
|
const ignoreRules = loadWeavatrixIgnore(boundary.root);
|
|
@@ -329,6 +392,7 @@ export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
|
329
392
|
const candidates = [...new Set((codeFiles || []).map((entry) => normalizeFile(entry?.path || entry)).filter(Boolean))].sort();
|
|
330
393
|
let truncated = candidates.length > maxFiles;
|
|
331
394
|
let filesScanned = 0;
|
|
395
|
+
const sources = [];
|
|
332
396
|
const calls = [];
|
|
333
397
|
for (const file of candidates.slice(0, maxFiles)) {
|
|
334
398
|
if (!/\.(?:[cm]?[jt]sx?|vue|svelte)$/i.test(file) || isWeavatrixIgnored(file, ignoreRules)) continue;
|
|
@@ -339,14 +403,53 @@ export function detectHttpClientCalls(repoRoot, codeFiles, options = {}) {
|
|
|
339
403
|
const text = safeRead(resolved.path);
|
|
340
404
|
if (!text) continue;
|
|
341
405
|
filesScanned += 1;
|
|
406
|
+
sources.push({ file, text });
|
|
407
|
+
}
|
|
408
|
+
const config = loadHttpContractConfig(boundary.root);
|
|
409
|
+
const clientNames = [...new Set([
|
|
410
|
+
...normalizeHttpClientNames(options.clientNames),
|
|
411
|
+
...config.clientNames,
|
|
412
|
+
])];
|
|
413
|
+
const configured = [
|
|
414
|
+
...normalizeHttpWrapperDescriptors(options.wrappers, "input"),
|
|
415
|
+
...config.wrappers,
|
|
416
|
+
];
|
|
417
|
+
const discoveryEnabled = options.autoDiscoverWrappers !== false && config.autoDiscoverWrappers !== false;
|
|
418
|
+
const discovered = discoveryEnabled ? discoverHttpWrappers(sources, clientNames) : { wrappers: [], ambiguous: [], truncated: false };
|
|
419
|
+
const scopedDiscovered = discovered.wrappers.map((wrapper) => ({
|
|
420
|
+
...wrapper,
|
|
421
|
+
allowedFiles: wrapperScopeFiles(wrapper.definitionFile, options.graph),
|
|
422
|
+
}));
|
|
423
|
+
for (const { file, text } of sources) {
|
|
342
424
|
const remaining = maxCalls - calls.length;
|
|
343
425
|
if (remaining <= 0) { truncated = true; break; }
|
|
344
|
-
const extracted = extractHttpClientCallsFromText(text, file, {
|
|
426
|
+
const extracted = extractHttpClientCallsFromText(text, file, {
|
|
427
|
+
clientNames,
|
|
428
|
+
normalizedWrappers: [...configured, ...scopedDiscovered],
|
|
429
|
+
maxCalls: remaining,
|
|
430
|
+
});
|
|
345
431
|
calls.push(...extracted.calls);
|
|
346
432
|
if (extracted.truncated) truncated = true;
|
|
347
433
|
}
|
|
348
434
|
calls.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line || left.method.localeCompare(right.method) || String(left.path).localeCompare(String(right.path)));
|
|
349
|
-
|
|
435
|
+
const reasons = [];
|
|
436
|
+
if (config.error) reasons.push(`HTTP contract config ${config.error}`);
|
|
437
|
+
reasons.push(...(config.warnings || []));
|
|
438
|
+
if (discovered.truncated) reasons.push("auto-discovered wrapper cap reached");
|
|
439
|
+
if (discovered.ambiguous.length) reasons.push(`${discovered.ambiguous.length} ambiguous auto-discovered wrapper name(s) skipped`);
|
|
440
|
+
return {
|
|
441
|
+
calls,
|
|
442
|
+
truncated,
|
|
443
|
+
filesScanned,
|
|
444
|
+
discovery: {
|
|
445
|
+
enabled: discoveryEnabled,
|
|
446
|
+
configured: configured.length,
|
|
447
|
+
discovered: scopedDiscovered.length,
|
|
448
|
+
ambiguous: discovered.ambiguous,
|
|
449
|
+
truncated: discovered.truncated,
|
|
450
|
+
},
|
|
451
|
+
reasons,
|
|
452
|
+
};
|
|
350
453
|
}
|
|
351
454
|
|
|
352
455
|
function pathSegments(path) {
|
|
@@ -434,6 +537,24 @@ function reverseImports(graph = {}) {
|
|
|
434
537
|
return reverse;
|
|
435
538
|
}
|
|
436
539
|
|
|
540
|
+
function wrapperScopeFiles(sourceFile, graph) {
|
|
541
|
+
const source = normalizeFile(sourceFile);
|
|
542
|
+
if (!source || !Array.isArray(graph?.nodes)) return null;
|
|
543
|
+
const reverse = reverseImports(graph);
|
|
544
|
+
const allowed = new Set([source]);
|
|
545
|
+
const queue = [{ file: source, depth: 0 }];
|
|
546
|
+
while (queue.length && allowed.size < 2_000) {
|
|
547
|
+
const current = queue.shift();
|
|
548
|
+
if (current.depth >= 4) continue;
|
|
549
|
+
for (const importer of [...(reverse.get(current.file) || [])].sort()) {
|
|
550
|
+
if (allowed.has(importer)) continue;
|
|
551
|
+
allowed.add(importer);
|
|
552
|
+
queue.push({ file: importer, depth: current.depth + 1 });
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return allowed;
|
|
556
|
+
}
|
|
557
|
+
|
|
437
558
|
function isScreen(file) {
|
|
438
559
|
const path = normalizeFile(file);
|
|
439
560
|
const base = path.split("/").at(-1) || "";
|
|
@@ -511,6 +632,55 @@ function endpointFilter(endpoint, backendId, options) {
|
|
|
511
632
|
return true;
|
|
512
633
|
}
|
|
513
634
|
|
|
635
|
+
function bareGraphLabel(value) {
|
|
636
|
+
return String(value || "").replace(/\s*\(.*$/, "").replace(/[()]/g, "").trim();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function handlerNodeEvidence(endpoint, graph) {
|
|
640
|
+
const handler = /^[A-Za-z_$][\w$]{0,127}$/.test(String(endpoint?.handler || "")) ? String(endpoint.handler) : null;
|
|
641
|
+
if (!handler) return { handler: null, handlerNodeId: null, handlerResolution: "inline-or-unresolved" };
|
|
642
|
+
const nodes = graph?.nodes || [];
|
|
643
|
+
const matches = nodes.filter((node) =>
|
|
644
|
+
normalizeFile(node?.source_file) && bareGraphLabel(node?.label) === handler && String(node?.id || "") !== normalizeFile(node?.source_file));
|
|
645
|
+
const endpointFile = normalizeFile(endpoint.file);
|
|
646
|
+
const sameFile = matches.filter((node) => normalizeFile(node?.source_file) === endpointFile);
|
|
647
|
+
const byId = new Map(nodes.map((node) => [endpointId(node?.id), node]));
|
|
648
|
+
const linkFile = (value) => {
|
|
649
|
+
const id = endpointId(value);
|
|
650
|
+
return normalizeFile(byId.get(id)?.source_file || String(id || "").split("#")[0]);
|
|
651
|
+
};
|
|
652
|
+
const directlyImportedFiles = new Set((graph?.links || [])
|
|
653
|
+
.filter((link) => ["imports", "re_exports"].includes(link?.relation) && linkFile(link?.source) === endpointFile)
|
|
654
|
+
.map((link) => linkFile(link?.target))
|
|
655
|
+
.filter(Boolean));
|
|
656
|
+
const imported = matches.filter((node) => directlyImportedFiles.has(normalizeFile(node?.source_file)));
|
|
657
|
+
const resolved = sameFile.length === 1 ? sameFile[0] : imported.length === 1 ? imported[0] : matches.length === 1 ? matches[0] : null;
|
|
658
|
+
return {
|
|
659
|
+
handler,
|
|
660
|
+
handlerNodeId: resolved ? String(resolved.id) : null,
|
|
661
|
+
handlerResolution: resolved ? "resolved" : matches.length > 1 ? "ambiguous" : "unresolved",
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function externalUseLiveness(callsites, handlerEvidence) {
|
|
666
|
+
const proven = callsites.filter((call) => call.match?.confidence === "high" || call.match?.confidence === "medium");
|
|
667
|
+
const possible = callsites.filter((call) => call.match?.confidence === "low");
|
|
668
|
+
const status = proven.length ? "NOT_DEAD_EXTERNAL_USE" : possible.length ? "POSSIBLE_EXTERNAL_USE" : "UNKNOWN";
|
|
669
|
+
const evidence = proven.length ? proven : possible;
|
|
670
|
+
return {
|
|
671
|
+
status,
|
|
672
|
+
subject: handlerEvidence.handlerNodeId ? "handler-node" : handlerEvidence.handler ? "endpoint-handler" : "endpoint",
|
|
673
|
+
canSuppressDeadCandidate: proven.length > 0 && Boolean(handlerEvidence.handlerNodeId),
|
|
674
|
+
staticEvidence: evidence.length,
|
|
675
|
+
consumerRepositories: [...new Set(evidence.map((call) => call.clientRepo))].sort(),
|
|
676
|
+
reason: proven.length
|
|
677
|
+
? "At least one selected external repository has a medium/high-confidence static HTTP contract match."
|
|
678
|
+
: possible.length
|
|
679
|
+
? "Only low-confidence external HTTP contract matches were found; review before suppressing a dead-code candidate."
|
|
680
|
+
: "No selected external repository proved a static caller; absence of evidence is not a dead-code verdict.",
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
514
684
|
// `backends` and `clients` are arrays of {id, repoRoot, codeFiles?, graph?}. Backends may optionally
|
|
515
685
|
// supply a precomputed `endpoints` array; otherwise the shared multi-language endpoint detector is used.
|
|
516
686
|
export function analyzeHttpContracts(input = {}) {
|
|
@@ -535,7 +705,7 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
535
705
|
if (filtered.length > endpointBudget) completeness.push(`${id}: endpoint cap reached`);
|
|
536
706
|
const accepted = filtered.slice(0, endpointBudget);
|
|
537
707
|
endpointBudget -= accepted.length;
|
|
538
|
-
backends.push({ id, endpoints: accepted });
|
|
708
|
+
backends.push({ id, endpoints: accepted, graph: descriptor.graph });
|
|
539
709
|
}
|
|
540
710
|
|
|
541
711
|
const clients = [];
|
|
@@ -546,10 +716,20 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
546
716
|
maxFiles: limits.maxClientFiles,
|
|
547
717
|
maxCalls: limits.maxCallsPerClient,
|
|
548
718
|
clientNames: descriptor.clientNames || input.clientNames,
|
|
719
|
+
wrappers: descriptor.wrappers || input.wrappers,
|
|
720
|
+
autoDiscoverWrappers: descriptor.autoDiscoverWrappers ?? input.autoDiscoverWrappers,
|
|
721
|
+
graph: descriptor.graph,
|
|
549
722
|
includeTests: descriptor.includeTests ?? input.includeTests,
|
|
550
723
|
});
|
|
551
724
|
if (detected.truncated) completeness.push(`${id}: client scan cap reached`);
|
|
552
|
-
|
|
725
|
+
for (const reason of detected.reasons || []) completeness.push(`${id}: ${reason}`);
|
|
726
|
+
clients.push({
|
|
727
|
+
id,
|
|
728
|
+
calls: detected.calls,
|
|
729
|
+
reverse: reverseImports(descriptor.graph),
|
|
730
|
+
filesScanned: detected.filesScanned,
|
|
731
|
+
wrapperDiscovery: detected.discovery,
|
|
732
|
+
});
|
|
553
733
|
}
|
|
554
734
|
|
|
555
735
|
const results = [];
|
|
@@ -581,20 +761,24 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
581
761
|
method: call.method,
|
|
582
762
|
path: call.path,
|
|
583
763
|
dynamic: call.dynamic,
|
|
764
|
+
detector: call.detector,
|
|
765
|
+
wrapper: call.wrapper,
|
|
584
766
|
match,
|
|
585
767
|
});
|
|
586
768
|
}
|
|
587
769
|
}
|
|
588
770
|
callsites.sort((left, right) => right.match.score - left.match.score || left.clientRepo.localeCompare(right.clientRepo) || left.file.localeCompare(right.file) || left.line - right.line);
|
|
771
|
+
const handlerEvidence = handlerNodeEvidence(endpoint, backend.graph);
|
|
589
772
|
results.push({
|
|
590
773
|
backend: backend.id,
|
|
591
774
|
method: endpoint.method,
|
|
592
775
|
path: endpoint.path,
|
|
593
776
|
normalizedPath: normalizeHttpContractPath(endpoint.path),
|
|
594
|
-
|
|
777
|
+
...handlerEvidence,
|
|
595
778
|
file: normalizeFile(endpoint.file) || null,
|
|
596
779
|
line: Number(endpoint.line) || null,
|
|
597
780
|
callsites,
|
|
781
|
+
liveness: externalUseLiveness(callsites, handlerEvidence),
|
|
598
782
|
affected: affectedForEndpoint(callsites, clients, limits),
|
|
599
783
|
});
|
|
600
784
|
}
|
|
@@ -626,7 +810,12 @@ export function analyzeHttpContracts(input = {}) {
|
|
|
626
810
|
matches,
|
|
627
811
|
methodMismatches,
|
|
628
812
|
uncertainCalls: uncertainAll.length,
|
|
813
|
+
notDeadExternalUse: results.filter((endpoint) => endpoint.liveness.status === "NOT_DEAD_EXTERNAL_USE").length,
|
|
814
|
+
notDeadExternalHandlers: results.filter((endpoint) => endpoint.liveness.canSuppressDeadCandidate).length,
|
|
815
|
+
possibleExternalUse: results.filter((endpoint) => endpoint.liveness.status === "POSSIBLE_EXTERNAL_USE").length,
|
|
816
|
+
unknownLiveness: results.filter((endpoint) => endpoint.liveness.status === "UNKNOWN").length,
|
|
629
817
|
},
|
|
818
|
+
wrapperDiscovery: clients.map((client) => ({ clientRepo: client.id, ...client.wrapperDiscovery })),
|
|
630
819
|
endpoints: results,
|
|
631
820
|
uncertain: uncertainAll.slice(0, limits.maxUncertain),
|
|
632
821
|
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Versioned origin classification for graph edges. `confidence` remains a compatibility field;
|
|
2
|
+
// provenance says how an edge was established and leaves room for a bounded LSP precision overlay.
|
|
3
|
+
export const EDGE_PROVENANCE_V = 1;
|
|
4
|
+
export const EDGE_PROVENANCE_KINDS = Object.freeze([
|
|
5
|
+
"EXACT_LSP", "EXTRACTED", "RESOLVED", "INFERRED", "CONFLICT",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
const ALLOWED = new Set(EDGE_PROVENANCE_KINDS);
|
|
9
|
+
|
|
10
|
+
export function edgeProvenance(edge) {
|
|
11
|
+
const explicit = String(edge?.provenance || "").trim().toUpperCase();
|
|
12
|
+
if (ALLOWED.has(explicit)) return explicit;
|
|
13
|
+
if (edge?.semanticOrigin === true) return "RESOLVED";
|
|
14
|
+
const legacy = String(edge?.confidence || "").trim().toUpperCase();
|
|
15
|
+
return ALLOWED.has(legacy) ? legacy : "UNKNOWN";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stampEdgeProvenance(links) {
|
|
19
|
+
for (const link of Array.isArray(links) ? links : []) {
|
|
20
|
+
const provenance = edgeProvenance(link);
|
|
21
|
+
if (provenance !== "UNKNOWN") link.provenance = provenance;
|
|
22
|
+
}
|
|
23
|
+
return links;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function summarizeEdgeProvenance(links) {
|
|
27
|
+
const counts = Object.fromEntries([...EDGE_PROVENANCE_KINDS, "UNKNOWN"].map((kind) => [kind, 0]));
|
|
28
|
+
for (const link of Array.isArray(links) ? links : []) counts[edgeProvenance(link)] += 1;
|
|
29
|
+
const total = Array.isArray(links) ? links.length : 0;
|
|
30
|
+
return {version: EDGE_PROVENANCE_V, total, classified: total - counts.UNKNOWN, complete: counts.UNKNOWN === 0, counts};
|
|
31
|
+
}
|
|
@@ -9,7 +9,7 @@ import {childProcessEnv} from '../child-env.js'
|
|
|
9
9
|
import {createRepoBoundary} from '../repo-path.js'
|
|
10
10
|
|
|
11
11
|
export const REPOSITORY_FRESHNESS_PROBE_V = 1
|
|
12
|
-
export const GRAPH_BUILDER_SCHEMA_V =
|
|
12
|
+
export const GRAPH_BUILDER_SCHEMA_V = 2
|
|
13
13
|
export const GRAPH_BUILDER_VERSION = (() => {
|
|
14
14
|
try { return String(createRequire(import.meta.url)('../../package.json').version) }
|
|
15
15
|
catch { return '0.0.0' }
|
|
@@ -21,6 +21,7 @@ export const GRAPH_BUILDER_VERSION = (() => {
|
|
|
21
21
|
const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
22
22
|
extImportsV: 2,
|
|
23
23
|
edgeTypesV: 2,
|
|
24
|
+
edgeProvenanceV: 1,
|
|
24
25
|
complexityV: 1,
|
|
25
26
|
repoBoundaryV: 1,
|
|
26
27
|
barrelResolutionV: 1,
|
|
@@ -162,7 +162,7 @@ function mergeScopedGraph(base, scoped, affected, snapshot) {
|
|
|
162
162
|
controlHashes: snapshot.controlHashes,
|
|
163
163
|
graphRevision: snapshot.revision,
|
|
164
164
|
};
|
|
165
|
-
for (const key of ["extImportsV", "edgeTypesV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
|
|
165
|
+
for (const key of ["extImportsV", "edgeTypesV", "edgeProvenanceV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
|
|
166
166
|
merged[key] = Math.max(Number(base[key]) || 0, Number(scoped[key]) || 0);
|
|
167
167
|
}
|
|
168
168
|
return merged;
|
|
@@ -183,7 +183,7 @@ export async function refreshGraphIncrementally(repoDir, existingGraph, {
|
|
|
183
183
|
|
|
184
184
|
if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
|
|
185
185
|
|| !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
|
|
186
|
-
|| Number(existingGraph.extractorSchemaV) < 1) {
|
|
186
|
+
|| Number(existingGraph.extractorSchemaV) < 1 || Number(existingGraph.edgeProvenanceV) < 1) {
|
|
187
187
|
return full("incremental-baseline-unavailable");
|
|
188
188
|
}
|
|
189
189
|
if (!sameRecord(existingGraph.controlHashes, snapshot.controlHashes)) return full("ignore-or-control-config-changed");
|
|
@@ -12,6 +12,7 @@ import { addJavaReferences } from "./internal-builder.java.js";
|
|
|
12
12
|
import { assignDeterministicCommunities } from "./community.js";
|
|
13
13
|
import { resolveJsBarrels } from "./internal-builder.barrels.js";
|
|
14
14
|
import { snapshotRepository } from "./incremental-refresh.js";
|
|
15
|
+
import { EDGE_PROVENANCE_V, stampEdgeProvenance } from "./edge-provenance.js";
|
|
15
16
|
|
|
16
17
|
// Parse a repo directory into a graph-builder-compatible { nodes, links } graph.
|
|
17
18
|
export async function buildInternalGraph(repoDir, opts = {}) {
|
|
@@ -128,6 +129,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
128
129
|
target: tgt,
|
|
129
130
|
relation: meta.relation || "imports",
|
|
130
131
|
confidence: "EXTRACTED",
|
|
132
|
+
provenance: meta.provenance || "RESOLVED",
|
|
131
133
|
...(typeof meta.typeOnly === "boolean" ? { typeOnly: meta.typeOnly } : {}),
|
|
132
134
|
...(meta.compileOnly === true ? { compileOnly: true } : {}),
|
|
133
135
|
...(meta.line ? { line: meta.line } : {}),
|
|
@@ -317,6 +319,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
317
319
|
// community = folder bucket (top 2 path parts) — deterministic, mirrors the folder-based module grouping the
|
|
318
320
|
// app already uses (graph-builder-analysis.js). Populates Modules/community cards without a heavy clustering pass.
|
|
319
321
|
assignDeterministicCommunities(nodes);
|
|
322
|
+
stampEdgeProvenance(links);
|
|
320
323
|
|
|
321
324
|
// extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
|
|
322
325
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
@@ -328,6 +331,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
328
331
|
externalImports,
|
|
329
332
|
extImportsV: 2,
|
|
330
333
|
edgeTypesV: 2,
|
|
334
|
+
edgeProvenanceV: EDGE_PROVENANCE_V,
|
|
331
335
|
complexityV: 1,
|
|
332
336
|
repoBoundaryV: 1,
|
|
333
337
|
barrelResolutionV: 1,
|
|
@@ -248,6 +248,21 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
248
248
|
};
|
|
249
249
|
|
|
250
250
|
const JS_EXTS = ["", ".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs", ".json", "/index.js", "/index.ts", "/index.jsx", "/index.tsx"];
|
|
251
|
+
const resolveJsBase = (base) => {
|
|
252
|
+
for (const extension of JS_EXTS) {
|
|
253
|
+
const candidate = (base + extension).replace(/\/+/g, "/");
|
|
254
|
+
if (fileSet.has(candidate)) return candidate;
|
|
255
|
+
}
|
|
256
|
+
// TypeScript NodeNext source commonly imports the emitted extension (`./http.js`) while the
|
|
257
|
+
// repository contains `http.ts`/`http.tsx`. Exact runtime files win above; a source counterpart
|
|
258
|
+
// is accepted only when unique so same-basename ambiguity is never guessed.
|
|
259
|
+
const sourceCandidates = [];
|
|
260
|
+
if (/\.js$/i.test(base)) sourceCandidates.push(base.replace(/\.js$/i, ".ts"), base.replace(/\.js$/i, ".tsx"));
|
|
261
|
+
else if (/\.jsx$/i.test(base)) sourceCandidates.push(base.replace(/\.jsx$/i, ".tsx"));
|
|
262
|
+
const existing = [...new Set(sourceCandidates.map((candidate) => candidate.replace(/\/+/g, "/")))]
|
|
263
|
+
.filter((candidate) => fileSet.has(candidate));
|
|
264
|
+
return existing.length === 1 ? existing[0] : null;
|
|
265
|
+
};
|
|
251
266
|
const resolveJsImport = (fromRel, spec) => {
|
|
252
267
|
if (!spec) return null;
|
|
253
268
|
let base;
|
|
@@ -258,13 +273,13 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
258
273
|
// baseUrl-rooted internal import ("components/Button" with baseUrl:"src") — try before calling it an npm package
|
|
259
274
|
for (const ctx of contextsForFile(fromRel)) for (const b of ctx.baseUrls) {
|
|
260
275
|
const root = (b ? b + "/" : "") + spec;
|
|
261
|
-
|
|
276
|
+
const resolved = resolveJsBase(root);
|
|
277
|
+
if (resolved) return resolved;
|
|
262
278
|
}
|
|
263
279
|
return null; // genuinely bare → npm package (stays unresolved here)
|
|
264
280
|
}
|
|
265
281
|
}
|
|
266
|
-
|
|
267
|
-
return null;
|
|
282
|
+
return resolveJsBase(base);
|
|
268
283
|
};
|
|
269
284
|
|
|
270
285
|
const resolvePyPath = (baseDir, parts) => {
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.m
|
|
|
30
30
|
|
|
31
31
|
function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
32
32
|
const tools = [
|
|
33
|
-
{cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, confidence
|
|
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
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)},
|
|
@@ -39,7 +39,49 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
|
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)},
|
|
40
40
|
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
41
41
|
{cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
|
|
42
|
-
{
|
|
42
|
+
{
|
|
43
|
+
cap: 'crossrepo',
|
|
44
|
+
name: 'trace_api_contract',
|
|
45
|
+
description: 'Cross-repository HTTP contract, handler-liveness and blast-radius evidence. Select a registered backend and one or more registered clients; joins backend endpoints to built-in, configured, or conservatively auto-discovered HTTP wrappers. Medium/high-confidence external matches mark a handler NOT_DEAD_EXTERNAL_USE; no match remains UNKNOWN. Repository paths stay local and cannot be supplied through this tool.',
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'},
|
|
50
|
+
clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'},
|
|
51
|
+
method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
|
|
52
|
+
path: {type: 'string', maxLength: 2048, description: 'Optional full route or segment-aligned route fragment; /query matches /edgeAnalytics/query/... and {id}, :id and concrete parameter values are normalized'},
|
|
53
|
+
changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'},
|
|
54
|
+
client_names: {type: 'array', items: {type: 'string', pattern: '^[A-Za-z_$][\\w$]{0,127}$'}, maxItems: 40, uniqueItems: true, description: 'Extra object-style clients whose .get/.post/... methods perform HTTP requests; persistent per-repo configuration belongs in .weavatrix.json httpContracts.clientNames'},
|
|
55
|
+
client_wrappers: {
|
|
56
|
+
type: 'array', maxItems: 100,
|
|
57
|
+
description: 'Fixed-method wrapper calls. Use call+method for get(url), or object+member+method for transport.send(url). url_argument is zero-based.',
|
|
58
|
+
items: {
|
|
59
|
+
type: 'object', additionalProperties: false,
|
|
60
|
+
properties: {
|
|
61
|
+
call: {type: 'string', pattern: '^[A-Za-z_$][\\w$]{0,127}$'},
|
|
62
|
+
object: {type: 'string', pattern: '^[A-Za-z_$][\\w$]{0,127}$'},
|
|
63
|
+
member: {type: 'string', pattern: '^[A-Za-z_$][\\w$]{0,127}$'},
|
|
64
|
+
method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
|
|
65
|
+
url_argument: {type: 'integer', minimum: 0, maximum: 5, default: 0},
|
|
66
|
+
},
|
|
67
|
+
anyOf: [
|
|
68
|
+
{required: ['call', 'method'], not: {anyOf: [{required: ['object']}, {required: ['member']}]}},
|
|
69
|
+
{required: ['object', 'member', 'method'], not: {required: ['call']}},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
auto_discover_wrappers: {type: 'boolean', default: true, description: 'Discover only simple unambiguous functions that forward a URL parameter directly to a known object-style HTTP client'},
|
|
74
|
+
include_tests: {type: 'boolean', default: false},
|
|
75
|
+
max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2},
|
|
76
|
+
max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250},
|
|
77
|
+
max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000},
|
|
78
|
+
max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100},
|
|
79
|
+
top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10},
|
|
80
|
+
},
|
|
81
|
+
required: ['backend', 'clients'],
|
|
82
|
+
},
|
|
83
|
+
run: (g, a, ctx) => tc.tTraceApiContract(g, a, ctx),
|
|
84
|
+
},
|
|
43
85
|
{cap: 'graph', name: 'get_community', description: 'Get all nodes in a community by community ID (0-indexed by size).', inputSchema: {type: 'object', properties: {community_id: {type: 'integer', description: 'Community ID (0-indexed by size)'}}, required: ['community_id']}, run: (g, a) => tg.tGetCommunity(g, a)},
|
|
44
86
|
{cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
|
|
45
87
|
{cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
|
|
@@ -8,6 +8,7 @@ import {spawnSync} from 'node:child_process'
|
|
|
8
8
|
import {childProcessEnv} from '../child-env.js'
|
|
9
9
|
import {resolveRepoPath} from '../repo-path.js'
|
|
10
10
|
import {isStructuralRelation} from '../graph/relations.js'
|
|
11
|
+
import {edgeProvenance} from '../graph/edge-provenance.js'
|
|
11
12
|
|
|
12
13
|
// ---- graph load + indexes -----------------------------------------------------------------------
|
|
13
14
|
export function loadGraph(path) {
|
|
@@ -36,6 +37,7 @@ export function loadGraph(path) {
|
|
|
36
37
|
const metadata = {
|
|
37
38
|
relation: e.relation,
|
|
38
39
|
confidence: e.confidence,
|
|
40
|
+
provenance: edgeProvenance(e),
|
|
39
41
|
...(e.typeOnly === true ? {typeOnly: true} : {}),
|
|
40
42
|
...(e.compileOnly === true ? {compileOnly: true} : {}),
|
|
41
43
|
...(Number.isInteger(e.line) ? {line: e.line} : {}),
|
|
@@ -51,6 +53,7 @@ export function loadGraph(path) {
|
|
|
51
53
|
nodes, links, byId, byLabel, out, inn,
|
|
52
54
|
repoBoundaryV: Number(raw.repoBoundaryV) || 0,
|
|
53
55
|
edgeTypesV: Number(raw.edgeTypesV) || 0,
|
|
56
|
+
edgeProvenanceV: Number(raw.edgeProvenanceV) || 0,
|
|
54
57
|
barrelResolutionV: Number(raw.barrelResolutionV) || 0,
|
|
55
58
|
extractorSchemaV: Number(raw.extractorSchemaV) || 0,
|
|
56
59
|
extImportsV: Number(raw.extImportsV) || 0,
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Versioned, explicit wire schema for sync_graph. Never forward graph.json wholesale: it is a local
|
|
2
2
|
// cache file and may contain future fields or attacker-injected data that are not safe to upload.
|
|
3
3
|
import {sanitizeEvidenceSnapshot} from './sync-evidence.mjs';
|
|
4
|
+
import {edgeProvenance} from '../graph/edge-provenance.js';
|
|
4
5
|
|
|
5
6
|
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
6
7
|
const ABSOLUTE_PATH_FRAGMENT = /(?:^|[\/\s"'`(=])[a-z]:[\\/]|(?:^|[\s"'`(=])(?:\\\\[^\\/\s]+(?:[\\/]|$)|file:(?:\/\/)?[\\/]|\/(?!\/)[^\s])/i;
|
|
@@ -202,6 +203,8 @@ function sanitizeLinkV3(value) {
|
|
|
202
203
|
if (safe) out[key] = safe;
|
|
203
204
|
else delete out[key];
|
|
204
205
|
}
|
|
206
|
+
const provenance = edgeProvenance(value);
|
|
207
|
+
if (provenance !== 'UNKNOWN') out.provenance = provenance;
|
|
205
208
|
const specifier = privacySafeText(value.specifier, 1024);
|
|
206
209
|
if (specifier) out.specifier = specifier;
|
|
207
210
|
else delete out.specifier;
|
|
@@ -265,6 +268,9 @@ export function createSyncPayloadV3(raw, evidence) {
|
|
|
265
268
|
// Reuse the v2 schema gates, but construct v3 arrays independently so the stricter path/identity
|
|
266
269
|
// rules cannot change graph-only compatibility for existing endpoints.
|
|
267
270
|
const base = createSyncPayload(raw);
|
|
271
|
+
if (!Number.isInteger(raw.edgeProvenanceV) || raw.edgeProvenanceV < 1) {
|
|
272
|
+
throw new Error('graph predates edge provenance metadata');
|
|
273
|
+
}
|
|
268
274
|
assertArrayLimit(raw, 'nodes', MAX_SYNC_NODES);
|
|
269
275
|
assertArrayLimit(raw, 'links', MAX_SYNC_LINKS);
|
|
270
276
|
assertArrayLimit(raw, 'externalImports', MAX_SYNC_EXTERNAL_IMPORTS);
|
|
@@ -285,6 +291,7 @@ export function createSyncPayloadV3(raw, evidence) {
|
|
|
285
291
|
syncPayloadV: 3,
|
|
286
292
|
repoBoundaryV: base.repoBoundaryV,
|
|
287
293
|
edgeTypesV: base.edgeTypesV,
|
|
294
|
+
edgeProvenanceV: Number.isInteger(raw.edgeProvenanceV) ? raw.edgeProvenanceV : 0,
|
|
288
295
|
extImportsV: base.extImportsV,
|
|
289
296
|
complexityV: base.complexityV,
|
|
290
297
|
evidenceV: 1,
|
|
@@ -41,6 +41,7 @@ export async function tRebuildGraph(g, args, ctx) {
|
|
|
41
41
|
const before = g?.nodes ? {
|
|
42
42
|
nodes: g.nodes, links: g.links,
|
|
43
43
|
edgeTypesV: g.edgeTypesV || 0,
|
|
44
|
+
edgeProvenanceV: g.edgeProvenanceV || 0,
|
|
44
45
|
barrelResolutionV: g.barrelResolutionV || 0,
|
|
45
46
|
extractorSchemaV: g.extractorSchemaV || 0,
|
|
46
47
|
} : null
|
|
@@ -73,6 +74,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
73
74
|
try {
|
|
74
75
|
const saved = JSON.parse(readFileSync(graphPath, 'utf8'))
|
|
75
76
|
upgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
|
|
77
|
+
|| !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
|
|
76
78
|
} catch {
|
|
77
79
|
upgrade = true
|
|
78
80
|
}
|
|
@@ -80,7 +82,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
80
82
|
if (!existsSync(graphPath) || upgrade) {
|
|
81
83
|
if (args.build === false) {
|
|
82
84
|
return upgrade
|
|
83
|
-
? `The existing graph for ${repoPath} predates
|
|
85
|
+
? `The existing graph for ${repoPath} predates current typed-edge/provenance metadata. Re-call without build:false to upgrade it before switching.`
|
|
84
86
|
: `No graph yet for ${repoPath} (expected at ${graphPath}). Re-call without build:false to build one — large repos can take minutes.`
|
|
85
87
|
}
|
|
86
88
|
const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
|
|
@@ -99,7 +101,7 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
99
101
|
return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
|
|
100
102
|
}
|
|
101
103
|
registerRepository({repoPath, graphDir: graphOutDirForRepo(repoPath), graphHome: graphHomeDir()})
|
|
102
|
-
const buildNote = built ? (upgrade ? ' (graph upgraded to edge metadata
|
|
104
|
+
const buildNote = built ? (upgrade ? ' (graph upgraded to current edge metadata)' : ' (graph built fresh)') : ''
|
|
103
105
|
return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
|
|
104
106
|
}
|
|
105
107
|
|
|
@@ -9,6 +9,7 @@ import {liveRepositoryRecords} from '../graph/repo-registry.js'
|
|
|
9
9
|
import {loadGraph} from './graph-context.mjs'
|
|
10
10
|
import {toolResult} from './tool-result.mjs'
|
|
11
11
|
|
|
12
|
+
const CROSS_REPO_HTTP_CONTRACT_V = 2
|
|
12
13
|
const selectorText = (value) => String(value ?? '').trim()
|
|
13
14
|
|
|
14
15
|
function selectRecord(records, selector) {
|
|
@@ -120,6 +121,10 @@ function verdictFor(analysis) {
|
|
|
120
121
|
affectedScreens: affectedScreens.size,
|
|
121
122
|
methodMismatches: analysis.totals.methodMismatches,
|
|
122
123
|
uncertainCalls: analysis.totals.uncertainCalls,
|
|
124
|
+
notDeadExternalUse: analysis.totals.notDeadExternalUse,
|
|
125
|
+
notDeadExternalHandlers: analysis.totals.notDeadExternalHandlers,
|
|
126
|
+
possibleExternalUse: analysis.totals.possibleExternalUse,
|
|
127
|
+
unknownLiveness: analysis.totals.unknownLiveness,
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
|
|
@@ -188,7 +193,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
188
193
|
return toolResult(
|
|
189
194
|
`VERDICT PARTIAL — selected repository graphs could not all be refreshed and loaded.\nCompleteness: partial — ${reasons.join('; ')}.`,
|
|
190
195
|
{
|
|
191
|
-
crossRepoHttpContractV:
|
|
196
|
+
crossRepoHttpContractV: CROSS_REPO_HTTP_CONTRACT_V,
|
|
192
197
|
status: 'PARTIAL',
|
|
193
198
|
repositories: {
|
|
194
199
|
backend: publicRecord(backend, backendAlias),
|
|
@@ -209,6 +214,9 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
209
214
|
path: args.path,
|
|
210
215
|
changedFiles: args.changed_files,
|
|
211
216
|
includeTests: args.include_tests === true,
|
|
217
|
+
clientNames: args.client_names,
|
|
218
|
+
wrappers: args.client_wrappers,
|
|
219
|
+
autoDiscoverWrappers: args.auto_discover_wrappers !== false,
|
|
212
220
|
maxImpactDepth: args.max_impact_depth,
|
|
213
221
|
maxEndpoints: args.max_endpoints,
|
|
214
222
|
maxMatches: args.max_matches,
|
|
@@ -236,7 +244,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
236
244
|
const screens = endpoint.affected.screens.slice(0, 3)
|
|
237
245
|
.map((screen) => ` screen ${screen.client}:${screen.file} (distance ${screen.distance})`)
|
|
238
246
|
return [
|
|
239
|
-
` ${endpoint.method} ${endpoint.path}${location} → ${endpoint.callsites.length} callsite(s), ${endpoint.affected.screens.length} screen(s), ${endpoint.affected.files.length} affected file(s)`,
|
|
247
|
+
` ${endpoint.method} ${endpoint.path}${location} [${endpoint.liveness.status}${endpoint.handler ? `; handler ${endpoint.handler}` : ''}] → ${endpoint.callsites.length} callsite(s), ${endpoint.affected.screens.length} screen(s), ${endpoint.affected.files.length} affected file(s)`,
|
|
240
248
|
...callsites,
|
|
241
249
|
...screens,
|
|
242
250
|
]
|
|
@@ -246,7 +254,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
246
254
|
: `Completeness: partial — ${completeness.reasons.join('; ')}.`,
|
|
247
255
|
]
|
|
248
256
|
const result = {
|
|
249
|
-
crossRepoHttpContractV:
|
|
257
|
+
crossRepoHttpContractV: CROSS_REPO_HTTP_CONTRACT_V,
|
|
250
258
|
verdict,
|
|
251
259
|
repositories: {
|
|
252
260
|
backend: publicRecord(backend, backendAlias),
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors,
|
|
7
7
|
graphStaleness, fileStalenessNote,
|
|
8
8
|
} from './graph-context.mjs'
|
|
9
|
+
import {summarizeEdgeProvenance} from '../graph/edge-provenance.js'
|
|
9
10
|
|
|
10
11
|
const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
|
|
11
12
|
|
|
@@ -34,15 +35,17 @@ export function tGraphStats(g, ctx) {
|
|
|
34
35
|
.map(([k, v]) => `${k}: ${v}`)
|
|
35
36
|
.join(', ')
|
|
36
37
|
const freshness = ctx ? graphStaleness(ctx) : null
|
|
38
|
+
const provenance = summarizeEdgeProvenance(g.links)
|
|
37
39
|
return [
|
|
38
40
|
`Graph summary`,
|
|
39
41
|
ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
|
|
40
42
|
`- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
|
|
41
43
|
`- Edges: ${g.links.length}`,
|
|
42
44
|
g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
|
|
45
|
+
g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${fmt(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : `- Edge provenance: unavailable (rebuild_graph required)`,
|
|
43
46
|
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)`,
|
|
44
47
|
`- Relations: ${fmt(relCount)}`,
|
|
45
|
-
Object.keys(confCount).length ? `-
|
|
48
|
+
Object.keys(confCount).length ? `- Legacy confidence: ${fmt(confCount)}` : null,
|
|
46
49
|
`- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
|
|
47
50
|
freshness?.builtAt ? `- Built: ${freshness.builtAt.toISOString()}${freshness.headAt ? ` (repo HEAD committed ${freshness.headAt.toISOString()})` : ''}` : null,
|
|
48
51
|
]
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -112,6 +112,7 @@ export async function tGraphDiff(g, args, ctx) {
|
|
|
112
112
|
nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
|
|
113
113
|
links: (graph.links || []).filter((l) => String(edgeEndpoint(l.source)).startsWith(filter) || String(edgeEndpoint(l.target)).startsWith(filter)),
|
|
114
114
|
edgeTypesV: graph.edgeTypesV || 0,
|
|
115
|
+
edgeProvenanceV: graph.edgeProvenanceV || 0,
|
|
115
116
|
barrelResolutionV: graph.barrelResolutionV || 0,
|
|
116
117
|
extractorSchemaV: graph.extractorSchemaV || 0,
|
|
117
118
|
} : graph
|