dotmd-cli 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dotmd.mjs +18 -0
- package/package.json +1 -1
- package/src/doctor.mjs +5 -0
- package/src/index.mjs +2 -1
- package/src/migrate-template.mjs +168 -0
- package/src/validate.mjs +85 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -341,6 +341,24 @@ Modes:
|
|
|
341
341
|
paused / queued-after). Heuristic only — verify
|
|
342
342
|
before migrating.
|
|
343
343
|
--statuses --json Machine-readable suggestion shape for tooling.
|
|
344
|
+
--migrate-template Plan-template migrator for plans created before
|
|
345
|
+
v0.21. Auto-fixes:
|
|
346
|
+
- drops singular \`surface:\` when \`surfaces:\`
|
|
347
|
+
array is populated (same for \`module:\`/\`modules:\`)
|
|
348
|
+
- renames \`## Open questions\` → \`## Open Questions\`,
|
|
349
|
+
\`## Out of scope\` / \`## Non-goals\` → \`## Non-Goals\`
|
|
350
|
+
- adds \`## Version History\` section if missing,
|
|
351
|
+
seeded with the file's \`updated\` timestamp
|
|
352
|
+
Skips non-plans. Per-file diff. Doesn't touch
|
|
353
|
+
long next_step/current_state or unmarked phase
|
|
354
|
+
headings (those need human input).
|
|
355
|
+
--migrate-template <file> Migrate just one plan.
|
|
356
|
+
--migrate-template --include-archived
|
|
357
|
+
Also touch plans in the archive directory.
|
|
358
|
+
Default skips archived plans (they're closed
|
|
359
|
+
history; a "Migrated to v0.21 template" entry
|
|
360
|
+
in their Version History would be misleading).
|
|
361
|
+
--migrate-template --json Machine-readable result.
|
|
344
362
|
|
|
345
363
|
Use --dry-run (-n) to preview all changes without writing anything.`,
|
|
346
364
|
|
package/package.json
CHANGED
package/src/doctor.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
|
6
6
|
import { renderCheck } from './render.mjs';
|
|
7
7
|
import { bold, dim, green, yellow } from './color.mjs';
|
|
8
8
|
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
9
|
+
import { runMigrateTemplate } from './migrate-template.mjs';
|
|
9
10
|
|
|
10
11
|
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
11
12
|
// MIN_BUCKET_SIZE: only flag buckets with at least this many docs (small buckets aren't worth nagging).
|
|
@@ -41,6 +42,10 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
41
42
|
runDoctorStatuses(config, { json: argv.includes('--json') });
|
|
42
43
|
return;
|
|
43
44
|
}
|
|
45
|
+
if (argv.includes('--migrate-template')) {
|
|
46
|
+
runMigrateTemplate(argv, config, opts);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
44
49
|
|
|
45
50
|
const { dryRun } = opts;
|
|
46
51
|
process.stdout.write(bold('dotmd doctor') + '\n\n');
|
package/src/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
6
|
-
import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
6
|
+
import { validateDoc, validatePlanShape, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
8
8
|
import { checkClaudeCommands } from './claude-commands.mjs';
|
|
9
9
|
|
|
@@ -193,5 +193,6 @@ export function parseDocFile(filePath, config) {
|
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
validateDoc(doc, parsedFrontmatter, headingTitle, config);
|
|
196
|
+
validatePlanShape(doc, body, parsedFrontmatter, config);
|
|
196
197
|
return doc;
|
|
197
198
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { asString, toRepoPath, nowIso } from './util.mjs';
|
|
5
|
+
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
const HEADING_RENAMES = [
|
|
9
|
+
{ from: /^##\s+Open questions\s*$/gm, to: '## Open Questions' },
|
|
10
|
+
{ from: /^##\s+open questions\s*$/gm, to: '## Open Questions' },
|
|
11
|
+
{ from: /^##\s+Out of scope\s*$/gm, to: '## Non-Goals' },
|
|
12
|
+
{ from: /^##\s+out of scope\s*$/gm, to: '## Non-Goals' },
|
|
13
|
+
{ from: /^##\s+Non-goals\s*$/gm, to: '## Non-Goals' },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
// Detect "both surface and surfaces are populated" — applies same logic to module/modules.
|
|
17
|
+
// Returns the singular-line text to delete, or null if no fix needed.
|
|
18
|
+
function findRedundantSingular(rawFrontmatter, singularKey, pluralKey, parsed) {
|
|
19
|
+
const singularVal = asString(parsed[singularKey]);
|
|
20
|
+
const pluralVal = parsed[pluralKey];
|
|
21
|
+
const pluralHasValues = Array.isArray(pluralVal) && pluralVal.length > 0;
|
|
22
|
+
if (!singularVal || !pluralHasValues) return null;
|
|
23
|
+
// Find the exact line: `<singularKey>: <value>` (single inline, not a block start)
|
|
24
|
+
const lineRe = new RegExp(`^${singularKey}:\\s*[^\\n]+$`, 'm');
|
|
25
|
+
const match = rawFrontmatter.match(lineRe);
|
|
26
|
+
return match ? match[0] : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function ensureVersionHistory(body, timestamp) {
|
|
30
|
+
if (/^##\s+Version History\s*$/m.test(body)) return null;
|
|
31
|
+
const newSection = `## Version History\n\n- **${timestamp}** Migrated to v0.21 template.\n`;
|
|
32
|
+
// Insert before ## Closeout if it exists; else append at end.
|
|
33
|
+
const closeoutMatch = body.match(/^##\s+Closeout\s*$/m);
|
|
34
|
+
if (closeoutMatch) {
|
|
35
|
+
const idx = body.indexOf(closeoutMatch[0]);
|
|
36
|
+
return body.slice(0, idx) + newSection + '\n' + body.slice(idx);
|
|
37
|
+
}
|
|
38
|
+
return body.trimEnd() + '\n\n' + newSection;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Run plan-shape migrations on one file. Pure-ish: returns { changes, newRaw }
|
|
42
|
+
// without writing. Caller handles IO.
|
|
43
|
+
export function migrateOne(raw) {
|
|
44
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
45
|
+
if (!frontmatter || !body) return { changes: [], newRaw: raw };
|
|
46
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
47
|
+
if (asString(parsed.type) !== 'plan') return { changes: [], newRaw: raw, skipped: 'not-plan' };
|
|
48
|
+
|
|
49
|
+
let newFrontmatter = frontmatter;
|
|
50
|
+
let newBody = body;
|
|
51
|
+
const changes = [];
|
|
52
|
+
|
|
53
|
+
// 1. Drop redundant singular surface/module when array form is populated.
|
|
54
|
+
const dropSurface = findRedundantSingular(newFrontmatter, 'surface', 'surfaces', parsed);
|
|
55
|
+
if (dropSurface) {
|
|
56
|
+
newFrontmatter = newFrontmatter.replace(new RegExp(`\\n?${dropSurface.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\n?`), '\n').replace(/\n+/g, '\n');
|
|
57
|
+
changes.push({ kind: 'drop-singular', detail: dropSurface });
|
|
58
|
+
}
|
|
59
|
+
const dropModule = findRedundantSingular(newFrontmatter, 'module', 'modules', parsed);
|
|
60
|
+
if (dropModule) {
|
|
61
|
+
newFrontmatter = newFrontmatter.replace(new RegExp(`\\n?${dropModule.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\n?`), '\n').replace(/\n+/g, '\n');
|
|
62
|
+
changes.push({ kind: 'drop-singular', detail: dropModule });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 2. Heading renames in body
|
|
66
|
+
for (const { from, to } of HEADING_RENAMES) {
|
|
67
|
+
const matches = [...newBody.matchAll(from)];
|
|
68
|
+
if (matches.length > 0) {
|
|
69
|
+
newBody = newBody.replace(from, to);
|
|
70
|
+
for (const m of matches) {
|
|
71
|
+
changes.push({ kind: 'rename-heading', detail: `\`${m[0].trim()}\` → \`${to}\`` });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 3. Add ## Version History section if missing.
|
|
77
|
+
// Use the file's `updated` timestamp if present, else nowIso(), so the
|
|
78
|
+
// seed entry reads truthfully ("when this plan was last touched") rather
|
|
79
|
+
// than dating itself to the migration moment.
|
|
80
|
+
const seedTs = asString(parsed.updated) || nowIso();
|
|
81
|
+
const withVh = ensureVersionHistory(newBody, seedTs);
|
|
82
|
+
if (withVh !== null) {
|
|
83
|
+
newBody = withVh;
|
|
84
|
+
changes.push({ kind: 'add-version-history', detail: `seeded with ${seedTs}` });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (changes.length === 0) return { changes: [], newRaw: raw };
|
|
88
|
+
|
|
89
|
+
const newRaw = `---\n${newFrontmatter.trim()}\n---\n${newBody}`;
|
|
90
|
+
return { changes, newRaw };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isInArchive(filePath, config) {
|
|
94
|
+
const archiveDir = config.archiveDir || 'archived';
|
|
95
|
+
const sep = path.sep;
|
|
96
|
+
return filePath.includes(`${sep}${archiveDir}${sep}`) || filePath.endsWith(`${sep}${archiveDir}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function runMigrateTemplate(argv, config, opts = {}) {
|
|
100
|
+
const { dryRun } = opts;
|
|
101
|
+
const json = argv.includes('--json');
|
|
102
|
+
const includeArchived = argv.includes('--include-archived');
|
|
103
|
+
// First positional anywhere in argv (skip 'doctor' subcommand if present at idx 0).
|
|
104
|
+
const fileArg = argv.find(a => !a.startsWith('-') && a !== 'doctor');
|
|
105
|
+
|
|
106
|
+
// Allow targeting one file, else sweep all plans (excluding archived by default).
|
|
107
|
+
let files;
|
|
108
|
+
if (fileArg) {
|
|
109
|
+
const target = fileArg.endsWith('.md') ? fileArg : `${fileArg}.md`;
|
|
110
|
+
files = collectDocFiles(config).filter(f => f.endsWith(target) || f === target);
|
|
111
|
+
if (files.length === 0) {
|
|
112
|
+
process.stderr.write(`File not found: ${fileArg}\n`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
} else {
|
|
117
|
+
files = collectDocFiles(config);
|
|
118
|
+
if (!includeArchived) files = files.filter(f => !isInArchive(f, config));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const results = [];
|
|
122
|
+
let totalChanges = 0;
|
|
123
|
+
let touched = 0;
|
|
124
|
+
|
|
125
|
+
for (const filePath of files) {
|
|
126
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
127
|
+
const result = migrateOne(raw);
|
|
128
|
+
if (result.changes.length === 0) continue;
|
|
129
|
+
|
|
130
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
131
|
+
results.push({ path: repoPath, changes: result.changes });
|
|
132
|
+
totalChanges += result.changes.length;
|
|
133
|
+
touched++;
|
|
134
|
+
|
|
135
|
+
if (!dryRun) writeFileSync(filePath, result.newRaw, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (json) {
|
|
139
|
+
process.stdout.write(JSON.stringify({
|
|
140
|
+
dryRun: Boolean(dryRun),
|
|
141
|
+
filesTouched: touched,
|
|
142
|
+
totalChanges,
|
|
143
|
+
results,
|
|
144
|
+
}, null, 2) + '\n');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (results.length === 0) {
|
|
149
|
+
process.stdout.write(green('No template migrations needed.') + '\n');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
154
|
+
process.stdout.write(bold(`${prefix}${touched} plan${touched === 1 ? '' : 's'} ${dryRun ? 'would be' : 'were'} migrated (${totalChanges} change${totalChanges === 1 ? '' : 's'}):\n\n`));
|
|
155
|
+
|
|
156
|
+
for (const r of results) {
|
|
157
|
+
process.stdout.write(` ${r.path}\n`);
|
|
158
|
+
for (const c of r.changes) {
|
|
159
|
+
process.stdout.write(dim(` [${c.kind}] ${c.detail}\n`));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
process.stdout.write(`\nRun ${bold('dotmd doctor --migrate-template')} without --dry-run to apply.\n`);
|
|
165
|
+
} else {
|
|
166
|
+
process.stdout.write(`\n${green('Done.')} Re-run ${bold('dotmd check')} to see remaining issues.\n`);
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -174,6 +174,91 @@ export function checkGitStaleness(docs, config) {
|
|
|
174
174
|
return warnings;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// Plan-shape lint: soft warnings on convention drift. Plan-only.
|
|
178
|
+
// Body is the unparsed plan body (everything after the closing `---`).
|
|
179
|
+
export function validatePlanShape(doc, body, frontmatter, config) {
|
|
180
|
+
if (doc.type !== 'plan') return;
|
|
181
|
+
// Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
|
|
182
|
+
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
183
|
+
if (config.lifecycle.skipWarningsFor.has(doc.status)) return;
|
|
184
|
+
|
|
185
|
+
// 1. next_step length cap (300 chars)
|
|
186
|
+
const nextStep = typeof frontmatter.next_step === 'string' ? frontmatter.next_step : '';
|
|
187
|
+
if (nextStep.length > 300) {
|
|
188
|
+
doc.warnings.push({
|
|
189
|
+
path: doc.path,
|
|
190
|
+
level: 'warning',
|
|
191
|
+
message: `\`next_step\` is ${nextStep.length} chars (cap: 300). Long prose belongs in the body — keep next_step as a 1-2 line pointer.`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 2. current_state length cap (500 chars)
|
|
196
|
+
const currentState = typeof frontmatter.current_state === 'string' ? frontmatter.current_state : '';
|
|
197
|
+
if (currentState.length > 500) {
|
|
198
|
+
doc.warnings.push({
|
|
199
|
+
path: doc.path,
|
|
200
|
+
level: 'warning',
|
|
201
|
+
message: `\`current_state\` is ${currentState.length} chars (cap: 500). Long prose belongs in the body.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 3. surface AND surfaces both populated
|
|
206
|
+
if (frontmatter.surface && Array.isArray(frontmatter.surfaces) && frontmatter.surfaces.length > 0) {
|
|
207
|
+
doc.warnings.push({
|
|
208
|
+
path: doc.path,
|
|
209
|
+
level: 'warning',
|
|
210
|
+
message: 'Both `surface` (singular) and `surfaces` (array) are set. Pick one — prefer `surfaces` array form.',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (frontmatter.module && Array.isArray(frontmatter.modules) && frontmatter.modules.length > 0) {
|
|
214
|
+
doc.warnings.push({
|
|
215
|
+
path: doc.path,
|
|
216
|
+
level: 'warning',
|
|
217
|
+
message: 'Both `module` (singular) and `modules` (array) are set. Pick one — prefer `modules` array form.',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!body) return;
|
|
222
|
+
|
|
223
|
+
// 4. Heading drift: case + name variants
|
|
224
|
+
const headingDrift = [
|
|
225
|
+
{ wrong: /^##\s+Open questions\s*$/m, right: '## Open Questions' },
|
|
226
|
+
{ wrong: /^##\s+(Non-goals|Out of scope|Out of Scope|out of scope)\s*$/m, right: '## Non-Goals' },
|
|
227
|
+
{ wrong: /^##\s+open questions\s*$/m, right: '## Open Questions' },
|
|
228
|
+
];
|
|
229
|
+
for (const { wrong, right } of headingDrift) {
|
|
230
|
+
const m = body.match(wrong);
|
|
231
|
+
if (m) {
|
|
232
|
+
doc.warnings.push({
|
|
233
|
+
path: doc.path,
|
|
234
|
+
level: 'warning',
|
|
235
|
+
message: `Heading drift: \`${m[0].trim()}\` → suggest \`${right}\`.`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 5. Phases section exists but no phase H3 has a status marker
|
|
241
|
+
const phasesIdx = body.search(/^## Phases\s*$/m);
|
|
242
|
+
if (phasesIdx >= 0) {
|
|
243
|
+
// Find the section's body (until next H2 or EOF)
|
|
244
|
+
const after = body.slice(phasesIdx);
|
|
245
|
+
const nextH2 = after.slice(8).search(/^## /m);
|
|
246
|
+
const phasesBody = nextH2 >= 0 ? after.slice(8, 8 + nextH2) : after.slice(8);
|
|
247
|
+
const phaseHeadings = [...phasesBody.matchAll(/^###\s+(.+?)\s*$/gm)].map(m => m[1]);
|
|
248
|
+
if (phaseHeadings.length > 0) {
|
|
249
|
+
const markerRe = /(✅|⏭|🟡|⬜|🚧|☑|✔|◻|☐|⬛|\bshipped\b|\bskip(?:ped)?\b|\bin[-_ ]?(?:progress|flight)\b|\bblocked\b|\btodo\b|\bnot[-_ ]?started\b|\bwip\b|\bdone\b|\bcomplete\b)/i;
|
|
250
|
+
const unmarked = phaseHeadings.filter(h => !markerRe.test(h));
|
|
251
|
+
if (unmarked.length > 0) {
|
|
252
|
+
doc.warnings.push({
|
|
253
|
+
path: doc.path,
|
|
254
|
+
level: 'warning',
|
|
255
|
+
message: `${unmarked.length} of ${phaseHeadings.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
177
262
|
export function computeDaysSinceUpdate(updated) {
|
|
178
263
|
if (!updated) return null;
|
|
179
264
|
const parsed = new Date(updated);
|