dotmd-cli 0.5.0 → 0.7.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 CHANGED
@@ -19,6 +19,7 @@ dotmd new my-feature # scaffold a new doc with frontmatter
19
19
  dotmd list # index all docs grouped by status
20
20
  dotmd check # validate frontmatter and references
21
21
  dotmd context # compact briefing (great for LLM context)
22
+ dotmd doctor # auto-fix everything in one pass
22
23
  ```
23
24
 
24
25
  ### Shell Completion
@@ -37,9 +38,11 @@ dotmd scans a directory of markdown files, parses their YAML frontmatter, and gi
37
38
 
38
39
  - **Index** — group docs by status, show progress bars, next steps
39
40
  - **Query** — filter by status, keyword, module, surface, owner, staleness
40
- - **Validate** — check for missing fields, broken references, stale dates
41
- - **Lifecycle** — transition statuses, auto-archive with `git mv`, bump dates
42
- - **Scaffold** — create new docs with frontmatter from the command line
41
+ - **Validate** — check for missing fields, broken references, stale dates, broken body links
42
+ - **Graph** — visualize document relationships as text, Graphviz DOT, or JSON
43
+ - **Lifecycle** — transition statuses, auto-archive with `git mv` and reference updates
44
+ - **Doctor** — auto-fix broken refs, lint issues, date drift, and stale indexes in one pass
45
+ - **Scaffold** — create new docs from templates (plan, ADR, RFC, audit, design)
43
46
  - **Index generation** — auto-generate a `docs.md` index block
44
47
  - **Context briefing** — compact summary designed for AI/LLM consumption
45
48
  - **Dry-run** — preview any mutation with `--dry-run` before committing
@@ -56,6 +59,8 @@ module: auth
56
59
  surface: backend
57
60
  next_step: implement token refresh
58
61
  current_state: initial scaffolding complete
62
+ related_plans:
63
+ - ./design-doc.md
59
64
  ---
60
65
 
61
66
  # Auth Token Refresh
@@ -67,28 +72,32 @@ Design doc content here...
67
72
  - [ ] Add tests
68
73
  ```
69
74
 
70
- The only required field is `status`. Everything else is optional but unlocks more features (staleness detection, filtering, coverage reports).
75
+ The only required field is `status`. Everything else is optional but unlocks more features (staleness detection, filtering, coverage reports, graph visualization).
71
76
 
72
77
  ## Commands
73
78
 
74
79
  ```
75
80
  dotmd list [--verbose] List docs grouped by status (default)
76
81
  dotmd json Full index as JSON
77
- dotmd check Validate frontmatter and references
82
+ dotmd check [--errors-only] [--fix] Validate frontmatter and references
78
83
  dotmd coverage [--json] Metadata coverage report
84
+ dotmd graph [--dot|--json] Visualize document relationships
79
85
  dotmd context Compact briefing (LLM-oriented)
80
86
  dotmd focus [status] Detailed view for one status group
81
87
  dotmd query [filters] Filtered search
82
88
  dotmd index [--write] Generate/update docs.md index block
83
89
  dotmd status <file> <status> Transition document status
84
- dotmd archive <file> Archive (status + move + index regen)
90
+ dotmd archive <file> Archive (status + move + update refs)
85
91
  dotmd touch <file> Bump updated date
92
+ dotmd touch --git Bulk-sync dates from git history
93
+ dotmd doctor Auto-fix everything in one pass
94
+ dotmd fix-refs Auto-fix broken reference paths
86
95
  dotmd lint [--fix] Check and auto-fix frontmatter issues
87
96
  dotmd rename <old> <new> Rename doc and update references
88
97
  dotmd migrate <f> <old> <new> Batch update a frontmatter field
89
98
  dotmd watch [command] Re-run a command on file changes
90
99
  dotmd diff [file] Show changes since last updated date
91
- dotmd new <name> Create a new document with frontmatter
100
+ dotmd new <name> Create a new document from template
92
101
  dotmd init Create starter config + docs directory
93
102
  dotmd completions <shell> Output shell completion script (bash, zsh)
94
103
  ```
@@ -114,31 +123,91 @@ dotmd query --surface backend --checklist-open
114
123
 
115
124
  Flags: `--status`, `--keyword`, `--module`, `--surface`, `--domain`, `--owner`, `--updated-since`, `--stale`, `--has-next-step`, `--has-blockers`, `--checklist-open`, `--sort`, `--limit`, `--all`, `--git`, `--json`.
