@supersuit/hyperspec 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +4 -2
- package/SPEC.md +1 -1
- package/WRITING.md +64 -10
- package/bin/hyperspec.mjs +113 -11
- package/package.json +1 -1
- package/src/draft.mjs +15 -1
- package/src/ready.mjs +78 -0
- package/src/segments.mjs +84 -0
- package/src/sequence-draft.mjs +4 -4
- package/src/spec-text.mjs +85 -0
- package/src/stations/sequence.mjs +38 -4
- package/src/writing-exports.mjs +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
What a drafting loop and a per-audience renderer need to run on the engine without hand work
|
|
6
|
+
(build 7 wiring, from the letter's BUILD-NOTES).
|
|
7
|
+
|
|
8
|
+
- **A draft's frontmatter and HTML comments are not prose.** `check`, `judge` and `learn` read every
|
|
9
|
+
draft through `readDraft`, which now blanks a leading YAML frontmatter block and every
|
|
10
|
+
`<!-- ... -->` comment line for line (their characters go, their line breaks stay, so line
|
|
11
|
+
numbers are the file's own). A kept document (a title, a status, a note to self about what it
|
|
12
|
+
was meant to be) grades exactly as its prose does. Sequence drafts already blanked frontmatter;
|
|
13
|
+
they now blank comments too. `hideUnseen` in `src/draft.mjs` is the rule.
|
|
14
|
+
- **`hyperspec segments label <segments-file> <ids>=<label>[:key=value]...`** labels segments in
|
|
15
|
+
place: `s1,s4=aside`, `s3=quote:speaker=gary-sheng`, `s2=claim:own=true` (`own=true` is written as
|
|
16
|
+
the boolean). Offsets and text never change. An unknown id, a label outside the closed set or a
|
|
17
|
+
malformed assignment exits 2 with nothing written. It prints what is still unlabeled.
|
|
18
|
+
`labelSegments` and `parseLabelAssignment` in `src/segments.mjs`.
|
|
19
|
+
- **`hyperspec ready <spec> [--draft <file>] [--judges a,b] [--json]`** answers whether this draft
|
|
20
|
+
has been through the engine under this spec, from the runs ledger alone: the latest full check
|
|
21
|
+
of these exact draft and spec bytes passed; every required judge (the list given, or every judge
|
|
22
|
+
that applies) passed on the same bytes, each panel reader including the buyer; and that check
|
|
23
|
+
came after the last of those judge lines, so the panel's findings were triaged and held. Exit 0
|
|
24
|
+
ready, 1 not ready with each missing step listed, 2 usage.
|
|
25
|
+
- **`specText(data, body)` and `parseSpecText(text)`**, exported from `@supersuit/hyperspec/writing`, writes a spec's text
|
|
26
|
+
from data and reads it back with the linter's own reader, throwing (and naming the field) when
|
|
27
|
+
any value would not come back unchanged. For tools that build specs rather than people typing
|
|
28
|
+
them.
|
|
29
|
+
|
|
30
|
+
**Behavior change:** a draft whose frontmatter or comments held words now counts fewer words, and a
|
|
31
|
+
term that first appeared in a comment now first appears in the prose. No finding id changed.
|
|
32
|
+
|
|
33
|
+
## 0.9.1 (2026-09-30)
|
|
34
|
+
|
|
35
|
+
Two fixes, each found by the first book written as a sequence.
|
|
36
|
+
|
|
37
|
+
- The sequence station no longer reads a word inside a longer defined term as a use of the shorter
|
|
38
|
+
one. A book that defines "Thinking level" in Lesson 2 and "Level" in Lesson 22 failed
|
|
39
|
+
`station-sequence-used-before-defined` and `station-sequence-quiz-used-before-defined` at every
|
|
40
|
+
"thinking level" before Lesson 22. Before it looks for a term, the station now blanks every other
|
|
41
|
+
defined term that contains it as a whole-word phrase, a trailing plural "s" included, in the order
|
|
42
|
+
guard, the quiz order guard and quiz coverage. The shorter term on its own still counts, and the
|
|
43
|
+
longer term no longer tests the shorter one in a quiz. `longerTerms` and `maskLonger` are exported
|
|
44
|
+
from the station.
|
|
45
|
+
- `hyperspec segments init <material> --id <mid> --keep <old>` re-marks an edited material without
|
|
46
|
+
relabeling what did not change (issue #2). Every segment whose text, trimmed, a segment in `<old>`
|
|
47
|
+
has keeps that segment's id, label and every other key (`own`, `source`, `teller`, `speaker`, and
|
|
48
|
+
anything added by hand), with new offsets; the rest start `unlabeled` with the next unused
|
|
49
|
+
`s<n>` id, and the command lists them. The id is carried so the spine's citations keep pointing
|
|
50
|
+
at the same words. `--keep` may name the file being written, so a material is re-marked in place;
|
|
51
|
+
otherwise an existing file is still never overwritten. It exits 2 for a `--keep` file that cannot
|
|
52
|
+
be read, has a line that is not a JSON object, or marks another material. `carrySegments` in
|
|
53
|
+
`src/segments.mjs` is the logic.
|
|
54
|
+
|
|
55
|
+
**Behavior change:** a sequential work that defines a term inside a longer one may now pass where
|
|
56
|
+
0.9.0 failed it, and a quiz whose only question on a shorter term used it inside the longer term now
|
|
57
|
+
fails `station-sequence-quiz-untested` for that term. No finding id changed.
|
|
58
|
+
|
|
3
59
|
## 0.9.0 (2026-09-29)
|
|
4
60
|
|
|
5
61
|
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,10 +25,12 @@ 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
|
+
| `hyperspec segments label <segments-file> <ids>=<label>[:key=value]...` | Label segments in place (`s1,s4=aside`, `s3=quote:speaker=gary-sheng`, `s2=claim:own=true`); offsets and text never change. Refuses an unknown id or label with nothing written. |
|
|
29
30
|
| `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
31
|
| `hyperspec dna measure <scope-dir>` | Check every golden in a scope and write its measured features. |
|
|
31
32
|
| `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. |
|
|
33
|
+
| `hyperspec ready <spec> --draft <file> [--judges a,b]` | Has this draft been through the engine? From the runs ledger only: a passing full check of these exact bytes, every required judge passing on them (each panel reader, the buyer included), and the check after the last judge so triage was held. Exit 0 ready, 1 not ready with each missing step listed. |
|
|
32
34
|
| `hyperspec judge prepare <spec> --draft <file> --out <dir> [--only a,b] [--force]` | Write one packet per judgment station, for an outside judge to fill. |
|
|
33
35
|
| `hyperspec judge record <packet> --verdict <file>` | Check a judge's verdict against its packet, derive the station's status, and record it. |
|
|
34
36
|
| `hyperspec triage status <spec> [--draft <file>]` | Count every finding's answer, list the passages two or more readers share, and hold every answer to the draft. |
|
|
@@ -43,7 +45,7 @@ improvement ledger. Every test is defined in [SPEC.md](SPEC.md).
|
|
|
43
45
|
| `hyperspec regenerate <recipe> --out <path> --clicker <slug> <one change> [--run cmd]` | Make a child recipe from a parent and one named change, rerunning only the stages it reaches. |
|
|
44
46
|
| `hyperspec compare <child-recipe> --doctor cmd` | Grade a child and its parent through one doctor against one spec. |
|
|
45
47
|
|
|
46
|
-
Every command except `init`, `segments init` and `dna init` takes `--json`. `hyperspec --help` prints every flag.
|
|
48
|
+
Every command except `init`, `segments init`, `segments label` and `dna init` takes `--json`. `hyperspec --help` prints every flag.
|
|
47
49
|
|
|
48
50
|
## Exit codes
|
|
49
51
|
|
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.
|
|
112
|
+
**Version 0.10.0** (2026-10-02)
|
|
113
113
|
|
|
114
114
|
## What makes a spec a hyperspec
|
|
115
115
|
|
package/WRITING.md
CHANGED
|
@@ -397,17 +397,21 @@ 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
|
+
`hyperspec segments label <segments-file> <ids>=<label>[:key=value]...` does it without editing
|
|
412
|
+
JSON by hand: `s1,s4=aside`, `s3=quote:speaker=gary-sheng`, `s2=claim:own=true`. Offsets and text
|
|
413
|
+
never change, an unknown id or a label outside the set is refused with nothing written, and it
|
|
414
|
+
prints what is still unlabeled. You may also move a boundary by hand, splitting one segment in two or joining two, as long as
|
|
411
415
|
the rules under [Coverage](#coverage) still hold.
|
|
412
416
|
|
|
413
417
|
### The segments file
|
|
@@ -482,9 +486,31 @@ fails test 1.
|
|
|
482
486
|
|
|
483
487
|
The header's `sha256` pins the material as it was when it was marked. If the material changes,
|
|
484
488
|
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
|
-
|
|
489
|
+
is no longer there. Mark it again, keeping the labels of every stretch of text that did not change:
|
|
490
|
+
|
|
491
|
+
```bash
|
|
492
|
+
npx @supersuit/hyperspec segments init materials/voice-memo.md --id voice-memo --keep materials/voice-memo.md.segments.jsonl
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
`--keep <old>` splits the material as it reads now, and every segment whose text, trimmed, a
|
|
496
|
+
segment in `<old>` has keeps that segment's `id`, its `label` and every other key it carries
|
|
497
|
+
(`own`, `source`, `teller`, `speaker`, anything added by hand), with its `start` and `end` taken
|
|
498
|
+
from the new split. The `id` is kept because the spine cites segments by id, so a citation keeps
|
|
499
|
+
pointing at the words it pointed at. Old segments with the same text are used in order, each once.
|
|
500
|
+
A segment no old one matches is new or changed text: it starts `unlabeled`, gets the next `s<n>` id
|
|
501
|
+
no old segment used, and is listed with the start of its text, so only those need a label.
|
|
502
|
+
`--keep` may name the file being written, which is how a material is re-marked in place; with no
|
|
503
|
+
`--keep`, or one naming another file, an existing file is still never overwritten. It exits 2 when
|
|
504
|
+
the `--keep` file cannot be read, has a line that is not a JSON object, or marks a material other
|
|
505
|
+
than `--id`.
|
|
506
|
+
|
|
507
|
+
After the author's framing line, s4, is edited to "and I kept the note in my wallet.", that prints:
|
|
508
|
+
|
|
509
|
+
```text
|
|
510
|
+
5 segments written to materials/voice-memo.md.segments.jsonl, 4 labels carried from materials/voice-memo.md.segments.jsonl, 1 to label:
|
|
511
|
+
s6 "My first manager said this to me in my second week, and I ke..."
|
|
512
|
+
Label each one (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec.
|
|
513
|
+
```
|
|
488
514
|
|
|
489
515
|
The hash is over the file's bytes, so a change nobody would call an edit still counts. Converting
|
|
490
516
|
line endings is the common one: a material marked with LF endings reads as stale once an editor
|
|
@@ -821,6 +847,10 @@ against the draft:
|
|
|
821
847
|
npx @supersuit/hyperspec check essay.hyperspec.md --draft essay/draft.md
|
|
822
848
|
```
|
|
823
849
|
|
|
850
|
+
A draft is read as a reader sees it: a leading YAML frontmatter block and every HTML comment
|
|
851
|
+
are blanked line for line, so a kept document (a title, a status, a note to self) grades exactly
|
|
852
|
+
as its prose does, and every line number is still the file's own.
|
|
853
|
+
|
|
824
854
|
It lints the spec first. A spec that fails lint, or is blocked on an open decision, runs no
|
|
825
855
|
station and exits with lint's own code, because a draft cannot be checked against a spec that is
|
|
826
856
|
not ready. Then it runs nine stations in a fixed order and prints one line for each: `pass`,
|
|
@@ -1146,7 +1176,10 @@ marks and any parenthetical dropped. An item that says `(from Lesson 3)` reminds
|
|
|
1146
1176
|
earlier term and defines nothing.
|
|
1147
1177
|
|
|
1148
1178
|
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".
|
|
1179
|
+
use "context", and "skills" does not use "skill". A word inside a longer defined term is not a
|
|
1180
|
+
use of the shorter one: with "level" and "thinking level" both defined, "thinking level" and
|
|
1181
|
+
"thinking levels" use only "thinking level", while "level" on its own still uses "level". The quiz
|
|
1182
|
+
rules read a question the same way. Code is not prose: fenced blocks and inline code
|
|
1150
1183
|
never count as a use, so `@supersuit/superskill` does not use "supersuit". A unit's own terms
|
|
1151
1184
|
section is not a use either. Listing a word in `knows` or `audience.knows` is a decision that the
|
|
1152
1185
|
reader already has it, and it is the only way a unit may use a word before the unit that defines it.
|
|
@@ -1841,6 +1874,27 @@ cannot be read. `--json` prints the whole result.
|
|
|
1841
1874
|
| `triage-import-quote-not-found` | warn | an imported finding quotes text, and none of it is in the draft |
|
|
1842
1875
|
| `triage-import-empty` | invalid | the review holds no finding to answer |
|
|
1843
1876
|
|
|
1877
|
+
## Is it ready
|
|
1878
|
+
|
|
1879
|
+
```bash
|
|
1880
|
+
npx @supersuit/hyperspec ready essay.hyperspec.md --draft essay/draft.md
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
`hyperspec ready <spec> [--draft <file>] [--judges a,b] [--json]` answers one question for a
|
|
1884
|
+
tool that hands a draft on (to a person, to a send step): has THIS draft been through the engine
|
|
1885
|
+
under THIS spec? It reads the runs ledger and runs nothing. Ready means all three:
|
|
1886
|
+
|
|
1887
|
+
- the latest full check of these exact draft and spec bytes passed (an `--only` run proves
|
|
1888
|
+
nothing about the stations it skipped);
|
|
1889
|
+
- every required judge has a line for the same bytes and the latest passed; required is the
|
|
1890
|
+
`--judges` list, or every judgment station that applies to the spec and draft, and the panel
|
|
1891
|
+
needs a line for every reader, the buyer included;
|
|
1892
|
+
- that check comes after the last of those judge lines, so the panel's findings went to triage
|
|
1893
|
+
and a check held every answer.
|
|
1894
|
+
|
|
1895
|
+
It exits 0 ready, 1 not ready with each missing step listed, 2 usage. Edit the draft or the spec
|
|
1896
|
+
and it is not ready until it is graded again, because every rule is about the bytes.
|
|
1897
|
+
|
|
1844
1898
|
## Learning from edits
|
|
1845
1899
|
|
|
1846
1900
|
A factory writes a first draft, and a person edits it into the draft they approve. Every edit is
|
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, labelSegments } 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";
|
|
@@ -21,6 +21,7 @@ import { prepareJudges, recordJudgment } from "../src/judge.mjs";
|
|
|
21
21
|
import { prepareLearn, recordLearn, tallyLine } from "../src/learn.mjs";
|
|
22
22
|
import { triageContext, triageState, answerFinding, importReview, replyText } from "../src/triage.mjs";
|
|
23
23
|
import { sourceAt } from "../src/sequence-draft.mjs";
|
|
24
|
+
import { ready } from "../src/ready.mjs";
|
|
24
25
|
|
|
25
26
|
const HELP = `hyperspec <command> [options]
|
|
26
27
|
|
|
@@ -41,6 +42,14 @@ const HELP = `hyperspec <command> [options]
|
|
|
41
42
|
exit 0 every run station passed, 1 a station failed, 2 usage
|
|
42
43
|
(including a missing draft file, a spec without the writing
|
|
43
44
|
profile, or an --only that names no known station)
|
|
45
|
+
ready <spec> [--draft <file>] [--judges a,b] [--json]
|
|
46
|
+
has this draft been through the engine under this spec? read from
|
|
47
|
+
the runs ledger only: the latest full check of these exact draft
|
|
48
|
+
and spec bytes passed, every required judge (the --judges list, or
|
|
49
|
+
every judge that applies; each panel reader, the buyer included)
|
|
50
|
+
passed on the same bytes, and that check came after the last of
|
|
51
|
+
those judge lines, so panel findings were triaged and held
|
|
52
|
+
exit 0 ready, 1 not ready (each missing step listed), 2 usage
|
|
44
53
|
judge prepare <spec> --draft <file> --out <dir> [--only a,b] [--force] [--json]
|
|
45
54
|
needs a writing spec that passes lint (exits with lint's own code
|
|
46
55
|
otherwise); writes one <station>.packet.json per applicable
|
|
@@ -125,16 +134,28 @@ const HELP = `hyperspec <command> [options]
|
|
|
125
134
|
writing, --kind with it (use --form), or a folder that does not
|
|
126
135
|
exist
|
|
127
136
|
|
|
128
|
-
segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence]
|
|
137
|
+
segments init <material> --id <mid> [--out <file>] [--by paragraph|sentence] [--keep <old>]
|
|
129
138
|
split a material into candidate segments, written as JSONL to
|
|
130
139
|
<material>.segments.jsonl by default; every segment starts label:
|
|
131
140
|
unlabeled, never valid in lint; label each one by hand (claim,
|
|
132
141
|
story, quote, stance, question, aside, private), then run hyperspec
|
|
133
142
|
lint on the spec; --by sentence also starts a segment at each list
|
|
134
|
-
item (-, *, +, 1. or 1) then a space);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
item (-, *, +, 1. or 1) then a space); --keep re-marks an edited
|
|
144
|
+
material: every segment whose text, trimmed, an old segment in
|
|
145
|
+
<old> has keeps that segment's id, label and every other key, and
|
|
146
|
+
the rest are listed to label; refuses to overwrite an existing
|
|
147
|
+
file unless --keep names it (exit 2); exit 2 for a missing
|
|
148
|
+
material, a material with nothing in it, an --out folder that does
|
|
149
|
+
not exist, a --by outside paragraph/sentence, or a --keep file that
|
|
150
|
+
cannot be read, has a line that is not a JSON object, or marks
|
|
151
|
+
another material
|
|
152
|
+
|
|
153
|
+
segments label <segments-file> <ids>=<label>[:key=value]...
|
|
154
|
+
label segments in place: ids a comma list (s1,s4), the label from
|
|
155
|
+
the closed set, then per-label fields (speaker=, teller=, source=,
|
|
156
|
+
own=true); offsets and text are never touched; refuses an unknown
|
|
157
|
+
id, a label outside the set or a malformed assignment with nothing
|
|
158
|
+
written (exit 2); prints what is still unlabeled
|
|
138
159
|
|
|
139
160
|
dna init <scope-dir> --writer W --form F --audience A --purpose P
|
|
140
161
|
write a new writer-DNA scope: scope.md (writer, form, audience,
|
|
@@ -225,7 +246,7 @@ if (cmd === "segments") {
|
|
|
225
246
|
const sub = argv[1];
|
|
226
247
|
|
|
227
248
|
if (sub === "init") {
|
|
228
|
-
const parsed = parseArgs(argv.slice(2), { valueFlags: ["--id", "--out", "--by"] });
|
|
249
|
+
const parsed = parseArgs(argv.slice(2), { valueFlags: ["--id", "--out", "--by", "--keep"] });
|
|
229
250
|
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
230
251
|
const [material] = parsed.positionals;
|
|
231
252
|
if (!material) { console.error("segments init needs a material path"); process.exit(2); }
|
|
@@ -237,18 +258,73 @@ if (cmd === "segments") {
|
|
|
237
258
|
try { materialStat = statSync(material); } catch { materialStat = null; }
|
|
238
259
|
if (!materialStat || !materialStat.isFile()) { console.error(`material not found: ${material}`); process.exit(2); }
|
|
239
260
|
const out = parsed.values["--out"] ?? `${material}.segments.jsonl`;
|
|
240
|
-
|
|
261
|
+
const keep = parsed.values["--keep"];
|
|
262
|
+
// --keep may name the file being written: re-marking in place is the point. Nothing else is
|
|
263
|
+
// ever overwritten.
|
|
264
|
+
const samePath = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
|
|
265
|
+
if (existsSync(out) && !(keep && samePath(keep, out))) { console.error(`refusing to overwrite ${out}${keep ? " (--keep names a different file)" : ""}`); process.exit(2); }
|
|
241
266
|
const outFolder = dirname(resolve(out));
|
|
242
267
|
if (!existsSync(outFolder) || !statSync(outFolder).isDirectory()) { console.error(`folder does not exist: ${dirname(out)}; create it first`); process.exit(2); }
|
|
243
268
|
|
|
269
|
+
// The old segments to carry labels from: every line after the header, each a JSON object.
|
|
270
|
+
let old = null;
|
|
271
|
+
if (keep) {
|
|
272
|
+
let raw;
|
|
273
|
+
try { raw = readFileSync(keep, "utf8"); } catch { console.error(`--keep file not found: ${keep}`); process.exit(2); }
|
|
274
|
+
const rows = raw.split("\n").map((l, i) => ({ n: i + 1, l })).filter((r) => r.l.trim());
|
|
275
|
+
old = [];
|
|
276
|
+
for (const { n, l } of rows) {
|
|
277
|
+
let o;
|
|
278
|
+
try { o = JSON.parse(l); } catch { o = null; }
|
|
279
|
+
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); }
|
|
280
|
+
if (n === 1) {
|
|
281
|
+
if (str(o.material) && str(o.material) !== id) { console.error(`--keep file ${keep} marks material "${o.material}", not "${id}"`); process.exit(2); }
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
old.push(o);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
244
288
|
const buf = readFileSync(material);
|
|
245
289
|
const text = buf.toString("utf8");
|
|
246
290
|
if (!/\S/.test(text)) { console.error(`nothing to mark: ${material} has no text`); process.exit(2); }
|
|
247
|
-
const
|
|
291
|
+
const fresh = splitSegments(text, { by });
|
|
292
|
+
const kept = old ? carrySegments(fresh, old) : null;
|
|
293
|
+
const segments = kept ? kept.segments : fresh;
|
|
248
294
|
const header = { material: id, path: material, sha256: sha256(buf) };
|
|
249
295
|
const lines = [JSON.stringify(header), ...segments.map((s) => JSON.stringify(s))];
|
|
250
296
|
writeFileSync(out, `${lines.join("\n")}\n`);
|
|
251
|
-
|
|
297
|
+
if (!kept) {
|
|
298
|
+
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.`);
|
|
299
|
+
process.exit(0);
|
|
300
|
+
}
|
|
301
|
+
const todo = kept.unlabeled;
|
|
302
|
+
console.log(`${segments.length} segments written to ${out}, ${kept.carried} labels carried from ${keep}, ${todo.length} to label${todo.length ? ":" : "."}`);
|
|
303
|
+
for (const s of todo) {
|
|
304
|
+
const t = s.text.replace(/\s+/g, " ").trim();
|
|
305
|
+
console.log(` ${s.id} ${JSON.stringify(t.length > 60 ? `${t.slice(0, 60)}...` : t)}`);
|
|
306
|
+
}
|
|
307
|
+
console.log(todo.length
|
|
308
|
+
? "Label each one (claim, story, quote, stance, question, aside, private), then run hyperspec lint on the spec."
|
|
309
|
+
: "Run hyperspec lint on the spec.");
|
|
310
|
+
process.exit(0);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (sub === "label") {
|
|
314
|
+
const [file, ...assignments] = argv.slice(2);
|
|
315
|
+
if (!file || !assignments.length) { console.error("segments label needs a segments file and at least one <ids>=<label>[:key=value] assignment"); process.exit(2); }
|
|
316
|
+
let raw;
|
|
317
|
+
try { raw = readFileSync(file, "utf8"); } catch { console.error(`segments file not found: ${file}`); process.exit(2); }
|
|
318
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
319
|
+
let parsed;
|
|
320
|
+
try { parsed = lines.map((l) => JSON.parse(l)); } catch { console.error(`${file} has a line that is not JSON; fix it before labeling`); process.exit(2); }
|
|
321
|
+
const [header, ...segments] = parsed;
|
|
322
|
+
const done = labelSegments(segments, assignments);
|
|
323
|
+
if (done.error) { console.error(`segments label: ${done.error}; nothing written`); process.exit(2); }
|
|
324
|
+
writeFileSync(file, `${[header, ...done.segments].map((o) => JSON.stringify(o)).join("\n")}\n`);
|
|
325
|
+
const touched = new Set(assignments.flatMap((a) => a.slice(0, a.indexOf("=")).split(",").map((s) => s.trim())));
|
|
326
|
+
const todo = done.segments.filter((s) => s.label === "unlabeled").map((s) => s.id);
|
|
327
|
+
console.log(`${touched.size} segments labeled in ${file}${todo.length ? `; ${todo.length} still unlabeled: ${todo.join(", ")}` : "; every segment is labeled. Run hyperspec lint on the spec."}`);
|
|
252
328
|
process.exit(0);
|
|
253
329
|
}
|
|
254
330
|
|
|
@@ -373,6 +449,32 @@ if (cmd === "lint") {
|
|
|
373
449
|
process.exit(worst);
|
|
374
450
|
}
|
|
375
451
|
|
|
452
|
+
if (cmd === "ready") {
|
|
453
|
+
const parsed = parseArgs(argv.slice(1), { valueFlags: ["--draft", "--judges"], boolFlags: ["--json"] });
|
|
454
|
+
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
|
455
|
+
const [specPath] = parsed.positionals;
|
|
456
|
+
const json = parsed.values["--json"];
|
|
457
|
+
const usage = (error) => {
|
|
458
|
+
if (json) console.log(JSON.stringify({ spec: specPath ?? null, draft: parsed.values["--draft"] ?? null, error }, null, 2));
|
|
459
|
+
else console.error(error);
|
|
460
|
+
process.exit(2);
|
|
461
|
+
};
|
|
462
|
+
if (!specPath) usage("ready needs a spec path");
|
|
463
|
+
let judges;
|
|
464
|
+
if (parsed.values["--judges"] !== undefined) {
|
|
465
|
+
judges = parsed.values["--judges"].split(",").map((s) => s.trim()).filter(Boolean);
|
|
466
|
+
if (!judges.length) usage("--judges names no judge");
|
|
467
|
+
}
|
|
468
|
+
const r = ready(specPath, parsed.values["--draft"], { judges });
|
|
469
|
+
if (r.usage) usage(r.error);
|
|
470
|
+
if (json) console.log(JSON.stringify({ spec: specPath, draft: r.draft, ready: r.ready, judges: r.judges, missing: r.missing }, null, 2));
|
|
471
|
+
else {
|
|
472
|
+
console.log(r.ready ? `ready: checked and judged (${r.judges.join(", ")}) on these exact bytes` : "not ready:");
|
|
473
|
+
for (const m of r.missing) console.log(` - ${m}`);
|
|
474
|
+
}
|
|
475
|
+
process.exit(r.code);
|
|
476
|
+
}
|
|
477
|
+
|
|
376
478
|
if (cmd === "check") {
|
|
377
479
|
const parsed = parseArgs(argv.slice(1), { valueFlags: ["--draft", "--only"], boolFlags: ["--json"] });
|
|
378
480
|
if (parsed.error) { console.error(parsed.error); process.exit(2); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/hyperspec",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
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/draft.mjs
CHANGED
|
@@ -21,6 +21,20 @@ export function splitLines(text) {
|
|
|
21
21
|
export function readDraft(draftPathArg) {
|
|
22
22
|
let buf;
|
|
23
23
|
try { buf = readFileSync(resolve(draftPathArg)); } catch { return null; }
|
|
24
|
-
const text = buf.toString("utf8").replace(
|
|
24
|
+
const text = hideUnseen(buf.toString("utf8").replace(/^\uFEFF/, ""));
|
|
25
25
|
return { path: draftPathArg, text, lines: splitLines(text), sha256: sha256(buf) };
|
|
26
26
|
}
|
|
27
|
+
|
|
28
|
+
// WHAT A READER NEVER SEES IS NOT PROSE (0.10). A leading YAML frontmatter block and every HTML
|
|
29
|
+
// comment are blanked line for line: their characters go and their line breaks stay, so every
|
|
30
|
+
// line number a finding names is still the file's own. A draft that is a kept document (a title,
|
|
31
|
+
// a status, a note to self about what it was meant to be) then grades exactly as its prose does:
|
|
32
|
+
// no hidden word counts toward length, is a term's first use, or needs a claim. Sequence drafts
|
|
33
|
+
// already blanked frontmatter per file (src/sequence-draft.mjs); this is the same rule for every
|
|
34
|
+
// draft. Offsets inside a line can move; evidence is matched by text, never by column.
|
|
35
|
+
export function hideUnseen(text) {
|
|
36
|
+
const blank = (s) => s.replace(/[^\n]/g, "");
|
|
37
|
+
return text
|
|
38
|
+
.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, blank)
|
|
39
|
+
.replace(/<!--[\s\S]*?-->/g, blank);
|
|
40
|
+
}
|
package/src/ready.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `hyperspec ready` (0.10): has this draft, under this spec, been through the engine?
|
|
2
|
+
//
|
|
3
|
+
// Answered from the runs ledger alone (improvement.ledger), never by running a station or a judge,
|
|
4
|
+
// so a caller can gate a handoff on it cheaply and deterministically: a drafting loop before it
|
|
5
|
+
// shows a person the draft, a per-audience renderer before a version may be sent. Every rule
|
|
6
|
+
// below is about the BYTES, the same hashes check and judge record write, so a draft or spec
|
|
7
|
+
// edited after it was graded is never ready on the strength of grades it no longer has.
|
|
8
|
+
//
|
|
9
|
+
// 1. the latest FULL check of these draft and spec bytes passed (a partial --only run proves
|
|
10
|
+
// nothing about the stations it skipped);
|
|
11
|
+
// 2. every required judge has a line for these bytes and its latest says pass; the panel needs
|
|
12
|
+
// one per reader, its buyer (the audience's own reader) included;
|
|
13
|
+
// 3. that check is later in the ledger than every one of those judge lines, so the panel's
|
|
14
|
+
// findings went to triage and a check held every answer, instead of being recorded and left.
|
|
15
|
+
//
|
|
16
|
+
// Required judges: the caller's list, or every judgment station that applies to this spec and
|
|
17
|
+
// draft (src/judges/index.mjs skipReason is null).
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from "node:fs";
|
|
20
|
+
import { resolve } from "node:path";
|
|
21
|
+
import { loadWritingSpec } from "./check.mjs";
|
|
22
|
+
import { readDraft } from "./draft.mjs";
|
|
23
|
+
import { readSequenceDraft, sequenceFilesDecl } from "./sequence-draft.mjs";
|
|
24
|
+
import { openLedger } from "./ledger.mjs";
|
|
25
|
+
import { sha256 } from "./hash.mjs";
|
|
26
|
+
import { JUDGES, JUDGE_NAMES } from "./judges/index.mjs";
|
|
27
|
+
|
|
28
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
29
|
+
|
|
30
|
+
export function ready(specPathArg, draftPathArg, { judges } = {}) {
|
|
31
|
+
const loaded = loadWritingSpec(specPathArg, "ready");
|
|
32
|
+
if (loaded.usage) return { usage: true, error: loaded.error };
|
|
33
|
+
const { spec } = loaded;
|
|
34
|
+
const fromSequence = !present(draftPathArg);
|
|
35
|
+
if (fromSequence && !sequenceFilesDecl(spec).length) return { usage: true, error: "ready needs --draft <file>" };
|
|
36
|
+
if (judges) {
|
|
37
|
+
const unknown = judges.filter((j) => !JUDGE_NAMES.includes(j));
|
|
38
|
+
if (unknown.length) return { usage: true, error: `unknown judge${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}; known: ${JUDGE_NAMES.join(", ")}` };
|
|
39
|
+
}
|
|
40
|
+
const draft = fromSequence ? readSequenceDraft(spec, specPathArg) : readDraft(draftPathArg);
|
|
41
|
+
if (!draft) return { usage: true, error: `cannot read draft: ${draftPathArg ?? "the sequence files"}` };
|
|
42
|
+
const specSha = sha256(readFileSync(resolve(specPathArg)));
|
|
43
|
+
|
|
44
|
+
const required = judges ?? JUDGES.filter((j) => j.skipReason(spec, draft) === null).map((j) => j.name);
|
|
45
|
+
const ledger = openLedger(spec);
|
|
46
|
+
const missing = [];
|
|
47
|
+
if (!ledger || ledger.warning) {
|
|
48
|
+
missing.push(ledger?.warning ?? "the spec declares no improvement.ledger, so nothing it was graded on is recorded");
|
|
49
|
+
return { ok: true, ready: false, judges: required, missing, code: 1 };
|
|
50
|
+
}
|
|
51
|
+
const lines = ledger.priorText.split("\n").filter((l) => l.trim())
|
|
52
|
+
.map((l, i) => { try { return { ...JSON.parse(l), _n: i }; } catch { return null; } })
|
|
53
|
+
.filter((l) => l && l.draft_sha256 === draft.sha256 && l.spec_sha256 === specSha);
|
|
54
|
+
|
|
55
|
+
const check = lines.filter((l) => l.kind === "check" && l.partial !== true).at(-1);
|
|
56
|
+
if (!check) missing.push("no full check of this draft under this spec; run hyperspec check");
|
|
57
|
+
else {
|
|
58
|
+
const failing = Object.entries(check.stations ?? {}).filter(([, s]) => s === "fail").map(([n]) => n);
|
|
59
|
+
if (failing.length) missing.push(`the last check failed: ${failing.join(", ")}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let lastJudge = -1;
|
|
63
|
+
for (const name of required) {
|
|
64
|
+
const judge = JUDGES.find((j) => j.name === name);
|
|
65
|
+
const of = lines.filter((l) => l.kind === "judge" && l.station === name);
|
|
66
|
+
const variants = judge.variants ? judge.variants(spec).map((v) => v.id) : [null];
|
|
67
|
+
for (const v of variants) {
|
|
68
|
+
const label = v ? `${name} (${v})` : name;
|
|
69
|
+
const last = of.filter((l) => (v ? l.reader === v : true)).at(-1);
|
|
70
|
+
if (!last) { missing.push(`${label} has not judged this draft under this spec`); continue; }
|
|
71
|
+
lastJudge = Math.max(lastJudge, last._n);
|
|
72
|
+
if (last.status !== "pass") missing.push(`${label} did not pass: ${last.status}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (check && lastJudge > check._n) missing.push("a judge recorded after the last check; run hyperspec check again so its findings are held");
|
|
76
|
+
|
|
77
|
+
return { ok: true, ready: missing.length === 0, judges: required, missing, draft: draft.path, code: missing.length ? 1 : 0 };
|
|
78
|
+
}
|
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),
|
|
@@ -405,3 +449,43 @@ export function readSegments(segmentsPath, { materialPath, materialId, displayPa
|
|
|
405
449
|
|
|
406
450
|
return { header, segments, findings };
|
|
407
451
|
}
|
|
452
|
+
|
|
453
|
+
// ── labeling (0.10) ──────────────────────────────────────────────────────────────────────────
|
|
454
|
+
// One assignment is "<ids>=<label>[:key=value]...": ids a comma list ("s1,s4"), the label from the
|
|
455
|
+
// closed set, then any per-label fields (speaker=, teller=, source=, own=true). own's "true" is
|
|
456
|
+
// written as the boolean the format reads. Parsed and applied as pure data so a caller (the CLI,
|
|
457
|
+
// a capture stage) can label without hand-editing JSONL, and so a bad assignment is refused
|
|
458
|
+
// before anything is written: { segments } on success, { error } on the first problem.
|
|
459
|
+
export function parseLabelAssignment(raw) {
|
|
460
|
+
const eq = String(raw).indexOf("=");
|
|
461
|
+
if (eq < 1) return { error: `"${raw}" is not <ids>=<label>[:key=value]...` };
|
|
462
|
+
const ids = raw.slice(0, eq).split(",").map((s) => s.trim()).filter(Boolean);
|
|
463
|
+
const [label, ...pairs] = raw.slice(eq + 1).split(":");
|
|
464
|
+
if (!MATERIAL_LABELS.includes(label)) return { error: `"${label}" is not a label; use one of ${MATERIAL_LABELS.join(", ")}` };
|
|
465
|
+
const fields = {};
|
|
466
|
+
for (const p of pairs) {
|
|
467
|
+
const k = p.indexOf("=");
|
|
468
|
+
if (k < 1) return { error: `"${p}" in "${raw}" is not key=value` };
|
|
469
|
+
const key = p.slice(0, k).trim();
|
|
470
|
+
if (["id", "start", "end", "text", "label"].includes(key)) return { error: `"${key}" is set by marking, not by a label assignment` };
|
|
471
|
+
const value = p.slice(k + 1);
|
|
472
|
+
fields[key] = key === "own" && value === "true" ? true : value;
|
|
473
|
+
}
|
|
474
|
+
return { ids, label, fields };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function labelSegments(segments, assignments) {
|
|
478
|
+
const out = segments.map((s) => ({ ...s }));
|
|
479
|
+
const byId = new Map(out.map((s) => [s.id, s]));
|
|
480
|
+
for (const raw of assignments) {
|
|
481
|
+
const a = parseLabelAssignment(raw);
|
|
482
|
+
if (a.error) return { error: a.error };
|
|
483
|
+
for (const id of a.ids) {
|
|
484
|
+
const s = byId.get(id);
|
|
485
|
+
if (!s) return { error: `no segment "${id}" in this file` };
|
|
486
|
+
s.label = a.label;
|
|
487
|
+
Object.assign(s, a.fields);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return { segments: out };
|
|
491
|
+
}
|
package/src/sequence-draft.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
|
7
7
|
import { dirname, join, posix, resolve } from "node:path";
|
|
8
8
|
import { sha256 } from "./hash.mjs";
|
|
9
9
|
import { str } from "./placeholder.mjs";
|
|
10
|
-
import { splitLines } from "./draft.mjs";
|
|
10
|
+
import { splitLines, hideUnseen } from "./draft.mjs";
|
|
11
11
|
|
|
12
12
|
const list = (v) => (Array.isArray(v) ? v : []);
|
|
13
13
|
const byNumber = (a, b) => a.localeCompare(b, "en", { numeric: true });
|
|
@@ -44,8 +44,8 @@ export function sequenceFiles(specDir, entries) {
|
|
|
44
44
|
// { path, text, lines, sha256, sources }, the same shape src/draft.mjs's readDraft returns plus
|
|
45
45
|
// sources, one per file: { file, at, startLine, lineCount }. file is the path relative to the spec
|
|
46
46
|
// (what a finding names); at resolves from the working directory (what a station opens, such as
|
|
47
|
-
// links resolving a relative link beside the file that holds it). Each file's YAML frontmatter
|
|
48
|
-
// blanked line for line, so its metadata is not prose and its line numbers stay its own. sha256
|
|
47
|
+
// links resolving a relative link beside the file that holds it). Each file's YAML frontmatter and HTML comments are
|
|
48
|
+
// blanked line for line (src/draft.mjs's hideUnseen), so its metadata is not prose and its line numbers stay its own. sha256
|
|
49
49
|
// covers every file's name and bytes. path is the first file's. null when no file matches.
|
|
50
50
|
export function readSequenceDraft(spec, specPathArg) {
|
|
51
51
|
const files = sequenceFiles(spec.dir, sequenceFilesDecl(spec));
|
|
@@ -58,7 +58,7 @@ export function readSequenceDraft(spec, specPathArg) {
|
|
|
58
58
|
const buf = readFileSync(resolve(spec.dir, file));
|
|
59
59
|
hashed.push(Buffer.from(`${file}\n`), buf);
|
|
60
60
|
let text = buf.toString("utf8").replace(/^\uFEFF/, "");
|
|
61
|
-
text = text
|
|
61
|
+
text = hideUnseen(text);
|
|
62
62
|
if (!text.endsWith("\n")) text += "\n";
|
|
63
63
|
const lineCount = text.split("\n").length - 1;
|
|
64
64
|
sources.push({ file, at: join(dirname(specPathArg), file), startLine, lineCount });
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// specText(data, body) (0.10): a hyperspec file's text from data, for a tool that BUILDS specs
|
|
2
|
+
// rather than a person typing one: a drafting loop filling a skeleton's mechanical blocks, a
|
|
3
|
+
// renderer writing one spec per audience. Writes the block-style YAML subset hyperspec reads
|
|
4
|
+
// (maps, lists of scalars, lists of maps, scalars), quoting every scalar the way init does
|
|
5
|
+
// (template.mjs's scalar), then READS IT BACK with the same reader lint uses and throws, naming
|
|
6
|
+
// the field, if any value came back different. A lossy spec is never handed over: hand-written
|
|
7
|
+
// YAML once lost everything after " #" and after a leading quoted phrase while lint passed
|
|
8
|
+
// (letter BUILD-NOTES item 1).
|
|
9
|
+
//
|
|
10
|
+
// Every scalar reads back as a string (the reader's own rule), so numbers and booleans are
|
|
11
|
+
// written plain and compared as text. null and nested lists have no form here and are refused.
|
|
12
|
+
import { parseSkillFile } from "@supersuit/superskill/yaml";
|
|
13
|
+
import { scalar } from "./template.mjs";
|
|
14
|
+
|
|
15
|
+
const isMap = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
16
|
+
|
|
17
|
+
function emitScalar(v, at) {
|
|
18
|
+
if (typeof v === "string") return scalar(v);
|
|
19
|
+
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
|
20
|
+
if (typeof v === "boolean") return String(v);
|
|
21
|
+
throw new Error(`specText: ${at} is ${v === null ? "null" : typeof v}, which a hyperspec cannot hold; write a string or leave the key out`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function emitMap(obj, indent, at) {
|
|
25
|
+
const pad = " ".repeat(indent);
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
28
|
+
if (v === undefined) continue;
|
|
29
|
+
const here = at ? `${at}.${k}` : k;
|
|
30
|
+
if (Array.isArray(v)) {
|
|
31
|
+
if (!v.length) { out.push(`${pad}${k}: []`); continue; }
|
|
32
|
+
out.push(`${pad}${k}:`);
|
|
33
|
+
v.forEach((item, i) => out.push(...emitItem(item, indent + 2, `${here}[${i}]`)));
|
|
34
|
+
} else if (isMap(v)) {
|
|
35
|
+
if (!Object.keys(v).length) throw new Error(`specText: ${here} is an empty map, which reads back as nothing; leave the key out`);
|
|
36
|
+
out.push(`${pad}${k}:`, ...emitMap(v, indent + 2, here));
|
|
37
|
+
} else out.push(`${pad}${k}: ${emitScalar(v, here)}`);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emitItem(item, indent, at) {
|
|
43
|
+
const pad = " ".repeat(indent);
|
|
44
|
+
if (Array.isArray(item)) throw new Error(`specText: ${at} is a list inside a list, which a hyperspec cannot hold`);
|
|
45
|
+
if (!isMap(item)) return [`${pad}- ${emitScalar(item, at)}`];
|
|
46
|
+
const lines = emitMap(item, indent + 2, at);
|
|
47
|
+
if (!lines.length) throw new Error(`specText: ${at} is an empty map`);
|
|
48
|
+
return [`${pad}- ${lines[0].slice(indent + 2)}`, ...lines.slice(1)];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Scalars compared as the reader returns them: text.
|
|
52
|
+
const norm = (v) => (Array.isArray(v) ? v.map(norm) : isMap(v)
|
|
53
|
+
? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => [k, norm(x)]))
|
|
54
|
+
: String(v));
|
|
55
|
+
|
|
56
|
+
function firstDiff(a, b, at = "") {
|
|
57
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
58
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return at || "(root)";
|
|
59
|
+
for (let i = 0; i < a.length; i++) { const d = firstDiff(a[i], b[i], `${at}[${i}]`); if (d) return d; }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
if (isMap(a) || isMap(b)) {
|
|
63
|
+
if (!isMap(a) || !isMap(b)) return at || "(root)";
|
|
64
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { const d = firstDiff(a[k], b[k], at ? `${at}.${k}` : k); if (d) return d; }
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return a === b ? null : at;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function specText(data, body = "") {
|
|
71
|
+
if (!isMap(data)) throw new Error("specText: data must be an object");
|
|
72
|
+
const text = `---\n${emitMap(data, 0, "").join("\n")}\n---\n${body}`;
|
|
73
|
+
const { data: readBack, error } = parseSkillFile(text);
|
|
74
|
+
if (error) throw new Error(`specText: the reader refused the result: ${error}`);
|
|
75
|
+
const diff = firstDiff(norm(data), readBack);
|
|
76
|
+
if (diff) throw new Error(`specText: ${diff} does not read back as written; nothing returned`);
|
|
77
|
+
return text;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The reading half: a spec's text as lint reads it, { data, body, error }. With specText, a tool
|
|
81
|
+
// can read a skeleton (`hyperspec init --profile writing`), fill the blocks it knows, and write
|
|
82
|
+
// it back without a second YAML reader that disagrees with the linter.
|
|
83
|
+
export function parseSpecText(text) {
|
|
84
|
+
return parseSkillFile(String(text ?? ""));
|
|
85
|
+
}
|
|
@@ -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
|
}
|
package/src/writing-exports.mjs
CHANGED
|
@@ -9,3 +9,6 @@
|
|
|
9
9
|
export { MATERIAL_LABELS } from "./labels.mjs";
|
|
10
10
|
export { readSegments } from "./segments.mjs";
|
|
11
11
|
export { readScope, measureFeatures } from "./dna.mjs";
|
|
12
|
+
// specText writes a spec's text from data and refuses anything the reader would not return
|
|
13
|
+
// unchanged, for a tool that builds specs (0.10).
|
|
14
|
+
export { specText, parseSpecText } from "./spec-text.mjs";
|