@patronage/software-factory 1.0.0-alpha.35 → 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.
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
- import { t as __exportAll } from "./chunk-pbuEa-1d.js";
1
+ import { t as __exportAll } from "./chunk-DrSxFLj_.js";
2
+ import { createRequire } from "node:module";
2
3
  import { Command, InvalidArgumentError } from "commander";
3
4
  import path from "node:path";
4
5
  import crypto, { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -18,9 +19,10 @@ import { Worker } from "node:worker_threads";
18
19
  import { parse } from "yaml";
19
20
  import http from "node:http";
20
21
  import { pathToFileURL } from "node:url";
22
+ import { choice, noul, score } from "@typesafe-ai/sdk";
21
23
  //#region package.json
22
24
  var name = "@patronage/software-factory";
23
- var version = "1.0.0-alpha.35";
25
+ var version = "1.0.0-alpha.36";
24
26
  //#endregion
25
27
  //#region src/cli-entry.ts
26
28
  /**
@@ -1517,6 +1519,15 @@ function normalizeRepositoryIdentity(repository) {
1517
1519
  owner: repository.owner.toLowerCase()
1518
1520
  };
1519
1521
  }
1522
+ function normalizeRepositorySlug(slug) {
1523
+ const segments = slug.split("/");
1524
+ const [owner, name] = segments;
1525
+ if (!(owner && name) || segments.length !== 2) return;
1526
+ return repositorySlug(normalizeRepositoryIdentity({
1527
+ name,
1528
+ owner
1529
+ }));
1530
+ }
1520
1531
  const GITHUB_HOSTNAME = "github.com";
1521
1532
  const SCP_GITHUB_REMOTE_PATTERN = /^(?:[^@\s]+@)?github\.com:(?<path>[^\s]+)$/iu;
1522
1533
  function githubRepositoryPath(remoteUrl) {
@@ -2344,7 +2355,7 @@ const concurrentWriteMessage = (output, cause) => {
2344
2355
  const LOCK_REMOVAL_INSTRUCTION = "if that writer is gone, remove the file and retry";
2345
2356
  const DEFAULT_LOCK_WAIT_MS = 5e3;
2346
2357
  const LOCK_POLL_MS = 20;
2347
- const errorCode = (error) => error?.code;
2358
+ const errorCode$1 = (error) => error?.code;
2348
2359
  const sleepSync = (ms) => {
2349
2360
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
2350
2361
  };
@@ -2383,7 +2394,7 @@ const describeHeldProofLock = (output, fs = nodeProofWriteFileSystem, now = Date
2383
2394
  try {
2384
2395
  ({mtimeMs} = fs.statSync(lockPath));
2385
2396
  } catch (error) {
2386
- if (errorCode(error) === "ENOENT") return;
2397
+ if (errorCode$1(error) === "ENOENT") return;
2387
2398
  throw error;
2388
2399
  }
2389
2400
  let contents = "";
@@ -2414,7 +2425,7 @@ const acquireProofLock = (output, fs, { lockWaitMs = DEFAULT_LOCK_WAIT_MS, repor
2414
2425
  fs.closeSync(fs.openSync(lockPath, "wx"));
2415
2426
  return true;
2416
2427
  } catch (error) {
2417
- if (errorCode(error) !== "EEXIST") throw error;
2428
+ if (errorCode$1(error) !== "EEXIST") throw error;
2418
2429
  return false;
2419
2430
  }
2420
2431
  };
@@ -2432,7 +2443,7 @@ const acquireProofLock = (output, fs, { lockWaitMs = DEFAULT_LOCK_WAIT_MS, repor
2432
2443
  try {
2433
2444
  current = fs.readFileSync(lockPath, "utf-8");
2434
2445
  } catch (error) {
2435
- report(errorCode(error) === "ENOENT" ? `pr-proof: lock ${lockPath} was removed by someone else before this writer released it; nothing to release.` : `pr-proof: lock ${lockPath} could not be read on release (${String(error)}); left in place.`);
2446
+ report(errorCode$1(error) === "ENOENT" ? `pr-proof: lock ${lockPath} was removed by someone else before this writer released it; nothing to release.` : `pr-proof: lock ${lockPath} could not be read on release (${String(error)}); left in place.`);
2436
2447
  return;
2437
2448
  }
2438
2449
  if (current !== mine) {
@@ -4005,6 +4016,112 @@ function tryUserConfig(input = {}) {
4005
4016
  function formatUserConfigError(configPath, error) {
4006
4017
  return `User config is invalid: ${configPath}: ${formatZodIssues(error)}`;
4007
4018
  }
4019
+ //#endregion
4020
+ //#region src/hq-origin-policy.ts
4021
+ /**
4022
+ * Where the HQ Access service token may be sent, decided once.
4023
+ *
4024
+ * A request that carries the token goes only to an HTTPS origin with no
4025
+ * embedded credentials that the operator listed, exactly, in
4026
+ * `hqAllowedOrigins` in the operator user config. Committed repository data
4027
+ * and the environment can name an origin; only the operator can authorize one
4028
+ * (ADR 0015). HQ ingest and the Jev client both call this module, so the two
4029
+ * cannot drift apart: one protocol rule, one reader, one parser.
4030
+ *
4031
+ * The reader is bounded. A config path that is a FIFO, a huge file, or a disk
4032
+ * that hangs answers "no origin is authorized" within the caller's deadline
4033
+ * instead of holding a gate or a lint run open.
4034
+ */
4035
+ /** Largest operator config or profile file the HQ readers will open. */
4036
+ const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
4037
+ const remainingMs = (deadline) => Math.max(0, deadline - performance.now());
4038
+ const settleWithin = async (operation, budgetMs) => {
4039
+ const settleOperation = async () => {
4040
+ try {
4041
+ return {
4042
+ status: "fulfilled",
4043
+ value: await operation
4044
+ };
4045
+ } catch (error) {
4046
+ return {
4047
+ error,
4048
+ status: "rejected"
4049
+ };
4050
+ }
4051
+ };
4052
+ const timeoutAbort = new AbortController();
4053
+ const settleTimeout = async () => {
4054
+ try {
4055
+ await setTimeout$1(Math.max(0, budgetMs), void 0, { signal: timeoutAbort.signal });
4056
+ } catch {}
4057
+ return { status: "timed-out" };
4058
+ };
4059
+ const result = await Promise.race([settleOperation(), settleTimeout()]);
4060
+ timeoutAbort.abort();
4061
+ return result;
4062
+ };
4063
+ const readBoundedTextFile = async (filePath, deadline, maxBytes = MAX_HQ_CONFIG_BYTES) => {
4064
+ const metadata = await settleWithin(stat(filePath), remainingMs(deadline));
4065
+ if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.size > maxBytes) return;
4066
+ const budget = remainingMs(deadline);
4067
+ if (budget <= 0) return;
4068
+ const abort = new AbortController();
4069
+ const abortTimer = setTimeout(() => abort.abort(), budget);
4070
+ const contents = await settleWithin(readFile(filePath, {
4071
+ encoding: "utf-8",
4072
+ signal: abort.signal
4073
+ }), budget);
4074
+ clearTimeout(abortTimer);
4075
+ if (contents.status !== "fulfilled" || Buffer.byteLength(contents.value, "utf-8") > maxBytes) return;
4076
+ return contents.value;
4077
+ };
4078
+ /**
4079
+ * The protocol rule: an `https:` URL with no username and no password. There
4080
+ * is no `http:` exception, loopback included. The caller composes its own path
4081
+ * onto the returned URL's `origin`.
4082
+ */
4083
+ const validatedHqOrigin = (value) => {
4084
+ try {
4085
+ const endpoint = new URL(value);
4086
+ if (endpoint.protocol !== "https:" || endpoint.username !== "" || endpoint.password !== "") return;
4087
+ return endpoint;
4088
+ } catch {
4089
+ return;
4090
+ }
4091
+ };
4092
+ /**
4093
+ * The operator's `hqAllowedOrigins`, from the text of the operator user
4094
+ * config. A file that is not JSON or not a valid user config authorizes
4095
+ * nothing: the list is empty, never partly read.
4096
+ */
4097
+ const hqAllowedOriginsFromConfigText = (text) => {
4098
+ try {
4099
+ const parsed = factoryUserConfigSchema.safeParse(JSON.parse(text));
4100
+ return parsed.success ? parsed.data.hqAllowedOrigins ?? [] : [];
4101
+ } catch {
4102
+ return [];
4103
+ }
4104
+ };
4105
+ /**
4106
+ * The operator's `hqAllowedOrigins`, read from the operator user config within
4107
+ * `deadline` (a `performance.now()` time). A missing, unreadable, oversized or
4108
+ * invalid file, or one that does not answer in time, authorizes nothing.
4109
+ */
4110
+ const loadHqAllowedOrigins = async (env, deadline) => {
4111
+ let configPath;
4112
+ try {
4113
+ configPath = defaultUserConfigPath(env);
4114
+ } catch {
4115
+ return [];
4116
+ }
4117
+ const contents = await readBoundedTextFile(configPath, deadline);
4118
+ return contents === void 0 ? [] : hqAllowedOriginsFromConfigText(contents);
4119
+ };
4120
+ /**
4121
+ * The matching rule: the URL's origin must equal one listed origin exactly.
4122
+ * No prefix, wildcard, or host-only match.
4123
+ */
4124
+ const isHqOriginAuthorized = (allowedOrigins, endpoint) => allowedOrigins.includes(endpoint.origin);
4008
4125
  const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
4009
4126
  const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
4010
4127
  /**
@@ -4048,7 +4165,6 @@ const SPOOL_REPOSITORY_MARKER = ".repository-identity";
4048
4165
  */
4049
4166
  const HQ_SPOOL_SWEEP_MAX_DEPTH = 4;
4050
4167
  const HQ_SPOOL_STATE_SEGMENTS = ["patronage-factory", "hq-spool"];
4051
- const MAX_HQ_CONFIG_BYTES = 1024 * 1024;
4052
4168
  const MAX_HQ_INGEST_PAYLOAD_BYTES = 256 * 1024;
4053
4169
  const MAX_HQ_REPLAY_ENTRIES = 32;
4054
4170
  const STALE_SPOOL_ARTIFACT_MS = 6e4;
@@ -4442,47 +4558,6 @@ const transportTimeoutFor = (dependencies) => {
4442
4558
  return DEFAULT_HQ_TRANSPORT_TIMEOUT_MS;
4443
4559
  }
4444
4560
  };
4445
- const remainingMs = (deadline) => Math.max(0, deadline - performance.now());
4446
- const settleWithin = async (operation, budgetMs) => {
4447
- const settleOperation = async () => {
4448
- try {
4449
- return {
4450
- status: "fulfilled",
4451
- value: await operation
4452
- };
4453
- } catch (error) {
4454
- return {
4455
- error,
4456
- status: "rejected"
4457
- };
4458
- }
4459
- };
4460
- const timeoutAbort = new AbortController();
4461
- const settleTimeout = async () => {
4462
- try {
4463
- await setTimeout$1(Math.max(0, budgetMs), void 0, { signal: timeoutAbort.signal });
4464
- } catch {}
4465
- return { status: "timed-out" };
4466
- };
4467
- const result = await Promise.race([settleOperation(), settleTimeout()]);
4468
- timeoutAbort.abort();
4469
- return result;
4470
- };
4471
- const readBoundedTextFile = async (filePath, deadline, maxBytes = MAX_HQ_CONFIG_BYTES) => {
4472
- const metadata = await settleWithin(stat(filePath), remainingMs(deadline));
4473
- if (metadata.status !== "fulfilled" || !metadata.value.isFile() || metadata.value.size > maxBytes) return;
4474
- const budget = remainingMs(deadline);
4475
- if (budget <= 0) return;
4476
- const abort = new AbortController();
4477
- const abortTimer = setTimeout(() => abort.abort(), budget);
4478
- const contents = await settleWithin(readFile(filePath, {
4479
- encoding: "utf-8",
4480
- signal: abort.signal
4481
- }), budget);
4482
- clearTimeout(abortTimer);
4483
- if (contents.status !== "fulfilled" || Buffer.byteLength(contents.value, "utf-8") > maxBytes) return;
4484
- return contents.value;
4485
- };
4486
4561
  const readBoundedTextFileNoFollow = async (filePath, deadline, maxBytes) => {
4487
4562
  const opened = await openWithin(filePath, constants.O_RDONLY + constants.O_NOFOLLOW, remainingMs(deadline));
4488
4563
  if (opened.status !== "fulfilled") return;
@@ -4503,22 +4578,6 @@ const readBoundedTextFileNoFollow = async (filePath, deadline, maxBytes) => {
4503
4578
  }
4504
4579
  };
4505
4580
  };
4506
- const loadAllowedOrigins = async (env, deadline) => {
4507
- let configPath;
4508
- try {
4509
- configPath = defaultUserConfigPath(env);
4510
- } catch {
4511
- return [];
4512
- }
4513
- const contents = await readBoundedTextFile(configPath, deadline);
4514
- if (contents === void 0) return [];
4515
- try {
4516
- const parsed = factoryUserConfigSchema.safeParse(JSON.parse(contents));
4517
- return parsed.success ? parsed.data.hqAllowedOrigins ?? [] : [];
4518
- } catch {
4519
- return [];
4520
- }
4521
- };
4522
4581
  const reportDiagnostic = async (dependencies, deadline, message, prefix = "HQ ingest deferred") => {
4523
4582
  const line = `${prefix}: ${message.replaceAll(/[\r\n]+/gu, " ").trim()}`;
4524
4583
  let diagnostic;
@@ -4608,15 +4667,6 @@ const loadBoundedPayload = async (input, deadline) => {
4608
4667
  status: "dropped"
4609
4668
  } : parseBoundedPayloadText(source);
4610
4669
  };
4611
- const validatedEndpoint = (value) => {
4612
- try {
4613
- const endpoint = new URL(value);
4614
- if (endpoint.protocol !== "https:" || endpoint.username !== "" || endpoint.password !== "") return;
4615
- return endpoint;
4616
- } catch {
4617
- return;
4618
- }
4619
- };
4620
4670
  /** HQ's ingest boundary, composed onto a profile origin (#318). */
4621
4671
  const HQ_INGEST_PATH = "/api/ingest";
