@supersuit/hyperspec 0.3.0 → 0.5.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 (35) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +46 -1
  3. package/SPEC.md +5 -3
  4. package/WRITING.md +475 -40
  5. package/bin/hyperspec.mjs +157 -3
  6. package/examples/writing/dna/essay-new-managers-teach/features.json +56 -0
  7. package/examples/writing/dna/essay-new-managers-teach/goldens/README.md +14 -0
  8. package/examples/writing/dna/essay-new-managers-teach/goldens/close.md +9 -0
  9. package/examples/writing/dna/essay-new-managers-teach/goldens/opening.md +9 -0
  10. package/examples/writing/dna/essay-new-managers-teach/goldens/status.md +10 -0
  11. package/examples/writing/dna/essay-new-managers-teach/scope.md +11 -0
  12. package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +10 -0
  13. package/examples/writing/essay/materials/team-survey.md.segments.jsonl +6 -0
  14. package/examples/writing/essay/materials/voice-memo.md.segments.jsonl +7 -0
  15. package/examples/writing/essay.hyperspec.md +24 -13
  16. package/examples/writing/story/materials/bakery-visit.md +1 -0
  17. package/examples/writing/story/materials/bakery-visit.md.segments.jsonl +13 -0
  18. package/examples/writing/story/materials/notes.md +2 -0
  19. package/examples/writing/story/materials/notes.md.segments.jsonl +7 -0
  20. package/examples/writing/story/materials/scene-list.md.segments.jsonl +14 -0
  21. package/examples/writing/story.hyperspec.md +8 -5
  22. package/package.json +2 -1
  23. package/src/blobs.mjs +1 -1
  24. package/src/compare.mjs +6 -6
  25. package/src/dna.mjs +471 -0
  26. package/src/fsutil.mjs +1 -1
  27. package/src/labels.mjs +6 -0
  28. package/src/reproduce.mjs +5 -5
  29. package/src/segments.mjs +407 -0
  30. package/src/writing-exports.mjs +11 -0
  31. package/src/writing-fields.mjs +279 -6
  32. package/src/writing-template.mjs +16 -1
  33. package/src/writing.mjs +4 -4
  34. package/examples/writing/essay/goldens/close.md +0 -2
  35. package/examples/writing/essay/goldens/opening.md +0 -2
@@ -18,13 +18,16 @@
18
18
  // (test 1), because dialogue cannot be specified without them. relationships stays optional: a
19
19
  // character may genuinely relate to no one yet, and nothing gives it a closed set or a count.
20
20
 
21
- import { statSync } from "node:fs";
21
+ import { readFileSync, realpathSync, statSync } from "node:fs";
22
+ import { basename, dirname, join, sep } from "node:path";
22
23
  import { str } from "./placeholder.mjs";
24
+ import { readSegments } from "./segments.mjs";
25
+ import { readScope, isGoldenFileName, measureFeatures, featuresText, DNA_FORMAT } from "./dna.mjs";
23
26
 
24
27
  const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
25
28
  const list = (v) => (Array.isArray(v) ? v : []);
26
29
  const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
27
- // "file", "other" (a directory or a device), or null when nothing is there at all — the same
30
+ // "file", "other" (a directory or a device), or null when nothing is there at all. This is the same
28
31
  // three-way classification the core examples rule uses (src/rules.mjs's kind()), so a real
29
32
  // directory is reported as "is not a file" rather than the misleading "does not exist".
30
33
  const pathKind = (here, p) => { try { return statSync(here(p)).isFile() ? "file" : "other"; } catch { return null; } };
