@comity-dev/validate 0.2.0 → 0.2.1

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.
@@ -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,40 @@
1
1
  import type { Repository } from "../discover.js";
2
2
  import type { EngineResult } from "./types.js";
3
+ /**
4
+ * Derive the Python interpreter from a semgrep executable's shebang
5
+ * line. Shim installs (Homebrew, pipx, venv) point `#!` at the
6
+ * interpreter that ships the `semgrep` module; resolving it keeps the
7
+ * scan on a working interpreter instead of an unrelated system python
8
+ * that merely exists on PATH.
9
+ *
10
+ * @param binPath - Path to the semgrep executable.
11
+ * @param env - Environment to read for `/usr/bin/env` resolution.
12
+ *
13
+ * @returns The interpreter path when the file starts with a usable
14
+ * `#!` line naming an existing executable, otherwise `null`.
15
+ */
16
+ export declare function resolveInterpreterFromBin(binPath: string, env?: NodeJS.ProcessEnv): string | null;
3
17
  /**
4
18
  * Discover the Python interpreter that ships the `semgrep` module.
5
19
  *
6
20
  * Selection order:
7
21
  * 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
8
22
  * configuration always wins and is never silently overridden.
9
- * 2. Well-known absolute paths, then `python3` on PATH (historic default).
23
+ * 2. The interpreter named by the semgrep executable's shebang line
24
+ * (shim installs point at the interpreter that ships the module).
25
+ * 3. Well-known absolute paths, then `python3` on PATH (historic default).
26
+ *
27
+ * Every auto-discovered candidate (steps 2-3) must both exist and
28
+ * successfully `import semgrep`; the first candidate that does wins.
29
+ * An explicit `SEMGREP_PYTHON` is honored as-is for backwards
30
+ * compatibility.
10
31
  *
11
32
  * @param env - Environment to read. Defaults to the process environment.
33
+ * @param binPath - Resolved semgrep executable used for step 2.
12
34
  *
13
35
  * @returns The selected interpreter path, or `null` when none is available.
14
36
  * A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
15
37
  * report it as invalid configuration rather than falling back silently.
16
38
  */
17
- export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv): string | null;
39
+ export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv, binPath?: string): string | null;
18
40
  export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
@@ -1,41 +1,121 @@
1
1
  import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, writeFileSync, } from "node:fs";
4
4
  import { relative, resolve } from "node:path";
