@patronage/software-factory 1.0.0-alpha.34 → 1.0.0-alpha.36

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.
@@ -0,0 +1,1007 @@
1
+ import path from "node:path";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { z } from "zod";
5
+ import { noul } from "@typesafe-ai/sdk";
6
+ import { spawnSync } from "node:child_process";
7
+ import { readFile, stat } from "node:fs/promises";
8
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
9
+ import os from "node:os";
10
+ //#region src/jev/schema.ts
11
+ const noulAnswerSchema = z.object({
12
+ noul: z.number().min(0).max(1),
13
+ type: z.literal("noul")
14
+ });
15
+ const choiceAnswerSchema = z.object({
16
+ choice: z.string(),
17
+ confidence: z.number(),
18
+ probabilities: z.record(z.string(), z.number()),
19
+ type: z.literal("choice")
20
+ });
21
+ const scoreAnswerSchema = z.object({
22
+ confidence: z.number(),
23
+ probabilities: z.record(z.string(), z.number()),
24
+ score: z.number(),
25
+ type: z.literal("score")
26
+ });
27
+ /**
28
+ * One answer, discriminated exactly as Jev discriminates it.
29
+ *
30
+ * Every member is a *stripping* object: an unknown key inside one answer is
31
+ * dropped, not an error. Erroring would let a single surprising field sink a
32
+ * whole response, and keeping it would put an upstream-named field into a
33
+ * record. Dropping is the only behavior that is both safe and non-destructive.
34
+ */
35
+ const jevAnswerSchema = z.discriminatedUnion("type", [
36
+ noulAnswerSchema,
37
+ choiceAnswerSchema,
38
+ scoreAnswerSchema
39
+ ]);
40
+ /** Token counters, copied field by field. Never a passthrough object. */
41
+ const usageSchema = z.object({
42
+ input_tokens: z.number().optional(),
43
+ output_tokens: z.number().optional()
44
+ });
45
+ /**
46
+ * Jev's response body. Unknown keys are stripped at every level: nothing
47
+ * upstream names may end up on a record simply because it was present.
48
+ */
49
+ const jevResponseSchema = z.object({
50
+ answers: z.record(z.string(), jevAnswerSchema),
51
+ model: z.string().min(1),
52
+ usage: usageSchema.optional()
53
+ });
54
+ /** A model id is a short token. Anything longer or stranger is prose. */
55
+ const MODEL_ID_SHAPE = /^[A-Za-z0-9][\w.-]{0,63}$/u;
56
+ /** What a report says when HQ names its model in something other than an id. */
57
+ const UNKNOWN_MODEL = "unknown";
58
+ /**
59
+ * The one way an upstream body becomes a value this package will carry.
60
+ *
61
+ * It returns `undefined` rather than throwing, because a thrown validation
62
+ * error carries the rejected data in its own message — the exact thing that
63
+ * must not travel. Every field is copied explicitly: a passthrough object, a
64
+ * spread, or a `z.infer` handed straight to a record would all reintroduce
65
+ * "whatever HQ sent" as "whatever we publish".
66
+ */
67
+ const parseJevResponse = (payload) => {
68
+ const parsed = jevResponseSchema.safeParse(payload);
69
+ if (!parsed.success) return;
70
+ const { answers, model, usage } = parsed.data;
71
+ const counters = {};
72
+ if (typeof usage?.input_tokens === "number") counters.input_tokens = usage.input_tokens;
73
+ if (typeof usage?.output_tokens === "number") counters.output_tokens = usage.output_tokens;
74
+ return {
75
+ answers,
76
+ model: MODEL_ID_SHAPE.test(model) ? model : UNKNOWN_MODEL,
77
+ ...usage === void 0 ? {} : { usage: counters }
78
+ };
79
+ };
80
+ /**
81
+ * Projects one answer onto the question that asked for it.
82
+ *
83
+ * Two jobs, and the second is why this lives here rather than in a caller.
84
+ * Type agreement keeps a well-formed answer of the wrong kind from throwing
85
+ * later, deeper, and taking a whole report with it. And the only free text an
86
+ * answer can carry — a `choice` label — is accepted solely when it is a label
87
+ * this package itself wrote into the question. Probability keys are rebuilt
88
+ * from the question's own labels or rubric levels for the same reason, so an
89
+ * upstream-invented key cannot ride along.
90
+ */
91
+ const projectAnswer = (question, answer) => {
92
+ if (answer.type !== question.type) return { reason: `asked ${question.type}, answered ${answer.type}` };
93
+ if (answer.type === "noul") return { answer: {
94
+ noul: answer.noul,
95
+ type: "noul"
96
+ } };
97
+ const keep = (keys) => Object.fromEntries(keys.map((key) => [key, answer.probabilities[key]]).filter(([, value]) => typeof value === "number"));
98
+ if (answer.type === "choice") {
99
+ const labels = Object.keys(question.criteria);
100
+ if (!labels.includes(answer.choice)) return { reason: "answered with a label the question did not offer" };
101
+ return { answer: {
102
+ choice: answer.choice,
103
+ confidence: answer.confidence,
104
+ probabilities: keep(labels),
105
+ type: "choice"
106
+ } };
107
+ }
108
+ const levels = question.criteria.map((_, index) => String(index));
109
+ return { answer: {
110
+ confidence: answer.confidence,
111
+ probabilities: keep(levels),
112
+ score: answer.score,
113
+ type: "score"
114
+ } };
115
+ };
116
+ //#endregion
117
+ //#region src/jev/cache.ts
118
+ /**
119
+ * The request-hash cache: the same request bytes against the same endpoint
120
+ * answer from disk instead of from HQ.
121
+ *
122
+ * The key is the endpoint plus the exact serialized request, and nothing else.
123
+ * That is the whole design: a changed snippet, a reworded question, a
124
+ * different reference file and a different HQ all move the bytes, so they all
125
+ * move the key. There is no key schema to keep in step with the request
126
+ * builder, and no way to grow one that forgets a field.
127
+ *
128
+ * It lives under `node_modules/.cache`, which is disposable by convention and
129
+ * already ignored everywhere — a Jev answer is a session diagnostic (ADR
130
+ * 0034), not a build input and not evidence.
131
+ *
132
+ * An entry that exists but cannot be used — unreadable, not JSON, not Jev's
133
+ * response shape — is an error, not a miss, and so is a write that fails. Only
134
+ * `ENOENT` means "nothing is stored". Repairing any of the others silently and
135
+ * calling HQ anyway would be a second behavior for one failure, and a paid
136
+ * one; the run names the entry and its cause, and the operator removes it.
137
+ */
138
+ /** A cache entry that could not be used, or could not be written. */
139
+ var JevCacheError = class extends Error {
140
+ path;
141
+ constructor(entryPath, detail) {
142
+ super(`the Jev cache entry ${entryPath} could not be used: ${detail}. Remove it and run again.`);
143
+ this.name = "JevCacheError";
144
+ this.path = entryPath;
145
+ }
146
+ };
147
+ /**
148
+ * The filesystem's own error code, which is a short constant it names
149
+ * (`EISDIR`, `EACCES`), not a message that could quote anything.
150
+ */
151
+ const errnoOf = (error) => error?.code ?? "an unknown error";
152
+ /** Where a repository's Jev answers are kept. */
153
+ const defaultJevCacheDirectory = (cwd) => path.join(cwd, "node_modules", ".cache", "patronage-jev");
154
+ /**
155
+ * Opens the cache, creating its directory now so a directory that cannot be
156
+ * written fails here — at the one place that names it — rather than midway
157
+ * through a run as an unexplained request failure.
158
+ */
159
+ const openJevCache = ({ directory = defaultJevCacheDirectory(process.cwd()), endpoint }) => {
160
+ mkdirSync(directory, { recursive: true });
161
+ const keyFor = (serialized) => createHash("sha256").update(`${endpoint}\0${serialized}`).digest("hex");
162
+ const entryPathFor = (serialized) => path.join(directory, `${keyFor(serialized)}.json`);
163
+ return {
164
+ keyFor,
165
+ read: (serialized) => {
166
+ const entryPath = entryPathFor(serialized);
167
+ let text;
168
+ try {
169
+ text = readFileSync(entryPath, "utf-8");
170
+ } catch (error) {
171
+ if (errnoOf(error) === "ENOENT") return;
172
+ throw new JevCacheError(entryPath, `reading it failed with ${errnoOf(error)}`);
173
+ }
174
+ let payload;
175
+ try {
176
+ payload = JSON.parse(text);
177
+ } catch {
178
+ throw new JevCacheError(entryPath, "it is not JSON");
179
+ }
180
+ const parsed = parseJevResponse(payload);
181
+ if (parsed === void 0) throw new JevCacheError(entryPath, "it is not Jev's response shape");
182
+ return parsed;
183
+ },
184
+ write: (serialized, response) => {
185
+ const entryPath = entryPathFor(serialized);
186
+ try {
187
+ writeFileSync(entryPath, JSON.stringify(response), "utf-8");
188
+ } catch (error) {
189
+ throw new JevCacheError(entryPath, `writing it failed with ${errnoOf(error)}`);
190
+ }
191
+ }
192
+ };
193
+ };
194
+ //#endregion
195
+ //#region src/hq-access.ts
196
+ const CF_ACCESS_CLIENT_ID_ENV = "CF-Access-Client-Id";
197
+ const CF_ACCESS_CLIENT_SECRET_ENV = "CF-Access-Client-Secret";
198
+ /**
199
+ * True in a hosted runner, where secret-manager resolution must not be
200
+ * tried (#312). Lives here — a dependency-free leaf both
201
+ * `hq-ingest-preflight.ts` and `hq-credentials.ts` already import from for
202
+ * the env var names above — so neither of those two needs to import the
203
+ * other for it; that import, either direction, would close a cycle (#394).
204
+ */
205
+ const isHostedRunner = (env) => env.CI === "true" || env.CI === "1" || env.GITHUB_ACTIONS === "true";
206
+ /**
207
+ * Builds a credential-bearing HQ request that cannot follow redirects. Access
208
+ * credentials must never leave the origin selected by the caller.
209
+ */
210
+ const buildCloudflareAccessRequestInit = (token, init) => ({
211
+ ...init,
212
+ headers: {
213
+ ...init.headers,
214
+ [CF_ACCESS_CLIENT_ID_ENV]: token.clientId,
215
+ [CF_ACCESS_CLIENT_SECRET_ENV]: token.clientSecret
216
+ },
217
+ redirect: "error"
218
+ });
219
+ //#endregion
220
+ //#region src/hq-credentials.ts
221
+ /**
222
+ * Resolving the HQ Access credentials, and saying *why* when that fails.
223
+ *
224
+ * A deliberate command (`psf hq:flush`, and doctor's remote checks in #394) is
225
+ * the one place allowed to ask the secret manager for the HQ Access token. It
226
+ * used to keep a value-or-nothing answer, so a missing binary, a sandbox that
227
+ * cannot reach the keychain, and a mistyped reference all collapsed into one
228
+ * generic "credentials are unavailable" line. A sandboxed lane read that as
229
+ * environmental noise while every one of its proofs stayed spooled.
230
+ *
231
+ * Two boundaries hold everywhere in this module:
232
+ *
233
+ * - **Classification comes from the failure mode, never from resolver output.**
234
+ * No stderr is captured and no resolver text is retained or rendered: a
235
+ * secret manager's diagnostics can quote the material it was asked for.
236
+ * Exit status, spawn error code, and signal are the whole evidence base.
237
+ * - **Trusted-local only.** Hosted runners never invoke a secret manager
238
+ * (#312); references stay inert there and the failure says so.
239
+ */
240
+ /** Ceiling for one secret-manager probe; expiry means unresolved, never a hang. */
241
+ const SECRET_PROBE_TIMEOUT_MS = 1e4;
242
+ /** The operator's resolution path: `op-fast` first, official `op` as fallback. */
243
+ const SECRET_RESOLVER_BINARIES = ["op-fast", "op"];
244
+ const spawnFailure = (error) => {
245
+ const code = error?.code;
246
+ if (code === "ENOENT") return "resolver-missing";
247
+ if (code === "EACCES" || code === "EPERM") return "resolver-blocked";
248
+ return "resolver-timeout";
249
+ };
250
+ const probe = (binary, reference, capture) => {
251
+ const result = spawnSync(binary, ["read", reference], {
252
+ encoding: "utf-8",
253
+ stdio: [
254
+ "ignore",
255
+ capture ? "pipe" : "ignore",
256
+ "ignore"
257
+ ],
258
+ timeout: SECRET_PROBE_TIMEOUT_MS
259
+ });
260
+ if (result.error) return { status: spawnFailure(result.error) };
261
+ if (result.signal) return { status: "resolver-timeout" };
262
+ if (result.status !== 0) return { status: "resolver-refused" };
263
+ if (!capture) return {
264
+ status: "resolved",
265
+ value: ""
266
+ };
267
+ const value = result.stdout.trim();
268
+ return value === "" ? { status: "resolver-refused" } : {
269
+ status: "resolved",
270
+ value
271
+ };
272
+ };
273
+ /**
274
+ * `op-fast` first, official `op` as the fallback, matching `doctor
275
+ * --preflight`'s resolvability probe. The reported failure is the first
276
+ * *informative* one: a machine without `op-fast` that has `op` installed and
277
+ * denied must report the denial, not the missing binary.
278
+ */
279
+ const resolveThrough = (reference, capture) => {
280
+ let failure = "resolver-missing";
281
+ for (const binary of SECRET_RESOLVER_BINARIES) {
282
+ const outcome = probe(binary, reference, capture);
283
+ if (outcome.status === "resolved") return outcome;
284
+ if (failure === "resolver-missing") failure = outcome.status;
285
+ }
286
+ return { status: failure };
287
+ };
288
+ const defaultSecretReferenceResolver = (reference) => resolveThrough(reference, true);
289
+ /**
290
+ * Environment first (the #312 emit-time path), then the operator's configured
291
+ * references. The first reference that fails ends the resolution: a second
292
+ * probe would add nothing but another chance to hang.
293
+ */
294
+ const resolveHqCredentials = ({ env, references, resolve = defaultSecretReferenceResolver }) => {
295
+ const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
296
+ const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
297
+ if (clientId && clientSecret) return {
298
+ credentials: {
299
+ clientId,
300
+ clientSecret
301
+ },
302
+ source: "environment",
303
+ status: "resolved"
304
+ };
305
+ if (!references) return {
306
+ failure: "no-reference",
307
+ status: "unresolved"
308
+ };
309
+ if (isHostedRunner(env)) return {
310
+ failure: "hosted-runner",
311
+ status: "unresolved"
312
+ };
313
+ const resolvedId = resolve(references.clientIdRef);
314
+ if (resolvedId.status !== "resolved") return {
315
+ failure: resolvedId.status,
316
+ reference: references.clientIdRef,
317
+ status: "unresolved"
318
+ };
319
+ const resolvedSecret = resolve(references.clientSecretRef);
320
+ if (resolvedSecret.status !== "resolved") return {
321
+ failure: resolvedSecret.status,
322
+ reference: references.clientSecretRef,
323
+ status: "unresolved"
324
+ };
325
+ return {
326
+ credentials: {
327
+ clientId: resolvedId.value,
328
+ clientSecret: resolvedSecret.value
329
+ },
330
+ source: "references",
331
+ status: "resolved"
332
+ };
333
+ };
334
+ //#endregion
335
+ //#region src/user-config.ts
336
+ const FACTORY_USER_CONFIG_SCHEMA_VERSION = 2;
337
+ const githubAppConfigSchema = z.object({
338
+ appId: z.union([z.string().min(1), z.number().int().positive()]),
339
+ installationId: z.number().int().positive().optional(),
340
+ privateKeyPath: z.string().min(1)
341
+ });
342
+ const hqAllowedOriginSchema = z.string().url().refine((value) => {
343
+ try {
344
+ const url = new URL(value);
345
+ return url.protocol === "https:" && url.username === "" && url.password === "" && value === url.origin;
346
+ } catch {
347
+ return false;
348
+ }
349
+ }, "Must be an HTTPS origin without credentials, path, query, or fragment");
350
+ const hqIngestCredentialReferencesSchema = z.object({
351
+ clientIdRef: z.string().min(1),
352
+ clientSecretRef: z.string().min(1)
353
+ });
354
+ const factoryUserConfigSchema = z.object({
355
+ githubApp: githubAppConfigSchema.optional(),
356
+ hqAllowedOrigins: z.array(hqAllowedOriginSchema).describe("Operator-controlled exact HTTPS origins authorized to receive HQ credential-bearing requests; an absent or empty list authorizes none.").optional(),
357
+ hqIngestCredentials: hqIngestCredentialReferencesSchema.describe("Operator-controlled secret-manager references for the HQ ingest Cloudflare Access service token. References only — the factory never reads, prints, exports, or caches the values.").optional(),
358
+ schemaVersion: z.literal(FACTORY_USER_CONFIG_SCHEMA_VERSION)
359
+ });
360
+ function defaultUserConfigPath(env = process.env) {
361
+ const configHome = env.XDG_CONFIG_HOME !== void 0 && env.XDG_CONFIG_HOME !== "" ? env.XDG_CONFIG_HOME : path.join(env.HOME !== void 0 && env.HOME !== "" ? env.HOME : os.homedir(), ".config");
362
+ return path.join(configHome, "patronage-factory", "config.json");
363
+ }
364
+ //#endregion
365
+ //#region src/hq-origin-policy.ts
366
+ /**
367
+ * Where the HQ Access service token may be sent, decided once.
368
+ *
369
+ * A request that carries the token goes only to an HTTPS origin with no
370
+ * embedded credentials that the operator listed, exactly, in
371
+ * `hqAllowedOrigins` in the operator user config. Committed repository data
372
+ * and the environment can name an origin; only the operator can authorize one
373
+ * (ADR 0015). HQ ingest and the Jev client both call this module, so the two
374
+ * cannot drift apart: one protocol rule, one reader, one parser.
375
+ *
376
+ * The reader is bounded. A config path that is a FIFO, a huge file, or a disk
377
+ * that hangs answers "no origin is authorized" within the caller's deadline
378
+ * instead of holding a gate or a lint run open.
379
+ */
380
+ /** Largest operator config or profile file the HQ readers will open. */
381
+ const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
382
+ const remainingMs = (deadline) => Math.max(0, deadline - performance.now());
383
+ const settleWithin = async (operation, budgetMs) => {
384
+ const settleOperation = async () => {
385
+ try {
386
+ return {
387
+ status: "fulfilled",
388
+ value: await operation
389
+ };
390
+ } catch (error) {
391
+ return {
392
+ error,
393
+ status: "rejected"
394
+ };
395
+ }
396
+ };
397
+ const timeoutAbort = new AbortController();
398
+ const settleTimeout = async () => {
399
+ try {
400
+ await setTimeout$1(Math.max(0, budgetMs), void 0, { signal: timeoutAbort.signal });
401
+ } catch {}
402
+ return { status: "timed-out" };
403
+ };
404
+ const result = await Promise.race([settleOperation(), settleTimeout()]);
405
+ timeoutAbort.abort();
406
+ return result;
407
+ };
408
+ const readBoundedTextFile = async (filePath, deadline, maxBytes = MAX_HQ_CONFIG_BYTES) => {
409
+ const metadata = await settleWithin(stat(filePath), remainingMs(deadline));
410
+ if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.size > maxBytes) return;
411
+ const budget = remainingMs(deadline);
412
+ if (budget <= 0) return;
413
+ const abort = new AbortController();
414
+ const abortTimer = setTimeout(() => abort.abort(), budget);
415
+ const contents = await settleWithin(readFile(filePath, {
416
+ encoding: "utf-8",
417
+ signal: abort.signal
418
+ }), budget);
419
+ clearTimeout(abortTimer);
420
+ if (contents.status !== "fulfilled" || Buffer.byteLength(contents.value, "utf-8") > maxBytes) return;
421
+ return contents.value;
422
+ };
423
+ /**
424
+ * The protocol rule: an `https:` URL with no username and no password. There
425
+ * is no `http:` exception, loopback included. The caller composes its own path
426
+ * onto the returned URL's `origin`.
427
+ */
428
+ const validatedHqOrigin = (value) => {
429
+ try {
430
+ const endpoint = new URL(value);
431
+ if (endpoint.protocol !== "https:" || endpoint.username !== "" || endpoint.password !== "") return;
432
+ return endpoint;
433
+ } catch {
434
+ return;
435
+ }
436
+ };
437
+ /**
438
+ * The operator's `hqAllowedOrigins`, from the text of the operator user
439
+ * config. A file that is not JSON or not a valid user config authorizes
440
+ * nothing: the list is empty, never partly read.
441
+ */
442
+ const hqAllowedOriginsFromConfigText = (text) => {
443
+ try {
444
+ const parsed = factoryUserConfigSchema.safeParse(JSON.parse(text));
445
+ return parsed.success ? parsed.data.hqAllowedOrigins ?? [] : [];
446
+ } catch {
447
+ return [];
448
+ }
449
+ };
450
+ /**
451
+ * The operator's `hqAllowedOrigins`, read from the operator user config within
452
+ * `deadline` (a `performance.now()` time). A missing, unreadable, oversized or
453
+ * invalid file, or one that does not answer in time, authorizes nothing.
454
+ */
455
+ const loadHqAllowedOrigins = async (env, deadline) => {
456
+ let configPath;
457
+ try {
458
+ configPath = defaultUserConfigPath(env);
459
+ } catch {
460
+ return [];
461
+ }
462
+ const contents = await readBoundedTextFile(configPath, deadline);
463
+ return contents === void 0 ? [] : hqAllowedOriginsFromConfigText(contents);
464
+ };
465
+ /**
466
+ * The matching rule: the URL's origin must equal one listed origin exactly.
467
+ * No prefix, wildcard, or host-only match.
468
+ */
469
+ const isHqOriginAuthorized = (allowedOrigins, endpoint) => allowedOrigins.includes(endpoint.origin);
470
+ //#endregion
471
+ //#region src/jev/client.ts
472
+ /**
473
+ * The one Jev gateway: HQ (ADR 0033).
474
+ *
475
+ * There is no fallback provider and no direct mode "for local dev". This
476
+ * module knows Jev's request schema and how to reach HQ; it does not know
477
+ * Cloudflare, TypeSafe, accounts, or model versions. HQ names the model and
478
+ * returns the one that actually ran.
479
+ *
480
+ * `HQ_INGEST_URL` names the origin (#259). The origin is authorized before
481
+ * anything else happens, by the same code HQ ingest uses
482
+ * (`hq-origin-policy.ts`, ADR 0015): it must be an `https:` origin with no
483
+ * embedded credentials, listed exactly in the operator's `hqAllowedOrigins`.
484
+ * Only then are credentials read: `resolveHqCredentials` reads the hyphenated
485
+ * `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair, and
486
+ * `buildCloudflareAccessRequestInit` attaches them to a request that refuses
487
+ * redirects so the credentials cannot leave the authorized origin.
488
+ *
489
+ * Credentials are read from the environment only. A diagnostic must not reach
490
+ * into the operator's secret manager: `hq:flush` is the deliberate command
491
+ * allowed to do that, and a session that has already loaded the pair for a
492
+ * factory command has it here too. So `resolveHqCredentials` is called with no
493
+ * references, and the remedy this module prints is the one that actually
494
+ * applies — load the variables — never "record references in the user config",
495
+ * which would do nothing for this command.
496
+ */
497
+ /** The environment variable that carries HQ's origin, same as ingest. */
498
+ const HQ_ORIGIN_ENV = "HQ_INGEST_URL";
499
+ /** HQ's Jev boundary, composed onto the origin. */
500
+ const JEV_ROUTE_PATH = "/api/jev";
501
+ /** Per-attempt ceiling. A 32k-token packet is a slow request, not a hung one. */
502
+ const JEV_REQUEST_TIMEOUT_MS = 6e4;
503
+ /**
504
+ * Ceiling for reading the operator user config. A config path that hangs
505
+ * authorizes nothing rather than holding the run open.
506
+ */
507
+ const ORIGIN_POLICY_READ_MS = 5e3;
508
+ /**
509
+ * An upstream refusal, carried with the code HQ passed through rather than
510
+ * flattened into prose. `max_tokens_exceeded` is the one a caller acts on: it
511
+ * means the packet was too large despite the local budget.
512
+ */
513
+ var JevRequestError = class extends Error {
514
+ code;
515
+ httpStatus;
516
+ constructor(httpStatus, code, message) {
517
+ super(message);
518
+ this.name = "JevRequestError";
519
+ this.code = code;
520
+ this.httpStatus = httpStatus;
521
+ }
522
+ };
523
+ /**
524
+ * A code is a short machine token. Anything else in that field is prose the
525
+ * upstream chose, and prose is exactly what must not travel into a report.
526
+ */
527
+ const CODE_SHAPE = /^[a-z0-9_.-]{1,64}$/iu;
528
+ /** Reads the upstream error code out of whatever shape the body arrived in. */
529
+ const rawErrorCode = (body) => {
530
+ if (body === null || typeof body !== "object") return;
531
+ const record = body;
532
+ if (typeof record.code === "string") return record.code;
533
+ if (typeof record.error === "string") return record.error;
534
+ if (record.error !== null && typeof record.error === "object") {
535
+ const nested = record.error.code;
536
+ return typeof nested === "string" ? nested : void 0;
537
+ }
538
+ };
539
+ /**
540
+ * The only thing kept from a refusal body.
541
+ *
542
+ * An upstream 400 routinely quotes the state it rejected, and this state is
543
+ * repository source. That message used to be copied into the request record and
544
+ * into every affected case's reasons, so an ordinary run without
545
+ * `--include-evidence` could print source back out. Status and a code-shaped
546
+ * token are enough to act on — `max_tokens_exceeded` is the one a caller does
547
+ * anything about — and a field that is not code-shaped is dropped rather than
548
+ * trimmed, because a truncated leak is still a leak.
549
+ */
550
+ const errorCode = (body) => {
551
+ const raw = rawErrorCode(body);
552
+ return raw !== void 0 && CODE_SHAPE.test(raw) ? raw : void 0;
553
+ };
554
+ /**
555
+ * How to name a rejected origin without repeating it.
556
+ *
557
+ * Every `unavailable` reason reaches `statusReason`, each case's `reasons`, and
558
+ * both the JSON and Markdown renderings — none of which is redacted anywhere.
559
+ * An `HQ_INGEST_URL` carrying userinfo is exactly the value that must not be
560
+ * echoed, and it is exactly the value being rejected, so the diagnostic is
561
+ * limited to scheme and host. A value that does not parse is not quoted at all:
562
+ * there is no structure to trim it down to.
563
+ */
564
+ const describeOrigin = (origin) => {
565
+ const parsed = URL.parse(origin);
566
+ return parsed === null ? "(not a URL; the value is withheld because it could not be parsed to strip credentials)" : `${parsed.protocol}//${parsed.host}`;
567
+ };
568
+ /**
569
+ * HQ's Jev endpoint for a configured origin, or `undefined` when the value
570
+ * breaks HQ ingest's protocol rule (`validatedHqOrigin`: `https:` only, no
571
+ * embedded credentials, no `http:` exception).
572
+ *
573
+ * The path is composed onto the URL's *origin*, which is what both existing HQ
574
+ * readers do and what makes the documented misconfiguration harmless: the
575
+ * operator who sets `HQ_INGEST_URL` to the profile's `/api/ingest` endpoint
576
+ * instead of the origin (#259) still reaches `/api/jev`, not
577
+ * `/api/ingest/api/jev`.
578
+ */
579
+ const jevEndpointFor = (origin) => {
580
+ const parsed = validatedHqOrigin(origin);
581
+ return parsed === void 0 ? void 0 : new URL(JEV_ROUTE_PATH, parsed.origin).href;
582
+ };
583
+ /**
584
+ * Whether an upstream token is this request's own credential handed back.
585
+ *
586
+ * Narrow on purpose. A model id and an error code are opaque tokens HQ names,
587
+ * so they cannot be pattern-matched for secrets in general, and general secret
588
+ * scanning is not wanted here: HQ already holds both the credential and the
589
+ * source, so a hostile HQ is outside the threat model. What *is* cheap and
590
+ * exact is the comparison this function makes — the credential values are in
591
+ * scope at the one place a response is read, so a field that echoes one back
592
+ * can be recognized by equality rather than by guesswork.
593
+ */
594
+ const reflectsCredential = (value, token) => value.includes(token.clientId) || value.includes(token.clientSecret);
595
+ /**
596
+ * The whole exchange, and the only place an upstream body is touched.
597
+ *
598
+ * Fetching, reading the body, parsing it, and validating it all happen inside
599
+ * one boundary with one outer catch, because the leak this shape prevents kept
600
+ * coming back by a different route each time: first the refusal body, then the
601
+ * transport message, then the body stream, then a schema issue. Each of those
602
+ * is a distinct throw site, and patching them one at a time is a losing game.
603
+ *
604
+ * The rule is therefore structural, not per-site: **everything that leaves here
605
+ * is a `JevRequestError` whose message is a fixed string, plus an HTTP status,
606
+ * plus a code-shaped token.** Nothing derived from an upstream value —
607
+ * `error.message`, `error.cause`, response text, or a validation issue — is
608
+ * ever read into a message. A throw this function does not recognize becomes
609
+ * one fixed sentence rather than being inspected.
610
+ */
611
+ const exchange = async (input) => {
612
+ const init = buildCloudflareAccessRequestInit(input.token, {
613
+ body: input.serializedBody,
614
+ headers: { "content-type": "application/json" },
615
+ method: "POST",
616
+ signal: AbortSignal.timeout(JEV_REQUEST_TIMEOUT_MS)
617
+ });
618
+ try {
619
+ let response;
620
+ try {
621
+ response = await input.fetchImpl(input.endpoint, init);
622
+ } catch (error) {
623
+ const timedOut = error instanceof Error && error.name === "TimeoutError";
624
+ throw new JevRequestError(0, timedOut ? "timeout" : "unreachable", timedOut ? `HQ Jev at ${input.endpoint} did not answer within ${JEV_REQUEST_TIMEOUT_MS}ms.` : `HQ Jev at ${input.endpoint} could not be reached. Redirects are refused to protect Cloudflare Access credentials.`);
625
+ }
626
+ const text = await response.text();
627
+ let payload;
628
+ try {
629
+ payload = JSON.parse(text);
630
+ } catch {
631
+ payload = void 0;
632
+ }
633
+ if (!response.ok) {
634
+ const reported = errorCode(payload);
635
+ const code = reported !== void 0 && reflectsCredential(reported, input.token) ? void 0 : reported;
636
+ throw new JevRequestError(response.status, code, `HQ Jev refused the request with HTTP ${response.status}${code === void 0 ? "" : ` (${code})`}. The response body is not reported: an upstream validation error can quote the state it rejected, which is repository source.`);
637
+ }
638
+ const parsed = parseJevResponse(payload);
639
+ const body = parsed !== void 0 && reflectsCredential(parsed.model, input.token) ? {
640
+ ...parsed,
641
+ model: UNKNOWN_MODEL
642
+ } : parsed;
643
+ if (body === void 0) throw new JevRequestError(response.status, "malformed_response", "HQ Jev returned a body that is not Jev's response shape. The body is not reported.");
644
+ return body;
645
+ } catch (error) {
646
+ if (error instanceof JevRequestError) throw error;
647
+ throw new JevRequestError(0, "exchange_failed", "The HQ Jev exchange failed before a response could be read. No upstream text is reported: it can quote the request.");
648
+ }
649
+ };
650
+ /**
651
+ * The whole remedy, in one sentence. The variable names are literally
652
+ * hyphenated, so a plain `export` does not set them.
653
+ */
654
+ const MISSING_CREDENTIALS_REASON = `no HQ credentials: ${CF_ACCESS_CLIENT_ID_ENV} and ${CF_ACCESS_CLIENT_SECRET_ENV} are not both in the environment. The names are literally hyphenated, so a plain \`export\` does not set them — prefix the command: \`env '${CF_ACCESS_CLIENT_ID_ENV}=…' '${CF_ACCESS_CLIENT_SECRET_ENV}=…' <the command> …\`.`;
655
+ /** Where the operator config was looked for, for a message that names it. */
656
+ const operatorConfigLocation = (env) => {
657
+ try {
658
+ return ` (${defaultUserConfigPath(env)})`;
659
+ } catch {
660
+ return "";
661
+ }
662
+ };
663
+ /**
664
+ * Why an origin is not authorized. An empty list and an unlisted origin are
665
+ * different remedies, so they are different sentences. A config that is
666
+ * missing, unreadable or invalid reads as an empty list, exactly as it does
667
+ * for HQ ingest.
668
+ */
669
+ const unauthorizedOriginReason = (allowedOrigins, origin, env) => allowedOrigins.length === 0 ? `no HQ origin is authorized: the operator user config${operatorConfigLocation(env)} has no hqAllowedOrigins, or it is missing or cannot be read. List ${origin} there, as HQ ingest requires, before Jev sends credentials or source to it.` : `HQ origin ${origin} is not authorized by hqAllowedOrigins in the operator user config${operatorConfigLocation(env)}. List the exact origin there, as HQ ingest requires, before Jev sends credentials or source to it.`;
670
+ /**
671
+ * Resolves the gateway, or says why there isn't one. Every `unavailable`
672
+ * reason is an operator-facing sentence naming a remedy that applies to this
673
+ * command, and none of them can carry a credential: nothing here reads a
674
+ * resolved value into a message.
675
+ *
676
+ * The order is the policy. The origin is checked against HQ ingest's protocol
677
+ * rule and the operator's `hqAllowedOrigins` first, and only an authorized
678
+ * origin gets as far as reading credentials. An unauthorized origin never sees
679
+ * a request.
680
+ */
681
+ const resolveJevGateway = async ({ env, fetchImpl = fetch }) => {
682
+ const origin = env[HQ_ORIGIN_ENV]?.trim();
683
+ if (!origin) return {
684
+ reason: `no HQ origin: ${HQ_ORIGIN_ENV} is unset. It wants the origin, for example https://hq.patronage.com.`,
685
+ status: "unavailable"
686
+ };
687
+ const endpoint = jevEndpointFor(origin);
688
+ if (endpoint === void 0) return {
689
+ reason: `${HQ_ORIGIN_ENV} must be an https origin without embedded credentials, as HQ ingest requires: ${describeOrigin(origin)}`,
690
+ status: "unavailable"
691
+ };
692
+ const endpointUrl = new URL(endpoint);
693
+ const allowedOrigins = await loadHqAllowedOrigins(env, performance.now() + ORIGIN_POLICY_READ_MS);
694
+ if (!isHqOriginAuthorized(allowedOrigins, endpointUrl)) return {
695
+ reason: unauthorizedOriginReason(allowedOrigins, endpointUrl.origin, env),
696
+ status: "unavailable"
697
+ };
698
+ const resolution = resolveHqCredentials({ env });
699
+ if (resolution.status !== "resolved") return {
700
+ reason: MISSING_CREDENTIALS_REASON,
701
+ status: "unavailable"
702
+ };
703
+ return {
704
+ endpoint,
705
+ evaluate: (serializedBody) => exchange({
706
+ endpoint,
707
+ fetchImpl,
708
+ serializedBody,
709
+ token: resolution.credentials
710
+ }),
711
+ status: "ready"
712
+ };
713
+ };
714
+ //#endregion
715
+ //#region src/jev/request.ts
716
+ /**
717
+ * The per-request ceiling, in tokens. Jev's context is the constraint this
718
+ * bounds; 32k is the size the epic designed to, and the headroom below covers
719
+ * the estimator's error rather than pretending it has none.
720
+ */
721
+ const JEV_REQUEST_TOKEN_BUDGET = 32e3;
722
+ /** Fraction of the budget a request may fill. The rest absorbs estimator error. */
723
+ const BUDGET_HEADROOM = .9;
724
+ /** JSON punctuation the per-part sizes do not account for. */
725
+ const ENVELOPE_OVERHEAD_BYTES = 128;
726
+ const BYTES_PER_TOKEN = 4;
727
+ /**
728
+ * Tokens from bytes, at the conventional four-bytes-per-token ratio.
729
+ *
730
+ * A real tokenizer would be a heavy dependency and a second thing to keep in
731
+ * step with whatever model HQ routes to — and the factory holds no model
732
+ * vocabulary by design (ADR 0033). An over-estimate costs an extra split; the
733
+ * headroom above covers an under-estimate. It is a budget, not a measurement.
734
+ */
735
+ const tokensFor = (bytes) => Math.ceil(bytes / BYTES_PER_TOKEN);
736
+ /**
737
+ * JSON with object keys in sorted order, so the same logical request always
738
+ * serializes to the same bytes — and therefore the same hash, and therefore
739
+ * the same cache entry. Two runs that assembled their state in a different
740
+ * order must not look like two different requests.
741
+ */
742
+ const stableStringify = (value) => JSON.stringify(value, (_key, nested) => {
743
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested)) return nested;
744
+ const source = nested;
745
+ return Object.fromEntries(Object.keys(source).toSorted().map((key) => [key, source[key]]));
746
+ });
747
+ const byteSize = (value) => Buffer.byteLength(stableStringify(value), "utf-8");
748
+ /** What one match adds to a request: its state entry plus its questions. */
749
+ const matchCost = (match) => byteSize({ [match.key]: match.state }) + byteSize(match.questions);
750
+ /** What a request costs before any match is in it. */
751
+ const baseCost = (file) => ENVELOPE_OVERHEAD_BYTES + byteSize({ path: file.path }) + byteSize({ file: file.shared });
752
+ const buildBody = (file, matches) => {
753
+ const matchStates = Object.fromEntries(matches.map((match) => [match.key, match.state]));
754
+ return {
755
+ questions: Object.fromEntries(matches.flatMap((match) => Object.entries(match.questions))),
756
+ state: {
757
+ file: file.shared,
758
+ matches: matchStates,
759
+ path: file.path
760
+ }
761
+ };
762
+ };
763
+ const toRequest = (file, matches) => {
764
+ const body = buildBody(file, matches);
765
+ const serialized = stableStringify(body);
766
+ return {
767
+ body,
768
+ estimatedTokens: tokensFor(Buffer.byteLength(serialized, "utf-8")),
769
+ hash: createHash("sha256").update(serialized).digest("hex"),
770
+ matchKeys: matches.map((match) => match.key),
771
+ path: file.path,
772
+ serialized
773
+ };
774
+ };
775
+ /**
776
+ * Turns files into the requests that will be sent, plus the matches that
777
+ * cannot be sent and why. Pure: it performs no I/O and decides nothing about
778
+ * transport.
779
+ */
780
+ const buildJevRequests = ({ budgetTokens = JEV_REQUEST_TOKEN_BUDGET, files }) => {
781
+ const ceiling = Math.floor(budgetTokens * BUDGET_HEADROOM);
782
+ const ceilingBytes = ceiling * BYTES_PER_TOKEN;
783
+ const requests = [];
784
+ const unavailable = [];
785
+ for (const file of files) {
786
+ const base = baseCost(file);
787
+ let open = [];
788
+ let openBytes = base;
789
+ const flush = () => {
790
+ if (open.length > 0) {
791
+ requests.push(toRequest(file, open));
792
+ open = [];
793
+ openBytes = base;
794
+ }
795
+ };
796
+ for (const match of file.matches) {
797
+ const cost = matchCost(match);
798
+ if (openBytes + cost <= ceilingBytes) {
799
+ open.push(match);
800
+ openBytes += cost;
801
+ continue;
802
+ }
803
+ flush();
804
+ if (openBytes + cost <= ceilingBytes) {
805
+ open.push(match);
806
+ openBytes += cost;
807
+ continue;
808
+ }
809
+ unavailable.push({
810
+ key: match.key,
811
+ reason: `this match does not fit one Jev request: ${file.path} plus this match is estimated at ${tokensFor(base + cost)} tokens against a ${ceiling}-token ceiling`
812
+ });
813
+ }
814
+ flush();
815
+ }
816
+ return {
817
+ requests,
818
+ unavailable
819
+ };
820
+ };
821
+ //#endregion
822
+ //#region src/jev/run.ts
823
+ const describe = (error) => error instanceof Error ? error.message : String(error);
824
+ /**
825
+ * Matches one match's answers to the questions it asked.
826
+ *
827
+ * `projectAnswer` does the work: it rebuilds each answer from the question
828
+ * that asked for it, so an answer of the wrong kind, a `choice` label nobody
829
+ * offered, and an upstream-invented probability key are each refused or
830
+ * dropped rather than carried. A refusal costs one match — it becomes
831
+ * unavailable with a reason — instead of throwing later, deeper, and taking a
832
+ * whole report's correctly answered siblings with it.
833
+ */
834
+ const routeAnswers = (questions, answers) => {
835
+ const routed = /* @__PURE__ */ new Map();
836
+ const missing = [];
837
+ const mismatched = [];
838
+ for (const [name, question] of Object.entries(questions)) {
839
+ const answer = answers[name];
840
+ if (answer === void 0) {
841
+ missing.push(name);
842
+ continue;
843
+ }
844
+ const projected = projectAnswer(question, answer);
845
+ if (projected.reason === void 0) routed.set(name, projected.answer);
846
+ else mismatched.push(`${name} (${projected.reason})`);
847
+ }
848
+ if (missing.length > 0) return { reason: `Jev answered the request but omitted ${missing.join(", ")}` };
849
+ if (mismatched.length > 0) return { reason: `answer type mismatch: ${mismatched.join(", ")}` };
850
+ return { answers: Object.fromEntries(routed) };
851
+ };
852
+ const completionOf = (answered, unanswered) => {
853
+ if (unanswered === 0) return "complete";
854
+ return answered === 0 ? "unavailable" : "partial";
855
+ };
856
+ /** Counters copied one by one, never the parsed object: this record is printed. */
857
+ const copyUsage = (usage) => {
858
+ const copied = {};
859
+ if (typeof usage.input_tokens === "number") copied.input_tokens = usage.input_tokens;
860
+ if (typeof usage.output_tokens === "number") copied.output_tokens = usage.output_tokens;
861
+ return copied;
862
+ };
863
+ /**
864
+ * One request's answer, from the cache when the exact bytes are already
865
+ * answered and from HQ otherwise. A live answer is stored on the way back, so
866
+ * the next identical request costs nothing.
867
+ */
868
+ const answerFor = async (gateway, cache, serialized) => {
869
+ const stored = cache?.read(serialized);
870
+ if (stored !== void 0) return {
871
+ cached: true,
872
+ response: stored
873
+ };
874
+ const response = await gateway.evaluate(serialized);
875
+ cache?.write(serialized, response);
876
+ return {
877
+ cached: false,
878
+ response
879
+ };
880
+ };
881
+ /** Writes what HQ reported about the call itself onto the request's record. */
882
+ const recordResponse = (record, response, models) => {
883
+ record.model = response.model;
884
+ if (response.usage !== void 0) record.usage = copyUsage(response.usage);
885
+ if (!models.includes(response.model)) models.push(response.model);
886
+ };
887
+ /**
888
+ * Builds the requests, sends the ones the cache does not already answer, and
889
+ * routes answers back to the matches that asked for them.
890
+ *
891
+ * Requests go one at a time. Bounded load is the point: a repository-wide run
892
+ * would otherwise open dozens of concurrent 32k-token requests against one HQ
893
+ * route for a diagnostic nothing is waiting on.
894
+ */
895
+ const runJevFiles = async ({ budgetTokens, cache, files, gateway }) => {
896
+ const plan = buildJevRequests({
897
+ ...budgetTokens === void 0 ? {} : { budgetTokens },
898
+ files
899
+ });
900
+ const questionsByMatch = /* @__PURE__ */ new Map();
901
+ for (const file of files) for (const match of file.matches) questionsByMatch.set(match.key, match.questions);
902
+ const answers = /* @__PURE__ */ new Map();
903
+ const unavailable = /* @__PURE__ */ new Map();
904
+ const requests = [];
905
+ const models = [];
906
+ for (const { key, reason } of plan.unavailable) unavailable.set(key, reason);
907
+ if (gateway.status !== "ready") {
908
+ for (const request of plan.requests) for (const key of request.matchKeys) unavailable.set(key, gateway.reason);
909
+ return {
910
+ answersByMatchKey: {},
911
+ completion: completionOf(0, unavailable.size),
912
+ elapsedMs: 0,
913
+ models,
914
+ requests,
915
+ unavailableByMatchKey: Object.fromEntries(unavailable)
916
+ };
917
+ }
918
+ const runStarted = Date.now();
919
+ for (const request of plan.requests) {
920
+ const record = {
921
+ cached: false,
922
+ elapsedMs: 0,
923
+ estimatedTokens: request.estimatedTokens,
924
+ matchKeys: request.matchKeys,
925
+ path: request.path,
926
+ requestHash: request.hash
927
+ };
928
+ const started = Date.now();
929
+ try {
930
+ const { cached, response } = await answerFor(gateway, cache, request.serialized);
931
+ record.cached = cached;
932
+ recordResponse(record, response, models);
933
+ for (const matchKey of request.matchKeys) {
934
+ const routed = routeAnswers(questionsByMatch.get(matchKey) ?? {}, response.answers);
935
+ if (routed.reason === void 0) answers.set(matchKey, routed.answers);
936
+ else unavailable.set(matchKey, routed.reason);
937
+ }
938
+ } catch (error) {
939
+ record.error = describe(error);
940
+ if (error instanceof JevRequestError && error.code !== void 0) record.errorCode = error.code;
941
+ for (const matchKey of request.matchKeys) unavailable.set(matchKey, record.error);
942
+ }
943
+ record.elapsedMs = Date.now() - started;
944
+ requests.push(record);
945
+ }
946
+ return {
947
+ answersByMatchKey: Object.fromEntries(answers),
948
+ completion: completionOf(answers.size, unavailable.size),
949
+ elapsedMs: Date.now() - runStarted,
950
+ models,
951
+ requests,
952
+ unavailableByMatchKey: Object.fromEntries(unavailable)
953
+ };
954
+ };
955
+ //#endregion
956
+ //#region src/oxlint-jev/bridge.ts
957
+ /**
958
+ * A result in which nothing was answered, for the failures that happen outside
959
+ * the subprocess's own reporting — it could not start, it died, or what it
960
+ * printed was not a result.
961
+ */
962
+ const allUnavailable = (files, reason) => ({
963
+ answersByMatchKey: {},
964
+ completion: "unavailable",
965
+ elapsedMs: 0,
966
+ models: [],
967
+ requests: [],
968
+ unavailableByMatchKey: Object.fromEntries(files.flatMap((file) => file.matches.map((match) => [match.key, reason])))
969
+ });
970
+ /**
971
+ * `worker.ts` in a source checkout, `worker.js` in the published package. The
972
+ * extension is read from this module's own path, so the source layout and the
973
+ * built one stay in step without a build-time constant. The build keeps the
974
+ * two files side by side in `dist/oxlint-jev/` for the same reason.
975
+ */
976
+ const workerPath = () => path.join(import.meta.dirname, `worker${path.extname(import.meta.filename)}`);
977
+ /** Room for a whole file's requests and answers. The 1 MiB default is not. */
978
+ const MAX_OUTPUT_BYTES = 64 * 1024 * 1024;
979
+ /**
980
+ * Runs one file's requests in a subprocess and returns what happened.
981
+ *
982
+ * Nothing the subprocess wrote is read into a reason. Its stderr can carry a
983
+ * stack that quotes the request, and the request is repository source
984
+ * (`src/jev/client.ts` holds the same rule for upstream bodies), so a failure
985
+ * this function cannot parse becomes one fixed sentence plus the exit status.
986
+ */
987
+ const spawnJevBridge = ({ cacheDirectory, files }) => {
988
+ const child = spawnSync(process.execPath, [workerPath()], {
989
+ encoding: "utf-8",
990
+ input: JSON.stringify({
991
+ cacheDirectory,
992
+ files
993
+ }),
994
+ maxBuffer: MAX_OUTPUT_BYTES
995
+ });
996
+ if (child.error !== void 0 || child.status !== 0) {
997
+ const code = child.error?.code;
998
+ return allUnavailable(files, `the Jev worker exited with status ${String(child.status)}, signal ${String(child.signal)}${code === void 0 ? "" : `, spawn error ${code}`}. Its output is not reported: it can quote the request, which is repository source.`);
999
+ }
1000
+ try {
1001
+ return JSON.parse(child.stdout);
1002
+ } catch {
1003
+ return allUnavailable(files, "the Jev worker did not print a result. Its output is not reported: it can quote the request, which is repository source.");
1004
+ }
1005
+ };
1006
+ //#endregion
1007
+ export { resolveJevGateway as a, UNKNOWN_MODEL as c, buildJevRequests as i, noul as l, spawnJevBridge as n, defaultJevCacheDirectory as o, runJevFiles as r, openJevCache as s, allUnavailable as t };