runlist 0.0.0 → 0.94.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/util.mjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dim } from './color.mjs';
|
|
7
|
+
|
|
8
|
+
// The one list of environment variables that can name the session runlist is
|
|
9
|
+
// running inside. It lives here, in a leaf module, because both consumers must
|
|
10
|
+
// read the same list: `currentSessionId` below (journal attribution, which falls
|
|
11
|
+
// back to the shell) and `authoritativeSessionId` in pickup.mjs (plan ownership,
|
|
12
|
+
// which fails closed). Two hand-maintained copies is how the OpenCode entries
|
|
13
|
+
// drifted into naming variables OpenCode does not set.
|
|
14
|
+
//
|
|
15
|
+
// Order is most-specific-first. `OPENCODE_PID` is a real fallback, not a guess:
|
|
16
|
+
// OpenCode's CLI middleware sets it on the process every tool shell inherits,
|
|
17
|
+
// and it is per-OpenCode-process rather than per-session, so it sits below the
|
|
18
|
+
// session-scoped names above it and above `TERM_SESSION_ID` — a terminal id is
|
|
19
|
+
// shared by every agent run in that window and outlives all of them.
|
|
20
|
+
// `scope: 'session'` means the variable names one agent session; `'process'`
|
|
21
|
+
// and `'terminal'` are coarser — several sessions can share one, so they cannot
|
|
22
|
+
// tell two of them apart. `runlist doctor --session` reports that distinction, and
|
|
23
|
+
// it is the whole reason `runlist install opencode` exists.
|
|
24
|
+
const SESSION_ID_SOURCES = [
|
|
25
|
+
{ variable: 'RUNLIST_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
|
|
26
|
+
{ variable: 'DOTMD_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
|
|
27
|
+
{ variable: 'CLAUDE_CODE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
|
|
28
|
+
{ variable: 'CLAUDE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
|
|
29
|
+
// Verified against codex-cli 0.153.4 (2026-09-07): `codex exec` ran
|
|
30
|
+
// `env | grep ^CODEX` in a tool shell and printed both variables, each equal
|
|
31
|
+
// to the "session id:" line Codex prints in its own header, so they name one
|
|
32
|
+
// Codex thread (session), not the Codex process. THREAD_ID is what the exec
|
|
33
|
+
// path (core/src/exec.rs) sets first; SESSION_ID is its older twin. No
|
|
34
|
+
// CODEX_PID exists, so a Codex claim records no owning process and reads as
|
|
35
|
+
// 'unverifiable' — a takeover stays an explicit --force.
|
|
36
|
+
{ variable: 'CODEX_THREAD_ID', prefix: null, scope: 'session', host: 'Codex' },
|
|
37
|
+
{ variable: 'CODEX_SESSION_ID', prefix: null, scope: 'session', host: 'Codex' },
|
|
38
|
+
{ variable: 'OPENCODE_SESSION_ID', prefix: null, scope: 'session', host: 'OpenCode' },
|
|
39
|
+
{ variable: 'OPENCODE_SESSION', prefix: null, scope: 'session', host: 'OpenCode' },
|
|
40
|
+
{ variable: 'OPENCODE_PID', prefix: 'opencode', scope: 'process', host: 'OpenCode' },
|
|
41
|
+
{ variable: 'TERM_SESSION_ID', prefix: 'term', scope: 'terminal', host: 'terminal' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Which source named the session, with the id it produced — or null when the
|
|
45
|
+
// environment names none.
|
|
46
|
+
export function hostSessionSource(env = process.env) {
|
|
47
|
+
for (const source of SESSION_ID_SOURCES) {
|
|
48
|
+
const value = env[source.variable]?.trim();
|
|
49
|
+
if (!value) continue;
|
|
50
|
+
return { ...source, id: source.prefix ? `${source.prefix}:${value}` : value };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The session id the environment names, or null when it names none.
|
|
56
|
+
export function hostSessionId(env = process.env) {
|
|
57
|
+
return hostSessionSource(env)?.id ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Stable identifier for the current shell/agent session. Used for journal
|
|
61
|
+
// attribution and hint de-duplication — not for any plan locking.
|
|
62
|
+
export function currentSessionId() {
|
|
63
|
+
return hostSessionId() ?? `shell:${os.userInfo().username}@${os.hostname()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The running CLI's version, read once and memoized — several surfaces compare
|
|
67
|
+
// it against what a host integration was generated from.
|
|
68
|
+
let cachedVersion;
|
|
69
|
+
export function runlistVersion() {
|
|
70
|
+
if (cachedVersion === undefined) {
|
|
71
|
+
try {
|
|
72
|
+
const pkgPath = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'package.json');
|
|
73
|
+
cachedVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
cachedVersion = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return cachedVersion;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Public compatibility alias for callers of older builds.
|
|
82
|
+
export const dotmdVersion = runlistVersion;
|
|
83
|
+
|
|
84
|
+
// Is `bin` runnable from PATH? Used to decide whether runlist can drive a host's
|
|
85
|
+
// own CLI or must print the in-session commands for the user to run instead.
|
|
86
|
+
export function which(bin) {
|
|
87
|
+
try {
|
|
88
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
89
|
+
return spawnSync(cmd, [bin], { encoding: 'utf8' }).status === 0;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Windows resolves `foo` to `foo.cmd` only through a shell; spawn needs the
|
|
96
|
+
// real name.
|
|
97
|
+
export function executableName(bin) {
|
|
98
|
+
return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function escapeTable(value) {
|
|
102
|
+
return String(value).replace(/\|/g, '\\|');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function asString(value) {
|
|
106
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function capitalize(value) {
|
|
110
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function toSlug(plan) {
|
|
114
|
+
return path.basename(plan.path, '.md');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function truncate(text, max) {
|
|
118
|
+
if (text.length <= max) return text;
|
|
119
|
+
return text.slice(0, max - 3) + '...';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeStringList(value) {
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
return value.map(item => String(item).trim()).filter(Boolean);
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === 'string' && value.trim()) {
|
|
127
|
+
return [value.trim()];
|
|
128
|
+
}
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function normalizeBlockers(blockers) {
|
|
133
|
+
if (Array.isArray(blockers)) {
|
|
134
|
+
return blockers.map(item => String(item));
|
|
135
|
+
}
|
|
136
|
+
if (typeof blockers === 'string' && blockers.trim()) {
|
|
137
|
+
return [blockers.trim()];
|
|
138
|
+
}
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function mergeUniqueStrings(...lists) {
|
|
143
|
+
return [...new Set(lists.flat().filter(Boolean))];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function escapeRegex(str) {
|
|
147
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function toRepoPath(absolutePath, repoRoot) {
|
|
151
|
+
return path.relative(repoRoot, absolutePath).split(path.sep).join('/');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// True when any path segment equals `config.archiveDir`. Covers both
|
|
155
|
+
// `docs/plans/archived/foo.md` and `docs/prompts/archived/foo.md` regardless
|
|
156
|
+
// of whether the layout is single-root or flat-array. Read-side commands use
|
|
157
|
+
// this to skip files whose frontmatter `status:` has drifted out of sync with
|
|
158
|
+
// their archive location (issue #13).
|
|
159
|
+
export function isArchivedPath(repoPath, config) {
|
|
160
|
+
if (!repoPath || !config?.archiveDir) return false;
|
|
161
|
+
return repoPath.split('/').includes(config.archiveDir);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Emit a `files: a b c` line to stderr listing every doc / index path
|
|
165
|
+
// the command touched (deduped, sorted, repo-relative). Lets agents do
|
|
166
|
+
// `git add` with the exact set instead of guessing. Opt-in via
|
|
167
|
+
// `--show-files` on lifecycle commands; default off to keep output stable.
|
|
168
|
+
export function emitFilesFooter(paths, config) {
|
|
169
|
+
const rel = [...new Set(paths.filter(Boolean))]
|
|
170
|
+
.map(p => path.isAbsolute(p) ? toRepoPath(p, config.repoRoot) : p)
|
|
171
|
+
.sort();
|
|
172
|
+
if (rel.length === 0) return;
|
|
173
|
+
process.stderr.write(`files: ${rel.join(' ')}\n`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function nowIso() {
|
|
177
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Coarse "how long ago", for messages where the exact interval matters less than
|
|
181
|
+
// which order of magnitude it is — a claim held 3d is a dead session, one held
|
|
182
|
+
// 4m is a colleague mid-edit. Lives here rather than in either caller because
|
|
183
|
+
// `hud` and `pickup` both need it and util is a leaf both already import.
|
|
184
|
+
export function relTime(ts, now = Date.now()) {
|
|
185
|
+
const t = new Date(ts).getTime();
|
|
186
|
+
if (!Number.isFinite(t)) return '?';
|
|
187
|
+
const delta = Math.max(0, now - t);
|
|
188
|
+
const sec = Math.floor(delta / 1000);
|
|
189
|
+
if (sec < 60) return `${sec}s ago`;
|
|
190
|
+
const min = Math.floor(sec / 60);
|
|
191
|
+
if (min < 60) return `${min}m ago`;
|
|
192
|
+
const hr = Math.floor(min / 60);
|
|
193
|
+
if (hr < 24) return `${hr}h ago`;
|
|
194
|
+
return `${Math.floor(hr / 24)}d ago`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function warn(message) {
|
|
198
|
+
process.stderr.write(`${dim(message)}\n`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export class DotmdError extends Error {
|
|
202
|
+
constructor(message) {
|
|
203
|
+
super(message);
|
|
204
|
+
this.name = 'DotmdError';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function die(message) {
|
|
209
|
+
throw new DotmdError(message);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function levenshtein(a, b) {
|
|
213
|
+
if (a.length === 0) return b.length;
|
|
214
|
+
if (b.length === 0) return a.length;
|
|
215
|
+
const matrix = [];
|
|
216
|
+
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
|
|
217
|
+
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
|
|
218
|
+
for (let i = 1; i <= b.length; i++) {
|
|
219
|
+
for (let j = 1; j <= a.length; j++) {
|
|
220
|
+
const cost = b[i - 1] === a[j - 1] ? 0 : 1;
|
|
221
|
+
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return matrix[b.length][a.length];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Edit distance, but only as far as the caller cares. Returns the distance when
|
|
228
|
+
// it is <= limit and null when it provably exceeds it. The suggester asks
|
|
229
|
+
// "within 3 edits?" of every candidate in the index, and the full matrix
|
|
230
|
+
// computes an exact answer it then throws away: ~900 cells and two allocations
|
|
231
|
+
// per pair, several hundred thousand pairs per unresolved reference on a large
|
|
232
|
+
// corpus. Only cells within `limit` of the diagonal can hold a value <= limit,
|
|
233
|
+
// so this walks that band in two flat rows and abandons a row whose cheapest
|
|
234
|
+
// cell is already past the limit. Distances <= limit are identical to
|
|
235
|
+
// `levenshtein`'s — test/util.test.mjs checks that against it directly.
|
|
236
|
+
export function levenshteinWithin(a, b, limit) {
|
|
237
|
+
if (Math.abs(a.length - b.length) > limit) return null;
|
|
238
|
+
if (a.length === 0) return b.length;
|
|
239
|
+
if (b.length === 0) return a.length;
|
|
240
|
+
const over = limit + 1;
|
|
241
|
+
let prev = new Int32Array(a.length + 2).fill(over);
|
|
242
|
+
let cur = new Int32Array(a.length + 2).fill(over);
|
|
243
|
+
for (let j = 0; j <= Math.min(a.length, limit); j++) prev[j] = j;
|
|
244
|
+
for (let i = 1; i <= b.length; i++) {
|
|
245
|
+
const lo = Math.max(1, i - limit);
|
|
246
|
+
const hi = Math.min(a.length, i + limit);
|
|
247
|
+
// Reset the cells this row will write plus the two the next row reads as
|
|
248
|
+
// band neighbours, so nothing stale survives the row swap.
|
|
249
|
+
cur.fill(over, Math.max(0, lo - 1), hi + 2);
|
|
250
|
+
cur[0] = i <= limit ? i : over;
|
|
251
|
+
let rowMin = over;
|
|
252
|
+
for (let j = lo; j <= hi; j++) {
|
|
253
|
+
const substitution = prev[j - 1] + (b.charCodeAt(i - 1) === a.charCodeAt(j - 1) ? 0 : 1);
|
|
254
|
+
const deletion = prev[j] + 1;
|
|
255
|
+
const insertion = cur[j - 1] + 1;
|
|
256
|
+
let best = substitution < deletion ? substitution : deletion;
|
|
257
|
+
if (insertion < best) best = insertion;
|
|
258
|
+
if (best > limit) best = over;
|
|
259
|
+
cur[j] = best;
|
|
260
|
+
if (best < rowMin) rowMin = best;
|
|
261
|
+
}
|
|
262
|
+
if (rowMin > limit) return null;
|
|
263
|
+
const swap = prev; prev = cur; cur = swap;
|
|
264
|
+
}
|
|
265
|
+
const distance = prev[a.length];
|
|
266
|
+
return distance <= limit ? distance : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Top-N candidates from a list, ranked for "did you mean" hints. Substring
|
|
270
|
+
// match wins (cheap and intent-revealing for typos that share a prefix or
|
|
271
|
+
// stem); Levenshtein distance ≤3 catches transpositions and small edits.
|
|
272
|
+
// Results are deduped and capped. Empty input or empty candidate list returns
|
|
273
|
+
// an empty array — callers should suppress the hint line in that case.
|
|
274
|
+
export function suggestCandidates(query, candidates, max = 3) {
|
|
275
|
+
if (!query || !candidates?.length) return [];
|
|
276
|
+
const lower = String(query).toLowerCase();
|
|
277
|
+
const scored = new Map();
|
|
278
|
+
|
|
279
|
+
for (const cand of candidates) {
|
|
280
|
+
if (!cand) continue;
|
|
281
|
+
const candLower = String(cand).toLowerCase();
|
|
282
|
+
if (candLower === lower) continue;
|
|
283
|
+
if (candLower.includes(lower) || lower.includes(candLower)) {
|
|
284
|
+
// Substring hits sort first; shorter candidates rank higher within that bucket.
|
|
285
|
+
scored.set(cand, Math.min(scored.get(cand) ?? Infinity, candLower.length));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (scored.size < max) {
|
|
290
|
+
for (const cand of candidates) {
|
|
291
|
+
if (!cand || scored.has(cand)) continue;
|
|
292
|
+
const candLower = String(cand).toLowerCase();
|
|
293
|
+
if (candLower === lower) continue;
|
|
294
|
+
// The candidate list here is every basename AND every repo path in the
|
|
295
|
+
// index, so on a large corpus this loop ran hundreds of thousands of
|
|
296
|
+
// distance computations per unresolved reference. Only "within 3 edits"
|
|
297
|
+
// matters, so it asks for exactly that.
|
|
298
|
+
const dist = levenshteinWithin(lower, candLower, 3);
|
|
299
|
+
if (dist !== null) scored.set(cand, 1000 + dist);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return [...scored.entries()]
|
|
304
|
+
.sort((a, b) => a[1] - b[1])
|
|
305
|
+
.slice(0, max)
|
|
306
|
+
.map(([cand]) => cand);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function resolveDocPath(input, config) {
|
|
310
|
+
if (!input) return null;
|
|
311
|
+
if (path.isAbsolute(input)) return existsSync(input) ? input : null;
|
|
312
|
+
|
|
313
|
+
let candidate = path.resolve(config.repoRoot, input);
|
|
314
|
+
if (existsSync(candidate)) return candidate;
|
|
315
|
+
|
|
316
|
+
const roots = config.docsRoots || [config.docsRoot];
|
|
317
|
+
for (const root of roots) {
|
|
318
|
+
candidate = path.resolve(root, input);
|
|
319
|
+
if (existsSync(candidate)) return candidate;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Resolve a reference path written in frontmatter or a body link.
|
|
326
|
+
// Tries doc-relative first (the historical convention), then falls back to
|
|
327
|
+
// repo-root-relative — so paths like `docs/foo/bar.md` written from any nesting
|
|
328
|
+
// level resolve correctly. Returns the absolute path if either form exists,
|
|
329
|
+
// else null.
|
|
330
|
+
export function resolveRefPath(relPath, docDir, repoRoot) {
|
|
331
|
+
if (!relPath) return null;
|
|
332
|
+
const docRelative = path.resolve(docDir, relPath);
|
|
333
|
+
if (existsSync(docRelative)) return docRelative;
|
|
334
|
+
const repoRelative = path.resolve(repoRoot, relPath);
|
|
335
|
+
if (existsSync(repoRelative)) return repoRelative;
|
|
336
|
+
return null;
|
|
337
|
+
}
|