dotmd-cli 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -41
- package/bin/dotmd.mjs +98 -5
- package/dotmd.config.example.mjs +1 -1
- package/package.json +1 -1
- package/src/completions.mjs +8 -4
- package/src/config.mjs +2 -0
- package/src/doctor.mjs +41 -0
- package/src/extractors.mjs +19 -0
- package/src/fix-refs.mjs +113 -0
- package/src/graph.mjs +268 -0
- package/src/index.mjs +3 -1
- package/src/lifecycle.mjs +128 -36
- package/src/new.mjs +90 -7
- package/src/render.mjs +5 -4
- package/src/validate.mjs +8 -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/lifecycle.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
3
|
+
import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
|
|
4
4
|
import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex } from './util.mjs';
|
|
5
|
-
import { gitMv } from './git.mjs';
|
|
5
|
+
import { gitMv, getGitLastModified } from './git.mjs';
|
|
6
6
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
7
7
|
import { renderIndexFile, writeIndex } from './index-file.mjs';
|
|
8
|
-
import { green, dim } from './color.mjs';
|
|
8
|
+
import { green, dim, yellow } from './color.mjs';
|
|
9
9
|
|
|
10
10
|
export function runStatus(argv, config, opts = {}) {
|
|
11
11
|
const { dryRun } = opts;
|
|
@@ -114,20 +114,10 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
114
114
|
process.stdout.write(`${prefix} Would move: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
115
115
|
if (config.indexPath) process.stdout.write(`${prefix} Would regenerate index\n`);
|
|
116
116
|
|
|
117
|
-
//
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (docFile === targetPath) continue;
|
|
122
|
-
const docRaw = readFileSync(docFile, 'utf8');
|
|
123
|
-
const { frontmatter: docFm } = extractFrontmatter(docRaw);
|
|
124
|
-
if (docFm.includes(basename)) {
|
|
125
|
-
references.push(toRepoPath(docFile, config.repoRoot));
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
if (references.length > 0) {
|
|
129
|
-
process.stdout.write('\nThese docs reference the old path — would need updating:\n');
|
|
130
|
-
for (const ref of references) process.stdout.write(`- ${ref}\n`);
|
|
117
|
+
// Preview reference updates
|
|
118
|
+
const refCount = countRefsToUpdate(filePath, targetPath, config);
|
|
119
|
+
if (refCount > 0) {
|
|
120
|
+
process.stdout.write(`${prefix} Would update references in ${refCount} file(s)\n`);
|
|
131
121
|
}
|
|
132
122
|
return;
|
|
133
123
|
}
|
|
@@ -140,39 +130,78 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
140
130
|
const result = gitMv(filePath, targetPath, config.repoRoot);
|
|
141
131
|
if (result.status !== 0) { die(result.stderr || 'git mv failed.'); }
|
|
142
132
|
|
|
133
|
+
// Auto-update references in other docs
|
|
134
|
+
const updatedRefCount = updateRefsAfterMove(filePath, targetPath, config);
|
|
135
|
+
|
|
143
136
|
if (config.indexPath) {
|
|
144
137
|
const index = buildIndex(config);
|
|
145
138
|
writeIndex(renderIndexFile(index, config), config);
|
|
146
139
|
}
|
|
147
140
|
|
|
148
141
|
process.stdout.write(`${green('Archived')}: ${oldRepoPath} → ${newRepoPath}\n`);
|
|
142
|
+
if (updatedRefCount > 0) process.stdout.write(`Updated references in ${updatedRefCount} file(s).\n`);
|
|
149
143
|
if (config.indexPath) process.stdout.write('Index regenerated.\n');
|
|
150
144
|
|
|
151
|
-
const basename = path.basename(filePath);
|
|
152
|
-
const references = [];
|
|
153
|
-
for (const docFile of collectDocFiles(config)) {
|
|
154
|
-
if (docFile === targetPath) continue;
|
|
155
|
-
const docRaw = readFileSync(docFile, 'utf8');
|
|
156
|
-
const { frontmatter: docFm } = extractFrontmatter(docRaw);
|
|
157
|
-
if (docFm.includes(basename)) {
|
|
158
|
-
references.push(toRepoPath(docFile, config.repoRoot));
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (references.length > 0) {
|
|
163
|
-
process.stdout.write('\nThese docs reference the old path — update reference entries:\n');
|
|
164
|
-
for (const ref of references) process.stdout.write(`- ${ref}\n`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
process.stdout.write('\nNext: commit, then update references if needed.\n');
|
|
168
145
|
try { config.hooks.onArchive?.({ path: newRepoPath, oldStatus }, { oldPath: oldRepoPath, newPath: newRepoPath }); } catch (err) { warn(`Hook 'onArchive' threw: ${err.message}`); }
|
|
169
146
|
}
|
|
170
147
|
|
|
171
148
|
export function runTouch(argv, config, opts = {}) {
|
|
172
149
|
const { dryRun } = opts;
|
|
173
|
-
const
|
|
150
|
+
const useGit = argv.includes('--git');
|
|
151
|
+
const positional = [];
|
|
152
|
+
for (let i = 0; i < argv.length; i++) {
|
|
153
|
+
if (argv[i] === '--config') { i++; continue; }
|
|
154
|
+
if (argv[i].startsWith('-')) continue;
|
|
155
|
+
positional.push(argv[i]);
|
|
156
|
+
}
|
|
157
|
+
const input = positional[0];
|
|
174
158
|
|
|
175
|
-
|
|
159
|
+
// --git mode: bulk-sync frontmatter dates from git history
|
|
160
|
+
if (useGit) {
|
|
161
|
+
const allFiles = input ? [resolveDocPath(input, config)].filter(Boolean) : collectDocFiles(config);
|
|
162
|
+
if (input && allFiles.length === 0) { die(`File not found: ${input}`); }
|
|
163
|
+
|
|
164
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
165
|
+
let synced = 0;
|
|
166
|
+
|
|
167
|
+
for (const filePath of allFiles) {
|
|
168
|
+
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
169
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
170
|
+
const { frontmatter } = extractFrontmatter(raw);
|
|
171
|
+
if (!frontmatter) continue;
|
|
172
|
+
|
|
173
|
+
const parsed = parseSimpleFrontmatter(frontmatter);
|
|
174
|
+
const status = asString(parsed.status);
|
|
175
|
+
if (config.lifecycle.skipStaleFor.has(status)) continue;
|
|
176
|
+
|
|
177
|
+
const fmUpdated = asString(parsed.updated);
|
|
178
|
+
const gitDate = getGitLastModified(repoPath, config.repoRoot);
|
|
179
|
+
if (!gitDate) continue;
|
|
180
|
+
|
|
181
|
+
const gitDay = gitDate.slice(0, 10);
|
|
182
|
+
if (fmUpdated === gitDay) continue;
|
|
183
|
+
|
|
184
|
+
// Only sync if git is newer than frontmatter
|
|
185
|
+
const gitMs = new Date(gitDate).getTime();
|
|
186
|
+
const fmMs = fmUpdated ? new Date(fmUpdated).getTime() : 0;
|
|
187
|
+
if (fmMs >= gitMs) continue;
|
|
188
|
+
|
|
189
|
+
if (!dryRun) {
|
|
190
|
+
updateFrontmatter(filePath, { updated: gitDay });
|
|
191
|
+
}
|
|
192
|
+
process.stdout.write(`${prefix}${green('Synced')}: ${repoPath} (updated → ${gitDay})\n`);
|
|
193
|
+
synced++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (synced === 0) {
|
|
197
|
+
process.stdout.write(green('All frontmatter dates are in sync with git.') + '\n');
|
|
198
|
+
} else {
|
|
199
|
+
process.stdout.write(`\n${prefix}${synced} file(s) synced.\n`);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
|
|
176
205
|
|
|
177
206
|
const filePath = resolveDocPath(input, config);
|
|
178
207
|
if (!filePath) { die(`File not found: ${input}\nSearched: ${toRepoPath(config.repoRoot, config.repoRoot) || '.'}, ${toRepoPath(config.docsRoot, config.repoRoot)}`); }
|
|
@@ -190,6 +219,69 @@ export function runTouch(argv, config, opts = {}) {
|
|
|
190
219
|
try { config.hooks.onTouch?.({ path: toRepoPath(filePath, config.repoRoot) }, { path: toRepoPath(filePath, config.repoRoot), date: today }); } catch (err) { warn(`Hook 'onTouch' threw: ${err.message}`); }
|
|
191
220
|
}
|
|
192
221
|
|
|
222
|
+
/**
|
|
223
|
+
* After a file moves (archive/unarchive), update frontmatter references in all
|
|
224
|
+
* docs that pointed to the old location so they point to the new one.
|
|
225
|
+
*/
|
|
226
|
+
function updateRefsAfterMove(oldPath, newPath, config) {
|
|
227
|
+
const basename = path.basename(oldPath);
|
|
228
|
+
const allFiles = collectDocFiles(config);
|
|
229
|
+
let updatedCount = 0;
|
|
230
|
+
|
|
231
|
+
for (const docFile of allFiles) {
|
|
232
|
+
if (docFile === newPath) continue;
|
|
233
|
+
let raw = readFileSync(docFile, 'utf8');
|
|
234
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
235
|
+
if (!fm || !fm.includes(basename)) continue;
|
|
236
|
+
|
|
237
|
+
const docDir = path.dirname(docFile);
|
|
238
|
+
const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
|
|
239
|
+
const newRelPath = path.relative(docDir, newPath).split(path.sep).join('/');
|
|
240
|
+
|
|
241
|
+
let newFm = fm;
|
|
242
|
+
|
|
243
|
+
// Replace exact relative path
|
|
244
|
+
if (newFm.includes(oldRelPath)) {
|
|
245
|
+
newFm = newFm.split(oldRelPath).join(newRelPath);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Also handle ./ prefix variant
|
|
249
|
+
const dotSlashOld = './' + oldRelPath;
|
|
250
|
+
if (newFm.includes(dotSlashOld)) {
|
|
251
|
+
newFm = newFm.split(dotSlashOld).join(newRelPath);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (newFm !== fm) {
|
|
255
|
+
raw = replaceFrontmatter(raw, newFm);
|
|
256
|
+
writeFileSync(docFile, raw, 'utf8');
|
|
257
|
+
updatedCount++;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return updatedCount;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function countRefsToUpdate(oldPath, newPath, config) {
|
|
265
|
+
const basename = path.basename(oldPath);
|
|
266
|
+
const allFiles = collectDocFiles(config);
|
|
267
|
+
let count = 0;
|
|
268
|
+
|
|
269
|
+
for (const docFile of allFiles) {
|
|
270
|
+
if (docFile === newPath) continue;
|
|
271
|
+
const raw = readFileSync(docFile, 'utf8');
|
|
272
|
+
const { frontmatter: fm } = extractFrontmatter(raw);
|
|
273
|
+
if (!fm || !fm.includes(basename)) continue;
|
|
274
|
+
|
|
275
|
+
const docDir = path.dirname(docFile);
|
|
276
|
+
const oldRelPath = path.relative(docDir, oldPath).split(path.sep).join('/');
|
|
277
|
+
if (fm.includes(oldRelPath) || fm.includes('./' + oldRelPath)) {
|
|
278
|
+
count++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return count;
|
|
283
|
+
}
|
|
284
|
+
|
|
193
285
|
export function updateFrontmatter(filePath, updates) {
|
|
194
286
|
const raw = readFileSync(filePath, 'utf8');
|
|
195
287
|
if (!raw.startsWith('---\n')) throw new Error(`${filePath} has no frontmatter block.`);
|
package/src/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/render.mjs
CHANGED
|
@@ -143,8 +143,8 @@ function _renderContext(index, config) {
|
|
|
143
143
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
export function renderCheck(index, config) {
|
|
147
|
-
const defaultRenderer = (idx) => _renderCheck(idx);
|
|
146
|
+
export function renderCheck(index, config, opts = {}) {
|
|
147
|
+
const defaultRenderer = (idx) => _renderCheck(idx, opts);
|
|
148
148
|
if (config.hooks.renderCheck) {
|
|
149
149
|
try { return config.hooks.renderCheck(index, defaultRenderer); }
|
|
150
150
|
catch (err) { warn(`Hook 'renderCheck' threw: ${err.message}`); }
|
|
@@ -152,7 +152,8 @@ export function renderCheck(index, config) {
|
|
|
152
152
|
return defaultRenderer(index);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
function _renderCheck(index) {
|
|
155
|
+
function _renderCheck(index, opts = {}) {
|
|
156
|
+
const { errorsOnly } = opts;
|
|
156
157
|
const lines = ['Check', ''];
|
|
157
158
|
lines.push(`- docs scanned: ${index.docs.length}`);
|
|
158
159
|
lines.push(`- errors: ${index.errors.length}`);
|
|
@@ -167,7 +168,7 @@ function _renderCheck(index) {
|
|
|
167
168
|
lines.push('');
|
|
168
169
|
}
|
|
169
170
|
|
|
170
|
-
if (index.warnings.length > 0) {
|
|
171
|
+
if (!errorsOnly && index.warnings.length > 0) {
|
|
171
172
|
lines.push(yellow('Warnings'));
|
|
172
173
|
for (const issue of index.warnings) {
|
|
173
174
|
lines.push(`- ${issue.path}: ${issue.message}`);
|
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) {
|