@sabaiway/agent-workflow-kit 7.6.0 → 9.0.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 +57 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/archive-caps.mjs +110 -0
- package/references/scripts/archive-caps.test.mjs +321 -0
- package/references/scripts/archive-changelog.mjs +45 -25
- package/references/scripts/archive-changelog.test.mjs +8 -1
- package/references/scripts/spec-schema.mjs +5 -1
- package/references/scripts/spec-schema.test.mjs +8 -0
- package/tools/known-footprint.mjs +2 -0
- package/tools/migrate-adr-store.mjs +17 -2
- package/tools/script-priors.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,63 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
|
|
|
4
4
|
is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
|
|
5
5
|
every `migrations/<version>-<slug>.md` newer than it, in semver order.
|
|
6
6
|
|
|
7
|
+
## 9.0.0 — the archiver's cap table ships, and the migration seeds it behind its importer (AD-118)
|
|
8
|
+
|
|
9
|
+
Memory **6.0.0** replaces the changelog archiver's fixed cap literals with a stamp it can honour,
|
|
10
|
+
and this kit ships those bytes. One kit-owned behaviour moved with them, and it is the interesting
|
|
11
|
+
half: a new import by a refreshed archiver reopens a hole the companion seed exists to close.
|
|
12
|
+
|
|
13
|
+
> ### ⚠ BREAKING — inherited from memory 6.0.0
|
|
14
|
+
>
|
|
15
|
+
> The archiver REFUSES once a tier's line count goes past its ceiling, in every mode including
|
|
16
|
+
> `--check`, and META's floor rises 300 → 1500.
|
|
17
|
+
>
|
|
18
|
+
> **When it bites.** The new bytes reach a project's `scripts/` on a FRESH bootstrap, a
|
|
19
|
+
> `migrate-adr-store` refresh, or a hand copy — installing this kit does NOT overwrite an existing
|
|
20
|
+
> deployed `archive-changelog.mjs`. Once they land, a project whose archive has already outgrown a
|
|
21
|
+
> ceiling sees its `changelog-rotation` gate turn red with no edit of its own.
|
|
22
|
+
|
|
23
|
+
- **`references/scripts/archive-caps{,.test}.mjs`** — new, mirrored byte-identical from the memory
|
|
24
|
+
canon; `references/scripts/archive-changelog{,.test}.mjs` refreshed to the 6.0.0 bodies.
|
|
25
|
+
`sync-mirrors --check` green over all three copies.
|
|
26
|
+
- **`tools/migrate-adr-store.mjs` — `COMPANION_SEEDS` gains a per-entry condition.** The list exists
|
|
27
|
+
so a refreshed archiver never crashes on a missing runtime import, and it was flat: every entry
|
|
28
|
+
seeded on every migration. That was correct for what it held — the ONE runtime dependency
|
|
29
|
+
`markdown-blocks.mjs`, which all three archivers import, plus its deploy-payload test riding along
|
|
30
|
+
with it. `archive-caps.mjs` is imported by `archive-changelog.mjs` ALONE, so seeding it
|
|
31
|
+
unconditionally would write a file with no importer during a plain ADR migration — the directional
|
|
32
|
+
"never ADD a basename the consumer lacks" rule broken, and a log line saying something untrue. An
|
|
33
|
+
entry may now NAME the importer it rides: the markdown pair stays unconditional, the `archive-caps`
|
|
34
|
+
pair rides `archive-changelog.mjs`, and two arms pin both cases — importer present, importer absent.
|
|
35
|
+
- **`tools/known-footprint.mjs`** — `KIT_OWN_PATHS` gains both new script paths (30 → 32), so a
|
|
36
|
+
HIDDEN deployment does not leave them visible in `git status`.
|
|
37
|
+
- Tarball sentinels move with the payload on their documented ladders: kit 250 → 252, memory 59 → 61.
|
|
38
|
+
|
|
39
|
+
## 8.0.0 — the bundled reader gets the scenario floor, and the refresh lane carries it out (AD-117)
|
|
40
|
+
|
|
41
|
+
Memory **5.0.0** gives the spec reader one new refusal — a `## Scenarios` section carrying no
|
|
42
|
+
scenario line — and this kit ships those bytes. Nothing in the kit's own logic changed shape; what
|
|
43
|
+
changed is what the deployed reader says about a document, and therefore what a project's spec gate
|
|
44
|
+
says about its store.
|
|
45
|
+
|
|
46
|
+
> ### ⚠ BREAKING — inherited from memory 5.0.0
|
|
47
|
+
>
|
|
48
|
+
> `spec-check` (both lanes) and the `spec-check` gate row relay the reader's verdict, so a spec that
|
|
49
|
+
> pins NOTHING now turns a deployment red with no edit of its own. The remedy is one line per
|
|
50
|
+
> scenario — `- S<N> <name> :: unbound` while no test pins it. The refusal is the migration signal:
|
|
51
|
+
> a contract that pins nothing is the one shape the layer exists to refuse.
|
|
52
|
+
|
|
53
|
+
- **`references/scripts/spec-schema{,.test}.mjs`** — refreshed to the 5.0.0 bodies; the four mirrored
|
|
54
|
+
copies stay byte-identical, `sync-mirrors --check` green.
|
|
55
|
+
- **`tools/script-priors.mjs`** — the catalog gains the two OUTGOING `4.7.0..4.7.0` reader rows
|
|
56
|
+
(`spec-schema.mjs`, `spec-schema.test.mjs`), read from the released bytes, so an unmodified 4.7.0
|
|
57
|
+
deployment is recognised as unmodified and converges on the next kit touch instead of stranding as
|
|
58
|
+
`custom`. The independent `FROZEN_PRIORS` pin in the test moves with it — 8 catalog rows, none
|
|
59
|
+
equal to the bundled body.
|
|
60
|
+
- **Obligation discharged: KIT-SUBTREE-CHANGED-WITHOUT-A-BUMP.** Slice 4 wrote 19 spec-binding
|
|
61
|
+
markers into this package's subtree and shipped no bump. This release carries them.
|
|
62
|
+
|
|
63
|
+
|
|
7
64
|
## 7.6.0 — `spec-check`: the spec store judged against what the session SAYS it changed (AD-115)
|
|
8
65
|
|
|
9
66
|
The feature-spec layer gets its structural checker. The change source is EXPLICIT and never git — a
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-kit
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '
|
|
6
|
+
version: '9.0.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-kit
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-kit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The ONE cap table the rolling changelog archive stamps from.
|
|
3
|
+
//
|
|
4
|
+
// Each tier's frontmatter `maxLines` used to be a LITERAL inside the builder that wrote it: COLD
|
|
5
|
+
// 1500, WARM 3500, META 300. A literal is a promise about a corpus nobody has seen yet, so the day a
|
|
6
|
+
// tier outgrew its number the archiver emitted a file the docs gate refuses — its own output failing
|
|
7
|
+
// its own gate — and the repair was to hand-raise the number in the emitted file until the next run
|
|
8
|
+
// stamped the literal straight back over it.
|
|
9
|
+
//
|
|
10
|
+
// capFor stamps a cap the file HONOURS: the tier FLOOR while the file fits under it, the file's OWN
|
|
11
|
+
// line count once it does not, and a REFUSAL once that count passes the tier CEILING. A growing
|
|
12
|
+
// archive therefore stays green with no hand edit, and an archive that has grown past what its tier
|
|
13
|
+
// was ever meant to hold STOPS the run instead of stamping a number nobody chose.
|
|
14
|
+
//
|
|
15
|
+
// The ceiling is 2x the floor, FIXED here and never measured from a corpus. The refusal rides the
|
|
16
|
+
// standing `--check` gate, so a ceiling derived from whatever happened to be on disk the day someone
|
|
17
|
+
// wrote the test would brick every commit as soon as the corpus grew past it.
|
|
18
|
+
//
|
|
19
|
+
// Dependency-free (one sibling import for the shared failure shape), Node >= 22. No side effects on
|
|
20
|
+
// import.
|
|
21
|
+
|
|
22
|
+
import { fail } from './markdown-blocks.mjs';
|
|
23
|
+
|
|
24
|
+
// Each tier carries its OWN remedy, because the three run out of room for different reasons and one
|
|
25
|
+
// piece of advice is wrong for two of them: COLD is already sharded per month, and WARM is a window
|
|
26
|
+
// whose size is a flag, not a file layout.
|
|
27
|
+
//
|
|
28
|
+
// A remedy must never instruct a layout this archiver cannot READ BACK. COLD files are discovered by
|
|
29
|
+
// `/^\d{4}-\d{2}\.md$/` alone, so a hand-split `2026-03-a.md` would silently drop out of the corpus
|
|
30
|
+
// on the next run — conservation lost by following our own advice. META is always regenerated as a
|
|
31
|
+
// single `condensed-index.md`, so a per-year file would be orphaned the moment it was written. Both
|
|
32
|
+
// therefore name the work as UNIMPLEMENTED rather than telling anyone to do it. Only WARM's remedy
|
|
33
|
+
// is something a run supports today: `--warm-days` is a flag, not a file layout.
|
|
34
|
+
export const CAP_TABLE = Object.freeze({
|
|
35
|
+
// COLD — docs/ai/history/YYYY-MM.md: one CLOSED month of compressed entries. A closed month never
|
|
36
|
+
// grows again, so its stamp is a fact about that month, not a forecast.
|
|
37
|
+
cold: Object.freeze({
|
|
38
|
+
floor: 1500,
|
|
39
|
+
ceiling: 3000,
|
|
40
|
+
remedy: 'compress that month harder — it is already one file per month, and sub-month sharding is NOT implemented (discovery matches YYYY-MM.md only, so a hand-split file would drop out of the corpus)',
|
|
41
|
+
}),
|
|
42
|
+
// WARM — docs/ai/history/recent.md: a rolling window of full-text entries, the widest tier.
|
|
43
|
+
warm: Object.freeze({
|
|
44
|
+
floor: 3500,
|
|
45
|
+
ceiling: 7000,
|
|
46
|
+
remedy: 'shorten the WARM window with --warm-days so the oldest entries move on to COLD',
|
|
47
|
+
}),
|
|
48
|
+
// META — docs/ai/history/condensed-index.md: one line per archived entry, so it grows O(total) and
|
|
49
|
+
// never sheds. Its floor rose 300 -> 1500 because 300 never fit the ~1159-at-2y growth the
|
|
50
|
+
// archiver's own header documents; the ceiling is where per-year sharding stops being optional.
|
|
51
|
+
meta: Object.freeze({
|
|
52
|
+
floor: 1500,
|
|
53
|
+
ceiling: 3000,
|
|
54
|
+
remedy: 'shard it per-year (condensed-index-YYYY.md) — NOT implemented yet: this file is always regenerated whole, so the split needs discovery and generation before anyone splits it by hand',
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export const TIERS = Object.freeze(Object.keys(CAP_TABLE));
|
|
59
|
+
|
|
60
|
+
// The docs gate's OWN line count (check-docs-size.mjs): a trailing newline terminates the last line,
|
|
61
|
+
// it does not open a new one. Counting any other way would stamp a cap the gate disagrees with —
|
|
62
|
+
// precisely the failure this module exists to end.
|
|
63
|
+
export const countLines = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
64
|
+
|
|
65
|
+
// Object.hasOwn, never a bare lookup: `CAP_TABLE['toString']` finds an INHERITED function, which is
|
|
66
|
+
// truthy, and the stamp then reads `undefined` off it and writes `maxLines: undefined`. An unknown
|
|
67
|
+
// tier has to refuse, and "unknown" includes every name Object.prototype happens to carry.
|
|
68
|
+
const bandFor = (tier) => {
|
|
69
|
+
if (!Object.hasOwn(CAP_TABLE, tier)) {
|
|
70
|
+
throw fail(1, `unknown archive tier: ${String(tier)} (known: ${TIERS.join(', ')})`);
|
|
71
|
+
}
|
|
72
|
+
return CAP_TABLE[tier];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const assertCount = (tier, count) => {
|
|
76
|
+
if (!Number.isInteger(count) || count < 0) {
|
|
77
|
+
throw fail(1, `${tier}: line count must be a non-negative integer, got ${JSON.stringify(count)}`);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// The stamp. `count` is the FINAL line count of the file being written, frontmatter included — the
|
|
82
|
+
// caller renders the whole file once with a placeholder cap, counts it, and renders again with what
|
|
83
|
+
// this returns. The stamped integer occupies one line either way, so the second render has the same
|
|
84
|
+
// count as the first and the value is a fixed point.
|
|
85
|
+
export const capFor = ({ tier, count }) => {
|
|
86
|
+
const band = bandFor(tier);
|
|
87
|
+
assertCount(tier, count);
|
|
88
|
+
if (count > band.ceiling) {
|
|
89
|
+
throw fail(
|
|
90
|
+
1,
|
|
91
|
+
`${tier} archive is ${count} lines, past its ${band.ceiling}-line ceiling — there is no cap it ` +
|
|
92
|
+
`can both stamp and honour. Remedy: ${band.remedy}. Raising the ceiling is a reviewed edit to ` +
|
|
93
|
+
`archive-caps.mjs, never something a run decides for itself.`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return count > band.floor ? count : band.floor;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// The sharding tripwire. A tier past its FLOOR still stamps and still passes — it is simply growing
|
|
100
|
+
// on room the floor was not sized for. Warning here puts the remedy in front of whoever runs the
|
|
101
|
+
// archiver while there is still headroom, instead of at the ceiling where the run refuses.
|
|
102
|
+
export const shardingWarning = ({ tier, count }) => {
|
|
103
|
+
const band = bandFor(tier);
|
|
104
|
+
assertCount(tier, count);
|
|
105
|
+
if (count <= band.floor) return null;
|
|
106
|
+
return (
|
|
107
|
+
`${tier} archive is ${count} lines, past its ${band.floor}-line floor (ceiling ${band.ceiling}) — ` +
|
|
108
|
+
`it is stamping its own count. Remedy before the count goes PAST the ceiling and the run refuses: ${band.remedy}.`
|
|
109
|
+
);
|
|
110
|
+
};
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { expect } from './_expect-shim.mjs';
|
|
6
|
+
import { runCli } from './archive-changelog.mjs';
|
|
7
|
+
|
|
8
|
+
// RED-FIRST, and deliberately a DYNAMIC import: `archive-caps.mjs` does not exist when these arms
|
|
9
|
+
// are first recorded, and a STATIC import of an absent module makes the WHOLE file unloadable — every
|
|
10
|
+
// arm would then be recorded as "unresolvable" rather than as failing, which proves nothing about the
|
|
11
|
+
// behaviour it pins. Imported this way the file still loads and each arm fails on its own missing
|
|
12
|
+
// symbol.
|
|
13
|
+
const caps = await import('./archive-caps.mjs').catch(() => ({}));
|
|
14
|
+
|
|
15
|
+
// The tier table, restated here on purpose. A test that imports the table it is checking agrees with
|
|
16
|
+
// the source by construction and pins nothing; these three rows are the frozen contract, and moving a
|
|
17
|
+
// number in the module must break this file.
|
|
18
|
+
const TABLE = [
|
|
19
|
+
['cold', 1500, 3000],
|
|
20
|
+
['warm', 3500, 7000],
|
|
21
|
+
['meta', 1500, 3000],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Every arm builds its own corpus under a temp root and passes a fixed `--today`, so nothing here
|
|
25
|
+
// reads the project's own `docs/ai` — these pass in a clean checkout where that tree is absent.
|
|
26
|
+
const TODAY = '2026-06-15';
|
|
27
|
+
const FM = '---\ntype: history\nlastUpdated: 2026-06-15\nscope: permanent\nstaleAfter: never\nowner: none\nmaxLines: 700\n---\n';
|
|
28
|
+
|
|
29
|
+
const makeRoot = () => {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), 'archive-caps-'));
|
|
31
|
+
mkdirSync(join(root, 'docs', 'ai', 'history'), { recursive: true });
|
|
32
|
+
return root;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// TALL entries carry a files block and a metric, so compression keeps eight lines of them; MINIMAL
|
|
36
|
+
// entries keep three. The difference is how a tier is driven to a chosen size: COLD/WARM need height,
|
|
37
|
+
// META needs COUNT (one line per archived entry) with every month's own COLD file left small.
|
|
38
|
+
const entryBlock = (dateStr, n, tall) =>
|
|
39
|
+
tall
|
|
40
|
+
? `## ${dateStr} — synthetic ${n}\n\n**Goal:** synthetic session ${n}.\n\n**Files:**\n- \`src/f${n}.mjs\`\n\n**Result:** ${n} tests\n`
|
|
41
|
+
: `## ${dateStr} — synthetic ${n}\n\n**Goal:** synthetic session ${n}.\n`;
|
|
42
|
+
|
|
43
|
+
const seed = (root, dates, tall = true) => {
|
|
44
|
+
const body = dates.map((d, i) => entryBlock(d, i + 1, tall)).join('\n');
|
|
45
|
+
writeFileSync(join(root, 'docs', 'ai', 'changelog.md'), `${FM}\n# Changelog\n\n${body}`, 'utf8');
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const day = (i) => String((i % 28) + 1).padStart(2, '0');
|
|
49
|
+
|
|
50
|
+
// One month, well behind the WARM cutoff — everything lands in that month's COLD file.
|
|
51
|
+
const oneMonth = (count) => Array.from({ length: count }, (_, i) => `2026.03.${day(i)}`);
|
|
52
|
+
|
|
53
|
+
// Inside the WARM window (older than today-3, newer than today-30 = 2026-05-16).
|
|
54
|
+
const warmWindow = (count) =>
|
|
55
|
+
Array.from({ length: count }, (_, i) => `2026.05.${String(17 + (i % 14)).padStart(2, '0')}`);
|
|
56
|
+
|
|
57
|
+
// Spread across many CLOSED months so META grows by count while no single COLD file gets near its
|
|
58
|
+
// own ceiling — the only shape in which META can be the tier that refuses.
|
|
59
|
+
const manyMonths = (months, perMonth) => {
|
|
60
|
+
const dates = [];
|
|
61
|
+
for (let m = 0; m < months; m += 1) {
|
|
62
|
+
const year = 2019 + Math.floor(m / 12);
|
|
63
|
+
const month = String((m % 12) + 1).padStart(2, '0');
|
|
64
|
+
for (let i = 0; i < perMonth; i += 1) dates.push(`${year}.${month}.${day(i)}`);
|
|
65
|
+
}
|
|
66
|
+
return dates;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const run = (root, argv) => {
|
|
70
|
+
const out = [];
|
|
71
|
+
const err = [];
|
|
72
|
+
const code = runCli([...argv, `--today=${TODAY}`], {
|
|
73
|
+
root,
|
|
74
|
+
log: (m) => out.push(String(m)),
|
|
75
|
+
logError: (m) => err.push(String(m)),
|
|
76
|
+
});
|
|
77
|
+
return { code, out: out.join('\n'), err: err.join('\n') };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const historyDir = (root) => join(root, 'docs', 'ai', 'history');
|
|
81
|
+
const emitted = (root, name) => readFileSync(join(historyDir(root), name), 'utf8');
|
|
82
|
+
|
|
83
|
+
// A refusal must leave the tree BYTE-unchanged, and "the history dir is still empty" does not say
|
|
84
|
+
// that: it would miss a rewritten changelog.md. Snapshot every file under docs/ai instead.
|
|
85
|
+
// Built with fromCharCode, not an escape literal: the separator only has to be deterministic, and
|
|
86
|
+
// spelling it in code points keeps a stray byte out of a file the NUL guard scans.
|
|
87
|
+
const SEP = String.fromCharCode(10, 64, 64, 10);
|
|
88
|
+
const snapshot = (root) => {
|
|
89
|
+
const base = join(root, 'docs', 'ai');
|
|
90
|
+
const walk = (dir, prefix) =>
|
|
91
|
+
readdirSync(dir, { withFileTypes: true })
|
|
92
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
93
|
+
.flatMap((entry) =>
|
|
94
|
+
entry.isDirectory()
|
|
95
|
+
? walk(join(dir, entry.name), `${prefix}${entry.name}/`)
|
|
96
|
+
: [`${prefix}${entry.name}:${readFileSync(join(dir, entry.name), 'utf8')}`],
|
|
97
|
+
);
|
|
98
|
+
return walk(base, '').join(SEP);
|
|
99
|
+
};
|
|
100
|
+
const stampOf = (text) => Number(/^maxLines: (\d+)$/m.exec(text)[1]);
|
|
101
|
+
const countOf = (text) => text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
102
|
+
|
|
103
|
+
const refusal = (fn) => {
|
|
104
|
+
try {
|
|
105
|
+
fn();
|
|
106
|
+
return null;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return err;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const withRoot = (body) => {
|
|
113
|
+
const root = makeRoot();
|
|
114
|
+
try {
|
|
115
|
+
body(root);
|
|
116
|
+
} finally {
|
|
117
|
+
rmSync(root, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
describe('capFor — the frozen tier table', () => {
|
|
122
|
+
for (const [tier, floor, ceiling] of TABLE) {
|
|
123
|
+
it(`capFor: ${tier} stamps the floor below it, its own count inside the band, and the ceiling itself`, () => {
|
|
124
|
+
expect(caps.capFor({ tier, count: 0 })).toBe(floor);
|
|
125
|
+
expect(caps.capFor({ tier, count: floor - 1 })).toBe(floor);
|
|
126
|
+
expect(caps.capFor({ tier, count: floor })).toBe(floor);
|
|
127
|
+
expect(caps.capFor({ tier, count: floor + 1 })).toBe(floor + 1);
|
|
128
|
+
expect(caps.capFor({ tier, count: ceiling - 1 })).toBe(ceiling - 1);
|
|
129
|
+
// The boundary ACCEPTS. A cap equal to the count is a cap the file honours, so the ceiling is
|
|
130
|
+
// the LAST count that can be stamped, never the first that refuses — and stamping the count
|
|
131
|
+
// (not the ceiling) here is what separates this from a `Math.min(count, ceiling)` that would
|
|
132
|
+
// pass the boundary arm while getting the whole band wrong.
|
|
133
|
+
expect(caps.capFor({ tier, count: ceiling })).toBe(ceiling);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it(`capFor: ${tier} refuses one line past its ceiling`, () => {
|
|
137
|
+
const err = refusal(() => caps.capFor({ tier, count: ceiling + 1 }));
|
|
138
|
+
expect(err).not.toBeNull();
|
|
139
|
+
expect(err.message).toContain(`${ceiling}-line ceiling`);
|
|
140
|
+
expect(err.exitCode).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
it('capFor: an unknown tier and a nonsense count both refuse rather than stamp a default', () => {
|
|
145
|
+
expect(refusal(() => caps.capFor({ tier: 'lukewarm', count: 10 })).message).toContain('unknown archive tier');
|
|
146
|
+
expect(refusal(() => caps.capFor({ tier: 'cold', count: -1 })).message).toContain('non-negative integer');
|
|
147
|
+
expect(refusal(() => caps.capFor({ tier: 'cold', count: 1.5 })).message).toContain('non-negative integer');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('countLines: counts the docs gate its own way — a trailing newline ends a line, it never opens one', () => {
|
|
151
|
+
expect(caps.countLines('a\nb\n')).toBe(2);
|
|
152
|
+
expect(caps.countLines('a\nb')).toBe(2);
|
|
153
|
+
expect(caps.countLines('a\n')).toBe(1);
|
|
154
|
+
expect(caps.countLines('a\n\n')).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('shardingWarning: silent under the floor, and past it names the tier, the count and the remedy', () => {
|
|
158
|
+
expect(caps.shardingWarning({ tier: 'meta', count: 1500 })).toBeNull();
|
|
159
|
+
const warning = caps.shardingWarning({ tier: 'meta', count: 1501 });
|
|
160
|
+
expect(warning).toContain('1501');
|
|
161
|
+
expect(warning).toContain('1500-line floor');
|
|
162
|
+
expect(warning).toContain('condensed-index-YYYY.md');
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('the archiver stamps a cap it can honour', () => {
|
|
167
|
+
it('cold: a band-sized month stamps its own count and round-trips byte-identically', () => {
|
|
168
|
+
withRoot((root) => {
|
|
169
|
+
seed(root, oneMonth(160));
|
|
170
|
+
expect(run(root, []).code).toBe(0);
|
|
171
|
+
|
|
172
|
+
const first = emitted(root, '2026-03.md');
|
|
173
|
+
const count = countOf(first);
|
|
174
|
+
// Strictly inside the band: below the floor the tier would stamp the floor and this arm would
|
|
175
|
+
// pin nothing; at or above the ceiling it would refuse.
|
|
176
|
+
expect(count).toBeGreaterThan(1500);
|
|
177
|
+
expect(count).toBeLessThan(3001);
|
|
178
|
+
expect(stampOf(first)).toBe(count);
|
|
179
|
+
|
|
180
|
+
// The byte fixed point, under the new stamp: run two re-reads what run one wrote — including
|
|
181
|
+
// the cap it stamped — and must reproduce it exactly.
|
|
182
|
+
expect(run(root, []).code).toBe(0);
|
|
183
|
+
expect(emitted(root, '2026-03.md')).toBe(first);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('warm: a band-sized window stamps its own count and round-trips byte-identically', () => {
|
|
188
|
+
withRoot((root) => {
|
|
189
|
+
seed(root, warmWindow(400));
|
|
190
|
+
expect(run(root, []).code).toBe(0);
|
|
191
|
+
|
|
192
|
+
const first = emitted(root, 'recent.md');
|
|
193
|
+
const count = countOf(first);
|
|
194
|
+
expect(count).toBeGreaterThan(3500);
|
|
195
|
+
expect(count).toBeLessThan(7001);
|
|
196
|
+
expect(stampOf(first)).toBe(count);
|
|
197
|
+
|
|
198
|
+
expect(run(root, []).code).toBe(0);
|
|
199
|
+
expect(emitted(root, 'recent.md')).toBe(first);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('meta: a band-sized corpus stamps its own count and round-trips byte-identically', () => {
|
|
204
|
+
withRoot((root) => {
|
|
205
|
+
seed(root, manyMonths(32, 50), false);
|
|
206
|
+
expect(run(root, []).code).toBe(0);
|
|
207
|
+
|
|
208
|
+
const first = emitted(root, 'condensed-index.md');
|
|
209
|
+
const count = countOf(first);
|
|
210
|
+
expect(count).toBeGreaterThan(1500);
|
|
211
|
+
expect(count).toBeLessThan(3001);
|
|
212
|
+
expect(stampOf(first)).toBe(count);
|
|
213
|
+
|
|
214
|
+
expect(run(root, []).code).toBe(0);
|
|
215
|
+
expect(emitted(root, 'condensed-index.md')).toBe(first);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('a tier under its floor still stamps the floor — the cap never shrinks to the corpus', () => {
|
|
220
|
+
withRoot((root) => {
|
|
221
|
+
seed(root, oneMonth(4));
|
|
222
|
+
expect(run(root, []).code).toBe(0);
|
|
223
|
+
const cold = emitted(root, '2026-03.md');
|
|
224
|
+
expect(countOf(cold)).toBeLessThan(1500);
|
|
225
|
+
expect(stampOf(cold)).toBe(1500);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('the archiver refuses past a ceiling, identically in every mode', () => {
|
|
231
|
+
// The refusal has to fire in --check and --dry-run as well as the default run, or the standing
|
|
232
|
+
// `--check` gate would go green on a corpus the next real run cannot write. That is only true if
|
|
233
|
+
// every output is BUILT before the mode branch — which is what these three arms pin.
|
|
234
|
+
const modes = [[], ['--dry-run'], ['--check']];
|
|
235
|
+
|
|
236
|
+
// Each mode gets its OWN fixture: sharing one root lets an earlier mode's write hide inside a
|
|
237
|
+
// later mode's assertion. And the tree is compared WHOLE before and after, so a rewritten
|
|
238
|
+
// changelog.md is caught as well as a written archive.
|
|
239
|
+
const refusesInEveryMode = (dates, tall, ceiling) => {
|
|
240
|
+
for (const argv of modes) {
|
|
241
|
+
withRoot((root) => {
|
|
242
|
+
seed(root, dates, tall);
|
|
243
|
+
const before = snapshot(root);
|
|
244
|
+
const result = run(root, argv);
|
|
245
|
+
expect(result.code).toBe(1);
|
|
246
|
+
expect(result.err).toContain(`${ceiling}-line ceiling`);
|
|
247
|
+
expect(snapshot(root)).toBe(before);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
it('cold: a month past the ceiling refuses in all three modes', () => {
|
|
253
|
+
refusesInEveryMode(oneMonth(400), true, 3000);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('warm: a window past the ceiling refuses in all three modes', () => {
|
|
257
|
+
refusesInEveryMode(warmWindow(700), true, 7000);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('meta: a corpus past the ceiling refuses in all three modes', () => {
|
|
261
|
+
refusesInEveryMode(manyMonths(62, 50), false, 3000);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Two arms, deliberately separate: this one pins that a refusal is ACTIONABLE at all — it names
|
|
265
|
+
// the tier, the measured size and a remedy clause, rather than a bare number the reader has to
|
|
266
|
+
// interpret.
|
|
267
|
+
it('the refusal names the tier and the remedy, not just the number', () => {
|
|
268
|
+
withRoot((root) => {
|
|
269
|
+
seed(root, oneMonth(400));
|
|
270
|
+
const cold = run(root, ['--check']);
|
|
271
|
+
expect(cold.err).toContain('cold archive is');
|
|
272
|
+
expect(cold.err).toContain('-line ceiling');
|
|
273
|
+
expect(cold.err).toContain('Remedy:');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// And this one pins that the remedy is RIGHT for the tier it is printed for. One piece of advice
|
|
278
|
+
// would be wrong for two of the three: COLD is already one file per month, and WARM's size is a
|
|
279
|
+
// flag rather than a layout. A remedy must also never name a layout the archiver cannot read back.
|
|
280
|
+
it('the refusal names the tier and a remedy that fits THAT tier', () => {
|
|
281
|
+
withRoot((root) => {
|
|
282
|
+
seed(root, oneMonth(400));
|
|
283
|
+
const cold = run(root, ['--check']);
|
|
284
|
+
expect(cold.err).toContain('already one file per month');
|
|
285
|
+
expect(cold.err).toContain('NOT implemented');
|
|
286
|
+
});
|
|
287
|
+
withRoot((root) => {
|
|
288
|
+
seed(root, warmWindow(700));
|
|
289
|
+
const warm = run(root, ['--check']);
|
|
290
|
+
expect(warm.err).toContain('warm archive is');
|
|
291
|
+
expect(warm.err).toContain('--warm-days');
|
|
292
|
+
});
|
|
293
|
+
withRoot((root) => {
|
|
294
|
+
seed(root, manyMonths(62, 50), false);
|
|
295
|
+
const meta = run(root, ['--check']);
|
|
296
|
+
expect(meta.err).toContain('meta archive is');
|
|
297
|
+
expect(meta.err).toContain('condensed-index-YYYY.md');
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('the sharding tripwire', () => {
|
|
303
|
+
it('meta past its floor warns while still passing, so the remedy lands before the ceiling', () => {
|
|
304
|
+
withRoot((root) => {
|
|
305
|
+
seed(root, manyMonths(32, 50), false);
|
|
306
|
+
const result = run(root, []);
|
|
307
|
+
expect(result.code).toBe(0);
|
|
308
|
+
expect(result.err).toContain('past its 1500-line floor');
|
|
309
|
+
expect(result.err).toContain('condensed-index-YYYY.md');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('meta under its floor says nothing at all', () => {
|
|
314
|
+
withRoot((root) => {
|
|
315
|
+
seed(root, oneMonth(4));
|
|
316
|
+
const result = run(root, []);
|
|
317
|
+
expect(result.code).toBe(0);
|
|
318
|
+
expect(result.err).not.toMatch(/floor/);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -13,19 +13,25 @@
|
|
|
13
13
|
// does not parse as an entry refuses with file:line — it is never glued into the previous entry,
|
|
14
14
|
// never duplicated into the footer, never normalised into a different calendar date.
|
|
15
15
|
//
|
|
16
|
-
// NOTE (multi-year scaling): condensed-index.md grows O(total archived entries),
|
|
17
|
-
//
|
|
18
|
-
// test).
|
|
19
|
-
//
|
|
20
|
-
// a
|
|
16
|
+
// NOTE (multi-year scaling): condensed-index.md grows O(total archived entries) and never sheds, so
|
|
17
|
+
// on a multi-year horizon it is the tier that runs out of room first (~1159 lines over 2y in a
|
|
18
|
+
// stress test). Its floor is 1500 and its ceiling 3000 (archive-caps.mjs): past the FLOOR every run
|
|
19
|
+
// prints a sharding warning, and once the count goes PAST the ceiling the run refuses outright
|
|
20
|
+
// rather than stamp a cap it cannot honour — the ceiling itself is still stamped. The remedy is per-year sharding (condensed-index-YYYY.md) — a queued row, never
|
|
21
|
+
// something a run decides for itself. Stress-test rotation via the exported pure functions against
|
|
22
|
+
// a temp copy seeded with a synthetic multi-year dataset (include burst periods).
|
|
21
23
|
//
|
|
22
24
|
// Modes:
|
|
23
25
|
// (default) run rotation, mutate files in place
|
|
24
26
|
// --dry-run print planned distribution, do not change files
|
|
25
27
|
// --check exit 1 if changelog.md still holds entries that should be archived
|
|
26
28
|
//
|
|
27
|
-
// Every mode parses every source
|
|
28
|
-
//
|
|
29
|
+
// Every mode parses every source AND builds every tier-STAMPED output (WARM, COLD, META) BEFORE any
|
|
30
|
+
// write, so a refusal — an unparsable source, or a tier that has outgrown its ceiling — fires
|
|
31
|
+
// identically for the default run, --dry-run and --check, and nothing is written on a refused input.
|
|
32
|
+
// The HOT changelog is deliberately NOT in that set: buildChangelog passes the file's OWN
|
|
33
|
+
// frontmatter through and reaches a literal only when the file carries none. That fallback is
|
|
34
|
+
// unguarded, and it is a filed queue row rather than an oversight.
|
|
29
35
|
//
|
|
30
36
|
// CLI overrides:
|
|
31
37
|
// --hot-days=N (default 3)
|
|
@@ -36,6 +42,7 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync, realpa
|
|
|
36
42
|
import { dirname, resolve, basename } from 'node:path';
|
|
37
43
|
import { fileURLToPath } from 'node:url';
|
|
38
44
|
import { tokenizeMarkdown, findParagraphBreak, fail } from './markdown-blocks.mjs';
|
|
45
|
+
import { capFor, countLines, shardingWarning } from './archive-caps.mjs';
|
|
39
46
|
|
|
40
47
|
const __filename = fileURLToPath(import.meta.url);
|
|
41
48
|
const __dirname = dirname(__filename);
|
|
@@ -347,22 +354,25 @@ export const buildChangelog = ({ frontmatter, preamble, hot, footer, hasArchive
|
|
|
347
354
|
return parts.filter((p) => p !== null && p !== undefined).join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
348
355
|
};
|
|
349
356
|
|
|
357
|
+
// Each tier renders TWICE and stamps what the second render can honour. The cap is one line of
|
|
358
|
+
// frontmatter whatever integer it holds, so the first render's line count IS the final file's line
|
|
359
|
+
// count — which makes the stamp a fixed point rather than a promise about a corpus nobody has seen.
|
|
360
|
+
// capFor refuses outright past the tier's ceiling; see archive-caps.mjs.
|
|
361
|
+
const stamped = (tier, render) => render(capFor({ tier, count: countLines(render(0)) }));
|
|
362
|
+
|
|
350
363
|
export const buildRecent = (entries, todayStr) => {
|
|
351
|
-
const frontmatter = FRONTMATTER('history', 3500, todayStr);
|
|
352
364
|
const preamble = `# Changelog WARM Archive — ${PROJECT_NAME}\n\n> Entries aged **7–30 days** from today. Newer → [\`../changelog.md\`](../changelog.md). Older → [\`condensed-index.md\`](./condensed-index.md) plus per-month \`YYYY-MM.md\` files.`;
|
|
353
365
|
const body = renderEntries(entries);
|
|
354
|
-
return `${
|
|
366
|
+
return stamped('warm', (cap) => `${FRONTMATTER('history', cap, todayStr)}\n${preamble}\n\n---\n\n${body}\n`);
|
|
355
367
|
};
|
|
356
368
|
|
|
357
369
|
export const buildCold = (year, month, entries, todayStr) => {
|
|
358
|
-
const frontmatter = FRONTMATTER('history', 1500, todayStr);
|
|
359
370
|
const preamble = `# Changelog COLD Archive — ${year}-${month}\n\n> Compressed entries from ${year}-${month} (older than 30 days). Cross-month one-liners → [\`condensed-index.md\`](./condensed-index.md). Full commit history: \`git log --since=${year}-${month}-01 --until=${year}-${month}-31\`.`;
|
|
360
371
|
const compressed = entries.map(compressEntry).join('\n\n---\n\n');
|
|
361
|
-
return `${
|
|
372
|
+
return stamped('cold', (cap) => `${FRONTMATTER('history', cap, todayStr)}\n${preamble}\n\n---\n\n${compressed}\n`);
|
|
362
373
|
};
|
|
363
374
|
|
|
364
375
|
export const buildCondensedIndex = (warmEntries, coldByMonth, todayStr) => {
|
|
365
|
-
const frontmatter = FRONTMATTER('history', 300, todayStr);
|
|
366
376
|
const intro = `# Condensed Index — ${PROJECT_NAME} Changelog\n\n> One-line TL;DR for every archived entry. Each line links to the file holding the full text.`;
|
|
367
377
|
|
|
368
378
|
const lines = [];
|
|
@@ -378,7 +388,7 @@ export const buildCondensedIndex = (warmEntries, coldByMonth, todayStr) => {
|
|
|
378
388
|
for (const e of coldByMonth.get(key)) lines.push(summarizeEntry(e, `${year}-${month}.md`));
|
|
379
389
|
lines.push('');
|
|
380
390
|
}
|
|
381
|
-
return `${
|
|
391
|
+
return stamped('meta', (cap) => `${FRONTMATTER('history', cap, todayStr)}\n${intro}\n\n${lines.join('\n').trim()}\n`);
|
|
382
392
|
};
|
|
383
393
|
|
|
384
394
|
export const groupByMonth = (entries) => {
|
|
@@ -471,6 +481,27 @@ export const runCli = (argv, deps = {}) => {
|
|
|
471
481
|
coldFiles: [...coldByMonth.keys()].sort(),
|
|
472
482
|
};
|
|
473
483
|
|
|
484
|
+
// EVERY cap-bearing output is BUILT here, before the mode branch, and capFor refuses a tier that
|
|
485
|
+
// has outgrown its ceiling. That refusal has to fire identically in all three modes: a `--check`
|
|
486
|
+
// that went green on a corpus the next real run cannot write would be exactly the fail-open the
|
|
487
|
+
// parse above already refuses to be. Building is pure — nothing reaches disk until the writes
|
|
488
|
+
// below, so a refusal still leaves the tree untouched.
|
|
489
|
+
const archives = [];
|
|
490
|
+
if (warm.length > 0) archives.push({ path: recentPath, text: buildRecent(warm, todayStr) });
|
|
491
|
+
for (const [key, entries] of coldByMonth) {
|
|
492
|
+
const [year, month] = key.split('-');
|
|
493
|
+
archives.push({ path: resolve(historyDir, `${year}-${month}.md`), text: buildCold(year, month, entries, todayStr) });
|
|
494
|
+
}
|
|
495
|
+
if (warm.length > 0 || cold.length > 0) {
|
|
496
|
+
const index = buildCondensedIndex(warm, coldByMonth, todayStr);
|
|
497
|
+
// The sharding tripwire. META grows O(total) and never sheds, so it is the tier that reaches
|
|
498
|
+
// its ceiling first; warning at the floor puts the remedy in front of whoever runs the
|
|
499
|
+
// archiver while there is still room, instead of at the ceiling where the run refuses.
|
|
500
|
+
const warning = shardingWarning({ tier: 'meta', count: countLines(index) });
|
|
501
|
+
if (warning) logError(`[archive-changelog] WARNING: ${warning}`);
|
|
502
|
+
archives.push({ path: indexPath, text: index });
|
|
503
|
+
}
|
|
504
|
+
|
|
474
505
|
if (flags.check) {
|
|
475
506
|
const tooOldInHot = parsed.entries.filter((e) => e.dateObj < cutoffs.hotCutoff);
|
|
476
507
|
if (tooOldInHot.length > 0) {
|
|
@@ -516,18 +547,7 @@ export const runCli = (argv, deps = {}) => {
|
|
|
516
547
|
});
|
|
517
548
|
writeFileSync(changelogPath, newChangelog, 'utf8');
|
|
518
549
|
|
|
519
|
-
|
|
520
|
-
writeFileSync(recentPath, buildRecent(warm, todayStr), 'utf8');
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
for (const [key, entries] of coldByMonth) {
|
|
524
|
-
const [year, month] = key.split('-');
|
|
525
|
-
writeFileSync(resolve(historyDir, `${year}-${month}.md`), buildCold(year, month, entries, todayStr), 'utf8');
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
if (warm.length > 0 || cold.length > 0) {
|
|
529
|
-
writeFileSync(indexPath, buildCondensedIndex(warm, coldByMonth, todayStr), 'utf8');
|
|
530
|
-
}
|
|
550
|
+
for (const archive of archives) writeFileSync(archive.path, archive.text, 'utf8');
|
|
531
551
|
|
|
532
552
|
log('[archive-changelog] migrated:');
|
|
533
553
|
log(` HOT (${CHANGELOG_REL}): ${hot.length}`);
|
|
@@ -158,12 +158,19 @@ describe('buildChangelog', () => {
|
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
describe('buildRecent', () => {
|
|
161
|
-
it('emits frontmatter
|
|
161
|
+
it('emits frontmatter at the WARM floor for a corpus that fits under it', () => {
|
|
162
162
|
const result = buildRecent([makeEntry('2026.05.10', 'warm')], '2026-05-24');
|
|
163
163
|
expect(result).toMatch(/maxLines: 3500/);
|
|
164
164
|
expect(result).toMatch(/Changelog WARM Archive/);
|
|
165
165
|
expect(result).toMatch(/## 2026\.05\.10/);
|
|
166
166
|
});
|
|
167
|
+
|
|
168
|
+
// 3500/1500/1500 are tier FLOORS reached through capFor (archive-caps.mjs), not literals a builder
|
|
169
|
+
// still carries — this pins all three onto that path; band/ceiling/fixed point: archive-caps.test.mjs.
|
|
170
|
+
it('every builder stamps its own tier floor on a corpus that fits under it', () => {
|
|
171
|
+
expect(buildCold('2026', '03', [makeEntry('2026.03.10', 'c')], '2026-05-24')).toMatch(/maxLines: 1500/);
|
|
172
|
+
expect(buildCondensedIndex([makeEntry('2026.05.10', 'w')], new Map(), '2026-05-24')).toMatch(/maxLines: 1500/);
|
|
173
|
+
});
|
|
167
174
|
});
|
|
168
175
|
|
|
169
176
|
describe('buildCold', () => {
|
|
@@ -38,7 +38,7 @@ export const SPEC_SCHEMA = Object.freeze({
|
|
|
38
38
|
'frontmatter', 'frontmatter-key', 'substrate-key', 'type', 'kind', 'maxlines', 'status', 'revision',
|
|
39
39
|
'root-owns', 'slug', 'kind-path', 'root-uplink', 'title', 'section-missing', 'section-order',
|
|
40
40
|
'section-forbidden', 'fence', 'children-link', 'children-duplicate', 'fan-out', 'scenario-line',
|
|
41
|
-
'scenario-number', 'scenario-marker', 'scenario-path', 'out-of-scope', 'module-line', 'module-empty',
|
|
41
|
+
'scenario-number', 'scenario-marker', 'scenario-path', 'scenarios-empty', 'out-of-scope', 'module-line', 'module-empty',
|
|
42
42
|
'module-traversal', 'module-absolute', 'module-backslash', 'module-glob', 'module-mix', 'parts',
|
|
43
43
|
]),
|
|
44
44
|
});
|
|
@@ -238,6 +238,10 @@ const checkScenarios = (parsed, slug, status, errors, warnings) => {
|
|
|
238
238
|
}
|
|
239
239
|
scenarios.push(scenario);
|
|
240
240
|
}
|
|
241
|
+
if (scenarios.length === 0) {
|
|
242
|
+
errors.push({ rule: 'scenarios-empty', message: `at least one scenario line (\`${SPEC_SCHEMA.unboundMarker}\` while no test pins it)` });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
241
245
|
const gap = scenarios.findIndex((scenario, i) => scenario.n !== i + 1);
|
|
242
246
|
if (gap !== -1) errors.push({ rule: 'scenario-number', message: `scenario ${gap + 1} is numbered S${scenarios[gap].n} — N runs contiguously from 1` });
|
|
243
247
|
for (const scenario of scenarios) {
|
|
@@ -131,6 +131,14 @@ describe('readSpecDocument — refuse, exactly one rule per defect', () => {
|
|
|
131
131
|
refuses(specDoc({ scenarios: ['- S1 a :: /abs/login.test.mjs :: spec:login/S1'] }), 'login.md', 'scenario-path');
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
it('scenarios-empty / no empty-marker escape', () => {
|
|
135
|
+
refuses(specDoc({ scenarios: [] }), 'login.md', 'scenarios-empty');
|
|
136
|
+
refuses(specDoc({ scenarios: ['*(empty)*'] }), 'login.md', 'scenario-line');
|
|
137
|
+
for (const status of SPEC_SCHEMA.statuses) {
|
|
138
|
+
expect(readSpecDocument(specDoc({ scenarios: ['- S1 x :: unbound'], fields: { status } }), 'login.md').errors).toEqual([]);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
134
142
|
it('out-of-scope / module-empty / module-* path refusals / module-mix / parts', () => {
|
|
135
143
|
refuses(specDoc({ outOfScope: '' }), 'login.md', 'out-of-scope');
|
|
136
144
|
refuses(specDoc({ outOfScope: 'nothing excluded' }), 'login.md', 'out-of-scope');
|
|
@@ -49,6 +49,8 @@ export const KIT_OWN_PATHS = [
|
|
|
49
49
|
'/CLAUDE.md',
|
|
50
50
|
'/docs/ai/',
|
|
51
51
|
'/scripts/_expect-shim.mjs',
|
|
52
|
+
'/scripts/archive-caps.mjs',
|
|
53
|
+
'/scripts/archive-caps.test.mjs',
|
|
52
54
|
'/scripts/archive-changelog.mjs',
|
|
53
55
|
'/scripts/archive-changelog.test.mjs',
|
|
54
56
|
'/scripts/archive-conservation.test.mjs',
|
|
@@ -125,14 +125,29 @@ export const planScriptRefresh = (cwd, deps = {}) => {
|
|
|
125
125
|
// archivers to this kit's canon without their runtime dependency would leave every refreshed
|
|
126
126
|
// script crashing on a missing `./markdown-blocks.mjs` import until a separate upgrade run — so
|
|
127
127
|
// the dependency rides the SAME apply, atomically, written before its importers.
|
|
128
|
-
|
|
128
|
+
// `archive-caps.mjs` joined the list the moment `archive-changelog.mjs` began importing it: a new
|
|
129
|
+
// import by a refreshed archiver reopens this exact hole, so the list moves with the imports.
|
|
130
|
+
//
|
|
131
|
+
// A seed is UNCONDITIONAL only when every refreshable archiver imports it. `markdown-blocks.mjs` is
|
|
132
|
+
// imported by all three (changelog, decisions, issues), so any refresh needs it. `archive-caps.mjs`
|
|
133
|
+
// is imported by `archive-changelog.mjs` ALONE — seeding it on a plain ADR migration that never
|
|
134
|
+
// refreshed the changelog archiver would write a file with no importer, which is the directional
|
|
135
|
+
// "never ADD a basename the consumer lacks" rule broken and a log line that says something untrue.
|
|
136
|
+
const COMPANION_SEEDS = [
|
|
137
|
+
{ name: 'markdown-blocks.mjs', requiredBy: null },
|
|
138
|
+
{ name: 'markdown-blocks.test.mjs', requiredBy: null },
|
|
139
|
+
{ name: 'archive-caps.mjs', requiredBy: 'archive-changelog.mjs' },
|
|
140
|
+
{ name: 'archive-caps.test.mjs', requiredBy: 'archive-changelog.mjs' },
|
|
141
|
+
];
|
|
129
142
|
export const planCompanionSeeds = (cwd, refresh, deps = {}) => {
|
|
130
143
|
if (refresh.length === 0) return [];
|
|
131
144
|
const exists = deps.exists ?? existsSync;
|
|
132
145
|
const kitScripts = deps.kitScripts ?? KIT_SCRIPTS;
|
|
133
146
|
const consumerScripts = join(cwd, CONSUMER_SCRIPTS_REL);
|
|
147
|
+
const refreshed = new Set(refresh.map((entry) => entry.name));
|
|
134
148
|
const out = [];
|
|
135
|
-
for (const name of COMPANION_SEEDS) {
|
|
149
|
+
for (const { name, requiredBy } of COMPANION_SEEDS) {
|
|
150
|
+
if (requiredBy !== null && !refreshed.has(requiredBy)) continue;
|
|
136
151
|
const canon = join(kitScripts, name);
|
|
137
152
|
const dst = join(consumerScripts, name);
|
|
138
153
|
if (exists(canon) && !exists(dst)) out.push({ name, canon, dst });
|
package/tools/script-priors.mjs
CHANGED
|
@@ -31,6 +31,8 @@ export const SCRIPT_PRIORS = Object.freeze([
|
|
|
31
31
|
prior('check-docs-size.test.mjs', '4.0.0', '4.5.4', '88fbb3d7f097d74771b7c5d9ad99fcd58b274ae33f391e1ff01f4b138b9236cd'),
|
|
32
32
|
prior('spec-schema.mjs', '4.6.0', '4.6.1', 'f8ee23d81e90fd4225ca4ece288cba41982c4430290bc6d033f5ca18d2d283f4'),
|
|
33
33
|
prior('spec-schema.test.mjs', '4.6.0', '4.6.1', 'a12d6d3f5d32c6dabdee7e15af7d2ab15a0ced37515d1844fe0951f60cddbc99'),
|
|
34
|
+
prior('spec-schema.mjs', '4.7.0', '4.7.0', '40b5b038d5ec5ed53c327c6d269d22fe5fa2bed99ae711fbf84306ad047be452'),
|
|
35
|
+
prior('spec-schema.test.mjs', '4.7.0', '4.7.0', 'fde896419924223e54cfcabfdb1ef5807df5386fac700ed7c1463b6e7f81501b'),
|
|
34
36
|
]);
|
|
35
37
|
|
|
36
38
|
export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|