@golden-frijoles/kit 0.0.0 → 0.3.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/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/init.mjs +132 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +204 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/skeleton/Roadmap/00-ideas/README.md +85 -0
- package/dist/skeleton/Roadmap/00-ideas/audits/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/00-ideas/seeds/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/LEARNINGS.md +164 -0
- package/dist/skeleton/Roadmap/README.md +67 -0
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +233 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- package/package.json +33 -1
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// doc-format.mjs — check Roadmap/ epic docs (README.md, sprint-N.md, RETROSPECTIVE.md) against the
|
|
3
|
+
// canonical shape: the `groom` plugin's scaffolding templates (golden-frijoles/skills,
|
|
4
|
+
// skills/groom/templates/) — proven canonical in the project this was ported from by a zero-drift
|
|
5
|
+
// control group (81 seeds, one authoring path, identical shape; epic READMEs drift because they get
|
|
6
|
+
// hand-edited after scaffolding, away from the template). dobby-foundation's CI renders a scaffolded
|
|
7
|
+
// epic and runs this checker over it, so the producer validates its own templates.
|
|
8
|
+
//
|
|
9
|
+
// This is FORMAT checking (headings, frontmatter shape, section order) — complementary to
|
|
10
|
+
// doc-hygiene.mjs's CONTENT checking (dedupe, dead paths, staleness) on exactly two files.
|
|
11
|
+
//
|
|
12
|
+
// node scripts/doc-format.mjs # full-tree report (all docs, advisory)
|
|
13
|
+
// node scripts/doc-format.mjs --check # CI mode — exit 1 only for paths in ENFORCED_SWEPT_PATHS
|
|
14
|
+
// node scripts/doc-format.mjs --hook # single-file mode: read a path from stdin JSON
|
|
15
|
+
// (Claude Code PostToolUse hook payload), exit 2 on drift
|
|
16
|
+
// node scripts/doc-format.mjs --fix [--path=Roadmap/09-platform-infra/]
|
|
17
|
+
// # mechanically rewrites ONLY the fully-unambiguous
|
|
18
|
+
// offenses (DoD heading wording, sprint Status-line shape,
|
|
19
|
+
// retro Closed-line bold→italic) — never guesses real
|
|
20
|
+
// content (Class/Risk/Area/Scope-seed/dates/sections).
|
|
21
|
+
// Everything else is reported as still needing hand-fix.
|
|
22
|
+
//
|
|
23
|
+
// Reuse, don't rebuild: epic discovery + status come from `roadmap-extract.mjs` (the same SSOT
|
|
24
|
+
// build-order.mjs and doc-hygiene.mjs read) — this script does not re-derive epic status itself, and
|
|
25
|
+
// NEVER rewrites the `status:` frontmatter field (everything that reads the board depends on that
|
|
26
|
+
// field's name + values staying stable).
|
|
27
|
+
//
|
|
28
|
+
// The machine-readable frontmatter contract (epic README fields, sprint frontmatter, the per-story
|
|
29
|
+
// block, the `phase:` ladder) is defined in lib/roadmap-contract.mjs and only ENFORCED here — see
|
|
30
|
+
// checkContract below. It is reported as `contract-*` rules, never `--fix`ed: a missing field is filled
|
|
31
|
+
// by `node scripts/roadmap-backfill.mjs --write` (which reads the doc's own prose), not by this checker.
|
|
32
|
+
|
|
33
|
+
import { execFileSync } from 'node:child_process';
|
|
34
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
import { dirname, join, resolve, relative } from 'node:path';
|
|
37
|
+
import {
|
|
38
|
+
parseDocFrontmatter,
|
|
39
|
+
validateEpicFrontmatter,
|
|
40
|
+
validateSprintFrontmatter,
|
|
41
|
+
} from './lib/roadmap-contract.mjs';
|
|
42
|
+
import { projectAsset, projectRoot } from './lib/project-root.mjs';
|
|
43
|
+
|
|
44
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
const REPO = projectRoot(); // D2
|
|
46
|
+
const EXTRACTOR = join(__dirname, 'roadmap-extract.mjs');
|
|
47
|
+
const ENFORCED_PATH = projectAsset('doc-format.enforced.json'); // per-project policy, kit default
|
|
48
|
+
|
|
49
|
+
// Active (non-archived) epics are the only ones the sweep + hard gate ever apply to — status:
|
|
50
|
+
// archived epics are frozen historical record (Decision D3, doc-format-consistency epic README).
|
|
51
|
+
const ACTIVE_STATUSES = new Set(['Scaffolded', 'In progress', 'Shipped']);
|
|
52
|
+
|
|
53
|
+
// WHICH docs `--check` fails on is the PROJECT's decision, so it lives in the committed
|
|
54
|
+
// scripts/doc-format.enforced.json, not in this file: `{ "enforced": [ ... ] }`. An entry ending in `/` is
|
|
55
|
+
// a prefix. A new project enforces `Roadmap/` from day one — nothing predates the templates. A project
|
|
56
|
+
// adopting this late lists files as each area is swept, so `--check` stays green on today's state and
|
|
57
|
+
// red on anything new (the origin project swept incrementally this way, one macro-section at a time).
|
|
58
|
+
// A missing file enforces nothing, and says so. Everything outside the list is still REPORTED.
|
|
59
|
+
export function loadEnforced(path = ENFORCED_PATH) {
|
|
60
|
+
if (!existsSync(path)) return { entries: [], source: null };
|
|
61
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
62
|
+
if (!Array.isArray(raw.enforced) || raw.enforced.some((e) => typeof e !== 'string')) {
|
|
63
|
+
throw new Error(`${path}: "enforced" must be an array of paths (a trailing "/" makes a prefix)`);
|
|
64
|
+
}
|
|
65
|
+
return { entries: raw.enforced, source: path };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isEnforced(relPath, entries) {
|
|
69
|
+
return entries.some((e) => (e.endsWith('/') ? relPath.startsWith(e) : relPath === e));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ENFORCED = loadEnforced();
|
|
73
|
+
export const ENFORCED_SWEPT_PATHS = {
|
|
74
|
+
has: (p) => isEnforced(p, ENFORCED.entries),
|
|
75
|
+
get size() {
|
|
76
|
+
return ENFORCED.entries.length;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const VALID_EPIC_STATUSES = ['scaffolded', 'in-progress', 'shipped', 'archived'];
|
|
81
|
+
const CANONICAL_DOD_HEADING = '## Definition of Done (epic)';
|
|
82
|
+
const VALID_CLASSES = ['Feature', 'Spike', 'Bug', 'Chore'];
|
|
83
|
+
|
|
84
|
+
export function extractEpics() {
|
|
85
|
+
const json = execFileSync('node', [EXTRACTOR], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
86
|
+
return JSON.parse(json).filter((r) => r.grain === 'Epic');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readRelative(relPath) {
|
|
90
|
+
return readFileSync(join(REPO, relPath), 'utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function existsRelative(relPath) {
|
|
94
|
+
return existsSync(join(REPO, relPath));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Every sprint-N.md and RETROSPECTIVE.md sitting alongside an epic's README.md. */
|
|
98
|
+
export function siblingDocs(readmeRelPath) {
|
|
99
|
+
const dir = dirname(readmeRelPath);
|
|
100
|
+
const absDir = join(REPO, dir);
|
|
101
|
+
if (!existsSync(absDir)) return { sprints: [], retro: null };
|
|
102
|
+
const entries = readdirSync(absDir);
|
|
103
|
+
const sprints = entries
|
|
104
|
+
.filter((f) => /^sprint-\d+\.md$/.test(f))
|
|
105
|
+
.sort((a, z) => Number(a.match(/\d+/)[0]) - Number(z.match(/\d+/)[0]))
|
|
106
|
+
.map((f) => join(dir, f));
|
|
107
|
+
const retro = entries.includes('RETROSPECTIVE.md') ? join(dir, 'RETROSPECTIVE.md') : null;
|
|
108
|
+
return { sprints, retro };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Individual checkers — each returns a list of { rule, detail } offenses for one file's content ──
|
|
112
|
+
|
|
113
|
+
export function checkEpicReadme(content, { slug, exists = existsRelative } = {}) {
|
|
114
|
+
const offenses = [];
|
|
115
|
+
// Many epics predate the seeds/ convention and genuinely have no seed file to link — that's an
|
|
116
|
+
// accepted state (Sprint 2 sweep decision), not drift. Only require a **Scope seed:** field when a
|
|
117
|
+
// real seed file exists for this epic's slug; a seed that exists but isn't linked IS still flagged.
|
|
118
|
+
const hasRealSeed = Boolean(slug) && exists(join('Roadmap', '00-ideas', 'seeds', `${slug}.md`));
|
|
119
|
+
|
|
120
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
121
|
+
if (!fmMatch) {
|
|
122
|
+
offenses.push({ rule: 'frontmatter-missing', detail: 'no --- frontmatter block at the top of the file' });
|
|
123
|
+
} else {
|
|
124
|
+
const fm = fmMatch[1];
|
|
125
|
+
if (!/^status:\s*\S/m.test(fm))
|
|
126
|
+
offenses.push({ rule: 'frontmatter-status-missing', detail: 'no `status:` key in frontmatter' });
|
|
127
|
+
else {
|
|
128
|
+
const statusVal = fm.match(/^status:\s*(\S+)/m)[1];
|
|
129
|
+
if (!VALID_EPIC_STATUSES.includes(statusVal)) {
|
|
130
|
+
offenses.push({
|
|
131
|
+
rule: 'frontmatter-status-invalid',
|
|
132
|
+
detail: `status: "${statusVal}" is not one of ${VALID_EPIC_STATUSES.join('|')}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!/^slug:\s*\S/m.test(fm))
|
|
137
|
+
offenses.push({ rule: 'frontmatter-slug-missing', detail: 'no `slug:` key in frontmatter' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Catch any header-shaped blockquote line, not just an already-canonical one — a line starting
|
|
141
|
+
// with the legacy `**Macro-section:**` still needs to be FOUND so it can be flagged as legacy,
|
|
142
|
+
// rather than mis-reported as entirely missing.
|
|
143
|
+
const headerLine = content.split('\n').find((l) => {
|
|
144
|
+
const t = l.trim();
|
|
145
|
+
return t.startsWith('> **Area:**') || t.startsWith('> **Macro-section:**');
|
|
146
|
+
});
|
|
147
|
+
if (!headerLine) {
|
|
148
|
+
offenses.push({ rule: 'header-missing', detail: 'no `> **Area:** ...` header blockquote line found' });
|
|
149
|
+
} else {
|
|
150
|
+
if (!headerLine.includes('**Risk:**'))
|
|
151
|
+
offenses.push({ rule: 'header-missing-risk', detail: 'header line has Area but no **Risk:** field' });
|
|
152
|
+
if (!headerLine.includes('**Class:**')) {
|
|
153
|
+
offenses.push({
|
|
154
|
+
rule: 'header-missing-class',
|
|
155
|
+
detail:
|
|
156
|
+
'header line has no **Class:** field (should be one of Feature|Spike|Bug|Chore, between Risk and Scope seed)',
|
|
157
|
+
});
|
|
158
|
+
} else {
|
|
159
|
+
const classMatch = headerLine.match(/\*\*Class:\*\*\s*([^·]+)/);
|
|
160
|
+
const classVal = classMatch ? classMatch[1].trim() : '';
|
|
161
|
+
if (!VALID_CLASSES.includes(classVal)) {
|
|
162
|
+
offenses.push({
|
|
163
|
+
rule: 'header-class-invalid',
|
|
164
|
+
detail: `**Class:** "${classVal}" is not one of ${VALID_CLASSES.join('|')} (free-text descriptions belong in ## Why, not the header)`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!headerLine.includes('**Scope seed:**')) {
|
|
169
|
+
if (headerLine.includes('**Scope doc:**')) {
|
|
170
|
+
offenses.push({
|
|
171
|
+
rule: 'header-scope-doc-legacy',
|
|
172
|
+
detail:
|
|
173
|
+
'header uses **Scope doc:** — canonical is **Scope seed:** pointing at 00-ideas/seeds/ (2. readyforscope/ is documented legacy per 00-ideas/README.md)',
|
|
174
|
+
});
|
|
175
|
+
} else if (hasRealSeed) {
|
|
176
|
+
offenses.push({
|
|
177
|
+
rule: 'header-missing-scope-seed',
|
|
178
|
+
detail: 'header line has no **Scope seed:** field',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} else if (headerLine.includes('00-ideas/seeds/')) {
|
|
182
|
+
const linkMatch = headerLine.match(/\(([^)]*00-ideas\/seeds\/[^)]+)\)/);
|
|
183
|
+
if (linkMatch) {
|
|
184
|
+
const linkTarget = linkMatch[1].replace(/^\.\.\/\.\.\//, '');
|
|
185
|
+
if (!exists(join('Roadmap', linkTarget))) {
|
|
186
|
+
offenses.push({
|
|
187
|
+
rule: 'header-scope-seed-broken-link',
|
|
188
|
+
detail: `**Scope seed:** links to ${linkTarget}, which doesn't exist`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (headerLine.includes('**Macro-section:**')) {
|
|
194
|
+
offenses.push({
|
|
195
|
+
rule: 'header-macro-section-legacy',
|
|
196
|
+
detail: 'header uses **Macro-section:**/**BUILD-ORDER:** — canonical is **Area:**',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!content.includes(CANONICAL_DOD_HEADING)) {
|
|
202
|
+
if (/^##\s+Epic Definition of Done/m.test(content)) {
|
|
203
|
+
offenses.push({
|
|
204
|
+
rule: 'dod-heading-legacy',
|
|
205
|
+
detail: `heading is "## Epic Definition of Done" — canonical is "${CANONICAL_DOD_HEADING}"`,
|
|
206
|
+
});
|
|
207
|
+
} else if (/^##\s+Definition of Done/m.test(content)) {
|
|
208
|
+
const actual = content.match(/^##\s+.*Definition of Done.*$/m)[0];
|
|
209
|
+
offenses.push({
|
|
210
|
+
rule: 'dod-heading-mismatch',
|
|
211
|
+
detail: `heading is "${actual}" — canonical is "${CANONICAL_DOD_HEADING}"`,
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
offenses.push({ rule: 'dod-heading-missing', detail: `no "${CANONICAL_DOD_HEADING}" section found` });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return offenses;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Index of the first line AFTER a leading `---` frontmatter block (0 when there is none). Sprint files
|
|
222
|
+
// carry frontmatter since build-visualization-claude-mods, and a sprint or story titled "Status: …" must
|
|
223
|
+
// not be mistaken for the prose Status line — every Status-line search starts here, and skips headings
|
|
224
|
+
// (the scaffolder puts the sprint title in the H1, so the same title reappears there).
|
|
225
|
+
export function bodyStartIndex(lines) {
|
|
226
|
+
if (lines[0]?.trim() !== '---') return 0;
|
|
227
|
+
const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
228
|
+
return end === -1 ? 0 : end + 1;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function checkSprintDoc(content) {
|
|
232
|
+
const offenses = [];
|
|
233
|
+
const all = content.split('\n');
|
|
234
|
+
const statusLine = all
|
|
235
|
+
.slice(bodyStartIndex(all))
|
|
236
|
+
.find((l) => !l.startsWith('#') && (l.includes('**Status:**') || l.includes('Status:')));
|
|
237
|
+
if (!statusLine) {
|
|
238
|
+
offenses.push({ rule: 'sprint-status-missing', detail: 'no Status line found' });
|
|
239
|
+
} else {
|
|
240
|
+
const trimmed = statusLine.trim();
|
|
241
|
+
// "Combined" (Epic/Risk sharing the line with Status) is checked independent of what the line
|
|
242
|
+
// starts with — the real drift examples combine in different orders (Epic·Risk·Status vs
|
|
243
|
+
// Epic·Risk on one line + Status on the next blockquote line), so this must not be nested under
|
|
244
|
+
// a "starts with **Status:**" branch or the most common combined shape never matches.
|
|
245
|
+
const combinesOtherFields = /\*\*(Risk|Epic):/i.test(trimmed);
|
|
246
|
+
if (trimmed.startsWith('>')) {
|
|
247
|
+
offenses.push({
|
|
248
|
+
rule: 'sprint-status-blockquote',
|
|
249
|
+
detail:
|
|
250
|
+
'Status line is a blockquote (`> ...`) — canonical is a plain `**Status:** ...` line, no backlink/Risk on the same line',
|
|
251
|
+
});
|
|
252
|
+
} else if (combinesOtherFields) {
|
|
253
|
+
offenses.push({
|
|
254
|
+
rule: 'sprint-status-combined',
|
|
255
|
+
detail: 'Status line combines Epic/Risk on the same line — canonical is Status alone',
|
|
256
|
+
});
|
|
257
|
+
} else if (!trimmed.startsWith('**Status:**')) {
|
|
258
|
+
offenses.push({
|
|
259
|
+
rule: 'sprint-status-format',
|
|
260
|
+
detail: `Status line doesn't start with "**Status:**" — found: "${trimmed.slice(0, 60)}"`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return offenses;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── The frontmatter contract (build-visualization-claude-mods S2.1) ─────────────────────────────────
|
|
268
|
+
// Kept apart from checkEpicReadme/checkSprintDoc on purpose: those check the prose FORMAT of one file's
|
|
269
|
+
// text, while the contract needs the file's place in its epic (sprint number, slug, sibling totals).
|
|
270
|
+
// The rules themselves live in lib/roadmap-contract.mjs — this only supplies that context.
|
|
271
|
+
|
|
272
|
+
/** The contract context for one epic dir: slug, archived?, sprint numbers + each sprint's parsed frontmatter. */
|
|
273
|
+
export function contractContext(readmeRelPath, { read = readRelative } = {}) {
|
|
274
|
+
const dir = dirname(readmeRelPath);
|
|
275
|
+
const slug = dir.split('/').at(-1);
|
|
276
|
+
const epic = parseDocFrontmatter(read(readmeRelPath));
|
|
277
|
+
const { sprints } = siblingDocs(readmeRelPath);
|
|
278
|
+
const parsedSprints = sprints.map((p) => ({
|
|
279
|
+
path: p,
|
|
280
|
+
n: Number(p.match(/sprint-(\d+)\.md$/)[1]),
|
|
281
|
+
parsed: parseDocFrontmatter(read(p)),
|
|
282
|
+
}));
|
|
283
|
+
// The epic's story total is only cross-checked when every sprint's list could be read — a sprint that
|
|
284
|
+
// fails to parse is already its own finding, and a sum over a partial set would add a false second one.
|
|
285
|
+
const allRead = parsedSprints.every(
|
|
286
|
+
(s) => s.parsed.hasFrontmatter && !s.parsed.error && Array.isArray(s.parsed.data.stories)
|
|
287
|
+
);
|
|
288
|
+
return {
|
|
289
|
+
slug,
|
|
290
|
+
archived: epic.data.status === 'archived',
|
|
291
|
+
epic,
|
|
292
|
+
sprints: parsedSprints,
|
|
293
|
+
sprintCount: parsedSprints.length,
|
|
294
|
+
storyCount: allRead ? parsedSprints.reduce((a, s) => a + s.parsed.data.stories.length, 0) : undefined,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Contract offenses for one doc, given its epic's context. RETROSPECTIVE.md carries no contract. */
|
|
299
|
+
export function checkContract(docType, content, ctx) {
|
|
300
|
+
if (ctx.archived) return []; // frozen historical record (doc-format-consistency D3)
|
|
301
|
+
if (docType === 'epic-README')
|
|
302
|
+
return validateEpicFrontmatter(parseDocFrontmatter(content), {
|
|
303
|
+
sprintCount: ctx.sprintCount,
|
|
304
|
+
storyCount: ctx.storyCount,
|
|
305
|
+
});
|
|
306
|
+
if (docType === 'sprint')
|
|
307
|
+
return validateSprintFrontmatter(parseDocFrontmatter(content), { n: ctx.n, slug: ctx.slug });
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Accepted section headings, by stem. Triage against a second project (golden-beans, plugin-audit-and-
|
|
312
|
+
// extraction S2.4) found the canonical sections written with a subtitle ("## What we learned the hard
|
|
313
|
+
// way", "## Gaps, stated rather than implied") or a plain synonym ("## Follow-ups", "## Durable
|
|
314
|
+
// learnings"). Same section, same job — flagging them was a rule tuned to one corpus, so it is relaxed
|
|
315
|
+
// to the stem. A retro with none of a section's stems is still flagged.
|
|
316
|
+
export const RETRO_SECTION_STEMS = [
|
|
317
|
+
{ canonical: '## What shipped', stems: ['## What shipped'] },
|
|
318
|
+
{ canonical: '## What went well', stems: ['## What went well', '## What worked'] },
|
|
319
|
+
{
|
|
320
|
+
canonical: '## What we learned',
|
|
321
|
+
stems: ['## What we learned', '## What was learned', '## Durable learning'],
|
|
322
|
+
},
|
|
323
|
+
{ canonical: '## Gaps / follow-ups', stems: ['## Gaps', '## Follow-ups', '## Remaining follow-up'] },
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
// The close line stays STRICT — `_Closed: YYYY-MM-DD_` — on purpose. A relaxed "any close word with a
|
|
327
|
+
// date" rule was tried after the golden-beans triage and reverted on review: it false-passed lines like
|
|
328
|
+
// "Epic not closed yet — last touched 2026-07-20", and it disagreed with epic-dod.mjs's `retro-written`
|
|
329
|
+
// item (byte-identical across projects), which requires the literal form. One rule, two rails agreeing.
|
|
330
|
+
// A retro that records its close as "**Shipped:** <date>" gets the canonical line added, carrying the
|
|
331
|
+
// same date — that is fixing drift, not relaxing the check.
|
|
332
|
+
export function checkRetrospective(content) {
|
|
333
|
+
const offenses = [];
|
|
334
|
+
const closedLine = content.split('\n').find((l) => /closed/i.test(l) && l.trim() !== '');
|
|
335
|
+
if (!closedLine) {
|
|
336
|
+
offenses.push({ rule: 'retro-closed-missing', detail: 'no "Closed" date line found near the top' });
|
|
337
|
+
} else {
|
|
338
|
+
const trimmed = closedLine.trim();
|
|
339
|
+
// Canonical is an italic line STARTING with "_Closed: YYYY-MM-DD" — trailing content after the
|
|
340
|
+
// date (sprint counts, PR refs, caveats) is genuinely the norm across real retros, not drift, as
|
|
341
|
+
// long as the italic markup actually closes somewhere (immediately after the date, or at the end
|
|
342
|
+
// of the line). Require: starts with the italic-open + "Closed:" + a real date, and a closing "_"
|
|
343
|
+
// appears somewhere after that.
|
|
344
|
+
// The canonical groom scaffold intentionally has no close date yet. Accept its literal sentinel:
|
|
345
|
+
// treating an unbuilt epic as closed would make roadmap-to-notion derive a false Shipped state.
|
|
346
|
+
const isScaffoldPlaceholder = trimmed === '_Closed: <date>_';
|
|
347
|
+
const startsWithDate = /^_Closed:\s*\d{4}-\d{2}-\d{2}/.test(trimmed);
|
|
348
|
+
const hasClosingItalic = trimmed.slice(1).includes('_');
|
|
349
|
+
if (!isScaffoldPlaceholder && (!startsWithDate || !hasClosingItalic)) {
|
|
350
|
+
if (/^\*\*Closed/.test(trimmed)) {
|
|
351
|
+
offenses.push({
|
|
352
|
+
rule: 'retro-closed-bold',
|
|
353
|
+
detail: `"Closed" line is bold (**Closed ...**) — canonical is italic: "_Closed: YYYY-MM-DD_"`,
|
|
354
|
+
});
|
|
355
|
+
} else {
|
|
356
|
+
offenses.push({
|
|
357
|
+
rule: 'retro-closed-format',
|
|
358
|
+
detail: `"Closed" line doesn't match canonical "_Closed: YYYY-MM-DD_" — found: "${trimmed}"`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const headings = content.split('\n').filter((l) => l.startsWith('## '));
|
|
365
|
+
for (const { canonical, stems } of RETRO_SECTION_STEMS) {
|
|
366
|
+
if (!headings.some((h) => stems.some((stem) => h.startsWith(stem)))) {
|
|
367
|
+
offenses.push({ rule: 'retro-section-missing', detail: `missing canonical section "${canonical}"` });
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return offenses;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── Mechanical rewrites — only for offenses with exactly one unambiguous correct rewrite. Never
|
|
375
|
+
// guess real content (Class/Risk/Area/Scope-seed/dates/section text) — those stay hand-fix-only. ──
|
|
376
|
+
|
|
377
|
+
/** dod-heading-legacy / dod-heading-mismatch → rename the heading line to the canonical wording. */
|
|
378
|
+
export function fixDodHeading(content) {
|
|
379
|
+
return content.replace(/^##\s+.*Definition of Done.*$/m, CANONICAL_DOD_HEADING);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* sprint-status-blockquote / sprint-status-combined → collapse to a plain `**Status:** <value>` line.
|
|
384
|
+
* Extracts just the status value (whatever follows "Status:"/"Status**" up to the field's own end —
|
|
385
|
+
* `·`, end of blockquote line, or end of string) and drops any Epic/Risk sharing the line/block.
|
|
386
|
+
*/
|
|
387
|
+
export function fixSprintStatusLine(content) {
|
|
388
|
+
const lines = content.split('\n');
|
|
389
|
+
const from = bodyStartIndex(lines);
|
|
390
|
+
const idx = lines.findIndex(
|
|
391
|
+
(l, i) => i >= from && !l.startsWith('#') && (l.includes('**Status:**') || l.includes('Status:'))
|
|
392
|
+
);
|
|
393
|
+
if (idx === -1) return content;
|
|
394
|
+
|
|
395
|
+
const extractValue = (line) => {
|
|
396
|
+
// Whether to strip a trailing `**` from the captured value depends on WHERE the label's bold
|
|
397
|
+
// closes, and both real shapes exist in this tree:
|
|
398
|
+
// "**Status:** value" — label bold closes right after the colon (captured, non-greedy
|
|
399
|
+
// group below matches those 2 asterisks) — the value that follows
|
|
400
|
+
// is plain; any trailing `**`/`*` in it belongs to unrelated
|
|
401
|
+
// content later in the value (e.g. "...*(...)*" or "...**x**") and
|
|
402
|
+
// must be left alone.
|
|
403
|
+
// "**Status: value**" — label bold stays open past the colon and closes at the very end
|
|
404
|
+
// of the value — THAT trailing `**` really is the label wrapper's
|
|
405
|
+
// own close and should be stripped.
|
|
406
|
+
const m = line.match(/(\*{0,2})Status:?(\*{0,2})\s*(.*)$/i);
|
|
407
|
+
if (!m) return null;
|
|
408
|
+
const labelOpened = m[1] === '**';
|
|
409
|
+
const labelClosedAtColon = m[2] === '**';
|
|
410
|
+
const value = labelOpened && !labelClosedAtColon ? m[3].replace(/\*+$/, '') : m[3];
|
|
411
|
+
return value.trim();
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
// Real docs have both failure modes a naive rewrite can silently cause: (a) a bold span that
|
|
415
|
+
// wraps onto the NEXT physical line — collapsing just this line leaves a dangling unmatched `**`
|
|
416
|
+
// behind; (b) a bold span that closes mid-sentence, not at the end of the extracted value — the
|
|
417
|
+
// leftover `**` ends up embedded inside the new line. Both show up as an ODD count of `**` somewhere
|
|
418
|
+
// that should have been even. Bail (leave for hand-fix) rather than risk corrupting or discarding
|
|
419
|
+
// real content — checked against BOTH the raw input (catches case a) and the candidate output
|
|
420
|
+
// (catches case b).
|
|
421
|
+
const isBalancedBold = (s) => (s.match(/\*\*/g) || []).length % 2 === 0;
|
|
422
|
+
|
|
423
|
+
const trimmed = lines[idx].trim();
|
|
424
|
+
if (trimmed.startsWith('>')) {
|
|
425
|
+
// Blockquote form: the Status line may be this line, or the block may span a preceding
|
|
426
|
+
// `> Epic: ... **Risk: ...**` line immediately above — collapse the whole contiguous blockquote
|
|
427
|
+
// run into one plain Status line, but ONLY when that block is short backlink/Risk noise. A long
|
|
428
|
+
// or prose-heavy block (PR links, findings, "Owed to the product owner" notes, etc.) is real documentation,
|
|
429
|
+
// not formatting cruft — silently discarding it is worse than leaving it for hand-fix.
|
|
430
|
+
let start = idx;
|
|
431
|
+
while (start > 0 && lines[start - 1].trim().startsWith('>')) start--;
|
|
432
|
+
let end = idx;
|
|
433
|
+
while (end < lines.length - 1 && lines[end + 1].trim().startsWith('>')) end++;
|
|
434
|
+
|
|
435
|
+
// A short line is NOT proof it's disposable backlink noise — e.g. a one-line "Surfaces: ..."
|
|
436
|
+
// continuation is real, load-bearing content, not formatting cruft (a real case found sweeping
|
|
437
|
+
// promoter-funnel-v2/sprint-5.md, where a length-only heuristic let it through and it was
|
|
438
|
+
// silently discarded). Require every non-Status line in the block to be STRICTLY an Epic:/Risk:
|
|
439
|
+
// labeled field and nothing else — anything else in the block means real content is mixed in,
|
|
440
|
+
// so bail and leave the whole thing for hand-fix.
|
|
441
|
+
const blockLines = lines.slice(start, end + 1);
|
|
442
|
+
if (blockLines.length > 3) return content;
|
|
443
|
+
const isDisposableNoiseLine = (l) => /^\*{0,2}(Epic|Risk)\s*:.*$/i.test(l.replace(/^>\s*/, '').trim());
|
|
444
|
+
if (blockLines.some((l, i) => start + i !== idx && !isDisposableNoiseLine(l))) return content;
|
|
445
|
+
if (!isBalancedBold(blockLines.join('\n'))) return content;
|
|
446
|
+
|
|
447
|
+
// Same ordering hazard as the combined branch below: if Risk/Epic trails Status on the Status
|
|
448
|
+
// line itself, extractValue would fold it into the kept value instead of dropping it.
|
|
449
|
+
const statusLineNoQuote = trimmed.replace(/^>\s*/, '');
|
|
450
|
+
const statusIdx = statusLineNoQuote.search(/Status:?/i);
|
|
451
|
+
if (statusIdx === -1 || /\*\*(Risk|Epic):/i.test(statusLineNoQuote.slice(statusIdx))) return content;
|
|
452
|
+
|
|
453
|
+
const value = extractValue(statusLineNoQuote);
|
|
454
|
+
if (value === null || !isBalancedBold(value)) return content;
|
|
455
|
+
const newLines = [...lines.slice(0, start), `**Status:** ${value}`, ...lines.slice(end + 1)];
|
|
456
|
+
return newLines.join('\n');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (/\*\*(Risk|Epic):/i.test(trimmed)) {
|
|
460
|
+
if (!isBalancedBold(lines[idx])) return content;
|
|
461
|
+
// extractValue captures everything from "Status:" to the end of the line — safe only when
|
|
462
|
+
// Risk/Epic appear BEFORE Status (the dropped fields precede the kept one). If Status comes
|
|
463
|
+
// first, Risk/Epic trailing after it would get silently folded into the "Status value" instead
|
|
464
|
+
// of dropped, defeating the whole point of this rewrite (real case found sweeping
|
|
465
|
+
// homepage-polish-b sprint-1/3, where Status led and Risk trailed on the same line).
|
|
466
|
+
const statusIdx = trimmed.search(/Status:?/i);
|
|
467
|
+
if (statusIdx === -1 || /\*\*(Risk|Epic):/i.test(trimmed.slice(statusIdx))) return content;
|
|
468
|
+
const value = extractValue(trimmed);
|
|
469
|
+
if (value === null || !isBalancedBold(value)) return content;
|
|
470
|
+
lines[idx] = `**Status:** ${value}`;
|
|
471
|
+
return lines.join('\n');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return content;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** retro-closed-bold → convert `**Closed ...**` to `_Closed: YYYY-MM-DD ...trailing..._`. */
|
|
478
|
+
export function fixRetroClosedLine(content) {
|
|
479
|
+
const lines = content.split('\n');
|
|
480
|
+
const idx = lines.findIndex((l) => /^\*\*Closed/.test(l.trim()));
|
|
481
|
+
if (idx === -1) return content;
|
|
482
|
+
|
|
483
|
+
// Real retros have plain-prose paragraphs that just happen to START with "**Closed <date>.**" —
|
|
484
|
+
// the bold span self-closes right after the date, then the SAME paragraph continues in plain text
|
|
485
|
+
// across several more physical lines (soft-wrapped, no blank line between). Rewriting only the
|
|
486
|
+
// first line there leaves a dangling italic close (`_...text_`) mid-paragraph and strands the
|
|
487
|
+
// continuation lines as an orphaned fragment. Only touch a Closed line that is its own complete
|
|
488
|
+
// paragraph — i.e. the next line is blank, a heading, or EOF.
|
|
489
|
+
const nextLine = lines[idx + 1];
|
|
490
|
+
const isStandaloneParagraph =
|
|
491
|
+
nextLine === undefined || nextLine.trim() === '' || /^#{1,6}\s/.test(nextLine.trim());
|
|
492
|
+
if (!isStandaloneParagraph) return content;
|
|
493
|
+
|
|
494
|
+
const trimmed = lines[idx].trim();
|
|
495
|
+
const dateMatch = trimmed.match(/\d{4}-\d{2}-\d{2}/);
|
|
496
|
+
if (!dateMatch) return content;
|
|
497
|
+
// Strip the bold markers, keep whatever trailing content follows the date (sprint counts, PR
|
|
498
|
+
// refs) as-is, re-wrap the whole thing in italics starting with "_Closed: ".
|
|
499
|
+
const rest = trimmed.slice(trimmed.indexOf(dateMatch[0]) + dateMatch[0].length).replace(/\*+\s*$/, '');
|
|
500
|
+
lines[idx] = `_Closed: ${dateMatch[0]}${rest}_`;
|
|
501
|
+
return lines.join('\n');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Applies every mechanical rewrite this module knows to one file's content; returns { content, fixedRules, remainingOffenses }. */
|
|
505
|
+
export function applyMechanicalFixes(content, docType) {
|
|
506
|
+
let next = content;
|
|
507
|
+
const fixedRules = [];
|
|
508
|
+
|
|
509
|
+
const before1 = next;
|
|
510
|
+
next = fixDodHeading(next);
|
|
511
|
+
if (next !== before1) fixedRules.push('dod-heading-legacy/dod-heading-mismatch');
|
|
512
|
+
|
|
513
|
+
if (docType === 'sprint') {
|
|
514
|
+
const before2 = next;
|
|
515
|
+
next = fixSprintStatusLine(next);
|
|
516
|
+
if (next !== before2) fixedRules.push('sprint-status-blockquote/sprint-status-combined');
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (docType === 'retrospective') {
|
|
520
|
+
const before3 = next;
|
|
521
|
+
next = fixRetroClosedLine(next);
|
|
522
|
+
if (next !== before3) fixedRules.push('retro-closed-bold');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
return { content: next, fixedRules };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ── Full-tree walk ──────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
export function findAllOffenses({ activeOnly = false } = {}) {
|
|
531
|
+
const epics = extractEpics().filter((e) => !activeOnly || ACTIVE_STATUSES.has(e.status));
|
|
532
|
+
const results = [];
|
|
533
|
+
|
|
534
|
+
for (const epic of epics) {
|
|
535
|
+
const readmePath = epic.doc_link;
|
|
536
|
+
if (!existsRelative(readmePath)) continue; // extractor can lag a just-renamed/moved doc
|
|
537
|
+
const ctx = contractContext(readmePath);
|
|
538
|
+
const readmeText = readRelative(readmePath);
|
|
539
|
+
const readmeOffenses = [
|
|
540
|
+
...checkEpicReadme(readmeText, { slug: epic.slug }),
|
|
541
|
+
...checkContract('epic-README', readmeText, ctx),
|
|
542
|
+
];
|
|
543
|
+
if (readmeOffenses.length)
|
|
544
|
+
results.push({ path: readmePath, docType: 'epic-README', offenses: readmeOffenses });
|
|
545
|
+
|
|
546
|
+
const { sprints, retro } = siblingDocs(readmePath);
|
|
547
|
+
for (const sprintPath of sprints) {
|
|
548
|
+
const sprintText = readRelative(sprintPath);
|
|
549
|
+
const n = Number(sprintPath.match(/sprint-(\d+)\.md$/)[1]);
|
|
550
|
+
const sprintOffenses = [
|
|
551
|
+
...checkSprintDoc(sprintText),
|
|
552
|
+
...checkContract('sprint', sprintText, { ...ctx, n }),
|
|
553
|
+
];
|
|
554
|
+
if (sprintOffenses.length)
|
|
555
|
+
results.push({ path: sprintPath, docType: 'sprint', offenses: sprintOffenses });
|
|
556
|
+
}
|
|
557
|
+
if (retro) {
|
|
558
|
+
const retroOffenses = checkRetrospective(readRelative(retro));
|
|
559
|
+
if (retroOffenses.length)
|
|
560
|
+
results.push({ path: retro, docType: 'retrospective', offenses: retroOffenses });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return results;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function formatOffense(fileResult) {
|
|
568
|
+
return fileResult.offenses.map((o) => ` [${o.rule}] ${o.detail}`).join('\n');
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// ── CLI modes ────────────────────────────────────────────────────────────────
|
|
572
|
+
|
|
573
|
+
function runReport() {
|
|
574
|
+
const results = findAllOffenses();
|
|
575
|
+
if (!results.length) {
|
|
576
|
+
console.log('doc-format: zero findings across the full Roadmap tree.');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
console.log(`doc-format: ${results.length} file(s) with findings\n`);
|
|
580
|
+
const byType = {};
|
|
581
|
+
for (const r of results) (byType[r.docType] ??= []).push(r);
|
|
582
|
+
for (const [docType, group] of Object.entries(byType)) {
|
|
583
|
+
console.log(`── ${docType} (${group.length}) ──`);
|
|
584
|
+
for (const r of group) {
|
|
585
|
+
const enforced = ENFORCED_SWEPT_PATHS.has(r.path) ? ' [ENFORCED]' : '';
|
|
586
|
+
console.log(`${r.path}${enforced}`);
|
|
587
|
+
console.log(formatOffense(r));
|
|
588
|
+
}
|
|
589
|
+
console.log('');
|
|
590
|
+
}
|
|
591
|
+
const enforcedCount = results.filter((r) => ENFORCED_SWEPT_PATHS.has(r.path)).length;
|
|
592
|
+
console.log(`Total: ${results.length} file(s), ${enforcedCount} enforced (would fail --check).`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function runCheck() {
|
|
596
|
+
const results = findAllOffenses();
|
|
597
|
+
const enforced = results.filter((r) => ENFORCED_SWEPT_PATHS.has(r.path));
|
|
598
|
+
if (enforced.length) {
|
|
599
|
+
console.error(`doc-format --check: ${enforced.length} enforced file(s) have drift:\n`);
|
|
600
|
+
for (const r of enforced) {
|
|
601
|
+
console.error(r.path);
|
|
602
|
+
console.error(formatOffense(r));
|
|
603
|
+
}
|
|
604
|
+
process.exit(1);
|
|
605
|
+
}
|
|
606
|
+
console.log(
|
|
607
|
+
`doc-format --check: clean (${ENFORCED_SWEPT_PATHS.size} path(s) enforced, ${results.length} advisory finding(s) elsewhere).`
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const MECHANICAL_RULES = new Set([
|
|
612
|
+
'dod-heading-legacy',
|
|
613
|
+
'dod-heading-mismatch',
|
|
614
|
+
'sprint-status-blockquote',
|
|
615
|
+
'sprint-status-combined',
|
|
616
|
+
'retro-closed-bold',
|
|
617
|
+
]);
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Mechanically rewrites only the fully-unambiguous offenses (see MECHANICAL_RULES / the rewrite
|
|
621
|
+
* functions above). Everything else — header fields, missing sections, missing dates — needs a human
|
|
622
|
+
* to read the epic's own content, so it's left untouched and reported as still-needing-hand-fix.
|
|
623
|
+
*/
|
|
624
|
+
function runFix() {
|
|
625
|
+
const pathFilterArg = process.argv.find((a) => a.startsWith('--path='));
|
|
626
|
+
const pathFilter = pathFilterArg ? pathFilterArg.slice('--path='.length) : null;
|
|
627
|
+
|
|
628
|
+
const results = findAllOffenses();
|
|
629
|
+
let filesFixed = 0;
|
|
630
|
+
let filesUntouched = 0;
|
|
631
|
+
const stillNeedsHandFix = [];
|
|
632
|
+
|
|
633
|
+
for (const r of results) {
|
|
634
|
+
if (pathFilter && !r.path.startsWith(pathFilter)) continue;
|
|
635
|
+
const mechanicalOffenses = r.offenses.filter((o) => MECHANICAL_RULES.has(o.rule));
|
|
636
|
+
const remainingOffenses = r.offenses.filter((o) => !MECHANICAL_RULES.has(o.rule));
|
|
637
|
+
|
|
638
|
+
if (mechanicalOffenses.length) {
|
|
639
|
+
const original = readRelative(r.path);
|
|
640
|
+
const { content: fixed, fixedRules } = applyMechanicalFixes(original, r.docType);
|
|
641
|
+
if (fixed !== original) {
|
|
642
|
+
writeFileSync(join(REPO, r.path), fixed);
|
|
643
|
+
filesFixed++;
|
|
644
|
+
console.log(`fixed: ${r.path} (${fixedRules.join(', ')})`);
|
|
645
|
+
} else {
|
|
646
|
+
filesUntouched++;
|
|
647
|
+
}
|
|
648
|
+
} else {
|
|
649
|
+
filesUntouched++;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (remainingOffenses.length) {
|
|
653
|
+
stillNeedsHandFix.push({ path: r.path, offenses: remainingOffenses });
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
console.log(
|
|
658
|
+
`\ndoc-format --fix: ${filesFixed} file(s) mechanically rewritten, ${filesUntouched} left as they were.`
|
|
659
|
+
);
|
|
660
|
+
if (stillNeedsHandFix.length) {
|
|
661
|
+
console.log(`${stillNeedsHandFix.length} file(s) still need hand-fixing (real content judgment):\n`);
|
|
662
|
+
for (const r of stillNeedsHandFix) {
|
|
663
|
+
console.log(r.path);
|
|
664
|
+
console.log(formatOffense(r));
|
|
665
|
+
}
|
|
666
|
+
} else {
|
|
667
|
+
console.log('No remaining offenses need hand-fixing.');
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Check ONE doc by path. Returns `null` when the path isn't a doc type this checker covers, or an
|
|
673
|
+
* array of offenses (possibly empty) when it is.
|
|
674
|
+
*
|
|
675
|
+
* Extracted so the single-file path has exactly one implementation. `--hook` (the editor hook) and
|
|
676
|
+
* `--files` (the git pre-commit hook) are the same question asked by two callers, and a second copy
|
|
677
|
+
* of this dispatch would drift — the epic-README segment-count rule below is subtle enough that a
|
|
678
|
+
* near-duplicate would eventually disagree with this one about what counts as an epic doc.
|
|
679
|
+
*/
|
|
680
|
+
export function checkOneDoc(relPath, contentOverride = null) {
|
|
681
|
+
if (!/^Roadmap\/.*\.md$/.test(relPath)) return null; // not a Roadmap doc — nothing to check
|
|
682
|
+
const abs = join(REPO, relPath);
|
|
683
|
+
if (contentOverride === null && !existsSync(abs)) return null;
|
|
684
|
+
|
|
685
|
+
const content = contentOverride ?? readFileSync(abs, 'utf8');
|
|
686
|
+
const segs = relPath.split('/');
|
|
687
|
+
const base = segs.pop();
|
|
688
|
+
// An epic README is exactly Roadmap/<macro-section>/<epic>/README.md (4 segments). The top-level
|
|
689
|
+
// poster (Roadmap/README.md, 2) and macro-section index READMEs (Roadmap/<section>/README.md, 3)
|
|
690
|
+
// share the README.md basename but are NOT epic docs — checkEpicReadme's frontmatter/Area/DoD
|
|
691
|
+
// rules don't apply to them. segs still holds the dir path after the pop() above.
|
|
692
|
+
// Sprint + README docs also carry the frontmatter contract, which needs the epic's context — read
|
|
693
|
+
// from the sibling README when there is one (a sprint file with no README is not an epic doc).
|
|
694
|
+
const readme = join(...segs, 'README.md');
|
|
695
|
+
const ctx = segs.length === 3 && existsRelative(readme) ? contractContext(readme) : null;
|
|
696
|
+
if (base === 'README.md') {
|
|
697
|
+
if (segs.length !== 3) return null; // ['Roadmap', section, epic] ⇒ epic README; else skip
|
|
698
|
+
return [
|
|
699
|
+
...checkEpicReadme(content, { slug: segs.at(-1) }),
|
|
700
|
+
...(ctx ? checkContract('epic-README', content, ctx) : []),
|
|
701
|
+
];
|
|
702
|
+
}
|
|
703
|
+
if (/^sprint-\d+\.md$/.test(base)) {
|
|
704
|
+
const n = Number(base.match(/\d+/)[0]);
|
|
705
|
+
// A sprint edit can make its epic's declared totals wrong (a story added to `stories:`), and the README
|
|
706
|
+
// is not in the staged set — so its totals are re-checked here, named as the README's, or the commit
|
|
707
|
+
// passes locally and only the full CI walk notices.
|
|
708
|
+
const totals = ctx
|
|
709
|
+
? checkContract('epic-README', readRelative(readme), ctx)
|
|
710
|
+
.filter((o) => o.rule === 'contract-total-mismatch')
|
|
711
|
+
.map((o) => ({ ...o, detail: `${readme}: ${o.detail}` }))
|
|
712
|
+
: [];
|
|
713
|
+
return [
|
|
714
|
+
...checkSprintDoc(content),
|
|
715
|
+
...(ctx ? checkContract('sprint', content, { ...ctx, n }) : []),
|
|
716
|
+
...totals,
|
|
717
|
+
];
|
|
718
|
+
}
|
|
719
|
+
if (base === 'RETROSPECTIVE.md') return checkRetrospective(content);
|
|
720
|
+
return null; // not an epic doc type this checker covers (e.g. a seed, the poster)
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* `--check --files <path>...` — validate ONLY the named docs.
|
|
725
|
+
*
|
|
726
|
+
* This is the pre-commit path, and it exists for one reason: `--check` (the full-tree walk) reads all
|
|
727
|
+
* ~860 docs under Roadmap/ to tell you about the two you just staged. That walk is ~99.8% waste at
|
|
728
|
+
* commit time and it was the single slowest thing in the local gate alongside build-order's own walk.
|
|
729
|
+
*
|
|
730
|
+
* The full walk still has a job — it catches drift in docs *nobody touched* (a rule added today makes
|
|
731
|
+
* yesterday's file non-conforming). That's a CI question, not a per-commit one, so `--check` stays
|
|
732
|
+
* exactly as it is and runs on the PR.
|
|
733
|
+
*/
|
|
734
|
+
/** The committed (HEAD) text of a doc, or null when it is new — the "known state" a commit is measured against. */
|
|
735
|
+
function committedVersion(relPath) {
|
|
736
|
+
try {
|
|
737
|
+
return execFileSync('git', ['show', `HEAD:${relPath}`], {
|
|
738
|
+
cwd: REPO,
|
|
739
|
+
encoding: 'utf8',
|
|
740
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
741
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
742
|
+
});
|
|
743
|
+
} catch {
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The offenses a commit INTRODUCES: those in the staged doc that its committed version did not already
|
|
750
|
+
* have (same rule + detail, counted). A NEW doc has no committed version, so every finding is its own.
|
|
751
|
+
*
|
|
752
|
+
* Why (build-visualization-claude-mods S2): the doctrine for this checker is "green on today's known
|
|
753
|
+
* state, red on anything new". Blocking on a touched doc's PRE-EXISTING findings made every edit to a
|
|
754
|
+
* legacy doc a demand to sweep it — and a mechanical change touching hundreds of legacy docs (the
|
|
755
|
+
* frontmatter backfill) impossible to commit without bypassing the hook. A finding the commit did not
|
|
756
|
+
* cause is still printed, as a count, and the full walk still reports all of them.
|
|
757
|
+
*/
|
|
758
|
+
export function introducedOffenses(now, before) {
|
|
759
|
+
if (!before) return now;
|
|
760
|
+
const seen = new Map();
|
|
761
|
+
for (const o of before)
|
|
762
|
+
seen.set(`${o.rule}\u0000${o.detail}`, (seen.get(`${o.rule}\u0000${o.detail}`) || 0) + 1);
|
|
763
|
+
return now.filter((o) => {
|
|
764
|
+
const key = `${o.rule}\u0000${o.detail}`;
|
|
765
|
+
if (!seen.get(key)) return true;
|
|
766
|
+
seen.set(key, seen.get(key) - 1);
|
|
767
|
+
return false;
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function runCheckFiles(paths) {
|
|
772
|
+
const findings = [];
|
|
773
|
+
let checked = 0;
|
|
774
|
+
let preExisting = 0;
|
|
775
|
+
for (const p of paths) {
|
|
776
|
+
const relPath = p.startsWith(REPO) ? relative(REPO, p) : p;
|
|
777
|
+
const offenses = checkOneDoc(relPath);
|
|
778
|
+
if (offenses === null) continue; // not a covered doc type
|
|
779
|
+
checked++;
|
|
780
|
+
if (!offenses.length) continue;
|
|
781
|
+
const committed = committedVersion(relPath);
|
|
782
|
+
const introduced = introducedOffenses(
|
|
783
|
+
offenses,
|
|
784
|
+
committed === null ? null : checkOneDoc(relPath, committed) || []
|
|
785
|
+
);
|
|
786
|
+
preExisting += offenses.length - introduced.length;
|
|
787
|
+
if (introduced.length) findings.push({ path: relPath, offenses: introduced });
|
|
788
|
+
}
|
|
789
|
+
if (preExisting)
|
|
790
|
+
console.log(
|
|
791
|
+
`doc-format: ${preExisting} pre-existing finding(s) in the staged docs were already committed — not this commit's (see: node scripts/doc-format.mjs).`
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
if (findings.length) {
|
|
795
|
+
console.error(
|
|
796
|
+
`doc-format: ${findings.length} of ${checked} checked doc(s) have findings this commit introduces:\n`
|
|
797
|
+
);
|
|
798
|
+
for (const f of findings) {
|
|
799
|
+
console.error(f.path);
|
|
800
|
+
console.error(formatOffense(f));
|
|
801
|
+
}
|
|
802
|
+
console.error("Fix by hand, or 'node scripts/doc-format.mjs --fix' for the mechanical rules.");
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
console.log(`doc-format: ${checked} staged doc(s) clean.`);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function runHook() {
|
|
809
|
+
let input = '';
|
|
810
|
+
process.stdin.on('data', (chunk) => {
|
|
811
|
+
input += chunk;
|
|
812
|
+
});
|
|
813
|
+
process.stdin.on('end', () => {
|
|
814
|
+
let filePath;
|
|
815
|
+
try {
|
|
816
|
+
const payload = JSON.parse(input);
|
|
817
|
+
filePath = payload?.tool_input?.file_path;
|
|
818
|
+
} catch {
|
|
819
|
+
process.exit(0); // malformed hook payload — fail open, never block an edit on a parse error
|
|
820
|
+
}
|
|
821
|
+
if (!filePath) process.exit(0);
|
|
822
|
+
const relPath = relative(REPO, filePath);
|
|
823
|
+
const offenses = checkOneDoc(relPath);
|
|
824
|
+
if (offenses === null) process.exit(0);
|
|
825
|
+
|
|
826
|
+
if (offenses.length) {
|
|
827
|
+
console.error(`doc-format: ${relPath} has ${offenses.length} format finding(s):`);
|
|
828
|
+
console.error(formatOffense({ offenses }));
|
|
829
|
+
process.exit(2);
|
|
830
|
+
}
|
|
831
|
+
process.exit(0);
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
836
|
+
if (isMain) {
|
|
837
|
+
if (process.argv.includes('--hook')) {
|
|
838
|
+
runHook();
|
|
839
|
+
} else if (process.argv.includes('--files')) {
|
|
840
|
+
// `--files a.md b.md` — everything after the flag that isn't itself a flag. Checked BEFORE
|
|
841
|
+
// `--check` so `--check --files …` reads naturally and still takes the cheap path.
|
|
842
|
+
const i = process.argv.indexOf('--files');
|
|
843
|
+
const paths = process.argv.slice(i + 1).filter((a) => !a.startsWith('-'));
|
|
844
|
+
if (!paths.length) {
|
|
845
|
+
console.log('doc-format: --files given no paths — nothing to check.');
|
|
846
|
+
process.exit(0);
|
|
847
|
+
}
|
|
848
|
+
runCheckFiles(paths);
|
|
849
|
+
} else if (process.argv.includes('--check')) {
|
|
850
|
+
runCheck();
|
|
851
|
+
} else if (process.argv.includes('--fix')) {
|
|
852
|
+
runFix();
|
|
853
|
+
} else {
|
|
854
|
+
runReport();
|
|
855
|
+
}
|
|
856
|
+
}
|