dotmd-cli 0.34.0 → 0.36.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 +6 -0
- package/bin/dotmd.mjs +59 -1
- package/package.json +1 -1
- package/src/commands.mjs +1 -1
- package/src/index.mjs +23 -2
- package/src/modules.mjs +355 -0
- package/src/validate.mjs +18 -2
package/README.md
CHANGED
|
@@ -757,6 +757,12 @@ export const referenceFields = {
|
|
|
757
757
|
bidirectional: ['related_plans'], // warn if A→B but B↛A
|
|
758
758
|
unidirectional: ['supports_plans'], // one-way, no symmetry check
|
|
759
759
|
};
|
|
760
|
+
// Per-ref opt-out: prefix any value with `>` to mark that specific ref one-way
|
|
761
|
+
// without changing the field's default. Useful for leaf→upstream-parent refs
|
|
762
|
+
// (audits, hub docs) where a back-ref would force editing a stable parent.
|
|
763
|
+
// related_docs:
|
|
764
|
+
// - docs/sibling-design.md # bidirectional (default for the field)
|
|
765
|
+
// - "> docs/audit-beyond-platform.md" # one-way upstream — no back-ref expected
|
|
760
766
|
|
|
761
767
|
export const index = {
|
|
762
768
|
path: 'docs/docs.md',
|
package/bin/dotmd.mjs
CHANGED
|
@@ -32,6 +32,8 @@ Analyze:
|
|
|
32
32
|
coverage [--json] Metadata coverage report
|
|
33
33
|
graph [--dot] [--json] Visualize document relationships
|
|
34
34
|
deps [file] [--json] Dependency tree or overview
|
|
35
|
+
modules [--sort cleanup] [--json] Module dashboard (plans grouped by module)
|
|
36
|
+
module <name> [--json] Plans for one module, grouped by status
|
|
35
37
|
unblocks <file> [--json] Show what completes when this doc ships
|
|
36
38
|
diff [file] [--summarize] Show changes since last updated date
|
|
37
39
|
summary <file> [--json] AI summary of a document
|
|
@@ -297,6 +299,43 @@ Options:
|
|
|
297
299
|
--depth <n> Max tree depth (default: 5)
|
|
298
300
|
--json Machine-readable JSON output`,
|
|
299
301
|
|
|
302
|
+
modules: `dotmd modules — module dashboard (plans grouped by module)
|
|
303
|
+
|
|
304
|
+
One row per module discovered in plan frontmatter. Dynamic status columns
|
|
305
|
+
(only statuses with ≥1 plan render). Defaults to --type plan; pass --type
|
|
306
|
+
to scope to docs/prompts.
|
|
307
|
+
|
|
308
|
+
Sort modes:
|
|
309
|
+
--sort total Plan count, desc (default)
|
|
310
|
+
--sort stale Stale-plan count, desc
|
|
311
|
+
--sort age Average age in days, desc
|
|
312
|
+
--sort nextstep % of plans with a next_step set, desc
|
|
313
|
+
--sort cleanup Triage score: (stale × avgAge) / max(total, 1)
|
|
314
|
+
|
|
315
|
+
Options:
|
|
316
|
+
--limit <n> Cap rows (default: 20)
|
|
317
|
+
--all Show every module
|
|
318
|
+
--json Machine-readable shape (includes _totalUnique to
|
|
319
|
+
detect modules: [a, b] double-counting)
|
|
320
|
+
|
|
321
|
+
A plan with \`modules: [a, b]\` counts in both rows — intentional, so
|
|
322
|
+
multi-module plans surface in every relevant triage view. \`(none)\` is a
|
|
323
|
+
literal row for plans with no module tag.`,
|
|
324
|
+
|
|
325
|
+
module: `dotmd module <name> — plans for one module, grouped by status
|
|
326
|
+
|
|
327
|
+
Status groups follow config.statusOrder. Stale plans are flagged inline.
|
|
328
|
+
|
|
329
|
+
Sort modes (within each status group):
|
|
330
|
+
--sort status By config.statusOrder, then age (default)
|
|
331
|
+
--sort updated Most-recently-updated first
|
|
332
|
+
--sort age Oldest first
|
|
333
|
+
|
|
334
|
+
Options:
|
|
335
|
+
--json Machine-readable shape
|
|
336
|
+
|
|
337
|
+
Unknown module name suggests close matches (or lists what's available).`,
|
|
338
|
+
|
|
300
339
|
doctor: `dotmd doctor — auto-fix everything in one pass
|
|
301
340
|
|
|
302
341
|
Runs in sequence: fix broken references, lint --fix, sync dates from
|
|
@@ -563,7 +602,10 @@ Examples:
|
|
|
563
602
|
stale: `dotmd stale — list stale documents
|
|
564
603
|
|
|
565
604
|
Shows docs that haven't been updated within their staleness threshold.
|
|
566
|
-
Supports all query flags (--status, --json, --sort, etc.)
|
|
605
|
+
Supports all query flags (--status, --json, --sort, etc.)
|
|
606
|
+
|
|
607
|
+
Examples:
|
|
608
|
+
dotmd stale --group module Stale plans grouped by module (triage view)`,
|
|
567
609
|
|
|
568
610
|
actionable: `dotmd actionable — list docs with next steps
|
|
569
611
|
|
|
@@ -970,6 +1012,22 @@ async function main() {
|
|
|
970
1012
|
|
|
971
1013
|
if (command === 'focus') { runFocus(index, restArgs, config); return; }
|
|
972
1014
|
if (command === 'query') { runQuery(index, restArgs, config); return; }
|
|
1015
|
+
if (command === 'modules' || command === 'module') {
|
|
1016
|
+
// D3: default `--type plan` when the user didn't pass --type explicitly.
|
|
1017
|
+
// applyIndexFilters already narrowed by typeArg if it was set; if not, the
|
|
1018
|
+
// index still spans all types, and the dashboard would mix plans/docs/prompts
|
|
1019
|
+
// into the same module rows. Narrow here so the docs/prompts case stays a
|
|
1020
|
+
// deliberate `--type doc` opt-in (deferred per plan).
|
|
1021
|
+
const scoped = typeArg ? index : { ...index, docs: index.docs.filter(d => d.type === 'plan') };
|
|
1022
|
+
if (command === 'modules') {
|
|
1023
|
+
const { runModulesDashboard } = await import('../src/modules.mjs');
|
|
1024
|
+
runModulesDashboard(scoped, restArgs, config);
|
|
1025
|
+
} else {
|
|
1026
|
+
const { runModuleDetail } = await import('../src/modules.mjs');
|
|
1027
|
+
runModuleDetail(scoped, restArgs, config);
|
|
1028
|
+
}
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
973
1031
|
if (command === 'briefing') {
|
|
974
1032
|
if (args.includes('--json')) {
|
|
975
1033
|
const plans = index.docs.filter(d => d.type === 'plan');
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
export const KNOWN_COMMANDS = [
|
|
6
6
|
'list', 'json', 'check', 'coverage', 'stats', 'graph', 'deps', 'briefing', 'context', 'hud',
|
|
7
7
|
'focus', 'query', 'plans', 'prompts', 'stale', 'actionable', 'index', 'pickup', 'release', 'finish', 'status', 'archive', 'bulk', 'bulk-tag', 'touch', 'doctor',
|
|
8
|
-
'unblocks', 'health', 'glossary',
|
|
8
|
+
'unblocks', 'health', 'glossary', 'modules', 'module',
|
|
9
9
|
'fix-refs', 'lint', 'rename', 'migrate', 'notion', 'export', 'summary',
|
|
10
10
|
'watch', 'diff', 'new', 'init', 'completions', 'statuses',
|
|
11
11
|
];
|
package/src/index.mjs
CHANGED
|
@@ -175,10 +175,30 @@ export function parseDocFile(filePath, config) {
|
|
|
175
175
|
const bodyLinks = extractBodyLinks(body);
|
|
176
176
|
const hasCloseout = /^##\s+Closeout/m.test(body);
|
|
177
177
|
|
|
178
|
-
// Dynamic reference field extraction
|
|
178
|
+
// Dynamic reference field extraction. A leading `>` on a value (e.g.
|
|
179
|
+
// `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
|
|
180
|
+
// the prefix is stripped so path resolution still works, and the direction
|
|
181
|
+
// is recorded on a parallel `refFieldDirections[field]` array indexed the
|
|
182
|
+
// same as `refFields[field]`. Bidirectional reciprocity checks consume the
|
|
183
|
+
// directions to skip outbound entries that opted out of expecting a back-ref.
|
|
179
184
|
const refFields = {};
|
|
185
|
+
const refFieldDirections = {};
|
|
180
186
|
for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
|
|
181
|
-
|
|
187
|
+
const raw = normalizeStringList(parsedFrontmatter[field]);
|
|
188
|
+
const paths = [];
|
|
189
|
+
const directions = [];
|
|
190
|
+
for (const entry of raw) {
|
|
191
|
+
const oneWay = entry.match(/^>\s*(.+)$/);
|
|
192
|
+
if (oneWay) {
|
|
193
|
+
paths.push(oneWay[1].trim());
|
|
194
|
+
directions.push('one-way');
|
|
195
|
+
} else {
|
|
196
|
+
paths.push(entry);
|
|
197
|
+
directions.push('two-way');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
refFields[field] = paths;
|
|
201
|
+
refFieldDirections[field] = directions;
|
|
182
202
|
}
|
|
183
203
|
|
|
184
204
|
// Tag doc with its root
|
|
@@ -215,6 +235,7 @@ export function parseDocFile(filePath, config) {
|
|
|
215
235
|
checklist,
|
|
216
236
|
bodyLinks,
|
|
217
237
|
refFields,
|
|
238
|
+
refFieldDirections,
|
|
218
239
|
checklistCompletionRate: computeChecklistCompletionRate(checklist),
|
|
219
240
|
hasCloseout,
|
|
220
241
|
hasNextStep: Boolean(nextStep),
|
package/src/modules.mjs
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Modules dashboard (F16). Two view-only commands:
|
|
2
|
+
// `dotmd modules` — one row per discovered module, dynamic status columns
|
|
3
|
+
// `dotmd module <name>` — plans for one module, grouped by status, stale flagged inline
|
|
4
|
+
//
|
|
5
|
+
// Aggregation lives in `aggregateModules` (pure data). Both renderers consume
|
|
6
|
+
// the same shape, so JSON output and the table share one source of truth.
|
|
7
|
+
//
|
|
8
|
+
// Constraint cheatsheet (see docs/archived/modules-dashboard.md):
|
|
9
|
+
// M1 dynamic columns — discover statuses from rendered rows, drop empty cols
|
|
10
|
+
// M2 overflow fallback — drop-empty first, then stacked render if still too wide
|
|
11
|
+
// M3 double-counting — `modules: [a, b]` increments both rows (intended)
|
|
12
|
+
// M4 (none) bucket — surface when ≥1 plan has empty modules
|
|
13
|
+
// M6 archived/skipStale — terminal statuses excluded; skipStale don't contribute to `stale`
|
|
14
|
+
import { toSlug, truncate, die, suggestCandidates } from './util.mjs';
|
|
15
|
+
import { bold, dim, yellow, red, green, blue, magenta, cyan, brightYellow } from './color.mjs';
|
|
16
|
+
|
|
17
|
+
const STATUS_COLORS = {
|
|
18
|
+
'in-session': (s) => bold(red(s)),
|
|
19
|
+
'active': green,
|
|
20
|
+
'planned': blue,
|
|
21
|
+
'blocked': yellow,
|
|
22
|
+
'partial': (s) => dim(green(s)),
|
|
23
|
+
'paused': magenta,
|
|
24
|
+
'awaiting': brightYellow,
|
|
25
|
+
'queued-after': (s) => dim(cyan(s)),
|
|
26
|
+
'archived': dim,
|
|
27
|
+
'pending': bold,
|
|
28
|
+
'claimed': dim,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function colorStatus(status, text) {
|
|
32
|
+
const fn = STATUS_COLORS[status] ?? ((s) => s);
|
|
33
|
+
return fn(text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Single pass over docs → Map<moduleName, aggregate>. Terminal-status docs are
|
|
37
|
+
// excluded entirely (M6) — they pollute the active-work picture. skipStale
|
|
38
|
+
// statuses are counted toward totals but never contribute to `stale` so they
|
|
39
|
+
// don't lie about staleness when a custom config (e.g. `backlog`) opts out.
|
|
40
|
+
export function aggregateModules(docs, config) {
|
|
41
|
+
const terminal = config.lifecycle.terminalStatuses;
|
|
42
|
+
const skipStale = config.lifecycle.skipStaleFor;
|
|
43
|
+
const liveDocs = docs.filter(d => !terminal.has(d.status));
|
|
44
|
+
const uniqueTotal = liveDocs.length;
|
|
45
|
+
|
|
46
|
+
const modules = new Map();
|
|
47
|
+
function bucket(name) {
|
|
48
|
+
if (!modules.has(name)) {
|
|
49
|
+
modules.set(name, {
|
|
50
|
+
name,
|
|
51
|
+
plans: [],
|
|
52
|
+
byStatus: {},
|
|
53
|
+
stale: 0,
|
|
54
|
+
ageSum: 0,
|
|
55
|
+
ageCount: 0,
|
|
56
|
+
oldest: null,
|
|
57
|
+
nextStepCount: 0,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return modules.get(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const doc of liveDocs) {
|
|
64
|
+
const names = doc.modules?.length ? doc.modules : ['(none)'];
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
const m = bucket(name);
|
|
67
|
+
m.plans.push(doc);
|
|
68
|
+
m.byStatus[doc.status] = (m.byStatus[doc.status] ?? 0) + 1;
|
|
69
|
+
if (doc.isStale && !skipStale.has(doc.status)) m.stale += 1;
|
|
70
|
+
if (typeof doc.daysSinceUpdate === 'number') {
|
|
71
|
+
m.ageSum += doc.daysSinceUpdate;
|
|
72
|
+
m.ageCount += 1;
|
|
73
|
+
if (!m.oldest || doc.daysSinceUpdate > m.oldest.ageDays) {
|
|
74
|
+
m.oldest = { slug: toSlug(doc), ageDays: doc.daysSinceUpdate };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (doc.hasNextStep) m.nextStepCount += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const rows = [...modules.values()].map(m => ({
|
|
82
|
+
name: m.name,
|
|
83
|
+
total: m.plans.length,
|
|
84
|
+
byStatus: m.byStatus,
|
|
85
|
+
stale: m.stale,
|
|
86
|
+
avgAgeDays: m.ageCount > 0 ? m.ageSum / m.ageCount : 0,
|
|
87
|
+
oldest: m.oldest,
|
|
88
|
+
nextStepPct: m.plans.length > 0 ? m.nextStepCount / m.plans.length : 0,
|
|
89
|
+
_plans: m.plans,
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
return { rows, totalUnique: uniqueTotal };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Cleanup-rank formula (R3): high stale share + high average age + lower total
|
|
96
|
+
// floats modules that have rotted; low total in the denominator keeps a single
|
|
97
|
+
// ancient plan from outweighing a 30-plan module with two stale ones. Document
|
|
98
|
+
// in `--help`; iterate after running on real corpora.
|
|
99
|
+
function cleanupScore(row) {
|
|
100
|
+
return (row.stale * row.avgAgeDays) / Math.max(row.total, 1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildSorter(sort) {
|
|
104
|
+
if (sort === 'stale') return (a, b) => b.stale - a.stale || b.total - a.total;
|
|
105
|
+
if (sort === 'age') return (a, b) => b.avgAgeDays - a.avgAgeDays || b.total - a.total;
|
|
106
|
+
if (sort === 'nextstep') return (a, b) => b.nextStepPct - a.nextStepPct || b.total - a.total;
|
|
107
|
+
if (sort === 'cleanup') return (a, b) => cleanupScore(b) - cleanupScore(a) || b.stale - a.stale;
|
|
108
|
+
return (a, b) => b.total - a.total || a.name.localeCompare(b.name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseFlags(argv) {
|
|
112
|
+
const flags = { sort: 'total', json: false, limit: 20, all: false };
|
|
113
|
+
for (let i = 0; i < argv.length; i++) {
|
|
114
|
+
const arg = argv[i];
|
|
115
|
+
if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
|
|
116
|
+
if (arg === '--json') { flags.json = true; continue; }
|
|
117
|
+
if (arg === '--limit' && argv[i + 1]) { flags.limit = parseInt(argv[++i], 10); continue; }
|
|
118
|
+
if (arg === '--all') { flags.all = true; continue; }
|
|
119
|
+
}
|
|
120
|
+
const validSorts = new Set(['total', 'stale', 'age', 'nextstep', 'cleanup']);
|
|
121
|
+
if (!validSorts.has(flags.sort)) {
|
|
122
|
+
die(`Invalid --sort value: ${flags.sort}. Valid: ${[...validSorts].join(', ')}.`);
|
|
123
|
+
}
|
|
124
|
+
return flags;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Discover which status columns to render. Only statuses with ≥1 non-zero cell
|
|
128
|
+
// across the rendered rows make the cut (M1). Ordered by config.statusOrder
|
|
129
|
+
// first, then alphabetically for any custom statuses the index encountered
|
|
130
|
+
// that aren't in statusOrder (so a Beyond-style `research` column appears).
|
|
131
|
+
function discoverStatusColumns(rows, config) {
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
for (const [status, count] of Object.entries(row.byStatus)) {
|
|
135
|
+
if (count > 0) seen.add(status);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const ordered = config.statusOrder.filter(s => seen.has(s));
|
|
139
|
+
const extras = [...seen].filter(s => !config.statusOrder.includes(s)).sort();
|
|
140
|
+
return [...ordered, ...extras];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function fmtAge(days) {
|
|
144
|
+
if (!Number.isFinite(days)) return '-';
|
|
145
|
+
if (days < 1) return '<1d';
|
|
146
|
+
if (days < 10) return `${days.toFixed(1)}d`;
|
|
147
|
+
return `${Math.round(days)}d`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function fmtPct(frac) {
|
|
151
|
+
if (!Number.isFinite(frac)) return '-';
|
|
152
|
+
return `${Math.round(frac * 100)}%`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function fmtOldest(oldest) {
|
|
156
|
+
if (!oldest) return '-';
|
|
157
|
+
const slug = truncate(oldest.slug, 14);
|
|
158
|
+
return `${slug} (${fmtAge(oldest.ageDays)})`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Heuristic column-width budget. Returns true if the dynamic-columns table
|
|
162
|
+
// will fit within the terminal; caller switches to the stacked render if not.
|
|
163
|
+
function tableFits(rows, statuses, termWidth) {
|
|
164
|
+
const nameCol = Math.max(8, ...rows.map(r => r.name.length));
|
|
165
|
+
const statusCols = statuses.length * 7; // each status header padded
|
|
166
|
+
const tailCols = 6 /*stale*/ + 8 /*age*/ + 22 /*oldest*/ + 6 /*next%*/ + 8 /*gutters*/;
|
|
167
|
+
return nameCol + statusCols + tailCols <= termWidth;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function pad(s, width) {
|
|
171
|
+
if (s.length >= width) return s;
|
|
172
|
+
return s + ' '.repeat(width - s.length);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function padLeft(s, width) {
|
|
176
|
+
if (s.length >= width) return s;
|
|
177
|
+
return ' '.repeat(width - s.length) + s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderTable(rows, statuses, config) {
|
|
181
|
+
const nameCol = Math.max(8, ...rows.map(r => r.name.length));
|
|
182
|
+
const lines = [];
|
|
183
|
+
const headerCells = [
|
|
184
|
+
pad('module', nameCol),
|
|
185
|
+
...statuses.map(s => padLeft(s.slice(0, 5), 5)),
|
|
186
|
+
padLeft('stale', 5),
|
|
187
|
+
padLeft('avgAge', 7),
|
|
188
|
+
pad('oldest', 22),
|
|
189
|
+
padLeft('next%', 5),
|
|
190
|
+
];
|
|
191
|
+
lines.push(dim(headerCells.join(' ')));
|
|
192
|
+
for (const row of rows) {
|
|
193
|
+
const cells = [
|
|
194
|
+
pad(row.name, nameCol),
|
|
195
|
+
...statuses.map(s => {
|
|
196
|
+
const n = row.byStatus[s] ?? 0;
|
|
197
|
+
const text = padLeft(String(n), 5);
|
|
198
|
+
return n > 0 ? colorStatus(s, text) : dim(text);
|
|
199
|
+
}),
|
|
200
|
+
padLeft(String(row.stale), 5),
|
|
201
|
+
padLeft(fmtAge(row.avgAgeDays), 7),
|
|
202
|
+
pad(fmtOldest(row.oldest), 22),
|
|
203
|
+
padLeft(fmtPct(row.nextStepPct), 5),
|
|
204
|
+
];
|
|
205
|
+
lines.push(cells.join(' '));
|
|
206
|
+
}
|
|
207
|
+
return lines.join('\n') + '\n';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Stacked fallback (M2 step 2): one block per module, status counts indented.
|
|
211
|
+
// Preserves every value — never collapses to "Other".
|
|
212
|
+
function renderStacked(rows, statuses) {
|
|
213
|
+
const lines = [];
|
|
214
|
+
for (const row of rows) {
|
|
215
|
+
lines.push(bold(row.name) + dim(` · ${row.total} total · ${row.stale} stale · avg ${fmtAge(row.avgAgeDays)} · next-step ${fmtPct(row.nextStepPct)}`));
|
|
216
|
+
const cells = statuses
|
|
217
|
+
.filter(s => (row.byStatus[s] ?? 0) > 0)
|
|
218
|
+
.map(s => `${colorStatus(s, s)}: ${row.byStatus[s]}`);
|
|
219
|
+
if (cells.length) lines.push(' ' + cells.join(' '));
|
|
220
|
+
if (row.oldest) lines.push(dim(` oldest: ${row.oldest.slug} (${fmtAge(row.oldest.ageDays)})`));
|
|
221
|
+
lines.push('');
|
|
222
|
+
}
|
|
223
|
+
return lines.join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function runModulesDashboard(index, argv, config) {
|
|
227
|
+
const flags = parseFlags(argv);
|
|
228
|
+
const { rows, totalUnique } = aggregateModules(index.docs, config);
|
|
229
|
+
rows.sort(buildSorter(flags.sort));
|
|
230
|
+
const totalModules = rows.length;
|
|
231
|
+
const displayRows = flags.all ? rows : rows.slice(0, flags.limit);
|
|
232
|
+
|
|
233
|
+
if (flags.json) {
|
|
234
|
+
const out = {
|
|
235
|
+
type: 'plan',
|
|
236
|
+
sort: flags.sort,
|
|
237
|
+
_totalUnique: totalUnique,
|
|
238
|
+
modules: displayRows.map(r => ({
|
|
239
|
+
name: r.name,
|
|
240
|
+
total: r.total,
|
|
241
|
+
byStatus: r.byStatus,
|
|
242
|
+
stale: r.stale,
|
|
243
|
+
avgAgeDays: Math.round(r.avgAgeDays * 10) / 10,
|
|
244
|
+
oldest: r.oldest,
|
|
245
|
+
nextStepPct: Math.round(r.nextStepPct * 100) / 100,
|
|
246
|
+
})),
|
|
247
|
+
};
|
|
248
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (rows.length === 0) {
|
|
253
|
+
process.stdout.write('No modules found.\n');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const statuses = discoverStatusColumns(displayRows, config);
|
|
258
|
+
const termWidth = process.stdout.columns || 120;
|
|
259
|
+
|
|
260
|
+
const header = `${totalModules} modules · ${totalUnique} plans · sort: ${flags.sort}`;
|
|
261
|
+
process.stdout.write(dim(header) + '\n\n');
|
|
262
|
+
|
|
263
|
+
if (tableFits(displayRows, statuses, termWidth)) {
|
|
264
|
+
process.stdout.write(renderTable(displayRows, statuses, config));
|
|
265
|
+
} else {
|
|
266
|
+
process.stdout.write(renderStacked(displayRows, statuses));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!flags.all && rows.length > flags.limit) {
|
|
270
|
+
const hidden = rows.length - flags.limit;
|
|
271
|
+
process.stdout.write('\n' + dim(` ${hidden} more module${hidden === 1 ? '' : 's'} · dotmd modules --all\n`));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function runModuleDetail(index, argv, config) {
|
|
276
|
+
const flags = { sort: 'status', json: false };
|
|
277
|
+
const positional = [];
|
|
278
|
+
for (let i = 0; i < argv.length; i++) {
|
|
279
|
+
const arg = argv[i];
|
|
280
|
+
if (arg === '--sort' && argv[i + 1]) { flags.sort = argv[++i]; continue; }
|
|
281
|
+
if (arg === '--json') { flags.json = true; continue; }
|
|
282
|
+
if (arg.startsWith('-')) continue;
|
|
283
|
+
positional.push(arg);
|
|
284
|
+
}
|
|
285
|
+
const name = positional[0];
|
|
286
|
+
if (!name) die('Usage: dotmd module <name>');
|
|
287
|
+
|
|
288
|
+
const { rows } = aggregateModules(index.docs, config);
|
|
289
|
+
const row = rows.find(r => r.name === name);
|
|
290
|
+
if (!row) {
|
|
291
|
+
const candidates = rows.map(r => r.name);
|
|
292
|
+
const suggestions = suggestCandidates(name, candidates);
|
|
293
|
+
const hint = suggestions.length
|
|
294
|
+
? `Did you mean: ${suggestions.join(', ')}?`
|
|
295
|
+
: `Available: ${candidates.slice(0, 5).join(', ')}${candidates.length > 5 ? ', …' : ''} (run \`dotmd modules\` for the full list).`;
|
|
296
|
+
die(`Module '${name}' not found. ${hint}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const plans = row._plans.slice();
|
|
300
|
+
if (flags.sort === 'updated' || flags.sort === 'age') {
|
|
301
|
+
plans.sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
|
|
302
|
+
} else {
|
|
303
|
+
plans.sort((a, b) => {
|
|
304
|
+
const ai = config.statusOrder.indexOf(a.status);
|
|
305
|
+
const bi = config.statusOrder.indexOf(b.status);
|
|
306
|
+
const aIdx = ai === -1 ? Number.MAX_SAFE_INTEGER : ai;
|
|
307
|
+
const bIdx = bi === -1 ? Number.MAX_SAFE_INTEGER : bi;
|
|
308
|
+
if (aIdx !== bIdx) return aIdx - bIdx;
|
|
309
|
+
return (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (flags.json) {
|
|
314
|
+
const out = {
|
|
315
|
+
name: row.name,
|
|
316
|
+
total: row.total,
|
|
317
|
+
plans: plans.map(p => ({
|
|
318
|
+
path: p.path,
|
|
319
|
+
slug: toSlug(p),
|
|
320
|
+
status: p.status,
|
|
321
|
+
daysSinceUpdate: p.daysSinceUpdate,
|
|
322
|
+
isStale: p.isStale,
|
|
323
|
+
hasNextStep: p.hasNextStep,
|
|
324
|
+
summary: p.summary,
|
|
325
|
+
})),
|
|
326
|
+
};
|
|
327
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
process.stdout.write(dim(`${row.total} plans · ${row.stale} stale · module: ${row.name}`) + '\n');
|
|
332
|
+
|
|
333
|
+
const skipStale = config.lifecycle.skipStaleFor;
|
|
334
|
+
const groups = new Map();
|
|
335
|
+
for (const p of plans) {
|
|
336
|
+
if (!groups.has(p.status)) groups.set(p.status, []);
|
|
337
|
+
groups.get(p.status).push(p);
|
|
338
|
+
}
|
|
339
|
+
const orderedStatuses = [
|
|
340
|
+
...config.statusOrder.filter(s => groups.has(s)),
|
|
341
|
+
...[...groups.keys()].filter(s => !config.statusOrder.includes(s)),
|
|
342
|
+
];
|
|
343
|
+
for (const status of orderedStatuses) {
|
|
344
|
+
const group = groups.get(status);
|
|
345
|
+
process.stdout.write(`\n${bold(colorStatus(status, status))} (${group.length})\n`);
|
|
346
|
+
for (const p of group) {
|
|
347
|
+
const slug = toSlug(p);
|
|
348
|
+
const age = fmtAge(p.daysSinceUpdate);
|
|
349
|
+
const staleTag = p.isStale && !skipStale.has(p.status) ? dim(' [stale]') : '';
|
|
350
|
+
const next = p.nextStep ? ` — ${truncate(p.nextStep, 60)}` : '';
|
|
351
|
+
process.stdout.write(` ${slug} ${dim(age)}${staleTag}${next}\n`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
process.stdout.write('\n');
|
|
355
|
+
}
|
package/src/validate.mjs
CHANGED
|
@@ -258,26 +258,42 @@ export function checkBidirectionalReferences(docs, config) {
|
|
|
258
258
|
const biFields = config.referenceFields.bidirectional || [];
|
|
259
259
|
if (!biFields.length) return { warnings, errors: [] };
|
|
260
260
|
|
|
261
|
+
// refMap stores `Set<targetPath>` keyed by the source doc path — used to
|
|
262
|
+
// answer "does B reference A?" via .has(). oneWayMap stores the subset of A's
|
|
263
|
+
// outbound refs that opted out of expecting a back-ref (via the `>` prefix in
|
|
264
|
+
// frontmatter, parsed in src/index.mjs:parseDocFile). We split these so the
|
|
265
|
+
// membership check stays cheap (Set.has) while the per-ref directionality
|
|
266
|
+
// filter only consults oneWayMap when iterating outbound edges.
|
|
261
267
|
const refMap = new Map();
|
|
268
|
+
const oneWayMap = new Map();
|
|
262
269
|
for (const doc of docs) {
|
|
263
270
|
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
264
271
|
const refs = new Set();
|
|
272
|
+
const oneWay = new Set();
|
|
265
273
|
for (const field of biFields) {
|
|
266
|
-
|
|
274
|
+
const entries = doc.refFields[field] || [];
|
|
275
|
+
const dirs = doc.refFieldDirections?.[field] || [];
|
|
276
|
+
for (let i = 0; i < entries.length; i++) {
|
|
277
|
+
const relPath = entries[i];
|
|
267
278
|
// Use the same doc-relative-then-repo-root fallback as validateDoc so
|
|
268
279
|
// both styles produce identical refMap keys; otherwise an entry like
|
|
269
280
|
// `docs/foo.md` (repo-root style) gets keyed as
|
|
270
281
|
// `<doc-parent>/docs/foo.md` and never matches the target's repo path.
|
|
271
282
|
const resolved = resolveRefPath(relPath, docDir, config.repoRoot)
|
|
272
283
|
?? path.resolve(docDir, relPath);
|
|
273
|
-
|
|
284
|
+
const targetPath = toRepoPath(resolved, config.repoRoot);
|
|
285
|
+
refs.add(targetPath);
|
|
286
|
+
if (dirs[i] === 'one-way') oneWay.add(targetPath);
|
|
274
287
|
}
|
|
275
288
|
}
|
|
276
289
|
refMap.set(doc.path, refs);
|
|
290
|
+
oneWayMap.set(doc.path, oneWay);
|
|
277
291
|
}
|
|
278
292
|
|
|
279
293
|
for (const [docPath, refs] of refMap) {
|
|
294
|
+
const oneWay = oneWayMap.get(docPath);
|
|
280
295
|
for (const targetPath of refs) {
|
|
296
|
+
if (oneWay.has(targetPath)) continue;
|
|
281
297
|
const targetRefs = refMap.get(targetPath);
|
|
282
298
|
if (targetRefs && !targetRefs.has(docPath)) {
|
|
283
299
|
warnings.push({ path: docPath, level: 'warning',
|