@alisio/core 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application.d.ts +15 -0
- package/dist/application.js +38 -3
- package/dist/changelog/data.js +23 -0
- package/dist/commands/builtins.js +7 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +32 -0
- package/dist/core/runner.d.ts +4 -0
- package/dist/core/runner.js +57 -1
- package/dist/eval/engine.d.ts +1 -0
- package/dist/eval/engine.js +3 -0
- package/dist/eval/secrets.d.ts +2 -0
- package/dist/eval/secrets.js +10 -0
- package/dist/goal/command.d.ts +7 -2
- package/dist/goal/command.js +10 -2
- package/dist/goal/format.js +2 -0
- package/dist/goal/machine.d.ts +50 -3
- package/dist/goal/machine.js +80 -2
- package/dist/goal/service.d.ts +22 -2
- package/dist/goal/service.js +94 -7
- package/dist/goal/store.js +22 -3
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/plan/criteria.d.ts +30 -0
- package/dist/plan/criteria.js +256 -0
- package/dist/plan/exit-plan.d.ts +8 -0
- package/dist/plan/exit-plan.js +89 -8
- package/dist/plan/manifest.d.ts +4 -2
- package/dist/plan/manifest.js +6 -1
- package/dist/plan/state.d.ts +13 -2
- package/dist/plan/state.js +7 -1
- package/dist/plugins/host.d.ts +7 -0
- package/dist/plugins/host.js +15 -0
- package/dist/proof/command.d.ts +24 -0
- package/dist/proof/command.js +109 -0
- package/dist/proof/coverage.d.ts +40 -0
- package/dist/proof/coverage.js +102 -0
- package/dist/proof/divergence.d.ts +24 -0
- package/dist/proof/divergence.js +120 -0
- package/dist/proof/export.d.ts +65 -0
- package/dist/proof/export.js +144 -0
- package/dist/proof/index.d.ts +12 -0
- package/dist/proof/index.js +12 -0
- package/dist/proof/ledger.d.ts +38 -0
- package/dist/proof/ledger.js +57 -0
- package/dist/proof/plugin-verifiers.d.ts +23 -0
- package/dist/proof/plugin-verifiers.js +39 -0
- package/dist/proof/recorder.d.ts +69 -0
- package/dist/proof/recorder.js +347 -0
- package/dist/proof/scope.d.ts +12 -0
- package/dist/proof/scope.js +121 -0
- package/dist/proof/store.d.ts +52 -0
- package/dist/proof/store.js +192 -0
- package/dist/proof/tree.d.ts +6 -0
- package/dist/proof/tree.js +78 -0
- package/dist/proof/types.d.ts +21 -0
- package/dist/proof/types.js +1 -0
- package/dist/proof/verifiers.d.ts +46 -0
- package/dist/proof/verifiers.js +176 -0
- package/dist/proof/verify.d.ts +36 -0
- package/dist/proof/verify.js +171 -0
- package/dist/runtime/store.js +60 -0
- package/dist/tools/goal.d.ts +5 -1
- package/dist/tools/goal.js +35 -4
- package/package.json +2 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence of the Proof ledger (tables `proof_criteria` and `proof_evidence`, schema v11).
|
|
3
|
+
*
|
|
4
|
+
* Evidence is append-only: there is no update path here and a database trigger rejects UPDATE;
|
|
5
|
+
* rows disappear only with their session (`ON DELETE CASCADE`). The status is never stored (D4):
|
|
6
|
+
* `ledger()` derives it on every read.
|
|
7
|
+
*/
|
|
8
|
+
import type { ProofEvidence, ProofLedger, SqlDatabase } from "@alisio/sdk";
|
|
9
|
+
import type { CriterionRecord, NewCriterion, NewEvidence } from "./types.ts";
|
|
10
|
+
export declare class ProofStore {
|
|
11
|
+
private readonly db;
|
|
12
|
+
constructor(db: SqlDatabase);
|
|
13
|
+
addCriterion(input: NewCriterion): CriterionRecord;
|
|
14
|
+
criterion(criterionId: string): CriterionRecord | undefined;
|
|
15
|
+
/** The criteria of a session in creation order, without any derived field. */
|
|
16
|
+
criteria(sessionId: string): CriterionRecord[];
|
|
17
|
+
/** How many criteria a session has (one indexed count; the stream snapshot uses it). */
|
|
18
|
+
criteriaCount(sessionId: string): number;
|
|
19
|
+
/** Marks a criterion not applicable. Callers must be the user, never a tool result (O2). */
|
|
20
|
+
markNotApplicable(criterionId: string, reason: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* The user edits a criterion. Only the wording and the required flag change: the verifier and
|
|
23
|
+
* the scope stay, so the evidence already captured keeps meaning what it meant (add a new
|
|
24
|
+
* criterion to check something else). Callers must be the user, never a tool result (O2).
|
|
25
|
+
*/
|
|
26
|
+
updateCriterion(criterionId: string, change: {
|
|
27
|
+
text?: string;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
}): CriterionRecord | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* The unbacked claims the last COMPLETED run of the session warned about (`proof_divergence`
|
|
32
|
+
* events of the event journal). A later run that completes without a warning clears them.
|
|
33
|
+
* Display only (D8): nothing here changes a status.
|
|
34
|
+
*/
|
|
35
|
+
divergences(sessionId: string): ProofLedger["divergences"];
|
|
36
|
+
/** Appends one immutable evidence (D2: it must name its run and its call). */
|
|
37
|
+
appendEvidence(input: NewEvidence): ProofEvidence;
|
|
38
|
+
/**
|
|
39
|
+
* Notes a successful edit of a workspace-relative path (D5). One row per path keeps the newest
|
|
40
|
+
* time, so the table is bounded by the files touched and an older time never rewinds it.
|
|
41
|
+
*/
|
|
42
|
+
recordEdit(sessionId: string, path: string, editedAt: string): void;
|
|
43
|
+
/** The files edited in a session with the time of their latest edit, sorted by path. */
|
|
44
|
+
edits(sessionId: string): Array<{
|
|
45
|
+
path: string;
|
|
46
|
+
editedAt: string;
|
|
47
|
+
}>;
|
|
48
|
+
/** The ledger of a session with every status derived now (D4). */
|
|
49
|
+
ledger(sessionId: string, now?: string): ProofLedger;
|
|
50
|
+
/** The ledger every surface shows: `ledger()` plus the divergences of the last completed run. */
|
|
51
|
+
view(sessionId: string, now?: string): ProofLedger;
|
|
52
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { newId } from "../runtime/ids.js";
|
|
2
|
+
import { buildLedger } from "./ledger.js";
|
|
3
|
+
import { scopeMatches } from "./scope.js";
|
|
4
|
+
const str = (value) => value === null || value === undefined ? undefined : String(value);
|
|
5
|
+
function json(raw) {
|
|
6
|
+
if (typeof raw !== "string" || !raw)
|
|
7
|
+
return undefined;
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(raw);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function criterionFromRow(row) {
|
|
16
|
+
const goalId = str(row.goal_id);
|
|
17
|
+
const verifier = json(row.verifier_json);
|
|
18
|
+
const scope = json(row.scope_json);
|
|
19
|
+
const notApplicableReason = str(row.na_reason);
|
|
20
|
+
return {
|
|
21
|
+
criterionId: String(row.criterion_id),
|
|
22
|
+
sessionId: String(row.session_id),
|
|
23
|
+
...(goalId ? { goalId } : {}),
|
|
24
|
+
text: String(row.text),
|
|
25
|
+
required: Number(row.required) === 1,
|
|
26
|
+
origin: String(row.origin),
|
|
27
|
+
...(verifier ? { verifier } : {}),
|
|
28
|
+
...(scope ? { scope } : {}),
|
|
29
|
+
...(notApplicableReason ? { notApplicableReason } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function evidenceFromRow(row) {
|
|
33
|
+
const commandFingerprint = str(row.fingerprint);
|
|
34
|
+
const exitCode = row.exit_code === null || row.exit_code === undefined ? undefined : Number(row.exit_code);
|
|
35
|
+
const excerpt = str(row.excerpt);
|
|
36
|
+
return {
|
|
37
|
+
evidenceId: String(row.evidence_id),
|
|
38
|
+
criterionId: String(row.criterion_id),
|
|
39
|
+
verifierKind: String(row.verifier_kind),
|
|
40
|
+
runId: String(row.run_id),
|
|
41
|
+
callId: String(row.call_id),
|
|
42
|
+
ok: Number(row.ok) === 1,
|
|
43
|
+
...(commandFingerprint ? { commandFingerprint } : {}),
|
|
44
|
+
...(exitCode !== undefined ? { exitCode } : {}),
|
|
45
|
+
durationMs: Number(row.duration_ms),
|
|
46
|
+
outputHash: String(row.output_hash),
|
|
47
|
+
...(excerpt !== undefined ? { excerpt } : {}),
|
|
48
|
+
treeFingerprint: String(row.tree_fingerprint),
|
|
49
|
+
filesTouched: json(row.files_json) ?? [],
|
|
50
|
+
capturedAt: String(row.captured_at),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export class ProofStore {
|
|
54
|
+
db;
|
|
55
|
+
constructor(db) {
|
|
56
|
+
this.db = db;
|
|
57
|
+
}
|
|
58
|
+
addCriterion(input) {
|
|
59
|
+
const criterionId = newId("crit");
|
|
60
|
+
this.db
|
|
61
|
+
.prepare(`INSERT INTO proof_criteria(criterion_id,session_id,goal_id,text,required,origin,verifier_json,scope_json,created_at)
|
|
62
|
+
VALUES(?,?,?,?,?,?,?,?,?)`)
|
|
63
|
+
.run(criterionId, input.sessionId, input.goalId ?? null, input.text, input.required ? 1 : 0, input.origin, input.verifier ? JSON.stringify(input.verifier) : null, input.scope ? JSON.stringify(input.scope) : null, Date.now());
|
|
64
|
+
return this.criterion(criterionId);
|
|
65
|
+
}
|
|
66
|
+
criterion(criterionId) {
|
|
67
|
+
const row = this.db
|
|
68
|
+
.prepare("SELECT * FROM proof_criteria WHERE criterion_id=?")
|
|
69
|
+
.get(criterionId);
|
|
70
|
+
return row ? criterionFromRow(row) : undefined;
|
|
71
|
+
}
|
|
72
|
+
/** The criteria of a session in creation order, without any derived field. */
|
|
73
|
+
criteria(sessionId) {
|
|
74
|
+
return this.db
|
|
75
|
+
.prepare("SELECT * FROM proof_criteria WHERE session_id=? ORDER BY created_at, rowid")
|
|
76
|
+
.all(sessionId)
|
|
77
|
+
.map(criterionFromRow);
|
|
78
|
+
}
|
|
79
|
+
/** How many criteria a session has (one indexed count; the stream snapshot uses it). */
|
|
80
|
+
criteriaCount(sessionId) {
|
|
81
|
+
const row = this.db
|
|
82
|
+
.prepare("SELECT COUNT(*) AS n FROM proof_criteria WHERE session_id=?")
|
|
83
|
+
.get(sessionId);
|
|
84
|
+
return Number(row?.n ?? 0);
|
|
85
|
+
}
|
|
86
|
+
/** Marks a criterion not applicable. Callers must be the user, never a tool result (O2). */
|
|
87
|
+
markNotApplicable(criterionId, reason) {
|
|
88
|
+
const trimmed = reason.trim();
|
|
89
|
+
if (!trimmed)
|
|
90
|
+
throw new Error("not_applicable requires a reason");
|
|
91
|
+
const result = this.db
|
|
92
|
+
.prepare("UPDATE proof_criteria SET na_reason=? WHERE criterion_id=?")
|
|
93
|
+
.run(trimmed, criterionId);
|
|
94
|
+
if (Number(result.changes) === 0)
|
|
95
|
+
throw new Error(`unknown criterion: ${criterionId}`);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The user edits a criterion. Only the wording and the required flag change: the verifier and
|
|
99
|
+
* the scope stay, so the evidence already captured keeps meaning what it meant (add a new
|
|
100
|
+
* criterion to check something else). Callers must be the user, never a tool result (O2).
|
|
101
|
+
*/
|
|
102
|
+
updateCriterion(criterionId, change) {
|
|
103
|
+
const text = change.text === undefined ? undefined : change.text.replace(/\s+/g, " ").trim();
|
|
104
|
+
if (text !== undefined && !text)
|
|
105
|
+
throw new Error("a criterion needs text");
|
|
106
|
+
if (!this.criterion(criterionId))
|
|
107
|
+
return undefined;
|
|
108
|
+
if (text !== undefined)
|
|
109
|
+
this.db
|
|
110
|
+
.prepare("UPDATE proof_criteria SET text=? WHERE criterion_id=?")
|
|
111
|
+
.run(text, criterionId);
|
|
112
|
+
if (change.required !== undefined)
|
|
113
|
+
this.db
|
|
114
|
+
.prepare("UPDATE proof_criteria SET required=? WHERE criterion_id=?")
|
|
115
|
+
.run(change.required ? 1 : 0, criterionId);
|
|
116
|
+
return this.criterion(criterionId);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The unbacked claims the last COMPLETED run of the session warned about (`proof_divergence`
|
|
120
|
+
* events of the event journal). A later run that completes without a warning clears them.
|
|
121
|
+
* Display only (D8): nothing here changes a status.
|
|
122
|
+
*/
|
|
123
|
+
divergences(sessionId) {
|
|
124
|
+
const last = this.db
|
|
125
|
+
.prepare("SELECT run_id FROM events WHERE session=? AND type='run_completed' ORDER BY seq DESC LIMIT 1")
|
|
126
|
+
.get(sessionId);
|
|
127
|
+
if (!last)
|
|
128
|
+
return [];
|
|
129
|
+
return this.db
|
|
130
|
+
.prepare("SELECT body FROM events WHERE session=? AND run_id=? AND type='proof_divergence' ORDER BY seq")
|
|
131
|
+
.all(sessionId, String(last.run_id))
|
|
132
|
+
.flatMap((row) => {
|
|
133
|
+
const body = json(row.body);
|
|
134
|
+
return body ? [body] : [];
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Appends one immutable evidence (D2: it must name its run and its call). */
|
|
138
|
+
appendEvidence(input) {
|
|
139
|
+
if (!input.runId || !input.callId)
|
|
140
|
+
throw new Error("evidence requires runId and callId");
|
|
141
|
+
const evidenceId = newId("ev");
|
|
142
|
+
this.db
|
|
143
|
+
.prepare(`INSERT INTO proof_evidence(evidence_id,criterion_id,run_id,call_id,ok,verifier_kind,fingerprint,exit_code,
|
|
144
|
+
duration_ms,output_hash,excerpt,tree_fingerprint,files_json,captured_at)
|
|
145
|
+
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
146
|
+
.run(evidenceId, input.criterionId, input.runId, input.callId, input.ok ? 1 : 0, input.verifierKind, input.commandFingerprint ?? null, input.exitCode ?? null, input.durationMs, input.outputHash, input.excerpt ?? null, input.treeFingerprint, JSON.stringify(input.filesTouched), input.capturedAt);
|
|
147
|
+
return { ...input, evidenceId };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Notes a successful edit of a workspace-relative path (D5). One row per path keeps the newest
|
|
151
|
+
* time, so the table is bounded by the files touched and an older time never rewinds it.
|
|
152
|
+
*/
|
|
153
|
+
recordEdit(sessionId, path, editedAt) {
|
|
154
|
+
this.db
|
|
155
|
+
.prepare(`INSERT INTO proof_edits(session_id,path,edited_at) VALUES(?,?,?)
|
|
156
|
+
ON CONFLICT(session_id,path) DO UPDATE SET edited_at=excluded.edited_at
|
|
157
|
+
WHERE excluded.edited_at > proof_edits.edited_at`)
|
|
158
|
+
.run(sessionId, path, editedAt);
|
|
159
|
+
}
|
|
160
|
+
/** The files edited in a session with the time of their latest edit, sorted by path. */
|
|
161
|
+
edits(sessionId) {
|
|
162
|
+
return this.db
|
|
163
|
+
.prepare("SELECT path,edited_at FROM proof_edits WHERE session_id=? ORDER BY path")
|
|
164
|
+
.all(sessionId)
|
|
165
|
+
.map((row) => ({ path: String(row.path), editedAt: String(row.edited_at) }));
|
|
166
|
+
}
|
|
167
|
+
/** The ledger of a session with every status derived now (D4). */
|
|
168
|
+
ledger(sessionId, now = new Date().toISOString()) {
|
|
169
|
+
const criteria = this.criteria(sessionId);
|
|
170
|
+
const evidence = this.db
|
|
171
|
+
.prepare(`SELECT e.* FROM proof_evidence e JOIN proof_criteria c ON c.criterion_id=e.criterion_id
|
|
172
|
+
WHERE c.session_id=? ORDER BY e.rowid`)
|
|
173
|
+
.all(sessionId)
|
|
174
|
+
.map(evidenceFromRow);
|
|
175
|
+
const edits = this.edits(sessionId);
|
|
176
|
+
const lastEditAt = (criterion) => {
|
|
177
|
+
let latest;
|
|
178
|
+
for (const edit of edits) {
|
|
179
|
+
if (!scopeMatches(criterion.scope, edit.path))
|
|
180
|
+
continue;
|
|
181
|
+
if (latest === undefined || Date.parse(edit.editedAt) > Date.parse(latest))
|
|
182
|
+
latest = edit.editedAt;
|
|
183
|
+
}
|
|
184
|
+
return latest;
|
|
185
|
+
};
|
|
186
|
+
return buildLedger(sessionId, criteria, evidence, { now, lastEditAt });
|
|
187
|
+
}
|
|
188
|
+
/** The ledger every surface shows: `ledger()` plus the divergences of the last completed run. */
|
|
189
|
+
view(sessionId, now) {
|
|
190
|
+
return { ...this.ledger(sessionId, now), divergences: this.divergences(sessionId) };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Bytes of `git diff` that are read; the rest of a larger diff is not hashed. */
|
|
2
|
+
export declare const MAX_DIFF_BYTES = 1048576;
|
|
3
|
+
/** Touched files that enter the fingerprint (sorted, so the cut is deterministic). */
|
|
4
|
+
export declare const MAX_FINGERPRINT_FILES = 200;
|
|
5
|
+
/** The fingerprint of the tree at `workspace` given the files the session touched. */
|
|
6
|
+
export declare function treeFingerprint(workspace: string, filesTouched: readonly string[]): string;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tree fingerprint of a Proof evidence (specs/011 3.4, D5, section 7): a hash of a bounded
|
|
3
|
+
* `git diff` plus the sha256 of the files the session touched. It is an audit anchor stored with
|
|
4
|
+
* the evidence, not the expiry mechanism (expiry reads the recorded edit times). Deterministic for
|
|
5
|
+
* a given tree, bounded in cost, and safe outside git: no repository or no `git` binary just
|
|
6
|
+
* means the diff part is a constant.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
12
|
+
/** Bytes of `git diff` that are read; the rest of a larger diff is not hashed. */
|
|
13
|
+
export const MAX_DIFF_BYTES = 1_048_576;
|
|
14
|
+
/** Files larger than this are fingerprinted by size only. */
|
|
15
|
+
const MAX_FILE_BYTES = 1_048_576;
|
|
16
|
+
/** Touched files that enter the fingerprint (sorted, so the cut is deterministic). */
|
|
17
|
+
export const MAX_FINGERPRINT_FILES = 200;
|
|
18
|
+
const GIT_TIMEOUT_MS = 3_000;
|
|
19
|
+
const HASH_CACHE_MAX = 512;
|
|
20
|
+
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
21
|
+
/** File hashes by `path|size|mtime`: an unchanged file is never read twice. */
|
|
22
|
+
const fileHashes = new Map();
|
|
23
|
+
function fileDigest(workspace, path) {
|
|
24
|
+
const absolute = resolve(workspace, path);
|
|
25
|
+
const rel = relative(workspace, absolute);
|
|
26
|
+
if (rel.startsWith("..") || isAbsolute(rel))
|
|
27
|
+
return "outside";
|
|
28
|
+
try {
|
|
29
|
+
const info = statSync(absolute);
|
|
30
|
+
if (!info.isFile())
|
|
31
|
+
return "missing";
|
|
32
|
+
if (info.size > MAX_FILE_BYTES)
|
|
33
|
+
return `large:${info.size}`;
|
|
34
|
+
const key = `${absolute}\u0000${info.size}\u0000${info.mtimeMs}`;
|
|
35
|
+
const known = fileHashes.get(key);
|
|
36
|
+
if (known !== undefined)
|
|
37
|
+
return known;
|
|
38
|
+
const digest = sha256(readFileSync(absolute));
|
|
39
|
+
if (fileHashes.size >= HASH_CACHE_MAX)
|
|
40
|
+
fileHashes.clear();
|
|
41
|
+
fileHashes.set(key, digest);
|
|
42
|
+
return digest;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return "missing";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Digest of the working-tree diff against HEAD, or a constant when git cannot answer. */
|
|
49
|
+
function diffDigest(workspace) {
|
|
50
|
+
const run = (args) => spawnSync("git", ["--no-pager", ...args], {
|
|
51
|
+
cwd: workspace,
|
|
52
|
+
maxBuffer: MAX_DIFF_BYTES,
|
|
53
|
+
timeout: GIT_TIMEOUT_MS,
|
|
54
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" },
|
|
55
|
+
windowsHide: true,
|
|
56
|
+
});
|
|
57
|
+
const flags = ["--no-ext-diff", "--no-textconv", "--no-color", "--no-renames"];
|
|
58
|
+
let result = run(["diff", ...flags, "HEAD", "--"]);
|
|
59
|
+
// A repository with no commit has no HEAD: fall back to the index.
|
|
60
|
+
if (result.status !== 0 &&
|
|
61
|
+
/bad revision|unknown revision|ambiguous argument/.test(String(result.stderr)))
|
|
62
|
+
result = run(["diff", ...flags, "--"]);
|
|
63
|
+
const out = result.stdout;
|
|
64
|
+
const overflow = result.error !== undefined && Buffer.isBuffer(out) && out.length > 0;
|
|
65
|
+
if (!overflow && (result.error || result.status !== 0 || !Buffer.isBuffer(out)))
|
|
66
|
+
return "nogit";
|
|
67
|
+
const bytes = out.subarray(0, MAX_DIFF_BYTES);
|
|
68
|
+
return `${overflow ? "truncated:" : "full:"}${sha256(bytes)}`;
|
|
69
|
+
}
|
|
70
|
+
/** The fingerprint of the tree at `workspace` given the files the session touched. */
|
|
71
|
+
export function treeFingerprint(workspace, filesTouched) {
|
|
72
|
+
const files = [...new Set(filesTouched)].sort().slice(0, MAX_FINGERPRINT_FILES);
|
|
73
|
+
return sha256(JSON.stringify({
|
|
74
|
+
v: 1,
|
|
75
|
+
diff: diffDigest(workspace),
|
|
76
|
+
files: files.map((file) => [file, fileDigest(workspace, file)]),
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core-side shapes of the Proof ledger (specs/011). The wire types (`ProofCriterion`,
|
|
3
|
+
* `ProofEvidence`, `ProofLedger`) live in `@alisio/sdk`; these are what the store keeps before the
|
|
4
|
+
* status is derived (D4: the status is never stored).
|
|
5
|
+
*/
|
|
6
|
+
import type { ProofCriterion, ProofEvidence } from "@alisio/sdk";
|
|
7
|
+
/** A criterion as persisted: the wire shape without the derived fields. */
|
|
8
|
+
export type CriterionRecord = Omit<ProofCriterion, "status" | "stale" | "evidenceIds">;
|
|
9
|
+
/** What a caller supplies to create a criterion. */
|
|
10
|
+
export interface NewCriterion {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
text: string;
|
|
13
|
+
origin: ProofCriterion["origin"];
|
|
14
|
+
/** Defaults to false. */
|
|
15
|
+
required?: boolean;
|
|
16
|
+
goalId?: string;
|
|
17
|
+
verifier?: ProofCriterion["verifier"];
|
|
18
|
+
scope?: string[];
|
|
19
|
+
}
|
|
20
|
+
/** What a caller supplies to append evidence: the store assigns `evidenceId`. */
|
|
21
|
+
export type NewEvidence = Omit<ProofEvidence, "evidenceId">;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ProofVerifierSpec } from "@alisio/sdk";
|
|
2
|
+
export type FileSpec = Extract<ProofVerifierSpec, {
|
|
3
|
+
kind: "file";
|
|
4
|
+
}>;
|
|
5
|
+
export type DataCheckSpec = Extract<ProofVerifierSpec, {
|
|
6
|
+
kind: "data-check";
|
|
7
|
+
}>;
|
|
8
|
+
/** A core tool call that makes a verifier's evidence appear when it runs. */
|
|
9
|
+
export interface VerifierCall {
|
|
10
|
+
name: string;
|
|
11
|
+
input: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
/** What a judge concluded: the verdict and a short, secret-free sentence about it. */
|
|
14
|
+
export interface Judgement {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
summary: string;
|
|
17
|
+
}
|
|
18
|
+
/** The single SQL statement whose result decides a `data-check` (no column: no verdict). */
|
|
19
|
+
export declare function canonicalSql(spec: DataCheckSpec): string | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* Case, quoting, spacing and a trailing `;` do not change a canonical query. Identifiers with
|
|
22
|
+
* quotes or case differences collapse, which is what SQLite does with them anyway.
|
|
23
|
+
*/
|
|
24
|
+
export declare function normalizeSql(sql: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* The tool call an on-demand verification runs for this spec, or undefined when the spec cannot
|
|
27
|
+
* be checked by one (`manual`, `command`, `plugin`, or a data-check missing its column).
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifierCall(spec: ProofVerifierSpec): VerifierCall | undefined;
|
|
30
|
+
/** Workspace-relative POSIX path, or undefined when it leaves the workspace. */
|
|
31
|
+
export declare function workspacePath(workspace: string, path: string): string | undefined;
|
|
32
|
+
/** Does a `read_file` call (by its input) read the file this criterion names? */
|
|
33
|
+
export declare function readsFile(spec: FileSpec, inputPath: unknown, workspace: string): boolean;
|
|
34
|
+
/** Does a `data_query` call (by its input) run the canonical query of this criterion? */
|
|
35
|
+
export declare function runsCanonicalQuery(spec: DataCheckSpec, input: Record<string, unknown>): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Judges a successful `read_file` result against a `file` criterion: existence, sha256 and a
|
|
38
|
+
* regex over the content (line anchors work: the pattern runs with the `m` flag). A refutation
|
|
39
|
+
* needs the whole file in view; a window that starts after line 1 or stops early can only
|
|
40
|
+
* confirm a match.
|
|
41
|
+
*/
|
|
42
|
+
export declare function judgeFile(spec: FileSpec, body: Record<string, unknown>): Judgement | undefined;
|
|
43
|
+
/** `read_file` failed because the file is not there: the criterion is refuted. */
|
|
44
|
+
export declare function judgeMissingFile(errorText: string): Judgement | undefined;
|
|
45
|
+
/** Judges the result text of the canonical query of a `data-check` criterion. */
|
|
46
|
+
export declare function judgeDataCheck(spec: DataCheckSpec, text: string): Judgement | undefined;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure judgements of the `file` and `data-check` verifiers (specs/011 3.1, D3). They read the
|
|
3
|
+
* RAW result of a core tool call (`read_file`, `data_query`) and say whether the criterion holds;
|
|
4
|
+
* they never touch the filesystem or a dataset themselves, so the only inputs that count are the
|
|
5
|
+
* structured results of calls the runner really executed under its gate (D6).
|
|
6
|
+
*
|
|
7
|
+
* `undefined` from a judge means "this call cannot decide": a window of a file that does not
|
|
8
|
+
* start at line 1, a scalar that is not a number. No evidence is better than a guess.
|
|
9
|
+
*/
|
|
10
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
11
|
+
const DEFAULT_TABLE = "data";
|
|
12
|
+
/** Lines a pattern read asks for; `read_file` caps a window at 1000 lines. */
|
|
13
|
+
const PATTERN_LINES = 1000;
|
|
14
|
+
const NUMBER_TOLERANCE = 1e-9;
|
|
15
|
+
const quote = (identifier) => `"${identifier.replaceAll('"', '""')}"`;
|
|
16
|
+
/** The single SQL statement whose result decides a `data-check` (no column: no verdict). */
|
|
17
|
+
export function canonicalSql(spec) {
|
|
18
|
+
const from = `FROM ${quote(spec.table ?? DEFAULT_TABLE)}`;
|
|
19
|
+
if (spec.check === "row_count")
|
|
20
|
+
return `SELECT COUNT(*) ${from}`;
|
|
21
|
+
if (!spec.column)
|
|
22
|
+
return undefined;
|
|
23
|
+
// TOTAL() is 0.0 over no rows where SUM() would be NULL.
|
|
24
|
+
if (spec.check === "sum")
|
|
25
|
+
return `SELECT TOTAL(${quote(spec.column)}) ${from}`;
|
|
26
|
+
if (spec.check === "equals")
|
|
27
|
+
return `SELECT DISTINCT ${quote(spec.column)} ${from} LIMIT 2`;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Case, quoting, spacing and a trailing `;` do not change a canonical query. Identifiers with
|
|
32
|
+
* quotes or case differences collapse, which is what SQLite does with them anyway.
|
|
33
|
+
*/
|
|
34
|
+
export function normalizeSql(sql) {
|
|
35
|
+
return sql
|
|
36
|
+
.replaceAll('"', "")
|
|
37
|
+
.replace(/\s+/g, " ")
|
|
38
|
+
.trim()
|
|
39
|
+
.replace(/\s*;+$/, "")
|
|
40
|
+
.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The tool call an on-demand verification runs for this spec, or undefined when the spec cannot
|
|
44
|
+
* be checked by one (`manual`, `command`, `plugin`, or a data-check missing its column).
|
|
45
|
+
*/
|
|
46
|
+
export function verifierCall(spec) {
|
|
47
|
+
if (spec.kind === "file")
|
|
48
|
+
return {
|
|
49
|
+
name: "read_file",
|
|
50
|
+
input: { path: spec.path, limit: spec.pattern ? PATTERN_LINES : 1 },
|
|
51
|
+
};
|
|
52
|
+
// The configured coverage command, as the `shell` call whose raw result the recorder judges.
|
|
53
|
+
if (spec.kind === "coverage")
|
|
54
|
+
return { name: "shell", input: { command: spec.command } };
|
|
55
|
+
if (spec.kind === "data-check") {
|
|
56
|
+
const sql = canonicalSql(spec);
|
|
57
|
+
return sql ? { name: "data_query", input: { datasetId: spec.datasetId, sql } } : undefined;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** Workspace-relative POSIX path, or undefined when it leaves the workspace. */
|
|
62
|
+
export function workspacePath(workspace, path) {
|
|
63
|
+
const rel = relative(workspace, resolve(workspace, path));
|
|
64
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel))
|
|
65
|
+
return undefined;
|
|
66
|
+
return rel.split(sep).join("/");
|
|
67
|
+
}
|
|
68
|
+
/** Does a `read_file` call (by its input) read the file this criterion names? */
|
|
69
|
+
export function readsFile(spec, inputPath, workspace) {
|
|
70
|
+
if (typeof inputPath !== "string")
|
|
71
|
+
return false;
|
|
72
|
+
const wanted = workspacePath(workspace, spec.path);
|
|
73
|
+
return wanted !== undefined && wanted === workspacePath(workspace, inputPath);
|
|
74
|
+
}
|
|
75
|
+
/** Does a `data_query` call (by its input) run the canonical query of this criterion? */
|
|
76
|
+
export function runsCanonicalQuery(spec, input) {
|
|
77
|
+
const canonical = canonicalSql(spec);
|
|
78
|
+
return (canonical !== undefined &&
|
|
79
|
+
input.datasetId === spec.datasetId &&
|
|
80
|
+
typeof input.sql === "string" &&
|
|
81
|
+
normalizeSql(input.sql) === normalizeSql(canonical));
|
|
82
|
+
}
|
|
83
|
+
const LINE_PREFIX = /^\d+: ?/;
|
|
84
|
+
/** The text of a `read_file` window without its `N: ` line prefixes. */
|
|
85
|
+
function plainContent(content) {
|
|
86
|
+
return content
|
|
87
|
+
.split("\n")
|
|
88
|
+
.map((line) => line.replace(LINE_PREFIX, ""))
|
|
89
|
+
.join("\n");
|
|
90
|
+
}
|
|
91
|
+
function patternMatches(pattern, text) {
|
|
92
|
+
try {
|
|
93
|
+
return new RegExp(pattern, "m").test(text);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false; // an unusable pattern never verifies anything
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Judges a successful `read_file` result against a `file` criterion: existence, sha256 and a
|
|
101
|
+
* regex over the content (line anchors work: the pattern runs with the `m` flag). A refutation
|
|
102
|
+
* needs the whole file in view; a window that starts after line 1 or stops early can only
|
|
103
|
+
* confirm a match.
|
|
104
|
+
*/
|
|
105
|
+
export function judgeFile(spec, body) {
|
|
106
|
+
const parts = ["exists"];
|
|
107
|
+
let undecided = false;
|
|
108
|
+
if (spec.sha256 !== undefined) {
|
|
109
|
+
if (typeof body.sha256 !== "string")
|
|
110
|
+
undecided = true; // too large to hash
|
|
111
|
+
else if (body.sha256.toLowerCase() !== spec.sha256.toLowerCase())
|
|
112
|
+
return { ok: false, summary: `sha256 differs (found ${body.sha256})` };
|
|
113
|
+
else
|
|
114
|
+
parts.push("sha256 matches");
|
|
115
|
+
}
|
|
116
|
+
if (spec.pattern !== undefined) {
|
|
117
|
+
const content = typeof body.content === "string" ? plainContent(body.content) : "";
|
|
118
|
+
if (patternMatches(spec.pattern, content))
|
|
119
|
+
parts.push("pattern matched");
|
|
120
|
+
else if (body.truncated === false && body.startLine === 1)
|
|
121
|
+
return { ok: false, summary: "pattern not found" };
|
|
122
|
+
else
|
|
123
|
+
undecided = true;
|
|
124
|
+
}
|
|
125
|
+
return undecided ? undefined : { ok: true, summary: parts.join("; ") };
|
|
126
|
+
}
|
|
127
|
+
/** `read_file` failed because the file is not there: the criterion is refuted. */
|
|
128
|
+
export function judgeMissingFile(errorText) {
|
|
129
|
+
return /^File not found\b/.test(errorText) ? { ok: false, summary: "file not found" } : undefined;
|
|
130
|
+
}
|
|
131
|
+
/** A `data_query` text result: header, rows (tab separated) and the `[N rows…]` summary. */
|
|
132
|
+
function parseQuery(text) {
|
|
133
|
+
const lines = text.split("\n");
|
|
134
|
+
const summary = lines.at(-1) ?? "";
|
|
135
|
+
if (lines.length < 2 || !/^\[[\d,]+ rows?/.test(summary))
|
|
136
|
+
return undefined;
|
|
137
|
+
return { rows: lines.slice(1, -1), cut: /truncated|shortened/.test(summary) };
|
|
138
|
+
}
|
|
139
|
+
const numeric = (cell) => {
|
|
140
|
+
if (cell === undefined || cell.trim() === "")
|
|
141
|
+
return undefined;
|
|
142
|
+
const value = Number(cell);
|
|
143
|
+
return Number.isFinite(value) ? value : undefined;
|
|
144
|
+
};
|
|
145
|
+
const sameNumber = (a, b) => Math.abs(a - b) <= NUMBER_TOLERANCE * Math.max(1, Math.abs(b));
|
|
146
|
+
/** Judges the result text of the canonical query of a `data-check` criterion. */
|
|
147
|
+
export function judgeDataCheck(spec, text) {
|
|
148
|
+
const parsed = parseQuery(text);
|
|
149
|
+
if (!parsed || parsed.cut)
|
|
150
|
+
return undefined;
|
|
151
|
+
const { rows } = parsed;
|
|
152
|
+
if (spec.check === "equals") {
|
|
153
|
+
if (rows.length === 0)
|
|
154
|
+
return { ok: false, summary: "equals: the column has no values" };
|
|
155
|
+
if (rows.length > 1)
|
|
156
|
+
return { ok: false, summary: "equals: the column has several values" };
|
|
157
|
+
const actual = rows[0] ?? "";
|
|
158
|
+
const expected = String(spec.expected);
|
|
159
|
+
const ok = actual === expected ||
|
|
160
|
+
(numeric(actual) !== undefined &&
|
|
161
|
+
numeric(expected) !== undefined &&
|
|
162
|
+
sameNumber(numeric(actual), numeric(expected)));
|
|
163
|
+
return { ok, summary: `equals ${spec.column}: actual ${actual}, expected ${expected}` };
|
|
164
|
+
}
|
|
165
|
+
if (rows.length !== 1)
|
|
166
|
+
return undefined;
|
|
167
|
+
const actual = numeric(rows[0]);
|
|
168
|
+
if (actual === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
const expected = numeric(String(spec.expected));
|
|
171
|
+
const label = spec.check === "sum" ? `sum of ${spec.column}` : "row_count";
|
|
172
|
+
return {
|
|
173
|
+
ok: expected !== undefined && sameNumber(actual, expected),
|
|
174
|
+
summary: `${label}: actual ${actual}, expected ${spec.expected}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ProofStatus, SqlDatabase, ToolResult } from "@alisio/sdk";
|
|
2
|
+
import type { ProofVerifierRegistry } from "./plugin-verifiers.ts";
|
|
3
|
+
import type { ProofStore } from "./store.ts";
|
|
4
|
+
export interface VerifyDeps {
|
|
5
|
+
store: ProofStore;
|
|
6
|
+
registry: ProofVerifierRegistry;
|
|
7
|
+
db: SqlDatabase;
|
|
8
|
+
/** Runs one core tool call as a run of the session, through the same gate as a model call. */
|
|
9
|
+
callTool(sessionId: string, name: string, input: Record<string, unknown>, signal: AbortSignal): Promise<{
|
|
10
|
+
callId: string;
|
|
11
|
+
runId: string;
|
|
12
|
+
result: ToolResult;
|
|
13
|
+
}>;
|
|
14
|
+
/** `proof.coverage.enabled`: without it a `coverage` verifier never runs its command. */
|
|
15
|
+
coverage?: () => boolean;
|
|
16
|
+
/** Time a plugin verifier may take; 60 s by default. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
}
|
|
20
|
+
export interface VerifyRequest {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
criterionId: string;
|
|
23
|
+
workspace: string;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export interface VerifyOutcome {
|
|
27
|
+
criterionId: string;
|
|
28
|
+
/** The criterion's derived status after the attempt. */
|
|
29
|
+
status: ProofStatus;
|
|
30
|
+
evidenceId?: string;
|
|
31
|
+
/** Why nothing (or only a degradation) was recorded; absent when evidence was captured. */
|
|
32
|
+
reason?: "unknown_criterion" | "not_applicable" | "no_verifier" | "unsupported_verifier" | "unverifiable_spec" | "coverage_disabled" | "no_evidence" | "verifier_unavailable" | "verifier_failed" | "no_referenceable_evidence";
|
|
33
|
+
/** Redacted, bounded detail of a failure. */
|
|
34
|
+
detail?: string;
|
|
35
|
+
}
|
|
36
|
+
export declare function verifyCriterion(deps: VerifyDeps, request: VerifyRequest): Promise<VerifyOutcome>;
|