@catheadowl/ukp 0.1.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/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,1040 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, relative, resolve, win32 } from "node:path";
|
|
4
|
+
import { loadManifest } from "../config/manifest.ts";
|
|
5
|
+
import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
|
|
6
|
+
import { resolveScope } from "../scope.ts";
|
|
7
|
+
import { buildQmdInvocation, defaultQmdCommand, isBareDocidReference, providerTimeoutMs, stripDocidHash, stripQmdHeader, toQmdGetArgument } from "./qmd.ts";
|
|
8
|
+
import { isValidPin, pinFromSourceDocument, recoverRenamedResource, type RecoveryCandidate } from "./rename-recovery.ts";
|
|
9
|
+
import { isInsideRealRoot, splitEndpointRelativeSegments } from "../path-safety.ts";
|
|
10
|
+
|
|
11
|
+
export interface ReadRequest {
|
|
12
|
+
/** Undefined only for an absolute filesystem reference, which the
|
|
13
|
+
* capability maps against the Registry before any read (ADR-URI-001
|
|
14
|
+
* tolerant tier); every other tier requires it. */
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
path: string;
|
|
17
|
+
lines?: LineRange;
|
|
18
|
+
/** "uri" = exact slot addressing (ukp:// input, ADR 0014): the path must
|
|
19
|
+
* resolve exactly; no fuzzy fallback and no provider delegation on miss.
|
|
20
|
+
* "absolute" = Registry-mapped absolute path (ADR-URI-001): same exact
|
|
21
|
+
* intent — a miss fails fast with resource-missing and never enters the
|
|
22
|
+
* filesystem candidate scan (which on large endpoints is pathologically
|
|
23
|
+
* slow; BB-006 Run 2 evidence: 118s no-output on a miss). */
|
|
24
|
+
addressing?: "uri" | "absolute";
|
|
25
|
+
/** Document-relative context (ADR-URI-001 tolerant tier): the
|
|
26
|
+
* endpoint-relative route of the source document the reference was copied
|
|
27
|
+
* from. `path` is then resolved against that document's directory. */
|
|
28
|
+
fromRef?: string;
|
|
29
|
+
/** Consumer-pinned content hash (`sha256-<64 hex>`, ADR 0020 / spec
|
|
30
|
+
* read-rename-recovery): the ukp-pin verification key for the miss-path
|
|
31
|
+
* recovery descent. Absent = stale-unknown verification. */
|
|
32
|
+
pin?: string;
|
|
33
|
+
/** The reference exactly as the caller wrote it before tolerant-tier
|
|
34
|
+
* rewriting — used to locate the same-line ukp-pin annotation in the
|
|
35
|
+
* --from source document (Q1 spelling). */
|
|
36
|
+
sourceReference?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LineRange {
|
|
40
|
+
start: number;
|
|
41
|
+
count?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReadContext {
|
|
45
|
+
currentDirectory: string;
|
|
46
|
+
registryPath: string;
|
|
47
|
+
qmdCommand?: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Tolerant-addressing resolution provenance (ADR-URI-001): the factual echo
|
|
51
|
+
* of the request rewrite. Structured so a non-CLI surface can hand the
|
|
52
|
+
* consumer back the canonical endpoint+route instead of parsing stderr. */
|
|
53
|
+
export interface ReadResolution {
|
|
54
|
+
kind: "absolute-mapped" | "doc-relative";
|
|
55
|
+
/** Factual echo body without the `ukp read:` prefix. */
|
|
56
|
+
note: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Rename-recovery metadata (ADR 0020 / spec read-rename-recovery). `from`
|
|
60
|
+
* carries the original route so the success echo is renderable from data. */
|
|
61
|
+
export interface ReadRecoveryMeta {
|
|
62
|
+
from: string;
|
|
63
|
+
attempted: string[];
|
|
64
|
+
outcome: "recovered" | "exhausted";
|
|
65
|
+
recoveredTo?: string;
|
|
66
|
+
layer?: string;
|
|
67
|
+
verification?: string;
|
|
68
|
+
candidates?: RecoveryCandidate[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** ADR 0021 error classification — lifted from the word-level taxonomy that
|
|
72
|
+
* already lived in the stderr wording (`provider-unavailable:`,
|
|
73
|
+
* `resource-missing:`, `provider-timeout`, ...). Exit codes are adapter
|
|
74
|
+
* renderings of these classes. */
|
|
75
|
+
export type ReadErrorClass =
|
|
76
|
+
| "no-endpoint"
|
|
77
|
+
| "endpoint-name-mismatch"
|
|
78
|
+
| "provider-unavailable"
|
|
79
|
+
| "provider-timeout"
|
|
80
|
+
| "provider-cancelled"
|
|
81
|
+
| "provider-incompatible"
|
|
82
|
+
| "provider-no-content"
|
|
83
|
+
| "resource-missing"
|
|
84
|
+
| "qmd-route-unavailable"
|
|
85
|
+
| "resource-disappeared"
|
|
86
|
+
| "resource-is-directory"
|
|
87
|
+
| "start-beyond-eof"
|
|
88
|
+
| "ambiguous-match"
|
|
89
|
+
| "no-exact-match"
|
|
90
|
+
| "resource-not-found"
|
|
91
|
+
/** Inline tier-validation failures (containment escape, lexical path
|
|
92
|
+
* violations re-checked at execution): exit 2 with a bare message, unlike
|
|
93
|
+
* parse-layer usage errors which render the usage block. */
|
|
94
|
+
| "usage-error";
|
|
95
|
+
|
|
96
|
+
export interface ReadFailure {
|
|
97
|
+
errorClass: ReadErrorClass;
|
|
98
|
+
/** Factual message body — no `ukp read:` prefix (the adapter adds it once,
|
|
99
|
+
* on the first line). May be multi-line; embedded recovery-hint wording is
|
|
100
|
+
* known debt of the same class as search warnings (envelope/byte
|
|
101
|
+
* compatibility first). */
|
|
102
|
+
message: string;
|
|
103
|
+
recovery?: ReadRecoveryMeta;
|
|
104
|
+
resolution?: ReadResolution;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** ADR 0021 payload-type success: the resource body IS the contract (stdout
|
|
108
|
+
* purity); echoes, recovery metadata, and warnings are sideband fields. */
|
|
109
|
+
export interface ReadSuccess {
|
|
110
|
+
content: string;
|
|
111
|
+
recovery?: ReadRecoveryMeta;
|
|
112
|
+
/** Recovery-descent warnings (factual lines; adapter prefixes each). */
|
|
113
|
+
recoveryWarnings: string[];
|
|
114
|
+
resolution?: ReadResolution;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type ReadOutcome =
|
|
118
|
+
| { ok: true; result: ReadSuccess }
|
|
119
|
+
| { ok: false; failure: ReadFailure };
|
|
120
|
+
|
|
121
|
+
export class ReadUsageError extends Error {
|
|
122
|
+
constructor(message: string) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.name = "ReadUsageError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readFailure(
|
|
129
|
+
errorClass: ReadErrorClass,
|
|
130
|
+
message: string,
|
|
131
|
+
recovery?: ReadRecoveryMeta,
|
|
132
|
+
): ReadOutcome {
|
|
133
|
+
return { ok: false, failure: recovery ? { errorClass, message, recovery } : { errorClass, message } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** provider-unavailable classification for "no qmd executable at all" — the
|
|
137
|
+
* read channel is absent, which is the same recovery class as a spawn
|
|
138
|
+
* failure (install/verify qmd), never resource-missing. */
|
|
139
|
+
function providerUnavailableNoExecutable(endpointName: string): ReadOutcome {
|
|
140
|
+
return readFailure(
|
|
141
|
+
"provider-unavailable",
|
|
142
|
+
`provider-unavailable: the QMD read channel for endpoint '${endpointName}' has no usable qmd executable.\n`
|
|
143
|
+
+ `Browse the endpoint with 'ukp nav --endpoint ${endpointName}' or install qmd, then retry.`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateEndpointRelativePath(reference: string): string[] {
|
|
148
|
+
if (reference.length === 0) throw new ReadUsageError("reference must be a non-empty endpoint-scoped reference");
|
|
149
|
+
// Path-safety primitive (ADR 0023): verdicts shared, error wording local.
|
|
150
|
+
const result = splitEndpointRelativeSegments(reference);
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
if (result.reason === "absolute") {
|
|
153
|
+
throw new ReadUsageError("reference must be endpoint-scoped, not absolute");
|
|
154
|
+
}
|
|
155
|
+
if (result.reason === "empty-segment") {
|
|
156
|
+
throw new ReadUsageError("reference must not contain empty path segments");
|
|
157
|
+
}
|
|
158
|
+
throw new ReadUsageError("reference must not contain '.' or '..' path segments");
|
|
159
|
+
}
|
|
160
|
+
return result.segments;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveEndpointPath(serviceFolder: string, reference: string): string {
|
|
164
|
+
const segments = validateEndpointRelativePath(reference);
|
|
165
|
+
const targetPath = resolve(join(serviceFolder, ...segments));
|
|
166
|
+
// Single containment check via realpath: catches both lexical and symlink escapes
|
|
167
|
+
const serviceReal = realpathSync(serviceFolder);
|
|
168
|
+
const targetReal = realpathSync(targetPath);
|
|
169
|
+
if (!isInsideRealRoot(serviceReal, targetReal)) {
|
|
170
|
+
throw new ReadUsageError("reference must stay inside the selected Service folder when resolved as a file");
|
|
171
|
+
}
|
|
172
|
+
return targetReal;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Tolerant-tier absolute-shape detection (ADR-URI-001): an input that is an
|
|
176
|
+
* absolute filesystem path (any of the shapes the plain tier rejects) is
|
|
177
|
+
* mapped against the Registry instead of being a usage error. */
|
|
178
|
+
export function isAbsoluteFilesystemReference(reference: string): boolean {
|
|
179
|
+
// `C:foo` (no separator) is drive-relative, not absolute — it stays on the
|
|
180
|
+
// plain tier, where validateEndpointRelativePath rejects the `X:` shape.
|
|
181
|
+
return (
|
|
182
|
+
isAbsolute(reference)
|
|
183
|
+
|| win32.isAbsolute(reference)
|
|
184
|
+
|| /^[A-Za-z]:[\\/]/.test(reference)
|
|
185
|
+
|| reference.startsWith("//")
|
|
186
|
+
|| reference.startsWith("\\\\")
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve a document-relative reference (ADR-URI-001 tolerant tier): the
|
|
192
|
+
* reference as it appears inside a source document (`../x.md`, `./x.md`,
|
|
193
|
+
* bare `x.md`), resolved against `fromRef`'s directory with `.`/`..`
|
|
194
|
+
* segment normalization. Pure string work — no filesystem access, no fuzzy
|
|
195
|
+
* search; escaping the endpoint root is a usage error, not a containment
|
|
196
|
+
* miss (the caller learns the boundary before any file is touched).
|
|
197
|
+
*/
|
|
198
|
+
function resolveDocRelativeReference(fromRef: string, reference: string): string {
|
|
199
|
+
const base = validateEndpointRelativePath(fromRef).slice(0, -1);
|
|
200
|
+
const segments = [...base];
|
|
201
|
+
for (const segment of reference.split(/[\\/]/)) {
|
|
202
|
+
if (segment.length === 0 || segment === ".") continue;
|
|
203
|
+
if (segment === "..") {
|
|
204
|
+
if (segments.length === 0) {
|
|
205
|
+
throw new ReadUsageError(
|
|
206
|
+
`'${reference}' (from '${fromRef}') resolves outside the endpoint`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
segments.pop();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
segments.push(segment);
|
|
213
|
+
}
|
|
214
|
+
if (segments.length === 0) {
|
|
215
|
+
throw new ReadUsageError(
|
|
216
|
+
`'${reference}' (from '${fromRef}') resolves to the endpoint root, not a resource`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return segments.join("/");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Map an absolute filesystem reference to { endpoint, route } by matching it
|
|
224
|
+
* against every registered endpoint's Service folder (ADR-URI-001 tolerant
|
|
225
|
+
* tier; the agent-facing equivalent of `gh pr view` accepting a pasted URL).
|
|
226
|
+
* Containment uses the same resolved-realpath stance as reads; a target that
|
|
227
|
+
* does not exist yet falls back to a lexical check against the real Service
|
|
228
|
+
* root. Ambiguity (zero or multiple endpoints) is a usage error, never a
|
|
229
|
+
* silent first-match: the mapping must be reproducible.
|
|
230
|
+
*/
|
|
231
|
+
function resolveAbsoluteReference(
|
|
232
|
+
reference: string,
|
|
233
|
+
registry: readonly RegistryBinding[],
|
|
234
|
+
): { endpoint: string; route: string } {
|
|
235
|
+
const matches: { endpoint: string; route: string }[] = [];
|
|
236
|
+
for (const binding of registry) {
|
|
237
|
+
if (isRemoteBinding(binding) || binding.path === undefined) continue;
|
|
238
|
+
let service: ReturnType<typeof loadManifest>;
|
|
239
|
+
try {
|
|
240
|
+
service = loadManifest(binding.path);
|
|
241
|
+
} catch {
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (service.effectiveName !== binding.name) continue;
|
|
245
|
+
let serviceReal: string;
|
|
246
|
+
try {
|
|
247
|
+
serviceReal = realpathSync(service.folder);
|
|
248
|
+
} catch {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
let target: string;
|
|
252
|
+
try {
|
|
253
|
+
target = realpathSync(reference);
|
|
254
|
+
} catch {
|
|
255
|
+
target = resolve(reference);
|
|
256
|
+
}
|
|
257
|
+
if (!isInsideRealRoot(serviceReal, target)) continue;
|
|
258
|
+
matches.push({
|
|
259
|
+
endpoint: binding.name,
|
|
260
|
+
route: relative(serviceReal, target).replace(/\\/g, "/"),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
if (matches.length === 0) {
|
|
264
|
+
const names = registry.map((binding) => binding.name).join(", ");
|
|
265
|
+
throw new ReadUsageError(
|
|
266
|
+
`absolute reference '${reference}' matches no registered endpoint`
|
|
267
|
+
+ (names.length > 0 ? ` (registered: ${names})` : " (no endpoints registered)"),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (matches.length > 1) {
|
|
271
|
+
const list = matches.map((match) => `${match.endpoint} (${match.route})`).join(", ");
|
|
272
|
+
throw new ReadUsageError(
|
|
273
|
+
`absolute reference '${reference}' matches multiple endpoints: ${list}; use 'ukp read --endpoint <name> <route>' instead`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
return matches[0];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizeFilename(name: string): string {
|
|
280
|
+
// Strip extension (only if there's a leading character before the dot) and normalize hyphens/underscores
|
|
281
|
+
// Dotfiles like .env keep their name (dotIndex === 0 means no leading char)
|
|
282
|
+
const dotIndex = name.lastIndexOf(".");
|
|
283
|
+
const withoutExt = dotIndex > 0 ? name.slice(0, dotIndex) : name;
|
|
284
|
+
return withoutExt.toLowerCase().replace(/[-_]/g, "");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Budget for the human-shorthand candidate scan. The scan is advisory only
|
|
288
|
+
* (Did-you-mean), so it is bounded: beyond this many files scanned it stops
|
|
289
|
+
* with whatever it has collected. The walk itself is cheap — the per-file
|
|
290
|
+
* name filter below rejects non-matching entries before ANY filesystem
|
|
291
|
+
* syscall (realpathSync per file on a large endpoint was the BB-006 Run 2
|
|
292
|
+
* pathology: 118s on a miss), so this budget is a safety net, not the fix.
|
|
293
|
+
* `UKP_CANDIDATE_SCAN_FILE_BUDGET` overrides (min 1) so the trigger branch
|
|
294
|
+
* is testable without materializing 20k files. */
|
|
295
|
+
function candidateScanFileBudget(): number {
|
|
296
|
+
const raw = process.env.UKP_CANDIDATE_SCAN_FILE_BUDGET;
|
|
297
|
+
if (raw !== undefined && /^\d+$/.test(raw) && Number(raw) >= 1) return Number(raw);
|
|
298
|
+
return 20_000;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function findFilesBySuffix(serviceFolder: string, suffix: string): {
|
|
302
|
+
serviceReal: string;
|
|
303
|
+
suffixMatches: string[];
|
|
304
|
+
nameFuzzyMatches: string[];
|
|
305
|
+
} {
|
|
306
|
+
const serviceReal = realpathSync(serviceFolder);
|
|
307
|
+
const suffixMatches = new Set<string>();
|
|
308
|
+
const nameFuzzyMatches = new Set<string>();
|
|
309
|
+
const visitedDirs = new Set<string>();
|
|
310
|
+
const normalizedSuffix = suffix.replace(/\\/g, "/");
|
|
311
|
+
const suffixSegments = normalizedSuffix.split("/");
|
|
312
|
+
const targetFileName = suffixSegments[suffixSegments.length - 1];
|
|
313
|
+
const normalizedTargetName = normalizeFilename(targetFileName);
|
|
314
|
+
// For fuzzy matching, also normalize the path prefix (all segments except the last)
|
|
315
|
+
const pathPrefix = suffixSegments.slice(0, -1).join("/");
|
|
316
|
+
const normalizedPathPrefix = pathPrefix.replace(/\\/g, "/").toLowerCase().replace(/[-_]/g, "");
|
|
317
|
+
let filesScanned = 0;
|
|
318
|
+
|
|
319
|
+
function scan(currentPath: string): void {
|
|
320
|
+
if (filesScanned > candidateScanFileBudget()) return;
|
|
321
|
+
let entries;
|
|
322
|
+
try {
|
|
323
|
+
entries = readdirSync(currentPath, { withFileTypes: true });
|
|
324
|
+
} catch {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
for (const entry of entries) {
|
|
329
|
+
const fullPath = join(currentPath, entry.name);
|
|
330
|
+
let canonicalPath: string;
|
|
331
|
+
let isSymlink = false;
|
|
332
|
+
|
|
333
|
+
if (entry.isSymbolicLink()) {
|
|
334
|
+
isSymlink = true;
|
|
335
|
+
try {
|
|
336
|
+
canonicalPath = realpathSync(fullPath);
|
|
337
|
+
if (!isInsideRealRoot(serviceReal, canonicalPath)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
continue; // Broken symlink
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
canonicalPath = fullPath;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Determine actual type (symlinks need statSync since Dirent methods return false for symlinks)
|
|
348
|
+
let isDir: boolean;
|
|
349
|
+
let isFile: boolean;
|
|
350
|
+
if (isSymlink) {
|
|
351
|
+
try {
|
|
352
|
+
const stat = statSync(canonicalPath);
|
|
353
|
+
isDir = stat.isDirectory();
|
|
354
|
+
isFile = stat.isFile();
|
|
355
|
+
} catch {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
isDir = entry.isDirectory();
|
|
360
|
+
isFile = entry.isFile();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (isDir) {
|
|
364
|
+
const dirReal = isSymlink ? canonicalPath : realpathSync(canonicalPath);
|
|
365
|
+
if (visitedDirs.has(dirReal)) continue;
|
|
366
|
+
visitedDirs.add(dirReal);
|
|
367
|
+
scan(canonicalPath);
|
|
368
|
+
} else if (isFile) {
|
|
369
|
+
filesScanned += 1;
|
|
370
|
+
// Cheap name filter BEFORE any per-file syscall: both match checks
|
|
371
|
+
// below are gated on the entry name (exact or normalized), so a
|
|
372
|
+
// non-matching name can skip realpathSync/relative entirely. On a
|
|
373
|
+
// large endpoint this is the difference between a cheap readdir walk
|
|
374
|
+
// and a per-file realpath storm (BB-006 Run 2: 118s miss).
|
|
375
|
+
const nameExact = entry.name === targetFileName;
|
|
376
|
+
if (!nameExact && normalizeFilename(entry.name) !== normalizedTargetName) {
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
const fileReal = isSymlink ? canonicalPath : realpathSync(canonicalPath);
|
|
380
|
+
const relPath = relative(serviceReal, fileReal).replace(/\\/g, "/");
|
|
381
|
+
|
|
382
|
+
// Check 1: Exact suffix match (filename exact, path fuzzy)
|
|
383
|
+
if (nameExact) {
|
|
384
|
+
if (relPath === normalizedSuffix || relPath.endsWith("/" + normalizedSuffix)) {
|
|
385
|
+
if (isInsideRealRoot(serviceReal, fileReal)) {
|
|
386
|
+
suffixMatches.add(fileReal);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Check 2: Name fuzzy match (filename fuzzy with -/_ normalization, ignoring extension)
|
|
393
|
+
// Also check path prefix if the user specified one
|
|
394
|
+
if (normalizeFilename(entry.name) === normalizedTargetName) {
|
|
395
|
+
// If user specified a path prefix (e.g., "subdir/file.md"), verify it matches
|
|
396
|
+
if (normalizedPathPrefix) {
|
|
397
|
+
const fileDir = relative(serviceReal, isSymlink ? canonicalPath : realpathSync(join(canonicalPath, "..")))
|
|
398
|
+
.replace(/\\/g, "/")
|
|
399
|
+
.toLowerCase()
|
|
400
|
+
.replace(/[-_]/g, "");
|
|
401
|
+
// Check if the file's directory path ends with the normalized prefix
|
|
402
|
+
if (fileDir !== normalizedPathPrefix && !fileDir.endsWith("/" + normalizedPathPrefix)) {
|
|
403
|
+
continue; // Path prefix doesn't match, skip
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (isInsideRealRoot(serviceReal, fileReal)) {
|
|
407
|
+
nameFuzzyMatches.add(fileReal);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
visitedDirs.add(serviceReal);
|
|
415
|
+
scan(serviceReal);
|
|
416
|
+
return {
|
|
417
|
+
serviceReal,
|
|
418
|
+
suffixMatches: Array.from(suffixMatches),
|
|
419
|
+
nameFuzzyMatches: Array.from(nameFuzzyMatches),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
type LineRangeResult =
|
|
424
|
+
| { kind: "ok"; content: string }
|
|
425
|
+
| { kind: "start-beyond-eof"; start: number; lineCount: number };
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Slice `content` to the requested 1-based `--lines` window.
|
|
429
|
+
*
|
|
430
|
+
* Two out-of-range shapes are distinguished so the caller can enforce the
|
|
431
|
+
* read/file exit contract, which must match read/qmd (where an empty provider
|
|
432
|
+
* body already fails with exit 1):
|
|
433
|
+
* - `start` past the last line → `start-beyond-eof`; the caller errors (exit 1)
|
|
434
|
+
* and never surfaces a silently-empty success read.
|
|
435
|
+
* - `start` valid but the window runs past the end → content is truncated to
|
|
436
|
+
* the available lines and remains `ok` (exit 0). `--lines` is a best-effort
|
|
437
|
+
* reading hint and never selects another resource.
|
|
438
|
+
*/
|
|
439
|
+
function applyLineRange(content: string, range: LineRange | undefined): LineRangeResult {
|
|
440
|
+
if (!range) return { kind: "ok", content };
|
|
441
|
+
// An empty file has zero lines, so any start is beyond the end.
|
|
442
|
+
if (content.length === 0) return { kind: "start-beyond-eof", start: range.start, lineCount: 0 };
|
|
443
|
+
const lines = content.split(/\r?\n/);
|
|
444
|
+
if (content.endsWith("\n") || content.endsWith("\r\n")) lines.pop();
|
|
445
|
+
const lineCount = lines.length;
|
|
446
|
+
const startIndex = range.start - 1;
|
|
447
|
+
if (startIndex >= lineCount) {
|
|
448
|
+
return { kind: "start-beyond-eof", start: range.start, lineCount };
|
|
449
|
+
}
|
|
450
|
+
const selected = lines.slice(startIndex, range.count === undefined ? undefined : startIndex + range.count);
|
|
451
|
+
return { kind: "ok", content: `${selected.join("\n")}\n` };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Read an explicit provider-tier reference (bare docid handoff key, ADR 0011,
|
|
456
|
+
* or a `qmd://` provider reference, ADR 0008) through the QMD-backed get
|
|
457
|
+
* adapter. Never entered from a plain-path miss (ADR 0017: shape-based
|
|
458
|
+
* dispatch). The adapter re-adds the `#` to a bare docid so QMD resolves it
|
|
459
|
+
* by content fingerprint, strips the provider header so stdout starts at the
|
|
460
|
+
* body, and keeps UKP's exit/error discipline — failures classify as
|
|
461
|
+
* provider-unavailable (spawn) or resource-missing (provider ran, no
|
|
462
|
+
* resolution) without leaking QMD internals as traces.
|
|
463
|
+
*/
|
|
464
|
+
function readViaQmd(
|
|
465
|
+
qmdCommand: readonly string[],
|
|
466
|
+
serviceFolder: string,
|
|
467
|
+
request: ReadRequest,
|
|
468
|
+
endpointName: string,
|
|
469
|
+
): ReadOutcome {
|
|
470
|
+
const providerArgs = [
|
|
471
|
+
"get",
|
|
472
|
+
toQmdGetArgument(request.path, request.lines),
|
|
473
|
+
"--no-line-numbers",
|
|
474
|
+
];
|
|
475
|
+
const invocation = buildQmdInvocation(qmdCommand, providerArgs);
|
|
476
|
+
const result = spawnSync(invocation.file, invocation.args, {
|
|
477
|
+
cwd: serviceFolder,
|
|
478
|
+
encoding: "utf8",
|
|
479
|
+
windowsHide: true,
|
|
480
|
+
windowsVerbatimArguments: invocation.verbatim,
|
|
481
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
482
|
+
timeout: providerTimeoutMs(),
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
if (result.signal === "SIGTERM") {
|
|
486
|
+
// Zero-output hang audit: a provider that never responds must land in a
|
|
487
|
+
// classified failure, not silence. spawnSync kills with SIGTERM on timeout.
|
|
488
|
+
return readFailure(
|
|
489
|
+
"provider-timeout",
|
|
490
|
+
`provider-timeout: the QMD read channel for endpoint '${endpointName}' did not respond within ${providerTimeoutMs() / 1000}s.\n`
|
|
491
|
+
+ `Verify the qmd installation or set UKP_PROVIDER_TIMEOUT_MS, then retry.`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (result.error) {
|
|
496
|
+
// provider-unavailable, not resource-missing: the read channel itself
|
|
497
|
+
// could not start (spawn failure — qmd missing, or an unstartable shim
|
|
498
|
+
// form). The two classes drive completely different recovery actions, so
|
|
499
|
+
// the wording must classify and point at recovery paths.
|
|
500
|
+
const detail = result.error instanceof Error ? result.error.message : String(result.error);
|
|
501
|
+
return readFailure(
|
|
502
|
+
"provider-unavailable",
|
|
503
|
+
`provider-unavailable: the QMD read channel for endpoint '${endpointName}' could not start (${detail}).\n`
|
|
504
|
+
+ `Browse the endpoint with 'ukp nav --endpoint ${endpointName}' or verify the qmd installation, then retry.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (result.signal === "SIGINT" || result.status === 130) {
|
|
508
|
+
return readFailure("provider-cancelled", "provider cancelled");
|
|
509
|
+
}
|
|
510
|
+
if (result.status !== 0) {
|
|
511
|
+
// Provider stderr detail keeps at most the first non-empty line — a
|
|
512
|
+
// multi-line crash dump must not flood the surface (a provider
|
|
513
|
+
// malfunction still lands in this branch; the classification stays
|
|
514
|
+
// resource-missing-shaped, which is a declared limitation of a one-line
|
|
515
|
+
// provider detail).
|
|
516
|
+
const rawProviderError = (result.stderr ?? "").trim();
|
|
517
|
+
if (
|
|
518
|
+
rawProviderError.includes("no-line-numbers")
|
|
519
|
+
|| rawProviderError.includes("unknown option")
|
|
520
|
+
|| rawProviderError.includes("unknown flag")
|
|
521
|
+
) {
|
|
522
|
+
return readFailure(
|
|
523
|
+
"provider-incompatible",
|
|
524
|
+
"qmd build does not support '--no-line-numbers'; provider incompatible",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const providerError = rawProviderError
|
|
528
|
+
.split(/\r?\n/)
|
|
529
|
+
.map((line) => line.trim())
|
|
530
|
+
.find((line) => line.length > 0);
|
|
531
|
+
return readFailure(
|
|
532
|
+
"resource-missing",
|
|
533
|
+
`resource-missing: '${request.path}' could not be resolved by the provider in endpoint '${endpointName}'`
|
|
534
|
+
+ (providerError ? `\n(provider: ${providerError})` : ""),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const body = stripQmdHeader(result.stdout ?? "");
|
|
539
|
+
if (body.length === 0) {
|
|
540
|
+
return readFailure(
|
|
541
|
+
"provider-no-content",
|
|
542
|
+
`provider returned no content for '${request.path}' in endpoint '${endpointName}'`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return { ok: true, result: { content: body, recoveryWarnings: [] } };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Miss-path rename recovery (ADR 0020 / spec read-rename-recovery): run the
|
|
550
|
+
* layered descent for a slot miss and, on recovery, read the proposed route
|
|
551
|
+
* with the same line-window contract. Returns either the recovered read
|
|
552
|
+
* (echo + warnings prepended on stderr, `recovery` metadata attached) or a
|
|
553
|
+
* marker the miss site merges into its existing resource-missing wording
|
|
554
|
+
* (plus advisory candidates). Provider shapes never enter here (spec:
|
|
555
|
+
* recovery is a file-slot concern); the L2 search layer runs only when the
|
|
556
|
+
* endpoint declares a qmd search capability, and its absence is silent.
|
|
557
|
+
*/
|
|
558
|
+
function attemptRenameRecovery(
|
|
559
|
+
request: ReadRequest,
|
|
560
|
+
endpointName: string,
|
|
561
|
+
serviceFolder: string,
|
|
562
|
+
qmdCommand: readonly string[] | undefined,
|
|
563
|
+
pin: string | undefined,
|
|
564
|
+
): { kind: "recovered"; outcome: ReadOutcome } | { kind: "exhausted"; recovery: ReadRecoveryMeta } {
|
|
565
|
+
const outcome = recoverRenamedResource({
|
|
566
|
+
route: request.path,
|
|
567
|
+
endpointName,
|
|
568
|
+
serviceFolder,
|
|
569
|
+
...(pin !== undefined ? { pin } : {}),
|
|
570
|
+
...(qmdCommand !== undefined ? { qmdCommand } : {}),
|
|
571
|
+
});
|
|
572
|
+
const recovery: ReadRecoveryMeta = {
|
|
573
|
+
from: request.path,
|
|
574
|
+
attempted: outcome.attempted,
|
|
575
|
+
outcome: outcome.status,
|
|
576
|
+
...(outcome.recoveredRoute !== undefined ? { recoveredTo: outcome.recoveredRoute } : {}),
|
|
577
|
+
...(outcome.layer !== undefined ? { layer: outcome.layer } : {}),
|
|
578
|
+
...(outcome.verification !== undefined ? { verification: outcome.verification } : {}),
|
|
579
|
+
...(outcome.candidates.length > 0 ? { candidates: outcome.candidates } : {}),
|
|
580
|
+
};
|
|
581
|
+
if (outcome.status === "recovered" && outcome.recoveredRoute !== undefined) {
|
|
582
|
+
try {
|
|
583
|
+
const targetPath = resolveEndpointPath(serviceFolder, outcome.recoveredRoute);
|
|
584
|
+
const read = readTargetWithLines(targetPath, { ...request, path: outcome.recoveredRoute });
|
|
585
|
+
if (read.ok) {
|
|
586
|
+
// Success sideband: echo + warnings render from structured fields.
|
|
587
|
+
return {
|
|
588
|
+
kind: "recovered",
|
|
589
|
+
outcome: { ok: true, result: { ...read.result, recovery, recoveryWarnings: outcome.warnings } },
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
// The proposed route failed its own read (e.g. start-beyond-eof): the
|
|
593
|
+
// recovery found the resource but the read contract still governs —
|
|
594
|
+
// surface that failure rather than hiding behind resource-missing.
|
|
595
|
+
return { kind: "recovered", outcome: { ...read, failure: { ...read.failure, recovery } } };
|
|
596
|
+
} catch {
|
|
597
|
+
// Proposed route vanished or escaped containment mid-recovery: fall
|
|
598
|
+
// through to exhaustion (recorded below with the attempted layers).
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return { kind: "exhausted", recovery };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Advisory recovery candidates appended to a resource-missing miss (spec
|
|
605
|
+
* read-rename-recovery): pin-rejected and search-recalled routes the consumer
|
|
606
|
+
* may confirm manually. Empty input renders nothing. */
|
|
607
|
+
function formatRecoveryCandidates(candidates: readonly RecoveryCandidate[] | undefined): string {
|
|
608
|
+
if (!candidates || candidates.length === 0) return "";
|
|
609
|
+
const lines = candidates.map((candidate) => ` - ${candidate.path} (recovery candidate, ${candidate.verified})`);
|
|
610
|
+
return `Rename recovery candidates:\n${lines.join("\n")}\n`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function readTargetWithLines(targetPath: string, request: ReadRequest): ReadOutcome {
|
|
614
|
+
let content: string;
|
|
615
|
+
try {
|
|
616
|
+
content = readFileSync(targetPath, "utf8");
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
619
|
+
return readFailure("resource-disappeared", "resource disappeared during lookup");
|
|
620
|
+
}
|
|
621
|
+
// A directory tail resolves as a path but is not a readable resource; the
|
|
622
|
+
// failure must state that in operator-facing terms instead of leaking the Node
|
|
623
|
+
// errno (`EISDIR: illegal operation on a directory, read`) as the surface.
|
|
624
|
+
if (error instanceof Error && "code" in error && error.code === "EISDIR") {
|
|
625
|
+
return readFailure(
|
|
626
|
+
"resource-is-directory",
|
|
627
|
+
`'${request.path}' is a directory, not a readable resource`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
throw error;
|
|
631
|
+
}
|
|
632
|
+
const rangeResult = applyLineRange(content, request.lines);
|
|
633
|
+
if (rangeResult.kind === "start-beyond-eof") {
|
|
634
|
+
// Word by input origin: a URI #L<line> fragment never mentions --lines.
|
|
635
|
+
const origin = request.addressing === "uri" ? "line window start" : "--lines start";
|
|
636
|
+
return readFailure(
|
|
637
|
+
"start-beyond-eof",
|
|
638
|
+
`${origin} ${rangeResult.start} is beyond the end of '${request.path}' (${rangeResult.lineCount} lines)`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return { ok: true, result: { content: rangeResult.content, recoveryWarnings: [] } };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** ADR 0021 core entry: runs the read capability and returns the structured
|
|
645
|
+
* outcome. Parse-layer usage errors and scope/manifest failures still throw
|
|
646
|
+
* typed errors for the surface adapter to map; execution-tier usage
|
|
647
|
+
* violations classify as `usage-error` failures (exit-2 class). */
|
|
648
|
+
export function runRead(request: ReadRequest, context: ReadContext): ReadOutcome {
|
|
649
|
+
// A docid[:line] handoff key already carries an embedded line; a separate
|
|
650
|
+
// --lines range would double-specify (ADR 0011 / read-qmd-adapter). Strip any
|
|
651
|
+
// leading `#` first so a hash-prefixed `#docid:line` is caught too, even
|
|
652
|
+
// though `#` never appears on the UKP surface.
|
|
653
|
+
const barePath = stripDocidHash(request.path);
|
|
654
|
+
if (request.lines && isBareDocidReference(barePath) && barePath.includes(":")) {
|
|
655
|
+
throw new ReadUsageError(
|
|
656
|
+
"a docid[:line] reference already carries a line; do not also pass --lines",
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Tolerant-addressing pre-pass (ADR-URI-001): the caller's address encoding
|
|
661
|
+
// is UKP's job, never a shape the agent must pre-normalize. Two
|
|
662
|
+
// explicit tiers — an absolute filesystem path mapped against the Registry
|
|
663
|
+
// (endpoint inferred), and a document-relative reference resolved against
|
|
664
|
+
// --from. Both rewrite the request to the canonical endpoint+route and
|
|
665
|
+
// record the mapping as structured resolution provenance (echoed on stderr
|
|
666
|
+
// by the adapter; stdout stays body-only); both fail loud as usage
|
|
667
|
+
// errors, never silently delegating (the weak-reference lesson, ADR 0017).
|
|
668
|
+
const registry = readRegistry(context.registryPath);
|
|
669
|
+
let resolution: ReadResolution | undefined;
|
|
670
|
+
if (request.addressing !== "uri" && isAbsoluteFilesystemReference(request.path)) {
|
|
671
|
+
if (request.fromRef !== undefined) {
|
|
672
|
+
throw new ReadUsageError("an absolute filesystem path cannot be combined with --from");
|
|
673
|
+
}
|
|
674
|
+
if (request.endpoint !== undefined) {
|
|
675
|
+
throw new ReadUsageError(
|
|
676
|
+
"an absolute filesystem path carries its own endpoint (matched against registered endpoints); do not also pass --endpoint",
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
const mapped = resolveAbsoluteReference(request.path, registry);
|
|
680
|
+
request = { ...request, endpoint: mapped.endpoint, path: mapped.route, addressing: "absolute" };
|
|
681
|
+
resolution = {
|
|
682
|
+
kind: "absolute-mapped",
|
|
683
|
+
note: `absolute path matched endpoint '${mapped.endpoint}', route '${mapped.route}'`,
|
|
684
|
+
};
|
|
685
|
+
} else if (request.fromRef !== undefined) {
|
|
686
|
+
if (request.endpoint === undefined) {
|
|
687
|
+
throw new ReadUsageError("--from requires --endpoint <name>");
|
|
688
|
+
}
|
|
689
|
+
if (request.path.startsWith("qmd://") || isBareDocidReference(barePath)) {
|
|
690
|
+
throw new ReadUsageError("--from applies to document-relative path references, not provider references");
|
|
691
|
+
}
|
|
692
|
+
const resolved = resolveDocRelativeReference(request.fromRef, request.path);
|
|
693
|
+
resolution = {
|
|
694
|
+
kind: "doc-relative",
|
|
695
|
+
note: `resolved '${request.path}' from '${request.fromRef}' -> '${resolved}'`,
|
|
696
|
+
};
|
|
697
|
+
// Keep the verbatim reference for same-line ukp-pin extraction (Q1).
|
|
698
|
+
request = { ...request, path: resolved, sourceReference: request.path };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const outcome = executeResolvedRead(request, context, registry);
|
|
702
|
+
if (resolution === undefined) return outcome;
|
|
703
|
+
return outcome.ok
|
|
704
|
+
? { ok: true, result: { ...outcome.result, resolution } }
|
|
705
|
+
: { ok: false, failure: { ...outcome.failure, resolution } };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function executeResolvedRead(
|
|
709
|
+
request: ReadRequest,
|
|
710
|
+
context: ReadContext,
|
|
711
|
+
registry: ReturnType<typeof readRegistry>,
|
|
712
|
+
): ReadOutcome {
|
|
713
|
+
if (request.endpoint === undefined) {
|
|
714
|
+
return readFailure("no-endpoint", "no endpoint selected");
|
|
715
|
+
}
|
|
716
|
+
const scope = resolveScope({
|
|
717
|
+
currentDirectory: context.currentDirectory,
|
|
718
|
+
registry,
|
|
719
|
+
explicitEndpoints: [request.endpoint],
|
|
720
|
+
global: false,
|
|
721
|
+
});
|
|
722
|
+
const [binding] = scope.bindings;
|
|
723
|
+
if (!binding) {
|
|
724
|
+
return readFailure("no-endpoint", "no endpoint selected");
|
|
725
|
+
}
|
|
726
|
+
if (isRemoteBinding(binding)) {
|
|
727
|
+
// Defensive: the read adapter routes remote endpoints through the
|
|
728
|
+
// remote transport before runRead is reached.
|
|
729
|
+
return readFailure(
|
|
730
|
+
"provider-incompatible",
|
|
731
|
+
`endpoint '${binding.name}' is remote (${binding.url}); remote reads route through the remote transport`,
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const service = loadManifest(localPathOf(binding));
|
|
736
|
+
if (service.effectiveName !== binding.name) {
|
|
737
|
+
return readFailure(
|
|
738
|
+
"endpoint-name-mismatch",
|
|
739
|
+
`endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ukp-pin from the --from source document (Q1 same-line spelling): the
|
|
744
|
+
// consumer's verification key travels with the reference, not the command.
|
|
745
|
+
let pin = request.pin;
|
|
746
|
+
if (pin === undefined && request.fromRef !== undefined && request.sourceReference !== undefined) {
|
|
747
|
+
pin = pinFromSourceDocument(request.fromRef, request.sourceReference, service.folder);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// QMD-backed route is derived from the declared search provider; there is no
|
|
751
|
+
// explicit get capability in the current Manifest. ADR 0017: QMD visibility
|
|
752
|
+
// governs only the explicit provider tiers (bare docid, qmd://), never the
|
|
753
|
+
// file-native plain-path tier.
|
|
754
|
+
const qmdBacked = service.manifest.capabilities.search?.provider === "qmd";
|
|
755
|
+
const qmdCommand = qmdBacked
|
|
756
|
+
? context.qmdCommand ?? defaultQmdCommand()
|
|
757
|
+
: undefined;
|
|
758
|
+
|
|
759
|
+
// ukp:// URI input (ADR 0014): exact slot addressing. The rel-path must
|
|
760
|
+
// resolve exactly — no filesystem fuzzy fallback and no QMD weak-reference
|
|
761
|
+
// delegation on a miss, and this tier routes BEFORE the qmd:// provider
|
|
762
|
+
// reference check: a rel-path that happens to start with `qmd://` (e.g.
|
|
763
|
+
// `ukp://ep/qmd://abc`) is a literal URI path segment sequence, never a
|
|
764
|
+
// provider-owned reference — the URI's determinism promise outranks
|
|
765
|
+
// provider delegation everywhere. Failures classify per the three-valued
|
|
766
|
+
// taxonomy (dangling-endpoint is raised earlier by scope resolution; here:
|
|
767
|
+
// resource-missing). Containment is the same resolved-realpath check as
|
|
768
|
+
// the explicit file baseline (G5: resolved containment stance).
|
|
769
|
+
if (request.addressing === "uri") {
|
|
770
|
+
let targetPath: string;
|
|
771
|
+
try {
|
|
772
|
+
targetPath = resolveEndpointPath(service.folder, request.path);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
if (error instanceof ReadUsageError) {
|
|
775
|
+
return readFailure("usage-error", error.message);
|
|
776
|
+
}
|
|
777
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
778
|
+
// ADR 0020 miss-path recovery: the URI slot missed, so descend the
|
|
779
|
+
// layered stack (git-derived -> search re-anchor) before classifying.
|
|
780
|
+
const recovered = attemptRenameRecovery(request, binding.name, service.folder, qmdCommand, pin);
|
|
781
|
+
if (recovered.kind === "recovered") return recovered.outcome;
|
|
782
|
+
return readFailure(
|
|
783
|
+
"resource-missing",
|
|
784
|
+
`resource-missing '${request.path}' in endpoint '${binding.name}' (ukp:// addresses a slot exactly; no fuzzy resolution)\n`
|
|
785
|
+
+ formatRecoveryCandidates(recovered.recovery.candidates).trimEnd(),
|
|
786
|
+
recovered.recovery,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
return readTargetWithLines(targetPath, request);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// ukp:// provider reference (non-URI input only; the URI tier above already
|
|
795
|
+
// returned): route before endpoint-local path validation, so the `://`
|
|
796
|
+
// empty segment is never misread as a file-path usage error.
|
|
797
|
+
if (request.path.startsWith("qmd://")) {
|
|
798
|
+
if (!qmdBacked) {
|
|
799
|
+
return readFailure(
|
|
800
|
+
"qmd-route-unavailable",
|
|
801
|
+
`qmd:// references require a QMD-backed endpoint; endpoint '${binding.name}' has no QMD get route`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
if (!qmdCommand) {
|
|
805
|
+
return providerUnavailableNoExecutable(binding.name);
|
|
806
|
+
}
|
|
807
|
+
return readViaQmd(qmdCommand, service.folder, request, binding.name);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Bare docid[:line] handoff key (ADR 0011) — an explicit provider shape
|
|
811
|
+
// (ADR 0017: shape-based dispatch). It routes to the provider before any
|
|
812
|
+
// filesystem resolution: the fingerprint carries search-handoff intent, and
|
|
813
|
+
// a Service-folder file that happens to be named like a docid must not
|
|
814
|
+
// shadow it. (Recomputed here: the tolerant pre-pass may have rewritten
|
|
815
|
+
// request.path, but a rewritten path is always a plain route, never a docid.)
|
|
816
|
+
const barePath = stripDocidHash(request.path);
|
|
817
|
+
if (isBareDocidReference(barePath)) {
|
|
818
|
+
if (!qmdBacked) {
|
|
819
|
+
return readFailure(
|
|
820
|
+
"qmd-route-unavailable",
|
|
821
|
+
`a docid[:line] reference requires a QMD-backed endpoint; endpoint '${binding.name}' has no QMD get route`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
if (!qmdCommand) {
|
|
825
|
+
return providerUnavailableNoExecutable(binding.name);
|
|
826
|
+
}
|
|
827
|
+
return readViaQmd(qmdCommand, service.folder, request, binding.name);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
let targetPath: string;
|
|
831
|
+
try {
|
|
832
|
+
targetPath = resolveEndpointPath(service.folder, request.path);
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (error instanceof ReadUsageError) {
|
|
835
|
+
return readFailure("usage-error", error.message);
|
|
836
|
+
}
|
|
837
|
+
// realpathSync throws ENOENT if path doesn't exist
|
|
838
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
839
|
+
// Absolute-tier miss (ADR-URI-001): the input carried exact intent (a
|
|
840
|
+
// Registry-mapped absolute path), so there is no human-shorthand to
|
|
841
|
+
// recover from — fail fast with resource-missing and never enter the
|
|
842
|
+
// candidate scan (BB-006 Run 2 evidence: scanning a large external
|
|
843
|
+
// endpoint on such a miss took 118s with zero output).
|
|
844
|
+
if (request.addressing === "absolute") {
|
|
845
|
+
const recovered = attemptRenameRecovery(request, binding.name, service.folder, qmdCommand, pin);
|
|
846
|
+
if (recovered.kind === "recovered") return recovered.outcome;
|
|
847
|
+
return readFailure(
|
|
848
|
+
"resource-missing",
|
|
849
|
+
`resource-missing '${request.path}' in endpoint '${binding.name}'`
|
|
850
|
+
+ ` (absolute paths address files exactly; the mapped route does not exist — the target may live one level deeper, e.g. under src/)\n`
|
|
851
|
+
+ formatRecoveryCandidates(recovered.recovery.candidates).trimEnd(),
|
|
852
|
+
recovered.recovery,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
// ADR 0017: read is file-native — a plain-path miss is resource-missing
|
|
856
|
+
// on every endpoint; the provider is never entered from a miss. On a
|
|
857
|
+
// QMD-backed endpoint the filesystem scan is advisory only (candidate
|
|
858
|
+
// list for human shorthand recovery); it never resolves the read, so
|
|
859
|
+
// provider collection/ignore visibility is never bypassed by a silent
|
|
860
|
+
// hit. Pure file-backed endpoints keep the file layer's
|
|
861
|
+
// own suffix/fuzzy resolution, whose visibility root is the Service
|
|
862
|
+
// folder itself.
|
|
863
|
+
if (qmdBacked) {
|
|
864
|
+
const recovered = attemptRenameRecovery(request, binding.name, service.folder, qmdCommand, pin);
|
|
865
|
+
if (recovered.kind === "recovered") return recovered.outcome;
|
|
866
|
+
const { serviceReal, suffixMatches, nameFuzzyMatches } = findFilesBySuffix(service.folder, request.path);
|
|
867
|
+
const candidates = [...suffixMatches, ...nameFuzzyMatches]
|
|
868
|
+
.map((m) => ` - ${relative(serviceReal, m).replace(/\\/g, "/")}`);
|
|
869
|
+
// Shape-tailored miss wording (BB-006 evidence): never mention docid/
|
|
870
|
+
// qmd:// here — a file-surface consumer misread that parenthetical as
|
|
871
|
+
// a hint and detoured into a docid miss. Discovery pointers only.
|
|
872
|
+
const shapeHint = request.path.includes("/")
|
|
873
|
+
? `Browse the endpoint with 'ukp nav --endpoint ${binding.name}'`
|
|
874
|
+
: `For a document-relative reference use '--from <route>', or browse with 'ukp nav --endpoint ${binding.name}'`;
|
|
875
|
+
return readFailure(
|
|
876
|
+
"resource-missing",
|
|
877
|
+
`resource-missing '${request.path}' in endpoint '${binding.name}' (plain paths and ukp:// URIs address files exactly; no fuzzy resolution)\n`
|
|
878
|
+
+ `${shapeHint}.`
|
|
879
|
+
+ (candidates.length > 0
|
|
880
|
+
? `\nDid you mean:\n${candidates.join("\n")}`
|
|
881
|
+
: "")
|
|
882
|
+
+ (recovered.recovery.candidates && recovered.recovery.candidates.length > 0
|
|
883
|
+
? `\n${formatRecoveryCandidates(recovered.recovery.candidates).trimEnd()}`
|
|
884
|
+
: ""),
|
|
885
|
+
recovered.recovery,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
// Pure file-backed: filesystem fuzzy fallback (visibility root is the
|
|
889
|
+
// Service folder itself).
|
|
890
|
+
const { serviceReal, suffixMatches, nameFuzzyMatches } = findFilesBySuffix(service.folder, request.path);
|
|
891
|
+
|
|
892
|
+
// Suffix match (filename exact): single match → return directly, multiple → list candidates
|
|
893
|
+
if (suffixMatches.length === 1) {
|
|
894
|
+
targetPath = suffixMatches[0];
|
|
895
|
+
} else if (suffixMatches.length > 1) {
|
|
896
|
+
const matchList = suffixMatches
|
|
897
|
+
.map((m) => ` - ${relative(serviceReal, m).replace(/\\/g, "/")}`)
|
|
898
|
+
.join("\n");
|
|
899
|
+
return readFailure(
|
|
900
|
+
"ambiguous-match",
|
|
901
|
+
`multiple resources match '${request.path}' in endpoint '${binding.name}':\n${matchList}\nUse a more specific path.`,
|
|
902
|
+
);
|
|
903
|
+
} else if (nameFuzzyMatches.length > 0) {
|
|
904
|
+
// Name fuzzy match (filename fuzzy): always show candidates
|
|
905
|
+
const matchList = nameFuzzyMatches
|
|
906
|
+
.map((m) => ` - ${relative(serviceReal, m).replace(/\\/g, "/")}`)
|
|
907
|
+
.join("\n");
|
|
908
|
+
return readFailure(
|
|
909
|
+
"no-exact-match",
|
|
910
|
+
`no exact match for '${request.path}' in endpoint '${binding.name}'.\nDid you mean:\n${matchList}`,
|
|
911
|
+
);
|
|
912
|
+
} else {
|
|
913
|
+
const recovered = attemptRenameRecovery(request, binding.name, service.folder, undefined, pin);
|
|
914
|
+
if (recovered.kind === "recovered") return recovered.outcome;
|
|
915
|
+
return readFailure(
|
|
916
|
+
"resource-not-found",
|
|
917
|
+
`resource '${request.path}' was not found in endpoint '${binding.name}'`
|
|
918
|
+
+ (recovered.recovery.candidates && recovered.recovery.candidates.length > 0
|
|
919
|
+
? `\n${formatRecoveryCandidates(recovered.recovery.candidates).trimEnd()}`
|
|
920
|
+
: ""),
|
|
921
|
+
recovered.recovery,
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
throw error;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
return readTargetWithLines(targetPath, request);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ---------------------------------------------------------------------------
|
|
933
|
+
// Presentation (ADR 0021 two-stage form). read is a payload-type capability:
|
|
934
|
+
// stdout is the resource body itself; every echo/warning/diagnostic is a
|
|
935
|
+
// sideband rendered onto stderr by the shared render below. Adapters must
|
|
936
|
+
// consume these — never re-parse rendered text (the old --format json path
|
|
937
|
+
// regex-extracted the error class out of stderr; the class is data now).
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
/** Success sideband lines: resolution echo, recovery echo, then recovery
|
|
941
|
+
* warnings — composed from structured fields, `ukp read:` prefixes added
|
|
942
|
+
* here (CLI wording, not capability data). */
|
|
943
|
+
function renderReadSideband(result: ReadSuccess): string {
|
|
944
|
+
const parts: string[] = [];
|
|
945
|
+
if (result.resolution !== undefined) {
|
|
946
|
+
parts.push(`ukp read: ${result.resolution.note}`);
|
|
947
|
+
}
|
|
948
|
+
if (result.recovery?.outcome === "recovered" && result.recovery.recoveredTo !== undefined) {
|
|
949
|
+
const layerLabel = result.recovery.layer === "search-reanchor" ? "search re-anchor" : "git history";
|
|
950
|
+
parts.push(`ukp read: recovered: '${result.recovery.from}' moved to '${result.recovery.recoveredTo}' (${layerLabel})`);
|
|
951
|
+
}
|
|
952
|
+
for (const warning of result.recoveryWarnings) {
|
|
953
|
+
parts.push(`ukp read: warning: ${warning}`);
|
|
954
|
+
}
|
|
955
|
+
return parts.map((part) => `${part}\n`).join("");
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Human rendering of an outcome, in surface-neutral terms: `body` is the
|
|
959
|
+
* payload (the CLI prints it on stdout), `diagnostics` is the sideband
|
|
960
|
+
* (echoes/warnings/failure message — the CLI prints it on stderr). Channel
|
|
961
|
+
* assignment belongs to adapters, so the presentation view stays
|
|
962
|
+
* surface-neutral. */
|
|
963
|
+
export interface ReadHumanView {
|
|
964
|
+
body: string;
|
|
965
|
+
diagnostics: string;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
export function renderReadHuman(outcome: ReadOutcome): ReadHumanView {
|
|
969
|
+
if (outcome.ok) {
|
|
970
|
+
return { body: outcome.result.content, diagnostics: renderReadSideband(outcome.result) };
|
|
971
|
+
}
|
|
972
|
+
const failure = outcome.failure;
|
|
973
|
+
const prefix = failure.resolution !== undefined ? `ukp read: ${failure.resolution.note}\n` : "";
|
|
974
|
+
const message = failure.message.endsWith("\n") ? failure.message : `${failure.message}\n`;
|
|
975
|
+
return { body: "", diagnostics: `${prefix}ukp read: ${message}` };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** `--format json` envelope (spec read-rename-recovery). Failure class is
|
|
979
|
+
* data (`failure.errorClass`); only the three word-level classes that the
|
|
980
|
+
* published envelope vocabulary defines surface as-is, `usage-error` keeps
|
|
981
|
+
* its name, everything else maps to `error` — identical to the envelope the
|
|
982
|
+
* text-regex extraction produced. */
|
|
983
|
+
export interface ReadEnvelope {
|
|
984
|
+
ok: boolean;
|
|
985
|
+
endpoint?: string;
|
|
986
|
+
reference: string;
|
|
987
|
+
recovered_to?: string;
|
|
988
|
+
error?: {
|
|
989
|
+
class: string;
|
|
990
|
+
message: string;
|
|
991
|
+
recovery?: {
|
|
992
|
+
attempted: string[];
|
|
993
|
+
outcome: string;
|
|
994
|
+
recovered_to?: string;
|
|
995
|
+
candidates?: RecoveryCandidate[];
|
|
996
|
+
};
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
export function projectReadEnvelope(request: ReadRequest, outcome: ReadOutcome): ReadEnvelope {
|
|
1001
|
+
if (outcome.ok) {
|
|
1002
|
+
return {
|
|
1003
|
+
ok: true,
|
|
1004
|
+
...(request.endpoint !== undefined ? { endpoint: request.endpoint } : {}),
|
|
1005
|
+
reference: request.path,
|
|
1006
|
+
...(outcome.result.recovery?.recoveredTo !== undefined
|
|
1007
|
+
? { recovered_to: outcome.result.recovery.recoveredTo }
|
|
1008
|
+
: {}),
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
const failure = outcome.failure;
|
|
1012
|
+
const wordLevel = failure.errorClass === "resource-missing"
|
|
1013
|
+
|| failure.errorClass === "provider-unavailable"
|
|
1014
|
+
|| failure.errorClass === "provider-timeout";
|
|
1015
|
+
const errorClass = failure.errorClass === "usage-error"
|
|
1016
|
+
? "usage-error"
|
|
1017
|
+
: wordLevel
|
|
1018
|
+
? failure.errorClass
|
|
1019
|
+
: "error";
|
|
1020
|
+
const firstLine = failure.message.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
|
|
1021
|
+
return {
|
|
1022
|
+
ok: false,
|
|
1023
|
+
...(request.endpoint !== undefined ? { endpoint: request.endpoint } : {}),
|
|
1024
|
+
reference: request.path,
|
|
1025
|
+
error: {
|
|
1026
|
+
class: errorClass,
|
|
1027
|
+
message: firstLine,
|
|
1028
|
+
...(failure.recovery !== undefined
|
|
1029
|
+
? {
|
|
1030
|
+
recovery: {
|
|
1031
|
+
attempted: failure.recovery.attempted,
|
|
1032
|
+
outcome: failure.recovery.outcome,
|
|
1033
|
+
...(failure.recovery.recoveredTo !== undefined ? { recovered_to: failure.recovery.recoveredTo } : {}),
|
|
1034
|
+
...(failure.recovery.candidates !== undefined ? { candidates: failure.recovery.candidates } : {}),
|
|
1035
|
+
},
|
|
1036
|
+
}
|
|
1037
|
+
: {}),
|
|
1038
|
+
},
|
|
1039
|
+
};
|
|
1040
|
+
}
|