@supersuit/hyperspec 0.9.0 → 0.9.1

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.1 (2026-09-30)
4
+
5
+ Two fixes, each found by the first book written as a sequence.
6
+
7
+ - The sequence station no longer reads a word inside a longer defined term as a use of the shorter
8
+ one. A book that defines "Thinking level" in Lesson 2 and "Level" in Lesson 22 failed
9
+ `station-sequence-used-before-defined` and `station-sequence-quiz-used-before-defined` at every
10
+ "thinking level" before Lesson 22. Before it looks for a term, the station now blanks every other
11
+ defined term that contains it as a whole-word phrase, a trailing plural "s" included, in the order
12
+ guard, the quiz order guard and quiz coverage. The shorter term on its own still counts, and the
13
+ longer term no longer tests the shorter one in a quiz. `longerTerms` and `maskLonger` are exported
14
+ from the station.
15
+ - `hyperspec segments init <material> --id <mid> --keep <old>` re-marks an edited material without
16
+ relabeling what did not change (issue #2). Every segment whose text, trimmed, a segment in `<old>`
17
+ has keeps that segment's id, label and every other key (`own`, `source`, `teller`, `speaker`, and
18
+ anything added by hand), with new offsets; the rest start `unlabeled` with the next unused
19
+ `s<n>` id, and the command lists them. The id is carried so the spine's citations keep pointing
20
+ at the same words. `--keep` may name the file being written, so a material is re-marked in place;
21
+ otherwise an existing file is still never overwritten. It exits 2 for a `--keep` file that cannot
22
+ be read, has a line that is not a JSON object, or marks another material. `carrySegments` in
23
+ `src/segments.mjs` is the logic.
24
+
25
+ **Behavior change:** a sequential work that defines a term inside a longer one may now pass where
26
+ 0.9.0 failed it, and a quiz whose only question on a shorter term used it inside the longer term now
27
+ fails `station-sequence-quiz-untested` for that term. No finding id changed.
28
+
3
29
  ## 0.9.0 (2026-09-29)
4
30
 
5
31
  The pressure test a draft gets before it ships is now part of the run, and so is answering it.
package/README.md CHANGED
@@ -25,7 +25,7 @@ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
25
25
  | `hyperspec lint <file...> [--json]` | Score each hyperspec against the nine tests. |
26
26
  | `hyperspec init <file> [--title T] [--kind K]` | Write a new hyperspec skeleton. Refuses to overwrite an existing file. |
27
27
  | `hyperspec init <file> --profile writing [--title T] [--form F] [--fiction]` | Write a writing-spec skeleton, every block shown with placeholders. |
28
- | `hyperspec segments init <material> --id <mid> [--out F] [--by paragraph\|sentence]` | Split a material into segments to label. Refuses to overwrite an existing file. |
28
+ | `hyperspec segments init <material> --id <mid> [--out F] [--by paragraph\|sentence] [--keep OLD]` | Split a material into segments to label. With `--keep`, re-mark an edited material: every segment whose text is unchanged keeps its id and labels, and only the rest are listed to label. Refuses to overwrite an existing file unless `--keep` names it. |
29
29
  | `hyperspec dna init <scope-dir> --writer W --form F --audience A --purpose P` | Start a writer-DNA scope folder. Refuses to overwrite an existing `scope.md`. |
30
30
  | `hyperspec dna measure <scope-dir>` | Check every golden in a scope and write its measured features. |
31
31
  | `hyperspec check <spec> [--draft <file>] [--only a,b]` | Run a writing spec's deterministic stations against a draft, or, for a sequential work, against its files in reading order. |
package/SPEC.md CHANGED
@@ -109,7 +109,7 @@ improvement:
109
109
 
110
110
  A person writing for another person leaves most of the specification unsaid, because the other person fills the gaps from shared context. An agent has none of that context, so it fills every gap with the average, and the average is what reads as middling. Hyperspecification is writing down the gaps. It is a level of detail that would feel like overkill between two people and is exactly enough for an agent: every decision the agent would otherwise guess is either decided, delegated with the rule for deciding it, or marked open, so the work stops instead of guessing.
