dotmd-cli 0.81.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -282,6 +282,26 @@ export const types = {
282
282
  };
283
283
  ```
284
284
 
285
+ ### Code references
286
+
287
+ Archive and rename repair every reference inside the doc roots. `codeRoots`
288
+ extends that to source files that cite a document by its repo-relative path in
289
+ a comment, a docstring or a string literal:
290
+
291
+ ```js
292
+ export const codeRoots = ['packages', 'scripts', 'services'];
293
+ export const codeRefsUntouched = ['scripts/guards/plan-baseline.json'];
294
+ ```
295
+
296
+ `runlist refs <old> <new>` reports every citation by file and line and writes
297
+ only on `--fix`; `runlist refs repair` does the same for every archived
298
+ document still cited at its previous path. Archive and rename print the count
299
+ afterwards and take `--fix-refs`. Only the full repo-relative path matches,
300
+ never a bare basename, and only that segment is replaced, so a trailing `§` or
301
+ `#` anchor survives. A citation inside a string literal waits for `--strings`,
302
+ a file in `codeRefsUntouched` is never written, and with no `codeRoots` set
303
+ nothing is scanned.
304
+
285
305
  Configuration supports multiple roots, custom types and templates, taxonomy,
286
306
  reference fields, presets, rendering, lifecycle hooks, validation hooks, and
287
307
  AI summarization hooks. See [`runlist.config.example.mjs`](runlist.config.example.mjs)
package/bin/dotmd.mjs CHANGED
@@ -256,6 +256,8 @@ Validate & Fix:
256
256
  self-check Project/version skew diagnostic (alias: doctor --project)
257
257
  lint [--fix] Check and auto-fix frontmatter issues
258
258
  fix-refs [--dry-run] Auto-fix broken reference paths + body links
259
+ refs <old> <new> [--fix] Report (or rewrite) code-root citations of a moved document
260
+ refs repair [--fix] Same, for every archived document still cited at its old path
259
261
  fix-membership [<hub>...] Add unambiguous missing child parent_plan back-references
260
262
  sync-status [<hub>...] [--adopt] Rewrite hub table rows whose printed status drifted from the plan
261
263
 
@@ -891,6 +893,39 @@ a parentless child ranked by multiple hubs as ambiguous.
891
893
  runlist fix-membership --dry-run preview without writing
892
894
  runlist fix-membership --dry-run --json`,
893
895
 
896
+ refs: `runlist refs <old> <new> — code-root citations of a moved document
897
+ runlist refs repair — every archived document still cited at its old path
898
+
899
+ Reference repair inside the doc roots is what archive and rename already do.
900
+ This is the other half: a source comment, docstring or string literal that
901
+ cites a document by its repo-relative path, in the roots \`codeRoots\` names.
902
+ Absent that key, nothing is scanned and nothing changes.
903
+
904
+ What it matches:
905
+ - The full repo-relative path only (\`docs/plans/<slug>.md\`). Never a bare
906
+ basename — a corpus cites slugs as words, and matching one is how a rename
907
+ corrupts an unrelated line.
908
+ - A trailing \`§\` or \`#\` anchor is kept as written: only the path is
909
+ replaced, so quotes, backticks and punctuation around it survive.
910
+
911
+ What it writes:
912
+ - Report is the default. \`--fix\` rewrites citations in comments.
913
+ - A citation inside a string literal (or bare in code) is reported in its own
914
+ group and rewritten only with \`--strings\` — a test or a guard may assert
915
+ on it.
916
+ - A file listed in \`codeRefsUntouched\` is reported and never written.
917
+ - A file that is not writable, or that resolves outside the repository, is
918
+ refused by name.
919
+
920
+ runlist refs docs/plans/a.md docs/plans/archived/a.md
921
+ runlist refs docs/plans/a.md docs/plans/archived/a.md --fix
922
+ runlist refs repair report every stale archived citation
923
+ runlist refs repair --fix --strings rewrite them, string literals included
924
+
925
+ \`repair\` reads each previous path from the archive directory mapping, which is
926
+ the one move the tool leaves a readable trace of. A rename records none, so a
927
+ renamed document is covered by the two-argument form.`,
928
+
894
929
  'fix-refs': `runlist fix-refs — auto-fix broken reference paths
895
930
 
896
931
  Scans all docs for reference fields that point to non-existent files,
@@ -1717,12 +1752,18 @@ async function main() {
1717
1752
  process.stderr.write(`Repo root: ${config.repoRoot}\n`);
1718
1753
  }
1719
1754
 
1755
+ // A printed list shows status, title, age and next step, none of which the
1756
+ // validating passes produce, so it reads the index the way `prompts` and
1757
+ // `hud` do. `--json` emits each document's warnings and errors, so it still
1758
+ // pays for the full pass.
1759
+ const listIndexOptions = listArgs => (listArgs.includes('--json') ? {} : { fast: true });
1760
+
1720
1761
  // Preset aliases (user config can override built-in commands below)
1721
1762
  if ((command === 'stale' || command === 'actionable') && !config.configuredPresetNames.has(command)) {
1722
1763
  const { buildIndex } = await import('../src/index.mjs');
1723
1764
  const { runQuery } = await import('../src/query.mjs');
1724
1765
  const { statusMetadataFor } = await import('../src/status-metadata.mjs');
1725
- const index = buildIndex(config);
1766
+ const index = buildIndex(config, listIndexOptions(restArgs));
1726
1767
  applyIndexFilters(index);
1727
1768
  const docs = index.docs.filter(doc => {
1728
1769
  const metadata = statusMetadataFor(config, doc.type, doc.status);
@@ -1739,7 +1780,7 @@ async function main() {
1739
1780
  if (config.presets[command]) {
1740
1781
  const { buildIndex } = await import('../src/index.mjs');
1741
1782
  const { runQuery } = await import('../src/query.mjs');
1742
- const index = buildIndex(config);
1783
+ const index = buildIndex(config, listIndexOptions([...config.presets[command], ...restArgs]));
1743
1784
  applyIndexFilters(index);
1744
1785
  runQuery(index, [...config.presets[command], ...restArgs], config, { preset: command, type: typeArg, root: rootArg });
1745
1786
  return;
@@ -1752,7 +1793,7 @@ async function main() {
1752
1793
  if (command === 'plans') {
1753
1794
  const { buildIndex } = await import('../src/index.mjs');
1754
1795
  const { runQuery } = await import('../src/query.mjs');
1755
- const index = buildIndex(config);
1796
+ const index = buildIndex(config, listIndexOptions(restArgs));
1756
1797
  applyIndexFilters(index);
1757
1798
  const sub = restArgs[0];
1758
1799
  let defaults;
@@ -1772,7 +1813,7 @@ async function main() {
1772
1813
  if (command === 'runlists') {
1773
1814
  const { buildIndex } = await import('../src/index.mjs');
1774
1815
  const { runRunlists } = await import('../src/query.mjs');
1775
- const index = buildIndex(config);
1816
+ const index = buildIndex(config, listIndexOptions(restArgs));
1776
1817
  applyIndexFilters(index);
1777
1818
  runRunlists(index, restArgs, config);
1778
1819
  return;
@@ -1783,7 +1824,7 @@ async function main() {
1783
1824
  if (command === 'roadmaps') {
1784
1825
  const { buildIndex } = await import('../src/index.mjs');
1785
1826
  const { runRoadmaps } = await import('../src/roadmap.mjs');
1786
- const index = buildIndex(config);
1827
+ const index = buildIndex(config, listIndexOptions(restArgs));
1787
1828
  applyIndexFilters(index);
1788
1829
  runRoadmaps(index, restArgs, config);
1789
1830
  return;
@@ -1873,6 +1914,7 @@ async function main() {
1873
1914
  if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
1874
1915
  if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
1875
1916
  if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
1917
+ if (command === 'refs') { const { runRefs } = await import('../src/code-refs.mjs'); runRefs(restArgs, config, { dryRun }); return; }
1876
1918
  if (command === 'fix-membership') { const { runFixMembership } = await import('../src/fix-membership.mjs'); await runFixMembership(restArgs, config, { dryRun }); return; }
1877
1919
  if (command === 'sync-status') { const { runSyncStatus } = await import('../src/sync-status.mjs'); await runSyncStatus(restArgs, config, { dryRun }); return; }
1878
1920
  if (command === 'self-check') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.81.0",
3
+ "version": "0.83.0",
4
4
  "description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,38 @@ export const excludeDirs = ['evidence'];
21
21
  // checks, which are deliberate subsets.
22
22
  // export const minDocs = 500;
23
23
 
24
+ // ─── Code references (`runlist refs`) ────────────────────────────────────────
25
+ //
26
+ // Archive and rename repair every reference inside the doc roots. These four
27
+ // keys extend that to source files that cite a document by its repo-relative
28
+ // path in a comment, a docstring or a string literal. With no `codeRoots` the
29
+ // scan never runs and nothing changes.
30
+ //
31
+ // `runlist refs <old> <new>` reports, `--fix` writes; `runlist refs repair`
32
+ // does the same for every archived document still cited at its previous path.
33
+ // Archive and rename print the count afterwards and take `--fix-refs`.
34
+ //
35
+ // Only the full repo-relative path matches, never a bare basename — a corpus
36
+ // cites slugs as words, and matching one is how a rename corrupts an
37
+ // unrelated line. Only the path segment is replaced, so a trailing `§` or `#`
38
+ // anchor, a backtick or a quote is kept as written.
39
+
40
+ // Repo-relative directories scanned for path-shaped citations. Empty = off.
41
+ // export const codeRoots = ['packages', 'scripts', 'services'];
42
+
43
+ // File kinds considered. An entry with no leading dot is an exact basename,
44
+ // which is how a file with no extension opts in. Unset = the built-in list.
45
+ // export const codeExtensions = ['.ts', '.tsx', '.mjs', '.swift', '.sql', '.py', '.sh', 'Dockerfile'];
46
+
47
+ // Never scanned. Vendored trees and generated output, where a rewrite is
48
+ // undone by the next codegen run and the citation belongs upstream.
49
+ // Unset = node_modules, dist/build, and the usual generated-output shapes.
50
+ // export const codeExcludes = ['**/node_modules/**', '**/*.generated.*', '**/__generated__/**'];
51
+
52
+ // Files whose content is data keyed on document paths — a guard baseline
53
+ // whose keys are plan paths, for instance. Reported, never written.
54
+ // export const codeRefsUntouched = ['scripts/guards/plan-baseline.json'];
55
+
24
56
  // Document types — each type has its own status vocabulary and context layout.
