@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +37 -0
  3. package/SPEC.md +2 -2
  4. package/WRITING.md +542 -10
  5. package/bin/hyperspec.mjs +183 -0
  6. package/examples/writing/essay/judge/doctor.packet.json +108 -0
  7. package/examples/writing/essay/judge/lineup.packet.json +64 -0
  8. package/examples/writing/essay/judge/persona.packet.json +73 -0
  9. package/examples/writing/essay/judge/reader.packet.json +93 -0
  10. package/examples/writing/essay/learn/first-draft.md +84 -0
  11. package/examples/writing/essay/learn/learn.packet.json +106 -0
  12. package/examples/writing/essay/sample-verdicts/doctor.verdict.json +43 -0
  13. package/examples/writing/essay/sample-verdicts/learn.verdict.json +30 -0
  14. package/examples/writing/essay/sample-verdicts/lineup.verdict.json +6 -0
  15. package/examples/writing/essay/sample-verdicts/persona.verdict.json +4 -0
  16. package/examples/writing/essay/sample-verdicts/reader.verdict.json +7 -0
  17. package/examples/writing/essay.hyperspec.md +6 -1
  18. package/examples/writing/story/judge/attribution.packet.json +194 -0
  19. package/examples/writing/story/judge/doctor.packet.json +108 -0
  20. package/examples/writing/story/judge/knowledge.packet.json +77 -0
  21. package/examples/writing/story/judge/persona.packet.json +73 -0
  22. package/examples/writing/story/judge/reader.packet.json +94 -0
  23. package/examples/writing/story/sample-verdicts/attribution.verdict.json +81 -0
  24. package/examples/writing/story/sample-verdicts/doctor.verdict.json +43 -0
  25. package/examples/writing/story/sample-verdicts/knowledge.verdict.json +4 -0
  26. package/examples/writing/story/sample-verdicts/persona.verdict.json +20 -0
  27. package/examples/writing/story/sample-verdicts/reader.verdict.json +16 -0
  28. package/examples/writing/story.hyperspec.md +7 -3
  29. package/package.json +1 -1
  30. package/src/check.mjs +75 -129
  31. package/src/draft.mjs +26 -0
  32. package/src/judge.mjs +386 -0
  33. package/src/judges/attribution.mjs +360 -0
  34. package/src/judges/doctor.mjs +126 -0
  35. package/src/judges/index.mjs +31 -0
  36. package/src/judges/knowledge.mjs +111 -0
  37. package/src/judges/lineup.mjs +272 -0
  38. package/src/judges/persona.mjs +137 -0
  39. package/src/judges/reader.mjs +111 -0
  40. package/src/learn.mjs +422 -0
  41. package/src/ledger.mjs +108 -0
  42. package/src/sentences.mjs +81 -0
  43. package/src/stations/claims.mjs +44 -39
  44. package/src/stations/quotes.mjs +6 -4
  45. 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 below exist anyway, for the same reason compare.mjs (the other
12
- // ledger writer) keeps its own copy: defense in depth costs one branch and this file should never
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, sep } from "node:path";
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 { insideDir } from "./fsutil.mjs";
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 spec = loadSpec(specPathArg);
99
- if (spec.error) return { usage: true, error: spec.error };
100
- // Every station reads the writing profile's blocks; a spec without it has nothing for them to
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 lintFindings = lintSpec(spec);
118
- const lintScore = score(lintFindings, spec.data);
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
- let draftBuf;
132
- try { draftBuf = readFileSync(resolve(draftPathArg)); }
133
- catch { return { usage: true, error: `cannot read draft: ${draftPathArg}` }; }
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
- // What a line says, and why each reason is true:
147
- // - A run with --only is partial: verdict not-improved, reason "partial run: <stations>",
148
- // partial: true. Later verdicts ignore partial lines, so a subset never claims (or uses up)
149
- // the verdict for the whole draft.
150
- // - A full run compares with the most recent earlier FULL line for the same draft path (the
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 ledgerDecl = spec.data?.improvement?.ledger;
169
- if (present(ledgerDecl)) {
170
- if (!insideDir(spec.dir, ledgerDecl)) {
171
- ledgerWarning = "improvement.ledger escapes the spec's directory; not appended";
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 ledgerAbs = resolve(spec.dir, ledgerDecl);
174
- let priorText = "";
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
+ }