@supersuit/hyperspec 0.7.0 → 0.9.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 +93 -0
  2. package/README.md +76 -9
  3. package/SPEC.md +2 -2
  4. package/WRITING.md +463 -18
  5. package/bin/hyperspec.mjs +137 -6
  6. package/examples/writing/course/claims.jsonl +0 -0
  7. package/examples/writing/course/goldens/lesson.md +1 -0
  8. package/examples/writing/course/materials/brief.md +9 -0
  9. package/examples/writing/course/materials/brief.md.segments.jsonl +6 -0
  10. package/examples/writing/course/outline.md +11 -0
  11. package/examples/writing/course/part-1.md +64 -0
  12. package/examples/writing/course/part-2.md +57 -0
  13. package/examples/writing/course/runs.jsonl +0 -0
  14. package/examples/writing/course.hyperspec.md +206 -0
  15. package/examples/writing/essay/judge/panel-buyer.packet.json +118 -0
  16. package/examples/writing/essay/judge/panel-expert.packet.json +114 -0
  17. package/examples/writing/essay/judge/panel-novice.packet.json +114 -0
  18. package/examples/writing/essay/judge/panel-skeptic.packet.json +114 -0
  19. package/examples/writing/essay/sample-verdicts/panel-buyer.verdict.json +11 -0
  20. package/examples/writing/essay/sample-verdicts/panel-expert.verdict.json +13 -0
  21. package/examples/writing/essay/sample-verdicts/panel-novice.verdict.json +11 -0
  22. package/examples/writing/essay/sample-verdicts/panel-skeptic.verdict.json +13 -0
  23. package/examples/writing/story/judge/panel-buyer.packet.json +118 -0
  24. package/examples/writing/story/judge/panel-expert.packet.json +114 -0
  25. package/examples/writing/story/judge/panel-novice.packet.json +114 -0
  26. package/examples/writing/story/judge/panel-skeptic.packet.json +114 -0
  27. package/examples/writing/story/sample-verdicts/panel-buyer.verdict.json +13 -0
  28. package/examples/writing/story/sample-verdicts/panel-expert.verdict.json +11 -0
  29. package/examples/writing/story/sample-verdicts/panel-novice.verdict.json +11 -0
  30. package/examples/writing/story/sample-verdicts/panel-skeptic.verdict.json +11 -0
  31. package/package.json +1 -1
  32. package/src/check.mjs +25 -7
  33. package/src/evidence.mjs +74 -0
  34. package/src/judge.mjs +50 -66
  35. package/src/judges/index.mjs +7 -1
  36. package/src/judges/panel.mjs +135 -0
  37. package/src/sequence-draft.mjs +75 -0
  38. package/src/stations/index.mjs +5 -1
  39. package/src/stations/links.mjs +11 -3
  40. package/src/stations/quotes.mjs +26 -2
  41. package/src/stations/sequence.mjs +388 -0
  42. package/src/stations/triage.mjs +20 -0
  43. package/src/triage.mjs +401 -0
  44. package/src/writing-fields.mjs +81 -0
  45. package/src/writing.mjs +5 -1
@@ -30,6 +30,7 @@
30
30
  import { existsSync } from "node:fs";
31
31
  import { dirname, resolve } from "node:path";
32
32
  import { lineAt, maskCode, maskRanges, truncate } from "./util.mjs";
33
+ import { sourceAt } from "../sequence-draft.mjs";
33
34
 
34
35
  export const name = "links";
35
36
 
