@codyswann/lisa 3.35.1 → 3.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +4 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +10 -4
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/create-only/.github/workflows/maestro-e2e.yml +27 -0
- package/expo/create-only/.github/workflows/nightly-e2e-health.yml +27 -0
- 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/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +317 -18
- package/typescript/copy-overwrite/scripts/nightly-e2e-suites.schema.json +5 -0
|
@@ -89,7 +89,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
|
|
|
89
89
|
* rather than running a contract neither half agrees on. See §8 of
|
|
90
90
|
* `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
|
|
91
91
|
*/
|
|
92
|
-
export const NIGHTLY_E2E_CONTRACT_VERSION = "1.
|
|
92
|
+
export const NIGHTLY_E2E_CONTRACT_VERSION = "1.4.0";
|
|
93
93
|
|
|
94
94
|
/**
|
|
95
95
|
* The conclusions that constitute a verdict about the code.
|
|
@@ -140,6 +140,65 @@ export const SUITE_STATES = Object.freeze({
|
|
|
140
140
|
*/
|
|
141
141
|
export const INCOMPLETE_EVIDENCE_REASON = "incomplete_run";
|
|
142
142
|
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// SUITE SCOPE — "did this run test the WHOLE suite?" (rows 36-38)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
//
|
|
147
|
+
// Row 26 asks whether every JOB ran. It cannot see inside a job, so it cannot
|
|
148
|
+
// see the other way a green run proves nothing: a job that ran, passed, and
|
|
149
|
+
// tested a hand-picked slice.
|
|
150
|
+
//
|
|
151
|
+
// Measured 2026-08-18. AcmeOrgB/frontend's only recent `success` ran 4 of ~80
|
|
152
|
+
// flows under `ios_include_tags: smoke`. AcmeOrgD/frontend's REQUIRED merge gate
|
|
153
|
+
// was, at the time of writing, satisfied by run 32120016803: `maestro-ios-report`
|
|
154
|
+
// green, Android skipped, and the run's own published count reading
|
|
155
|
+
// `maestro-ios-flowcount-7`. Seven flows cleared a merge gate for a suite of
|
|
156
|
+
// eighty. `gh run list` renders that identically to a full green, which is how a
|
|
157
|
+
// reader — and this gate — concludes a suite "has been green recently" when it
|
|
158
|
+
// never has.
|
|
159
|
+
//
|
|
160
|
+
// TWO independent signals, and BOTH are required rather than either:
|
|
161
|
+
//
|
|
162
|
+
// 1. SCOPE (the run's own recorded inputs). `maestro-native-e2e.yml` publishes
|
|
163
|
+
// `maestro-<platform>-scope-full` or `maestro-<platform>-scope-filtered`
|
|
164
|
+
// from the tag inputs it was actually given. This is the definitive signal
|
|
165
|
+
// — it reads the filter itself rather than its consequences — but it only
|
|
166
|
+
// exists on runs from a Lisa version that publishes it, so it is ABSENT on
|
|
167
|
+
// every historical run and on any suite that is not maestro.
|
|
168
|
+
// 2. COUNT (executed flows). `maestro-<platform>-flowcount-<N>` has shipped for
|
|
169
|
+
// longer and is the backstop: it catches a run narrowed by any mechanism at
|
|
170
|
+
// all — a tag filter, a `flows_dir` override, a hand-edited flow list — and
|
|
171
|
+
// it catches the runs signal 1 cannot see.
|
|
172
|
+
//
|
|
173
|
+
// Both travel as ARTIFACT NAMES, read from the artifacts LIST. That is one
|
|
174
|
+
// cheap API call with no download and no zip reader, and — the property that
|
|
175
|
+
// makes it usable at all — an artifact's NAME outlives its bytes, so an expired
|
|
176
|
+
// artifact still answers "how many flows did that night run?". The header of
|
|
177
|
+
// `nightly-e2e-health.yml` says this gate reads no artifacts; that meant no
|
|
178
|
+
// artifact CONTENT, and it now says so explicitly.
|
|
179
|
+
|
|
180
|
+
/** A run positively recorded as narrowed. Absence of evidence, not failure. */
|
|
181
|
+
export const FILTERED_RUN_REASON = "filtered_run";
|
|
182
|
+
|
|
183
|
+
/** A run that executed fewer flows than the suite declared it must. */
|
|
184
|
+
export const FLOW_SHORTFALL_REASON = "flow_shortfall";
|
|
185
|
+
|
|
186
|
+
/** `min_flows` was declared and the run published nothing to check it against. */
|
|
187
|
+
export const SCOPE_UNREADABLE_REASON = "scope_unreadable";
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `maestro-<platform>-flowcount-<N>`, as published by `maestro-native-e2e.yml`.
|
|
191
|
+
*
|
|
192
|
+
* Anchored at both ends for the reason `job_pattern` is: unanchored, this is a
|
|
193
|
+
* substring test, and `maestro-ios-flowcount-7-retry` would be read as 7.
|
|
194
|
+
*/
|
|
195
|
+
export const FLOW_COUNT_ARTIFACT_PATTERN =
|
|
196
|
+
/^maestro-(?<platform>[a-z0-9]+)-flowcount-(?<count>\d+)$/;
|
|
197
|
+
|
|
198
|
+
/** `maestro-<platform>-scope-<full|filtered>`, same publisher, same anchoring. */
|
|
199
|
+
export const SCOPE_ARTIFACT_PATTERN =
|
|
200
|
+
/^maestro-(?<platform>[a-z0-9]+)-scope-(?<scope>full|filtered)$/;
|
|
201
|
+
|
|
143
202
|
/** The label that identifies a tracking issue this reporter owns. */
|
|
144
203
|
export const TRACKING_ISSUE_LABEL = "nightly-e2e";
|
|
145
204
|
|
|
@@ -355,6 +414,7 @@ const SUITE_KEYS = Object.freeze(
|
|
|
355
414
|
"required_sha",
|
|
356
415
|
"first_seen",
|
|
357
416
|
"grace_days",
|
|
417
|
+
"min_flows",
|
|
358
418
|
])
|
|
359
419
|
);
|
|
360
420
|
|
|
@@ -555,6 +615,18 @@ export function validateSuites(raw) {
|
|
|
555
615
|
);
|
|
556
616
|
}
|
|
557
617
|
}
|
|
618
|
+
// Row 37 — the executed-flow floor. A floor of 0 or a fractional one is
|
|
619
|
+
// rejected rather than coerced: `min_flows: 0` reads as "a floor is
|
|
620
|
+
// declared" and enforces nothing, which is the shape of every control that
|
|
621
|
+
// reports success because it did nothing.
|
|
622
|
+
if (entry.min_flows !== undefined) {
|
|
623
|
+
const floor = entry.min_flows;
|
|
624
|
+
if (!Number.isInteger(floor) || floor < 1) {
|
|
625
|
+
throw new GateConfigError(
|
|
626
|
+
`${where}: \`min_flows\` must be an integer >= 1 — the number of flows this suite must execute before its green counts as evidence. \`0\` would declare a floor and enforce nothing. Read from the \`maestro-<platform>-flowcount-<N>\` artifact the suite publishes; once declared, a run that publishes no count BLOCKS rather than passing unchecked.`
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
558
630
|
if (entry.required_sha !== undefined) {
|
|
559
631
|
if (
|
|
560
632
|
typeof entry.required_sha !== "string" ||
|
|
@@ -601,6 +673,122 @@ export function stateForConclusion(conclusion) {
|
|
|
601
673
|
return SUITE_STATES.unknown;
|
|
602
674
|
}
|
|
603
675
|
|
|
676
|
+
/**
|
|
677
|
+
* Reads the scope markers a run published, from its artifact NAMES.
|
|
678
|
+
*
|
|
679
|
+
* Pure, so the parsing is provable without a network. `readable: false` is the
|
|
680
|
+
* "we could not ask" case and is deliberately distinct from an empty reading —
|
|
681
|
+
* a run that published no markers and a run whose artifact list would not load
|
|
682
|
+
* are different facts, and only one of them is the suite's fault.
|
|
683
|
+
*
|
|
684
|
+
* @param {ReadonlyArray<{name?: string}>|null} artifacts - The artifacts list, or null when unreadable
|
|
685
|
+
* @returns {{readable: boolean, filtered: ReadonlyArray<string>, counts: Readonly<Record<string, number>>, totalFlows: number|null}} What the run recorded about its own scope
|
|
686
|
+
*/
|
|
687
|
+
export function readSuiteScope(artifacts) {
|
|
688
|
+
if (!Array.isArray(artifacts)) {
|
|
689
|
+
return Object.freeze({
|
|
690
|
+
readable: false,
|
|
691
|
+
filtered: Object.freeze([]),
|
|
692
|
+
counts: Object.freeze({}),
|
|
693
|
+
totalFlows: null,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
const filtered = [];
|
|
697
|
+
const counts = {};
|
|
698
|
+
for (const artifact of artifacts) {
|
|
699
|
+
const name = typeof artifact?.name === "string" ? artifact.name : "";
|
|
700
|
+
const scope = SCOPE_ARTIFACT_PATTERN.exec(name);
|
|
701
|
+
if (scope && scope.groups.scope === "filtered") {
|
|
702
|
+
filtered.push(scope.groups.platform);
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const count = FLOW_COUNT_ARTIFACT_PATTERN.exec(name);
|
|
706
|
+
if (count) {
|
|
707
|
+
// MAX, never sum, when one platform somehow publishes twice: a re-uploaded
|
|
708
|
+
// count is the same arm counted again, and summing it would inflate the
|
|
709
|
+
// suite past its own floor. Taking the larger of two readings of one arm
|
|
710
|
+
// is the reading that cannot invent flows.
|
|
711
|
+
const value = Number(count.groups.count);
|
|
712
|
+
const platform = count.groups.platform;
|
|
713
|
+
counts[platform] = Math.max(counts[platform] ?? 0, value);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
const platforms = Object.keys(counts);
|
|
717
|
+
return Object.freeze({
|
|
718
|
+
readable: true,
|
|
719
|
+
filtered: Object.freeze(filtered.sort()),
|
|
720
|
+
counts: Object.freeze({ ...counts }),
|
|
721
|
+
// `null`, not 0, when nothing was published. Zero is a READING — the arm ran
|
|
722
|
+
// and tested nothing — and rendering "no evidence" as that reading is the
|
|
723
|
+
// exact substitution this whole section exists to refuse.
|
|
724
|
+
totalFlows:
|
|
725
|
+
platforms.length === 0
|
|
726
|
+
? null
|
|
727
|
+
: platforms.reduce((sum, platform) => sum + counts[platform], 0),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Row 36-38 — was this run NARROWED?
|
|
733
|
+
*
|
|
734
|
+
* Returns `null` when the run is not disqualified, or the reason token that
|
|
735
|
+
* disqualifies it. Every return here resolves to state `unknown`, never `fail`:
|
|
736
|
+
* a narrowed run is ABSENCE of evidence about the flows it skipped, not evidence
|
|
737
|
+
* that they are broken. That places it on the same side of the line as the
|
|
738
|
+
* skipped-job half of row 26, which means bootstrap and per-suite grace forgive
|
|
739
|
+
* it on the same terms — deliberate, so ARMING this row cannot wedge a repo that
|
|
740
|
+
* is still standing its suites up.
|
|
741
|
+
*
|
|
742
|
+
* The asymmetry between the two signals is the whole design:
|
|
743
|
+
*
|
|
744
|
+
* - A POSITIVELY OBSERVED filter (row 36) disqualifies unconditionally. No
|
|
745
|
+
* declaration is needed to disbelieve a run that reported its own narrowing.
|
|
746
|
+
* - A SHORTFALL against `min_flows` (row 37) and an UNREADABLE scope (row 38)
|
|
747
|
+
* require the suite to have declared `min_flows`. A floor cannot be inferred:
|
|
748
|
+
* this gate reads suites it did not write, including non-maestro ones that
|
|
749
|
+
* publish no counts at all, and a guessed denominator would either forgive
|
|
750
|
+
* everything or block every consumer on the day it shipped.
|
|
751
|
+
*
|
|
752
|
+
* That last point is where fail-closed actually lives. Declaring `min_flows` is
|
|
753
|
+
* the act of saying "this suite publishes counts"; from that moment an
|
|
754
|
+
* unreadable count is a BLOCK (row 38), never a shrug. Without the declaration
|
|
755
|
+
* the gate cannot tell, and it says so on the report line rather than passing
|
|
756
|
+
* silently — see `scopeUnverified` on the finding.
|
|
757
|
+
*
|
|
758
|
+
* @param {object} suite - A validated suite entry
|
|
759
|
+
* @param {{readable: boolean, filtered: ReadonlyArray<string>, totalFlows: number|null}} scope - Output of `readSuiteScope`
|
|
760
|
+
* @returns {{reason: string, detail: string}|null} The disqualification, or null
|
|
761
|
+
*/
|
|
762
|
+
export function assessSuiteScope(suite, scope) {
|
|
763
|
+
if (scope.filtered.length > 0) {
|
|
764
|
+
return {
|
|
765
|
+
reason: FILTERED_RUN_REASON,
|
|
766
|
+
detail: `the run recorded itself as tag-filtered on ${scope.filtered.map(platform => `\`${platform}\``).join(", ")}`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const floor = suite.min_flows;
|
|
770
|
+
if (floor === undefined) return null;
|
|
771
|
+
if (!scope.readable) {
|
|
772
|
+
return {
|
|
773
|
+
reason: SCOPE_UNREADABLE_REASON,
|
|
774
|
+
detail: `\`min_flows\` is ${floor} but this run's artifact list could not be read, so the executed-flow count is unknown`,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
if (scope.totalFlows === null) {
|
|
778
|
+
return {
|
|
779
|
+
reason: SCOPE_UNREADABLE_REASON,
|
|
780
|
+
detail: `\`min_flows\` is ${floor} but this run published no \`maestro-<platform>-flowcount-<N>\` marker, so how much of the suite ran is unknown`,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
if (scope.totalFlows < floor) {
|
|
784
|
+
return {
|
|
785
|
+
reason: FLOW_SHORTFALL_REASON,
|
|
786
|
+
detail: `this run executed ${scope.totalFlows} flow(s) against a declared floor of ${floor}`,
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
|
|
604
792
|
/**
|
|
605
793
|
* Assesses one suite from what was observed for it.
|
|
606
794
|
*
|
|
@@ -616,6 +804,50 @@ export function stateForConclusion(conclusion) {
|
|
|
616
804
|
export function assessSuite(suite, observation, context) {
|
|
617
805
|
const base = { label: suite.label, workflow: suite.workflow };
|
|
618
806
|
const blank = { conclusion: null, url: null, createdAt: null, event: null };
|
|
807
|
+
// An observation with no `scope` at all is the same fact as an artifacts list
|
|
808
|
+
// that would not load — "nobody asked" and "the answer did not come back" are
|
|
809
|
+
// both absence of a reading — so it goes through the same constructor rather
|
|
810
|
+
// than a hand-rolled stub. A stub missing a field would throw here and take
|
|
811
|
+
// the whole gate red for a reason having nothing to do with any suite.
|
|
812
|
+
const scope = observation.scope ?? readSuiteScope(null);
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* The ONE place a green may be returned, so row 36-38 cannot be bypassed by
|
|
816
|
+
* a future third pass path.
|
|
817
|
+
*
|
|
818
|
+
* `scopeUnverified` rides on the PASSING finding rather than being swallowed:
|
|
819
|
+
* a suite that declared no floor and published no counts really did pass every
|
|
820
|
+
* check this gate can run, and the honest report of that is a green line that
|
|
821
|
+
* says out loud which question went unasked. A silent green here would be the
|
|
822
|
+
* same defect one layer up.
|
|
823
|
+
*
|
|
824
|
+
* @param {object} seen - The run fields already resolved for this finding
|
|
825
|
+
* @param {string} reason - The truth-table reason token for the green
|
|
826
|
+
* @returns {object} The finding
|
|
827
|
+
*/
|
|
828
|
+
const green = (seen, reason) => {
|
|
829
|
+
const disqualifier = assessSuiteScope(suite, scope);
|
|
830
|
+
if (disqualifier) {
|
|
831
|
+
return {
|
|
832
|
+
...base,
|
|
833
|
+
...seen,
|
|
834
|
+
state: SUITE_STATES.unknown,
|
|
835
|
+
reason: disqualifier.reason,
|
|
836
|
+
scopeDetail: disqualifier.detail,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
return {
|
|
840
|
+
...base,
|
|
841
|
+
...seen,
|
|
842
|
+
state: SUITE_STATES.pass,
|
|
843
|
+
reason,
|
|
844
|
+
// Only ever true when no floor was declared — with one declared, the same
|
|
845
|
+
// condition returns a disqualifier above instead.
|
|
846
|
+
scopeUnverified:
|
|
847
|
+
suite.min_flows === undefined &&
|
|
848
|
+
(!scope.readable || scope.totalFlows === null),
|
|
849
|
+
};
|
|
850
|
+
};
|
|
619
851
|
|
|
620
852
|
// Row 11 — the workflow file the table names no longer exists. That is not
|
|
621
853
|
// missing evidence, it is a broken gate: someone renamed or deleted the suite
|
|
@@ -732,12 +964,7 @@ export function assessSuite(suite, observation, context) {
|
|
|
732
964
|
};
|
|
733
965
|
}
|
|
734
966
|
|
|
735
|
-
return
|
|
736
|
-
...base,
|
|
737
|
-
...seen,
|
|
738
|
-
state: SUITE_STATES.pass,
|
|
739
|
-
reason: "run_conclusion",
|
|
740
|
-
};
|
|
967
|
+
return green(seen, "run_conclusion");
|
|
741
968
|
}
|
|
742
969
|
|
|
743
970
|
const matches =
|
|
@@ -774,13 +1001,7 @@ export function assessSuite(suite, observation, context) {
|
|
|
774
1001
|
// message points at the thing that failed rather than at the run's summary.
|
|
775
1002
|
const offender = matches.find(job => job.conclusion !== GREEN_CONCLUSION);
|
|
776
1003
|
if (!offender) {
|
|
777
|
-
return {
|
|
778
|
-
...base,
|
|
779
|
-
...seen,
|
|
780
|
-
conclusion: GREEN_CONCLUSION,
|
|
781
|
-
state: SUITE_STATES.pass,
|
|
782
|
-
reason: "job_conclusion",
|
|
783
|
-
};
|
|
1004
|
+
return green({ ...seen, conclusion: GREEN_CONCLUSION }, "job_conclusion");
|
|
784
1005
|
}
|
|
785
1006
|
const state = stateForConclusion(offender.conclusion);
|
|
786
1007
|
return {
|
|
@@ -1145,6 +1366,12 @@ const REASON_TEXT = Object.freeze({
|
|
|
1145
1366
|
"the job pattern matched zero jobs in the newest run. Zero matches is the signature of a renamed job.",
|
|
1146
1367
|
[INCOMPLETE_EVIDENCE_REASON]:
|
|
1147
1368
|
"the run reported `success`, but it did not run everything: at least one job was skipped, failed under `continue-on-error`, or could not be read. A run that skipped part of itself did not gather the evidence its green claims — a suite re-run for one platform only is not a verdict about the other one. Re-run the suite WITHOUT narrowing it.",
|
|
1369
|
+
[FILTERED_RUN_REASON]:
|
|
1370
|
+
'the run reported `success`, but it recorded itself as TAG-FILTERED — it ran a hand-picked slice of the suite, not the suite. A slice that passes says nothing about the flows it never started, so this is no result rather than a green. Re-run the suite with every tag / platform / shard picker left on its "all" default.',
|
|
1371
|
+
[FLOW_SHORTFALL_REASON]:
|
|
1372
|
+
"the run reported `success`, but it executed FEWER FLOWS than this suite declares it must (`min_flows`). A narrowed run reaching green is how a four-flow dispatch clears a merge gate for an eighty-flow suite. Re-run the whole suite, or lower `min_flows` deliberately if the suite really did shrink.",
|
|
1373
|
+
[SCOPE_UNREADABLE_REASON]:
|
|
1374
|
+
'this suite declares `min_flows`, so its runs must publish an executed-flow count — and this one\'s could not be read. That is NOT a pass: an unreadable count is exactly what a narrowed run looks like from here, and rendering "we could not check" as "it is fine" is the defect this row exists to close. Check the suite still publishes `maestro-<platform>-flowcount-<N>` and that the caller grants `actions: read`.',
|
|
1148
1375
|
run_conclusion: "",
|
|
1149
1376
|
job_conclusion: "",
|
|
1150
1377
|
});
|
|
@@ -1163,8 +1390,19 @@ export function formatFinding(finding) {
|
|
|
1163
1390
|
const link = finding.url ? `: ${finding.url}` : "";
|
|
1164
1391
|
const detail = REASON_TEXT[finding.reason] || "";
|
|
1165
1392
|
if (finding.state === "pass") {
|
|
1166
|
-
|
|
1393
|
+
// A green whose SCOPE went unchecked says so on its own line. The
|
|
1394
|
+
// alternative — printing it identically to a fully verified green — is the
|
|
1395
|
+
// precise reading error this gate was measured making: a filtered run and a
|
|
1396
|
+
// full one rendered the same, so the history read as "green recently".
|
|
1397
|
+
const unverified = finding.scopeUnverified
|
|
1398
|
+
? " — ⚠️ scope unverified: this run published no executed-flow count, so how much of the suite ran is unknown. If this suite publishes `maestro-<platform>-flowcount-<N>`, declare `min_flows` to make that a blocking question; if it does not (a browser suite, say), this line is the honest limit of what the gate can see"
|
|
1399
|
+
: "";
|
|
1400
|
+
return `${marker} ${finding.label} — green${when}${link}${unverified}`;
|
|
1167
1401
|
}
|
|
1402
|
+
// The measured numbers ride on the line, not just in the prose: "executed 7
|
|
1403
|
+
// flow(s) against a declared floor of 60" is actionable where "the run was
|
|
1404
|
+
// narrowed" sends the reader back to the run page to find out how much.
|
|
1405
|
+
const scopeDetail = finding.scopeDetail ? ` (${finding.scopeDetail})` : "";
|
|
1168
1406
|
const verdictWord =
|
|
1169
1407
|
finding.state === "bootstrap"
|
|
1170
1408
|
? "not yet blocking"
|
|
@@ -1177,7 +1415,7 @@ export function formatFinding(finding) {
|
|
|
1177
1415
|
finding.state === "bootstrap" && finding.grace?.active
|
|
1178
1416
|
? ` — new suite (first seen ${finding.grace.firstSeen}); its grace expires ${finding.grace.until} (in ${finding.grace.expiresInDays} day(s)), after which this line blocks`
|
|
1179
1417
|
: "";
|
|
1180
|
-
return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${link}${grace}`;
|
|
1418
|
+
return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${scopeDetail}${link}${grace}`;
|
|
1181
1419
|
}
|
|
1182
1420
|
|
|
1183
1421
|
/**
|
|
@@ -1290,7 +1528,23 @@ export function formatReport(verdict, context) {
|
|
|
1290
1528
|
* @returns {boolean} True when the run was complete
|
|
1291
1529
|
*/
|
|
1292
1530
|
export function isCompleteEvidence(finding) {
|
|
1293
|
-
|
|
1531
|
+
// Rows 36-38 join row 26 here for the reason the doc comment gives: with the
|
|
1532
|
+
// gate as written none of them can produce a `pass`, so asking again looks
|
|
1533
|
+
// redundant — and that is exactly why it is asked. The reporter's close action
|
|
1534
|
+
// must not depend on the gate staying strict.
|
|
1535
|
+
//
|
|
1536
|
+
// `scopeUnverified` is deliberately NOT here. It is true of every suite that
|
|
1537
|
+
// has not adopted `min_flows` yet, which is all of them on the day this ships;
|
|
1538
|
+
// refusing to close on it would make the tracking issues immortal across four
|
|
1539
|
+
// repositories to express a doubt the gate itself does not act on. The two
|
|
1540
|
+
// halves tighten together instead: declare `min_flows` and an unreadable count
|
|
1541
|
+
// becomes `scope_unreadable`, which IS in this list.
|
|
1542
|
+
return (
|
|
1543
|
+
finding.reason !== INCOMPLETE_EVIDENCE_REASON &&
|
|
1544
|
+
finding.reason !== FILTERED_RUN_REASON &&
|
|
1545
|
+
finding.reason !== FLOW_SHORTFALL_REASON &&
|
|
1546
|
+
finding.reason !== SCOPE_UNREADABLE_REASON
|
|
1547
|
+
);
|
|
1294
1548
|
}
|
|
1295
1549
|
|
|
1296
1550
|
/**
|
|
@@ -1697,6 +1951,42 @@ export async function fetchAllJobs(api, runId, wait) {
|
|
|
1697
1951
|
);
|
|
1698
1952
|
}
|
|
1699
1953
|
|
|
1954
|
+
/**
|
|
1955
|
+
* The artifact NAMES a run published, paginated to exhaustion.
|
|
1956
|
+
*
|
|
1957
|
+
* Names only — nothing here downloads an artifact, and the gate still has no zip
|
|
1958
|
+
* reader. The names carry the scope markers (rows 36-38) and, unlike the bytes,
|
|
1959
|
+
* they survive the retention window, so a three-month-old run can still answer
|
|
1960
|
+
* "how many flows did you run?".
|
|
1961
|
+
*
|
|
1962
|
+
* Returns `null` on 404 and on a truncated read, which is the "we could not ask"
|
|
1963
|
+
* signal `assessSuiteScope` turns into a BLOCK for any suite that declared a
|
|
1964
|
+
* floor. A partial page walk is deliberately reported as unreadable rather than
|
|
1965
|
+
* as what was read: a flow-count marker sitting on the page this walk never
|
|
1966
|
+
* reached is indistinguishable from one that was never published, and the second
|
|
1967
|
+
* of those readings is the one that passes.
|
|
1968
|
+
*
|
|
1969
|
+
* @param {object} api - API coordinates
|
|
1970
|
+
* @param {number|string} runId - The run
|
|
1971
|
+
* @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
|
|
1972
|
+
* @returns {Promise<ReadonlyArray<object>|null>} All artifacts, or null when unreadable
|
|
1973
|
+
*/
|
|
1974
|
+
export async function fetchRunArtifacts(api, runId, wait) {
|
|
1975
|
+
const artifacts = [];
|
|
1976
|
+
for (let page = 1; page <= api.maxPages; page += 1) {
|
|
1977
|
+
const result = await apiGet(
|
|
1978
|
+
api,
|
|
1979
|
+
`/repos/${api.repo}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
|
|
1980
|
+
wait
|
|
1981
|
+
);
|
|
1982
|
+
if (result === null) return null;
|
|
1983
|
+
const batch = result.body.artifacts ?? [];
|
|
1984
|
+
artifacts.push(...batch);
|
|
1985
|
+
if (batch.length < 100) return Object.freeze(artifacts);
|
|
1986
|
+
}
|
|
1987
|
+
return null;
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1700
1990
|
/**
|
|
1701
1991
|
* Observes every suite.
|
|
1702
1992
|
*
|
|
@@ -1737,10 +2027,19 @@ export async function observe(api, suites, branch, wait) {
|
|
|
1737
2027
|
if (!run) {
|
|
1738
2028
|
return { workflowMissing: false, run, jobs: [] };
|
|
1739
2029
|
}
|
|
2030
|
+
// Artifacts are read for every suite, not only the ones declaring
|
|
2031
|
+
// `min_flows`: a run that recorded ITSELF as filtered disqualifies with no
|
|
2032
|
+
// declaration at all (row 36), and a gate that only looked when asked to
|
|
2033
|
+
// would miss exactly the repos that have not adopted the field yet.
|
|
2034
|
+
const [jobs, artifacts] = await Promise.all([
|
|
2035
|
+
fetchAllJobs(api, run.id, wait),
|
|
2036
|
+
fetchRunArtifacts(api, run.id, wait),
|
|
2037
|
+
]);
|
|
1740
2038
|
return {
|
|
1741
2039
|
workflowMissing: false,
|
|
1742
2040
|
run,
|
|
1743
|
-
jobs
|
|
2041
|
+
jobs,
|
|
2042
|
+
scope: readSuiteScope(artifacts),
|
|
1744
2043
|
};
|
|
1745
2044
|
})
|
|
1746
2045
|
);
|
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
"maximum": 720,
|
|
38
38
|
"description": "Override the workflow-level freshness window for this suite. A run older than this is treated as no run at all (missing evidence), which fails once the bootstrap window has closed."
|
|
39
39
|
},
|
|
40
|
+
"min_flows": {
|
|
41
|
+
"type": "integer",
|
|
42
|
+
"minimum": 1,
|
|
43
|
+
"description": "The number of flows a run must have EXECUTED before its green counts as evidence for this suite (truth-table rows 37-38). Read from the `maestro-<platform>-flowcount-<N>` artifact `maestro-native-e2e.yml` publishes, summed across platforms; the count travels as the artifact NAME, so the gate reads it from the artifacts list without downloading anything and it stays readable after the artifact expires. Declaring this field is the act of asserting that this suite publishes counts, and it is what makes the check FAIL CLOSED: from then on a run that publishes no readable count BLOCKS (`scope_unreadable`) instead of passing unchecked. Without it the gate can still reject a run that recorded ITSELF as tag-filtered (row 36), but it cannot detect a run narrowed by any other means, and it says so on the suite's report line rather than passing silently. `0` is rejected rather than coerced — a floor that enforces nothing reads as a floor."
|
|
44
|
+
},
|
|
40
45
|
"required_sha": {
|
|
41
46
|
"type": "string",
|
|
42
47
|
"pattern": "^[0-9a-f]{40}$",
|