25
57
  // Defaults: plan, doc, prompt. Override to customize statuses per type, or add new types.
26
58
  //
@@ -0,0 +1,546 @@
1
+ // Reference repair outside the doc roots.
2
+ //
3
+ // Every sweep the lifecycle runs is pushed through `authorizeManagedSweep` →
4
+ // `authorizeManagedSource`, which refuses anything that is not a `.md` file
5
+ // inside a configured docs root. That chokepoint is what makes a move's write
6
+ // set provable, so it is deliberately NOT widened here: this module walks the
7
+ // configured code roots on its own, outside the move transaction, and archive
8
+ // and rename call it after their own doc-root repair has committed.
9
+ //
10
+ // Two rules separate it from the document rewriter:
11
+ // 1. It matches the full repo-relative path only, never a bare basename. A
12
+ // corpus cites slugs as words in prose comments, so a basename match is
13
+ // how a rename corrupts an unrelated line.
14
+ // 2. It replaces the path segment and nothing else, so a `§`/`#` anchor, a
15
+ // closing backtick, a quote or a trailing comma all survive untouched.
16
+ // That also means the rendering is repo-relative, which is the spelling a
17
+ // reader of a source comment can paste; the doc rewriter's doc-relative
18
+ // rendering is not reused.
19
+
20
+ import { accessSync, constants, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs';
21
+ import path from 'node:path';
22
+ import { escapeRegex, toRepoPath } from './util.mjs';
23
+ import { collectDocFiles } from './index.mjs';
24
+ import { bold, dim, green, yellow } from './color.mjs';
25
+
26
+ // Measured file kinds in a corpus of 839 stale mentions: TypeScript, Swift,
27
+ // SQL, ESM scripts, TSX, shell, Python, GraphQL, Dockerfiles, and a long tail.
28
+ // An entry without a leading dot is an exact basename, which is how a file
29
+ // with no extension opts in.
30
+ export const DEFAULT_CODE_EXTENSIONS = Object.freeze([
31
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
32
+ '.swift', '.kt', '.sql', '.py', '.sh', '.rb', '.go', '.rs',
33
+ '.graphql', '.gql', '.yml', '.yaml', '.css', '.scss', '.toml',
34
+ 'Dockerfile', 'Justfile', 'Makefile',
35
+ ]);
36
+
37
+ // Generated output is excluded rather than reported: rewriting an artifact is
38
+ // undone by the next codegen run, and the citation lives in the schema
39
+ // description upstream.
40
+ export const DEFAULT_CODE_EXCLUDES = Object.freeze([
41
+ '**/node_modules/**',
42
+ '**/.git/**',
43
+ '**/dist/**',
44
+ '**/build/**',
45
+ '**/.next/**',
46
+ '**/*.generated.*',
47
+ '**/__generated__/**',
48
+ '**/generated/**',
49
+ ]);
50
+
51
+ // Comment openers by file kind. The classifier only asks whether an opener
52
+ // precedes the match on the same line, so a table this small is enough — and
53
+ // getting it wrong is safe in one direction only, which is why `#` is not
54
+ // handed to every language.
55
+ const COMMENT_OPENERS = {
56
+ c: ['//', '/*', '*'],
57
+ hash: ['#'],
58
+ sql: ['--', '/*', '*'],
59
+ all: ['//', '/*', '*', '#', '--'],
60
+ };
61
+
62
+ const OPENERS_BY_EXTENSION = new Map([
63
+ ['.ts', 'c'], ['.tsx', 'c'], ['.js', 'c'], ['.jsx', 'c'], ['.mjs', 'c'], ['.cjs', 'c'],
64
+ ['.swift', 'c'], ['.kt', 'c'], ['.css', 'c'], ['.scss', 'c'], ['.go', 'c'], ['.rs', 'c'],
65
+ ['.sql', 'sql'],
66
+ ['.py', 'hash'], ['.sh', 'hash'], ['.rb', 'hash'], ['.yml', 'hash'], ['.yaml', 'hash'],
67
+ ['.toml', 'hash'], ['.graphql', 'hash'], ['.gql', 'hash'],
68
+ ]);
69
+
70
+ export function codeRefsConfig(config) {
71
+ const roots = config?.codeRefs?.roots ?? [];
72
+ return {
73
+ roots,
74
+ extensions: config?.codeRefs?.extensions ?? [...DEFAULT_CODE_EXTENSIONS],
75
+ excludes: config?.codeRefs?.excludes ?? [...DEFAULT_CODE_EXCLUDES],
76
+ untouched: new Set(config?.codeRefs?.untouched ?? []),
77
+ enabled: roots.length > 0,
78
+ };
79
+ }
80
+
81
+ // ── Globs ────────────────────────────────────────────────────────────────
82
+
83
+ // A deliberately small subset: `**` spans segments, `*` stays inside one, `?`
84
+ // is one character. Enough for the exclude shapes a code root needs, and it
85
+ // refuses to grow into a dependency.
86
+ export function globToRegex(pattern) {
87
+ let out = '^';
88
+ for (let i = 0; i < pattern.length; i++) {
89
+ const ch = pattern[i];
90
+ if (ch === '*') {
91
+ if (pattern[i + 1] === '*') {
92
+ i++;
93
+ if (pattern[i + 1] === '/') { i++; out += '(?:.*/)?'; }
94
+ else out += '.*';
95
+ } else {
96
+ out += '[^/]*';
97
+ }
98
+ } else if (ch === '?') {
99
+ out += '[^/]';
100
+ } else {
101
+ out += escapeRegex(ch);
102
+ }
103
+ }
104
+ return new RegExp(out + '$');
105
+ }
106
+
107
+ export function matchesAnyGlob(repoPath, patterns) {
108
+ return patterns.some(pattern => globToRegex(pattern).test(repoPath));
109
+ }
110
+
111
+ // ── Walking the code roots ───────────────────────────────────────────────
112
+
113
+ function extensionAllowed(basename, extensions) {
114
+ const ext = path.extname(basename);
115
+ if (ext && extensions.includes(ext)) return true;
116
+ return extensions.includes(basename);
117
+ }
118
+
119
+ export function collectCodeFiles(config) {
120
+ const settings = codeRefsConfig(config);
121
+ if (!settings.enabled) return [];
122
+ const files = [];
123
+ const seen = new Set();
124
+
125
+ const walk = (dir) => {
126
+ let entries;
127
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
128
+ for (const entry of entries) {
129
+ const abs = path.join(dir, entry.name);
130
+ const repoPath = toRepoPath(abs, config.repoRoot);
131
+ if (matchesAnyGlob(repoPath, settings.excludes)) continue;
132
+ if (entry.isSymbolicLink()) continue;
133
+ if (entry.isDirectory()) { walk(abs); continue; }
134
+ if (!entry.isFile()) continue;
135
+ if (!extensionAllowed(entry.name, settings.extensions)) continue;
136
+ if (seen.has(abs)) continue;
137
+ seen.add(abs);
138
+ files.push(abs);
139
+ }
140
+ };
141
+
142
+ for (const root of settings.roots) {
143
+ const abs = path.resolve(config.repoRoot, root);
144
+ let stat;
145
+ try { stat = statSync(abs); } catch { continue; }
146
+ if (!stat.isDirectory()) continue;
147
+ walk(abs);
148
+ }
149
+ return files.sort((a, b) => a.localeCompare(b));
150
+ }
151
+
152
+ // ── The matcher ──────────────────────────────────────────────────────────
153
+
154
+ // The path may not continue in either direction: a preceding path character
155
+ // means this is a longer path that merely ends with ours, and a following one
156
+ // means the citation names something below it. Everything else — a quote, a
157
+ // backtick, a bracket, a space, a line start, a trailing `§` or `#` anchor —
158
+ // is a boundary, and is left exactly as it was found.
159
+ export function referenceRegex(repoPath) {
160
+ return new RegExp(`(?<![A-Za-z0-9_\\-/.])${escapeRegex(repoPath)}(?![A-Za-z0-9_\\-/])`, 'g');
161
+ }
162
+
163
+ const ANCHOR = /^\s*(?:§|#[A-Za-z0-9])/;
164
+
165
+ function commentOpenerIndex(line, kind) {
166
+ const openers = COMMENT_OPENERS[kind] ?? COMMENT_OPENERS.all;
167
+ let best = -1;
168
+ for (const opener of openers) {
169
+ let from = 0;
170
+ for (;;) {
171
+ const at = line.indexOf(opener, from);
172
+ if (at === -1) break;
173
+ from = at + 1;
174
+ // `https://` is not a comment. A lone `*` only opens a continuation
175
+ // line when it is the first thing on the line.
176
+ if (opener === '//' && line[at - 1] === ':') continue;
177
+ if (opener === '*' && line.slice(0, at).trim() !== '') continue;
178
+ if (best === -1 || at < best) best = at;
179
+ break;
180
+ }
181
+ }
182
+ return best;
183
+ }
184
+
185
+ function insideQuotes(line, index) {
186
+ let quote = null;
187
+ for (let i = 0; i < index; i++) {
188
+ const ch = line[i];
189
+ if (ch === '\\') { i++; continue; }
190
+ if (quote) { if (ch === quote) quote = null; continue; }
191
+ if (ch === '"' || ch === "'" || ch === '`') quote = ch;
192
+ }
193
+ return quote;
194
+ }
195
+
196
+ // `comment` is written by `--fix`; `string` and `code` wait for `--strings`,
197
+ // because a path inside a quoted string can be data a test or a guard asserts
198
+ // on rather than prose a reader follows.
199
+ export function classifyMatch(line, index, fileKind) {
200
+ const opener = commentOpenerIndex(line, fileKind);
201
+ if (opener !== -1 && opener < index) return 'comment';
202
+ return insideQuotes(line, index) ? 'string' : 'code';
203
+ }
204
+
205
+ function fileKindFor(filePath) {
206
+ return OPENERS_BY_EXTENSION.get(path.extname(filePath)) ?? 'all';
207
+ }
208
+
209
+ export function findCodeReferences(content, repoPath, filePath) {
210
+ const kind = fileKindFor(filePath);
211
+ const lines = content.split('\n');
212
+ const hits = [];
213
+ for (let n = 0; n < lines.length; n++) {
214
+ const line = lines[n];
215
+ const regex = referenceRegex(repoPath);
216
+ let match;
217
+ while ((match = regex.exec(line)) !== null) {
218
+ hits.push({
219
+ line: n + 1,
220
+ column: match.index + 1,
221
+ text: line.trim(),
222
+ form: classifyMatch(line, match.index, kind),
223
+ anchored: ANCHOR.test(line.slice(match.index + repoPath.length)),
224
+ });
225
+ }
226
+ }
227
+ return hits;
228
+ }
229
+
230
+ export function rewriteCodeReferences(content, oldRepoPath, newRepoPath, filePath, { strings = false } = {}) {
231
+ const kind = fileKindFor(filePath);
232
+ const lines = content.split('\n');
233
+ let changed = 0;
234
+ const out = lines.map(line => {
235
+ const regex = referenceRegex(oldRepoPath);
236
+ return line.replace(regex, (found, offset) => {
237
+ const form = classifyMatch(line, offset, kind);
238
+ if (form !== 'comment' && !strings) return found;
239
+ changed++;
240
+ return newRepoPath;
241
+ });
242
+ });
243
+ return { content: out.join('\n'), changed };
244
+ }
245
+
246
+ // ── Scanning ─────────────────────────────────────────────────────────────
247
+
248
+ function writability(absPath, repoRoot) {
249
+ let canonical;
250
+ try { canonical = realpathSync(absPath); } catch (err) { return `cannot be resolved (${err.code ?? err.message})`; }
251
+ const repoCanonical = realpathSync(repoRoot);
252
+ const relative = path.relative(repoCanonical, canonical);
253
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return 'resolves outside the repository';
254
+ try { accessSync(canonical, constants.W_OK); } catch { return 'is not writable'; }
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Report every code-root citation of `oldRepoPath`. Pure read: `runlist refs`
260
+ * and the archive / rename count line share it, the same way `fixBrokenRefs`
261
+ * is shared by `fix-refs` and `check --fix`.
262
+ */
263
+ export function scanCodeRefs(config, oldRepoPath, { files = null } = {}) {
264
+ const settings = codeRefsConfig(config);
265
+ const result = emptyScan(settings.enabled);
266
+ if (!settings.enabled) return result;
267
+
268
+ for (const absPath of files ?? collectCodeFiles(config)) {
269
+ let content;
270
+ try { content = readFileSync(absPath, 'utf8'); } catch { continue; }
271
+ if (!content.includes(oldRepoPath)) continue;
272
+ const repoPath = toRepoPath(absPath, config.repoRoot);
273
+ const hits = findCodeReferences(content, oldRepoPath, absPath);
274
+ if (!hits.length) continue;
275
+ const untouched = settings.untouched.has(repoPath);
276
+ result.files.push({ path: repoPath, absPath, untouched, hits });
277
+ result.total += hits.length;
278
+ if (untouched) result.untouchedCount += hits.length;
279
+ for (const hit of hits) result.byForm[hit.form]++;
280
+ }
281
+ return result;
282
+ }
283
+
284
+ // Any document-shaped path, so a sweep reads each file once instead of once
285
+ // per moved document. `repair` over a corpus of 1,000 archived documents and
286
+ // 2,000 code files is 2 million file reads the other way round; the captured
287
+ // token is then looked up in the work list, which keeps the match rule
288
+ // identical to the single-document scan.
289
+ const ANY_DOC_PATH = /(?<![A-Za-z0-9_\-/.])([A-Za-z0-9_\-./]+\.md)(?![A-Za-z0-9_\-/])/g;
290
+
291
+ function emptyScan(enabled) {
292
+ return { enabled, files: [], total: 0, byForm: { comment: 0, string: 0, code: 0 }, untouchedCount: 0, refused: [] };
293
+ }
294
+
295
+ /**
296
+ * One pass over the code roots for many document paths at once. Returns a Map
297
+ * of path → the same scan shape `scanCodeRefs` returns, for the paths that
298
+ * were actually cited.
299
+ */
300
+ export function scanManyCodeRefs(config, wanted, { files = null } = {}) {
301
+ const settings = codeRefsConfig(config);
302
+ const byPath = new Map();
303
+ if (!settings.enabled || wanted.size === 0) return byPath;
304
+
305
+ for (const absPath of files ?? collectCodeFiles(config)) {
306
+ let content;
307
+ try { content = readFileSync(absPath, 'utf8'); } catch { continue; }
308
+ if (!content.includes('.md')) continue;
309
+ const repoPath = toRepoPath(absPath, config.repoRoot);
310
+ const untouched = settings.untouched.has(repoPath);
311
+ const kind = fileKindFor(absPath);
312
+ const lines = content.split('\n');
313
+ const perPath = new Map();
314
+
315
+ for (let n = 0; n < lines.length; n++) {
316
+ const line = lines[n];
317
+ if (!line.includes('.md')) continue;
318
+ ANY_DOC_PATH.lastIndex = 0;
319
+ let match;
320
+ while ((match = ANY_DOC_PATH.exec(line)) !== null) {
321
+ const found = match[1];
322
+ if (!wanted.has(found)) continue;
323
+ if (!perPath.has(found)) perPath.set(found, []);
324
+ perPath.get(found).push({
325
+ line: n + 1,
326
+ column: match.index + 1,
327
+ text: line.trim(),
328
+ form: classifyMatch(line, match.index, kind),
329
+ anchored: ANCHOR.test(line.slice(match.index + found.length)),
330
+ });
331
+ }
332
+ }
333
+
334
+ for (const [found, hits] of perPath) {
335
+ if (!byPath.has(found)) byPath.set(found, emptyScan(true));
336
+ const scan = byPath.get(found);
337
+ scan.files.push({ path: repoPath, absPath, untouched, hits });
338
+ scan.total += hits.length;
339
+ if (untouched) scan.untouchedCount += hits.length;
340
+ for (const hit of hits) scan.byForm[hit.form]++;
341
+ }
342
+ }
343
+ return byPath;
344
+ }
345
+
346
+ /** Writable citations only: what `--fix` (or `--fix --strings`) would change. */
347
+ export function fixableCount(scan, { strings = false } = {}) {
348
+ let count = 0;
349
+ for (const file of scan.files) {
350
+ if (file.untouched) continue;
351
+ for (const hit of file.hits) {
352
+ if (hit.form === 'comment' || strings) count++;
353
+ }
354
+ }
355
+ return count;
356
+ }
357
+
358
+ export function applyCodeRefs(config, scan, oldRepoPath, newRepoPath, { strings = false, dryRun = false } = {}) {
359
+ const written = [];
360
+ const refused = [];
361
+ let changed = 0;
362
+ for (const file of scan.files) {
363
+ if (file.untouched) continue;
364
+ const reason = writability(file.absPath, config.repoRoot);
365
+ if (reason) { refused.push({ path: file.path, reason }); continue; }
366
+ const content = readFileSync(file.absPath, 'utf8');
367
+ const result = rewriteCodeReferences(content, oldRepoPath, newRepoPath, file.absPath, { strings });
368
+ if (!result.changed || result.content === content) continue;
369
+ if (!dryRun) writeFileSync(file.absPath, result.content, 'utf8');
370
+ written.push({ path: file.path, changed: result.changed });
371
+ changed += result.changed;
372
+ }
373
+ return { written, refused, changed };
374
+ }
375
+
376
+ // ── The work list for `refs repair` ──────────────────────────────────────
377
+
378
+ // Nothing in the tool records where a document used to live: a rename moves
379
+ // the file and rewrites the doc-root references, and neither writes the old
380
+ // path anywhere. The one move that leaves a readable trace is the archive,
381
+ // whose destination is the source path with the archive directory inserted,
382
+ // so the previous path is that segment removed. A hand-moved or renamed
383
+ // document is out of reach here and `refs <old> <new>` covers it by hand.
384
+ export function archivedPreviousPaths(config) {
385
+ const docs = collectDocFiles(config).map(file => toRepoPath(file, config.repoRoot));
386
+ const live = new Set(docs);
387
+ const pairs = [];
388
+ for (const current of docs) {
389
+ const segments = current.split('/');
390
+ const at = segments.lastIndexOf(config.archiveDir);
391
+ if (at === -1) continue;
392
+ const previous = [...segments.slice(0, at), ...segments.slice(at + 1)].join('/');
393
+ if (previous === current) continue;
394
+ // A live document at that path means the citation is not stale.
395
+ if (live.has(previous)) continue;
396
+ pairs.push({ previous, current });
397
+ }
398
+ return pairs;
399
+ }
400
+
401
+ // ── Output ───────────────────────────────────────────────────────────────
402
+
403
+ const FORM_LABELS = { comment: 'In a comment', string: 'In a string literal', code: 'In code' };
404
+
405
+ function writeGroup(out, label, entries, { prefix = '' } = {}) {
406
+ if (!entries.length) return;
407
+ const lines = entries.reduce((sum, entry) => sum + entry.hits.length, 0);
408
+ out.write(`${prefix}${bold(label)} (${lines} in ${entries.length} file${entries.length === 1 ? '' : 's'}):\n`);
409
+ for (const entry of entries) {
410
+ for (const hit of entry.hits) {
411
+ out.write(`${prefix} ${entry.path}:${hit.line} ${dim(hit.text)}\n`);
412
+ }
413
+ }
414
+ }
415
+
416
+ function groupBy(scan, form) {
417
+ return scan.files
418
+ .filter(file => !file.untouched)
419
+ .map(file => ({ path: file.path, hits: file.hits.filter(hit => hit.form === form) }))
420
+ .filter(file => file.hits.length);
421
+ }
422
+
423
+ export function reportScan(scan, oldRepoPath, newRepoPath, out, { prefix = '' } = {}) {
424
+ out.write(`${prefix}${oldRepoPath} → ${newRepoPath}\n\n`);
425
+ for (const form of ['comment', 'string', 'code']) {
426
+ writeGroup(out, FORM_LABELS[form], groupBy(scan, form), { prefix });
427
+ }
428
+ const untouched = scan.files.filter(file => file.untouched);
429
+ if (untouched.length) {
430
+ writeGroup(out, 'Never written (listed in codeRefsUntouched)', untouched, { prefix });
431
+ }
432
+ const anchored = scan.files.reduce((sum, file) => sum + file.hits.filter(hit => hit.anchored).length, 0);
433
+ if (anchored) out.write(`${prefix}${dim(`${anchored} carry a section anchor, which is kept as written.`)}\n`);
434
+ }
435
+
436
+ export function countLine(scan, oldRepoPath, newRepoPath) {
437
+ const files = scan.files.length;
438
+ return `${scan.total} code reference${scan.total === 1 ? '' : 's'} in ${files} file${files === 1 ? '' : 's'} still ${scan.total === 1 ? 'cites' : 'cite'} the old path; run \`runlist refs ${oldRepoPath} ${newRepoPath} --fix\``;
439
+ }
440
+
441
+ // ── The verb ─────────────────────────────────────────────────────────────
442
+
443
+ function requireConfigured(settings, out) {
444
+ if (settings.enabled) return true;
445
+ out.write(`${yellow('No code roots configured.')} Set \`codeRoots\` in runlist.config.mjs to scan source files for document citations.\n`);
446
+ return false;
447
+ }
448
+
449
+ export function runRefs(argv, config, opts = {}) {
450
+ const out = opts.out ?? process.stdout;
451
+ const fix = argv.includes('--fix');
452
+ const strings = argv.includes('--strings');
453
+ const dryRun = Boolean(opts.dryRun);
454
+ const positional = argv.filter(arg => !arg.startsWith('-'));
455
+ const settings = codeRefsConfig(config);
456
+ if (!requireConfigured(settings, out)) return { enabled: false };
457
+
458
+ if (positional[0] === 'repair') return repairAll(config, { fix, strings, dryRun, out });
459
+
460
+ const [oldInput, newInput] = positional;
461
+ if (!oldInput || !newInput) {
462
+ out.write('Usage: runlist refs <old> <new> [--fix] [--strings]\n runlist refs repair [--fix] [--strings]\n');
463
+ return { enabled: true };
464
+ }
465
+ const oldRepoPath = normalizeInput(oldInput, config);
466
+ const newRepoPath = normalizeInput(newInput, config);
467
+ return reportOne(config, oldRepoPath, newRepoPath, { fix, strings, dryRun, out, showEmpty: true });
468
+ }
469
+
470
+ function normalizeInput(input, config) {
471
+ const abs = path.resolve(config.repoRoot, input);
472
+ return toRepoPath(abs, config.repoRoot);
473
+ }
474
+
475
+ function reportOne(config, oldRepoPath, newRepoPath, { fix, strings, dryRun, out, showEmpty = false, files = null, scan: given = null }) {
476
+ const scan = given ?? scanCodeRefs(config, oldRepoPath, { files });
477
+ if (!scan.total) {
478
+ if (showEmpty) out.write(green(`No code references to ${oldRepoPath}.\n`));
479
+ return { enabled: true, scan, changed: 0 };
480
+ }
481
+ reportScan(scan, oldRepoPath, newRepoPath, out);
482
+ if (!fix) {
483
+ const writable = fixableCount(scan, { strings });
484
+ out.write(`\n${scan.total} reference${scan.total === 1 ? '' : 's'} in ${scan.files.length} file${scan.files.length === 1 ? '' : 's'}. `);
485
+ out.write(`${writable} would be rewritten by --fix${strings ? ' --strings' : ''}.\n`);
486
+ if (!strings && (scan.byForm.string || scan.byForm.code)) {
487
+ out.write(dim('Add --strings to rewrite the ones inside string literals and code.\n'));
488
+ }
489
+ return { enabled: true, scan, changed: 0 };
490
+ }
491
+ const applied = applyCodeRefs(config, scan, oldRepoPath, newRepoPath, { strings, dryRun });
492
+ const prefix = dryRun ? dim('[dry-run] ') : '';
493
+ out.write(`\n${prefix}${green(`Rewrote ${applied.changed} reference(s) in ${applied.written.length} file(s).`)}\n`);
494
+ for (const entry of applied.refused) {
495
+ out.write(`${yellow('Refused')}: ${entry.path} ${entry.reason}.\n`);
496
+ }
497
+ return { enabled: true, scan, changed: applied.changed, refused: applied.refused };
498
+ }
499
+
500
+ function repairAll(config, { fix, strings, dryRun, out }) {
501
+ const pairs = archivedPreviousPaths(config);
502
+ const wanted = new Map(pairs.map(pair => [pair.previous, pair.current]));
503
+ const found = scanManyCodeRefs(config, new Set(wanted.keys()));
504
+ let total = 0;
505
+ let changed = 0;
506
+ const stale = [];
507
+ for (const pair of pairs) {
508
+ const scan = found.get(pair.previous);
509
+ if (!scan?.total) continue;
510
+ stale.push({ ...pair, scan });
511
+ total += scan.total;
512
+ }
513
+ if (!stale.length) {
514
+ out.write(green(`No stale code references across ${pairs.length} archived document(s).\n`));
515
+ return { enabled: true, total: 0, changed: 0, documents: 0 };
516
+ }
517
+ out.write(`${bold(`${total} stale code reference(s) across ${stale.length} archived document(s).`)}\n\n`);
518
+ for (const entry of stale) {
519
+ const applied = reportOne(config, entry.previous, entry.current, { fix, strings, dryRun, out, scan: entry.scan });
520
+ changed += applied.changed ?? 0;
521
+ out.write('\n');
522
+ }
523
+ out.write(dim('Previous paths come from the archive directory mapping — a rename records none, so `refs <old> <new>` covers those by hand.\n'));
524
+ return { enabled: true, total, changed, documents: stale.length };
525
+ }
526
+
527
+ /**
528
+ * The line archive and rename print after their own doc-root repair. Returns
529
+ * null when no code root is configured, which is today's behaviour exactly.
530
+ */
531
+ export function reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { fix = false, strings = false, dryRun = false, out = process.stdout, prefix = '' } = {}) {
532
+ const settings = codeRefsConfig(config);
533
+ if (!settings.enabled) return null;
534
+ const scan = scanCodeRefs(config, oldRepoPath);
535
+ if (!scan.total) return { total: 0, changed: 0 };
536
+ if (!fix) {
537
+ out.write(`${prefix}${countLine(scan, oldRepoPath, newRepoPath)}\n`);
538
+ return { total: scan.total, changed: 0 };
539
+ }
540
+ const applied = applyCodeRefs(config, scan, oldRepoPath, newRepoPath, { strings, dryRun });
541
+ out.write(`${prefix}Rewrote ${applied.changed} code reference(s) in ${applied.written.length} file(s).\n`);
542
+ for (const entry of applied.refused) {
543
+ out.write(`${prefix}${yellow('Refused')}: ${entry.path} ${entry.reason}.\n`);
544
+ }
545
+ return { total: scan.total, changed: applied.changed, refused: applied.refused };
546
+ }
package/src/commands.mjs CHANGED
@@ -120,7 +120,7 @@ const definitions = [
120
120
  command('status', mutates('managed source and same-root destination'), 'mutate', [form('<file> [status]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
121
121
  command('set', mutates('managed source and same-root destination'), 'mutate', [form('<status> [file]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
122
122
  command('ship', mutates('repository release/index paths and global release tooling'), 'mutate', [form('[patch|minor|major]', { args: positionals(0, 1) })]),
123
- command('archive', mutates('managed source and same-root destination'), 'mutate', [form('<file>', { args: positionals(1, 1), options: [...LIFECYCLE_OPTIONS, flag('--closeout-template')] })]),
123
+ command('archive', mutates('managed source and same-root destination'), 'mutate', [form('<file>', { args: positionals(1, 1), options: [...LIFECYCLE_OPTIONS, flag('--closeout-template'), flag('--fix-refs'), flag('--strings')] })]),
124
124
  command('bulk', mutates('managed source sweep; archive destinations preserve roots'), 'mutate', [
125
125
  form('archive <files...>', { subcommands: ['archive'], args: positionals(1, Infinity), options: [flag('--json'), flag('--no-index'), flag('--show-files')] }),
126
126
  form('tag [files...]', { subcommands: ['tag'], args: positionals(0, Infinity), options: [value('--type'), value('--status'), flag('--json')] }),
@@ -133,9 +133,13 @@ const definitions = [
133
133
  dashPositionalsAfter: 1,
134
134
  })]),
135
135
  command('lint', mutates('managed source sweep with --fix; otherwise read-only'), 'mutate', [form('', { options: [flag('--fix')] })]),
136
- command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files')] })]),
136
+ command('rename', mutates('managed source, same-root destination, and rewrite sweep'), 'mutate', [form('<old> [new]', { args: positionals(1, 2), options: [flag('--show-files'), flag('--fix-refs'), flag('--strings')] })]),
137
137
  command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
138
138
  command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
139
+ command('refs', mutates('configured code roots with --fix; otherwise read-only'), 'mutate', [
140
+ form('repair', { subcommands: ['repair'], options: [flag('--fix'), flag('--strings')] }),
141
+ form('<old> <new>', { args: positionals(2, 2), options: [flag('--fix'), flag('--strings')] }),
142
+ ]),
139
143
  command('fix-membership', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--json')] })]),
140
144
  command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
141
145
  command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
package/src/config.mjs CHANGED
@@ -22,6 +22,18 @@ const DEFAULTS = {
22
22
  // Floor under the scan surface; null = off. See `applyScanFloor` in validate.mjs.
23
23
  minDocs: null,
24
24
 
25
+ // Reference repair outside the doc roots (`runlist refs`). An empty
26
+ // `codeRoots` is off, which is the behaviour before this key existed: the
27
+ // doc-root repair archive and rename already run is unchanged either way.
28
+ // Paths are repo-relative. `null` on the two lists means "the built-in
29
+ // list", which lives in code-refs.mjs so config.mjs stays a leaf.
30
+ codeRoots: [],
31
+ codeExtensions: null,
32
+ codeExcludes: null,
33
+ // Files whose content is data keyed on document paths — a guard baseline
34
+ // whose keys are plan paths, for instance. Reported, never written.
35
+ codeRefsUntouched: [],
36
+
25
37
  types: {
26
38
  plan: {
27
39
  statuses: ['in-session', 'active', 'planned', 'blocked', 'partial', 'paused', 'awaiting', 'queued-after', 'archived'],
@@ -394,6 +406,17 @@ function validateConfig(userConfig, config, validStatuses, indexPath) {
394
406
  warnings.push("Config: index.snapshot must be 'status' or 'state'.");
395
407
  }
396
408
 
409
+ for (const key of ['codeRoots', 'codeRefsUntouched']) {
410
+ if (config[key] !== undefined && !Array.isArray(config[key])) {
411
+ warnings.push(`Config: ${key} must be an array.`);
412
+ }
413
+ }
414
+ for (const key of ['codeExtensions', 'codeExcludes']) {
415
+ if (config[key] != null && !Array.isArray(config[key])) {
416
+ warnings.push(`Config: ${key} must be an array.`);
417
+ }
418
+ }
419
+
397
420
  // Unknown top-level user config keys
398
421
  for (const key of Object.keys(userConfig)) {
399
422
  if (!VALID_CONFIG_KEYS.has(key)) {
@@ -608,6 +631,13 @@ export async function resolveConfig(cwd, explicitConfigPath) {
608
631
  docsRootPrefix,
609
632
  minDocs,
610
633
 
634
+ codeRefs: {
635
+ roots: Array.isArray(config.codeRoots) ? config.codeRoots : [],
636
+ extensions: Array.isArray(config.codeExtensions) ? config.codeExtensions : null,
637
+ excludes: Array.isArray(config.codeExcludes) ? config.codeExcludes : null,
638
+ untouched: Array.isArray(config.codeRefsUntouched) ? config.codeRefsUntouched : [],
639
+ },
640
+
611
641
  statusOrder,
612
642
  validStatuses,
613
643
  validTypes,
@@ -42,7 +42,8 @@ export function extractNextStep(body) {
42
42
  }
43
43
 
44
44
  export function extractBodyLinks(body) {
45
- if (!body) return [];
45
+ // Every inline link contains `](`, and masking never creates one.
46
+ if (!body || !body.includes('](')) return [];
46
47
  // Strip fenced code blocks, then MASK inline code rather than delete it.
47
48
  // Deleting it ate the commonest link idiom in a plan hub: [`plan.md`](plan.md)
48
49
  // has its link TEXT as a code span, so removing the span left `[](plan.md)`,
package/src/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
4
  import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts, extractBodyLinks } from './extractors.mjs';
5
5
  import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath, warn, die, resolveDocPath, suggestCandidates } from './util.mjs';
6
6
  import { findLexicalDocsRoot } from './managed-path.mjs';
7
+ import { openParseCache, fileStamp, stampSize } from './parse-cache.mjs';
7
8
  import { validateDoc, validatePlanShape, validateDocShape, checkBidirectionalReferences, checkGitStaleness, checkRunlistBackPointers, checkCoordinationHubExecutionMode, checkRoadmapHubExecutionMode, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate, enrichRefErrorSuggestions } from './validate.mjs';
8
9
  import { checkIndex } from './index-file.mjs';
9
10
  import { checkClaudeCommands } from './claude-commands.mjs';
@@ -30,7 +31,9 @@ export function buildIndex(config, opts = {}) {
30
31
  const invokeHooks = opts.invokeHooks ?? !config._execution?.suppressSideEffects;
31
32
  const gitStaleness = opts.gitStaleness ?? config._execution?.gitStaleness ?? true;
32
33
  const skipWarningOnlyChecks = fast || errorsOnly;
33
- const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast }));
34
+ const cache = openParseCache(config);
35
+ const docs = collectDocFiles(config).map(f => parseDocFile(f, config, { fast, cache }));
36
+ if (cache && !config._execution?.suppressSideEffects) cache.save();
34
37
  if (!fast) {
35
38
  // Per-file validation (validateDoc) ran during parse without sibling
36
39
  // visibility. Now that the full index is materialized, enrich
@@ -271,16 +274,45 @@ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths, seen = new
271
274
  }
272
275
  }
273
276
 
274
- export function parseDocFile(filePath, config, opts = {}) {
275
- const { fast = false } = opts;
276
- const relativePath = toRepoPath(filePath, config.repoRoot);
277
- const raw = readFileSync(filePath, 'utf8');
278
- const { frontmatter, body } = extractFrontmatter(raw);
277
+ // Everything parseDocFile takes from the file's text alone, with no config and
278
+ // no clock, which is what the parse cache may keep.
279
+ function extractDocText(frontmatter, body) {
279
280
  const fmWarnings = [];
280
281
  const parsedFrontmatter = parseSimpleFrontmatter(frontmatter, fmWarnings);
281
- const headingTitle = extractFirstHeading(body);
282
+ return {
283
+ parsedFrontmatter,
284
+ fmWarnings: fmWarnings.map(w => ({ message: w.message })),
285
+ headingTitle: extractFirstHeading(body),
286
+ bodySummary: extractSummary(body),
287
+ bodyStatusSnapshot: extractStatusSnapshot(body),
288
+ bodyNextStep: extractNextStep(body),
289
+ checklist: extractChecklistCounts(body),
290
+ bodyLinks: extractBodyLinks(body),
291
+ hasCloseout: /^##\s+Closeout/m.test(body),
292
+ };
293
+ }
294
+
295
+ export function parseDocFile(filePath, config, opts = {}) {
296
+ const { fast = false, cache = null } = opts;
297
+ const relativePath = toRepoPath(filePath, config.repoRoot);
298
+ const stamp = cache ? fileStamp(filePath) : null;
299
+ let text = stamp ? cache.get(relativePath, stamp) : null;
300
+ // Validation reads the body itself, so only a fast build can skip the read.
301
+ let body = null;
302
+ if (!text || !fast) {
303
+ const raw = readFileSync(filePath, 'utf8');
304
+ const extracted = extractFrontmatter(raw);
305
+ body = extracted.body;
306
+ // A file rewritten between the stat and the read no longer matches its stamp.
307
+ if (text && Buffer.byteLength(raw) !== stampSize(stamp)) text = null;
308
+ if (!text) {
309
+ text = extractDocText(extracted.frontmatter, body);
310
+ if (stamp) cache.set(relativePath, stamp, text);
311
+ }
312
+ }
313
+ const { parsedFrontmatter, fmWarnings, headingTitle, checklist, bodyLinks, hasCloseout } = text;
282
314
  const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
283
- const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
315
+ const summary = asString(parsedFrontmatter.summary) ?? text.bodySummary ?? null;
284
316
  // For terminal-status docs (archived / reference / deprecated by default),
285
317
  // skip the body-scrape and the "No current_state set" fallback when the user
286
318
  // didn't set `current_state:` in frontmatter explicitly. Body text on a
@@ -305,7 +337,7 @@ export function parseDocFile(filePath, config, opts = {}) {
305
337
  } else if (isTerminalDoc) {
306
338
  currentState = null;
307
339
  } else {
308
- const scraped = extractStatusSnapshot(body);
340
+ const scraped = text.bodyStatusSnapshot;
309
341
  if (scraped) {
310
342
  currentState = scraped;
311
343
  currentStateOrigin = 'body';
@@ -313,7 +345,7 @@ export function parseDocFile(filePath, config, opts = {}) {
313
345
  currentState = 'No current_state set';
314
346
  }
315
347
  }
316
- const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
348
+ const nextStep = asString(parsedFrontmatter.next_step) ?? text.bodyNextStep ?? null;
317
349
  // `blocked_by` is accepted as an alias for `blockers` since 0.39.3 — agents
318
350
  // filing tickets naturally reach for the JIRA/Linear name. If both are set,
319
351
  // they're merged (de-duped via normalizeBlockers → mergeUniqueStrings).
@@ -328,9 +360,6 @@ export function parseDocFile(filePath, config, opts = {}) {
328
360
  const domain = asString(parsedFrontmatter.domain) ?? null;
329
361
  const audience = asString(parsedFrontmatter.audience) ?? null;
330
362
  const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
331
- const checklist = extractChecklistCounts(body);
332
- const bodyLinks = extractBodyLinks(body);
333
- const hasCloseout = /^##\s+Closeout/m.test(body);
334
363
 
335
364
  // Dynamic reference field extraction. A leading `>` on a value (e.g.
336
365
  // `"> docs/audit-beyond-platform.md"`) marks that single ref as one-way —
package/src/lifecycle.mjs CHANGED
@@ -13,6 +13,7 @@ import { walkSections, findSection } from './section.mjs';
13
13
  import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSweep } from './managed-path.mjs';
14
14
  import { withPathLocks, snapshotFile, replaceSnapshot, moveFileAtomic, mutateFile, mutateFileSet } from './atomic-mutation.mjs';
15
15
  import { configuredReferenceFields, createReferenceIdentitySet, rewriteDocumentReferences } from './reference-planner.mjs';
16
+ import { reportMovedCodeRefs } from './code-refs.mjs';
16
17
  import {
17
18
  assertPlanMutationAuthorized,
18
19
  assertHookDeliveryTakeoverSafe,
@@ -705,7 +706,10 @@ export function runArchive(argv, config, opts = {}) {
705
706
  const noIndex = argv.includes('--no-index') || opts.noIndex;
706
707
  const showFiles = argv.includes('--show-files') || opts.showFiles;
707
708
  const closeoutTemplate = argv.includes('--closeout-template');
708
- argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template' && a !== '--force');
709
+ const fixCodeRefs = argv.includes('--fix-refs') || opts.fixRefs;
710
+ const codeRefStrings = argv.includes('--strings');
711
+ argv = argv.filter(a => a !== '--no-index' && a !== '--show-files' && a !== '--closeout-template' && a !== '--force'
712
+ && a !== '--fix-refs' && a !== '--strings');
709
713
  let note = opts.note ?? null;
710
714
  const noteIdx = argv.indexOf('--note');
711
715
  if (noteIdx !== -1) {
@@ -833,6 +837,10 @@ export function runArchive(argv, config, opts = {}) {
833
837
  }
834
838
  }
835
839
 
840
+ if (!opts.skipInboundRefs) {
841
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { dryRun: true, out, prefix: `${prefix} ` });
842
+ }
843
+
836
844
  // Preview onArchive hook fire
837
845
  if (config.hooks?.onArchive) {
838
846
  out.write(`${prefix} Would fire hook: onArchive\n`);
@@ -868,6 +876,12 @@ export function runArchive(argv, config, opts = {}) {
868
876
  }
869
877
  if (selfRefsFixed) out.write('Updated references in archived file.\n');
870
878
  if (updatedRefCount > 0) out.write(`Updated references in ${updatedRefCount} file(s).\n`);
879
+ // The doc-root repair above is committed. The code roots are a separate
880
+ // sweep outside the move transaction, so the count is reported and the
881
+ // write waits for --fix-refs.
882
+ if (!opts.skipInboundRefs) {
883
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { fix: fixCodeRefs, strings: codeRefStrings, out });
884
+ }
871
885
  if (config.indexPath && indexRegenerated) out.write('Index regenerated.\n');
872
886
  if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
873
887
 
@@ -1,7 +1,5 @@
1
- function maskRange(value, start, end) {
2
- return value.slice(0, start)
3
- + value.slice(start, end).replace(/[^\n]/g, 'x')
4
- + value.slice(end);
1
+ function mask(text) {
2
+ return text.includes('\n') ? text.replace(/[^\n]/g, 'x') : 'x'.repeat(text.length);
5
3
  }
6
4
 
7
5
  // Markdown code spans close only on a backtick run of the same length as their
@@ -11,13 +9,19 @@ function maskRange(value, start, end) {
11
9
  // rewriter survive: after an unmatched opener, nothing is treated as prose
12
10
  // until a compatible closer appears.
13
11
  export function maskInlineCodeLine(line, state = { run: null }) {
14
- const ranges = [];
12
+ if (!line.includes('`')) return state.run === null ? line : mask(line);
13
+
15
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 = [];
16
20
  let index = 0;
17
21
 
18
22
  if (state.run !== null) {
19
- const closingIndex = runs.findIndex(candidate => candidate[0].length === state.run);
20
- if (closingIndex === -1) return maskRange(line, 0, line.length);
23
+ const closingIndex = findCloser(0, state.run);
24
+ if (closingIndex === -1) return mask(line);
21
25
  const closing = runs[closingIndex];
22
26
  ranges.push([0, closing.index + closing[0].length]);
23
27
  index = closingIndex + 1;
@@ -26,8 +30,7 @@ export function maskInlineCodeLine(line, state = { run: null }) {
26
30
 
27
31
  for (; index < runs.length; index++) {
28
32
  const opening = runs[index];
29
- const closingIndex = runs.findIndex((candidate, candidateIndex) =>
30
- candidateIndex > index && candidate[0].length === opening[0].length);
33
+ const closingIndex = findCloser(index + 1, opening[0].length);
31
34
  if (closingIndex === -1) {
32
35
  ranges.push([opening.index, line.length]);
33
36
  state.run = opening[0].length;
@@ -38,7 +41,15 @@ export function maskInlineCodeLine(line, state = { run: null }) {
38
41
  index = closingIndex;
39
42
  }
40
43
 
41
- return ranges.reduceRight((masked, [start, end]) => maskRange(masked, start, end), line);
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);
42
53
  }
43
54
 
44
55
  export function maskInlineCodeSpans(value) {
@@ -0,0 +1,99 @@
1
+ import { existsSync, readFileSync, statSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { commitRename } from './durable-rename.mjs';
5
+ import { readEnv, stateDir } from './naming.mjs';
6
+
7
+ // What every build of the index used to redo from scratch: read each document
8
+ // and pull its frontmatter, links, checklist and summary out of the body. None
9
+ // of that depends on the config or the clock, so it is kept per file under the
10
+ // state directory and reused while the file's size, times and inode are
11
+ // unchanged. Everything that does depend on them (terminal statuses, staleness,
12
+ // reference fields, validation) is still computed on every run.
13
+ //
14
+ // One line per document, `path \t stamp \t json`, so a hit parses only its own
15
+ // line and hands back fresh objects: nothing a caller does to a document can
16
+ // leak into what is saved. The cache is an optimisation and never an input:
17
+ // a missing, unreadable or foreign-version file is treated as empty, a failed
18
+ // write is dropped, and `RUNLIST_NO_PARSE_CACHE=1` turns it off.
19
+
20
+ const SCHEMA = 1;
21
+ const FILE_NAME = 'parse-cache';
22
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
+ const VERSION = (() => {
24
+ try { return JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).version; }
25
+ catch { return 'unknown'; }
26
+ })();
27
+ const HEADER = JSON.stringify({ schema: SCHEMA, version: VERSION });
28
+
29
+ export function fileStamp(filePath) {
30
+ try {
31
+ const s = statSync(filePath, { bigint: true });
32
+ return `${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.ino}`;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ export function stampSize(stamp) {
39
+ return Number(stamp.slice(0, stamp.indexOf(':')));
40
+ }
41
+
42
+ export function openParseCache(config) {
43
+ if (readEnv('NO_PARSE_CACHE') === '1') return null;
44
+ const dir = stateDir(config.repoRoot);
45
+ // The state directory is created, and ignored by git, by `init` and the
46
+ // commands that own it; a repo without one gets no cache rather than a new
47
+ // untracked folder from a read-only command.
48
+ if (!existsSync(dir)) return null;
49
+ const cachePath = path.join(dir, FILE_NAME);
50
+ const lines = new Map();
51
+ try {
52
+ const text = readFileSync(cachePath, 'utf8');
53
+ const rows = text.split('\n');
54
+ if (rows[0] === HEADER) {
55
+ for (let i = 1; i < rows.length; i++) {
56
+ const row = rows[i];
57
+ const a = row.indexOf('\t');
58
+ const b = row.indexOf('\t', a + 1);
59
+ if (a < 0 || b < 0) continue;
60
+ lines.set(row.slice(0, a), { stamp: row.slice(a + 1, b), row });
61
+ }
62
+ }
63
+ } catch { /* no cache yet */ }
64
+
65
+ const seen = new Set();
66
+ let dirty = false;
67
+
68
+ return {
69
+ get(key, stamp) {
70
+ seen.add(key);
71
+ const entry = lines.get(key);
72
+ if (!entry || entry.stamp !== stamp) return null;
73
+ try { return JSON.parse(entry.row.slice(entry.row.indexOf('\t', entry.row.indexOf('\t') + 1) + 1)); }
74
+ catch { return null; }
75
+ },
76
+ set(key, stamp, value) {
77
+ seen.add(key);
78
+ if (/[\t\n]/.test(key)) return;
79
+ lines.set(key, { stamp, row: `${key}\t${stamp}\t${JSON.stringify(value)}` });
80
+ dirty = true;
81
+ },
82
+ save({ complete = true } = {}) {
83
+ if (complete) {
84
+ for (const key of lines.keys()) {
85
+ if (!seen.has(key)) { lines.delete(key); dirty = true; }
86
+ }
87
+ }
88
+ if (!dirty) return;
89
+ const temp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
90
+ try {
91
+ writeFileSync(temp, [HEADER, ...[...lines.values()].map(entry => entry.row)].join('\n'), 'utf8');
92
+ commitRename(temp, cachePath);
93
+ dirty = false;
94
+ } catch {
95
+ try { unlinkSync(temp); } catch { /* already gone */ }
96
+ }
97
+ },
98
+ };
99
+ }
package/src/pickup.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-path.mjs';
5
5
  import os from 'node:os';
@@ -233,7 +233,48 @@ function validateBinding(record, identity, config) {
233
233
  return record;
234
234
  }
235
235
 
236
+ // Working out a plan's canonical identity lists every directory on its path,
237
+ // three times over, and listing plans asks it of every plan in the repo. A
238
+ // record can only belong to a plan whose file name it carries, so the names in
239
+ // the ownership folder rule most plans out first. The summary is rebuilt
240
+ // whenever the folder changes, and any record it cannot read turns the check
241
+ // off, so the full identity check still decides every case it could get wrong.
242
+ let ownershipNamesCache = null;
243
+
244
+ function ownershipNames(config) {
245
+ const dir = ownershipRoot(config);
246
+ let stamp;
247
+ try { stamp = `${dir}\0${statSync(dir, { bigint: true }).mtimeNs}`; }
248
+ catch { return new Set(); }
249
+ if (ownershipNamesCache?.stamp === stamp) return ownershipNamesCache.names;
250
+ let names = new Set();
251
+ try {
252
+ for (const entry of readdirSync(dir)) {
253
+ if (!entry.endsWith('.json')) continue;
254
+ const record = parseOwnership(readFileSync(path.join(dir, entry), 'utf8'), entry);
255
+ if (record.corrupt) { names = null; break; }
256
+ names.add(path.basename(record.canonicalPath).toLowerCase());
257
+ names.add(path.basename(record.plan).toLowerCase());
258
+ }
259
+ } catch { names = null; }
260
+ ownershipNamesCache = { stamp, names };
261
+ return names;
262
+ }
263
+
264
+ function mayHaveOwnershipRecord(absolutePath, config) {
265
+ const names = ownershipNames(config);
266
+ if (names === null || names.has(path.basename(absolutePath).toLowerCase())) return true;
267
+ try {
268
+ if (lstatSync(absolutePath).isSymbolicLink()) return true;
269
+ // A record whose fields were rewritten still sits at its identity's file
270
+ // name, and has to be found to be reported corrupt.
271
+ const guess = createHash('sha256').update(realpathSync(absolutePath)).digest('hex');
272
+ return existsSync(path.join(ownershipRoot(config), `${guess}.json`));
273
+ } catch { return true; }
274
+ }
275
+
236
276
  export function readPlanOwnership(repoPath, config) {
277
+ if (!mayHaveOwnershipRecord(path.resolve(config.repoRoot, repoPath), config)) return null;
237
278
  let identity;
238
279
  try { identity = canonicalPlanIdentity(path.resolve(config.repoRoot, repoPath), config); }
239
280
  catch { return null; }
package/src/rename.mjs CHANGED
@@ -10,9 +10,12 @@ import { authorizeManagedDestination, authorizeManagedSource, authorizeManagedSw
10
10
  import { availableSessionId, prepareOwnershipMigration } from './pickup.mjs';
11
11
  import { moveFileAtomic } from './atomic-mutation.mjs';
12
12
  import { configuredReferenceFields, createReferenceIdentitySet, planReferenceMove, rewriteDocumentReferences } from './reference-planner.mjs';
13
+ import { reportMovedCodeRefs } from './code-refs.mjs';
13
14
 
14
15
  export async function runRename(argv, config, opts = {}) {
15
16
  const { dryRun } = opts;
17
+ const fixCodeRefs = argv.includes('--fix-refs') || opts.fixRefs;
18
+ const codeRefStrings = argv.includes('--strings');
16
19
  const positional = argv.filter(arg => !arg.startsWith('-'));
17
20
  const oldInput = positional[0];
18
21
  let newInput = positional[1];
@@ -58,6 +61,7 @@ export async function runRename(argv, config, opts = {}) {
58
61
  for (const item of referencePlan.updates) process.stdout.write(`${prefix} ${toRepoPath(item.path, config.repoRoot)}\n`);
59
62
  }
60
63
  if (ownership) process.stdout.write(`${prefix} Would migrate this session's ownership record.\n`);
64
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { dryRun: true, prefix: `${prefix} ` });
61
65
  return;
62
66
  }
63
67
 
@@ -93,6 +97,10 @@ export async function runRename(argv, config, opts = {}) {
93
97
  regenIndex(config);
94
98
  process.stdout.write(`${green('Renamed')}: ${oldRepoPath} → ${newRepoPath}\n`);
95
99
  if (result.updatedPaths.length) process.stdout.write(`Updated references in ${result.updatedPaths.length} file(s).\n`);
100
+ // The doc-root repair above is committed. The code roots are a separate
101
+ // sweep outside the move transaction, so the count is reported and the
102
+ // write waits for --fix-refs.
103
+ reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { fix: fixCodeRefs, strings: codeRefStrings });
96
104
  try { config.hooks.onRename?.({ oldPath: oldRepoPath, newPath: newRepoPath, referencesUpdated: result.updatedPaths.length }); }
97
105
  catch (err) { warn(`Hook 'onRename' threw: ${err.message}`); }
98
106
  return { oldRepoPath, newRepoPath, referencePaths: result.updatedPaths.map(item => toRepoPath(item, config.repoRoot)) };