@dev-loops/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
"./loop/steering": "./src/loop/steering.mjs",
|
|
57
57
|
"./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
|
|
58
58
|
"./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
|
|
59
|
+
"./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
|
|
59
60
|
"./refinement/ac-dod-matrix": "./src/refinement/ac-dod-matrix.mjs",
|
|
60
61
|
"./harness": "./src/harness/index.mjs",
|
|
61
62
|
"./loop/worktree-guard": "./src/loop/worktree-guard.mjs",
|
|
@@ -169,6 +169,32 @@ export function transformAgent({ source, raw, version = "latest" }) {
|
|
|
169
169
|
return `${lines.join("\n")}\n${body}`;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Transform a canonical `commands/<name>.command.md` into a Claude `.claude/commands/<name>.md`
|
|
174
|
+
* slash command (#972). Commands are thin wrappers over the public dev-loop contract: the body
|
|
175
|
+
* is a prompt (with `$ARGUMENTS`) that invokes the existing entrypoint, so there is NO routing
|
|
176
|
+
* logic here. Frontmatter keeps Claude's command fields (`description`, `argument-hint`); the body
|
|
177
|
+
* is passed through `stripPiOnlyBlocks` + `rewriteCliInvocation` like agents/skills.
|
|
178
|
+
* @param {{ source: string, raw: string, version?: string }} input
|
|
179
|
+
* @returns {string} Full generated file content.
|
|
180
|
+
*/
|
|
181
|
+
export function transformCommand({ source, raw, version = "latest" }) {
|
|
182
|
+
const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
|
|
183
|
+
const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
|
|
184
|
+
|
|
185
|
+
const lines = ["---"];
|
|
186
|
+
if (frontmatter.description != null) {
|
|
187
|
+
lines.push(`description: ${JSON.stringify(String(frontmatter.description))}`);
|
|
188
|
+
}
|
|
189
|
+
if (frontmatter["argument-hint"] != null) {
|
|
190
|
+
lines.push(`argument-hint: ${JSON.stringify(String(frontmatter["argument-hint"]))}`);
|
|
191
|
+
}
|
|
192
|
+
lines.push("---");
|
|
193
|
+
lines.push(GENERATED_NOTE(source));
|
|
194
|
+
lines.push("");
|
|
195
|
+
return `${lines.join("\n")}\n${body}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
172
198
|
/**
|
|
173
199
|
* Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
|
|
174
200
|
* @param {{ source: string, raw: string, version?: string }} input
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* Async context marker (required when workflow.asyncStartMode is `required`)
|
|
14
14
|
* — see `@dev-loops/core/loop/run-context`:
|
|
15
15
|
* - DEVLOOPS_RUN_ID env var (neutral, harness-agnostic)
|
|
16
|
+
* - PI_SUBAGENT_RUN_ID env var (the alias the Pi runtime injects)
|
|
16
17
|
*
|
|
17
18
|
* Allowed modes:
|
|
18
19
|
* - workflow.asyncStartMode: required | allowed
|
|
@@ -159,14 +160,20 @@ export function validateAsyncStartContext({
|
|
|
159
160
|
};
|
|
160
161
|
}
|
|
161
162
|
|
|
162
|
-
// No marker found — fail closed
|
|
163
|
+
// No marker found — fail closed.
|
|
164
|
+
// Derive the marker hint from ASYNC_CONTEXT_MARKERS (primary first, aliases after)
|
|
165
|
+
// so the message never drifts from the recognized-marker list.
|
|
166
|
+
const [primaryMarker, ...aliasMarkers] = ASYNC_CONTEXT_MARKERS;
|
|
167
|
+
const markerHint = aliasMarkers.length
|
|
168
|
+
? `Set ${primaryMarker} (or the ${aliasMarkers.join("/")} alias) to proceed. `
|
|
169
|
+
: `Set ${primaryMarker} to proceed. `;
|
|
163
170
|
return {
|
|
164
171
|
status: ASYNC_START_STATUS.REJECTED,
|
|
165
172
|
reason:
|
|
166
173
|
"No async context detected. " +
|
|
167
174
|
"The dev-loop must run within a visible async subagent session, " +
|
|
168
175
|
"not as a detached local process. " +
|
|
169
|
-
|
|
176
|
+
markerHint +
|
|
170
177
|
"Repository-maintained workflow policy controls any exceptions.",
|
|
171
178
|
detectedMarker: null,
|
|
172
179
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { DISPOSITION, STATE } from "./copilot-loop-state.mjs";
|
|
2
2
|
import { findBlockingTitleMarkers } from "./pr-title-markers.mjs";
|
|
3
|
+
import { evaluateUiE2eScoping } from "./ui-e2e-scoping.mjs";
|
|
3
4
|
|
|
4
5
|
export const PR_CHECKPOINT = Object.freeze({
|
|
5
6
|
DRAFT_REVIEW: "draft_review",
|
|
6
7
|
POST_DRAFT_EXTERNAL_REVIEW: "post_draft_external_review",
|
|
7
8
|
FEEDBACK_RESOLUTION: "feedback_resolution",
|
|
8
9
|
CONFLICT_RESOLUTION: "conflict_resolution",
|
|
10
|
+
UI_E2E_SCOPING: "ui_e2e_scoping",
|
|
9
11
|
PRE_APPROVAL_GATE_WINDOW: "pre_approval_gate_window",
|
|
10
12
|
FINAL_APPROVAL_READY: "final_approval_ready",
|
|
11
13
|
PRE_APPROVAL_GATE_NEEDED: "pre_approval_gate_needed",
|
|
@@ -48,6 +50,7 @@ export const PR_CHECKPOINT_ACTION = Object.freeze({
|
|
|
48
50
|
RECONCILE_DRAFT_GATE: "reconcile_draft_gate",
|
|
49
51
|
REPORT_BLOCKED: "report_blocked",
|
|
50
52
|
REPORT_DONE: "report_done",
|
|
53
|
+
RUN_UI_E2E_SUITE: "run_ui_e2e_suite",
|
|
51
54
|
});
|
|
52
55
|
|
|
53
56
|
function normalizeGateComment(summary = null) {
|
|
@@ -687,6 +690,10 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
687
690
|
const requireRetrospectiveInternalTooling = input.requireRetrospectiveInternalTooling === true;
|
|
688
691
|
const retrospectiveCheckpoint = input.retrospectiveCheckpoint;
|
|
689
692
|
const prTitle = typeof input.prTitle === "string" ? input.prTitle : "";
|
|
693
|
+
// UI e2e auto-scoping (#976): the PR changed-file set + whether the shared UI
|
|
694
|
+
// e2e suite passed for this head. Inclusion is path-triggered, never annotated.
|
|
695
|
+
const changedFiles = Array.isArray(input.changedFiles) ? input.changedFiles : [];
|
|
696
|
+
const uiE2ePassed = input.uiE2ePassed === true ? true : (input.uiE2ePassed === false ? false : null);
|
|
690
697
|
const refinementArtifact = input.refinementArtifact && typeof input.refinementArtifact === "object"
|
|
691
698
|
? input.refinementArtifact
|
|
692
699
|
: null;
|
|
@@ -836,6 +843,45 @@ function evaluatePrGateCoordinationCore(input = {}) {
|
|
|
836
843
|
});
|
|
837
844
|
}
|
|
838
845
|
|
|
846
|
+
// UI e2e auto-scoping precondition (#976). Path-triggered + fail-closed:
|
|
847
|
+
// if the PR's changed files touch a rendered artifact (a deck under
|
|
848
|
+
// docs/articles|presentations, or the inspect-run viewer source), it MUST be
|
|
849
|
+
// registered in the shared UI e2e suite AND that suite must have passed for
|
|
850
|
+
// this head. A rendered-artifact change with no registered/passing coverage
|
|
851
|
+
// blocks here with a reason naming the artifact. Distinct seam from the
|
|
852
|
+
// mergeability (#980) and retrospective (#982) preconditions to minimize
|
|
853
|
+
// merge-time conflict. Non-UI changes pass through untouched (required=false).
|
|
854
|
+
const uiE2eScoping = evaluateUiE2eScoping(changedFiles, { uiE2ePassed });
|
|
855
|
+
if (uiE2eScoping.required && !uiE2eScoping.satisfied) {
|
|
856
|
+
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE]);
|
|
857
|
+
pushUnique(forbiddenActions, [
|
|
858
|
+
PR_CHECKPOINT_ACTION.MARK_READY_FOR_REVIEW,
|
|
859
|
+
PR_CHECKPOINT_ACTION.REQUEST_COPILOT_REVIEW,
|
|
860
|
+
PR_CHECKPOINT_ACTION.RUN_PRE_APPROVAL_GATE,
|
|
861
|
+
PR_CHECKPOINT_ACTION.AWAIT_FINAL_HUMAN_APPROVAL,
|
|
862
|
+
PR_CHECKPOINT_ACTION.DECLARE_MERGE_READY,
|
|
863
|
+
]);
|
|
864
|
+
return buildResult({
|
|
865
|
+
repo: input.repo ?? null,
|
|
866
|
+
pr: Number.isInteger(input.pr) ? input.pr : null,
|
|
867
|
+
currentHeadSha,
|
|
868
|
+
lifecycleState: effectiveLifecycleState,
|
|
869
|
+
loopDisposition: DISPOSITION.ACTION_REQUIRED,
|
|
870
|
+
gateBoundary: PR_CHECKPOINT.UI_E2E_SCOPING,
|
|
871
|
+
draftGateAlreadySatisfied,
|
|
872
|
+
draftGate,
|
|
873
|
+
preApprovalGate,
|
|
874
|
+
allowedNextActions,
|
|
875
|
+
forbiddenActions,
|
|
876
|
+
nextAction: PR_CHECKPOINT_ACTION.RUN_UI_E2E_SUITE,
|
|
877
|
+
reason: uiE2eScoping.reason,
|
|
878
|
+
mergeStateStatus,
|
|
879
|
+
conflictFiles,
|
|
880
|
+
refinementArtifact,
|
|
881
|
+
copilotReviewRoundCount,
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
839
885
|
if (prDraft || effectiveLifecycleState === STATE.PR_DRAFT) {
|
|
840
886
|
if (refinementArtifactStatus === REFINEMENT_ARTIFACT_STATUS.MISSING) {
|
|
841
887
|
pushUnique(allowedNextActions, [PR_CHECKPOINT_ACTION.REPORT_BLOCKED]);
|
package/src/loop/run-context.mjs
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
* The dev-loop async path keys off the harness-neutral `DEVLOOPS_RUN_ID` env var to
|
|
5
5
|
* identify an inspectable per-subagent run (runner ownership, async-start enforcement,
|
|
6
6
|
* human-comment gating), and provides a mint-and-propagate path for harnesses (e.g. Claude
|
|
7
|
-
* Code) that inject no native per-subagent run id.
|
|
8
|
-
* dispatching an async subagent.
|
|
7
|
+
* Code) that inject no native per-subagent run id. For those harnesses dev-loops itself mints
|
|
8
|
+
* and sets `DEVLOOPS_RUN_ID` when dispatching an async subagent.
|
|
9
|
+
*
|
|
10
|
+
* Other harnesses may already inject their own run-id var: the Pi runtime injects
|
|
11
|
+
* `PI_SUBAGENT_RUN_ID` (not `DEVLOOPS_RUN_ID`) into each async subagent's child env, so that
|
|
12
|
+
* name is honored as a recognized run-id alias (precedence after the neutral primary). It is
|
|
13
|
+
* an externally-injected Pi-runtime contract var, not a dev-loops-owned var — dev-loops still
|
|
14
|
+
* mints/propagates only the neutral `DEVLOOPS_RUN_ID`.
|
|
9
15
|
*
|
|
10
16
|
* This module is pure except for the explicit file/IO helpers (writeRunContext/readRunContext),
|
|
11
17
|
* which take an injectable `fs` and `root` for testability.
|
|
@@ -17,9 +23,10 @@ import path from "node:path";
|
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Env var names that carry the async-context run id, in resolution precedence order.
|
|
20
|
-
* The neutral `DEVLOOPS_RUN_ID` is the
|
|
26
|
+
* The neutral `DEVLOOPS_RUN_ID` is primary; `PI_SUBAGENT_RUN_ID` is the alias the Pi
|
|
27
|
+
* runtime injects into async-subagent child envs (the only run-id marker present under Pi).
|
|
21
28
|
*/
|
|
22
|
-
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID"]);
|
|
29
|
+
export const RUN_ID_MARKERS = Object.freeze(["DEVLOOPS_RUN_ID", "PI_SUBAGENT_RUN_ID"]);
|
|
23
30
|
|
|
24
31
|
/** Neutral env var name used when minting/propagating a run id. */
|
|
25
32
|
export const NEUTRAL_RUN_ID_VAR = "DEVLOOPS_RUN_ID";
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// UI e2e auto-scoping (issue #976).
|
|
2
|
+
//
|
|
3
|
+
// Deterministic, path-triggered criterion: a PR that adds or modifies a
|
|
4
|
+
// *rendered* HTML artifact (a presentation deck, an article page, or the
|
|
5
|
+
// inspect-run viewer's served page/component) MUST run the shared UI e2e
|
|
6
|
+
// assertions (mobile + desktop) AND have that artifact registered in the e2e
|
|
7
|
+
// suite (DECK_REGISTRY / ARTICLE_REGISTRY / VIEWER_REGISTRY). Inclusion is
|
|
8
|
+
// triggered by the changed-file set, never by a human annotating the PR.
|
|
9
|
+
//
|
|
10
|
+
// This module is the testable core of that criterion: classify changed paths
|
|
11
|
+
// → rendered-artifact set → check each is registered → fail closed if a
|
|
12
|
+
// rendered artifact changed with no registered/passing coverage.
|
|
13
|
+
|
|
14
|
+
// Explicit path globs for rendered artifacts. Kept conservative and explicit
|
|
15
|
+
// (issue #976 scope discipline): only artifacts that render to a page/component.
|
|
16
|
+
export const RENDERED_ARTIFACT_GLOBS = Object.freeze([
|
|
17
|
+
"docs/articles/*.html",
|
|
18
|
+
"docs/presentations/*.html",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// The inspect-run viewer is served from a component, not a static .html file,
|
|
22
|
+
// so its trigger is the served-page source (matches the existing
|
|
23
|
+
// inspect-run-viewer-ci-changes.mjs trigger seam).
|
|
24
|
+
export const VIEWER_SOURCE_PATHS = Object.freeze([
|
|
25
|
+
"scripts/loop/inspect-run-viewer.mjs",
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Registered artifacts — keyed by FULL repo-relative path (not basename), so
|
|
29
|
+
// docs/articles/X.html and docs/presentations/X.html (which share basenames,
|
|
30
|
+
// e.g. introducing-dev-loops.html) are DISTINCT and can never alias onto each
|
|
31
|
+
// other. Mirrors the registries' actual on-disk locations:
|
|
32
|
+
// decks → DECK_REGISTRY served from docs/presentations/<deck>
|
|
33
|
+
// articles→ ARTICLE_REGISTRY served from docs/articles/<file>
|
|
34
|
+
// Note: kept as an explicit list here rather than importing the harness
|
|
35
|
+
// (which pulls @playwright/test into core); the ui-e2e-scoping.test.mjs sync
|
|
36
|
+
// test fails if a registry entry is added without updating this list, so it
|
|
37
|
+
// can't silently drift.
|
|
38
|
+
export const REGISTERED_ARTIFACT_PATHS = Object.freeze([
|
|
39
|
+
"docs/presentations/introducing-dev-loops.html",
|
|
40
|
+
"docs/presentations/dev-loops-deep-dive.html",
|
|
41
|
+
"docs/articles/introducing-dev-loops.html",
|
|
42
|
+
"docs/articles/dev-loops-deep-dive.html",
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const VIEWER_ARTIFACT_ID = "inspect-run-viewer";
|
|
46
|
+
|
|
47
|
+
// CI check names that constitute the shared UI e2e coverage. The detect layer
|
|
48
|
+
// reads these from the statusCheckRollup to set uiE2ePassed. Note: a plain
|
|
49
|
+
// name match against the rollup is enough; the gate only needs to know whether
|
|
50
|
+
// the suite passed for this head. Each rendered-artifact family has a stable CI
|
|
51
|
+
// job whose name appears here; an absent check is unknown → fails closed.
|
|
52
|
+
export const UI_E2E_CHECK_NAMES = Object.freeze(["viewer-smoke", "deck-smoke", "article-smoke"]);
|
|
53
|
+
|
|
54
|
+
function normalizePath(filePath) {
|
|
55
|
+
return String(filePath ?? "").trim().replace(/^\.\/+/u, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Match a single explicit "dir/*.ext" glob (one path segment, no recursion).
|
|
59
|
+
function matchesGlob(normalizedPath, glob) {
|
|
60
|
+
const [dir, file] = [glob.slice(0, glob.lastIndexOf("/")), glob.slice(glob.lastIndexOf("/") + 1)];
|
|
61
|
+
if (!file.startsWith("*.")) return normalizedPath === glob;
|
|
62
|
+
const ext = file.slice(1); // ".html"
|
|
63
|
+
if (!normalizedPath.startsWith(`${dir}/`)) return false;
|
|
64
|
+
const rest = normalizedPath.slice(dir.length + 1);
|
|
65
|
+
return rest.length > 0 && !rest.includes("/") && rest.endsWith(ext);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Classify one changed path into a rendered-artifact descriptor, or null.
|
|
69
|
+
// A descriptor carries the path, a stable `id` (the deck filename or the
|
|
70
|
+
// viewer id) and whether that id is registered in the e2e suite.
|
|
71
|
+
export function classifyRenderedArtifactPath(filePath) {
|
|
72
|
+
const normalized = normalizePath(filePath);
|
|
73
|
+
if (normalized.length === 0) return null;
|
|
74
|
+
|
|
75
|
+
if (VIEWER_SOURCE_PATHS.includes(normalized)) {
|
|
76
|
+
return { path: normalized, kind: "viewer", id: VIEWER_ARTIFACT_ID, registered: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const glob of RENDERED_ARTIFACT_GLOBS) {
|
|
80
|
+
if (matchesGlob(normalized, glob)) {
|
|
81
|
+
// Key registration on the FULL repo-relative path so an article and a
|
|
82
|
+
// deck that share a basename are distinct artifacts. id is the full path
|
|
83
|
+
// too, so the fail-closed reason names the exact file to register.
|
|
84
|
+
return {
|
|
85
|
+
path: normalized,
|
|
86
|
+
kind: normalized.startsWith("docs/articles/") ? "article" : "deck",
|
|
87
|
+
id: normalized,
|
|
88
|
+
registered: REGISTERED_ARTIFACT_PATHS.includes(normalized),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deterministic UI e2e scoping check.
|
|
97
|
+
*
|
|
98
|
+
* @param {string[]} changedPaths - PR changed-file paths.
|
|
99
|
+
* @param {{ uiE2ePassed?: boolean|null }} [coverage]
|
|
100
|
+
* uiE2ePassed: whether the shared UI e2e suite passed for this head.
|
|
101
|
+
* null/undefined means "not run / unknown" → fails closed.
|
|
102
|
+
* @returns {{
|
|
103
|
+
* required: boolean,
|
|
104
|
+
* artifacts: Array<{path,kind,id,registered}>,
|
|
105
|
+
* unregistered: string[],
|
|
106
|
+
* satisfied: boolean,
|
|
107
|
+
* reason: string|null,
|
|
108
|
+
* }}
|
|
109
|
+
*/
|
|
110
|
+
export function evaluateUiE2eScoping(changedPaths = [], { uiE2ePassed = null } = {}) {
|
|
111
|
+
const artifacts = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
for (const p of Array.isArray(changedPaths) ? changedPaths : []) {
|
|
114
|
+
const descriptor = classifyRenderedArtifactPath(p);
|
|
115
|
+
if (descriptor && !seen.has(descriptor.path)) {
|
|
116
|
+
seen.add(descriptor.path);
|
|
117
|
+
artifacts.push(descriptor);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const required = artifacts.length > 0;
|
|
122
|
+
if (!required) {
|
|
123
|
+
return { required: false, artifacts, unregistered: [], satisfied: true, reason: null };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fail closed: any touched rendered artifact that is not registered blocks
|
|
127
|
+
// and names itself so the fix is unambiguous (register it in the suite).
|
|
128
|
+
const unregistered = artifacts.filter((a) => !a.registered).map((a) => a.id);
|
|
129
|
+
if (unregistered.length > 0) {
|
|
130
|
+
return {
|
|
131
|
+
required: true,
|
|
132
|
+
artifacts,
|
|
133
|
+
unregistered,
|
|
134
|
+
satisfied: false,
|
|
135
|
+
reason:
|
|
136
|
+
`UI e2e coverage is required: this PR changes rendered artifact(s) ` +
|
|
137
|
+
`${unregistered.join(", ")} that are not registered in the shared UI e2e suite ` +
|
|
138
|
+
`(DECK_REGISTRY or ARTICLE_REGISTRY in test/playwright/harness/deck-fit-harness.mjs, ` +
|
|
139
|
+
`or VIEWER_REGISTRY in test/playwright/harness/inspect-run-viewer-harness.mjs). ` +
|
|
140
|
+
`Register the artifact and add a spec that runs ` +
|
|
141
|
+
`the mobile + desktop assertions before this gate can pass.`,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All touched artifacts are registered — coverage must have actually passed.
|
|
146
|
+
if (uiE2ePassed !== true) {
|
|
147
|
+
const touched = artifacts.map((a) => a.id).join(", ");
|
|
148
|
+
return {
|
|
149
|
+
required: true,
|
|
150
|
+
artifacts,
|
|
151
|
+
unregistered: [],
|
|
152
|
+
satisfied: false,
|
|
153
|
+
reason:
|
|
154
|
+
`UI e2e coverage is required: this PR changes rendered artifact(s) ${touched}, ` +
|
|
155
|
+
`but the shared UI e2e suite (mobile + desktop) has not passed for this head ` +
|
|
156
|
+
`(uiE2ePassed=${String(uiE2ePassed)}). Run the UI/mobile e2e loop and let it pass ` +
|
|
157
|
+
`before this gate can proceed.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { required: true, artifacts, unregistered: [], satisfied: true, reason: null };
|
|
162
|
+
}
|