dotmd-cli 0.14.10 → 0.14.12

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 CHANGED
@@ -103,6 +103,7 @@ Filters:
103
103
  --has-blockers Only docs with blockers
104
104
  --checklist-open Only docs with open checklist items
105
105
  --sort <field> Sort by: updated (default), title, status
106
+ --group <field> Group by: module, surface, owner (plans view)
106
107
  --limit <n> Max results (default: 20)
107
108
  --all Show all results (no limit)
108
109
  --git Use git dates instead of frontmatter
@@ -365,11 +366,13 @@ modules, and reference fields to pre-populate the config.`,
365
366
  plans: `dotmd plans — list all plans
366
367
 
367
368
  Shows all documents with type: plan, sorted by status.
368
- Supports all query flags (--status, --json, --sort, etc.)
369
+ Supports all query flags (--status, --module, --json, --sort, --group, etc.)
369
370
 
370
371
  Examples:
371
372
  dotmd plans # all plans
372
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
373
376
  dotmd plans --json # JSON output`,
374
377
 
375
378
  stale: `dotmd stale — list stale documents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.14.10",
3
+ "version": "0.14.12",
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",
@@ -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/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;
@@ -146,7 +147,7 @@ export function parseDocFile(filePath, config) {
146
147
 
147
148
  // Tag doc with its root
148
149
  const roots = config.docsRoots || [config.docsRoot];
149
- const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
150
+ const docRoot = roots.find(r => filePath.startsWith(r + '/')) ?? config.docsRoot;
150
151
  const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
151
152
 
152
153
  const docType = asString(parsedFrontmatter.type) ?? 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/query.mjs CHANGED
@@ -77,6 +77,7 @@ export function parseQueryArgs(argv) {
77
77
  types: null, statuses: null, keyword: null, owner: null, surface: null,
78
78
  module: null, domain: null, audience: null, executionMode: null,
79
79
  updatedSince: null, limit: 20, all: false, sort: 'updated',
80
+ group: null,
80
81
  stale: false, hasNextStep: false, hasBlockers: false,
81
82
  checklistOpen: false, json: false, git: false,
82
83
  summarize: false, summarizeLimit: 5, model: undefined,
@@ -98,6 +99,7 @@ export function parseQueryArgs(argv) {
98
99
  if (arg === '--updated-since' && next) { filters.updatedSince = next; i += 1; continue; }
99
100
  if (arg === '--limit' && next) { filters.limit = Number.parseInt(next, 10) || 20; i += 1; continue; }
100
101
  if (arg === '--sort' && next) { filters.sort = next; i += 1; continue; }
102
+ if (arg === '--group' && next) { filters.group = next; i += 1; continue; }
101
103
  if (arg === '--all') { filters.all = true; continue; }
102
104
  if (arg === '--stale') { filters.stale = true; continue; }
103
105
  if (arg === '--has-next-step') { filters.hasNextStep = true; continue; }
@@ -248,50 +250,85 @@ function renderPlansOutput(docs, filters, config) {
248
250
  // Active filter note (only if user applied extra filters beyond the preset defaults)
249
251
  const activeFilters = [];
250
252
  if (filters.statuses?.length) activeFilters.push(`status: ${filters.statuses.join(', ')}`);
253
+ if (filters.module) activeFilters.push(`module: ${filters.module}`);
254
+ if (filters.surface) activeFilters.push(`surface: ${filters.surface}`);
255
+ if (filters.owner) activeFilters.push(`owner: ${filters.owner}`);
251
256
  if (filters.keyword) activeFilters.push(`keyword: ${filters.keyword}`);
252
257
  if (filters.stale) activeFilters.push('stale only');
253
258
  if (filters.hasNextStep) activeFilters.push('has next step');
254
259
  if (filters.hasBlockers) activeFilters.push('has blockers');
255
260
  if (activeFilters.length) process.stdout.write(dim(` filtered: ${activeFilters.join(' | ')}`) + '\n');
256
261
 
257
- // Group by status, ordered by config.statusOrder
258
- const statusGroups = new Map();
259
- for (const d of docs) {
260
- const s = d.status ?? 'unknown';
261
- if (!statusGroups.has(s)) statusGroups.set(s, []);
262
- statusGroups.get(s).push(d);
263
- }
264
-
265
- const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
266
262
  const maxWidth = process.stdout.columns || 100;
267
263
 
268
- for (const status of orderedStatuses) {
269
- const group = statusGroups.get(status);
270
- process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
271
-
272
- const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
264
+ // Group by module or status
265
+ if (filters.group === 'module') {
266
+ renderPlansByGroup(docs, d => d.modules?.length ? d.modules : ['(none)'], filters, maxWidth);
267
+ } else if (filters.group === 'surface') {
268
+ renderPlansByGroup(docs, d => d.surfaces?.length ? d.surfaces : ['(none)'], filters, maxWidth);
269
+ } else if (filters.group === 'owner') {
270
+ renderPlansByGroup(docs, d => [d.owner ?? '(none)'], filters, maxWidth);
271
+ } else {
272
+ // Default: group by status, ordered by config.statusOrder
273
+ const statusGroups = new Map();
274
+ for (const d of docs) {
275
+ const s = d.status ?? 'unknown';
276
+ if (!statusGroups.has(s)) statusGroups.set(s, []);
277
+ statusGroups.get(s).push(d);
278
+ }
273
279
 
274
- for (const doc of group) {
275
- const slug = toSlug(doc).padEnd(maxSlug);
276
- const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
277
- const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
278
- const progress = renderProgressBar(doc.checklist);
280
+ const orderedStatuses = [...config.statusOrder.filter(s => statusGroups.has(s)), ...([...statusGroups.keys()].filter(s => !config.statusOrder.includes(s)))];
279
281
 
280
- const parts = [` ${slug} ${ageStr}`];
281
- if (progress) parts.push(progress);
282
+ for (const status of orderedStatuses) {
283
+ const group = statusGroups.get(status);
284
+ process.stdout.write(`\n${bold(`${capitalize(status)} (${group.length})`)}\n`);
285
+ renderPlanRows(group, filters, maxWidth);
286
+ }
287
+ }
282
288
 
283
- if (doc.blockers?.length && (status === 'blocked')) {
284
- parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
285
- } else if (doc.nextStep) {
286
- parts.push(`next: ${doc.nextStep}`);
287
- } else {
288
- parts.push(dim('(no next step)'));
289
- }
289
+ process.stdout.write('\n');
290
+ }
290
291
 
291
- const line = parts.join(' ');
292
- process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
292
+ function renderPlansByGroup(docs, keyFn, filters, maxWidth) {
293
+ const groups = new Map();
294
+ for (const d of docs) {
295
+ for (const key of keyFn(d)) {
296
+ if (!groups.has(key)) groups.set(key, []);
297
+ groups.get(key).push(d);
293
298
  }
294
299
  }
295
300
 
296
- process.stdout.write('\n');
301
+ const ordered = [...groups.keys()].sort((a, b) => a === '(none)' ? 1 : b === '(none)' ? -1 : a.localeCompare(b));
302
+ for (const key of ordered) {
303
+ const group = groups.get(key);
304
+ process.stdout.write(`\n${bold(`${key} (${group.length})`)}\n`);
305
+ renderPlanRows(group, filters, maxWidth);
306
+ }
307
+ }
308
+
309
+ function renderPlanRows(group, filters, maxWidth) {
310
+ const maxSlug = Math.min(30, Math.max(...group.map(d => toSlug(d).length)));
311
+ const showModule = !filters.module && filters.group !== 'module';
312
+
313
+ for (const doc of group) {
314
+ const slug = toSlug(doc).padEnd(maxSlug);
315
+ const age = doc.daysSinceUpdate != null ? `${doc.daysSinceUpdate}d` : ' —';
316
+ const ageStr = doc.daysSinceUpdate != null && doc.isStale ? red(age.padStart(4)) : dim(age.padStart(4));
317
+ const progress = renderProgressBar(doc.checklist);
318
+
319
+ const parts = [` ${slug} ${ageStr}`];
320
+ if (progress) parts.push(progress);
321
+ if (showModule && doc.modules?.length) parts.push(dim(`[${doc.modules.join(',')}]`));
322
+
323
+ if (doc.blockers?.length && (doc.status === 'blocked')) {
324
+ parts.push(yellow(`blockers: ${doc.blockers.join('; ')}`));
325
+ } else if (doc.nextStep) {
326
+ parts.push(`next: ${doc.nextStep}`);
327
+ } else {
328
+ parts.push(dim('(no next step)'));
329
+ }
330
+
331
+ const line = parts.join(' ');
332
+ process.stdout.write((line.length > maxWidth ? line.slice(0, maxWidth - 3) + '...' : line) + '\n');
333
+ }
297
334
  }
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
  }