@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 +21 -0
- package/README.md +76 -0
- 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 +82 -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 +170 -0
- package/dist/engines/schema.d.ts +3 -0
- package/dist/engines/schema.js +48 -0
- package/dist/engines/semgrep.d.ts +3 -0
- package/dist/engines/semgrep.js +252 -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 +45 -0
|
@@ -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,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,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
|
+
});
|
package/dist/format.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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