@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
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The backlog queue as a CLASSIFIED corpus, not a prose pile.
|
|
3
|
+
//
|
|
4
|
+
// `docs/plans/queue.md` is the one long-lived surface in this family with no type, cap, rotation or
|
|
5
|
+
// exit event: rows accumulate, a closed row stays listed because its 26 lines of measurements have
|
|
6
|
+
// nowhere else to live, and a heading can say one thing while its body says another. The file's own
|
|
7
|
+
// header already asked for the discipline in prose ("a DONE entry is <=5 lines") and it did not hold.
|
|
8
|
+
// This module is the half prose cannot do — it says, per row and with the literal evidence, whether
|
|
9
|
+
// the row is still WORK.
|
|
10
|
+
//
|
|
11
|
+
// Four classes, and the boundaries between them are deliberately conservative, because the consumer
|
|
12
|
+
// of a `terminal` verdict is a DELETION:
|
|
13
|
+
//
|
|
14
|
+
// live no status marker decides otherwise — the default, and what a queue should hold.
|
|
15
|
+
// terminal the row is DEAD — done, closed, superseded, moot, declined: a marker in the TITLE, or
|
|
16
|
+
// a marker OPENING a bold status line in the body (`**CLOSED 2026-07-20 …**`). The body
|
|
17
|
+
// decides when the title is silent — the real shape of a row that was closed in place.
|
|
18
|
+
// parked the row is FROZEN, not dead: `PARKED` / `STOPPED` carry a stated resume condition, so
|
|
19
|
+
// deleting one loses work that is only waiting. Reported, never deleted, never a refusal.
|
|
20
|
+
// record the row is not work at all: a TALLY counter or a SEQUENCING note.
|
|
21
|
+
// ambiguous the row contradicts itself (a terminal AND a live marker in the title, or a bold
|
|
22
|
+
// terminal body under an explicitly QUEUED title). REPORTED, never auto-deleted.
|
|
23
|
+
//
|
|
24
|
+
// A terminal WORD in ordinary prose is NOT a status: rows routinely cite a sibling that was CLOSED or
|
|
25
|
+
// explain why something was CUT, and reading that as the row's own state would delete live work. Only
|
|
26
|
+
// the title and a bold status line are status positions.
|
|
27
|
+
//
|
|
28
|
+
// Markdown is read through the family's ONE block model (references/scripts/markdown-blocks.mjs) and
|
|
29
|
+
// the ONE bullet scan the other queue reader uses (fold-scope.mjs) — fences, CRLF, indented headings
|
|
30
|
+
// and the backtick-info-string rule are THEIR problem, never a second hand-rolled grammar here. A
|
|
31
|
+
// document either of them refuses is a loud refusal, never a silent empty read.
|
|
32
|
+
//
|
|
33
|
+
// Pure string functions plus a thin CLI. Read-only: it reads the file it is pointed at and writes
|
|
34
|
+
// nothing. Dependency-free, Node >= 22. No side effects on import.
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
import { tokenizeMarkdown, fail } from '../references/scripts/markdown-blocks.mjs';
|
|
38
|
+
import { bulletBlocks } from './fold-scope.mjs';
|
|
39
|
+
import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, classifyRow, titleOf } from './queue-audit-rows.mjs';
|
|
40
|
+
|
|
41
|
+
// The row grammar is re-exported so one import names the whole reader: the CLI, the tests and any
|
|
42
|
+
// consumer ask this module, and the split into a rules half stays an implementation detail.
|
|
43
|
+
export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, classifyRow, leadMarkers } from './queue-audit-rows.mjs';
|
|
44
|
+
|
|
45
|
+
// The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
|
|
46
|
+
// the next heading of the SAME level or higher, so a level-3 subheading stays inside. An absent
|
|
47
|
+
// section is a named refusal — auditing the whole file when the caller asked for one section would
|
|
48
|
+
// report rows the caller never meant to judge, and a deletion would follow.
|
|
49
|
+
// `frontLines` is not decoration: every line number this module reports is a FILE line, frontmatter
|
|
50
|
+
// included (the row manifest already adds it), so a refusal that named body-relative lines would send
|
|
51
|
+
// a reader to the wrong place in the very file it is refusing.
|
|
52
|
+
// ATX allows an optional CLOSING run of `#`, so `## Pending ##` and `## Pending` are the SAME
|
|
53
|
+
// heading. Comparing raw text made them two: the audit took one, and every row under the other left
|
|
54
|
+
// the domain silently — a section full of dead rows reported as zero rows and exit 0.
|
|
55
|
+
const canonicalHeading = (text) =>
|
|
56
|
+
String(text ?? '')
|
|
57
|
+
.replace(/\r/g, '')
|
|
58
|
+
.replace(/\s+#+\s*$/, '')
|
|
59
|
+
.replace(/\s+/g, ' ')
|
|
60
|
+
.trim();
|
|
61
|
+
|
|
62
|
+
const sectionWindow = (headings, lines, section, frontLines = 0) => {
|
|
63
|
+
if (!section) return { from: 0, to: lines.length };
|
|
64
|
+
const wanted = canonicalHeading(section);
|
|
65
|
+
const matches = headings.filter((heading) => canonicalHeading(heading.text) === wanted);
|
|
66
|
+
// A usage error, not a document refusal: what is wrong is the ARGUMENT, and the CLI contract
|
|
67
|
+
// promises 2 for that. AMBIGUITY refuses on the same footing as absence: taking the first of two
|
|
68
|
+
// same-named headings would leave every row under the second one outside the audit — invisible to
|
|
69
|
+
// the caps, absent from the report a deletion is driven by, and silently so.
|
|
70
|
+
if (matches.length === 0) throw fail(2, `no section heading "${wanted}" in the queue — the audit refuses to guess its domain.`);
|
|
71
|
+
if (matches.length > 1) {
|
|
72
|
+
throw fail(2, `${matches.length} section headings read "${wanted}" (lines ${matches.map((h) => frontLines + h.index + 1).join(', ')}) — the audit refuses to pick one and leave the rest unjudged.`);
|
|
73
|
+
}
|
|
74
|
+
const [open] = matches;
|
|
75
|
+
const next = headings.find((heading) => heading.index > open.index && heading.level <= open.level);
|
|
76
|
+
return { from: open.index + 1, to: next ? next.index : lines.length };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// auditQueue(text, { section, label }) -> { rows, counts }. Each row carries its 1-based FILE line
|
|
80
|
+
// (frontmatter included), the title as written, its class and the literal evidence for that class.
|
|
81
|
+
export const auditQueue = (text, { section = null, label = 'the queue' } = {}) => {
|
|
82
|
+
const { lines, headings, fencedLines, frontLines } = tokenizeMarkdown(String(text ?? ''), label);
|
|
83
|
+
const { from, to } = sectionWindow(headings, lines, section, frontLines);
|
|
84
|
+
// A fence CONTINUES a queue row rather than ending it, and the cap judges the row's PHYSICAL span.
|
|
85
|
+
// Both halves are the same defect: a row carrying a code block reported one line and hid whatever
|
|
86
|
+
// followed the fence — its length from the cap, and a closure from the classifier.
|
|
87
|
+
// A list item this grammar cannot read is a REFUSAL, never a silence. `*` and `+` open a list in
|
|
88
|
+
// every Markdown dialect and a bare `-` is an empty item; none of them is a row here, and dropping
|
|
89
|
+
// them made a section of dead work report "0 rows" and exit 0 — a gate answering about a domain it
|
|
90
|
+
// never looked at. The queue writes `-` rows; anything else is corrected by hand, not guessed at.
|
|
91
|
+
for (let index = from; index < to; index += 1) {
|
|
92
|
+
if (fencedLines.has(index) || !UNREAD_ITEM.test(lines[index])) continue;
|
|
93
|
+
throw fail(2, `line ${frontLines + index + 1} opens a list item this audit does not read ("${lines[index].trim().slice(0, 40)}") — a queue row is a "- " bullet, and judging around this one would report a domain that was never looked at.`);
|
|
94
|
+
}
|
|
95
|
+
const rows = bulletBlocks(lines, fencedLines, from, to, { fenceContinues: true }).map((block) => {
|
|
96
|
+
const { klass, evidence } = classifyRow(block.lines, block.gaps);
|
|
97
|
+
return {
|
|
98
|
+
line: frontLines + block.start + 1,
|
|
99
|
+
lines: block.span,
|
|
100
|
+
title: titleOf(block.lines, block.gaps).text,
|
|
101
|
+
klass,
|
|
102
|
+
evidence,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const counts = Object.fromEntries(CLASSES.map((klass) => [klass, rows.filter((row) => row.klass === klass).length]));
|
|
106
|
+
return { rows, counts, total: rows.length };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// checkQueue(text, options) -> { ok, problems, notes }. A problem is a REFUSAL and every one of them
|
|
110
|
+
// names a location: the family's bar is locations, never counts. An ambiguous row is a NOTE — it is
|
|
111
|
+
// exactly the case a human must settle, and failing on it would make the cap unpassable by anyone
|
|
112
|
+
// who did not already know the answer.
|
|
113
|
+
export const checkQueue = (text, options = {}) => {
|
|
114
|
+
const { maxRows = DEFAULTS.maxRows, maxRowLines = DEFAULTS.maxRowLines, label = 'the queue' } = options;
|
|
115
|
+
const { rows, counts, total } = auditQueue(text, { section: options.section ?? null, label });
|
|
116
|
+
const problems = [];
|
|
117
|
+
const notes = [];
|
|
118
|
+
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
if (row.klass === 'terminal' || row.klass === 'record') {
|
|
121
|
+
problems.push(
|
|
122
|
+
`${label}:${row.line}: a ${row.klass} row is still listed (${row.evidence}) — its story belongs to the ` +
|
|
123
|
+
`ADR or the changelog, and the row leaves the queue in the same commit: ${row.title.slice(0, 80)}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if (row.klass === 'ambiguous') {
|
|
127
|
+
notes.push(`${label}:${row.line}: ambiguous (${row.evidence}) — settle it by hand: ${row.title.slice(0, 80)}`);
|
|
128
|
+
}
|
|
129
|
+
if (row.klass === 'parked') {
|
|
130
|
+
notes.push(`${label}:${row.line}: parked (${row.evidence}) — frozen, not dead: ${row.title.slice(0, 80)}`);
|
|
131
|
+
}
|
|
132
|
+
if (CARRY_WORK.has(row.klass) && row.lines > maxRowLines) {
|
|
133
|
+
problems.push(
|
|
134
|
+
`${label}:${row.line}: the row is ${row.lines} lines, over the ${maxRowLines}-line cap — a row names the ` +
|
|
135
|
+
`work; the measurements belong to a record or an ADR: ${row.title.slice(0, 80)}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Both caps count EVERY row that still carries work, frozen and ambiguous included. Counting only
|
|
141
|
+
// `live` would let the queue grow without limit through the Frozen bucket — moving a row there, or
|
|
142
|
+
// leaving it self-contradicting, would buy room the cap is there to deny.
|
|
143
|
+
const working = rows.filter((row) => CARRY_WORK.has(row.klass)).length;
|
|
144
|
+
if (working > maxRows) {
|
|
145
|
+
problems.push(
|
|
146
|
+
`${label}: ${working} rows carry work, over the ${maxRows}-row cap — a backlog nobody can read is a dump. ` +
|
|
147
|
+
'Close, delete or fold rows before filing another.',
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { ok: problems.length === 0, problems, notes, counts, total };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// One tab-separated line per row: line, class, row-length, title. Deterministic and stable, so it can
|
|
155
|
+
// be diffed between runs and used as the manifest a deletion is driven by.
|
|
156
|
+
export const formatReport = (audit, { label = 'the queue' } = {}) => {
|
|
157
|
+
const head = [
|
|
158
|
+
`# queue-audit — ${label}`,
|
|
159
|
+
`# ${audit.total} rows: ${CLASSES.map((klass) => `${audit.counts[klass]} ${klass}`).join(' · ')}`,
|
|
160
|
+
'# line\tclass\tlines\ttitle\tevidence',
|
|
161
|
+
];
|
|
162
|
+
const body = audit.rows.map((row) => [row.line, row.klass, row.lines, row.title, row.evidence].join('\t'));
|
|
163
|
+
return [...head, ...body].join('\n');
|
|
164
|
+
};
|
package/tools/script-priors.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export const SCRIPT_PRIORS = Object.freeze([
|
|
|
33
33
|
prior('spec-schema.test.mjs', '4.6.0', '4.6.1', 'a12d6d3f5d32c6dabdee7e15af7d2ab15a0ced37515d1844fe0951f60cddbc99'),
|
|
34
34
|
prior('spec-schema.mjs', '4.7.0', '4.7.0', '40b5b038d5ec5ed53c327c6d269d22fe5fa2bed99ae711fbf84306ad047be452'),
|
|
35
35
|
prior('spec-schema.test.mjs', '4.7.0', '4.7.0', 'fde896419924223e54cfcabfdb1ef5807df5386fac700ed7c1463b6e7f81501b'),
|
|
36
|
+
prior('check-docs-size.mjs', '4.6.0', '6.0.0', '22d020c3668cdbfb4cbc1f67a8a85b2baa2d0c4956808a50626d37409e81ab38'),
|
|
36
37
|
]);
|
|
37
38
|
|
|
38
39
|
export const digestOf = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
package/tools/spec-check.mjs
CHANGED
|
@@ -43,7 +43,17 @@ const dirOf = (rel) => rel.slice(0, rel.lastIndexOf('/'));
|
|
|
43
43
|
const leafOf = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
|
|
44
44
|
const bare = (rel) => (rel.endsWith('/') ? rel.slice(0, -1) : rel);
|
|
45
45
|
const lineCount = (text) => text.replace(/\n$/, '').split('\n').length;
|
|
46
|
-
|
|
46
|
+
// A marker is counted as a WHOLE ordinal, never as a prefix of a longer one. A plain substring count
|
|
47
|
+
// makes `spec:…/S1` occur twice the moment `spec:…/S11` is written in the same file — so a store
|
|
48
|
+
// that reaches ten scenarios starts refusing bindings that are perfectly correct, and the refusal
|
|
49
|
+
// names the wrong scenario. Measured here at S11.
|
|
50
|
+
const occurrences = (text, needle) => {
|
|
51
|
+
let found = 0;
|
|
52
|
+
for (let at = text.indexOf(needle); at !== -1; at = text.indexOf(needle, at + needle.length)) {
|
|
53
|
+
if (!/[0-9]/.test(text[at + needle.length] ?? '')) found += 1;
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
};
|
|
47
57
|
// Containment is a question about path COMPONENTS, and only the platform's own path model answers
|
|
48
58
|
// it. A textual prefix test reads "/repo\outside" as a child of "/repo" on a POSIX host — where the
|
|
49
59
|
// backslash is an ordinary filename character — and it mis-reads a filesystem root ("/" or "C:\")
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The CLI half of the coverage requirement: argv, fs and the debt record. No rule lives here (the
|
|
3
|
+
// rule is spec-coverage.mjs), and the ratchet is enforced HERE because it is the only write.
|
|
4
|
+
//
|
|
5
|
+
// Exit codes: 0 accept; 1 refuse (an uncovered tool, or a settled debt entry still recorded); 2
|
|
6
|
+
// usage — an unknown flag, a flag with no value, an unreadable scope or store, a reasonless write.
|
|
7
|
+
|
|
8
|
+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join, relative, sep } from 'node:path';
|
|
10
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
11
|
+
import { claimsOf, formatFindings, judgeCoverage, settleAfter } from './spec-coverage.mjs';
|
|
12
|
+
|
|
13
|
+
export const SCOPE_PATH = join('docs', 'ai', 'spec-coverage.json');
|
|
14
|
+
export const STORE_ROOT = join('docs', 'ai', 'specs');
|
|
15
|
+
const REASON_MAX_BYTES = 300;
|
|
16
|
+
|
|
17
|
+
const HELP = `spec-coverage — every shipped tool is governed by a contract, or the debt names it.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
node spec-coverage-cli.mjs --report [--root <dir>]
|
|
21
|
+
node spec-coverage-cli.mjs --check [--root <dir>]
|
|
22
|
+
node spec-coverage-cli.mjs --write-debt --reason "<what was paid, and by which contract>" [--root <dir>]
|
|
23
|
+
|
|
24
|
+
--report one line per in-scope tool: the contract that covers it, or that none does.
|
|
25
|
+
--check refuses an in-scope tool no contract claims and is not recorded as debt, and a
|
|
26
|
+
recorded entry that is already settled — the record must not overstate the debt.
|
|
27
|
+
--write-debt records what was PAID: every adopted path whose contract now exists moves into the
|
|
28
|
+
settled set, and nothing else changes. It never touches the adoption baseline, so a
|
|
29
|
+
path outside it cannot be invented — it is refused by name. Write the contract first.
|
|
30
|
+
|
|
31
|
+
Scope and debt: ${SCOPE_PATH}. Contracts: ${STORE_ROOT}. Exit codes: 0 accept; 1 refuse; 2 usage.`;
|
|
32
|
+
|
|
33
|
+
const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
34
|
+
const posix = (p) => p.split(sep).join('/');
|
|
35
|
+
|
|
36
|
+
// A scope this tool cannot trust is worse than no scope: `{}`, an empty `roots`, or an `exclude`
|
|
37
|
+
// carrying an empty string all yield a census of ZERO tools and a cheerful PASS — a gate answering
|
|
38
|
+
// about a domain it never looked at, which is the exact failure this whole rung exists to end.
|
|
39
|
+
const validateScope = (scope, path) => {
|
|
40
|
+
const bad = (why) => { throw fail(2, `the coverage scope ${path} is unusable: ${why}`); };
|
|
41
|
+
if (scope === null || typeof scope !== 'object' || Array.isArray(scope)) bad('it is not an object');
|
|
42
|
+
if (scope.schema !== 1) bad(`schema must be 1, got ${JSON.stringify(scope.schema)}`);
|
|
43
|
+
const list = (key, required) => {
|
|
44
|
+
const value = scope[key];
|
|
45
|
+
if (value === undefined && !required) return [];
|
|
46
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string' || v === '')) bad(`${key} must be an array of non-empty strings`);
|
|
47
|
+
if (required && value.length === 0) bad(`${key} is empty, so nothing would ever be judged`);
|
|
48
|
+
return value;
|
|
49
|
+
};
|
|
50
|
+
list('roots', true);
|
|
51
|
+
const extensions = list('extensions', true);
|
|
52
|
+
if (extensions.some((ext) => !ext.startsWith('.'))) bad('every extension starts with a dot');
|
|
53
|
+
list('exclude', false);
|
|
54
|
+
// Both recorded sets are PATHS. `Array.isArray` alone let `[42]` through, and a scope the tool
|
|
55
|
+
// cannot trust is the thing this validator exists to catch.
|
|
56
|
+
if (!Array.isArray(scope.adopted)) bad('adopted is the frozen set measured at adoption, and it must be an array');
|
|
57
|
+
list('adopted', false);
|
|
58
|
+
list('settled', false);
|
|
59
|
+
return scope;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const readJson = (path, what) => {
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = readFileSync(path, 'utf8');
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw fail(2, `cannot read ${what} ${path}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(raw);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw fail(2, `${what} ${path} is not valid JSON: ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Deterministic order in both walks: a report a human compares between runs must not depend on the
|
|
77
|
+
// order a directory happens to be read in.
|
|
78
|
+
const sorted = (entries) => [...entries].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
79
|
+
|
|
80
|
+
export const specDocuments = (root, io = { readdirSync, readFileSync }) => {
|
|
81
|
+
const out = [];
|
|
82
|
+
const walk = (dir) => {
|
|
83
|
+
for (const entry of sorted(io.readdirSync(dir, { withFileTypes: true }))) {
|
|
84
|
+
const full = join(dir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) walk(full);
|
|
86
|
+
else if (entry.name.endsWith('.md')) out.push({ rel: posix(relative(root, full)), text: io.readFileSync(full, 'utf8') });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(join(root, STORE_ROOT));
|
|
90
|
+
return out;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// A test file is never in scope: a contract governs the module, and its tests are the evidence FOR
|
|
94
|
+
// that contract, not a second thing to write one for. A `<name>.test/` directory is the same answer.
|
|
95
|
+
export const toolsIn = (root, scope, io = { readdirSync }) => {
|
|
96
|
+
const extensions = scope.extensions ?? ['.mjs'];
|
|
97
|
+
// A textual prefix is not a path. `.../fixtures` would also hide `.../fixtures-escape.mjs`, so a
|
|
98
|
+
// new tool could leave the scope by being named next to an excluded directory. The boundary is a
|
|
99
|
+
// path COMPONENT: the entry itself, or something under it.
|
|
100
|
+
const excluded = (rel) => (scope.exclude ?? []).some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`));
|
|
101
|
+
const isTest = (name) => extensions.some((ext) => name.endsWith(`.test${ext}`));
|
|
102
|
+
const out = [];
|
|
103
|
+
const walk = (dir) => {
|
|
104
|
+
for (const entry of sorted(io.readdirSync(dir, { withFileTypes: true }))) {
|
|
105
|
+
const full = join(dir, entry.name);
|
|
106
|
+
const rel = posix(relative(root, full));
|
|
107
|
+
if (excluded(rel)) continue;
|
|
108
|
+
if (entry.isDirectory()) {
|
|
109
|
+
if (!entry.name.endsWith('.test')) walk(full);
|
|
110
|
+
} else if (extensions.some((ext) => entry.name.endsWith(ext)) && !isTest(entry.name)) out.push(rel);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
for (const scopeRoot of scope.roots ?? []) walk(join(root, scopeRoot));
|
|
114
|
+
return out;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const parseArgv = (argv) => {
|
|
118
|
+
if (argv.length === 1 && (argv[0] === '--help' || argv[0] === '-h')) return { mode: 'help' };
|
|
119
|
+
const options = { mode: null, root: process.cwd(), reason: null };
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const valueOf = (index, flag) => {
|
|
122
|
+
const value = argv[index + 1];
|
|
123
|
+
if (value === undefined || value === '' || value.startsWith('--')) throw fail(2, `${flag} takes a value`);
|
|
124
|
+
return value;
|
|
125
|
+
};
|
|
126
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
127
|
+
const arg = argv[index];
|
|
128
|
+
if (arg === '--report' || arg === '--check' || arg === '--write-debt') {
|
|
129
|
+
if (options.mode) throw fail(2, `--${options.mode} was already given — name exactly one mode`);
|
|
130
|
+
options.mode = arg.slice(2);
|
|
131
|
+
} else if (arg === '--root' || arg === '--reason') {
|
|
132
|
+
if (seen.has(arg)) throw fail(2, `${arg} was given twice — each option is named exactly once`);
|
|
133
|
+
seen.add(arg);
|
|
134
|
+
options[arg === '--root' ? 'root' : 'reason'] = valueOf(index, arg);
|
|
135
|
+
index += 1;
|
|
136
|
+
} else throw fail(2, `unknown argument "${arg}" — run with --help`);
|
|
137
|
+
}
|
|
138
|
+
if (!options.mode) throw fail(2, 'one of --report, --check or --write-debt is required — run with --help');
|
|
139
|
+
// A repayment with no stated reason is how a ratchet becomes a rubber stamp: the reason is recorded
|
|
140
|
+
// in the file it changes and is what the commit message and the changelog restate.
|
|
141
|
+
if (options.mode === 'write-debt' && !options.reason) throw fail(2, '--write-debt requires --reason "<what was paid, and by which contract>"');
|
|
142
|
+
if (options.reason && Buffer.byteLength(options.reason, 'utf8') > REASON_MAX_BYTES) {
|
|
143
|
+
throw fail(2, `a reason must be at most ${REASON_MAX_BYTES} UTF-8 bytes, got ${Buffer.byteLength(options.reason, 'utf8')}`);
|
|
144
|
+
}
|
|
145
|
+
return options;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
export const main = (argv, { log = console.log, error = console.error, io } = {}) => {
|
|
149
|
+
let options;
|
|
150
|
+
try {
|
|
151
|
+
options = parseArgv(argv);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
error(err.message);
|
|
154
|
+
return err.exitCode ?? 2;
|
|
155
|
+
}
|
|
156
|
+
if (options.mode === 'help') {
|
|
157
|
+
log(HELP);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let scope;
|
|
162
|
+
let judged;
|
|
163
|
+
let unreadable;
|
|
164
|
+
try {
|
|
165
|
+
scope = validateScope(readJson(join(options.root, SCOPE_PATH), 'the coverage scope'), join(options.root, SCOPE_PATH));
|
|
166
|
+
const documents = specDocuments(options.root, io);
|
|
167
|
+
const found = claimsOf(documents);
|
|
168
|
+
unreadable = found.unreadable;
|
|
169
|
+
const tools = toolsIn(options.root, scope, io);
|
|
170
|
+
// A census of nothing is not a pass. Either the roots are wrong or the tree is not what the
|
|
171
|
+
// scope describes; both are refusals, never a green.
|
|
172
|
+
if (tools.length === 0) throw fail(2, `the declared roots (${(scope.roots ?? []).join(', ')}) hold no file this scope would judge — a census of zero is not a pass`);
|
|
173
|
+
judged = judgeCoverage({ tools, claims: found.claims, adopted: scope.adopted, settled: scope.settled ?? [] });
|
|
174
|
+
} catch (err) {
|
|
175
|
+
error(err.message);
|
|
176
|
+
return err.exitCode ?? 2;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (options.mode === 'report') {
|
|
180
|
+
for (const { path, by } of judged.covered) log(`${path}\tcovered\t${by}`);
|
|
181
|
+
for (const path of judged.uncovered) log(`${path}\tuncovered\t-`);
|
|
182
|
+
for (const path of judged.debt) log(`${path}\tdebt\t-`);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (options.mode === 'write-debt') {
|
|
187
|
+
// What is PAYABLE is a subset of what was ADOPTED by construction — it is the owed set filtered,
|
|
188
|
+
// and the owed set is the baseline minus what is already settled. So this write cannot invent a
|
|
189
|
+
// path even in principle; `settleAfter` states that as a rule and refuses one directly, which is
|
|
190
|
+
// where it is asserted. A branch here would be unreachable, and an unreachable guard is not a
|
|
191
|
+
// guard: it is a claim nobody can check.
|
|
192
|
+
const next = settleAfter(scope.adopted, scope.settled ?? [], judged.payable);
|
|
193
|
+
// `adopted` is never rewritten here: it is the state this write is judged against.
|
|
194
|
+
writeFileSync(join(options.root, SCOPE_PATH), `${JSON.stringify({ ...scope, reason: options.reason, settled: next.settled }, null, 2)}\n`);
|
|
195
|
+
log(`spec-coverage: debt ${judged.debt.length} → ${judged.debt.length - next.added.length} (${next.added.length} paid and recorded)`);
|
|
196
|
+
log(`reason: ${options.reason}`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const findings = formatFindings({ ...judged, unreadable });
|
|
201
|
+
if (findings.length === 0) {
|
|
202
|
+
log(`spec-coverage: PASS — ${judged.covered.length} tool(s) governed by a contract, ${judged.debt.length} still owed`);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
error(`spec-coverage: FAIL — ${findings.length} finding(s) against ${join(options.root, SCOPE_PATH)}:`);
|
|
206
|
+
for (const line of findings) error(line);
|
|
207
|
+
error('spec-coverage: WHY — no work is done without a specification; a tool no contract governs promises nothing anyone can check.');
|
|
208
|
+
return 1;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NO WORK IS DONE WITHOUT A SPECIFICATION — the half prose cannot do.
|
|
3
|
+
//
|
|
4
|
+
// The spec store answers "what does this module promise?" only for the modules somebody chose to
|
|
5
|
+
// write a contract for, so a contract has been a suggestion: measured when this module was written,
|
|
6
|
+
// 122 tool modules under `agent-workflow-kit/tools/` and 14 of them covered. This module makes the
|
|
7
|
+
// contract a REQUIREMENT with a ratchet — a shipped tool no contract governs is a REFUSAL, and the
|
|
8
|
+
// debt of today's uncovered tools may only shrink.
|
|
9
|
+
//
|
|
10
|
+
// The contract this module is built to is `docs/ai/specs/kit/spec-coverage.md`, and it was written
|
|
11
|
+
// BEFORE this file. That order is the point: a contract amended after the code describes whatever
|
|
12
|
+
// the last review happened to find, which makes review an open question with no bounded answer.
|
|
13
|
+
//
|
|
14
|
+
// Pure functions. No filesystem, no argv, no side effects on import — the CLI half owns all of that.
|
|
15
|
+
// Dependency-free, Node >= 22.
|
|
16
|
+
|
|
17
|
+
import { readSpecDocument } from '../references/scripts/spec-schema.mjs';
|
|
18
|
+
|
|
19
|
+
// One `## Module` bullet, carried with the document that made the claim so a refusal can name the
|
|
20
|
+
// owner. The two forms are the ones the spec schema already validates and there is no third here:
|
|
21
|
+
// a `dir/` root covers by PREFIX, a file claim by EQUALITY. The trailing slash is what makes the
|
|
22
|
+
// prefix test safe — `tools/manifest/` can never swallow `tools/manifest-validate.mjs`.
|
|
23
|
+
// Only a LIVE contract claims shipped code. A `draft` is a proposal — it may name a module nobody
|
|
24
|
+
// has built and bind a scenario to nothing — and a `retired` one has stopped promising anything. If
|
|
25
|
+
// either counted, a tool could ship covered by a contract that was never in force.
|
|
26
|
+
const CLAIMING_KINDS = new Set(['spec']);
|
|
27
|
+
const CLAIMING_STATUS = 'live';
|
|
28
|
+
|
|
29
|
+
export const claimsOf = (documents) => {
|
|
30
|
+
const claims = [];
|
|
31
|
+
const unreadable = [];
|
|
32
|
+
for (const { rel, text } of documents) {
|
|
33
|
+
const verdict = readSpecDocument(String(text ?? ''), rel);
|
|
34
|
+
// Only a CONTRACT claims code. A navigator (`kind: index`) lists children and a part belongs to
|
|
35
|
+
// the module its parent already claims, so neither is asked for a `## Module` — skipping them by
|
|
36
|
+
// KIND, never by the absence of the section, is what keeps the next line honest.
|
|
37
|
+
if (!CLAIMING_KINDS.has(verdict.kind) || verdict.status !== CLAIMING_STATUS) continue;
|
|
38
|
+
const module = verdict.structure?.module;
|
|
39
|
+
// A CONTRACT whose module cannot be read is its OWN finding, never a silent skip: the tools it
|
|
40
|
+
// would have covered would look uncovered and the refusal would name the wrong defect.
|
|
41
|
+
if (!module) unreadable.push({ rel, why: verdict.errors?.[0]?.message ?? 'no readable ## Module declaration' });
|
|
42
|
+
else for (const path of module.paths) claims.push({ path, form: module.form, by: rel });
|
|
43
|
+
}
|
|
44
|
+
return { claims, unreadable };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const coveredBy = (claims, path) =>
|
|
48
|
+
claims.find((claim) => (claim.path.endsWith('/') ? path.startsWith(claim.path) : claim.path === path)) ?? null;
|
|
49
|
+
|
|
50
|
+
// The verdict over one census. The debt is DERIVED — `adopted` minus `settled` — so there is no
|
|
51
|
+
// stored list a hand can edit into a lie. Three findings:
|
|
52
|
+
// uncovered a tool no contract covers and the derived debt does not owe — the refusal
|
|
53
|
+
// falselySettled a path recorded as PAID whose contract is not there — the record claims what the
|
|
54
|
+
// contracts do not say, and it is checked against them every run
|
|
55
|
+
// payable a path still owed whose contract now EXISTS — the debt shrank and the record did
|
|
56
|
+
// not, so run --write-debt; until then the record overstates what is owed
|
|
57
|
+
export const judgeCoverage = ({ tools, claims, adopted = [], settled = [] }) => {
|
|
58
|
+
const paid = new Set(settled);
|
|
59
|
+
const owed = adopted.filter((path) => !paid.has(path));
|
|
60
|
+
const stillOwed = new Set(owed);
|
|
61
|
+
const present = new Set(tools);
|
|
62
|
+
const covered = [];
|
|
63
|
+
const uncovered = [];
|
|
64
|
+
for (const path of tools) {
|
|
65
|
+
const claim = coveredBy(claims, path);
|
|
66
|
+
if (claim) covered.push({ path, by: claim.by });
|
|
67
|
+
else if (!stillOwed.has(path)) uncovered.push(path);
|
|
68
|
+
}
|
|
69
|
+
const falselySettled = settled.filter((path) => present.has(path) && coveredBy(claims, path) === null);
|
|
70
|
+
const payable = owed.filter((path) => !present.has(path) || coveredBy(claims, path) !== null);
|
|
71
|
+
return { covered, uncovered, falselySettled, payable, debt: owed };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// What a `--write-debt` run may record: every path whose contract now exists moves into `settled`,
|
|
75
|
+
// and nothing else changes. `adopted` is never touched, so a path that is not in it cannot be
|
|
76
|
+
// invented — the run names it and says to write the contract.
|
|
77
|
+
export const settleAfter = (adopted, settled, payable) => {
|
|
78
|
+
const unknown = payable.filter((path) => !adopted.includes(path));
|
|
79
|
+
if (unknown.length) return { ok: false, unknown };
|
|
80
|
+
return { ok: true, settled: [...new Set([...settled, ...payable])].sort(), added: payable };
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const formatFindings = ({ uncovered, falselySettled = [], payable = [], unreadable = [] }) => [
|
|
84
|
+
...unreadable.map((u) => ` ${u.rel}: its ## Module cannot be read (${u.why}) — the tools it claims are unknown`),
|
|
85
|
+
...uncovered.map((p) => ` ${p}: no contract under docs/ai/specs/ claims this module — write one, or it cannot ship`),
|
|
86
|
+
...falselySettled.map((p) => ` ${p}: recorded as SETTLED, but no live contract claims it — the record asserts a contract that is not there`),
|
|
87
|
+
...payable.map((p) => ` ${p}: still recorded as owed although it is covered now (or gone) — run --write-debt to record what was paid`),
|
|
88
|
+
];
|