weavatrix 0.2.0 → 0.2.2

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,39 @@ 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.2 regression and cross-repository evidence
177
+
178
+ - Permanent TS/JS/Python/Go/Java/Rust regression fixtures now gate graph correctness, output size,
179
+ latency, freshness, reconnect behavior and repository-target stability.
180
+ - Every current graph edge carries versioned `EXTRACTED`, `RESOLVED`, or `INFERRED` provenance;
181
+ `EXACT_LSP` and `CONFLICT` are reserved for the optional precision overlay.
182
+ - `trace_api_contract` recognizes configured and conservatively auto-discovered HTTP wrappers,
183
+ resolves bounded dynamic URL prefixes and can mark an unambiguous backend handler
184
+ `NOT_DEAD_EXTERNAL_USE` when another registered repository supplies medium/high-confidence use.
185
+ - Real-repository verification records explicit `MISSING`, `UNBASELINED`, and `STALE` gaps instead
186
+ of converting absent Java/Rust or source-checkout evidence into a green result.
187
+ - No mandatory runtime dependency was added.
188
+
189
+ Full release notes: [docs/releases/v0.2.2.md](docs/releases/v0.2.2.md).
190
+
191
+ ### 0.2.1 bounded-output patch
192
+
193
+ - `git_history top_n=N` is a hard per-collection MCP cap, including churn, hotspots and every
194
+ coupling list. JSON output reports `total`, `returned` and `truncated` for each collection.
195
+ - `god_nodes` ranks production code by default, excluding classified tests and generated/build
196
+ artifacts; pass `include_classified:true` only when those surfaces are intentionally in scope.
197
+ - `output_format:"text"` returns concise TextContent only. Use `output_format:"json"` when a
198
+ workflow needs the full stable `weavatrix.tool.v1` structured envelope.
199
+ - `trace_api_contract` resolves bounded constant prefixes in template URLs and accepts
200
+ segment-aligned path fragments such as `/query` for `/edgeAnalytics/query/...`.
201
+ - `change_impact` labels test/e2e edits as `test-only` and does not seed product blast radius from
202
+ them.
203
+ - Without a saved architecture contract, `prepare_change` returns concise provisional budgets and
204
+ clearly labels them as non-enforceable; request `get_architecture_contract output_format:"json"`
205
+ only when the inferred starter contract is actually needed.
206
+
207
+ Full patch notes: [docs/releases/v0.2.1.md](docs/releases/v0.2.1.md).
208
+
171
209
  ## Signal quality and repository configuration
172
210
 
173
211
  Weavatrix `0.2.0` reduces the most common sources of static-analysis noise while deepening Rust and
@@ -216,6 +254,34 @@ Java graphs:
216
254
  agent review queue. Public/exported methods, framework entries, decorators, dynamic loading and
217
255
  reflection lower confidence; the result always says `REVIEW_REQUIRED` and `autoDelete:false`.
218
256
 
257
+ ### HTTP clients and wrappers
258
+
259
+ `trace_api_contract` recognizes built-in object clients such as `axios.get(...)`, explicit bare or
260
+ object/member wrappers, and simple auto-discovered functions that forward a URL parameter directly
261
+ to a known HTTP client. Auto-discovered wrappers are restricted to their bounded reverse-import
262
+ scope; ambiguous same-name definitions are skipped and reported as incomplete evidence.
263
+
264
+ Persistent per-client-repository configuration lives in `.weavatrix.json`:
265
+
266
+ ```json
267
+ {
268
+ "httpContracts": {
269
+ "clientNames": ["internalHttp"],
270
+ "wrappers": [
271
+ { "call": "get", "method": "GET", "urlArgument": 0 },
272
+ { "object": "transport", "member": "send", "method": "POST", "urlArgument": 1 }
273
+ ],
274
+ "autoDiscoverWrappers": true
275
+ }
276
+ }
277
+ ```
278
+
279
+ The MCP call exposes the same ad-hoc controls as `client_names`, `client_wrappers` and
280
+ `auto_discover_wrappers`. A medium/high-confidence client match marks the backend endpoint/handler
281
+ `NOT_DEAD_EXTERNAL_USE`; a low-confidence match is `POSSIBLE_EXTERNAL_USE`; no match is `UNKNOWN`.
282
+ Only an unambiguously resolved handler node can suppress a method-level dead-code candidate. The tool
283
+ never turns missing static evidence into a `DEAD` verdict.
284
+
219
285
  `run_audit` makes incomplete security coverage explicit. OSV state is `OK` only after every
220
286
  supported pinned package/version for this repository was queried successfully. `PARTIAL` means
