dotmd-cli 0.14.11 → 0.15.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/README.md CHANGED
@@ -88,7 +88,7 @@ Every document can have a `type` field in its frontmatter. Types determine which
88
88
 
89
89
  | Type | Purpose | Valid Statuses |
90
90
  |------|---------|----------------|
91
- | `plan` | Execution plans | `in-session`, `active`, `planned`, `blocked`, `done`, `archived` |
91
+ | `plan` | Execution plans | `in-session`, `active`, `planned`, `blocked`, `partial`, `paused`, `awaiting`, `queued-after`, `archived` |
92
92
  | `doc` | Design docs, specs, ADRs, RFCs | `draft`, `active`, `review`, `reference`, `deprecated`, `archived` |
93
93
  | `research` | Investigations, audits, analysis | `active`, `reference`, `archived` |
94
94
 
@@ -106,6 +106,26 @@ dotmd export --type research # export research only
106
106
 
107
107
  Customize types and their statuses in config with the `types` key. See [`dotmd.config.example.mjs`](dotmd.config.example.mjs).
108
108
 
109
+ ### What each plan status means
110
+
111
+ The default plan vocabulary is shaped around the **unstuck-action test**: every stop-status should map to a distinct next move. If two statuses have the same unstuck-action, one is dead weight; if a single status covers several different actions, it's overloaded.
112
+
113
+ | Status | Unstuck-action | When to use |
114
+ |--------|----------------|-------------|
115
+ | `in-session` | — | A Claude session is working on it right now. Don't pick up. |
116
+ | `active` | Pick up | Ready to be worked on. |
117
+ | `planned` | Wait for trigger | Queued; not yet ready to execute. |
118
+ | `blocked` | **Monitor** | External arrival on its own schedule (hardware, vendor, third-party rollout). You can't speed it up. |
119
+ | `partial` | **Spawn successors** | Shipped most of the plan; tail deferred. Body should reference successor plans tracking the tail. Visible but quiet (no nagging). |
120
+ | `paused` | **Re-evaluate** | Intentionally set aside, no external dependency. Resume by deciding the work is still worth doing. Quiet. |
121
+ | `awaiting` | **Ask** | Needs a human decision or input. NOT quiet — pings get forgotten, so this status generates stale pressure to chase the answer. |
122
+ | `queued-after` | **Check predecessor** | Sequenced behind another plan; can start once that one ships. Quiet. |
123
+ | `archived` | — | No longer relevant; auto-moved to the archive directory on transition. |
124
+
125
+ Each *quiet* status (`partial`, `paused`, `queued-after`, `archived`) is exempt from stale-warning pressure but still appears in active scope and metrics — quietness is a presentation flag, not a closure flag. `awaiting` deliberately stays loud so unanswered questions don't decay into invisible backlog.
126
+
127
+ > **Heads-up:** versions before 0.15 included a `done` plan status in the defaults. It saw effectively zero real-world use (plans went `in-session`/`active` → `archived` directly), so it was dropped from the built-in vocabulary. To finish a plan, run `dotmd archive <plan-file>` — or, if you preferred the previous behavior, add `done` back via the `types.plan.statuses` key in your config.
128
+
109
129
  ## Commands
110
130
 
111
131
  ```
@@ -390,8 +410,8 @@ dotmd rename old-name.md new-name # renames + updates refs
390
410
  ### Migrate
391
411
 
392
412
  ```bash
393
- dotmd migrate status research exploration # rename a status
394
- dotmd migrate module auth identity # rename a module
413
+ dotmd migrate status research scoping # rename a status (e.g. for the 0.15 default-vocab change)
414
+ dotmd migrate module auth identity # rename a module
395
415
  ```
396
416
 
397
417
  ### Preset Aliases
package/bin/dotmd.mjs CHANGED
@@ -140,6 +140,17 @@ Moves the document to the new status. If transitioning to an archive
140
140
  status, automatically moves the file to the archive directory and
141
141
  regenerates the index (if configured).
142
142
 
143
+ Default plan statuses (each maps to a distinct unstuck-action):
144
+ in-session A Claude session is working on it now
145
+ active Ready to be picked up
146
+ planned Queued for future work
147
+ blocked External arrival wait — monitor (hardware, vendor, rollout)
148
+ partial Shipped + deferred tail — spawn successor plans
149
+ paused Intentionally set aside — re-evaluate to resume
150
+ awaiting Needs human input/decision — chase the answer
151
+ queued-after Sequenced behind another plan — check predecessor
152
+ archived No longer relevant; auto-moved to archive directory
153
+
143
154
  Use --dry-run (-n) to preview changes without writing anything.`,