@@ -72,22 +75,207 @@ function materialsFields(raw, d, here, idPrefix) {
72
75
  const p = str(it?.path);
73
76
  if (!p) out.push(f(1, `${idPrefix}-item-${i}-path`, "fail", `material ${tag} has no path`, "Add path: to the material."));
74
77
  else out.push(...pathFindings(here, p, `${idPrefix}-item-${i}-path`, "material", "Fix the path, or add the material file."));
78
+
79
+ // Marking is required from 0.4 on. A material item with no segments: field is not
80
+ // marked at all (the design puts marking before specifying), so it fails on its own, distinct
81
+ // from the segments file existing but being broken (readSegments' own findings below). The
82
+ // material's text-dependent checks (verbatim, coverage, overlap, staleness) only run when the
83
+ // path itself already resolved to a real file, so a broken path is never reported twice: once
84
+ // here for the path field and again for the material readSegments could not read.
85
+ const segPath = str(it?.segments);
86
+ if (!segPath) {
87
+ out.push(f(1, "writing-materials-unmarked", "fail",
88
+ `material ${tag} is not marked (no segments field)`,
89
+ "Run `hyperspec segments init <material> --id <id>`, then add segments: to the material item."));
90
+ } else {
91
+ const { findings: segFindings } = readSegments(here(segPath), { materialPath: materialFilePath(it, here), materialId: id || undefined, ...shownPaths(it, segPath) });
92
+ out.push(...segFindings);
93
+ }
75
94
  });
76
95
  return out;
77
96
  }
78
97
 
98
+ // The material file readSegments checks segment text against, or undefined when the item's own
99
+ // path is missing or is not a file. That broken path is already reported by pathFindings (test 6);
100
+ // passing it on would make readSegments report the same root cause a second time, under test 1.
101
+ // materialsFields and resolveMaterialSegments both resolve through here, so they cannot disagree.
102
+ function materialFilePath(item, here) {
103
+ const p = str(item?.path);
104
+ return p && pathKind(here, p) === "file" ? here(p) : undefined;
105
+ }
106
+
107
+ // The paths readSegments' messages print: exactly as the spec wrote them, the way every other path
108
+ // finding in the linter reads, never resolved against the spec's folder. A finding pasted into a
109
+ // public issue then names no one's home folder, and --json is the same on every machine.
110
+ function shownPaths(item, segPath) {
111
+ return { displayPath: segPath, materialDisplayPath: str(item?.path) || undefined };
112
+ }
113
+
114
+ // Resolves ONE material item's segments (for spine ref resolution below). Never pushes
115
+ // readSegments' own findings: those are already reported once, by materialsFields, under the
116
+ // materials block; this is read-only lookup. When nothing resolves, `why` says which of the three
117
+ // causes it was, because each needs a different fix: "unmarked" (no segments: field), "unreadable"
118
+ // (the segments file does not exist or cannot be read) or "empty" (read, but no segment lines).
119
+ function resolveMaterialSegments(item, here) {
120
+ const segPath = str(item?.segments);
121
+ if (!segPath) return { segments: [], loaded: false, why: "unmarked" };
122
+ const { segments, findings } = readSegments(here(segPath), { materialPath: materialFilePath(item, here), materialId: str(item?.id) || undefined, ...shownPaths(item, segPath) });
123
+ if (segments.length > 0) return { segments, loaded: true };
124
+ const unreadable = findings.some((x) => x.id === "writing-materials-segments-missing");
125
+ return { segments, loaded: false, why: unreadable ? "unreadable" : "empty" };
126
+ }
127
+
128
+ const UNRESOLVABLE_BECAUSE = {
129
+ unmarked: "it is not marked (no segments field)",
130
+ unreadable: "its segments file could not be read",
131
+ empty: "its segments file has no segments",
132
+ };
133
+
79
134
  // ---------------------------------------------------------------- 2. dna ----------------------
80
135
 