116
125
 
117
- ### Scaffold a Document
126
+ ### Scaffold with Templates
118
127
 
119
128
  ```bash
120
- dotmd new my-feature # creates docs/my-feature.md (status: active)
121
- dotmd new "API Redesign" --status planned # custom status
122
- dotmd new auth-refresh --title "Auth Refresh" # custom title
123
- dotmd new something --dry-run # preview without creating
129
+ dotmd new my-feature # default (status + title)
130
+ dotmd new my-plan --template plan # plan with module, surface, refs
131
+ dotmd new my-decision --template adr # ADR: Context, Decision, Consequences
132
+ dotmd new my-proposal --template rfc # RFC: Summary, Motivation, Design
133
+ dotmd new my-audit --template audit # Audit: Scope, Findings, Recommendations
134
+ dotmd new my-design --template design # Design: Goals, Non-Goals, Design
135
+ dotmd new my-feature --status planned --title "Title" # custom status and title
136
+ dotmd new --list-templates # show all available templates
124
137
  ```
125
138
 
126
- ### Preset Aliases
127
-
128
- Define custom query presets in your config:
139
+ Built-in templates: `default`, `plan`, `adr`, `rfc`, `audit`, `design`. Add custom templates in your config:
129
140
 
130
141
  ```js
131
- export const presets = {
132
- stale: ['--status', 'active,ready', '--stale', '--sort', 'updated', '--all'],
133
- mine: ['--owner', 'robert', '--status', 'active', '--all'],
142
+ export const templates = {
143
+ spike: {
144
+ description: 'Timeboxed investigation',
145
+ frontmatter: (status, today) => `status: ${status}\nupdated: ${today}\ntimebox: 2d`,
146
+ body: (title) => `\n# ${title}\n\n## Hypothesis\n\n\n\n## Findings\n\n\n`,
147
+ },
134
148
  };
135
149
  ```
136
150
 
137
- Then run `dotmd stale` or `dotmd mine` as shorthand.
151
+ ### Check & Fix
138
152
 
139
- ### Lint
153
+ ```bash
154
+ dotmd check # validate everything
155
+ dotmd check --errors-only # suppress warnings, show only errors
156
+ dotmd check --fix # auto-fix broken refs + lint + regen index
157
+ ```
158
+
159
+ Validates: required fields, status values, broken reference paths, broken body links (`[text](path.md)`), bidirectional reference symmetry, git date drift, taxonomy mismatches.
160
+
161
+ ### Doctor
162
+
163
+ One command to fix everything fixable:
164
+
165
+ ```bash
166
+ dotmd doctor # fix refs → lint → sync git dates → regen index
167
+ dotmd doctor --dry-run # preview all changes
168
+ ```
169
+
170
+ Runs in sequence: `fix-refs` → `lint --fix` → `touch --git` → `index --write` → shows remaining issues.
171
+
172
+ ### Graph
173
+
174
+ Visualize how documents reference each other:
175
+
176
+ ```bash
177
+ dotmd graph # text adjacency list
178
+ dotmd graph --dot | dot -Tpng -o g.png # Graphviz PNG
179
+ dotmd graph --json # machine-readable
180
+ dotmd graph --status active,ready # filter by status
181
+ dotmd graph --module auth # filter by module
182
+ ```
140
183
 
141
- Check docs for fixable frontmatter issues and optionally auto-fix them:
184
+ ### Archive
185
+
186
+ ```bash
187
+ dotmd archive docs/old-plan.md # move + update refs + regen index
188
+ dotmd archive docs/old-plan.md -n # preview
189
+ ```
190
+
191
+ Archives a document: sets status to `archived`, moves to archive directory via `git mv`, auto-updates references in other docs, and regenerates the index.
192
+
193
+ ### Touch
194
+
195
+ ```bash
196
+ dotmd touch docs/my-doc.md # set updated to today
197
+ dotmd touch --git # bulk-sync all docs from git history
198
+ dotmd touch --git docs/my-doc.md # sync one file from git
199
+ ```
200
+
201
+ ### Fix References
202
+
203
+ ```bash
204
+ dotmd fix-refs # find and fix broken ref paths
205
+ dotmd fix-refs --dry-run # preview fixes
206
+ ```
207
+
208
+ Scans all reference fields for broken paths, resolves by basename matching, and rewrites frontmatter.
209
+
210
+ ### Lint
142
211
 
143
212
  ```bash
