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 +104 -10
- package/package.json +5 -1
- package/skill/SKILL.md +34 -7
- package/src/analysis/change-classification.js +27 -2
- package/src/analysis/git-history.js +42 -0
- package/src/analysis/http-contract-wrappers.js +232 -0
- package/src/analysis/http-contracts.js +246 -17
- package/src/graph/edge-provenance.js +31 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +2 -2
- package/src/graph/internal-builder.build.js +4 -0
- package/src/graph/internal-builder.resolvers.js +18 -3
- package/src/mcp/catalog.mjs +50 -7
- package/src/mcp/graph-context.mjs +3 -0
- package/src/mcp/sync-payload.mjs +7 -0
- package/src/mcp/tool-result.mjs +9 -6
- package/src/mcp/tools-actions.mjs +4 -2
- package/src/mcp/tools-architecture.mjs +72 -16
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph-hubs.mjs +22 -3
- package/src/mcp/tools-graph.mjs +4 -1
- package/src/mcp/tools-history.mjs +15 -9
- package/src/mcp/tools-impact-change.mjs +1 -1
- package/src/mcp/tools-impact.mjs +1 -0
- package/src/mcp-server.mjs +3 -1
- package/src/path-classification.js +5 -0
package/README.md
CHANGED
|
@@ -95,7 +95,7 @@ No graph yet? Ask the agent to call `rebuild_graph`; it builds the missing graph
|
|
|
95
95
|
With the default `offline` profile (or an explicit capability set containing `retarget`),
|
|
96
96
|
`open_repo` can change the active repository
|
|
97
97
|
and builds a missing graph automatically. A normal
|
|
98
|
-
`open_repo`
|
|
98
|
+
`open_repo` also upgrades graphs that predate current typed-edge/provenance metadata; `build:false`
|
|
99
99
|
probes without building and refuses a legacy graph. Retargeting is offline but intentionally changes
|
|
100
100
|
the filesystem boundary for subsequent tools; select `pinned` when that boundary must not move.
|
|
101
101
|
|
|
@@ -111,6 +111,11 @@ An agent skill with recipes ships in [skill/SKILL.md](skill/SKILL.md) — instal
|
|
|
111
111
|
compile-only edges (Rust module/use, Java imports) are reported separately where that distinction
|
|
112
112
|
changes the result.
|
|
113
113
|
|
|
114
|
+
Every current edge also carries versioned provenance: `EXTRACTED`, `RESOLVED`, or `INFERRED` today,
|
|
115
|
+
with `EXACT_LSP` and `CONFLICT` reserved for the optional precision overlay. `graph_stats` reports
|
|
116
|
+
the complete breakdown; provenance describes how the relationship was established, while the older
|
|
117
|
+
`confidence` field remains for compatibility.
|
|
118
|
+
|
|
114
119
|
**search / source** — `search_code` (ripgrep-backed, pure-Node fallback), `read_source` (a
|
|
115
120
|
symbol's actual code in one hop), `list_endpoints` (HTTP route inventory:
|
|
116
121
|
Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web …)
|
|
@@ -168,6 +173,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
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
- **
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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.
|
|
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`.
|
|
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
|
|
121
|
-
|
|
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.
|
|
124
|
-
|
|
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
|
|
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) =>
|
|
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(
|
|
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
|
+
}
|