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.
- 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 +10 -11
- 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 +271 -37
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- 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 +34 -2
- 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 +434 -53
- 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 +175 -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} +229 -27
- 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/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +80 -24
- 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 +175 -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 +3 -0
- package/template/qa/approve.mjs +10 -11
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +271 -37
- 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/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 +34 -2
- 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 +434 -53
- 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 +175 -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} +229 -27
- 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/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +80 -24
- 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
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
206
|
-
* own citations rather than guessed from a package convention — an
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
/**
|
|
304
|
-
|
|
305
|
-
|
|
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:
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
465
|
-
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
-
|
|
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
|
|
54
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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) {
|