@supersuit/hyperspec 0.4.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 +68 -0
- package/README.md +25 -1
- package/SPEC.md +4 -4
- package/WRITING.md +279 -15
- package/bin/hyperspec.mjs +108 -2
- 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.hyperspec.md +15 -7
- package/package.json +1 -1
- package/src/dna.mjs +471 -0
- package/src/writing-exports.mjs +8 -3
- package/src/writing-fields.mjs +173 -1
- package/src/writing-template.mjs +8 -0
- 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,9 +18,11 @@
|
|
|
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";
|
|
23
24
|
import { readSegments } from "./segments.mjs";
|
|
25
|
+
import { readScope, isGoldenFileName, measureFeatures, featuresText, DNA_FORMAT } from "./dna.mjs";
|
|
24
26
|
|
|
25
27
|
const f = (test, id, severity, message, fix) => ({ test, id, severity, message, fix });
|
|
26
28
|
const list = (v) => (Array.isArray(v) ? v : []);
|
|
@@ -131,16 +133,149 @@ const UNRESOLVABLE_BECAUSE = {
|
|
|
131
133
|
|
|
132
134
|
// ---------------------------------------------------------------- 2. dna ----------------------
|
|
133
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
|
+
|
|
134
246
|
function dnaFields(raw, d, here, idPrefix) {
|
|
135
247
|
const out = [];
|
|
136
248
|
if (!str(raw.writer)) out.push(f(1, `${idPrefix}-writer`, "fail", "writing.dna has no writer", "Add writer:."));
|
|
137
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.
|
|
138
252
|
if (!str(scope.form)) out.push(f(1, `${idPrefix}-scope-form`, "fail", "writing.dna.scope has no form", "Add scope.form:."));
|
|
139
253
|
if (!str(scope.audience)) out.push(f(1, `${idPrefix}-scope-audience`, "fail", "writing.dna.scope has no audience", "Add scope.audience:."));
|
|
140
254
|
if (!str(scope.purpose)) out.push(f(1, `${idPrefix}-scope-purpose`, "fail", "writing.dna.scope has no purpose", "Add scope.purpose:."));
|
|
141
255
|
const rulesPath = str(raw.rules);
|
|
142
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."));
|
|
143
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
|
+
|
|
144
279
|
const goldens = list(raw.goldens);
|
|
145
280
|
if (!goldens.length) out.push(f(1, `${idPrefix}-goldens`, "fail", "writing.dna has no goldens", "Add at least one golden under dna.goldens."));
|
|
146
281
|
goldens.forEach((g, i) => {
|
|
@@ -148,7 +283,44 @@ function dnaFields(raw, d, here, idPrefix) {
|
|
|
148
283
|
if (!p) out.push(f(1, `${idPrefix}-golden-${i}-path`, "fail", `dna.goldens[${i + 1}] has no path`, "Add path: to the golden."));
|
|
149
284
|
else out.push(...pathFindings(here, p, `${idPrefix}-golden-${i}`, "golden", "Fix the path, or add the golden file."));
|
|
150
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
|
+
}
|
|
151
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
|
+
|
|
152
324
|
return out;
|
|
153
325
|
}
|
|
154
326
|
|
package/src/writing-template.mjs
CHANGED
|
@@ -20,6 +20,13 @@
|
|
|
20
20
|
// materials check.station is the marking station itself, since that check is the same for every
|
|
21
21
|
// writing spec: the linter enforces it, and there is nothing for the operator to decide there.
|
|
22
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
|
+
//
|
|
23
30
|
// init with no --profile never imports or calls this file: template.mjs's own template() is
|
|
24
31
|
// untouched, so a bare init is still byte-for-byte what it always was.
|
|
25
32
|
import { scalar } from "./template.mjs";
|
|
@@ -110,6 +117,7 @@ writing:
|
|
|
110
117
|
author: TODO
|
|
111
118
|
dna:
|
|
112
119
|
writer: TODO
|
|
120
|
+
scope_dir: TODO
|
|
113
121
|
scope:
|
|
114
122
|
form: TODO
|
|
115
123
|
audience: TODO
|