dotmd-cli 0.80.0 → 0.82.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 +20 -0
- package/bin/dotmd.mjs +36 -0
- package/package.json +1 -1
- package/runlist.config.example.mjs +32 -0
- package/src/baton.mjs +30 -2
- package/src/code-refs.mjs +546 -0
- package/src/commands.mjs +6 -2
- package/src/config.mjs +30 -0
- package/src/git.mjs +15 -3
- package/src/lifecycle.mjs +24 -2
- package/src/prompts.mjs +14 -4
- package/src/rename.mjs +8 -0
- package/src/util.mjs +48 -2
- package/src/validate.mjs +13 -2
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,
|
|
@@ -1873,6 +1908,7 @@ async function main() {
|
|
|
1873
1908
|
if (command === 'rename') { const { runRename } = await import('../src/rename.mjs'); await runRename(restArgs, config, { dryRun }); return; }
|
|
1874
1909
|
if (command === 'migrate') { const { runMigrate } = await import('../src/migrate.mjs'); runMigrate(restArgs, config, { dryRun }); return; }
|
|
1875
1910
|
if (command === 'fix-refs') { const { runFixRefs } = await import('../src/fix-refs.mjs'); runFixRefs(restArgs, config, { dryRun }); return; }
|
|
1911
|
+
if (command === 'refs') { const { runRefs } = await import('../src/code-refs.mjs'); runRefs(restArgs, config, { dryRun }); return; }
|
|
1876
1912
|
if (command === 'fix-membership') { const { runFixMembership } = await import('../src/fix-membership.mjs'); await runFixMembership(restArgs, config, { dryRun }); return; }
|
|
1877
1913
|
if (command === 'sync-status') { const { runSyncStatus } = await import('../src/sync-status.mjs'); await runSyncStatus(restArgs, config, { dryRun }); return; }
|
|
1878
1914
|
if (command === 'self-check') {
|
package/package.json
CHANGED
|
@@ -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
|
//
|
package/src/baton.mjs
CHANGED
|
@@ -161,6 +161,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
161
161
|
let repoPath = null;
|
|
162
162
|
let oldStatus = null;
|
|
163
163
|
let ownershipPath = null;
|
|
164
|
+
let planGuard = null;
|
|
164
165
|
if (planPath) {
|
|
165
166
|
planPath = authorizeManagedSource(planPath, config, { kind: 'Baton plan source' }).path;
|
|
166
167
|
repoPath = toRepoPath(planPath, config.repoRoot);
|
|
@@ -183,7 +184,29 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
183
184
|
if (status === 'in-session') {
|
|
184
185
|
die('`runlist baton --status in-session` contradicts baton release semantics. Choose active/paused/awaiting/partial/blocked.');
|
|
185
186
|
}
|
|
186
|
-
|
|
187
|
+
const sessionId = authoritativeSessionId();
|
|
188
|
+
const ownership = assertPlanMutationAuthorized(repoPath, config, { sessionId, force });
|
|
189
|
+
const ownedHere = ownership?.state === 'owned' && ownership.sessionId === sessionId;
|
|
190
|
+
// Baton's release only means something when there is a claim to release. A
|
|
191
|
+
// plan that is neither in-session nor owned here carries a status someone
|
|
192
|
+
// chose on purpose (`awaiting`, `blocked`, a repo's own `awaiting-testing`),
|
|
193
|
+
// and flipping it to the default `active` as a side effect of saving a
|
|
194
|
+
// prompt overwrites that reason — silently, since baton's headline is the
|
|
195
|
+
// prompt it saved. That is not hypothetical: a prompt-refresh pass named a
|
|
196
|
+
// plan slug, got a status flip it never asked for, and had to notice and
|
|
197
|
+
// undo it. So the prompt still lands with its `plan:` link, and the plan's
|
|
198
|
+
// status stays put unless the caller states the transition with --status.
|
|
199
|
+
const currentStatus = asString(fm.status);
|
|
200
|
+
if (!statusFlag && oldStatus !== 'in-session' && !ownedHere) {
|
|
201
|
+
if (!currentStatus || (validStatuses?.size > 0 && !validStatuses.has(currentStatus))) {
|
|
202
|
+
die(`${repoPath} is not in-session and its status (\`${oldStatus}\`) is not one baton can leave in place.\n`
|
|
203
|
+
+ `Say what it should become: runlist baton ${repoPath} @<draft-file> --status <status>\n`
|
|
204
|
+
+ `Or save the prompt without touching the plan: runlist baton ${path.basename(planPath, '.md')} @<draft-file>`);
|
|
205
|
+
}
|
|
206
|
+
status = currentStatus;
|
|
207
|
+
planGuard = { path: planPath, expectedContent: raw };
|
|
208
|
+
if (note) warn('--note ignored — the plan\'s status is unchanged, so there is no transition to record.');
|
|
209
|
+
}
|
|
187
210
|
// Before the plan-completion step: a refusal must leave nothing changed.
|
|
188
211
|
refuseIfPending();
|
|
189
212
|
ownershipPath = readPlanOwnership(repoPath, config)?.recordPath ?? null;
|
|
@@ -221,7 +244,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
221
244
|
const prepared = preparePromptDocument(slugBase, body, config, { plan: repoPath, dryRun });
|
|
222
245
|
const setArgs = [status, planPath];
|
|
223
246
|
if (force) setArgs.push('--force');
|
|
224
|
-
if (note) setArgs.push('--note', note);
|
|
247
|
+
if (note && !planGuard) setArgs.push('--note', note);
|
|
225
248
|
try {
|
|
226
249
|
if (dryRun) process.stdout.write(`${dim('[dry-run]')} Would create: ${prepared.repoPath}\n`);
|
|
227
250
|
archiveResult = await runSet(setArgs, config, {
|
|
@@ -229,6 +252,7 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
229
252
|
viaBaton: true,
|
|
230
253
|
testHooks: opts.testHooks,
|
|
231
254
|
creations: dryRun ? [] : [{ path: prepared.filePath, content: prepared.content }],
|
|
255
|
+
guards: planGuard && !dryRun ? [planGuard] : [],
|
|
232
256
|
deferIndex: true,
|
|
233
257
|
});
|
|
234
258
|
} catch (err) {
|
|
@@ -297,6 +321,10 @@ export async function runBaton(argv, config, opts = {}) {
|
|
|
297
321
|
return operationResult;
|
|
298
322
|
}
|
|
299
323
|
process.stderr.write(`\n${prefix}${green('✓ Baton passed')}: ${createdSlug} (the next session's hud surfaces it — nothing to paste into chat)\n`);
|
|
324
|
+
if (planGuard) {
|
|
325
|
+
process.stderr.write(dim(`${repoPath} left at \`${oldStatus}\` — it was not in-session, so there was no claim to release.\n`));
|
|
326
|
+
process.stderr.write(dim(`Meant to change it? runlist set <status> ${repoPath}\n`));
|
|
327
|
+
}
|
|
300
328
|
if (statusChanged) {
|
|
301
329
|
const pathspec = operationResult.repositoryFiles.join(' ');
|
|
302
330
|
let gitignored = false;
|
|
@@ -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,
|
package/src/git.mjs
CHANGED
|
@@ -122,8 +122,7 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
|
122
122
|
assertSafeGitPaths(paths);
|
|
123
123
|
if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
|
|
124
124
|
// Full-tree callers can supply a small set of configured root pathspecs for
|
|
125
|
-
// diff extraction.
|
|
126
|
-
// so excluded or unrelated documents cannot consume the commit bound.
|
|
125
|
+
// diff extraction.
|
|
127
126
|
const scanPaths = options.pathspecs?.length ? [...new Set(options.pathspecs)] : paths;
|
|
128
127
|
assertSafeGitPaths(scanPaths);
|
|
129
128
|
const expectedPaths = new Set(paths);
|
|
@@ -133,10 +132,23 @@ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
|
133
132
|
const commitsByPath = new Map();
|
|
134
133
|
const history = new Map();
|
|
135
134
|
let reason = null;
|
|
135
|
+
// Revision selection uses the root pathspecs when the caller supplied them,
|
|
136
|
+
// not the full requested list: Git matches every pathspec against every
|
|
137
|
+
// commit's diff, so a full-tree scan of a ~5k-doc corpus spent ~14s here
|
|
138
|
+
// walking history 5k pathspecs at a time (the same walk costs ~0.25s against
|
|
139
|
+
// one root). The window can only widen, never narrow, per requested path —
|
|
140
|
+
// every requested path lives under a root, so a commit touching one matches
|
|
141
|
+
// the root too, and the window stays a newest-first prefix of history. What
|
|
142
|
+
// it costs is depth: commits touching only non-requested docs now consume the
|
|
143
|
+
// bound, so a path whose latest commit falls outside the window resolves to
|
|
144
|
+
// no date at all rather than to a stale one. That case is already accounted
|
|
145
|
+
// for below — an unresolved TRACKED path keeps `commit-limit`, which callers
|
|
146
|
+
// surface as "Git metadata is incomplete".
|
|
147
|
+
const revisionPathspecs = scanPaths.length < paths.length ? scanPaths : paths;
|
|
136
148
|
const revisions = spawnSync('git', ['rev-list', '--stdin', `--max-count=${maxCommits + 1}`, revision], {
|
|
137
149
|
cwd: repoRoot,
|
|
138
150
|
encoding: 'utf8',
|
|
139
|
-
input: `--\n${
|
|
151
|
+
input: `--\n${revisionPathspecs.map(literalGitPathspec).join('\n')}\n`,
|
|
140
152
|
maxBuffer,
|
|
141
153
|
});
|
|
142
154
|
if (revisions.error?.code === 'ENOBUFS') {
|
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,
|
|
@@ -417,7 +418,15 @@ export async function runStatus(argv, config, opts = {}) {
|
|
|
417
418
|
|
|
418
419
|
if (oldStatus === newStatus) {
|
|
419
420
|
if (!dryRun && (opts.additionalUpdates?.length || opts.creations?.length)) {
|
|
420
|
-
|
|
421
|
+
// Guards belong here as much as on the mutating paths below: a caller that
|
|
422
|
+
// asked for the status it already has (baton's prompt-only handoff) decided
|
|
423
|
+
// that from this file's status and then writes only its creations, so the
|
|
424
|
+
// read and the write need something holding the gap.
|
|
425
|
+
mutateFileSet({
|
|
426
|
+
updates: opts.additionalUpdates ?? [],
|
|
427
|
+
creations: opts.creations ?? [],
|
|
428
|
+
guards: opts.guards ?? [],
|
|
429
|
+
}, {
|
|
421
430
|
repoRoot: config.repoRoot,
|
|
422
431
|
testHooks: opts.testHooks,
|
|
423
432
|
});
|
|
@@ -697,7 +706,10 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
697
706
|
const noIndex = argv.includes('--no-index') || opts.noIndex;
|
|
698
707
|
const showFiles = argv.includes('--show-files') || opts.showFiles;
|
|
699
708
|
const closeoutTemplate = argv.includes('--closeout-template');
|
|
700
|
-
|
|
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');
|
|
701
713
|
let note = opts.note ?? null;
|
|
702
714
|
const noteIdx = argv.indexOf('--note');
|
|
703
715
|
if (noteIdx !== -1) {
|
|
@@ -825,6 +837,10 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
825
837
|
}
|
|
826
838
|
}
|
|
827
839
|
|
|
840
|
+
if (!opts.skipInboundRefs) {
|
|
841
|
+
reportMovedCodeRefs(config, oldRepoPath, newRepoPath, { dryRun: true, out, prefix: `${prefix} ` });
|
|
842
|
+
}
|
|
843
|
+
|
|
828
844
|
// Preview onArchive hook fire
|
|
829
845
|
if (config.hooks?.onArchive) {
|
|
830
846
|
out.write(`${prefix} Would fire hook: onArchive\n`);
|
|
@@ -860,6 +876,12 @@ export function runArchive(argv, config, opts = {}) {
|
|
|
860
876
|
}
|
|
861
877
|
if (selfRefsFixed) out.write('Updated references in archived file.\n');
|
|
862
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
|
+
}
|
|
863
885
|
if (config.indexPath && indexRegenerated) out.write('Index regenerated.\n');
|
|
864
886
|
if (config.indexPath && noIndex) out.write(dim('(index not regenerated — run `runlist index` to refresh)\n'));
|
|
865
887
|
|
package/src/prompts.mjs
CHANGED
|
@@ -56,7 +56,13 @@ export async function runPrompts(argv, config, opts = {}) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function runPromptsList(argv, config, opts = {}) {
|
|
59
|
-
|
|
59
|
+
// Listing prompts renders slug / status / updated / target — nothing any
|
|
60
|
+
// validating pass produces, and `runQuery` never reads warnings either. A
|
|
61
|
+
// full `buildIndex` here parsed every body in the repo through the ref,
|
|
62
|
+
// git-staleness and hub passes (~25s on a 5k-doc corpus) to print ten lines,
|
|
63
|
+
// and then `pendingPromptsOldestFirst` scanned the tree a second time. One
|
|
64
|
+
// fast index, shared, on the same terms hud reads on.
|
|
65
|
+
const index = buildIndex(config, { fast: true, invokeHooks: false });
|
|
60
66
|
const hasStatusFlag = argv.includes('--status');
|
|
61
67
|
const includeArchived = argv.includes('--include-archived');
|
|
62
68
|
const sub = argv[0];
|
|
@@ -87,7 +93,7 @@ function runPromptsList(argv, config, opts = {}) {
|
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
function renderPromptQueueList(index, config) {
|
|
90
|
-
const queue = pendingPromptsOldestFirst(config);
|
|
96
|
+
const queue = pendingPromptsOldestFirst(config, index);
|
|
91
97
|
const queuedPaths = new Set(queue.map(q => q.doc.path));
|
|
92
98
|
const others = index.docs
|
|
93
99
|
.filter(d => d.type === 'prompt' && !queuedPaths.has(d.path) && !isArchivedPath(d.path, config) && d.status !== 'archived')
|
|
@@ -175,8 +181,12 @@ function renderPromptsVerbose(index, config, { hasStatusFlag, includeArchived })
|
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
|
|
179
|
-
|
|
184
|
+
// `index` lets a caller that already has a fast index (the prompt listings)
|
|
185
|
+
// share it rather than re-walking the tree. It must be a full index of the
|
|
186
|
+
// repo: the filter below narrows it, so a pre-filtered one would drop prompts
|
|
187
|
+
// from the queue.
|
|
188
|
+
export function pendingPromptsOldestFirst(config, index = null) {
|
|
189
|
+
index = index ?? buildIndex(config, { fast: true, invokeHooks: false });
|
|
180
190
|
const actionable = actionablePromptStatuses(config);
|
|
181
191
|
const prompts = index.docs.filter(d =>
|
|
182
192
|
d.type === 'prompt'
|
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)) };
|
package/src/util.mjs
CHANGED
|
@@ -221,6 +221,48 @@ export function levenshtein(a, b) {
|
|
|
221
221
|
return matrix[b.length][a.length];
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Edit distance, but only as far as the caller cares. Returns the distance when
|
|
225
|
+
// it is <= limit and null when it provably exceeds it. The suggester asks
|
|
226
|
+
// "within 3 edits?" of every candidate in the index, and the full matrix
|
|
227
|
+
// computes an exact answer it then throws away: ~900 cells and two allocations
|
|
228
|
+
// per pair, several hundred thousand pairs per unresolved reference on a large
|
|
229
|
+
// corpus. Only cells within `limit` of the diagonal can hold a value <= limit,
|
|
230
|
+
// so this walks that band in two flat rows and abandons a row whose cheapest
|
|
231
|
+
// cell is already past the limit. Distances <= limit are identical to
|
|
232
|
+
// `levenshtein`'s — test/util.test.mjs checks that against it directly.
|
|
233
|
+
export function levenshteinWithin(a, b, limit) {
|
|
234
|
+
if (Math.abs(a.length - b.length) > limit) return null;
|
|
235
|
+
if (a.length === 0) return b.length;
|
|
236
|
+
if (b.length === 0) return a.length;
|
|
237
|
+
const over = limit + 1;
|
|
238
|
+
let prev = new Int32Array(a.length + 2).fill(over);
|
|
239
|
+
let cur = new Int32Array(a.length + 2).fill(over);
|
|
240
|
+
for (let j = 0; j <= Math.min(a.length, limit); j++) prev[j] = j;
|
|
241
|
+
for (let i = 1; i <= b.length; i++) {
|
|
242
|
+
const lo = Math.max(1, i - limit);
|
|
243
|
+
const hi = Math.min(a.length, i + limit);
|
|
244
|
+
// Reset the cells this row will write plus the two the next row reads as
|
|
245
|
+
// band neighbours, so nothing stale survives the row swap.
|
|
246
|
+
cur.fill(over, Math.max(0, lo - 1), hi + 2);
|
|
247
|
+
cur[0] = i <= limit ? i : over;
|
|
248
|
+
let rowMin = over;
|
|
249
|
+
for (let j = lo; j <= hi; j++) {
|
|
250
|
+
const substitution = prev[j - 1] + (b.charCodeAt(i - 1) === a.charCodeAt(j - 1) ? 0 : 1);
|
|
251
|
+
const deletion = prev[j] + 1;
|
|
252
|
+
const insertion = cur[j - 1] + 1;
|
|
253
|
+
let best = substitution < deletion ? substitution : deletion;
|
|
254
|
+
if (insertion < best) best = insertion;
|
|
255
|
+
if (best > limit) best = over;
|
|
256
|
+
cur[j] = best;
|
|
257
|
+
if (best < rowMin) rowMin = best;
|
|
258
|
+
}
|
|
259
|
+
if (rowMin > limit) return null;
|
|
260
|
+
const swap = prev; prev = cur; cur = swap;
|
|
261
|
+
}
|
|
262
|
+
const distance = prev[a.length];
|
|
263
|
+
return distance <= limit ? distance : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
224
266
|
// Top-N candidates from a list, ranked for "did you mean" hints. Substring
|
|
225
267
|
// match wins (cheap and intent-revealing for typos that share a prefix or
|
|
226
268
|
// stem); Levenshtein distance ≤3 catches transpositions and small edits.
|
|
@@ -246,8 +288,12 @@ export function suggestCandidates(query, candidates, max = 3) {
|
|
|
246
288
|
if (!cand || scored.has(cand)) continue;
|
|
247
289
|
const candLower = String(cand).toLowerCase();
|
|
248
290
|
if (candLower === lower) continue;
|
|
249
|
-
|
|
250
|
-
|
|
291
|
+
// The candidate list here is every basename AND every repo path in the
|
|
292
|
+
// index, so on a large corpus this loop ran hundreds of thousands of
|
|
293
|
+
// distance computations per unresolved reference. Only "within 3 edits"
|
|
294
|
+
// matters, so it asks for exactly that.
|
|
295
|
+
const dist = levenshteinWithin(lower, candLower, 3);
|
|
296
|
+
if (dist !== null) scored.set(cand, 1000 + dist);
|
|
251
297
|
}
|
|
252
298
|
}
|
|
253
299
|
|
package/src/validate.mjs
CHANGED
|
@@ -338,14 +338,25 @@ function candidatePathsForType(docs, type) {
|
|
|
338
338
|
// can't see siblings). Filters candidates by ref-field type when the field
|
|
339
339
|
// name implies one (e.g. `related_plans` → plans only).
|
|
340
340
|
export function enrichRefErrorSuggestions(docs, config) {
|
|
341
|
+
// One candidate list per inferred type, not per broken ref. The list is a
|
|
342
|
+
// filter plus a ~2-per-doc Set build over the whole index, and it does not
|
|
343
|
+
// depend on the entry — rebuilding it for each entry made this pass quadratic
|
|
344
|
+
// in (docs × broken refs), which on a 4.8k-doc corpus with a couple hundred
|
|
345
|
+
// unresolved refs was ~9s of every `check`, `briefing` and `plans` run.
|
|
346
|
+
const candidateCache = new Map();
|
|
347
|
+
const candidatesFor = (type) => {
|
|
348
|
+
const key = type ?? '';
|
|
349
|
+
if (!candidateCache.has(key)) candidateCache.set(key, candidatePathsForType(docs, type));
|
|
350
|
+
return candidateCache.get(key);
|
|
351
|
+
};
|
|
352
|
+
|
|
341
353
|
const enrich = (entry) => {
|
|
342
354
|
if (!entry?.meta || !['ref-resolution', 'body-link-resolution'].includes(entry.meta.kind)) return;
|
|
343
355
|
if (entry.meta.kind === 'body-link-resolution'
|
|
344
356
|
&& (entry.meta.targetKind !== 'document' || entry.meta.reason !== 'missing')) return;
|
|
345
357
|
if (entry._suggested) return;
|
|
346
358
|
const inferred = inferRefFieldType(entry.meta.field);
|
|
347
|
-
const
|
|
348
|
-
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidates);
|
|
359
|
+
const suggestions = suggestCandidates(path.basename(entry.meta.relPath), candidatesFor(inferred));
|
|
349
360
|
entry._suggested = true;
|
|
350
361
|
if (suggestions.length === 0) return;
|
|
351
362
|
entry.message = `${entry.message} Did you mean: ${suggestions.join(', ')}?`;
|