dotmd-cli 0.76.5 → 0.76.7
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 +26 -5
- package/package.json +1 -1
- package/src/body-link.mjs +40 -0
- package/src/commands.mjs +1 -0
- package/src/doctor.mjs +20 -14
- package/src/extractors.mjs +20 -8
- package/src/fix-membership.mjs +132 -0
- package/src/fix-refs.mjs +21 -10
- package/src/hub-membership.mjs +168 -35
- package/src/parent-plan.mjs +58 -0
- package/src/prompts.mjs +1 -1
- package/src/render.mjs +16 -1
- package/src/runlist.mjs +4 -43
- package/src/sync-status.mjs +10 -8
- package/src/validate.mjs +24 -4
package/bin/dotmd.mjs
CHANGED
|
@@ -238,12 +238,13 @@ Analyze:
|
|
|
238
238
|
glossary <term> [--list] [--json] Look up domain terms + related docs
|
|
239
239
|
|
|
240
240
|
Validate & Fix:
|
|
241
|
-
doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
|
|
241
|
+
doctor [--apply] Auto-fix everything: refs, membership, lint, long fields, dates, index (preview by default)
|
|
242
242
|
doctor --transactions Report/clear wedged mutation transactions (run this if mutations refuse repo-wide)
|
|
243
243
|
doctor --claims Report/release plan claims held by sessions that are gone ("busy in another session")
|
|
244
244
|
self-check Project/version skew diagnostic (alias: doctor --project)
|
|
245
245
|
lint [--fix] Check and auto-fix frontmatter issues
|
|
246
246
|
fix-refs [--dry-run] Auto-fix broken reference paths + body links
|
|
247
|
+
fix-membership [<hub>...] Add unambiguous missing child parent_plan back-references
|
|
247
248
|
sync-status [<hub>...] [--adopt] Rewrite hub table rows whose printed status drifted from the plan
|
|
248
249
|
|
|
249
250
|
Lifecycle:
|
|
@@ -749,9 +750,10 @@ the command says so instead of printing an empty list.`,
|
|
|
749
750
|
|
|
750
751
|
doctor: `dotmd doctor — auto-fix everything in one pass
|
|
751
752
|
|
|
752
|
-
Runs in sequence: fix broken references,
|
|
753
|
-
frontmatter prose into body sections,
|
|
754
|
-
the index, then show
|
|
753
|
+
Runs in sequence: fix broken references, repair unambiguous membership
|
|
754
|
+
back-references, lint --fix, move over-cap frontmatter prose into body sections,
|
|
755
|
+
sync hub status rows, sync dates from git, regenerate the index, then show
|
|
756
|
+
remaining issues.
|
|
755
757
|
|
|
756
758
|
Modes:
|
|
757
759
|
(default) Auto-fix pass — previews by default since 0.37.0
|
|
@@ -861,6 +863,21 @@ Rows under a status column with no readable status word are reported by
|
|
|
861
863
|
are not findings. Not to be confused with \`dotmd set <status>\`, which changes a
|
|
862
864
|
document's OWN status — this only rewrites what a hub prints about others.`,
|
|
863
865
|
|
|
866
|
+
'fix-membership': `dotmd fix-membership — repair unambiguous missing parent_plan back-references
|
|
867
|
+
|
|
868
|
+
A repair is safe only when one live hub has already declared the relationship
|
|
869
|
+
in its frontmatter runlist or body execution order and the live child plan has
|
|
870
|
+
no parent_plan at all. The command writes the child-relative back-reference and
|
|
871
|
+
updated date atomically.
|
|
872
|
+
|
|
873
|
+
It never creates or edits hub prose, never overwrites another parent, and skips
|
|
874
|
+
a parentless child ranked by multiple hubs as ambiguous.
|
|
875
|
+
|
|
876
|
+
dotmd fix-membership every hub in the repo
|
|
877
|
+
dotmd fix-membership <hub>... narrow to named hubs
|
|
878
|
+
dotmd fix-membership --dry-run preview without writing
|
|
879
|
+
dotmd fix-membership --dry-run --json`,
|
|
880
|
+
|
|
864
881
|
'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
|
|
865
882
|
|
|
866
883
|
Scans all docs for reference fields that point to non-existent files,
|
|
@@ -1806,6 +1823,7 @@ async function main() {
|
|
|
1806
1823
|
if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
|
|
1807
1824
|
if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
|
|
1808
1825
|
if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
|
|
1826
|
+
if (command === 'fix-membership') { const { runFixMembership } = await import('../src/fix-membership.mjs'); await runFixMembership(restArgs, config, { dryRun }); return; }
|
|
1809
1827
|
if (command === 'sync-status') { const { runSyncStatus } = await import('../src/sync-status.mjs'); await runSyncStatus(restArgs, config, { dryRun }); return; }
|
|
1810
1828
|
if (command === 'self-check') {
|
|
1811
1829
|
const { runDoctor } = await import('../src/doctor.mjs');
|
|
@@ -1917,11 +1935,14 @@ async function main() {
|
|
|
1917
1935
|
}
|
|
1918
1936
|
|
|
1919
1937
|
if (fix) {
|
|
1920
|
-
// Auto-fix: broken refs,
|
|
1938
|
+
// Auto-fix: broken refs, mechanical membership back-references, lint,
|
|
1939
|
+
// hub status tokens, then rebuild the index.
|
|
1921
1940
|
const { fixBrokenRefs } = await import('../src/fix-refs.mjs');
|
|
1941
|
+
const { fixMembershipBackrefs } = await import('../src/fix-membership.mjs');
|
|
1922
1942
|
const { runLint } = await import('../src/lint.mjs');
|
|
1923
1943
|
const { syncHubStatuses } = await import('../src/sync-status.mjs');
|
|
1924
1944
|
fixBrokenRefs(config, { dryRun, quiet: false });
|
|
1945
|
+
fixMembershipBackrefs(config, { docs: buildIndex(config).docs, dryRun, quiet: false });
|
|
1925
1946
|
runLint(['--fix'], config, { dryRun });
|
|
1926
1947
|
// Rewrites drifted status TOKENS only. Adding markers is a content edit to
|
|
1927
1948
|
// prose the user wrote, so it stays opt-in behind `sync-status --adopt`.
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
function isWithin(root, candidate) {
|
|
5
|
+
const relative = path.relative(root, candidate);
|
|
6
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Markdown body links are filesystem paths relative to the document that
|
|
10
|
+
// contains them. They deliberately do not inherit frontmatter references'
|
|
11
|
+
// repo-root fallback: that fallback can make a broken Markdown link look valid.
|
|
12
|
+
export function resolveBodyLinkTarget(href, docDir, repoRoot) {
|
|
13
|
+
if (!href) return { ok: false, reason: 'missing' };
|
|
14
|
+
|
|
15
|
+
const root = path.resolve(repoRoot);
|
|
16
|
+
const candidate = path.resolve(docDir, href);
|
|
17
|
+
if (!isWithin(root, candidate)) return { ok: false, reason: 'outside-repo' };
|
|
18
|
+
if (!existsSync(candidate)) return { ok: false, reason: 'missing' };
|
|
19
|
+
|
|
20
|
+
let canonicalRoot;
|
|
21
|
+
let canonicalTarget;
|
|
22
|
+
try {
|
|
23
|
+
canonicalRoot = realpathSync(root);
|
|
24
|
+
canonicalTarget = realpathSync(candidate);
|
|
25
|
+
} catch {
|
|
26
|
+
return { ok: false, reason: 'unreadable' };
|
|
27
|
+
}
|
|
28
|
+
if (!isWithin(canonicalRoot, canonicalTarget)) return { ok: false, reason: 'outside-repo' };
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const stat = statSync(canonicalTarget);
|
|
32
|
+
if (!stat.isFile() && !stat.isDirectory()) return { ok: false, reason: 'unsupported-target' };
|
|
33
|
+
} catch {
|
|
34
|
+
return { ok: false, reason: 'unreadable' };
|
|
35
|
+
}
|
|
36
|
+
// Return the authored lexical route after using the canonical route only for
|
|
37
|
+
// containment. Callers index documents by their repo-relative spelling (and
|
|
38
|
+
// may intentionally include an in-repo symlink alias).
|
|
39
|
+
return { ok: true, path: candidate, realPath: canonicalTarget };
|
|
40
|
+
}
|
package/src/commands.mjs
CHANGED
|
@@ -136,6 +136,7 @@ const definitions = [
|
|
|
136
136
|
command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
|
|
137
137
|
command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
|
|
138
138
|
command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
|
|
139
|
+
command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })]),
|
|
139
140
|
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
140
141
|
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('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
141
142
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
package/src/doctor.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { fixBrokenRefs } from './fix-refs.mjs';
|
|
4
4
|
import { runLint } from './lint.mjs';
|
|
5
5
|
import { syncHubStatuses } from './sync-status.mjs';
|
|
6
|
+
import { fixMembershipBackrefs } from './fix-membership.mjs';
|
|
6
7
|
import { runSet, runTouch } from './lifecycle.mjs';
|
|
7
8
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
8
9
|
import { writeRenderedIndex } from './index-file.mjs';
|
|
@@ -285,30 +286,35 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
285
286
|
process.stdout.write(bold('1. Fixing broken references...') + '\n');
|
|
286
287
|
fixBrokenRefs(config, { dryRun });
|
|
287
288
|
|
|
288
|
-
// Step 2:
|
|
289
|
-
|
|
289
|
+
// Step 2: Add only child back-references already authorized by one
|
|
290
|
+
// unambiguous hub. Hub prose is never generated or edited here.
|
|
291
|
+
process.stdout.write('\n' + bold('2. Fixing membership back-references...') + '\n');
|
|
292
|
+
fixMembershipBackrefs(config, { docs: buildIndex(config).docs, dryRun, testHooks });
|
|
293
|
+
|
|
294
|
+
// Step 3: Lint --fix
|
|
295
|
+
process.stdout.write('\n' + bold('3. Fixing frontmatter issues...') + '\n');
|
|
290
296
|
runLint(['--fix'], config, { dryRun });
|
|
291
297
|
|
|
292
|
-
// Step
|
|
293
|
-
process.stdout.write('\n' + bold('
|
|
298
|
+
// Step 4: Move over-cap status prose into body sections.
|
|
299
|
+
process.stdout.write('\n' + bold('4. Fixing long frontmatter...') + '\n');
|
|
294
300
|
runFrontmatterFix(config, { dryRun });
|
|
295
301
|
|
|
296
|
-
// Step
|
|
302
|
+
// Step 5: Rewrite hub rows whose printed status drifted from the plan they
|
|
297
303
|
// link to. Tokens only — adding markers is a content edit to prose the user
|
|
298
304
|
// wrote, so it stays opt-in behind `dotmd sync-status --adopt`.
|
|
299
|
-
process.stdout.write('\n' + bold('
|
|
305
|
+
process.stdout.write('\n' + bold('5. Syncing hub status rows...') + '\n');
|
|
300
306
|
const hubSync = syncHubStatuses(config, { docs: buildIndex(config).docs, dryRun });
|
|
301
307
|
if (hubSync.fixed === 0 && hubSync.adopted === 0 && hubSync.unreadable === 0) {
|
|
302
308
|
process.stdout.write('Hub status rows are in sync.\n');
|
|
303
309
|
}
|
|
304
310
|
|
|
305
|
-
// Step
|
|
306
|
-
process.stdout.write('\n' + bold('
|
|
311
|
+
// Step 6: Sync dates from git
|
|
312
|
+
process.stdout.write('\n' + bold('6. Syncing dates from git...') + '\n');
|
|
307
313
|
runTouch(['--git'], config, { dryRun });
|
|
308
314
|
|
|
309
|
-
// Step
|
|
315
|
+
// Step 7: Regenerate index. Heading always prints so numbering remains
|
|
310
316
|
// contiguous even when `index.path` isn't configured.
|
|
311
|
-
process.stdout.write('\n' + bold('
|
|
317
|
+
process.stdout.write('\n' + bold('7. Regenerating index...') + '\n');
|
|
312
318
|
if (!config.indexPath) {
|
|
313
319
|
process.stdout.write('No index path configured (skip).\n');
|
|
314
320
|
} else if (dryRun) {
|
|
@@ -318,11 +324,11 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
318
324
|
process.stdout.write('Index updated.\n');
|
|
319
325
|
}
|
|
320
326
|
|
|
321
|
-
// Step
|
|
327
|
+
// Step 8: Clean up retired Claude Code command scaffolding. The per-repo
|
|
322
328
|
// `.claude/commands/{plans,docs}.md` files are superseded by the dotmd plugin
|
|
323
329
|
// skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
|
|
324
330
|
// Always print the heading so the numbering remains contiguous.
|
|
325
|
-
process.stdout.write('\n' + bold('
|
|
331
|
+
process.stdout.write('\n' + bold('8. Claude Code commands:') + '\n');
|
|
326
332
|
if (dryRun) {
|
|
327
333
|
const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
|
|
328
334
|
if (wouldRemove.length === 0) {
|
|
@@ -343,8 +349,8 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
343
349
|
}
|
|
344
350
|
}
|
|
345
351
|
|
|
346
|
-
// Step
|
|
347
|
-
const issueLabel = dryRun ? '
|
|
352
|
+
// Step 9: Show remaining check
|
|
353
|
+
const issueLabel = dryRun ? '9. Remaining issues in current tree (preview fixes above were not applied):' : '9. Remaining issues:';
|
|
348
354
|
process.stdout.write('\n' + bold(issueLabel) + '\n');
|
|
349
355
|
const freshIndex = buildIndex(config);
|
|
350
356
|
process.stdout.write(renderCheck(freshIndex, config));
|
package/src/extractors.mjs
CHANGED
|
@@ -53,16 +53,28 @@ export function extractBodyLinks(body) {
|
|
|
53
53
|
.replace(/^```[\s\S]*?^```/gm, '')
|
|
54
54
|
.replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
|
|
55
55
|
const links = [];
|
|
56
|
-
//
|
|
57
|
-
|
|
56
|
+
// Supported inline destinations are a whitespace-free token (with Markdown
|
|
57
|
+
// backslash escapes) or an angle-bracket destination, plus an optional
|
|
58
|
+
// quoted/parenthesized title. Reference links, HTML links, and nested
|
|
59
|
+
// unescaped parentheses remain deliberately outside this lightweight parser.
|
|
60
|
+
const regex = /(?<!!)\[([^\]\n]+)\]\(\s*(<[^>\n]+>|(?:\\.|[^\s()])+)(?:\s+(?:"[^"\n]*"|'[^'\n]*'|\([^\n)]*\)))?\s*\)/g;
|
|
58
61
|
let match;
|
|
59
62
|
while ((match = regex.exec(stripped)) !== null) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
const destination = match[2];
|
|
64
|
+
const angle = destination.startsWith('<') && destination.endsWith('>');
|
|
65
|
+
const rawHref = angle ? destination.slice(1, -1) : destination;
|
|
66
|
+
// These destinations are meaningful to a renderer or another application,
|
|
67
|
+
// not portable repo-local filesystem targets.
|
|
68
|
+
if (/^(?:#|\/|[a-z][a-z\d+.-]*:)/i.test(rawHref)) continue;
|
|
69
|
+
|
|
70
|
+
const rawPath = rawHref.replace(/[?#].*$/, '');
|
|
71
|
+
if (!rawPath) continue;
|
|
72
|
+
const unescaped = rawPath.replace(/\\([\s()[\]<>])/g, '$1');
|
|
73
|
+
let href;
|
|
74
|
+
try { href = decodeURIComponent(unescaped); }
|
|
75
|
+
catch { href = unescaped; }
|
|
76
|
+
const targetKind = /\.md$/i.test(href) ? 'document' : 'file';
|
|
77
|
+
links.push({ text: match[1], rawHref, href, targetKind, angle });
|
|
66
78
|
}
|
|
67
79
|
return links;
|
|
68
80
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { collectMembershipBackrefCandidates } from './hub-membership.mjs';
|
|
3
|
+
import { planChildParentUpdate } from './parent-plan.mjs';
|
|
4
|
+
import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
5
|
+
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
6
|
+
import { isHubDoc } from './hub.mjs';
|
|
7
|
+
import { bold, dim, green, yellow } from './color.mjs';
|
|
8
|
+
import { die } from './util.mjs';
|
|
9
|
+
|
|
10
|
+
function resolveHubArgs(args, docs) {
|
|
11
|
+
const paths = new Set();
|
|
12
|
+
for (const arg of args) {
|
|
13
|
+
const slug = arg.replace(/\.md$/, '');
|
|
14
|
+
const matches = docs.filter(doc =>
|
|
15
|
+
doc.path === arg || doc.path === `${slug}.md`
|
|
16
|
+
|| path.basename(doc.path, '.md') === path.basename(slug));
|
|
17
|
+
if (matches.length === 0) die(`No doc matches "${arg}".`);
|
|
18
|
+
if (matches.length > 1) {
|
|
19
|
+
die(`Multiple docs match "${arg}":\n${matches.map(match => ` ${match.path}`).join('\n')}`);
|
|
20
|
+
}
|
|
21
|
+
const [match] = matches;
|
|
22
|
+
if (!isHubDoc(match)) {
|
|
23
|
+
die(`${match.path} is not a hub — it has no \`runlist:\` and no \`execution_mode: coordination|roadmap\`. `
|
|
24
|
+
+ 'Run `dotmd fix-membership` with no argument to sweep every hub.');
|
|
25
|
+
}
|
|
26
|
+
paths.add(match.path);
|
|
27
|
+
}
|
|
28
|
+
return paths;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fixMembershipBackrefs(config, {
|
|
32
|
+
docs,
|
|
33
|
+
dryRun = false,
|
|
34
|
+
hubPaths = null,
|
|
35
|
+
quiet = false,
|
|
36
|
+
testHooks,
|
|
37
|
+
} = {}) {
|
|
38
|
+
const planned = collectMembershipBackrefCandidates(docs, config, { hubPaths });
|
|
39
|
+
const updates = [];
|
|
40
|
+
const changes = [];
|
|
41
|
+
const skipped = [];
|
|
42
|
+
const guardByPath = new Map();
|
|
43
|
+
|
|
44
|
+
testHooks?.afterMembershipCandidates?.(planned);
|
|
45
|
+
for (const candidate of planned.candidates) {
|
|
46
|
+
if (candidate.childType !== 'plan') {
|
|
47
|
+
skipped.push({ path: candidate.childPath, hub: candidate.hubPath, reason: 'untyped-child' });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const update = planChildParentUpdate(candidate.childAbs, candidate.hubAbs, config);
|
|
51
|
+
if (update.raw !== candidate.childRaw) {
|
|
52
|
+
throw new MutationConflictError(`Child changed while membership repair was being prepared: ${candidate.childPath}`);
|
|
53
|
+
}
|
|
54
|
+
if (!update.update) {
|
|
55
|
+
skipped.push({
|
|
56
|
+
path: candidate.childPath,
|
|
57
|
+
hub: candidate.hubPath,
|
|
58
|
+
reason: update.reason,
|
|
59
|
+
...(update.existing ? { existing: update.existing } : {}),
|
|
60
|
+
});
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
updates.push(update.update);
|
|
64
|
+
guardByPath.set(candidate.hubAbs, { path: candidate.hubAbs, expectedContent: candidate.hubRaw });
|
|
65
|
+
changes.push({
|
|
66
|
+
path: candidate.childPath,
|
|
67
|
+
hub: candidate.hubPath,
|
|
68
|
+
ref: update.ref,
|
|
69
|
+
sources: candidate.sources,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const ambiguous = planned.ambiguous.map(item => ({ path: item.childPath, hubs: item.hubPaths }));
|
|
74
|
+
const managed = [
|
|
75
|
+
...updates.map(update => update.path),
|
|
76
|
+
...guardByPath.values().map(guard => guard.path),
|
|
77
|
+
];
|
|
78
|
+
if (managed.length) authorizeManagedSweep(managed, config, { kind: 'Membership repair source' });
|
|
79
|
+
|
|
80
|
+
testHooks?.afterMembershipPlan?.({ changes, ambiguous, skipped });
|
|
81
|
+
if (!dryRun && updates.length > 0) {
|
|
82
|
+
mutateFileSet({ updates, guards: [...guardByPath.values()] }, { repoRoot: config.repoRoot, testHooks });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = {
|
|
86
|
+
fixed: changes.length,
|
|
87
|
+
skipped: skipped.length + ambiguous.length,
|
|
88
|
+
ambiguous: ambiguous.length,
|
|
89
|
+
changes,
|
|
90
|
+
skippedDetails: skipped,
|
|
91
|
+
ambiguousDetails: ambiguous,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (!quiet) {
|
|
95
|
+
const prefix = dryRun ? `${dim('[dry-run]')} ` : '';
|
|
96
|
+
for (const change of changes) {
|
|
97
|
+
process.stdout.write(`${prefix}${green(dryRun ? 'Would set' : 'Set')}: ${change.path} `
|
|
98
|
+
+ `${dim(`parent_plan: ${change.ref} (stated by ${change.hub})`)}\n`);
|
|
99
|
+
}
|
|
100
|
+
for (const item of ambiguous) {
|
|
101
|
+
process.stdout.write(yellow(`Skipped ambiguous membership: ${item.path} is ranked by ${item.hubs.join(', ')}.`) + '\n');
|
|
102
|
+
}
|
|
103
|
+
for (const item of skipped) {
|
|
104
|
+
process.stdout.write(yellow(`Skipped membership repair: ${item.path} (${item.reason}).`) + '\n');
|
|
105
|
+
}
|
|
106
|
+
if (changes.length > 0) {
|
|
107
|
+
process.stdout.write(`\n${bold(`${prefix}${changes.length} membership back-reference${changes.length === 1 ? '' : 's'} ${dryRun ? 'would be repaired' : 'repaired'}.`)}\n`);
|
|
108
|
+
} else if (ambiguous.length === 0 && skipped.length === 0) {
|
|
109
|
+
process.stdout.write(green('Membership back-references are in sync.') + '\n');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function runFixMembership(argv, config, opts = {}) {
|
|
116
|
+
const { buildIndex } = await import('./index.mjs');
|
|
117
|
+
const json = argv.includes('--json');
|
|
118
|
+
const hubArgs = argv.filter(arg => !arg.startsWith('-'));
|
|
119
|
+
const docs = buildIndex(config).docs;
|
|
120
|
+
const hubPaths = hubArgs.length ? resolveHubArgs(hubArgs, docs) : null;
|
|
121
|
+
const result = fixMembershipBackrefs(config, {
|
|
122
|
+
docs,
|
|
123
|
+
dryRun: opts.dryRun,
|
|
124
|
+
hubPaths,
|
|
125
|
+
quiet: json,
|
|
126
|
+
testHooks: opts.testHooks,
|
|
127
|
+
});
|
|
128
|
+
if (json) {
|
|
129
|
+
process.stdout.write(`${JSON.stringify({ dryRun: Boolean(opts.dryRun), ...result }, null, 2)}\n`);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
package/src/fix-refs.mjs
CHANGED
|
@@ -102,7 +102,9 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
102
102
|
|
|
103
103
|
for (const doc of index.docs) {
|
|
104
104
|
const brokenBodyWarnings = doc.warnings.filter(w =>
|
|
105
|
-
w.
|
|
105
|
+
w.meta?.kind === 'body-link-resolution'
|
|
106
|
+
&& w.meta?.targetKind === 'document'
|
|
107
|
+
&& w.meta?.reason === 'missing'
|
|
106
108
|
);
|
|
107
109
|
if (!brokenBodyWarnings.length) continue;
|
|
108
110
|
|
|
@@ -112,12 +114,14 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
112
114
|
let newBody = body;
|
|
113
115
|
const docDir = path.dirname(absPath);
|
|
114
116
|
const bodyFixes = [];
|
|
117
|
+
const seenBodyTargets = new Set();
|
|
115
118
|
|
|
116
119
|
for (const warn of brokenBodyWarnings) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
const brokenHref = warn.meta.relPath;
|
|
121
|
+
const rawHref = warn.meta.rawHref ?? brokenHref;
|
|
122
|
+
const targetKey = `${warn.meta.angle ? 'angle' : 'plain'}:${rawHref}`;
|
|
123
|
+
if (seenBodyTargets.has(targetKey)) continue;
|
|
124
|
+
seenBodyTargets.add(targetKey);
|
|
121
125
|
const brokenBasename = path.basename(brokenHref);
|
|
122
126
|
|
|
123
127
|
if (duplicateBasenames.has(brokenBasename)) continue;
|
|
@@ -127,14 +131,21 @@ export function fixBrokenRefs(config, opts = {}) {
|
|
|
127
131
|
const correctHref = path.relative(docDir, resolved).split(path.sep).join('/');
|
|
128
132
|
if (correctHref === brokenHref) continue;
|
|
129
133
|
|
|
134
|
+
const suffixAt = rawHref.search(/[?#]/);
|
|
135
|
+
const suffix = suffixAt === -1 ? '' : rawHref.slice(suffixAt);
|
|
136
|
+
const renderedHref = warn.meta.angle
|
|
137
|
+
? `<${correctHref}${suffix}>`
|
|
138
|
+
: `${correctHref.replace(/([\s()[\]<>])/g, '\\$1')}${suffix}`;
|
|
130
139
|
const linkRegex = new RegExp(
|
|
131
|
-
'(
|
|
140
|
+
'(\\]\\(\\s*)' + (warn.meta.angle ? '<' : '') + escapeRegex(rawHref) + (warn.meta.angle ? '>' : '') + '(?=\\s|\\))',
|
|
132
141
|
'g'
|
|
133
142
|
);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
143
|
+
let replacements = 0;
|
|
144
|
+
newBody = newBody.replace(linkRegex, (_, prefix) => {
|
|
145
|
+
replacements++;
|
|
146
|
+
return `${prefix}${renderedHref}`;
|
|
147
|
+
});
|
|
148
|
+
for (let i = 0; i < replacements; i++) bodyFixes.push({ brokenHref, correctHref });
|
|
138
149
|
}
|
|
139
150
|
|
|
140
151
|
if (bodyFixes.length > 0 && newBody !== body) {
|
package/src/hub-membership.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { resolveRefPath, toRepoPath } from './util.mjs';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, normalizeStringList, resolveRefPath, toRepoPath } from './util.mjs';
|
|
5
5
|
import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
|
|
6
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
6
7
|
|
|
7
8
|
// Membership drift: a hub's list of children and the plans that claim it via
|
|
8
9
|
// `parent_plan:` are two halves of one relationship, and either half can go
|
|
@@ -37,11 +38,159 @@ import { detectBodyRunlistRefs, isHubDoc } from './hub.mjs';
|
|
|
37
38
|
const ORPHAN_KIND = 'hub-membership-orphan';
|
|
38
39
|
const BACKREF_KIND = 'hub-membership-backref';
|
|
39
40
|
|
|
41
|
+
function quietDoc(status, type, config) {
|
|
42
|
+
return (config.lifecycle?.isTerminal?.(status, type)
|
|
43
|
+
?? config.lifecycle?.terminalStatuses?.has(status))
|
|
44
|
+
|| config.lifecycle?.skipsWarnings(status, type);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function configuredRunlist(parsed, config) {
|
|
48
|
+
const fields = [
|
|
49
|
+
...(config.referenceFields?.bidirectional ?? []),
|
|
50
|
+
...(config.referenceFields?.unidirectional ?? []),
|
|
51
|
+
];
|
|
52
|
+
if (!fields.includes('runlist')) return { paths: [], directions: [] };
|
|
53
|
+
const paths = [];
|
|
54
|
+
const directions = [];
|
|
55
|
+
for (const entry of normalizeStringList(parsed.runlist)) {
|
|
56
|
+
const oneWay = entry.match(/^>\s*(.+)$/);
|
|
57
|
+
paths.push(oneWay ? oneWay[1].trim() : entry);
|
|
58
|
+
directions.push(oneWay ? 'one-way' : 'two-way');
|
|
59
|
+
}
|
|
60
|
+
return { paths, directions };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One shared definition of mechanically repairable membership evidence. It
|
|
64
|
+
// reads the exact hub/child bytes it returns so callers can bind those snapshots
|
|
65
|
+
// into an atomic mutation: frontmatter `runlist:` (except `>` one-way entries)
|
|
66
|
+
// plus the body order read by `dotmd runlist next`. Ordinary body links remain
|
|
67
|
+
// pointers, and an existing parent — same or different — is never a candidate.
|
|
68
|
+
export function collectMembershipBackrefCandidates(docs, config, { hubPaths = null } = {}) {
|
|
69
|
+
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
70
|
+
const evidence = new Map();
|
|
71
|
+
|
|
72
|
+
const resolve = (ref, dir) => {
|
|
73
|
+
const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
|
|
74
|
+
return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
for (const indexedHub of docs) {
|
|
78
|
+
if (hubPaths && !hubPaths.has(indexedHub.path)) continue;
|
|
79
|
+
// The index is the discovery snapshot. Re-read only docs it identified as
|
|
80
|
+
// hubs; current bytes below can still demote one safely, while a doc that
|
|
81
|
+
// became a hub after indexing is simply discovered on the next run.
|
|
82
|
+
if (!isHubDoc(indexedHub)) continue;
|
|
83
|
+
const hubAbs = path.join(config.repoRoot, indexedHub.path);
|
|
84
|
+
let hubRaw;
|
|
85
|
+
let hubFrontmatter;
|
|
86
|
+
let body;
|
|
87
|
+
try {
|
|
88
|
+
hubRaw = readFileSync(hubAbs, 'utf8');
|
|
89
|
+
({ frontmatter: hubFrontmatter, body } = extractFrontmatter(hubRaw));
|
|
90
|
+
} catch { continue; }
|
|
91
|
+
const parsedHub = parseSimpleFrontmatter(hubFrontmatter);
|
|
92
|
+
const runlist = configuredRunlist(parsedHub, config);
|
|
93
|
+
const currentHub = {
|
|
94
|
+
...indexedHub,
|
|
95
|
+
type: asString(parsedHub.type) ?? null,
|
|
96
|
+
status: asString(parsedHub.status) ?? null,
|
|
97
|
+
executionMode: asString(parsedHub.execution_mode) ?? null,
|
|
98
|
+
refFields: { ...indexedHub.refFields, runlist: runlist.paths },
|
|
99
|
+
refFieldDirections: { ...indexedHub.refFieldDirections, runlist: runlist.directions },
|
|
100
|
+
};
|
|
101
|
+
if (!isHubDoc(currentHub) || quietDoc(currentHub.status, currentHub.type, config)) continue;
|
|
102
|
+
|
|
103
|
+
const hubDir = path.dirname(hubAbs);
|
|
104
|
+
const runlistTargets = new Map();
|
|
105
|
+
for (let index = 0; index < runlist.paths.length; index++) {
|
|
106
|
+
const child = resolve(runlist.paths[index], hubDir);
|
|
107
|
+
if (!child || child.path === currentHub.path) continue;
|
|
108
|
+
runlistTargets.set(child.path, runlist.directions[index]);
|
|
109
|
+
if (runlist.directions[index] === 'one-way') continue;
|
|
110
|
+
addEvidence(child, 'frontmatter-runlist', runlist.paths[index]);
|
|
111
|
+
}
|
|
112
|
+
for (const ref of detectBodyRunlistRefs(body)) {
|
|
113
|
+
const child = resolve(ref, hubDir);
|
|
114
|
+
if (!child || child.path === currentHub.path) continue;
|
|
115
|
+
// A frontmatter entry owns this pair. A `>` entry is an explicit opt-out;
|
|
116
|
+
// a normal entry was already recorded above. Either way, body order must
|
|
117
|
+
// not create a second or contradictory authorization.
|
|
118
|
+
if (runlistTargets.has(child.path)) continue;
|
|
119
|
+
addEvidence(child, 'body-order', ref);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function addEvidence(indexedChild, source, ref) {
|
|
123
|
+
const childAbs = path.join(config.repoRoot, indexedChild.path);
|
|
124
|
+
let childRaw;
|
|
125
|
+
let childFrontmatter;
|
|
126
|
+
try {
|
|
127
|
+
childRaw = readFileSync(childAbs, 'utf8');
|
|
128
|
+
({ frontmatter: childFrontmatter } = extractFrontmatter(childRaw));
|
|
129
|
+
} catch { return; }
|
|
130
|
+
const parsedChild = parseSimpleFrontmatter(childFrontmatter);
|
|
131
|
+
const childType = asString(parsedChild.type) ?? null;
|
|
132
|
+
const childStatus = asString(parsedChild.status) ?? null;
|
|
133
|
+
if (quietDoc(childStatus, childType, config)) return;
|
|
134
|
+
// Preserve the validator's brownfield behavior: an untyped legacy doc is
|
|
135
|
+
// still plan-like enough to diagnose. The writer applies the stricter
|
|
136
|
+
// type: plan boundary before it mutates anything.
|
|
137
|
+
if (childType && childType !== 'plan') return;
|
|
138
|
+
const childRunlist = configuredRunlist(parsedChild, config);
|
|
139
|
+
const currentChild = {
|
|
140
|
+
...indexedChild,
|
|
141
|
+
type: childType,
|
|
142
|
+
status: childStatus,
|
|
143
|
+
executionMode: asString(parsedChild.execution_mode) ?? null,
|
|
144
|
+
refFields: { ...indexedChild.refFields, runlist: childRunlist.paths },
|
|
145
|
+
};
|
|
146
|
+
if (isHubDoc(currentChild)) return;
|
|
147
|
+
if (normalizeStringList(parsedChild.parent_plan).length > 0) return;
|
|
148
|
+
|
|
149
|
+
const key = `${indexedChild.path}\0${currentHub.path}`;
|
|
150
|
+
const existing = evidence.get(key);
|
|
151
|
+
if (existing) {
|
|
152
|
+
if (!existing.sources.includes(source)) existing.sources.push(source);
|
|
153
|
+
if (!existing.refs.includes(ref)) existing.refs.push(ref);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
evidence.set(key, {
|
|
157
|
+
child: indexedChild,
|
|
158
|
+
hub: currentHub,
|
|
159
|
+
childPath: indexedChild.path,
|
|
160
|
+
hubPath: currentHub.path,
|
|
161
|
+
childAbs,
|
|
162
|
+
hubAbs,
|
|
163
|
+
childRaw,
|
|
164
|
+
childType,
|
|
165
|
+
hubRaw,
|
|
166
|
+
sources: [source],
|
|
167
|
+
refs: [ref],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const byChild = new Map();
|
|
173
|
+
for (const item of evidence.values()) {
|
|
174
|
+
if (!byChild.has(item.childPath)) byChild.set(item.childPath, []);
|
|
175
|
+
byChild.get(item.childPath).push(item);
|
|
176
|
+
}
|
|
177
|
+
const candidates = [];
|
|
178
|
+
const ambiguous = [];
|
|
179
|
+
for (const [childPath, items] of byChild) {
|
|
180
|
+
if (items.length === 1) candidates.push(items[0]);
|
|
181
|
+
else ambiguous.push({ childPath, hubPaths: items.map(item => item.hubPath).sort(), evidence: items });
|
|
182
|
+
}
|
|
183
|
+
candidates.sort((a, b) => a.childPath.localeCompare(b.childPath));
|
|
184
|
+
ambiguous.sort((a, b) => a.childPath.localeCompare(b.childPath));
|
|
185
|
+
return { candidates, ambiguous, evidence: [...evidence.values()] };
|
|
186
|
+
}
|
|
187
|
+
|
|
40
188
|
export function checkHubMembershipDrift(docs, config) {
|
|
41
189
|
const warnings = [];
|
|
42
190
|
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
43
191
|
// type stays loud for another that declared the same name.
|
|
44
|
-
const quiet = (d) => config.lifecycle?.
|
|
192
|
+
const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
|
|
193
|
+
?? config.lifecycle?.terminalStatuses?.has(d.status))
|
|
45
194
|
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
46
195
|
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
47
196
|
const refFields = [
|
|
@@ -54,6 +203,11 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
54
203
|
const abs = resolveRefPath(String(ref).replace(/#.*$/, ''), dir, config.repoRoot);
|
|
55
204
|
return abs ? byPath.get(toRepoPath(abs, config.repoRoot)) ?? null : null;
|
|
56
205
|
};
|
|
206
|
+
const resolveBody = (link, dir) => {
|
|
207
|
+
if (link.targetKind && link.targetKind !== 'document') return null;
|
|
208
|
+
const result = resolveBodyLinkTarget(link.href, dir, config.repoRoot);
|
|
209
|
+
return result.ok ? byPath.get(toRepoPath(result.path, config.repoRoot)) ?? null : null;
|
|
210
|
+
};
|
|
57
211
|
|
|
58
212
|
// Everything a hub says about other docs: every configured reference field
|
|
59
213
|
// plus every body link. Config-driven rather than a hardcoded field list, so a
|
|
@@ -68,7 +222,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
68
222
|
}
|
|
69
223
|
}
|
|
70
224
|
for (const link of (hub.bodyLinks ?? [])) {
|
|
71
|
-
const target =
|
|
225
|
+
const target = resolveBody(link, dir);
|
|
72
226
|
if (target) known.add(target.path);
|
|
73
227
|
}
|
|
74
228
|
return known;
|
|
@@ -112,37 +266,16 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
112
266
|
// it can't see; children already covered there are skipped, so one missing
|
|
113
267
|
// back-ref is never reported twice. Warns on the CHILD, matching that check:
|
|
114
268
|
// it's the file that needs the edit.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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(child)) 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
|
-
}
|
|
269
|
+
const { evidence } = collectMembershipBackrefCandidates(docs, config);
|
|
270
|
+
for (const item of evidence) {
|
|
271
|
+
if (!item.sources.includes('body-order')) continue;
|
|
272
|
+
const { child, hub } = item;
|
|
273
|
+
warnings.push({
|
|
274
|
+
path: child.path,
|
|
275
|
+
level: 'warning',
|
|
276
|
+
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.`,
|
|
277
|
+
meta: { kind: BACKREF_KIND, hub: hub.path, source: 'body-order' },
|
|
278
|
+
});
|
|
146
279
|
}
|
|
147
280
|
|
|
148
281
|
return warnings;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { MutationConflictError } from './atomic-mutation.mjs';
|
|
5
|
+
import { escapeRegex, normalizeStringList, nowIso, resolveRefPath, toRepoPath } from './util.mjs';
|
|
6
|
+
|
|
7
|
+
// Replace a top-level frontmatter field (its `key:` line + any indented
|
|
8
|
+
// continuation block) with `serialized`, or append it when absent. Shared by
|
|
9
|
+
// runlist mutations and the membership back-reference fixer so both paths
|
|
10
|
+
// serialize `parent_plan:` identically.
|
|
11
|
+
export function upsertFrontmatterField(fm, key, serialized) {
|
|
12
|
+
const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
|
|
13
|
+
if (re.test(fm)) return fm.replace(re, serialized);
|
|
14
|
+
return fm.replace(/\s*$/, '') + '\n' + serialized;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Plan an atomic child-side `parent_plan:` update. The caller owns the evidence
|
|
18
|
+
// that authorizes the relationship (a runlist entry or body-order row); this
|
|
19
|
+
// helper owns the byte-level invariant: never clobber another parent, write a
|
|
20
|
+
// child-relative forward-slash ref, bump `updated:`, and compare-and-swap the
|
|
21
|
+
// exact child snapshot used to make the decision.
|
|
22
|
+
export function planChildParentUpdate(childAbs, hubAbs, config) {
|
|
23
|
+
const raw = readFileSync(childAbs, 'utf8');
|
|
24
|
+
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
25
|
+
if (fmRaw == null) return { wrote: false, reason: 'missing-frontmatter', raw };
|
|
26
|
+
const fm = parseSimpleFrontmatter(fmRaw);
|
|
27
|
+
const childDir = path.dirname(childAbs);
|
|
28
|
+
const existingRefs = normalizeStringList(fm.parent_plan);
|
|
29
|
+
if (existingRefs.length > 0) {
|
|
30
|
+
const resolved = existingRefs.map(ref => resolveRefPath(ref, childDir, config.repoRoot));
|
|
31
|
+
if (existingRefs.length === 1 && resolved[0] === hubAbs) {
|
|
32
|
+
return { wrote: false, reason: 'already-set', raw };
|
|
33
|
+
}
|
|
34
|
+
return { wrote: false, reason: 'different-parent', existing: existingRefs.join(', '), raw };
|
|
35
|
+
}
|
|
36
|
+
const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
|
|
37
|
+
return {
|
|
38
|
+
wrote: true,
|
|
39
|
+
ref,
|
|
40
|
+
raw,
|
|
41
|
+
update: {
|
|
42
|
+
path: childAbs,
|
|
43
|
+
expectedContent: raw,
|
|
44
|
+
render: current => {
|
|
45
|
+
const { frontmatter: currentFm } = extractFrontmatter(current);
|
|
46
|
+
const currentParsed = parseSimpleFrontmatter(currentFm);
|
|
47
|
+
const currentParents = normalizeStringList(currentParsed.parent_plan);
|
|
48
|
+
if (currentParents.length > 0
|
|
49
|
+
&& !(currentParents.length === 1 && resolveRefPath(currentParents[0], childDir, config.repoRoot) === hubAbs)) {
|
|
50
|
+
throw new MutationConflictError(`Child parent_plan changed while the membership mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
|
|
51
|
+
}
|
|
52
|
+
let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', `parent_plan: ${ref}`);
|
|
53
|
+
updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
|
|
54
|
+
return replaceFrontmatter(current, updatedFm);
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
package/src/prompts.mjs
CHANGED
|
@@ -118,7 +118,7 @@ function findPromptTarget(promptDoc, config) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
const links = promptDoc.bodyLinks ?? [];
|
|
121
|
-
const mdLink = links.find(l =>
|
|
121
|
+
const mdLink = links.find(l => l.targetKind === 'document');
|
|
122
122
|
if (mdLink) return resolveBodyLink(mdLink.href, promptDoc.path);
|
|
123
123
|
return null;
|
|
124
124
|
}
|
package/src/render.mjs
CHANGED
|
@@ -425,6 +425,15 @@ export function classifyIssueAction(issue) {
|
|
|
425
425
|
if (issue?.meta?.kind === 'hub-status-unreadable') {
|
|
426
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
427
|
}
|
|
428
|
+
if (issue?.meta?.kind === 'hub-membership-backref') {
|
|
429
|
+
return { action: 'dotmd fix-membership', fixable: true, label: 'membership back-references' };
|
|
430
|
+
}
|
|
431
|
+
if (issue?.meta?.kind === 'hub-membership-orphan') {
|
|
432
|
+
return { action: `edit ${file}: add the child to the hub's meaningful list, or correct the child's parent_plan`, fixable: false, label: 'orphan membership claims' };
|
|
433
|
+
}
|
|
434
|
+
if (issue?.meta?.kind === 'hub-membership-conflict') {
|
|
435
|
+
return { action: `edit ${file}: reconcile its existing parent_plan with ${issue.meta.hub}`, fixable: false, label: 'membership parent conflicts' };
|
|
436
|
+
}
|
|
428
437
|
|
|
429
438
|
if (/Missing frontmatter `status`/.test(message)) {
|
|
430
439
|
return { action: `dotmd bulk-tag ${file}`, fixable: false, label: 'missing status' };
|
|
@@ -454,7 +463,13 @@ export function classifyIssueAction(issue) {
|
|
|
454
463
|
if (/`modules` is required/.test(message)) {
|
|
455
464
|
return { action: `edit ${file} modules: or run dotmd lint --fix if scaffolded empty`, fixable: false, label: 'module metadata' };
|
|
456
465
|
}
|
|
457
|
-
if (
|
|
466
|
+
if (issue?.meta?.kind === 'body-link-resolution') {
|
|
467
|
+
if (issue.meta.targetKind === 'document' && issue.meta.reason === 'missing') {
|
|
468
|
+
return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'document links' };
|
|
469
|
+
}
|
|
470
|
+
return { action: `edit ${file}: correct the linked file, asset, or directory`, fixable: false, label: 'file links' };
|
|
471
|
+
}
|
|
472
|
+
if (/entry `.*` does not resolve/.test(message)) {
|
|
458
473
|
return { action: 'dotmd fix-refs --dry-run', fixable: true, label: 'references' };
|
|
459
474
|
}
|
|
460
475
|
|
package/src/runlist.mjs
CHANGED
|
@@ -5,7 +5,6 @@ import { extractFirstHeading } from './extractors.mjs';
|
|
|
5
5
|
import {
|
|
6
6
|
asString,
|
|
7
7
|
die,
|
|
8
|
-
escapeRegex,
|
|
9
8
|
isArchivedPath,
|
|
10
9
|
normalizeStringList,
|
|
11
10
|
nowIso,
|
|
@@ -21,6 +20,7 @@ import { bold, cyan, dim, green, red, yellow } from './color.mjs';
|
|
|
21
20
|
import { authorizeManagedDestination, authorizeManagedSource } from './managed-path.mjs';
|
|
22
21
|
import { mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
23
22
|
import { pickupFactsForDoc } from './pickup.mjs';
|
|
23
|
+
import { planChildParentUpdate, upsertFrontmatterField } from './parent-plan.mjs';
|
|
24
24
|
|
|
25
25
|
// A child is the runlist's NEXT PICKUP only when a session could start it right
|
|
26
26
|
// now — i.e. its status is one `dotmd use` accepts. The "parked" statuses
|
|
@@ -433,17 +433,6 @@ function renderRunlist(hubRepoPath, children, opts = {}) {
|
|
|
433
433
|
|
|
434
434
|
// --- `runlist add` mutation (Phase 1) -------------------------------------
|
|
435
435
|
|
|
436
|
-
// Replace a top-level frontmatter field (its `key:` line + any indented
|
|
437
|
-
// continuation block) with `serialized`, or append it when absent. The regex
|
|
438
|
-
// mirrors what `mergeBodyFrontmatter` uses so the rewritten field keeps the
|
|
439
|
-
// scaffold's shape. Shared by the block-array (`runlist:`) and scalar
|
|
440
|
-
// (`parent_plan:`, `updated:`) writers below.
|
|
441
|
-
function upsertFrontmatterField(fm, key, serialized) {
|
|
442
|
-
const re = new RegExp(`^${escapeRegex(key)}:.*(\\n[ \\t]+.*)*`, 'm');
|
|
443
|
-
if (re.test(fm)) return fm.replace(re, serialized);
|
|
444
|
-
return fm.replace(/\s*$/, '') + '\n' + serialized;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
436
|
function serializeBlockArray(key, items) {
|
|
448
437
|
if (items.length === 0) return `${key}:`;
|
|
449
438
|
return `${key}:\n${items.map(v => ` - ${v}`).join('\n')}`;
|
|
@@ -499,37 +488,6 @@ function classifyChildToken(token, hubDir, hubSlug, pos, config) {
|
|
|
499
488
|
};
|
|
500
489
|
}
|
|
501
490
|
|
|
502
|
-
// Set `parent_plan:` on an existing child to point back at the hub, unless it
|
|
503
|
-
// already resolves to the hub. Never clobbers a parent_plan that points
|
|
504
|
-
// elsewhere (warns instead — the child may belong to another hub). Returns
|
|
505
|
-
// true when it wrote, false when it left the file alone.
|
|
506
|
-
function planChildParentUpdate(childAbs, hubAbs, config) {
|
|
507
|
-
const raw = readFileSync(childAbs, 'utf8');
|
|
508
|
-
const { frontmatter: fmRaw } = extractFrontmatter(raw);
|
|
509
|
-
if (fmRaw == null) return { wrote: false };
|
|
510
|
-
const fm = parseSimpleFrontmatter(fmRaw);
|
|
511
|
-
const childDir = path.dirname(childAbs);
|
|
512
|
-
const existing = asString(fm.parent_plan);
|
|
513
|
-
if (existing) {
|
|
514
|
-
const resolved = resolveRefPath(existing, childDir, config.repoRoot);
|
|
515
|
-
if (resolved === hubAbs) return { wrote: false }; // already points at this hub
|
|
516
|
-
warn(`${toRepoPath(childAbs, config.repoRoot)} already has parent_plan: ${existing} — left as-is (not pointing it at the hub).`);
|
|
517
|
-
return { wrote: false };
|
|
518
|
-
}
|
|
519
|
-
const ref = path.relative(childDir, hubAbs).split(path.sep).join('/');
|
|
520
|
-
return { wrote: true, update: { path: childAbs, expectedContent: raw, render: current => {
|
|
521
|
-
const { frontmatter: currentFm } = extractFrontmatter(current);
|
|
522
|
-
const currentParsed = parseSimpleFrontmatter(currentFm);
|
|
523
|
-
const currentParent = asString(currentParsed.parent_plan);
|
|
524
|
-
if (currentParent && resolveRefPath(currentParent, childDir, config.repoRoot) !== hubAbs) {
|
|
525
|
-
throw new MutationConflictError(`Child parent_plan changed while the runlist mutation was being prepared: ${toRepoPath(childAbs, config.repoRoot)}`);
|
|
526
|
-
}
|
|
527
|
-
let updatedFm = upsertFrontmatterField(currentFm, 'parent_plan', `parent_plan: ${ref}`);
|
|
528
|
-
updatedFm = upsertFrontmatterField(updatedFm, 'updated', `updated: ${nowIso()}`);
|
|
529
|
-
return replaceFrontmatter(current, updatedFm);
|
|
530
|
-
} } };
|
|
531
|
-
}
|
|
532
|
-
|
|
533
491
|
// `dotmd runlist add <hub> <child...>` — append children to a hub's `runlist:`
|
|
534
492
|
// array, scaffolding a `planned` stub for any bare-slug child that doesn't yet
|
|
535
493
|
// exist (mirroring `dotmd new plan --runlist`) and wiring each child's
|
|
@@ -619,6 +577,9 @@ async function runRunlistAdd(positional, config, { dryRun, json, testHooks }) {
|
|
|
619
577
|
if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim('(scaffolded · planned)')}\n`);
|
|
620
578
|
} else {
|
|
621
579
|
const planned = planChildParentUpdate(c.abs, hubAbs, config);
|
|
580
|
+
if (planned.reason === 'different-parent') {
|
|
581
|
+
warn(`${c.repoPath} already has parent_plan: ${planned.existing} — left as-is (not pointing it at the hub).`);
|
|
582
|
+
}
|
|
622
583
|
if (planned.update) updates.push(planned.update);
|
|
623
584
|
const note = planned.wrote ? 'existing · parent_plan set' : 'existing';
|
|
624
585
|
if (!json) process.stdout.write(`${prefix} ${green('+')} ${c.repoPath} ${dim(`(${note})`)}\n`);
|
package/src/sync-status.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
4
|
-
import { die, isArchivedPath,
|
|
4
|
+
import { die, isArchivedPath, toRepoPath } from './util.mjs';
|
|
5
5
|
import { cyan, dim, green, red, yellow } from './color.mjs';
|
|
6
6
|
import { authorizeManagedSweep } from './managed-path.mjs';
|
|
7
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
7
8
|
import {
|
|
8
9
|
MARKER_CLOSE,
|
|
9
10
|
MARKER_OPEN,
|
|
@@ -21,8 +22,8 @@ import {
|
|
|
21
22
|
// dotmd already reads those rows to compute next-pickup, so the guard is
|
|
22
23
|
// standing next to the data it needs. Everything it needs is already owned:
|
|
23
24
|
// `config.types[<type>].statuses` for the vocabulary (type-aware, so a `doc`
|
|
24
|
-
// rowed in a plan hub is judged by the doc vocabulary),
|
|
25
|
-
//
|
|
25
|
+
// rowed in a plan hub is judged by the doc vocabulary), the strict Markdown
|
|
26
|
+
// body-link resolver, the index for the child's real status, and
|
|
26
27
|
// archive-dir-outranks-frontmatter for archived children.
|
|
27
28
|
//
|
|
28
29
|
// The three-way split below is the part that is easy to get wrong. A row with
|
|
@@ -79,7 +80,7 @@ function truncate(text, max = 60) {
|
|
|
79
80
|
// for that hub by name, so the noise-control rule doesn't apply).
|
|
80
81
|
export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
81
82
|
const docByPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
82
|
-
//
|
|
83
|
+
// The filesystem resolver is case-fold-aware, so on a case-folding filesystem a row
|
|
83
84
|
// linking `BILLING-A.md` resolves to the file that is indexed as
|
|
84
85
|
// `billing-a.md` — a path string the exact map can't answer. Fold as a
|
|
85
86
|
// fallback, and only when the fold is unambiguous: on a case-SENSITIVE
|
|
@@ -91,7 +92,8 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
|
91
92
|
}
|
|
92
93
|
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
93
94
|
// type stays loud for another that declared the same name.
|
|
94
|
-
const quiet = (d) => config.lifecycle?.
|
|
95
|
+
const quiet = (d) => (config.lifecycle?.isTerminal?.(d.status, d.type)
|
|
96
|
+
?? config.lifecycle?.terminalStatuses?.has(d.status))
|
|
95
97
|
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
96
98
|
const out = [];
|
|
97
99
|
|
|
@@ -108,11 +110,11 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
|
108
110
|
const rows = [];
|
|
109
111
|
for (const row of scanHubStatusRows(body)) {
|
|
110
112
|
const href = row.ref.replace(/#.*$/, '');
|
|
111
|
-
const
|
|
113
|
+
const resolution = resolveBodyLinkTarget(href, hubDir, config.repoRoot);
|
|
112
114
|
// A row whose link is broken is already a body-link finding. Reporting it
|
|
113
115
|
// again as unreadable status would double-report one problem.
|
|
114
|
-
if (!
|
|
115
|
-
const repoPath = toRepoPath(
|
|
116
|
+
if (!resolution.ok) continue;
|
|
117
|
+
const repoPath = toRepoPath(resolution.path, config.repoRoot);
|
|
116
118
|
const target = docByPath.get(repoPath) ?? docByFoldedPath.get(repoPath.toLowerCase()) ?? null;
|
|
117
119
|
if (!target || target.path === hub.path) continue;
|
|
118
120
|
const actual = effectiveStatus(target, config);
|
package/src/validate.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
|
|
|
3
3
|
import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
|
|
4
4
|
import { toRepoPath } from './util.mjs';
|
|
5
5
|
import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
|
|
6
|
+
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
6
7
|
|
|
7
8
|
const NOW = new Date();
|
|
8
9
|
|
|
@@ -277,12 +278,24 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
277
278
|
|
|
278
279
|
// Validate body links resolve to existing files
|
|
279
280
|
for (const link of (doc.bodyLinks || [])) {
|
|
280
|
-
|
|
281
|
+
const resolution = resolveBodyLinkTarget(link.href, docDir, config.repoRoot);
|
|
282
|
+
if (!resolution.ok) {
|
|
283
|
+
const shownHref = link.rawHref ?? link.href;
|
|
281
284
|
doc.warnings.push({
|
|
282
285
|
path: doc.path,
|
|
283
286
|
level: 'warning',
|
|
284
|
-
message:
|
|
285
|
-
|
|
287
|
+
message: resolution.reason === 'outside-repo'
|
|
288
|
+
? `body link \`${shownHref}\` escapes the repository.`
|
|
289
|
+
: `body link \`${shownHref}\` does not resolve to an existing file or directory.`,
|
|
290
|
+
meta: {
|
|
291
|
+
kind: 'body-link-resolution',
|
|
292
|
+
field: 'body-link',
|
|
293
|
+
relPath: link.href,
|
|
294
|
+
rawHref: shownHref,
|
|
295
|
+
targetKind: link.targetKind ?? 'document',
|
|
296
|
+
angle: link.angle === true,
|
|
297
|
+
reason: resolution.reason,
|
|
298
|
+
},
|
|
286
299
|
});
|
|
287
300
|
}
|
|
288
301
|
}
|
|
@@ -326,7 +339,9 @@ function candidatePathsForType(docs, type) {
|
|
|
326
339
|
// name implies one (e.g. `related_plans` → plans only).
|
|
327
340
|
export function enrichRefErrorSuggestions(docs, config) {
|
|
328
341
|
const enrich = (entry) => {
|
|
329
|
-
if (!entry?.meta || entry.meta.kind
|
|
342
|
+
if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
|
|
343
|
+
if (entry.meta.kind === 'body-link-resolution'
|
|
344
|
+
&& (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
|
|
330
345
|
if (entry._suggested) return;
|
|
331
346
|
const inferred = inferRefFieldType(entry.meta.field);
|
|
332
347
|
const candidates = candidatePathsForType(docs, inferred);
|
|
@@ -438,6 +453,11 @@ export function checkRunlistBackPointers(docs, config) {
|
|
|
438
453
|
path: child.path,
|
|
439
454
|
level: 'warning',
|
|
440
455
|
message: `appears in runlist of \`${hub.path}\` but \`parent_plan:\` does not point back at it. Add \`parent_plan: ${hub.path}\` so reverse-link tooling (pickup-card Related:, graph) stays consistent.`,
|
|
456
|
+
meta: {
|
|
457
|
+
kind: childParents.length === 0 ? 'hub-membership-backref' : 'hub-membership-conflict',
|
|
458
|
+
hub: hub.path,
|
|
459
|
+
source: 'frontmatter-runlist',
|
|
460
|
+
},
|
|
441
461
|
});
|
|
442
462
|
}
|
|
443
463
|
}
|