144
155
 
145
156
  check: `dotmd check — validate frontmatter and references
@@ -262,6 +273,10 @@ Options:
262
273
  --root <name> Create in a specific docs root
263
274
  --list-templates Show available templates
264
275
 
276
+ For plans, the default status vocabulary is: in-session, active, planned,
277
+ blocked, partial, paused, awaiting, queued-after, archived. Run
278
+ \`dotmd status --help\` for what each one means.
279
+
265
280
  The filename is derived from <name> by slugifying it.
266
281
  Use --dry-run (-n) to preview without creating the file.`,
267
282
 
@@ -350,7 +365,7 @@ Finds all docs where the given field equals old-value and updates it
350
365
  to new-value.
351
366
 
352
367
  Examples:
353
- dotmd migrate status research exploration
368
+ dotmd migrate status research scoping
354
369
  dotmd migrate module auth identity
355
370
 
356
371
  Use --dry-run (-n) to preview changes without writing anything.`,
@@ -368,12 +383,18 @@ modules, and reference fields to pre-populate the config.`,
368
383
  Shows all documents with type: plan, sorted by status.
369
384
  Supports all query flags (--status, --module, --json, --sort, --group, etc.)
370
385
 
386
+ Default plan statuses: in-session, active, planned, blocked, partial,
387
+ paused, awaiting, queued-after, archived. Run \`dotmd status --help\` for
388
+ the unstuck-action behind each one.
389
+
371
390
  Examples:
372
- dotmd plans # all plans
373
- dotmd plans --status active # active plans only
374
- dotmd plans --module auth # plans for the auth module
375
- dotmd plans --group module # all plans grouped by module
376
- dotmd plans --json # JSON output`,
391
+ dotmd plans # all plans
392
+ dotmd plans --status active # active plans only
393
+ dotmd plans --status awaiting # plans waiting on a human decision
394
+ dotmd plans --status partial,paused # shipped-tail and parked plans
395
+ dotmd plans --module auth # plans for the auth module
396
+ dotmd plans --group module # all plans grouped by module
397
+ dotmd plans --json # JSON output`,
377
398
 
