@codyswann/lisa 4.60.4 → 4.60.5

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.
Files changed (80) hide show
  1. package/{scripts/check-workflow-load-failures.ts → all/copy-overwrite/scripts/check-workflow-load-failures.mjs} +36 -76
  2. package/all/copy-overwrite/scripts/lib/reusable-workflow-load-adapter.d.mts +21 -0
  3. package/{dist/core/reusable-workflow-load-adapter.js → all/copy-overwrite/scripts/lib/reusable-workflow-load-adapter.mjs} +50 -41
  4. package/all/copy-overwrite/scripts/lib/reusable-workflow-load-failure.d.mts +29 -0
  5. package/all/copy-overwrite/scripts/lib/reusable-workflow-load-failure.mjs +124 -0
  6. package/all/copy-overwrite/scripts/lib/reusable-workflow-load-scan.d.mts +36 -0
  7. package/all/copy-overwrite/scripts/lib/reusable-workflow-load-scan.mjs +102 -0
  8. package/all/create-only/.github/workflows/workflow-load-failure-sweep.yml +42 -0
  9. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  10. package/dist/core/lisa-owned-hash-ledger.js +64 -0
  11. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  12. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  13. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  14. package/dist/core/upstream-evidence-manifest.js +21 -7
  15. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  16. package/package.json +5 -5
  17. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  19. package/plugins/lisa-agy/plugin.json +1 -1
  20. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  23. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-agy/plugin.json +1 -1
  30. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  50. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-agy/plugin.json +1 -1
  55. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  60. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  65. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  67. package/scripts/check-workflow-load-failures.mjs +4 -0
  68. package/scripts/two-channel-couplings.json +1 -1
  69. package/typescript/copy-overwrite/.prettierignore +1 -0
  70. package/dist/core/reusable-workflow-load-adapter.d.ts +0 -78
  71. package/dist/core/reusable-workflow-load-adapter.d.ts.map +0 -1
  72. package/dist/core/reusable-workflow-load-adapter.js.map +0 -1
  73. package/dist/core/reusable-workflow-load-failure.d.ts +0 -144
  74. package/dist/core/reusable-workflow-load-failure.d.ts.map +0 -1
  75. package/dist/core/reusable-workflow-load-failure.js +0 -184
  76. package/dist/core/reusable-workflow-load-failure.js.map +0 -1
  77. package/dist/core/reusable-workflow-load-scan.d.ts +0 -92
  78. package/dist/core/reusable-workflow-load-scan.d.ts.map +0 -1
  79. package/dist/core/reusable-workflow-load-scan.js +0 -108
  80. package/dist/core/reusable-workflow-load-scan.js.map +0 -1
@@ -1,55 +1,20 @@
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
- */
1
+ #!/usr/bin/env node
2
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
3
+ // Do not edit directly — durable changes belong upstream in Lisa.
4
+
30
5
  import { execFile } from "node:child_process";
31
6
  import { readdir, readFile } from "node:fs/promises";
32
7
  import path from "node:path";
8
+ import { pathToFileURL } from "node:url";
33
9
  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
-
10
+ import { callerDeclaresReusableWorkflow } from "./lib/reusable-workflow-load-failure.mjs";
11
+ import { createRunPageFetcher } from "./lib/reusable-workflow-load-adapter.mjs";
12
+ import { scanForLoadFailures } from "./lib/reusable-workflow-load-scan.mjs";
45
13
  const run = promisify(execFile);
46
-
47
14
  /** Where caller workflows live, relative to the repository root. */
48
15
  const WORKFLOW_DIR = ".github/workflows";
49
-
50
16
  /** How far back to scan, in hours. */
51
17
  const WINDOW_HOURS = 26;
52
-
53
18
  /**
54
19
  * Page cap.
55
20
  *
@@ -58,7 +23,6 @@ const WINDOW_HOURS = 26;
58
23
  * evidence about the runs beyond it.
59
24
  */
60
25
  const MAX_PAGES = 20;
61
-
62
26
  /**
63
27
  * Build the population test by reading the callers' own source.
64
28
  *
@@ -68,37 +32,32 @@ const MAX_PAGES = 20;
68
32
  * @param root - Repository root
69
33
  * @returns A predicate over a run's caller `path`
70
34
  */
