dotmd-cli 0.70.0 → 0.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dotmd.mjs CHANGED
@@ -200,7 +200,7 @@ Analyze:
200
200
  glossary <term> [--list] [--json] Look up domain terms + related docs
201
201
 
202
202
  Validate & Fix:
203
- doctor [--apply] Auto-fix everything: refs, lint, dates, index (preview by default)
203
+ doctor [--apply] Auto-fix everything: refs, lint, long fields, dates, index (preview by default)
204
204
  self-check Project/version skew diagnostic (alias: doctor --project)
205
205
  lint [--fix] Check and auto-fix frontmatter issues
206
206
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
@@ -218,7 +218,7 @@ Lifecycle:
218
218
  ship [patch|minor|major] Regen + commit + bump in one step (default: patch)
219
219
  bulk-tag [files...] Tag pre-existing untagged .md files
220
220
  touch <file> Bump updated date
221
- touch --git Bulk-sync dates from git history
221
+ touch --git [<file>...] Sync dates from substantive git history
222
222
  rename <old> <new> Rename doc and update all references
223
223
  migrate <field> <old> <new> [f...]Batch update a frontmatter field value (optional file filter)
224
224
 
@@ -702,8 +702,9 @@ the command says so instead of printing an empty list.`,
702
702
 
703
703
  doctor: `dotmd doctor — auto-fix everything in one pass
704
704
 
705
- Runs in sequence: fix broken references, lint --fix, sync dates from
706
- git, regenerate index, then show remaining issues.
705
+ Runs in sequence: fix broken references, lint --fix, move over-cap
706
+ frontmatter prose into body sections, sync dates from git, regenerate
707
+ the index, then show remaining issues.
707
708
 
708
709
  Modes:
709
710
  (default) Auto-fix pass — previews by default since 0.37.0
@@ -765,11 +766,12 @@ docs. Fixes are applied by rewriting the frontmatter path.
765
766
  Use --dry-run (-n) to preview changes without writing anything.`,
766
767
 
767
768
  touch: `dotmd touch <file> — bump updated date
768
- dotmd touch --git — bulk-sync dates from git history
769
+ dotmd touch --git [<file>...] — sync dates from git history
769
770
 
770
771
  Without --git, updates a single file's frontmatter updated date to today.
771
- With --git, scans all docs (or a specific file) and syncs their updated
772
- date to match the last git commit date, fixing date drift warnings.
772
+ With --git, scans all docs (or the specified files) and syncs their updated
773
+ date to match the last substantive git commit date, fixing date drift warnings.
774
+ Commits that only changed the updated line are ignored so the fix converges.
773
775
 
774
776
  Use --dry-run (-n) to preview changes without writing anything.`,
775
777
 
@@ -1380,6 +1382,12 @@ async function main() {
1380
1382
  let { command, explicitConfig, rootArg, typeArg, dryRun, verbose } = parsed;
1381
1383
  let restArgs = parsed.rest;
1382
1384
 
1385
+ // Tolerate accidentally pasting the command prefix twice, while leaving all
1386
+ // remaining arguments to the normal `use` grammar and path validation.
1387
+ if (command === 'use') {
1388
+ while (restArgs[0] === 'dotmd' && restArgs[1] === 'use') restArgs = restArgs.slice(2);
1389
+ }
1390
+
1383
1391
  // Reconstruct the active global flags for proxy commands (e.g. `watch`) that
1384
1392
  // re-invoke the CLI in a child process and must propagate them through.
1385
1393
  const globalFlagArgs = () => {
@@ -1444,6 +1452,10 @@ async function main() {
1444
1452
  const effectiveDryRun = dryRun || (command === 'doctor' && !doctorSubMode && !doctorExplicitApply);
1445
1453
  const passiveMachineContext = command === 'agent-context'
1446
1454
  || (command === 'context' && args.includes('--json') && args.includes('--compact'));
1455
+ // Git/frontmatter drift is validation work, not index construction. Keep the
1456
+ // bounded history scan on commands that report or repair that drift; ordinary
1457
+ // reads still run schema/reference validation without walking 10k commits.
1458
+ const gitStaleness = command === 'check' || command === 'doctor';
1447
1459
  _suppressObservability = effectiveDryRun || command === 'hud' || passiveMachineContext;
1448
1460
 
1449
1461
  // Per-command help
@@ -1474,7 +1486,7 @@ async function main() {
1474
1486
  _resolvedConfig = config;
1475
1487
  const suppressSideEffects = effectiveDryRun || command === 'hud' || passiveMachineContext;
1476
1488
  Object.defineProperty(config, '_execution', {
1477
- value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects },
1489
+ value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects, gitStaleness },
1478
1490
  enumerable: false,
1479
1491
  });
1480
1492
  // Unknown names may still be user-defined query presets. Every built-in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.70.0",
3
+ "version": "0.70.2",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,13 +18,13 @@ const CATEGORIES = [
18
18
  },
19
19
  {
20
20
  key: 'singular-module',
21
- match: /^`module:` \(singular\) is deprecated/,
21
+ match: /^`module:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
22
22
  label: 'docs use deprecated singular `module:`',
23
23
  fix: 'dotmd lint --fix',
24
24
  },
25
25
  {
26
26
  key: 'singular-surface',
27
- match: /^`surface:` \(singular\) is deprecated/,
27
+ match: /^`surface:` \(singular\) is deprecated.*Run `dotmd lint --fix`/,
28
28
  label: 'docs use deprecated singular `surface:`',
29
29
  fix: 'dotmd lint --fix',
30
30
  },
package/src/commands.mjs CHANGED
@@ -123,7 +123,7 @@ const definitions = [
123
123
  form('tag [files...]', { subcommands: ['tag'], args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] }),
124
124
  ]),
125
125
  command('bulk-tag', mutates('managed source sweep'), 'mutate', [form('[files...]', { args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] })]),
