@comity-dev/validate 0.1.2 → 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.
@@ -0,0 +1,48 @@
1
+ import { comityConfigSchema, createAjvInstance, packageMetadataSchema, } from "@comity-dev/schemas";
2
+ export function runSchema(repo) {
3
+ const start = Date.now();
4
+ const findings = [];
5
+ const ajv = createAjvInstance();
6
+ // Per-package metadata
7
+ const validateMeta = ajv.compile(packageMetadataSchema);
8
+ for (const pkg of repo.packages) {
9
+ const valid = validateMeta(pkg.manifest);
10
+ if (!valid) {
11
+ for (const err of validateMeta.errors ?? []) {
12
+ findings.push({
13
+ tool: "schema",
14
+ rule: "package-metadata",
15
+ package: pkg.name,
16
+ path: err.instancePath,
17
+ message: err.message ?? "schema violation",
18
+ keyword: err.keyword,
19
+ severity: "error",
20
+ });
21
+ }
22
+ }
23
+ }
24
+ // Repository config
25
+ const validateConfig = ajv.compile(comityConfigSchema);
26
+ const configValid = validateConfig(repo.config);
27
+ if (!configValid) {
28
+ for (const err of validateConfig.errors ?? []) {
29
+ findings.push({
30
+ tool: "schema",
31
+ rule: "comity-config",
32
+ path: err.instancePath,
33
+ message: err.message ?? "schema violation",
34
+ keyword: err.keyword,
35
+ severity: "error",
36
+ });
37
+ }
38
+ }
39
+ return {
40
+ executed: true,
41
+ exitCode: 0,
42
+ passed: findings.length === 0,
43
+ findings,
44
+ duration: Date.now() - start,
45
+ status: findings.length === 0 ? "PASS" : "FAIL",
46
+ tool: "schema",
47
+ };
48
+ }
@@ -0,0 +1,40 @@
1
+ import type { Repository } from "../discover.js";
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;
17
+ /**
18
+ * Discover the Python interpreter that ships the `semgrep` module.
19
+ *
20
+ * Selection order:
21
+ * 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
22
+ * configuration always wins and is never silently overridden.
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.
31
+ *
32
+ * @param env - Environment to read. Defaults to the process environment.
33
+ * @param binPath - Resolved semgrep executable used for step 2.
34
+ *
35
+ * @returns The selected interpreter path, or `null` when none is available.
36
+ * A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
37
+ * report it as invalid configuration rather than falling back silently.
38
+ */
39
+ export declare function resolvePythonForSemgrep(env?: NodeJS.ProcessEnv, binPath?: string): string | null;
40
+ export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
@@ -0,0 +1,364 @@
1
+ import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
2
+ import { spawnSync } from "node:child_process";
3
+ import { closeSync, existsSync, mkdtempSync, openSync, readSync, rmSync, writeFileSync, } from "node:fs";
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
+ }
72
+ /**
73
+ * Discover the Python interpreter that ships the `semgrep` module.
74
+ *
75
+ * Selection order:
76
+ * 1. `SEMGREP_PYTHON` when set to an existing path — explicit operator
77
+ * configuration always wins and is never silently overridden.
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.
86
+ *
87
+ * @param env - Environment to read. Defaults to the process environment.
88
+ * @param binPath - Resolved semgrep executable used for step 2.
89
+ *
90
+ * @returns The selected interpreter path, or `null` when none is available.
91
+ * A set-but-missing `SEMGREP_PYTHON` also yields `null` so the caller can
92
+ * report it as invalid configuration rather than falling back silently.
93
+ */
94
+ export function resolvePythonForSemgrep(env = process.env, binPath) {
95
+ const explicit = env["SEMGREP_PYTHON"];
96
+ if (explicit) {
97
+ return existsSync(explicit) ? explicit : null;
98
+ }
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");
106
+ for (const c of candidates) {
107
+ if (existsSync(c) && interpreterShipsSemgrep(c))
108
+ return c;
109
+ }
110
+ const probe = spawnSync("which", ["python3"], {
111
+ encoding: "utf8",
112
+ env: { ...env, PATH: env["PATH"] ?? "" },
113
+ });
114
+ if (probe.status === 0 && probe.stdout?.trim()) {
115
+ const viaPath = probe.stdout.trim();
116
+ if (interpreterShipsSemgrep(viaPath))
117
+ return viaPath;
118
+ }
119
+ return null;
120
+ }
121
+ function findSemgrepBin(fallbackDirs) {
122
+ const explicitBin = process.env["SEMGREP_BIN"];
123
+ if (explicitBin && existsSync(explicitBin))
124
+ return explicitBin;
125
+ const probe = spawnSync("which", ["semgrep"], {
126
+ encoding: "utf8",
127
+ env: {
128
+ ...process.env,
129
+ PATH: `${fallbackDirs.join(":")}:${process.env["PATH"] ?? ""}`,
130
+ },
131
+ });
132
+ if (probe.status === 0 && probe.stdout?.trim()) {
133
+ return probe.stdout.trim();
134
+ }
135
+ const absoluteCandidates = [
136
+ "/usr/bin/semgrep",
137
+ "/usr/local/bin/semgrep",
138
+ "/opt/homebrew/bin/semgrep",
139
+ ];
140
+ for (const c of absoluteCandidates) {
141
+ if (existsSync(c))
142
+ return c;
143
+ }
144
+ return null;
145
+ }
146
+ /**
147
+ * Returns the set of package directory names that should be excluded
148
+ * from Semgrep scanning. The Development-owned runtime rule set
149
+ * (`@comity-dev/semgrep-rules`) targets Comity Core Modules and
150
+ * Adapters; it MUST NOT fire on Development tooling packages
151
+ * (`comity.layer: "dev-tooling"`) because those packages legitimately
152
+ * use Node APIs, `process.env`, and other patterns the rules forbid
153
+ * for runtime Core Modules.
154
+ */
155
+ function devToolingPackageDirs(repo) {
156
+ const dirs = [];
157
+ for (const pkg of repo.packages) {
158
+ if (pkg.layer === "dev-tooling") {
159
+ const rel = relative(repo.root, pkg.path);
160
+ const top = rel.split("/")[0];
161
+ if (top)
162
+ dirs.push(top);
163
+ }
164
+ }
165
+ return [...new Set(dirs)];
166
+ }
167
+ function buildSemgrepArgs(configPath, target, excludedDirs) {
168
+ const args = [
169
+ "-c",
170
+ "from semgrep.console_scripts.entrypoint import main; main()",
171
+ "scan",
172
+ "--config",
173
+ configPath,
174
+ "--json",
175
+ "--quiet",
176
+ "--error",
177
+ target,
178
+ ];
179
+ // Exclude dev-tooling package directories by both path-pattern
180
+ // (via --exclude) and directory name (via --exclude-dir) so semgrep
181
+ // does not scan them at all. The runtime rule set is not designed
182
+ // for development-tooling source.
183
+ for (const d of excludedDirs) {
184
+ args.push("--exclude", `**/packages/${d}/**`);
185
+ args.push("--exclude-dir", d);
186
+ }
187
+ return args;
188
+ }
189
+ export async function runSemgrep(repo) {
190
+ const start = Date.now();
191
+ // Build the combined Semgrep config. Semgrep's CLI accepts a single
192
+ // --config path, so we concatenate all canonical rule files into one
193
+ // synthesized config.
194
+ const configYaml = await buildSemgrepConfig();
195
+ const tmpDir = mkdtempSync("/tmp/comity-validate-semgrep-");
196
+ const configPath = `${tmpDir}/semgrep-config.yaml`;
197
+ writeFileSync(configPath, configYaml);
198
+ const pathDirs = (process.env["PATH"] ?? "").split(":").filter(Boolean);
199
+ const fallbackDirs = ["/usr/local/bin", "/opt/homebrew/bin"];
200
+ const candidateDirs = [...new Set([...pathDirs, ...fallbackDirs])];
201
+ const cleanup = () => {
202
+ try {
203
+ rmSync(tmpDir, { recursive: true, force: true });
204
+ }
205
+ catch {
206
+ // ignore
207
+ }
208
+ };
209
+ const semgrepBin = findSemgrepBin(fallbackDirs);
210
+ if (!semgrepBin) {
211
+ cleanup();
212
+ return {
213
+ executed: false,
214
+ exitCode: null,
215
+ passed: false,
216
+ findings: [],
217
+ duration: Date.now() - start,
218
+ status: "TOOL-UNAVAILABLE",
219
+ tool: "semgrep",
220
+ reason: "semgrep binary not found on PATH; install semgrep or set SEMGREP_BIN",
221
+ };
222
+ }
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);
229
+ if (!pythonBin) {
230
+ cleanup();
231
+ const explicitPython = process.env["SEMGREP_PYTHON"];
232
+ return {
233
+ executed: false,
234
+ exitCode: null,
235
+ passed: false,
236
+ findings: [],
237
+ duration: Date.now() - start,
238
+ status: "TOOL-UNAVAILABLE",
239
+ tool: "semgrep",
240
+ reason: explicitPython
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`
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`,
243
+ };
244
+ }
245
+ // Augment PATH so the inner Python process can find semgrep's
246
+ // sibling `pysemgrep` script (semgrep's `subprocess.run` does an
247
+ // `execvp("pysemgrep")` lookup against this PATH). Directories of the
248
+ // explicitly configured SEMGREP_BIN and resolved interpreter come first
249
+ // so a locally installed Semgrep resolves its own sibling scripts
250
+ // instead of whatever happens to be on PATH.
251
+ const configuredDirs = [semgrepBin, pythonBin]
252
+ .map((p) => p.slice(0, Math.max(0, p.lastIndexOf("/"))))
253
+ .filter((d) => d.length > 0);
254
+ const envPath = [
255
+ ...new Set(configuredDirs),
256
+ ...candidateDirs,
257
+ "/Users/filippo/Library/Python/3.14/bin",
258
+ "/Users/filippo/Library/Python/3.13/bin",
259
+ "/Users/filippo/Library/Python/3.12/bin",
260
+ process.env["PATH"] ?? "",
261
+ "/usr/bin",
262
+ "/bin",
263
+ ].join(":");
264
+ const target = resolve(repo.root, "packages");
265
+ const excludedDirs = devToolingPackageDirs(repo);
266
+ // If every discovered package is dev-tooling, the runtime rule
267
+ // set is not applicable to this repository. Report NOT-APPLICABLE
268
+ // rather than running semgrep against an empty target.
269
+ const runtimePackages = repo.packages.filter((p) => p.layer !== "dev-tooling");
270
+ if (runtimePackages.length === 0) {
271
+ cleanup();
272
+ return {
273
+ executed: false,
274
+ exitCode: null,
275
+ passed: true,
276
+ findings: [],
277
+ duration: Date.now() - start,
278
+ status: "NOT-APPLICABLE",
279
+ tool: "semgrep",
280
+ reason: "Repository contains no runtime packages; the @comity-dev/semgrep-rules rule set targets Comity Core Modules and Adapters and is not applicable to development-tooling-only repositories.",
281
+ };
282
+ }
283
+ const args = buildSemgrepArgs(configPath, target, excludedDirs);
284
+ const proc = spawnSync(pythonBin, args, {
285
+ cwd: repo.root,
286
+ env: { ...process.env, PATH: envPath },
287
+ maxBuffer: 16 * 1024 * 1024,
288
+ encoding: "utf8",
289
+ });
290
+ cleanup();
291
+ const stdout = proc.stdout ?? "";
292
+ const stderr = proc.stderr ?? "";
293
+ const findings = parseSemgrepOutput(stdout, stderr);
294
+ // If semgrep exited non-zero but produced no parseable findings, this
295
+ // is an EXECUTION-ERROR (configuration, rule-parse, internal crash),
296
+ // not a policy violation.
297
+ if (proc.status !== null && proc.status !== 0 && findings.length === 0) {
298
+ const stderrHead = stderr.split("\n")[0] ?? "";
299
+ const stdoutHead = stdout.slice(0, 500);
300
+ findings.push({
301
+ tool: "semgrep",
302
+ rule: "SEM-CRASH",
303
+ message: `semgrep exited with code ${proc.status}; stderr: ${stderrHead}; stdout (first 500): ${stdoutHead}`,
304
+ severity: "error",
305
+ });
306
+ return {
307
+ executed: true,
308
+ exitCode: proc.status,
309
+ passed: false,
310
+ findings,
311
+ duration: Date.now() - start,
312
+ status: "EXECUTION-ERROR",
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`,
315
+ };
316
+ }
317
+ return {
318
+ executed: true,
319
+ exitCode: proc.status,
320
+ passed: proc.status === 0 && findings.length === 0,
321
+ findings,
322
+ duration: Date.now() - start,
323
+ status: proc.status === 0 ? "PASS" : "FAIL",
324
+ tool: "semgrep",
325
+ };
326
+ }
327
+ function parseSemgrepOutput(stdout, stderr) {
328
+ const out = stdout ?? stderr ?? "";
329
+ if (!out.trim())
330
+ return [];
331
+ let parsed = null;
332
+ try {
333
+ parsed = JSON.parse(out);
334
+ }
335
+ catch {
336
+ return [];
337
+ }
338
+ const findings = [];
339
+ for (const e of parsed?.errors ?? []) {
340
+ if (e.level === "error") {
341
+ findings.push({
342
+ tool: "semgrep",
343
+ rule: `SEM-PARSE-${e.rule_id ?? "UNKNOWN"}`,
344
+ message: e.message ?? "semgrep rule parse error",
345
+ severity: "error",
346
+ });
347
+ }
348
+ }
349
+ const results = parsed?.results ?? [];
350
+ for (const r of results) {
351
+ const start = r["start"];
352
+ const extra = r["extra"];
353
+ findings.push({
354
+ tool: "semgrep",
355
+ rule: `SEM-${String(r["check_id"] ?? "UNKNOWN")}`,
356
+ message: extra?.message ?? "Semgrep rule violation",
357
+ file: typeof r["path"] === "string" ? r["path"] : undefined,
358
+ line: start?.line,
359
+ column: start?.col,
360
+ severity: extra?.severity === "ERROR" ? "error" : "warning",
361
+ });
362
+ }
363
+ return findings;
364
+ }
@@ -0,0 +1,25 @@
1
+ export type EngineStatus = "PASS" | "FAIL" | "NOT-RUN" | "TOOL-UNAVAILABLE" | "CONFIGURATION-ERROR" | "EXECUTION-ERROR" | "NOT-APPLICABLE";
2
+ export interface Finding {
3
+ tool: string;
4
+ rule: string;
5
+ package?: string | undefined;
6
+ path?: string | undefined;
7
+ message: string;
8
+ severity: "error" | "warning";
9
+ file?: string | undefined;
10
+ line?: number | undefined;
11
+ column?: number | undefined;
12
+ keyword?: string | undefined;
13
+ source?: string | undefined;
14
+ remediation?: string | undefined;
15
+ }
16
+ export interface EngineResult {
17
+ executed: boolean;
18
+ exitCode: number | null;
19
+ passed: boolean;
20
+ findings: Finding[];
21
+ duration: number;
22
+ status: EngineStatus;
23
+ tool?: string | undefined;
24
+ reason?: string | undefined;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Canonical exit codes for `comity validate`.
3
+ *
4
+ * 0 = validation passed
5
+ * 1 = validation failed (violations found)
6
+ * 2 = configuration / discovery error (cannot run)
7
+ * 3 = tool not installed (e.g. dependency-cruiser missing)
8
+ */
9
+ export declare const ExitCode: Readonly<{
10
+ readonly PASS: 0;
11
+ readonly FAIL: 1;
12
+ readonly CONFIG_ERROR: 2;
13
+ readonly TOOL_MISSING: 3;
14
+ }>;
15
+ export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode];
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Canonical exit codes for `comity validate`.
3
+ *
4
+ * 0 = validation passed
5
+ * 1 = validation failed (violations found)
6
+ * 2 = configuration / discovery error (cannot run)
7
+ * 3 = tool not installed (e.g. dependency-cruiser missing)
8
+ */
9
+ export const ExitCode = Object.freeze({
10
+ PASS: 0,
11
+ FAIL: 1,
12
+ CONFIG_ERROR: 2,
13
+ TOOL_MISSING: 3,
14
+ });
@@ -0,0 +1,33 @@
1
+ import type { Finding } from "./engines/types.js";
2
+ import type { CategoryResult } from "./run.js";
3
+ export interface FormatInput {
4
+ /** Whether the overall validation passed or failed */
5
+ passed: boolean;
6
+ /** The list of all findings across all categories */
7
+ violations: Finding[];
8
+ /** The results of each validation category */
9
+ categories: {
10
+ /** Schema validation results */
11
+ schema: CategoryResult;
12
+ /** Package metadata validation results */
13
+ metadata: CategoryResult;
14
+ /** Comity config validation results */
15
+ config: CategoryResult;
16
+ /** Dependency validation results */
17
+ dependencies: CategoryResult;
18
+ /** ESLint validation results */
19
+ eslint: CategoryResult;
20
+ /** Semgrep validation results */
21
+ semgrep: CategoryResult;
22
+ /** Adapter peers validation results */
23
+ adapterPeers: CategoryResult;
24
+ };
25
+ }
26
+ /**
27
+ * Format a unified summary for the terminal.
28
+ */
29
+ export declare function formatSummary(result: FormatInput): string;
30
+ /**
31
+ * Format detailed violations for debugging.
32
+ */
33
+ export declare function formatViolations(violations: Finding[]): string;
package/dist/format.js ADDED
@@ -0,0 +1,89 @@
1
+ const CATEGORY_LABELS = {
2
+ schema: "Schema",
3
+ metadata: "Package metadata",
4
+ config: "Comity config",
5
+ dependencies: "Dependencies (depcruise)",
6
+ eslint: "Source rules (ESLint)",
7
+ semgrep: "Structural patterns (Semgrep)",
8
+ adapterPeers: "Adapter peers",
9
+ };
10
+ function statusBadge(cat) {
11
+ switch (cat.status) {
12
+ case "PASS":
13
+ return "PASS";
14
+ case "FAIL":
15
+ return "FAIL";
16
+ case "NOT-RUN":
17
+ return "NOT-RUN";
18
+ case "TOOL-UNAVAILABLE":
19
+ return "TOOL-UNAVAILABLE";
20
+ case "CONFIGURATION-ERROR":
21
+ return "CONFIG-ERROR";
22
+ case "EXECUTION-ERROR":
23
+ return "EXEC-ERROR";
24
+ case "NOT-APPLICABLE":
25
+ return "NOT-APPLICABLE";
26
+ default:
27
+ return cat.executed ? (cat.passed ? "PASS" : "FAIL") : "NOT-RUN";
28
+ }
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
+ }
43
+ /**
44
+ * Format a unified summary for the terminal.
45
+ */
46
+ export function formatSummary(result) {
47
+ const lines = ["Comity validation", ""];
48
+ for (const [key, label] of Object.entries(CATEGORY_LABELS)) {
49
+ const cat = result.categories[key];
50
+ if (!cat)
51
+ continue;
52
+ const badge = statusBadge(cat);
53
+ const count = cat.findings > 0 ? ` (${cat.findings} findings)` : "";
54
+ const exit = cat.exitCode !== null && cat.exitCode !== 0
55
+ ? ` exit=${cat.exitCode}`
56
+ : "";
57
+ lines.push(`${badge.padEnd(18)} ${label}${count}${exit}`);
58
+ }
59
+ lines.push("");
60
+ for (const [key, label] of Object.entries(CATEGORY_LABELS)) {
61
+ const cat = result.categories[key];
62
+ if (!cat)
63
+ continue;
64
+ if (cat.status === "TOOL-UNAVAILABLE" ||
65
+ cat.status === "CONFIGURATION-ERROR" ||
66
+ cat.status === "EXECUTION-ERROR" ||
67
+ cat.status === "NOT-APPLICABLE") {
68
+ lines.push(` ${label}: ${cat.reason ?? cat.status}`);
69
+ }
70
+ }
71
+ lines.push("");
72
+ lines.push(`Result: ${overallResult(result)}`);
73
+ return lines.join("\n");
74
+ }
75
+ /**
76
+ * Format detailed violations for debugging.
77
+ */
78
+ export function formatViolations(violations) {
79
+ if (violations.length === 0)
80
+ return "";
81
+ const lines = [`${violations.length} violation(s):\n`];
82
+ for (const v of violations) {
83
+ const loc = v.file
84
+ ? ` ${v.file}${v.line ? `:${v.line}` : ""}${v.column ? `:${v.column}` : ""}`
85
+ : "";
86
+ lines.push(`[${v.tool}] ${v.rule} ${v.package ?? ""}${loc} — ${v.message}`);
87
+ }
88
+ return lines.join("\n");
89
+ }
@@ -0,0 +1,9 @@
1
+ export type { DiscoverOptions, PackageRecord, Repository } from "./discover.js";
2
+ export type { EngineResult, EngineStatus, Finding } from "./engines/types.js";
3
+ export type { ExitCodeValue } from "./exit-codes.js";
4
+ export type { FormatInput } from "./format.js";
5
+ export type { CategoryResult, RunOptions, RunResult } from "./run.js";
6
+ export { discoverRepository } from "./discover.js";
7
+ export { ExitCode } from "./exit-codes.js";
8
+ export { formatSummary, formatViolations } from "./format.js";
9
+ export { exitCodeForResult, runValidation } from "./run.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { discoverRepository } from "./discover.js";
2
+ export { ExitCode } from "./exit-codes.js";
3
+ export { formatSummary, formatViolations } from "./format.js";
4
+ export { exitCodeForResult, runValidation } from "./run.js";