@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.
@@ -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
 
@@ -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
@@ -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.