111
111
 
112
- **Version 0.9.0** (2026-09-29)
112
+ **Version 0.9.1** (2026-09-30)
113
113
 
114
114
  ## What makes a spec a hyperspec
115
115
 
package/WRITING.md CHANGED
@@ -397,14 +397,15 @@ writing spec cannot pass until every material it draws on is marked.
397
397
  npx @supersuit/hyperspec segments init materials/voice-memo.md --id voice-memo
398
398
  ```
399
399
 
400
- `hyperspec segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence]` writes
401
- `<material>.segments.jsonl`, or the path `--out` names. `--by paragraph`, the default, makes one
400
+ `hyperspec segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence] [--keep <old>]`
401
+ writes `<material>.segments.jsonl`, or the path `--out` names. `--by paragraph`, the default, makes one
402
402
  segment per paragraph. `--by sentence` makes one per sentence, and a new line that opens on a list
403
403
  marker (`-`, `*`, `+`, `1.` or `1)`, then a space) also starts a segment, so each bullet in a set
404
404
  of notes stands on its own. Every segment starts as `unlabeled`, which lint never accepts. `init`
405
- refuses to overwrite a file that exists, and exits 2 on a material that does not exist or a
406
- `--by` it does not know, a material with nothing in it, and an `--out` folder that does not
407
- exist.
405
+ refuses to overwrite a file that exists, unless `--keep` names that file (see
406
+ [When a material changes](#when-a-material-changes)), and exits 2 on a material that does not
407
+ exist or a `--by` it does not know, a material with nothing in it, and an `--out` folder that does
408
+ not exist.
408
409
 
409
410
  Then name the file on the material item, as `segments:` beside `path:`, and label every segment.
410
411
  You may also move a boundary by hand, splitting one segment in two or joining two, as long as
@@ -482,9 +483,31 @@ fails test 1.
482
483
 
483
484
  The header's `sha256` pins the material as it was when it was marked. If the material changes,
484
485
  lint fails the segments file as stale (test 4), because its offsets and labels describe text that
485
- is no longer there. Mark it again: run `segments init` with `--out` to a new file, point the
486
- material item at it, and label every segment, carrying labels over from the old file wherever the
487
- text did not change.
486
+ is no longer there. Mark it again, keeping the labels of every stretch of text that did not change:
487
+
488
+ ```bash
489
+ npx @supersuit/hyperspec segments init materials/voice-memo.md --id voice-memo --keep materials/voice-memo.md.segments.jsonl
490
+ ```
491
+
492
+ `--keep <old>` splits the material as it reads now, and every segment whose text, trimmed, a
493
+ segment in `<old>` has keeps that segment's `id`, its `label` and every other key it carries
494
+ (`own`, `source`, `teller`, `speaker`, anything added by hand), with its `start` and `end` taken
495
+ from the new split. The `id` is kept because the spine cites segments by id, so a citation keeps
496
+ pointing at the words it pointed at. Old segments with the same text are used in order, each once.
497
+ A segment no old one matches is new or changed text: it starts `unlabeled`, gets the next `s<n>` id
498
+ no old segment used, and is listed with the start of its text, so only those need a label.
499
+ `--keep` may name the file being written, which is how a material is re-marked in place; with no
500
+ `--keep`, or one naming another file, an existing file is still never overwritten. It exits 2 when
501
+ the `--keep` file cannot be read, has a line that is not a JSON object, or marks a material other
502
+ than `--id`.
503
+
504
+ After the author's framing line, s4, is edited to "and I kept the note in my wallet.", that prints:
505
+
506
+ ```text
507
+ 5 segments written to materials/voice-memo.md.segments.jsonl, 4 labels carried from materials/voice-memo.md.segments.jsonl, 1 to label:
508
+ s6 "My first manager said this to me in my second week, and I ke..."
509
+ Label each one (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.
510
+ ```
488
511
 
489
512
  The hash is over the file's bytes, so a change nobody would call an edit still counts. Converting
490
513
  line endings is the common one: a material marked with LF endings reads as stale once an editor
@@ -1146,7 +1169,10 @@ marks and any parenthetical dropped. An item that says `(from Lesson 3)` reminds
1146
1169
  earlier term and defines nothing.
1147
1170
 
1148
1171
  A use is a whole-word match, ignoring case, where a hyphen joins a word: "context-aware" does not
1149
- use "context", and "skills" does not use "skill". Code is not prose: fenced blocks and inline code
1172
+ use "context", and "skills" does not use "skill". A word inside a longer defined term is not a
1173
+ use of the shorter one: with "level" and "thinking level" both defined, "thinking level" and
1174
+ "thinking levels" use only "thinking level", while "level" on its own still uses "level". The quiz
1175
+ rules read a question the same way. Code is not prose: fenced blocks and inline code
1150
1176
  never count as a use, so `@supersuit/superskill` does not use "supersuit". A unit's own terms
1151
1177
  section is not a use either. Listing a word in `knows` or `audience.knows` is a decision that the
1152
1178
  reader already has it, and it is the only way a unit may use a word before the unit that defines it.
package/bin/hyperspec.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
2
+ import { existsSync, statSync, writeFileSync, readFileSync, mkdirSync, realpathSync } from "node:fs";
3
3
  import { dirname, resolve, join } from "node:path";
4
4
  import { loadSpec } from "../src/load.mjs";
5
5
  import { lintSpec } from "../src/rules.mjs";
@@ -12,7 +12,7 @@ import { approve } from "../src/writer.mjs";
12
12
  import { reproduce } from "../src/reproduce.mjs";
13
13
  import { regenerate } from "../src/regenerate.mjs";
14
14
  import { compare } from "../src/compare.mjs";
15
- import { splitSegments } from "../src/segments.mjs";
15
+ import { splitSegments, carrySegments } from "../src/segments.mjs";
16
16
  import { sha256 } from "../src/hash.mjs";
17
17
  import { readScope, measureFeatures, writeFeatures, scopeTemplate, GOLDENS_README } from "../src/dna.mjs";
18
18
  import { str } from "../src/placeholder.mjs";
@@ -125,16 +125,21 @@ const HELP = `hyperspec <command> [options]
125
125
  writing, --kind with it (use --form), or a folder that does not
126
126
  exist
127
127
 
128
- segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence]
128
+ segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence] [--keep <old>]
129
129
  split a material into candidate segments, written as JSONL to
