@codyswann/lisa 3.0.0 โ†’ 3.2.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 (139) hide show
  1. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  2. package/dist/core/upstream-evidence-manifest.js +50 -23
  3. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  4. package/expo/copy-overwrite/scripts/bdd/baseline.mjs +211 -121
  5. package/expo/copy-overwrite/scripts/bdd/contract.mjs +10 -2
  6. package/expo/copy-overwrite/scripts/bdd/envelope.mjs +3 -2
  7. package/expo/copy-overwrite/scripts/bdd/render.mjs +2 -2
  8. package/expo/copy-overwrite/scripts/check-bdd-coverage.mjs +45 -8
  9. package/expo/copy-overwrite/scripts/classify-maestro-failures.mjs +775 -0
  10. package/expo/create-only/.github/workflows/nightly-e2e-report.yml +71 -0
  11. package/expo/create-only/.maestro/flake-classification.json +21 -0
  12. package/expo/create-only/bdd/coverage-map.json +1 -2
  13. package/expo/package-lisa/package.lisa.json +1 -0
  14. package/package.json +1 -1
  15. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  16. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  17. package/plugins/lisa/.codex-plugin/skills/lisa-atlassian-access/SKILL.md +75 -64
  18. package/plugins/lisa/.codex-plugin/skills/lisa-jam-access/SKILL.md +13 -5
  19. package/plugins/lisa/.codex-plugin/skills/lisa-linear-access/SKILL.md +30 -10
  20. package/plugins/lisa/.codex-plugin/skills/lisa-notion-access/SKILL.md +36 -23
  21. package/plugins/lisa/.codex-plugin/skills/lisa-posthog-access/SKILL.md +16 -6
  22. package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/SKILL.md +8 -0
  23. package/plugins/lisa/.codex-plugin/skills/lisa-sentry-access/SKILL.md +16 -6
  24. package/plugins/lisa/.codex-plugin/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  25. package/plugins/lisa/hooks/threshold-ratchet-families.mjs +24 -0
  26. package/plugins/lisa/rules/eager/credential-substrate-precedence.md +52 -0
  27. package/plugins/lisa/rules/eager/integration-access-layer.md +7 -3
  28. package/plugins/lisa/rules/reference/bdd-e2e-coverage.md +19 -8
  29. package/plugins/lisa/rules/reference/credential-substrate-precedence.md +166 -0
  30. package/plugins/lisa/rules/reference/integration-access-layer.md +27 -15
  31. package/plugins/lisa/skills/lisa-atlassian-access/SKILL.md +76 -65
  32. package/plugins/lisa/skills/lisa-jam-access/SKILL.md +14 -6
  33. package/plugins/lisa/skills/lisa-linear-access/SKILL.md +31 -11
  34. package/plugins/lisa/skills/lisa-notion-access/SKILL.md +37 -24
  35. package/plugins/lisa/skills/lisa-posthog-access/SKILL.md +17 -7
  36. package/plugins/lisa/skills/lisa-secrets-access/SKILL.md +8 -0
  37. package/plugins/lisa/skills/lisa-sentry-access/SKILL.md +17 -7
  38. package/plugins/lisa/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  39. package/plugins/lisa-agy/plugin.json +1 -1
  40. package/plugins/lisa-agy/skills/lisa-atlassian-access/SKILL.md +76 -65
  41. package/plugins/lisa-agy/skills/lisa-jam-access/SKILL.md +14 -6
  42. package/plugins/lisa-agy/skills/lisa-linear-access/SKILL.md +31 -11
  43. package/plugins/lisa-agy/skills/lisa-notion-access/SKILL.md +37 -24
  44. package/plugins/lisa-agy/skills/lisa-posthog-access/SKILL.md +17 -7
  45. package/plugins/lisa-agy/skills/lisa-secrets-access/SKILL.md +8 -0
  46. package/plugins/lisa-agy/skills/lisa-sentry-access/SKILL.md +17 -7
  47. package/plugins/lisa-agy/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  48. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  50. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  51. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  54. package/plugins/lisa-copilot/hooks/threshold-ratchet-families.mjs +24 -0
  55. package/plugins/lisa-copilot/rules/eager/credential-substrate-precedence.md +52 -0
  56. package/plugins/lisa-copilot/rules/eager/integration-access-layer.md +7 -3
  57. package/plugins/lisa-copilot/rules/reference/bdd-e2e-coverage.md +19 -8
  58. package/plugins/lisa-copilot/rules/reference/credential-substrate-precedence.md +166 -0
  59. package/plugins/lisa-copilot/rules/reference/integration-access-layer.md +27 -15
  60. package/plugins/lisa-copilot/skills/lisa-atlassian-access/SKILL.md +76 -65
  61. package/plugins/lisa-copilot/skills/lisa-jam-access/SKILL.md +14 -6
  62. package/plugins/lisa-copilot/skills/lisa-linear-access/SKILL.md +31 -11
  63. package/plugins/lisa-copilot/skills/lisa-notion-access/SKILL.md +37 -24
  64. package/plugins/lisa-copilot/skills/lisa-posthog-access/SKILL.md +17 -7
  65. package/plugins/lisa-copilot/skills/lisa-secrets-access/SKILL.md +8 -0
  66. package/plugins/lisa-copilot/skills/lisa-sentry-access/SKILL.md +17 -7
  67. package/plugins/lisa-copilot/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  68. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-cursor/hooks/threshold-ratchet-families.mjs +24 -0
  70. package/plugins/lisa-cursor/rules/bdd-e2e-coverage-reference.mdc +19 -8
  71. package/plugins/lisa-cursor/rules/credential-substrate-precedence-reference.mdc +171 -0
  72. package/plugins/lisa-cursor/rules/credential-substrate-precedence.mdc +57 -0
  73. package/plugins/lisa-cursor/rules/integration-access-layer-reference.mdc +27 -15
  74. package/plugins/lisa-cursor/rules/integration-access-layer.mdc +7 -3
  75. package/plugins/lisa-cursor/skills/lisa-atlassian-access/SKILL.md +76 -65
  76. package/plugins/lisa-cursor/skills/lisa-jam-access/SKILL.md +14 -6
  77. package/plugins/lisa-cursor/skills/lisa-linear-access/SKILL.md +31 -11
  78. package/plugins/lisa-cursor/skills/lisa-notion-access/SKILL.md +37 -24
  79. package/plugins/lisa-cursor/skills/lisa-posthog-access/SKILL.md +17 -7
  80. package/plugins/lisa-cursor/skills/lisa-secrets-access/SKILL.md +8 -0
  81. package/plugins/lisa-cursor/skills/lisa-sentry-access/SKILL.md +17 -7
  82. package/plugins/lisa-cursor/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  83. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  85. package/plugins/lisa-expo-agy/plugin.json +1 -1
  86. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  87. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  90. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  91. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  92. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  95. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  96. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  97. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  99. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  100. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  101. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  102. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  103. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  104. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  105. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  106. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  107. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  108. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  109. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  110. package/plugins/lisa-rails-agy/plugin.json +1 -1
  111. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  112. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  113. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  114. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  115. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  116. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  117. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  118. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  119. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  120. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  121. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  122. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  123. package/plugins/src/base/hooks/threshold-ratchet-families.mjs +24 -0
  124. package/plugins/src/base/rules/eager/credential-substrate-precedence.md +52 -0
  125. package/plugins/src/base/rules/eager/integration-access-layer.md +7 -3
  126. package/plugins/src/base/rules/reference/bdd-e2e-coverage.md +19 -8
  127. package/plugins/src/base/rules/reference/credential-substrate-precedence.md +166 -0
  128. package/plugins/src/base/rules/reference/integration-access-layer.md +27 -15
  129. package/plugins/src/base/skills/lisa-atlassian-access/SKILL.md +76 -65
  130. package/plugins/src/base/skills/lisa-jam-access/SKILL.md +14 -6
  131. package/plugins/src/base/skills/lisa-linear-access/SKILL.md +31 -11
  132. package/plugins/src/base/skills/lisa-notion-access/SKILL.md +37 -24
  133. package/plugins/src/base/skills/lisa-posthog-access/SKILL.md +17 -7
  134. package/plugins/src/base/skills/lisa-secrets-access/SKILL.md +8 -0
  135. package/plugins/src/base/skills/lisa-sentry-access/SKILL.md +17 -7
  136. package/plugins/src/base/skills/lisa-sonarcloud-access/SKILL.md +7 -1
  137. package/rails/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
  138. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +631 -5
  139. package/typescript/copy-overwrite/scripts/threshold-ratchet-families.mjs +24 -0
