dotmd-cli 0.69.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.
Files changed (54) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +251 -202
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/check-collapse.mjs +2 -2
  10. package/src/commands.mjs +326 -12
  11. package/src/completions.mjs +38 -98
  12. package/src/config.mjs +18 -3
  13. package/src/diff.mjs +7 -3
  14. package/src/doctor.mjs +25 -15
  15. package/src/export.mjs +154 -25
  16. package/src/fix-refs.mjs +2 -0
  17. package/src/frontmatter-fix.mjs +9 -7
  18. package/src/frontmatter.mjs +3 -2
  19. package/src/git.mjs +722 -14
  20. package/src/graph.mjs +53 -25
  21. package/src/guard.mjs +163 -60
  22. package/src/hud.mjs +65 -76
  23. package/src/index-file.mjs +28 -16
  24. package/src/index.mjs +21 -13
  25. package/src/init.mjs +1 -1
  26. package/src/journal.mjs +145 -12
  27. package/src/lifecycle.mjs +596 -294
  28. package/src/lint.mjs +117 -56
  29. package/src/managed-path.mjs +192 -0
  30. package/src/migrate-prompts.mjs +2 -0
  31. package/src/migrate-template.mjs +2 -0
  32. package/src/migrate.mjs +7 -1
  33. package/src/new.mjs +135 -54
  34. package/src/output-identity.mjs +106 -0
  35. package/src/pickup-card.mjs +24 -10
  36. package/src/pickup.mjs +457 -0
  37. package/src/prompts.mjs +134 -75
  38. package/src/query.mjs +22 -10
  39. package/src/reference-planner.mjs +292 -0
  40. package/src/rename.mjs +65 -73
  41. package/src/render.mjs +24 -11
  42. package/src/runlist.mjs +109 -71
  43. package/src/section.mjs +2 -1
  44. package/src/ship.mjs +39 -20
  45. package/src/stats.mjs +1 -1
  46. package/src/status-metadata.mjs +87 -0
  47. package/src/statuses.mjs +11 -26
  48. package/src/summary.mjs +14 -3
  49. package/src/update.mjs +38 -10
  50. package/src/use.mjs +4 -1
  51. package/src/util.mjs +1 -0
  52. package/src/validate.mjs +53 -17
  53. package/src/watch.mjs +6 -1
  54. package/src/notion.mjs +0 -528
package/src/lint.mjs CHANGED
@@ -6,6 +6,7 @@ import { buildIndex, collectDocFiles } from './index.mjs';
6
6
  import { updateFrontmatter } from './lifecycle.mjs';
7
7
  import { runMLX, checkUvAvailable } from './ai.mjs';
8
8
  import { bold, green, yellow, dim } from './color.mjs';
9
+ import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
9
10
 
