@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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/bun.lock +35 -0
  4. package/package.json +51 -0
  5. package/src/artifacts.ts +75 -0
  6. package/src/capabilities/nav.ts +734 -0
  7. package/src/capabilities/propose.ts +456 -0
  8. package/src/capabilities/qmd.ts +174 -0
  9. package/src/capabilities/read.ts +1040 -0
  10. package/src/capabilities/remote-client.ts +856 -0
  11. package/src/capabilities/rename-recovery.ts +294 -0
  12. package/src/capabilities/rg.ts +505 -0
  13. package/src/capabilities/search.ts +1105 -0
  14. package/src/capabilities/tls-identity.ts +87 -0
  15. package/src/capabilities/update.ts +385 -0
  16. package/src/cli.ts +425 -0
  17. package/src/commands/diagnose.ts +383 -0
  18. package/src/commands/flags.ts +49 -0
  19. package/src/commands/guide-content/client.txt +36 -0
  20. package/src/commands/guide-content/propose.txt +38 -0
  21. package/src/commands/guide-content/service-qmd.txt +44 -0
  22. package/src/commands/guide-content/service.txt +114 -0
  23. package/src/commands/guide.ts +121 -0
  24. package/src/commands/init.ts +245 -0
  25. package/src/commands/inspect.ts +182 -0
  26. package/src/commands/inventory.ts +570 -0
  27. package/src/commands/kit.ts +269 -0
  28. package/src/commands/nav.ts +280 -0
  29. package/src/commands/propose.ts +257 -0
  30. package/src/commands/read.ts +558 -0
  31. package/src/commands/rg.ts +355 -0
  32. package/src/commands/search.ts +325 -0
  33. package/src/commands/serve.ts +221 -0
  34. package/src/commands/update.ts +99 -0
  35. package/src/config/client.ts +55 -0
  36. package/src/config/external-tool.ts +58 -0
  37. package/src/config/file-native.ts +111 -0
  38. package/src/config/manifest.ts +188 -0
  39. package/src/fslock.ts +95 -0
  40. package/src/path-safety.ts +65 -0
  41. package/src/registry.ts +436 -0
  42. package/src/scope.ts +78 -0
  43. package/src/server.ts +1011 -0
  44. package/tsconfig.json +12 -0