136
+ // writing.dna.scope_dir (0.5, optional): the path (relative to the spec, like every other path in
137
+ // this file) to a scoped-DNA folder built by `hyperspec dna init`/`dna measure` (src/dna.mjs).
138
+ // The KEY being absent means none of the checks below run: 0.4 behavior, unchanged. A key that IS
139
+ // present but placeholder-ish (TODO, tbd, an empty string) fails on its own (test 1,
140
+ // writing-dna-scope-dir, naming the value) before any of this runs, since a real value is what
141
+ // every check below needs. Present with a real value, four things must all hold:
142
+ // - scope.md's writer/form/audience/purpose agree with dna.writer/dna.scope (test 1);
143
+ // - every dna.goldens[].path is one of the goldens readScope actually reads: resolved through
144
+ // any symlink, a golden-named file directly in the scope's REAL goldens/ folder (test 5,
145
+ // writing-dna-golden-leak, naming the golden and the scope). Anything else feeds the spec a
146
+ // passage that is never checked or measured under this scope. A goldens/ folder that itself
147
+ // resolves outside the scope is readScope's own finding (writing-dna-goldens-outside), which
148
+ // stands in for the per-golden findings it would otherwise cause;
149
+ // - every golden IN the scope passes its own field checks, exactly readScope's findings, reused
150
+ // rather than re-derived, with displayDir set to the scope_dir string the spec wrote (never a
151
+ // resolved filesystem path, so a finding here never names this machine's folders);
152
+ // - <scope_dir>/features.json is current: byte for byte what `dna measure` would write now
153
+ // (test 6), with the stale finding naming what differs.
154
+ function isInsideDir(parentAbs, childAbs) {
155
+ return childAbs === parentAbs || childAbs.startsWith(parentAbs + sep);
156
+ }
157
+
158
+ const realOrNull = (p) => { try { return realpathSync(p); } catch { return null; } };
159
+
160
+ // Why a listed golden is not one of the scope's goldens, as the words of the leak finding, or
161
+ // null when it is one. lexicalGoldens is <scope_dir>/goldens as written; realGoldens is where
162
+ // that folder really is.
163
+ function leakReason(lexicalGoldens, realGoldens, goldenAbs) {
164
+ const real = realOrNull(goldenAbs);
165
+ if (real && realGoldens && dirname(real) === realGoldens && isGoldenFileName(basename(real))) return null;
166
+ if (!isInsideDir(lexicalGoldens, goldenAbs)) return "outside";
167
+ if (!real || !realGoldens || !isInsideDir(realGoldens, real)) return "symlink";
168
+ return "not-a-golden";
169
+ }
170
+
171
+ function leakFinding(idPrefix, reason, p, i, scopeDirRaw) {
172
+ const where = `${scopeDirRaw}/goldens/`;
173
+ if (reason === "outside") {
174
+ return f(5, `${idPrefix}-golden-leak`, "fail",
175
+ `golden "${p}" feeds only work that shares its scope; it does not live under ${where}`,
176
+ `Move ${p} into ${where}, or point dna.goldens[${i + 1}].path at a golden already there.`);
177
+ }
178
+ if (reason === "symlink") {
179
+ return f(5, `${idPrefix}-golden-leak`, "fail",
180
+ `golden "${p}" is a symlink that resolves outside ${where} (or sits in a folder that does), so it feeds this scope a passage from somewhere else`,
181
+ `Replace the link at ${p} with the passage itself, as a file in ${where} with why, approved_by and source.`);
182
+ }
183
+ return f(5, `${idPrefix}-golden-leak`, "fail",
184
+ `golden "${p}" is not one of the scope's goldens: only .md files directly in ${where}, other than README.md, are read, checked and measured`,
185
+ `Put the passage in its own .md file directly in ${where}, with why, approved_by and source, and point dna.goldens[${i + 1}].path at it.`);
186
+ }
187
+
188
+ // One field of the spec's own dna claim against the same field read off scope.md. Silent when
189
+ // either side is empty: an empty spec-side value already fails its own presence check above (e.g.
190
+ // writing-dna-writer), and an empty disk-side value already fails as one of readScope's own
191
+ // findings (e.g. writing-dna-scope-file-writer); comparing two things when one is already known-broken
192
+ // would just be a second name for the same defect, not a second defect.
193
+ function scopeMismatch(out, idPrefix, scopeDirRaw, label, idSuffix, specVal, diskVal) {
194
+ const a = str(specVal);
195
+ const b = str(diskVal);
196
+ if (!a || !b || a.trim().toLowerCase() === b.trim().toLowerCase()) return;
197
+ out.push(f(1, `${idPrefix}-scope-mismatch-${idSuffix}`, "fail",
198
+ `writing.dna.scope_dir "${scopeDirRaw}": ${label} "${a}" does not match ${scopeDirRaw}/scope.md's ${label} "${b}"`,
199
+ `Make writing.dna's ${label} and ${scopeDirRaw}/scope.md's ${label} agree; one of them is wrong.`));
200
+ }
201
+
202
+ // Whether <scope>/features.json is what `dna measure` would write now. Returns "missing" (absent
203
+ // or not JSON), null (current), or the words naming what differs: goldens added, removed or
204
+ // changed by hash; scope fields that differ from scope.md; a dna format this linter does not
205
+ // know; features that differ from a fresh measurement (only named when the goldens themselves
206
+ // are unchanged, since changed goldens explain every number); and, when nothing more specific
207
+ // differs, bytes dna measure would not have written.
208
+ function featuresStaleness(featuresPath, diskScope, diskGoldens) {
209
+ let text;
210
+ let recorded;
211
+ try {
212
+ text = readFileSync(featuresPath, "utf8");
213
+ recorded = JSON.parse(text);
214
+ } catch {
215
+ return "missing";
216
+ }
217
+ if (!recorded || typeof recorded !== "object" || Array.isArray(recorded)) return "missing";
218
+ const parts = [];
219
+ const recordedGoldens = new Map(list(recorded.goldens).map((g) => [str(g?.path), str(g?.sha256)]));
220
+ const current = new Map(diskGoldens.map((g) => [g.path, g.sha256]));
221
+ const added = [...current.keys()].filter((p) => !recordedGoldens.has(p)).sort();
222
+ const removed = [...recordedGoldens.keys()].filter((p) => !current.has(p)).sort();
223
+ const changed = [...current.keys()].filter((p) => recordedGoldens.has(p) && recordedGoldens.get(p) !== current.get(p)).sort();
224
+ if (added.length) parts.push(`added ${added.join(", ")}`);
225
+ if (removed.length) parts.push(`removed ${removed.join(", ")}`);
226
+ if (changed.length) parts.push(`changed ${changed.join(", ")}`);
227
+ if (recorded.dna !== DNA_FORMAT) parts.push(`dna version ${JSON.stringify(recorded.dna ?? null)} is not the "${DNA_FORMAT}" this linter knows`);
228
+ if (!diskScope) return parts.length ? parts.join("; ") : null;
229
+
230
+ const recordedScope = isObj(recorded.scope) ? recorded.scope : {};
231
+ const scopeFields = ["writer", "form", "audience", "purpose"].filter((k) => recordedScope[k] !== diskScope[k]);
232
+ if (scopeFields.length) parts.push(`scope changed: ${scopeFields.join(", ")}`);
233
+ const features = measureFeatures(diskGoldens.map((g) => g.text));
234
+ if (!added.length && !removed.length && !changed.length) {
235
+ const recordedFeatures = isObj(recorded.features) ? recorded.features : {};
236
+ const keys = [...new Set([...Object.keys(features), ...Object.keys(recordedFeatures)])];
237
+ const differ = keys.filter((k) => JSON.stringify(features[k]) !== JSON.stringify(recordedFeatures[k]));
238
+ if (differ.length) parts.push(`features differ from a fresh measurement: ${differ.join(", ")}`);
239
+ }
240
+ if (!parts.length && text !== featuresText({ scope: diskScope, goldens: diskGoldens, features }).text) {
241
+ parts.push("the file is not byte for byte what `hyperspec dna measure` writes");
242
+ }
243
+ return parts.length ? parts.join("; ") : null;
244
+ }
245
+
81
246
  function dnaFields(raw, d, here, idPrefix) {
82
247
  const out = [];
83
248
  if (!str(raw.writer)) out.push(f(1, `${idPrefix}-writer`, "fail", "writing.dna has no writer", "Add writer:."));
84
249
  const scope = isObj(raw.scope) ? raw.scope : {};
250
+ // scope-<field> is the spec's own writing.dna.scope, the id 0.4.0 shipped; scope-file-<field>
251
+ // (src/dna.mjs) is a scope folder's scope.md.
85
252
  if (!str(scope.form)) out.push(f(1, `${idPrefix}-scope-form`, "fail", "writing.dna.scope has no form", "Add scope.form:."));
86
253
  if (!str(scope.audience)) out.push(f(1, `${idPrefix}-scope-audience`, "fail", "writing.dna.scope has no audience", "Add scope.audience:."));
87
254
  if (!str(scope.purpose)) out.push(f(1, `${idPrefix}-scope-purpose`, "fail", "writing.dna.scope has no purpose", "Add scope.purpose:."));
88
255
  const rulesPath = str(raw.rules);
89
256
  if (!rulesPath) out.push(f(1, `${idPrefix}-rules`, "fail", "writing.dna has no rules", "Add rules: the path to the always-on writing style."));
90
257
  else out.push(...pathFindings(here, rulesPath, `${idPrefix}-rules`, "dna.rules", "Fix the path, or add the file."));
258
+
259
+ // scope_dir is optional, so its KEY being absent from writing.dna is never a finding (the
260
+ // whole scope_dir section below simply does not run). But a key that IS present with a
261
+ // placeholder-ish value (TODO, tbd, an empty string, ...) is a different situation: the operator
262
+ // wrote something and str() silently reads it as "not there", which would otherwise make a
263
+ // half-filled skeleton lint clean by accident. That gets its own finding, naming the value, and
264
+ // is why this check reads raw.scope_dir directly rather than through scopeDirRaw.
265
+ if (raw.scope_dir !== undefined && !str(raw.scope_dir)) {
266
+ out.push(f(1, `${idPrefix}-scope-dir`, "fail",
267
+ `writing.dna.scope_dir "${raw.scope_dir}" looks like a placeholder`,
268
+ "Point scope_dir: at a real scope folder (built with hyperspec dna init), or remove the field entirely; it is optional."));
269
+ }
270
+ const scopeDirRaw = str(raw.scope_dir);
271
+ const scopeDirAbs = scopeDirRaw ? here(scopeDirRaw) : null;
272
+ const disk = scopeDirRaw ? readScope(scopeDirAbs, { displayDir: scopeDirRaw }) : null;
273
+ const diskIds = new Set((disk?.findings ?? []).map((x) => x.id));
274
+ const goldensOutside = diskIds.has("writing-dna-goldens-outside");
275
+ const lexicalGoldens = scopeDirRaw ? here(join(scopeDirRaw, "goldens")) : null;
276
+ const realScope = scopeDirRaw ? realOrNull(scopeDirAbs) : null;
277
+ const realGoldens = realScope ? join(realScope, "goldens") : null;
278
+
91
279
  const goldens = list(raw.goldens);
92
280
  if (!goldens.length) out.push(f(1, `${idPrefix}-goldens`, "fail", "writing.dna has no goldens", "Add at least one golden under dna.goldens."));
93
281
  goldens.forEach((g, i) => {
@@ -95,7 +283,44 @@ function dnaFields(raw, d, here, idPrefix) {
95
283
  if (!p) out.push(f(1, `${idPrefix}-golden-${i}-path`, "fail", `dna.goldens[${i + 1}] has no path`, "Add path: to the golden."));
96
284
  else out.push(...pathFindings(here, p, `${idPrefix}-golden-${i}`, "golden", "Fix the path, or add the golden file."));
97
285
  if (!str(g?.why)) out.push(f(6, `${idPrefix}-golden-${i}-why`, "fail", `golden "${p || `#${i + 1}`}" has no why`, "Add why: what it shows that an adjective could not."));
286
+ // Checked only once the path resolves to a real file: a missing or non-file path is already
287
+ // reported above under test 6, and is not also a leak.
288
+ if (scopeDirRaw && p && pathKind(here, p) === "file") {
289
+ const reason = leakReason(lexicalGoldens, realGoldens, here(p));
290
+ // A goldens/ folder that resolves outside the scope already has its own finding, which
291
+ // names the cause for every golden listed through it.
292
+ const coveredByFolder = goldensOutside && isInsideDir(lexicalGoldens, here(p));
293
+ if (reason && !coveredByFolder) out.push(leakFinding(idPrefix, reason, p, i, scopeDirRaw));
294
+ }
98
295
  });
296
+
297
+ if (scopeDirRaw) {
298
+ const { scope: diskScope, goldens: diskGoldens, findings: diskFindings } = disk;
299
+ out.push(...diskFindings);
300
+
301
+ if (diskScope) {
302
+ scopeMismatch(out, idPrefix, scopeDirRaw, "writer", "writer", raw.writer, diskScope.writer);
303
+ scopeMismatch(out, idPrefix, scopeDirRaw, "form", "form", scope.form, diskScope.form);
304
+ scopeMismatch(out, idPrefix, scopeDirRaw, "audience", "audience", scope.audience, diskScope.audience);
305
+ scopeMismatch(out, idPrefix, scopeDirRaw, "purpose", "purpose", scope.purpose, diskScope.purpose);
306
+ }
307
+
308
+ // With the goldens folder unreadable or somewhere else, there is nothing to compare
309
+ // features.json against; that folder's own finding says what to fix first.
310
+ if (!goldensOutside && !diskIds.has("writing-dna-goldens-missing")) {
311
+ const stale = featuresStaleness(join(scopeDirAbs, "features.json"), diskScope, diskGoldens);
312
+ if (stale === "missing") {
313
+ out.push(f(6, `${idPrefix}-features-missing`, "fail",
314
+ `writing.dna.scope_dir "${scopeDirRaw}" has no features.json (or it is not valid JSON)`,
315
+ `Run \`hyperspec dna measure ${scopeDirRaw}\`.`));
316
+ } else if (stale) {
317
+ out.push(f(6, `${idPrefix}-features-stale`, "fail",
318
+ `writing.dna.scope_dir "${scopeDirRaw}"'s features.json is stale: ${stale}`,
319
+ `Run \`hyperspec dna measure ${scopeDirRaw}\` again.`));
320
+ }
321
+ }
322
+ }
323
+
99
324
  return out;
100
325
  }
101
326
 
@@ -222,7 +447,19 @@ function spineFields(raw, d, here, idPrefix) {
222
447
  distinct += 1;
223
448
  });
224
449
  if (distinct < 3 || distinct > 7) out.push(f(1, `${idPrefix}-claims-count`, "fail", `writing.spine has ${distinct} distinct claims, outside 3 to 7`, "List 3 to 7 claims, each with its own id, under spine.claims."));
225
- const materialIds = new Set(list(d.writing?.materials?.items).map((m) => str(m?.id)).filter(Boolean));
450
+ const items = list(d.writing?.materials?.items);
451
+ const itemsById = new Map(items.map((m) => [str(m?.id), m]).filter(([id]) => id));
452
+ const materialIds = new Set(itemsById.keys());
453
+ // A material whose segments cannot be resolved at all (no segments: field, a segments file that
454
+ // cannot be read, or one with no segment lines) makes every #segment ref against it equally
455
+ // unresolvable. Report that once per material, not once per ref: two claims both pointing at
456
+ // "m1#s1" and "m1#s2" when m1 is unmarked are the same underlying problem, not two.
457
+ const segmentsCache = new Map();
458
+ const segmentsFor = (mid) => {
459
+ if (!segmentsCache.has(mid)) segmentsCache.set(mid, resolveMaterialSegments(itemsById.get(mid), here));
460
+ return segmentsCache.get(mid);
461
+ };
462
+ const reportedUnresolvable = new Set();
226
463
  claims.forEach((c, i) => {
227
464
  const cid = str(c?.id) || `#${i + 1}`;
228
465
  if (!str(c?.id)) out.push(f(1, `${idPrefix}-claim-${i}-id`, "fail", `spine claim ${cid} has no id`, "Give it a short id, e.g. c1."));
@@ -232,8 +469,44 @@ function spineFields(raw, d, here, idPrefix) {
232
469
  out.push(f(4, `${idPrefix}-claim-${i}-materials`, "fail", `spine claim "${cid}" has no materials`, "Point materials: at one or more material ids."));
233
470
  } else {
234
471
  refs.forEach((ref) => {
235
- const mid = ref.split("#")[0];
236
- if (!materialIds.has(mid)) out.push(f(4, `${idPrefix}-claim-${i}-materials-unknown`, "fail", `spine claim "${cid}" points at material "${ref}", which is not in writing.materials.items`, "Point materials: at an id that exists in writing.materials.items."));
472
+ const hashIdx = ref.indexOf("#");
473
+ const mid = hashIdx === -1 ? ref : ref.slice(0, hashIdx);
474
+ const segId = hashIdx === -1 ? "" : ref.slice(hashIdx + 1);
475
+ if (!materialIds.has(mid)) {
476
+ out.push(f(4, `${idPrefix}-claim-${i}-materials-unknown`, "fail", `spine claim "${cid}" points at material "${ref}", which is not in writing.materials.items`, "Point materials: at an id that exists in writing.materials.items."));
477
+ return;
478
+ }
479
+ // A bare material id (no "#") stays valid on its own; only a ref naming a specific segment
480
+ // needs resolving against that material's segments file. "m1#" names an empty segment id,
481
+ // which is not a bare ref, so it goes on to fail as an unknown segment.
482
+ if (hashIdx === -1) return;
483
+ const { segments, loaded, why } = segmentsFor(mid);
484
+ if (!loaded) {
485
+ if (!reportedUnresolvable.has(mid)) {
486
+ out.push(f(4, `${idPrefix}-materials-segments-unresolvable-${mid}`, "fail",
487
+ `spine claims point at material "${mid}"'s segments, but ${UNRESOLVABLE_BECAUSE[why]}`,
488
+ "Run `hyperspec segments init` on the material, label every segment, then re-check the spine refs."));
489
+ reportedUnresolvable.add(mid);
490
+ }
491
+ return;
492
+ }
493
+ const seg = segId ? segments.find((s) => str(s?.id) === segId) : undefined;
494
+ if (!seg) {
495
+ out.push(f(4, `${idPrefix}-claim-${i}-materials-segment-unknown`, "fail",
496
+ `spine claim "${cid}" points at material "${ref}", which is not a segment in "${mid}"'s segments file`,
497
+ "Point materials: at a segment id that exists in the material's segments file, or drop the #segment suffix to reference the whole material."));
498
+ return;
499
+ }
500
+ const label = typeof seg.label === "string" ? seg.label : "";
501
+ if (label === "private") {
502
+ out.push(f(5, `${idPrefix}-claim-${i}-materials-segment-private`, "fail",
503
+ `spine claim "${cid}" points at material "${ref}", which is labeled private (private is never used)`,
504
+ "Point materials: at a different segment, or drop this ref."));
505
+ } else if (label === "question") {
506
+ out.push(f(5, `${idPrefix}-claim-${i}-materials-segment-question`, "fail",
507
+ `spine claim "${cid}" points at material "${ref}", which is labeled question (a question is never an assertion)`,
508
+ "Point materials: at a different segment, or drop this ref."));
509
+ }
237
510
  });
238
511
  }
239
512
  });
@@ -266,7 +539,7 @@ function characterFields(c, here, idPrefix) {
266
539
  if (!str(c?.id)) out.push(f(1, `${idPrefix}-id`, "fail", "character has no id", "Give it a short id."));
267
540
 
268
541
  // Raw array length, matching dnaFields' goldens.length check: a present-but-malformed entry
269
- // (missing by or knows) must not ALSO trigger "has no knowledge" — that's only true when the
542
+ // (missing by or knows) must not ALSO trigger "has no knowledge"; that is only true when the
270
543
  // list is literally empty.
271
544
  const knowledge = list(c?.knowledge);
272
545
  if (!knowledge.length) out.push(f(1, `${idPrefix}-knowledge`, "fail", `character "${tag}" has no knowledge`, "Add at least one { by, knows } entry under knowledge."));
@@ -14,6 +14,19 @@
14
14
  // block. Without that rule the character block, whose checks are all presence checks, would lint
15
15
  // clean the moment init wrote it.
16
16
  //
17
+ // Two values are not placeholders. The material item's segments: names the file `hyperspec
18
+ // segments init materials/TODO.md` would write, so it follows the path placeholder beside it and
19
+ // fails as a segments file that does not exist yet (every material must be marked). And the
20
+ // materials check.station is the marking station itself, since that check is the same for every
21
+ // writing spec: the linter enforces it, and there is nothing for the operator to decide there.
22
+ //
23
+ // dna.scope_dir is the one optional field shown, and it is a real `scope_dir: TODO` like every
24
+ // other placeholder here. scope_dir is optional, so str() blanking a bare TODO would read as
25
+ // "absent", and a spec filled in everywhere else would pass with the placeholder still sitting
26
+ // there. Its own check closes that hole (writing-dna-scope-dir, src/writing-fields.mjs): a
27
+ // scope_dir key that is PRESENT with a placeholder-ish value fails on its own, the same as every
28
+ // other field here, so the skeleton can show it the same way as every other field.
29
+ //
17
30
  // init with no --profile never imports or calls this file: template.mjs's own template() is
18
31
  // untouched, so a bare init is still byte-for-byte what it always was.
19
32
  import { scalar } from "./template.mjs";
@@ -93,16 +106,18 @@ writing:
93
106
  items:
94
107
  - id: m1
95
108
  path: materials/TODO.md
109
+ segments: materials/TODO.md.segments.jsonl
96
110
  produced_by: TODO
97
111
  captured: TODO
98
112
  how: TODO
99
113
  trust: TODO
100
114
  check:
101
- station: TODO
115
+ station: every segment of every material carries a label from the closed set, matches its source verbatim, and the markings are current
102
116
  source: TODO
103
117
  author: TODO
104
118
  dna:
105
119
  writer: TODO
120
+ scope_dir: TODO
106
121
  scope:
107
122
  form: TODO
108
123
  audience: TODO
package/src/writing.mjs CHANGED
@@ -18,10 +18,10 @@ const f = (test, id, severity, message, fix) => ({ test, id, severity, message,
18
18
  const list = (v) => (Array.isArray(v) ? v : []);
19
19
  const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
20
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"]);
21
+ // The closed vocabulary every segment of a material is labeled from. Defined once, in the leaf
22
+ // module src/labels.mjs (see there for why it is not defined here), and re-exported so existing
23
+ // imports from this file keep working.
24
+ export { MATERIAL_LABELS } from "./labels.mjs";
25
25
 
26
26
  // The nine writing blocks, in schema order. "characters" is the one block that is not always
27
27
  // required: it is required only when fiction: true, everywhere else in this file and in
@@ -1,2 +0,0 @@
1
- So write the three questions on a card. Ask the first one. Then wait, longer than feels polite,
2
- because the first answer is the one they rehearsed and the second one is the one you came for.
@@ -1,2 +0,0 @@
1
- Your first one-on-one with a new report is the only meeting on your calendar where they should
2
- set the agenda. Everything else you run. This one you hand over.