@supersuit/hyperspec 0.5.0 → 0.7.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/CHANGELOG.md +166 -0
- package/README.md +62 -1
- package/SPEC.md +4 -4
- package/WRITING.md +770 -9
- package/bin/hyperspec.mjs +252 -0
- package/examples/writing/essay/claims.jsonl +9 -0
- package/examples/writing/essay/draft.md +82 -0
- package/examples/writing/essay/judge/doctor.packet.json +108 -0
- package/examples/writing/essay/judge/lineup.packet.json +64 -0
- package/examples/writing/essay/judge/persona.packet.json +73 -0
- package/examples/writing/essay/judge/reader.packet.json +93 -0
- package/examples/writing/essay/learn/first-draft.md +84 -0
- package/examples/writing/essay/learn/learn.packet.json +106 -0
- package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +2 -2
- package/examples/writing/essay/sample-verdicts/doctor.verdict.json +43 -0
- package/examples/writing/essay/sample-verdicts/learn.verdict.json +30 -0
- package/examples/writing/essay/sample-verdicts/lineup.verdict.json +6 -0
- package/examples/writing/essay/sample-verdicts/persona.verdict.json +4 -0
- package/examples/writing/essay/sample-verdicts/reader.verdict.json +7 -0
- package/examples/writing/essay.hyperspec.md +23 -7
- package/examples/writing/story/claims.jsonl +9 -0
- package/examples/writing/story/draft.md +267 -0
- package/examples/writing/story/judge/attribution.packet.json +194 -0
- package/examples/writing/story/judge/doctor.packet.json +108 -0
- package/examples/writing/story/judge/knowledge.packet.json +77 -0
- package/examples/writing/story/judge/persona.packet.json +73 -0
- package/examples/writing/story/judge/reader.packet.json +94 -0
- package/examples/writing/story/sample-verdicts/attribution.verdict.json +81 -0
- package/examples/writing/story/sample-verdicts/doctor.verdict.json +43 -0
- package/examples/writing/story/sample-verdicts/knowledge.verdict.json +4 -0
- package/examples/writing/story/sample-verdicts/persona.verdict.json +20 -0
- package/examples/writing/story/sample-verdicts/reader.verdict.json +16 -0
- package/examples/writing/story.hyperspec.md +24 -5
- package/package.json +1 -1
- package/src/check.mjs +191 -0
- package/src/dna.mjs +4 -1
- package/src/draft.mjs +26 -0
- package/src/judge.mjs +386 -0
- package/src/judges/attribution.mjs +360 -0
- package/src/judges/doctor.mjs +126 -0
- package/src/judges/index.mjs +31 -0
- package/src/judges/knowledge.mjs +111 -0
- package/src/judges/lineup.mjs +272 -0
- package/src/judges/persona.mjs +137 -0
- package/src/judges/reader.mjs +111 -0
- package/src/learn.mjs +422 -0
- package/src/ledger.mjs +108 -0
- package/src/sentences.mjs +81 -0
- package/src/stations/claims.mjs +155 -0
- package/src/stations/dna.mjs +126 -0
- package/src/stations/form.mjs +115 -0
- package/src/stations/index.mjs +27 -0
- package/src/stations/links.mjs +275 -0
- package/src/stations/private.mjs +117 -0
- package/src/stations/quotes.mjs +170 -0
- package/src/stations/terms.mjs +131 -0
- package/src/stations/util.mjs +99 -0
- package/src/writing-fields.mjs +26 -2
- package/src/writing.mjs +1 -1
package/src/check.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// `hyperspec check`: run every deterministic station against a draft, once the spec that
|
|
2
|
+
// declares them is itself lint-clean. This module is the command's logic, independent of the
|
|
3
|
+
// CLI's argv parsing and printing (bin/hyperspec.mjs owns those), the same split rules.mjs and
|
|
4
|
+
// score.mjs already keep for `lint`.
|
|
5
|
+
//
|
|
6
|
+
// A draft is never checked against a spec that is not ready to check anything against: the spec
|
|
7
|
+
// is linted first, and a spec that fails or is blocked runs no station at all, exiting with lint's
|
|
8
|
+
// own code (1 fail, 3 blocked) rather than a check-specific one. Passing lint's test 9 requires
|
|
9
|
+
// improvement.ledger to be a non-empty path (see
|
|
10
|
+
// src/rules.mjs), so by the time any station runs, the spec is guaranteed to declare one; the
|
|
11
|
+
// presence check and escape check in openLedger (src/ledger.mjs) exist anyway: defense in depth
|
|
12
|
+
// costs one branch and this file should never silently assume another file's invariant holds.
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, readFileSync } from "node:fs";
|
|
15
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
16
|
+
import { loadSpec } from "./load.mjs";
|
|
17
|
+
import { lintSpec } from "./rules.mjs";
|
|
18
|
+
import { score, exitCode } from "./score.mjs";
|
|
19
|
+
import { sha256 } from "./hash.mjs";
|
|
20
|
+
import { readDraft } from "./draft.mjs";
|
|
21
|
+
import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
|
|
22
|
+
import { STATIONS, STATION_NAMES } from "./stations/index.mjs";
|
|
23
|
+
import { str } from "./placeholder.mjs";
|
|
24
|
+
|
|
25
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
26
|
+
|
|
27
|
+
// A crash message with every absolute path in it made relative to the working directory, or cut to
|
|
28
|
+
// "<path>/<file name>" when it lies outside it, so a station that hits a filesystem error never
|
|
29
|
+
// prints this machine's layout.
|
|
30
|
+
export function withoutAbsolutePaths(message) {
|
|
31
|
+
return message.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`,)]+/g, (p) => {
|
|
32
|
+
if (!isAbsolute(p)) return p;
|
|
33
|
+
const rel = relative(process.cwd(), p);
|
|
34
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : `<path>/${basename(p)}`;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// runStation(station, spec, draft, ctx): runs one station's run(spec, draft, ctx), converting a
|
|
39
|
+
// throw into a single failing finding rather than letting it crash the whole command. A station
|
|
40
|
+
// is pure and deterministic BY CONTRACT, but that contract is not enforced by the type system,
|
|
41
|
+
// and later stations (terms, claims, quotes, private, dna) read JSONL ledgers, segments files
|
|
42
|
+
// and regexes over untrusted draft text, which is a lot more surface for a bug to throw from
|
|
43
|
+
// than form's own narrow reading. A throw here must never crash the whole command (no raw stack
|
|
44
|
+
// trace, no half-finished --json, no skipped ledger line): every OTHER station and the ledger
|
|
45
|
+
// write still run normally, the same way lintSpec's own crash in bin/hyperspec.mjs's `lint`
|
|
46
|
+
// handler becomes a reported error rather than an uncaught exception. Exported (and taking the
|
|
47
|
+
// station object rather than reading STATIONS itself) so this exact wrapping is testable against
|
|
48
|
+
// a station built to throw, without needing one registered in the shared registry
|
|
49
|
+
// (src/stations/index.mjs), which this file does not own.
|
|
50
|
+
export function runStation(station, spec, draft, ctx) {
|
|
51
|
+
try {
|
|
52
|
+
return station.run(spec, draft, ctx);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
const raw = e instanceof Error ? e.message : String(e);
|
|
55
|
+
return {
|
|
56
|
+
station: station.name,
|
|
57
|
+
status: "fail",
|
|
58
|
+
findings: [{
|
|
59
|
+
station: station.name,
|
|
60
|
+
id: `station-${station.name}-crashed`,
|
|
61
|
+
severity: "fail",
|
|
62
|
+
message: withoutAbsolutePaths(raw),
|
|
63
|
+
fix: "Fix the station or file an issue; it should never throw.",
|
|
64
|
+
}],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The spec at specPathArg, loaded and required to carry the writing profile: { spec }, or a usage
|
|
70
|
+
// result naming the command. Every station and every judge reads the writing profile's blocks; a
|
|
71
|
+
// spec without it has nothing for them to read, and a run against it would fail quotations it has
|
|
72
|
+
// no materials for.
|
|
73
|
+
export function loadWritingSpec(specPathArg, command) {
|
|
74
|
+
const spec = loadSpec(specPathArg);
|
|
75
|
+
if (spec.error) return { usage: true, error: spec.error };
|
|
76
|
+
if (str(spec.data?.profile) !== "writing") return { usage: true, error: `${command} needs a writing spec (profile: writing)` };
|
|
77
|
+
return { spec };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// null when the spec lints clean; otherwise the result a command returns instead of grading
|
|
81
|
+
// anything, carrying lint's own exit code (1 fail, 3 blocked): a draft is never graded against a
|
|
82
|
+
// spec that is not ready.
|
|
83
|
+
export function lintBlock(spec, specPathArg) {
|
|
84
|
+
const lintFindings = lintSpec(spec);
|
|
85
|
+
const lintScore = score(lintFindings, spec.data);
|
|
86
|
+
if (lintScore.status === "pass") return null;
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
lintBlocked: true,
|
|
90
|
+
specPath: specPathArg,
|
|
91
|
+
lintStatus: lintScore.status,
|
|
92
|
+
lintScore,
|
|
93
|
+
lintFindings,
|
|
94
|
+
code: exitCode(lintScore.status),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// runCheck(specPathArg, draftPathArg, { only }): specPathArg and draftPathArg are exactly what
|
|
99
|
+
// the CLI (or a caller) was given, never resolved, so every path this returns or writes to the
|
|
100
|
+
// ledger is displayed and recorded the way the operator typed it, not as an absolute path on this
|
|
101
|
+
// machine. only, when given, is an array of station names to run instead of every registered one.
|
|
102
|
+
export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
103
|
+
if (!present(specPathArg)) return { usage: true, error: "check needs a spec path" };
|
|
104
|
+
if (!present(draftPathArg)) return { usage: true, error: "check needs --draft <file>" };
|
|
105
|
+
|
|
106
|
+
const loaded = loadWritingSpec(specPathArg, "check");
|
|
107
|
+
if (loaded.usage) return loaded;
|
|
108
|
+
const { spec } = loaded;
|
|
109
|
+
|
|
110
|
+
// --only: every name must be one this build's registry knows; unknown names are a usage error
|
|
111
|
+
// (exit 2) rather than a silent no-op, and the run order always follows the registry, never the
|
|
112
|
+
// order --only happened to name them in, so two operators running the same --only string in a
|
|
113
|
+
// different order still see stations printed and ledgered identically.
|
|
114
|
+
let stationsToRun = STATIONS;
|
|
115
|
+
if (only && only.length) {
|
|
116
|
+
const unknown = only.filter((n) => !STATION_NAMES.includes(n));
|
|
117
|
+
if (unknown.length) {
|
|
118
|
+
return { usage: true, error: `unknown station${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}; known stations: ${STATION_NAMES.join(", ") || "(none)"}` };
|
|
119
|
+
}
|
|
120
|
+
stationsToRun = STATIONS.filter((s) => only.includes(s.name));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const blocked = lintBlock(spec, specPathArg);
|
|
124
|
+
if (blocked) return blocked;
|
|
125
|
+
|
|
126
|
+
// BOM stripped, CRLF-clean lines, sha256 over the raw bytes: see src/draft.mjs.
|
|
127
|
+
const draft = readDraft(draftPathArg);
|
|
128
|
+
if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg}` };
|
|
129
|
+
|
|
130
|
+
const ctx = {};
|
|
131
|
+
const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx));
|
|
132
|
+
const failing = results.filter((r) => r.status === "fail").map((r) => r.station);
|
|
133
|
+
const code = failing.length ? 1 : 0;
|
|
134
|
+
|
|
135
|
+
// ---- ledger: one line of evidence per check, only when the spec declares one -----------------
|
|
136
|
+
// A run with --only is partial: verdict not-improved, reason "partial run: <stations>",
|
|
137
|
+
// partial: true. Later verdicts ignore partial lines, so a subset never claims (or uses up) the
|
|
138
|
+
// verdict for the whole draft. A full run is compared with the most recent earlier FULL line for
|
|
139
|
+
// the same draft path; which verdict that earns, and why each reason is true, is
|
|
140
|
+
// ledgerVerdict's (src/ledger.mjs), shared with `judge record` so both follow one set of rules.
|
|
141
|
+
let ledgerPath = null;
|
|
142
|
+
let ledgerWarning = null;
|
|
143
|
+
let verdict = null;
|
|
144
|
+
let verdictDetail = {};
|
|
145
|
+
const partial = Boolean(only && only.length);
|
|
146
|
+
const ledger = openLedger(spec);
|
|
147
|
+
if (ledger?.warning) ledgerWarning = ledger.warning;
|
|
148
|
+
else if (ledger) {
|
|
149
|
+
const draftKey = ledgerDraftKey(spec.dir, draftPathArg);
|
|
150
|
+
const specSha = sha256(readFileSync(resolve(specPathArg)));
|
|
151
|
+
const statusNow = Object.fromEntries(results.map((r) => [r.station, r.status]));
|
|
152
|
+
|
|
153
|
+
if (partial) {
|
|
154
|
+
verdict = "not-improved";
|
|
155
|
+
verdictDetail.reason = `partial run: ${results.map((r) => r.station).join(", ")}`;
|
|
156
|
+
} else {
|
|
157
|
+
const last = priorLines(ledger.priorText, "check").filter((l) => l.draft === draftKey && l.partial !== true).at(-1);
|
|
158
|
+
({ verdict, detail: verdictDetail } = ledgerVerdict({ last, statusNow, draftSha: draft.sha256, specSha }));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const line = {
|
|
162
|
+
at: new Date().toISOString(),
|
|
163
|
+
kind: "check",
|
|
164
|
+
draft: draftKey,
|
|
165
|
+
draft_sha256: draft.sha256,
|
|
166
|
+
spec_sha256: specSha,
|
|
167
|
+
stations: statusNow,
|
|
168
|
+
...(partial ? { partial: true } : {}),
|
|
169
|
+
verdict,
|
|
170
|
+
...verdictDetail,
|
|
171
|
+
};
|
|
172
|
+
appendFileSync(ledger.abs, `${JSON.stringify(line)}\n`);
|
|
173
|
+
// Reported exactly as the spec wrote it (improvement.ledger's own string), never resolved.
|
|
174
|
+
ledgerPath = ledger.decl;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
specPath: specPathArg,
|
|
180
|
+
draftPath: draftPathArg,
|
|
181
|
+
draftSha256: draft.sha256,
|
|
182
|
+
stations: results,
|
|
183
|
+
failing,
|
|
184
|
+
partial,
|
|
185
|
+
verdict,
|
|
186
|
+
verdictDetail,
|
|
187
|
+
ledgerPath,
|
|
188
|
+
ledgerWarning,
|
|
189
|
+
code,
|
|
190
|
+
};
|
|
191
|
+
}
|
package/src/dna.mjs
CHANGED
|
@@ -251,7 +251,10 @@ function percentileNearestRank(sortedArr, p) {
|
|
|
251
251
|
// the token by this same rule; it is simply never "between letters", so it never makes the word
|
|
252
252
|
// count as a contraction below.
|
|
253
253
|
const WORD_RE = /[\p{L}\p{N}'\u2019]+/gu;
|
|
254
|
-
|
|
254
|
+
// Exported so a later reader of a whole document (the check command's "form" station counting a
|
|
255
|
+
// draft's words against writing.form.length) reuses this exact word definition rather than
|
|
256
|
+
// keeping a second one that could quietly disagree with it.
|
|
257
|
+
export function wordsOf(text) {
|
|
255
258
|
const m = text.match(WORD_RE);
|
|
256
259
|
return m ? m.map((w) => w.toLowerCase()) : [];
|
|
257
260
|
}
|
package/src/draft.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Reading a draft the way every command that grades one reads it (`hyperspec check`, `hyperspec
|
|
2
|
+
// judge prepare` and `judge record`), so a station and a judge see the same text for the same file.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { sha256 } from "./hash.mjs";
|
|
7
|
+
|
|
8
|
+
// 1-based line array: text.split("\n"), so array index i holds line i + 1. A trailing "\r" (a
|
|
9
|
+
// CRLF file) is stripped from every entry here, at the source, so every station that reads
|
|
10
|
+
// draft.lines sees a clean line ("# Claim", never "# Claim\r") without needing to know CRLF
|
|
11
|
+
// exists; the line COUNT and every 1-based line number are unaffected, since stripping a
|
|
12
|
+
// trailing byte from an entry never changes how many entries there are.
|
|
13
|
+
export function splitLines(text) {
|
|
14
|
+
return text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// { path, text, lines, sha256 } for the draft at draftPathArg (resolved against the working
|
|
18
|
+
// directory, recorded as given), or null when it cannot be read. One leading UTF-8 BOM is not part
|
|
19
|
+
// of the draft's text: stripped here, so a heading on line 1 is found and every offset and line
|
|
20
|
+
// number counts from the first real character. sha256 stays over the raw bytes.
|
|
21
|
+
export function readDraft(draftPathArg) {
|
|
22
|
+
let buf;
|
|
23
|
+
try { buf = readFileSync(resolve(draftPathArg)); } catch { return null; }
|
|
24
|
+
const text = buf.toString("utf8").replace(/^/, "");
|
|
25
|
+
return { path: draftPathArg, text, lines: splitLines(text), sha256: sha256(buf) };
|
|
26
|
+
}
|
package/src/judge.mjs
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// `hyperspec judge prepare` and `hyperspec judge record`: judgment stations as packets. hyperspec
|
|
2
|
+
// never calls a model. For each judgment station, `prepare` writes a PACKET: everything a judge
|
|
3
|
+
// needs (the rubric from the spec, fixed instructions, the inputs, the exact verdict shape). An
|
|
4
|
+
// outside judge, a person or a model, fills a VERDICT file. `record` validates the verdict,
|
|
5
|
+
// including that every evidence span it cites really appears in the draft, derives the station's
|
|
6
|
+
// status deterministically, and appends one line to the spec's runs ledger, under the same truth
|
|
7
|
+
// rules `check` follows (src/ledger.mjs).
|
|
8
|
+
//
|
|
9
|
+
// This module is the commands' logic; bin/hyperspec.mjs owns argv parsing and printing, the split
|
|
10
|
+
// src/check.mjs keeps too. Each station lives in src/judges/ and is registered in its index.
|
|
11
|
+
|
|
12
|
+
import { appendFileSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { sha256 } from "./hash.mjs";
|
|
15
|
+
import { writeFileAtomic } from "./fsutil.mjs";
|
|
16
|
+
import { readDraft } from "./draft.mjs";
|
|
17
|
+
import { loadWritingSpec, lintBlock, withoutAbsolutePaths } from "./check.mjs";
|
|
18
|
+
import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
|
|
19
|
+
import { lineAt, truncate } from "./stations/util.mjs";
|
|
20
|
+
import { JUDGES, JUDGE_NAMES } from "./judges/index.mjs";
|
|
21
|
+
|
|
22
|
+
export const PACKET_VERSION = "0.1";
|
|
23
|
+
|
|
24
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
25
|
+
|
|
26
|
+
// ---- the evidence rule -------------------------------------------------------------------------
|
|
27
|
+
// A span of evidence counts as quoted from the draft when, after both are normalized, the draft
|
|
28
|
+
// contains it. Normalization collapses every run of whitespace (spaces, tabs, line breaks, CRLF) to
|
|
29
|
+
// one space and turns curly, low and angle quotation marks and apostrophes into their straight
|
|
30
|
+
// forms (primes are not quotation marks and are left alone), so a judge that reflows a quotation or
|
|
31
|
+
// types typographic quotes is still quoting; changing a single word is not.
|
|
32
|
+
//
|
|
33
|
+
// A span must also carry at least MIN_EVIDENCE_WORDS word tokens (runs of letters and digits), and
|
|
34
|
+
// match on word boundaries: a match may not start or end in the middle of a word. A one-letter or
|
|
35
|
+
// one-word "quotation" is found almost anywhere and so checks nothing.
|
|
36
|
+
|
|
37
|
+
export const MIN_EVIDENCE_WORDS = 3;
|
|
38
|
+
const WORD_CHAR = /[\p{L}\p{N}]/u;
|
|
39
|
+
const WORD_TOKENS = /[\p{L}\p{N}]+/gu;
|
|
40
|
+
|
|
41
|
+
const QUOTE_CHARS = new Map([
|
|
42
|
+
["\u2018", "'"], ["\u2019", "'"], ["\u201A", "'"], ["\u201B", "'"], ["\u2039", "'"], ["\u203A", "'"],
|
|
43
|
+
["\u201C", '"'], ["\u201D", '"'], ["\u201E", '"'], ["\u201F", '"'], ["\u00AB", '"'], ["\u00BB", '"'],
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// { norm, map }: the normalized text, and for each of its characters the offset in `text` it came
|
|
47
|
+
// from, so a match in the normalized text can be traced back to a line of the original.
|
|
48
|
+
export function normalizeForEvidence(text) {
|
|
49
|
+
let norm = "";
|
|
50
|
+
const map = [];
|
|
51
|
+
let pendingSpace = -1;
|
|
52
|
+
for (let i = 0; i < text.length; i++) {
|
|
53
|
+
const ch = text[i];
|
|
54
|
+
if (/\s/.test(ch)) { if (norm && pendingSpace < 0) pendingSpace = i; continue; }
|
|
55
|
+
if (pendingSpace >= 0) { norm += " "; map.push(pendingSpace); pendingSpace = -1; }
|
|
56
|
+
norm += QUOTE_CHARS.get(ch) ?? ch;
|
|
57
|
+
map.push(i);
|
|
58
|
+
}
|
|
59
|
+
return { norm, map };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The helpers a station's validate() and derive() receive, bound to one packet and one draft: every
|
|
63
|
+
// finding they build has the same shape as a `check` finding ({ station, id, severity, message,
|
|
64
|
+
// fix, line? }).
|
|
65
|
+
function toolsFor(stationName, draft) {
|
|
66
|
+
const { norm, map } = normalizeForEvidence(draft.text);
|
|
67
|
+
// The 1-based line of the first whole-word match of `span`, or -1.
|
|
68
|
+
const locate = (span) => {
|
|
69
|
+
const needle = normalizeForEvidence(span).norm;
|
|
70
|
+
if (!needle) return -1;
|
|
71
|
+
const startsWord = WORD_CHAR.test(needle[0]);
|
|
72
|
+
const endsWord = WORD_CHAR.test(needle[needle.length - 1]);
|
|
73
|
+
for (let at = norm.indexOf(needle); at >= 0; at = norm.indexOf(needle, at + 1)) {
|
|
74
|
+
const before = at > 0 ? norm[at - 1] : "";
|
|
75
|
+
const after = norm[at + needle.length] ?? "";
|
|
76
|
+
if (startsWord && before && WORD_CHAR.test(before)) continue;
|
|
77
|
+
if (endsWord && after && WORD_CHAR.test(after)) continue;
|
|
78
|
+
return lineAt(draft.text, map[at]);
|
|
79
|
+
}
|
|
80
|
+
return -1;
|
|
81
|
+
};
|
|
82
|
+
const finding = (id, message, fix, line) => ({ station: stationName, id, severity: "fail", message, fix, ...(typeof line === "number" && line > 0 ? { line } : {}) });
|
|
83
|
+
return {
|
|
84
|
+
finding,
|
|
85
|
+
shape: (message, fix) => finding("judge-verdict-shape", message, fix),
|
|
86
|
+
// [] when `value` is a non-empty span found in the draft, else the one finding saying why.
|
|
87
|
+
evidence(value, where) {
|
|
88
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
89
|
+
return [finding("judge-evidence-missing", `${where} is empty or not a string`, "Quote the span of the draft this judgment rests on, verbatim.")];
|
|
90
|
+
}
|
|
91
|
+
const words = (normalizeForEvidence(value).norm.match(WORD_TOKENS) ?? []).length;
|
|
92
|
+
if (words < MIN_EVIDENCE_WORDS) {
|
|
93
|
+
return [finding("judge-evidence-too-short", `${where} has ${words} word${words === 1 ? "" : "s"}, fewer than ${MIN_EVIDENCE_WORDS}: "${truncate(value, 80)}"`, `Quote the sentence or clause the judgment rests on, at least ${MIN_EVIDENCE_WORDS} words, verbatim.`)];
|
|
94
|
+
}
|
|
95
|
+
if (locate(value) < 0) {
|
|
96
|
+
return [finding("judge-evidence-not-found", `${where} is not in the draft: "${truncate(value, 80)}"`, "Copy the evidence verbatim from the draft, whole words only; only whitespace and quote characters may differ.")];
|
|
97
|
+
}
|
|
98
|
+
return [];
|
|
99
|
+
},
|
|
100
|
+
// The 1-based draft line where a (valid) evidence span starts, or undefined.
|
|
101
|
+
lineOf(span) {
|
|
102
|
+
const line = typeof span === "string" ? locate(span) : -1;
|
|
103
|
+
return line > 0 ? line : undefined;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The message for files whose bytes no longer match the hashes a packet recorded. Nothing on disk
|
|
109
|
+
// can tell a file changed since prepare from a packet whose hash (or path) was edited, so it claims
|
|
110
|
+
// neither. `names` lists the files, e.g. ["the spec", "the draft"]. Shared with
|
|
111
|
+
// `learn record`.
|
|
112
|
+
export function hashMismatchMessage(names) {
|
|
113
|
+
const one = names.length === 1;
|
|
114
|
+
const what = one ? names[0] : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
|
|
115
|
+
return `${what} ${one ? "does" : "do"} not match the ${one ? "hash" : "hashes"} the packet recorded: ${one ? "it" : "they"} changed since prepare, or the packet was edited`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const packetJson = (packet) => `${JSON.stringify(packet, null, 2)}\n`;
|
|
119
|
+
|
|
120
|
+
// The packet one judge gets for this spec and draft, built the one way both commands build it:
|
|
121
|
+
// prepare writes it, and record rebuilds it to check the file it was handed. { packet, packetBytes,
|
|
122
|
+
// key }: key is the station's hidden answer key (null for a station with none), written by prepare
|
|
123
|
+
// to <station>.key.json for a person to read, and never read back as truth: record rebuilds it.
|
|
124
|
+
// specPathArg and the draft's path are the strings as given, so the bytes are deterministic.
|
|
125
|
+
export function buildPacket(judge, specPathArg, spec, draft, specSha) {
|
|
126
|
+
const parts = judge.packet(spec, draft);
|
|
127
|
+
const packet = {
|
|
128
|
+
hyperspec_judge: PACKET_VERSION,
|
|
129
|
+
station: judge.name,
|
|
130
|
+
spec: specPathArg,
|
|
131
|
+
spec_sha256: specSha,
|
|
132
|
+
draft: draft.path,
|
|
133
|
+
draft_sha256: draft.sha256,
|
|
134
|
+
rubric: parts.rubric,
|
|
135
|
+
instructions: judge.instructions,
|
|
136
|
+
inputs: parts.inputs,
|
|
137
|
+
verdict_schema: parts.verdict_schema,
|
|
138
|
+
};
|
|
139
|
+
return { packet, packetBytes: packetJson(packet), key: parts.key ?? null };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- prepare -----------------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
// prepareJudges(specPathArg, draftPathArg, outDirArg, { only, force }): writes one
|
|
145
|
+
// <station>.packet.json (plus <station>.key.json where a station has a hidden answer key) per
|
|
146
|
+
// applicable station into outDirArg. Paths are kept as given, so the same spec and draft produce
|
|
147
|
+
// byte-identical packets. Refuses (usage) to overwrite an existing file without force, naming every
|
|
148
|
+
// one, and writes nothing in that case.
|
|
149
|
+
export function prepareJudges(specPathArg, draftPathArg, outDirArg, { only, force = false } = {}) {
|
|
150
|
+
if (!present(specPathArg)) return { usage: true, error: "judge prepare needs a spec path" };
|
|
151
|
+
if (!present(draftPathArg)) return { usage: true, error: "judge prepare needs --draft <file>" };
|
|
152
|
+
if (!present(outDirArg)) return { usage: true, error: "judge prepare needs --out <dir>" };
|
|
153
|
+
|
|
154
|
+
const loaded = loadWritingSpec(specPathArg, "judge prepare");
|
|
155
|
+
if (loaded.usage) return loaded;
|
|
156
|
+
const { spec } = loaded;
|
|
157
|
+
|
|
158
|
+
let judges = JUDGES;
|
|
159
|
+
if (only && only.length) {
|
|
160
|
+
const unknown = only.filter((n) => !JUDGE_NAMES.includes(n));
|
|
161
|
+
if (unknown.length) {
|
|
162
|
+
return { usage: true, error: `unknown judge${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}; known judges: ${JUDGE_NAMES.join(", ")}` };
|
|
163
|
+
}
|
|
164
|
+
judges = JUDGES.filter((j) => only.includes(j.name));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let outStat = null;
|
|
168
|
+
try { outStat = statSync(resolve(outDirArg)); } catch { /* reported below */ }
|
|
169
|
+
if (!outStat) return { usage: true, error: `--out folder does not exist: ${outDirArg}; create it first` };
|
|
170
|
+
if (!outStat.isDirectory()) return { usage: true, error: `--out is not a folder: ${outDirArg}` };
|
|
171
|
+
|
|
172
|
+
const blocked = lintBlock(spec, specPathArg);
|
|
173
|
+
if (blocked) return blocked;
|
|
174
|
+
|
|
175
|
+
const draft = readDraft(draftPathArg);
|
|
176
|
+
if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg}` };
|
|
177
|
+
const specSha = sha256(readFileSync(resolve(specPathArg)));
|
|
178
|
+
|
|
179
|
+
const files = [];
|
|
180
|
+
const skipped = [];
|
|
181
|
+
const crashed = [];
|
|
182
|
+
for (const judge of judges) {
|
|
183
|
+
const reason = judge.skipReason(spec, draft);
|
|
184
|
+
if (reason) { skipped.push({ station: judge.name, reason }); continue; }
|
|
185
|
+
let built;
|
|
186
|
+
try { built = buildPacket(judge, specPathArg, spec, draft, specSha); }
|
|
187
|
+
catch (e) {
|
|
188
|
+
crashed.push({ station: judge.name, id: `judge-${judge.name}-crashed`, severity: "fail", message: withoutAbsolutePaths(e instanceof Error ? e.message : String(e)), fix: "Fix the station or file an issue; it should never throw." });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
files.push({ station: judge.name, path: join(outDirArg, `${judge.name}.packet.json`), bytes: built.packetBytes });
|
|
192
|
+
if (built.key) files.push({ station: judge.name, path: join(outDirArg, `${judge.name}.key.json`), bytes: packetJson(built.key) });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const existing = files.filter((f) => existsSync(resolve(f.path))).map((f) => f.path);
|
|
196
|
+
if (existing.length && !force) {
|
|
197
|
+
return { usage: true, error: `refusing to overwrite ${existing.join(", ")}; pass --force to replace ${existing.length > 1 ? "them" : "it"}` };
|
|
198
|
+
}
|
|
199
|
+
for (const f of files) writeFileAtomic(resolve(f.path), f.bytes);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
ok: true,
|
|
203
|
+
specPath: specPathArg,
|
|
204
|
+
draftPath: draftPathArg,
|
|
205
|
+
draftSha256: draft.sha256,
|
|
206
|
+
written: files.map((f) => ({ station: f.station, path: f.path })),
|
|
207
|
+
skipped,
|
|
208
|
+
crashed,
|
|
209
|
+
code: crashed.length ? 1 : 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- record ------------------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
// recordJudgment(packetPathArg, verdictPathArg): validates the verdict against its packet and, when
|
|
216
|
+
// it is valid, derives the station's status and appends one ledger line. Returns a usage result
|
|
217
|
+
// (exit 2), { invalid } (exit 1, nothing appended; a stale packet is { invalid, stale }, the shape
|
|
218
|
+
// `learn record` returns too), or the recorded result (exit 0 when the station passes, 1 when it
|
|
219
|
+
// fails).
|
|
220
|
+
export function recordJudgment(packetPathArg, verdictPathArg) {
|
|
221
|
+
if (!present(packetPathArg)) return { usage: true, error: "judge record needs a packet path" };
|
|
222
|
+
if (!present(verdictPathArg)) return { usage: true, error: "judge record needs --verdict <file>" };
|
|
223
|
+
|
|
224
|
+
let packetText;
|
|
225
|
+
try { packetText = readFileSync(resolve(packetPathArg), "utf8"); }
|
|
226
|
+
catch { return { usage: true, error: `cannot read packet: ${packetPathArg}` }; }
|
|
227
|
+
let packet;
|
|
228
|
+
try { packet = JSON.parse(packetText); } catch { packet = null; }
|
|
229
|
+
if (!packet || typeof packet !== "object" || packet.hyperspec_judge !== PACKET_VERSION || !present(packet.station) || !present(packet.spec) || !present(packet.draft)) {
|
|
230
|
+
return { usage: true, error: `not a hyperspec judge packet: ${packetPathArg}` };
|
|
231
|
+
}
|
|
232
|
+
const judge = JUDGES.find((j) => j.name === packet.station);
|
|
233
|
+
if (!judge) return { usage: true, error: `the packet names an unknown judge: ${packet.station}; known judges: ${JUDGE_NAMES.join(", ")}` };
|
|
234
|
+
|
|
235
|
+
let verdictBuf;
|
|
236
|
+
try { verdictBuf = readFileSync(resolve(verdictPathArg)); }
|
|
237
|
+
catch { return { usage: true, error: `cannot read verdict: ${verdictPathArg}` }; }
|
|
238
|
+
|
|
239
|
+
const loaded = loadWritingSpec(packet.spec, "judge record");
|
|
240
|
+
if (loaded.usage) return { usage: true, error: `the packet's spec: ${loaded.error}` };
|
|
241
|
+
const { spec } = loaded;
|
|
242
|
+
const draft = readDraft(packet.draft);
|
|
243
|
+
if (!draft) return { usage: true, error: `cannot read the packet's draft: ${packet.draft}` };
|
|
244
|
+
|
|
245
|
+
const base = { packetPath: packetPathArg, verdictPath: verdictPathArg, station: judge.name };
|
|
246
|
+
const t = toolsFor(judge.name, draft);
|
|
247
|
+
|
|
248
|
+
// A verdict on bytes other than the ones the packet was prepared from judges nothing that exists.
|
|
249
|
+
const specSha = sha256(readFileSync(resolve(packet.spec)));
|
|
250
|
+
const draftChanged = draft.sha256 !== packet.draft_sha256;
|
|
251
|
+
const specChanged = specSha !== packet.spec_sha256;
|
|
252
|
+
if (draftChanged || specChanged) {
|
|
253
|
+
const names = [specChanged && "the spec", draftChanged && "the draft"].filter(Boolean);
|
|
254
|
+
return { ...base, ok: false, invalid: true, stale: true, findings: [t.finding("judge-stale", hashMismatchMessage(names), "Run judge prepare again (with --force) and judge the new packet.")], code: 1 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// record never trusts the packet file: it rebuilds the packet (and any answer key) from the spec
|
|
258
|
+
// and draft on disk, requires the file to be those exact bytes, and validates the verdict against
|
|
259
|
+
// the rebuilt copy only. A packet edited after prepare (its conditions, its inputs, a hash made to
|
|
260
|
+
// match a changed draft) is refused here.
|
|
261
|
+
const skip = judge.skipReason(spec, draft);
|
|
262
|
+
let rebuilt = null;
|
|
263
|
+
if (!skip) {
|
|
264
|
+
try { rebuilt = buildPacket(judge, packet.spec, spec, draft, specSha); }
|
|
265
|
+
catch (e) {
|
|
266
|
+
return { ...base, ok: false, invalid: true, findings: [t.finding(`judge-${judge.name}-crashed`, withoutAbsolutePaths(e instanceof Error ? e.message : String(e)), "Fix the station or file an issue; it should never throw.")], code: 1 };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// A station that reads files besides the spec and the draft (lineup reads the DNA
|
|
270
|
+
// goldens) can go out of date with both hashes unchanged. When the file is a well-formed packet
|
|
271
|
+
// that differs from the rebuilt one only in its inputs, that is reported as stale, naming those
|
|
272
|
+
// files; the packet could also have been hand-edited there (its inputs, or a hash forged to match
|
|
273
|
+
// an edited draft), and the message says so without claiming either hash is honest,
|
|
274
|
+
// since nothing on disk can tell these apart. Anything else is an altered packet.
|
|
275
|
+
//
|
|
276
|
+
// A station can also stop applying because of those files (persona's claims ledger deleted,
|
|
277
|
+
// lineup's goldens removed): its sourceSkip names that reason, and with the spec and draft hashes
|
|
278
|
+
// matching the packet is stale for the same reason, the message saying the station no longer
|
|
279
|
+
// applies and why. Any other skip (the spec changed under a forged hash, say) is an altered
|
|
280
|
+
// packet. Neither message tells the user to judge a packet prepare will no longer write.
|
|
281
|
+
const sources = judge.inputSources?.(spec) ?? null;
|
|
282
|
+
const staleSources = (message, fix) => ({ ...base, ok: false, invalid: true, stale: true, findings: [t.finding("judge-stale", `${message}: ${sources} changed since the packet was prepared, or the packet was edited`, fix)], code: 1 });
|
|
283
|
+
const sourceSkip = !rebuilt && sources ? judge.sourceSkip?.(spec, draft) ?? null : null;
|
|
284
|
+
if (sourceSkip) {
|
|
285
|
+
return staleSources(`${judge.name} no longer applies (${sourceSkip})`, `Restore ${sources} and record this verdict again; as they are now, judge prepare skips ${judge.name} for this spec and draft.`);
|
|
286
|
+
}
|
|
287
|
+
if (rebuilt && rebuilt.packetBytes !== packetText && sources && packetJson(packet) === packetText
|
|
288
|
+
&& JSON.stringify({ ...packet, inputs: null }) === JSON.stringify({ ...rebuilt.packet, inputs: null })) {
|
|
289
|
+
return staleSources(`the packet's inputs no longer match what the spec, the draft and ${sources} produce now`, `Run judge prepare again (with --force) so the packet is built from the spec, the draft and ${sources} as they are now, and judge the new packet.`);
|
|
290
|
+
}
|
|
291
|
+
if (!rebuilt) {
|
|
292
|
+
return { ...base, ok: false, invalid: true, findings: [t.finding("judge-packet-altered", `${packetPathArg} is for a station that does not apply to this spec and draft (${skip})`, `judge prepare writes no ${judge.name} packet for this spec and draft; record verdicts only on packets prepare writes, and never edit one.`)], code: 1 };
|
|
293
|
+
}
|
|
294
|
+
if (rebuilt.packetBytes !== packetText) {
|
|
295
|
+
return { ...base, ok: false, invalid: true, findings: [t.finding("judge-packet-altered", `${packetPathArg} is not the packet judge prepare builds from the spec and draft on disk`, "Run judge prepare again (with --force) and judge the new packet; never edit a packet.")], code: 1 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let verdictJson;
|
|
299
|
+
try { verdictJson = JSON.parse(verdictBuf.toString("utf8").replace(/^/, "")); }
|
|
300
|
+
catch (e) {
|
|
301
|
+
return { ...base, ok: false, invalid: true, findings: [t.finding("judge-verdict-not-json", `the verdict is not valid JSON (${e instanceof Error ? e.message : String(e)})`, "Write the verdict as one JSON object in the packet's verdict_schema shape.")], code: 1 };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let problems;
|
|
305
|
+
let derived;
|
|
306
|
+
try {
|
|
307
|
+
problems = judge.validate(verdictJson, rebuilt.packet, t, rebuilt.key);
|
|
308
|
+
if (!problems.length) derived = judge.derive(verdictJson, rebuilt.packet, t, rebuilt.key);
|
|
309
|
+
} catch (e) {
|
|
310
|
+
const crash = t.finding(`judge-${judge.name}-crashed`, withoutAbsolutePaths(e instanceof Error ? e.message : String(e)), "Fix the station or file an issue; it should never throw.");
|
|
311
|
+
return { ...base, ok: false, invalid: true, findings: [crash], code: 1 };
|
|
312
|
+
}
|
|
313
|
+
if (problems.length) return { ...base, ok: false, invalid: true, findings: problems, code: 1 };
|
|
314
|
+
|
|
315
|
+
// summary: an optional one-line result a station reports beside its status (attribution's
|
|
316
|
+
// accuracy); printed after the status and carried in --json, never in the ledger line.
|
|
317
|
+
const { status, findings, summary } = derived;
|
|
318
|
+
|
|
319
|
+
// ---- ledger: the same truth rules as check: compared with the most recent earlier
|
|
320
|
+
// judge line for the same station and draft path, through ledgerVerdict; two judge-only rules on
|
|
321
|
+
// top are marked below.
|
|
322
|
+
let ledgerPath = null;
|
|
323
|
+
let ledgerWarning = null;
|
|
324
|
+
let verdict = null;
|
|
325
|
+
let verdictDetail = {};
|
|
326
|
+
const ledger = openLedger(spec);
|
|
327
|
+
if (ledger?.warning) ledgerWarning = ledger.warning;
|
|
328
|
+
else if (ledger) {
|
|
329
|
+
const draftKey = ledgerDraftKey(spec.dir, packet.draft);
|
|
330
|
+
// packet_sha256: the hash of what the judge was shown, taken over the packet with its two paths
|
|
331
|
+
// written as the ledger writes them (relative to the spec's folder), so the same packet
|
|
332
|
+
// prepared from another folder, or as ./draft.md, hashes the same and a path's spelling can
|
|
333
|
+
// never pass for a change.
|
|
334
|
+
const packetSha = sha256(packetJson({ ...rebuilt.packet, spec: ledgerDraftKey(spec.dir, packet.spec), draft: draftKey }));
|
|
335
|
+
// inputs_sha256: the hash of the packet's inputs alone, so a packet that changed can be told
|
|
336
|
+
// apart as changed inputs (the files the station reads) or changed fixed text (a release that
|
|
337
|
+
// rewords the instructions or the schema).
|
|
338
|
+
const inputsSha = sha256(JSON.stringify(rebuilt.packet.inputs));
|
|
339
|
+
const judgeLines = priorLines(ledger.priorText, "judge");
|
|
340
|
+
const prior = judgeLines.filter((l) => l.station === judge.name && l.draft === draftKey).at(-1);
|
|
341
|
+
const last = prior ? { stations: { [prior.station]: prior.status }, draft_sha256: prior.draft_sha256, spec_sha256: prior.spec_sha256 } : undefined;
|
|
342
|
+
// What changed since that line is judged by what the judge was shown: the packet. The draft
|
|
343
|
+
// and the spec are named when their bytes changed. A packet that changed with both unchanged
|
|
344
|
+
// was changed either in its inputs, by the files the station reads besides them (the DNA
|
|
345
|
+
// scope, the claims ledger), which are named, or in its fixed text (hyperspec's instructions
|
|
346
|
+
// or format), which is said. A line with no packet_sha256 (none is written without one) is
|
|
347
|
+
// compared by the two hashes alone.
|
|
348
|
+
let what = null;
|
|
349
|
+
if (prior) {
|
|
350
|
+
const draftChanged = prior.draft_sha256 !== draft.sha256;
|
|
351
|
+
const specChanged = prior.spec_sha256 !== specSha;
|
|
352
|
+
if (draftChanged || specChanged) what = draftChanged && specChanged ? "spec and draft" : specChanged ? "spec" : "draft";
|
|
353
|
+
else if (typeof prior.packet_sha256 === "string" && prior.packet_sha256 !== packetSha) {
|
|
354
|
+
what = prior.inputs_sha256 === inputsSha ? "the packet's fixed text (hyperspec's instructions or format)" : sources ?? "the packet's inputs";
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
({ verdict, detail: verdictDetail } = ledgerVerdict({
|
|
358
|
+
last, statusNow: { [judge.name]: status }, draftSha: draft.sha256, specSha, noun: "judgment", what,
|
|
359
|
+
// A judge can answer differently about an identical packet; that is not the work improving.
|
|
360
|
+
unchangedImprovedReason: "the verdict changed; nothing the judge was shown changed",
|
|
361
|
+
}));
|
|
362
|
+
// one-shot means these bytes passed the first time they were judged, whatever the file was
|
|
363
|
+
// called: bytes already judged under another path (a copy, a rename) never earn it.
|
|
364
|
+
if (verdict === "one-shot") {
|
|
365
|
+
const sameBytes = judgeLines.filter((l) => l.station === judge.name && l.draft_sha256 === draft.sha256).at(-1);
|
|
366
|
+
if (sameBytes) { verdict = "not-improved"; verdictDetail = { reason: `these draft bytes were judged before as ${sameBytes.draft}: ${sameBytes.status}` }; }
|
|
367
|
+
}
|
|
368
|
+
const line = {
|
|
369
|
+
at: new Date().toISOString(),
|
|
370
|
+
kind: "judge",
|
|
371
|
+
station: judge.name,
|
|
372
|
+
draft: draftKey,
|
|
373
|
+
draft_sha256: draft.sha256,
|
|
374
|
+
spec_sha256: specSha,
|
|
375
|
+
packet_sha256: packetSha,
|
|
376
|
+
inputs_sha256: inputsSha,
|
|
377
|
+
status,
|
|
378
|
+
verdict,
|
|
379
|
+
...verdictDetail,
|
|
380
|
+
};
|
|
381
|
+
appendFileSync(ledger.abs, `${JSON.stringify(line)}\n`);
|
|
382
|
+
ledgerPath = ledger.decl;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { ...base, ok: true, status, ...(summary ? { summary } : {}), findings, verdict, verdictDetail, ledgerPath, ledgerWarning, code: status === "pass" ? 0 : 1 };
|
|
386
|
+
}
|