weavatrix 0.3.8 → 0.3.9

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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "type": "module",
5
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.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
@@ -42,6 +42,7 @@
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",
45
46
  "docs/releases/v0.2.19.md",
46
47
  "README.md",
47
48
  "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() {
@@ -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)
@@ -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
  }
@@ -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)
@@ -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
+ }
@@ -2,10 +2,9 @@
2
2
  // authority when something changed; this exact Git/worktree token is safe to cache in-process and in
3
3
  // versioned graph metadata when HEAD + dirty/untracked/control-file content remain unchanged.
4
4
  import {createHash} from 'node:crypto'
5
- import {spawnSync} from 'node:child_process'
6
5
  import {readFileSync, statSync} from 'node:fs'
7
6
  import {createRequire} from 'node:module'
8
- import {childProcessEnv} from '../child-env.js'
7
+ import {runGit} from '../git-exec.js'
9
8
  import {createRepoBoundary} from '../repo-path.js'
10
9
 
11
10
  const REPOSITORY_FRESHNESS_PROBE_V = 1
@@ -34,9 +33,7 @@ const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
34
33
  const MAX_CONTROL_BYTES = 1_000_000
35
34
 
36
35
  function git(repoRoot, args) {
37
- const result = spawnSync('git', ['-C', repoRoot, ...args], {
38
- encoding: 'buffer', timeout: 8000, windowsHide: true, env: childProcessEnv(), maxBuffer: 16 * 1024 * 1024,
39
- })
36
+ const result = runGit(repoRoot, args, {encoding: 'buffer', maxBuffer: 16 * 1024 * 1024})
40
37
  return result.status === 0 ? Buffer.from(result.stdout || '') : null
41
38
  }
42
39
 
@@ -5,10 +5,9 @@
5
5
  // build works — and Electron main runs Node, so this needs no external runtime.
6
6
  import { readdirSync, statSync, realpathSync } from "node:fs";
7
7
  import { join, extname, dirname } from "node:path";
8
- import { execFileSync } from "node:child_process";
9
8
  import { createRequire } from "node:module";
10
9
  import { isPathInside } from "../repo-path.js";
11
- import { childProcessEnv } from "../child-env.js";
10
+ import { runGit } from "../git-exec.js";
12
11
  import { filterWeavatrixIgnored } from "../path-ignore.js";
13
12
  import {trustedGrammarWasm, trustedRuntimeWasm} from './parser-artifact-boundary.js'
14
13
  import LANG_JS from "./builder/lang-js.js";
@@ -112,17 +111,11 @@ function walkFallback(dir, acc = [], seen = new Set(), depth = 0, rootReal = nul
112
111
  // A repository may be opened without Git (or Git may be unavailable), so failure is deliberately a signal to
113
112
  // use the boundary-safe walker above rather than a build failure.
114
113
  function gitFileUniverse(dir) {
115
- let raw;
116
- try {
117
- raw = execFileSync("git", ["-C", dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
118
- encoding: "utf8",
119
- windowsHide: true,
120
- stdio: ["ignore", "pipe", "ignore"],
121
- timeout: 15_000,
122
- maxBuffer: 64 * 1024 * 1024,
123
- env: childProcessEnv(),
124
- });
125
- } catch { return null; }
114
+ const lsFiles = runGit(dir, ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
115
+ stdio: ["ignore", "pipe", "ignore"], timeout: 15_000, maxBuffer: 64 * 1024 * 1024,
116
+ });
117
+ if (lsFiles.status !== 0 || lsFiles.error) return null;
118
+ const raw = lsFiles.stdout;
126
119
 
127
120
  // `git -C <dir>` also succeeds when <dir> is merely nested under some parent repository. If that
128
121
  // nested directory is ignored by the parent, `ls-files` is empty even though <dir> can be a valid
@@ -130,17 +123,11 @@ function gitFileUniverse(dir) {
130
123
  // that one ambiguous case, fall back to the boundary-safe walker. Preserve an empty universe for
131
124
  // an actual Git root so ignored files do not leak back into its graph.
132
125
  if (!raw) {
133
- try {
134
- const top = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
135
- encoding: "utf8",
136
- windowsHide: true,
137
- stdio: ["ignore", "pipe", "ignore"],
138
- timeout: 15_000,
139
- maxBuffer: 1024 * 1024,
140
- env: childProcessEnv(),
141
- }).trim();
142
- if (realpathSync.native(top) !== realpathSync.native(dir)) return null;
143
- } catch { return null; }
126
+ const top = runGit(dir, ["rev-parse", "--show-toplevel"], {
127
+ stdio: ["ignore", "pipe", "ignore"], timeout: 15_000, maxBuffer: 1024 * 1024,
128
+ });
129
+ if (top.status !== 0 || top.error) return null;
130
+ if (realpathSync.native(String(top.stdout).trim()) !== realpathSync.native(dir)) return null;
144
131
  }
