@comity-dev/validate 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -57,6 +57,32 @@ Exit codes:
57
57
  - `2` — configuration / discovery error
58
58
  - `3` — required tool not installed
59
59
 
60
+ ## Environment
61
+
62
+ The Semgrep engine shells out to a local Semgrep installation:
63
+
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. |
68
+
69
+ Rules:
70
+
71
+ - An explicit `SEMGREP_PYTHON` always wins and is never silently overridden.
72
+ - A set-but-missing `SEMGREP_PYTHON` is invalid configuration: the engine
73
+ reports `TOOL-UNAVAILABLE` naming the variable instead of falling back to
74
+ another interpreter.
75
+ - When `SEMGREP_PYTHON` is unset, the previous resolution applies
76
+ (`/usr/bin/python3`, `/usr/local/bin/python3`, `/opt/homebrew/bin/python3`,
77
+ then `python3` on PATH).
78
+ - The scan `PATH` is prefixed with the directories of the resolved
79
+ `SEMGREP_BIN` and interpreter so Semgrep finds its sibling `pysemgrep`.
80
+
81
+ ```bash
82
+ SEMGREP_BIN=~/.local/bin/semgrep SEMGREP_PYTHON=/opt/homebrew/bin/python3 \
83
+ comity-validate --repo /path/to/repo
84
+ ```
85
+
60
86
  ## Negative fixtures
61
87
 
