dotmd-cli 0.8.5 → 0.8.7

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
@@ -431,8 +431,10 @@ async function main() {
431
431
  index.docs = index.docs.filter(d => d.root === rootFilter || d.root.endsWith('/' + rootFilter) || d.root.split('/').pop() === rootFilter);
432
432
  index.errors = index.errors.filter(e => index.docs.some(d => d.path === e.path));
433
433
  index.warnings = index.warnings.filter(w => index.docs.some(d => d.path === w.path));
434
- for (const status of config.statusOrder) {
435
- index.countsByStatus[status] = index.docs.filter(d => d.status === status).length;
434
+ index.countsByStatus = {};
435
+ for (const doc of index.docs) {
436
+ const s = doc.status ?? 'unknown';
437
+ index.countsByStatus[s] = (index.countsByStatus[s] ?? 0) + 1;
436
438
  }
437
439
  }
438
440
 
@@ -551,9 +553,10 @@ async function main() {
551
553
  if (command === 'context') {
552
554
  if (args.includes('--json')) {
553
555
  const byStatus = {};
554
- for (const s of config.statusOrder) {
555
- const docs = index.docs.filter(d => d.status === s);
556
- if (docs.length) byStatus[s] = docs;
556
+ for (const doc of index.docs) {
557
+ const s = doc.status ?? 'unknown';
558
+ if (!byStatus[s]) byStatus[s] = [];
559
+ byStatus[s].push(doc);
557
560
  }
558
561
  const stale = index.docs.filter(d => d.isStale && !config.lifecycle.skipStaleFor.has(d.status));
559
562
  process.stdout.write(JSON.stringify({
@@ -16,6 +16,12 @@ export const excludeDirs = ['evidence'];
16
16
  // Status workflow — order determines display grouping
17
17
  export const statuses = {
18
18
  order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
19
+ // Additional statuses valid only in specific roots (merged with order)
20
+ // Useful when different doc areas track different things (e.g. plans vs module docs)
21
+ // rootStatuses: {
22
+ // 'docs/modules': ['implemented', 'partial', 'draft', 'deprecated'],
23
+ // 'docs/core': ['implemented', 'partial'],
24
+ // },
19
25
  // Days after which a doc is considered stale (null = never stale)
20
26
  staleDays: {
21
27
  active: 14,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
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
@@ -216,6 +216,16 @@ export async function resolveConfig(cwd, explicitConfigPath) {
216
216
  staleDaysByStatus[status] = config.statuses.staleDays?.[status] ?? null;
217
217
  }
218
218
 
219
+ // Per-root additional statuses (merged with global validStatuses)
220
+ const rootStatusesRaw = config.statuses.rootStatuses ?? {};
221
+ const rootLabels = new Set(rootPaths.map(r => path.relative(configDir, path.resolve(configDir, r)).split(path.sep).join('/')));
222
+ const rootValidStatuses = new Map();
223
+ for (const [rootKey, extraStatuses] of Object.entries(rootStatusesRaw)) {
224
+ const merged = new Set(validStatuses);
225
+ for (const s of extraStatuses) merged.add(s);
226
+ rootValidStatuses.set(rootKey, merged);
227
+ }
228
+
219
229
  const validSurfaces = config.taxonomy.surfaces
220
230
  ? new Set(config.taxonomy.surfaces)
221
231
  : null;
@@ -235,6 +245,13 @@ export async function resolveConfig(cwd, explicitConfigPath) {
235
245
  const skipStaleFor = new Set(lifecycle.skipStaleFor);
236
246
  const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
237
247
 
248
+ // Warn if rootStatuses keys don't match any configured root
249
+ for (const rootKey of Object.keys(rootStatusesRaw)) {
250
+ if (!rootLabels.has(rootKey)) {
251
+ earlyWarnings.push(`Config: statuses.rootStatuses key '${rootKey}' does not match any configured root.`);
252
+ }
253
+ }
254
+
238
255
  const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
239
256
 
240
257
  return {
@@ -252,6 +269,7 @@ export async function resolveConfig(cwd, explicitConfigPath) {
252
269
 
253
270
  statusOrder,
254
271
  validStatuses,
272
+ rootValidStatuses,
255
273
  staleDaysByStatus,
256
274
 
257
275
  lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor },
package/src/index.mjs CHANGED
@@ -51,6 +51,12 @@ export function buildIndex(config) {
51
51
  status,
52
52
  transformedDocs.filter(doc => doc.status === status).length,
53
53
  ]));
54
+ const knownStatuses = new Set(config.statusOrder);
55
+ for (const doc of transformedDocs) {
56
+ if (doc.status && !knownStatuses.has(doc.status)) {
57
+ countsByStatus[doc.status] = (countsByStatus[doc.status] ?? 0) + 1;
58
+ }
59
+ }
54
60
 
55
61
  if (config.indexPath) {
56
62
  const indexCheck = checkIndex(transformedDocs, config);
package/src/lifecycle.mjs CHANGED
@@ -27,11 +27,16 @@ export async function runStatus(argv, config, opts = {}) {
27
27
  die('Usage: dotmd status <file> <new-status>');
28
28
  }
29
29
  }
30
- if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); }
31
-
32
30
  const filePath = resolveDocPath(input, config);
33
31
  if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
34
32
 
33
+ // Validate status against root-specific vocabulary
34
+ const fileRoot = findFileRoot(filePath, config);
35
+ const rootLabel = path.relative(config.repoRoot, fileRoot).split(path.sep).join('/');
36
+ const rootSet = config.rootValidStatuses?.get(rootLabel);
37
+ const effectiveValid = rootSet ?? config.validStatuses;
38
+ if (!effectiveValid.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...effectiveValid].join(', ')}`); }
39
+
35
40
  const raw = readFileSync(filePath, 'utf8');
36
41
  const { frontmatter } = extractFrontmatter(raw);
37
42
  const parsed = parseSimpleFrontmatter(frontmatter);
@@ -43,7 +48,6 @@ export async function runStatus(argv, config, opts = {}) {
43
48
  }
44
49
 
45
50
  const today = new Date().toISOString().slice(0, 10);
46
- const fileRoot = findFileRoot(filePath, config);
47
51
  const archiveDir = path.join(fileRoot, config.archiveDir);
48
52
  const isArchiving = config.lifecycle.archiveStatuses.has(newStatus) && !filePath.includes(`/${config.archiveDir}/`);
49
53
  const isUnarchiving = !config.lifecycle.archiveStatuses.has(newStatus) && filePath.includes(`/${config.archiveDir}/`);
package/src/render.mjs CHANGED
@@ -124,8 +124,10 @@ function _renderContext(index, config, opts = {}) {
124
124
  const ctx = config.context;
125
125
 
126
126
  const byStatus = {};
127
- for (const status of config.statusOrder) {
128
- byStatus[status] = index.docs.filter(d => d.status === status);
127
+ for (const doc of index.docs) {
128
+ const s = doc.status ?? 'unknown';
129
+ if (!byStatus[s]) byStatus[s] = [];
130
+ byStatus[s].push(doc);
129
131
  }
130
132
 
131
133
  for (const status of (ctx.expanded || [])) {
package/src/stats.mjs CHANGED
@@ -94,7 +94,11 @@ function _renderStats(stats, config) {
94
94
 
95
95
  // Status
96
96
  lines.push(bold('Status'));
97
- const statusParts = config.statusOrder
97
+ const allStatuses = [
98
+ ...config.statusOrder.filter(s => stats.countsByStatus[s]),
99
+ ...Object.keys(stats.countsByStatus).filter(s => !config.statusOrder.includes(s)).sort(),
100
+ ];
101
+ const statusParts = allStatuses
98
102
  .filter(s => stats.countsByStatus[s])
99
103
  .map(s => `${s}: ${stats.countsByStatus[s]}`);
100
104
  lines.push(' ' + statusParts.join(' '));
package/src/validate.mjs CHANGED
@@ -6,15 +6,20 @@ import { toRepoPath } from './util.mjs';
6
6
 
7
7
  const NOW = new Date();
8
8
 
9
+ function isValidStatus(status, root, config) {
10
+ const rootSet = config.rootValidStatuses?.get(root);
11
+ if (rootSet) return rootSet.has(status);
12
+ return config.validStatuses.has(status);
13
+ }
14
+
9
15
  export function validateDoc(doc, frontmatter, headingTitle, config) {
10
16
  if (!doc.status) {
11
17
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `status`.' });
12
- } else if (!config.validStatuses.has(doc.status)) {
18
+ } else if (!isValidStatus(doc.status, doc.root, config)) {
13
19
  doc.warnings.push({ path: doc.path, level: 'warning', message: `Unknown status \`${doc.status}\`; not in statuses.order.` });
14
20
  }
15
21
 
16
- // Only enforce lifecycle fields for known statuses (skip for unknown like implemented, partial, etc.)
17
- const knownStatus = config.validStatuses.has(doc.status);
22
+ const knownStatus = isValidStatus(doc.status, doc.root, config);
18
23
 
19
24
  if (knownStatus && !config.lifecycle.skipWarningsFor.has(doc.status) && !doc.updated) {
20
25
  doc.errors.push({ path: doc.path, level: 'error', message: 'Missing frontmatter `updated` for non-archived doc.' });