@supersuit/hyperspec 0.5.0 → 0.7.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +62 -1
  3. package/SPEC.md +4 -4
  4. package/WRITING.md +770 -9
  5. package/bin/hyperspec.mjs +252 -0
  6. package/examples/writing/essay/claims.jsonl +9 -0
  7. package/examples/writing/essay/draft.md +82 -0
  8. package/examples/writing/essay/judge/doctor.packet.json +108 -0
  9. package/examples/writing/essay/judge/lineup.packet.json +64 -0
  10. package/examples/writing/essay/judge/persona.packet.json +73 -0
  11. package/examples/writing/essay/judge/reader.packet.json +93 -0
  12. package/examples/writing/essay/learn/first-draft.md +84 -0
  13. package/examples/writing/essay/learn/learn.packet.json +106 -0
  14. package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +2 -2
  15. package/examples/writing/essay/sample-verdicts/doctor.verdict.json +43 -0
  16. package/examples/writing/essay/sample-verdicts/learn.verdict.json +30 -0
  17. package/examples/writing/essay/sample-verdicts/lineup.verdict.json +6 -0
  18. package/examples/writing/essay/sample-verdicts/persona.verdict.json +4 -0
  19. package/examples/writing/essay/sample-verdicts/reader.verdict.json +7 -0
  20. package/examples/writing/essay.hyperspec.md +23 -7
  21. package/examples/writing/story/claims.jsonl +9 -0
  22. package/examples/writing/story/draft.md +267 -0
  23. package/examples/writing/story/judge/attribution.packet.json +194 -0
  24. package/examples/writing/story/judge/doctor.packet.json +108 -0
  25. package/examples/writing/story/judge/knowledge.packet.json +77 -0
  26. package/examples/writing/story/judge/persona.packet.json +73 -0
  27. package/examples/writing/story/judge/reader.packet.json +94 -0
  28. package/examples/writing/story/sample-verdicts/attribution.verdict.json +81 -0
  29. package/examples/writing/story/sample-verdicts/doctor.verdict.json +43 -0
  30. package/examples/writing/story/sample-verdicts/knowledge.verdict.json +4 -0
  31. package/examples/writing/story/sample-verdicts/persona.verdict.json +20 -0
  32. package/examples/writing/story/sample-verdicts/reader.verdict.json +16 -0
  33. package/examples/writing/story.hyperspec.md +24 -5
  34. package/package.json +1 -1
  35. package/src/check.mjs +191 -0
  36. package/src/dna.mjs +4 -1
  37. package/src/draft.mjs +26 -0
  38. package/src/judge.mjs +386 -0
  39. package/src/judges/attribution.mjs +360 -0
  40. package/src/judges/doctor.mjs +126 -0
  41. package/src/judges/index.mjs +31 -0
  42. package/src/judges/knowledge.mjs +111 -0
  43. package/src/judges/lineup.mjs +272 -0
  44. package/src/judges/persona.mjs +137 -0
  45. package/src/judges/reader.mjs +111 -0
  46. package/src/learn.mjs +422 -0
  47. package/src/ledger.mjs +108 -0
  48. package/src/sentences.mjs +81 -0
  49. package/src/stations/claims.mjs +155 -0
  50. package/src/stations/dna.mjs +126 -0
  51. package/src/stations/form.mjs +115 -0
  52. package/src/stations/index.mjs +27 -0
  53. package/src/stations/links.mjs +275 -0
  54. package/src/stations/private.mjs +117 -0
  55. package/src/stations/quotes.mjs +170 -0
  56. package/src/stations/terms.mjs +131 -0
  57. package/src/stations/util.mjs +99 -0
  58. package/src/writing-fields.mjs +26 -2
  59. package/src/writing.mjs +1 -1
