@supersuit/hyperspec 0.6.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 +91 -0
- package/README.md +37 -0
- package/SPEC.md +2 -2
- package/WRITING.md +542 -10
- package/bin/hyperspec.mjs +183 -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/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 +6 -1
- 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 +7 -3
- package/package.json +1 -1
- package/src/check.mjs +75 -129
- 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 +44 -39
- package/src/stations/quotes.mjs +6 -4
- package/src/writing.mjs +1 -1
package/src/check.mjs
CHANGED
|
@@ -8,47 +8,26 @@
|
|
|
8
8
|
// own code (1 fail, 3 blocked) rather than a check-specific one. Passing lint's test 9 requires
|
|
9
9
|
// improvement.ledger to be a non-empty path (see
|
|
10
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
|
|
12
|
-
//
|
|
13
|
-
// silently assume another file's invariant holds.
|
|
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.
|
|
14
13
|
|
|
15
14
|
import { appendFileSync, readFileSync } from "node:fs";
|
|
16
|
-
import { basename, isAbsolute, relative, resolve
|
|
15
|
+
import { basename, isAbsolute, relative, resolve } from "node:path";
|
|
17
16
|
import { loadSpec } from "./load.mjs";
|
|
18
17
|
import { lintSpec } from "./rules.mjs";
|
|
19
18
|
import { score, exitCode } from "./score.mjs";
|
|
20
19
|
import { sha256 } from "./hash.mjs";
|
|
21
|
-
import {
|
|
20
|
+
import { readDraft } from "./draft.mjs";
|
|
21
|
+
import { openLedger, priorLines, ledgerDraftKey, ledgerVerdict } from "./ledger.mjs";
|
|
22
22
|
import { STATIONS, STATION_NAMES } from "./stations/index.mjs";
|
|
23
23
|
import { str } from "./placeholder.mjs";
|
|
24
24
|
|
|
25
25
|
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
26
26
|
|
|
27
|
-
// 1-based line array: text.split("\n"), so array index i holds line i + 1. A trailing "\r" (a
|
|
28
|
-
// CRLF file) is stripped from every entry here, at the source, so every station that reads
|
|
29
|
-
// draft.lines sees a clean line ("# Claim", never "# Claim\r") without needing to know CRLF
|
|
30
|
-
// exists; the line COUNT and every 1-based line number are unaffected, since stripping a
|
|
31
|
-
// trailing byte from an entry never changes how many entries there are.
|
|
32
|
-
function splitLines(text) {
|
|
33
|
-
return text.split("\n").map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// Every well-formed kind: "check" line already in the ledger, in file order (oldest first). A
|
|
37
|
-
// line that is not valid JSON, or not a kind: "check" object, is silently skipped here: this is a
|
|
38
|
-
// read for verdict history, not a lint pass. A malformed ledger line is rules.mjs's test 9's
|
|
39
|
-
// finding to report, not this function's to crash on.
|
|
40
|
-
function priorCheckLines(text) {
|
|
41
|
-
return (text ?? "")
|
|
42
|
-
.split("\n")
|
|
43
|
-
.filter((l) => l.trim())
|
|
44
|
-
.map((l) => { try { return JSON.parse(l); } catch { return null; } })
|
|
45
|
-
.filter((v) => v && typeof v === "object" && !Array.isArray(v) && v.kind === "check");
|
|
46
|
-
}
|
|
47
|
-
|
|
48
27
|
// A crash message with every absolute path in it made relative to the working directory, or cut to
|
|
49
28
|
// "<path>/<file name>" when it lies outside it, so a station that hits a filesystem error never
|
|
50
29
|
// prints this machine's layout.
|
|
51
|
-
function withoutAbsolutePaths(message) {
|
|
30
|
+
export function withoutAbsolutePaths(message) {
|
|
52
31
|
return message.replace(/(?:[A-Za-z]:\\|\/)[^\s'"`,)]+/g, (p) => {
|
|
53
32
|
if (!isAbsolute(p)) return p;
|
|
54
33
|
const rel = relative(process.cwd(), p);
|
|
@@ -87,6 +66,35 @@ export function runStation(station, spec, draft, ctx) {
|
|
|
87
66
|
}
|
|
88
67
|
}
|
|
89
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
|
+
|
|
90
98
|
// runCheck(specPathArg, draftPathArg, { only }): specPathArg and draftPathArg are exactly what
|
|
91
99
|
// the CLI (or a caller) was given, never resolved, so every path this returns or writes to the
|
|
92
100
|
// ledger is displayed and recorded the way the operator typed it, not as an absolute path on this
|
|
@@ -95,11 +103,9 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
|
95
103
|
if (!present(specPathArg)) return { usage: true, error: "check needs a spec path" };
|
|
96
104
|
if (!present(draftPathArg)) return { usage: true, error: "check needs --draft <file>" };
|
|
97
105
|
|
|
98
|
-
const
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
// read, and a run against it would fail quotations it has no materials for.
|
|
102
|
-
if (str(spec.data?.profile) !== "writing") return { usage: true, error: "check needs a writing spec (profile: writing)" };
|
|
106
|
+
const loaded = loadWritingSpec(specPathArg, "check");
|
|
107
|
+
if (loaded.usage) return loaded;
|
|
108
|
+
const { spec } = loaded;
|
|
103
109
|
|
|
104
110
|
// --only: every name must be one this build's registry knows; unknown names are a usage error
|
|
105
111
|
// (exit 2) rather than a silent no-op, and the run order always follows the registry, never the
|
|
@@ -114,28 +120,12 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
|
114
120
|
stationsToRun = STATIONS.filter((s) => only.includes(s.name));
|
|
115
121
|
}
|
|
116
122
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (lintScore.status !== "pass") {
|
|
120
|
-
return {
|
|
121
|
-
ok: false,
|
|
122
|
-
lintBlocked: true,
|
|
123
|
-
specPath: specPathArg,
|
|
124
|
-
lintStatus: lintScore.status,
|
|
125
|
-
lintScore,
|
|
126
|
-
lintFindings,
|
|
127
|
-
code: exitCode(lintScore.status),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
123
|
+
const blocked = lintBlock(spec, specPathArg);
|
|
124
|
+
if (blocked) return blocked;
|
|
130
125
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
// One leading UTF-8 BOM is not part of the draft's text: stripped here, so a heading on line 1
|
|
135
|
-
// is found and every offset and line number counts from the first real character. sha256 stays
|
|
136
|
-
// over the raw bytes.
|
|
137
|
-
const text = draftBuf.toString("utf8").replace(/^\uFEFF/, "");
|
|
138
|
-
const draft = { path: draftPathArg, text, lines: splitLines(text), sha256: sha256(draftBuf) };
|
|
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}` };
|
|
139
129
|
|
|
140
130
|
const ctx = {};
|
|
141
131
|
const results = stationsToRun.map((s) => runStation(s, spec, draft, ctx));
|
|
@@ -143,89 +133,45 @@ export function runCheck(specPathArg, draftPathArg, { only } = {}) {
|
|
|
143
133
|
const code = failing.length ? 1 : 0;
|
|
144
134
|
|
|
145
135
|
// ---- ledger: one line of evidence per check, only when the spec declares one -----------------
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
// path relative to the spec's folder). "Changed" means the draft's bytes (draft_sha256) or
|
|
152
|
-
// the spec's bytes (spec_sha256); files the spec names are not hashed.
|
|
153
|
-
// none, and every station passes -> one-shot
|
|
154
|
-
// none, and a station fails -> not-improved "failing stations: X"
|
|
155
|
-
// it failed, every station passes now -> improved "stations now pass: X", exactly
|
|
156
|
-
// the stations that failed then and pass now
|
|
157
|
-
// it passed, nothing changed, passing -> not-improved "no change since the last passing check"
|
|
158
|
-
// it passed, something changed, passing -> not-improved "<what> changed; every station still passes"
|
|
159
|
-
// failing now -> not-improved "[<what> changed; |no change since
|
|
160
|
-
// the last check; ]still failing: X" when every
|
|
161
|
-
// failing station also failed then, else
|
|
162
|
-
// "[<what> changed; ]failing stations: X"
|
|
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.
|
|
163
141
|
let ledgerPath = null;
|
|
164
142
|
let ledgerWarning = null;
|
|
165
143
|
let verdict = null;
|
|
166
144
|
let verdictDetail = {};
|
|
167
145
|
const partial = Boolean(only && only.length);
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
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(", ")}`;
|
|
172
156
|
} else {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
try { priorText = readFileSync(ledgerAbs, "utf8"); } catch { /* not written yet; a first check creates it */ }
|
|
176
|
-
|
|
177
|
-
// The draft as the ledger records it: relative to the spec's folder, with forward slashes, so
|
|
178
|
-
// "./draft.md", "draft.md" and an absolute path are one history, and no absolute path lands
|
|
179
|
-
// in a ledger that is usually committed.
|
|
180
|
-
const draftKey = relative(resolve(spec.dir), resolve(draftPathArg)).split(sep).join("/");
|
|
181
|
-
const specSha = sha256(readFileSync(resolve(specPathArg)));
|
|
182
|
-
const statusNow = Object.fromEntries(results.map((r) => [r.station, r.status]));
|
|
183
|
-
const list = (names) => names.join(", ");
|
|
184
|
-
|
|
185
|
-
if (partial) {
|
|
186
|
-
verdict = "not-improved";
|
|
187
|
-
verdictDetail.reason = `partial run: ${list(results.map((r) => r.station))}`;
|
|
188
|
-
} else {
|
|
189
|
-
const last = priorCheckLines(priorText).filter((l) => l.draft === draftKey && l.partial !== true).at(-1);
|
|
190
|
-
const failedThen = last ? Object.entries(last.stations ?? {}).filter(([, st]) => st === "fail").map(([n]) => n) : [];
|
|
191
|
-
const draftChanged = Boolean(last) && last.draft_sha256 !== draft.sha256;
|
|
192
|
-
const specChanged = Boolean(last) && last.spec_sha256 !== specSha;
|
|
193
|
-
const what = draftChanged && specChanged ? "spec and draft" : specChanged ? "spec" : draftChanged ? "draft" : null;
|
|
194
|
-
const passedNow = failing.length === 0;
|
|
195
|
-
|
|
196
|
-
if (!last) {
|
|
197
|
-
if (passedNow) verdict = "one-shot";
|
|
198
|
-
else { verdict = "not-improved"; verdictDetail.reason = `failing stations: ${list(failing)}`; }
|
|
199
|
-
} else if (passedNow && failedThen.length) {
|
|
200
|
-
const nowPass = failedThen.filter((n) => statusNow[n] === "pass");
|
|
201
|
-
if (nowPass.length) { verdict = "improved"; verdictDetail.change = `stations now pass: ${list(nowPass)}`; }
|
|
202
|
-
else { verdict = "not-improved"; verdictDetail.reason = `${what ? `${what} changed; ` : ""}stations that failed last time now skip: ${list(failedThen)}`; }
|
|
203
|
-
} else if (passedNow) {
|
|
204
|
-
verdict = "not-improved";
|
|
205
|
-
verdictDetail.reason = what ? `${what} changed; every station still passes` : "no change since the last passing check";
|
|
206
|
-
} else {
|
|
207
|
-
verdict = "not-improved";
|
|
208
|
-
const still = failing.every((n) => failedThen.includes(n));
|
|
209
|
-
const prefix = what ? `${what} changed; ` : still ? "no change since the last check; " : "";
|
|
210
|
-
verdictDetail.reason = `${prefix}${still ? "still failing" : "failing stations"}: ${list(failing)}`;
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const line = {
|
|
215
|
-
at: new Date().toISOString(),
|
|
216
|
-
kind: "check",
|
|
217
|
-
draft: draftKey,
|
|
218
|
-
draft_sha256: draft.sha256,
|
|
219
|
-
spec_sha256: specSha,
|
|
220
|
-
stations: statusNow,
|
|
221
|
-
...(partial ? { partial: true } : {}),
|
|
222
|
-
verdict,
|
|
223
|
-
...verdictDetail,
|
|
224
|
-
};
|
|
225
|
-
appendFileSync(ledgerAbs, `${JSON.stringify(line)}\n`);
|
|
226
|
-
// Reported exactly as the spec wrote it (improvement.ledger's own string), never resolved.
|
|
227
|
-
ledgerPath = ledgerDecl;
|
|
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 }));
|
|
228
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;
|
|
229
175
|
}
|
|
230
176
|
|
|
231
177
|
return {
|
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
|
+
}
|