@codyswann/lisa 4.9.3 → 4.10.0
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/lisa-gates.mjs +824 -3
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +21 -107
- package/dist/cli/{doctor-learnings-merge-driver.d.ts → doctor-merge-drivers.d.ts} +6 -6
- package/dist/cli/doctor-merge-drivers.d.ts.map +1 -0
- package/dist/cli/doctor-merge-drivers.js +163 -0
- package/dist/cli/doctor-merge-drivers.js.map +1 -0
- package/dist/cli/doctor.js +2 -2
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/gitattributes-merge-drivers.d.ts +28 -0
- package/dist/core/gitattributes-merge-drivers.d.ts.map +1 -0
- package/dist/core/gitattributes-merge-drivers.js +62 -0
- package/dist/core/gitattributes-merge-drivers.js.map +1 -0
- package/dist/core/learnings-merge-driver-install.d.ts +0 -13
- package/dist/core/learnings-merge-driver-install.d.ts.map +1 -1
- package/dist/core/learnings-merge-driver-install.js +0 -16
- package/dist/core/learnings-merge-driver-install.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +7 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/merge-driver-registration.d.ts +26 -0
- package/dist/core/merge-driver-registration.d.ts.map +1 -0
- package/dist/core/merge-driver-registration.js +38 -0
- package/dist/core/merge-driver-registration.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +21 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +3 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +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-cursor/.claude-plugin/plugin.json +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/scripts/check-merge-driver-registration.mjs +142 -0
- package/scripts/install-generated-artifact-merge-driver.mjs +200 -0
- package/scripts/lib/gitattributes-merge-drivers.mjs +52 -0
- package/scripts/merge-generated-artifact.mjs +519 -0
- package/dist/cli/doctor-learnings-merge-driver.d.ts.map +0 -1
- package/dist/cli/doctor-learnings-merge-driver.js +0 -73
- package/dist/cli/doctor-learnings-merge-driver.js.map +0 -1
|
@@ -68,7 +68,8 @@
|
|
|
68
68
|
* @module lisa-gates
|
|
69
69
|
*/
|
|
70
70
|
|
|
71
|
-
import {
|
|
71
|
+
import { createHash } from "node:crypto";
|
|
72
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
72
73
|
import { join } from "node:path";
|
|
73
74
|
|
|
74
75
|
import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
@@ -163,7 +164,7 @@ const NO_STATUS_MOMENTS = Object.freeze([
|
|
|
163
164
|
export const STATE_FAMILIES = [CONTINUOUS];
|
|
164
165
|
|
|
165
166
|
/** Keys on a gate entry that are settings rather than moments. */
|
|
166
|
-
export const GATE_FIELDS = new Set(["run", "needs", "task"]);
|
|
167
|
+
export const GATE_FIELDS = new Set(["run", "needs", "task", "reuse"]);
|
|
167
168
|
|
|
168
169
|
/**
|
|
169
170
|
* The per-declaration field naming the job chain this gate's prover sits under.
|
|
@@ -2757,6 +2758,711 @@ export function readEvidence(evidence, definition = {}, nowMs = Date.now()) {
|
|
|
2757
2758
|
return { status: "pass", reason: null };
|
|
2758
2759
|
}
|
|
2759
2760
|
|
|
2761
|
+
// ─── Release evidence reuse (CodySwannGT/lisa#3013) ─────────────────────────
|
|
2762
|
+
//
|
|
2763
|
+
// Release calls the full quality workflow again after merge, on a tree that
|
|
2764
|
+
// already passed the same contract on its pull request. Reuse is allowed only
|
|
2765
|
+
// when a VERIFIED `lisa.gate-evidence/v1` envelope proves the same tree under
|
|
2766
|
+
// the same-or-stricter contract, and every unproved dimension makes the gate
|
|
2767
|
+
// RUN. The design, including the permission wall that shaped it, is in
|
|
2768
|
+
// `docs/design/release-evidence-reuse.md`.
|
|
2769
|
+
|
|
2770
|
+
/**
|
|
2771
|
+
* The schema token, spelled here as well as in the producer.
|
|
2772
|
+
*
|
|
2773
|
+
* This is a deliberate SECOND ADDRESS for one string, and it is forced rather
|
|
2774
|
+
* than sloppy: `.github/workflows/gates.yml` tells an old runner apart from a
|
|
2775
|
+
* current one by grepping the runner file for this literal, so the literal
|
|
2776
|
+
* cannot move out of `lisa-run-gates.mjs`. A verifier that imported it from
|
|
2777
|
+
* there would make this module depend on the runner, inverting the dependency.
|
|
2778
|
+
* `tests/unit/scripts/lisa-gates-reuse-plan.test.ts` pins the two together, so
|
|
2779
|
+
* they cannot drift even though they are written twice.
|
|
2780
|
+
*/
|
|
2781
|
+
export const EVIDENCE_SCHEMA_TOKEN = "lisa.gate-evidence/v1";
|
|
2782
|
+
|
|
2783
|
+
/**
|
|
2784
|
+
* Compare strings by UTF-16 code units, independent of the host locale.
|
|
2785
|
+
*
|
|
2786
|
+
* Evidence digests cross machines. `localeCompare` answers a human collation
|
|
2787
|
+
* question using the process locale and installed ICU data, so it can order the
|
|
2788
|
+
* same keys differently on two runners. Relational string comparison is the
|
|
2789
|
+
* ECMAScript code-unit order and is therefore the contract we can reuse.
|
|
2790
|
+
* @param {string} left Left value.
|
|
2791
|
+
* @param {string} right Right value.
|
|
2792
|
+
* @returns {number} Negative, zero, or positive.
|
|
2793
|
+
*/
|
|
2794
|
+
export function compareCodeUnits(left, right) {
|
|
2795
|
+
if (left === right) return 0;
|
|
2796
|
+
return left < right ? -1 : 1;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
/**
|
|
2800
|
+
* The same value with every object key in a stable order.
|
|
2801
|
+
*
|
|
2802
|
+
* A digest over `JSON.stringify` of the raw block would change when an editor
|
|
2803
|
+
* reordered two keys, which would make every prior observation read as produced
|
|
2804
|
+
* under a different contract. Ordering is normalised so the digest answers "is
|
|
2805
|
+
* this the same declaration?" and nothing else.
|
|
2806
|
+
*
|
|
2807
|
+
* This lives HERE rather than beside the producer that first needed it, because
|
|
2808
|
+
* a verifier that recomputed the digest with a second implementation would be
|
|
2809
|
+
* comparing two answers to the same question. One implementation, two callers.
|
|
2810
|
+
* @param {unknown} value Any JSON value.
|
|
2811
|
+
* @returns {unknown} The same value, key-ordered.
|
|
2812
|
+
*/
|
|
2813
|
+
export function canonical(value) {
|
|
2814
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
2815
|
+
if (value === null || typeof value !== "object") return value;
|
|
2816
|
+
return Object.fromEntries(
|
|
2817
|
+
Object.keys(value)
|
|
2818
|
+
// An explicit comparator, never a bare `.sort()`: the default sorts by
|
|
2819
|
+
// UTF-16 code unit through `String()`, and a lint rule in this tree
|
|
2820
|
+
// rejects the bare form for exactly that reason.
|
|
2821
|
+
.sort(compareCodeUnits)
|
|
2822
|
+
.map(key => [key, canonical(value[key])])
|
|
2823
|
+
);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* A sha256 over canonical JSON, spelled once.
|
|
2828
|
+
* @param {unknown} value Any JSON value.
|
|
2829
|
+
* @returns {string} `sha256:<hex>`.
|
|
2830
|
+
*/
|
|
2831
|
+
export function digest(value) {
|
|
2832
|
+
const canonicalised = JSON.stringify(canonical(value));
|
|
2833
|
+
return `sha256:${createHash("sha256").update(canonicalised).digest("hex")}`;
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
/**
|
|
2837
|
+
* Digest of the RESOLVED PLAN at a moment — not the config file's bytes.
|
|
2838
|
+
*
|
|
2839
|
+
* Evidence is only reusable while the contract that produced it still holds,
|
|
2840
|
+
* and three things make the plan the right subject rather than the file:
|
|
2841
|
+
*
|
|
2842
|
+
* - an unrelated key elsewhere in `.lisa.config.json` being edited would force
|
|
2843
|
+
* spurious reruns against a file digest, while proving nothing changed here;
|
|
2844
|
+
* - the raw file does not capture `shippedAs` alias resolution, which reads
|
|
2845
|
+
* `package.json` scripts and genuinely changes WHICH COMMAND proved the
|
|
2846
|
+
* gate — a file digest would call two different provers the same contract;
|
|
2847
|
+
* - `includeOff: true` is what makes "this project declared the gate off" a
|
|
2848
|
+
* RECORDED fact rather than an absence indistinguishable from a registry that
|
|
2849
|
+
* never knew the gate existed.
|
|
2850
|
+
*
|
|
2851
|
+
* It is also what refuses a result recorded while a gate was `optional` from
|
|
2852
|
+
* satisfying a moment that now declares it `required` — a stale-evidence hole
|
|
2853
|
+
* no timestamp closes.
|
|
2854
|
+
* @param {object} options Inputs.
|
|
2855
|
+
* @param {object|null} options.gates The gates block.
|
|
2856
|
+
* @param {string} options.moment The moment being run.
|
|
2857
|
+
* @param {string} [options.runner] The task-runner prefix.
|
|
2858
|
+
* @param {Record<string,string>|null} [options.scripts] Project scripts.
|
|
2859
|
+
* @returns {string|null} `sha256:<hex>`, or null when no plan could resolve.
|
|
2860
|
+
*/
|
|
2861
|
+
export function planDigest({ gates, moment, runner, scripts = null }) {
|
|
2862
|
+
if (!gates) return null;
|
|
2863
|
+
try {
|
|
2864
|
+
// The runner is forwarded verbatim when stated, INCLUDING a value
|
|
2865
|
+
// `resolveMoment` refuses. A truthiness guard here would let an invalid
|
|
2866
|
+
// runner fall through to the default, and the envelope would then record
|
|
2867
|
+
// `contract.runner` as one thing while digesting a plan built from another
|
|
2868
|
+
// — two facts about the same run, in one document.
|
|
2869
|
+
const options = { gates, includeOff: true, moment, scripts };
|
|
2870
|
+
const plan = resolveMoment(
|
|
2871
|
+
runner === undefined ? options : { ...options, runner }
|
|
2872
|
+
).map(gate => ({
|
|
2873
|
+
awaits: gate.awaits,
|
|
2874
|
+
command: gate.command,
|
|
2875
|
+
id: gate.id,
|
|
2876
|
+
level: gate.level,
|
|
2877
|
+
mode: gate.mode,
|
|
2878
|
+
task: gate.task,
|
|
2879
|
+
work: gate.work,
|
|
2880
|
+
}));
|
|
2881
|
+
return digest({
|
|
2882
|
+
gates: [...plan].sort((left, right) =>
|
|
2883
|
+
compareCodeUnits(left.id, right.id)
|
|
2884
|
+
),
|
|
2885
|
+
runner: runner ?? null,
|
|
2886
|
+
});
|
|
2887
|
+
} catch {
|
|
2888
|
+
// An unresolvable plan — an invalid runner, a refused configuration — has
|
|
2889
|
+
// no contract to digest. Null says so; it does not guess one.
|
|
2890
|
+
return null;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
/**
|
|
2895
|
+
* What kind of thing a gate's verdict is, which decides whether it may be
|
|
2896
|
+
* reused and for how long.
|
|
2897
|
+
*
|
|
2898
|
+
* Three values, and the default for anything not named is `never`. That
|
|
2899
|
+
* asymmetry is the whole safety posture: a gate becomes reusable only when
|
|
2900
|
+
* someone wrote down why it is, and a gate added tomorrow is not reusable by
|
|
2901
|
+
* accident.
|
|
2902
|
+
*/
|
|
2903
|
+
export const REUSE_CLASS = Object.freeze({
|
|
2904
|
+
/** Same tree + same contract ⇒ same verdict. No external state consulted. */
|
|
2905
|
+
DETERMINISTIC: "deterministic",
|
|
2906
|
+
/** Never satisfiable by evidence from another run. */
|
|
2907
|
+
NEVER: "never",
|
|
2908
|
+
/** Depends on external state that changes without the tree. Needs a window. */
|
|
2909
|
+
TIME_SENSITIVE: "time-sensitive",
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
/** Every legal class, for validation. */
|
|
2913
|
+
export const REUSE_CLASSES = Object.freeze(Object.values(REUSE_CLASS));
|
|
2914
|
+
|
|
2915
|
+
/**
|
|
2916
|
+
* The built-in classification, one row per registry gate.
|
|
2917
|
+
*
|
|
2918
|
+
* `tests/unit/scripts/lisa-gates-reuse-plan.test.ts` derives the key set from
|
|
2919
|
+
* `REGISTRY` and fails when they disagree, so a gate cannot be added without a
|
|
2920
|
+
* stated class. Deriving the table itself is not possible — whether a verdict
|
|
2921
|
+
* depends on external state is a fact about the prover, not about the registry
|
|
2922
|
+
* entry — but requiring the row is, and that is what stops a silent default.
|
|
2923
|
+
*
|
|
2924
|
+
* A project overrides a row with `gates.<id>.reuse` in `.lisa.config.json`.
|
|
2925
|
+
*/
|
|
2926
|
+
export const GATE_REUSE_CLASS = Object.freeze({
|
|
2927
|
+
accessibility: { class: REUSE_CLASS.NEVER },
|
|
2928
|
+
// A guard whose job is to detect a stale generated artifact must not itself
|
|
2929
|
+
// be satisfied by a record of a previous run, and it costs seconds.
|
|
2930
|
+
"artifact-freshness": { class: REUSE_CLASS.NEVER },
|
|
2931
|
+
"behavior-contract": { class: REUSE_CLASS.DETERMINISTIC, diff: true },
|
|
2932
|
+
"build-integrity": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2933
|
+
"code-review": { class: REUSE_CLASS.NEVER },
|
|
2934
|
+
"code-style": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2935
|
+
"code-style-slow": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2936
|
+
"commit-conformance": { class: REUSE_CLASS.DETERMINISTIC, diff: true },
|
|
2937
|
+
"conflict-residue": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2938
|
+
"coverage-adequacy": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2939
|
+
"credential-availability": { class: REUSE_CLASS.NEVER },
|
|
2940
|
+
// ── Time-sensitive. 60 minutes throughout, stated rather than inferred: it
|
|
2941
|
+
// sits well above the observed merge→release latency (minutes) and well below
|
|
2942
|
+
// the interval over which a GIVEN lockfile's answer meaningfully changes.
|
|
2943
|
+
// Re-proving these is cheap, so a narrow window costs little.
|
|
2944
|
+
"credential-leakage": {
|
|
2945
|
+
class: REUSE_CLASS.TIME_SENSITIVE,
|
|
2946
|
+
maxAgeMinutes: 60,
|
|
2947
|
+
},
|
|
2948
|
+
"dead-code": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2949
|
+
"dependency-vulnerability": {
|
|
2950
|
+
class: REUSE_CLASS.TIME_SENSITIVE,
|
|
2951
|
+
maxAgeMinutes: 60,
|
|
2952
|
+
},
|
|
2953
|
+
"e2e-browser": { class: REUSE_CLASS.NEVER },
|
|
2954
|
+
"e2e-native": { class: REUSE_CLASS.NEVER },
|
|
2955
|
+
"environment-reseed": { class: REUSE_CLASS.NEVER },
|
|
2956
|
+
"environment-reset": { class: REUSE_CLASS.NEVER },
|
|
2957
|
+
"format-conformance": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2958
|
+
"generative-testing": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2959
|
+
"journey-coverage": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2960
|
+
"learnings-budget": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2961
|
+
"license-compliance": {
|
|
2962
|
+
class: REUSE_CLASS.TIME_SENSITIVE,
|
|
2963
|
+
maxAgeMinutes: 60,
|
|
2964
|
+
},
|
|
2965
|
+
"load-capacity": { class: REUSE_CLASS.NEVER },
|
|
2966
|
+
"migration-provenance": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2967
|
+
// Its measurement varies with runner speed, so it is not a pure function of
|
|
2968
|
+
// the tree. But the property it gates is a property of the CODE, and runner
|
|
2969
|
+
// variance is noise in both directions: rerunning it adds a second sample of
|
|
2970
|
+
// the same noise, not a second proof. A project that disagrees declares
|
|
2971
|
+
// `"reuse": { "class": "time-sensitive", "max_age_minutes": N }`.
|
|
2972
|
+
"performance-budget": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2973
|
+
"runtime-web-vulnerability": { class: REUSE_CLASS.NEVER },
|
|
2974
|
+
"security-floor-integrity": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2975
|
+
"state-classification": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2976
|
+
// The main-branch analysis is a PUBLISHING action — it updates the
|
|
2977
|
+
// server-side baseline every later pull request is measured against — not
|
|
2978
|
+
// only a proof. Skipping it would silently stop maintaining that baseline.
|
|
2979
|
+
"static-security": { class: REUSE_CLASS.NEVER },
|
|
2980
|
+
"structural-rules": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2981
|
+
"suppression-residue": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2982
|
+
"test-correctness": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2983
|
+
// Hermetic in this repository. A project whose integration suite reaches a
|
|
2984
|
+
// live service MUST override this — the built-in cannot know, and the cost of
|
|
2985
|
+
// being wrong is reusing a proof about a system that has since changed.
|
|
2986
|
+
"test-integration": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2987
|
+
"test-meaningfulness": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2988
|
+
"test-node-suites": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2989
|
+
"threshold-monotonicity": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2990
|
+
"tool-availability": { class: REUSE_CLASS.NEVER },
|
|
2991
|
+
// `pull_request`-only by its job condition, so there is nothing to reuse and
|
|
2992
|
+
// nothing to save; declaring it `never` says that rather than implying it.
|
|
2993
|
+
traceability: { class: REUSE_CLASS.NEVER },
|
|
2994
|
+
"type-correctness": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2995
|
+
"version-duplication": { class: REUSE_CLASS.DETERMINISTIC },
|
|
2996
|
+
});
|
|
2997
|
+
|
|
2998
|
+
/**
|
|
2999
|
+
* Why a gate is running rather than reusing, as a stable token.
|
|
3000
|
+
*
|
|
3001
|
+
* Tokens rather than prose because the ledger is read by a job that must
|
|
3002
|
+
* compare two sets, and by an operator who needs the same phrase to mean the
|
|
3003
|
+
* same thing across runs.
|
|
3004
|
+
*/
|
|
3005
|
+
export const REUSE_REASON = Object.freeze({
|
|
3006
|
+
CONTRACT_MISMATCH: "contract-mismatch",
|
|
3007
|
+
DERIVATIVE: "derivative",
|
|
3008
|
+
LEVEL_DOWNGRADE: "level-downgrade",
|
|
3009
|
+
MALFORMED: "malformed",
|
|
3010
|
+
NEVER_REUSABLE: "never-reusable",
|
|
3011
|
+
NOT_PROVED: "not-proved",
|
|
3012
|
+
REUSED: "reused",
|
|
3013
|
+
STALE: "stale",
|
|
3014
|
+
SUBJECT_MISMATCH: "subject-mismatch",
|
|
3015
|
+
UNATTRIBUTABLE: "unattributable",
|
|
3016
|
+
UNAVAILABLE: "unavailable",
|
|
3017
|
+
UNCLASSIFIED: "unclassified",
|
|
3018
|
+
UNCOVERED: "uncovered",
|
|
3019
|
+
});
|
|
3020
|
+
|
|
3021
|
+
/** The largest envelope the verifier will read, in bytes. */
|
|
3022
|
+
export const EVIDENCE_BYTE_BUDGET = 1024 * 1024;
|
|
3023
|
+
|
|
3024
|
+
/** How deep a caller chain may be and still be primary pull-request proof. */
|
|
3025
|
+
const PRIMARY_CALLER_CHAIN_DEPTH = 1;
|
|
3026
|
+
|
|
3027
|
+
/** How strong each level is, so evidence can never satisfy a stricter one. */
|
|
3028
|
+
const LEVEL_RANK = Object.freeze({ off: 0, optional: 1, required: 2 });
|
|
3029
|
+
|
|
3030
|
+
/**
|
|
3031
|
+
* The reuse policy for one gate: built-in row, overridden by the project.
|
|
3032
|
+
*
|
|
3033
|
+
* An unknown gate id resolves to `never`. That is the fail-closed default and
|
|
3034
|
+
* it is why the built-in table does not need to anticipate a consumer's own
|
|
3035
|
+
* gate: a gate nobody classified is a gate nobody argued was reusable.
|
|
3036
|
+
* @param {string} id Gate id.
|
|
3037
|
+
* @param {object|null} [gates] The project gates block.
|
|
3038
|
+
* @returns {{class: string, diff: boolean, known: boolean, maxAgeMinutes: number|null}} Policy.
|
|
3039
|
+
*/
|
|
3040
|
+
export function reusePolicyFor(id, gates = null) {
|
|
3041
|
+
const builtIn = GATE_REUSE_CLASS[id] ?? null;
|
|
3042
|
+
const declared = gates?.[id]?.reuse ?? null;
|
|
3043
|
+
const known = Boolean(builtIn || declared);
|
|
3044
|
+
const merged = {
|
|
3045
|
+
class: declared?.class ?? builtIn?.class ?? REUSE_CLASS.NEVER,
|
|
3046
|
+
diff: declared?.diff ?? builtIn?.diff ?? false,
|
|
3047
|
+
known,
|
|
3048
|
+
maxAgeMinutes: declared?.max_age_minutes ?? builtIn?.maxAgeMinutes ?? null,
|
|
3049
|
+
};
|
|
3050
|
+
// An unrecognised class is not a licence to guess. `validate` refuses it at
|
|
3051
|
+
// declaration time; this is the runtime arm of the same refusal.
|
|
3052
|
+
return REUSE_CLASSES.includes(merged.class)
|
|
3053
|
+
? merged
|
|
3054
|
+
: { class: REUSE_CLASS.NEVER, diff: false, known, maxAgeMinutes: null };
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
/**
|
|
3058
|
+
* The tree this evidence is about must be the tree being released.
|
|
3059
|
+
*
|
|
3060
|
+
* `tree` rather than `commit` is the identity: two commits legitimately share a
|
|
3061
|
+
* tree after a rebase or a merge of an up-to-date branch, and that is exactly
|
|
3062
|
+
* the case where reuse is sound. A null on either side is a mismatch, not a
|
|
3063
|
+
* pass — an unknown tree is not the same tree.
|
|
3064
|
+
* @param {object} subject The envelope's subject block.
|
|
3065
|
+
* @param {object} observed Locally observed facts.
|
|
3066
|
+
* @returns {string|null} A problem, or null.
|
|
3067
|
+
*/
|
|
3068
|
+
function verifySubject(subject, observed) {
|
|
3069
|
+
if (!subject.repository || subject.repository !== observed.repository) {
|
|
3070
|
+
return `evidence is about ${JSON.stringify(subject.repository ?? null)}, not ${JSON.stringify(observed.repository ?? null)}`;
|
|
3071
|
+
}
|
|
3072
|
+
if (!subject.tree || !observed.tree || subject.tree !== observed.tree) {
|
|
3073
|
+
return `evidence is about tree ${JSON.stringify(subject.tree ?? null)}, not ${JSON.stringify(observed.tree ?? null)}`;
|
|
3074
|
+
}
|
|
3075
|
+
return null;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
/**
|
|
3079
|
+
* Compare two dotted versions numerically, ignoring any pre-release suffix.
|
|
3080
|
+
*
|
|
3081
|
+
* Deliberately small: the only question asked of it is "is the producer's
|
|
3082
|
+
* registry at least as new as mine", and a full semver implementation would be
|
|
3083
|
+
* a second answer to a question nothing else asks.
|
|
3084
|
+
* @param {string} left Version.
|
|
3085
|
+
* @param {string} right Version.
|
|
3086
|
+
* @returns {number} Negative, zero or positive.
|
|
3087
|
+
*/
|
|
3088
|
+
function compareVersions(left, right) {
|
|
3089
|
+
const parts = value =>
|
|
3090
|
+
String(value)
|
|
3091
|
+
.split("-")[0]
|
|
3092
|
+
.split(".")
|
|
3093
|
+
.map(part => Number.parseInt(part, 10) || 0);
|
|
3094
|
+
const a = parts(left);
|
|
3095
|
+
const b = parts(right);
|
|
3096
|
+
const width = Math.max(a.length, b.length);
|
|
3097
|
+
for (let index = 0; index < width; index += 1) {
|
|
3098
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
3099
|
+
if (difference !== 0) return difference;
|
|
3100
|
+
}
|
|
3101
|
+
return 0;
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* The contract that produced this evidence must be the one being planned.
|
|
3106
|
+
*
|
|
3107
|
+
* `workflow_ref` and `workflow_sha` are not redundant with the tree: consumers
|
|
3108
|
+
* call the reusable workflow at `@main`, so its contents can change with NO
|
|
3109
|
+
* change to the caller's tree, and tree identity alone would let an older,
|
|
3110
|
+
* weaker workflow's proof satisfy a stricter one. `registry_version` may be
|
|
3111
|
+
* NEWER than the local one — a stricter producer is acceptable, which is what
|
|
3112
|
+
* "same or stricter" means — but never older.
|
|
3113
|
+
* @param {object} contract The envelope's contract block.
|
|
3114
|
+
* @param {string} moment The moment being planned.
|
|
3115
|
+
* @param {object} observed Locally observed facts.
|
|
3116
|
+
* @returns {string|null} A problem, or null.
|
|
3117
|
+
*/
|
|
3118
|
+
function verifyContract(contract, moment, observed) {
|
|
3119
|
+
if (contract.moment !== moment) {
|
|
3120
|
+
return `evidence is for moment ${JSON.stringify(contract.moment ?? null)}, not ${JSON.stringify(moment)}`;
|
|
3121
|
+
}
|
|
3122
|
+
const pairs = [
|
|
3123
|
+
["gates_digest", observed.gatesDigest],
|
|
3124
|
+
["inputs_digest", observed.inputsDigest],
|
|
3125
|
+
["workflow_ref", observed.workflowRef],
|
|
3126
|
+
["workflow_sha", observed.workflowSha],
|
|
3127
|
+
];
|
|
3128
|
+
for (const [field, expected] of pairs) {
|
|
3129
|
+
if (!contract[field] || !expected || contract[field] !== expected) {
|
|
3130
|
+
return `${field} is ${JSON.stringify(contract[field] ?? null)}, not ${JSON.stringify(expected ?? null)}`;
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
if (!contract.registry_version || !observed.registryVersion) {
|
|
3134
|
+
return `registry_version is ${JSON.stringify(contract.registry_version ?? null)}, and an unknown producer version cannot be shown to be same-or-stricter`;
|
|
3135
|
+
}
|
|
3136
|
+
if (
|
|
3137
|
+
compareVersions(contract.registry_version, observed.registryVersion) < 0
|
|
3138
|
+
) {
|
|
3139
|
+
return `registry_version ${contract.registry_version} is older than this run's ${observed.registryVersion}, so it may declare less or declare it more weakly`;
|
|
3140
|
+
}
|
|
3141
|
+
return null;
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
/**
|
|
3145
|
+
* The evidence must be PRIMARY and attributable.
|
|
3146
|
+
*
|
|
3147
|
+
* `reused_gates` closes circular reuse: a run that reused evidence and then
|
|
3148
|
+
* emitted its own could otherwise be reused in turn, and the chain of proof
|
|
3149
|
+
* bottoms out on nothing. `caller_chain` closes it a second time from a
|
|
3150
|
+
* different direction — a release-path envelope is nested one level deeper than
|
|
3151
|
+
* a pull-request one — and a null chain is ineligible rather than assumed
|
|
3152
|
+
* shallow. `run_url` is what makes the ledger auditable: evidence nobody can go
|
|
3153
|
+
* and read is not evidence anyone checked.
|
|
3154
|
+
* @param {object} producer The envelope's producer block.
|
|
3155
|
+
* @returns {{detail: string, reason: string}|null} A refusal, or null.
|
|
3156
|
+
*/
|
|
3157
|
+
function verifyProducer(producer) {
|
|
3158
|
+
const reused = producer.reused_gates;
|
|
3159
|
+
if (!Array.isArray(reused)) {
|
|
3160
|
+
return {
|
|
3161
|
+
detail:
|
|
3162
|
+
"producer.reused_gates is absent or not an array, so this run cannot be shown to be primary proof",
|
|
3163
|
+
reason: REUSE_REASON.DERIVATIVE,
|
|
3164
|
+
};
|
|
3165
|
+
}
|
|
3166
|
+
if (reused.length) {
|
|
3167
|
+
return {
|
|
3168
|
+
detail: `producer reused ${reused.length} gate(s), so this is not primary proof`,
|
|
3169
|
+
reason: REUSE_REASON.DERIVATIVE,
|
|
3170
|
+
};
|
|
3171
|
+
}
|
|
3172
|
+
const chain = producer.caller_chain;
|
|
3173
|
+
if (!Array.isArray(chain) || chain.length !== PRIMARY_CALLER_CHAIN_DEPTH) {
|
|
3174
|
+
return {
|
|
3175
|
+
detail: `producer.caller_chain is ${JSON.stringify(chain ?? null)}; only a chain of depth ${PRIMARY_CALLER_CHAIN_DEPTH} is a primary pull-request path`,
|
|
3176
|
+
reason: REUSE_REASON.UNATTRIBUTABLE,
|
|
3177
|
+
};
|
|
3178
|
+
}
|
|
3179
|
+
if (!producer.run_id || !producer.run_url) {
|
|
3180
|
+
return {
|
|
3181
|
+
detail:
|
|
3182
|
+
"producer.run_id or producer.run_url is absent, so the proof cannot be read back",
|
|
3183
|
+
reason: REUSE_REASON.UNATTRIBUTABLE,
|
|
3184
|
+
};
|
|
3185
|
+
}
|
|
3186
|
+
return null;
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3189
|
+
/**
|
|
3190
|
+
* Whether the envelope as a WHOLE may be used as a source of proof.
|
|
3191
|
+
*
|
|
3192
|
+
* Every check here discards the entire document, because each one says the
|
|
3193
|
+
* evidence is about a different subject, a different contract, or is not
|
|
3194
|
+
* primary. A per-gate check cannot rescue any of them.
|
|
3195
|
+
* @param {object} options Inputs.
|
|
3196
|
+
* @param {unknown} options.envelope The parsed envelope, or null.
|
|
3197
|
+
* @param {string} options.moment The moment being planned.
|
|
3198
|
+
* @param {object} options.observed Locally observed facts.
|
|
3199
|
+
* @returns {{detail: string|null, ok: boolean, reason: string|null}} Verdict.
|
|
3200
|
+
*/
|
|
3201
|
+
function verifyEnvelope({ envelope, moment, observed }) {
|
|
3202
|
+
const refuse = (reason, detail) => ({ detail, ok: false, reason });
|
|
3203
|
+
if (!envelope || typeof envelope !== "object" || Array.isArray(envelope)) {
|
|
3204
|
+
return refuse(REUSE_REASON.MALFORMED, "the envelope is not a JSON object");
|
|
3205
|
+
}
|
|
3206
|
+
if (envelope.schema !== EVIDENCE_SCHEMA_TOKEN) {
|
|
3207
|
+
return refuse(
|
|
3208
|
+
REUSE_REASON.MALFORMED,
|
|
3209
|
+
`schema is ${JSON.stringify(envelope.schema ?? null)}, not ${JSON.stringify(EVIDENCE_SCHEMA_TOKEN)}`
|
|
3210
|
+
);
|
|
3211
|
+
}
|
|
3212
|
+
if (!Array.isArray(envelope.gates)) {
|
|
3213
|
+
return refuse(REUSE_REASON.MALFORMED, "gates is not an array");
|
|
3214
|
+
}
|
|
3215
|
+
if (envelope.verdict !== "proved") {
|
|
3216
|
+
return refuse(
|
|
3217
|
+
REUSE_REASON.NOT_PROVED,
|
|
3218
|
+
`verdict is ${JSON.stringify(envelope.verdict ?? null)}, not "proved"`
|
|
3219
|
+
);
|
|
3220
|
+
}
|
|
3221
|
+
const subjectProblem = verifySubject(envelope.subject ?? {}, observed);
|
|
3222
|
+
if (subjectProblem) {
|
|
3223
|
+
return refuse(REUSE_REASON.SUBJECT_MISMATCH, subjectProblem);
|
|
3224
|
+
}
|
|
3225
|
+
const contractProblem = verifyContract(
|
|
3226
|
+
envelope.contract ?? {},
|
|
3227
|
+
moment,
|
|
3228
|
+
observed
|
|
3229
|
+
);
|
|
3230
|
+
if (contractProblem) {
|
|
3231
|
+
return refuse(REUSE_REASON.CONTRACT_MISMATCH, contractProblem);
|
|
3232
|
+
}
|
|
3233
|
+
const producerProblem = verifyProducer(envelope.producer ?? {});
|
|
3234
|
+
if (producerProblem) {
|
|
3235
|
+
return refuse(producerProblem.reason, producerProblem.detail);
|
|
3236
|
+
}
|
|
3237
|
+
return { detail: null, ok: true, reason: null };
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
/**
|
|
3241
|
+
* The freshness bound to judge a row by: the tighter of the two, never the
|
|
3242
|
+
* looser.
|
|
3243
|
+
*
|
|
3244
|
+
* A producer that stamped its own bound and a policy that declares one are two
|
|
3245
|
+
* claims about the same question, and taking the maximum would let a generous
|
|
3246
|
+
* producer widen a window the project narrowed.
|
|
3247
|
+
* @param {object} row The evidence row.
|
|
3248
|
+
* @param {object} policy The gate's reuse policy.
|
|
3249
|
+
* @returns {number|null} The bound in minutes, or null for unbounded.
|
|
3250
|
+
*/
|
|
3251
|
+
function effectiveBound(row, policy) {
|
|
3252
|
+
const bounds = [row.max_age_minutes, policy.maxAgeMinutes].filter(
|
|
3253
|
+
value => typeof value === "number" && value > 0
|
|
3254
|
+
);
|
|
3255
|
+
return bounds.length ? Math.min(...bounds) : null;
|
|
3256
|
+
}
|
|
3257
|
+
|
|
3258
|
+
/**
|
|
3259
|
+
* Whether ONE gate's row proves what this moment requires of it.
|
|
3260
|
+
*
|
|
3261
|
+
* Row-level failures affect one gate and never leak into another's decision —
|
|
3262
|
+
* the fixtures assert exactly that, because a verifier whose failures
|
|
3263
|
+
* cross-contaminate is one nobody can reason about one dimension at a time.
|
|
3264
|
+
* @param {object} options Inputs.
|
|
3265
|
+
* @param {object} options.gate The resolved gate at this moment.
|
|
3266
|
+
* @param {number} options.nowMs Clock.
|
|
3267
|
+
* @param {object} options.observed Locally observed facts.
|
|
3268
|
+
* @param {object} options.policy Its reuse policy.
|
|
3269
|
+
* @param {object} [options.row] Its evidence row, if any.
|
|
3270
|
+
* @returns {{decision: string, detail: string|null, reason: string}} Verdict.
|
|
3271
|
+
*/
|
|
3272
|
+
function verifyRow({ gate, nowMs, observed, policy, row }) {
|
|
3273
|
+
const run = (reason, detail = null) => ({ decision: "run", detail, reason });
|
|
3274
|
+
if (policy.class === REUSE_CLASS.NEVER) {
|
|
3275
|
+
return policy.known
|
|
3276
|
+
? run(REUSE_REASON.NEVER_REUSABLE, "declared never reusable")
|
|
3277
|
+
: run(
|
|
3278
|
+
REUSE_REASON.UNCLASSIFIED,
|
|
3279
|
+
"no reuse class is declared for this gate, and the default is never"
|
|
3280
|
+
);
|
|
3281
|
+
}
|
|
3282
|
+
if (!row) {
|
|
3283
|
+
return run(REUSE_REASON.UNCOVERED, "the envelope carries no row for it");
|
|
3284
|
+
}
|
|
3285
|
+
const bound = effectiveBound(row, policy);
|
|
3286
|
+
const effective = readEvidence(
|
|
3287
|
+
{ ...row, max_age_minutes: bound },
|
|
3288
|
+
gate,
|
|
3289
|
+
nowMs
|
|
3290
|
+
);
|
|
3291
|
+
if (effective.status !== "pass") {
|
|
3292
|
+
const stale = (effective.reason ?? "").includes("past its");
|
|
3293
|
+
return run(
|
|
3294
|
+
stale ? REUSE_REASON.STALE : REUSE_REASON.NOT_PROVED,
|
|
3295
|
+
effective.reason
|
|
3296
|
+
);
|
|
3297
|
+
}
|
|
3298
|
+
if (policy.class === REUSE_CLASS.TIME_SENSITIVE) {
|
|
3299
|
+
if (!bound) {
|
|
3300
|
+
return run(
|
|
3301
|
+
REUSE_REASON.STALE,
|
|
3302
|
+
"time-sensitive with no declared window, so freshness was never bounded"
|
|
3303
|
+
);
|
|
3304
|
+
}
|
|
3305
|
+
if (!row.prover?.version) {
|
|
3306
|
+
return run(
|
|
3307
|
+
REUSE_REASON.UNATTRIBUTABLE,
|
|
3308
|
+
"time-sensitive and prover.version is absent, so the scanner that produced it is unknown"
|
|
3309
|
+
);
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
const rank = LEVEL_RANK[row.level];
|
|
3313
|
+
if (rank === undefined || rank < LEVEL_RANK[gate.level]) {
|
|
3314
|
+
return run(
|
|
3315
|
+
REUSE_REASON.LEVEL_DOWNGRADE,
|
|
3316
|
+
`proved at ${JSON.stringify(row.level ?? null)} but this moment requires ${gate.level}`
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3319
|
+
// A diff gate resolved a BASE revision, which the tree hash does not carry.
|
|
3320
|
+
// Two commits sharing a tree do not share a diff, so this sub-class binds to
|
|
3321
|
+
// the commit as well — a strictly stronger requirement than the rest.
|
|
3322
|
+
if (policy.diff && !observed.commitMatches) {
|
|
3323
|
+
return run(
|
|
3324
|
+
REUSE_REASON.SUBJECT_MISMATCH,
|
|
3325
|
+
"a diff gate is bound to the commit it diffed, and the commit differs"
|
|
3326
|
+
);
|
|
3327
|
+
}
|
|
3328
|
+
return { decision: "reuse", detail: null, reason: REUSE_REASON.REUSED };
|
|
3329
|
+
}
|
|
3330
|
+
|
|
3331
|
+
/**
|
|
3332
|
+
* Plan which gates at a moment may stand down on verified prior evidence.
|
|
3333
|
+
*
|
|
3334
|
+
* FAIL-CLOSED IS THE WHOLE CONTRACT. Every path through this function that is
|
|
3335
|
+
* not "the envelope passed every check AND this row passed every check" returns
|
|
3336
|
+
* `run`. There is no blanket switch, no `skip_jobs` equivalent, and no branch
|
|
3337
|
+
* where the ABSENCE of evidence produces anything but a full plan.
|
|
3338
|
+
* @param {object} options Inputs.
|
|
3339
|
+
* @param {unknown} options.envelope The parsed inbound envelope, or null.
|
|
3340
|
+
* @param {object|null} [options.gates] The project gates block.
|
|
3341
|
+
* @param {string} options.moment The moment being planned.
|
|
3342
|
+
* @param {number} [options.nowMs] Clock, injected for tests.
|
|
3343
|
+
* @param {object} options.observed Locally observed facts.
|
|
3344
|
+
* @param {{detail: string, reason: string}|null} [options.refusal] A refusal the caller already reached.
|
|
3345
|
+
* @param {string} [options.runner] The task-runner prefix.
|
|
3346
|
+
* @param {Record<string,string>|null} [options.scripts] Project scripts.
|
|
3347
|
+
* @returns {{decisions: object[], detail: string|null, verdict: string}} Plan.
|
|
3348
|
+
*/
|
|
3349
|
+
export function reusePlan({
|
|
3350
|
+
envelope,
|
|
3351
|
+
gates = null,
|
|
3352
|
+
moment,
|
|
3353
|
+
nowMs = Date.now(),
|
|
3354
|
+
observed,
|
|
3355
|
+
refusal = null,
|
|
3356
|
+
runner = undefined,
|
|
3357
|
+
scripts = null,
|
|
3358
|
+
}) {
|
|
3359
|
+
const resolveOptions = { gates, moment, scripts };
|
|
3360
|
+
const resolved = resolveMoment(
|
|
3361
|
+
runner === undefined ? resolveOptions : { ...resolveOptions, runner }
|
|
3362
|
+
);
|
|
3363
|
+
// A refusal the CALLER already reached outranks anything this function could
|
|
3364
|
+
// work out. "The file was not there" and "the file was there and wrong" are
|
|
3365
|
+
// different facts about the same absent proof, and only the caller that tried
|
|
3366
|
+
// to open it knows which — reporting the wrong one sends an operator looking
|
|
3367
|
+
// at the wrong thing, and the ledger compares these tokens.
|
|
3368
|
+
const envelopeVerdict = refusal
|
|
3369
|
+
? { detail: refusal.detail, ok: false, reason: refusal.reason }
|
|
3370
|
+
: verifyEnvelope({ envelope, moment, observed });
|
|
3371
|
+
const rows = new Map(
|
|
3372
|
+
envelopeVerdict.ok
|
|
3373
|
+
? envelope.gates
|
|
3374
|
+
.filter(row => row && typeof row.gate === "string")
|
|
3375
|
+
.map(row => [row.gate, row])
|
|
3376
|
+
: []
|
|
3377
|
+
);
|
|
3378
|
+
const commitMatches = Boolean(
|
|
3379
|
+
envelope?.subject?.commit &&
|
|
3380
|
+
observed.commit &&
|
|
3381
|
+
envelope.subject.commit === observed.commit
|
|
3382
|
+
);
|
|
3383
|
+
const decisions = resolved.map(gate => {
|
|
3384
|
+
const policy = reusePolicyFor(gate.id, gates);
|
|
3385
|
+
const outcome = envelopeVerdict.ok
|
|
3386
|
+
? verifyRow({
|
|
3387
|
+
gate,
|
|
3388
|
+
nowMs,
|
|
3389
|
+
observed: { ...observed, commitMatches },
|
|
3390
|
+
policy,
|
|
3391
|
+
row: rows.get(gate.id),
|
|
3392
|
+
})
|
|
3393
|
+
: {
|
|
3394
|
+
decision: "run",
|
|
3395
|
+
detail: envelopeVerdict.detail,
|
|
3396
|
+
reason: envelopeVerdict.reason,
|
|
3397
|
+
};
|
|
3398
|
+
return {
|
|
3399
|
+
class: policy.class,
|
|
3400
|
+
decision: outcome.decision,
|
|
3401
|
+
detail: outcome.detail,
|
|
3402
|
+
gate: gate.id,
|
|
3403
|
+
label: gate.label,
|
|
3404
|
+
level: gate.level,
|
|
3405
|
+
proof:
|
|
3406
|
+
outcome.decision === "reuse"
|
|
3407
|
+
? (envelope.producer?.run_url ?? null)
|
|
3408
|
+
: null,
|
|
3409
|
+
reason: outcome.reason,
|
|
3410
|
+
};
|
|
3411
|
+
});
|
|
3412
|
+
return {
|
|
3413
|
+
decisions,
|
|
3414
|
+
detail: envelopeVerdict.detail,
|
|
3415
|
+
verdict: envelopeVerdict.ok ? "verified" : envelopeVerdict.reason,
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
|
|
3419
|
+
/**
|
|
3420
|
+
* Read an envelope from disk for the verifier, refusing anything it cannot
|
|
3421
|
+
* safely read.
|
|
3422
|
+
*
|
|
3423
|
+
* A read failure and a parse failure both return null plus a reason, because
|
|
3424
|
+
* the caller does the same thing with both: run everything. They carry
|
|
3425
|
+
* DIFFERENT refusal tokens, because an absent record and a corrupt one are
|
|
3426
|
+
* different things to go and fix. The byte budget lives here rather than in the
|
|
3427
|
+
* caller so every entry point shares one limit.
|
|
3428
|
+
* @param {string} path File to read.
|
|
3429
|
+
* @param {object} [io] Injected fs, for tests.
|
|
3430
|
+
* @returns {{envelope: object|null, reason: string|null, refusal: string|null}} The parsed envelope.
|
|
3431
|
+
*/
|
|
3432
|
+
export function readEvidenceFile(path, io = {}) {
|
|
3433
|
+
const { readFile = readFileSync, statFile = statSync } = io;
|
|
3434
|
+
let raw = null;
|
|
3435
|
+
try {
|
|
3436
|
+
const { size } = statFile(path);
|
|
3437
|
+
if (size > EVIDENCE_BYTE_BUDGET) {
|
|
3438
|
+
return {
|
|
3439
|
+
envelope: null,
|
|
3440
|
+
reason: `evidence at ${path} is ${size} bytes, past the ${EVIDENCE_BYTE_BUDGET}-byte budget`,
|
|
3441
|
+
refusal: REUSE_REASON.UNAVAILABLE,
|
|
3442
|
+
};
|
|
3443
|
+
}
|
|
3444
|
+
raw = readFile(path, "utf8");
|
|
3445
|
+
} catch (err) {
|
|
3446
|
+
return {
|
|
3447
|
+
envelope: null,
|
|
3448
|
+
reason: `evidence at ${path} could not be opened: ${err.message}`,
|
|
3449
|
+
refusal: REUSE_REASON.UNAVAILABLE,
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
try {
|
|
3453
|
+
return { envelope: JSON.parse(raw), reason: null, refusal: null };
|
|
3454
|
+
} catch (err) {
|
|
3455
|
+
// Split from the open failure deliberately. Bytes that exist and do not
|
|
3456
|
+
// parse are a MALFORMED record; bytes that are not there are an ABSENT one.
|
|
3457
|
+
// Both run everything, and an operator needs to know which to go and fix.
|
|
3458
|
+
return {
|
|
3459
|
+
envelope: null,
|
|
3460
|
+
reason: `evidence at ${path} is not JSON: ${err.message}`,
|
|
3461
|
+
refusal: REUSE_REASON.MALFORMED,
|
|
3462
|
+
};
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
|
|
2760
3466
|
/** Description phrases that grant or deny credit for an awaited signal. */
|
|
2761
3467
|
export const EVIDENCE_DEFAULTS = Object.freeze({
|
|
2762
3468
|
// Matched strictly — whole description, case-insensitive — because a match
|
|
@@ -3752,6 +4458,7 @@ function validateGate(id, gate) {
|
|
|
3752
4458
|
);
|
|
3753
4459
|
}
|
|
3754
4460
|
problems.push(...validateNeeds(id, gate));
|
|
4461
|
+
problems.push(...validateReuse(id, gate.reuse));
|
|
3755
4462
|
|
|
3756
4463
|
for (const [moment, value] of Object.entries(gate)) {
|
|
3757
4464
|
// A key that is neither a known field nor a well-formed moment is a typo,
|
|
@@ -3791,6 +4498,57 @@ function validateGate(id, gate) {
|
|
|
3791
4498
|
return problems;
|
|
3792
4499
|
}
|
|
3793
4500
|
|
|
4501
|
+
/**
|
|
4502
|
+
* Validate a gate's reuse declaration (#3013).
|
|
4503
|
+
*
|
|
4504
|
+
* A half-written declaration here is the dangerous kind: a `time-sensitive`
|
|
4505
|
+
* class with no window would either be unbounded — the freshness requirement
|
|
4506
|
+
* resolving against nothing, which is the failure the gate façade exists to
|
|
4507
|
+
* refuse — or silently fall back to `never`, which is safe but leaves the
|
|
4508
|
+
* author believing they configured something. Refusing at declaration time is
|
|
4509
|
+
* the only reading that tells them.
|
|
4510
|
+
*
|
|
4511
|
+
* A window on a class that does not use one is refused for the mirror reason:
|
|
4512
|
+
* it reads as a bound that is being applied, and it is not.
|
|
4513
|
+
* @param {string} id Gate id.
|
|
4514
|
+
* @param {*} reuse The declared block, if any.
|
|
4515
|
+
* @returns {string[]} Problems.
|
|
4516
|
+
*/
|
|
4517
|
+
function validateReuse(id, reuse) {
|
|
4518
|
+
if (reuse === undefined) return [];
|
|
4519
|
+
const where = `gates."${id}"."reuse"`;
|
|
4520
|
+
if (!reuse || typeof reuse !== "object" || Array.isArray(reuse)) {
|
|
4521
|
+
return [`${where} must be an object`];
|
|
4522
|
+
}
|
|
4523
|
+
const problems = [];
|
|
4524
|
+
if (!REUSE_CLASSES.includes(reuse.class)) {
|
|
4525
|
+
problems.push(
|
|
4526
|
+
`${where}.class is ${JSON.stringify(reuse.class ?? null)}; ` +
|
|
4527
|
+
`expected one of ${REUSE_CLASSES.join(", ")}.`
|
|
4528
|
+
);
|
|
4529
|
+
}
|
|
4530
|
+
const window = reuse.max_age_minutes;
|
|
4531
|
+
if (reuse.class === REUSE_CLASS.TIME_SENSITIVE) {
|
|
4532
|
+
if (!Number.isFinite(window) || window <= 0) {
|
|
4533
|
+
problems.push(
|
|
4534
|
+
`${where}.max_age_minutes is ${JSON.stringify(window ?? null)}; ` +
|
|
4535
|
+
`a time-sensitive gate depends on external state that changes ` +
|
|
4536
|
+
`without the tree, so it must say how stale an answer it accepts. ` +
|
|
4537
|
+
`An unbounded window is not freshness, it is an unmeasured claim.`
|
|
4538
|
+
);
|
|
4539
|
+
}
|
|
4540
|
+
} else if (window !== undefined) {
|
|
4541
|
+
problems.push(
|
|
4542
|
+
`${where}.max_age_minutes is declared on a "${reuse.class}" gate, ` +
|
|
4543
|
+
`where nothing reads it. Either the class is wrong or the window is.`
|
|
4544
|
+
);
|
|
4545
|
+
}
|
|
4546
|
+
if (reuse.diff !== undefined && typeof reuse.diff !== "boolean") {
|
|
4547
|
+
problems.push(`${where}.diff must be true or false`);
|
|
4548
|
+
}
|
|
4549
|
+
return problems;
|
|
4550
|
+
}
|
|
4551
|
+
|
|
3794
4552
|
/**
|
|
3795
4553
|
* Validate one moment entry on a gate.
|
|
3796
4554
|
* @param {string} id Gate id.
|
|
@@ -5375,6 +6133,69 @@ function main() {
|
|
|
5375
6133
|
return;
|
|
5376
6134
|
}
|
|
5377
6135
|
|
|
6136
|
+
// ─── reuse-plan (#3013) ────────────────────────────────────────────────
|
|
6137
|
+
// Answers ONE question: which gates at this moment may stand down because a
|
|
6138
|
+
// VERIFIED envelope already proves them for this exact tree under this exact
|
|
6139
|
+
// contract. Every ambiguity — an unreadable file, a mismatched dimension, a
|
|
6140
|
+
// gate nobody classified — resolves to `run`, so the worst this command can
|
|
6141
|
+
// do is describe today's behaviour. It never exits non-zero on a refusal: an
|
|
6142
|
+
// optimization that can redden a release is not an optimization.
|
|
6143
|
+
if (command === "reuse-plan") {
|
|
6144
|
+
const moment = flag("moment");
|
|
6145
|
+
if (!moment) {
|
|
6146
|
+
throw new Error(
|
|
6147
|
+
"usage: lisa-gates.mjs reuse-plan --moment=<moment> --evidence=<file> --repository=<o/r> --tree=<sha> [--commit=<sha>] [--workflow-ref=<ref>] [--workflow-sha=<sha>] [--inputs-digest=<sha256:…>] [--registry-version=<v>] [--json]"
|
|
6148
|
+
);
|
|
6149
|
+
}
|
|
6150
|
+
const evidencePath = flag("evidence");
|
|
6151
|
+
const read = evidencePath
|
|
6152
|
+
? readEvidenceFile(evidencePath)
|
|
6153
|
+
: {
|
|
6154
|
+
envelope: null,
|
|
6155
|
+
reason: "no --evidence path was given, so nothing was verified",
|
|
6156
|
+
refusal: REUSE_REASON.UNAVAILABLE,
|
|
6157
|
+
};
|
|
6158
|
+
const plan = reusePlan({
|
|
6159
|
+
envelope: read.envelope,
|
|
6160
|
+
gates,
|
|
6161
|
+
moment,
|
|
6162
|
+
observed: {
|
|
6163
|
+
commit: flag("commit") ?? null,
|
|
6164
|
+
gatesDigest: planDigest({ gates, moment, runner, scripts }),
|
|
6165
|
+
inputsDigest: flag("inputs-digest") ?? null,
|
|
6166
|
+
registryVersion: flag("registry-version") ?? null,
|
|
6167
|
+
repository: flag("repository") ?? null,
|
|
6168
|
+
tree: flag("tree") ?? null,
|
|
6169
|
+
workflowRef: flag("workflow-ref") ?? null,
|
|
6170
|
+
workflowSha: flag("workflow-sha") ?? null,
|
|
6171
|
+
},
|
|
6172
|
+
refusal: read.refusal
|
|
6173
|
+
? { detail: read.reason, reason: read.refusal }
|
|
6174
|
+
: null,
|
|
6175
|
+
runner,
|
|
6176
|
+
scripts,
|
|
6177
|
+
});
|
|
6178
|
+
if (rest.includes("--json")) {
|
|
6179
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
6180
|
+
return;
|
|
6181
|
+
}
|
|
6182
|
+
console.log(`reuse verdict: ${plan.verdict}`);
|
|
6183
|
+
if (plan.detail) console.log(` ${plan.detail}`);
|
|
6184
|
+
for (const entry of plan.decisions) {
|
|
6185
|
+
const proof = entry.proof ? ` ← ${entry.proof}` : "";
|
|
6186
|
+
console.log(
|
|
6187
|
+
` ${entry.decision === "reuse" ? "♻️ " : "▶️ "} ${entry.gate.padEnd(28)} ${entry.reason}${proof}`
|
|
6188
|
+
);
|
|
6189
|
+
}
|
|
6190
|
+
const reused = plan.decisions.filter(
|
|
6191
|
+
entry => entry.decision === "reuse"
|
|
6192
|
+
).length;
|
|
6193
|
+
console.log(
|
|
6194
|
+
`\n${reused} of ${plan.decisions.length} gate(s) at ${moment} are proved by prior evidence.`
|
|
6195
|
+
);
|
|
6196
|
+
return;
|
|
6197
|
+
}
|
|
6198
|
+
|
|
5378
6199
|
if (command === "audit-config") {
|
|
5379
6200
|
const findings = auditConfigKeys(config);
|
|
5380
6201
|
for (const finding of findings) console.error(` ${finding.message}`);
|
|
@@ -5383,7 +6204,7 @@ function main() {
|
|
|
5383
6204
|
}
|
|
5384
6205
|
|
|
5385
6206
|
throw new Error(
|
|
5386
|
-
"usage: lisa-gates.mjs validate|list|legs|quality-plan|needs|contexts|verify-contexts|skip-jobs|audit-config|inventory|unconfigured|seed"
|
|
6207
|
+
"usage: lisa-gates.mjs validate|list|legs|quality-plan|reuse-plan|needs|contexts|verify-contexts|skip-jobs|audit-config|inventory|unconfigured|seed"
|
|
5387
6208
|
);
|
|
5388
6209
|
}
|
|
5389
6210
|
|