@isonimus/stele 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,14 +10,44 @@
10
10
  //
11
11
  // Exit 1 if any error-severity rule fails. Warnings never fail the build.
12
12
 
13
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
14
- import { join, basename } from 'node:path';
13
+ import { readFileSync, readdirSync, existsSync, realpathSync } from 'node:fs';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { join, basename, dirname, relative } from 'node:path';
15
16
 
16
17
  const STATUSES = ['accepted', 'proposed', 'superseded', 'amended'];
17
18
  const TYPES = ['architecture', 'slice', 'batch'];
18
19
  const REQUIRED = ['id', 'title', 'type', 'status', 'date'];
19
20
  const DOC_DIRS = ['adr', 'slices'];
20
21
 
22
+ // Prose that is read as instruction rather than as a record (ADR-0020). `CLAUDE.md` is
23
+ // loaded at the start of every session and the command files are the assistant's own
24
+ // procedures, so a citation rotting in either misroutes work silently — the corpus itself
25
+ // stays green because rules 8 and 9 never open these files. Directories are scanned one
26
+ // level deep; anything else here is a plain file path.
27
+ const PROSE_FILES = ['CLAUDE.md', 'README.md'];
28
+ const PROSE_DIRS = ['docs', '.claude/commands'];
29
+
30
+ /**
31
+ * Every path this linter reads, relative to the repo root — the hook's archive list
32
+ * (ADR-0018) in machine-readable form.
33
+ *
34
+ * The hook materialises the staged tree and copies only what the checks read, so a rule
35
+ * reading outside this set runs against a file that was never extracted. That happened:
36
+ * rules 14 and 15 shipped reading four paths the hook did not copy, and were dead there
37
+ * for a release while passing in CI. `test/read-set.test.mjs` holds the two lists equal.
38
+ */
39
+ export const READ_SCOPE = [
40
+ ...DOC_DIRS, 'LEDGER.md', ...PROSE_FILES, ...PROSE_DIRS, 'scripts', 'package.json',
41
+ ];
42
+
43
+ /** Whether a root-relative path lies inside the checked scope. Outside it, the hook and a
44
+ * working-tree run would disagree, and a check that depends on where it runs is worse
45
+ * than no check. */
46
+ function inReadScope(rootRelative) {
47
+ if (rootRelative.startsWith('..')) return false;
48
+ return READ_SCOPE.some((entry) => rootRelative === entry || rootRelative.startsWith(`${entry}/`));
49
+ }
50
+
21
51
  // The date the required-slice-section rules (R12/R13) shipped (ADR-0004, ADR-0011). A
22
52
  // slice dated before this predates the rules and only warns; one dated on or after must
23
53
  // comply. Without the split, a repo adopting this linter would go red on its whole legacy
@@ -34,16 +64,89 @@ const normId = (v) => String(v).trim().padStart(4, '0');
34
64
 
35
65
  const isId = (v) => /^\d{1,4}$/.test(String(v).trim());
36
66
 
67
+ /**
68
+ * ISO 8601 `YYYY-MM-DD`, and a day that exists — `2026-02-31` parses but round-trips wrong.
69
+ *
70
+ * `date` is not decoration: R12/R13 pick their severity by string-comparing it against
71
+ * SLICE_SECTIONS_SINCE, so anything sorting above `2026-07-22` grades as current and
72
+ * anything below grades as legacy. Unvalidated, `date: sometime last tuesday` graded as
73
+ * *current* purely because 's' > '2', and a copy-pasted earlier date graded as legacy —
74
+ * shipping a slice with no Definition of Done on a green build. ADR-0002 already specifies
75
+ * ISO 8601; this is that specification made executable.
76
+ */
77
+ function isCalendarDate(v) {
78
+ const text = String(v).trim();
79
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
80
+ // `2026-02-30` parses and normalises to March, so the round-trip catches it. `2026-00-01`
81
+ // does not parse at all, and `toISOString()` on an invalid Date throws — checked first,
82
+ // because a linter that dies with a stack trace on a malformed date reports nothing about
83
+ // the other documents and breaks `--update`'s report (ADR-0021).
84
+ const parsed = new Date(`${text}T00:00:00Z`);
85
+ if (Number.isNaN(parsed.getTime())) return false;
86
+ return parsed.toISOString().startsWith(text);
87
+ }
88
+
37
89
  // Citations, bare or qualified (ADR-0009). A leading `<repo>:` says the decision lives in
38
90
  // another repo's corpus, which this linter cannot open and so must skip. The colon has to
39
91
  // be adjacent, leaving an ordinary sentence ending in a colon ("see also: ADR-0004")
40
92
  // resolving locally as before.
41
93
  const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
42
94
 