71
- export async function buildPopulationTest(
72
- root: string
73
- ): Promise<(runPath: string) => boolean> {
35
+ export async function buildPopulationTest(root) {
74
36
  const dir = path.join(root, WORKFLOW_DIR);
75
- const names = await readdir(dir).catch(() => [] as string[]);
37
+ const names = await readdir(dir);
76
38
  const declaring = await Promise.all(
77
39
  names
78
40
  .filter(name => name.endsWith(".yml") || name.endsWith(".yaml"))
79
41
  .map(async name => {
80
- const source = await readFile(path.join(dir, name), "utf8").catch(
81
- () => ""
82
- );
42
+ const source = await readFile(path.join(dir, name), "utf8");
83
43
  return callerDeclaresReusableWorkflow(source) ? name : "";
84
44
  })
85
45
  );
86
46
  const declared = new Set(declaring.filter(name => name !== ""));
87
- return (runPath: string): boolean => declared.has(path.basename(runPath));
47
+ return runPath => declared.has(path.basename(runPath));
88
48
  }
89
-
90
49
  /**
91
50
  * A request function backed by the `gh` CLI.
92
51
  * @param repo - Repository in `owner/name` form, used only for error text
93
52
  * @returns A request function returning decoded JSON
94
53
  */
95
- export function ghRequest(repo: string): GithubRequest {
96
- return async (apiPath: string): Promise<unknown> => {
54
+ export function ghRequest(repo) {
55
+ return async apiPath => {
97
56
  const { stdout } = await run("gh", ["api", apiPath], {
98
57
  maxBuffer: 32 * 1024 * 1024,
99
58
  });
100
59
  try {
101
- return JSON.parse(stdout) as unknown;
60
+ return JSON.parse(stdout);
102
61
  } catch {
103
62
  throw new Error(
104
63
  `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.`
@@ -106,18 +65,17 @@ export function ghRequest(repo: string): GithubRequest {
106
65
  }
107
66
  };
108
67
  }
109
-
110
68
  /**
111
69
  * Render the scan's answer for an operator.
112
70
  * @param result - What the scan found, and whether it could see
113
71
  * @returns Operator-readable lines
114
72
  */
115
- export function describe(result: ScanResult): string {
73
+ export function describe(result) {
116
74
  if (!result.covered) {
117
75
  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
76
  }
119
77
  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.`;
78
+ return `check-workflow-load-failures: OK. ${result.inspected} run(s) inspected across the last ${WINDOW_HOURS}h; no reusable workflow load failures found.`;
121
79
  }
122
80
  const lines = result.loadFailures.map(
123
81
  finding =>
@@ -130,42 +88,44 @@ export function describe(result: ScanResult): string {
130
88
  "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
89
  ].join("\n");
132
90
  }
133
-
134
91
  /**
135
92
  * Scan, report, and choose an exit code.
136
93
  * @param repo - Repository in `owner/name` form
137
94
  * @param root - Repository root
138
95
  * @returns 0 when the window was covered and clean, 1 otherwise
139
96
  */
140
- export async function main(repo: string, root: string): Promise<number> {
97
+ export async function main(repo, root) {
141
98
  const windowStart = new Date(
142
99
  Date.now() - WINDOW_HOURS * 60 * 60 * 1000
143
100
  ).toISOString();
144
-
145
101
  const result = await scanForLoadFailures({
146
102
  fetchPage: createRunPageFetcher({ repo, request: ghRequest(repo) }),
147
103
  windowStart,
148
104
  inPopulation: await buildPopulationTest(root),
149
105
  maxPages: MAX_PAGES,
150
106
  });
151
-
152
107
  console.log(describe(result));
153
108
  return result.covered && result.loadFailures.length === 0 ? 0 : 1;
154
109
  }
110
+ /** Run against the repository explicitly supplied by the workflow. */
111
+ export async function runCli() {
112
+ const repo = process.env.GITHUB_REPOSITORY;
113
+ if (!repo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) {
114
+ console.error("Set GITHUB_REPOSITORY to the owner/repository to scan.");
115
+ process.exitCode = 1;
116
+ return;
117
+ }
118
+ try {
119
+ process.exitCode = await main(repo, process.cwd());
120
+ } catch (error) {
121
+ console.error(String(error));
122
+ process.exitCode = 1;
123
+ }
124
+ }
155
125
 
156
- /* c8 ignore start -- entry point, exercised by the workflow rather than a test */
157
126
  if (
158
- process.argv[1] !== undefined &&
159
- import.meta.url.endsWith(path.basename(process.argv[1]))
127
+ process.argv[1] &&
128
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href
160
129
  ) {
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
- });
130
+ runCli();
170
131
  }
171
- /* c8 ignore stop */
@@ -0,0 +1,21 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ import type { RunPage } from "./reusable-workflow-load-scan.mjs";
5
+ export declare const RUNS_PER_PAGE = 100;
6
+ export type GithubRequest = (path: string) => Promise<unknown>;
7
+ export interface AdapterOptions {
8
+ readonly repo: string;
9
+ readonly request: GithubRequest;
10
+ readonly perPage?: number;
11
+ }
12
+ export declare function runsPath(
13
+ repo: string,
14
+ page: number,
15
+ perPage: number
16
+ ): string;
17
+ export declare function runPath(repo: string, id: number): string;
18
+ export declare function jobsPath(repo: string, id: number): string;
19
+ export declare function createRunPageFetcher(
20
+ options: AdapterOptions
21
+ ): (page: number) => Promise<RunPage>;
@@ -1,3 +1,6 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
1
4
  /**
2
5
  * Conclusions worth spending two extra requests on.
3
6
  *
@@ -17,7 +20,7 @@ export const RUNS_PER_PAGE = 100;
17
20
  * @returns The API path to request
18
21
  */
19
22
  export function runsPath(repo, page, perPage) {
20
- return `repos/${repo}/actions/runs?per_page=${perPage}&page=${page}`;
23
+ return `repos/${repo}/actions/runs?per_page=${perPage}&page=${page}`;
21
24
  }
22
25
  /**
23
26
  * Path for one run's detail, which is where `referenced_workflows` lives.
@@ -26,7 +29,7 @@ export function runsPath(repo, page, perPage) {
26
29
  * @returns The API path to request
27
30
  */
28
31
  export function runPath(repo, id) {
29
- return `repos/${repo}/actions/runs/${id}`;
32
+ return `repos/${repo}/actions/runs/${id}`;
30
33
  }
31
34
  /**
32
35
  * Path for one run's jobs, read only for its `total_count`.
@@ -35,7 +38,7 @@ export function runPath(repo, id) {
35
38
  * @returns The API path to request
36
39
  */
37
40
  export function jobsPath(repo, id) {
38
- return `repos/${repo}/actions/runs/${id}/jobs?per_page=1`;
41
+ return `repos/${repo}/actions/runs/${id}/jobs?per_page=1`;
39
42
  }
40
43
  /**
41
44
  * Read a property off an unknown decoded body without asserting a shape.
@@ -44,9 +47,8 @@ export function jobsPath(repo, id) {
44
47
  * @returns The value, or undefined when the body is not an object
45
48
  */
46
49
  function field(body, key) {
47
- if (typeof body !== "object" || body === null)
48
- return undefined;
49
- return body[key];
50
+ if (typeof body !== "object" || body === null) return undefined;
51
+ return body[key];
50
52
  }
51
53
  /**
52
54
  * Coerce an unknown to a number, defaulting rather than throwing.
@@ -54,7 +56,7 @@ function field(body, key) {
54
56
  * @returns The number, or 0 when it is not a finite one
55
57
  */
56
58
  function count(value) {
57
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
59
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
58
60
  }
59
61
  /**
60
62
  * Coerce an unknown to a string, or empty.
@@ -62,7 +64,7 @@ function count(value) {
62
64
  * @returns The string, or empty when it is not one
63
65
  */
64
66
  function text(value) {
65
- return typeof value === "string" ? value : "";
67
+ return typeof value === "string" ? value : "";
66
68
  }
67
69
  /**
68
70
  * Read the list endpoint's runs array into the fields the scan needs.
@@ -70,17 +72,21 @@ function text(value) {
70
72
  * @returns One entry per run, in the order GitHub returned them
71
73
  */
72
74
  function listedRuns(body) {
73
- const runs = field(body, "workflow_runs");
74
- if (!Array.isArray(runs))
75
- return [];
76
- return runs.map((run) => ({
77
- id: count(field(run, "id")),
78
- path: text(field(run, "path")),
79
- createdAt: text(field(run, "created_at")),
80
- conclusion: typeof field(run, "conclusion") === "string"
81
- ? text(field(run, "conclusion"))
82
- : null,
83
- }));
75
+ const runs = field(body, "workflow_runs");
76
+ if (!Array.isArray(runs)) {
77
+ throw new Error(
78
+ "Workflow run list is unreadable: expected workflow_runs array."
79
+ );
80
+ }
81
+ return runs.map(run => ({
82
+ id: count(field(run, "id")),
83
+ path: text(field(run, "path")),
84
+ createdAt: text(field(run, "created_at")),
85
+ conclusion:
86
+ typeof field(run, "conclusion") === "string"
87
+ ? text(field(run, "conclusion"))
88
+ : null,
89
+ }));
84
90
  }
85
91
  /**
86
92
  * Gather the two extra facts a candidate run's verdict turns on.
@@ -89,14 +95,14 @@ function listedRuns(body) {
89
95
  * @returns The run with its referenced-workflow and job counts filled in
90
96
  */
91
97
  async function withDetail(listed, options) {
92
- const detail = await options.request(runPath(options.repo, listed.id));
93
- const jobs = await options.request(jobsPath(options.repo, listed.id));
94
- const referenced = field(detail, "referenced_workflows");
95
- return {
96
- ...listed,
97
- referencedCount: Array.isArray(referenced) ? referenced.length : 0,
98
- jobCount: count(field(jobs, "total_count")),
99
- };
98
+ const detail = await options.request(runPath(options.repo, listed.id));
99
+ const jobs = await options.request(jobsPath(options.repo, listed.id));
100
+ const referenced = field(detail, "referenced_workflows");
101
+ return {
102
+ ...listed,
103
+ referencedCount: Array.isArray(referenced) ? referenced.length : 0,
104
+ jobCount: count(field(jobs, "total_count")),
105
+ };
100
106
  }
101
107
  /**
102
108
  * Build the page fetcher `scanForLoadFailures` consumes.
@@ -108,18 +114,21 @@ async function withDetail(listed, options) {
108
114
  * @returns A fetcher for one 1-based page of run history
109
115
  */
110
116
  export function createRunPageFetcher(options) {
111
- const perPage = options.perPage ?? RUNS_PER_PAGE;
112
- return async (page) => {
113
- const body = await options.request(runsPath(options.repo, page, perPage));
114
- const listed = listedRuns(body);
115
- const runs = await Promise.all(listed.map(async (run) => {
116
- if (run.conclusion === null ||
117
- !DETAIL_WORTH_FETCHING.includes(run.conclusion)) {
118
- return { ...run, referencedCount: 0, jobCount: 0 };
119
- }
120
- return withDetail(run, options);
121
- }));
122
- return { runs, hasMore: listed.length === perPage };
123
- };
117
+ const perPage = options.perPage ?? RUNS_PER_PAGE;
118
+ return async page => {
119
+ const body = await options.request(runsPath(options.repo, page, perPage));
120
+ const listed = listedRuns(body);
121
+ const runs = await Promise.all(
122
+ listed.map(async run => {
123
+ if (
124
+ run.conclusion === null ||
125
+ !DETAIL_WORTH_FETCHING.includes(run.conclusion)
126
+ ) {
127
+ return { ...run, referencedCount: 0, jobCount: 0 };
128
+ }
129
+ return withDetail(run, options);
130
+ })
131
+ );
132
+ return { runs, hasMore: listed.length === perPage };
133
+ };
124
134
  }
125
- //# sourceMappingURL=reusable-workflow-load-adapter.js.map
@@ -0,0 +1,29 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ export type RunLoadClass =
5
+ | "load-failure"
6
+ | "dead-runner"
7
+ | "resolved"
8
+ | "out-of-population"
9
+ | "skipped"
10
+ | "inconclusive"
11
+ | "in-flight";
12
+ export interface RunLoadFacts {
13
+ readonly inPopulation: boolean;
14
+ readonly referencedCount: number;
15
+ readonly jobCount: number;
16
+ readonly conclusion: string | null;
17
+ }
18
+ export declare function callerDeclaresReusableWorkflow(source: string): boolean;
19
+ export declare function classifyRunLoad(facts: RunLoadFacts): RunLoadClass;
20
+ export interface WindowScan {
21
+ readonly oldestSeen: string | null;
22
+ readonly windowStart: string;
23
+ readonly exhausted: boolean;
24
+ }
25
+ export interface WindowVerdict {
26
+ readonly covered: boolean;
27
+ readonly reason: string;
28
+ }
29
+ export declare function windowCoverage(scan: WindowScan): WindowVerdict;
@@ -0,0 +1,124 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ /**
5
+ * A job-level `uses:` naming a reusable workflow in another repository.
6
+ *
7
+ * The path shape is what discriminates, not a roster of known callers: a
8
+ * reusable workflow is always `<owner>/<repo>/.github/workflows/<file>@<ref>`.
9
+ * A step action is `<owner>/<repo>@<ref>` with no path, so `actions/checkout@v6`
10
+ * cannot match — which matters, because reading step actions as declarations
11
+ * is exactly what produced 14 false positives out of 19.
12
+ */
13
+ const CROSS_REPO_USES =
14
+ /^[ \t]*uses:[ \t]*["']?[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml@[^\s"'#]+/u;
15
+ /**
16
+ * A job-level `uses:` naming a reusable workflow in the caller's own
17
+ * repository.
18
+ *
19
+ * Unlike the cross-repo arm this one is NOT backed by a measured run — the
20
+ * ticket's evidence is all cross-repo. It is included because the path shape
21
+ * is unambiguous and excluding it would leave local reusable workflows
22
+ * undetectable, but if this detector ever reports a false positive, this is
23
+ * the first line to suspect.
24
+ */
25
+ const LOCAL_USES =
26
+ /^[ \t]*uses:[ \t]*["']?\.\/\.github\/workflows\/[A-Za-z0-9._-]+\.ya?ml/u;
27
+ /** Whether a line is entirely a YAML comment. */
28
+ const COMMENT_LINE = /^\s*#/u;
29
+ /**
30
+ * Conclusions that can be reached before a single job is created.
31
+ *
32
+ * `startup_failure` is GitHub's own name for this class and is the conclusion
33
+ * a load failure most often carries; `failure` is what the runs measured for
34
+ * this ticket carried. Anything else — `success`, `cancelled`, `neutral` — is
35
+ * never a load failure, and treating it as one is how a detector starts
36
+ * inventing reds.
37
+ */
38
+ const PRE_JOB_FAILURES = ["failure", "startup_failure"];
39
+ /**
40
+ * Whether a caller workflow declares any reusable workflow.
41
+ *
42
+ * This is the population gate, and it is DERIVED from the caller's own source
43
+ * rather than read from a roster. There is deliberately no list of known
44
+ * callers or known upstream repositories anywhere in this module: a roster
45
+ * would silently exclude every caller nobody remembered to add, which is the
46
+ * failure mode this codebase already has a ticket for one level up.
47
+ * @param source - Full text of a caller's workflow file
48
+ * @returns True when at least one non-comment line calls a reusable workflow
49
+ */
50
+ export function callerDeclaresReusableWorkflow(source) {
51
+ return source
52
+ .split("\n")
53
+ .some(
54
+ line =>
55
+ !COMMENT_LINE.test(line) &&
56
+ (CROSS_REPO_USES.test(line) || LOCAL_USES.test(line))
57
+ );
58
+ }
59
+ /**
60
+ * Classify what a finished run says about reusable-workflow loading.
61
+ *
62
+ * The order of these tests is the whole design, so it is worth stating: a run
63
+ * that RESOLVED something is settled before the population gate is consulted,
64
+ * because resolution is direct evidence that loading worked and the gate is
65
+ * only a heuristic about whether the field can speak. That way an
66
+ * under-detecting population gate can never manufacture a load-failure
67
+ * verdict — it can only cause one to be missed, which is the direction a
68
+ * detector should fail in.
69
+ * @param facts - The four run facts
70
+ * @returns The single class this run belongs to
71
+ */
72
+ export function classifyRunLoad(facts) {
73
+ const { inPopulation, referencedCount, jobCount, conclusion } = facts;
74
+ if (conclusion === null) return "in-flight";
75
+ if (conclusion === "skipped") return "skipped";
76
+ // Resolution outranks the population gate: see the note above.
77
+ if (referencedCount > 0) return "resolved";
78
+ if (!inPopulation) return "out-of-population";
79
+ // Jobs that exist and are empty are a dead runner, not a parse failure.
80
+ if (jobCount > 0) return "dead-runner";
81
+ if (!PRE_JOB_FAILURES.includes(conclusion)) return "inconclusive";
82
+ return "load-failure";
83
+ }
84
+ /**
85
+ * Whether a page set actually covered the window it claims to report on.
86
+ *
87
+ * A first implementation of this detector read one page of runs and filtered
88
+ * by timestamp. On a busy repository the window's start fell off page one, so
89
+ * it reported "OK, 100 runs inspected" across a period that contained four
90
+ * known load failures. It was clean because it could not see. A scan that
91
+ * cannot cover its window must report an ERROR, never a pass — a detector
92
+ * that reports success for the range it failed to read is worse than no
93
+ * detector, because it also retires the suspicion.
94
+ * @param scan - How far back the page set reached
95
+ * @returns Covered, or not covered with the reason
96
+ */
97
+ export function windowCoverage(scan) {
98
+ const { oldestSeen, windowStart, exhausted } = scan;
99
+ // An exhausted history is covered even when it stops short: a repository
100
+ // younger than the window has no earlier runs to read, and refusing it
101
+ // would be a red nobody can ever clear.
102
+ if (exhausted) return { covered: true, reason: "" };
103
+ if (oldestSeen === null) {
104
+ return {
105
+ covered: false,
106
+ reason: `read no runs at all, so it did not reach ${windowStart}. An empty read is not an empty window.`,
107
+ };
108
+ }
109
+ const oldest = Date.parse(oldestSeen);
110
+ const start = Date.parse(windowStart);
111
+ if (Number.isNaN(oldest) || Number.isNaN(start)) {
112
+ return {
113
+ covered: false,
114
+ reason: `could not read \`${oldestSeen}\` or \`${windowStart}\` as a timestamp, so it did not reach the start of the window. An unreadable bound is not a satisfied one.`,
115
+ };
116
+ }
117
+ if (oldest > start) {
118
+ return {
119
+ covered: false,
120
+ reason: `paging stopped at ${oldestSeen} and did not reach ${windowStart}, so any load failure older than that was never read. Page further, or narrow the window.`,
121
+ };
122
+ }
123
+ return { covered: true, reason: "" };
124
+ }
@@ -0,0 +1,36 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ import { type RunLoadClass } from "./reusable-workflow-load-failure.mjs";
5
+ export interface ScannedRun {
6
+ readonly id: number;
7
+ readonly path: string;
8
+ readonly createdAt: string;
9
+ readonly referencedCount: number;
10
+ readonly jobCount: number;
11
+ readonly conclusion: string | null;
12
+ }
13
+ export interface RunPage {
14
+ readonly runs: readonly ScannedRun[];
15
+ readonly hasMore: boolean;
16
+ }
17
+ export interface ScanRequest {
18
+ readonly fetchPage: (page: number) => Promise<RunPage>;
19
+ readonly windowStart: string;
20
+ readonly inPopulation: (path: string) => boolean;
21
+ readonly maxPages: number;
22
+ }
23
+ export interface ScanFinding {
24
+ readonly id: number;
25
+ readonly path: string;
26
+ readonly verdict: RunLoadClass;
27
+ }
28
+ export interface ScanResult {
29
+ readonly loadFailures: readonly ScanFinding[];
30
+ readonly inspected: number;
31
+ readonly covered: boolean;
32
+ readonly reason: string;
33
+ }
34
+ export declare function scanForLoadFailures(
35
+ request: ScanRequest
36
+ ): Promise<ScanResult>;
@@ -0,0 +1,102 @@
1
+ // This file is managed by Lisa and IS replaced on each `lisa` run.
2
+ // Do not edit directly — durable changes belong upstream in Lisa.
3
+
4
+ import {
5
+ classifyRunLoad,
6
+ windowCoverage,
7
+ } from "./reusable-workflow-load-failure.mjs";
8
+ /**
9
+ * Whether a run predates the window, so everything after it can be ignored.
10
+ * @param run - The run being considered
11
+ * @param windowStart - ISO timestamp the scan intends to cover back to
12
+ * @returns True only when this run is at or before the start of the window
13
+ */
14
+ function reachedWindow(run, windowStart) {
15
+ const created = Date.parse(run.createdAt);
16
+ const start = Date.parse(windowStart);
17
+ // An unparseable timestamp must not read as "we are past the window" —
18
+ // that would end the scan early and call the short read complete.
19
+ if (Number.isNaN(created) || Number.isNaN(start)) return false;
20
+ return created <= start;
21
+ }
22
+ /**
23
+ * Classify one run through the shared decision.
24
+ * @param run - The run being classified
25
+ * @param inPopulation - Whether this run's caller declares a reusable workflow
26
+ * @returns The run's id, caller path, and verdict
27
+ */
28
+ function findingFor(run, inPopulation) {
29
+ return {
30
+ id: run.id,
31
+ path: run.path,
32
+ verdict: classifyRunLoad({
33
+ inPopulation,
34
+ referencedCount: run.referencedCount,
35
+ jobCount: run.jobCount,
36
+ conclusion: run.conclusion,
37
+ }),
38
+ };
39
+ }
40
+ /**
41
+ * Fold one page into the running scan state.
42
+ * @param state - Everything the scan has accumulated so far
43
+ * @param page - The page just fetched
44
+ * @param request - The scan request, for its window and population test
45
+ * @returns The scan state after absorbing this page
46
+ */
47
+ function absorb(state, page, request) {
48
+ const findings = page.runs
49
+ .filter(run => !reachedWindow(run, request.windowStart))
50
+ .map(run => findingFor(run, request.inPopulation(run.path)));
51
+ const oldest = page.runs.at(-1)?.createdAt ?? state.oldestSeen;
52
+ const reached = page.runs.some(run =>
53
+ reachedWindow(run, request.windowStart)
54
+ );
55
+ return {
56
+ findings: [...state.findings, ...findings],
57
+ inspected: state.inspected + page.runs.length,
58
+ oldestSeen: oldest,
59
+ exhausted: !page.hasMore,
60
+ done: reached || !page.hasMore,
61
+ };
62
+ }
63
+ /**
64
+ * Read run history until the window is covered, then report load failures.
65
+ *
66
+ * Paging stops on the first of three conditions: a run older than the window
67
+ * was seen, the history ran out, or `maxPages` was reached. Only the first
68
+ * two are coverage; hitting the cap leaves `covered` false, because a cap is
69
+ * a budget the operator set and not evidence about the runs beyond it.
70
+ * @param request - Fetcher, window, population test, and page cap
71
+ * @returns Findings plus whether the window was actually covered
72
+ */
73
+ export async function scanForLoadFailures(request) {
74
+ const initial = {
75
+ findings: [],
76
+ inspected: 0,
77
+ oldestSeen: null,
78
+ exhausted: false,
79
+ done: false,
80
+ };
81
+ const final = await Array.from(
82
+ { length: request.maxPages },
83
+ (_unused, index) => index + 1
84
+ ).reduce(async (carried, pageNumber) => {
85
+ const state = await carried;
86
+ if (state.done) return state;
87
+ return absorb(state, await request.fetchPage(pageNumber), request);
88
+ }, Promise.resolve(initial));
89
+ const coverage = windowCoverage({
90
+ oldestSeen: final.oldestSeen,
91
+ windowStart: request.windowStart,
92
+ exhausted: final.exhausted,
93
+ });
94
+ return {
95
+ loadFailures: final.findings.filter(
96
+ finding => finding.verdict === "load-failure"
97
+ ),
98
+ inspected: final.inspected,
99
+ covered: coverage.covered,
100
+ reason: coverage.reason,
101
+ };
102
+ }