dotmd-cli 0.6.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 +115 -41
- package/bin/dotmd.mjs +45 -0
- package/package.json +1 -1
- package/src/completions.mjs +4 -3
- package/src/config.mjs +2 -0
- package/src/doctor.mjs +41 -0
- package/src/extractors.mjs +19 -0
- package/src/graph.mjs +268 -0
- package/src/index.mjs +3 -1
- package/src/new.mjs +90 -7
- package/src/validate.mjs +8 -0
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
|
-
- **
|
|
42
|
-
- **
|
|
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
|
|
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 +
|
|
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
|
|
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
|
|
126
|
+
### Scaffold with Templates
|
|
118
127
|
|
|
119
128
|
```bash
|
|
120
|
-
dotmd new my-feature
|
|
121
|
-
dotmd new
|
|
122
|
-
dotmd new
|
|
123
|
-
dotmd new
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
151
|
+
### Check & Fix
|
|
138
152
|
|
|
139
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
@@ -18,6 +18,8 @@ import { runLint } from '../src/lint.mjs';
|
|
|
18
18
|
import { runRename } from '../src/rename.mjs';
|
|
19
19
|
import { runMigrate } from '../src/migrate.mjs';
|
|
20
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';
|
|
21
23
|
import { die, warn } from '../src/util.mjs';
|
|
22
24
|
|
|
23
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -32,6 +34,7 @@ Commands:
|
|
|
32
34
|
json Full index as JSON
|
|
33
35
|
check [flags] Validate frontmatter and references
|
|
34
36
|
coverage [--json] Metadata coverage report
|
|
37
|
+
graph [--dot|--json] Visualize document relationships
|
|
35
38
|
context Compact briefing (LLM-oriented)
|
|
36
39
|
focus [status] Detailed view for one status group
|
|
37
40
|
query [filters] Filtered search
|
|
@@ -39,6 +42,7 @@ Commands:
|
|
|
39
42
|
status <file> <status> Transition document status
|
|
40
43
|
archive <file> Archive (status + move + update refs)
|
|
41
44
|
touch <file> Bump updated date
|
|
45
|
+
doctor Auto-fix everything: refs, lint, dates, index
|
|
42
46
|
fix-refs Auto-fix broken reference paths
|
|
43
47
|
lint [--fix] Check and auto-fix frontmatter issues
|
|
44
48
|
rename <old> <new> Rename doc and update references
|
|
@@ -97,6 +101,25 @@ references in other docs, and regenerates the index.
|
|
|
97
101
|
|
|
98
102
|
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
99
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
|
+
|
|
100
123
|
'fix-refs': `dotmd fix-refs — auto-fix broken reference paths
|
|
101
124
|
|
|
102
125
|
Scans all docs for reference fields that point to non-existent files,
|
|
@@ -126,8 +149,10 @@ Use --dry-run (-n) with --write to preview without writing.`,
|
|
|
126
149
|
Creates a new markdown document with frontmatter in the docs root.
|
|
127
150
|
|
|
128
151
|
Options:
|
|
152
|
+
--template <name> Use a template (default, plan, adr, rfc, audit, design)
|
|
129
153
|
--status <s> Set initial status (default: active)
|
|
130
154
|
--title <t> Override the document title
|
|
155
|
+
--list-templates Show available templates
|
|
131
156
|
|
|
132
157
|
The filename is derived from <name> by slugifying it.
|
|
133
158
|
Use --dry-run (-n) to preview without creating the file.`,
|
|
@@ -269,6 +294,7 @@ async function main() {
|
|
|
269
294
|
if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
|
|
270
295
|
if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
|
|
271
296
|
if (command === 'fix-refs') { runFixRefs(restArgs, config, { dryRun }); return; }
|
|
297
|
+
if (command === 'doctor') { runDoctor(restArgs, config, { dryRun }); return; }
|
|
272
298
|
|
|
273
299
|
const index = buildIndex(config);
|
|
274
300
|
|
|
@@ -348,6 +374,25 @@ async function main() {
|
|
|
348
374
|
if (command === 'query') { runQuery(index, restArgs, config); return; }
|
|
349
375
|
if (command === 'context') { process.stdout.write(renderContext(index, config)); return; }
|
|
350
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
|
+
|
|
351
396
|
// Unknown command — show help
|
|
352
397
|
die(`Unknown command: ${command}\n\n${HELP._main}`);
|
|
353
398
|
}
|
package/package.json
CHANGED
package/src/completions.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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',
|
|
4
|
+
'list', 'json', 'check', 'coverage', 'graph', 'context', 'focus', 'query',
|
|
5
|
+
'index', 'status', 'archive', 'touch', 'doctor', 'lint', 'rename', 'migrate',
|
|
6
6
|
'fix-refs', 'watch', 'diff', 'init', 'new', 'completions',
|
|
7
7
|
];
|
|
8
8
|
|
|
@@ -15,9 +15,10 @@ 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
20
|
check: ['--errors-only', '--fix'],
|
|
21
|
+
graph: ['--dot', '--json', '--status', '--module', '--surface'],
|
|
21
22
|
lint: ['--fix'],
|
|
22
23
|
rename: [],
|
|
23
24
|
migrate: [],
|
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
|
+
}
|
package/src/extractors.mjs
CHANGED
|
@@ -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;
|
package/src/graph.mjs
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { toSlug, toRepoPath, warn } from './util.mjs';
|
|
3
|
+
import { bold, red, green, dim } from './color.mjs';
|
|
4
|
+
|
|
5
|
+
const STATUS_COLORS = {
|
|
6
|
+
active: '#b3e6b3',
|
|
7
|
+
ready: '#b3d9ff',
|
|
8
|
+
planned: '#ffffb3',
|
|
9
|
+
research: '#e6ccff',
|
|
10
|
+
blocked: '#ffb3b3',
|
|
11
|
+
reference: '#d9d9d9',
|
|
12
|
+
archived: '#e6e6e6',
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_COLOR = '#f2f2f2';
|
|
15
|
+
|
|
16
|
+
export function buildGraph(index, config, filters = {}) {
|
|
17
|
+
const biFields = new Set(config.referenceFields.bidirectional || []);
|
|
18
|
+
const uniFields = new Set(config.referenceFields.unidirectional || []);
|
|
19
|
+
const allRefFields = [...biFields, ...uniFields];
|
|
20
|
+
|
|
21
|
+
// Filter docs
|
|
22
|
+
let docs = index.docs;
|
|
23
|
+
if (filters.statuses?.length) {
|
|
24
|
+
docs = docs.filter(d => filters.statuses.includes(d.status));
|
|
25
|
+
}
|
|
26
|
+
if (filters.module) {
|
|
27
|
+
const m = filters.module.toLowerCase();
|
|
28
|
+
docs = docs.filter(d => (d.modules ?? []).some(mod => mod.toLowerCase() === m) || (d.module ?? '').toLowerCase() === m);
|
|
29
|
+
}
|
|
30
|
+
if (filters.surface) {
|
|
31
|
+
const s = filters.surface.toLowerCase();
|
|
32
|
+
docs = docs.filter(d => (d.surfaces ?? []).some(sf => sf.toLowerCase() === s) || (d.surface ?? '').toLowerCase() === s);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const docPathSet = new Set(docs.map(d => d.path));
|
|
36
|
+
const allDocPaths = new Set(index.docs.map(d => d.path));
|
|
37
|
+
const docByPath = new Map(index.docs.map(d => [d.path, d]));
|
|
38
|
+
|
|
39
|
+
// Build nodes
|
|
40
|
+
const nodes = docs.map(d => ({
|
|
41
|
+
id: d.path,
|
|
42
|
+
slug: toSlug(d),
|
|
43
|
+
title: d.title,
|
|
44
|
+
status: d.status,
|
|
45
|
+
module: d.module,
|
|
46
|
+
surface: d.surface,
|
|
47
|
+
edgeCount: 0,
|
|
48
|
+
}));
|
|
49
|
+
const nodeMap = new Map(nodes.map(n => [n.id, n]));
|
|
50
|
+
|
|
51
|
+
// Build edges
|
|
52
|
+
const edges = [];
|
|
53
|
+
const edgeKeys = new Set();
|
|
54
|
+
const referencedPaths = new Set();
|
|
55
|
+
|
|
56
|
+
for (const doc of docs) {
|
|
57
|
+
const docDir = path.dirname(path.join(config.repoRoot, doc.path));
|
|
58
|
+
|
|
59
|
+
for (const field of allRefFields) {
|
|
60
|
+
for (const relPath of (doc.refFields[field] || [])) {
|
|
61
|
+
const resolved = path.resolve(docDir, relPath);
|
|
62
|
+
const targetPath = toRepoPath(resolved, config.repoRoot);
|
|
63
|
+
const edgeKey = `${doc.path}|${targetPath}|${field}`;
|
|
64
|
+
if (edgeKeys.has(edgeKey)) continue;
|
|
65
|
+
edgeKeys.add(edgeKey);
|
|
66
|
+
|
|
67
|
+
const broken = !allDocPaths.has(targetPath);
|
|
68
|
+
const external = !broken && !docPathSet.has(targetPath);
|
|
69
|
+
|
|
70
|
+
edges.push({
|
|
71
|
+
source: doc.path,
|
|
72
|
+
target: targetPath,
|
|
73
|
+
field,
|
|
74
|
+
type: biFields.has(field) ? 'bidirectional' : 'unidirectional',
|
|
75
|
+
broken,
|
|
76
|
+
external,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
referencedPaths.add(targetPath);
|
|
80
|
+
referencedPaths.add(doc.path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Count edges per node
|
|
86
|
+
for (const edge of edges) {
|
|
87
|
+
if (nodeMap.has(edge.source)) nodeMap.get(edge.source).edgeCount++;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Find orphans (no outgoing or incoming edges among filtered docs)
|
|
91
|
+
const connectedPaths = new Set();
|
|
92
|
+
for (const edge of edges) {
|
|
93
|
+
connectedPaths.add(edge.source);
|
|
94
|
+
if (!edge.broken && !edge.external) connectedPaths.add(edge.target);
|
|
95
|
+
}
|
|
96
|
+
const orphans = docs.filter(d => !connectedPaths.has(d.path)).map(d => d.path);
|
|
97
|
+
|
|
98
|
+
const brokenEdges = edges.filter(e => e.broken);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
nodes,
|
|
102
|
+
edges,
|
|
103
|
+
orphans,
|
|
104
|
+
brokenEdges,
|
|
105
|
+
stats: {
|
|
106
|
+
nodeCount: nodes.length,
|
|
107
|
+
edgeCount: edges.length,
|
|
108
|
+
orphanCount: orphans.length,
|
|
109
|
+
brokenEdgeCount: brokenEdges.length,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Text renderer ──────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export function renderGraphText(graph, config) {
|
|
117
|
+
const defaultRenderer = (g) => _renderGraphText(g, config);
|
|
118
|
+
if (config.hooks.renderGraph) {
|
|
119
|
+
try { return config.hooks.renderGraph(graph, defaultRenderer); }
|
|
120
|
+
catch (err) { warn(`Hook 'renderGraph' threw: ${err.message}`); }
|
|
121
|
+
}
|
|
122
|
+
return defaultRenderer(graph);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _renderGraphText(graph, config) {
|
|
126
|
+
const { nodes, edges, orphans, stats } = graph;
|
|
127
|
+
|
|
128
|
+
if (stats.nodeCount === 0) return 'No documents found.\n';
|
|
129
|
+
|
|
130
|
+
const allRefFields = [
|
|
131
|
+
...(config.referenceFields.bidirectional || []),
|
|
132
|
+
...(config.referenceFields.unidirectional || []),
|
|
133
|
+
];
|
|
134
|
+
if (allRefFields.length === 0) {
|
|
135
|
+
return `Graph — ${stats.nodeCount} docs, no reference fields configured\n\nAdd \`referenceFields\` to your config to enable relationship tracking.\n`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lines = [];
|
|
139
|
+
const parts = [`${stats.nodeCount} docs`, `${stats.edgeCount} edges`];
|
|
140
|
+
if (stats.orphanCount > 0) parts.push(`${stats.orphanCount} orphans`);
|
|
141
|
+
if (stats.brokenEdgeCount > 0) parts.push(`${stats.brokenEdgeCount} broken`);
|
|
142
|
+
lines.push(bold(`Graph`) + dim(` — ${parts.join(', ')}`));
|
|
143
|
+
lines.push('');
|
|
144
|
+
|
|
145
|
+
// Compute max field name length for alignment
|
|
146
|
+
const fieldNames = [...new Set(edges.map(e => e.field))];
|
|
147
|
+
const maxFieldLen = fieldNames.length > 0 ? Math.max(...fieldNames.map(f => f.length)) : 0;
|
|
148
|
+
|
|
149
|
+
// Group edges by source
|
|
150
|
+
const edgesBySource = new Map();
|
|
151
|
+
for (const edge of edges) {
|
|
152
|
+
if (!edgesBySource.has(edge.source)) edgesBySource.set(edge.source, []);
|
|
153
|
+
edgesBySource.get(edge.source).push(edge);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Build a slug lookup for targets
|
|
157
|
+
const allDocByPath = new Map();
|
|
158
|
+
for (const n of nodes) allDocByPath.set(n.id, n.slug);
|
|
159
|
+
|
|
160
|
+
// Render each node with edges
|
|
161
|
+
const nodesWithEdges = nodes.filter(n => edgesBySource.has(n.id));
|
|
162
|
+
const nodesWithoutEdges = nodes.filter(n => !edgesBySource.has(n.id) && !orphans.includes(n.id));
|
|
163
|
+
|
|
164
|
+
for (const node of nodesWithEdges) {
|
|
165
|
+
lines.push(`${node.slug} ${dim(`(${node.status})`)}`);
|
|
166
|
+
for (const edge of edgesBySource.get(node.id)) {
|
|
167
|
+
const targetSlug = allDocByPath.get(edge.target) ?? path.basename(edge.target, '.md');
|
|
168
|
+
const fieldPad = edge.field.padEnd(maxFieldLen);
|
|
169
|
+
let line = ` ${'──'} ${fieldPad} ${'──'} ${targetSlug}`;
|
|
170
|
+
if (edge.broken) line += ' ' + red('[broken]');
|
|
171
|
+
if (edge.external) line += ' ' + dim('[external]');
|
|
172
|
+
lines.push(line);
|
|
173
|
+
}
|
|
174
|
+
lines.push('');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (orphans.length > 0) {
|
|
178
|
+
const orphanSlugs = orphans.map(p => path.basename(p, '.md'));
|
|
179
|
+
lines.push(`${dim('Orphans')}: ${orphanSlugs.join(', ')}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── DOT renderer ───────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
export function renderGraphDot(graph, config) {
|
|
189
|
+
const { nodes, edges } = graph;
|
|
190
|
+
const lines = [];
|
|
191
|
+
lines.push('digraph dotmd {');
|
|
192
|
+
lines.push(' rankdir=LR;');
|
|
193
|
+
lines.push(' node [shape=box, style="rounded,filled", fontname="Helvetica"];');
|
|
194
|
+
lines.push('');
|
|
195
|
+
|
|
196
|
+
// Nodes
|
|
197
|
+
const nodeSet = new Set(nodes.map(n => n.slug));
|
|
198
|
+
for (const node of nodes) {
|
|
199
|
+
const color = STATUS_COLORS[node.status] ?? DEFAULT_COLOR;
|
|
200
|
+
lines.push(` "${node.slug}" [label="${node.slug}\\n(${node.status ?? 'unknown'})", fillcolor="${color}"];`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Synthesize broken/external target nodes
|
|
204
|
+
const syntheticNodes = new Set();
|
|
205
|
+
for (const edge of edges) {
|
|
206
|
+
const targetSlug = path.basename(edge.target, '.md');
|
|
207
|
+
if (!nodeSet.has(targetSlug) && !syntheticNodes.has(targetSlug)) {
|
|
208
|
+
syntheticNodes.add(targetSlug);
|
|
209
|
+
if (edge.broken) {
|
|
210
|
+
lines.push(` "${targetSlug}" [label="${targetSlug}\\n(unknown)", style="rounded,dashed,filled", fillcolor="#ffb3b3"];`);
|
|
211
|
+
} else if (edge.external) {
|
|
212
|
+
lines.push(` "${targetSlug}" [label="${targetSlug}\\n(filtered)", style="rounded,dashed,filled", fillcolor="#e6e6e6"];`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
lines.push('');
|
|
218
|
+
|
|
219
|
+
// Detect mutual bidirectional edges for dir=both rendering
|
|
220
|
+
const biEdgeIndex = new Map();
|
|
221
|
+
for (const edge of edges) {
|
|
222
|
+
if (edge.type !== 'bidirectional') continue;
|
|
223
|
+
const key = `${edge.source}|${edge.target}|${edge.field}`;
|
|
224
|
+
biEdgeIndex.set(key, edge);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rendered = new Set();
|
|
228
|
+
for (const edge of edges) {
|
|
229
|
+
const sourceSlug = path.basename(edge.source, '.md');
|
|
230
|
+
const targetSlug = path.basename(edge.target, '.md');
|
|
231
|
+
const edgeKey = [edge.source, edge.target, edge.field].sort().join('|');
|
|
232
|
+
|
|
233
|
+
if (rendered.has(edgeKey)) continue;
|
|
234
|
+
rendered.add(edgeKey);
|
|
235
|
+
|
|
236
|
+
if (edge.broken) {
|
|
237
|
+
lines.push(` "${sourceSlug}" -> "${targetSlug}" [style=dashed, color=red, label="${edge.field}"];`);
|
|
238
|
+
} else if (edge.type === 'bidirectional') {
|
|
239
|
+
// Check if reverse edge exists
|
|
240
|
+
const reverseKey = `${edge.target}|${edge.source}|${edge.field}`;
|
|
241
|
+
if (biEdgeIndex.has(reverseKey)) {
|
|
242
|
+
lines.push(` "${sourceSlug}" -> "${targetSlug}" [dir=both, label="${edge.field}", color="#666666"];`);
|
|
243
|
+
} else {
|
|
244
|
+
lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#666666"];`);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
const style = edge.external ? ', style=dashed' : '';
|
|
248
|
+
lines.push(` "${sourceSlug}" -> "${targetSlug}" [label="${edge.field}", color="#999999"${style}];`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
lines.push('}');
|
|
253
|
+
return lines.join('\n') + '\n';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── JSON renderer ──────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export function renderGraphJson(graph) {
|
|
259
|
+
return JSON.stringify({
|
|
260
|
+
generatedAt: new Date().toISOString(),
|
|
261
|
+
stats: graph.stats,
|
|
262
|
+
nodes: graph.nodes,
|
|
263
|
+
edges: graph.edges.map(({ source, target, field, type, broken }) => ({
|
|
264
|
+
source, target, field, type, broken,
|
|
265
|
+
})),
|
|
266
|
+
orphans: graph.orphans,
|
|
267
|
+
}, null, 2) + '\n';
|
|
268
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
|
|
4
|
+
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
|
|
5
5
|
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn } from './util.mjs';
|
|
6
6
|
import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
|
|
7
7
|
import { checkIndex } from './index-file.mjs';
|
|
@@ -119,6 +119,7 @@ export function parseDocFile(filePath, config) {
|
|
|
119
119
|
const audience = asString(parsedFrontmatter.audience) ?? null;
|
|
120
120
|
const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
|
|
121
121
|
const checklist = extractChecklistCounts(body);
|
|
122
|
+
const bodyLinks = extractBodyLinks(body);
|
|
122
123
|
|
|
123
124
|
// Dynamic reference field extraction
|
|
124
125
|
const refFields = {};
|
|
@@ -148,6 +149,7 @@ export function parseDocFile(filePath, config) {
|
|
|
148
149
|
auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
|
|
149
150
|
sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
|
|
150
151
|
checklist,
|
|
152
|
+
bodyLinks,
|
|
151
153
|
refFields,
|
|
152
154
|
checklistCompletionRate: computeChecklistCompletionRate(checklist),
|
|
153
155
|
hasNextStep: Boolean(nextStep),
|
package/src/new.mjs
CHANGED
|
@@ -1,7 +1,40 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { toRepoPath, die, warn } from './util.mjs';
|
|
4
|
-
import { green, dim } from './color.mjs';
|
|
4
|
+
import { green, dim, bold } from './color.mjs';
|
|
5
|
+
|
|
6
|
+
const BUILTIN_TEMPLATES = {
|
|
7
|
+
default: {
|
|
8
|
+
description: 'Minimal document with status and updated date',
|
|
9
|
+
frontmatter: (s, d) => `status: ${s}\nupdated: ${d}`,
|
|
10
|
+
body: (t) => `\n# ${t}\n`,
|
|
11
|
+
},
|
|
12
|
+
plan: {
|
|
13
|
+
description: 'Execution plan with module, surface, and cross-references',
|
|
14
|
+
frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nsurface:\nmodule:\ncurrent_state:\nrelated_plans:`,
|
|
15
|
+
body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Implementation Plan\n\n- [ ] \n\n## Open Questions\n\n\n`,
|
|
16
|
+
},
|
|
17
|
+
adr: {
|
|
18
|
+
description: 'Architecture Decision Record',
|
|
19
|
+
frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\ndecision_date:\ndeciders:`,
|
|
20
|
+
body: (t) => `\n# ${t}\n\n## Context\n\n\n\n## Decision\n\n\n\n## Consequences\n\n\n`,
|
|
21
|
+
},
|
|
22
|
+
rfc: {
|
|
23
|
+
description: 'Request for Comments',
|
|
24
|
+
frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nreviewers:`,
|
|
25
|
+
body: (t) => `\n# ${t}\n\n## Summary\n\n\n\n## Motivation\n\n\n\n## Detailed Design\n\n\n\n## Alternatives\n\n\n\n## Open Questions\n\n\n`,
|
|
26
|
+
},
|
|
27
|
+
audit: {
|
|
28
|
+
description: 'Codebase audit or research investigation',
|
|
29
|
+
frontmatter: (s, d) => `status: research\nupdated: ${d}\naudited: ${d}\naudit_level: pass1\nmodule:\nsource_of_truth: code\nsupports_plans:`,
|
|
30
|
+
body: (t) => `\n# ${t}\n\n## Scope\n\n\n\n## Findings\n\n\n\n## Recommendations\n\n\n`,
|
|
31
|
+
},
|
|
32
|
+
design: {
|
|
33
|
+
description: 'Design document with goals, non-goals, and implementation plan',
|
|
34
|
+
frontmatter: (s, d) => `status: ${s}\nupdated: ${d}\nowner:\nsurface:\nmodule:\nrelated_plans:`,
|
|
35
|
+
body: (t) => `\n# ${t}\n\n## Overview\n\n\n\n## Goals\n\n\n\n## Non-Goals\n\n\n\n## Design\n\n\n\n## Implementation Plan\n\n- [ ] \n`,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
5
38
|
|
|
6
39
|
export function runNew(argv, config, opts = {}) {
|
|
7
40
|
const { dryRun } = opts;
|
|
@@ -10,20 +43,29 @@ export function runNew(argv, config, opts = {}) {
|
|
|
10
43
|
const positional = [];
|
|
11
44
|
let status = 'active';
|
|
12
45
|
let title = null;
|
|
46
|
+
let templateName = null;
|
|
13
47
|
for (let i = 0; i < argv.length; i++) {
|
|
14
48
|
if (argv[i] === '--status' && argv[i + 1]) { status = argv[++i]; continue; }
|
|
15
49
|
if (argv[i] === '--title' && argv[i + 1]) { title = argv[++i]; continue; }
|
|
50
|
+
if (argv[i] === '--template' && argv[i + 1]) { templateName = argv[++i]; continue; }
|
|
51
|
+
if (argv[i] === '--list-templates') {
|
|
52
|
+
listTemplates(config);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
16
55
|
if (!argv[i].startsWith('-')) positional.push(argv[i]);
|
|
17
56
|
}
|
|
18
57
|
|
|
19
58
|
const name = positional[0];
|
|
20
|
-
if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); }
|
|
59
|
+
if (!name) { die('Usage: dotmd new <name> [--template <t>] [--status <s>] [--title <t>]\n dotmd new --list-templates'); }
|
|
21
60
|
|
|
22
61
|
// Validate status
|
|
23
62
|
if (!config.validStatuses.has(status)) {
|
|
24
63
|
die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
|
|
25
64
|
}
|
|
26
65
|
|
|
66
|
+
// Resolve template
|
|
67
|
+
const template = resolveTemplate(templateName ?? 'default', config);
|
|
68
|
+
|
|
27
69
|
// Slugify
|
|
28
70
|
const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
29
71
|
if (!slug) { die('Name resolves to empty slug: ' + name); }
|
|
@@ -39,16 +81,57 @@ export function runNew(argv, config, opts = {}) {
|
|
|
39
81
|
die(`File already exists: ${repoPath}`);
|
|
40
82
|
}
|
|
41
83
|
|
|
84
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
85
|
+
|
|
86
|
+
// Generate content
|
|
87
|
+
let content;
|
|
88
|
+
if (typeof template === 'function') {
|
|
89
|
+
content = template(name, { status, title: docTitle, today });
|
|
90
|
+
} else {
|
|
91
|
+
const fm = template.frontmatter(status, today);
|
|
92
|
+
const body = template.body(docTitle);
|
|
93
|
+
content = `---\n${fm}\n---\n${body}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
42
96
|
if (dryRun) {
|
|
43
97
|
process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
|
|
98
|
+
if (templateName) process.stdout.write(`${dim('[dry-run]')} Template: ${templateName}\n`);
|
|
44
99
|
return;
|
|
45
100
|
}
|
|
46
101
|
|
|
47
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
48
|
-
const content = `---\nstatus: ${status}\nupdated: ${today}\n---\n\n# ${docTitle}\n`;
|
|
49
|
-
|
|
50
102
|
writeFileSync(filePath, content, 'utf8');
|
|
51
|
-
process.stdout.write(`${green('Created')}: ${repoPath}
|
|
103
|
+
process.stdout.write(`${green('Created')}: ${repoPath}`);
|
|
104
|
+
if (templateName) process.stdout.write(` ${dim(`(template: ${templateName})`)}`);
|
|
105
|
+
process.stdout.write('\n');
|
|
52
106
|
|
|
53
|
-
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
107
|
+
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, template: templateName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveTemplate(name, config) {
|
|
111
|
+
// Config templates take priority
|
|
112
|
+
const configTemplates = config.raw?.templates ?? {};
|
|
113
|
+
if (configTemplates[name]) return configTemplates[name];
|
|
114
|
+
if (BUILTIN_TEMPLATES[name]) return BUILTIN_TEMPLATES[name];
|
|
115
|
+
|
|
116
|
+
const available = [...new Set([...Object.keys(BUILTIN_TEMPLATES), ...Object.keys(configTemplates)])];
|
|
117
|
+
die(`Unknown template: ${name}\nAvailable: ${available.join(', ')}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function listTemplates(config) {
|
|
121
|
+
const configTemplates = config.raw?.templates ?? {};
|
|
122
|
+
const all = { ...BUILTIN_TEMPLATES };
|
|
123
|
+
for (const [k, v] of Object.entries(configTemplates)) {
|
|
124
|
+
all[k] = v;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
process.stdout.write(bold('Available templates') + '\n\n');
|
|
128
|
+
for (const [name, tmpl] of Object.entries(all)) {
|
|
129
|
+
const desc = typeof tmpl === 'function'
|
|
130
|
+
? '(custom function)'
|
|
131
|
+
: (tmpl.description ?? '');
|
|
132
|
+
const source = configTemplates[name] ? dim(' (config)') : '';
|
|
133
|
+
process.stdout.write(` ${name}${source}\n`);
|
|
134
|
+
if (desc) process.stdout.write(` ${dim(desc)}\n`);
|
|
135
|
+
process.stdout.write('\n');
|
|
136
|
+
}
|
|
54
137
|
}
|
package/src/validate.mjs
CHANGED
|
@@ -76,6 +76,14 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
// Validate body links resolve to existing files
|
|
81
|
+
for (const link of (doc.bodyLinks || [])) {
|
|
82
|
+
const resolved = path.resolve(docDir, link.href);
|
|
83
|
+
if (!existsSync(resolved)) {
|
|
84
|
+
doc.warnings.push({ path: doc.path, level: 'warning', message: `body link \`${link.href}\` does not resolve to an existing file.` });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export function checkBidirectionalReferences(docs, config) {
|