43
- /** Ids cited in `text` that this repo is expected to own — cross-repo refs skipped. */
44
- function* localCitations(text) {
45
- for (const [, repo, id] of text.matchAll(CITATION)) {
46
- if (repo === undefined) yield normId(id);
95
+ /**
96
+ * `text` with link destinations and URLs removed, so only prose is scanned for citations.
97
+ *
98
+ * A URL path can contain an `ADR-1234`-shaped run that cites nothing —
99
+ * `https://example.com/docs/ADR-9999`, or a ticket link. Rules 8 and 14 are error severity,
100
+ * so one coincidence blocks a correct commit, and the advice their message gives is
101
+ * unusable: the `<repo>:` qualifier cannot be written inside a URL. Link *text* is kept,
102
+ * because `[ADR-0020](adr/0020-….md)` is a citation and rule 15 checks the target
103
+ * separately.
104
+ */
105
+ const citableText = (text) =>
106
+ text.replace(/\]\([^)]*\)/g, ']()').replace(/\S*:\/\/\S*/g, '');
107
+
108
+ /**
109
+ * Ids cited in `text` that this repo is expected to own — cross-repo refs skipped.
110
+ *
111
+ * A qualifier naming *this* repo resolves locally (ADR-0020). Text that is vendored into
112
+ * other repos must qualify its citations, or a bare `ADR-0005` copied into gamatar reads
113
+ * as gamatar's ADR-0005; but qualifying it would also put it permanently beyond checking,
114
+ * since ADR-0009 skips every qualified reference. Recognising our own name is what keeps
115
+ * `stele:ADR-0005` verified in the one corpus that can verify it.
116
+ */
117
+ function* localCitations(text, selfRepo = null) {
118
+ for (const [, repo, id] of citableText(text).matchAll(CITATION)) {
119
+ if (repo === undefined || (selfRepo !== null && repo === selfRepo)) yield normId(id);
120
+ }
121
+ }
122
+
123
+ /** This repo's own name for citation purposes: the unscoped half of package.json `name`.
124
+ * Null when there is no readable name — rule 11 owns malformed package.json, and without
125
+ * a name there is simply no self-qualifier to recognise. */
126
+ function repoName(root) {
127
+ const path = join(root, 'package.json');
128
+ if (!existsSync(path)) return null;
129
+ let pkg;
130
+ try {
131
+ pkg = JSON.parse(readFileSync(path, 'utf8'));
132
+ } catch {
133
+ return null;
134
+ }
135
+ const name = typeof pkg.name === 'string' ? pkg.name : '';
136
+ const unscoped = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name;
137
+ return unscoped === '' ? null : unscoped;
138
+ }
139
+
140
+ // Inline markdown links, with the optional title form `[x](path "title")`.
141
+ const LINK = /\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
142
+
143
+ /** Link targets in `line` that name a file in this repo. External URLs, mail links,
144
+ * in-page anchors and absolute paths are all outside what a file check can decide. */
145
+ function* relativeLinks(line) {
146
+ for (const [, target] of line.matchAll(LINK)) {
147
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/|#)/i.test(target)) continue;
148
+ const path = target.split('#')[0].split('?')[0];
149
+ if (path !== '') yield path;
47
150
  }
48
151
  }
49
152
 
@@ -127,16 +230,53 @@ export function loadDocs(root) {
127
230
  return docs;
128
231
  }
129
232
 
233
+ /** The prose files present under `root`, as { path, text }. Missing ones are simply
234
+ * absent — not every repo has docs/ or slash commands. */
235
+ function loadProse(root) {
236
+ const paths = PROSE_FILES.map((f) => join(root, f));
237
+ for (const dir of PROSE_DIRS) {
238
+ const full = join(root, dir);
239
+ if (!existsSync(full)) continue;
240
+ paths.push(...readdirSync(full).sort().filter((f) => f.endsWith('.md')).map((f) => join(full, f)));
241
+ }
242
+ return paths.filter(existsSync).map((path) => ({ path, text: readFileSync(path, 'utf8') }));
243
+ }
244
+
130
245
  // --- section helpers --------------------------------------------------------
131
246
  // Slice rules (R12/R13) assert the presence and shape of `## Sections` in the body prose.
132
247
  // This is the only place the linter reads body text structurally; ADR-0002 keeps
133
248
  // frontmatter the machine-readable surface, and a markdown heading is not frontmatter.
134
249
 