5
+ /**
6
+ * Derive the Python interpreter from a semgrep executable's shebang
7
+ * line. Shim installs (Homebrew, pipx, venv) point `#!` at the
8
+ * interpreter that ships the `semgrep` module; resolving it keeps the
9
+ * scan on a working interpreter instead of an unrelated system python
10
+ * that merely exists on PATH.
11
+ *
12
+ * @param binPath - Path to the semgrep executable.
13
+ * @param env - Environment to read for `/usr/bin/env` resolution.
14
+ *
15
+ * @returns The interpreter path when the file starts with a usable
16
+ * `#!` line naming an existing executable, otherwise `null`.
17
+ */
18
+ export function resolveInterpreterFromBin(binPath, env = process.env) {
19
+ let head;
20
+ try {
21
+ const fd = openSync(binPath, "r");
22
+ try {
23
+ const buf = Buffer.alloc(2048);
24
+ const read = readSync(fd, buf, 0, buf.length, 0);
25
+ head = buf.subarray(0, read).toString("utf8").split("\n")[0] ?? "";
26
+ }
27
+ finally {
28
+ closeSync(fd);
29
+ }
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ if (!head.startsWith("#!"))
35
+ return null;
36
+ const parts = head.slice(2).trim().split(/\s+/).filter(Boolean);
37
+ const [first, second] = parts;
38
+ if (!first)
39
+ return null;
40
+ // `#!/usr/bin/env python3 ...` form: resolve the named interpreter
41
+ // against PATH the same way a shell would.
42
+ if (first === "env" || first.endsWith("/env")) {
43
+ if (!second)
44
+ return null;
45
+ const probe = spawnSync("which", [second], {
46
+ encoding: "utf8",
47
+ env: { ...env, PATH: env["PATH"] ?? "" },
48
+ });
49
+ if (probe.status === 0 && probe.stdout?.trim()) {
50
+ return probe.stdout.trim();
51
+ }
52
+ return null;
53
+ }
54
+ return existsSync(first) ? first : null;
55
+ }
56
+ /**
57
+ * Probe whether an interpreter ships the `semgrep` module, so a
58
+ * resolved python is never assumed to work by mere existence.
59
+ */
60
+ function interpreterShipsSemgrep(interpreter) {
61
+ try {
62
+ const probe = spawnSync(interpreter, ["-c", "import semgrep"], {
63
+ encoding: "utf8",
64
+ timeout: 30000,
65
+ });
66
+ return probe.status === 0;
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
5
72
  /**
6
73
  * Discover the Python interpreter that ships the `semgrep` module.
7
74
  *
8
75
  * Selection order:
9
76
  * 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
10
77
  * configuration always wins and is never silently overridden.
11
- * 2. Well-known absolute paths, then `python3` on PATH (historic default).
78
+ * 2. The interpreter named by the semgrep executable's shebang line
79
+ * (shim installs point at the interpreter that ships the module).
80
+ * 3. Well-known absolute paths, then `python3` on PATH (historic default).
81
+ *
82
+ * Every auto-discovered candidate (steps 2-3) must both exist and
83
+ * successfully `import semgrep`; the first candidate that does wins.
84
+ * An explicit `SEMGREP_PYTHON` is honored as-is for backwards
85
+ * compatibility.
12
86
  *
13
87
  * @param env - Environment to read. Defaults to the process environment.
88
+ * @param binPath - Resolved semgrep executable used for step 2.
14
89
  *
15
90
  * @returns The selected interpreter path, or `null` when none is available.
16
91
  * A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
17
92
  * report it as invalid configuration rather than falling back silently.
18
93
  */
19
- export function resolvePythonForSemgrep(env = process.env) {
94
+ export function resolvePythonForSemgrep(env = process.env, binPath) {
20
95
  const explicit = env["SEMGREP_PYTHON"];
21
96
  if (explicit) {
22
97
  return existsSync(explicit) ? explicit : null;
23
98
  }
24
- const candidates = [
25
- "/usr/bin/python3",
26
- "/usr/local/bin/python3",
27
- "/opt/homebrew/bin/python3",
28
- ];
99
+ const candidates = [];
100
+ if (binPath) {
101
+ const fromBin = resolveInterpreterFromBin(binPath, env);
102
+ if (fromBin)
103
+ candidates.push(fromBin);
104
+ }
105
+ candidates.push("/usr/bin/python3", "/usr/local/bin/python3", "/opt/homebrew/bin/python3");
29
106
  for (const c of candidates) {
30
- if (existsSync(c))
107
+ if (existsSync(c) && interpreterShipsSemgrep(c))
31
108
  return c;
32
109
  }
33
110
  const probe = spawnSync("which", ["python3"], {
34
111
  encoding: "utf8",
35
112
  env: { ...env, PATH: env["PATH"] ?? "" },
36
113
  });
37
- if (probe.status === 0 && probe.stdout?.trim())
38
- return probe.stdout.trim();
114
+ if (probe.status === 0 && probe.stdout?.trim()) {
115
+ const viaPath = probe.stdout.trim();
116
+ if (interpreterShipsSemgrep(viaPath))
117
+ return viaPath;
118
+ }
39
119
  return null;
40
120
  }
41
121
  function findSemgrepBin(fallbackDirs) {
@@ -140,10 +220,12 @@ export async function runSemgrep(repo) {
140
220
  reason: "semgrep binary not found on PATH; install semgrep or set SEMGREP_BIN",
141
221
  };
142
222
  }
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();
223
+ // Locate the Python interpreter that ships the semgrep module,
224
+ // preferring the interpreter named by the semgrep executable's own
225
+ // shebang line. An explicit SEMGREP_PYTHON is honored as-is; a
226
+ // set-but-missing value is invalid configuration, never a reason to
227
+ // silently fall back.
228
+ const pythonBin = resolvePythonForSemgrep(process.env, semgrepBin ?? undefined);
147
229
  if (!pythonBin) {
148
230
  cleanup();
149
231
  const explicitPython = process.env["SEMGREP_PYTHON"];
@@ -157,7 +239,7 @@ export async function runSemgrep(repo) {
157
239
  tool: "semgrep",
158
240
  reason: explicitPython
159
241
  ? `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",
242
+ : `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
243
  };
162
244
  }
163
245
  // Augment PATH so the inner Python process can find semgrep's
@@ -229,6 +311,7 @@ export async function runSemgrep(repo) {
229
311
  duration: Date.now() - start,
230
312
  status: "EXECUTION-ERROR",
231
313
  tool: "semgrep",
314
+ reason: `semgrep exited with code ${proc.status} before producing findings (stderr: ${stderrHead}); this is a tool execution failure, not a rule match — see the SEM-CRASH finding`,
232
315
  };
233
316
  }
234
317
  return {
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,
@@ -142,3 +143,17 @@ function emptyEngineResult(reason) {
142
143
  reason,
143
144
  };
144
145
  }
146
+ /**
147
+ * Map a validation result to the documented process exit code.
148
+ *
149
+ * A required tool that could not run must never surface as PASS: when
150
+ * every executed engine passed but at least one category reports
151
+ * `TOOL-UNAVAILABLE`, the result exits `TOOL_MISSING` (3) instead of
152
+ * fabricating a passing result.
153
+ */
154
+ export function exitCodeForResult(result) {
155
+ if (!result.passed)
156
+ return ExitCode.FAIL;
157
+ const toolUnavailable = Object.values(result.categories).some((c) => c.status === "TOOL-UNAVAILABLE");
158
+ return toolUnavailable ? ExitCode.TOOL_MISSING : ExitCode.PASS;
159
+ }
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.1",
4
4
  "description": "Thin orchestrator over Comity shared validation tooling. Development tooling.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,7 +37,7 @@
37
37
  "@comity-dev/schemas": "0.2.0",
38
38
  "@comity-dev/dependency-rules": "0.2.0",
39
39
  "@comity-dev/eslint-plugin": "0.2.0",
40
- "@comity-dev/semgrep-rules": "0.2.0"
40
+ "@comity-dev/semgrep-rules": "0.2.1"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^24.13.3",