@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/README.md +2 -0
- package/dist/{chunk-pbuEa-1d.js → chunk-DrSxFLj_.js} +1 -0
- package/dist/index.d.ts +285 -15
- package/dist/index.js +1774 -94
- package/dist/oxlint-jev/bridge-B4Sx95ZO.js +1007 -0
- package/dist/oxlint-jev/index.d.ts +6 -0
- package/dist/oxlint-jev/index.js +581 -0
- package/dist/oxlint-jev/worker.d.ts +1 -0
- package/dist/oxlint-jev/worker.js +49 -0
- package/package.json +8 -2
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { t as __exportAll } from "./chunk-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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$
|
|
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 };
|