@mmerterden/multi-agent-pipeline 20.27.1 → 20.28.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 +38 -0
- package/README.md +6 -5
- 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/index.mjs +0 -2
- package/manifest.json +74 -51
- 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/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +124 -0
- package/pipeline/multi-agent-refs/cross-cli-contract.md +11 -5
- package/pipeline/multi-agent-refs/features/doctor.md +13 -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 +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +6 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +3 -3
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +6 -5
- package/pipeline/multi-agent-refs/store-ready/gate-3-policy.md +6 -2
- package/pipeline/multi-agent-refs/test-gen/stages.md +227 -0
- package/pipeline/preferences-template.json +3 -0
- package/pipeline/rules/app-store-guidelines.md +29 -41
- package/pipeline/schemas/prefs.schema.json +14 -0
- 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/_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/doctor.mjs +51 -0
- package/pipeline/scripts/evidence-gate.mjs +74 -3
- package/pipeline/scripts/handle-diff-audit.mjs +188 -0
- package/pipeline/scripts/routine-registry.mjs +5 -1
- 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 +1 -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/skills/.skill-manifest.json +25 -5
- package/pipeline/skills/.skills-index.json +57 -2
- package/pipeline/skills/shared/README.md +11 -6
- package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +5 -4
- package/pipeline/skills/shared/core/apple-archive-compliance/references/rules.yml +8 -8
- 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/app-store-review/SKILL.md +69 -8
- package/pipeline/skills/shared/external/app-store-review/evals/evals.json +12 -0
- package/pipeline/skills/shared/external/app-store-review/references/review-checklists.md +10 -1
- 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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file _worktree-snapshot.mjs - the working tree as a git tree, and diffs between snapshots.
|
|
3
|
+
*
|
|
4
|
+
* A stage that may edit the repository is judged by what IT changed, not by
|
|
5
|
+
* everything that differs from HEAD: the person's own uncommitted work was
|
|
6
|
+
* there before the stage started, and an audit that reads it as the stage's
|
|
7
|
+
* change would tell the agent to revert it. `snapshotTree` records the whole
|
|
8
|
+
* working tree (tracked and untracked files, ignored files excluded) as a git
|
|
9
|
+
* tree object through a throwaway index, so neither the working tree nor the
|
|
10
|
+
* real index is touched. `treeDiff` diffs two such trees, or a commit and a
|
|
11
|
+
* tree.
|
|
12
|
+
*
|
|
13
|
+
* Every ref a caller passes is checked with `rev-parse --verify` and placed
|
|
14
|
+
* after `--end-of-options`, so a value starting with `-` is never read as an
|
|
15
|
+
* option. Every diff carries explicit `a/` `b/` prefixes, so a user's
|
|
16
|
+
* `diff.noprefix` or `diff.mnemonicPrefix` setting cannot change the paths the
|
|
17
|
+
* parser reads.
|
|
18
|
+
*
|
|
19
|
+
* @module pipeline/scripts/_worktree-snapshot
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
export class GitError extends Error {}
|
|
28
|
+
|
|
29
|
+
/** Run git in `repo`; throws GitError with git's first stderr line on failure. */
|
|
30
|
+
export function git(repo, args, env = {}) {
|
|
31
|
+
const r = spawnSync("git", ["-C", repo, ...args], {
|
|
32
|
+
encoding: "utf8",
|
|
33
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
34
|
+
timeout: 120000,
|
|
35
|
+
env: { ...process.env, ...env },
|
|
36
|
+
});
|
|
37
|
+
if (r.status !== 0) {
|
|
38
|
+
const why = (r.stderr || r.error?.message || `exit ${r.status}`).trim().split("\n")[0];
|
|
39
|
+
throw new GitError(`git ${args.find((a) => !a.startsWith("-")) || ""} failed: ${why}`);
|
|
40
|
+
}
|
|
41
|
+
return r.stdout;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A value an option was given, refused when it is missing or looks like another option. */
|
|
45
|
+
export function optionValue(name, value) {
|
|
46
|
+
if (typeof value !== "string" || value === "" || value.startsWith("-")) {
|
|
47
|
+
throw new GitError(`${name} needs a value that does not start with "-"`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The commit `ref` names, as a full sha. */
|
|
53
|
+
export function verifyCommit(repo, ref) {
|
|
54
|
+
optionValue("ref", ref);
|
|
55
|
+
return git(repo, [
|
|
56
|
+
"rev-parse",
|
|
57
|
+
"--verify",
|
|
58
|
+
"--quiet",
|
|
59
|
+
"--end-of-options",
|
|
60
|
+
`${ref}^{commit}`,
|
|
61
|
+
]).trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The working tree, tracked and untracked, as a tree object sha. */
|
|
65
|
+
export function snapshotTree(repo) {
|
|
66
|
+
const dir = mkdtempSync(join(tmpdir(), "ma-snapshot-"));
|
|
67
|
+
const env = { GIT_INDEX_FILE: join(dir, "index") };
|
|
68
|
+
try {
|
|
69
|
+
let hasHead = true;
|
|
70
|
+
try {
|
|
71
|
+
git(repo, ["rev-parse", "--verify", "--quiet", "HEAD"]);
|
|
72
|
+
} catch {
|
|
73
|
+
hasHead = false;
|
|
74
|
+
}
|
|
75
|
+
if (hasHead) git(repo, ["read-tree", "HEAD"], env);
|
|
76
|
+
git(repo, ["add", "-A", "--", "."], env);
|
|
77
|
+
return git(repo, ["write-tree"], env).trim();
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Unified diff (-U0) from one tree-ish to another, renames off so moves read as delete + add. */
|
|
84
|
+
export function treeDiff(repo, from, to) {
|
|
85
|
+
return git(repo, [
|
|
86
|
+
"diff-tree",
|
|
87
|
+
"-p",
|
|
88
|
+
"-r",
|
|
89
|
+
"-U0",
|
|
90
|
+
"--no-color",
|
|
91
|
+
"--no-ext-diff",
|
|
92
|
+
"--no-renames",
|
|
93
|
+
"--src-prefix=a/",
|
|
94
|
+
"--dst-prefix=b/",
|
|
95
|
+
"--end-of-options",
|
|
96
|
+
from,
|
|
97
|
+
to,
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What changed in the working tree since the snapshot `fromTree` was taken. */
|
|
102
|
+
export function diffSince(repo, fromTree) {
|
|
103
|
+
optionValue("snapshot", fromTree);
|
|
104
|
+
git(repo, ["cat-file", "-e", "--end-of-options", `${fromTree}^{tree}`]);
|
|
105
|
+
return treeDiff(repo, fromTree, snapshotTree(repo));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The working tree (untracked files included) against the merge base of HEAD and `base`. */
|
|
109
|
+
export function diffFromMergeBase(repo, base) {
|
|
110
|
+
const baseSha = verifyCommit(repo, base);
|
|
111
|
+
const mb = git(repo, ["merge-base", "--end-of-options", baseSha, "HEAD"]).trim();
|
|
112
|
+
return treeDiff(repo, mb, snapshotTree(repo));
|
|
113
|
+
}
|
|
@@ -104,6 +104,7 @@ const CHECK_IDS = [
|
|
|
104
104
|
"disk-space",
|
|
105
105
|
"worktree-residue",
|
|
106
106
|
"usage-report",
|
|
107
|
+
"usage-register",
|
|
107
108
|
"free-models",
|
|
108
109
|
"xcode-skills",
|
|
109
110
|
];
|
|
@@ -936,6 +937,55 @@ function checkUsageReport() {
|
|
|
936
937
|
);
|
|
937
938
|
}
|
|
938
939
|
|
|
940
|
+
// usage-register.mjs runs quietly from setup, update and Phase 0, so the record
|
|
941
|
+
// of its last attempt, and of the failure report it may have sent, is where a
|
|
942
|
+
// machine that never registers shows why.
|
|
943
|
+
const REGISTER_FINE = new Set(["registered", "registered-feedback-only", "enabled"]);
|
|
944
|
+
const REGISTER_QUIET = new Set([
|
|
945
|
+
"token in environment",
|
|
946
|
+
"token in prefs",
|
|
947
|
+
"token already onboarded",
|
|
948
|
+
]);
|
|
949
|
+
|
|
950
|
+
function beaconSummary(beacon) {
|
|
951
|
+
const last = beacon?.last;
|
|
952
|
+
if (!last || typeof last.outcome !== "string") return "no failure report was sent";
|
|
953
|
+
const when = last.t ? ` at ${last.t}` : "";
|
|
954
|
+
return last.outcome === "sent"
|
|
955
|
+
? `the failure report (${last.reason}) reached the site${when}`
|
|
956
|
+
: `the failure report (${last.reason}) could not be delivered${when}`;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function checkUsageRegister() {
|
|
960
|
+
const prefs = readJson(join(CLAUDE, "multi-agent-preferences.json"));
|
|
961
|
+
if (prefs?.global?.usageLog?.optOut === true) {
|
|
962
|
+
skip("usage-register", "usage reporting is opted out");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
const logs = join(CLAUDE, "logs", "multi-agent");
|
|
966
|
+
const last = readJson(join(logs, "usage-last-register.json"));
|
|
967
|
+
if (!last || typeof last.status !== "string") {
|
|
968
|
+
skip("usage-register", "no registration has been attempted on this machine yet");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (
|
|
972
|
+
REGISTER_FINE.has(last.status) ||
|
|
973
|
+
(last.status === "skipped" && REGISTER_QUIET.has(last.detail))
|
|
974
|
+
) {
|
|
975
|
+
ok("usage-register");
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
const beacon = beaconSummary(readJson(join(logs, "usage-register-beacon.json")));
|
|
979
|
+
const when = last.t ? ` at ${last.t}` : "";
|
|
980
|
+
const detail = last.detail ? ` (${last.detail})` : "";
|
|
981
|
+
report(
|
|
982
|
+
"usage-register",
|
|
983
|
+
"WARN",
|
|
984
|
+
`the last usage registration${when} was ${last.status}${detail}; ${beacon}`,
|
|
985
|
+
`run node ${join(HERE, "usage-register.mjs")} --refresh`,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
939
989
|
// route-on --free promises that simple tasks leave Claude. With no usable
|
|
940
990
|
// provider every one of them silently escalates back, which costs nothing but
|
|
941
991
|
// saves nothing either - the user believes a control is working that is not.
|
|
@@ -1362,6 +1412,7 @@ function main() {
|
|
|
1362
1412
|
checkDiskSpace();
|
|
1363
1413
|
checkWorktreeResidue();
|
|
1364
1414
|
checkUsageReport();
|
|
1415
|
+
checkUsageRegister();
|
|
1365
1416
|
checkFreeModels();
|
|
1366
1417
|
checkXcodeSkills();
|
|
1367
1418
|
if (PROFILE === "server") checkServerProfile(resolved);
|
|
@@ -12,11 +12,23 @@
|
|
|
12
12
|
// node pipeline/scripts/evidence-gate.mjs --claim test --status passed --evidence test.log \
|
|
13
13
|
// --success-pattern 'All tests passed' --failure-pattern 'FAILED'
|
|
14
14
|
// node pipeline/scripts/evidence-gate.mjs --claim manual --status passed --evidence manual-test.json
|
|
15
|
+
// node pipeline/scripts/evidence-gate.mjs --claim artefact --status passed --evidence gaps.json \
|
|
16
|
+
// --stage-start 2026-10-23T09:00:00Z
|
|
15
17
|
//
|
|
16
18
|
// Flags:
|
|
17
|
-
// --claim build | test | review | manual
|
|
19
|
+
// --claim build | test | review | manual | artefact
|
|
18
20
|
// build/test/review select default success/failure markers;
|
|
19
|
-
// manual is JSON-based (Phase 5 manual-test.json, see below)
|
|
21
|
+
// manual is JSON-based (Phase 5 manual-test.json, see below);
|
|
22
|
+
// artefact is a stage's own output file, judged only by
|
|
23
|
+
// --stage-start (required with it)
|
|
24
|
+
// --stage-start <t> ISO time or epoch (ms, or seconds) the stage started.
|
|
25
|
+
// The evidence must be this stage's own file, written
|
|
26
|
+
// during it and whole: an mtime earlier than the start
|
|
27
|
+
// less 2s of clock slack is another stage's (or an old
|
|
28
|
+
// run's) file. A .json file is whole when it parses; any
|
|
29
|
+
// other file under 400 bytes, or ending in a letter or
|
|
30
|
+
// digit with no trailing newline, is partial, cut off
|
|
31
|
+
// mid-write. Applies to every claim when given.
|
|
20
32
|
// --status the claimed status (only "passed" is gated; others pass through)
|
|
21
33
|
// --evidence <path> the log / artifact that must substantiate a "passed" claim
|
|
22
34
|
// --require-screenshot manual claim only: every "pass" criterion must name a
|
|
@@ -93,7 +105,54 @@ const DEFAULT_MARKERS = {
|
|
|
93
105
|
};
|
|
94
106
|
|
|
95
107
|
const JSON_CLAIMS = new Set(["manual"]);
|
|
96
|
-
const
|
|
108
|
+
const ARTEFACT_CLAIM = "artefact";
|
|
109
|
+
const CLAIMS = [...Object.keys(DEFAULT_MARKERS), ...JSON_CLAIMS, ARTEFACT_CLAIM];
|
|
110
|
+
|
|
111
|
+
const STAGE_SLACK_MS = 2000;
|
|
112
|
+
const PARTIAL_UNDER_BYTES = 400;
|
|
113
|
+
|
|
114
|
+
/** --stage-start as epoch ms: ISO, epoch ms, or epoch seconds. */
|
|
115
|
+
function stageStartMs(value) {
|
|
116
|
+
if (value === undefined) return null;
|
|
117
|
+
const s = String(value).trim();
|
|
118
|
+
if (/^\d+(\.\d+)?$/.test(s)) {
|
|
119
|
+
const n = Number(s);
|
|
120
|
+
return n < 1e12 ? n * 1000 : n;
|
|
121
|
+
}
|
|
122
|
+
const t = Date.parse(s);
|
|
123
|
+
if (value === true || Number.isNaN(t)) usage(`--stage-start is not a time: ${value}`);
|
|
124
|
+
return t;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Is this the stage's own, whole file? Returns the reason it is not, or null.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} path
|
|
131
|
+
* @param {Buffer} raw
|
|
132
|
+
* @param {number} startMs
|
|
133
|
+
*/
|
|
134
|
+
function stageArtefactProblem(path, raw, startMs) {
|
|
135
|
+
const mtime = statSync(path).mtimeMs;
|
|
136
|
+
if (mtime < startMs - STAGE_SLACK_MS) {
|
|
137
|
+
return `written ${new Date(mtime).toISOString()}, before the stage started (${new Date(startMs).toISOString()}); not this stage's output`;
|
|
138
|
+
}
|
|
139
|
+
if (/\.json$/i.test(path)) {
|
|
140
|
+
try {
|
|
141
|
+
JSON.parse(raw.toString("utf8"));
|
|
142
|
+
return null;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return `partial: not valid JSON (${err.message})`;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (raw.length < PARTIAL_UNDER_BYTES) {
|
|
148
|
+
return `partial: ${raw.length} bytes, under ${PARTIAL_UNDER_BYTES}`;
|
|
149
|
+
}
|
|
150
|
+
const last = raw.toString("utf8").slice(-1);
|
|
151
|
+
if (/[A-Za-z0-9]/.test(last)) {
|
|
152
|
+
return "partial: ends mid-token, with no trailing newline";
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
97
156
|
const MANUAL_VERDICTS = new Set(["pass", "fail", "not-tested"]);
|
|
98
157
|
|
|
99
158
|
const MAX_PATTERN_LEN = 200;
|
|
@@ -291,6 +350,10 @@ if (typeof status !== "string") {
|
|
|
291
350
|
}
|
|
292
351
|
ledgerClaim = claim;
|
|
293
352
|
ledgerState = typeof args.state === "string" ? args.state : null;
|
|
353
|
+
const stageStart = stageStartMs(args["stage-start"]);
|
|
354
|
+
if (claim === ARTEFACT_CLAIM && stageStart === null) {
|
|
355
|
+
usage("--claim artefact needs --stage-start");
|
|
356
|
+
}
|
|
294
357
|
|
|
295
358
|
// Only a "passed" claim needs proof. failed / pending / anything else is honest
|
|
296
359
|
// about not succeeding, so it passes through untouched.
|
|
@@ -336,6 +399,14 @@ if (body.trim().length === 0) {
|
|
|
336
399
|
fail(`${claim} claims passed but evidence file is blank: ${evidence} (default-FAIL)`);
|
|
337
400
|
}
|
|
338
401
|
|
|
402
|
+
if (stageStart !== null) {
|
|
403
|
+
const problem = stageArtefactProblem(evidence, readFileSync(evidence), stageStart);
|
|
404
|
+
if (problem) fail(`${claim} evidence ${evidence}: ${problem} (default-FAIL)`);
|
|
405
|
+
if (claim === ARTEFACT_CLAIM) {
|
|
406
|
+
ok(`${claim} ${evidence} is this stage's own output and whole`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
339
410
|
if (JSON_CLAIMS.has(claim)) {
|
|
340
411
|
gateManual(claim, evidence, body, args["require-screenshot"] === true);
|
|
341
412
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file handle-diff-audit.mjs - did the handle stage add only handles?
|
|
4
|
+
*
|
|
5
|
+
* The test-gen handle stage may change product code for one purpose: adding
|
|
6
|
+
* the automation handles a UI test finds elements by (iOS
|
|
7
|
+
* accessibilityIdentifier, Compose testTag / semantics, web data-testid). A
|
|
8
|
+
* prompt that says "only add attributes" is a wish; this measures it.
|
|
9
|
+
*
|
|
10
|
+
* Per changed file, the removed lines and the added lines with their handle
|
|
11
|
+
* constructs stripped are read as ordered code tokens (_handle-patterns.mjs
|
|
12
|
+
* codeTokens: operators and punctuation kept, whitespace dropped), hunk by
|
|
13
|
+
* hunk in order. The two sequences must be equal. Re-wrapping a tag over
|
|
14
|
+
* several lines or moving a modifier onto its own line keeps them equal; a
|
|
15
|
+
* flipped comparison, a dropped `!`, swapped arguments or a deleted modifier
|
|
16
|
+
* does not. A difference is reported as:
|
|
17
|
+
*
|
|
18
|
+
* lost tokens the removed lines had that the added lines lack
|
|
19
|
+
* extra tokens the added lines carry beyond the removed ones
|
|
20
|
+
* reordered the same tokens in another order
|
|
21
|
+
* new-file a file appeared that is not a handle-constants file (string
|
|
22
|
+
* constants, the enums or objects grouping them, imports,
|
|
23
|
+
* comments, nothing else)
|
|
24
|
+
* deleted-file / binary
|
|
25
|
+
*
|
|
26
|
+
* Snapshot files are listed apart: a new attribute changes a rendered
|
|
27
|
+
* snapshot, and its owners should know, but it is not a violation.
|
|
28
|
+
*
|
|
29
|
+
* What is read:
|
|
30
|
+
* --since <tree> / --baseline <json> only what changed after the stage-start
|
|
31
|
+
* snapshot (test-gen-gate.mjs baseline records its tree),
|
|
32
|
+
* so uncommitted work the person had before the stage is
|
|
33
|
+
* never read as the stage's change
|
|
34
|
+
* --base <ref> the working tree, untracked files included, against a
|
|
35
|
+
* commit (default HEAD)
|
|
36
|
+
* --diff-file <p> a saved unified diff
|
|
37
|
+
*
|
|
38
|
+
* Usage:
|
|
39
|
+
* node handle-diff-audit.mjs [--repo <path>] [--since <tree> | --baseline <json> | --base <ref>]
|
|
40
|
+
* node handle-diff-audit.mjs --diff-file <unified.diff>
|
|
41
|
+
*
|
|
42
|
+
* Output: one JSON object on stdout.
|
|
43
|
+
* Exit: 0 only handles (or nothing changed), 2 violation, 3 input unreadable
|
|
44
|
+
* (a missing or option-shaped value, an unknown ref, a failed git call)
|
|
45
|
+
*
|
|
46
|
+
* @module pipeline/scripts/handle-diff-audit
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { pathToFileURL } from "node:url";
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
codeTokens,
|
|
54
|
+
isHandleConstantsFile,
|
|
55
|
+
isHandleLine,
|
|
56
|
+
isSnapshotPath,
|
|
57
|
+
parseUnifiedDiff,
|
|
58
|
+
stripHandles,
|
|
59
|
+
} from "./_handle-patterns.mjs";
|
|
60
|
+
import {
|
|
61
|
+
GitError,
|
|
62
|
+
diffSince,
|
|
63
|
+
optionValue,
|
|
64
|
+
snapshotTree,
|
|
65
|
+
treeDiff,
|
|
66
|
+
verifyCommit,
|
|
67
|
+
} from "./_worktree-snapshot.mjs";
|
|
68
|
+
|
|
69
|
+
const VALUE_FLAGS = new Set(["repo", "base", "since", "baseline", "diff-file"]);
|
|
70
|
+
|
|
71
|
+
function emit(result, code) {
|
|
72
|
+
writeFileSync(1, `${JSON.stringify({ ...result, code })}\n`);
|
|
73
|
+
process.exit(code);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function unreadable(reason) {
|
|
77
|
+
emit({ ok: false, reason }, 3);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseArgs(argv) {
|
|
81
|
+
const out = {};
|
|
82
|
+
for (let i = 0; i < argv.length; i++) {
|
|
83
|
+
const a = argv[i];
|
|
84
|
+
if (!a.startsWith("--")) unreadable(`unexpected argument ${a}`);
|
|
85
|
+
const key = a.slice(2);
|
|
86
|
+
if (!VALUE_FLAGS.has(key)) unreadable(`unknown option ${a}`);
|
|
87
|
+
const v = argv[i + 1];
|
|
88
|
+
if (v === undefined || v.startsWith("-"))
|
|
89
|
+
unreadable(`${a} needs a value that does not start with "-"`);
|
|
90
|
+
out[key] = v;
|
|
91
|
+
i++;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readDiff(args) {
|
|
97
|
+
if (args["diff-file"]) {
|
|
98
|
+
try {
|
|
99
|
+
return readFileSync(args["diff-file"], "utf8");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
return unreadable(`cannot read --diff-file: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const repo = args.repo || process.cwd();
|
|
105
|
+
try {
|
|
106
|
+
if (args.since || args.baseline) {
|
|
107
|
+
let tree = args.since;
|
|
108
|
+
if (!tree) {
|
|
109
|
+
const doc = JSON.parse(readFileSync(args.baseline, "utf8"));
|
|
110
|
+
tree = optionValue("baseline tree", doc.tree);
|
|
111
|
+
}
|
|
112
|
+
return diffSince(repo, tree);
|
|
113
|
+
}
|
|
114
|
+
const base = verifyCommit(repo, args.base || "HEAD");
|
|
115
|
+
return treeDiff(repo, base, snapshotTree(repo));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err instanceof GitError || err instanceof SyntaxError || err.code === "ENOENT") {
|
|
118
|
+
return unreadable(err.message);
|
|
119
|
+
}
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Multiset difference: tokens of `a` not covered by `b`. */
|
|
125
|
+
function missing(a, b) {
|
|
126
|
+
const pool = new Map();
|
|
127
|
+
for (const t of b) pool.set(t, (pool.get(t) || 0) + 1);
|
|
128
|
+
const out = [];
|
|
129
|
+
for (const t of a) {
|
|
130
|
+
const n = pool.get(t) || 0;
|
|
131
|
+
if (n > 0) pool.set(t, n - 1);
|
|
132
|
+
else out.push(t);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function audit(diffText) {
|
|
138
|
+
const files = [];
|
|
139
|
+
const violations = [];
|
|
140
|
+
const snapshots = [];
|
|
141
|
+
let handlesAdded = 0;
|
|
142
|
+
for (const [file, f] of parseUnifiedDiff(diffText)) {
|
|
143
|
+
if (isSnapshotPath(file)) {
|
|
144
|
+
snapshots.push(file);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const handles = f.added.filter((l) => isHandleLine(file, l)).length;
|
|
148
|
+
handlesAdded += handles;
|
|
149
|
+
files.push({ file, handles, added: f.added.length, removed: f.removed.length });
|
|
150
|
+
if (f.binary) {
|
|
151
|
+
violations.push({ file, kind: "binary", tokens: [] });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (f.isDeleted) {
|
|
155
|
+
violations.push({ file, kind: "deleted-file", tokens: [] });
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (f.isNew) {
|
|
159
|
+
if (!isHandleConstantsFile(f.added)) violations.push({ file, kind: "new-file", tokens: [] });
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const before = f.hunks.flatMap((h) => codeTokens(h.removed.map((d) => d.text).join("\n")));
|
|
163
|
+
const after = f.hunks.flatMap((h) =>
|
|
164
|
+
codeTokens(h.added.map((d) => stripHandles(file, d.text)).join("\n")),
|
|
165
|
+
);
|
|
166
|
+
if (before.length === after.length && before.every((t, i) => t === after[i])) continue;
|
|
167
|
+
const lost = missing(before, after);
|
|
168
|
+
const extra = missing(after, before);
|
|
169
|
+
if (lost.length) violations.push({ file, kind: "lost", tokens: [...new Set(lost)] });
|
|
170
|
+
if (extra.length) violations.push({ file, kind: "extra", tokens: [...new Set(extra)] });
|
|
171
|
+
if (!lost.length && !extra.length) {
|
|
172
|
+
const at = before.findIndex((t, i) => t !== after[i]);
|
|
173
|
+
violations.push({ file, kind: "reordered", tokens: before.slice(at, at + 6) });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return { ok: violations.length === 0, handlesAdded, files, snapshots, violations };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function main() {
|
|
180
|
+
const args = parseArgs(process.argv.slice(2));
|
|
181
|
+
const text = readDiff(args);
|
|
182
|
+
if (!text.trim())
|
|
183
|
+
emit({ ok: true, handlesAdded: 0, files: [], snapshots: [], violations: [] }, 0);
|
|
184
|
+
const result = audit(text);
|
|
185
|
+
emit(result, result.ok ? 0 : 2);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
|
@@ -122,6 +122,10 @@ function cmdGet(home, name) {
|
|
|
122
122
|
process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
// Every saved routine is written with this grant; permission-baseline.mjs
|
|
126
|
+
// freezes it.
|
|
127
|
+
export const ROUTINE_ALLOWED_TOOLS = "Skill, Bash, Read, Edit, Write, AskUserQuestion";
|
|
128
|
+
|
|
125
129
|
function buildSkill(args) {
|
|
126
130
|
const desc = args.description;
|
|
127
131
|
const descTr = args["description-tr"];
|
|
@@ -132,7 +136,7 @@ function buildSkill(args) {
|
|
|
132
136
|
`description: ${JSON.stringify(desc)}`,
|
|
133
137
|
descTr ? `description-tr: ${JSON.stringify(descTr)}` : null,
|
|
134
138
|
`argument-hint: ${JSON.stringify(hint)}`,
|
|
135
|
-
|
|
139
|
+
`allowed-tools: ${ROUTINE_ALLOWED_TOOLS}`,
|
|
136
140
|
"local-only: true",
|
|
137
141
|
"---",
|
|
138
142
|
].filter(Boolean);
|
|
@@ -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
|