@comity-dev/validate 0.1.2 → 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/dist/bin/comity-validate.d.ts +26 -0
- package/dist/bin/comity-validate.js +99 -0
- package/dist/discover.d.ts +41 -0
- package/dist/discover.js +74 -0
- package/dist/engines/adapter-peers.d.ts +3 -0
- package/dist/engines/adapter-peers.js +120 -0
- package/dist/engines/depcruise.d.ts +3 -0
- package/dist/engines/depcruise.js +109 -0
- package/dist/engines/eslint.d.ts +3 -0
- package/dist/engines/eslint.js +148 -0
- package/dist/engines/schema.d.ts +3 -0
- package/dist/engines/schema.js +48 -0
- package/dist/engines/semgrep.d.ts +18 -0
- package/dist/engines/semgrep.js +281 -0
- package/dist/engines/types.d.ts +25 -0
- package/dist/engines/types.js +1 -0
- package/dist/exit-codes.d.ts +15 -0
- package/dist/exit-codes.js +14 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +76 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/run.d.ts +56 -0
- package/dist/run.js +144 -0
- package/package.json +5 -5
|
@@ -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>;
|
package/dist/discover.js
ADDED
|
@@ -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,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,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,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
|
+
}
|