130
130
  <material>.segments.jsonl by default; every segment starts label:
131
131
  unlabeled, never valid in lint; label each one by hand (claim,
132
132
  story, quote, stance, question, aside, private), then run hyperspec
133
133
  lint on the spec; --by sentence also starts a segment at each list
134
- item (-, *, +, 1. or 1) then a space); refuses to overwrite an
135
- existing file (exit 2); exit 2 for a missing material, a material
136
- with nothing in it, an --out folder that does not exist, or a --by
137
- outside paragraph/sentence
134
+ item (-, *, +, 1. or 1) then a space); --keep re-marks an edited
135
+ material: every segment whose text, trimmed, an old segment in
136
+ <old> has keeps that segment's id, label and every other key, and
137
+ the rest are listed to label; refuses to overwrite an existing
138
+ file unless --keep names it (exit 2); exit 2 for a missing
139
+ material, a material with nothing in it, an --out folder that does
140
+ not exist, a --by outside paragraph/sentence, or a --keep file that
141
+ cannot be read, has a line that is not a JSON object, or marks
142
+ another material
138
143
 
139
144
  dna init <scope-dir> --writer W --form F --audience A --purpose P
140
145
  write a new writer-DNA scope: scope.md (writer, form, audience,
@@ -225,7 +230,7 @@ if (cmd === "segments") {
225
230
  const sub = argv[1];
226
231
 
227
232
  if (sub === "init") {
228
- const parsed = parseArgs(argv.slice(2), { valueFlags: ["--id", "--out", "--by"] });
233
+ const parsed = parseArgs(argv.slice(2), { valueFlags: ["--id", "--out", "--by", "--keep"] });
229
234
  if (parsed.error) { console.error(parsed.error); process.exit(2); }
230
235
  const [material] = parsed.positionals;
231
236
  if (!material) { console.error("segments init needs a material path"); process.exit(2); }
@@ -237,18 +242,55 @@ if (cmd === "segments") {
237
242
  try { materialStat = statSync(material); } catch { materialStat = null; }
238
243
  if (!materialStat || !materialStat.isFile()) { console.error(`material not found: ${material}`); process.exit(2); }
239
244
  const out = parsed.values["--out"] ?? `${material}.segments.jsonl`;
240
- if (existsSync(out)) { console.error(`refusing to overwrite ${out}`); process.exit(2); }
245
+ const keep = parsed.values["--keep"];
246
+ // --keep may name the file being written: re-marking in place is the point. Nothing else is
247
+ // ever overwritten.
248
+ const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
249
+ if (existsSync(out) && !(keep && samePath(keep, out))) { console.error(`refusing to overwrite ${out}${keep ? " (--keep names a different file)" : ""}`); process.exit(2); }
241
250
  const outFolder = dirname(resolve(out));
242
251
  if (!existsSync(outFolder) || !statSync(outFolder).isDirectory()) { console.error(`folder does not exist: ${dirname(out)}; create it first`); process.exit(2); }
243
252
 
253
+ // The old segments to carry labels from: every line after the header, each a JSON object.
254
+ let old = null;
255
+ if (keep) {
256
+ let raw;
257
+ try { raw = readFileSync(keep, "utf8"); } catch { console.error(`--keep file not found: ${keep}`); process.exit(2); }
258
+ const rows = raw.split("\n").map((l, i) => ({ n: i + 1, l })).filter((r) => r.l.trim());
259
+ old = [];
260
+ for (const { n, l } of rows) {
261
+ let o;
262
+ try { o = JSON.parse(l); } catch { o = null; }
263
+ if (!o || typeof o !== "object" || Array.isArray(o)) { console.error(`--keep file ${keep} line ${n} is not a JSON object; fix it, or mark the material from scratch`); process.exit(2); }
264
+ if (n === 1) {
265
+ if (str(o.material) && str(o.material) !== id) { console.error(`--keep file ${keep} marks material "${o.material}", not "${id}"`); process.exit(2); }
266
+ continue;
267
+ }
268
+ old.push(o);
269
+ }
270
+ }
271
+
244
272
  const buf = readFileSync(material);
245
273
  const text = buf.toString("utf8");
246
274
  if (!/\S/.test(text)) { console.error(`nothing to mark: ${material} has no text`); process.exit(2); }
247
- const segments = splitSegments(text, { by });
275
+ const fresh = splitSegments(text, { by });
276
+ const kept = old ? carrySegments(fresh, old) : null;
277
+ const segments = kept ? kept.segments : fresh;
248
278
  const header = { material: id, path: material, sha256: sha256(buf) };
249
279
  const lines = [JSON.stringify(header), ...segments.map((s) => JSON.stringify(s))];
250
280
  writeFileSync(out, `${lines.join("\n")}\n`);
251
- console.log(`${segments.length} segments written to ${out}. Label every segment (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.`);
281
+ if (!kept) {
282
+ console.log(`${segments.length} segments written to ${out}. Label every segment (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.`);
283
+ process.exit(0);
284
+ }
285
+ const todo = kept.unlabeled;
286
+ console.log(`${segments.length} segments written to ${out}, ${kept.carried} labels carried from ${keep}, ${todo.length} to label${todo.length ? ":" : "."}`);
287
+ for (const s of todo) {
288
+ const t = s.text.replace(/\s+/g, " ").trim();
289
+ console.log(` ${s.id} ${JSON.stringify(t.length > 60 ? `${t.slice(0, 60)}...` : t)}`);
290
+ }
291
+ console.log(todo.length
292
+ ? "Label each one (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec."
293
+ : "Run hyperspec lint on the spec.");
252
294
  process.exit(0);
253
295
  }
254
296
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/hyperspec",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "A hyperspec is a spec written for an agent: every decision accounted for, every requirement failable and checked, every field traced. The standard and its linter.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/segments.mjs CHANGED
@@ -182,6 +182,50 @@ export function splitSegments(text, { by = "paragraph" } = {}) {
182
182
  return spans.map((s, i) => ({ id: `s${i + 1}`, start: s.start, end: s.end, label: "unlabeled", text: text.slice(s.start, s.end) }));
183
183
  }
184
184
 
185
+ // -------------------------------------------------------------------------------------------
186
+ // carrySegments: re-marking an edited material without relabeling what did not change (0.9.1).
187
+ // A label is a fact about a stretch of text, so while the same text (compared trimmed) is still in
188
+ // the material, its label is still true. `fresh` is what splitSegments returned for the material as
189
+ // it reads now; `old` is the segment objects of the file it was marked in before. Each fresh
190
+ // segment whose trimmed text an old segment has takes that old segment's id, label and every other
191
+ // key it carries (own, source, teller, speaker, anything a person added), with start, end and text
192
+ // from the new split. The id is carried too, because the spine cites segments by id: "m1#s3" keeps
193
+ // pointing at the words it pointed at. Old segments with the same text are used in document order,
194
+ // each once. A fresh segment no old one matches stays "unlabeled" and gets the next id no old
195
+ // segment used, "s<n>" counting up past the highest. Returns { segments, carried, unlabeled }:
196
+ // carried counts the segments that took a label other than "unlabeled"; unlabeled lists every
197
+ // segment still to label, in document order.
198
+ export function carrySegments(fresh, old) {
199
+ const byText = new Map();
200
+ for (const o of old) {
201
+ if (!o || typeof o !== "object" || typeof o.text !== "string") continue;
202
+ const k = o.text.trim();
203
+ if (!byText.has(k)) byText.set(k, []);
204
+ byText.get(k).push(o);
205
+ }
206
+ const taken = new Set(old.map((o) => str(o?.id)).filter(Boolean));
207
+ let n = Math.max(0, ...[...taken].map((id) => Number(/^s(\d+)$/.exec(id)?.[1] ?? 0)));
208
+ const nextId = () => {
209
+ do n += 1; while (taken.has(`s${n}`));
210
+ taken.add(`s${n}`);
211
+ return `s${n}`;
212
+ };
213
+ const segments = [];
214
+ let carried = 0;
215
+ for (const seg of fresh) {
216
+ const match = byText.get(seg.text.trim())?.shift();
217
+ if (!match) {
218
+ segments.push({ id: nextId(), start: seg.start, end: seg.end, label: "unlabeled", text: seg.text });
219
+ continue;
220
+ }
221
+ const { id, start, end, label, text, ...extra } = match;
222
+ const kept = { id: str(id) || nextId(), start: seg.start, end: seg.end, label: typeof label === "string" && label ? label : "unlabeled", ...extra, text: seg.text };
223
+ if (kept.label !== "unlabeled") carried += 1;
224
+ segments.push(kept);
225
+ }
226
+ return { segments, carried, unlabeled: segments.filter((s) => s.label === "unlabeled") };
227
+ }
228
+
185
229
  // -------------------------------------------------------------------------------------------
186
230
  // readSegments: parse + validate a marked-up segments file. Never throws on a bad or missing
187
231
  // file; every failure mode becomes a finding in the lint shape (test, id, severity, message, fix),
@@ -27,7 +27,11 @@
27
27
  // way used, so the rules and the question shape are that checker's.
28
28
  //
29
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
30
+ // does not use "context", and "skills" does not use "skill". A word inside a longer defined term is
31
+ // not a use of the shorter one (hyperspec 0.9.1): with "level" and "thinking level" both defined,
32
+ // "thinking level" and "thinking levels" use only "thinking level", and "level" on its own still
33
+ // uses "level". Every guard that looks for a use (order, quiz order, quiz coverage) masks the
34
+ // longer terms first. The station reads the outline from
31
35
  // disk, resolved against the spec's folder, the way claims reads its ledger. It never reads a
32
36
  // part's text for meaning: a term used in a sense other than its definition still counts as a use.
33
37
 
@@ -202,6 +206,25 @@ function firstUse(unit, prose, term) {
202
206
  // Whether `text` (code already masked) uses `term`, by the same whole-word rule as firstUse.
203
207
  const usesTerm = (text, term) => new RegExp(`(^|[^a-z0-9-])${escapeRe(term)}([^a-z0-9-]|$)`, "i").test(text);
204
208
 
209
+ // For each defined term, the other defined terms that contain it as a whole-word phrase, longest
210
+ // first: { level: ["thinking level"] }. A term no other term contains maps to [].
211
+ export function longerTerms(terms) {
212
+ const all = [...terms];
213
+ return new Map(all.map((t) => [t, all.filter((u) => u !== t && usesTerm(u, t)).sort((a, b) => b.length - a.length)]));
214
+ }
215
+
216
+ // `text` with every whole-word occurrence of each `longer` term, a trailing plural "s" included,
217
+ // blanked to spaces. Line breaks survive, so a line number still maps to the same line, and a term
218
+ // broken across two lines is blanked on both.
219
+ export function maskLonger(text, longer) {
220
+ let out = text;
221
+ for (const t of longer) {
222
+ const re = new RegExp(`(?<![a-z0-9-])${escapeRe(t).replace(/ /g, "\\s+")}s?(?![a-z0-9-])`, "gi");
223
+ out = out.replace(re, (m) => m.replace(/[^\n]/g, " "));
224
+ }
225
+ return out;
226
+ }
227
+
205
228
  // Every quiz in the draft, read the way the first book written to this shape wrote it:
206
229
  //
207
230
  // ## Check yourself: Part 1
@@ -308,12 +331,17 @@ export function run(spec, draft) {
308
331
  }
309
332
 
310
333
  // order: no unit before the defining one uses the term.
334
+ // A longer defined term that contains the term is masked first: "thinking level" is not a use of
335
+ // "level".
311
336
  const prose = new Map(units.map((u) => [u, proseOf(u, seq, { withTerms: false })]));
337
+ const longer = longerTerms(definedAt.keys());
312
338
  for (const [t, at] of definedAt) {
313
339
  if (seq.knows.has(t)) continue;
340
+ const sups = longer.get(t);
314
341
  for (const u of units) {
315
342
  if (u === at) break;
316
- const line = firstUse(u, prose.get(u), t);
343
+ const lines = sups.length ? maskLonger(prose.get(u).join("\n"), sups).split("\n") : prose.get(u);
344
+ const line = firstUse(u, lines, t);
317
345
  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
346
  }
319
347
  }
@@ -362,6 +390,12 @@ function quizFindings(draft, seq, definedAt) {
362
390
  const U = seq.unit;
363
391
  const out = [];
364
392
  const { quizzes, questions, untagged } = parseQuizzes(draft, seq);
393
+ // Every question's text with each term's longer defined terms masked, as the order guard reads
394
+ // prose: "thinking level" in a question neither uses nor tests "level".
395
+ const longer = longerTerms(definedAt.keys());
396
+ const masked = (q, t) => (longer.get(t).length ? maskLonger(q.text, longer.get(t)) : q.text);
397
+ const uses = (q, t) => usesTerm(masked(q, t), t);
398
+ const tests = (q, t) => { const text = masked(q, t); return usesTerm(text, t) || usesTerm(text, `${t}s`); };
365
399
  if (!quizzes) {
366
400
  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
401
  }
@@ -375,12 +409,12 @@ function quizFindings(draft, seq, definedAt) {
375
409
  }
376
410
  for (const [t, at] of definedAt) {
377
411
  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 }));
412
+ if (uses(q, 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
413
  }
380
414
  }
381
415
  // coverage: a simple plural counts, so "skills" tests "skill".
382
416
  for (const [t, at] of definedAt) {
383
- if (!questions.some((q) => usesTerm(q.text, t) || usesTerm(q.text, `${t}s`))) {
417
+ if (!questions.some((q) => tests(q, t))) {
384
418
  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
419
  }
386
420
  }