@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.
- package/.claude/commands/adr.md +5 -4
- package/.claude/commands/init-method.md +27 -8
- package/.claude/commands/remember.md +4 -4
- package/.claude/commands/slice.md +5 -5
- package/.claude/commands/wrap-up.md +41 -4
- package/.claude/hooks/pre-commit +52 -2
- package/README.md +77 -11
- package/package.json +3 -1
- package/scripts/check-immutable.mjs +145 -0
- package/scripts/check-mutants.mjs +262 -0
- package/scripts/init-method.mjs +180 -21
- package/scripts/lint-docs.mjs +201 -11
- package/templates/CLAUDE.md +16 -2
package/scripts/lint-docs.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
}
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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.
|