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 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` call also upgrades graphs created before `0.1.4` to edge metadata v2; `build:false`
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 built before `0.1.4` must be rebuilt once before syncing; V3 also refuses a stale graph so
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 # node --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
- Design rule: **no source file exceeds 300 lines.** Larger concerns split into dotted-suffix modules
351
- behind a slim facade (`foo.js` re-exports `foo.parse.js`, `foo.report.js`, …); the MCP layer lives
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
- - **Hosted graph view** on [weavatrix.com](https://weavatrix.com) sync with one tool call, share
358
- the interactive map with your team
359
- - **Graph-anchored memory** agent notes pinned to nodes/modules, staleness-tracked as the code
360
- under them changes
361
- - **CI blast radius** `change_impact` as a PR comment
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.1",
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. Treat
121
- remaining unresolved/dynamic URLs as incomplete evidence, not a clean result.
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 built before `0.1.4`, or one stale against
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 = 1;
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 parseUrlArgument(text, openParen, constants) {
239
- let start = openParen + 1;
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([...DEFAULT_CLIENT_NAMES, ...(Array.isArray(values) ? values : [])]
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, { clientNames: options.clientNames, maxCalls: remaining });
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
- return { calls, truncated, filesScanned };
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
- clients.push({ id, calls: detected.calls, reverse: reverseImports(descriptor.graph), filesScanned: detected.filesScanned });
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
- handler: /^[A-Za-z_$][\w$]{0,127}$/.test(String(endpoint.handler || "")) ? String(endpoint.handler) : null,
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 = 1
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
- for (const e of JS_EXTS) { const cand = (root + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
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
- for (const e of JS_EXTS) { const cand = (base + e).replace(/\/+/g, "/"); if (fileSet.has(cand)) return cand; }
267
- return null;
282
+ return resolveJsBase(base);
268
283
  };
269
284
 
270
285
  const resolvePyPath = (baseDir, parts) => {
@@ -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 breakdown, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
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
- {cap: 'crossrepo', name: 'trace_api_contract', description: 'Cross-repository HTTP contract and blast-radius evidence. Select a registered backend and one or more registered clients by stable repository UUID or unambiguous label; joins backend endpoints to static client calls and follows client importers to affected screens. Repository paths stay local and cannot be supplied through this tool. This capability is intentionally absent from the pinned profile.', inputSchema: {type: 'object', properties: {backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, 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'}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'}, include_tests: {type: 'boolean', default: false}, max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2}, max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250}, max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}, required: ['backend', 'clients']}, run: (g, a, ctx) => tc.tTraceApiContract(g, a, ctx)},
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,
@@ -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 compile-only edge metadata (edge schema v2). Re-call without build:false to upgrade it before switching.`
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 v2)' : ' (graph built fresh)') : ''
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: 1,
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: 1,
257
+ crossRepoHttpContractV: CROSS_REPO_HTTP_CONTRACT_V,
250
258
  verdict,
251
259
  repositories: {
252
260
  backend: publicRecord(backend, backendAlias),
@@ -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 ? `- Confidence: ${fmt(confCount)}` : null,
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
  ]
@@ -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