@rightkit/release 0.2.150 → 0.2.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE-APACHE +201 -0
- package/LICENSE-MIT +21 -0
- package/README.md +119 -8
- package/build-release.mjs +16 -2
- package/cli/right-release.mjs +18 -5
- package/committed-source.mjs +19 -1
- package/crate-graph.mjs +121 -0
- package/development-signing.mjs +8 -0
- package/development.mjs +46 -13
- package/devtools-policy.json +5 -0
- package/devtools-policy.mjs +119 -0
- package/direct-bootstrap.mjs +9 -3
- package/dmg-build.mjs +19 -5
- package/dmg-layout.mjs +2 -2
- package/dmg-spec.mjs +97 -0
- package/execution-policy.mjs +17 -1
- package/github-release.mjs +25 -7
- package/github-upload-gate.mjs +84 -0
- package/hardeningscan.mjs +3 -0
- package/inventory-check.mjs +4 -4
- package/mac-bundle-signing.mjs +1 -1
- package/mac-sign-core.mjs +97 -0
- package/mac-signing-matrix.mjs +186 -0
- package/macos-notarization.mjs +18 -2
- package/msix-appinstaller.mjs +4 -1
- package/msix-config.mjs +5 -1
- package/msix-repair-guard.mjs +24 -0
- package/msix-repair-guard.ps1 +356 -0
- package/notarize-macos.mjs +4 -3
- package/ownership-catalog-config.json +6 -7
- package/ownership-command.mjs +69 -8
- package/ownership-evidence.mjs +1 -1
- package/ownership-key-authority.mjs +1 -1
- package/ownership-key-command.mjs +15 -3
- package/ownership-key-elevation.mjs +11 -2
- package/ownership-mandatory-set.json +420 -0
- package/ownership-mandatory-set.mjs +273 -0
- package/ownership.mjs +2 -2
- package/package.json +15 -3
- package/packaged-app-identity.mjs +88 -0
- package/preflight.mjs +25 -3
- package/prune-r2.mjs +28 -3
- package/publication-gate.mjs +49 -10
- package/publish-cargo.mjs +21 -1
- package/publish-swift.mjs +78 -141
- package/publish-update.mjs +24 -2
- package/qa-build-features.mjs +216 -0
- package/qa-control-scan.mjs +435 -0
- package/registry-parity.mjs +18 -1
- package/release-invocation.mjs +2 -1
- package/release-state.mjs +9 -0
- package/release-switch-registry.json +33 -0
- package/release-switch-scan.mjs +452 -0
- package/release.mjs +324 -26
- package/renamed-away.mjs +29 -0
- package/rightkit-ownership.json +682 -709
- package/rightkit-sync.mjs +8 -4
- package/rightkit-version-policy.mjs +18 -0
- package/rightkit-versions.json +212 -127
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +870 -0
- package/runtime-bundle-scan.mjs +61 -0
- package/sign-macos.mjs +28 -30
- package/sign-updater.mjs +5 -2
- package/swift-mac-dmg.mjs +7 -2
- package/swift-package-command.mjs +1 -1
- package/upload-release.mjs +10 -37
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
// Runtime budget gate for long-running shared crates (docs/RUNTIME-BUDGETS.md).
|
|
2
|
+
//
|
|
3
|
+
// Membership is fail-closed: every crate that reaches `right-release publish cargo` must carry an
|
|
4
|
+
// explicit declaration, either
|
|
5
|
+
// * `runtime-budget.json` (a continuous crate: ceilings, status pointer, measurements with raw
|
|
6
|
+
// bench evidence), or
|
|
7
|
+
// * `[package.metadata.rightkit] continuous = false` plus a one-line `continuous_reason`.
|
|
8
|
+
// A crate with neither is refused. A crate declared `continuous = false` is refused when its non-test
|
|
9
|
+
// `src` matches a continuous-work pattern (CONTINUOUS_PATTERNS) that the reason does not name, so a
|
|
10
|
+
// false positive is a reviewed decision, never a silent one. The 10 crates in RUNTIME_BUDGET_CRATES
|
|
11
|
+
// can never declare `continuous = false`.
|
|
12
|
+
//
|
|
13
|
+
// runtime-budget.json (schema 1):
|
|
14
|
+
// {
|
|
15
|
+
// "schema": 1,
|
|
16
|
+
// "crate": "rightkit-fsindex",
|
|
17
|
+
// "hostPolicy": "darwin-only", // optional; must agree with the release evidence hostPolicy
|
|
18
|
+
// "hostPolicyReason": "no Windows backend",
|
|
19
|
+
// "ceilings": { "residentMemoryBytes": N, "idleCpuPercent": N, "wakeupsPerSecond": N, "diskOpsPerSecond": N }, // null = undeclared, refused
|
|
20
|
+
// "ceilingSources": { "residentMemoryBytes": "path:line" },
|
|
21
|
+
// "requiredScale": { "unit": "entries", "minCount": 1000000, "source": "path:line" }, // null = undeclared, refused
|
|
22
|
+
// "status": { "api": "Index::status()", "file": "src/index.rs", "exposes": ["residentMemoryBytes", ...] },
|
|
23
|
+
// "measurements": [ {
|
|
24
|
+
// "host": { "os": "darwin", "arch": "arm64", "model": ".." }, "crateVersion": "0.2.0",
|
|
25
|
+
// "commit": "<40-hex>", "date": "2026-10-11", "scope": "synthetic" | "real" | "installed",
|
|
26
|
+
// "app": { "name": "..", "version": ".." }, // installed scope only
|
|
27
|
+
// "dataScale": { "description": "..", "unit": "entries", "count": 1000000 },
|
|
28
|
+
// "command": "..", "observed": { "residentMemoryBytes": N, "idleCpuPercent": N, "wakeupsPerSecond": N, "diskOpsPerSecond": N },
|
|
29
|
+
// "evidence": { "file": "runtime-budget.evidence/darwin-arm64-0.2.0-synthetic.txt", "sha256": "<64-hex>" }
|
|
30
|
+
// } ]
|
|
31
|
+
// }
|
|
32
|
+
// Measurements are written by `node runtime-budgets.mjs record`, never typed: the raw bench output is
|
|
33
|
+
// stored under runtime-budget.evidence/, and the gate re-parses it and compares every value.
|
|
34
|
+
//
|
|
35
|
+
// Bench output the `record` command accepts (exactly one such line in the file):
|
|
36
|
+
// RESULT budget crate=<name> version=<semver> resident_bytes=<int> idle_cpu_percent=<n> wakeups_per_s=<n> disk_ops_per_s=<n> scale_unit=<word> scale_count=<int>
|
|
37
|
+
//
|
|
38
|
+
// Check objects use the preflight shape ({ name, status, detail, fix }) so formatPreflight() in
|
|
39
|
+
// preflight.mjs prints them as `[FAIL] runtime-budget: ...`.
|
|
40
|
+
|
|
41
|
+
import crypto from "node:crypto";
|
|
42
|
+
import fs, { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
43
|
+
import os from "node:os";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
import { spawnSync } from "./background-process.mjs";
|
|
47
|
+
import { parse } from "smol-toml";
|
|
48
|
+
import { isDarwinOnlyPolicy } from "./publication-gate.mjs";
|
|
49
|
+
import { findWorkspaceRoot, lockClosure, pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
50
|
+
|
|
51
|
+
export const RUNTIME_BUDGET_CHECK = "runtime-budget";
|
|
52
|
+
export const RUNTIME_BUDGET_CODE = "RUNTIME_BUDGET_MISSING";
|
|
53
|
+
export const RUNTIME_BUDGET_FILE = "runtime-budget.json";
|
|
54
|
+
export const RUNTIME_BUDGET_EVIDENCE_DIR = "runtime-budget.evidence";
|
|
55
|
+
export const RUNTIME_BUDGET_SCHEMA = 1;
|
|
56
|
+
|
|
57
|
+
export const RUNTIME_BUDGET_CRATES = Object.freeze([
|
|
58
|
+
"rightkit-fsindex",
|
|
59
|
+
"rightkit-file-watch",
|
|
60
|
+
"rightkit-job-queue",
|
|
61
|
+
"rightkit-daemon",
|
|
62
|
+
"rightkit-cache",
|
|
63
|
+
"rightkit-sync",
|
|
64
|
+
"rightkit-logs",
|
|
65
|
+
"rightkit-tauri-shell",
|
|
66
|
+
"rightkit-qa",
|
|
67
|
+
"rightkit-send",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
export const BUDGET_FIGURES = Object.freeze(["residentMemoryBytes", "idleCpuPercent", "wakeupsPerSecond", "diskOpsPerSecond"]);
|
|
71
|
+
const FIGURE_LABEL = Object.freeze({
|
|
72
|
+
residentMemoryBytes: "resident memory",
|
|
73
|
+
idleCpuPercent: "idle CPU",
|
|
74
|
+
wakeupsPerSecond: "wake-ups",
|
|
75
|
+
diskOpsPerSecond: "disk operations",
|
|
76
|
+
});
|
|
77
|
+
// The crate's own paths, and the paths of every workspace crate it depends on through `path`
|
|
78
|
+
// dependencies, whose change after a measurement makes the measurement stale (see measuredPathspecs).
|
|
79
|
+
export const CRATE_PATHS = Object.freeze(["src", "benches", "Cargo.toml", "build.rs"]);
|
|
80
|
+
export const DEPENDENCY_PATHS = Object.freeze(["src", "Cargo.toml", "build.rs"]);
|
|
81
|
+
// Same host rule as the publication gate (publication-gate.mjs REQUIRED_HOSTS).
|
|
82
|
+
const HOSTS = Object.freeze({ darwin: "macOS", win32: "Windows" });
|
|
83
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
84
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
85
|
+
const SCOPES = Object.freeze(["synthetic", "real", "installed"]);
|
|
86
|
+
|
|
87
|
+
// Long-lived work a crate must declare. Each id must be named in `continuous_reason` for a `continuous = false`
|
|
88
|
+
// crate; for a held-resource crate every file that matches must be listed in `resources`.
|
|
89
|
+
export const CONTINUOUS_PATTERNS = Object.freeze({
|
|
90
|
+
"thread-spawn": /\b(?:std::)?thread::(?:spawn|Builder|scope)\b|\brayon::(?:spawn|ThreadPoolBuilder)\b/,
|
|
91
|
+
"task-spawn": /\b(?:tokio|async_std|smol)::(?:task::)?spawn(?:_blocking|_local)?\b|\btask::spawn(?:_blocking|_local)?\b|\bspawn_blocking\b|\bspawn_local\b|\bJoinSet\b/,
|
|
92
|
+
"runtime-builder": /\btokio::runtime::Builder\b|\bRuntime::new\s*\(|\bBuilder::new_(?:multi|current)_thread\b/,
|
|
93
|
+
sleep: /\b(?:thread::sleep|time::sleep|park_timeout)\s*\(|(?<![\w.:])sleep\s*\(/,
|
|
94
|
+
timer: /\b(?:time::)?interval(?:_at)?\s*\(|\bTimer\b|\bDispatchSource\b|\bCFRunLoopTimer\w*|\bset_interval\b/,
|
|
95
|
+
listener: /\b(?:TcpListener|UnixListener|UdpSocket|LocalSocketListener|NamedPipeServer|ServerOptions)\b|\.listen\s*\(/,
|
|
96
|
+
watcher: /\b(?:RecommendedWatcher|FSEventStream\w*|ReadDirectoryChangesW|inotify|kqueue)\b|\bnotify::/,
|
|
97
|
+
"event-loop": /\bkqueue\s*\(|\bepoll_(?:create1?|wait)\b|\bCreateIoCompletionPort\b|\bGetQueuedCompletionStatus(?:Ex)?\b|\bmio::(?:Poll|Events)\b|\bCFRunLoopRun\b/,
|
|
98
|
+
"audio-stream": /\bcpal::|\bbuild_(?:input|output)_stream\b/,
|
|
99
|
+
"onnx-session": /\bSession::builder\b|\bSessionBuilder\b|\bort::init\b|\bGlobalThreadPoolOptions\b|\bwith_(?:intra|inter)_threads\b|\bEnvironment::builder\b/,
|
|
100
|
+
ggml: /\bggml_(?:init|backend_\w+|threadpool\w*|graph_compute|gallocr\w*)\b|\bthreadpool_(?:new|create)\b/,
|
|
101
|
+
"conn-pool": /\br2d2\b|\bdeadpool\b|\bbb8\b|\bPooledConnection\b|\bConnectionPool\b|\bPool::builder\b|\bpool_max_idle_per_host\b|\bClient::builder\b|\breqwest::Client\b/,
|
|
102
|
+
// A static holding a collection, lock, lazily built value or model: process-wide residency, for review.
|
|
103
|
+
"resident-static": /\bstatic\s+(?:mut\s+)?\w+\s*:\s*[^=;]*\b(?:OnceLock|LazyLock|OnceCell|Lazy|HashMap|HashSet|BTreeMap|BTreeSet|Vec|VecDeque|Mutex|RwLock|Arc)\b|\blazy_static!|\bthread_local!/,
|
|
104
|
+
// A child that outlives the caller by construction. Never behind a held value.
|
|
105
|
+
"detached-child": /\bspawn_detached\b|\bDETACHED_PROCESS\b|\bprocess_group\s*\(\s*0\s*\)|\bsetsid\b/,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Strongest pattern -> resource kind it requires a listed resource to hold.
|
|
109
|
+
const PATTERN_HOLDS = Object.freeze({ "thread-spawn": "thread", "task-spawn": "task", "runtime-builder": "task", listener: "listener" });
|
|
110
|
+
export const HELD_KINDS = Object.freeze(["thread", "task", "listener", "child", "memory"]);
|
|
111
|
+
export const CLASSES_FILE = "runtime-budget-classes.json";
|
|
112
|
+
export const APP_ADOPTION_FILE = "runtime-budget-adoption.json";
|
|
113
|
+
|
|
114
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
115
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
116
|
+
|
|
117
|
+
/** Reads `[package.metadata.rightkit] runtime_budget` from Cargo.toml text. Undefined when unset. */
|
|
118
|
+
export function runtimeBudgetOptIn(manifestText) {
|
|
119
|
+
const manifest = parse(manifestText);
|
|
120
|
+
const value = manifest.package?.metadata?.rightkit?.runtime_budget;
|
|
121
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
122
|
+
throw new Error("[package.metadata.rightkit] runtime_budget must be true or false");
|
|
123
|
+
}
|
|
124
|
+
return value;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** True when the crate is listed, or its manifest opts in with runtime_budget = true. */
|
|
128
|
+
export function isRuntimeBudgetCrate(crate, manifestText) {
|
|
129
|
+
if (RUNTIME_BUDGET_CRATES.includes(crate)) return true;
|
|
130
|
+
return manifestText !== undefined && runtimeBudgetOptIn(manifestText) === true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Hosts the budget must be measured on. `evidence` (release evidence) is the policy authority when given. */
|
|
134
|
+
export function requiredBudgetHosts(budget, evidence) {
|
|
135
|
+
return isDarwinOnlyPolicy(evidence ?? budget) ? ["darwin"] : ["darwin", "win32"];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------- README view (generated from the JSON)
|
|
139
|
+
|
|
140
|
+
function sectionBounds(lines) {
|
|
141
|
+
let fenced = false;
|
|
142
|
+
let start = -1;
|
|
143
|
+
let end = lines.length;
|
|
144
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
145
|
+
const line = lines[index];
|
|
146
|
+
if (/^\s*(```|~~~)/.test(line)) { fenced = !fenced; continue; }
|
|
147
|
+
if (fenced) continue;
|
|
148
|
+
if (start < 0) {
|
|
149
|
+
if (/^##\s+Runtime budget\s*$/.test(line)) start = index;
|
|
150
|
+
} else if (/^#{1,2}\s/.test(line)) {
|
|
151
|
+
end = index;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return start < 0 ? null : { start, end };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lines of the `## Runtime budget` section (exact level-2 heading), ending at the next `#` or `##`
|
|
160
|
+
* heading. `###` subsections stay inside. Headings inside fenced code blocks are ignored.
|
|
161
|
+
* Returns null when the section is absent.
|
|
162
|
+
*/
|
|
163
|
+
export function runtimeBudgetSection(readmeText) {
|
|
164
|
+
const lines = String(readmeText).split(/\r?\n/);
|
|
165
|
+
const bounds = sectionBounds(lines);
|
|
166
|
+
return bounds ? lines.slice(bounds.start + 1, bounds.end) : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mib = (bytes) => `${(bytes / 1048576).toFixed(1)} MiB`;
|
|
170
|
+
const count = (n) => Number(n).toLocaleString("en-US");
|
|
171
|
+
const FORMAT = Object.freeze({
|
|
172
|
+
residentMemoryBytes: mib,
|
|
173
|
+
idleCpuPercent: (n) => `${n}%`,
|
|
174
|
+
wakeupsPerSecond: (n) => `${n} per second`,
|
|
175
|
+
diskOpsPerSecond: (n) => `${n} operations per second`,
|
|
176
|
+
});
|
|
177
|
+
const figure = (key, value) => (typeof value === "number" && Number.isFinite(value) ? FORMAT[key](value) : "undeclared");
|
|
178
|
+
|
|
179
|
+
/** The README section body generated from a parsed runtime-budget.json (no heading line). */
|
|
180
|
+
export function renderRuntimeBudgetBody(budget) {
|
|
181
|
+
const out = [];
|
|
182
|
+
out.push("Generated from `runtime-budget.json`; edit that file, then run `node packages/release/runtime-budgets.mjs sync <crate-dir>`.");
|
|
183
|
+
out.push("");
|
|
184
|
+
out.push(`- Resident memory ceiling: ${figure("residentMemoryBytes", budget.ceilings?.residentMemoryBytes)}.`);
|
|
185
|
+
out.push(`- CPU ceiling (idle, steady state): ${figure("idleCpuPercent", budget.ceilings?.idleCpuPercent)}.`);
|
|
186
|
+
out.push(`- Wake-ups ceiling: ${figure("wakeupsPerSecond", budget.ceilings?.wakeupsPerSecond)}.`);
|
|
187
|
+
out.push(`- Disk operations ceiling: ${figure("diskOpsPerSecond", budget.ceilings?.diskOpsPerSecond)}.`);
|
|
188
|
+
const rs = budget.requiredScale;
|
|
189
|
+
out.push(rs && rs.unit && Number.isFinite(rs.minCount) ? `- Required scale: ${count(rs.minCount)} ${rs.unit} or more.` : "- Required scale: undeclared.");
|
|
190
|
+
const hosts = requiredBudgetHosts(budget).map((h) => HOSTS[h]);
|
|
191
|
+
out.push(`- Required hosts: ${hosts.join(" and ")}${hosts.length === 1 ? ` only (${String(budget.hostPolicyReason).trim()})` : ""}.`);
|
|
192
|
+
const status = budget.status;
|
|
193
|
+
out.push(status?.api
|
|
194
|
+
? `- Status exposure: \`${status.api}\` in \`${status.file}\`; exposes ${(status.exposes ?? []).map((k) => FIGURE_LABEL[k] ?? k).join(", ") || "none of the budget figures"}.`
|
|
195
|
+
: "- Status exposure: none recorded.");
|
|
196
|
+
const records = Array.isArray(budget.measurements) ? budget.measurements : [];
|
|
197
|
+
if (records.length === 0) out.push("- Measured: not yet measured.");
|
|
198
|
+
for (const m of records) {
|
|
199
|
+
const host = `${m.host?.os}/${m.host?.arch}${m.host?.model ? ` (${m.host.model})` : ""}`;
|
|
200
|
+
const scale = `${count(m.dataScale?.count)} ${m.dataScale?.unit} (${m.dataScale?.description})`;
|
|
201
|
+
const app = m.scope === "installed" && m.app ? `, app ${m.app.name} ${m.app.version}` : "";
|
|
202
|
+
const observed = BUDGET_FIGURES.map((k) => `${FIGURE_LABEL[k]} ${m.observed?.[k] === undefined ? "n/a" : FORMAT[k](m.observed[k])}`).join(", ");
|
|
203
|
+
out.push(`- Measured: ${host}, ${budget.crate} ${m.crateVersion} at ${String(m.commit).slice(0, 12)}, ${scale}, ${m.scope}${app}, \`${m.command}\`, ${m.date}, evidence ${String(m.evidence?.sha256 ?? "none").slice(0, 12)}: ${observed}.`);
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const normalizeLines = (lines) => lines.map((line) => line.trim()).filter(Boolean);
|
|
209
|
+
|
|
210
|
+
/** README text with its `## Runtime budget` section replaced (or appended) from the JSON. */
|
|
211
|
+
export function syncRuntimeBudgetReadme(readmeText, budget) {
|
|
212
|
+
const lines = String(readmeText).split(/\r?\n/);
|
|
213
|
+
const block = ["## Runtime budget", "", ...renderRuntimeBudgetBody(budget), ""];
|
|
214
|
+
const bounds = sectionBounds(lines);
|
|
215
|
+
if (!bounds) {
|
|
216
|
+
const trimmed = String(readmeText).replace(/\s+$/, "");
|
|
217
|
+
return `${trimmed}${trimmed ? "\n\n" : ""}${block.join("\n")}`;
|
|
218
|
+
}
|
|
219
|
+
return [...lines.slice(0, bounds.start), ...block, ...lines.slice(bounds.end)].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- freshness (git)
|
|
223
|
+
|
|
224
|
+
function defaultGit(args, cwd) {
|
|
225
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true, maxBuffer: 256 * 1024 * 1024 });
|
|
226
|
+
if (result.error) return { status: 1, stdout: "" };
|
|
227
|
+
return { status: result.status ?? 1, stdout: (result.stdout ?? "").trim() };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The git pathspecs (relative to the workspace root) whose change makes a measurement stale: the
|
|
234
|
+
* crate's src, benches, Cargo.toml (features) and build.rs, plus src, Cargo.toml and build.rs of every
|
|
235
|
+
* workspace crate it reaches through `path` dependencies (transitively). Returns { root, specs }.
|
|
236
|
+
*/
|
|
237
|
+
export function measuredPathspecs(crateDir, deps = {}) {
|
|
238
|
+
const root = findWorkspaceRoot(crateDir, deps) ?? path.resolve(crateDir);
|
|
239
|
+
const specs = [];
|
|
240
|
+
const add = (dir, names) => { for (const name of names) specs.push(posix(path.relative(root, path.join(dir, name)))); };
|
|
241
|
+
add(path.resolve(crateDir), CRATE_PATHS);
|
|
242
|
+
let dependencies = [];
|
|
243
|
+
try { dependencies = pathDependencyDirs(crateDir, deps); } catch { /* unreadable manifest: the crate paths alone */ }
|
|
244
|
+
for (const dir of dependencies) add(dir, DEPENDENCY_PATHS);
|
|
245
|
+
return { root, specs: [...new Set(specs)] };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Why a measurement no longer describes the crate, or null when it does. Fails closed on any git error.
|
|
250
|
+
*
|
|
251
|
+
* Stale when, against the measured commit: any measured path changed in the working tree (commits since
|
|
252
|
+
* and uncommitted edits alike), a new untracked file appeared under one, the measured commit is not in
|
|
253
|
+
* this checkout (a too-shallow clone) or not an ancestor of HEAD, or the Cargo.lock closure of the
|
|
254
|
+
* crate changed. "In the graph" is exact: the closure starts at the crate's [[package]] entry and
|
|
255
|
+
* follows every `dependencies` edge, so a lock change to a package the crate (transitively) uses
|
|
256
|
+
* counts and a change to an unrelated package does not.
|
|
257
|
+
*/
|
|
258
|
+
export function measurementStaleness(commit, crateDir, git = defaultGit, deps = {}) {
|
|
259
|
+
const { exists = existsSync, readFile = readFileSync } = deps;
|
|
260
|
+
if (!SHA.test(String(commit ?? ""))) return "commit is not a full 40-character SHA";
|
|
261
|
+
const known = git(["cat-file", "-t", commit], crateDir);
|
|
262
|
+
if (known.status !== 0 || known.stdout !== "commit") return `commit ${commit.slice(0, 12)} is not a commit in this repository (shallow clone?)`;
|
|
263
|
+
if (git(["merge-base", "--is-ancestor", commit, "HEAD"], crateDir).status !== 0) return `commit ${commit.slice(0, 12)} is not an ancestor of HEAD`;
|
|
264
|
+
const { root, specs } = measuredPathspecs(crateDir, { exists, readFile });
|
|
265
|
+
// `git diff <commit>` (no second revision) compares the working tree to the commit.
|
|
266
|
+
const changed = git(["diff", "--name-only", commit, "--", ...specs], root);
|
|
267
|
+
if (changed.status !== 0) return "cannot diff the crate against the measured commit";
|
|
268
|
+
if (changed.stdout !== "") return `crate or workspace dependency changed since commit ${commit.slice(0, 12)}: ${changed.stdout.split("\n").slice(0, 5).join(", ")}`;
|
|
269
|
+
const untracked = git(["ls-files", "--others", "--exclude-standard", "--", ...specs], root);
|
|
270
|
+
if (untracked.status !== 0) return "cannot list untracked crate files";
|
|
271
|
+
if (untracked.stdout !== "") return `untracked files since the measurement: ${untracked.stdout.split("\n").slice(0, 5).join(", ")}`;
|
|
272
|
+
let name = null;
|
|
273
|
+
try { name = readCargoPackage(crateDir, { exists, readFile }).name; } catch { /* no manifest: nothing to resolve in the lock */ }
|
|
274
|
+
if (name) {
|
|
275
|
+
const lockPath = path.join(root, "Cargo.lock");
|
|
276
|
+
const now = exists(lockPath) ? readFile(lockPath, "utf8") : null;
|
|
277
|
+
const then = git(["show", `${commit}:./Cargo.lock`], root);
|
|
278
|
+
const had = then.status === 0;
|
|
279
|
+
if (now === null && had) return "Cargo.lock was removed since the measurement";
|
|
280
|
+
if (now !== null && !had) return "Cargo.lock did not exist at the measured commit";
|
|
281
|
+
if (now !== null) {
|
|
282
|
+
try {
|
|
283
|
+
if (JSON.stringify(lockClosure(now, name)) !== JSON.stringify(lockClosure(then.stdout, name))) {
|
|
284
|
+
return `Cargo.lock changed in the dependency graph of ${name} since commit ${commit.slice(0, 12)}`;
|
|
285
|
+
}
|
|
286
|
+
} catch (error) { return `cannot compare Cargo.lock: ${error.message}`; }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ---------------------------------------------------------------- bench evidence
|
|
293
|
+
|
|
294
|
+
const RESULT_NUMBERS = Object.freeze({ resident_bytes: "residentMemoryBytes", idle_cpu_percent: "idleCpuPercent", wakeups_per_s: "wakeupsPerSecond", disk_ops_per_s: "diskOpsPerSecond", scale_count: "scaleCount" });
|
|
295
|
+
const RESULT_WORDS = Object.freeze(["crate", "version", "scale_unit"]);
|
|
296
|
+
|
|
297
|
+
/** Parses the one `RESULT budget ...` line of a bench's raw output. */
|
|
298
|
+
export function parseBudgetResult(text) {
|
|
299
|
+
const lines = String(text).split(/\r?\n/).filter((line) => /^RESULT budget(?:\s|$)/.test(line));
|
|
300
|
+
if (lines.length !== 1) return { ok: false, problems: [`expected exactly one "RESULT budget" line, found ${lines.length}`] };
|
|
301
|
+
const fields = {};
|
|
302
|
+
const problems = [];
|
|
303
|
+
for (const token of lines[0].replace(/^RESULT budget\s*/, "").split(/\s+/).filter(Boolean)) {
|
|
304
|
+
const eq = token.indexOf("=");
|
|
305
|
+
const key = eq > 0 ? token.slice(0, eq) : token;
|
|
306
|
+
const value = eq > 0 ? token.slice(eq + 1) : "";
|
|
307
|
+
if (!(key in RESULT_NUMBERS) && !RESULT_WORDS.includes(key)) problems.push(`unknown field ${key}`);
|
|
308
|
+
else if (key in fields) problems.push(`duplicate field ${key}`);
|
|
309
|
+
else fields[key] = value;
|
|
310
|
+
}
|
|
311
|
+
const value = {};
|
|
312
|
+
for (const key of RESULT_WORDS) { if (!nonEmpty(fields[key])) problems.push(`missing field ${key}`); else value[key] = fields[key]; }
|
|
313
|
+
for (const [key, name] of Object.entries(RESULT_NUMBERS)) {
|
|
314
|
+
const n = Number(fields[key]);
|
|
315
|
+
if (fields[key] === undefined || fields[key] === "" || !Number.isFinite(n) || n < 0) problems.push(`field ${key} must be a non-negative number`);
|
|
316
|
+
else value[name] = n;
|
|
317
|
+
}
|
|
318
|
+
return problems.length ? { ok: false, problems } : { ok: true, problems: [], value };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sha256Of = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
|
322
|
+
|
|
323
|
+
function checkEvidence(m, index, budget, crateDir, { exists, readFile }) {
|
|
324
|
+
const at = `measurement[${index}]`;
|
|
325
|
+
const evidence = m.evidence;
|
|
326
|
+
if (!evidence || !nonEmpty(evidence.file) || !SHA256.test(String(evidence.sha256 ?? ""))) return [`${at} has no evidence {file, sha256} (record it with runtime-budgets.mjs record)`];
|
|
327
|
+
const resolved = path.resolve(crateDir, evidence.file);
|
|
328
|
+
const relative = path.relative(path.resolve(crateDir, RUNTIME_BUDGET_EVIDENCE_DIR), resolved);
|
|
329
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) return [`${at} evidence file must be under ${RUNTIME_BUDGET_EVIDENCE_DIR}/`];
|
|
330
|
+
if (!exists(resolved)) return [`${at} evidence file ${evidence.file} is missing`];
|
|
331
|
+
const bytes = readFile(resolved);
|
|
332
|
+
if (sha256Of(bytes) !== evidence.sha256) return [`${at} evidence file ${evidence.file} does not match its recorded sha256`];
|
|
333
|
+
const parsed = parseBudgetResult(bytes.toString("utf8"));
|
|
334
|
+
if (!parsed.ok) return parsed.problems.map((p) => `${at} evidence ${evidence.file}: ${p}`);
|
|
335
|
+
const v = parsed.value;
|
|
336
|
+
const problems = [];
|
|
337
|
+
if (v.crate !== budget.crate) problems.push(`evidence is for ${v.crate}, not ${budget.crate}`);
|
|
338
|
+
if (v.version !== m.crateVersion) problems.push(`evidence version ${v.version} != recorded ${m.crateVersion}`);
|
|
339
|
+
for (const key of BUDGET_FIGURES) if (v[key] !== m.observed[key]) problems.push(`evidence ${key} ${v[key]} != recorded ${m.observed[key]}`);
|
|
340
|
+
if (v.scale_unit !== m.dataScale.unit || v.scaleCount !== m.dataScale.count) problems.push("evidence data scale differs from the record");
|
|
341
|
+
return problems.map((p) => `${at} ${p}`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ---------------------------------------------------------------- evaluation
|
|
345
|
+
|
|
346
|
+
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "");
|
|
347
|
+
|
|
348
|
+
/** Source with `#[cfg(test)]` modules removed (brace-balanced), for scanning shipped code only. */
|
|
349
|
+
function withoutTestModules(text) {
|
|
350
|
+
const out = [];
|
|
351
|
+
const lines = text.split("\n");
|
|
352
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
353
|
+
if (!/^\s*#\[cfg\(test\)\]/.test(lines[i])) { out.push(lines[i]); continue; }
|
|
354
|
+
let j = i + 1;
|
|
355
|
+
while (j < lines.length && /^\s*#\[/.test(lines[j])) j += 1;
|
|
356
|
+
if (j < lines.length && /\bmod\s+\w+\s*;/.test(lines[j])) { i = j; continue; }
|
|
357
|
+
let depth = 0;
|
|
358
|
+
let opened = false;
|
|
359
|
+
for (; j < lines.length; j += 1) {
|
|
360
|
+
for (const ch of lines[j]) { if (ch === "{") { depth += 1; opened = true; } else if (ch === "}") depth -= 1; }
|
|
361
|
+
if (opened && depth <= 0) break;
|
|
362
|
+
}
|
|
363
|
+
i = j;
|
|
364
|
+
}
|
|
365
|
+
return out.join("\n");
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function rustFiles(dir) {
|
|
369
|
+
const files = [];
|
|
370
|
+
if (!fs.existsSync(dir)) return files;
|
|
371
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
372
|
+
const full = path.join(dir, entry.name);
|
|
373
|
+
if (entry.isDirectory()) { if (entry.name !== "tests") files.push(...rustFiles(full)); }
|
|
374
|
+
else if (entry.isFile() && entry.name.endsWith(".rs") && entry.name !== "tests.rs") files.push(full);
|
|
375
|
+
}
|
|
376
|
+
return files;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** `{ "src/x.rs": [ids] }` for every shipped (non-test) source file that matches a CONTINUOUS_PATTERNS entry. */
|
|
380
|
+
export function scanContinuousFiles(crateDir) {
|
|
381
|
+
const files = {};
|
|
382
|
+
for (const file of rustFiles(path.join(crateDir, "src")).sort()) {
|
|
383
|
+
const text = stripComments(withoutTestModules(fs.readFileSync(file, "utf8")));
|
|
384
|
+
const ids = Object.entries(CONTINUOUS_PATTERNS).filter(([, pattern]) => pattern.test(text)).map(([id]) => id);
|
|
385
|
+
if (ids.length) files[path.relative(crateDir, file).split(path.sep).join("/")] = ids;
|
|
386
|
+
}
|
|
387
|
+
return files;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Ids of CONTINUOUS_PATTERNS matched in the crate's shipped (non-test) src, with the first file for each. */
|
|
391
|
+
export function scanContinuousPatterns(crateDir) {
|
|
392
|
+
const hits = {};
|
|
393
|
+
for (const [file, ids] of Object.entries(scanContinuousFiles(crateDir))) for (const id of ids) hits[id] ??= file;
|
|
394
|
+
return hits;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function validateMeasurement(m, index) {
|
|
398
|
+
const at = `measurement[${index}]`;
|
|
399
|
+
const problems = [];
|
|
400
|
+
if (!m || typeof m !== "object") return [`${at} is not an object`];
|
|
401
|
+
if (!nonEmpty(m.host?.os) || !nonEmpty(m.host?.arch)) problems.push(`${at} host must name os and arch`);
|
|
402
|
+
if (!nonEmpty(m.crateVersion)) problems.push(`${at} has no crateVersion`);
|
|
403
|
+
if (!SHA.test(String(m.commit ?? ""))) problems.push(`${at} commit must be a full 40-character SHA`);
|
|
404
|
+
if (!/^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?$/.test(String(m.date ?? "")) || Number.isNaN(Date.parse(m.date))) problems.push(`${at} date must be an ISO date`);
|
|
405
|
+
if (!SCOPES.includes(m.scope)) problems.push(`${at} scope must be one of ${SCOPES.join(", ")}`);
|
|
406
|
+
if (m.scope === "installed" && !(nonEmpty(m.app?.name) && nonEmpty(m.app?.version))) problems.push(`${at} installed scope must name app.name and app.version`);
|
|
407
|
+
if (!nonEmpty(m.dataScale?.description) || !nonEmpty(m.dataScale?.unit) || !isNumber(m.dataScale?.count) || m.dataScale.count <= 0) {
|
|
408
|
+
problems.push(`${at} dataScale must name description, unit and a positive count`);
|
|
409
|
+
}
|
|
410
|
+
if (!nonEmpty(m.command)) problems.push(`${at} has no command`);
|
|
411
|
+
for (const key of BUDGET_FIGURES) {
|
|
412
|
+
if (!isNumber(m.observed?.[key]) || m.observed[key] < 0) problems.push(`${at} observed.${key} must be a non-negative number`);
|
|
413
|
+
}
|
|
414
|
+
if (isNumber(m.observed?.residentMemoryBytes) && m.observed.residentMemoryBytes === 0) {
|
|
415
|
+
problems.push(`${at} observed.residentMemoryBytes of 0 is implausible for a running process`);
|
|
416
|
+
}
|
|
417
|
+
return problems;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
421
|
+
|
|
422
|
+
/** True when `api` (`Type::method()` or a bare type) is a `pub fn` / `pub struct|enum|trait` item in `source`, not in a comment. */
|
|
423
|
+
export function statusApiDeclared(api, source) {
|
|
424
|
+
const code = stripComments(source);
|
|
425
|
+
const symbol = api.replace(/\(.*$/, "").split(/::|\./).pop();
|
|
426
|
+
if (!symbol) return false;
|
|
427
|
+
const fn = new RegExp(`\\bpub(?:\\([^)]*\\))?\\s+(?:const\\s+)?(?:async\\s+)?(?:unsafe\\s+)?fn\\s+${escapeRe(symbol)}\\b`);
|
|
428
|
+
const type = new RegExp(`\\bpub(?:\\([^)]*\\))?\\s+(?:struct|enum|trait|type)\\s+${escapeRe(symbol)}\\b`);
|
|
429
|
+
return api.includes("(") || api.includes("::") ? fn.test(code) : (type.test(code) || fn.test(code));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Evaluates runtime-budget.json for one crate. Returns { ok, problems }; problems name every
|
|
434
|
+
* refusal so the author can fix them in one pass. `version` is the version being published and
|
|
435
|
+
* `evidence` is the release gate evidence for it (the host-policy authority). `requireInstalled`
|
|
436
|
+
* (adoption) additionally needs a fresh in-ceiling installed-app measurement, optionally for `app`.
|
|
437
|
+
*/
|
|
438
|
+
export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requireInstalled = false, app, heldResource = false }, { readFile = readFileSync, exists = existsSync, git = defaultGit } = {}) {
|
|
439
|
+
const file = path.join(crateDir, RUNTIME_BUDGET_FILE);
|
|
440
|
+
if (!exists(file)) return { ok: false, problems: [`${RUNTIME_BUDGET_FILE} is missing (docs/RUNTIME-BUDGETS.md)`] };
|
|
441
|
+
let budget;
|
|
442
|
+
try { budget = JSON.parse(readFile(file, "utf8")); } catch (error) {
|
|
443
|
+
return { ok: false, problems: [`${RUNTIME_BUDGET_FILE} is not valid JSON: ${error.message}`] };
|
|
444
|
+
}
|
|
445
|
+
const problems = [];
|
|
446
|
+
if (!budget || typeof budget !== "object" || Array.isArray(budget)) return { ok: false, problems: [`${RUNTIME_BUDGET_FILE} must be a JSON object`] };
|
|
447
|
+
if (budget.schema !== RUNTIME_BUDGET_SCHEMA) problems.push(`${RUNTIME_BUDGET_FILE} schema must be ${RUNTIME_BUDGET_SCHEMA}`);
|
|
448
|
+
if (budget.crate !== crate) problems.push(`${RUNTIME_BUDGET_FILE} is for ${budget.crate}, expected ${crate}`);
|
|
449
|
+
if (!nonEmpty(version)) problems.push("publishing version is unknown, cannot bind the measurements to it");
|
|
450
|
+
if (budget.hostPolicy !== undefined && budget.hostPolicy !== "darwin-only") problems.push('hostPolicy must be "darwin-only" when present');
|
|
451
|
+
if (budget.hostPolicy === "darwin-only" && !nonEmpty(budget.hostPolicyReason)) problems.push("hostPolicy darwin-only needs a hostPolicyReason");
|
|
452
|
+
// One source of truth for the host policy: the release evidence. The budget file may repeat it, never differ.
|
|
453
|
+
if (evidence) {
|
|
454
|
+
const evidenceDarwinOnly = requiredBudgetHosts(null, evidence).length === 1;
|
|
455
|
+
const budgetDarwinOnly = requiredBudgetHosts(budget).length === 1;
|
|
456
|
+
if (evidenceDarwinOnly !== budgetDarwinOnly) {
|
|
457
|
+
problems.push(`hostPolicy disagrees with the release evidence (evidence: ${evidenceDarwinOnly ? "darwin-only" : "macOS and Windows"}; ${RUNTIME_BUDGET_FILE}: ${budgetDarwinOnly ? "darwin-only" : "macOS and Windows"})`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Ceilings: declared, finite, positive.
|
|
462
|
+
const ceilings = {};
|
|
463
|
+
for (const key of BUDGET_FIGURES) {
|
|
464
|
+
const value = budget.ceilings?.[key];
|
|
465
|
+
if (value === null || value === undefined) problems.push(`ceiling ${key} is undeclared`);
|
|
466
|
+
else if (!isNumber(value) || value <= 0) problems.push(`ceiling ${key} must be a positive number`);
|
|
467
|
+
else ceilings[key] = value;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Required scale: the unit and minimum the crate's measurement must reach.
|
|
471
|
+
const scale = budget.requiredScale;
|
|
472
|
+
let requiredScale = null;
|
|
473
|
+
if (scale === null || scale === undefined) problems.push("requiredScale is undeclared (unit and minCount the measurement must reach)");
|
|
474
|
+
else if (!nonEmpty(scale.unit) || !isNumber(scale.minCount) || scale.minCount <= 0) problems.push("requiredScale must name unit and a positive minCount");
|
|
475
|
+
else requiredScale = scale;
|
|
476
|
+
|
|
477
|
+
// Status exposure pointer: a real pub item in the named file, exposing the resident figure.
|
|
478
|
+
const status = budget.status;
|
|
479
|
+
if (!status || !nonEmpty(status.api) || !nonEmpty(status.file)) problems.push("status exposure is not recorded (status.api and status.file)");
|
|
480
|
+
else {
|
|
481
|
+
const statusFile = path.resolve(crateDir, status.file);
|
|
482
|
+
const inside = path.relative(crateDir, statusFile);
|
|
483
|
+
if (inside.startsWith("..") || path.isAbsolute(inside)) problems.push(`status.file ${status.file} must stay inside the crate`);
|
|
484
|
+
else if (!exists(statusFile)) problems.push(`status.file ${status.file} does not exist`);
|
|
485
|
+
else if (!statusApiDeclared(status.api, readFile(statusFile, "utf8"))) problems.push(`status.file ${status.file} has no pub item matching ${status.api}`);
|
|
486
|
+
const exposes = Array.isArray(status.exposes) ? status.exposes : [];
|
|
487
|
+
if (!exposes.includes("residentMemoryBytes")) problems.push("status does not expose residentMemoryBytes (the runtime must show the budget figure)");
|
|
488
|
+
for (const key of exposes) if (!BUDGET_FIGURES.includes(key)) problems.push(`status.exposes names unknown figure ${key}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Measurements.
|
|
492
|
+
const records = Array.isArray(budget.measurements) ? budget.measurements : [];
|
|
493
|
+
if (records.length === 0) problems.push("no measurements recorded (a declared ceiling is not evidence)");
|
|
494
|
+
const valid = [];
|
|
495
|
+
records.forEach((m, index) => {
|
|
496
|
+
const bad = validateMeasurement(m, index);
|
|
497
|
+
if (bad.length) { problems.push(...bad); return; }
|
|
498
|
+
const evidenceProblems = checkEvidence(m, index, budget, crateDir, { exists, readFile });
|
|
499
|
+
if (evidenceProblems.length) problems.push(...evidenceProblems); else valid.push({ m, index });
|
|
500
|
+
});
|
|
501
|
+
const counted = valid.filter(({ m }) => m.crateVersion === version);
|
|
502
|
+
if (valid.length && nonEmpty(version) && counted.length === 0) {
|
|
503
|
+
problems.push(`no measurement for version ${version} (recorded: ${[...new Set(valid.map(({ m }) => m.crateVersion))].join(", ")})`);
|
|
504
|
+
}
|
|
505
|
+
for (const { m, index } of counted) {
|
|
506
|
+
for (const key of BUDGET_FIGURES) {
|
|
507
|
+
if (ceilings[key] !== undefined && m.observed[key] > ceilings[key]) {
|
|
508
|
+
problems.push(`measurement[${index}] on ${m.host.os}/${m.host.arch} exceeds the ${FIGURE_LABEL[key]} ceiling: observed ${m.observed[key]} > ${ceilings[key]}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && m.observed[key] <= ceilings[key]);
|
|
513
|
+
const atScale = (m) => requiredScale !== null && m.dataScale.unit === requiredScale.unit && m.dataScale.count >= requiredScale.minCount;
|
|
514
|
+
const staleness = new Map();
|
|
515
|
+
const stale = (m) => {
|
|
516
|
+
if (!staleness.has(m.commit)) staleness.set(m.commit, measurementStaleness(m.commit, crateDir, git, { exists, readFile }));
|
|
517
|
+
return staleness.get(m.commit);
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
// Required hosts: one fresh, in-ceiling, at-scale measurement of this version on each.
|
|
521
|
+
// A held-resource crate defers measurement to adoption: no per-host publish measurement is required.
|
|
522
|
+
const required = heldResource ? [] : requiredBudgetHosts(budget, evidence);
|
|
523
|
+
for (const host of required) {
|
|
524
|
+
const onHost = counted.filter(({ m }) => m.host.os === host);
|
|
525
|
+
if (onHost.length === 0) { problems.push(`no ${HOSTS[host]} measurement for version ${version} (required hosts: ${required.map((h) => HOSTS[h]).join(" and ")}${required.length > 1 ? "; apps ship on both and the release gate requires both unless a darwin-only policy with a reason is recorded" : `; darwin-only policy: ${String((evidence ?? budget).hostPolicyReason).trim()}`})`); continue; }
|
|
526
|
+
const fresh = onHost.filter(({ m }) => stale(m) === null);
|
|
527
|
+
if (fresh.length === 0) { problems.push(`${HOSTS[host]} measurement is stale: ${onHost.map(({ m }) => stale(m)).join("; ")}`); continue; }
|
|
528
|
+
const within = fresh.filter(({ m }) => withinCeilings(m));
|
|
529
|
+
if (within.length === 0) { problems.push(`no ${HOSTS[host]} measurement within all ceilings`); continue; }
|
|
530
|
+
if (!within.some(({ m }) => atScale(m))) {
|
|
531
|
+
problems.push(`no ${HOSTS[host]} measurement at the required scale (${requiredScale ? `${requiredScale.minCount} ${requiredScale.unit} or more` : "requiredScale undeclared"}; recorded: ${within.map(({ m }) => `${m.dataScale.count} ${m.dataScale.unit}`).join(", ")})`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Adoption: the figures come from the installed app, not the bench.
|
|
536
|
+
if (requireInstalled) {
|
|
537
|
+
if (heldResource && !nonEmpty(app)) problems.push("adoption of a held-resource crate must name the app whose installed measurement is required");
|
|
538
|
+
const installed = counted.filter(({ m }) => m.scope === "installed" && (!app || m.app.name === app));
|
|
539
|
+
if (installed.length === 0) problems.push(`no installed-app measurement for version ${version}${app ? ` in ${app}` : ""} (adoption needs the figures from the installed app)`);
|
|
540
|
+
else if (!installed.some(({ m }) => stale(m) === null && withinCeilings(m) && atScale(m))) problems.push("no fresh installed-app measurement within all ceilings at the required scale");
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// README human view must say what the JSON says.
|
|
544
|
+
const readmePath = path.join(crateDir, "README.md");
|
|
545
|
+
if (!exists(readmePath)) problems.push("README.md is missing");
|
|
546
|
+
else {
|
|
547
|
+
const section = runtimeBudgetSection(readFile(readmePath, "utf8"));
|
|
548
|
+
if (section === null) problems.push('README.md has no "## Runtime budget" section (generate it from runtime-budget.json)');
|
|
549
|
+
else if (JSON.stringify(normalizeLines(section)) !== JSON.stringify(normalizeLines(renderRuntimeBudgetBody(budget)))) {
|
|
550
|
+
problems.push('README.md "## Runtime budget" section differs from runtime-budget.json (regenerate it with runtime-budgets.mjs sync)');
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return { ok: problems.length === 0, problems };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---------------------------------------------------------------- membership (fail closed)
|
|
557
|
+
|
|
558
|
+
const fail = (crate, crateDir, problems, fix) => ({
|
|
559
|
+
name: RUNTIME_BUDGET_CHECK, status: "fail", detail: `${crate}: ${problems.join("; ")}`,
|
|
560
|
+
fix: fix ?? `measure the crate and record it in ${path.join(crateDir, RUNTIME_BUDGET_FILE)}, then regenerate the README section (docs/RUNTIME-BUDGETS.md)`,
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The class a crate declares in `[package.metadata.rightkit]`: `continuous` is `false`, `true`/"always-on"
|
|
565
|
+
* or "held-resource". Returns { kind: "false" | "always-on" | "held-resource" | "undeclared", error? }.
|
|
566
|
+
*/
|
|
567
|
+
export function crateClass(meta, { hasFile = false } = {}) {
|
|
568
|
+
const value = meta.continuous;
|
|
569
|
+
if (value === false) return { kind: "false" };
|
|
570
|
+
if (value === true || value === "always-on") return { kind: "always-on" };
|
|
571
|
+
if (value === "held-resource") return { kind: "held-resource" };
|
|
572
|
+
if (value !== undefined) return { kind: "undeclared", error: '[package.metadata.rightkit] continuous must be false, true, "always-on" or "held-resource"' };
|
|
573
|
+
return { kind: meta.runtime_budget === true || hasFile ? "always-on" : "undeclared" };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Problems with a held-resource declaration (empty when it holds). Held-resource crates are checked for
|
|
578
|
+
* a complete `resources` table whose entries resolve to `pub` items, and for the scanner finding no
|
|
579
|
+
* continuous pattern in a file the table does not cover. Nothing is measured at crate publish.
|
|
580
|
+
*/
|
|
581
|
+
export function heldResourceProblems({ crate, crateDir, meta }, { readFile = readFileSync, exists = existsSync } = {}) {
|
|
582
|
+
const problems = [];
|
|
583
|
+
if (RUNTIME_BUDGET_CRATES.includes(crate)) problems.push("a listed continuous owner (RUNTIME_BUDGET_CRATES) cannot use held-resource; it stays always-on");
|
|
584
|
+
const resources = meta.resources;
|
|
585
|
+
if (!Array.isArray(resources) || resources.length === 0) {
|
|
586
|
+
problems.push("held-resource needs a non-empty [[package.metadata.rightkit.resources]] table");
|
|
587
|
+
return problems;
|
|
588
|
+
}
|
|
589
|
+
const covered = new Map();
|
|
590
|
+
resources.forEach((entry, index) => {
|
|
591
|
+
const at = `resources[${index}]`;
|
|
592
|
+
if (!entry || typeof entry !== "object") { problems.push(`${at} is not a table`); return; }
|
|
593
|
+
for (const key of ["api", "file", "scale", "release"]) if (!nonEmpty(entry[key])) problems.push(`${at} has no ${key}`);
|
|
594
|
+
const kinds = Array.isArray(entry.holds) ? entry.holds : [entry.holds];
|
|
595
|
+
if (!kinds.length || !kinds.every((k) => HELD_KINDS.includes(k))) problems.push(`${at}.holds must be one of ${HELD_KINDS.join("|")}`);
|
|
596
|
+
if (nonEmpty(entry.release) && /^(none|never|n\/a|-)$/i.test(entry.release.trim())) problems.push(`${at}.release must name Drop or the function that ends it`);
|
|
597
|
+
if (!nonEmpty(entry.file)) return;
|
|
598
|
+
const resolved = path.resolve(crateDir, entry.file);
|
|
599
|
+
const inside = path.relative(path.join(crateDir, "src"), resolved);
|
|
600
|
+
if (inside.startsWith("..") || path.isAbsolute(inside)) { problems.push(`${at}.file ${entry.file} must be under src/`); return; }
|
|
601
|
+
if (!exists(resolved)) { problems.push(`${at}.file ${entry.file} does not exist`); return; }
|
|
602
|
+
if (nonEmpty(entry.api) && !statusApiDeclared(entry.api, readFile(resolved, "utf8"))) problems.push(`${at}.api ${entry.api} is not a pub item in ${entry.file}`);
|
|
603
|
+
const rel = posix(path.relative(crateDir, resolved));
|
|
604
|
+
if (!covered.has(rel)) covered.set(rel, []);
|
|
605
|
+
covered.get(rel).push({ entry, kinds });
|
|
606
|
+
});
|
|
607
|
+
for (const [file, ids] of Object.entries(scanContinuousFiles(crateDir))) {
|
|
608
|
+
const entries = covered.get(file);
|
|
609
|
+
if (!entries) { problems.push(`${file} matches ${ids.join(", ")} but no resource lists it (new long-lived code cannot hide)`); continue; }
|
|
610
|
+
if (ids.includes("detached-child")) problems.push(`${file} starts a detached child, which outlives any held value; the crate is always-on, or the launch moves behind a held handle`);
|
|
611
|
+
for (const [pattern, kind] of Object.entries(PATTERN_HOLDS)) {
|
|
612
|
+
if (ids.includes(pattern) && !entries.some(({ kinds }) => kinds.includes(kind))) problems.push(`${file} matches ${pattern} but no resource for it holds ${kind}`);
|
|
613
|
+
}
|
|
614
|
+
if (ids.includes("resident-static") && !entries.some(({ entry }) => nonEmpty(entry.static_note))) {
|
|
615
|
+
problems.push(`${file} matches resident-static: a resource entry for it needs a static_note saying why the process-wide value is bounded or only reachable through the held value`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return problems;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Preflight-shaped check for one crate directory. Never null: a crate must be declared `false` (reason
|
|
623
|
+
* naming every matched pattern), "held-resource" (resources table, adoption-time proof) or always-on
|
|
624
|
+
* (runtime-budget.json, fully checked). `readFile`, `exists` and `git` are injectable.
|
|
625
|
+
*/
|
|
626
|
+
export function runtimeBudgetCheck({ crate, crateDir, version, evidence, requireInstalled = false, app }, deps = {}) {
|
|
627
|
+
const { readFile = readFileSync, exists = existsSync } = deps;
|
|
628
|
+
let pkg;
|
|
629
|
+
try { pkg = readCargoPackage(crateDir, { exists, readFile }); } catch (error) {
|
|
630
|
+
return fail(crate, crateDir, [`cannot read Cargo.toml: ${error.message}`], `fix ${path.join(crateDir, "Cargo.toml")}`);
|
|
631
|
+
}
|
|
632
|
+
if (pkg.name !== crate) {
|
|
633
|
+
return fail(crate, crateDir, [`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crate ${crate}`], "publish the crate by its manifest name");
|
|
634
|
+
}
|
|
635
|
+
const meta = pkg.manifest.package?.metadata?.rightkit ?? {};
|
|
636
|
+
if (meta.runtime_budget !== undefined && typeof meta.runtime_budget !== "boolean") return fail(crate, crateDir, ["[package.metadata.rightkit] runtime_budget must be true or false"], "set runtime_budget = true or false");
|
|
637
|
+
const listed = RUNTIME_BUDGET_CRATES.includes(crate);
|
|
638
|
+
const hasFile = exists(path.join(crateDir, RUNTIME_BUDGET_FILE));
|
|
639
|
+
const klass = crateClass(meta, { hasFile: hasFile || listed });
|
|
640
|
+
if (klass.error) return fail(crate, crateDir, [klass.error], 'set continuous = false, "always-on" or "held-resource"');
|
|
641
|
+
|
|
642
|
+
if (klass.kind === "false") {
|
|
643
|
+
if (listed) return fail(crate, crateDir, ["listed as a continuous owner in RUNTIME_BUDGET_CRATES; it cannot declare continuous = false"], "remove continuous = false and measure the crate");
|
|
644
|
+
if (hasFile) return fail(crate, crateDir, [`declares continuous = false but also carries ${RUNTIME_BUDGET_FILE}`], "remove one of the two declarations");
|
|
645
|
+
const reason = typeof meta.continuous_reason === "string" ? meta.continuous_reason.trim() : "";
|
|
646
|
+
if (!reason) return fail(crate, crateDir, ["continuous = false needs a one-line continuous_reason"], 'add continuous_reason = "<why this crate runs no continuous work>"');
|
|
647
|
+
const hits = scanContinuousPatterns(crateDir);
|
|
648
|
+
// The id must appear as a whole token (hyphens included), not inside another word.
|
|
649
|
+
const unnamed = Object.keys(hits).filter((id) => !new RegExp(`(?<![\\w-])${id}(?![\\w-])`).test(reason));
|
|
650
|
+
if (unnamed.length) {
|
|
651
|
+
return fail(crate, crateDir, [`declares continuous = false but src matches ${unnamed.map((id) => `${id} (${hits[id]})`).join(", ")}; continuous_reason must name each pattern it justifies`],
|
|
652
|
+
"either measure the crate (runtime-budget.json) or write a continuous_reason that names each matched pattern after reviewing it");
|
|
653
|
+
}
|
|
654
|
+
return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${crate} declared continuous = false: ${reason}` };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (klass.kind === "held-resource") {
|
|
658
|
+
const problems = heldResourceProblems({ crate, crateDir, meta }, deps);
|
|
659
|
+
if (problems.length) return fail(crate, crateDir, problems, "complete the resources table or declare the crate always-on (docs/RUNTIME-BUDGETS.md)");
|
|
660
|
+
if (!requireInstalled) {
|
|
661
|
+
return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${crate} declared held-resource (${meta.resources.length} resource(s)); measurement is required at app adoption, not at crate publish` };
|
|
662
|
+
}
|
|
663
|
+
const result = evaluateRuntimeBudget({ crate, crateDir, version: version ?? pkg.version, evidence, requireInstalled: true, app, heldResource: true }, deps);
|
|
664
|
+
return result.ok
|
|
665
|
+
? { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${crate} ${version ?? pkg.version} has an installed measurement for ${app}` }
|
|
666
|
+
: fail(crate, crateDir, result.problems);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (klass.kind === "undeclared") {
|
|
670
|
+
return fail(crate, crateDir, [`undeclared: none of ${RUNTIME_BUDGET_FILE}, continuous = false with a continuous_reason, or continuous = "held-resource" with resources`],
|
|
671
|
+
`declare the crate (docs/RUNTIME-BUDGETS.md)`);
|
|
672
|
+
}
|
|
673
|
+
const result = evaluateRuntimeBudget({ crate, crateDir, version: version ?? pkg.version, evidence, requireInstalled, app }, deps);
|
|
674
|
+
if (result.ok) {
|
|
675
|
+
return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${crate} ${version ?? pkg.version} has measured runtime budgets on every required host` };
|
|
676
|
+
}
|
|
677
|
+
return fail(crate, crateDir, result.problems);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Adoption variant: the same artifact, plus a fresh installed-app measurement (optionally for `app`).
|
|
682
|
+
* For a held-resource crate this is the only place its budget is proven, and `app` is required.
|
|
683
|
+
*/
|
|
684
|
+
export function runtimeBudgetAdoptionCheck(options, deps = {}) {
|
|
685
|
+
return runtimeBudgetCheck({ ...options, requireInstalled: true }, deps);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ---------------------------------------------------------------- app adoption (held-resource crates)
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* `{ crate: { ceilings, requiredScale } }` for every held-resource crate under `cratesDir`, read from each
|
|
692
|
+
* crate's manifest and (optional) runtime-budget.json. This is what ships in runtime-budget-classes.json so
|
|
693
|
+
* an app, which has no access to crate source, can be checked at qualification.
|
|
694
|
+
*/
|
|
695
|
+
export function heldResourceClasses(cratesDir) {
|
|
696
|
+
const out = {};
|
|
697
|
+
for (const entry of fs.readdirSync(cratesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
698
|
+
const dir = path.join(cratesDir, entry.name);
|
|
699
|
+
if (!entry.isDirectory() || !existsSync(path.join(dir, "Cargo.toml"))) continue;
|
|
700
|
+
const pkg = readCargoPackage(dir);
|
|
701
|
+
if (pkg.manifest.package?.metadata?.rightkit?.continuous !== "held-resource") continue;
|
|
702
|
+
const file = path.join(dir, RUNTIME_BUDGET_FILE);
|
|
703
|
+
const budget = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
704
|
+
out[pkg.name] = {
|
|
705
|
+
ceilings: BUDGET_FIGURES.every((k) => isNumber(budget.ceilings?.[k])) ? Object.fromEntries(BUDGET_FIGURES.map((k) => [k, budget.ceilings[k]])) : null,
|
|
706
|
+
requiredScale: budget.requiredScale && nonEmpty(budget.requiredScale.unit) && isNumber(budget.requiredScale.minCount) ? { unit: budget.requiredScale.unit, minCount: budget.requiredScale.minCount } : null,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
return out;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** The shipped classes document for `heldResourceClasses`. */
|
|
713
|
+
export const renderClasses = (classes) => `${JSON.stringify({ schema: 1, heldResource: classes }, null, 2)}\n`;
|
|
714
|
+
|
|
715
|
+
function lockedPackages(lockPaths, { readFile = readFileSync, exists = existsSync } = {}) {
|
|
716
|
+
const seen = new Map();
|
|
717
|
+
for (const lock of lockPaths) {
|
|
718
|
+
if (!exists(lock)) continue;
|
|
719
|
+
for (const entry of parse(readFile(lock, "utf8")).package ?? []) {
|
|
720
|
+
if (!seen.has(entry.name)) seen.set(entry.name, new Set());
|
|
721
|
+
seen.get(entry.name).add(entry.version);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
return seen;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* App qualification: an app whose Cargo.lock contains a held-resource crate must carry an installed-scope
|
|
729
|
+
* measurement of THAT app, of the locked crate version, in `runtime-budget-adoption.json` at the app root
|
|
730
|
+
* (records have the crate-measurement shape plus `crate`, with raw bench evidence under
|
|
731
|
+
* `runtime-budget.evidence/`). The measurement must be within the crate's declared ceilings and at its
|
|
732
|
+
* declared scale, and taken at the app's current version. Returns null when the app uses no held-resource
|
|
733
|
+
* crate. Runs from preflight (`collectPreflight`), i.e. right-release doctor, --dry-run and every build.
|
|
734
|
+
*/
|
|
735
|
+
export function appRuntimeBudgetAdoptionCheck({ appRoot, app, version, cargoLockPaths = [] }, deps = {}) {
|
|
736
|
+
const { readFile = readFileSync, exists = existsSync } = deps;
|
|
737
|
+
let classes = deps.classes;
|
|
738
|
+
if (!classes) {
|
|
739
|
+
const file = new URL(`./${CLASSES_FILE}`, import.meta.url);
|
|
740
|
+
try { classes = JSON.parse(readFileSync(file, "utf8")).heldResource ?? {}; } catch { classes = {}; }
|
|
741
|
+
}
|
|
742
|
+
let locked;
|
|
743
|
+
try { locked = lockedPackages(cargoLockPaths, { readFile, exists }); } catch (error) {
|
|
744
|
+
return { name: RUNTIME_BUDGET_CHECK, status: "fail", detail: `cannot read Cargo.lock: ${error.message}`, fix: "fix the app Cargo.lock" };
|
|
745
|
+
}
|
|
746
|
+
const used = Object.keys(classes).filter((name) => locked.has(name));
|
|
747
|
+
if (used.length === 0) return null;
|
|
748
|
+
const problems = [];
|
|
749
|
+
const adoptionPath = path.join(appRoot, APP_ADOPTION_FILE);
|
|
750
|
+
let records = [];
|
|
751
|
+
if (!exists(adoptionPath)) problems.push(`${APP_ADOPTION_FILE} is missing`);
|
|
752
|
+
else {
|
|
753
|
+
try { records = JSON.parse(readFile(adoptionPath, "utf8")).measurements ?? []; } catch (error) { problems.push(`${APP_ADOPTION_FILE} is not valid JSON: ${error.message}`); }
|
|
754
|
+
}
|
|
755
|
+
for (const crate of used) {
|
|
756
|
+
const { ceilings, requiredScale } = classes[crate];
|
|
757
|
+
if (!ceilings) { problems.push(`${crate}: ceilings undeclared (declare them in its runtime-budget.json and regenerate ${CLASSES_FILE})`); continue; }
|
|
758
|
+
if (!requiredScale) { problems.push(`${crate}: requiredScale undeclared`); continue; }
|
|
759
|
+
const mine = records.map((m, index) => ({ m, index })).filter(({ m }) => m?.crate === crate && m.scope === "installed" && locked.get(crate).has(m.crateVersion));
|
|
760
|
+
if (mine.length === 0) { problems.push(`${crate} ${[...locked.get(crate)].join("/")}: no installed-scope measurement of ${app}`); continue; }
|
|
761
|
+
const good = mine.filter(({ m, index }) => {
|
|
762
|
+
const bad = validateMeasurement(m, index);
|
|
763
|
+
if (bad.length) { problems.push(`${crate}: ${bad.join("; ")}`); return false; }
|
|
764
|
+
const evidenceProblems = checkEvidence(m, index, { crate }, appRoot, { exists, readFile });
|
|
765
|
+
if (evidenceProblems.length) { problems.push(`${crate}: ${evidenceProblems.join("; ")}`); return false; }
|
|
766
|
+
if (m.app.name !== app) { problems.push(`${crate}: measurement[${index}] is for app ${m.app.name}, not ${app}`); return false; }
|
|
767
|
+
if (version && m.app.version !== version) { problems.push(`${crate}: measurement[${index}] was taken at ${app} ${m.app.version}, not the current ${version}`); return false; }
|
|
768
|
+
const over = BUDGET_FIGURES.filter((k) => m.observed[k] > ceilings[k]);
|
|
769
|
+
if (over.length) { problems.push(`${crate}: measurement[${index}] exceeds ${over.join(", ")}`); return false; }
|
|
770
|
+
if (m.dataScale.unit !== requiredScale.unit || m.dataScale.count < requiredScale.minCount) { problems.push(`${crate}: measurement[${index}] is below the required scale ${requiredScale.minCount} ${requiredScale.unit}`); return false; }
|
|
771
|
+
return true;
|
|
772
|
+
});
|
|
773
|
+
if (good.length === 0 && !problems.some((p) => p.startsWith(`${crate}`))) problems.push(`${crate}: no qualifying measurement`);
|
|
774
|
+
}
|
|
775
|
+
return problems.length
|
|
776
|
+
? { name: RUNTIME_BUDGET_CHECK, status: "fail", detail: `${app} uses held-resource crate(s) ${used.join(", ")}: ${problems.join("; ")}`, fix: `record an installed measurement of ${app} for each crate in ${APP_ADOPTION_FILE} (docs/RUNTIME-BUDGETS.md)` }
|
|
777
|
+
: { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${app} has installed measurements for ${used.join(", ")}` };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---------------------------------------------------------------- record (measurement provenance)
|
|
781
|
+
|
|
782
|
+
/** os/arch/model of this machine. `model` is the hardware model on macOS, else the CPU model. */
|
|
783
|
+
export function detectHost(deps = {}) {
|
|
784
|
+
const platform = deps.platform ?? process.platform;
|
|
785
|
+
const arch = deps.arch ?? process.arch;
|
|
786
|
+
let model = deps.model;
|
|
787
|
+
if (model === undefined) {
|
|
788
|
+
if (platform === "darwin") {
|
|
789
|
+
const result = (deps.spawn ?? spawnSync)("sysctl", ["-n", "hw.model"], { encoding: "utf8", windowsHide: true });
|
|
790
|
+
model = result.status === 0 ? String(result.stdout).trim() : undefined;
|
|
791
|
+
}
|
|
792
|
+
model ??= os.cpus()[0]?.model;
|
|
793
|
+
}
|
|
794
|
+
return { os: platform, arch, ...(model ? { model } : {}) };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Writes the measurement record for a bench run: raw output copied to runtime-budget.evidence/, its
|
|
799
|
+
* sha256 stored in the record, host and commit detected (never typed). Refuses when the measured paths
|
|
800
|
+
* are not at HEAD (a measurement of uncommitted code cannot be reproduced) or the output disagrees with
|
|
801
|
+
* the crate. Replaces a record for the same host, version, scope and app.
|
|
802
|
+
*/
|
|
803
|
+
export function recordMeasurement({ crateDir, resultFile, scope, command, description, app, date }, deps = {}) {
|
|
804
|
+
const git = deps.git ?? defaultGit;
|
|
805
|
+
const host = deps.host ?? detectHost();
|
|
806
|
+
const raw = fs.readFileSync(resultFile);
|
|
807
|
+
const parsed = parseBudgetResult(raw.toString("utf8"));
|
|
808
|
+
if (!parsed.ok) throw new Error(`bench output rejected: ${parsed.problems.join("; ")}`);
|
|
809
|
+
const pkg = readCargoPackage(crateDir);
|
|
810
|
+
const v = parsed.value;
|
|
811
|
+
if (v.crate !== pkg.name) throw new Error(`bench output is for ${v.crate}, crate is ${pkg.name}`);
|
|
812
|
+
if (v.version !== pkg.version) throw new Error(`bench output is for version ${v.version}, crate is ${pkg.version}`);
|
|
813
|
+
if (!SCOPES.includes(scope)) throw new Error(`--scope must be one of ${SCOPES.join(", ")}`);
|
|
814
|
+
if (scope === "installed" && !(nonEmpty(app?.name) && nonEmpty(app?.version))) throw new Error("--scope installed needs --app and --app-version");
|
|
815
|
+
if (!nonEmpty(command)) throw new Error("--command (the exact bench command) is required");
|
|
816
|
+
if (!nonEmpty(description)) throw new Error("--description of the data scale is required");
|
|
817
|
+
const head = git(["rev-parse", "HEAD"], crateDir);
|
|
818
|
+
if (head.status !== 0 || !SHA.test(head.stdout)) throw new Error("cannot read HEAD");
|
|
819
|
+
const stale = measurementStaleness(head.stdout, crateDir, git);
|
|
820
|
+
if (stale) throw new Error(`refusing to record: ${stale} (commit the code under test, then re-run the bench)`);
|
|
821
|
+
const file = path.join(crateDir, RUNTIME_BUDGET_FILE);
|
|
822
|
+
const budget = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
823
|
+
const evidenceName = `${host.os}-${host.arch}-${pkg.version}-${scope}${scope === "installed" ? `-${app.name}` : ""}.txt`.replace(/[^\w.-]/g, "_");
|
|
824
|
+
const evidenceRel = `${RUNTIME_BUDGET_EVIDENCE_DIR}/${evidenceName}`;
|
|
825
|
+
fs.mkdirSync(path.join(crateDir, RUNTIME_BUDGET_EVIDENCE_DIR), { recursive: true });
|
|
826
|
+
fs.writeFileSync(path.join(crateDir, evidenceRel), raw);
|
|
827
|
+
const record = {
|
|
828
|
+
host, crateVersion: pkg.version, commit: head.stdout, date: date ?? (deps.now ?? new Date()).toISOString().slice(0, 10), scope,
|
|
829
|
+
...(scope === "installed" ? { app: { name: app.name, version: app.version } } : {}),
|
|
830
|
+
dataScale: { description, unit: v.scale_unit, count: v.scaleCount },
|
|
831
|
+
command,
|
|
832
|
+
observed: Object.fromEntries(BUDGET_FIGURES.map((key) => [key, v[key]])),
|
|
833
|
+
evidence: { file: evidenceRel, sha256: sha256Of(raw) },
|
|
834
|
+
};
|
|
835
|
+
const same = (m) => m.host?.os === host.os && m.host?.arch === host.arch && m.crateVersion === pkg.version && m.scope === scope && (m.app?.name ?? null) === (app?.name ?? null);
|
|
836
|
+
budget.measurements = [...(budget.measurements ?? []).filter((m) => !same(m)), record];
|
|
837
|
+
fs.writeFileSync(file, `${JSON.stringify(budget, null, 2)}\n`);
|
|
838
|
+
return record;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// `node runtime-budgets.mjs sync <crate-dir>` regenerates the README section from runtime-budget.json.
|
|
842
|
+
// `node runtime-budgets.mjs record <crate-dir> --result <file> --scope <s> --command <c> --description <d> [--app <n> --app-version <v>]`
|
|
843
|
+
const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
|
|
844
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
845
|
+
const [command, dir, ...rest] = process.argv.slice(2);
|
|
846
|
+
const flag = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; };
|
|
847
|
+
try {
|
|
848
|
+
if (!dir || !["sync", "record", "classes"].includes(command)) throw new Error("usage: runtime-budgets.mjs sync|record <crate-dir> [record flags] | classes <crates-dir>");
|
|
849
|
+
const crateDir = path.resolve(dir);
|
|
850
|
+
if (command === "classes") {
|
|
851
|
+
writeFileSync(fileURLToPath(new URL(`./${CLASSES_FILE}`, import.meta.url)), renderClasses(heldResourceClasses(crateDir)));
|
|
852
|
+
process.stdout.write(`[runtime-budgets] ${CLASSES_FILE} regenerated from ${crateDir}\n`);
|
|
853
|
+
} else if (command === "sync") {
|
|
854
|
+
const budget = JSON.parse(readFileSync(path.join(crateDir, RUNTIME_BUDGET_FILE), "utf8"));
|
|
855
|
+
const readmePath = path.join(crateDir, "README.md");
|
|
856
|
+
const readme = existsSync(readmePath) ? readFileSync(readmePath, "utf8") : `# ${budget.crate}\n`;
|
|
857
|
+
writeFileSync(readmePath, syncRuntimeBudgetReadme(readme, budget));
|
|
858
|
+
process.stdout.write(`[runtime-budgets] README.md ## Runtime budget regenerated for ${budget.crate}\n`);
|
|
859
|
+
} else {
|
|
860
|
+
const record = recordMeasurement({
|
|
861
|
+
crateDir, resultFile: flag("--result"), scope: flag("--scope"), command: flag("--command"), description: flag("--description"),
|
|
862
|
+
app: flag("--app") ? { name: flag("--app"), version: flag("--app-version") } : undefined,
|
|
863
|
+
});
|
|
864
|
+
process.stdout.write(`[runtime-budgets] recorded ${record.scope} measurement for ${record.crateVersion} on ${record.host.os}/${record.host.arch} (evidence ${record.evidence.file}); now run: runtime-budgets.mjs sync ${dir}\n`);
|
|
865
|
+
}
|
|
866
|
+
} catch (error) {
|
|
867
|
+
process.stderr.write(`runtime-budgets: ${error.message}\n`);
|
|
868
|
+
process.exitCode = 1;
|
|
869
|
+
}
|
|
870
|
+
}
|