126
- command('touch', mutates('managed source or managed source sweep'), 'mutate', [form('[file]', { args: positionals(0, 1), options: [flag('--git')] })]),
126
+ command('touch', mutates('managed source or managed source sweep'), 'mutate', [form('[file...]', { args: positionals(0, Infinity), options: [flag('--git')] })]),
127
127
  command('new', mutates('managed document destination; external body input unrestricted'), 'mutate', [form('[type] <name> [body...]', {
128
128
  args: positionals(0, Infinity),
129
129
  options: [value('--status'), value('--title'), value('--runlist'), flag('--coordination'), flag('--roadmap'), flag('--lite', '--minimal'), flag('--audit', '--findings'), value('--body', '--message'), value('--root'), flag('--show-files'), flag('--list-templates', '--list-types')],
package/src/doctor.mjs CHANGED
@@ -89,14 +89,17 @@ export function runDoctor(argv, config, opts = {}) {
89
89
  process.stdout.write('\n' + bold('2. Fixing frontmatter issues...') + '\n');
90
90
  runLint(['--fix'], config, { dryRun });
91
91
 
92
- // Step 3: Sync dates from git
93
- process.stdout.write('\n' + bold('3. Syncing dates from git...') + '\n');
92
+ // Step 3: Move over-cap status prose into body sections.
93
+ process.stdout.write('\n' + bold('3. Fixing long frontmatter...') + '\n');
94
+ runFrontmatterFix(config, { dryRun });
95
+
96
+ // Step 4: Sync dates from git
97
+ process.stdout.write('\n' + bold('4. Syncing dates from git...') + '\n');
94
98
  runTouch(['--git'], config, { dryRun });
95
99
 
96
- // Step 4: Regenerate index. Heading always prints so the numbering stays
97
- // `1,2,3,4,5,6` even when `index.path` isn't configured — pre-fix this was
98
- // gated on `config.indexPath`, producing `1,2,3,5,6` on repos with no index.
99
- process.stdout.write('\n' + bold('4. Regenerating index...') + '\n');
100
+ // Step 5: Regenerate index. Heading always prints so numbering remains
101
+ // contiguous even when `index.path` isn't configured.
102
+ process.stdout.write('\n' + bold('5. Regenerating index...') + '\n');
100
103
  if (!config.indexPath) {
101
104
  process.stdout.write('No index path configured (skip).\n');
102
105
  } else if (dryRun) {
@@ -106,11 +109,11 @@ export function runDoctor(argv, config, opts = {}) {
106
109
  process.stdout.write('Index updated.\n');
107
110
  }
108
111
 
109
- // Step 5: Clean up retired Claude Code command scaffolding. The per-repo
112
+ // Step 6: Clean up retired Claude Code command scaffolding. The per-repo
110
113
  // `.claude/commands/{plans,docs}.md` files are superseded by the dotmd plugin
111
114
  // skill; doctor sweeps any leftover banner-stamped (dotmd-generated) files.
112
- // Always print the heading so the numbering stays `1,2,3,4,5,6`.
113
- process.stdout.write('\n' + bold('5. Claude Code commands:') + '\n');
115
+ // Always print the heading so the numbering remains contiguous.
116
+ process.stdout.write('\n' + bold('6. Claude Code commands:') + '\n');
114
117
  if (dryRun) {
115
118
  const wouldRemove = removeGeneratedSlashCommands(config.repoRoot, { dryRun: true });
116
119
  if (wouldRemove.length === 0) {
@@ -131,8 +134,8 @@ export function runDoctor(argv, config, opts = {}) {
131
134
  }
132
135
  }
133
136
 
134
- // Step 6: Show remaining check
135
- const issueLabel = dryRun ? '6. Remaining issues in current tree (preview fixes above were not applied):' : '6. Remaining issues:';
137
+ // Step 7: Show remaining check
138
+ const issueLabel = dryRun ? '7. Remaining issues in current tree (preview fixes above were not applied):' : '7. Remaining issues:';
136
139
  process.stdout.write('\n' + bold(issueLabel) + '\n');
137
140
  const freshIndex = buildIndex(config);
138
141
  process.stdout.write(renderCheck(freshIndex, config));
@@ -30,6 +30,10 @@ export function runFrontmatterFix(config, opts = {}) {
30
30
  // skip the warning too, so skip them here as well — auto-injecting a
31
31
  // `## Current State` into a non-plan doc would be surprising.
32
32
  if (docType !== 'plan') continue;
33
+ const status = asString(parsed.status);
34
+ if (config.lifecycle.terminalStatuses.has(status)
35
+ || config.lifecycle.archiveStatuses.has(status)
36
+ || config.lifecycle.skipWarningsFor.has(status)) continue;
33
37
 
34
38
  const ops = [];
35
39
  for (const { name, cap, target, heading } of FIELDS) {
@@ -129,13 +133,9 @@ export function replaceFrontmatterField(fm, key, newValue) {
129
133
  const isBlock = /^[>|][-+]?\s*$/.test(rest);
130
134
  i++;
131
135
  if (isBlock || rest === '') {
132
- // Consume continuation: blank or indented lines until the next
133
- // top-level key. The parser uses the same dedent rule (block scalar
134
- // ends when indent returns to 0 and the line is non-blank).
135
- while (i < lines.length) {
136
- if (/^[A-Za-z0-9_-]+:/.test(lines[i])) break;
137
- i++;
138
- }
136
+ // Consume only scalar continuation lines. Top-level comments and other
137
+ // YAML syntax belong to the frontmatter, not to this field.
138
+ while (i < lines.length && (lines[i].trim() === '' || /^[ \t]/.test(lines[i]))) i++;
139
139
  }
140
140
  const folded = foldBlockScalar(newValue);
141
141
  out.push(`${key}: >`);
package/src/git.mjs CHANGED
@@ -25,7 +25,7 @@ export function isGitIgnored(absPath, repoRoot) {
25
25
  let gitChecked = false;
26
26
  function assertSafeGitPaths(paths) {
27
27
  for (const filePath of paths) {
28
- if (!filePath || path.isAbsolute(filePath) || filePath.split(/[\\/]/).includes('..')) {
28
+ if (!filePath || /[\0\r\n]/.test(filePath) || path.isAbsolute(filePath) || filePath.split(/[\\/]/).includes('..')) {
29
29
  throw new Error(`Unsafe repository-relative Git path: ${filePath}`);
30
30
  }
31
31
  }
@@ -39,6 +39,10 @@ function ensureGit() {
39
39
  gitChecked = true;
40
40
  }
41
41
 
42
+ function literalGitPathspec(filePath) {
43
+ return filePath === '.' ? ':(top,literal)' : `:(top,literal)${filePath}`;
44
+ }
45
+
42
46
  export function getGitLastModified(relPath, repoRoot) {
43
47
  const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
44
48
  cwd: repoRoot,
@@ -61,6 +65,7 @@ export function getGitFirstAdded(relPath, repoRoot) {
61
65
  const DEFAULT_GIT_METADATA_MAX_COMMITS = 10_000;
62
66
  const DEFAULT_GIT_METADATA_MAX_BUFFER = 10 * 1024 * 1024;
63
67
  const DEFAULT_GIT_METADATA_PATH_BATCH = 256;
68
+ const GIT_METADATA_HISTORY_PER_PATH = 16;
64
69
 
65
70
  function boundedPositiveInteger(value, fallback, name) {
66
71
  if (value == null) return fallback;
@@ -73,21 +78,33 @@ function boundedPositiveInteger(value, fallback, name) {
73
78
  function unavailableGitHistory(result) {
74
79
  if (result.error?.code === 'ENOENT') return true;
75
80
  const detail = `${result.stderr ?? ''}\n${result.error?.message ?? ''}`;
76
- return /not a git repository|does not have any commits yet|bad revision ['"]?HEAD|ambiguous argument ['"]?HEAD/i.test(detail);
81
+ return /not a git repository|does not have any commits yet|bad revision|unknown revision|ambiguous argument/i.test(detail);
77
82
  }
78
83
 
79
- function parseGitMetadataOutput(stdout, dates, expectedPaths) {
84
+ function parseGitMetadataOutput(stdout, dates, commits, history, expectedPaths) {
80
85
  const fields = String(stdout ?? '').split('\0');
81
86
  let currentDate = null;
87
+ let currentCommit = null;
82
88
  for (let i = 0; i < fields.length; i++) {
83
89
  const field = fields[i];
84
- if (field === 'dotmd:git-metadata:commit' && /^\d{4}-\d{2}-\d{2}T/.test(fields[i + 1] ?? '')) {
90
+ if (field === 'dotmd:git-metadata:commit'
91
+ && /^[0-9a-f]{40,64}$/i.test(fields[i + 1] ?? '')
92
+ && /^\d{4}-\d{2}-\d{2}T/.test(fields[i + 2] ?? '')) {
93
+ currentCommit = fields[++i];
85
94
  currentDate = fields[++i];
86
95
  continue;
87
96
  }
88
97
  const filePath = field.startsWith('\n') ? field.slice(1) : field;
89
- if (filePath && currentDate && expectedPaths.has(filePath) && !dates.has(filePath)) {
90
- dates.set(filePath, currentDate);
98
+ if (filePath && currentDate && expectedPaths.has(filePath)) {
99
+ if (!history.has(filePath)) history.set(filePath, []);
100
+ const entries = history.get(filePath);
101
+ if (entries.length < GIT_METADATA_HISTORY_PER_PATH && entries.at(-1)?.commit !== currentCommit) {
102
+ entries.push({ date: currentDate, commit: currentCommit });
103
+ }
104
+ if (!dates.has(filePath)) {
105
+ dates.set(filePath, currentDate);
106
+ commits.set(filePath, currentCommit);
107
+ }
91
108
  }
92
109
  }
93
110
  }
@@ -101,36 +118,46 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
101
118
  const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
102
119
  const paths = [...new Set(relPaths ?? [])];
103
120
  assertSafeGitPaths(paths);
104
- if (paths.length === 0) return { dates: new Map(), complete: true, reason: null };
121
+ if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
122
+ // Full-tree callers can supply a small set of configured root pathspecs for
123
+ // diff extraction. Revision selection still uses the exact requested paths,
124
+ // so excluded or unrelated documents cannot consume the commit bound.
125
+ const scanPaths = options.pathspecs?.length ? [...new Set(options.pathspecs)] : paths;
126
+ assertSafeGitPaths(scanPaths);
127
+ const expectedPaths = new Set(paths);
128
+ const revision = options.revision ?? 'HEAD';
105
129
 
106
130
  const dates = new Map();
131
+ const commitsByPath = new Map();
132
+ const history = new Map();
107
133
  let reason = null;
108
- const revisions = spawnSync('git', ['rev-list', `--max-count=${maxCommits + 1}`, 'HEAD'], {
134
+ const revisions = spawnSync('git', ['rev-list', '--stdin', `--max-count=${maxCommits + 1}`, revision], {
109
135
  cwd: repoRoot,
110
136
  encoding: 'utf8',
137
+ input: `--\n${paths.map(literalGitPathspec).join('\n')}\n`,
111
138
  maxBuffer,
112
139
  });
113
140
  if (revisions.error?.code === 'ENOBUFS') {
114
141
  reason = 'output-limit';
115
142
  } else if (revisions.error || revisions.status !== 0) {
116
- if (unavailableGitHistory(revisions)) return { dates, complete: true, reason: null };
117
- return { dates, complete: false, reason: 'git-error' };
143
+ if (unavailableGitHistory(revisions)) return { dates, commits: commitsByPath, history, complete: true, reason: null };
144
+ return { dates, commits: commitsByPath, history, complete: false, reason: 'git-error' };
118
145
  }
119
- const commits = String(revisions.stdout ?? '')
146
+ const revisionList = String(revisions.stdout ?? '')
120
147
  .split('\n')
121
148
  .filter(line => /^[0-9a-f]{40,64}$/i.test(line))
122
149
  .slice(0, maxCommits);
123
- if (commits.length === 0) return { dates, complete: reason === null, reason };
150
+ if (revisionList.length === 0) return { dates, commits: commitsByPath, history, complete: reason === null, reason };
124
151
  if (!reason && String(revisions.stdout ?? '').trim().split('\n').length > maxCommits) {
125
152
  reason = 'commit-limit';
126
153
  }
127
154
 
128
- for (let offset = 0; offset < paths.length; offset += maxPathsPerBatch) {
129
- const batch = paths.slice(offset, offset + maxPathsPerBatch);
155
+ for (let offset = 0; offset < scanPaths.length; offset += maxPathsPerBatch) {
156
+ const batch = scanPaths.slice(offset, offset + maxPathsPerBatch);
130
157
  const result = spawnSync('git', [
131
- 'diff-tree', '--stdin', '--root', '-r', '-z', '--format=%x00dotmd:git-metadata:commit%x00%aI%x00', '--name-only', '--diff-filter=ACDMR', '--', ...batch,
132
- ], { cwd: repoRoot, encoding: 'utf8', input: commits.join('\n') + '\n', maxBuffer });
133
- parseGitMetadataOutput(result.stdout, dates, new Set(batch));
158
+ 'diff-tree', '--stdin', '--root', '-r', '-z', '--format=%x00dotmd:git-metadata:commit%x00%H%x00%aI%x00', '--name-only', '--diff-filter=ACDMR', '--', ...batch.map(literalGitPathspec),
159
+ ], { cwd: repoRoot, encoding: 'utf8', input: revisionList.join('\n') + '\n', maxBuffer });
160
+ parseGitMetadataOutput(result.stdout, dates, commitsByPath, history, expectedPaths);
134
161
 
135
162
  if (result.error?.code === 'ENOBUFS') {
136
163
  reason = 'output-limit';
@@ -139,7 +166,171 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
139
166
  }
140
167
  }
141
168
 
142
- return { dates, complete: reason === null, reason };
169
+ // Reaching the history window is harmless when every requested tracked path
170
+ // already received a latest date. Only unresolved tracked paths require
171
+ // older history; untracked files legitimately have no Git date.
172
+ if (reason === 'commit-limit') {
173
+ const missing = paths.filter(filePath => !dates.has(filePath));
174
+ let unresolvedTracked = false;
175
+ let trackingCheckFailed = false;
176
+ for (let offset = 0; offset < missing.length; offset += maxPathsPerBatch) {
177
+ const batch = missing.slice(offset, offset + maxPathsPerBatch);
178
+ const result = spawnSync('git', ['ls-files', '-z', '--', ...batch.map(literalGitPathspec)], {
179
+ cwd: repoRoot, encoding: 'utf8', maxBuffer,
180
+ });
181
+ if (result.error || result.status !== 0) {
182
+ trackingCheckFailed = true;
183
+ break;
184
+ }
185
+ if (String(result.stdout ?? '').split('\0').some(Boolean)) {
186
+ unresolvedTracked = true;
187
+ break;
188
+ }
189
+ }
190
+ if (!trackingCheckFailed && !unresolvedTracked) reason = null;
191
+ }
192
+
193
+ return { dates, commits: commitsByPath, history, complete: reason === null, reason };
194
+ }
195
+
196
+ function parseBatchObjects(stdout, count) {
197
+ const output = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? '');
198
+ const objects = [];
199
+ let offset = 0;
200
+ while (objects.length < count && offset < output.length) {
201
+ const newline = output.indexOf(0x0a, offset);
202
+ if (newline < 0) return null;
203
+ const header = output.subarray(offset, newline).toString('utf8');
204
+ offset = newline + 1;
205
+ if (header.endsWith(' missing')) {
206
+ objects.push(null);
207
+ continue;
208
+ }
209
+ const size = Number(header.match(/\s(\d+)$/)?.[1]);
210
+ if (!Number.isSafeInteger(size) || size < 0 || offset + size > output.length) return null;
211
+ objects.push(output.subarray(offset, offset + size).toString('utf8'));
212
+ offset += size;
213
+ if (output[offset] === 0x0a) offset++;
214
+ }
215
+ return objects.length === count ? objects : null;
216
+ }
217
+
218
+ function withoutUpdatedLine(raw) {
219
+ if (raw == null) return null;
220
+ const eol = raw.startsWith('---\r\n') ? '\r\n' : raw.startsWith('---\n') ? '\n' : null;
221
+ if (!eol) return null;
222
+ const marker = `${eol}---${eol}`;
223
+ const end = raw.indexOf(marker, 3 + eol.length);
224
+ if (end < 0) return null;
225
+ const frontmatter = raw.slice(3 + eol.length, end).split(eol);
226
+ const index = frontmatter.findIndex(line => line.startsWith('updated:'));
227
+ if (index < 0) return { line: null, content: raw };
228
+ const [line] = frontmatter.splice(index, 1);
229
+ return { line, content: `---${eol}${frontmatter.join(eol)}${marker}${raw.slice(end + marker.length)}` };
230
+ }
231
+
232
+ function updatedOnlyPathsByCommit(repoRoot, byCommit, maxBuffer, maxPathsPerBatch) {
233
+ const entries = [...byCommit].flatMap(([commit, commitPaths]) => commitPaths.map(filePath => ({ commit, filePath })));
234
+ const paths = new Set();
235
+ // Blob contents are much larger than path metadata, so keep each bounded
236
+ // cat-file response comfortably below the shared output cap.
237
+ const batchSize = Math.min(maxPathsPerBatch, 32);
238
+ for (let offset = 0; offset < entries.length; offset += batchSize) {
239
+ const batch = entries.slice(offset, offset + batchSize);
240
+ if (batch.some(item => item.filePath.includes('\n'))) return { paths: new Set(), complete: false };
241
+ const specs = batch.flatMap(item => [`${item.commit}:${item.filePath}`, `${item.commit}^:${item.filePath}`]);
242
+ const result = spawnSync('git', ['cat-file', '--batch'], {
243
+ cwd: repoRoot, input: specs.join('\n') + '\n', maxBuffer,
244
+ });
245
+ if (result.error || result.status !== 0) {
246
+ return { paths: new Set(), complete: false };
247
+ }
248
+ const objects = parseBatchObjects(result.stdout, specs.length);
249
+ if (!objects) return { paths: new Set(), complete: false };
250
+ for (let i = 0; i < batch.length; i++) {
251
+ const current = withoutUpdatedLine(objects[i * 2]);
252
+ const parent = withoutUpdatedLine(objects[i * 2 + 1]);
253
+ if (!current || !parent || current.line === parent.line) continue;
254
+ if (current.content === parent.content) paths.add(`${batch[i].commit}\0${batch[i].filePath}`);
255
+ }
256
+ }
257
+ return { paths, complete: true };
258
+ }
259
+
260
+ // Resolve the latest substantive date for paths whose latest commit may only
261
+ // have synchronized the top-level `updated:` line. Callers provide their first
262
+ // bounded history scan so normal paths pay no extra Git cost. Metadata-only
263
+ // paths are grouped by commit, then resolved from the retained history window
264
+ // (with a bounded parent fallback); consecutive sync-only commits are skipped.
265
+ export function getGitLastSubstantiveModifiedBatch(repoRoot, relPaths, initialMetadata, options = {}) {
266
+ const maxBuffer = boundedPositiveInteger(options.maxBuffer, DEFAULT_GIT_METADATA_MAX_BUFFER, 'maxBuffer');
267
+ const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
268
+ const paths = [...new Set(relPaths ?? [])];
269
+ assertSafeGitPaths(paths);
270
+ if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
271
+
272
+ const initial = initialMetadata ?? getGitLastModifiedBatch(repoRoot, paths, options);
273
+ const dates = new Map(initial.dates);
274
+ const commits = new Map(initial.commits ?? []);
275
+ const history = new Map(initial.history ?? []);
276
+ let complete = initial.complete;
277
+ let reason = initial.reason;
278
+ const byCommit = new Map();
279
+ for (const filePath of paths) {
280
+ const commit = commits.get(filePath);
281
+ if (!commit) continue;
282
+ if (!byCommit.has(commit)) byCommit.set(commit, []);
283
+ byCommit.get(commit).push(filePath);
284
+ }
285
+
286
+ const classification = updatedOnlyPathsByCommit(repoRoot, byCommit, maxBuffer, maxPathsPerBatch);
287
+ if (!classification.complete) {
288
+ complete = false;
289
+ reason = reason ?? 'git-error';
290
+ }
291
+
292
+ for (const [commit, commitPaths] of byCommit) {
293
+ const metadataOnly = commitPaths.filter(filePath => classification.paths.has(`${commit}\0${filePath}`));
294
+ if (metadataOnly.length === 0) continue;
295
+
296
+ const older = {
297
+ dates: new Map(), commits: new Map(), history: new Map(),
298
+ complete: initial.complete, reason: initial.reason,
299
+ };
300
+ const unresolved = [];
301
+ for (const filePath of metadataOnly) {
302
+ const entries = history.get(filePath) ?? [];
303
+ const currentIndex = entries.findIndex(entry => entry.commit === commit);
304
+ const remaining = currentIndex >= 0 ? entries.slice(currentIndex + 1) : [];
305
+ if (remaining.length === 0) {
306
+ unresolved.push(filePath);
307
+ continue;
308
+ }
309
+ older.dates.set(filePath, remaining[0].date);
310
+ older.commits.set(filePath, remaining[0].commit);
311
+ older.history.set(filePath, remaining);
312
+ }
313
+ if (unresolved.length > 0) {
314
+ older.complete = false;
315
+ older.reason = older.reason ?? 'commit-limit';
316
+ }
317
+ const substantive = getGitLastSubstantiveModifiedBatch(repoRoot, metadataOnly, older, options);
318
+ complete = complete && substantive.complete;
319
+ reason = reason ?? substantive.reason;
320
+ for (const filePath of metadataOnly) {
321
+ if (substantive.dates.has(filePath)) {
322
+ dates.set(filePath, substantive.dates.get(filePath));
323
+ commits.set(filePath, substantive.commits.get(filePath));
324
+ if (substantive.history.has(filePath)) history.set(filePath, substantive.history.get(filePath));
325
+ } else {
326
+ dates.delete(filePath);
327
+ commits.delete(filePath);
328
+ history.delete(filePath);
329
+ }
330
+ }
331
+ }
332
+
333
+ return { dates, commits, history, complete, reason };
143
334
  }
144
335
 
145
336
  function parseNullPaths(result) {
package/src/index.mjs CHANGED
@@ -26,6 +26,7 @@ import { checkSkillDrift } from './skill-drift.mjs';
26
26
  export function buildIndex(config, opts = {}) {
27
27
  const { fast = false, errorsOnly = false, autoHealIndex = false } = opts;
28
28
  const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
29
+ const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
29
30
  const skipWarningOnlyChecks = fast || errorsOnly;
30
31
  const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
31
32
  if (!fast) {
@@ -141,8 +142,10 @@ export function buildIndex(config, opts = {}) {
141
142
  if (hub) hub.warnings.push(w);
142
143
  }
143
144
 
144
- const gitWarnings = checkGitStaleness(transformedDocs, config, opts.gitMetadataOptions);
145
- warnings.push(...gitWarnings);
145
+ if (gitStaleness) {
146
+ const gitWarnings = checkGitStaleness(transformedDocs, config, opts.gitMetadataOptions);
147
+ warnings.push(...gitWarnings);
148
+ }
146
149
 
147
150
  const claudeWarnings = checkClaudeCommands(config.repoRoot);
148
151
  warnings.push(...claudeWarnings);
package/src/lifecycle.mjs CHANGED
@@ -3,7 +3,7 @@ import path from 'node:path';
3
3
  import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, die, warn, resolveDocPath, escapeRegex, nowIso, suggestCandidates, emitFilesFooter, isArchivedPath, currentSessionId } from './util.mjs';
5
5
  import { readJournalEntries } from './journal.mjs';
6
- import { captureGitIndexGeneration, getGitLastModifiedBatch, isTracked } from './git.mjs';
6
+ import { captureGitIndexGeneration, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch, isTracked } from './git.mjs';
7
7
  import { buildIndex, collectDocFiles, resolveDocArg } from './index.mjs';
8
8
  import { writeRenderedIndex } from './index-file.mjs';
9
9
  import { green, dim } from './color.mjs';
@@ -1137,41 +1137,70 @@ export function runTouch(argv, config, opts = {}) {
1137
1137
  if (argv[i].startsWith('-')) continue;
1138
1138
  positional.push(argv[i]);
1139
1139
  }
1140
+ const inputs = positional;
1140
1141
  const input = positional[0];
1141
1142
 
1142
1143
  // --git mode: bulk-sync frontmatter dates from git history
1143
1144
  if (useGit) {
1144
- const allFiles = input ? [resolveDocArg(input, config)] : collectDocFiles(config);
1145
+ const allFiles = inputs.length > 0
1146
+ ? [...new Set(inputs.map(item => resolveDocArg(item, config)))]
1147
+ : collectDocFiles(config);
1145
1148
  authorizeManagedSweep(allFiles, config, { kind: 'Touch --git source' });
1146
1149
 
1147
- const prefix = dryRun ? dim('[dry-run] ') : '';
1148
- let synced = 0;
1149
- const repoPaths = allFiles.map(filePath => toRepoPath(filePath, config.repoRoot));
1150
- const gitMetadata = getGitLastModifiedBatch(config.repoRoot, repoPaths, opts.gitMetadataOptions);
1151
- if (!gitMetadata.complete) {
1152
- die(`Cannot touch from incomplete Git metadata (${gitMetadata.reason}); no files were changed.`);
1153
- }
1154
- const gitDates = gitMetadata.dates;
1155
-
1150
+ const records = [];
1156
1151
  for (const filePath of allFiles) {
1157
- const repoPath = toRepoPath(filePath, config.repoRoot);
1158
1152
  const raw = readFileSync(filePath, 'utf8');
1159
1153
  const { frontmatter } = extractFrontmatter(raw);
1160
1154
  if (!frontmatter) continue;
1161
-
1162
1155
  const parsed = parseSimpleFrontmatter(frontmatter);
1163
- const status = asString(parsed.status);
1164
- if (config.lifecycle.skipStaleFor.has(status)) continue;
1156
+ if (config.lifecycle.skipStaleFor.has(asString(parsed.status))) continue;
1157
+ records.push({
1158
+ filePath,
1159
+ repoPath: toRepoPath(filePath, config.repoRoot),
1160
+ fmUpdated: asString(parsed.updated),
1161
+ });
1162
+ }
1165
1163
 
1166
- const fmUpdated = asString(parsed.updated);
1167
- const gitDate = gitDates.get(repoPath) ?? null;
1164
+ const prefix = dryRun ? dim('[dry-run] ') : '';
1165
+ let synced = 0;
1166
+ const repoPaths = records.map(item => item.repoPath);
1167
+ const rootPathspecs = inputs.length > 0 ? null : (config.docsRoots || [config.docsRoot])
1168
+ .map(root => toRepoPath(root, config.repoRoot) || '.');
1169
+ const gitMetadata = getGitLastModifiedBatch(config.repoRoot, repoPaths, {
1170
+ ...(rootPathspecs ? { pathspecs: rootPathspecs } : {}),
1171
+ ...opts.gitMetadataOptions,
1172
+ });
1173
+ if (!gitMetadata.complete) {
1174
+ die(`Cannot touch from incomplete Git metadata (${gitMetadata.reason}); no files were changed.`);
1175
+ }
1176
+ const candidates = [];
1177
+ for (const { filePath, repoPath, fmUpdated } of records) {
1178
+ const gitDate = gitMetadata.dates.get(repoPath) ?? null;
1168
1179
  if (!gitDate) continue;
1169
-
1170
1180
  const gitDay = gitDate.slice(0, 10);
1171
1181
  if (fmUpdated === gitDay) continue;
1172
-
1173
1182
  // Only sync if git is newer than frontmatter (compare date strings)
1174
1183
  if (fmUpdated && fmUpdated >= gitDay) continue;
1184
+ candidates.push({ filePath, repoPath, fmUpdated });
1185
+ }
1186
+
1187
+ const candidatePaths = candidates.map(item => item.repoPath);
1188
+ const substantiveMetadata = getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, {
1189
+ dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
1190
+ commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
1191
+ history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
1192
+ complete: gitMetadata.complete,
1193
+ reason: gitMetadata.reason,
1194
+ }, opts.gitMetadataOptions);
1195
+ if (!substantiveMetadata.complete) {
1196
+ die(`Cannot touch from incomplete Git metadata (${substantiveMetadata.reason}); no files were changed.`);
1197
+ }
1198
+
1199
+ for (const { filePath, repoPath, fmUpdated } of candidates) {
1200
+ const gitDate = substantiveMetadata.dates.get(repoPath) ?? null;
1201
+ if (!gitDate) continue;
1202
+ const gitDay = gitDate.slice(0, 10);
1203
+ if (fmUpdated && fmUpdated >= gitDay) continue;
1175
1204
 
1176
1205
  if (!dryRun) {
1177
1206
  const result = mutateFile(filePath, { repoRoot: config.repoRoot, testHooks: opts.testHooks }, current => {
@@ -1195,6 +1224,7 @@ export function runTouch(argv, config, opts = {}) {
1195
1224
  return;
1196
1225
  }
1197
1226
 
1227
+ if (inputs.length > 1) die('Multiple files require `dotmd touch --git <file...>`.');
1198
1228
  if (!input) { die('Usage: dotmd touch <file>\n dotmd touch --git Bulk-sync dates from git history'); }
1199
1229
 
1200
1230
  let filePath = resolveDocArg(input, config);
package/src/lint.mjs CHANGED
@@ -17,6 +17,55 @@ const KEY_RENAMES = {
17
17
  supportsPlans: 'supports_plans',
18
18
  };
19
19
 
20
+ function frontmatterFieldBlock(frontmatter, field) {
21
+ const lines = frontmatter.split(/\r?\n/);
22
+ const fieldRe = new RegExp(`^${escapeRegex(field)}:`);
23
+ const start = lines.findIndex(line => fieldRe.test(line));
24
+ if (start < 0) return null;
25
+ let end = start + 1;
26
+ while (end < lines.length) {
27
+ if (/^[ \t]/.test(lines[end]) || lines[end].trim() === '') {
28
+ end++;
29
+ continue;
30
+ }
31
+ if (lines[end].trimStart().startsWith('#')) {
32
+ let next = end + 1;
33
+ while (next < lines.length && (lines[next].trim() === '' || lines[next].trimStart().startsWith('#'))) next++;
34
+ if (next < lines.length && /^[ \t]/.test(lines[next])) {
35
+ end++;
36
+ continue;
37
+ }
38
+ }
39
+ break;
40
+ }
41
+ return { lines, start, end };
42
+ }
43
+
44
+ function replaceFrontmatterFieldBlock(frontmatter, field, replacement = '') {
45
+ const block = frontmatterFieldBlock(frontmatter, field);
46
+ if (!block) return frontmatter;
47
+ const { lines, start, end } = block;
48
+ lines.splice(start, end - start, ...(replacement ? replacement.split('\n') : []));
49
+ return lines.join('\n');
50
+ }
51
+
52
+ function frontmatterFieldComments(frontmatter, field) {
53
+ const block = frontmatterFieldBlock(frontmatter, field);
54
+ if (!block) return [];
55
+ return block.lines
56
+ .slice(block.start + 1, block.end)
57
+ .map(line => line.trim())
58
+ .filter(line => line.startsWith('#'));
59
+ }
60
+
61
+ function isAutoFixableString(value) {
62
+ return typeof value === 'string' && !/(^|\s)#/.test(value);
63
+ }
64
+
65
+ function formatYamlScalar(value) {
66
+ return /^[A-Za-z0-9_./-]+$/.test(value) ? value : JSON.stringify(value);
67
+ }
68
+
20
69
  export function runLint(argv, config, opts = {}) {
21
70
  const { dryRun } = opts;
22
71
  const fix = argv.includes('--fix');
@@ -30,6 +79,7 @@ export function runLint(argv, config, opts = {}) {
30
79
  const parsed = parseSimpleFrontmatter(frontmatter);
31
80
  const repoPath = toRepoPath(filePath, config.repoRoot);
32
81
  const fixes = [];
82
+ const skipWarnings = config.lifecycle.skipWarningsFor.has(asString(parsed.status));
33
83
 
34
84
  // Missing type (fixable — infer from root: plans → 'plan', else 'doc')
35
85
  if (!asString(parsed.type)) {
@@ -46,7 +96,7 @@ export function runLint(argv, config, opts = {}) {
46
96
  }
47
97
 
48
98
  // Missing updated
49
- if (!asString(parsed.updated) && asString(parsed.status) && !config.lifecycle.skipWarningsFor.has(asString(parsed.status))) {
99
+ if (!asString(parsed.updated) && asString(parsed.status) && !skipWarnings) {
50
100
  const today = new Date().toISOString().slice(0, 10);
51
101
  fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
52
102
  }
@@ -72,26 +122,31 @@ export function runLint(argv, config, opts = {}) {
72
122
  // split on `,`, single values become a one-item list. Merging with any
73
123
  // existing plural array happens at apply-time so the message reflects
74
124
  // just what's being introduced from the singular form.
75
- for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
76
- const rawVal = parsed[singular];
77
- const val = asString(rawVal);
78
- if (val) {
79
- // Inline value (`surface: foo`) → migrate into the plural array.
80
- const values = val.includes(',')
81
- ? val.split(',').map(s => s.trim()).filter(Boolean)
82
- : [val];
83
- fixes.push({ field: singular, oldValue: val, newValue: values, pluralKey: plural, type: 'singular-to-plural' });
84
- } else if (singular in parsed && (!Array.isArray(rawVal) || rawVal.length === 0)) {
85
- // Empty deprecated key (`surface:` with nothing after it, which the
86
- // parser yields as `[]`) — usually sitting right above a populated
87
- // `surfaces:`. There's no value to migrate; the deprecation warning
88
- // just wants the dead line gone. `validate` treats the empty `[]` as
89
- // present (so it warns), while the old `asString` gate here saw it as
90
- // absent and skipped — the exact no-op reported in issue #17. Emit a
91
- // drop fix so `lint --fix` does what the warning advertises. A
92
- // populated block-form singular (rawVal.length > 0) is left untouched:
93
- // too rare to risk mis-editing.
94
- fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
125
+ if (!skipWarnings) {
126
+ for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
127
+ const rawVal = parsed[singular];
128
+ const rawPluralVal = parsed[plural];
129
+ const val = asString(rawVal);
130
+ const scalarValue = val && isAutoFixableString(val) ? val : null;
131
+ const blockValues = Array.isArray(rawVal) && rawVal.every(isAutoFixableString)
132
+ ? rawVal.map(item => item.trim()).filter(Boolean)
133
+ : [];
134
+ const pluralAutoFixable = rawPluralVal === undefined
135
+ || isAutoFixableString(rawPluralVal)
136
+ || (Array.isArray(rawPluralVal) && rawPluralVal.every(isAutoFixableString));
137
+ if (pluralAutoFixable && (scalarValue || blockValues.length > 0)) {
138
+ const values = blockValues.length > 0
139
+ ? blockValues
140
+ : scalarValue.includes(',')
141
+ ? scalarValue.split(',').map(s => s.trim()).filter(Boolean)
142
+ : [scalarValue];
143
+ fixes.push({ field: singular, oldValue: scalarValue || blockValues.join(', '), newValue: values, pluralKey: plural, type: 'singular-to-plural' });
144
+ } else if (pluralAutoFixable && singular in parsed && (
145
+ (typeof rawVal === 'string' && !rawVal.trim())
146
+ || (Array.isArray(rawVal) && rawVal.every(item => typeof item === 'string' && !item.trim()))
147
+ )) {
148
+ fixes.push({ field: singular, pluralKey: plural, type: 'drop-singular' });
149
+ }
95
150
  }
96
151
  }
97
152
 
@@ -113,27 +168,31 @@ export function runLint(argv, config, opts = {}) {
113
168
  }
114
169
  }
115
170
 
116
- // Also get non-fixable issues from index, excluding issues we can already fix
117
- const index = buildIndex(config);
118
- const fixablePaths = new Set(fixable.map(f => f.repoPath));
119
- // Singular-key deprecation warnings whose fix `lint --fix` will actually make.
120
- // Without this, those warnings landed in BOTH the "fixable" preview and the
121
- // "non-fixable" list, so the header claimed "N non-fixable" while every line
122
- // told you to run `lint --fix` (issue #17, item 8).
123
- const fixableSingularKeys = new Set();
124
- for (const { repoPath, fixes } of fixable) {
125
- for (const f of fixes) {
126
- if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
127
- fixableSingularKeys.add(`${repoPath}::${f.field}`);
171
+ let nonFixable = [];
172
+ if (!fix) {
173
+ // Report-only mode also gets non-fixable issues from the index. Fix mode
174
+ // does not render these and must not pay for a full validation/Git scan.
175
+ const index = buildIndex(config);
176
+ const fixablePaths = new Set(fixable.map(f => f.repoPath));
177
+ // Singular-key deprecation warnings whose fix `lint --fix` will actually make.
178
+ // Without this, those warnings landed in BOTH the "fixable" preview and the
179
+ // "non-fixable" list, so the header claimed "N non-fixable" while every line
180
+ // told you to run `lint --fix` (issue #17, item 8).
181
+ const fixableSingularKeys = new Set();
182
+ for (const { repoPath, fixes } of fixable) {
183
+ for (const f of fixes) {
184
+ if (f.type === 'singular-to-plural' || f.type === 'drop-singular') {
185
+ fixableSingularKeys.add(`${repoPath}::${f.field}`);
186
+ }
128
187
  }
129
188
  }
189
+ nonFixable = [...index.errors, ...index.warnings].filter(issue => {
190
+ if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
191
+ const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
192
+ if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
193
+ return true;
194
+ });
130
195
  }
131
- const nonFixable = [...index.errors, ...index.warnings].filter(issue => {
132
- if (issue.message.includes('Missing frontmatter `status`') && fixablePaths.has(issue.path)) return false;
133
- const dep = issue.message.match(/^`(module|surface):` \(singular\) is deprecated/);
134
- if (dep && fixableSingularKeys.has(`${issue.path}::${dep[1]}`)) return false;
135
- return true;
136
- });
137
196
 
138
197
  if (!fix) {
139
198
  // Report mode
@@ -225,22 +284,25 @@ export function runLint(argv, config, opts = {}) {
225
284
  }
226
285
 
227
286
  // Apply singular-to-plural fixes (module/surface → modules/surfaces array).
228
- // Removes the singular key line; merges its value(s) into the plural array,
287
+ // Removes the singular key block; merges its value(s) into the plural array,
229
288
  // or creates the plural block if absent. Duplicates are skipped.
230
289
  for (const sa of singularToPlural) {
231
290
  let raw = readFileSync(filePath, 'utf8');
232
291
  const { frontmatter: fm } = extractFrontmatter(raw);
233
- let newFm = fm.replace(new RegExp(`^${escapeRegex(sa.field)}:.*$`, 'm'), '').replace(/\n{2,}/g, '\n');
234
- const pluralLineRe = new RegExp(`^${escapeRegex(sa.pluralKey)}:[ \\t]*$`, 'm');
235
- if (pluralLineRe.test(newFm)) {
236
- for (const val of sa.newValue) {
237
- const hasVal = new RegExp(`^[ \\t]*-[ \\t]+${escapeRegex(val)}[ \\t]*$`, 'm').test(newFm);
238
- if (!hasVal) {
239
- newFm = newFm.replace(pluralLineRe, `${sa.pluralKey}:\n - ${val}`);
240
- }
241
- }
292
+ const parsedFm = parseSimpleFrontmatter(fm);
293
+ const pluralValue = parsedFm[sa.pluralKey];
294
+ const existing = Array.isArray(pluralValue)
295
+ ? pluralValue.filter(value => typeof value === 'string' && value.trim())
296
+ : asString(pluralValue) ? [asString(pluralValue)] : [];
297
+ const merged = [...new Set([...sa.newValue, ...existing])];
298
+ const comments = [...frontmatterFieldComments(fm, sa.field), ...frontmatterFieldComments(fm, sa.pluralKey)];
299
+ let newFm = replaceFrontmatterFieldBlock(fm, sa.field);
300
+ const pluralLines = [sa.pluralKey + ':', ...comments.map(comment => ` ${comment}`), ...merged.map(v => ` - ${formatYamlScalar(v)}`)];
301
+ const pluralBlock = pluralLines.join('\n');
302
+ if (Object.prototype.hasOwnProperty.call(parsedFm, sa.pluralKey)) {
303
+ newFm = replaceFrontmatterFieldBlock(newFm, sa.pluralKey, pluralBlock);
242
304
  } else {
243
- newFm += `\n${sa.pluralKey}:\n${sa.newValue.map(v => ` - ${v}`).join('\n')}`;
305
+ newFm = `${newFm.trim()}\n${pluralBlock}`;
244
306
  }
245
307
  raw = replaceFrontmatter(raw, newFm.trim());
246
308
  writeFileSync(filePath, raw, 'utf8');
@@ -252,9 +314,8 @@ export function runLint(argv, config, opts = {}) {
252
314
  for (const ds of dropSingular) {
253
315
  let raw = readFileSync(filePath, 'utf8');
254
316
  const { frontmatter: fm } = extractFrontmatter(raw);
255
- const newFm = fm
256
- .replace(new RegExp(`^${escapeRegex(ds.field)}:[ \\t]*$`, 'm'), '')
257
- .replace(/\n{2,}/g, '\n');
317
+ const comments = frontmatterFieldComments(fm, ds.field);
318
+ const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
258
319
  raw = replaceFrontmatter(raw, newFm.trim());
259
320
  writeFileSync(filePath, raw, 'utf8');
260
321
  }
package/src/render.mjs CHANGED
@@ -422,7 +422,8 @@ export function classifyIssueAction(issue) {
422
422
  if (/Missing frontmatter `updated`/.test(message) || /frontmatter `updated: .*` is behind git history/.test(message)) {
423
423
  return { action: 'dotmd touch --git', fixable: true, label: 'dates' };
424
424
  }
425
- if (/`(?:module|surface):` \(singular\) is deprecated/.test(message) || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
425
+ if ((/`(?:module|surface):` \(singular\) is deprecated/.test(message) && /Run `dotmd lint --fix`/.test(message))
426
+ || /camelCase|nextStep|currentState|auditLevel/.test(message)) {
426
427
  return { action: 'dotmd lint --fix', fixable: true, label: 'frontmatter migrations' };
427
428
  }
428
429
  if (/Unknown surface/.test(message)) {
@@ -456,8 +457,9 @@ export function renderManualFixes(index) {
456
457
  for (const issue of issues) {
457
458
  const item = classifyIssueAction(issue);
458
459
  const key = `${item.fixable ? 'fixable' : 'manual'}:${item.action}`;
459
- if (!groups.has(key)) groups.set(key, { ...item, paths: new Set() });
460
+ if (!groups.has(key)) groups.set(key, { ...item, paths: new Set(), reasons: new Set() });
460
461
  groups.get(key).paths.add(issue.path);
462
+ groups.get(key).reasons.add(issue.message);
461
463
  }
462
464
  if (groups.size === 0) return '';
463
465
 
@@ -478,7 +480,9 @@ export function renderManualFixes(index) {
478
480
  lines.push('Manual fixes remaining');
479
481
  for (const group of manual) {
480
482
  const count = group.paths.size;
481
- lines.push(`- ${group.action} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
483
+ const reasons = [...group.reasons];
484
+ const reason = reasons.slice(0, 2).join('; ') + (reasons.length > 2 ? '; ...' : '');
485
+ lines.push(`- ${group.action}: ${reason} (${count} ${count === 1 ? 'file' : 'files'}: ${[...group.paths].slice(0, 3).join(', ')}${count > 3 ? ', ...' : ''})`);
482
486
  }
483
487
  lines.push('');
484
488
  }
package/src/validate.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { asString, resolveRefPath, suggestCandidates } from './util.mjs';
3
- import { getGitLastModified, getGitLastModifiedBatch } from './git.mjs';
3
+ import { getGitLastModified, getGitLastModifiedBatch, getGitLastSubstantiveModifiedBatch } from './git.mjs';
4
4
  import { toRepoPath } from './util.mjs';
5
5
 
6
6
  const NOW = new Date();
@@ -156,17 +156,27 @@ export function validateDoc(doc, frontmatter, headingTitle, config) {
156
156
  if (!config.lifecycle.skipWarningsFor.has(doc.status)) {
157
157
  for (const { singular, plural } of [{ singular: 'module', plural: 'modules' }, { singular: 'surface', plural: 'surfaces' }]) {
158
158
  const singularValue = frontmatter[singular];
159
- if (!singularValue) continue;
160
- const pluralValue = Array.isArray(frontmatter[plural]) ? frontmatter[plural] : [];
159
+ if (!Object.prototype.hasOwnProperty.call(frontmatter, singular)) continue;
160
+ const rawPluralValue = frontmatter[plural];
161
+ const pluralValue = Array.isArray(rawPluralValue)
162
+ ? rawPluralValue
163
+ : rawPluralValue === undefined ? [] : [rawPluralValue];
164
+ const singularValues = Array.isArray(singularValue) ? singularValue : [singularValue];
161
165
  const merged = [];
162
- for (const v of [singularValue, ...pluralValue]) {
166
+ for (const v of [...singularValues, ...pluralValue]) {
163
167
  if (typeof v === 'string' && v && !merged.includes(v)) merged.push(v);
164
168
  }
165
- const target = `${plural}: [${merged.map(v => `"${v}"`).join(', ')}]`;
169
+ const target = `${plural}: [${merged.map(v => JSON.stringify(v)).join(', ')}]`;
170
+ const autoFixableString = value => typeof value === 'string' && !/(^|\s)#/.test(value);
171
+ const autoFixable = singularValues.every(autoFixableString)
172
+ && pluralValue.every(autoFixableString);
173
+ const guidance = autoFixable
174
+ ? `use \`${target}\`. Run \`dotmd lint --fix\` to migrate.`
175
+ : `use a \`${plural}:\` YAML list. Remove the deprecated \`${singular}:\` block manually and preserve all of its values.`;
166
176
  doc.warnings.push({
167
177
  path: doc.path,
168
178
  level: 'warning',
169
- message: `\`${singular}:\` (singular) is deprecated — use \`${target}\`. Run \`dotmd lint --fix\` to migrate.`,
179
+ message: `\`${singular}:\` (singular) is deprecated — ${guidance}`,
170
180
  });
171
181
  }
172
182
  }
@@ -521,15 +531,33 @@ export function checkRoadmapHubExecutionMode(docs, config) {
521
531
 
522
532
  export function checkGitStaleness(docs, config, options = {}) {
523
533
  const warnings = [];
524
- const gitMetadata = getGitLastModifiedBatch(config.repoRoot, docs.map(doc => doc.path), options);
525
- const gitDates = gitMetadata.dates;
526
- for (const doc of docs) {
527
- if (config.lifecycle.skipStaleFor.has(doc.status)) continue;
528
- if (!doc.updated) continue;
534
+ const eligibleDocs = docs.filter(doc => !config.lifecycle.skipStaleFor.has(doc.status) && doc.updated);
535
+ const pathspecs = (config.docsRoots || [config.docsRoot])
536
+ .map(root => toRepoPath(root, config.repoRoot) || '.');
537
+ const gitMetadata = getGitLastModifiedBatch(
538
+ config.repoRoot,
539
+ eligibleDocs.map(doc => doc.path),
540
+ { pathspecs, ...options },
541
+ );
542
+ const driftCandidates = eligibleDocs.filter(doc => {
543
+ const gitDate = gitMetadata.dates.get(doc.path) ?? null;
544
+ return Boolean(gitDate && gitDate.slice(0, 10) > doc.updated.slice(0, 10));
545
+ });
546
+ const candidatePaths = driftCandidates.map(doc => doc.path);
547
+ const candidateMetadata = {
548
+ dates: new Map(candidatePaths.filter(p => gitMetadata.dates.has(p)).map(p => [p, gitMetadata.dates.get(p)])),
549
+ commits: new Map(candidatePaths.filter(p => gitMetadata.commits?.has(p)).map(p => [p, gitMetadata.commits.get(p)])),
550
+ history: new Map(candidatePaths.filter(p => gitMetadata.history?.has(p)).map(p => [p, gitMetadata.history.get(p)])),
551
+ complete: gitMetadata.complete,
552
+ reason: gitMetadata.reason,
553
+ };
554
+ const substantiveMetadata = candidatePaths.length > 0
555
+ ? getGitLastSubstantiveModifiedBatch(config.repoRoot, candidatePaths, candidateMetadata, options)
556
+ : candidateMetadata;
529
557
 
530
- const gitDate = gitDates.get(doc.path) ?? null;
558
+ for (const doc of driftCandidates) {
559
+ const gitDate = substantiveMetadata.dates.get(doc.path) ?? null;
531
560
  if (!gitDate) continue;
532
-
533
561
  const gitDay = gitDate.slice(0, 10);
534
562
  const fmDay = doc.updated.slice(0, 10);
535
563
 
@@ -541,11 +569,11 @@ export function checkGitStaleness(docs, config, options = {}) {
541
569
  });
542
570
  }
543
571
  }
544
- if (!gitMetadata.complete) {
572
+ if (!substantiveMetadata.complete) {
545
573
  warnings.push({
546
574
  path: toRepoPath(config.docsRoot, config.repoRoot) || '.',
547
575
  level: 'warning',
548
- message: `Git metadata is incomplete (${gitMetadata.reason}); staleness checks used known dates only.`,
576
+ message: `Git metadata is incomplete (${substantiveMetadata.reason}); staleness checks used known dates only.`,
549
577
  });
550
578
  }
551
579
  return warnings;