@sabaiway/agent-workflow-memory 4.0.0 → 4.1.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/CHANGELOG.md +61 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/archive-decisions.mjs +340 -15
- package/references/scripts/archive-decisions.test.mjs +522 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,67 @@ All notable changes to the memory substrate. Versions are this **package's** npm
|
|
|
4
4
|
they are distinct from the **deployment-lineage** stamp written into a project's
|
|
5
5
|
`docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
|
|
6
6
|
|
|
7
|
+
## 4.1.0 — the ADR rotation carries your inbound links with it (AD-087)
|
|
8
|
+
|
|
9
|
+
Rotating `decisions.md` used to be a link-breaking event: `archive-decisions.mjs` moved ADR
|
|
10
|
+
blocks out of the HOT window into per-record `adr/` files while every `decisions.md#ad-NNN…`
|
|
11
|
+
link elsewhere in your `docs/ai/` kept pointing at a heading that was no longer there — and
|
|
12
|
+
neither `--check` nor `--dry-run` would say a word about it. One deployed project measured 114
|
|
13
|
+
such inbound anchors (23 aimed at the first tier a rotation would move) and had raised its HOT
|
|
14
|
+
cap twice specifically so the rotation never runs. A safety valve nobody dares open is not
|
|
15
|
+
shipped; this release makes the crossing carry its links:
|
|
16
|
+
|
|
17
|
+
- **Rotate and `--migrate --apply` rewrite inbound links.** Every non-fenced
|
|
18
|
+
`decisions.md#ad-NNN…` link under `docs/ai/` whose id is in the moved set is rewritten to the
|
|
19
|
+
record file. The heading-slug fragment is preserved — each record holds the verbatim
|
|
20
|
+
`## AD-NNN — title` block, so the same anchor resolves — and the leading relative prefix
|
|
21
|
+
survives (`adr/` is a sibling of `decisions.md`). Migrate additionally rewrites links into
|
|
22
|
+
the retired monolith archives it deletes, each target computed relative to its linking file.
|
|
23
|
+
- **A conservation invariant guards every rewrite.** The rewrite set is computed and verified
|
|
24
|
+
before the run's first write: every moved-id link rewritten, every other byte identical,
|
|
25
|
+
per-file and total matched-link counts equal before and after — any mismatch exits 1 with
|
|
26
|
+
nothing written.
|
|
27
|
+
- **The write order is pinned crash-safe:** records → inbound rewrites → HOT rewrite / monolith
|
|
28
|
+
removal. Every interrupted intermediate state re-runs to completion; previously a crash after
|
|
29
|
+
the HOT write re-ran as «nothing to rotate» with the orphans permanent.
|
|
30
|
+
- **`--check` now proves reference integrity.** A `decisions.md#ad-NNN…` anchor whose id has
|
|
31
|
+
left the HOT window (stale even when the id exists as an archived record) and an
|
|
32
|
+
`adr/AD-NNN-….md` link naming no existing record file both fail exit 1, every violation
|
|
33
|
+
listed with `file:line`. A tree with matches but no ADR substrate fails too; the early SKIP
|
|
34
|
+
remains only for trees with zero matches.
|
|
35
|
+
- **`--dry-run` prints the rewrite set** (`file:line`, old target → new target) alongside the
|
|
36
|
+
move set and writes nothing; plain `--migrate` prints the same summary, and
|
|
37
|
+
`--migrate --apply --dry-run` refuses loudly instead of silently writing.
|
|
38
|
+
- **Fail-closed edges, each red-tested:** a rewrite-form link targeting a moved id inside the
|
|
39
|
+
ADR corpus itself (HOT preamble or block, a record, a monolith tier) refuses pre-write with
|
|
40
|
+
`file:line` — convert the link (e.g. to `[[AD-NNN]]`) and re-run; a HOT block ABOUT to move
|
|
41
|
+
that itself links a retained id or carries a record-form link refuses pre-write (the verbatim
|
|
42
|
+
move would silently break the link from inside the record); symlinked `.md` files and
|
|
43
|
+
directories in the scan scope refuse loudly; an unparseable scanned file (unclosed fence)
|
|
44
|
+
aborts either write path before any write; a stale two-pass snapshot refuses instead of
|
|
45
|
+
rewriting from old bytes.
|
|
46
|
+
|
|
47
|
+
**Stated limitations:** the scan is line-scoped (a link hand-wrapped across a line break is not
|
|
48
|
+
matched — the same accepted residual as the preamble contract), inline code is not tracked (a
|
|
49
|
+
backtick-wrapped link counts as live), and YAML frontmatter is opaque metadata — a link inside
|
|
50
|
+
it is neither rewritten nor checked, and is preserved byte-exactly on every write. The boundary
|
|
51
|
+
is `docs/ai/` — links in README or agent entry points outside it are neither rewritten nor
|
|
52
|
+
checked.
|
|
53
|
+
|
|
54
|
+
**Upgrading:** the tool itself has nothing to reconfigure — the rewrite is additive, and on a
|
|
55
|
+
healthy tree whose links resolve the new assertions stay green. **Reaching your deployment:** a
|
|
56
|
+
fresh Node-project bootstrap through the memory 4.1.0 skill — or a clean-layout upgrade where
|
|
57
|
+
the pair is entirely absent — copies the fixed pair from this package (a No-Node project skips
|
|
58
|
+
the scripts; a legacy-monolith layout goes through the consented migration ask); a normal
|
|
59
|
+
upgrade of an already-deployed pair preserves it byte-for-byte (local edits are never
|
|
60
|
+
clobbered — drift repair belongs to a lineage migration), so an existing deployment is NOT
|
|
61
|
+
auto-refreshed by this release; a verified drift-safe refresh lane is queued family work. If
|
|
62
|
+
`--check` then reports a `dead ADR anchor` or `dead ADR record link`, those are real orphans it
|
|
63
|
+
found — each line names the file, line and dead target; fix or remove the link, or re-point it
|
|
64
|
+
at an existing `adr/` record (or the `[[AD-NNN]]` form); with the links live, rotation is safe
|
|
65
|
+
to run again, so a cap raised only to avoid it can come back down. Any other `--check` red
|
|
66
|
+
carries its own printed diagnosis.
|
|
67
|
+
|
|
7
68
|
## 4.0.0 — the archivers stop reporting green on files they did not understand (AD-084)
|
|
8
69
|
|
|
9
70
|
> ### ⚠ BREAKING — the rotation gates fail CLOSED now
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-memory
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '4.
|
|
6
|
+
version: '4.1.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -17,25 +17,45 @@
|
|
|
17
17
|
// plateaus at O(governing), never O(cumulative). Not a ledger.
|
|
18
18
|
//
|
|
19
19
|
// Modes:
|
|
20
|
-
// (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records,
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
20
|
+
// (default) rotate: explode the oldest HOT entries beyond the cap into adr/ records,
|
|
21
|
+
// REWRITE the inbound `decisions.md#ad-NNN…` links across docs/ai/** to the
|
|
22
|
+
// record files (fragment preserved), then regenerate the navigator +
|
|
23
|
+
// docs/ai/index.md (item (h)). Monoliths present → a LOUD legacy-guard refusal
|
|
24
|
+
// ("run --migrate first"); it never half-explodes.
|
|
25
|
+
// --check verify HOT cap + adr/ store integrity + the legacy guard + navigator freshness
|
|
26
|
+
// + reference integrity (every inbound `decisions.md#ad-NNN…` anchor must
|
|
27
|
+
// resolve to the CURRENT HOT window — an archived id is stale, not resolving —
|
|
28
|
+
// and every `adr/AD-NNN-slug.md` link must name an existing record FILE); exit 1
|
|
29
|
+
// listing every breach with file:line. A STATED skip (exit 0) only when NO ADR
|
|
30
|
+
// substrate exists (neither decisions.md NOR docs/ai/adr/) AND the docs tree
|
|
31
|
+
// carries no matching ADR reference.
|
|
26
32
|
// --migrate one-time retirement of the 3-tier monoliths → per-file adr/ records. Dry-run by
|
|
27
|
-
// default (prints the file set + id diff + conservation proof
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
33
|
+
// default (prints the file set + id diff + conservation proof + the planned
|
|
34
|
+
// inbound-rewrite set, writes nothing).
|
|
35
|
+
// --migrate --apply writes a durable pre-delete snapshot, writes the records, rewrites the inbound
|
|
36
|
+
// links (monolith-form anchors too, targets computed relative to the linking
|
|
37
|
+
// file) and the retained HOT preamble, and only THEN removes the monoliths —
|
|
38
|
+
// gated on conservation AND the snapshot. Re-run skips byte-identical records
|
|
39
|
+
// (crash-resumable). Combining with --dry-run is a loud pre-spend refusal.
|
|
31
40
|
// --write-navigator regenerate docs/ai/adr/log.md AND re-trigger the index regen (the authoring /
|
|
32
41
|
// supersession write-side; the --write-index analog). With --dry-run it runs
|
|
33
42
|
// EXACTLY the same validation (parse, half-migrated guard, store integrity) and
|
|
34
43
|
// stops before every write — the read-only preflight a guarded caller needs to
|
|
35
44
|
// earn a go-ahead without risking a partial write.
|
|
36
|
-
// --dry-run print the planned rotation move-set,
|
|
45
|
+
// --dry-run print the planned rotation move-set + inbound-rewrite set (file:line, old
|
|
46
|
+
// target → new target), change nothing.
|
|
37
47
|
// --today=YYYY-MM-DD pin the lastUpdated stamp (tests / reproducible runs).
|
|
38
48
|
//
|
|
49
|
+
// Reference-scan boundary + stated limitations: the inbound-link scan covers docs/ai/** ONLY (links
|
|
50
|
+
// in README / agent entry points are out of scope); the ADR corpus surfaces themselves are NEVER
|
|
51
|
+
// rewritten — a rewrite-form link to a moved id inside decisions.md, an adr/ record or a monolith
|
|
52
|
+
// tier is a loud pre-write refusal (convert it, e.g. to the [[AD-NNN]] form, then re-run). Fenced
|
|
53
|
+
// regions never count; inline code is NOT tracked (a backtick-wrapped link is treated as live);
|
|
54
|
+
// matching is line-scoped — a link hand-wrapped across a line break is not matched (the same
|
|
55
|
+
// accepted residual as the hand-wrapped preamble continuation line below); and frontmatter is
|
|
56
|
+
// opaque metadata — an ADR link inside YAML frontmatter is neither rewritten nor checked (it is
|
|
57
|
+
// preserved byte-exactly on every write).
|
|
58
|
+
//
|
|
39
59
|
// FAIL-LOUD invariants (the Issue-009 lesson — never silently glue an entry to the previous body):
|
|
40
60
|
// • every `## ` heading MUST parse canonically as `## AD-NNN — <title>` (AD-\d{3,}) — a malformed
|
|
41
61
|
// heading is exit 1 naming file:line, never a silent merge;
|
|
@@ -43,6 +63,10 @@
|
|
|
43
63
|
// • migration is CONSERVATION-checked before any destructive write: the full multiset
|
|
44
64
|
// {id → sha256(verbatim block)} across the OLD monoliths equals {retained-HOT ∪ written records};
|
|
45
65
|
// a drop / renumber / edited-block / stray adr record fails exit 1 before any remove or overwrite;
|
|
66
|
+
// • inbound-link rewrites are CONSERVATION-checked before the run's first write: every moved-id
|
|
67
|
+
// link rewritten, every other byte of every scanned file identical — any mismatch is exit 1 with
|
|
68
|
+
// nothing written; the cross-file write order is pinned (records → inbound rewrites → HOT rewrite
|
|
69
|
+
// / monolith removal) so every interrupted state re-runs to completion;
|
|
46
70
|
// • a legacy monolith still on disk fails LOUD on default/--check (it is a half-migrated tree).
|
|
47
71
|
//
|
|
48
72
|
// docs/ai here is git-ignored, so the monoliths were NEVER committed (no VCS recovery) — every
|
|
@@ -51,8 +75,8 @@
|
|
|
51
75
|
//
|
|
52
76
|
// Dependency-free, Node >= 22. Deployed into a consumer's scripts/ like its siblings.
|
|
53
77
|
|
|
54
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
|
|
55
|
-
import { dirname, resolve, join } from 'node:path';
|
|
78
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
79
|
+
import { dirname, resolve, join, posix } from 'node:path';
|
|
56
80
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
57
81
|
import { spawnSync } from 'node:child_process';
|
|
58
82
|
import { createHash } from 'node:crypto';
|
|
@@ -275,6 +299,265 @@ export const verifyConservation = (oldItems, newItems) => {
|
|
|
275
299
|
}
|
|
276
300
|
};
|
|
277
301
|
|
|
302
|
+
// ── inbound reference integrity (the anchor-orphan fix) ─────────────────────────────────
|
|
303
|
+
//
|
|
304
|
+
// Match contract (line-scoped; fenced lines never count): a textual occurrence of the
|
|
305
|
+
// source-file-plus-fragment form. The lookbehind rejects a longer filename
|
|
306
|
+
// (`other-decisions.md#…` is a different file, never a match); a bare `#ad-NNN` has no file part.
|
|
307
|
+
const DOCS_AI_REL = 'docs/ai';
|
|
308
|
+
const HOT_LINK_RE = /(?<![\w.-])decisions\.md#(ad-(\d{3,})[\w-]*)/g;
|
|
309
|
+
const MONOLITH_LINK_RE = /(?<![\w.-])(?:\.\.?\/)*(?:history\/)?decisions-archive(?:-early)?\.md#(ad-(\d{3,})[\w-]*)/g;
|
|
310
|
+
// The right boundary mirrors the lookbehind: `…md.bak` / `…md/child` are DIFFERENT targets, never
|
|
311
|
+
// a match for the base record (the exact-filename contract cuts both ways).
|
|
312
|
+
const RECORD_LINK_RE = /(?<![\w.-])adr\/(AD-(\d{3,})-[A-Za-z0-9-]+\.md)(?![\w./-])/g;
|
|
313
|
+
|
|
314
|
+
const walkDocsMarkdown = (root) => {
|
|
315
|
+
const base = resolve(root, DOCS_AI_REL);
|
|
316
|
+
if (!existsSync(base)) return [];
|
|
317
|
+
const out = [];
|
|
318
|
+
const walk = (dir, relDir) => {
|
|
319
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
320
|
+
const rel = `${relDir}/${entry.name}`;
|
|
321
|
+
if (entry.isDirectory()) {
|
|
322
|
+
walk(join(dir, entry.name), rel);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// read/write both FOLLOW a symlink (out of the tree), and silently skipping one hides a
|
|
326
|
+
// scannable doc or a whole subtree from the reference scan — refuse loudly either way; only
|
|
327
|
+
// a symlink resolving to a plain non-markdown file stays an ignored stray.
|
|
328
|
+
if (entry.isSymbolicLink() && !entry.name.endsWith('.md')) {
|
|
329
|
+
let targetIsDirectory = false;
|
|
330
|
+
try {
|
|
331
|
+
targetIsDirectory = statSync(join(dir, entry.name)).isDirectory();
|
|
332
|
+
} catch {
|
|
333
|
+
throw fail(1, `${rel}: a dangling symlink in the scan tree — the reference scan cannot classify it; remove or materialize it, then re-run`);
|
|
334
|
+
}
|
|
335
|
+
if (targetIsDirectory) {
|
|
336
|
+
throw fail(1, `${rel}: a symlinked directory in the scan tree would hide its subtree from the reference scan — materialize or remove it, then re-run`);
|
|
337
|
+
}
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (entry.name.endsWith('.md')) {
|
|
341
|
+
if (!entry.isFile()) {
|
|
342
|
+
throw fail(1, `${rel}: a markdown name in the scan tree is not a regular file (a symlink or special file) — the reference scan never reads or writes THROUGH it; materialize or remove it, then re-run`);
|
|
343
|
+
}
|
|
344
|
+
out.push(rel);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
walk(base, DOCS_AI_REL);
|
|
349
|
+
return out;
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// REWRITE scope: docs/ai/** minus the ADR corpus surfaces (HOT, adr/ records + navigator) and the
|
|
353
|
+
// retired monoliths — those are parsed, conservation-hashed and deleted in the same run; a same-run
|
|
354
|
+
// disk rewrite would poison the crash-resume corpus-union guard and write into removed files.
|
|
355
|
+
const isRewriteScope = (rel) =>
|
|
356
|
+
rel !== HOT_REL && rel !== WARM_REL && rel !== COLD_REL && rel !== NAV_REL && !rel.startsWith(`${ADR_DIR_REL}/`);
|
|
357
|
+
|
|
358
|
+
// The corpus surfaces are never rewritten: a rewrite-form link to a MOVED id anywhere in them is a
|
|
359
|
+
// loud pre-write refusal — the operator converts the link and re-runs; verbatim blocks stay absolute.
|
|
360
|
+
const assertAdrCorpusFreeOfMovedLinks = (root, movedById, corpusRels, linkRes) => {
|
|
361
|
+
const violations = [];
|
|
362
|
+
for (const rel of corpusRels) {
|
|
363
|
+
const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
364
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
365
|
+
if (fencedLines.has(index)) continue;
|
|
366
|
+
for (const re of linkRes) {
|
|
367
|
+
for (const m of lines[index].matchAll(re)) {
|
|
368
|
+
if (movedById.has(m[2])) violations.push(`${rel}:${frontLines + index + 1}: "${m[0]}"`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (violations.length > 0) {
|
|
374
|
+
throw fail(1, `refusing pre-write: the ADR corpus itself carries rewrite-form links to a moved id — a corpus surface is never rewritten; convert each link (e.g. to the [[AD-NNN]] form), then re-run:\n ${violations.join('\n ')}`);
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// A block leaving decisions.md cannot keep a RELATIVE ADR link meaningful: the block moves
|
|
379
|
+
// verbatim (the conservation invariant forbids editing it) while its base directory changes —
|
|
380
|
+
// refuse pre-write when a TO-EXPLODE block links a RETAINED id (the moved-id complement is
|
|
381
|
+
// refused corpus-wide by assertAdrCorpusFreeOfMovedLinks) or carries ANY record-form link
|
|
382
|
+
// (valid from decisions.md, broken from inside adr/).
|
|
383
|
+
const assertMovingBlocksFreeOfRelativeAdrLinks = (root, movedIds, retainedIds) => {
|
|
384
|
+
const { frontLines, lines, fencedLines, headings } = tokenizeMarkdown(readFileSync(resolve(root, HOT_REL), 'utf8'), HOT_REL);
|
|
385
|
+
const violations = [];
|
|
386
|
+
const h2 = headings.filter((h) => h.level === 2 && HEADING_RE.test(h.text));
|
|
387
|
+
for (let i = 0; i < h2.length; i += 1) {
|
|
388
|
+
if (!movedIds.has(HEADING_RE.exec(h2[i].text)[1])) continue;
|
|
389
|
+
const end = i + 1 < h2.length ? h2[i + 1].index : lines.length;
|
|
390
|
+
for (let index = h2[i].index; index < end; index += 1) {
|
|
391
|
+
if (fencedLines.has(index)) continue;
|
|
392
|
+
for (const m of lines[index].matchAll(HOT_LINK_RE)) {
|
|
393
|
+
if (retainedIds.has(m[2])) violations.push(`${HOT_REL}:${frontLines + index + 1}: "${m[0]}"`);
|
|
394
|
+
}
|
|
395
|
+
for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
|
|
396
|
+
violations.push(`${HOT_REL}:${frontLines + index + 1}: "${m[0]}"`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (violations.length > 0) {
|
|
401
|
+
throw fail(1, `refusing pre-write: a block leaving ${HOT_REL} carries a relative ADR link (a RETAINED decisions.md#… anchor or an adr/… record link) — the block moves verbatim, so the link would change meaning inside the record; convert each link (e.g. to the [[AD-NNN]] form), then re-run:\n ${violations.join('\n ')}`);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Migrate deletes the monolith files: a monolith-form link whose id is NOT in the moved set could
|
|
406
|
+
// never point at a record after --apply (D5's "all point at records" outcome) — refuse pre-write
|
|
407
|
+
// wherever it sits (rewrite scope, HOT, records, the monolith blocks about to become records).
|
|
408
|
+
const assertNoOrphanedMonolithLinks = (root, movedById, rels) => {
|
|
409
|
+
const violations = [];
|
|
410
|
+
for (const rel of rels) {
|
|
411
|
+
const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
412
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
413
|
+
if (fencedLines.has(index)) continue;
|
|
414
|
+
for (const m of lines[index].matchAll(MONOLITH_LINK_RE)) {
|
|
415
|
+
if (!movedById.has(m[2])) violations.push(`${rel}:${frontLines + index + 1}: "${m[0]}"`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (violations.length > 0) {
|
|
420
|
+
throw fail(1, `refusing pre-write: monolith-form links target an id OUTSIDE the moved set — after --apply removes the monoliths these links could never resolve to a record; fix each link, then re-run:\n ${violations.join('\n ')}`);
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// The inbound-rewrite plan across the REWRITE scope. HOT-form links keep their leading relative
|
|
425
|
+
// prefix (adr/ is a SIBLING of decisions.md, so the same prefix reaches the record and the fragment
|
|
426
|
+
// resolves verbatim there); monolith-form targets are computed RELATIVE TO THE LINKING FILE with
|
|
427
|
+
// URL-style forward-slash separators on every platform.
|
|
428
|
+
const planInboundRewrites = (root, movedById, withMonolithForms) => {
|
|
429
|
+
const plans = [];
|
|
430
|
+
for (const rel of walkDocsMarkdown(root).filter(isRewriteScope)) {
|
|
431
|
+
const { frontmatter, frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
432
|
+
const rewrites = [];
|
|
433
|
+
const afterLines = lines.map((line, index) => {
|
|
434
|
+
if (fencedLines.has(index)) return line;
|
|
435
|
+
// Every match of every form is collected POSITIONALLY on the ORIGINAL line (the forms are
|
|
436
|
+
// textually disjoint), then the line is rebuilt by range-splicing — a negative sharing
|
|
437
|
+
// bytes with a valid link elsewhere on the line can never contaminate the rewrite or its
|
|
438
|
+
// re-derivation.
|
|
439
|
+
const matches = [];
|
|
440
|
+
for (const m of line.matchAll(HOT_LINK_RE)) {
|
|
441
|
+
const moved = movedById.get(m[2]);
|
|
442
|
+
if (moved) matches.push({ start: m.index, old: m[0], new: `adr/${moved.fileName}#${m[1]}` });
|
|
443
|
+
}
|
|
444
|
+
if (withMonolithForms) {
|
|
445
|
+
for (const m of line.matchAll(MONOLITH_LINK_RE)) {
|
|
446
|
+
const moved = movedById.get(m[2]);
|
|
447
|
+
if (moved) matches.push({ start: m.index, old: m[0], new: `${posix.relative(posix.dirname(rel), `${ADR_DIR_REL}/${moved.fileName}`)}#${m[1]}` });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (matches.length === 0) return line;
|
|
451
|
+
matches.sort((a, b) => a.start - b.start);
|
|
452
|
+
let cursor = 0;
|
|
453
|
+
let next = '';
|
|
454
|
+
for (const match of matches) {
|
|
455
|
+
if (match.start < cursor) throw fail(1, `${rel}:${frontLines + index + 1}: overlapping link matches — refusing to plan a rewrite`);
|
|
456
|
+
next += line.slice(cursor, match.start) + match.new;
|
|
457
|
+
cursor = match.start + match.old.length;
|
|
458
|
+
rewrites.push({ index, line: frontLines + index + 1, start: match.start, old: match.old, new: match.new });
|
|
459
|
+
}
|
|
460
|
+
next += line.slice(cursor);
|
|
461
|
+
return next;
|
|
462
|
+
});
|
|
463
|
+
if (rewrites.length > 0) plans.push({ rel, frontmatter, frontLines, beforeLines: lines, afterLines, rewrites });
|
|
464
|
+
}
|
|
465
|
+
return plans;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Conservation (fail-loud): re-derive the rewritten file from { before + planned rewrites } and
|
|
469
|
+
// require byte-equality — a dropped/altered link or any changed byte outside the plan is exit 1
|
|
470
|
+
// with nothing written. Exported so its failure paths are unit-testable directly.
|
|
471
|
+
export const verifyRewriteConservation = (plan) => {
|
|
472
|
+
const { rel, frontLines, beforeLines, afterLines, rewrites } = plan;
|
|
473
|
+
const refuse = (line, detail) => fail(1, `${rel}:${line}: inbound-rewrite conservation violation — ${detail}; refusing with nothing written`);
|
|
474
|
+
if (beforeLines.length !== afterLines.length) throw refuse(frontLines + 1, `the rewrite changed the line count (${beforeLines.length} → ${afterLines.length})`);
|
|
475
|
+
const byIndex = new Map();
|
|
476
|
+
for (const rw of rewrites) {
|
|
477
|
+
const list = byIndex.get(rw.index);
|
|
478
|
+
if (list) list.push(rw);
|
|
479
|
+
else byIndex.set(rw.index, [rw]);
|
|
480
|
+
}
|
|
481
|
+
for (let index = 0; index < beforeLines.length; index += 1) {
|
|
482
|
+
const fileLine = frontLines + index + 1;
|
|
483
|
+
const planned = byIndex.get(index);
|
|
484
|
+
if (!planned) {
|
|
485
|
+
if (beforeLines[index] !== afterLines[index]) throw refuse(fileLine, 'a line outside the planned rewrite set changed');
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
let expected;
|
|
489
|
+
if (planned.every((rw) => typeof rw.start === 'number')) {
|
|
490
|
+
// Range-splicing re-derivation (offsets recorded on the ORIGINAL line) — a same-bytes
|
|
491
|
+
// negative elsewhere on the line can never contaminate it.
|
|
492
|
+
const ordered = [...planned].sort((a, b) => a.start - b.start);
|
|
493
|
+
let cursor = 0;
|
|
494
|
+
expected = '';
|
|
495
|
+
for (const rw of ordered) {
|
|
496
|
+
if (rw.start < cursor) throw refuse(fileLine, 'overlapping planned rewrites');
|
|
497
|
+
if (beforeLines[index].slice(rw.start, rw.start + rw.old.length) !== rw.old) throw refuse(fileLine, `the planned link "${rw.old}" is not at its recorded offset in the source line`);
|
|
498
|
+
expected += beforeLines[index].slice(cursor, rw.start) + rw.new;
|
|
499
|
+
cursor = rw.start + rw.old.length;
|
|
500
|
+
}
|
|
501
|
+
expected += beforeLines[index].slice(cursor);
|
|
502
|
+
} else {
|
|
503
|
+
// Occurrence-based fallback for plans without offsets (the exported contract's original
|
|
504
|
+
// shape); longest-first so a link that is a textual prefix of another cannot corrupt it.
|
|
505
|
+
const unique = [...new Map(planned.map((rw) => [`${rw.old} ${rw.new}`, rw])).values()].sort((a, b) => b.old.length - a.old.length);
|
|
506
|
+
expected = beforeLines[index];
|
|
507
|
+
for (const rw of unique) {
|
|
508
|
+
if (!expected.includes(rw.old)) throw refuse(fileLine, `the planned link "${rw.old}" is absent from the source line`);
|
|
509
|
+
expected = expected.split(rw.old).join(rw.new);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (afterLines[index] !== expected) throw refuse(fileLine, 'the rewritten line diverges from the planned substitution (a link would be dropped or altered)');
|
|
513
|
+
for (const rw of planned) {
|
|
514
|
+
if (!afterLines[index].includes(rw.new)) throw refuse(fileLine, `the rewritten link "${rw.new}" is missing from the output line (a link would be dropped)`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const summarizeRewrites = (plans) =>
|
|
520
|
+
plans.flatMap((plan) => plan.rewrites.map((rw) => `${plan.rel}:${rw.line} ${rw.old} → ${rw.new}`));
|
|
521
|
+
|
|
522
|
+
export const writeInboundRewrites = (root, plans) => {
|
|
523
|
+
// Two passes (the plan is computed pre-write, D4): verify the LIVE bytes of EVERY planned file
|
|
524
|
+
// first, then write — a drift anywhere refuses with NOTHING written in the rewrite phase.
|
|
525
|
+
for (const plan of plans) {
|
|
526
|
+
if (readFileSync(resolve(root, plan.rel), 'utf8') !== `${plan.frontmatter}${plan.beforeLines.join('\n')}`) {
|
|
527
|
+
throw fail(1, `${plan.rel}: changed between the rewrite plan and the write — refusing to overwrite from a stale snapshot; re-run`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const plan of plans) {
|
|
531
|
+
writeFileSync(resolve(root, plan.rel), `${plan.frontmatter}${plan.afterLines.join('\n')}`, 'utf8');
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
// CHECK scope (read-only, wider): docs/ai/** including decisions.md (whole file) and the adr/
|
|
536
|
+
// records, excluding only the generated navigator. (a) a `decisions.md#ad-NNN…` id outside the
|
|
537
|
+
// CURRENT HOT window is dead (an archived id is stale, not resolving); (b) an `adr/AD-NNN-slug.md`
|
|
538
|
+
// link must name an existing record FILE (exact filename, not id-presence).
|
|
539
|
+
const collectReferenceViolations = (root, hotIds, archivedIds) => {
|
|
540
|
+
const violations = [];
|
|
541
|
+
for (const rel of walkDocsMarkdown(root).filter((r) => r !== NAV_REL)) {
|
|
542
|
+
const { frontLines, lines, fencedLines } = tokenizeMarkdown(readFileSync(resolve(root, rel), 'utf8'), rel);
|
|
543
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
544
|
+
if (fencedLines.has(index)) continue;
|
|
545
|
+
const fileLine = frontLines + index + 1;
|
|
546
|
+
for (const m of lines[index].matchAll(HOT_LINK_RE)) {
|
|
547
|
+
if (hotIds.has(m[2])) continue;
|
|
548
|
+
const why = archivedIds.has(m[2]) ? `AD-${m[2]} is archived — repoint the link at ${ADR_DIR_REL}/` : `AD-${m[2]} is not in the current HOT window`;
|
|
549
|
+
violations.push(`${rel}:${fileLine}: dead ADR anchor "${m[0]}" — ${why}`);
|
|
550
|
+
}
|
|
551
|
+
for (const m of lines[index].matchAll(RECORD_LINK_RE)) {
|
|
552
|
+
if (!existsSync(resolve(root, ADR_DIR_REL, m[1]))) {
|
|
553
|
+
violations.push(`${rel}:${fileLine}: dead ADR record link "${m[0]}" — no record file ${ADR_DIR_REL}/${m[1]}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return violations;
|
|
559
|
+
};
|
|
560
|
+
|
|
278
561
|
// ── tier / store IO ─────────────────────────────────────────────────────────────────────
|
|
279
562
|
|
|
280
563
|
export const lineCountOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
@@ -581,6 +864,10 @@ const parseArgs = (argv) => {
|
|
|
581
864
|
else if (arg.startsWith('--today=')) today = arg.slice('--today='.length);
|
|
582
865
|
else throw fail(2, `Unknown argument: ${arg}\n${USAGE}`);
|
|
583
866
|
}
|
|
867
|
+
// Pre-fix, --apply silently won over --dry-run and wrote — a fail-closed contract cannot keep that.
|
|
868
|
+
if (flags.migrate && flags.apply && flags.dryRun) {
|
|
869
|
+
throw fail(2, `--migrate --apply --dry-run is contradictory — plain --migrate IS the dry run; drop --apply to preview or --dry-run to apply\n${USAGE}`);
|
|
870
|
+
}
|
|
584
871
|
return { flags, today };
|
|
585
872
|
};
|
|
586
873
|
|
|
@@ -672,10 +959,23 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
672
959
|
for (const r of records) finalStoreById.set(r.id, { id: r.id, idNum: r.idNum, fileName: r.fileName });
|
|
673
960
|
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
674
961
|
|
|
962
|
+
const movedById = new Map(records.map((r) => [r.id, r]));
|
|
963
|
+
assertAdrCorpusFreeOfMovedLinks(root, movedById, [HOT_REL, ...existingStore.map((e) => e.rel), ...present], [HOT_LINK_RE, MONOLITH_LINK_RE]);
|
|
964
|
+
assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
|
|
965
|
+
assertNoOrphanedMonolithLinks(root, movedById, [
|
|
966
|
+
...walkDocsMarkdown(root).filter(isRewriteScope),
|
|
967
|
+
HOT_REL,
|
|
968
|
+
...existingStore.map((e) => e.rel),
|
|
969
|
+
...present,
|
|
970
|
+
]);
|
|
971
|
+
const rewritePlans = planInboundRewrites(root, movedById, true);
|
|
972
|
+
for (const plan of rewritePlans) verifyRewriteConservation(plan);
|
|
973
|
+
|
|
675
974
|
const summary = {
|
|
676
975
|
records: records.map((r) => r.fileName),
|
|
677
976
|
retainedHot: retained.map((e) => `AD-${e.id}`),
|
|
678
977
|
monolithsRetired: present,
|
|
978
|
+
inboundRewrites: summarizeRewrites(rewritePlans),
|
|
679
979
|
conservation: `${oldItems.length} corpus blocks → ${retained.length} retained-HOT + ${records.length} records (conserved)`,
|
|
680
980
|
};
|
|
681
981
|
|
|
@@ -692,7 +992,9 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
692
992
|
];
|
|
693
993
|
const snapshot = writeSnapshot(root, snapshotFiles, deps);
|
|
694
994
|
|
|
995
|
+
// Pinned write order: records → inbound rewrites → HOT rewrite / monolith removal.
|
|
695
996
|
writeRecords(root, records);
|
|
997
|
+
writeInboundRewrites(root, rewritePlans);
|
|
696
998
|
const corpus = [...retained, ...loadAdrStore(root)];
|
|
697
999
|
writeNavigatorFile(root, corpus, today);
|
|
698
1000
|
writeHot(root, hot, retained, today);
|
|
@@ -703,6 +1005,7 @@ const runMigrate = (root, flags, today, deps, log, logError) => {
|
|
|
703
1005
|
log('[archive-decisions] migrated the 3-tier cascade → one-file-per-ADR store:');
|
|
704
1006
|
log(` snapshot: ${snapshot.dir} (${snapshot.viaGitDir ? 'git dir' : 'out-of-tree fallback'})`);
|
|
705
1007
|
log(` records written: ${records.length} under ${ADR_DIR_REL}/`);
|
|
1008
|
+
log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
|
|
706
1009
|
log(` retained HOT: ${summary.retainedHot.join(', ') || '(none)'}`);
|
|
707
1010
|
log(` retired monoliths: ${present.join(', ')}`);
|
|
708
1011
|
log(` navigator: ${NAV_REL}`);
|
|
@@ -756,6 +1059,13 @@ const runCheck = (root, today, log, logError) => {
|
|
|
756
1059
|
return 1;
|
|
757
1060
|
}
|
|
758
1061
|
if (!hasHot && !hasStore) {
|
|
1062
|
+
// The reference scan still runs: a matching reference over NO substrate is a dead link, never a
|
|
1063
|
+
// clean skip — the SKIP remains only for a tree with zero matches.
|
|
1064
|
+
const orphaned = collectReferenceViolations(root, new Set(), new Set());
|
|
1065
|
+
if (orphaned.length > 0) {
|
|
1066
|
+
for (const v of orphaned) logError(`[archive-decisions] FAIL: ${v}.`);
|
|
1067
|
+
return 1;
|
|
1068
|
+
}
|
|
759
1069
|
log(`[archive-decisions] SKIP — no ADR substrate (neither ${HOT_REL} nor ${ADR_DIR_REL}); nothing to check.`);
|
|
760
1070
|
return 0;
|
|
761
1071
|
}
|
|
@@ -783,11 +1093,13 @@ const runCheck = (root, today, log, logError) => {
|
|
|
783
1093
|
problems.push(`${NAV_REL} is stale (out of sync with the ADR corpus) — run \`node scripts/archive-decisions.mjs --write-navigator\` and commit it`);
|
|
784
1094
|
}
|
|
785
1095
|
|
|
1096
|
+
problems.push(...collectReferenceViolations(root, new Set(hotEntries.map((e) => e.id)), new Set(adrEntries.map((e) => e.id))));
|
|
1097
|
+
|
|
786
1098
|
if (problems.length > 0) {
|
|
787
1099
|
for (const p of problems) logError(`[archive-decisions] FAIL: ${p}.`);
|
|
788
1100
|
return 1;
|
|
789
1101
|
}
|
|
790
|
-
log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh.');
|
|
1102
|
+
log('[archive-decisions] OK — HOT within cap, store integrity intact, navigator fresh, inbound ADR references resolve.');
|
|
791
1103
|
return 0;
|
|
792
1104
|
};
|
|
793
1105
|
|
|
@@ -825,20 +1137,33 @@ const runRotate = (root, flags, today, deps, log, logError) => {
|
|
|
825
1137
|
for (const rec of records) finalStoreById.set(rec.id, { id: rec.id, idNum: rec.idNum, fileName: rec.fileName });
|
|
826
1138
|
assertStoreIntegrity(retained, [...finalStoreById.values()]);
|
|
827
1139
|
|
|
828
|
-
const
|
|
1140
|
+
const movedById = new Map(records.map((r) => [r.id, r]));
|
|
1141
|
+
assertAdrCorpusFreeOfMovedLinks(root, movedById, [HOT_REL, ...existingStore.map((e) => e.rel)], [HOT_LINK_RE]);
|
|
1142
|
+
assertMovingBlocksFreeOfRelativeAdrLinks(root, new Set(movedById.keys()), new Set(retained.map((e) => e.id)));
|
|
1143
|
+
const rewritePlans = planInboundRewrites(root, movedById, false);
|
|
1144
|
+
for (const plan of rewritePlans) verifyRewriteConservation(plan);
|
|
1145
|
+
|
|
1146
|
+
const summary = {
|
|
1147
|
+
explode: records.map((r) => r.fileName),
|
|
1148
|
+
retainedHot: retained.map((e) => `AD-${e.id}`),
|
|
1149
|
+
inboundRewrites: summarizeRewrites(rewritePlans),
|
|
1150
|
+
};
|
|
829
1151
|
if (flags.dryRun) {
|
|
830
1152
|
log('[archive-decisions] DRY-RUN — no files will be changed.');
|
|
831
1153
|
log(JSON.stringify(summary, null, 2));
|
|
832
1154
|
return 0;
|
|
833
1155
|
}
|
|
834
1156
|
|
|
1157
|
+
// Pinned write order: records → inbound rewrites → HOT rewrite (crash-resume idempotency).
|
|
835
1158
|
writeRecords(root, records);
|
|
1159
|
+
writeInboundRewrites(root, rewritePlans);
|
|
836
1160
|
const corpus = [...retained, ...loadAdrStore(root)];
|
|
837
1161
|
writeNavigatorFile(root, corpus, today);
|
|
838
1162
|
writeHot(root, hot, retained, today);
|
|
839
1163
|
const regen = (deps.regenerateIndex ?? defaultRegenerateIndex)(root, today);
|
|
840
1164
|
log('[archive-decisions] rotated:');
|
|
841
1165
|
log(` exploded to adr/: ${summary.explode.join(', ') || '(none)'}`);
|
|
1166
|
+
log(` inbound links rewritten: ${summary.inboundRewrites.length} across ${rewritePlans.length} file(s)`);
|
|
842
1167
|
log(` retained HOT: ${summary.retainedHot.join(', ')}`);
|
|
843
1168
|
if (regen.ok) log(' regenerated docs/ai/index.md');
|
|
844
1169
|
else logError(`[archive-decisions] docs/ai/index.md NOT regenerated — ${regen.detail}`);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, it, afterEach } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, existsSync, readdirSync, symlinkSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
5
|
+
import { join, dirname } from 'node:path';
|
|
6
6
|
import {
|
|
7
7
|
HOT_REL,
|
|
8
8
|
WARM_REL,
|
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
lineCountOf,
|
|
24
24
|
runCli,
|
|
25
25
|
defaultRegenerateIndex,
|
|
26
|
+
verifyRewriteConservation,
|
|
27
|
+
writeInboundRewrites,
|
|
26
28
|
} from './archive-decisions.mjs';
|
|
27
29
|
|
|
28
30
|
// Hermetic: this test ships as deploy payload and runs inside CONSUMER repos via the pre-commit
|
|
@@ -940,3 +942,521 @@ describe('usage', () => {
|
|
|
940
942
|
assert.match(text, /Usage: archive-decisions/);
|
|
941
943
|
});
|
|
942
944
|
});
|
|
945
|
+
|
|
946
|
+
// ── reference integrity — inbound anchor rewrite + check assertions (the anchor-orphan fix) ────
|
|
947
|
+
//
|
|
948
|
+
// Rotation/migration move ADR blocks out of HOT while inbound `decisions.md#ad-NNN…` links keep
|
|
949
|
+
// pointing at the old home. The write passes rewrite those links (monolith forms too under
|
|
950
|
+
// migrate) under a conservation invariant, `--check` asserts every inbound ADR reference
|
|
951
|
+
// resolves, and the exported verifier refuses a dropped/altered rewrite with nothing written.
|
|
952
|
+
// Fenced occurrences never count; inline code is treated as live (a stated limitation).
|
|
953
|
+
|
|
954
|
+
const writeDoc = (root, rel, text) => {
|
|
955
|
+
mkdirSync(dirname(join(root, rel)), { recursive: true });
|
|
956
|
+
writeFileSync(join(root, rel), text);
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
// An over-cap migrated-shape HOT (no monolith): cap = rendered - 1 → exactly the oldest explodes.
|
|
960
|
+
const seedOverCapHot = (root, ids) => {
|
|
961
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
962
|
+
const blocks = ids.map((id) => adrBlock(id));
|
|
963
|
+
const preamble = '# ADRs\n\n> Newest at the bottom.';
|
|
964
|
+
const probe = tierText(9999, preamble, blocks);
|
|
965
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
describe('reference integrity — rotate/migrate rewrite inbound links, --check asserts them', () => {
|
|
969
|
+
// Legacy tree whose migrate moves AD-001…AD-005 (HOT overflows by one) and retains AD-006…AD-008,
|
|
970
|
+
// with inbound links at two depths: pages (needs ../) and history (monolith sibling).
|
|
971
|
+
const seedMigrateFixture = (root) => {
|
|
972
|
+
seedLegacy(root, { hot: ['005', '006', '007', '008'], warm: ['003', '004'], cold: ['001', '002'], hotCapDelta: -1 });
|
|
973
|
+
writeDoc(root, 'docs/ai/pages/spec.md', [
|
|
974
|
+
'hot [AD-005](../decisions.md#ad-005--decision-005)',
|
|
975
|
+
'warm [AD-003](../history/decisions-archive.md#ad-003--decision-003)',
|
|
976
|
+
'',
|
|
977
|
+
].join('\n'));
|
|
978
|
+
writeDoc(root, 'docs/ai/history/notes.md', 'cold sibling [AD-001](decisions-archive-early.md#ad-001--decision-001)\n');
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
it('rotate rewrites inbound decisions.md anchors to record links, fragment preserved', () => {
|
|
982
|
+
const root = makeRoot();
|
|
983
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
984
|
+
writeDoc(root, 'docs/ai/pages/spec.md', '# Spec\n\nsee [AD-001](../decisions.md#ad-001--decision-001) for the decision.\n');
|
|
985
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
986
|
+
assert.equal(code, 0, errText);
|
|
987
|
+
const text = readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8');
|
|
988
|
+
assert.match(text, /\[AD-001\]\(\.\.\/adr\/AD-001-decision-001\.md#ad-001--decision-001\)/);
|
|
989
|
+
assert.doesNotMatch(text, /decisions\.md#ad-001(?!-)|\(\.\.\/decisions\.md/);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it('rotate rewrite is count-preserving: every moved-id link rewritten, all other bytes identical', () => {
|
|
993
|
+
const root = makeRoot();
|
|
994
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
995
|
+
const spec = [
|
|
996
|
+
'# Spec',
|
|
997
|
+
'',
|
|
998
|
+
'moved [AD-001](../decisions.md#ad-001--decision-001) and inline `../decisions.md#ad-001--decision-001` on one line.',
|
|
999
|
+
'retained [AD-004](../decisions.md#ad-004--decision-004) stays.',
|
|
1000
|
+
'negatives: [other](../other-decisions.md#ad-001--decision-001) and [bare](#ad-001--decision-001).',
|
|
1001
|
+
'',
|
|
1002
|
+
].join('\n');
|
|
1003
|
+
writeDoc(root, 'docs/ai/pages/spec.md', spec);
|
|
1004
|
+
writeDoc(root, 'docs/ai/history/notes.md', 'notes: [AD-001](../decisions.md#ad-001--decision-001)\n');
|
|
1005
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1006
|
+
assert.equal(code, 0, errText);
|
|
1007
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), [
|
|
1008
|
+
'# Spec',
|
|
1009
|
+
'',
|
|
1010
|
+
'moved [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001) and inline `../adr/AD-001-decision-001.md#ad-001--decision-001` on one line.',
|
|
1011
|
+
'retained [AD-004](../decisions.md#ad-004--decision-004) stays.',
|
|
1012
|
+
'negatives: [other](../other-decisions.md#ad-001--decision-001) and [bare](#ad-001--decision-001).',
|
|
1013
|
+
'',
|
|
1014
|
+
].join('\n'));
|
|
1015
|
+
assert.equal(readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8'), 'notes: [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)\n');
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it('rotate dry-run prints the rewrite set and writes nothing', () => {
|
|
1019
|
+
const root = makeRoot();
|
|
1020
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
1021
|
+
const spec = 'see [AD-001](../decisions.md#ad-001--decision-001).\n';
|
|
1022
|
+
writeDoc(root, 'docs/ai/pages/spec.md', spec);
|
|
1023
|
+
const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1024
|
+
const { code, text } = run(['--dry-run', '--today=2026-08-07'], root);
|
|
1025
|
+
assert.equal(code, 0, text);
|
|
1026
|
+
assert.match(text, /docs\/ai\/pages\/spec\.md:1 decisions\.md#ad-001--decision-001 → adr\/AD-001-decision-001\.md#ad-001--decision-001/);
|
|
1027
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), spec, 'the doc is byte-unchanged');
|
|
1028
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), hotBefore, 'HOT is byte-unchanged');
|
|
1029
|
+
assert.deepEqual(adrFiles(root), [], 'no record written on a dry-run');
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('--check exits 1 listing every stale decisions.md anchor with file:line', () => {
|
|
1033
|
+
const root = makeRoot();
|
|
1034
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
|
|
1035
|
+
writeDoc(root, 'docs/ai/pages/spec.md', [
|
|
1036
|
+
'stale-but-archived [AD-001](../decisions.md#ad-001--decision-001)',
|
|
1037
|
+
'nonexistent [AD-099](../decisions.md#ad-099--gone)',
|
|
1038
|
+
'',
|
|
1039
|
+
].join('\n'));
|
|
1040
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
1041
|
+
assert.equal(code, 1);
|
|
1042
|
+
assert.match(errText, /docs\/ai\/pages\/spec\.md:1: .*ad-001/);
|
|
1043
|
+
assert.match(errText, /docs\/ai\/pages\/spec\.md:2: .*ad-099/);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it('--check exits 1 with file:line on a dead adr record link (exact filename)', () => {
|
|
1047
|
+
const root = makeRoot();
|
|
1048
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
1049
|
+
writeDoc(root, 'docs/ai/pages/spec.md', [
|
|
1050
|
+
'good [AD-001](../adr/AD-001-decision-001.md)',
|
|
1051
|
+
'dead [AD-001](../adr/AD-001-wrong-slug.md)',
|
|
1052
|
+
'',
|
|
1053
|
+
].join('\n'));
|
|
1054
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
1055
|
+
assert.equal(code, 1);
|
|
1056
|
+
assert.match(errText, /docs\/ai\/pages\/spec\.md:2: .*AD-001-wrong-slug\.md/);
|
|
1057
|
+
assert.doesNotMatch(errText, /spec\.md:1:/, 'the resolving record link is not flagged');
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('--migrate --apply rewrites inbound links including monolith-form anchors, source-relative', () => {
|
|
1061
|
+
const root = makeRoot();
|
|
1062
|
+
seedMigrateFixture(root);
|
|
1063
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
|
|
1064
|
+
assert.equal(code, 0, errText);
|
|
1065
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), [
|
|
1066
|
+
'hot [AD-005](../adr/AD-005-decision-005.md#ad-005--decision-005)',
|
|
1067
|
+
'warm [AD-003](../adr/AD-003-decision-003.md#ad-003--decision-003)',
|
|
1068
|
+
'',
|
|
1069
|
+
].join('\n'));
|
|
1070
|
+
assert.equal(readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8'), 'cold sibling [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)\n');
|
|
1071
|
+
assert.equal(run(['--check', '--today=2026-08-07'], root).code, 0, 'the rewritten tree is green end-to-end');
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it('migrate dry-run prints the monolith and HOT rewrite set and writes nothing', () => {
|
|
1075
|
+
const root = makeRoot();
|
|
1076
|
+
seedMigrateFixture(root);
|
|
1077
|
+
const specBefore = readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8');
|
|
1078
|
+
const notesBefore = readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8');
|
|
1079
|
+
const { code, text } = run(['--migrate', '--today=2026-08-07'], root);
|
|
1080
|
+
assert.equal(code, 0, text);
|
|
1081
|
+
assert.match(text, /DRY-RUN/);
|
|
1082
|
+
assert.match(text, /docs\/ai\/pages\/spec\.md:1 decisions\.md#ad-005--decision-005 → adr\/AD-005-decision-005\.md#ad-005--decision-005/);
|
|
1083
|
+
assert.match(text, /docs\/ai\/pages\/spec\.md:2 \.\.\/history\/decisions-archive\.md#ad-003--decision-003 → \.\.\/adr\/AD-003-decision-003\.md#ad-003--decision-003/);
|
|
1084
|
+
assert.match(text, /docs\/ai\/history\/notes\.md:1 decisions-archive-early\.md#ad-001--decision-001 → \.\.\/adr\/AD-001-decision-001\.md#ad-001--decision-001/);
|
|
1085
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'), specBefore);
|
|
1086
|
+
assert.equal(readFileSync(join(root, 'docs/ai/history/notes.md'), 'utf8'), notesBefore);
|
|
1087
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
|
|
1088
|
+
assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no adr/ tree created on a dry-run');
|
|
1089
|
+
});
|
|
1090
|
+
|
|
1091
|
+
it('rotate and migrate refuse pre-write when the ADR corpus links a moved id', () => {
|
|
1092
|
+
// (a) the HOT preamble links the moved id
|
|
1093
|
+
{
|
|
1094
|
+
const root = makeRoot();
|
|
1095
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
1096
|
+
const preamble = '# ADRs\n\n> see [old](decisions.md#ad-005--decision-005)';
|
|
1097
|
+
const blocks = ['005', '006', '007', '008'].map((id) => adrBlock(id));
|
|
1098
|
+
const probe = tierText(9999, preamble, blocks);
|
|
1099
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, preamble, blocks));
|
|
1100
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1101
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1102
|
+
assert.equal(code, 1);
|
|
1103
|
+
assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
|
|
1104
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
|
|
1105
|
+
assert.deepEqual(adrFiles(root), [], 'no record written');
|
|
1106
|
+
}
|
|
1107
|
+
// (b) a HOT block body links the moved id
|
|
1108
|
+
{
|
|
1109
|
+
const root = makeRoot();
|
|
1110
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
1111
|
+
const linked = ['## AD-007 — Decision 007', '', '**Date:** 2026-01-01 · **Status:** Accepted', '', 'refers to [x](decisions.md#ad-005--decision-005)'].join('\n');
|
|
1112
|
+
const blocks = [adrBlock('005'), adrBlock('006'), linked, adrBlock('008')];
|
|
1113
|
+
const probe = tierText(9999, '# ADRs', blocks);
|
|
1114
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
|
|
1115
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1116
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1117
|
+
assert.equal(code, 1);
|
|
1118
|
+
assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
|
|
1119
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
|
|
1120
|
+
assert.deepEqual(adrFiles(root), [], 'no record written');
|
|
1121
|
+
}
|
|
1122
|
+
// (c) an existing adr/ record block links the moved id
|
|
1123
|
+
{
|
|
1124
|
+
const root = makeRoot();
|
|
1125
|
+
seedOverCapHot(root, ['005', '006', '007', '008']);
|
|
1126
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-001-decision-001.md'), `${fm(RECORD_CAP)}\n## AD-001 — Decision 001\n\n**Date:** 2026-01-01\n\nsee [x](../decisions.md#ad-005--decision-005)\n`);
|
|
1127
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1128
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1129
|
+
assert.equal(code, 1);
|
|
1130
|
+
assert.match(errText, /adr\/AD-001-decision-001\.md:\d+/);
|
|
1131
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
|
|
1132
|
+
assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md'], 'no new record written');
|
|
1133
|
+
}
|
|
1134
|
+
// (d) a WARM monolith block links a moved id (migrate)
|
|
1135
|
+
{
|
|
1136
|
+
const root = makeRoot();
|
|
1137
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
1138
|
+
writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
|
|
1139
|
+
const warmBlock = ['## AD-003 — Decision 003', '', '**Date:** 2026-01-01', '', 'see [x](../decisions.md#ad-001--decision-001)'].join('\n');
|
|
1140
|
+
writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [warmBlock]));
|
|
1141
|
+
writeFileSync(join(root, COLD_REL), tierText(500, COLD_PREAMBLE, [adrBlock('001')]));
|
|
1142
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
|
|
1143
|
+
assert.equal(code, 1);
|
|
1144
|
+
assert.match(errText, /docs\/ai\/history\/decisions-archive\.md:\d+/);
|
|
1145
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
|
|
1146
|
+
assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no record written');
|
|
1147
|
+
assert.ok(!existsSync(join(root, '.git', 'agent-workflow-adr-migration-snapshot-STAMP')), 'no snapshot on the refusal');
|
|
1148
|
+
}
|
|
1149
|
+
// (e) a COLD monolith block carries a monolith-form link to a moved id (migrate)
|
|
1150
|
+
{
|
|
1151
|
+
const root = makeRoot();
|
|
1152
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
1153
|
+
writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
|
|
1154
|
+
writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [adrBlock('003')]));
|
|
1155
|
+
const coldBlock = ['## AD-001 — Decision 001', '', '**Date:** 2026-01-01', '', 'see [x](decisions-archive.md#ad-003--decision-003)'].join('\n');
|
|
1156
|
+
writeFileSync(join(root, COLD_REL), tierText(500, COLD_PREAMBLE, [coldBlock]));
|
|
1157
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
|
|
1158
|
+
assert.equal(code, 1);
|
|
1159
|
+
assert.match(errText, /docs\/ai\/history\/decisions-archive-early\.md:\d+/);
|
|
1160
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
|
|
1161
|
+
assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no record written');
|
|
1162
|
+
}
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
it('--check exits 1 on a dead reference inside an ADR block body', () => {
|
|
1166
|
+
const root = makeRoot();
|
|
1167
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
1168
|
+
const hotBlock = ['## AD-005 — Decision 005', '', '**Date:** 2026-01-01', '', 'cites [gone](decisions.md#ad-002--decision-002)'].join('\n');
|
|
1169
|
+
writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [hotBlock]));
|
|
1170
|
+
writeFileSync(join(root, ADR_DIR_REL, 'AD-001-decision-001.md'), `${fm(RECORD_CAP)}\n## AD-001 — Decision 001\n\n**Date:** 2026-01-01\n\ncites [gone too](../decisions.md#ad-099--gone)\n`);
|
|
1171
|
+
assert.equal(run(['--write-navigator', '--today=2026-07-09'], root).code, 0);
|
|
1172
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
1173
|
+
assert.equal(code, 1);
|
|
1174
|
+
assert.match(errText, /docs\/ai\/decisions\.md:\d+: .*ad-002/);
|
|
1175
|
+
assert.match(errText, /docs\/ai\/adr\/AD-001-decision-001\.md:\d+: .*ad-099/);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('--check without an ADR substrate still fails on a matching reference', () => {
|
|
1179
|
+
const root = makeRoot();
|
|
1180
|
+
writeDoc(root, 'docs/ai/pages/spec.md', 'orphan [AD-001](../decisions.md#ad-001--decision-001)\n');
|
|
1181
|
+
const { code, errText } = run(['--check'], root);
|
|
1182
|
+
assert.equal(code, 1);
|
|
1183
|
+
assert.match(errText, /docs\/ai\/pages\/spec\.md:1/);
|
|
1184
|
+
|
|
1185
|
+
const clean = makeRoot();
|
|
1186
|
+
writeDoc(clean, 'docs/ai/pages/spec.md', 'no adr links here\n');
|
|
1187
|
+
const skip = run(['--check'], clean);
|
|
1188
|
+
assert.equal(skip.code, 0);
|
|
1189
|
+
assert.match(skip.text, /SKIP — no ADR substrate/);
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
it('the rewrite conservation verifier refuses a dropped or altered link with nothing written', () => {
|
|
1193
|
+
const base = {
|
|
1194
|
+
rel: 'docs/ai/pages/spec.md',
|
|
1195
|
+
frontLines: 0,
|
|
1196
|
+
rewrites: [{ index: 0, line: 1, old: 'decisions.md#ad-001--decision-001', new: 'adr/AD-001-decision-001.md#ad-001--decision-001' }],
|
|
1197
|
+
};
|
|
1198
|
+
// a dropped link: the output line never received the planned rewrite
|
|
1199
|
+
assert.throws(() => verifyRewriteConservation({
|
|
1200
|
+
...base,
|
|
1201
|
+
beforeLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
|
|
1202
|
+
afterLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
|
|
1203
|
+
}), (e) => e.exitCode === 1 && /conservation/.test(e.message));
|
|
1204
|
+
// an altered byte outside the planned rewrite set
|
|
1205
|
+
assert.throws(() => verifyRewriteConservation({
|
|
1206
|
+
...base,
|
|
1207
|
+
beforeLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
|
|
1208
|
+
afterLines: ['see [x](../adr/AD-001-decision-001.md#ad-001--decision-001)', 'tail ALTERED'],
|
|
1209
|
+
}), (e) => e.exitCode === 1 && /conservation/.test(e.message));
|
|
1210
|
+
// the honest plan passes
|
|
1211
|
+
assert.doesNotThrow(() => verifyRewriteConservation({
|
|
1212
|
+
...base,
|
|
1213
|
+
beforeLines: ['see [x](../decisions.md#ad-001--decision-001)', 'tail'],
|
|
1214
|
+
afterLines: ['see [x](../adr/AD-001-decision-001.md#ad-001--decision-001)', 'tail'],
|
|
1215
|
+
}));
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it('a fenced dead-link sample leaves --check green and survives rotate byte-identically', () => {
|
|
1219
|
+
const root = makeRoot();
|
|
1220
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
|
|
1221
|
+
writeDoc(root, 'docs/ai/pages/spec.md', ['fenced sample:', '', '```md', 'dead [x](../decisions.md#ad-099--gone)', '```', ''].join('\n'));
|
|
1222
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'a fenced dead link never counts');
|
|
1223
|
+
|
|
1224
|
+
const root2 = makeRoot();
|
|
1225
|
+
seedOverCapHot(root2, ['001', '002', '003', '004']);
|
|
1226
|
+
const doc2 = ['```md', 'fenced moved link [x](../decisions.md#ad-001--decision-001)', '```', ''].join('\n');
|
|
1227
|
+
writeDoc(root2, 'docs/ai/pages/spec.md', doc2);
|
|
1228
|
+
assert.equal(run(['--today=2026-08-07'], root2).code, 0);
|
|
1229
|
+
assert.equal(readFileSync(join(root2, 'docs/ai/pages/spec.md'), 'utf8'), doc2, 'the fenced occurrence survives rotate byte-identically');
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
it('an unclosed fence in a scanned doc aborts BOTH write paths exit 1 with the tree untouched', () => {
|
|
1233
|
+
const root = makeRoot();
|
|
1234
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
1235
|
+
writeDoc(root, 'docs/ai/pages/broken.md', '```md\nnever closed\n');
|
|
1236
|
+
const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1237
|
+
const r = run(['--today=2026-08-07'], root);
|
|
1238
|
+
assert.equal(r.code, 1);
|
|
1239
|
+
assert.match(r.errText, /never closed/);
|
|
1240
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), hotBefore, 'HOT untouched');
|
|
1241
|
+
assert.deepEqual(adrFiles(root), [], 'no record written');
|
|
1242
|
+
|
|
1243
|
+
const root2 = makeRoot();
|
|
1244
|
+
seedLegacy(root2, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
1245
|
+
writeDoc(root2, 'docs/ai/pages/broken.md', '```md\nnever closed\n');
|
|
1246
|
+
const m = run(['--migrate', '--apply', '--today=2026-08-07'], root2);
|
|
1247
|
+
assert.equal(m.code, 1);
|
|
1248
|
+
assert.match(m.errText, /never closed/);
|
|
1249
|
+
assert.ok(existsSync(join(root2, WARM_REL)) && existsSync(join(root2, COLD_REL)), 'monoliths untouched');
|
|
1250
|
+
assert.ok(!existsSync(join(root2, ADR_DIR_REL)), 'no record written');
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
it('the migrate rewrite set never targets a monolith file and written records preserve moved blocks verbatim', () => {
|
|
1254
|
+
const root = makeRoot();
|
|
1255
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
1256
|
+
writeFileSync(join(root, HOT_REL), tierText(500, '# ADRs', [adrBlock('005')]));
|
|
1257
|
+
const warmBlock = ['## AD-003 — Decision 003', '', '**Date:** 2026-01-01', '', 'links retained [AD-005](../decisions.md#ad-005--decision-005)'].join('\n');
|
|
1258
|
+
writeFileSync(join(root, WARM_REL), tierText(500, WARM_PREAMBLE, [warmBlock]));
|
|
1259
|
+
const dry = run(['--migrate', '--today=2026-08-07'], root);
|
|
1260
|
+
assert.equal(dry.code, 0, dry.errText);
|
|
1261
|
+
assert.doesNotMatch(dry.text, /history\/decisions-archive\.md:\d+ /, 'no rewrite entry targets a monolith file');
|
|
1262
|
+
const applied = run(['--migrate', '--apply', '--today=2026-08-07'], root);
|
|
1263
|
+
assert.equal(applied.code, 0, applied.errText);
|
|
1264
|
+
const rec = readFileSync(join(root, ADR_DIR_REL, 'AD-003-decision-003.md'), 'utf8');
|
|
1265
|
+
assert.match(rec, /links retained \[AD-005\]\(\.\.\/decisions\.md#ad-005--decision-005\)/, 'the moved block is preserved verbatim');
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it('D9 negative fixtures stay byte-identical: a different filename and a bare fragment never match', () => {
|
|
1269
|
+
const root = makeRoot();
|
|
1270
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
1271
|
+
const doc = 'neg: [a](../my-decisions.md#ad-001--decision-001) [b](#ad-001--decision-001)\n';
|
|
1272
|
+
writeDoc(root, 'docs/ai/pages/neg.md', doc);
|
|
1273
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1274
|
+
assert.equal(code, 0, errText);
|
|
1275
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/neg.md'), 'utf8'), doc);
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
it('an interrupted state (records + rewrites written, HOT untrimmed) re-runs to completion', () => {
|
|
1279
|
+
const root = makeRoot();
|
|
1280
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
1281
|
+
const entry = parseDecisionsText(tierText(999, '# T', [adrBlock('001')]), 'x').entries[0];
|
|
1282
|
+
const [rec] = explode([entry], '2026-08-07');
|
|
1283
|
+
writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
|
|
1284
|
+
writeDoc(root, 'docs/ai/pages/spec.md', 'see [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)\n');
|
|
1285
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1286
|
+
assert.equal(code, 0, errText);
|
|
1287
|
+
assert.deepEqual(idsIn(root, HOT_REL), ['002', '003', '004'], 'the re-run completed the trim');
|
|
1288
|
+
assert.equal(run(['--check', '--today=2026-08-07'], root).code, 0);
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('--check stays green on a live tree with resolving anchors', () => {
|
|
1292
|
+
const root = makeRoot();
|
|
1293
|
+
seedMigrated(root, { hotIds: ['005', '006'], storeIds: ['001'] });
|
|
1294
|
+
writeDoc(root, 'docs/ai/pages/spec.md', [
|
|
1295
|
+
'live [AD-005](../decisions.md#ad-005--decision-005)',
|
|
1296
|
+
'archived [AD-001](../adr/AD-001-decision-001.md#ad-001--decision-001)',
|
|
1297
|
+
'',
|
|
1298
|
+
].join('\n'));
|
|
1299
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0);
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
it('--migrate --apply --dry-run refuses loudly pre-spend', () => {
|
|
1303
|
+
const root = makeRoot();
|
|
1304
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
1305
|
+
const { code, errText } = run(['--migrate', '--apply', '--dry-run'], root);
|
|
1306
|
+
assert.equal(code, 2);
|
|
1307
|
+
assert.match(errText, /--migrate --apply --dry-run/);
|
|
1308
|
+
assert.ok(existsSync(join(root, WARM_REL)), 'nothing written');
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it('a symlinked markdown file in the scan tree refuses loudly (never read or written through)', () => {
|
|
1312
|
+
const root = makeRoot();
|
|
1313
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
1314
|
+
writeFileSync(join(root, 'outside.md'), 'outside the docs tree\n');
|
|
1315
|
+
mkdirSync(join(root, 'docs', 'ai', 'pages'), { recursive: true });
|
|
1316
|
+
symlinkSync(join(root, 'outside.md'), join(root, 'docs/ai/pages/link.md'));
|
|
1317
|
+
const hotBefore = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1318
|
+
const rotate = run(['--today=2026-08-07'], root);
|
|
1319
|
+
assert.equal(rotate.code, 1);
|
|
1320
|
+
assert.match(rotate.errText, /link\.md/);
|
|
1321
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), hotBefore, 'HOT untouched');
|
|
1322
|
+
assert.deepEqual(adrFiles(root), [], 'no record written');
|
|
1323
|
+
assert.equal(readFileSync(join(root, 'outside.md'), 'utf8'), 'outside the docs tree\n', 'the symlink target is never touched');
|
|
1324
|
+
const check = run(['--check', '--today=2026-08-07'], root);
|
|
1325
|
+
assert.equal(check.code, 1);
|
|
1326
|
+
assert.match(check.errText, /link\.md/);
|
|
1327
|
+
|
|
1328
|
+
const clean = makeRoot();
|
|
1329
|
+
seedMigrated(clean, { hotIds: ['005'], storeIds: ['001'] });
|
|
1330
|
+
writeFileSync(join(clean, 'notes.txt'), 'plain\n');
|
|
1331
|
+
mkdirSync(join(clean, 'docs', 'ai', 'pages'), { recursive: true });
|
|
1332
|
+
symlinkSync(join(clean, 'notes.txt'), join(clean, 'docs/ai/pages/notes.txt'));
|
|
1333
|
+
assert.equal(run(['--check', '--today=2026-07-09'], clean).code, 0, 'a non-markdown symlink stays an ignored stray');
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
it('a rewrite-scope file that changed after planning refuses pre-write with the human edit intact', () => {
|
|
1337
|
+
const root = makeRoot();
|
|
1338
|
+
mkdirSync(join(root, 'docs', 'ai', 'pages'), { recursive: true });
|
|
1339
|
+
const rel = 'docs/ai/pages/spec.md';
|
|
1340
|
+
const before = 'see [AD-001](../decisions.md#ad-001--decision-001)\n';
|
|
1341
|
+
writeFileSync(join(root, rel), before);
|
|
1342
|
+
const plan = {
|
|
1343
|
+
rel,
|
|
1344
|
+
frontmatter: '',
|
|
1345
|
+
frontLines: 0,
|
|
1346
|
+
beforeLines: before.split('\n'),
|
|
1347
|
+
afterLines: before.replace('decisions.md#ad-001--decision-001', 'adr/AD-001-decision-001.md#ad-001--decision-001').split('\n'),
|
|
1348
|
+
rewrites: [],
|
|
1349
|
+
};
|
|
1350
|
+
writeFileSync(join(root, rel), 'humanly edited meanwhile\n');
|
|
1351
|
+
assert.throws(() => writeInboundRewrites(root, [plan]), (e) => e.exitCode === 1 && /stale snapshot/.test(e.message));
|
|
1352
|
+
assert.equal(readFileSync(join(root, rel), 'utf8'), 'humanly edited meanwhile\n', 'the human edit survives');
|
|
1353
|
+
writeFileSync(join(root, rel), before);
|
|
1354
|
+
writeInboundRewrites(root, [plan]);
|
|
1355
|
+
assert.match(readFileSync(join(root, rel), 'utf8'), /adr\/AD-001-decision-001\.md#ad-001--decision-001/, 'the unchanged file is rewritten');
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it('a symlinked directory in the scan tree refuses loudly instead of hiding its subtree', () => {
|
|
1359
|
+
const root = makeRoot();
|
|
1360
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
1361
|
+
mkdirSync(join(root, 'elsewhere'), { recursive: true });
|
|
1362
|
+
writeFileSync(join(root, 'elsewhere', 'notes.md'), 'dead [x](../decisions.md#ad-099--gone)\n');
|
|
1363
|
+
symlinkSync(join(root, 'elsewhere'), join(root, 'docs/ai/pages'));
|
|
1364
|
+
const { code, errText } = run(['--check', '--today=2026-07-09'], root);
|
|
1365
|
+
assert.equal(code, 1);
|
|
1366
|
+
assert.match(errText, /docs\/ai\/pages/);
|
|
1367
|
+
|
|
1368
|
+
const root2 = makeRoot();
|
|
1369
|
+
seedMigrated(root2, { hotIds: ['005'], storeIds: ['001'] });
|
|
1370
|
+
symlinkSync(join(root2, 'gone-target'), join(root2, 'docs/ai/dangling'));
|
|
1371
|
+
const r2 = run(['--check', '--today=2026-07-09'], root2);
|
|
1372
|
+
assert.equal(r2.code, 1);
|
|
1373
|
+
assert.match(r2.errText, /dangling symlink/);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
it('migrate refuses pre-write when a monolith-form link targets an id outside the moved set', () => {
|
|
1377
|
+
const root = makeRoot();
|
|
1378
|
+
seedLegacy(root, { hot: ['005'], warm: ['003'], cold: ['001'] });
|
|
1379
|
+
writeDoc(root, 'docs/ai/pages/spec.md', 'dead-after-apply [x](../history/decisions-archive.md#ad-777--nope)\n');
|
|
1380
|
+
const { code, errText } = run(['--migrate', '--apply', '--today=2026-08-07'], root);
|
|
1381
|
+
assert.equal(code, 1);
|
|
1382
|
+
assert.match(errText, /docs\/ai\/pages\/spec\.md:1/);
|
|
1383
|
+
assert.ok(existsSync(join(root, WARM_REL)) && existsSync(join(root, COLD_REL)), 'monoliths untouched');
|
|
1384
|
+
assert.ok(!existsSync(join(root, ADR_DIR_REL)), 'no record written');
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
it('rotate refuses pre-write when a MOVING block links a retained id', () => {
|
|
1388
|
+
const root = makeRoot();
|
|
1389
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
1390
|
+
const moving = ['## AD-001 — Decision 001', '', '**Date:** 2026-01-01', '', 'see [x](decisions.md#ad-004--decision-004)'].join('\n');
|
|
1391
|
+
const blocks = [moving, adrBlock('002'), adrBlock('003'), adrBlock('004')];
|
|
1392
|
+
const probe = tierText(9999, '# ADRs', blocks);
|
|
1393
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
|
|
1394
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1395
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1396
|
+
assert.equal(code, 1);
|
|
1397
|
+
assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
|
|
1398
|
+
assert.match(errText, /RETAINED/);
|
|
1399
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
|
|
1400
|
+
assert.deepEqual(adrFiles(root), [], 'no record written');
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
it('rotate refuses pre-write when a MOVING block carries a record-form link', () => {
|
|
1404
|
+
const root = makeRoot();
|
|
1405
|
+
mkdirSync(join(root, ADR_DIR_REL), { recursive: true });
|
|
1406
|
+
const rec = explode(parseDecisionsText(tierText(999, '# T', [adrBlock('001')]), 'x').entries, '2026-08-07')[0];
|
|
1407
|
+
writeFileSync(join(root, ADR_DIR_REL, rec.fileName), `${rec.frontmatter}\n${rec.block}\n`);
|
|
1408
|
+
const moving = ['## AD-005 — Decision 005', '', '**Date:** 2026-01-01', '', 'see [x](adr/AD-001-decision-001.md)'].join('\n');
|
|
1409
|
+
const blocks = [moving, adrBlock('006'), adrBlock('007'), adrBlock('008')];
|
|
1410
|
+
const probe = tierText(9999, '# ADRs', blocks);
|
|
1411
|
+
writeFileSync(join(root, HOT_REL), tierText(lineCountOf(probe) - 1, '# ADRs', blocks));
|
|
1412
|
+
const before = readFileSync(join(root, HOT_REL), 'utf8');
|
|
1413
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1414
|
+
assert.equal(code, 1);
|
|
1415
|
+
assert.match(errText, /docs\/ai\/decisions\.md:\d+/);
|
|
1416
|
+
assert.match(errText, /record link|relative ADR link/);
|
|
1417
|
+
assert.equal(readFileSync(join(root, HOT_REL), 'utf8'), before, 'HOT untouched');
|
|
1418
|
+
assert.deepEqual(adrFiles(root), ['AD-001-decision-001.md'], 'no new record written');
|
|
1419
|
+
});
|
|
1420
|
+
|
|
1421
|
+
it('a record-link match ends exactly at .md — a suffixed or child path is a different target', () => {
|
|
1422
|
+
const root = makeRoot();
|
|
1423
|
+
seedMigrated(root, { hotIds: ['005'], storeIds: ['001'] });
|
|
1424
|
+
writeDoc(root, 'docs/ai/pages/spec.md', [
|
|
1425
|
+
'not-a-record-link [a](../adr/AD-777-zzz.md.bak)',
|
|
1426
|
+
'not-a-record-link [b](../adr/AD-001-decision-001.md/child)',
|
|
1427
|
+
'',
|
|
1428
|
+
].join('\n'));
|
|
1429
|
+
assert.equal(run(['--check', '--today=2026-07-09'], root).code, 0, 'suffixed and child paths are different filenames, never record-form links');
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('a negative sharing bytes with a valid link on ONE line never poisons the rewrite', () => {
|
|
1433
|
+
const root = makeRoot();
|
|
1434
|
+
seedOverCapHot(root, ['001', '002', '003', '004']);
|
|
1435
|
+
writeDoc(root, 'docs/ai/pages/spec.md', 'both [a](../decisions.md#ad-001--decision-001) and [n](../other-decisions.md#ad-001--decision-001)\n');
|
|
1436
|
+
const { code, errText } = run(['--today=2026-08-07'], root);
|
|
1437
|
+
assert.equal(code, 0, errText);
|
|
1438
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/spec.md'), 'utf8'),
|
|
1439
|
+
'both [a](../adr/AD-001-decision-001.md#ad-001--decision-001) and [n](../other-decisions.md#ad-001--decision-001)\n');
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
it('a drift in ANY planned file refuses the whole rewrite phase with nothing written', () => {
|
|
1443
|
+
const root = makeRoot();
|
|
1444
|
+
mkdirSync(join(root, 'docs', 'ai', 'pages'), { recursive: true });
|
|
1445
|
+
const before1 = 'one [AD-001](../decisions.md#ad-001--decision-001)\n';
|
|
1446
|
+
const before2 = 'two [AD-001](../decisions.md#ad-001--decision-001)\n';
|
|
1447
|
+
writeFileSync(join(root, 'docs/ai/pages/a.md'), before1);
|
|
1448
|
+
writeFileSync(join(root, 'docs/ai/pages/b.md'), before2);
|
|
1449
|
+
const planOf = (rel, before) => ({
|
|
1450
|
+
rel,
|
|
1451
|
+
frontmatter: '',
|
|
1452
|
+
frontLines: 0,
|
|
1453
|
+
beforeLines: before.split('\n'),
|
|
1454
|
+
afterLines: before.replace('decisions.md#ad-001--decision-001', 'adr/AD-001-decision-001.md#ad-001--decision-001').split('\n'),
|
|
1455
|
+
rewrites: [],
|
|
1456
|
+
});
|
|
1457
|
+
const plans = [planOf('docs/ai/pages/a.md', before1), planOf('docs/ai/pages/b.md', before2)];
|
|
1458
|
+
writeFileSync(join(root, 'docs/ai/pages/b.md'), 'moved meanwhile\n');
|
|
1459
|
+
assert.throws(() => writeInboundRewrites(root, plans), /stale snapshot/);
|
|
1460
|
+
assert.equal(readFileSync(join(root, 'docs/ai/pages/a.md'), 'utf8'), before1, 'the earlier planned file is byte-unchanged');
|
|
1461
|
+
});
|
|
1462
|
+
});
|