@codyswann/lisa 4.5.0 → 4.6.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/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +4 -1
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +1 -1
- 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-package-paths.mjs +218 -57
- package/scripts/lib/workflow-contract-probe.mjs +445 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove that the artifact at a workflow's package path still honours the
|
|
3
|
+
* contract that workflow expects of it (issue #2982).
|
|
4
|
+
*
|
|
5
|
+
* `scripts/check-workflow-package-paths.mjs` (#2960) proves a workflow's
|
|
6
|
+
* package paths EXIST in the released package. Its own docblock says why that
|
|
7
|
+
* is only half the question: "a file can keep its path and change its contract
|
|
8
|
+
* ... only the PATH is legible from a workflow, so a contract probe needs a
|
|
9
|
+
* declaration this gate does not have and does not invent." This module is that
|
|
10
|
+
* declaration's engine.
|
|
11
|
+
*
|
|
12
|
+
* ## Why a declaration, and not inference
|
|
13
|
+
*
|
|
14
|
+
* A workflow step gives up exactly one legible fact: the path it resolves. What
|
|
15
|
+
* the artifact must DO — the flags it accepts, the exit code the step branches
|
|
16
|
+
* on, whether a prover that reports success examined anything — appears nowhere
|
|
17
|
+
* in the workflow. A guessed contract is the same failure in a new costume: a
|
|
18
|
+
* check that passes because it asked a question too weak to fail. So the
|
|
19
|
+
* contract is DECLARED, in the same file as the compatibility floor, for the
|
|
20
|
+
* same reason the floor is declared rather than counted.
|
|
21
|
+
*
|
|
22
|
+
* ## The two probe shapes, and the non-vacuity signal each yields
|
|
23
|
+
*
|
|
24
|
+
* The hard question #2982 poses is what "it examined N things" means for an
|
|
25
|
+
* artifact that is not a prover. Measured against three released tarballs, two
|
|
26
|
+
* shapes cover every executed path in this repository's workflows:
|
|
27
|
+
*
|
|
28
|
+
* **Prove.** Run the artifact with the workflow's own arguments against a
|
|
29
|
+
* fixture that contains something to examine, and require the count it reports
|
|
30
|
+
* to exceed a declared minimum. `check-conflict-markers.mjs --root .` says "no
|
|
31
|
+
* leftover conflict markers in 2 tracked files"; a released copy that scans
|
|
32
|
+
* nothing says 0 and fails. That is #2951's defect, stated as an assertion.
|
|
33
|
+
*
|
|
34
|
+
* **Refuse.** Hand the artifact a deliberately invalid value for the very
|
|
35
|
+
* parameter the workflow supplies, and require it to reject the value AND
|
|
36
|
+
* enumerate the domain it would have accepted. `dispatch.mjs` answers "unknown
|
|
37
|
+
* executionEnv ... Supported: local, codex-cloud, claude-web"; the non-vacuity
|
|
38
|
+
* count is the size of that enumerated domain, and `contains` pins the exact
|
|
39
|
+
* token the workflow passes. A released copy that lost the flag prints a
|
|
40
|
+
* generic usage line, enumerates nothing, and fails. Refuse probes have no side
|
|
41
|
+
* effects at all, which is what makes it safe to run a dispatcher and a secret
|
|
42
|
+
* rotator out of a tarball.
|
|
43
|
+
*
|
|
44
|
+
* Both reduce to one thing: a regex with ONE capture group, whose captured
|
|
45
|
+
* domain must be large enough and must contain what the workflow depends on.
|
|
46
|
+
*
|
|
47
|
+
* ## Refusing to pass on nothing
|
|
48
|
+
*
|
|
49
|
+
* A probe that resolved no artifact, could not read its output, or matched its
|
|
50
|
+
* signal zero times FAILS. It does not report all-clear, because an empty
|
|
51
|
+
* inspection and a satisfied contract are otherwise the same green. A child
|
|
52
|
+
* killed at its deadline returns EMPTY streams — which reads exactly like an
|
|
53
|
+
* absent signal — so a timeout is reported as an operational failure and never
|
|
54
|
+
* as a contract violation.
|
|
55
|
+
* @module scripts/lib/workflow-contract-probe
|
|
56
|
+
*/
|
|
57
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
58
|
+
import path from "node:path";
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
boundedExecFileSync,
|
|
62
|
+
boundedSpawnSync,
|
|
63
|
+
isChildTimeout,
|
|
64
|
+
} from "./bounded-spawn.mjs";
|
|
65
|
+
|
|
66
|
+
/** How a declaration may describe the artifact at a package path. */
|
|
67
|
+
export const CONTRACT_KINDS = new Set(["executed", "reference"]);
|
|
68
|
+
|
|
69
|
+
/** How a probe's captured domain is turned into a count. */
|
|
70
|
+
export const SIGNAL_SHAPES = new Set(["count", "list", "json-object-keys"]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What separates one token from the next inside a captured domain.
|
|
74
|
+
* @remarks
|
|
75
|
+
* The word alternatives precede the whitespace one deliberately: the leading
|
|
76
|
+
* `\s*` has already consumed the space before `and`, so `and` is matched as a
|
|
77
|
+
* separator rather than counted as a member of the domain it joins.
|
|
78
|
+
*/
|
|
79
|
+
const TOKEN_SEPARATOR = /\s*(?:,|\||\band\b|\bor\b|\s+)\s*/;
|
|
80
|
+
|
|
81
|
+
/** How long one probe may run before it is killed and reported as such. */
|
|
82
|
+
const PROBE_BUDGET_MS = 120_000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compare two `major.minor.patch` versions.
|
|
86
|
+
* @param {string} left - A version
|
|
87
|
+
* @param {string} right - Another version
|
|
88
|
+
* @returns {number} Negative when left is older, positive when newer, 0 equal
|
|
89
|
+
*/
|
|
90
|
+
export function compareVersions(left, right) {
|
|
91
|
+
const parse = version =>
|
|
92
|
+
version.split(".").map(part => Number.parseInt(part, 10) || 0);
|
|
93
|
+
const a = parse(left);
|
|
94
|
+
const b = parse(right);
|
|
95
|
+
for (let index = 0; index < 3; index += 1) {
|
|
96
|
+
if ((a[index] ?? 0) !== (b[index] ?? 0))
|
|
97
|
+
return (a[index] ?? 0) - (b[index] ?? 0);
|
|
98
|
+
}
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reject a probe declaration that could not fail.
|
|
104
|
+
* @param {string} where - The package path being validated
|
|
105
|
+
* @param {object} probe - One declared probe
|
|
106
|
+
* @returns {void}
|
|
107
|
+
* @throws {Error} When the probe is malformed
|
|
108
|
+
*/
|
|
109
|
+
function validateProbe(where, probe) {
|
|
110
|
+
if (!Array.isArray(probe.argv)) {
|
|
111
|
+
throw new Error(`contracts["${where}"]: a probe needs an "argv" array`);
|
|
112
|
+
}
|
|
113
|
+
if (!Number.isInteger(probe.expectExit)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`contracts["${where}"]: a probe needs an integer "expectExit"`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const signal = probe.signal;
|
|
119
|
+
if (!signal || typeof signal.pattern !== "string") {
|
|
120
|
+
throw new Error(`contracts["${where}"]: a probe needs signal.pattern`);
|
|
121
|
+
}
|
|
122
|
+
if (!SIGNAL_SHAPES.has(signal.shape)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`contracts["${where}"]: signal.shape must be one of ${[...SIGNAL_SHAPES].join(", ")}`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!Number.isInteger(signal.min) || signal.min < 1) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`contracts["${where}"]: signal.min must be a positive integer — a probe whose minimum is zero cannot tell an empty inspection from a satisfied contract`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (typeof probe.why !== "string" || probe.why.length < 40) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`contracts["${where}"]: a probe needs a "why" recording what the workflow depends on`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Read and validate the contract half of the floor declaration.
|
|
141
|
+
* @param {object} declared - Parsed `.github/workflow-package-floor.json`
|
|
142
|
+
* @returns {{contracts: Record<string, object>, fixtures: Record<string, object>}}
|
|
143
|
+
* @throws {Error} When the declaration is missing or malformed
|
|
144
|
+
*/
|
|
145
|
+
export function readContractDeclaration(declared) {
|
|
146
|
+
const contracts = declared.contracts;
|
|
147
|
+
const fixtures = declared.fixtures ?? {};
|
|
148
|
+
if (!contracts || typeof contracts !== "object" || Array.isArray(contracts)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'the declaration has no "contracts" object. Every package path a workflow resolves must declare what the artifact there does — an undeclared path is a path whose contract nobody checked, which is the gap #2982 exists to close.'
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
for (const [where, entry] of Object.entries(contracts)) {
|
|
154
|
+
if (!CONTRACT_KINDS.has(entry?.kind)) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`contracts["${where}"]: kind must be one of ${[...CONTRACT_KINDS].join(", ")}`
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (typeof entry.why !== "string" || entry.why.length < 40) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`contracts["${where}"]: needs a "why" a later reader can argue with`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (entry.kind === "reference") continue;
|
|
165
|
+
if (!Array.isArray(entry.probes) || entry.probes.length === 0) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`contracts["${where}"]: kind "executed" needs at least one probe. Declaring an executed artifact with no probe is existence-only treatment wearing a contract's name.`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
for (const probe of entry.probes) {
|
|
171
|
+
validateProbe(where, probe);
|
|
172
|
+
if (
|
|
173
|
+
probe.fixture !== undefined &&
|
|
174
|
+
fixtures[probe.fixture] === undefined
|
|
175
|
+
) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`contracts["${where}"]: no fixture named "${probe.fixture}" is declared`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { contracts, fixtures };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Which referenced paths carry no declaration, and which declarations are stale.
|
|
187
|
+
* @param {readonly {workflow: string, step: string, paths: readonly string[]}[]} groups - Step claims
|
|
188
|
+
* @param {Record<string, object>} contracts - Declared contracts
|
|
189
|
+
* @returns {{undeclared: readonly string[], stale: readonly string[]}} Operator-readable lines
|
|
190
|
+
*/
|
|
191
|
+
export function declarationGaps(groups, contracts) {
|
|
192
|
+
const referenced = new Set(groups.flatMap(group => group.paths));
|
|
193
|
+
const undeclared = groups.flatMap(group =>
|
|
194
|
+
group.paths
|
|
195
|
+
.filter(candidate => contracts[candidate] === undefined)
|
|
196
|
+
.map(
|
|
197
|
+
candidate =>
|
|
198
|
+
`${group.workflow} step "${group.step}" resolves node_modules/@codyswann/lisa/${candidate}, which declares no contract. Add it to "contracts" in .github/workflow-package-floor.json — as "executed" with a probe if the step runs it, or "reference" with a reason if it does not.`
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
const stale = Object.keys(contracts)
|
|
202
|
+
.filter(candidate => !referenced.has(candidate))
|
|
203
|
+
.map(
|
|
204
|
+
candidate =>
|
|
205
|
+
`contracts["${candidate}"] is declared but no workflow references it. A declaration nobody reaches is a probe that never runs; delete it or restore the reference.`
|
|
206
|
+
);
|
|
207
|
+
return { undeclared: [...new Set(undeclared)], stale };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a probe's fixture on disk.
|
|
212
|
+
* @remarks
|
|
213
|
+
* `git: true` stages the files rather than committing them. `git ls-files`
|
|
214
|
+
* reads the index, so staging is enough, and it avoids needing a committer
|
|
215
|
+
* identity that a CI runner may not have configured. `GIT_*` variables are
|
|
216
|
+
* stripped so an ambient environment cannot reach into the fixture.
|
|
217
|
+
* @param {object} fixture - Declared fixture: `{git?: boolean, files: Record<string,string>}`
|
|
218
|
+
* @param {string} dir - Directory to build it in
|
|
219
|
+
* @returns {void}
|
|
220
|
+
*/
|
|
221
|
+
export function materialiseFixture(fixture, dir) {
|
|
222
|
+
mkdirSync(dir, { recursive: true });
|
|
223
|
+
for (const [relative, contents] of Object.entries(fixture.files ?? {})) {
|
|
224
|
+
const target = path.join(dir, relative);
|
|
225
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
226
|
+
writeFileSync(target, contents);
|
|
227
|
+
}
|
|
228
|
+
if (fixture.git !== true) return;
|
|
229
|
+
const env = Object.fromEntries(
|
|
230
|
+
Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_"))
|
|
231
|
+
);
|
|
232
|
+
boundedExecFileSync("git", ["init", "-q", dir], { env, stdio: "ignore" });
|
|
233
|
+
boundedExecFileSync("git", ["-C", dir, "add", "-A"], {
|
|
234
|
+
env,
|
|
235
|
+
stdio: "ignore",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The domain a probe's signal captured, and how big it is.
|
|
241
|
+
* @param {string} output - The artifact's combined stdout and stderr
|
|
242
|
+
* @param {object} signal - The declared signal
|
|
243
|
+
* @returns {{text: string|null, count: number}} Captured domain and its size
|
|
244
|
+
*/
|
|
245
|
+
export function capturedDomain(output, signal) {
|
|
246
|
+
if (signal.shape === "json-object-keys") {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(output.trim());
|
|
249
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
250
|
+
return { text: null, count: 0 };
|
|
251
|
+
}
|
|
252
|
+
const keys = Object.keys(parsed);
|
|
253
|
+
return { text: keys.join(", "), count: keys.length };
|
|
254
|
+
} catch {
|
|
255
|
+
return { text: null, count: 0 };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const match = new RegExp(signal.pattern, "m").exec(output);
|
|
259
|
+
const captured = match?.[1];
|
|
260
|
+
if (captured === undefined) return { text: null, count: 0 };
|
|
261
|
+
if (signal.shape === "count") {
|
|
262
|
+
const parsed = Number.parseInt(captured, 10);
|
|
263
|
+
return { text: captured, count: Number.isInteger(parsed) ? parsed : 0 };
|
|
264
|
+
}
|
|
265
|
+
const tokens = captured
|
|
266
|
+
.split(TOKEN_SEPARATOR)
|
|
267
|
+
.map(token => token.trim())
|
|
268
|
+
.filter(Boolean);
|
|
269
|
+
return { text: captured, count: tokens.length };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Judge one probe run against its declaration.
|
|
274
|
+
* @param {{status: number|null, output: string}} run - What the artifact did
|
|
275
|
+
* @param {object} probe - The declared probe
|
|
276
|
+
* @returns {{ok: boolean, count: number, reason: string|null}} The verdict
|
|
277
|
+
*/
|
|
278
|
+
export function evaluateProbe(run, probe) {
|
|
279
|
+
const domain = capturedDomain(run.output, probe.signal);
|
|
280
|
+
if (run.status !== probe.expectExit) {
|
|
281
|
+
return {
|
|
282
|
+
ok: false,
|
|
283
|
+
count: domain.count,
|
|
284
|
+
reason: `exited ${run.status} where the workflow depends on ${probe.expectExit}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if (domain.text === null) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
count: 0,
|
|
291
|
+
reason: `emitted no ${probe.signal.shape} signal matching ${probe.signal.pattern} — it ran, and proved nothing`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (domain.count < probe.signal.min) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
count: domain.count,
|
|
298
|
+
reason: `signal counted ${domain.count}, below the declared minimum of ${probe.signal.min} — an inspection this empty is indistinguishable from a satisfied contract`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const missing = (probe.signal.contains ?? []).filter(
|
|
302
|
+
token => !domain.text.includes(token)
|
|
303
|
+
);
|
|
304
|
+
if (missing.length > 0) {
|
|
305
|
+
return {
|
|
306
|
+
ok: false,
|
|
307
|
+
count: domain.count,
|
|
308
|
+
reason: `enumerated "${domain.text}", which is missing ${missing.join(", ")} — the workflow passes that value`,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return { ok: true, count: domain.count, reason: null };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Run one probe against one released artifact.
|
|
316
|
+
* @param {object} input - `{artifact, probe, fixtures, workDir}`
|
|
317
|
+
* @returns {{status: number|null, output: string, timedOut: boolean}} What happened
|
|
318
|
+
*/
|
|
319
|
+
export function runProbe({ artifact, probe, fixtures, workDir }) {
|
|
320
|
+
if (probe.fixture !== undefined) {
|
|
321
|
+
materialiseFixture(fixtures[probe.fixture], workDir);
|
|
322
|
+
} else {
|
|
323
|
+
mkdirSync(workDir, { recursive: true });
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const result = boundedSpawnSync(
|
|
327
|
+
process.execPath,
|
|
328
|
+
[artifact, ...probe.argv],
|
|
329
|
+
{
|
|
330
|
+
cwd: workDir,
|
|
331
|
+
encoding: "utf8",
|
|
332
|
+
timeout: PROBE_BUDGET_MS,
|
|
333
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
334
|
+
env: { ...process.env, CI: "1", NO_COLOR: "1" },
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
return {
|
|
338
|
+
status: result.status,
|
|
339
|
+
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
|
340
|
+
timedOut: false,
|
|
341
|
+
};
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (isChildTimeout(error))
|
|
344
|
+
return { status: null, output: "", timedOut: true };
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The path a step would actually EXECUTE in a given release.
|
|
351
|
+
* @remarks
|
|
352
|
+
* Mirrors the workflow's own `for candidate in ...; break` loop, with one
|
|
353
|
+
* refinement: a path declared `reference` is skipped. A directory named in a
|
|
354
|
+
* step's error prose exists in every release and would otherwise shadow the
|
|
355
|
+
* artifact the step really runs — which would quietly retire the probe.
|
|
356
|
+
* @param {readonly string[]} paths - The step's candidates, in workflow order
|
|
357
|
+
* @param {(candidate: string) => boolean} exists - Does this release carry it?
|
|
358
|
+
* @param {Record<string, object>} contracts - Declared contracts
|
|
359
|
+
* @returns {string|null} The executed path, or null when the step runs nothing
|
|
360
|
+
*/
|
|
361
|
+
export function resolveExecutedPath(paths, exists, contracts) {
|
|
362
|
+
return (
|
|
363
|
+
paths.find(
|
|
364
|
+
candidate =>
|
|
365
|
+
contracts[candidate]?.kind === "executed" && exists(candidate)
|
|
366
|
+
) ?? null
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Probe every executed path every step resolves, in every release.
|
|
372
|
+
* @remarks
|
|
373
|
+
* Deduplicated by release, path and probe: `lisa-gates.mjs` is resolved by more
|
|
374
|
+
* than forty steps, and running the same probe forty times per release would
|
|
375
|
+
* cost minutes and prove nothing extra.
|
|
376
|
+
* @param {object} input - `{groups, releases, contracts, fixtures, workRoot}`
|
|
377
|
+
* @returns {{executed: number, violations: readonly string[], deferred: readonly string[], operational: readonly string[], probed: readonly string[]}}
|
|
378
|
+
*/
|
|
379
|
+
export function probeReleases({
|
|
380
|
+
groups,
|
|
381
|
+
releases,
|
|
382
|
+
contracts,
|
|
383
|
+
fixtures,
|
|
384
|
+
workRoot,
|
|
385
|
+
}) {
|
|
386
|
+
const seen = new Set();
|
|
387
|
+
const violations = [];
|
|
388
|
+
const deferred = [];
|
|
389
|
+
const operational = [];
|
|
390
|
+
const probed = [];
|
|
391
|
+
for (const release of releases) {
|
|
392
|
+
for (const group of groups) {
|
|
393
|
+
const target = resolveExecutedPath(
|
|
394
|
+
group.paths,
|
|
395
|
+
release.contains,
|
|
396
|
+
contracts
|
|
397
|
+
);
|
|
398
|
+
if (target === null) continue;
|
|
399
|
+
contracts[target].probes.forEach((probe, index) => {
|
|
400
|
+
const key = `${release.version}::${target}::${index}`;
|
|
401
|
+
if (seen.has(key)) return;
|
|
402
|
+
seen.add(key);
|
|
403
|
+
const where = `${release.version}: node_modules/@codyswann/lisa/${target}`;
|
|
404
|
+
if (
|
|
405
|
+
probe.since !== undefined &&
|
|
406
|
+
compareVersions(release.version, probe.since) < 0
|
|
407
|
+
) {
|
|
408
|
+
deferred.push(
|
|
409
|
+
`${where} — probe requires ${probe.since} or newer; the workflow degrades instead: ${probe.degradation}`
|
|
410
|
+
);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const artifact = path.join(release.root, target);
|
|
414
|
+
const run = runProbe({
|
|
415
|
+
artifact,
|
|
416
|
+
probe,
|
|
417
|
+
fixtures,
|
|
418
|
+
workDir: path.join(workRoot, key.replace(/[^A-Za-z0-9]+/g, "_")),
|
|
419
|
+
});
|
|
420
|
+
if (run.timedOut) {
|
|
421
|
+
operational.push(
|
|
422
|
+
`${where} — the probe was killed at its ${PROBE_BUDGET_MS}ms deadline. A killed child returns EMPTY streams, so this would otherwise read as an absent signal; it is reported as "could not look" instead.`
|
|
423
|
+
);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const verdict = evaluateProbe(run, probe);
|
|
427
|
+
probed.push(
|
|
428
|
+
`${where} — ${probe.why} [signal counted ${verdict.count}]`
|
|
429
|
+
);
|
|
430
|
+
if (!verdict.ok) {
|
|
431
|
+
violations.push(
|
|
432
|
+
`${where} exists, and ${verdict.reason}. Expected because ${probe.why}. The path check passes here; the contract does not.`
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
executed: probed.length,
|
|
440
|
+
violations,
|
|
441
|
+
deferred,
|
|
442
|
+
operational,
|
|
443
|
+
probed,
|
|
444
|
+
};
|
|
445
|
+
}
|