@catheadowl/ukp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bun.lock +35 -0
- package/package.json +51 -0
- package/src/artifacts.ts +75 -0
- package/src/capabilities/nav.ts +734 -0
- package/src/capabilities/propose.ts +456 -0
- package/src/capabilities/qmd.ts +174 -0
- package/src/capabilities/read.ts +1040 -0
- package/src/capabilities/remote-client.ts +856 -0
- package/src/capabilities/rename-recovery.ts +294 -0
- package/src/capabilities/rg.ts +505 -0
- package/src/capabilities/search.ts +1105 -0
- package/src/capabilities/tls-identity.ts +87 -0
- package/src/capabilities/update.ts +385 -0
- package/src/cli.ts +425 -0
- package/src/commands/diagnose.ts +383 -0
- package/src/commands/flags.ts +49 -0
- package/src/commands/guide-content/client.txt +36 -0
- package/src/commands/guide-content/propose.txt +38 -0
- package/src/commands/guide-content/service-qmd.txt +44 -0
- package/src/commands/guide-content/service.txt +114 -0
- package/src/commands/guide.ts +121 -0
- package/src/commands/init.ts +245 -0
- package/src/commands/inspect.ts +182 -0
- package/src/commands/inventory.ts +570 -0
- package/src/commands/kit.ts +269 -0
- package/src/commands/nav.ts +280 -0
- package/src/commands/propose.ts +257 -0
- package/src/commands/read.ts +558 -0
- package/src/commands/rg.ts +355 -0
- package/src/commands/search.ts +325 -0
- package/src/commands/serve.ts +221 -0
- package/src/commands/update.ts +99 -0
- package/src/config/client.ts +55 -0
- package/src/config/external-tool.ts +58 -0
- package/src/config/file-native.ts +111 -0
- package/src/config/manifest.ts +188 -0
- package/src/fslock.ts +95 -0
- package/src/path-safety.ts +65 -0
- package/src/registry.ts +436 -0
- package/src/scope.ts +78 -0
- package/src/server.ts +1011 -0
- package/tsconfig.json +12 -0
|
@@ -0,0 +1,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 };
|