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
@@ -33,12 +33,13 @@
33
33
  // changes, because a crash mid-plant must never be able to lose your work.
34
34
  //
35
35
  // Which plants are possible is DERIVED from the tree (qa/lib/framework-check.mjs).
36
- // A project with no specs, no flows, or no Kotlin tests still gets the region
36
+ // A project with no specs, no flows, or no test sources still gets the region
37
37
  // plants; each unavailable plant is reported WITH ITS REASON. Nothing here ever
38
38
  // prints PASS because it found nothing to do.
39
39
 
40
40
  import { spawnSync } from "node:child_process";
41
41
  import fs from "node:fs";
42
+ import os from "node:os";
42
43
  import path from "node:path";
43
44
  import { fileURLToPath } from "node:url";
44
45
 
@@ -51,15 +52,22 @@ import {
51
52
  assessPlantRun,
52
53
  assessGreenRun,
53
54
  assessCalibrationCost,
55
+ assessBadgeFloor,
56
+ plantRow,
54
57
  } from "./lib/framework-check.mjs";
58
+ import { plantCalibration } from "./lib/plant-calibration.mjs";
59
+ import { evidenceLadderFor } from "./lib/evidence-ladder.mjs";
55
60
  import { listHarnessFiles } from "./lib/harness-region.mjs";
56
- import { listFlowFiles, scanCitations, walkFiles, DESKTOP_TIERS } from "./lib/spec-coverage.mjs";
61
+ import { checkHarnessIntegrity, describeIntegrity, writeHarnessLock } from "./lib/harness-lock.mjs";
62
+ import { listFlowFiles, scanCitations, walkFiles } from "./lib/spec-coverage.mjs";
63
+ import { resolveSpecModel } from "./lib/spec-model.mjs";
64
+ import { resolveHarnessManifest } from "./lib/harness-manifest.mjs";
65
+ import { loadProfileSync } from "./lib/profile-loader.mjs";
57
66
 
58
67
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
59
68
  const RECEIPT_REL = "qa/evidence/latest.json";
60
69
  const SURFACE_REL = "qa/verified-surface.json";
61
70
  const README_REL = "README.md";
62
- const PLANTED_TEST_BASENAME = "CmpFrameworkCheckPlanted.kt";
63
71
 
64
72
  const argv = process.argv.slice(2);
65
73
  const flag = (n) => argv.includes(n);
