@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.
- package/CHANGELOG.md +127 -0
- package/README.md +46 -1
- package/SPEC.md +5 -3
- package/WRITING.md +475 -40
- package/bin/hyperspec.mjs +157 -3
- package/examples/writing/dna/essay-new-managers-teach/features.json +56 -0
- package/examples/writing/dna/essay-new-managers-teach/goldens/README.md +14 -0
- package/examples/writing/dna/essay-new-managers-teach/goldens/close.md +9 -0
- package/examples/writing/dna/essay-new-managers-teach/goldens/opening.md +9 -0
- package/examples/writing/dna/essay-new-managers-teach/goldens/status.md +10 -0
- package/examples/writing/dna/essay-new-managers-teach/scope.md +11 -0
- package/examples/writing/essay/materials/interview-notes.md.segments.jsonl +10 -0
- package/examples/writing/essay/materials/team-survey.md.segments.jsonl +6 -0
- package/examples/writing/essay/materials/voice-memo.md.segments.jsonl +7 -0
- package/examples/writing/essay.hyperspec.md +24 -13
- package/examples/writing/story/materials/bakery-visit.md +1 -0
- package/examples/writing/story/materials/bakery-visit.md.segments.jsonl +13 -0
- package/examples/writing/story/materials/notes.md +2 -0
- package/examples/writing/story/materials/notes.md.segments.jsonl +7 -0
- package/examples/writing/story/materials/scene-list.md.segments.jsonl +14 -0
- package/examples/writing/story.hyperspec.md +8 -5
- package/package.json +2 -1
- package/src/blobs.mjs +1 -1
- package/src/compare.mjs +6 -6
- package/src/dna.mjs +471 -0
- package/src/fsutil.mjs +1 -1
- package/src/labels.mjs +6 -0
- package/src/reproduce.mjs +5 -5
- package/src/segments.mjs +407 -0
- package/src/writing-exports.mjs +11 -0
- package/src/writing-fields.mjs +279 -6
- package/src/writing-template.mjs +16 -1
- package/src/writing.mjs +4 -4
- package/examples/writing/essay/goldens/close.md +0 -2
- package/examples/writing/essay/goldens/opening.md +0 -2
package/src/writing-fields.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
236
|
-
|
|
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"
|
|
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."));
|
package/src/writing-template.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
export
|
|
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
|