@isonimus/stele 0.2.0 → 0.4.1

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.
@@ -0,0 +1,84 @@
1
+ # Releasing
2
+
3
+ How a version of `@isonimus/stele` reaches npm. A **live doc** (ADR-0010): update it in the
4
+ same change as the procedure it describes.
5
+
6
+ It exists because the steps below were previously done from memory, in the repo whose thesis
7
+ is that a rule enforced by memory holds until the first busy afternoon (ADR-0003). Two things
8
+ had already gone wrong by the time it was written: three published versions had **no git tags
9
+ at all**, and a build tarball had been sitting in version control since 2026-07-27.
10
+
11
+ ## The order, and why it is this order
12
+
13
+ ```
14
+ 1. npm run lint && npm test the corpus is green and the suite passes
15
+ 2. npm run index regenerate adr/INDEX.md; commit if it moved
16
+ 3. edit package.json bump the version
17
+ 4. commit the last commit before the tag
18
+ 5. git tag -a vX.Y.Z -m "…" on the commit being published (ADR-0026)
19
+ 6. npm run changelog regenerate CHANGELOG.md from the new tag
20
+ 7. commit "chore: changelog for vX.Y.Z"
21
+ 8. git push && git push --tags tag and history reach the remote together
22
+ 9. npm publish irreversible: a version number is never reusable
23
+ 10. /init-method --update in every installed repo, so the fix actually lands
24
+ ```
25
+
26
+ **Why the tag comes before the changelog.** `scripts/build-changelog.mjs` reads tags and
27
+ nothing else, so a tag that does not exist yet produces a changelog missing its own release.
28
+ Tagging first is not a preference; it is the data dependency.
29
+
30
+ **Why the changelog is a second commit, and why publish is one commit past the tag.** The
31
+ changelog describes commits up to and including the tagged one, so it cannot live inside the
32
+ object it is naming. `npm publish` therefore packs a tree one commit ahead of `vX.Y.Z` —
33
+ identical to it but for `CHANGELOG.md`, which is the file that could not have been there. That
34
+ skew is deliberate and bounded; anything else in the diff at step 9 means the release is not
35
+ what the tag says it is.
36
+
37
+ **Step 3 bumps the version, but step 5 tags the commit you are about to publish** — and on
38
+ 0.4.0 those were three commits apart, because work continued after the bump. `npm publish`
39
+ packs the working tree, not the version-bump commit, so a tag left behind on the bump would
40
+ name bytes nobody received. Bump last where you can; tag what ships where you cannot.
41
+
42
+ **Why publish is last and by hand.** `npm publish` cannot be undone: a version number is burned
43
+ even if the release is unpublished within the 72-hour window. Everything reversible happens
44
+ first, so the irreversible step runs against a state already checked.
45
+
46
+ **Why step 10 is part of the release.** The linter, the hook and the commands are *vendored*
47
+ per repo (ADR-0006, ADR-0023). Publishing changes nothing in boxel or gamatar until
48
+ `/init-method --update` runs there, so a release that stops at step 9 has shipped to the
49
+ registry and to nobody.
50
+
51
+ ## Version numbers
52
+
53
+ Pre-1.0, so the minor slot carries breaking changes:
54
+
55
+ - **patch** — a fix that changes no vendored file's behaviour.
56
+ - **minor** — a new linter rule, a change to a vendored command or to `docs/quality-bar.md`,
57
+ or any change to the installer's behaviour. A new rule makes commits fail that were green in
58
+ the previous version, which is breaking however small the diff.
59
+ - **major** — reserved for 1.0.
60
+
61
+ ## What the changelog does and does not say
62
+
63
+ It is generated from commit subjects between tags (ADR-0026). It therefore inherits exactly
64
+ the quality of the commit messages, which is the argument for writing them properly rather
65
+ than an argument for editing the changelog afterwards — **never hand-edit it**; the CI check
66
+ will fail and the edit is lost at the next regeneration.
67
+
68
+ It cannot say "you must run `--update` for this one". If a release needs that, say so in the
69
+ release commit's subject, where the changelog will pick it up verbatim.
70
+
71
+ ## If something goes wrong
72
+
73
+ - **Anything changed after tagging, including docs:** do not move the tag. Bump a patch
74
+ version, tag that, and publish it instead — the working tree is what `npm publish` packs, so
75
+ a tree that has moved past the tag is no longer the release the tag names. This happened on
76
+ the first run of this procedure: `v0.4.0` was tagged, a README update followed, and `0.4.1`
77
+ was published in its place. `v0.4.0` stays in the history as a tagged release that was never
78
+ published, which the changelog shows and which is the honest record (`0.2.1` is the same).
79
+ - **Tagged the wrong commit, not yet pushed:** `git tag -d vX.Y.Z`, retag, regenerate.
80
+ - **Tagged and pushed the wrong commit:** do not move the tag. Tag the correction as the next
81
+ patch version — a moved tag means two clones disagree about what a release was.
82
+ - **Published a broken version:** publish the fix as a new version. Unpublishing is available
83
+ for 72 hours and is worse than a superseding release: it breaks every lockfile that already
84
+ pinned it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@isonimus/stele",
3
- "version": "0.2.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "A linted, installable ADR workflow for git projects using Claude as an assistant",
6
6
  "keywords": [
@@ -29,9 +29,11 @@
29
29
  "files": [
30
30
  "scripts/",
31
31
  "templates/",
32
+ "docs/",
32
33
  ".claude/commands/",
33
34
  ".claude/hooks/",
34
35
  "README.md",
36
+ "CHANGELOG.md",
35
37
  "LICENSE"
36
38
  ],
37
39
  "publishConfig": {
@@ -41,7 +43,9 @@
41
43
  "test": "node --test test/*.test.mjs",
42
44
  "lint": "node scripts/lint-docs.mjs .",
43
45
  "index": "node scripts/build-index.mjs .",
46
+ "changelog": "node scripts/build-changelog.mjs .",
44
47
  "immutable": "node scripts/check-immutable.mjs",
48
+ "mutants": "node scripts/check-mutants.mjs",
45
49
  "scan": "node scripts/scan-legacy.mjs",
46
50
  "migrate": "node scripts/migrate-adrs.mjs",
47
51
  "init": "node scripts/init-method.mjs"
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env node
2
+ // Builds CHANGELOG.md from annotated git tags (ADR-0026). Generated, never hand-edited.
3
+ //
4
+ // node scripts/build-changelog.mjs [repo-root] write CHANGELOG.md
5
+ // node scripts/build-changelog.mjs --check [repo-root] fail if it is stale
6
+ //
7
+ // Tags are the only input, so the file changes when a release is tagged and at no other
8
+ // time — which is what makes --check affordable in CI. Commits made after the newest tag
9
+ // are deliberately absent until the next tag exists (ADR-0026).
10
+ //
11
+ // --check runs in CI only, and cannot move into the pre-commit hook: the hook checks the
12
+ // commit by extracting a `git archive` of the staged tree into a temp directory (ADR-0018),
13
+ // which has no `.git` and therefore no tags. Reading tags from the real repo instead would
14
+ // check the working tree while claiming to check the commit, which is the exact confusion
15
+ // ADR-0018 exists to prevent.
16
+
17
+ import { writeFileSync, readFileSync, existsSync, realpathSync } from 'node:fs';
18
+ import { execFileSync } from 'node:child_process';
19
+ import { join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const CHANGELOG = 'CHANGELOG.md';
23
+
24
+ /** Conventional-commit types, in the order a reader cares about them. Anything else sorts
25
+ * last under its own literal prefix rather than being dropped: a commit that escaped the
26
+ * convention is still a change that shipped, and hiding it would make the file a claim
27
+ * about the corpus instead of a rendering of it. */
28
+ const TYPE_ORDER = ['feat', 'fix', 'perf', 'refactor', 'test', 'docs', 'chore'];
29
+
30
+ const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
31
+
32
+ /** The type prefix of a conventional subject (`feat(x): …` → `feat`), or null. */
33
+ export function commitType(subject) {
34
+ const match = /^([a-z]+)(\([^)]*\))?!?:/.exec(subject);
35
+ return match ? match[1] : null;
36
+ }
37
+
38
+ const typeRank = (subject) => {
39
+ const index = TYPE_ORDER.indexOf(commitType(subject));
40
+ return index === -1 ? TYPE_ORDER.length : index;
41
+ };
42
+
43
+ /**
44
+ * The tags, split into releases and everything this tool refuses to call one.
45
+ *
46
+ * A release is an **annotated** tag that dereferences to a **commit**, and both halves of
47
+ * that are load-bearing (ADR-0026, found by the ADR-0017 pass on this file):
48
+ *
49
+ * - `refs/tags` holds lightweight tags too — local bookmarks, bisect markers, CI leftovers —
50
+ * and counting one as a release publishes a section for something nobody released.
51
+ * - An annotated tag may point at a blob or a tree. `git rev-list -n 1` then yields the empty
52
+ * string, and the next git call inherits it as a ref and dies with a raw `fatal: ambiguous
53
+ * argument ''` plus a stack trace.
54
+ *
55
+ * Both are read straight off `for-each-ref` rather than probed afterwards: `%(*objecttype)`
56
+ * and `%(*objectname)` are the peeled object, so one call answers both questions and there is
57
+ * no window where a tag is assumed to be something it is not.
58
+ *
59
+ * Releases come back oldest first, ordered by the tagged **commit's** date rather than the
60
+ * tag's own — this repo's first three tags were written retroactively in one sitting, so
61
+ * creation order carries no release information whatever.
62
+ */
63
+ export function readTags(cwd) {
64
+ const lines = git(cwd, [
65
+ 'for-each-ref',
66
+ '--format=%(refname:short)\t%(objecttype)\t%(*objecttype)\t%(*objectname)',
67
+ 'refs/tags',
68
+ ]);
69
+ if (lines === '') return { releases: [], skipped: [] };
70
+
71
+ const releases = [];
72
+ const skipped = [];
73
+
74
+ for (const line of lines.split('\n')) {
75
+ const [name, objectType, peeledType, peeledCommit] = line.split('\t');
76
+ if (objectType !== 'tag') {
77
+ skipped.push({ name, why: 'lightweight — a release tag is annotated (git tag -a)' });
78
+ continue;
79
+ }
80
+ if (peeledType !== 'commit') {
81
+ skipped.push({ name, why: `annotated but points at a ${peeledType || 'non-commit object'}` });
82
+ continue;
83
+ }
84
+ // Full timestamp for ordering, short date for display: two releases on one day are
85
+ // ordinary, and a date-only sort would fall back to alphabetical refname order, which
86
+ // puts v0.10.0 before v0.9.0.
87
+ const [orderedBy, date] = git(cwd, ['log', '-1', '--format=%aI\t%ad', '--date=short', peeledCommit]).split('\t');
88
+ releases.push({ name, commit: peeledCommit, orderedBy, date });
89
+ }
90
+
91
+ releases.sort((a, b) => (a.orderedBy < b.orderedBy ? -1 : a.orderedBy > b.orderedBy ? 1 : 0));
92
+ return { releases, skipped };
93
+ }
94
+
95
+ /**
96
+ * Subject and short SHA of every commit reachable from `to` and from none of `alreadyShipped`,
97
+ * newest first.
98
+ *
99
+ * Excluding *every* earlier release rather than only the previous one is what keeps a commit
100
+ * in one section. A tagged branch that merges in later is reachable from both its own tag and
101
+ * the merge's tag, so a plain `previous..this` range reports it twice — once under the release
102
+ * that shipped it and again under the release that merged it (ADR-0017 pass on this file).
103
+ *
104
+ * Merges themselves are excluded: a merge commit's subject records an integration, not a
105
+ * change, and the commits it brings in are listed individually.
106
+ */
107
+ export function commitsSince(cwd, alreadyShipped, to) {
108
+ const lines = git(cwd, ['log', '--no-merges', '--format=%h\t%s', to, '--not', ...alreadyShipped]);
109
+ if (lines === '') return [];
110
+
111
+ return lines.split('\n').map((line) => {
112
+ const separator = line.indexOf('\t');
113
+ return { sha: line.slice(0, separator), subject: line.slice(separator + 1) };
114
+ });
115
+ }
116
+
117
+ /** The document. Pure, so the shape is testable without a repo. */
118
+ export function renderChangelog(releases) {
119
+ const head = `# Changelog
120
+
121
+ Generated by \`scripts/build-changelog.mjs\` from annotated git tags — never hand-edited
122
+ (ADR-0026). Run \`npm run changelog\` after tagging a release.
123
+
124
+ Every entry is a commit subject verbatim. Work committed after the newest tag is absent by
125
+ design and appears when the next release is tagged.
126
+ `;
127
+
128
+ if (releases.length === 0) {
129
+ return `${head}\nNo tags yet, so no releases to describe.\n`;
130
+ }
131
+
132
+ const sections = [...releases].reverse().map(({ name, date, commits }) => {
133
+ const body = commits.length === 0
134
+ ? '\nNo commits between this tag and the previous one.\n'
135
+ : `\n${[...commits]
136
+ .sort((a, b) => typeRank(a.subject) - typeRank(b.subject))
137
+ .map(({ sha, subject }) => `- ${subject} (\`${sha}\`)`)
138
+ .join('\n')}\n`;
139
+ return `## ${name} — ${date}\n${body}`;
140
+ });
141
+
142
+ return `${head}\n${sections.join('\n')}`;
143
+ }
144
+
145
+ /** The document this repo's tags describe, and the tags that were not treated as releases. */
146
+ export function buildChangelog(cwd) {
147
+ const { releases, skipped } = readTags(cwd);
148
+ return {
149
+ text: renderChangelog(
150
+ releases.map((release, index) => ({
151
+ ...release,
152
+ commits: commitsSince(cwd, releases.slice(0, index).map((earlier) => earlier.commit), release.commit),
153
+ })),
154
+ ),
155
+ skipped,
156
+ };
157
+ }
158
+
159
+ function main(argv) {
160
+ const check = argv.includes('--check');
161
+ const cwd = argv.find((arg) => !arg.startsWith('--')) ?? process.cwd();
162
+ const path = join(cwd, CHANGELOG);
163
+ const { text: wanted, skipped } = buildChangelog(cwd);
164
+
165
+ // Named rather than dropped in silence: a release tagged with a bare `git tag` produces a
166
+ // changelog missing that release, and the only symptom is an absence nobody looks for.
167
+ for (const { name, why } of skipped) {
168
+ console.error(`note: tag ${name} is not treated as a release — ${why}`);
169
+ }
170
+
171
+ if (!check) {
172
+ writeFileSync(path, wanted);
173
+ console.log(`wrote ${CHANGELOG}`);
174
+ return 0;
175
+ }
176
+
177
+ if (!existsSync(path)) {
178
+ console.error(`${CHANGELOG} is missing — run \`npm run changelog\`.`);
179
+ return 1;
180
+ }
181
+ if (readFileSync(path, 'utf8') !== wanted) {
182
+ console.error(`${CHANGELOG} does not match the tags — a release was tagged without regenerating it. Run \`npm run changelog\`.`);
183
+ return 1;
184
+ }
185
+ console.log(`${CHANGELOG} matches the tags`);
186
+ return 0;
187
+ }
188
+
189
+ // Resolved on both sides before comparing: a raw `file://${argv[1]}` check is false whenever
190
+ // the script is reached through a symlink, and main() then silently never runs (ADR-0015
191
+ // amendment, where that cost an install that exited 0 and did nothing).
192
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
193
+ process.exit(main(process.argv.slice(2)));
194
+ }
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ // Mutation check over the linter's pure predicates (ADR-0004's probe idea, made standing).
3
+ //
4
+ // node scripts/check-mutants.mjs [--quiet]
5
+ //
6
+ // Each mutant is a small, deliberate behaviour change. The suite is run against it: a
7
+ // mutant that dies proves a test was watching, one that SURVIVES proves the behaviour is
8
+ // unpinned and a refactor could reverse it in silence. Exit 1 if any non-exempt mutant
9
+ // survives.
10
+ //
11
+ // Scope is deliberately narrow: the total, IO-free predicates in lint-docs.mjs and
12
+ // check-immutable.mjs. Every subtle-logic defect this repo has had lived in that class.
13
+ // init-method.mjs is excluded — its behaviour is pinned by real-filesystem fixtures, where
14
+ // a mutant mostly proves the filesystem still works — and the hook is shell, not JS.
15
+ //
16
+ // What this does NOT buy, stated so a green run is not read as more than it is: none of the
17
+ // three defects this repo has actually suffered would have been caught. Two were missing
18
+ // inputs and one was a wrong specification; mutation testing perturbs code and asks whether
19
+ // tests notice, so it is blind to a case nobody wrote and to a rule that was wrong from the
20
+ // start. It measures regression durability, not correctness (LEDGER).
21
+
22
+ import { cpSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { tmpdir } from 'node:os';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
+
30
+ const LINTER = 'scripts/lint-docs.mjs';
31
+ const IMMUTABLE = 'scripts/check-immutable.mjs';
32
+
33
+ /** ADR-0022 excluded init-method.mjs wholesale, reasoning that a mutant there mostly proves
34
+ * the filesystem still works. `classifyVendored` (ADR-0023) is the first total, IO-free
35
+ * predicate in that file, so it falls inside the scope ADR-0022 actually described rather
36
+ * than the file it named. Recorded as a refinement, never a silent exception. */
37
+ const INIT = 'scripts/init-method.mjs';
38
+
39
+ /** The changelog's type classifier and its ordering are total and IO-free, so they fall in
40
+ * the same scope as the linter's predicates — the git-reading half does not (ADR-0026). */
41
+ const CHANGELOG = 'scripts/build-changelog.mjs';
42
+
43
+ /**
44
+ * The curated mutant list. Each entry names one behaviour and the smallest edit that
45
+ * reverses it.
46
+ *
47
+ * Hand-picked rather than generated: a generator emits hundreds of mutants over these files
48
+ * and most are equivalent or trivial, so the triage cost — not the runtime — is what makes
49
+ * a framework a bad trade here. Curating is the work; the runner is twenty lines.
50
+ *
51
+ * `equivalent` marks a mutant that cannot be killed because the mutated code means the same
52
+ * thing. It is kept rather than deleted, so nobody re-adds it as a "gap", and it must carry
53
+ * the reason it is exempt.
54
+ */
55
+ export const MUTANTS = [
56
+ {
57
+ label: 'normId: pad ids to 3 digits instead of 4',
58
+ file: LINTER,
59
+ find: "String(v).trim().padStart(4, '0')",
60
+ replace: "String(v).trim().padStart(3, '0')",
61
+ },
62
+ {
63
+ label: 'isCalendarDate: drop the round-trip check',
64
+ file: LINTER,
65
+ find: 'return parsed.toISOString().startsWith(text);',
66
+ replace: 'return true;',
67
+ },
68
+ {
69
+ label: 'citableText: stop stripping URLs before matching citations',
70
+ file: LINTER,
71
+ find: "text.replace(/\\]\\([^)]*\\)/g, ']()').replace(/\\S*:\\/\\/\\S*/g, '')",
72
+ replace: "text.replace(/\\]\\([^)]*\\)/g, ']()')",
73
+ },
74
+ {
75
+ label: 'inReadScope: drop the exact-match branch, keeping only the prefix',
76
+ file: LINTER,
77
+ find: 'rootRelative === entry || rootRelative.startsWith(`${entry}/`)',
78
+ replace: 'rootRelative.startsWith(`${entry}/`)',
79
+ },
80
+ {
81
+ label: 'withoutFences: stop blanking lines inside a code fence',
82
+ file: LINTER,
83
+ find: "return open === null ? line : '';",
84
+ replace: 'return line;',
85
+ },
86
+ {
87
+ label: 'sectionText: read to end of body instead of stopping at the next heading',
88
+ file: LINTER,
89
+ find: "(end === -1 ? rest : rest.slice(0, end)).join('\\n')",
90
+ replace: "rest.join('\\n')",
91
+ },
92
+ {
93
+ label: 'hasGherkinTriad: require any step kind rather than all three',
94
+ file: LINTER,
95
+ find: "steps.has('given') && steps.has('when') && steps.has('then')",
96
+ replace: "steps.has('given') || steps.has('when') || steps.has('then')",
97
+ },
98
+ {
99
+ label: 'R6: ignore a superseded_by list of exactly one target',
100
+ file: LINTER,
101
+ find: 'if (!isSuperseded && supersededBy.length > 0) {',
102
+ replace: 'if (!isSuperseded && supersededBy.length > 1) {',
103
+ },
104
+ {
105
+ label: 'classifyVendored: treat an unrecorded command as stale rather than unknown',
106
+ file: INIT,
107
+ find: "if (recordedDigest === undefined) return 'unknown';",
108
+ replace: "if (recordedDigest === undefined) return 'stale';",
109
+ },
110
+ {
111
+ label: 'classifyVendored: swap the stale/adapted verdict',
112
+ file: INIT,
113
+ find: "return digest(targetText) === recordedDigest ? 'stale' : 'adapted';",
114
+ replace: "return digest(targetText) === recordedDigest ? 'adapted' : 'stale';",
115
+ },
116
+ {
117
+ label: 'firstLostLine: weaken the exhausted-scan guard',
118
+ file: IMMUTABLE,
119
+ find: 'if (cursor === now.length) return i;',
120
+ replace: 'if (cursor > now.length) return i;',
121
+ },
122
+ {
123
+ label: 'commitType: accept a subject with no colon as conventional',
124
+ file: CHANGELOG,
125
+ find: "/^([a-z]+)(\\([^)]*\\))?!?:/",
126
+ replace: "/^([a-z]+)(\\([^)]*\\))?!?:?/",
127
+ },
128
+ {
129
+ label: 'typeRank: sort an unrecognised type first rather than last',
130
+ file: CHANGELOG,
131
+ find: 'return index === -1 ? TYPE_ORDER.length : index;',
132
+ replace: 'return index === -1 ? 0 : index;',
133
+ },
134
+ {
135
+ label: 'firstLostLine: compare body lines loosely',
136
+ file: IMMUTABLE,
137
+ find: 'while (cursor < now.length && now[cursor] !== was[i]) cursor++;',
138
+ replace: 'while (cursor < now.length && now[cursor] != was[i]) cursor++;',
139
+ equivalent: 'both operands are strings, so != and !== are the same comparison',
140
+ },
141
+ ];
142
+
143
+ /**
144
+ * A disposable copy of the working tree. The check never edits the operator's files: a run
145
+ * interrupted midway would otherwise leave mutated source behind, and "my linter is subtly
146
+ * wrong and I do not know why" is an expensive afternoon.
147
+ *
148
+ * `.git` is copied with everything else, because the copy has to behave identically or the
149
+ * baseline below fails for reasons that have nothing to do with any mutant. `migrate-adrs`
150
+ * reads commit dates and falls back silently when git cannot answer, so a hollow `.git`
151
+ * changed its plan and failed five tests. At 3.2MB the copy is not worth being clever about.
152
+ */
153
+ function scratchCopy() {
154
+ const dir = mkdtempSync(join(tmpdir(), 'stele-mutants-'));
155
+ cpSync(ROOT, dir, { recursive: true, filter: (src) => !src.endsWith('/node_modules') });
156
+ return dir;
157
+ }
158
+
159
+ /**
160
+ * `test/mutants.test.mjs` guards this script's own list by asserting every anchor matches
161
+ * exactly once. Applying a mutant is precisely what stops an anchor matching, so under a
162
+ * mutated tree that test fails — and every mutant would be scored as killed by the guard
163
+ * rather than by any regression test, including the equivalent one that cannot be killed
164
+ * at all. It runs in the ordinary suite, where it belongs, and is excluded here.
165
+ */
166
+ const SELF_REFERENTIAL = 'mutants.test.mjs';
167
+
168
+ /** The suite's files, expanded here rather than by a shell: `node --test test/` reads that
169
+ * argument as a module path and dies, which is not a test failure but looks exactly like
170
+ * one from the outside. */
171
+ function testFiles(cwd) {
172
+ const dir = join(cwd, 'test');
173
+ const files = readdirSync(dir)
174
+ .filter((f) => f.endsWith('.test.mjs') && f !== SELF_REFERENTIAL)
175
+ .sort();
176
+ if (files.length === 0) throw new Error(`no test files under ${dir} — nothing to run mutants against`);
177
+ return files.map((f) => join('test', f));
178
+ }
179
+
180
+ /** True when the suite passes — i.e. the mutant went unnoticed. */
181
+ function suitePasses(cwd, files) {
182
+ try {
183
+ execFileSync(process.execPath, ['--test', ...files], { cwd, stdio: 'ignore' });
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ export function checkMutants({ quiet = false } = {}) {
191
+ const dir = scratchCopy();
192
+ const survivors = [];
193
+ try {
194
+ const files = testFiles(dir);
195
+
196
+ // The load-bearing guard. Every verdict here is "the suite failed, so something was
197
+ // watching" — which is worthless if the suite fails for its own reasons, and reports a
198
+ // perfect score while testing nothing. The first draft of this script did exactly that:
199
+ // it ran `node --test test/`, which is not a directory glob, so all ten mutants "died"
200
+ // in 621ms against a suite that never started.
201
+ if (!suitePasses(dir, files)) {
202
+ throw new Error(
203
+ 'the unmutated suite does not pass — every mutant would report as killed for the\n' +
204
+ 'wrong reason. Fix the suite first, then re-run this check.',
205
+ );
206
+ }
207
+
208
+ for (const mutant of MUTANTS) {
209
+ const path = join(dir, mutant.file);
210
+ const pristine = readFileSync(path, 'utf8');
211
+
212
+ // A rotted anchor is a failure, never a skip. The alternative is a probe that quietly
213
+ // stops testing what it claims to — the exact false green this repo exists to remove.
214
+ const hits = pristine.split(mutant.find).length - 1;
215
+ if (hits !== 1) {
216
+ throw new Error(
217
+ `mutant anchor matched ${hits} times in ${mutant.file}, expected exactly 1:\n` +
218
+ ` ${mutant.label}\n` +
219
+ ` The code moved. Re-aim the mutant at the behaviour it was written for, or ` +
220
+ `delete it if that behaviour is gone.`,
221
+ );
222
+ }
223
+
224
+ writeFileSync(path, pristine.replace(mutant.find, mutant.replace));
225
+ const survived = suitePasses(dir, files);
226
+ writeFileSync(path, pristine);
227
+
228
+ // An equivalent mutant that dies is the canary, not a bonus. It cannot be detected by
229
+ // any honest test, so a kill means the suite failed for a reason unrelated to the
230
+ // mutation and every other verdict in this run is worthless. Both times this harness
231
+ // scored wrongly — the crashing test command, then the self-referential guard — this
232
+ // is the line that showed it.
233
+ if (mutant.equivalent && !survived) {
234
+ throw new Error(
235
+ `an equivalent mutant was killed, which cannot happen honestly:\n` +
236
+ ` ${mutant.label}\n` +
237
+ ` exempt because: ${mutant.equivalent}\n` +
238
+ ` The suite is failing for a reason unrelated to the mutation, so every other\n` +
239
+ ` verdict in this run is meaningless. Run the suite by hand and find out why.`,
240
+ );
241
+ }
242
+
243
+ if (survived && !mutant.equivalent) survivors.push(mutant);
244
+ if (!quiet) {
245
+ const verdict = mutant.equivalent ? 'exempt (equivalent)' : (survived ? 'SURVIVED' : 'killed');
246
+ console.log(` ${verdict.padEnd(24)} ${mutant.label}`);
247
+ }
248
+ }
249
+ } finally {
250
+ rmSync(dir, { recursive: true, force: true });
251
+ }
252
+ return survivors;
253
+ }
254
+
255
+ function main(argv) {
256
+ const quiet = argv.includes('--quiet');
257
+ if (!quiet) console.log(`\n${ROOT} — ${MUTANTS.length} mutant(s)`);
258
+
259
+ const survivors = checkMutants({ quiet });
260
+ const exempt = MUTANTS.filter((m) => m.equivalent).length;
261
+
262
+ if (survivors.length === 0) {
263
+ console.log(`\nall ${MUTANTS.length - exempt} non-exempt mutant(s) killed, ${exempt} exempt`);
264
+ return 0;
265
+ }
266
+
267
+ console.log(`\n${survivors.length} mutant(s) survived — the behaviour below is unpinned:`);
268
+ for (const m of survivors) console.log(` ${m.file}: ${m.label}`);
269
+ console.log(
270
+ '\nEach needs a regression test, or — if the mutated code genuinely means the same thing\n' +
271
+ '— an `equivalent` note on the mutant saying why it cannot be killed.',
272
+ );
273
+ return 1;
274
+ }
275
+
276
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
277
+ process.exit(main(process.argv.slice(2)));
278
+ }