@codyswann/lisa 4.50.9 → 4.52.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/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +2 -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/reusable-workflow-load-adapter.d.ts +78 -0
- package/dist/core/reusable-workflow-load-adapter.d.ts.map +1 -0
- package/dist/core/reusable-workflow-load-adapter.js +125 -0
- package/dist/core/reusable-workflow-load-adapter.js.map +1 -0
- package/dist/core/reusable-workflow-load-failure.d.ts +144 -0
- package/dist/core/reusable-workflow-load-failure.d.ts.map +1 -0
- package/dist/core/reusable-workflow-load-failure.js +184 -0
- package/dist/core/reusable-workflow-load-failure.js.map +1 -0
- package/dist/core/reusable-workflow-load-scan.d.ts +92 -0
- package/dist/core/reusable-workflow-load-scan.d.ts.map +1 -0
- package/dist/core/reusable-workflow-load-scan.js +108 -0
- package/dist/core/reusable-workflow-load-scan.js.map +1 -0
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -3
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/check-e2e-coverage.mjs +117 -4
- package/package.json +5 -4
- 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-workflow-load-failures.ts +171 -0
- package/scripts/generate-two-channel-couplings.ts +215 -7
- package/scripts/two-channel-couplings.json +94 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scan this repository's recent run history for reusable workflows that failed
|
|
3
|
+
* to LOAD (CodySwannGT/lisa#3581).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this runs on a schedule and not at a gate
|
|
6
|
+
*
|
|
7
|
+
* The failure is invisible at the moments a gate covers. On a pull request it
|
|
8
|
+
* is already caught — `check-skipped-required-checks --pr=<n>` raises
|
|
9
|
+
* `absent_required_check`, and a ruleset-required context that never reports
|
|
10
|
+
* blocks the merge anyway. The incident this closes happened somewhere no gate
|
|
11
|
+
* looks: a `push` handler failed five times in a row, ~40 minutes after an
|
|
12
|
+
* upstream commit landed, with nothing changed on the consumer side and no red
|
|
13
|
+
* job to open. A control that only runs where the failure cannot happen is not
|
|
14
|
+
* a control, so the moment is the design decision here, not the mechanism.
|
|
15
|
+
*
|
|
16
|
+
* The sibling precedent is `lifecycle-drift-sweep.yml`, which exists because a
|
|
17
|
+
* report nobody scheduled and nobody read is indistinguishable from no report.
|
|
18
|
+
* This follows it: scheduled, and failing the job on drift so the finding lands
|
|
19
|
+
* in the existing failure-to-issue path rather than a job summary nobody opens.
|
|
20
|
+
*
|
|
21
|
+
* ## Two ways to exit non-zero, and they mean different things
|
|
22
|
+
*
|
|
23
|
+
* Findings are one. **A window this scan could not cover is the other**, and it
|
|
24
|
+
* is the more important of the two: the implementation this replaces read one
|
|
25
|
+
* page, filtered by timestamp, and reported that a hundred runs were inspected
|
|
26
|
+
* across a window that contained four known load failures. "Found nothing" and
|
|
27
|
+
* "could not look" must never render as the same green.
|
|
28
|
+
* @module scripts/check-workflow-load-failures
|
|
29
|
+
*/
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { promisify } from "node:util";
|
|
34
|
+
|
|
35
|
+
import { callerDeclaresReusableWorkflow } from "../src/core/reusable-workflow-load-failure.js";
|
|
36
|
+
import {
|
|
37
|
+
createRunPageFetcher,
|
|
38
|
+
type GithubRequest,
|
|
39
|
+
} from "../src/core/reusable-workflow-load-adapter.js";
|
|
40
|
+
import {
|
|
41
|
+
scanForLoadFailures,
|
|
42
|
+
type ScanResult,
|
|
43
|
+
} from "../src/core/reusable-workflow-load-scan.js";
|
|
44
|
+
|
|
45
|
+
const run = promisify(execFile);
|
|
46
|
+
|
|
47
|
+
/** Where caller workflows live, relative to the repository root. */
|
|
48
|
+
const WORKFLOW_DIR = ".github/workflows";
|
|
49
|
+
|
|
50
|
+
/** How far back to scan, in hours. */
|
|
51
|
+
const WINDOW_HOURS = 26;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Page cap.
|
|
55
|
+
*
|
|
56
|
+
* Hitting it is NOT coverage — the scan reports the window as uncovered and
|
|
57
|
+
* this script exits non-zero, because a cap is a budget somebody set and not
|
|
58
|
+
* evidence about the runs beyond it.
|
|
59
|
+
*/
|
|
60
|
+
const MAX_PAGES = 20;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the population test by reading the callers' own source.
|
|
64
|
+
*
|
|
65
|
+
* Derived, never enumerated: there is no list of known callers here, because a
|
|
66
|
+
* roster silently excludes every workflow nobody remembered to add and the
|
|
67
|
+
* exclusion looks exactly like a pass.
|
|
68
|
+
* @param root - Repository root
|
|
69
|
+
* @returns A predicate over a run's caller `path`
|
|
70
|
+
*/
|
|
71
|
+
export async function buildPopulationTest(
|
|
72
|
+
root: string
|
|
73
|
+
): Promise<(runPath: string) => boolean> {
|
|
74
|
+
const dir = path.join(root, WORKFLOW_DIR);
|
|
75
|
+
const names = await readdir(dir).catch(() => [] as string[]);
|
|
76
|
+
const declaring = await Promise.all(
|
|
77
|
+
names
|
|
78
|
+
.filter(name => name.endsWith(".yml") || name.endsWith(".yaml"))
|
|
79
|
+
.map(async name => {
|
|
80
|
+
const source = await readFile(path.join(dir, name), "utf8").catch(
|
|
81
|
+
() => ""
|
|
82
|
+
);
|
|
83
|
+
return callerDeclaresReusableWorkflow(source) ? name : "";
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
const declared = new Set(declaring.filter(name => name !== ""));
|
|
87
|
+
return (runPath: string): boolean => declared.has(path.basename(runPath));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A request function backed by the `gh` CLI.
|
|
92
|
+
* @param repo - Repository in `owner/name` form, used only for error text
|
|
93
|
+
* @returns A request function returning decoded JSON
|
|
94
|
+
*/
|
|
95
|
+
export function ghRequest(repo: string): GithubRequest {
|
|
96
|
+
return async (apiPath: string): Promise<unknown> => {
|
|
97
|
+
const { stdout } = await run("gh", ["api", apiPath], {
|
|
98
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(stdout) as unknown;
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`check-workflow-load-failures: \`gh api ${apiPath}\` on ${repo} returned a body that is not JSON. Treating that as "nothing found" would be a pass produced by a broken read, so this is an error.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render the scan's answer for an operator.
|
|
112
|
+
* @param result - What the scan found, and whether it could see
|
|
113
|
+
* @returns Operator-readable lines
|
|
114
|
+
*/
|
|
115
|
+
export function describe(result: ScanResult): string {
|
|
116
|
+
if (!result.covered) {
|
|
117
|
+
return `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.`;
|
|
118
|
+
}
|
|
119
|
+
if (result.loadFailures.length === 0) {
|
|
120
|
+
return `check-workflow-load-failures: OK. ${result.inspected} run(s) inspected across the last ${WINDOW_HOURS}h; every caller that declared a reusable workflow resolved one.`;
|
|
121
|
+
}
|
|
122
|
+
const lines = result.loadFailures.map(
|
|
123
|
+
finding =>
|
|
124
|
+
` - run ${finding.id} (${finding.path}) declared a reusable workflow and resolved none`
|
|
125
|
+
);
|
|
126
|
+
return [
|
|
127
|
+
`check-workflow-load-failures: ${result.loadFailures.length} run(s) failed to LOAD a reusable workflow.`,
|
|
128
|
+
...lines,
|
|
129
|
+
"",
|
|
130
|
+
"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.",
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Scan, report, and choose an exit code.
|
|
136
|
+
* @param repo - Repository in `owner/name` form
|
|
137
|
+
* @param root - Repository root
|
|
138
|
+
* @returns 0 when the window was covered and clean, 1 otherwise
|
|
139
|
+
*/
|
|
140
|
+
export async function main(repo: string, root: string): Promise<number> {
|
|
141
|
+
const windowStart = new Date(
|
|
142
|
+
Date.now() - WINDOW_HOURS * 60 * 60 * 1000
|
|
143
|
+
).toISOString();
|
|
144
|
+
|
|
145
|
+
const result = await scanForLoadFailures({
|
|
146
|
+
fetchPage: createRunPageFetcher({ repo, request: ghRequest(repo) }),
|
|
147
|
+
windowStart,
|
|
148
|
+
inPopulation: await buildPopulationTest(root),
|
|
149
|
+
maxPages: MAX_PAGES,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
console.log(describe(result));
|
|
153
|
+
return result.covered && result.loadFailures.length === 0 ? 0 : 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* c8 ignore start -- entry point, exercised by the workflow rather than a test */
|
|
157
|
+
if (
|
|
158
|
+
process.argv[1] !== undefined &&
|
|
159
|
+
import.meta.url.endsWith(path.basename(process.argv[1]))
|
|
160
|
+
) {
|
|
161
|
+
const repo = process.env["GITHUB_REPOSITORY"] ?? "CodySwannGT/lisa";
|
|
162
|
+
main(repo, process.cwd())
|
|
163
|
+
.then(code => {
|
|
164
|
+
process.exitCode = code;
|
|
165
|
+
})
|
|
166
|
+
.catch((error: unknown) => {
|
|
167
|
+
console.error(String(error));
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/* c8 ignore stop */
|
|
@@ -228,9 +228,50 @@ export function buildDeliveryInventory(
|
|
|
228
228
|
);
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* The only part of a report these two functions read.
|
|
233
|
+
*
|
|
234
|
+
* Narrower than the full report on purpose: they need a key and a verdict per
|
|
235
|
+
* entry and nothing else, and a parameter that asked for the whole measurement
|
|
236
|
+
* would force every caller — including a test — to build one it does not use.
|
|
237
|
+
*/
|
|
238
|
+
interface CouplingKeySource {
|
|
239
|
+
readonly entries: readonly {
|
|
240
|
+
readonly key: string;
|
|
241
|
+
readonly verdict: string;
|
|
242
|
+
}[];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** One coupling's answer to "can this notice a STALE artifact?" (#3687). */
|
|
246
|
+
export interface StalenessClassification {
|
|
247
|
+
/**
|
|
248
|
+
* What the coupling CAN detect today — an observation, not a decision.
|
|
249
|
+
*
|
|
250
|
+
* `version-handshake` asks the artifact for a contract version and compares
|
|
251
|
+
* it. `capability-probe` asks for one subcommand or flag and infers age from
|
|
252
|
+
* whether it answers, which sees staleness at exactly one point and is blind
|
|
253
|
+
* above that floor — the CodySwannGT/lisa#3477 defect. `existence-only`
|
|
254
|
+
* cannot see age at all.
|
|
255
|
+
*/
|
|
256
|
+
readonly detects: "version-handshake" | "capability-probe" | "existence-only";
|
|
257
|
+
/**
|
|
258
|
+
* Why the current level is acceptable. Required unless a handshake exists.
|
|
259
|
+
*
|
|
260
|
+
* This is the DECISION half, kept separate from `detects` on purpose: a
|
|
261
|
+
* coupling that cannot see age and is fine that way (an advisory read, an
|
|
262
|
+
* artifact carrying no verdict-bearing logic) is a different fact from one
|
|
263
|
+
* that cannot see age and should. Collapsing them into a single
|
|
264
|
+
* "not-needed" token would lose exactly the distinction the sweep exists to
|
|
265
|
+
* record, and would let an unexamined default wear the same word as a
|
|
266
|
+
* considered exemption.
|
|
267
|
+
*/
|
|
268
|
+
readonly reason?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
231
271
|
/** The ledger's hand-authored half plus its derived half. */
|
|
232
272
|
interface Ledger {
|
|
233
273
|
readonly ratified: Readonly<Record<string, string>>;
|
|
274
|
+
readonly staleness: Readonly<Record<string, StalenessClassification>>;
|
|
234
275
|
readonly inspected: TwoChannelReport["inspected"];
|
|
235
276
|
readonly counts: TwoChannelReport["counts"];
|
|
236
277
|
readonly couplings: readonly {
|
|
@@ -259,6 +300,156 @@ function readRatified(ledgerPath: string): Readonly<Record<string, string>> {
|
|
|
259
300
|
return parsed?.ratified ?? {};
|
|
260
301
|
}
|
|
261
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Read the staleness classifications already recorded (#3687).
|
|
305
|
+
*
|
|
306
|
+
* Exactly the shape `readRatified` uses, and for the same reason: the ledger is
|
|
307
|
+
* a DERIVED artifact regenerated in-commit, so a hand-authored field survives
|
|
308
|
+
* only if the generator reads the previous version before rewriting it. This is
|
|
309
|
+
* the established local precedent for persisting a human decision inside a
|
|
310
|
+
* generated file, which is why the classification lives here rather than in a
|
|
311
|
+
* new sidecar nobody would remember to update.
|
|
312
|
+
* @param ledgerPath - Absolute path to the ledger
|
|
313
|
+
* @returns The classifications, keyed `<workflow>::<path>`
|
|
314
|
+
*/
|
|
315
|
+
function readStaleness(
|
|
316
|
+
ledgerPath: string
|
|
317
|
+
): Readonly<Record<string, StalenessClassification>> {
|
|
318
|
+
if (!existsSync(ledgerPath)) return {};
|
|
319
|
+
const parsed = JSON.parse(readFileSync(ledgerPath, "utf8")) as
|
|
320
|
+
| Partial<Ledger>
|
|
321
|
+
| undefined;
|
|
322
|
+
return parsed?.staleness ?? {};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The verdict for a coupling the package channel already covers.
|
|
327
|
+
*
|
|
328
|
+
* Those are omitted from the ledger, so every question this file asks about a
|
|
329
|
+
* live coupling has to exclude them — named once so the three sites cannot
|
|
330
|
+
* disagree about which verdict that is.
|
|
331
|
+
*/
|
|
332
|
+
const PACKAGE_BACKED = "package-backed";
|
|
333
|
+
|
|
334
|
+
/** The `detects` value meaning the coupling asks for a contract version. */
|
|
335
|
+
const VERSION_HANDSHAKE = "version-handshake";
|
|
336
|
+
|
|
337
|
+
/** The `detects` value meaning it infers age from one subcommand or flag. */
|
|
338
|
+
const CAPABILITY_PROBE = "capability-probe";
|
|
339
|
+
|
|
340
|
+
/** The `detects` value meaning it can see presence and nothing else. */
|
|
341
|
+
const EXISTENCE_ONLY = "existence-only";
|
|
342
|
+
|
|
343
|
+
/** The `detects` values a classification may carry. */
|
|
344
|
+
const DETECTS = new Set([VERSION_HANDSHAKE, CAPABILITY_PROBE, EXISTENCE_ONLY]);
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The floor a stated reason must clear to be a reason rather than a label.
|
|
348
|
+
*
|
|
349
|
+
* A floor, not a proof — no check tells a real argument from filler. What it
|
|
350
|
+
* buys is that "n/a", "ok" and an empty string cannot stand in for the decision
|
|
351
|
+
* this field exists to record.
|
|
352
|
+
*/
|
|
353
|
+
const MIN_REASON_CHARACTERS = 40;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Every problem with the staleness half, as operator-readable paragraphs.
|
|
357
|
+
*
|
|
358
|
+
* THREE refusals, and the first is the one that makes this a standing property
|
|
359
|
+
* rather than a one-time sweep: a coupling with no classification fails, so
|
|
360
|
+
* entry 25 cannot silently inherit "nobody looked". A sweep decays the moment
|
|
361
|
+
* somebody adds an entry; a required field does not.
|
|
362
|
+
* @param report - The measurement
|
|
363
|
+
* @param staleness - The recorded classifications
|
|
364
|
+
* @returns Problem paragraphs, empty when the staleness half is sound
|
|
365
|
+
*/
|
|
366
|
+
export function stalenessProblems(
|
|
367
|
+
report: CouplingKeySource,
|
|
368
|
+
staleness: Readonly<Record<string, StalenessClassification>>
|
|
369
|
+
): readonly string[] {
|
|
370
|
+
const live = new Set(
|
|
371
|
+
report.entries
|
|
372
|
+
.filter(entry => entry.verdict !== PACKAGE_BACKED)
|
|
373
|
+
.map(entry => entry.key)
|
|
374
|
+
);
|
|
375
|
+
const missing = [...live]
|
|
376
|
+
.filter(key => staleness[key] === undefined)
|
|
377
|
+
.map(
|
|
378
|
+
key =>
|
|
379
|
+
`\nUNCLASSIFIED COUPLING ${key}\n No staleness classification. Every ` +
|
|
380
|
+
`coupling must record whether it can detect a STALE artifact, not only ` +
|
|
381
|
+
`an absent one — absence skips and posts nothing, while present-and-old ` +
|
|
382
|
+
`posts a verdict from superseded logic that every reader believes. Add ` +
|
|
383
|
+
`a "staleness" entry with "detects" (version-handshake | ` +
|
|
384
|
+
`capability-probe | existence-only) and, unless it is a handshake, a ` +
|
|
385
|
+
`"reason" saying why that is acceptable here.`
|
|
386
|
+
);
|
|
387
|
+
const orphaned = Object.keys(staleness)
|
|
388
|
+
.filter(key => !live.has(key))
|
|
389
|
+
.map(
|
|
390
|
+
key =>
|
|
391
|
+
`\nSTALE CLASSIFICATION ${key}\n Nothing reads this path any more, so ` +
|
|
392
|
+
`the classification is an unexamined judgement the next matching path ` +
|
|
393
|
+
`would inherit. Delete it from ${LEDGER_PATH}.`
|
|
394
|
+
);
|
|
395
|
+
const malformed = Object.entries(staleness)
|
|
396
|
+
.filter(([key]) => live.has(key))
|
|
397
|
+
.flatMap(([key, value]) => describeMalformed(key, value));
|
|
398
|
+
return [...missing, ...orphaned, ...malformed];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Why one recorded classification is not usable, if it is not.
|
|
403
|
+
* @param key - The coupling key
|
|
404
|
+
* @param value - The recorded classification
|
|
405
|
+
* @returns Problem paragraphs for this entry
|
|
406
|
+
*/
|
|
407
|
+
function describeMalformed(
|
|
408
|
+
key: string,
|
|
409
|
+
value: StalenessClassification
|
|
410
|
+
): readonly string[] {
|
|
411
|
+
if (!DETECTS.has(value?.detects)) {
|
|
412
|
+
return [
|
|
413
|
+
`\nMALFORMED CLASSIFICATION ${key}\n "detects" is ` +
|
|
414
|
+
`${JSON.stringify(value?.detects)}; expected one of ` +
|
|
415
|
+
`${[...DETECTS].join(", ")}.`,
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
if (value.detects === VERSION_HANDSHAKE) return [];
|
|
419
|
+
const reason = typeof value.reason === "string" ? value.reason.trim() : "";
|
|
420
|
+
return reason.length >= MIN_REASON_CHARACTERS
|
|
421
|
+
? []
|
|
422
|
+
: [
|
|
423
|
+
`\nUNREASONED CLASSIFICATION ${key}\n "detects" is ` +
|
|
424
|
+
`"${value.detects}", which cannot see how old the artifact is, and no ` +
|
|
425
|
+
`"reason" of at least ${MIN_REASON_CHARACTERS} characters says why ` +
|
|
426
|
+
`that is acceptable here. A recorded decision and an unexamined ` +
|
|
427
|
+
`default must not look the same.`,
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The one-line fleet coverage sentence (#3687).
|
|
433
|
+
* @param report - The measurement
|
|
434
|
+
* @param staleness - The recorded classifications
|
|
435
|
+
* @returns A sentence naming how much of the fleet can see age
|
|
436
|
+
*/
|
|
437
|
+
export function stalenessCoverage(
|
|
438
|
+
report: CouplingKeySource,
|
|
439
|
+
staleness: Readonly<Record<string, StalenessClassification>>
|
|
440
|
+
): string {
|
|
441
|
+
const live = report.entries.filter(entry => entry.verdict !== PACKAGE_BACKED);
|
|
442
|
+
const tally = (kind: string): number =>
|
|
443
|
+
live.filter(entry => staleness[entry.key]?.detects === kind).length;
|
|
444
|
+
const handshake = tally(VERSION_HANDSHAKE);
|
|
445
|
+
return (
|
|
446
|
+
` staleness detection: ${handshake} of ${live.length} coupling(s) can see ` +
|
|
447
|
+
`how old the artifact is (${VERSION_HANDSHAKE}); ${tally(CAPABILITY_PROBE)} ` +
|
|
448
|
+
`detect it at one floor only (${CAPABILITY_PROBE}); ${tally(EXISTENCE_ONLY)} ` +
|
|
449
|
+
`detect absence only (${EXISTENCE_ONLY}).`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
262
453
|
/**
|
|
263
454
|
* Render the ledger a report implies.
|
|
264
455
|
*
|
|
@@ -272,14 +463,16 @@ function readRatified(ledgerPath: string): Readonly<Record<string, string>> {
|
|
|
272
463
|
*/
|
|
273
464
|
function toLedger(
|
|
274
465
|
report: TwoChannelReport,
|
|
275
|
-
ratified: Readonly<Record<string, string
|
|
466
|
+
ratified: Readonly<Record<string, string>>,
|
|
467
|
+
staleness: Readonly<Record<string, StalenessClassification>>
|
|
276
468
|
): Ledger {
|
|
277
469
|
return {
|
|
278
470
|
ratified,
|
|
471
|
+
staleness,
|
|
279
472
|
inspected: report.inspected,
|
|
280
473
|
counts: report.counts,
|
|
281
474
|
couplings: report.entries
|
|
282
|
-
.filter(entry => entry.verdict !==
|
|
475
|
+
.filter(entry => entry.verdict !== PACKAGE_BACKED)
|
|
283
476
|
.map(entry => ({
|
|
284
477
|
key: entry.key,
|
|
285
478
|
workflow: entry.workflow,
|
|
@@ -302,6 +495,7 @@ function toLedger(
|
|
|
302
495
|
export function measure(root: string): {
|
|
303
496
|
readonly report: TwoChannelReport;
|
|
304
497
|
readonly ratified: Readonly<Record<string, string>>;
|
|
498
|
+
readonly staleness: Readonly<Record<string, StalenessClassification>>;
|
|
305
499
|
} {
|
|
306
500
|
const workflowsDir = path.join(root, WORKFLOWS_DIR);
|
|
307
501
|
const inventory = buildDeliveryInventory(root);
|
|
@@ -342,6 +536,7 @@ export function measure(root: string): {
|
|
|
342
536
|
ratified: readRatified(path.join(root, LEDGER_PATH)),
|
|
343
537
|
}),
|
|
344
538
|
ratified: readRatified(path.join(root, LEDGER_PATH)),
|
|
539
|
+
staleness: readStaleness(path.join(root, LEDGER_PATH)),
|
|
345
540
|
};
|
|
346
541
|
}
|
|
347
542
|
|
|
@@ -350,7 +545,10 @@ export function measure(root: string): {
|
|
|
350
545
|
* @param report - The measurement
|
|
351
546
|
* @returns The report text
|
|
352
547
|
*/
|
|
353
|
-
function humanReport(
|
|
548
|
+
function humanReport(
|
|
549
|
+
report: TwoChannelReport,
|
|
550
|
+
staleness: Readonly<Record<string, StalenessClassification>>
|
|
551
|
+
): string {
|
|
354
552
|
const counts = report.inspected;
|
|
355
553
|
const header =
|
|
356
554
|
`two-channel delivery: inspected ${counts.workflows} reusable workflow(s), ` +
|
|
@@ -370,7 +568,12 @@ function humanReport(report: TwoChannelReport): string {
|
|
|
370
568
|
`inherit. Delete it from ${LEDGER_PATH}.`
|
|
371
569
|
)
|
|
372
570
|
.join("");
|
|
373
|
-
|
|
571
|
+
// The coverage line answers the question the registry could not answer at
|
|
572
|
+
// all before this: "how many of our couplings can detect staleness?" — in one
|
|
573
|
+
// line, without opening 24 workflows.
|
|
574
|
+
const coverage = stalenessCoverage(report, staleness);
|
|
575
|
+
const unclassified = stalenessProblems(report, staleness).join("");
|
|
576
|
+
return `${header}\n${verdicts}\n${coverage}${findings}${stale}${unclassified}`;
|
|
374
577
|
}
|
|
375
578
|
|
|
376
579
|
/**
|
|
@@ -384,9 +587,9 @@ export function main(argv: readonly string[]): number {
|
|
|
384
587
|
process.stderr.write(`error: --root does not exist: ${options.root}\n`);
|
|
385
588
|
return EXIT_OPERATIONAL;
|
|
386
589
|
}
|
|
387
|
-
const { report, ratified } = measure(options.root);
|
|
590
|
+
const { report, ratified, staleness } = measure(options.root);
|
|
388
591
|
process.stdout.write(
|
|
389
|
-
`${options.json ? JSON.stringify(report, null, 2) : humanReport(report)}\n`
|
|
592
|
+
`${options.json ? JSON.stringify(report, null, 2) : humanReport(report, staleness)}\n`
|
|
390
593
|
);
|
|
391
594
|
if (!report.measured) {
|
|
392
595
|
process.stderr.write(
|
|
@@ -397,7 +600,7 @@ export function main(argv: readonly string[]): number {
|
|
|
397
600
|
return EXIT_OPERATIONAL;
|
|
398
601
|
}
|
|
399
602
|
const ledgerPath = path.join(options.root, LEDGER_PATH);
|
|
400
|
-
const rendered = `${JSON.stringify(toLedger(report, ratified), null, 2)}\n`;
|
|
603
|
+
const rendered = `${JSON.stringify(toLedger(report, ratified, staleness), null, 2)}\n`;
|
|
401
604
|
const current = existsSync(ledgerPath)
|
|
402
605
|
? readFileSync(ledgerPath, "utf8")
|
|
403
606
|
: "";
|
|
@@ -413,9 +616,14 @@ export function main(argv: readonly string[]): number {
|
|
|
413
616
|
mkdirSync(path.dirname(ledgerPath), { recursive: true });
|
|
414
617
|
writeFileSync(ledgerPath, rendered);
|
|
415
618
|
}
|
|
619
|
+
// The staleness half fails in BOTH modes, exactly as findings do. A gate you
|
|
620
|
+
// can regenerate your way past is not a gate: regenerating records a coupling,
|
|
621
|
+
// it does not classify one, and `toLedger` carries the map through untouched
|
|
622
|
+
// rather than inventing a default for a new entry (#3687).
|
|
416
623
|
const failed =
|
|
417
624
|
report.findings.length > 0 ||
|
|
418
625
|
report.staleRatifications.length > 0 ||
|
|
626
|
+
stalenessProblems(report, staleness).length > 0 ||
|
|
419
627
|
ledgerStale;
|
|
420
628
|
return failed ? 1 : 0;
|
|
421
629
|
}
|
|
@@ -3,6 +3,100 @@
|
|
|
3
3
|
"quality.yml::scripts/check-learnings-budget.ts": "Lisa-source-only branch, and the else arm is the delivery. The step tests for BOTH scripts/check-learnings-budget.ts and src/core/learnings-budget-check.ts, which co-exist only in this repository; every consumer takes the else arm and runs the prover as `bunx @codyswann/lisa@<their own declared version>`, which is the PACKAGE channel and not a caller-tree read at all. A trailing `grep -qE \"learnings budget (passed|saturated)|no learnings file\"` then refuses a run that printed no verdict, so the arm cannot pass vacuously. Ratified because the caller-tree read is unreachable outside this repository, not because the gap is tolerated.",
|
|
4
4
|
"quality.yml::scripts/generate-nightly-e2e-guard-certificate.mjs": "Lisa-source-only branch. Both reusable jobs require `github.repository == 'CodySwannGT/lisa'` before the step can start, and the run body repeats that exact repository guard before checking this artifact path. Ordinary and private host callers therefore skip the step without reading or fetching from their own tree. The scoped branch fetches only the immutable refs needed to behavior-test Lisa's own retained certificate inputs. Ratified because the caller-tree read is unreachable outside this repository, not because an undelivered host dependency is tolerated."
|
|
5
5
|
},
|
|
6
|
+
"staleness": {
|
|
7
|
+
"create-github-issue-on-failure.yml::scripts/nightly-e2e-provider-action.mjs": {
|
|
8
|
+
"detects": "existence-only",
|
|
9
|
+
"reason": "The read files a tracker issue after a nightly failure; it posts no required check and gates no merge. A stale copy files a slightly wrong issue body, which a human reads and corrects, rather than turning a merge green or red from superseded logic. The harm this classification exists to catch is a verdict nobody can see is old, and this coupling produces no verdict."
|
|
10
|
+
},
|
|
11
|
+
"create-jira-issue-on-failure.yml::scripts/nightly-e2e-provider-action.mjs": {
|
|
12
|
+
"detects": "existence-only",
|
|
13
|
+
"reason": "Same provider action as the GitHub arm, same reasoning: it files a tracker issue after a nightly failure, posts no required check, and gates no merge. A stale copy misformats a report a human reads; it cannot produce a merge verdict derived from superseded logic."
|
|
14
|
+
},
|
|
15
|
+
"create-linear-issue-on-failure.yml::scripts/nightly-e2e-provider-action.mjs": {
|
|
16
|
+
"detects": "existence-only",
|
|
17
|
+
"reason": "Same provider action as the GitHub and Jira arms, same reasoning: it files a tracker issue after a nightly failure, posts no required check, and gates no merge. A stale copy degrades a report rather than a verdict."
|
|
18
|
+
},
|
|
19
|
+
"create-sentry-issue-on-failure.yml::scripts/nightly-e2e-provider-action.mjs": {
|
|
20
|
+
"detects": "existence-only",
|
|
21
|
+
"reason": "Same provider action as the other three failure arms, same reasoning: it files an issue after a nightly failure, posts no required check, and gates no merge. A stale copy degrades a report rather than a verdict."
|
|
22
|
+
},
|
|
23
|
+
"maestro-native-e2e.yml::scripts/classify-maestro-failures.mjs": {
|
|
24
|
+
"detects": "existence-only",
|
|
25
|
+
"reason": "Classifies native E2E failures for the run summary so an operator can tell an infrastructure flake from a product regression. It labels a failure that has already been decided elsewhere; it does not decide one. A stale classifier mislabels a red the operator is already looking at, which is visible in the same output."
|
|
26
|
+
},
|
|
27
|
+
"nightly-e2e-health.yml::scripts/check-nightly-e2e-health.mjs": {
|
|
28
|
+
"detects": "version-handshake"
|
|
29
|
+
},
|
|
30
|
+
"nightly-e2e-health.yml::scripts/nightly-e2e-suites.schema.json": {
|
|
31
|
+
"detects": "existence-only",
|
|
32
|
+
"reason": "A JSON schema has no executable surface, so it cannot answer a contract-version call at all — no handshake is constructible here. It is covered transitively rather than left unguarded: the schema and check-nightly-e2e-health.mjs ship on the same typescript/copy-overwrite lane and are read by the same workflow, and that checker DOES handshake, so a tree old enough to carry a stale schema carries a stale checker and the existing handshake fails first."
|
|
33
|
+
},
|
|
34
|
+
"nightly-e2e-report.yml::scripts/check-nightly-e2e-health.mjs": {
|
|
35
|
+
"detects": "version-handshake"
|
|
36
|
+
},
|
|
37
|
+
"nightly-e2e-tracking.yml::scripts/reconcile-nightly-e2e-tracking.mjs": {
|
|
38
|
+
"detects": "existence-only",
|
|
39
|
+
"reason": "Reconciles the nightly tracking issue — opens, refreshes or closes it to match the latest health verdict. It consumes a verdict rather than producing one, so a stale copy mismanages a tracking issue a human reads and cannot make a merge gate green."
|
|
40
|
+
},
|
|
41
|
+
"publish-to-npm.yml::scripts/check-npm-publish-landed.mjs": {
|
|
42
|
+
"detects": "existence-only",
|
|
43
|
+
"reason": "A GENUINE GAP, recorded rather than waved through. It verifies a publish actually landed, so a stale copy can attest to a release that did not happen. It runs post-merge in the release workflow and posts no merge-blocking required context, which is why it is not the first handshake to build — but it is verdict-bearing and belongs at the front of the queue when this classification is next acted on."
|
|
44
|
+
},
|
|
45
|
+
"publish-to-npm.yml::scripts/check-release-package-identity.mjs": {
|
|
46
|
+
"detects": "existence-only",
|
|
47
|
+
"reason": "A GENUINE GAP, recorded rather than waved through, and the sibling of the publish-landed read: it attests that the published package is the one intended, so a stale copy can attest to an identity it no longer checks the same way. Post-merge and not merge-blocking, so it queues behind the merge gates rather than ahead of them."
|
|
48
|
+
},
|
|
49
|
+
"quality-rails.yml::scripts/check-threshold-ratchet.mjs": {
|
|
50
|
+
"detects": "existence-only",
|
|
51
|
+
"reason": "Same prover as the quality.yml read and the same GENUINE GAP, reached from the Rails caller tree. Recorded separately because the two callers can drift apart: a handshake added to one workflow does not reach the other, which is how a fleet ends up half-covered."
|
|
52
|
+
},
|
|
53
|
+
"quality-rails.yml::scripts/lisa-mutation.sh": {
|
|
54
|
+
"detects": "existence-only",
|
|
55
|
+
"reason": "A shell wrapper that shapes a mutation-testing invocation; the verdict comes from Stryker and its configured thresholds, not from this file. A stale wrapper passes older flags, which fails loudly as a runner usage error rather than producing a wrong mutation score."
|
|
56
|
+
},
|
|
57
|
+
"quality-rails.yml::scripts/lisa-scratch-run.sh": {
|
|
58
|
+
"detects": "existence-only",
|
|
59
|
+
"reason": "A shell wrapper that isolates a run's scratch namespace. It carries no verdict-bearing logic at all — it prepares a directory and execs the real command — so there is no superseded judgement for a stale copy to apply."
|
|
60
|
+
},
|
|
61
|
+
"quality-rails.yml::scripts/lisa-work-item.mjs": {
|
|
62
|
+
"detects": "version-handshake"
|
|
63
|
+
},
|
|
64
|
+
"quality.yml::scripts/check-e2e-coverage.mjs": {
|
|
65
|
+
"detects": "existence-only",
|
|
66
|
+
"reason": "Guarded by an existence test and scoped to Expo consumers. It reports route coverage against a declared threshold; a stale copy computes coverage by an older rule and says which rule it used in its own output, so the number it prints is interpretable rather than silently wrong."
|
|
67
|
+
},
|
|
68
|
+
"quality.yml::scripts/check-learnings-budget.ts": {
|
|
69
|
+
"detects": "existence-only",
|
|
70
|
+
"reason": "Already ratified as a Lisa-source-only branch: every consumer takes the else arm and runs the prover from the PACKAGE channel at their own declared version, which is a version pin rather than an unversioned copy. The two-channel skew this field is about does not arise on the path consumers actually take."
|
|
71
|
+
},
|
|
72
|
+
"quality.yml::scripts/check-skipped-required-checks.mjs": {
|
|
73
|
+
"detects": "existence-only",
|
|
74
|
+
"reason": "A GENUINE GAP and the sharpest one in this registry: it is the vacuity and review-evidence prover, so a stale copy posts a review verdict from superseded logic on a merge gate. Its own workflow resolves a second in-repo address when scripts/ has no copy, which bounds the absent case but says nothing about age. Highest-value handshake candidate here."
|
|
75
|
+
},
|
|
76
|
+
"quality.yml::scripts/check-state-classification.mjs": {
|
|
77
|
+
"detects": "existence-only",
|
|
78
|
+
"reason": "Guarded by an existence test and advisory in effect: it classifies demo/fixture state against a declared contract and reports what it found. A stale copy reports against an older contract version, which surfaces as a classification mismatch in its own output rather than as a green merge gate, so the failure is self-announcing."
|
|
79
|
+
},
|
|
80
|
+
"quality.yml::scripts/check-threshold-ratchet.mjs": {
|
|
81
|
+
"detects": "existence-only",
|
|
82
|
+
"reason": "A GENUINE GAP. The ratchet decides whether a threshold moved the wrong way, so a stale copy can pass a loosened threshold it does not yet know how to refuse. It is declared optional at push in this repository's own gate set, which lowers the blast radius but does not remove it."
|
|
83
|
+
},
|
|
84
|
+
"quality.yml::scripts/check-verification-coverage.mjs": {
|
|
85
|
+
"detects": "existence-only",
|
|
86
|
+
"reason": "Bespoke and conditional — it requires a verification-spec delta on feat/fix changes. A stale copy applies an older definition of what counts as a spec delta, which produces a visible disagreement with the spec in the pull request rather than a silent pass."
|
|
87
|
+
},
|
|
88
|
+
"quality.yml::scripts/generate-nightly-e2e-guard-certificate.mjs": {
|
|
89
|
+
"detects": "existence-only",
|
|
90
|
+
"reason": "Already ratified as undelivered: no lane ships this path, so no consumer tree carries a copy that could be stale. The staleness question is vacuous here for the same reason the delivery question was, and it is recorded rather than left blank so the entry is not mistaken for one nobody examined."
|
|
91
|
+
},
|
|
92
|
+
"quality.yml::scripts/lisa-floor-collisions.mjs": {
|
|
93
|
+
"detects": "existence-only",
|
|
94
|
+
"reason": "Reports collisions between declared security floors. A stale copy knows a smaller set of floors, so it under-reports rather than mis-decides, and the floors it does know it still checks correctly. Under-reporting is the benign direction for a collision detector."
|
|
95
|
+
},
|
|
96
|
+
"quality.yml::scripts/lisa-work-item.mjs": {
|
|
97
|
+
"detects": "version-handshake"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
6
100
|
"inspected": {
|
|
7
101
|
"workflows": 23,
|
|
8
102
|
"steps": 719,
|