@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,294 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { buildQmdInvocation, defaultQmdCommand, providerTimeoutMs } from "./qmd.ts";
6
+ import { endpointRelativePathOf, providerLocationOf } from "./search.ts";
7
+
8
+ // ADR 0020: read-time layered rename recovery. This module owns the
9
+ // miss-path descent (L1 git-derived -> L2 search re-anchor -> exhausted) and
10
+ // the ukp-pin three-valued verification. It never resolves a read by itself —
11
+ // the caller (read capability) still owns the final file read and the miss
12
+ // wording; this module only proposes a recovered route (plus verification
13
+ // metadata) or reports exhaustion with whatever candidates L2 collected.
14
+ //
15
+ // Guaranteed contract (spec: read-rename-recovery): exact path / L1 git
16
+ // derivation / classified failure. L2 is opportunistic — its absence (no
17
+ // search capability, provider spawn failure, zero recall) is silent and never
18
+ // an error. `ukp move` is retired (Q4): no dedicated moves-log surface exists.
19
+
20
+ export interface RecoveryRequest {
21
+ /** Endpoint-relative route that missed (the slot the caller addressed). */
22
+ route: string;
23
+ endpointName: string;
24
+ /** Service folder (git repo root candidate, qmd cwd). */
25
+ serviceFolder: string;
26
+ /** Consumer-pinned content hash (`sha256-<64 hex>`), from --pin or the
27
+ * --from source document's ukp-pin annotation. Absent = stale-unknown. */
28
+ pin?: string;
29
+ /** QMD command when the endpoint declares a qmd search capability. */
30
+ qmdCommand?: readonly string[];
31
+ }
32
+
33
+ export interface RecoveryCandidate {
34
+ path: string;
35
+ verified: "match" | "mismatch" | "unverified";
36
+ }
37
+
38
+ export interface RecoveryOutcome {
39
+ status: "recovered" | "exhausted";
40
+ /** Endpoint-relative route to read instead (status "recovered" only). */
41
+ recoveredRoute?: string;
42
+ layer?: "git-history" | "search-reanchor";
43
+ verification: "match" | "mismatch" | "stale-unknown" | "unverified";
44
+ /** Human/stderr lines beyond the echo (stale warnings). */
45
+ warnings: string[];
46
+ /** L2-collected candidates for the exhaustion message (advisory). */
47
+ candidates: RecoveryCandidate[];
48
+ /** Layer names attempted, for the JSON envelope. */
49
+ attempted: string[];
50
+ }
51
+
52
+ /** sha256 over the LF-normalized UTF-8 content (Q7: pin contract normalizes
53
+ * CRLF -> LF so a pin computed on any platform verifies on any other; the
54
+ * autocrlf drift trap was demonstrated by the w2 seed dogfood). */
55
+ export function pinHashOf(content: string): string {
56
+ return createHash("sha256").update(content.replace(/\r\n/g, "\n"), "utf8").digest("hex");
57
+ }
58
+
59
+ function readPinVerified(route: string, request: RecoveryRequest): "match" | "mismatch" | "unverified" {
60
+ if (request.pin === undefined) return "unverified";
61
+ let content: string;
62
+ try {
63
+ content = readFileSync(join(request.serviceFolder, ...route.split("/")), "utf8");
64
+ } catch {
65
+ return "unverified";
66
+ }
67
+ return pinHashOf(content) === request.pin.slice("sha256-".length) ? "match" : "mismatch";
68
+ }
69
+
70
+ /** L1: derive old->new from git rename records. Repo-wide recent renames are
71
+ * listed (no path filter — a path filter breaks on renamed-away old paths),
72
+ * then matched against the missed route. Bounded to recent history: recovery
73
+ * targets living documents, not archaeology. Any git failure (missing git,
74
+ * not a repo, corrupted) means the layer is absent — silent, per spec. */
75
+ function spawnGit(serviceFolder: string, args: readonly string[]): ReturnType<typeof spawnSync> {
76
+ // Read-only git queries; one retry absorbs transient spawn failures
77
+ // (sandboxed environments intermittently deny piped stdio). A persistent
78
+ // failure still means silent layer absence, per the L1 contract.
79
+ let result = spawnSync("git", args, {
80
+ cwd: serviceFolder,
81
+ encoding: "utf8",
82
+ windowsHide: true,
83
+ timeout: 10_000,
84
+ });
85
+ if (result.status !== 0 || result.error) {
86
+ result = spawnSync("git", args, {
87
+ cwd: serviceFolder,
88
+ encoding: "utf8",
89
+ windowsHide: true,
90
+ timeout: 10_000,
91
+ });
92
+ }
93
+ return result;
94
+ }
95
+
96
+ function deriveFromGit(request: RecoveryRequest): {
97
+ newRoute?: string;
98
+ editedAfterMove?: boolean;
99
+ } {
100
+ // Cheap presence guard before any spawn: a folder without `.git` (dir or
101
+ // worktree file) is L1-absent with zero process cost — a miss must never
102
+ // pay a git round-trip on non-git endpoints.
103
+ if (!existsSync(join(request.serviceFolder, ".git"))) return {};
104
+ const result = spawnGit(request.serviceFolder, ["log", "-M", "--name-status", "--diff-filter=R", "--format=%H", "-n", "500"]);
105
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") return {};
106
+ const lines = result.stdout.split(/\r?\n/);
107
+ let commit = "";
108
+ // Known limitation (review 2026-09-10, accepted): chained renames (A→B→C)
109
+ // recover A only to B (the newest record naming A as old), git-quoted paths
110
+ // and merge-commit renames are silently missed — all covered by the L1
111
+ // silent-absence contract; a follow-up read of B misses again and would
112
+ // recover B→C on the next call, but the single-call chain is not folded.
113
+ for (let i = 0; i < lines.length; i += 1) {
114
+ const line = lines[i];
115
+ if (/^[0-9a-f]{40}$/.test(line)) {
116
+ commit = line;
117
+ continue;
118
+ }
119
+ // R100\told\tnew (tab-separated, per git name-status)
120
+ const rename = /^R\d+\t(.+)\t(.+)$/.exec(line);
121
+ if (rename && rename[1].replace(/\\/g, "/") === request.route) {
122
+ const newRoute = rename[2].replace(/\\/g, "/");
123
+ // Edited after the move? A later commit touching the new route means the
124
+ // pinned hash (if any) is expected to drift — stale, not a mismatch.
125
+ const later = spawnGit(request.serviceFolder, ["log", "--oneline", `${commit}..HEAD`, "--", newRoute]);
126
+ const editedAfterMove = !later.error
127
+ && later.status === 0
128
+ && typeof later.stdout === "string"
129
+ && later.stdout.trim().length > 0;
130
+ return { newRoute, editedAfterMove };
131
+ }
132
+ }
133
+ return {};
134
+ }
135
+
136
+ /** L2: opportunistic re-anchor through the QMD index. Recall by the missed
137
+ * route's basename, filter to endpoint-local files with an equal basename.
138
+ * Provider stdout is a JSON array of result objects (same shape search
139
+ * consumes); failure of any kind (spawn, unparseable output) is silent layer
140
+ * absence. */
141
+ function reanchorViaSearch(request: RecoveryRequest): string[] {
142
+ if (!request.qmdCommand) return [];
143
+ const basename = request.route.split("/").pop() ?? request.route;
144
+ // --format json matches the search capability's provider contract: the
145
+ // recall layer consumes the same JSON array shape search renders from.
146
+ const invocation = buildQmdInvocation(request.qmdCommand, ["search", basename, "--format", "json"]);
147
+ const result = spawnSync(invocation.file, invocation.args, {
148
+ cwd: request.serviceFolder,
149
+ encoding: "utf8",
150
+ windowsHide: true,
151
+ windowsVerbatimArguments: invocation.verbatim,
152
+ maxBuffer: 16 * 1024 * 1024,
153
+ timeout: providerTimeoutMs(),
154
+ });
155
+ if (result.error || result.status !== 0 || typeof result.stdout !== "string") return [];
156
+ let nativeResults: unknown;
157
+ try {
158
+ nativeResults = JSON.parse(result.stdout);
159
+ } catch {
160
+ return [];
161
+ }
162
+ if (!Array.isArray(nativeResults)) return [];
163
+ const routes = new Set<string>();
164
+ for (const item of nativeResults) {
165
+ const location = providerLocationOf(item);
166
+ if (!location) continue;
167
+ const route = endpointRelativePathOf(location, request.serviceFolder);
168
+ if (route && route.split("/").pop() === basename && route !== request.route) {
169
+ routes.add(route);
170
+ }
171
+ }
172
+ return [...routes].sort();
173
+ }
174
+
175
+ /** Run the miss-path descent for a slot miss. Pure orchestration: file reads
176
+ * happen only for pin verification of an already-proposed candidate. */
177
+ export function recoverRenamedResource(request: RecoveryRequest): RecoveryOutcome {
178
+ const attempted: string[] = [];
179
+ const warnings: string[] = [];
180
+ const candidates: RecoveryCandidate[] = [];
181
+
182
+ // L1 — git-derived (primary, guaranteed-stack member).
183
+ const git = deriveFromGit(request);
184
+ if (git.newRoute !== undefined) {
185
+ attempted.push("git-derived");
186
+ const verified = readPinVerified(git.newRoute, request);
187
+ if (verified === "match") {
188
+ // The pin verifies the CURRENT content of the mapped target: nothing
189
+ // is stale, even when edits followed the rename (dogfood 2026-09-10:
190
+ // a match + editedAfterMove warning was noise — the pin was computed
191
+ // after those edits and fully covers what is being read).
192
+ return {
193
+ status: "recovered",
194
+ recoveredRoute: git.newRoute,
195
+ layer: "git-history",
196
+ verification: "match",
197
+ warnings,
198
+ candidates,
199
+ attempted,
200
+ };
201
+ }
202
+ if (request.pin === undefined) {
203
+ warnings.push("recovered without verification (no ukp-pin for this reference)");
204
+ return {
205
+ status: "recovered",
206
+ recoveredRoute: git.newRoute,
207
+ layer: "git-history",
208
+ verification: "stale-unknown",
209
+ warnings,
210
+ candidates,
211
+ attempted,
212
+ };
213
+ }
214
+ if (git.editedAfterMove) {
215
+ // Three-valued check, third value: the mismatch is explained by edits
216
+ // after the rename — the mapping itself is authoritative.
217
+ warnings.push("content changed after the move (pin stale); trusting the git rename mapping");
218
+ return {
219
+ status: "recovered",
220
+ recoveredRoute: git.newRoute,
221
+ layer: "git-history",
222
+ verification: "stale-unknown",
223
+ warnings,
224
+ candidates,
225
+ attempted,
226
+ };
227
+ }
228
+ // Clean mismatch: the git-mapped candidate is NOT the pinned content —
229
+ // block it (path recycling guard) and keep descending.
230
+ candidates.push({ path: git.newRoute, verified: "mismatch" });
231
+ }
232
+
233
+ // L2 — search re-anchor (opportunistic; absence is silent).
234
+ const recalled = reanchorViaSearch(request);
235
+ if (recalled.length > 0) {
236
+ attempted.push("search-reanchor");
237
+ const verifiedOnes = recalled.map((route) => ({ route, verified: readPinVerified(route, request) }));
238
+ for (const { route, verified } of verifiedOnes) {
239
+ if (verified === "match") {
240
+ return {
241
+ status: "recovered",
242
+ recoveredRoute: route,
243
+ layer: "search-reanchor",
244
+ verification: "match",
245
+ warnings: [...warnings, "recovered via search re-anchor (advisory); update the embedded reference"],
246
+ candidates,
247
+ attempted,
248
+ };
249
+ }
250
+ }
251
+ // No pin-verified unique hit: multiple or unverified candidates never
252
+ // auto-recover (spec: recall is advisory).
253
+ for (const { route, verified } of verifiedOnes) {
254
+ candidates.push({
255
+ path: route,
256
+ verified: verified === "mismatch" ? "mismatch" : "unverified",
257
+ });
258
+ }
259
+ }
260
+
261
+ return { status: "exhausted", verification: "unverified", warnings, candidates, attempted };
262
+ }
263
+
264
+ /** `--pin` flag validation (spec: only `sha256-<64 lowercase hex>` is legal —
265
+ * SRI-narrowed, no multi-algorithm negotiation). */
266
+ export function isValidPin(value: string): boolean {
267
+ return /^sha256-[0-9a-f]{64}$/.test(value);
268
+ }
269
+
270
+ /** Extract a same-line ukp-pin from a --from source document: the line that
271
+ * carries the original reference also carrying `ukp-pin: sha256-…` (Q1
272
+ * spelling). Absent line or absent pin → undefined (stale-unknown path). */
273
+ export function pinFromSourceDocument(
274
+ sourceRoute: string,
275
+ originalReference: string,
276
+ serviceFolder: string,
277
+ ): string | undefined {
278
+ let content: string;
279
+ try {
280
+ content = readFileSync(join(serviceFolder, ...sourceRoute.split("/")), "utf8");
281
+ } catch {
282
+ return undefined;
283
+ }
284
+ for (const line of content.split(/\r?\n/)) {
285
+ if (!line.includes(originalReference)) continue;
286
+ const pin = /ukp-pin:\s*(sha256-[0-9a-f]{64})/.exec(line);
287
+ if (pin) return pin[1];
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ /** Default qmd command accessor re-exported for callers wiring RecoveryRequest
293
+ * from a manifest-derived search provider. */
294
+ export { defaultQmdCommand };