dotmd-cli 0.5.0 → 0.6.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/bin/dotmd.mjs CHANGED
@@ -17,6 +17,7 @@ import { runDiff } from '../src/diff.mjs';
17
17
  import { runLint } from '../src/lint.mjs';
18
18
  import { runRename } from '../src/rename.mjs';
19
19
  import { runMigrate } from '../src/migrate.mjs';
20
+ import { runFixRefs, fixBrokenRefs } from '../src/fix-refs.mjs';
20
21
  import { die, warn } from '../src/util.mjs';
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
@@ -29,15 +30,16 @@ const HELP = {
29
30
  Commands:
30
31
  list [--verbose] List docs grouped by status (default)
31
32
  json Full index as JSON
32
- check Validate frontmatter and references
33
+ check [flags] Validate frontmatter and references
33
34
  coverage [--json] Metadata coverage report
34
35
  context Compact briefing (LLM-oriented)
35
36
  focus [status] Detailed view for one status group
36
37
  query [filters] Filtered search
37
38
  index [--write] Generate/update docs.md index block
38
39
  status <file> <status> Transition document status
39
- archive <file> Archive (status + move + index regen)
40
+ archive <file> Archive (status + move + update refs)
40
41
  touch <file> Bump updated date
42
+ fix-refs Auto-fix broken reference paths
41
43
  lint [--fix] Check and auto-fix frontmatter issues
42
44
  rename <old> <new> Rename doc and update references
43
45
  migrate <f> <old> <new> Batch update a frontmatter field
@@ -82,10 +84,33 @@ regenerates the index (if configured).
82
84
 
83
85
  Use --dry-run (-n) to preview changes without writing anything.`,
84
86
 
87
+ check: `dotmd check — validate frontmatter and references
88
+
89
+ Options:
90
+ --errors-only Show only errors, suppress warnings
91
+ --fix Auto-fix broken references and regenerate index`,
92
+
85
93
  archive: `dotmd archive <file> — archive a document
86
94
 
87
- Sets status to 'archived', moves to the archive directory, regenerates
88
- the index, and scans for stale references.
95
+ Sets status to 'archived', moves to the archive directory, auto-updates
96
+ references in other docs, and regenerates the index.
97
+
98
+ Use --dry-run (-n) to preview changes without writing anything.`,
99
+
100
+ 'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
101
+
102
+ Scans all docs for reference fields that point to non-existent files,
103
+ then attempts to resolve them by matching the basename against all known
104
+ docs. Fixes are applied by rewriting the frontmatter path.
105
+
106
+ Use --dry-run (-n) to preview changes without writing anything.`,
107
+
108
+ touch: `dotmd touch <file> — bump updated date
109
+ dotmd touch --git — bulk-sync dates from git history
110
+
111
+ Without --git, updates a single file's frontmatter updated date to today.
112
+ With --git, scans all docs (or a specific file) and syncs their updated
113
+ date to match the last git commit date, fixing date drift warnings.
89
114
 
90
115
  Use --dry-run (-n) to preview changes without writing anything.`,
91
116
 
@@ -243,6 +268,7 @@ async function main() {
243
268
  if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
244
269
  if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
245
270
  if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
271
+ if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
246
272
 
247
273
  const index = buildIndex(config);
248
274
 
@@ -265,7 +291,29 @@ async function main() {
265
291
  }
266
292
 
267
293
  if (command === 'check') {
268
- process.stdout.write(renderCheck(index, config));
294
+ const fix = args.includes('--fix');
295
+ const errorsOnly = args.includes('--errors-only');
296
+
297
+ if (fix) {
298
+ // Auto-fix: broken refs, then lint, then rebuild index
299
+ const refResult = fixBrokenRefs(config, { dryRun, quiet: false });
300
+ if (!dryRun) {
301
+ runLint(['--fix'], config, { dryRun });
302
+ }
303
+ if (!dryRun && config.indexPath) {
304
+ const { renderIndexFile: rif, writeIndex: wi } = await import('../src/index-file.mjs');
305
+ const freshIndex = buildIndex(config);
306
+ wi(rif(freshIndex, config), config);
307
+ process.stdout.write('Index regenerated.\n');
308
+ }
309
+ // Show remaining issues
310
+ const freshIndex = buildIndex(config);
311
+ process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly }));
312
+ if (freshIndex.errors.length > 0) process.exitCode = 1;
313
+ return;
314
+ }
315
+
316
+ process.stdout.write(renderCheck(index, config, { errorsOnly }));
269
317
  if (index.errors.length > 0) process.exitCode = 1;