@@ -226,6 +227,12 @@ function undefinedReferenceFinding(label, line) {
226
227
 
227
228
  export function run(spec, draft) {
228
229
  const draftDirAbs = dirname(resolve(draft.path));
230
+ // A draft assembled from a sequence's files (src/sequence-draft.mjs) resolves each relative link
231
+ // beside the file that holds it.
232
+ const dirAt = (line) => {
233
+ const src = sourceAt(draft, line);
234
+ return src ? dirname(resolve(src.at)) : draftDirAbs;
235
+ };
229
236
 
230
237
  // Masking pipeline: code first, then each link form in turn, each pass working on the text the
231
238
  // previous pass left behind, so nothing is ever matched twice by a later, looser pattern (a
@@ -253,8 +260,9 @@ export function run(spec, draft) {
253
260
  const entries = [];
254
261
 
255
262
  for (const { start, url } of [...mdLinks, ...bare]) {
256
- const reason = checkUrl(url, draftDirAbs);
257
- if (reason) entries.push({ start, finding: reasonFinding(reason, url, lineAt(draft.text, start)) });
263
+ const line = lineAt(draft.text, start);
264
+ const reason = checkUrl(url, dirAt(line));
265
+ if (reason) entries.push({ start, finding: reasonFinding(reason, url, line) });
258
266
  }
259
267
 
260
268
  for (const { start, label } of [...fullRefs, ...shortcutRefs]) {
@@ -264,7 +272,7 @@ export function run(spec, draft) {
264
272
  entries.push({ start, finding: undefinedReferenceFinding(label, line) });
265
273
  continue;
266
274
  }
267
- const reason = checkUrl(def.url, draftDirAbs);
275
+ const reason = checkUrl(def.url, dirAt(line));
268
276
  if (reason) entries.push({ start, finding: reasonFinding(reason, def.url, line) });
269
277
  }
270
278
 
@@ -33,6 +33,14 @@
33
33
  // story segment is enough. Sentences come from splitSegments(text, { by: "sentence" }), and every
34
34
  // sentence the span overlaps is read, so a curly quote the splitter cuts in two still keeps its
35
35
  // attribution.
36
+ //
37
+ // Example phrasings (hyperspec 0.9). A primer shows the reader what to type or say: "write the
38
+ // update for Dana" is an example, not a quotation, and no material holds it. writing.quotes.examples
39
+ // says which quoted spans are examples. `true` makes every span whose sentence names no known
40
+ // speaker an example: it passes unmatched, and a quotation that names its speaker is still held to
41
+ // the materials. A list names the example phrasings themselves, compared the way a span is matched,
42
+ // so every other span is still checked. The list is the stricter choice: under `true`, an invented
43
+ // quotation that names no one passes too.
36
44
 
37
45
  import { splitSegments } from "../segments.mjs";
38
46
  import { STOPWORDS, wordsOf } from "../dna.mjs";
@@ -75,6 +83,18 @@ export function quotedSpans(text) {
75
83
  return spans;
76
84
  }
77
85
 
86
+ // The spec's example setting: { all: true } for `examples: true`, { phrases } (a Set of match keys)
87
+ // for a list, or null when the spec declares none, or `false`.
88
+ export function examplesOf(spec) {
89
+ const raw = spec?.data?.writing?.quotes?.examples;
90
+ if (str(raw) === "true") return { all: true };
91
+ if (Array.isArray(raw)) {
92
+ const phrases = new Set(raw.map(str).filter(Boolean).map(matchKey).filter(Boolean));
93
+ return phrases.size ? { phrases } : null;
94
+ }
95
+ return null;
96
+ }
97
+
78
98
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
99
 
80
100
  const STOPWORD_SET = new Set(STOPWORDS);
@@ -133,13 +153,19 @@ export function run(spec, draft, ctx) {
133
153
  .filter((sp) => sp.re);
134
154
 
135
155
  const sentences = splitSegments(text, { by: "sentence" });
156
+ const examples = examplesOf(spec);
136
157
  const findings = [];
137
158
  for (const span of spans) {
138
159
  const key = matchKey(span.inner);
139
160
  if (!key) continue;
161
+ if (examples?.phrases?.has(key)) continue;
140
162
  const shown = truncate(span.inner.replace(/\s+/g, " "), 80);
141
163
  const line = lineAt(draft.text, span.start);
142
164
  const hits = sources.filter((s) => s.norm.includes(key));
165
+ const around = attributionContext(text, sentences, span);
166
+ const named = speakers.filter((sp) => sp.re.test(around)).map((sp) => sp.key);
167
+ // examples: true reads a span that names no known speaker as an example phrasing.
168
+ if (examples?.all && !named.length) continue;
143
169
  if (!hits.length) {
144
170
  findings.push({
145
171
  station: name,
@@ -151,8 +177,6 @@ export function run(spec, draft, ctx) {
151
177
  });
152
178
  continue;
153
179
  }
154
- const around = attributionContext(text, sentences, span);
155
- const named = speakers.filter((sp) => sp.re.test(around)).map((sp) => sp.key);
156
180
  if (named.length && !hits.some((h) => h.label === "quote" && named.includes(h.speaker))) {
157
181
  const who = named.map((k) => shownSpeaker.get(k) ?? k).join(", ");
158
182
  findings.push({
@@ -0,0 +1,388 @@
1
+ // Station "sequence" (hyperspec 0.8). Every other station checks one piece against its spec. A
2
+ // work read in order (a course, a primer, a textbook, a book of lessons) makes promises ACROSS its
3
+ // pieces: lesson 5 is written for someone who has read lessons 1 to 4 and nothing else. This
4
+ // station holds the draft to those promises, and runs only when the spec declares
5
+ // writing.form.sequence.
6
+ //
7
+ // A unit is one lesson (or chapter, or whatever sequence.unit names): an ATX heading "<unit> <n>",
8
+ // optionally followed by ":" or "." and a title, running until the next unit heading, the next
9
+ // heading of its own level or above (a part's heading, whose introduction belongs to no lesson), or
10
+ // the end of the file it sits in. Headings inside fenced code are not headings.
11
+ //
12
+ // The guards, every one deterministic:
13
+ // sections every unit carries each of sequence.sections, as a "**Label:**" line or a heading
14
+ // defined every term is defined in exactly one unit's terms section
15
+ // order no unit uses a term before the unit that defines it; code is not prose, the terms
16
+ // section is not a use, and neither is the unit's closing teaser (a "**<teaser>" line
17
+ // and everything after it). Words in audience.knows and sequence.knows are exempt:
18
+ // listing a word there is a decision that the reader already has it
19
+ // outline each unit defines every term the outline promises for it
20
+ // numbering unit numbers increase through the work
21
+ // pointers (warning) a "<unit> N" pointer to a later unit is reported, so it stays a pointer
22
+ // and never becomes a dependency
23
+ // quiz (hyperspec 0.9, only when sequence.quiz names the quiz heading) every defined term is
24
+ // tested by some question, a simple plural counting; no question uses a term defined
25
+ // after the unit it is tagged with; every question is tagged; every answer names one
26
+ // of its question's options. Promoted from the checker the first book written this
27
+ // way used, so the rules and the question shape are that checker's.
28
+ //
29
+ // A use is a whole-word, case-insensitive match, where a hyphen is part of a word: "context-aware"
30
+ // does not use "context", and "skills" does not use "skill". The station reads the outline from
31
+ // disk, resolved against the spec's folder, the way claims reads its ledger. It never reads a
32
+ // part's text for meaning: a term used in a sense other than its definition still counts as a use.
33
+
34
+ import { readFileSync } from "node:fs";
35
+ import { resolve } from "node:path";
36
+ import { str } from "../placeholder.mjs";
37
+ import { maskCode } from "./util.mjs";
38
+
39
+ export const name = "sequence";
40
+
41
+ export const DEFAULTS = Object.freeze({
42
+ unit: "Lesson",
43
+ sections: Object.freeze(["After this lesson you can", "New terms", "Try this"]),
44
+ terms_section: "New terms",
45
+ teaser: "Next,",
46
+ });
47
+
48
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
49
+ const list = (v) => (Array.isArray(v) ? v : []);
50
+ const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/;
51
+
52
+ // A term as the station compares it: lower case, no code or emphasis marks, no parenthetical, one
53
+ // space between words.
54
+ export const normTerm = (s) => String(s).toLowerCase().replace(/[`*]/g, "").replace(/\s*\(.*?\)\s*/g, " ").replace(/\s+/g, " ").trim();
55
+
56
+ // The sequence block with every default filled in. null when the spec declares none.
57
+ export function sequenceOf(spec) {
58
+ const raw = spec?.data?.writing?.form?.sequence;
59
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
60
+ const sections = list(raw.sections).map(str).filter(Boolean);
61
+ const knows = [...list(raw.knows), ...list(spec?.data?.writing?.audience?.knows)].map(str).filter(Boolean).map(normTerm);
62
+ return {
63
+ unit: str(raw.unit) || DEFAULTS.unit,
64
+ sections: sections.length ? sections : [...DEFAULTS.sections],
65
+ termsSection: str(raw.terms_section) || DEFAULTS.terms_section,
66
+ teaser: str(raw.teaser) || DEFAULTS.teaser,
67
+ outline: str(raw.outline) || null,
68
+ quiz: str(raw.quiz) || null,
69
+ knows: new Set(knows),
70
+ };
71
+ }
72
+
73
+ // Every unit in the draft, in document order: { n, title, line, startLine, text, lines }, where
74
+ // line is the heading's 1-based line, and text is everything after the heading up to where the
75
+ // unit ends (see the header). startLine is the line text begins on. A draft assembled from several
76
+ // files (draft.sources, see src/sequence-draft.mjs) ends a unit at its file's end too.
77
+ export function parseUnits(draft, { unit }) {
78
+ const lines = draft.text.split("\n");
79
+ const masked = maskCode(draft.text).split("\n");
80
+ const unitRe = new RegExp(`^${escapeRe(unit)}[ \\t]+(\\d+)\\b[ \\t]*[:.]?[ \\t]*(.*)$`, "i");
81
+ const fileEnds = new Set(list(draft.sources).map((s) => s.startLine + s.lineCount - 1));
82
+ const units = [];
83
+ let open = null;
84
+ const close = (endLine) => {
85
+ if (!open) return;
86
+ const body = lines.slice(open.line, endLine);
87
+ units.push({ n: open.n, title: open.title, line: open.line, startLine: open.line + 1, lines: body, text: body.join("\n") });
88
+ open = null;
89
+ };
90
+ for (let i = 0; i < lines.length; i++) {
91
+ const h = ATX.exec(masked[i].replace(/\r$/, ""));
92
+ if (h) {
93
+ const level = h[1].length;
94
+ const m = unitRe.exec((h[2] ?? "").replace(/(^|[ \t]+)#+[ \t]*$/, "").trim());
95
+ if (m) {
96
+ close(i);
97
+ open = { n: Number(m[1]), title: m[2].trim(), line: i + 1, level };
98
+ } else if (open && level <= open.level) close(i);
99
+ }
100
+ if (open && fileEnds.has(i + 1)) close(i + 1);
101
+ }
102
+ close(lines.length);
103
+ return units;
104
+ }
105
+
106
+ // A section label line: "**Label:**", "**Label**:" or "**Label**" at the start of a line (text may
107
+ // follow), or a heading whose text is the label, with or without a closing colon. Case-insensitive.
108
+ function labelRe(label) {
109
+ const l = escapeRe(label);
110
+ return new RegExp(`^(?:[ \\t]*\\*\\*${l}(?::\\*\\*|\\*\\*:?)|[ \\t]{0,3}#{1,6}[ \\t]+${l}:?[ \\t]*#*[ \\t]*$)`, "i");
111
+ }
112
+
113
+ // The index (into unit.lines) of the first line carrying `label`, or -1. Code is masked first, so a
114
+ // label shown in an example is not the unit's own.
115
+ function labelLine(unit, label) {
116
+ const masked = maskCode(unit.text).split("\n");
117
+ const re = labelRe(label);
118
+ return masked.findIndex((l) => re.test(l.replace(/\r$/, "")));
119
+ }
120
+
121
+ // The [start, end) range of unit.lines holding the terms section: its label line, then the list
122
+ // under it, up to the first blank line after the list begins. null when the unit has no such section.
123
+ function termsRange(unit, termsSection) {
124
+ const at = labelLine(unit, termsSection);
125
+ if (at < 0) return null;
126
+ let end = at + 1;
127
+ while (end < unit.lines.length && !unit.lines[end].trim()) end++; // blank lines before the list
128
+ while (end < unit.lines.length && unit.lines[end].trim()) end++;
129
+ return { start: at, end };
130
+ }
131
+
132
+ // The terms a unit's terms section defines, normalized, each once, in order. A list item that
133
+ // reminds the reader of an earlier term, "(from Lesson 3)", defines nothing. Several bold terms
134
+ // before the item's first ":**" are all defined, so "- **A**, **B** and **C:** ..." defines three.
135
+ export function newTerms(unit, { unit: unitWord, termsSection }) {
136
+ const range = termsRange(unit, termsSection);
137
+ if (!range) return [];
138
+ const reminder = new RegExp(`\\(\\s*from\\s+${escapeRe(unitWord)}\\s+\\d+\\s*\\)`, "i");
139
+ const out = [];
140
+ for (const raw of unit.lines.slice(range.start + 1, range.end)) {
141
+ const line = raw.replace(/^[ \t]*[-*+][ \t]+/, "");
142
+ if (reminder.test(line)) continue;
143
+ let head;
144
+ if (line.includes(":**")) head = `${line.split(":**")[0]}**`;
145
+ else if (line.includes("**:")) head = line.split("**:")[0] + "**";
146
+ else head = (line.match(/\*\*(.+?)\*\*/) || [""])[0];
147
+ for (const m of head.matchAll(/\*\*(.+?)\*\*/g)) {
148
+ const t = normTerm(m[1]);
149
+ if (t && !out.includes(t)) out.push(t);
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+
155
+ // The terms an outline promises per unit: { n: [term, ...] }. An outline item is a numbered list
156
+ // line, "N. ...", running until the next numbered item at its indent or less, or a heading; its
157
+ // promise is an emphasized "*Terms: a, b, c.*" anywhere in the item.
158
+ export function outlineTerms(text) {
159
+ const out = {};
160
+ const lines = String(text).replace(/\r/g, "").split("\n");
161
+ let cur = null;
162
+ const flush = () => {
163
+ if (!cur) return;
164
+ const m = cur.text.match(/\*Terms:\s*([^*]*?)\.?\s*\*/);
165
+ if (m) out[cur.n] = m[1].split(/,\s*/).map(normTerm).filter(Boolean);
166
+ cur = null;
167
+ };
168
+ for (const line of lines) {
169
+ const item = /^([ \t]*)(\d+)\.[ \t]/.exec(line);
170
+ if (item && (!cur || item[1].length <= cur.indent)) {
171
+ flush();
172
+ cur = { n: Number(item[2]), indent: item[1].length, text: line };
173
+ } else if (/^ {0,3}#{1,6}[ \t]/.test(line)) flush();
174
+ else if (cur) cur.text += `\n${line}`;
175
+ }
176
+ flush();
177
+ return out;
178
+ }
179
+
180
+ // The unit's prose as the order and pointer guards read it: code masked and the closing teaser
181
+ // (from its "**<teaser>" line to the unit's end) blanked, lines kept so a position still maps to a
182
+ // line. withTerms false blanks the terms section too, for the order guard: a unit's terms section
183
+ // is where it defines words, and a reminder there names an earlier unit's word on purpose. The
184
+ // pointer guard keeps it, since "(Lesson 18 covers this)" in a definition is still a pointer.
185
+ function proseOf(unit, seq, { withTerms }) {
186
+ const lines = maskCode(unit.text).split("\n");
187
+ const range = withTerms ? null : termsRange(unit, seq.termsSection);
188
+ if (range) for (let i = range.start; i < range.end; i++) lines[i] = "";
189
+ const teaserRe = new RegExp(`^[ \\t]*\\*\\*${escapeRe(seq.teaser)}`, "i");
190
+ const t = lines.findIndex((l) => teaserRe.test(l));
191
+ if (t >= 0) for (let i = t; i < lines.length; i++) lines[i] = "";
192
+ return lines;
193
+ }
194
+
195
+ // The 1-based draft line of the first whole-word use of `term` in the unit's prose, or 0.
196
+ function firstUse(unit, prose, term) {
197
+ const re = new RegExp(`(^|[^a-z0-9-])${escapeRe(term)}([^a-z0-9-]|$)`, "i");
198
+ const i = prose.findIndex((l) => re.test(l));
199
+ return i < 0 ? 0 : unit.startLine + i;
200
+ }
201
+
202
+ // Whether `text` (code already masked) uses `term`, by the same whole-word rule as firstUse.
203
+ const usesTerm = (text, term) => new RegExp(`(^|[^a-z0-9-])${escapeRe(term)}([^a-z0-9-]|$)`, "i").test(text);
204
+
205
+ // Every quiz in the draft, read the way the first book written to this shape wrote it:
206
+ //
207
+ // ## Check yourself: Part 1
208
+ //
209
+ // 1. *(Lesson 1)* You type "draft an email" into a chat app. What did you just send?
210
+ // - a) A file on your computer
211
+ // - b) A prompt, sent to a chat app
212
+ // ...
213
+ // **Answers:** 1 b · 2 c
214
+ //
215
+ // A quiz starts at an ATX heading whose text starts with `quiz` (any case) and runs to the next
216
+ // heading of its own level or above, or the end of its file. A question is a numbered line tagged
217
+ // "*(<unit> N)*"; it runs to the next numbered line, the answers line or the quiz's end, and its
218
+ // options are the indented "- a) " lines inside it. The answers line is "**Answers:**" followed by
219
+ // "<number> <letter>" pairs, in any separator. Returns { quizzes, questions, untagged }: quizzes
220
+ // counts the quiz headings; each question is { n, unit, line, text, options, answer }, text with
221
+ // code masked (code is not prose), line 1-based in the draft; untagged lists { n, line } for a
222
+ // numbered line in a quiz that carries no tag.
223
+ export function parseQuizzes(draft, { unit, quiz }) {
224
+ const lines = maskCode(draft.text).split("\n").map((l) => l.replace(/\r$/, ""));
225
+ const quizRe = new RegExp(`^${escapeRe(quiz)}`, "i");
226
+ const fileEnds = new Set(list(draft.sources).map((s) => s.startLine + s.lineCount - 1));
227
+ const numbered = /^[ \t]{0,3}(\d+)\.[ \t]/;
228
+ const tagged = new RegExp(`^[ \\t]{0,3}(\\d+)\\.[ \\t]+\\*\\([ \\t]*${escapeRe(unit)}[ \\t]+(\\d+)[ \\t]*\\)\\*`, "i");
229
+ const answersRe = /^[ \t]*\*\*Answers:?\*\*:?(.*)$/i;
230
+ const optionRe = /^[ \t]+[-*+][ \t]+([a-z])\)[ \t]/i;
231
+ const out = { quizzes: 0, questions: [], untagged: [] };
232
+ for (let i = 0; i < lines.length; i++) {
233
+ const h = ATX.exec(lines[i]);
234
+ if (!h || !quizRe.test((h[2] ?? "").replace(/(^|[ \t]+)#+[ \t]*$/, "").trim())) continue;
235
+ out.quizzes += 1;
236
+ const level = h[1].length;
237
+ // [i + 1, end): stops before a heading of its level or above, or after its file's last line
238
+ // (fileEnds holds 1-based line numbers, so fileEnds.has(end) says line index end - 1 was one).
239
+ let end = i + 1;
240
+ while (end < lines.length && !fileEnds.has(end)) {
241
+ const next = ATX.exec(lines[end]);
242
+ if (next && next[1].length <= level) break;
243
+ end++;
244
+ }
245
+ const answers = new Map();
246
+ let cur = null;
247
+ const qs = [];
248
+ const flush = () => { if (cur) qs.push(cur); cur = null; };
249
+ for (let j = i + 1; j < end; j++) {
250
+ const a = answersRe.exec(lines[j]);
251
+ if (a) {
252
+ flush();
253
+ for (const m of a[1].matchAll(/(\d+)[ \t]+([a-z])(?![a-z])/gi)) answers.set(Number(m[1]), m[2].toLowerCase());
254
+ continue;
255
+ }
256
+ const t = tagged.exec(lines[j]);
257
+ if (t) {
258
+ flush();
259
+ cur = { n: Number(t[1]), unit: Number(t[2]), line: j + 1, text: lines[j], options: [] };
260
+ continue;
261
+ }
262
+ const u = numbered.exec(lines[j]);
263
+ if (u) { flush(); out.untagged.push({ n: Number(u[1]), line: j + 1 }); continue; }
264
+ if (!cur) continue;
265
+ cur.text += `\n${lines[j]}`;
266
+ const o = optionRe.exec(lines[j]);
267
+ if (o) cur.options.push(o[1].toLowerCase());
268
+ }
269
+ flush();
270
+ for (const q of qs) out.questions.push({ ...q, answer: answers.get(q.n) ?? null });
271
+ i = end - 1;
272
+ }
273
+ return out;
274
+ }
275
+
276
+ // One finding, in the shape every station returns; line only when there is one to point at.
277
+ const finding = ({ id, severity, message, fix, line }) => ({ station: name, id, severity, ...(line ? { line } : {}), message, fix });
278
+
279
+ export function run(spec, draft) {
280
+ const seq = sequenceOf(spec);
281
+ if (!seq) return { station: name, status: "skip", findings: [], reason: "the spec declares no writing.form.sequence" };
282
+ const U = seq.unit;
283
+ const findings = [];
284
+ const units = parseUnits(draft, seq);
285
+ if (!units.length) {
286
+ findings.push(finding({ id: "station-sequence-no-units", severity: "fail", message: `no "${U} <n>" heading in the draft`, fix: `Head each ${U.toLowerCase()} "## ${U} 1: <title>", or set writing.form.sequence.unit to the word the headings use.` }));
287
+ return { station: name, status: "fail", findings };
288
+ }
289
+
290
+ // numbering: each unit's number is greater than the one before it.
291
+ for (let i = 1; i < units.length; i++) {
292
+ if (units[i].n <= units[i - 1].n) {
293
+ findings.push(finding({ id: "station-sequence-numbering", severity: "fail", message: `${U} ${units[i].n} comes after ${U} ${units[i - 1].n}`, fix: `Number the ${U.toLowerCase()}s in reading order, or reorder writing.form.sequence.files.`, line: units[i].line }));
294
+ }
295
+ }
296
+
297
+ // sections, and where each term is defined.
298
+ const definedAt = new Map();
299
+ for (const u of units) {
300
+ for (const s of seq.sections) {
301
+ if (labelLine(u, s) < 0) findings.push(finding({ id: "station-sequence-missing-section", severity: "fail", message: `${U} ${u.n} has no "${s}" section`, fix: `Add a "**${s}:**" line (or a "${s}" heading) to ${U} ${u.n}.`, line: u.line }));
302
+ }
303
+ for (const t of newTerms(u, seq)) {
304
+ const first = definedAt.get(t);
305
+ if (first && first !== u) findings.push(finding({ id: "station-sequence-defined-twice", severity: "fail", message: `"${t}" is defined in ${U} ${first.n} and again in ${U} ${u.n}`, fix: `Define "${t}" once; later ${U.toLowerCase()}s remind the reader with "- **${t}** (from ${U} ${first.n}): ...".`, line: u.line }));
306
+ else if (!first) definedAt.set(t, u);
307
+ }
308
+ }
309
+
310
+ // order: no unit before the defining one uses the term.
311
+ const prose = new Map(units.map((u) => [u, proseOf(u, seq, { withTerms: false })]));
312
+ for (const [t, at] of definedAt) {
313
+ if (seq.knows.has(t)) continue;
314
+ for (const u of units) {
315
+ if (u === at) break;
316
+ const line = firstUse(u, prose.get(u), t);
317
+ if (line) findings.push(finding({ id: "station-sequence-used-before-defined", severity: "fail", message: `${U} ${u.n} uses "${t}" before ${U} ${at.n} defines it`, fix: `Rewrite ${U} ${u.n} without "${t}", define it earlier, or add it to writing.form.sequence.knows if the reader already has the word.`, line: line }));
318
+ }
319
+ }
320
+
321
+ // outline: each unit defines what the outline promises for it. A unit the outline promises that
322
+ // the draft does not hold yet is not a finding: a work is checked while it is being written.
323
+ if (seq.outline) {
324
+ let text = null;
325
+ try { text = readFileSync(resolve(spec?.dir || ".", seq.outline), "utf8"); } catch { /* reported below */ }
326
+ if (text === null) {
327
+ findings.push(finding({ id: "station-sequence-outline-unreadable", severity: "fail", message: `the outline "${seq.outline}" cannot be read`, fix: "Point writing.form.sequence.outline at the outline file, relative to the spec." }));
328
+ } else {
329
+ const promised = outlineTerms(text);
330
+ for (const u of units) {
331
+ const have = newTerms(u, seq);
332
+ for (const t of promised[u.n] ?? []) {
333
+ if (!have.includes(t)) findings.push(finding({ id: "station-sequence-outline-unkept", severity: "fail", message: `the outline promises "${t}" in ${U} ${u.n}, and ${U} ${u.n} does not define it`, fix: `Define "${t}" in ${U} ${u.n}'s ${seq.termsSection}, or change the outline.`, line: u.line }));
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ // pointers: a mention of a later unit, once per pair.
340
+ const pointerRe = new RegExp(`\\b${escapeRe(U)}\\s+(\\d+)`, "gi");
341
+ for (const u of units) {
342
+ const seen = new Set();
343
+ proseOf(u, seq, { withTerms: true }).forEach((l, i) => {
344
+ for (const m of l.matchAll(pointerRe)) {
345
+ const n = Number(m[1]);
346
+ if (n <= u.n || seen.has(n)) continue;
347
+ seen.add(n);
348
+ findings.push(finding({ id: "station-sequence-forward-pointer", severity: "warn", message: `${U} ${u.n} points forward to ${U} ${n}`, fix: `Keep it a pointer ("more in ${U} ${n}"): ${U} ${u.n} must make sense to a reader who has not read ${U} ${n}.`, line: u.startLine + i }));
349
+ }
350
+ });
351
+ }
352
+
353
+ // quiz: only when the spec names the quiz heading.
354
+ if (seq.quiz) findings.push(...quizFindings(draft, seq, definedAt));
355
+
356
+ const status = findings.some((f) => f.severity === "fail") ? "fail" : "pass";
357
+ return { station: name, status, findings };
358
+ }
359
+
360
+ // The quiz guards, against the terms the units define (term -> defining unit).
361
+ function quizFindings(draft, seq, definedAt) {
362
+ const U = seq.unit;
363
+ const out = [];
364
+ const { quizzes, questions, untagged } = parseQuizzes(draft, seq);
365
+ if (!quizzes) {
366
+ return [finding({ id: "station-sequence-quiz-missing", severity: "fail", message: `no "${seq.quiz}" heading in the draft, and writing.form.sequence.quiz names one`, fix: `Add a "## ${seq.quiz}" section of questions, or delete quiz: from the spec.` })];
367
+ }
368
+ for (const q of untagged) {
369
+ out.push(finding({ id: "station-sequence-quiz-untagged", severity: "fail", message: `question ${q.n} names no ${U.toLowerCase()} it tests`, fix: `Start the question "${q.n}. *(${U} N)* ...", naming the ${U.toLowerCase()} it tests.`, line: q.line }));
370
+ }
371
+ for (const q of questions) {
372
+ if (!q.answer || !q.options.includes(q.answer)) {
373
+ const has = q.options.length ? q.options.join(", ") : "none";
374
+ out.push(finding({ id: "station-sequence-quiz-answer", severity: "fail", message: q.answer ? `question ${q.n}'s answer is ${q.answer}, which is not one of its options (${has})` : `question ${q.n} has no answer on the quiz's "**Answers:**" line`, fix: `Give question ${q.n} one answer, "${q.n} <letter>", naming one of its options.`, line: q.line }));
375
+ }
376
+ for (const [t, at] of definedAt) {
377
+ if (seq.knows.has(t) || at.n <= q.unit) continue;
378
+ if (usesTerm(q.text, t)) out.push(finding({ id: "station-sequence-quiz-used-before-defined", severity: "fail", message: `question ${q.n}, tagged ${U} ${q.unit}, uses "${t}", which ${U} ${at.n} defines later`, fix: `Rewrite the question without "${t}", or tag it with ${U} ${at.n} or later.`, line: q.line }));
379
+ }
380
+ }
381
+ // coverage: a simple plural counts, so "skills" tests "skill".
382
+ for (const [t, at] of definedAt) {
383
+ if (!questions.some((q) => usesTerm(q.text, t) || usesTerm(q.text, `${t}s`))) {
384
+ out.push(finding({ id: "station-sequence-quiz-untested", severity: "fail", message: `no quiz question tests "${t}", which ${U} ${at.n} defines`, fix: `Add a question that uses "${t}", tagged ${U} ${at.n} or later.`, line: at.line }));
385
+ }
386
+ }
387
+ return out;
388
+ }
@@ -0,0 +1,20 @@
1
+ // Station "triage" (hyperspec 0.9). Every finding a panel reader or an outside review raised about
2
+ // the draft, held to its answer: a finding nobody answered fails, an answer that says the draft now
3
+ // does it (taken) or already did (already-true) fails unless the passage it quotes is in the draft
4
+ // as it is now, and a kept answer fails without its reason. An open finding is a warning: it is a
5
+ // decision for the operator, and the draft can ship while it waits. So is an answer that kept a
6
+ // passage, or left it open, when that passage is no longer in the draft.
7
+ //
8
+ // The rules and the file live in src/triage.mjs, which `hyperspec triage status` reads too, so the
9
+ // station and the command can never disagree. With no triage file yet the station skips.
10
+
11
+ import { triageState } from "../triage.mjs";
12
+
13
+ export const name = "triage";
14
+
15
+ export function run(spec, draft) {
16
+ const state = triageState(spec, draft);
17
+ if (state.skip) return { station: name, status: "skip", findings: [], reason: state.skip };
18
+ const status = state.findings.some((f) => f.severity === "fail") ? "fail" : "pass";
19
+ return { station: name, status, findings: state.findings };
20
+ }