@sabaiway/agent-workflow-memory 4.2.0 → 4.4.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 +62 -0
- package/SKILL.md +22 -4
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size-cli.test.mjs +5 -4
- package/references/scripts/check-docs-size-ensure.test.mjs +332 -0
- package/references/scripts/check-docs-size.mjs +181 -30
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/templates/gates.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,68 @@ 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.4.0 — the deploy finishes by writing the navigator its entry point declares (AD-096; ships with kit 5.9.0)
|
|
8
|
+
|
|
9
|
+
**The substrate deployed an `AGENTS.md` that calls `docs/ai/index.md` always-loaded, and no step
|
|
10
|
+
ever wrote it.** The navigator is generated — `check-docs-size.mjs` is its only writer — and neither
|
|
11
|
+
bootstrap nor upgrade ran the generator, so a fresh Node deployment started life with a pre-commit
|
|
12
|
+
hook failing its own index check, and a project without Node stayed silently broken. Fixed at the
|
|
13
|
+
source: the generator gains an idempotent finalizer, and every documented path now runs it.
|
|
14
|
+
|
|
15
|
+
- **`check-docs-size.mjs --ensure-index [--root=<dir>]`** — probe first, write only when the
|
|
16
|
+
navigator is missing or stale, and close with exactly ONE outcome line: `ensure-index:
|
|
17
|
+
regenerated` / `already-current` on stdout, or `write-refused` / `probe-failed` on stderr with
|
|
18
|
+
exit 2. It reuses the existing generator and freshness check — there is no second index
|
|
19
|
+
implementation, and no seed template that could drift from it.
|
|
20
|
+
- **The bootstrap and upgrade prose run it at the LAST `docs/ai` mutation of each path:** after the
|
|
21
|
+
template fill (which covers a No-Node target — the generator runs from the skill home, so the
|
|
22
|
+
step-8 script copy is not a precondition), stamp-independently BEFORE the equal-head short-circuit
|
|
23
|
+
on upgrade, and again after the migrations before the re-stamp. Either refusal is a STOP.
|
|
24
|
+
- **The write is contained and atomic.** Every component of `<root>/docs/ai/index.md` is lstat'ed
|
|
25
|
+
no-follow — a symlinked root, `docs`, `docs/ai` or leaf refuses by name — the body is published
|
|
26
|
+
through an exclusive-create temp renamed into place with the chain re-checked immediately before
|
|
27
|
+
the rename, and a failure ATTEMPTS the temp discard — never a name this run did not create, and a
|
|
28
|
+
cleanup that itself fails names the temp it could not remove instead of swallowing it. The
|
|
29
|
+
containment guard runs BEFORE the freshness read, so a symlink whose target happens to hold
|
|
30
|
+
current bytes is refused rather than reported "already current".
|
|
31
|
+
- **"Could not read" never passes for "nothing there".** Under the finalizer, only a genuine
|
|
32
|
+
`ENOENT` counts as an absence; any other read failure refuses instead of publishing an index
|
|
33
|
+
missing whatever the run could not see. A malformed `package.json` stays the documented
|
|
34
|
+
project-name fallback.
|
|
35
|
+
- **A closure guard** asserts every `docs/ai` reference in the entry-point template's Memory Map
|
|
36
|
+
resolves to a shipped template, a generated artifact whose finalizer the prose documents, or an
|
|
37
|
+
exact exception list — and the deploy fixture EXTRACTS the documented command from `SKILL.md` and
|
|
38
|
+
runs it, so a step that loses its finalizer line fails as a broken deployment.
|
|
39
|
+
|
|
40
|
+
Known residual: a project that already carries `scripts/check-docs-size.mjs` keeps its un-hardened
|
|
41
|
+
copy (the scripts ensure is create-only by contract) — repair rides the next lineage migration.
|
|
42
|
+
|
|
43
|
+
## 4.3.0 — the migration emits a destination that refuses by name, accepts the producer marker, and preserves a vendored core check (AD-092 + AD-093 + AD-094; ships with kit 5.7.0)
|
|
44
|
+
|
|
45
|
+
The `migrate-gates.mjs` canon moves in lockstep with the kit it is byte-twinned to. Three changes,
|
|
46
|
+
all in the drift-guarded shared block or the branches around it:
|
|
47
|
+
|
|
48
|
+
- **The emitted coverage destination becomes `"${AW_GIT_DIR:?exported by run-gates}/…lcov.info"`.**
|
|
49
|
+
Where the runner injects the variable, behaviour is byte-identical; where a human pastes the cmd
|
|
50
|
+
into a bare shell, bash refuses loudly by name — the old form expanded to empty and wrote the
|
|
51
|
+
lcov to the filesystem root. Recognition stays APPEND-ONLY: every previously emitted form is
|
|
52
|
+
still recognised, so an old-form declaration migrates as `keep` with zero diff.
|
|
53
|
+
- **The migration recognises the `lcovProducer` marker.** An optional gate-level boolean; only the
|
|
54
|
+
literal `true` is a producer claim, and producer-ness stays POSITIONAL — a marker on the coverage
|
|
55
|
+
checker itself never self-pairs. Strict schema validation of the key lives in the kit
|
|
56
|
+
(`gates-declaration.mjs`), which accepts it in lockstep with this release; the published 5.6.0
|
|
57
|
+
kit rejects a marker-carrying `gates.json` at exit 5 by design (forward-only; the kit's
|
|
58
|
+
Issue-016 note owns the cross-version statement).
|
|
59
|
+
- **A vendored copy of a core check is PRESERVED instead of stopping the upgrade.** The
|
|
60
|
+
three-outcome claim classifier (`canonical` / `tool-elsewhere` / `not-the-tool`) routes a
|
|
61
|
+
shape-matching copy at a non-canonical realpath into a `keep` row plus separately rendered
|
|
62
|
+
verify metadata — exit 0, nothing auto-added, a zero-diff apply, with the preview stating the
|
|
63
|
+
verification instead of
|
|
64
|
+
claiming final-run-capability the runner would refuse. A genuine id-squatter still hard-stops.
|
|
65
|
+
|
|
66
|
+
This package stays standalone — it imports nothing from the kit; the shared block is held equal by
|
|
67
|
+
the text drift guard, never by an import.
|
|
68
|
+
|
|
7
69
|
## 4.2.0 — the gates migration stops handing you a coverage checker with nothing to read (AD-089)
|
|
8
70
|
|
|
9
71
|
`migrate-gates.mjs` added the canonical `coverage-check` gate to any legacy declaration that lacked
|
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.4.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
|
@@ -113,7 +113,15 @@ bootstrapping over a live system, but the user makes the final call.
|
|
|
113
113
|
`docs/ai/autonomy.json` — the per-project autonomy policy, seeded SPARSE (the onboarding note
|
|
114
114
|
only, defaults-equivalent — behavior changes only when the user declares levels). Keep each
|
|
115
115
|
`.md` file's frontmatter.
|
|
116
|
-
7. **Fill templates** per the table below.
|
|
116
|
+
7. **Fill templates** per the table below. **Then materialize the navigator, as the last `docs/ai`
|
|
117
|
+
step:** the entry point declares `docs/ai/index.md` always-loaded and it is a GENERATED artifact
|
|
118
|
+
with no template, so run
|
|
119
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
120
|
+
and relay its one outcome line (`ensure-index: regenerated` / `already-current`, or a
|
|
121
|
+
`write-refused` / `probe-failed` line naming the offending path — either is a STOP: resolve it
|
|
122
|
+
before reporting the deploy, since the navigator the entry point declares does not exist yet). The
|
|
123
|
+
generator runs from the **skill home**, so a **No-Node target** is covered too: the operator's own
|
|
124
|
+
host runs it, and step 8's copy is not a precondition.
|
|
117
125
|
8. **Install enforcement (Node projects).** Copy `${CLAUDE_SKILL_DIR}/references/scripts/*.mjs`
|
|
118
126
|
(+ `*.test.mjs`) into the project's `scripts/`. **No Node runtime** → skip this + the hook;
|
|
119
127
|
follow the cap/archive/index policy manually.
|
|
@@ -214,7 +222,14 @@ Fill strategy:
|
|
|
214
222
|
the tree untouched and report the pending migration. The deployed pre-commit
|
|
215
223
|
hook gains its `archive-decisions.mjs --check` line only when the hook is next refreshed via
|
|
216
224
|
`node scripts/install-git-hooks.mjs`; an old hook without the line stays consistent-safe (the
|
|
217
|
-
decisions gate is simply not enforced yet — never a broken hook). **
|
|
225
|
+
decisions gate is simply not enforced yet — never a broken hook). **Same gate, also
|
|
226
|
+
stamp-independent — ensure the NAVIGATOR:** `docs/ai/index.md` is a GENERATED artifact the entry
|
|
227
|
+
point declares always-loaded, so a deployment that never had one must gain it even at head — run
|
|
228
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
229
|
+
and relay its outcome line. It writes only when the navigator is missing or stale
|
|
230
|
+
(`already-current` otherwise), so a re-run changes nothing; a `write-refused` (the write) or
|
|
231
|
+
`probe-failed` (the tree could not be read) line names the offending path and STOPs this
|
|
232
|
+
reconcile like any other failed ensure. **Then**, if the stamp **equals** the head → the substrate is
|
|
218
233
|
current (no structure migration is due), and stop after reporting. Report **in the user's
|
|
219
234
|
conversational language**: if step 2's reconcile just **changed something** (a footprint move /
|
|
220
235
|
config seed), say **what changed** in plain terms and ask before committing; if **nothing changed at
|
|
@@ -242,7 +257,10 @@ Fill strategy:
|
|
|
242
257
|
absent (a legacy `AGENTS.md`), gracefully **no-op** on that slot (adding a slot to
|
|
243
258
|
already-deployed files is the composition root's reconcile, not this substrate's job). On any
|
|
244
259
|
malformed marker state (single, reversed, nested, or duplicate pair), **no-op with an error** —
|
|
245
|
-
never edit.
|
|
260
|
+
never edit. **Then, before the stamp, re-run the navigator finalizer:** steps 4–6 may have added
|
|
261
|
+
or changed `docs/ai` files, so run
|
|
262
|
+
`node ${CLAUDE_SKILL_DIR}/references/scripts/check-docs-size.mjs --ensure-index --root=<target>`
|
|
263
|
+
once more (idempotent — an untouched tree reports `already-current`) and relay its outcome line.
|
|
246
264
|
7. **Re-stamp** `docs/ai/.memory-version` to the lineage head (atomic write — mechanics unchanged).
|
|
247
265
|
Report changes **in plain human terms** (which parts of the deployment are now different);
|
|
248
266
|
**omit the raw structure number**, and do not recite the two-axes note here (it belongs to
|
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.4.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",
|
|
@@ -28,12 +28,13 @@ describe('check-docs-size runCli — refusal branches', () => {
|
|
|
28
28
|
join(root, 'docs', 'ai', 'a.md'),
|
|
29
29
|
'---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# a\n',
|
|
30
30
|
);
|
|
31
|
-
// The index path is a symlink into /dev/null: the write
|
|
32
|
-
// the
|
|
33
|
-
|
|
31
|
+
// The index path is a symlink into /dev/null: the contained write refuses to publish THROUGH
|
|
32
|
+
// the link (it would clobber the link target), names the path, and writes nothing.
|
|
33
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
34
|
+
symlinkSync('/dev/null', indexPath);
|
|
34
35
|
const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
|
|
35
36
|
assert.equal(code, 2);
|
|
36
|
-
assert.match(stderr,
|
|
37
|
+
assert.match(stderr, new RegExp(`${indexPath} is a symlink`));
|
|
37
38
|
} finally {
|
|
38
39
|
rmSync(root, { recursive: true, force: true });
|
|
39
40
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// check-docs-size-ensure.test.mjs — the navigator WRITE contract (containment + atomic publication)
|
|
2
|
+
// and the idempotent `--ensure-index` finalizer mode. A separate file because the main spec pair is
|
|
3
|
+
// suite-parity-frozen; single responsibility: what the write refuses, and what the mode reports.
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, symlinkSync, rmSync, existsSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { runCli } from './check-docs-size.mjs';
|
|
10
|
+
|
|
11
|
+
const DOC = (title) =>
|
|
12
|
+
`---\ntype: state\nlastUpdated: 2026-08-15\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: 10\n---\n\n# ${title}\n`;
|
|
13
|
+
|
|
14
|
+
const makeTree = (prefix) => {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
16
|
+
mkdirSync(join(dir, 'docs', 'ai'), { recursive: true });
|
|
17
|
+
writeFileSync(join(dir, 'docs', 'ai', 'a.md'), DOC('a'));
|
|
18
|
+
return dir;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const drop = (...dirs) => {
|
|
22
|
+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const tempFiles = (docsAi) => readdirSync(docsAi).filter((name) => name.endsWith('.tmp'));
|
|
26
|
+
|
|
27
|
+
describe('check-docs-size navigator write — containment', () => {
|
|
28
|
+
it('refuses a symlinked index.md, exit 2, naming the path', async () => {
|
|
29
|
+
const root = makeTree('cds-leaf-');
|
|
30
|
+
const target = mkdtempSync(join(tmpdir(), 'cds-leaf-target-'));
|
|
31
|
+
try {
|
|
32
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
33
|
+
symlinkSync(join(target, 'sink.md'), indexPath);
|
|
34
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
|
|
35
|
+
assert.equal(code, 2);
|
|
36
|
+
assert.match(stderr, /symlink/);
|
|
37
|
+
assert.ok(stderr.includes(indexPath), `refusal must name ${indexPath}, got: ${stderr}`);
|
|
38
|
+
} finally {
|
|
39
|
+
drop(root, target);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('refuses a symlinked docs/ai, exit 2, naming the path', async () => {
|
|
44
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-docsai-'));
|
|
45
|
+
const real = mkdtempSync(join(tmpdir(), 'cds-docsai-real-'));
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(join(root, 'docs'), { recursive: true });
|
|
48
|
+
writeFileSync(join(real, 'a.md'), DOC('a'));
|
|
49
|
+
symlinkSync(real, join(root, 'docs', 'ai'));
|
|
50
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
|
|
51
|
+
assert.equal(code, 2);
|
|
52
|
+
assert.ok(stderr.includes(join(root, 'docs', 'ai')), `refusal must name the symlinked dir, got: ${stderr}`);
|
|
53
|
+
} finally {
|
|
54
|
+
drop(root, real);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('refuses a symlinked docs, exit 2, naming the path', async () => {
|
|
59
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-docs-'));
|
|
60
|
+
const real = mkdtempSync(join(tmpdir(), 'cds-docs-real-'));
|
|
61
|
+
try {
|
|
62
|
+
mkdirSync(join(real, 'ai'), { recursive: true });
|
|
63
|
+
writeFileSync(join(real, 'ai', 'a.md'), DOC('a'));
|
|
64
|
+
symlinkSync(real, join(root, 'docs'));
|
|
65
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`]);
|
|
66
|
+
assert.equal(code, 2);
|
|
67
|
+
assert.ok(stderr.includes(join(root, 'docs')), `refusal must name the symlinked dir, got: ${stderr}`);
|
|
68
|
+
} finally {
|
|
69
|
+
drop(root, real);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('refuses a symlinked project root, exit 2, naming the path', async () => {
|
|
74
|
+
const real = makeTree('cds-root-real-');
|
|
75
|
+
const host = mkdtempSync(join(tmpdir(), 'cds-root-host-'));
|
|
76
|
+
const link = join(host, 'project');
|
|
77
|
+
try {
|
|
78
|
+
symlinkSync(real, link);
|
|
79
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${link}`]);
|
|
80
|
+
assert.equal(code, 2);
|
|
81
|
+
assert.ok(stderr.includes(link), `refusal must name the symlinked root, got: ${stderr}`);
|
|
82
|
+
} finally {
|
|
83
|
+
drop(real, host);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('writes an absent index rather than refusing it', async () => {
|
|
88
|
+
const root = makeTree('cds-absent-');
|
|
89
|
+
try {
|
|
90
|
+
const { code } = await runCli(['--write-index', `--root=${root}`]);
|
|
91
|
+
assert.equal(code, 0);
|
|
92
|
+
assert.ok(existsSync(join(root, 'docs', 'ai', 'index.md')));
|
|
93
|
+
} finally {
|
|
94
|
+
drop(root);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// The temp name is exclusive-create for a reason: a collision means the name is SOMEONE ELSE's
|
|
99
|
+
// file, which this run never wrote and must never delete.
|
|
100
|
+
it('never removes a temp name it lost the exclusive-create race for', async () => {
|
|
101
|
+
const root = makeTree('cds-collision-');
|
|
102
|
+
const removed = [];
|
|
103
|
+
try {
|
|
104
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`], {
|
|
105
|
+
writeFile: () => {
|
|
106
|
+
throw Object.assign(new Error('EEXIST: file already exists'), { code: 'EEXIST' });
|
|
107
|
+
},
|
|
108
|
+
rm: (target) => { removed.push(target); },
|
|
109
|
+
});
|
|
110
|
+
assert.equal(code, 2);
|
|
111
|
+
assert.match(stderr, /EEXIST/);
|
|
112
|
+
assert.deepEqual(removed, [], 'a collision is not this run\'s file to discard');
|
|
113
|
+
} finally {
|
|
114
|
+
drop(root);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('discards the temp when the write itself dies mid-flight', async () => {
|
|
119
|
+
const root = makeTree('cds-partial-');
|
|
120
|
+
const removed = [];
|
|
121
|
+
try {
|
|
122
|
+
const { code } = await runCli(['--write-index', `--root=${root}`], {
|
|
123
|
+
writeFile: () => {
|
|
124
|
+
throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' });
|
|
125
|
+
},
|
|
126
|
+
rm: (target) => { removed.push(target); },
|
|
127
|
+
});
|
|
128
|
+
assert.equal(code, 2);
|
|
129
|
+
assert.equal(removed.length, 1, 'the partially-created temp is the run\'s own to discard');
|
|
130
|
+
assert.match(removed[0], /\.tmp$/);
|
|
131
|
+
} finally {
|
|
132
|
+
drop(root);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('names the temp it could not remove instead of swallowing the cleanup failure', async () => {
|
|
137
|
+
const root = makeTree('cds-cleanup-');
|
|
138
|
+
try {
|
|
139
|
+
const { code, stderr } = await runCli(['--write-index', `--root=${root}`], {
|
|
140
|
+
rename: () => {
|
|
141
|
+
throw Object.assign(new Error('EXDEV: cross-device link'), { code: 'EXDEV' });
|
|
142
|
+
},
|
|
143
|
+
rm: () => {
|
|
144
|
+
throw Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
assert.equal(code, 2);
|
|
148
|
+
assert.match(stderr, /could not be removed/);
|
|
149
|
+
assert.match(stderr, /\.tmp/);
|
|
150
|
+
} finally {
|
|
151
|
+
drop(root);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('leaves no temp file and no index behind when the publication fails', async () => {
|
|
156
|
+
const root = makeTree('cds-tmp-');
|
|
157
|
+
try {
|
|
158
|
+
const { code } = await runCli(['--write-index', `--root=${root}`], {
|
|
159
|
+
rename: () => {
|
|
160
|
+
throw Object.assign(new Error('injected publication failure'), { code: 'EIO' });
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
assert.equal(code, 2);
|
|
164
|
+
assert.deepEqual(tempFiles(join(root, 'docs', 'ai')), []);
|
|
165
|
+
assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false);
|
|
166
|
+
} finally {
|
|
167
|
+
drop(root);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('check-docs-size --ensure-index', () => {
|
|
173
|
+
it('reports regenerated and materializes the navigator on a fresh tree', async () => {
|
|
174
|
+
const root = makeTree('cds-ensure-fresh-');
|
|
175
|
+
try {
|
|
176
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
177
|
+
assert.equal(code, 0);
|
|
178
|
+
assert.match(stdout, /ensure-index: regenerated/);
|
|
179
|
+
const { code: checkCode } = await runCli(['--check-index', `--root=${root}`]);
|
|
180
|
+
assert.equal(checkCode, 0);
|
|
181
|
+
} finally {
|
|
182
|
+
drop(root);
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('reports already-current on a second run and leaves the bytes identical', async () => {
|
|
187
|
+
const root = makeTree('cds-ensure-idem-');
|
|
188
|
+
try {
|
|
189
|
+
await runCli(['--ensure-index', `--root=${root}`]);
|
|
190
|
+
const first = readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
191
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`, '--today=2027-01-01']);
|
|
192
|
+
assert.equal(code, 0);
|
|
193
|
+
assert.match(stdout, /ensure-index: already-current/);
|
|
194
|
+
assert.equal(readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8'), first);
|
|
195
|
+
} finally {
|
|
196
|
+
drop(root);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// A symlink whose TARGET happens to hold the current bytes reads as fresh through the link — so a
|
|
201
|
+
// freshness-first finalizer would report `already-current` over a file it would never write
|
|
202
|
+
// through: an exit 0 that proves nothing about the deployment's own navigator.
|
|
203
|
+
it('refuses a symlinked navigator even when its target holds the CURRENT bytes', async () => {
|
|
204
|
+
const root = makeTree('cds-ensure-symlink-fresh-');
|
|
205
|
+
try {
|
|
206
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
207
|
+
await runCli(['--ensure-index', `--root=${root}`]);
|
|
208
|
+
const current = readFileSync(indexPath, 'utf8');
|
|
209
|
+
rmSync(indexPath);
|
|
210
|
+
const target = join(root, 'elsewhere-index.md');
|
|
211
|
+
writeFileSync(target, current);
|
|
212
|
+
symlinkSync(target, indexPath);
|
|
213
|
+
|
|
214
|
+
const { code, stdout, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
215
|
+
assert.equal(code, 2, `a symlinked navigator must be refused, got: ${stdout}${stderr}`);
|
|
216
|
+
assert.match(stderr, /ensure-index: write-refused/);
|
|
217
|
+
assert.equal(readFileSync(target, 'utf8'), current, 'the link target is untouched');
|
|
218
|
+
} finally {
|
|
219
|
+
drop(root);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// The two refusals name STAGES: a reader told the PROBE failed will go looking at the tree, while
|
|
224
|
+
// a write that died may have left the navigator half-published.
|
|
225
|
+
it('reports a WRITE failure as write-refused, never as a failed probe', async () => {
|
|
226
|
+
const root = makeTree('cds-write-stage-');
|
|
227
|
+
try {
|
|
228
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
|
|
229
|
+
writeFile: () => {
|
|
230
|
+
throw Object.assign(new Error('EIO: i/o error'), { code: 'EIO' });
|
|
231
|
+
},
|
|
232
|
+
rm: () => {},
|
|
233
|
+
});
|
|
234
|
+
assert.equal(code, 2);
|
|
235
|
+
assert.match(stderr, /ensure-index: write-refused/);
|
|
236
|
+
assert.match(stderr, /EIO/);
|
|
237
|
+
} finally {
|
|
238
|
+
drop(root);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// "Could not read" is not "nothing there": a finalizer that swallowed the difference would publish
|
|
243
|
+
// a navigator missing whatever it failed to see, and report success over it.
|
|
244
|
+
it('refuses a tree it cannot fully read instead of writing an incomplete navigator', async () => {
|
|
245
|
+
const root = makeTree('cds-strict-meta-');
|
|
246
|
+
try {
|
|
247
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
|
|
248
|
+
readdir: () => {
|
|
249
|
+
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
assert.equal(code, 2);
|
|
253
|
+
assert.match(stderr, /ensure-index: probe-failed/);
|
|
254
|
+
assert.match(stderr, /EACCES/);
|
|
255
|
+
assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false, 'nothing written over a tree it could not read');
|
|
256
|
+
} finally {
|
|
257
|
+
drop(root);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// "Unknown" must not read as "empty" either: a reader that throws WITHOUT an fs code is still not
|
|
262
|
+
// evidence that the tree is absent, so the finalizer refuses rather than publishing a short index.
|
|
263
|
+
it('refuses a code-less read failure too, instead of treating it as an absence', async () => {
|
|
264
|
+
const root = makeTree('cds-codeless-');
|
|
265
|
+
try {
|
|
266
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`], {
|
|
267
|
+
readdir: () => {
|
|
268
|
+
throw new Error('the injected reader failed without an fs code');
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
assert.equal(code, 2);
|
|
272
|
+
assert.match(stderr, /ensure-index: probe-failed/);
|
|
273
|
+
assert.equal(existsSync(join(root, 'docs', 'ai', 'index.md')), false);
|
|
274
|
+
} finally {
|
|
275
|
+
drop(root);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a MALFORMED package.json stays the documented basename fallback, never a refusal', async () => {
|
|
280
|
+
const root = makeTree('cds-badpkg-');
|
|
281
|
+
try {
|
|
282
|
+
writeFileSync(join(root, 'package.json'), '{ not json');
|
|
283
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
284
|
+
assert.equal(code, 0, 'authored content that is malformed is not an unreadable tree');
|
|
285
|
+
assert.match(stdout, /ensure-index: regenerated/);
|
|
286
|
+
} finally {
|
|
287
|
+
drop(root);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('closes with ONE named probe-failed line when the tree cannot be read', async () => {
|
|
292
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-ensure-nodocs-'));
|
|
293
|
+
try {
|
|
294
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
295
|
+
assert.equal(code, 2);
|
|
296
|
+
assert.match(stderr, /ensure-index: probe-failed/);
|
|
297
|
+
assert.equal(/at .*check-docs-size/.test(stderr), false, 'a named refusal, never a stack trace');
|
|
298
|
+
} finally {
|
|
299
|
+
drop(root);
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('reports a named write-refused, exit 2, when the write cannot be published', async () => {
|
|
304
|
+
const root = makeTree('cds-ensure-refused-');
|
|
305
|
+
const target = mkdtempSync(join(tmpdir(), 'cds-ensure-refused-target-'));
|
|
306
|
+
try {
|
|
307
|
+
const indexPath = join(root, 'docs', 'ai', 'index.md');
|
|
308
|
+
symlinkSync(join(target, 'sink.md'), indexPath);
|
|
309
|
+
const { code, stderr } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
310
|
+
assert.equal(code, 2);
|
|
311
|
+
assert.match(stderr, /ensure-index: write-refused/);
|
|
312
|
+
assert.ok(stderr.includes(indexPath), `the refusal must name ${indexPath}, got: ${stderr}`);
|
|
313
|
+
} finally {
|
|
314
|
+
drop(root, target);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('reports regenerated when the on-disk navigator went stale', async () => {
|
|
319
|
+
const root = makeTree('cds-ensure-stale-');
|
|
320
|
+
try {
|
|
321
|
+
await runCli(['--ensure-index', `--root=${root}`]);
|
|
322
|
+
writeFileSync(join(root, 'docs', 'ai', 'b.md'), DOC('b'));
|
|
323
|
+
const { code, stdout } = await runCli(['--ensure-index', `--root=${root}`]);
|
|
324
|
+
assert.equal(code, 0);
|
|
325
|
+
assert.match(stdout, /ensure-index: regenerated/);
|
|
326
|
+
const { code: checkCode } = await runCli(['--check-index', `--root=${root}`]);
|
|
327
|
+
assert.equal(checkCode, 0);
|
|
328
|
+
} finally {
|
|
329
|
+
drop(root);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
});
|