scriptonia 0.8.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 +15 -6
- package/dist/index.js +1686 -0
- package/dist/index.js.map +1 -0
- package/package.json +49 -5
package/dist/index.js
ADDED
|
@@ -0,0 +1,1686 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import fs9 from "fs";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path10 from "path";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
import YAML3 from "yaml";
|
|
10
|
+
|
|
11
|
+
// ../packages/core/src/index.ts
|
|
12
|
+
import { createHash, randomBytes } from "crypto";
|
|
13
|
+
import path from "path";
|
|
14
|
+
function canonicalize(value) {
|
|
15
|
+
return JSON.stringify(normalize(value));
|
|
16
|
+
}
|
|
17
|
+
function normalize(value) {
|
|
18
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return typeof value === "string" ? value.replace(/\r\n?/g, "\n") : value;
|
|
19
|
+
if (typeof value === "number") {
|
|
20
|
+
if (!Number.isFinite(value)) throw new TypeError("canonical JSON rejects non-finite numbers");
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) return value.map(normalize);
|
|
24
|
+
if (typeof value === "object") {
|
|
25
|
+
return Object.fromEntries(Object.entries(value).filter(([, item2]) => item2 !== void 0).sort(([a], [b]) => a.localeCompare(b)).map(([key, item2]) => [key, normalize(item2)]));
|
|
26
|
+
}
|
|
27
|
+
throw new TypeError(`canonical JSON rejects ${typeof value}`);
|
|
28
|
+
}
|
|
29
|
+
function sha256(value) {
|
|
30
|
+
return createHash("sha256").update(value).digest("hex");
|
|
31
|
+
}
|
|
32
|
+
function hashObject(value) {
|
|
33
|
+
return sha256(canonicalize(value));
|
|
34
|
+
}
|
|
35
|
+
function createId(prefix, at = Date.now()) {
|
|
36
|
+
return `${prefix}_${at.toString(36).padStart(9, "0")}${randomBytes(8).toString("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
function normalizeRepoPath(input) {
|
|
39
|
+
if (input.includes("\0")) throw new Error("repository path contains a null byte");
|
|
40
|
+
const normalized = input.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
41
|
+
if (path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
42
|
+
throw new Error(`path escapes repository: ${input}`);
|
|
43
|
+
}
|
|
44
|
+
return path.posix.normalize(normalized);
|
|
45
|
+
}
|
|
46
|
+
function assertInside(root, candidate) {
|
|
47
|
+
const resolvedRoot = path.resolve(root);
|
|
48
|
+
const resolved = path.resolve(candidate);
|
|
49
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new Error(`path escapes repository: ${candidate}`);
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
function aggregateGate(checks, incompleteBlocks = true) {
|
|
53
|
+
if (checks.some((item2) => item2.state === "FAIL" && item2.enforcement === "block")) return "BLOCKED";
|
|
54
|
+
if (checks.some((item2) => item2.state === "FAIL" && item2.enforcement === "action_required")) return "ACTION_REQUIRED";
|
|
55
|
+
if (checks.some((item2) => item2.state === "ERROR" || incompleteBlocks && item2.state === "INCONCLUSIVE" && item2.enforcement === "block")) return "INCOMPLETE";
|
|
56
|
+
if (checks.some((item2) => item2.state === "FAIL" || item2.state === "INCONCLUSIVE")) return "PASS_WITH_WARNINGS";
|
|
57
|
+
return "PASS";
|
|
58
|
+
}
|
|
59
|
+
function exitCodeForGate(state) {
|
|
60
|
+
return state === "PASS" || state === "PASS_WITH_WARNINGS" ? 0 : state === "ACTION_REQUIRED" ? 2 : state === "BLOCKED" ? 3 : 4;
|
|
61
|
+
}
|
|
62
|
+
function nowIso() {
|
|
63
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ../packages/eval-engine/src/index.ts
|
|
67
|
+
import fs7 from "fs";
|
|
68
|
+
import path8 from "path";
|
|
69
|
+
import YAML2 from "yaml";
|
|
70
|
+
|
|
71
|
+
// ../packages/evidence/src/index.ts
|
|
72
|
+
import fs from "fs";
|
|
73
|
+
import path2 from "path";
|
|
74
|
+
import fg from "fast-glob";
|
|
75
|
+
import { XMLParser } from "fast-xml-parser";
|
|
76
|
+
function item(kind, collector, source, state, payload) {
|
|
77
|
+
return { id: createId("evidence"), kind, collector, collectorVersion: "1", source, state, contentHash: hashObject({ kind, source, state, payload }), payload, collectedAt: nowIso(), redaction: { applied: false, policyVersion: "1" } };
|
|
78
|
+
}
|
|
79
|
+
function collectGitHubNeeds(raw = process.env.SCRIPTONIA_NEEDS_JSON) {
|
|
80
|
+
if (!raw) return [];
|
|
81
|
+
try {
|
|
82
|
+
const needs = JSON.parse(raw);
|
|
83
|
+
return Object.entries(needs).map(([name, value]) => {
|
|
84
|
+
const result = String(value?.result ?? "unknown").toLowerCase();
|
|
85
|
+
const state = result === "success" ? "available" : result === "failure" || result === "cancelled" ? "failed" : "missing";
|
|
86
|
+
return item("github_check", "github-needs", `github:${name}`, state, { name, conclusion: result, outputs: value?.outputs ?? {} });
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return [item("github_check", "github-needs", "env:SCRIPTONIA_NEEDS_JSON", "error", { error: error instanceof Error ? error.message : String(error) })];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function collectJUnit(root, patterns) {
|
|
93
|
+
const files = fg.sync(patterns, { cwd: root, absolute: true, onlyFiles: true, unique: true, suppressErrors: true });
|
|
94
|
+
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", allowBooleanAttributes: true });
|
|
95
|
+
const items = [];
|
|
96
|
+
for (const filename of files) {
|
|
97
|
+
try {
|
|
98
|
+
const xml = fs.readFileSync(filename, "utf8");
|
|
99
|
+
if (Buffer.byteLength(xml) > 1e7) throw new Error("JUnit artifact exceeds 10 MB limit");
|
|
100
|
+
const parsed = parser.parse(xml);
|
|
101
|
+
for (const test of flattenTests(parsed)) {
|
|
102
|
+
const failed = Boolean(test.failure || test.error);
|
|
103
|
+
const skipped = Boolean(test.skipped);
|
|
104
|
+
const suite = String(test.classname ?? test.suite ?? "");
|
|
105
|
+
const name = String(test.name ?? "unnamed");
|
|
106
|
+
items.push(item("junit_test", "junit-xml", path2.relative(root, filename), skipped ? "missing" : failed ? "failed" : "available", { suite, test: name, failed, skipped }));
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
items.push(item("junit_test", "junit-xml", path2.relative(root, filename), "error", { error: error instanceof Error ? error.message : String(error) }));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return items;
|
|
113
|
+
}
|
|
114
|
+
function flattenTests(value, suite = "") {
|
|
115
|
+
if (!value || typeof value !== "object") return [];
|
|
116
|
+
const record = value;
|
|
117
|
+
const currentSuite = String(record["@_name"] ?? suite);
|
|
118
|
+
const tests = asArray(record.testcase).map((entry) => ({ ...entry, suite: currentSuite, classname: entry["@_classname"], name: entry["@_name"], failure: entry.failure, error: entry.error, skipped: entry.skipped }));
|
|
119
|
+
for (const key of ["testsuite", "testsuites"]) for (const child of asArray(record[key])) tests.push(...flattenTests(child, currentSuite));
|
|
120
|
+
return tests;
|
|
121
|
+
}
|
|
122
|
+
function asArray(value) {
|
|
123
|
+
return value === void 0 ? [] : Array.isArray(value) ? value : [value];
|
|
124
|
+
}
|
|
125
|
+
function collectRepositoryFacts(root, changedFiles) {
|
|
126
|
+
return changedFiles.map((file) => {
|
|
127
|
+
const full = path2.join(root, file);
|
|
128
|
+
let exists = false;
|
|
129
|
+
let size = 0;
|
|
130
|
+
try {
|
|
131
|
+
const stat = fs.statSync(full);
|
|
132
|
+
exists = stat.isFile();
|
|
133
|
+
size = stat.size;
|
|
134
|
+
} catch {
|
|
135
|
+
}
|
|
136
|
+
return item("repository_fact", "repository-fact", `path:${file}`, "available", { path: file, exists, size });
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
function evaluateRequirement(requirement, evidence) {
|
|
140
|
+
const relevant = evidence.filter((candidate) => candidate.kind === requirement.kind && requirementMatches(requirement, candidate));
|
|
141
|
+
if (!relevant.length) return { state: "INCONCLUSIVE", reason: `Required ${requirement.kind} evidence is unavailable.`, evidence: [] };
|
|
142
|
+
if (relevant.some((candidate) => candidate.state === "error")) return { state: "ERROR", reason: `${requirement.kind} collector failed.`, evidence: relevant };
|
|
143
|
+
if (relevant.some((candidate) => candidate.state === "failed")) return { state: "FAIL", reason: `Required ${requirement.kind} evidence failed.`, evidence: relevant };
|
|
144
|
+
if (relevant.some((candidate) => candidate.state === "available")) return { state: "PASS", reason: `Required ${requirement.kind} evidence passed.`, evidence: relevant };
|
|
145
|
+
return { state: "INCONCLUSIVE", reason: `Required ${requirement.kind} evidence is incomplete.`, evidence: relevant };
|
|
146
|
+
}
|
|
147
|
+
function requirementMatches(requirement, candidate) {
|
|
148
|
+
const payload = candidate.payload;
|
|
149
|
+
if (requirement.name && String(payload.name ?? payload.test ?? "") !== requirement.name) return false;
|
|
150
|
+
if (requirement.suite && String(payload.suite ?? "") !== requirement.suite) return false;
|
|
151
|
+
if (requirement.test && String(payload.test ?? "") !== requirement.test) return false;
|
|
152
|
+
if (requirement.path && String(payload.path ?? "") !== requirement.path) return false;
|
|
153
|
+
if (requirement.value && !Object.values(payload).map(String).includes(requirement.value)) return false;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ../packages/git-snapshot/src/index.ts
|
|
158
|
+
import fs2 from "fs";
|
|
159
|
+
import path3 from "path";
|
|
160
|
+
import { spawnSync } from "child_process";
|
|
161
|
+
function git(root, args, allowFailure = false) {
|
|
162
|
+
const result = spawnSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 4e7, shell: false });
|
|
163
|
+
if (result.error) throw result.error;
|
|
164
|
+
if (result.status !== 0 && !allowFailure) throw new Error(`git ${args[0] ?? "command"} failed: ${(result.stderr || "").trim()}`);
|
|
165
|
+
return result.status === 0 ? (result.stdout || "").trimEnd() : "";
|
|
166
|
+
}
|
|
167
|
+
function findRepositoryRoot(cwd = process.cwd()) {
|
|
168
|
+
const root = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
169
|
+
if (!root) throw new Error("not inside a Git repository");
|
|
170
|
+
return path3.resolve(root);
|
|
171
|
+
}
|
|
172
|
+
function resolveRef(root, ref) {
|
|
173
|
+
const sha = git(root, ["rev-parse", "--verify", `${ref}^{commit}`]);
|
|
174
|
+
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`invalid Git ref: ${ref}`);
|
|
175
|
+
return sha.toLowerCase();
|
|
176
|
+
}
|
|
177
|
+
function readFileAtRef(root, ref, filename) {
|
|
178
|
+
const sha = resolveRef(root, ref);
|
|
179
|
+
const safe = normalizeRepoPath(filename);
|
|
180
|
+
const result = spawnSync("git", ["show", `${sha}:${safe}`], { cwd: root, encoding: "utf8", maxBuffer: 1e7, shell: false });
|
|
181
|
+
return result.status === 0 ? result.stdout : void 0;
|
|
182
|
+
}
|
|
183
|
+
function listFilesAtRef(root, ref, prefixes) {
|
|
184
|
+
const sha = resolveRef(root, ref);
|
|
185
|
+
const safe = prefixes.map(normalizeRepoPath);
|
|
186
|
+
const output = git(root, ["ls-tree", "-r", "--name-only", sha, "--", ...safe], true);
|
|
187
|
+
return output.split("\n").filter(Boolean).map(normalizeRepoPath).sort();
|
|
188
|
+
}
|
|
189
|
+
function parseNameStatus(text) {
|
|
190
|
+
const out = [];
|
|
191
|
+
for (const line of text.split("\n")) {
|
|
192
|
+
if (!line) continue;
|
|
193
|
+
const [rawStatus = "", first = "", second] = line.split(" ");
|
|
194
|
+
const code = rawStatus[0];
|
|
195
|
+
const status = code === "A" ? "added" : code === "M" ? "modified" : code === "D" ? "deleted" : code === "R" ? "renamed" : code === "C" ? "copied" : "unknown";
|
|
196
|
+
const target = status === "renamed" || status === "copied" ? second : first;
|
|
197
|
+
if (!target) continue;
|
|
198
|
+
const item2 = { path: normalizeRepoPath(target), status, binary: false };
|
|
199
|
+
if ((status === "renamed" || status === "copied") && first) item2.oldPath = normalizeRepoPath(first);
|
|
200
|
+
out.push(item2);
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
function untrackedFiles(root) {
|
|
205
|
+
return git(root, ["ls-files", "--others", "--exclude-standard"], true).split("\n").filter(Boolean).map(normalizeRepoPath).filter((file) => !isRuntimeFile(file)).map((file) => ({ path: file, status: "added", binary: isBinary(path3.join(root, file)) }));
|
|
206
|
+
}
|
|
207
|
+
function isRuntimeFile(file) {
|
|
208
|
+
return /^\.scriptonia\/(?:brain\.db(?:-shm|-wal)?|artifacts\/|result(?:\.|-)|self-verification\.)/.test(file);
|
|
209
|
+
}
|
|
210
|
+
function isBinary(filename) {
|
|
211
|
+
try {
|
|
212
|
+
const fd = fs2.openSync(filename, "r");
|
|
213
|
+
const buffer = Buffer.alloc(8192);
|
|
214
|
+
const read = fs2.readSync(fd, buffer, 0, buffer.length, 0);
|
|
215
|
+
fs2.closeSync(fd);
|
|
216
|
+
return buffer.subarray(0, read).includes(0);
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function untrackedPatch(root, files) {
|
|
222
|
+
const chunks = [];
|
|
223
|
+
for (const file of files) {
|
|
224
|
+
if (file.binary) {
|
|
225
|
+
chunks.push(`diff --git a/${file.path} b/${file.path}
|
|
226
|
+
new file mode 100644
|
|
227
|
+
Binary files /dev/null and b/${file.path} differ`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const full = path3.join(root, file.path);
|
|
231
|
+
try {
|
|
232
|
+
const stat = fs2.statSync(full);
|
|
233
|
+
if (!stat.isFile() || stat.size > 1e6) continue;
|
|
234
|
+
const body = fs2.readFileSync(full, "utf8").replace(/\r\n?/g, "\n");
|
|
235
|
+
const lines = body.split("\n");
|
|
236
|
+
chunks.push(`diff --git a/${file.path} b/${file.path}
|
|
237
|
+
new file mode 100644
|
|
238
|
+
--- /dev/null
|
|
239
|
+
+++ b/${file.path}
|
|
240
|
+
@@ -0,0 +1,${lines.length} @@
|
|
241
|
+
${lines.map((line) => `+${line}`).join("\n")}`);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return chunks.join("\n");
|
|
246
|
+
}
|
|
247
|
+
function buildSnapshot(options = {}) {
|
|
248
|
+
const root = findRepositoryRoot(options.cwd);
|
|
249
|
+
const baseRef = options.base ?? "HEAD";
|
|
250
|
+
const baseSha = resolveRef(root, baseRef);
|
|
251
|
+
const headSha = resolveRef(root, "HEAD");
|
|
252
|
+
const includeWorkingTree = options.includeWorkingTree ?? true;
|
|
253
|
+
const range = baseSha === headSha ? "HEAD" : `${baseSha}...${headSha}`;
|
|
254
|
+
let diff = git(root, ["diff", "--no-ext-diff", "--binary", "--unified=20", range]);
|
|
255
|
+
let files = parseNameStatus(git(root, ["diff", "--name-status", "--find-renames", range]));
|
|
256
|
+
let dirty = false;
|
|
257
|
+
if (includeWorkingTree) {
|
|
258
|
+
const working = git(root, ["diff", "--no-ext-diff", "--binary", "--unified=20", "HEAD"]);
|
|
259
|
+
const workingFiles = parseNameStatus(git(root, ["diff", "--name-status", "--find-renames", "HEAD"]));
|
|
260
|
+
const untracked = untrackedFiles(root);
|
|
261
|
+
dirty = workingFiles.length > 0 || untracked.length > 0;
|
|
262
|
+
diff = [diff, working, untrackedPatch(root, untracked)].filter(Boolean).join("\n");
|
|
263
|
+
const map = new Map([...files, ...workingFiles, ...untracked].map((file) => [file.path, file]));
|
|
264
|
+
files = [...map.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
265
|
+
}
|
|
266
|
+
for (const file of files) if (!file.binary && file.status !== "deleted") file.binary = isBinary(path3.join(root, file.path));
|
|
267
|
+
const diffHash = sha256(diff.replace(/\r\n?/g, "\n"));
|
|
268
|
+
const identity = { schemaVersion: "1", baseSha, headSha, dirty, diffHash, files: files.map(({ path: name, status, oldPath, binary }) => ({ path: name, status, oldPath, binary })) };
|
|
269
|
+
return { schemaVersion: "1", repositoryRoot: root, baseRef, baseSha, headSha, dirty, diff, diffHash, files, snapshotId: hashObject(identity) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ../packages/impact/src/index.ts
|
|
273
|
+
import fs3 from "fs";
|
|
274
|
+
import path4 from "path";
|
|
275
|
+
import fg2 from "fast-glob";
|
|
276
|
+
var SOURCE = ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}", "!**/node_modules/**", "!**/dist/**", "!**/.next/**", "!**/coverage/**"];
|
|
277
|
+
var EXTENSIONS = ["", ".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js", "/index.jsx"];
|
|
278
|
+
function buildFallbackImpactIndex(root) {
|
|
279
|
+
const files = fg2.sync(SOURCE, { cwd: root, onlyFiles: true, unique: true }).map(normalizeRepoPath).sort();
|
|
280
|
+
const known = new Set(files);
|
|
281
|
+
const edges = [];
|
|
282
|
+
for (const file of files) {
|
|
283
|
+
let text;
|
|
284
|
+
try {
|
|
285
|
+
text = fs3.readFileSync(path4.join(root, file), "utf8");
|
|
286
|
+
} catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const imports = [
|
|
290
|
+
...text.matchAll(/(?:import|export)\s+(?:[^'";]+?\s+from\s+)?["']([^"']+)["']/g),
|
|
291
|
+
...text.matchAll(/(?:require|import)\(\s*["']([^"']+)["']\s*\)/g)
|
|
292
|
+
];
|
|
293
|
+
for (const match of imports) {
|
|
294
|
+
const specifier = match[1];
|
|
295
|
+
if (!specifier?.startsWith(".")) continue;
|
|
296
|
+
const base = normalizeRepoPath(path4.posix.join(path4.posix.dirname(file), specifier));
|
|
297
|
+
const target = EXTENSIONS.map((extension) => `${base}${extension}`).find((candidate) => known.has(candidate));
|
|
298
|
+
if (target) edges.push({ from: file, to: target, kind: "import", confidence: "high", provenance: `static-import:${file}` });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const stable = edges.sort((a, b) => `${a.from}:${a.to}`.localeCompare(`${b.from}:${b.to}`));
|
|
302
|
+
return { provider: "fallback-import-graph", version: "1", id: hashObject(stable), edges: stable };
|
|
303
|
+
}
|
|
304
|
+
function impactedFiles(index, changedFiles, depth = 2) {
|
|
305
|
+
const changed = new Set(changedFiles.map(normalizeRepoPath));
|
|
306
|
+
const files = new Set(changed);
|
|
307
|
+
const used = [];
|
|
308
|
+
let frontier = new Set(changed);
|
|
309
|
+
for (let step = 0; step < depth; step++) {
|
|
310
|
+
const next = /* @__PURE__ */ new Set();
|
|
311
|
+
for (const edge of index.edges) {
|
|
312
|
+
if (edge.confidence !== "high" || !frontier.has(edge.to) || files.has(edge.from)) continue;
|
|
313
|
+
files.add(edge.from);
|
|
314
|
+
next.add(edge.from);
|
|
315
|
+
used.push(edge);
|
|
316
|
+
}
|
|
317
|
+
frontier = next;
|
|
318
|
+
if (!frontier.size) break;
|
|
319
|
+
}
|
|
320
|
+
return { files: [...files].sort(), edges: used };
|
|
321
|
+
}
|
|
322
|
+
function relatedTests(index, changedFiles) {
|
|
323
|
+
const impacted = impactedFiles(index, changedFiles, 3).files;
|
|
324
|
+
return impacted.filter((file) => /(?:^|\/)(?:__tests__|tests?|spec)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file));
|
|
325
|
+
}
|
|
326
|
+
function loadCodeGraphIndex(root) {
|
|
327
|
+
const filename = path4.join(root, ".scriptonia", "codegraph.json");
|
|
328
|
+
if (!fs3.existsSync(filename)) return void 0;
|
|
329
|
+
try {
|
|
330
|
+
const value = JSON.parse(fs3.readFileSync(filename, "utf8"));
|
|
331
|
+
const edges = (value.edges ?? []).filter((edge) => edge && typeof edge.from === "string" && typeof edge.to === "string" && ["high", "medium", "low"].includes(edge.confidence));
|
|
332
|
+
return { provider: "codegraph-file-adapter", version: "1", id: hashObject({ sourceVersion: value.version, edges }), edges };
|
|
333
|
+
} catch {
|
|
334
|
+
return void 0;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ../packages/model-gateway/src/index.ts
|
|
339
|
+
import { z } from "zod";
|
|
340
|
+
var judgeResultSchema = z.object({
|
|
341
|
+
result: z.enum(["supports", "contradicts", "uncertain"]),
|
|
342
|
+
confidence: z.number().min(0).max(1),
|
|
343
|
+
evidenceRefs: z.array(z.string()).default([]),
|
|
344
|
+
reason: z.string()
|
|
345
|
+
});
|
|
346
|
+
async function semanticJudge(input) {
|
|
347
|
+
const model = input.model ?? process.env.OPENAI_OUTPUT_MODEL ?? "gpt-5.6-terra";
|
|
348
|
+
const rubric = "Assess only whether the supplied evidence supports or contradicts the criterion. Return uncertain when evidence is insufficient. Never infer behavior from code style or plausibility.";
|
|
349
|
+
const serialized = JSON.stringify(input.evidenceBundle);
|
|
350
|
+
const maxBytes = input.maxBytes ?? 12e4;
|
|
351
|
+
if (Buffer.byteLength(serialized) > maxBytes) return { available: false, error: `evidence bundle exceeds ${maxBytes} byte limit` };
|
|
352
|
+
const gatewayUrl = process.env.SCRIPTONIA_MODEL_GATEWAY_URL?.replace(/\/$/, "");
|
|
353
|
+
const gatewayToken = process.env.SCRIPTONIA_API_TOKEN;
|
|
354
|
+
if (gatewayUrl && gatewayToken) {
|
|
355
|
+
const controller2 = new AbortController();
|
|
356
|
+
const timeout2 = setTimeout(() => controller2.abort(), input.timeoutMs ?? 2e4);
|
|
357
|
+
try {
|
|
358
|
+
const response = await (input.fetchImpl ?? fetch)(`${gatewayUrl}/api/v2/judge`, { method: "POST", headers: { authorization: `Bearer ${gatewayToken}`, "content-type": "application/json" }, signal: controller2.signal, body: JSON.stringify({ criterion: input.criterion, evidence: input.evidenceBundle }) });
|
|
359
|
+
const body = await response.json();
|
|
360
|
+
if (!response.ok) return { available: false, error: `Scriptonia model gateway returned ${response.status}` };
|
|
361
|
+
const parsed = judgeResultSchema.safeParse(body);
|
|
362
|
+
if (!parsed.success) return { available: false, error: "Scriptonia model gateway returned invalid structured output" };
|
|
363
|
+
return { available: true, result: { ...parsed.data, schemaVersion: "1", evaluatorVersion: "1", model: String(body.model ?? model), ...typeof body.providerRevision === "string" ? { providerRevision: body.providerRevision } : {}, rubricHash: String(body.rubricHash ?? hashObject(rubric)) } };
|
|
364
|
+
} catch (error) {
|
|
365
|
+
return { available: false, error: error instanceof Error ? error.message : String(error) };
|
|
366
|
+
} finally {
|
|
367
|
+
clearTimeout(timeout2);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const endpoint = input.endpoint ?? process.env.LITELLM_BASE_URL;
|
|
371
|
+
const apiKey = input.apiKey ?? process.env.LITELLM_MASTER_KEY;
|
|
372
|
+
if (!endpoint || !apiKey) return { available: false, error: "Scriptonia model gateway is not configured" };
|
|
373
|
+
const directModel = input.model ?? process.env.LITELLM_MODEL ?? "semantic-judge";
|
|
374
|
+
const controller = new AbortController();
|
|
375
|
+
const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 2e4);
|
|
376
|
+
const url = `${endpoint.replace(/\/$/, "")}/v1/chat/completions`;
|
|
377
|
+
try {
|
|
378
|
+
const response = await (input.fetchImpl ?? fetch)(url, {
|
|
379
|
+
method: "POST",
|
|
380
|
+
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
381
|
+
signal: controller.signal,
|
|
382
|
+
body: JSON.stringify({
|
|
383
|
+
model: directModel,
|
|
384
|
+
temperature: 0,
|
|
385
|
+
response_format: { type: "json_object" },
|
|
386
|
+
messages: [
|
|
387
|
+
{ role: "system", content: `${rubric}
|
|
388
|
+
Return JSON with result, confidence, evidenceRefs, reason.` },
|
|
389
|
+
{ role: "user", content: JSON.stringify({ criterion: input.criterion, evidence: input.evidenceBundle }) }
|
|
390
|
+
]
|
|
391
|
+
})
|
|
392
|
+
});
|
|
393
|
+
const body = await response.json();
|
|
394
|
+
if (!response.ok) return { available: false, error: `LiteLLM returned ${response.status}` };
|
|
395
|
+
const content = body.choices?.[0]?.message?.content;
|
|
396
|
+
if (typeof content !== "string") return { available: false, error: "LiteLLM response contains no message content" };
|
|
397
|
+
const parsed = judgeResultSchema.safeParse(JSON.parse(content));
|
|
398
|
+
if (!parsed.success) return { available: false, error: "semantic judge returned invalid structured output" };
|
|
399
|
+
return { available: true, result: { ...parsed.data, schemaVersion: "1", evaluatorVersion: "1", model: directModel, rubricHash: hashObject(rubric) } };
|
|
400
|
+
} catch (error) {
|
|
401
|
+
return { available: false, error: error instanceof Error ? error.message : String(error) };
|
|
402
|
+
} finally {
|
|
403
|
+
clearTimeout(timeout);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ../packages/observability/src/index.ts
|
|
408
|
+
import { SpanStatusCode, trace } from "@opentelemetry/api";
|
|
409
|
+
var started = false;
|
|
410
|
+
async function initializeTelemetry() {
|
|
411
|
+
if (started || process.env.SCRIPTONIA_TELEMETRY !== "1" || !process.env.OTEL_EXPORTER_OTLP_ENDPOINT) return;
|
|
412
|
+
const [{ NodeSDK }, { OTLPTraceExporter }] = await Promise.all([import("@opentelemetry/sdk-node"), import("@opentelemetry/exporter-trace-otlp-http")]);
|
|
413
|
+
const sdk = new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/$/, "")}/v1/traces` }) });
|
|
414
|
+
await sdk.start();
|
|
415
|
+
started = true;
|
|
416
|
+
}
|
|
417
|
+
async function withSpan(name, attributes, operation) {
|
|
418
|
+
const tracer = trace.getTracer("scriptonia", "0.9.0-rc.1");
|
|
419
|
+
return tracer.startActiveSpan(name, { attributes }, async (span) => {
|
|
420
|
+
const startedAt = performance.now();
|
|
421
|
+
try {
|
|
422
|
+
const result = await operation();
|
|
423
|
+
span.setAttribute("duration_bucket_ms", bucket(performance.now() - startedAt));
|
|
424
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
425
|
+
return result;
|
|
426
|
+
} catch (error) {
|
|
427
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error) });
|
|
428
|
+
throw error;
|
|
429
|
+
} finally {
|
|
430
|
+
span.end();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
function bucket(duration) {
|
|
435
|
+
const buckets = [10, 25, 50, 100, 250, 500, 1e3, 2e3, 5e3, 1e4, 3e4];
|
|
436
|
+
return buckets.find((value) => duration <= value) ?? 6e4;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ../packages/policy-engine/src/index.ts
|
|
440
|
+
import fs5 from "fs";
|
|
441
|
+
import path6 from "path";
|
|
442
|
+
import picomatch2 from "picomatch";
|
|
443
|
+
import YAML from "yaml";
|
|
444
|
+
|
|
445
|
+
// ../packages/schemas/src/index.ts
|
|
446
|
+
import { z as z2 } from "zod";
|
|
447
|
+
var checkStateSchema = z2.enum(["PASS", "FAIL", "INCONCLUSIVE", "SKIPPED", "ERROR"]);
|
|
448
|
+
var gateStateSchema = z2.enum(["PASS", "PASS_WITH_WARNINGS", "ACTION_REQUIRED", "BLOCKED", "INCOMPLETE"]);
|
|
449
|
+
var enforcementSchema = z2.enum(["observe", "warn", "action_required", "block"]);
|
|
450
|
+
var lifecycleSchema = z2.enum(["draft", "active", "superseded", "expired", "stale", "quarantined", "archived"]);
|
|
451
|
+
var defaultScope = {
|
|
452
|
+
repositories: [],
|
|
453
|
+
packages: [],
|
|
454
|
+
services: [],
|
|
455
|
+
paths: { include: ["**"], exclude: [] },
|
|
456
|
+
symbols: [],
|
|
457
|
+
dependencies: [],
|
|
458
|
+
owners: [],
|
|
459
|
+
tags: []
|
|
460
|
+
};
|
|
461
|
+
var scopeSchema = z2.object({
|
|
462
|
+
repositories: z2.array(z2.string()).default([]),
|
|
463
|
+
packages: z2.array(z2.string()).default([]),
|
|
464
|
+
services: z2.array(z2.string()).default([]),
|
|
465
|
+
paths: z2.object({ include: z2.array(z2.string()).default(["**"]), exclude: z2.array(z2.string()).default([]) }).default({ include: ["**"], exclude: [] }),
|
|
466
|
+
symbols: z2.array(z2.string()).default([]),
|
|
467
|
+
dependencies: z2.array(z2.string()).default([]),
|
|
468
|
+
owners: z2.array(z2.string()).default([]),
|
|
469
|
+
tags: z2.array(z2.string()).default([])
|
|
470
|
+
}).default(defaultScope);
|
|
471
|
+
var predicateKinds = [
|
|
472
|
+
"path_forbidden",
|
|
473
|
+
"path_requires_owner",
|
|
474
|
+
"dependency_forbidden",
|
|
475
|
+
"dependency_required",
|
|
476
|
+
"symbol_forbidden",
|
|
477
|
+
"symbol_requires_test",
|
|
478
|
+
"file_pattern_required",
|
|
479
|
+
"permission_required",
|
|
480
|
+
"check_required"
|
|
481
|
+
];
|
|
482
|
+
var predicateSchema = z2.object({
|
|
483
|
+
kind: z2.enum(predicateKinds),
|
|
484
|
+
names: z2.array(z2.string()).default([]),
|
|
485
|
+
patterns: z2.array(z2.string()).default([]),
|
|
486
|
+
owner: z2.string().optional(),
|
|
487
|
+
permission: z2.string().optional(),
|
|
488
|
+
check: z2.string().optional()
|
|
489
|
+
});
|
|
490
|
+
var decisionSchema = z2.object({
|
|
491
|
+
schema_version: z2.literal("1").default("1"),
|
|
492
|
+
id: z2.string().min(1),
|
|
493
|
+
version: z2.number().int().positive().default(1),
|
|
494
|
+
title: z2.string().min(1),
|
|
495
|
+
state: lifecycleSchema.default("draft"),
|
|
496
|
+
owner: z2.string().optional(),
|
|
497
|
+
source_refs: z2.array(z2.string()).default([]),
|
|
498
|
+
scope: scopeSchema,
|
|
499
|
+
predicate: predicateSchema,
|
|
500
|
+
enforcement: enforcementSchema.default("observe"),
|
|
501
|
+
created_at: z2.string().datetime().optional(),
|
|
502
|
+
confirmed_by: z2.string().optional(),
|
|
503
|
+
supersedes: z2.string().optional()
|
|
504
|
+
});
|
|
505
|
+
var evidenceRequirementSchema = z2.object({
|
|
506
|
+
kind: z2.enum(["github_check", "junit_test", "repository_fact", "decision_predicate", "state_validator", "human_confirmation", "semantic_judge"]),
|
|
507
|
+
name: z2.string().optional(),
|
|
508
|
+
suite: z2.string().optional(),
|
|
509
|
+
test: z2.string().optional(),
|
|
510
|
+
path: z2.string().optional(),
|
|
511
|
+
value: z2.string().optional()
|
|
512
|
+
});
|
|
513
|
+
var evalCaseSchema = z2.object({
|
|
514
|
+
schema_version: z2.literal("1").default("1"),
|
|
515
|
+
id: z2.string().min(1),
|
|
516
|
+
version: z2.number().int().positive().default(1),
|
|
517
|
+
title: z2.string().min(1),
|
|
518
|
+
state: lifecycleSchema.default("draft"),
|
|
519
|
+
owner: z2.string().optional(),
|
|
520
|
+
origin: z2.object({ kind: z2.string(), run_ref: z2.string().optional(), finding_ref: z2.string().optional() }),
|
|
521
|
+
decision_refs: z2.array(z2.string()).default([]),
|
|
522
|
+
signal_refs: z2.array(z2.string()).default([]),
|
|
523
|
+
scope: scopeSchema,
|
|
524
|
+
assertion: z2.object({ kind: z2.string(), description: z2.string().optional() }),
|
|
525
|
+
evidence: z2.object({ all: z2.array(evidenceRequirementSchema).default([]), any: z2.array(evidenceRequirementSchema).default([]) }).default({ all: [], any: [] }),
|
|
526
|
+
enforcement: enforcementSchema.default("observe"),
|
|
527
|
+
fingerprint: z2.string().optional(),
|
|
528
|
+
last_confirmed_at: z2.string().datetime().optional(),
|
|
529
|
+
review_after: z2.string().datetime().optional()
|
|
530
|
+
});
|
|
531
|
+
var runEventSchema = z2.object({
|
|
532
|
+
schemaVersion: z2.literal("1"),
|
|
533
|
+
eventId: z2.string(),
|
|
534
|
+
runId: z2.string(),
|
|
535
|
+
sequence: z2.number().int().nonnegative(),
|
|
536
|
+
occurredAt: z2.string().datetime(),
|
|
537
|
+
source: z2.object({ adapter: z2.string(), adapterVersion: z2.string(), agent: z2.string().optional(), agentVersion: z2.string().optional() }),
|
|
538
|
+
type: z2.enum(["run.started", "run.finished", "tool.requested", "tool.completed", "file.changed", "command.requested", "human.corrected", "error"]),
|
|
539
|
+
payload: z2.unknown(),
|
|
540
|
+
redaction: z2.object({ applied: z2.boolean(), policyVersion: z2.string() })
|
|
541
|
+
});
|
|
542
|
+
var configSchema = z2.object({
|
|
543
|
+
schema_version: z2.literal("1").default("1"),
|
|
544
|
+
base_ref: z2.string().default("HEAD"),
|
|
545
|
+
required_plan: z2.boolean().default(false),
|
|
546
|
+
incomplete_blocks: z2.boolean().default(true),
|
|
547
|
+
trusted_config_ref: z2.string().default("HEAD"),
|
|
548
|
+
evidence: z2.object({ junit: z2.array(z2.string()).default([]), github_needs_env: z2.string().default("SCRIPTONIA_NEEDS_JSON") }).default({ junit: [], github_needs_env: "SCRIPTONIA_NEEDS_JSON" }),
|
|
549
|
+
semantic: z2.object({ enabled: z2.boolean().default(false), endpoint: z2.string().url().optional(), model: z2.string().optional(), max_bytes: z2.number().int().positive().default(12e4), timeout_ms: z2.number().int().positive().default(2e4) }).default({ enabled: false, max_bytes: 12e4, timeout_ms: 2e4 }),
|
|
550
|
+
redaction: z2.object({ env_names: z2.array(z2.string()).default([]), patterns: z2.array(z2.string()).default([]), max_payload_bytes: z2.number().int().positive().default(128e3) }).default({ env_names: [], patterns: [], max_payload_bytes: 128e3 })
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ../packages/scope-engine/src/index.ts
|
|
554
|
+
import fs4 from "fs";
|
|
555
|
+
import path5 from "path";
|
|
556
|
+
import picomatch from "picomatch";
|
|
557
|
+
function discoverPackages(root) {
|
|
558
|
+
const result = /* @__PURE__ */ new Map();
|
|
559
|
+
const visit = (relative, depth) => {
|
|
560
|
+
if (depth > 4) return;
|
|
561
|
+
const absolute = path5.join(root, relative);
|
|
562
|
+
for (const entry of safeReadDir(absolute)) {
|
|
563
|
+
if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") continue;
|
|
564
|
+
const next = normalizeRepoPath(path5.posix.join(relative.replaceAll("\\", "/"), entry.name));
|
|
565
|
+
const pkgFile = path5.join(root, next, "package.json");
|
|
566
|
+
if (fs4.existsSync(pkgFile)) {
|
|
567
|
+
try {
|
|
568
|
+
result.set(next, String(JSON.parse(fs4.readFileSync(pkgFile, "utf8")).name || next));
|
|
569
|
+
} catch {
|
|
570
|
+
result.set(next, next);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
visit(next, depth + 1);
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
if (fs4.existsSync(path5.join(root, "package.json"))) {
|
|
577
|
+
try {
|
|
578
|
+
result.set(".", String(JSON.parse(fs4.readFileSync(path5.join(root, "package.json"), "utf8")).name || "."));
|
|
579
|
+
} catch {
|
|
580
|
+
result.set(".", ".");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
visit(".", 0);
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
function safeReadDir(dir) {
|
|
587
|
+
try {
|
|
588
|
+
return fs4.readdirSync(dir, { withFileTypes: true });
|
|
589
|
+
} catch {
|
|
590
|
+
return [];
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
function parseCodeowners(root) {
|
|
594
|
+
const candidates = ["CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS"];
|
|
595
|
+
const file = candidates.map((name) => path5.join(root, name)).find(fs4.existsSync);
|
|
596
|
+
const owners = /* @__PURE__ */ new Map();
|
|
597
|
+
if (!file) return owners;
|
|
598
|
+
for (const raw of fs4.readFileSync(file, "utf8").split("\n")) {
|
|
599
|
+
const line = raw.trim();
|
|
600
|
+
if (!line || line.startsWith("#")) continue;
|
|
601
|
+
const [pattern, ...names] = line.split(/\s+/);
|
|
602
|
+
if (pattern && names.length) owners.set(pattern.replace(/^\//, ""), names);
|
|
603
|
+
}
|
|
604
|
+
return owners;
|
|
605
|
+
}
|
|
606
|
+
function ownerFor(file, owners) {
|
|
607
|
+
let found;
|
|
608
|
+
for (const [pattern, names] of owners) {
|
|
609
|
+
try {
|
|
610
|
+
if (picomatch.isMatch(file, pattern, { dot: true, basename: !pattern.includes("/") })) found = names;
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return found?.join(",");
|
|
615
|
+
}
|
|
616
|
+
function buildScopeContext(root, files, extras = {}) {
|
|
617
|
+
return {
|
|
618
|
+
root,
|
|
619
|
+
files: files.map(normalizeRepoPath),
|
|
620
|
+
symbols: extras.symbols ?? extractSymbols(root, files),
|
|
621
|
+
dependencies: extras.dependencies ?? discoverDependencies(root),
|
|
622
|
+
checks: extras.checks ?? [],
|
|
623
|
+
permissions: extras.permissions ?? [],
|
|
624
|
+
owners: parseCodeowners(root),
|
|
625
|
+
packages: discoverPackages(root)
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
function matchScope(scope, context) {
|
|
629
|
+
const include = scope.paths.include.length ? scope.paths.include : ["**"];
|
|
630
|
+
const included = picomatch(include, { dot: true });
|
|
631
|
+
const excluded = scope.paths.exclude.length ? picomatch(scope.paths.exclude, { dot: true }) : () => false;
|
|
632
|
+
const pathFiles = context.files.filter((file) => included(file) && !excluded(file));
|
|
633
|
+
const packageRoots = [...context.packages.entries()].filter(([, name]) => scope.packages.includes(name)).map(([root]) => root);
|
|
634
|
+
const packageFiles = scope.packages.length ? context.files.filter((file) => packageRoots.some((root) => root === "." || file === root || file.startsWith(`${root}/`))) : [];
|
|
635
|
+
const symbols = scope.symbols.filter((symbol) => context.symbols.includes(symbol));
|
|
636
|
+
const deps = scope.dependencies.filter((dependency) => context.dependencies.includes(dependency));
|
|
637
|
+
const hasConstraint = scope.packages.length + scope.services.length + scope.symbols.length + scope.dependencies.length > 0 || scope.paths.include.some((item2) => item2 !== "**");
|
|
638
|
+
const matched = hasConstraint ? Boolean(pathFiles.length || packageFiles.length || symbols.length || deps.length) : Boolean(context.files.length);
|
|
639
|
+
const specificity = symbols.length ? 5 : pathFiles.some((file) => scope.paths.include.includes(file)) ? 4 : pathFiles.length ? 3 : packageFiles.length ? 2 : matched ? 1 : 0;
|
|
640
|
+
const files = [.../* @__PURE__ */ new Set([...pathFiles, ...packageFiles])];
|
|
641
|
+
const reasons = [files.length && `paths:${files.length}`, symbols.length && `symbols:${symbols.join(",")}`, deps.length && `dependencies:${deps.join(",")}`].filter(Boolean);
|
|
642
|
+
return { matched, specificity, files, reasons, owner: files.map((file) => ownerFor(file, context.owners)).find(Boolean) };
|
|
643
|
+
}
|
|
644
|
+
function discoverDependencies(root) {
|
|
645
|
+
const names = /* @__PURE__ */ new Set();
|
|
646
|
+
for (const [directory] of discoverPackages(root)) {
|
|
647
|
+
try {
|
|
648
|
+
const pkg = JSON.parse(fs4.readFileSync(path5.join(root, directory, "package.json"), "utf8"));
|
|
649
|
+
for (const section of [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies]) for (const name of Object.keys(section || {})) names.add(name);
|
|
650
|
+
} catch {
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return [...names].sort();
|
|
654
|
+
}
|
|
655
|
+
function extractSymbols(root, files) {
|
|
656
|
+
const symbols = /* @__PURE__ */ new Set();
|
|
657
|
+
const pattern = /\b(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|const|let|var)\s+([A-Za-z_$][\w$]*)/g;
|
|
658
|
+
for (const file of files) {
|
|
659
|
+
if (!/\.[cm]?[jt]sx?$/.test(file)) continue;
|
|
660
|
+
try {
|
|
661
|
+
const text = fs4.readFileSync(path5.join(root, file), "utf8");
|
|
662
|
+
for (const match of text.matchAll(pattern)) if (match[1]) symbols.add(match[1]);
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return [...symbols].sort();
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ../packages/policy-engine/src/index.ts
|
|
670
|
+
function loadDecisions(root) {
|
|
671
|
+
const directory = path6.join(root, ".scriptonia", "decisions");
|
|
672
|
+
if (!fs5.existsSync(directory)) return { decisions: [], errors: [] };
|
|
673
|
+
const decisions = [];
|
|
674
|
+
const errors = [];
|
|
675
|
+
for (const name of fs5.readdirSync(directory).filter((file) => /\.ya?ml$/i.test(file)).sort()) {
|
|
676
|
+
try {
|
|
677
|
+
decisions.push(decisionSchema.parse(YAML.parse(fs5.readFileSync(path6.join(directory, name), "utf8"))));
|
|
678
|
+
} catch (error) {
|
|
679
|
+
errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return { decisions, errors };
|
|
683
|
+
}
|
|
684
|
+
function parseDecisionDocuments(documents) {
|
|
685
|
+
const decisions = [];
|
|
686
|
+
const errors = [];
|
|
687
|
+
for (const document of documents) {
|
|
688
|
+
try {
|
|
689
|
+
decisions.push(decisionSchema.parse(YAML.parse(document.text)));
|
|
690
|
+
} catch (error) {
|
|
691
|
+
errors.push(`${document.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return { decisions, errors };
|
|
695
|
+
}
|
|
696
|
+
function evaluateDecisions(decisions, context) {
|
|
697
|
+
return decisions.map((decision) => evaluateDecision(decision, context));
|
|
698
|
+
}
|
|
699
|
+
function evaluateDecision(decision, context) {
|
|
700
|
+
const base = { id: `decision:${decision.id}@${decision.version}`, evaluator: `predicate:${decision.predicate.kind}`, evaluatorVersion: "1", enforcement: decision.enforcement, title: decision.title, decisionRef: `${decision.id}@${decision.version}` };
|
|
701
|
+
const finish = (state, reason, evidence, owner) => {
|
|
702
|
+
const identity = { decision: base.decisionRef, state, evidence, evaluator: base.evaluator, version: base.evaluatorVersion };
|
|
703
|
+
return { ...base, state, reason, evidence, ...owner ? { owner } : {}, fingerprint: hashObject(identity) };
|
|
704
|
+
};
|
|
705
|
+
if (decision.state !== "active") return finish("SKIPPED", `Decision is ${decision.state}; only active decisions enforce.`, []);
|
|
706
|
+
if (!decision.confirmed_by) return finish("INCONCLUSIVE", "Active decision has no human confirmation.", []);
|
|
707
|
+
const scoped = matchScope(decision.scope, context);
|
|
708
|
+
if (!scoped.matched) return finish("SKIPPED", "Decision scope does not intersect this change.", []);
|
|
709
|
+
const p = decision.predicate;
|
|
710
|
+
const names = p.names.length ? p.names : p.patterns;
|
|
711
|
+
const matches = (patterns) => context.files.filter((file) => picomatch2.isMatch(file, patterns, { dot: true }));
|
|
712
|
+
switch (p.kind) {
|
|
713
|
+
case "path_forbidden": {
|
|
714
|
+
const found = matches(names);
|
|
715
|
+
return finish(found.length ? "FAIL" : "PASS", found.length ? `Forbidden paths changed: ${found.join(", ")}` : "No forbidden path changed.", found.map((value) => ({ kind: "path", value })), decision.owner ?? scoped.owner);
|
|
716
|
+
}
|
|
717
|
+
case "path_requires_owner": {
|
|
718
|
+
const target = matches(names.length ? names : decision.scope.paths.include);
|
|
719
|
+
const missing = target.filter((file) => !ownerFor(file, context.owners) && !decision.owner && !p.owner);
|
|
720
|
+
return finish(missing.length ? "FAIL" : "PASS", missing.length ? `Changed paths have no owner: ${missing.join(", ")}` : "All applicable paths have an owner.", target.map((value) => ({ kind: "path", value })), decision.owner ?? p.owner ?? scoped.owner);
|
|
721
|
+
}
|
|
722
|
+
case "dependency_forbidden": {
|
|
723
|
+
const found = names.filter((name) => context.dependencies.includes(name));
|
|
724
|
+
return finish(found.length ? "FAIL" : "PASS", found.length ? `Forbidden dependencies present: ${found.join(", ")}` : "No forbidden dependency is present.", found.map((value) => ({ kind: "dependency", value })), decision.owner ?? scoped.owner);
|
|
725
|
+
}
|
|
726
|
+
case "dependency_required": {
|
|
727
|
+
const missing = names.filter((name) => !context.dependencies.includes(name));
|
|
728
|
+
return finish(missing.length ? "FAIL" : "PASS", missing.length ? `Required dependencies missing: ${missing.join(", ")}` : "Required dependencies are present.", names.map((value) => ({ kind: "dependency", value })), decision.owner ?? scoped.owner);
|
|
729
|
+
}
|
|
730
|
+
case "symbol_forbidden": {
|
|
731
|
+
const found = names.filter((name) => context.symbols.includes(name));
|
|
732
|
+
return finish(found.length ? "FAIL" : "PASS", found.length ? `Forbidden symbols changed: ${found.join(", ")}` : "No forbidden symbol changed.", found.map((value) => ({ kind: "symbol", value })), decision.owner ?? scoped.owner);
|
|
733
|
+
}
|
|
734
|
+
case "symbol_requires_test": {
|
|
735
|
+
const targeted = names.some((name) => context.symbols.includes(name));
|
|
736
|
+
const tests = context.files.filter((file) => /(?:^|\/)(?:__tests__|tests?|spec)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file));
|
|
737
|
+
return finish(targeted && !tests.length ? "FAIL" : "PASS", targeted && !tests.length ? "Changed symbol requires a changed test." : "Required test-file evidence is present or the symbol is unchanged.", tests.map((value) => ({ kind: "path", value })), decision.owner ?? scoped.owner);
|
|
738
|
+
}
|
|
739
|
+
case "file_pattern_required": {
|
|
740
|
+
const found = matches(names);
|
|
741
|
+
return finish(found.length ? "PASS" : "FAIL", found.length ? "Required file pattern is present in the change." : `No changed file matches: ${names.join(", ")}`, found.map((value) => ({ kind: "path", value })), decision.owner ?? scoped.owner);
|
|
742
|
+
}
|
|
743
|
+
case "permission_required": {
|
|
744
|
+
const required = p.permission ?? names[0];
|
|
745
|
+
if (!required) return finish("ERROR", "permission_required has no permission value.", []);
|
|
746
|
+
return finish(context.permissions.includes(required) ? "PASS" : "INCONCLUSIVE", context.permissions.includes(required) ? `Permission confirmed: ${required}` : `Permission not confirmed: ${required}`, [{ kind: "permission", value: required }], decision.owner ?? scoped.owner);
|
|
747
|
+
}
|
|
748
|
+
case "check_required": {
|
|
749
|
+
const required = p.check ?? names[0];
|
|
750
|
+
if (!required) return finish("ERROR", "check_required has no check name.", []);
|
|
751
|
+
return finish(context.checks.includes(required) ? "PASS" : "INCONCLUSIVE", context.checks.includes(required) ? `Required check passed: ${required}` : `Required check evidence unavailable: ${required}`, [{ kind: "check", value: required }], decision.owner ?? scoped.owner);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// ../packages/storage-sqlite/src/index.ts
|
|
757
|
+
import fs6 from "fs";
|
|
758
|
+
import path7 from "path";
|
|
759
|
+
import Database from "better-sqlite3";
|
|
760
|
+
var MIGRATION_1 = `
|
|
761
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL);
|
|
762
|
+
CREATE TABLE IF NOT EXISTS agent_runs (
|
|
763
|
+
id TEXT PRIMARY KEY, adapter TEXT NOT NULL, command_json TEXT NOT NULL, started_at TEXT NOT NULL,
|
|
764
|
+
finished_at TEXT, base_sha TEXT, head_sha TEXT, diff_hash TEXT, exit_code INTEGER, status TEXT NOT NULL
|
|
765
|
+
);
|
|
766
|
+
CREATE TABLE IF NOT EXISTS run_events (
|
|
767
|
+
event_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES agent_runs(id), sequence INTEGER NOT NULL,
|
|
768
|
+
occurred_at TEXT NOT NULL, type TEXT NOT NULL, payload_json TEXT NOT NULL, redacted INTEGER NOT NULL,
|
|
769
|
+
UNIQUE(run_id, sequence)
|
|
770
|
+
);
|
|
771
|
+
CREATE TABLE IF NOT EXISTS git_snapshots (
|
|
772
|
+
id TEXT PRIMARY KEY, base_sha TEXT NOT NULL, head_sha TEXT NOT NULL, diff_hash TEXT NOT NULL,
|
|
773
|
+
payload_json TEXT NOT NULL, created_at TEXT NOT NULL
|
|
774
|
+
);
|
|
775
|
+
CREATE TABLE IF NOT EXISTS verification_runs (
|
|
776
|
+
id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, gate_state TEXT NOT NULL, result_json TEXT NOT NULL,
|
|
777
|
+
created_at TEXT NOT NULL, refreshed_from TEXT
|
|
778
|
+
);
|
|
779
|
+
CREATE TABLE IF NOT EXISTS verdict_cache (
|
|
780
|
+
snapshot_id TEXT PRIMARY KEY, verification_id TEXT NOT NULL REFERENCES verification_runs(id), result_json TEXT NOT NULL,
|
|
781
|
+
created_at TEXT NOT NULL
|
|
782
|
+
);
|
|
783
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
784
|
+
id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL, fingerprint TEXT NOT NULL, evaluator TEXT NOT NULL,
|
|
785
|
+
state TEXT NOT NULL, enforcement TEXT NOT NULL, owner TEXT, payload_json TEXT NOT NULL,
|
|
786
|
+
first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, UNIQUE(fingerprint, snapshot_id)
|
|
787
|
+
);
|
|
788
|
+
CREATE TABLE IF NOT EXISTS acknowledgements (
|
|
789
|
+
id TEXT PRIMARY KEY, finding_id TEXT NOT NULL REFERENCES findings(id), reason TEXT NOT NULL,
|
|
790
|
+
comment TEXT, actor TEXT NOT NULL, snapshot_id TEXT NOT NULL, created_at TEXT NOT NULL
|
|
791
|
+
);
|
|
792
|
+
CREATE TABLE IF NOT EXISTS eval_cases (
|
|
793
|
+
id TEXT NOT NULL, version INTEGER NOT NULL, state TEXT NOT NULL, fingerprint TEXT,
|
|
794
|
+
payload_json TEXT NOT NULL, indexed_at TEXT NOT NULL, PRIMARY KEY(id, version)
|
|
795
|
+
);
|
|
796
|
+
CREATE TABLE IF NOT EXISTS decision_versions (
|
|
797
|
+
id TEXT NOT NULL, version INTEGER NOT NULL, state TEXT NOT NULL, payload_json TEXT NOT NULL,
|
|
798
|
+
indexed_at TEXT NOT NULL, PRIMARY KEY(id, version)
|
|
799
|
+
);
|
|
800
|
+
CREATE TABLE IF NOT EXISTS evidence_items (
|
|
801
|
+
id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, kind TEXT NOT NULL, state TEXT NOT NULL,
|
|
802
|
+
payload_json TEXT NOT NULL, collected_at TEXT NOT NULL
|
|
803
|
+
);
|
|
804
|
+
CREATE INDEX IF NOT EXISTS idx_findings_fingerprint ON findings(fingerprint);
|
|
805
|
+
CREATE INDEX IF NOT EXISTS idx_runs_started ON agent_runs(started_at DESC);
|
|
806
|
+
CREATE INDEX IF NOT EXISTS idx_verifications_created ON verification_runs(created_at DESC);
|
|
807
|
+
`;
|
|
808
|
+
var MIGRATION_2 = `
|
|
809
|
+
CREATE TABLE IF NOT EXISTS signals (
|
|
810
|
+
id TEXT PRIMARY KEY, source TEXT NOT NULL, body TEXT NOT NULL, content_hash TEXT NOT NULL,
|
|
811
|
+
metadata_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL
|
|
812
|
+
);
|
|
813
|
+
CREATE TABLE IF NOT EXISTS plans (
|
|
814
|
+
id TEXT NOT NULL, version INTEGER NOT NULL, state TEXT NOT NULL, body TEXT NOT NULL,
|
|
815
|
+
content_hash TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(id, version)
|
|
816
|
+
);
|
|
817
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
818
|
+
id TEXT PRIMARY KEY, current_version INTEGER NOT NULL, state TEXT NOT NULL, title TEXT NOT NULL, owner TEXT
|
|
819
|
+
);
|
|
820
|
+
CREATE TABLE IF NOT EXISTS decision_links (
|
|
821
|
+
decision_id TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL,
|
|
822
|
+
PRIMARY KEY(decision_id, ref_type, ref_id)
|
|
823
|
+
);
|
|
824
|
+
CREATE TABLE IF NOT EXISTS changed_files (
|
|
825
|
+
snapshot_id TEXT NOT NULL, path TEXT NOT NULL, status TEXT NOT NULL, old_path TEXT, binary INTEGER NOT NULL,
|
|
826
|
+
PRIMARY KEY(snapshot_id, path)
|
|
827
|
+
);
|
|
828
|
+
CREATE TABLE IF NOT EXISTS changed_symbols (
|
|
829
|
+
snapshot_id TEXT NOT NULL, symbol TEXT NOT NULL, path TEXT NOT NULL, language TEXT,
|
|
830
|
+
PRIMARY KEY(snapshot_id, symbol, path)
|
|
831
|
+
);
|
|
832
|
+
CREATE TABLE IF NOT EXISTS impact_indexes (
|
|
833
|
+
id TEXT PRIMARY KEY, provider TEXT NOT NULL, version TEXT NOT NULL, snapshot_id TEXT NOT NULL,
|
|
834
|
+
payload_json TEXT NOT NULL, created_at TEXT NOT NULL
|
|
835
|
+
);
|
|
836
|
+
CREATE TABLE IF NOT EXISTS impact_edges (
|
|
837
|
+
impact_index_id TEXT NOT NULL, from_ref TEXT NOT NULL, to_ref TEXT NOT NULL, kind TEXT NOT NULL,
|
|
838
|
+
confidence TEXT NOT NULL, provenance TEXT NOT NULL,
|
|
839
|
+
PRIMARY KEY(impact_index_id, from_ref, to_ref, kind)
|
|
840
|
+
);
|
|
841
|
+
CREATE TABLE IF NOT EXISTS eval_versions (
|
|
842
|
+
id TEXT NOT NULL, version INTEGER NOT NULL, state TEXT NOT NULL, payload_json TEXT NOT NULL,
|
|
843
|
+
indexed_at TEXT NOT NULL, PRIMARY KEY(id, version)
|
|
844
|
+
);
|
|
845
|
+
CREATE TABLE IF NOT EXISTS eval_links (
|
|
846
|
+
eval_id TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL,
|
|
847
|
+
PRIMARY KEY(eval_id, ref_type, ref_id)
|
|
848
|
+
);
|
|
849
|
+
CREATE TABLE IF NOT EXISTS verification_checks (
|
|
850
|
+
verification_id TEXT NOT NULL, check_id TEXT NOT NULL, state TEXT NOT NULL, enforcement TEXT NOT NULL,
|
|
851
|
+
fingerprint TEXT NOT NULL, payload_json TEXT NOT NULL, PRIMARY KEY(verification_id, check_id)
|
|
852
|
+
);
|
|
853
|
+
CREATE TABLE IF NOT EXISTS evaluator_calibration (
|
|
854
|
+
evaluator TEXT NOT NULL, version TEXT NOT NULL, sample_id TEXT NOT NULL, judge_result TEXT NOT NULL,
|
|
855
|
+
human_result TEXT NOT NULL, agreed INTEGER NOT NULL, created_at TEXT NOT NULL,
|
|
856
|
+
PRIMARY KEY(evaluator, version, sample_id)
|
|
857
|
+
);
|
|
858
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS fts_memory USING fts5(kind, ref_id UNINDEXED, title, body, tokenize='unicode61');
|
|
859
|
+
`;
|
|
860
|
+
var BrainStore = class {
|
|
861
|
+
db;
|
|
862
|
+
filename;
|
|
863
|
+
constructor(repoRoot, filename = path7.join(repoRoot, ".scriptonia", "brain.db")) {
|
|
864
|
+
fs6.mkdirSync(path7.dirname(filename), { recursive: true });
|
|
865
|
+
this.filename = filename;
|
|
866
|
+
this.db = new Database(filename);
|
|
867
|
+
this.db.pragma("journal_mode = WAL");
|
|
868
|
+
this.db.pragma("foreign_keys = ON");
|
|
869
|
+
this.db.pragma("busy_timeout = 5000");
|
|
870
|
+
this.db.pragma("synchronous = NORMAL");
|
|
871
|
+
this.migrate();
|
|
872
|
+
}
|
|
873
|
+
migrate() {
|
|
874
|
+
const current = Number(this.db.pragma("user_version", { simple: true }));
|
|
875
|
+
if (current < 1) this.db.transaction(() => {
|
|
876
|
+
this.db.exec(MIGRATION_1);
|
|
877
|
+
this.db.prepare("INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)").run(1, nowIso());
|
|
878
|
+
this.db.pragma("user_version = 1");
|
|
879
|
+
})();
|
|
880
|
+
if (current < 2) this.db.transaction(() => {
|
|
881
|
+
this.db.exec(MIGRATION_2);
|
|
882
|
+
this.db.prepare("INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)").run(2, nowIso());
|
|
883
|
+
this.db.pragma("user_version = 2");
|
|
884
|
+
})();
|
|
885
|
+
}
|
|
886
|
+
close() {
|
|
887
|
+
this.db.close();
|
|
888
|
+
}
|
|
889
|
+
createRun(run) {
|
|
890
|
+
this.db.prepare(`INSERT INTO agent_runs(id,adapter,command_json,started_at,base_sha,status) VALUES (?,?,?,?,?,?)`).run(run.id, run.adapter, canonicalize(run.command), run.startedAt, run.baseSha ?? null, "running");
|
|
891
|
+
}
|
|
892
|
+
appendEvent(event) {
|
|
893
|
+
this.db.prepare(`INSERT INTO run_events(event_id,run_id,sequence,occurred_at,type,payload_json,redacted) VALUES (?,?,?,?,?,?,?)`).run(event.eventId, event.runId, event.sequence, event.occurredAt, event.type, canonicalize(event.payload), event.redacted ? 1 : 0);
|
|
894
|
+
}
|
|
895
|
+
finishRun(run) {
|
|
896
|
+
this.db.prepare(`UPDATE agent_runs SET finished_at=?, head_sha=?, diff_hash=?, exit_code=?, status=? WHERE id=?`).run(run.finishedAt, run.headSha ?? null, run.diffHash ?? null, run.exitCode, run.status, run.id);
|
|
897
|
+
}
|
|
898
|
+
listRuns(limit = 20) {
|
|
899
|
+
return this.db.prepare(`SELECT id,adapter,command_json AS command,started_at,finished_at,base_sha,head_sha,diff_hash,exit_code,status FROM agent_runs ORDER BY started_at DESC LIMIT ?`).all(limit).map((row) => ({ ...row, command: JSON.parse(String(row.command)) }));
|
|
900
|
+
}
|
|
901
|
+
getRun(id) {
|
|
902
|
+
const run = this.db.prepare(`SELECT * FROM agent_runs WHERE id=?`).get(id);
|
|
903
|
+
if (!run) return void 0;
|
|
904
|
+
const events = this.db.prepare(`SELECT event_id,sequence,occurred_at,type,payload_json,redacted FROM run_events WHERE run_id=? ORDER BY sequence`).all(id).map((row) => {
|
|
905
|
+
const { payload_json, ...rest2 } = row;
|
|
906
|
+
return { ...rest2, payload: JSON.parse(String(payload_json)) };
|
|
907
|
+
});
|
|
908
|
+
const { command_json, ...rest } = run;
|
|
909
|
+
return { ...rest, command: JSON.parse(String(command_json)), events };
|
|
910
|
+
}
|
|
911
|
+
getCached(snapshotId) {
|
|
912
|
+
const row = this.db.prepare(`SELECT result_json FROM verdict_cache WHERE snapshot_id=?`).get(snapshotId);
|
|
913
|
+
return row ? JSON.parse(row.result_json) : void 0;
|
|
914
|
+
}
|
|
915
|
+
indexDecision(decision) {
|
|
916
|
+
const json = canonicalize(decision);
|
|
917
|
+
this.db.transaction(() => {
|
|
918
|
+
this.db.prepare(`INSERT INTO decisions(id,current_version,state,title,owner) VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET current_version=excluded.current_version,state=excluded.state,title=excluded.title,owner=excluded.owner`).run(decision.id, decision.version, decision.state, decision.title, decision.owner ?? null);
|
|
919
|
+
this.db.prepare(`INSERT INTO decision_versions(id,version,state,payload_json,indexed_at) VALUES (?,?,?,?,?) ON CONFLICT(id,version) DO UPDATE SET state=excluded.state,payload_json=excluded.payload_json,indexed_at=excluded.indexed_at`).run(decision.id, decision.version, decision.state, json, nowIso());
|
|
920
|
+
this.db.prepare(`DELETE FROM fts_memory WHERE kind='decision' AND ref_id=?`).run(decision.id);
|
|
921
|
+
this.db.prepare(`INSERT INTO fts_memory(kind,ref_id,title,body) VALUES ('decision',?,?,?)`).run(decision.id, decision.title, json);
|
|
922
|
+
for (const ref of decision.source_refs ?? []) this.db.prepare(`INSERT OR IGNORE INTO decision_links(decision_id,ref_type,ref_id) VALUES (?,?,?)`).run(decision.id, ref.split("_")[0] || "ref", ref);
|
|
923
|
+
})();
|
|
924
|
+
}
|
|
925
|
+
indexEval(evaluation) {
|
|
926
|
+
const json = canonicalize(evaluation);
|
|
927
|
+
this.db.transaction(() => {
|
|
928
|
+
this.db.prepare(`INSERT INTO eval_cases(id,version,state,fingerprint,payload_json,indexed_at) VALUES (?,?,?,?,?,?) ON CONFLICT(id,version) DO UPDATE SET state=excluded.state,fingerprint=excluded.fingerprint,payload_json=excluded.payload_json,indexed_at=excluded.indexed_at`).run(evaluation.id, evaluation.version, evaluation.state, String(evaluation.fingerprint ?? ""), json, nowIso());
|
|
929
|
+
this.db.prepare(`INSERT INTO eval_versions(id,version,state,payload_json,indexed_at) VALUES (?,?,?,?,?) ON CONFLICT(id,version) DO UPDATE SET state=excluded.state,payload_json=excluded.payload_json,indexed_at=excluded.indexed_at`).run(evaluation.id, evaluation.version, evaluation.state, json, nowIso());
|
|
930
|
+
this.db.prepare(`DELETE FROM fts_memory WHERE kind='eval' AND ref_id=?`).run(evaluation.id);
|
|
931
|
+
this.db.prepare(`INSERT INTO fts_memory(kind,ref_id,title,body) VALUES ('eval',?,?,?)`).run(evaluation.id, evaluation.title, json);
|
|
932
|
+
for (const ref of evaluation.decision_refs ?? []) this.db.prepare(`INSERT OR IGNORE INTO eval_links(eval_id,ref_type,ref_id) VALUES (?,?,?)`).run(evaluation.id, "decision", ref);
|
|
933
|
+
for (const ref of evaluation.signal_refs ?? []) this.db.prepare(`INSERT OR IGNORE INTO eval_links(eval_id,ref_type,ref_id) VALUES (?,?,?)`).run(evaluation.id, "signal", ref);
|
|
934
|
+
})();
|
|
935
|
+
}
|
|
936
|
+
saveSnapshot(snapshot) {
|
|
937
|
+
this.db.transaction(() => {
|
|
938
|
+
this.db.prepare(`INSERT OR IGNORE INTO git_snapshots(id,base_sha,head_sha,diff_hash,payload_json,created_at) VALUES (?,?,?,?,?,?)`).run(snapshot.id, snapshot.baseSha, snapshot.headSha, snapshot.diffHash, canonicalize(snapshot.payload), nowIso());
|
|
939
|
+
const statement = this.db.prepare(`INSERT OR REPLACE INTO changed_files(snapshot_id,path,status,old_path,binary) VALUES (?,?,?,?,?)`);
|
|
940
|
+
for (const file of snapshot.files) statement.run(snapshot.id, file.path, file.status, file.oldPath ?? null, file.binary ? 1 : 0);
|
|
941
|
+
})();
|
|
942
|
+
}
|
|
943
|
+
saveImpact(input) {
|
|
944
|
+
this.db.transaction(() => {
|
|
945
|
+
this.db.prepare(`INSERT OR IGNORE INTO impact_indexes(id,provider,version,snapshot_id,payload_json,created_at) VALUES (?,?,?,?,?,?)`).run(input.id, input.provider, input.version, input.snapshotId, canonicalize(input), nowIso());
|
|
946
|
+
const statement = this.db.prepare(`INSERT OR IGNORE INTO impact_edges(impact_index_id,from_ref,to_ref,kind,confidence,provenance) VALUES (?,?,?,?,?,?)`);
|
|
947
|
+
for (const edge of input.edges) statement.run(input.id, edge.from, edge.to, edge.kind, edge.confidence, edge.provenance);
|
|
948
|
+
})();
|
|
949
|
+
}
|
|
950
|
+
saveEvidence(items) {
|
|
951
|
+
const statement = this.db.prepare(`INSERT OR REPLACE INTO evidence_items(id,content_hash,kind,state,payload_json,collected_at) VALUES (?,?,?,?,?,?)`);
|
|
952
|
+
this.db.transaction(() => {
|
|
953
|
+
for (const evidence of items) statement.run(evidence.id, evidence.contentHash, evidence.kind, evidence.state, canonicalize(evidence.payload), evidence.collectedAt);
|
|
954
|
+
})();
|
|
955
|
+
}
|
|
956
|
+
saveChecks(verificationId, checks) {
|
|
957
|
+
const statement = this.db.prepare(`INSERT OR REPLACE INTO verification_checks(verification_id,check_id,state,enforcement,fingerprint,payload_json) VALUES (?,?,?,?,?,?)`);
|
|
958
|
+
this.db.transaction(() => {
|
|
959
|
+
for (const check of checks) statement.run(verificationId, check.id, check.state, check.enforcement, check.fingerprint, canonicalize(check));
|
|
960
|
+
})();
|
|
961
|
+
}
|
|
962
|
+
saveVerification(input) {
|
|
963
|
+
const json = canonicalize(input.result);
|
|
964
|
+
this.db.transaction(() => {
|
|
965
|
+
this.db.prepare(`INSERT INTO verification_runs(id,snapshot_id,gate_state,result_json,created_at) VALUES (?,?,?,?,?)`).run(input.id, input.snapshotId, input.gateState, json, nowIso());
|
|
966
|
+
this.db.prepare(`INSERT INTO verdict_cache(snapshot_id,verification_id,result_json,created_at) VALUES (?,?,?,?) ON CONFLICT(snapshot_id) DO UPDATE SET verification_id=excluded.verification_id,result_json=excluded.result_json,created_at=excluded.created_at`).run(input.snapshotId, input.id, json, nowIso());
|
|
967
|
+
})();
|
|
968
|
+
}
|
|
969
|
+
saveFinding(finding) {
|
|
970
|
+
const now = nowIso();
|
|
971
|
+
const existing = this.db.prepare(`SELECT id FROM findings WHERE fingerprint=? ORDER BY first_seen LIMIT 1`).get(finding.fingerprint);
|
|
972
|
+
if (existing) {
|
|
973
|
+
this.db.prepare(`UPDATE findings SET snapshot_id=?,state=?,enforcement=?,owner=?,payload_json=?,last_seen=? WHERE id=?`).run(finding.snapshotId, finding.state, finding.enforcement, finding.owner ?? null, canonicalize(finding.payload), now, existing.id);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
this.db.prepare(`INSERT INTO findings(id,snapshot_id,fingerprint,evaluator,state,enforcement,owner,payload_json,first_seen,last_seen) VALUES (?,?,?,?,?,?,?,?,?,?)`).run(finding.id, finding.snapshotId, finding.fingerprint, finding.evaluator, finding.state, finding.enforcement, finding.owner ?? null, canonicalize(finding.payload), now, now);
|
|
977
|
+
}
|
|
978
|
+
findFindingId(fingerprint) {
|
|
979
|
+
return this.db.prepare(`SELECT id FROM findings WHERE fingerprint=? ORDER BY first_seen LIMIT 1`).get(fingerprint)?.id;
|
|
980
|
+
}
|
|
981
|
+
getFinding(id) {
|
|
982
|
+
const row = this.db.prepare(`SELECT * FROM findings WHERE id=?`).get(id);
|
|
983
|
+
return row ? { ...row, payload: JSON.parse(String(row.payload_json)) } : void 0;
|
|
984
|
+
}
|
|
985
|
+
acknowledge(input) {
|
|
986
|
+
this.db.prepare(`INSERT INTO acknowledgements(id,finding_id,reason,comment,actor,snapshot_id,created_at) VALUES (?,?,?,?,?,?,?)`).run(input.id, input.findingId, input.reason, input.comment ?? null, input.actor, input.snapshotId, nowIso());
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
// ../packages/eval-engine/src/index.ts
|
|
991
|
+
var DEFAULT_CONFIG = configSchema.parse({});
|
|
992
|
+
function loadConfig(root) {
|
|
993
|
+
const filename = path8.join(root, ".scriptonia", "config.yaml");
|
|
994
|
+
if (!fs7.existsSync(filename)) return { config: DEFAULT_CONFIG, errors: [] };
|
|
995
|
+
try {
|
|
996
|
+
return { config: configSchema.parse(YAML2.parse(fs7.readFileSync(filename, "utf8"))), errors: [] };
|
|
997
|
+
} catch (error) {
|
|
998
|
+
return { config: DEFAULT_CONFIG, errors: [`config.yaml: ${error instanceof Error ? error.message : String(error)}`] };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function parseConfigDocument(text, name = "config.yaml") {
|
|
1002
|
+
if (text === void 0) return { config: DEFAULT_CONFIG, errors: [] };
|
|
1003
|
+
try {
|
|
1004
|
+
return { config: configSchema.parse(YAML2.parse(text)), errors: [] };
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
return { config: DEFAULT_CONFIG, errors: [`${name}: ${error instanceof Error ? error.message : String(error)}`] };
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
function ensureConfig(root) {
|
|
1010
|
+
const directory = path8.join(root, ".scriptonia");
|
|
1011
|
+
const filename = path8.join(directory, "config.yaml");
|
|
1012
|
+
fs7.mkdirSync(path8.join(directory, "decisions"), { recursive: true });
|
|
1013
|
+
fs7.mkdirSync(path8.join(directory, "evals"), { recursive: true });
|
|
1014
|
+
if (!fs7.existsSync(filename)) fs7.writeFileSync(filename, YAML2.stringify(DEFAULT_CONFIG));
|
|
1015
|
+
return filename;
|
|
1016
|
+
}
|
|
1017
|
+
function loadEvals(root) {
|
|
1018
|
+
const directory = path8.join(root, ".scriptonia", "evals");
|
|
1019
|
+
if (!fs7.existsSync(directory)) return { evals: [], errors: [] };
|
|
1020
|
+
const evals = [];
|
|
1021
|
+
const errors = [];
|
|
1022
|
+
for (const filename of fs7.readdirSync(directory).filter((file) => /\.ya?ml$/i.test(file)).sort()) {
|
|
1023
|
+
try {
|
|
1024
|
+
evals.push(evalCaseSchema.parse(YAML2.parse(fs7.readFileSync(path8.join(directory, filename), "utf8"))));
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
errors.push(`${filename}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
return { evals, errors };
|
|
1030
|
+
}
|
|
1031
|
+
function parseEvalDocuments(documents) {
|
|
1032
|
+
const evals = [];
|
|
1033
|
+
const errors = [];
|
|
1034
|
+
for (const document of documents) {
|
|
1035
|
+
try {
|
|
1036
|
+
evals.push(evalCaseSchema.parse(YAML2.parse(document.text)));
|
|
1037
|
+
} catch (error) {
|
|
1038
|
+
errors.push(`${document.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return { evals, errors };
|
|
1042
|
+
}
|
|
1043
|
+
async function verifyRepository(options) {
|
|
1044
|
+
if (!options.trustedBase) ensureConfig(options.root);
|
|
1045
|
+
const trustedRef = options.trustedBase ? options.base : void 0;
|
|
1046
|
+
const loadedConfig = trustedRef ? parseConfigDocument(readFileAtRef(options.root, trustedRef, ".scriptonia/config.yaml"), `${trustedRef}:.scriptonia/config.yaml`) : loadConfig(options.root);
|
|
1047
|
+
const config = loadedConfig.config;
|
|
1048
|
+
const snapshot = await withSpan("snapshot.build", { base_configured: Boolean(options.base ?? config.base_ref) }, () => buildSnapshot({ cwd: options.root, base: options.base ?? config.base_ref }));
|
|
1049
|
+
const planPath = path8.resolve(options.root, options.plan ?? "PLAN.md");
|
|
1050
|
+
const plan = fs7.existsSync(planPath) ? fs7.readFileSync(planPath, "utf8") : void 0;
|
|
1051
|
+
const loadedDecisions = trustedRef ? parseDecisionDocuments(listFilesAtRef(options.root, trustedRef, [".scriptonia/decisions"]).filter((name) => /\.ya?ml$/i.test(name)).map((name) => ({ name: `${trustedRef}:${name}`, text: readFileAtRef(options.root, trustedRef, name) ?? "" }))) : loadDecisions(options.root);
|
|
1052
|
+
const loadedEvals = trustedRef ? parseEvalDocuments(listFilesAtRef(options.root, trustedRef, [".scriptonia/evals"]).filter((name) => /\.ya?ml$/i.test(name)).map((name) => ({ name: `${trustedRef}:${name}`, text: readFileAtRef(options.root, trustedRef, name) ?? "" }))) : loadEvals(options.root);
|
|
1053
|
+
const evals = options.evalFilter ? loadedEvals.evals.filter((entry) => entry.id === options.evalFilter || entry.title.toLowerCase().includes(options.evalFilter.toLowerCase())) : loadedEvals.evals;
|
|
1054
|
+
const collected = await withSpan("evidence.collect", { changed_file_count: snapshot.files.length }, () => {
|
|
1055
|
+
const github2 = collectGitHubNeeds(options.githubNeeds ?? process.env[config.evidence.github_needs_env]);
|
|
1056
|
+
const junit = collectJUnit(options.root, config.evidence.junit);
|
|
1057
|
+
const repositoryFacts = collectRepositoryFacts(options.root, snapshot.files.map((file) => file.path));
|
|
1058
|
+
return { github: github2, evidence: [...github2, ...junit, ...repositoryFacts] };
|
|
1059
|
+
});
|
|
1060
|
+
const { github, evidence } = collected;
|
|
1061
|
+
const passedChecks = github.filter((item2) => item2.state === "available").map((item2) => String(item2.payload.name));
|
|
1062
|
+
const impactIndex = await withSpan("impact.index", { changed_file_count: snapshot.files.length }, () => loadCodeGraphIndex(options.root) ?? buildFallbackImpactIndex(options.root));
|
|
1063
|
+
const impact = impactedFiles(impactIndex, snapshot.files.map((file) => file.path));
|
|
1064
|
+
const scope = await withSpan("scope.resolve", { impacted_file_count: impact.files.length }, () => buildScopeContext(options.root, impact.files, { checks: passedChecks }));
|
|
1065
|
+
const policy = await withSpan("policy.evaluate", { decision_count: loadedDecisions.decisions.length }, () => evaluateDecisions(loadedDecisions.decisions, scope));
|
|
1066
|
+
const identity = {
|
|
1067
|
+
schemaVersion: "1",
|
|
1068
|
+
baseSha: snapshot.baseSha,
|
|
1069
|
+
headSha: snapshot.headSha,
|
|
1070
|
+
diffHash: snapshot.diffHash,
|
|
1071
|
+
config,
|
|
1072
|
+
plan: plan ? hashObject(plan) : null,
|
|
1073
|
+
decisions: loadedDecisions.decisions.map((entry) => ({ id: entry.id, version: entry.version, hash: hashObject(entry) })),
|
|
1074
|
+
evals: evals.map((entry) => ({ id: entry.id, version: entry.version, hash: hashObject(entry) })),
|
|
1075
|
+
evidence: evidence.map((entry) => ({ hash: entry.contentHash, state: entry.state })),
|
|
1076
|
+
impact: { provider: impactIndex.provider, version: impactIndex.version, id: impactIndex.id, edges: impact.edges },
|
|
1077
|
+
evaluators: { policy: "1", eval: "1", plan: "1", gate: "1" },
|
|
1078
|
+
semantic: config.semantic.enabled || options.semantic ? {
|
|
1079
|
+
model: config.semantic.model ?? process.env.OPENAI_OUTPUT_MODEL ?? process.env.LITELLM_MODEL ?? "gpt-5.6-terra",
|
|
1080
|
+
endpoint: config.semantic.endpoint ?? process.env.SCRIPTONIA_MODEL_GATEWAY_URL ?? process.env.LITELLM_BASE_URL ?? "unconfigured"
|
|
1081
|
+
} : null,
|
|
1082
|
+
trustedConfigurationRef: trustedRef ?? "working-tree"
|
|
1083
|
+
};
|
|
1084
|
+
const snapshotId = hashObject(identity);
|
|
1085
|
+
const store = new BrainStore(options.root);
|
|
1086
|
+
try {
|
|
1087
|
+
for (const decision of loadedDecisions.decisions) store.indexDecision({ ...decision });
|
|
1088
|
+
for (const evaluation of loadedEvals.evals) store.indexEval({ ...evaluation });
|
|
1089
|
+
store.saveSnapshot({ id: snapshotId, baseSha: snapshot.baseSha, headSha: snapshot.headSha, diffHash: snapshot.diffHash, payload: identity, files: snapshot.files });
|
|
1090
|
+
store.saveImpact({ id: impactIndex.id, provider: impactIndex.provider, version: impactIndex.version, snapshotId, edges: impact.edges });
|
|
1091
|
+
store.saveEvidence(evidence.map((entry) => ({ id: entry.id, contentHash: entry.contentHash, kind: entry.kind, state: entry.state, payload: entry.payload, collectedAt: entry.collectedAt })));
|
|
1092
|
+
if (!options.refresh) {
|
|
1093
|
+
const cached = await withSpan("cache.lookup", { refresh: false }, () => store.getCached(snapshotId));
|
|
1094
|
+
if (cached) return { ...cached, cached: true };
|
|
1095
|
+
}
|
|
1096
|
+
const checks = [];
|
|
1097
|
+
for (const error of [...loadedConfig.errors, ...loadedDecisions.errors, ...loadedEvals.errors]) checks.push(makeCheck({ evaluator: "definition-loader", title: "Definition validation", state: "ERROR", enforcement: "block", reason: error, refs: [] }));
|
|
1098
|
+
checks.push(...policy.map(policyToCheck));
|
|
1099
|
+
checks.push(...evaluateEvals(evals, scope, evidence, policy));
|
|
1100
|
+
checks.push(...evaluatePlan(plan, config.required_plan, evidence));
|
|
1101
|
+
checks.push(testCoverageCheck(snapshot, relatedTests(impactIndex, snapshot.files.map((file) => file.path))));
|
|
1102
|
+
checks.push(...github.map(githubCheck));
|
|
1103
|
+
let semanticCoverage = config.semantic.enabled || options.semantic ? "unavailable" : "disabled";
|
|
1104
|
+
if ((config.semantic.enabled || options.semantic) && plan) {
|
|
1105
|
+
const semanticChecks = await evaluateSemanticCriteria(plan, snapshot, config);
|
|
1106
|
+
if (semanticChecks.some((check) => !check.reason.includes("unavailable"))) semanticCoverage = "available";
|
|
1107
|
+
checks.push(...semanticChecks);
|
|
1108
|
+
}
|
|
1109
|
+
const gate = await withSpan("verdict.aggregate", { check_count: checks.length }, () => aggregateGate(checks, config.incomplete_blocks));
|
|
1110
|
+
for (const check of checks) {
|
|
1111
|
+
if (check.state === "FAIL" || check.state === "ERROR" || check.state === "INCONCLUSIVE" && check.enforcement === "block") {
|
|
1112
|
+
check.findingId = store.findFindingId(check.fingerprint) ?? `finding_${hashObject({ snapshotId, fingerprint: check.fingerprint }).slice(0, 24)}`;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const verificationId = createId("verification");
|
|
1116
|
+
const decisionEvaluated = policy.filter((entry) => entry.state !== "SKIPPED").length;
|
|
1117
|
+
const evalEvaluated = checks.filter((entry) => entry.evaluator === "eval-evidence" && entry.state !== "SKIPPED").length;
|
|
1118
|
+
const result = {
|
|
1119
|
+
schemaVersion: "1",
|
|
1120
|
+
verificationId,
|
|
1121
|
+
snapshotId,
|
|
1122
|
+
createdAt: nowIso(),
|
|
1123
|
+
cached: false,
|
|
1124
|
+
gate,
|
|
1125
|
+
exitCode: exitCodeForGate(gate),
|
|
1126
|
+
coverage: { plan: plan ? "present" : "absent", decisions: { total: policy.length, evaluated: decisionEvaluated, skipped: policy.length - decisionEvaluated }, evals: { total: evals.length, evaluated: evalEvaluated, skipped: evals.length - evalEvaluated }, semantic: semanticCoverage },
|
|
1127
|
+
snapshot: { schemaVersion: snapshot.schemaVersion, baseRef: snapshot.baseRef, baseSha: snapshot.baseSha, headSha: snapshot.headSha, dirty: snapshot.dirty, diffHash: snapshot.diffHash, files: snapshot.files, snapshotId: snapshot.snapshotId, repositoryRoot: "." },
|
|
1128
|
+
checks,
|
|
1129
|
+
evidence: evidence.map(({ id, kind, source, state, contentHash }) => ({ id, kind, source, state, contentHash })),
|
|
1130
|
+
configurationHash: hashObject(config)
|
|
1131
|
+
};
|
|
1132
|
+
store.saveVerification({ id: verificationId, snapshotId, gateState: gate, result, refresh: options.refresh });
|
|
1133
|
+
store.saveChecks(verificationId, checks);
|
|
1134
|
+
for (const check of checks.filter((entry) => entry.findingId)) {
|
|
1135
|
+
store.saveFinding({ id: check.findingId, snapshotId, fingerprint: check.fingerprint, evaluator: check.evaluator, state: check.state, enforcement: check.enforcement, owner: check.owner, payload: check });
|
|
1136
|
+
}
|
|
1137
|
+
return result;
|
|
1138
|
+
} finally {
|
|
1139
|
+
store.close();
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
function policyToCheck(result) {
|
|
1143
|
+
return { id: result.id, evaluator: result.evaluator, evaluatorVersion: result.evaluatorVersion, title: result.title, state: result.state, enforcement: result.enforcement, reason: result.reason, evidence: result.evidence.map((entry) => ({ kind: entry.kind, value: entry.value })), refs: [result.decisionRef], ...result.owner ? { owner: result.owner } : {}, fingerprint: result.fingerprint };
|
|
1144
|
+
}
|
|
1145
|
+
function evaluateEvals(evals, scope, evidence, policies) {
|
|
1146
|
+
return evals.map((evaluation) => {
|
|
1147
|
+
if (evaluation.state !== "active") return makeCheck({ id: `eval:${evaluation.id}@${evaluation.version}`, evaluator: "eval-evidence", title: evaluation.title, state: "SKIPPED", enforcement: evaluation.enforcement, reason: `Eval is ${evaluation.state}; only active evals enforce.`, refs: [evaluation.id], owner: evaluation.owner });
|
|
1148
|
+
const match = matchScope(evaluation.scope, scope);
|
|
1149
|
+
if (!match.matched) return makeCheck({ id: `eval:${evaluation.id}@${evaluation.version}`, evaluator: "eval-evidence", title: evaluation.title, state: "SKIPPED", enforcement: evaluation.enforcement, reason: "Eval scope does not intersect this change.", refs: [evaluation.id], owner: evaluation.owner ?? match.owner });
|
|
1150
|
+
const withPolicies = [...evidence, ...policies.map((policy) => ({ id: policy.id, kind: "decision_predicate", collector: "policy-engine", collectorVersion: "1", source: policy.decisionRef, state: policy.state === "PASS" ? "available" : policy.state === "FAIL" ? "failed" : "missing", contentHash: policy.fingerprint, payload: { name: policy.decisionRef, result: policy.state }, collectedAt: nowIso(), redaction: { applied: false, policyVersion: "1" } }))];
|
|
1151
|
+
const all = evaluation.evidence.all.map((requirement) => evaluateRequirement(requirement, withPolicies));
|
|
1152
|
+
const any = evaluation.evidence.any.map((requirement) => evaluateRequirement(requirement, withPolicies));
|
|
1153
|
+
let state = "PASS";
|
|
1154
|
+
if (all.some((entry) => entry.state === "ERROR") || any.some((entry) => entry.state === "ERROR")) state = "ERROR";
|
|
1155
|
+
else if (all.some((entry) => entry.state === "FAIL") || any.length > 0 && any.every((entry) => entry.state === "FAIL")) state = "FAIL";
|
|
1156
|
+
else if (all.some((entry) => entry.state === "INCONCLUSIVE") || any.length > 0 && !any.some((entry) => entry.state === "PASS")) state = "INCONCLUSIVE";
|
|
1157
|
+
else if (!all.length && !any.length) state = "INCONCLUSIVE";
|
|
1158
|
+
const used = [...all, ...any].flatMap((entry) => entry.evidence);
|
|
1159
|
+
return makeCheck({ id: `eval:${evaluation.id}@${evaluation.version}`, evaluator: "eval-evidence", title: evaluation.title, state, enforcement: evaluation.enforcement, reason: state === "PASS" ? "All declared eval evidence passed." : state === "FAIL" ? "Declared eval evidence failed." : state === "ERROR" ? "An evidence collector failed." : "Declared eval evidence is unavailable or incomplete.", evidence: used.map((entry) => ({ id: entry.id, kind: entry.kind, value: entry.source })), refs: [evaluation.id, ...evaluation.decision_refs, ...evaluation.signal_refs], owner: evaluation.owner ?? match.owner });
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
function evaluatePlan(plan, required, evidence) {
|
|
1163
|
+
if (!plan) return [makeCheck({ evaluator: "plan-coverage", title: "Approved plan", state: required ? "INCONCLUSIVE" : "SKIPPED", enforcement: required ? "block" : "observe", reason: required ? "PLAN.md is required but unavailable." : "No PLAN.md is present; policy-only mode is active.", refs: [] })];
|
|
1164
|
+
const checks = [];
|
|
1165
|
+
const unresolved = plan.split("\n").filter((line) => /(?:⚠\s*)?UNRESOLVED/i.test(line));
|
|
1166
|
+
checks.push(makeCheck({ evaluator: "plan-unresolved", title: "Unresolved constraints", state: unresolved.length ? "FAIL" : "PASS", enforcement: "block", reason: unresolved.length ? "PLAN.md contains unresolved approval gates." : "No unresolved approval gate remains.", evidence: unresolved.map((value) => ({ kind: "plan_line", value: value.trim() })), refs: ["PLAN.md"] }));
|
|
1167
|
+
for (const criterion of parseSectionItems(plan, "acceptance criteria")) {
|
|
1168
|
+
const requirements = parseInlineEvidence(criterion);
|
|
1169
|
+
checks.push(planEvidenceCheck("plan-criterion", criterion, requirements, evidence));
|
|
1170
|
+
}
|
|
1171
|
+
for (const nonGoal of parseSectionItems(plan, "non-goals")) {
|
|
1172
|
+
const requirements = parseInlineEvidence(nonGoal);
|
|
1173
|
+
checks.push(planEvidenceCheck("plan-non-goal", nonGoal, requirements, evidence));
|
|
1174
|
+
}
|
|
1175
|
+
return checks;
|
|
1176
|
+
}
|
|
1177
|
+
function planEvidenceCheck(evaluator, text, requirements, evidence) {
|
|
1178
|
+
const title = text.replace(/\s*\[evidence:[^\]]+\]/g, "");
|
|
1179
|
+
if (!requirements.length) return makeCheck({ evaluator, title, state: "INCONCLUSIVE", enforcement: "warn", reason: evaluator === "plan-criterion" ? "Acceptance criterion has no declared evidence mapping." : "Non-goal has no deterministic evidence mapping.", refs: ["PLAN.md"] });
|
|
1180
|
+
const results = requirements.map((requirement) => evaluateRequirement(requirement, evidence));
|
|
1181
|
+
const state = results.some((entry) => entry.state === "ERROR") ? "ERROR" : results.some((entry) => entry.state === "FAIL") ? "FAIL" : results.some((entry) => entry.state === "INCONCLUSIVE") ? "INCONCLUSIVE" : "PASS";
|
|
1182
|
+
return makeCheck({ evaluator, title, state, enforcement: "warn", reason: state === "PASS" ? "All declared plan evidence passed." : state === "FAIL" ? "Declared plan evidence failed." : state === "ERROR" ? "A declared plan evidence collector failed." : "Declared plan evidence is unavailable or incomplete.", evidence: results.flatMap((entry) => entry.evidence).map((entry) => ({ id: entry.id, kind: entry.kind, value: entry.source })), refs: ["PLAN.md"] });
|
|
1183
|
+
}
|
|
1184
|
+
function parseSectionItems(markdown, title) {
|
|
1185
|
+
const lines = markdown.split("\n");
|
|
1186
|
+
const start = lines.findIndex((line) => new RegExp(`^#{1,6}\\s+${escapeRegex(title)}\\s*$`, "i").test(line.trim()));
|
|
1187
|
+
if (start < 0) return [];
|
|
1188
|
+
const items = [];
|
|
1189
|
+
for (const line of lines.slice(start + 1)) {
|
|
1190
|
+
if (/^#{1,6}\s+/.test(line.trim())) break;
|
|
1191
|
+
const match = line.match(/^\s*(?:[-*]|\d+[.)])\s+(?:\[[ xX]\]\s*)?(.+)/);
|
|
1192
|
+
if (match?.[1]) items.push(match[1].trim());
|
|
1193
|
+
}
|
|
1194
|
+
return items;
|
|
1195
|
+
}
|
|
1196
|
+
function parseInlineEvidence(text) {
|
|
1197
|
+
return [...text.matchAll(/\[evidence:([a-z_]+)(?::([^:\]]+))?(?::([^\]]+))?\]/gi)].flatMap((match) => {
|
|
1198
|
+
const kind = match[1];
|
|
1199
|
+
const first = match[2];
|
|
1200
|
+
const second = match[3];
|
|
1201
|
+
const candidate = kind === "junit_test" ? { kind, suite: first, test: second } : kind === "repository_fact" ? { kind, path: first } : { kind, name: first };
|
|
1202
|
+
const parsed = evidenceRequirementSchema.safeParse(candidate);
|
|
1203
|
+
return parsed.success ? [parsed.data] : [];
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
function testCoverageCheck(snapshot, impactedTests) {
|
|
1207
|
+
const source = snapshot.files.filter((file) => /\.(?:[cm]?[jt]sx?|py|go|rs|java|rb|php|swift|kt)$/i.test(file.path) && !/(?:^|\/)(?:scripts?|migrations?|drizzle)(?:\/|$)/i.test(file.path));
|
|
1208
|
+
const tests = snapshot.files.filter((file) => /(?:^|\/)(?:__tests__|tests?|spec)(?:\/|$)|\.(?:test|spec)\.[^.]+$/i.test(file.path));
|
|
1209
|
+
const state = source.length && !tests.length ? impactedTests.length ? "INCONCLUSIVE" : "FAIL" : "PASS";
|
|
1210
|
+
const evidence = tests.length ? tests.map((file) => ({ kind: "path", value: file.path })) : impactedTests.length ? impactedTests.map((file) => ({ kind: "related_test", value: file })) : source.map((file) => ({ kind: "path", value: file.path }));
|
|
1211
|
+
return makeCheck({ evaluator: "test-coverage", title: "Changed behavior has test evidence", state, enforcement: "warn", reason: state === "FAIL" ? "Product code changed without a changed or statically related test file." : state === "INCONCLUSIVE" ? "Related tests exist, but no result artifact proves they ran." : "A changed test is present or no product code changed.", evidence, refs: [] });
|
|
1212
|
+
}
|
|
1213
|
+
function githubCheck(evidence) {
|
|
1214
|
+
return makeCheck({ evaluator: "github-check", title: `CI check: ${String(evidence.payload.name)}`, state: evidence.state === "available" ? "PASS" : evidence.state === "failed" ? "FAIL" : evidence.state === "error" ? "ERROR" : "INCONCLUSIVE", enforcement: "block", reason: evidence.state === "available" ? "GitHub job succeeded." : evidence.state === "failed" ? `GitHub job concluded ${String(evidence.payload.conclusion)}.` : "GitHub job conclusion is unavailable.", evidence: [{ id: evidence.id, kind: evidence.kind, value: evidence.source }], refs: [] });
|
|
1215
|
+
}
|
|
1216
|
+
async function evaluateSemanticCriteria(plan, snapshot, config) {
|
|
1217
|
+
const criteria = parseSectionItems(plan, "acceptance criteria").filter((line) => !parseInlineEvidence(line).length);
|
|
1218
|
+
const results = [];
|
|
1219
|
+
for (const criterion of criteria.slice(0, 20)) {
|
|
1220
|
+
const judged = await withSpan("judge.evaluate", { criterion_length: criterion.length }, () => semanticJudge({ criterion, evidenceBundle: { changedFiles: snapshot.files, diff: snapshot.diff.slice(0, config.semantic.max_bytes) }, endpoint: config.semantic.endpoint, model: config.semantic.model, timeoutMs: config.semantic.timeout_ms, maxBytes: config.semantic.max_bytes }));
|
|
1221
|
+
const state = !judged.available || !judged.result || judged.result.result === "uncertain" ? "INCONCLUSIVE" : judged.result.result === "supports" ? "PASS" : "FAIL";
|
|
1222
|
+
results.push(makeCheck({ evaluator: "semantic-judge", title: criterion, state, enforcement: "warn", reason: judged.result?.reason ?? `Semantic evaluation unavailable: ${judged.error ?? "unknown error"}`, evidence: judged.result?.evidenceRefs.map((value) => ({ kind: "semantic_ref", value })) ?? [], refs: ["PLAN.md"] }));
|
|
1223
|
+
}
|
|
1224
|
+
return results;
|
|
1225
|
+
}
|
|
1226
|
+
function makeCheck(input) {
|
|
1227
|
+
const identity = { evaluator: input.evaluator, version: "1", title: input.title, state: input.state, evidence: input.evidence ?? [], refs: input.refs };
|
|
1228
|
+
return { id: input.id ?? createId("check"), evaluator: input.evaluator, evaluatorVersion: "1", title: input.title, state: input.state, enforcement: input.enforcement, reason: input.reason, evidence: input.evidence ?? [], refs: input.refs, ...input.owner ? { owner: input.owner } : {}, fingerprint: hashObject(identity) };
|
|
1229
|
+
}
|
|
1230
|
+
function escapeRegex(value) {
|
|
1231
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1232
|
+
}
|
|
1233
|
+
function learnFromFinding(root, findingId) {
|
|
1234
|
+
const store = new BrainStore(root);
|
|
1235
|
+
try {
|
|
1236
|
+
const finding = store.getFinding(findingId);
|
|
1237
|
+
if (!finding) throw new Error(`finding not found: ${findingId}`);
|
|
1238
|
+
const payload = finding.payload;
|
|
1239
|
+
const id = `eval_${payload.fingerprint.slice(0, 16)}`;
|
|
1240
|
+
const evalCase = evalCaseSchema.parse({
|
|
1241
|
+
schema_version: "1",
|
|
1242
|
+
id,
|
|
1243
|
+
version: 1,
|
|
1244
|
+
title: payload.title,
|
|
1245
|
+
state: "draft",
|
|
1246
|
+
owner: payload.owner,
|
|
1247
|
+
origin: { kind: "confirmed_failure", finding_ref: findingId },
|
|
1248
|
+
decision_refs: payload.refs.filter((ref) => ref.startsWith("decision_")),
|
|
1249
|
+
signal_refs: payload.refs.filter((ref) => ref.startsWith("signal_")),
|
|
1250
|
+
scope: { paths: { include: payload.evidence.filter((entry) => entry.kind === "path").map((entry) => entry.value).slice(0, 20).length ? payload.evidence.filter((entry) => entry.kind === "path").map((entry) => entry.value).slice(0, 20) : ["**"], exclude: [] } },
|
|
1251
|
+
assertion: { kind: "required_evidence", description: payload.reason },
|
|
1252
|
+
evidence: { all: [], any: [] },
|
|
1253
|
+
enforcement: "observe",
|
|
1254
|
+
fingerprint: payload.fingerprint
|
|
1255
|
+
});
|
|
1256
|
+
const filename = path8.join(root, ".scriptonia", "evals", `${id}.yaml`);
|
|
1257
|
+
if (fs7.existsSync(filename)) throw new Error(`draft eval already exists: ${path8.relative(root, filename)}`);
|
|
1258
|
+
fs7.mkdirSync(path8.dirname(filename), { recursive: true });
|
|
1259
|
+
fs7.writeFileSync(filename, YAML2.stringify(evalCase));
|
|
1260
|
+
return { path: filename, evalCase };
|
|
1261
|
+
} finally {
|
|
1262
|
+
store.close();
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
function acknowledgeFinding(root, findingId, reason, actor, comment) {
|
|
1266
|
+
const store = new BrainStore(root);
|
|
1267
|
+
try {
|
|
1268
|
+
const finding = store.getFinding(findingId);
|
|
1269
|
+
if (!finding) throw new Error(`finding not found: ${findingId}`);
|
|
1270
|
+
const id = createId("ack");
|
|
1271
|
+
store.acknowledge({ id, findingId, reason, comment, actor, snapshotId: String(finding.snapshot_id) });
|
|
1272
|
+
return id;
|
|
1273
|
+
} finally {
|
|
1274
|
+
store.close();
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
function evalDoctor(root) {
|
|
1278
|
+
const { evals, errors } = loadEvals(root);
|
|
1279
|
+
const { decisions } = loadDecisions(root);
|
|
1280
|
+
const issues = errors.map((message) => ({ kind: "invalid", id: "definition", message }));
|
|
1281
|
+
const activeDecisions = new Map(decisions.filter((entry) => entry.state === "active").map((entry) => [entry.id, entry]));
|
|
1282
|
+
const fingerprints = /* @__PURE__ */ new Map();
|
|
1283
|
+
for (const evaluation of evals) {
|
|
1284
|
+
if (!evaluation.owner) issues.push({ kind: "missing_owner", id: evaluation.id, message: "Eval has no owner." });
|
|
1285
|
+
if (evaluation.review_after && Date.parse(evaluation.review_after) < Date.now() && evaluation.state === "active") issues.push({ kind: "expired_review", id: evaluation.id, message: `Review date passed: ${evaluation.review_after}` });
|
|
1286
|
+
for (const ref of evaluation.decision_refs) if (!activeDecisions.has(ref) && evaluation.state === "active") issues.push({ kind: "stale_decision", id: evaluation.id, message: `Referenced decision is not active: ${ref}` });
|
|
1287
|
+
const fingerprint = evaluation.fingerprint ?? hashObject({ assertion: evaluation.assertion, scope: evaluation.scope, origin: evaluation.origin, evidence: evaluation.evidence });
|
|
1288
|
+
const duplicate = fingerprints.get(fingerprint);
|
|
1289
|
+
if (duplicate) issues.push({ kind: "duplicate", id: evaluation.id, message: `Duplicates ${duplicate}.` });
|
|
1290
|
+
else fingerprints.set(fingerprint, evaluation.id);
|
|
1291
|
+
}
|
|
1292
|
+
return { healthy: issues.length === 0, issues };
|
|
1293
|
+
}
|
|
1294
|
+
function toSarif(result) {
|
|
1295
|
+
const actionable = result.checks.filter((check) => check.state === "FAIL" || check.state === "ERROR" || check.state === "INCONCLUSIVE");
|
|
1296
|
+
return { version: "2.1.0", $schema: "https://json.schemastore.org/sarif-2.1.0.json", runs: [{ tool: { driver: { name: "Scriptonia", version: "0.9.0-rc.1", informationUri: "https://scriptonia.dev", rules: [...new Map(actionable.map((check) => [check.evaluator, { id: check.evaluator, name: check.evaluator, shortDescription: { text: check.title } }])).values()] } }, results: actionable.map((check) => ({ ruleId: check.evaluator, level: check.enforcement === "block" ? "error" : "warning", message: { text: `${check.title}: ${check.reason}` }, properties: { state: check.state, enforcement: check.enforcement, fingerprint: check.fingerprint, refs: check.refs } })) }] };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// ../packages/github/src/index.ts
|
|
1300
|
+
function githubStepSummary(result) {
|
|
1301
|
+
const lines = [`# Scriptonia gate: ${result.gate}`, "", `Snapshot: \`${result.snapshotId}\``, "", "| State | Check | Reason |", "|---|---|---|"];
|
|
1302
|
+
for (const check of result.checks) lines.push(`| ${check.state} | ${escapeCell(check.title)} | ${escapeCell(check.reason)} |`);
|
|
1303
|
+
return `${lines.join("\n")}
|
|
1304
|
+
`;
|
|
1305
|
+
}
|
|
1306
|
+
function escapeCell(value) {
|
|
1307
|
+
return value.replaceAll("|", "\\|").replaceAll("\n", " ");
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// ../packages/run-capture/src/index.ts
|
|
1311
|
+
import { spawn } from "child_process";
|
|
1312
|
+
import fs8 from "fs";
|
|
1313
|
+
import path9 from "path";
|
|
1314
|
+
|
|
1315
|
+
// ../packages/run-protocol/src/index.ts
|
|
1316
|
+
var BUILTIN_PATTERNS = [
|
|
1317
|
+
/\b(?:sk|sk-ant|ghp|github_pat|xox[baprs])[-_][A-Za-z0-9_-]{12,}\b/g,
|
|
1318
|
+
/\bBearer\s+[A-Za-z0-9._~+\/-]+=*\b/gi,
|
|
1319
|
+
/\b[A-Za-z0-9_-]*(?:TOKEN|SECRET|PASSWORD|API_KEY)\s*[=:]\s*[^\s,;]+/gi
|
|
1320
|
+
];
|
|
1321
|
+
function redact(value, options = {}) {
|
|
1322
|
+
const secrets = (options.envNames ?? []).map((name) => process.env[name]).filter((entry) => Boolean(entry && entry.length >= 4));
|
|
1323
|
+
const custom = (options.patterns ?? []).map((pattern) => new RegExp(pattern, "gi"));
|
|
1324
|
+
let text = JSON.stringify(value);
|
|
1325
|
+
const original = text;
|
|
1326
|
+
for (const secret of secrets) text = text.replaceAll(secret, "[REDACTED]");
|
|
1327
|
+
for (const pattern of [...BUILTIN_PATTERNS, ...custom]) text = text.replace(pattern, "[REDACTED]");
|
|
1328
|
+
const maxBytes = options.maxBytes ?? 128e3;
|
|
1329
|
+
if (Buffer.byteLength(text) > maxBytes) text = JSON.stringify({ truncated: true, bytes: Buffer.byteLength(text), preview: text.slice(0, maxBytes - 128) });
|
|
1330
|
+
try {
|
|
1331
|
+
return { value: JSON.parse(text), applied: text !== original };
|
|
1332
|
+
} catch {
|
|
1333
|
+
return { value: "[REDACTED:invalid-payload]", applied: true };
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
function createEvent(input) {
|
|
1337
|
+
const sanitized = redact(input.payload, input.redaction);
|
|
1338
|
+
return runEventSchema.parse({
|
|
1339
|
+
schemaVersion: "1",
|
|
1340
|
+
eventId: createId("event"),
|
|
1341
|
+
runId: input.runId,
|
|
1342
|
+
sequence: input.sequence,
|
|
1343
|
+
occurredAt: nowIso(),
|
|
1344
|
+
source: { adapter: input.adapter ?? "explicit-wrapper", adapterVersion: "1", ...input.agent ? { agent: input.agent } : {} },
|
|
1345
|
+
type: input.type,
|
|
1346
|
+
payload: sanitized.value,
|
|
1347
|
+
redaction: { applied: sanitized.applied, policyVersion: "1" }
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
function parseJsonl(text) {
|
|
1351
|
+
if (Buffer.byteLength(text) > 1e7) throw new Error("JSONL payload exceeds 10 MB limit");
|
|
1352
|
+
return text.split("\n").filter(Boolean).map((line, index) => {
|
|
1353
|
+
if (Buffer.byteLength(line) > 512e3) throw new Error(`JSONL line ${index + 1} exceeds 512 KB limit`);
|
|
1354
|
+
return runEventSchema.parse(JSON.parse(line));
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
function normalizeCodexJsonl(text, importedRunId = createId("run")) {
|
|
1358
|
+
if (Buffer.byteLength(text) > 1e7) throw new Error("Codex JSONL payload exceeds 10 MB limit");
|
|
1359
|
+
let sequence = 0;
|
|
1360
|
+
const events = [];
|
|
1361
|
+
for (const [index, line] of text.split("\n").filter(Boolean).entries()) {
|
|
1362
|
+
if (Buffer.byteLength(line) > 512e3) throw new Error(`Codex JSONL line ${index + 1} exceeds 512 KB limit`);
|
|
1363
|
+
const raw = JSON.parse(line);
|
|
1364
|
+
const type = String(raw.type ?? raw.event ?? "");
|
|
1365
|
+
const payload = raw.payload ?? raw.item ?? raw;
|
|
1366
|
+
let normalized = "tool.completed";
|
|
1367
|
+
if (/session_meta|thread\.started|turn\.started|run\.started/.test(type)) normalized = "run.started";
|
|
1368
|
+
else if (/turn\.completed|thread\.completed|run\.finished/.test(type)) normalized = "run.finished";
|
|
1369
|
+
else if (/error|failed/.test(type)) normalized = "error";
|
|
1370
|
+
else if (/file_change|file\.changed/.test(type) || payload.type === "file_change") normalized = "file.changed";
|
|
1371
|
+
else if (/command.*requested|command_execution.*started/.test(type)) normalized = "command.requested";
|
|
1372
|
+
else if (/tool.*requested|item\.started/.test(type)) normalized = "tool.requested";
|
|
1373
|
+
events.push(createEvent({ runId: String(raw.runId ?? raw.thread_id ?? importedRunId), sequence: sequence++, type: normalized, payload: raw, adapter: "codex-jsonl", agent: "codex" }));
|
|
1374
|
+
}
|
|
1375
|
+
return events;
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ../packages/run-capture/src/index.ts
|
|
1379
|
+
async function captureCommand(input) {
|
|
1380
|
+
if (!input.command.length || !input.command[0]) throw new Error("usage: scriptonia run -- <command> [args...]");
|
|
1381
|
+
const store = new BrainStore(input.root);
|
|
1382
|
+
const runId = createId("run");
|
|
1383
|
+
const before = buildSnapshot({ cwd: input.root, base: "HEAD" });
|
|
1384
|
+
const beforeHashes = snapshotFileHashes(input.root, before.files);
|
|
1385
|
+
let sequence = 0;
|
|
1386
|
+
const persist = (type, payload) => {
|
|
1387
|
+
const event = createEvent({ runId, sequence: sequence++, type, payload, adapter: input.adapter, agent: input.agent, redaction: input.redaction });
|
|
1388
|
+
store.appendEvent({ eventId: event.eventId, runId, sequence: event.sequence, occurredAt: event.occurredAt, type: event.type, payload: event.payload, redacted: event.redaction.applied });
|
|
1389
|
+
};
|
|
1390
|
+
store.createRun({ id: runId, adapter: input.adapter ?? "explicit-wrapper", command: input.command, startedAt: nowIso(), baseSha: before.headSha });
|
|
1391
|
+
persist("run.started", { baseSha: before.headSha, dirty: before.dirty });
|
|
1392
|
+
persist("command.requested", { argv: input.command, cwd: ".", inheritedAuthority: true });
|
|
1393
|
+
let exitCode = 1;
|
|
1394
|
+
try {
|
|
1395
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
1396
|
+
const child = spawn(input.command[0], input.command.slice(1), { cwd: input.root, stdio: "inherit", shell: false, env: process.env });
|
|
1397
|
+
child.once("error", reject);
|
|
1398
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
1399
|
+
});
|
|
1400
|
+
const after = buildSnapshot({ cwd: input.root, base: before.headSha });
|
|
1401
|
+
const afterHashes = snapshotFileHashes(input.root, after.files);
|
|
1402
|
+
const changedByCommand = after.files.filter((file) => beforeHashes.get(file.path) !== afterHashes.get(file.path));
|
|
1403
|
+
for (const file of changedByCommand) persist("file.changed", { path: file.path, status: file.status, binary: file.binary });
|
|
1404
|
+
persist("run.finished", { exitCode, headSha: after.headSha, diffHash: after.diffHash, changedFiles: changedByCommand.length });
|
|
1405
|
+
store.finishRun({ id: runId, finishedAt: nowIso(), headSha: after.headSha, diffHash: after.diffHash, exitCode, status: exitCode === 0 ? "completed" : "failed" });
|
|
1406
|
+
return { runId, exitCode };
|
|
1407
|
+
} catch (error) {
|
|
1408
|
+
persist("error", { message: error instanceof Error ? error.message : String(error) });
|
|
1409
|
+
store.finishRun({ id: runId, finishedAt: nowIso(), exitCode, status: "error" });
|
|
1410
|
+
throw error;
|
|
1411
|
+
} finally {
|
|
1412
|
+
store.close();
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
function snapshotFileHashes(root, files) {
|
|
1416
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
1417
|
+
for (const file of files) {
|
|
1418
|
+
if (file.status === "deleted") {
|
|
1419
|
+
hashes.set(file.path, "deleted");
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
try {
|
|
1423
|
+
hashes.set(file.path, sha256(fs8.readFileSync(path9.join(root, file.path))));
|
|
1424
|
+
} catch {
|
|
1425
|
+
hashes.set(file.path, "unreadable");
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
return hashes;
|
|
1429
|
+
}
|
|
1430
|
+
function ingestJsonl(root, text, adapter = "normalized") {
|
|
1431
|
+
const events = adapter === "codex" ? normalizeCodexJsonl(text) : parseJsonl(text);
|
|
1432
|
+
if (!events.length) return 0;
|
|
1433
|
+
const store = new BrainStore(root);
|
|
1434
|
+
try {
|
|
1435
|
+
const runIds = new Set(events.map((event) => event.runId));
|
|
1436
|
+
for (const runId of runIds) if (!store.getRun(runId)) store.createRun({ id: runId, adapter: events.find((event) => event.runId === runId)?.source.adapter ?? adapter, command: ["imported-events"], startedAt: events.find((event) => event.runId === runId)?.occurredAt ?? nowIso() });
|
|
1437
|
+
for (const event of events) store.appendEvent({ eventId: event.eventId, runId: event.runId, sequence: event.sequence, occurredAt: event.occurredAt, type: event.type, payload: event.payload, redacted: event.redaction.applied });
|
|
1438
|
+
for (const runId of runIds) store.finishRun({ id: runId, finishedAt: nowIso(), exitCode: 0, status: "imported" });
|
|
1439
|
+
return events.length;
|
|
1440
|
+
} finally {
|
|
1441
|
+
store.close();
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// src/index.ts
|
|
1446
|
+
var V2_COMMANDS = /* @__PURE__ */ new Set(["run", "runs", "verify", "gate", "learn", "eval", "acknowledge", "events", "v2-init", "report-summary", "diff-check", "why", "recall", "decide", "pulse"]);
|
|
1447
|
+
var requested = process.argv[2];
|
|
1448
|
+
if (!requested || !V2_COMMANDS.has(requested)) {
|
|
1449
|
+
await import(new URL("../bin/scriptonia.mjs", import.meta.url).href);
|
|
1450
|
+
} else {
|
|
1451
|
+
await runV2();
|
|
1452
|
+
}
|
|
1453
|
+
async function runV2() {
|
|
1454
|
+
await initializeTelemetry();
|
|
1455
|
+
const program = new Command();
|
|
1456
|
+
program.name("scriptonia").description("Evidence-based product memory and verification for AI coding agents").version("0.9.0-rc.1");
|
|
1457
|
+
program.command("v2-init").description("Create inspectable V2 configuration directories").action(() => {
|
|
1458
|
+
const root = findRepositoryRoot();
|
|
1459
|
+
const filename = ensureConfig(root);
|
|
1460
|
+
console.log(`${pc.green("\u2713")} V2 brain configured at ${path10.relative(root, filename)}`);
|
|
1461
|
+
});
|
|
1462
|
+
program.command("diff-check").description("Run only sub-second deterministic changed-file policies").option("--base <ref>", "base Git reference", "HEAD").action((options) => {
|
|
1463
|
+
const started2 = performance.now();
|
|
1464
|
+
const root = findRepositoryRoot();
|
|
1465
|
+
const snapshot = buildSnapshot({ cwd: root, base: options.base });
|
|
1466
|
+
const definitions = loadDecisions(root);
|
|
1467
|
+
const context = buildScopeContext(root, snapshot.files.map((file) => file.path));
|
|
1468
|
+
const results = evaluateDecisions(definitions.decisions, context).filter((result) => result.state !== "SKIPPED");
|
|
1469
|
+
const plan = path10.join(root, "PLAN.md");
|
|
1470
|
+
const unresolved = fs9.existsSync(plan) ? fs9.readFileSync(plan, "utf8").split("\n").filter((line) => /(?:⚠\s*)?UNRESOLVED/i.test(line)) : [];
|
|
1471
|
+
console.log(`
|
|
1472
|
+
${pc.bold("SCRIPTONIA DIFF CHECK")} \xB7 ${snapshot.files.length} changed files
|
|
1473
|
+
`);
|
|
1474
|
+
for (const error of definitions.errors) console.log(` ${pc.red("ERROR")} ${error}`);
|
|
1475
|
+
for (const result of results) console.log(` ${result.state === "PASS" ? pc.green(result.state) : pc.red(result.state)} ${result.title} \xB7 ${result.reason}`);
|
|
1476
|
+
if (unresolved.length) console.log(` ${pc.red("FAIL")} PLAN.md contains ${unresolved.length} unresolved constraint(s).`);
|
|
1477
|
+
const blocked = unresolved.length > 0 || results.some((result) => result.state === "FAIL" && result.enforcement === "block");
|
|
1478
|
+
const errored = definitions.errors.length > 0 || results.some((result) => result.state === "ERROR");
|
|
1479
|
+
console.log(`
|
|
1480
|
+
${errored ? "ERROR" : blocked ? "BLOCKED" : "PASS"} \xB7 ${Math.round(performance.now() - started2)}ms
|
|
1481
|
+
`);
|
|
1482
|
+
process.exitCode = errored ? 4 : blocked ? 3 : 0;
|
|
1483
|
+
});
|
|
1484
|
+
program.command("decide <title>").description("Create a reviewable structured product or architecture decision").requiredOption("--kind <predicate>", "deterministic predicate kind").option("--names <values>", "comma-separated dependency, symbol, or path values", "").option("--include <globs>", "comma-separated scope globs", "**").option("--exclude <globs>", "comma-separated excluded globs", "").option("--owner <owner>", "responsible owner").option("--enforcement <level>", "observe, warn, action_required, or block", "observe").option("--confirm <actor>", "activate and record the confirming human").action((title, options) => {
|
|
1485
|
+
const root = findRepositoryRoot();
|
|
1486
|
+
ensureConfig(root);
|
|
1487
|
+
const id = `decision_${slug(title)}_${Date.now().toString(36)}`;
|
|
1488
|
+
const decision = decisionSchema.parse({ schema_version: "1", id, version: 1, title, state: options.confirm ? "active" : "draft", owner: options.owner, source_refs: [], scope: { paths: { include: csv(options.include), exclude: csv(options.exclude) } }, predicate: { kind: options.kind, names: csv(options.names) }, enforcement: options.enforcement, created_at: (/* @__PURE__ */ new Date()).toISOString(), confirmed_by: options.confirm });
|
|
1489
|
+
const filename = path10.join(root, ".scriptonia", "decisions", `${id}.yaml`);
|
|
1490
|
+
fs9.writeFileSync(filename, YAML3.stringify(decision));
|
|
1491
|
+
console.log(`${pc.green("\u2713")} ${decision.state} decision written to ${path10.relative(root, filename)}`);
|
|
1492
|
+
if (decision.state === "draft") console.log(" Review its scope and predicate, then confirm it before enforcement.");
|
|
1493
|
+
});
|
|
1494
|
+
program.command("why <file>").description("Explain the active product memory affecting a file").action((file) => {
|
|
1495
|
+
const root = findRepositoryRoot();
|
|
1496
|
+
const relative = path10.relative(root, assertInside(root, path10.resolve(root, file))).replaceAll("\\", "/");
|
|
1497
|
+
const context = buildScopeContext(root, [relative]);
|
|
1498
|
+
const decisions = loadDecisions(root).decisions.filter((entry) => entry.state === "active" && matchScope(entry.scope, context).matched);
|
|
1499
|
+
const evals = loadEvals(root).evals.filter((entry) => entry.state === "active" && matchScope(entry.scope, context).matched);
|
|
1500
|
+
console.log(`
|
|
1501
|
+
${pc.bold("WHY")} ${relative}
|
|
1502
|
+
`);
|
|
1503
|
+
for (const decision of decisions) console.log(` decision ${decision.id}@${decision.version} \xB7 ${decision.title} \xB7 ${decision.enforcement}`);
|
|
1504
|
+
for (const evaluation2 of evals) console.log(` eval ${evaluation2.id}@${evaluation2.version} \xB7 ${evaluation2.title} \xB7 ${evaluation2.enforcement}`);
|
|
1505
|
+
if (!decisions.length && !evals.length) console.log(" No active scoped decisions or evals apply.");
|
|
1506
|
+
console.log("");
|
|
1507
|
+
});
|
|
1508
|
+
program.command("recall <query>").description("Search inspectable local decisions and evals").option("--limit <number>", "maximum matches", "10").action((query, options) => {
|
|
1509
|
+
const root = findRepositoryRoot();
|
|
1510
|
+
const entries = [
|
|
1511
|
+
...loadDecisions(root).decisions.map((value) => ({ kind: "decision", id: value.id, title: value.title, body: JSON.stringify(value) })),
|
|
1512
|
+
...loadEvals(root).evals.map((value) => ({ kind: "eval", id: value.id, title: value.title, body: JSON.stringify(value) }))
|
|
1513
|
+
];
|
|
1514
|
+
const tokens = query.toLowerCase().split(/\W+/).filter(Boolean);
|
|
1515
|
+
const matches = entries.map((entry) => ({ ...entry, score: tokens.reduce((score, token) => score + (entry.body.toLowerCase().split(token).length - 1), 0) })).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score).slice(0, Math.max(1, Number(options.limit) || 10));
|
|
1516
|
+
console.log(`
|
|
1517
|
+
${pc.bold("RECALL")} \u201C${query}\u201D
|
|
1518
|
+
`);
|
|
1519
|
+
for (const entry of matches) console.log(` ${entry.kind.padEnd(9)} ${entry.id} \xB7 ${entry.title}`);
|
|
1520
|
+
if (!matches.length) console.log(" No matching local memory.");
|
|
1521
|
+
console.log("");
|
|
1522
|
+
});
|
|
1523
|
+
program.command("pulse").description("Show local brain and repository health").action(() => {
|
|
1524
|
+
const root = findRepositoryRoot();
|
|
1525
|
+
ensureConfig(root);
|
|
1526
|
+
const snapshot = buildSnapshot({ cwd: root, base: "HEAD" });
|
|
1527
|
+
const decisions = loadDecisions(root);
|
|
1528
|
+
const evals = loadEvals(root);
|
|
1529
|
+
const health = evalDoctor(root);
|
|
1530
|
+
const store = new BrainStore(root);
|
|
1531
|
+
try {
|
|
1532
|
+
const runs = store.listRuns(5);
|
|
1533
|
+
console.log(`
|
|
1534
|
+
${pc.bold("SCRIPTONIA PULSE")}
|
|
1535
|
+
`);
|
|
1536
|
+
console.log(` repository ${snapshot.dirty ? pc.yellow("dirty") : pc.green("clean")} \xB7 ${snapshot.files.length} changed files`);
|
|
1537
|
+
console.log(` decisions ${decisions.decisions.filter((entry) => entry.state === "active").length} active \xB7 ${decisions.errors.length} invalid`);
|
|
1538
|
+
console.log(` evals ${evals.evals.filter((entry) => entry.state === "active").length} active \xB7 ${health.issues.length} health issues`);
|
|
1539
|
+
console.log(` runs ${runs.length} recent captured`);
|
|
1540
|
+
console.log(` semantic ${process.env.SCRIPTONIA_MODEL_GATEWAY_URL && process.env.SCRIPTONIA_API_TOKEN || process.env.LITELLM_BASE_URL && process.env.LITELLM_MASTER_KEY ? pc.green("configured") : pc.dim("disabled until credentials are added")}`);
|
|
1541
|
+
console.log("");
|
|
1542
|
+
} finally {
|
|
1543
|
+
store.close();
|
|
1544
|
+
}
|
|
1545
|
+
});
|
|
1546
|
+
program.command("run [argv...]").description("Capture exactly the agent command supplied after --").option("--adapter <name>", "adapter identity", "explicit-wrapper").option("--agent <name>", "agent identity").allowUnknownOption(true).action(async (argv, options) => {
|
|
1547
|
+
const root = findRepositoryRoot();
|
|
1548
|
+
if (argv[0] === "show") {
|
|
1549
|
+
if (!argv[1]) throw new Error("usage: scriptonia run show <run-id>");
|
|
1550
|
+
const store = new BrainStore(root);
|
|
1551
|
+
try {
|
|
1552
|
+
const run = store.getRun(argv[1]);
|
|
1553
|
+
if (!run) throw new Error(`run not found: ${argv[1]}`);
|
|
1554
|
+
console.log(JSON.stringify(run, null, 2));
|
|
1555
|
+
} finally {
|
|
1556
|
+
store.close();
|
|
1557
|
+
}
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
const separator = process.argv.indexOf("--");
|
|
1561
|
+
const exact = separator >= 0 ? process.argv.slice(separator + 1) : argv;
|
|
1562
|
+
const result = await captureCommand({ root, command: exact, adapter: options.adapter, agent: options.agent });
|
|
1563
|
+
console.log(`
|
|
1564
|
+
${pc.green("\u2713")} captured ${result.runId} \xB7 child exit ${result.exitCode}`);
|
|
1565
|
+
process.exitCode = result.exitCode;
|
|
1566
|
+
});
|
|
1567
|
+
program.command("runs").description("List recent captured agent runs").option("--json", "print JSON").option("--limit <number>", "maximum rows", "20").action((options) => {
|
|
1568
|
+
const root = findRepositoryRoot();
|
|
1569
|
+
const store = new BrainStore(root);
|
|
1570
|
+
try {
|
|
1571
|
+
const runs = store.listRuns(Math.max(1, Math.min(200, Number(options.limit) || 20)));
|
|
1572
|
+
if (options.json) console.log(JSON.stringify(runs, null, 2));
|
|
1573
|
+
else {
|
|
1574
|
+
console.log(`
|
|
1575
|
+
${pc.bold("RECENT AGENT RUNS")}
|
|
1576
|
+
`);
|
|
1577
|
+
if (!runs.length) console.log(" No captured runs yet. Use: scriptonia run -- <agent-command>");
|
|
1578
|
+
for (const run of runs) console.log(` ${String(run.status).padEnd(10)} ${run.id} ${JSON.stringify(run.command)} exit=${run.exit_code ?? "-"}`);
|
|
1579
|
+
console.log("");
|
|
1580
|
+
}
|
|
1581
|
+
} finally {
|
|
1582
|
+
store.close();
|
|
1583
|
+
}
|
|
1584
|
+
});
|
|
1585
|
+
for (const name of ["verify", "gate"]) {
|
|
1586
|
+
program.command(name).description(name === "gate" ? "Aggregate evidence into a CI-safe exit status" : "Produce a complete local evidence report").option("--base <ref>", "base Git reference").option("--plan <file>", "plan file", "PLAN.md").option("--refresh", "create a new verification instead of using the immutable cache").option("--semantic", "enable advisory semantic evaluation").option("--ci", "consume CI evidence from SCRIPTONIA_NEEDS_JSON").option("--json [file]", "print JSON or write it to a file").option("--sarif <file>", "write SARIF output").action(async (options) => {
|
|
1587
|
+
const root = findRepositoryRoot();
|
|
1588
|
+
if (options.semantic) configureGatewayFromLegacyLogin();
|
|
1589
|
+
const result = await verifyRepository({ root, base: options.base, plan: options.plan, refresh: options.refresh, semantic: options.semantic, trustedBase: Boolean(options.ci) });
|
|
1590
|
+
emitVerification(root, result, options);
|
|
1591
|
+
if (name === "gate") process.exitCode = result.exitCode;
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
program.command("learn <finding-id>").description("Convert a confirmed finding into a reviewable draft eval").action((findingId) => {
|
|
1595
|
+
const root = findRepositoryRoot();
|
|
1596
|
+
const learned = learnFromFinding(root, findingId);
|
|
1597
|
+
console.log(`${pc.green("\u2713")} draft eval ${learned.evalCase.id} written to ${path10.relative(root, learned.path)}`);
|
|
1598
|
+
console.log(" Review its scope and evidence, add an owner, then change state to active.");
|
|
1599
|
+
});
|
|
1600
|
+
const evaluation = program.command("eval").description("Run and maintain versioned regression cases");
|
|
1601
|
+
evaluation.command("run [id-or-scope]").option("--base <ref>", "base Git reference").option("--refresh", "bypass immutable cache").option("--json [file]", "print JSON or write it to a file").action(async (filter, options) => {
|
|
1602
|
+
const root = findRepositoryRoot();
|
|
1603
|
+
const result = await verifyRepository({ root, base: options.base, refresh: options.refresh, evalFilter: filter });
|
|
1604
|
+
emitVerification(root, result, options);
|
|
1605
|
+
process.exitCode = result.exitCode;
|
|
1606
|
+
});
|
|
1607
|
+
evaluation.command("doctor").action(() => {
|
|
1608
|
+
const root = findRepositoryRoot();
|
|
1609
|
+
const report = evalDoctor(root);
|
|
1610
|
+
console.log(`
|
|
1611
|
+
${pc.bold("EVAL DOCTOR")} \xB7 ${report.healthy ? pc.green("HEALTHY") : pc.yellow("NEEDS ATTENTION")}
|
|
1612
|
+
`);
|
|
1613
|
+
for (const issue of report.issues) console.log(` ${issue.kind.padEnd(16)} ${issue.id} \xB7 ${issue.message}`);
|
|
1614
|
+
if (!report.issues.length) console.log(" No duplicate, stale, ownerless, invalid, or expired evals.");
|
|
1615
|
+
console.log("");
|
|
1616
|
+
process.exitCode = report.healthy ? 0 : 1;
|
|
1617
|
+
});
|
|
1618
|
+
program.command("acknowledge <finding-id>").requiredOption("--reason <reason>", "accepted_risk, false_positive, not_applicable, or fix_later").requiredOption("--actor <identity>", "human identity").option("--comment <text>", "reason details").action((findingId, options) => {
|
|
1619
|
+
const allowed = ["accepted_risk", "false_positive", "not_applicable", "fix_later"];
|
|
1620
|
+
if (!allowed.includes(options.reason)) throw new Error(`invalid reason: ${options.reason}`);
|
|
1621
|
+
const root = findRepositoryRoot();
|
|
1622
|
+
const id = acknowledgeFinding(root, findingId, options.reason, options.actor, options.comment);
|
|
1623
|
+
console.log(`${pc.green("\u2713")} acknowledgement recorded: ${id}`);
|
|
1624
|
+
});
|
|
1625
|
+
const events = program.command("events").description("Ingest normalized adapter events");
|
|
1626
|
+
events.command("ingest <file>").option("--adapter <name>", "normalized or codex", "normalized").action((filename, options) => {
|
|
1627
|
+
const root = findRepositoryRoot();
|
|
1628
|
+
const full = assertInside(root, path10.resolve(root, filename));
|
|
1629
|
+
if (options.adapter !== "normalized" && options.adapter !== "codex") throw new Error(`unsupported event adapter: ${options.adapter}`);
|
|
1630
|
+
const count = ingestJsonl(root, fs9.readFileSync(full, "utf8"), options.adapter);
|
|
1631
|
+
console.log(`${pc.green("\u2713")} ingested ${count} normalized run events`);
|
|
1632
|
+
});
|
|
1633
|
+
program.command("report-summary <file>").action((filename) => {
|
|
1634
|
+
const root = findRepositoryRoot();
|
|
1635
|
+
const full = assertInside(root, path10.resolve(root, filename));
|
|
1636
|
+
const result = JSON.parse(fs9.readFileSync(full, "utf8"));
|
|
1637
|
+
const markdown = githubStepSummary(result);
|
|
1638
|
+
if (process.env.GITHUB_STEP_SUMMARY) fs9.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
|
|
1639
|
+
else console.log(markdown);
|
|
1640
|
+
});
|
|
1641
|
+
await program.parseAsync(process.argv);
|
|
1642
|
+
}
|
|
1643
|
+
function emitVerification(root, result, options) {
|
|
1644
|
+
if (options.json === true) console.log(JSON.stringify(result, null, 2));
|
|
1645
|
+
else {
|
|
1646
|
+
const color = result.gate === "PASS" ? pc.green : result.gate === "PASS_WITH_WARNINGS" ? pc.yellow : pc.red;
|
|
1647
|
+
console.log(`
|
|
1648
|
+
${pc.bold("SCRIPTONIA VERIFY")} \xB7 ${color(result.gate)}${result.cached ? pc.dim(" \xB7 cached") : ""}`);
|
|
1649
|
+
console.log(pc.dim(`snapshot ${result.snapshotId}`));
|
|
1650
|
+
console.log(`coverage plan=${result.coverage.plan} decisions=${result.coverage.decisions.evaluated}/${result.coverage.decisions.total} evals=${result.coverage.evals.evaluated}/${result.coverage.evals.total} semantic=${result.coverage.semantic}
|
|
1651
|
+
`);
|
|
1652
|
+
for (const check of result.checks) {
|
|
1653
|
+
const state = check.state === "PASS" ? pc.green(check.state) : check.state === "SKIPPED" ? pc.dim(check.state) : check.state === "INCONCLUSIVE" ? pc.yellow(check.state) : pc.red(check.state);
|
|
1654
|
+
console.log(` ${state.padEnd(18)} ${check.title}`);
|
|
1655
|
+
if (check.state !== "PASS" && check.state !== "SKIPPED") console.log(` ${pc.dim(check.reason)} \xB7 ${check.findingId ?? check.id}`);
|
|
1656
|
+
}
|
|
1657
|
+
console.log(`
|
|
1658
|
+
exit ${result.exitCode}
|
|
1659
|
+
`);
|
|
1660
|
+
}
|
|
1661
|
+
if (typeof options.json === "string") writeOutput(root, options.json, `${JSON.stringify(result, null, 2)}
|
|
1662
|
+
`);
|
|
1663
|
+
if (options.sarif) writeOutput(root, options.sarif, `${JSON.stringify(toSarif(result), null, 2)}
|
|
1664
|
+
`);
|
|
1665
|
+
}
|
|
1666
|
+
function writeOutput(root, filename, content) {
|
|
1667
|
+
const full = assertInside(root, path10.resolve(root, filename));
|
|
1668
|
+
fs9.mkdirSync(path10.dirname(full), { recursive: true });
|
|
1669
|
+
fs9.writeFileSync(full, content);
|
|
1670
|
+
}
|
|
1671
|
+
function csv(value) {
|
|
1672
|
+
return value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
1673
|
+
}
|
|
1674
|
+
function slug(value) {
|
|
1675
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "").slice(0, 48) || "decision";
|
|
1676
|
+
}
|
|
1677
|
+
function configureGatewayFromLegacyLogin() {
|
|
1678
|
+
if (process.env.SCRIPTONIA_MODEL_GATEWAY_URL && process.env.SCRIPTONIA_API_TOKEN) return;
|
|
1679
|
+
try {
|
|
1680
|
+
const config = JSON.parse(fs9.readFileSync(path10.join(os.homedir(), ".scriptonia", "config.json"), "utf8"));
|
|
1681
|
+
if (config.url && !process.env.SCRIPTONIA_MODEL_GATEWAY_URL) process.env.SCRIPTONIA_MODEL_GATEWAY_URL = config.url;
|
|
1682
|
+
if (config.token && !process.env.SCRIPTONIA_API_TOKEN) process.env.SCRIPTONIA_API_TOKEN = config.token;
|
|
1683
|
+
} catch {
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
//# sourceMappingURL=index.js.map
|