378
399
  stale: `dotmd stale — list stale documents
379
400
 
@@ -25,20 +25,35 @@ export const excludeDirs = ['evidence'];
25
25
  // context: 'expanded' | 'listed' | 'counted' (default: 'counted')
26
26
  // staleDays: number | null — stale threshold (default: null = never stale)
27
27
  // requiresModule: boolean — require `module` frontmatter (default: false)
28
- // terminal: boolean — skip current_state/next_step warnings (default: false)
29
28
  // archive: boolean — auto-move to archiveDir on transition (default: false)
29
+ // terminal: boolean — closed state; excluded from active-work stats/coverage scope (default: false)
30
30
  // skipStale: boolean — exempt from stale checks (default: false)
31
31
  // skipWarnings: boolean — exempt from validation warnings (default: false)
32
+ // quiet: boolean — sugar for `skipStale: true, skipWarnings: true`. Use for visible-but-quiet
33
+ // statuses (e.g. `partial`) where you want no nagging but DO want them in scope.
34
+ // Setting `skipStale: false` or `skipWarnings: false` explicitly overrides the sugar.
35
+ //
36
+ // `terminal` and `quiet` are orthogonal. Mark a status `terminal` only when it represents closure
37
+ // (excluded from active-work scope). Use `quiet` for noise suppression without closure semantics.
38
+ //
39
+ // Each plan stop-status maps to a distinct unstuck-action — the test for whether
40
+ // the vocabulary earns its weight. blocked = monitor (external arrival on its own
41
+ // schedule), awaiting = ask (chase the human/decision), queued-after = check the
42
+ // predecessor, paused = re-evaluate, partial = spawn successor plans for the
43
+ // deferred tail.
32
44
  //
33
45
  // export const types = {
34
46
  // plan: {
35
47
  // statuses: {
36
- // 'in-session': { context: 'expanded', staleDays: 1, requiresModule: true },
37
- // 'active': { context: 'expanded', staleDays: 14, requiresModule: true },
38
- // 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
39
- // 'blocked': { context: 'listed', staleDays: 30, requiresModule: true, skipStale: true },
40
- // 'done': { context: 'counted', terminal: true, skipStale: true, skipWarnings: true },
41
- // 'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
48
+ // 'in-session': { context: 'expanded', staleDays: 1, requiresModule: true },
49
+ // 'active': { context: 'expanded', staleDays: 14, requiresModule: true },
50
+ // 'planned': { context: 'listed', staleDays: 30, requiresModule: true },
51
+ // 'blocked': { context: 'listed', staleDays: 30, requiresModule: true },
52
+ // 'partial': { context: 'expanded', requiresModule: true, quiet: true }, // shipped + deferred tail; visible, no nagging
53
+ // 'paused': { context: 'listed', requiresModule: true, quiet: true }, // intentionally set aside, no external dep
54
+ // 'awaiting': { context: 'listed', staleDays: 14, requiresModule: true }, // human input/decision wait — NOT quiet (pings get forgotten)
55
+ // 'queued-after': { context: 'counted', requiresModule: true, quiet: true }, // sequenced behind another plan
56
+ // 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
42
57
  // },
43
58
  // },
44
59
  // doc: {
@@ -48,7 +63,7 @@ export const excludeDirs = ['evidence'];
48
63
  // 'review': { context: 'listed', staleDays: 14 },
49
64
  // 'reference': { context: 'counted', skipStale: true },
50
65
  // 'deprecated': { context: 'counted', terminal: true, skipStale: true },
51
- // 'archived': { context: 'counted', archive: true, terminal: true, skipStale: true, skipWarnings: true },
66
+ // 'archived': { context: 'counted', archive: true, terminal: true, quiet: true },
52
67
  // },
53
68
  // },
54
69
  // };
@@ -57,16 +72,20 @@ export const excludeDirs = ['evidence'];
57
72
  // When using array form, define behavior in separate statuses/lifecycle/taxonomy sections.
58
73
  // export const types = {
59
74
  // plan: {
60
- // statuses: ['in-session', 'active', 'planned', 'blocked', 'done', 'archived'],
61
- // context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
62
- // staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
75
+ // statuses: ['in-session', 'active', 'planned', 'blocked', 'partial', 'paused', 'awaiting', 'queued-after', 'archived'],
76
+ // context: {
77
+ // expanded: ['in-session', 'active', 'partial'],
78
+ // listed: ['planned', 'blocked', 'paused', 'awaiting'],
79
+ // counted: ['queued-after', 'archived'],
80
+ // },
81
+ // staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, awaiting: 14 },
63
82
  // },
64
83
  // };
65
84
 
66
85
  // Status workflow — fallback for docs without a type field. Order determines display grouping.
67
86
  // When using rich status definitions, statuses.order and staleDays are derived automatically.
68
87
  export const statuses = {
69
- order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
88
+ order: ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'],
70
89
  // Additional statuses valid only in specific roots (merged with order)
71
90
  // Useful when different doc areas track different things (e.g. plans vs module docs)
72
91
  // rootStatuses: {
@@ -79,7 +98,7 @@ export const statuses = {
79
98
  ready: 14,
80
99
  planned: 30,
81
100
  blocked: 30,
82
- research: 30,
101
+ scoping: 30,
83
102
  },
84
103
  };
85
104
 
@@ -111,7 +130,7 @@ export const index = {
111
130
  export const context = {
112
131
  expanded: ['active'],
113
132
  listed: ['ready', 'planned'],
114
- counted: ['blocked', 'research', 'reference', 'archived'],
133
+ counted: ['blocked', 'scoping', 'reference', 'archived'],
115
134
  recentDays: 3,
116
135
  recentStatuses: ['active', 'ready', 'planned'],
117
136
  recentLimit: 10,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.14.11",
3
+ "version": "0.15.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, Notion sync, AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -22,9 +22,13 @@ const DEFAULTS = {
22
22
 
23
23
  types: {
24
24
  plan: {
25
- statuses: ['in-session', 'active', 'planned', 'blocked', 'done', 'archived'],
26
- context: { expanded: ['in-session', 'active'], listed: ['planned', 'blocked'], counted: ['done', 'archived'] },
27
- staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30 },
25
+ statuses: ['in-session', 'active', 'planned', 'blocked', 'partial', 'paused', 'awaiting', 'queued-after', 'archived'],
26
+ context: {
27
+ expanded: ['in-session', 'active', 'partial'],
28
+ listed: ['planned', 'blocked', 'paused', 'awaiting'],
29
+ counted: ['queued-after', 'archived'],
30
+ },
31
+ staleDays: { 'in-session': 1, active: 14, planned: 30, blocked: 30, awaiting: 14 },
28
32
  },
29
33
  doc: {
30
34
  statuses: ['draft', 'active', 'review', 'reference', 'deprecated', 'archived'],
@@ -39,26 +43,26 @@ const DEFAULTS = {
39
43
  },
40
44
 
41
45
  statuses: {
42
- order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
46
+ order: ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'],
43
47
  staleDays: {
44
48
  active: 14,
45
49
  ready: 14,
46
50
  planned: 30,
47
51
  blocked: 30,
48
- research: 30,
52
+ scoping: 30,
49
53
  },
50
54
  },
51
55
 
52
56
  lifecycle: {
53
57
  archiveStatuses: ['archived'],
54
- skipStaleFor: ['archived', 'reference'],
55
- skipWarningsFor: ['archived'],
56
- terminalStatuses: ['archived', 'deprecated', 'reference', 'done'],
58
+ skipStaleFor: ['archived', 'reference', 'partial', 'paused', 'queued-after'],
59
+ skipWarningsFor: ['archived', 'partial', 'paused', 'queued-after'],
60
+ terminalStatuses: ['archived', 'deprecated', 'reference'],
57
61
  },
58
62
 
59
63
  taxonomy: {
60
64
  surfaces: null,
61
- moduleRequiredFor: [],
65
+ moduleRequiredFor: ['partial', 'paused', 'awaiting', 'queued-after'],
62
66
  },
63
67
 
64
68
  index: null,
@@ -66,7 +70,7 @@ const DEFAULTS = {
66
70
  context: {
67
71
  expanded: ['active'],
68
72
  listed: ['ready', 'planned'],
69
- counted: ['blocked', 'research', 'reference', 'archived'],
73
+ counted: ['blocked', 'scoping', 'reference', 'archived'],
70
74
  recentDays: 3,
71
75
  recentStatuses: ['active', 'ready', 'planned'],
72
76
  recentLimit: 10,
@@ -92,7 +96,7 @@ const DEFAULTS = {
92
96
 
93
97
  presets: {
94
98
  plans: ['--type', 'plan', '--sort', 'status', '--all'],
95
- stale: ['--status', 'active,ready,planned,blocked,research', '--stale', '--sort', 'updated', '--all'],
99
+ stale: ['--status', 'active,ready,planned,blocked,scoping', '--stale', '--sort', 'updated', '--all'],
96
100
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
97
101
  },
98
102
  };
@@ -147,9 +151,13 @@ function normalizeRichStatuses(config, userConfig) {
147
151
  derived.staleDays[name] = p.staleDays;
148
152
  }
149
153
 
154
+ // `quiet: true` is sugar for skipStale + skipWarnings unless those are explicitly false.
155
+ const quietImpliesSkipStale = p.quiet && p.skipStale !== false;
156
+ const quietImpliesSkipWarnings = p.quiet && p.skipWarnings !== false;
157
+
150
158
  if (p.archive && !derived.archiveStatuses.includes(name)) derived.archiveStatuses.push(name);
151
- if (p.skipStale && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
152
- if (p.skipWarnings && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
159
+ if ((p.skipStale || quietImpliesSkipStale) && !derived.skipStaleFor.includes(name)) derived.skipStaleFor.push(name);
160
+ if ((p.skipWarnings || quietImpliesSkipWarnings) && !derived.skipWarningsFor.includes(name)) derived.skipWarningsFor.push(name);
153
161
  if (p.terminal && !derived.terminalStatuses.includes(name)) derived.terminalStatuses.push(name);
154
162
  if (p.requiresModule && !derived.moduleRequiredFor.includes(name)) derived.moduleRequiredFor.push(name);
155
163
 
@@ -22,11 +22,18 @@ export function replaceFrontmatter(raw, newFrontmatter) {
22
22
  return `---\n${newFrontmatter}\n---\n${body}`;
23
23
  }
24
24
 
25
- export function parseSimpleFrontmatter(text) {
25
+ // Parses our YAML subset. Optional `warnings` array receives non-fatal
26
+ // structural issues (e.g. duplicate keys) — caller decides whether to surface
27
+ // them. Default behavior is unchanged: keep first occurrence of a duplicate
28
+ // key, ignore subsequent ones.
29
+ export function parseSimpleFrontmatter(text, warnings) {
26
30
  const data = {};
31
+ const seenDupKeys = new Set();
27
32
  let currentArrayKey = null;
33
+ let lineNum = 0;
28
34
 
29
35
  for (const rawLine of text.split('\n')) {
36
+ lineNum++;
30
37
  const line = rawLine.replace(/\r$/, '');
31
38
  if (!line.trim()) continue;
32
39
 
@@ -35,6 +42,11 @@ export function parseSimpleFrontmatter(text) {
35
42
  const [, key, rawValue] = keyMatch;
36
43
  if (Object.prototype.hasOwnProperty.call(data, key)) {
37
44
  currentArrayKey = null;
45
+ if (warnings && !seenDupKeys.has(key)) {
46
+ seenDupKeys.add(key);
47
+ warnings.push({ key, line: lineNum,
48
+ message: `Duplicate frontmatter key \`${key}\` at line ${lineNum}; keeping first occurrence, ignoring later values.` });
49
+ }
38
50
  continue;
