runlist 0.0.0 → 0.94.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.
Files changed (167) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1112 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +296 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/public-inspection.mjs +19 -0
  136. package/src/query.mjs +977 -0
  137. package/src/record-action.mjs +11 -0
  138. package/src/record-create.mjs +82 -0
  139. package/src/reference-planner.mjs +388 -0
  140. package/src/reference-values.mjs +11 -0
  141. package/src/rename.mjs +107 -0
  142. package/src/render.mjs +678 -0
  143. package/src/roadmap.mjs +184 -0
  144. package/src/runlist.mjs +944 -0
  145. package/src/section.mjs +225 -0
  146. package/src/semantic-search-client.mjs +58 -0
  147. package/src/ship.mjs +166 -0
  148. package/src/show.mjs +101 -0
  149. package/src/skill-drift.mjs +98 -0
  150. package/src/source-editor.mjs +491 -0
  151. package/src/stages.mjs +73 -0
  152. package/src/state-migration.mjs +123 -0
  153. package/src/stats.mjs +200 -0
  154. package/src/status-metadata.mjs +87 -0
  155. package/src/statuses.mjs +721 -0
  156. package/src/summary.mjs +74 -0
  157. package/src/surfaces.mjs +28 -0
  158. package/src/sync-status.mjs +316 -0
  159. package/src/template-store.mjs +62 -0
  160. package/src/update.mjs +338 -0
  161. package/src/use.mjs +58 -0
  162. package/src/util.mjs +337 -0
  163. package/src/validate.mjs +795 -0
  164. package/src/watch.mjs +56 -0
  165. package/src/xref.mjs +406 -0
  166. package/src/yardstick-command.mjs +178 -0
  167. package/src/yardstick.mjs +112 -0
