@comity-dev/validate 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -50,6 +50,11 @@ comity-validate --only=schema # one engine only
50
50
  comity-validate --only=eslint --verbose
51
51
  ```
52
52
 
53
+ `--only <category>` runs a single engine. Policies owned by another engine are
54
+ not evaluated: `--only semgrep` does not check ESLint-owned policy, including
55
+ the required `@internal` marker rule (`@comity-dev/require-internal-marker`).
56
+ Use the default run (no `--only`) for full policy coverage.
57
+
53
58
  Exit codes:
54
59
 
55
60
  - `0` — validation passed
@@ -61,10 +66,11 @@ Exit codes:
61
66
 
62
67
  The Semgrep engine shells out to a local Semgrep installation:
63
68
 
64
- | Variable | Purpose |
65
- | ---------------- | ------------------------------------------------------------------------------------------------ |
66
- | `SEMGREP_BIN` | Path to the `semgrep` executable. Used as-is when it exists; otherwise resolved from PATH. |
67
- | `SEMGREP_PYTHON` | Path to the Python interpreter that ships the `semgrep` module. The scan is executed with it. |
69
+ | Variable | Purpose |
70
+ | --------------------------- | ------------------------------------------------------------------------------------------------ |
71
+ | `SEMGREP_BIN` | Path to the `semgrep` executable. Used as-is when it exists; otherwise resolved from PATH. |
72
+ | `SEMGREP_PYTHON` | Path to the Python interpreter that ships the `semgrep` module. The scan is executed with it. |
73
+ | `COMITY_SEMGREP_TIMEOUT_MS` | Overall scan timeout in milliseconds. Defaults to `300000` (five minutes). Must be an integer between `1` and `2147483647`; invalid or oversized values are reported as `CONFIGURATION-ERROR`. |
68
74
 
69
75
  Rules:
70
76
 
@@ -77,6 +83,17 @@ Rules:
77
83
  then `python3` on PATH).
78
84
  - The scan `PATH` is prefixed with the directories of the resolved
79
85
  `SEMGREP_BIN` and interpreter so Semgrep finds its sibling `pysemgrep`.
86
+ - Every scan has an overall liveness bound (`COMITY_SEMGREP_TIMEOUT_MS`). A
87
+ timeout is reported as `EXECUTION-ERROR`, never as PASS. On POSIX the
88
+ scanner's process group is terminated so Semgrep's Python and `semgrep-core`
89
+ descendants do not remain running; on Windows only the immediate child can
90
+ be signalled, so descendant termination is best-effort.
91
+ - `--verbose` emits a stderr heartbeat every ten seconds while a scan is
92
+ running; the final summary remains on stdout.
93
+ - A file Semgrep could not fully parse (`PartialParsing`) is reported as
94
+ `EXECUTION-ERROR` with a `SEM-PARTIAL-PARSE` finding naming the file, never
95
+ as PASS: a partially parsed file may have been skipped by AST-based rules,
96
+ so the scan is incomplete rather than clean.
80
97
 
81
98
  ```bash
82
99
  SEMGREP_BIN=~/.local/bin/semgrep SEMGREP_PYTHON=/opt/homebrew/bin/python3 \
@@ -26,7 +26,7 @@
26
26
  import { resolve } from "node:path";
27
27
  import { ExitCode } from "../exit-codes.js";
28
28
  import { formatSummary, formatViolations } from "../format.js";
29
- import { runValidation } from "../run.js";
29
+ import { exitCodeForResult, runValidation } from "../run.js";
30
30
  function parseArgs(argv) {
31
31
  const args = {
32
32
  root: process.cwd(),
@@ -88,7 +88,10 @@ async function main() {
88
88
  console.log("");
89
89
  console.log(formatViolations(result.violations));
90
90
  }
91
- process.exit(result.passed ? ExitCode.PASS : ExitCode.FAIL);
91
+ // A required tool that could not run must never surface as PASS:
92
+ // report it with the documented TOOL_MISSING exit code instead of
93
+ // fabricating a passing result.
94
+ process.exit(exitCodeForResult(result));
92
95
  }
93
96
  catch (err) {
94
97
  const message = err instanceof Error ? err.message : String(err);
@@ -1,18 +1,72 @@
1
1
  import type { Repository } from "../discover.js";
2
2
  import type { EngineResult } from "./types.js";
3
+ /** Default overall Semgrep scan timeout: five minutes. */
4
+ export declare const DEFAULT_SEMGREP_TIMEOUT_MS = 300000;
5
+ /** Maximum single Node.js timer value, in milliseconds (2^31 - 1). */
6
+ export declare const MAX_SEMGREP_TIMEOUT_MS = 2147483647;
7
+ export interface SemgrepOptions {
8
+ /** Overall scan timeout in milliseconds; overrides COMITY_SEMGREP_TIMEOUT_MS. */
9
+ readonly timeoutMs?: number;
10
+ /** Emit a heartbeat on stderr while the scan is running. */
11
+ readonly verbose?: boolean;
12
+ }
13
+ export interface SemgrepTimeoutResolution {
14
+ /** Validated timeout in milliseconds. */
15
+ readonly timeoutMs: number;
16
+ /** Configuration error, if the configured timeout is invalid. */
17
+ readonly error?: string;
18
+ }
19
+ /**
20
+ * Resolve the overall Semgrep timeout from an explicit override or the
21
+ * `COMITY_SEMGREP_TIMEOUT_MS` environment variable.
22
+ *
23
+ * @param env - Environment to read
24
+ * @param override - Test/embedding override; when supplied, it wins
25
+ *
26
+ * @returns A supported timeout in milliseconds, or a configuration error
27
+ *
28
+ * @remarks
29
+ * The timeout is deliberately bounded. A value of zero would disable the
30
+ * liveness guarantee, and values above Node's 32-bit timer limit would be
31
+ * clamped to 1 ms, so both are rejected. Invalid values are reported as
32
+ * configuration errors and never fall back silently.
33
+ */
34
+ export declare function resolveSemgrepTimeout(env?: NodeJS.ProcessEnv, override?: number): SemgrepTimeoutResolution;
35
+ /**
36
+ * Derive the Python interpreter from a semgrep executable's shebang
37
+ * line. Shim installs (Homebrew, pipx, venv) point `#!` at the
38
+ * interpreter that ships the `semgrep` module; resolving it keeps the
39
+ * scan on a working interpreter instead of an unrelated system python
40
+ * that merely exists on PATH.
41
+ *
42
+ * @param binPath - Path to the semgrep executable.
43
+ * @param env - Environment to read for `/usr/bin/env` resolution.
44
+ *
45
+ * @returns The interpreter path when the file starts with a usable
46
+ * `#!` line naming an existing executable, otherwise `null`.
47
+ */
48
+ export declare function resolveInterpreterFromBin(binPath: string, env?: NodeJS.ProcessEnv): string | null;
3
49
  /**
4
50
  * Discover the Python interpreter that ships the `semgrep` module.
5
51
  *
6
52
  * Selection order:
7
53
  * 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
8
54
  * configuration always wins and is never silently overridden.
9
- * 2. Well-known absolute paths, then `python3` on PATH (historic default).
55
+ * 2. The interpreter named by the semgrep executable's shebang line
56
+ * (shim installs point at the interpreter that ships the module).
57
+ * 3. Well-known absolute paths, then `python3` on PATH (historic default).
58
+ *
59
+ * Every auto-discovered candidate (steps 2-3) must both exist and
60
+ * successfully `import semgrep`; the first candidate that does wins.
61
+ * An explicit `SEMGREP_PYTHON` is honored as-is for backwards
62
+ * compatibility.
10
63
  *
11
64
  * @param env - Environment to read. Defaults to the process environment.
65
+ * @param binPath - Resolved semgrep executable used for step 2.
12
66
  *
13
67
  * @returns The selected interpreter path, or `null` when none is available.
14
68
  * A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
15
69
  * report it as invalid configuration rather than falling back silently.
16
70
  */
17
- export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv): string | null;
18
- export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
71
+ export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv, binPath?: string): string | null;
72
+ export declare function runSemgrep(repo: Repository, options?: SemgrepOptions): Promise<EngineResult>;
@@ -1,41 +1,336 @@
1
1
  import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