250
+ /**
251
+ * Body lines with fenced code blocks blanked out, positions preserved.
252
+ *
253
+ * A `## Verification` inside a fence is a *quotation* of the rule, not compliance with it.
254
+ * Reproduced 2026-07-27: a slice whose only two required sections sat in a ```markdown
255
+ * sample — exactly what a document explaining the slice template contains — lints clean,
256
+ * which is a false green on the two rules that define "done".
257
+ */
258
+ function withoutFences(body) {
259
+ const fence = /^\s*(```|~~~)/;
260
+ let open = null;
261
+ return (body ?? '').split('\n').map((line) => {
262
+ const marker = line.match(fence);
263
+ if (marker && open === null) {
264
+ open = marker[1];
265
+ return '';
266
+ }
267
+ if (marker && line.trim().startsWith(open)) {
268
+ open = null;
269
+ return '';
270
+ }
271
+ return open === null ? line : '';
272
+ });
273
+ }
274
+
135
275
  /** The text under a `## Heading`, up to the next `#`/`##` heading or end of body.
136
276
  * Returns null when the heading is absent — distinct from a present-but-empty section. */
137
277
  function sectionText(body, name) {
138
278
  const heading = new RegExp(`^##\\s+${name}\\s*$`, 'i');
139
- const lines = (body ?? '').split('\n');
279
+ const lines = withoutFences(body);
140
280
  const start = lines.findIndex((l) => heading.test(l.trim()));
141
281
  if (start === -1) return null;
142
282
  const rest = lines.slice(start + 1);
@@ -201,6 +341,9 @@ const rules = {
201
341
  report('error', d.path, `R1 missing required field "${field}"`);
202
342
  }
203
343
  }
344
+ if (d.data.date !== undefined && !isCalendarDate(d.data.date)) {
345
+ report('error', d.path, `R1 date "${d.data.date}" is not a calendar date in YYYY-MM-DD form`);
346
+ }
204
347
  }
205
348
  },
206
349
 
