@sabaiway/agent-workflow-kit 12.2.0 → 12.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/tools/epic-shape-brief.mjs +75 -0
- package/tools/epic-shape-cli.mjs +153 -0
- package/tools/epic-shape-ledger.mjs +180 -0
- package/tools/epic-shape.mjs +232 -0
- package/tools/queue-audit-rows.mjs +1 -0
- package/tools/queue-audit.mjs +14 -5
- package/tools/queue-purge-archive.mjs +176 -0
- package/tools/queue-purge-cli.mjs +111 -0
- package/tools/queue-purge.mjs +66 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,101 @@ 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
|
+
## 12.4.0 — the epic tier gets its checker: `epic-shape` judges a capped concept file by one altitude predicate and one claim relation, renders its review brief, folds the findings by the same predicate, and closes it by rewriting one byte range (AD-138)
|
|
8
|
+
|
|
9
|
+
**MINOR: one new tool, four verbs, four shipped modules; no deployed gate line changes its verdict.** The epic this
|
|
10
|
+
family works under was settled after a six-round design consult whose brief had no altitude bar, and the file drifted
|
|
11
|
+
within a day — a backticked hash in a state field, ground named in prose, an id paragraph above the first section. A
|
|
12
|
+
prose bar has been measured failing under load here, so the rung is a tool. The contract is
|
|
13
|
+
`docs/ai/specs/kit/epic-shape/` (index + `ledger.md` + `brief.md`, live, revision 1, S1–S16 bound).
|
|
14
|
+
|
|
15
|
+
- **`tools/epic-shape-cli.mjs --check <epic>`** reads `docs/ai/epics/<ID>.md` through the no-follow door — the id is
|
|
16
|
+
the file stem — and refuses on: a frontmatter key set other than the substrate six plus `state: open | landed`,
|
|
17
|
+
`maxLines` other than 60, a file over 60 lines, a title other than `# Epic: <title>`, any heading outside the seven
|
|
18
|
+
sections in their order (Intent, Value, Non-goals, Acceptance, Specs, Stories ledger, Queue), a non-blank line
|
|
19
|
+
above Intent, a `## Queue` line outside `Row <ID> in <bucket>`, a second or non-date `Result line:`, a
|
|
20
|
+
`docs/plans/<name>.md` token other than the queue, and — the ALTITUDE BAR, one predicate over every body line
|
|
21
|
+
outside the two claim fields — a code fence line, a backtick span or a path-with-line citation (`something.ext:123`,
|
|
22
|
+
`dir/file:123`, backticked or bare); a mechanism written as plain prose passes, a stated residual, no word list.
|
|
23
|
+
The story ledger is six ` | ` fields per row (`S<N>`, name, `depends-on:`, `owns:`, `shared:`, `state:`), ids
|
|
24
|
+
contiguous from S1, at most 200 bytes counted OUTSIDE `owns:` and `shared:`; a claim is a repo-relative path in one
|
|
25
|
+
canonical spelling (a closed byte set — no whitespace, backtick, parenthesis, wildcard, pipe, colon or hash — no
|
|
26
|
+
`.`/`..` segment, no leading slash, an optional trailing slash as a directory prefix), never a file the checker
|
|
27
|
+
opens; a co-located test path (`foo.test.mjs`, `foo.test/`) is covered by `foo.mjs`. ONE claim relation between
|
|
28
|
+
distinct not-landed stories identified by (epic path, id): overlap is equality or a slash-boundary prefix;
|
|
29
|
+
owns × owns refuses inside and across epics; a pair with a shared side needs a transitive `depends-on` edge inside
|
|
30
|
+
one epic and is REPORTED (exit 0) across epics; the graph is closed and acyclic, a cycle named by its stories. The
|
|
31
|
+
sibling sweep reads the DIRECT entries of the store into an entry list — a symlinked or unreadable `.md` refuses
|
|
32
|
+
naming it, a `type: epic` file with a state outside `open | landed` refuses, an OPEN sibling is judged whole by
|
|
33
|
+
the same shape check and a refusal names the file (unknown never reads as "owns nothing"), a LANDED sibling is
|
|
34
|
+
its header only and contributes no story and no finding whatever its body holds, another type or a subdirectory
|
|
35
|
+
is skipped and counted — and prints `epic-shape: sweep <root>: epics read <n>; entries skipped <n>`, the handed
|
|
36
|
+
epic counted among the read.
|
|
37
|
+
- **`--review-brief <epic>`** prints, for an epic `--check` accepts and for no other file, a deterministic brief: the
|
|
38
|
+
id and state, the guard verbatim ("Judge intent, value, boundaries, ownership and story order. A finding that names
|
|
39
|
+
a mechanism, a file line or an implementation choice is below this tier's altitude and is NOT counted."), the
|
|
40
|
+
seven sections, five questions one per line. The same render serves a design consult over a draft.
|
|
41
|
+
- **`--fold <findings-file>`** reads entries by its own reader (column-0 `-`, `*`, `<n>.` with indented and blank
|
|
42
|
+
continuation), excises each entry's first citation (with its backticks only when the span is exactly the token),
|
|
43
|
+
judges the remainder by the same predicate and prints three named lists with counts — kept, discarded with a
|
|
44
|
+
reason each, lines outside entries; a zero-entry file refuses.
|
|
45
|
+
- **`--close <epic>`** runs the check, then four conditions — every story `landed <date>`, a result-line date, the
|
|
46
|
+
header not already landed, the queue row not standing by parsed id or whole-token display title over
|
|
47
|
+
`queue-audit`'s parser of `<root>/docs/plans/queue.md` read whole (absent or unparseable refuses) — and on accept
|
|
48
|
+
rewrites ONLY the header `state` bytes through the one descriptor it read; a second hard link refuses without a
|
|
49
|
+
write; an epic outside a `docs/ai/epics/` store refuses.
|
|
50
|
+
- **Grammar and exits.** `--help` alone, or exactly one verb and exactly one positional; a positional opening with
|
|
51
|
+
`-`, a second verb, a repeated positional and an empty invocation are usage. Exit 0 accept · 1 refuse · 2 usage.
|
|
52
|
+
Findings render as `epic-shape: <path>:<line>: <code>: <message>`. Nothing reads git.
|
|
53
|
+
- **Modules and suites.** `tools/epic-shape.mjs` (232 lines — `EPIC_SECTIONS`, `parseEpic`, `parseStoryRow`,
|
|
54
|
+
`judgeAltitude`, `checkEpic`), `tools/epic-shape-ledger.mjs` (180 — `checkClaims`, `sweepSiblings`, `judgeClose`),
|
|
55
|
+
`tools/epic-shape-brief.mjs` (75 — `BRIEF_GUARD`, `renderBrief`, `readEntries`, `foldFindings`),
|
|
56
|
+
`tools/epic-shape-cli.mjs` (153 — argv and fs only, `main(argv, io)`); four suites under `tools/epic-shape.test/`
|
|
57
|
+
bind S1–S16 with every changed line covered. The tarball gains the four modules by name (301 files, from 297).
|
|
58
|
+
|
|
59
|
+
Repo-only: this repo's own epic is rewritten to the contract inside its 60 lines (58) and its `--check` line is a
|
|
60
|
+
declared gate before `coverage-check`; the engine canon gains `## The epic` (engine 5.3.0).
|
|
61
|
+
|
|
62
|
+
## 12.3.0 — the purge archive is a tool: `queue-purge` snapshots a section's bytes and vouches for what leaves, and `queue-audit` exports the two surfaces it reads (AD-137)
|
|
63
|
+
|
|
64
|
+
**MINOR: one new tool, two verbs, and the sibling's revision 6; no deployed gate line changes its verdict.** The canon has
|
|
65
|
+
demanded a purge archive since the row grammar was written, as prose kept by hand — eight dated archives sat under
|
|
66
|
+
`docs/ai/history/` tied by no checked fact to the queue they came out of. The rung is now a tool, and the contract is
|
|
67
|
+
`docs/ai/specs/kit/queue-purge/` (live, revision 2, S1–S18 bound).
|
|
68
|
+
|
|
69
|
+
- **`tools/queue-purge-cli.mjs snapshot <queue> --section <heading> --archive <file>`** writes the section's
|
|
70
|
+
heading line and range VERBATIM from the file's own bytes — every row, the preamble, the bucket headings — plus one
|
|
71
|
+
index entry per row (archived line, class, key, the title's first 80 characters) under a header that records the
|
|
72
|
+
date, the canonical section, the queue path, both byte lengths, the section sha256, the block sha256 (sealing every
|
|
73
|
+
other header field and the whole body) and the row count. A block is FRAMED by its recorded lengths, never found by
|
|
74
|
+
scanning backward; the archive grows by appended blocks, whole or not at all; a second snapshot of an unmoved
|
|
75
|
+
section refuses; a keyless row is archived and marked, with the count on stderr. `--check` reads the archive whole,
|
|
76
|
+
judges it intact, selects the last block for the section and refuses on exactly six causes — a key defect, not
|
|
77
|
+
intact, not moved, an addition, an unauthorised absence, a keyless entry — collected, each naming its row or offset.
|
|
78
|
+
The key of a row is its id and nothing else; a doubled id anywhere in the document refuses both verbs naming both
|
|
79
|
+
lines. Exit 0 accept · 1 refuse · 2 usage; `--archive` and `--section` are both required, every option and
|
|
80
|
+
positional at most once, `--help` only whole, an archive that resolves to the queue (path, symlink or hard link)
|
|
81
|
+
is usage, and the eight hand-written dated archives are foreign grammar (exit 2 naming line 1). Neither verb reads
|
|
82
|
+
a cap or judges a length or a name; the queue is byte-identical after both. Three modules under the 400-line cap —
|
|
83
|
+
`queue-purge-archive.mjs` (the grammar, the digests, the append-whole writer), `queue-purge.mjs` (the two verbs
|
|
84
|
+
over ONE document-wide parse), `queue-purge-cli.mjs` — and four suites under `tools/queue-purge.test/`.
|
|
85
|
+
- **`queue-audit` revision 6 (`docs/ai/specs/kit/queue-audit/`, now a folder: index + `argv.md`).**
|
|
86
|
+
`authorisesRemoval(klass)` is exported from the row leaf — true for `terminal` and `record` only, false for every
|
|
87
|
+
other value, an unknown class included — and `checkQueue`'s still-listed refusal reads it, as the purge check does
|
|
88
|
+
over the class an archive block froze: two callers, one list. `auditQueue(text, { range })` — exclusive with
|
|
89
|
+
`section` — keeps the rows, counts, total and the unread-item refusal DOCUMENT-WIDE and adds
|
|
90
|
+
`range: { headingLine, from, to }` in file lines (half-open, an empty section included); a `section` call is
|
|
91
|
+
byte-identical to before, so a deployed gate line keeps its verdict. Scenarios S21 and S22 bind in
|
|
92
|
+
`queue-audit.test/check-queue.test.mjs`.
|
|
93
|
+
- **The tarball** gains the three modules by name (297 files, from 294); the suites stay out of it.
|
|
94
|
+
|
|
95
|
+
Repo-only, the tool's first live run: this repo's `## Pending / backlog` went from 257 rows / 7020 lines to 256 / 1717
|
|
96
|
+
in the order the spec names — 8 doubled ids renamed, 120 keyless rows keyed, 8 ambiguous rows settled with no line
|
|
97
|
+
added or deleted, one snapshot (257 rows, 655095 section bytes, no keyless note), five trim slices by bucket with
|
|
98
|
+
every id byte-identical and one terminal row deleted, `--check` accepting at 256 rows; eight rows this release's own
|
|
99
|
+
review filed after the check took the count to 264, so the gate line reads `--max-rows 264 --max-row-lines 10` under
|
|
100
|
+
a one-sentence title with no ratchet log.
|
|
101
|
+
|
|
7
102
|
## 12.2.0 — the bundled agy bridge defaults to Gemini 3.8 Flash (High) (AD-136; antigravity-cli-bridge 5.7.0)
|
|
8
103
|
|
|
9
104
|
**MINOR: a default moved, nothing was removed.** The kit bundles the agy bridge mirror, so the bridge's pin move rides
|
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: '12.
|
|
6
|
+
version: '12.4.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": "12.
|
|
3
|
+
"version": "12.4.0",
|
|
4
4
|
"description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { checkEpic, judgeAltitude, EPIC_SECTIONS, findCitation } from './epic-shape.mjs';
|
|
2
|
+
import { getLineCount } from './plan-shape.mjs';
|
|
3
|
+
|
|
4
|
+
export const BRIEF_GUARD = 'Judge intent, value, boundaries, ownership and story order. A finding that names a mechanism, a file line or an implementation choice is below this tier\'s altitude and is NOT counted.';
|
|
5
|
+
const QUESTIONS = Object.freeze([
|
|
6
|
+
'Intent: Is the intended change clear?',
|
|
7
|
+
'Value: Is the value worth the work?',
|
|
8
|
+
'Boundaries: Are the non-goals and acceptance boundaries clear?',
|
|
9
|
+
'Ownership: Does each story have distinct, declared ownership?',
|
|
10
|
+
'Story order: Do the dependencies express the required order?',
|
|
11
|
+
]);
|
|
12
|
+
const BRIEF_PREFIX = '# Epic review: ';
|
|
13
|
+
const FIRST_LINE = 1;
|
|
14
|
+
const ENTRY_START = /^(?:[-*]|\d+\.)(?:[ \t]|$)/;
|
|
15
|
+
const INDENTED_LINE = /^[ \t]+\S/;
|
|
16
|
+
const CR_END = /\r$/;
|
|
17
|
+
const CITATION_SPAN = /(?<!`)(`+)(?!`)([\s\S]*?)(?<!`)\1(?!`)/g;
|
|
18
|
+
const SPAN_CONTENT = 2;
|
|
19
|
+
const CITATION_GAP = ' ';
|
|
20
|
+
const ERROR = 'error';
|
|
21
|
+
const makeEntry = (entry) => ({ ...entry, text: entry.lines.join('\n') });
|
|
22
|
+
|
|
23
|
+
export const renderBrief = (text, path) => {
|
|
24
|
+
const { findings, epic } = checkEpic(text, path);
|
|
25
|
+
if (findings.length) return { findings, brief: null };
|
|
26
|
+
const sections = EPIC_SECTIONS.map((heading) => epic.sections.find((section) => section.heading === heading).text).join('');
|
|
27
|
+
const separator = sections.endsWith('\n') ? '\n' : '\n\n';
|
|
28
|
+
return `${BRIEF_PREFIX}${epic.id} (${epic.state})\n\n${BRIEF_GUARD}\n\n${sections}${separator}${QUESTIONS.join('\n')}\n`;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const readEntries = (text) => {
|
|
32
|
+
const source = String(text ?? '');
|
|
33
|
+
const lines = source.split('\n').slice(0, getLineCount(source));
|
|
34
|
+
const read = lines.reduce((state, raw, index) => {
|
|
35
|
+
const line = index + FIRST_LINE;
|
|
36
|
+
if (ENTRY_START.test(raw.replace(CR_END, ''))) {
|
|
37
|
+
return { ...state, entries: state.current ? [...state.entries, makeEntry(state.current)] : state.entries,
|
|
38
|
+
current: { line, lines: [raw] } };
|
|
39
|
+
}
|
|
40
|
+
if (state.current && (raw.trim() === '' || INDENTED_LINE.test(raw))) {
|
|
41
|
+
return { ...state, current: { ...state.current, lines: [...state.current.lines, raw] } };
|
|
42
|
+
}
|
|
43
|
+
return { current: null, entries: state.current ? [...state.entries, makeEntry(state.current)] : state.entries,
|
|
44
|
+
outside: raw.trim() === '' ? state.outside : [...state.outside, { line, text: raw }] };
|
|
45
|
+
}, { entries: [], outside: [], current: null });
|
|
46
|
+
return { entries: read.current ? [...read.entries, makeEntry(read.current)] : read.entries, outside: read.outside };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const findCitationRange = (text) => {
|
|
50
|
+
const citation = findCitation(text);
|
|
51
|
+
if (!citation) return null;
|
|
52
|
+
const span = [...text.matchAll(CITATION_SPAN)].find((match) => match.index < citation.index && match.index + match[0].length > citation.end);
|
|
53
|
+
if (!span) return citation;
|
|
54
|
+
return span[SPAN_CONTENT] === citation.text ? { index: span.index, end: span.index + span[0].length } : null;
|
|
55
|
+
};
|
|
56
|
+
const judgeEntry = (entry) => {
|
|
57
|
+
const citation = findCitationRange(entry.text);
|
|
58
|
+
const remainder = citation ? entry.text.slice(0, citation.index) + CITATION_GAP + entry.text.slice(citation.end) : entry.text;
|
|
59
|
+
const lines = remainder.split('\n').map((line, index) => ({
|
|
60
|
+
...judgeAltitude(index === 0 ? line.replace(ENTRY_START, '') : line), reasonLine: entry.line + index,
|
|
61
|
+
}));
|
|
62
|
+
const violation = lines.find((line) => line.below) ?? { ...judgeAltitude(remainder), reasonLine: entry.line };
|
|
63
|
+
return violation.below ? { ...entry, form: violation.form, reasonLine: violation.reasonLine,
|
|
64
|
+
reason: `${violation.form} is below concept altitude` } : null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const foldFindings = (text) => {
|
|
68
|
+
const { entries, outside } = readEntries(text);
|
|
69
|
+
const judged = entries.map((entry) => ({ entry, discarded: judgeEntry(entry) }));
|
|
70
|
+
const kept = judged.filter((item) => !item.discarded).map((item) => item.entry);
|
|
71
|
+
const discarded = judged.filter((item) => item.discarded).map((item) => item.discarded);
|
|
72
|
+
const findings = entries.length ? [] : [{ line: FIRST_LINE, code: 'empty-findings', severity: ERROR, message: 'the findings file contains no entries' }];
|
|
73
|
+
return { ok: findings.length === 0, findings, kept, discarded, outside,
|
|
74
|
+
counts: { kept: kept.length, discarded: discarded.length, outside: outside.length } };
|
|
75
|
+
};
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readdirSync, writeSync, ftruncateSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
5
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
6
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
7
|
+
import { checkEpic } from './epic-shape.mjs';
|
|
8
|
+
import { checkClaims, sweepSiblings, judgeClose, STORE_PATH, QUEUE_PATH } from './epic-shape-ledger.mjs';
|
|
9
|
+
import { renderBrief, foldFindings } from './epic-shape-brief.mjs';
|
|
10
|
+
|
|
11
|
+
const EXIT = Object.freeze({ accept: 0, refuse: 1, usage: 2 });
|
|
12
|
+
const CHECK = '--check';
|
|
13
|
+
const BRIEF = '--review-brief';
|
|
14
|
+
const FOLD = '--fold';
|
|
15
|
+
const CLOSE = '--close';
|
|
16
|
+
const VERBS = Object.freeze([CHECK, BRIEF, FOLD, CLOSE]);
|
|
17
|
+
const HELP_FLAG = '--help';
|
|
18
|
+
const HELP_ARITY = 1;
|
|
19
|
+
const VERB_ARITY = 2;
|
|
20
|
+
const FIRST_LINE = 1;
|
|
21
|
+
const MARKDOWN_SUFFIX = '.md';
|
|
22
|
+
const PREFIX = 'epic-shape:';
|
|
23
|
+
const INFO = 'info';
|
|
24
|
+
const STORE_TO_PROJECT = '../../..';
|
|
25
|
+
const LANDED_BYTES = Buffer.from('landed');
|
|
26
|
+
const HELP = `Usage:
|
|
27
|
+
node epic-shape-cli.mjs --check <epic>
|
|
28
|
+
node epic-shape-cli.mjs --review-brief <epic>
|
|
29
|
+
node epic-shape-cli.mjs --fold <findings-file>
|
|
30
|
+
node epic-shape-cli.mjs --close <epic>
|
|
31
|
+
node epic-shape-cli.mjs --help
|
|
32
|
+
|
|
33
|
+
Only --close writes: the epic header state becomes landed.
|
|
34
|
+
Exit codes: 0 accept, 1 refuse, 2 usage.`;
|
|
35
|
+
|
|
36
|
+
const parseArgv = (argv) => {
|
|
37
|
+
if (argv.length === HELP_ARITY && argv[0] === HELP_FLAG) return { help: true };
|
|
38
|
+
const [verb, path] = argv;
|
|
39
|
+
if (argv.length !== VERB_ARITY || !VERBS.includes(verb) || !path || path.startsWith('-')) {
|
|
40
|
+
throw fail(EXIT.usage, 'use --help alone, or exactly one verb followed by exactly one path that does not open with a dash');
|
|
41
|
+
}
|
|
42
|
+
return { verb, path: resolve(path) };
|
|
43
|
+
};
|
|
44
|
+
const makeFailure = (path, code, message) => Object.assign(fail(EXIT.refuse, message), { path, code });
|
|
45
|
+
const formatFinding = (finding, fallbackPath) => {
|
|
46
|
+
const path = finding.path ?? fallbackPath;
|
|
47
|
+
const prefix = `${path}: `;
|
|
48
|
+
const message = finding.message.startsWith(prefix) ? finding.message.slice(prefix.length) : finding.message;
|
|
49
|
+
return `${PREFIX} ${path}:${finding.line ?? FIRST_LINE}: ${finding.code}: ${message}`;
|
|
50
|
+
};
|
|
51
|
+
const printFindings = (findings, path, io) => {
|
|
52
|
+
for (const finding of findings) (finding.severity === INFO ? io.log : io.error)(formatFinding(finding, path));
|
|
53
|
+
};
|
|
54
|
+
const printAccept = (verb, path, log) => log(`${PREFIX} accept ${verb} ${path}`);
|
|
55
|
+
const printSweep = (sweep, log) => log(`${PREFIX} sweep ${sweep.root}: epics read ${sweep.counts.epicsRead + FIRST_LINE}; entries skipped ${sweep.counts.skipped}`);
|
|
56
|
+
const describeRead = (result) => result.code ?? result.className ?? result.outcome;
|
|
57
|
+
const openWritable = (path, flags) => openSync(path, flags | constants.O_RDWR);
|
|
58
|
+
const readInput = (path, read, writable) => {
|
|
59
|
+
const result = read(path, writable ? { keepFd: true, open: openWritable } : {});
|
|
60
|
+
if (result.outcome !== 'ok') throw makeFailure(path, 'read', describeRead(result));
|
|
61
|
+
return result;
|
|
62
|
+
};
|
|
63
|
+
const readDirectory = (root, read) => readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).map((entry) => {
|
|
64
|
+
const name = entry.name;
|
|
65
|
+
if (entry.isDirectory()) return { name, outcome: 'directory' };
|
|
66
|
+
if (!name.endsWith(MARKDOWN_SUFFIX)) return { name, text: '' };
|
|
67
|
+
const result = read(join(root, name));
|
|
68
|
+
if (result.outcome === 'ok') return { name, text: result.content };
|
|
69
|
+
if (result.outcome === 'foreign') return { name, outcome: 'non-regular', kind: result.className };
|
|
70
|
+
return { name, outcome: 'unreadable', reason: describeRead(result) };
|
|
71
|
+
});
|
|
72
|
+
const readQueue = (path, read) => {
|
|
73
|
+
if (!STORE_PATH.test(path)) return { outcome: 'unreadable', reason: 'epic is outside a project store' };
|
|
74
|
+
const queuePath = resolve(dirname(path), STORE_TO_PROJECT, QUEUE_PATH);
|
|
75
|
+
const result = read(queuePath);
|
|
76
|
+
return result.outcome === 'ok' ? result.content : { outcome: result.outcome, reason: describeRead(result) };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const writeAll = (fd, bytes, position, path, offset = 0) => {
|
|
80
|
+
if (offset === bytes.length) return;
|
|
81
|
+
const written = writeSync(fd, bytes, offset, bytes.length - offset, position + offset);
|
|
82
|
+
if (written <= 0) throw makeFailure(path, 'write', 'the descriptor write made no progress');
|
|
83
|
+
writeAll(fd, bytes, position, path, offset + written);
|
|
84
|
+
};
|
|
85
|
+
const writeState = (input, range, path) => {
|
|
86
|
+
const stats = fstatSync(input.fd);
|
|
87
|
+
if (stats.nlink !== FIRST_LINE) throw makeFailure(path, 'hardlink', 'close refuses a file with a second hard link');
|
|
88
|
+
if (!stats.isFile() || stats.dev !== input.dev || stats.ino !== input.ino || stats.size !== input.bytes.length) throw makeFailure(path, 'changed-file', 'the held file changed after the check');
|
|
89
|
+
const suffix = Buffer.concat([LANDED_BYTES, input.bytes.subarray(range.end)]);
|
|
90
|
+
writeAll(input.fd, suffix, range.start, path);
|
|
91
|
+
ftruncateSync(input.fd, range.start + suffix.length);
|
|
92
|
+
};
|
|
93
|
+
const runFold = (input, path, io) => {
|
|
94
|
+
const result = foldFindings(input.content);
|
|
95
|
+
io.log(`${PREFIX} kept (${result.counts.kept})`);
|
|
96
|
+
for (const entry of result.kept) io.log(`${entry.line}: ${entry.text}`);
|
|
97
|
+
io.log(`${PREFIX} discarded (${result.counts.discarded})`);
|
|
98
|
+
for (const entry of result.discarded) io.log(`${entry.line}: ${entry.reason} (line ${entry.reasonLine})\n${entry.text}`);
|
|
99
|
+
io.log(`${PREFIX} outside (${result.counts.outside})`);
|
|
100
|
+
for (const line of result.outside) io.log(`${line.line}: ${line.text}`);
|
|
101
|
+
printFindings(result.findings, path, io);
|
|
102
|
+
if (!result.ok) return EXIT.refuse;
|
|
103
|
+
printAccept(FOLD, path, io.log);
|
|
104
|
+
return EXIT.accept;
|
|
105
|
+
};
|
|
106
|
+
const runEpic = (verb, input, path, io) => {
|
|
107
|
+
const entries = readDirectory(dirname(path), io.read).filter((entry) => entry.name !== basename(path));
|
|
108
|
+
const shape = checkEpic(input.content, path);
|
|
109
|
+
const sweep = sweepSiblings(entries, dirname(path));
|
|
110
|
+
const claims = shape.findings.length === 0 && sweep.ok
|
|
111
|
+
? checkClaims([shape.epic, ...sweep.epics]) : { findings: [] };
|
|
112
|
+
const findings = [...shape.findings, ...sweep.findings, ...claims.findings];
|
|
113
|
+
const output = verb === BRIEF ? { ...io, log: io.error } : io;
|
|
114
|
+
printSweep(sweep, output.log);
|
|
115
|
+
if (findings.some((finding) => finding.severity !== INFO)) {
|
|
116
|
+
printFindings(findings, path, output);
|
|
117
|
+
return EXIT.refuse;
|
|
118
|
+
}
|
|
119
|
+
if (verb === CLOSE) {
|
|
120
|
+
const closed = judgeClose({ epic: input.content, path, queue: readQueue(path, io.read), entries });
|
|
121
|
+
printFindings(closed.findings, path, io);
|
|
122
|
+
if (!closed.ok) return EXIT.refuse;
|
|
123
|
+
writeState(input, closed.stateRange, path);
|
|
124
|
+
} else {
|
|
125
|
+
printFindings(findings, path, output);
|
|
126
|
+
if (verb === BRIEF) {
|
|
127
|
+
const brief = renderBrief(input.content, path);
|
|
128
|
+
if (typeof brief !== 'string') { printFindings(brief.findings, path, output); return EXIT.refuse; }
|
|
129
|
+
io.log(brief.slice(0, -FIRST_LINE));
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
printAccept(verb, path, output.log);
|
|
133
|
+
return EXIT.accept;
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const main = (argv, { log = console.log, error = console.error, read = readRegularFileNoFollow } = {}) => {
|
|
137
|
+
try {
|
|
138
|
+
const options = parseArgv(argv);
|
|
139
|
+
if (options.help) { log(HELP); return EXIT.accept; }
|
|
140
|
+
const input = readInput(options.path, read, options.verb === CLOSE);
|
|
141
|
+
try {
|
|
142
|
+
const io = { log, error, read };
|
|
143
|
+
return options.verb === FOLD ? runFold(input, options.path, io) : runEpic(options.verb, input, options.path, io);
|
|
144
|
+
} finally {
|
|
145
|
+
if (input.fd !== undefined) closeSync(input.fd);
|
|
146
|
+
}
|
|
147
|
+
} catch (failure) {
|
|
148
|
+
error(formatFinding({ path: failure.path, line: FIRST_LINE, code: failure.code ?? 'usage', message: failure.message }, argv[1] ?? 'invocation'));
|
|
149
|
+
return failure.exitCode === EXIT.usage ? EXIT.usage : EXIT.refuse;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(VERB_ARITY));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
2
|
+
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
3
|
+
import { parseHeader, checkEpic } from './epic-shape.mjs';
|
|
4
|
+
import { auditQueue } from './queue-audit.mjs';
|
|
5
|
+
import { unique } from './plan-shape.mjs';
|
|
6
|
+
|
|
7
|
+
const FIRST_LINE = 1;
|
|
8
|
+
const ERROR = 'error';
|
|
9
|
+
const INFO = 'info';
|
|
10
|
+
const OPEN = 'open';
|
|
11
|
+
const LANDED = 'landed';
|
|
12
|
+
const EPIC = 'epic';
|
|
13
|
+
const OWN = 'owns';
|
|
14
|
+
const CLAIM_FIELDS = Object.freeze([OWN, 'shared']);
|
|
15
|
+
const FRONT_DELIMITER = '---';
|
|
16
|
+
const CR_END = /\r$/;
|
|
17
|
+
const MARKDOWN_SUFFIX = '.md';
|
|
18
|
+
const TRAILING_SLASH = /\/$/;
|
|
19
|
+
const SOURCE_EXTENSION = /^(.*[^/])(\.[^./]+)$/;
|
|
20
|
+
const TEST_FILE = /\.test\.[^/]+$/;
|
|
21
|
+
const TEST_SUFFIX = '.test';
|
|
22
|
+
export const STORE_PATH = /^(?:(.*)\/)?docs\/ai\/epics\/([^/]+)\.md$/;
|
|
23
|
+
export const QUEUE_PATH = 'docs/plans/queue.md';
|
|
24
|
+
const TOKEN_EDGE = '[\\p{L}\\p{N}_-]';
|
|
25
|
+
const REGEX_BYTES = /[.*+?^${}()|[\]\\]/g;
|
|
26
|
+
const PARSE_REFUSALS = Object.freeze([1, 2]);
|
|
27
|
+
const makeFinding = (path, line, code, message, severity = ERROR) => ({ path, line, code, message: `${path}: ${message}`, severity });
|
|
28
|
+
const finishJudgement = (findings, data = {}) => ({ ...data, ok: !findings.some((finding) => finding.severity === ERROR), findings });
|
|
29
|
+
const nameFindings = (findings, path) => findings.map((finding) => makeFinding(path, finding.line, finding.code, finding.message));
|
|
30
|
+
const getIdentity = (story) => JSON.stringify([story.path, story.id]);
|
|
31
|
+
|
|
32
|
+
const findReachable = (graph, pending, visited = new Set()) => {
|
|
33
|
+
const unseen = unique(pending.filter((id) => !visited.has(id)));
|
|
34
|
+
if (unseen.length === 0) return visited;
|
|
35
|
+
return findReachable(graph, unseen.flatMap((id) => graph.get(id) ?? []), new Set([...visited, ...unseen]));
|
|
36
|
+
};
|
|
37
|
+
const readGraph = (epic, rows) => {
|
|
38
|
+
const graph = new Map(rows.map((row) => [row.id, row.dependsOn]));
|
|
39
|
+
const reachable = new Map(rows.map((row) => [row.id, findReachable(graph, row.dependsOn)]));
|
|
40
|
+
const unknowns = rows.flatMap((row) => row.dependsOn.filter((id) => !graph.has(id))
|
|
41
|
+
.map((id) => makeFinding(epic.path, row.line, 'dependency-id', `${row.id} depends on absent story ${id}`)));
|
|
42
|
+
const cyclic = rows.filter((row) => reachable.get(row.id).has(row.id));
|
|
43
|
+
const groups = unique(cyclic.map((row) => JSON.stringify(cyclic.filter((other) =>
|
|
44
|
+
reachable.get(row.id).has(other.id) && reachable.get(other.id).has(row.id)).map((other) => other.id))));
|
|
45
|
+
const cycles = groups.map((group) => {
|
|
46
|
+
const ids = JSON.parse(group);
|
|
47
|
+
return makeFinding(epic.path, rows.find((row) => row.id === ids[0]).line, 'dependency-cycle', `dependency cycle includes ${ids.join(', ')}`);
|
|
48
|
+
});
|
|
49
|
+
return { reachable, findings: [...unknowns, ...cycles] };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const expandClaim = (path) => {
|
|
53
|
+
const source = SOURCE_EXTENSION.exec(path);
|
|
54
|
+
return source && !TEST_FILE.test(path)
|
|
55
|
+
? [path, `${source[1]}${TEST_SUFFIX}${source[2]}`, `${source[1]}${TEST_SUFFIX}/`] : [path];
|
|
56
|
+
};
|
|
57
|
+
const hasPathOverlap = (left, right) => {
|
|
58
|
+
const a = left.replace(TRAILING_SLASH, '');
|
|
59
|
+
const b = right.replace(TRAILING_SLASH, '');
|
|
60
|
+
return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
61
|
+
};
|
|
62
|
+
const getClaims = (row) => CLAIM_FIELDS.flatMap((field) => row[field].map((path) => ({ field, path, ground: expandClaim(path) })));
|
|
63
|
+
const hasClaimOverlap = (left, right) => left.ground.some((a) => right.ground.some((b) => hasPathOverlap(a, b)));
|
|
64
|
+
const checkPair = (left, right) => {
|
|
65
|
+
if (getIdentity(left) === getIdentity(right)) return [];
|
|
66
|
+
const sameEpic = left.path === right.path;
|
|
67
|
+
const ordered = sameEpic && (left.reachable.has(right.id) || right.reachable.has(left.id));
|
|
68
|
+
return left.claims.flatMap((a) => right.claims.flatMap((b) => {
|
|
69
|
+
if (!hasClaimOverlap(a, b)) return [];
|
|
70
|
+
const owners = a.field === OWN && b.field === OWN;
|
|
71
|
+
if (!owners && ordered) return [];
|
|
72
|
+
const code = owners ? 'owns-overlap' : sameEpic ? 'shared-order' : 'shared-cross-epic';
|
|
73
|
+
const severity = owners || sameEpic ? ERROR : INFO;
|
|
74
|
+
const message = `${left.id} ${a.field} ${a.path} overlaps ${right.path} ${right.id} ${b.field} ${b.path}`;
|
|
75
|
+
return [makeFinding(left.path, left.line, code, message, severity)];
|
|
76
|
+
}));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const checkClaims = (epics) => {
|
|
80
|
+
const parsed = epics.map((epic) => {
|
|
81
|
+
const rows = epic.rows;
|
|
82
|
+
const graph = readGraph(epic, rows);
|
|
83
|
+
const findings = rows.filter((row) => !row.valid)
|
|
84
|
+
.map((row) => makeFinding(epic.path, row.line, 'row-grammar', `${row.id} has invalid story fields or claim tokens`));
|
|
85
|
+
const stories = epic.state === OPEN ? rows.filter((row) => row.valid && row.state !== LANDED).map((row) => ({
|
|
86
|
+
path: epic.path, id: row.id, line: row.line, claims: getClaims(row), reachable: graph.reachable.get(row.id),
|
|
87
|
+
})) : [];
|
|
88
|
+
return { stories, findings: [...nameFindings(epic.findings, epic.path), ...findings, ...graph.findings] };
|
|
89
|
+
});
|
|
90
|
+
const stories = parsed.flatMap((epic) => epic.stories);
|
|
91
|
+
const collisions = stories.flatMap((left, index) => stories.slice(index + FIRST_LINE).flatMap((right) => checkPair(left, right)));
|
|
92
|
+
return finishJudgement([...parsed.flatMap((epic) => epic.findings), ...collisions]);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const readSiblingHeader = (text, path) => {
|
|
96
|
+
const lines = text.split('\n');
|
|
97
|
+
if (lines[0].replace(CR_END, '') !== FRONT_DELIMITER) return { fields: null, findings: [] };
|
|
98
|
+
const end = lines.findIndex((line, index) => index > 0 && line.replace(CR_END, '') === FRONT_DELIMITER);
|
|
99
|
+
if (end < 0) return { fields: null, findings: [makeFinding(path, FIRST_LINE, 'sibling-header', 'frontmatter opens but never closes')] };
|
|
100
|
+
try {
|
|
101
|
+
const frontmatter = tokenizeMarkdown(`${lines.slice(0, end + FIRST_LINE).join('\n')}\n`, path).frontmatter;
|
|
102
|
+
const { fields } = parseHeader(frontmatter);
|
|
103
|
+
const valid = Object.keys(fields).length === end - FIRST_LINE;
|
|
104
|
+
return valid ? { fields, findings: [] }
|
|
105
|
+
: { fields: null, findings: [makeFinding(path, FIRST_LINE, 'sibling-header', 'frontmatter contains an unreadable or repeated field')] };
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!PARSE_REFUSALS.includes(error.exitCode)) throw error;
|
|
108
|
+
return { fields: null, findings: [makeFinding(path, FIRST_LINE, 'sibling-header', error.message)] };
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const readSibling = (entry, root) => {
|
|
112
|
+
const skipped = { read: 0, skipped: FIRST_LINE, epics: [], findings: [] };
|
|
113
|
+
if (entry.outcome === 'directory' || entry.kind === 'directory') return skipped;
|
|
114
|
+
if (!entry.name.endsWith(MARKDOWN_SUFFIX)) return skipped;
|
|
115
|
+
const path = posix.join(root, entry.name);
|
|
116
|
+
if (typeof entry.text !== 'string' || entry.outcome) {
|
|
117
|
+
return { read: 0, skipped: 0, epics: [], findings: [makeFinding(path, FIRST_LINE, 'sibling-read', `${entry.outcome ?? 'unreadable'}: ${entry.reason ?? entry.kind ?? 'no readable text'}`)] };
|
|
118
|
+
}
|
|
119
|
+
const header = readSiblingHeader(entry.text, path);
|
|
120
|
+
if (header.findings.length) return { read: 0, skipped: 0, epics: [], findings: header.findings };
|
|
121
|
+
if (header.fields?.type !== EPIC) return skipped;
|
|
122
|
+
if (![OPEN, LANDED].includes(header.fields.state)) {
|
|
123
|
+
return { read: 0, skipped: 0, epics: [], findings: [makeFinding(path, FIRST_LINE, 'sibling-state', 'epic state is missing or outside open and landed')] };
|
|
124
|
+
}
|
|
125
|
+
if (header.fields.state === LANDED) return { read: FIRST_LINE, skipped: 0, epics: [], findings: [] };
|
|
126
|
+
const { epic, findings } = checkEpic(entry.text, path);
|
|
127
|
+
return { read: FIRST_LINE, skipped: 0, epics: findings.length ? [] : [epic], findings: nameFindings(findings, path) };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export const sweepSiblings = (entries, root) => {
|
|
131
|
+
if (!Array.isArray(entries)) {
|
|
132
|
+
return finishJudgement([makeFinding(root, FIRST_LINE, 'sibling-read', 'the direct entry list is unreadable')], { root, epics: [], counts: { epicsRead: 0, skipped: 0 } });
|
|
133
|
+
}
|
|
134
|
+
const results = entries.map((entry) => readSibling(entry, root));
|
|
135
|
+
const epics = results.flatMap((result) => result.epics);
|
|
136
|
+
return finishJudgement(results.flatMap((result) => result.findings), {
|
|
137
|
+
root, epics, counts: { epicsRead: results.reduce((sum, result) => sum + result.read, 0), skipped: results.reduce((sum, result) => sum + result.skipped, 0) },
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const carriesToken = (title, id) => {
|
|
142
|
+
const escaped = id.replace(REGEX_BYTES, '\\$&');
|
|
143
|
+
return new RegExp(`(?<!${TOKEN_EDGE})${escaped}(?!${TOKEN_EDGE})`, 'u').test(title);
|
|
144
|
+
};
|
|
145
|
+
const checkQueueAbsence = (queue, path, id) => {
|
|
146
|
+
if (typeof queue !== 'string') {
|
|
147
|
+
return [makeFinding(path, FIRST_LINE, 'queue-read', `${queue?.outcome ?? 'absent'}: ${queue?.reason ?? 'no readable queue text'}`)];
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const { rows } = auditQueue(queue, { label: path });
|
|
151
|
+
return rows.filter((row) => row.id === id || carriesToken(row.title, id))
|
|
152
|
+
.map((row) => makeFinding(path, row.line, 'close-queue', `queue row still stands for ${id}: ${row.title}`));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!PARSE_REFUSALS.includes(error.exitCode)) throw error;
|
|
155
|
+
return [makeFinding(path, FIRST_LINE, 'queue-read', error.message)];
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const judgeClose = ({ epic: text, path, queue, entries }) => {
|
|
160
|
+
const checked = checkEpic(text, path);
|
|
161
|
+
const epic = checked.epic;
|
|
162
|
+
const base = { epic, stateRange: null };
|
|
163
|
+
if (checked.findings.length) return finishJudgement(nameFindings(checked.findings, path), base);
|
|
164
|
+
const store = STORE_PATH.exec(path);
|
|
165
|
+
if (!store) return finishJudgement([makeFinding(path, FIRST_LINE, 'close-path', 'close requires docs/ai/epics/<ID>.md in a project store')], base);
|
|
166
|
+
const root = store[1] === undefined ? '.' : store[1] || '/';
|
|
167
|
+
const queuePath = posix.join(root, QUEUE_PATH);
|
|
168
|
+
const sweep = sweepSiblings(entries, posix.dirname(path));
|
|
169
|
+
const claims = checkClaims([epic, ...sweep.epics.filter((sibling) => sibling.path !== posix.normalize(path))]);
|
|
170
|
+
const checks = [...sweep.findings, ...claims.findings];
|
|
171
|
+
const data = { ...base, root, queuePath, sweep };
|
|
172
|
+
if (!sweep.ok || !claims.ok) return finishJudgement(checks, data);
|
|
173
|
+
const stories = epic.rows.filter((row) => row.state !== LANDED)
|
|
174
|
+
.map((row) => makeFinding(path, row.line, 'close-stories', `${row.id} has not landed with a date`));
|
|
175
|
+
const result = epic.result ? [] : [makeFinding(path, FIRST_LINE, 'close-result', 'Acceptance has no Result line with a bare date')];
|
|
176
|
+
const state = epic.state === LANDED ? [makeFinding(path, FIRST_LINE, 'close-state', 'header state is already landed')] : [];
|
|
177
|
+
const findings = [...checks, ...stories, ...result, ...state, ...checkQueueAbsence(queue, queuePath, epic.id)];
|
|
178
|
+
const judged = finishJudgement(findings, data);
|
|
179
|
+
return { ...judged, stateRange: judged.ok ? epic.stateRange : null };
|
|
180
|
+
};
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { tokenizeMarkdown } from '../references/scripts/markdown-blocks.mjs';
|
|
2
|
+
import { bulletBlocks, getLineCount, unique } from './plan-shape.mjs';
|
|
3
|
+
|
|
4
|
+
export const EPIC_SECTIONS = Object.freeze([
|
|
5
|
+
'## Intent', '## Value', '## Non-goals', '## Acceptance', '## Specs', '## Stories ledger', '## Queue',
|
|
6
|
+
]);
|
|
7
|
+
const [INTENT, , , ACCEPTANCE, , LEDGER, QUEUE] = EPIC_SECTIONS;
|
|
8
|
+
const HEADER_KEYS = Object.freeze(['type', 'lastUpdated', 'scope', 'staleAfter', 'owner', 'maxLines', 'state']);
|
|
9
|
+
const HEADER_STATES = Object.freeze(['open', 'landed']);
|
|
10
|
+
const STORY_STATES = Object.freeze(['planned', 'in-flight']);
|
|
11
|
+
const MAX_LINES = 60;
|
|
12
|
+
const MAX_ROW_BYTES = 200;
|
|
13
|
+
const FIELD_COUNT = 6;
|
|
14
|
+
const FIRST_LINE = 1;
|
|
15
|
+
const HEADER_TAIL_LINES = 2;
|
|
16
|
+
const MARKDOWN_REFUSAL = 1;
|
|
17
|
+
const FIELD_SEPARATOR = ' | ';
|
|
18
|
+
const NONE = 'none';
|
|
19
|
+
const EPIC_TYPE = 'epic';
|
|
20
|
+
const TITLE_PREFIX = '# Epic: ';
|
|
21
|
+
const RESULT_PREFIX = 'Result line:';
|
|
22
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
23
|
+
const DATE_TIME_SUFFIX = 'T00:00:00.000Z';
|
|
24
|
+
const STORY_ID = /^S[1-9]\d*$/;
|
|
25
|
+
const DEPENDENCIES = /^depends-on: (none|S[1-9]\d*(?:, S[1-9]\d*)*)$/;
|
|
26
|
+
const CLAIM_FIELD = /^(owns|shared): (.+)$/;
|
|
27
|
+
const CLAIM_FORBIDDEN = /[\s`()\*?\[\]{}|:#]/;
|
|
28
|
+
const STATE_FIELD = /^state: (planned|in-flight|landed (\d{4}-\d{2}-\d{2}))$/;
|
|
29
|
+
const HEADER_FIELD = /^([A-Za-z][A-Za-z0-9]*):([ \t]*)(.*?)[ \t]*\r?$/;
|
|
30
|
+
const QUEUE_LINE = /^Row (\S+) in (\S(?:.*\S)?)$/;
|
|
31
|
+
const RESULT_LINE = /^Result line: (\d{4}-\d{2}-\d{2})$/;
|
|
32
|
+
const PLAN_PATH = /docs\/plans\/([^\s`()[\]{}<>|,:;"']+\.md)(?=$|[\s`()[\]{}<>|,:;"'.!?])/g;
|
|
33
|
+
const QUEUE_NAME = 'queue.md';
|
|
34
|
+
const FENCE_LINE = /^\s*(?:`{3,}[^`]*|~{3,}.*)$/;
|
|
35
|
+
const BACKTICK_RUN = /`+/g;
|
|
36
|
+
const CITATION = /[^\s`()[\]{}<>|:;,"'!?]+[./][^\s`()[\]{}<>|:;,"'!?]+:\d+(?![\dA-Za-z_])/;
|
|
37
|
+
const TOP_BULLET = /^-\s+\S/;
|
|
38
|
+
const BULLET_PREFIX = /^-\s+/;
|
|
39
|
+
const INDENTED_LINE = /^\s+\S/;
|
|
40
|
+
const MARKDOWN_ERROR_LINE = /:(\d+):/;
|
|
41
|
+
const makeFinding = (line, code, message) => ({ line, code, message });
|
|
42
|
+
const getFileLine = (document, index) => document.frontLines + index + FIRST_LINE;
|
|
43
|
+
const getSection = (epic, heading) => epic.sections.find((section) => section.heading === heading);
|
|
44
|
+
const getNonBlank = (entries) => entries.filter((entry) => entry.raw.trim() !== '');
|
|
45
|
+
const getEntries = (document, start, end) => document.lines.slice(start, end).map((raw, offset) => ({
|
|
46
|
+
raw, index: start + offset, line: getFileLine(document, start + offset),
|
|
47
|
+
}));
|
|
48
|
+
export const isEpicDate = (value) => {
|
|
49
|
+
if (!DATE_PATTERN.test(value)) return false;
|
|
50
|
+
const date = new Date(`${value}${DATE_TIME_SUFFIX}`);
|
|
51
|
+
return Number.isFinite(date.getTime()) && date.toISOString() === `${value}${DATE_TIME_SUFFIX}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const findCitation = (line) => {
|
|
55
|
+
const match = CITATION.exec(line);
|
|
56
|
+
return match ? { text: match[0], index: match.index, end: match.index + match[0].length } : null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const hasBacktickSpan = (line) => {
|
|
60
|
+
const runs = [...line.matchAll(BACKTICK_RUN)];
|
|
61
|
+
return runs.some((opening, index) => runs.slice(index + FIRST_LINE).some((closing) => closing[0].length === opening[0].length));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const judgeAltitude = (line) => {
|
|
65
|
+
const form = FENCE_LINE.test(line) ? 'fence' : hasBacktickSpan(line) ? 'backtick' : findCitation(line) ? 'citation' : null;
|
|
66
|
+
return { below: form !== null, form };
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const isClaim = (token) => {
|
|
70
|
+
if (!token || token === NONE || token.startsWith('/') || CLAIM_FORBIDDEN.test(token)) return false;
|
|
71
|
+
const segments = token.replace(/\/$/, '').split('/');
|
|
72
|
+
return segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
73
|
+
};
|
|
74
|
+
const parseClaims = (field, name) => {
|
|
75
|
+
const match = CLAIM_FIELD.exec(field ?? '');
|
|
76
|
+
if (!match || match[1] !== name) return null;
|
|
77
|
+
if (match[2] === NONE) return [];
|
|
78
|
+
const claims = match[2].split(',').map((claim) => claim.trim());
|
|
79
|
+
return claims.every(isClaim) ? claims : null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const parseStoryRow = (blockLines) => {
|
|
83
|
+
const lines = blockLines.map((line) => line.replace(/\r$/, ''));
|
|
84
|
+
const raw = lines[0] ?? '';
|
|
85
|
+
const parts = raw.replace(BULLET_PREFIX, '').split(FIELD_SEPARATOR).map((part) => part.trim());
|
|
86
|
+
const [id, name, dependencyField, ownsField, sharedField, stateField] = parts;
|
|
87
|
+
const dependencies = DEPENDENCIES.exec(dependencyField ?? '');
|
|
88
|
+
const owns = parseClaims(ownsField, 'owns');
|
|
89
|
+
const shared = parseClaims(sharedField, 'shared');
|
|
90
|
+
const stateMatch = STATE_FIELD.exec(stateField ?? '');
|
|
91
|
+
const date = stateMatch?.[2] ?? null;
|
|
92
|
+
const state = date ? 'landed' : stateMatch?.[1] ?? null;
|
|
93
|
+
const bodyLines = lines.slice(FIRST_LINE);
|
|
94
|
+
const body = bodyLines.join('\n').trimEnd();
|
|
95
|
+
const outsideClaims = [id, name, dependencyField, stateField].join(FIELD_SEPARATOR);
|
|
96
|
+
const countedBytes = Buffer.byteLength(outsideClaims + (body ? `\n${body}` : ''), 'utf8');
|
|
97
|
+
const valid = TOP_BULLET.test(raw) && parts.length === FIELD_COUNT && STORY_ID.test(id ?? '') && Boolean(name)
|
|
98
|
+
&& Boolean(dependencies) && owns !== null && shared !== null
|
|
99
|
+
&& (STORY_STATES.includes(state) || (state === 'landed' && isEpicDate(date)))
|
|
100
|
+
&& bodyLines.every((line) => line.trim() === '' || INDENTED_LINE.test(line));
|
|
101
|
+
return {
|
|
102
|
+
raw, id: id ?? null, name: name ?? null, valid, countedBytes, body, bodyLines,
|
|
103
|
+
dependsOn: dependencies ? (dependencies[1] === NONE ? [] : unique(dependencies[1].split(', '))) : [],
|
|
104
|
+
owns: owns ?? [], shared: shared ?? [], state, date,
|
|
105
|
+
altitudeLine: parts.length === FIELD_COUNT ? outsideClaims : raw,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export const parseHeader = (frontmatter) => {
|
|
110
|
+
const lines = frontmatter.split('\n');
|
|
111
|
+
const entries = lines.slice(FIRST_LINE, -HEADER_TAIL_LINES).map((raw, index) => {
|
|
112
|
+
const match = HEADER_FIELD.exec(raw);
|
|
113
|
+
const prefix = lines.slice(0, index + FIRST_LINE).join('\n') + '\n';
|
|
114
|
+
const start = match ? Buffer.byteLength(prefix + `${match[1]}:${match[2]}`, 'utf8') : null;
|
|
115
|
+
return { key: match?.[1], value: match?.[3], line: index + FIRST_LINE + FIRST_LINE,
|
|
116
|
+
range: match ? { start, end: start + Buffer.byteLength(match[3], 'utf8') } : null };
|
|
117
|
+
});
|
|
118
|
+
const fields = Object.fromEntries(entries.filter((entry) => entry.key).map((entry) => [entry.key, entry.value]));
|
|
119
|
+
const findings = entries.flatMap((entry) => !entry.key || !HEADER_KEYS.includes(entry.key) || entry.value === ''
|
|
120
|
+
? [makeFinding(entry.line, 'frontmatter', 'frontmatter must contain only the seven non-empty epic fields')] : []);
|
|
121
|
+
for (const key of HEADER_KEYS) {
|
|
122
|
+
if (entries.filter((entry) => entry.key === key).length !== FIRST_LINE) {
|
|
123
|
+
findings.push(makeFinding(FIRST_LINE, 'frontmatter', `frontmatter must carry ${key} exactly once`));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (fields.type !== EPIC_TYPE) findings.push(makeFinding(FIRST_LINE, 'frontmatter', 'type must be epic'));
|
|
127
|
+
if (fields.maxLines !== String(MAX_LINES)) findings.push(makeFinding(FIRST_LINE, 'max-lines', `maxLines is frozen at ${MAX_LINES}`));
|
|
128
|
+
if (!HEADER_STATES.includes(fields.state)) findings.push(makeFinding(FIRST_LINE, 'header-state', 'header state must be open or landed'));
|
|
129
|
+
const states = entries.filter((entry) => entry.key === 'state');
|
|
130
|
+
return { fields, stateRange: states.length === FIRST_LINE ? states[0].range : null, findings };
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const parseSections = (document, titleHeading) => document.headings.filter((heading) => heading !== titleHeading).map((heading, index, headings) => {
|
|
134
|
+
const end = headings[index + FIRST_LINE]?.index ?? document.lines.length;
|
|
135
|
+
const lines = document.lines.slice(heading.index + FIRST_LINE, end);
|
|
136
|
+
const raw = document.lines.slice(heading.index, end).join('\n');
|
|
137
|
+
return { heading: heading.text, index: heading.index, line: getFileLine(document, heading.index), end, lines,
|
|
138
|
+
text: raw + (end < document.lines.length ? '\n' : ''), entries: getEntries(document, heading.index + FIRST_LINE, end) };
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export const parseEpic = (text, path) => {
|
|
142
|
+
const source = String(text ?? '');
|
|
143
|
+
const epicPath = String(path ?? '');
|
|
144
|
+
const id = epicPath.split('/').at(-1).replace(/\.md$/, '');
|
|
145
|
+
const base = { text: source, path: epicPath, id, title: null, fields: {}, state: null, stateRange: null,
|
|
146
|
+
sections: [], rows: [], queue: null, result: null, resultLines: [], ledgerEntries: [], document: null, findings: [] };
|
|
147
|
+
try {
|
|
148
|
+
const document = tokenizeMarkdown(source, epicPath);
|
|
149
|
+
const header = parseHeader(document.frontmatter);
|
|
150
|
+
const titleHeading = document.headings.find((heading) => heading.level === FIRST_LINE && heading.text.startsWith(TITLE_PREFIX));
|
|
151
|
+
const sections = parseSections(document, titleHeading);
|
|
152
|
+
const ledger = sections.find((section) => section.heading === LEDGER);
|
|
153
|
+
const blocks = ledger ? bulletBlocks(document.lines, document.fencedLines, ledger.index + FIRST_LINE, ledger.end) : [];
|
|
154
|
+
const rows = blocks.map((block) => ({ ...parseStoryRow(block.lines), index: block.start,
|
|
155
|
+
line: getFileLine(document, block.start), span: block.span }));
|
|
156
|
+
const queueEntries = getNonBlank(sections.find((section) => section.heading === QUEUE)?.entries ?? []);
|
|
157
|
+
const queueMatch = queueEntries.length === FIRST_LINE ? QUEUE_LINE.exec(queueEntries[0].raw.trim()) : null;
|
|
158
|
+
const resultLines = (sections.find((section) => section.heading === ACCEPTANCE)?.entries ?? [])
|
|
159
|
+
.filter((entry) => entry.raw.trim().startsWith(RESULT_PREFIX));
|
|
160
|
+
const resultMatch = resultLines.length === FIRST_LINE ? RESULT_LINE.exec(resultLines[0].raw.trim()) : null;
|
|
161
|
+
return {
|
|
162
|
+
...base, ...header, document, titleHeading, title: titleHeading?.text.slice(TITLE_PREFIX.length).trim() ?? null,
|
|
163
|
+
state: header.fields.state ?? null, sections, rows, ledgerEntries: ledger?.entries ?? [], resultLines,
|
|
164
|
+
queue: queueMatch ? { id: queueMatch[1], bucket: queueMatch[2], line: queueEntries[0].line } : null,
|
|
165
|
+
result: resultMatch && isEpicDate(resultMatch[1]) ? { date: resultMatch[1], line: resultLines[0].line } : null,
|
|
166
|
+
};
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.exitCode !== MARKDOWN_REFUSAL) throw error;
|
|
169
|
+
return { ...base, findings: [makeFinding(Number(MARKDOWN_ERROR_LINE.exec(error.message)?.[1] ?? FIRST_LINE), 'markdown', error.message)] };
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const checkSections = (epic) => {
|
|
174
|
+
const { document, titleHeading, sections } = epic;
|
|
175
|
+
const findings = [];
|
|
176
|
+
if (!epic.title || document.headings[0] !== titleHeading) {
|
|
177
|
+
findings.push(makeFinding(titleHeading ? getFileLine(document, titleHeading.index) : FIRST_LINE, 'title', `the first heading must be ${TITLE_PREFIX}<title>`));
|
|
178
|
+
}
|
|
179
|
+
if (sections.length !== EPIC_SECTIONS.length || sections.some((section, index) => section.heading !== EPIC_SECTIONS[index])) {
|
|
180
|
+
findings.push(makeFinding(sections[0]?.line ?? FIRST_LINE, 'headings', 'the seven epic sections must appear exactly once in their literal order, with no other headings'));
|
|
181
|
+
}
|
|
182
|
+
const intent = getSection(epic, INTENT);
|
|
183
|
+
const outside = getEntries(document, 0, intent?.index ?? document.lines.length)
|
|
184
|
+
.filter((entry) => entry.index !== titleHeading?.index && entry.raw.trim() !== '');
|
|
185
|
+
for (const entry of outside) findings.push(makeFinding(entry.line, 'outside-section', 'every non-blank body line after the title must lie inside a section'));
|
|
186
|
+
return findings;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const checkClosedLines = (epic) => {
|
|
190
|
+
const findings = [];
|
|
191
|
+
if (!epic.queue || epic.queue.id !== epic.id) {
|
|
192
|
+
findings.push(makeFinding(getSection(epic, QUEUE)?.line ?? FIRST_LINE, 'queue', `Queue must hold exactly one line: Row ${epic.id} in <bucket>`));
|
|
193
|
+
}
|
|
194
|
+
if (epic.resultLines.length > FIRST_LINE || (epic.resultLines.length === FIRST_LINE && !epic.result)) {
|
|
195
|
+
findings.push(makeFinding(epic.resultLines[0].line, 'result-line', 'Acceptance admits at most one Result line: YYYY-MM-DD with a date and nothing else'));
|
|
196
|
+
}
|
|
197
|
+
return findings;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const checkRows = (epic) => {
|
|
201
|
+
const findings = [];
|
|
202
|
+
if (epic.rows.length === 0) findings.push(makeFinding(getSection(epic, LEDGER)?.line ?? FIRST_LINE, 'empty-ledger', 'the ledger must carry at least S1'));
|
|
203
|
+
const covered = new Set(epic.rows.flatMap((row) => Array.from({ length: row.span }, (_, offset) => row.index + offset)));
|
|
204
|
+
for (const entry of getNonBlank(epic.ledgerEntries)) {
|
|
205
|
+
if (!covered.has(entry.index)) findings.push(makeFinding(entry.line, 'ledger-line', 'ledger content must belong to a top-level story bullet'));
|
|
206
|
+
}
|
|
207
|
+
for (const [index, row] of epic.rows.entries()) {
|
|
208
|
+
if (!row.valid) findings.push(makeFinding(row.line, 'row-grammar', 'a story must carry the six ordered fields, canonical claims and a valid state'));
|
|
209
|
+
if (row.id !== `S${index + FIRST_LINE}`) findings.push(makeFinding(row.line, 'story-ids', 'story ids must run contiguously from S1'));
|
|
210
|
+
if (row.countedBytes > MAX_ROW_BYTES) findings.push(makeFinding(row.line, 'row-bytes', `${row.id} has ${row.countedBytes} bytes outside owns and shared; the cap is ${MAX_ROW_BYTES}`));
|
|
211
|
+
}
|
|
212
|
+
return findings;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const checkAltitude = (epic) => {
|
|
216
|
+
const rowLines = new Map(epic.rows.map((row) => [row.index, row.altitudeLine]));
|
|
217
|
+
return epic.document.lines.flatMap((line, index) => {
|
|
218
|
+
const judgement = judgeAltitude(rowLines.get(index) ?? line);
|
|
219
|
+
return judgement.below ? [makeFinding(getFileLine(epic.document, index), 'altitude', `${judgement.form} is below concept altitude`)] : [];
|
|
220
|
+
});
|
|
221
|
+
};
|
|
222
|
+
const checkPlanPaths = (text) => text.split('\n').flatMap((line, index) => [...line.matchAll(PLAN_PATH)]
|
|
223
|
+
.filter((match) => match[1] !== QUEUE_NAME)
|
|
224
|
+
.map((match) => makeFinding(index + FIRST_LINE, 'plan-path', `${match[0]} is ephemeral; only docs/plans/queue.md may be stored`)));
|
|
225
|
+
|
|
226
|
+
export const checkEpic = (text, path) => {
|
|
227
|
+
const epic = parseEpic(text, path);
|
|
228
|
+
const capFindings = getLineCount(epic.text) > MAX_LINES
|
|
229
|
+
? [makeFinding(FIRST_LINE, 'line-cap', `the epic has ${getLineCount(epic.text)} lines; the cap is ${MAX_LINES}`)] : [];
|
|
230
|
+
const shapeFindings = epic.document ? [...checkSections(epic), ...checkClosedLines(epic), ...checkRows(epic), ...checkAltitude(epic)] : [];
|
|
231
|
+
return { findings: [...epic.findings, ...capFindings, ...checkPlanPaths(epic.text), ...shapeFindings], epic };
|
|
232
|
+
};
|
|
@@ -49,6 +49,7 @@ export const CLASSES = ['live', 'terminal', 'parked', 'record', 'ambiguous'];
|
|
|
49
49
|
// The classes that still carry work, and therefore still cost a reader attention: both caps judge
|
|
50
50
|
// exactly these.
|
|
51
51
|
export const CARRY_WORK = new Set(['live', 'parked', 'ambiguous']);
|
|
52
|
+
export const authorisesRemoval = (klass) => klass === 'terminal' || klass === 'record';
|
|
52
53
|
|
|
53
54
|
export const DEFAULTS = { maxRows: 60, maxRowLines: 12 };
|
|
54
55
|
|
package/tools/queue-audit.mjs
CHANGED
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
|
|
37
37
|
import { tokenizeMarkdown, fail } from '../references/scripts/markdown-blocks.mjs';
|
|
38
38
|
import { bulletBlocks } from './fold-scope.mjs';
|
|
39
|
-
import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, classifyRow, titleOf } from './queue-audit-rows.mjs';
|
|
39
|
+
import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, authorisesRemoval, classifyRow, titleOf } from './queue-audit-rows.mjs';
|
|
40
40
|
import { nameFindings } from './queue-row-name.mjs';
|
|
41
41
|
|
|
42
42
|
// The row grammar is re-exported so one import names the whole reader: the CLI, the tests and any
|
|
43
43
|
// consumer ask this module, and the split into a rules half stays an implementation detail.
|
|
44
|
-
export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, classifyRow, leadMarkers, titleOf } from './queue-audit-rows.mjs';
|
|
44
|
+
export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, authorisesRemoval, classifyRow, leadMarkers, titleOf } from './queue-audit-rows.mjs';
|
|
45
45
|
export { nameOf, nameFindings } from './queue-row-name.mjs';
|
|
46
46
|
|
|
47
47
|
// The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
|
|
@@ -83,7 +83,8 @@ const sectionWindow = (headings, lines, section, frontLines = 0) => {
|
|
|
83
83
|
// and beside them the fields a RENDERER reads: the row's plain `name`, the `id` the one parse cut
|
|
84
84
|
// at, the `nameFindings` that name is judged by, and the line-numbered heading `buckets` it sits
|
|
85
85
|
// under. They are computed here, once, so no consumer becomes a second grammar over this document.
|
|
86
|
-
export const auditQueue = (text, { section = null, label = 'the queue' } = {}) => {
|
|
86
|
+
export const auditQueue = (text, { section = null, range = null, label = 'the queue' } = {}) => {
|
|
87
|
+
if (section !== null && range !== null) throw fail(2, 'section and range are exclusive — name only one.');
|
|
87
88
|
const { lines, headings, fencedLines, frontLines } = tokenizeMarkdown(String(text ?? ''), label);
|
|
88
89
|
// ONE heading set answers BOTH questions this module asks of a heading — where a `--section` ends,
|
|
89
90
|
// and which bucket a row sits in — so the rows of the whole document are scanned FIRST and a
|
|
@@ -107,6 +108,7 @@ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) =
|
|
|
107
108
|
throw fail(2, `line ${frontLines + index + 1} is a heading this document and its row scan read differently ("${lines[index].trim().slice(0, 40)}") — the row above swallows it as body while the document opens a section there, and judging around it would report a domain that was never looked at. Indent it past three spaces to make it body, or to column 0 to make it a heading.`);
|
|
108
109
|
});
|
|
109
110
|
const { from, to } = sectionWindow(documentHeadings, lines, section, frontLines);
|
|
111
|
+
const ranged = range === null ? null : sectionWindow(documentHeadings, lines, range, frontLines);
|
|
110
112
|
// A fence CONTINUES a queue row rather than ending it, and the cap judges the row's PHYSICAL span.
|
|
111
113
|
// Both halves are the same defect: a row carrying a code block reported one line and hid whatever
|
|
112
114
|
// followed the fence — its length from the cap, and a closure from the classifier.
|
|
@@ -143,7 +145,14 @@ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) =
|
|
|
143
145
|
};
|
|
144
146
|
});
|
|
145
147
|
const counts = Object.fromEntries(CLASSES.map((klass) => [klass, rows.filter((row) => row.klass === klass).length]));
|
|
146
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
rows, counts, total: rows.length,
|
|
150
|
+
...(ranged === null ? {} : { range: {
|
|
151
|
+
headingLine: frontLines + ranged.from,
|
|
152
|
+
from: frontLines + ranged.from + 1,
|
|
153
|
+
to: frontLines + ranged.to + 1 - Number(ranged.to === lines.length && lines.at(-1) === ''),
|
|
154
|
+
} }),
|
|
155
|
+
};
|
|
147
156
|
};
|
|
148
157
|
|
|
149
158
|
// checkQueue(text, options) -> { ok, problems, notes }. A problem is a REFUSAL and every one of them
|
|
@@ -159,7 +168,7 @@ export const checkQueue = (text, options = {}) => {
|
|
|
159
168
|
const notes = [];
|
|
160
169
|
|
|
161
170
|
for (const row of rows) {
|
|
162
|
-
if (row.klass
|
|
171
|
+
if (authorisesRemoval(row.klass)) {
|
|
163
172
|
problems.push(
|
|
164
173
|
`${label}:${row.line}: a ${row.klass} row is still listed (${row.evidence}) — its story belongs to the ` +
|
|
165
174
|
`ADR or the changelog, and the row leaves the queue in the same commit: ${row.title.slice(0, 80)}`,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, fsyncSync, readFileSync, writeFileSync, writeSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join } from 'node:path';
|
|
4
|
+
import { canonicalHeading } from './queue-audit.mjs';
|
|
5
|
+
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
6
|
+
|
|
7
|
+
export const EXIT = Object.freeze({ accept: 0, refuse: 1, usage: 2 });
|
|
8
|
+
const MAGIC = 'queue-purge: 1';
|
|
9
|
+
const HEADER_FIELDS = ['date', 'section', 'queue-path', 'section-bytes', 'index-bytes', 'section-sha256', 'block-sha256', 'rows'];
|
|
10
|
+
const BLOCK_DIGEST_FIELD = 'block-sha256';
|
|
11
|
+
const TITLE_WIDTH = 80;
|
|
12
|
+
const KEYLESS = '<keyless>';
|
|
13
|
+
const LF = 0x0a;
|
|
14
|
+
const HASH = 'sha256';
|
|
15
|
+
const HEX_DIGEST = /^[a-f0-9]{64}$/;
|
|
16
|
+
const COUNT = /^(0|[1-9][0-9]*)$/;
|
|
17
|
+
const INDEX_COLUMNS = 4;
|
|
18
|
+
const PRIVATE_MODE = 0o600;
|
|
19
|
+
|
|
20
|
+
export const hashBytes = (bytes) => createHash(HASH).update(bytes).digest();
|
|
21
|
+
export const renderTitle = (title) => String(title).replace(/\s+/g, ' ').trim().slice(0, TITLE_WIDTH);
|
|
22
|
+
|
|
23
|
+
export const sliceSection = (bytes, range) => {
|
|
24
|
+
const starts = [0];
|
|
25
|
+
bytes.forEach((byte, offset) => { if (byte === LF) starts.push(offset + 1); });
|
|
26
|
+
const start = starts[range.headingLine - 1];
|
|
27
|
+
const end = starts[range.to - 1] ?? bytes.length;
|
|
28
|
+
if (start === undefined || end < start) throw fail(EXIT.usage, 'invalid section byte range');
|
|
29
|
+
return bytes.subarray(start, end);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const renderHeader = (fields, includeDigest = true) => Buffer.from([
|
|
33
|
+
MAGIC, ...HEADER_FIELDS.filter((field) => includeDigest || field !== BLOCK_DIGEST_FIELD)
|
|
34
|
+
.map((field) => `${field}: ${fields[field]}`), '', '',
|
|
35
|
+
].join('\n'));
|
|
36
|
+
|
|
37
|
+
const sealBlock = (headerBytes, body) => createHash(HASH).update(headerBytes).update(body).digest();
|
|
38
|
+
|
|
39
|
+
export const frameBlock = ({ date, section, queuePath, sectionBytes, indexEntries }) => {
|
|
40
|
+
const indexBytes = Buffer.from(indexEntries.map(({ line, klass, id, title }) =>
|
|
41
|
+
`${line}\t${klass}\t${id ?? KEYLESS}\t${renderTitle(title)}\n`).join(''));
|
|
42
|
+
const body = Buffer.concat([sectionBytes, indexBytes]);
|
|
43
|
+
const fields = {
|
|
44
|
+
date, section: JSON.stringify(canonicalHeading(section)), 'queue-path': JSON.stringify(queuePath),
|
|
45
|
+
'section-bytes': sectionBytes.length, 'index-bytes': indexBytes.length,
|
|
46
|
+
'section-sha256': hashBytes(sectionBytes).toString('hex'), rows: indexEntries.length,
|
|
47
|
+
};
|
|
48
|
+
fields[BLOCK_DIGEST_FIELD] = sealBlock(renderHeader(fields, false), body).toString('hex');
|
|
49
|
+
return Buffer.concat([renderHeader(fields), body]);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const readHeader = (bytes, offset) => {
|
|
53
|
+
const state = { at: offset, line: 1 };
|
|
54
|
+
const readLine = () => {
|
|
55
|
+
const end = bytes.indexOf(LF, state.at);
|
|
56
|
+
if (end < 0) throw { line: state.line, truncated: true };
|
|
57
|
+
const line = bytes.subarray(state.at, end).toString('utf8');
|
|
58
|
+
state.at = end + 1;
|
|
59
|
+
state.line += 1;
|
|
60
|
+
return line;
|
|
61
|
+
};
|
|
62
|
+
const opening = readLine();
|
|
63
|
+
if (opening !== MAGIC) throw { line: 1 };
|
|
64
|
+
const fields = {};
|
|
65
|
+
const sealedParts = [bytes.subarray(offset, state.at)];
|
|
66
|
+
for (const field of HEADER_FIELDS) {
|
|
67
|
+
const start = state.at;
|
|
68
|
+
const line = readLine();
|
|
69
|
+
const prefix = `${field}: `;
|
|
70
|
+
if (!line.startsWith(prefix)) throw { line: state.line - 1 };
|
|
71
|
+
fields[field] = line.slice(prefix.length);
|
|
72
|
+
if (field !== BLOCK_DIGEST_FIELD) sealedParts.push(bytes.subarray(start, state.at));
|
|
73
|
+
}
|
|
74
|
+
const separator = state.at;
|
|
75
|
+
if (readLine() !== '') throw { line: state.line - 1 };
|
|
76
|
+
sealedParts.push(bytes.subarray(separator, state.at));
|
|
77
|
+
for (const field of ['section-bytes', 'index-bytes', 'rows']) {
|
|
78
|
+
if (!COUNT.test(fields[field]) || !Number.isSafeInteger(Number(fields[field]))) {
|
|
79
|
+
throw { line: HEADER_FIELDS.indexOf(field) + 2 };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const field of ['section-sha256', BLOCK_DIGEST_FIELD]) {
|
|
83
|
+
if (!HEX_DIGEST.test(fields[field])) throw { line: HEADER_FIELDS.indexOf(field) + 2 };
|
|
84
|
+
}
|
|
85
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) throw { line: 2 };
|
|
86
|
+
const readString = (field) => {
|
|
87
|
+
try {
|
|
88
|
+
const value = JSON.parse(fields[field]);
|
|
89
|
+
if (typeof value === 'string') return value;
|
|
90
|
+
} catch { /* The field's line is reported below. */ }
|
|
91
|
+
throw { line: HEADER_FIELDS.indexOf(field) + 2 };
|
|
92
|
+
};
|
|
93
|
+
return { fields, bodyStart: state.at, sealedHeader: Buffer.concat(sealedParts),
|
|
94
|
+
section: readString('section'), queuePath: readString('queue-path') };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const readIndex = (bytes) => {
|
|
98
|
+
if (bytes.length === 0) return [];
|
|
99
|
+
if (bytes.at(-1) !== LF) return null;
|
|
100
|
+
const lines = bytes.toString('utf8').slice(0, -1).split('\n');
|
|
101
|
+
const entries = lines.map((text) => {
|
|
102
|
+
const cells = text.split('\t');
|
|
103
|
+
if (cells.length !== INDEX_COLUMNS) return null;
|
|
104
|
+
const [line, klass, key, title] = cells;
|
|
105
|
+
if (!COUNT.test(line) || Number(line) < 1 || !Number.isSafeInteger(Number(line)) || !klass || !key || /\r/.test(text)) return null;
|
|
106
|
+
return { line: Number(line), klass, id: key === KEYLESS ? null : key, title };
|
|
107
|
+
});
|
|
108
|
+
return entries.includes(null) ? null : entries;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const judgeBlock = (block) => Boolean(block.indexEntries
|
|
112
|
+
&& block.rowCount === block.indexEntries.length
|
|
113
|
+
&& hashBytes(block.sectionBytes).equals(block.sectionDigest)
|
|
114
|
+
&& sealBlock(block.sealedHeader, block.body).equals(block.blockDigest));
|
|
115
|
+
|
|
116
|
+
export const readArchive = (bytes) => {
|
|
117
|
+
const archive = { bytes, blocks: [], intact: true, offset: 0, cause: null };
|
|
118
|
+
const refuse = (cause, line) => Object.assign(archive, {
|
|
119
|
+
intact: false, cause, line,
|
|
120
|
+
problem: cause === 'foreign' ? `foreign archive grammar at line ${line}`
|
|
121
|
+
: `archive not intact: block ${archive.blocks.length + 1} at byte offset ${archive.offset}; no key comparison performed`,
|
|
122
|
+
});
|
|
123
|
+
while (archive.offset < bytes.length) {
|
|
124
|
+
const offset = archive.offset;
|
|
125
|
+
const parsed = (() => {
|
|
126
|
+
try { return readHeader(bytes, offset); }
|
|
127
|
+
catch (error) {
|
|
128
|
+
const recognised = bytes.subarray(offset, offset + Buffer.byteLength(MAGIC)).equals(Buffer.from(MAGIC));
|
|
129
|
+
refuse(offset === 0 && !(recognised && error.truncated) ? 'foreign' : 'not-intact', error.line);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
if (!parsed) return archive;
|
|
134
|
+
const { fields, bodyStart, sealedHeader, section, queuePath } = parsed;
|
|
135
|
+
const sectionEnd = bodyStart + Number(fields['section-bytes']);
|
|
136
|
+
const end = sectionEnd + Number(fields['index-bytes']);
|
|
137
|
+
if (!Number.isSafeInteger(end) || end > bytes.length) return refuse('not-intact');
|
|
138
|
+
const block = {
|
|
139
|
+
date: fields.date, section, queuePath, offset, end, sealedHeader,
|
|
140
|
+
sectionBytes: bytes.subarray(bodyStart, sectionEnd), indexBytes: bytes.subarray(sectionEnd, end),
|
|
141
|
+
sectionDigest: Buffer.from(fields['section-sha256'], 'hex'),
|
|
142
|
+
blockDigest: Buffer.from(fields[BLOCK_DIGEST_FIELD], 'hex'),
|
|
143
|
+
rowCount: Number(fields.rows), body: bytes.subarray(bodyStart, end),
|
|
144
|
+
indexEntries: readIndex(bytes.subarray(sectionEnd, end)),
|
|
145
|
+
};
|
|
146
|
+
if (!judgeBlock(block)) return refuse('not-intact');
|
|
147
|
+
archive.blocks.push(block);
|
|
148
|
+
archive.offset = end;
|
|
149
|
+
}
|
|
150
|
+
return archive;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const judgeIntact = (archive) => archive.intact && archive.offset === archive.bytes.length
|
|
154
|
+
&& archive.blocks.every(judgeBlock);
|
|
155
|
+
|
|
156
|
+
export const appendBlock = (path, blockBytes) => {
|
|
157
|
+
const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
158
|
+
const staged = openSync(temporary, 'wx', PRIVATE_MODE);
|
|
159
|
+
try {
|
|
160
|
+
writeFileSync(staged, blockBytes);
|
|
161
|
+
fsyncSync(staged);
|
|
162
|
+
const complete = readFileSync(temporary);
|
|
163
|
+
if (!complete.equals(blockBytes)) throw fail(EXIT.refuse, `incomplete staged block for ${path}`);
|
|
164
|
+
const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND
|
|
165
|
+
| constants.O_NOFOLLOW | constants.O_NONBLOCK, PRIVATE_MODE);
|
|
166
|
+
try {
|
|
167
|
+
if (!fstatSync(descriptor).isFile()) throw fail(EXIT.usage, `${path}: archive is not a regular file`);
|
|
168
|
+
const written = writeSync(descriptor, complete);
|
|
169
|
+
fsyncSync(descriptor);
|
|
170
|
+
if (written !== complete.length) throw fail(EXIT.refuse, `${path}: partial archive append (${written} of ${complete.length} bytes)`);
|
|
171
|
+
} finally { closeSync(descriptor); }
|
|
172
|
+
} finally {
|
|
173
|
+
try { closeSync(staged); }
|
|
174
|
+
finally { unlinkSync(temporary); }
|
|
175
|
+
}
|
|
176
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { constants, openSync, closeSync, fstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
5
|
+
import { isDirectRun } from './direct-run.mjs';
|
|
6
|
+
import { EXIT, appendBlock, readArchive } from './queue-purge-archive.mjs';
|
|
7
|
+
import { snapshotQueue, checkPurge } from './queue-purge.mjs';
|
|
8
|
+
|
|
9
|
+
const VERBS = ['snapshot', '--check'];
|
|
10
|
+
const OPTIONS = new Map([['--section', 'section'], ['--archive', 'archive']]);
|
|
11
|
+
const HELP_FLAGS = ['--help', '-h'];
|
|
12
|
+
const HELP = `Usage:
|
|
13
|
+
node queue-purge-cli.mjs snapshot <queue-file> --section <heading> --archive <file>
|
|
14
|
+
node queue-purge-cli.mjs --check <queue-file> --section <heading> --archive <file>
|
|
15
|
+
|
|
16
|
+
snapshot appends the section bytes and row index; --check verifies the purge without writing.
|
|
17
|
+
Exit codes: 0 accept, 1 refuse, 2 usage. --help or -h must be the whole invocation.`;
|
|
18
|
+
|
|
19
|
+
const parseArgv = (argv) => {
|
|
20
|
+
if (argv.length === 1 && HELP_FLAGS.includes(argv[0])) return { help: true };
|
|
21
|
+
const options = {};
|
|
22
|
+
const state = { index: 0 };
|
|
23
|
+
const readValue = (flag) => {
|
|
24
|
+
const value = argv[state.index + 1];
|
|
25
|
+
if (!value || value.startsWith('-') || VERBS.includes(value)) throw fail(EXIT.usage, `${flag} takes a value`);
|
|
26
|
+
state.index += 1;
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
while (state.index < argv.length) {
|
|
30
|
+
const arg = argv[state.index];
|
|
31
|
+
if (VERBS.includes(arg)) {
|
|
32
|
+
if (options.verb) throw fail(EXIT.usage, 'name exactly one verb: snapshot or --check');
|
|
33
|
+
options.verb = arg;
|
|
34
|
+
options.queuePath = readValue(arg);
|
|
35
|
+
} else if (OPTIONS.has(arg)) {
|
|
36
|
+
const field = OPTIONS.get(arg);
|
|
37
|
+
if (Object.hasOwn(options, field)) throw fail(EXIT.usage, `${arg} was given twice`);
|
|
38
|
+
options[field] = readValue(arg);
|
|
39
|
+
} else if (HELP_FLAGS.includes(arg)) throw fail(EXIT.usage, '--help is answered only as the whole invocation');
|
|
40
|
+
else throw fail(EXIT.usage, `unknown argument ${JSON.stringify(arg)}`);
|
|
41
|
+
state.index += 1;
|
|
42
|
+
}
|
|
43
|
+
if (!options.verb) throw fail(EXIT.usage, 'exactly one verb is required: snapshot or --check');
|
|
44
|
+
for (const [flag, field] of OPTIONS) if (!options[field]) throw fail(EXIT.usage, `${flag} is required`);
|
|
45
|
+
return options;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const readRegular = (path, read) => {
|
|
49
|
+
const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
50
|
+
try {
|
|
51
|
+
const stats = fstatSync(descriptor);
|
|
52
|
+
if (!stats.isFile()) throw fail(EXIT.usage, `${path}: not a regular file`);
|
|
53
|
+
return read(descriptor, stats);
|
|
54
|
+
} finally { closeSync(descriptor); }
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const readFiles = ({ queuePath, archive, verb }) => {
|
|
58
|
+
const queueReal = realpathSync(queuePath);
|
|
59
|
+
const queue = readRegular(queueReal, (descriptor, stats) => ({ bytes: readFileSync(descriptor), stats }));
|
|
60
|
+
if (!statSync(dirname(archive)).isDirectory()) throw fail(EXIT.usage, `${archive}: parent is not a directory`);
|
|
61
|
+
const archiveReal = (() => {
|
|
62
|
+
try { return realpathSync(archive); }
|
|
63
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
64
|
+
})();
|
|
65
|
+
if (resolve(queuePath) === resolve(archive) || queueReal === archiveReal) {
|
|
66
|
+
throw fail(EXIT.usage, `${archive}: archive resolves to the queue ${queuePath}`);
|
|
67
|
+
}
|
|
68
|
+
const archiveBytes = (() => {
|
|
69
|
+
try {
|
|
70
|
+
return readRegular(archive, (descriptor, stats) => {
|
|
71
|
+
if (stats.dev === queue.stats.dev && stats.ino === queue.stats.ino) {
|
|
72
|
+
throw fail(EXIT.usage, `${archive}: archive is a hard link to the queue ${queuePath}`);
|
|
73
|
+
}
|
|
74
|
+
return readFileSync(descriptor);
|
|
75
|
+
});
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error.code === 'ENOENT' && verb === 'snapshot') return Buffer.alloc(0);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
if (!archiveBytes.length && verb === '--check') throw fail(EXIT.usage, `${archive}: empty archive`);
|
|
82
|
+
return { queueBytes: queue.bytes, archiveBytes };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const main = (argv, { log = console.log, error = console.error } = {}) => {
|
|
86
|
+
const state = { fallback: EXIT.usage, options: null };
|
|
87
|
+
try {
|
|
88
|
+
const options = parseArgv(argv);
|
|
89
|
+
state.options = options;
|
|
90
|
+
if (options.help) { log(HELP); return EXIT.accept; }
|
|
91
|
+
const inputs = readFiles(options);
|
|
92
|
+
const args = { ...options, ...inputs, date: new Date().toISOString().split('T')[0] };
|
|
93
|
+
const result = options.verb === 'snapshot' ? snapshotQueue(args) : checkPurge(args);
|
|
94
|
+
for (const problem of result.problems) error(problem);
|
|
95
|
+
if (!result.ok) return EXIT.refuse;
|
|
96
|
+
if (options.verb === 'snapshot') {
|
|
97
|
+
state.fallback = EXIT.refuse;
|
|
98
|
+
appendBlock(options.archive, result.block);
|
|
99
|
+
const [block] = readArchive(result.block).blocks;
|
|
100
|
+
for (const note of result.notes) error(note);
|
|
101
|
+
log(`${options.archive}: ${block.rowCount} rows, ${block.sectionBytes.length} section bytes (${block.section})`);
|
|
102
|
+
} else log(`accept: ${options.section}; archive ${options.archive}`);
|
|
103
|
+
return EXIT.accept;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const paths = state.options ? `${state.options.queuePath}; archive ${state.options.archive}: ` : '';
|
|
106
|
+
error(`${paths}${err.message}`);
|
|
107
|
+
return [EXIT.refuse, EXIT.usage].includes(err.exitCode) ? err.exitCode : state.fallback;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { auditQueue, authorisesRemoval, canonicalHeading } from './queue-audit.mjs';
|
|
2
|
+
import { EXIT, frameBlock, hashBytes, judgeIntact, readArchive, renderTitle, sliceSection } from './queue-purge-archive.mjs';
|
|
3
|
+
import { fail } from '../references/scripts/markdown-blocks.mjs';
|
|
4
|
+
|
|
5
|
+
const readInputs = ({ queueBytes, section, archiveBytes = Buffer.alloc(0) }) => {
|
|
6
|
+
if (!canonicalHeading(section)) throw fail(EXIT.usage, '--section is required');
|
|
7
|
+
const audit = auditQueue(queueBytes.toString('utf8'), { range: section });
|
|
8
|
+
const rows = audit.rows.filter(({ line }) => line >= audit.range.from && line < audit.range.to);
|
|
9
|
+
const problems = [];
|
|
10
|
+
const keys = new Map();
|
|
11
|
+
for (const row of audit.rows) {
|
|
12
|
+
if (!row.id) continue;
|
|
13
|
+
const earlier = keys.get(row.id);
|
|
14
|
+
if (earlier) problems.push(`key defect: doubled id ${row.id} at file lines ${earlier.line} and ${row.line}`);
|
|
15
|
+
else keys.set(row.id, row);
|
|
16
|
+
}
|
|
17
|
+
const archive = readArchive(archiveBytes);
|
|
18
|
+
if (archive.cause === 'foreign') throw fail(EXIT.usage, archive.problem);
|
|
19
|
+
return { rows, problems, archive, section: canonicalHeading(section), sectionBytes: sliceSection(queueBytes, audit.range) };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const selectBlock = (archive, section) => archive.blocks.findLast((block) => canonicalHeading(block.section) === section);
|
|
23
|
+
const makeResult = (problems, notes = [], block) => ({ ok: problems.length === 0, problems, notes,
|
|
24
|
+
...(block === undefined ? {} : { block }) });
|
|
25
|
+
|
|
26
|
+
export const snapshotQueue = (options) => {
|
|
27
|
+
const { rows, problems, archive, section, sectionBytes } = readInputs(options);
|
|
28
|
+
const keyless = rows.filter(({ id }) => !id);
|
|
29
|
+
const notes = keyless.length ? [`${keyless.length} keyless rows archived in ${section}`] : [];
|
|
30
|
+
if (!judgeIntact(archive)) problems.push(archive.problem);
|
|
31
|
+
if (problems.length) return makeResult(problems, [], null);
|
|
32
|
+
const previous = selectBlock(archive, section);
|
|
33
|
+
if (previous?.sectionDigest.equals(hashBytes(sectionBytes))) {
|
|
34
|
+
return makeResult([`same section digest as the last block for ${section}`], [], null);
|
|
35
|
+
}
|
|
36
|
+
const block = frameBlock({ date: options.date, section, queuePath: options.queuePath, sectionBytes, indexEntries: rows });
|
|
37
|
+
return makeResult(problems, notes, block);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const checkPurge = (options) => {
|
|
41
|
+
const { rows, problems, archive, section, sectionBytes } = readInputs(options);
|
|
42
|
+
for (const row of rows.filter(({ id }) => !id)) {
|
|
43
|
+
problems.push(`key defect: no id at current file line ${row.line}: ${renderTitle(row.title)}`);
|
|
44
|
+
}
|
|
45
|
+
if (!judgeIntact(archive)) return makeResult([...problems, archive.problem]);
|
|
46
|
+
const block = selectBlock(archive, section);
|
|
47
|
+
if (!block) {
|
|
48
|
+
const recorded = [...new Set(archive.blocks.map(({ section: heading }) => heading))];
|
|
49
|
+
throw fail(EXIT.usage, `no archive block for ${section}; recorded headings: ${recorded.join(', ') || '(none)'}`);
|
|
50
|
+
}
|
|
51
|
+
if (block.sectionDigest.equals(hashBytes(sectionBytes))) problems.push(`section not moved: ${section}`);
|
|
52
|
+
const recordedKeys = new Set(block.indexEntries.filter(({ id }) => id).map(({ id }) => id));
|
|
53
|
+
const currentKeys = new Set(rows.filter(({ id }) => id).map(({ id }) => id));
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
if (row.id && !recordedKeys.has(row.id)) {
|
|
56
|
+
problems.push(`addition: ${row.id} at current file line ${row.line}: ${renderTitle(row.title)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const entry of block.indexEntries) {
|
|
60
|
+
if (!entry.id) problems.push(`keyless entry at archived line ${entry.line}: ${entry.title}`);
|
|
61
|
+
else if (!currentKeys.has(entry.id) && !authorisesRemoval(entry.klass)) {
|
|
62
|
+
problems.push(`unauthorised absence: ${entry.id} at archived line ${entry.line}: ${entry.title} (recorded class ${entry.klass})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return makeResult(problems);
|
|
66
|
+
};
|