@comity-dev/validate 0.2.1 → 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 \
@@ -1,5 +1,37 @@
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;
3
35
  /**
4
36
  * Derive the Python interpreter from a semgrep executable's shebang
5
37
  * line. Shim installs (Homebrew, pipx, venv) point `#!` at the
@@ -37,4 +69,4 @@ export declare function resolveInterpreterFromBin(binPath: string, env?: NodeJS.
37
69
  * report it as invalid configuration rather than falling back silently.
38
70
  */
39
71
  export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv, binPath?: string): string | null;
40
- export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
72
+ export declare function runSemgrep(repo: Repository, options?: SemgrepOptions): Promise<EngineResult>;
@@ -1,7 +1,222 @@
1
1
  import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
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
+ }
5
220
  /**
6
221
  * Derive the Python interpreter from a semgrep executable's shebang
7
222
  * line. Shim installs (Homebrew, pipx, venv) point `#!` at the
@@ -186,8 +401,21 @@ function buildSemgrepArgs(configPath, target, excludedDirs) {
186
401
  }
187
402
  return args;
188
403
  }
189
- export async function runSemgrep(repo) {
404
+ export async function runSemgrep(repo, options = {}) {
190
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
+ }
191
419
  // Build the combined Semgrep config. Semgrep's CLI accepts a single
192
420
  // --config path, so we concatenate all canonical rule files into one
193
421
  // synthesized config.
@@ -281,73 +509,334 @@ export async function runSemgrep(repo) {
281
509
  };
282
510
  }
283
511
  const args = buildSemgrepArgs(configPath, target, excludedDirs);
284
- const proc = spawnSync(pythonBin, args, {
512
+ const outcome = await runSemgrepProcess(pythonBin, args, {
285
513
  cwd: repo.root,
286
514
  env: { ...process.env, PATH: envPath },
287
- maxBuffer: 16 * 1024 * 1024,
288
- encoding: "utf8",
515
+ timeoutMs: timeout.timeoutMs,
516
+ verbose: options.verbose === true,
289
517
  });
290
518
  cleanup();
291
- const stdout = proc.stdout ?? "";
292
- const stderr = proc.stderr ?? "";
293
- 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
+ }
294
630
  // If semgrep exited non-zero but produced no parseable findings, this
295
631
  // is an EXECUTION-ERROR (configuration, rule-parse, internal crash),
296
632
  // not a policy violation.
297
- if (proc.status !== null && proc.status !== 0 && findings.length === 0) {
633
+ if (outcome.status !== null && outcome.status !== 0 && findings.length === 0) {
298
634
  const stderrHead = stderr.split("\n")[0] ?? "";
299
635
  const stdoutHead = stdout.slice(0, 500);
300
636
  findings.push({
301
637
  tool: "semgrep",
302
638
  rule: "SEM-CRASH",
303
- 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}`,
304
640
  severity: "error",
305
641
  });
306
642
  return {
307
643
  executed: true,
308
- exitCode: proc.status,
644
+ exitCode: outcome.status,
309
645
  passed: false,
310
646
  findings,
311
- 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,
312
667
  status: "EXECUTION-ERROR",
313
668
  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`,
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,
687
+ status: "EXECUTION-ERROR",
688
+ tool: "semgrep",
689
+ reason: message,
315
690
  };
316
691
  }
317
692
  return {
318
693
  executed: true,
319
- exitCode: proc.status,
320
- passed: proc.status === 0 && findings.length === 0,
694
+ exitCode: outcome.status,
695
+ passed: outcome.status === 0 && findings.length === 0,
321
696
  findings,
322
- duration: Date.now() - start,
323
- status: proc.status === 0 ? "PASS" : "FAIL",
697
+ duration,
698
+ status: outcome.status === 0 && findings.length === 0 ? "PASS" : "FAIL",
324
699
  tool: "semgrep",
325
700
  };
326
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
+ }
327
788
  function parseSemgrepOutput(stdout, stderr) {
328
- const out = stdout ?? stderr ?? "";
789
+ const out = stdout?.trim() ? stdout : (stderr ?? "");
329
790
  if (!out.trim())
330
- return [];
331
- let parsed = null;
791
+ return { findings: [], malformed: true, partialParsing: [] };
792
+ let parsedValue;
332
793
  try {
333
- parsed = JSON.parse(out);
794
+ parsedValue = JSON.parse(out);
334
795
  }
335
796
  catch {
336
- 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: [] };
337
815
  }
338
816
  const findings = [];
339
- for (const e of parsed?.errors ?? []) {
340
- if (e.level === "error") {
817
+ const partialParsing = [];
818
+ for (const e of (rawErrors ?? [])) {
819
+ if (e["level"] === "error") {
341
820
  findings.push({
342
821
  tool: "semgrep",
343
- rule: `SEM-PARSE-${e.rule_id ?? "UNKNOWN"}`,
344
- 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",
345
826
  severity: "error",
346
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
+ });
347
837
  }
348
838
  }
349
- const results = parsed?.results ?? [];
350
- for (const r of results) {
839
+ for (const r of rawResults) {
351
840
  const start = r["start"];
352
841
  const extra = r["extra"];
353
842
  findings.push({
@@ -360,5 +849,5 @@ function parseSemgrepOutput(stdout, stderr) {
360
849
  severity: extra?.severity === "ERROR" ? "error" : "warning",
361
850
  });
362
851
  }
363
- return findings;
852
+ return { findings, malformed: false, partialParsing };
364
853
  }
package/dist/run.js CHANGED
@@ -18,6 +18,21 @@ function categoryOf(result) {
18
18
  reason: result.reason,
19
19
  };
20
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
+ }
21
36
  function emptyCategory(reason) {
22
37
  return {
23
38
  status: "NOT-RUN",
@@ -88,7 +103,7 @@ export async function runValidation(options) {
88
103
  }
89
104
  let semgrepResult;
90
105
  if (!only || only === "semgrep") {
91
- semgrepResult = await runSemgrep(repo);
106
+ semgrepResult = await runSemgrep(repo, { verbose: options.verbose === true });
92
107
  }
93
108
  else {
94
109
  semgrepResult = emptyEngineResult("semgrep engine skipped");
@@ -113,11 +128,9 @@ export async function runValidation(options) {
113
128
  }
114
129
  }
115
130
  const allExecuted = [depResult, eslintResult, semgrepResult, adapterResult];
116
- const allPassed = allExecuted.every((r) => (r.executed ? r.passed : r.status !== "FAIL")) &&
117
- (metaResult.executed ? metaResult.passed : metaResult.status !== "FAIL") &&
118
- (configResult.executed
119
- ? configResult.passed
120
- : configResult.status !== "FAIL");
131
+ const allPassed = allExecuted.every(categoryIsAcceptable) &&
132
+ categoryIsAcceptable(metaResult) &&
133
+ categoryIsAcceptable(configResult);
121
134
  return {
122
135
  passed: allPassed,
123
136
  violations,
@@ -152,8 +165,10 @@ function emptyEngineResult(reason) {
152
165
  * fabricating a passing result.
153
166
  */
154
167
  export function exitCodeForResult(result) {
155
- if (!result.passed)
156
- return ExitCode.FAIL;
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
+ }
157
172
  const toolUnavailable = Object.values(result.categories).some((c) => c.status === "TOOL-UNAVAILABLE");
158
173
  return toolUnavailable ? ExitCode.TOOL_MISSING : ExitCode.PASS;
159
174
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comity-dev/validate",
3
- "version": "0.2.1",
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.1"
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",