dflow-sdd-ddd 0.15.0 → 0.16.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CONTRIBUTING.md +1 -0
  3. package/README.en.md +7 -4
  4. package/README.md +7 -4
  5. package/bin/dflow.js +75 -0
  6. package/docs/closeout-check.en.md +204 -0
  7. package/docs/closeout-check.md +204 -0
  8. package/docs/commands.en.md +4 -2
  9. package/docs/commands.md +4 -2
  10. package/docs/release-versioning-policy.md +1 -0
  11. package/lib/closeout-check.js +690 -0
  12. package/lib/doc-shapes.json +198 -0
  13. package/lib/init.js +17 -2
  14. package/package.json +2 -2
  15. package/templates/brownfield/references/finish-feature-flow.md +29 -26
  16. package/templates/brownfield/references/finish-feature-minimal-host.md +38 -17
  17. package/templates/brownfield/references/finish-feature-record-correction.md +45 -0
  18. package/templates/brownfield/references/git-integration.md +62 -28
  19. package/templates/brownfield/references/modify-existing-flow.md +55 -29
  20. package/templates/brownfield/references/modify-existing-follow-up.md +2 -1
  21. package/templates/brownfield/references/modify-existing-post-hoc-hotfix.md +121 -3
  22. package/templates/brownfield/references/new-feature-flow.md +49 -31
  23. package/templates/brownfield/references/new-phase-flow.md +2 -2
  24. package/templates/brownfield/references/pr-review-checklist.md +29 -4
  25. package/templates/brownfield/scaffolding/Git-principles-gitflow.md +13 -5
  26. package/templates/brownfield/scaffolding/Git-principles-trunk.md +8 -4
  27. package/templates/brownfield/templates/_index.md +17 -13
  28. package/templates/common/references/flow-rationale-registry.md +20 -4
  29. package/templates/greenfield/references/finish-feature-flow.md +29 -26
  30. package/templates/greenfield/references/finish-feature-minimal-host.md +36 -16
  31. package/templates/greenfield/references/finish-feature-record-correction.md +45 -0
  32. package/templates/greenfield/references/git-integration.md +63 -29
  33. package/templates/greenfield/references/modify-existing-flow.md +42 -27
  34. package/templates/greenfield/references/modify-existing-follow-up.md +2 -1
  35. package/templates/greenfield/references/modify-existing-post-hoc-hotfix.md +121 -3
  36. package/templates/greenfield/references/new-feature-flow.md +49 -31
  37. package/templates/greenfield/references/new-phase-flow.md +2 -2
  38. package/templates/greenfield/references/pr-review-checklist.md +28 -4
  39. package/templates/greenfield/scaffolding/Git-principles-gitflow.md +13 -5
  40. package/templates/greenfield/scaffolding/Git-principles-trunk.md +8 -4
  41. package/templates/greenfield/templates/_index.md +18 -13
