@mmerterden/multi-agent-pipeline 20.27.2 → 20.29.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/CHANGELOG.md +41 -0
- package/README.md +20 -6
- package/README.tr.md +6 -5
- package/docs/adr/0008-installer-modularization-and-secret-leak-defense.md +0 -1
- package/docs/architecture.md +4 -4
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +14 -12
- package/docs/features.md +2 -0
- package/install/_dev-only-files.mjs +1 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +112 -0
- package/install/index.mjs +0 -2
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +101 -67
- package/package.json +4 -1
- package/pipeline/agents/code-reviewer.md +1 -0
- package/pipeline/agents/test-gen-analyst.md +98 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
- package/pipeline/contract/types/index.d.ts +1 -1
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
- package/pipeline/multi-agent-refs/features/doctor.md +24 -0
- package/pipeline/multi-agent-refs/features/test-classification.md +76 -0
- package/pipeline/multi-agent-refs/features/usage-reporting.md +17 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/operations.md +19 -2
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +7 -8
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +10 -8
- package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
- package/pipeline/preferences-template.json +10 -0
- package/pipeline/rules/figma-pipeline.md +2 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +51 -0
- package/pipeline/schemas/runs-index.schema.json +1 -1
- package/pipeline/schemas/test-gen-discovery.schema.json +73 -0
- package/pipeline/schemas/test-gen-gaps.schema.json +122 -0
- package/pipeline/schemas/test-gen-inventory.schema.json +59 -0
- package/pipeline/schemas/test-gen-results.schema.json +44 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_handle-patterns.mjs +166 -0
- package/pipeline/scripts/_http-post.mjs +141 -0
- package/pipeline/scripts/_stack-routing.mjs +4 -0
- package/pipeline/scripts/_test-gaming.mjs +227 -0
- package/pipeline/scripts/_unified-diff.mjs +104 -0
- package/pipeline/scripts/_worktree-snapshot.mjs +113 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/doctor.mjs +105 -0
- package/pipeline/scripts/evidence-gate.mjs +82 -7
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/handle-diff-audit.mjs +188 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +5 -1
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- package/pipeline/scripts/run-ui-tests.sh +27 -3
- package/pipeline/scripts/test-classify.mjs +296 -0
- package/pipeline/scripts/test-gen-gate.mjs +409 -0
- package/pipeline/scripts/test-gen-state.mjs +313 -0
- package/pipeline/scripts/test-integrity-gate.mjs +76 -11
- package/pipeline/scripts/uninstall.mjs +2 -0
- package/pipeline/scripts/usage-register-beacon.mjs +239 -0
- package/pipeline/scripts/usage-register.mjs +128 -33
- package/pipeline/scripts/usage-report.mjs +43 -33
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +23 -3
- package/pipeline/skills/.skills-index.json +57 -2
- package/pipeline/skills/shared/README.md +10 -5
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +120 -0
- package/pipeline/skills/shared/external/fix-review-verdicts/SKILL.md +133 -0
- package/pipeline/skills/shared/external/handle-request/SKILL.md +97 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/SKILL.md +129 -0
- package/pipeline/skills/shared/external/scenario-gap-analysis/references/scenario-schema.md +60 -0
- package/pipeline/skills/shared/external/test-result-triage/SKILL.md +160 -0
- package/pipeline/skills/skills-index.md +7 -2
- package/install/_telemetry.mjs +0 -61
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* run-stop-guard.mjs - a session driving a pipeline run does not end its turn
|
|
4
|
+
* mid-phase without saying why.
|
|
5
|
+
*
|
|
6
|
+
* Claude Code Stop hook. When the session that is driving an active run tries
|
|
7
|
+
* to stop while the run is `in_progress`, below its mode's last phase, with no
|
|
8
|
+
* `haltReason` and no `waitingFor`, it answers
|
|
9
|
+
* `{"decision":"block","reason":...}`. The reason names the phase to finish
|
|
10
|
+
* and the two legal ways out: finish it, or record a halt (operations.md,
|
|
11
|
+
* "Halt visibility").
|
|
12
|
+
*
|
|
13
|
+
* Which run: _active-run.mjs findActiveRun, and only a run bound to this
|
|
14
|
+
* session by `sessionId` (MULTI_AGENT_SESSION_ID) or `hostSessionId`. A run
|
|
15
|
+
* found only because its worktree is the session's cwd never holds a stop: an
|
|
16
|
+
* old local-mode run has the project root as its worktree and would trap
|
|
17
|
+
* every later session opened there. No bound run: exit 0, no output.
|
|
18
|
+
*
|
|
19
|
+
* The stop is let through, with no output, whenever the session has a reason
|
|
20
|
+
* to stop that the state does not record:
|
|
21
|
+
* - the turn ends waiting for the user: `last_assistant_message` ends in a
|
|
22
|
+
* question or asks for a reply (the pipeline's plain-text waits: plan
|
|
23
|
+
* approval, commit, PR, a local-test handoff);
|
|
24
|
+
* - background tasks or scheduled crons are running, so the run is driven
|
|
25
|
+
* elsewhere and this session is waiting;
|
|
26
|
+
* - prefs.global.stopGuard.enabled is false.
|
|
27
|
+
*
|
|
28
|
+
* SubagentStop is never held: a subagent finishes its piece and the session
|
|
29
|
+
* that dispatched it decides what comes next.
|
|
30
|
+
*
|
|
31
|
+
* Repeat ceiling: the host sets `stop_hook_active` while it continues because
|
|
32
|
+
* of a Stop hook, and overrides a hook that blocks too many times in a row.
|
|
33
|
+
* This hook counts its own consecutive blocks per run in `.stop-guard.json`
|
|
34
|
+
* next to the state. After REPEAT_LIMIT blocks it stops blocking, writes the
|
|
35
|
+
* halt record itself (`status: paused`, `haltReason: "<phase>:stop-guard: ..."`)
|
|
36
|
+
* and tells the user through `systemMessage`. write-state.mjs clears that
|
|
37
|
+
* halt the moment the run advances a phase, so it never outlives the run
|
|
38
|
+
* moving on. A stop with `stop_hook_active` false is a new turn and starts the
|
|
39
|
+
* count again.
|
|
40
|
+
*
|
|
41
|
+
* This is a completion aid, not a security gate: any error (unparseable
|
|
42
|
+
* payload, unreadable state, a failed write) exits 0 with no decision and a
|
|
43
|
+
* line in <log root>/hook-failures.log.
|
|
44
|
+
*
|
|
45
|
+
* @module pipeline/scripts/run-stop-guard
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
|
|
51
|
+
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
52
|
+
import { globalPref } from "../lib/prefs-value.mjs";
|
|
53
|
+
import {
|
|
54
|
+
finalPhaseOf,
|
|
55
|
+
findActiveRun,
|
|
56
|
+
logHookFailure,
|
|
57
|
+
modeOf,
|
|
58
|
+
phaseName,
|
|
59
|
+
runDirOf,
|
|
60
|
+
} from "./_active-run.mjs";
|
|
61
|
+
|
|
62
|
+
export const REPEAT_LIMIT = 3;
|
|
63
|
+
export const STOP_GUARD_HALT = "stop-guard:";
|
|
64
|
+
const COUNTER_FILE = ".stop-guard.json";
|
|
65
|
+
const HOOK = "run-stop-guard";
|
|
66
|
+
const SESSION_BOUND = new Set(["session", "host-session"]);
|
|
67
|
+
|
|
68
|
+
// A reply the turn asks for, in the closing text: a question mark (ASCII or
|
|
69
|
+
// full-width) at the end of a line, or a request addressed to the user. A
|
|
70
|
+
// request is an imperative that opens a sentence ("Confirm the plan.") or a
|
|
71
|
+
// second-person phrase ("can you", "tell me"); a reply verb inside a
|
|
72
|
+
// statement ("Next I will confirm the build") is the model's own plan and
|
|
73
|
+
// does not count, nor does one used as a label ("Answer: the cache was stale").
|
|
74
|
+
const QUESTION_END = /[??]\s*(?:\(.{0,40}\))?\s*$/;
|
|
75
|
+
const REPLY_VERB =
|
|
76
|
+
"(?:reply|respond|answer|confirm|choose|pick|approve|send|write your|tell me|let me know)";
|
|
77
|
+
const ASKS_FOR_REPLY = new RegExp(
|
|
78
|
+
[
|
|
79
|
+
`(?:^|[.!:;]\\s+)(?:please\\s+)?${REPLY_VERB}\\b(?!\\s*:)`,
|
|
80
|
+
`\\bplease\\s+${REPLY_VERB}\\b(?!\\s*:)`,
|
|
81
|
+
"\\b(?:can|could|would|will) you\\b",
|
|
82
|
+
"\\b(?:do|would) you (?:want|like|prefer)\\b",
|
|
83
|
+
"\\b(?:let me know|tell me)\\b",
|
|
84
|
+
"\\b(?:reply|respond|answer) with\\b",
|
|
85
|
+
"\\(\\s*(?:yes|y)\\s*\\/\\s*(?:no|n)\\s*\\)",
|
|
86
|
+
].join("|"),
|
|
87
|
+
"im",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* True when the closing assistant text waits for the user.
|
|
92
|
+
*
|
|
93
|
+
* @param {unknown} message last_assistant_message
|
|
94
|
+
*/
|
|
95
|
+
export function asksTheUser(message) {
|
|
96
|
+
if (typeof message !== "string" || !message.trim()) return false;
|
|
97
|
+
const lines = message
|
|
98
|
+
.trim()
|
|
99
|
+
.split("\n")
|
|
100
|
+
.map((l) => l.replace(/[*_`>#]+/g, "").trim())
|
|
101
|
+
.filter(Boolean);
|
|
102
|
+
const tail = lines.slice(-3);
|
|
103
|
+
if (tail.some((l) => QUESTION_END.test(l))) return true;
|
|
104
|
+
return ASKS_FOR_REPLY.test(tail.join("\n"));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Why the run may stop now, or null when it is mid-phase and must not.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} state
|
|
111
|
+
* @returns {string|null}
|
|
112
|
+
*/
|
|
113
|
+
export function stopAllowedBecause(state) {
|
|
114
|
+
if (!state || typeof state !== "object") return "no state";
|
|
115
|
+
if (state.status !== "in_progress") return `status is ${state.status ?? "unset"}`;
|
|
116
|
+
if (state.haltReason) return "a halt is recorded";
|
|
117
|
+
if (state.waitingFor) return `waiting for ${state.waitingFor}`;
|
|
118
|
+
const final = finalPhaseOf(state);
|
|
119
|
+
if (final === null) return `mode ${modeOf(state)} is not declared in phases.json`;
|
|
120
|
+
const cur = Number(state.currentPhase);
|
|
121
|
+
if (!Number.isInteger(cur)) return "currentPhase is not recorded";
|
|
122
|
+
if (cur >= final) return "the final phase is reached";
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The block reason: where the run is and the two ways out. */
|
|
127
|
+
export function blockReason(state, statePath) {
|
|
128
|
+
const cur = Number(state.currentPhase);
|
|
129
|
+
const final = finalPhaseOf(state);
|
|
130
|
+
const id = state.taskId || "this run";
|
|
131
|
+
return [
|
|
132
|
+
`Pipeline run ${id} (mode ${modeOf(state)}) is at Phase ${cur} (${phaseName(cur)}) of ${final}, status in_progress, and no halt or wait is recorded.`,
|
|
133
|
+
`Now finish Phase ${cur} (${phaseName(cur)}) and go on as its document says.`,
|
|
134
|
+
`If you are waiting for the user, ask the question so the turn ends on it, or record the wait: printf '{"waitingFor":"attended-reply"}' | node "$HOME/.claude/scripts/write-state.mjs" "${statePath}".`,
|
|
135
|
+
`If the run cannot go on, record the halt before stopping: printf '{"status":"paused","haltReason":"${cur}:<cause>"}' | node "$HOME/.claude/scripts/write-state.mjs" "${statePath}" (operations.md, Halt visibility).`,
|
|
136
|
+
].join(" ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readCounter(dir) {
|
|
140
|
+
try {
|
|
141
|
+
const doc = JSON.parse(readFileSync(join(dir, COUNTER_FILE), "utf8"));
|
|
142
|
+
return Number.isInteger(doc?.count) ? doc.count : 0;
|
|
143
|
+
} catch {
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function writeCounter(dir, count) {
|
|
149
|
+
writeFileSync(
|
|
150
|
+
join(dir, COUNTER_FILE),
|
|
151
|
+
`${JSON.stringify({ count, at: new Date().toISOString() })}\n`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function clearCounter(dir) {
|
|
156
|
+
rmSync(join(dir, COUNTER_FILE), { force: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function recordHalt(statePath, state, blocks) {
|
|
160
|
+
const { updateState } = await import("./gate-ledger.mjs");
|
|
161
|
+
const cur = Number(state.currentPhase);
|
|
162
|
+
const cause = `${STOP_GUARD_HALT} the guard blocked ${blocks} consecutive stops before Phase ${cur} (${phaseName(cur)}) finished`;
|
|
163
|
+
await updateState(statePath, (s) => ({
|
|
164
|
+
...s,
|
|
165
|
+
status: "paused",
|
|
166
|
+
haltReason: `${cur}:${cause}`,
|
|
167
|
+
}));
|
|
168
|
+
return cause;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const nonEmpty = (v) => Array.isArray(v) && v.length > 0;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The hook's answer for one payload.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} payload
|
|
177
|
+
* @param {Record<string,string|undefined>} [env]
|
|
178
|
+
* @returns {Promise<object|null>} the JSON to print, or null for no output
|
|
179
|
+
*/
|
|
180
|
+
export async function decide(payload, env = process.env) {
|
|
181
|
+
if (globalPref("stopGuard.enabled", true, env) === false) return null;
|
|
182
|
+
if (!payload || typeof payload !== "object") return null;
|
|
183
|
+
const event = payload.hook_event_name || "Stop";
|
|
184
|
+
if (event !== "Stop") return null;
|
|
185
|
+
if (nonEmpty(payload.background_tasks) || nonEmpty(payload.session_crons)) return null;
|
|
186
|
+
if (asksTheUser(payload.last_assistant_message)) return null;
|
|
187
|
+
const run = findActiveRun({
|
|
188
|
+
sessionId: typeof payload.session_id === "string" ? payload.session_id : null,
|
|
189
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : null,
|
|
190
|
+
env,
|
|
191
|
+
});
|
|
192
|
+
if (!run || !SESSION_BOUND.has(run.matchedBy)) {
|
|
193
|
+
if (!run && findActiveRun.lastUnreadable > 0) {
|
|
194
|
+
logHookFailure(
|
|
195
|
+
HOOK,
|
|
196
|
+
`${findActiveRun.lastUnreadable} run state file(s) did not parse; allowed the stop`,
|
|
197
|
+
env,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
const dir = runDirOf(run.statePath);
|
|
203
|
+
if (stopAllowedBecause(run.state) !== null) {
|
|
204
|
+
if (existsSync(join(dir, COUNTER_FILE))) clearCounter(dir);
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const prior = payload.stop_hook_active === true ? readCounter(dir) : 0;
|
|
209
|
+
if (prior >= REPEAT_LIMIT) {
|
|
210
|
+
const cause = await recordHalt(run.statePath, run.state, prior);
|
|
211
|
+
clearCounter(dir);
|
|
212
|
+
return {
|
|
213
|
+
systemMessage: `run-stop-guard: recorded a halt on ${run.state.taskId || run.statePath} (${cause}). Resume it with /multi-agent:resume.`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
writeCounter(dir, prior + 1);
|
|
217
|
+
return { decision: "block", reason: blockReason(run.state, run.statePath) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
let raw;
|
|
222
|
+
try {
|
|
223
|
+
raw = readFileSync(0, "utf8");
|
|
224
|
+
} catch {
|
|
225
|
+
raw = "";
|
|
226
|
+
}
|
|
227
|
+
let payload;
|
|
228
|
+
try {
|
|
229
|
+
payload = JSON.parse(raw);
|
|
230
|
+
} catch {
|
|
231
|
+
logHookFailure(HOOK, "the hook payload is not JSON; allowed the stop");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const out = await decide(payload);
|
|
236
|
+
if (out) process.stdout.write(`${JSON.stringify(out)}\n`);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
logHookFailure(HOOK, `${err?.message || err}; allowed the stop`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const onFatal = (err) => logHookFailure(HOOK, `${err?.message || err}; allowed the stop`);
|
|
243
|
+
|
|
244
|
+
// Exit 0 on every path: nothing above sets a non-zero code, and a failure
|
|
245
|
+
// is logged and allowed.
|
|
246
|
+
if (invokedDirectly(import.meta.url)) main().catch(onFatal);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# run-stop-guard.sh - Stop hook for the multi-agent pipeline.
|
|
3
|
+
#
|
|
4
|
+
# A session driving a pipeline run that tries to end its turn mid-run, with no
|
|
5
|
+
# halt recorded, is told to continue or record the halt. The decision is in
|
|
6
|
+
# run-stop-guard.mjs; this wrapper exists because every hook the pipeline
|
|
7
|
+
# registers is a `bash "$HOME/.<host>/scripts/<name>"` command.
|
|
8
|
+
#
|
|
9
|
+
# Contract (Claude Code Stop hook):
|
|
10
|
+
# - Reads the hook JSON on stdin.
|
|
11
|
+
# - Prints {"decision":"block","reason":...} to keep the session going, or
|
|
12
|
+
# {"systemMessage":...} when it gives up and records the halt itself, or
|
|
13
|
+
# nothing.
|
|
14
|
+
# - Exits 0 on every path. A missing node or any internal error means no
|
|
15
|
+
# output: this is a completion aid, not a security gate.
|
|
16
|
+
|
|
17
|
+
set -u
|
|
18
|
+
|
|
19
|
+
HERE="$(cd "$(dirname "$0")" 2>/dev/null && pwd || true)"
|
|
20
|
+
CORE="$HERE/run-stop-guard.mjs"
|
|
21
|
+
[ -f "$CORE" ] || exit 0
|
|
22
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
23
|
+
|
|
24
|
+
node "$CORE" 2>/dev/null || true
|
|
25
|
+
exit 0
|
|
@@ -27,7 +27,15 @@
|
|
|
27
27
|
#
|
|
28
28
|
# run-ui-tests.sh run --platform <ios|android|web> [--repo <path>] [--changed <f>...]
|
|
29
29
|
# [--device <udid|serial>] [--log <path>] [--all]
|
|
30
|
-
# Run the matching tests (or the whole UI suite with --all).
|
|
30
|
+
# Run the matching tests (or the whole UI suite with --all). Prints
|
|
31
|
+
# UI_TEST_LOG=<path>
|
|
32
|
+
# UI_TEST_SELECTED=<what ran>
|
|
33
|
+
# UI_TEST_RESULT=<per-test report>| (empty when the runner has none)
|
|
34
|
+
# UI_TEST_RESULT_FORMAT=xcresult|junit|
|
|
35
|
+
# The report sits next to the log: <log stem>.xcresult on iOS,
|
|
36
|
+
# <log stem>.junit.xml for Playwright, the module's connected test results
|
|
37
|
+
# directory on Android. It is what test_results_classify reads per test;
|
|
38
|
+
# the log alone says only whether the run passed.
|
|
31
39
|
#
|
|
32
40
|
# Env:
|
|
33
41
|
# UI_TEST_LOG default "$PWD/.pipeline/ui-test.log"
|
|
@@ -396,6 +404,7 @@ fi
|
|
|
396
404
|
}
|
|
397
405
|
|
|
398
406
|
mkdir -p "$(dirname "$LOG")"
|
|
407
|
+
RESULT=""; RESULT_FORMAT=""
|
|
399
408
|
|
|
400
409
|
case "$PLATFORM" in
|
|
401
410
|
ios)
|
|
@@ -434,9 +443,13 @@ case "$PLATFORM" in
|
|
|
434
443
|
[ -n "$DEVICE" ] || { echo "run-ui-tests: no booted simulator and no --device" >&2; exit 2; }
|
|
435
444
|
fi
|
|
436
445
|
DEST="platform=iOS Simulator,id=$DEVICE"
|
|
446
|
+
# xcodebuild refuses a result bundle path that already exists.
|
|
447
|
+
RESULT="${LOG%.log}.xcresult"
|
|
448
|
+
rm -rf "$RESULT"
|
|
437
449
|
(cd "$REPO" && xcodebuild test "${CONTAINER_ARGV[@]}" -scheme "$SCHEME" \
|
|
438
|
-
-destination "$DEST" "${ONLY_ARGV[@]}") >"$LOG" 2>&1
|
|
450
|
+
-destination "$DEST" -resultBundlePath "$RESULT" "${ONLY_ARGV[@]}") >"$LOG" 2>&1
|
|
439
451
|
RC=$?
|
|
452
|
+
if [ -d "$RESULT" ]; then RESULT_FORMAT="xcresult"; else RESULT=""; fi
|
|
440
453
|
;;
|
|
441
454
|
android)
|
|
442
455
|
command -v adb >/dev/null 2>&1 || { echo "run-ui-tests: adb unavailable" >&2; exit 2; }
|
|
@@ -450,6 +463,11 @@ case "$PLATFORM" in
|
|
|
450
463
|
# shellcheck disable=SC2086
|
|
451
464
|
(cd "$REPO" && "$GRADLE" "$TARGET" $ARGS) >"$LOG" 2>&1
|
|
452
465
|
RC=$?
|
|
466
|
+
MODULE_DIR=$(printf '%s\n' "$TARGET" | sed 's#:connectedAndroidTest$##; s#^:##; s#:#/#g')
|
|
467
|
+
if [ -d "$REPO/$MODULE_DIR/build/outputs/androidTest-results/connected" ]; then
|
|
468
|
+
RESULT="$REPO/$MODULE_DIR/build/outputs/androidTest-results/connected"
|
|
469
|
+
RESULT_FORMAT="junit"
|
|
470
|
+
fi
|
|
453
471
|
;;
|
|
454
472
|
web)
|
|
455
473
|
command -v npx >/dev/null 2>&1 || { echo "run-ui-tests: npx unavailable" >&2; exit 2; }
|
|
@@ -459,7 +477,10 @@ case "$PLATFORM" in
|
|
|
459
477
|
# --no-install: npx would otherwise DOWNLOAD the runner on a machine that
|
|
460
478
|
# does not have it, which turns "this repo has no UI tests" into a silent
|
|
461
479
|
# network fetch mid-phase.
|
|
462
|
-
WEB_ARGV=(npx --no-install playwright test --reporter=line)
|
|
480
|
+
WEB_ARGV=(npx --no-install playwright test "--reporter=line,junit")
|
|
481
|
+
RESULT="${LOG%.log}.junit.xml"
|
|
482
|
+
rm -f "$RESULT"
|
|
483
|
+
export PLAYWRIGHT_JUNIT_OUTPUT_NAME="$RESULT"
|
|
463
484
|
if [ "$RUN_ALL" -eq 0 ]; then
|
|
464
485
|
OLDIFS="$IFS"; IFS=','
|
|
465
486
|
for m in $MATCHES; do WEB_ARGV+=("$m"); done
|
|
@@ -474,11 +495,14 @@ case "$PLATFORM" in
|
|
|
474
495
|
esac
|
|
475
496
|
(cd "$REPO" && "${WEB_ARGV[@]}") >"$LOG" 2>&1
|
|
476
497
|
RC=$?
|
|
498
|
+
if [ -n "$RESULT" ] && [ -s "$RESULT" ]; then RESULT_FORMAT="junit"; else RESULT=""; fi
|
|
477
499
|
;;
|
|
478
500
|
esac
|
|
479
501
|
|
|
480
502
|
printf 'UI_TEST_LOG=%s\n' "$LOG"
|
|
481
503
|
printf 'UI_TEST_SELECTED=%s\n' "${MATCHES:-$TARGET}"
|
|
504
|
+
printf 'UI_TEST_RESULT=%s\n' "$RESULT"
|
|
505
|
+
printf 'UI_TEST_RESULT_FORMAT=%s\n' "$RESULT_FORMAT"
|
|
482
506
|
|
|
483
507
|
# The exit code alone is not the verdict here for the same reason it is not one
|
|
484
508
|
# for the build: a runner that died before it reached the tests also exits
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file test-classify.mjs - per-test classifications to the verdict the gates read.
|
|
4
|
+
*
|
|
5
|
+
* A red test is a claim about the change only when the base was green and the
|
|
6
|
+
* test stayed red on a second run. The toolkit's `test_results_classify`
|
|
7
|
+
* (multi-agent-toolkit-mcp >= 3.25.0) measures that per test:
|
|
8
|
+
* passed | regression | preexisting | unstable | not_measured. This script
|
|
9
|
+
* takes its answer and applies the pipeline's own rules on top:
|
|
10
|
+
*
|
|
11
|
+
* - `unstable` is never green: a pass only on retry is a flake signal.
|
|
12
|
+
* - `not_measured` stays red, with its reason: no base to compare is not a
|
|
13
|
+
* clean base.
|
|
14
|
+
* - the Phase 0 test baseline (`state.baseline.tests`, status red with
|
|
15
|
+
* `failing[]`) joins the preexisting set. A test it lists that the toolkit
|
|
16
|
+
* could not measure becomes preexisting; a test it lists that the toolkit
|
|
17
|
+
* calls a regression (base measured green) becomes unstable, because the
|
|
18
|
+
* base disagreed with itself.
|
|
19
|
+
* - a tripped breaker (the same failure signature across most runs: an
|
|
20
|
+
* environment, not the tests) is not green.
|
|
21
|
+
* - nothing classified is not green.
|
|
22
|
+
*
|
|
23
|
+
* On a toolkit without the tool, `fallback` classifies from the repeat runs
|
|
24
|
+
* alone (JUnit XML): runs that disagree, or one run that saw a test both fail
|
|
25
|
+
* and pass (a retry, per-device reports), are `unstable`, red in every run is
|
|
26
|
+
* `not_measured` (there is no base run), green in every run is `passed`. The
|
|
27
|
+
* baseline join and the verdict are the same.
|
|
28
|
+
*
|
|
29
|
+
* Usage:
|
|
30
|
+
* node test-classify.mjs merge --classified <toolkit-output.json> [--state <agent-state.json>] [--out <path>]
|
|
31
|
+
* node test-classify.mjs fallback --run <junit.xml|dir> [--run ...] [--state <agent-state.json>] [--out <path>]
|
|
32
|
+
*
|
|
33
|
+
* Output: the verdict as JSON on stdout (and to --out):
|
|
34
|
+
* { schemaVersion, source, green, reason, tests: [{id, status, reason?}],
|
|
35
|
+
* summary: {passed, regression, preexisting, unstable, not_measured},
|
|
36
|
+
* blocking: [ids], inherited: [ids], breaker }
|
|
37
|
+
* Exit: 0 green, 1 not green, 2 usage, 3 input unreadable or malformed,
|
|
38
|
+
* 4 internal error (a crash, never a verdict)
|
|
39
|
+
*
|
|
40
|
+
* @module pipeline/scripts/test-classify
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
44
|
+
import { dirname, join } from "node:path";
|
|
45
|
+
import { pathToFileURL } from "node:url";
|
|
46
|
+
|
|
47
|
+
export const STATUSES = ["passed", "regression", "preexisting", "unstable", "not_measured"];
|
|
48
|
+
const BLOCKING = new Set(["regression", "unstable", "not_measured"]);
|
|
49
|
+
|
|
50
|
+
class InputError extends Error {}
|
|
51
|
+
|
|
52
|
+
function usage(msg) {
|
|
53
|
+
process.stderr.write(`test-classify: ${msg}\n`);
|
|
54
|
+
process.exit(2);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseArgs(argv) {
|
|
58
|
+
const out = { run: [] };
|
|
59
|
+
for (let i = 0; i < argv.length; i++) {
|
|
60
|
+
const a = argv[i];
|
|
61
|
+
if (!a.startsWith("--")) continue;
|
|
62
|
+
const v = argv[i + 1];
|
|
63
|
+
if (v === undefined || v.startsWith("--")) usage(`${a} needs a value`);
|
|
64
|
+
if (a === "--run") out.run.push(v);
|
|
65
|
+
else out[a.slice(2)] = v;
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readJson(path, what) {
|
|
72
|
+
let body;
|
|
73
|
+
try {
|
|
74
|
+
body = readFileSync(path, "utf8");
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new InputError(`cannot read ${what} ${path}: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(body);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
throw new InputError(`${what} ${path} is not JSON: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Test ids compared across runners: case, `()`, and `/` `::` `#` separators ignored. */
|
|
86
|
+
export function normalizeId(id) {
|
|
87
|
+
return String(id)
|
|
88
|
+
.trim()
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.replace(/\(\)$/, "")
|
|
91
|
+
.replace(/::|[/#]/g, ".");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The Phase 0 baseline's failing ids, or [] when it recorded none. */
|
|
95
|
+
function baselineFailing(state) {
|
|
96
|
+
const t = state?.baseline?.tests;
|
|
97
|
+
if (!t || t.status !== "red" || !Array.isArray(t.failing)) return { ids: [], logPath: null };
|
|
98
|
+
return { ids: t.failing.map(String), logPath: t.logPath || null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Apply the pipeline's rules to classified tests.
|
|
103
|
+
*
|
|
104
|
+
* @param {{tests: {id: string, status: string, reason?: string}[], breaker?: object}} classified
|
|
105
|
+
* @param {object|null} state agent-state.json, for the Phase 0 baseline
|
|
106
|
+
* @param {"toolkit"|"fallback"} source
|
|
107
|
+
*/
|
|
108
|
+
export function verdict(classified, state, source) {
|
|
109
|
+
if (!Array.isArray(classified?.tests)) throw new InputError("classification has no tests[]");
|
|
110
|
+
const base = baselineFailing(state);
|
|
111
|
+
const inBase = new Set(base.ids.map(normalizeId));
|
|
112
|
+
const where = base.logPath ? ` (${base.logPath})` : "";
|
|
113
|
+
const tests = classified.tests.map((t) => {
|
|
114
|
+
if (!t || typeof t.id !== "string" || !STATUSES.includes(t.status)) {
|
|
115
|
+
throw new InputError(`not a classified test: ${JSON.stringify(t)}`);
|
|
116
|
+
}
|
|
117
|
+
const listed = inBase.has(normalizeId(t.id));
|
|
118
|
+
if (listed && t.status === "not_measured") {
|
|
119
|
+
return {
|
|
120
|
+
...t,
|
|
121
|
+
status: "preexisting",
|
|
122
|
+
reason: `listed failing in the Phase 0 baseline${where}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (listed && t.status === "regression") {
|
|
126
|
+
return {
|
|
127
|
+
...t,
|
|
128
|
+
status: "unstable",
|
|
129
|
+
reason: `the base measured green here, but the Phase 0 baseline lists it failing${where}: the base disagrees with itself`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { ...t };
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const summary = Object.fromEntries(STATUSES.map((s) => [s, 0]));
|
|
136
|
+
for (const t of tests) summary[t.status]++;
|
|
137
|
+
const blocking = tests.filter((t) => BLOCKING.has(t.status)).map((t) => t.id);
|
|
138
|
+
const inherited = [
|
|
139
|
+
...new Set([...tests.filter((t) => t.status === "preexisting").map((t) => t.id), ...base.ids]),
|
|
140
|
+
];
|
|
141
|
+
const breaker = classified.breaker?.tripped ? classified.breaker : null;
|
|
142
|
+
|
|
143
|
+
let reason;
|
|
144
|
+
if (breaker) {
|
|
145
|
+
reason = `breaker tripped on "${breaker.signature}": ${breaker.reason || "one failure signature across most runs"}`;
|
|
146
|
+
} else if (tests.length === 0) {
|
|
147
|
+
reason = "no test was classified";
|
|
148
|
+
} else if (blocking.length) {
|
|
149
|
+
reason = `${blocking.length} test(s) not green: ${STATUSES.filter(
|
|
150
|
+
(s) => BLOCKING.has(s) && summary[s],
|
|
151
|
+
)
|
|
152
|
+
.map((s) => `${summary[s]} ${s}`)
|
|
153
|
+
.join(", ")}`;
|
|
154
|
+
} else {
|
|
155
|
+
reason = `${summary.passed} passed${summary.preexisting ? `, ${summary.preexisting} preexisting (inherited)` : ""}`;
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
schemaVersion: "1.0.0",
|
|
159
|
+
source,
|
|
160
|
+
green: !breaker && tests.length > 0 && blocking.length === 0,
|
|
161
|
+
reason,
|
|
162
|
+
tests,
|
|
163
|
+
summary,
|
|
164
|
+
blocking,
|
|
165
|
+
inherited,
|
|
166
|
+
breaker,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Per-test outcomes in one JUnit document, every occurrence kept:
|
|
172
|
+
* Map<id, ("pass"|"fail"|"skip")[]>. A runner that retries a test, or writes
|
|
173
|
+
* one entry per device, lists the same id more than once.
|
|
174
|
+
*/
|
|
175
|
+
export function junitOutcomes(xml) {
|
|
176
|
+
if (!/<testsuites?\b|<testcase\b/.test(xml)) throw new InputError("not JUnit XML");
|
|
177
|
+
const out = new Map();
|
|
178
|
+
const re = /<testcase\b([^>]*?)(\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
179
|
+
for (const m of xml.matchAll(re)) {
|
|
180
|
+
const attr = (name) => {
|
|
181
|
+
const a = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(m[1]);
|
|
182
|
+
return a ? (a[1] ?? a[2]) : undefined;
|
|
183
|
+
};
|
|
184
|
+
const name = attr("name");
|
|
185
|
+
if (!name) continue;
|
|
186
|
+
const cls = attr("classname");
|
|
187
|
+
const body = m[3] || "";
|
|
188
|
+
const outcome = /<(failure|error)\b/.test(body)
|
|
189
|
+
? "fail"
|
|
190
|
+
: /<skipped\b/.test(body)
|
|
191
|
+
? "skip"
|
|
192
|
+
: "pass";
|
|
193
|
+
const id = cls ? `${cls}.${name}` : name;
|
|
194
|
+
if (!out.has(id)) out.set(id, []);
|
|
195
|
+
out.get(id).push(outcome);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function junitFiles(path) {
|
|
201
|
+
let st;
|
|
202
|
+
try {
|
|
203
|
+
st = statSync(path);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new InputError(`cannot read run ${path}: ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
if (!st.isDirectory()) return [path];
|
|
208
|
+
return readdirSync(path)
|
|
209
|
+
.filter((f) => f.endsWith(".xml"))
|
|
210
|
+
.sort()
|
|
211
|
+
.map((f) => join(path, f));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One run's outcome per test: pass, fail, skip, or mixed when the run saw both. */
|
|
215
|
+
function runOutcomes(path) {
|
|
216
|
+
const all = new Map();
|
|
217
|
+
for (const f of junitFiles(path)) {
|
|
218
|
+
let xml;
|
|
219
|
+
try {
|
|
220
|
+
xml = readFileSync(f, "utf8");
|
|
221
|
+
} catch (err) {
|
|
222
|
+
throw new InputError(`cannot read run ${f}: ${err.message}`);
|
|
223
|
+
}
|
|
224
|
+
for (const [id, list] of junitOutcomes(xml)) all.set(id, [...(all.get(id) || []), ...list]);
|
|
225
|
+
}
|
|
226
|
+
const merged = new Map();
|
|
227
|
+
for (const [id, list] of all) {
|
|
228
|
+
const ran = list.filter((o) => o !== "skip");
|
|
229
|
+
if (ran.length === 0) merged.set(id, "skip");
|
|
230
|
+
else if (ran.every((o) => o === "pass")) merged.set(id, "pass");
|
|
231
|
+
else if (ran.every((o) => o === "fail")) merged.set(id, "fail");
|
|
232
|
+
else merged.set(id, "mixed");
|
|
233
|
+
}
|
|
234
|
+
return merged;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Classify from repeat runs only, for a toolkit without test_results_classify.
|
|
239
|
+
* A test that failed and passed inside one run (a retry, one device of
|
|
240
|
+
* several) is unstable: the run's pass came only on another attempt.
|
|
241
|
+
*/
|
|
242
|
+
export function fromRepeats(runPaths) {
|
|
243
|
+
const runs = runPaths.map(runOutcomes);
|
|
244
|
+
const ids = [...new Set(runs.flatMap((r) => [...r.keys()]))];
|
|
245
|
+
const tests = ids.map((id) => {
|
|
246
|
+
const mixedIn = runs.findIndex((r) => r.get(id) === "mixed");
|
|
247
|
+
if (mixedIn !== -1) {
|
|
248
|
+
return {
|
|
249
|
+
id,
|
|
250
|
+
status: "unstable",
|
|
251
|
+
reason: `failed and passed within run ${mixedIn + 1} (a retry or another device)`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const seen = runs.map((r) => r.get(id)).filter((o) => o && o !== "skip");
|
|
255
|
+
const passed = seen.filter((o) => o === "pass").length;
|
|
256
|
+
if (seen.length === 0) return { id, status: "not_measured", reason: "skipped in every run" };
|
|
257
|
+
if (passed === seen.length) return { id, status: "passed" };
|
|
258
|
+
if (passed > 0) {
|
|
259
|
+
return { id, status: "unstable", reason: `passed ${passed} of ${seen.length} repeats` };
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
id,
|
|
263
|
+
status: "not_measured",
|
|
264
|
+
reason: `red in all ${seen.length} repeat(s); no base run to compare (test_results_classify unavailable)`,
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
return { tests };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function main() {
|
|
271
|
+
const [mode, ...rest] = process.argv.slice(2);
|
|
272
|
+
if (mode !== "merge" && mode !== "fallback") usage("first argument is merge or fallback");
|
|
273
|
+
const args = parseArgs(rest);
|
|
274
|
+
if (mode === "merge" && !args.classified) usage("merge needs --classified <toolkit-output.json>");
|
|
275
|
+
if (mode === "fallback" && args.run.length === 0) usage("fallback needs at least one --run");
|
|
276
|
+
let result;
|
|
277
|
+
try {
|
|
278
|
+
const state = args.state ? readJson(args.state, "state") : null;
|
|
279
|
+
const classified =
|
|
280
|
+
mode === "merge" ? readJson(args.classified, "classification") : fromRepeats(args.run);
|
|
281
|
+
result = verdict(classified, state, mode === "merge" ? "toolkit" : "fallback");
|
|
282
|
+
} catch (err) {
|
|
283
|
+
const code = err instanceof InputError ? 3 : 4;
|
|
284
|
+
writeFileSync(1, `${JSON.stringify({ green: false, reason: err.message })}\n`);
|
|
285
|
+
process.exit(code);
|
|
286
|
+
}
|
|
287
|
+
const text = `${JSON.stringify(result, null, 2)}\n`;
|
|
288
|
+
if (args.out) {
|
|
289
|
+
mkdirSync(dirname(args.out), { recursive: true });
|
|
290
|
+
writeFileSync(args.out, text);
|
|
291
|
+
}
|
|
292
|
+
writeFileSync(1, text);
|
|
293
|
+
process.exit(result.green ? 0 : 1);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|