dotmd-cli 0.70.0 → 0.70.1
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 +14 -8
- package/package.json +1 -1
- package/src/check-collapse.mjs +2 -2
- package/src/commands.mjs +1 -1
- package/src/doctor.mjs +14 -11
- package/src/frontmatter-fix.mjs +7 -7
- package/src/git.mjs +209 -18
- package/src/index.mjs +5 -2
- package/src/lifecycle.mjs +49 -19
- package/src/lint.mjs +114 -53
- package/src/render.mjs +7 -3
- package/src/validate.mjs +43 -15
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
|
|
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,
|
|
706
|
-
|
|
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 —
|
|
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
|
|
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
|
|
|
@@ -1444,6 +1446,10 @@ async function main() {
|
|
|
1444
1446
|
const effectiveDryRun = dryRun || (command === 'doctor' && !doctorSubMode && !doctorExplicitApply);
|
|
1445
1447
|
const passiveMachineContext = command === 'agent-context'
|
|
1446
1448
|
|| (command === 'context' && args.includes('--json') && args.includes('--compact'));
|
|
1449
|
+
// Git/frontmatter drift is validation work, not index construction. Keep the
|
|
1450
|
+
// bounded history scan on commands that report or repair that drift; ordinary
|
|
1451
|
+
// reads still run schema/reference validation without walking 10k commits.
|
|
1452
|
+
const gitStaleness = command === 'check' || command === 'doctor';
|
|
1447
1453
|
_suppressObservability = effectiveDryRun || command === 'hud' || passiveMachineContext;
|
|
1448
1454
|
|
|
1449
1455
|
// Per-command help
|
|
@@ -1474,7 +1480,7 @@ async function main() {
|
|
|
1474
1480
|
_resolvedConfig = config;
|
|
1475
1481
|
const suppressSideEffects = effectiveDryRun || command === 'hud' || passiveMachineContext;
|
|
1476
1482
|
Object.defineProperty(config, '_execution', {
|
|
1477
|
-
value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects },
|
|
1483
|
+
value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects, gitStaleness },
|
|
1478
1484
|
enumerable: false,
|
|
1479
1485
|
});
|
|
1480
1486
|
// Unknown names may still be user-defined query presets. Every built-in
|
package/package.json
CHANGED
package/src/check-collapse.mjs
CHANGED
|
@@ -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,
|
|
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:
|
|
93
|
-
process.stdout.write('\n' + bold('3.
|
|
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
|
|
97
|
-
//
|
|
98
|
-
|
|
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
|
|
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
|
|
113
|
-
process.stdout.write('\n' + bold('
|
|
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
|
|
135
|
-
const issueLabel = dryRun ? '
|
|
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));
|
package/src/frontmatter-fix.mjs
CHANGED
|
@@ -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
|
|
133
|
-
//
|
|
134
|
-
|
|
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
|
|
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'
|
|
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)
|
|
90
|
-
|
|
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}`,
|
|
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
|
|
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 (
|
|
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 <
|
|
129
|
-
const batch =
|
|
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:
|
|
133
|
-
parseGitMetadataOutput(result.stdout, dates,
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1167
|
-
|
|
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) && !
|
|
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
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
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
|
|
256
|
-
|
|
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)
|
|
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
|
-
|
|
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 (!
|
|
160
|
-
const
|
|
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 [
|
|
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 =>
|
|
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 —
|
|
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
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (${
|
|
576
|
+
message: `Git metadata is incomplete (${substantiveMetadata.reason}); staleness checks used known dates only.`,
|
|
549
577
|
});
|
|
550
578
|
}
|
|
551
579
|
return warnings;
|