@supersuit/hyperspec 0.2.0 → 0.3.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.
@@ -0,0 +1,181 @@
1
+ // The writing profile (profile: writing). Ten blocks live under writing:; nine of them are
2
+ // checked by this file (materials, dna, persona, audience, goal, form, spine, sources,
3
+ // characters). "progress" is the tenth word in the design and is not a block at all: it is
4
+ // forbidden, because stored progress goes stale the moment a session dies mid-arc, and this file
5
+ // is the one place that refusal is enforced.
6
+ //
7
+ // This module carries the GENERIC rules, the ones true of every block regardless which one it is:
8
+ // present or openly deferred, carrying a check and a source and an author. Each block's own field
9
+ // rules (a golden's why, a claim's material refs, a character's golden and rejected lines, ...)
10
+ // live in writing-fields.mjs and are dispatched from the loop below, under the same ids and the
11
+ // same nine tests; they do not change the shape here.
12
+
13
+ import { resolve } from "node:path";
14
+ import { BLOCK_FIELD_RULES, characterFields } from "./writing-fields.mjs";
15
+ import { str } from "./placeholder.mjs";
16
+
17
+ const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
18
+ const list = (v) => (Array.isArray(v) ? v : []);
19
+ const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
20
+
21
+ // The closed vocabulary a later version will enforce on every segment of a material file. This
22
+ // version does not read segment files; the export exists so the vocabulary is defined once, here,
23
+ // rather than copied into whatever later reads it.
24
+ export const MATERIAL_LABELS = Object.freeze(["claim", "story", "quote", "stance", "question", "aside", "private"]);
25
+
26
+ // The nine writing blocks, in schema order. "characters" is the one block that is not always
27
+ // required: it is required only when fiction: true, everywhere else in this file and in
28
+ // blockStatus below.
29
+ export const BLOCKS = Object.freeze(["materials", "dna", "persona", "audience", "goal", "form", "spine", "sources", "characters"]);
30
+
31
+ const required = (block, fiction) => block !== "characters" || fiction;
32
+
33
+ // Whether a block's raw value under writing: counts as present at all, before any of its own
34
+ // fields are checked. materials is present when it has at least one item; characters is present
35
+ // when the list has at least one entry; every other block is present when it is an object with at
36
+ // least one field. This is deliberately shallow: it is the bar for "something was written here",
37
+ // not the bar for "this block is correct", which is what checkOwner and the field rules in
38
+ // writing-fields.mjs are for.
39
+ function blockPresent(block, raw) {
40
+ if (block === "characters") return Array.isArray(raw) && raw.length > 0;
41
+ if (block === "materials") return isObj(raw) && Array.isArray(raw.items) && raw.items.length > 0;
42
+ return isObj(raw) && Object.keys(raw).length > 0;
43
+ }
44
+
45
+ // A block is deferred when a decision with id "writing-<block>" exists in state open, or in
46
+ // state delegated WITH a rule. Open defers it to a question only a human can answer, so the spec
47
+ // is blocked on that decision the same way any open decision blocks a spec (score.mjs already
48
+ // does this; no separate mechanism is needed here); it exempts the block on state alone, since
49
+ // whether it also carries a well-formed question is the ordinary decision rule's job (test 1),
50
+ // not this one's. Delegated defers it to a standing rule the agent follows instead of writing the
51
+ // block out, and "delegated (with a rule)" is a precondition on the exemption, not just a
52
+ // description of delegated's normal shape: a delegated decision with no rule has deferred to
53
+ // nothing, so it does not stand in for the block, and writing-<block>-missing still fires
54
+ // alongside the decision's own delegated-rule finding (test 1). Either way, when a deferral does
55
+ // apply, the block's absence from writing: is not itself a finding.
56
+ function deferredBy(decisions, block) {
57
+ const d = decisions.find((x) => str(x?.id) === `writing-${block}`);
58
+ if (!d) return null;
59
+ const st = str(d.state);
60
+ if (st === "open") return st;
61
+ if (st === "delegated" && str(d.rule)) return st;
62
+ return null;
63
+ }
64
+
65
+ // check: (station: or rubric:), source: and author: on one owner object (a block, or one
66
+ // character entry). idPrefix becomes the finding id's prefix (kept unique per owner so
67
+ // blockStatus below can attribute a failure to the right block); label is the human-readable name
68
+ // used in every message.
69
+ function checkOwner(idPrefix, label, owner) {
70
+ const out = [];
71
+ const check = isObj(owner?.check) ? owner.check : {};
72
+ if (!str(check.station) && !str(check.rubric)) {
73
+ out.push(f(3, `${idPrefix}-check`, "fail", `${label} names no check`, "Add check: with station: <a deterministic check> or rubric: <what a grader applies>."));
74
+ }
75
+ if (!str(owner?.source)) {
76
+ out.push(f(4, `${idPrefix}-source`, "fail", `${label} does not say where it came from`, `Add source: to ${label}.`));
77
+ }
78
+ if (!str(owner?.author)) {
79
+ out.push(f(4, `${idPrefix}-author`, "fail", `${label} does not say who wrote it`, `Add author: to ${label}.`));
80
+ }
81
+ return out;
82
+ }
83
+
84
+ export function lintWriting(spec) {
85
+ const d = spec.data || {};
86
+ const out = [];
87
+ const decisions = list(d.decisions);
88
+ const writing = isObj(d.writing) ? d.writing : {};
89
+ // Paths inside writing: resolve the same way examples: does elsewhere in this linter: relative
90
+ // to the spec file, never to process.cwd().
91
+ const here = (p) => resolve(spec.dir || ".", p);
92
+ // The frontmatter reader (parseSkillFile) treats every scalar as a string, so "fiction: true"
93
+ // is read back as the string "true", never the boolean; comparing through str() is the same
94
+ // discipline every closed-set field in this file and in rules.mjs already follows.
95
+ const fiction = str(d.fiction) === "true";
96
+
97
+ // fiction is a closed set: absent means false; present, it must be exactly true or false. A
98
+ // typo here would otherwise drop the whole characters block from a story without a word.
99
+ if (d.fiction !== undefined) {
100
+ const raw = typeof d.fiction === "string" ? d.fiction.trim() : "";
101
+ if (str(d.fiction) !== "true" && str(d.fiction) !== "false") {
102
+ out.push(f(1, "writing-fiction", "fail", `fiction is "${raw || "(none)"}", not true or false`, "Set fiction: true or fiction: false, or remove it (absent means false)."));
103
+ }
104
+ }
105
+
106
+ // Progress is never stored, under any key spelled writing.progress: test 7, stale state.
107
+ if ("progress" in writing) {
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
+ }
110
+
111
+ for (const block of BLOCKS) {
112
+ const raw = writing[block];
113
+ if (!blockPresent(block, raw)) {
114
+ // Missing is only ever a finding for a REQUIRED block; an unrequired, unwritten block (only
115
+ // characters, only with fiction: false) is simply absent, nothing to check and nothing to
116
+ // defer. A required block's absence fails test 1, unless deferred.
117
+ if (!required(block, fiction)) continue;
118
+ if (deferredBy(decisions, block)) continue;
119
+ out.push(f(1, `writing-${block}-missing`, "fail", `writing.${block} is missing`, `Add writing.${block}, or defer it with a decision id "writing-${block}" in state open (a question) or delegated (a rule).`));
120
+ continue;
121
+ }
122
+ // Present, so its content is checked whether or not the block was required: an author who
123
+ // wrote a characters: list with fiction: false still owes it a real check/source/author on
124
+ // every entry, the same as any other present block. Ownership (check/source/author) is
125
+ // generic, from this file; a block's own field rules (the schema inside it) live in
126
+ // writing-fields.mjs and are applied right alongside it, under the same id prefix, so a
127
+ // block's completeness (blockStatus below) reflects both without either file needing to know
128
+ // about the other's findings.
129
+ if (block === "characters") {
130
+ // A character id names one person: persona.identity: character:<id> and every later check
131
+ // resolve through it, so a repeated id is reported once per id, like a repeated material.
132
+ const seen = new Set();
133
+ const reported = new Set();
134
+ raw.forEach((c) => {
135
+ const id = str(c?.id);
136
+ if (!id) return;
137
+ if (seen.has(id) && !reported.has(id)) {
138
+ out.push(f(1, "writing-characters-id", "fail", `character id "${id}" is used twice`, "Ids must be unique across writing.characters; rename one."));
139
+ reported.add(id);
140
+ }
141
+ seen.add(id);
142
+ });
143
+ raw.forEach((c, i) => {
144
+ const cid = str(c?.id) || `#${i + 1}`;
145
+ const idPrefix = `writing-characters-${i}`;
146
+ out.push(...checkOwner(idPrefix, `character "${cid}"`, c));
147
+ out.push(...characterFields(c, here, idPrefix));
148
+ });
149
+ } else {
150
+ out.push(...checkOwner(`writing-${block}`, `writing.${block}`, raw));
151
+ const fieldRule = BLOCK_FIELD_RULES[block];
152
+ if (fieldRule) out.push(...fieldRule(raw, d, here, `writing-${block}`));
153
+ }
154
+ }
155
+
156
+ return out;
157
+ }
158
+
159
+ // Derives the "writing: k/9 blocks complete" count from the same data and findings lintWriting
160
+ // just produced, so the two can never disagree. A block counts complete when it has no
161
+ // fail-severity finding attributed to it AND (it is present, or it is simply not required, only
162
+ // characters with fiction: false). A block that is present but unrequired is still held to the
163
+ // same bar as any other present block: writing it with broken content does not count as complete
164
+ // just because nothing required it to be written at all.
165
+ export function blockStatus(data, findings) {
166
+ const d = data || {};
167
+ const writing = isObj(d.writing) ? d.writing : {};
168
+ const fiction = str(d.fiction) === "true";
169
+ const failedIds = new Set((Array.isArray(findings) ? findings : []).filter((x) => x.severity === "fail").map((x) => x.id));
170
+ let complete = 0;
171
+ for (const block of BLOCKS) {
172
+ const present = blockPresent(block, writing[block]);
173
+ if (!present) {
174
+ if (!required(block, fiction)) complete += 1;
175
+ continue;
176
+ }
177
+ const broken = [...failedIds].some((id) => typeof id === "string" && id.startsWith(`writing-${block}-`));
178
+ if (!broken) complete += 1;
179
+ }
180
+ return { complete, total: BLOCKS.length };
181
+ }