@@ -0,0 +1,690 @@
1
+ 'use strict';
2
+
3
+ // PROPOSAL-105 — `dflow check-closeout`, the closeout gate a pre-commit hook or a
4
+ // CI job runs (dist issue #5). It answers one question with its exit code: did
5
+ // this change archive a feature directory into `features/completed/` in a state
6
+ // the record contradicts — the directory moved while the `_index.md` inside it
7
+ // still said `in-progress`, a copy left behind in `active/`, no closeout row?
8
+ //
9
+ // ⚠ Why this is not a `dflow doctor` check: doctor exits 0 by design (it reports
10
+ // by exception, and projects already run it in CI), and it reads the working
11
+ // tree — the one side #5's symptom does not show on. There the working tree was
12
+ // right and the index was stale. So this reads git objects: the index for
13
+ // `--staged`, `<head>`'s tree for `--range`. The working tree is read once, by
14
+ // check 5, and nothing is ever written.
15
+ //
16
+ // ⚠ What it does NOT check is stated in `dflow check-closeout --help` and in
17
+ // `docs/closeout-check.md` / `.en.md`: the rest of finish-feature Step 4's
18
+ // post-commit verification needs the closeout baseline and judgement, and stays
19
+ // with the AI and pr-review. Passing here does not mean the closeout is clean.
20
+ //
21
+ // A pre-commit hook runs this on every closeout commit, so the number of git
22
+ // processes is kept down on purpose (each costs tens of milliseconds on Windows):
23
+ // one `rev-parse` for the project, one listing per tree, one `cat-file --batch`
24
+ // for every file read, one `git status` for every archived directory.
25
+
26
+ const { spawnSync } = require('node:child_process');
27
+ const fs = require('node:fs/promises');
28
+ const path = require('node:path');
29
+
30
+ const pkg = require('../package.json');
31
+ const doctorChecks = require('./doctor-checks');
32
+ const { readDocShapesRegistry, WORKFLOW_BUNDLE_GENERATED_MARKER } = require('./init');
33
+
34
+ const FEATURES_REL = 'dflow/specs/features';
35
+
36
+ // A mutable `blob/main` pointer, like doctor's uncertainty page, so the page
37
+ // inherits PROPOSAL-081's evergreen contract (MAINTAINERS.md § README Language
38
+ // Strategy): it must stay usable by every published CLI that prints this line.
39
+ const CLOSEOUT_CHECK_DOC_URL = 'https://github.com/weilung/dflow-sdd-ddd/blob/main/docs/closeout-check.en.md';
40
+
41
+ // Built from its code point: a literal byte-order mark in source is invisible in
42
+ // a diff and an editor may drop it.
43
+ const BYTE_ORDER_MARK = String.fromCharCode(0xfeff);
44
+
45
+ class GateError extends Error {}
46
+
47
+ // Which files in a host are specs, derived rather than listed: a template
48
+ // registered under `features/active/*/` whose skeleton carries a `status` field.
49
+ // Today that is the lightweight spec (`lightweight-*.md`, `BUG-*.md`) and the
50
+ // phase spec; greenfield's `aggregate-design.md` has no `status`, so it is not
51
+ // one. `_index.md` is left to check 1, which says more about it. A doc a project
52
+ // adds to a host is not a spec, whether or not it carries `status:` — the
53
+ // measured case is a project doc whose `status:` is `ready…`.
54
+ // ⚠ Any registered shape counts, not only the newest: if a later shape drops the
55
+ // field, a spec without it reads as uncertain here instead of going unchecked.
56
+ // ⚠ The registry is read, and trusted, by doctor's own rules (`readDocShapesRegistry`
57
+ // in `lib/init.js`): valid JSON is not enough — a registry that lost one template's
58
+ // entry still parses, and would drop that template's specs out of check 3. A
59
+ // damaged package stops the gate; passing without check 3 would be a silent pass.
60
+ // ⚠ Read when the gate runs, never when this module loads: `bin/dflow.js` loads
61
+ // this module only for `check-closeout`, and `dflow doctor` has to start on a
62
+ // damaged package and report it (PROPOSAL-092).
63
+ let specFileGlobCache = null;
64
+ async function specFileGlobs() {
65
+ if (specFileGlobCache) return specFileGlobCache;
66
+ const damaged = (why) => new GateError(`The installed dflow package looks incomplete: ${why}. The gate cannot tell which files in a host are specs. Reinstall dflow (e.g. \`npm install -g dflow-sdd-ddd@latest\`, or re-link your local checkout); this is a problem with the installed package, not with your project.`);
67
+ const { registry, packageDamage } = await readDocShapesRegistry();
68
+ if (!registry) throw damaged(packageDamage.join('; '));
69
+ const globs = new Set();
70
+ for (const entry of Object.values(registry.templates)) {
71
+ const carriesStatus = entry.shapes.some((shape) => shape.skeleton.some(
72
+ (item) => item[0] === 'field' && item[1] === 'status' && item.length === 2
73
+ ));
74
+ if (!carriesStatus) continue;
75
+ for (const pattern of entry.paths) {
76
+ const match = /^features\/active\/\*\/([^/]+)$/.exec(pattern);
77
+ if (match && match[1] !== '_index.md') globs.add(match[1]);
78
+ }
79
+ }
80
+ if (globs.size === 0) throw damaged('`lib/doc-shapes.json` names no template under `features/active/*/` that carries a `status` field');
81
+ specFileGlobCache = [...globs].sort();
82
+ return specFileGlobCache;
83
+ }
84
+
85
+ function parseArgs(args) {
86
+ let mode = null;
87
+ let range = null;
88
+ for (let i = 0; i < args.length; i += 1) {
89
+ const arg = args[i];
90
+ if (arg === '--staged') {
91
+ if (mode === 'range') throw new GateError('--staged and --range cannot be combined.');
92
+ mode = 'staged';
93
+ continue;
94
+ }
95
+ if (arg === '--range' || arg.startsWith('--range=')) {
96
+ if (mode === 'staged') throw new GateError('--staged and --range cannot be combined.');
97
+ if (mode === 'range') throw new GateError('--range can be given only once.');
98
+ let value;
99
+ if (arg === '--range') {
100
+ i += 1;
101
+ value = i < args.length ? args[i] : '';
102
+ } else {
103
+ value = arg.slice('--range='.length);
104
+ }
105
+ // `A..B` only. `A...B` would mean the same here (the range is always taken
106
+ // from the merge base), but accepting it means documenting two spellings of
107
+ // one thing; refusing it costs the caller one character.
108
+ const match = /^([^.].*?)\.\.([^.].*)$/.exec(value);
109
+ if (!match || match[1].endsWith('.') || match[2].includes('..')) {
110
+ throw new GateError(`--range needs <base>..<head> (two commits joined by two dots), got ${JSON.stringify(value)}.`);
111
+ }
112
+ mode = 'range';
113
+ range = { base: match[1], head: match[2] };
114
+ continue;
115
+ }
116
+ throw new GateError(`Unsupported check-closeout option: ${arg}`);
117
+ }
118
+ return mode === 'range' ? { mode, ...range } : { mode: 'staged' };
119
+ }
120
+
121
+ // Every git call goes through here.
122
+ // ⚠ `GIT_LITERAL_PATHSPECS`: a host directory name is user text, and `[`, `*` or
123
+ // `:(` in it must not turn a path into a pattern.
124
+ // ⚠ `GIT_OPTIONAL_LOCKS=0`: inside a pre-commit hook `git commit` holds the index
125
+ // lock, and `git status` would otherwise try to refresh the index it is reading.
126
+ // ⚠ Output stays bytes and is decoded as UTF-8 by the callers: with `-z` git
127
+ // prints path names raw, and a host name can be Chinese.
128
+ // The index a hook sees is `GIT_INDEX_FILE`, which `git commit` sets (to a path
129
+ // relative to the repository root on a plain commit — git resolves it after
130
+ // moving to the root, so running from anywhere inside the repository is safe);
131
+ // it reaches these calls because the environment is passed through.
132
+ function runGit(cwd, args, input) {
133
+ const result = spawnSync('git', args, {
134
+ cwd,
135
+ input,
136
+ env: { ...process.env, GIT_LITERAL_PATHSPECS: '1', GIT_OPTIONAL_LOCKS: '0' },
137
+ maxBuffer: 256 * 1024 * 1024,
138
+ windowsHide: true
139
+ });
140
+ if (result.error) {
141
+ if (result.error.code === 'ENOENT') throw new GateError('git was not found on PATH; dflow check-closeout reads the repository through it.');
142
+ throw result.error;
143
+ }
144
+ return { status: result.status, stdout: result.stdout, stderr: result.stderr.toString('utf8').trim() };
145
+ }
146
+
147
+ function gitOut(cwd, args, input) {
148
+ const result = runGit(cwd, args, input);
149
+ if (result.status !== 0) {
150
+ throw new GateError(`git ${args.join(' ')} failed${result.stderr ? `: ${result.stderr}` : '.'}`);
151
+ }
152
+ return result.stdout;
153
+ }
154
+
155
+ function zRecords(buffer) {
156
+ return buffer.toString('utf8').split('\0').filter((record) => record !== '');
157
+ }
158
+
159
+ function firstLine(buffer) {
160
+ return buffer.toString('utf8').replace(/\r?\n[\s\S]*$/, '');
161
+ }
162
+
163
+ async function locateProject(cwd) {
164
+ let stat = null;
165
+ try {
166
+ stat = await fs.stat(path.join(cwd, 'dflow', 'specs'));
167
+ } catch (error) {
168
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
169
+ }
170
+ if (!stat || !stat.isDirectory()) {
171
+ throw new GateError(`No dflow/specs/ directory in ${cwd}. Run dflow check-closeout from the directory that holds dflow/specs/ — when the Dflow project is not at the repository root, cd into it first.`);
172
+ }
173
+ const located = runGit(cwd, ['rev-parse', '--show-toplevel', '--show-prefix']);
174
+ if (located.status !== 0) {
175
+ throw new GateError(`${cwd} is not inside a git work tree${located.stderr ? ` (${located.stderr})` : ''}. dflow check-closeout reads the commit being made, so it needs the repository.`);
176
+ }
177
+ // The project can sit below the repository root (a monorepo); every path below
178
+ // is repository-relative, so the project's own position is the prefix. An
179
+ // empty second line is a project at the root.
180
+ const [toplevel, prefix = ''] = located.stdout.toString('utf8').split('\n');
181
+ return { toplevel, projectPrefix: prefix, featuresRel: `${prefix}${FEATURES_REL}` };
182
+ }
183
+
184
+ function resolveCommit(ctx, rev, label) {
185
+ const result = runGit(ctx.toplevel, ['rev-parse', '--verify', '-q', '--end-of-options', `${rev}^{commit}`]);
186
+ if (result.status !== 0) {
187
+ throw new GateError(`${label} ${JSON.stringify(rev)} is not a commit in this repository. In CI this is usually a shallow clone: fetch the full history (GitLab: GIT_DEPTH: 0; GitHub Actions: fetch-depth: 0).`);
188
+ }
189
+ return firstLine(result.stdout);
190
+ }
191
+
192
+ // `path -> blob id` for every file under `features/` in a commit, keyed relative
193
+ // to `features/` (`completed/<host>/_index.md`). A commit with no `features/`
194
+ // tree gives an empty map, so the first archive in a project is still in scope.
195
+ // ⚠ Only a MISSING path reads as empty. The commit is listed from its root with
196
+ // the path as a filter: a path the commit does not have prints nothing and
197
+ // succeeds, while a tree git cannot read fails. (Listing `<commit>:<path>` fails
198
+ // in both cases, and a probe of that path cannot tell a missing path from a
199
+ // missing object either — an empty map standing in for a tree git could not read
200
+ // takes every host out of scope and passes.)
201
+ function commitFeatures(ctx, commit) {
202
+ const files = new Map();
203
+ if (!commit) return files;
204
+ const lead = `${ctx.featuresRel}/`;
205
+ for (const record of zRecords(gitOut(ctx.toplevel, ['ls-tree', '-r', '-z', commit, '--', lead]))) {
206
+ const tab = record.indexOf('\t');
207
+ const [, kind, oid] = record.slice(0, tab).split(' ');
208
+ const file = record.slice(tab + 1);
209
+ if (kind === 'blob' && file.startsWith(lead)) files.set(file.slice(lead.length), oid);
210
+ }
211
+ return files;
212
+ }
213
+
214
+ // The same map for the index: what `git commit` is about to record. Only stage-0
215
+ // entries — git refuses to commit while a path is unmerged, so there is nothing
216
+ // to judge until the conflict is resolved.
217
+ function indexFeatures(ctx) {
218
+ const files = new Map();
219
+ const lead = `${ctx.featuresRel}/`;
220
+ for (const record of zRecords(gitOut(ctx.toplevel, ['ls-files', '--stage', '-z', '--', lead]))) {
221
+ const tab = record.indexOf('\t');
222
+ const [, oid, stage] = record.slice(0, tab).split(' ');
223
+ const file = record.slice(tab + 1);
224
+ if (stage === '0' && file.startsWith(lead)) files.set(file.slice(lead.length), oid);
225
+ }
226
+ return files;
227
+ }
228
+
229
+ // Directory names under `completed/` (or `active/`) that hold at least one file.
230
+ // A file directly under `completed/` — the `.gitkeep` `dflow init` writes — is
231
+ // not a host.
232
+ function hostDirs(files, area) {
233
+ const dirs = new Set();
234
+ const lead = `${area}/`;
235
+ for (const file of files) {
236
+ if (!file.startsWith(lead)) continue;
237
+ const slash = file.indexOf('/', lead.length);
238
+ if (slash > lead.length) dirs.add(file.slice(lead.length, slash));
239
+ }
240
+ return dirs;
241
+ }
242
+
243
+ // D1's scope: a directory with files under `completed/` after the change and
244
+ // none before. Only whether the directory has files on either side counts, never
245
+ // whether git pairs its files as `R` or as `D` + `A` — git pairs a rename by
246
+ // content similarity, so a small file edited heavily comes out as `D` + `A`.
247
+ // 1. the host was in `active/` under the same name -> newly archived: check it.
248
+ // "Was in" is `wasActive`: the `active/` directories of the before side,
249
+ // plus, for a range, every one a commit in the range touched.
250
+ // 2. otherwise -> uncertain. The flow moves a host out of `active/` without
251
+ // renaming it, so a directory renamed on the way, one created directly in
252
+ // `completed/`, and an archived host renamed inside `completed/` all land
253
+ // here: the gate does not guess which of these it is, and a person confirms.
254
+ function newArchives(before, after, wasActive) {
255
+ const beforeCompleted = hostDirs(before.keys(), 'completed');
256
+ const appeared = [...hostDirs(after.keys(), 'completed')].filter((dir) => !beforeCompleted.has(dir)).sort();
257
+ return {
258
+ archived: appeared.filter((dir) => wasActive.has(dir)),
259
+ unplaced: appeared.filter((dir) => !wasActive.has(dir))
260
+ };
261
+ }
262
+
263
+ // `--staged`: after = the index; before = HEAD, or the empty tree before the
264
+ // first commit. ⚠ The hosts HEAD itself archived are in scope too, judged by
265
+ // rule 1 alone against HEAD's parent. A pre-commit hook cannot tell `git commit
266
+ // --amend` from a new commit, and a minimal host repairs its closeout commit
267
+ // exactly by re-adding and amending (finish-feature Step 4); compared with HEAD
268
+ // alone, a host already in HEAD would never be checked on that amend. The cost:
269
+ // the commit after a closeout checks that host once more, and passes when it is
270
+ // still consistent. Not for a merge commit — a host someone else archived and
271
+ // merged in would block your next commit.
272
+ function stagedScope(ctx) {
273
+ const after = indexFeatures(ctx);
274
+ let head = null;
275
+ let parent = null;
276
+ const revs = runGit(ctx.toplevel, ['rev-list', '--parents', '-n', '1', 'HEAD', '--']);
277
+ if (revs.status === 0) {
278
+ const [commit, ...parents] = firstLine(revs.stdout).split(' ');
279
+ head = commit;
280
+ if (parents.length === 1) parent = parents[0];
281
+ } else if (runGit(ctx.toplevel, ['rev-parse', '--verify', '-q', 'HEAD']).status === 0) {
282
+ throw new GateError(`git rev-list --parents -n 1 HEAD failed${revs.stderr ? `: ${revs.stderr}` : '.'}`);
283
+ }
284
+ const headFiles = commitFeatures(ctx, head);
285
+ const current = newArchives(headFiles, after, hostDirs(headFiles.keys(), 'active'));
286
+ const stillArchived = hostDirs(after.keys(), 'completed');
287
+ let amended = [];
288
+ if (parent) {
289
+ const parentFiles = commitFeatures(ctx, parent);
290
+ amended = newArchives(parentFiles, headFiles, hostDirs(parentFiles.keys(), 'active'))
291
+ .archived.filter((dir) => stillArchived.has(dir));
292
+ }
293
+ return {
294
+ after,
295
+ archived: [...new Set([...current.archived, ...amended])].sort(),
296
+ unplaced: current.unplaced
297
+ };
298
+ }
299
+
300
+ // `--range <base>..<head>`: after = `<head>`'s tree; before = the merge base.
301
+ // The FINAL state of `<head>`, not each commit on the way: a phase-bearing host
302
+ // whose closeout failed verification may be repaired by a later commit
303
+ // (finish-feature Step 4), and checking commit by commit would block a closeout
304
+ // that is already fixed.
305
+ // ⚠ Rule 1 cannot look at the merge base alone. A feature branch usually opens
306
+ // its host AND archives it, so at the merge base `active/<host>/` does not exist
307
+ // yet, and every ordinary merge request would read as uncertain. So a host counts
308
+ // as moved from `active/` when it was there at the merge base or when any commit
309
+ // in the range touched a path under `active/<host>/` — which it can only do while
310
+ // the directory has files. `--full-history`, so that a host opened on a side
311
+ // branch merged into this one is still seen.
312
+ function rangeScope(ctx, options) {
313
+ const base = resolveCommit(ctx, options.base, '--range base');
314
+ const head = resolveCommit(ctx, options.head, '--range head');
315
+ const mergeBaseRun = runGit(ctx.toplevel, ['merge-base', base, head]);
316
+ if (mergeBaseRun.status !== 0) {
317
+ throw new GateError(`${options.base} and ${options.head} have no merge base. In CI this is usually a shallow clone: fetch the full history (GitLab: GIT_DEPTH: 0; GitHub Actions: fetch-depth: 0).`);
318
+ }
319
+ const mergeBase = firstLine(mergeBaseRun.stdout);
320
+ const before = commitFeatures(ctx, mergeBase);
321
+ const after = commitFeatures(ctx, head);
322
+ const lead = `${ctx.featuresRel}/`;
323
+ const touched = zRecords(gitOut(ctx.toplevel, ['log', '--full-history', '--format=', '--name-only', '-z', `${mergeBase}..${head}`, '--', `${lead}active/`]))
324
+ .map((file) => file.replace(/^\n+/, ''))
325
+ .filter((file) => file.startsWith(lead))
326
+ .map((file) => file.slice(lead.length));
327
+ const wasActive = new Set([...hostDirs(before.keys(), 'active'), ...hostDirs(touched, 'active')]);
328
+ const { archived, unplaced } = newArchives(before, after, wasActive);
329
+ return { after, archived, unplaced, head, mergeBase };
330
+ }
331
+
332
+ // Every blob the checks read, in one `git cat-file --batch`. A missing object
333
+ // maps to null, which the checks report as unreadable rather than passing.
334
+ function readBlobs(ctx, oids) {
335
+ const texts = new Map();
336
+ const wanted = [...new Set(oids)];
337
+ if (wanted.length === 0) return texts;
338
+ const out = gitOut(ctx.toplevel, ['cat-file', '--batch'], `${wanted.join('\n')}\n`);
339
+ let at = 0;
340
+ for (const oid of wanted) {
341
+ const eol = out.indexOf(0x0a, at);
342
+ const header = out.subarray(at, eol).toString('utf8').split(' ');
343
+ at = eol + 1;
344
+ if (header[1] !== 'blob') {
345
+ texts.set(oid, null);
346
+ if (header[1] !== 'missing' && header[2] !== undefined) at += Number(header[2]) + 1;
347
+ continue;
348
+ }
349
+ const size = Number(header[2]);
350
+ let text = out.subarray(at, at + size).toString('utf8');
351
+ at += size + 1;
352
+ // A byte-order mark is a standard shape here, not an unreadable one.
353
+ if (text.startsWith(BYTE_ORDER_MARK)) text = text.slice(1);
354
+ texts.set(oid, text);
355
+ }
356
+ return texts;
357
+ }
358
+
359
+ // The frontmatter's `status` value, read by the rule `dflow render` and doctor
360
+ // use (the document's first line starts with `---`, the block ends at the next
361
+ // line that is `---` once trimmed; a field is the text before a line's first
362
+ // `:`, on a line not starting with `#`), with the value's trailing ` # comment`
363
+ // and surrounding quotes dropped. CRLF lines read the same as LF (the caller has
364
+ // already dropped a leading byte-order mark). Anything else is a shape this gate
365
+ // will not guess about.
366
+ function frontmatterStatus(text) {
367
+ const lines = text.split('\n');
368
+ const count = doctorChecks.frontmatterLineCount(lines);
369
+ if (count === 0) return { state: 'no-frontmatter' };
370
+ const values = [];
371
+ for (const raw of lines.slice(1, count - 1)) {
372
+ const line = raw.replace(/\r$/, '');
373
+ if (line.trimStart().startsWith('#')) continue;
374
+ const colon = line.indexOf(':');
375
+ if (colon === -1 || line.slice(0, colon).trim() !== 'status') continue;
376
+ const value = line.slice(colon + 1).replace(/\s+#.*$/, '').trim();
377
+ const quoted = /^"(.*)"$/.exec(value) || /^'(.*)'$/.exec(value);
378
+ values.push(quoted ? quoted[1] : value);
379
+ }
380
+ if (values.length === 1) return { state: 'ok', value: values[0] };
381
+ return { state: values.length === 0 ? 'no-status' : 'many-status', count: values.length };
382
+ }
383
+
384
+ function tableCells(line) {
385
+ let row = line.replace(/<!--[\s\S]*?-->/g, '').trim();
386
+ if (row.startsWith('|')) row = row.slice(1);
387
+ if (row.endsWith('|') && !row.endsWith('\\|')) row = row.slice(0, -1);
388
+ return row.split(/(?<!\\)\|/).map((cell) => cell.trim());
389
+ }
390
+
391
+ // The table under `## Checkpoint Log`, found with doctor's own block classifier
392
+ // so that a table-shaped line inside a `>` note, an HTML comment or a fenced
393
+ // example is not mistaken for the log — the template's notes quote
394
+ // `closeout | committed` in exactly such places.
395
+ function checkpointLog(text) {
396
+ const lines = text.split('\n');
397
+ const body = doctorChecks.blankFencedBlocks(lines.slice(doctorChecks.frontmatterLineCount(lines)).join('\n'));
398
+ const classes = doctorChecks.classifyLines(body);
399
+ let sections = 0;
400
+ let inside = false;
401
+ let table = null;
402
+ for (let i = 0; i < body.length; i += 1) {
403
+ const c = classes[i];
404
+ if (c.heading && c.heading.level <= 2) {
405
+ inside = c.heading.level === 2 && c.heading.text.replace(/\s+/g, ' ').trim().toLowerCase() === 'checkpoint log';
406
+ if (inside) sections += 1;
407
+ continue;
408
+ }
409
+ if (!inside || table || c.type !== 'table') continue;
410
+ let end = i;
411
+ while (end + 1 < body.length && classes[end + 1].type === 'table') end += 1;
412
+ table = { header: tableCells(body[i]), rows: body.slice(i + 2, end + 1).map(tableCells) };
413
+ i = end;
414
+ }
415
+ return { sections, table };
416
+ }
417
+
418
+ function relFile(host, name) {
419
+ return `${FEATURES_REL}/completed/${host}/${name}`;
420
+ }
421
+
422
+ function finding(level, title, detail, action) {
423
+ return { level, title, detail, action };
424
+ }
425
+
426
+ const CONFIRM_BY_HAND = 'If you read it and it is right, see the docs page below for letting a checked commit through.';
427
+
428
+ function statusFindings(host, name, text, what) {
429
+ const file = relFile(host, name);
430
+ if (text === null) {
431
+ return [finding('uncertain', `${file} could not be read from git.`,
432
+ 'The gate will not report a file it could not read as passing.',
433
+ `Check that ${file} is a regular file, then run the check again.`)];
434
+ }
435
+ const status = frontmatterStatus(text);
436
+ if (status.state === 'ok') {
437
+ if (status.value === 'completed') return [];
438
+ return [finding('fail', `${file} says \`status: ${status.value}\` — ${what} must say \`completed\` when the host is archived.`,
439
+ name === '_index.md'
440
+ ? 'The directory moved to completed/, but the record inside it does not say the feature is done (finish-feature Step 2 flips it).'
441
+ : 'finish-feature Step 1 requires every spec in the host to read `status: completed` before the host is archived.',
442
+ `Set \`status: completed\` in that file and stage it (git add ${file}). If your working tree already says \`completed\`, the edit simply is not staged.`)];
443
+ }
444
+ // A document copied whole from a template's projected copy starts with the
445
+ // bundle's own line, and the frontmatter under it is not frontmatter — to
446
+ // `dflow render` and doctor either. The cause and the repair are known, so it is
447
+ // a failure with its fix, not a shape left for someone to confirm; and after
448
+ // archiving nothing reads the document again, so this is the last place to
449
+ // catch it.
450
+ if (status.state === 'no-frontmatter' && text.split('\n')[0].replace(/\r$/, '').trim() === WORKFLOW_BUNDLE_GENERATED_MARKER) {
451
+ return [finding('fail', `${file}: line 1 is \`${WORKFLOW_BUNDLE_GENERATED_MARKER}\`, copied from the template's projected copy, so the frontmatter under it — \`status\` included — is not read as frontmatter.`,
452
+ 'The projected templates under dflow/specs/shared/dflow-workflows/templates/ carry that line; a document created from one must not. `dflow render` and `dflow doctor` do not read frontmatter under it either.',
453
+ `Delete that line and the blank line after it, then stage the file (git add ${file}).`)];
454
+ }
455
+ const shape = status.state === 'no-frontmatter'
456
+ ? 'it has no frontmatter block (a first line `---`, closed by another `---` line)'
457
+ : status.state === 'no-status'
458
+ ? 'its frontmatter has no `status:` field'
459
+ : `its frontmatter has ${status.count} \`status:\` fields`;
460
+ return [finding('uncertain', `${file}: cannot read its status — ${shape}.`,
461
+ `The gate reads \`status\` only from the template's frontmatter shape, and will not guess where else ${what} might say it.`,
462
+ `Confirm by hand that ${file} says the work is completed, and restore the template's \`status: completed\` frontmatter line so the next run can read it. ${CONFIRM_BY_HAND}`)];
463
+ }
464
+
465
+ function closeoutRowFindings(host, text) {
466
+ const file = relFile(host, '_index.md');
467
+ const log = checkpointLog(text);
468
+ const confirm = `Confirm by hand that the Checkpoint Log in ${file} records the closeout as \`committed\` (the AI commits) or \`skipped\` (you commit yourself).`;
469
+ if (log.sections !== 1) {
470
+ return [finding('uncertain',
471
+ log.sections === 0 ? `${file} has no \`## Checkpoint Log\` section.` : `${file} has ${log.sections} \`## Checkpoint Log\` sections.`,
472
+ 'The closeout row is read only from the one `## Checkpoint Log` table the template carries.',
473
+ `${confirm} ${CONFIRM_BY_HAND}`)];
474
+ }
475
+ const header = log.table ? log.table.header.map((cell) => cell.toLowerCase()) : [];
476
+ const col = { checkpoint: header.indexOf('checkpoint'), result: header.indexOf('result'), timestamp: header.indexOf('timestamp') };
477
+ if (!log.table || col.checkpoint === -1 || col.result === -1) {
478
+ return [finding('uncertain', `${file}: the \`## Checkpoint Log\` section has no table with \`Checkpoint\` and \`Result\` columns.`,
479
+ 'The closeout row is read only from the template\'s table shape.',
480
+ `${confirm} ${CONFIRM_BY_HAND}`)];
481
+ }
482
+ const real = [];
483
+ const odd = [];
484
+ for (const row of log.table.rows) {
485
+ const checkpoint = row[col.checkpoint] || '';
486
+ const result = (row[col.result] || '').replace(/\s+/g, ' ');
487
+ const timestamp = col.timestamp === -1 ? '' : (row[col.timestamp] || '');
488
+ if (checkpoint === 'closeout') {
489
+ // The template carries a placeholder closeout row of its own. Without this
490
+ // exclusion that row alone would satisfy the check on every host.
491
+ if (/^\{[^}]*\}$/.test(timestamp) || result === 'committed / skipped / failed') continue;
492
+ real.push(result);
493
+ } else if (/closeout/i.test(checkpoint)) {
494
+ odd.push(checkpoint);
495
+ }
496
+ }
497
+ if (real.length === 0) {
498
+ if (odd.length > 0) {
499
+ return [finding('uncertain', `${file}: a Checkpoint Log row mentions closeout, but not in the template's shape (Checkpoint cell ${JSON.stringify(odd[0])}).`,
500
+ 'The gate reads a row whose Checkpoint cell is exactly `closeout`. An older way of writing it — the explanation and the checkpoint in one cell — is not one it will guess about.',
501
+ `Rewrite the row as Checkpoint \`closeout\` with Result \`committed\` or \`skipped\`, keeping any explanation out of those two cells, and stage ${file}. ${CONFIRM_BY_HAND}`)];
502
+ }
503
+ return [finding('fail', `${file}: the Checkpoint Log has no closeout row.`,
504
+ 'finish-feature Step 4 instruction 1 writes it before the closeout commit. The template\'s own placeholder row (`{YYYY-MM-DD HH:MM}` / `committed / skipped / failed`) does not count.',
505
+ `Add a row with Checkpoint \`closeout\` and Result \`committed\` (the AI commits) or \`skipped\` (you commit yourself), then stage ${file}.`)];
506
+ }
507
+ if (real.includes('failed')) {
508
+ return [finding('fail', `${file}: the closeout row says \`failed\`, in the commit it describes.`,
509
+ 'This usually means an earlier attempt was refused by a hook and finish-feature Step 4 instruction 3 flipped the row to `failed`. The commit you are making now would record that its own commit failed.',
510
+ `Change the Result back to \`committed\` (the AI commits) or \`skipped\` (you commit yourself), then stage ${file}.`)];
511
+ }
512
+ const unknown = real.find((result) => result !== 'committed' && result !== 'skipped');
513
+ if (unknown !== undefined) {
514
+ return [finding('uncertain', `${file}: the closeout row's Result is ${JSON.stringify(unknown)}, not \`committed\` or \`skipped\`.`,
515
+ 'The closeout row carries no commit hash (finish-feature Step 4 instruction 1), and the gate reads only those two values.',
516
+ `Write \`committed\` (the AI commits) or \`skipped\` (you commit yourself) and stage ${file}. ${CONFIRM_BY_HAND}`)];
517
+ }
518
+ return [];
519
+ }
520
+
521
+ // Check 5, `--staged` only: nothing under an archived directory left out of the
522
+ // commit — a tracked file modified or deleted without being staged (#5's `RM`
523
+ // signal), or a new file not yet added and not ignored. The same condition as
524
+ // Step 4's post-commit "working tree clean", taken before the commit instead.
525
+ // One `git status` for all the hosts; its entries are sorted back to each host.
526
+ function unstagedByHost(ctx, hosts) {
527
+ const byHost = new Map(hosts.map((host) => [host, []]));
528
+ if (hosts.length === 0) return byHost;
529
+ const lead = `${ctx.featuresRel}/completed/`;
530
+ const records = zRecords(gitOut(ctx.toplevel, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...hosts.map((host) => `${lead}${host}/`)]));
531
+ for (let i = 0; i < records.length; i += 1) {
532
+ const record = records[i];
533
+ const code = record.slice(0, 2);
534
+ const file = record.slice(3);
535
+ // A rename or copy entry is followed by its source path as the next record.
536
+ if (/[RC]/.test(code)) i += 1;
537
+ if (code[1] === ' ' || !file.startsWith(lead)) continue;
538
+ const host = file.slice(lead.length, file.indexOf('/', lead.length));
539
+ if (byHost.has(host)) byHost.get(host).push(`${code === '??' ? 'untracked' : 'not staged'}: ${file.slice(ctx.projectPrefix.length)}`);
540
+ }
541
+ return byHost;
542
+ }
543
+
544
+ function unstagedFindings(host, dirty) {
545
+ if (dirty.length === 0) return [];
546
+ const rel = `${FEATURES_REL}/completed/${host}`;
547
+ return [finding('fail', `${rel}/ has changes this commit would leave behind (${dirty.length}): ${dirty.join('; ')}.`,
548
+ 'git mv stages the move with the last committed content, so every edit made to the moved files afterwards stays out of the commit until it is added — finish-feature Step 4 instruction 2.',
549
+ `Stage the archived directory (git add ${rel}), or remove what does not belong in it, then commit again.`)];
550
+ }
551
+
552
+ // The top-level files of an archived host the checks read: `_index.md` and the
553
+ // specs.
554
+ function hostFiles(host, after, globs) {
555
+ const inHost = `completed/${host}/`;
556
+ const names = [...after.keys()].filter((file) => file.startsWith(inHost)).map((file) => file.slice(inHost.length));
557
+ return {
558
+ names,
559
+ specs: names.filter((name) => !name.includes('/') && globs.some((glob) => doctorChecks.shapePathMatches(glob, name)))
560
+ };
561
+ }
562
+
563
+ function checkHost(host, after, globs, texts, dirty) {
564
+ const findings = [];
565
+ const inHost = `completed/${host}/`;
566
+ const { names, specs } = hostFiles(host, after, globs);
567
+
568
+ // 1. `_index.md` exists and says `completed`; 4. its closeout row.
569
+ if (!names.includes('_index.md')) {
570
+ findings.push(finding('fail', `${relFile(host, '_index.md')} is missing.`,
571
+ 'An archived host keeps its `_index.md`: it is the record the move archives.',
572
+ 'Restore it — archive the whole directory with git mv (finish-feature Step 4).'));
573
+ } else {
574
+ const text = texts.get(after.get(`${inHost}_index.md`));
575
+ findings.push(...statusFindings(host, '_index.md', text, '`_index.md`'));
576
+ if (text !== null) findings.push(...closeoutRowFindings(host, text));
577
+ }
578
+
579
+ // 2. moved, not copied.
580
+ const leftover = [...after.keys()].filter((file) => file.startsWith(`active/${host}/`));
581
+ if (leftover.length > 0) {
582
+ const activeDir = `${FEATURES_REL}/active/${host}`;
583
+ findings.push(finding('fail', `${activeDir}/ still has ${leftover.length} file(s) — the host was copied to completed/, not moved.`,
584
+ 'Archiving is a git mv: the host leaves active/ in the same commit that adds it to completed/.',
585
+ `Remove the copy left in active/ (git rm -r -- ${activeDir}) after checking it holds nothing the archive lacks.`));
586
+ }
587
+
588
+ // 3. every spec says `completed`.
589
+ for (const name of specs) {
590
+ findings.push(...statusFindings(host, name, texts.get(after.get(`${inHost}${name}`)), 'a spec'));
591
+ }
592
+
593
+ // 5. `--staged` only (`dirty` is null for a range).
594
+ if (dirty) findings.push(...unstagedFindings(host, dirty));
595
+ return findings;
596
+ }
597
+
598
+ function unplacedFinding(host) {
599
+ const dir = `${FEATURES_REL}/completed/${host}/`;
600
+ return finding('uncertain', `${dir} is new, but was never in ${FEATURES_REL}/active/${host}/.`,
601
+ 'finish-feature moves a host out of active/ with git mv and never renames it, so this directory did not come from a closeout under its own name: it was renamed on the way, created directly in completed/, or it is an archived host renamed. The gate does not guess which.',
602
+ `Confirm by hand. If it is an archived host you renamed, check that the rename changed nothing else. Otherwise check what the five checks would: _index.md says \`status: completed\`; nothing for this host is left under active/; every spec in it says \`status: completed\`; the Checkpoint Log has a \`closeout\` row reading \`committed\` or \`skipped\`; nothing in the directory is left unstaged. ${CONFIRM_BY_HAND}`);
603
+ }
604
+
605
+ function writeFindings(stdout, list) {
606
+ for (const f of list) {
607
+ stdout.write(` [${f.level}] ${f.title}\n`);
608
+ stdout.write(` ${f.detail}\n`);
609
+ stdout.write(` ${f.action}\n`);
610
+ }
611
+ }
612
+
613
+ async function runCheckCloseout({ cwd, args = [], stdout, stderr }) {
614
+ try {
615
+ const options = parseArgs(args);
616
+ const globs = await specFileGlobs();
617
+ const ctx = await locateProject(path.resolve(cwd));
618
+ const staged = options.mode === 'staged';
619
+ const scope = staged ? stagedScope(ctx) : rangeScope(ctx, options);
620
+
621
+ stdout.write(`Dflow check-closeout ${pkg.version}\n`);
622
+ stdout.write(staged
623
+ ? 'Checking the index (what this commit will record) for feature directories newly archived to completed/.\n\n'
624
+ : `Checking ${options.head} (${scope.head.slice(0, 12)}) against its merge base with ${options.base} (${scope.mergeBase.slice(0, 12)}) for feature directories newly archived to completed/.\n\n`);
625
+
626
+ if (scope.archived.length === 0 && scope.unplaced.length === 0) {
627
+ stdout.write('No feature directory is newly archived here — nothing to check.\n');
628
+ return 0;
629
+ }
630
+
631
+ const oids = [];
632
+ for (const host of scope.archived) {
633
+ const { names, specs } = hostFiles(host, scope.after, globs);
634
+ for (const name of names.includes('_index.md') ? ['_index.md', ...specs] : specs) oids.push(scope.after.get(`completed/${host}/${name}`));
635
+ }
636
+ const texts = readBlobs(ctx, oids);
637
+ const dirty = staged ? unstagedByHost(ctx, scope.archived) : null;
638
+
639
+ let blocked = 0;
640
+ let problems = 0;
641
+ for (const host of scope.archived) {
642
+ const findings = checkHost(host, scope.after, globs, texts, dirty ? dirty.get(host) : null);
643
+ if (findings.length === 0) {
644
+ stdout.write(`[pass] ${FEATURES_REL}/completed/${host}/\n`);
645
+ continue;
646
+ }
647
+ blocked += 1;
648
+ problems += findings.length;
649
+ stdout.write(`[blocked] ${FEATURES_REL}/completed/${host}/\n`);
650
+ writeFindings(stdout, findings);
651
+ }
652
+ for (const host of scope.unplaced) {
653
+ blocked += 1;
654
+ problems += 1;
655
+ stdout.write(`[blocked] ${FEATURES_REL}/completed/${host}/\n`);
656
+ writeFindings(stdout, [unplacedFinding(host)]);
657
+ }
658
+
659
+ const total = scope.archived.length + scope.unplaced.length;
660
+ stdout.write('\n');
661
+ if (blocked === 0) {
662
+ stdout.write(`All ${total} newly archived feature director${total === 1 ? 'y' : 'ies'} passed.\n`);
663
+ stdout.write('This gate covers only what a machine can judge; the rest of finish-feature Step 4\'s verification and pr-review still apply (see dflow check-closeout --help).\n');
664
+ return 0;
665
+ }
666
+ stdout.write(`BLOCKED: ${blocked} of ${total} newly archived feature director${total === 1 ? 'y' : 'ies'}, ${problems} problem(s).\n`);
667
+ stdout.write('A [fail] names what is wrong and how to fix it. An [uncertain] blocks too: the gate will not report a shape it cannot read as passing.\n');
668
+ stdout.write(`What each result means, and how to proceed: ${CLOSEOUT_CHECK_DOC_URL} (offline copy: docs/closeout-check.en.md in the installed package).\n`);
669
+ return 1;
670
+ } catch (error) {
671
+ if (error instanceof GateError) {
672
+ stderr.write(`dflow check-closeout: ${error.message}\n`);
673
+ return 1;
674
+ }
675
+ throw error;
676
+ }
677
+ }
678
+
679
+ module.exports = {
680
+ runCheckCloseout,
681
+ // Exported for `--help`, which names the same page the report does.
682
+ CLOSEOUT_CHECK_DOC_URL,
683
+ // Exported for `--help` and the tests: the spec set is derived, so the help
684
+ // text and the fixtures read the same derivation instead of a copy.
685
+ specFileGlobs,
686
+ // Exported for tests: the frontmatter and Checkpoint Log readers are pinned
687
+ // directly on the standard shapes and on the ones that must read uncertain.
688
+ frontmatterStatus,
689
+ checkpointLog
690
+ };