221
287
  some queries failed, the response was incomplete, the dependency fingerprint changed, or the cache
@@ -272,7 +338,8 @@ Weavatrix itself initiates outbound HTTP only from three tools; all are absent f
272
338
  hosted list. The endpoint is **yours**, configured through `WEAVATRIX_SYNC_URL` and the optional
273
339
  `WEAVATRIX_SYNC_TOKEN`; the feature is off by default. Pass `payload_version: 2` only for an
274
340
  intentional graph-only compatibility sync—there is no silent downgrade that discards evidence.
275
- Graphs built before `0.1.4` must be rebuilt once before syncing; V3 also refuses a stale graph so
341
+ Graphs that predate current typed-edge/provenance metadata must be rebuilt once before V3 sync;
342
+ V3 also refuses a stale graph so
276
343
  source-derived evidence cannot be mixed with old topology.
277
344
 
278
345
  Evidence sections carry independent `state` (`COMPLETE`, `PARTIAL`, `NOT_CHECKED`,
@@ -326,21 +393,48 @@ Graphs are derived data and never live inside your repo: the global per-user reg
326
393
 
327
394
  ```sh
328
395
  npm install
329
- npm test # node --test
396
+ npm test # unit/integration tests plus the quick golden benchmark
397
+ npm run benchmark # full TS/JS/Python/Go/Java/Rust + MCP lifecycle gate
398
+ npm run benchmark:real # locally available real repos vs source-free 0.2.1 baselines
330
399
  ```
331
400
 
332
- Design rule: **no source file exceeds 300 lines.** Larger concerns split into dotted-suffix modules
333
- behind a slim facade (`foo.js` re-exports `foo.parse.js`, `foo.report.js`, …); the MCP layer lives
401
+ The benchmark checks representative graph correctness, complete edge provenance, cross-repository
402
+ HTTP tracing, output bytes, latency, freshness, reconnect and active-target stability. Its budgets, semantics and intentionally
403
+ limited claims are documented in [docs/benchmarking.md](docs/benchmarking.md).
404
+
405
+ Refactoring target: keep focused implementation modules near 300 lines and split larger concerns
406
+ into dotted-suffix modules behind a slim facade (`foo.js` re-exports `foo.parse.js`,
407
+ `foo.report.js`, …). A few older orchestration modules remain above that target; new work should
408
+ reduce them instead of growing new monoliths. The MCP layer lives
334
409
  in `src/mcp/` (graph context, tool entry modules, focused helpers, and the catalog/hot-reload
335
410
  loader) behind the thin stdio entry `src/mcp-server.mjs`.
336
411
 
337
412
  ## Roadmap
338
413
 
339
- - **Hosted graph view** on [weavatrix.com](https://weavatrix.com) sync with one tool call, share
340
- the interactive map with your team
341
- - **Graph-anchored memory** agent notes pinned to nodes/modules, staleness-tracked as the code
342
- under them changes
343
- - **CI blast radius** `change_impact` as a PR comment
414
+ - **Public 0.2.2 regression foundation** now has the permanent six-language golden corpus,
415
+ cross-repository wrapper/liveness fixture, framework/convention fixture, full MCP lifecycle gate
416
+ and a portable real-repository runner. Five source-free 0.2.1 real-repository baselines are
417
+ recorded; edge provenance is gated end-to-end. The unavailable Rust source checkout is the only
418
+ explicit local gap preventing the strict six-repository release command from passing here.
419
+ - **Wrapper-aware API contracts** shipped in 0.2.2: persistent/ad-hoc configuration,
420
+ conservative discovery, cross-repository handler liveness and explicit unknown states. The next
421
+ hosted increment joins privacy-safe contract identities across separately synced services.
422
+ - **Hosted architecture workbench** is live at
423
+ [app.weavatrix.com](https://app.weavatrix.com): explicit source-free sync,
424
+ immutable history, Flow/DSM/Map, target architecture and bounded review
425
+ evidence. Hosted use remains optional and owner-authenticated.
426
+ - **Semantic precision bridge** — optional local language-server validation for
427
+ ambiguous impact, reference and dead-code evidence; the current parser graph
428
+ remains the offline fallback.
429
+ - **Git-native architecture history** — bounded tag/ref timelines and branch
430
+ reports built outside the worktree; graph artifacts stay out of Git.
431
+ - **Cross-repository company evidence** — endpoints, events and internal
432
+ packages joined to affected consumers and ownership without uploading source.
433
+ - **CI blast radius** — bounded `change_impact` and architecture-ratchet evidence
434
+ as a PR check/comment.
435
+
436
+ The public alignment note for the fixed cross-product release sequence is in
437
+ [docs/product-roadmap.md](docs/product-roadmap.md).
344
438
 
345
439
  ## License
346
440
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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
@@ -84,7 +89,9 @@ named profiles for new registrations.
84
89
 
85
90
  ## Recipes
86
91
 
87
- - **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`.
92
+ - **Orient in the configured repo**: `module_map` → `list_communities` → `god_nodes`. Hub ranking
93
+ is production-only by default; use `include_classified:true` only when tests/generated/build
94
+ surfaces are deliberately part of the question.
88
95
  - **Refactor safety for one symbol**: `get_dependents` → `coverage_map` (low coverage × many
89
96
  dependents ⇒ write tests first) → `read_source`.
90
97
  - **Pre-PR review of your current changes**: `change_impact` (auto merge-base; includes uncommitted
@@ -114,14 +121,34 @@ named profiles for new registrations.
114
121
  - **API inventory**: `list_endpoints` (including Next.js App Router, Rust axum and actix-web).
115
122
  - **Cross-repository API impact**: ensure both repositories are in `list_known_repos`, then call
116
123
  `trace_api_contract backend=<uuid-or-label> clients=[<uuid-or-label>]`; narrow with `method`, `path`,
117
- or backend `changed_files`. Treat unresolved/dynamic URLs as incomplete evidence, not a clean result.
124
+ or backend `changed_files`. `path` may be a segment-aligned fragment (`/query` can select
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.
118
140
  - **Target architecture before editing**: `get_architecture_contract` → `prepare_change` with the
119
141
  intended files → edit and rebuild → `verify_architecture`. A missing contract returns a starter
120
- proposal, not an automatically approved architecture. Pull an owner-approved hosted contract only
121
- when the user selected `hosted` and explicitly asks for it.
142
+ proposal from `get_architecture_contract output_format:"json"`, not an automatically approved
143
+ architecture. Without a contract, `prepare_change` still returns provisional no-regression
144
+ budgets, but they are guidance rather than enforceable policy. Pull an owner-approved hosted
145
+ contract only when the user selected `hosted` and explicitly asks for it.
122
146
  - **Behavioral architecture**: `git_history` ranks churn × connectivity hotspots and hidden
123
- co-change coupling from bounded local numstat history. Use it as review evidence, not proof that two
124
- files must be merged.
147
+ co-change coupling from bounded local numstat history. Always set `top_n`; it is enforced across
148
+ every returned structured collection and JSON reports per-collection truncation. Use it as review
149
+ evidence, not proof that two files must be merged.
150
+ - **Machine output**: keep the default `output_format:"text"` for concise agent conversations; opt
151
+ into `output_format:"json"` only when a workflow consumes the full `weavatrix.tool.v1` envelope.
125
152
  - **Find code**: `search_code` (regex + glob) → `get_node` → `read_source`.
126
153
  - **Another repo**: `list_known_repos` → `open_repo <path>`
127
154
  (builds or upgrades the graph when needed; `build:false` probes without building).
@@ -161,7 +188,7 @@ runtime/type-only/compile-only module dependencies. Package evidence contains a
161
188
  graph with direct/transitive runtime, dev, optional and peer edges plus explicit resolution counts.
162
189
  Duplicate evidence contains stable, source-free clone/divergence candidates; it never sends method
163
190
  bodies or snippets. Use `payload_version: 2` only when the user explicitly wants graph-only
164
- 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
165
192
  the working tree, must be rebuilt first. Sync remains unavailable until the user selects `hosted`
166
193
  (or the exact `hosted` capability) and configures `WEAVATRIX_SYNC_URL`.
167
194
 
@@ -4,6 +4,7 @@
4
4
  // from flooding the report with every legacy importer of that file.
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { childProcessEnv } from "../child-env.js";
7
+ import { createPathClassifier, hasPathClass } from "../path-classification.js";
7
8
 
8
9
  const DEFAULT_LIMITS = Object.freeze({
9
10
  maxDiffBytes: 2 * 1024 * 1024,
@@ -15,6 +16,7 @@ const DEFAULT_LIMITS = Object.freeze({
15
16
  });
16
17
  const CLASS_RANK = Object.freeze({
17
18
  "metadata-only": 0,
19
+ "test-only": 0,
18
20
  added: 1,
19
21
  "body-changed": 2,
20
22
  "signature-changed": 3,
@@ -395,6 +397,20 @@ function unknownFile(path, indexed, reason) {
395
397
  };
396
398
  }
397
399
 
400
+ function classifyTestSurface(file, pathClassifier) {
401
+ const explanation = pathClassifier.explain(file.path);
402
+ if (!hasPathClass(explanation, "test", "e2e")) return file;
403
+ const surface = explanation.classes.includes("e2e") ? "e2e" : "test";
404
+ return {
405
+ ...file,
406
+ classification: "test-only",
407
+ changeClassification: file.classification,
408
+ reason: `${surface} path; excluded from the product blast-radius seed set`,
409
+ pathClasses: explanation.classes,
410
+ seedIds: [],
411
+ };
412
+ }
413
+
398
414
  function runGitDiff(repoRoot, base, _files, limits) {
399
415
  const args = ["-C", repoRoot, "diff", "--no-ext-diff", "--find-renames", "--no-color", "--unified=0", String(base), "--"];
400
416
  const result = spawnSync("git", args, {
@@ -443,17 +459,25 @@ export function classifyChangeImpact({
443
459
  }
444
460
 
445
461
  const indexed = graphIndex(graph, limits);
462
+ const pathClassifier = createPathClassifier(repoRoot);
446
463
  const parsed = available ? parseZeroContextDiff(text, limits) : { files: [], changedLines: 0, byteLength: 0, truncated: gitOversized, oversized: gitOversized, limits };
447
- const analyzed = parsed.files.map((file) => analyzeParsedFile(file, indexed, { includeAddedSeeds }));
464
+ const analyzed = parsed.files.map((file) => classifyTestSurface(
465
+ analyzeParsedFile(file, indexed, { includeAddedSeeds }),
466
+ pathClassifier,
467
+ ));
448
468
  const represented = new Set(analyzed.flatMap((file) => [file.oldPath, file.newPath].filter(Boolean)));
449
469
  for (const file of explicitFiles) {
450
- if (!represented.has(file)) analyzed.push(unknownFile(file, indexed, available ? "explicitly changed file had no textual hunk" : unavailableReason));
470
+ if (!represented.has(file)) analyzed.push(classifyTestSurface(
471
+ unknownFile(file, indexed, available ? "explicitly changed file had no textual hunk" : unavailableReason),
472
+ pathClassifier,
473
+ ));
451
474
  }
452
475
  if (!available && !explicitFiles.length) analyzed.push(unknownFile("(diff unavailable)", indexed, unavailableReason));
453
476
  analyzed.sort((a, b) => a.path.localeCompare(b.path));
454
477
 
455
478
  if (parsed.truncated || parsed.oversized || gitOversized) {
456
479
  for (const file of analyzed) {
480
+ if (file.classification === "test-only") continue;
457
481
  file.classification = "unknown";
458
482
  file.reason = "diff was truncated/oversized; symbol-level classification is incomplete";
459
483
  const record = indexed.get(file.newPath) || indexed.get(file.oldPath);
@@ -480,6 +504,7 @@ export function classifyChangeImpact({
480
504
  if (counts["body-changed"]) reasons.push(`${counts["body-changed"]} file(s) contain mapped executable body changes.`);
481
505
  if (counts.added && !includeAddedSeeds) reasons.push(`${counts.added} purely additive file change(s) create no dependent seeds by default.`);
482
506
  if (counts["metadata-only"]) reasons.push(`${counts["metadata-only"]} metadata-only file change(s) create no dependent seeds.`);
507
+ if (counts["test-only"]) reasons.push(`${counts["test-only"]} test-only file change(s) are labelled explicitly and create no product blast-radius seeds.`);
483
508
  if (counts.unknown) reasons.push(`${counts.unknown} file change(s) remain unknown and are seeded conservatively.`);
484
509
  if (!reasons.length) reasons.push("No changed files were present in the supplied diff.");
485
510
 
@@ -547,3 +547,45 @@ export function formatGitHistoryAnalytics(result, options = {}) {
547
547
  if (result.completeness.reasons.length) lines.push("", `Partial: ${result.completeness.reasons.join("; ")}.`);
548
548
  return lines.join("\n");
549
549
  }
550
+
551
+ // Keep the full analytics object available to local callers, but never expose its unbounded
552
+ // collections through an MCP response. `topN` is a per-collection ceiling, not merely a text
553
+ // formatting hint. The accompanying page metadata lets machine consumers distinguish "there were
554
+ // no more results" from "more evidence exists but was deliberately omitted".
555
+ export function boundGitHistoryAnalytics(result, options = {}) {
556
+ const topN = boundedInteger(options.topN, 10, 1, 50);
557
+ const source = result && typeof result === "object" ? result : {};
558
+ const coupling = source.coupling && typeof source.coupling === "object" ? source.coupling : {};
559
+ const collections = {};
560
+ const cap = (name, value) => {
561
+ const items = Array.isArray(value) ? value : [];
562
+ const bounded = items.slice(0, topN);
563
+ collections[name] = {
564
+ total: items.length,
565
+ returned: bounded.length,
566
+ truncated: items.length > bounded.length,
567
+ };
568
+ return bounded;
569
+ };
570
+
571
+ const bounded = {
572
+ ...source,
573
+ limits: {...(source.limits || {}), topN},
574
+ fileChurn: cap("fileChurn", source.fileChurn),
575
+ hotspots: cap("hotspots", source.hotspots),
576
+ coupling: {
577
+ ...coupling,
578
+ observed: cap("coupling.observed", coupling.observed),
579
+ expectedTestSource: cap("coupling.expectedTestSource", coupling.expectedTestSource),
580
+ hidden: cap("coupling.hidden", coupling.hidden),
581
+ },
582
+ };
583
+ return {
584
+ result: bounded,
585
+ page: {
586
+ limit: topN,
587
+ truncated: Object.values(collections).some((entry) => entry.truncated),
588
+ collections,
589
+ },
590
+ };
591
+ }
@@ -0,0 +1,232 @@
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
+ const unique = [...new Map(matches.map((item) => [`${item.method}\0${item.urlArgument}`, item])).values()];
185
+ return unique.length === 1 ? unique[0] : null;
186
+ }
187
+
188
+ export function discoverHttpWrappers(sources, clientNames = []) {
189
+ const allowedClients = [...new Set([...DEFAULT_HTTP_CLIENT_NAMES, ...normalizeHttpClientNames(clientNames)])];
190
+ const candidates = [];
191
+ let truncated = false;
192
+ for (const source of Array.isArray(sources) ? sources : []) {
193
+ if (candidates.length >= MAX_WRAPPERS) { truncated = true; break; }
194
+ const mask = codeMask(source.text);
195
+ const definitions = [];
196
+ const functions = /\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(([^)]{0,300})\)\s*(?::[^\n{]{1,200})?\s*\{/g;
197
+ let match;
198
+ while ((match = functions.exec(mask)) && definitions.length < MAX_WRAPPERS) {
199
+ const open = functions.lastIndex - 1;
200
+ definitions.push({ call: match[1], parameters: parameterNames(match[2]), bodyStart: open + 1, bodyEnd: matchingBrace(mask, open) - 1 });
201
+ }
202
+ 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;
203
+ while ((match = arrows.exec(mask)) && definitions.length < MAX_WRAPPERS) {
204
+ const parameters = parameterNames(match[2] ?? match[3]);
205
+ const open = mask.indexOf("{", arrows.lastIndex);
206
+ const block = open >= 0 && /^\s*\{/.test(mask.slice(arrows.lastIndex, open + 1));
207
+ const bodyStart = block ? open + 1 : arrows.lastIndex;
208
+ const bodyEnd = block ? matchingBrace(mask, open) - 1 : Math.min(mask.length, bodyStart + MAX_DISCOVERY_BODY, mask.indexOf(";", bodyStart) >= 0 ? mask.indexOf(";", bodyStart) : mask.length);
209
+ definitions.push({ call: match[1], parameters, bodyStart, bodyEnd });
210
+ }
211
+ for (const definition of definitions) {
212
+ if (!definition.parameters.length) continue;
213
+ const delegated = delegatedWrapper(mask, definition, allowedClients);
214
+ if (delegated && candidates.length < MAX_WRAPPERS) candidates.push({ kind: "function", call: definition.call, ...delegated, source: "auto", definitionFile: source.file });
215
+ else if (delegated) truncated = true;
216
+ }
217
+ }
218
+
219
+ const byCall = new Map();
220
+ for (const candidate of candidates) {
221
+ const entries = byCall.get(candidate.call) || [];
222
+ entries.push(candidate);
223
+ byCall.set(candidate.call, entries);
224
+ }
225
+ const wrappers = [], ambiguous = [];
226
+ for (const [call, entries] of [...byCall.entries()].sort(([left], [right]) => left.localeCompare(right))) {
227
+ const unique = [...new Map(entries.map((entry) => [`${entry.method}\0${entry.urlArgument}\0${entry.definitionFile}`, entry])).values()];
228
+ if (unique.length === 1) wrappers.push(unique[0]);
229
+ else ambiguous.push({ call, definitions: unique.length });
230
+ }
231
+ return { wrappers, ambiguous, truncated };
232
+ }