39
51
  }
40
52
  if (!rawValue.trim()) {
package/src/health.mjs CHANGED
@@ -76,7 +76,7 @@ export function runHealth(argv, config) {
76
76
 
77
77
  // Pipeline
78
78
  process.stdout.write(bold('Pipeline:') + '\n');
79
- const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'research', 'archived'];
79
+ const pipeline = ['active', 'paused', 'ready', 'planned', 'blocked', 'scoping', 'archived'];
80
80
  for (const s of pipeline) {
81
81
  const count = byStatus[s] || 0;
82
82
  if (count > 0) {
package/src/index.mjs CHANGED
@@ -120,7 +120,8 @@ export function parseDocFile(filePath, config) {
120
120
  const relativePath = toRepoPath(filePath, config.repoRoot);
121
121
  const raw = readFileSync(filePath, 'utf8');
122
122
  const { frontmatter, body } = extractFrontmatter(raw);
123
- const parsedFrontmatter = parseSimpleFrontmatter(frontmatter);
123
+ const fmWarnings = [];
124
+ const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
124
125
  const headingTitle = extractFirstHeading(body);
125
126
  const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
126
127
  const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
@@ -187,6 +188,10 @@ export function parseDocFile(filePath, config) {
187
188
  errors: [],
188
189
  };
189
190
 
191
+ for (const w of fmWarnings) {
192
+ doc.warnings.push({ path: relativePath, level: 'warning', message: w.message });
193
+ }
194
+
190
195
  validateDoc(doc, parsedFrontmatter, headingTitle, config);
191
196
  return doc;
192
197
  }
package/src/init.mjs CHANGED
@@ -68,7 +68,7 @@ function generateDetectedConfig(scan, rootPath) {
68
68
  lines.push(`export const root = '${rootPath}';`);
69
69
  lines.push('');
70
70
 
71
- const defaultOrder = ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'];
71
+ const defaultOrder = ['active', 'ready', 'planned', 'scoping', 'blocked', 'reference', 'archived'];
72
72
  const ordered = defaultOrder.filter(s => scan.statuses.has(s));
73
73
  const extra = [...scan.statuses].filter(s => !defaultOrder.includes(s)).sort();
74
74
  const allStatuses = [...ordered, ...extra];
package/src/render.mjs CHANGED
@@ -368,8 +368,8 @@ export function renderCoverage(index, config) {
368
368
  }
369
369
 
370
370
  export function buildCoverage(index, config) {
371
- const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !config.lifecycle.terminalStatuses.has(s) && !config.lifecycle.skipWarningsFor.has(s)))];
372
- const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status) && !config.lifecycle.skipWarningsFor.has(doc.status));
371
+ const scope = [...new Set(index.docs.map(d => d.status).filter(s => s && !config.lifecycle.terminalStatuses.has(s)))];
372
+ const scoped = index.docs.filter(doc => doc.status && !config.lifecycle.terminalStatuses.has(doc.status));
373
373
  const missingSurface = scoped.filter(doc => !doc.surface);