145
132
 
146
133
  let rootReal;
@@ -1,10 +1,7 @@
1
- import {spawnSync} from 'node:child_process'
2
- import {childProcessEnv} from '../child-env.js'
1
+ import {runGit} from '../git-exec.js'
3
2
 
4
3
  export function gitLines(repoRoot, args) {
5
- const result = spawnSync('git', ['-C', repoRoot, ...args], {
6
- encoding: 'utf8', timeout: 8000, env: childProcessEnv(), windowsHide: true,
7
- })
4
+ const result = runGit(repoRoot, args)
8
5
  if (result.status !== 0) return null
9
6
  return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
10
7
  }
@@ -1,7 +1,6 @@
1
1
  import {readFileSync, statSync} 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 {resolveRepoPath} from '../../repo-path.js'
6
5
  import {mergePrecisionOverlay, precisionSemanticInputsMatch, readPrecisionOverlay} from '../../precision/lsp-overlay.js'
7
6
 
@@ -14,19 +13,19 @@ export function graphStaleness(ctx) {
14
13
  try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file */ }
15
14
  if (ctx.repoRoot && info.builtAt) {
16
15
  try {
17
- const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
16
+ const head = runGit(ctx.repoRoot, ['log', '-1', '--format=%cI'], {timeout: 4000})
18
17
  const iso = (head.stdout || '').trim()
19
18
  if (head.status === 0 && iso) {
20
19
  info.headAt = new Date(iso)
21
20
  if (info.headAt > info.builtAt) {
22
21
  info.stale = true
23
- const count = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
22
+ const count = runGit(ctx.repoRoot, ['rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {timeout: 4000})
24
23
  if (count.status === 0) info.behind = Number(count.stdout.trim()) || null
25
24
  }
26
25
  }
27
26
  } catch { /* git unavailable */ }
28
27
  try {
29
- const status = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
28
+ const status = runGit(ctx.repoRoot, ['status', '--porcelain'], {timeout: 4000})
30
29
  if (status.status === 0) {
31
30
  let newer = 0
32
31
  for (const line of String(status.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
@@ -1,6 +1,5 @@
1
- import {spawnSync} from 'node:child_process'
2
1
  import {summarizeFindings} from '../../analysis/findings.js'
3
- import {childProcessEnv} from '../../child-env.js'
2
+ import {runGit} from '../../git-exec.js'
4
3
  import {createPathClassifier, hasPathClass} from '../../path-classification.js'
5
4
 
6
5
  const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
@@ -179,9 +178,7 @@ export const formatOrdinaryAudit = (audit, args, findings = audit.findings, head
179
178
  }
180
179
 
181
180
  export const gitUntracked = (repoRoot) => {
182
- const result = spawnSync('git', ['-C', repoRoot, 'ls-files', '--others', '--exclude-standard'], {
183
- encoding: 'utf8', timeout: 8000, maxBuffer: 2 * 1024 * 1024, env: childProcessEnv(), windowsHide: true,
184
- })
181
+ const result = runGit(repoRoot, ['ls-files', '--others', '--exclude-standard'], {maxBuffer: 2 * 1024 * 1024})
185
182
  if (result.status !== 0) return {
186
183
  ok: false,
187
184
  files: [],
@@ -1,7 +1,6 @@
1
1
  // Symbol-aware change_impact implementation, isolated from the other hot impact tools so its
2
2
  // classifier/evidence contract can be tested without perturbing get_dependents or graph_diff.
3
- import {spawnSync} from 'node:child_process'
4
- import {childProcessEnv} from '../child-env.js'
3
+ import {runGit} from '../git-exec.js'
5
4
  import {fileOfId} from '../graph/node-id.js'
6
5
  import {classifyChangeImpact} from '../analysis/change-classification.js'
7
6
  import {readCoverageForRepo} from '../analysis/coverage-reports.js'
@@ -12,7 +11,7 @@ import {gitLines} from './git-output.mjs'
12
11
  import {toolResult} from './tool-result.mjs'
13
12
 
14
13
  function gitValue(repoRoot, args) {
15
- const result = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
14
+ const result = runGit(repoRoot, args)
16
15
  if (result.status !== 0) return null
17
16
  return String(result.stdout || '').trim() || null
18
17
  }
package/src/mcp-rg.mjs CHANGED
@@ -1,9 +1,7 @@
1
1
  import { readdirSync, existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
- import { spawnSync } from 'node:child_process'
4
- import { childProcessEnv } from './child-env.js'
5
3
  import { createRequire } from 'node:module'
6
- import process from 'node:process'
4
+ import { runCommandSync } from './process.js'
7
5
 
8
6
  const rgInInstall = (base) => [
9
7
  join(base, 'resources', 'app', 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg.exe'),
@@ -43,7 +41,7 @@ export function createRgResolver(selfDir) {
43
41
  } catch { /* optional packaged ripgrep path */ }
44
42
  for (const c of editorRgCandidates()) if (existsSync(c)) return (rgPath = c)
45
43
  try {
46
- const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {encoding: 'utf8', env: childProcessEnv(), timeout: 3000, windowsHide: true})
44
+ const probe = runCommandSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {timeout: 3000})
47
45
  const p = probe.status === 0 ? probe.stdout.split(/\r?\n/)[0].trim() : ''
48
46
  if (p && existsSync(p)) return (rgPath = p)
49
47
  } catch { /* optional PATH probe */ }
@@ -1,8 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
2
- import { spawnSync } from 'node:child_process'
3
2
  import { extname, isAbsolute, join, relative, resolve } from 'node:path'
4
3
  import { resolveRepoPath } from './repo-path.js'
5
- import { childProcessEnv } from './child-env.js'
4
+ import { runCommandSync } from './process.js'
6
5
  import { toolResult } from './mcp/tool-result.mjs'
7
6
 
8
7
  const SEARCH_SKIP = new Set(['.git', 'node_modules', 'dist', 'build', 'out', '.next', 'coverage', 'vendor', '.venv', 'venv', 'env', 'target', '__pycache__', '.idea', '.vscode', '.cache', 'bin', 'obj', 'weavatrix-graphs'])
@@ -11,7 +10,7 @@ const MAX_SEARCH_FILE_BYTES = 1024 * 1024
11
10
  const MAX_SOURCE_FILE_BYTES = 2 * 1024 * 1024
12
11
  const MAX_SOURCE_CONTEXT_LINES = 1000
13
12
 
14
- function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spawnRg = spawnSync) {
13
+ function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spawnRg = runCommandSync) {
15
14
  const rg = resolveRg()
16
15
  if (!rg) return null
17
16
  // Exclude node_modules explicitly: --hidden + the default gitignore stack is the only thing that
@@ -24,7 +23,7 @@ function rgSearch(repoRoot, resolveRg, query, { isRegex, glob, maxResults }, spa
24
23
  // against repository-relative paths on Windows too. Passing an absolute search root makes rg
25
24
  // compare the glob with a drive-prefixed path and silently return no matches.
26
25
  args.push('--', query, '.')
27
- const res = spawnRg(rg, args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000, env: childProcessEnv() })
26
+ const res = spawnRg(rg, args, { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 15000 })
28
27
  if (res.status !== 0 && res.status !== 1) return null
29
28
  const out = []
30
29
  for (const line of (res.stdout || '').split(/\r?\n/)) {
@@ -93,7 +92,7 @@ function nodeGrep(repoRoot, query, { isRegex, glob, maxResults }) {
93
92
  return out
94
93
  }
95
94
 
96
- export function searchCode({ repoRoot, resolveRg, spawnRg = spawnSync }, { query, is_regex = false, max_results = 40, glob } = {}) {
95
+ export function searchCode({ repoRoot, resolveRg, spawnRg = runCommandSync }, { query, is_regex = false, max_results = 40, glob } = {}) {
97
96
  if (!query) return 'Provide a "query" string.'
98
97
  if (!repoRoot || !existsSync(repoRoot)) return 'Source search unavailable: repo root not provided to this MCP server.'
99
98
  const max = Math.max(1, Math.min(200, Number(max_results) || 40))
package/src/process.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Subprocess runner shared by the search engines and security sweeps.
2
- import { spawn } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
3
  import { childProcessEnv } from "./child-env.js";
4
4
 
5
5
  // Windows: .cmd/.ps1 shims (npx, etc.) can't be spawned directly by Node — they need a
@@ -76,3 +76,20 @@ export function runCommand(command, args = [], options = {}) {
76
76
  child.on("close", (code) => finish(() => (timedOut ? reject(new Error("Command timed out")) : resolve({ stdout, stderr, exitCode: code }))));
77
77
  });
78
78
  }
79
+
80
+ // Synchronous run-and-collect variant for callers that resolve a real binary and read its output in
81
+ // one shot (ripgrep resolution/search, tar extraction). These launch a resolved .exe or bare binary
82
+ // directly, so no shell quoting is needed; the child env is stripped of connector secrets and a
83
+ // bounded timeout/buffer applies. Returns the raw spawnSync result ({status, stdout, stderr, error}).
84
+ export function runCommandSync(command, args = [], options = {}) {
85
+ const spawnOptions = {
86
+ cwd: options.cwd || undefined,
87
+ encoding: options.encoding ?? "utf8",
88
+ env: childProcessEnv(options.env),
89
+ windowsHide: true,
90
+ };
91
+ if (options.timeout != null) spawnOptions.timeout = options.timeout;
92
+ if (options.maxBuffer != null) spawnOptions.maxBuffer = options.maxBuffer;
93
+ if (options.stdio != null) spawnOptions.stdio = options.stdio;
94
+ return spawnSync(command, args, spawnOptions);
95
+ }