@@ -188,45 +196,129 @@ function hookRefuses() {
188
196
  // ── Read the tree, decide what can be planted ───────────────────────────────
189
197
 
190
198
  function readSpecs() {
191
- const dir = path.join(ROOT, "specs");
199
+ if (!SPEC_MODEL) return [];
200
+ const dir = path.join(ROOT, ...SPEC_MODEL.specsDir.split("/"));
192
201
  if (!fs.existsSync(dir)) return [];
193
202
  return fs
194
203
  .readdirSync(dir)
195
204
  .filter((n) => n.endsWith(".spec.md"))
196
205
  .sort()
197
- .map((n) => ({ rel: `specs/${n}`, text: fs.readFileSync(path.join(dir, n), "utf8") }));
206
+ .map((n) => ({ rel: `${SPEC_MODEL.specsDir}/${n}`, text: fs.readFileSync(path.join(dir, n), "utf8") }));
198
207
  }
199
208
 
209
+ // The scanner's model — the profile's layout and tiers. This runner plants
210
+ // into specs, flows and test sources, so it has to know where they are; it
211
+ // asks the profile, never a constant. A tree with no usable profile has no
212
+ // spec plants to make, and says so per plant rather than guessing.
213
+ const SPEC_MODEL_RESULT = resolveSpecModel(ROOT);
214
+ // REFUSE FIRST. With no manifest there is no layout, so every plant that reads
215
+ // the tree reported an honest-looking absence of files that exist: a project
216
+ // with two specs and nine clauses was told "no spec files — nothing declares
217
+ // behavior to plant against", four lines before the manifest refusal explained
218
+ // the real problem. That is the exact failure harness-manifest.mjs names in its
219
+ // own header as the reason there is no default profile, reproduced inside the
220
+ // instrument built to prove the lane returns. A reader who cannot know the
221
+ // layout says so and stops, rather than describing a tree it cannot see.
222
+ if (!SPEC_MODEL_RESULT.ok) {
223
+ process.stderr.write(`\n${SPEC_MODEL_RESULT.reason}\n`);
224
+ process.exit(2);
225
+ }
226
+ const SPEC_MODEL = SPEC_MODEL_RESULT.model;
227
+
228
+ // The SOURCE this profile's planted citations live in. A citation must sit on
229
+ // a test, so two of the plants have to WRITE a test — in this stack's
230
+ // language, with its test-declaration syntax, at a path it compiles. Everything
231
+ // else about a plant is stack-free: the clause grammar and the citation marker
232
+ // are the core scanner's, and which plants a tree can support is derived from
233
+ // the tree. A profile that declares none simply ships without those two, said
234
+ // out loud per plant (§5.2: no plants, no badge).
235
+ const PROFILE_MODULE = (() => {
236
+ const manifest = resolveHarnessManifest(ROOT);
237
+ if (!manifest.ok) return null;
238
+ const loaded = loadProfileSync(ROOT, manifest.manifest.profile);
239
+ return loaded.ok ? loaded.profile : null;
240
+ })();
241
+ const PLANT_DECL = PROFILE_MODULE?.plants ?? null;
242
+
243
+ // The SOURCE half of that declaration, which the two citation plants cannot do
244
+ // without. Kept separate from the declaration itself because the other half —
245
+ // which step observes which plant (`observedBy`) — costs a pack nothing to give
246
+ // and must not be withheld by the absence of a Kotlin/Python/Go snippet it has
247
+ // not written yet. Requiring all of it or none of it would mean a pack that
248
+ // names its steps but ships no plant source still had cmp's spellings asserted
249
+ // against it, which is the defect this reads the declaration to avoid.
250
+ //
251
+ // THE TEST FOR "ENOUGH TO PLANT WITH" USED TO BE THREE `typeof`s WRITTEN HERE.
252
+ // It moved to qa/lib/plant-calibration.mjs the day the RUNG came to depend on
253
+ // the same answer (NORTH-STAR §8.9 — a profile with no calibrated plants earns
254
+ // no rung): the grader and the instrument asking that question separately is
255
+ // how the same word ends up meaning two things in one tree, which is the
256
+ // `flowCitation`/`scanCitations` episode this file already carries.
257
+ const PROFILE_PLANTS = plantCalibration(PLANT_DECL).ok ? PLANT_DECL : null;
258
+ const PLANTED_TEST_REL = (testDir) => `${testDir}/${PROFILE_PLANTS.testFileBasename}`;
259
+
200
260
  function readFlows() {
201
- return listFlowFiles(ROOT).map((rel) => ({ rel, text: fs.readFileSync(abs(rel), "utf8") }));
261
+ if (!SPEC_MODEL) return [];
262
+ return listFlowFiles(ROOT, SPEC_MODEL).map((rel) => ({ rel, text: fs.readFileSync(abs(rel), "utf8") }));
202
263
  }
203
264
 
204
265
  /**
205
- * A directory that already holds a desktop-tier test, derived from the tree's
206
- * own citations rather than guessed from a package convention — an adopted
207
- * project's source layout is not ours to assume.
266
+ * A directory that already holds a host-only-tier test, derived from the
267
+ * tree's own citations rather than guessed from a package convention — an
268
+ * adopted project's source layout is not ours to assume.
208
269
  */
209
270
  function findTestDir() {
271
+ if (!SPEC_MODEL) return null;
210
272
  let tags = [];
211
273
  try {
212
- tags = scanCitations(ROOT);
274
+ tags = scanCitations(ROOT, SPEC_MODEL);
213
275
  } catch {
214
276
  return null;
215
277
  }
216
- const desktop = tags.find((tag) => DESKTOP_TIERS.includes(tag.tier));
217
- if (desktop) return path.dirname(desktop.file);
218
- // No citations yet: fall back to any directory holding a Kotlin test source.
219
- const kt = walkFiles(path.join(ROOT, "composeApp/src"), [".kt"]).find((f) => /(commonTest|desktopTest)/.test(f));
220
- return kt ? path.dirname(path.relative(ROOT, kt)) : null;
278
+ const hostOnly = SPEC_MODEL.tiers.hostOnly;
279
+ const cited = tags.find((tag) => hostOnly.includes(tag.tier));
280
+ if (cited) return path.dirname(cited.file);
281
+ // No citations yet: fall back to any source under a citation root that the
282
+ // profile's own tiering places on a host-only tier.
283
+ for (const rootRel of SPEC_MODEL.citationRoots) {
284
+ const hit = walkFiles(path.join(ROOT, ...rootRel.split("/")), SPEC_MODEL.citationExts).find((f) =>
285
+ hostOnly.includes(SPEC_MODEL.tiers.forFile(path.relative(ROOT, f))),
286
+ );
287
+ if (hit) return path.dirname(path.relative(ROOT, hit));
288
+ }
289
+ return null;
221
290
  }
222
291
 
223
- out(`framework check: bound=${BOUND_MS}ms per direction, profile=smoke (no Gradle, no device, no network)`);
292
+ // `smoke` is the lane's cheapest profile: the steps that need no build tool, no
293
+ // device and no network. Naming the build tool here ("no Gradle") was one
294
+ // stack's word in the instrument that exists to prove this file has none — a
295
+ // small lie, but in the one place that can least afford it.
296
+ out(`framework check: bound=${BOUND_MS}ms per direction, profile=smoke (host-only steps: no build tool, no device, no network)`);
224
297
 
298
+ // With no plant material the instrument cannot write a test, so the two plants
299
+ // that need one are unavailable — reported by name, never quietly dropped.
225
300
  const tree = {
226
301
  specs: readSpecs(),
227
302
  flows: readFlows(),
228
303
  harnessLib: listHarnessFiles(ROOT).filter((rel) => rel.startsWith("qa/lib/")),
229
- testDir: findTestDir(),
304
+ testDir: PROFILE_PLANTS ? findTestDir() : null,
305
+ // So the skip can name the REAL cause instead of blaming the tree.
306
+ plantsDeclared: Boolean(PROFILE_PLANTS),
307
+ // No `?? "e2e"` anywhere: the tier a plant declares is the profile's or absent.
308
+ unmeetableTier: PROFILE_PLANTS?.unmeetableTier ?? null,
309
+ // WHICH STEP CATCHES WHICH PLANT is the pack's word, exactly as the tier
310
+ // above is. There is no `?? "specCoverage"` here for the same reason there is
311
+ // no `?? "e2e"` there: a default is another stack's name asserted against
312
+ // this one, and lib/framework-check.mjs `observingStep` records what that
313
+ // cost. A pack that names none gets an assertion over the lane.
314
+ observedBy: PLANT_DECL?.observedBy ?? null,
315
+ flowsDir: SPEC_MODEL && SPEC_MODEL.flows ? SPEC_MODEL.flows.dir : null,
316
+ // What a citation LOOKS like is the profile's, never `#`. Without this the
317
+ // selector falls back to DEFAULT_GRAMMAR, which accepts `//` and `#` and so
318
+ // happens to serve C-family and YAML journeys — and silently refuses to
319
+ // select either flow plant for a stack whose comments start `--`, `;` or `%`.
320
+ // A plant that is never selected is a gate nobody is calibrating.
321
+ grammar: SPEC_MODEL ? SPEC_MODEL.grammar : null,
230
322
  };
231
323
 
232
324
  const { plants, unavailable } = selectPlants(tree);
@@ -235,6 +327,50 @@ if (!coverage.ok) die(coverage.reason);
235
327
 
236
328
  for (const u of unavailable) out(` ⓘ ${u.kind.padEnd(24)} not planted — ${u.reason}`);
237
329
 
330
+ // A plant whose step this pack has not named is neither unavailable nor broken:
331
+ // it asserts over the LANE instead of over one row, which is a real gate and a
332
+ // blunter one (qa/lib/framework-check.mjs `observingStep`). It is said out loud
333
+ // for the same reason every unavailable plant says why — and for one more. The
334
+ // whole episode behind `observedBy` is that a requirement nobody can discover is
335
+ // a requirement nobody meets: `harnessIntegrity` was undiscoverable and cost an
336
+ // adopter a lane that could never mint a valid receipt (NORTH-STAR §9.1). A
337
+ // declaration that only appears in a refusal repeats that, one level quieter. So
338
+ // the instrument names it while everything is GREEN, which is the only moment
339
+ // the reader is not already debugging something else.
340
+ for (const p of plants) {
341
+ if (p.step || p.vouching) continue;
342
+ out(
343
+ ` ⓘ ${p.kind.padEnd(24)} asserted over the whole lane — this profile's \`plants.observedBy\` names no step ` +
344
+ `for it; naming one narrows the assertion to that row`,
345
+ );
346
+ }
347
+
348
+ // ── The badge floor, planted (NORTH-STAR §8.9, §6.7; GATE-RULES Rule 1) ─────
349
+ // "A profile with no calibrated plants earns no rung" is a guarantee, and until
350
+ // 2026-09-08 nothing checked it: two scratch adopters differing in exactly one
351
+ // export — one shipping `plants`, one shipping none — both earned L1. The floor
352
+ // now lives in the grader (qa/lib/evidence-level.mjs), and a floor nobody
353
+ // watches fail is a floor that quietly stops being there, so this plants the
354
+ // violation it exists to catch and asserts the refusal.
355
+ //
356
+ // It runs BEFORE the dirty-tree check because it touches nothing: no file, no
357
+ // lane, no receipt. That is also its limit and the line below says so — it
358
+ // proves what the grader WOULD give this ladder, not that a lane ran.
359
+ const badgeFloor = (() => {
360
+ const started = t();
361
+ const resolved = evidenceLadderFor(PROFILE_MODULE);
362
+ const assessed = resolved.ok
363
+ ? assessBadgeFloor({ ladder: resolved.ladder, plants: PLANT_DECL })
364
+ : { available: false, reason: resolved.reason };
365
+ return { ...assessed, ms: t() - started };
366
+ })();
367
+ if (badgeFloor.ok === false) die(badgeFloor.reason);
368
+ out(
369
+ badgeFloor.available === false
370
+ ? ` ⓘ ${"badge floor".padEnd(24)} not planted — ${badgeFloor.reason}`
371
+ : ` badge floor ${String(badgeFloor.ms).padStart(5)}ms ✓ this profile's ladder grades ${badgeFloor.rung} with its plants and NOTHING without them (grader only — no lane run)`,
372
+ );
373
+
238
374
  // ── Refuse to start on a dirty tree ─────────────────────────────────────────
239
375
  // Everything this may write, named up front. A file with uncommitted changes is
240
376
  // a file whose bytes we would be gambling with if the process died mid-plant.
@@ -245,7 +381,7 @@ for (const p of plants) {
245
381
  if (p.target.flow) touched.add(p.target.flow);
246
382
  if (p.target.file) touched.add(p.target.file);
247
383
  if (p.target.declaration) touched.add(p.target.declaration);
248
- if (p.target.testDir) touched.add(`${p.target.testDir}/${PLANTED_TEST_BASENAME}`);
384
+ if (p.target.testDir) touched.add(PLANTED_TEST_REL(p.target.testDir));
249
385
  }
250
386
 
251
387
  {
@@ -289,7 +425,7 @@ function plantFiles(plant) {
289
425
  if (plant.target.flows) rels.push(...plant.target.flows);
290
426
  if (plant.target.file) rels.push(plant.target.file);
291
427
  if (plant.target.declaration) rels.push(plant.target.declaration);
292
- if (plant.target.testDir) rels.push(`${plant.target.testDir}/${PLANTED_TEST_BASENAME}`);
428
+ if (plant.target.testDir) rels.push(PLANTED_TEST_REL(plant.target.testDir));
293
429
  if (plant.target.nestInto) {
294
430
  for (const rel of plant.target.flows ?? []) rels.push(`${plant.target.nestInto}/${path.basename(rel)}`);
295
431
  }
@@ -300,9 +436,31 @@ function read(rel) {
300
436
  return fs.readFileSync(abs(rel), "utf8");
301
437
  }
302
438
 
303
- /** Every `# SPEC:` citation line neutralised — the flow stays valid YAML. */
304
- function stripCitations(text) {
305
- return text.replace(/^#\s*SPEC:.*$/gm, "# (citation removed by the framework check)");
439
+ /**
440
+ * Every citation line REMOVED — in whatever comment syntax this stack writes.
441
+ *
442
+ * This replaced a line that neutralised `^#\s*SPEC:` and wrote a `#` comment in
443
+ * its place, which is one stack's syntax twice over. It was survivable only
444
+ * while the plant selector was ALSO keyed to `#`: a `//`-citing project had
445
+ * both flow plants skipped, silently, and the two failures cancelled. Now that
446
+ * the selector reads the profile's declared marker, this one would be reached
447
+ * with a marker it cannot match — the citation would stay, the plant would not
448
+ * bite, and the instrument would report a framework defect that is its own.
449
+ *
450
+ * Removing the whole line rather than rewriting it is what makes this
451
+ * language-free: deleting a comment is safe in YAML, in every C-family flow, in
452
+ * Python and in Lua, whereas writing a replacement comment requires knowing the
453
+ * syntax. The marker is matched on the TRIMMED line, agreeing with
454
+ * `scanCitations` and with the selector about indented citations.
455
+ */
456
+ function stripCitations(text, marker = SPEC_MODEL?.grammar?.citationMarker ?? /\bSPEC:/) {
457
+ // A profile could declare its marker with /g, which makes `.test()` stateful
458
+ // across calls. Rebuild without flags rather than trust the declaration.
459
+ const probe = new RegExp(marker.source);
460
+ return text
461
+ .split("\n")
462
+ .filter((line) => !probe.test(line.trim()))
463
+ .join("\n");
306
464
  }
307
465
 
308
466
  function applyPlant(plant) {
@@ -320,10 +478,7 @@ function applyPlant(plant) {
320
478
  // the clause exists, the tag exists, and nothing runs.
321
479
  const text = read(target.spec);
322
480
  write(target.spec, `${text.trimEnd()}\n- **${target.clause}** — Given a planted clause, Then a class-level tag must not count.\n`);
323
- write(
324
- `${target.testDir}/${PLANTED_TEST_BASENAME}`,
325
- `// Planted by qa/framework-check.mjs — reverted automatically.\n\n// SPEC: ${target.clause}\nclass CmpFrameworkCheckPlanted {\n val a = 1\n val b = 2\n val c = 3\n val d = 4\n val e = 5\n val f = 6\n fun helper() {}\n}\n`,
326
- );
481
+ write(PLANTED_TEST_REL(target.testDir), PROFILE_PLANTS.unboundCitationSource(target.clause));
327
482
  break;
328
483
  }
329
484
  case PLANT_KINDS.TIER_UNMET: {
@@ -331,12 +486,9 @@ function applyPlant(plant) {
331
486
  const text = read(target.spec);
332
487
  write(
333
488
  target.spec,
334
- `${text.trimEnd()}\n- **${target.clause}** [tier: e2e] — Given a planted device-only clause, Then a JVM citation cannot satisfy it.\n`,
335
- );
336
- write(
337
- `${target.testDir}/${PLANTED_TEST_BASENAME}`,
338
- `// Planted by qa/framework-check.mjs — reverted automatically.\n\nimport kotlin.test.Test\n\nclass CmpFrameworkCheckPlanted {\n // SPEC: ${target.clause}\n @Test\n fun planted() {}\n}\n`,
489
+ `${text.trimEnd()}\n- **${target.clause}** [tier: ${target.unmeetableTier}] — Given a planted clause only the target can observe, Then a host-tier citation cannot satisfy it.\n`,
339
490
  );
491
+ write(PLANTED_TEST_REL(target.testDir), PROFILE_PLANTS.tierUnmetCitationSource(target.clause));
340
492
  break;
341
493
  }
342
494
  case PLANT_KINDS.FEATURE_WITHOUT_FLOW: {
@@ -429,8 +581,15 @@ try {
429
581
  }
430
582
  }
431
583
 
584
+ // NAME THE ROW THAT ACTUALLY WENT RED, not the one the plant asked for.
585
+ // For a plant whose step this pack did not declare those are different
586
+ // strings — the plant asked for nothing — and "undefined FAIL naming
587
+ // CART-01" is a line that makes a working run look broken. It is also the
588
+ // more useful line in every case: on a pack the reader does not know, this
589
+ // is where they learn which of their own gates is calibrated.
432
590
  const named = plant.names.length ? ` naming ${plant.names.join(", ")}` : "";
433
- out(` FAIL: ${plant.label.padEnd(28)} ${String(run.ms).padStart(5)}ms ✓ ${plant.step} FAIL${named}`);
591
+ const observed = plantRow(run.receipt.steps, plant)?.name ?? plant.step ?? "the lane";
592
+ out(` FAIL: ${plant.label.padEnd(28)} ${String(run.ms).padStart(5)}ms ✓ ${observed} FAIL${named}`);
434
593
  revertPlant(plant);
435
594
  }
436
595
 
@@ -439,9 +598,21 @@ try {
439
598
  const first = plants[0];
440
599
  applyPlant(first);
441
600
  const r = runSmoke();
442
- revertPlant(first);
443
601
  if (!r.receipt || r.receipt.verdict !== "FAIL") die("could not produce a FAIL receipt for the hook check");
602
+ // ASK THE HOOK BEFORE REVERTING. `revertPlant` restores RECEIPT_REL from
603
+ // what was on disk when this run started, so reverting first meant the hook
604
+ // was never shown the FAIL receipt the lane had just written. On a tree with
605
+ // no prior receipt the revert DELETED it and the hook refused because there
606
+ // was no receipt at all — green, for a reason that has nothing to do with
607
+ // this assertion. On a tree where the lane had already run, the revert put
608
+ // the earlier PASS back and the check failed on a working lane. Neither
609
+ // outcome tested "the Stop hook refuses a FAIL receipt". A check that cannot
610
+ // fail for its own reason is the exact defect this instrument exists to
611
+ // catch, which is why it is worth the four lines to say so. Found by the
612
+ // first non-Compose adoption, 2026-09-04. (`die` is safe here: every touched
613
+ // file is restored in the outer finally, plant reverted or not.)
444
614
  if (!hookRefuses().refused) die("the Stop hook did not refuse a FAIL receipt");
615
+ revertPlant(first);
445
616
  out(` Stop hook refuses a FAIL receipt ✓`);
446
617
  }
447
618
 
@@ -449,7 +620,19 @@ try {
449
620
  // synthetic row over this tree's own valid hash, so only the skip is new.
450
621
  {
451
622
  const green = runSmoke();
452
- if (!green.receipt || green.receipt.verdict !== "PASS") die("could not produce a PASS receipt for the device-tier hook check");
623
+ if (!green.receipt || green.receipt.verdict !== "PASS") {
624
+ // NAME THE STEPS. "could not produce a PASS receipt" told a reader that
625
+ // something was wrong and nothing about what, in the one place where the
626
+ // instrument has the whole receipt in hand. Evidence-or-silence applies to
627
+ // the instrument's own refusals too.
628
+ const bad = (green.receipt?.steps ?? [])
629
+ .filter((s) => s.verdict === "FAIL" || s.verdict === "ERROR")
630
+ .map((s) => `${s.name}: ${s.reason ?? s.verdict}`);
631
+ die(
632
+ `could not produce a PASS receipt for the device-tier hook check` +
633
+ (bad.length ? ` — ${bad.join("; ")}` : green.receipt ? ` (verdict ${green.receipt.verdict}, no failing step)` : " (no receipt at all)"),
634
+ );
635
+ }
453
636
  const planted = {
454
637
  ...green.receipt,
455
638
  profile: "local",
@@ -461,10 +644,57 @@ try {
461
644
  };
462
645
  write(RECEIPT_REL, JSON.stringify(planted, null, 2));
463
646
  const hook = hookRefuses();
464
- if (!hook.refused || !/device tier did not run/.test(hook.stderr)) {
465
- die(`the Stop hook did not refuse a receipt whose device tier was skipped for an environmental reason:\n${hook.stderr.slice(-400)}`);
647
+ // Stage 0 PR 6c: the refusal is stack-free — ANY step that skipped for an
648
+ // environmental reason blocks done, whatever it is called.
649
+ if (!hook.refused || !/a tier did not run/.test(hook.stderr)) {
650
+ die(`the Stop hook did not refuse a receipt whose tier was skipped for an environmental reason:\n${hook.stderr.slice(-400)}`);
651
+ }
652
+ out(` Stop hook refuses a skipped tier ✓`);
653
+ }
654
+
655
+ // 4b. THE VACUITY FLOOR (ADR-0010) — the one plant that cannot be made in
656
+ // this tree.
657
+ //
658
+ // Its violation is the ABSENCE of the engine, so planting it here would
659
+ // delete the files this instrument is running from: the lane would fail to
660
+ // START rather than fail by name, and a plant that proves the tree is broken
661
+ // proves nothing about the gate. So the region is built in a scratch
662
+ // directory, locked, and the refusal is watched there.
663
+ //
664
+ // The exception is granted for one reason and holds only while that reason
665
+ // does: this is the only gate whose violation removes the instrument that
666
+ // would observe it. A future gate wanting an out-of-tree plant must show the
667
+ // same property, not cite this one.
668
+ {
669
+ const started = Date.now();
670
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "vacuity-floor-"));
671
+ try {
672
+ fs.mkdirSync(path.join(scratch, "qa"), { recursive: true });
673
+ // A region of declarations and nothing else — ADR-0008's probe, exactly.
674
+ fs.writeFileSync(path.join(scratch, "qa", "verified-surface.json"), '{"surface":["src"]}\n');
675
+ fs.writeFileSync(path.join(scratch, "qa", "harness-manifest.json"), '{"schema":"harness-manifest/1"}\n');
676
+ writeHarnessLock(scratch, { version: "0.0.0-plant" });
677
+ const seen = checkHarnessIntegrity(scratch);
678
+ if (!seen.vacuous || seen.engineFiles !== 0) {
679
+ die(`the vacuity floor did not see an engine-free region: engineFiles=${seen.engineFiles}, vacuous=${seen.vacuous}`);
680
+ }
681
+ if (seen.status !== "intact") {
682
+ die(`the plant is only meaningful while the region reads intact — it read "${seen.status}", so the floor is being proved against the wrong state`);
683
+ }
684
+ // FAILING BY NAME is the requirement (§8.8), and the name has to reach a
685
+ // human. `describeIntegrity` is the shared voice every caller renders, so
686
+ // the plant watches THAT refuse — a flag nobody prints would be a gate
687
+ // nobody sees.
688
+ const said = describeIntegrity(seen);
689
+ if (!/not a lane/.test(said) || /files verified/.test(said)) {
690
+ die(`the vacuity floor is invisible to every caller that renders an integrity result: describeIntegrity said "${said}"`);
691
+ }
692
+ const ms = Date.now() - started;
693
+ cycles.push({ label: "vacuity floor", ms });
694
+ out(` FAIL: region holds no engine ${String(ms).padStart(5)}ms ✓ intact but vacuous, and it SAYS so — ${seen.fileCount} file(s), 0 engine`);
695
+ } finally {
696
+ fs.rmSync(scratch, { recursive: true, force: true });
466
697
  }
467
- out(` Stop hook refuses a skipped device tier ✓`);
468
698
  }
469
699
 
470
700
  // 5. Everything reverted, and it passes again — the plants were the only cause.
@@ -492,6 +722,10 @@ if (asJson) {
492
722
  verdict: "PASS",
493
723
  plants: plants.map((p) => ({ kind: p.kind, label: p.label, step: p.step })),
494
724
  unavailable,
725
+ // Reported beside the tree plants, never counted as one: it plants into
726
+ // the GRADER rather than into the tree, and a reader comparing plant
727
+ // counts across runs must not see this one move the number.
728
+ badgeFloor,
495
729
  cycles,
496
730
  totalMs: cost.totalMs,
497
731
  budgetMs: BUDGET_MS,
@@ -21,7 +21,7 @@
21
21
  // Trees produced before the role/clickable/disabled contract extension are handled
22
22
  // gracefully: nodes without `clickable` are simply skipped, never crashed on.
23
23
 
24
- import { walk } from "./tree.mjs";
24
+ import { walk } from "./profiles/cmp/tree.mjs";
25
25
 
26
26
  /**
27
27
  * Audit a tree for accessibility faults.
@@ -14,11 +14,18 @@
14
14
  // and the caller reports which case it was in the step's output and the
15
15
  // receipt, so a filtered run can never be mistaken for the full suite.
16
16
  // - The BLAST-RADIUS ESCAPE HATCH is mandatory: some paths fan out too
17
- // widely to subset safely (build files rewire compilation, DI rewires
18
- // object graphs, theme/tokens and shared components render into every
19
- // screen, qa/ is the harness judging itself, and anything outside
20
- // composeApp/src is by definition not a scoped source edit). Any one such
21
- // change disables filtering for the run.
17
+ // widely to subset safely. qa/ is the harness judging itself — that one is
18
+ // the core's, on every stack. WHICH OTHER paths fan out, and how a changed
19
+ // source maps to a test filter, are facts about one build tool and one
20
+ // source layout, so they come from the PROFILE (Stage 0 PR 6d;
21
+ // profiles/cmp/affected.mjs). Any one broad-impact change disables
22
+ // filtering for the run.
23
+ //
24
+ // A profile that supplies no mapping gets `mode: "all"` with that as the
25
+ // reason — fail open, said out loud. Vendored into a repo whose sources are
26
+ // not under composeApp/src, the old hardcoded rules did something worse than
27
+ // nothing: every path failed the layout test, so every fast run fell open to
28
+ // the full suite with the optimisation silently off.
22
29
  //
23
30
  // Pure functions over path lists — git access is injected/separate so the
24
31
  // engine suite can test every branch with no repo state.
@@ -43,50 +50,37 @@ import path from "node:path";
43
50
  // first fell open to the full suite, visible only in one parenthetical.
44
51
  // Found by payment-blueprint's spine adoption (2026-09-03), where the same
45
52
  // line also landed in their locked region.
46
- export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/flight-recorder.jsonl"];
53
+ // qa/.lane-in-progress is the lane's own marker (qa/lib/lane-markers.mjs) —
54
+ // present, untracked, for exactly the duration of the run that would read it.
55
+ export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/flight-recorder.jsonl", "qa/.lane-in-progress"];
47
56
 
48
57
  function isLaneOutput(p) {
49
58
  return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
50
59
  }
51
60
 
52
61
  /**
53
- * The mandatory blast-radius escape hatch: paths whose change fans out too
54
- * widely to subset the suite safely. Returns the human-readable category when
55
- * `p` is broad-impact, else null. Checked in order; the first match names the
56
- * reason.
62
+ * The core's own blast-radius rule, on every stack: a change under qa/ is the
63
+ * harness judging itself, so nothing may be subsetted by it.
57
64
  * @param {string} p POSIX relpath from the project root
58
65
  * @returns {string|null}
59
66
  */
60
- export function broadImpactReason(p) {
61
- if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
62
- return "build files rewire compilation";
63
- }
64
- if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
65
- if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
66
- if (p.includes("presentation/components/")) return "shared components render into every screen";
67
+ export function coreBroadImpactReason(p) {
67
68
  if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
68
- if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
69
69
  return null;
70
70
  }
71
71
 
72
72
  /**
73
73
  * Derive the fast-mode unit-test filter from a list of changed paths.
74
74
  *
75
- * Mapping (deliberately simple and defensible): each changed `.kt` file under
76
- * composeApp/src contributes its package's last segment — the parent
77
- * directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
78
- * template's package-mirrors-path conformance makes a package segment) — and
79
- * the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
80
- * class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
81
- * the feature, which over-selects a little and under-maintains nothing.
82
- *
83
75
  * @param {string[]} changedPaths relpaths (either separator style) — tracked
84
76
  * diffs plus untracked files, as from changedWorkingTreePaths()
77
+ * @param {{broadImpact: (p: string) => (string|null), patternsFor: (paths: string[]) => {patterns: string[], sourcePaths: string[]}}} [mapping]
78
+ * the profile's rules (profiles/<id>/affected.mjs). Absent = no subsetting.
85
79
  * @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
86
80
  * {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
87
81
  * mode "all" ALWAYS carries the honest reason to report.
88
82
  */
89
- export function deriveAffectedFilter(changedPaths) {
83
+ export function deriveAffectedFilter(changedPaths, mapping = null) {
90
84
  const paths = [...new Set((changedPaths ?? [])
91
85
  .filter((p) => typeof p === "string" && p.length > 0)
92
86
  .map((p) => p.split(path.sep).join("/")))]
@@ -97,32 +91,93 @@ export function deriveAffectedFilter(changedPaths) {
97
91
  return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
98
92
  }
99
93
 
94
+ // No mapping, no subsetting — and the reason says which half is missing, so
95
+ // a profile author sees the optimisation is off rather than wondering why
96
+ // the fast lane costs what the full one does.
97
+ if (!mapping || typeof mapping.broadImpact !== "function" || typeof mapping.patternsFor !== "function") {
98
+ return { mode: "all", reason: "this profile declares no affected-test mapping — every fast run tests everything", patterns: [], sourcePaths: paths };
99
+ }
100
+
100
101
  for (const p of paths) {
101
- const broad = broadImpactReason(p);
102
+ const broad = coreBroadImpactReason(p) ?? mapping.broadImpact(p);
102
103
  if (broad) {
103
104
  return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
104
105
  }
105
106
  }
106
107
 
107
- // Every remaining path is a scoped file under composeApp/src. Only .kt
108
- // files map to test patterns; a change that maps to nothing (resources,
109
- // manifests) falls open to the full suite below.
110
- const ktPaths = paths.filter((p) => p.endsWith(".kt"));
111
- const segments = new Set();
112
- for (const p of ktPaths) {
113
- const seg = path.posix.basename(path.posix.dirname(p));
114
- if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
115
- }
116
-
117
- if (segments.size === 0) {
108
+ // Every remaining path is a scoped source edit by the profile's own reckoning.
109
+ // A change that maps to no pattern (resources, manifests) falls open below.
110
+ const { patterns, sourcePaths } = mapping.patternsFor(paths);
111
+ if (!Array.isArray(patterns) || patterns.length === 0) {
118
112
  return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
119
113
  }
120
114
 
121
- return {
122
- mode: "filtered",
123
- patterns: [...segments].sort().map((s) => `*${s}*`),
124
- sourcePaths: ktPaths,
125
- };
115
+ return { mode: "filtered", patterns, sourcePaths: Array.isArray(sourcePaths) ? sourcePaths : paths };
116
+ }
117
+
118
+ /**
119
+ * Must the expensive tier run for this change?
120
+ *
121
+ * The most costly thing an agent does is run a device or journey tier — minutes
122
+ * of emulator, build and flow — to prove something about a change that tier
123
+ * cannot see. Running it anyway is not rigour, it is latency, and latency is
124
+ * how an agent ends up CLAIMING instead of deriving (G1, G2).
125
+ *
126
+ * THE DECLARATION IS OF IRRELEVANCE, NOT OF RELEVANCE, and getting that
127
+ * backwards turns this into the defect it prevents. The first version of this
128
+ * function asked "is any changed path under a root that FEEDS the tier?" and
129
+ * answered "not required" when none matched — an allowlist, so anything nobody
130
+ * thought to list was silently deferred. `cmp` declares
131
+ * `sourceRoots: ["composeApp/src"]`, which excludes `build.gradle.kts` and
132
+ * `gradle/libs.versions.toml` — both of which absolutely change what runs on a
133
+ * device. A dependency bump would have skipped the device tier and said so
134
+ * confidently (ADR-0009, "what would make this wrong").
135
+ *
136
+ * So: the tier RUNS unless every changed path is under something explicitly
137
+ * declared unable to affect it. A path nobody classified obliges the tier,
138
+ * which is the safe direction — being wrong toward running costs minutes, being
139
+ * wrong toward deferring costs a regression nobody saw.
140
+ *
141
+ * This does not skip anything; it answers a question and always carries the
142
+ * reason, so a caller can record it and a reader can disagree with it. An
143
+ * unrecorded skip is a lie; a recorded one is evidence.
144
+ *
145
+ * @param {string[]|null} changedPaths relpaths, or null when git could not say
146
+ * @param {{irrelevantRoots?: string[], tierName?: string}} [decl] paths declared
147
+ * unable to affect the tier: a `dir/` prefix, or a `*.ext` suffix
148
+ * @returns {{required: boolean, reason: string, obliging: string[]}}
149
+ */
150
+ export function deriveTierNeed(changedPaths, { irrelevantRoots = [], tierName = "the device tier" } = {}) {
151
+ if (!Array.isArray(changedPaths)) {
152
+ return { required: true, reason: `cannot tell what changed — ${tierName} runs`, obliging: [] };
153
+ }
154
+ const paths = changedPaths
155
+ .filter((p) => typeof p === "string" && p.length > 0)
156
+ .map((p) => p.split(path.sep).join("/"))
157
+ .filter((p) => !isLaneOutput(p));
158
+ if (paths.length === 0) {
159
+ return { required: true, reason: `no change to reason about — ${tierName} runs`, obliging: [] };
160
+ }
161
+ // The harness judging itself is always broad impact — the same rule the
162
+ // fast-lane filter uses, so the two cannot disagree about the same edit.
163
+ const core = paths.find((p) => coreBroadImpactReason(p));
164
+ if (core) {
165
+ return { required: true, reason: `broad-impact change — ${coreBroadImpactReason(core)} (${core})`, obliging: [core] };
166
+ }
167
+ const declared = (irrelevantRoots ?? []).filter((r) => typeof r === "string" && r.length > 0);
168
+ if (declared.length === 0) {
169
+ return { required: true, reason: `nothing is declared unable to affect ${tierName} — it runs`, obliging: paths };
170
+ }
171
+ const isIrrelevant = (p) =>
172
+ declared.some((r) => (r.startsWith("*.") ? p.endsWith(r.slice(1)) : p === r.replace(/\/$/, "") || p.startsWith(r.endsWith("/") ? r : `${r}/`)));
173
+ const obliging = paths.filter((p) => !isIrrelevant(p));
174
+ return obliging.length > 0
175
+ ? {
176
+ required: true,
177
+ reason: `${obliging.length} changed path(s) are not declared irrelevant to ${tierName}: ${obliging.slice(0, 3).join(", ")}${obliging.length > 3 ? ", …" : ""}`,
178
+ obliging,
179
+ }
180
+ : { required: false, reason: `every changed path is declared unable to affect ${tierName} (${declared.join(", ")})`, obliging: [] };
126
181
  }
127
182
 
128
183
  function defaultRunGit(args, root) {