dotmd-cli 0.76.2 → 0.76.4
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 +2 -1
- package/package.json +1 -1
- package/src/config.mjs +18 -2
- package/src/index.mjs +2 -1
- package/src/render.mjs +16 -2
- package/src/section.mjs +34 -3
- package/src/status-metadata.mjs +1 -1
- package/src/validate.mjs +9 -7
package/bin/dotmd.mjs
CHANGED
|
@@ -1831,7 +1831,7 @@ async function main() {
|
|
|
1831
1831
|
|
|
1832
1832
|
// All remaining commands need the index + render modules
|
|
1833
1833
|
const { buildIndex } = await import('../src/index.mjs');
|
|
1834
|
-
const { renderCompactList, renderVerboseList, renderContext, renderBriefing, renderCheck, renderCoverage, buildCoverage } = await import('../src/render.mjs');
|
|
1834
|
+
const { renderCompactList, renderVerboseList, renderContext, renderBriefing, renderCheck, renderCoverage, buildCoverage, buildReferenceValidationCoverage } = await import('../src/render.mjs');
|
|
1835
1835
|
const { runFocus, runQuery } = await import('../src/query.mjs');
|
|
1836
1836
|
// `dotmd check` is the one shared-buildIndex command that should auto-heal a
|
|
1837
1837
|
// drifted index block (frontmatter edits by direct Edit/Write, `lint --fix`,
|
|
@@ -1898,6 +1898,7 @@ async function main() {
|
|
|
1898
1898
|
warnings: errorsOnly ? [] : checkIndex.warnings,
|
|
1899
1899
|
errorCount: checkIndex.errors.length,
|
|
1900
1900
|
warningCount: checkIndex.warnings.length,
|
|
1901
|
+
referenceValidation: buildReferenceValidationCoverage(checkIndex, config),
|
|
1901
1902
|
passed: complete ? builtInPassed : null,
|
|
1902
1903
|
...(complete ? {} : {
|
|
1903
1904
|
builtInPassed,
|
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -154,6 +154,7 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
154
154
|
// reads them has to be too.
|
|
155
155
|
skipWarningsForByType: {},
|
|
156
156
|
terminalStatuses: [],
|
|
157
|
+
terminalStatusesByType: {},
|
|
157
158
|
moduleRequiredFor: [],
|
|
158
159
|
// F15: status-name → directory-name (defaults to the status name verbatim).
|
|
159
160
|
// Filed statuses move docs into <root>/<dirName>/ on transition INTO the
|
|
@@ -177,6 +178,7 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
177
178
|
// declared its statuses and suppresses nothing", which must not fall back to
|
|
178
179
|
// the global union. Absent entirely means "type never declared rich statuses".
|
|
179
180
|
derived.skipWarningsForByType[typeName] ??= [];
|
|
181
|
+
derived.terminalStatusesByType[typeName] ??= [];
|
|
180
182
|
const statusNames = [];
|
|
181
183
|
const typeContext = { expanded: [], listed: [], counted: [] };
|
|
182
184
|
const typeStaleDays = {};
|
|
@@ -218,7 +220,10 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
218
220
|
if (!derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
|
|
219
221
|
(derived.skipWarningsForByType[typeName] ??= []).push(name);
|
|
220
222
|
}
|
|
221
|
-
if (p.terminal
|
|
223
|
+
if (p.terminal) {
|
|
224
|
+
if (!derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
|
|
225
|
+
derived.terminalStatusesByType[typeName].push(name);
|
|
226
|
+
}
|
|
222
227
|
if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
|
|
223
228
|
if (p.filed && !derived.filedStatuses[name]) {
|
|
224
229
|
// dirName defaults to the status name; users can override with
|
|
@@ -281,6 +286,10 @@ function applyDerivedConfig(config, userConfig, derived) {
|
|
|
281
286
|
}
|
|
282
287
|
if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
|
|
283
288
|
config.lifecycle.terminalStatuses = derived.terminalStatuses;
|
|
289
|
+
config.lifecycle.terminalStatusesByType = derived.terminalStatusesByType;
|
|
290
|
+
} else {
|
|
291
|
+
// An explicit lifecycle list is intentionally repo-wide.
|
|
292
|
+
config.lifecycle.terminalStatusesByType = {};
|
|
284
293
|
}
|
|
285
294
|
if (!userConfig.lifecycle?.filedStatuses && Object.keys(derived.filedStatuses).length) {
|
|
286
295
|
config.lifecycle.filedStatuses = derived.filedStatuses;
|
|
@@ -539,6 +548,13 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
539
548
|
return own ? own.has(status) : skipWarningsFor.has(status);
|
|
540
549
|
};
|
|
541
550
|
const terminalStatuses = new Set(lifecycle.terminalStatuses);
|
|
551
|
+
const terminalStatusesByType = new Map(
|
|
552
|
+
Object.entries(lifecycle.terminalStatusesByType ?? {}).map(([type, names]) => [type, new Set(names)]),
|
|
553
|
+
);
|
|
554
|
+
const isTerminal = (status, type) => {
|
|
555
|
+
const own = type != null ? terminalStatusesByType.get(type) : undefined;
|
|
556
|
+
return own ? own.has(status) : terminalStatuses.has(status);
|
|
557
|
+
};
|
|
542
558
|
// F15: filedStatuses keyed by status name, value = directory name. Empty
|
|
543
559
|
// object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
|
|
544
560
|
const filedStatuses = new Map(Object.entries(lifecycle.filedStatuses ?? {}));
|
|
@@ -589,7 +605,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
589
605
|
rootValidStatuses,
|
|
590
606
|
staleDaysByStatus,
|
|
591
607
|
|
|
592
|
-
lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, skipWarningsForByType, skipsWarnings, terminalStatuses, filedStatuses, archiveNestedTypes },
|
|
608
|
+
lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, skipWarningsForByType, skipsWarnings, terminalStatuses, terminalStatusesByType, isTerminal, filedStatuses, archiveNestedTypes },
|
|
593
609
|
|
|
594
610
|
validSurfaces,
|
|
595
611
|
validModules,
|
package/src/index.mjs
CHANGED
|
@@ -290,7 +290,8 @@ export function parseDocFile(filePath, config, opts = {}) {
|
|
|
290
290
|
// criterion: "should defer to frontmatter when status is terminal."
|
|
291
291
|
const fmCurrentState = asString(parsedFrontmatter.current_state);
|
|
292
292
|
const docStatus = asString(parsedFrontmatter.status);
|
|
293
|
-
const isTerminalDoc = docStatus && config.lifecycle?.
|
|
293
|
+
const isTerminalDoc = docStatus && (config.lifecycle?.isTerminal?.(docStatus, asString(parsedFrontmatter.type) ?? null)
|
|
294
|
+
?? config.lifecycle?.terminalStatuses?.has?.(docStatus));
|
|
294
295
|
// Track where currentState came from so renderers can prefix `(auto)` on
|
|
295
296
|
// body-scraped values. Frontmatter wins silently; body-scraped values flag
|
|
296
297
|
// their origin so the user knows the string was inferred (and that adding
|
package/src/render.mjs
CHANGED
|
@@ -404,7 +404,7 @@ export function renderBriefing(index, config) {
|
|
|
404
404
|
}
|
|
405
405
|
|
|
406
406
|
export function renderCheck(index, config, opts = {}) {
|
|
407
|
-
const defaultRenderer = (idx) => _renderCheck(idx, opts);
|
|
407
|
+
const defaultRenderer = (idx) => _renderCheck(idx, config, opts);
|
|
408
408
|
if (!config._execution?.suppressSideEffects && config.hooks.renderCheck) {
|
|
409
409
|
try { return config.hooks.renderCheck(index, defaultRenderer); }
|
|
410
410
|
catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
|
|
@@ -500,12 +500,26 @@ export function renderManualFixes(index) {
|
|
|
500
500
|
return lines.join('\n');
|
|
501
501
|
}
|
|
502
502
|
|
|
503
|
-
function
|
|
503
|
+
export function buildReferenceValidationCoverage(index, config) {
|
|
504
|
+
let checkedDocs = 0;
|
|
505
|
+
let terminalDocsSkipped = 0;
|
|
506
|
+
for (const doc of index.docs) {
|
|
507
|
+
const terminal = config.lifecycle.isTerminal?.(doc.status, doc.type)
|
|
508
|
+
?? config.lifecycle.terminalStatuses.has(doc.status);
|
|
509
|
+
if (terminal) terminalDocsSkipped++;
|
|
510
|
+
else checkedDocs++;
|
|
511
|
+
}
|
|
512
|
+
return { checkedDocs, terminalDocsSkipped };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function _renderCheck(index, config, opts = {}) {
|
|
504
516
|
const { errorsOnly, noCollapse, verbose } = opts;
|
|
517
|
+
const referenceValidation = buildReferenceValidationCoverage(index, config);
|
|
505
518
|
const lines = ['Check', ''];
|
|
506
519
|
lines.push(`- docs scanned: ${index.docs.length}`);
|
|
507
520
|
lines.push(`- errors: ${index.errors.length}`);
|
|
508
521
|
lines.push(`- warnings: ${index.warnings.length}`);
|
|
522
|
+
lines.push(`- reference validation: ${referenceValidation.checkedDocs} docs checked; ${referenceValidation.terminalDocsSkipped} terminal docs skipped`);
|
|
509
523
|
lines.push('');
|
|
510
524
|
|
|
511
525
|
if (index.errors.length > 0) {
|
package/src/section.mjs
CHANGED
|
@@ -9,6 +9,7 @@ export function walkSections(body) {
|
|
|
9
9
|
const headingRe = /^(#{1,6})\s+(.+?)\s*$/;
|
|
10
10
|
const sections = [];
|
|
11
11
|
let fenceChar = null;
|
|
12
|
+
let h2Ancestor = null;
|
|
12
13
|
|
|
13
14
|
for (let i = 0; i < lines.length; i++) {
|
|
14
15
|
const line = lines[i];
|
|
@@ -22,13 +23,18 @@ export function walkSections(body) {
|
|
|
22
23
|
if (fenceChar !== null) continue;
|
|
23
24
|
const h = line.match(headingRe);
|
|
24
25
|
if (!h) continue;
|
|
26
|
+
const level = h[1].length;
|
|
27
|
+
const heading = h[2];
|
|
28
|
+
if (level === 1) h2Ancestor = null;
|
|
25
29
|
sections.push({
|
|
26
|
-
level
|
|
27
|
-
heading
|
|
30
|
+
level,
|
|
31
|
+
heading,
|
|
32
|
+
h2Ancestor: level > 2 ? h2Ancestor : null,
|
|
28
33
|
lineStart: i + 1, // 1-indexed
|
|
29
34
|
lineEnd: lines.length, // patched below
|
|
30
35
|
bodyLineStart: i + 2,
|
|
31
36
|
});
|
|
37
|
+
if (level === 2) h2Ancestor = heading;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
for (let i = 0; i < sections.length; i++) {
|
|
@@ -111,6 +117,7 @@ export function detectMarker(heading) {
|
|
|
111
117
|
// codepoint was. Reported by the owner, 2026-08-16.
|
|
112
118
|
const PHASE_DECORATION = String.raw`[\s>*_~\`#-]*(?:[✅🚧⬜🟡⏭☑✔✓◻☐⬛🔴🔄][︎️]?\s*)*`;
|
|
113
119
|
const PHASE_LEAD = new RegExp(`^${PHASE_DECORATION}phase\\b`, 'i');
|
|
120
|
+
const FILES_MANIFEST_ANCESTOR = new RegExp(`^${PHASE_DECORATION}files\\b`, 'i');
|
|
114
121
|
|
|
115
122
|
// "Phase 3 outcome" is commentary ABOUT a phase, not a phase. Counting it
|
|
116
123
|
// inflates the phase set, and because `findActivePhase` ranks blocked above
|
|
@@ -126,9 +133,33 @@ const PHASE_COMMENTARY = new RegExp(
|
|
|
126
133
|
'i',
|
|
127
134
|
);
|
|
128
135
|
|
|
136
|
+
// Compound commentary shapes proven against the full platform corpus. Keep
|
|
137
|
+
// these anchored immediately after the phase identifier: the individual nouns
|
|
138
|
+
// all collide with genuine work. In particular, do not add bare `status`,
|
|
139
|
+
// `audit`, `design`, `shape`, `plan`, `pre-plan`, or `gap-check`; the corpus has
|
|
140
|
+
// executable phases with each of those names. The dated gap-check form below is
|
|
141
|
+
// a status report, while `Phase N — gap-check fixes` remains a real phase.
|
|
142
|
+
const PHASE_COMPOUND_COMMENTARY = new RegExp(
|
|
143
|
+
`^${PHASE_DECORATION}phase\\s+\\S+\\s+(?:` +
|
|
144
|
+
`execution\\s+status|` +
|
|
145
|
+
`status\\s+snapshot|` +
|
|
146
|
+
`gap[- ]check\\s+\\d{4}-\\d{2}-\\d{2}|` +
|
|
147
|
+
`audit\\s+results?|` +
|
|
148
|
+
`inventory\\s+findings?|` +
|
|
149
|
+
`deviations\\s*(?:\\+|&|and)\\s*(?:open\\s+questions|notes?)|` +
|
|
150
|
+
`design\\s+[-—:]\\s*researched|` +
|
|
151
|
+
`shape\\s+[-—:]\\s*worked\\s+out|` +
|
|
152
|
+
`sub[- ]phases` +
|
|
153
|
+
`)\\b`,
|
|
154
|
+
'i',
|
|
155
|
+
);
|
|
156
|
+
|
|
129
157
|
export function isPhaseHeading(section) {
|
|
130
158
|
if (section.level !== 3) return false;
|
|
131
|
-
|
|
159
|
+
if (FILES_MANIFEST_ANCESTOR.test(section.h2Ancestor ?? '')) return false;
|
|
160
|
+
return PHASE_LEAD.test(section.heading)
|
|
161
|
+
&& !PHASE_COMMENTARY.test(section.heading)
|
|
162
|
+
&& !PHASE_COMPOUND_COMMENTARY.test(section.heading);
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
// A phase's OWN checklist — the boxes directly under its heading, stopping at
|
package/src/status-metadata.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function resolveStatusMetadata(config) {
|
|
|
30
30
|
context: contextByStatus.get(name) ?? 'counted',
|
|
31
31
|
staleDays: hasTypeStaleDays ? typeDef.staleDays[name] : (config.staleDaysByStatus?.[name] ?? null),
|
|
32
32
|
startable: config.lifecycle.startableStatuses.has(name),
|
|
33
|
-
terminal: config.lifecycle.terminalStatuses.has(name),
|
|
33
|
+
terminal: config.lifecycle.isTerminal?.(name, type) ?? config.lifecycle.terminalStatuses.has(name),
|
|
34
34
|
archive: config.lifecycle.archiveStatuses.has(name),
|
|
35
35
|
filed: config.lifecycle.filedStatuses.get(name) ?? null,
|
|
36
36
|
skipStale,
|
package/src/validate.mjs
CHANGED
|
@@ -259,18 +259,20 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
259
259
|
// exit code with a hard error.
|
|
260
260
|
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
261
261
|
const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
|
|
262
|
-
const skipRefValidation = config.lifecycle.
|
|
263
|
-
|
|
262
|
+
const skipRefValidation = config.lifecycle.isTerminal?.(doc.status, doc.type)
|
|
263
|
+
?? config.lifecycle.terminalStatuses.has(doc.status);
|
|
264
264
|
if (!skipRefValidation) {
|
|
265
|
+
const compatibilityWarning = config.lifecycle.skipsWarnings(doc.status, doc.type);
|
|
265
266
|
for (const field of allRefFields) {
|
|
266
267
|
for (const relPath of (doc.refFields[field] || [])) {
|
|
267
268
|
if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
|
|
268
|
-
|
|
269
|
+
const issue = {
|
|
269
270
|
path: doc.path,
|
|
270
|
-
level: 'error',
|
|
271
|
+
level: compatibilityWarning ? 'warning' : 'error',
|
|
271
272
|
message: `${field} entry \`${relPath}\` does not resolve to an existing file.`,
|
|
272
|
-
meta: { kind: 'ref-resolution', field, relPath },
|
|
273
|
-
}
|
|
273
|
+
meta: { kind: compatibilityWarning ? 'ref-resolution-compat' : 'ref-resolution', field, relPath },
|
|
274
|
+
};
|
|
275
|
+
(compatibilityWarning ? doc.warnings : doc.errors).push(issue);
|
|
274
276
|
}
|
|
275
277
|
}
|
|
276
278
|
}
|
|
@@ -326,7 +328,7 @@ function candidatePathsForType(docs, type) {
|
|
|
326
328
|
// name implies one (e.g. `related_plans` → plans only).
|
|
327
329
|
export function enrichRefErrorSuggestions(docs, config) {
|
|
328
330
|
const enrich = (entry) => {
|
|
329
|
-
if (!entry?.meta ||
|
|
331
|
+
if (!entry?.meta || !['ref-resolution', 'ref-resolution-compat'].includes(entry.meta.kind)) return;
|
|
330
332
|
if (entry._suggested) return;
|
|
331
333
|
const inferred = inferRefFieldType(entry.meta.field);
|
|
332
334
|
const candidates = candidatePathsForType(docs, inferred);
|