@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.
- package/.claude/commands/adr.md +3 -2
- package/.claude/commands/init-method.md +38 -13
- package/.claude/commands/wrap-up.md +25 -3
- package/CHANGELOG.md +86 -0
- package/README.md +139 -6
- package/docs/quality-bar.md +169 -0
- package/docs/releasing.md +84 -0
- package/package.json +5 -1
- package/scripts/build-changelog.mjs +194 -0
- package/scripts/check-mutants.mjs +278 -0
- package/scripts/init-method.mjs +245 -29
- package/scripts/lint-docs.mjs +21 -2
- package/templates/CLAUDE.md +8 -4
package/scripts/init-method.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Installs the method kit into a git repo (ADR-0006). Dry-run by default.
|
|
3
3
|
//
|
|
4
|
-
// node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update]
|
|
4
|
+
// node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update [--force]]
|
|
5
5
|
//
|
|
6
6
|
// The load-bearing rule lives in installHook(): the pre-commit hook is linked ONLY
|
|
7
7
|
// against a corpus the linter calls clean. An unwired scripts/*-verify.mjs is an R11
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// documents it would report "0 document(s) — ok" (the reason rule 10 exists), certifying
|
|
13
13
|
// an install that checks nothing.
|
|
14
14
|
|
|
15
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync } from 'node:fs';
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, lstatSync, statSync, accessSync, constants as fsConstants, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync } from 'node:fs';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
16
17
|
import { join, dirname, relative } from 'node:path';
|
|
17
18
|
import { fileURLToPath } from 'node:url';
|
|
18
19
|
|
|
@@ -34,17 +35,49 @@ const VENDORED = [
|
|
|
34
35
|
|
|
35
36
|
const COMMANDS_DIR = '.claude/commands';
|
|
36
37
|
|
|
38
|
+
/** Prose vendored alongside the commands and under the same rules: adaptable, kept on
|
|
39
|
+
* update unless untouched. The quality bar is the standard a slice's Definition of Done
|
|
40
|
+
* is measured against, so it has to reach the repo the Definition of Done lives in
|
|
41
|
+
* (ADR-0024). A Python repo cutting the rule about `any` is use, not drift. */
|
|
42
|
+
export const ADAPTABLE_DOCS = ['docs/quality-bar.md'];
|
|
43
|
+
|
|
37
44
|
/**
|
|
38
|
-
*
|
|
45
|
+
* Everything vendored under the adaptable rules (ADR-0023) — same target path as toolkit
|
|
46
|
+
* path, which is what lets one classification serve both `--update` and `--check`.
|
|
39
47
|
*
|
|
40
|
-
*
|
|
41
|
-
* anyone remembering to extend an array here.
|
|
48
|
+
* Commands are read from disk rather than listed, so a new command reaches installed repos
|
|
49
|
+
* without anyone remembering to extend an array here.
|
|
42
50
|
*/
|
|
43
|
-
const
|
|
44
|
-
readdirSync(join(toolkit, COMMANDS_DIR))
|
|
51
|
+
const adaptableFiles = (toolkit) => [
|
|
52
|
+
...readdirSync(join(toolkit, COMMANDS_DIR))
|
|
45
53
|
.filter((name) => name.endsWith('.md'))
|
|
46
54
|
.sort()
|
|
47
|
-
.map((name) => `${COMMANDS_DIR}/${name}`)
|
|
55
|
+
.map((name) => `${COMMANDS_DIR}/${name}`),
|
|
56
|
+
...ADAPTABLE_DOCS,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What the toolkit last handed this repo: vendored adaptable path → SHA-256 of the content
|
|
61
|
+
* written there (ADR-0023).
|
|
62
|
+
*
|
|
63
|
+
* The JSON key is still `commands`, though the set now includes `docs/quality-bar.md`
|
|
64
|
+
* (ADR-0024). Renaming it would mean bumping PROVENANCE_VERSION, and an unrecognised
|
|
65
|
+
* version is deliberately read as *no record at all* — so a cosmetic rename would
|
|
66
|
+
* reclassify every command in every installed repo as `unknown` on the next run. The
|
|
67
|
+
* inaccuracy is cheaper than that, and this comment is the fix.
|
|
68
|
+
*
|
|
69
|
+
* Without it an update sees two states where three are needed — a stale copy of an older
|
|
70
|
+
* release and a deliberate local adaptation are the same observation, "differs from the
|
|
71
|
+
* toolkit", and the only safe reading of that is the destructive one. Committed, not
|
|
72
|
+
* ignored: a fresh clone missing it classifies every command as unreconciled.
|
|
73
|
+
*/
|
|
74
|
+
const PROVENANCE = '.claude/.stele-vendored.json';
|
|
75
|
+
|
|
76
|
+
/** Bumped only when the record's shape changes; an unrecognised version is treated as no
|
|
77
|
+
* record at all, which keeps every command rather than overwriting it. */
|
|
78
|
+
const PROVENANCE_VERSION = 1;
|
|
79
|
+
|
|
80
|
+
const digest = (text) => createHash('sha256').update(text).digest('hex');
|
|
48
81
|
|
|
49
82
|
/** Scaffolded once and never overwritten: target path ← template path. */
|
|
50
83
|
const SCAFFOLD = [
|
|
@@ -109,6 +142,76 @@ function isSymlink(path) {
|
|
|
109
142
|
}
|
|
110
143
|
}
|
|
111
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Managed paths in the target that exist but are not a file this tool can read and write.
|
|
147
|
+
*
|
|
148
|
+
* `existsSync` answers the wrong question twice, and every managed path lives in a repo we
|
|
149
|
+
* do not control. It is **true** for a directory, so the read crashes with an `EISDIR` that
|
|
150
|
+
* names `readFileSync` rather than the offending path; it is **false** for a symlink that
|
|
151
|
+
* does not resolve, so the path reads as *absent* — `--check` calls it missing and `--apply`
|
|
152
|
+
* writes straight through the link. Refusing by name is the fix, for the reason the hook
|
|
153
|
+
* install refuses rather than warns (ADR-0006): a stack trace tells the operator nothing
|
|
154
|
+
* about which file is wrong.
|
|
155
|
+
*
|
|
156
|
+
* `.git/hooks/pre-commit` is deliberately a symlink and is judged by installHook(), not here.
|
|
157
|
+
*/
|
|
158
|
+
function unusablePaths({ target, toolkit }) {
|
|
159
|
+
const managed = [
|
|
160
|
+
...SCAFFOLD.map(([dest]) => dest),
|
|
161
|
+
...VENDORED.map(([dest]) => dest),
|
|
162
|
+
...adaptableFiles(toolkit),
|
|
163
|
+
join('adr', 'INDEX.md'),
|
|
164
|
+
PROVENANCE,
|
|
165
|
+
FRAMEWORK_CONFIG,
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
return managed.flatMap((dest) => {
|
|
169
|
+
const path = join(target, dest);
|
|
170
|
+
let link;
|
|
171
|
+
try {
|
|
172
|
+
link = lstatSync(path, { throwIfNoEntry: false });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
// throwIfNoEntry suppresses ENOENT and nothing else. ENOTDIR here means an ancestor
|
|
175
|
+
// exists as a file, so nothing at this path can be read, written or created — and
|
|
176
|
+
// pointing at the leaf would send the operator to the wrong file.
|
|
177
|
+
return [{ path, kind: error.code === 'ENOTDIR'
|
|
178
|
+
? 'unreachable — a parent of it exists as a file rather than a directory'
|
|
179
|
+
: `cannot be inspected (${error.code})` }];
|
|
180
|
+
}
|
|
181
|
+
if (link === undefined) return []; // absent: the ordinary case
|
|
182
|
+
if (link.isFile()) return whenUnreadable(path);
|
|
183
|
+
if (!link.isSymbolicLink()) {
|
|
184
|
+
return [{ path, kind: link.isDirectory() ? 'a directory' : 'not a regular file' }];
|
|
185
|
+
}
|
|
186
|
+
// A symlink to a real file reads and writes fine, so it stays supported. statSync
|
|
187
|
+
// throws ELOOP on a cycle, which throwIfNoEntry does not suppress — the code it
|
|
188
|
+
// carries goes into the report rather than being discarded.
|
|
189
|
+
try {
|
|
190
|
+
if (statSync(path).isFile()) return whenUnreadable(path);
|
|
191
|
+
return [{ path, kind: `a symlink to ${readlinkSync(path)}, which is not a file` }];
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return [{ path, kind: `a symlink to ${readlinkSync(path)} that cannot be resolved (${error.code})` }];
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A file whose type is fine but whose permissions are not: `lstat` succeeds on a `chmod 000`
|
|
200
|
+
* file, and the read that follows throws a bare EACCES.
|
|
201
|
+
*
|
|
202
|
+
* Only readability is checked. Every managed path that exists is read unconditionally, while
|
|
203
|
+
* writes are conditional — so refusing on write permission too would reject a read-only
|
|
204
|
+
* vendored file that already matches the toolkit and needs no write at all.
|
|
205
|
+
*/
|
|
206
|
+
function whenUnreadable(path) {
|
|
207
|
+
try {
|
|
208
|
+
accessSync(path, fsConstants.R_OK);
|
|
209
|
+
return [];
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return [{ path, kind: `unreadable (${error.code})` }];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
112
215
|
/** The linter's error findings for a repo.
|
|
113
216
|
* Always the repo ROOT: pointed at a subdirectory holding no documents the linter
|
|
114
217
|
* reports "0 document(s) — ok", certifying an install that checks nothing (rule 10). */
|
|
@@ -133,38 +236,123 @@ function vendor({ target, toolkit, apply, report }) {
|
|
|
133
236
|
}
|
|
134
237
|
mkdirSync(dirname(to), { recursive: true });
|
|
135
238
|
copyFileSync(from, to);
|
|
136
|
-
report('wrote', to, `${verb}
|
|
239
|
+
report('wrote', to, `${verb === 'copy' ? 'copied' : 'updated'} from toolkit`);
|
|
137
240
|
}
|
|
138
241
|
}
|
|
139
242
|
|
|
140
243
|
/**
|
|
141
|
-
*
|
|
244
|
+
* The recorded digests, or `{}` when there is no usable record.
|
|
245
|
+
*
|
|
246
|
+
* A record we cannot read is reported and then treated as absent. That is the safe
|
|
247
|
+
* direction and not a silenced error: every command classifies as `unknown`, so nothing is
|
|
248
|
+
* overwritten and the operator sees why (ADR-0023).
|
|
249
|
+
*/
|
|
250
|
+
export function readProvenance(target, report) {
|
|
251
|
+
const path = join(target, PROVENANCE);
|
|
252
|
+
if (!existsSync(path)) return {};
|
|
253
|
+
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(read(path));
|
|
257
|
+
} catch (error) {
|
|
258
|
+
report('problem', path, `unreadable (${error.message}) — treating every command as unreconciled, so none will be overwritten. Delete it to start a fresh record.`);
|
|
259
|
+
return {};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const commands = parsed?.commands;
|
|
263
|
+
if (parsed?.version !== PROVENANCE_VERSION || typeof commands !== 'object' || commands === null) {
|
|
264
|
+
report('problem', path, `unrecognised shape (expected version ${PROVENANCE_VERSION}) — treating every command as unreconciled, so none will be overwritten.`);
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
return commands;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function writeProvenance(target, commands) {
|
|
271
|
+
const path = join(target, PROVENANCE);
|
|
272
|
+
const ordered = Object.fromEntries(Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)));
|
|
273
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
274
|
+
writeFileSync(path, `${JSON.stringify({ version: PROVENANCE_VERSION, commands: ordered }, null, 2)}\n`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What an installed command is, relative to the toolkit and to what we last handed over.
|
|
279
|
+
*
|
|
280
|
+
* `stale` and `adapted` are the two states the old boolean could not tell apart, and the
|
|
281
|
+
* whole of ADR-0023 is the ability to name them separately. `unknown` is a differing file
|
|
282
|
+
* with no record — an install predating the record — which is kept, because assuming
|
|
283
|
+
* permission to overwrite is exactly the incident.
|
|
284
|
+
*
|
|
285
|
+
* @returns {'absent'|'current'|'stale'|'adapted'|'unknown'}
|
|
286
|
+
*/
|
|
287
|
+
export function classifyVendored({ targetText, toolkitText, recordedDigest }) {
|
|
288
|
+
if (targetText === null) return 'absent';
|
|
289
|
+
if (targetText === toolkitText) return 'current';
|
|
290
|
+
if (recordedDigest === undefined) return 'unknown';
|
|
291
|
+
return digest(targetText) === recordedDigest ? 'stale' : 'adapted';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const KEPT_REASON = {
|
|
295
|
+
stale: 'behind the toolkit but unmodified here — `--update` takes the new version',
|
|
296
|
+
adapted: 'adapted locally — kept; `--update --force` discards the adaptation',
|
|
297
|
+
unknown: 'differs from the toolkit and predates the vendoring record, so it cannot be told from a local adaptation — kept. Reconcile it once by hand, or `--update --force` to take the toolkit version',
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Slash commands and the quality bar — prose, and therefore adaptable (ADR-0023, ADR-0024).
|
|
142
302
|
*
|
|
143
303
|
* Copy-if-absent, unlike vendor(): a repo that has tailored `/slice` to its own workflow
|
|
144
|
-
* must not have that overwritten
|
|
145
|
-
*
|
|
304
|
+
* must not have that overwritten. `--update` additionally refreshes anything the repo has
|
|
305
|
+
* not touched, and only `--force` discards an adaptation (ADR-0023).
|
|
146
306
|
*/
|
|
147
|
-
function
|
|
148
|
-
|
|
307
|
+
function vendorAdaptable({ target, toolkit, apply, update, force, report }) {
|
|
308
|
+
const recorded = readProvenance(target, report);
|
|
309
|
+
const learned = { ...recorded };
|
|
310
|
+
let changed = false;
|
|
311
|
+
|
|
312
|
+
for (const path of adaptableFiles(toolkit)) {
|
|
149
313
|
const to = join(target, path);
|
|
150
|
-
const
|
|
151
|
-
|
|
314
|
+
const toolkitText = read(join(toolkit, path));
|
|
315
|
+
const state = classifyVendored({
|
|
316
|
+
targetText: existsSync(to) ? read(to) : null,
|
|
317
|
+
toolkitText,
|
|
318
|
+
recordedDigest: recorded[path],
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// An up-to-date command is how an install predating the record acquires one: its bytes
|
|
322
|
+
// ARE the toolkit's, so the digest is known without having written anything.
|
|
323
|
+
if (state === 'current') {
|
|
152
324
|
report('ok', to, 'current');
|
|
325
|
+
if (recorded[path] !== digest(toolkitText)) {
|
|
326
|
+
learned[path] = digest(toolkitText);
|
|
327
|
+
changed = true;
|
|
328
|
+
}
|
|
153
329
|
continue;
|
|
154
330
|
}
|
|
155
|
-
|
|
156
|
-
|
|
331
|
+
|
|
332
|
+
const takeover = state === 'absent' || (update && (state === 'stale' || force));
|
|
333
|
+
if (!takeover) {
|
|
334
|
+
report('keep', to, KEPT_REASON[state]);
|
|
157
335
|
continue;
|
|
158
336
|
}
|
|
159
|
-
|
|
337
|
+
|
|
338
|
+
const verb = state === 'absent' ? 'copy' : 'overwrite';
|
|
160
339
|
if (!apply) {
|
|
161
340
|
report('would', to, `${verb} from toolkit`);
|
|
162
341
|
continue;
|
|
163
342
|
}
|
|
164
343
|
mkdirSync(dirname(to), { recursive: true });
|
|
165
|
-
|
|
344
|
+
writeFileSync(to, toolkitText);
|
|
345
|
+
learned[path] = digest(toolkitText);
|
|
346
|
+
changed = true;
|
|
166
347
|
report('wrote', to, `${verb === 'copy' ? 'copied' : 'overwritten'} from toolkit`);
|
|
167
348
|
}
|
|
349
|
+
|
|
350
|
+
// Never on a dry run: the record describes what is on disk, and writing it while writing
|
|
351
|
+
// nothing else would claim we handed over files we did not.
|
|
352
|
+
if (apply && changed) {
|
|
353
|
+
writeProvenance(target, learned);
|
|
354
|
+
report('wrote', join(target, PROVENANCE), 'recorded what was vendored, so a later --update can tell a stale command from an adapted one');
|
|
355
|
+
}
|
|
168
356
|
}
|
|
169
357
|
|
|
170
358
|
function scaffold({ target, toolkit, apply, report }) {
|
|
@@ -302,12 +490,22 @@ function check({ target, toolkit, report }) {
|
|
|
302
490
|
else report('ok', to, 'current');
|
|
303
491
|
}
|
|
304
492
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
493
|
+
// An *adaptation* is prose a repo may legitimately own, so it stays informational. A
|
|
494
|
+
// command merely behind and unmodified is a repo missing a fix, which is a problem — the
|
|
495
|
+
// record is what lets --check tell those two apart at all (ADR-0023, superseding ADR-0007,
|
|
496
|
+
// under which no command difference could be counted and so a shipped defect in one was
|
|
497
|
+
// invisible in every installed repo).
|
|
498
|
+
const recorded = readProvenance(target, report);
|
|
499
|
+
for (const path of adaptableFiles(toolkit)) {
|
|
308
500
|
const to = join(target, path);
|
|
309
|
-
|
|
310
|
-
|
|
501
|
+
const state = classifyVendored({
|
|
502
|
+
targetText: existsSync(to) ? read(to) : null,
|
|
503
|
+
toolkitText: read(join(toolkit, path)),
|
|
504
|
+
recordedDigest: recorded[path],
|
|
505
|
+
});
|
|
506
|
+
if (state === 'absent') report('missing', to, 'not installed — run /init-method --apply');
|
|
507
|
+
else if (state === 'stale') report('problem', to, 'behind the toolkit and unmodified here — run /init-method --update');
|
|
508
|
+
else if (state !== 'current') report('local', to, KEPT_REASON[state]);
|
|
311
509
|
else report('ok', to, 'current');
|
|
312
510
|
}
|
|
313
511
|
|
|
@@ -340,9 +538,10 @@ function check({ target, toolkit, report }) {
|
|
|
340
538
|
* @param {string} [options.toolkit] this kit's root (overridable for tests)
|
|
341
539
|
* @param {'install'|'check'|'update'} [options.mode]
|
|
342
540
|
* @param {boolean} [options.apply] false = dry run, the default
|
|
541
|
+
* @param {boolean} [options.force] update only: discard local command adaptations too
|
|
343
542
|
* @returns {{actions: Array<{status: string, path: string, message: string}>, problems: number}}
|
|
344
543
|
*/
|
|
345
|
-
export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false }) {
|
|
544
|
+
export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false, force = false }) {
|
|
346
545
|
const actions = [];
|
|
347
546
|
const report = (status, path, message) => actions.push({ status, path, message });
|
|
348
547
|
|
|
@@ -351,16 +550,27 @@ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply
|
|
|
351
550
|
return { actions, problems: 1 };
|
|
352
551
|
}
|
|
353
552
|
|
|
553
|
+
// Before anything reads or writes, and in every mode: a partial install is worse than no
|
|
554
|
+
// install, and --check cannot judge a state it would crash on. Every offending path is
|
|
555
|
+
// named, not just the first, so one re-run is enough to clear them.
|
|
556
|
+
const unusable = unusablePaths({ target, toolkit });
|
|
557
|
+
if (unusable.length > 0) {
|
|
558
|
+
for (const { path, kind } of unusable) {
|
|
559
|
+
report('problem', path, `${kind} — this tool reads and writes it as a file. Move or remove it, then re-run.`);
|
|
560
|
+
}
|
|
561
|
+
return { actions, problems: unusable.length };
|
|
562
|
+
}
|
|
563
|
+
|
|
354
564
|
if (mode === 'check') {
|
|
355
565
|
check({ target, toolkit, report });
|
|
356
566
|
} else if (mode === 'update') {
|
|
357
567
|
vendor({ target, toolkit, apply, report });
|
|
358
|
-
|
|
568
|
+
vendorAdaptable({ target, toolkit, apply, update: true, force, report });
|
|
359
569
|
if (apply) lintAfterUpdate({ target, report });
|
|
360
570
|
} else {
|
|
361
571
|
scaffold({ target, toolkit, apply, report });
|
|
362
572
|
vendor({ target, toolkit, apply, report });
|
|
363
|
-
|
|
573
|
+
vendorAdaptable({ target, toolkit, apply, update: false, force: false, report });
|
|
364
574
|
buildIndex({ target, apply, report });
|
|
365
575
|
installHook({ target, apply, report });
|
|
366
576
|
}
|
|
@@ -374,8 +584,14 @@ function main(argv) {
|
|
|
374
584
|
const target = positional[0] ?? process.cwd();
|
|
375
585
|
const mode = flags.has('--check') ? 'check' : flags.has('--update') ? 'update' : 'install';
|
|
376
586
|
const apply = flags.has('--apply');
|
|
587
|
+
const force = flags.has('--force');
|
|
588
|
+
|
|
589
|
+
if (force && mode !== 'update') {
|
|
590
|
+
console.error('--force only means anything with --update: it discards local command adaptations.');
|
|
591
|
+
return 1;
|
|
592
|
+
}
|
|
377
593
|
|
|
378
|
-
const { actions, problems } = initMethod({ target, mode, apply });
|
|
594
|
+
const { actions, problems } = initMethod({ target, mode, apply, force });
|
|
379
595
|
|
|
380
596
|
console.log(`\n${target} — /init-method ${mode}${apply || mode === 'check' ? '' : ' (dry run)'}`);
|
|
381
597
|
for (const a of actions) {
|
package/scripts/lint-docs.mjs
CHANGED
|
@@ -77,7 +77,13 @@ const isId = (v) => /^\d{1,4}$/.test(String(v).trim());
|
|
|
77
77
|
function isCalendarDate(v) {
|
|
78
78
|
const text = String(v).trim();
|
|
79
79
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
|
|
80
|
-
|
|
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);
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
// Citations, bare or qualified (ADR-0009). A leading `<repo>:` says the decision lives in
|
|
@@ -86,6 +92,19 @@ function isCalendarDate(v) {
|
|
|
86
92
|
// resolving locally as before.
|
|
87
93
|
const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
|
|
88
94
|
|
|
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
|
+
|
|
89
108
|
/**
|
|
90
109
|
* Ids cited in `text` that this repo is expected to own — cross-repo refs skipped.
|
|
91
110
|
*
|
|
@@ -96,7 +115,7 @@ const CITATION = /(?:([A-Za-z][\w.-]*):)?ADR[-\s](\d{1,4})/g;
|
|
|
96
115
|
* `stele:ADR-0005` verified in the one corpus that can verify it.
|
|
97
116
|
*/
|
|
98
117
|
function* localCitations(text, selfRepo = null) {
|
|
99
|
-
for (const [, repo, id] of text.matchAll(CITATION)) {
|
|
118
|
+
for (const [, repo, id] of citableText(text).matchAll(CITATION)) {
|
|
100
119
|
if (repo === undefined || (selfRepo !== null && repo === selfRepo)) yield normId(id);
|
|
101
120
|
}
|
|
102
121
|
}
|
package/templates/CLAUDE.md
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
Stack: {{STACK}}
|
|
6
6
|
|
|
7
|
-
General working practices — quality bar, commit hygiene, delegation,
|
|
8
|
-
— live in
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
General working practices — quality bar, testing standard, commit hygiene, delegation,
|
|
8
|
+
correction, language — live in [`docs/quality-bar.md`](docs/quality-bar.md) and apply here
|
|
9
|
+
without being restated. It ships with the method and is vendored into this repo, so it is
|
|
10
|
+
yours to adapt; an update keeps what you changed (stele:ADR-0024).
|
|
11
|
+
|
|
12
|
+
This file carries only what is specific to **this** repo. Restating a rule from the bar here
|
|
13
|
+
would create a second copy with no sync path, which is the failure stele:ADR-0005 exists to
|
|
14
|
+
prevent.
|
|
11
15
|
|
|
12
16
|
## 1. Document taxonomy — four kinds
|
|
13
17
|
|