@sabaiway/agent-workflow-memory 6.0.0 → 7.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 +51 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/check-docs-size-cli.test.mjs +259 -21
- package/references/scripts/check-docs-size.mjs +89 -19
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@ 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
|
+
## 7.0.0 — a symlinked docs file gets named instead of skipped (AD-119)
|
|
8
|
+
|
|
9
|
+
The docs cap-validator discovered files through `readdir(dir, { withFileTypes: true })` and kept an
|
|
10
|
+
entry only when `entry.isFile()`. For a symlink that predicate is FALSE — and so is `isDirectory()`
|
|
11
|
+
— so a symlinked `*.md` under `docs/ai` fell through BOTH arms of the walk and left it entirely.
|
|
12
|
+
Probed: a temp tree holding a 14-line `real.md` under `maxLines: 3`, a `linked.md` pointing at it and
|
|
13
|
+
an `escaped.md` pointing outside `docs/ai` reported `1 files inspected — 1 error(s)`. No cap check,
|
|
14
|
+
no missing-frontmatter error, no staleness check, no navigator row — and not counted in the file
|
|
15
|
+
total either, which is what made it a lie rather than a gap.
|
|
16
|
+
|
|
17
|
+
> ### ⚠ BREAKING — a docs tree that was green can now go RED
|
|
18
|
+
>
|
|
19
|
+
> A symlink under `docs/ai` named `*.md`, or resolving to a directory, is now a NAMED error row and
|
|
20
|
+
> is never read.
|
|
21
|
+
>
|
|
22
|
+
> `--check-index` additionally runs the navigator's containment guard BEFORE it reads. On a chain
|
|
23
|
+
> the walk can TRAVERSE, that guard rejects a symlink at the project root, `docs`, `docs/ai` or
|
|
24
|
+
> `docs/ai/index.md` and exits 2 — where the mode used to compare and pass. It is the same guard
|
|
25
|
+
> `--write-index` and `--ensure-index` already applied. (A chain the walk cannot traverse at all —
|
|
26
|
+
> a dangling or unreadable `docs/ai` — still ends the run as it always did, before any guard; only
|
|
27
|
+
> `--ensure-index` promises one named line there. That is unchanged by this release.)
|
|
28
|
+
> Reading through a link matters because `buildIndex` drops the navigator's own row, so a symlinked
|
|
29
|
+
> `index.md` whose target held the current bytes compared EQUAL and reported it fresh (measured:
|
|
30
|
+
> exit 0 before, exit 2 after).
|
|
31
|
+
>
|
|
32
|
+
> **When it bites.** Only a deployment that actually has such a symlink — and only once these bytes
|
|
33
|
+
> reach its `scripts/`, which happens on a fresh bootstrap, a `migrate-adr-store` refresh, a
|
|
34
|
+
> prior-matched `specs` ensure, or a hand copy; installing this package does not overwrite an
|
|
35
|
+
> existing deployed `check-docs-size.mjs`. Such a project sees `docs-caps` turn red with no edit of
|
|
36
|
+
> its own. For a symlinked docs FILE, `docs-index` reds until the navigator is regenerated with that
|
|
37
|
+
> link's row. For a symlink on the navigator's own chain the regeneration is itself REFUSED, so
|
|
38
|
+
> `--write-index` cannot clear it — only replacing the link can.
|
|
39
|
+
>
|
|
40
|
+
> **Remedy.** Replace it with a real file or a real directory, whichever it stands for, or move it out
|
|
41
|
+
> of `docs/ai`. The gate names the path and which case it is.
|
|
42
|
+
|
|
43
|
+
**What is deliberately preserved.** A symlinked NON-`.md` regular file is still skipped, so the
|
|
44
|
+
pinned `orchestration.json` skip is unchanged; a tree with no symlink under `docs/ai` writes a
|
|
45
|
+
byte-identical navigator; and an over-cap REAL `adr/` record still collapses into the aggregate row,
|
|
46
|
+
because the collapse guard keys on the refusal and never on `errors.length`. Each is pinned by its
|
|
47
|
+
own arm.
|
|
48
|
+
|
|
49
|
+
**Only `ENOENT` and `ENOTDIR` mean "nothing is there".** The name decides first — a link named `*.md`
|
|
50
|
+
is refused with no `stat` at all — and only a differently named one is stat'ed, to learn whether it
|
|
51
|
+
stands where a directory would and would hide a whole subtree. Every other code (`EACCES`, `EIO`,
|
|
52
|
+
`ELOOP` from a symlink cycle) yields a named refusal carrying the code, because treating an
|
|
53
|
+
unclassifiable link as a skip is how the subtree behind it escapes all over again.
|
|
54
|
+
|
|
55
|
+
`walkMarkdownFiles` keeps its exact historical one-argument signature and contract: the `*.md` files
|
|
56
|
+
a run may READ. A refused symlink is not one of them — it is a row, not a file.
|
|
57
|
+
|
|
7
58
|
## 6.0.0 — a rolling archive stamps a cap it can honour, and refuses past a ceiling (AD-118)
|
|
8
59
|
|
|
9
60
|
The changelog archiver wrote each tier's frontmatter `maxLines` as a LITERAL in the builder that
|
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: '
|
|
6
|
+
version: '7.0.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": "
|
|
3
|
+
"version": "7.0.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",
|
|
@@ -1,17 +1,62 @@
|
|
|
1
1
|
// check-docs-size-cli.test.mjs — runCli branch pins the subprocess smokes cannot reach
|
|
2
2
|
// in-process (Phase-5 coverage fill; the main spec file is parity-frozen, so these ride a
|
|
3
|
-
// colocated file): the unknown-argument refusal
|
|
3
|
+
// colocated file): the unknown-argument refusal, the pre-write symlink refusal on the index path,
|
|
4
|
+
// and the symlink refusals the WALK itself emits.
|
|
4
5
|
import { describe, it } from 'node:test';
|
|
5
6
|
import assert from 'node:assert/strict';
|
|
6
|
-
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
|
|
7
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, symlinkSync, rmSync } from 'node:fs';
|
|
7
8
|
import { tmpdir } from 'node:os';
|
|
8
9
|
import { join } from 'node:path';
|
|
9
10
|
import { runCli } from './check-docs-size.mjs';
|
|
10
11
|
|
|
11
|
-
const cli =
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
const cli = (argv) => runCli(argv);
|
|
13
|
+
const DOC = (name, maxLines = 10) =>
|
|
14
|
+
`---\ntype: state\nlastUpdated: 2026-07-18\nscope: session\nstaleAfter: never\nowner: none\nmaxLines: ${maxLines}\n---\n\n# ${name}\n`;
|
|
15
|
+
|
|
16
|
+
// One temp root per arm, always torn down: docs/ai holds one real `a.md`, and `build` adds
|
|
17
|
+
// whatever the arm needs before the CLI runs.
|
|
18
|
+
const withRoot = async (build, run) => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'cds-cli-'));
|
|
20
|
+
const docs = join(root, 'docs', 'ai');
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(docs, { recursive: true });
|
|
23
|
+
// A fixed package.json name, so the navigator's own bytes are deterministic across temp roots.
|
|
24
|
+
writeFileSync(join(root, 'package.json'), '{ "name": "probe-project" }\n');
|
|
25
|
+
writeFileSync(join(docs, 'a.md'), DOC('a'));
|
|
26
|
+
build({ root, docs });
|
|
27
|
+
await run({ root, docs });
|
|
28
|
+
} finally {
|
|
29
|
+
rmSync(root, { recursive: true, force: true });
|
|
30
|
+
}
|
|
14
31
|
};
|
|
32
|
+
const indexOf = (root) => readFileSync(join(root, 'docs', 'ai', 'index.md'), 'utf8');
|
|
33
|
+
|
|
34
|
+
// The exact navigator a symlink-free probe tree writes. A characterization literal: the symlink
|
|
35
|
+
// refusal must leave every byte of it — header, preamble, table, footer — untouched.
|
|
36
|
+
const EXPECTED_INDEX = `---
|
|
37
|
+
type: reference
|
|
38
|
+
lastUpdated: 2026-07-18
|
|
39
|
+
scope: permanent
|
|
40
|
+
staleAfter: 30d
|
|
41
|
+
owner: none
|
|
42
|
+
maxLines: 80
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Memory Map — probe-project \`docs/ai/\`
|
|
46
|
+
|
|
47
|
+
> **Auto-generated** — edit the source files' frontmatter, not this file. Regenerate after changes.
|
|
48
|
+
> Layered context architecture:
|
|
49
|
+
> **Always-loaded** — root \`AGENTS.md\` + this index.
|
|
50
|
+
> **On-demand** — read a specific \`docs/ai/\` file when its "Read When" applies.
|
|
51
|
+
> **Hierarchical** — subdirectory \`AGENTS.md\` files load when working in that folder.
|
|
52
|
+
> **Archive** — \`history/recent.md\` (WARM) + \`history/condensed-index.md\` + per-month files.
|
|
53
|
+
|
|
54
|
+
## Files
|
|
55
|
+
|
|
56
|
+
| File | Type | Lines/Max | Updated | Stale after |
|
|
57
|
+
|------|------|-----------|---------|-------------|
|
|
58
|
+
| [\`a.md\`](./a.md) | state | 10/10 | 2026-07-18 | never |
|
|
59
|
+
`;
|
|
15
60
|
|
|
16
61
|
describe('check-docs-size runCli — refusal branches', () => {
|
|
17
62
|
it('an unknown argument exits 2 naming it', async () => {
|
|
@@ -20,23 +65,216 @@ describe('check-docs-size runCli — refusal branches', () => {
|
|
|
20
65
|
assert.match(stderr, /Unknown argument: --bogus/);
|
|
21
66
|
});
|
|
22
67
|
|
|
23
|
-
it('
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
68
|
+
it('every mode refuses a symlinked navigator whose target holds the CURRENT bytes', async () => {
|
|
69
|
+
await withRoot(
|
|
70
|
+
() => {},
|
|
71
|
+
async ({ root, docs }) => {
|
|
72
|
+
// Write the real navigator, move its bytes to a target OUTSIDE docs/ai, and point index.md
|
|
73
|
+
// at them. Freshness now MATCHES, so an implementation that reads through the link and only
|
|
74
|
+
// complains when the comparison differs would pass: the refusal has to fire by NAME, before
|
|
75
|
+
// the read. A `/dev/null` target would have proved nothing but a mismatch.
|
|
76
|
+
const indexPath = join(docs, 'index.md');
|
|
77
|
+
const target = join(root, 'elsewhere-index.md');
|
|
78
|
+
await cli(['--write-index', `--root=${root}`, '--today=2026-07-18']);
|
|
79
|
+
writeFileSync(target, readFileSync(indexPath, 'utf8'));
|
|
80
|
+
rmSync(indexPath);
|
|
81
|
+
symlinkSync(target, indexPath);
|
|
82
|
+
const linked = new RegExp(`${indexPath} is a symlink`);
|
|
83
|
+
const c = await cli(['--check-index', `--root=${root}`, '--today=2026-07-18']);
|
|
84
|
+
assert.equal(c.code, 2, 'a symlinked navigator is REFUSED, never compared');
|
|
85
|
+
assert.match(c.stderr, linked);
|
|
86
|
+
const w = await cli(['--write-index', `--root=${root}`]);
|
|
87
|
+
assert.equal(w.code, 2);
|
|
88
|
+
assert.match(w.stderr, linked);
|
|
89
|
+
const d = await cli([`--root=${root}`]);
|
|
90
|
+
assert.equal(d.code, 1);
|
|
91
|
+
assert.match(d.stdout, /docs\/ai\/index\.md[\s\S]*?ERROR\s+is a symlink/);
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('check-docs-size — a symlink under docs/ai is NAMED, never read', () => {
|
|
98
|
+
it('a link to a READABLE in-tree doc errors, its target is inspected once, and --report still exits 0', async () => {
|
|
99
|
+
await withRoot(
|
|
100
|
+
({ docs }) => symlinkSync(join(docs, 'a.md'), join(docs, 'link.md')),
|
|
101
|
+
async ({ root }) => {
|
|
102
|
+
const { code, stdout } = await cli([`--root=${root}`]);
|
|
103
|
+
assert.equal(code, 1);
|
|
104
|
+
assert.match(stdout, /docs\/ai\/link\.md[\s\S]*?ERROR\s+is a symlink/);
|
|
105
|
+
assert.equal(stdout.match(/docs\/ai\/a\.md/g).length, 1, 'the target is inspected exactly once');
|
|
106
|
+
assert.match(stdout, /2 files inspected {2}— {2}1 error/, 'the refused link is counted');
|
|
107
|
+
const r = await cli(['--report', `--root=${root}`]);
|
|
108
|
+
assert.equal(r.code, 0);
|
|
109
|
+
assert.match(r.stdout, /ERROR\s+is a symlink/);
|
|
110
|
+
},
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('a symlinked DIRECTORY errors instead of hiding its subtree', async () => {
|
|
115
|
+
await withRoot(
|
|
116
|
+
({ root, docs }) => {
|
|
117
|
+
mkdirSync(join(root, 'elsewhere'));
|
|
118
|
+
writeFileSync(join(root, 'elsewhere', 'hidden.md'), 'no frontmatter at all\n');
|
|
119
|
+
symlinkSync(join(root, 'elsewhere'), join(docs, 'history'));
|
|
120
|
+
},
|
|
121
|
+
async ({ root }) => {
|
|
122
|
+
const { code, stdout } = await cli([`--root=${root}`]);
|
|
123
|
+
assert.equal(code, 1);
|
|
124
|
+
assert.match(stdout, /docs\/ai\/history[\s\S]*?ERROR\s+is a symlink to a directory/);
|
|
125
|
+
assert.doesNotMatch(stdout, /hidden\.md/, 'the subtree behind the link is never walked');
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// The name carries no regex metacharacter on purpose: a red-proof testId is matched as a pattern,
|
|
131
|
+
// and a literal star makes the arm unselectable (measured — the mint refused it as unresolvable).
|
|
132
|
+
it('a DANGLING doc link errors by NAME, never as an ENOENT stack trace', async () => {
|
|
133
|
+
await withRoot(
|
|
134
|
+
({ docs }) => symlinkSync(join(docs, 'gone.md'), join(docs, 'dangling.md')),
|
|
135
|
+
async ({ root }) => {
|
|
136
|
+
const { code, stdout } = await cli([`--root=${root}`]);
|
|
137
|
+
assert.equal(code, 1);
|
|
138
|
+
assert.match(stdout, /docs\/ai\/dangling\.md[\s\S]*?ERROR\s+is a symlink/);
|
|
139
|
+
assert.doesNotMatch(stdout, /ENOENT/);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('a link under adr/ gets its OWN index row and joins neither the collapse count nor its range', async () => {
|
|
145
|
+
await withRoot(
|
|
146
|
+
({ docs }) => {
|
|
147
|
+
mkdirSync(join(docs, 'adr'));
|
|
148
|
+
writeFileSync(join(docs, 'adr', 'AD-001-real.md'), DOC('AD-001', 400));
|
|
149
|
+
writeFileSync(join(docs, 'adr', 'log.md'), DOC('log', 200));
|
|
150
|
+
symlinkSync(join(docs, 'adr', 'AD-001-real.md'), join(docs, 'adr', 'AD-999-link.md'));
|
|
151
|
+
},
|
|
152
|
+
async ({ root }) => {
|
|
153
|
+
assert.equal((await cli(['--write-index', `--root=${root}`])).code, 1);
|
|
154
|
+
assert.match(indexOf(root), /\| 1 records \|/, 'the real record is the only one counted');
|
|
155
|
+
assert.doesNotMatch(indexOf(root), /AD-999 \|/, 'the link never enters the id range');
|
|
156
|
+
assert.match(indexOf(root), /adr\/AD-999-link\.md/, 'the link renders its own row');
|
|
157
|
+
},
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('--check-index reds until the regenerated navigator carries the LINK row', async () => {
|
|
162
|
+
await withRoot(
|
|
163
|
+
() => {},
|
|
164
|
+
async ({ root, docs }) => {
|
|
165
|
+
// The BASELINE navigator is written first, with no link present, so the red below can only
|
|
166
|
+
// come from the added row — not from a missing index.md, which would let an implementation
|
|
167
|
+
// that merely checks the navigator EXISTS pass this arm.
|
|
168
|
+
await cli(['--write-index', `--root=${root}`, '--today=2026-07-18']);
|
|
169
|
+
assert.equal((await cli(['--check-index', `--root=${root}`])).code, 0, 'the baseline is fresh');
|
|
170
|
+
symlinkSync(join(docs, 'a.md'), join(docs, 'link.md'));
|
|
171
|
+
assert.equal((await cli(['--check-index', `--root=${root}`])).code, 1, 'the link makes it stale');
|
|
172
|
+
await cli(['--write-index', `--root=${root}`]);
|
|
173
|
+
assert.equal((await cli(['--check-index', `--root=${root}`])).code, 0);
|
|
174
|
+
assert.match(indexOf(root), /link\.md/);
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// The PARTITION itself, both sides of it — not one example. ENOENT and ENOTDIR are the only codes
|
|
180
|
+
// that mean "nothing is there"; every other one leaves the kind UNKNOWN, and unknown must never
|
|
181
|
+
// read as skip, because that is how a link standing where a directory would, with a whole .md
|
|
182
|
+
// subtree behind it, escapes the gate again. An implementation that skipped EACCES, or that
|
|
183
|
+
// refused on ENOTDIR, passes a single-code arm and fails this one.
|
|
184
|
+
const STAT_CASES = [
|
|
185
|
+
{ code: 'ELOOP', named: true },
|
|
186
|
+
{ code: 'EACCES', named: true },
|
|
187
|
+
{ code: 'EIO', named: true },
|
|
188
|
+
{ code: 'ENOENT', named: false },
|
|
189
|
+
{ code: 'ENOTDIR', named: false },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
it('a link whose kind cannot be determined is NAMED, never skipped', async () => {
|
|
193
|
+
for (const { code, named } of STAT_CASES) {
|
|
194
|
+
await withRoot(
|
|
195
|
+
({ root, docs }) => {
|
|
196
|
+
mkdirSync(join(root, 'elsewhere'));
|
|
197
|
+
writeFileSync(join(root, 'elsewhere', 'hidden.md'), 'no frontmatter at all\n');
|
|
198
|
+
symlinkSync(join(root, 'elsewhere'), join(docs, 'history'));
|
|
199
|
+
},
|
|
200
|
+
async ({ root }) => {
|
|
201
|
+
const stat = async () => {
|
|
202
|
+
throw Object.assign(new Error(`${code}: injected`), { code });
|
|
203
|
+
};
|
|
204
|
+
const { code: exit, stdout } = await runCli([`--root=${root}`], { stat });
|
|
205
|
+
if (named) {
|
|
206
|
+
assert.equal(exit, 1, code);
|
|
207
|
+
assert.match(stdout, new RegExp(`docs/ai/history[\\s\\S]*?ERROR\\s+is a symlink this run could not classify \\(${code}\\)`), code);
|
|
208
|
+
} else {
|
|
209
|
+
assert.equal(exit, 0, code);
|
|
210
|
+
assert.doesNotMatch(stdout, /docs\/ai\/history/, code);
|
|
211
|
+
}
|
|
212
|
+
assert.doesNotMatch(stdout, /hidden\.md/, code);
|
|
213
|
+
},
|
|
30
214
|
);
|
|
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);
|
|
35
|
-
const { code, stderr } = await cli(['--write-index', `--root=${root}`]);
|
|
36
|
-
assert.equal(code, 2);
|
|
37
|
-
assert.match(stderr, new RegExp(`${indexPath} is a symlink`));
|
|
38
|
-
} finally {
|
|
39
|
-
rmSync(root, { recursive: true, force: true });
|
|
40
215
|
}
|
|
41
216
|
});
|
|
42
217
|
});
|
|
218
|
+
|
|
219
|
+
// Characterization, GREEN before the refusal landed as well as after — these two pin what the
|
|
220
|
+
// change must NOT move, so neither carries a red-proof record.
|
|
221
|
+
describe('check-docs-size — what the symlink refusal preserves', () => {
|
|
222
|
+
it('a symlinked NON-.md regular file is still skipped', async () => {
|
|
223
|
+
await withRoot(
|
|
224
|
+
({ root, docs }) => {
|
|
225
|
+
writeFileSync(join(root, 'orchestration.json'), '{ "plan-authoring": { "review": "reviewed" } }\n');
|
|
226
|
+
symlinkSync(join(root, 'orchestration.json'), join(docs, 'orchestration.json'));
|
|
227
|
+
},
|
|
228
|
+
async ({ root }) => {
|
|
229
|
+
const { code, stdout } = await cli([`--root=${root}`]);
|
|
230
|
+
assert.equal(code, 0);
|
|
231
|
+
assert.doesNotMatch(stdout, /orchestration\.json/);
|
|
232
|
+
assert.match(stdout, /1 files inspected {2}— {2}0 error/);
|
|
233
|
+
},
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('a DANGLING link that is not named .md is skipped — an unclassifiable link is not in scope', async () => {
|
|
238
|
+
await withRoot(
|
|
239
|
+
({ root, docs }) => symlinkSync(join(root, 'never-existed.json'), join(docs, 'orchestration.json')),
|
|
240
|
+
async ({ root }) => {
|
|
241
|
+
// stat throws here, so the kind is unknowable — and the NAME never put it in scope, so it
|
|
242
|
+
// stays as skipped as a real non-.md file. Only a name-based refusal survives a failed stat.
|
|
243
|
+
const { code, stdout } = await cli([`--root=${root}`]);
|
|
244
|
+
assert.equal(code, 0);
|
|
245
|
+
assert.doesNotMatch(stdout, /orchestration\.json/);
|
|
246
|
+
assert.match(stdout, /1 files inspected {2}— {2}0 error/);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('an OVER-CAP real adr/ record still collapses — the guard keys on the refusal, not on errors', async () => {
|
|
252
|
+
await withRoot(
|
|
253
|
+
({ docs }) => {
|
|
254
|
+
mkdirSync(join(docs, 'adr'));
|
|
255
|
+
// 12 body lines under a maxLines of 5: a genuine cap ERROR on a genuine record.
|
|
256
|
+
writeFileSync(join(docs, 'adr', 'AD-001-fat.md'), DOC('AD-001', 5) + 'x\n'.repeat(12));
|
|
257
|
+
writeFileSync(join(docs, 'adr', 'log.md'), DOC('log', 200));
|
|
258
|
+
},
|
|
259
|
+
async ({ root }) => {
|
|
260
|
+
assert.equal((await cli(['--write-index', `--root=${root}`])).code, 1, 'the cap error still reds the run');
|
|
261
|
+
assert.match(indexOf(root), /\| 1 records \|/);
|
|
262
|
+
assert.doesNotMatch(indexOf(root), /AD-001-fat\.md/, 'an errored REAL record stays inside the collapse');
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('a tree with NO symlink under docs/ai writes a BYTE-IDENTICAL navigator', async () => {
|
|
268
|
+
await withRoot(
|
|
269
|
+
() => {},
|
|
270
|
+
async ({ root }) => {
|
|
271
|
+
const { code, stdout } = await cli([`--root=${root}`, '--today=2026-07-18', '--write-index']);
|
|
272
|
+
assert.equal(code, 0);
|
|
273
|
+
assert.match(stdout, /1 files inspected {2}— {2}0 error\(s\), 0 warning\(s\)/);
|
|
274
|
+
// The WHOLE file, not a filtered row: header, preamble, table and footer alike are what a
|
|
275
|
+
// symlink-free tree must keep producing.
|
|
276
|
+
assert.equal(indexOf(root), EXPECTED_INDEX);
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -178,23 +178,69 @@ export const parseStaleAfter = (value) => {
|
|
|
178
178
|
return Number(m[1]);
|
|
179
179
|
};
|
|
180
180
|
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
181
|
+
// A symlink is neither `isFile()` nor `isDirectory()`, so it used to fall through BOTH arms of the
|
|
182
|
+
// walk and leave it entirely — silently, not even counted in the report's file total. It is REFUSED
|
|
183
|
+
// instead, and the NAME decides first: a link named `*.md` is refused with no stat at all, and only
|
|
184
|
+
// a differently named one is stat'ed, to learn whether it stands where a directory would and would
|
|
185
|
+
// therefore hide a whole subtree. A stat that throws leaves a link this run cannot classify, which
|
|
186
|
+
// is a refusal only when the name already put it in scope — so a dangling `notes.txt` link stays as
|
|
187
|
+
// out of scope as a real one. Refusing rather than FOLLOWING is deliberate: following would need
|
|
188
|
+
// realpath, a containment test and a cycle guard, while the write side of this very module already
|
|
189
|
+
// refuses to publish through a link (assertContainedNoSymlink).
|
|
190
|
+
// The ONLY stat failures that mean "there is nothing there": the target is absent, or a path
|
|
191
|
+
// component is not a directory. Every other code — EACCES, EIO, and ELOOP from a symlink cycle —
|
|
192
|
+
// means the kind is UNKNOWN, and "unknown" must never read as "skip": that is how a link standing
|
|
193
|
+
// where a directory would, with a whole `.md` subtree behind it, would silently escape again.
|
|
194
|
+
const ABSENT_STAT_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
195
|
+
|
|
196
|
+
const symlinkRefusal = async (full, name, statPath) => {
|
|
197
|
+
if (name.endsWith('.md')) {
|
|
198
|
+
return 'is a symlink — the caps gate reads only real files it owns; replace it or move it out of docs/ai';
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
if (!(await statPath(full)).isDirectory()) return null;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (ABSENT_STAT_CODES.has(err?.code)) return null;
|
|
204
|
+
return `is a symlink this run could not classify (${err?.code ?? 'unknown'}) — it may stand where a directory would and hide a whole subtree; resolve it or move it out of docs/ai`;
|
|
205
|
+
}
|
|
206
|
+
return 'is a symlink to a directory — its whole subtree would escape the caps gate; replace it or move it out of docs/ai';
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Discover what the docs walk found: ONLY `*.md` files (recursively) plus the symlinks it refuses.
|
|
210
|
+
// Non-`.md` files — e.g. a hand-edited `docs/ai/orchestration.json` config — are inherently skipped,
|
|
211
|
+
// so they are never subject to the frontmatter / maxLines caps. Each entry is `{ path, refusal }`:
|
|
212
|
+
// a refusal rides out as a row nobody reads, never as a file nobody sees.
|
|
213
|
+
const walkDocsEntries = async (dir, deps = {}) => {
|
|
214
|
+
const readDir = deps.readdir ?? readdir;
|
|
215
|
+
const statPath = deps.stat ?? stat;
|
|
216
|
+
const entries = await readDir(dir, { withFileTypes: true });
|
|
217
|
+
const found = [];
|
|
187
218
|
for (const entry of entries) {
|
|
188
219
|
const full = join(dir, entry.name);
|
|
189
|
-
if (entry.
|
|
190
|
-
|
|
220
|
+
if (entry.isSymbolicLink()) {
|
|
221
|
+
const refusal = await symlinkRefusal(full, entry.name, statPath);
|
|
222
|
+
if (refusal !== null) found.push({ path: full, refusal });
|
|
223
|
+
} else if (entry.isDirectory()) {
|
|
224
|
+
found.push(...(await walkDocsEntries(full, deps)));
|
|
191
225
|
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
192
|
-
|
|
226
|
+
found.push({ path: full, refusal: null });
|
|
193
227
|
}
|
|
194
228
|
}
|
|
195
|
-
return
|
|
229
|
+
return found;
|
|
196
230
|
};
|
|
197
231
|
|
|
232
|
+
// The historical export, signature and contract BOTH unchanged: the `*.md` files this run may READ,
|
|
233
|
+
// as paths, from one argument. A refused symlink is not one of them — it is a row, not a file — so
|
|
234
|
+
// the pinned non-`.md` skip still reads exactly as it always did. The entry walk above and the
|
|
235
|
+
// comparator below stay private: they are this module's internals, and the injectable they carry has
|
|
236
|
+
// no business in a deployed script's public surface.
|
|
237
|
+
export const walkMarkdownFiles = async (dir) =>
|
|
238
|
+
(await walkDocsEntries(dir)).filter((entry) => entry.refusal === null).map((entry) => entry.path);
|
|
239
|
+
|
|
240
|
+
// The walk's ORDER is unchanged: a bare `.sort()` over the old string list compared UTF-16 code
|
|
241
|
+
// units, and `<`/`>` on strings compares them the same way.
|
|
242
|
+
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
243
|
+
|
|
198
244
|
export const computeToday = (todayStr) =>
|
|
199
245
|
todayStr
|
|
200
246
|
? new Date(`${todayStr}T00:00:00Z`)
|
|
@@ -206,11 +252,19 @@ export const computeToday = (todayStr) =>
|
|
|
206
252
|
const readSpecVerdict = (rel, text) =>
|
|
207
253
|
rel.startsWith(SPECS_COLLAPSE.prefix) ? readSpecDocument(text, rel.slice(SPECS_COLLAPSE.prefix.length)) : null;
|
|
208
254
|
|
|
209
|
-
|
|
255
|
+
// Takes a walk entry; a bare path string still works, so a caller holding one file keeps its call.
|
|
256
|
+
export const inspectFile = async (found, today, root = ROOT) => {
|
|
257
|
+
const filePath = typeof found === 'string' ? found : found.path;
|
|
258
|
+
const refusal = typeof found === 'string' ? null : found.refusal;
|
|
259
|
+
const rel = relative(root, filePath);
|
|
260
|
+
// A refused entry is REPORTED, never read — reading through the link is the whole thing being
|
|
261
|
+
// refused. `refused` rides the row so the navigator's collapse can keep it visible.
|
|
262
|
+
if (refusal) {
|
|
263
|
+
return { path: rel, lineCount: 0, frontmatter: null, spec: null, refused: true, errors: [refusal], warnings: [] };
|
|
264
|
+
}
|
|
210
265
|
const text = await readFile(filePath, 'utf8');
|
|
211
266
|
const lineCount = text.split('\n').length - (text.endsWith('\n') ? 1 : 0);
|
|
212
267
|
const fm = parseFrontmatter(text);
|
|
213
|
-
const rel = relative(root, filePath);
|
|
214
268
|
const spec = readSpecVerdict(rel, text);
|
|
215
269
|
|
|
216
270
|
if (!fm) {
|
|
@@ -330,8 +384,11 @@ const formatSpecsCollapseRow = (specRows) => {
|
|
|
330
384
|
return `| ${link} | ${SPECS_COLLAPSE.type} | ${count('spec')} specs | ${count('part')} parts · ${count('index')} indexes | — |`;
|
|
331
385
|
};
|
|
332
386
|
|
|
387
|
+
// Membership keys on the REFUSAL, never on `errors.length`: a refused symlink is exactly the row
|
|
388
|
+
// this gate exists to make visible, so it must not be absorbed into a count — while an over-cap or
|
|
389
|
+
// frontmatter-less REAL record keeps collapsing as it always did, so no deployment's navigator moves.
|
|
333
390
|
const COLLAPSE_GROUPS = [
|
|
334
|
-
{ sortPath: ADR_DIR_PREFIX, isMember: (r) => r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
|
|
391
|
+
{ sortPath: ADR_DIR_PREFIX, isMember: (r) => !r.refused && r.path.startsWith(ADR_DIR_PREFIX) && (ADR_RECORD_RE.test(r.path) || r.path === ADR_NAV_PATH), format: formatAdrCollapseRow },
|
|
335
392
|
{ sortPath: SPECS_COLLAPSE.prefix, isMember: (r) => Boolean(r.spec) && r.spec.errors.length === 0, format: formatSpecsCollapseRow },
|
|
336
393
|
];
|
|
337
394
|
|
|
@@ -398,15 +455,18 @@ const lstatNoFollow = (target, lstat) => {
|
|
|
398
455
|
|
|
399
456
|
// The target is always DERIVED from `root` here (the navigator and its temp sibling), never handed
|
|
400
457
|
// in by a caller, so there is no escape arm to guard: what remains is the no-follow walk.
|
|
401
|
-
|
|
458
|
+
// `verb` names what the CALLER was about to do, because the refusal is now shared with a read-only
|
|
459
|
+
// mode: `--check-index` compares the navigator's bytes, and a comparison that followed a link would
|
|
460
|
+
// judge a file this deployment does not own.
|
|
461
|
+
const assertContainedNoSymlink = (root, target, lstat, verb = 'write') => {
|
|
402
462
|
const rel = relative(root, target);
|
|
403
463
|
if (lstatNoFollow(root, lstat)?.isSymbolicLink()) {
|
|
404
|
-
throw refuse(`${root} is a symlink — refusing to
|
|
464
|
+
throw refuse(`${root} is a symlink — refusing to ${verb} the navigator through it`);
|
|
405
465
|
}
|
|
406
466
|
rel.split(sep).filter(Boolean).reduce((walked, part) => {
|
|
407
467
|
const current = join(walked, part);
|
|
408
468
|
if (lstatNoFollow(current, lstat)?.isSymbolicLink()) {
|
|
409
|
-
throw refuse(`${current} is a symlink — refusing to
|
|
469
|
+
throw refuse(`${current} is a symlink — refusing to ${verb} the navigator through it`);
|
|
410
470
|
}
|
|
411
471
|
return current;
|
|
412
472
|
}, root);
|
|
@@ -452,7 +512,7 @@ const writeIndex = async (rows, today, meta, { root = ROOT, indexPath = INDEX_PA
|
|
|
452
512
|
export const regenerateIndex = async (root, todayStr = null, deps = {}) => {
|
|
453
513
|
const paths = pathsFor(root);
|
|
454
514
|
const today = computeToday(todayStr);
|
|
455
|
-
const files = (await
|
|
515
|
+
const files = (await walkDocsEntries(paths.docsDir, deps)).sort(byPath);
|
|
456
516
|
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, paths.root)));
|
|
457
517
|
const rows = inspected.map(formatRow);
|
|
458
518
|
const meta = await discoverMeta(paths.root);
|
|
@@ -474,7 +534,7 @@ const runEnsureIndex = async ({ root, docsDir, indexPath, today, deps }) => {
|
|
|
474
534
|
let writing = false;
|
|
475
535
|
try {
|
|
476
536
|
assertContainedNoSymlink(root, indexPath, lstat);
|
|
477
|
-
const files = (await
|
|
537
|
+
const files = (await walkDocsEntries(docsDir, deps)).sort(byPath);
|
|
478
538
|
const inspected = await Promise.all(files.map((file) => inspectFile(file, today, root)));
|
|
479
539
|
const rows = inspected.map(formatRow);
|
|
480
540
|
const meta = await discoverMeta(root, { strict: true, deps });
|
|
@@ -523,7 +583,7 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
523
583
|
return result(code);
|
|
524
584
|
}
|
|
525
585
|
|
|
526
|
-
const files = (await
|
|
586
|
+
const files = (await walkDocsEntries(docsDir, deps)).sort(byPath);
|
|
527
587
|
const inspected = await Promise.all(files.map((f) => inspectFile(f, today, root)));
|
|
528
588
|
const rows = inspected.map(formatRow);
|
|
529
589
|
|
|
@@ -545,6 +605,16 @@ export const runCli = async (argv, deps = {}) => {
|
|
|
545
605
|
}
|
|
546
606
|
|
|
547
607
|
if (flags.checkIndex) {
|
|
608
|
+
// BEFORE the read, and by NAME: `buildIndex` drops the navigator's own row, so a symlinked
|
|
609
|
+
// index.md contributes nothing to compare against — and a link whose target happened to hold
|
|
610
|
+
// the current bytes would compare EQUAL and report the navigator fresh. Freshness can never
|
|
611
|
+
// see this one; only the containment walk can.
|
|
612
|
+
try {
|
|
613
|
+
assertContainedNoSymlink(root, indexPath, deps.lstat ?? lstatSync, 'read');
|
|
614
|
+
} catch (err) {
|
|
615
|
+
logError(`[check-docs-size] FAIL: ${indexPath}: ${err.message}`);
|
|
616
|
+
return result(2);
|
|
617
|
+
}
|
|
548
618
|
const onDisk = existsSync(indexPath) ? await readFile(indexPath, 'utf8') : null;
|
|
549
619
|
const { fresh } = checkIndexFreshness(rows, onDisk, meta);
|
|
550
620
|
if (!fresh) {
|