62
88
  The `src/__fixtures__/third-repo/` directory contains a deliberately invalid repository used by the package's own integration tests. It is **not** scanned by the canonical validate run (the engine excludes `__fixtures__/**`); it is asserted against failure by `src/__tests__/integration.test.ts`.
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * comity-validate — thin orchestrator over Comity shared validation tooling.
4
+ *
5
+ * Usage:
6
+ * comity-validate [options]
7
+ *
8
+ * Options:
9
+ * --help, -h Show this help
10
+ * --verbose, -v Print underlying tool output
11
+ * --repo <path> Repository root (default: cwd)
12
+ * --only <category> Only run a single category
13
+ * (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
14
+ * Default: run all categories.
15
+ *
16
+ * Exit codes:
17
+ * 0 validation passed
18
+ * 1 validation failed (violations found)
19
+ * 2 configuration / discovery error
20
+ * 3 required tool not installed
21
+ *
22
+ * The TypeScript source for this binary lives at
23
+ * `src/bin/comity-validate.ts` and is compiled to
24
+ * `dist/bin/comity-validate.js` by `tsc -p tsconfig.json`.
25
+ */
26
+ export {};
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * comity-validate — thin orchestrator over Comity shared validation tooling.
4
+ *
5
+ * Usage:
6
+ * comity-validate [options]
7
+ *
8
+ * Options:
9
+ * --help, -h Show this help
10
+ * --verbose, -v Print underlying tool output
11
+ * --repo <path> Repository root (default: cwd)
12
+ * --only <category> Only run a single category
13
+ * (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
14
+ * Default: run all categories.
15
+ *
16
+ * Exit codes:
17
+ * 0 validation passed
18
+ * 1 validation failed (violations found)
19
+ * 2 configuration / discovery error
20
+ * 3 required tool not installed
21
+ *
22
+ * The TypeScript source for this binary lives at
23
+ * `src/bin/comity-validate.ts` and is compiled to
24
+ * `dist/bin/comity-validate.js` by `tsc -p tsconfig.json`.
25
+ */
26
+ import { resolve } from "node:path";
27
+ import { ExitCode } from "../exit-codes.js";
28
+ import { formatSummary, formatViolations } from "../format.js";
29
+ import { runValidation } from "../run.js";
30
+ function parseArgs(argv) {
31
+ const args = {
32
+ root: process.cwd(),
33
+ verbose: false,
34
+ only: null,
35
+ help: false,
36
+ };
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const arg = argv[i];
39
+ if (arg === "--help" || arg === "-h")
40
+ args.help = true;
41
+ else if (arg === "--verbose" || arg === "-v")
42
+ args.verbose = true;
43
+ else if (arg === "--repo")
44
+ args.root = resolve(argv[++i] ?? "");
45
+ else if (arg?.startsWith("--repo="))
46
+ args.root = resolve(arg.slice("--repo=".length));
47
+ else if (arg === "--only")
48
+ args.only = argv[++i] ?? null;
49
+ else if (arg?.startsWith("--only="))
50
+ args.only = arg.slice("--only=".length);
51
+ }
52
+ return args;
53
+ }
54
+ function printHelp() {
55
+ console.log(`comity-validate — thin orchestrator over Comity shared validation tooling
56
+
57
+ Usage:
58
+ comity-validate [options]
59
+
60
+ Options:
61
+ --help, -h Show this help
62
+ --verbose, -v Print underlying tool output
63
+ --repo <path> Repository root (default: cwd)
64
+ --only <category> Only run a single category
65
+ (schema | metadata | config | dependencies | eslint | semgrep | adapter-peers)
66
+
67
+ Exit codes:
68
+ 0 validation passed
69
+ 1 validation failed
70
+ 2 configuration / discovery error
71
+ 3 required tool not installed
72
+ `);
73
+ }
74
+ async function main() {
75
+ const args = parseArgs(process.argv.slice(2));
76
+ if (args.help) {
77
+ printHelp();
78
+ process.exit(ExitCode.PASS);
79
+ }
80
+ try {
81
+ const result = await runValidation({
82
+ root: args.root,
83
+ verbose: args.verbose,
84
+ only: args.only,
85
+ });
86
+ console.log(formatSummary(result));
87
+ if (!result.passed && args.verbose) {
88
+ console.log("");
89
+ console.log(formatViolations(result.violations));
90
+ }
91
+ process.exit(result.passed ? ExitCode.PASS : ExitCode.FAIL);
92
+ }
93
+ catch (err) {
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ console.error("comity-validate: configuration error:", message);
96
+ process.exit(ExitCode.CONFIG_ERROR);
97
+ }
98
+ }
99
+ main();
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @comity-dev/validate — repository discovery.
3
+ *
4
+ * Walks a repository's `comity.config.json` and the configured package
5
+ * roots to produce a normalized Repository handle that the orchestrator
6
+ * passes to specialized validators.
7
+ */
8
+ export interface PackageRecord {
9
+ /** The package's `name` field from its `package.json`. */
10
+ name: string;
11
+ /** The absolute path of the package directory. */
12
+ path: string;
13
+ /** The package's `comity.layer` field from its `package.json`, or null if not set. */
14
+ layer: string | null;
15
+ /** The parsed contents of the package's `package.json`. */
16
+ manifest: Record<string, unknown>;
17
+ }
18
+ export interface Repository {
19
+ /** The absolute path of the repository root. */
20
+ root: string;
21
+ /** The parsed contents of the repository's `comity.config.json`. */
22
+ repository: {
23
+ name: string;
24
+ type: string;
25
+ };
26
+ /** The configured package roots, relative to the repository root. */
27
+ packageRoots: string[];
28
+ /** The discovered packages in the repository, sorted by name. */
29
+ packages: PackageRecord[];
30
+ /** The parsed contents of the repository's `comity.config.json`. */
31
+ config: Record<string, unknown>;
32
+ }
33
+ export interface DiscoverOptions {
34
+ /** The absolute path of the repository root. */
35
+ root: string;
36
+ }
37
+ /**
38
+ * Discover a repository: load comity.config.json, walk the configured
39
+ * package roots, and produce a normalized Repository handle.
40
+ */
41
+ export declare function discoverRepository({ root, }: DiscoverOptions): Promise<Repository>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @comity-dev/validate — repository discovery.
3
+ *
4
+ * Walks a repository's `comity.config.json` and the configured package
5
+ * roots to produce a normalized Repository handle that the orchestrator
6
+ * passes to specialized validators.
7
+ */
8
+ import { readdir, readFile, stat } from "node:fs/promises";
9
+ import { join, resolve } from "node:path";
10
+ /**
11
+ * Discover a repository: load comity.config.json, walk the configured
12
+ * package roots, and produce a normalized Repository handle.
13
+ */
14
+ export async function discoverRepository({ root, }) {
15
+ const absRoot = resolve(root);
16
+ const configPath = join(absRoot, "comity.config.json");
17
+ let config = null;
18
+ try {
19
+ const text = await readFile(configPath, "utf8");
20
+ config = JSON.parse(text);
21
+ }
22
+ catch {
23
+ config = {
24
+ repository: { name: "unknown", type: "third-party" },
25
+ packages: { roots: ["packages"] },
26
+ };
27
+ }
28
+ const cfg = config;
29
+ const roots = cfg.packages?.roots ?? ["packages"];
30
+ const packages = [];
31
+ for (const rel of roots) {
32
+ const dir = join(absRoot, rel);
33
+ try {
34
+ await stat(dir);
35
+ }
36
+ catch {
37
+ continue;
38
+ }
39
+ const entries = await readdir(dir, { withFileTypes: true });
40
+ for (const entry of entries) {
41
+ if (!entry.isDirectory())
42
+ continue;
43
+ const pkgDir = join(dir, entry.name);
44
+ const manifestPath = join(pkgDir, "package.json");
45
+ let manifest = null;
46
+ try {
47
+ const text = await readFile(manifestPath, "utf8");
48
+ manifest = JSON.parse(text);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ if (!manifest?.["name"] ||
54
+ (!String(manifest["name"]).startsWith("@comity/") &&
55
+ !String(manifest["name"]).startsWith("@comity-dev/")))
56
+ continue;
57
+ const m = manifest;
58
+ packages.push({
59
+ name: m.name,
60
+ path: pkgDir,
61
+ layer: m.comity?.layer ?? null,
62
+ manifest,
63
+ });
64
+ }
65
+ }
66
+ packages.sort((a, b) => a.name.localeCompare(b.name));
67
+ return {
68
+ root: absRoot,
69
+ repository: cfg.repository ?? { name: "unknown", type: "third-party" },
70
+ packageRoots: roots,
71
+ packages,
72
+ config: config ?? {},
73
+ };
74
+ }
@@ -0,0 +1,3 @@
1
+ import type { Repository } from "../discover.js";
2
+ import type { EngineResult } from "./types.js";
3
+ export declare function runAdapterPeers(repo: Repository): EngineResult;
@@ -0,0 +1,120 @@
1
+ const KERNEL_PACKAGES = new Set([
2
+ "@comity/primitives",
3
+ "@comity/kernel",
4
+ "@comity/composition",
5
+ ]);
6
+ const SOURCE = "adapters.md §13 / architecture-validation.md §7.4 (docs/standards/)";
7
+ export function runAdapterPeers(repo) {
8
+ const start = Date.now();
9
+ const findings = [];
10
+ // Authoritative layer lookup per ADR-026: each package's own manifest
11
+ // declares its layer. Used to distinguish consumed Core Modules (which
12
+ // §13 requires as peers) from kernel and other layers.
13
+ const layers = new Map(repo.packages.map((p) => [p.name, p.layer]));
14
+ for (const pkg of repo.packages) {
15
+ if (pkg.layer !== "technology-adapter")
16
+ continue;
17
+ const m = pkg.manifest;
18
+ const implementsMeta = m.comity?.implements;
19
+ // Validate that the technology-adapter declares its implemented Core Module in comity.implements
20
+ if (typeof implementsMeta !== "string" || !implementsMeta.trim()) {
21
+ findings.push({
22
+ tool: "schema",
23
+ rule: "ARCH-ADAPTER-PEER-005",
24
+ message: `Technology Adapter "${pkg.name}" cannot determine implemented Core Module (missing or invalid comity.implements)`,
25
+ package: pkg.name,
26
+ severity: "error",
27
+ source: SOURCE,
28
+ remediation: 'Ensure package.json has "comity.implements" set to a string Core Module package name.',
29
+ });
30
+ continue;
31
+ }
32
+ const coreModule = implementsMeta.trim();
33
+ const peerDeps = m.peerDependencies ?? {};
34
+ const deps = m.dependencies ?? {};
35
+ // Validate that the technology-adapter declares its implemented Core Module as a peerDependency
36
+ if (!peerDeps[coreModule]) {
37
+ findings.push({
38
+ tool: "schema",
39
+ rule: "ARCH-ADAPTER-PEER-001",
40
+ message: `Technology Adapter "${pkg.name}" implements "${coreModule}" but does not declare it as a peerDependency`,
41
+ package: pkg.name,
42
+ severity: "error",
43
+ source: SOURCE,
44
+ remediation: `Add "${coreModule}" to peerDependencies.`,
45
+ });
46
+ }
47
+ // Validate that the technology-adapter declares its implemented Core Module as a dependency
48
+ if (!deps[coreModule] && !peerDeps[coreModule]) {
49
+ findings.push({
50
+ tool: "schema",
51
+ rule: "ARCH-ADAPTER-PEER-002",
52
+ message: `Technology Adapter "${pkg.name}" implements "${coreModule}" but does not depend on it at all`,
53
+ package: pkg.name,
54
+ severity: "error",
55
+ source: SOURCE,
56
+ remediation: `Add "${coreModule}" to dependencies and peerDependencies.`,
57
+ });
58
+ }
59
+ // Validate that every consumed Core Module is declared as a
60
+ // peerDependency (adapters.md §13). "Consumed" is the manifest
61
+ // `dependencies` signal: npm runtime-requirement semantics, mirrored by
62
+ // the §13 examples and by every adapter in the monorepo. Transitive,
63
+ // lockfile-only, and dev dependencies are structurally excluded. The
64
+ // implemented module is covered by ARCH-ADAPTER-PEER-001 above and is
65
+ // skipped here to keep diagnostics singular.
66
+ for (const dep of Object.keys(deps)) {
67
+ if (!dep.startsWith("@comity/"))
68
+ continue;
69
+ if (dep === coreModule)
70
+ continue;
71
+ if (layers.get(dep) !== "core")
72
+ continue;
73
+ if (!peerDeps[dep]) {
74
+ findings.push({
75
+ tool: "schema",
76
+ rule: "ARCH-ADAPTER-PEER-003",
77
+ message: `Technology Adapter "${pkg.name}" consumes Core Module "${dep}" but does not declare it as a peerDependency`,
78
+ package: pkg.name,
79
+ severity: "error",
80
+ source: SOURCE,
81
+ remediation: `Add "${dep}" to peerDependencies.`,
82
+ });
83
+ }
84
+ }
85
+ // Validate that the technology-adapter does not declare peerDependencies
86
+ // on unrelated @comity/* packages. Per §13, peers are allowed for the
87
+ // implemented Core Module, kernel packages, and consumed Core Modules.
88
+ // A matching `dependencies` entry is the authoritative consumption
89
+ // signal (npm runtime-requirement semantics, mirrored by the §13
90
+ // examples and every adapter in the monorepo), so it uniformly covers
91
+ // workspace and external packages. A peer with no dependency
92
+ // relationship expresses nothing real and is rejected. Adapter-to-
93
+ // adapter edges remain the depcruise L6 rule's responsibility.
94
+ for (const peerDep of Object.keys(peerDeps)) {
95
+ if (!peerDep.startsWith("@comity/"))
96
+ continue;
97
+ const allowed = [coreModule, ...KERNEL_PACKAGES].includes(peerDep) || peerDep in deps;
98
+ if (!allowed) {
99
+ findings.push({
100
+ tool: "schema",
101
+ rule: "ARCH-ADAPTER-PEER-004",
102
+ message: `Technology Adapter "${pkg.name}" declares peerDependency on "${peerDep}" which is not the implemented Core Module, a Kernel package, or a consumed Core Module`,
103
+ package: pkg.name,
104
+ severity: "error",
105
+ source: SOURCE,
106
+ remediation: `Remove "${peerDep}" from peerDependencies unless it is the implemented Core Module, a Kernel package, or a consumed Core Module declared in dependencies.`,
107
+ });
108
+ }
109
+ }
110
+ }
111
+ return {
112
+ executed: true,
113
+ exitCode: 0,
114
+ passed: findings.length === 0,
115
+ findings,
116
+ duration: Date.now() - start,
117
+ status: findings.length === 0 ? "PASS" : "FAIL",
118
+ tool: "adapter-peers",
119
+ };
120
+ }
@@ -0,0 +1,3 @@
1
+ import type { Repository } from "../discover.js";
2
+ import type { EngineResult } from "./types.js";
3
+ export declare function runDepcruise(repo: Repository, binDir: string): EngineResult;
@@ -0,0 +1,109 @@
1
+ import { buildDependencyRulesForRoot, classifyByLayer, } from "@comity-dev/dependency-rules";
2
+ import { spawnSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ export function runDepcruise(repo, binDir) {
6
+ const start = Date.now();
7
+ const classification = classifyByLayer(repo.packages.map((p) => ({ name: p.name, layer: p.layer })));
8
+ const depConfig = buildDependencyRulesForRoot(classification, repo.root);
9
+ // Write the generated config to an OS temp directory so the consumer
10
+ // repository is never mutated by the validator. The temp directory is
11
+ // unique per run and cleaned up after the engine returns.
12
+ const tmpDir = mkdtempSync("/tmp/comity-validate-depcruise-");
13
+ const configPath = `${tmpDir}/depcruise.json`;
14
+ writeFileSync(configPath, JSON.stringify(depConfig, null, 2));
15
+ const bin = resolve(binDir, "depcruise");
16
+ if (!existsSync(bin)) {
17
+ return {
18
+ executed: false,
19
+ exitCode: null,
20
+ passed: false,
21
+ findings: [],
22
+ duration: Date.now() - start,
23
+ status: "TOOL-UNAVAILABLE",
24
+ tool: "dependency-cruiser",
25
+ reason: `dependency-cruiser binary not found at ${bin}`,
26
+ };
27
+ }
28
+ // Build target paths from discovered package directories
29
+ // Use the repository's actual package directories, not a hardcoded "packages"
30
+ const packageDirs = repo.packages.map((p) => resolve(repo.root, p.path));
31
+ const targets = packageDirs.length > 0 ? packageDirs : [resolve(repo.root, "packages")];
32
+ // Run depcruise from the validate package's directory so it can resolve
33
+ // the shared @comity-dev/dependency-rules via workspace node_modules.
34
+ // The tsConfig.fileName path is resolved relative to this CWD; the
35
+ // generated config uses an absolute path so this is robust.
36
+ const engineCwd = resolve(binDir, "..", "..");
37
+ const proc = spawnSync(bin, ["--config", configPath, "--output-type", "json", ...targets], {
38
+ encoding: "utf8",
39
+ maxBuffer: 16 * 1024 * 1024,
40
+ cwd: engineCwd,
41
+ env: {
42
+ ...process.env,
43
+ TSCONFIG_PATH: resolve(repo.root, "tsconfig.json"),
44
+ },
45
+ });
46
+ const findings = parseDepcruiseOutput(proc.stdout, proc.stderr);
47
+ // Clean up the temp config directory.
48
+ try {
49
+ rmSync(tmpDir, { recursive: true, force: true });
50
+ }
51
+ catch {
52
+ // ignore
53
+ }
54
+ // If depcruise exited non-zero but produced no parseable JSON output,
55
+ // that is an EXECUTION-ERROR, not a policy violation. Surface stderr
56
+ // so the operator can diagnose, but mark status accordingly.
57
+ if (proc.status !== 0 && findings.length === 0) {
58
+ const stderrHead = (proc.stderr ?? "").split("\n")[0] ?? "";
59
+ findings.push({
60
+ tool: "dependency-cruiser",
61
+ rule: "DEP-CRASH",
62
+ message: `dependency-cruiser exited with code ${proc.status}; stderr: ${stderrHead}`,
63
+ severity: "error",
64
+ });
65
+ return {
66
+ executed: true,
67
+ exitCode: proc.status,
68
+ passed: false,
69
+ findings,
70
+ duration: Date.now() - start,
71
+ status: "EXECUTION-ERROR",
72
+ tool: "dependency-cruiser",
73
+ };
74
+ }
75
+ return {
76
+ executed: true,
77
+ exitCode: proc.status,
78
+ passed: proc.status === 0 && findings.length === 0,
79
+ findings,
80
+ duration: Date.now() - start,
81
+ status: proc.status === 0 ? "PASS" : "FAIL",
82
+ tool: "dependency-cruiser",
83
+ };
84
+ }
85
+ function parseDepcruiseOutput(stdout, stderr) {
86
+ const out = stdout ?? stderr ?? "";
87
+ if (!out.trim())
88
+ return [];
89
+ let parsed = null;
90
+ try {
91
+ parsed = JSON.parse(out);
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ const violations = parsed?.violations ?? [];
97
+ return violations.map((v) => {
98
+ const from = v["from"];
99
+ const rule = v["rule"];
100
+ return {
101
+ tool: "dependency-cruiser",
102
+ rule: `DEP-${rule?.name ?? "UNKNOWN"}`,
103
+ message: rule?.comment ?? "Dependency rule violation",
104
+ file: from?.file,
105
+ line: from?.line,
106
+ severity: "error",
107
+ };
108
+ });
109
+ }
@@ -0,0 +1,3 @@
1
+ import type { Repository } from "../discover.js";
2
+ import type { EngineResult } from "./types.js";
3
+ export declare function runEslint(repo: Repository, binDir: string): EngineResult;
@@ -0,0 +1,148 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { relative, resolve } 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
+ // Resolve modules from the repository's node_modules, not from the validate
11
+ // package's node_modules. This ensures we use the repository's installed
12
+ // versions of the plugins and parser.
13
+ const repoNodeModules = resolve(repo.root, "node_modules");
14
+ const pluginPath = resolve(repoNodeModules, "@comity-dev", "eslint-plugin", "dist", "index.js");
15
+ const recommendedPath = resolve(repoNodeModules, "@comity-dev", "eslint-plugin", "dist", "recommended.js");
16
+ const tsParserPath = resolve(repoNodeModules, "@typescript-eslint", "parser", "dist", "index.js");
17
+ // Build target directories from discovered package roots
18
+ // Use the repository's actual package directories
19
+ const packageDirs = repo.packages.map((p) => resolve(repo.root, p.path));
20
+ // Compute relative paths from repo.root for ESLint target
21
+ const targets = packageDirs.length > 0
22
+ ? packageDirs.map((dir) => relative(repo.root, dir))
23
+ : ["packages"];
24
+ const configBody = `import comityPlugin from ${JSON.stringify(pluginPath)};\nimport { recommended } from ${JSON.stringify(recommendedPath)};\nimport tsParser from ${JSON.stringify(tsParserPath)};\nexport default [\n // Explicitly override ESLint's auto-detected ignores. The temp config\n // is created at runtime, far from any .gitignore / eslintrc that the\n // consumer repo might ship.\n { ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**"] },\n {\n files: ["**/*.{ts,tsx,js,jsx}"],\n languageOptions: {\n parser: tsParser,\n parserOptions: { ecmaVersion: "latest", sourceType: "module" },\n },\n plugins: { "@comity-dev": comityPlugin },\n rules: recommended.rules,\n },\n];\n`;
25
+ writeFileSync(configPath, configBody);
26
+ if (process.env["DEBUG_VALIDATE"]) {
27
+ console.error("[DEBUG] ESLint config written to:", configPath);
28
+ console.error("[DEBUG] Targets:", targets);
29
+ }
30
+ const bin = resolve(binDir, "eslint");
31
+ if (!existsSync(bin)) {
32
+ try {
33
+ rmSync(tmpDir, { recursive: true, force: true });
34
+ }
35
+ catch {
36
+ // ignore
37
+ }
38
+ return {
39
+ executed: false,
40
+ exitCode: null,
41
+ passed: false,
42
+ findings: [],
43
+ duration: Date.now() - start,
44
+ status: "TOOL-UNAVAILABLE",
45
+ tool: "eslint",
46
+ reason: `eslint binary not found at ${bin}`,
47
+ };
48
+ }
49
+ // Run ESLint with cwd = repo.root so its file-path resolution and
50
+ // ignore handling are anchored at the repository being validated.
51
+ const proc = spawnSync(bin, [
52
+ "--config",
53
+ configPath,
54
+ "--format",
55
+ "json",
56
+ "--no-config-lookup",
57
+ "--no-inline-config",
58
+ ...targets,
59
+ ], {
60
+ encoding: "utf8",
61
+ maxBuffer: 16 * 1024 * 1024,
62
+ cwd: repo.root,
63
+ });
64
+ const findings = parseEslintOutput(proc.stdout, proc.stderr);
65
+ // Clean up temp config (skip in debug mode for inspection)
66
+ if (!process.env["DEBUG_VALIDATE"]) {
67
+ try {
68
+ rmSync(tmpDir, { recursive: true, force: true });
69
+ }
70
+ catch {
71
+ // ignore
72
+ }
73
+ }
74
+ else {
75
+ console.error("[DEBUG] Temp config preserved at:", tmpDir);
76
+ }
77
+ // If ESLint exited non-zero but produced no parseable output, this is
78
+ // an EXECUTION-ERROR (config failure, missing plugin, etc.) not a
79
+ // policy violation. Surface stderr so the operator can diagnose.
80
+ if (proc.status !== 0 && findings.length === 0) {
81
+ const stderrHead = (proc.stderr ?? "").split("\n")[0] ?? "";
82
+ findings.push({
83
+ tool: "eslint",
84
+ rule: "ESLINT-CRASH",
85
+ message: `eslint exited with code ${proc.status}; stderr: ${stderrHead}`,
86
+ severity: "error",
87
+ });
88
+ return {
89
+ executed: true,
90
+ exitCode: proc.status,
91
+ passed: false,
92
+ findings,
93
+ duration: Date.now() - start,
94
+ status: "EXECUTION-ERROR",
95
+ tool: "eslint",
96
+ };
97
+ }
98
+ // Debug logging
99
+ if (process.env["DEBUG_VALIDATE"]) {
100
+ console.error("[DEBUG] ESLint result:", {
101
+ status: proc.status,
102
+ findingsCount: findings.length,
103
+ passed: proc.status === 0 &&
104
+ findings.filter((f) => f.severity === "error").length === 0,
105
+ stdout: proc.stdout?.slice(0, 500),
106
+ stderr: proc.stderr?.slice(0, 500),
107
+ });
108
+ }
109
+ return {
110
+ executed: true,
111
+ exitCode: proc.status,
112
+ passed: proc.status === 0 &&
113
+ findings.filter((f) => f.severity === "error").length === 0,
114
+ findings,
115
+ duration: Date.now() - start,
116
+ status: proc.status === 0 ? "PASS" : "FAIL",
117
+ tool: "eslint",
118
+ };
119
+ }
120
+ function parseEslintOutput(stdout, stderr) {
121
+ const out = stdout ?? stderr ?? "";
122
+ if (!out.trim())
123
+ return [];
124
+ let parsed = null;
125
+ try {
126
+ parsed = JSON.parse(out);
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ if (!Array.isArray(parsed))
132
+ return [];
133
+ const findings = [];
134
+ for (const fileResult of parsed) {
135
+ for (const msg of fileResult.messages) {
136
+ findings.push({
137
+ tool: "eslint",
138
+ rule: msg.ruleId ?? "ESLINT",
139
+ message: msg.message,
140
+ file: fileResult.filePath,
141
+ line: msg.line,
142
+ column: msg.column,
143
+ severity: msg.severity === 2 ? "error" : "warning",
144
+ });
145
+ }
146
+ }
147
+ return findings;
148
+ }
@@ -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;