@@ -0,0 +1,775 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * classify-maestro-failures โ€” tell a preamble loss apart from a product
4
+ * regression when reading a Maestro JUnit report.
5
+ *
6
+ * Usage:
7
+ * node scripts/classify-maestro-failures.mjs <report.xml> [more.xml ...]
8
+ * node scripts/classify-maestro-failures.mjs --json <report.xml>
9
+ * node scripts/classify-maestro-failures.mjs --markdown <report.xml>
10
+ * node scripts/classify-maestro-failures.mjs --platform=android <report.xml>
11
+ *
12
+ * ## This is a DIAGNOSTIC, never a gate
13
+ *
14
+ * It scores a run so a reader can attribute a red night correctly; it does not
15
+ * decide anything. It exits 0 on every readable report โ€” including one full of
16
+ * product failures โ€” and the only non-zero exit is usage (no report named).
17
+ * The nightly e2e merge gate (`check-nightly-e2e-health.mjs`,
18
+ * `docs/nightly-e2e-gate.md`) is the thing that blocks, it reads run history
19
+ * rather than artifacts, and nothing here feeds it. Keep it that way: a
20
+ * heuristic that can turn a red run green is a fail-open path, and a heuristic
21
+ * that can turn a green run red is a flaky gate.
22
+ *
23
+ * ## Why this exists
24
+ *
25
+ * Every authenticated flow runs a sign-in preamble before it asserts anything
26
+ * about the product. When the preamble fails, the flow reds having tested
27
+ * nothing โ€” and it reds on an assertion naming a preamble gate, not the
28
+ * feature, so a reader scoring the run by flow name counts it as a product
29
+ * regression. Upstreamed from TunnlAI/frontend, where proof runs 6
30
+ * (31424664541) and 7 (31446155638) lost four flows each that way; run 6's
31
+ * Android arm was written up as a possible product regression and every one of
32
+ * those flows came back green in run 7 with no code addressing them.
33
+ *
34
+ * ## How a failure is classified
35
+ *
36
+ * The preamble gate identities are DERIVED from the preamble subflows rather
37
+ * than hardcoded: a hardcoded filename list silently stops matching the moment
38
+ * somebody edits a gate, and the failure mode of that drift is preamble noise
39
+ * quietly re-entering the product column.
40
+ *
41
+ * For each failing test case:
42
+ *
43
+ * 1. Resolve which subflows the flow transitively runs (`runFlow`).
44
+ * 2. Split them into PREAMBLE subflows and everything else. A subflow is a
45
+ * preamble iff it transitively performs the sign-in sequence โ€” it touches
46
+ * one of the project's sign-in marker selectors. That is an objective
47
+ * trait read off the file, not a filename list to maintain.
48
+ * 3. If the failing assertion names a gate from a preamble subflow, and NO
49
+ * other surface the flow runs asserts that same gate, it is a PREAMBLE
50
+ * loss.
51
+ *
52
+ * Navigation helpers are deliberately NOT preambles. A helper that opens a
53
+ * detail screen runs mid-scenario, after sign-in, as the flow's own product
54
+ * work; a flow that dies in one HAS begun testing its subject and its failure
55
+ * belongs in the product column.
56
+ *
57
+ * ## The tie-break always favors the product column
58
+ *
59
+ * A selector the flow's own body asserts, or that a non-preamble subflow
60
+ * asserts, is reported as a product failure even when a preamble shares it.
61
+ * Misfiling a preamble loss as a product regression costs a reader some time;
62
+ * misfiling a real product regression as preamble noise HIDES it, and this
63
+ * whole file exists because hidden signal is expensive. Make the cheap error.
64
+ *
65
+ * The same asymmetry sets the default behavior of an UNCONFIGURED project: with
66
+ * no sign-in markers that match anything, no subflow qualifies as a preamble
67
+ * and every failure reads as product. Silence in the config produces the safe
68
+ * column, never the flattering one.
69
+ *
70
+ * ## Elapsed-at-gate, and why it is reported
71
+ *
72
+ * `extendedWaitUntil` polls until its ceiling and only then asserts, so a
73
+ * timed-out gate has always consumed its FULL timeout โ€” verified on Maestro
74
+ * 2.7.0, where a 3000ms gate failed a flow at 5s against a ~2s launch. That
75
+ * makes `elapsed_at_gate = flow_duration - gate_ceiling` the time the flow took
76
+ * to REACH the gate, which is the measurement that decides whether a gate is
77
+ * under-tolerant or the device is unstable:
78
+ *
79
+ * - reach-time near the healthy value, gate expired => the screen never
80
+ * came; raising the ceiling has no measured basis.
81
+ * - reach-time far above the healthy value => the arm is degraded
82
+ * and tolerance is the wrong lever anyway.
83
+ *
84
+ * No gate should be raised again without quoting this number.
85
+ *
86
+ * ## Known-intermittent registry
87
+ *
88
+ * `.maestro/flake-classification.json` (create-only; the project owns it) also
89
+ * carries the flows that fail SOMETIMES on an unchanged build. Every entry must
90
+ * carry a measured rate and the methodology behind it, and an entry that does
91
+ * not is reported as a registry defect and annotates nothing. That rule is the
92
+ * whole point: an unmeasured "known flake" entry is how a real regression gets
93
+ * dismissed, so a claim with no measurement behind it must have no power to
94
+ * excuse a failure.
95
+ *
96
+ * The contract this file implements is `docs/maestro-flake-classification.md`
97
+ * in Lisa; the non-gating property is proved by executing the workflow step's
98
+ * own shell in `tests/integration/maestro-native-flake-classification.test.ts`.
99
+ *
100
+ * @module scripts/classify-maestro-failures
101
+ */
102
+ import * as fs from "node:fs";
103
+ import * as path from "node:path";
104
+ import { pathToFileURL } from "node:url";
105
+
106
+ /**
107
+ * Wait commands whose `timeout:` is a ceiling the command burns before failing.
108
+ *
109
+ * `scrollUntilVisible` is included: it also polls to a ceiling and reports a
110
+ * not-found against its `element:` selector.
111
+ */
112
+ const GATE_COMMANDS = ["extendedWaitUntil", "scrollUntilVisible"];
113
+
114
+ /**
115
+ * Selectors whose presence marks a subflow as performing sign-in.
116
+ *
117
+ * These are the REFERENCE project's selectors, shipped as a starting default
118
+ * and meant to be replaced in `.maestro/flake-classification.json` with the
119
+ * ones your own sign-in flow uses. Leaving them unmatched is safe by
120
+ * construction โ€” nothing qualifies as a preamble, so every failure reads as
121
+ * product, which is the direction this tool is required to err in.
122
+ */
123
+ export const DEFAULT_SIGN_IN_MARKERS = [
124
+ "landing:sign-in",
125
+ "signin:email-input",
126
+ ];
127
+
128
+ /** Project-owned configuration file, relative to the project root. */
129
+ export const CONFIG_REL_PATH = path.join(
130
+ ".maestro",
131
+ "flake-classification.json"
132
+ );
133
+
134
+ /** Methodology strings that assert nothing and must not be accepted as one. */
135
+ const PLACEHOLDER_METHODS = new Set([
136
+ "",
137
+ "-",
138
+ "n/a",
139
+ "na",
140
+ "tbd",
141
+ "todo",
142
+ "unknown",
143
+ "none",
144
+ "?",
145
+ ]);
146
+
147
+ /**
148
+ * Pull `id:`/`text:` selectors and their ceilings out of a flow's YAML source.
149
+ *
150
+ * Deliberately a line scanner rather than a YAML parse: this script must run
151
+ * with zero dependencies in a CI job that has not installed anything, and the
152
+ * shapes Maestro accepts under a wait command are uniform enough that a scanner
153
+ * reads them exactly. It tracks the ceiling seen most recently inside the
154
+ * current gate block so a selector is paired with its own timeout.
155
+ * @param {string} source - Flow YAML source
156
+ * @returns {{kind: string, selector: string, timeoutMs: number | null}[]} Gates
157
+ */
158
+ export function extractGates(source) {
159
+ const lines = source.split("\n");
160
+ const gates = [];
161
+ let inGate = false;
162
+ let indent = 0;
163
+ let pending = [];
164
+ let timeout = null;
165
+
166
+ const flush = () => {
167
+ for (const selector of pending) {
168
+ gates.push({ ...selector, timeoutMs: timeout });
169
+ }
170
+ pending = [];
171
+ timeout = null;
172
+ };
173
+
174
+ for (const line of lines) {
175
+ if (/^\s*#/.test(line) || line.trim() === "") continue;
176
+ const command = line.match(/^(\s*)-?\s*(\w+):/);
177
+ if (command && GATE_COMMANDS.includes(command[2])) {
178
+ if (inGate) flush();
179
+ inGate = true;
180
+ indent = command[1].length;
181
+ continue;
182
+ }
183
+ if (!inGate) continue;
184
+ const currentIndent = line.match(/^\s*/)[0].length;
185
+ // A line at or left of the gate's own indent ends the block.
186
+ if (currentIndent <= indent && line.trim().startsWith("-")) {
187
+ flush();
188
+ inGate = false;
189
+ continue;
190
+ }
191
+ const timeoutMatch = line.match(/^\s*timeout:\s*(\d+)/);
192
+ if (timeoutMatch) {
193
+ timeout = Number(timeoutMatch[1]);
194
+ continue;
195
+ }
196
+ const idMatch = line.match(/^\s*id:\s*['"]?([^'"\n]+?)['"]?\s*$/);
197
+ if (idMatch) {
198
+ pending.push({ kind: "id", selector: idMatch[1] });
199
+ continue;
200
+ }
201
+ const textMatch = line.match(/^\s*text:\s*['"]?([^'"\n]+?)['"]?\s*$/);
202
+ if (textMatch) pending.push({ kind: "text", selector: textMatch[1] });
203
+ }
204
+ if (inGate) flush();
205
+ return gates;
206
+ }
207
+
208
+ /**
209
+ * Resolve the `runFlow:` targets a flow references, as absolute paths.
210
+ * @param {string} source - Flow YAML source
211
+ * @param {string} flowPath - Absolute path of the flow being read
212
+ * @returns {string[]} Absolute paths of referenced flows
213
+ */
214
+ export function extractRunFlowTargets(source, flowPath) {
215
+ const dir = path.dirname(flowPath);
216
+ const targets = [];
217
+ const inline = source.matchAll(/^\s*-?\s*runFlow:\s*['"]?([^'"\s]+\.yaml)/gm);
218
+ for (const match of inline) targets.push(path.resolve(dir, match[1]));
219
+ const nested = source.matchAll(/^\s*file:\s*['"]?([^'"\s]+\.yaml)/gm);
220
+ for (const match of nested) targets.push(path.resolve(dir, match[1]));
221
+ return targets;
222
+ }
223
+
224
+ /**
225
+ * Walk a flow's `runFlow` graph and return every subflow it transitively runs.
226
+ *
227
+ * Cycle-safe via the visited set โ€” a subflow that re-enters one already on the
228
+ * stack would otherwise recurse forever.
229
+ * @param {string} flowPath - Absolute path of the entry flow
230
+ * @param {(target: string) => string | null} readFile - Source reader
231
+ * @param {Set<string>} [seen] - Visited set, supplied by the recursion
232
+ * @returns {string[]} Absolute paths of every subflow reached
233
+ */
234
+ export function resolveSubflows(flowPath, readFile, seen = new Set()) {
235
+ const resolved = [];
236
+ const source = readFile(flowPath);
237
+ if (source === null) return resolved;
238
+ for (const target of extractRunFlowTargets(source, flowPath)) {
239
+ if (seen.has(target)) continue;
240
+ seen.add(target);
241
+ resolved.push(target);
242
+ resolved.push(...resolveSubflows(target, readFile, seen));
243
+ }
244
+ return resolved;
245
+ }
246
+
247
+ /**
248
+ * Is this subflow a sign-in preamble โ€” directly, or through one it runs?
249
+ *
250
+ * Cycle-safe via `seen` for the same reason `resolveSubflows` is.
251
+ * @param {string} flowPath - Absolute path of the subflow
252
+ * @param {(target: string) => string | null} readFile - Source reader
253
+ * @param {readonly string[]} [markers] - Project sign-in marker selectors
254
+ * @param {Set<string>} [seen] - Visited set, supplied by the recursion
255
+ * @returns {boolean} True when the subflow signs somebody in
256
+ */
257
+ export function isPreambleSubflow(
258
+ flowPath,
259
+ readFile,
260
+ markers = DEFAULT_SIGN_IN_MARKERS,
261
+ seen = new Set()
262
+ ) {
263
+ if (seen.has(flowPath)) return false;
264
+ seen.add(flowPath);
265
+ const source = readFile(flowPath);
266
+ if (source === null) return false;
267
+ if (markers.some(marker => source.includes(marker))) return true;
268
+ return extractRunFlowTargets(source, flowPath).some(target =>
269
+ isPreambleSubflow(target, readFile, markers, seen)
270
+ );
271
+ }
272
+
273
+ /**
274
+ * Does a Maestro failure message name this selector?
275
+ *
276
+ * Maestro renders a timed-out gate as `Assertion is false: id: <sel> is
277
+ * visible` and a not-found as `Element not found: Id matching regex: <sel>`.
278
+ * Both embed the selector verbatim, so an exact substring match is enough and
279
+ * avoids the false positives a loose regex would invite.
280
+ *
281
+ * NOTE (verified on Maestro 2.7.0): a command's `label:` does NOT reach the
282
+ * JUnit failure text โ€” a labelled gate still reports the raw assertion. That is
283
+ * why classification reads the selector rather than asking flow authors to
284
+ * label their gates.
285
+ * @param {string | null} message - Failure message from the report
286
+ * @param {{kind: string, selector: string}} gate - Gate to look for
287
+ * @returns {boolean} True when the message names this gate
288
+ */
289
+ export function messageNamesSelector(message, { kind, selector }) {
290
+ if (!message || !selector) return false;
291
+ const needle = kind === "id" ? `id: ${selector}` : selector;
292
+ return (
293
+ message.includes(needle) ||
294
+ message.includes(`Id matching regex: ${selector}`) ||
295
+ message.includes(`Text matching regex: ${selector}`)
296
+ );
297
+ }
298
+
299
+ /**
300
+ * Parse the `<testcase>` rows out of a Maestro JUnit report.
301
+ * @param {string} xml - JUnit report source
302
+ * @returns {{file: string, status: string, durationSec: number, message: string | null}[]} Rows
303
+ */
304
+ export function parseReport(xml) {
305
+ const cases = [];
306
+ // Attributes are matched LAZILY and the self-closing form is an alternative
307
+ // of the same match, not a separate pattern: a greedy `[^>]*` swallows the
308
+ // `/` of `<testcase .../>` and then hunts for the next `</testcase>`,
309
+ // silently merging a passing case into the following failing one โ€” which
310
+ // reports the PASSING flow's name against the failing flow's message.
311
+ for (const match of xml.matchAll(
312
+ /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g
313
+ )) {
314
+ const attrs = match[1] ?? "";
315
+ const body = match[2] ?? "";
316
+ const attr = name =>
317
+ (attrs.match(new RegExp(`${name}="([^"]*)"`)) || [])[1];
318
+ const failure = body.match(
319
+ /<(?:failure|error)\b[^>]*>([\s\S]*?)<\/(?:failure|error)>/
320
+ );
321
+ cases.push({
322
+ file: attr("file") || "",
323
+ status: attr("status") || "",
324
+ durationSec: Number(attr("time") || 0),
325
+ message: failure
326
+ ? decodeEntities(failure[1].trim().split("\n")[0].trim())
327
+ : null,
328
+ });
329
+ }
330
+ return cases;
331
+ }
332
+
333
+ /**
334
+ * Decode the five XML entities Maestro emits into failure text.
335
+ * @param {string} value - Encoded text
336
+ * @returns {string} Decoded text
337
+ */
338
+ function decodeEntities(value) {
339
+ return value
340
+ .replace(/&lt;/g, "<")
341
+ .replace(/&gt;/g, ">")
342
+ .replace(/&quot;/g, '"')
343
+ .replace(/&apos;/g, "'")
344
+ .replace(/&amp;/g, "&");
345
+ }
346
+
347
+ /**
348
+ * Reject a registry entry that cannot support the claim it makes.
349
+ * @param {unknown} entry - Candidate registry entry
350
+ * @returns {string | null} Defect reason, or null when the entry is sound
351
+ */
352
+ function intermittentDefect(entry) {
353
+ if (!entry || typeof entry !== "object") return "entry is not an object";
354
+ const { flow, measured } = /** @type {Record<string, any>} */ (entry);
355
+ if (typeof flow !== "string" || flow.trim() === "")
356
+ return "entry has no `flow`";
357
+ if (!measured || typeof measured !== "object")
358
+ return "entry has no `measured` block โ€” a rate nobody measured is not evidence";
359
+ const { failures, runs, measuredAt, method } = measured;
360
+ if (!Number.isInteger(runs) || runs <= 0)
361
+ return "`measured.runs` must be a positive integer";
362
+ if (!Number.isInteger(failures) || failures < 1)
363
+ return "`measured.failures` must be at least 1 โ€” an entry that never failed is not a known intermittent";
364
+ if (failures > runs) return "`measured.failures` exceeds `measured.runs`";
365
+ if (typeof measuredAt !== "string" || !/^\d{4}-\d{2}-\d{2}/.test(measuredAt))
366
+ return "`measured.measuredAt` must be an ISO date";
367
+ if (
368
+ typeof method !== "string" ||
369
+ PLACEHOLDER_METHODS.has(method.trim().toLowerCase())
370
+ )
371
+ return "`measured.method` must describe how the rate was measured";
372
+ return null;
373
+ }
374
+
375
+ /**
376
+ * Validate the known-intermittent registry, keeping only entries that earn it.
377
+ *
378
+ * Defective entries are REPORTED and DISCARDED rather than tolerated: the whole
379
+ * purpose of the registry is to let a reader discount a failure, so an entry
380
+ * with no measurement behind it must be unable to discount anything.
381
+ * @param {unknown} entries - Raw `knownIntermittent` array from the config
382
+ * @returns {{entries: object[], defects: {flow: string, reason: string}[]}} Verdict
383
+ */
384
+ export function validateIntermittentRegistry(entries) {
385
+ if (!Array.isArray(entries)) return { entries: [], defects: [] };
386
+ const accepted = [];
387
+ const defects = [];
388
+ for (const entry of entries) {
389
+ const reason = intermittentDefect(entry);
390
+ if (reason) {
391
+ const named =
392
+ entry && typeof entry === "object" && typeof entry.flow === "string"
393
+ ? entry.flow
394
+ : "(unnamed)";
395
+ defects.push({ flow: named, reason });
396
+ continue;
397
+ }
398
+ accepted.push({
399
+ flow: entry.flow,
400
+ platforms: Array.isArray(entry.platforms)
401
+ ? entry.platforms.map(String)
402
+ : [],
403
+ measured: entry.measured,
404
+ ticket: typeof entry.ticket === "string" ? entry.ticket : null,
405
+ notes: typeof entry.notes === "string" ? entry.notes : null,
406
+ });
407
+ }
408
+ return { entries: accepted, defects };
409
+ }
410
+
411
+ /**
412
+ * Find the measured-rate annotation for one flow on one platform.
413
+ * @param {string} flow - Flow basename from the report
414
+ * @param {string | null} platform - Arm the report came from, when known
415
+ * @param {readonly object[]} registry - Validated registry entries
416
+ * @returns {object | null} Annotation, or null when nothing applies
417
+ */
418
+ function intermittentFor(flow, platform, registry) {
419
+ const entry = registry.find(candidate => {
420
+ if (path.basename(candidate.flow) !== flow) return false;
421
+ if (candidate.platforms.length === 0) return true;
422
+ if (!platform) return false;
423
+ return candidate.platforms.some(
424
+ value => value.toLowerCase() === platform.toLowerCase()
425
+ );
426
+ });
427
+ if (!entry) return null;
428
+ const { failures, runs, measuredAt, method } = entry.measured;
429
+ return {
430
+ ratePercent: Number(((failures / runs) * 100).toFixed(1)),
431
+ failures,
432
+ runs,
433
+ measuredAt,
434
+ method,
435
+ ticket: entry.ticket,
436
+ notes: entry.notes,
437
+ };
438
+ }
439
+
440
+ /**
441
+ * Resolve a report's `file` attribute to a path on disk.
442
+ *
443
+ * Lisa's reusable workflow takes a configurable flows directory, so the
444
+ * conventional `<root>/.maestro/flows/<name>` cannot be assumed. The reported
445
+ * path is tried first (it is usually repo-relative and exact); the conventional
446
+ * layout is the fallback, which is what keeps reports from older runners
447
+ * readable.
448
+ * @param {string} reported - `file` attribute from the testcase row
449
+ * @param {{projectRoot: string, maestroRoot: string, readFile: Function}} context - Lookup context
450
+ * @returns {string} Absolute path of the best candidate
451
+ */
452
+ function resolveFlowPath(reported, { projectRoot, maestroRoot, readFile }) {
453
+ const candidates = [
454
+ path.resolve(projectRoot, reported),
455
+ path.join(maestroRoot, "flows", path.basename(reported)),
456
+ path.join(maestroRoot, path.basename(reported)),
457
+ ];
458
+ return (
459
+ candidates.find(candidate => readFile(candidate) !== null) ?? candidates[0]
460
+ );
461
+ }
462
+
463
+ /**
464
+ * Classify every failure in a report.
465
+ *
466
+ * `readFile` returns a flow's source or `null` when it is not on disk โ€” a
467
+ * report may name a flow deleted since the run, and that must degrade to
468
+ * "product" rather than throw.
469
+ * @param {string} reportXml - JUnit report source
470
+ * @param {object} options - Classification context
471
+ * @param {string} options.maestroRoot - Absolute path of the `.maestro` directory
472
+ * @param {(target: string) => string | null} options.readFile - Source reader
473
+ * @param {string} [options.projectRoot] - Absolute project root
474
+ * @param {readonly string[]} [options.signInMarkers] - Project sign-in markers
475
+ * @param {readonly unknown[]} [options.knownIntermittent] - Raw registry entries
476
+ * @param {string} [options.platform] - Arm the report came from
477
+ * @returns {object[]} One record per failing flow
478
+ */
479
+ export function classify(reportXml, options) {
480
+ const {
481
+ maestroRoot,
482
+ readFile,
483
+ projectRoot = path.dirname(maestroRoot),
484
+ signInMarkers = DEFAULT_SIGN_IN_MARKERS,
485
+ knownIntermittent = [],
486
+ platform = null,
487
+ } = options;
488
+ const registry = validateIntermittentRegistry(knownIntermittent).entries;
489
+ const results = [];
490
+ for (const testCase of parseReport(reportXml)) {
491
+ if (!testCase.message) continue;
492
+ const flowPath = resolveFlowPath(testCase.file, {
493
+ projectRoot,
494
+ maestroRoot,
495
+ readFile,
496
+ });
497
+ const matched = matchPreambleGate(flowPath, testCase, {
498
+ readFile,
499
+ signInMarkers,
500
+ });
501
+ const flow = path.basename(testCase.file);
502
+ results.push({
503
+ flow,
504
+ durationSec: testCase.durationSec,
505
+ message: testCase.message,
506
+ kind: matched ? "preamble" : "product",
507
+ gate: matched ? matched.selector : null,
508
+ subflow: matched ? matched.subflow : null,
509
+ gateCeilingSec:
510
+ matched && matched.timeoutMs ? matched.timeoutMs / 1000 : null,
511
+ elapsedAtGateSec:
512
+ matched && matched.timeoutMs
513
+ ? Number((testCase.durationSec - matched.timeoutMs / 1000).toFixed(1))
514
+ : null,
515
+ intermittent: intermittentFor(flow, platform, registry),
516
+ });
517
+ }
518
+ return results;
519
+ }
520
+
521
+ /**
522
+ * Find the preamble gate a failure died on, if any.
523
+ *
524
+ * Split out of `classify` so the tie-break rule sits in one readable place: a
525
+ * gate any product surface also asserts is skipped, which sends the failure to
526
+ * the product column.
527
+ * @param {string} flowPath - Absolute path of the failing flow
528
+ * @param {{message: string}} testCase - Parsed testcase row
529
+ * @param {{readFile: Function, signInMarkers: readonly string[]}} context - Lookup context
530
+ * @returns {object | null} Matched gate plus its subflow, or null
531
+ */
532
+ function matchPreambleGate(flowPath, testCase, { readFile, signInMarkers }) {
533
+ const ownSource = readFile(flowPath);
534
+ const subflows = ownSource ? resolveSubflows(flowPath, readFile) : [];
535
+ const preambles = subflows.filter(subflow =>
536
+ isPreambleSubflow(subflow, readFile, signInMarkers)
537
+ );
538
+ const others = subflows.filter(subflow => !preambles.includes(subflow));
539
+
540
+ // Every gate the flow could have died on that is NOT preamble setup: its own
541
+ // body, plus every navigation helper it runs mid-scenario.
542
+ const productGates = [
543
+ ...(ownSource ? extractGates(ownSource) : []),
544
+ ...others.flatMap(subflow => extractGates(readFile(subflow) ?? "")),
545
+ ];
546
+
547
+ for (const subflow of preambles) {
548
+ const source = readFile(subflow);
549
+ if (source === null) continue;
550
+ for (const gate of extractGates(source)) {
551
+ if (!messageNamesSelector(testCase.message, gate)) continue;
552
+ // Tie-break toward the product column: a selector any product surface
553
+ // also asserts is the flow's own business, even if a preamble shares it.
554
+ const contested = productGates.some(
555
+ own => own.kind === gate.kind && own.selector === gate.selector
556
+ );
557
+ if (contested) continue;
558
+ return { ...gate, subflow: path.basename(subflow) };
559
+ }
560
+ }
561
+ return null;
562
+ }
563
+
564
+ /**
565
+ * Read a file, or null when it is absent or unreadable.
566
+ * @param {string} target - Absolute path
567
+ * @returns {string | null} Contents, or null
568
+ */
569
+ function readFileOrNull(target) {
570
+ try {
571
+ return fs.readFileSync(target, "utf8");
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Load the project's classification config, tolerating absence and damage.
579
+ *
580
+ * A malformed config degrades to defaults with a reported defect rather than
581
+ * throwing: this tool runs beside a failing test suite, and a diagnostic that
582
+ * dies on a typo is a diagnostic nobody consults on the night they need it.
583
+ * @param {string} projectRoot - Absolute project root
584
+ * @returns {{signInMarkers: string[], knownIntermittent: unknown[], defects: object[]}} Config
585
+ */
586
+ export function loadConfig(projectRoot) {
587
+ const raw = readFileOrNull(path.join(projectRoot, CONFIG_REL_PATH));
588
+ if (raw === null)
589
+ return {
590
+ signInMarkers: DEFAULT_SIGN_IN_MARKERS,
591
+ knownIntermittent: [],
592
+ defects: [],
593
+ };
594
+ try {
595
+ const parsed = JSON.parse(raw);
596
+ return {
597
+ signInMarkers: Array.isArray(parsed.signInMarkers)
598
+ ? parsed.signInMarkers.map(String)
599
+ : DEFAULT_SIGN_IN_MARKERS,
600
+ knownIntermittent: Array.isArray(parsed.knownIntermittent)
601
+ ? parsed.knownIntermittent
602
+ : [],
603
+ defects: [],
604
+ };
605
+ } catch (error) {
606
+ return {
607
+ signInMarkers: DEFAULT_SIGN_IN_MARKERS,
608
+ knownIntermittent: [],
609
+ defects: [
610
+ { flow: CONFIG_REL_PATH, reason: `unreadable JSON: ${error.message}` },
611
+ ],
612
+ };
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Classify every named report against one project checkout.
618
+ * @param {readonly string[]} reportPaths - Report paths
619
+ * @param {{projectRoot: string, platform: string | null}} context - Run context
620
+ * @returns {{report: string, failures: object[], defects: object[]}[]} Results
621
+ */
622
+ export function run(reportPaths, { projectRoot, platform }) {
623
+ const maestroRoot = path.join(projectRoot, ".maestro");
624
+ const config = loadConfig(projectRoot);
625
+ const registry = validateIntermittentRegistry(config.knownIntermittent);
626
+ const defects = [...config.defects, ...registry.defects];
627
+ return reportPaths.map(reportPath => ({
628
+ report: path.basename(reportPath),
629
+ defects,
630
+ failures: classify(readFileOrNull(reportPath) ?? "", {
631
+ maestroRoot,
632
+ projectRoot,
633
+ readFile: readFileOrNull,
634
+ signInMarkers: config.signInMarkers,
635
+ knownIntermittent: config.knownIntermittent,
636
+ platform,
637
+ }),
638
+ }));
639
+ }
640
+
641
+ /**
642
+ * Render one report's classification as GitHub step-summary markdown.
643
+ * @param {{report: string, failures: object[], defects: object[]}} result - One report's result
644
+ * @returns {string} Markdown block
645
+ */
646
+ export function renderMarkdown({ report, failures, defects }) {
647
+ const preamble = failures.filter(failure => failure.kind === "preamble");
648
+ const product = failures.filter(failure => failure.kind === "product");
649
+ const lines = [
650
+ `### ๐Ÿ” Flake classification โ€” \`${report}\``,
651
+ "",
652
+ `**${product.length} product** ยท **${preamble.length} preamble** (tested nothing) ยท ${failures.length} failing flow(s)`,
653
+ "",
654
+ "_Diagnostic only โ€” this never changes the result of any gate._",
655
+ "",
656
+ ];
657
+ if (product.length > 0) {
658
+ lines.push(
659
+ "| flow | failure | known intermittent |",
660
+ "| --- | --- | --- |"
661
+ );
662
+ for (const failure of product) {
663
+ const known = failure.intermittent
664
+ ? `${failure.intermittent.ratePercent}% (${failure.intermittent.failures}/${failure.intermittent.runs}, measured ${failure.intermittent.measuredAt})`
665
+ : "โ€”";
666
+ lines.push(
667
+ `| \`${failure.flow}\` | ${escapeCell(failure.message)} | ${known} |`
668
+ );
669
+ }
670
+ lines.push("");
671
+ }
672
+ if (preamble.length > 0) {
673
+ lines.push(
674
+ "| flow | preamble gate | reached gate at | ceiling |",
675
+ "| --- | --- | --- | --- |"
676
+ );
677
+ for (const failure of preamble) {
678
+ lines.push(
679
+ `| \`${failure.flow}\` | \`${failure.gate}\` (${failure.subflow}) | ${
680
+ failure.elapsedAtGateSec === null
681
+ ? "โ€”"
682
+ : `${failure.elapsedAtGateSec}s`
683
+ } | ${failure.gateCeilingSec === null ? "โ€”" : `${failure.gateCeilingSec}s`} |`
684
+ );
685
+ }
686
+ lines.push("");
687
+ }
688
+ for (const defect of defects) {
689
+ lines.push(
690
+ `> โš ๏ธ known-intermittent entry \`${defect.flow}\` was IGNORED: ${defect.reason}`
691
+ );
692
+ }
693
+ return lines.join("\n");
694
+ }
695
+
696
+ /**
697
+ * Escape a value so it cannot break out of a markdown table cell.
698
+ * @param {string} value - Raw text
699
+ * @returns {string} Escaped text
700
+ */
701
+ function escapeCell(value) {
702
+ return String(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
703
+ }
704
+
705
+ /**
706
+ * Render one report's classification as plain text.
707
+ * @param {{report: string, failures: object[], defects: object[]}} result - One report's result
708
+ * @returns {string} Text block
709
+ */
710
+ function renderText({ report, failures, defects }) {
711
+ const preamble = failures.filter(failure => failure.kind === "preamble");
712
+ const product = failures.filter(failure => failure.kind === "product");
713
+ const lines = [
714
+ "",
715
+ `${report}: ${failures.length} failures`,
716
+ ` ${product.length} product ยท ${preamble.length} preamble (tested nothing)`,
717
+ ];
718
+ for (const failure of preamble) {
719
+ const reach =
720
+ failure.elapsedAtGateSec === null
721
+ ? ""
722
+ : ` โ€” reached the gate at ${failure.elapsedAtGateSec}s, then burned its ${failure.gateCeilingSec}s ceiling`;
723
+ lines.push(` [preamble] ${failure.flow} (${failure.subflow})${reach}`);
724
+ }
725
+ for (const failure of product) {
726
+ const known = failure.intermittent
727
+ ? ` [known intermittent ${failure.intermittent.ratePercent}% โ€” ${failure.intermittent.failures}/${failure.intermittent.runs} measured ${failure.intermittent.measuredAt}]`
728
+ : "";
729
+ lines.push(` [product ] ${failure.flow}: ${failure.message}${known}`);
730
+ }
731
+ for (const defect of defects) {
732
+ lines.push(
733
+ ` [ignored ] known-intermittent entry ${defect.flow}: ${defect.reason}`
734
+ );
735
+ }
736
+ return lines.join("\n");
737
+ }
738
+
739
+ /**
740
+ * CLI entry point. Exits non-zero only on usage error โ€” never on findings.
741
+ * @param {readonly string[]} argv - Arguments after the script name
742
+ * @returns {void}
743
+ */
744
+ function main(argv) {
745
+ const asJson = argv.includes("--json");
746
+ const asMarkdown = argv.includes("--markdown");
747
+ const platformArg = argv.find(arg => arg.startsWith("--platform="));
748
+ const reports = argv.filter(arg => !arg.startsWith("--"));
749
+ if (reports.length === 0) {
750
+ console.error(
751
+ "usage: node scripts/classify-maestro-failures.mjs [--json|--markdown] [--platform=android] <report.xml> [...]"
752
+ );
753
+ process.exitCode = 1;
754
+ return;
755
+ }
756
+ const projectRoot = process.env.MAESTRO_CHECK_ROOT || process.cwd();
757
+ const results = run(reports, {
758
+ projectRoot,
759
+ platform: platformArg ? platformArg.slice("--platform=".length) : null,
760
+ });
761
+ if (asJson) {
762
+ console.log(JSON.stringify(results, null, 2));
763
+ return;
764
+ }
765
+ for (const result of results) {
766
+ console.log(asMarkdown ? renderMarkdown(result) : renderText(result));
767
+ }
768
+ }
769
+
770
+ if (
771
+ process.argv[1] &&
772
+ import.meta.url === pathToFileURL(process.argv[1]).href
773
+ ) {
774
+ main(process.argv.slice(2));
775
+ }