@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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command fingerprint v1 (specs/011, Fase 0 decision): `sha256(JSON.stringify({v:1, argv, cwd}))`.
|
|
3
|
+
* `run_process` uses `[command, ...args]`; `shell` and `bg_run` use `["sh","-c", normalized]`; the
|
|
4
|
+
* working directory is relative to the workspace with `/` and `.` by default. No environment.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
8
|
+
/** The canonical workspace-relative directory: forward slashes, no `./` prefix, `.` when empty. */
|
|
9
|
+
export function normalizeCwd(cwd) {
|
|
10
|
+
return (cwd
|
|
11
|
+
.split("\\")
|
|
12
|
+
.join("/")
|
|
13
|
+
.replace(/^(\.\/)+/, "")
|
|
14
|
+
.replace(/\/+$/, "") || ".");
|
|
15
|
+
}
|
|
16
|
+
/** Directory of a call relative to the workspace (`.` when it is the workspace itself). */
|
|
17
|
+
export function relativeCwd(workspace, cwd) {
|
|
18
|
+
if (!cwd)
|
|
19
|
+
return ".";
|
|
20
|
+
const abs = isAbsolute(cwd) ? cwd : resolve(workspace, cwd);
|
|
21
|
+
return normalizeCwd(relative(workspace, abs));
|
|
22
|
+
}
|
|
23
|
+
export function commandFingerprint(argv, cwd) {
|
|
24
|
+
return createHash("sha256")
|
|
25
|
+
.update(JSON.stringify({ v: 1, argv, cwd: normalizeCwd(cwd) }))
|
|
26
|
+
.digest("hex");
|
|
27
|
+
}
|
|
28
|
+
/** Trims and collapses whitespace runs OUTSIDE single/double quotes; quoted text stays verbatim. */
|
|
29
|
+
export function normalizeShell(command) {
|
|
30
|
+
let out = "";
|
|
31
|
+
let quote;
|
|
32
|
+
let pendingSpace = false;
|
|
33
|
+
for (const ch of command.trim()) {
|
|
34
|
+
if (quote) {
|
|
35
|
+
out += ch;
|
|
36
|
+
if (ch === quote)
|
|
37
|
+
quote = undefined;
|
|
38
|
+
}
|
|
39
|
+
else if (/\s/.test(ch)) {
|
|
40
|
+
pendingSpace = true;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (pendingSpace)
|
|
44
|
+
out += " ";
|
|
45
|
+
pendingSpace = false;
|
|
46
|
+
out += ch;
|
|
47
|
+
if (ch === '"' || ch === "'")
|
|
48
|
+
quote = ch;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** Fingerprint of a call as the tool received it. */
|
|
54
|
+
export function callFingerprint(tool, input, cwd) {
|
|
55
|
+
const argv = tool === "run_process"
|
|
56
|
+
? [input.command, ...(input.args ?? [])]
|
|
57
|
+
: ["sh", "-c", normalizeShell(input.command)];
|
|
58
|
+
return commandFingerprint(argv, cwd);
|
|
59
|
+
}
|
|
60
|
+
const SHELL_OPERATORS = /[|&;<>()$`\\*?~#{}!\n]/;
|
|
61
|
+
/**
|
|
62
|
+
* Splits a plain command line into an argv (single and double quotes group words). Returns
|
|
63
|
+
* undefined when the line needs a shell (operators, expansions, unbalanced quotes): only the
|
|
64
|
+
* shell form can then match it.
|
|
65
|
+
*/
|
|
66
|
+
export function splitCommand(command) {
|
|
67
|
+
const argv = [];
|
|
68
|
+
let word;
|
|
69
|
+
let quote;
|
|
70
|
+
for (const ch of command.trim()) {
|
|
71
|
+
if (quote) {
|
|
72
|
+
if (ch === quote)
|
|
73
|
+
quote = undefined;
|
|
74
|
+
else
|
|
75
|
+
word += ch;
|
|
76
|
+
}
|
|
77
|
+
else if (ch === '"' || ch === "'") {
|
|
78
|
+
quote = ch;
|
|
79
|
+
word ??= "";
|
|
80
|
+
}
|
|
81
|
+
else if (/\s/.test(ch)) {
|
|
82
|
+
if (word !== undefined)
|
|
83
|
+
argv.push(word);
|
|
84
|
+
word = undefined;
|
|
85
|
+
}
|
|
86
|
+
else if (SHELL_OPERATORS.test(ch)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
word = (word ?? "") + ch;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (quote)
|
|
94
|
+
return undefined;
|
|
95
|
+
if (word !== undefined)
|
|
96
|
+
argv.push(word);
|
|
97
|
+
return argv.length ? argv : undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Every fingerprint a call may have to satisfy the criterion command `command` run in `cwd`: the
|
|
101
|
+
* `shell` form always and the `run_process` form when the line is a plain argv.
|
|
102
|
+
*/
|
|
103
|
+
export function criterionFingerprints(command, cwd) {
|
|
104
|
+
const out = [callFingerprint("shell", { command }, cwd)];
|
|
105
|
+
const argv = splitCommand(command);
|
|
106
|
+
if (argv)
|
|
107
|
+
out.unshift(commandFingerprint(argv, cwd));
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parser and judge of the `coverage` verifier (specs/011 3.1, Fase 7). They read the text a
|
|
3
|
+
* coverage command printed and say whether the overall percentage reaches `minPercent`; they never
|
|
4
|
+
* run anything and never generate tests. The evidence itself is captured by the recorder from the
|
|
5
|
+
* RAW result of a call the runner executed under its gate (D3, D6).
|
|
6
|
+
*
|
|
7
|
+
* Formats, tried from the most explicit overall figure to the least:
|
|
8
|
+
* 1. `coverage.py` / pytest-cov: the `TOTAL` row (last percentage) or `Total coverage: N%`.
|
|
9
|
+
* 2. A text table (vitest, c8, istanbul): the `All files` row, `% Lines` column.
|
|
10
|
+
* 3. An istanbul `text-summary`: the `Lines : N%` row.
|
|
11
|
+
* 4. `lcov --summary`: the `lines......: N%` row.
|
|
12
|
+
* 5. Raw `lcov` tracefile: sum of `LH` over sum of `LF`.
|
|
13
|
+
* Per-file rows never stand in for the total: a missing overall figure is "not found", so an
|
|
14
|
+
* unparseable output can never verify a criterion. Several matches of one format: the last wins.
|
|
15
|
+
*/
|
|
16
|
+
import type { ProofVerifierSpec } from "@alisio/sdk";
|
|
17
|
+
export type CoverageSpec = Extract<ProofVerifierSpec, {
|
|
18
|
+
kind: "coverage";
|
|
19
|
+
}>;
|
|
20
|
+
export type CoverageFormat = "coverage.py" | "table" | "summary" | "lcov-summary" | "lcov";
|
|
21
|
+
export interface ParsedCoverage {
|
|
22
|
+
percent: number;
|
|
23
|
+
format: CoverageFormat;
|
|
24
|
+
}
|
|
25
|
+
/** The overall coverage percentage a command printed, or undefined when there is none to trust. */
|
|
26
|
+
export declare function parseCoverage(output: string): ParsedCoverage | undefined;
|
|
27
|
+
/** What a coverage judgement concluded: the verdict and a short, secret-free sentence about it. */
|
|
28
|
+
export interface CoverageJudgement {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
summary: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Judges what the coverage command did. It verifies only when the command exited 0 AND an overall
|
|
34
|
+
* percentage was found AND it reaches `minPercent` (inclusive); every other case is a failure with
|
|
35
|
+
* a reason, so an unparseable output or a crashed run is never silently verified.
|
|
36
|
+
*/
|
|
37
|
+
export declare function judgeCoverage(spec: CoverageSpec, run: {
|
|
38
|
+
exitCode: number;
|
|
39
|
+
output: string;
|
|
40
|
+
}): CoverageJudgement;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const ANSI = /\u001b\[[0-9;]*[A-Za-z]/g;
|
|
2
|
+
const NUMBER = String.raw `(\d{1,3}(?:\.\d+)?)`;
|
|
3
|
+
const valid = (value) => Number.isFinite(value) && value >= 0 && value <= 100;
|
|
4
|
+
const lastMatch = (text, pattern) => {
|
|
5
|
+
let found;
|
|
6
|
+
for (const match of text.matchAll(pattern))
|
|
7
|
+
found = match;
|
|
8
|
+
return found;
|
|
9
|
+
};
|
|
10
|
+
function pythonTotal(text) {
|
|
11
|
+
// pytest-cov prints the precise figure on its own line; prefer it over the rounded TOTAL row.
|
|
12
|
+
const line = lastMatch(text, new RegExp(String.raw `Total coverage:\s*${NUMBER}%`, "gi"));
|
|
13
|
+
if (line?.[1])
|
|
14
|
+
return Number(line[1]);
|
|
15
|
+
const row = lastMatch(text, new RegExp(String.raw `^\s*TOTAL\b[^\n]*?(?<![\d.])${NUMBER}%[ \t]*$`, "gim"));
|
|
16
|
+
return row?.[1] ? Number(row[1]) : undefined;
|
|
17
|
+
}
|
|
18
|
+
function tableTotal(text) {
|
|
19
|
+
const lines = text.split("\n");
|
|
20
|
+
let column = -1;
|
|
21
|
+
let result;
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
if (!line.includes("|"))
|
|
24
|
+
continue;
|
|
25
|
+
const cells = line.split("|").map((cell) => cell.trim());
|
|
26
|
+
const header = cells.findIndex((cell) => /^%\s*lines?$/i.test(cell));
|
|
27
|
+
if (header >= 0) {
|
|
28
|
+
column = header;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (column < 0 || !/^all files$/i.test(cells[0] ?? ""))
|
|
32
|
+
continue;
|
|
33
|
+
const raw = (cells[column] ?? "").replace(/%$/, "");
|
|
34
|
+
result = /^\d{1,3}(?:\.\d+)?$/.test(raw) ? Number(raw) : undefined;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
function summaryLines(text) {
|
|
39
|
+
const row = lastMatch(text, new RegExp(String.raw `^\s*Lines\s*:\s*${NUMBER}%`, "gim"));
|
|
40
|
+
return row?.[1] ? Number(row[1]) : undefined;
|
|
41
|
+
}
|
|
42
|
+
function lcovSummary(text) {
|
|
43
|
+
const row = lastMatch(text, new RegExp(String.raw `^\s*lines\.+:\s*${NUMBER}%`, "gim"));
|
|
44
|
+
return row?.[1] ? Number(row[1]) : undefined;
|
|
45
|
+
}
|
|
46
|
+
function lcovTracefile(text) {
|
|
47
|
+
let found = 0;
|
|
48
|
+
let hit = 0;
|
|
49
|
+
for (const match of text.matchAll(/^(LF|LH):(\d+)\s*$/gm)) {
|
|
50
|
+
if (match[1] === "LF")
|
|
51
|
+
found += Number(match[2]);
|
|
52
|
+
else
|
|
53
|
+
hit += Number(match[2]);
|
|
54
|
+
}
|
|
55
|
+
return found > 0 ? (hit / found) * 100 : undefined;
|
|
56
|
+
}
|
|
57
|
+
const PARSERS = [
|
|
58
|
+
["coverage.py", pythonTotal],
|
|
59
|
+
["table", tableTotal],
|
|
60
|
+
["summary", summaryLines],
|
|
61
|
+
["lcov-summary", lcovSummary],
|
|
62
|
+
["lcov", lcovTracefile],
|
|
63
|
+
];
|
|
64
|
+
/** The overall coverage percentage a command printed, or undefined when there is none to trust. */
|
|
65
|
+
export function parseCoverage(output) {
|
|
66
|
+
const text = output.replace(ANSI, "");
|
|
67
|
+
for (const [format, parse] of PARSERS) {
|
|
68
|
+
const percent = parse(text);
|
|
69
|
+
if (percent !== undefined && valid(percent))
|
|
70
|
+
return { percent, format };
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const shown = (value) => String(Math.round(value * 100) / 100);
|
|
75
|
+
/**
|
|
76
|
+
* Judges what the coverage command did. It verifies only when the command exited 0 AND an overall
|
|
77
|
+
* percentage was found AND it reaches `minPercent` (inclusive); every other case is a failure with
|
|
78
|
+
* a reason, so an unparseable output or a crashed run is never silently verified.
|
|
79
|
+
*/
|
|
80
|
+
export function judgeCoverage(spec, run) {
|
|
81
|
+
const parsed = parseCoverage(run.output);
|
|
82
|
+
if (!parsed)
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
summary: `no coverage percentage found in the output (command exited ${run.exitCode}); expected lcov, a vitest/istanbul table or coverage.py report`,
|
|
86
|
+
};
|
|
87
|
+
const percent = `${shown(parsed.percent)}%`;
|
|
88
|
+
if (run.exitCode !== 0)
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
summary: `the coverage command exited ${run.exitCode} (reported ${percent}, ${parsed.format})`,
|
|
92
|
+
};
|
|
93
|
+
if (parsed.percent < spec.minPercent)
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
summary: `coverage ${percent} is below the minimum ${shown(spec.minPercent)}% (${parsed.format})`,
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
ok: true,
|
|
100
|
+
summary: `coverage ${percent} reaches the minimum ${shown(spec.minPercent)}% (${parsed.format})`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claim/evidence divergence (specs/011 3.5, D7, D8). `detectDivergence` is pure: it reads closed
|
|
3
|
+
* EN/ES claim patterns in a final answer ("tests pass", "typecheck ok", "lint clean", "los tests
|
|
4
|
+
* pasan"...), cross-references them with the criteria of an equivalent kind and reports the ones
|
|
5
|
+
* the ledger does not back. It never edits text or statuses; it is a heuristic signal, not a
|
|
6
|
+
* guarantee, and claims inside quotes and code are ignored to limit false positives.
|
|
7
|
+
*/
|
|
8
|
+
import type { ProofLedger, ProofStatus } from "@alisio/sdk";
|
|
9
|
+
export type ClaimKind = "test" | "typecheck" | "lint";
|
|
10
|
+
export interface Divergence {
|
|
11
|
+
/** The matched wording, whitespace-collapsed and capped. */
|
|
12
|
+
claim: string;
|
|
13
|
+
kind: ClaimKind;
|
|
14
|
+
/** What would have backed the claim. */
|
|
15
|
+
expected: "verified";
|
|
16
|
+
/** The status of the equivalent criterion (`not_checked` when there is none). */
|
|
17
|
+
actualState: ProofStatus;
|
|
18
|
+
criterionId?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The claims of `finalText` that `ledger` does not back, in order of appearance. An empty ledger
|
|
22
|
+
* yields nothing (D7: without criteria Proof stays out of the way).
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectDivergence(finalText: string, ledger: ProofLedger): Divergence[];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** Only this much of the answer is read: the cost is bounded whatever the model writes. */
|
|
2
|
+
const MAX_TEXT = 100_000;
|
|
3
|
+
const MAX_CLAIM = 200;
|
|
4
|
+
const H = "[ \\t]+";
|
|
5
|
+
const SUBJECTS = {
|
|
6
|
+
test: `(?:(?:unit|integration|e2e)${H})?tests?(?:${H}suite)?(?:${H}(?:unitarios|unitarias|e2e|de${H}integración))?|(?:suite${H}de${H})?(?:tests|pruebas)(?:${H}(?:unitarias|de${H}integración))?`,
|
|
7
|
+
typecheck: `type${H}?check(?:ing|s)?|typecheck(?:ing)?|(?:verificación|chequeo|comprobación)${H}de${H}tipos|tsc`,
|
|
8
|
+
lint: "lint(?:ing|er)?",
|
|
9
|
+
};
|
|
10
|
+
const ARTICLE = `(?:(?:the|el|la|los|las)${H})?`;
|
|
11
|
+
const SUBJECT = `${ARTICLE}(?:${Object.values(SUBJECTS).join("|")})`;
|
|
12
|
+
const SEPARATOR = `[ \\t]*,[ \\t]*(?:(?:and|y)${H})?|${H}(?:and|y)${H}|[ \\t]*&[ \\t]*`;
|
|
13
|
+
const FILLER = `(?:is|are|now|also|all|both|still|were|have|has|been|ya|ahora|todos|todas|están|está|esta|siguen)`;
|
|
14
|
+
const VERDICT = `(?:passe[sd]|pass(?:ing)?|ok|okay|clean|green|succeed(?:s|ed)?|successful|fine|pasan|pasa|pasó|pasaron|pasando|limpi[oa]s?|verdes?|en${H}verde|correct[oa]s?|bien)`;
|
|
15
|
+
const CLAIM = new RegExp(`(?<![\\p{L}\\p{N}])(?:(?:all|todos|todas)${H})?(?:(?:the|los|las|el|la)${H})?` +
|
|
16
|
+
`((${SUBJECT}(?:(?:${SEPARATOR})${SUBJECT}){0,4})(?:${H}${FILLER})*${H}${VERDICT})` +
|
|
17
|
+
"(?![\\p{L}\\p{N}])", "giu");
|
|
18
|
+
const KIND_IN_LIST = Object.entries(SUBJECTS).map(([kind, source]) => [
|
|
19
|
+
kind,
|
|
20
|
+
new RegExp(`(?<![\\p{L}\\p{N}])(?:${source})(?![\\p{L}\\p{N}])`, "iu"),
|
|
21
|
+
]);
|
|
22
|
+
const WORD = (alternatives) => new RegExp(`(?<![\\p{L}\\p{N}])(?:${alternatives})(?![\\p{L}\\p{N}])|\\p{L}+n['’]t`, "iu");
|
|
23
|
+
/** Words that make a following "tests pass" a wish, a condition, an instruction or a denial. */
|
|
24
|
+
const HEDGE = WORD([
|
|
25
|
+
"not|never|no|cannot|unable|if|once|until|unless|whether|when|whenever|should|shall|would|could|might|may",
|
|
26
|
+
"hope|hoping|expect|expecting|need|needs|want|ensure|ensuring|make|makes|making|get|gets|getting",
|
|
27
|
+
"verify|confirm|check|let's|lets",
|
|
28
|
+
"nunca|jamás|sin|si|cuando|hasta|salvo|debe|deben|debería|deberían|espero|esperamos|quiero",
|
|
29
|
+
"necesito|necesitamos|asegura|asegúrate|asegurar|verifica|verificar|comprueba|comprobar",
|
|
30
|
+
"confirma|confirmar|hacer|haz",
|
|
31
|
+
].join("|"));
|
|
32
|
+
const CLAUSE_BOUNDARY = /[.!?;:\n]|(?<![\p{L}\p{N}])(?:but|however|pero|aunque|sin embargo)(?![\p{L}\p{N}])/giu;
|
|
33
|
+
const CLAUSE_WINDOW = 60;
|
|
34
|
+
/** Removes what quotes rather than asserts: code (fenced, inline), block quotes and quoted text. */
|
|
35
|
+
function unquoted(text) {
|
|
36
|
+
return text
|
|
37
|
+
.slice(0, MAX_TEXT)
|
|
38
|
+
.replace(/(```|~~~)[\s\S]*?\1/g, "\n")
|
|
39
|
+
.replace(/(```|~~~)[\s\S]*$/, "\n")
|
|
40
|
+
.replace(/^[ \t]*>.*$/gm, "")
|
|
41
|
+
.replace(/`[^`\n]{0,300}`/g, " ")
|
|
42
|
+
.replace(/"[^"\n]{0,300}"|“[^”\n]{0,300}”|«[^»\n]{0,300}»|‘[^’\n]{0,300}’/g, " ");
|
|
43
|
+
}
|
|
44
|
+
/** Whether the words just before `index`, inside its clause, hedge or deny the claim. */
|
|
45
|
+
function hedged(text, index) {
|
|
46
|
+
const before = text.slice(Math.max(0, index - 200), index);
|
|
47
|
+
let start = 0;
|
|
48
|
+
for (const boundary of before.matchAll(CLAUSE_BOUNDARY))
|
|
49
|
+
start = boundary.index + boundary[0].length;
|
|
50
|
+
return HEDGE.test(before.slice(start).slice(-CLAUSE_WINDOW));
|
|
51
|
+
}
|
|
52
|
+
function claimsIn(finalText) {
|
|
53
|
+
const text = unquoted(finalText);
|
|
54
|
+
const found = [];
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const match of text.matchAll(CLAIM)) {
|
|
57
|
+
if (hedged(text, match.index))
|
|
58
|
+
continue;
|
|
59
|
+
const wording = (match[1] ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_CLAIM);
|
|
60
|
+
for (const [kind, pattern] of KIND_IN_LIST) {
|
|
61
|
+
if (seen.has(kind) || !pattern.test(match[2] ?? ""))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(kind);
|
|
64
|
+
found.push({ claim: wording, kind });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
const TYPECHECK_CMD = /(?<![\w-])(?:tsc|typecheck|type-check|check-types|mypy|pyright)(?![\w-])/i;
|
|
70
|
+
const LINT_CMD = /(?<![\w-])(?:lint|eslint|biome|ruff|flake8|pylint|clippy|golangci-lint|stylelint|oxlint)(?![\w-])/i;
|
|
71
|
+
const TEST_CMD = /(?<![\w-])(?:tests?|vitest|jest|pytest|mocha|rspec)(?![\w-])/i;
|
|
72
|
+
const TYPECHECK_TEXT = /(?<![\p{L}\p{N}])type[ -]?check|tipos(?![\p{L}\p{N}])/iu;
|
|
73
|
+
const LINT_TEXT = /(?<![\p{L}\p{N}])lint(?:ing|er)?(?![\p{L}\p{N}])/iu;
|
|
74
|
+
const TEST_TEXT = /(?<![\p{L}\p{N}])(?:tests?|pruebas)(?![\p{L}\p{N}])/iu;
|
|
75
|
+
/** The kind of check a criterion stands for: its command when it has one, else its text. */
|
|
76
|
+
function kindOf(criterion) {
|
|
77
|
+
const verifier = criterion.verifier;
|
|
78
|
+
if (verifier && (verifier.kind === "command" || verifier.kind === "coverage")) {
|
|
79
|
+
if (TYPECHECK_CMD.test(verifier.command))
|
|
80
|
+
return "typecheck";
|
|
81
|
+
if (LINT_CMD.test(verifier.command))
|
|
82
|
+
return "lint";
|
|
83
|
+
if (TEST_CMD.test(verifier.command))
|
|
84
|
+
return "test";
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (TYPECHECK_TEXT.test(criterion.text))
|
|
88
|
+
return "typecheck";
|
|
89
|
+
if (LINT_TEXT.test(criterion.text))
|
|
90
|
+
return "lint";
|
|
91
|
+
if (TEST_TEXT.test(criterion.text))
|
|
92
|
+
return "test";
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The claims of `finalText` that `ledger` does not back, in order of appearance. An empty ledger
|
|
97
|
+
* yields nothing (D7: without criteria Proof stays out of the way).
|
|
98
|
+
*/
|
|
99
|
+
export function detectDivergence(finalText, ledger) {
|
|
100
|
+
if (ledger.criteria.length === 0)
|
|
101
|
+
return [];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const { claim, kind } of claimsIn(finalText)) {
|
|
104
|
+
const equivalent = ledger.criteria.filter((c) => c.status !== "not_applicable" && kindOf(c) === kind);
|
|
105
|
+
if (equivalent.length === 0) {
|
|
106
|
+
out.push({ claim, kind, expected: "verified", actualState: "not_checked" });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const c of equivalent)
|
|
110
|
+
if (c.status !== "verified")
|
|
111
|
+
out.push({
|
|
112
|
+
claim,
|
|
113
|
+
kind,
|
|
114
|
+
expected: "verified",
|
|
115
|
+
actualState: c.status,
|
|
116
|
+
criterionId: c.criterionId,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The surfaces of Proof (specs/011 phase 5) read ONE ledger and share these pure helpers: the
|
|
3
|
+
* versioned `proof/v1` export, the Markdown report, the CI exit code and the parsing of criteria
|
|
4
|
+
* a user or a CLI flag supplies. Nothing here touches a database or a clock.
|
|
5
|
+
*/
|
|
6
|
+
import type { ArtifactPublisher, ArtifactRef, ProofCriterion, ProofEvidence, ProofLedger } from "@alisio/sdk";
|
|
7
|
+
import type { NewCriterion } from "./types.ts";
|
|
8
|
+
/** The `schema` of the JSON export. A breaking change to the shape is a new version. */
|
|
9
|
+
export declare const PROOF_EXPORT_SCHEMA = "proof/v1";
|
|
10
|
+
export interface ProofSummary {
|
|
11
|
+
total: number;
|
|
12
|
+
required: number;
|
|
13
|
+
/** Required criteria that are `failed`, `not_checked` or `manual_review`. */
|
|
14
|
+
requiredUnmet: number;
|
|
15
|
+
verified: number;
|
|
16
|
+
failed: number;
|
|
17
|
+
notChecked: number;
|
|
18
|
+
manualReview: number;
|
|
19
|
+
notApplicable: number;
|
|
20
|
+
}
|
|
21
|
+
/** The JSON export (`proof.json`): one object, fixed key order, versioned. */
|
|
22
|
+
export interface ProofExportV1 {
|
|
23
|
+
schema: typeof PROOF_EXPORT_SCHEMA;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
generatedAt: string;
|
|
26
|
+
summary: ProofSummary;
|
|
27
|
+
criteria: ProofCriterion[];
|
|
28
|
+
evidence: ProofEvidence[];
|
|
29
|
+
divergences: ProofLedger["divergences"];
|
|
30
|
+
}
|
|
31
|
+
export declare function proofSummary(ledger: ProofLedger): ProofSummary;
|
|
32
|
+
export declare function proofExportV1(ledger: ProofLedger): ProofExportV1;
|
|
33
|
+
/** `proof.json`: pretty JSON ending in a newline. */
|
|
34
|
+
export declare const renderProofJson: (ledger: ProofLedger) => string;
|
|
35
|
+
/** `proof.md`: the same statuses as the JSON, readable. The output excerpts are not repeated. */
|
|
36
|
+
export declare function renderProofMarkdown(ledger: ProofLedger): string;
|
|
37
|
+
/** The plain-text view of the ledger (terminal and headless report): one line per criterion. */
|
|
38
|
+
export declare function renderProofText(ledger: ProofLedger): string;
|
|
39
|
+
export interface ProofExitCodes {
|
|
40
|
+
/** At least one required criterion is `failed`. */
|
|
41
|
+
failed: number;
|
|
42
|
+
/** No failure, but a required criterion is `not_checked` or `manual_review`. */
|
|
43
|
+
unverified: number;
|
|
44
|
+
}
|
|
45
|
+
export declare const DEFAULT_PROOF_EXIT_CODES: ProofExitCodes;
|
|
46
|
+
/** The CI exit code of a ledger (specs/011 3.7): failures win over unchecked criteria. */
|
|
47
|
+
export declare function proofExitCode(ledger: ProofLedger, codes?: ProofExitCodes): number;
|
|
48
|
+
/** Publishes `proof.md` and `proof.json` as artifacts of the calling session. */
|
|
49
|
+
export declare function publishProofExport(publisher: Pick<ArtifactPublisher, "publishText">, ledger: ProofLedger): Promise<ArtifactRef[]>;
|
|
50
|
+
export interface CriteriaInputError {
|
|
51
|
+
index: number;
|
|
52
|
+
reason: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Turns user-supplied criteria (HTTP body, `--proof-file`, `--criterion/--verify`) into
|
|
56
|
+
* `NewCriterion` values without a session. `verify` is shorthand for a `command` verifier and the
|
|
57
|
+
* default for `required` is true: a criterion the user wrote is one the user wants met.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseCriteriaInput(raw: unknown, options: {
|
|
60
|
+
origin: "user" | "cli";
|
|
61
|
+
defaultRequired?: boolean;
|
|
62
|
+
}): {
|
|
63
|
+
criteria: Array<Omit<NewCriterion, "sessionId">>;
|
|
64
|
+
errors: CriteriaInputError[];
|
|
65
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { validateCriteriaDrafts } from "../plan/criteria.js";
|
|
2
|
+
/** The `schema` of the JSON export. A breaking change to the shape is a new version. */
|
|
3
|
+
export const PROOF_EXPORT_SCHEMA = "proof/v1";
|
|
4
|
+
const UNMET = new Set(["failed", "not_checked", "manual_review"]);
|
|
5
|
+
export function proofSummary(ledger) {
|
|
6
|
+
const count = (status) => ledger.criteria.filter((c) => c.status === status).length;
|
|
7
|
+
return {
|
|
8
|
+
total: ledger.criteria.length,
|
|
9
|
+
required: ledger.criteria.filter((c) => c.required).length,
|
|
10
|
+
requiredUnmet: ledger.criteria.filter((c) => c.required && UNMET.has(c.status)).length,
|
|
11
|
+
verified: count("verified"),
|
|
12
|
+
failed: count("failed"),
|
|
13
|
+
notChecked: count("not_checked"),
|
|
14
|
+
manualReview: count("manual_review"),
|
|
15
|
+
notApplicable: count("not_applicable"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function proofExportV1(ledger) {
|
|
19
|
+
return {
|
|
20
|
+
schema: PROOF_EXPORT_SCHEMA,
|
|
21
|
+
sessionId: ledger.sessionId,
|
|
22
|
+
generatedAt: ledger.generatedAt,
|
|
23
|
+
summary: proofSummary(ledger),
|
|
24
|
+
criteria: ledger.criteria,
|
|
25
|
+
evidence: ledger.evidence,
|
|
26
|
+
divergences: ledger.divergences,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** `proof.json`: pretty JSON ending in a newline. */
|
|
30
|
+
export const renderProofJson = (ledger) => `${JSON.stringify(proofExportV1(ledger), null, 2)}\n`;
|
|
31
|
+
const cell = (text) => text.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim();
|
|
32
|
+
/** `proof.md`: the same statuses as the JSON, readable. The output excerpts are not repeated. */
|
|
33
|
+
export function renderProofMarkdown(ledger) {
|
|
34
|
+
const s = proofSummary(ledger);
|
|
35
|
+
const byId = new Map(ledger.evidence.map((e) => [e.evidenceId, e]));
|
|
36
|
+
const lines = [
|
|
37
|
+
"# Proof",
|
|
38
|
+
"",
|
|
39
|
+
`Session \`${ledger.sessionId}\` · generated ${ledger.generatedAt} · schema \`${PROOF_EXPORT_SCHEMA}\``,
|
|
40
|
+
"",
|
|
41
|
+
`${s.total} criteria · ${s.verified} verified · ${s.failed} failed · ${s.notChecked} not checked · ` +
|
|
42
|
+
`${s.manualReview} manual review · ${s.notApplicable} not applicable · ` +
|
|
43
|
+
`${s.requiredUnmet} required not verified`,
|
|
44
|
+
"",
|
|
45
|
+
];
|
|
46
|
+
if (!ledger.criteria.length)
|
|
47
|
+
lines.push("_No criteria._", "");
|
|
48
|
+
else {
|
|
49
|
+
lines.push("| Status | Required | Criterion | Evidence |", "| --- | --- | --- | --- |");
|
|
50
|
+
for (const c of ledger.criteria) {
|
|
51
|
+
const latest = [...c.evidenceIds]
|
|
52
|
+
.map((id) => byId.get(id))
|
|
53
|
+
.filter((e) => !!e)
|
|
54
|
+
.at(-1);
|
|
55
|
+
const status = c.stale ? `${c.status} (stale)` : c.status;
|
|
56
|
+
const evidence = latest
|
|
57
|
+
? `${latest.callId} · exit ${latest.exitCode ?? "-"} · ${latest.capturedAt}`
|
|
58
|
+
: c.notApplicableReason
|
|
59
|
+
? `n/a: ${c.notApplicableReason}`
|
|
60
|
+
: "-";
|
|
61
|
+
lines.push(`| ${status} | ${c.required ? "yes" : "no"} | ${cell(c.text)} | ${cell(evidence)} |`);
|
|
62
|
+
}
|
|
63
|
+
lines.push("");
|
|
64
|
+
}
|
|
65
|
+
if (ledger.divergences.length) {
|
|
66
|
+
lines.push("## Claims without evidence", "");
|
|
67
|
+
for (const d of ledger.divergences)
|
|
68
|
+
lines.push(`- "${cell(d.claim)}" (${d.actual})`);
|
|
69
|
+
lines.push("");
|
|
70
|
+
}
|
|
71
|
+
lines.push("_Proof records operational evidence; it does not prove that the code is correct._", "");
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
/** The plain-text view of the ledger (terminal and headless report): one line per criterion. */
|
|
75
|
+
export function renderProofText(ledger) {
|
|
76
|
+
const s = proofSummary(ledger);
|
|
77
|
+
const head = !s.total
|
|
78
|
+
? "Proof: no criteria"
|
|
79
|
+
: `Proof: ${s.verified} of ${s.total} criteria verified` +
|
|
80
|
+
(s.required ? ` · ${s.requiredUnmet} of ${s.required} required not verified` : "");
|
|
81
|
+
const lines = [head];
|
|
82
|
+
const width = Math.max(0, ...ledger.criteria.map((c) => statusLabel(c).length));
|
|
83
|
+
for (const c of ledger.criteria)
|
|
84
|
+
lines.push(` ${statusLabel(c).padEnd(width)} ${c.text.replace(/\s+/g, " ").trim()}${c.required ? " (required)" : ""}`);
|
|
85
|
+
for (const d of ledger.divergences)
|
|
86
|
+
lines.push(` ! claim without evidence: "${d.claim.replace(/\s+/g, " ").trim()}" (${d.actual})`);
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
|
89
|
+
const statusLabel = (c) => (c.stale ? `${c.status}(stale)` : c.status);
|
|
90
|
+
export const DEFAULT_PROOF_EXIT_CODES = { failed: 10, unverified: 11 };
|
|
91
|
+
/** The CI exit code of a ledger (specs/011 3.7): failures win over unchecked criteria. */
|
|
92
|
+
export function proofExitCode(ledger, codes = DEFAULT_PROOF_EXIT_CODES) {
|
|
93
|
+
const required = ledger.criteria.filter((c) => c.required);
|
|
94
|
+
if (required.some((c) => c.status === "failed"))
|
|
95
|
+
return codes.failed;
|
|
96
|
+
if (required.some((c) => c.status === "not_checked" || c.status === "manual_review"))
|
|
97
|
+
return codes.unverified;
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
/** Publishes `proof.md` and `proof.json` as artifacts of the calling session. */
|
|
101
|
+
export async function publishProofExport(publisher, ledger) {
|
|
102
|
+
const md = await publisher.publishText({
|
|
103
|
+
fileName: "proof.md",
|
|
104
|
+
title: "Proof",
|
|
105
|
+
text: renderProofMarkdown(ledger),
|
|
106
|
+
});
|
|
107
|
+
const json = await publisher.publishText({
|
|
108
|
+
fileName: "proof.json",
|
|
109
|
+
title: "Proof (JSON)",
|
|
110
|
+
text: renderProofJson(ledger),
|
|
111
|
+
});
|
|
112
|
+
return [md, json];
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Turns user-supplied criteria (HTTP body, `--proof-file`, `--criterion/--verify`) into
|
|
116
|
+
* `NewCriterion` values without a session. `verify` is shorthand for a `command` verifier and the
|
|
117
|
+
* default for `required` is true: a criterion the user wrote is one the user wants met.
|
|
118
|
+
*/
|
|
119
|
+
export function parseCriteriaInput(raw, options) {
|
|
120
|
+
const list = Array.isArray(raw) ? raw : [];
|
|
121
|
+
const normalized = list.map((entry) => {
|
|
122
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
|
|
123
|
+
return entry;
|
|
124
|
+
const e = entry;
|
|
125
|
+
const { verify, ...rest } = e;
|
|
126
|
+
return {
|
|
127
|
+
...rest,
|
|
128
|
+
required: e.required === undefined ? (options.defaultRequired ?? true) : e.required,
|
|
129
|
+
...(typeof verify === "string" && e.verifier === undefined
|
|
130
|
+
? { verifier: { kind: "command", command: verify } }
|
|
131
|
+
: {}),
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
if (!Array.isArray(raw))
|
|
135
|
+
return { criteria: [], errors: [{ index: 0, reason: "criteria must be an array" }] };
|
|
136
|
+
const checked = validateCriteriaDrafts(normalized);
|
|
137
|
+
return {
|
|
138
|
+
criteria: checked.accepted.map(({ origin: _inferred, ...draft }) => ({
|
|
139
|
+
...draft,
|
|
140
|
+
origin: options.origin,
|
|
141
|
+
})),
|
|
142
|
+
errors: checked.dropped.map((d) => ({ index: d.index, reason: d.reason })),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./command.ts";
|
|
2
|
+
export * from "./divergence.ts";
|
|
3
|
+
export * from "./export.ts";
|
|
4
|
+
export * from "./ledger.ts";
|
|
5
|
+
export * from "./plugin-verifiers.ts";
|
|
6
|
+
export * from "./recorder.ts";
|
|
7
|
+
export * from "./scope.ts";
|
|
8
|
+
export * from "./store.ts";
|
|
9
|
+
export * from "./tree.ts";
|
|
10
|
+
export * from "./types.ts";
|
|
11
|
+
export * from "./verifiers.ts";
|
|
12
|
+
export * from "./verify.ts";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./command.js";
|
|
2
|
+
export * from "./divergence.js";
|
|
3
|
+
export * from "./export.js";
|
|
4
|
+
export * from "./ledger.js";
|
|
5
|
+
export * from "./plugin-verifiers.js";
|
|
6
|
+
export * from "./recorder.js";
|
|
7
|
+
export * from "./scope.js";
|
|
8
|
+
export * from "./store.js";
|
|
9
|
+
export * from "./tree.js";
|
|
10
|
+
export * from "./types.js";
|
|
11
|
+
export * from "./verifiers.js";
|
|
12
|
+
export * from "./verify.js";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure derivation of the Proof ledger (specs/011 section 3.2). Nothing here reads a clock, a
|
|
3
|
+
* database or the filesystem: the same inputs always give the same statuses (D4).
|
|
4
|
+
*/
|
|
5
|
+
import type { ProofEvidence, ProofLedger, ProofStatus, ProofVerifierSpec } from "@alisio/sdk";
|
|
6
|
+
import type { CriterionRecord } from "./types.ts";
|
|
7
|
+
export interface DerivedStatus {
|
|
8
|
+
status: ProofStatus;
|
|
9
|
+
/** True when the criterion had successful evidence that a later edit in its scope invalidated. */
|
|
10
|
+
stale: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface DeriveOptions {
|
|
13
|
+
/** ISO time of the latest edit inside the criterion scope (D5), when there was one. */
|
|
14
|
+
lastEditAt?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Derives the status of one criterion from its own evidence.
|
|
18
|
+
*
|
|
19
|
+
* - user `not_applicable` wins; no verifier is `not_checked` whatever evidence exists (D1);
|
|
20
|
+
* a `manual` verifier is `manual_review`.
|
|
21
|
+
* - the latest backed evidence decides (later `capturedAt` wins; ties go to the later entry); a
|
|
22
|
+
* latest evidence of kind `manual` means a verifier degraded the criterion to `manual_review`;
|
|
23
|
+
* - an edit at or after that evidence expires it (D5, fail safe on equal instants): an expired
|
|
24
|
+
* success is `not_checked` and stale, an expired failure is just `not_checked`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function deriveStatus(criterion: {
|
|
27
|
+
verifier?: ProofVerifierSpec;
|
|
28
|
+
notApplicableReason?: string;
|
|
29
|
+
}, evidence: readonly ProofEvidence[], options?: DeriveOptions): DerivedStatus;
|
|
30
|
+
export interface BuildLedgerOptions {
|
|
31
|
+
/** ISO time stamped on the ledger; the caller owns the clock. */
|
|
32
|
+
now: string;
|
|
33
|
+
/** Latest edit time inside the scope of a criterion (Fase 3 supplies it). */
|
|
34
|
+
lastEditAt?: (criterion: CriterionRecord) => string | undefined;
|
|
35
|
+
divergences?: ProofLedger["divergences"];
|
|
36
|
+
}
|
|
37
|
+
/** Assembles the ledger of one session: every criterion derived from its own evidence. */
|
|
38
|
+
export declare function buildLedger(sessionId: string, criteria: readonly CriterionRecord[], evidence: readonly ProofEvidence[], options: BuildLedgerOptions): ProofLedger;
|