dotmd-cli 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.mjs +2 -1
- package/src/validate.mjs +85 -0
package/package.json
CHANGED
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
|
}
|
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);
|