4622
4672
  /**
@@ -4626,7 +4676,7 @@ const HQ_INGEST_PATH = "/api/ingest";
4626
4676
  * the composed href, so replay and origin authorization are unchanged.
4627
4677
  */
4628
4678
  const validatedIngestEndpoint = (value) => {
4629
- const origin = validatedEndpoint(value);
4679
+ const origin = validatedHqOrigin(value);
4630
4680
  return origin === void 0 ? void 0 : new URL(HQ_INGEST_PATH, origin.origin);
4631
4681
  };
4632
4682
  const retainRetryEntry = async (layout, entry, dependencies, deadline) => {
@@ -4838,7 +4888,7 @@ const replayCloseoutSpool = async (layout, endpoint, clientId, clientSecret, req
4838
4888
  if (!await mutateBoundDirectory(spool, spoolIdentity, setupDeadline, async () => unlink(claimPath))) return;
4839
4889
  continue;
4840
4890
  }
4841
- const entryEndpoint = validatedEndpoint(entry.endpoint);
4891
+ const entryEndpoint = validatedHqOrigin(entry.endpoint);
4842
4892
  if (!entryEndpoint || entryEndpoint.origin !== endpoint.origin) {
4843
4893
  if (!await mutateBoundDirectory(spool, spoolIdentity, performance.now() + flushBudgetMs, async () => rename(claimPath, `${eventPath}${UNDELIVERABLE_SUFFIX}`))) return;
4844
4894
  report?.({
@@ -4910,7 +4960,7 @@ async function deliverHqIngest(input, dependencies, setupDeadline, timeoutMs, tr
4910
4960
  await reportDiagnostic(dependencies, setupDeadline, "operator configuration is unavailable", "HQ ingest skipped");
4911
4961
  return "skipped";
4912
4962
  }
4913
- if (!(await loadAllowedOrigins(env, setupDeadline)).includes(endpoint.origin)) {
4963
+ if (!isHqOriginAuthorized(await loadHqAllowedOrigins(env, setupDeadline), endpoint)) {
4914
4964
  await reportDiagnostic(dependencies, setupDeadline, `endpoint origin ${endpoint.origin} is not authorized by hqAllowedOrigins in the operator user config`, "HQ ingest skipped");
4915
4965
  return "skipped";
4916
4966
  }
@@ -5725,14 +5775,7 @@ const deferProjectHqIngestIsolated = async (input, env) => {
5725
5775
  if (!isIsolatedReadResult(sourcesRead)) return "held";
5726
5776
  const configSource = sourcesRead.results.config;
5727
5777
  if (configSource?.status !== "ok" || configSource.source === void 0) return "skipped";
5728
- let allowedOrigins = [];
5729
- try {
5730
- const parsed = factoryUserConfigSchema.safeParse(JSON.parse(configSource.source));
5731
- allowedOrigins = parsed.success ? parsed.data.hqAllowedOrigins ?? [] : [];
5732
- } catch {
5733
- allowedOrigins = [];
5734
- }
5735
- if (!allowedOrigins.includes(endpoint.origin)) return "skipped";
5778
+ if (!isHqOriginAuthorized(hqAllowedOriginsFromConfigText(configSource.source), endpoint)) return "skipped";
5736
5779
  const payloadSource = input.payloadJson ?? (sourcesRead.results.payload?.status === "ok" ? sourcesRead.results.payload.source : void 0);
5737
5780
  if (payloadSource === void 0) return "held";
5738
5781
  const projectedPayload = parseBoundedPayloadText(payloadSource);
@@ -5987,7 +6030,7 @@ const canonical = (value) => {
5987
6030
  if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
5988
6031
  return value;
5989
6032
  };
5990
- const stableStringify$1 = (value) => JSON.stringify(canonical(value));
6033
+ const stableStringify$2 = (value) => JSON.stringify(canonical(value));
5991
6034
  const parseLockfile = (content) => {
5992
6035
  try {
5993
6036
  const parsed = parse(content);
@@ -6000,7 +6043,7 @@ const parseLockfile = (content) => {
6000
6043
  };
6001
6044
  const nonGraphSections = ({ importers: _importers, packages: _packages, snapshots: _snapshots, ...rest }) => rest;
6002
6045
  const changedSectionKeys = (before, after) => {
6003
- return [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].filter((key) => stableStringify$1(before?.[key]) !== stableStringify$1(after?.[key]));
6046
+ return [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].filter((key) => stableStringify$2(before?.[key]) !== stableStringify$2(after?.[key]));
6004
6047
  };
6005
6048
  const linkTarget = (importerPath, version) => {
6006
6049
  if (!version.startsWith("link:")) return;
@@ -6259,7 +6302,7 @@ const computeImpactStampOrThrow = ({ changedFiles, profile, profilePath = "softw
6259
6302
  unsubscribedPaths: source.unsubscribedPaths
6260
6303
  };
6261
6304
  const [baseLock, headLock] = sides.parsed;
6262
- if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeImpactStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"], source.unsubscribedPaths, source.inertPaths);
6305
+ if (stableStringify$2(nonGraphSections(baseLock)) !== stableStringify$2(nonGraphSections(headLock))) return conservativeImpactStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"], source.unsubscribedPaths, source.inertPaths);
6263
6306
  const delta = {
6264
6307
  changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
6265
6308
  changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
@@ -15261,9 +15304,9 @@ const buildEpicStructurePayload = (dag) => {
15261
15304
  };
15262
15305
  };
15263
15306
  /** Stable, key-sorted JSON for content hashing (values already ordered). */
15264
- const stableStringify = (value) => {
15265
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
15266
- if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
15307
+ const stableStringify$1 = (value) => {
15308
+ if (Array.isArray(value)) return `[${value.map(stableStringify$1).join(",")}]`;
15309
+ if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify$1(value[key])}`).join(",")}}`;
15267
15310
  return JSON.stringify(value ?? null);
15268
15311
  };
15269
15312
  /**
@@ -15273,7 +15316,7 @@ const stableStringify = (value) => {
15273
15316
  * supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
15274
15317
  * seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
15275
15318
  */
15276
- const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify(payload)).digest("hex").slice(0, 16)}`;
15319
+ const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify$1(payload)).digest("hex").slice(0, 16)}`;
15277
15320
  /**
15278
15321
  * Validates and builds the full `epic-structure` v1 ingest event, ready to POST
15279
15322
  * to `/api/ingest`. Throws {@link EpicStructureValidationError} fail-closed.
@@ -15580,7 +15623,7 @@ async function runHqFlush(args, dependencies = {}) {
15580
15623
  status: "flushed"
15581
15624
  };
15582
15625
  }
15583
- const outcomeLine = (outcome) => `[${outcome.status}${outcome.permanent === true ? ", permanent" : ""}] ${outcome.kind} ${outcome.eventId}${outcome.detail === void 0 ? "" : ` — ${outcome.detail}`}`;
15626
+ const outcomeLine$1 = (outcome) => `[${outcome.status}${outcome.permanent === true ? ", permanent" : ""}] ${outcome.kind} ${outcome.eventId}${outcome.detail === void 0 ? "" : ` — ${outcome.detail}`}`;
15584
15627
  const orphanLine = (orphan) => `ORPHAN SPOOL: ${orphan.directory} — ${orphan.unlistable > 0 && orphan.pending === 0 ? "could not be inspected" : `${orphan.pending} event(s)`}${orphan.oldestQueuedAt === void 0 ? "" : `, oldest ${orphan.oldestQueuedAt}`}`;
15585
15628
  /**
15586
15629
  * Orphan lines, plus the recovery instruction. This run did not drain these
@@ -15593,7 +15636,7 @@ function renderHqFlush(result) {
15593
15636
  return `${[
15594
15637
  `hq:flush ${result.endpoint}`,
15595
15638
  ...result.spools.length === 0 ? ["no spool directory found"] : result.spools.map((spool) => `spool: ${spool}`),
15596
- ...result.outcomes.map(outcomeLine),
15639
+ ...result.outcomes.map(outcomeLine$1),
15597
15640
  `delivered ${result.delivered}, duplicate ${result.duplicate}, rejected ${result.rejected}, undeliverable ${result.undeliverable}, unreachable ${result.unreachable}`,
15598
15641
  ...result.undeliverable > 0 ? [`${result.undeliverable} event(s) can never be delivered and were dispositioned in place, renamed with \`.undeliverable\` and left readable; they no longer count as work waiting.`] : [],
15599
15642
  ...result.outcomes.some((outcome) => outcome.permanent === true && outcome.status === "rejected") ? ["Permanent rejection(s) above will fail identically on every retry; evict them with `psf hq:flush --evict-permanent`, or wait for an HQ ingest schema fix."] : [],
@@ -15636,6 +15679,1642 @@ function createHqFlushCommand(output, action = runHqFlush) {
15636
15679
  }));
15637
15680
  }
15638
15681
  //#endregion
15682
+ //#region src/jev-calibrate/report.ts
15683
+ /**
15684
+ * The calibration report for a terminal. It prints counts and the cases behind
15685
+ * them, never a rate, a grade or a recommendation, and never one number that
15686
+ * mixes tuning with holdout.
15687
+ */
15688
+ const usageLine = (usage) => `requests ${usage.live} live, ${usage.cached} cached, ${usage.failed} failed · tokens ${usage.inputTokens} in / ${usage.outputTokens} out · ${(usage.elapsedMs / 1e3).toFixed(1)} s · model HQ reported: ${usage.models.length === 0 ? "none" : usage.models.join(", ")}`;
15689
+ const where = (outcome) => `${outcome.path}${outcome.target === "file" ? "" : `:${outcome.target.line}${outcome.target.column === void 0 ? "" : `:${outcome.target.column}`}`} @ ${outcome.sha.slice(0, 12)}`;
15690
+ const scoreText = (outcome, cutoff) => outcome.score === void 0 ? "" : ` — answered ${String(outcome.score)} against ${String(cutoff)}`;
15691
+ const outcomeLine = (outcome, cutoff) => ` ${outcome.id} expected ${outcome.expected}: ${outcome.status}${scoreText(outcome, cutoff)}${outcome.knownMiss ? " [known miss]" : ""}${outcome.reason === void 0 ? "" : ` — ${outcome.reason}`} (${where(outcome)})`;
15692
+ const poolSection = (pool, cutoff) => {
15693
+ const listed = pool.outcomes.filter((outcome) => outcome.knownMiss || !(outcome.status === "caught" || outcome.status === "quiet"));
15694
+ return [
15695
+ ` ${pool.pool}: ${pool.outcomes.length} case(s), ${pool.compared} answer(s) compared`,
15696
+ ` caught ${pool.caught} · quiet ${pool.quiet} · selector misses ${pool.selectorMisses} · model misses ${pool.modelMisses} · unanswered ${pool.unanswered} · unavailable ${pool.unavailable}`,
15697
+ ...listed.map((outcome) => outcomeLine(outcome, cutoff))
15698
+ ];
15699
+ };
15700
+ const questionSection = (question) => [
15701
+ `## ${question.id} [${question.textHash}] — target ${question.target}, cutoff ${String(question.cutoff)}`,
15702
+ ` ${usageLine(question.usage)}`,
15703
+ ` matches sent: ${question.sentMatches}`,
15704
+ ...question.pools.flatMap((pool) => poolSection(pool, question.cutoff))
15705
+ ].join("\n");
15706
+ const renderJevCalibrationText = (report) => [
15707
+ "# jev:calibrate",
15708
+ "",
15709
+ "A session diagnostic: counts against each question's own cutoff. No bar, no verdict, no recommendation.",
15710
+ "",
15711
+ `generated: ${report.generatedAt}`,
15712
+ `mode: ${report.mode}${report.mode === "solo" ? " (each labeled match in its own request)" : " (the plugin's requests, one per file)"}`,
15713
+ `config: ${report.configPath}`,
15714
+ ...report.manifests.map((manifest) => `${manifest.pool}: ${manifest.path} sha256 ${manifest.sha256}`),
15715
+ "cache: fresh for this run, discarded after it",
15716
+ `total: ${usageLine(report.usage)}`,
15717
+ `answers compared: ${report.compared}`,
15718
+ "",
15719
+ ...report.questions.map((question) => `${questionSection(question)}\n`),
15720
+ ...report.failures.length === 0 ? ["The run completed."] : [`The run did not complete (${report.failures.length}):`, ...report.failures.map((failure) => ` - ${failure}`)],
15721
+ ""
15722
+ ].join("\n");
15723
+ //#endregion
15724
+ //#region src/jev/schema.ts
15725
+ const noulAnswerSchema = z.object({
15726
+ noul: z.number().min(0).max(1),
15727
+ type: z.literal("noul")
15728
+ });
15729
+ const choiceAnswerSchema = z.object({
15730
+ choice: z.string(),
15731
+ confidence: z.number(),
15732
+ probabilities: z.record(z.string(), z.number()),
15733
+ type: z.literal("choice")
15734
+ });
15735
+ const scoreAnswerSchema = z.object({
15736
+ confidence: z.number(),
15737
+ probabilities: z.record(z.string(), z.number()),
15738
+ score: z.number(),
15739
+ type: z.literal("score")
15740
+ });
15741
+ /**
15742
+ * One answer, discriminated exactly as Jev discriminates it.
15743
+ *
15744
+ * Every member is a *stripping* object: an unknown key inside one answer is
15745
+ * dropped, not an error. Erroring would let a single surprising field sink a
15746
+ * whole response, and keeping it would put an upstream-named field into a
15747
+ * record. Dropping is the only behavior that is both safe and non-destructive.
15748
+ */
15749
+ const jevAnswerSchema = z.discriminatedUnion("type", [
15750
+ noulAnswerSchema,
15751
+ choiceAnswerSchema,
15752
+ scoreAnswerSchema
15753
+ ]);
15754
+ /** Token counters, copied field by field. Never a passthrough object. */
15755
+ const usageSchema = z.object({
15756
+ input_tokens: z.number().optional(),
15757
+ output_tokens: z.number().optional()
15758
+ });
15759
+ /**
15760
+ * Jev's response body. Unknown keys are stripped at every level: nothing
15761
+ * upstream names may end up on a record simply because it was present.
15762
+ */
15763
+ const jevResponseSchema = z.object({
15764
+ answers: z.record(z.string(), jevAnswerSchema),
15765
+ model: z.string().min(1),
15766
+ usage: usageSchema.optional()
15767
+ });
15768
+ /** A model id is a short token. Anything longer or stranger is prose. */
15769
+ const MODEL_ID_SHAPE = /^[A-Za-z0-9][\w.-]{0,63}$/u;
15770
+ /** What a report says when HQ names its model in something other than an id. */
15771
+ const UNKNOWN_MODEL = "unknown";
15772
+ /**
15773
+ * The one way an upstream body becomes a value this package will carry.
15774
+ *
15775
+ * It returns `undefined` rather than throwing, because a thrown validation
15776
+ * error carries the rejected data in its own message — the exact thing that
15777
+ * must not travel. Every field is copied explicitly: a passthrough object, a
15778
+ * spread, or a `z.infer` handed straight to a record would all reintroduce
15779
+ * "whatever HQ sent" as "whatever we publish".
15780
+ */
15781
+ const parseJevResponse = (payload) => {
15782
+ const parsed = jevResponseSchema.safeParse(payload);
15783
+ if (!parsed.success) return;
15784
+ const { answers, model, usage } = parsed.data;
15785
+ const counters = {};
15786
+ if (typeof usage?.input_tokens === "number") counters.input_tokens = usage.input_tokens;
15787
+ if (typeof usage?.output_tokens === "number") counters.output_tokens = usage.output_tokens;
15788
+ return {
15789
+ answers,
15790
+ model: MODEL_ID_SHAPE.test(model) ? model : UNKNOWN_MODEL,
15791
+ ...usage === void 0 ? {} : { usage: counters }
15792
+ };
15793
+ };
15794
+ /**
15795
+ * Projects one answer onto the question that asked for it.
15796
+ *
15797
+ * Two jobs, and the second is why this lives here rather than in a caller.
15798
+ * Type agreement keeps a well-formed answer of the wrong kind from throwing
15799
+ * later, deeper, and taking a whole report with it. And the only free text an
15800
+ * answer can carry — a `choice` label — is accepted solely when it is a label
15801
+ * this package itself wrote into the question. Probability keys are rebuilt
15802
+ * from the question's own labels or rubric levels for the same reason, so an
15803
+ * upstream-invented key cannot ride along.
15804
+ */
15805
+ const projectAnswer = (question, answer) => {
15806
+ if (answer.type !== question.type) return { reason: `asked ${question.type}, answered ${answer.type}` };
15807
+ if (answer.type === "noul") return { answer: {
15808
+ noul: answer.noul,
15809
+ type: "noul"
15810
+ } };
15811
+ const keep = (keys) => Object.fromEntries(keys.map((key) => [key, answer.probabilities[key]]).filter(([, value]) => typeof value === "number"));
15812
+ if (answer.type === "choice") {
15813
+ const labels = Object.keys(question.criteria);
15814
+ if (!labels.includes(answer.choice)) return { reason: "answered with a label the question did not offer" };
15815
+ return { answer: {
15816
+ choice: answer.choice,
15817
+ confidence: answer.confidence,
15818
+ probabilities: keep(labels),
15819
+ type: "choice"
15820
+ } };
15821
+ }
15822
+ const levels = question.criteria.map((_, index) => String(index));
15823
+ return { answer: {
15824
+ confidence: answer.confidence,
15825
+ probabilities: keep(levels),
15826
+ score: answer.score,
15827
+ type: "score"
15828
+ } };
15829
+ };
15830
+ //#endregion
15831
+ //#region src/jev/cache.ts
15832
+ /**
15833
+ * The request-hash cache: the same request bytes against the same endpoint
15834
+ * answer from disk instead of from HQ.
15835
+ *
15836
+ * The key is the endpoint plus the exact serialized request, and nothing else.
15837
+ * That is the whole design: a changed snippet, a reworded question, a
15838
+ * different reference file and a different HQ all move the bytes, so they all
15839
+ * move the key. There is no key schema to keep in step with the request
15840
+ * builder, and no way to grow one that forgets a field.
15841
+ *
15842
+ * It lives under `node_modules/.cache`, which is disposable by convention and
15843
+ * already ignored everywhere — a Jev answer is a session diagnostic (ADR
15844
+ * 0034), not a build input and not evidence.
15845
+ *
15846
+ * An entry that exists but cannot be used — unreadable, not JSON, not Jev's
15847
+ * response shape — is an error, not a miss, and so is a write that fails. Only
15848
+ * `ENOENT` means "nothing is stored". Repairing any of the others silently and
15849
+ * calling HQ anyway would be a second behavior for one failure, and a paid
15850
+ * one; the run names the entry and its cause, and the operator removes it.
15851
+ */
15852
+ /** A cache entry that could not be used, or could not be written. */
15853
+ var JevCacheError = class extends Error {
15854
+ path;
15855
+ constructor(entryPath, detail) {
15856
+ super(`the Jev cache entry ${entryPath} could not be used: ${detail}. Remove it and run again.`);
15857
+ this.name = "JevCacheError";
15858
+ this.path = entryPath;
15859
+ }
15860
+ };
15861
+ /**
15862
+ * The filesystem's own error code, which is a short constant it names
15863
+ * (`EISDIR`, `EACCES`), not a message that could quote anything.
15864
+ */
15865
+ const errnoOf = (error) => error?.code ?? "an unknown error";
15866
+ /** Where a repository's Jev answers are kept. */
15867
+ const defaultJevCacheDirectory = (cwd) => path.join(cwd, "node_modules", ".cache", "patronage-jev");
15868
+ /**
15869
+ * Opens the cache, creating its directory now so a directory that cannot be
15870
+ * written fails here — at the one place that names it — rather than midway
15871
+ * through a run as an unexplained request failure.
15872
+ */
15873
+ const openJevCache = ({ directory = defaultJevCacheDirectory(process.cwd()), endpoint }) => {
15874
+ mkdirSync(directory, { recursive: true });
15875
+ const keyFor = (serialized) => createHash("sha256").update(`${endpoint}\0${serialized}`).digest("hex");
15876
+ const entryPathFor = (serialized) => path.join(directory, `${keyFor(serialized)}.json`);
15877
+ return {
15878
+ keyFor,
15879
+ read: (serialized) => {
15880
+ const entryPath = entryPathFor(serialized);
15881
+ let text;
15882
+ try {
15883
+ text = readFileSync(entryPath, "utf-8");
15884
+ } catch (error) {
15885
+ if (errnoOf(error) === "ENOENT") return;
15886
+ throw new JevCacheError(entryPath, `reading it failed with ${errnoOf(error)}`);
15887
+ }
15888
+ let payload;
15889
+ try {
15890
+ payload = JSON.parse(text);
15891
+ } catch {
15892
+ throw new JevCacheError(entryPath, "it is not JSON");
15893
+ }
15894
+ const parsed = parseJevResponse(payload);
15895
+ if (parsed === void 0) throw new JevCacheError(entryPath, "it is not Jev's response shape");
15896
+ return parsed;
15897
+ },
15898
+ write: (serialized, response) => {
15899
+ const entryPath = entryPathFor(serialized);
15900
+ try {
15901
+ writeFileSync(entryPath, JSON.stringify(response), "utf-8");
15902
+ } catch (error) {
15903
+ throw new JevCacheError(entryPath, `writing it failed with ${errnoOf(error)}`);
15904
+ }
15905
+ }
15906
+ };
15907
+ };
15908
+ //#endregion
15909
+ //#region src/jev/client.ts
15910
+ /**
15911
+ * The one Jev gateway: HQ (ADR 0033).
15912
+ *
15913
+ * There is no fallback provider and no direct mode "for local dev". This
15914
+ * module knows Jev's request schema and how to reach HQ; it does not know
15915
+ * Cloudflare, TypeSafe, accounts, or model versions. HQ names the model and
15916
+ * returns the one that actually ran.
15917
+ *
15918
+ * `HQ_INGEST_URL` names the origin (#259). The origin is authorized before
15919
+ * anything else happens, by the same code HQ ingest uses
15920
+ * (`hq-origin-policy.ts`, ADR 0015): it must be an `https:` origin with no
15921
+ * embedded credentials, listed exactly in the operator's `hqAllowedOrigins`.
15922
+ * Only then are credentials read: `resolveHqCredentials` reads the hyphenated
15923
+ * `CF-Access-Client-Id` / `CF-Access-Client-Secret` pair, and
15924
+ * `buildCloudflareAccessRequestInit` attaches them to a request that refuses
15925
+ * redirects so the credentials cannot leave the authorized origin.
15926
+ *
15927
+ * Credentials are read from the environment only. A diagnostic must not reach
15928
+ * into the operator's secret manager: `hq:flush` is the deliberate command
15929
+ * allowed to do that, and a session that has already loaded the pair for a
15930
+ * factory command has it here too. So `resolveHqCredentials` is called with no
15931
+ * references, and the remedy this module prints is the one that actually
15932
+ * applies — load the variables — never "record references in the user config",
15933
+ * which would do nothing for this command.
15934
+ */
15935
+ /** The environment variable that carries HQ's origin, same as ingest. */
15936
+ const HQ_ORIGIN_ENV = "HQ_INGEST_URL";
15937
+ /** HQ's Jev boundary, composed onto the origin. */
15938
+ const JEV_ROUTE_PATH = "/api/jev";
15939
+ /** Per-attempt ceiling. A 32k-token packet is a slow request, not a hung one. */
15940
+ const JEV_REQUEST_TIMEOUT_MS = 6e4;
15941
+ /**
15942
+ * Ceiling for reading the operator user config. A config path that hangs
15943
+ * authorizes nothing rather than holding the run open.
15944
+ */
15945
+ const ORIGIN_POLICY_READ_MS = 5e3;
15946
+ /**
15947
+ * An upstream refusal, carried with the code HQ passed through rather than
15948
+ * flattened into prose. `max_tokens_exceeded` is the one a caller acts on: it
15949
+ * means the packet was too large despite the local budget.
15950
+ */
15951
+ var JevRequestError = class extends Error {
15952
+ code;
15953
+ httpStatus;
15954
+ constructor(httpStatus, code, message) {
15955
+ super(message);
15956
+ this.name = "JevRequestError";
15957
+ this.code = code;
15958
+ this.httpStatus = httpStatus;
15959
+ }
15960
+ };
15961
+ /**
15962
+ * A code is a short machine token. Anything else in that field is prose the
15963
+ * upstream chose, and prose is exactly what must not travel into a report.
15964
+ */
15965
+ const CODE_SHAPE = /^[a-z0-9_.-]{1,64}$/iu;
15966
+ /** Reads the upstream error code out of whatever shape the body arrived in. */
15967
+ const rawErrorCode = (body) => {
15968
+ if (body === null || typeof body !== "object") return;
15969
+ const record = body;
15970
+ if (typeof record.code === "string") return record.code;
15971
+ if (typeof record.error === "string") return record.error;
15972
+ if (record.error !== null && typeof record.error === "object") {
15973
+ const nested = record.error.code;
15974
+ return typeof nested === "string" ? nested : void 0;
15975
+ }
15976
+ };
15977
+ /**
15978
+ * The only thing kept from a refusal body.
15979
+ *
15980
+ * An upstream 400 routinely quotes the state it rejected, and this state is
15981
+ * repository source. That message used to be copied into the request record and
15982
+ * into every affected case's reasons, so an ordinary run without
15983
+ * `--include-evidence` could print source back out. Status and a code-shaped
15984
+ * token are enough to act on — `max_tokens_exceeded` is the one a caller does
15985
+ * anything about — and a field that is not code-shaped is dropped rather than
15986
+ * trimmed, because a truncated leak is still a leak.
15987
+ */
15988
+ const errorCode = (body) => {
15989
+ const raw = rawErrorCode(body);
15990
+ return raw !== void 0 && CODE_SHAPE.test(raw) ? raw : void 0;
15991
+ };
15992
+ /**
15993
+ * How to name a rejected origin without repeating it.
15994
+ *
15995
+ * Every `unavailable` reason reaches `statusReason`, each case's `reasons`, and
15996
+ * both the JSON and Markdown renderings — none of which is redacted anywhere.
15997
+ * An `HQ_INGEST_URL` carrying userinfo is exactly the value that must not be
15998
+ * echoed, and it is exactly the value being rejected, so the diagnostic is
15999
+ * limited to scheme and host. A value that does not parse is not quoted at all:
16000
+ * there is no structure to trim it down to.
16001
+ */
16002
+ const describeOrigin = (origin) => {
16003
+ const parsed = URL.parse(origin);
16004
+ return parsed === null ? "(not a URL; the value is withheld because it could not be parsed to strip credentials)" : `${parsed.protocol}//${parsed.host}`;
16005
+ };
16006
+ /**
16007
+ * HQ's Jev endpoint for a configured origin, or `undefined` when the value
16008
+ * breaks HQ ingest's protocol rule (`validatedHqOrigin`: `https:` only, no
16009
+ * embedded credentials, no `http:` exception).
16010
+ *
16011
+ * The path is composed onto the URL's *origin*, which is what both existing HQ
16012
+ * readers do and what makes the documented misconfiguration harmless: the
16013
+ * operator who sets `HQ_INGEST_URL` to the profile's `/api/ingest` endpoint
16014
+ * instead of the origin (#259) still reaches `/api/jev`, not
16015
+ * `/api/ingest/api/jev`.
16016
+ */
16017
+ const jevEndpointFor = (origin) => {
16018
+ const parsed = validatedHqOrigin(origin);
16019
+ return parsed === void 0 ? void 0 : new URL(JEV_ROUTE_PATH, parsed.origin).href;
16020
+ };
16021
+ /**
16022
+ * Whether an upstream token is this request's own credential handed back.
16023
+ *
16024
+ * Narrow on purpose. A model id and an error code are opaque tokens HQ names,
16025
+ * so they cannot be pattern-matched for secrets in general, and general secret
16026
+ * scanning is not wanted here: HQ already holds both the credential and the
16027
+ * source, so a hostile HQ is outside the threat model. What *is* cheap and
16028
+ * exact is the comparison this function makes — the credential values are in
16029
+ * scope at the one place a response is read, so a field that echoes one back
16030
+ * can be recognized by equality rather than by guesswork.
16031
+ */
16032
+ const reflectsCredential = (value, token) => value.includes(token.clientId) || value.includes(token.clientSecret);
16033
+ /**
16034
+ * The whole exchange, and the only place an upstream body is touched.
16035
+ *
16036
+ * Fetching, reading the body, parsing it, and validating it all happen inside
16037
+ * one boundary with one outer catch, because the leak this shape prevents kept
16038
+ * coming back by a different route each time: first the refusal body, then the
16039
+ * transport message, then the body stream, then a schema issue. Each of those
16040
+ * is a distinct throw site, and patching them one at a time is a losing game.
16041
+ *
16042
+ * The rule is therefore structural, not per-site: **everything that leaves here
16043
+ * is a `JevRequestError` whose message is a fixed string, plus an HTTP status,
16044
+ * plus a code-shaped token.** Nothing derived from an upstream value —
16045
+ * `error.message`, `error.cause`, response text, or a validation issue — is
16046
+ * ever read into a message. A throw this function does not recognize becomes
16047
+ * one fixed sentence rather than being inspected.
16048
+ */
16049
+ const exchange = async (input) => {
16050
+ const init = buildCloudflareAccessRequestInit(input.token, {
16051
+ body: input.serializedBody,
16052
+ headers: { "content-type": "application/json" },
16053
+ method: "POST",
16054
+ signal: AbortSignal.timeout(JEV_REQUEST_TIMEOUT_MS)
16055
+ });
16056
+ try {
16057
+ let response;
16058
+ try {
16059
+ response = await input.fetchImpl(input.endpoint, init);
16060
+ } catch (error) {
16061
+ const timedOut = error instanceof Error && error.name === "TimeoutError";
16062
+ 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.`);
16063
+ }
16064
+ const text = await response.text();
16065
+ let payload;
16066
+ try {
16067
+ payload = JSON.parse(text);
16068
+ } catch {
16069
+ payload = void 0;
16070
+ }
16071
+ if (!response.ok) {
16072
+ const reported = errorCode(payload);
16073
+ const code = reported !== void 0 && reflectsCredential(reported, input.token) ? void 0 : reported;
16074
+ 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.`);
16075
+ }
16076
+ const parsed = parseJevResponse(payload);
16077
+ const body = parsed !== void 0 && reflectsCredential(parsed.model, input.token) ? {
16078
+ ...parsed,
16079
+ model: UNKNOWN_MODEL
16080
+ } : parsed;
16081
+ 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.");
16082
+ return body;
16083
+ } catch (error) {
16084
+ if (error instanceof JevRequestError) throw error;
16085
+ 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.");
16086
+ }
16087
+ };
16088
+ /**
16089
+ * The whole remedy, in one sentence. The variable names are literally
16090
+ * hyphenated, so a plain `export` does not set them.
16091
+ */
16092
+ 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> …\`.`;
16093
+ /** Where the operator config was looked for, for a message that names it. */
16094
+ const operatorConfigLocation = (env) => {
16095
+ try {
16096
+ return ` (${defaultUserConfigPath(env)})`;
16097
+ } catch {
16098
+ return "";
16099
+ }
16100
+ };
16101
+ /**
16102
+ * Why an origin is not authorized. An empty list and an unlisted origin are
16103
+ * different remedies, so they are different sentences. A config that is
16104
+ * missing, unreadable or invalid reads as an empty list, exactly as it does
16105
+ * for HQ ingest.
16106
+ */
16107
+ 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.`;
16108
+ /**
16109
+ * Resolves the gateway, or says why there isn't one. Every `unavailable`
16110
+ * reason is an operator-facing sentence naming a remedy that applies to this
16111
+ * command, and none of them can carry a credential: nothing here reads a
16112
+ * resolved value into a message.
16113
+ *
16114
+ * The order is the policy. The origin is checked against HQ ingest's protocol
16115
+ * rule and the operator's `hqAllowedOrigins` first, and only an authorized
16116
+ * origin gets as far as reading credentials. An unauthorized origin never sees
16117
+ * a request.
16118
+ */
16119
+ const resolveJevGateway = async ({ env, fetchImpl = fetch }) => {
16120
+ const origin = env[HQ_ORIGIN_ENV]?.trim();
16121
+ if (!origin) return {
16122
+ reason: `no HQ origin: ${HQ_ORIGIN_ENV} is unset. It wants the origin, for example https://hq.patronage.com.`,
16123
+ status: "unavailable"
16124
+ };
16125
+ const endpoint = jevEndpointFor(origin);
16126
+ if (endpoint === void 0) return {
16127
+ reason: `${HQ_ORIGIN_ENV} must be an https origin without embedded credentials, as HQ ingest requires: ${describeOrigin(origin)}`,
16128
+ status: "unavailable"
16129
+ };
16130
+ const endpointUrl = new URL(endpoint);
16131
+ const allowedOrigins = await loadHqAllowedOrigins(env, performance.now() + ORIGIN_POLICY_READ_MS);
16132
+ if (!isHqOriginAuthorized(allowedOrigins, endpointUrl)) return {
16133
+ reason: unauthorizedOriginReason(allowedOrigins, endpointUrl.origin, env),
16134
+ status: "unavailable"
16135
+ };
16136
+ const resolution = resolveHqCredentials({ env });
16137
+ if (resolution.status !== "resolved") return {
16138
+ reason: MISSING_CREDENTIALS_REASON,
16139
+ status: "unavailable"
16140
+ };
16141
+ return {
16142
+ endpoint,
16143
+ evaluate: (serializedBody) => exchange({
16144
+ endpoint,
16145
+ fetchImpl,
16146
+ serializedBody,
16147
+ token: resolution.credentials
16148
+ }),
16149
+ status: "ready"
16150
+ };
16151
+ };
16152
+ //#endregion
16153
+ //#region src/jev/request.ts
16154
+ /**
16155
+ * The per-request ceiling, in tokens. Jev's context is the constraint this
16156
+ * bounds; 32k is the size the epic designed to, and the headroom below covers
16157
+ * the estimator's error rather than pretending it has none.
16158
+ */
16159
+ const JEV_REQUEST_TOKEN_BUDGET = 32e3;
16160
+ /** Fraction of the budget a request may fill. The rest absorbs estimator error. */
16161
+ const BUDGET_HEADROOM = .9;
16162
+ /** JSON punctuation the per-part sizes do not account for. */
16163
+ const ENVELOPE_OVERHEAD_BYTES = 128;
16164
+ const BYTES_PER_TOKEN = 4;
16165
+ /**
16166
+ * Tokens from bytes, at the conventional four-bytes-per-token ratio.
16167
+ *
16168
+ * A real tokenizer would be a heavy dependency and a second thing to keep in
16169
+ * step with whatever model HQ routes to — and the factory holds no model
16170
+ * vocabulary by design (ADR 0033). An over-estimate costs an extra split; the
16171
+ * headroom above covers an under-estimate. It is a budget, not a measurement.
16172
+ */
16173
+ const tokensFor = (bytes) => Math.ceil(bytes / BYTES_PER_TOKEN);
16174
+ /**
16175
+ * JSON with object keys in sorted order, so the same logical request always
16176
+ * serializes to the same bytes — and therefore the same hash, and therefore
16177
+ * the same cache entry. Two runs that assembled their state in a different
16178
+ * order must not look like two different requests.
16179
+ */
16180
+ const stableStringify = (value) => JSON.stringify(value, (_key, nested) => {
16181
+ if (nested === null || typeof nested !== "object" || Array.isArray(nested)) return nested;
16182
+ const source = nested;
16183
+ return Object.fromEntries(Object.keys(source).toSorted().map((key) => [key, source[key]]));
16184
+ });
16185
+ const byteSize = (value) => Buffer.byteLength(stableStringify(value), "utf-8");
16186
+ /** What one match adds to a request: its state entry plus its questions. */
16187
+ const matchCost = (match) => byteSize({ [match.key]: match.state }) + byteSize(match.questions);
16188
+ /** What a request costs before any match is in it. */
16189
+ const baseCost = (file) => ENVELOPE_OVERHEAD_BYTES + byteSize({ path: file.path }) + byteSize({ file: file.shared });
16190
+ const buildBody = (file, matches) => {
16191
+ const matchStates = Object.fromEntries(matches.map((match) => [match.key, match.state]));
16192
+ return {
16193
+ questions: Object.fromEntries(matches.flatMap((match) => Object.entries(match.questions))),
16194
+ state: {
16195
+ file: file.shared,
16196
+ matches: matchStates,
16197
+ path: file.path
16198
+ }
16199
+ };
16200
+ };
16201
+ const toRequest = (file, matches) => {
16202
+ const body = buildBody(file, matches);
16203
+ const serialized = stableStringify(body);
16204
+ return {
16205
+ body,
16206
+ estimatedTokens: tokensFor(Buffer.byteLength(serialized, "utf-8")),
16207
+ hash: createHash("sha256").update(serialized).digest("hex"),
16208
+ matchKeys: matches.map((match) => match.key),
16209
+ path: file.path,
16210
+ serialized
16211
+ };
16212
+ };
16213
+ /**
16214
+ * Turns files into the requests that will be sent, plus the matches that
16215
+ * cannot be sent and why. Pure: it performs no I/O and decides nothing about
16216
+ * transport.
16217
+ */
16218
+ const buildJevRequests = ({ budgetTokens = JEV_REQUEST_TOKEN_BUDGET, files }) => {
16219
+ const ceiling = Math.floor(budgetTokens * BUDGET_HEADROOM);
16220
+ const ceilingBytes = ceiling * BYTES_PER_TOKEN;
16221
+ const requests = [];
16222
+ const unavailable = [];
16223
+ for (const file of files) {
16224
+ const base = baseCost(file);
16225
+ let open = [];
16226
+ let openBytes = base;
16227
+ const flush = () => {
16228
+ if (open.length > 0) {
16229
+ requests.push(toRequest(file, open));
16230
+ open = [];
16231
+ openBytes = base;
16232
+ }
16233
+ };
16234
+ for (const match of file.matches) {
16235
+ const cost = matchCost(match);
16236
+ if (openBytes + cost <= ceilingBytes) {
16237
+ open.push(match);
16238
+ openBytes += cost;
16239
+ continue;
16240
+ }
16241
+ flush();
16242
+ if (openBytes + cost <= ceilingBytes) {
16243
+ open.push(match);
16244
+ openBytes += cost;
16245
+ continue;
16246
+ }
16247
+ unavailable.push({
16248
+ key: match.key,
16249
+ 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`
16250
+ });
16251
+ }
16252
+ flush();
16253
+ }
16254
+ return {
16255
+ requests,
16256
+ unavailable
16257
+ };
16258
+ };
16259
+ //#endregion
16260
+ //#region src/jev/run.ts
16261
+ const describe = (error) => error instanceof Error ? error.message : String(error);
16262
+ /**
16263
+ * Matches one match's answers to the questions it asked.
16264
+ *
16265
+ * `projectAnswer` does the work: it rebuilds each answer from the question
16266
+ * that asked for it, so an answer of the wrong kind, a `choice` label nobody
16267
+ * offered, and an upstream-invented probability key are each refused or
16268
+ * dropped rather than carried. A refusal costs one match — it becomes
16269
+ * unavailable with a reason — instead of throwing later, deeper, and taking a
16270
+ * whole report's correctly answered siblings with it.
16271
+ */
16272
+ const routeAnswers = (questions, answers) => {
16273
+ const routed = /* @__PURE__ */ new Map();
16274
+ const missing = [];
16275
+ const mismatched = [];
16276
+ for (const [name, question] of Object.entries(questions)) {
16277
+ const answer = answers[name];
16278
+ if (answer === void 0) {
16279
+ missing.push(name);
16280
+ continue;
16281
+ }
16282
+ const projected = projectAnswer(question, answer);
16283
+ if (projected.reason === void 0) routed.set(name, projected.answer);
16284
+ else mismatched.push(`${name} (${projected.reason})`);
16285
+ }
16286
+ if (missing.length > 0) return { reason: `Jev answered the request but omitted ${missing.join(", ")}` };
16287
+ if (mismatched.length > 0) return { reason: `answer type mismatch: ${mismatched.join(", ")}` };
16288
+ return { answers: Object.fromEntries(routed) };
16289
+ };
16290
+ const completionOf = (answered, unanswered) => {
16291
+ if (unanswered === 0) return "complete";
16292
+ return answered === 0 ? "unavailable" : "partial";
16293
+ };
16294
+ /** Counters copied one by one, never the parsed object: this record is printed. */
16295
+ const copyUsage = (usage) => {
16296
+ const copied = {};
16297
+ if (typeof usage.input_tokens === "number") copied.input_tokens = usage.input_tokens;
16298
+ if (typeof usage.output_tokens === "number") copied.output_tokens = usage.output_tokens;
16299
+ return copied;
16300
+ };
16301
+ /**
16302
+ * One request's answer, from the cache when the exact bytes are already
16303
+ * answered and from HQ otherwise. A live answer is stored on the way back, so
16304
+ * the next identical request costs nothing.
16305
+ */
16306
+ const answerFor = async (gateway, cache, serialized) => {
16307
+ const stored = cache?.read(serialized);
16308
+ if (stored !== void 0) return {
16309
+ cached: true,
16310
+ response: stored
16311
+ };
16312
+ const response = await gateway.evaluate(serialized);
16313
+ cache?.write(serialized, response);
16314
+ return {
16315
+ cached: false,
16316
+ response
16317
+ };
16318
+ };
16319
+ /** Writes what HQ reported about the call itself onto the request's record. */
16320
+ const recordResponse = (record, response, models) => {
16321
+ record.model = response.model;
16322
+ if (response.usage !== void 0) record.usage = copyUsage(response.usage);
16323
+ if (!models.includes(response.model)) models.push(response.model);
16324
+ };
16325
+ /**
16326
+ * Builds the requests, sends the ones the cache does not already answer, and
16327
+ * routes answers back to the matches that asked for them.
16328
+ *
16329
+ * Requests go one at a time. Bounded load is the point: a repository-wide run
16330
+ * would otherwise open dozens of concurrent 32k-token requests against one HQ
16331
+ * route for a diagnostic nothing is waiting on.
16332
+ */
16333
+ const runJevFiles = async ({ budgetTokens, cache, files, gateway }) => {
16334
+ const plan = buildJevRequests({
16335
+ ...budgetTokens === void 0 ? {} : { budgetTokens },
16336
+ files
16337
+ });
16338
+ const questionsByMatch = /* @__PURE__ */ new Map();
16339
+ for (const file of files) for (const match of file.matches) questionsByMatch.set(match.key, match.questions);
16340
+ const answers = /* @__PURE__ */ new Map();
16341
+ const unavailable = /* @__PURE__ */ new Map();
16342
+ const requests = [];
16343
+ const models = [];
16344
+ for (const { key, reason } of plan.unavailable) unavailable.set(key, reason);
16345
+ if (gateway.status !== "ready") {
16346
+ for (const request of plan.requests) for (const key of request.matchKeys) unavailable.set(key, gateway.reason);
16347
+ return {
16348
+ answersByMatchKey: {},
16349
+ completion: completionOf(0, unavailable.size),
16350
+ elapsedMs: 0,
16351
+ models,
16352
+ requests,
16353
+ unavailableByMatchKey: Object.fromEntries(unavailable)
16354
+ };
16355
+ }
16356
+ const runStarted = Date.now();
16357
+ for (const request of plan.requests) {
16358
+ const record = {
16359
+ cached: false,
16360
+ elapsedMs: 0,
16361
+ estimatedTokens: request.estimatedTokens,
16362
+ matchKeys: request.matchKeys,
16363
+ path: request.path,
16364
+ requestHash: request.hash
16365
+ };
16366
+ const started = Date.now();
16367
+ try {
16368
+ const { cached, response } = await answerFor(gateway, cache, request.serialized);
16369
+ record.cached = cached;
16370
+ recordResponse(record, response, models);
16371
+ for (const matchKey of request.matchKeys) {
16372
+ const routed = routeAnswers(questionsByMatch.get(matchKey) ?? {}, response.answers);
16373
+ if (routed.reason === void 0) answers.set(matchKey, routed.answers);
16374
+ else unavailable.set(matchKey, routed.reason);
16375
+ }
16376
+ } catch (error) {
16377
+ record.error = describe(error);
16378
+ if (error instanceof JevRequestError && error.code !== void 0) record.errorCode = error.code;
16379
+ for (const matchKey of request.matchKeys) unavailable.set(matchKey, record.error);
16380
+ }
16381
+ record.elapsedMs = Date.now() - started;
16382
+ requests.push(record);
16383
+ }
16384
+ return {
16385
+ answersByMatchKey: Object.fromEntries(answers),
16386
+ completion: completionOf(answers.size, unavailable.size),
16387
+ elapsedMs: Date.now() - runStarted,
16388
+ models,
16389
+ requests,
16390
+ unavailableByMatchKey: Object.fromEntries(unavailable)
16391
+ };
16392
+ };
16393
+ //#endregion
16394
+ //#region src/oxlint-jev/pass.ts
16395
+ /** The environment variable that turns spending on. */
16396
+ const JEV_MODE_ENV = "PATRONAGE_JEV_MODE";
16397
+ /**
16398
+ * The mode this run is in.
16399
+ *
16400
+ * Deliberately an environment variable rather than a rule option. The Oxlint
16401
+ * config that carries the questions is a checked-in file: a `mode: "live"` in
16402
+ * it would be one merge away from every runner with HQ credentials spending on
16403
+ * every invocation, including whichever tool picks the config up next. An
16404
+ * environment variable is set per invocation by whoever is paying, and it is
16405
+ * bounded by the same credentials — `live` without `HQ_INGEST_URL` and the
16406
+ * Cloudflare Access pair buys nothing, it reports that it could not ask. A
16407
+ * value that is neither mode is refused rather than rounded to the safe one,
16408
+ * because "I set it and nothing happened" is how an operator ends up believing
16409
+ * a run asked something it did not.
16410
+ */
16411
+ const resolveJevMode = (env) => {
16412
+ const raw = env[JEV_MODE_ENV]?.trim();
16413
+ if (raw === void 0 || raw === "") return "record";
16414
+ if (raw === "live" || raw === "record") return raw;
16415
+ throw new Error(`jev: ${JEV_MODE_ENV} must be "record" or "live"; got "${raw}"`);
16416
+ };
16417
+ /** Where recorded request bodies are written. */
16418
+ const recordDirectoryFor = (cacheDirectory) => path.join(cacheDirectory, "record");
16419
+ const fail = (detail) => {
16420
+ throw new Error(`jev/ask: ${detail}`);
16421
+ };
16422
+ /** The short hash of a question's text that every finding carries. */
16423
+ const questionTextHash = (question) => createHash("sha256").update(question).digest("hex").slice(0, 8);
16424
+ /**
16425
+ * A reference must name a file inside the repository. An absolute path or one
16426
+ * that climbs out of the checkout would make the request depend on the machine
16427
+ * that ran the lint, and the request is the cache key — two machines would
16428
+ * disagree about what the same configuration asked.
16429
+ */
16430
+ const checkReference = (id, reference) => {
16431
+ if (path.isAbsolute(reference)) fail(`question "${id}" references an absolute path (${reference}); references are repository-relative`);
16432
+ const normalized = path.normalize(reference);
16433
+ if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) fail(`question "${id}" references a path outside the repository (${reference})`);
16434
+ };
16435
+ /**
16436
+ * Turns the rule's options into the questions this file's pass will ask.
16437
+ *
16438
+ * Called once per file, so it stays cheap: the only work is compiling
16439
+ * `contains` and hashing the question text.
16440
+ */
16441
+ const resolveJevQuestions = (raw) => {
16442
+ if (raw === null || typeof raw !== "object") fail("the rule takes one options object with a `questions` array; configure it as `\"jev/ask\": [\"warn\", { \"questions\": [ … ] }]`");
16443
+ const { questions } = raw;
16444
+ if (!Array.isArray(questions) || questions.length === 0) fail("`questions` must list at least one question");
16445
+ const seen = /* @__PURE__ */ new Set();
16446
+ return questions.map((question) => {
16447
+ if (seen.has(question.id)) fail(`question id "${question.id}" is used more than once`);
16448
+ seen.add(question.id);
16449
+ for (const reference of question.references ?? []) checkReference(question.id, reference);
16450
+ let pattern;
16451
+ if (question.contains !== void 0) try {
16452
+ pattern = new RegExp(question.contains, "u");
16453
+ } catch {
16454
+ fail(`question "${question.id}" has a \`contains\` that is not a valid regular expression: ${question.contains}`);
16455
+ }
16456
+ return {
16457
+ ...question,
16458
+ pattern,
16459
+ textHash: questionTextHash(question.question)
16460
+ };
16461
+ });
16462
+ };
16463
+ //#endregion
16464
+ //#region src/jev-calibrate/installed.ts
16465
+ /**
16466
+ * Finding what a project installed, and nothing else.
16467
+ *
16468
+ * `require.resolve` would also search `NODE_PATH` and Node's global folders.
16469
+ * Those belong to whatever launched the command, not to the project: pnpm, for
16470
+ * one, sets `NODE_PATH` to its own hoisted store for every child it runs, so a
16471
+ * project with no Oxlint at all "resolves" one from there. Calibration must
16472
+ * run the Oxlint and the plugin the project's Jev config runs with, so the
16473
+ * search here is only the `node_modules` directories from the starting
16474
+ * directory up to the filesystem root.
16475
+ */
16476
+ /** The installed root of `packageName` for `fromDirectory`, or `undefined`. */
16477
+ const installedPackageRoot = (fromDirectory, packageName) => {
16478
+ for (let directory = path.resolve(fromDirectory);; directory = path.dirname(directory)) {
16479
+ const candidate = path.join(directory, "node_modules", packageName);
16480
+ if (existsSync(path.join(candidate, "package.json"))) return realpathSync(candidate);
16481
+ if (path.dirname(directory) === directory) return;
16482
+ }
16483
+ };
16484
+ /**
16485
+ * The package `fromDirectory` itself belongs to, when that package is
16486
+ * `packageName`: Node's self-reference, which Oxlint's resolver honors too, so
16487
+ * a config inside the plugin's own package can name it by package name.
16488
+ */
16489
+ const selfPackageRoot = (fromDirectory, packageName) => {
16490
+ for (let directory = path.resolve(fromDirectory);; directory = path.dirname(directory)) {
16491
+ const manifest = path.join(directory, "package.json");
16492
+ if (existsSync(manifest)) {
16493
+ const { name } = JSON.parse(readFileSync(manifest, "utf-8"));
16494
+ return name === packageName ? realpathSync(directory) : void 0;
16495
+ }
16496
+ if (path.dirname(directory) === directory) return;
16497
+ }
16498
+ };
16499
+ /** The package name a bare specifier names: `@scope/name` or `name`. */
16500
+ const packageNameOf = (specifier) => {
16501
+ const segments = specifier.split("/");
16502
+ return (specifier.startsWith("@") ? segments.slice(0, 2) : segments.slice(0, 1)).join("/");
16503
+ };
16504
+ /**
16505
+ * A module specifier resolved from `fromDirectory`: a relative or absolute
16506
+ * path as a file, a bare specifier through the package that encloses
16507
+ * `fromDirectory` (when it is that package) or the one the project installed,
16508
+ * and that package's own `exports`. The result is a file inside that package,
16509
+ * or this throws.
16510
+ */
16511
+ const resolveInstalledModule = (fromDirectory, specifier) => {
16512
+ if (specifier.startsWith(".") || path.isAbsolute(specifier)) {
16513
+ const resolved = path.resolve(fromDirectory, specifier);
16514
+ if (!existsSync(resolved)) throw new Error(`${resolved} does not exist`);
16515
+ return resolved;
16516
+ }
16517
+ const packageName = packageNameOf(specifier);
16518
+ const root = selfPackageRoot(fromDirectory, packageName) ?? installedPackageRoot(fromDirectory, packageName);
16519
+ if (root === void 0) throw new Error(`${packageName} is not installed in any node_modules above ${fromDirectory}`);
16520
+ const resolved = createRequire(path.join(root, "package.json")).resolve(specifier);
16521
+ if (!resolved.startsWith(`${root}${path.sep}`)) throw new Error(`${specifier} resolved outside the installed ${packageName} at ${root}`);
16522
+ return resolved;
16523
+ };
16524
+ //#endregion
16525
+ //#region src/jev-calibrate/config.ts
16526
+ /**
16527
+ * The project's dedicated Jev Oxlint config, read once for two things: the
16528
+ * questions (so each case is scored against its own question's cutoff) and the
16529
+ * plugin the config loads (so the selection that runs is the project's own).
16530
+ *
16531
+ * The questions go through `resolveJevQuestions`, the same function the plugin
16532
+ * runs on its options, so an id, a text hash and a `contains` pattern mean the
16533
+ * same thing here as in a lint run. Nothing about a question is decided here:
16534
+ * the cutoff, the wording and the selector are the project's.
16535
+ */
16536
+ /** The file the calibration config is written to inside each rebuilt tree. */
16537
+ const CALIBRATION_CONFIG_NAME = "jev-calibration.oxlintrc.json";
16538
+ /**
16539
+ * Keys that change which files or nodes a lint run selects and that a
16540
+ * calibration config cannot carry faithfully: their globs and paths are
16541
+ * relative to the project's config file, and the calibration config lives
16542
+ * somewhere else. Refusing them keeps "the selection is the plugin's" true
16543
+ * instead of approximately true.
16544
+ */
16545
+ const UNREPRODUCIBLE_KEYS = [
16546
+ "extends",
16547
+ "ignorePatterns",
16548
+ "overrides"
16549
+ ];
16550
+ const specifierOf = (entry) => typeof entry === "string" ? entry : entry.specifier;
16551
+ /**
16552
+ * A plugin specifier resolved the way Oxlint resolves it: relative to the
16553
+ * config file, through the project's own `node_modules`. The result is an
16554
+ * absolute path, so the calibration config can load the very same file from a
16555
+ * different directory.
16556
+ */
16557
+ const resolvePlugin = (configPath, entry) => {
16558
+ const specifier = specifierOf(entry);
16559
+ let resolved;
16560
+ try {
16561
+ resolved = resolveInstalledModule(path.dirname(configPath), specifier);
16562
+ } catch (error) {
16563
+ throw new Error(`the Jev config ${configPath} loads the plugin "${specifier}", which the project has not installed: ${error instanceof Error ? error.message : String(error)}.`, { cause: error });
16564
+ }
16565
+ return typeof entry === "string" ? resolved : {
16566
+ ...entry,
16567
+ specifier: resolved
16568
+ };
16569
+ };
16570
+ /**
16571
+ * Reads the project's Jev config. Every problem throws: a config calibration
16572
+ * cannot read faithfully is a run that must not start.
16573
+ */
16574
+ const loadJevCalibrationConfig = (configPath) => {
16575
+ let parsed;
16576
+ try {
16577
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
16578
+ } catch (error) {
16579
+ throw new Error(`the Jev config ${configPath} could not be read as JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
16580
+ }
16581
+ for (const key of UNREPRODUCIBLE_KEYS) if (key in parsed) throw new Error(`the Jev config ${configPath} uses "${key}", which changes what the plugin selects and cannot be carried into a calibration run. Keep the Jev config flat: jsPlugins and top-level rules.`);
16582
+ const ask = (parsed.rules ?? {})["jev/ask"];
16583
+ if (!Array.isArray(ask) || ask.length !== 2) throw new Error(`the Jev config ${configPath} has no top-level "jev/ask": [severity, { questions }] rule to calibrate.`);
16584
+ const plugins = parsed.jsPlugins;
16585
+ if (!Array.isArray(plugins) || plugins.length === 0) throw new Error(`the Jev config ${configPath} loads no jsPlugins, so it cannot run the Jev plugin.`);
16586
+ return {
16587
+ askOptions: ask[1],
16588
+ jsPlugins: plugins.map((entry) => resolvePlugin(configPath, entry)),
16589
+ path: configPath,
16590
+ questions: resolveJevQuestions(ask[1])
16591
+ };
16592
+ };
16593
+ /**
16594
+ * The Oxlint config written beside each rebuilt tree.
16595
+ *
16596
+ * It asks the project's questions verbatim, through the project's plugin, and
16597
+ * nothing else runs: built-in plugins and the default `correctness` category
16598
+ * are off, so the only diagnostics are the plugin's own. `jev/ask` is a
16599
+ * warning whatever the project set — a recorded match is not a failure — and
16600
+ * `jev/unavailable` is an error, so an incomplete selection exits non-zero.
16601
+ */
16602
+ const calibrationOxlintConfig = (config) => ({
16603
+ categories: { correctness: "off" },
16604
+ jsPlugins: config.jsPlugins,
16605
+ plugins: [],
16606
+ rules: {
16607
+ "jev/ask": ["warn", config.askOptions],
16608
+ "jev/unavailable": "error"
16609
+ }
16610
+ });
16611
+ //#endregion
16612
+ //#region src/jev-calibrate/manifest.ts
16613
+ /**
16614
+ * The labeled cases, as manifests rather than fixtures.
16615
+ *
16616
+ * A case is a label about code at a commit: "the node this question selects on
16617
+ * line 38 of this file, at this sha, should be answered yes". The manifest
16618
+ * stores only enough to find that code again — repository, sha, path, where
16619
+ * the node starts, the question id, and the expected answer. The file is
16620
+ * rebuilt from git at run time. No source is copied here: a copy would keep
16621
+ * answering yesterday's question about yesterday's snippet after the question
16622
+ * or the selector changed, which is exactly what calibration exists to catch.
16623
+ *
16624
+ * Two pools, and they are never merged:
16625
+ *
16626
+ * - `tuning` is the only pool a question's wording, `contains` or cutoff may
16627
+ * be tuned against.
16628
+ * - `holdout` is the pool the author does not tune against. Every report
16629
+ * prints each manifest's SHA-256, so an edit to the holdout is visible
16630
+ * beside the numbers it produced.
16631
+ */
16632
+ const CALIBRATION_POOL_NAMES = ["tuning", "holdout"];
16633
+ /**
16634
+ * Where the node a case labels starts, as an editor shows it: 1-based line and
16635
+ * column. `file` for a question whose target is the whole file. A column is
16636
+ * needed only when two selected nodes of one question start on the same line.
16637
+ */
16638
+ const caseTargetSchema = z.union([z.literal("file"), z.object({
16639
+ column: z.number().int().min(1).optional(),
16640
+ line: z.number().int().min(1)
16641
+ }).strict()]);
16642
+ const calibrationCaseSchema = z.object({
16643
+ /** Who or what produced `expected`: a model id, or a named person. */
16644
+ adjudicator: z.string().min(1),
16645
+ expected: z.enum(["yes", "no"]),
16646
+ id: z.string().min(1),
16647
+ /**
16648
+ * A case the question is already known to get wrong. It is asked and
16649
+ * scored like every other case and stays listed in every report: a known
16650
+ * miss is evidence, and dropping it would make the numbers look better
16651
+ * than the question is.
16652
+ */
16653
+ knownMiss: z.boolean().optional(),
16654
+ /** The adjudicator's own words. */
16655
+ note: z.string().min(1).optional(),
16656
+ /** Repository-relative, exactly as git names it at `sha`. */
16657
+ path: z.string().min(1),
16658
+ /** The id of a question in the Jev config this run is given. */
16659
+ question: z.string().min(1),
16660
+ /** `owner/name`. It must be the repository the command runs in. */
16661
+ repository: z.string().regex(/^[^/\s]+\/[^/\s]+$/u),
16662
+ sha: z.string().regex(/^[0-9a-f]{40}$/u),
16663
+ target: caseTargetSchema
16664
+ }).strict();
16665
+ const calibrationPoolSchema = z.object({
16666
+ cases: z.array(calibrationCaseSchema),
16667
+ pool: z.enum(CALIBRATION_POOL_NAMES),
16668
+ schemaVersion: z.literal(1),
16669
+ /** Where these labels came from, in prose. */
16670
+ source: z.string().min(1)
16671
+ }).strict();
16672
+ const poolPath = (directory, name) => path.join(directory, `${name}.json`);
16673
+ const loadPool = (directory, name) => {
16674
+ const manifestPath = poolPath(directory, name);
16675
+ const text = readFileSync(manifestPath, "utf-8");
16676
+ const parsed = calibrationPoolSchema.safeParse(JSON.parse(text));
16677
+ if (!parsed.success) throw new Error(`${manifestPath} is not a calibration manifest: ${z.prettifyError(parsed.error)}`);
16678
+ if (parsed.data.pool !== name) throw new Error(`${manifestPath} declares pool ${parsed.data.pool}, not ${name}.`);
16679
+ for (const entry of parsed.data.cases) assertRepositoryRelativePath(entry.path, `case ${entry.id}`);
16680
+ return {
16681
+ cases: parsed.data.cases,
16682
+ name,
16683
+ path: manifestPath,
16684
+ sha256: createHash("sha256").update(text).digest("hex"),
16685
+ source: parsed.data.source
16686
+ };
16687
+ };
16688
+ /**
16689
+ * Reads both pools from `directory` (`tuning.json` and `holdout.json`) and
16690
+ * checks every case against the questions it names.
16691
+ *
16692
+ * Every problem here throws, before git, Oxlint or HQ is touched. A case that
16693
+ * names a question the config does not have cannot be scored against any
16694
+ * cutoff; a case whose target shape disagrees with its question's target
16695
+ * cannot be attributed; and two cases writing the identical target would
16696
+ * have one answer counted twice. Targets that differ in spelling but name one
16697
+ * node are caught after selection, in `run.ts`, on the node itself.
16698
+ */
16699
+ const loadCalibrationPools = (directory, questions) => {
16700
+ const pools = CALIBRATION_POOL_NAMES.map((name) => loadPool(directory, name));
16701
+ const targetById = new Map(questions.map((question) => [question.id, question.target]));
16702
+ const cases = [];
16703
+ const ids = /* @__PURE__ */ new Set();
16704
+ const nodes = /* @__PURE__ */ new Set();
16705
+ for (const pool of pools) for (const entry of pool.cases) {
16706
+ if (ids.has(entry.id)) throw new Error(`case id ${entry.id} is used more than once.`);
16707
+ ids.add(entry.id);
16708
+ const target = targetById.get(entry.question);
16709
+ if (target === void 0) throw new Error(`case ${entry.id} names question ${entry.question}, which the Jev config does not ask.`);
16710
+ if (target === "file" !== (entry.target === "file")) throw new Error(`case ${entry.id}: question ${entry.question} targets ${target}, so the case target must be ${target === "file" ? "\"file\"" : "{ line, column? }"}.`);
16711
+ const node = JSON.stringify([
16712
+ entry.question,
16713
+ entry.sha,
16714
+ entry.path,
16715
+ entry.target
16716
+ ]);
16717
+ if (nodes.has(node)) throw new Error(`case ${entry.id} labels the same node as an earlier case; one node takes one label.`);
16718
+ nodes.add(node);
16719
+ cases.push({
16720
+ ...entry,
16721
+ pool: pool.name
16722
+ });
16723
+ }
16724
+ return {
16725
+ cases,
16726
+ pools
16727
+ };
16728
+ };
16729
+ //#endregion
16730
+ //#region src/jev-calibrate/score.ts
16731
+ /**
16732
+ * The one comparison. At or above the cutoff is reported, exactly as the
16733
+ * plugin reports a finding (`noul >= cutoff`), so a case scored here lands on
16734
+ * the same side it would land on in a lint run.
16735
+ */
16736
+ const statusFor = (expected, observation, cutoff) => {
16737
+ if (observation.kind === "unanswered" || observation.kind === "unavailable") return observation.kind;
16738
+ if (observation.kind === "not-selected") return expected === "yes" ? "selector-miss" : "quiet";
16739
+ const reported = observation.score >= cutoff;
16740
+ if (expected === "yes") return reported ? "caught" : "model-miss";
16741
+ return reported ? "model-miss" : "quiet";
16742
+ };
16743
+ /**
16744
+ * Totals over a set of request records. A request answered from the cache
16745
+ * adds no tokens; a failed one adds none either, and is counted as failed.
16746
+ */
16747
+ const usageOf = (requests) => {
16748
+ const usage = {
16749
+ cached: 0,
16750
+ elapsedMs: 0,
16751
+ failed: 0,
16752
+ inputTokens: 0,
16753
+ live: 0,
16754
+ models: [],
16755
+ outputTokens: 0
16756
+ };
16757
+ for (const request of requests) {
16758
+ usage.elapsedMs += request.elapsedMs;
16759
+ if (request.error !== void 0) {
16760
+ usage.failed += 1;
16761
+ continue;
16762
+ }
16763
+ if (request.cached) usage.cached += 1;
16764
+ else {
16765
+ usage.live += 1;
16766
+ usage.inputTokens += request.usage?.input_tokens ?? 0;
16767
+ usage.outputTokens += request.usage?.output_tokens ?? 0;
16768
+ }
16769
+ if (request.model !== void 0 && !usage.models.includes(request.model)) usage.models.push(request.model);
16770
+ }
16771
+ return usage;
16772
+ };
16773
+ const countOf = (outcomes, status) => outcomes.filter((outcome) => outcome.status === status).length;
16774
+ /** One question's cases, one pool at a time, in manifest order. */
16775
+ const scorePools = (cases, observations, cutoff) => CALIBRATION_POOL_NAMES.map((pool) => {
16776
+ const outcomes = cases.filter((entry) => entry.pool === pool).map((entry) => {
16777
+ const observation = observations.get(entry.id) ?? {
16778
+ kind: "unavailable",
16779
+ reason: "the run recorded nothing for this case"
16780
+ };
16781
+ return {
16782
+ expected: entry.expected,
16783
+ id: entry.id,
16784
+ knownMiss: entry.knownMiss ?? false,
16785
+ path: entry.path,
16786
+ ...observation.kind === "unanswered" || observation.kind === "unavailable" ? { reason: observation.reason } : {},
16787
+ ...observation.kind === "answered" ? { score: observation.score } : {},
16788
+ sha: entry.sha,
16789
+ status: statusFor(entry.expected, observation, cutoff),
16790
+ target: entry.target
16791
+ };
16792
+ });
16793
+ return {
16794
+ caught: countOf(outcomes, "caught"),
16795
+ compared: outcomes.filter((outcome) => outcome.score !== void 0).length,
16796
+ modelMisses: countOf(outcomes, "model-miss"),
16797
+ outcomes,
16798
+ pool,
16799
+ quiet: countOf(outcomes, "quiet"),
16800
+ selectorMisses: countOf(outcomes, "selector-miss"),
16801
+ unanswered: countOf(outcomes, "unanswered"),
16802
+ unavailable: countOf(outcomes, "unavailable")
16803
+ };
16804
+ });
16805
+ //#endregion
16806
+ //#region src/jev-calibrate/select.ts
16807
+ /**
16808
+ * Selection: the project's Oxlint, running the project's plugin in `record`
16809
+ * mode over a rebuilt tree, and nothing else.
16810
+ *
16811
+ * There is no second selector here. Which nodes are asked about is whatever
16812
+ * the plugin recorded; this module only reads the records back and says where
16813
+ * each recorded node starts, so a labeled case can be joined to it.
16814
+ *
16815
+ * ## Two Linux hazards, both invisible on macOS
16816
+ *
16817
+ * Oxlint is launched as `process.execPath` running the Oxlint package's own
16818
+ * `bin/oxlint`, with `shell: false`. The `node_modules/.bin/oxlint` shim is a
16819
+ * `#!/bin/sh` script, and on Debian and Ubuntu `/bin/sh` is dash, which drops
16820
+ * every environment variable whose name is not a shell identifier — the HQ
16821
+ * credential names are hyphenated.
16822
+ *
16823
+ * It always runs with `--threads 1`. Oxlint maps a 6 GiB arena per lint
16824
+ * thread when JS plugins load, and on a small Linux runner the plugin's
16825
+ * subprocess then fails to fork with `ENOMEM` (`src/oxlint-jev/bridge.ts`
16826
+ * carries the measurements).
16827
+ */
16828
+ /**
16829
+ * The project's Oxlint entry: the package's own `bin/oxlint`, resolved from the
16830
+ * directory the command runs in. That is the Oxlint the project's Jev config
16831
+ * runs with; there is no copy to fall back to.
16832
+ */
16833
+ const resolveProjectOxlint = (cwd) => {
16834
+ const root = installedPackageRoot(cwd, "oxlint");
16835
+ if (root === void 0) throw new Error(`Oxlint is not installed where jev:calibrate runs: no node_modules/oxlint in ${cwd} or above it. Run the command from the package whose Jev config runs Oxlint.`);
16836
+ const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf-8"));
16837
+ const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.oxlint;
16838
+ if (bin === void 0) throw new Error(`the Oxlint package at ${root} declares no oxlint bin entry.`);
16839
+ return path.join(root, bin);
16840
+ };
16841
+ /** The one way the command launches Oxlint. */
16842
+ const oxlintInvocation = (oxlintBin, files) => ({
16843
+ args: [
16844
+ oxlintBin,
16845
+ "-c",
16846
+ CALIBRATION_CONFIG_NAME,
16847
+ "-f",
16848
+ "json",
16849
+ "--threads",
16850
+ "1",
16851
+ ...files
16852
+ ],
16853
+ command: process.execPath,
16854
+ shell: false
16855
+ });
16856
+ /** Room for Oxlint's JSON report over a whole tree of recorded matches. */
16857
+ const MAX_OXLINT_OUTPUT_BYTES = 64 * 1024 * 1024;
16858
+ const describeDiagnostic = (entry) => `${entry.filename ?? "?"}:${entry.labels?.[0]?.span?.line ?? "?"} ${entry.code ?? "?"} ${entry.message ?? ""}`;
16859
+ /**
16860
+ * Runs the selection over `files` in `root`. The plugin writes one record per
16861
+ * request it would send; nothing is sent.
16862
+ *
16863
+ * The environment is the caller's, with the mode forced to `record` and the
16864
+ * HQ credentials removed: the selection run cannot spend, by construction.
16865
+ * Any non-zero exit — a plugin that did not load, a file that did not parse, a
16866
+ * `jev/unavailable` for a reference or an item too large for one request — is
16867
+ * a failed run, and every diagnostic that explains it is quoted.
16868
+ */
16869
+ const runSelection = (oxlintBin, root, files) => {
16870
+ const invocation = oxlintInvocation(oxlintBin, files);
16871
+ const withheld = new Set([CF_ACCESS_CLIENT_ID_ENV, CF_ACCESS_CLIENT_SECRET_ENV]);
16872
+ const env = {
16873
+ ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !withheld.has(name))),
16874
+ [JEV_MODE_ENV]: "record"
16875
+ };
16876
+ const child = spawnSync(invocation.command, invocation.args, {
16877
+ cwd: root,
16878
+ encoding: "utf-8",
16879
+ env,
16880
+ maxBuffer: MAX_OXLINT_OUTPUT_BYTES,
16881
+ shell: invocation.shell
16882
+ });
16883
+ if (child.error !== void 0) throw new Error(`Oxlint could not run: ${child.error.code ?? child.error.message}`);
16884
+ let diagnostics;
16885
+ try {
16886
+ ({diagnostics} = JSON.parse(child.stdout));
16887
+ } catch {
16888
+ diagnostics = void 0;
16889
+ }
16890
+ if (child.status !== 0 || diagnostics === void 0) {
16891
+ const explained = (diagnostics ?? []).filter((entry) => entry.code !== "jev(ask)").map(describeDiagnostic);
16892
+ throw new Error([
16893
+ `the selection run exited with status ${String(child.status)} in a tree rebuilt at one sha.`,
16894
+ ...explained,
16895
+ child.stderr.trim()
16896
+ ].filter((line) => line !== "").join("\n"));
16897
+ }
16898
+ };
16899
+ /** One file's recorded request, as the plugin wrote it. */
16900
+ const recordSchema = z.object({
16901
+ body: z.object({
16902
+ questions: z.record(z.string(), z.unknown()),
16903
+ state: z.object({
16904
+ file: z.object({ source: z.string() }).loose(),
16905
+ matches: z.record(z.string(), z.unknown()),
16906
+ path: z.string()
16907
+ })
16908
+ }),
16909
+ matchKeys: z.array(z.string()).min(1),
16910
+ path: z.string(),
16911
+ requestHash: z.string()
16912
+ });
16913
+ /**
16914
+ * A match key is `<question id>@<start>-<end>`: the plugin's own key, whose
16915
+ * range is the selected node's offsets in the file text. The question id is
16916
+ * everything before the last `@`, since an id may itself contain one.
16917
+ */
16918
+ const parseMatchKey = (key) => {
16919
+ const at = key.lastIndexOf("@");
16920
+ const range = /^(?<start>\d+)-\d+$/u.exec(key.slice(at + 1));
16921
+ if (at <= 0 || range?.groups?.start === void 0) throw new Error(`the plugin recorded a match key calibration cannot read: ${key}`);
16922
+ return {
16923
+ question: key.slice(0, at),
16924
+ start: Number(range.groups.start)
16925
+ };
16926
+ };
16927
+ /** 1-based line and column of a string offset, as an editor counts them. */
16928
+ const positionOf = (source, offset) => {
16929
+ const before = source.slice(0, offset);
16930
+ return {
16931
+ column: offset - before.lastIndexOf("\n"),
16932
+ line: before.split("\n").length
16933
+ };
16934
+ };
16935
+ /**
16936
+ * One recorded request rebuilt as the `JevFile` it came from.
16937
+ *
16938
+ * The rebuilt file is fed back through `buildJevRequests`, and the bytes must
16939
+ * hash to the plugin's own `requestHash`. That makes "grouped mode sends
16940
+ * exactly what the plugin would send" a checked fact rather than a claim: a
16941
+ * mismatch is a bug and ends the run.
16942
+ */
16943
+ const rebuildUnit = (recordPath) => {
16944
+ const record = recordSchema.parse(JSON.parse(readFileSync(recordPath, "utf-8")));
16945
+ const { questions, state } = record.body;
16946
+ const unit = {
16947
+ matches: record.matchKeys.map((key) => ({
16948
+ key,
16949
+ questions: { [key]: questions[key] },
16950
+ state: state.matches[key]
16951
+ })),
16952
+ path: state.path,
16953
+ shared: state.file
16954
+ };
16955
+ const rebuilt = buildJevRequests({ files: [unit] });
16956
+ if (rebuilt.requests.length !== 1 || rebuilt.unavailable.length > 0 || rebuilt.requests[0]?.hash !== record.requestHash) throw new Error(`the request rebuilt from ${recordPath} does not match the bytes the plugin recorded; calibration would measure a request the plugin never sends.`);
16957
+ return unit;
16958
+ };
16959
+ /**
16960
+ * Runs the selection over the rebuilt tree at `root` and returns every node
16961
+ * the plugin selected. `files` are the repository-relative case files that
16962
+ * exist there.
16963
+ */
16964
+ const selectMatches = (input) => {
16965
+ runSelection(input.oxlintBin, input.root, input.files);
16966
+ const directory = recordDirectoryFor(defaultJevCacheDirectory(input.root));
16967
+ if (!existsSync(directory)) return [];
16968
+ const selected = [];
16969
+ for (const name of readdirSync(directory).toSorted()) {
16970
+ const unit = rebuildUnit(path.join(directory, name));
16971
+ const { source } = unit.shared;
16972
+ for (const match of unit.matches) {
16973
+ const { question, start } = parseMatchKey(match.key);
16974
+ selected.push({
16975
+ ...positionOf(source, start),
16976
+ key: match.key,
16977
+ path: unit.path,
16978
+ question,
16979
+ unit
16980
+ });
16981
+ }
16982
+ }
16983
+ return selected;
16984
+ };
16985
+ //#endregion
16986
+ //#region src/jev-calibrate/run.ts
16987
+ /**
16988
+ * `psf jev:calibrate`: run the project's labeled cases through the project's
16989
+ * own Jev selection, ask HQ, and report each question against its own cutoff.
16990
+ *
16991
+ * The shape of a run:
16992
+ *
16993
+ * 1. Every case's file is rebuilt from git at its sha into a fresh tree, one
16994
+ * tree per sha, at its repository-relative path — so the request the plugin
16995
+ * builds names the same path a lint run from the repository root would.
16996
+ * 2. The project's Oxlint runs the project's plugin over each tree in `record`
16997
+ * mode. That is the one selection path: a node is asked about if and only
16998
+ * if the plugin recorded it.
16999
+ * 3. The command sends. Grouped (the default) sends each recorded request
17000
+ * exactly as recorded — one request per file, every match in it — and
17001
+ * `--solo` sends each labeled match in its own request with the same shared
17002
+ * state. The plugin cannot send solo, and must not learn to: `--solo` is a
17003
+ * measurement of this command, not a product mode. Both go through the
17004
+ * shared Jev client with a cache that is fresh for this run.
17005
+ * 4. Each case is scored against its question's own cutoff.
17006
+ *
17007
+ * A Jev report is a session diagnostic (ADR 0034). This command gates
17008
+ * nothing, is never a verification command, and emits no evidence. It exits
17009
+ * non-zero only when the run itself did not complete: a case that could not be
17010
+ * rebuilt, a request that failed, or a run that compared no answers at all.
17011
+ */
17012
+ const REPORT_SCHEMA_VERSION = 1;
17013
+ /** A file's bytes at a sha, or why there are none. Never a silent absence. */
17014
+ const blobAt = (root, sha, filePath) => {
17015
+ try {
17016
+ return { content: runCapture("git", [
17017
+ "cat-file",
17018
+ "blob",
17019
+ `${sha}:${filePath}`
17020
+ ], root).stdout };
17021
+ } catch {
17022
+ return { reason: `${filePath} does not exist at ${sha}` };
17023
+ }
17024
+ };
17025
+ const commitReachable = (root, sha) => {
17026
+ try {
17027
+ runCapture("git", [
17028
+ "cat-file",
17029
+ "-e",
17030
+ `${sha}^{commit}`
17031
+ ], root);
17032
+ return true;
17033
+ } catch {
17034
+ return false;
17035
+ }
17036
+ };
17037
+ const writeInto = (tree, filePath, content) => {
17038
+ const target = path.join(tree, filePath);
17039
+ mkdirSync(path.dirname(target), { recursive: true });
17040
+ writeFileSync(target, content, "utf-8");
17041
+ };
17042
+ /**
17043
+ * Rebuilds one sha's tree and runs the selection over it. Returns the selected
17044
+ * nodes, and every case at this sha that could not be rebuilt, with why.
17045
+ *
17046
+ * References are rebuilt at the same sha as the code: a lint run at that
17047
+ * commit would have read them there. One that is absent is left absent, and
17048
+ * the plugin reports it as `jev/unavailable` if a question that needs it
17049
+ * matched — which fails the run by name.
17050
+ */
17051
+ const selectAtSha = (input) => {
17052
+ const unavailable = /* @__PURE__ */ new Map();
17053
+ const { cases, repositoryRootPath: root, sha, tree } = input;
17054
+ if (!commitReachable(root, sha)) {
17055
+ for (const entry of cases) unavailable.set(entry.id, `${sha} is not a commit in this checkout: fetch it, or it has been garbage collected`);
17056
+ return {
17057
+ selected: [],
17058
+ unavailable
17059
+ };
17060
+ }
17061
+ const files = [];
17062
+ for (const filePath of new Set(cases.map((entry) => entry.path))) {
17063
+ const blob = blobAt(root, sha, filePath);
17064
+ if ("reason" in blob) {
17065
+ for (const entry of cases) if (entry.path === filePath) unavailable.set(entry.id, blob.reason);
17066
+ continue;
17067
+ }
17068
+ writeInto(tree, filePath, blob.content);
17069
+ files.push(filePath);
17070
+ }
17071
+ if (files.length === 0) return {
17072
+ selected: [],
17073
+ unavailable
17074
+ };
17075
+ const references = new Set(input.config.questions.flatMap((question) => question.references ?? []));
17076
+ for (const reference of references) {
17077
+ if (files.includes(reference)) continue;
17078
+ const blob = blobAt(root, sha, reference);
17079
+ if ("content" in blob) writeInto(tree, reference, blob.content);
17080
+ }
17081
+ writeFileSync(path.join(tree, CALIBRATION_CONFIG_NAME), `${JSON.stringify(calibrationOxlintConfig(input.config), null, 2)}\n`, "utf-8");
17082
+ return {
17083
+ selected: selectMatches({
17084
+ files,
17085
+ oxlintBin: input.oxlintBin,
17086
+ root: tree
17087
+ }),
17088
+ unavailable
17089
+ };
17090
+ };
17091
+ /**
17092
+ * The selected node a case labels, or why it cannot be named. A case that
17093
+ * names two nodes is refused rather than resolved: attributing one node's
17094
+ * answer to another's label is worse than not scoring the case.
17095
+ */
17096
+ const locate = (entry, selected) => {
17097
+ const { target } = entry;
17098
+ const candidates = selected.filter((match) => match.path === entry.path && match.question === entry.question && (target === "file" || match.line === target.line && (target.column === void 0 || match.column === target.column)));
17099
+ if (candidates.length > 1) return { reason: `${candidates.length} selected nodes of ${entry.question} start on line ${target === "file" ? "?" : target.line} of ${entry.path}; name a column` };
17100
+ return { match: candidates[0] };
17101
+ };
17102
+ const questionOfKey = (key) => key.slice(0, key.lastIndexOf("@"));
17103
+ /**
17104
+ * The files to send. Grouped: each recorded request that carries at least one
17105
+ * labeled match, whole and unchanged — its other matches ride along exactly as
17106
+ * they would in a lint run. Solo: one request per labeled match, with the same
17107
+ * shared state and nothing else.
17108
+ *
17109
+ * A case's answer is read back from the unit it was sent in, never looked up
17110
+ * by match key across units: a key is unique within its file only.
17111
+ */
17112
+ const unitsToSend = (mode, labeled) => {
17113
+ if (mode === "solo") return [...labeled].map(([caseId, match]) => ({
17114
+ cases: new Map([[caseId, match.key]]),
17115
+ file: {
17116
+ ...match.unit,
17117
+ matches: match.unit.matches.filter((candidate) => candidate.key === match.key)
17118
+ }
17119
+ }));
17120
+ const byFile = /* @__PURE__ */ new Map();
17121
+ for (const [caseId, match] of labeled) {
17122
+ const unit = byFile.get(match.unit) ?? {
17123
+ cases: /* @__PURE__ */ new Map(),
17124
+ file: match.unit
17125
+ };
17126
+ unit.cases.set(caseId, match.key);
17127
+ byFile.set(match.unit, unit);
17128
+ }
17129
+ return [...byFile.values()];
17130
+ };
17131
+ /**
17132
+ * Rebuilds every sha's tree, runs the selection over it, and joins each case
17133
+ * to the node it labels. Cases that cannot be rebuilt or attributed are
17134
+ * observed as unavailable; cases the plugin did not select, as not selected.
17135
+ * Two cases that resolve to one node end the run before anything is sent:
17136
+ * one answer would otherwise count twice, possibly in both pools and under
17137
+ * two labels.
17138
+ */
17139
+ const observeSelection = (input) => {
17140
+ const observations = /* @__PURE__ */ new Map();
17141
+ const labeled = /* @__PURE__ */ new Map();
17142
+ const bySha = /* @__PURE__ */ new Map();
17143
+ for (const entry of input.cases) if (normalizeRepositorySlug(entry.repository) === input.repository) bySha.set(entry.sha, [...bySha.get(entry.sha) ?? [], entry]);
17144
+ else observations.set(entry.id, {
17145
+ kind: "unavailable",
17146
+ reason: `the case names ${entry.repository}; this checkout is ${input.repository}`
17147
+ });
17148
+ for (const [sha, shaCases] of bySha) {
17149
+ const tree = path.join(input.scratch, "trees", sha);
17150
+ mkdirSync(tree, { recursive: true });
17151
+ const { selected, unavailable } = selectAtSha({
17152
+ cases: shaCases,
17153
+ config: input.config,
17154
+ oxlintBin: input.oxlintBin,
17155
+ repositoryRootPath: input.root,
17156
+ sha,
17157
+ tree
17158
+ });
17159
+ const caseByNode = /* @__PURE__ */ new Map();
17160
+ for (const entry of shaCases) {
17161
+ const reason = unavailable.get(entry.id);
17162
+ const located = reason === void 0 ? locate(entry, selected) : { reason };
17163
+ if ("reason" in located) observations.set(entry.id, {
17164
+ kind: "unavailable",
17165
+ reason: located.reason
17166
+ });
17167
+ else if (located.match === void 0) observations.set(entry.id, { kind: "not-selected" });
17168
+ else {
17169
+ const node = `${located.match.path}\0${located.match.key}`;
17170
+ const earlier = caseByNode.get(node);
17171
+ if (earlier !== void 0) throw new Error(`cases ${earlier.id} (${earlier.pool}) and ${entry.id} (${entry.pool}) both resolve to the same selected node of ${entry.question} in ${entry.path} at ${sha}; one node takes one case.`);
17172
+ caseByNode.set(node, entry);
17173
+ labeled.set(entry.id, located.match);
17174
+ }
17175
+ }
17176
+ }
17177
+ return {
17178
+ labeled,
17179
+ observations
17180
+ };
17181
+ };
17182
+ /**
17183
+ * Sends every unit, one at a time, through the shared Jev client and a cache
17184
+ * that is fresh for this run: an answer bought by an earlier run is never
17185
+ * counted as this run's measurement.
17186
+ *
17187
+ * Each unit gets its own `runJevFiles` call. A match key is unique within its
17188
+ * file only, so two files are never routed through one answer map.
17189
+ */
17190
+ const sendUnits = async (units, gateway, cacheDirectory) => {
17191
+ const cache = openJevCache({
17192
+ directory: cacheDirectory,
17193
+ endpoint: gateway.endpoint
17194
+ });
17195
+ const sent = [];
17196
+ for (const unit of units) {
17197
+ const result = await runJevFiles({
17198
+ cache,
17199
+ files: [unit.file],
17200
+ gateway
17201
+ });
17202
+ sent.push({
17203
+ ...unit,
17204
+ result
17205
+ });
17206
+ }
17207
+ return sent;
17208
+ };
17209
+ /** Each sent case's answer, read back from the unit it was sent in. */
17210
+ const observeAnswers = (units, observations) => {
17211
+ for (const unit of units) for (const [caseId, key] of unit.cases) {
17212
+ const answer = unit.result.answersByMatchKey[key]?.[key];
17213
+ observations.set(caseId, answer?.type === "noul" ? {
17214
+ kind: "answered",
17215
+ score: answer.noul
17216
+ } : {
17217
+ kind: "unanswered",
17218
+ reason: unit.result.unavailableByMatchKey[key] ?? "the send returned no answer and no reason"
17219
+ });
17220
+ }
17221
+ };
17222
+ const questionReport = (question, cases, observations, units) => {
17223
+ const carries = (key) => questionOfKey(key) === question.id;
17224
+ const requests = units.filter((unit) => unit.file.matches.some((match) => carries(match.key))).flatMap((unit) => unit.result.requests);
17225
+ return {
17226
+ cutoff: question.cutoff,
17227
+ id: question.id,
17228
+ pools: scorePools(cases.filter((entry) => entry.question === question.id), observations, question.cutoff),
17229
+ sentMatches: requests.flatMap((request) => request.matchKeys).filter(carries).length,
17230
+ target: question.target,
17231
+ textHash: question.textHash,
17232
+ usage: usageOf(requests)
17233
+ };
17234
+ };
17235
+ /** Every reason the run did not complete, each named. */
17236
+ const failuresOf = (questions, units, compared) => {
17237
+ const failures = units.flatMap((unit) => unit.result.requests).filter((request) => request.error !== void 0).map((request) => `request for ${request.path} (${request.matchKeys.length} match(es)) failed: ${request.error ?? ""}`);
17238
+ for (const question of questions) for (const pool of question.pools) for (const outcome of pool.outcomes) if (outcome.status === "unanswered" || outcome.status === "unavailable") failures.push(`case ${outcome.id} (${question.id}, ${pool.pool}) is ${outcome.status}: ${outcome.reason ?? ""}`);
17239
+ if (compared === 0) failures.push("the run compared zero answers: no labeled case was both selected and answered, so nothing was measured");
17240
+ return failures;
17241
+ };
17242
+ const runJevCalibration = async ({ casesDirectory, configPath, cwd, env = process.env, generatedAt, mode }) => {
17243
+ if (resolveJevMode(env) !== "live") throw new Error(`jev:calibrate asks HQ and runs only with ${JEV_MODE_ENV}=live. To see what a question selects without spending, run the Jev config itself in record mode.`);
17244
+ const gateway = await resolveJevGateway({ env });
17245
+ if (gateway.status !== "ready") throw new Error(`jev:calibrate cannot ask HQ: ${gateway.reason}`);
17246
+ const config = loadJevCalibrationConfig(configPath);
17247
+ const { cases, pools } = loadCalibrationPools(casesDirectory, config.questions);
17248
+ const oxlintBin = resolveProjectOxlint(cwd);
17249
+ const root = repositoryRoot(cwd);
17250
+ const repository = repositorySlug(checkoutRepository(root));
17251
+ const scratch = mkdtempSync(path.join(tmpdir(), "jev-calibrate-"));
17252
+ let observations;
17253
+ let units;
17254
+ try {
17255
+ const selection = observeSelection({
17256
+ cases,
17257
+ config,
17258
+ oxlintBin,
17259
+ repository,
17260
+ root,
17261
+ scratch
17262
+ });
17263
+ ({observations} = selection);
17264
+ units = await sendUnits(unitsToSend(mode, selection.labeled), gateway, path.join(scratch, "cache"));
17265
+ } finally {
17266
+ rmSync(scratch, {
17267
+ force: true,
17268
+ recursive: true
17269
+ });
17270
+ }
17271
+ observeAnswers(units, observations);
17272
+ const questions = config.questions.map((question) => questionReport(question, cases, observations, units));
17273
+ const compared = questions.flatMap((question) => question.pools).reduce((total, pool) => total + pool.compared, 0);
17274
+ return {
17275
+ casesDirectory,
17276
+ compared,
17277
+ configPath,
17278
+ failures: failuresOf(questions, units, compared),
17279
+ generatedAt: generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
17280
+ manifests: pools.map((pool) => ({
17281
+ path: pool.path,
17282
+ pool: pool.name,
17283
+ sha256: pool.sha256
17284
+ })),
17285
+ mode,
17286
+ questions,
17287
+ schemaVersion: REPORT_SCHEMA_VERSION,
17288
+ usage: usageOf(units.flatMap((unit) => unit.result.requests))
17289
+ };
17290
+ };
17291
+ //#endregion
17292
+ //#region src/commands/jev-calibrate.ts
17293
+ /**
17294
+ * `psf jev:calibrate` — ask HQ about a project's labeled Jev cases through the
17295
+ * project's own Oxlint and plugin, and report each question against its own
17296
+ * cutoff: caught, quiet, selector misses, model misses, usage and timing, for
17297
+ * the tuning pool and the holdout apart.
17298
+ *
17299
+ * **Exit codes.** Zero when the run completed, whatever Jev answered (ADR
17300
+ * 0034). Non-zero when it did not: a case that could not be rebuilt, a request
17301
+ * that failed, a run that compared zero answers, or a selection run that did
17302
+ * not finish. The report is printed first, so the reason is on screen.
17303
+ */
17304
+ function createJevCalibrateCommand(output, action = runJevCalibration) {
17305
+ return markCwdOptionDefault(new Command("jev:calibrate").description("Run labeled Jev cases through the project's own Oxlint and Jev plugin, ask HQ, and report each question's caught, quiet, selector misses and model misses against its own cutoff, for the tuning pool and the holdout apart. Spends only with PATRONAGE_JEV_MODE=live. A session diagnostic: never a gate").option("--cwd <path>", "directory the project's Oxlint and Jev plugin resolve from", collectCwdOption).requiredOption("--config <path>", "the project's dedicated Jev Oxlint config").requiredOption("--cases <dir>", "directory holding the tuning.json and holdout.json case manifests").option("--solo", "send each labeled match in its own request instead of the plugin's one request per file").option("--json", "print the JSON report instead of text").action(async (options) => {
17306
+ const cwd = resolveCwdOption(options.cwd);
17307
+ const report = await action({
17308
+ casesDirectory: path.resolve(cwd, options.cases),
17309
+ configPath: path.resolve(cwd, options.config),
17310
+ cwd,
17311
+ mode: options.solo === true ? "solo" : "grouped"
17312
+ });
17313
+ output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderJevCalibrationText(report));
17314
+ if (report.failures.length > 0) throw new Error(`jev:calibrate did not complete: ${report.failures.length} failure(s), listed in the report.`);
17315
+ }));
17316
+ }
17317
+ //#endregion
15639
17318
  //#region src/follow-up.ts
15640
17319
  const FACTORY_CLI_EXECUTABLE = "patronage-factory";
15641
17320
  const FactoryCliInvocationSchema = z.object({
@@ -22618,6 +24297,7 @@ function createProgram(options = {}) {
22618
24297
  program.addCommand(createEvidenceEmitCommand(output));
22619
24298
  program.addCommand(createCiAnalyzeCommand(output));
22620
24299
  program.addCommand(createHqFlushCommand(output));
24300
+ program.addCommand(createJevCalibrateCommand(output));
22621
24301
  program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
22622
24302
  program.addCommand(createPrVerifyCommand(output));
22623
24303
  program.addCommand(createProductionImpactCommand(output));
@@ -22647,4 +24327,4 @@ async function run(argv = process.argv, cliEntry = factoryCliEntry()) {
22647
24327
  }
22648
24328
  }
22649
24329
  //#endregion
22650
- export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DEFAULT_ROOT_SHARED_GLOBS, DemandWaiveRefusalError, EMPTY_TREE_OBJECT_HASH, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FACTORY_BASE_REF_ENV, FACTORY_CHANGED_FILES_ENV, FACTORY_CHANGED_FILES_FILE_ENV, FactoryCliInvocationSchema, FollowUpActionSchema, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, REVIEW_FOCUS_SECTION, WorkerCheckoutGuardError, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertFactoryPrStatusIdentity, assertWorkerCheckoutAllowed, authorizeDemandWaiver, batteryScopeSummaryLines, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildReviewGateNotRequiredTraceEvent, comment_provenance_exports as commentProvenance, createGitHubCheckRunMergeFreezeStore, createLocalJsonlTraceSink, createProgram, createVerificationRunContext, doctorProjectProfile, epicStructureEventId, evidenceEnvelopeFilename, followUpFromArgv, impactStampScopeDecision, isProductionHqUrl, loadProjectProfile, normalizeIssueComments, planFactoryPrStatusHud, planPreviewLifecycle, planPublishFollowUp, planVerificationBattery, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, presentFactoryPrStatusHud, previewLifecycleTargets, publishEpicStructure, readDemandWaivers, readFactoryPrStatusSources, readPrReadyProof, refreshFactoryPrStatusHud, refreshFactoryPrStatusHudSafely, renderFactoryPrStatusHud, resolveFactoryRepository, resolveTraceWriteSinks, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, selectWaiversForCandidate, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrVerifyProof, validatePreviewLifecyclePlan, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };
24330
+ export { DEFAULT_DEMAND_WAIVER_PATH, DEFAULT_FACTORY_REPOSITORY, DEFAULT_ROOT_SHARED_GLOBS, DemandWaiveRefusalError, EMPTY_TREE_OBJECT_HASH, EPIC_STRUCTURE_NODE_STATUSES, EPIC_STRUCTURE_SCHEMA_VERSION, EpicStructureValidationError, FACTORY_BASE_REF_ENV, FACTORY_CHANGED_FILES_ENV, FACTORY_CHANGED_FILES_FILE_ENV, FactoryCliInvocationSchema, FollowUpActionSchema, JevCacheError, JevRequestError, MERGE_FREEZE_APP_SLUG, MERGE_FREEZE_CHECK_NAME, PrPublishFollowUpError, REVIEW_FOCUS_SECTION, UNKNOWN_MODEL, WorkerCheckoutGuardError, appendWithTraceSinks, applyDemandWaiver, assembleReviewPrompt, assertFactoryPrStatusIdentity, assertWorkerCheckoutAllowed, authorizeDemandWaiver, batteryScopeSummaryLines, boundary_manifest_exports as boundaryManifest, boundary_review_proof_exports as boundaryReviewProof, buildEpicStructureEvent, buildEpicStructurePayload, buildEvidenceEnvelope, buildJevRequests, buildReviewGateNotRequiredTraceEvent, choice, comment_provenance_exports as commentProvenance, createGitHubCheckRunMergeFreezeStore, createLocalJsonlTraceSink, createProgram, createVerificationRunContext, defaultJevCacheDirectory, doctorProjectProfile, epicStructureEventId, evidenceEnvelopeFilename, followUpFromArgv, impactStampScopeDecision, isProductionHqUrl, loadProjectProfile, normalizeIssueComments, noul, openJevCache, planFactoryPrStatusHud, planPreviewLifecycle, planPublishFollowUp, planVerificationBattery, readiness_evaluation_exports as prReadinessEvaluation, external_evidence_exports as prReadinessExternalEvidence, pr_body_renderer_exports as prReadinessPrBodyRenderer, proof_identity_exports as prReadinessProofIdentity, review_proof_exports as prReadinessReviewProof, status_check_rollup_exports as prReadinessStatusChecks, verification_proof_exports as prReadinessVerificationProof, presentFactoryPrStatusHud, previewLifecycleTargets, publishEpicStructure, readDemandWaivers, readFactoryPrStatusSources, readPrReadyProof, refreshFactoryPrStatusHud, refreshFactoryPrStatusHudSafely, renderFactoryPrStatusHud, resolveFactoryRepository, resolveJevGateway, resolveTraceWriteSinks, reviewFocusFromIssueBody, reviewPromptSectionSchema, reviewPromptSectionsSchema, run, runBoundaryCheck, runDemandWaive, runEvidenceEmit, runJevFiles, runPrPublish, runPrReady, runPrReview, runPrVerify, scanFactoryTraceDiagnostics, scanFactoryTraceEvents, score, selectWaiversForCandidate, toFactoryTraceEnvelope, tryAppendReviewGateNotRequiredTraceEvent, validateBoundaryCheckProof, validateDagDocument, validateDemandWaiverStore, validateFactoryTraceEvent, validateMergeFreezeState, validatePrReadyProof, validatePrVerifyProof, validatePreviewLifecyclePlan, waivedDemandNotice, waivedDemandSchema, worktree_scratch_files_exports as worktreeScratchFiles };