package/src/lint.mjs ADDED
@@ -0,0 +1,386 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter, replaceFrontmatter } from './frontmatter.mjs';
4
+ import { asString, toRepoPath, escapeRegex, warn } from './util.mjs';
5
+ import { buildIndex, collectDocFiles } from './index.mjs';
6
+ import { updateFrontmatter } from './lifecycle.mjs';
7
+ import { runModel } from './ai.mjs';
8
+ import { bold, green, yellow, dim } from './color.mjs';
9
+ import { authorizeManagedSweep, findLexicalDocsRoot } from './managed-path.mjs';
10
+
11
+ const KEY_RENAMES = {
12
+ nextStep: 'next_step',
13
+ currentState: 'current_state',
14
+ auditLevel: 'audit_level',
15
+ sourceOfTruth: 'source_of_truth',
16
+ relatedPlans: 'related_plans',
17
+ supportsPlans: 'supports_plans',
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
+
69
+ export function runLint(argv, config, opts = {}) {
70
+ const { dryRun } = opts;
71
+ const fix = argv.includes('--fix');
72
+ const allFiles = collectDocFiles(config);
73
+ const fixable = [];
74
+
75
+ for (const filePath of allFiles) {
76
+ const raw = readFileSync(filePath, 'utf8');
77
+ const { frontmatter } = extractFrontmatter(raw);
78
+ if (!frontmatter) continue;
79
+ const parsed = parseSimpleFrontmatter(frontmatter);
80
+ const repoPath = toRepoPath(filePath, config.repoRoot);
81
+ const fixes = [];
82
+ const skipWarnings = config.lifecycle.skipsWarnings(asString(parsed.status), asString(parsed.type));
83
+
84
+ // Missing type (fixable — infer from root: plans → 'plan', else 'doc')
85
+ if (!asString(parsed.type)) {
86
+ const docRoot = findLexicalDocsRoot(filePath, config) ?? config.docsRoot;
87
+ const rootLabel = path.relative(config.repoRoot, docRoot).split(path.sep).join('/');
88
+ // If the root label contains 'plan' (e.g. 'docs/plans'), default to plan type
89
+ const inferredType = rootLabel.includes('plan') ? 'plan' : 'doc';
90
+ fixes.push({ field: 'type', oldValue: null, newValue: inferredType, type: 'add' });
91
+ }
92
+
93
+ // Missing status (fixable via AI inference)
94
+ if (!asString(parsed.status)) {
95
+ fixes.push({ field: 'status', oldValue: null, newValue: null, type: 'infer-status' });
96
+ }
97
+
98
+ // Missing updated
99
+ if (!asString(parsed.updated) && asString(parsed.status) && !skipWarnings) {
100
+ const today = new Date().toISOString().slice(0, 10);
101
+ fixes.push({ field: 'updated', oldValue: null, newValue: today, type: 'add' });
102
+ }
103
+
104
+ // Status casing — check against type-specific or global valid statuses
105
+ const status = asString(parsed.status);
106
+ const docType = asString(parsed.type);
107
+ const typeSet = docType ? config.typeStatuses?.get(docType) : null;
108
+ const effectiveStatuses = typeSet ?? config.validStatuses;
109
+ if (status && status !== status.toLowerCase() && effectiveStatuses.has(status.toLowerCase())) {
110
+ fixes.push({ field: 'status', oldValue: status, newValue: status.toLowerCase(), type: 'update' });
111
+ }
112
+
113
+ // Key renames
114
+ for (const [oldKey, newKey] of Object.entries(KEY_RENAMES)) {
115
+ if (parsed[oldKey] !== undefined && parsed[newKey] === undefined) {
116
+ fixes.push({ field: oldKey, oldValue: oldKey, newValue: newKey, type: 'rename-key' });
117
+ }
118
+ }
119
+
120
+ // Singular `module:` / `surface:` → plural array (F18 deprecation).
121
+ // Any singular use migrates, regardless of comma — comma-containing values
122
+ // split on `,`, single values become a one-item list. Merging with any
123
+ // existing plural array happens at apply-time so the message reflects
124
+ // just what's being introduced from the singular form.
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
+ }
150
+ }
151
+ }
152
+
153
+ // Trailing whitespace in values
154
+ for (const line of frontmatter.split('\n')) {
155
+ const m = line.match(/^([A-Za-z0-9_-]+):(.+\S)\s+$/);
156
+ if (m) {
157
+ fixes.push({ field: m[1], oldValue: m[2] + line.slice(line.indexOf(m[2]) + m[2].length), newValue: m[2], type: 'trim' });
158
+ }
159
+ }
160
+
161
+ // Missing newline at EOF
162
+ if (!raw.endsWith('\n')) {
163
+ fixes.push({ field: '(eof)', oldValue: 'missing', newValue: 'newline', type: 'eof' });
164
+ }
165
+
166
+ if (fixes.length > 0) {
167
+ fixable.push({ filePath, repoPath, fixes });
168
+ }
169
+ }
170
+
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
+ }
187
+ }
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
+ });
195
+ }
196
+
197
+ if (!fix) {
198
+ // Report mode
199
+ if (fixable.length > 0) {
200
+ process.stdout.write(bold(`${fixable.length} file(s) with fixable issues:\n\n`));
201
+ for (const { repoPath, fixes } of fixable) {
202
+ process.stdout.write(` ${repoPath}\n`);
203
+ for (const f of fixes) {
204
+ if (f.type === 'rename-key') {
205
+ process.stdout.write(dim(` ${f.oldValue} → ${f.newValue}\n`));
206
+ } else if (f.type === 'infer-status') {
207
+ process.stdout.write(dim(` missing status (fixable via AI)\n`));
208
+ } else if (f.type === 'singular-to-plural') {
209
+ process.stdout.write(dim(` ${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]\n`));
210
+ } else if (f.type === 'drop-singular') {
211
+ process.stdout.write(dim(` remove deprecated \`${f.field}:\` (empty; \`${f.pluralKey}:\` is the live key)\n`));
212
+ } else if (f.type === 'eof') {
213
+ process.stdout.write(dim(` missing newline at end of file\n`));
214
+ } else if (f.type === 'add') {
215
+ process.stdout.write(dim(` add ${f.field}: ${f.newValue}\n`));
216
+ } else {
217
+ process.stdout.write(dim(` ${f.field}: ${f.oldValue} → ${f.newValue}\n`));
218
+ }
219
+ }
220
+ }
221
+ process.stdout.write(`\nRun ${bold('runlist lint --fix')} to auto-fix.\n`);
222
+ }
223
+
224
+ if (nonFixable.length > 0) {
225
+ process.stdout.write(`\n${yellow(`${nonFixable.length} non-fixable issue(s)`)} (manual attention needed):\n`);
226
+ for (const issue of nonFixable) {
227
+ process.stdout.write(` ${issue.path}: ${issue.message}\n`);
228
+ }
229
+ }
230
+
231
+ if (fixable.length === 0 && nonFixable.length === 0) {
232
+ process.stdout.write(green('No issues found.') + '\n');
233
+ }
234
+ return;
235
+ }
236
+
237
+ // Fix mode
238
+ authorizeManagedSweep(fixable.map(item => item.filePath), config, { kind: 'Lint fix source' });
239
+ const prefix = dryRun ? dim('[dry-run] ') : '';
240
+ let totalFixes = 0;
241
+
242
+ for (const { filePath, repoPath, fixes } of fixable) {
243
+ const updates = {};
244
+ const keyRenames = [];
245
+ let needsEofFix = false;
246
+ const trimFixes = [];
247
+ const singularToPlural = [];
248
+ const dropSingular = [];
249
+
250
+ for (const f of fixes) {
251
+ if (f.type === 'rename-key') {
252
+ keyRenames.push(f);
253
+ } else if (f.type === 'eof') {
254
+ needsEofFix = true;
255
+ } else if (f.type === 'trim') {
256
+ trimFixes.push(f);
257
+ } else if (f.type === 'singular-to-plural') {
258
+ singularToPlural.push(f);
259
+ } else if (f.type === 'drop-singular') {
260
+ dropSingular.push(f);
261
+ } else {
262
+ updates[f.field] = f.newValue;
263
+ }
264
+ }
265
+
266
+ if (!dryRun) {
267
+ // Apply infer-status fixes via AI
268
+ for (const f of fixes.filter(f => f.type === 'infer-status')) {
269
+ const raw = readFileSync(filePath, 'utf8');
270
+ const { frontmatter: fmInfer } = extractFrontmatter(raw);
271
+ const parsedInfer = parseSimpleFrontmatter(fmInfer);
272
+ const inferType = asString(parsedInfer.type);
273
+ const inferTypeSet = inferType ? config.typeStatuses?.get(inferType) : null;
274
+ const statusList = inferTypeSet ? [...inferTypeSet].join(', ') : config.statusOrder.join(', ');
275
+ const { body } = extractFrontmatter(raw);
276
+ const prompt = `Given this markdown document, classify it into exactly one of these statuses: ${statusList}.\nReply with ONLY the status word, nothing else.\n\nFile: ${repoPath}\n\n${(body ?? '').slice(0, 4000)}`;
277
+ const result = runModel(prompt, { maxTokens: 10 });
278
+ const suggested = result?.trim().toLowerCase().split(/\s+/)[0];
279
+ const inferValid = inferTypeSet ?? config.validStatuses;
280
+ if (suggested && inferValid.has(suggested)) {
281
+ updateFrontmatter(filePath, { status: suggested });
282
+ f.newValue = suggested;
283
+ }
284
+ }
285
+
286
+ // Apply singular-to-plural fixes (module/surface → modules/surfaces array).
287
+ // Removes the singular key block; merges its value(s) into the plural array,
288
+ // or creates the plural block if absent. Duplicates are skipped.
289
+ for (const sa of singularToPlural) {
290
+ let raw = readFileSync(filePath, 'utf8');
291
+ const { frontmatter: fm } = extractFrontmatter(raw);
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);
304
+ } else {
305
+ newFm = `${newFm.trim()}\n${pluralBlock}`;
306
+ }
307
+ raw = replaceFrontmatter(raw, newFm.trim());
308
+ writeFileSync(filePath, raw, 'utf8');
309
+ }
310
+
311
+ // Drop empty deprecated singular keys. The value is known-empty (the fix
312
+ // is only emitted for `[]`/`''`), so this matches the bare `key:` line
313
+ // exactly — never a line carrying content — and removes it.
314
+ for (const ds of dropSingular) {
315
+ let raw = readFileSync(filePath, 'utf8');
316
+ const { frontmatter: fm } = extractFrontmatter(raw);
317
+ const comments = frontmatterFieldComments(fm, ds.field);
318
+ const newFm = replaceFrontmatterFieldBlock(fm, ds.field, comments.join('\n'));
319
+ raw = replaceFrontmatter(raw, newFm.trim());
320
+ writeFileSync(filePath, raw, 'utf8');
321
+ }
322
+
323
+ // Apply key renames and trim fixes via raw string manipulation
324
+ if (keyRenames.length > 0 || trimFixes.length > 0) {
325
+ let raw = readFileSync(filePath, 'utf8');
326
+ const { frontmatter: fm } = extractFrontmatter(raw);
327
+ let newFm = fm;
328
+ for (const kr of keyRenames) {
329
+ const regex = new RegExp(`^${escapeRegex(kr.oldValue)}:`, 'm');
330
+ newFm = newFm.replace(regex, `${kr.newValue}:`);
331
+ }
332
+ for (const tf of trimFixes) {
333
+ const regex = new RegExp(`^(${escapeRegex(tf.field)}:)${escapeRegex(tf.oldValue)}$`, 'm');
334
+ newFm = newFm.replace(regex, `$1${tf.newValue}`);
335
+ }
336
+ if (newFm !== fm) {
337
+ raw = replaceFrontmatter(raw, newFm);
338
+ writeFileSync(filePath, raw, 'utf8');
339
+ }
340
+ }
341
+
342
+ // Apply value updates via updateFrontmatter
343
+ if (Object.keys(updates).length > 0) {
344
+ updateFrontmatter(filePath, updates);
345
+ }
346
+
347
+ // EOF fix
348
+ if (needsEofFix) {
349
+ const current = readFileSync(filePath, 'utf8');
350
+ if (!current.endsWith('\n')) {
351
+ writeFileSync(filePath, current + '\n', 'utf8');
352
+ }
353
+ }
354
+ }
355
+
356
+ process.stdout.write(`${prefix}${green('Fixed')}: ${repoPath} (${fixes.length} issue${fixes.length > 1 ? 's' : ''})\n`);
357
+ for (const f of fixes) {
358
+ if (f.type === 'rename-key') {
359
+ process.stdout.write(`${prefix} ${dim(`${f.oldValue} → ${f.newValue}`)}\n`);
360
+ } else if (f.type === 'eof') {
361
+ process.stdout.write(`${prefix} ${dim('added newline at EOF')}\n`);
362
+ } else if (f.type === 'infer-status') {
363
+ if (f.newValue) {
364
+ process.stdout.write(`${prefix} ${dim(`status: (missing) → ${f.newValue} (AI-inferred)`)}\n`);
365
+ } else {
366
+ process.stdout.write(`${prefix} ${dim('status: (missing) — AI inference unavailable')}\n`);
367
+ }
368
+ } else if (f.type === 'singular-to-plural') {
369
+ process.stdout.write(`${prefix} ${dim(`${f.field}: "${f.oldValue}" → ${f.pluralKey}: [${f.newValue.join(', ')}]`)}\n`);
370
+ } else if (f.type === 'drop-singular') {
371
+ process.stdout.write(`${prefix} ${dim(`removed deprecated \`${f.field}:\` (empty)`)}\n`);
372
+ } else if (f.type === 'add') {
373
+ process.stdout.write(`${prefix} ${dim(`add ${f.field}: ${f.newValue}`)}\n`);
374
+ } else {
375
+ process.stdout.write(`${prefix} ${dim(`${f.field}: ${f.oldValue} → ${f.newValue}`)}\n`);
376
+ }
377
+ }
378
+ totalFixes += fixes.length;
379
+
380
+ if (!dryRun) {
381
+ try { config.hooks.onLint?.({ path: repoPath, fixes }); } catch (err) { warn(`Hook 'onLint' threw: ${err.message}`); }
382
+ }
383
+ }
384
+
385
+ process.stdout.write(`\n${prefix}${totalFixes} fix${totalFixes !== 1 ? 'es' : ''} applied across ${fixable.length} file(s).\n`);
386
+ }
@@ -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
+ }
@@ -0,0 +1,58 @@
1
+ function mask(text) {
2
+ return text.includes('\n') ? text.replace(/[^\n]/g, 'x') : 'x'.repeat(text.length);
3
+ }
4
+
5
+ // Markdown code spans close only on a backtick run of the same length as their
6
+ // opener. The mask is deliberately the same length as the source so consumers
7
+ // can inspect neutralized Markdown while applying any edits at source offsets.
8
+ // `state` lets the conservative multiline behavior used by the reference
9
+ // rewriter survive: after an unmatched opener, nothing is treated as prose
10
+ // until a compatible closer appears.
11
+ export function maskInlineCodeLine(line, state = { run: null }) {
12
+ if (!line.includes('`')) return state.run === null ? line : mask(line);
13
+
14
+ const runs = [...line.matchAll(/`+/g)];
15
+ const findCloser = (from, length) => {
16
+ for (let i = from; i < runs.length; i++) if (runs[i][0].length === length) return i;
17
+ return -1;
18
+ };
19
+ const ranges = [];
20
+ let index = 0;
21
+
22
+ if (state.run !== null) {
23
+ const closingIndex = findCloser(0, state.run);
24
+ if (closingIndex === -1) return mask(line);
25
+ const closing = runs[closingIndex];
26
+ ranges.push([0, closing.index + closing[0].length]);
27
+ index = closingIndex + 1;
28
+ state.run = null;
29
+ }
30
+
31
+ for (; index < runs.length; index++) {
32
+ const opening = runs[index];
33
+ const closingIndex = findCloser(index + 1, opening[0].length);
34
+ if (closingIndex === -1) {
35
+ ranges.push([opening.index, line.length]);
36
+ state.run = opening[0].length;
37
+ break;
38
+ }
39
+ const closing = runs[closingIndex];
40
+ ranges.push([opening.index, closing.index + closing[0].length]);
41
+ index = closingIndex;
42
+ }
43
+
44
+ // Ranges are ascending and disjoint, so one left-to-right pass builds the
45
+ // result instead of re-copying the whole line once per span.
46
+ let out = '';
47
+ let cursor = 0;
48
+ for (const [start, end] of ranges) {
49
+ out += line.slice(cursor, start) + mask(line.slice(start, end));
50
+ cursor = end;
51
+ }
52
+ return out + line.slice(cursor);
53
+ }
54
+
55
+ export function maskInlineCodeSpans(value) {
56
+ const state = { run: null };
57
+ return value.split('\n').map(line => maskInlineCodeLine(line, state)).join('\n');
58
+ }