@supersuit/hyperspec 0.8.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 +83 -0
- package/README.md +46 -5
- package/SPEC.md +2 -2
- package/WRITING.md +369 -25
- package/bin/hyperspec.mjs +185 -13
- package/examples/writing/course/part-1.md +17 -0
- package/examples/writing/course/part-2.md +17 -0
- package/examples/writing/course.hyperspec.md +1 -0
- package/examples/writing/essay/judge/panel-buyer.packet.json +118 -0
- package/examples/writing/essay/judge/panel-expert.packet.json +114 -0
- package/examples/writing/essay/judge/panel-novice.packet.json +114 -0
- package/examples/writing/essay/judge/panel-skeptic.packet.json +114 -0
- package/examples/writing/essay/sample-verdicts/panel-buyer.verdict.json +11 -0
- package/examples/writing/essay/sample-verdicts/panel-expert.verdict.json +13 -0
- package/examples/writing/essay/sample-verdicts/panel-novice.verdict.json +11 -0
- package/examples/writing/essay/sample-verdicts/panel-skeptic.verdict.json +13 -0
- package/examples/writing/story/judge/panel-buyer.packet.json +118 -0
- package/examples/writing/story/judge/panel-expert.packet.json +114 -0
- package/examples/writing/story/judge/panel-novice.packet.json +114 -0
- package/examples/writing/story/judge/panel-skeptic.packet.json +114 -0
- package/examples/writing/story/sample-verdicts/panel-buyer.verdict.json +13 -0
- package/examples/writing/story/sample-verdicts/panel-expert.verdict.json +11 -0
- package/examples/writing/story/sample-verdicts/panel-novice.verdict.json +11 -0
- package/examples/writing/story/sample-verdicts/panel-skeptic.verdict.json +11 -0
- package/package.json +1 -1
- package/src/evidence.mjs +74 -0
- package/src/judge.mjs +50 -66
- package/src/judges/index.mjs +7 -1
- package/src/judges/panel.mjs +135 -0
- package/src/segments.mjs +44 -0
- package/src/stations/index.mjs +3 -1
- package/src/stations/quotes.mjs +26 -2
- package/src/stations/sequence.mjs +149 -2
- package/src/stations/triage.mjs +20 -0
- package/src/triage.mjs +401 -0
- package/src/writing-fields.mjs +48 -1
- package/src/writing.mjs +5 -1
package/src/judges/index.mjs
CHANGED
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
// packet whose inputs went out of date stale rather than edited; such a station also carries
|
|
10
10
|
// sourceSkip(spec, draft), the skip reason when it skips because of those files (else null), so a
|
|
11
11
|
// packet whose station stopped applying for that reason is stale too, and any other skip is not. The packet and key a station receives are
|
|
12
|
-
// always the ones record rebuilt from the spec and draft on disk, never read from a file
|
|
12
|
+
// always the ones record rebuilt from the spec and draft on disk, never read from a file. A
|
|
13
|
+
// station that writes one packet per variant (the panel: one per reader) also carries variants(spec),
|
|
14
|
+
// the list of { id, ... } it writes packets for, and variantOf(packet), the id a packet names;
|
|
15
|
+
// packet() then receives the variant as a third argument, the file is <station>-<id>.packet.json,
|
|
16
|
+
// and derive may return triage, the findings `hyperspec triage` answers. See
|
|
13
17
|
// src/judge.mjs for the framework that calls them. Adding a judge is one file plus one line here.
|
|
14
18
|
|
|
15
19
|
import * as doctor from "./doctor.mjs";
|
|
@@ -18,6 +22,7 @@ import * as reader from "./reader.mjs";
|
|
|
18
22
|
import * as persona from "./persona.mjs";
|
|
19
23
|
import * as attribution from "./attribution.mjs";
|
|
20
24
|
import * as knowledge from "./knowledge.mjs";
|
|
25
|
+
import * as panel from "./panel.mjs";
|
|
21
26
|
|
|
22
27
|
export const JUDGES = Object.freeze([
|
|
23
28
|
{ name: doctor.name, instructions: doctor.DOCTOR_INSTRUCTIONS, skipReason: doctor.skipReason, packet: doctor.packet, validate: doctor.validate, derive: doctor.derive },
|
|
@@ -26,6 +31,7 @@ export const JUDGES = Object.freeze([
|
|
|
26
31
|
{ name: persona.name, instructions: persona.PERSONA_INSTRUCTIONS, skipReason: persona.skipReason, packet: persona.packet, validate: persona.validate, derive: persona.derive, inputSources: persona.inputSources, sourceSkip: persona.sourceSkip },
|
|
27
32
|
{ name: attribution.name, instructions: attribution.ATTRIBUTION_INSTRUCTIONS, skipReason: attribution.skipReason, packet: attribution.packet, validate: attribution.validate, derive: attribution.derive },
|
|
28
33
|
{ name: knowledge.name, instructions: knowledge.KNOWLEDGE_INSTRUCTIONS, skipReason: knowledge.skipReason, packet: knowledge.packet, validate: knowledge.validate, derive: knowledge.derive },
|
|
34
|
+
{ name: panel.name, instructions: panel.PANEL_INSTRUCTIONS, skipReason: panel.skipReason, packet: panel.packet, validate: panel.validate, derive: panel.derive, variants: panel.variants, variantOf: panel.variantOf },
|
|
29
35
|
]);
|
|
30
36
|
|
|
31
37
|
export const JUDGE_NAMES = Object.freeze(JUDGES.map((j) => j.name));
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The panel (hyperspec 0.9): the draft read by several readers at once, each through their own
|
|
2
|
+
// lens, the way a draft is pressure-tested by hand before it ships. Every other judgment station
|
|
3
|
+
// asks one question with a right answer; the panel asks each reader what works, what to improve,
|
|
4
|
+
// what is missing and what to remove, and every answer is a finding somebody has to answer.
|
|
5
|
+
//
|
|
6
|
+
// One packet per reader (panel-<id>.packet.json): the readers writing.panel declares, or with none
|
|
7
|
+
// declared the default three (a skeptic, a newcomer and an expert), and always the spec's own
|
|
8
|
+
// audience reader, added last as "buyer", because a panel that never includes the person the piece
|
|
9
|
+
// is for tests everything except whether it works. Every item a reader lists carries evidence
|
|
10
|
+
// copied from the draft (src/evidence.mjs's rule; a missing item anchors to the passage nearest
|
|
11
|
+
// where it belongs), so a review can never be of a stale or truncated copy.
|
|
12
|
+
//
|
|
13
|
+
// The panel never fails a draft: its findings are warnings, and each improve, missing and remove
|
|
14
|
+
// item goes to the triage file (src/triage.mjs), where the operator answers it and `check` holds
|
|
15
|
+
// every answer to the draft. What works is counted, never triaged: there is nothing to answer.
|
|
16
|
+
|
|
17
|
+
import { str } from "../placeholder.mjs";
|
|
18
|
+
|
|
19
|
+
export const name = "panel";
|
|
20
|
+
|
|
21
|
+
export const PANEL_INSTRUCTIONS = [
|
|
22
|
+
"Read inputs.draft as inputs.reader: the person inputs.reader.who describes, knowing only what inputs.reader.knows lists and what anyone would, reading for inputs.reader.lens.",
|
|
23
|
+
"List what works for this reader under good, what should change under improve, what this reader needs that the draft does not give under missing, and what should go under remove.",
|
|
24
|
+
"Every item is { evidence, note }. evidence is a passage copied verbatim from inputs.draft, at least three whole words; for a missing item, quote the passage nearest to where the missing thing belongs. note says what and why, in a sentence, in this reader's terms.",
|
|
25
|
+
"A list may be empty. Report what this reader would say, not what another reader would.",
|
|
26
|
+
"Answer only in the verdict shape given in verdict_schema.",
|
|
27
|
+
].join(" ");
|
|
28
|
+
|
|
29
|
+
// The default panel, used when writing.panel is not declared. The buyer is added to it, as to any.
|
|
30
|
+
export const DEFAULT_PANEL = Object.freeze([
|
|
31
|
+
Object.freeze({ id: "skeptic", who: "a skeptic who doubts the piece's central claim and wants it earned", knows: Object.freeze([]), lens: "what is asserted without support, overstated, or does not follow" }),
|
|
32
|
+
Object.freeze({ id: "novice", who: "a newcomer to the subject, meeting its ideas for the first time", knows: Object.freeze([]), lens: "every term, step or assumption the piece does not explain" }),
|
|
33
|
+
Object.freeze({ id: "expert", who: "an expert in the piece's subject", knows: Object.freeze([]), lens: "what is wrong, out of date, oversimplified or missing for someone who knows the field" }),
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
export const KINDS = Object.freeze(["good", "improve", "missing", "remove"]);
|
|
37
|
+
|
|
38
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
39
|
+
const texts = (v) => (Array.isArray(v) ? v.map(str).filter(Boolean) : []);
|
|
40
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim().length > 0;
|
|
41
|
+
|
|
42
|
+
// null when the panel applies: its buyer is the audience's reader, so it needs a written audience
|
|
43
|
+
// block whose check names a rubric, the reader station's own condition.
|
|
44
|
+
export function skipReason(spec) {
|
|
45
|
+
const audience = spec.data?.writing?.audience;
|
|
46
|
+
if (!isObject(audience)) return "writing.audience is not written (deferred); the panel's buyer is the audience's reader";
|
|
47
|
+
if (!str(audience.check?.rubric)) return "writing.audience.check has no rubric";
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Every reader on this spec's panel, in order: the declared readers (or the default three), then
|
|
52
|
+
// the buyer. Each is { id, who, knows, lens }.
|
|
53
|
+
export function variants(spec) {
|
|
54
|
+
const declared = spec.data?.writing?.panel;
|
|
55
|
+
const readers = Array.isArray(declared) && declared.length
|
|
56
|
+
? declared.filter(isObject).map((r) => ({ id: str(r.id), who: str(r.who), knows: texts(r.knows), lens: str(r.lens) })).filter((r) => r.id && r.id !== "buyer")
|
|
57
|
+
: DEFAULT_PANEL.map((r) => ({ id: r.id, who: r.who, knows: [...r.knows], lens: r.lens }));
|
|
58
|
+
const a = spec.data.writing.audience;
|
|
59
|
+
const buyer = { id: "buyer", who: str(a.who), knows: texts(a.knows), lens: `what they want from this piece: ${str(a.wants)}` };
|
|
60
|
+
return [...readers, buyer];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Which reader a packet is for, as it says; record rebuilds the packet for that reader and
|
|
64
|
+
// compares every byte, so a changed name is caught with the rest.
|
|
65
|
+
export const variantOf = (packet) => packet?.inputs?.reader?.id;
|
|
66
|
+
|
|
67
|
+
const itemSchema = {
|
|
68
|
+
type: "object",
|
|
69
|
+
required: ["evidence", "note"],
|
|
70
|
+
properties: {
|
|
71
|
+
evidence: { type: "string", description: "a passage copied verbatim from the draft, at least three words; for a missing item, the passage nearest where it belongs" },
|
|
72
|
+
note: { type: "string", description: "what and why, in a sentence" },
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// The packet's rubric (audience.check.rubric, verbatim: the buyer's test is the piece's), inputs
|
|
77
|
+
// (the reader and the draft) and verdict schema. No answer key.
|
|
78
|
+
export function packet(spec, draft, reader) {
|
|
79
|
+
return {
|
|
80
|
+
rubric: spec.data.writing.audience.check.rubric,
|
|
81
|
+
inputs: {
|
|
82
|
+
reader: { id: reader.id, who: reader.who, knows: reader.knows, lens: reader.lens },
|
|
83
|
+
draft: draft.text,
|
|
84
|
+
},
|
|
85
|
+
verdict_schema: {
|
|
86
|
+
type: "object",
|
|
87
|
+
required: [...KINDS],
|
|
88
|
+
properties: {
|
|
89
|
+
good: { type: "array", description: "what works for this reader; empty when nothing does", items: itemSchema },
|
|
90
|
+
improve: { type: "array", description: "what should change", items: itemSchema },
|
|
91
|
+
missing: { type: "array", description: "what this reader needs that the draft does not give", items: itemSchema },
|
|
92
|
+
remove: { type: "array", description: "what should go", items: itemSchema },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
key: null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Every problem with the verdict, as findings; every evidence span must be in the draft.
|
|
100
|
+
export function validate(verdict, pkt, t) {
|
|
101
|
+
if (!isObject(verdict)) return [t.shape("the verdict is not a JSON object", "Write one object: { good, improve, missing, remove }.")];
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const kind of KINDS) {
|
|
104
|
+
if (!Array.isArray(verdict[kind])) { out.push(t.shape(`${kind} is missing or not a list`, `Give ${kind}: one { evidence, note } per item, or [] when there is none.`)); continue; }
|
|
105
|
+
verdict[kind].forEach((item, i) => {
|
|
106
|
+
const at = `${kind}[${i}]`;
|
|
107
|
+
if (!isObject(item)) { out.push(t.shape(`${at} is not an object`, "Write it as { evidence, note }.")); return; }
|
|
108
|
+
if (!nonEmpty(item.note)) out.push(t.shape(`${at}.note is missing or empty`, "Say what and why, in a sentence."));
|
|
109
|
+
out.push(...t.evidence(item.evidence, `${at}.evidence`));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const WORD = { improve: "would improve", missing: "misses", remove: "would remove" };
|
|
116
|
+
|
|
117
|
+
// Always passes: every improve, missing and remove item is a warning at its evidence's line, and a
|
|
118
|
+
// finding for the triage file. The summary counts all four lists.
|
|
119
|
+
export function derive(verdict, pkt, t) {
|
|
120
|
+
const reader = pkt.inputs.reader.id;
|
|
121
|
+
const findings = [];
|
|
122
|
+
const triage = [];
|
|
123
|
+
for (const kind of ["improve", "missing", "remove"]) {
|
|
124
|
+
for (const item of verdict[kind]) {
|
|
125
|
+
const note = item.note.trim();
|
|
126
|
+
if (kind === "improve") findings.push({ ...t.finding("judge-panel-improve", `${reader} ${WORD[kind]} this: ${note}`, "Answer it in the triage file: take it, keep the passage with a reason, show it is already true, or leave it open for a decision.", t.lineOf(item.evidence)), severity: "warn" });
|
|
127
|
+
if (kind === "missing") findings.push({ ...t.finding("judge-panel-missing", `${reader} ${WORD[kind]} something here: ${note}`, "Answer it in the triage file: take it, keep the passage with a reason, show it is already true, or leave it open for a decision.", t.lineOf(item.evidence)), severity: "warn" });
|
|
128
|
+
if (kind === "remove") findings.push({ ...t.finding("judge-panel-remove", `${reader} ${WORD[kind]} this: ${note}`, "Answer it in the triage file: take it, keep the passage with a reason, show it is already true, or leave it open for a decision.", t.lineOf(item.evidence)), severity: "warn" });
|
|
129
|
+
triage.push({ kind, text: note, evidence: item.evidence });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const n = (k) => verdict[k].length;
|
|
133
|
+
const summary = `${reader}: ${n("good")} good, ${n("improve")} to improve, ${n("missing")} missing, ${n("remove")} to remove`;
|
|
134
|
+
return { status: "pass", findings, summary, triage };
|
|
135
|
+
}
|
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),
|
package/src/stations/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/check.mjs calls; adding a station is adding one file plus one line here, which is the whole
|
|
4
4
|
// point of the registry existing rather than check.mjs importing each station by name itself.
|
|
5
5
|
//
|
|
6
|
-
// The order: form, terms, claims, quotes, private, dna, links, sequence. quotes and private share ctx (util.mjs's
|
|
6
|
+
// The order: form, terms, claims, quotes, private, dna, links, sequence, triage. quotes and private share ctx (util.mjs's
|
|
7
7
|
// markedSegments caches the spec's marked materials there), so a check run reads them once.
|
|
8
8
|
|
|
9
9
|
import * as form from "./form.mjs";
|
|
@@ -14,6 +14,7 @@ import * as privateStation from "./private.mjs";
|
|
|
14
14
|
import * as dna from "./dna.mjs";
|
|
15
15
|
import * as links from "./links.mjs";
|
|
16
16
|
import * as sequence from "./sequence.mjs";
|
|
17
|
+
import * as triage from "./triage.mjs";
|
|
17
18
|
|
|
18
19
|
export const STATIONS = Object.freeze([
|
|
19
20
|
{ name: form.name, run: form.run },
|
|
@@ -24,6 +25,7 @@ export const STATIONS = Object.freeze([
|
|
|
24
25
|
{ name: dna.name, run: dna.run },
|
|
25
26
|
{ name: links.name, run: links.run },
|
|
26
27
|
{ name: sequence.name, run: sequence.run },
|
|
28
|
+
{ name: triage.name, run: triage.run },
|
|
27
29
|
]);
|
|
28
30
|
|
|
29
31
|
export const STATION_NAMES = Object.freeze(STATIONS.map((s) => s.name));
|
package/src/stations/quotes.mjs
CHANGED
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
// story segment is enough. Sentences come from splitSegments(text, { by: "sentence" }), and every
|
|
34
34
|
// sentence the span overlaps is read, so a curly quote the splitter cuts in two still keeps its
|
|
35
35
|
// attribution.
|
|
36
|
+
//
|
|
37
|
+
// Example phrasings (hyperspec 0.9). A primer shows the reader what to type or say: "write the
|
|
38
|
+
// update for Dana" is an example, not a quotation, and no material holds it. writing.quotes.examples
|
|
39
|
+
// says which quoted spans are examples. `true` makes every span whose sentence names no known
|
|
40
|
+
// speaker an example: it passes unmatched, and a quotation that names its speaker is still held to
|
|
41
|
+
// the materials. A list names the example phrasings themselves, compared the way a span is matched,
|
|
42
|
+
// so every other span is still checked. The list is the stricter choice: under `true`, an invented
|
|
43
|
+
// quotation that names no one passes too.
|
|
36
44
|
|
|
37
45
|
import { splitSegments } from "../segments.mjs";
|
|
38
46
|
import { STOPWORDS, wordsOf } from "../dna.mjs";
|
|
@@ -75,6 +83,18 @@ export function quotedSpans(text) {
|
|
|
75
83
|
return spans;
|
|
76
84
|
}
|
|
77
85
|
|
|
86
|
+
// The spec's example setting: { all: true } for `examples: true`, { phrases } (a Set of match keys)
|
|
87
|
+
// for a list, or null when the spec declares none, or `false`.
|
|
88
|
+
export function examplesOf(spec) {
|
|
89
|
+
const raw = spec?.data?.writing?.quotes?.examples;
|
|
90
|
+
if (str(raw) === "true") return { all: true };
|
|
91
|
+
if (Array.isArray(raw)) {
|
|
92
|
+
const phrases = new Set(raw.map(str).filter(Boolean).map(matchKey).filter(Boolean));
|
|
93
|
+
return phrases.size ? { phrases } : null;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
78
98
|
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
79
99
|
|
|
80
100
|
const STOPWORD_SET = new Set(STOPWORDS);
|
|
@@ -133,13 +153,19 @@ export function run(spec, draft, ctx) {
|
|
|
133
153
|
.filter((sp) => sp.re);
|
|
134
154
|
|
|
135
155
|
const sentences = splitSegments(text, { by: "sentence" });
|
|
156
|
+
const examples = examplesOf(spec);
|
|
136
157
|
const findings = [];
|
|
137
158
|
for (const span of spans) {
|
|
138
159
|
const key = matchKey(span.inner);
|
|
139
160
|
if (!key) continue;
|
|
161
|
+
if (examples?.phrases?.has(key)) continue;
|
|
140
162
|
const shown = truncate(span.inner.replace(/\s+/g, " "), 80);
|
|
141
163
|
const line = lineAt(draft.text, span.start);
|
|
142
164
|
const hits = sources.filter((s) => s.norm.includes(key));
|
|
165
|
+
const around = attributionContext(text, sentences, span);
|
|
166
|
+
const named = speakers.filter((sp) => sp.re.test(around)).map((sp) => sp.key);
|
|
167
|
+
// examples: true reads a span that names no known speaker as an example phrasing.
|
|
168
|
+
if (examples?.all && !named.length) continue;
|
|
143
169
|
if (!hits.length) {
|
|
144
170
|
findings.push({
|
|
145
171
|
station: name,
|
|
@@ -151,8 +177,6 @@ export function run(spec, draft, ctx) {
|
|
|
151
177
|
});
|
|
152
178
|
continue;
|
|
153
179
|
}
|
|
154
|
-
const around = attributionContext(text, sentences, span);
|
|
155
|
-
const named = speakers.filter((sp) => sp.re.test(around)).map((sp) => sp.key);
|
|
156
180
|
if (named.length && !hits.some((h) => h.label === "quote" && named.includes(h.speaker))) {
|
|
157
181
|
const who = named.map((k) => shownSpeaker.get(k) ?? k).join(", ");
|
|
158
182
|
findings.push({
|
|
@@ -20,9 +20,18 @@
|
|
|
20
20
|
// numbering unit numbers increase through the work
|
|
21
21
|
// pointers (warning) a "<unit> N" pointer to a later unit is reported, so it stays a pointer
|
|
22
22
|
// and never becomes a dependency
|
|
23
|
+
// quiz (hyperspec 0.9, only when sequence.quiz names the quiz heading) every defined term is
|
|
24
|
+
// tested by some question, a simple plural counting; no question uses a term defined
|
|
25
|
+
// after the unit it is tagged with; every question is tagged; every answer names one
|
|
26
|
+
// of its question's options. Promoted from the checker the first book written this
|
|
27
|
+
// way used, so the rules and the question shape are that checker's.
|
|
23
28
|
//
|
|
24
29
|
// A use is a whole-word, case-insensitive match, where a hyphen is part of a word: "context-aware"
|
|
25
|
-
// 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
|
|
26
35
|
// disk, resolved against the spec's folder, the way claims reads its ledger. It never reads a
|
|
27
36
|
// part's text for meaning: a term used in a sense other than its definition still counts as a use.
|
|
28
37
|
|
|
@@ -60,6 +69,7 @@ export function sequenceOf(spec) {
|
|
|
60
69
|
termsSection: str(raw.terms_section) || DEFAULTS.terms_section,
|
|
61
70
|
teaser: str(raw.teaser) || DEFAULTS.teaser,
|
|
62
71
|
outline: str(raw.outline) || null,
|
|
72
|
+
quiz: str(raw.quiz) || null,
|
|
63
73
|
knows: new Set(knows),
|
|
64
74
|
};
|
|
65
75
|
}
|
|
@@ -193,6 +203,99 @@ function firstUse(unit, prose, term) {
|
|
|
193
203
|
return i < 0 ? 0 : unit.startLine + i;
|
|
194
204
|
}
|
|
195
205
|
|
|
206
|
+
// Whether `text` (code already masked) uses `term`, by the same whole-word rule as firstUse.
|
|
207
|
+
const usesTerm = (text, term) => new RegExp(`(^|[^a-z0-9-])${escapeRe(term)}([^a-z0-9-]|$)`, "i").test(text);
|
|
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
|
+
|
|
228
|
+
// Every quiz in the draft, read the way the first book written to this shape wrote it:
|
|
229
|
+
//
|
|
230
|
+
// ## Check yourself: Part 1
|
|
231
|
+
//
|
|
232
|
+
// 1. *(Lesson 1)* You type "draft an email" into a chat app. What did you just send?
|
|
233
|
+
// - a) A file on your computer
|
|
234
|
+
// - b) A prompt, sent to a chat app
|
|
235
|
+
// ...
|
|
236
|
+
// **Answers:** 1 b · 2 c
|
|
237
|
+
//
|
|
238
|
+
// A quiz starts at an ATX heading whose text starts with `quiz` (any case) and runs to the next
|
|
239
|
+
// heading of its own level or above, or the end of its file. A question is a numbered line tagged
|
|
240
|
+
// "*(<unit> N)*"; it runs to the next numbered line, the answers line or the quiz's end, and its
|
|
241
|
+
// options are the indented "- a) " lines inside it. The answers line is "**Answers:**" followed by
|
|
242
|
+
// "<number> <letter>" pairs, in any separator. Returns { quizzes, questions, untagged }: quizzes
|
|
243
|
+
// counts the quiz headings; each question is { n, unit, line, text, options, answer }, text with
|
|
244
|
+
// code masked (code is not prose), line 1-based in the draft; untagged lists { n, line } for a
|
|
245
|
+
// numbered line in a quiz that carries no tag.
|
|
246
|
+
export function parseQuizzes(draft, { unit, quiz }) {
|
|
247
|
+
const lines = maskCode(draft.text).split("\n").map((l) => l.replace(/\r$/, ""));
|
|
248
|
+
const quizRe = new RegExp(`^${escapeRe(quiz)}`, "i");
|
|
249
|
+
const fileEnds = new Set(list(draft.sources).map((s) => s.startLine + s.lineCount - 1));
|
|
250
|
+
const numbered = /^[ \t]{0,3}(\d+)\.[ \t]/;
|
|
251
|
+
const tagged = new RegExp(`^[ \\t]{0,3}(\\d+)\\.[ \\t]+\\*\\([ \\t]*${escapeRe(unit)}[ \\t]+(\\d+)[ \\t]*\\)\\*`, "i");
|
|
252
|
+
const answersRe = /^[ \t]*\*\*Answers:?\*\*:?(.*)$/i;
|
|
253
|
+
const optionRe = /^[ \t]+[-*+][ \t]+([a-z])\)[ \t]/i;
|
|
254
|
+
const out = { quizzes: 0, questions: [], untagged: [] };
|
|
255
|
+
for (let i = 0; i < lines.length; i++) {
|
|
256
|
+
const h = ATX.exec(lines[i]);
|
|
257
|
+
if (!h || !quizRe.test((h[2] ?? "").replace(/(^|[ \t]+)#+[ \t]*$/, "").trim())) continue;
|
|
258
|
+
out.quizzes += 1;
|
|
259
|
+
const level = h[1].length;
|
|
260
|
+
// [i + 1, end): stops before a heading of its level or above, or after its file's last line
|
|
261
|
+
// (fileEnds holds 1-based line numbers, so fileEnds.has(end) says line index end - 1 was one).
|
|
262
|
+
let end = i + 1;
|
|
263
|
+
while (end < lines.length && !fileEnds.has(end)) {
|
|
264
|
+
const next = ATX.exec(lines[end]);
|
|
265
|
+
if (next && next[1].length <= level) break;
|
|
266
|
+
end++;
|
|
267
|
+
}
|
|
268
|
+
const answers = new Map();
|
|
269
|
+
let cur = null;
|
|
270
|
+
const qs = [];
|
|
271
|
+
const flush = () => { if (cur) qs.push(cur); cur = null; };
|
|
272
|
+
for (let j = i + 1; j < end; j++) {
|
|
273
|
+
const a = answersRe.exec(lines[j]);
|
|
274
|
+
if (a) {
|
|
275
|
+
flush();
|
|
276
|
+
for (const m of a[1].matchAll(/(\d+)[ \t]+([a-z])(?![a-z])/gi)) answers.set(Number(m[1]), m[2].toLowerCase());
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const t = tagged.exec(lines[j]);
|
|
280
|
+
if (t) {
|
|
281
|
+
flush();
|
|
282
|
+
cur = { n: Number(t[1]), unit: Number(t[2]), line: j + 1, text: lines[j], options: [] };
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const u = numbered.exec(lines[j]);
|
|
286
|
+
if (u) { flush(); out.untagged.push({ n: Number(u[1]), line: j + 1 }); continue; }
|
|
287
|
+
if (!cur) continue;
|
|
288
|
+
cur.text += `\n${lines[j]}`;
|
|
289
|
+
const o = optionRe.exec(lines[j]);
|
|
290
|
+
if (o) cur.options.push(o[1].toLowerCase());
|
|
291
|
+
}
|
|
292
|
+
flush();
|
|
293
|
+
for (const q of qs) out.questions.push({ ...q, answer: answers.get(q.n) ?? null });
|
|
294
|
+
i = end - 1;
|
|
295
|
+
}
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
196
299
|
// One finding, in the shape every station returns; line only when there is one to point at.
|
|
197
300
|
const finding = ({ id, severity, message, fix, line }) => ({ station: name, id, severity, ...(line ? { line } : {}), message, fix });
|
|
198
301
|
|
|
@@ -228,12 +331,17 @@ export function run(spec, draft) {
|
|
|
228
331
|
}
|
|
229
332
|
|
|
230
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".
|
|
231
336
|
const prose = new Map(units.map((u) => [u, proseOf(u, seq, { withTerms: false })]));
|
|
337
|
+
const longer = longerTerms(definedAt.keys());
|
|
232
338
|
for (const [t, at] of definedAt) {
|
|
233
339
|
if (seq.knows.has(t)) continue;
|
|
340
|
+
const sups = longer.get(t);
|
|
234
341
|
for (const u of units) {
|
|
235
342
|
if (u === at) break;
|
|
236
|
-
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);
|
|
237
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 }));
|
|
238
346
|
}
|
|
239
347
|
}
|
|
@@ -270,6 +378,45 @@ export function run(spec, draft) {
|
|
|
270
378
|
});
|
|
271
379
|
}
|
|
272
380
|
|
|
381
|
+
// quiz: only when the spec names the quiz heading.
|
|
382
|
+
if (seq.quiz) findings.push(...quizFindings(draft, seq, definedAt));
|
|
383
|
+
|
|
273
384
|
const status = findings.some((f) => f.severity === "fail") ? "fail" : "pass";
|
|
274
385
|
return { station: name, status, findings };
|
|
275
386
|
}
|
|
387
|
+
|
|
388
|
+
// The quiz guards, against the terms the units define (term -> defining unit).
|
|
389
|
+
function quizFindings(draft, seq, definedAt) {
|
|
390
|
+
const U = seq.unit;
|
|
391
|
+
const out = [];
|
|
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`); };
|
|
399
|
+
if (!quizzes) {
|
|
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.` })];
|
|
401
|
+
}
|
|
402
|
+
for (const q of untagged) {
|
|
403
|
+
out.push(finding({ id: "station-sequence-quiz-untagged", severity: "fail", message: `question ${q.n} names no ${U.toLowerCase()} it tests`, fix: `Start the question "${q.n}. *(${U} N)* ...", naming the ${U.toLowerCase()} it tests.`, line: q.line }));
|
|
404
|
+
}
|
|
405
|
+
for (const q of questions) {
|
|
406
|
+
if (!q.answer || !q.options.includes(q.answer)) {
|
|
407
|
+
const has = q.options.length ? q.options.join(", ") : "none";
|
|
408
|
+
out.push(finding({ id: "station-sequence-quiz-answer", severity: "fail", message: q.answer ? `question ${q.n}'s answer is ${q.answer}, which is not one of its options (${has})` : `question ${q.n} has no answer on the quiz's "**Answers:**" line`, fix: `Give question ${q.n} one answer, "${q.n} <letter>", naming one of its options.`, line: q.line }));
|
|
409
|
+
}
|
|
410
|
+
for (const [t, at] of definedAt) {
|
|
411
|
+
if (seq.knows.has(t) || at.n <= q.unit) continue;
|
|
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 }));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// coverage: a simple plural counts, so "skills" tests "skill".
|
|
416
|
+
for (const [t, at] of definedAt) {
|
|
417
|
+
if (!questions.some((q) => tests(q, t))) {
|
|
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 }));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Station "triage" (hyperspec 0.9). Every finding a panel reader or an outside review raised about
|
|
2
|
+
// the draft, held to its answer: a finding nobody answered fails, an answer that says the draft now
|
|
3
|
+
// does it (taken) or already did (already-true) fails unless the passage it quotes is in the draft
|
|
4
|
+
// as it is now, and a kept answer fails without its reason. An open finding is a warning: it is a
|
|
5
|
+
// decision for the operator, and the draft can ship while it waits. So is an answer that kept a
|
|
6
|
+
// passage, or left it open, when that passage is no longer in the draft.
|
|
7
|
+
//
|
|
8
|
+
// The rules and the file live in src/triage.mjs, which `hyperspec triage status` reads too, so the
|
|
9
|
+
// station and the command can never disagree. With no triage file yet the station skips.
|
|
10
|
+
|
|
11
|
+
import { triageState } from "../triage.mjs";
|
|
12
|
+
|
|
13
|
+
export const name = "triage";
|
|
14
|
+
|
|
15
|
+
export function run(spec, draft) {
|
|
16
|
+
const state = triageState(spec, draft);
|
|
17
|
+
if (state.skip) return { station: name, status: "skip", findings: [], reason: state.skip };
|
|
18
|
+
const status = state.findings.some((f) => f.severity === "fail") ? "fail" : "pass";
|
|
19
|
+
return { station: name, status, findings: state.findings };
|
|
20
|
+
}
|