@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.
@@ -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
- outcome.status = providerOutput ? "succeeded" : "no_matches";
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
- function docidOf(result: unknown): string | undefined {
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
- * 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).
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
- /** 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;
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 candidate: string;
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
- // 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("/");
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 undefined;
571
- candidate = rest.slice(firstSlash + 1).split(/[\\/]/).join("/");
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
- 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;
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
- resolved = realpathSync(absolute);
583
- resolvedFolder = realpathSync(endpointFolder);
729
+ realFolderRoot = realpathSync(folderRoot);
584
730
  } catch {
585
- return undefined;
731
+ return { reason: "no-candidate" };
586
732
  }
587
- if (!isInsideRealRoot(resolvedFolder, resolved)) return undefined;
588
- let stat;
589
- try {
590
- stat = statSync(resolved);
591
- } catch {
592
- return undefined;
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 (!stat.isFile()) return undefined;
595
- return candidate;
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 ukpUriOf(providerLocation: string, endpointName: string, endpointFolder: string): string | undefined {
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
- * 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`.
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
- ...(ukpUri ? { ukp_uri: ukpUri } : {}),
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 ? { ukp_uri: 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
- const ukpUri = remoteUri ?? ukpUriOf(providerLocation, endpointName, endpointFolder);
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, index) => renderResultUnit(index + 1, endpointName, endpointFolder, result, remoteUris?.[index], remoteUris !== undefined))
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, index) => ({
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, index) => ({
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);