2
- import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { relative, resolve } from "node:path";
5
+ /** Default overall Semgrep scan timeout: five minutes. */
6
+ export const DEFAULT_SEMGREP_TIMEOUT_MS = 300_000;
7
+ /** Maximum single Node.js timer value, in milliseconds (2^31 - 1). */
8
+ export const MAX_SEMGREP_TIMEOUT_MS = 2_147_483_647;
9
+ /** Time allowed for a timed-out process group to exit before SIGKILL. */
10
+ const SEMGREP_TERMINATION_GRACE_MS = 2_000;
11
+ /** Frequency of optional verbose heartbeats. */
12
+ const SEMGREP_HEARTBEAT_MS = 10_000;
13
+ /** Preserve the previous spawnSync maxBuffer ceiling for the async scanner. */
14
+ const MAX_SEMGREP_OUTPUT_BYTES = 16 * 1024 * 1024;
15
+ /**
16
+ * Resolve the overall Semgrep timeout from an explicit override or the
17
+ * `COMITY_SEMGREP_TIMEOUT_MS` environment variable.
18
+ *
19
+ * @param env - Environment to read
20
+ * @param override - Test/embedding override; when supplied, it wins
21
+ *
22
+ * @returns A supported timeout in milliseconds, or a configuration error
23
+ *
24
+ * @remarks
25
+ * The timeout is deliberately bounded. A value of zero would disable the
26
+ * liveness guarantee, and values above Node's 32-bit timer limit would be
27
+ * clamped to 1 ms, so both are rejected. Invalid values are reported as
28
+ * configuration errors and never fall back silently.
29
+ */
30
+ export function resolveSemgrepTimeout(env = process.env, override) {
31
+ if (override !== undefined) {
32
+ if (!Number.isSafeInteger(override) ||
33
+ override <= 0 ||
34
+ override > MAX_SEMGREP_TIMEOUT_MS) {
35
+ return {
36
+ timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS,
37
+ error: `timeoutMs must be an integer between 1 and ${MAX_SEMGREP_TIMEOUT_MS}; received ${String(override)}`,
38
+ };
39
+ }
40
+ return { timeoutMs: override };
41
+ }
42
+ const raw = env["COMITY_SEMGREP_TIMEOUT_MS"];
43
+ if (raw === undefined) {
44
+ return { timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS };
45
+ }
46
+ const trimmed = raw.trim();
47
+ if (!/^\d+$/.test(trimmed)) {
48
+ return {
49
+ timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS,
50
+ error: `COMITY_SEMGREP_TIMEOUT_MS must be an integer between 1 and ${MAX_SEMGREP_TIMEOUT_MS}; received "${raw}"`,
51
+ };
52
+ }
53
+ const parsed = Number(trimmed);
54
+ if (!Number.isSafeInteger(parsed) ||
55
+ parsed <= 0 ||
56
+ parsed > MAX_SEMGREP_TIMEOUT_MS) {
57
+ return {
58
+ timeoutMs: DEFAULT_SEMGREP_TIMEOUT_MS,
59
+ error: `COMITY_SEMGREP_TIMEOUT_MS must be an integer between 1 and ${MAX_SEMGREP_TIMEOUT_MS}; received "${raw}"`,
60
+ };
61
+ }
62
+ return { timeoutMs: parsed };
63
+ }
64
+ /**
65
+ * Terminate a Semgrep child process and, on POSIX, its process group.
66
+ *
67
+ * @param child - Child process started by the scanner
68
+ * @param signal - Signal to send
69
+ *
70
+ * @remarks
71
+ * The scanner is started with `detached: true` on POSIX so its Python and
72
+ * semgrep-core descendants share a process group. Killing the group prevents
73
+ * orphaned semgrep-core processes after a timeout. Windows has no equivalent
74
+ * process-group primitive in Node; there only the immediate child is signalled.
75
+ */
76
+ function terminateSemgrepProcess(child, signal) {
77
+ if (child.pid === undefined)
78
+ return;
79
+ if (process.platform === "win32") {
80
+ try {
81
+ child.kill(signal);
82
+ }
83
+ catch {
84
+ // The child may already have exited.
85
+ }
86
+ return;
87
+ }
88
+ try {
89
+ process.kill(-child.pid, signal);
90
+ }
91
+ catch {
92
+ try {
93
+ child.kill(signal);
94
+ }
95
+ catch {
96
+ // The process group may already have exited.
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * Run the Semgrep subprocess with an overall timeout and bounded output.
102
+ *
103
+ * @param command - Interpreter to execute
104
+ * @param args - Interpreter arguments
105
+ * @param options - Working directory, environment, timeout, and verbosity
106
+ *
107
+ * @returns Captured output and termination details
108
+ *
109
+ * @remarks
110
+ * A timeout sends SIGTERM to the process group, then SIGKILL after a short
111
+ * grace period. The promise always settles; a close event normally does so
112
+ * sooner, while the grace timer is a hard ceiling. On Windows only the direct
113
+ * child can be signalled, so descendant termination is best-effort there.
114
+ */
115
+ function runSemgrepProcess(command, args, options) {
116
+ return new Promise((resolvePromise) => {
117
+ let child;
118
+ try {
119
+ child = spawn(command, args, {
120
+ cwd: options.cwd,
121
+ env: options.env,
122
+ detached: process.platform !== "win32",
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ });
125
+ }
126
+ catch (error) {
127
+ resolvePromise({
128
+ stdout: "",
129
+ stderr: "",
130
+ status: null,
131
+ signal: null,
132
+ timedOut: false,
133
+ spawnError: error instanceof Error ? error : new Error(String(error)),
134
+ outputLimitExceeded: false,
135
+ });
136
+ return;
137
+ }
138
+ let stdout = "";
139
+ let stderr = "";
140
+ let timedOut = false;
141
+ let spawnError = null;
142
+ let outputLimitExceeded = false;
143
+ let settled = false;
144
+ let timeoutTimer;
145
+ let forceTimer;
146
+ let heartbeatTimer;
147
+ const started = Date.now();
148
+ const clearTimers = () => {
149
+ if (timeoutTimer !== undefined)
150
+ clearTimeout(timeoutTimer);
151
+ if (forceTimer !== undefined)
152
+ clearTimeout(forceTimer);
153
+ if (heartbeatTimer !== undefined)
154
+ clearInterval(heartbeatTimer);
155
+ };
156
+ const finish = (status, signal) => {
157
+ if (settled)
158
+ return;
159
+ settled = true;
160
+ clearTimers();
161
+ resolvePromise({
162
+ stdout,
163
+ stderr,
164
+ status,
165
+ signal,
166
+ timedOut,
167
+ spawnError,
168
+ outputLimitExceeded,
169
+ });
170
+ };
171
+ const terminate = () => {
172
+ terminateSemgrepProcess(child, "SIGTERM");
173
+ // A process that ignores SIGTERM must not hold validation open. The
174
+ // hard timer also guarantees the promise settles if no close event
175
+ // arrives; killing the process group is the primary termination path.
176
+ forceTimer = setTimeout(() => {
177
+ terminateSemgrepProcess(child, "SIGKILL");
178
+ finish(null, "SIGKILL");
179
+ }, SEMGREP_TERMINATION_GRACE_MS);
180
+ };
181
+ const appendOutput = (target, chunk) => {
182
+ const text = chunk.toString();
183
+ if (target === "stdout")
184
+ stdout += text;
185
+ else
186
+ stderr += text;
187
+ if (!outputLimitExceeded &&
188
+ Buffer.byteLength(stdout, "utf8") + Buffer.byteLength(stderr, "utf8") >
189
+ MAX_SEMGREP_OUTPUT_BYTES) {
190
+ outputLimitExceeded = true;
191
+ terminate();
192
+ }
193
+ };
194
+ child.stdout?.on("data", (chunk) => appendOutput("stdout", chunk));
195
+ child.stderr?.on("data", (chunk) => appendOutput("stderr", chunk));
196
+ child.on("error", (error) => {
197
+ spawnError = error;
198
+ finish(null, null);
199
+ });
200
+ child.on("close", (status, signal) => {
201
+ finish(status, signal);
202
+ });
203
+ timeoutTimer = setTimeout(() => {
204
+ timedOut = true;
205
+ terminate();
206
+ }, options.timeoutMs);
207
+ if (options.verbose) {
208
+ heartbeatTimer = setInterval(() => {
209
+ const elapsedSeconds = Math.round((Date.now() - started) / 1000);
210
+ try {
211
+ process.stderr.write(`comity-validate: semgrep scan still running after ${elapsedSeconds}s (timeout ${Math.round(options.timeoutMs / 1000)}s)\n`);
212
+ }
213
+ catch {
214
+ // Diagnostics must never change the validation result.
215
+ }
216
+ }, SEMGREP_HEARTBEAT_MS);
217
+ }
218
+ });
219
+ }
220
+ /**
221
+ * Derive the Python interpreter from a semgrep executable's shebang
222
+ * line. Shim installs (Homebrew, pipx, venv) point `#!` at the
223
+ * interpreter that ships the `semgrep` module; resolving it keeps the
224
+ * scan on a working interpreter instead of an unrelated system python
225
+ * that merely exists on PATH.
226
+ *
227
+ * @param binPath - Path to the semgrep executable.
228
+ * @param env - Environment to read for `/usr/bin/env` resolution.
229
+ *
230
+ * @returns The interpreter path when the file starts with a usable
231
+ * `#!` line naming an existing executable, otherwise `null`.
232
+ */
233
+ export function resolveInterpreterFromBin(binPath, env = process.env) {
234
+ let head;
235
+ try {
236
+ const fd = openSync(binPath, "r");
237
+ try {
238
+ const buf = Buffer.alloc(2048);
239
+ const read = readSync(fd, buf, 0, buf.length, 0);
240
+ head = buf.subarray(0, read).toString("utf8").split("\n")[0] ?? "";
241
+ }
242
+ finally {
243
+ closeSync(fd);
244
+ }
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ if (!head.startsWith("#!"))
250
+ return null;
251
+ const parts = head.slice(2).trim().split(/\s+/).filter(Boolean);
252
+ const [first, second] = parts;
253
+ if (!first)
254
+ return null;
255
+ // `#!/usr/bin/env python3 ...` form: resolve the named interpreter
256
+ // against PATH the same way a shell would.
257
+ if (first === "env" || first.endsWith("/env")) {
258
+ if (!second)
259
+ return null;
260
+ const probe = spawnSync("which", [second], {
261
+ encoding: "utf8",
262
+ env: { ...env, PATH: env["PATH"] ?? "" },
263
+ });
264
+ if (probe.status === 0 && probe.stdout?.trim()) {
265
+ return probe.stdout.trim();
266
+ }
267
+ return null;
268
+ }
269
+ return existsSync(first) ? first : null;
270
+ }
271
+ /**
272
+ * Probe whether an interpreter ships the `semgrep` module, so a
273
+ * resolved python is never assumed to work by mere existence.
274
+ */
275
+ function interpreterShipsSemgrep(interpreter) {
276
+ try {
277
+ const probe = spawnSync(interpreter, ["-c", "import semgrep"], {
278
+ encoding: "utf8",
279
+ timeout: 30000,
280
+ });
281
+ return probe.status === 0;
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ }
5
287
  /**
6
288
  * Discover the Python interpreter that ships the `semgrep` module.
7
289
  *
8
290
  * Selection order:
9
291
  * 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
10
292
  * configuration always wins and is never silently overridden.
11
- * 2. Well-known absolute paths, then `python3` on PATH (historic default).
293
+ * 2. The interpreter named by the semgrep executable's shebang line
294
+ * (shim installs point at the interpreter that ships the module).
295
+ * 3. Well-known absolute paths, then `python3` on PATH (historic default).
296
+ *
297
+ * Every auto-discovered candidate (steps 2-3) must both exist and
298
+ * successfully `import semgrep`; the first candidate that does wins.
299
+ * An explicit `SEMGREP_PYTHON` is honored as-is for backwards
300
+ * compatibility.
12
301
  *
13
302
  * @param env - Environment to read. Defaults to the process environment.
303
+ * @param binPath - Resolved semgrep executable used for step 2.
14
304
  *
15
305
  * @returns The selected interpreter path, or `null` when none is available.
16
306
  * A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
17
307
  * report it as invalid configuration rather than falling back silently.
18
308
  */
19
- export function resolvePythonForSemgrep(env = process.env) {
309
+ export function resolvePythonForSemgrep(env = process.env, binPath) {
20
310
  const explicit = env["SEMGREP_PYTHON"];
21
311
  if (explicit) {
22
312
  return existsSync(explicit) ? explicit : null;
23
313
  }
24
- const candidates = [
25
- "/usr/bin/python3",
26
- "/usr/local/bin/python3",
27
- "/opt/homebrew/bin/python3",
28
- ];
314
+ const candidates = [];
315
+ if (binPath) {
316
+ const fromBin = resolveInterpreterFromBin(binPath, env);
317
+ if (fromBin)
318
+ candidates.push(fromBin);
319
+ }
320
+ candidates.push("/usr/bin/python3", "/usr/local/bin/python3", "/opt/homebrew/bin/python3");
29
321
  for (const c of candidates) {
30
- if (existsSync(c))
322
+ if (existsSync(c) && interpreterShipsSemgrep(c))
31
323
  return c;
32
324
  }
33
325
  const probe = spawnSync("which", ["python3"], {
34
326
  encoding: "utf8",
35
327
  env: { ...env, PATH: env["PATH"] ?? "" },
36
328
  });
37
- if (probe.status === 0 && probe.stdout?.trim())
38
- return probe.stdout.trim();
329
+ if (probe.status === 0 && probe.stdout?.trim()) {
330
+ const viaPath = probe.stdout.trim();
331
+ if (interpreterShipsSemgrep(viaPath))
332
+ return viaPath;
333
+ }
39
334
  return null;
40
335
  }
41
336
  function findSemgrepBin(fallbackDirs) {
@@ -106,8 +401,21 @@ function buildSemgrepArgs(configPath, target, excludedDirs) {
106
401
  }
107
402
  return args;
108
403
  }
109
- export async function runSemgrep(repo) {
404
+ export async function runSemgrep(repo, options = {}) {
110
405
  const start = Date.now();
406
+ const timeout = resolveSemgrepTimeout(process.env, options.timeoutMs);
407
+ if (timeout.error !== undefined) {
408
+ return {
409
+ executed: false,
410
+ exitCode: null,
411
+ passed: false,
412
+ findings: [],
413
+ duration: Date.now() - start,
414
+ status: "CONFIGURATION-ERROR",
415
+ tool: "semgrep",
416
+ reason: `${timeout.error}; the Semgrep scan was not started`,
417
+ };
418
+ }
111
419
  // Build the combined Semgrep config. Semgrep's CLI accepts a single
112
420
  // --config path, so we concatenate all canonical rule files into one
113
421
  // synthesized config.
@@ -140,10 +448,12 @@ export async function runSemgrep(repo) {
140
448
  reason: "semgrep binary not found on PATH; install semgrep or set SEMGREP_BIN",
141
449
  };
142
450
  }
143
- // Locate the Python interpreter that ships the semgrep module.
144
- // An explicit SEMGREP_PYTHON is honored as-is; a set-but-missing value
145
- // is invalid configuration, never a reason to silently fall back.
146
- const pythonBin = resolvePythonForSemgrep();
451
+ // Locate the Python interpreter that ships the semgrep module,
452
+ // preferring the interpreter named by the semgrep executable's own
453
+ // shebang line. An explicit SEMGREP_PYTHON is honored as-is; a
454
+ // set-but-missing value is invalid configuration, never a reason to
455
+ // silently fall back.
456
+ const pythonBin = resolvePythonForSemgrep(process.env, semgrepBin ?? undefined);
147
457
  if (!pythonBin) {
148
458
  cleanup();
149
459
  const explicitPython = process.env["SEMGREP_PYTHON"];
@@ -157,7 +467,7 @@ export async function runSemgrep(repo) {
157
467
  tool: "semgrep",
158
468
  reason: explicitPython
159
469
  ? `SEMGREP_PYTHON is set to "${explicitPython}" but that path does not exist; set SEMGREP_PYTHON to a Python interpreter that ships the semgrep module`
160
- : "Python interpreter not found; semgrep requires Python on PATH",
470
+ : `No Python interpreter shipping the semgrep module was found (semgrep binary: "${semgrepBin}"); install semgrep so its executable resolves to a working interpreter, or set SEMGREP_PYTHON to a Python interpreter that ships the semgrep module`,
161
471
  };
162
472
  }
163
473
  // Augment PATH so the inner Python process can find semgrep's
@@ -199,72 +509,334 @@ export async function runSemgrep(repo) {
199
509
  };
200
510
  }
201
511
  const args = buildSemgrepArgs(configPath, target, excludedDirs);
202
- const proc = spawnSync(pythonBin, args, {
512
+ const outcome = await runSemgrepProcess(pythonBin, args, {
203
513
  cwd: repo.root,
204
514
  env: { ...process.env, PATH: envPath },
205
- maxBuffer: 16 * 1024 * 1024,
206
- encoding: "utf8",
515
+ timeoutMs: timeout.timeoutMs,
516
+ verbose: options.verbose === true,
207
517
  });
208
518
  cleanup();
209
- const stdout = proc.stdout ?? "";
210
- const stderr = proc.stderr ?? "";
211
- const findings = parseSemgrepOutput(stdout, stderr);
519
+ const { stdout, stderr } = outcome;
520
+ const parsed = parseSemgrepOutput(stdout, stderr);
521
+ const findings = [...parsed.findings];
522
+ const duration = Date.now() - start;
523
+ if (outcome.spawnError !== null) {
524
+ const message = `semgrep could not be started: ${outcome.spawnError.message}; this is a tool execution failure, not a rule match`;
525
+ findings.push({
526
+ tool: "semgrep",
527
+ rule: "SEM-SPAWN",
528
+ message,
529
+ severity: "error",
530
+ });
531
+ return {
532
+ executed: true,
533
+ exitCode: null,
534
+ passed: false,
535
+ findings,
536
+ duration,
537
+ status: "EXECUTION-ERROR",
538
+ tool: "semgrep",
539
+ reason: message,
540
+ };
541
+ }
542
+ if (outcome.timedOut) {
543
+ const message = `semgrep exceeded the ${timeout.timeoutMs} ms timeout after ${duration} ms during structural-pattern validation; the process group was terminated. This is a tool execution failure, not a rule match.`;
544
+ findings.push({
545
+ tool: "semgrep",
546
+ rule: "SEM-TIMEOUT",
547
+ message,
548
+ severity: "error",
549
+ });
550
+ return {
551
+ executed: true,
552
+ exitCode: null,
553
+ passed: false,
554
+ findings,
555
+ duration,
556
+ status: "EXECUTION-ERROR",
557
+ tool: "semgrep",
558
+ reason: message,
559
+ };
560
+ }
561
+ if (outcome.outputLimitExceeded) {
562
+ const message = `semgrep output exceeded ${MAX_SEMGREP_OUTPUT_BYTES} bytes; the process group was terminated. This is a tool execution failure, not a rule match.`;
563
+ findings.push({
564
+ tool: "semgrep",
565
+ rule: "SEM-OUTPUT",
566
+ message,
567
+ severity: "error",
568
+ });
569
+ return {
570
+ executed: true,
571
+ exitCode: null,
572
+ passed: false,
573
+ findings,
574
+ duration,
575
+ status: "EXECUTION-ERROR",
576
+ tool: "semgrep",
577
+ reason: message,
578
+ };
579
+ }
580
+ if (outcome.signal !== null) {
581
+ const message = `semgrep was terminated by ${outcome.signal}; this is a tool execution failure, not a rule match.`;
582
+ findings.push({
583
+ tool: "semgrep",
584
+ rule: "SEM-SIGNAL",
585
+ message,
586
+ severity: "error",
587
+ });
588
+ return {
589
+ executed: true,
590
+ exitCode: null,
591
+ passed: false,
592
+ findings,
593
+ duration,
594
+ status: "EXECUTION-ERROR",
595
+ tool: "semgrep",
596
+ reason: message,
597
+ };
598
+ }
599
+ // Incomplete analysis takes precedence over the policy verdict. Semgrep
600
+ // reports a partially parsed file as a `level: "warn"` error and still
601
+ // exits 0, so the exit code alone would report a clean pass even though
602
+ // AST-based rules may never have analysed that file. This check runs before
603
+ // the crash/exit-code checks so the affected file is always surfaced.
604
+ if (parsed.partialParsing.length > 0) {
605
+ const first = parsed.partialParsing[0];
606
+ for (const diagnostic of parsed.partialParsing) {
607
+ findings.push({
608
+ tool: "semgrep",
609
+ rule: "SEM-PARTIAL-PARSE",
610
+ message: `semgrep could not fully parse ${diagnostic.path}: ${diagnostic.message}. AST-based rules may not have analysed this file, so the result is incomplete.`,
611
+ file: diagnostic.path,
612
+ severity: "error",
613
+ });
614
+ }
615
+ const exitNote = outcome.status !== null && outcome.status !== 0
616
+ ? `; semgrep exited with code ${outcome.status}`
617
+ : "";
618
+ const message = `semgrep could not fully parse ${parsed.partialParsing.length} file(s) (first: ${first?.path ?? "<unknown file>"}); the scan is incomplete, so this category cannot report PASS${exitNote}. This is an incomplete-analysis failure, not a rule match.`;
619
+ return {
620
+ executed: true,
621
+ exitCode: outcome.status,
622
+ passed: false,
623
+ findings,
624
+ duration,
625
+ status: "EXECUTION-ERROR",
626
+ tool: "semgrep",
627
+ reason: message,
628
+ };
629
+ }
212
630
  // If semgrep exited non-zero but produced no parseable findings, this
213
631
  // is an EXECUTION-ERROR (configuration, rule-parse, internal crash),
214
632
  // not a policy violation.
215
- if (proc.status !== null && proc.status !== 0 && findings.length === 0) {
633
+ if (outcome.status !== null && outcome.status !== 0 && findings.length === 0) {
216
634
  const stderrHead = stderr.split("\n")[0] ?? "";
217
635
  const stdoutHead = stdout.slice(0, 500);
218
636
  findings.push({
219
637
  tool: "semgrep",
220
638
  rule: "SEM-CRASH",
221
- message: `semgrep exited with code ${proc.status}; stderr: ${stderrHead}; stdout (first 500): ${stdoutHead}`,
639
+ message: `semgrep exited with code ${outcome.status}; stderr: ${stderrHead}; stdout (first 500): ${stdoutHead}`,
222
640
  severity: "error",
223
641
  });
224
642
  return {
225
643
  executed: true,
226
- exitCode: proc.status,
644
+ exitCode: outcome.status,
227
645
  passed: false,
228
646
  findings,
229
- duration: Date.now() - start,
647
+ duration,
648
+ status: "EXECUTION-ERROR",
649
+ tool: "semgrep",
650
+ reason: `semgrep exited with code ${outcome.status} before producing findings (stderr: ${stderrHead}); this is a tool execution failure, not a rule match — see the SEM-CRASH finding`,
651
+ };
652
+ }
653
+ if (outcome.status === null) {
654
+ const message = "semgrep terminated without an exit code; this is a tool execution failure, not a rule match.";
655
+ findings.push({
656
+ tool: "semgrep",
657
+ rule: "SEM-CRASH",
658
+ message,
659
+ severity: "error",
660
+ });
661
+ return {
662
+ executed: true,
663
+ exitCode: null,
664
+ passed: false,
665
+ findings,
666
+ duration,
667
+ status: "EXECUTION-ERROR",
668
+ tool: "semgrep",
669
+ reason: message,
670
+ };
671
+ }
672
+ if (parsed.malformed) {
673
+ const stdoutHead = stdout.slice(0, 500);
674
+ const message = `semgrep produced malformed JSON output (first 500 chars: ${stdoutHead}); this is a tool execution failure, not a rule match.`;
675
+ findings.push({
676
+ tool: "semgrep",
677
+ rule: "SEM-MALFORMED",
678
+ message,
679
+ severity: "error",
680
+ });
681
+ return {
682
+ executed: true,
683
+ exitCode: outcome.status,
684
+ passed: false,
685
+ findings,
686
+ duration,
230
687
  status: "EXECUTION-ERROR",
231
688
  tool: "semgrep",
689
+ reason: message,
232
690
  };
233
691
  }
234
692
  return {
235
693
  executed: true,
236
- exitCode: proc.status,
237
- passed: proc.status === 0 && findings.length === 0,
694
+ exitCode: outcome.status,
695
+ passed: outcome.status === 0 && findings.length === 0,
238
696
  findings,
239
- duration: Date.now() - start,
240
- status: proc.status === 0 ? "PASS" : "FAIL",
697
+ duration,
698
+ status: outcome.status === 0 && findings.length === 0 ? "PASS" : "FAIL",
241
699
  tool: "semgrep",
242
700
  };
243
701
  }
702
+ function isRecord(value) {
703
+ return typeof value === "object" && value !== null && !Array.isArray(value);
704
+ }
705
+ /** Validate the result fields consumed by this validator. */
706
+ function isSemgrepResult(value) {
707
+ if (!isRecord(value))
708
+ return false;
709
+ const start = value["start"];
710
+ return (typeof value["check_id"] === "string" &&
711
+ typeof value["path"] === "string" &&
712
+ isRecord(start) &&
713
+ typeof start["line"] === "number" &&
714
+ typeof start["col"] === "number");
715
+ }
716
+ /** Validate the error fields consumed by this validator. */
717
+ function isSemgrepError(value) {
718
+ if (!isRecord(value))
719
+ return false;
720
+ if (typeof value["level"] !== "string")
721
+ return false;
722
+ if ("rule_id" in value && typeof value["rule_id"] !== "string")
723
+ return false;
724
+ if ("message" in value && typeof value["message"] !== "string")
725
+ return false;
726
+ // Semgrep encodes `type` as a tag name or a `[tagName, ...]` tuple; this
727
+ // validator now reads it to detect partial parsing, so anything else is
728
+ // treated as malformed rather than silently ignored.
729
+ if ("type" in value && !isSemgrepErrorType(value["type"]))
730
+ return false;
731
+ return true;
732
+ }
733
+ /** Whether a Semgrep error `type` field has a supported shape. */
734
+ function isSemgrepErrorType(value) {
735
+ if (typeof value === "string")
736
+ return true;
737
+ return Array.isArray(value) && typeof value[0] === "string";
738
+ }
739
+ /** The tag name of a Semgrep error `type` field, when it is well formed. */
740
+ function semgrepErrorTypeName(value) {
741
+ if (typeof value === "string")
742
+ return value;
743
+ if (Array.isArray(value) && typeof value[0] === "string")
744
+ return value[0];
745
+ return null;
746
+ }
747
+ /**
748
+ * Whether an error entry reports a file Semgrep could not fully parse.
749
+ *
750
+ * @remarks
751
+ * Semgrep 1.180.0 reports partial parsing at `level: "warn"` with the
752
+ * `PartialParsing` tag name while still exiting `0`, so the exit code alone
753
+ * cannot detect it. The tag name is the primary signal; a warning that names
754
+ * a syntax error is treated the same way so Semgrep versions that omit the
755
+ * tag name cannot silently skip a file. Warnings that do not indicate
756
+ * incomplete analysis keep their existing (non-fatal) treatment.
757
+ */
758
+ function isIncompleteAnalysisError(entry) {
759
+ const type = semgrepErrorTypeName(entry["type"]);
760
+ if (type !== null && /partialparsing/i.test(type))
761
+ return true;
762
+ const message = entry["message"];
763
+ return typeof message === "string" && /\bsyntax error\b/i.test(message);
764
+ }
765
+ /** The file an error entry refers to, when Semgrep reports one. */
766
+ function errorEntryPath(entry) {
767
+ const direct = entry["path"];
768
+ if (typeof direct === "string" && direct.length > 0)
769
+ return direct;
770
+ const spans = entry["spans"];
771
+ if (Array.isArray(spans)) {
772
+ for (const span of spans) {
773
+ if (isRecord(span) && typeof span["file"] === "string") {
774
+ return span["file"];
775
+ }
776
+ }
777
+ }
778
+ const locations = Array.isArray(entry["type"]) ? entry["type"][1] : undefined;
779
+ if (Array.isArray(locations)) {
780
+ for (const location of locations) {
781
+ if (isRecord(location) && typeof location["path"] === "string") {
782
+ return location["path"];
783
+ }
784
+ }
785
+ }
786
+ return "<unknown file>";
787
+ }
244
788
  function parseSemgrepOutput(stdout, stderr) {
245
- const out = stdout ?? stderr ?? "";
789
+ const out = stdout?.trim() ? stdout : (stderr ?? "");
246
790
  if (!out.trim())
247
- return [];
248
- let parsed = null;
791
+ return { findings: [], malformed: true, partialParsing: [] };
792
+ let parsedValue;
249
793
  try {
250
- parsed = JSON.parse(out);
794
+ parsedValue = JSON.parse(out);
251
795
  }
252
796
  catch {
253
- return [];
797
+ return { findings: [], malformed: true, partialParsing: [] };
798
+ }
799
+ if (!isRecord(parsedValue)) {
800
+ return { findings: [], malformed: true, partialParsing: [] };
801
+ }
802
+ const rawResults = parsedValue["results"];
803
+ const rawErrors = parsedValue["errors"];
804
+ if (!Array.isArray(rawResults)) {
805
+ return { findings: [], malformed: true, partialParsing: [] };
806
+ }
807
+ if (rawErrors !== undefined && !Array.isArray(rawErrors)) {
808
+ return { findings: [], malformed: true, partialParsing: [] };
809
+ }
810
+ if (!rawResults.every(isSemgrepResult)) {
811
+ return { findings: [], malformed: true, partialParsing: [] };
812
+ }
813
+ if (rawErrors !== undefined && !rawErrors.every(isSemgrepError)) {
814
+ return { findings: [], malformed: true, partialParsing: [] };
254
815
  }
255
816
  const findings = [];
256
- for (const e of parsed?.errors ?? []) {
257
- if (e.level === "error") {
817
+ const partialParsing = [];
818
+ for (const e of (rawErrors ?? [])) {
819
+ if (e["level"] === "error") {
258
820
  findings.push({
259
821
  tool: "semgrep",
260
- rule: `SEM-PARSE-${e.rule_id ?? "UNKNOWN"}`,
261
- message: e.message ?? "semgrep rule parse error",
822
+ rule: `SEM-PARSE-${typeof e["rule_id"] === "string" ? e["rule_id"] : "UNKNOWN"}`,
823
+ message: typeof e["message"] === "string"
824
+ ? e["message"]
825
+ : "semgrep rule parse error",
262
826
  severity: "error",
263
827
  });
828
+ continue;
829
+ }
830
+ if (isIncompleteAnalysisError(e)) {
831
+ partialParsing.push({
832
+ path: errorEntryPath(e),
833
+ message: typeof e["message"] === "string"
834
+ ? e["message"]
835
+ : "semgrep reported a file it could not fully parse",
836
+ });
264
837
  }
265
838
  }
266
- const results = parsed?.results ?? [];
267
- for (const r of results) {
839
+ for (const r of rawResults) {
268
840
  const start = r["start"];
269
841
  const extra = r["extra"];
270
842
  findings.push({
@@ -277,5 +849,5 @@ function parseSemgrepOutput(stdout, stderr) {
277
849
  severity: extra?.severity === "ERROR" ? "error" : "warning",
278
850
  });
279
851
  }
280
- return findings;
852
+ return { findings, malformed: false, partialParsing };
281
853
  }
package/dist/format.js CHANGED
@@ -27,6 +27,19 @@ function statusBadge(cat) {
27
27
  return cat.executed ? (cat.passed ? "PASS" : "FAIL") : "NOT-RUN";
28
28
  }
29
29
  }
30
+ /**
31
+ * The overall result line. A run that found no violations but could not
32
+ * execute a required stage is neither a pass nor a failure: it is
33
+ * incomplete. In particular the summary must never claim PASS while a
34
+ * category reports TOOL-UNAVAILABLE (the process exit code already
35
+ * distinguishes that case as TOOL_MISSING).
36
+ */
37
+ function overallResult(result) {
38
+ if (!result.passed)
39
+ return "FAIL";
40
+ const toolUnavailable = Object.values(result.categories).some((cat) => cat.status === "TOOL-UNAVAILABLE");
41
+ return toolUnavailable ? "INCOMPLETE" : "PASS";
42
+ }
30
43
  /**
31
44
  * Format a unified summary for the terminal.
32
45
  */
@@ -56,7 +69,7 @@ export function formatSummary(result) {
56
69
  }
57
70
  }
58
71
  lines.push("");
59
- lines.push(`Result: ${result.passed ? "PASS" : "FAIL"}`);
72
+ lines.push(`Result: ${overallResult(result)}`);
60
73
  return lines.join("\n");
61
74
  }
62
75
  /**
package/dist/index.d.ts CHANGED
@@ -6,4 +6,4 @@ export type { CategoryResult, RunOptions, RunResult } from "./run.js";
6
6
  export { discoverRepository } from "./discover.js";
7
7
  export { ExitCode } from "./exit-codes.js";
8
8
  export { formatSummary, formatViolations } from "./format.js";
9
- export { runValidation } from "./run.js";
9
+ export { exitCodeForResult, runValidation } from "./run.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { discoverRepository } from "./discover.js";
2
2
  export { ExitCode } from "./exit-codes.js";
3
3
  export { formatSummary, formatViolations } from "./format.js";
4
- export { runValidation } from "./run.js";
4
+ export { exitCodeForResult, runValidation } from "./run.js";
package/dist/run.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { EngineResult, Finding } from "./engines/types.js";
2
+ import type { ExitCodeValue } from "./exit-codes.js";
2
3
  export interface RunOptions {
3
4
  /** The root directory of the repository to validate */
4
5
  root: string;
@@ -54,3 +55,12 @@ export interface RunResult {
54
55
  * state. PASS without execution is impossible.
55
56
  */
56
57
  export declare function runValidation(options: RunOptions): Promise<RunResult>;
58
+ /**
59
+ * Map a validation result to the documented process exit code.
60
+ *
61
+ * A required tool that could not run must never surface as PASS: when
62
+ * every executed engine passed but at least one category reports
63
+ * `TOOL-UNAVAILABLE`, the result exits `TOOL_MISSING` (3) instead of
64
+ * fabricating a passing result.
65
+ */
66
+ export declare function exitCodeForResult(result: RunResult): ExitCodeValue;
package/dist/run.js CHANGED
@@ -6,6 +6,7 @@ import { runDepcruise } from "./engines/depcruise.js";
6
6
  import { runEslint } from "./engines/eslint.js";
7
7
  import { runSchema } from "./engines/schema.js";
8
8
  import { runSemgrep } from "./engines/semgrep.js";
9
+ import { ExitCode } from "./exit-codes.js";
9
10
  function categoryOf(result) {
10
11
  return {
11
12
  status: result.status,
@@ -17,6 +18,21 @@ function categoryOf(result) {
17
18
  reason: result.reason,
18
19
  };
19
20
  }
21
+ /**
22
+ * Whether a category is acceptable for the overall validation result.
23
+ *
24
+ * A skipped category is acceptable only when it is explicitly NOT-RUN or
25
+ * NOT-APPLICABLE, or when a required tool is unavailable (which the exit-code
26
+ * mapping reports separately). Configuration and execution errors are never
27
+ * acceptable merely because they did not execute.
28
+ */
29
+ function categoryIsAcceptable(result) {
30
+ if (result.executed)
31
+ return result.passed;
32
+ return (result.status === "NOT-RUN" ||
33
+ result.status === "NOT-APPLICABLE" ||
34
+ result.status === "TOOL-UNAVAILABLE");
35
+ }
20
36
  function emptyCategory(reason) {
21
37
  return {
22
38
  status: "NOT-RUN",
@@ -87,7 +103,7 @@ export async function runValidation(options) {
87
103
  }
88
104
  let semgrepResult;
89
105
  if (!only || only === "semgrep") {
90
- semgrepResult = await runSemgrep(repo);
106
+ semgrepResult = await runSemgrep(repo, { verbose: options.verbose === true });
91
107
  }
92
108
  else {
93
109
  semgrepResult = emptyEngineResult("semgrep engine skipped");
@@ -112,11 +128,9 @@ export async function runValidation(options) {
112
128
  }
113
129
  }
114
130
  const allExecuted = [depResult, eslintResult, semgrepResult, adapterResult];
115
- const allPassed = allExecuted.every((r) => (r.executed ? r.passed : r.status !== "FAIL")) &&
116
- (metaResult.executed ? metaResult.passed : metaResult.status !== "FAIL") &&
117
- (configResult.executed
118
- ? configResult.passed
119
- : configResult.status !== "FAIL");
131
+ const allPassed = allExecuted.every(categoryIsAcceptable) &&
132
+ categoryIsAcceptable(metaResult) &&
133
+ categoryIsAcceptable(configResult);
120
134
  return {
121
135
  passed: allPassed,
122
136
  violations,
@@ -142,3 +156,19 @@ function emptyEngineResult(reason) {
142
156
  reason,
143
157
  };
144
158
  }
159
+ /**
160
+ * Map a validation result to the documented process exit code.
161
+ *
162
+ * A required tool that could not run must never surface as PASS: when
163
+ * every executed engine passed but at least one category reports
164
+ * `TOOL-UNAVAILABLE`, the result exits `TOOL_MISSING` (3) instead of
165
+ * fabricating a passing result.
166
+ */
167
+ export function exitCodeForResult(result) {
168
+ if (!result.passed) {
169
+ const configurationError = Object.values(result.categories).some((category) => category.status === "CONFIGURATION-ERROR");
170
+ return configurationError ? ExitCode.CONFIG_ERROR : ExitCode.FAIL;
171
+ }
172
+ const toolUnavailable = Object.values(result.categories).some((c) => c.status === "TOOL-UNAVAILABLE");
173
+ return toolUnavailable ? ExitCode.TOOL_MISSING : ExitCode.PASS;
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comity-dev/validate",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -36,8 +36,8 @@
36
36
  "eslint": "^9.39.5",
37
37
  "@comity-dev/schemas": "0.2.0",
38
38
  "@comity-dev/dependency-rules": "0.2.0",
39
- "@comity-dev/eslint-plugin": "0.2.0",
40
- "@comity-dev/semgrep-rules": "0.2.0"
39
+ "@comity-dev/eslint-plugin": "0.3.0",
40
+ "@comity-dev/semgrep-rules": "0.2.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^24.13.3",