@@ -261,6 +404,14 @@ const rules = {
261
404
  const supersededBy = listOf(d, 'superseded_by');
262
405
  const supersedes = listOf(d, 'supersedes');
263
406
 
407
+ // R4 — a document cannot supersede itself. Self-reference satisfies every other
408
+ // check in this rule vacuously: the bidirectionality test finds the id in its own
409
+ // list, R6 sees a superseded status with a non-empty superseded_by, and R7 sees a
410
+ // target that is not "accepted". The whole graph agrees, about nothing.
411
+ if (supersedes.includes(id) || supersededBy.includes(id)) {
412
+ report('error', d.path, `R4 ADR ${id} supersedes itself — a decision is replaced by a later one, never by itself`);
413
+ }
414
+
264
415
  // R5 — dangling references. Legacy 0051 and 0061 claimed supersession with no
265
416
  // resolvable target at all.
266
417
  for (const key of ['supersedes', 'superseded_by']) {
@@ -315,8 +466,9 @@ const rules = {
315
466
  const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
316
467
 
317
468
  const text = readFileSync(path, 'utf8');
469
+ const self = repoName(root);
318
470
  text.split('\n').forEach((line, i) => {
319
- for (const id of localCitations(line)) {
471
+ for (const id of localCitations(line, self)) {
320
472
  if (!ids.has(id)) {
321
473
  report('error', path, `R8 line ${i + 1} cites ADR ${id}, which does not exist. Another repo's decision is cited as \`<repo>:ADR-${id}\` (ADR-0009).`);
322
474
  }
@@ -324,6 +476,41 @@ const rules = {
324
476
  });
325
477
  },
326
478
 
479
+ // R14/R15 — the prose that is read as instruction (ADR-0020). Rules 8 and 9 open
480
+ // `LEDGER.md` and the corpus and nothing else, so `CLAUDE.md`, `README.md`, `docs/` and
481
+ // the slash commands were never checked at all. That is not hypothetical: gamatar's
482
+ // vendored `/remember` said "the exact failure ADR-0005 exists to prevent", and
483
+ // gamatar's ADR-0005 is a superseded decision about canvas face textures.
484
+ //
485
+ // Error, not a warning like R9: measured across boxel's prose (43 bare references) and
486
+ // gamatar's, every reference that is meant to be local resolves, so the severity that
487
+ // forced R9 to warn — legacy volume — is absent here.
488
+ //
489
+ // The two run together because they share the file set. R15 checks relative link
490
+ // targets, and only here: a broken link inside an immutable document cannot be fixed
491
+ // without the rewrite ADR-0019 forbids, so it is not something to fail a build on.
492
+ prose(docs, root, report) {
493
+ const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
494
+ const self = repoName(root);
495
+
496
+ for (const { path, text } of loadProse(root)) {
497
+ text.split('\n').forEach((line, i) => {
498
+ for (const id of localCitations(line, self)) {
499
+ if (!ids.has(id)) {
500
+ report('error', path, `R14 line ${i + 1} cites ADR ${id}, which does not exist. Another repo's decision is cited as \`<repo>:ADR-${id}\` (ADR-0009).`);
501
+ }
502
+ }
503
+ for (const target of relativeLinks(line)) {
504
+ const resolved = relative(root, join(dirname(path), target));
505
+ if (!inReadScope(resolved)) continue;
506
+ if (!existsSync(join(root, resolved))) {
507
+ report('error', path, `R15 line ${i + 1} links to ${target}, which does not exist`);
508
+ }
509
+ }
510
+ });
511
+ }
512
+ },
513
+
327
514
  // R11 — every verify script is wired into package.json (ADR-0004). The harness's
328
515
  // load-bearing half: an unwired `*-verify.mjs` ran once on the day it was written and
329
516
  // never again — ADR-0004 Finding 2 found eleven of twelve boxel scripts in exactly that
@@ -400,12 +587,13 @@ const rules = {
400
587
  // Since ADR-0009 a bare reference means unambiguously "in this repo" — the other-repo
401
588
  // case has its own syntax — so the remaining obstacle to erroring here is boxel's
402
589
  // legacy volume alone, not the mechanism.
403
- proseRefs(docs, _root, report) {
590
+ proseRefs(docs, root, report) {
404
591
  const ids = new Set(docs.filter((d) => d.ok && d.data.id !== undefined).map((d) => normId(d.data.id)));
592
+ const self = repoName(root);
405
593
  for (const d of docs) {
406
594
  if (!d.ok || !d.body) continue;
407
595
  const unresolved = new Set();
408
- for (const id of localCitations(d.body)) {
596
+ for (const id of localCitations(d.body, self)) {
409
597
  if (!ids.has(id)) unresolved.add(id);
410
598
  }
411
599
  for (const ref of [...unresolved].sort()) {
@@ -458,6 +646,8 @@ function main(argv) {
458
646
  return errors > 0 ? 1 : 0;
459
647
  }
460
648
 
461
- if (import.meta.url === `file://${process.argv[1]}`) {
649
+ // realpath, not a string compare on argv[1]: invoked through a bin symlink the naive form
650
+ // silently does nothing, which is how `npx stele` shipped as a no-op (ADR-0015).
651
+ if (realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
462
652
  process.exit(main(process.argv.slice(2)));
463
653
  }
@@ -36,13 +36,27 @@ Changing our minds means writing a **new** ADR that supersedes the old one, neve
36
36
  it. The superseding note must say *why the old reasoning was wrong* — that record is the
37
37
  most valuable thing this workflow produces, and an in-place edit destroys it.
38
38
 
39
+ A committed document's body may **gain** lines — an appended `## Amendment — <date>: …`, or a
40
+ correction marker placed at the claim it corrects — and may never lose or rewrite one. The
41
+ hook enforces this (stele:ADR-0019); frontmatter is exempt, because status and supersession
42
+ fields are how a record announces it was superseded.
43
+
39
44
  This is also how a justified rule-violation gets recorded. `~/.claude/CLAUDE.md` §2 says a
40
45
  justified violation is written down as a decision rather than taken as a silent exception;
41
46
  in this repo, that decision is a new or superseding ADR.
42
47
 
43
48
  ## 2. Enforcement — invariants are executable
44
49
 
45
- `node scripts/lint-docs.mjs` runs from a pre-commit hook and in CI.
50
+ `node scripts/lint-docs.mjs` runs from a pre-commit hook and in CI. The hook checks the
51
+ **commit**, not the working tree, so a fix you forgot to stage cannot green a red commit
52
+ (stele:ADR-0018); alongside the linter it verifies that `adr/INDEX.md` matches the corpus and
53
+ that immutable bodies have only gained lines (stele:ADR-0019).
54
+
55
+ A citation is bare (`ADR-NNNN`) only when it means *this* repo, and qualified
56
+ (`<repo>:ADR-NNNN`) otherwise (stele:ADR-0009). The linter resolves citations in `LEDGER.md`,
57
+ in the corpus, and in the prose that is read as instruction — this file, `README.md`,
58
+ `docs/`, `.claude/commands/` — so a citation that rots there fails the build rather than
59
+ quietly misrouting the next session (stele:ADR-0020).
46
60
 
47
61
  A rule enforced by memory is a rule that holds until the first busy afternoon. If a
48
62
  convention matters, it gets a rule; if it genuinely can't be checked, say so out loud
@@ -76,7 +90,7 @@ are exempt: a probe answers its question once and the number lands in an ADR.
76
90
 
77
91
  Every slice carries two required sections, both rule-checked: `## Verification` names the
78
92
  proof (R12), and `## Definition of Done` states the acceptance criteria as Given/When/Then
79
- scenarios written before the code (R13, ADR-0011). Each scenario names its proof in
93
+ scenarios written before the code (R13, stele:ADR-0011). Each scenario names its proof in
80
94
  `## Verification`; the linter checks the sections exist and that the Definition of Done
81
95
  holds a full triad — it cannot check that a scenario is *right*, which is what `/wrap-up`
82
96
  is for.