dotmd-cli 0.74.3 → 0.74.5
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/config.mjs +36 -2
- package/src/extractors.mjs +11 -2
- package/src/frontmatter-fix.mjs +1 -1
- package/src/hub-membership.mjs +8 -8
- package/src/hub.mjs +24 -2
- package/src/lint.mjs +1 -1
- package/src/render.mjs +2 -2
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +1 -1
- package/src/statuses.mjs +1 -1
- package/src/sync-status.mjs +5 -5
- package/src/validate.mjs +32 -28
package/package.json
CHANGED
package/src/config.mjs
CHANGED
|
@@ -146,6 +146,13 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
146
146
|
archiveStatuses: [],
|
|
147
147
|
skipStaleFor: [],
|
|
148
148
|
skipWarningsFor: [],
|
|
149
|
+
// Same membership as `skipWarningsFor`, but kept per declaring type. The flat
|
|
150
|
+
// list is a union of status NAMES, so a name any type marks quiet suppresses
|
|
151
|
+
// warnings for every other type that happens to share it — `journey.active`
|
|
152
|
+
// silencing every `active` plan in the repo. Statuses are type-scoped
|
|
153
|
+
// everywhere else (`typeStatuses`, `isValidStatus`), so the suppression that
|
|
154
|
+
// reads them has to be too.
|
|
155
|
+
skipWarningsForByType: {},
|
|
149
156
|
terminalStatuses: [],
|
|
150
157
|
moduleRequiredFor: [],
|
|
151
158
|
// F15: status-name → directory-name (defaults to the status name verbatim).
|
|
@@ -166,6 +173,10 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
166
173
|
if (typeof typeDef.statuses !== 'object') continue;
|
|
167
174
|
|
|
168
175
|
hasRich = true;
|
|
176
|
+
// Claim the key even when no status is quiet: an empty list means "this type
|
|
177
|
+
// declared its statuses and suppresses nothing", which must not fall back to
|
|
178
|
+
// the global union. Absent entirely means "type never declared rich statuses".
|
|
179
|
+
derived.skipWarningsForByType[typeName] ??= [];
|
|
169
180
|
const statusNames = [];
|
|
170
181
|
const typeContext = { expanded: [], listed: [], counted: [] };
|
|
171
182
|
const typeStaleDays = {};
|
|
@@ -203,7 +214,10 @@ function normalizeRichStatuses(config, userConfig) {
|
|
|
203
214
|
if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
|
|
204
215
|
if (p.startable && !derived.startableStatuses.includes(name)) derived.startableStatuses.push(name);
|
|
205
216
|
if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
|
|
206
|
-
if (
|
|
217
|
+
if (p.skipWarnings || quietImpliesSkipWarnings) {
|
|
218
|
+
if (!derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
|
|
219
|
+
(derived.skipWarningsForByType[typeName] ??= []).push(name);
|
|
220
|
+
}
|
|
207
221
|
if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
|
|
208
222
|
if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
|
|
209
223
|
if (p.filed && !derived.filedStatuses[name]) {
|
|
@@ -253,8 +267,17 @@ function applyDerivedConfig(config, userConfig, derived) {
|
|
|
253
267
|
if (!userConfig.lifecycle?.skipStaleFor && derived.skipStaleFor.length) {
|
|
254
268
|
config.lifecycle.skipStaleFor = derived.skipStaleFor;
|
|
255
269
|
}
|
|
270
|
+
// Assigned on BOTH branches, never conditionally: `config.lifecycle` can be the
|
|
271
|
+
// module-level DEFAULTS.lifecycle object itself (deepMerge shallow-copies, so a
|
|
272
|
+
// user config with no `lifecycle` key shares the reference), and a conditional
|
|
273
|
+
// write would leave a previous resolveConfig call's map in place.
|
|
256
274
|
if (!userConfig.lifecycle?.skipWarningsFor && derived.skipWarningsFor.length) {
|
|
257
275
|
config.lifecycle.skipWarningsFor = derived.skipWarningsFor;
|
|
276
|
+
config.lifecycle.skipWarningsForByType = derived.skipWarningsForByType;
|
|
277
|
+
} else {
|
|
278
|
+
// An explicit `lifecycle.skipWarningsFor` is a deliberate repo-wide statement
|
|
279
|
+
// and stays type-blind.
|
|
280
|
+
config.lifecycle.skipWarningsForByType = {};
|
|
258
281
|
}
|
|
259
282
|
if (!userConfig.lifecycle?.terminalStatuses && derived.terminalStatuses.length) {
|
|
260
283
|
config.lifecycle.terminalStatuses = derived.terminalStatuses;
|
|
@@ -504,6 +527,17 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
504
527
|
const startableStatuses = new Set(lifecycle.startableStatuses ?? ['active', 'planned']);
|
|
505
528
|
const skipStaleFor = new Set(lifecycle.skipStaleFor);
|
|
506
529
|
const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
|
|
530
|
+
const skipWarningsForByType = new Map(
|
|
531
|
+
Object.entries(lifecycle.skipWarningsForByType ?? {}).map(([type, names]) => [type, new Set(names)]),
|
|
532
|
+
);
|
|
533
|
+
// Does `status` suppress warning-only checks for a doc of this type? A type that
|
|
534
|
+
// declared rich statuses answers for itself — including answering "no" for a
|
|
535
|
+
// name some other type marked quiet. Everything else (no rich statuses, an
|
|
536
|
+
// explicit global `skipWarningsFor`, a doc with no type) reads the flat set.
|
|
537
|
+
const skipsWarnings = (status, type) => {
|
|
538
|
+
const own = type != null ? skipWarningsForByType.get(type) : undefined;
|
|
539
|
+
return own ? own.has(status) : skipWarningsFor.has(status);
|
|
540
|
+
};
|
|
507
541
|
const terminalStatuses = new Set(lifecycle.terminalStatuses);
|
|
508
542
|
// F15: filedStatuses keyed by status name, value = directory name. Empty
|
|
509
543
|
// object when no status opts in via `filed: true` (or `filed: '<dirname>'`).
|
|
@@ -555,7 +589,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
555
589
|
rootValidStatuses,
|
|
556
590
|
staleDaysByStatus,
|
|
557
591
|
|
|
558
|
-
lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, terminalStatuses, filedStatuses, archiveNestedTypes },
|
|
592
|
+
lifecycle: { archiveStatuses, startableStatuses, skipStaleFor, skipWarningsFor, skipWarningsForByType, skipsWarnings, terminalStatuses, filedStatuses, archiveNestedTypes },
|
|
559
593
|
|
|
560
594
|
validSurfaces,
|
|
561
595
|
validModules,
|
package/src/extractors.mjs
CHANGED
|
@@ -41,8 +41,17 @@ export function extractNextStep(body) {
|
|
|
41
41
|
|
|
42
42
|
export function extractBodyLinks(body) {
|
|
43
43
|
if (!body) return [];
|
|
44
|
-
// Strip fenced code blocks
|
|
45
|
-
|
|
44
|
+
// Strip fenced code blocks, then MASK inline code rather than delete it.
|
|
45
|
+
// Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
|
|
46
|
+
// has its link TEXT as a code span, so removing the span left `[](plan.md)`,
|
|
47
|
+
// which the regex below rejects for having empty text. A hub with hundreds of
|
|
48
|
+
// such links reported one — and since this list is what validates body links,
|
|
49
|
+
// every one of them was also never checked for breakage. Masking to same-length
|
|
50
|
+
// filler keeps the link matchable while still neutralizing a link that is
|
|
51
|
+
// 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));
|
|
46
55
|
const links = [];
|
|
47
56
|
// Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
|
|
48
57
|
const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
|
package/src/frontmatter-fix.mjs
CHANGED
|
@@ -33,7 +33,7 @@ export function runFrontmatterFix(config, opts = {}) {
|
|
|
33
33
|
const status = asString(parsed.status);
|
|
34
34
|
if (config.lifecycle.terminalStatuses.has(status)
|
|
35
35
|
|| config.lifecycle.archiveStatuses.has(status)
|
|
36
|
-
|| config.lifecycle.
|
|
36
|
+
|| config.lifecycle.skipsWarnings(status, docType)) continue;
|
|
37
37
|
|
|
38
38
|
const ops = [];
|
|
39
39
|
for (const { name, cap, target, heading } of FIELDS) {
|
package/src/hub-membership.mjs
CHANGED
|
@@ -39,10 +39,10 @@ const BACKREF_KIND = 'hub-membership-backref';
|
|
|
39
39
|
|
|
40
40
|
export function checkHubMembershipDrift(docs, config) {
|
|
41
41
|
const warnings = [];
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
43
|
+
// type stays loud for another that declared the same name.
|
|
44
|
+
const quiet = (d) => config.lifecycle?.terminalStatuses?.has(d.status)
|
|
45
|
+
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
46
46
|
const byPath = new Map(docs.map(doc => [doc.path, doc]));
|
|
47
47
|
const refFields = [
|
|
48
48
|
...(config.referenceFields?.bidirectional ?? []),
|
|
@@ -82,7 +82,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
82
82
|
// it lives. Warns on the HUB: the hub's list is the half that lost the entry.
|
|
83
83
|
const knownCache = new Map();
|
|
84
84
|
for (const child of docs) {
|
|
85
|
-
if (quiet
|
|
85
|
+
if (quiet(child)) continue;
|
|
86
86
|
const parents = child.refFields?.parent_plan ?? [];
|
|
87
87
|
if (parents.length === 0) continue;
|
|
88
88
|
const dir = dirOf(child);
|
|
@@ -92,7 +92,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
92
92
|
// parent rows nothing, and demanding a link back there would be a new
|
|
93
93
|
// opinion rather than a drift check.
|
|
94
94
|
if (!hub || hub.path === child.path || !isHubDoc(hub)) continue;
|
|
95
|
-
if (quiet
|
|
95
|
+
if (quiet(hub)) continue;
|
|
96
96
|
if (!knownCache.has(hub.path)) knownCache.set(hub.path, knownTo(hub));
|
|
97
97
|
if (knownCache.get(hub.path).has(child.path)) continue;
|
|
98
98
|
warnings.push({
|
|
@@ -113,7 +113,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
113
113
|
// back-ref is never reported twice. Warns on the CHILD, matching that check:
|
|
114
114
|
// it's the file that needs the edit.
|
|
115
115
|
for (const hub of docs) {
|
|
116
|
-
if (!isHubDoc(hub) || quiet
|
|
116
|
+
if (!isHubDoc(hub) || quiet(hub)) continue;
|
|
117
117
|
let body;
|
|
118
118
|
try { ({ body } = extractFrontmatter(readFileSync(path.join(config.repoRoot, hub.path), 'utf8'))); }
|
|
119
119
|
catch { continue; }
|
|
@@ -131,7 +131,7 @@ export function checkHubMembershipDrift(docs, config) {
|
|
|
131
131
|
const child = resolve(ref, dir);
|
|
132
132
|
if (!child || child.path === hub.path || seen.has(child.path)) continue;
|
|
133
133
|
seen.add(child.path);
|
|
134
|
-
if (quiet
|
|
134
|
+
if (quiet(child)) continue; // closed work is normal history
|
|
135
135
|
if (inFrontmatterRunlist.has(child.path)) continue; // checkRunlistBackPointers owns it
|
|
136
136
|
if (isHubDoc(child)) continue; // a hub under a hub is the roadmap tier
|
|
137
137
|
if (child.type && child.type !== 'plan') continue; // `parent_plan` is a plan relationship
|
package/src/hub.mjs
CHANGED
|
@@ -242,9 +242,31 @@ export function detectBodyRunlistRefs(body) {
|
|
|
242
242
|
let match;
|
|
243
243
|
while ((match = linkSectionRe.exec(body)) !== null) {
|
|
244
244
|
const section = sliceSection(match.index + match[0].length);
|
|
245
|
+
// Line by line, because the SHAPE decides what a link means, not the
|
|
246
|
+
// heading's name. A section titled `## Runlist index — by category` matches
|
|
247
|
+
// this heading list but is written as tables, and taking every link in it
|
|
248
|
+
// made each row's descriptive prose a membership claim: a row about plan A
|
|
249
|
+
// whose prose says "spawned children B, C" ranked B and C as the hub's own,
|
|
250
|
+
// then warned B and C for not naming the hub as parent. In a table, only the
|
|
251
|
+
// row's first link is the ranked plan — the same rule the ranked-queue
|
|
252
|
+
// branch below already applies.
|
|
245
253
|
const allLinks = new RegExp(linkRe.source, 'g');
|
|
246
|
-
|
|
247
|
-
|
|
254
|
+
for (const rawLine of section.split('\n')) {
|
|
255
|
+
const line = rawLine.trim();
|
|
256
|
+
if (line.startsWith('|')) {
|
|
257
|
+
const link = firstRowLink(line);
|
|
258
|
+
if (link) refs.push(link);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
// One ranked item per line, first link wins — the same rule as a table
|
|
262
|
+
// row. A ranked item routinely carries commentary that links elsewhere
|
|
263
|
+
// ("Phase 7's open item closes on a route decision in [other-plan.md]"),
|
|
264
|
+
// and counting those made the hub claim plans it was only citing. A hub
|
|
265
|
+
// that means to rank two plans lists them on two lines.
|
|
266
|
+
allLinks.lastIndex = 0;
|
|
267
|
+
const link = allLinks.exec(line);
|
|
268
|
+
if (link) refs.push(link[1]);
|
|
269
|
+
}
|
|
248
270
|
|
|
249
271
|
const checklistRe = /^\s*[-*]\s+\[[ xX]\]\s+([^\s)]+\.md(?:#[^\s)]+)?)/gm;
|
|
250
272
|
let item;
|
package/src/lint.mjs
CHANGED
|
@@ -79,7 +79,7 @@ export function runLint(argv, config, opts = {}) {
|
|
|
79
79
|
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
80
80
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
81
81
|
const fixes = [];
|
|
82
|
-
const skipWarnings = config.lifecycle.
|
|
82
|
+
const skipWarnings = config.lifecycle.skipsWarnings(asString(parsed.status), asString(parsed.type));
|
|
83
83
|
|
|
84
84
|
// Missing type (fixable — infer from root: plans → 'plan', else 'doc')
|
|
85
85
|
if (!asString(parsed.type)) {
|
package/src/render.mjs
CHANGED
|
@@ -291,8 +291,8 @@ function _renderContext(index, config, opts = {}) {
|
|
|
291
291
|
lines.push('Stale: none');
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
-
const withErrors = index.docs.filter(d => d.errors.length > 0 && !config.lifecycle.
|
|
295
|
-
const withWarnings = index.docs.filter(d => d.warnings.length > 0 && !config.lifecycle.
|
|
294
|
+
const withErrors = index.docs.filter(d => d.errors.length > 0 && !config.lifecycle.skipsWarnings(d.status, d.type));
|
|
295
|
+
const withWarnings = index.docs.filter(d => d.warnings.length > 0 && !config.lifecycle.skipsWarnings(d.status, d.type));
|
|
296
296
|
if (withErrors.length || withWarnings.length) {
|
|
297
297
|
const parts = [];
|
|
298
298
|
if (withErrors.length) parts.push(`${withErrors.length} with errors`);
|
package/src/stats.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export function buildStats(index, config) {
|
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
17
|
const scoped = docs.filter(d => scope.includes(d.status));
|
|
18
|
-
const nonArchived = docs.filter(d => !config.lifecycle.
|
|
18
|
+
const nonArchived = docs.filter(d => !config.lifecycle.skipsWarnings(d.status, d.type));
|
|
19
19
|
|
|
20
20
|
// Health
|
|
21
21
|
const staleCount = nonArchived.filter(d => d.isStale).length;
|
package/src/status-metadata.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export function resolveStatusMetadata(config) {
|
|
|
22
22
|
|
|
23
23
|
byType[type] = statuses.map((name, rank) => {
|
|
24
24
|
const skipStale = config.lifecycle.skipStaleFor.has(name);
|
|
25
|
-
const skipWarnings = config.lifecycle.
|
|
25
|
+
const skipWarnings = config.lifecycle.skipsWarnings(name, type);
|
|
26
26
|
const hasTypeStaleDays = Object.prototype.hasOwnProperty.call(typeDef.staleDays ?? {}, name);
|
|
27
27
|
return {
|
|
28
28
|
name,
|
package/src/statuses.mjs
CHANGED
|
@@ -451,7 +451,7 @@ async function runMigrateType(args, config, opts) {
|
|
|
451
451
|
if (lc.terminalStatuses.has(name)) props.terminal = true;
|
|
452
452
|
// Apply quiet sugar when both skipStale and skipWarnings hold; otherwise emit the individual flag.
|
|
453
453
|
const skipStale = lc.skipStaleFor.has(name);
|
|
454
|
-
const skipWarnings = lc.
|
|
454
|
+
const skipWarnings = lc.skipsWarnings(name, typeName);
|
|
455
455
|
if (skipStale && skipWarnings) props.quiet = true;
|
|
456
456
|
else {
|
|
457
457
|
if (skipStale) props.skipStale = true;
|
package/src/sync-status.mjs
CHANGED
|
@@ -89,16 +89,16 @@ export function collectHubStatusRows(docs, config, { hubPaths = null } = {}) {
|
|
|
89
89
|
const key = doc.path.toLowerCase();
|
|
90
90
|
docByFoldedPath.set(key, docByFoldedPath.has(key) ? null : doc);
|
|
91
91
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
// Per-doc: warning suppression is type-scoped, so a status name quiet for one
|
|
93
|
+
// type stays loud for another that declared the same name.
|
|
94
|
+
const quiet = (d) => config.lifecycle?.terminalStatuses?.has(d.status)
|
|
95
|
+
|| config.lifecycle?.skipsWarnings(d.status, d.type);
|
|
96
96
|
const out = [];
|
|
97
97
|
|
|
98
98
|
for (const hub of docs) {
|
|
99
99
|
if (!isHubDoc(hub)) continue;
|
|
100
100
|
if (hubPaths) { if (!hubPaths.has(hub.path)) continue; }
|
|
101
|
-
else if (quiet
|
|
101
|
+
else if (quiet(hub)) continue;
|
|
102
102
|
|
|
103
103
|
let raw;
|
|
104
104
|
try { raw = readFileSync(path.join(config.repoRoot, hub.path), 'utf8'); } catch { continue; }
|
package/src/validate.mjs
CHANGED
|
@@ -93,7 +93,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
93
93
|
|
|
94
94
|
const knownStatus = isValidStatus(doc.status, doc.root, config, doc.type);
|
|
95
95
|
|
|
96
|
-
if (knownStatus && !config.lifecycle.
|
|
96
|
+
if (knownStatus && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !doc.updated) {
|
|
97
97
|
doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -130,7 +130,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
130
130
|
doc.errors.push({ path: doc.path, level: 'error', message: '`modules` is required for this status; declare a real module from `taxonomy.modules`, or `none` as the explicit no-module sentinel.' });
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
if (config.validSurfaces && !config.lifecycle.
|
|
133
|
+
if (config.validSurfaces && !config.lifecycle.skipsWarnings(doc.status, doc.type)) {
|
|
134
134
|
const knownSurfaces = [...config.validSurfaces];
|
|
135
135
|
for (const surface of doc.surfaces) {
|
|
136
136
|
if (!config.validSurfaces.has(surface)) {
|
|
@@ -153,7 +153,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
153
153
|
// target is inlined in the message so `dotmd lint --fix` users see exactly
|
|
154
154
|
// what they'll end up with — and so non-fix readers can hand-migrate.
|
|
155
155
|
// Suppress for archived/terminal docs (same noise-control rule as F2).
|
|
156
|
-
if (!config.lifecycle.
|
|
156
|
+
if (!config.lifecycle.skipsWarnings(doc.status, doc.type)) {
|
|
157
157
|
for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
|
|
158
158
|
const singularValue = frontmatter[singular];
|
|
159
159
|
if (!Object.prototype.hasOwnProperty.call(frontmatter, singular)) continue;
|
|
@@ -190,13 +190,13 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
190
190
|
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `title` and no H1 found for fallback.' });
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
if (!skipTitleSummary && !config.lifecycle.
|
|
193
|
+
if (!skipTitleSummary && !config.lifecycle.skipsWarnings(doc.status, doc.type) && !asString(frontmatter.summary) && !doc.summary) {
|
|
194
194
|
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `summary` and no blockquote fallback found.' });
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
// Determine which statuses should have current_state and next_step (plans only, not docs/research)
|
|
198
198
|
const isPlanWork = knownStatus && doc.status && (!doc.type || doc.type === 'plan')
|
|
199
|
-
&& !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.
|
|
199
|
+
&& !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipsWarnings(doc.status, doc.type);
|
|
200
200
|
|
|
201
201
|
if (isPlanWork && !asString(frontmatter.current_state)) {
|
|
202
202
|
doc.warnings.push({ path: doc.path, level: 'warning', message: 'Missing `current_state`; index output is using a fallback or placeholder.' });
|
|
@@ -259,7 +259,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
259
259
|
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
260
260
|
const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
|
|
261
261
|
const skipRefValidation = config.lifecycle.terminalStatuses.has(doc.status)
|
|
262
|
-
|| config.lifecycle.
|
|
262
|
+
|| config.lifecycle.skipsWarnings(doc.status, doc.type);
|
|
263
263
|
if (!skipRefValidation) {
|
|
264
264
|
for (const field of allRefFields) {
|
|
265
265
|
for (const relPath of (doc.refFields[field] || [])) {
|
|
@@ -406,14 +406,14 @@ export function checkBidirectionalReferences(docs, config) {
|
|
|
406
406
|
// child it doesn't own without nagging the child to add `parent_plan:`.
|
|
407
407
|
export function checkRunlistBackPointers(docs, config) {
|
|
408
408
|
const warnings = [];
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
409
|
+
// Per-doc, not a precomputed name set: warning suppression is type-scoped, so
|
|
410
|
+
// the same status name can be quiet for one type and loud for another.
|
|
411
|
+
const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
|
|
412
|
+
|| config.lifecycle.skipsWarnings(d.status, d.type);
|
|
413
413
|
const byPath = new Map(docs.map(d => [d.path, d]));
|
|
414
414
|
|
|
415
415
|
for (const hub of docs) {
|
|
416
|
-
if (
|
|
416
|
+
if (skipDoc(hub)) continue;
|
|
417
417
|
const runlistRefs = hub.refFields?.runlist ?? [];
|
|
418
418
|
const runlistDirs = hub.refFieldDirections?.runlist ?? [];
|
|
419
419
|
if (runlistRefs.length === 0) continue;
|
|
@@ -427,7 +427,7 @@ export function checkRunlistBackPointers(docs, config) {
|
|
|
427
427
|
const childPath = toRepoPath(resolved, config.repoRoot);
|
|
428
428
|
const child = byPath.get(childPath);
|
|
429
429
|
if (!child) continue;
|
|
430
|
-
if (
|
|
430
|
+
if (skipDoc(child)) continue;
|
|
431
431
|
const childParents = (child.refFields?.parent_plan ?? []).map(p => {
|
|
432
432
|
const abs = resolveRefPath(p, path.dirname(path.join(config.repoRoot, child.path)), config.repoRoot);
|
|
433
433
|
return abs ? toRepoPath(abs, config.repoRoot) : p;
|
|
@@ -451,17 +451,21 @@ export function checkRunlistBackPointers(docs, config) {
|
|
|
451
451
|
// it explicit. Skips terminal/quiet statuses like every other warning-only check.
|
|
452
452
|
export function checkCoordinationHubExecutionMode(docs, config) {
|
|
453
453
|
const warnings = [];
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
454
|
+
// Per-doc, not a precomputed name set: warning suppression is type-scoped, so
|
|
455
|
+
// the same status name can be quiet for one type and loud for another.
|
|
456
|
+
const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
|
|
457
|
+
|| config.lifecycle.skipsWarnings(d.status, d.type);
|
|
458
458
|
for (const doc of docs) {
|
|
459
459
|
if (doc.type && doc.type !== 'plan') continue;
|
|
460
|
-
if (
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
460
|
+
if (skipDoc(doc)) continue;
|
|
461
|
+
// ANY explicit `execution_mode` ends it. The field is the canonical signal
|
|
462
|
+
// and the slug is only a fallback for hubs that predate it, so a plan that
|
|
463
|
+
// already answered — `implementation`, `roadmap`, anything — has said what it
|
|
464
|
+
// is. Nudging past that misfires on exactly one population: plans ABOUT
|
|
465
|
+
// runlists, whose slug contains the word while the document is ordinary work.
|
|
466
|
+
// (A real one: a plan to rename the runlist concept, declared
|
|
467
|
+
// `execution_mode: implementation`, nagged to call itself a coordination hub.)
|
|
468
|
+
if (doc.executionMode) continue;
|
|
465
469
|
const base = (doc.path.split('/').pop() || '').replace(/\.md$/, '');
|
|
466
470
|
if (base !== 'runlist' && !base.endsWith('-runlist')) continue;
|
|
467
471
|
warnings.push({
|
|
@@ -482,10 +486,10 @@ export function checkCoordinationHubExecutionMode(docs, config) {
|
|
|
482
486
|
// coordination hub that merely references one sibling runlist isn't mislabelled.
|
|
483
487
|
export function checkRoadmapHubExecutionMode(docs, config) {
|
|
484
488
|
const warnings = [];
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
+
// Per-doc, not a precomputed name set: warning suppression is type-scoped, so
|
|
490
|
+
// the same status name can be quiet for one type and loud for another.
|
|
491
|
+
const skipDoc = (d) => config.lifecycle.terminalStatuses.has(d.status)
|
|
492
|
+
|| config.lifecycle.skipsWarnings(d.status, d.type);
|
|
489
493
|
const byPath = new Map(docs.map(d => [d.path, d]));
|
|
490
494
|
const byBasename = new Map();
|
|
491
495
|
for (const d of docs) {
|
|
@@ -501,7 +505,7 @@ export function checkRoadmapHubExecutionMode(docs, config) {
|
|
|
501
505
|
};
|
|
502
506
|
for (const doc of docs) {
|
|
503
507
|
if (doc.type && doc.type !== 'plan') continue;
|
|
504
|
-
if (
|
|
508
|
+
if (skipDoc(doc)) continue;
|
|
505
509
|
if (doc.executionMode === 'roadmap') continue; // already a roadmap
|
|
506
510
|
if (doc.executionMode !== 'coordination') continue; // only nudge explicit coordination hubs
|
|
507
511
|
const refs = doc.refFields?.related_plans ?? [];
|
|
@@ -585,7 +589,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
585
589
|
if (doc.type !== 'plan') return;
|
|
586
590
|
// Skip plans in terminal/archive statuses (closed work shouldn't generate noise)
|
|
587
591
|
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
588
|
-
if (config.lifecycle.
|
|
592
|
+
if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
|
|
589
593
|
|
|
590
594
|
// 1. next_step length cap (800 chars). Was 300; raised in parallel with
|
|
591
595
|
// current_state for the same reason: agents need to encode "what to do next"
|
|
@@ -658,7 +662,7 @@ export function validatePlanShape(doc, body, frontmatter, config) {
|
|
|
658
662
|
export function validateDocShape(doc, body, frontmatter, config) {
|
|
659
663
|
if (doc.type !== 'doc') return;
|
|
660
664
|
if (config.lifecycle.terminalStatuses.has(doc.status) || config.lifecycle.archiveStatuses.has(doc.status)) return;
|
|
661
|
-
if (config.lifecycle.
|
|
665
|
+
if (config.lifecycle.skipsWarnings(doc.status, doc.type)) return;
|
|
662
666
|
|
|
663
667
|
if (!body) return;
|
|
664
668
|
|