@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.
Files changed (56) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +4 -1
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/package.json +1 -1
  5. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  6. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  7. package/plugins/lisa-agy/plugin.json +1 -1
  8. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  9. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  10. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  11. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  13. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  14. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa-expo-agy/plugin.json +1 -1
  18. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  22. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  23. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  24. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  27. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  28. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  29. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  32. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  33. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  37. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  38. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  39. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  42. package/plugins/lisa-rails-agy/plugin.json +1 -1
  43. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  44. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  47. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  48. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  52. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  53. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  55. package/scripts/check-workflow-package-paths.mjs +218 -57
  56. 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
+ }