@@ -0,0 +1,155 @@
1
+ // Station "claims" (hyperspec 0.6). Reads writing.sources.ledger, a JSONL file
2
+ // where each line is one claim: { "text": <claim as it appears in the draft>, "source":
3
+ // <non-empty>, "span"?: <quote or locator> }. Two things are checked per line, and whether a
4
+ // sentence in the draft even IS a factual claim is never attempted here: that is judgment, and
5
+ // the ledger is the closed list of what counts as a claim. This station only checks that the
6
+ // ledger and the draft agree with each other:
7
+ //
8
+ // - the claim's text still appears verbatim in the draft (normalized for whitespace and quote
9
+ // characters, but not case) -- otherwise the ledger is stale (station-claims-stale);
10
+ // - the claim carries a real, non-placeholder source -- otherwise it is unsourced
11
+ // (station-claims-unsourced, downgraded to a warning when writing.sources.unsourced_claim is
12
+ // "warn", the same closed-set field src/writing-fields.mjs already validates on the spec).
13
+ //
14
+ // The ledger path resolves relative to the spec (spec.dir), the same way every other path-bearing
15
+ // writing field does. A missing or unreadable ledger fails the whole station on its own
16
+ // (station-claims-ledger-missing); a malformed JSONL line (not JSON, not an object, or missing
17
+ // text) is its own finding naming the line number, and does not stop the rest of the file from
18
+ // being read.
19
+
20
+ import { readFileSync } from "node:fs";
21
+ import { resolve } from "node:path";
22
+ import { str } from "../placeholder.mjs";
23
+ import { lineAt, truncate } from "./util.mjs";
24
+
25
+ export const name = "claims";
26
+
27
+ // Quote characters normalized to their straight ASCII form, then whitespace runs collapsed to a
28
+ // single space and the ends trimmed. "text matching is exact after normalizing whitespace and
29
+ // quote characters": case is NOT normalized, so a claim's text must
30
+ // still match the draft's actual capitalization.
31
+ function normalize(text) {
32
+ return String(text)
33
+ .replace(/[‘’‚‛]/g, "'")
34
+ .replace(/[“”„‟]/g, '"')
35
+ .replace(/\s+/g, " ")
36
+ .trim();
37
+ }
38
+
39
+ // Where a claim's text first appears in the original draft, under the same normalization the match
40
+ // uses (any whitespace run for a space, any quote character for a quote), or -1.
41
+ function firstOccurrence(text, claimText) {
42
+ const body = [...normalize(claimText)].map((c) => (c === " " ? "\\s+" : c === "'" ? "['‘’‚‛]" : c === '"' ? '["“”„‟]' : c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("");
43
+ const m = new RegExp(body).exec(text);
44
+ return m ? m.index : -1;
45
+ }
46
+
47
+ // The claims ledger as the claims station reads it, shared with the persona judge
48
+ // (src/judges/persona.mjs), so both see the same claims. { path, missing, lines }: path is
49
+ // writing.sources.ledger as written (null when unset); missing is true when it is set but cannot be
50
+ // read; lines holds every non-blank line, 1-based as n, each either { n, text } (a claim with
51
+ // non-empty text; claim is the parsed object) or { n, problem } naming why it is not one.
52
+ export function readClaimsLedger(spec) {
53
+ const ledgerPath = str(spec?.data?.writing?.sources?.ledger);
54
+ if (!ledgerPath) return { path: null, missing: false, lines: [] };
55
+ let raw;
56
+ try {
57
+ // A leading UTF-8 BOM (written by default by several Windows/Excel-adjacent editors) is not
58
+ // valid JSON leading whitespace, so it must come off before line 1 is parsed, or a genuinely
59
+ // well-formed first line reports as broken JSON for a reason that has nothing to do with its
60
+ // content.
61
+ raw = readFileSync(resolve(spec?.dir || ".", ledgerPath), "utf8").replace(/^\uFEFF/, "");
62
+ } catch {
63
+ return { path: ledgerPath, missing: true, lines: [] };
64
+ }
65
+ const lines = [];
66
+ raw.split("\n").forEach((text, i) => {
67
+ if (text.trim() === "") return;
68
+ const n = i + 1;
69
+ let obj;
70
+ try { obj = JSON.parse(text); } catch { lines.push({ n, problem: "is not valid JSON" }); return; }
71
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) { lines.push({ n, problem: "is not a JSON object" }); return; }
72
+ const claimText = typeof obj.text === "string" ? obj.text : "";
73
+ if (!claimText.trim()) { lines.push({ n, problem: "has no text" }); return; }
74
+ lines.push({ n, text: claimText, claim: obj });
75
+ });
76
+ return { path: ledgerPath, missing: false, lines };
77
+ }
78
+
79
+ const PROBLEM_FIX = {
80
+ "is not valid JSON": "Fix the JSON on that line.",
81
+ "is not a JSON object": 'Each ledger line must be a JSON object: {"text": "...", "source": "..."}.',
82
+ "has no text": "Add text: the claim exactly as it appears in the draft.",
83
+ };
84
+
85
+ export function run(spec, draft) {
86
+ const sources = spec?.data?.writing?.sources ?? {};
87
+ const ledgerPath = str(sources.ledger);
88
+ const unsourcedSeverity = str(sources.unsourced_claim) === "warn" ? "warn" : "fail";
89
+
90
+ if (!ledgerPath) {
91
+ // writing.sources.ledger is a required field (lint test 1), so by the time check runs (lint
92
+ // already passed) a real spec always has one; this is defense in depth for a caller that
93
+ // builds a spec object by hand and skips lint, mirroring how form.mjs treats its own inputs
94
+ // as never fully trusted either.
95
+ return { station: name, status: "skip", findings: [], reason: "writing.sources.ledger is not set" };
96
+ }
97
+
98
+ const ledger = readClaimsLedger(spec);
99
+ if (ledger.missing) {
100
+ return {
101
+ station: name,
102
+ status: "fail",
103
+ findings: [{
104
+ station: name,
105
+ id: "station-claims-ledger-missing",
106
+ severity: "fail",
107
+ message: `writing.sources.ledger "${ledgerPath}" does not exist or cannot be read`,
108
+ fix: `Create ${ledgerPath} as JSONL, one claim per line: {"text": "...", "source": "..."}.`,
109
+ }],
110
+ };
111
+ }
112
+
113
+ const findings = [];
114
+ const draftNorm = normalize(draft.text);
115
+
116
+ for (const { n, problem, text: claimText, claim: obj } of ledger.lines) {
117
+ if (problem) {
118
+ findings.push({
119
+ station: name,
120
+ id: `station-claims-json-line-${n}`,
121
+ severity: "fail",
122
+ message: `writing.sources.ledger "${ledgerPath}" line ${n} ${problem}`,
123
+ fix: PROBLEM_FIX[problem],
124
+ });
125
+ continue;
126
+ }
127
+
128
+ const tag = truncate(claimText, 80);
129
+
130
+ if (!draftNorm.includes(normalize(claimText))) {
131
+ findings.push({
132
+ station: name,
133
+ id: "station-claims-stale",
134
+ severity: "fail",
135
+ message: `ledger line ${n}, "${tag}", does not appear verbatim in the draft`,
136
+ fix: "Update the ledger's text to match the draft exactly, or remove the stale claim.",
137
+ });
138
+ }
139
+
140
+ if (!str(obj.source)) {
141
+ const at = firstOccurrence(draft.text, claimText);
142
+ findings.push({
143
+ station: name,
144
+ id: "station-claims-unsourced",
145
+ severity: unsourcedSeverity,
146
+ ...(at >= 0 ? { line: lineAt(draft.text, at) } : {}),
147
+ message: `ledger line ${n}, "${tag}", has no source (or it is a placeholder)`,
148
+ fix: "Add a real source: to the claim, or remove it from the ledger.",
149
+ });
150
+ }
151
+ }
152
+
153
+ const status = findings.some((x) => x.severity === "fail") ? "fail" : "pass";
154
+ return { station: name, status, findings };
155
+ }
@@ -0,0 +1,126 @@
1
+ // Station "dna" (hyperspec 0.6). Measures the draft the way `hyperspec dna measure`
2
+ // measures a scope's goldens (dna.mjs's measureFeatures, the same function, never a second copy)
3
+ // and compares it with the scope's recorded features.json. Pure and deterministic: arithmetic on
4
+ // two features objects, no model call and no judgment about whether a difference matters beyond the
5
+ // band rule below.
6
+ //
7
+ // Runs only when writing.dna.scope_dir is set (no scope_dir: skip) and its features.json is
8
+ // current. "Current" is lint's own test-6 notion, read through the one function lint uses
9
+ // (writing-fields.mjs's featuresStaleness): a missing or stale features.json, or a scope whose
10
+ // goldens folder cannot be read, makes this station skip with the reason. Lint already fails the
11
+ // spec for each of those, and check runs no station on a spec lint fails, so the skip is defense in
12
+ // depth for a direct caller; comparing a draft against numbers that no longer describe the goldens
13
+ // would report drift from something that is not the writer's voice.
14
+ //
15
+ // Compared features, and only when the scope's features.json records them: sentence_length.mean,
16
+ // paragraph_length.mean_sentences and paragraph_length.mean_words (the two paragraph-length means),
17
+ // and every per-1000-words rate (each rates_per_1000_words entry, plus contraction_rate and the
18
+ // three person rates, which measureFeatures computes per 1000 words too). Counts, medians, p90,
19
+ // mean word length and signature words are not compared. For a scope value v the band is
20
+ // v / 1.5 (floor 0) to max(v * 1.5, v + 5), edges inside; a draft value outside it is one
21
+ // station-dna-drift finding carrying both values and the band.
22
+ //
23
+ // The em dash has one rule of its own: when the scope's em dash rate is 0 and the draft's is above
24
+ // 0, that is station-dna-em-dash, and the em dash's band finding is not also reported (it is the
25
+ // same defect, and the stricter rule already names it). Fenced and inline code are masked out of
26
+ // the draft before it is measured, like every station that reads prose.
27
+ //
28
+ // Severity: both findings are WARNINGS, never failures, so the station's status is pass
29
+ // whenever it runs. This station measures; judging whether the draft is in the writer's voice
30
+ // belongs to the lineup judge of a later release, and a band over a handful of goldens is evidence
31
+ // for that judge, not a verdict.
32
+
33
+ import { readFileSync } from "node:fs";
34
+ import { join, resolve } from "node:path";
35
+ import { str } from "../placeholder.mjs";
36
+ import { measureFeatures, readScope } from "../dna.mjs";
37
+ import { featuresStaleness } from "../writing-fields.mjs";
38
+ import { lineAt, maskCode } from "./util.mjs";
39
+
40
+ export const name = "dna";
41
+
42
+ const MEANS = [["sentence_length", "mean"], ["paragraph_length", "mean_sentences"], ["paragraph_length", "mean_words"]];
43
+ const FLAT_RATES = ["contraction_rate", "first_person_singular_rate", "first_person_plural_rate", "second_person_rate"];
44
+ const EM_DASH = "rates_per_1000_words.em_dash";
45
+
46
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
47
+ const isNum = (v) => typeof v === "number" && Number.isFinite(v);
48
+ const fmt = (x) => String(Math.round(x * 1000) / 1000);
49
+
50
+ // Every compared feature as [dotted name, scope value, draft value], in a fixed order, for the
51
+ // features the scope records as numbers. A draft value that is not a number reads as 0.
52
+ function comparable(scope, draft) {
53
+ const out = [];
54
+ const add = (key, s, d) => { if (isNum(s)) out.push([key, s, isNum(d) ? d : 0]); };
55
+ for (const [group, field] of MEANS) add(`${group}.${field}`, scope?.[group]?.[field], draft?.[group]?.[field]);
56
+ const rates = isObj(scope?.rates_per_1000_words) ? scope.rates_per_1000_words : {};
57
+ for (const k of Object.keys(rates)) add(`rates_per_1000_words.${k}`, rates[k], draft?.rates_per_1000_words?.[k]);
58
+ for (const k of FLAT_RATES) add(k, scope?.[k], draft?.[k]);
59
+ return out;
60
+ }
61
+
62
+ // compareFeatures(scopeFeatures, draftFeatures): the band rule alone, over two measureFeatures-shaped
63
+ // objects. Returns [{ feature, scope, draft, low, high }] for every compared feature outside its band.
64
+ export function compareFeatures(scopeFeatures, draftFeatures) {
65
+ const drift = [];
66
+ for (const [feature, s, d] of comparable(scopeFeatures, draftFeatures)) {
67
+ const low = Math.max(0, s / 1.5);
68
+ const high = Math.max(s * 1.5, s + 5);
69
+ if (d < low || d > high) drift.push({ feature, scope: s, draft: d, low, high });
70
+ }
71
+ return drift;
72
+ }
73
+
74
+ const skip = (reason) => ({ station: name, status: "skip", findings: [], reason });
75
+
76
+ export function run(spec, draft) {
77
+ const scopeDir = str(spec?.data?.writing?.dna?.scope_dir);
78
+ if (!scopeDir) return skip("writing.dna.scope_dir is not set");
79
+
80
+ const scopeAbs = resolve(spec?.dir || ".", scopeDir);
81
+ const measure = `run \`hyperspec dna measure ${scopeDir}\``;
82
+ const disk = readScope(scopeAbs, { displayDir: scopeDir });
83
+ if (disk.findings.some((x) => x.id === "writing-dna-goldens-missing" || x.id === "writing-dna-goldens-outside")) {
84
+ return skip(`writing.dna.scope_dir "${scopeDir}": its goldens cannot be read (run \`hyperspec lint\` for details)`);
85
+ }
86
+ const featuresPath = join(scopeAbs, "features.json");
87
+ const stale = featuresStaleness(featuresPath, disk.scope, disk.goldens);
88
+ if (stale === "missing") return skip(`writing.dna.scope_dir "${scopeDir}" has no features.json (or it is not valid JSON); ${measure}`);
89
+ if (stale) return skip(`writing.dna.scope_dir "${scopeDir}"'s features.json is stale: ${stale}; ${measure}`);
90
+
91
+ let scopeFeatures;
92
+ try { scopeFeatures = JSON.parse(readFileSync(featuresPath, "utf8")).features; } catch { scopeFeatures = null; }
93
+ if (!isObj(scopeFeatures)) return skip(`writing.dna.scope_dir "${scopeDir}" has no features.json (or it is not valid JSON); ${measure}`);
94
+
95
+ const masked = maskCode(draft.text);
96
+ const draftFeatures = measureFeatures([masked]);
97
+ const findings = [];
98
+
99
+ const scopeEm = scopeFeatures.rates_per_1000_words?.em_dash;
100
+ const draftEm = draftFeatures.rates_per_1000_words.em_dash;
101
+ const emDashRule = scopeEm === 0 && draftEm > 0;
102
+ if (emDashRule) {
103
+ findings.push({
104
+ station: name,
105
+ id: "station-dna-em-dash",
106
+ severity: "warn",
107
+ line: lineAt(draft.text, masked.indexOf("\u2014")),
108
+ message: `the draft uses em dashes (${fmt(draftEm)} per 1000 words); the scope's goldens use none (0)`,
109
+ fix: "Rewrite each em dash as the punctuation the goldens use instead: a comma, a colon, parentheses or a new sentence.",
110
+ });
111
+ }
112
+
113
+ for (const d of compareFeatures(scopeFeatures, draftFeatures)) {
114
+ if (emDashRule && d.feature === EM_DASH) continue;
115
+ findings.push({
116
+ station: name,
117
+ id: "station-dna-drift",
118
+ severity: "warn",
119
+ message: `${d.feature} is ${fmt(d.draft)} in the draft; the scope's goldens measure ${fmt(d.scope)}, band ${fmt(d.low)} to ${fmt(d.high)}`,
120
+ fix: `Bring ${d.feature} back inside the band, or, if the scope no longer describes this writer, re-measure it with better goldens.`,
121
+ });
122
+ }
123
+
124
+ // Every finding here is a warning, so the station passes whenever it runs.
125
+ return { station: name, status: findings.some((x) => x.severity === "fail") ? "fail" : "pass", findings };
126
+ }
@@ -0,0 +1,115 @@
1
+ // Station "form" (hyperspec 0.6). The first of hyperspec check's deterministic
2
+ // stations: it checks a draft's word count against writing.form.length, and checks that every
3
+ // writing.form.required_parts entry actually shows up in the draft. Pure and deterministic, like
4
+ // every station: (spec, draft) in, a result out, no filesystem access beyond what the caller
5
+ // already read, no model call.
6
+ //
7
+ // Length: writing.form.length.unit is only measured when it is exactly "words" (lint already
8
+ // requires the key to be present; a spec naming an unmeasured unit, e.g. "characters" or
9
+ // "minutes", is not wrong, this build just cannot grade it yet). When the unit is not "words"
10
+ // the whole station skips, findings included, rather than silently passing or half-checking: a
11
+ // length this build cannot read is not evidence the length is fine, and running required_parts
12
+ // alone while staying silent about length would read as a check that covered more than it did.
13
+ //
14
+ // Required parts: a required part is judged present two ways, either one is enough, because
15
+ // required_parts sometimes names a heading-shaped thing ("claim", "evidence", "close") and
16
+ // sometimes names a field a form fills in inline rather than under its own heading (a memo's
17
+ // "To:", an email's "Subject:"). Neither the schema nor the draft says which kind a given part
18
+ // is, so both checks always run for every part, regardless of the form's name:
19
+ // - an ATX heading (up to three spaces of indent, 1 to 6 "#" characters, a space, the text, an
20
+ // optional closing run of "#"s) whose text, trimmed and case-folded, equals the part name; or
21
+ // - a line whose text, trimmed and case-folded, starts with the part name immediately
22
+ // followed by ":".
23
+ // Fenced and inline code are masked first. This is a literal, narrow reading on purpose: it will
24
+ // miss a heading spelled "## The Claim" against a required part "claim", a Setext heading
25
+ // (underlined with === or ---), or one styled "**Claim**". Widening the match is a later
26
+ // station's decision once real drafts show what this narrow reading actually misses.
27
+
28
+ import { wordsOf } from "../dna.mjs";
29
+ import { maskCode } from "./util.mjs";
30
+
31
+ export const name = "form";
32
+
33
+ // An ATX heading as CommonMark reads it: up to three spaces of indent, 1 to 6 "#"s, then a space
34
+ // or tab and the text (or nothing); an optional closing run of "#"s is not part of the text.
35
+ const ATX_HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
36
+ const headingText = (m) => (m[2] ?? "").replace(/(^|[ \t]+)#+[ \t]*$/, "").trim();
37
+
38
+ // A finding id's slug half: lowercase, non [a-z0-9] runs collapsed to one "-", no leading or
39
+ // trailing "-". Falls back to `fallback` when nothing alphanumeric survives (e.g. a required
40
+ // part that is pure punctuation), so an id is never left with a trailing "station-form-required-part-".
41
+ function slug(text, fallback) {
42
+ const s = String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
43
+ return s || fallback;
44
+ }
45
+
46
+ // Whether `part` shows up in the draft, either as a matching ATX heading or as a line starting
47
+ // "<part>:" (both compared trimmed and case-folded).
48
+ function partPresent(lines, part) {
49
+ const needle = part.trim().toLowerCase();
50
+ for (const line of lines) {
51
+ const heading = ATX_HEADING.exec(line);
52
+ if (heading && headingText(heading).toLowerCase() === needle) return true;
53
+ if (line.trim().toLowerCase().startsWith(`${needle}:`)) return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ // run(spec, draft): spec is a loadSpec()-shaped object (spec.data.writing.form is what this
59
+ // station reads); draft is { path, text, lines, sha256 } as src/check.mjs builds it. ctx (a
60
+ // third argument every station receives) is unused here: form shares nothing with the other
61
+ // stations.
62
+ export function run(spec, draft) {
63
+ const form = spec?.data?.writing?.form ?? {};
64
+ const length = form.length && typeof form.length === "object" ? form.length : {};
65
+ // Trimmed and case-folded for the comparison: lint only requires length.unit to be non-
66
+ // placeholder text (writing-fields.mjs's formFields), not literally the lowercase word
67
+ // "words", so a spec author who writes "Words" or "WORDS" still gets the word count checked
68
+ // rather than a silent skip. The reason message on a genuine skip still shows the unit as
69
+ // written, never lowercased, since that is what the spec actually says.
70
+ const rawUnit = typeof length.unit === "string" ? length.unit.trim() : "";
71
+ const unit = rawUnit.toLowerCase();
72
+
73
+ if (unit !== "words") {
74
+ return { station: name, status: "skip", findings: [], reason: `length unit ${rawUnit || "(none)"} is not measured yet` };
75
+ }
76
+
77
+ const findings = [];
78
+ const min = Number(length.min);
79
+ const max = Number(length.max);
80
+ const count = wordsOf(draft.text).length;
81
+ if (!(count >= min && count <= max)) {
82
+ findings.push({
83
+ station: name,
84
+ id: "station-form-length",
85
+ severity: "fail",
86
+ message: `word count ${count} is outside writing.form.length (${min} to ${max} words)`,
87
+ fix: `Trim or expand the draft to ${min}-${max} words; it is currently ${count}.`,
88
+ });
89
+ }
90
+
91
+ // Code is masked first, like every station that reads prose: a heading shown inside a fenced
92
+ // block is an example, not the draft's own part.
93
+ const lines = maskCode(draft.text).split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
94
+ const parts = Array.isArray(form.required_parts) ? form.required_parts.filter((p) => typeof p === "string" && p.trim()) : [];
95
+ const usedIds = new Set();
96
+ for (const part of parts) {
97
+ if (partPresent(lines, part)) continue;
98
+ let id = `station-form-required-part-${slug(part, "part")}`;
99
+ // Two required parts that slug to the same string (e.g. "Close" and "close!") would
100
+ // otherwise collide on one finding id; the second and later ones get a numeric suffix so
101
+ // every missing part still gets its own finding.
102
+ let n = 2;
103
+ while (usedIds.has(id)) { id = `station-form-required-part-${slug(part, "part")}-${n}`; n += 1; }
104
+ usedIds.add(id);
105
+ findings.push({
106
+ station: name,
107
+ id,
108
+ severity: "fail",
109
+ message: `required part "${part}" does not appear as a heading or a "${part}:" line`,
110
+ fix: `Add a heading ("# ${part}") or a line starting "${part}:" for required part "${part}".`,
111
+ });
112
+ }
113
+
114
+ return { station: name, status: findings.length ? "fail" : "pass", findings };
115
+ }
@@ -0,0 +1,27 @@
1
+ // The station registry: every station `hyperspec check` knows about, in run order. Each entry's
2
+ // run is the pure function (spec, draft, ctx) -> { station, status, findings, reason? } that
3
+ // src/check.mjs calls; adding a station is adding one file plus one line here, which is the whole
4
+ // point of the registry existing rather than check.mjs importing each station by name itself.
5
+ //
6
+ // The order: form, terms, claims, quotes, private, dna, links. quotes and private share ctx (util.mjs's
7
+ // markedSegments caches the spec's marked materials there), so a check run reads them once.
8
+
9
+ import * as form from "./form.mjs";
10
+ import * as terms from "./terms.mjs";
11
+ import * as claims from "./claims.mjs";
12
+ import * as quotes from "./quotes.mjs";
13
+ import * as privateStation from "./private.mjs";
14
+ import * as dna from "./dna.mjs";
15
+ import * as links from "./links.mjs";
16
+
17
+ export const STATIONS = Object.freeze([
18
+ { name: form.name, run: form.run },
19
+ { name: terms.name, run: terms.run },
20
+ { name: claims.name, run: claims.run },
21
+ { name: quotes.name, run: quotes.run },
22
+ { name: privateStation.name, run: privateStation.run },
23
+ { name: dna.name, run: dna.run },
24
+ { name: links.name, run: links.run },
25
+ ]);
26
+
27
+ export const STATION_NAMES = Object.freeze(STATIONS.map((s) => s.name));