@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 +26 -0
- package/README.md +1 -1
- package/SPEC.md +1 -1
- package/WRITING.md +35 -9
- package/bin/hyperspec.mjs +53 -11
- package/package.json +1 -1
- package/src/segments.mjs +44 -0
- package/src/stations/sequence.mjs +38 -4
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.
|
|
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]`
|
|
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,
|
|
406
|
-
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
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".
|
|
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);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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".
|
|
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
|
|
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 (
|
|
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) =>
|
|
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
|
}
|