@codyswann/lisa 4.12.0 → 4.13.1
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-hooks/parity-safety-net.sh +7 -1
- package/dist/cli/doctor-two-channel-drift.d.ts +20 -0
- package/dist/cli/doctor-two-channel-drift.d.ts.map +1 -0
- package/dist/cli/doctor-two-channel-drift.js +190 -0
- package/dist/cli/doctor-two-channel-drift.js.map +1 -0
- package/dist/cli/doctor-wiki.d.ts +8 -0
- package/dist/cli/doctor-wiki.d.ts.map +1 -0
- package/dist/cli/doctor-wiki.js +42 -0
- package/dist/cli/doctor-wiki.js.map +1 -0
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +7 -30
- package/dist/cli/doctor.js.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +3 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/two-channel-delivery-scan.d.ts +81 -0
- package/dist/core/two-channel-delivery-scan.d.ts.map +1 -0
- package/dist/core/two-channel-delivery-scan.js +191 -0
- package/dist/core/two-channel-delivery-scan.js.map +1 -0
- package/dist/core/two-channel-delivery.d.ts +215 -0
- package/dist/core/two-channel-delivery.d.ts.map +1 -0
- package/dist/core/two-channel-delivery.js +259 -0
- package/dist/core/two-channel-delivery.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +18 -5
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/dist/opencode/plugin-templates/parity-safety-net.sh +7 -1
- package/package.json +4 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/hooks/parity-safety-net.sh +7 -1
- package/plugins/lisa/rules/eager/falsifiable-checks.md +1 -1
- package/plugins/lisa/rules/reference/falsifiable-checks.md +1 -1
- package/plugins/lisa-agy/hooks/parity-safety-net.sh +7 -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/parity-safety-net.sh +7 -1
- package/plugins/lisa-copilot/rules/eager/falsifiable-checks.md +1 -1
- package/plugins/lisa-copilot/rules/reference/falsifiable-checks.md +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/parity-safety-net.sh +7 -1
- package/plugins/lisa-cursor/rules/falsifiable-checks-reference.mdc +1 -1
- package/plugins/lisa-cursor/rules/falsifiable-checks.mdc +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/src/base/hooks/parity-safety-net.sh +7 -1
- package/plugins/src/base/rules/eager/falsifiable-checks.md +1 -1
- package/plugins/src/base/rules/reference/falsifiable-checks.md +1 -1
- package/scripts/generate-two-channel-couplings.ts +427 -0
- package/scripts/two-channel-couplings.json +214 -0
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +19 -20
|
@@ -752,7 +752,13 @@ while IFS= read -r rm_stmt; do
|
|
|
752
752
|
# arm A). Unquoted and run-leading invocations get the whole statement back,
|
|
753
753
|
# so this is a no-op for every real delete.
|
|
754
754
|
rm_assignment_stmt="$rm_stmt"
|
|
755
|
-
|
|
755
|
+
rm_walk_status=0
|
|
756
|
+
rm_walk_text="$(rm_scan_scope "$rm_stmt")" || rm_walk_status=$?
|
|
757
|
+
if [ "$rm_walk_status" -ne 0 ]; then
|
|
758
|
+
# The substitution above runs in a subshell. A denial inside it cannot stop
|
|
759
|
+
# this parent hook, so carry the failure out explicitly and deny here.
|
|
760
|
+
scan_failed "$rm_walk_status"
|
|
761
|
+
fi
|
|
756
762
|
while IFS= read -r rm_scope; do
|
|
757
763
|
set -f
|
|
758
764
|
seen_rm=0
|
|
@@ -34,7 +34,7 @@ Each has been observed in real runs; each reported success while asserting nothi
|
|
|
34
34
|
This form is preferred over the alternatives because it is the only one that both survives `sh` and keeps the full output, which is what you need the moment the status says the gate failed. `set -o pipefail` is correct where it exists but is **not POSIX `sh`** — dash has no such option, and this repository runs hooks under `sh`. `${PIPESTATUS[0]}` is **bash-only** (zsh spells it `$pipestatus[1]`, 1-indexed) and is overwritten by the very next command, so it must be read immediately. Never report a gate as green from a status read through `| tail`, `| head`, or `| tee`.
|
|
35
35
|
- **Falsify before reporting.** No clean result is reportable until the check has been shown to fail on a deliberate break. **"Mentally reverting" does not count** — reasoning that the assertion *would* fail is precisely the step that lets a non-functional guard ship, because the author already believes it is load-bearing. Run the break.
|
|
36
36
|
- **Mutation-prove the guard, then read the cardinality.** Neuter the protection **in production code**, run the **whole suite**, count the failures and **read their names**, then revert. **Zero** ⇒ the guard is inert (one pinning a single field shipped with all 50 tests green while the regression walked through). **Many, unrelated** ⇒ over-broad; its next failure will not name the cause. **Exactly one, or several all named for the same regression** ⇒ load-bearing and correctly scoped. A **zero is robust to contention** — load only ever adds failures — so only the non-zero counts need a quiet machine.
|
|
37
|
-
- **The automated mutation gate is language-scoped; the manual probe above is not.** Stryker instruments the
|
|
37
|
+
- **The automated mutation gate is language-scoped; the manual probe above is not.** Stryker instruments the JavaScript and TypeScript family plus Vue, HTML and Svelte — there is **no shell parser**, so a guard whose logic lives in `.sh` yields no mutants under **any** `mutate` list. Adding one does not measure it: it aborts the whole run (`No parser registered for .sh!` — measured, not inferred) and takes every other guard's score with it. So a mutation gate that stays grey or green on a shell-guard change **measured nothing about that guard**, and reading it as coverage is the fifth failure mode above wearing a config file. Shell guards are covered by **driving tests only**: execute the script against a payload table and assert the blocked/allowed verdict, with a control on **both** sides — a refusal case *and* an allowed case. A shell guard nothing executes has no bite evidence at all, whatever the gate printed.
|
|
38
38
|
- **Never scope the probe by filename, and never infer absent coverage from a grep.** Suites split at the line cap, so a protection's tests often sit in a sibling file: a filename-scoped probe reported cardinality 0 on a live security fix that a whole-suite run showed failing 4 tests, every one named for it.
|
|
39
39
|
- **Assert that no copy is wrong, not that some copy is right.** An existential assertion over duplicated content is satisfied by the correct copy and cannot see the broken one — rewriting one suite's assertions to universal negatives took the same mutation from **0 to 18** failures. Enumerate the property (*every* marker uses an em-dash), never the one known-bad spelling.
|
|
40
40
|
- **Reconstruct a red state; never narrate one you did not run.** Evidence describing a failing or dirty state must come from a run you actually observed — an evidence file once claimed "working tree clean" while listing two untracked scripts in the same file. To observe a state that no longer exists, check that commit out in a **throwaway detached-HEAD worktree** and run it there. Writing down what the output would have said is fabrication, however confident the reasoning.
|
|
@@ -170,7 +170,7 @@ Practical consequence: an inert-guard finding is safe to report from a busy mach
|
|
|
170
170
|
|
|
171
171
|
Everything above is a *manual* probe, and it is language-agnostic: neuter a shell guard, run the suite, read the names, and the yardstick works exactly as it does for TypeScript.
|
|
172
172
|
|
|
173
|
-
The **automated** mutation gate is not language-agnostic, and the difference is easy to miss because both end in a green result. Stryker's instrumenter is per-language — the
|
|
173
|
+
The **automated** mutation gate is not language-agnostic, and the difference is easy to miss because both end in a green result. Stryker's instrumenter is per-language — the JavaScript and TypeScript family, plus Vue, HTML and Svelte, which delegate to it. There is **no shell parser**. So:
|
|
174
174
|
|
|
175
175
|
- A guard whose logic lives in `.sh` produces **no mutants under any `mutate` list**. It is outside the gate by construction, not by a selection decision, and widening the patterns cannot bring it in.
|
|
176
176
|
- Adding a `.sh` path to `mutate` does not measure it and does not quietly no-op. Stryker throws out of instrumentation — `Unable to parse …. No parser registered for .sh!` — and the run aborts before a single mutant is tried, so **one such entry destroys the score of every other guard in the list.** Measured, not inferred.
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic gate and ledger for Lisa's two-channel delivery gap (#3050).
|
|
3
|
+
*
|
|
4
|
+
* Lisa reaches a consumer down two channels at two speeds. Reusable workflow
|
|
5
|
+
* bodies travel at `@main` and are live on the consumer's next run, needing no
|
|
6
|
+
* action from them. Scripts and declarations travel by `lisa apply` and are
|
|
7
|
+
* live only where somebody applied. A change that REMOVES a built-in from a
|
|
8
|
+
* workflow and replaces it with something the consumer's tree must provide
|
|
9
|
+
* therefore lands in the wrong order, and nothing measured the gap: the
|
|
10
|
+
* replacement never runs, so it posts no context, and an absent required
|
|
11
|
+
* context is not a red one.
|
|
12
|
+
*
|
|
13
|
+
* This script measures it, for Lisa's own tree, before such a change ships.
|
|
14
|
+
* The reasoning about verdicts lives in `src/core/two-channel-delivery.ts`;
|
|
15
|
+
* the reasoning about how a workflow spells a path lives in
|
|
16
|
+
* `src/core/two-channel-delivery-scan.ts`. This file is the I/O around them.
|
|
17
|
+
*
|
|
18
|
+
* ## The ledger is the deliverable, not the exit code
|
|
19
|
+
*
|
|
20
|
+
* Fourteen of Lisa's fifteen host-only couplings are `apply-lagged`, and every
|
|
21
|
+
* one is deliberate. Failing on them would mean ratifying fourteen entries on
|
|
22
|
+
* day one, which turns an allowlist into a bypass. So they are RECORDED in
|
|
23
|
+
* `scripts/two-channel-couplings.json`, regenerated in-commit like Lisa's
|
|
24
|
+
* other derived artifacts, and a change that adds one shows up as a diff a
|
|
25
|
+
* reviewer must look at. That is the check the issue asked to be reachable
|
|
26
|
+
* before a change of this shape ships.
|
|
27
|
+
*
|
|
28
|
+
* The ledger also ships in the package — `scripts/` is in the npm `files`
|
|
29
|
+
* allowlist — which is what lets `lisa doctor` tell a consumer, on their own
|
|
30
|
+
* machine, which of the couplings their tree does not satisfy. The workflow
|
|
31
|
+
* bodies themselves are NOT in that allowlist, so a consumer cannot derive
|
|
32
|
+
* this locally; the ledger is the only way the fast half's requirements reach
|
|
33
|
+
* them at all.
|
|
34
|
+
*
|
|
35
|
+
* ## What fails
|
|
36
|
+
*
|
|
37
|
+
* - a `never-delivered` or `undelivered` coupling nobody ratified — the
|
|
38
|
+
* shape no apply and no bump ever closes;
|
|
39
|
+
* - a ratification matching no live coupling, because a permission left
|
|
40
|
+
* behind after its subject is gone is inherited for free by the next path
|
|
41
|
+
* that happens to match;
|
|
42
|
+
* - `--check` against a stale ledger;
|
|
43
|
+
* - and any run that measured nothing.
|
|
44
|
+
*
|
|
45
|
+
* Findings fail in BOTH modes. Regenerating the ledger records a coupling; it
|
|
46
|
+
* does not ratify one, and a gate you can regenerate your way past is not a
|
|
47
|
+
* gate.
|
|
48
|
+
*
|
|
49
|
+
* ## Refusing to pass on nothing
|
|
50
|
+
*
|
|
51
|
+
* Zero reusable workflows, zero steps, zero couplings, or an empty delivery
|
|
52
|
+
* inventory is exit 2 and never a clean pass. Every run prints what it
|
|
53
|
+
* inspected, so an empty sweep and a converged tree cannot produce the same
|
|
54
|
+
* output.
|
|
55
|
+
*
|
|
56
|
+
* CLI:
|
|
57
|
+
* bun scripts/generate-two-channel-couplings.ts [--root <dir>] [--check] [--json]
|
|
58
|
+
*
|
|
59
|
+
* Exit codes:
|
|
60
|
+
* 0 — every unrestorable coupling is ratified, and the ledger is current.
|
|
61
|
+
* 1 — an unratified finding, a stale ratification, or a stale ledger.
|
|
62
|
+
* 2 — operational: unknown flag, a flag missing its value, `--root` absent,
|
|
63
|
+
* or a run that measured nothing.
|
|
64
|
+
* @module scripts/generate-two-channel-couplings
|
|
65
|
+
*/
|
|
66
|
+
import {
|
|
67
|
+
existsSync,
|
|
68
|
+
mkdirSync,
|
|
69
|
+
readdirSync,
|
|
70
|
+
readFileSync,
|
|
71
|
+
writeFileSync,
|
|
72
|
+
} from "node:fs";
|
|
73
|
+
import * as path from "node:path";
|
|
74
|
+
import process from "node:process";
|
|
75
|
+
|
|
76
|
+
import {
|
|
77
|
+
classifyTwoChannelDelivery,
|
|
78
|
+
type CouplingInput,
|
|
79
|
+
type TwoChannelReport,
|
|
80
|
+
} from "../src/core/two-channel-delivery.js";
|
|
81
|
+
import {
|
|
82
|
+
isReusable,
|
|
83
|
+
scanWorkflow,
|
|
84
|
+
} from "../src/core/two-channel-delivery-scan.js";
|
|
85
|
+
|
|
86
|
+
/** Where the fast channel's bodies live in this repository. */
|
|
87
|
+
const WORKFLOWS_DIR = path.join(".github", "workflows");
|
|
88
|
+
|
|
89
|
+
/** Where the ledger is written, inside the npm `files` allowlist. */
|
|
90
|
+
const LEDGER_PATH = path.join("scripts", "two-channel-couplings.json");
|
|
91
|
+
|
|
92
|
+
/** Every delivery lane a stack directory may carry. */
|
|
93
|
+
const LANES = [
|
|
94
|
+
"copy-overwrite",
|
|
95
|
+
"copy-contents",
|
|
96
|
+
"merge",
|
|
97
|
+
"tagged-merge",
|
|
98
|
+
"package-lisa",
|
|
99
|
+
"create-only",
|
|
100
|
+
] as const;
|
|
101
|
+
|
|
102
|
+
/** Directories a consumer keeps its own executable artifacts in. */
|
|
103
|
+
const CALLER_DIRECTORIES = ["scripts", "bin", "tools"] as const;
|
|
104
|
+
|
|
105
|
+
/** Exit code for an operational failure — including "I could not look". */
|
|
106
|
+
const EXIT_OPERATIONAL = 2;
|
|
107
|
+
|
|
108
|
+
/** Parsed command line. */
|
|
109
|
+
interface Options {
|
|
110
|
+
readonly root: string;
|
|
111
|
+
readonly check: boolean;
|
|
112
|
+
readonly json: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Print usage guidance and exit.
|
|
117
|
+
* @param message - Why the invocation was rejected
|
|
118
|
+
* @returns Never; the process exits
|
|
119
|
+
*/
|
|
120
|
+
function usage(message: string): never {
|
|
121
|
+
process.stderr.write(
|
|
122
|
+
`${message}\n\nUsage: bun scripts/generate-two-channel-couplings.ts [--root <dir>] [--check] [--json]\n`
|
|
123
|
+
);
|
|
124
|
+
process.exit(EXIT_OPERATIONAL);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Parse argv into options.
|
|
129
|
+
* @param argv - Arguments after the script name
|
|
130
|
+
* @returns The parsed options
|
|
131
|
+
*/
|
|
132
|
+
function parseArgs(argv: readonly string[]): Options {
|
|
133
|
+
return argv.reduce<Options>(
|
|
134
|
+
(options, flag, index) => {
|
|
135
|
+
if (flag === "--check") return { ...options, check: true };
|
|
136
|
+
if (flag === "--json") return { ...options, json: true };
|
|
137
|
+
if (flag === "--root") {
|
|
138
|
+
const value = argv[index + 1];
|
|
139
|
+
if (value === undefined) usage("--root needs a value");
|
|
140
|
+
return { ...options, root: path.resolve(value) };
|
|
141
|
+
}
|
|
142
|
+
if (argv[index - 1] === "--root") return options;
|
|
143
|
+
return usage(`Unknown flag: ${flag}`);
|
|
144
|
+
},
|
|
145
|
+
{ root: process.cwd(), check: false, json: false }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Every file under one directory, as paths relative to it.
|
|
151
|
+
* @param directory - Absolute directory path
|
|
152
|
+
* @returns POSIX-style relative paths, empty when the directory is absent
|
|
153
|
+
*/
|
|
154
|
+
function filesUnder(directory: string): readonly string[] {
|
|
155
|
+
if (!existsSync(directory)) return [];
|
|
156
|
+
return readdirSync(directory, { withFileTypes: true, recursive: true })
|
|
157
|
+
.filter(entry => entry.isFile())
|
|
158
|
+
.map(entry =>
|
|
159
|
+
path
|
|
160
|
+
.relative(directory, path.join(entry.parentPath, entry.name))
|
|
161
|
+
.split(path.sep)
|
|
162
|
+
.join("/")
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One delivered caller-tree path and the lane delivering it. */
|
|
167
|
+
interface DeliveredPath {
|
|
168
|
+
readonly callerPath: string;
|
|
169
|
+
readonly lane: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every caller-tree path one `<stack>/<lane>` pair delivers.
|
|
174
|
+
* @param root - Repository root
|
|
175
|
+
* @param stack - Stack directory name
|
|
176
|
+
* @param lane - Delivery lane name
|
|
177
|
+
* @returns One entry per delivered file
|
|
178
|
+
*/
|
|
179
|
+
function deliveredBy(
|
|
180
|
+
root: string,
|
|
181
|
+
stack: string,
|
|
182
|
+
lane: string
|
|
183
|
+
): readonly DeliveredPath[] {
|
|
184
|
+
return CALLER_DIRECTORIES.flatMap(directory =>
|
|
185
|
+
filesUnder(path.join(root, stack, lane, directory)).map(relative => ({
|
|
186
|
+
callerPath: `${directory}/${relative}`,
|
|
187
|
+
lane: `${stack}/${lane}`,
|
|
188
|
+
}))
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Index every caller-tree path Lisa delivers, and the lanes delivering it.
|
|
194
|
+
*
|
|
195
|
+
* Only the caller directories are indexed. The question this script asks is
|
|
196
|
+
* "does a workflow's caller-tree read arrive", and a path outside `scripts/`,
|
|
197
|
+
* `bin/`, or `tools/` is never one of those reads — indexing the whole
|
|
198
|
+
* template estate would make the inventory count large and meaningless.
|
|
199
|
+
*
|
|
200
|
+
* Stacks are sorted, and each path's lanes are sorted again below.
|
|
201
|
+
* `readdirSync` order is the filesystem's, and it differs between the two
|
|
202
|
+
* runtimes that invoke this file — the ledger rendered under bun and under
|
|
203
|
+
* vitest disagreed about the order of `check-threshold-ratchet.mjs`'s two
|
|
204
|
+
* lanes, and a derived artifact whose bytes depend on who generated it cannot
|
|
205
|
+
* be checked for staleness.
|
|
206
|
+
* @param root - Repository root
|
|
207
|
+
* @returns Caller path to the `<stack>/<lane>` lanes shipping it
|
|
208
|
+
*/
|
|
209
|
+
export function buildDeliveryInventory(
|
|
210
|
+
root: string
|
|
211
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
212
|
+
const stacks = readdirSync(root, { withFileTypes: true })
|
|
213
|
+
.filter(entry => entry.isDirectory() && !entry.name.startsWith("."))
|
|
214
|
+
.map(entry => entry.name)
|
|
215
|
+
.sort((left, right) => left.localeCompare(right));
|
|
216
|
+
const delivered = stacks.flatMap(stack =>
|
|
217
|
+
LANES.flatMap(lane => deliveredBy(root, stack, lane))
|
|
218
|
+
);
|
|
219
|
+
return delivered.reduce(
|
|
220
|
+
(inventory, entry) =>
|
|
221
|
+
new Map(inventory).set(
|
|
222
|
+
entry.callerPath,
|
|
223
|
+
[...(inventory.get(entry.callerPath) ?? []), entry.lane].sort(
|
|
224
|
+
(left, right) => left.localeCompare(right)
|
|
225
|
+
)
|
|
226
|
+
),
|
|
227
|
+
new Map<string, readonly string[]>()
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The ledger's hand-authored half plus its derived half. */
|
|
232
|
+
interface Ledger {
|
|
233
|
+
readonly ratified: Readonly<Record<string, string>>;
|
|
234
|
+
readonly inspected: TwoChannelReport["inspected"];
|
|
235
|
+
readonly counts: TwoChannelReport["counts"];
|
|
236
|
+
readonly couplings: readonly {
|
|
237
|
+
readonly key: string;
|
|
238
|
+
readonly workflow: string;
|
|
239
|
+
readonly path: string;
|
|
240
|
+
readonly channel: string;
|
|
241
|
+
readonly verdict: string;
|
|
242
|
+
readonly remedy: string;
|
|
243
|
+
readonly guarded: boolean;
|
|
244
|
+
readonly lanes: readonly string[];
|
|
245
|
+
readonly detail: string;
|
|
246
|
+
}[];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Read the ratifications already recorded, so regenerating never drops them.
|
|
251
|
+
* @param ledgerPath - Absolute path to the ledger
|
|
252
|
+
* @returns The ratification reasons, keyed `<workflow>::<path>`
|
|
253
|
+
*/
|
|
254
|
+
function readRatified(ledgerPath: string): Readonly<Record<string, string>> {
|
|
255
|
+
if (!existsSync(ledgerPath)) return {};
|
|
256
|
+
const parsed = JSON.parse(readFileSync(ledgerPath, "utf8")) as
|
|
257
|
+
| Partial<Ledger>
|
|
258
|
+
| undefined;
|
|
259
|
+
return parsed?.ratified ?? {};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render the ledger a report implies.
|
|
264
|
+
*
|
|
265
|
+
* `package-backed` couplings are omitted: they are covered by the package
|
|
266
|
+
* channel, they outnumber the interesting ones several times over, and a
|
|
267
|
+
* consumer reading this ledger can do nothing about them. Their COUNT is kept,
|
|
268
|
+
* so the ledger still says how many were inspected.
|
|
269
|
+
* @param report - The measurement
|
|
270
|
+
* @param ratified - The ratifications to carry forward
|
|
271
|
+
* @returns The ledger, ready to serialise
|
|
272
|
+
*/
|
|
273
|
+
function toLedger(
|
|
274
|
+
report: TwoChannelReport,
|
|
275
|
+
ratified: Readonly<Record<string, string>>
|
|
276
|
+
): Ledger {
|
|
277
|
+
return {
|
|
278
|
+
ratified,
|
|
279
|
+
inspected: report.inspected,
|
|
280
|
+
counts: report.counts,
|
|
281
|
+
couplings: report.entries
|
|
282
|
+
.filter(entry => entry.verdict !== "package-backed")
|
|
283
|
+
.map(entry => ({
|
|
284
|
+
key: entry.key,
|
|
285
|
+
workflow: entry.workflow,
|
|
286
|
+
path: entry.path,
|
|
287
|
+
channel: entry.channel,
|
|
288
|
+
verdict: entry.verdict,
|
|
289
|
+
remedy: entry.remedy,
|
|
290
|
+
guarded: entry.guarded,
|
|
291
|
+
lanes: entry.lanes,
|
|
292
|
+
detail: entry.detail,
|
|
293
|
+
})),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Measure this repository's fast channel against its delivery lanes.
|
|
299
|
+
* @param root - Repository root
|
|
300
|
+
* @returns The measurement, plus the ratifications it was judged against
|
|
301
|
+
*/
|
|
302
|
+
export function measure(root: string): {
|
|
303
|
+
readonly report: TwoChannelReport;
|
|
304
|
+
readonly ratified: Readonly<Record<string, string>>;
|
|
305
|
+
} {
|
|
306
|
+
const workflowsDir = path.join(root, WORKFLOWS_DIR);
|
|
307
|
+
const inventory = buildDeliveryInventory(root);
|
|
308
|
+
const lanesFor = (callerPath: string): readonly string[] =>
|
|
309
|
+
inventory.get(callerPath) ?? [];
|
|
310
|
+
const names = existsSync(workflowsDir)
|
|
311
|
+
? readdirSync(workflowsDir)
|
|
312
|
+
.filter(name => name.endsWith(".yml") || name.endsWith(".yaml"))
|
|
313
|
+
.sort((left, right) => left.localeCompare(right))
|
|
314
|
+
: [];
|
|
315
|
+
const reusables = names
|
|
316
|
+
.map(name => ({
|
|
317
|
+
name,
|
|
318
|
+
text: readFileSync(path.join(workflowsDir, name), "utf8"),
|
|
319
|
+
}))
|
|
320
|
+
.filter(entry => isReusable(entry.text));
|
|
321
|
+
const scanned = reusables.map(entry => ({
|
|
322
|
+
steps: entry.text.split("\n").filter(line => /^\s*-\s+name:/.test(line))
|
|
323
|
+
.length,
|
|
324
|
+
couplings: scanWorkflow({
|
|
325
|
+
workflow: entry.name,
|
|
326
|
+
text: entry.text,
|
|
327
|
+
lanesFor,
|
|
328
|
+
}),
|
|
329
|
+
}));
|
|
330
|
+
const couplings: readonly CouplingInput[] = scanned.flatMap(
|
|
331
|
+
entry => entry.couplings
|
|
332
|
+
);
|
|
333
|
+
return {
|
|
334
|
+
report: classifyTwoChannelDelivery({
|
|
335
|
+
couplings,
|
|
336
|
+
inspected: {
|
|
337
|
+
workflows: reusables.length,
|
|
338
|
+
steps: scanned.reduce((total, entry) => total + entry.steps, 0),
|
|
339
|
+
couplings: couplings.length,
|
|
340
|
+
inventory: inventory.size,
|
|
341
|
+
},
|
|
342
|
+
ratified: readRatified(path.join(root, LEDGER_PATH)),
|
|
343
|
+
}),
|
|
344
|
+
ratified: readRatified(path.join(root, LEDGER_PATH)),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* One human-readable paragraph per finding, plus the inspection counts.
|
|
350
|
+
* @param report - The measurement
|
|
351
|
+
* @returns The report text
|
|
352
|
+
*/
|
|
353
|
+
function humanReport(report: TwoChannelReport): string {
|
|
354
|
+
const counts = report.inspected;
|
|
355
|
+
const header =
|
|
356
|
+
`two-channel delivery: inspected ${counts.workflows} reusable workflow(s), ` +
|
|
357
|
+
`${counts.steps} step(s), ${counts.couplings} caller-tree read(s) against ` +
|
|
358
|
+
`${counts.inventory} delivered path(s).`;
|
|
359
|
+
const verdicts = Object.entries(report.counts)
|
|
360
|
+
.map(([verdict, total]) => ` ${verdict}: ${total}`)
|
|
361
|
+
.join("\n");
|
|
362
|
+
const findings = report.findings
|
|
363
|
+
.map(entry => `\nFINDING ${entry.key}\n ${entry.detail}`)
|
|
364
|
+
.join("");
|
|
365
|
+
const stale = report.staleRatifications
|
|
366
|
+
.map(
|
|
367
|
+
key =>
|
|
368
|
+
`\nSTALE RATIFICATION ${key}\n Nothing reads this path any more, so the ` +
|
|
369
|
+
`ratification is an unexamined permission the next matching path would ` +
|
|
370
|
+
`inherit. Delete it from ${LEDGER_PATH}.`
|
|
371
|
+
)
|
|
372
|
+
.join("");
|
|
373
|
+
return `${header}\n${verdicts}${findings}${stale}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Run the gate.
|
|
378
|
+
* @param argv - Arguments after the script name
|
|
379
|
+
* @returns The process exit code
|
|
380
|
+
*/
|
|
381
|
+
export function main(argv: readonly string[]): number {
|
|
382
|
+
const options = parseArgs(argv);
|
|
383
|
+
if (!existsSync(options.root)) {
|
|
384
|
+
process.stderr.write(`error: --root does not exist: ${options.root}\n`);
|
|
385
|
+
return EXIT_OPERATIONAL;
|
|
386
|
+
}
|
|
387
|
+
const { report, ratified } = measure(options.root);
|
|
388
|
+
process.stdout.write(
|
|
389
|
+
`${options.json ? JSON.stringify(report, null, 2) : humanReport(report)}\n`
|
|
390
|
+
);
|
|
391
|
+
if (!report.measured) {
|
|
392
|
+
process.stderr.write(
|
|
393
|
+
`error: the measurement did not happen — ${String(report.unmeasuredReason)}. ` +
|
|
394
|
+
"Reporting a clean sweep here would be the exact failure this gate exists " +
|
|
395
|
+
"to detect, so it fails instead.\n"
|
|
396
|
+
);
|
|
397
|
+
return EXIT_OPERATIONAL;
|
|
398
|
+
}
|
|
399
|
+
const ledgerPath = path.join(options.root, LEDGER_PATH);
|
|
400
|
+
const rendered = `${JSON.stringify(toLedger(report, ratified), null, 2)}\n`;
|
|
401
|
+
const current = existsSync(ledgerPath)
|
|
402
|
+
? readFileSync(ledgerPath, "utf8")
|
|
403
|
+
: "";
|
|
404
|
+
const ledgerStale = options.check && rendered !== current;
|
|
405
|
+
if (options.check) {
|
|
406
|
+
if (ledgerStale) {
|
|
407
|
+
process.stderr.write(
|
|
408
|
+
`error: ${LEDGER_PATH} is stale. Regenerate it with ` +
|
|
409
|
+
"`bun run generate:two-channel-couplings` and commit the result.\n"
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
414
|
+
writeFileSync(ledgerPath, rendered);
|
|
415
|
+
}
|
|
416
|
+
const failed =
|
|
417
|
+
report.findings.length > 0 ||
|
|
418
|
+
report.staleRatifications.length > 0 ||
|
|
419
|
+
ledgerStale;
|
|
420
|
+
return failed ? 1 : 0;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (import.meta.main) {
|
|
424
|
+
// exitCode (not process.exit): when stdout is a pipe, writes are async and
|
|
425
|
+
// process.exit() truncates the report mid-flush.
|
|
426
|
+
process.exitCode = main(process.argv.slice(2));
|
|
427
|
+
}
|