@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Filippo Bovo and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @comity-dev/validate
2
+
3
+ ## Purpose
4
+
5
+ Thin orchestrator over Comity shared validation tooling. Discovers a repository, invokes the specialized engines (schema, dependency-cruiser, ESLint, Semgrep, adapter-peers), and returns a normalized result.
6
+
7
+ This package is intentionally small. It does **not** implement any rule. Its only responsibilities are:
8
+
9
+ 1. Discover repository configuration (`comity.config.json`).
10
+ 2. Discover packages.
11
+ 3. Invoke specialized engines.
12
+ 4. Collect and normalize results.
13
+ 5. Present a unified summary.
14
+
15
+ ## Scope
16
+
17
+ The engines wrap shared Development-owned tooling:
18
+
19
+ | Engine | Tool | Concern |
20
+ | --------------- | --------------------------------------- | ---------------------------------------------------------- |
21
+ | `schema` | `@comity-dev/schemas` (Ajv) | JSON Schema for package metadata and `comity.config.json`. |
22
+ | `depcruise` | `dependency-cruiser` | Dependency graph layering. |
23
+ | `eslint` | ESLint + `@comity-dev/eslint-plugin` | Source-code lint policy. |
24
+ | `semgrep` | `semgrep` + `@comity-dev/semgrep-rules` | Structural source patterns. |
25
+ | `adapter-peers` | in-process | Adapter peer-dependency contract. |
26
+
27
+ Engines report one of `PASS` / `FAIL` / `NOT-RUN` / `TOOL-UNAVAILABLE` / `CONFIGURATION-ERROR` / `EXECUTION-ERROR` / `NOT-APPLICABLE`. `PASS` is only reported when the engine actually executed.
28
+
29
+ ## Ownership
30
+
31
+ Owned by `comity-development`. Consumed by Comity repositories via the `comity-validate` CLI.
32
+
33
+ ## Public API
34
+
35
+ ```ts
36
+ import {
37
+ runValidation,
38
+ discoverRepository,
39
+ formatSummary,
40
+ formatViolations,
41
+ ExitCode,
42
+ } from "@comity-dev/validate";
43
+ ```
44
+
45
+ ## CLI
46
+
47
+ ```bash
48
+ comity-validate --repo /path/to/repo
49
+ comity-validate --only=schema # one engine only
50
+ comity-validate --only=eslint --verbose
51
+ ```
52
+
53
+ Exit codes:
54
+
55
+ - `0` — validation passed
56
+ - `1` — validation failed (violations found)
57
+ - `2` — configuration / discovery error
58
+ - `3` — required tool not installed
59
+
60
+ ## Negative fixtures
61
+
62
+ 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`.
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ pnpm build
68
+ pnpm test
69
+ ```
70
+
71
+ ## Relationship to Comity Standards
72
+
73
+ - `layering-policy.md`
74
+ - `architecture-validation.md`
75
+ - `ADR-008`, `ADR-026`
76
+ - `adapters.md` §13 (adapter-peer engine)
@@ -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 { runValidation } from "../run.js";
29
+ import { formatSummary, formatViolations } from "../format.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,82 @@
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
+ for (const pkg of repo.packages) {
11
+ if (pkg.layer !== "technology-adapter")
12
+ continue;
13
+ const m = pkg.manifest;
14
+ const implementsMeta = m.comity?.implements;
15
+ // Validate that the technology-adapter declares its implemented Core Module in comity.implements
16
+ if (typeof implementsMeta !== "string" || !implementsMeta.trim()) {
17
+ findings.push({
18
+ tool: "schema",
19
+ rule: "ARCH-ADAPTER-PEER-005",
20
+ message: `Technology Adapter "${pkg.name}" cannot determine implemented Core Module (missing or invalid comity.implements)`,
21
+ package: pkg.name,
22
+ severity: "error",
23
+ source: SOURCE,
24
+ remediation: 'Ensure package.json has "comity.implements" set to a string Core Module package name.',
25
+ });
26
+ continue;
27
+ }
28
+ const coreModule = implementsMeta.trim();
29
+ const peerDeps = m.peerDependencies ?? {};
30
+ const deps = m.dependencies ?? {};
31
+ // Validate that the technology-adapter declares its implemented Core Module as a peerDependency
32
+ if (!peerDeps[coreModule]) {
33
+ findings.push({
34
+ tool: "schema",
35
+ rule: "ARCH-ADAPTER-PEER-001",
36
+ message: `Technology Adapter "${pkg.name}" implements "${coreModule}" but does not declare it as a peerDependency`,
37
+ package: pkg.name,
38
+ severity: "error",
39
+ source: SOURCE,
40
+ remediation: `Add "${coreModule}" to peerDependencies.`,
41
+ });
42
+ }
43
+ // Validate that the technology-adapter declares its implemented Core Module as a dependency
44
+ if (!deps[coreModule] && !peerDeps[coreModule]) {
45
+ findings.push({
46
+ tool: "schema",
47
+ rule: "ARCH-ADAPTER-PEER-002",
48
+ message: `Technology Adapter "${pkg.name}" implements "${coreModule}" but does not depend on it at all`,
49
+ package: pkg.name,
50
+ severity: "error",
51
+ source: SOURCE,
52
+ remediation: `Add "${coreModule}" to dependencies and peerDependencies.`,
53
+ });
54
+ }
55
+ // Validate that the technology-adapter does not declare peerDependencies on disallowed @comity/* packages
56
+ for (const peerDep of Object.keys(peerDeps)) {
57
+ if (!peerDep.startsWith("@comity/"))
58
+ continue;
59
+ const allowed = [coreModule, ...KERNEL_PACKAGES].includes(peerDep);
60
+ if (!allowed) {
61
+ findings.push({
62
+ tool: "schema",
63
+ rule: "ARCH-ADAPTER-PEER-004",
64
+ message: `Technology Adapter "${pkg.name}" declares peerDependency on "${peerDep}" which is not the implemented Core Module or a Kernel package`,
65
+ package: pkg.name,
66
+ severity: "error",
67
+ source: SOURCE,
68
+ remediation: `Remove "${peerDep}" from peerDependencies unless it is the implemented Core Module, a Kernel package, or a required technology dependency.`,
69
+ });
70
+ }
71
+ }
72
+ }
73
+ return {
74
+ executed: true,
75
+ exitCode: 0,
76
+ passed: findings.length === 0,
77
+ findings,
78
+ duration: Date.now() - start,
79
+ status: findings.length === 0 ? "PASS" : "FAIL",
80
+ tool: "adapter-peers",
81
+ };
82
+ }
@@ -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;