@comity-dev/validate 0.1.0

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,170 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { resolve, relative } from "node:path";
4
+ export function runEslint(repo, binDir) {
5
+ const start = Date.now();
6
+ // Write the temp config to an OS temp directory so the consumer
7
+ // repository is never mutated by the validator.
8
+ const tmpDir = mkdtempSync("/tmp/comity-validate-eslint-");
9
+ const configPath = `${tmpDir}/eslint.config.mjs`;
10
+ // Use absolute paths to the plugin's dist output so Node can resolve
11
+ // them from the temp config's location (anywhere in the repo).
12
+ // The plugin is a workspace dep of this validate package; we resolve
13
+ // its dist via the validate package's own node_modules.
14
+ const pluginEntry = resolve(resolve(binDir, "..", "..", "node_modules", "@comity-dev", "eslint-plugin"), "dist", "index.js");
15
+ const recommendedEntry = resolve(resolve(binDir, "..", "..", "node_modules", "@comity-dev", "eslint-plugin"), "dist", "recommended.js");
16
+ // Resolve the @typescript-eslint/parser from validate's workspace
17
+ // node_modules; ESLint 9 flat-config needs an explicit parser for TS.
18
+ const tsParserEntry = resolve(resolve(binDir, "..", "..", "node_modules", "@typescript-eslint"), "parser", "dist", "index.js");
19
+ // Build target directories from discovered package roots
20
+ // Use the repository's actual package directories
21
+ const packageDirs = repo.packages.map((p) => resolve(repo.root, p.path));
22
+ // Compute relative paths from repo.root for ESLint target
23
+ const targets = packageDirs.length > 0
24
+ ? packageDirs.map((dir) => relative(repo.root, dir))
25
+ : ["packages"];
26
+ const configBody = `import comityPlugin from ${JSON.stringify(pluginEntry)};
27
+ import { recommended } from ${JSON.stringify(recommendedEntry)};
28
+ import tsParser from ${JSON.stringify(tsParserEntry)};
29
+ export default [
30
+ // Explicitly override ESLint's auto-detected ignores. The temp config
31
+ // is created at runtime, far from any .gitignore / eslintrc that the
32
+ // consumer repo might ship.
33
+ { ignores: ["**/node_modules/**", "**/dist/**"] },
34
+ {
35
+ files: ["**/*.{ts,tsx,js,jsx}"],
36
+ languageOptions: {
37
+ parser: tsParser,
38
+ parserOptions: { ecmaVersion: "latest", sourceType: "module" },
39
+ },
40
+ plugins: { "@comity-dev": comityPlugin },
41
+ rules: recommended.rules,
42
+ },
43
+ ];
44
+ `;
45
+ writeFileSync(configPath, configBody);
46
+ if (process.env["DEBUG_VALIDATE"]) {
47
+ console.error("[DEBUG] ESLint config written to:", configPath);
48
+ console.error("[DEBUG] Targets:", targets);
49
+ }
50
+ const bin = resolve(binDir, "eslint");
51
+ if (!existsSync(bin)) {
52
+ try {
53
+ rmSync(tmpDir, { recursive: true, force: true });
54
+ }
55
+ catch {
56
+ // ignore
57
+ }
58
+ return {
59
+ executed: false,
60
+ exitCode: null,
61
+ passed: false,
62
+ findings: [],
63
+ duration: Date.now() - start,
64
+ status: "TOOL-UNAVAILABLE",
65
+ tool: "eslint",
66
+ reason: `eslint binary not found at ${bin}`,
67
+ };
68
+ }
69
+ // Run ESLint with cwd = repo.root so its file-path resolution and
70
+ // ignore handling are anchored at the repository being validated.
71
+ // The temp config uses absolute imports for the shared plugin, so
72
+ // workspace layout is irrelevant to module resolution. We pass the
73
+ // target directories and let ESLint auto-discover the files; the
74
+ // config's `files` pattern filters by extension.
75
+ const proc = spawnSync(bin, [
76
+ "--config",
77
+ configPath,
78
+ "--format",
79
+ "json",
80
+ "--no-config-lookup",
81
+ "--no-inline-config",
82
+ ...targets,
83
+ ], {
84
+ encoding: "utf8",
85
+ maxBuffer: 16 * 1024 * 1024,
86
+ cwd: repo.root,
87
+ });
88
+ const findings = parseEslintOutput(proc.stdout, proc.stderr);
89
+ // Clean up temp config (skip in debug mode for inspection)
90
+ if (!process.env["DEBUG_VALIDATE"]) {
91
+ try {
92
+ rmSync(tmpDir, { recursive: true, force: true });
93
+ }
94
+ catch {
95
+ // ignore
96
+ }
97
+ }
98
+ else {
99
+ console.error("[DEBUG] Temp config preserved at:", tmpDir);
100
+ }
101
+ // If ESLint exited non-zero but produced no parseable output, this is
102
+ // an EXECUTION-ERROR (config failure, missing plugin, etc.) not a
103
+ // policy violation. Surface stderr so the operator can diagnose.
104
+ if (proc.status !== 0 && findings.length === 0) {
105
+ const stderrHead = (proc.stderr ?? "").split("\n")[0] ?? "";
106
+ findings.push({
107
+ tool: "eslint",
108
+ rule: "ESLINT-CRASH",
109
+ message: `eslint exited with code ${proc.status}; stderr: ${stderrHead}`,
110
+ severity: "error",
111
+ });
112
+ return {
113
+ executed: true,
114
+ exitCode: proc.status,
115
+ passed: false,
116
+ findings,
117
+ duration: Date.now() - start,
118
+ status: "EXECUTION-ERROR",
119
+ tool: "eslint",
120
+ };
121
+ }
122
+ // Debug logging
123
+ if (process.env["DEBUG_VALIDATE"]) {
124
+ console.error("[DEBUG] ESLint result:", {
125
+ status: proc.status,
126
+ findingsCount: findings.length,
127
+ passed: proc.status === 0 && findings.length === 0,
128
+ stdout: proc.stdout?.slice(0, 500),
129
+ stderr: proc.stderr?.slice(0, 500),
130
+ });
131
+ }
132
+ return {
133
+ executed: true,
134
+ exitCode: proc.status,
135
+ passed: proc.status === 0 && findings.length === 0,
136
+ findings,
137
+ duration: Date.now() - start,
138
+ status: proc.status === 0 ? "PASS" : "FAIL",
139
+ tool: "eslint",
140
+ };
141
+ }
142
+ function parseEslintOutput(stdout, stderr) {
143
+ const out = stdout ?? stderr ?? "";
144
+ if (!out.trim())
145
+ return [];
146
+ let parsed = null;
147
+ try {
148
+ parsed = JSON.parse(out);
149
+ }
150
+ catch {
151
+ return [];
152
+ }
153
+ if (!Array.isArray(parsed))
154
+ return [];
155
+ const findings = [];
156
+ for (const fileResult of parsed) {
157
+ for (const msg of fileResult.messages) {
158
+ findings.push({
159
+ tool: "eslint",
160
+ rule: msg.ruleId ?? "ESLINT",
161
+ message: msg.message,
162
+ file: fileResult.filePath,
163
+ line: msg.line,
164
+ column: msg.column,
165
+ severity: msg.severity === 2 ? "error" : "warning",
166
+ });
167
+ }
168
+ }
169
+ return findings;
170
+ }
@@ -0,0 +1,3 @@
1
+ import type { Repository } from "../discover.js";
2
+ import type { EngineResult } from "./types.js";
3
+ export declare function runSchema(repo: Repository): EngineResult;
@@ -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,3 @@
1
+ import type { Repository } from "../discover.js";
2
+ import type { EngineResult } from "./types.js";
3
+ export declare function runSemgrep(repo: Repository): Promise<EngineResult>;
@@ -0,0 +1,252 @@
1
+ import { buildSemgrepConfig } from "@comity-dev/semgrep-rules";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { relative, resolve } from "node:path";
5
+ /**
6
+ * Discover the Python interpreter that ships the `semgrep` module.
7
+ * We try well-known absolute paths first, then fall back to PATH.
8
+ */
9
+ function resolvePythonForSemgrep() {
10
+ const candidates = [
11
+ "/usr/bin/python3",
12
+ "/usr/local/bin/python3",
13
+ "/opt/homebrew/bin/python3",
14
+ ];
15
+ for (const c of candidates) {
16
+ if (existsSync(c))
17
+ return c;
18
+ }
19
+ const probe = spawnSync("which", ["python3"], { encoding: "utf8" });
20
+ if (probe.status === 0 && probe.stdout?.trim())
21
+ return probe.stdout.trim();
22
+ return null;
23
+ }
24
+ function findSemgrepBin(fallbackDirs) {
25
+ const explicitBin = process.env["SEMGREP_BIN"];
26
+ if (explicitBin && existsSync(explicitBin))
27
+ return explicitBin;
28
+ const probe = spawnSync("which", ["semgrep"], {
29
+ encoding: "utf8",
30
+ env: {
31
+ ...process.env,
32
+ PATH: `${fallbackDirs.join(":")}:${process.env["PATH"] ?? ""}`,
33
+ },
34
+ });
35
+ if (probe.status === 0 && probe.stdout?.trim()) {
36
+ return probe.stdout.trim();
37
+ }
38
+ const absoluteCandidates = [
39
+ "/usr/bin/semgrep",
40
+ "/usr/local/bin/semgrep",
41
+ "/opt/homebrew/bin/semgrep",
42
+ ];
43
+ for (const c of absoluteCandidates) {
44
+ if (existsSync(c))
45
+ return c;
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * Returns the set of package directory names that should be excluded
51
+ * from Semgrep scanning. The Development-owned runtime rule set
52
+ * (`@comity-dev/semgrep-rules`) targets Comity Core Modules and
53
+ * Adapters; it MUST NOT fire on Development tooling packages
54
+ * (`comity.layer: "dev-tooling"`) because those packages legitimately
55
+ * use Node APIs, `process.env`, and other patterns the rules forbid
56
+ * for runtime Core Modules.
57
+ */
58
+ function devToolingPackageDirs(repo) {
59
+ const dirs = [];
60
+ for (const pkg of repo.packages) {
61
+ if (pkg.layer === "dev-tooling") {
62
+ const rel = relative(repo.root, pkg.path);
63
+ const top = rel.split("/")[0];
64
+ if (top)
65
+ dirs.push(top);
66
+ }
67
+ }
68
+ return [...new Set(dirs)];
69
+ }
70
+ function buildSemgrepArgs(configPath, target, excludedDirs) {
71
+ const args = [
72
+ "-c",
73
+ "from semgrep.console_scripts.entrypoint import main; main()",
74
+ "scan",
75
+ "--config",
76
+ configPath,
77
+ "--json",
78
+ "--quiet",
79
+ "--error",
80
+ target,
81
+ ];
82
+ // Exclude dev-tooling package directories by both path-pattern
83
+ // (via --exclude) and directory name (via --exclude-dir) so semgrep
84
+ // does not scan them at all. The runtime rule set is not designed
85
+ // for development-tooling source.
86
+ for (const d of excludedDirs) {
87
+ args.push("--exclude", `**/packages/${d}/**`);
88
+ args.push("--exclude-dir", d);
89
+ }
90
+ return args;
91
+ }
92
+ export async function runSemgrep(repo) {
93
+ const start = Date.now();
94
+ // Build the combined Semgrep config. Semgrep's CLI accepts a single
95
+ // --config path, so we concatenate all canonical rule files into one
96
+ // synthesized config.
97
+ const configYaml = await buildSemgrepConfig();
98
+ const tmpDir = mkdtempSync("/tmp/comity-validate-semgrep-");
99
+ const configPath = `${tmpDir}/semgrep-config.yaml`;
100
+ writeFileSync(configPath, configYaml);
101
+ const pathDirs = (process.env["PATH"] ?? "").split(":").filter(Boolean);
102
+ const fallbackDirs = ["/usr/local/bin", "/opt/homebrew/bin"];
103
+ const candidateDirs = [...new Set([...pathDirs, ...fallbackDirs])];
104
+ const cleanup = () => {
105
+ try {
106
+ rmSync(tmpDir, { recursive: true, force: true });
107
+ }
108
+ catch {
109
+ // ignore
110
+ }
111
+ };
112
+ const semgrepBin = findSemgrepBin(fallbackDirs);
113
+ if (!semgrepBin) {
114
+ cleanup();
115
+ return {
116
+ executed: false,
117
+ exitCode: null,
118
+ passed: false,
119
+ findings: [],
120
+ duration: Date.now() - start,
121
+ status: "TOOL-UNAVAILABLE",
122
+ tool: "semgrep",
123
+ reason: "semgrep binary not found on PATH; install semgrep or set SEMGREP_BIN",
124
+ };
125
+ }
126
+ // Locate the Python interpreter that ships the semgrep module.
127
+ const pythonBin = resolvePythonForSemgrep();
128
+ if (!pythonBin) {
129
+ cleanup();
130
+ return {
131
+ executed: false,
132
+ exitCode: null,
133
+ passed: false,
134
+ findings: [],
135
+ duration: Date.now() - start,
136
+ status: "TOOL-UNAVAILABLE",
137
+ tool: "semgrep",
138
+ reason: "Python interpreter not found; semgrep requires Python on PATH",
139
+ };
140
+ }
141
+ // Augment PATH so the inner Python process can find semgrep's
142
+ // sibling `pysemgrep` script (semgrep's `subprocess.run` does an
143
+ // `execvp("pysemgrep")` lookup against this PATH).
144
+ const envPath = [
145
+ ...candidateDirs,
146
+ "/Users/filippo/Library/Python/3.14/bin",
147
+ "/Users/filippo/Library/Python/3.13/bin",
148
+ "/Users/filippo/Library/Python/3.12/bin",
149
+ process.env["PATH"] ?? "",
150
+ "/usr/bin",
151
+ "/bin",
152
+ ].join(":");
153
+ const target = resolve(repo.root, "packages");
154
+ const excludedDirs = devToolingPackageDirs(repo);
155
+ // If every discovered package is dev-tooling, the runtime rule
156
+ // set is not applicable to this repository. Report NOT-APPLICABLE
157
+ // rather than running semgrep against an empty target.
158
+ const runtimePackages = repo.packages.filter((p) => p.layer !== "dev-tooling");
159
+ if (runtimePackages.length === 0) {
160
+ cleanup();
161
+ return {
162
+ executed: false,
163
+ exitCode: null,
164
+ passed: true,
165
+ findings: [],
166
+ duration: Date.now() - start,
167
+ status: "NOT-APPLICABLE",
168
+ tool: "semgrep",
169
+ 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.",
170
+ };
171
+ }
172
+ const args = buildSemgrepArgs(configPath, target, excludedDirs);
173
+ const proc = spawnSync(pythonBin, args, {
174
+ cwd: repo.root,
175
+ env: { ...process.env, PATH: envPath },
176
+ maxBuffer: 16 * 1024 * 1024,
177
+ encoding: "utf8",
178
+ });
179
+ cleanup();
180
+ const stdout = proc.stdout ?? "";
181
+ const stderr = proc.stderr ?? "";
182
+ const findings = parseSemgrepOutput(stdout, stderr);
183
+ // If semgrep exited non-zero but produced no parseable findings, this
184
+ // is an EXECUTION-ERROR (configuration, rule-parse, internal crash),
185
+ // not a policy violation.
186
+ if (proc.status !== null && proc.status !== 0 && findings.length === 0) {
187
+ const stderrHead = stderr.split("\n")[0] ?? "";
188
+ const stdoutHead = stdout.slice(0, 500);
189
+ findings.push({
190
+ tool: "semgrep",
191
+ rule: "SEM-CRASH",
192
+ message: `semgrep exited with code ${proc.status}; stderr: ${stderrHead}; stdout (first 500): ${stdoutHead}`,
193
+ severity: "error",
194
+ });
195
+ return {
196
+ executed: true,
197
+ exitCode: proc.status,
198
+ passed: false,
199
+ findings,
200
+ duration: Date.now() - start,
201
+ status: "EXECUTION-ERROR",
202
+ tool: "semgrep",
203
+ };
204
+ }
205
+ return {
206
+ executed: true,
207
+ exitCode: proc.status,
208
+ passed: proc.status === 0 && findings.length === 0,
209
+ findings,
210
+ duration: Date.now() - start,
211
+ status: proc.status === 0 ? "PASS" : "FAIL",
212
+ tool: "semgrep",
213
+ };
214
+ }
215
+ function parseSemgrepOutput(stdout, stderr) {
216
+ const out = stdout ?? stderr ?? "";
217
+ if (!out.trim())
218
+ return [];
219
+ let parsed = null;
220
+ try {
221
+ parsed = JSON.parse(out);
222
+ }
223
+ catch {
224
+ return [];
225
+ }
226
+ const findings = [];
227
+ for (const e of parsed?.errors ?? []) {
228
+ if (e.level === "error") {
229
+ findings.push({
230
+ tool: "semgrep",
231
+ rule: `SEM-PARSE-${e.rule_id ?? "UNKNOWN"}`,
232
+ message: e.message ?? "semgrep rule parse error",
233
+ severity: "error",
234
+ });
235
+ }
236
+ }
237
+ const results = parsed?.results ?? [];
238
+ for (const r of results) {
239
+ const start = r["start"];
240
+ const extra = r["extra"];
241
+ findings.push({
242
+ tool: "semgrep",
243
+ rule: `SEM-${String(r["check_id"] ?? "UNKNOWN")}`,
244
+ message: extra?.message ?? "Semgrep rule violation",
245
+ file: typeof r["path"] === "string" ? r["path"] : undefined,
246
+ line: start?.line,
247
+ column: start?.col,
248
+ severity: extra?.severity === "ERROR" ? "error" : "warning",
249
+ });
250
+ }
251
+ return findings;
252
+ }
@@ -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,76 @@
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
+ * Format a unified summary for the terminal.
32
+ */
33
+ export function formatSummary(result) {
34
+ const lines = ["Comity validation", ""];
35
+ for (const [key, label] of Object.entries(CATEGORY_LABELS)) {
36
+ const cat = result.categories[key];
37
+ if (!cat)
38
+ continue;
39
+ const badge = statusBadge(cat);
40
+ const count = cat.findings > 0 ? ` (${cat.findings} findings)` : "";
41
+ const exit = cat.exitCode !== null && cat.exitCode !== 0
42
+ ? ` exit=${cat.exitCode}`
43
+ : "";
44
+ lines.push(`${badge.padEnd(18)} ${label}${count}${exit}`);
45
+ }
46
+ lines.push("");
47
+ for (const [key, label] of Object.entries(CATEGORY_LABELS)) {
48
+ const cat = result.categories[key];
49
+ if (!cat)
50
+ continue;
51
+ if (cat.status === "TOOL-UNAVAILABLE" ||
52
+ cat.status === "CONFIGURATION-ERROR" ||
53
+ cat.status === "EXECUTION-ERROR" ||
54
+ cat.status === "NOT-APPLICABLE") {
55
+ lines.push(` ${label}: ${cat.reason ?? cat.status}`);
56
+ }
57
+ }
58
+ lines.push("");
59
+ lines.push(`Result: ${result.passed ? "PASS" : "FAIL"}`);
60
+ return lines.join("\n");
61
+ }
62
+ /**
63
+ * Format detailed violations for debugging.
64
+ */
65
+ export function formatViolations(violations) {
66
+ if (violations.length === 0)
67
+ return "";
68
+ const lines = [`${violations.length} violation(s):\n`];
69
+ for (const v of violations) {
70
+ const loc = v.file
71
+ ? ` ${v.file}${v.line ? `:${v.line}` : ""}${v.column ? `:${v.column}` : ""}`
72
+ : "";
73
+ lines.push(`[${v.tool}] ${v.rule} ${v.package ?? ""}${loc} — ${v.message}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
@@ -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 { 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 { runValidation } from "./run.js";