dotmd-cli 0.3.0 → 0.5.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 +53 -1
- package/bin/dotmd.mjs +44 -2
- package/package.json +1 -1
- package/src/completions.mjs +5 -2
- package/src/config.mjs +58 -16
- package/src/diff.mjs +15 -3
- package/src/frontmatter.mjs +18 -1
- package/src/git.mjs +13 -1
- package/src/index.mjs +22 -10
- package/src/lifecycle.mjs +24 -22
- package/src/lint.mjs +183 -0
- package/src/migrate.mjs +55 -0
- package/src/new.mjs +5 -5
- package/src/rename.mjs +146 -0
- package/src/render.mjs +9 -5
- package/src/util.mjs +12 -2
package/README.md
CHANGED
|
@@ -83,6 +83,9 @@ dotmd index [--write] Generate/update docs.md index block
|
|
|
83
83
|
dotmd status <file> <status> Transition document status
|
|
84
84
|
dotmd archive <file> Archive (status + move + index regen)
|
|
85
85
|
dotmd touch <file> Bump updated date
|
|
86
|
+
dotmd lint [--fix] Check and auto-fix frontmatter issues
|
|
87
|
+
dotmd rename <old> <new> Rename doc and update references
|
|
88
|
+
dotmd migrate <f> <old> <new> Batch update a frontmatter field
|
|
86
89
|
dotmd watch [command] Re-run a command on file changes
|
|
87
90
|
dotmd diff [file] Show changes since last updated date
|
|
88
91
|
dotmd new <name> Create a new document with frontmatter
|
|
@@ -133,6 +136,44 @@ export const presets = {
|
|
|
133
136
|
|
|
134
137
|
Then run `dotmd stale` or `dotmd mine` as shorthand.
|
|
135
138
|
|
|
139
|
+
### Lint
|
|
140
|
+
|
|
141
|
+
Check docs for fixable frontmatter issues and optionally auto-fix them:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
dotmd lint # report issues
|
|
145
|
+
dotmd lint --fix # fix all issues
|
|
146
|
+
dotmd lint --fix --dry-run # preview fixes without writing
|
|
147
|
+
```
|
|
148
|
+
|
|
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
|
|
155
|
+
|
|
156
|
+
### Rename
|
|
157
|
+
|
|
158
|
+
Rename a document and update all frontmatter references across your docs:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
dotmd rename old-name.md new-name # renames + updates refs
|
|
162
|
+
dotmd rename old-name.md new-name -n # preview without writing
|
|
163
|
+
```
|
|
164
|
+
|
|
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.
|
|
166
|
+
|
|
167
|
+
### Migrate
|
|
168
|
+
|
|
169
|
+
Batch update a frontmatter field value across all docs:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
dotmd migrate status research exploration # rename a status
|
|
173
|
+
dotmd migrate module auth identity # rename a module
|
|
174
|
+
dotmd migrate module auth identity -n # preview
|
|
175
|
+
```
|
|
176
|
+
|
|
136
177
|
### Watch Mode
|
|
137
178
|
|
|
138
179
|
```bash
|
|
@@ -231,7 +272,18 @@ export function onStatusChange(doc, { oldStatus, newStatus }) {
|
|
|
231
272
|
}
|
|
232
273
|
```
|
|
233
274
|
|
|
234
|
-
Available: `onArchive`, `onStatusChange`, `onTouch`.
|
|
275
|
+
Available: `onArchive`, `onStatusChange`, `onTouch`, `onNew`, `onRename`, `onLint`.
|
|
276
|
+
|
|
277
|
+
### Summarize Hook
|
|
278
|
+
|
|
279
|
+
Override the diff summarizer (replaces the default MLX model call):
|
|
280
|
+
|
|
281
|
+
```js
|
|
282
|
+
export function summarizeDiff(diffOutput, filePath) {
|
|
283
|
+
// call your preferred LLM, return a string summary
|
|
284
|
+
return `Changes in ${filePath}: ...`;
|
|
285
|
+
}
|
|
286
|
+
```
|
|
235
287
|
|
|
236
288
|
## Features
|
|
237
289
|
|
package/bin/dotmd.mjs
CHANGED
|
@@ -14,6 +14,9 @@ import { runNew } from '../src/new.mjs';
|
|
|
14
14
|
import { runCompletions } from '../src/completions.mjs';
|
|
15
15
|
import { runWatch } from '../src/watch.mjs';
|
|
16
16
|
import { runDiff } from '../src/diff.mjs';
|
|
17
|
+
import { runLint } from '../src/lint.mjs';
|
|
18
|
+
import { runRename } from '../src/rename.mjs';
|
|
19
|
+
import { runMigrate } from '../src/migrate.mjs';
|
|
17
20
|
import { die, warn } from '../src/util.mjs';
|
|
18
21
|
|
|
19
22
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -35,6 +38,9 @@ Commands:
|
|
|
35
38
|
status <file> <status> Transition document status
|
|
36
39
|
archive <file> Archive (status + move + index regen)
|
|
37
40
|
touch <file> Bump updated date
|
|
41
|
+
lint [--fix] Check and auto-fix frontmatter issues
|
|
42
|
+
rename <old> <new> Rename doc and update references
|
|
43
|
+
migrate <f> <old> <new> Batch update a frontmatter field
|
|
38
44
|
watch [command] Re-run a command on file changes
|
|
39
45
|
diff [file] Show changes since last updated date
|
|
40
46
|
new <name> Create a new document with frontmatter
|
|
@@ -122,6 +128,33 @@ Options:
|
|
|
122
128
|
--summarize Generate AI summary using local MLX model
|
|
123
129
|
--model <name> MLX model to use (default: mlx-community/Llama-3.2-3B-Instruct-4bit)`,
|
|
124
130
|
|
|
131
|
+
lint: `dotmd lint [--fix] — check and auto-fix frontmatter issues
|
|
132
|
+
|
|
133
|
+
Scans all docs for fixable problems: missing updated dates, status casing,
|
|
134
|
+
camelCase key names, trailing whitespace in values, missing EOF newline.
|
|
135
|
+
|
|
136
|
+
Without --fix, reports all issues. With --fix, applies fixes in place.
|
|
137
|
+
Use --dry-run (-n) with --fix to preview without writing anything.`,
|
|
138
|
+
|
|
139
|
+
rename: `dotmd rename <old> <new> — rename doc and update references
|
|
140
|
+
|
|
141
|
+
Renames a document using git mv and updates all frontmatter references
|
|
142
|
+
in other docs that point to the old filename.
|
|
143
|
+
|
|
144
|
+
Body markdown links are warned about but not auto-fixed.
|
|
145
|
+
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
146
|
+
|
|
147
|
+
migrate: `dotmd migrate <field> <old-value> <new-value> — batch update a frontmatter field
|
|
148
|
+
|
|
149
|
+
Finds all docs where the given field equals old-value and updates it
|
|
150
|
+
to new-value.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
dotmd migrate status research exploration
|
|
154
|
+
dotmd migrate module auth identity
|
|
155
|
+
|
|
156
|
+
Use --dry-run (-n) to preview changes without writing anything.`,
|
|
157
|
+
|
|
125
158
|
init: `dotmd init — create starter config and docs directory
|
|
126
159
|
|
|
127
160
|
Creates dotmd.config.mjs, docs/, and docs/docs.md in the current
|
|
@@ -179,6 +212,12 @@ async function main() {
|
|
|
179
212
|
warn('No dotmd config found — using defaults. Run `dotmd init` to create one.');
|
|
180
213
|
}
|
|
181
214
|
|
|
215
|
+
if (config.configWarnings && config.configWarnings.length > 0) {
|
|
216
|
+
for (const w of config.configWarnings) {
|
|
217
|
+
warn(w);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
182
221
|
if (verbose) {
|
|
183
222
|
process.stderr.write(`Config: ${config.configPath ?? 'none'}\n`);
|
|
184
223
|
process.stderr.write(`Docs root: ${config.docsRoot}\n`);
|
|
@@ -201,6 +240,9 @@ async function main() {
|
|
|
201
240
|
if (command === 'archive') { runArchive(restArgs, config, { dryRun }); return; }
|
|
202
241
|
if (command === 'touch') { runTouch(restArgs, config, { dryRun }); return; }
|
|
203
242
|
if (command === 'new') { runNew(restArgs, config, { dryRun }); return; }
|
|
243
|
+
if (command === 'lint') { runLint(restArgs, config, { dryRun }); return; }
|
|
244
|
+
if (command === 'rename') { runRename(restArgs, config, { dryRun }); return; }
|
|
245
|
+
if (command === 'migrate') { runMigrate(restArgs, config, { dryRun }); return; }
|
|
204
246
|
|
|
205
247
|
const index = buildIndex(config);
|
|
206
248
|
|
|
@@ -240,7 +282,6 @@ async function main() {
|
|
|
240
282
|
if (command === 'index') {
|
|
241
283
|
if (!config.indexPath) {
|
|
242
284
|
die('Index generation is not configured. Add an `index` section to your dotmd.config.mjs.');
|
|
243
|
-
return;
|
|
244
285
|
}
|
|
245
286
|
const write = args.includes('--write');
|
|
246
287
|
const rendered = renderIndexFile(index, config);
|
|
@@ -264,5 +305,6 @@ async function main() {
|
|
|
264
305
|
}
|
|
265
306
|
|
|
266
307
|
main().catch(err => {
|
|
267
|
-
|
|
308
|
+
process.stderr.write(`${err.message}\n`);
|
|
309
|
+
process.exitCode = 1;
|
|
268
310
|
});
|
package/package.json
CHANGED
package/src/completions.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import { die } from './util.mjs';
|
|
|
2
2
|
|
|
3
3
|
const COMMANDS = [
|
|
4
4
|
'list', 'json', 'check', 'coverage', 'context', 'focus', 'query',
|
|
5
|
-
'index', 'status', 'archive', 'touch', '
|
|
5
|
+
'index', 'status', 'archive', 'touch', 'lint', 'rename', 'migrate',
|
|
6
|
+
'watch', 'diff', 'init', 'new', 'completions',
|
|
6
7
|
];
|
|
7
8
|
|
|
8
9
|
const GLOBAL_FLAGS = ['--config', '--dry-run', '--verbose', '--help', '--version'];
|
|
@@ -16,6 +17,9 @@ const COMMAND_FLAGS = {
|
|
|
16
17
|
coverage: ['--json'],
|
|
17
18
|
new: ['--status', '--title'],
|
|
18
19
|
diff: ['--stat', '--since', '--summarize', '--model'],
|
|
20
|
+
lint: ['--fix'],
|
|
21
|
+
rename: [],
|
|
22
|
+
migrate: [],
|
|
19
23
|
};
|
|
20
24
|
|
|
21
25
|
function bashCompletion() {
|
|
@@ -86,7 +90,6 @@ export function runCompletions(argv) {
|
|
|
86
90
|
const shell = argv[0];
|
|
87
91
|
if (!shell) {
|
|
88
92
|
die('Usage: dotmd completions <bash|zsh>');
|
|
89
|
-
return;
|
|
90
93
|
}
|
|
91
94
|
if (shell === 'bash') {
|
|
92
95
|
process.stdout.write(bashCompletion() + '\n');
|
package/src/config.mjs
CHANGED
|
@@ -76,6 +76,59 @@ function findConfigFile(startDir) {
|
|
|
76
76
|
return null;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
const VALID_CONFIG_KEYS = new Set(Object.keys(DEFAULTS));
|
|
80
|
+
|
|
81
|
+
function validateConfig(userConfig, config, validStatuses, indexPath) {
|
|
82
|
+
const warnings = [];
|
|
83
|
+
|
|
84
|
+
// statuses.order must be array
|
|
85
|
+
if (config.statuses && config.statuses.order !== undefined && !Array.isArray(config.statuses.order)) {
|
|
86
|
+
warnings.push('Config: statuses.order must be an array.');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// archiveDir must be string
|
|
90
|
+
if (config.archiveDir !== undefined && typeof config.archiveDir !== 'string') {
|
|
91
|
+
warnings.push('Config: archiveDir must be a string.');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// lifecycle.archiveStatuses values must exist in validStatuses
|
|
95
|
+
if (config.lifecycle?.archiveStatuses) {
|
|
96
|
+
for (const s of config.lifecycle.archiveStatuses) {
|
|
97
|
+
if (!validStatuses.has(s)) {
|
|
98
|
+
warnings.push(`Config: lifecycle.archiveStatuses contains unknown status '${s}'.`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// staleDays keys must exist in validStatuses
|
|
104
|
+
if (config.statuses?.staleDays) {
|
|
105
|
+
for (const key of Object.keys(config.statuses.staleDays)) {
|
|
106
|
+
if (!validStatuses.has(key)) {
|
|
107
|
+
warnings.push(`Config: statuses.staleDays contains unknown status '${key}'.`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// taxonomy.surfaces must be null or array
|
|
113
|
+
if (config.taxonomy?.surfaces !== undefined && config.taxonomy.surfaces !== null && !Array.isArray(config.taxonomy.surfaces)) {
|
|
114
|
+
warnings.push('Config: taxonomy.surfaces must be null or an array.');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// index path file exists (if configured)
|
|
118
|
+
if (indexPath && !existsSync(indexPath)) {
|
|
119
|
+
warnings.push(`Config: index path does not exist: ${indexPath}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Unknown top-level user config keys
|
|
123
|
+
for (const key of Object.keys(userConfig)) {
|
|
124
|
+
if (!VALID_CONFIG_KEYS.has(key)) {
|
|
125
|
+
warnings.push(`Config: unknown key '${key}'.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return warnings;
|
|
130
|
+
}
|
|
131
|
+
|
|
79
132
|
function deepMerge(defaults, overrides) {
|
|
80
133
|
const result = { ...defaults };
|
|
81
134
|
for (const [key, value] of Object.entries(overrides)) {
|
|
@@ -105,21 +158,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
105
158
|
mod = await import(configUrl);
|
|
106
159
|
} catch (err) {
|
|
107
160
|
die('Failed to load config: ' + configPath + '\n' + err.message + '\nRun `dotmd init` to create a starter config.');
|
|
108
|
-
// Return defaults so caller can still function
|
|
109
|
-
const defaults = deepMerge(DEFAULTS, {});
|
|
110
|
-
const defaultStatusOrder = defaults.statuses.order;
|
|
111
|
-
return {
|
|
112
|
-
raw: defaults, docsRoot: cwd, repoRoot: cwd, configDir: cwd,
|
|
113
|
-
configPath: configPath ?? null, configFound: Boolean(configPath),
|
|
114
|
-
archiveDir: defaults.archiveDir, excludeDirs: new Set(defaults.excludeDirs),
|
|
115
|
-
docsRootPrefix: '', statusOrder: defaultStatusOrder,
|
|
116
|
-
validStatuses: new Set(defaultStatusOrder), staleDaysByStatus: {},
|
|
117
|
-
lifecycle: { archiveStatuses: new Set(defaults.lifecycle.archiveStatuses), skipStaleFor: new Set(defaults.lifecycle.skipStaleFor), skipWarningsFor: new Set(defaults.lifecycle.skipWarningsFor) },
|
|
118
|
-
validSurfaces: null, moduleRequiredStatuses: new Set(),
|
|
119
|
-
indexPath: null, indexStartMarker: '<!-- GENERATED:dotmd:start -->', indexEndMarker: '<!-- GENERATED:dotmd:end -->', archivedHighlightLimit: 8,
|
|
120
|
-
context: defaults.context, display: defaults.display,
|
|
121
|
-
referenceFields: defaults.referenceFields, presets: defaults.presets, hooks: {},
|
|
122
|
-
};
|
|
123
161
|
}
|
|
124
162
|
|
|
125
163
|
configDir = path.dirname(configPath);
|
|
@@ -144,8 +182,9 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
144
182
|
|
|
145
183
|
const docsRoot = path.resolve(configDir, config.root);
|
|
146
184
|
|
|
185
|
+
const earlyWarnings = [];
|
|
147
186
|
if (!existsSync(docsRoot)) {
|
|
148
|
-
|
|
187
|
+
earlyWarnings.push('Config: docs root does not exist: ' + docsRoot);
|
|
149
188
|
}
|
|
150
189
|
|
|
151
190
|
// Find repo root by walking up looking for .git
|
|
@@ -188,6 +227,8 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
188
227
|
const skipStaleFor = new Set(lifecycle.skipStaleFor);
|
|
189
228
|
const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
|
|
190
229
|
|
|
230
|
+
const configWarnings = [...earlyWarnings, ...validateConfig(userConfig, config, validStatuses, indexPath)];
|
|
231
|
+
|
|
191
232
|
return {
|
|
192
233
|
raw: config,
|
|
193
234
|
|
|
@@ -219,5 +260,6 @@ export async function resolveConfig(cwd, explicitConfigPath) {
|
|
|
219
260
|
referenceFields: config.referenceFields,
|
|
220
261
|
presets: config.presets,
|
|
221
262
|
hooks,
|
|
263
|
+
configWarnings,
|
|
222
264
|
};
|
|
223
265
|
}
|
package/src/diff.mjs
CHANGED
|
@@ -27,7 +27,6 @@ export function runDiff(argv, config) {
|
|
|
27
27
|
const filePath = resolveDocPath(file, config);
|
|
28
28
|
if (!filePath) {
|
|
29
29
|
die(`File not found: ${file}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
30
|
-
return;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
32
|
const raw = readFileSync(filePath, 'utf8');
|
|
@@ -37,7 +36,6 @@ export function runDiff(argv, config) {
|
|
|
37
36
|
|
|
38
37
|
if (!since) {
|
|
39
38
|
die(`No updated date found in ${file} and no --since provided.`);
|
|
40
|
-
return;
|
|
41
39
|
}
|
|
42
40
|
|
|
43
41
|
const relPath = toRepoPath(filePath, config.repoRoot);
|
|
@@ -80,7 +78,15 @@ function printFileDiff(relPath, since, diffOutput, opts) {
|
|
|
80
78
|
process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
|
|
81
79
|
|
|
82
80
|
if (opts.summarize) {
|
|
83
|
-
|
|
81
|
+
let summary;
|
|
82
|
+
try {
|
|
83
|
+
summary = opts.config?.hooks?.summarizeDiff
|
|
84
|
+
? opts.config.hooks.summarizeDiff(diffOutput, relPath)
|
|
85
|
+
: summarizeWithMLX(diffOutput, relPath, opts.model);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
warn(`Hook 'summarizeDiff' threw: ${err.message}`);
|
|
88
|
+
summary = null;
|
|
89
|
+
}
|
|
84
90
|
if (summary) {
|
|
85
91
|
process.stdout.write(dim(` Summary: ${summary}`) + '\n');
|
|
86
92
|
} else {
|
|
@@ -93,6 +99,12 @@ function printFileDiff(relPath, since, diffOutput, opts) {
|
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
function summarizeWithMLX(diffText, filePath, model) {
|
|
102
|
+
const uvCheck = spawnSync('uv', ['--version'], { encoding: 'utf8' });
|
|
103
|
+
if (uvCheck.error) {
|
|
104
|
+
warn('uv is not installed. Install it to enable --summarize: https://docs.astral.sh/uv/');
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
96
108
|
const prompt = `Summarize this git diff in 1-2 sentences. Focus on what changed semantically, not line counts.\n\nFile: ${filePath}\n\n${diffText.slice(0, 4000)}`;
|
|
97
109
|
|
|
98
110
|
const result = spawnSync('uv', [
|
package/src/frontmatter.mjs
CHANGED
|
@@ -14,6 +14,14 @@ export function extractFrontmatter(raw) {
|
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export function replaceFrontmatter(raw, newFrontmatter) {
|
|
18
|
+
if (!raw.startsWith('---\n')) return raw;
|
|
19
|
+
const endMarker = raw.indexOf('\n---\n', 4);
|
|
20
|
+
if (endMarker === -1) return raw;
|
|
21
|
+
const body = raw.slice(endMarker + 5);
|
|
22
|
+
return `---\n${newFrontmatter}\n---\n${body}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
export function parseSimpleFrontmatter(text) {
|
|
18
26
|
const data = {};
|
|
19
27
|
let currentArrayKey = null;
|
|
@@ -25,6 +33,10 @@ export function parseSimpleFrontmatter(text) {
|
|
|
25
33
|
const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
26
34
|
if (keyMatch) {
|
|
27
35
|
const [, key, rawValue] = keyMatch;
|
|
36
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
37
|
+
currentArrayKey = null;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
28
40
|
if (!rawValue.trim()) {
|
|
29
41
|
data[key] = [];
|
|
30
42
|
currentArrayKey = key;
|
|
@@ -48,7 +60,12 @@ export function parseSimpleFrontmatter(text) {
|
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
function parseScalar(value) {
|
|
51
|
-
|
|
63
|
+
let unquoted = value;
|
|
64
|
+
if (value.length > 1 &&
|
|
65
|
+
((value.startsWith("'") && value.endsWith("'")) ||
|
|
66
|
+
(value.startsWith('"') && value.endsWith('"')))) {
|
|
67
|
+
unquoted = value.slice(1, -1);
|
|
68
|
+
}
|
|
52
69
|
if (unquoted === 'true') return true;
|
|
53
70
|
if (unquoted === 'false') return false;
|
|
54
71
|
return unquoted;
|
package/src/git.mjs
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
2
|
|
|
3
|
+
let gitChecked = false;
|
|
4
|
+
function ensureGit() {
|
|
5
|
+
if (gitChecked) return;
|
|
6
|
+
const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
|
|
7
|
+
if (result.error) {
|
|
8
|
+
throw new Error('git is not installed or not found in PATH. dotmd requires git for this operation.');
|
|
9
|
+
}
|
|
10
|
+
gitChecked = true;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
export function getGitLastModified(relPath, repoRoot) {
|
|
4
14
|
const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
|
|
5
15
|
cwd: repoRoot,
|
|
6
16
|
encoding: 'utf8',
|
|
7
17
|
});
|
|
8
|
-
if (result.status !== 0 || !result.stdout.trim()) return null;
|
|
18
|
+
if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
|
|
9
19
|
return result.stdout.trim();
|
|
10
20
|
}
|
|
11
21
|
|
|
12
22
|
export function gitMv(source, target, repoRoot) {
|
|
23
|
+
ensureGit();
|
|
13
24
|
const result = spawnSync('git', ['mv', source, target], {
|
|
14
25
|
cwd: repoRoot,
|
|
15
26
|
encoding: 'utf8',
|
|
@@ -18,6 +29,7 @@ export function gitMv(source, target, repoRoot) {
|
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
|
|
32
|
+
ensureGit();
|
|
21
33
|
// Find the last commit at or before sinceDate
|
|
22
34
|
const baseline = spawnSync('git', [
|
|
23
35
|
'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
|
package/src/index.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
|
|
5
|
-
import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath } from './util.mjs';
|
|
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';
|
|
8
8
|
|
|
@@ -19,20 +19,32 @@ export function buildIndex(config) {
|
|
|
19
19
|
if (config.hooks.validate) {
|
|
20
20
|
const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
|
|
21
21
|
for (const doc of docs) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
try {
|
|
23
|
+
const result = config.hooks.validate(doc, ctx);
|
|
24
|
+
if (result?.errors) {
|
|
25
|
+
doc.errors.push(...result.errors);
|
|
26
|
+
errors.push(...result.errors);
|
|
27
|
+
}
|
|
28
|
+
if (result?.warnings) {
|
|
29
|
+
doc.warnings.push(...result.warnings);
|
|
30
|
+
warnings.push(...result.warnings);
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
const hookError = { path: doc.path, level: 'error', message: `Hook 'validate' threw: ${err.message}` };
|
|
34
|
+
doc.errors.push(hookError);
|
|
35
|
+
errors.push(hookError);
|
|
30
36
|
}
|
|
31
37
|
}
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
const transformedDocs = config.hooks.transformDoc
|
|
35
|
-
? docs.map(d =>
|
|
41
|
+
? docs.map(d => {
|
|
42
|
+
try { return config.hooks.transformDoc(d) ?? d; }
|
|
43
|
+
catch (err) {
|
|
44
|
+
warnings.push({ path: d.path, level: 'warning', message: `Hook 'transformDoc' threw: ${err.message}` });
|
|
45
|
+
return d;
|
|
46
|
+
}
|
|
47
|
+
})
|
|
36
48
|
: docs;
|
|
37
49
|
|
|
38
50
|
const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
|
package/src/lifecycle.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
4
|
-
import { asString, toRepoPath, die, resolveDocPath } from './util.mjs';
|
|
4
|
+
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
|
|
5
5
|
import { gitMv } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
@@ -12,11 +12,11 @@ export function runStatus(argv, config, opts = {}) {
|
|
|
12
12
|
const input = argv[0];
|
|
13
13
|
const newStatus = argv[1];
|
|
14
14
|
|
|
15
|
-
if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>');
|
|
16
|
-
if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`);
|
|
15
|
+
if (!input || !newStatus) { die('Usage: dotmd status <file> <new-status>'); }
|
|
16
|
+
if (!config.validStatuses.has(newStatus)) { die(`Invalid status: ${newStatus}\nValid: ${[...config.validStatuses].join(', ')}`); }
|
|
17
17
|
|
|
18
18
|
const filePath = resolveDocPath(input, config);
|
|
19
|
-
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
19
|
+
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
20
20
|
|
|
21
21
|
const raw = readFileSync(filePath, 'utf8');
|
|
22
22
|
const { frontmatter } = extractFrontmatter(raw);
|
|
@@ -57,18 +57,19 @@ export function runStatus(argv, config, opts = {}) {
|
|
|
57
57
|
updateFrontmatter(filePath, { status: newStatus, updated: today });
|
|
58
58
|
|
|
59
59
|
if (isArchiving) {
|
|
60
|
+
mkdirSync(archiveDir, { recursive: true });
|
|
60
61
|
const targetPath = path.join(archiveDir, path.basename(filePath));
|
|
61
|
-
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`);
|
|
62
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
62
63
|
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
63
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.');
|
|
64
|
+
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
64
65
|
finalPath = targetPath;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
if (isUnarchiving) {
|
|
68
69
|
const targetPath = path.join(config.docsRoot, path.basename(filePath));
|
|
69
|
-
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`);
|
|
70
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
70
71
|
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
71
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.');
|
|
72
|
+
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
72
73
|
finalPath = targetPath;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -79,21 +80,21 @@ export function runStatus(argv, config, opts = {}) {
|
|
|
79
80
|
|
|
80
81
|
process.stdout.write(`${green(toRepoPath(finalPath, config.repoRoot))}: ${oldStatus ?? 'unknown'} → ${newStatus}\n`);
|
|
81
82
|
|
|
82
|
-
config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
|
|
83
|
+
try { config.hooks.onStatusChange?.({ path: toRepoPath(finalPath, config.repoRoot), oldStatus, newStatus }, {
|
|
83
84
|
oldPath: toRepoPath(filePath, config.repoRoot),
|
|
84
85
|
newPath: toRepoPath(finalPath, config.repoRoot),
|
|
85
|
-
});
|
|
86
|
+
}); } catch (err) { warn(`Hook 'onStatusChange' threw: ${err.message}`); }
|
|
86
87
|
}
|
|
87
88
|
|
|
88
89
|
export function runArchive(argv, config, opts = {}) {
|
|
89
90
|
const { dryRun } = opts;
|
|
90
91
|
const input = argv[0];
|
|
91
92
|
|
|
92
|
-
if (!input) { die('Usage: dotmd archive <file>');
|
|
93
|
+
if (!input) { die('Usage: dotmd archive <file>'); }
|
|
93
94
|
|
|
94
95
|
const filePath = resolveDocPath(input, config);
|
|
95
|
-
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
96
|
-
if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`);
|
|
96
|
+
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
97
|
+
if (filePath.includes(`/${config.archiveDir}/`)) { die(`Already archived: ${toRepoPath(filePath, config.repoRoot)}`); }
|
|
97
98
|
|
|
98
99
|
const raw = readFileSync(filePath, 'utf8');
|
|
99
100
|
const { frontmatter } = extractFrontmatter(raw);
|
|
@@ -109,7 +110,7 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
109
110
|
if (dryRun) {
|
|
110
111
|
const prefix = dim('[dry-run]');
|
|
111
112
|
process.stdout.write(`${prefix} Would update frontmatter: status: ${oldStatus} → archived, updated: ${today}\n`);
|
|
112
|
-
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`);
|
|
113
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
113
114
|
process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
114
115
|
if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
|
|
115
116
|
|
|
@@ -133,10 +134,11 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
133
134
|
|
|
134
135
|
updateFrontmatter(filePath, { status: 'archived', updated: today });
|
|
135
136
|
|
|
136
|
-
|
|
137
|
+
mkdirSync(targetDir, { recursive: true });
|
|
138
|
+
if (existsSync(targetPath)) { die(`Target already exists: ${toRepoPath(targetPath, config.repoRoot)}`); }
|
|
137
139
|
|
|
138
140
|
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
139
|
-
if (result.status !== 0) { die(result.stderr || 'git mv failed.');
|
|
141
|
+
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
140
142
|
|
|
141
143
|
if (config.indexPath) {
|
|
142
144
|
const index = buildIndex(config);
|
|
@@ -163,17 +165,17 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
163
165
|
}
|
|
164
166
|
|
|
165
167
|
process.stdout.write('\nNext: commit, then update references if needed.\n');
|
|
166
|
-
config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath });
|
|
168
|
+
try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
export function runTouch(argv, config, opts = {}) {
|
|
170
172
|
const { dryRun } = opts;
|
|
171
173
|
const input = argv[0];
|
|
172
174
|
|
|
173
|
-
if (!input) { die('Usage: dotmd touch <file>');
|
|
175
|
+
if (!input) { die('Usage: dotmd touch <file>'); }
|
|
174
176
|
|
|
175
177
|
const filePath = resolveDocPath(input, config);
|
|
176
|
-
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
178
|
+
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
177
179
|
|
|
178
180
|
const today = new Date().toISOString().slice(0, 10);
|
|
179
181
|
|
|
@@ -185,7 +187,7 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
185
187
|
updateFrontmatter(filePath, { updated: today });
|
|
186
188
|
process.stdout.write(`${green('Touched')}: ${toRepoPath(filePath, config.repoRoot)} (updated → ${today})\n`);
|
|
187
189
|
|
|
188
|
-
config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today });
|
|
190
|
+
try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
|
|
189
191
|
}
|
|
190
192
|
|
|
191
193
|
export function updateFrontmatter(filePath, updates) {
|
|
@@ -199,7 +201,7 @@ export function updateFrontmatter(filePath, updates) {
|
|
|
199
201
|
const body = raw.slice(endMarker + 5);
|
|
200
202
|
|
|
201
203
|
for (const [key, value] of Object.entries(updates)) {
|
|
202
|
-
const regex = new RegExp(`^${key}:.*$`, 'm');
|
|
204
|
+
const regex = new RegExp(`^${escapeRegex(key)}:.*$`, 'm');
|
|
203
205
|
if (regex.test(frontmatter)) {
|
|
204
206
|
frontmatter = frontmatter.replace(regex, `${key}: ${value}`);
|
|
205
207
|
} else {
|
package/src/lint.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
|
|
4
|
+
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
5
|
+
import { updateFrontmatter } from './lifecycle.mjs';
|
|
6
|
+
import { bold, green, yellow, dim } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
const KEY_RENAMES = {
|
|
9
|
+
nextStep: 'next_step',
|
|
10
|
+
currentState: 'current_state',
|
|
11
|
+
auditLevel: 'audit_level',
|
|
12
|
+
sourceOfTruth: 'source_of_truth',
|
|
13
|
+
relatedPlans: 'related_plans',
|
|
14
|
+
supportsPlans: 'supports_plans',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function runLint(argv, config, opts = {}) {
|
|
18
|
+
const { dryRun } = opts;
|
|
19
|
+
const fix = argv.includes('--fix');
|
|
20
|
+
const allFiles = collectDocFiles(config);
|
|
21
|
+
const fixable = [];
|
|
22
|
+
|
|
23
|
+
for (const filePath of allFiles) {
|
|
24
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
25
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
26
|
+
if (!frontmatter) continue;
|
|
27
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
28
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
29
|
+
const fixes = [];
|
|
30
|
+
|
|
31
|
+
// Missing updated
|
|
32
|
+
if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
|
|
33
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
34
|
+
fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Status casing
|
|
38
|
+
const status = asString(parsed.status);
|
|
39
|
+
if (status && status !== status.toLowerCase() && config.validStatuses.has(status.toLowerCase())) {
|
|
40
|
+
fixes.push({ field: 'status', oldValue: status, newValue: status.toLowerCase(), type: 'update' });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Key renames
|
|
44
|
+
for (const [oldKey, newKey] of Object.entries(KEY_RENAMES)) {
|
|
45
|
+
if (parsed[oldKey] !== undefined && parsed[newKey] === undefined) {
|
|
46
|
+
fixes.push({ field: oldKey, oldValue: oldKey, newValue: newKey, type: 'rename-key' });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Trailing whitespace in values
|
|
51
|
+
for (const line of frontmatter.split('\n')) {
|
|
52
|
+
const m = line.match(/^([A-Za-z0-9_-]+):(.+\S)\s+$/);
|
|
53
|
+
if (m) {
|
|
54
|
+
fixes.push({ field: m[1], oldValue: m[2] + line.slice(line.indexOf(m[2]) + m[2].length), newValue: m[2], type: 'trim' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Missing newline at EOF
|
|
59
|
+
if (!raw.endsWith('\n')) {
|
|
60
|
+
fixes.push({ field: '(eof)', oldValue: 'missing', newValue: 'newline', type: 'eof' });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (fixes.length > 0) {
|
|
64
|
+
fixable.push({ filePath, repoPath, fixes });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Also get non-fixable issues from index
|
|
69
|
+
const index = buildIndex(config);
|
|
70
|
+
const nonFixable = [...index.errors, ...index.warnings];
|
|
71
|
+
|
|
72
|
+
if (!fix) {
|
|
73
|
+
// Report mode
|
|
74
|
+
if (fixable.length > 0) {
|
|
75
|
+
process.stdout.write(bold(`${fixable.length} file(s) with fixable issues:\n\n`));
|
|
76
|
+
for (const { repoPath, fixes } of fixable) {
|
|
77
|
+
process.stdout.write(` ${repoPath}\n`);
|
|
78
|
+
for (const f of fixes) {
|
|
79
|
+
if (f.type === 'rename-key') {
|
|
80
|
+
process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
|
|
81
|
+
} else if (f.type === 'eof') {
|
|
82
|
+
process.stdout.write(dim(` missing newline at end of file\n`));
|
|
83
|
+
} else if (f.type === 'add') {
|
|
84
|
+
process.stdout.write(dim(` add ${f.field}: ${f.newValue}\n`));
|
|
85
|
+
} else {
|
|
86
|
+
process.stdout.write(dim(` ${f.field}: ${f.oldValue} → ${f.newValue}\n`));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
process.stdout.write(`\nRun ${bold('dotmd lint --fix')} to auto-fix.\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (nonFixable.length > 0) {
|
|
94
|
+
process.stdout.write(`\n${yellow(`${nonFixable.length} non-fixable issue(s)`)} (manual attention needed):\n`);
|
|
95
|
+
for (const issue of nonFixable) {
|
|
96
|
+
process.stdout.write(` ${issue.path}: ${issue.message}\n`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (fixable.length === 0 && nonFixable.length === 0) {
|
|
101
|
+
process.stdout.write(green('No issues found.') + '\n');
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fix mode
|
|
107
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
108
|
+
let totalFixes = 0;
|
|
109
|
+
|
|
110
|
+
for (const { filePath, repoPath, fixes } of fixable) {
|
|
111
|
+
const updates = {};
|
|
112
|
+
const keyRenames = [];
|
|
113
|
+
let needsEofFix = false;
|
|
114
|
+
const trimFixes = [];
|
|
115
|
+
|
|
116
|
+
for (const f of fixes) {
|
|
117
|
+
if (f.type === 'rename-key') {
|
|
118
|
+
keyRenames.push(f);
|
|
119
|
+
} else if (f.type === 'eof') {
|
|
120
|
+
needsEofFix = true;
|
|
121
|
+
} else if (f.type === 'trim') {
|
|
122
|
+
trimFixes.push(f);
|
|
123
|
+
} else {
|
|
124
|
+
updates[f.field] = f.newValue;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!dryRun) {
|
|
129
|
+
// Apply key renames and trim fixes via raw string manipulation
|
|
130
|
+
if (keyRenames.length > 0 || trimFixes.length > 0) {
|
|
131
|
+
let raw = readFileSync(filePath, 'utf8');
|
|
132
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
133
|
+
let newFm = fm;
|
|
134
|
+
for (const kr of keyRenames) {
|
|
135
|
+
const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
|
|
136
|
+
newFm = newFm.replace(regex, `${kr.newValue}:`);
|
|
137
|
+
}
|
|
138
|
+
for (const tf of trimFixes) {
|
|
139
|
+
const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
|
|
140
|
+
newFm = newFm.replace(regex, `$1${tf.newValue}`);
|
|
141
|
+
}
|
|
142
|
+
if (newFm !== fm) {
|
|
143
|
+
raw = replaceFrontmatter(raw, newFm);
|
|
144
|
+
writeFileSync(filePath, raw, 'utf8');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Apply value updates via updateFrontmatter
|
|
149
|
+
if (Object.keys(updates).length > 0) {
|
|
150
|
+
updateFrontmatter(filePath, updates);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// EOF fix
|
|
154
|
+
if (needsEofFix) {
|
|
155
|
+
const current = readFileSync(filePath, 'utf8');
|
|
156
|
+
if (!current.endsWith('\n')) {
|
|
157
|
+
writeFileSync(filePath, current + '\n', 'utf8');
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
|
|
163
|
+
for (const f of fixes) {
|
|
164
|
+
if (f.type === 'rename-key') {
|
|
165
|
+
process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
|
|
166
|
+
} else if (f.type === 'eof') {
|
|
167
|
+
process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
|
|
168
|
+
} else if (f.type === 'add') {
|
|
169
|
+
process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
|
|
170
|
+
} else {
|
|
171
|
+
process.stdout.write(`${prefix} ${dim(`${f.field}: ${f.oldValue} → ${f.newValue}`)}\n`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
totalFixes += fixes.length;
|
|
175
|
+
|
|
176
|
+
if (!dryRun) {
|
|
177
|
+
try { config.hooks.onLint?.({ path: repoPath, fixes }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
|
|
182
|
+
}
|
|
183
|
+
|
package/src/migrate.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { asString, toRepoPath, die } from './util.mjs';
|
|
4
|
+
import { collectDocFiles } from './index.mjs';
|
|
5
|
+
import { updateFrontmatter } from './lifecycle.mjs';
|
|
6
|
+
import { bold, green, dim } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
export function runMigrate(argv, config, opts = {}) {
|
|
9
|
+
const { dryRun } = opts;
|
|
10
|
+
|
|
11
|
+
// Parse positional args (skip flags)
|
|
12
|
+
const positional = [];
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
if (argv[i].startsWith('-')) continue;
|
|
15
|
+
positional.push(argv[i]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const field = positional[0];
|
|
19
|
+
const oldValue = positional[1];
|
|
20
|
+
const newValue = positional[2];
|
|
21
|
+
|
|
22
|
+
if (!field || !oldValue || !newValue) {
|
|
23
|
+
die('Usage: dotmd migrate <field> <old-value> <new-value>');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const allFiles = collectDocFiles(config);
|
|
27
|
+
const matches = [];
|
|
28
|
+
|
|
29
|
+
for (const filePath of allFiles) {
|
|
30
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
31
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
32
|
+
if (!frontmatter) continue;
|
|
33
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
34
|
+
const current = asString(parsed[field]);
|
|
35
|
+
if (current === oldValue) {
|
|
36
|
+
matches.push({ filePath, repoPath: toRepoPath(filePath, config.repoRoot) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (matches.length === 0) {
|
|
41
|
+
process.stdout.write(`No docs found with ${bold(field)}: ${oldValue}\n`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
46
|
+
|
|
47
|
+
for (const { filePath, repoPath } of matches) {
|
|
48
|
+
if (!dryRun) {
|
|
49
|
+
updateFrontmatter(filePath, { [field]: newValue });
|
|
50
|
+
}
|
|
51
|
+
process.stdout.write(`${prefix}${green('Updated')}: ${repoPath} (${field}: ${oldValue} → ${newValue})\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.stdout.write(`\n${prefix}${matches.length} file(s) ${dryRun ? 'would be ' : ''}updated.\n`);
|
|
55
|
+
}
|
package/src/new.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { toRepoPath, die } from './util.mjs';
|
|
3
|
+
import { toRepoPath, die, warn } from './util.mjs';
|
|
4
4
|
import { green, dim } from './color.mjs';
|
|
5
5
|
|
|
6
6
|
export function runNew(argv, config, opts = {}) {
|
|
@@ -17,17 +17,16 @@ export function runNew(argv, config, opts = {}) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const name = positional[0];
|
|
20
|
-
if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]');
|
|
20
|
+
if (!name) { die('Usage: dotmd new <name> [--status <s>] [--title <t>]'); }
|
|
21
21
|
|
|
22
22
|
// Validate status
|
|
23
23
|
if (!config.validStatuses.has(status)) {
|
|
24
24
|
die(`Invalid status: ${status}\nValid: ${[...config.validStatuses].join(', ')}`);
|
|
25
|
-
return;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
27
|
// Slugify
|
|
29
28
|
const slug = name.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
30
|
-
if (!slug) { die('Name resolves to empty slug: ' + name);
|
|
29
|
+
if (!slug) { die('Name resolves to empty slug: ' + name); }
|
|
31
30
|
|
|
32
31
|
// Title
|
|
33
32
|
const docTitle = title ?? name.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
@@ -38,7 +37,6 @@ export function runNew(argv, config, opts = {}) {
|
|
|
38
37
|
|
|
39
38
|
if (existsSync(filePath)) {
|
|
40
39
|
die(`File already exists: ${repoPath}`);
|
|
41
|
-
return;
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
if (dryRun) {
|
|
@@ -51,4 +49,6 @@ export function runNew(argv, config, opts = {}) {
|
|
|
51
49
|
|
|
52
50
|
writeFileSync(filePath, content, 'utf8');
|
|
53
51
|
process.stdout.write(`${green('Created')}: ${repoPath}\n`);
|
|
52
|
+
|
|
53
|
+
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
54
54
|
}
|
package/src/rename.mjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
import { toRepoPath, resolveDocPath, die, warn } from './util.mjs';
|
|
5
|
+
import { collectDocFiles } from './index.mjs';
|
|
6
|
+
import { gitMv } from './git.mjs';
|
|
7
|
+
import { green, dim, yellow } from './color.mjs';
|
|
8
|
+
|
|
9
|
+
export function runRename(argv, config, opts = {}) {
|
|
10
|
+
const { dryRun } = opts;
|
|
11
|
+
|
|
12
|
+
// Parse positional args (skip flags)
|
|
13
|
+
const positional = [];
|
|
14
|
+
for (let i = 0; i < argv.length; i++) {
|
|
15
|
+
if (argv[i].startsWith('-')) continue;
|
|
16
|
+
positional.push(argv[i]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const oldInput = positional[0];
|
|
20
|
+
const newInput = positional[1];
|
|
21
|
+
|
|
22
|
+
if (!oldInput || !newInput) {
|
|
23
|
+
die('Usage: dotmd rename <old> <new>');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve old path
|
|
27
|
+
const oldPath = resolveDocPath(oldInput, config);
|
|
28
|
+
if (!oldPath) {
|
|
29
|
+
die(`File not found: ${oldInput}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Compute new path in same directory as old
|
|
33
|
+
const oldDir = path.dirname(oldPath);
|
|
34
|
+
let newBasename = newInput;
|
|
35
|
+
// If newInput contains a path separator, use just the basename
|
|
36
|
+
if (newInput.includes('/') || newInput.includes(path.sep)) {
|
|
37
|
+
newBasename = path.basename(newInput);
|
|
38
|
+
}
|
|
39
|
+
// Add .md if not present
|
|
40
|
+
if (!newBasename.endsWith('.md')) {
|
|
41
|
+
newBasename += '.md';
|
|
42
|
+
}
|
|
43
|
+
const newPath = path.join(oldDir, newBasename);
|
|
44
|
+
|
|
45
|
+
if (existsSync(newPath)) {
|
|
46
|
+
die(`Target already exists: ${toRepoPath(newPath, config.repoRoot)}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const oldRepoPath = toRepoPath(oldPath, config.repoRoot);
|
|
51
|
+
const newRepoPath = toRepoPath(newPath, config.repoRoot);
|
|
52
|
+
const oldBasename = path.basename(oldPath);
|
|
53
|
+
|
|
54
|
+
// Scan for references in other docs
|
|
55
|
+
const allFiles = collectDocFiles(config);
|
|
56
|
+
const allRefFields = [
|
|
57
|
+
...(config.referenceFields.bidirectional || []),
|
|
58
|
+
...(config.referenceFields.unidirectional || []),
|
|
59
|
+
];
|
|
60
|
+
const refUpdates = [];
|
|
61
|
+
const bodyWarnings = [];
|
|
62
|
+
|
|
63
|
+
for (const filePath of allFiles) {
|
|
64
|
+
if (filePath === oldPath) continue;
|
|
65
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
66
|
+
const { frontmatter, body } = extractFrontmatter(raw);
|
|
67
|
+
if (!frontmatter) continue;
|
|
68
|
+
|
|
69
|
+
// Check frontmatter reference fields for old basename
|
|
70
|
+
let hasRef = false;
|
|
71
|
+
for (const line of frontmatter.split('\n')) {
|
|
72
|
+
if (line.includes(oldBasename)) {
|
|
73
|
+
hasRef = true;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (hasRef) {
|
|
79
|
+
refUpdates.push(filePath);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Check body for markdown links containing old basename
|
|
83
|
+
if (body && body.includes(oldBasename)) {
|
|
84
|
+
bodyWarnings.push(toRepoPath(filePath, config.repoRoot));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (dryRun) {
|
|
89
|
+
const prefix = dim('[dry-run]');
|
|
90
|
+
process.stdout.write(`${prefix} Would rename: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
91
|
+
if (refUpdates.length > 0) {
|
|
92
|
+
process.stdout.write(`${prefix} Would update references in ${refUpdates.length} file(s):\n`);
|
|
93
|
+
for (const f of refUpdates) {
|
|
94
|
+
process.stdout.write(`${prefix} ${toRepoPath(f, config.repoRoot)}\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (bodyWarnings.length > 0) {
|
|
98
|
+
process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
|
|
99
|
+
for (const p of bodyWarnings) {
|
|
100
|
+
process.stdout.write(` ${p}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Perform git mv
|
|
107
|
+
const result = gitMv(oldPath, newPath, config.repoRoot);
|
|
108
|
+
if (result.status !== 0) {
|
|
109
|
+
die(result.stderr || 'git mv failed.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Update references in frontmatter of other docs
|
|
113
|
+
let updatedCount = 0;
|
|
114
|
+
for (const filePath of refUpdates) {
|
|
115
|
+
let raw = readFileSync(filePath, 'utf8');
|
|
116
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
117
|
+
if (!fm) continue;
|
|
118
|
+
|
|
119
|
+
const newFm = fm.split('\n').map(line => {
|
|
120
|
+
if (line.includes(oldBasename)) {
|
|
121
|
+
return line.split(oldBasename).join(newBasename);
|
|
122
|
+
}
|
|
123
|
+
return line;
|
|
124
|
+
}).join('\n');
|
|
125
|
+
|
|
126
|
+
if (newFm !== fm) {
|
|
127
|
+
raw = replaceFrontmatter(raw, newFm);
|
|
128
|
+
writeFileSync(filePath, raw, 'utf8');
|
|
129
|
+
updatedCount++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
134
|
+
if (updatedCount > 0) {
|
|
135
|
+
process.stdout.write(`Updated references in ${updatedCount} file(s).\n`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (bodyWarnings.length > 0) {
|
|
139
|
+
process.stdout.write(`\n${yellow('Body links referencing old name')} (manual update needed):\n`);
|
|
140
|
+
for (const p of bodyWarnings) {
|
|
141
|
+
process.stdout.write(` ${p}\n`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: updatedCount }); } catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
|
|
146
|
+
}
|
package/src/render.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { capitalize, toSlug, truncate } from './util.mjs';
|
|
1
|
+
import { capitalize, toSlug, truncate, warn } from './util.mjs';
|
|
2
2
|
import { bold, red, yellow, green } from './color.mjs';
|
|
3
3
|
|
|
4
4
|
export function renderCompactList(index, config) {
|
|
5
5
|
const defaultRenderer = (idx) => _renderCompactList(idx, config);
|
|
6
6
|
if (config.hooks.renderCompactList) {
|
|
7
|
-
return config.hooks.renderCompactList(index, defaultRenderer);
|
|
7
|
+
try { return config.hooks.renderCompactList(index, defaultRenderer); }
|
|
8
|
+
catch (err) { warn(`Hook 'renderCompactList' threw: ${err.message}`); }
|
|
8
9
|
}
|
|
9
10
|
return defaultRenderer(index);
|
|
10
11
|
}
|
|
@@ -63,7 +64,8 @@ export function renderVerboseList(index, config) {
|
|
|
63
64
|
export function renderContext(index, config) {
|
|
64
65
|
const defaultRenderer = (idx) => _renderContext(idx, config);
|
|
65
66
|
if (config.hooks.renderContext) {
|
|
66
|
-
return config.hooks.renderContext(index, defaultRenderer);
|
|
67
|
+
try { return config.hooks.renderContext(index, defaultRenderer); }
|
|
68
|
+
catch (err) { warn(`Hook 'renderContext' threw: ${err.message}`); }
|
|
67
69
|
}
|
|
68
70
|
return defaultRenderer(index);
|
|
69
71
|
}
|
|
@@ -144,7 +146,8 @@ function _renderContext(index, config) {
|
|
|
144
146
|
export function renderCheck(index, config) {
|
|
145
147
|
const defaultRenderer = (idx) => _renderCheck(idx);
|
|
146
148
|
if (config.hooks.renderCheck) {
|
|
147
|
-
return config.hooks.renderCheck(index, defaultRenderer);
|
|
149
|
+
try { return config.hooks.renderCheck(index, defaultRenderer); }
|
|
150
|
+
catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
|
|
148
151
|
}
|
|
149
152
|
return defaultRenderer(index);
|
|
150
153
|
}
|
|
@@ -234,7 +237,8 @@ export function renderProgressBar(checklist) {
|
|
|
234
237
|
export function formatSnapshot(doc, config) {
|
|
235
238
|
const defaultFormatter = (d) => _formatSnapshot(d);
|
|
236
239
|
if (config.hooks.formatSnapshot) {
|
|
237
|
-
return config.hooks.formatSnapshot(doc, defaultFormatter);
|
|
240
|
+
try { return config.hooks.formatSnapshot(doc, defaultFormatter); }
|
|
241
|
+
catch (err) { warn(`Hook 'formatSnapshot' threw: ${err.message}`); }
|
|
238
242
|
}
|
|
239
243
|
return defaultFormatter(doc);
|
|
240
244
|
}
|
package/src/util.mjs
CHANGED
|
@@ -47,6 +47,10 @@ export function mergeUniqueStrings(...lists) {
|
|
|
47
47
|
return [...new Set(lists.flat().filter(Boolean))];
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
export function escapeRegex(str) {
|
|
51
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
52
|
+
}
|
|
53
|
+
|
|
50
54
|
export function toRepoPath(absolutePath, repoRoot) {
|
|
51
55
|
return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
|
|
52
56
|
}
|
|
@@ -55,9 +59,15 @@ export function warn(message) {
|
|
|
55
59
|
process.stderr.write(`${dim(message)}\n`);
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
export class DotmdError extends Error {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'DotmdError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
export function die(message) {
|
|
59
|
-
|
|
60
|
-
process.exitCode = 1;
|
|
70
|
+
throw new DotmdError(message);
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export function resolveDocPath(input, config) {
|