@codyswann/lisa 4.71.6 → 4.71.8
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/all/copy-overwrite/scripts/check-threshold-ratchet.mjs +571 -0
- package/all/copy-overwrite/scripts/check-workflow-load-failures.mjs +31 -9
- package/all/copy-overwrite/scripts/lib/automation-provenance-contract.mjs +19 -0
- package/all/copy-overwrite/scripts/lib/automation-provenance-files.mjs +49 -0
- package/all/copy-overwrite/scripts/lib/automation-provenance-local.mjs +54 -38
- package/all/copy-overwrite/scripts/lib/npm-update-allocate.mjs +8 -2
- package/all/copy-overwrite/scripts/lib/npm-update-bun.mjs +189 -0
- package/all/copy-overwrite/scripts/lib/npm-update-cancel-origin.mjs +7 -3
- package/all/copy-overwrite/scripts/lib/npm-update-cancellation-proof.mjs +6 -1
- package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +26 -5
- package/all/copy-overwrite/scripts/lib/npm-update-gate.mjs +8 -5
- package/all/copy-overwrite/scripts/lib/npm-update-github.mjs +2 -2
- package/all/copy-overwrite/scripts/lib/npm-update-helper-inventory.mjs +3 -4
- package/all/copy-overwrite/scripts/lib/npm-update-hook-installation.mjs +68 -1
- package/all/copy-overwrite/scripts/lib/npm-update-hosted-gate.mjs +13 -80
- package/all/copy-overwrite/scripts/lib/npm-update-leaf-contract.mjs +6 -5
- package/all/copy-overwrite/scripts/lib/npm-update-prepare.mjs +35 -7
- package/all/copy-overwrite/scripts/lib/npm-update-publish.mjs +1 -1
- package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +43 -16
- package/all/copy-overwrite/scripts/threshold-ratchet-compare.mjs +521 -0
- package/all/copy-overwrite/scripts/threshold-ratchet-families.mjs +546 -0
- package/dist/configs/repo-scan.js +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +62 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +46 -32
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +4 -4
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/lisa/hooks/threshold-ratchet.mjs +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/lisa-copilot/hooks/threshold-ratchet.mjs +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/lisa-cursor/hooks/threshold-ratchet.mjs +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/materialized-artifacts.json +3 -0
- package/plugins/src/base/hooks/threshold-ratchet-compare.mjs +3 -3
- package/plugins/src/base/hooks/threshold-ratchet.mjs +1 -1
- package/rails/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
- package/rails/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
- package/scripts/build-plugins.sh +2 -1
- package/scripts/generate-scratch-supervisor-profile.mjs +1 -1
- package/scripts/two-channel-couplings.json +9 -7
- package/typescript/copy-overwrite/scripts/check-skipped-required-checks.mjs +22 -3
- package/typescript/copy-overwrite/scripts/check-threshold-ratchet.mjs +1 -1
- package/typescript/copy-overwrite/scripts/threshold-ratchet-compare.mjs +3 -3
- /package/{rails → all}/copy-contents/scripts/lisa-mutation.sh +0 -0
- /package/{rails → all}/copy-overwrite/scripts/lisa-clean-git-env.sh +0 -0
- /package/{rails → all}/copy-overwrite/scripts/lisa-scratch-run.sh +0 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
3
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Threshold ratchet gate — quality thresholds may tighten, never weaken.
|
|
7
|
+
*
|
|
8
|
+
* Deterministic comparator shared by three enforcement layers:
|
|
9
|
+
* 1. Agent-time soft block: PostToolUse hook via threshold-ratchet.sh
|
|
10
|
+
* (`--hook`, exit 2 on weakening so the agent gets actionable feedback).
|
|
11
|
+
* 2. Pre-commit backstop: husky / lefthook (`--staged`, exit 1).
|
|
12
|
+
* 3. CI gate: reusable quality workflows (`--base <ref>`, exit 1),
|
|
13
|
+
* comparing against the merge-base so nothing weakened lands in a PR.
|
|
14
|
+
*
|
|
15
|
+
* Tier 1 — designed tunables: vitest/jest/simplecov/e2e thresholds
|
|
16
|
+
* (minimums) and eslint/rubocop thresholds (maximums). Tier 2 — stryker's
|
|
17
|
+
* break score and k6 expression bounds. Tier 3 — exemption additions
|
|
18
|
+
* (stryker mutate exclusions, thresholdRatchet.allow entries) which weaken
|
|
19
|
+
* a gate without touching a number. Audit ignore lists are deliberately NOT
|
|
20
|
+
* watched: the security-audit-handling ladder authorizes agents to add
|
|
21
|
+
* documented entries autonomously, and doctor readiness (B5) audits that
|
|
22
|
+
* every entry carries a written decision.
|
|
23
|
+
*
|
|
24
|
+
* Human override: `.lisa.config.json` → `thresholdRatchet.allow` entries
|
|
25
|
+
* ({ file, key, reason, until }). Honored ONLY from the baseline side (HEAD /
|
|
26
|
+
* merge-base), never from the change under review — an agent cannot grant
|
|
27
|
+
* itself an exception in the same change that weakens a gate. `key: "*"`
|
|
28
|
+
* allows every key in the file, and reports as file-wide so it is not mistaken
|
|
29
|
+
* for a key-scoped one.
|
|
30
|
+
*
|
|
31
|
+
* Exemptions END (#3856). `until` is a `YYYY-MM-DD` day the entry is live
|
|
32
|
+
* through; past it the entry stops exempting and the weakening it permitted is
|
|
33
|
+
* refused again, naming the entry, its scope, its reason and the remedy. An
|
|
34
|
+
* entry naming no evaluable condition still exempts — that keeps a project
|
|
35
|
+
* mid-migration off a red wall — but is reported on every run until somebody
|
|
36
|
+
* gives it a condition or deletes it. Modelled on `_thresholdsDivergence` in
|
|
37
|
+
* `stryker.conf.json`, the sibling exemption that already stops exempting when
|
|
38
|
+
* it goes stale; see `threshold-ratchet-families.mjs` `ALLOW_STATE`.
|
|
39
|
+
*
|
|
40
|
+
* One exception, and only one: a PROMOTION between deploy-chain branches
|
|
41
|
+
* (`--base` + `--head`, both named in `deploy.branches`, head upstream of
|
|
42
|
+
* base, head fully containing base). There the allow list is read from the
|
|
43
|
+
* head, because the change under review is the baseline plus history that has
|
|
44
|
+
* already passed this same gate. See `isPromotion` for why that is the only
|
|
45
|
+
* discriminator that holds.
|
|
46
|
+
*
|
|
47
|
+
* Extraction lives in threshold-ratchet-families.mjs; comparison rules in
|
|
48
|
+
* threshold-ratchet-compare.mjs. Zero dependencies.
|
|
49
|
+
*/
|
|
50
|
+
import { execFileSync } from "node:child_process";
|
|
51
|
+
import * as fs from "node:fs";
|
|
52
|
+
import * as path from "node:path";
|
|
53
|
+
import { fileURLToPath } from "node:url";
|
|
54
|
+
import {
|
|
55
|
+
extractAllowEntries,
|
|
56
|
+
familyFor,
|
|
57
|
+
parseJson,
|
|
58
|
+
} from "./threshold-ratchet-families.mjs";
|
|
59
|
+
import {
|
|
60
|
+
applyAllowList,
|
|
61
|
+
compareFile,
|
|
62
|
+
describeAllowList,
|
|
63
|
+
formatReport,
|
|
64
|
+
} from "./threshold-ratchet-compare.mjs";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Standard git locations, checked in order so the executable comes from a
|
|
68
|
+
* fixed, unwriteable directory rather than a PATH lookup. The bare "git"
|
|
69
|
+
* fallback keeps unusual layouts (e.g. Windows git-bash) working.
|
|
70
|
+
*
|
|
71
|
+
* Order within that constraint is set by measurement, not by convention. On
|
|
72
|
+
* macOS `/usr/bin/git` is not git: it is Apple's `xcrun` shim, which locates a
|
|
73
|
+
* developer directory and re-executes the real binary there. Dispatching
|
|
74
|
+
* through it costs a **median 33 ms against 15 ms** for either
|
|
75
|
+
* developer-directory git, and **100 ms against 21 ms at p90** — randomized
|
|
76
|
+
* call order, fixed inter-call gaps, `git rev-parse --show-toplevel`, n=30 each
|
|
77
|
+
* (lisa#2898). The call does no work at all; the difference is the dispatch.
|
|
78
|
+
*
|
|
79
|
+
* The two entries promoted ahead of it are the developer-directory gits the
|
|
80
|
+
* shim itself re-executes. Both are `root:wheel` files in system locations, so
|
|
81
|
+
* this is the same trust class as `/usr/bin/git` and not a relaxation: the
|
|
82
|
+
* user-writable `/usr/local` and Homebrew entries stay behind it, exactly where
|
|
83
|
+
* they already were. Neither promoted path exists on Linux, so every CI runner
|
|
84
|
+
* resolves precisely what it resolved before.
|
|
85
|
+
*/
|
|
86
|
+
const GIT_LOCATIONS = [
|
|
87
|
+
"/Library/Developer/CommandLineTools/usr/bin/git",
|
|
88
|
+
"/Applications/Xcode.app/Contents/Developer/usr/bin/git",
|
|
89
|
+
"/usr/bin/git",
|
|
90
|
+
"/usr/local/bin/git",
|
|
91
|
+
"/opt/homebrew/bin/git",
|
|
92
|
+
];
|
|
93
|
+
const GIT = GIT_LOCATIONS.find(candidate => fs.existsSync(candidate)) ?? "git";
|
|
94
|
+
|
|
95
|
+
/** Git flag shared by every changed-file listing. */
|
|
96
|
+
const NAME_ONLY = "--name-only";
|
|
97
|
+
/**
|
|
98
|
+
* Suppress rename detection when discovering changed files.
|
|
99
|
+
*
|
|
100
|
+
* With it on, `git diff --name-only` reports a rename as the destination path
|
|
101
|
+
* only. Moving a watched threshold file to an unwatched path therefore lists
|
|
102
|
+
* one name the ratchet does not watch, and the loosening at the old path is
|
|
103
|
+
* never compared. Both sides have to be visible for the gate to mean anything.
|
|
104
|
+
*/
|
|
105
|
+
const NO_RENAMES = "--no-renames";
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run git, returning stdout or null on any failure.
|
|
109
|
+
*
|
|
110
|
+
* The swallow is CORRECT here and is kept, because #2777 already made the
|
|
111
|
+
* consumer fail closed: a null change set reaches `threshold-ratchet: failing
|
|
112
|
+
* closed — an enforcement gate that cannot read the change set has not verified
|
|
113
|
+
* it`. That comment names the original defect exactly — "git() swallows every
|
|
114
|
+
* failure, so the ratchet passed exactly when it had no evidence" — so a killed
|
|
115
|
+
* child now joins the failures that already refuse. This call site needed the
|
|
116
|
+
* DEADLINE and nothing else.
|
|
117
|
+
*
|
|
118
|
+
* The deadline is written inline rather than imported. This file is
|
|
119
|
+
* materialized from `plugins/src/base/hooks/` into two stack lanes AND runs as
|
|
120
|
+
* an agent-time hook inside a plugin payload, which has no `./lib/` to import
|
|
121
|
+
* from — the same accommodation `preflight-secrets.mjs` makes, and the same one
|
|
122
|
+
* the entry guard below is written out for.
|
|
123
|
+
* @param {string[]} args Git arguments
|
|
124
|
+
* @param {string} [cwd] Working directory
|
|
125
|
+
* @returns {string | null} Captured stdout, or null when git failed
|
|
126
|
+
*/
|
|
127
|
+
function git(args, cwd) {
|
|
128
|
+
try {
|
|
129
|
+
return execFileSync(GIT, args, {
|
|
130
|
+
cwd,
|
|
131
|
+
encoding: "utf-8",
|
|
132
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
133
|
+
// A hang detector, not a budget. `git` reached through PATH on macOS goes
|
|
134
|
+
// via Apple's xcrun shim, measured over 20s under load in #2887.
|
|
135
|
+
timeout: 30_000,
|
|
136
|
+
});
|
|
137
|
+
} catch {
|
|
138
|
+
// probe-direction: fail-closed — null never reaches a verdict as "no
|
|
139
|
+
// weakening found". `changePlan` turns it into `undeterminable`, which
|
|
140
|
+
// REFUSES, and `isPromotion` falls to the strict reading that keeps the
|
|
141
|
+
// allow list pinned to the baseline.
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve mode-specific candidate files and content readers.
|
|
148
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
149
|
+
* @param {string} root Repo root
|
|
150
|
+
* @param {string | undefined} baseRef Base ref (base mode only)
|
|
151
|
+
* @param {string[] | undefined} onlyFiles Restrict to these repo-relative
|
|
152
|
+
* paths (hook mode with a known edited file)
|
|
153
|
+
* @returns {{ files: string[], baselineRef: string, readCurrent: (f: string) => string | null } | null}
|
|
154
|
+
* The comparison plan, or null when git state can't support the mode
|
|
155
|
+
*/
|
|
156
|
+
function resolvePlan(mode, root, baseRef, onlyFiles) {
|
|
157
|
+
if (mode === "staged") {
|
|
158
|
+
const diff = git(["diff", "--cached", NAME_ONLY, NO_RENAMES], root);
|
|
159
|
+
if (diff === null) return null;
|
|
160
|
+
return {
|
|
161
|
+
files: diff.split("\n").filter(Boolean),
|
|
162
|
+
baselineRef: "HEAD",
|
|
163
|
+
readCurrent: f => git(["show", `:${f}`], root),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (mode === "base") {
|
|
167
|
+
if (!baseRef) return null;
|
|
168
|
+
const mergeBase = git(["merge-base", baseRef, "HEAD"], root)?.trim();
|
|
169
|
+
if (!mergeBase) return null;
|
|
170
|
+
const diff = git(["diff", NAME_ONLY, NO_RENAMES, mergeBase, "HEAD"], root);
|
|
171
|
+
if (diff === null) return null;
|
|
172
|
+
return {
|
|
173
|
+
files: diff.split("\n").filter(Boolean),
|
|
174
|
+
baselineRef: mergeBase,
|
|
175
|
+
readCurrent: f => git(["show", `HEAD:${f}`], root),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const diff = git(["diff", NAME_ONLY, NO_RENAMES, "HEAD"], root);
|
|
179
|
+
if (diff === null) return null;
|
|
180
|
+
return {
|
|
181
|
+
files: onlyFiles ?? diff.split("\n").filter(Boolean),
|
|
182
|
+
baselineRef: "HEAD",
|
|
183
|
+
readCurrent: f => {
|
|
184
|
+
try {
|
|
185
|
+
return fs.readFileSync(path.join(root, f), "utf-8");
|
|
186
|
+
} catch {
|
|
187
|
+
// probe-direction: fail-closed — an unreadable current file compares as
|
|
188
|
+
// absent against the baseline, which is the strict side of the ratchet.
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Strip a remote prefix so `origin/staging` and `staging` compare equal to a
|
|
197
|
+
* branch name declared in `.lisa.config.json`.
|
|
198
|
+
* @param {string} ref Git ref, possibly remote-qualified
|
|
199
|
+
* @returns {string} Bare branch name
|
|
200
|
+
*/
|
|
201
|
+
function bareBranch(ref) {
|
|
202
|
+
return ref.replace(/^refs\/heads\//u, "").replace(/^origin\//u, "");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The deploy chain, earliest environment first, from `deploy.branches`.
|
|
207
|
+
*
|
|
208
|
+
* Declaration order IS the chain order — that is already how Lisa reads it
|
|
209
|
+
* (dev → staging → production), and it is what makes "upstream of" decidable.
|
|
210
|
+
* @param {unknown} config Parsed `.lisa.config.json`
|
|
211
|
+
* @returns {string[]} Branch names in chain order
|
|
212
|
+
*/
|
|
213
|
+
function deployChain(config) {
|
|
214
|
+
const branches = config?.deploy?.branches;
|
|
215
|
+
if (!branches || typeof branches !== "object") return [];
|
|
216
|
+
return Object.values(branches).filter(b => typeof b === "string" && b !== "");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Whether this is a promotion of one deploy-chain branch into the next.
|
|
221
|
+
*
|
|
222
|
+
* A promotion carries approved history into a branch that is behind, so the
|
|
223
|
+
* exemptions it brings with it are not new — each one already faced this gate
|
|
224
|
+
* on the upstream branch. Reading the allow list from the baseline there
|
|
225
|
+
* reports every one of them as newly added, and the documented remedy is
|
|
226
|
+
* circular: recording them means adding `thresholdRatchet.allow` entries,
|
|
227
|
+
* which is itself the Tier 3 change being blocked. That deadlocked the whole
|
|
228
|
+
* promotion lane (#2531).
|
|
229
|
+
*
|
|
230
|
+
* The discriminator is branch IDENTITY, not ancestry. "The head contains the
|
|
231
|
+
* base" is true of any ordinary topic branch that is up to date with its base
|
|
232
|
+
* — and a repository with a strict up-to-date branch-protection rule REQUIRES
|
|
233
|
+
* that of every PR — so ancestry alone would hand self-approval to exactly the
|
|
234
|
+
* changes Tier 3 exists to stop. Being a deploy-chain branch cannot be
|
|
235
|
+
* arranged by a topic branch: those branches are protected, so everything on
|
|
236
|
+
* them arrived through a reviewed PR that passed this same ratchet.
|
|
237
|
+
*
|
|
238
|
+
* Ancestry is still required, as a second condition rather than the only one:
|
|
239
|
+
* a head that has diverged from its base is not "the baseline plus approved
|
|
240
|
+
* history", and the strict reading should stand.
|
|
241
|
+
*
|
|
242
|
+
* The chain is read from the BASELINE config, so a change cannot declare
|
|
243
|
+
* itself a promotion by adding `deploy.branches` entries in the same commit.
|
|
244
|
+
* @param {string} root Repo root
|
|
245
|
+
* @param {unknown} baselineConfig `.lisa.config.json` at the baseline
|
|
246
|
+
* @param {string | undefined} baseRef Ref being merged into
|
|
247
|
+
* @param {string | undefined} headRef Ref being merged from
|
|
248
|
+
* @returns {boolean} True when the allow list may be read from the head
|
|
249
|
+
*/
|
|
250
|
+
function isPromotion(root, baselineConfig, baseRef, headRef) {
|
|
251
|
+
if (!baseRef || !headRef) return false;
|
|
252
|
+
const chain = deployChain(baselineConfig);
|
|
253
|
+
const basePosition = chain.indexOf(bareBranch(baseRef));
|
|
254
|
+
const headPosition = chain.indexOf(bareBranch(headRef));
|
|
255
|
+
if (basePosition === -1 || headPosition === -1) return false;
|
|
256
|
+
if (headPosition >= basePosition) return false;
|
|
257
|
+
// Empty string on success, null when git exits non-zero or the ref is bogus.
|
|
258
|
+
if (git(["merge-base", "--is-ancestor", baseRef, headRef], root) !== null) {
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
// Both ARE deploy-chain branches, so this is a promotion that has diverged —
|
|
262
|
+
// typically a hotfix that landed on the base and was never synced down. The
|
|
263
|
+
// strict reading is correct here, but silence would leave an operator
|
|
264
|
+
// guessing why this promotion behaves differently from the last one.
|
|
265
|
+
process.stderr.write(
|
|
266
|
+
`threshold-ratchet: ${bareBranch(headRef)} does not contain ` +
|
|
267
|
+
`${bareBranch(baseRef)}, so this promotion is not the baseline plus ` +
|
|
268
|
+
`approved history and the allow list is read from the baseline. Sync ` +
|
|
269
|
+
`${bareBranch(baseRef)} down into ${bareBranch(headRef)} first.\n`
|
|
270
|
+
);
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the allow list and say where it came from.
|
|
276
|
+
* @param {string} root Repo root
|
|
277
|
+
* @param {string} baselineRef Ref the comparison baselines against
|
|
278
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
279
|
+
* @param {string | undefined} baseRef Base ref (base mode only)
|
|
280
|
+
* @param {string | undefined} headRef Head ref (base mode only)
|
|
281
|
+
* @returns {{ entries: object[], promotion: boolean, note: string | null }}
|
|
282
|
+
* Entries, whether this is a promotion, and an audit line to print when the
|
|
283
|
+
* entries came from anywhere but the baseline
|
|
284
|
+
*/
|
|
285
|
+
function resolveAllowList(root, baselineRef, mode, baseRef, headRef) {
|
|
286
|
+
const baselineConfig = parseJson(
|
|
287
|
+
git(["show", `${baselineRef}:.lisa.config.json`], root)
|
|
288
|
+
);
|
|
289
|
+
if (mode !== "base" || !isPromotion(root, baselineConfig, baseRef, headRef)) {
|
|
290
|
+
return {
|
|
291
|
+
entries: extractAllowEntries(baselineConfig),
|
|
292
|
+
promotion: false,
|
|
293
|
+
note: null,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
entries: extractAllowEntries(
|
|
298
|
+
parseJson(git(["show", "HEAD:.lisa.config.json"], root))
|
|
299
|
+
),
|
|
300
|
+
promotion: true,
|
|
301
|
+
note:
|
|
302
|
+
`threshold-ratchet: promotion ${bareBranch(headRef)} → ` +
|
|
303
|
+
`${bareBranch(baseRef)}; both are deploy-chain branches declared at ` +
|
|
304
|
+
`${baselineRef} and the head fully contains the base, so the allow list ` +
|
|
305
|
+
`is read from the head. Exemptions below were approved upstream, not by ` +
|
|
306
|
+
`this change.`,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Split off the allow-entry additions a promotion is carrying forward.
|
|
312
|
+
*
|
|
313
|
+
* `applyAllowList` never drops an `allow-added` finding, from either side —
|
|
314
|
+
* an exception must not approve its own creation. That is right for an
|
|
315
|
+
* ordinary PR and wrong for a promotion, where the entry is not being created:
|
|
316
|
+
* it already exists on the upstream branch, where its creation faced this same
|
|
317
|
+
* unconditional block and needed a human to clear it. Without this the fix
|
|
318
|
+
* would be cosmetic — a promotion carrying an approved exemption also carries
|
|
319
|
+
* the `.lisa.config.json` diff that records it, so it would still be blocked
|
|
320
|
+
* by the finding for the record of its own approval.
|
|
321
|
+
*
|
|
322
|
+
* Only `allow-added` is carried. An actual threshold weakening in the same
|
|
323
|
+
* promotion still has to be covered by an allow entry.
|
|
324
|
+
* @param {Array<{ type: string }>} findings All findings from the change
|
|
325
|
+
* @param {boolean} promotion Whether the change is a recognised promotion
|
|
326
|
+
* @returns {{ carried: object[], rest: object[] }} Findings excused as already
|
|
327
|
+
* approved upstream, and findings still subject to the allow list
|
|
328
|
+
*/
|
|
329
|
+
function partitionCarriedEntries(findings, promotion) {
|
|
330
|
+
if (!promotion) return { carried: [], rest: findings };
|
|
331
|
+
return {
|
|
332
|
+
carried: findings.filter(f => f.type === "allow-added"),
|
|
333
|
+
rest: findings.filter(f => f.type !== "allow-added"),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Decide the exit code when the ratchet cannot determine what changed.
|
|
339
|
+
*
|
|
340
|
+
* `staged` and `base` are enforcement gates — pre-commit and CI. A gate that
|
|
341
|
+
* cannot see the change set has not found the change set clean, and returning 0
|
|
342
|
+
* reports exactly that. It fails closed, loudly.
|
|
343
|
+
*
|
|
344
|
+
* `hook` is advisory feedback to an agent mid-edit, fires on every tool call,
|
|
345
|
+
* and blocks nothing downstream. Failing it closed on a transient git hiccup
|
|
346
|
+
* would turn a hint into an outage, so it stays permissive — but still says so.
|
|
347
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
348
|
+
* @param {string} reason What could not be determined
|
|
349
|
+
* @returns {number} Process exit code
|
|
350
|
+
*/
|
|
351
|
+
function undeterminable(mode, reason) {
|
|
352
|
+
process.stderr.write(
|
|
353
|
+
`threshold-ratchet: ${reason}; the ratchet could not run in ${mode} mode\n`
|
|
354
|
+
);
|
|
355
|
+
if (mode === "hook") return 0;
|
|
356
|
+
process.stderr.write(
|
|
357
|
+
"threshold-ratchet: failing closed — an enforcement gate that cannot read the change set has not verified it\n"
|
|
358
|
+
);
|
|
359
|
+
return 1;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Run the ratchet for a mode, print the report, and return the exit code.
|
|
364
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
365
|
+
* @param {string | undefined} [baseRef] Base ref (base mode only)
|
|
366
|
+
* @param {string[] | undefined} [onlyFiles] Restrict to these paths (hook mode)
|
|
367
|
+
* @param {string | undefined} [headRef] Head ref (base mode only), used solely
|
|
368
|
+
* to recognise a promotion between deploy-chain branches
|
|
369
|
+
* @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
|
|
370
|
+
*/
|
|
371
|
+
function run(mode, baseRef, onlyFiles, headRef) {
|
|
372
|
+
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
373
|
+
if (!root) return undeterminable(mode, "not a git repository");
|
|
374
|
+
const plan = resolvePlan(mode, root, baseRef, onlyFiles);
|
|
375
|
+
if (!plan) return undeterminable(mode, "could not resolve the changed files");
|
|
376
|
+
|
|
377
|
+
const watched = plan.files.filter(f => familyFor(f));
|
|
378
|
+
if (watched.length === 0) return 0;
|
|
379
|
+
|
|
380
|
+
// A null baseline means one of two opposite things, and they must not be
|
|
381
|
+
// conflated: the file is NEW (nothing to weaken — pass), or it exists at the
|
|
382
|
+
// baseline and could not be read (nothing could be COMPARED — the one case
|
|
383
|
+
// where the ratchet cannot do its job). Both arrive here as null because
|
|
384
|
+
// `git()` swallows every failure, so the ratchet passed exactly when it had
|
|
385
|
+
// no evidence — failing open in its blind spot.
|
|
386
|
+
//
|
|
387
|
+
// `cat-file -e` answers the question `git show` cannot: does this path exist
|
|
388
|
+
// at that ref? Absent means new; present-but-unreadable means undeterminable,
|
|
389
|
+
// and an undeterminable ratchet must refuse rather than wave the change on.
|
|
390
|
+
const unreadable = watched.filter(
|
|
391
|
+
f =>
|
|
392
|
+
git(["show", `${plan.baselineRef}:${f}`], root) === null &&
|
|
393
|
+
git(["cat-file", "-e", `${plan.baselineRef}:${f}`], root) !== null
|
|
394
|
+
);
|
|
395
|
+
if (unreadable.length > 0) {
|
|
396
|
+
return undeterminable(
|
|
397
|
+
mode,
|
|
398
|
+
`could not read the baseline for ${unreadable.join(", ")} — ` +
|
|
399
|
+
`the file exists at ${plan.baselineRef} but its contents could not be ` +
|
|
400
|
+
`retrieved, so a loosened threshold could not be detected`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const allow = resolveAllowList(
|
|
405
|
+
root,
|
|
406
|
+
plan.baselineRef,
|
|
407
|
+
mode,
|
|
408
|
+
baseRef,
|
|
409
|
+
headRef
|
|
410
|
+
);
|
|
411
|
+
const findings = watched.flatMap(f =>
|
|
412
|
+
compareFile(
|
|
413
|
+
f,
|
|
414
|
+
git(["show", `${plan.baselineRef}:${f}`], root),
|
|
415
|
+
plan.readCurrent(f)
|
|
416
|
+
)
|
|
417
|
+
);
|
|
418
|
+
// The allow-list inventory is printed whether or not the change produced
|
|
419
|
+
// findings. An exemption that has outlived its condition is dead weight the
|
|
420
|
+
// moment the condition passes, and reporting it only when something else has
|
|
421
|
+
// already failed is how a list nobody reads stays a list nobody reads.
|
|
422
|
+
if (findings.length === 0) {
|
|
423
|
+
reportAllowList(allow.entries, []);
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
return reportFindings(findings, allow, mode);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Print the allow entries a human has to act on, minus the ones a refusal is
|
|
431
|
+
* about to name in full.
|
|
432
|
+
* @param {object[]} entries The resolved allow list
|
|
433
|
+
* @param {Array<{ message: string }>} expired Refusals already being printed
|
|
434
|
+
* @returns {void}
|
|
435
|
+
*/
|
|
436
|
+
function reportAllowList(entries, expired) {
|
|
437
|
+
for (const line of describeAllowList(entries)) {
|
|
438
|
+
if (expired.some(item => item.message.startsWith(line))) continue;
|
|
439
|
+
process.stdout.write(`threshold-ratchet: ${line}\n`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Print the verdict for a change that produced findings.
|
|
445
|
+
* @param {object[]} findings Findings from every watched file
|
|
446
|
+
* @param {{ entries: object[], promotion: boolean, note: string | null }} allow
|
|
447
|
+
* The resolved allow list and its provenance
|
|
448
|
+
* @param {"hook"|"staged"|"base"} mode Comparison mode
|
|
449
|
+
* @returns {number} Process exit code (2 for hook mode, 1 otherwise; 0 clean)
|
|
450
|
+
*/
|
|
451
|
+
function reportFindings(findings, allow, mode) {
|
|
452
|
+
const split = partitionCarriedEntries(findings, allow.promotion);
|
|
453
|
+
const { blocked, allowed, expired } = applyAllowList(
|
|
454
|
+
split.rest,
|
|
455
|
+
allow.entries
|
|
456
|
+
);
|
|
457
|
+
if (allow.note) process.stdout.write(`${allow.note}\n`);
|
|
458
|
+
reportAllowList(allow.entries, expired);
|
|
459
|
+
for (const finding of split.carried) {
|
|
460
|
+
process.stdout.write(
|
|
461
|
+
`threshold-ratchet: carried forward by this promotion, approved upstream — ${finding.message}\n`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
for (const item of allowed) {
|
|
465
|
+
process.stdout.write(
|
|
466
|
+
`threshold-ratchet: ${item.message} (.lisa.config.json exception)\n`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
for (const item of expired) {
|
|
470
|
+
process.stderr.write(`threshold-ratchet: refused — ${item.message}\n`);
|
|
471
|
+
}
|
|
472
|
+
if (blocked.length === 0) return 0;
|
|
473
|
+
process.stderr.write(`${formatReport(blocked)}\n`);
|
|
474
|
+
return mode === "hook" ? 2 : 1;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Handle `--hook` mode: parse the tool-use event from stdin and scope the
|
|
479
|
+
* check to the edited file (Edit/Write/NotebookEdit) or every changed
|
|
480
|
+
* watched file (Bash).
|
|
481
|
+
* @returns {number} Process exit code
|
|
482
|
+
*/
|
|
483
|
+
function runHookMode() {
|
|
484
|
+
const state = { stdin: "" };
|
|
485
|
+
try {
|
|
486
|
+
state.stdin = fs.readFileSync(0, "utf-8");
|
|
487
|
+
} catch {
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
const input = parseJson(state.stdin);
|
|
491
|
+
if (!input || typeof input !== "object") return 0;
|
|
492
|
+
if (input.tool_name === "Bash") return run("hook");
|
|
493
|
+
if (!["Edit", "Write", "NotebookEdit"].includes(input.tool_name)) return 0;
|
|
494
|
+
const filePath = input.tool_input?.file_path;
|
|
495
|
+
if (typeof filePath !== "string") return 0;
|
|
496
|
+
const root = git(["rev-parse", "--show-toplevel"])?.trim();
|
|
497
|
+
if (!root) return 0;
|
|
498
|
+
const rel = path
|
|
499
|
+
.relative(root, path.resolve(filePath))
|
|
500
|
+
.split(path.sep)
|
|
501
|
+
.join("/");
|
|
502
|
+
if (rel.startsWith("..") || !familyFor(rel)) return 0;
|
|
503
|
+
return run("hook", undefined, [rel]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* CLI entrypoint.
|
|
508
|
+
* @returns {number} Process exit code
|
|
509
|
+
*/
|
|
510
|
+
function main() {
|
|
511
|
+
const args = process.argv.slice(2);
|
|
512
|
+
const headIndex = args.indexOf("--head");
|
|
513
|
+
const headRef = headIndex === -1 ? undefined : args[headIndex + 1];
|
|
514
|
+
if (args[0] === "--staged") return run("staged");
|
|
515
|
+
// `--head` is optional and additive: a caller that omits it gets exactly the
|
|
516
|
+
// behavior that shipped before promotions were recognised, so an older
|
|
517
|
+
// workflow driving a newer script stays strict rather than silently relaxing.
|
|
518
|
+
if (args[0] === "--base") return run("base", args[1], undefined, headRef);
|
|
519
|
+
if (args[0] === "--hook") return runHookMode();
|
|
520
|
+
process.stderr.write(
|
|
521
|
+
"usage: threshold-ratchet.mjs --hook | --staged | --base <ref> [--head <ref>]\n"
|
|
522
|
+
);
|
|
523
|
+
return 2;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* True when `moduleUrl` names the module node was asked to run.
|
|
528
|
+
*
|
|
529
|
+
* The one implementation of this lives at `scripts/lib/invoked-as-script.mjs`,
|
|
530
|
+
* and every other shipped entry point imports it. This file cannot: it is
|
|
531
|
+
* materialized from `plugins/src/base/hooks/`, where it also runs as an
|
|
532
|
+
* agent-time hook, and a plugin payload has no `./lib/` to import from. So the
|
|
533
|
+
* rule is written out here rather than pointed at — the same accommodation
|
|
534
|
+
* `preflight-secrets.mjs` makes, for the same reason.
|
|
535
|
+
*
|
|
536
|
+
* Both sides are realpath'd. The previous spelling compared
|
|
537
|
+
* `fileURLToPath(import.meta.url)` against `path.resolve(process.argv[1])`, and
|
|
538
|
+
* `resolve` makes a path absolute without following symlinks. `import.meta.url`
|
|
539
|
+
* is the REAL path — node resolves ESM through realpath unless
|
|
540
|
+
* `--preserve-symlinks` or `--preserve-symlinks-main` is set — while `argv[1]`
|
|
541
|
+
* is whatever the caller typed. Reached through a symlinked checkout, a git
|
|
542
|
+
* worktree, or a `/tmp` path on macOS (`/tmp` being a symlink to `/private/tmp`),
|
|
543
|
+
* the two disagreed, `main()` never ran, and the process exited 0.
|
|
544
|
+
*
|
|
545
|
+
* For a CHECK that is a fail-OPEN, not a mild degradation: no output, exit 0,
|
|
546
|
+
* the npm script "succeeds", and the ratchet silently stops having an opinion.
|
|
547
|
+
* It was measured, not theorised — every control in the #2531 promotion tests
|
|
548
|
+
* no-opped until the fixture directory was realpath'd.
|
|
549
|
+
*
|
|
550
|
+
* Realpathing BOTH sides rather than only `argv[1]` matters under
|
|
551
|
+
* `--preserve-symlinks-main`, which tells node not to resolve the main entry:
|
|
552
|
+
* normalizing one side then compares a real path against a symlinked one and
|
|
553
|
+
* answers `false` for an entry point that WAS invoked directly. Any resolution
|
|
554
|
+
* error returns `false` — node loaded the entry from that path moments ago, so
|
|
555
|
+
* a path that will not resolve now is not the path this module came through.
|
|
556
|
+
* @param {string} moduleUrl - The caller's own `import.meta.url`.
|
|
557
|
+
* @param {string | undefined} [argv1] - Entry path; defaults to `process.argv[1]`.
|
|
558
|
+
* @returns {boolean} Whether the caller should run its CLI body.
|
|
559
|
+
*/
|
|
560
|
+
export function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
561
|
+
if (!argv1) return false;
|
|
562
|
+
try {
|
|
563
|
+
return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
|
|
564
|
+
} catch {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (invokedAsScript(import.meta.url)) {
|
|
570
|
+
process.exit(main());
|
|
571
|
+
}
|
|
@@ -73,13 +73,19 @@ export function ghRequest(repo) {
|
|
|
73
73
|
export function describe(result) {
|
|
74
74
|
// Callers built before the startup-failure arm report no such key.
|
|
75
75
|
const startupFailures = result.startupFailures ?? [];
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
if (
|
|
77
|
+
result.covered &&
|
|
78
|
+
result.loadFailures.length === 0 &&
|
|
79
|
+
startupFailures.length === 0
|
|
80
|
+
) {
|
|
80
81
|
return `check-workflow-load-failures: OK. ${result.inspected} run(s) inspected across the last ${WINDOW_HOURS}h; no reusable workflow load failures found.`;
|
|
81
82
|
}
|
|
82
83
|
const sections = [];
|
|
84
|
+
if (!result.covered) {
|
|
85
|
+
sections.push(
|
|
86
|
+
`check-workflow-load-failures: INCOMPLETE. The scan ${result.reason} It inspected ${result.inspected} run(s), but that is not the same as having covered the window, so this is an error rather than a pass.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
83
89
|
if (result.loadFailures.length > 0) {
|
|
84
90
|
const lines = result.loadFailures.map(
|
|
85
91
|
finding =>
|
|
@@ -87,7 +93,7 @@ export function describe(result) {
|
|
|
87
93
|
);
|
|
88
94
|
sections.push(
|
|
89
95
|
[
|
|
90
|
-
`check-workflow-load-failures: ${result.loadFailures.length} run(s) failed to LOAD a reusable workflow.`,
|
|
96
|
+
`check-workflow-load-failures: ${result.covered ? "" : "at least "}${result.loadFailures.length} run(s) failed to LOAD a reusable workflow.`,
|
|
91
97
|
...lines,
|
|
92
98
|
"",
|
|
93
99
|
"A load failure creates NO jobs, so there is no red job to open and no annotation naming the line. Check the upstream workflow's most recent commit for a syntax or schema error.",
|
|
@@ -106,9 +112,15 @@ export function describe(result) {
|
|
|
106
112
|
.map(finding => finding.createdAt)
|
|
107
113
|
.filter(Boolean)
|
|
108
114
|
.sort();
|
|
109
|
-
const paths = [
|
|
115
|
+
const paths = [
|
|
116
|
+
...new Set(
|
|
117
|
+
startupFailures
|
|
118
|
+
.map(finding => finding.path)
|
|
119
|
+
.filter(value => value && value !== "BuildFailed")
|
|
120
|
+
),
|
|
121
|
+
];
|
|
110
122
|
const summary = [
|
|
111
|
-
`check-workflow-load-failures: ${startupFailures.length} run(s) ended in startup_failure — GitHub never started them, whatever workflow they are recorded under.`,
|
|
123
|
+
`check-workflow-load-failures: ${result.covered ? "" : "at least "}${startupFailures.length} run(s) ended in startup_failure — GitHub never started them, whatever workflow they are recorded under.`,
|
|
112
124
|
...lines,
|
|
113
125
|
"",
|
|
114
126
|
`First: ${times[0] ?? "unknown"}. Last: ${times.at(-1) ?? "unknown"}. Distinct workflow path(s): ${paths.length}.`,
|
|
@@ -117,9 +129,19 @@ export function describe(result) {
|
|
|
117
129
|
// ended paging — the outage shape is every run INSIDE the window, so the
|
|
118
130
|
// comparison belongs to `inWindow` (absent on older callers → fall back).
|
|
119
131
|
const inWindow = result.inWindow ?? result.inspected;
|
|
120
|
-
if (
|
|
132
|
+
if (
|
|
133
|
+
result.covered &&
|
|
134
|
+
inWindow > 0 &&
|
|
135
|
+
startupFailures.length === inWindow &&
|
|
136
|
+
(paths.length > 1 ||
|
|
137
|
+
startupFailures.some(finding => finding.path === "BuildFailed"))
|
|
138
|
+
) {
|
|
139
|
+
summary.push(
|
|
140
|
+
"EVERY run in the window failed to start across multiple workflows or GitHub's BuildFailed placeholder. This may indicate an account or Actions service availability problem. Check Actions availability and account restrictions, and verify the workflow files before deciding the cause."
|
|
141
|
+
);
|
|
142
|
+
} else if (paths.length === 1) {
|
|
121
143
|
summary.push(
|
|
122
|
-
"
|
|
144
|
+
"One workflow file is identified among the observed startup failures. Inspect the workflow file and check Actions availability; the run count alone does not establish the cause."
|
|
123
145
|
);
|
|
124
146
|
}
|
|
125
147
|
sections.push(summary.join("\n"));
|
|
@@ -26,6 +26,25 @@ const DESCRIPTOR_KEYS = [
|
|
|
26
26
|
const HEX = /^[0-9a-f]{64}$/;
|
|
27
27
|
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
28
28
|
|
|
29
|
+
/** Only an explicit original Bun digest extends the unchanged npm-only schema. */
|
|
30
|
+
export function optionalLockFields(value) {
|
|
31
|
+
if (!Object.hasOwn(value, "bunLockSha256")) return [];
|
|
32
|
+
requireProof(
|
|
33
|
+
typeof value.bunLockSha256 === "string" && HEX.test(value.bunLockSha256),
|
|
34
|
+
"invalid original Bun lock digest"
|
|
35
|
+
);
|
|
36
|
+
return ["bunLockSha256"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Signed original presence selects one closed two- or three-file cohort. */
|
|
40
|
+
export function proposalFileNames(value) {
|
|
41
|
+
const files = optionalLockFields(value).length
|
|
42
|
+
? ["bun.lock", ...FILES]
|
|
43
|
+
: FILES;
|
|
44
|
+
exactKeys(value.files, files, "proposal files");
|
|
45
|
+
return files;
|
|
46
|
+
}
|
|
47
|
+
|
|
29
48
|
/** Canonical bytes are shared by allocator and consumer, not inferred hashes. */
|
|
30
49
|
export function canonicalJson(value) {
|
|
31
50
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|