weavatrix 0.3.8 → 0.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +17 -0
  2. package/docs/releases/v0.3.10.md +76 -0
  3. package/docs/releases/v0.3.9.md +36 -0
  4. package/package.json +4 -2
  5. package/scripts/run-agent-task-benchmark.mjs +0 -1
  6. package/skill/SKILL.md +13 -6
  7. package/src/analysis/change-classification.js +9 -19
  8. package/src/analysis/dead-check.js +8 -6
  9. package/src/analysis/dead-code-review.js +3 -0
  10. package/src/analysis/dep-rules.js +1 -1
  11. package/src/analysis/git-history/collector.js +1 -43
  12. package/src/analysis/git-history.js +2 -1
  13. package/src/analysis/git-ref-graph.js +4 -10
  14. package/src/analysis/hot-path-review.js +1 -1
  15. package/src/analysis/http-contracts/analysis.js +9 -1
  16. package/src/analysis/internal-audit/repo-files.js +2 -6
  17. package/src/analysis/structure/findings.js +21 -1
  18. package/src/analysis/task-retrieval.js +2 -0
  19. package/src/build-graph.js +9 -1
  20. package/src/git-exec.js +67 -0
  21. package/src/graph/builder/lang-rust.js +36 -0
  22. package/src/graph/freshness-probe.js +3 -6
  23. package/src/graph/graph-filter.js +5 -3
  24. package/src/graph/incremental-refresh.js +1 -1
  25. package/src/graph/internal-builder.build.js +2 -1
  26. package/src/graph/internal-builder.langs.js +11 -24
  27. package/src/mcp/actions/graph-lifecycle.mjs +3 -3
  28. package/src/mcp/catalog.mjs +2 -2
  29. package/src/mcp/evidence-snapshot.structure.mjs +5 -2
  30. package/src/mcp/git-output.mjs +2 -5
  31. package/src/mcp/graph/context-seeds.mjs +1 -0
  32. package/src/mcp/graph/context-state.mjs +4 -5
  33. package/src/mcp/graph/tools-query.mjs +28 -2
  34. package/src/mcp/health/audit-format.mjs +2 -5
  35. package/src/mcp/health/endpoints.mjs +34 -5
  36. package/src/mcp/sync/payload-v2.mjs +1 -0
  37. package/src/mcp/tools-company.mjs +9 -0
  38. package/src/mcp/tools-endpoints.mjs +54 -12
  39. package/src/mcp/tools-graph-hubs.mjs +1 -0
  40. package/src/mcp/tools-impact-change.mjs +2 -3
  41. package/src/mcp/tools-impact.mjs +9 -1
  42. package/src/mcp-rg.mjs +2 -4
  43. package/src/mcp-source-tools.mjs +4 -5
  44. package/src/precision/lsp-overlay/build.js +9 -4
  45. package/src/precision/lsp-overlay/contract.js +1 -1
  46. package/src/precision/lsp-overlay/target-index.js +8 -3
  47. package/src/precision/symbol-query.js +2 -1
  48. package/src/process.js +18 -1
package/README.md CHANGED
@@ -131,6 +131,18 @@ Every finding is review evidence, never an auto-delete verdict: `find_dead_code`
131
131
  `run_audit category=unused` always return `REVIEW_REQUIRED` with `autoDelete:false`. Typecheck, tests
132
132
  and runtime checks remain the release authority.
133
133
 
134
+ ## Always-fresh graph
135
+
136
+ There is no watcher daemon to run and no manual refresh step: every graph/health call reconciles the
137
+ graph before answering. A Git-token freshness probe (HEAD + dirty/untracked content, debounced 2 s)
138
+ decides whether anything changed; when it did, a bounded incremental refresh reparses only the changed
139
+ files plus their reverse importers (≤ 24 changed / ≤ 80 reparsed JS/TS files) and merges the scoped
140
+ result into the previous graph under a file lock. Config/lockfile edits, export-surface changes,
141
+ barrel files and non-JS/TS languages fall back to a full rebuild — correctness always wins over speed.
142
+ Each refreshed answer carries a structured `refresh` record (`none` / `incremental` / `full`, changed
143
+ file count), so an agent can tell exactly which repository state it is reasoning about. The same
144
+ guarantees hold across concurrent MCP clients sharing one canonical graph.
145
+
134
146
  ## Benchmarks
135
147
 
136
148
  Two gates ship in the repository:
@@ -178,6 +190,11 @@ JavaScript · TypeScript · TSX · Python · Go · Java · C# · Rust · HTML ·
178
190
  [web-tree-sitter](https://github.com/tree-sitter/tree-sitter) WASM grammars; no Python install and no
179
191
  native compilation.
180
192
 
193
+ Test surfaces are classified per file (path conventions plus `.weavatrix.json` overrides) and, for
194
+ Rust, per symbol: `#[cfg(test)]` modules and `#[test]`/`#[bench]` items inside production `.rs` files
195
+ carry a node-level `test_surface` flag, so dead-code, query, hot-path and hub tools treat them as
196
+ tests rather than production code.
197
+
181
198
  ## Development
182
199
 
183
200
  ```sh
@@ -0,0 +1,76 @@
1
+ # Weavatrix 0.3.10
2
+
3
+ 0.3.10 closes the honesty gaps a production evaluation surfaced: Rust inline
4
+ tests are no longer reported as production code, endpoint tools are
5
+ production-first everywhere, and semantic-precision statuses say exactly how
6
+ much was actually proven. Every fix landed with regression tests and survived
7
+ an adversarial multi-agent review of the full changeset.
8
+
9
+ ## Node-level Rust test classification
10
+
11
+ Rust puts unit tests inside production files, so path classification alone
12
+ misreported them everywhere. The extractor now proves test-only compilation —
13
+ `#[cfg(test)]` / `#[cfg(all(test, ...))]` on items, impl blocks and modules,
14
+ `#[test]`/`#[tokio::test]`-style harness attributes and `#[bench]` functions,
15
+ inner `#![cfg(test)]` attributes at file and module-body scope, and attributes
16
+ separated from their item by comments — and stamps a `test_surface` flag on the
17
+ node. Deliberately conservative: `#[cfg(any(test, ...))]` also ships in
18
+ production builds and stays unclassified.
19
+
20
+ Every consumer honors the flag like a path-classified test: `find_dead_code`
21
+ (candidates and test-only consumer resolution), `query_graph` seeds and
22
+ traversal, `hot_path_review`, `god_nodes`, `verified_change` task anchors,
23
+ `no-tests`/`tests-only` build modes, hosted sync payloads and evidence
24
+ snapshots. A test-term question or `include_tests`/`include_classified` opts
25
+ back in. `extractorSchemaV` 5 → 6; cached graphs rebuild once, automatically.
26
+
27
+ ## Production-first endpoint tools
28
+
29
+ - `list_endpoints` suppresses endpoints declared in classified
30
+ test/e2e/generated/mock/story/docs/benchmark/temp paths by default, with a
31
+ count note and `include_classified` opt-in (auto-enabled on tests-only
32
+ graphs). Opted-in rows carry `[classified:...]` tags.
33
+ - `trace_endpoint` filters classified handler twins before its ambiguity
34
+ check, ranks remaining same-named candidates by the import binding at the
35
+ route file (below same-directory proximity — a file-level edge proves
36
+ coupling, not identity), and reports `importBoundAtRoute`/`pathClasses` per
37
+ candidate when it still fails closed.
38
+ - New `handler_file` parameter resolves genuinely symmetric candidates: it
39
+ narrows the full candidate pool before scoring, prefers exact repo-relative
40
+ matches over path-suffix matches, and compares case-insensitively.
41
+
42
+ ## Honest semantic-precision statuses
43
+
44
+ `COMPLETE` previously meant only "the bounded request finished", producing
45
+ `COMPLETE (0 EXACT_LSP edges)` on Rust repositories and `COMPLETE; exact
46
+ absence was not proven` in `get_dependents`. Now:
47
+
48
+ - A new `NONE` state covers the zero-eligible-targets outcome; `UNAVAILABLE`
49
+ stays reserved for provider failure. `PRECISION_OVERLAY_V` 4 → 5 discards
50
+ old-vocabulary sidecars.
51
+ - `COMPLETE` requires every selected target actually probed.
52
+ - `open_repo` and the build log print the `graph_stats`-style coverage
53
+ fraction: `PARTIAL — 32/1373 bounded target(s) queried (truncated)`.
54
+ - `get_dependents` never pairs a state word with a contradicting note, and
55
+ keeps `NONE`/`UNAVAILABLE` truthful instead of collapsing them into
56
+ `PARTIAL`.
57
+
58
+ ## Output fidelity
59
+
60
+ - `trace_api_contract` records per-endpoint method mismatches with sample
61
+ callers and always shows a mismatch block, so `*_WITH_METHOD_MISMATCHES`
62
+ verdicts stay backed by visible rows under any `top_n`.
63
+ - Structure findings and hosted evidence snapshots stop reporting idiomatic
64
+ Rust `mod.rs`/`lib.rs`/`main.rs` parent-child module-tree cycles as
65
+ compile-time coupling (counted as `rustModuleTreeCouplings`; genuine
66
+ cross-directory cycles still report).
67
+ - `query_graph` edge lines and `shortest_path` hops widen repeated or generic
68
+ file basenames (`mod.rs`, `index.*`, `__init__.py`) to their last two path
69
+ segments.
70
+
71
+ ## Documentation
72
+
73
+ The always-fresh graph contract — debounced Git freshness probe plus bounded
74
+ incremental refresh on every graph/health call, no watcher process required —
75
+ is now documented in the README and the agent skill, together with the Rust
76
+ test-surface classification rules.
@@ -0,0 +1,36 @@
1
+ # Weavatrix 0.3.9
2
+
3
+ 0.3.9 consolidates every subprocess launch behind a small number of audited
4
+ chokepoints. Tool behavior and output are unchanged — verified byte-identical
5
+ against 0.3.8 on real repositories.
6
+
7
+ ## Single-chokepoint subprocess execution
8
+
9
+ Git and other subprocess launches were scattered across roughly fifteen modules,
10
+ each with its own near-identical spawn wrapper. They now route through two
11
+ focused modules:
12
+
13
+ - **git** — every git command runs through `src/git-exec.js`: `runGit` for
14
+ synchronous run-and-collect calls and `boundedGitCommand` for the streaming
15
+ history collector.
16
+ - **everything else** — ripgrep resolution and search, `tar` archive extraction,
17
+ and `where`/`which` binary probing run through `src/process.js`: the existing
18
+ async `runCommand` plus a new synchronous `runCommandSync`.
19
+
20
+ Every launch now applies the same credential-stripped child environment,
21
+ `windowsHide`, bounded timeout, and `-C <repoRoot>`/`cwd` containment in one
22
+ place. The number of modules importing `node:child_process` drops from fifteen
23
+ to four — the two runners plus the long-lived language-server client — removing
24
+ duplicated wrappers and making the whole process surface auditable from a single
25
+ point.
26
+
27
+ This is a pure refactor: graph construction, change impact, Health, history and
28
+ search produce identical results. A byte-level comparison of the entire graph
29
+ (every node, edge and external import, hashed with SHA-256) against the published
30
+ 0.3.8 matched exactly on the analytics, controller-rest-api and Weavatrix
31
+ repositories.
32
+
33
+ ## Minor
34
+
35
+ Removed two redundant module imports (`node:perf_hooks`, `node:process`) that
36
+ re-imported values already available as Node globals.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "type": "module",
5
- "description": "Local repository intelligence MCP for AI coding agents: reusable architecture graph for fast application understanding, Health, dead code, clones, history, blast radius and architecture safeguards.",
5
+ "description": "Local repository intelligence MCP for AI coding agents: an always-fresh architecture graph with production-first evidence blast radius, dead code, endpoints, clones, history, Health and architecture safeguards, with honestly labeled precision.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
7
7
  "license": "MIT",
8
8
  "homepage": "https://weavatrix.com",
@@ -42,6 +42,8 @@
42
42
  "docs/releases/v0.3.6.md",
43
43
  "docs/releases/v0.3.7.md",
44
44
  "docs/releases/v0.3.8.md",
45
+ "docs/releases/v0.3.9.md",
46
+ "docs/releases/v0.3.10.md",
45
47
  "docs/releases/v0.2.19.md",
46
48
  "README.md",
47
49
  "SECURITY.md",
@@ -1,6 +1,5 @@
1
1
  import {readFileSync} from 'node:fs'
2
2
  import {pathToFileURL} from 'node:url'
3
- import {performance} from 'node:perf_hooks'
4
3
  import {retrieveTaskContext} from '../src/analysis/task-retrieval.js'
5
4
 
6
5
  function graphFixture() {
package/skill/SKILL.md CHANGED
@@ -142,12 +142,15 @@ interface dispatch, reflection, or runtime behavior.
142
142
  - **Evidence, not verdicts**: treat audit, hub, orphan and duplicate output as hypotheses. Confirm a
143
143
  finding in source and check framework/runtime conventions before deleting, merging or redesigning
144
144
  code. A same-name/different-body pair is a divergence candidate, not proof of duplication.
145
- - **Freshness**: graph/health calls automatically reconcile the active graph, while cross-repository
146
- tracing reconciles every selected registered graph. Read the structured `refresh` /
147
- `graphReconciliation` status: `none`, `incremental`, `full`, or explicitly `PARTIAL`. Use
148
- `rebuild_graph` only when automatic reconciliation reports a fallback/error or when intentionally
149
- changing build mode. A normal `open_repo` builds missing graphs and upgrades legacy schemas;
150
- `build:false` deliberately refuses that upgrade.
145
+ - **Freshness**: the graph is always fresh at answer time no watcher process and no manual refresh
146
+ step exists or is needed. Every graph/health call runs a debounced Git freshness probe and, when the
147
+ repository changed, refreshes before answering (bounded incremental reparse of changed files plus
148
+ reverse importers for JS/TS; full rebuild for config/export-surface/barrel changes and other
149
+ languages). Cross-repository tracing reconciles every selected registered graph the same way. Read
150
+ the structured `refresh` / `graphReconciliation` status: `none`, `incremental`, `full`, or
151
+ explicitly `PARTIAL`. Use `rebuild_graph` only when automatic reconciliation reports a
152
+ fallback/error or when intentionally changing build mode. A normal `open_repo` builds missing graphs
153
+ and upgrades legacy schemas; `build:false` deliberately refuses that upgrade.
151
154
  - **Ambiguity**: `get_node`/`get_neighbors`/`get_dependents` disclose `matched N nodes; using the
152
155
  best-connected` — read that note before trusting the answer; pass an exact node id to pin it.
153
156
  - **Runtime versus compile time**: keep runtime cycles separate from TypeScript type-only and
@@ -169,6 +172,10 @@ interface dispatch, reflection, or runtime behavior.
169
172
  roots. Dead-code/clone/audit review also suppresses `benchmarks/**` and `**/__temp/**` as classified
170
173
  non-production surfaces. A verified production benchmark can opt back in narrowly through
171
174
  `.weavatrix.json` `classify.product`, for example `{"classify":{"product":["benchmarks/core/**"]}}`.
175
+ Rust inline tests (`#[cfg(test)]` modules, `#[test]`/`#[bench]` items) are classified per symbol via
176
+ the node-level `test_surface` flag even though they live in production `.rs` files; dead-code,
177
+ query, hot-path and hub tools treat them like path-classified tests (`include_tests` /
178
+ `include_classified` and a test-term question opt back in).
172
179
  - **Architectural queries**: broad bootstrap/tool-execution/routing questions rank conventional
173
180
  production and graph-declared entry points ahead of classified docs, sites, benchmarks and
174
181
  fixtures. Production-first classification also applies during traversal. A class term in the
@@ -1,6 +1,5 @@
1
1
  // Symbol-aware git-diff classification for change_impact.
2
- import {spawnSync} from 'node:child_process'
3
- import {childProcessEnv} from '../child-env.js'
2
+ import {runGit} from '../git-exec.js'
4
3
  import {createPathClassifier} from '../path-classification.js'
5
4
  import {parseZeroContextDiff} from './change-classification/diff-parser.js'
6
5
  import {
@@ -33,13 +32,10 @@ function recoverBoundedDiff(repoRoot, base, files, limits) {
33
32
  fallbackFiles.push(...files.slice(index))
34
33
  break
35
34
  }
36
- const result = spawnSync('git', [
37
- '-C', repoRoot, 'diff', '--no-ext-diff', '--find-renames', '--no-color',
35
+ const result = runGit(repoRoot, [
36
+ 'diff', '--no-ext-diff', '--find-renames', '--no-color',
38
37
  '--unified=0', String(base), '--', files[index],
39
- ], {
40
- encoding: 'utf8', windowsHide: true, timeout: Math.min(2_000, remainingMs),
41
- maxBuffer: remainingBytes + 1, env: childProcessEnv(),
42
- })
38
+ ], {timeout: Math.min(2_000, remainingMs), maxBuffer: remainingBytes + 1})
43
39
  const chunk = String(result.stdout || '')
44
40
  if (result.status !== 0 || !chunk || Buffer.byteLength(chunk) > remainingBytes) {
45
41
  fallbackFiles.push(files[index])
@@ -51,11 +47,8 @@ function recoverBoundedDiff(repoRoot, base, files, limits) {
51
47
  }
52
48
 
53
49
  function runGitDiff(repoRoot, base, limits) {
54
- const args = ['-C', repoRoot, 'diff', '--no-ext-diff', '--find-renames', '--no-color', '--unified=0', String(base), '--']
55
- const result = spawnSync('git', args, {
56
- encoding: 'utf8', windowsHide: true, timeout: 12_000,
57
- maxBuffer: limits.maxDiffBytes + 1, env: childProcessEnv(),
58
- })
50
+ const args = ['diff', '--no-ext-diff', '--find-renames', '--no-color', '--unified=0', String(base), '--']
51
+ const result = runGit(repoRoot, args, {timeout: 12_000, maxBuffer: limits.maxDiffBytes + 1})
59
52
  const output = String(result.stdout || '')
60
53
  const oversized = result.error?.code === 'ENOBUFS' || Buffer.byteLength(output) > limits.maxDiffBytes
61
54
  if (result.status === 0 && !oversized) return {available: true, text: output, error: null}
@@ -63,12 +56,9 @@ function runGitDiff(repoRoot, base, limits) {
63
56
  let fallbackTruncated = false
64
57
  let recoveredText = ''
65
58
  if (oversized) {
66
- const names = spawnSync('git', [
67
- '-C', repoRoot, 'diff', '--name-only', '-z', '--no-ext-diff', '--find-renames', String(base), '--',
68
- ], {
69
- encoding: 'utf8', windowsHide: true, timeout: 12_000,
70
- maxBuffer: Math.max(64 * 1024, limits.maxFiles * 4_096), env: childProcessEnv(),
71
- })
59
+ const names = runGit(repoRoot, [
60
+ 'diff', '--name-only', '-z', '--no-ext-diff', '--find-renames', String(base), '--',
61
+ ], {timeout: 12_000, maxBuffer: Math.max(64 * 1024, limits.maxFiles * 4_096)})
72
62
  if (names.status === 0) {
73
63
  const all = String(names.stdout || '').split('\0').map(normalizeChangePath).filter(Boolean)
74
64
  const boundedFiles = [...new Set(all)].sort((a, b) => a.localeCompare(b)).slice(0, limits.maxFiles)
@@ -201,7 +201,8 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
201
201
  const deadSymbols = [];
202
202
  for (const n of symById.values()) {
203
203
  if (isReferenced(n)) continue;
204
- const test = isTestFile(n.source_file);
204
+ // test_surface: extractor-proven test-only symbols (Rust #[cfg(test)]) live in production paths.
205
+ const test = isTestFile(n.source_file) || n.test_surface === true;
205
206
  deadSymbols.push({ id: n.id, file: n.source_file, label: n.label, test, reason: "no inbound edge and name unreferenced outside its file" });
206
207
  }
207
208
  const deadSet = new Set(deadSymbols.map((s) => s.id));
@@ -209,12 +210,13 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
209
210
  // A production declaration consumed only by tests is live to the raw graph but dead to production.
210
211
  // Keep it in a separate review class: this is useful evidence, never an automatic delete verdict.
211
212
  const testOnlySymbols = [];
213
+ const consumerFileOf = (id) => nodesById.get(id)?.source_file || (id.includes("#") ? id.split("#")[0] : id);
214
+ const isTestConsumer = (id) => nodesById.get(id)?.test_surface === true || isTestFile(consumerFileOf(id));
212
215
  for (const n of symById.values()) {
213
- if (deadSet.has(n.id) || isTestFile(n.source_file) || isDecorated(n)) continue;
216
+ if (deadSet.has(n.id) || isTestFile(n.source_file) || n.test_surface === true || isDecorated(n)) continue;
214
217
  const sourcesForSymbol = inboundSources.get(String(n.id)) || [];
215
- const sourceFiles = sourcesForSymbol.map((id) => nodesById.get(id)?.source_file || (id.includes("#") ? id.split("#")[0] : id));
216
- const hasTestInbound = sourceFiles.some((file) => isTestFile(file));
217
- const hasProductionInbound = sourceFiles.some((file) => file && !isTestFile(file));
218
+ const hasTestInbound = sourcesForSymbol.some(isTestConsumer);
219
+ const hasProductionInbound = sourcesForSymbol.some((id) => consumerFileOf(id) && !isTestConsumer(id));
218
220
  const name = bareName(n.label);
219
221
  const occurrenceSet = occurrenceFiles.get(name) || new Set();
220
222
  const externalOccurrences = [...occurrenceSet].filter((file) => file !== n.source_file);
@@ -232,7 +234,7 @@ export function computeDead(graph, sources, { entrySet = new Set() } = {}) {
232
234
  label: n.label,
233
235
  test: false,
234
236
  reason: "referenced only from test/e2e code; no production consumer was found",
235
- testConsumerFiles: [...new Set(sourceFiles.filter((file) => file && isTestFile(file)))].sort(),
237
+ testConsumerFiles: [...new Set(sourcesForSymbol.filter(isTestConsumer).map(consumerFileOf).filter(Boolean))].sort(),
236
238
  evidence: hasTestInbound ? "graph" : hasExactTestInbound ? "exact-semantic" : "lexical",
237
239
  publicApi: n.exported === true || ["public", "protected"].includes(String(n.visibility || "").toLowerCase()),
238
240
  });
@@ -51,6 +51,9 @@ export function computeDeadCodeReview(graph, sources, options = {}) {
51
51
  const suppressed = { tests: 0, classified: 0, confidence: 0, path: 0, kind: 0 };
52
52
  const candidates = [];
53
53
  for (const candidate of raw) {
54
+ // Node-level test surfaces (Rust #[cfg(test)] symbols in production files) follow the same
55
+ // include_tests policy as path-classified test files.
56
+ if (!includeTests && nodesById.get(String(candidate.id))?.test_surface === true) { suppressed.tests += 1; continue; }
54
57
  const info = classify(candidate.file, sources.get(candidate.file));
55
58
  const allowed = pathAllowed(info, { includeTests, includeClassified });
56
59
  if (!allowed.ok) { suppressed[allowed.bucket] += 1; continue; }
@@ -7,4 +7,4 @@ export {
7
7
  globToRe,
8
8
  representativeCycle,
9
9
  } from './structure/dependency-graph.js'
10
- export {computeStructureFindings} from './structure/findings.js'
10
+ export {computeStructureFindings, isRustModuleTreeComponent} from './structure/findings.js'
@@ -1,4 +1,3 @@
1
- import {spawn} from 'node:child_process'
2
1
  import {isWeavatrixIgnored} from '../../path-ignore.js'
3
2
  import {
4
3
  boundedHistoryInteger,
@@ -10,48 +9,7 @@ import {
10
9
  const HEADER_SEPARATOR = '\x1e'
11
10
  const FIELD_SEPARATOR = '\x1f'
12
11
 
13
- export function boundedGitCommand(command, args, options) {
14
- return new Promise((resolve, reject) => {
15
- const child = spawn(command, args, {
16
- cwd: options.cwd, env: options.env, shell: false, windowsHide: true,
17
- stdio: ['ignore', 'pipe', 'pipe'],
18
- })
19
- const stdout = [], stderr = []
20
- let stdoutBytes = 0, stderrBytes = 0, truncated = false, timedOut = false, settled = false
21
- const finish = (callback) => {
22
- if (settled) return
23
- settled = true
24
- clearTimeout(timer)
25
- callback()
26
- }
27
- const stop = () => { try { child.kill('SIGKILL') } catch { /* process already exited */ } }
28
- const timer = setTimeout(() => { timedOut = true; stop() }, options.timeoutMs)
29
- child.stdout?.on('data', (chunk) => {
30
- if (truncated) return
31
- const remaining = options.maxOutputBytes - stdoutBytes
32
- if (remaining <= 0) { truncated = true; stop(); return }
33
- const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining)
34
- stdout.push(kept); stdoutBytes += kept.length
35
- if (kept.length !== chunk.length) { truncated = true; stop() }
36
- })
37
- child.stderr?.on('data', (chunk) => {
38
- const remaining = 64 * 1024 - stderrBytes
39
- if (remaining <= 0) return
40
- const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining)
41
- stderr.push(kept); stderrBytes += kept.length
42
- })
43
- child.on('error', (error) => finish(() => reject(error)))
44
- child.on('close', (exitCode) => finish(() => {
45
- if (timedOut) return reject(new Error('git history collection timed out'))
46
- resolve({
47
- stdout: Buffer.concat(stdout),
48
- stderr: Buffer.concat(stderr).toString('utf8'),
49
- exitCode: Number(exitCode ?? 1),
50
- truncated,
51
- })
52
- }))
53
- })
54
- }
12
+ // git subprocess execution (sync runGit + streaming boundedGitCommand) lives in ../../git-exec.js.
55
13
 
56
14
  const statNumber = (value) => value === '-'
57
15
  ? {value: 0, binary: true}
@@ -3,7 +3,8 @@ import {childProcessEnv} from '../child-env.js'
3
3
  import {createRepoBoundary} from '../repo-path.js'
4
4
  import {loadWeavatrixIgnore} from '../path-ignore.js'
5
5
  import {buildGitHistoryAnalytics} from './git-history/analytics.js'
6
- import {boundedGitCommand, parseGitNumstatLog} from './git-history/collector.js'
6
+ import {parseGitNumstatLog} from './git-history/collector.js'
7
+ import {boundedGitCommand} from '../git-exec.js'
7
8
  import {
8
9
  boundedHistoryInteger,
9
10
  GIT_FORMAT,
@@ -4,18 +4,15 @@
4
4
  import {mkdtempSync, mkdirSync, rmSync} from 'node:fs'
5
5
  import {join} from 'node:path'
6
6
  import {tmpdir} from 'node:os'
7
- import {spawnSync} from 'node:child_process'
8
- import {childProcessEnv} from '../child-env.js'
7
+ import {runGit} from '../git-exec.js'
8
+ import {runCommandSync} from '../process.js'
9
9
  import {buildInternalGraph} from '../graph/internal-builder.js'
10
10
  import {filterGraphForMode} from '../graph/graph-filter.js'
11
11
 
12
12
  const SAFE_REF = /^(?!-)[A-Za-z0-9][A-Za-z0-9._\/@{}+~^-]{0,199}$/
13
13
 
14
14
  function git(repoRoot, args, timeout = 15_000) {
15
- return spawnSync('git', ['-C', repoRoot, ...args], {
16
- encoding: 'utf8', timeout, env: childProcessEnv(), windowsHide: true,
17
- maxBuffer: 4 * 1024 * 1024,
18
- })
15
+ return runGit(repoRoot, args, {timeout, maxBuffer: 4 * 1024 * 1024})
19
16
  }
20
17
 
21
18
  export function resolveGitCommit(repoRoot, requestedRef) {
@@ -41,10 +38,7 @@ export async function withGitRefCheckout(repoRoot, requestedRef, operation) {
41
38
  if (archived.status !== 0) {
42
39
  return {ok: false, error: `git archive failed for ${resolved.ref}: ${String(archived.stderr || '').trim() || 'unknown error'}`}
43
40
  }
44
- const extracted = spawnSync('tar', ['-xf', archive, '-C', checkout], {
45
- encoding: 'utf8', timeout: 60_000, env: childProcessEnv(), windowsHide: true,
46
- maxBuffer: 4 * 1024 * 1024,
47
- })
41
+ const extracted = runCommandSync('tar', ['-xf', archive, '-C', checkout], {timeout: 60_000, maxBuffer: 4 * 1024 * 1024})
48
42
  if (extracted.status !== 0) {
49
43
  return {ok: false, error: `temporary Git archive extraction failed: ${String(extracted.stderr || '').trim() || 'tar unavailable'}`}
50
44
  }
@@ -128,7 +128,7 @@ export function computeHotPathReview(graph, options = {}) {
128
128
  if (!complexity || !file || !String(node?.id || '').includes('#')) continue
129
129
  if (scope.path && file !== scope.path && !file.startsWith(`${scope.path}/`)) { excluded.outOfScope++; continue }
130
130
  const classification = classifier.explain(file)
131
- const test = hasPathClass(classification, 'test', 'e2e')
131
+ const test = node?.test_surface === true || hasPathClass(classification, 'test', 'e2e')
132
132
  const classified = classification.excluded || hasPathClass(classification, ...NON_PRODUCT)
133
133
  if (test && options.includeTests !== true) { excluded.tests++; continue }
134
134
  if (classified && options.includeClassified !== true) { excluded.classified++; continue }
@@ -85,12 +85,18 @@ export function analyzeHttpContracts(input = {}) {
85
85
  for (const backend of backends) {
86
86
  for (const endpoint of backend.endpoints) {
87
87
  const callsites = [];
88
+ let endpointMismatches = 0;
89
+ const methodMismatchSites = [];
88
90
  for (const client of clients) {
89
91
  for (const call of client.calls) {
90
92
  if (call.path && !methodMatches(endpoint.method, call.method)) {
91
93
  const expected = pathSegments(normalizeHttpContractPath(endpoint.path));
92
94
  const actual = pathSegments(call.path);
93
- if (routeShapeMatches(expected, actual) || suffixShapeMatch(expected, actual)) methodMismatches++;
95
+ if (routeShapeMatches(expected, actual) || suffixShapeMatch(expected, actual)) {
96
+ methodMismatches++;
97
+ endpointMismatches++;
98
+ if (methodMismatchSites.length < 3) methodMismatchSites.push({ clientRepo: client.id, file: call.file, line: call.line, method: call.method });
99
+ }
94
100
  continue;
95
101
  }
96
102
  const match = matchHttpContract(endpoint, call);
@@ -124,6 +130,8 @@ export function analyzeHttpContracts(input = {}) {
124
130
  file: normalizeContractFile(endpoint.file) || null,
125
131
  line: Number(endpoint.line) || null,
126
132
  callsites,
133
+ methodMismatches: endpointMismatches,
134
+ methodMismatchSites,
127
135
  liveness: externalUseLiveness(callsites, handlerEvidence),
128
136
  affected: affectedForEndpoint(callsites, clients, limits),
129
137
  });
@@ -1,7 +1,6 @@
1
1
  import {readFileSync, readdirSync} from 'node:fs'
2
2
  import {join} from 'node:path'
3
- import {spawnSync} from 'node:child_process'
4
- import {childProcessEnv} from '../../child-env.js'
3
+ import {runGit} from '../../git-exec.js'
5
4
  import {filterWeavatrixIgnored} from '../../path-ignore.js'
6
5
 
7
6
  export const readText = (path) => {
@@ -29,10 +28,7 @@ const SKIP_DIRS = new Set([
29
28
 
30
29
  export function listRepoFiles(repoRoot) {
31
30
  try {
32
- const result = spawnSync('git', ['-C', repoRoot, 'ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
33
- encoding: 'utf8', windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
34
- env: childProcessEnv(),
35
- })
31
+ const result = runGit(repoRoot, ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {timeout: 15_000, maxBuffer: 32 * 1024 * 1024})
36
32
  if (result.status === 0) {
37
33
  const files = String(result.stdout || '').split('\0').filter(Boolean).map((file) => file.replace(/\\/g, '/'))
38
34
  return filterWeavatrixIgnored(repoRoot, files)
@@ -63,6 +63,23 @@ function compileTimeFinding(adjacency, component, runtimeComponents, edges) {
63
63
  })
64
64
  }
65
65
 
66
+ // Idiomatic Rust module trees close compile-time SCCs by construction: the parent (mod.rs, lib.rs,
67
+ // main.rs, or a 2018-edition foo.rs) declares `mod child` while children reach back via super::/crate::.
68
+ // Suppress only when every member is a .rs file sitting under one anchor's directory; genuine
69
+ // cross-directory .rs cycles keep their findings.
70
+ export function isRustModuleTreeComponent(component) {
71
+ if (!component.every((file) => String(file).endsWith('.rs'))) return false
72
+ return component.some((anchor) => {
73
+ const path = String(anchor)
74
+ const slash = path.lastIndexOf('/')
75
+ const base = path.slice(slash + 1)
76
+ const anchorDir = ['mod.rs', 'lib.rs', 'main.rs'].includes(base)
77
+ ? (slash >= 0 ? path.slice(0, slash) : '')
78
+ : path.slice(0, -'.rs'.length)
79
+ return anchorDir !== '' && component.every((member) => member === anchor || String(member).startsWith(`${anchorDir}/`))
80
+ })
81
+ }
82
+
66
83
  export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(), externalImportFiles = new Set()} = {}) {
67
84
  const imports = buildFileImportGraph(graph)
68
85
  const findings = []
@@ -73,7 +90,9 @@ export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(
73
90
 
74
91
  const runtimeKeys = new Set(runtimeComponents.map((component) => [...component].sort().join('\0')))
75
92
  const allComponents = findSccs(imports.allAdj).sort((a, b) => b.length - a.length)
76
- const compileTimeCouplings = allComponents.filter((component) => !runtimeKeys.has([...component].sort().join('\0')))
93
+ const compileTimeCandidates = allComponents.filter((component) => !runtimeKeys.has([...component].sort().join('\0')))
94
+ const rustModuleTreeComponents = compileTimeCandidates.filter((component) => isRustModuleTreeComponent(component))
95
+ const compileTimeCouplings = compileTimeCandidates.filter((component) => !isRustModuleTreeComponent(component))
77
96
  for (const component of compileTimeCouplings.slice(0, MAX_CYCLE_FINDINGS)) {
78
97
  findings.push(compileTimeFinding(imports.allAdj, component, runtimeComponents, {
79
98
  runtime: imports.edges,
@@ -135,6 +154,7 @@ export function computeStructureFindings(graph, {rules = {}, entrySet = new Set(
135
154
  largestTypeCoupling: compileTimeCouplings[0]?.length || 0,
136
155
  compileTimeCouplings: compileTimeCouplings.length,
137
156
  largestCompileTimeCoupling: compileTimeCouplings[0]?.length || 0,
157
+ rustModuleTreeCouplings: rustModuleTreeComponents.length,
138
158
  orphans: findings.filter((finding) => finding.rule === 'orphan-file').length,
139
159
  boundaryViolations: violations.length,
140
160
  },
@@ -64,6 +64,8 @@ export function retrieveTaskContext(g, {
64
64
  const pathAllowed = (node) => {
65
65
  const file = fileOf(node)
66
66
  if (!file || changedFiles.has(file) || includeClassified === true) return true
67
+ // Node-level test surfaces (Rust #[cfg(test)]) follow the same production-first policy.
68
+ if (node?.test_surface === true && !requestedClasses.has('test')) return false
67
69
  if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
68
70
  const info = classificationCache.get(file)
69
71
  const classes = PATH_CLASS_NAMES.filter((name) => hasPathClass(info, name))
@@ -31,6 +31,14 @@ export function defaultPrecisionMode(env = process.env) {
31
31
  return env?.WEAVATRIX_PRECISION === "off" ? "off" : "lsp";
32
32
  }
33
33
 
34
+ // One-line human status for a precision summary (precisionSummary shape) — the graph_stats
35
+ // composition, shared by the build log and open_repo so every surface reads the same fraction.
36
+ export function precisionStatusLine(precision) {
37
+ const p = precision || {};
38
+ if (p.state === "NONE") return `Semantic precision: NONE — ${p.reason || "no eligible JavaScript/TypeScript semantic targets"}`;
39
+ return `Semantic precision: ${p.state || "UNAVAILABLE"} — ${p.queried || 0}/${p.candidates || 0} bounded target(s) queried${p.truncated ? " (truncated)" : ""}; ${p.verifiedEdges || 0} EXACT_LSP edge(s)${p.state !== "COMPLETE" && p.reason ? `; ${p.reason}` : ""}`;
40
+ }
41
+
34
42
  function buildGraphInWorker(payload) {
35
43
  return new Promise((resolve, reject) => {
36
44
  let worker;
@@ -193,7 +201,7 @@ export async function buildGraphForRepo(repoPath, {
193
201
  hotspots: built.hotspots,
194
202
  refresh: built.refresh,
195
203
  precision: built.precision,
196
- log: `built-in builder: ${built.nodes} nodes, ${built.links} static links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"}); semantic precision ${built.precision?.state || "UNAVAILABLE"}, ${built.precision?.verifiedEdges || 0} EXACT_LSP edge(s)`
204
+ log: `built-in builder: ${built.nodes} nodes, ${built.links} static links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"}); ${precisionStatusLine(built.precision)}`
197
205
  };
198
206
  } catch (error) {
199
207
  return { ok: false, error: `graph build failed: ${error.message}`, builder: "internal" };
@@ -0,0 +1,67 @@
1
+ // The single chokepoint for launching git. Every git subprocess in the package routes through here,
2
+ // so there is one place that applies `-C <repoRoot>` containment, a credential-stripped child
3
+ // environment, a hidden Windows window and a bounded timeout — instead of a dozen near-identical
4
+ // spawnSync wrappers scattered across the analysis, graph and MCP layers.
5
+ import { spawnSync, spawn } from "node:child_process";
6
+ import { childProcessEnv } from "./child-env.js";
7
+
8
+ // Synchronous git. Returns the raw spawnSync result ({status, stdout, stderr, error}) so callers keep
9
+ // their existing status/stdout/error handling. encoding defaults to "utf8" (pass "buffer" for binary
10
+ // stdout); maxBuffer and stdio are forwarded only when provided so Node's defaults are preserved.
11
+ export function runGit(repoRoot, args, options = {}) {
12
+ const spawnOptions = {
13
+ encoding: options.encoding ?? "utf8",
14
+ windowsHide: true,
15
+ timeout: options.timeout ?? 8000,
16
+ env: childProcessEnv(options.env),
17
+ };
18
+ if (options.maxBuffer != null) spawnOptions.maxBuffer = options.maxBuffer;
19
+ if (options.stdio != null) spawnOptions.stdio = options.stdio;
20
+ return spawnSync("git", ["-C", repoRoot, ...args], spawnOptions);
21
+ }
22
+
23
+ // Streaming git for the history collector: byte-bounded stdout, a hard timeout that SIGKILLs the child,
24
+ // and a resolved {stdout, stderr, exitCode, truncated}. The caller owns cwd/env so reads can be scoped
25
+ // to a specific worktree.
26
+ export function boundedGitCommand(command, args, options) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn(command, args, {
29
+ cwd: options.cwd, env: options.env, shell: false, windowsHide: true,
30
+ stdio: ["ignore", "pipe", "pipe"],
31
+ });
32
+ const stdout = [], stderr = [];
33
+ let stdoutBytes = 0, stderrBytes = 0, truncated = false, timedOut = false, settled = false;
34
+ const finish = (callback) => {
35
+ if (settled) return;
36
+ settled = true;
37
+ clearTimeout(timer);
38
+ callback();
39
+ };
40
+ const stop = () => { try { child.kill("SIGKILL"); } catch { /* process already exited */ } };
41
+ const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs);
42
+ child.stdout?.on("data", (chunk) => {
43
+ if (truncated) return;
44
+ const remaining = options.maxOutputBytes - stdoutBytes;
45
+ if (remaining <= 0) { truncated = true; stop(); return; }
46
+ const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
47
+ stdout.push(kept); stdoutBytes += kept.length;
48
+ if (kept.length !== chunk.length) { truncated = true; stop(); }
49
+ });
50
+ child.stderr?.on("data", (chunk) => {
51
+ const remaining = 64 * 1024 - stderrBytes;
52
+ if (remaining <= 0) return;
53
+ const kept = chunk.length <= remaining ? chunk : chunk.subarray(0, remaining);
54
+ stderr.push(kept); stderrBytes += kept.length;
55
+ });
56
+ child.on("error", (error) => finish(() => reject(error)));
57
+ child.on("close", (exitCode) => finish(() => {
58
+ if (timedOut) return reject(new Error("git history collection timed out"));
59
+ resolve({
60
+ stdout: Buffer.concat(stdout),
61
+ stderr: Buffer.concat(stderr).toString("utf8"),
62
+ exitCode: Number(exitCode ?? 1),
63
+ truncated,
64
+ });
65
+ }));
66
+ });
67
+ }