10
11
  const KEY_RENAMES = {
11
12
  nextStep: 'next_step',
@@ -16,6 +17,55 @@ const KEY_RENAMES = {
16
17
  supportsPlans: 'supports_plans',
17
18
  };
18
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
+
19
69
  export function runLint(argv, config, opts = {}) {
20
70
  const { dryRun } = opts;
21
71
  const fix = argv.includes('--fix');
@@ -29,11 +79,11 @@ export function runLint(argv, config, opts = {}) {
29
79
  const parsed = parseSimpleFrontmatter(frontmatter);
30
80
  const repoPath = toRepoPath(filePath, config.repoRoot);
31
81
  const fixes = [];
82
+ const skipWarnings = config.lifecycle.skipWarningsFor.has(asString(parsed.status));
32
83
 
33
84
  // Missing type (fixable — infer from root: plans → 'plan', else 'doc')
34
85
  if (!asString(parsed.type)) {
35
- const roots = config.docsRoots || [config.docsRoot];
36
- const docRoot = roots.find(r => filePath.startsWith(r)) ?? config.docsRoot;
86
+ const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
37
87
  const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
38
88
  // If the root label contains 'plan' (e.g. 'docs/plans'), default to plan type
39
89
  const inferredType = rootLabel.includes('plan') ? 'plan' : 'doc';
@@ -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
@@ -176,6 +235,7 @@ export function runLint(argv, config, opts = {}) {
176
235
  }
177
236
 
178
237
  // Fix mode
238
+ authorizeManagedSweep(fixable.map(item => item.filePath), config, { kind: 'Lint fix source' });
179
239
  const prefix = dryRun ? dim('[dry-run] ') : '';
180
240
  let totalFixes = 0;
181
241
 
@@ -224,22 +284,25 @@ export function runLint(argv, config, opts = {}) {
224
284
  }
225
285
 
226
286
  // Apply singular-to-plural fixes (module/surface → modules/surfaces array).
227
- // 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,
228
288
  // or creates the plural block if absent. Duplicates are skipped.
229
289
  for (const sa of singularToPlural) {
230
290
  let raw = readFileSync(filePath, 'utf8');
231
291
  const { frontmatter: fm } = extractFrontmatter(raw);
232
- let newFm = fm.replace(new RegExp(`^${escapeRegex(sa.field)}:.*$`, 'm'), '').replace(/\n{2,}/g, '\n');
233
- const pluralLineRe = new RegExp(`^${escapeRegex(sa.pluralKey)}:[ \\t]*$`, 'm');
234
- if (pluralLineRe.test(newFm)) {
235
- for (const val of sa.newValue) {
236
- const hasVal = new RegExp(`^[ \\t]*-[ \\t]+${escapeRegex(val)}[ \\t]*$`, 'm').test(newFm);
237
- if (!hasVal) {
238
- newFm = newFm.replace(pluralLineRe, `${sa.pluralKey}:\n - ${val}`);
239
- }
240
- }
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);
241
304
  } else {
242
- newFm += `\n${sa.pluralKey}:\n${sa.newValue.map(v => ` - ${v}`).join('\n')}`;
305
+ newFm = `${newFm.trim()}\n${pluralBlock}`;
243
306
  }
244
307
  raw = replaceFrontmatter(raw, newFm.trim());
245
308
  writeFileSync(filePath, raw, 'utf8');
@@ -251,9 +314,8 @@ export function runLint(argv, config, opts = {}) {
251
314
  for (const ds of dropSingular) {
252
315
  let raw = readFileSync(filePath, 'utf8');
253
316
  const { frontmatter: fm } = extractFrontmatter(raw);
254
- const newFm = fm
255
- .replace(new RegExp(`^${escapeRegex(ds.field)}:[ \\t]*$`, 'm'), '')
256
- .replace(/\n{2,}/g, '\n');
317
+ const comments = frontmatterFieldComments(fm, ds.field);
318
+ const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
257
319
  raw = replaceFrontmatter(raw, newFm.trim());
258
320
  writeFileSync(filePath, raw, 'utf8');
259
321
  }
@@ -322,4 +384,3 @@ export function runLint(argv, config, opts = {}) {
322
384
 
323
385
  process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
324
386
  }
325
-
@@ -0,0 +1,192 @@
1
+ import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // These checks are authorization preflights, not filesystem transactions. A
5
+ // concurrent actor can still replace a checked ancestor before the later write
6
+ // (TOCTOU); eliminating that residual boundary requires descriptor-relative IO.
7
+
8
+ function contains(parent, child) {
9
+ const rel = path.relative(parent, child);
10
+ return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
11
+ }
12
+
13
+ function sameNode(a, b) {
14
+ try {
15
+ const left = statSync(a, { bigint: true });
16
+ const right = statSync(b, { bigint: true });
17
+ return left.dev === right.dev && left.ino === right.ino;
18
+ } catch { return false; }
19
+ }
20
+
21
+ // realpath on case-insensitive macOS volumes may preserve the caller's casing.
22
+ // Prove an alias by inode before treating it as contained; lowercase string
23
+ // comparison alone would be unsafe on case-sensitive volumes.
24
+ function aliasContainment(parent, child) {
25
+ if (contains(parent, child)) return { contained: true, relative: path.relative(parent, child) };
26
+ const parentParts = path.resolve(parent).split(path.sep).filter(Boolean);
27
+ const childParts = path.resolve(child).split(path.sep).filter(Boolean);
28
+ if (childParts.length < parentParts.length) return { contained: false, relative: null };
29
+ const childPrefix = `${path.parse(path.resolve(child)).root}${childParts.slice(0, parentParts.length).join(path.sep)}`;
30
+ if (!sameNode(parent, childPrefix)) return { contained: false, relative: null };
31
+ return { contained: true, relative: childParts.slice(parentParts.length).join(path.sep) };
32
+ }
33
+
34
+ function lexicalRootsFor(config) {
35
+ return (config.docsRoots ?? [config.docsRoot]).map(configuredPath => ({
36
+ configuredPath,
37
+ lexicalPath: path.resolve(configuredPath),
38
+ }));
39
+ }
40
+
41
+ function rootsFor(config) {
42
+ return lexicalRootsFor(config).map(root => {
43
+ const { configuredPath, lexicalPath } = root;
44
+ let canonicalPath;
45
+ try {
46
+ canonicalPath = realpathSync(lexicalPath);
47
+ } catch (err) {
48
+ throw new Error(`Configured docs root cannot be resolved: ${lexicalPath} (${err.message})`);
49
+ }
50
+ return { configuredPath, lexicalPath, canonicalPath };
51
+ });
52
+ }
53
+
54
+ function rootsMessage(roots) {
55
+ return roots.map(root => root.lexicalPath).join(', ');
56
+ }
57
+
58
+ function markdown(pathname, kind, roots) {
59
+ if (!pathname.endsWith('.md')) {
60
+ throw new Error(`${kind} must be a Markdown file ending in .md: ${pathname}\nConfigured docs roots: ${rootsMessage(roots)}`);
61
+ }
62
+ }
63
+
64
+ function nearestExistingAncestor(input, roots, kind) {
65
+ let current = input;
66
+ while (!existsSync(current)) {
67
+ try {
68
+ // existsSync is false for dangling symlinks, but lstat still sees them.
69
+ if (lstatSync(current).isSymbolicLink()) {
70
+ throw new Error(`dangling symlink: ${current}`);
71
+ }
72
+ } catch (err) {
73
+ if (err?.code !== 'ENOENT' && err?.code !== 'ENOTDIR') {
74
+ throw new Error(`${kind} has an unsafe existing ancestor: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
75
+ }
76
+ }
77
+ const parent = path.dirname(current);
78
+ if (parent === current) break;
79
+ current = parent;
80
+ }
81
+ try {
82
+ return realpathSync(current);
83
+ } catch (err) {
84
+ throw new Error(`${kind} ancestor cannot be resolved: ${current} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
85
+ }
86
+ }
87
+
88
+ function lexicalOwner(input, roots) {
89
+ return roots
90
+ .filter(root => contains(root.lexicalPath, input))
91
+ .sort((a, b) => b.lexicalPath.length - a.lexicalPath.length)[0] ?? null;
92
+ }
93
+
94
+ export function findLexicalDocsRoot(input, config) {
95
+ return lexicalOwner(path.resolve(input), lexicalRootsFor(config))?.lexicalPath ?? null;
96
+ }
97
+
98
+ export function authorizeManagedSource(input, config, { kind = 'Managed mutation source' } = {}) {
99
+ const roots = rootsFor(config);
100
+ const lexicalPath = path.resolve(input);
101
+ markdown(lexicalPath, kind, roots);
102
+
103
+ if (!existsSync(lexicalPath)) {
104
+ throw new Error(`${kind} does not exist: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
105
+ }
106
+ if (lstatSync(lexicalPath).isSymbolicLink()) {
107
+ throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
108
+ }
109
+
110
+ let canonicalPath;
111
+ try {
112
+ canonicalPath = realpathSync(lexicalPath);
113
+ } catch (err) {
114
+ throw new Error(`${kind} cannot be resolved: ${lexicalPath} (${err.message})\nConfigured docs roots: ${rootsMessage(roots)}`);
115
+ }
116
+ if (!statSync(canonicalPath).isFile()) {
117
+ throw new Error(`${kind} is not a file: ${lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
118
+ }
119
+
120
+ const lexical = lexicalOwner(lexicalPath, roots);
121
+ if (lexical && !aliasContainment(lexical.canonicalPath, canonicalPath).contained) {
122
+ throw new Error(`${kind} is lexically owned by ${lexical.lexicalPath} but resolves outside that root: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
123
+ }
124
+ const root = lexical
125
+ ?? roots.filter(candidate => aliasContainment(candidate.canonicalPath, canonicalPath).contained)
126
+ .sort((a, b) => b.canonicalPath.length - a.canonicalPath.length)[0];
127
+ if (!root) {
128
+ throw new Error(`${kind} resolves outside configured docs roots: ${lexicalPath} -> ${canonicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
129
+ }
130
+
131
+ // Keep operations on the configured root spelling (important when the root
132
+ // itself is a symlink), while normalizing OS-level aliases such as /var ->
133
+ // /private/var back through the owning root.
134
+ const managedPath = lexical
135
+ ? lexicalPath
136
+ : path.join(root.lexicalPath, aliasContainment(root.canonicalPath, canonicalPath).relative);
137
+ return { path: managedPath, canonicalPath, root };
138
+ }
139
+
140
+ export function authorizeManagedDestination(input, config, { root: requiredRoot = null, kind = 'Managed mutation destination' } = {}) {
141
+ const roots = rootsFor(config);
142
+ const lexicalPath = path.resolve(input);
143
+ markdown(lexicalPath, kind, roots);
144
+
145
+ const root = requiredRoot
146
+ ? roots.find(candidate => candidate.lexicalPath === requiredRoot.lexicalPath && candidate.canonicalPath === requiredRoot.canonicalPath)
147
+ : lexicalOwner(lexicalPath, roots);
148
+ if (!root || !contains(root.lexicalPath, lexicalPath)) {
149
+ const ownership = requiredRoot ? `Owning docs root: ${requiredRoot.lexicalPath}\n` : '';
150
+ throw new Error(`${kind} is lexically outside its configured docs root: ${lexicalPath}\n${ownership}Configured docs roots: ${rootsMessage(roots)}`);
151
+ }
152
+
153
+ const ancestor = nearestExistingAncestor(lexicalPath, roots, kind);
154
+ if (!aliasContainment(root.canonicalPath, ancestor).contained) {
155
+ throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nOwning docs root: ${root.lexicalPath}\nConfigured docs roots: ${rootsMessage(roots)}`);
156
+ }
157
+ return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null, root };
158
+ }
159
+
160
+ export function authorizeManagedMove(source, destination, config, options = {}) {
161
+ const authorizedSource = authorizeManagedSource(source, config, options);
162
+ const authorizedDestination = authorizeManagedDestination(destination, config, {
163
+ ...options,
164
+ kind: options.destinationKind ?? 'Managed mutation destination',
165
+ root: authorizedSource.root,
166
+ });
167
+ return { source: authorizedSource, destination: authorizedDestination };
168
+ }
169
+
170
+ export function authorizeManagedSweep(files, config, { kind = 'Managed mutation sweep' } = {}) {
171
+ return files.map(file => authorizeManagedSource(file, config, { kind }));
172
+ }
173
+
174
+ export function authorizeRepoGeneratedPath(input, config, { kind = 'Repository-generated destination' } = {}) {
175
+ const lexicalPath = path.resolve(input);
176
+ const repoRoot = path.resolve(config.repoRoot);
177
+ let canonicalRepo;
178
+ try { canonicalRepo = realpathSync(repoRoot); }
179
+ catch (err) { throw new Error(`Repository root cannot be resolved: ${repoRoot} (${err.message})`); }
180
+
181
+ if (!contains(repoRoot, lexicalPath)) {
182
+ throw new Error(`${kind} is outside the repository: ${lexicalPath}\nRepository root: ${repoRoot}`);
183
+ }
184
+ const ancestor = nearestExistingAncestor(lexicalPath, [{ lexicalPath: repoRoot }], kind);
185
+ if (!contains(canonicalRepo, ancestor)) {
186
+ throw new Error(`${kind} escapes through an existing symlinked parent: ${lexicalPath} -> ${ancestor}\nRepository root: ${repoRoot}`);
187
+ }
188
+ if (existsSync(lexicalPath) && lstatSync(lexicalPath).isSymbolicLink()) {
189
+ throw new Error(`${kind} may not be a symlink: ${lexicalPath}\nRepository root: ${repoRoot}`);
190
+ }
191
+ return { path: lexicalPath, canonicalPath: existsSync(lexicalPath) ? realpathSync(lexicalPath) : null };
192
+ }
@@ -6,6 +6,7 @@ import { getGitFirstAdded } from './git.mjs';
6
6
  import { bold, green, dim } from './color.mjs';
7
7
  import { readFileSync as rfs } from 'node:fs';
8
8
  import { fileURLToPath } from 'node:url';
9
+ import { authorizeManagedSweep } from './managed-path.mjs';
9
10
 
10
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
12
  const pkg = JSON.parse(rfs(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -124,6 +125,7 @@ export function runMigratePrompts(argv, config, opts = {}) {
124
125
  } else {
125
126
  files = findPromptCandidates(config);
126
127
  }
128
+ authorizeManagedSweep(files, config, { kind: 'Prompt migration source' });
127
129
 
128
130
  const results = [];
129
131
  let touched = 0;
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { asString, toRepoPath, nowIso } from './util.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
6
  import { bold, green, yellow, dim } from './color.mjs';
7
+ import { authorizeManagedSweep } from './managed-path.mjs';
7
8
 
8
9
  const HEADING_RENAMES = [
9
10
  { from: /^##\s+Open questions\s*$/gm, to: '## Open Questions' },
@@ -117,6 +118,7 @@ export function runMigrateTemplate(argv, config, opts = {}) {
117
118
  files = collectDocFiles(config);
118
119
  if (!includeArchived) files = files.filter(f => !isInArchive(f, config));
119
120
  }
121
+ authorizeManagedSweep(files, config, { kind: 'Template migration source' });
120
122
 
121
123
  const results = [];
122
124
  let totalChanges = 0;
package/src/migrate.mjs CHANGED
@@ -5,6 +5,7 @@ import { asString, toRepoPath, resolveDocPath, die } from './util.mjs';
5
5
  import { collectDocFiles } from './index.mjs';
6
6
  import { updateFrontmatter } from './lifecycle.mjs';
7
7
  import { bold, green, dim } from './color.mjs';
8
+ import { authorizeManagedSweep } from './managed-path.mjs';
8
9
 
9
10
  export function runMigrate(argv, config, opts = {}) {
10
11
  const { dryRun } = opts;
@@ -38,7 +39,11 @@ export function runMigrate(argv, config, opts = {}) {
38
39
  matched.push(filePath);
39
40
  continue;
40
41
  }
41
- const hits = allFiles.filter(f => f.includes(input) || path.basename(f).includes(input));
42
+ const normalizedInput = input.replaceAll('\\', '/');
43
+ const hits = allFiles.filter(f => {
44
+ const repoPath = toRepoPath(f, config.repoRoot);
45
+ return repoPath.includes(normalizedInput) || path.basename(f).includes(input);
46
+ });
42
47
  if (hits.length === 0) {
43
48
  unresolved.push(input);
44
49
  } else {
@@ -52,6 +57,7 @@ export function runMigrate(argv, config, opts = {}) {
52
57
  }
53
58
 
54
59
  const matches = [];
60
+ authorizeManagedSweep(fileFilter ? [...fileFilter] : allFiles, config, { kind: 'Migration source' });
55
61
 
56
62
  for (const filePath of allFiles) {
57
63
  if (fileFilter && !fileFilter.has(filePath)) continue;