@@ -0,0 +1,1105 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ openSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ statSync,
9
+ unlinkSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { join, relative, resolve } from "node:path";
13
+ import { createArtifactRun } from "../artifacts.ts";
14
+ import {
15
+ loadManifest,
16
+ ManifestError,
17
+ type LoadedManifest,
18
+ type ManifestDependency,
19
+ } from "../config/manifest.ts";
20
+ import { isRemoteBinding, localPathOf, readRegistry, type RegistryBinding } from "../registry.ts";
21
+ import { resolveScope } from "../scope.ts";
22
+ import { isAbsoluteShapedPath, isInsideRealRoot } from "../path-safety.ts";
23
+ import { buildQmdInvocation, defaultQmdCommand, isDocidBody, providerTimeoutMs, stripDocidHash } from "./qmd.ts";
24
+
25
+ export interface SearchRequest {
26
+ query: string;
27
+ limit: number;
28
+ }
29
+
30
+ export interface SearchOptions {
31
+ explicitEndpoints?: string[];
32
+ global: boolean;
33
+ recursive: boolean;
34
+ json: boolean;
35
+ }
36
+
37
+ export interface ParsedSearch {
38
+ request: SearchRequest;
39
+ options: SearchOptions;
40
+ warnings: string[];
41
+ }
42
+
43
+ export class SearchPlanningError extends Error {
44
+ constructor(message: string) {
45
+ super(message);
46
+ this.name = "SearchPlanningError";
47
+ }
48
+ }
49
+
50
+ /** ADR 0021 context injection (registry path, provider command, artifact
51
+ * root/run id, clock) — no ambient state. */
52
+ export interface SearchContext {
53
+ currentDirectory: string;
54
+ registryPath: string;
55
+ qmdCommand?: readonly string[];
56
+ artifactRoot?: string;
57
+ artifactRunId?: string;
58
+ now?: Date;
59
+ }
60
+
61
+ /** ADR 0021 aggregate classification (D-036: skip never changes the success
62
+ * state; a provider failure makes the whole run exit non-zero). The surface
63
+ * adapter maps this onto exit codes — the capability never decides them. */
64
+ export type SearchAggregateStatus =
65
+ | "succeeded"
66
+ | "provider-failure"
67
+ | "cancelled"
68
+ | "no-success";
69
+
70
+ /** One endpoint's structured result. Contract zone: name/provider/status/
71
+ * message plus the json-mode artifact fields. Provider-native zone: the raw
72
+ * provider output and exit status (human-mode render source, passed through
73
+ * unmodeled per ADR 0021 §1). `folder` is internal provenance for the human
74
+ * renderer (ukp:// derivation), never part of the envelope. */
75
+ export interface SearchEndpointOutcome {
76
+ name: string;
77
+ provider: string | null;
78
+ /** Undefined only transiently while the run classifies the provider
79
+ * result; every path assigns a final status before the outcome escapes
80
+ * (the envelope projection defaults any stray undefined to `failed`). */
81
+ status?:
82
+ | "succeeded"
83
+ | "no_matches"
84
+ | "skipped"
85
+ | "failed"
86
+ | "cancelled"
87
+ /** human mode only: this endpoint's SIGINT stopped the run — its header
88
+ * was already rendered, its result block never is. */
89
+ | "interrupted";
90
+ /** Factual skip/failure message (envelope `message`), no surface prefix. */
91
+ message?: string;
92
+ traversal?: TraversalProvenance;
93
+ folder?: string;
94
+ /** Raw provider stdout, trimmed (human-mode render source). */
95
+ providerOutput?: string;
96
+ /** Raw provider exit status (null on signal or spawn error). */
97
+ providerExitStatus?: number | null;
98
+ /** Provider stderr detail or spawn error message. */
99
+ providerErrorDetail?: string;
100
+ artifact?: string;
101
+ format?: string;
102
+ references_artifact?: string;
103
+ references_format?: "ukp-search-references-v1";
104
+ error_artifact?: string;
105
+ /** Remote endpoints only (ukp_remote W2): server-declared `ukp_uri` per
106
+ * result index (RQ-06/RQ-09 — the server owns containment). Internal
107
+ * renderer input like `folder`, never part of the envelope. */
108
+ remoteUris?: ReadonlyArray<string | undefined>;
109
+ }
110
+
111
+ /** ADR 0021 structured outcome: everything both render modes and future
112
+ * surfaces (MCP, programmatic) consume. No stdout/stderr/exit codes. */
113
+ export interface SearchResult {
114
+ query: string;
115
+ limit: number;
116
+ /** Present only when the run produced an artifact directory (json mode). */
117
+ runId?: string;
118
+ endpoints: SearchEndpointOutcome[];
119
+ warnings: string[];
120
+ aggregate: SearchAggregateStatus;
121
+ }
122
+
123
+ interface PlannedEndpoint {
124
+ name: string;
125
+ provider: string | null;
126
+ status: "executable" | "skipped";
127
+ folder?: string;
128
+ command?: readonly string[];
129
+ warning?: string;
130
+ traversal?: TraversalProvenance;
131
+ }
132
+
133
+ interface TraversalVia {
134
+ kind: ManifestDependency["kind"];
135
+ reason?: string;
136
+ }
137
+
138
+ interface TraversalProvenance {
139
+ depth: 0 | 1;
140
+ path: string[];
141
+ via: TraversalVia | null;
142
+ }
143
+
144
+ function isStaleServiceBinding(error: unknown): boolean {
145
+ if (!(error instanceof ManifestError)) return false;
146
+ return error.message.startsWith("Service folder is not accessible:")
147
+ || error.message.startsWith("Service Manifest is not readable:");
148
+ }
149
+
150
+ export interface SearchEndpointEnvelope {
151
+ name: string;
152
+ provider: string | null;
153
+ status: "succeeded" | "no_matches" | "skipped" | "failed" | "cancelled";
154
+ artifact?: string;
155
+ format?: string;
156
+ references_artifact?: string;
157
+ references_format?: "ukp-search-references-v1";
158
+ error_artifact?: string;
159
+ message?: string;
160
+ depth?: 0 | 1;
161
+ path?: string[];
162
+ via?: TraversalVia | null;
163
+ }
164
+
165
+ export interface SearchEnvelope {
166
+ schema: "ukp.search.v1";
167
+ run_id: string;
168
+ command: "search";
169
+ capability: "search";
170
+ query: string;
171
+ limit: number;
172
+ endpoints: SearchEndpointEnvelope[];
173
+ warnings: string[];
174
+ }
175
+
176
+ function planSearch(parsed: ParsedSearch, context: SearchContext): {
177
+ plan: PlannedEndpoint[];
178
+ warnings: string[];
179
+ } {
180
+ const registry = readRegistry(context.registryPath);
181
+ const scope = resolveScope({
182
+ currentDirectory: context.currentDirectory,
183
+ registry,
184
+ explicitEndpoints: parsed.options.explicitEndpoints,
185
+ global: parsed.options.global,
186
+ });
187
+
188
+ const qmdCommand = context.qmdCommand ?? defaultQmdCommand();
189
+ const warnings = [...parsed.warnings, ...scope.warnings];
190
+ const plan: PlannedEndpoint[] = [];
191
+ const loadedServices = new Map<string, LoadedManifest>();
192
+
193
+ const planBinding = (
194
+ binding: RegistryBinding,
195
+ traversal?: TraversalProvenance,
196
+ ): PlannedEndpoint => {
197
+ if (isRemoteBinding(binding)) {
198
+ // Adapter responsibility: remote endpoints execute through the remote
199
+ // transport (commands/search.ts mixed driver); the local planner only
200
+ // records the skip defensively.
201
+ return {
202
+ name: binding.name,
203
+ provider: null,
204
+ status: "skipped",
205
+ warning: "remote endpoints execute via the remote transport; the local planner skipped it",
206
+ };
207
+ }
208
+ let service;
209
+ try {
210
+ service = loadManifest(localPathOf(binding));
211
+ } catch (error) {
212
+ if (!isStaleServiceBinding(error)) throw error;
213
+ const detail = error instanceof Error ? error.message : String(error);
214
+ const warning = [
215
+ `endpoint '${binding.name}' is not accessible: ${detail}`,
216
+ `Hint: run 'ukp inspect --endpoint ${binding.name}' or re-register/remove the stale endpoint.`,
217
+ ].join("\n");
218
+ warnings.push(warning);
219
+ return { name: binding.name, provider: null, status: "skipped", warning, traversal };
220
+ }
221
+ if (service.effectiveName !== binding.name) {
222
+ throw new SearchPlanningError(
223
+ `endpoint '${binding.name}' no longer matches Service effective name '${service.effectiveName}'`,
224
+ );
225
+ }
226
+ loadedServices.set(binding.name, service);
227
+ const capability = service.manifest.capabilities.search;
228
+ if (!capability) {
229
+ const warning = `endpoint '${binding.name}' does not provide search`;
230
+ warnings.push(warning);
231
+ return { name: binding.name, provider: null, status: "skipped", warning, traversal };
232
+ }
233
+ if (capability.provider !== "qmd") {
234
+ const provider = capability.provider ?? "(none)";
235
+ const warning = `endpoint '${binding.name}' uses unsupported search provider '${provider}'`;
236
+ warnings.push(warning);
237
+ return {
238
+ name: binding.name,
239
+ provider,
240
+ status: "skipped",
241
+ warning,
242
+ traversal,
243
+ };
244
+ }
245
+ if (!qmdCommand) {
246
+ const warning = `endpoint '${binding.name}' search unavailable: qmd executable is not available`;
247
+ warnings.push(warning);
248
+ return { name: binding.name, provider: "qmd", status: "skipped", warning, traversal };
249
+ }
250
+ return {
251
+ name: binding.name,
252
+ provider: "qmd",
253
+ status: "executable",
254
+ folder: service.folder,
255
+ command: qmdCommand,
256
+ traversal,
257
+ };
258
+ };
259
+
260
+ for (const binding of scope.bindings) {
261
+ plan.push(planBinding(binding, parsed.options.recursive
262
+ ? { depth: 0, path: [binding.name], via: null }
263
+ : undefined));
264
+ }
265
+
266
+ // Dangling `default_endpoints` references never resolve to a binding, so they
267
+ // produce a skipped plan entry at their declared position (not only a warning
268
+ // string) — a JSON consumer must be able to count every skipped endpoint.
269
+ for (const { name, configPath, index } of scope.dangling) {
270
+ const warning = `'${name}' is not registered (from ${configPath})`;
271
+ // `scope.bindings` and `scope.dangling` each preserve declaration order, and
272
+ // each binding produced exactly one plan entry above, so the plan has
273
+ // `index` entries before this dangling reference's declared position.
274
+ plan.splice(index, 0, {
275
+ name,
276
+ provider: null,
277
+ status: "skipped",
278
+ warning,
279
+ ...(parsed.options.recursive
280
+ ? { traversal: { depth: 0 as const, path: [name], via: null } }
281
+ : {}),
282
+ });
283
+ }
284
+
285
+ if (scope.dangling.length > 0 && scope.configPath) {
286
+ warnings.push(`Hint: run 'ukp list' or 'ukp diagnose' to check the selected scope, or edit ${scope.configPath}, then retry.`);
287
+ }
288
+
289
+ if (parsed.options.recursive) {
290
+ const registryByName = new Map(registry.map((binding) => [binding.name, binding]));
291
+ const visited = new Set(plan.map((endpoint) => endpoint.name));
292
+ const discovered: PlannedEndpoint[] = [];
293
+
294
+ for (const binding of scope.bindings) {
295
+ const service = loadedServices.get(binding.name);
296
+ if (!service) continue;
297
+ const dependencies = [...(service.manifest.dependencies ?? [])]
298
+ .filter((dependency) => dependency.kind === "authority" || dependency.kind === "context")
299
+ .sort((left, right) => left.endpoint < right.endpoint ? -1 : left.endpoint > right.endpoint ? 1 : 0);
300
+ for (const dependency of dependencies) {
301
+ if (visited.has(dependency.endpoint)) continue;
302
+ const targetBinding = registryByName.get(dependency.endpoint);
303
+ if (!targetBinding) {
304
+ warnings.push(
305
+ `recursive dependency target '${dependency.endpoint}' is not registered `
306
+ + `(declared by ${binding.name}, kind: ${dependency.kind})`,
307
+ );
308
+ continue;
309
+ }
310
+ visited.add(dependency.endpoint);
311
+ discovered.push(planBinding(targetBinding, {
312
+ depth: 1,
313
+ path: [binding.name, dependency.endpoint],
314
+ via: {
315
+ kind: dependency.kind,
316
+ ...(dependency.reason ? { reason: dependency.reason } : {}),
317
+ },
318
+ }));
319
+ }
320
+ }
321
+ plan.push(...discovered);
322
+
323
+ for (const endpoint of discovered) {
324
+ const service = loadedServices.get(endpoint.name);
325
+ if (!service || !endpoint.traversal) continue;
326
+ for (const dependency of service.manifest.dependencies ?? []) {
327
+ if (dependency.kind !== "authority" && dependency.kind !== "context") continue;
328
+ if (!endpoint.traversal.path.includes(dependency.endpoint)) continue;
329
+ warnings.push(
330
+ `recursive dependency cycle truncated: ${endpoint.name} -> ${dependency.endpoint} `
331
+ + `(kind: ${dependency.kind}, path: ${endpoint.traversal.path.join(" -> ")})`,
332
+ );
333
+ }
334
+ }
335
+ }
336
+
337
+ if (!plan.some((endpoint) => endpoint.status === "executable")) {
338
+ warnings.push(registry.length === 0
339
+ ? "no executable search endpoints: the Host Registry is empty; run 'ukp register' from a Service folder, then retry"
340
+ : "no executable search endpoints: run 'ukp list' to inspect registrations and 'ukp diagnose' from the selected Service folder(s)");
341
+ }
342
+ return { plan, warnings };
343
+ }
344
+
345
+ function commandFor(endpoint: PlannedEndpoint, parsed: ParsedSearch, json: boolean): {
346
+ file: string;
347
+ args: string[];
348
+ verbatim: boolean;
349
+ } {
350
+ const providerArgs = [
351
+ "search",
352
+ parsed.request.query,
353
+ "-n",
354
+ String(parsed.request.limit),
355
+ ];
356
+ if (json) providerArgs.push("--format", "json");
357
+ // The query is arbitrary user text: route through buildQmdInvocation so a
358
+ // cmd.exe shim wrapper never lets cmd re-parse it.
359
+ return buildQmdInvocation(endpoint.command!, providerArgs);
360
+ }
361
+
362
+ function renderTraversalProvenance(traversal: TraversalProvenance | undefined): string[] {
363
+ if (!traversal) return [];
364
+ const via = traversal.via ? ` via=${traversal.via.kind}` : "";
365
+ const lines = [
366
+ `traversal: depth=${traversal.depth} path=${traversal.path.join(" -> ")}${via}`,
367
+ ];
368
+ if (traversal.via?.reason) lines.push(`traversal_reason: ${traversal.via.reason}`);
369
+ return lines;
370
+ }
371
+
372
+ function runHumanMode(
373
+ parsed: ParsedSearch,
374
+ plan: readonly PlannedEndpoint[],
375
+ warnings: string[],
376
+ ): SearchResult {
377
+ const executable = plan.filter((endpoint) => endpoint.status === "executable");
378
+ const endpoints: SearchEndpointOutcome[] = [];
379
+ if (executable.length === 0) {
380
+ return {
381
+ query: parsed.request.query,
382
+ limit: parsed.request.limit,
383
+ endpoints,
384
+ warnings,
385
+ aggregate: "no-success",
386
+ };
387
+ }
388
+
389
+ let failed = false;
390
+ let interrupted = false;
391
+ for (const endpoint of plan) {
392
+ if (endpoint.status === "skipped") {
393
+ endpoints.push({
394
+ name: endpoint.name,
395
+ provider: endpoint.provider,
396
+ status: "skipped",
397
+ message: endpoint.warning,
398
+ ...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
399
+ });
400
+ continue;
401
+ }
402
+ if (interrupted) {
403
+ // Executables after the SIGINT stop: recorded as cancelled so a JSON
404
+ // consumer can count them, never rendered in human mode.
405
+ endpoints.push({
406
+ name: endpoint.name,
407
+ provider: endpoint.provider,
408
+ status: "cancelled",
409
+ ...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
410
+ });
411
+ continue;
412
+ }
413
+
414
+ const command = commandFor(endpoint, parsed, true);
415
+ const result = spawnSync(command.file, command.args, {
416
+ cwd: endpoint.folder!,
417
+ encoding: "utf8",
418
+ windowsHide: true,
419
+ windowsVerbatimArguments: command.verbatim,
420
+ maxBuffer: 64 * 1024 * 1024,
421
+ timeout: providerTimeoutMs(),
422
+ });
423
+ const providerOutput = (result.stdout ?? "").trimEnd();
424
+ const outcome: SearchEndpointOutcome = {
425
+ name: endpoint.name,
426
+ provider: endpoint.provider,
427
+ ...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
428
+ folder: endpoint.folder,
429
+ providerOutput,
430
+ providerExitStatus: result.status,
431
+ };
432
+ endpoints.push(outcome);
433
+ if (result.signal === "SIGTERM") {
434
+ // Zero-output hang guard: a timed-out provider is a classified failure,
435
+ // never silence (see qmd.ts providerTimeoutMs).
436
+ failed = true;
437
+ const message = `endpoint '${endpoint.name}' provider timed out after ${providerTimeoutMs() / 1000}s (set UKP_PROVIDER_TIMEOUT_MS to adjust)`;
438
+ warnings.push(message);
439
+ outcome.status = "failed";
440
+ outcome.providerExitStatus = null;
441
+ outcome.message = `endpoint '${endpoint.name}' provider timed out`;
442
+ continue;
443
+ }
444
+ if (result.signal === "SIGINT") {
445
+ warnings.push(`endpoint '${endpoint.name}' provider cancelled`);
446
+ outcome.status = "interrupted";
447
+ outcome.providerExitStatus = null;
448
+ interrupted = true;
449
+ continue;
450
+ }
451
+ if (result.status !== 0 || result.error) {
452
+ failed = true;
453
+ const providerError = (result.stderr ?? "").trimEnd() || result.error?.message;
454
+ warnings.push(`endpoint '${endpoint.name}' provider failed${providerError ? `: ${providerError}` : ""}`);
455
+ outcome.status = "failed";
456
+ outcome.providerErrorDetail = providerError;
457
+ continue;
458
+ }
459
+ outcome.status = providerOutput ? "succeeded" : "no_matches";
460
+ }
461
+ return {
462
+ query: parsed.request.query,
463
+ limit: parsed.request.limit,
464
+ endpoints,
465
+ warnings,
466
+ aggregate: interrupted ? "cancelled" : failed ? "provider-failure" : "succeeded",
467
+ };
468
+ }
469
+
470
+ function appendErrorArtifact(path: string, message: string): void {
471
+ const previous = existsSync(path) ? readFileSync(path, "utf8") : "";
472
+ writeFileSync(path, `${previous}${previous && !previous.endsWith("\n") ? "\n" : ""}${message}\n`, {
473
+ encoding: "utf8",
474
+ mode: 0o600,
475
+ });
476
+ }
477
+
478
+ function classifyQmdArtifact(path: string): "succeeded" | "no_matches" {
479
+ const size = statSync(path).size;
480
+ if (size > 1024 * 1024) return "succeeded";
481
+ const value = JSON.parse(readFileSync(path, "utf8"));
482
+ return Array.isArray(value) && value.length === 0 ? "no_matches" : "succeeded";
483
+ }
484
+
485
+ interface QmdReferenceMapping {
486
+ provider_location: string;
487
+ endpoint: string;
488
+ reference?: string;
489
+ line?: number;
490
+ status: "read_ready" | "provider_only";
491
+ read_adapter?: "qmd";
492
+ ukp_uri?: string;
493
+ reason?: string;
494
+ }
495
+
496
+ interface QmdReferenceSidecar {
497
+ schema: "ukp.search.references.v1";
498
+ endpoint: string;
499
+ source_artifact: string;
500
+ results: Array<QmdReferenceMapping & { index: number }>;
501
+ }
502
+
503
+ function maybeLine(line: number | undefined): { line?: number } {
504
+ return line ? { line } : {};
505
+ }
506
+
507
+ function docidOf(result: unknown): string | undefined {
508
+ if (typeof result !== "object" || result === null || !("docid" in result)) return undefined;
509
+ const docid = (result as { docid?: unknown }).docid;
510
+ if (typeof docid !== "string") return undefined;
511
+ const bare = stripDocidHash(docid);
512
+ return isDocidBody(bare) ? bare : undefined;
513
+ }
514
+
515
+ function lineOf(result: unknown): number | undefined {
516
+ if (typeof result !== "object" || result === null || !("line" in result)) return undefined;
517
+ const line = (result as { line?: unknown }).line;
518
+ return Number.isSafeInteger(line) && (line as number) > 0 ? (line as number) : undefined;
519
+ }
520
+
521
+ /** Exported for the rename-recovery L2 re-anchor (ADR 0020): extracts the
522
+ * provider location (`uri`/`file` field) of one parsed QMD result object. */
523
+ export function providerLocationOf(result: unknown): string {
524
+ if (typeof result !== "object" || result === null) return "";
525
+ const record = result as { uri?: unknown; file?: unknown };
526
+ if (typeof record.uri === "string") return record.uri;
527
+ if (typeof record.file === "string") return record.file;
528
+ return "";
529
+ }
530
+
531
+ /**
532
+ * Percent-encode a `ukp://` path segment for emission (ADR 0019; the read
533
+ * side percent-decodes symmetrically). Raw UTF-8 stays raw (grep-ability, IRI semantics);
534
+ * only characters that cannot round-trip through the hierarchical form raw are
535
+ * encoded: `%` (would be re-decoded on read), space, and the `#`/`?` delimiters.
536
+ */
537
+ function encodeUkpUriSegment(segment: string): string {
538
+ return segment.replace(/[%#? ]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
539
+ }
540
+
541
+ /**
542
+ * Derive the endpoint-relative path of one QMD result location (ADR 0019).
543
+ *
544
+ * The provider location is either path-shaped (`qmd://<absolute path>` — the
545
+ * collection root is a filesystem path) or collection-shaped
546
+ * (`qmd://<collection>/<rel>`). Only a location that resolves *inside* the
547
+ * Service folder to an existing regular file yields a `ukp://` URI: the URI is
548
+ * a slot promise (`ukp read` resolves it exactly), so it must never be emitted
549
+ * for provider-managed or out-of-folder resources. Collection names are never
550
+ * assumed to equal endpoint names — the resolved-containment + existence check,
551
+ * not name matching, decides (lexical containment plus a realpath pass so
552
+ * symlink escapes cannot yield an unreadable slot, mirroring the read hit path).
553
+ */
554
+ /** Exported for the rename-recovery L2 re-anchor (ADR 0020): maps a provider
555
+ * location to an endpoint-relative route when it safely resolves inside the
556
+ * Service folder to an existing regular file. */
557
+ export function endpointRelativePathOf(providerLocation: string, endpointFolder: string): string | undefined {
558
+ if (!providerLocation.startsWith("qmd://")) return undefined;
559
+ const rest = providerLocation.slice("qmd://".length);
560
+ let candidate: string;
561
+ if (isAbsoluteShapedPath(rest)) {
562
+ const rel = relative(resolve(endpointFolder), resolve(rest));
563
+ // Cross-drive / UNC↔drive targets: `relative()` returns the absolute
564
+ // target itself, which never starts with `..` — reject explicitly so the
565
+ // containment boundary cannot be bypassed (ADR 0019 emission rule).
566
+ if (isAbsoluteShapedPath(rel)) return undefined;
567
+ candidate = rel.split(/[\\/]/).join("/");
568
+ } else {
569
+ const firstSlash = rest.indexOf("/");
570
+ if (firstSlash <= 0) return undefined;
571
+ candidate = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
572
+ }
573
+ if (candidate === "" || candidate.split("/").includes("..")) return undefined;
574
+ const absolute = resolve(endpointFolder, ...candidate.split("/"));
575
+ if (!isInsideRealRoot(resolve(endpointFolder), absolute)) return undefined;
576
+ // Resolved containment, aligned with the read hit path (G5 defect closure):
577
+ // a folder-internal symlink escaping the Service folder passes the lexical
578
+ // checks above but must not yield a URI the exact slot route would refuse.
579
+ let resolved: string;
580
+ let resolvedFolder: string;
581
+ try {
582
+ resolved = realpathSync(absolute);
583
+ resolvedFolder = realpathSync(endpointFolder);
584
+ } catch {
585
+ return undefined;
586
+ }
587
+ if (!isInsideRealRoot(resolvedFolder, resolved)) return undefined;
588
+ let stat;
589
+ try {
590
+ stat = statSync(resolved);
591
+ } catch {
592
+ return undefined;
593
+ }
594
+ if (!stat.isFile()) return undefined;
595
+ return candidate;
596
+ }
597
+
598
+ function ukpUriOf(providerLocation: string, endpointName: string, endpointFolder: string): string | undefined {
599
+ const relPath = endpointRelativePathOf(providerLocation, endpointFolder);
600
+ if (!relPath) return undefined;
601
+ return `ukp://${endpointName}/${relPath.split("/").map(encodeUkpUriSegment).join("/")}`;
602
+ }
603
+
604
+ /**
605
+ * Map one QMD search result to a UKP read-ready reference (ADR 0011).
606
+ *
607
+ * QMD-indexed results carry a stable `docid` content fingerprint. `search`
608
+ * emits it as a bare handoff key (`#` stripped), not the weak `qmd://`/path
609
+ * name; `read` re-adds the `#` and resolves by fingerprint exactly. Name, title,
610
+ * and path become display-only provenance (`provider_location`). A result with
611
+ * no usable docid has no UKP read route and is `provider_only`.
612
+ */
613
+ function mapQmdResultToReference(
614
+ endpointName: string,
615
+ endpointFolder: string,
616
+ result: unknown,
617
+ ): QmdReferenceMapping {
618
+ const providerLocation = providerLocationOf(result);
619
+ const ukpUri = ukpUriOf(providerLocation, endpointName, endpointFolder);
620
+ const docid = docidOf(result);
621
+ if (!docid) {
622
+ return {
623
+ provider_location: providerLocation,
624
+ endpoint: endpointName,
625
+ status: "provider_only",
626
+ ...(ukpUri ? { ukp_uri: ukpUri } : {}),
627
+ reason: "qmd result has no usable docid",
628
+ };
629
+ }
630
+ return {
631
+ provider_location: providerLocation,
632
+ endpoint: endpointName,
633
+ reference: docid,
634
+ ...maybeLine(lineOf(result)),
635
+ status: "read_ready",
636
+ read_adapter: "qmd",
637
+ ...(ukpUri ? { ukp_uri: ukpUri } : {}),
638
+ };
639
+ }
640
+
641
+ function rawTitleOf(result: unknown): string {
642
+ if (typeof result !== "object" || result === null) return "";
643
+ const title = (result as { title?: unknown }).title;
644
+ return typeof title === "string" && title.trim() !== "" ? title : "";
645
+ }
646
+
647
+ function basenameOf(location: string): string {
648
+ if (!location) return "";
649
+ return location.split(/[\\/]/).filter(Boolean).pop() ?? location;
650
+ }
651
+
652
+ function snippetOf(result: unknown): string | undefined {
653
+ if (typeof result !== "object" || result === null) return undefined;
654
+ const snippet = (result as { snippet?: unknown }).snippet;
655
+ if (typeof snippet !== "string") return undefined;
656
+ const trimmed = snippet.trim();
657
+ return trimmed === "" ? undefined : trimmed;
658
+ }
659
+
660
+ /**
661
+ * A snippet anchored at file line 1 that starts with a `---` frontmatter banner
662
+ * or a heading is file-head boilerplate, not a hit-location excerpt. It is not
663
+ * shown as the excerpt; the title stands in for it.
664
+ */
665
+ function isFileHeadBanner(snippet: string, line: number | undefined): boolean {
666
+ if (line !== 1) return false;
667
+ const head = snippet.trimStart();
668
+ return head.startsWith("---") || head.startsWith("#");
669
+ }
670
+
671
+ /**
672
+ * Render one structured QMD result as a result unit.
673
+ *
674
+ * A result unit answers three reader questions: what it is (`title — basename:line`),
675
+ * why it matches (excerpt), and how to read it (a copyable `read` line addressing
676
+ * the ADR 0011 handoff key `docid[:line]`). Provider vocabulary never reaches the
677
+ * default Human surface: the docid appears only inside the `get` command, and
678
+ * location provenance is reduced to a human-readable basename (the raw `qmd://`
679
+ * stays in the `--json` reference sidecar). A result with no usable docid has no
680
+ * get route and says so in plain words.
681
+ */
682
+ function renderResultUnit(
683
+ unitIndex: number,
684
+ endpointName: string,
685
+ endpointFolder: string,
686
+ result: unknown,
687
+ remoteUri?: string,
688
+ remoteEndpoint = false,
689
+ ): string {
690
+ const providerLocation = providerLocationOf(result);
691
+ const docid = docidOf(result);
692
+ const line = lineOf(result);
693
+ // Remote endpoints carry the server-declared ukp_uri (RQ-06/RQ-09): it is
694
+ // the handoff key, so the read line addresses the URI directly and the
695
+ // local docid serves provenance only.
696
+ const ukpUri = remoteUri ?? ukpUriOf(providerLocation, endpointName, endpointFolder);
697
+ const title = rawTitleOf(result);
698
+ const base = basenameOf(providerLocation);
699
+ const location = base ? (line ? `${base}:${line}` : base) : "";
700
+ const identity = title
701
+ ? (location ? `${title} — ${location}` : title)
702
+ : (location || "");
703
+
704
+ const snippet = snippetOf(result);
705
+ const fallback = title || base;
706
+ const excerpt = snippet && !isFileHeadBanner(snippet, line) ? snippet : fallback;
707
+
708
+ const lines = [`${unitIndex}. ${identity}`];
709
+ if (excerpt && excerpt !== fallback) lines.push(` ${excerpt.replace(/\n/g, "\n ")}`);
710
+ if (remoteUri !== undefined) {
711
+ lines.push(` read: ukp read ${remoteUri}${line ? `#L${line}` : ""}`);
712
+ } else if (docid && !remoteEndpoint) {
713
+ // Remote endpoints never hand off via docid (RQ-09: session-scoped
714
+ // fingerprint, meaningless across the wire) — a result without a
715
+ // server-declared ukp_uri has no remote read route at all.
716
+ const key = line ? `${docid}:${line}` : docid;
717
+ lines.push(` read: ukp read --endpoint ${endpointName} ${key}`);
718
+ } else {
719
+ lines.push(` (no direct read — provider-managed result)`);
720
+ }
721
+ if (ukpUri && remoteUri === undefined) lines.push(` uri: ${ukpUri}`);
722
+ return lines.join("\n");
723
+ }
724
+
725
+ /**
726
+ * Render an endpoint's structured QMD results as numbered result units.
727
+ *
728
+ * Returns `null` when the provider stdout is not a JSON array (or cannot be
729
+ * parsed), so the caller falls back to the appended raw-provider format. An
730
+ * empty array renders `(no matches)`. Units are separated by a blank line so
731
+ * each `N.` block reads as one self-contained chunk even with multi-line excerpts.
732
+ */
733
+ function renderResultUnits(
734
+ providerOutput: string,
735
+ endpointName: string,
736
+ endpointFolder: string,
737
+ remoteUris?: ReadonlyArray<string | undefined>,
738
+ ): string | null {
739
+ let nativeResults: unknown;
740
+ try {
741
+ nativeResults = JSON.parse(providerOutput);
742
+ } catch {
743
+ return null;
744
+ }
745
+ if (!Array.isArray(nativeResults)) return null;
746
+ if (nativeResults.length === 0) return "(no matches)";
747
+ return nativeResults
748
+ .map((result, index) => renderResultUnit(index + 1, endpointName, endpointFolder, result, remoteUris?.[index], remoteUris !== undefined))
749
+ .join("\n\n");
750
+ }
751
+
752
+ /**
753
+ * Fallback (spec "Fallback"): a provider that did not return a parseable JSON
754
+ * array renders as its raw output block plus a trailing `UKP reference:` list
755
+ * (docid form). Docids are read from the provider's QMD location header lines
756
+ * (`qmd://...:line #docid`); body lines with a 6-hex token (e.g. a color code)
757
+ * are never treated as docids.
758
+ */
759
+ function renderFallbackProviderBlock(providerOutput: string, endpointName: string): string {
760
+ const lines = [providerOutput];
761
+ const seen = new Set<string>();
762
+ for (const textLine of providerOutput.split(/\r?\n/)) {
763
+ if (!textLine.startsWith("qmd://")) continue;
764
+ const docid = /#([a-f0-9]{6})/.exec(textLine);
765
+ if (!docid) continue;
766
+ const bare = docid[1];
767
+ if (!isDocidBody(bare) || seen.has(bare)) continue;
768
+ seen.add(bare);
769
+ const lineHit = /:(\d+)\s+#[a-f0-9]{6}/.exec(textLine);
770
+ const lineHint = lineHit ? ` --lines ${Number(lineHit[1])}` : "";
771
+ lines.push(`UKP reference: ukp read --endpoint ${endpointName} ${bare}${lineHint}`);
772
+ }
773
+ return lines.join("\n");
774
+ }
775
+
776
+ function buildQmdReferenceSidecar(
777
+ endpointName: string,
778
+ endpointFolder: string,
779
+ sourceArtifact: string,
780
+ ): QmdReferenceSidecar {
781
+ if (statSync(sourceArtifact).size > 1024 * 1024) {
782
+ return {
783
+ schema: "ukp.search.references.v1",
784
+ endpoint: endpointName,
785
+ source_artifact: sourceArtifact,
786
+ results: [],
787
+ };
788
+ }
789
+ const nativeResults = JSON.parse(readFileSync(sourceArtifact, "utf8"));
790
+ if (!Array.isArray(nativeResults)) {
791
+ throw new Error("qmd-json output is not an array");
792
+ }
793
+ return {
794
+ schema: "ukp.search.references.v1",
795
+ endpoint: endpointName,
796
+ source_artifact: sourceArtifact,
797
+ results: nativeResults.map((result, index) => ({
798
+ index,
799
+ ...mapQmdResultToReference(endpointName, endpointFolder, result),
800
+ })),
801
+ };
802
+ }
803
+
804
+ function writeQmdReferenceSidecar(
805
+ endpointName: string,
806
+ endpointFolder: string,
807
+ sourceArtifact: string,
808
+ sidecarPath: string,
809
+ ): void {
810
+ const sidecar = buildQmdReferenceSidecar(endpointName, endpointFolder, sourceArtifact);
811
+ writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, {
812
+ encoding: "utf8",
813
+ mode: 0o600,
814
+ });
815
+ }
816
+
817
+ /** Inline reference payload for artifact-less surfaces (ukp_remote serve,
818
+ * RQ-07: remote responses inline the reference data — the serving side holds
819
+ * no artifact root). Same mapping as the sidecar; `source_artifact` is
820
+ * omitted because there is no artifact. Undefined when the provider output is
821
+ * not a parseable JSON array, matching the sidecar's fallback semantics. */
822
+ export interface InlineSearchReferences {
823
+ schema: "ukp.search.references.v1";
824
+ endpoint: string;
825
+ results: Array<QmdReferenceMapping & { index: number }>;
826
+ }
827
+
828
+ export function buildInlineReferences(
829
+ endpointName: string,
830
+ endpointFolder: string,
831
+ providerOutput: string,
832
+ ): InlineSearchReferences | undefined {
833
+ let nativeResults: unknown;
834
+ try {
835
+ nativeResults = JSON.parse(providerOutput);
836
+ } catch {
837
+ return undefined;
838
+ }
839
+ if (!Array.isArray(nativeResults)) return undefined;
840
+ return {
841
+ schema: "ukp.search.references.v1",
842
+ endpoint: endpointName,
843
+ results: nativeResults.map((result, index) => ({
844
+ index,
845
+ ...mapQmdResultToReference(endpointName, endpointFolder, result),
846
+ })),
847
+ };
848
+ }
849
+
850
+ function runJsonMode(
851
+ parsed: ParsedSearch,
852
+ context: SearchContext,
853
+ plan: readonly PlannedEndpoint[],
854
+ warnings: string[],
855
+ ): SearchResult {
856
+ const run = createArtifactRun({
857
+ root: context.artifactRoot,
858
+ runId: context.artifactRunId,
859
+ now: context.now,
860
+ });
861
+ let failed = false;
862
+ let succeeded = false;
863
+ let cancelled = false;
864
+
865
+ const endpoints: SearchEndpointOutcome[] = [];
866
+ for (const endpoint of plan) {
867
+ if (endpoint.status === "skipped") {
868
+ endpoints.push({
869
+ name: endpoint.name,
870
+ provider: endpoint.provider,
871
+ status: "skipped",
872
+ message: endpoint.warning,
873
+ ...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
874
+ });
875
+ continue;
876
+ }
877
+ if (cancelled) {
878
+ endpoints.push({
879
+ name: endpoint.name,
880
+ provider: endpoint.provider,
881
+ status: "cancelled",
882
+ ...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
883
+ });
884
+ continue;
885
+ }
886
+
887
+ const artifact = resolve(join(run.directory, `${endpoint.name}.json`));
888
+ const referencesArtifact = resolve(join(run.directory, `${endpoint.name}.references.json`));
889
+ const errorArtifact = resolve(join(run.directory, `${endpoint.name}.stderr.txt`));
890
+ let stdoutFd: number | undefined;
891
+ let stderrFd: number | undefined;
892
+ const outcome: SearchEndpointOutcome = {
893
+ name: endpoint.name,
894
+ provider: endpoint.provider,
895
+ ...(endpoint.traversal ? { traversal: endpoint.traversal } : {}),
896
+ folder: endpoint.folder,
897
+ };
898
+ endpoints.push(outcome);
899
+ try {
900
+ stdoutFd = openSync(artifact, "w", 0o600);
901
+ stderrFd = openSync(errorArtifact, "w", 0o600);
902
+ const command = commandFor(endpoint, parsed, true);
903
+ const result = spawnSync(command.file, command.args, {
904
+ cwd: endpoint.folder!,
905
+ stdio: ["ignore", stdoutFd, stderrFd],
906
+ windowsHide: true,
907
+ windowsVerbatimArguments: command.verbatim,
908
+ timeout: providerTimeoutMs(),
909
+ });
910
+ closeSync(stdoutFd);
911
+ stdoutFd = undefined;
912
+ closeSync(stderrFd);
913
+ stderrFd = undefined;
914
+
915
+ outcome.artifact = artifact;
916
+ outcome.format = "qmd-json";
917
+
918
+ if (result.signal === "SIGTERM") {
919
+ // Zero-output hang guard: classified failure + error artifact, never
920
+ // silence (see qmd.ts providerTimeoutMs).
921
+ failed = true;
922
+ appendErrorArtifact(errorArtifact, `provider timed out after ${providerTimeoutMs() / 1000}s`);
923
+ const message = `endpoint '${endpoint.name}' provider timed out`;
924
+ warnings.push(message);
925
+ outcome.status = "failed";
926
+ outcome.error_artifact = errorArtifact;
927
+ outcome.message = message;
928
+ continue;
929
+ }
930
+
931
+ if (result.signal === "SIGINT") {
932
+ cancelled = true;
933
+ appendErrorArtifact(errorArtifact, "provider cancelled by SIGINT");
934
+ outcome.status = "cancelled";
935
+ outcome.error_artifact = errorArtifact;
936
+ outcome.message = "provider cancelled by SIGINT";
937
+ warnings.push(`endpoint '${endpoint.name}' provider cancelled`);
938
+ continue;
939
+ }
940
+
941
+ if (result.status !== 0 || result.error) {
942
+ failed = true;
943
+ if (result.error) appendErrorArtifact(errorArtifact, result.error.message);
944
+ const message = `endpoint '${endpoint.name}' provider failed`;
945
+ warnings.push(message);
946
+ outcome.status = "failed";
947
+ outcome.error_artifact = errorArtifact;
948
+ outcome.message = message;
949
+ continue;
950
+ }
951
+
952
+ let status: "succeeded" | "no_matches";
953
+ try {
954
+ status = classifyQmdArtifact(artifact);
955
+ } catch (error) {
956
+ failed = true;
957
+ const detail = error instanceof Error ? error.message : String(error);
958
+ appendErrorArtifact(errorArtifact, `invalid qmd-json output: ${detail}`);
959
+ const message = `endpoint '${endpoint.name}' returned invalid qmd-json`;
960
+ warnings.push(message);
961
+ outcome.status = "failed";
962
+ outcome.error_artifact = errorArtifact;
963
+ outcome.message = message;
964
+ continue;
965
+ }
966
+
967
+ succeeded = true;
968
+ outcome.status = status;
969
+ let hasReferenceSidecar = false;
970
+ try {
971
+ writeQmdReferenceSidecar(endpoint.name, endpoint.folder!, artifact, referencesArtifact);
972
+ hasReferenceSidecar = true;
973
+ } catch (error) {
974
+ const detail = error instanceof Error ? error.message : String(error);
975
+ appendErrorArtifact(errorArtifact, `invalid qmd-json reference mapping: ${detail}`);
976
+ warnings.push(`endpoint '${endpoint.name}' reference sidecar unavailable: ${detail}`);
977
+ }
978
+ if (hasReferenceSidecar) {
979
+ outcome.references_artifact = referencesArtifact;
980
+ outcome.references_format = "ukp-search-references-v1";
981
+ }
982
+ const hasProviderStderr = statSync(errorArtifact).size > 0;
983
+ if (!hasProviderStderr) unlinkSync(errorArtifact);
984
+ else outcome.error_artifact = errorArtifact;
985
+ } catch (error) {
986
+ failed = true;
987
+ if (stdoutFd !== undefined) closeSync(stdoutFd);
988
+ if (stderrFd !== undefined) closeSync(stderrFd);
989
+ const detail = error instanceof Error ? error.message : String(error);
990
+ try {
991
+ appendErrorArtifact(errorArtifact, detail);
992
+ } catch {
993
+ // The envelope still records the failure if even the error artifact cannot be written.
994
+ }
995
+ const message = `endpoint '${endpoint.name}' artifact or provider execution failed: ${detail}`;
996
+ warnings.push(message);
997
+ outcome.status = "failed";
998
+ if (!existsSync(artifact)) {
999
+ delete outcome.artifact;
1000
+ delete outcome.format;
1001
+ }
1002
+ if (existsSync(errorArtifact)) outcome.error_artifact = errorArtifact;
1003
+ outcome.message = message;
1004
+ }
1005
+ }
1006
+
1007
+ return {
1008
+ query: parsed.request.query,
1009
+ limit: parsed.request.limit,
1010
+ runId: run.runId,
1011
+ endpoints,
1012
+ warnings,
1013
+ aggregate: cancelled
1014
+ ? "cancelled"
1015
+ : failed
1016
+ ? "provider-failure"
1017
+ : succeeded
1018
+ ? "succeeded"
1019
+ : "no-success",
1020
+ };
1021
+ }
1022
+
1023
+ /** ADR 0021 core entry: runs the search capability and returns the
1024
+ * structured outcome. Scope/planning failures still throw typed errors
1025
+ * (`SearchPlanningError`, `ScopeError`, `ManifestError`)
1026
+ * for the surface adapter to map. */
1027
+ export function runSearch(parsed: ParsedSearch, context: SearchContext): SearchResult {
1028
+ const { plan, warnings } = planSearch(parsed, context);
1029
+ return parsed.options.json
1030
+ ? runJsonMode(parsed, context, plan, warnings)
1031
+ : runHumanMode(parsed, plan, warnings);
1032
+ }
1033
+
1034
+ // ---------------------------------------------------------------------------
1035
+ // Presentation (ADR 0021 two-stage form): project a structured view first,
1036
+ // render text from the view only. Adapters must consume these — never build
1037
+ // a private rendering pipeline (single render source).
1038
+ // ---------------------------------------------------------------------------
1039
+
1040
+ /** Projection onto the public `ukp.search.v1` envelope: contract-zone fields
1041
+ * in the canonical order; internal provenance (`folder`, raw provider
1042
+ * output/exit) stays out. `interrupted` never occurs in json mode; it maps
1043
+ * to `cancelled` defensively. */
1044
+ function toEndpointEnvelope(outcome: SearchEndpointOutcome): SearchEndpointEnvelope {
1045
+ const status = outcome.status ?? "failed";
1046
+ return {
1047
+ name: outcome.name,
1048
+ provider: outcome.provider,
1049
+ status: status === "interrupted" ? "cancelled" : status,
1050
+ ...(outcome.artifact !== undefined ? { artifact: outcome.artifact } : {}),
1051
+ ...(outcome.format !== undefined ? { format: outcome.format } : {}),
1052
+ ...(outcome.references_artifact !== undefined ? { references_artifact: outcome.references_artifact } : {}),
1053
+ ...(outcome.references_format !== undefined ? { references_format: outcome.references_format } : {}),
1054
+ ...(outcome.error_artifact !== undefined ? { error_artifact: outcome.error_artifact } : {}),
1055
+ ...(outcome.message !== undefined ? { message: outcome.message } : {}),
1056
+ ...(outcome.traversal ?? {}),
1057
+ };
1058
+ }
1059
+
1060
+ export function projectSearchEnvelope(result: SearchResult): SearchEnvelope {
1061
+ return {
1062
+ schema: "ukp.search.v1",
1063
+ run_id: result.runId ?? "",
1064
+ command: "search",
1065
+ capability: "search",
1066
+ query: result.query,
1067
+ limit: result.limit,
1068
+ endpoints: result.endpoints.map(toEndpointEnvelope),
1069
+ warnings: result.warnings,
1070
+ };
1071
+ }
1072
+
1073
+ export function renderSearchJson(envelope: SearchEnvelope): string {
1074
+ return `${JSON.stringify(envelope, null, 2)}\n`;
1075
+ }
1076
+
1077
+ /** Human view: `== <name> ==` blocks in plan order. Skipped endpoints live
1078
+ * only in warnings; the first `interrupted` endpoint keeps its header (the
1079
+ * SIGINT stop happened mid-block) and ends the output; post-interrupt
1080
+ * `cancelled` endpoints render nothing. */
1081
+ export function renderSearchHuman(result: SearchResult): string {
1082
+ const lines: string[] = [];
1083
+ for (const endpoint of result.endpoints) {
1084
+ if (endpoint.status === "skipped") continue;
1085
+ if (endpoint.status === "cancelled") break;
1086
+ if (lines.length > 0) lines.push("");
1087
+ lines.push(`== ${endpoint.name} ==`);
1088
+ if (endpoint.traversal) lines.push(...renderTraversalProvenance(endpoint.traversal));
1089
+ if (endpoint.status === "interrupted") break;
1090
+ if (endpoint.providerOutput) {
1091
+ lines.push(
1092
+ endpoint.folder
1093
+ ? (renderResultUnits(endpoint.providerOutput, endpoint.name, endpoint.folder)
1094
+ ?? renderFallbackProviderBlock(endpoint.providerOutput, endpoint.name))
1095
+ : endpoint.remoteUris !== undefined
1096
+ ? (renderResultUnits(endpoint.providerOutput, endpoint.name, "", endpoint.remoteUris)
1097
+ ?? renderFallbackProviderBlock(endpoint.providerOutput, endpoint.name))
1098
+ : renderFallbackProviderBlock(endpoint.providerOutput, endpoint.name),
1099
+ );
1100
+ } else if (endpoint.providerExitStatus === 0) {
1101
+ lines.push("(no matches)");
1102
+ }
1103
+ }
1104
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
1105
+ }