dotmd-cli 0.7.0 → 0.8.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/src/stats.mjs ADDED
@@ -0,0 +1,161 @@
1
+ import { capitalize, toSlug, warn } from './util.mjs';
2
+ import { bold, dim } from './color.mjs';
3
+
4
+ function pct(n, total) {
5
+ if (!total) return 0;
6
+ return Math.round((n / total) * 100);
7
+ }
8
+
9
+ export function buildStats(index, config) {
10
+ const docs = index.docs;
11
+ const scope = ['active', 'ready', 'planned', 'blocked'];
12
+ const scoped = docs.filter(d => scope.includes(d.status));
13
+ const nonArchived = docs.filter(d => !config.lifecycle.skipWarningsFor.has(d.status));
14
+
15
+ // Health
16
+ const staleCount = nonArchived.filter(d => d.isStale).length;
17
+ const brokenRefCount = index.errors.filter(e => e.message.includes('does not resolve to an existing file')).length;
18
+ const brokenLinkCount = index.warnings.filter(w => w.message.startsWith('body link')).length;
19
+
20
+ // Freshness
21
+ const withDays = nonArchived.filter(d => d.daysSinceUpdate != null);
22
+ const sorted = [...withDays].sort((a, b) => (b.daysSinceUpdate ?? 0) - (a.daysSinceUpdate ?? 0));
23
+ const oldest = sorted[0] ?? null;
24
+
25
+ // Checklists
26
+ const withChecklists = docs.filter(d => d.checklist?.total > 0);
27
+ const avgCompletion = withChecklists.length > 0
28
+ ? Math.round(withChecklists.reduce((sum, d) => sum + (d.checklistCompletionRate ?? 0), 0) / withChecklists.length * 100)
29
+ : 0;
30
+ const fullyComplete = withChecklists.filter(d => d.checklistCompletionRate === 1).length;
31
+ const withOpenItems = withChecklists.filter(d => d.checklist.open > 0).length;
32
+
33
+ // Audit
34
+ const auditCounts = { pass1: 0, pass2: 0, deep: 0 };
35
+ for (const d of scoped) {
36
+ if (auditCounts[d.auditLevel] !== undefined) auditCounts[d.auditLevel]++;
37
+ }
38
+ const auditedCount = auditCounts.pass1 + auditCounts.pass2 + auditCounts.deep;
39
+
40
+ return {
41
+ generatedAt: new Date().toISOString(),
42
+ totalDocs: docs.length,
43
+ countsByStatus: index.countsByStatus,
44
+ health: {
45
+ staleCount,
46
+ stalePct: pct(staleCount, nonArchived.length),
47
+ nonArchivedCount: nonArchived.length,
48
+ errorCount: index.errors.length,
49
+ warningCount: index.warnings.length,
50
+ brokenRefCount,
51
+ brokenLinkCount,
52
+ },
53
+ freshness: {
54
+ today: docs.filter(d => d.daysSinceUpdate === 0).length,
55
+ thisWeek: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 7).length,
56
+ thisMonth: docs.filter(d => d.daysSinceUpdate != null && d.daysSinceUpdate <= 30).length,
57
+ oldest: oldest ? { path: oldest.path, slug: toSlug(oldest), daysSinceUpdate: oldest.daysSinceUpdate } : null,
58
+ },
59
+ completeness: {
60
+ scoped: scoped.length,
61
+ hasOwner: scoped.filter(d => d.owner).length,
62
+ hasSurface: scoped.filter(d => d.surface).length,
63
+ hasModule: scoped.filter(d => d.module).length,
64
+ hasNextStep: scoped.filter(d => d.hasNextStep).length,
65
+ },
66
+ checklists: {
67
+ docsWithChecklists: withChecklists.length,
68
+ avgCompletion,
69
+ fullyComplete,
70
+ withOpenItems,
71
+ },
72
+ audit: {
73
+ audited: auditedCount,
74
+ ...auditCounts,
75
+ },
76
+ };
77
+ }
78
+
79
+ // ── Text renderer ──────────────────────────────────────────────────────
80
+
81
+ export function renderStats(stats, config) {
82
+ const defaultRenderer = (s) => _renderStats(s, config);
83
+ if (config.hooks.renderStats) {
84
+ try { return config.hooks.renderStats(stats, defaultRenderer); }
85
+ catch (err) { warn(`Hook 'renderStats' threw: ${err.message}`); }
86
+ }
87
+ return defaultRenderer(stats);
88
+ }
89
+
90
+ function _renderStats(stats, config) {
91
+ const lines = [];
92
+ lines.push(bold(`Stats`) + dim(` — ${stats.totalDocs} docs`));
93
+ lines.push('');
94
+
95
+ // Status
96
+ lines.push(bold('Status'));
97
+ const statusParts = config.statusOrder
98
+ .filter(s => stats.countsByStatus[s])
99
+ .map(s => `${s}: ${stats.countsByStatus[s]}`);
100
+ lines.push(' ' + statusParts.join(' '));
101
+ lines.push('');
102
+
103
+ // Health
104
+ const h = stats.health;
105
+ lines.push(bold('Health'));
106
+ lines.push(` stale: ${h.staleCount}/${h.nonArchivedCount} (${h.stalePct}%)`);
107
+ lines.push(` errors: ${h.errorCount}`);
108
+ lines.push(` warnings: ${h.warningCount}`);
109
+ if (h.brokenRefCount) lines.push(` broken refs: ${h.brokenRefCount}`);
110
+ if (h.brokenLinkCount) lines.push(` broken links: ${h.brokenLinkCount}`);
111
+ lines.push('');
112
+
113
+ // Freshness
114
+ const f = stats.freshness;
115
+ lines.push(bold('Freshness'));
116
+ lines.push(` updated today: ${f.today} this week: ${f.thisWeek} this month: ${f.thisMonth}`);
117
+ if (f.oldest) {
118
+ lines.push(` oldest: ${f.oldest.slug} (${f.oldest.daysSinceUpdate}d)`);
119
+ }
120
+ lines.push('');
121
+
122
+ // Completeness
123
+ const c = stats.completeness;
124
+ if (c.scoped > 0) {
125
+ lines.push(bold('Completeness') + dim(' (active/ready/planned/blocked)'));
126
+ lines.push(` has owner: ${c.hasOwner}/${c.scoped} (${pct(c.hasOwner, c.scoped)}%)`);
127
+ lines.push(` has surface: ${c.hasSurface}/${c.scoped} (${pct(c.hasSurface, c.scoped)}%)`);
128
+ lines.push(` has module: ${c.hasModule}/${c.scoped} (${pct(c.hasModule, c.scoped)}%)`);
129
+ lines.push(` has next_step: ${c.hasNextStep}/${c.scoped} (${pct(c.hasNextStep, c.scoped)}%)`);
130
+ lines.push('');
131
+ }
132
+
133
+ // Checklists
134
+ const cl = stats.checklists;
135
+ if (cl.docsWithChecklists > 0) {
136
+ lines.push(bold('Checklists'));
137
+ lines.push(` docs with checklists: ${cl.docsWithChecklists}`);
138
+ lines.push(` avg completion: ${cl.avgCompletion}%`);
139
+ lines.push(` fully complete: ${cl.fullyComplete} open items: ${cl.withOpenItems}`);
140
+ lines.push('');
141
+ }
142
+
143
+ // Audit
144
+ const a = stats.audit;
145
+ if (c.scoped > 0) {
146
+ lines.push(bold('Audit'));
147
+ lines.push(` audited: ${a.audited}/${c.scoped} (${pct(a.audited, c.scoped)}%)`);
148
+ if (a.audited > 0) {
149
+ lines.push(` pass1: ${a.pass1} pass2: ${a.pass2} deep: ${a.deep}`);
150
+ }
151
+ lines.push('');
152
+ }
153
+
154
+ return `${lines.join('\n').trimEnd()}\n`;
155
+ }
156
+
157
+ // ── JSON renderer ──────────────────────────────────────────────────────
158
+
159
+ export function renderStatsJson(stats) {
160
+ return JSON.stringify(stats, null, 2) + '\n';
161
+ }
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, resolveDocPath, die, warn } from './util.mjs';
5
+ import { summarizeDocBody } from './ai.mjs';
6
+ import { bold, dim } from './color.mjs';
7
+
8
+ export function runSummary(argv, config) {
9
+ const positional = [];
10
+ let model;
11
+ let maxTokens;
12
+ let json = false;
13
+
14
+ for (let i = 0; i < argv.length; i++) {
15
+ if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
16
+ if (argv[i] === '--max-tokens' && argv[i + 1]) { maxTokens = Number.parseInt(argv[++i], 10); continue; }
17
+ if (argv[i] === '--config') { i++; continue; }
18
+ if (argv[i] === '--json') { json = true; continue; }
19
+ if (argv[i].startsWith('-')) continue;
20
+ positional.push(argv[i]);
21
+ }
22
+
23
+ const input = positional[0];
24
+ if (!input) { die('Usage: dotmd summary <file> [--model <name>] [--json]'); }
25
+
26
+ const filePath = resolveDocPath(input, config);
27
+ if (!filePath) { die(`File not found: ${input}`); }
28
+
29
+ const raw = readFileSync(filePath, 'utf8');
30
+ const { frontmatter, body } = extractFrontmatter(raw);
31
+ const parsed = parseSimpleFrontmatter(frontmatter);
32
+ const repoPath = toRepoPath(filePath, config.repoRoot);
33
+ const title = asString(parsed.title) ?? path.basename(filePath, '.md');
34
+ const status = asString(parsed.status) ?? 'unknown';
35
+
36
+ const meta = { title, status, path: repoPath };
37
+ const opts = {};
38
+ if (model) opts.model = model;
39
+ if (maxTokens) opts.maxTokens = maxTokens;
40
+
41
+ let summary;
42
+ try {
43
+ summary = config.hooks.summarizeDoc
44
+ ? config.hooks.summarizeDoc(body, meta)
45
+ : summarizeDocBody(body, meta, opts);
46
+ } catch (err) {
47
+ warn(`Hook 'summarizeDoc' threw: ${err.message}`);
48
+ summary = null;
49
+ }
50
+
51
+ if (json) {
52
+ process.stdout.write(JSON.stringify({ path: repoPath, title, status, summary: summary ?? null }, null, 2) + '\n');
53
+ return;
54
+ }
55
+
56
+ process.stdout.write(`${bold(title)} ${dim(`(${status})`)}\n`);
57
+ process.stdout.write(`${dim(repoPath)}\n\n`);
58
+ if (summary) {
59
+ process.stdout.write(`${summary}\n`);
60
+ } else {
61
+ process.stdout.write(dim('Summary unavailable (model call failed or uv not installed).') + '\n');
62
+ }
63
+ }
package/src/util.mjs CHANGED
@@ -77,8 +77,11 @@ export function resolveDocPath(input, config) {
77
77
  let candidate = path.resolve(config.repoRoot, input);
78
78
  if (existsSync(candidate)) return candidate;
79
79
 
80
- candidate = path.resolve(config.docsRoot, input);
81
- if (existsSync(candidate)) return candidate;
80
+ const roots = config.docsRoots || [config.docsRoot];
81
+ for (const root of roots) {
82
+ candidate = path.resolve(root, input);
83
+ if (existsSync(candidate)) return candidate;
84
+ }
82
85
 
83
86
  return null;
84
87
  }
package/src/watch.mjs CHANGED
@@ -28,18 +28,21 @@ export function runWatch(argv, config) {
28
28
  // Run once immediately
29
29
  run();
30
30
 
31
- process.stderr.write(dim(`\nWatching ${config.docsRoot} for changes... (Ctrl+C to stop)`) + '\n');
32
-
33
- // Watch for changes
34
- const watcher = watch(config.docsRoot, { recursive: true }, (eventType, filename) => {
35
- if (filename && filename.endsWith('.md')) {
36
- run();
37
- }
38
- });
31
+ const roots = config.docsRoots || [config.docsRoot];
32
+ process.stderr.write(dim(`\nWatching ${roots.length} root(s) for changes... (Ctrl+C to stop)`) + '\n');
33
+
34
+ // Watch for changes across all roots
35
+ const watchers = roots.map(root =>
36
+ watch(root, { recursive: true }, (eventType, filename) => {
37
+ if (filename && filename.endsWith('.md')) {
38
+ run();
39
+ }
40
+ })
41
+ );
39
42
 
40
43
  // Clean exit
41
44
  process.on('SIGINT', () => {
42
- watcher.close();
45
+ for (const w of watchers) w.close();
43
46
  process.exit(0);
44
47
  });
45
48