dotmd-cli 0.73.0 → 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/package.json +1 -1
- package/src/hub-membership.mjs +151 -0
- package/src/hub.mjs +53 -0
- package/src/index.mjs +8 -0
- package/src/runlist.mjs +1 -51
package/package.json
CHANGED
|
@@ -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
CHANGED
|
@@ -215,3 +215,56 @@ export function applyStatusCase(sample, replacement) {
|
|
|
215
215
|
if (/^[A-Z]/.test(sample)) return replacement.charAt(0).toUpperCase() + replacement.slice(1);
|
|
216
216
|
return replacement;
|
|
217
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
|
@@ -10,6 +10,7 @@ import { checkClaudeCommands } from './claude-commands.mjs';
|
|
|
10
10
|
import { checkGlossaryConfig } from './glossary-check.mjs';
|
|
11
11
|
import { checkSkillDrift } from './skill-drift.mjs';
|
|
12
12
|
import { checkHubStatusDrift } from './sync-status.mjs';
|
|
13
|
+
import { checkHubMembershipDrift } from './hub-membership.mjs';
|
|
13
14
|
|
|
14
15
|
// `fast: true` skips every pass that produces warnings/errors — the rendered
|
|
15
16
|
// index file consumes only status/title/snapshot/etc., not the validation
|
|
@@ -143,6 +144,13 @@ export function buildIndex(config, opts = {}) {
|
|
|
143
144
|
if (child) child.warnings.push(w);
|
|
144
145
|
}
|
|
145
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
|
+
|
|
146
154
|
const coordHubWarnings = checkCoordinationHubExecutionMode(transformedDocs, config);
|
|
147
155
|
warnings.push(...coordHubWarnings);
|
|
148
156
|
for (const w of coordHubWarnings) {
|
package/src/runlist.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
toSlug,
|
|
15
15
|
warn,
|
|
16
16
|
} from './util.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { detectBodyRunlistRefs, isCoordinationHub, isRoadmapHub } from './hub.mjs';
|
|
18
18
|
import { resolveDocArg } from './index.mjs';
|
|
19
19
|
import { runlistChildContent, slugify, titleize } from './new.mjs';
|
|
20
20
|
import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
@@ -312,56 +312,6 @@ function resolveRunlistRefs(refs, hubAbsPath, config) {
|
|
|
312
312
|
return out;
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
-
// Extract ordered plan refs from a hub's body prose. Two shapes:
|
|
316
|
-
// - link-list sections (`## Order of operations`, `## Runlist`, …) — every
|
|
317
|
-
// `.md` link or checklist item, in document order.
|
|
318
|
-
// - ranked-queue tables (`## Ranked queue`, …) — the first `.md` link in each
|
|
319
|
-
// table row (the ranked plan); header/separator rows contribute none.
|
|
320
|
-
// Coordination hubs encode their next-pickup order in the table shape; sprint-
|
|
321
|
-
// ish hubs use the link list. Deduped, first occurrence wins, order preserved.
|
|
322
|
-
function detectBodyRunlistRefs(body) {
|
|
323
|
-
if (!body) return [];
|
|
324
|
-
const refs = [];
|
|
325
|
-
const linkRe = /\[[^\]]+\]\(([^)]+\.md(?:#[^)]+)?)\)/;
|
|
326
|
-
const sliceSection = (start) => {
|
|
327
|
-
const rest = body.slice(start);
|
|
328
|
-
const next = rest.search(/^##\s+/m);
|
|
329
|
-
return next >= 0 ? rest.slice(0, next) : rest;
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
const linkSectionRe = /^##\s+(?:Order of operations|Runlist|Execution order|Implementation order|Plan order)\b.*$/gim;
|
|
333
|
-
let match;
|
|
334
|
-
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
335
|
-
const section = sliceSection(match.index + match[0].length);
|
|
336
|
-
const allLinks = new RegExp(linkRe.source, 'g');
|
|
337
|
-
let link;
|
|
338
|
-
while ((link = allLinks.exec(section)) !== null) refs.push(link[1]);
|
|
339
|
-
|
|
340
|
-
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
341
|
-
let item;
|
|
342
|
-
while ((item = checklistRe.exec(section)) !== null) refs.push(item[1]);
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Ranked-queue tables: the first `.md` link per row is the ranked plan. A
|
|
346
|
-
// header (`| Rank | Plan | … |`) and separator (`|---|`) carry no link and are
|
|
347
|
-
// skipped naturally. Heading may carry trailing text (`## Ranked queue (next
|
|
348
|
-
// pickup)`), so match the leading words, not an exact line.
|
|
349
|
-
const queueSectionRe = /^##\s+(?:Ranked queue|Queue|Pickup order|Heads)\b.*$/gim;
|
|
350
|
-
while ((match = queueSectionRe.exec(body)) !== null) {
|
|
351
|
-
const section = sliceSection(match.index + match[0].length);
|
|
352
|
-
for (const rawLine of section.split('\n')) {
|
|
353
|
-
const line = rawLine.trim();
|
|
354
|
-
if (!line.startsWith('|')) continue;
|
|
355
|
-
// `firstRowLink` is the shared row→target anchor: the hub-status guard
|
|
356
|
-
// reads the same link out of the same row, so next-pickup and drift
|
|
357
|
-
// detection can never disagree about which plan a row names.
|
|
358
|
-
const link = firstRowLink(line);
|
|
359
|
-
if (link) refs.push(link);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
return [...new Set(refs)];
|
|
364
|
-
}
|
|
365
315
|
|
|
366
316
|
// Label for a hub's next-pickup child: its slug with the hub's leading module
|
|
367
317
|
// segment stripped when shared (so `founder-runlist` → `founder-brand-conflicts`
|