@sabaiway/agent-workflow-kit 9.0.0 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +69 -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/tools/fold-scope.mjs +34 -7
- package/tools/queue-audit-cli.mjs +135 -0
- package/tools/queue-audit-rows.mjs +310 -0
- package/tools/queue-audit.mjs +164 -0
- package/tools/script-priors.mjs +1 -0
- package/tools/spec-check.mjs +11 -1
- package/tools/spec-coverage-cli.mjs +211 -0
- package/tools/spec-coverage.mjs +88 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,75 @@ 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
|
+
## 10.1.0 — no work without a specification, and a ratchet that does not trust its own file (AD-121)
|
|
8
|
+
|
|
9
|
+
A shipped tool that no LIVE contract claims is now a REFUSAL, not a preference. `tools/spec-coverage.mjs`
|
|
10
|
+
(the rule) and `tools/spec-coverage-cli.mjs` (argv, fs and the one write) read each contract's own
|
|
11
|
+
`## Module` list as the coverage map — coverage is never declared twice — and a project declares the
|
|
12
|
+
check as a gate. Measured at adoption in this repo: 122 tool modules, 14 governed by a contract.
|
|
13
|
+
|
|
14
|
+
The other 108 are owed, and what is owed is never STORED — it is a subtraction. `adopted` is the set
|
|
15
|
+
measured once at adoption and never rewritten; `settled` names the adopted paths whose contract has
|
|
16
|
+
since been written; the debt is the difference. So there is no list a hand can edit into a lie: the
|
|
17
|
+
only editable claim is "this one was paid", and every run checks that claim against the contracts
|
|
18
|
+
themselves — a settled path no live contract covers refuses, and refuses twice, because the tool is
|
|
19
|
+
uncovered again. A stored debt list was the first design and a review killed it: `--write-debt`
|
|
20
|
+
refusing to add a path guaranteed nothing while nobody was obliged to use it.
|
|
21
|
+
|
|
22
|
+
The scope file is validated before it is used: a wrong schema, an empty `roots` or `extensions`, a
|
|
23
|
+
non-string entry, or a census of ZERO files all refuse — a gate that passes because it looked at
|
|
24
|
+
nothing is not a pass. Exclusions are path-component boundaries, so excluding `x/fixtures` never also
|
|
25
|
+
hides `x/fixtures-escape.mjs`. `draft` and `retired` contracts cover nothing.
|
|
26
|
+
|
|
27
|
+
Also in this release: `tools/spec-check.mjs` counts a scenario marker as a WHOLE ordinal, so a store
|
|
28
|
+
reaching ten scenarios no longer refuses a correct binding while naming the wrong scenario
|
|
29
|
+
(`spec:x/S1` used to occur twice the moment `spec:x/S11` was written beside it).
|
|
30
|
+
|
|
31
|
+
## 10.0.0 — a symlinked docs file gets named instead of skipped (AD-119)
|
|
32
|
+
|
|
33
|
+
Mirrors the memory canon: `references/scripts/check-docs-size.mjs` no longer lets a symlinked docs
|
|
34
|
+
file leave the walk in silence. The predicate `entry.isFile()` is FALSE for a symlink and so is
|
|
35
|
+
`isDirectory()`, so a symlinked `*.md` under `docs/ai` fell through both arms of the walk and was not
|
|
36
|
+
even counted in the report's file total.
|
|
37
|
+
|
|
38
|
+
> ### ⚠ BREAKING — a docs tree that was green can now go RED
|
|
39
|
+
>
|
|
40
|
+
> A symlink under `docs/ai` named `*.md`, or resolving to a directory, is a NAMED error row and is
|
|
41
|
+
> never read.
|
|
42
|
+
>
|
|
43
|
+
> `--check-index` additionally runs the navigator's containment guard BEFORE it reads. On a chain
|
|
44
|
+
> the walk can TRAVERSE, that guard rejects a symlink at the project root, `docs`, `docs/ai` or
|
|
45
|
+
> `docs/ai/index.md` and exits 2 — where the mode used to compare and pass; it is the same guard
|
|
46
|
+
> `--write-index` and `--ensure-index` already applied. (A chain the walk cannot traverse at all
|
|
47
|
+
> still ends the run as it always did, before any guard — unchanged by this release.) It matters
|
|
48
|
+
> because `buildIndex` drops the navigator's own row, so a symlinked `index.md` whose target held
|
|
49
|
+
> the current bytes compared EQUAL and reported it fresh.
|
|
50
|
+
>
|
|
51
|
+
> **When it bites.** Only a deployment that actually has such a symlink, and only once these bytes
|
|
52
|
+
> reach its `scripts/`. `init` does NOT overwrite an existing deployed `check-docs-size.mjs`; the
|
|
53
|
+
> paths that do land it are a fresh bootstrap, a `migrate-adr-store` refresh, and the `specs` ensure's
|
|
54
|
+
> prior-matched refresh lane — which is what the catalog row below is for.
|
|
55
|
+
>
|
|
56
|
+
> **Remedy.** Replace it with a real file or a real directory, whichever it stands for, or move it out
|
|
57
|
+
> of `docs/ai`. The gate names the path and which case it is. For a symlink on the NAVIGATOR's own
|
|
58
|
+
> chain, note that regeneration is itself refused — `--write-index` cannot clear it, only replacing
|
|
59
|
+
> the link can.
|
|
60
|
+
|
|
61
|
+
**Deployments on the previous body stay upgradeable.** The prior catalog gains
|
|
62
|
+
`check-docs-size.mjs` `4.6.0..6.0.0`, so a project still carrying that body classifies `prior`
|
|
63
|
+
and remains inside the refresh lane instead of stranding as `custom` — which is what would otherwise
|
|
64
|
+
withhold every write that lane gates.
|
|
65
|
+
|
|
66
|
+
**Preserved, each pinned by its own arm.** A symlinked NON-`.md` regular file is still skipped; a
|
|
67
|
+
tree with no symlink under `docs/ai` writes a byte-identical navigator; an over-cap REAL `adr/`
|
|
68
|
+
record still collapses into the aggregate row, because the guard keys on the refusal and never on
|
|
69
|
+
`errors.length`; and `walkMarkdownFiles` keeps its exact historical one-argument signature, which is
|
|
70
|
+
why the parity-frozen spec file needed no edit.
|
|
71
|
+
|
|
72
|
+
**Only `ENOENT` and `ENOTDIR` mean absence.** Every other `stat` code — `EACCES`, `EIO`, `ELOOP` from
|
|
73
|
+
a symlink cycle — leaves the kind UNKNOWN and yields a named refusal carrying the code. Treating an
|
|
74
|
+
unclassifiable link as a skip is exactly how the subtree behind it escapes again.
|
|
75
|
+
|
|
7
76
|
## 9.0.0 — the archiver's cap table ships, and the migration seeds it behind its importer (AD-118)
|
|
8
77
|
|
|
9
78
|
Memory **6.0.0** replaces the changelog archiver's fixed cap literals with a stamp it can honour,
|
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: '10.1.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": "10.1.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",
|
|
@@ -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) {
|