144
213
  dotmd lint # report issues
@@ -146,34 +215,35 @@ dotmd lint --fix # fix all issues
146
215
  dotmd lint --fix --dry-run # preview fixes without writing
147
216
  ```
148
217
 
149
- Detected issues:
150
- - Missing `updated` date on non-archived docs
151
- - Status casing mismatch (e.g., `Active` → `active`)
152
- - camelCase frontmatter keys (e.g., `nextStep` → `next_step`)
153
- - Trailing whitespace in frontmatter values
154
- - Missing newline at end of file
218
+ Detected issues: missing `updated`, status casing, camelCase keys, trailing whitespace, missing EOF newline.
155
219
 
156
220
  ### Rename
157
221
 
158
- Rename a document and update all frontmatter references across your docs:
159
-
160
222
  ```bash
161
223
  dotmd rename old-name.md new-name # renames + updates refs
162
224
  dotmd rename old-name.md new-name -n # preview without writing
163
225
  ```
164
226
 
165
- Uses `git mv` for the rename and scans all reference fields for the old filename. Body markdown links are warned about but not auto-fixed.
227
+ Uses `git mv` and updates all frontmatter references. Body markdown links are warned about but not auto-fixed.
166
228
 
167
229
  ### Migrate
168
230
 
169
- Batch update a frontmatter field value across all docs:
170
-
171
231
  ```bash
172
232
  dotmd migrate status research exploration # rename a status
173
233
  dotmd migrate module auth identity # rename a module
174
- dotmd migrate module auth identity -n # preview
175
234
  ```
176
235
 
236
+ ### Preset Aliases
237
+
238
+ ```js
239
+ export const presets = {
240
+ stale: ['--status', 'active,ready', '--stale', '--sort', 'updated', '--all'],
241
+ mine: ['--owner', 'robert', '--status', 'active', '--all'],
242
+ };
243
+ ```
244
+
245
+ Then run `dotmd stale` or `dotmd mine` as shorthand.
246
+
177
247
  ### Watch Mode
178
248
 
179
249
  ```bash
@@ -184,19 +254,13 @@ dotmd watch context # live briefing
184
254
 
185
255
  ### Diff & Summarize
186
256
 
187
- Show git changes since each document's `updated` frontmatter date:
188
-
189
257
  ```bash
190
258
  dotmd diff # all drifted docs
191
259
  dotmd diff docs/plans/auth.md # single file
192
260
  dotmd diff --stat # summary stats only
193
- dotmd diff --since 2026-01-01 # override date
194
261
  dotmd diff --summarize # AI summary via local MLX model
195
- dotmd diff --summarize --model mlx-community/Mistral-7B-Instruct-v0.3-4bit
196
262
  ```
197
263
 
198
- The `--summarize` flag requires `uv` and a local MLX-compatible model. No JS dependencies are added.
199
-
200
264
  ## Configuration
201
265
 
202
266
  Create `dotmd.config.mjs` at your project root (or run `dotmd init`):
@@ -216,6 +280,16 @@ export const lifecycle = {
216
280
  skipWarningsFor: ['archived'],
217
281
  };
218
282
 
