create-cmp-cli 0.23.0 → 0.25.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/bin/create-cmp.mjs +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +46 -18
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +38 -6
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +778 -0
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +180 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +180 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/agent-hold.mjs +234 -0
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +38 -6
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +778 -0
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +180 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Does this profile ship plants its Rule 0 instrument can actually run?
|
|
2
|
+
//
|
|
3
|
+
// ONE QUESTION, TWO READERS, AND THAT IS WHY THIS FILE EXISTS RATHER THAN A
|
|
4
|
+
// PREDICATE INSIDE EITHER OF THEM. The instrument (framework-check.mjs and its
|
|
5
|
+
// runner) asks it to decide whether it can make the two profile-sourced plants;
|
|
6
|
+
// the grader (evidence-level.mjs) asks it to decide whether this profile may be
|
|
7
|
+
// graded at all. Those two answers must be the same answer — a profile the
|
|
8
|
+
// instrument cannot plant for, graded as if it had been calibrated, is exactly
|
|
9
|
+
// the defect below — and this repository already knows what happens when one
|
|
10
|
+
// question has two implementations: `flowCitation` and `scanCitations` held two
|
|
11
|
+
// copies of "what a citation looks like" and disagreed about the same file in
|
|
12
|
+
// the same tree (framework-check.mjs carries that episode in full).
|
|
13
|
+
//
|
|
14
|
+
// THE DEFECT. `scripts/stage2-gate.mjs` built two adopters differing in exactly
|
|
15
|
+
// one export — one shipping `plants`, one shipping none — ran the real lane in
|
|
16
|
+
// both, and both earned `L1 · every promise bound`. `evidenceLevel` derived the
|
|
17
|
+
// rung from the ladder alone and never asked whether the profile had plants, so
|
|
18
|
+
// the twin that could calibrate nothing was graded identically to the twin that
|
|
19
|
+
// could. Measured, not read: the ladder is the same ladder in both trees, which
|
|
20
|
+
// is what makes the pair evidence rather than an anecdote.
|
|
21
|
+
//
|
|
22
|
+
// That is a guarantee violation, not a missing feature. NORTH-STAR.md §8.9: "A
|
|
23
|
+
// profile with no calibrated plants earns no rung." §3 makes "Earns a rung
|
|
24
|
+
// without plants" one of a Stack Profile's four *nevers*. §6.7: "A profile ships
|
|
25
|
+
// with plants, run by `qa/framework-check.mjs` through the real runner in
|
|
26
|
+
// seconds and restored byte-for-byte, or it ships without a badge."
|
|
27
|
+
//
|
|
28
|
+
// ── WHICH READING OF "CALIBRATED", AND WHY THIS ONE ─────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// The guarantee says "calibrated plants" and that word carries three readings
|
|
31
|
+
// of increasing strength. The one implemented here is the second, and the
|
|
32
|
+
// argument for it is the whole point of this comment.
|
|
33
|
+
//
|
|
34
|
+
// 1. DECLARED — the profile exports `plants`. Rejected as too weak, and it is
|
|
35
|
+
// weak in a way that is cheap to exploit: `export const plants = {}` would
|
|
36
|
+
// satisfy it. An empty object declares nothing, plants nothing, and would
|
|
37
|
+
// buy the badge outright. A gate a one-line edit defeats is not a gate.
|
|
38
|
+
//
|
|
39
|
+
// 2. USABLE — the declaration is structurally sufficient for the instrument to
|
|
40
|
+
// make the plants only a profile can supply. THIS ONE. It is not a second
|
|
41
|
+
// opinion invented here: it is the instrument's OWN judgement, the test the
|
|
42
|
+
// runner already applies to decide whether it can plant at all, moved to
|
|
43
|
+
// where both readers can reach it. `{}` fails it, so does three fields out
|
|
44
|
+
// of the three, and so does a `plants` whose sources are not functions.
|
|
45
|
+
//
|
|
46
|
+
// 3. OBSERVED — the plants have actually been watched failing, on this tree,
|
|
47
|
+
// recently. This is the truest reading of the word, and it is NOT DERIVABLE
|
|
48
|
+
// FROM A LANE RUN, so it is not implemented and not pretended to. The lane
|
|
49
|
+
// does not run the instrument: `qa/framework-check.mjs` is a separate command
|
|
50
|
+
// that plants into the tree, runs the lane N+1 times and restores every byte,
|
|
51
|
+
// and nothing it does leaves a trace the lane could later read. Making it
|
|
52
|
+
// derivable would mean a recorded, hash-bound framework-check result the
|
|
53
|
+
// receipt path consults — a new artifact on the evidence path, which is an
|
|
54
|
+
// ADR before it is any code (fit-test question 5). Until that exists, reading
|
|
55
|
+
// 3 is a claim nobody measured, and this file claims reading 2 exactly.
|
|
56
|
+
//
|
|
57
|
+
// WHAT READING 2 DOES NOT RESIST, said out loud: plant sources that are
|
|
58
|
+
// plausible and inert — functions returning text that violates nothing. Those
|
|
59
|
+
// are structurally sufficient and would earn the badge here. They are caught by
|
|
60
|
+
// the instrument instead, which is the only reader that can catch them:
|
|
61
|
+
// `assessPlantRun` fails a plant whose lane stayed green. So the honest division
|
|
62
|
+
// is that this file refuses a profile that CANNOT be calibrated, and the
|
|
63
|
+
// instrument refuses a profile whose plants do not bite. Neither can do the
|
|
64
|
+
// other's half, and a reader of a rung should know which half they are holding.
|
|
65
|
+
//
|
|
66
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/plant-calibration.mjs in the
|
|
67
|
+
// create-cmp repo. Vendored byte-identical into qa/lib/ — edit the package
|
|
68
|
+
// source, then run `node scripts/sync-harness.mjs`.
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The plant material only a profile can supply, and the instrument cannot make
|
|
72
|
+
* up. A citation has to sit on a test, so two of the seven plants must WRITE a
|
|
73
|
+
* test — in this stack's language, with its test-declaration syntax, at a path
|
|
74
|
+
* it compiles — and those three fields are the whole of what that takes.
|
|
75
|
+
*
|
|
76
|
+
* These are the same three the runner tested inline before this file existed,
|
|
77
|
+
* and the same three `harness init` seeds in the commented `export const plants`
|
|
78
|
+
* skeleton (install/init.mjs). That agreement is deliberate and is
|
|
79
|
+
* the answer to the discoverability objection this repository has already paid
|
|
80
|
+
* for once: `harnessIntegrity` was a requirement no profile author could find,
|
|
81
|
+
* and it cost an adopter a lane that could never mint a valid receipt
|
|
82
|
+
* (NORTH-STAR.md §9.1). A badge floor asking for exactly what the on-ramp
|
|
83
|
+
* already documents asks for nothing an author cannot discover.
|
|
84
|
+
*
|
|
85
|
+
* `unmeetableTier` is DELIBERATELY NOT HERE, and that is the one judgement call
|
|
86
|
+
* in this file. It is a fourth field the skeleton also seeds, and without it the
|
|
87
|
+
* instrument skips the tier-unmet plant by name. But a stack with a single tier
|
|
88
|
+
* has no tier a host test cannot satisfy — the shape `harness init` itself
|
|
89
|
+
* seeds — so requiring it would make the badge unreachable by construction for a
|
|
90
|
+
* legitimate stack rather than for an uncalibrated one. A gate that cannot be
|
|
91
|
+
* passed by a correct profile is a wrong verdict in the other direction, and the
|
|
92
|
+
* instrument already reports that plant's absence per plant, by name, with the
|
|
93
|
+
* field that would fix it.
|
|
94
|
+
*/
|
|
95
|
+
export const PLANT_MATERIAL = Object.freeze([
|
|
96
|
+
["testFileBasename", "string", "the file a planted citation lives in"],
|
|
97
|
+
["unboundCitationSource", "function", "a citation on a type declaration with no test under it"],
|
|
98
|
+
["tierUnmetCitationSource", "function", "a host-tier test citing a clause only another tier can observe"],
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Can the Rule 0 instrument plant this profile's own violations?
|
|
103
|
+
*
|
|
104
|
+
* @param {object|null|undefined} plants the profile's `plants` declaration —
|
|
105
|
+
* NORTH-STAR §6's eighth. `undefined` (the profile exports none) and `null`
|
|
106
|
+
* mean the same thing here and are the same answer: no.
|
|
107
|
+
* @returns {{ok: true, material: string[]}|{ok: false, reason: string, missing: string[]}}
|
|
108
|
+
* `missing` names the fields, so a caller can say what would fix it rather
|
|
109
|
+
* than only that something is wrong.
|
|
110
|
+
*/
|
|
111
|
+
export function plantCalibration(plants) {
|
|
112
|
+
if (!plants || typeof plants !== "object") {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
missing: PLANT_MATERIAL.map(([field]) => field),
|
|
116
|
+
reason:
|
|
117
|
+
"this profile declares no `plants` — the Rule 0 instrument cannot plant the two violations only a " +
|
|
118
|
+
"profile can supply, so nothing it runs would calibrate anything (NORTH-STAR.md §6.7)",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const missing = PLANT_MATERIAL.filter(([field, kind]) => typeof plants[field] !== kind).map(([field]) => field);
|
|
122
|
+
if (missing.length) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
missing,
|
|
126
|
+
reason:
|
|
127
|
+
`this profile's \`plants\` declaration is missing ${missing.join(", ")} — ` +
|
|
128
|
+
PLANT_MATERIAL.filter(([field]) => missing.includes(field))
|
|
129
|
+
.map(([field, kind, what]) => `${field} (${kind}: ${what})`)
|
|
130
|
+
.join("; ") +
|
|
131
|
+
". Without it the Rule 0 instrument skips the plants only this profile can supply and calibrates nothing",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { ok: true, material: PLANT_MATERIAL.map(([field]) => field) };
|
|
135
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// profile-loader.mjs — load the stack profile the manifest names. NEVER by name.
|
|
2
|
+
//
|
|
3
|
+
// The one rule of the agnostic harness (docs/proposals/AGNOSTIC-HARNESS-
|
|
4
|
+
// ARCHITECTURE.md §3.2): nothing in the core imports a profile by name. Until
|
|
5
|
+
// this file, qa/verify.mjs imported the Compose step pack by name — which is
|
|
6
|
+
// why a Kotlin backend that wrote its own step pack correctly still had to
|
|
7
|
+
// fork eleven spine files to use it (§1.3). The runner could not start without
|
|
8
|
+
// Compose's pack.
|
|
9
|
+
//
|
|
10
|
+
// Now the runner asks the manifest which profile, and this loader imports
|
|
11
|
+
// qa/lib/profiles/<id>/index.mjs. The loader knows the SHAPE of a profile
|
|
12
|
+
// (§5.1) and nothing about any particular one. Terraform's core/provider rule;
|
|
13
|
+
// LSP's client/server rule.
|
|
14
|
+
//
|
|
15
|
+
// Every failure is a refusal by name: a missing directory, an id that does not
|
|
16
|
+
// match the manifest, a protocol the core does not speak, a module without the
|
|
17
|
+
// exports the runner needs. None of them fall back to anything.
|
|
18
|
+
//
|
|
19
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/profile-loader.mjs in the
|
|
20
|
+
// create-cmp repo. Vendored byte-identical into qa/lib/ — edit the package
|
|
21
|
+
// source, then run `node scripts/sync-harness.mjs`.
|
|
22
|
+
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { pathToFileURL } from "node:url";
|
|
27
|
+
|
|
28
|
+
import { PROFILE_ID_RE } from "./harness-manifest.mjs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The profile protocol this core speaks. A profile declares the protocol it
|
|
32
|
+
* implements; mismatch is a refusal naming both, with the upgrade command.
|
|
33
|
+
* One integer — the Terraform handshake.
|
|
34
|
+
*
|
|
35
|
+
* Protocol 1 is still being drawn: its required exports grew during Stage 0
|
|
36
|
+
* (`layout` and `tiers` in PR 4) while the only implementer ships in this
|
|
37
|
+
* tree, vendored beside the core it matches. It freezes at Stage 2, when a
|
|
38
|
+
* profile can be versioned apart from the harness; from then on a new
|
|
39
|
+
* required export is a new protocol number.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* The protocol this lane SPEAKS. Bumped to 2 when `extends` landed, because
|
|
43
|
+
* inheritance changes what a profile must export: an heir declares only what it
|
|
44
|
+
* changes, and only a loader that can DERIVE the rest can load it.
|
|
45
|
+
*
|
|
46
|
+
* Without the bump an heir would declare 1, and an older loader would refuse it
|
|
47
|
+
* with "profile X must export layout" — pointing the author at their own file
|
|
48
|
+
* when the real fix is to upgrade the harness. The protocol refusal below says
|
|
49
|
+
* exactly that instead, which is why the version has to move for the message to
|
|
50
|
+
* be worth anything.
|
|
51
|
+
*/
|
|
52
|
+
export const PROFILE_PROTOCOL = 2;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Every protocol this lane can load. A profile at 1 is one written before
|
|
56
|
+
* `extends` existed; it is complete on its own and nothing about it changed, so
|
|
57
|
+
* refusing it would be a rename dressed as a version — the failure ADR-0007
|
|
58
|
+
* refused for the receipt, one layer down.
|
|
59
|
+
*/
|
|
60
|
+
export const SUPPORTED_PROFILE_PROTOCOLS = Object.freeze([1, 2]);
|
|
61
|
+
|
|
62
|
+
/** The protocol a profile must declare before it may use `extends`. */
|
|
63
|
+
export const EXTENDS_PROTOCOL = 2;
|
|
64
|
+
|
|
65
|
+
/** Where profiles live, relative to the project root. Inside the lock region. */
|
|
66
|
+
export const PROFILES_DIR_REL = "qa/lib/profiles";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The exports a profile MUST provide for the runner to start. `layout` and
|
|
70
|
+
* `tiers` are the spec scanner's model (qa/lib/spec-model.mjs validates their
|
|
71
|
+
* shape); `steps(ctx)` is the pack.
|
|
72
|
+
*/
|
|
73
|
+
export const REQUIRED_EXPORTS = Object.freeze(["id", "protocol", "layout", "tiers", "steps"]);
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The project-relative path of a profile's entry module.
|
|
77
|
+
* @param {string} id
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function profileEntryRel(id) {
|
|
81
|
+
return `${PROFILES_DIR_REL}/${id}/index.mjs`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Judge a loaded module against the protocol — pure, so the refusals are
|
|
86
|
+
* unit-testable without a filesystem.
|
|
87
|
+
* @param {object} mod the imported module namespace
|
|
88
|
+
* @param {string} id the id the manifest named
|
|
89
|
+
* @returns {{ok: true} | {ok: false, reason: string}}
|
|
90
|
+
*/
|
|
91
|
+
export function validateProfileModule(mod, id) {
|
|
92
|
+
if (!mod || typeof mod !== "object") return { ok: false, reason: `profile "${id}" did not load as a module` };
|
|
93
|
+
const missing = REQUIRED_EXPORTS.filter((name) => !(name in mod));
|
|
94
|
+
if (missing.length) {
|
|
95
|
+
return { ok: false, reason: `profile "${id}" is missing required export(s): ${missing.join(", ")} (a profile must export ${REQUIRED_EXPORTS.join(", ")})` };
|
|
96
|
+
}
|
|
97
|
+
if (mod.id !== id) {
|
|
98
|
+
return { ok: false, reason: `profile "${id}" exports id ${JSON.stringify(mod.id)} — the manifest and the profile disagree about what this project is; fix one of them` };
|
|
99
|
+
}
|
|
100
|
+
if (!SUPPORTED_PROFILE_PROTOCOLS.includes(mod.protocol)) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: `profile "${id}" implements profile protocol ${JSON.stringify(mod.protocol)}; this lane speaks ${SUPPORTED_PROFILE_PROTOCOLS.join(" and ")} — upgrade the harness or the profile so they match (\`prooflane upgrade\`)`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (typeof mod.steps !== "function") return { ok: false, reason: `profile "${id}" must export steps(ctx) as a function` };
|
|
107
|
+
if (!mod.layout || typeof mod.layout !== "object") return { ok: false, reason: `profile "${id}" must export layout as an object (where specs, sources, tests and flows live)` };
|
|
108
|
+
if (!mod.tiers || typeof mod.tiers !== "object") return { ok: false, reason: `profile "${id}" must export tiers as an object (which test tiers exist and which can observe which promise)` };
|
|
109
|
+
// Optional declarations: absent is allowed (the core then applies its floor);
|
|
110
|
+
// present-but-wrong is refused, never ignored.
|
|
111
|
+
for (const name of ["artifacts", "governable"]) {
|
|
112
|
+
if (name in mod && typeof mod[name] !== "function") return { ok: false, reason: `profile "${id}" exports ${name} but it is not a function (${name}(root))` };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Locate the profile's entry module for `id`, refusing an unsafe id or a
|
|
119
|
+
* missing directory by name. Shared by the async and sync loaders.
|
|
120
|
+
* @param {string} root
|
|
121
|
+
* @param {string} id
|
|
122
|
+
* @returns {{ok: true, entryRel: string, entryAbs: string} | {ok: false, reason: string}}
|
|
123
|
+
*/
|
|
124
|
+
function locateProfile(root, id) {
|
|
125
|
+
if (typeof id !== "string" || !PROFILE_ID_RE.test(id)) {
|
|
126
|
+
return { ok: false, reason: `profile id ${JSON.stringify(id)} is not a valid profile name (${PROFILE_ID_RE}) — it names a directory under ${PROFILES_DIR_REL}/` };
|
|
127
|
+
}
|
|
128
|
+
const entryRel = profileEntryRel(id);
|
|
129
|
+
const entryAbs = path.join(root, ...entryRel.split("/"));
|
|
130
|
+
if (!fs.existsSync(entryAbs)) {
|
|
131
|
+
let present = [];
|
|
132
|
+
try {
|
|
133
|
+
present = fs
|
|
134
|
+
.readdirSync(path.join(root, ...PROFILES_DIR_REL.split("/")), { withFileTypes: true })
|
|
135
|
+
.filter((e) => e.isDirectory())
|
|
136
|
+
.map((e) => e.name);
|
|
137
|
+
} catch {
|
|
138
|
+
/* no profiles dir at all */
|
|
139
|
+
}
|
|
140
|
+
const have = present.length ? `profiles present: ${present.join(", ")}` : `no profiles are installed under ${PROFILES_DIR_REL}/`;
|
|
141
|
+
return { ok: false, reason: `the manifest names profile "${id}" but ${entryRel} does not exist (${have}) — install the profile or fix ${"qa/harness-manifest.json"}` };
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, entryRel, entryAbs };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* WHICH DECLARATION NAMES A BASE. `extends` is a reserved word — legal as an
|
|
148
|
+
* export NAME (`export { BASE as extends }`) but not as a binding — so a
|
|
149
|
+
* profile author may reasonably reach for either spelling. Both are read, and
|
|
150
|
+
* neither is privileged: an author should not have to guess which one the core
|
|
151
|
+
* happens to prefer.
|
|
152
|
+
*/
|
|
153
|
+
const BASE_KEYS = Object.freeze(["extends", "extendsProfile"]);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The base a profile declares, or null. A base is DATA — the id of another
|
|
157
|
+
* installed profile — never an import: an heir that imports its base is an ESM
|
|
158
|
+
* re-export wearing the word, and the core derived nothing.
|
|
159
|
+
* @param {object} mod
|
|
160
|
+
* @returns {string|null}
|
|
161
|
+
*/
|
|
162
|
+
export function declaredBase(mod) {
|
|
163
|
+
for (const key of BASE_KEYS) {
|
|
164
|
+
const value = mod?.[key];
|
|
165
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The declarations an heir inherits when it does not make them itself.
|
|
172
|
+
*
|
|
173
|
+
* WHY A NAMED LIST RATHER THAN "every export the base has". Spreading a module
|
|
174
|
+
* namespace would inherit `id` and `protocol` too — an heir would silently
|
|
175
|
+
* become its base, and a receipt would name the wrong pack. It would also
|
|
176
|
+
* inherit anything a base happens to export for its own internal use, which is
|
|
177
|
+
* not a contract. So inheritance covers exactly the protocol's own surface: the
|
|
178
|
+
* five REQUIRED_EXPORTS minus identity, plus the optional declarations the
|
|
179
|
+
* loader and the lane already know by name.
|
|
180
|
+
*/
|
|
181
|
+
const INHERITABLE = Object.freeze([
|
|
182
|
+
"layout",
|
|
183
|
+
"tiers",
|
|
184
|
+
"steps",
|
|
185
|
+
"artifacts",
|
|
186
|
+
"governable",
|
|
187
|
+
"grammar",
|
|
188
|
+
"reports",
|
|
189
|
+
"detect",
|
|
190
|
+
"tools",
|
|
191
|
+
"ladder",
|
|
192
|
+
"plants",
|
|
193
|
+
"console",
|
|
194
|
+
"version",
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolve a profile's inheritance chain into one module-shaped object.
|
|
199
|
+
*
|
|
200
|
+
* THE HEIR WINS, ALWAYS, and only for what it actually declares — `in` rather
|
|
201
|
+
* than a truthiness check, so a profile can override a declaration with `null`
|
|
202
|
+
* (a stack with no flows does exactly that) instead of having the base's value
|
|
203
|
+
* silently restored underneath it.
|
|
204
|
+
*
|
|
205
|
+
* A CYCLE IS REFUSED BY NAME, not survived. `a extends b extends a` is an
|
|
206
|
+
* author error, and the useful output is the chain that closed it; hanging or
|
|
207
|
+
* blowing the stack tells them nothing.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} mod the heir, already imported
|
|
210
|
+
* @param {string} id its own id
|
|
211
|
+
* @param {(baseId: string) => {ok: true, profile: object} | {ok: false, reason: string}} load
|
|
212
|
+
* how to load a base by id — the caller supplies sync or async resolution
|
|
213
|
+
* @returns {{ok: true, profile: object, chain: string[]} | {ok: false, reason: string}}
|
|
214
|
+
*/
|
|
215
|
+
export function resolveInheritance(mod, id, load) {
|
|
216
|
+
const chain = [id];
|
|
217
|
+
const merged = {};
|
|
218
|
+
let current = mod;
|
|
219
|
+
|
|
220
|
+
const ownBase = declaredBase(mod);
|
|
221
|
+
if (ownBase && mod.protocol < EXTENDS_PROTOCOL) {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
reason:
|
|
225
|
+
`profile "${id}" declares a base ("${ownBase}") but implements profile protocol ${JSON.stringify(mod.protocol)} — ` +
|
|
226
|
+
`\`extends\` arrived in protocol ${EXTENDS_PROTOCOL}. Declare \`protocol = ${EXTENDS_PROTOCOL}\` so an older lane refuses it by naming the protocol ` +
|
|
227
|
+
`rather than by naming a declaration you deliberately left out`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (;;) {
|
|
232
|
+
const base = declaredBase(current);
|
|
233
|
+
if (!base) break;
|
|
234
|
+
if (chain.includes(base)) {
|
|
235
|
+
return {
|
|
236
|
+
ok: false,
|
|
237
|
+
reason: `profile "${id}" has a circular \`extends\` chain: ${[...chain, base].join(" → ")} — a profile cannot inherit from itself, however many steps around`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const loaded = load(base);
|
|
241
|
+
if (!loaded.ok) {
|
|
242
|
+
return { ok: false, reason: `profile "${chain[chain.length - 1]}" extends "${base}", which did not load: ${loaded.reason}` };
|
|
243
|
+
}
|
|
244
|
+
chain.push(base);
|
|
245
|
+
// Nearest ancestor wins over a more distant one: only fill what is still
|
|
246
|
+
// absent as the walk moves away from the heir.
|
|
247
|
+
for (const key of INHERITABLE) {
|
|
248
|
+
if (!(key in merged) && key in loaded.profile) merged[key] = loaded.profile[key];
|
|
249
|
+
}
|
|
250
|
+
current = loaded.profile;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (chain.length === 1) return { ok: true, profile: mod, chain };
|
|
254
|
+
|
|
255
|
+
// The heir's own declarations sit on top, and identity is never inherited.
|
|
256
|
+
const profile = { ...merged };
|
|
257
|
+
for (const key of Object.keys(mod)) profile[key] = mod[key];
|
|
258
|
+
profile.id = mod.id;
|
|
259
|
+
profile.protocol = mod.protocol;
|
|
260
|
+
return { ok: true, profile, chain };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* loadProfile, synchronously — for the readers that only have a project root
|
|
265
|
+
* and sit in a sync chain (the spec scanner via feature-brief and approvals,
|
|
266
|
+
* the console's Specs bridge). A profile is plain ESM without top-level
|
|
267
|
+
* await, so `require()` loads it: supported since Node 20.19 / 22.12 and
|
|
268
|
+
* sharing the module cache with `import()`, so both loaders hand back the
|
|
269
|
+
* same instance. On an older Node the refusal names the floor instead of
|
|
270
|
+
* guessing a layout.
|
|
271
|
+
* @param {string} root project root
|
|
272
|
+
* @param {{id: string}} named the manifest's `profile`
|
|
273
|
+
* @returns {{ok: true, profile: object, entryRel: string} | {ok: false, reason: string}}
|
|
274
|
+
*/
|
|
275
|
+
export function loadProfileSync(root, { id } = {}) {
|
|
276
|
+
const where = locateProfile(root, id);
|
|
277
|
+
if (!where.ok) return where;
|
|
278
|
+
let mod;
|
|
279
|
+
try {
|
|
280
|
+
mod = createRequire(import.meta.url)(where.entryAbs);
|
|
281
|
+
} catch (err) {
|
|
282
|
+
const code = err && err.code;
|
|
283
|
+
if (code === "ERR_REQUIRE_ESM" || code === "ERR_REQUIRE_ASYNC_MODULE") {
|
|
284
|
+
return { ok: false, reason: `profile "${id}" cannot be loaded synchronously on Node ${process.version} — the harness needs Node 20.19 or 22.12 or newer (require() of ES modules); upgrade Node` };
|
|
285
|
+
}
|
|
286
|
+
return { ok: false, reason: `profile "${id}" failed to load from ${where.entryRel}: ${err && err.message ? err.message : String(err)}` };
|
|
287
|
+
}
|
|
288
|
+
// INHERITANCE BEFORE VALIDATION. The protocol's required exports are a
|
|
289
|
+
// property of the RESOLVED profile, not of the file: an heir that declares
|
|
290
|
+
// only what it changes is complete once its base is merged in, and
|
|
291
|
+
// validating the raw module would refuse it for missing a declaration it
|
|
292
|
+
// legitimately inherits.
|
|
293
|
+
const resolved = resolveInheritance(mod, id, (baseId) => loadProfileSync(root, { id: baseId }));
|
|
294
|
+
if (!resolved.ok) return resolved;
|
|
295
|
+
const verdict = validateProfileModule(resolved.profile, id);
|
|
296
|
+
if (!verdict.ok) return verdict;
|
|
297
|
+
return { ok: true, profile: resolved.profile, entryRel: where.entryRel, chain: resolved.chain };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Load and validate the profile the manifest names.
|
|
302
|
+
* @param {string} root project root
|
|
303
|
+
* @param {{id: string}} named the manifest's `profile`
|
|
304
|
+
* @returns {Promise<{ok: true, profile: object, entryRel: string} | {ok: false, reason: string}>}
|
|
305
|
+
*/
|
|
306
|
+
export async function loadProfile(root, { id } = {}) {
|
|
307
|
+
const where = locateProfile(root, id);
|
|
308
|
+
if (!where.ok) return where;
|
|
309
|
+
let mod;
|
|
310
|
+
try {
|
|
311
|
+
mod = await import(pathToFileURL(where.entryAbs).href);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
return { ok: false, reason: `profile "${id}" failed to load from ${where.entryRel}: ${err && err.message ? err.message : String(err)}` };
|
|
314
|
+
}
|
|
315
|
+
// The async twin resolves the chain the same way; `loadProfileSync` shares
|
|
316
|
+
// the module cache with `import()`, so both loaders hand back one instance
|
|
317
|
+
// of every base and cannot disagree about what an heir inherited.
|
|
318
|
+
const resolved = resolveInheritance(mod, id, (baseId) => loadProfileSync(root, { id: baseId }));
|
|
319
|
+
if (!resolved.ok) return resolved;
|
|
320
|
+
const verdict = validateProfileModule(resolved.profile, id);
|
|
321
|
+
if (!verdict.ok) return verdict;
|
|
322
|
+
return { ok: true, profile: resolved.profile, entryRel: where.entryRel, chain: resolved.chain };
|
|
323
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// profiles/cmp/affected.mjs — which changes fan out too widely to subset the
|
|
2
|
+
// test suite, and how a changed Compose source maps to a Gradle test filter.
|
|
3
|
+
// Mobile's, by definition (docs/NORTH-STAR.md §6).
|
|
4
|
+
//
|
|
5
|
+
// Stage 0 PR 6d. qa/lib/affected-tests.mjs held both halves: the HONESTY
|
|
6
|
+
// CONTRACT (fail open, never fail silent; lane outputs are not changes; an
|
|
7
|
+
// unmapped change runs everything) and the STACK RULES (`.gradle.kts` rewires
|
|
8
|
+
// compilation, `di/` rewires the object graph, `theme/` and
|
|
9
|
+
// `presentation/components/` render into every screen, anything outside
|
|
10
|
+
// composeApp/src is not a scoped source edit, and a `.kt` file's parent
|
|
11
|
+
// directory is a package segment a Gradle `--tests "*seg*"` pattern matches).
|
|
12
|
+
//
|
|
13
|
+
// The first half is the core's and is what makes the subsetting safe at all.
|
|
14
|
+
// The second half is a description of one build tool and one source layout,
|
|
15
|
+
// and vendored into a repo with neither it did something worse than nothing:
|
|
16
|
+
// EVERY path failed the `composeApp/src` test, so every fast run fell open to
|
|
17
|
+
// the full suite — the optimisation silently off, visible only in one
|
|
18
|
+
// parenthetical nobody reads.
|
|
19
|
+
//
|
|
20
|
+
// The pack passes this module to deriveAffectedFilter. Nothing resolves a
|
|
21
|
+
// manifest: the pack already IS the profile, and it can hand the core its own
|
|
22
|
+
// rules directly.
|
|
23
|
+
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Paths whose change fans out too widely to subset the suite safely. Returns
|
|
28
|
+
* the human-readable category when `p` is broad-impact, else null. Checked in
|
|
29
|
+
* order; the first match names the reason. The core adds its own neutral rule
|
|
30
|
+
* (qa/ is the harness judging itself) before consulting this.
|
|
31
|
+
* @param {string} p POSIX relpath from the project root
|
|
32
|
+
* @returns {string|null}
|
|
33
|
+
*/
|
|
34
|
+
export function broadImpact(p) {
|
|
35
|
+
if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
|
|
36
|
+
return "build files rewire compilation";
|
|
37
|
+
}
|
|
38
|
+
if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
|
|
39
|
+
if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
|
|
40
|
+
if (p.includes("presentation/components/")) return "shared components render into every screen";
|
|
41
|
+
if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The Gradle `--tests` patterns for a set of changed paths, or [] when nothing
|
|
47
|
+
* maps (the core then runs everything and says so).
|
|
48
|
+
*
|
|
49
|
+
* Deliberately simple and defensible: each changed `.kt` file under
|
|
50
|
+
* composeApp/src contributes its package's last segment — the parent directory
|
|
51
|
+
* name (`…/presentation/home/HomeViewModel.kt` → `home`, which the template's
|
|
52
|
+
* package-mirrors-path conformance makes a package segment) — and the union
|
|
53
|
+
* becomes patterns matched against test class FQNs. Coarse on purpose:
|
|
54
|
+
* `*home*` runs every test whose FQN mentions the feature, which over-selects
|
|
55
|
+
* a little and under-maintains nothing.
|
|
56
|
+
* @param {string[]} paths POSIX relpaths, already known not to be broad-impact
|
|
57
|
+
* @returns {{patterns: string[], sourcePaths: string[]}}
|
|
58
|
+
*/
|
|
59
|
+
export function patternsFor(paths) {
|
|
60
|
+
const ktPaths = paths.filter((p) => p.endsWith(".kt"));
|
|
61
|
+
const segments = new Set();
|
|
62
|
+
for (const p of ktPaths) {
|
|
63
|
+
const seg = path.posix.basename(path.posix.dirname(p));
|
|
64
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
|
|
65
|
+
}
|
|
66
|
+
return { patterns: [...segments].sort().map((s) => `*${s}*`), sourcePaths: ktPaths };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The mapping the pack hands to qa/lib/affected-tests.mjs. */
|
|
70
|
+
export const affected = Object.freeze({ broadImpact, patternsFor });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// profiles/cmp/android-checks.mjs — the instrumented-test step's verdict.
|
|
2
|
+
// Mobile's, by definition (docs/NORTH-STAR.md §6).
|
|
3
|
+
//
|
|
4
|
+
// This lived in qa/lib/step-outcomes.mjs beside the neutral verdict helpers
|
|
5
|
+
// (StepTimeout, stepErrorResult, spawnTimedOut), and it is not neutral: it
|
|
6
|
+
// reads a Gradle invocation's output, a JUnit summary from
|
|
7
|
+
// connectedDebugAndroidTest, and tells the operator to re-run a Gradle task on
|
|
8
|
+
// a device. A backend profile has none of those. Stage 0 PR 6b.2 moved it here
|
|
9
|
+
// so step-outcomes.mjs is the spine's again. The body is verbatim — the tests
|
|
10
|
+
// that pinned it moved with it.
|
|
11
|
+
//
|
|
12
|
+
// The distinction it draws is the load-bearing part, and it is the core's
|
|
13
|
+
// principle applied to one step: a tier that could not RUN is ERROR, never
|
|
14
|
+
// FAIL. "Your behaviour is broken" is a claim, and this step has observed
|
|
15
|
+
// nothing to support it.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The androidChecks outcome from Gradle's exit and the JUnit summary.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ok: boolean, out: string}} res the Gradle invocation
|
|
21
|
+
* @param {{tests: number, failures: number, errors: number}|null} summary parsed JUnit
|
|
22
|
+
* results, or null when none were written
|
|
23
|
+
* @param {{gradlew?: string}} [opts]
|
|
24
|
+
* @returns {{verdict: "PASS"|"FAIL"|"ERROR", executed: boolean, reason?: string}}
|
|
25
|
+
*/
|
|
26
|
+
export function androidChecksOutcome(res, summary, { gradlew = "./gradlew" } = {}) {
|
|
27
|
+
const executed = Boolean(summary && summary.tests > 0);
|
|
28
|
+
if (res.ok) return { verdict: "PASS", executed };
|
|
29
|
+
const tail = String(res.out ?? "")
|
|
30
|
+
.split("\n")
|
|
31
|
+
.filter((l) => /FAILED|error:|failed/i.test(l))
|
|
32
|
+
.slice(0, 12)
|
|
33
|
+
.join("\n");
|
|
34
|
+
if (executed) {
|
|
35
|
+
return {
|
|
36
|
+
verdict: "FAIL",
|
|
37
|
+
executed,
|
|
38
|
+
reason:
|
|
39
|
+
`connectedDebugAndroidTest failed (${summary.failures + summary.errors} of ${summary.tests} tests) — ` +
|
|
40
|
+
`an on-device behavior claim is broken. Fix the behavior, not the test:\n${tail}`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// ERROR, not FAIL: the step could not execute. A device tier that could not
|
|
44
|
+
// run is not evidence (the lane still FAILs), and going green would be the
|
|
45
|
+
// worse lie — but "your behaviour is broken" is withdrawn, and the receipt
|
|
46
|
+
// can tell a red that measured something from a red that measured nothing.
|
|
47
|
+
return {
|
|
48
|
+
verdict: "ERROR",
|
|
49
|
+
executed,
|
|
50
|
+
reason:
|
|
51
|
+
"connectedDebugAndroidTest DID NOT EXECUTE — the run reported no tests at all, so this step has observed " +
|
|
52
|
+
"nothing about your change and is not accusing it. Usual cause: another adb/Gradle session touching the same " +
|
|
53
|
+
"device (a manual `adb` command, a second lane, a running preview), or an install that never landed. " +
|
|
54
|
+
`Re-run this step alone with nothing else on the device before suspecting the code:\n ${gradlew} :composeApp:connectedDebugAndroidTest --rerun\n${tail}`,
|
|
55
|
+
};
|
|
56
|
+
}
|