@catheadowl/ukp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -25
- package/package.json +2 -4
- package/src/capabilities/nav.ts +4 -2
- package/src/capabilities/propose.ts +2 -1
- package/src/capabilities/read.ts +33 -3
- package/src/capabilities/remote-client.ts +306 -60
- package/src/capabilities/rename-recovery.ts +8 -4
- package/src/capabilities/rg.ts +8 -4
- package/src/capabilities/search.ts +253 -67
- package/src/capabilities/tls-identity.ts +172 -30
- package/src/cli.ts +1 -1
- package/src/commands/guide-content/client.txt +12 -0
- package/src/commands/guide-content/remote.txt +64 -0
- package/src/commands/guide-content/service-qmd.txt +9 -4
- package/src/commands/guide-content/service.txt +33 -32
- package/src/commands/guide.ts +11 -4
- package/src/commands/init.ts +16 -12
- package/src/commands/inventory.ts +192 -47
- package/src/commands/nav.ts +1 -1
- package/src/commands/read.ts +48 -9
- package/src/commands/rg.ts +4 -1
- package/src/commands/search.ts +4 -1
- package/src/commands/serve.ts +86 -12
- package/src/config/manifest.ts +6 -0
- package/src/registry.ts +104 -16
- package/src/server.ts +267 -44
- package/bun.lock +0 -35
- package/tsconfig.json +0 -12
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
closeSync,
|
|
4
5
|
existsSync,
|
|
5
6
|
openSync,
|
|
7
|
+
readdirSync,
|
|
6
8
|
readFileSync,
|
|
7
9
|
realpathSync,
|
|
8
10
|
statSync,
|
|
@@ -53,6 +55,8 @@ export interface SearchContext {
|
|
|
53
55
|
currentDirectory: string;
|
|
54
56
|
registryPath: string;
|
|
55
57
|
qmdCommand?: readonly string[];
|
|
58
|
+
/** Test injection point for the ssh binary the wake transport spawns. */
|
|
59
|
+
sshCommand?: readonly string[];
|
|
56
60
|
artifactRoot?: string;
|
|
57
61
|
artifactRunId?: string;
|
|
58
62
|
now?: Date;
|
|
@@ -369,6 +373,25 @@ function renderTraversalProvenance(traversal: TraversalProvenance | undefined):
|
|
|
369
373
|
return lines;
|
|
370
374
|
}
|
|
371
375
|
|
|
376
|
+
/** Zero hits by parsed content, not stdout size: qmd's JSON mode prints a
|
|
377
|
+
* non-empty `[]` for zero results. `null` marks non-array / unparseable
|
|
378
|
+
* provider-native output, which keeps the fallback render path `succeeded`. */
|
|
379
|
+
function parseJsonArrayHitCount(providerOutput: string): number | null {
|
|
380
|
+
try {
|
|
381
|
+
const value: unknown = JSON.parse(providerOutput);
|
|
382
|
+
return Array.isArray(value) ? value.length : null;
|
|
383
|
+
} catch {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** An empty provider result is indistinguishable from an unconfigured provider
|
|
389
|
+
* to a first-run caller — both classification sites surface the setup path
|
|
390
|
+
* (search's own contract stays: no matches is a result, not a failure, exit 0). */
|
|
391
|
+
function noMatchesSetupHint(endpointName: string): string {
|
|
392
|
+
return `endpoint '${endpointName}' returned no matches; if the QMD provider has not been set up for this Service yet, see 'ukp guide service qmd'`;
|
|
393
|
+
}
|
|
394
|
+
|
|
372
395
|
function runHumanMode(
|
|
373
396
|
parsed: ParsedSearch,
|
|
374
397
|
plan: readonly PlannedEndpoint[],
|
|
@@ -456,7 +479,11 @@ function runHumanMode(
|
|
|
456
479
|
outcome.providerErrorDetail = providerError;
|
|
457
480
|
continue;
|
|
458
481
|
}
|
|
459
|
-
|
|
482
|
+
const hitCount = parseJsonArrayHitCount(providerOutput);
|
|
483
|
+
outcome.status = hitCount === 0 || !providerOutput ? "no_matches" : "succeeded";
|
|
484
|
+
if (outcome.status === "no_matches") {
|
|
485
|
+
warnings.push(noMatchesSetupHint(endpoint.name));
|
|
486
|
+
}
|
|
460
487
|
}
|
|
461
488
|
return {
|
|
462
489
|
query: parsed.request.query,
|
|
@@ -490,6 +517,8 @@ interface QmdReferenceMapping {
|
|
|
490
517
|
status: "read_ready" | "provider_only";
|
|
491
518
|
read_adapter?: "qmd";
|
|
492
519
|
ukp_uri?: string;
|
|
520
|
+
/** REF-4 (ADR 0025): why no `ukp_uri` was emitted for this result. */
|
|
521
|
+
ukp_uri_omission_reason?: UriOmissionReason;
|
|
493
522
|
reason?: string;
|
|
494
523
|
}
|
|
495
524
|
|
|
@@ -504,7 +533,10 @@ function maybeLine(line: number | undefined): { line?: number } {
|
|
|
504
533
|
return line ? { line } : {};
|
|
505
534
|
}
|
|
506
535
|
|
|
507
|
-
|
|
536
|
+
/** The bare 6-hex content fingerprint of one QMD result (`#` stripped and
|
|
537
|
+
* body-validated); exported for the rename-recovery L2 re-anchor, which
|
|
538
|
+
* verifies recalled candidates against the same fingerprint (ADR 0025). */
|
|
539
|
+
export function docidOf(result: unknown): string | undefined {
|
|
508
540
|
if (typeof result !== "object" || result === null || !("docid" in result)) return undefined;
|
|
509
541
|
const docid = (result as { docid?: unknown }).docid;
|
|
510
542
|
if (typeof docid !== "string") return undefined;
|
|
@@ -539,91 +571,223 @@ function encodeUkpUriSegment(segment: string): string {
|
|
|
539
571
|
}
|
|
540
572
|
|
|
541
573
|
/**
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
* (
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
574
|
+
* Verified emission (ADR 0025 / D-082; REF-1 + REF-2 in
|
|
575
|
+
* `docs/concepts/reference-integrity.md`): a `ukp://` URI is emitted only
|
|
576
|
+
* when a candidate endpoint-local file's content hash matches the hit's
|
|
577
|
+
* docid — `sha256(raw on-disk bytes)[0:6]`, the provider's content
|
|
578
|
+
* fingerprint (empirically pinned 2026-09-17). Existence alone never
|
|
579
|
+
* suffices (REF-2: existence ≠ identity); enumeration may guess
|
|
580
|
+
* (normalization-insensitive matching — the provider location vocabulary is
|
|
581
|
+
* not filesystem-literal, e.g. `session_format` → `session-format`), the
|
|
582
|
+
* hash decides; every failure degrades to no-uri with an auditable reason
|
|
583
|
+
* (REF-4), never to a wrong uri. If the provider ever changes its hash
|
|
584
|
+
* scheme, verification fails closed: coverage drops, correctness never
|
|
585
|
+
* breaks.
|
|
553
586
|
*/
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
587
|
+
|
|
588
|
+
/** REF-4: why a `ukp_uri` was not emitted for a result. */
|
|
589
|
+
export type UriOmissionReason =
|
|
590
|
+
| "no-docid"
|
|
591
|
+
| "no-candidate"
|
|
592
|
+
| "hash-mismatch"
|
|
593
|
+
| "ambiguous"
|
|
594
|
+
| "out-of-folder";
|
|
595
|
+
|
|
596
|
+
export interface VerifiedRoute {
|
|
597
|
+
relPath?: string;
|
|
598
|
+
reason?: UriOmissionReason;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** Enumeration-only normalization (deliberately loose — the hash is the
|
|
602
|
+
* authority): folds the provider's observed `_`→`-` location vocabulary and
|
|
603
|
+
* case so wrong guesses stay cheap and harmless. */
|
|
604
|
+
function normalizeSegmentForEnumeration(segment: string): string {
|
|
605
|
+
return segment.toLowerCase().replaceAll("_", "-");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Endpoint file index: normalized endpoint-relative path → real rel paths.
|
|
609
|
+
* Built once per emission call (per endpoint). Symlinked files resolving
|
|
610
|
+
* back inside the folder are included (mirroring the read hit path);
|
|
611
|
+
* symlinked directories are not descended; `.git`/`.qmd` internals are
|
|
612
|
+
* skipped. The index only *generates candidates* — the resolved-containment
|
|
613
|
+
* safety gate and the content hash below decide. */
|
|
614
|
+
export type EndpointFileIndex = Map<string, string[]>;
|
|
615
|
+
|
|
616
|
+
/** Structural shape of a `readdirSync(withFileTypes)` entry as the index
|
|
617
|
+
* uses it (avoids the Dirent<Buffer> overload inference). */
|
|
618
|
+
interface IndexDirent {
|
|
619
|
+
name: string;
|
|
620
|
+
isDirectory(): boolean;
|
|
621
|
+
isFile(): boolean;
|
|
622
|
+
isSymbolicLink(): boolean;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
export function buildEndpointFileIndex(endpointFolder: string): EndpointFileIndex {
|
|
626
|
+
const index: EndpointFileIndex = new Map();
|
|
627
|
+
let realRoot: string;
|
|
628
|
+
try {
|
|
629
|
+
realRoot = realpathSync(endpointFolder);
|
|
630
|
+
} catch {
|
|
631
|
+
return index;
|
|
632
|
+
}
|
|
633
|
+
const walk = (directory: string, prefix: string): void => {
|
|
634
|
+
let entries: IndexDirent[];
|
|
635
|
+
try {
|
|
636
|
+
entries = readdirSync(directory, { withFileTypes: true }) as unknown as IndexDirent[];
|
|
637
|
+
} catch {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
for (const entry of entries) {
|
|
641
|
+
if (entry.name === ".git" || entry.name === ".qmd") continue;
|
|
642
|
+
const rel = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
643
|
+
if (entry.isDirectory()) {
|
|
644
|
+
walk(join(directory, entry.name), rel);
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
if (entry.isSymbolicLink()) {
|
|
648
|
+
// Include only symlinked files that resolve back inside the root
|
|
649
|
+
// (G5: a symlink escaping the folder must never yield a slot).
|
|
650
|
+
try {
|
|
651
|
+
if (!statSync(join(directory, entry.name)).isFile()) continue;
|
|
652
|
+
if (!isInsideRealRoot(realRoot, realpathSync(join(directory, entry.name)))) continue;
|
|
653
|
+
} catch {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
} else if (!entry.isFile()) {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const normalized = rel.split("/").map(normalizeSegmentForEnumeration).join("/");
|
|
660
|
+
const bucket = index.get(normalized);
|
|
661
|
+
if (bucket === undefined) index.set(normalized, [rel]);
|
|
662
|
+
else bucket.push(rel);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
walk(realRoot, "");
|
|
666
|
+
return index;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function sha256Prefix6Of(file: string): string | undefined {
|
|
670
|
+
try {
|
|
671
|
+
return createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, 6);
|
|
672
|
+
} catch {
|
|
673
|
+
return undefined;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* ADR 0025 core: map a provider location to an endpoint-relative route only
|
|
679
|
+
* when a candidate file verifies as the hit's content. Layered policy over
|
|
680
|
+
* the shared primitives (ADR 0023): candidate generation (path-shaped by
|
|
681
|
+
* folder-relative containment, collection-shaped by normalization-insensitive
|
|
682
|
+
* suffix match — the collection-root assumption that broke ISSUE-014 is
|
|
683
|
+
* gone) → resolved containment + isFile safety gate → sha256-prefix
|
|
684
|
+
* adjudication against the docid. Zero or multiple verified matches decline
|
|
685
|
+
* (ambiguity never emits).
|
|
686
|
+
*/
|
|
687
|
+
export function verifiedEndpointRouteOf(
|
|
688
|
+
providerLocation: string,
|
|
689
|
+
endpointFolder: string,
|
|
690
|
+
docid: string | undefined,
|
|
691
|
+
index: EndpointFileIndex,
|
|
692
|
+
): VerifiedRoute {
|
|
693
|
+
if (!providerLocation.startsWith("qmd://")) return {};
|
|
694
|
+
if (docid === undefined) return { reason: "no-docid" };
|
|
559
695
|
const rest = providerLocation.slice("qmd://".length);
|
|
560
|
-
let
|
|
696
|
+
let candidates: string[];
|
|
561
697
|
if (isAbsoluteShapedPath(rest)) {
|
|
698
|
+
// Path-shaped (collection name is the full on-disk path): folder-relative
|
|
699
|
+
// containment, with the cross-drive / UNC escape rejected explicitly
|
|
700
|
+
// (`relative()` returns the absolute target itself there).
|
|
562
701
|
const rel = relative(resolve(endpointFolder), resolve(rest));
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
candidate = rel.split(/[\\/]/).join("/");
|
|
702
|
+
if (isAbsoluteShapedPath(rel)) return { reason: "out-of-folder" };
|
|
703
|
+
const candidate = rel.split(/[\\/]/).join("/");
|
|
704
|
+
if (candidate === "" || candidate.split("/").includes("..")) return { reason: "out-of-folder" };
|
|
705
|
+
candidates = [candidate];
|
|
568
706
|
} else {
|
|
707
|
+
// Collection-shaped: strip the collection name, then match the remainder
|
|
708
|
+
// against the folder's normalized paths as an exact-or-suffix key. The
|
|
709
|
+
// suffix arm is what recovers subfolder-rooted collections (ISSUE-014
|
|
710
|
+
// false negatives) without knowing any collection root.
|
|
569
711
|
const firstSlash = rest.indexOf("/");
|
|
570
|
-
if (firstSlash <= 0) return
|
|
571
|
-
|
|
712
|
+
if (firstSlash <= 0) return { reason: "no-candidate" };
|
|
713
|
+
const rel = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
|
|
714
|
+
if (rel === "" || rel.split("/").includes("..")) return { reason: "no-candidate" };
|
|
715
|
+
const normalizedRel = rel.split("/").map(normalizeSegmentForEnumeration).join("/");
|
|
716
|
+
candidates = [];
|
|
717
|
+
for (const [normalized, paths] of index) {
|
|
718
|
+
if (normalized === normalizedRel || normalized.endsWith(`/${normalizedRel}`)) {
|
|
719
|
+
candidates.push(...paths);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
if (candidates.length === 0) return { reason: "no-candidate" };
|
|
572
723
|
}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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;
|
|
724
|
+
// Safety gate (aligned with the read hit path, G5 defect closure): lexical
|
|
725
|
+
// containment, then realpath containment, then isFile.
|
|
726
|
+
const folderRoot = resolve(endpointFolder);
|
|
727
|
+
let realFolderRoot: string;
|
|
581
728
|
try {
|
|
582
|
-
|
|
583
|
-
resolvedFolder = realpathSync(endpointFolder);
|
|
729
|
+
realFolderRoot = realpathSync(folderRoot);
|
|
584
730
|
} catch {
|
|
585
|
-
return
|
|
731
|
+
return { reason: "no-candidate" };
|
|
586
732
|
}
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
733
|
+
const safe: string[] = [];
|
|
734
|
+
for (const candidate of candidates) {
|
|
735
|
+
const absolute = resolve(folderRoot, ...candidate.split("/"));
|
|
736
|
+
if (!isInsideRealRoot(realFolderRoot, absolute)) continue;
|
|
737
|
+
let realAbsolute: string;
|
|
738
|
+
try {
|
|
739
|
+
realAbsolute = realpathSync(absolute);
|
|
740
|
+
} catch {
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
if (!isInsideRealRoot(realFolderRoot, realAbsolute)) continue;
|
|
744
|
+
try {
|
|
745
|
+
if (!statSync(realAbsolute).isFile()) continue;
|
|
746
|
+
} catch {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
safe.push(candidate);
|
|
593
750
|
}
|
|
594
|
-
if (
|
|
595
|
-
|
|
751
|
+
if (safe.length === 0) return { reason: "out-of-folder" };
|
|
752
|
+
const matches: string[] = [];
|
|
753
|
+
for (const candidate of safe) {
|
|
754
|
+
if (sha256Prefix6Of(resolve(folderRoot, ...candidate.split("/"))) === docid) matches.push(candidate);
|
|
755
|
+
}
|
|
756
|
+
if (matches.length === 1) return { relPath: matches[0] };
|
|
757
|
+
if (matches.length === 0) return { reason: "hash-mismatch" };
|
|
758
|
+
return { reason: "ambiguous" };
|
|
596
759
|
}
|
|
597
760
|
|
|
598
|
-
function
|
|
599
|
-
const relPath = endpointRelativePathOf(providerLocation, endpointFolder);
|
|
600
|
-
if (!relPath) return undefined;
|
|
761
|
+
function ukpUriFromRelPath(endpointName: string, relPath: string): string {
|
|
601
762
|
return `ukp://${endpointName}/${relPath.split("/").map(encodeUkpUriSegment).join("/")}`;
|
|
602
763
|
}
|
|
603
764
|
|
|
604
765
|
/**
|
|
605
|
-
* Map one QMD search result to a UKP read-ready reference (ADR 0011)
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* no usable docid has no UKP read route and is `provider_only`.
|
|
766
|
+
* Map one QMD search result to a UKP read-ready reference (ADR 0011) under
|
|
767
|
+
* the ADR 0025 verified-emission rule: the durable `ukp_uri` is emitted only
|
|
768
|
+
* when a candidate file's sha256 prefix matches the docid; otherwise the
|
|
769
|
+
* omission reason lands in the sidecar (REF-4). Name, title, and path stay
|
|
770
|
+
* display-only provenance (`provider_location`). A result with no usable
|
|
771
|
+
* docid has no UKP read route and no verifiable uri — `provider_only`.
|
|
612
772
|
*/
|
|
613
773
|
function mapQmdResultToReference(
|
|
614
774
|
endpointName: string,
|
|
615
775
|
endpointFolder: string,
|
|
616
776
|
result: unknown,
|
|
777
|
+
index: EndpointFileIndex,
|
|
617
778
|
): QmdReferenceMapping {
|
|
618
779
|
const providerLocation = providerLocationOf(result);
|
|
619
|
-
const ukpUri = ukpUriOf(providerLocation, endpointName, endpointFolder);
|
|
620
780
|
const docid = docidOf(result);
|
|
781
|
+
const route = providerLocation !== ""
|
|
782
|
+
? verifiedEndpointRouteOf(providerLocation, endpointFolder, docid, index)
|
|
783
|
+
: {};
|
|
784
|
+
const ukpUri = route.relPath !== undefined ? ukpUriFromRelPath(endpointName, route.relPath) : undefined;
|
|
621
785
|
if (!docid) {
|
|
622
786
|
return {
|
|
623
787
|
provider_location: providerLocation,
|
|
624
788
|
endpoint: endpointName,
|
|
625
789
|
status: "provider_only",
|
|
626
|
-
...(
|
|
790
|
+
...(route.reason !== undefined ? { ukp_uri_omission_reason: route.reason } : {}),
|
|
627
791
|
reason: "qmd result has no usable docid",
|
|
628
792
|
};
|
|
629
793
|
}
|
|
@@ -634,7 +798,11 @@ function mapQmdResultToReference(
|
|
|
634
798
|
...maybeLine(lineOf(result)),
|
|
635
799
|
status: "read_ready",
|
|
636
800
|
read_adapter: "qmd",
|
|
637
|
-
...(ukpUri
|
|
801
|
+
...(ukpUri !== undefined
|
|
802
|
+
? { ukp_uri: ukpUri }
|
|
803
|
+
: route.reason !== undefined
|
|
804
|
+
? { ukp_uri_omission_reason: route.reason }
|
|
805
|
+
: {}),
|
|
638
806
|
};
|
|
639
807
|
}
|
|
640
808
|
|
|
@@ -684,6 +852,7 @@ function renderResultUnit(
|
|
|
684
852
|
endpointName: string,
|
|
685
853
|
endpointFolder: string,
|
|
686
854
|
result: unknown,
|
|
855
|
+
resolveIndex: () => EndpointFileIndex,
|
|
687
856
|
remoteUri?: string,
|
|
688
857
|
remoteEndpoint = false,
|
|
689
858
|
): string {
|
|
@@ -692,8 +861,13 @@ function renderResultUnit(
|
|
|
692
861
|
const line = lineOf(result);
|
|
693
862
|
// Remote endpoints carry the server-declared ukp_uri (RQ-06/RQ-09): it is
|
|
694
863
|
// the handoff key, so the read line addresses the URI directly and the
|
|
695
|
-
// local docid serves provenance only.
|
|
696
|
-
|
|
864
|
+
// local docid serves provenance only. Local emission is ADR 0025 verified:
|
|
865
|
+
// the uri line appears only when a candidate file's hash matches the docid.
|
|
866
|
+
const verified = remoteUri !== undefined || endpointFolder === ""
|
|
867
|
+
? {}
|
|
868
|
+
: verifiedEndpointRouteOf(providerLocation, endpointFolder, docid, resolveIndex());
|
|
869
|
+
const ukpUri = remoteUri
|
|
870
|
+
?? (verified.relPath !== undefined ? ukpUriFromRelPath(endpointName, verified.relPath) : undefined);
|
|
697
871
|
const title = rawTitleOf(result);
|
|
698
872
|
const base = basenameOf(providerLocation);
|
|
699
873
|
const location = base ? (line ? `${base}:${line}` : base) : "";
|
|
@@ -744,8 +918,14 @@ function renderResultUnits(
|
|
|
744
918
|
}
|
|
745
919
|
if (!Array.isArray(nativeResults)) return null;
|
|
746
920
|
if (nativeResults.length === 0) return "(no matches)";
|
|
921
|
+
// One endpoint-file index per endpoint render (ADR 0025): built lazily so
|
|
922
|
+
// empty result sets and pure-remote endpoints pay no walk.
|
|
923
|
+
let index: EndpointFileIndex | undefined;
|
|
924
|
+
const resolveIndex = (): EndpointFileIndex => index ??= buildEndpointFileIndex(endpointFolder);
|
|
747
925
|
return nativeResults
|
|
748
|
-
.map((result,
|
|
926
|
+
.map((result, index_) =>
|
|
927
|
+
renderResultUnit(index_ + 1, endpointName, endpointFolder, result, resolveIndex, remoteUris?.[index_], remoteUris !== undefined)
|
|
928
|
+
)
|
|
749
929
|
.join("\n\n");
|
|
750
930
|
}
|
|
751
931
|
|
|
@@ -790,13 +970,15 @@ function buildQmdReferenceSidecar(
|
|
|
790
970
|
if (!Array.isArray(nativeResults)) {
|
|
791
971
|
throw new Error("qmd-json output is not an array");
|
|
792
972
|
}
|
|
973
|
+
// One endpoint-file index per sidecar build (ADR 0025).
|
|
974
|
+
const index = nativeResults.length > 0 ? buildEndpointFileIndex(endpointFolder) : new Map();
|
|
793
975
|
return {
|
|
794
976
|
schema: "ukp.search.references.v1",
|
|
795
977
|
endpoint: endpointName,
|
|
796
978
|
source_artifact: sourceArtifact,
|
|
797
|
-
results: nativeResults.map((result,
|
|
798
|
-
index,
|
|
799
|
-
...mapQmdResultToReference(endpointName, endpointFolder, result),
|
|
979
|
+
results: nativeResults.map((result, index_) => ({
|
|
980
|
+
index: index_,
|
|
981
|
+
...mapQmdResultToReference(endpointName, endpointFolder, result, index),
|
|
800
982
|
})),
|
|
801
983
|
};
|
|
802
984
|
}
|
|
@@ -837,12 +1019,15 @@ export function buildInlineReferences(
|
|
|
837
1019
|
return undefined;
|
|
838
1020
|
}
|
|
839
1021
|
if (!Array.isArray(nativeResults)) return undefined;
|
|
1022
|
+
// One endpoint-file index per inline build (ADR 0025) — same verified
|
|
1023
|
+
// mapping as the sidecar, so remote inline references stay in lockstep.
|
|
1024
|
+
const index = nativeResults.length > 0 ? buildEndpointFileIndex(endpointFolder) : new Map();
|
|
840
1025
|
return {
|
|
841
1026
|
schema: "ukp.search.references.v1",
|
|
842
1027
|
endpoint: endpointName,
|
|
843
|
-
results: nativeResults.map((result,
|
|
844
|
-
index,
|
|
845
|
-
...mapQmdResultToReference(endpointName, endpointFolder, result),
|
|
1028
|
+
results: nativeResults.map((result, index_) => ({
|
|
1029
|
+
index: index_,
|
|
1030
|
+
...mapQmdResultToReference(endpointName, endpointFolder, result, index),
|
|
846
1031
|
})),
|
|
847
1032
|
};
|
|
848
1033
|
}
|
|
@@ -966,6 +1151,7 @@ function runJsonMode(
|
|
|
966
1151
|
|
|
967
1152
|
succeeded = true;
|
|
968
1153
|
outcome.status = status;
|
|
1154
|
+
if (status === "no_matches") warnings.push(noMatchesSetupHint(endpoint.name));
|
|
969
1155
|
let hasReferenceSidecar = false;
|
|
970
1156
|
try {
|
|
971
1157
|
writeQmdReferenceSidecar(endpoint.name, endpoint.folder!, artifact, referencesArtifact);
|