create-cmp-cli 0.24.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.
Files changed (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /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
+ }