dotmd-cli 0.74.5 → 0.75.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/src/validate.mjs CHANGED
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
3
  import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
+ import { detectMarker, isPhaseHeading, phaseMarkerConflict, walkSections } from './section.mjs';
5
6
 
6
7
  const NOW = new Date();
7
8
 
@@ -635,26 +636,40 @@ export function validatePlanShape(doc, body, frontmatter, config) {
635
636
  }
636
637
  }
637
638
 
638
- // 5. Phases section exists but no phase H3 has a status marker
639
- const phasesIdx = body.search(/^## Phases\s*$/m);
640
- if (phasesIdx >= 0) {
641
- // Find the section's body (until next H2 or EOF)
642
- const after = body.slice(phasesIdx);
643
- const nextH2 = after.slice(8).search(/^## /m);
644
- const phasesBody = nextH2 >= 0 ? after.slice(8, 8 + nextH2) : after.slice(8);
645
- const phaseHeadings = [...phasesBody.matchAll(/^###\s+(.+?)\s*$/gm)].map(m => m[1]);
646
- if (phaseHeadings.length > 0) {
647
- 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;
648
- const unmarked = phaseHeadings.filter(h => !markerRe.test(h));
649
- if (unmarked.length > 0) {
650
- doc.warnings.push({
651
- path: doc.path,
652
- level: 'warning',
653
- message: `${unmarked.length} of ${phaseHeadings.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
654
- });
655
- }
639
+ // 5 & 6 read phases through section.mjs rather than re-parsing them here.
640
+ // This check used to carry its own heading scan and its own copy of the
641
+ // marker vocabulary, which drifted from the real reader in both directions:
642
+ // it counted `### Phase 3 outcome` as a phase needing a marker, and it never
643
+ // learned the fixes section.mjs got. Two parsers for one concept disagreeing
644
+ // is the same defect class as `reference-planner` vs `resolveRefPath`.
645
+ const phases = walkSections(body).filter(isPhaseHeading);
646
+
647
+ // 5. Phase headings that carry no status marker at all.
648
+ if (phases.length > 0) {
649
+ const unmarked = phases.filter(p => detectMarker(p.heading) === null);
650
+ if (unmarked.length > 0) {
651
+ doc.warnings.push({
652
+ path: doc.path,
653
+ level: 'warning',
654
+ message: `${unmarked.length} of ${phases.length} phase heading(s) lack a status marker. Use one of ✅ shipped, ⏭ skipped, 🟡 in-progress, ⬜ todo, 🚧 blocked.`,
655
+ });
656
656
  }
657
657
  }
658
+
659
+ // 6. A phase whose own checklist contradicts its marker. Reported, never
660
+ // fixed: dotmd cannot know whether the marker or the boxes are the stale
661
+ // half, and guessing would either mark work done that is not, or reopen work
662
+ // that is. Both halves are the author's own writing — this only says they
663
+ // disagree.
664
+ for (const phase of phases) {
665
+ const conflict = phaseMarkerConflict(phase);
666
+ if (!conflict) continue;
667
+ doc.warnings.push({
668
+ path: doc.path,
669
+ level: 'warning',
670
+ message: `Phase marker contradicts its own checklist (line ${phase.lineStart}): \`${phase.heading.trim()}\` is marked ${conflict.declared}, but ${conflict.checked}/${conflict.total} boxes are checked — that reads as ${conflict.implied}. Update whichever half is stale.`,
671
+ });
672
+ }
658
673
  }
659
674
 
660
675
  // Doc-shape lint: soft warnings on convention drift. Doc-only.