scriptonia 0.7.0 → 0.9.0-rc.1
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 +26 -12
- package/bin/scriptonia.mjs +106 -86
- package/bin/verify.mjs +266 -0
- package/dist/index.js +1686 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -5
package/bin/verify.mjs
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const RANK = { PASS: 0, WARN: 1, BLOCK: 2 };
|
|
7
|
+
const CHECK_SCRIPTS = ["test", "build", "lint", "typecheck", "type-check"];
|
|
8
|
+
|
|
9
|
+
export async function runVerification(args, { cfg, cwd = process.cwd(), fetchImpl = fetch } = {}) {
|
|
10
|
+
if (!cfg?.url || !cfg?.token) throw new Error("run `npx scriptonia login` first");
|
|
11
|
+
const flags = parseFlags(args);
|
|
12
|
+
const planPath = path.resolve(cwd, flags.plan ?? "PLAN.md");
|
|
13
|
+
if (!fs.existsSync(planPath)) throw new Error(`PLAN.md not found at ${planPath} (use --plan <file>)`);
|
|
14
|
+
|
|
15
|
+
const plan = fs.readFileSync(planPath, "utf8");
|
|
16
|
+
const meta = parsePlanMetadata(plan, planPath);
|
|
17
|
+
const { diff, files, source } = collectDiff(cwd, flags);
|
|
18
|
+
if (!diff.trim()) throw new Error("no git diff to verify (use --base <ref> or --diff <file>)");
|
|
19
|
+
|
|
20
|
+
const diffForJudge = diff.length > 290_000
|
|
21
|
+
? `${diff.slice(0, 290_000)}\n\n[diff truncated by CLI; verdicts requiring omitted evidence must WARN]`
|
|
22
|
+
: diff;
|
|
23
|
+
const semanticPromise = fetchImpl(`${cfg.url}/api/verify`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { authorization: `Bearer ${cfg.token}`, "content-type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ plan, diff: diffForJudge }),
|
|
27
|
+
});
|
|
28
|
+
const repositoryChecks = flags["skip-checks"] ? skippedRepositoryGate() : runRepositoryChecks(cwd, files);
|
|
29
|
+
const unresolvedConstraints = buildUnresolvedGate(plan);
|
|
30
|
+
const testCoverage = buildTestCoverageGate(files);
|
|
31
|
+
|
|
32
|
+
const response = await semanticPromise;
|
|
33
|
+
const semantic = await response.json().catch(() => ({}));
|
|
34
|
+
if (!response.ok) throw new Error(`verification service failed (${response.status}): ${JSON.stringify(semantic).slice(0, 240)}`);
|
|
35
|
+
for (const name of ["acceptance_criteria", "non_goals", "product_decisions"]) {
|
|
36
|
+
const result = semantic[name];
|
|
37
|
+
if (!result || !(result.verdict in RANK) || !Array.isArray(result.checks)) {
|
|
38
|
+
throw new Error(`verification service returned an invalid ${name} gate`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const gates = {
|
|
43
|
+
acceptance_criteria: semantic.acceptance_criteria,
|
|
44
|
+
non_goals: semantic.non_goals,
|
|
45
|
+
product_decisions: semantic.product_decisions,
|
|
46
|
+
unresolved_constraints: unresolvedConstraints,
|
|
47
|
+
repository_checks: repositoryChecks,
|
|
48
|
+
test_coverage: testCoverage,
|
|
49
|
+
};
|
|
50
|
+
const verdict = worstVerdict(Object.values(gates).map((gate) => gate.verdict));
|
|
51
|
+
const evalPath = path.resolve(cwd, flags.out ?? path.join(".scriptonia", "evals", `${meta.slug}.yaml`));
|
|
52
|
+
fs.mkdirSync(path.dirname(evalPath), { recursive: true });
|
|
53
|
+
fs.writeFileSync(evalPath, renderEvalYaml({
|
|
54
|
+
id: meta.slug,
|
|
55
|
+
plan: path.relative(cwd, planPath) || path.basename(planPath),
|
|
56
|
+
planVersion: meta.version,
|
|
57
|
+
diffSource: source,
|
|
58
|
+
diffSha256: createHash("sha256").update(diff).digest("hex"),
|
|
59
|
+
verdict,
|
|
60
|
+
gates,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
printResult({ verdict, gates, evalPath, cwd });
|
|
64
|
+
return { verdict, exitCode: verdict === "BLOCK" ? 1 : 0, evalPath, gates };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parsePlanMetadata(plan, planPath = "PLAN.md") {
|
|
68
|
+
const frontmatter = plan.match(/^---\s*\n([\s\S]*?)\n---/m)?.[1] ?? "";
|
|
69
|
+
const value = (key) => frontmatter.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1]?.trim().replace(/^['"]|['"]$/g, "");
|
|
70
|
+
const slug = value("plan") || slugify(plan.match(/^#\s+(.+)$/m)?.[1] || path.basename(planPath, path.extname(planPath)));
|
|
71
|
+
return { slug: slugify(slug), version: Number(value("version") || 1) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildUnresolvedGate(plan) {
|
|
75
|
+
const matches = plan.split("\n").filter((line) => /(?:⚠\s*)?UNRESOLVED/i.test(line));
|
|
76
|
+
const blocked = matches.length > 0;
|
|
77
|
+
return gate(blocked ? "BLOCK" : "PASS", {
|
|
78
|
+
evidence: blocked ? matches.slice(0, 6) : ["No UNRESOLVED marker appears in PLAN.md."],
|
|
79
|
+
plan_criterion: "Every unresolved constraint requires human approval before implementation.",
|
|
80
|
+
citation: decisionCitation(matches.join(" ")),
|
|
81
|
+
reason: blocked ? "PLAN.md still contains an unresolved approval gate." : "No unresolved approval gate remains in the approved plan.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildTestCoverageGate(files) {
|
|
86
|
+
const tests = files.filter(isTestFile);
|
|
87
|
+
const behavior = files.filter((file) => isSourceFile(file) && !isTestFile(file));
|
|
88
|
+
if (tests.length) return gate("PASS", {
|
|
89
|
+
evidence: tests,
|
|
90
|
+
plan_criterion: "Add or update tests for changed product behavior.",
|
|
91
|
+
citation: "PLAN.md test plan",
|
|
92
|
+
reason: "The diff includes test changes that a reviewer can inspect.",
|
|
93
|
+
});
|
|
94
|
+
if (behavior.length) return gate("BLOCK", {
|
|
95
|
+
evidence: behavior.slice(0, 8),
|
|
96
|
+
plan_criterion: "Add or update tests for changed product behavior.",
|
|
97
|
+
citation: "PLAN.md test plan",
|
|
98
|
+
reason: "Product code changed without any test file added or updated.",
|
|
99
|
+
});
|
|
100
|
+
return gate("PASS", {
|
|
101
|
+
evidence: files.length ? files.slice(0, 8) : ["No changed source files."],
|
|
102
|
+
plan_criterion: "Add or update tests for changed product behavior.",
|
|
103
|
+
citation: "PLAN.md test plan",
|
|
104
|
+
reason: "The diff contains no product-code change requiring test coverage.",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function runRepositoryChecks(cwd, files) {
|
|
109
|
+
const checks = [];
|
|
110
|
+
for (const root of relevantPackageRoots(cwd, files)) {
|
|
111
|
+
let pkg;
|
|
112
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); } catch { continue; }
|
|
113
|
+
const manager = packageManager(root);
|
|
114
|
+
const seen = new Set();
|
|
115
|
+
for (const name of CHECK_SCRIPTS) {
|
|
116
|
+
if (!pkg.scripts?.[name] || seen.has(pkg.scripts[name])) continue;
|
|
117
|
+
seen.add(pkg.scripts[name]);
|
|
118
|
+
const command = manager === "yarn" ? ["yarn", name] : manager === "pnpm" ? ["pnpm", "run", name] : manager === "bun" ? ["bun", "run", name] : ["npm", "run", name];
|
|
119
|
+
const result = spawnSync(command[0], command.slice(1), { cwd: root, encoding: "utf8", timeout: 300_000, maxBuffer: 2_000_000, env: { ...process.env, CI: "1" } });
|
|
120
|
+
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
|
121
|
+
checks.push({ name, command: command.join(" "), root: path.relative(cwd, root) || ".", ok: result.status === 0, output: output.slice(-1200) || `(exit ${result.status ?? "unknown"})` });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!checks.length) return gate("WARN", {
|
|
125
|
+
evidence: ["No test, build, lint, or type-check script was found for the changed packages."],
|
|
126
|
+
plan_criterion: "Run the repository's existing quality checks.",
|
|
127
|
+
citation: "repository package scripts",
|
|
128
|
+
reason: "There were no recognized repository checks to run automatically.",
|
|
129
|
+
});
|
|
130
|
+
const failed = checks.filter((check) => !check.ok);
|
|
131
|
+
return {
|
|
132
|
+
verdict: failed.length ? "BLOCK" : "PASS",
|
|
133
|
+
checks: checks.map((check) => ({
|
|
134
|
+
verdict: check.ok ? "PASS" : "BLOCK",
|
|
135
|
+
evidence: [`${check.root}: ${check.command}`, check.output],
|
|
136
|
+
plan_criterion: "Run the repository's existing quality checks.",
|
|
137
|
+
citation: "repository package scripts",
|
|
138
|
+
reason: check.ok ? `${check.name} completed successfully.` : `${check.name} failed and must be fixed before merge.`,
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function skippedRepositoryGate() {
|
|
144
|
+
return gate("WARN", {
|
|
145
|
+
evidence: ["Repository checks skipped with --skip-checks."],
|
|
146
|
+
plan_criterion: "Run the repository's existing quality checks.",
|
|
147
|
+
citation: "CLI flag",
|
|
148
|
+
reason: "CI should run the repository checks separately before merge.",
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function relevantPackageRoots(cwd, files) {
|
|
153
|
+
const candidates = [];
|
|
154
|
+
if (fs.existsSync(path.join(cwd, "package.json"))) candidates.push(cwd);
|
|
155
|
+
for (const entry of fs.readdirSync(cwd, { withFileTypes: true })) {
|
|
156
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && fs.existsSync(path.join(cwd, entry.name, "package.json"))) candidates.push(path.join(cwd, entry.name));
|
|
157
|
+
}
|
|
158
|
+
const relevant = candidates.filter((root) => {
|
|
159
|
+
const prefix = path.relative(cwd, root);
|
|
160
|
+
return !prefix || files.some((file) => file === prefix || file.startsWith(prefix + "/"));
|
|
161
|
+
});
|
|
162
|
+
return relevant.length ? relevant : candidates.length === 1 ? candidates : [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function packageManager(root) {
|
|
166
|
+
if (fs.existsSync(path.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
167
|
+
if (fs.existsSync(path.join(root, "yarn.lock"))) return "yarn";
|
|
168
|
+
if (fs.existsSync(path.join(root, "bun.lockb")) || fs.existsSync(path.join(root, "bun.lock"))) return "bun";
|
|
169
|
+
return "npm";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function collectDiff(cwd, flags) {
|
|
173
|
+
if (flags.diff) {
|
|
174
|
+
const file = path.resolve(cwd, flags.diff);
|
|
175
|
+
const diff = fs.readFileSync(file, "utf8");
|
|
176
|
+
return { diff, files: diffFiles(diff), source: `file:${path.relative(cwd, file)}` };
|
|
177
|
+
}
|
|
178
|
+
const base = flags.base || (process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : null);
|
|
179
|
+
const range = base ? `${base}...HEAD` : "HEAD";
|
|
180
|
+
const result = spawnSync("git", ["diff", range, "--no-ext-diff", "--unified=40"], { cwd, encoding: "utf8", maxBuffer: 20_000_000 });
|
|
181
|
+
if (result.status !== 0) throw new Error(`could not read git diff for ${range}: ${(result.stderr || "").trim()}`);
|
|
182
|
+
let diff = result.stdout || "";
|
|
183
|
+
if (!base) diff += untrackedDiff(cwd);
|
|
184
|
+
return { diff, files: diffFiles(diff), source: `git:${range}` };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function untrackedDiff(cwd) {
|
|
188
|
+
const status = spawnSync("git", ["status", "--porcelain", "--untracked-files=all"], { cwd, encoding: "utf8" });
|
|
189
|
+
if (status.status !== 0) return "";
|
|
190
|
+
const files = status.stdout.split("\n").filter((line) => line.startsWith("?? ")).map((line) => line.slice(3));
|
|
191
|
+
const chunks = [];
|
|
192
|
+
for (const file of files) {
|
|
193
|
+
const full = path.join(cwd, file);
|
|
194
|
+
try {
|
|
195
|
+
const stat = fs.statSync(full);
|
|
196
|
+
if (!stat.isFile() || stat.size > 500_000) continue;
|
|
197
|
+
const body = fs.readFileSync(full, "utf8");
|
|
198
|
+
chunks.push(`\ndiff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${body.split("\n").length} @@\n${body.split("\n").map((line) => `+${line}`).join("\n")}\n`);
|
|
199
|
+
} catch { /* ignore unreadable/binary untracked files */ }
|
|
200
|
+
}
|
|
201
|
+
return chunks.join("");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function diffFiles(diff) {
|
|
205
|
+
return [...new Set([...diff.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gm)].map((match) => match[2]))];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isTestFile(file) {
|
|
209
|
+
return /(^|\/)(?:__tests__|tests?|spec)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isSourceFile(file) {
|
|
213
|
+
return /\.(?:[cm]?[jt]sx?|py|go|rs|java|rb|php|swift|kt)$/i.test(file) && !/(?:^|\/)(?:scripts?|migrations?|drizzle)(?:\/|$)/i.test(file);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function gate(verdict, check) {
|
|
217
|
+
return { verdict, checks: [{ verdict, ...check }] };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function worstVerdict(verdicts) {
|
|
221
|
+
return verdicts.reduce((worst, verdict) => (RANK[verdict] > RANK[worst] ? verdict : worst), "PASS");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decisionCitation(text) {
|
|
225
|
+
return text.match(/(?:decision|dec)[-_:/ ]?[a-z0-9_-]+/i)?.[0] || "PLAN.md constraint";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderEvalYaml(data) {
|
|
229
|
+
const q = (value) => JSON.stringify(String(value ?? ""));
|
|
230
|
+
const lines = ["version: 1", `id: ${q(data.id)}`, `plan: ${q(data.plan)}`, `plan_version: ${data.planVersion}`, `diff_source: ${q(data.diffSource)}`, `diff_sha256: ${q(data.diffSha256)}`, `generated_at: ${q(new Date().toISOString())}`, `verdict: ${data.verdict}`, "gates:"];
|
|
231
|
+
for (const [name, gateResult] of Object.entries(data.gates)) {
|
|
232
|
+
lines.push(` ${name}:`, ` verdict: ${gateResult.verdict}`, " checks:");
|
|
233
|
+
for (const check of gateResult.checks ?? []) {
|
|
234
|
+
lines.push(` - verdict: ${check.verdict}`, ` plan_criterion: ${q(check.plan_criterion)}`, ` citation: ${q(check.citation)}`, ` reason: ${q(check.reason)}`, " evidence:");
|
|
235
|
+
for (const evidence of check.evidence ?? []) lines.push(` - ${q(String(evidence).slice(0, 1600))}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return lines.join("\n") + "\n";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function printResult({ verdict, gates, evalPath, cwd }) {
|
|
242
|
+
const icon = verdict === "PASS" ? "✓" : verdict === "WARN" ? "!" : "✕";
|
|
243
|
+
console.log(`\n ${icon} SCRIPTONIA VERIFY · ${verdict}\n`);
|
|
244
|
+
for (const [name, result] of Object.entries(gates)) {
|
|
245
|
+
console.log(` ${result.verdict.padEnd(5)} ${name.replaceAll("_", " ")}`);
|
|
246
|
+
for (const check of (result.checks ?? []).filter((item) => item.verdict !== "PASS").slice(0, 3)) console.log(` ${check.reason}`);
|
|
247
|
+
}
|
|
248
|
+
console.log(`\n case ${path.relative(cwd, evalPath)}`);
|
|
249
|
+
console.log(` exit ${verdict === "BLOCK" ? 1 : 0}\n`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function parseFlags(args) {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (let i = 0; i < args.length; i++) {
|
|
255
|
+
if (!args[i].startsWith("--")) continue;
|
|
256
|
+
const [name, inline] = args[i].slice(2).split(/=(.*)/s);
|
|
257
|
+
if (inline !== undefined) out[name] = inline;
|
|
258
|
+
else if (args[i + 1] && !args[i + 1].startsWith("--")) out[name] = args[++i];
|
|
259
|
+
else out[name] = true;
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function slugify(value) {
|
|
265
|
+
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "").slice(0, 80) || "plan";
|
|
266
|
+
}
|