374
374
  const missingModule = scoped.filter(doc => !doc.module);
375
375
  const modulePlatform = scoped.filter(doc => doc.module === 'platform');
@@ -403,7 +403,7 @@ export function formatSnapshot(doc, config) {
403
403
 
404
404
  function _formatSnapshot(doc) {
405
405
  const state = doc.currentState ?? 'No current_state set';
406
- if (/^active:|^ready:|^planned:|^research:|^blocked:|^archived:/i.test(state)) {
406
+ if (/^active:|^ready:|^planned:|^scoping:|^blocked:|^archived:/i.test(state)) {
407
407
  return state;
408
408
  }
409
409
  return `${capitalize(doc.status ?? 'unknown')}: ${state}`;
package/src/stats.mjs CHANGED
@@ -8,10 +8,10 @@ function pct(n, total) {
8
8
 
9
9
  export function buildStats(index, config) {
10
10
  const docs = index.docs;
11
- const scope = config.statusOrder.filter(s => !config.lifecycle.terminalStatuses.has(s) && !config.lifecycle.skipWarningsFor.has(s));
11
+ const scope = config.statusOrder.filter(s => !config.lifecycle.terminalStatuses.has(s));
12
12
  for (const typeSet of (config.typeStatuses?.values() ?? [])) {
13
13
  for (const s of typeSet) {
14
- if (!config.lifecycle.terminalStatuses.has(s) && !config.lifecycle.skipWarningsFor.has(s) && !scope.includes(s)) scope.push(s);
14
+ if (!config.lifecycle.terminalStatuses.has(s) && !scope.includes(s)) scope.push(s);
15
15
  }
16
16
  }
17
17
  const scoped = docs.filter(d => scope.includes(d.status));
package/src/util.mjs CHANGED
@@ -100,3 +100,17 @@ export function resolveDocPath(input, config) {
100
100
 
101
101
  return null;
102
102
  }
103
+
104
+ // Resolve a reference path written in frontmatter or a body link.
105
+ // Tries doc-relative first (the historical convention), then falls back to
106
+ // repo-root-relative — so paths like `docs/foo/bar.md` written from any nesting
107
+ // level resolve correctly. Returns the absolute path if either form exists,
108
+ // else null.
109
+ export function resolveRefPath(relPath, docDir, repoRoot) {
110
+ if (!relPath) return null;
111
+ const docRelative = path.resolve(docDir, relPath);
112
+ if (existsSync(docRelative)) return docRelative;
113
+ const repoRelative = path.resolve(repoRoot, relPath);
114
+ if (existsSync(repoRelative)) return repoRelative;
115
+ return null;
116
+ }
package/src/validate.mjs CHANGED
@@ -1,6 +1,5 @@
1
- import { existsSync } from 'node:fs';
2
1
  import path from 'node:path';
3
- import { asString } from './util.mjs';
2
+ import { asString, resolveRefPath } from './util.mjs';
4
3
  import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
5
4
  import { toRepoPath } from './util.mjs';
6
5
 
@@ -101,8 +100,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
101
100
  const allRefFields = [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])];
102
101
  for (const field of allRefFields) {
103
102
  for (const relPath of (doc.refFields[field] || [])) {
104
- const resolved = path.resolve(docDir, relPath);
105
- if (!existsSync(resolved)) {
103
+ if (!resolveRefPath(relPath, docDir, config.repoRoot)) {
106
104
  doc.errors.push({ path: doc.path, level: 'error', message: `${field} entry \`${relPath}\` does not resolve to an existing file.` });
107
105
  }
108
106
  }
@@ -110,8 +108,7 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
110
108
 
111
109
  // Validate body links resolve to existing files
112
110
  for (const link of (doc.bodyLinks || [])) {
113
- const resolved = path.resolve(docDir, link.href);
114
- if (!existsSync(resolved)) {
111
+ if (!resolveRefPath(link.href, docDir, config.repoRoot)) {
115
112
  doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
116
113
  }
117
114
  }
@@ -128,7 +125,12 @@ export function checkBidirectionalReferences(docs, config) {
128
125
  const refs = new Set();
129
126
  for (const field of biFields) {
130
127
  for (const relPath of (doc.refFields[field] || [])) {
131
- const resolved = path.resolve(docDir, relPath);
128
+ // Use the same doc-relative-then-repo-root fallback as validateDoc so
129
+ // both styles produce identical refMap keys; otherwise an entry like
130
+ // `docs/foo.md` (repo-root style) gets keyed as
131
+ // `<doc-parent>/docs/foo.md` and never matches the target's repo path.
132
+ const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
133
+ ?? path.resolve(docDir, relPath);
132
134
  refs.add(toRepoPath(resolved, config.repoRoot));
133
135
  }
134
136
  }