270
318
  return;
271
319
  }
@@ -35,7 +35,7 @@ export const lifecycle = {
35
35
 
36
36
  // Taxonomy validation — set fields to null to skip validation
37
37
  export const taxonomy = {
38
- surfaces: ['frontend', 'backend', 'mobile', 'docs', 'ops', 'platform'],
38
+ surfaces: ['web', 'ios', 'android', 'mobile', 'full-stack', 'frontend', 'backend', 'api', 'docs', 'ops', 'platform', 'infra', 'design'],
39
39
  moduleRequiredFor: ['active', 'ready', 'planned', 'blocked'],
40
40
  };
41
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Zero-dependency CLI for managing markdown documents with YAML frontmatter — index, query, validate, lifecycle.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,7 +3,7 @@ import { die } from './util.mjs';
3
3
  const COMMANDS = [
4
4
  'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
5
  'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
6
- 'watch', 'diff', 'init', 'new', 'completions',
6
+ 'fix-refs', 'watch', 'diff', 'init', 'new', 'completions',
7
7
  ];
8
8
 
9
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -17,9 +17,12 @@ const COMMAND_FLAGS = {
17
17
  coverage: ['--json'],
18
18
  new: ['--status', '--title'],
19
19
  diff: ['--stat', '--since', '--summarize', '--model'],
20
+ check: ['--errors-only', '--fix'],
20
21
  lint: ['--fix'],
21
22
  rename: [],
22
23
  migrate: [],
24
+ 'fix-refs': [],
25
+ touch: ['--git'],
23
26
  };
24
27
 
25
28
  function bashCompletion() {
@@ -0,0 +1,113 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { toRepoPath, warn } from './util.mjs';
5
+ import { buildIndex, collectDocFiles } from './index.mjs';
6
+ import { green, dim, yellow } from './color.mjs';
7
+
8
+ export function runFixRefs(argv, config, opts = {}) {
9
+ const { dryRun } = opts;
10
+ const result = fixBrokenRefs(config, { dryRun });
11
+ if (result.totalFixed === 0 && result.unfixableCount === 0) {
12
+ process.stdout.write(green('No broken references found.') + '\n');
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Core logic for fixing broken references. Returns { totalFixed, unfixableCount }.
18
+ * Shared by `dotmd fix-refs` and `dotmd check --fix`.
19
+ */
20
+ export function fixBrokenRefs(config, opts = {}) {
21
+ const { dryRun, quiet } = opts;
22
+ const index = buildIndex(config);
23
+ const allFiles = collectDocFiles(config);
24
+
25
+ // Build a map of basename → absolute path for all docs
26
+ const basenameMap = new Map();
27
+ const duplicateBasenames = new Set();
28
+ for (const filePath of allFiles) {
29
+ const basename = path.basename(filePath);
30
+ if (basenameMap.has(basename)) {
31
+ duplicateBasenames.add(basename);
32
+ } else {
33
+ basenameMap.set(basename, filePath);
34
+ }
35
+ }
36
+
37
+ // Find broken ref errors
38
+ const brokenRefErrors = index.errors.filter(e =>
39
+ e.message.includes('does not resolve to an existing file')
40
+ );
41
+
42
+ if (brokenRefErrors.length === 0) {
43
+ return { totalFixed: 0, unfixableCount: 0 };
44
+ }
45
+
46
+ // Group fixes by doc path
47
+ const fixesByDoc = new Map();
48
+ let unfixableCount = 0;
49
+
50
+ for (const err of brokenRefErrors) {
51
+ const match = err.message.match(/entry `([^`]+)` does not resolve/);
52
+ if (!match) { unfixableCount++; continue; }
53
+
54
+ const brokenRef = match[1];
55
+ const brokenBasename = path.basename(brokenRef);
56
+
57
+ if (duplicateBasenames.has(brokenBasename)) {
58
+ unfixableCount++;
59
+ continue;
60
+ }
61
+
62
+ const resolved = basenameMap.get(brokenBasename);
63
+ if (!resolved) { unfixableCount++; continue; }
64
+
65
+ const docAbsPath = path.join(config.repoRoot, err.path);
66
+ const docDir = path.dirname(docAbsPath);
67
+ const correctRelPath = path.relative(docDir, resolved).split(path.sep).join('/');
68
+
69
+ if (correctRelPath === brokenRef) { unfixableCount++; continue; }
70
+
71
+ if (!fixesByDoc.has(err.path)) {
72
+ fixesByDoc.set(err.path, []);
73
+ }
74
+ fixesByDoc.get(err.path).push({ brokenRef, correctRelPath });
75
+ }
76
+
77
+ const prefix = dryRun ? dim('[dry-run] ') : '';
78
+ let totalFixed = 0;
79
+
80
+ for (const [docPath, fixes] of fixesByDoc) {
81
+ const absPath = path.join(config.repoRoot, docPath);
82
+ let raw = readFileSync(absPath, 'utf8');
83
+ const { frontmatter: fm } = extractFrontmatter(raw);
84
+ if (!fm) continue;
85
+
86
+ let newFm = fm;
87
+ for (const { brokenRef, correctRelPath } of fixes) {
88
+ newFm = newFm.split(brokenRef).join(correctRelPath);
89
+ }
90
+
91
+ if (newFm !== fm && !dryRun) {
92
+ raw = replaceFrontmatter(raw, newFm);
93
+ writeFileSync(absPath, raw, 'utf8');
94
+ }
95
+
96
+ if (!quiet) {
97
+ process.stdout.write(`${prefix}${green('Fixed')}: ${docPath} (${fixes.length} ref${fixes.length > 1 ? 's' : ''})\n`);
98
+ for (const { brokenRef, correctRelPath } of fixes) {
99
+ process.stdout.write(`${prefix} ${dim(`${brokenRef} → ${correctRelPath}`)}\n`);
100
+ }
101
+ }
102
+ totalFixed += fixes.length;
103
+ }
104
+
105
+ if (!quiet) {
106
+ process.stdout.write(`\n${prefix}${totalFixed} reference${totalFixed !== 1 ? 's' : ''} fixed across ${fixesByDoc.size} file(s).\n`);
107
+ if (unfixableCount > 0) {
108
+ process.stdout.write(`${yellow(`${unfixableCount} broken reference(s) could not be auto-resolved`)} (file not found by basename).\n`);
109
+ }
110
+ }
111
+
112
+ return { totalFixed, unfixableCount };
113
+ }
package/src/lifecycle.mjs CHANGED
@@ -1,11 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
5
- import { gitMv } from './git.mjs';
5
+ import { gitMv, getGitLastModified } from './git.mjs';
6
6
  import { buildIndex, collectDocFiles } from './index.mjs';
7
7
  import { renderIndexFile, writeIndex } from './index-file.mjs';
8
- import { green, dim } from './color.mjs';
8
+ import { green, dim, yellow } from './color.mjs';
9
9
 
10
10
  export function runStatus(argv, config, opts = {}) {
11
11
  const { dryRun } = opts;
@@ -114,20 +114,10 @@ export function runArchive(argv, config, opts = {}) {
114
114
  process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
115
115
  if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
116
116
 
117
- // Reference scan is read-only, still useful in dry-run
118
- const basename = path.basename(filePath);
119
- const references = [];
120
- for (const docFile of collectDocFiles(config)) {
121
- if (docFile === targetPath) continue;
122
- const docRaw = readFileSync(docFile, 'utf8');
123
- const { frontmatter: docFm } = extractFrontmatter(docRaw);
124
- if (docFm.includes(basename)) {
125
- references.push(toRepoPath(docFile, config.repoRoot));
126
- }
127
- }
128
- if (references.length > 0) {
129
- process.stdout.write('\nThese docs reference the old path — would need updating:\n');
130
- for (const ref of references) process.stdout.write(`- ${ref}\n`);
117
+ // Preview reference updates
118
+ const refCount = countRefsToUpdate(filePath, targetPath, config);
119
+ if (refCount > 0) {
120
+ process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
131
121
  }
132
122
  return;
133
123
  }
@@ -140,39 +130,78 @@ export function runArchive(argv, config, opts = {}) {
140
130
  const result = gitMv(filePath, targetPath, config.repoRoot);
141
131
  if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
142
132
 
133
+ // Auto-update references in other docs
134
+ const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
135
+
143
136
  if (config.indexPath) {
144
137
  const index = buildIndex(config);
145
138
  writeIndex(renderIndexFile(index, config), config);
146
139
  }
147
140
 
148
141
  process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
142
+ if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
149
143
  if (config.indexPath) process.stdout.write('Index regenerated.\n');
150
144
 
151
- const basename = path.basename(filePath);
152
- const references = [];
153
- for (const docFile of collectDocFiles(config)) {
154
- if (docFile === targetPath) continue;
155
- const docRaw = readFileSync(docFile, 'utf8');
156
- const { frontmatter: docFm } = extractFrontmatter(docRaw);
157
- if (docFm.includes(basename)) {
158
- references.push(toRepoPath(docFile, config.repoRoot));
159
- }
160
- }
161
-
162
- if (references.length > 0) {
163
- process.stdout.write('\nThese docs reference the old path — update reference entries:\n');
164
- for (const ref of references) process.stdout.write(`- ${ref}\n`);
165
- }
166
-
167
- process.stdout.write('\nNext: commit, then update references if needed.\n');
168
145
  try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
169
146
  }
170
147
 
171
148
  export function runTouch(argv, config, opts = {}) {
172
149
  const { dryRun } = opts;
173
- const input = argv[0];
150
+ const useGit = argv.includes('--git');
151
+ const positional = [];
152
+ for (let i = 0; i < argv.length; i++) {
153
+ if (argv[i] === '--config') { i++; continue; }
154
+ if (argv[i].startsWith('-')) continue;
155
+ positional.push(argv[i]);
156
+ }
157
+ const input = positional[0];
174
158
 
175
- if (!input) { die('Usage: dotmd touch <file>'); }
159
+ // --git mode: bulk-sync frontmatter dates from git history
160
+ if (useGit) {
161
+ const allFiles = input ? [resolveDocPath(input, config)].filter(Boolean) : collectDocFiles(config);
162
+ if (input && allFiles.length === 0) { die(`File not found: ${input}`); }
163
+
164
+ const prefix = dryRun ? dim('[dry-run] ') : '';
165
+ let synced = 0;
166
+
167
+ for (const filePath of allFiles) {
168
+ const repoPath = toRepoPath(filePath, config.repoRoot);
169
+ const raw = readFileSync(filePath, 'utf8');
170
+ const { frontmatter } = extractFrontmatter(raw);
171
+ if (!frontmatter) continue;
172
+
173
+ const parsed = parseSimpleFrontmatter(frontmatter);
174
+ const status = asString(parsed.status);
175
+ if (config.lifecycle.skipStaleFor.has(status)) continue;
176
+
177
+ const fmUpdated = asString(parsed.updated);
178
+ const gitDate = getGitLastModified(repoPath, config.repoRoot);
179
+ if (!gitDate) continue;
180
+
181
+ const gitDay = gitDate.slice(0, 10);
182
+ if (fmUpdated === gitDay) continue;
183
+
184
+ // Only sync if git is newer than frontmatter
185
+ const gitMs = new Date(gitDate).getTime();
186
+ const fmMs = fmUpdated ? new Date(fmUpdated).getTime() : 0;
187
+ if (fmMs >= gitMs) continue;
188
+
189
+ if (!dryRun) {
190
+ updateFrontmatter(filePath, { updated: gitDay });
191
+ }
192
+ process.stdout.write(`${prefix}${green('Synced')}: ${repoPath} (updated → ${gitDay})\n`);
193
+ synced++;
194
+ }
195
+
196
+ if (synced === 0) {
197
+ process.stdout.write(green('All frontmatter dates are in sync with git.') + '\n');
198
+ } else {
199
+ process.stdout.write(`\n${prefix}${synced} file(s) synced.\n`);
200
+ }
201
+ return;
202
+ }
203
+
204
+ if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
176
205
 
177
206
  const filePath = resolveDocPath(input, config);
178
207
  if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
@@ -190,6 +219,69 @@ export function runTouch(argv, config, opts = {}) {
190
219
  try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
191
220
  }
192
221
 
222
+ /**
223
+ * After a file moves (archive/unarchive), update frontmatter references in all
224
+ * docs that pointed to the old location so they point to the new one.
225
+ */
226
+ function updateRefsAfterMove(oldPath, newPath, config) {
227
+ const basename = path.basename(oldPath);
228
+ const allFiles = collectDocFiles(config);
229
+ let updatedCount = 0;
230
+
231
+ for (const docFile of allFiles) {
232
+ if (docFile === newPath) continue;
233
+ let raw = readFileSync(docFile, 'utf8');
234
+ const { frontmatter: fm } = extractFrontmatter(raw);
235
+ if (!fm || !fm.includes(basename)) continue;
236
+
237
+ const docDir = path.dirname(docFile);
238
+ const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
239
+ const newRelPath = path.relative(docDir, newPath).split(path.sep).join('/');
240
+
241
+ let newFm = fm;
242
+
243
+ // Replace exact relative path
244
+ if (newFm.includes(oldRelPath)) {
245
+ newFm = newFm.split(oldRelPath).join(newRelPath);
246
+ }
247
+
248
+ // Also handle ./ prefix variant
249
+ const dotSlashOld = './' + oldRelPath;
250
+ if (newFm.includes(dotSlashOld)) {
251
+ newFm = newFm.split(dotSlashOld).join(newRelPath);
252
+ }
253
+
254
+ if (newFm !== fm) {
255
+ raw = replaceFrontmatter(raw, newFm);
256
+ writeFileSync(docFile, raw, 'utf8');
257
+ updatedCount++;
258
+ }
259
+ }
260
+
261
+ return updatedCount;
262
+ }
263
+
264
+ function countRefsToUpdate(oldPath, newPath, config) {
265
+ const basename = path.basename(oldPath);
266
+ const allFiles = collectDocFiles(config);
267
+ let count = 0;
268
+
269
+ for (const docFile of allFiles) {
270
+ if (docFile === newPath) continue;
271
+ const raw = readFileSync(docFile, 'utf8');
272
+ const { frontmatter: fm } = extractFrontmatter(raw);
273
+ if (!fm || !fm.includes(basename)) continue;
274
+
275
+ const docDir = path.dirname(docFile);
276
+ const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
277
+ if (fm.includes(oldRelPath) || fm.includes('./' + oldRelPath)) {
278
+ count++;
279
+ }
280
+ }
281
+
282
+ return count;
283
+ }
284
+
193
285
  export function updateFrontmatter(filePath, updates) {
194
286
  const raw = readFileSync(filePath, 'utf8');
195
287
  if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
package/src/render.mjs CHANGED
@@ -143,8 +143,8 @@ function _renderContext(index, config) {
143
143
  return `${lines.join('\n').trimEnd()}\n`;
144
144
  }
145
145
 
146
- export function renderCheck(index, config) {
147
- const defaultRenderer = (idx) => _renderCheck(idx);
146
+ export function renderCheck(index, config, opts = {}) {
147
+ const defaultRenderer = (idx) => _renderCheck(idx, opts);
148
148
  if (config.hooks.renderCheck) {
149
149
  try { return config.hooks.renderCheck(index, defaultRenderer); }
150
150
  catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
@@ -152,7 +152,8 @@ export function renderCheck(index, config) {
152
152
  return defaultRenderer(index);
153
153
  }
154
154
 
155
- function _renderCheck(index) {
155
+ function _renderCheck(index, opts = {}) {
156
+ const { errorsOnly } = opts;
156
157
  const lines = ['Check', ''];
157
158
  lines.push(`- docs scanned: ${index.docs.length}`);
158
159
  lines.push(`- errors: ${index.errors.length}`);
@@ -167,7 +168,7 @@ function _renderCheck(index) {
167
168
  lines.push('');
168
169
  }
169
170
 
170
- if (index.warnings.length > 0) {
171
+ if (!errorsOnly && index.warnings.length > 0) {
171
172
  lines.push(yellow('Warnings'));
172
173
  for (const issue of index.warnings) {
173
174
  lines.push(`- ${issue.path}: ${issue.message}`);