283
+ export const taxonomy = {
284
+ surfaces: ['web', 'ios', 'backend', 'api', 'platform'],
285
+ moduleRequiredFor: ['active', 'ready', 'planned', 'blocked'],
286
+ };
287
+
288
+ export const referenceFields = {
289
+ bidirectional: ['related_plans'], // warn if A→B but B↛A
290
+ unidirectional: ['supports_plans'], // one-way, no symmetry check
291
+ };
292
+
219
293
  export const index = {
220
294
  path: 'docs/docs.md',
221
295
  startMarker: '<!-- GENERATED:dotmd:start -->',
@@ -258,7 +332,7 @@ export function renderContext(index, defaultRenderer) {
258
332
  }
259
333
  ```
260
334
 
261
- Available: `renderContext`, `renderCompactList`, `renderCheck`, `formatSnapshot`.
335
+ Available: `renderContext`, `renderCompactList`, `renderCheck`, `renderGraph`, `formatSnapshot`.
262
336
 
263
337
  ### Lifecycle Hooks
264
338
 
@@ -291,7 +365,7 @@ export function summarizeDiff(diffOutput, filePath) {
291
365
  - **No build step** — ships as plain ESM, runs directly
292
366
  - **Git-aware** — detects frontmatter date drift vs git history, uses `git mv` for archives
293
367
  - **Dry-run everything** — preview any mutation with `--dry-run` / `-n`
294
- - **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display
368
+ - **Configurable everything** — statuses, taxonomy, lifecycle, validation rules, display, templates
295
369
  - **Hook system** — extend with JS functions, no plugin framework to learn
296
370
  - **LLM-friendly** — `dotmd context` generates compact briefings for AI assistants
297
371
  - **Shell completion** — bash and zsh via `dotmd completions`
package/bin/dotmd.mjs CHANGED
@@ -17,6 +17,9 @@ 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';
21
+ import { buildGraph, renderGraphText, renderGraphDot, renderGraphJson } from '../src/graph.mjs';
22
+ import { runDoctor } from '../src/doctor.mjs';
20
23
  import { die, warn } from '../src/util.mjs';
21
24
 
22
25
  const __filename = fileURLToPath(import.meta.url);
@@ -29,15 +32,18 @@ const HELP = {
29
32
  Commands:
30
33
  list [--verbose] List docs grouped by status (default)
31
34
  json Full index as JSON
32
- check Validate frontmatter and references
35
+ check [flags] Validate frontmatter and references
33
36
  coverage [--json] Metadata coverage report
37
+ graph [--dot|--json] Visualize document relationships
34
38
  context Compact briefing (LLM-oriented)
35
39
  focus [status] Detailed view for one status group
36
40
  query [filters] Filtered search
37
41
  index [--write] Generate/update docs.md index block
38
42
  status <file> <status> Transition document status
39
- archive <file> Archive (status + move + index regen)
43
+ archive <file> Archive (status + move + update refs)
40
44
  touch <file> Bump updated date
45
+ doctor Auto-fix everything: refs, lint, dates, index
46
+ fix-refs Auto-fix broken reference paths
41
47
  lint [--fix] Check and auto-fix frontmatter issues
42
48
  rename <old> <new> Rename doc and update references
43
49
  migrate <f> <old> <new> Batch update a frontmatter field
@@ -82,10 +88,52 @@ regenerates the index (if configured).
82
88
 
83
89
  Use --dry-run (-n) to preview changes without writing anything.`,
84
90
 
91
+ check: `dotmd check — validate frontmatter and references
92
+
93
+ Options:
94
+ --errors-only Show only errors, suppress warnings
95
+ --fix Auto-fix broken references and regenerate index`,
96
+
85
97
  archive: `dotmd archive <file> — archive a document
86
98
 
87
- Sets status to 'archived', moves to the archive directory, regenerates
88
- the index, and scans for stale references.
99
+ Sets status to 'archived', moves to the archive directory, auto-updates
100
+ references in other docs, and regenerates the index.
101
+
102
+ Use --dry-run (-n) to preview changes without writing anything.`,
103
+
104
+ graph: `dotmd graph — visualize document relationships
105
+
106
+ Output formats:
107
+ (default) Text adjacency list
108
+ --dot Graphviz DOT format (pipe to dot -Tpng)
109
+ --json Machine-readable JSON
110
+
111
+ Filters:
112
+ --status <s1,s2> Show only docs with these statuses
113
+ --module <name> Show only docs with this module
114
+ --surface <name> Show only docs with this surface`,
115
+
116
+ doctor: `dotmd doctor — auto-fix everything in one pass
117
+
118
+ Runs in sequence: fix broken references, lint --fix, sync dates from
119
+ git, regenerate index, then show remaining issues.
120
+
121
+ Use --dry-run (-n) to preview all changes without writing anything.`,
122
+
123
+ 'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
124
+
125
+ Scans all docs for reference fields that point to non-existent files,
126
+ then attempts to resolve them by matching the basename against all known
127
+ docs. Fixes are applied by rewriting the frontmatter path.
128
+
129
+ Use --dry-run (-n) to preview changes without writing anything.`,
130
+
131
+ touch: `dotmd touch <file> — bump updated date
132
+ dotmd touch --git — bulk-sync dates from git history
133
+
134
+ Without --git, updates a single file's frontmatter updated date to today.
135
+ With --git, scans all docs (or a specific file) and syncs their updated
136
+ date to match the last git commit date, fixing date drift warnings.
89
137
 
90
138
  Use --dry-run (-n) to preview changes without writing anything.`,
91
139
 
@@ -101,8 +149,10 @@ Use --dry-run (-n) with --write to preview without writing.`,
101
149
  Creates a new markdown document with frontmatter in the docs root.
102
150
 
103
151
  Options:
152
+ --template <name> Use a template (default, plan, adr, rfc, audit, design)
104
153
  --status <s> Set initial status (default: active)
105
154
  --title <t> Override the document title
155
+ --list-templates Show available templates
106
156
 
107
157
  The filename is derived from <name> by slugifying it.
108
158
  Use --dry-run (-n) to preview without creating the file.`,
@@ -243,6 +293,8 @@ async function main() {
243
293
  if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
244
294
  if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
245
295
  if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
296
+ if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
297
+ if (command === 'doctor') { runDoctor(restArgs, config, { dryRun }); return; }
246
298
 
247
299
  const index = buildIndex(config);
248
300
 
@@ -265,7 +317,29 @@ async function main() {
265
317
  }
266
318
 
267
319
  if (command === 'check') {
268
- process.stdout.write(renderCheck(index, config));
320
+ const fix = args.includes('--fix');
321
+ const errorsOnly = args.includes('--errors-only');
322
+
323
+ if (fix) {
324
+ // Auto-fix: broken refs, then lint, then rebuild index
325
+ const refResult = fixBrokenRefs(config, { dryRun, quiet: false });
326
+ if (!dryRun) {
327
+ runLint(['--fix'], config, { dryRun });
328
+ }
329
+ if (!dryRun && config.indexPath) {
330
+ const { renderIndexFile: rif, writeIndex: wi } = await import('../src/index-file.mjs');
331
+ const freshIndex = buildIndex(config);
332
+ wi(rif(freshIndex, config), config);
333
+ process.stdout.write('Index regenerated.\n');
334
+ }
335
+ // Show remaining issues
336
+ const freshIndex = buildIndex(config);
337
+ process.stdout.write('\n' + renderCheck(freshIndex, config, { errorsOnly }));
338
+ if (freshIndex.errors.length > 0) process.exitCode = 1;
339
+ return;
340
+ }
341
+
342
+ process.stdout.write(renderCheck(index, config, { errorsOnly }));
269
343
  if (index.errors.length > 0) process.exitCode = 1;
270
344
  return;
271
345
  }
@@ -300,6 +374,25 @@ async function main() {
300
374
  if (command === 'query') { runQuery(index, restArgs, config); return; }
301
375
  if (command === 'context') { process.stdout.write(renderContext(index, config)); return; }
302
376
 
377
+ if (command === 'graph') {
378
+ const statusFilter = (() => { const i = args.indexOf('--status'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
379
+ const moduleFilter = (() => { const i = args.indexOf('--module'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
380
+ const surfaceFilter = (() => { const i = args.indexOf('--surface'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })();
381
+ const graph = buildGraph(index, config, {
382
+ statuses: statusFilter?.split(',') ?? null,
383
+ module: moduleFilter,
384
+ surface: surfaceFilter,
385
+ });
386
+ if (args.includes('--dot')) {
387
+ process.stdout.write(renderGraphDot(graph, config));
388
+ } else if (args.includes('--json')) {
389
+ process.stdout.write(renderGraphJson(graph));
390
+ } else {
391
+ process.stdout.write(renderGraphText(graph, config));
392
+ }
393
+ return;
394
+ }
395
+
303
396
  // Unknown command — show help
304
397
  die(`Unknown command: ${command}\n\n${HELP._main}`);
305
398
  }
@@ -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.7.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",
@@ -1,9 +1,9 @@
1
1
  import { die } from './util.mjs';
2
2
 
3
3
  const COMMANDS = [
4
- 'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
5
- 'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
6
- 'watch', 'diff', 'init', 'new', 'completions',
4
+ 'list', 'json', 'check', 'coverage', 'graph', 'context', 'focus', 'query',
5
+ 'index', 'status', 'archive', 'touch', 'doctor', 'lint', 'rename', 'migrate',
6
+ 'fix-refs', 'watch', 'diff', 'init', 'new', 'completions',
7
7
  ];
8
8
 
9
9
  const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
@@ -15,11 +15,15 @@ const COMMAND_FLAGS = {
15
15
  index: ['--write'],
16
16
  list: ['--verbose'],
17
17
  coverage: ['--json'],
18
- new: ['--status', '--title'],
18
+ new: ['--status', '--title', '--template', '--list-templates'],
19
19
  diff: ['--stat', '--since', '--summarize', '--model'],
20
+ check: ['--errors-only', '--fix'],
21
+ graph: ['--dot', '--json', '--status', '--module', '--surface'],
20
22
  lint: ['--fix'],
21
23
  rename: [],
22
24
  migrate: [],
25
+ 'fix-refs': [],
26
+ touch: ['--git'],
23
27
  };
24
28
 
25
29
  function bashCompletion() {
package/src/config.mjs CHANGED
@@ -55,6 +55,8 @@ const DEFAULTS = {
55
55
  unidirectional: [],
56
56
  },
57
57
 
58
+ templates: {},
59
+
58
60
  presets: {
59
61
  stale: ['--status', 'active,ready,planned,blocked,research', '--stale', '--sort', 'updated', '--all'],
60
62
  actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
package/src/doctor.mjs ADDED
@@ -0,0 +1,41 @@
1
+ import { fixBrokenRefs } from './fix-refs.mjs';
2
+ import { runLint } from './lint.mjs';
3
+ import { runTouch } from './lifecycle.mjs';
4
+ import { buildIndex } from './index.mjs';
5
+ import { renderIndexFile, writeIndex } from './index-file.mjs';
6
+ import { renderCheck } from './render.mjs';
7
+ import { bold } from './color.mjs';
8
+
9
+ export function runDoctor(argv, config, opts = {}) {
10
+ const { dryRun } = opts;
11
+ process.stdout.write(bold('dotmd doctor') + '\n\n');
12
+
13
+ // Step 1: Fix broken references
14
+ process.stdout.write(bold('1. Fixing broken references...') + '\n');
15
+ fixBrokenRefs(config, { dryRun });
16
+
17
+ // Step 2: Lint --fix
18
+ process.stdout.write('\n' + bold('2. Fixing frontmatter issues...') + '\n');
19
+ runLint(['--fix'], config, { dryRun });
20
+
21
+ // Step 3: Sync dates from git
22
+ process.stdout.write('\n' + bold('3. Syncing dates from git...') + '\n');
23
+ runTouch(['--git'], config, { dryRun });
24
+
25
+ // Step 4: Regenerate index
26
+ if (config.indexPath) {
27
+ process.stdout.write('\n' + bold('4. Regenerating index...') + '\n');
28
+ if (!dryRun) {
29
+ const index = buildIndex(config);
30
+ writeIndex(renderIndexFile(index, config), config);
31
+ process.stdout.write('Index updated.\n');
32
+ } else {
33
+ process.stdout.write('[dry-run] Would regenerate index.\n');
34
+ }
35
+ }
36
+
37
+ // Step 5: Show remaining check
38
+ process.stdout.write('\n' + bold('5. Remaining issues:') + '\n');
39
+ const freshIndex = buildIndex(config);
40
+ process.stdout.write(renderCheck(freshIndex, config));
41
+ }
@@ -39,6 +39,25 @@ export function extractNextStep(body) {
39
39
  return lines[0] ?? null;
40
40
  }
41
41
 
42
+ export function extractBodyLinks(body) {
43
+ if (!body) return [];
44
+ // Strip fenced code blocks to avoid false positives
45
+ const stripped = body.replace(/^```[\s\S]*?^```/gm, '');
46
+ const links = [];
47
+ // Match [text](path.md) or [text](path.md#anchor), skip images (preceded by !)
48
+ const regex = /(?<!!)\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g;
49
+ let match;
50
+ while ((match = regex.exec(stripped)) !== null) {
51
+ const href = match[2];
52
+ // Skip external URLs
53
+ if (/^https?:\/\//i.test(href)) continue;
54
+ // Strip anchor fragment for path resolution
55
+ const cleanHref = href.replace(/#.*$/, '');
56
+ links.push({ text: match[1], href: cleanHref });
57
+ }
58
+ return links;
59
+ }
60
+
42
61
  export function extractChecklistCounts(body) {
43
62
  const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+/gm)];
44
63
  let completed = 0;
@@ -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
+ }