dotmd-cli 0.76.6 → 0.76.8
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/commands.mjs +1 -0
- package/src/doctor.mjs +20 -14
- package/src/extractors.mjs +3 -3
- package/src/fix-membership.mjs +132 -0
- package/src/hub-membership.mjs +159 -33
- package/src/markdown-code-spans.mjs +47 -0
- package/src/parent-plan.mjs +58 -0
- package/src/reference-planner.mjs +27 -47
- package/src/render.mjs +9 -0
- package/src/runlist.mjs +4 -43
- package/src/validate.mjs +5 -0
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
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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { maskInlineCodeSpans } from './markdown-code-spans.mjs';
|
|
2
|
+
|
|
1
3
|
export function extractFirstHeading(body) {
|
|
2
4
|
return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
|
|
3
5
|
}
|
|
@@ -49,9 +51,7 @@ export function extractBodyLinks(body) {
|
|
|
49
51
|
// every one of them was also never checked for breakage. Masking to same-length
|
|
50
52
|
// filler keeps the link matchable while still neutralizing a link that is
|
|
51
53
|
// itself inside code (`[fake](x.md)` stays unmatched), and preserves offsets.
|
|
52
|
-
const stripped = body
|
|
53
|
-
.replace(/^```[\s\S]*?^```/gm, '')
|
|
54
|
-
.replace(/`[^`]+`/g, match => 'x'.repeat(match.length));
|
|
54
|
+
const stripped = maskInlineCodeSpans(body.replace(/^```[\s\S]*?^```/gm, ''));
|
|
55
55
|
const links = [];
|
|
56
56
|
// Supported inline destinations are a whitespace-free token (with Markdown
|
|
57
57
|
// backslash escapes) or an angle-bracket destination, plus an optional
|
|
@@ -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/hub-membership.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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
6
|
import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
7
7
|
|
|
@@ -38,6 +38,153 @@ import { resolveBodyLinkTarget } from './body-link.mjs';
|
|
|
38
38
|
const ORPHAN_KIND = 'hub-membership-orphan';
|
|
39
39
|
const BACKREF_KIND = 'hub-membership-backref';
|
|
40
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
|
+
|
|
41
188
|
export function checkHubMembershipDrift(docs, config) {
|
|
42
189
|
const warnings = [];
|
|
43
190
|
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
@@ -119,37 +266,16 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
119
266
|
// it can't see; children already covered there are skipped, so one missing
|
|
120
267
|
// back-ref is never reported twice. Warns on the CHILD, matching that check:
|
|
121
268
|
// it's the file that needs the edit.
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const target = resolve(ref, dir);
|
|
133
|
-
if (target) inFrontmatterRunlist.add(target.path);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const seen = new Set();
|
|
137
|
-
for (const ref of ranked) {
|
|
138
|
-
const child = resolve(ref, dir);
|
|
139
|
-
if (!child || child.path === hub.path || seen.has(child.path)) continue;
|
|
140
|
-
seen.add(child.path);
|
|
141
|
-
if (quiet(child)) continue; // closed work is normal history
|
|
142
|
-
if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
|
|
143
|
-
if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
|
|
144
|
-
if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
|
|
145
|
-
if ((child.refFields?.parent_plan ?? []).length > 0) continue;
|
|
146
|
-
warnings.push({
|
|
147
|
-
path: child.path,
|
|
148
|
-
level: 'warning',
|
|
149
|
-
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.`,
|
|
150
|
-
meta: { kind: BACKREF_KIND, hub: hub.path },
|
|
151
|
-
});
|
|
152
|
-
}
|
|
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
|
+
});
|
|
153
279
|
}
|
|
154
280
|
|
|
155
281
|
return warnings;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function maskRange(value, start, end) {
|
|
2
|
+
return value.slice(0, start)
|
|
3
|
+
+ value.slice(start, end).replace(/[^\n]/g, 'x')
|
|
4
|
+
+ value.slice(end);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Markdown code spans close only on a backtick run of the same length as their
|
|
8
|
+
// opener. The mask is deliberately the same length as the source so consumers
|
|
9
|
+
// can inspect neutralized Markdown while applying any edits at source offsets.
|
|
10
|
+
// `state` lets the conservative multiline behavior used by the reference
|
|
11
|
+
// rewriter survive: after an unmatched opener, nothing is treated as prose
|
|
12
|
+
// until a compatible closer appears.
|
|
13
|
+
export function maskInlineCodeLine(line, state = { run: null }) {
|
|
14
|
+
const ranges = [];
|
|
15
|
+
const runs = [...line.matchAll(/`+/g)];
|
|
16
|
+
let index = 0;
|
|
17
|
+
|
|
18
|
+
if (state.run !== null) {
|
|
19
|
+
const closingIndex = runs.findIndex(candidate => candidate[0].length === state.run);
|
|
20
|
+
if (closingIndex === -1) return maskRange(line, 0, line.length);
|
|
21
|
+
const closing = runs[closingIndex];
|
|
22
|
+
ranges.push([0, closing.index + closing[0].length]);
|
|
23
|
+
index = closingIndex + 1;
|
|
24
|
+
state.run = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (; index < runs.length; index++) {
|
|
28
|
+
const opening = runs[index];
|
|
29
|
+
const closingIndex = runs.findIndex((candidate, candidateIndex) =>
|
|
30
|
+
candidateIndex > index && candidate[0].length === opening[0].length);
|
|
31
|
+
if (closingIndex === -1) {
|
|
32
|
+
ranges.push([opening.index, line.length]);
|
|
33
|
+
state.run = opening[0].length;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
const closing = runs[closingIndex];
|
|
37
|
+
ranges.push([opening.index, closing.index + closing[0].length]);
|
|
38
|
+
index = closingIndex;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return ranges.reduceRight((masked, [start, end]) => maskRange(masked, start, end), line);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function maskInlineCodeSpans(value) {
|
|
45
|
+
const state = { run: null };
|
|
46
|
+
return value.split('\n').map(line => maskInlineCodeLine(line, state)).join('\n');
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { maskInlineCodeLine } from './markdown-code-spans.mjs';
|
|
4
5
|
|
|
5
6
|
function slash(value) { return value.split(path.sep).join('/'); }
|
|
6
7
|
|
|
@@ -233,29 +234,37 @@ function rewriteDestination(raw, args) {
|
|
|
233
234
|
return parsed.angle ? `<${rendered}>` : rendered;
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
function
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
237
|
+
function rewriteMarkdownLine(line, masked, args) {
|
|
238
|
+
const edits = [];
|
|
239
|
+
const inline = /(\[[^\]]*\]\(\s*)(<[^>\n]+>|(?:\\.|[^\s()])+)(\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?(\s*\))/g;
|
|
240
|
+
let match;
|
|
241
|
+
while ((match = inline.exec(masked)) !== null) {
|
|
242
|
+
let escapes = 0;
|
|
243
|
+
for (let index = match.index - 1; index >= 0 && line[index] === '\\'; index--) escapes++;
|
|
244
|
+
if (escapes % 2 === 1) continue;
|
|
245
|
+
const start = match.index + match[1].length;
|
|
246
|
+
const destination = line.slice(start, start + match[2].length);
|
|
247
|
+
const rewritten = rewriteDestination(destination, args);
|
|
248
|
+
if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
|
|
249
|
+
}
|
|
250
|
+
|
|
246
251
|
// Reference definitions preserve labels, spacing, destinations, and titles.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
252
|
+
const definition = /^(\s{0,3}\[[^\]]+\]:\s*)(<[^>\n]+>|(?:\\.|[^\s])+)(.*)$/.exec(masked);
|
|
253
|
+
if (definition) {
|
|
254
|
+
const start = definition[1].length;
|
|
255
|
+
const destination = line.slice(start, start + definition[2].length);
|
|
256
|
+
const rewritten = rewriteDestination(destination, args);
|
|
257
|
+
if (rewritten !== destination) edits.push({ start, end: start + destination.length, value: rewritten });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return edits.sort((left, right) => right.start - left.start)
|
|
261
|
+
.reduce((next, edit) => next.slice(0, edit.start) + edit.value + next.slice(edit.end), line);
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
function rewriteMarkdown(body, args) {
|
|
256
265
|
const lines = body.split('\n');
|
|
257
266
|
let fence = null;
|
|
258
|
-
|
|
267
|
+
const inlineState = { run: null };
|
|
259
268
|
let indentedCode = false;
|
|
260
269
|
let htmlBlock = null;
|
|
261
270
|
const containerContext = line => {
|
|
@@ -317,36 +326,7 @@ function rewriteMarkdown(body, args) {
|
|
|
317
326
|
fence = { char: opener[1][0], length: opener[1].length, prefixes: container.prefixes };
|
|
318
327
|
return line;
|
|
319
328
|
}
|
|
320
|
-
|
|
321
|
-
let cursor = 0;
|
|
322
|
-
const runs = [...line.matchAll(/`+/g)];
|
|
323
|
-
let index = 0;
|
|
324
|
-
if (inlineRun !== null) {
|
|
325
|
-
const closingIndex = runs.findIndex(candidate => candidate[0].length === inlineRun);
|
|
326
|
-
if (closingIndex === -1) return line;
|
|
327
|
-
const closing = runs[closingIndex];
|
|
328
|
-
output += line.slice(0, closing.index + closing[0].length);
|
|
329
|
-
cursor = closing.index + closing[0].length;
|
|
330
|
-
index = closingIndex + 1;
|
|
331
|
-
inlineRun = null;
|
|
332
|
-
}
|
|
333
|
-
for (; index < runs.length; index++) {
|
|
334
|
-
const opening = runs[index];
|
|
335
|
-
const closingIndex = runs.findIndex((candidate, candidateIndex) => candidateIndex > index && candidate[0].length === opening[0].length);
|
|
336
|
-
if (closingIndex === -1) {
|
|
337
|
-
output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
|
|
338
|
-
output += line.slice(opening.index);
|
|
339
|
-
inlineRun = opening[0].length;
|
|
340
|
-
cursor = line.length;
|
|
341
|
-
break;
|
|
342
|
-
}
|
|
343
|
-
const closing = runs[closingIndex];
|
|
344
|
-
output += rewriteMarkdownSegment(line.slice(cursor, opening.index), args);
|
|
345
|
-
output += line.slice(opening.index, closing.index + closing[0].length);
|
|
346
|
-
cursor = closing.index + closing[0].length;
|
|
347
|
-
index = closingIndex;
|
|
348
|
-
}
|
|
349
|
-
return output + rewriteMarkdownSegment(line.slice(cursor), args);
|
|
329
|
+
return rewriteMarkdownLine(line, maskInlineCodeLine(line, inlineState), args);
|
|
350
330
|
}).join('\n');
|
|
351
331
|
}
|
|
352
332
|
|
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' };
|
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/validate.mjs
CHANGED
|
@@ -453,6 +453,11 @@ export function checkRunlistBackPointers(docs, config) {
|
|
|
453
453
|
path: child.path,
|
|
454
454
|
level: 'warning',
|
|
455
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
|
+
},
|
|
456
461
|
});
|
|
457
462
|
}
|
|
458
463
|
}
|