dotmd-cli 0.72.1 → 0.74.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/bin/dotmd.mjs +29 -0
- package/package.json +1 -1
- package/src/commands.mjs +1 -0
- package/src/doctor.mjs +18 -8
- package/src/hub-membership.mjs +151 -0
- package/src/hub.mjs +270 -0
- package/src/index.mjs +23 -0
- package/src/render.mjs +10 -0
- package/src/runlist.mjs +6 -79
- package/src/sync-status.mjs +314 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -205,6 +205,7 @@ Validate & Fix:
|
|
|
205
205
|
self-check Project/version skew diagnostic (alias: doctor --project)
|
|
206
206
|
lint [--fix] Check and auto-fix frontmatter issues
|
|
207
207
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
208
|
+
sync-status [<hub>...] [--adopt] Rewrite hub table rows whose printed status drifted from the plan
|
|
208
209
|
|
|
209
210
|
Lifecycle:
|
|
210
211
|
use <file> Open a plan (mark in-session + print it) or consume a prompt
|
|
@@ -772,6 +773,29 @@ Modes:
|
|
|
772
773
|
Sub-modes (--statuses, --migrate-*, --frontmatter-fix, --project) keep their
|
|
773
774
|
existing contracts: they write by default and honor --dry-run.`,
|
|
774
775
|
|
|
776
|
+
'sync-status': `dotmd sync-status — rewrite hub rows whose printed status drifted
|
|
777
|
+
|
|
778
|
+
A runlist / coordination / roadmap hub rows its children in a table and prints
|
|
779
|
+
each child's status by hand. This sweeps every hub, compares each row's status
|
|
780
|
+
word against the plan it links to, and rewrites the ones that drifted. Case is
|
|
781
|
+
preserved (\`Active\` stays capitalized), and nothing else in the cell is touched.
|
|
782
|
+
|
|
783
|
+
dotmd sync-status every hub in the repo (the normal case)
|
|
784
|
+
dotmd sync-status <hub>... narrow to named hubs
|
|
785
|
+
dotmd sync-status --adopt also wrap managed status words in <!--s-->…<!--/s-->
|
|
786
|
+
dotmd sync-status --dry-run --json
|
|
787
|
+
|
|
788
|
+
The status word is found positionally — comments stripped, cell's leading token,
|
|
789
|
+
matched against the vocabulary the CHILD's type declares — so no marker is
|
|
790
|
+
needed. A marker pins the span for the rows position can't read (a status sitting
|
|
791
|
+
behind a bolded headline). \`dotmd check\` warns on positional drift and ERRORS on
|
|
792
|
+
marked drift: the marker is the author saying dotmd owns that word.
|
|
793
|
+
|
|
794
|
+
Rows under a status column with no readable status word are reported by
|
|
795
|
+
\`dotmd check\` and left alone here; rows in a table with no status column at all
|
|
796
|
+
are not findings. Not to be confused with \`dotmd set <status>\`, which changes a
|
|
797
|
+
document's OWN status — this only rewrites what a hub prints about others.`,
|
|
798
|
+
|
|
775
799
|
'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
|
|
776
800
|
|
|
777
801
|
Scans all docs for reference fields that point to non-existent files,
|
|
@@ -1701,6 +1725,7 @@ async function main() {
|
|
|
1701
1725
|
if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
|
|
1702
1726
|
if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
|
|
1703
1727
|
if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
|
|
1728
|
+
if (command === 'sync-status') { const { runSyncStatus } = await import('../src/sync-status.mjs'); await runSyncStatus(restArgs, config, { dryRun }); return; }
|
|
1704
1729
|
if (command === 'self-check') {
|
|
1705
1730
|
const { runDoctor } = await import('../src/doctor.mjs');
|
|
1706
1731
|
runDoctor(['--project', ...restArgs], config, { dryRun });
|
|
@@ -1811,8 +1836,12 @@ async function main() {
|
|
|
1811
1836
|
// Auto-fix: broken refs, then lint, then rebuild index
|
|
1812
1837
|
const { fixBrokenRefs } = await import('../src/fix-refs.mjs');
|
|
1813
1838
|
const { runLint } = await import('../src/lint.mjs');
|
|
1839
|
+
const { syncHubStatuses } = await import('../src/sync-status.mjs');
|
|
1814
1840
|
fixBrokenRefs(config, { dryRun, quiet: false });
|
|
1815
1841
|
runLint(['--fix'], config, { dryRun });
|
|
1842
|
+
// Rewrites drifted status TOKENS only. Adding markers is a content edit to
|
|
1843
|
+
// prose the user wrote, so it stays opt-in behind `sync-status --adopt`.
|
|
1844
|
+
syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun, quiet: false });
|
|
1816
1845
|
if (config.indexPath) {
|
|
1817
1846
|
if (!dryRun) {
|
|
1818
1847
|
const { writeRenderedIndex } = await import('../src/index-file.mjs');
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -133,6 +133,7 @@ const definitions = [
|
|
|
133
133
|
command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
|
|
134
134
|
command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
|
|
135
135
|
command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
|
|
136
|
+
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
136
137
|
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--json'), flag('--include-archived')] })]),
|
|
137
138
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
|
138
139
|
form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
|
package/src/doctor.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
4
4
|
import { runLint } from './lint.mjs';
|
|
5
|
+
import { syncHubStatuses } from './sync-status.mjs';
|
|
5
6
|
import { runTouch } from './lifecycle.mjs';
|
|
6
7
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
8
|
import { writeRenderedIndex } from './index-file.mjs';
|
|
@@ -157,13 +158,22 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
157
158
|
process.stdout.write('\n' + bold('3. Fixing long frontmatter...') + '\n');
|
|
158
159
|
runFrontmatterFix(config, { dryRun });
|
|
159
160
|
|
|
160
|
-
// Step 4:
|
|
161
|
-
|
|
161
|
+
// Step 4: Rewrite hub rows whose printed status drifted from the plan they
|
|
162
|
+
// link to. Tokens only — adding markers is a content edit to prose the user
|
|
163
|
+
// wrote, so it stays opt-in behind `dotmd sync-status --adopt`.
|
|
164
|
+
process.stdout.write('\n' + bold('4. Syncing hub status rows...') + '\n');
|
|
165
|
+
const hubSync = syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun });
|
|
166
|
+
if (hubSync.fixed === 0 && hubSync.adopted === 0 && hubSync.unreadable === 0) {
|
|
167
|
+
process.stdout.write('Hub status rows are in sync.\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 5: Sync dates from git
|
|
171
|
+
process.stdout.write('\n' + bold('5. Syncing dates from git...') + '\n');
|
|
162
172
|
runTouch(['--git'], config, { dryRun });
|
|
163
173
|
|
|
164
|
-
// Step
|
|
174
|
+
// Step 6: Regenerate index. Heading always prints so numbering remains
|
|
165
175
|
// contiguous even when `index.path` isn't configured.
|
|
166
|
-
process.stdout.write('\n' + bold('
|
|
176
|
+
process.stdout.write('\n' + bold('6. Regenerating index...') + '\n');
|
|
167
177
|
if (!config.indexPath) {
|
|
168
178
|
process.stdout.write('No index path configured (skip).\n');
|
|
169
179
|
} else if (dryRun) {
|
|
@@ -173,11 +183,11 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
173
183
|
process.stdout.write('Index updated.\n');
|
|
174
184
|
}
|
|
175
185
|
|
|
176
|
-
// Step
|
|
186
|
+
// Step 7: Clean up retired Claude Code command scaffolding. The per-repo
|
|
177
187
|
// `.claude/commands/{plans,docs}.md` files are superseded by the dotmd plugin
|
|
178
188
|
// skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
|
|
179
189
|
// Always print the heading so the numbering remains contiguous.
|
|
180
|
-
process.stdout.write('\n' + bold('
|
|
190
|
+
process.stdout.write('\n' + bold('7. Claude Code commands:') + '\n');
|
|
181
191
|
if (dryRun) {
|
|
182
192
|
const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
|
|
183
193
|
if (wouldRemove.length === 0) {
|
|
@@ -198,8 +208,8 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
198
208
|
}
|
|
199
209
|
}
|
|
200
210
|
|
|
201
|
-
// Step
|
|
202
|
-
const issueLabel = dryRun ? '
|
|
211
|
+
// Step 8: Show remaining check
|
|
212
|
+
const issueLabel = dryRun ? '8. Remaining issues in current tree (preview fixes above were not applied):' : '8. Remaining issues:';
|
|
203
213
|
process.stdout.write('\n' + bold(issueLabel) + '\n');
|
|
204
214
|
const freshIndex = buildIndex(config);
|
|
205
215
|
process.stdout.write(renderCheck(freshIndex, config));
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { resolveRefPath, toRepoPath } from './util.mjs';
|
|
5
|
+
import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
|
|
6
|
+
|
|
7
|
+
// Membership drift: a hub's list of children and the plans that claim it via
|
|
8
|
+
// `parent_plan:` are two halves of one relationship, and either half can go
|
|
9
|
+
// stale on its own. dotmd already checks one direction — `checkRunlistBackPointers`
|
|
10
|
+
// warns when a `runlist:` child lacks the back-ref. These are the two arrows it
|
|
11
|
+
// doesn't cover.
|
|
12
|
+
//
|
|
13
|
+
// Membership only. The row is NEVER generated — same constraint as the status
|
|
14
|
+
// guard: the prose beside a row is why the row exists.
|
|
15
|
+
//
|
|
16
|
+
// ── What counts as a membership claim (measured, not assumed) ───────────────
|
|
17
|
+
//
|
|
18
|
+
// A body table row is NOT one. dotmd's own estate has an aggregator hub whose
|
|
19
|
+
// ranked queue draws plans from three other programs, and whose other tables row
|
|
20
|
+
// children purely to say "related" — the same pointer-row shape the status guard
|
|
21
|
+
// already declines to judge. Treating every rowed link as membership would flood
|
|
22
|
+
// correct hubs.
|
|
23
|
+
//
|
|
24
|
+
// What IS a claim: `runlist:` (frontmatter order) and the hub's BODY ORDER
|
|
25
|
+
// (`## Ranked queue` / `## Order of operations`) — the list `dotmd runlist next`
|
|
26
|
+
// actually walks. A plan there is one this hub would hand a session.
|
|
27
|
+
//
|
|
28
|
+
// ── Why "points at a different hub" is deliberately silent ──────────────────
|
|
29
|
+
//
|
|
30
|
+
// The scaffolded plan wanted a warning when a rowed child "moved to another
|
|
31
|
+
// hub". Measured against a real estate, sharing is legitimate and common: an
|
|
32
|
+
// aggregator hub ranks plans whose `parent_plan:` names the program hub that
|
|
33
|
+
// owns them, and demanding exclusivity would fire on every one of those rows
|
|
34
|
+
// with no fix that doesn't break the other hub's claim. So the guard fires only
|
|
35
|
+
// on the unambiguous half — a ranked plan claiming NO parent at all.
|
|
36
|
+
|
|
37
|
+
const ORPHAN_KIND = 'hub-membership-orphan';
|
|
38
|
+
const BACKREF_KIND = 'hub-membership-backref';
|
|
39
|
+
|
|
40
|
+
export function checkHubMembershipDrift(docs, config) {
|
|
41
|
+
const warnings = [];
|
|
42
|
+
const quiet = new Set([
|
|
43
|
+
...(config.lifecycle?.terminalStatuses ?? []),
|
|
44
|
+
...(config.lifecycle?.skipWarningsFor ?? []),
|
|
45
|
+
]);
|
|
46
|
+
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
47
|
+
const refFields = [
|
|
48
|
+
...(config.referenceFields?.bidirectional ?? []),
|
|
49
|
+
...(config.referenceFields?.unidirectional ?? []),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
const dirOf = (doc) => path.dirname(path.join(config.repoRoot, doc.path));
|
|
53
|
+
const resolve = (ref, dir) => {
|
|
54
|
+
const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
|
|
55
|
+
return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Everything a hub says about other docs: every configured reference field
|
|
59
|
+
// plus every body link. Config-driven rather than a hardcoded field list, so a
|
|
60
|
+
// repo that renames its reference fields keeps working.
|
|
61
|
+
const knownTo = (hub) => {
|
|
62
|
+
const dir = dirOf(hub);
|
|
63
|
+
const known = new Set();
|
|
64
|
+
for (const field of refFields) {
|
|
65
|
+
for (const ref of (hub.refFields?.[field] ?? [])) {
|
|
66
|
+
const target = resolve(ref, dir);
|
|
67
|
+
if (target) known.add(target.path);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const link of (hub.bodyLinks ?? [])) {
|
|
71
|
+
const target = resolve(link.href, dir);
|
|
72
|
+
if (target) known.add(target.path);
|
|
73
|
+
}
|
|
74
|
+
return known;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── Arrow 1: a claim only the child makes ────────────────────────────────
|
|
78
|
+
// The plan says `parent_plan: <hub>` and the hub references it NOWHERE — not
|
|
79
|
+
// in a reference field, not as a body link. One side of the relationship
|
|
80
|
+
// silently dropped the other, and every hub view (fold, rollup, next-pickup)
|
|
81
|
+
// is computed from the hub's side, so the child is invisible where it thinks
|
|
82
|
+
// it lives. Warns on the HUB: the hub's list is the half that lost the entry.
|
|
83
|
+
const knownCache = new Map();
|
|
84
|
+
for (const child of docs) {
|
|
85
|
+
if (quiet.has(child.status)) continue;
|
|
86
|
+
const parents = child.refFields?.parent_plan ?? [];
|
|
87
|
+
if (parents.length === 0) continue;
|
|
88
|
+
const dir = dirOf(child);
|
|
89
|
+
for (const ref of parents) {
|
|
90
|
+
const hub = resolve(ref, dir);
|
|
91
|
+
// Only hubs are asked to carry a membership list. A plain plan named as a
|
|
92
|
+
// parent rows nothing, and demanding a link back there would be a new
|
|
93
|
+
// opinion rather than a drift check.
|
|
94
|
+
if (!hub || hub.path === child.path || !isHubDoc(hub)) continue;
|
|
95
|
+
if (quiet.has(hub.status)) continue;
|
|
96
|
+
if (!knownCache.has(hub.path)) knownCache.set(hub.path, knownTo(hub));
|
|
97
|
+
if (knownCache.get(hub.path).has(child.path)) continue;
|
|
98
|
+
warnings.push({
|
|
99
|
+
path: hub.path,
|
|
100
|
+
level: 'warning',
|
|
101
|
+
message: `\`${child.path}\` claims \`parent_plan: ${ref}\` but this hub references it nowhere — no reference field, no body link. Add it to the hub's list, or fix the child's \`parent_plan:\`. A membership only one side records is invisible to every hub view (fold, rollup, next-pickup), which all read the hub's half.`,
|
|
102
|
+
meta: { kind: ORPHAN_KIND, child: child.path, ref },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Arrow 2: a claim only the hub makes ──────────────────────────────────
|
|
108
|
+
// The hub's BODY ORDER ranks a plan — the list `dotmd runlist next <hub>`
|
|
109
|
+
// walks, so this hub would hand a session that plan — and the plan carries no
|
|
110
|
+
// `parent_plan:` at all. This is the same finding `checkRunlistBackPointers`
|
|
111
|
+
// makes for frontmatter `runlist:` children, extended to the body-order hubs
|
|
112
|
+
// it can't see; children already covered there are skipped, so one missing
|
|
113
|
+
// back-ref is never reported twice. Warns on the CHILD, matching that check:
|
|
114
|
+
// it's the file that needs the edit.
|
|
115
|
+
for (const hub of docs) {
|
|
116
|
+
if (!isHubDoc(hub) || quiet.has(hub.status)) continue;
|
|
117
|
+
let body;
|
|
118
|
+
try { ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hub.path), 'utf8'))); }
|
|
119
|
+
catch { continue; }
|
|
120
|
+
const ranked = detectBodyRunlistRefs(body);
|
|
121
|
+
if (ranked.length === 0) continue;
|
|
122
|
+
const dir = dirOf(hub);
|
|
123
|
+
const inFrontmatterRunlist = new Set();
|
|
124
|
+
for (const ref of (hub.refFields?.runlist ?? [])) {
|
|
125
|
+
const target = resolve(ref, dir);
|
|
126
|
+
if (target) inFrontmatterRunlist.add(target.path);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
for (const ref of ranked) {
|
|
131
|
+
const child = resolve(ref, dir);
|
|
132
|
+
if (!child || child.path === hub.path || seen.has(child.path)) continue;
|
|
133
|
+
seen.add(child.path);
|
|
134
|
+
if (quiet.has(child.status)) continue; // closed work is normal history
|
|
135
|
+
if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
|
|
136
|
+
if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
|
|
137
|
+
if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
|
|
138
|
+
if ((child.refFields?.parent_plan ?? []).length > 0) continue;
|
|
139
|
+
warnings.push({
|
|
140
|
+
path: child.path,
|
|
141
|
+
level: 'warning',
|
|
142
|
+
message: `is ranked in the body order of \`${hub.path}\` (the list \`dotmd runlist next\` walks) but has no \`parent_plan:\`. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
|
|
143
|
+
meta: { kind: BACKREF_KIND, hub: hub.path },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return warnings;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export const HUB_MEMBERSHIP_KINDS = Object.freeze({ orphan: ORPHAN_KIND, backref: BACKREF_KIND });
|
package/src/hub.mjs
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Hub primitives: what counts as a hub, and how to read a hub's body tables.
|
|
2
|
+
//
|
|
3
|
+
// This module is deliberately a LEAF — it imports nothing from dotmd. The hub
|
|
4
|
+
// predicates used to live in `runlist.mjs`, but `runlist.mjs` imports
|
|
5
|
+
// `index.mjs` (for `resolveDocArg`), and the hub-status check has to run FROM
|
|
6
|
+
// `index.mjs`. Keeping the predicates here is what lets both sides share one
|
|
7
|
+
// definition of "hub" instead of re-deriving it — the exact duplication the
|
|
8
|
+
// hub-status-drift plan exists to avoid. `runlist.mjs` re-exports them so every
|
|
9
|
+
// existing importer is unaffected.
|
|
10
|
+
|
|
11
|
+
// A *coordination hub* is a prose-first plan that sits above a cluster of other
|
|
12
|
+
// plans — a "runlist" in the platform sense (master-runlist, ai-runlist, …)
|
|
13
|
+
// rather than a strictly-ordered frontmatter `runlist:` sprint. The signal is
|
|
14
|
+
// already in frontmatter (`execution_mode: coordination`), with the
|
|
15
|
+
// `*-runlist` / `runlist` naming convention as a fallback for the few hubs that
|
|
16
|
+
// predate the field. These plans aren't units of executable work — they're
|
|
17
|
+
// navigation maps — so the triage view tags them and lifts them out of the
|
|
18
|
+
// leaf-plan flow rather than treating them as one more active plan.
|
|
19
|
+
export function isCoordinationHub(doc) {
|
|
20
|
+
if (!doc) return false;
|
|
21
|
+
if (doc.type && doc.type !== 'plan') return false;
|
|
22
|
+
// Broad "held-out navigational hub" predicate: both coordination hubs and the
|
|
23
|
+
// tier-3 roadmap (`execution_mode: roadmap`) are lifted out of the active count
|
|
24
|
+
// and into a hub section. `isRoadmapHub` is the finer split the tier-3 views
|
|
25
|
+
// use to promote a roadmap above the Runlists section; here a roadmap counts as
|
|
26
|
+
// a coordination hub so all the existing held-out plumbing covers it for free.
|
|
27
|
+
if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
|
|
28
|
+
const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
29
|
+
return base === 'runlist' || base.endsWith('-runlist');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A *roadmap* is the tier-3 hub: a coordination hub whose children are themselves
|
|
33
|
+
// hubs (runlists / coordination hubs), with progress rolled up across them. The
|
|
34
|
+
// signal is explicit — `execution_mode: roadmap` — with NO slug-convention
|
|
35
|
+
// fallback (unlike coordination hubs' `*-runlist`): there's no naming convention
|
|
36
|
+
// for roadmaps, and `dotmd check` nudges the structural case (a coordination hub
|
|
37
|
+
// that points at other hubs) toward the explicit field rather than auto-promoting.
|
|
38
|
+
export function isRoadmapHub(doc) {
|
|
39
|
+
if (!doc) return false;
|
|
40
|
+
if (doc.type && doc.type !== 'plan') return false;
|
|
41
|
+
return doc.executionMode === 'roadmap';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// A *sprint hub* declares its ordered children in frontmatter (`runlist:`).
|
|
45
|
+
export function isSprintHub(doc) {
|
|
46
|
+
return (doc?.refFields?.runlist ?? []).length > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Every shape of hub: a frontmatter sprint, a coordination map, or a roadmap.
|
|
50
|
+
// This is the set whose body tables the hub-status guard reads.
|
|
51
|
+
export function isHubDoc(doc) {
|
|
52
|
+
return isSprintHub(doc) || isCoordinationHub(doc);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The row→target anchor: the first `.md` link in a table row is the plan that
|
|
56
|
+
// row is ABOUT. Shared by next-pickup detection (`detectBodyRunlistRefs`) and
|
|
57
|
+
// the hub-status guard, so the two can never disagree about which plan a given
|
|
58
|
+
// row names.
|
|
59
|
+
const ROW_LINK_RE = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
60
|
+
|
|
61
|
+
export function firstRowLink(line) {
|
|
62
|
+
const match = ROW_LINK_RE.exec(line);
|
|
63
|
+
return match ? match[1] : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Reading the status word out of a hub row ──────────────────────────────
|
|
67
|
+
//
|
|
68
|
+
// A hub row prints its child's status by hand. Two ways to find that word:
|
|
69
|
+
//
|
|
70
|
+
// position — strip HTML comments, take the cell's LEADING token, match it
|
|
71
|
+
// against the vocabulary the child's own type declares. No marker
|
|
72
|
+
// needed, so this works on tables that already exist. Measured on
|
|
73
|
+
// 472 real rows: agrees with an explicitly marked span 96% of the
|
|
74
|
+
// time and picks a wrong token zero times. Every miss leaves a
|
|
75
|
+
// leading word outside the vocabulary, so it declines (and the row
|
|
76
|
+
// is reported as unreadable) rather than rewriting confidently.
|
|
77
|
+
// marker — `<!--s-->active<!--/s-->` pins the span when position can't find
|
|
78
|
+
// it (the measured 4%: a status sitting behind a bolded headline).
|
|
79
|
+
//
|
|
80
|
+
// The marker is 19 characters against 62 for dotmd's block grammar
|
|
81
|
+
// (`<!-- GENERATED:dotmd:start -->`). That difference only matters because this
|
|
82
|
+
// one recurs hundreds of times per estate. It makes four comment grammars in
|
|
83
|
+
// dotmd; the other three are block-level and correct at block level. Do NOT
|
|
84
|
+
// "unify" them, and do not let the count grow again.
|
|
85
|
+
export const MARKER_OPEN = '<!--s-->';
|
|
86
|
+
export const MARKER_CLOSE = '<!--/s-->';
|
|
87
|
+
|
|
88
|
+
const MARKED_SPAN_RE = /<!--\s*s\s*-->([\s\S]*?)<!--\s*\/s\s*-->/;
|
|
89
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
90
|
+
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
91
|
+
// Statuses are hyphenated single words (`in-session`, `queued-after`). The
|
|
92
|
+
// optional emphasis prefix lets `**active**` read as a leading token while
|
|
93
|
+
// `**Phase 1 gate** — active` still declines: the leading word there is `Phase`.
|
|
94
|
+
const LEADING_TOKEN_RE = /^(\s*)(\*\*|__|\*|_|`)?([A-Za-z][A-Za-z0-9-]*)/;
|
|
95
|
+
|
|
96
|
+
// Split a markdown table row into cells, keeping each cell's offsets into the
|
|
97
|
+
// line so a fix can rewrite one word without touching the rest of the row.
|
|
98
|
+
// Honors `\|` escapes and pipes inside inline code.
|
|
99
|
+
export function splitRowCells(line) {
|
|
100
|
+
const cells = [];
|
|
101
|
+
let start = 0;
|
|
102
|
+
let inCode = false;
|
|
103
|
+
for (let i = 0; i < line.length; i++) {
|
|
104
|
+
const ch = line[i];
|
|
105
|
+
if (ch === '\\') { i++; continue; }
|
|
106
|
+
if (ch === '`') { inCode = !inCode; continue; }
|
|
107
|
+
if (ch === '|' && !inCode) {
|
|
108
|
+
cells.push({ raw: line.slice(start, i), start, end: i });
|
|
109
|
+
start = i + 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
cells.push({ raw: line.slice(start), start, end: line.length });
|
|
113
|
+
// Rows are conventionally pipe-delimited on both ends; drop the empty edge
|
|
114
|
+
// segments those produce so column indexes line up with the header's.
|
|
115
|
+
if (cells.length && cells[0].raw.trim() === '') cells.shift();
|
|
116
|
+
if (cells.length && cells[cells.length - 1].raw.trim() === '') cells.pop();
|
|
117
|
+
return cells;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isDelimiterRow(cells) {
|
|
121
|
+
return cells.length > 0 && cells.every(cell => /^\s*:?-+:?\s*$/.test(cell.raw));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A table declares a status column when a header cell IS "status"/"state".
|
|
125
|
+
// Deliberately exact (after stripping emphasis): a header that only mentions
|
|
126
|
+
// status in passing is not a promise that the column holds one.
|
|
127
|
+
function isStatusHeader(raw) {
|
|
128
|
+
const normalized = raw.replace(/[*_`]/g, '').trim().toLowerCase();
|
|
129
|
+
return normalized === 'status' || normalized === 'state';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function findMarkedSpan(line) {
|
|
133
|
+
const match = MARKED_SPAN_RE.exec(line);
|
|
134
|
+
if (!match) return null;
|
|
135
|
+
const innerStart = match.index + match[0].indexOf('-->') + 3;
|
|
136
|
+
const inner = match[1];
|
|
137
|
+
const lead = inner.length - inner.trimStart().length;
|
|
138
|
+
const text = inner.trim();
|
|
139
|
+
if (!text) return null;
|
|
140
|
+
return { start: innerStart + lead, end: innerStart + lead + text.length, text, marked: true };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Positional read of a cell: leading token, comments stripped, vocabulary-gated.
|
|
144
|
+
// `isKnownStatus` is the caller's vocabulary test — it depends on the CHILD's
|
|
145
|
+
// type, which only the caller can resolve, so it is injected rather than
|
|
146
|
+
// guessed here. Returns line-absolute offsets, like `findMarkedSpan`.
|
|
147
|
+
export function readPositionalToken(cell, isKnownStatus) {
|
|
148
|
+
if (!cell) return null;
|
|
149
|
+
// Blank out comments instead of deleting them so offsets stay line-absolute.
|
|
150
|
+
const masked = cell.raw.replace(HTML_COMMENT_RE, comment => ' '.repeat(comment.length));
|
|
151
|
+
const match = LEADING_TOKEN_RE.exec(masked);
|
|
152
|
+
if (!match) return null;
|
|
153
|
+
const word = match[3];
|
|
154
|
+
if (!isKnownStatus(word)) return null;
|
|
155
|
+
const start = cell.start + match[1].length + (match[2]?.length ?? 0);
|
|
156
|
+
return { start, end: start + word.length, text: word, marked: false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Walk a hub body's markdown tables and return one entry per row that names a
|
|
160
|
+
// child (`.md` link). Pure: no vocabulary, no resolution, no IO — the caller
|
|
161
|
+
// resolves the target and then reads the status word, because the vocabulary
|
|
162
|
+
// depends on the child's own type.
|
|
163
|
+
//
|
|
164
|
+
// Returns [{ lineIndex, ref, hasStatusColumn, statusCell, marked }] where
|
|
165
|
+
// `lineIndex` is 0-based into `body.split('\n')` and `statusCell`/`marked`
|
|
166
|
+
// carry line-absolute offsets.
|
|
167
|
+
export function scanHubStatusRows(body) {
|
|
168
|
+
if (!body) return [];
|
|
169
|
+
const lines = body.split('\n');
|
|
170
|
+
const rows = [];
|
|
171
|
+
let fence = null;
|
|
172
|
+
let table = null;
|
|
173
|
+
|
|
174
|
+
for (let i = 0; i < lines.length; i++) {
|
|
175
|
+
const line = lines[i];
|
|
176
|
+
const fenceMatch = FENCE_RE.exec(line);
|
|
177
|
+
if (fence) {
|
|
178
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) fence = null;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// Fenced examples routinely contain sample hub rows (this plan's own body
|
|
182
|
+
// does). Reading them would report drift against a fictional child.
|
|
183
|
+
if (fenceMatch) { fence = fenceMatch[1]; table = null; continue; }
|
|
184
|
+
|
|
185
|
+
if (!line.trim().startsWith('|')) { table = null; continue; }
|
|
186
|
+
const cells = splitRowCells(line);
|
|
187
|
+
|
|
188
|
+
if (!table) {
|
|
189
|
+
// A pipe line is a table header only when a delimiter row follows it.
|
|
190
|
+
const next = i + 1 < lines.length ? lines[i + 1] : '';
|
|
191
|
+
if (next.trim().startsWith('|') && isDelimiterRow(splitRowCells(next))) {
|
|
192
|
+
table = { statusColumn: cells.findIndex(cell => isStatusHeader(cell.raw)) };
|
|
193
|
+
}
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (isDelimiterRow(cells)) continue;
|
|
197
|
+
|
|
198
|
+
const ref = firstRowLink(line);
|
|
199
|
+
if (!ref) continue;
|
|
200
|
+
rows.push({
|
|
201
|
+
lineIndex: i,
|
|
202
|
+
ref,
|
|
203
|
+
hasStatusColumn: table.statusColumn >= 0,
|
|
204
|
+
statusCell: table.statusColumn >= 0 ? cells[table.statusColumn] ?? null : null,
|
|
205
|
+
marked: findMarkedSpan(line),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return rows;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Write `replacement` in the case style the author used, so a fix never
|
|
212
|
+
// restyles a table (`Active` stays capitalized, `ACTIVE` stays shouted).
|
|
213
|
+
export function applyStatusCase(sample, replacement) {
|
|
214
|
+
if (/[A-Z]/.test(sample) && sample === sample.toUpperCase()) return replacement.toUpperCase();
|
|
215
|
+
if (/^[A-Z]/.test(sample)) return replacement.charAt(0).toUpperCase() + replacement.slice(1);
|
|
216
|
+
return replacement;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Extract ordered plan refs from a hub's body prose. Two shapes:
|
|
220
|
+
// - link-list sections (`## Order of operations`, `## Runlist`, …) — every
|
|
221
|
+
// `.md` link or checklist item, in document order.
|
|
222
|
+
// - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
|
|
223
|
+
// table row (the ranked plan); header/separator rows contribute none.
|
|
224
|
+
// Coordination hubs encode their next-pickup order in the table shape; sprint-
|
|
225
|
+
// ish hubs use the link list. Deduped, first occurrence wins, order preserved.
|
|
226
|
+
//
|
|
227
|
+
// This is a hub's BODY MEMBERSHIP claim, not merely a set of links: it is the
|
|
228
|
+
// order `dotmd runlist <hub>` / `runlist next <hub>` walk, so a plan listed here
|
|
229
|
+
// is one this hub would hand a session. The membership guard leans on exactly
|
|
230
|
+
// that — a plan in some other table is a pointer, a plan in this order is a claim.
|
|
231
|
+
export function detectBodyRunlistRefs(body) {
|
|
232
|
+
if (!body) return [];
|
|
233
|
+
const refs = [];
|
|
234
|
+
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
235
|
+
const sliceSection = (start) => {
|
|
236
|
+
const rest = body.slice(start);
|
|
237
|
+
const next = rest.search(/^##\s+/m);
|
|
238
|
+
return next >= 0 ? rest.slice(0, next) : rest;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
|
|
242
|
+
let match;
|
|
243
|
+
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
244
|
+
const section = sliceSection(match.index + match[0].length);
|
|
245
|
+
const allLinks = new RegExp(linkRe.source, 'g');
|
|
246
|
+
let link;
|
|
247
|
+
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
248
|
+
|
|
249
|
+
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
250
|
+
let item;
|
|
251
|
+
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Ranked-queue tables: the first `.md` link per row is the ranked plan. A
|
|
255
|
+
// header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
|
|
256
|
+
// skipped naturally. Heading may carry trailing text (`## Ranked queue (next
|
|
257
|
+
// pickup)`), so match the leading words, not an exact line.
|
|
258
|
+
const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
|
|
259
|
+
while ((match = queueSectionRe.exec(body)) !== null) {
|
|
260
|
+
const section = sliceSection(match.index + match[0].length);
|
|
261
|
+
for (const rawLine of section.split('\n')) {
|
|
262
|
+
const line = rawLine.trim();
|
|
263
|
+
if (!line.startsWith('|')) continue;
|
|
264
|
+
const link = firstRowLink(line);
|
|
265
|
+
if (link) refs.push(link);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return [...new Set(refs)];
|
|
270
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { checkIndex } from './index-file.mjs';
|
|
|
9
9
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
10
10
|
import { checkGlossaryConfig } from './glossary-check.mjs';
|
|
11
11
|
import { checkSkillDrift } from './skill-drift.mjs';
|
|
12
|
+
import { checkHubStatusDrift } from './sync-status.mjs';
|
|
13
|
+
import { checkHubMembershipDrift } from './hub-membership.mjs';
|
|
12
14
|
|
|
13
15
|
// `fast: true` skips every pass that produces warnings/errors — the rendered
|
|
14
16
|
// index file consumes only status/title/snapshot/etc., not the validation
|
|
@@ -117,6 +119,20 @@ export function buildIndex(config, opts = {}) {
|
|
|
117
119
|
errors.push(...indexCheck.errors);
|
|
118
120
|
}
|
|
119
121
|
|
|
122
|
+
// Hub status drift produces ERRORS (a drifted marked span), so it runs in
|
|
123
|
+
// errorsOnly mode too — that's what keeps `dotmd hud`'s error count equal to
|
|
124
|
+
// `dotmd check`'s. Its warnings still obey the warning-only gate.
|
|
125
|
+
if (!fast) {
|
|
126
|
+
const hubStatus = checkHubStatusDrift(transformedDocs, config);
|
|
127
|
+
errors.push(...hubStatus.errors);
|
|
128
|
+
if (!skipWarningOnlyChecks) warnings.push(...hubStatus.warnings);
|
|
129
|
+
for (const entry of [...hubStatus.errors, ...(skipWarningOnlyChecks ? [] : hubStatus.warnings)]) {
|
|
130
|
+
const hub = transformedDocs.find(d => d.path === entry.path);
|
|
131
|
+
if (!hub) continue;
|
|
132
|
+
(entry.level === 'error' ? hub.errors : hub.warnings).push(entry);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
120
136
|
if (!skipWarningOnlyChecks) {
|
|
121
137
|
const refCheck = checkBidirectionalReferences(transformedDocs, config);
|
|
122
138
|
warnings.push(...refCheck.warnings);
|
|
@@ -128,6 +144,13 @@ export function buildIndex(config, opts = {}) {
|
|
|
128
144
|
if (child) child.warnings.push(w);
|
|
129
145
|
}
|
|
130
146
|
|
|
147
|
+
const membershipWarnings = checkHubMembershipDrift(transformedDocs, config);
|
|
148
|
+
warnings.push(...membershipWarnings);
|
|
149
|
+
for (const w of membershipWarnings) {
|
|
150
|
+
const owner = transformedDocs.find(d => d.path === w.path);
|
|
151
|
+
if (owner) owner.warnings.push(w);
|
|
152
|
+
}
|
|
153
|
+
|
|
131
154
|
const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
|
|
132
155
|
warnings.push(...coordHubWarnings);
|
|
133
156
|
for (const w of coordHubWarnings) {
|
package/src/render.mjs
CHANGED
|
@@ -416,6 +416,16 @@ export function classifyIssueAction(issue) {
|
|
|
416
416
|
const message = issue?.message ?? '';
|
|
417
417
|
const file = issue?.path ?? '<file>';
|
|
418
418
|
|
|
419
|
+
// Hub status rows carry a `meta.kind`, so classify them by that rather than by
|
|
420
|
+
// re-matching their prose. Drift is mechanically fixable; an unreadable cell
|
|
421
|
+
// is not — nothing can guess which word in it was meant to be the status.
|
|
422
|
+
if (issue?.meta?.kind === 'hub-status-drift') {
|
|
423
|
+
return { action: 'dotmd sync-status', fixable: true, label: 'hub status drift' };
|
|
424
|
+
}
|
|
425
|
+
if (issue?.meta?.kind === 'hub-status-unreadable') {
|
|
426
|
+
return { action: `edit ${file}: lead the status cell with the status word, or wrap it in <!--s-->…<!--/s-->`, fixable: false, label: 'hub status rows' };
|
|
427
|
+
}
|
|
428
|
+
|
|
419
429
|
if (/Missing frontmatter `status`/.test(message)) {
|
|
420
430
|
return { action: `dotmd bulk-tag ${file}`, fixable: false, label: 'missing status' };
|
|
421
431
|
}
|
package/src/runlist.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
toSlug,
|
|
15
15
|
warn,
|
|
16
16
|
} from './util.mjs';
|
|
17
|
+
import { detectBodyRunlistRefs, isCoordinationHub, isRoadmapHub } from './hub.mjs';
|
|
17
18
|
import { resolveDocArg } from './index.mjs';
|
|
18
19
|
import { runlistChildContent, slugify, titleize } from './new.mjs';
|
|
19
20
|
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
@@ -96,38 +97,11 @@ export function buildRunlistIndex(index, config) {
|
|
|
96
97
|
return { hubs, childToHub };
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
// predate the field. These plans aren't units of executable work — they're
|
|
105
|
-
// navigation maps — so the triage view tags them and lifts them out of the
|
|
106
|
-
// leaf-plan flow rather than treating them as one more active plan.
|
|
107
|
-
export function isCoordinationHub(doc) {
|
|
108
|
-
if (!doc) return false;
|
|
109
|
-
if (doc.type && doc.type !== 'plan') return false;
|
|
110
|
-
// Broad "held-out navigational hub" predicate: both coordination hubs and the
|
|
111
|
-
// tier-3 roadmap (`execution_mode: roadmap`) are lifted out of the active count
|
|
112
|
-
// and into a hub section. `isRoadmapHub` is the finer split the tier-3 views
|
|
113
|
-
// use to promote a roadmap above the Runlists section; here a roadmap counts as
|
|
114
|
-
// a coordination hub so all the existing held-out plumbing covers it for free.
|
|
115
|
-
if (doc.executionMode === 'coordination' || doc.executionMode === 'roadmap') return true;
|
|
116
|
-
const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
117
|
-
return base === 'runlist' || base.endsWith('-runlist');
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// A *roadmap* is the tier-3 hub: a coordination hub whose children are themselves
|
|
121
|
-
// hubs (runlists / coordination hubs), with progress rolled up across them. The
|
|
122
|
-
// signal is explicit — `execution_mode: roadmap` — with NO slug-convention
|
|
123
|
-
// fallback (unlike coordination hubs' `*-runlist`): there's no naming convention
|
|
124
|
-
// for roadmaps, and `dotmd check` nudges the structural case (a coordination hub
|
|
125
|
-
// that points at other hubs) toward the explicit field rather than auto-promoting.
|
|
126
|
-
export function isRoadmapHub(doc) {
|
|
127
|
-
if (!doc) return false;
|
|
128
|
-
if (doc.type && doc.type !== 'plan') return false;
|
|
129
|
-
return doc.executionMode === 'roadmap';
|
|
130
|
-
}
|
|
100
|
+
// The hub predicates live in `hub.mjs` — a leaf module, so the check pipeline
|
|
101
|
+
// (which runs from `index.mjs`, and can't import this file without a cycle) can
|
|
102
|
+
// share ONE definition of "hub" with the views here. Re-exported so every
|
|
103
|
+
// existing importer keeps reaching them at `runlist.mjs`.
|
|
104
|
+
export { isCoordinationHub, isRoadmapHub };
|
|
131
105
|
|
|
132
106
|
// Map each coordination hub to a `childCount` derived from its `related_plans:`
|
|
133
107
|
// cluster (resolved against the index; peers/self excluded). It's an
|
|
@@ -338,53 +312,6 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
|
338
312
|
return out;
|
|
339
313
|
}
|
|
340
314
|
|
|
341
|
-
// Extract ordered plan refs from a hub's body prose. Two shapes:
|
|
342
|
-
// - link-list sections (`## Order of operations`, `## Runlist`, …) — every
|
|
343
|
-
// `.md` link or checklist item, in document order.
|
|
344
|
-
// - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
|
|
345
|
-
// table row (the ranked plan); header/separator rows contribute none.
|
|
346
|
-
// Coordination hubs encode their next-pickup order in the table shape; sprint-
|
|
347
|
-
// ish hubs use the link list. Deduped, first occurrence wins, order preserved.
|
|
348
|
-
function detectBodyRunlistRefs(body) {
|
|
349
|
-
if (!body) return [];
|
|
350
|
-
const refs = [];
|
|
351
|
-
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
352
|
-
const sliceSection = (start) => {
|
|
353
|
-
const rest = body.slice(start);
|
|
354
|
-
const next = rest.search(/^##\s+/m);
|
|
355
|
-
return next >= 0 ? rest.slice(0, next) : rest;
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
|
|
359
|
-
let match;
|
|
360
|
-
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
361
|
-
const section = sliceSection(match.index + match[0].length);
|
|
362
|
-
const allLinks = new RegExp(linkRe.source, 'g');
|
|
363
|
-
let link;
|
|
364
|
-
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
365
|
-
|
|
366
|
-
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
367
|
-
let item;
|
|
368
|
-
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// Ranked-queue tables: the first `.md` link per row is the ranked plan. A
|
|
372
|
-
// header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
|
|
373
|
-
// skipped naturally. Heading may carry trailing text (`## Ranked queue (next
|
|
374
|
-
// pickup)`), so match the leading words, not an exact line.
|
|
375
|
-
const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
|
|
376
|
-
while ((match = queueSectionRe.exec(body)) !== null) {
|
|
377
|
-
const section = sliceSection(match.index + match[0].length);
|
|
378
|
-
for (const rawLine of section.split('\n')) {
|
|
379
|
-
const line = rawLine.trim();
|
|
380
|
-
if (!line.startsWith('|')) continue;
|
|
381
|
-
const link = linkRe.exec(line);
|
|
382
|
-
if (link) refs.push(link[1]);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return [...new Set(refs)];
|
|
387
|
-
}
|
|
388
315
|
|
|
389
316
|
// Label for a hub's next-pickup child: its slug with the hub's leading module
|
|
390
317
|
// segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
|
+
import { die, isArchivedPath, resolveRefPath, toRepoPath } from './util.mjs';
|
|
5
|
+
import { cyan, dim, green, red, yellow } from './color.mjs';
|
|
6
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
|
+
import {
|
|
8
|
+
MARKER_CLOSE,
|
|
9
|
+
MARKER_OPEN,
|
|
10
|
+
applyStatusCase,
|
|
11
|
+
isHubDoc,
|
|
12
|
+
readPositionalToken,
|
|
13
|
+
scanHubStatusRows,
|
|
14
|
+
} from './hub.mjs';
|
|
15
|
+
|
|
16
|
+
// Hub status drift: a hub rows its children in a table and prints each child's
|
|
17
|
+
// status by hand. Nothing keeps that word honest — the child goes `archived`,
|
|
18
|
+
// the hub still says `active`, and every later reader plans against a status
|
|
19
|
+
// that stopped being true.
|
|
20
|
+
//
|
|
21
|
+
// dotmd already reads those rows to compute next-pickup, so the guard is
|
|
22
|
+
// standing next to the data it needs. Everything it needs is already owned:
|
|
23
|
+
// `config.types[<type>].statuses` for the vocabulary (type-aware, so a `doc`
|
|
24
|
+
// rowed in a plan hub is judged by the doc vocabulary), `resolveRefPath` for
|
|
25
|
+
// the link (case-fold-aware), the index for the child's real status, and
|
|
26
|
+
// archive-dir-outranks-frontmatter for archived children.
|
|
27
|
+
//
|
|
28
|
+
// The three-way split below is the part that is easy to get wrong. A row with
|
|
29
|
+
// no readable status token is NOT automatically a finding:
|
|
30
|
+
//
|
|
31
|
+
// pointer — the table has no status column at all. Legitimate; many hubs
|
|
32
|
+
// row a child just to say "related". SILENT.
|
|
33
|
+
// unreadable — the table HAS a `Status`/`State` column but no status word can
|
|
34
|
+
// be read from the cell. WARNING: the row opts out of the
|
|
35
|
+
// invariant invisibly, and that is exactly where real drift hides.
|
|
36
|
+
// manageable — a token was found (positionally or in a marker). Compare it.
|
|
37
|
+
//
|
|
38
|
+
// Collapsing those (treating every unmatched row as a finding) floods perfectly
|
|
39
|
+
// correct hubs with false positives.
|
|
40
|
+
|
|
41
|
+
// Warning when inferred, error when marked — no config knob. `dotmd check`
|
|
42
|
+
// exits 0 on warnings and 1 on errors, and that asymmetry is the whole point: a
|
|
43
|
+
// positional match is dotmd INFERRING from prose, so it nudges; a marked span is
|
|
44
|
+
// the author declaring "dotmd owns this word", so drifting it fails the check.
|
|
45
|
+
// No repo that never asked for this feature starts failing builds over an
|
|
46
|
+
// inference.
|
|
47
|
+
const DRIFT_KIND = 'hub-status-drift';
|
|
48
|
+
const UNREADABLE_KIND = 'hub-status-unreadable';
|
|
49
|
+
|
|
50
|
+
// Mirror `isValidStatus` in validate.mjs: a doc's own type owns its vocabulary,
|
|
51
|
+
// falling back to the root set and then the global union for untyped docs.
|
|
52
|
+
function statusVocabulary(doc, config) {
|
|
53
|
+
if (doc.type) {
|
|
54
|
+
const typeSet = config.typeStatuses?.get(doc.type);
|
|
55
|
+
if (typeSet?.size) return typeSet;
|
|
56
|
+
}
|
|
57
|
+
return config.rootValidStatuses?.get(doc.root) ?? config.validStatuses ?? new Set();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A doc physically living under the archive dir is archived, whatever its
|
|
61
|
+
// frontmatter says — the same precedence the rest of dotmd applies. (The
|
|
62
|
+
// frontmatter itself is already an error from `validateDoc`; the hub row should
|
|
63
|
+
// still be told the truth rather than agreeing with the stale word.)
|
|
64
|
+
function effectiveStatus(doc, config) {
|
|
65
|
+
const archiveStatuses = config.lifecycle?.archiveStatuses ?? new Set(['archived']);
|
|
66
|
+
if (isArchivedPath(doc.path, config) && !archiveStatuses.has(doc.status)) {
|
|
67
|
+
return [...archiveStatuses][0] ?? 'archived';
|
|
68
|
+
}
|
|
69
|
+
return doc.status ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function truncate(text, max = 60) {
|
|
73
|
+
const squeezed = text.trim().replace(/\s+/g, ' ');
|
|
74
|
+
return squeezed.length > max ? `${squeezed.slice(0, max - 1)}…` : squeezed;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Read every hub's rows and classify each one. `hubPaths` narrows the sweep to
|
|
78
|
+
// named hubs; when given, quiet/terminal hubs are included too (the user asked
|
|
79
|
+
// for that hub by name, so the noise-control rule doesn't apply).
|
|
80
|
+
export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
81
|
+
const docByPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
82
|
+
// `resolveRefPath` is case-fold-aware, so on a case-folding filesystem a row
|
|
83
|
+
// linking `BILLING-A.md` resolves to the file that is indexed as
|
|
84
|
+
// `billing-a.md` — a path string the exact map can't answer. Fold as a
|
|
85
|
+
// fallback, and only when the fold is unambiguous: on a case-SENSITIVE
|
|
86
|
+
// filesystem those really are two different docs.
|
|
87
|
+
const docByFoldedPath = new Map();
|
|
88
|
+
for (const doc of docs) {
|
|
89
|
+
const key = doc.path.toLowerCase();
|
|
90
|
+
docByFoldedPath.set(key, docByFoldedPath.has(key) ? null : doc);
|
|
91
|
+
}
|
|
92
|
+
const quiet = new Set([
|
|
93
|
+
...(config.lifecycle?.terminalStatuses ?? []),
|
|
94
|
+
...(config.lifecycle?.skipWarningsFor ?? []),
|
|
95
|
+
]);
|
|
96
|
+
const out = [];
|
|
97
|
+
|
|
98
|
+
for (const hub of docs) {
|
|
99
|
+
if (!isHubDoc(hub)) continue;
|
|
100
|
+
if (hubPaths) { if (!hubPaths.has(hub.path)) continue; }
|
|
101
|
+
else if (quiet.has(hub.status)) continue;
|
|
102
|
+
|
|
103
|
+
let raw;
|
|
104
|
+
try { raw = readFileSync(path.join(config.repoRoot, hub.path), 'utf8'); } catch { continue; }
|
|
105
|
+
const { body, bodyLineOffset } = extractFrontmatter(raw);
|
|
106
|
+
const hubDir = path.dirname(path.join(config.repoRoot, hub.path));
|
|
107
|
+
|
|
108
|
+
const rows = [];
|
|
109
|
+
for (const row of scanHubStatusRows(body)) {
|
|
110
|
+
const href = row.ref.replace(/#.*$/, '');
|
|
111
|
+
const abs = resolveRefPath(href, hubDir, config.repoRoot);
|
|
112
|
+
// A row whose link is broken is already a body-link finding. Reporting it
|
|
113
|
+
// again as unreadable status would double-report one problem.
|
|
114
|
+
if (!abs) continue;
|
|
115
|
+
const repoPath = toRepoPath(abs, config.repoRoot);
|
|
116
|
+
const target = docByPath.get(repoPath) ?? docByFoldedPath.get(repoPath.toLowerCase()) ?? null;
|
|
117
|
+
if (!target || target.path === hub.path) continue;
|
|
118
|
+
const actual = effectiveStatus(target, config);
|
|
119
|
+
if (!actual) continue; // missing `status:` is the child's own error
|
|
120
|
+
|
|
121
|
+
const vocabulary = statusVocabulary(target, config);
|
|
122
|
+
const span = row.marked
|
|
123
|
+
?? readPositionalToken(row.statusCell, word => vocabulary.has(word.toLowerCase()));
|
|
124
|
+
const base = {
|
|
125
|
+
lineIndex: row.lineIndex,
|
|
126
|
+
line: bodyLineOffset + row.lineIndex + 1,
|
|
127
|
+
target: target.path,
|
|
128
|
+
actual,
|
|
129
|
+
};
|
|
130
|
+
if (!span) {
|
|
131
|
+
// No status column → pointer row → silent. Status column → the row opts
|
|
132
|
+
// out of the invariant invisibly, which is a finding.
|
|
133
|
+
if (row.hasStatusColumn) {
|
|
134
|
+
rows.push({ ...base, state: 'unreadable', marked: false, span: null, printed: null,
|
|
135
|
+
cell: row.statusCell ? truncate(row.statusCell.raw) : '' });
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
rows.push({
|
|
140
|
+
...base,
|
|
141
|
+
state: span.text.toLowerCase() === String(actual).toLowerCase() ? 'ok' : 'drift',
|
|
142
|
+
marked: Boolean(row.marked),
|
|
143
|
+
span,
|
|
144
|
+
printed: span.text,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (rows.length) out.push({ hub, rows, bodyLineOffset });
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// The check-pipeline pass. Returns index-level warnings/errors; `src/index.mjs`
|
|
153
|
+
// pushes them and attaches them to the owning hub doc.
|
|
154
|
+
export function checkHubStatusDrift(docs, config) {
|
|
155
|
+
const warnings = [];
|
|
156
|
+
const errors = [];
|
|
157
|
+
for (const { hub, rows } of collectHubStatusRows(docs, config)) {
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
if (row.state === 'drift') {
|
|
160
|
+
const entry = {
|
|
161
|
+
path: hub.path,
|
|
162
|
+
level: row.marked ? 'error' : 'warning',
|
|
163
|
+
message: row.marked
|
|
164
|
+
? `line ${row.line} rows \`${row.target}\` as \`${row.printed}\` inside a \`${MARKER_OPEN}…${MARKER_CLOSE}\` marker, but that doc's status is \`${row.actual}\`. The marker means dotmd owns that word — run \`dotmd sync-status\` to rewrite it, or change the doc's status.`
|
|
165
|
+
: `line ${row.line} rows \`${row.target}\` as \`${row.printed}\`, but that doc's status is \`${row.actual}\`. Run \`dotmd sync-status\` to rewrite the row, or change the doc's status.`,
|
|
166
|
+
meta: { kind: DRIFT_KIND, target: row.target, printed: row.printed, actual: row.actual, line: row.line, marked: row.marked },
|
|
167
|
+
};
|
|
168
|
+
(row.marked ? errors : warnings).push(entry);
|
|
169
|
+
} else if (row.state === 'unreadable') {
|
|
170
|
+
warnings.push({
|
|
171
|
+
path: hub.path,
|
|
172
|
+
level: 'warning',
|
|
173
|
+
message: `line ${row.line} rows \`${row.target}\` under a status column, but no status word could be read from the cell (\`${row.cell}\`). Lead the cell with the status, or wrap the status in \`${MARKER_OPEN}…${MARKER_CLOSE}\`, so drift in this row can be caught.`,
|
|
174
|
+
meta: { kind: UNREADABLE_KIND, target: row.target, line: row.line },
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { warnings, errors };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Rewrite drifted status words, and (with `adopt`) wrap managed positional
|
|
183
|
+
// tokens in markers. `adopt` is separate because wrapping a word in markers is a
|
|
184
|
+
// content edit to prose the user wrote: `check --fix` / `doctor` rewrite status
|
|
185
|
+
// TOKENS, marked or positional; only `--adopt` ADDS markers.
|
|
186
|
+
export function syncHubStatuses(config, { docs, dryRun = false, adopt = false, hubPaths = null, quiet = false } = {}) {
|
|
187
|
+
const hubs = collectHubStatusRows(docs, config, { hubPaths });
|
|
188
|
+
if (hubs.length) {
|
|
189
|
+
authorizeManagedSweep(hubs.map(h => path.join(config.repoRoot, h.hub.path)), config, { kind: 'Hub status sync source' });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
193
|
+
const result = { fixed: 0, adopted: 0, unreadable: 0, hubs: [], skippedLineStart: 0 };
|
|
194
|
+
|
|
195
|
+
for (const { hub, bodyLineOffset, rows } of hubs) {
|
|
196
|
+
const absPath = path.join(config.repoRoot, hub.path);
|
|
197
|
+
const raw = normalizeEol(readFileSync(absPath, 'utf8'));
|
|
198
|
+
const lines = raw.split('\n');
|
|
199
|
+
const applied = [];
|
|
200
|
+
let unreadable = 0;
|
|
201
|
+
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
if (row.state === 'unreadable') { unreadable++; continue; }
|
|
204
|
+
const drifted = row.state === 'drift';
|
|
205
|
+
let wrap = adopt && !row.marked;
|
|
206
|
+
// A marker must never begin a line: `reference-planner.mjs` returns any
|
|
207
|
+
// line starting with `<!--` unmodified (CommonMark HTML-block rule), so a
|
|
208
|
+
// link sharing that line would stop being rewritten by moves. A table row
|
|
209
|
+
// always starts with `|`, so this cannot bite in the intended use — the
|
|
210
|
+
// guard is here so a "markers on their own line" idea fails loudly.
|
|
211
|
+
if (wrap && !lines[bodyLineOffset + row.lineIndex].slice(0, row.span.start).trim()) {
|
|
212
|
+
wrap = false;
|
|
213
|
+
result.skippedLineStart++;
|
|
214
|
+
}
|
|
215
|
+
if (!drifted && !wrap) continue;
|
|
216
|
+
|
|
217
|
+
const replacement = drifted ? applyStatusCase(row.printed, row.actual) : row.printed;
|
|
218
|
+
const lineIdx = bodyLineOffset + row.lineIndex;
|
|
219
|
+
const line = lines[lineIdx];
|
|
220
|
+
lines[lineIdx] = line.slice(0, row.span.start)
|
|
221
|
+
+ (wrap ? MARKER_OPEN : '') + replacement + (wrap ? MARKER_CLOSE : '')
|
|
222
|
+
+ line.slice(row.span.end);
|
|
223
|
+
applied.push({ line: row.line, target: row.target, from: row.printed, to: replacement, drifted, wrapped: wrap, marked: row.marked });
|
|
224
|
+
if (drifted) result.fixed++;
|
|
225
|
+
if (wrap) result.adopted++;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
result.unreadable += unreadable;
|
|
229
|
+
if (applied.length === 0) {
|
|
230
|
+
if (unreadable > 0) result.hubs.push({ path: hub.path, changes: [], unreadable });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!dryRun) writeFileSync(absPath, lines.join('\n'), 'utf8');
|
|
234
|
+
result.hubs.push({ path: hub.path, changes: applied, unreadable });
|
|
235
|
+
|
|
236
|
+
if (!quiet) {
|
|
237
|
+
process.stdout.write(`${prefix}${green('Synced')}: ${hub.path} (${applied.length} row${applied.length === 1 ? '' : 's'})\n`);
|
|
238
|
+
for (const change of applied) {
|
|
239
|
+
const what = change.drifted
|
|
240
|
+
? `${red(change.from)} → ${green(change.to)}`
|
|
241
|
+
: `${dim('marked')} ${cyan(change.to)}`;
|
|
242
|
+
const wrapNote = change.wrapped && change.drifted ? dim(' + marker') : '';
|
|
243
|
+
process.stdout.write(`${prefix} ${dim(`line ${change.line}`)} ${what}${wrapNote} ${dim(`(${change.target})`)}\n`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!quiet) {
|
|
249
|
+
if (result.fixed > 0 || result.adopted > 0) {
|
|
250
|
+
const parts = [];
|
|
251
|
+
if (result.fixed) parts.push(`${result.fixed} row${result.fixed === 1 ? '' : 's'} rewritten`);
|
|
252
|
+
if (result.adopted) parts.push(`${result.adopted} marker${result.adopted === 1 ? '' : 's'} added`);
|
|
253
|
+
process.stdout.write(`\n${prefix}${parts.join(', ')}.\n`);
|
|
254
|
+
}
|
|
255
|
+
if (result.unreadable > 0) {
|
|
256
|
+
process.stdout.write(yellow(`${result.unreadable} row(s) sit under a status column with no readable status word`)
|
|
257
|
+
+ ' — lead the cell with the status, or wrap it in '
|
|
258
|
+
+ `${MARKER_OPEN}…${MARKER_CLOSE}. Run \`dotmd check\` to list them.\n`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Resolve `dotmd sync-status <hub...>` arguments against the index. Matching by
|
|
265
|
+
// path, path+`.md`, or basename slug — the same handles every other verb takes —
|
|
266
|
+
// with a hub-aware miss message (a plain plan named here is a mistake worth
|
|
267
|
+
// naming, not a silent no-op).
|
|
268
|
+
function resolveHubArgs(args, docs) {
|
|
269
|
+
const paths = new Set();
|
|
270
|
+
for (const arg of args) {
|
|
271
|
+
const slug = arg.replace(/\.md$/, '');
|
|
272
|
+
const matches = docs.filter(doc =>
|
|
273
|
+
doc.path === arg || doc.path === `${slug}.md`
|
|
274
|
+
|| path.basename(doc.path, '.md') === path.basename(slug));
|
|
275
|
+
if (matches.length === 0) die(`No doc matches "${arg}".`);
|
|
276
|
+
if (matches.length > 1) {
|
|
277
|
+
die(`Multiple docs match "${arg}":\n${matches.map(m => ` ${m.path}`).join('\n')}`);
|
|
278
|
+
}
|
|
279
|
+
const [match] = matches;
|
|
280
|
+
if (!isHubDoc(match)) {
|
|
281
|
+
die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
|
|
282
|
+
+ 'Run `dotmd sync-status` with no argument to sweep every hub.');
|
|
283
|
+
}
|
|
284
|
+
paths.add(match.path);
|
|
285
|
+
}
|
|
286
|
+
return paths;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function runSyncStatus(argv, config, opts = {}) {
|
|
290
|
+
// Dynamic so this module stays importable FROM index.mjs (which needs
|
|
291
|
+
// `checkHubStatusDrift`) without closing an import cycle.
|
|
292
|
+
const { buildIndex } = await import('./index.mjs');
|
|
293
|
+
|
|
294
|
+
const adopt = argv.includes('--adopt');
|
|
295
|
+
const json = argv.includes('--json');
|
|
296
|
+
const hubArgs = argv.filter(arg => !arg.startsWith('-'));
|
|
297
|
+
const docs = buildIndex(config).docs;
|
|
298
|
+
const hubPaths = hubArgs.length ? resolveHubArgs(hubArgs, docs) : null;
|
|
299
|
+
|
|
300
|
+
const result = syncHubStatuses(config, { docs, dryRun: opts.dryRun, adopt, hubPaths, quiet: json });
|
|
301
|
+
if (json) {
|
|
302
|
+
process.stdout.write(`${JSON.stringify({ dryRun: Boolean(opts.dryRun), adopt, ...result }, null, 2)}\n`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (result.fixed === 0 && result.adopted === 0 && result.unreadable === 0) {
|
|
306
|
+
process.stdout.write(green('Hub status rows are in sync.') + '\n');
|
|
307
|
+
}
|
|
308
|
+
if (adopt && result.skippedLineStart > 0) {
|
|
309
|
+
process.stdout.write(dim(`${result.skippedLineStart} token(s) left unmarked: a marker may never begin a line.\n`));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export { MARKER_CLOSE, MARKER_OPEN };
|
|
314
|
+
export const HUB_STATUS_KINDS = Object.freeze({ drift: DRIFT_KIND, unreadable: UNREADABLE_KIND });
|