@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/triage.mjs
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// Triage (hyperspec 0.9): every finding a reader raised about a draft, answered, and every answer
|
|
2
|
+
// held to the draft as it is now. A panel verdict (src/judges/panel.mjs) and an outside review
|
|
3
|
+
// brought in with `triage import` both land here as findings; the operator, or their agent, answers
|
|
4
|
+
// each one with `triage answer`; `check`'s triage station fails while a finding is unanswered or
|
|
5
|
+
// an answer's evidence is no longer in the draft; `triage reply` turns the answers into a reply to
|
|
6
|
+
// the reviewer, which hyperspec prints and never sends.
|
|
7
|
+
//
|
|
8
|
+
// The file is triage.jsonl beside the spec's runs ledger (improvement.ledger), one finding per line:
|
|
9
|
+
//
|
|
10
|
+
// { finding_id, source, reader, kind, text, evidence, draft_sha256, disposition, answer }
|
|
11
|
+
//
|
|
12
|
+
// source is "panel" or where an imported review came from; reader is the panel reader's id or the
|
|
13
|
+
// review's heading (null when it has none); kind is improve, missing, remove, or note for an
|
|
14
|
+
// imported item under no such label; evidence is the passage of the draft the finding is about (null
|
|
15
|
+
// when an imported item quotes none that is in the draft); draft_sha256 is the draft it was raised
|
|
16
|
+
// against. disposition is null until answered, then one of:
|
|
17
|
+
//
|
|
18
|
+
// taken the draft now does what the finding asked; answer.evidence quotes where
|
|
19
|
+
// kept the passage stays as it is on purpose; answer.reason says why
|
|
20
|
+
// already-true the draft already did it; answer.evidence quotes where
|
|
21
|
+
// open a decision for the operator; answer.reason may say what is to decide
|
|
22
|
+
//
|
|
23
|
+
// answer is { evidence?, reason?, draft_sha256, at }. Evidence follows src/evidence.mjs's rule, the
|
|
24
|
+
// one `judge record` holds a judge's quotes to.
|
|
25
|
+
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { posix, resolve } from "node:path";
|
|
28
|
+
import { sha256 } from "./hash.mjs";
|
|
29
|
+
import { insideDir, writeFileAtomic } from "./fsutil.mjs";
|
|
30
|
+
import { evidenceLocator, evidenceProblem, evidenceWords, MIN_EVIDENCE_WORDS } from "./evidence.mjs";
|
|
31
|
+
import { maskCode, truncate } from "./stations/util.mjs";
|
|
32
|
+
import { str } from "./placeholder.mjs";
|
|
33
|
+
import { readDraft } from "./draft.mjs";
|
|
34
|
+
import { readSequenceDraft, sequenceFilesDecl } from "./sequence-draft.mjs";
|
|
35
|
+
import { loadSpec } from "./load.mjs";
|
|
36
|
+
|
|
37
|
+
export const TRIAGE_FILE = "triage.jsonl";
|
|
38
|
+
export const DISPOSITIONS = Object.freeze(["taken", "kept", "already-true", "open"]);
|
|
39
|
+
const KEY_ORDER = ["finding_id", "source", "reader", "kind", "text", "evidence", "draft_sha256", "disposition", "answer"];
|
|
40
|
+
const present = (v) => typeof v === "string" && v.trim().length > 0;
|
|
41
|
+
const isObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
42
|
+
|
|
43
|
+
// The spec's triage file: { decl, abs }, decl the path relative to the spec's folder (what every
|
|
44
|
+
// message names), or { warning } when it would sit outside the spec's folder, or null when the spec
|
|
45
|
+
// declares no runs ledger to sit beside.
|
|
46
|
+
export function openTriage(spec) {
|
|
47
|
+
const ledger = str(spec?.data?.improvement?.ledger);
|
|
48
|
+
if (!ledger) return null;
|
|
49
|
+
const decl = posix.join(posix.dirname(ledger.replace(/\\/g, "/")), TRIAGE_FILE);
|
|
50
|
+
if (!insideDir(spec.dir, decl)) return { warning: "the triage file, beside improvement.ledger, would sit outside the spec's directory; not written" };
|
|
51
|
+
return { decl, abs: resolve(spec.dir, decl) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// { exists, entries }: every line of the file in order, each { item } (a finding) or { raw, line }
|
|
55
|
+
// (a line that is not one, kept so a rewrite never drops it).
|
|
56
|
+
export function readTriage(abs) {
|
|
57
|
+
if (!existsSync(abs)) return { exists: false, entries: [] };
|
|
58
|
+
const entries = [];
|
|
59
|
+
readFileSync(abs, "utf8").split("\n").forEach((raw, i) => {
|
|
60
|
+
if (!raw.trim()) return;
|
|
61
|
+
let v = null;
|
|
62
|
+
try { v = JSON.parse(raw); } catch { /* not JSON */ }
|
|
63
|
+
if (isObject(v) && present(v.finding_id) && present(v.text)) entries.push({ item: v });
|
|
64
|
+
else entries.push({ raw, line: i + 1 });
|
|
65
|
+
});
|
|
66
|
+
return { exists: true, entries };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ordered = (item) => {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const k of KEY_ORDER) out[k] = item[k] ?? null;
|
|
72
|
+
for (const k of Object.keys(item)) if (!(k in out)) out[k] = item[k];
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function writeTriage(abs, entries) {
|
|
77
|
+
writeFileAtomic(abs, entries.map((e) => (e.item ? JSON.stringify(ordered(e.item)) : e.raw)).join("\n") + (entries.length ? "\n" : ""));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A finding's id: the prefix, then eight hex characters of a hash of what it says, so the same
|
|
81
|
+
// finding recorded twice is one finding.
|
|
82
|
+
export const findingId = (prefix, parts) => `${prefix}-${sha256(JSON.stringify(parts)).slice(0, 8)}`;
|
|
83
|
+
|
|
84
|
+
// Adds findings ({ kind, text, evidence }) to the spec's triage file, each once. Returns { decl,
|
|
85
|
+
// added, already } or { warning }.
|
|
86
|
+
export function addFindings(spec, found, { source, reader, draftSha, idPrefix }) {
|
|
87
|
+
const t = openTriage(spec);
|
|
88
|
+
if (!t) return { warning: "the spec declares no improvement.ledger, so there is no triage file to write" };
|
|
89
|
+
if (t.warning) return t;
|
|
90
|
+
const { entries } = readTriage(t.abs);
|
|
91
|
+
const have = new Set(entries.filter((e) => e.item).map((e) => e.item.finding_id));
|
|
92
|
+
let added = 0;
|
|
93
|
+
let already = 0;
|
|
94
|
+
for (const f of found) {
|
|
95
|
+
const id = findingId(idPrefix, [source, reader, f.kind, f.text, f.evidence ?? null]);
|
|
96
|
+
if (have.has(id)) { already++; continue; }
|
|
97
|
+
have.add(id);
|
|
98
|
+
entries.push({ item: { finding_id: id, source, reader: reader ?? null, kind: f.kind, text: f.text, evidence: f.evidence ?? null, draft_sha256: draftSha, disposition: null, answer: null } });
|
|
99
|
+
added++;
|
|
100
|
+
}
|
|
101
|
+
if (added) writeTriage(t.abs, entries);
|
|
102
|
+
return { decl: t.decl, added, already };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The spec and the draft a triage command works against, found the way `check` finds them: --draft
|
|
106
|
+
// names one file, and without it a spec that lists writing.form.sequence.files is its files joined
|
|
107
|
+
// in reading order. { spec, draft } or { usage, error }.
|
|
108
|
+
export function triageContext(specPathArg, draftPathArg, command) {
|
|
109
|
+
if (!present(specPathArg)) return { usage: true, error: `${command} needs a spec path` };
|
|
110
|
+
// check.mjs's loadWritingSpec, inlined: check.mjs loads the station registry, which loads this
|
|
111
|
+
// module's station, so importing it here would be a cycle.
|
|
112
|
+
const spec = loadSpec(specPathArg);
|
|
113
|
+
if (spec.error) return { usage: true, error: spec.error };
|
|
114
|
+
if (str(spec.data?.profile) !== "writing") return { usage: true, error: `${command} needs a writing spec (profile: writing)` };
|
|
115
|
+
if (present(draftPathArg)) {
|
|
116
|
+
const draft = readDraft(draftPathArg);
|
|
117
|
+
return draft ? { spec, draft } : { usage: true, error: `cannot read draft: ${draftPathArg}` };
|
|
118
|
+
}
|
|
119
|
+
if (!sequenceFilesDecl(spec).length) return { usage: true, error: `${command} needs --draft <file>` };
|
|
120
|
+
const draft = readSequenceDraft(spec, specPathArg);
|
|
121
|
+
return draft ? { spec, draft } : { usage: true, error: `writing.form.sequence.files matches no file: ${sequenceFilesDecl(spec).join(", ")}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- the state of a triage file against a draft -------------------------------------------------
|
|
125
|
+
|
|
126
|
+
const who = (item) => (present(item.reader) ? item.reader : present(item.source) ? item.source : "a reader");
|
|
127
|
+
const label = (item) => `${item.finding_id} (${who(item)}, ${item.kind ?? "note"})`;
|
|
128
|
+
|
|
129
|
+
// Everything check's triage station and `triage status` report, from the file and the draft:
|
|
130
|
+
// { decl, exists, items, counts, findings, shared }. findings are station findings (station
|
|
131
|
+
// "triage"); shared lists the passages two or more readers raised findings about.
|
|
132
|
+
export function triageState(spec, draft) {
|
|
133
|
+
const t = openTriage(spec);
|
|
134
|
+
if (!t) return { skip: "the spec declares no improvement.ledger, so it has no triage file" };
|
|
135
|
+
if (t.warning) return { skip: t.warning };
|
|
136
|
+
const { exists, entries } = readTriage(t.abs);
|
|
137
|
+
if (!exists) return { decl: t.decl, exists: false, skip: `no triage file at ${t.decl} yet; a recorded panel verdict or a triage import starts one` };
|
|
138
|
+
|
|
139
|
+
const locate = evidenceLocator(draft.text);
|
|
140
|
+
const findings = [];
|
|
141
|
+
const finding = (id, severity, message, fix, line) => ({ station: "triage", id, severity, ...(line ? { line } : {}), message, fix });
|
|
142
|
+
const items = entries.filter((e) => e.item).map((e) => e.item);
|
|
143
|
+
const counts = { taken: 0, kept: 0, "already-true": 0, open: 0, unanswered: 0 };
|
|
144
|
+
|
|
145
|
+
for (const e of entries.filter((x) => !x.item)) {
|
|
146
|
+
findings.push(finding("station-triage-unreadable", "fail", `${t.decl} line ${e.line} is not a finding: not JSON, or no finding_id or text`, "Fix or remove the line; every line is one finding hyperspec wrote."));
|
|
147
|
+
}
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
const at = present(item.evidence) ? locate(item.evidence)?.line : undefined;
|
|
150
|
+
const d = item.disposition;
|
|
151
|
+
if (d === null || d === undefined || d === "") {
|
|
152
|
+
counts.unanswered++;
|
|
153
|
+
findings.push(finding("station-triage-untriaged", "fail", `${label(item)} is not answered: "${truncate(item.text, 80)}"`, `Answer it: hyperspec triage answer <spec> ${item.finding_id} taken|kept|already-true|open, with --evidence or --reason.`, at));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!DISPOSITIONS.includes(d)) {
|
|
157
|
+
findings.push(finding("station-triage-disposition", "fail", `${label(item)} has disposition "${truncate(String(d), 40)}", which is not taken, kept, already-true or open`, "Answer it again with triage answer."));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
counts[d]++;
|
|
161
|
+
const answer = isObject(item.answer) ? item.answer : {};
|
|
162
|
+
if (d === "taken" || d === "already-true") {
|
|
163
|
+
const problem = evidenceProblem(locate, answer.evidence);
|
|
164
|
+
if (problem === "missing" || problem === "too-short") {
|
|
165
|
+
findings.push(finding("station-triage-evidence-missing", "fail", `${label(item)} is ${d}, and its answer quotes no passage of at least ${MIN_EVIDENCE_WORDS} words`, `Answer it again with --evidence: the passage of the draft that ${d === "taken" ? "now does" : "already did"} what it asked.`, at));
|
|
166
|
+
} else if (problem === "not-found") {
|
|
167
|
+
findings.push(finding("station-triage-evidence-not-found", "fail", `${label(item)} is ${d}, and its evidence is not in the draft: "${truncate(answer.evidence, 80)}"`, "The passage changed or went: answer the finding again, quoting the draft as it is now.", at));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (d === "kept" && !present(answer.reason)) {
|
|
171
|
+
findings.push(finding("station-triage-reason-missing", "fail", `${label(item)} is kept, and its answer gives no reason`, "Answer it again with --reason: why the passage stays as it is.", at));
|
|
172
|
+
}
|
|
173
|
+
if (d === "open") {
|
|
174
|
+
findings.push(finding("station-triage-open", "warn", `${label(item)} is open: "${truncate(item.text, 80)}"${present(answer.reason) ? ` (${truncate(answer.reason, 80)})` : ""}`, "A decision for the operator; answer it once it is made.", at));
|
|
175
|
+
}
|
|
176
|
+
// An answer that keeps a passage, or leaves it open, was about that passage: when the passage
|
|
177
|
+
// is no longer in the draft, the answer is about a draft that is gone.
|
|
178
|
+
if ((d === "kept" || d === "open") && present(item.evidence) && !at && present(answer.draft_sha256) && answer.draft_sha256 !== draft.sha256) {
|
|
179
|
+
findings.push(finding("station-triage-stale", "warn", `${label(item)} was answered ${d} about a passage that is no longer in the draft: "${truncate(item.evidence, 80)}"`, "Read the answer again against the draft as it is now, and answer the finding again if it no longer holds."));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { decl: t.decl, exists: true, items, counts, findings, shared: sharedPassages(items, locate, draft.text) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The synthesis: every passage of the draft that findings from two or more readers point at, where
|
|
186
|
+
// two findings share a passage when their evidence overlaps in the draft. It groups by where, never
|
|
187
|
+
// by meaning: two readers who say the same thing about different passages are not grouped.
|
|
188
|
+
export function sharedPassages(items, locate, text) {
|
|
189
|
+
const placed = items
|
|
190
|
+
.filter((i) => present(i.evidence))
|
|
191
|
+
.map((i) => ({ item: i, at: locate(i.evidence) }))
|
|
192
|
+
.filter((x) => x.at)
|
|
193
|
+
.sort((a, b) => a.at.start - b.at.start || a.at.end - b.at.end);
|
|
194
|
+
const groups = [];
|
|
195
|
+
for (const p of placed) {
|
|
196
|
+
const g = groups.at(-1);
|
|
197
|
+
if (g && p.at.start < g.end) { g.members.push(p); g.end = Math.max(g.end, p.at.end); }
|
|
198
|
+
else groups.push({ start: p.at.start, end: p.at.end, line: p.at.line, members: [p] });
|
|
199
|
+
}
|
|
200
|
+
return groups
|
|
201
|
+
.filter((g) => new Set(g.members.map((m) => `${m.item.source}\u0000${m.item.reader}`)).size >= 2)
|
|
202
|
+
.map((g) => ({
|
|
203
|
+
line: g.line,
|
|
204
|
+
passage: truncate(text.slice(g.start, g.end).replace(/\s+/g, " "), 80),
|
|
205
|
+
findings: g.members.map((m) => ({ finding_id: m.item.finding_id, reader: who(m.item), kind: m.item.kind, text: m.item.text })),
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---- answering ----------------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
// Records one answer. Returns { usage, error } for a usage problem (exit 2), { invalid, findings }
|
|
212
|
+
// when the answer does not hold (exit 1, nothing written), or { ok, item } (exit 0).
|
|
213
|
+
export function answerFinding(spec, draft, findingIdArg, disposition, { evidence, reason } = {}) {
|
|
214
|
+
if (!present(findingIdArg)) return { usage: true, error: "triage answer needs a finding id" };
|
|
215
|
+
if (!DISPOSITIONS.includes(disposition)) return { usage: true, error: `triage answer needs a disposition: ${DISPOSITIONS.join(", ")}` };
|
|
216
|
+
const t = openTriage(spec);
|
|
217
|
+
if (!t || t.warning) return { usage: true, error: t?.warning ?? "the spec declares no improvement.ledger, so it has no triage file" };
|
|
218
|
+
const { exists, entries } = readTriage(t.abs);
|
|
219
|
+
if (!exists) return { usage: true, error: `no triage file yet: ${t.decl}` };
|
|
220
|
+
const entry = entries.find((e) => e.item?.finding_id === findingIdArg);
|
|
221
|
+
if (!entry) return { usage: true, error: `no finding ${findingIdArg} in ${t.decl}` };
|
|
222
|
+
|
|
223
|
+
const refuse = (id, message, fix) => ({ invalid: true, findings: [{ station: "triage", id, severity: "fail", message, fix }], code: 1 });
|
|
224
|
+
if (disposition === "taken" || disposition === "already-true") {
|
|
225
|
+
const problem = evidenceProblem(evidenceLocator(draft.text), evidence);
|
|
226
|
+
const where = disposition === "taken" ? "now does" : "already did";
|
|
227
|
+
if (problem === "missing") return refuse("triage-evidence-missing", `a ${disposition} answer needs --evidence`, `Quote the passage of the draft that ${where} what the finding asked.`);
|
|
228
|
+
if (problem === "too-short") return refuse("triage-evidence-too-short", `--evidence has ${evidenceWords(evidence)} words, fewer than ${MIN_EVIDENCE_WORDS}: "${truncate(evidence, 80)}"`, "Quote the sentence or clause itself, at least three words, verbatim.");
|
|
229
|
+
if (problem === "not-found") return refuse("triage-evidence-not-found", `--evidence is not in the draft: "${truncate(evidence, 80)}"`, "Copy it verbatim from the draft as it is now, whole words only; only whitespace and quote characters may differ.");
|
|
230
|
+
}
|
|
231
|
+
if (disposition === "kept" && !present(reason)) return refuse("triage-reason-missing", "a kept answer needs --reason", "Say why the passage stays as it is.");
|
|
232
|
+
|
|
233
|
+
const answer = {
|
|
234
|
+
...(present(evidence) ? { evidence } : {}),
|
|
235
|
+
...(present(reason) ? { reason: reason.trim() } : {}),
|
|
236
|
+
draft_sha256: draft.sha256,
|
|
237
|
+
at: new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
entry.item = { ...entry.item, disposition, answer };
|
|
240
|
+
writeTriage(t.abs, entries);
|
|
241
|
+
return { ok: true, path: t.decl, item: ordered(entry.item), code: 0 };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- importing an outside review ----------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const KIND_WORDS = new Map([["good", "good"], ["improve", "improve"], ["improvement", "improve"], ["improvements", "improve"], ["missing", "missing"], ["remove", "remove"]]);
|
|
247
|
+
|
|
248
|
+
// The kind a label names ("Good", "What to improve" does not; "Improve:" does), or null.
|
|
249
|
+
function kindOf(label) {
|
|
250
|
+
const first = String(label).toLowerCase().replace(/[*_`#:]/g, " ").trim().split(/\s+/)[0] ?? "";
|
|
251
|
+
return KIND_WORDS.get(first) ?? null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ATX = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t#]*$/;
|
|
255
|
+
const LIST = /^( {0,3})([-*+]|\d+[.)])[ \t]+(.*)$/;
|
|
256
|
+
const BOLD_LABEL = /^[ \t]*(?:\*\*([^*]+?)\*\*|__([^_]+?)__)[ \t]*:?[ \t]*(.*)$/;
|
|
257
|
+
const PLAIN_LABEL = /^[ \t]*([A-Za-z]+)[ \t]*:[ \t]*(.*)$/;
|
|
258
|
+
|
|
259
|
+
// A label at the start of `text`: bold ("**Improve:**", "**Missing**") or a plain word with a colon
|
|
260
|
+
// ("Remove: ..."), naming one of the four kinds. { kind, rest } or null.
|
|
261
|
+
function labelAt(text) {
|
|
262
|
+
const b = BOLD_LABEL.exec(text);
|
|
263
|
+
if (b && kindOf(b[1] ?? b[2])) return { kind: kindOf(b[1] ?? b[2]), rest: b[3].trim() };
|
|
264
|
+
const p = PLAIN_LABEL.exec(text);
|
|
265
|
+
if (p && kindOf(p[1])) return { kind: kindOf(p[1]), rest: p[2].trim() };
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const QUOTED = /"([^"\n]+)"|“([^“”\n]+)”/g;
|
|
269
|
+
|
|
270
|
+
// The findings in a review's text: [{ reader, kind, text, quotes }], plus how many "good" items were
|
|
271
|
+
// left out. See WRITING.md, Importing an outside review, for the reading rules.
|
|
272
|
+
export function parseReview(text) {
|
|
273
|
+
const body = String(text).replace(/^/, "").replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, (fm) => fm.replace(/[^\n]/g, ""));
|
|
274
|
+
const lines = maskCode(body).split("\n").map((l) => l.replace(/\r$/, ""));
|
|
275
|
+
const out = [];
|
|
276
|
+
let praise = 0;
|
|
277
|
+
let reader = null;
|
|
278
|
+
let kind = null;
|
|
279
|
+
let cur = null;
|
|
280
|
+
const flush = () => {
|
|
281
|
+
if (!cur) return;
|
|
282
|
+
const t = cur.lines.join(" ").replace(/\s+/g, " ").trim();
|
|
283
|
+
const { kind: k, blockquotes } = cur;
|
|
284
|
+
cur = null;
|
|
285
|
+
if (!t) return;
|
|
286
|
+
if (k === "good") { praise++; return; }
|
|
287
|
+
const quotes = [...t.matchAll(QUOTED)].map((m) => m[1] ?? m[2]).concat(blockquotes);
|
|
288
|
+
out.push({ reader, kind: k ?? "note", text: t, quotes });
|
|
289
|
+
};
|
|
290
|
+
const start = (first, isList, k = kind) => { cur = { kind: k, isList, lines: [first], blockquotes: [] }; };
|
|
291
|
+
for (let i = 0; i < lines.length; i++) {
|
|
292
|
+
const line = lines[i];
|
|
293
|
+
if (!line.trim()) {
|
|
294
|
+
// A list item runs on across a blank line only into an indented continuation.
|
|
295
|
+
const next = lines.slice(i + 1).find((l) => l.trim());
|
|
296
|
+
if (!(cur?.isList && next && /^[ \t]{2,}\S/.test(next) && !LIST.test(next))) flush();
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const h = ATX.exec(line);
|
|
300
|
+
if (h) {
|
|
301
|
+
flush();
|
|
302
|
+
const text = h[2].replace(/[*_`]/g, "").trim();
|
|
303
|
+
const k = kindOf(text);
|
|
304
|
+
if (k) kind = k;
|
|
305
|
+
else { reader = text || null; kind = null; }
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const li = LIST.exec(line);
|
|
309
|
+
const lab = li ? null : labelAt(line);
|
|
310
|
+
if (lab) {
|
|
311
|
+
// A label line sets the kind for what follows; text after it on the line is a finding.
|
|
312
|
+
flush();
|
|
313
|
+
kind = lab.kind;
|
|
314
|
+
if (lab.rest) start(lab.rest, false);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (li) {
|
|
318
|
+
flush();
|
|
319
|
+
const inner = labelAt(li[3]);
|
|
320
|
+
// "- Remove: ..." is one finding of that kind; the list's kind is unchanged.
|
|
321
|
+
if (inner && inner.rest) start(inner.rest, true, inner.kind);
|
|
322
|
+
else start(li[3], true);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (cur) {
|
|
326
|
+
// A quotation block inside a finding is a passage it quotes.
|
|
327
|
+
const bq = /^[ \t]*>[ \t]?(.*)$/.exec(line);
|
|
328
|
+
if (bq) { cur.blockquotes.push(bq[1].replace(/^["“]|["”]$/g, "").trim()); cur.lines.push(bq[1].trim()); continue; }
|
|
329
|
+
cur.lines.push(line.trim());
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
// A paragraph under a kind is one finding; prose under no kind (an introduction) is not.
|
|
333
|
+
if (kind) start(line.trim(), false);
|
|
334
|
+
}
|
|
335
|
+
flush();
|
|
336
|
+
return { findings: out, praise };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Brings an outside review in as findings. Returns { ok, path, source, added, already, praise,
|
|
340
|
+
// warnings } or { usage, error } / { invalid, findings } when the review holds no finding.
|
|
341
|
+
export function importReview(spec, draft, reviewText, source) {
|
|
342
|
+
const t = openTriage(spec);
|
|
343
|
+
if (!t || t.warning) return { usage: true, error: t?.warning ?? "the spec declares no improvement.ledger, so it has no triage file" };
|
|
344
|
+
const { findings, praise } = parseReview(reviewText);
|
|
345
|
+
if (!findings.length) {
|
|
346
|
+
return { invalid: true, findings: [{ station: "triage", id: "triage-import-empty", severity: "fail", message: `the review holds no finding to answer${praise ? ` (only ${praise} item${praise === 1 ? "" : "s"} of praise)` : ""}`, fix: "Import a review whose points are list items or paragraphs under Improve, Missing or Remove, or under a reader's heading." }], code: 1 };
|
|
347
|
+
}
|
|
348
|
+
const locate = evidenceLocator(draft.text);
|
|
349
|
+
const warnings = [];
|
|
350
|
+
const found = findings.map((f) => {
|
|
351
|
+
const quotes = f.quotes.filter((q) => evidenceWords(q) >= MIN_EVIDENCE_WORDS);
|
|
352
|
+
const evidence = quotes.find((q) => locate(q)) ?? null;
|
|
353
|
+
if (quotes.length && !evidence) {
|
|
354
|
+
warnings.push({ station: "triage", id: "triage-import-quote-not-found", severity: "warn", message: `a finding quotes text that is not in the current draft: "${truncate(quotes[0], 80)}" (${truncate(f.text, 60)})`, fix: "The review may have read another copy of the draft. Check the finding against the draft as it is now before answering it." });
|
|
355
|
+
}
|
|
356
|
+
return { kind: f.kind, text: f.text, evidence, reader: f.reader };
|
|
357
|
+
});
|
|
358
|
+
// One addFindings call per reader, so each finding records its reader.
|
|
359
|
+
let added = 0;
|
|
360
|
+
let already = 0;
|
|
361
|
+
const readers = [...new Set(found.map((f) => f.reader))];
|
|
362
|
+
for (const reader of readers) {
|
|
363
|
+
const r = addFindings(spec, found.filter((f) => f.reader === reader), { source, reader, draftSha: draft.sha256, idPrefix: "import" });
|
|
364
|
+
if (r.warning) return { usage: true, error: r.warning };
|
|
365
|
+
added += r.added;
|
|
366
|
+
already += r.already;
|
|
367
|
+
}
|
|
368
|
+
return { ok: true, path: t.decl, source, added, already, praise, warnings, code: 0 };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---- the reply ----------------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
const SECTIONS = [
|
|
374
|
+
["taken", "Taken"],
|
|
375
|
+
["kept", "Kept as it is"],
|
|
376
|
+
["already-true", "Already in the draft"],
|
|
377
|
+
["open", "Still open"],
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
// The reply to the reviewer, as plain text: what was taken, what was kept and why, what was already
|
|
381
|
+
// there, what is still open, and anything not answered yet. source limits it to one review.
|
|
382
|
+
export function replyText(items, source) {
|
|
383
|
+
const mine = items.filter((i) => !source || i.source === source);
|
|
384
|
+
const line = (i) => {
|
|
385
|
+
const a = isObject(i.answer) ? i.answer : {};
|
|
386
|
+
const lead = `- ${present(i.reader) ? `${i.reader}: ` : ""}${i.text.trim()}`;
|
|
387
|
+
if (i.disposition === "taken") return `${lead} Now in the draft: "${a.evidence}"`;
|
|
388
|
+
if (i.disposition === "kept") return `${lead} Why: ${a.reason}`;
|
|
389
|
+
if (i.disposition === "already-true") return `${lead} It is here: "${a.evidence}"`;
|
|
390
|
+
if (i.disposition === "open") return `${lead}${present(a.reason) ? ` To decide: ${a.reason}` : ""}`;
|
|
391
|
+
return lead;
|
|
392
|
+
};
|
|
393
|
+
const parts = ["Thank you for the review. Here is what happened to each point."];
|
|
394
|
+
for (const [d, heading] of SECTIONS) {
|
|
395
|
+
const these = mine.filter((i) => i.disposition === d);
|
|
396
|
+
if (these.length) parts.push(`${heading}:\n${these.map(line).join("\n")}`);
|
|
397
|
+
}
|
|
398
|
+
const unanswered = mine.filter((i) => !DISPOSITIONS.includes(i.disposition));
|
|
399
|
+
if (unanswered.length) parts.push(`Not answered yet:\n${unanswered.map(line).join("\n")}`);
|
|
400
|
+
return { text: `${parts.join("\n\n")}\n`, count: mine.length, unanswered: unanswered.length };
|
|
401
|
+
}
|
package/src/writing-fields.mjs
CHANGED
|
@@ -457,7 +457,7 @@ function formFields(raw, d, here, idPrefix) {
|
|
|
457
457
|
function sequenceFields(seq, here, idPrefix) {
|
|
458
458
|
if (!isObj(seq)) return [f(1, idPrefix, "fail", "writing.form.sequence is not a map", "Write sequence: as a map of the keys WRITING.md lists, one per line.")];
|
|
459
459
|
const out = [];
|
|
460
|
-
for (const key of ["unit", "terms_section", "teaser"]) {
|
|
460
|
+
for (const key of ["unit", "terms_section", "teaser", "quiz"]) {
|
|
461
461
|
if (key in seq && !str(seq[key])) out.push(f(1, `${idPrefix}-${key.replace("_", "-")}`, "fail", `writing.form.sequence.${key} is empty or a placeholder`, `Give ${key} a real value, or delete it for the default.`));
|
|
462
462
|
}
|
|
463
463
|
const strings = (key, fix) => {
|
|
@@ -632,6 +632,53 @@ function characterFields(c, here, idPrefix) {
|
|
|
632
632
|
return out;
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
// ---------------------------------------------------------------- quotes and panel -------------
|
|
636
|
+
// Two optional keys under writing: that are not blocks (no check, source or author of their own):
|
|
637
|
+
// writing.quotes, which tells the quotes station which quoted spans are example phrasings, and
|
|
638
|
+
// writing.panel, the readers the panel judge adds beside the audience's own reader. Each present
|
|
639
|
+
// key has to be usable, the way a present sequence key does, because a hollow value would switch
|
|
640
|
+
// something off without saying so.
|
|
641
|
+
|
|
642
|
+
export function quotesFields(raw) {
|
|
643
|
+
if (!isObj(raw)) return [f(1, "writing-quotes", "fail", "writing.quotes is not a map", "Write quotes: as a map, with examples: under it.")];
|
|
644
|
+
if (!("examples" in raw)) return [];
|
|
645
|
+
const ex = raw.examples;
|
|
646
|
+
if (Array.isArray(ex)) {
|
|
647
|
+
if (!ex.length || ex.some((x) => typeof x !== "string" || !str(x))) {
|
|
648
|
+
return [f(1, "writing-quotes-examples", "fail", "writing.quotes.examples is a list, and not a list of real phrasings", "List each example phrasing as it appears between the quotation marks, or set examples: true.")];
|
|
649
|
+
}
|
|
650
|
+
return [];
|
|
651
|
+
}
|
|
652
|
+
if (str(ex) === "true" || str(ex) === "false") return [];
|
|
653
|
+
return [f(1, "writing-quotes-examples", "fail", `writing.quotes.examples is "${str(ex) || "(none)"}", not true, false or a list of phrasings`, "Set examples: true, false, or a list of the example phrasings.")];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// A panel reader's id names its packet file (panel-<id>.packet.json), so it is a slug.
|
|
657
|
+
const READER_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
658
|
+
|
|
659
|
+
export function panelFields(raw) {
|
|
660
|
+
if (!Array.isArray(raw)) return [f(1, "writing-panel", "fail", "writing.panel is not a list", "Write panel: as a list of readers, each with id, who and lens, or delete it for the default panel.")];
|
|
661
|
+
if (!raw.length) return [f(1, "writing-panel", "fail", "writing.panel is present and names no reader", "List at least one reader, or delete panel: for the default panel.")];
|
|
662
|
+
const out = [];
|
|
663
|
+
const seen = new Set();
|
|
664
|
+
raw.forEach((r, i) => {
|
|
665
|
+
const tag = str(r?.id) || `#${i + 1}`;
|
|
666
|
+
if (!isObj(r)) { out.push(f(1, "writing-panel-reader", "fail", `writing.panel reader ${tag} is not a map`, "Write each reader as id, who, lens and optionally knows, one per line.")); return; }
|
|
667
|
+
const id = str(r.id);
|
|
668
|
+
if (!id) out.push(f(1, "writing-panel-id", "fail", `writing.panel reader ${tag} has no id`, "Give the reader a short id, such as skeptic."));
|
|
669
|
+
else if (!READER_ID.test(id)) out.push(f(1, "writing-panel-id", "fail", `writing.panel reader id "${id}" is not lower case letters, digits and hyphens`, "Use an id such as skeptic or domain-expert: it names the reader's packet file."));
|
|
670
|
+
else if (id === "buyer") out.push(f(1, "writing-panel-buyer", "fail", "writing.panel names a reader \"buyer\"; the buyer is always added, from writing.audience", "Rename this reader; the audience's own reader joins every panel as buyer."));
|
|
671
|
+
else if (seen.has(id)) out.push(f(1, "writing-panel-id-duplicate", "fail", `writing.panel reader id "${id}" is used twice`, "Ids must be unique across writing.panel; rename one."));
|
|
672
|
+
if (id) seen.add(id);
|
|
673
|
+
if (!str(r.who)) out.push(f(1, "writing-panel-who", "fail", `writing.panel reader ${tag} has no who`, "Say who this reader is, in a line."));
|
|
674
|
+
if (!str(r.lens)) out.push(f(1, "writing-panel-lens", "fail", `writing.panel reader ${tag} has no lens`, "Say what this reader reads for, in a line."));
|
|
675
|
+
if ("knows" in r && (!Array.isArray(r.knows) || r.knows.some((x) => typeof x !== "string" || !str(x)))) {
|
|
676
|
+
out.push(f(1, "writing-panel-knows", "fail", `writing.panel reader ${tag} has knows that is not a list of real entries`, "List what the reader already knows, one entry per line, or delete knows:."));
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
681
|
+
|
|
635
682
|
// The eight object blocks' field rules, keyed by block name. characters is a list and is called
|
|
636
683
|
// per-entry (characterFields) directly from writing.mjs, not through this map.
|
|
637
684
|
export const BLOCK_FIELD_RULES = Object.freeze({
|
package/src/writing.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// same nine tests; they do not change the shape here.
|
|
12
12
|
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
|
-
import { BLOCK_FIELD_RULES, characterFields } from "./writing-fields.mjs";
|
|
14
|
+
import { BLOCK_FIELD_RULES, characterFields, quotesFields, panelFields } from "./writing-fields.mjs";
|
|
15
15
|
import { str } from "./placeholder.mjs";
|
|
16
16
|
|
|
17
17
|
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
@@ -108,6 +108,10 @@ export function lintWriting(spec) {
|
|
|
108
108
|
out.push(f(7, "writing-progress", "fail", "writing.progress is stored state; progress is derived from disk, never saved", "Remove writing.progress; derive progress by reading the drafted work itself, not by saving a record of it."));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Two optional keys that are not blocks: the quotes station's example phrasings, and the panel.
|
|
112
|
+
if ("quotes" in writing) out.push(...quotesFields(writing.quotes));
|
|
113
|
+
if ("panel" in writing) out.push(...panelFields(writing.panel));
|
|
114
|
+
|
|
111
115
|
for (const block of BLOCKS) {
|
|
112
116
|
const raw = writing[block];
|
|
113
117
|
if (!blockPresent(block, raw)) {
|