create-cmp-cli 0.11.0 → 0.12.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/README.md +11 -9
- package/package.json +1 -1
- package/src/lib/package-name.mjs +72 -0
- package/src/scaffold.mjs +7 -2
- package/template/.claude/settings.json +30 -0
- package/template/CLAUDE.md +48 -6
- package/template/composeApp/build.gradle.kts +44 -0
- package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
- package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
- package/template/docs/ARCHITECTURE.md +41 -2
- package/template/docs/TESTING.md +165 -0
- package/template/gradle/libs.versions.toml +15 -0
- package/template/manifest.json +1 -0
- package/template/qa/evidence/schema.json +20 -2
- package/template/qa/lib/affected-tests.mjs +147 -0
- package/template/qa/lib/device-lease.mjs +249 -0
- package/template/qa/lib/evidence-level.mjs +117 -0
- package/template/qa/lib/spec-coverage.mjs +54 -3
- package/template/qa/lib/step-cache.mjs +221 -0
- package/template/qa/receipt-check.mjs +22 -2
- package/template/qa/scaffold-feature.mjs +20 -1
- package/template/qa/verify.mjs +637 -56
- package/template/qa/watch.mjs +622 -0
- package/template/specs/app-base.spec.md +11 -0
package/template/manifest.json
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
"required": ["schema", "profile", "verdict", "commit", "steps", "artifacts", "toolVersions", "generatedAt"],
|
|
7
7
|
"properties": {
|
|
8
8
|
"schema": { "const": "cmp-evidence/1" },
|
|
9
|
-
"profile": { "enum": ["scaffold", "local", "ci"] },
|
|
9
|
+
"profile": { "enum": ["scaffold", "local", "ci", "release"] },
|
|
10
|
+
"mode": {
|
|
11
|
+
"enum": ["full", "fast"],
|
|
12
|
+
"description": "How the lane was run. \"full\" is the done-gate. \"fast\" (verify --fast) excluded the device/release tier (releaseBuild, tokenDrift, e2eSmoke, androidChecks, releaseSmoke): an inner-loop signal whose receipt derives no evidence rung and is REFUSED by qa/receipt-check.mjs — it can never satisfy done. Absent on receipts predating the flag — treated as full."
|
|
13
|
+
},
|
|
10
14
|
"verdict": { "enum": ["PASS", "FAIL"] },
|
|
11
15
|
"commit": {
|
|
12
16
|
"type": "object",
|
|
@@ -32,13 +36,27 @@
|
|
|
32
36
|
"required": ["name", "verdict", "durationMs"],
|
|
33
37
|
"properties": {
|
|
34
38
|
"name": { "type": "string" },
|
|
35
|
-
"verdict": {
|
|
39
|
+
"verdict": {
|
|
40
|
+
"enum": ["PASS", "FAIL", "SKIP", "CACHED"],
|
|
41
|
+
"description": "CACHED appears ONLY on mode:\"fast\" receipts: a pure-Node step's last PASS reused because its content-hashed input set is unchanged (qa/lib/step-cache.mjs). Counts as PASS for the lane verdict but stays visibly distinct — the full lane never consults the cache, so a full receipt never carries it."
|
|
42
|
+
},
|
|
36
43
|
"reason": { "type": "string" },
|
|
44
|
+
"note": { "type": "string", "description": "Honest fine print on a non-FAIL step (fast mode): which unit-test filter ran, or when a CACHED verdict was originally earned." },
|
|
37
45
|
"durationMs": { "type": "number" },
|
|
38
46
|
"details": { "type": "object" }
|
|
39
47
|
}
|
|
40
48
|
}
|
|
41
49
|
},
|
|
50
|
+
"evidenceLevel": {
|
|
51
|
+
"type": ["object", "null"],
|
|
52
|
+
"description": "The evidence ladder rung (qa/lib/evidence-level.mjs), DERIVED from which steps actually ran and PASSed — never declared. L0 scaffold / L1 desktop / L2 device / L3 release; a SKIPped step never upgrades a rung. null when the lane FAILed (a failed lane has no rung). Absent on receipts predating the ladder.",
|
|
53
|
+
"required": ["rung", "name", "satisfiedBy"],
|
|
54
|
+
"properties": {
|
|
55
|
+
"rung": { "enum": ["L0", "L1", "L2", "L3"] },
|
|
56
|
+
"name": { "enum": ["scaffold", "desktop", "device", "release"] },
|
|
57
|
+
"satisfiedBy": { "type": "array", "items": { "type": "string" }, "description": "The PASSed step names the rung counts as its evidence, in lane order" }
|
|
58
|
+
}
|
|
59
|
+
},
|
|
42
60
|
"artifacts": {
|
|
43
61
|
"type": "array",
|
|
44
62
|
"items": {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// affected-tests.mjs — FAST-MODE-ONLY scoping of the unit-test suite to the
|
|
2
|
+
// tests plausibly affected by the working-tree change.
|
|
3
|
+
//
|
|
4
|
+
// The full lane always runs the whole suite; this module exists so the inner
|
|
5
|
+
// loop (`verify --fast`) doesn't pay for every test on a one-file edit. Its
|
|
6
|
+
// honesty contract:
|
|
7
|
+
//
|
|
8
|
+
// - FALSE NEGATIVES ARE ACCEPTABLE HERE — AND ONLY HERE. A filtered fast
|
|
9
|
+
// run can miss a cross-feature regression; that is tolerable purely
|
|
10
|
+
// because the full, unfiltered suite runs at the checkpoint (the full
|
|
11
|
+
// lane), where done is actually decided. No other gate gets this license.
|
|
12
|
+
// - FAIL OPEN, NEVER FAIL SILENT. No git, a failed git command, an unmapped
|
|
13
|
+
// change, a broad-impact change — every uncertain case runs EVERYTHING,
|
|
14
|
+
// and the caller reports which case it was in the step's output and the
|
|
15
|
+
// receipt, so a filtered run can never be mistaken for the full suite.
|
|
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.
|
|
22
|
+
//
|
|
23
|
+
// Pure functions over path lists — git access is injected/separate so the
|
|
24
|
+
// engine suite can test every branch with no repo state.
|
|
25
|
+
|
|
26
|
+
import { execSync } from "node:child_process";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lane OUTPUTS, excluded from the changed-set before any classification.
|
|
31
|
+
* The receipt (qa/evidence/) and hashed artifacts (qa-artifacts/) change on
|
|
32
|
+
* every lane run by design; counting them as "changes" would make the qa/**
|
|
33
|
+
* escape hatch self-triggering forever — run N's receipt forcing run N+1 to
|
|
34
|
+
* the full suite, permanently. They cannot affect a test outcome (the same
|
|
35
|
+
* principle as inputs-hash.mjs's EXCLUDED_PREFIXES: lane outputs are not
|
|
36
|
+
* verdict inputs).
|
|
37
|
+
*/
|
|
38
|
+
export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts"];
|
|
39
|
+
|
|
40
|
+
function isLaneOutput(p) {
|
|
41
|
+
return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The mandatory blast-radius escape hatch: paths whose change fans out too
|
|
46
|
+
* widely to subset the suite safely. Returns the human-readable category when
|
|
47
|
+
* `p` is broad-impact, else null. Checked in order; the first match names the
|
|
48
|
+
* reason.
|
|
49
|
+
* @param {string} p POSIX relpath from the project root
|
|
50
|
+
* @returns {string|null}
|
|
51
|
+
*/
|
|
52
|
+
export function broadImpactReason(p) {
|
|
53
|
+
if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
|
|
54
|
+
return "build files rewire compilation";
|
|
55
|
+
}
|
|
56
|
+
if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
|
|
57
|
+
if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
|
|
58
|
+
if (p.includes("presentation/components/")) return "shared components render into every screen";
|
|
59
|
+
if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
|
|
60
|
+
if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Derive the fast-mode unit-test filter from a list of changed paths.
|
|
66
|
+
*
|
|
67
|
+
* Mapping (deliberately simple and defensible): each changed `.kt` file under
|
|
68
|
+
* composeApp/src contributes its package's last segment — the parent
|
|
69
|
+
* directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
|
|
70
|
+
* template's package-mirrors-path conformance makes a package segment) — and
|
|
71
|
+
* the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
|
|
72
|
+
* class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
|
|
73
|
+
* the feature, which over-selects a little and under-maintains nothing.
|
|
74
|
+
*
|
|
75
|
+
* @param {string[]} changedPaths relpaths (either separator style) — tracked
|
|
76
|
+
* diffs plus untracked files, as from changedWorkingTreePaths()
|
|
77
|
+
* @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
|
|
78
|
+
* {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
|
|
79
|
+
* mode "all" ALWAYS carries the honest reason to report.
|
|
80
|
+
*/
|
|
81
|
+
export function deriveAffectedFilter(changedPaths) {
|
|
82
|
+
const paths = [...new Set((changedPaths ?? [])
|
|
83
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
84
|
+
.map((p) => p.split(path.sep).join("/")))]
|
|
85
|
+
.filter((p) => !isLaneOutput(p))
|
|
86
|
+
.sort();
|
|
87
|
+
|
|
88
|
+
if (paths.length === 0) {
|
|
89
|
+
return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const p of paths) {
|
|
93
|
+
const broad = broadImpactReason(p);
|
|
94
|
+
if (broad) {
|
|
95
|
+
return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Every remaining path is a scoped file under composeApp/src. Only .kt
|
|
100
|
+
// files map to test patterns; a change that maps to nothing (resources,
|
|
101
|
+
// manifests) falls open to the full suite below.
|
|
102
|
+
const ktPaths = paths.filter((p) => p.endsWith(".kt"));
|
|
103
|
+
const segments = new Set();
|
|
104
|
+
for (const p of ktPaths) {
|
|
105
|
+
const seg = path.posix.basename(path.posix.dirname(p));
|
|
106
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (segments.size === 0) {
|
|
110
|
+
return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
mode: "filtered",
|
|
115
|
+
patterns: [...segments].sort().map((s) => `*${s}*`),
|
|
116
|
+
sourcePaths: ktPaths,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function defaultRunGit(args, root) {
|
|
121
|
+
try {
|
|
122
|
+
return execSync(`git ${args}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The working-tree change: tracked files differing from HEAD (staged or not)
|
|
130
|
+
* plus untracked-but-not-ignored files — the same "what will this commit
|
|
131
|
+
* touch" surface inputs-hash.mjs hashes.
|
|
132
|
+
*
|
|
133
|
+
* Returns null when git is unavailable or either command fails — the caller
|
|
134
|
+
* MUST treat null as "run everything" (fail open) and say so (never fail
|
|
135
|
+
* silent).
|
|
136
|
+
*
|
|
137
|
+
* @param {string} root project root
|
|
138
|
+
* @param {(args: string, root: string) => string|null} [runGit] injectable for tests
|
|
139
|
+
* @returns {string[]|null}
|
|
140
|
+
*/
|
|
141
|
+
export function changedWorkingTreePaths(root, runGit = defaultRunGit) {
|
|
142
|
+
const diff = runGit("diff --name-only HEAD", root);
|
|
143
|
+
const untracked = runGit("ls-files --others --exclude-standard", root);
|
|
144
|
+
if (diff === null || untracked === null) return null;
|
|
145
|
+
const lines = (out) => out.replace(/\n+$/, "").split("\n").filter(Boolean);
|
|
146
|
+
return [...new Set([...lines(diff), ...lines(untracked)])];
|
|
147
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// The machine-global device lease — mutual exclusion for the ONE Android
|
|
2
|
+
// device/emulator a machine typically has.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS: the lane marker (composeApp/build/.cmp-lane-in-progress) is
|
|
5
|
+
// per-PROJECT, but the device is machine-GLOBAL. A scratch app stamped in /tmp
|
|
6
|
+
// and the real app are different roots sharing one emulator: each stamps its
|
|
7
|
+
// own marker, neither sees the other, and two concurrent device drivers produce
|
|
8
|
+
// exactly the observed failure class — wedged adbd, `device offline` while
|
|
9
|
+
// `adb devices` looks fine, crossed app state between sessions, false-red runs.
|
|
10
|
+
// The lease is keyed by the DEVICE (its adb serial), not the project, so the
|
|
11
|
+
// primitive finally matches the scarce resource it protects.
|
|
12
|
+
//
|
|
13
|
+
// ── ON-DISK CONTRACT ────────────────────────────────────────────────────────
|
|
14
|
+
// This exact contract is implemented independently by the create-cmp
|
|
15
|
+
// inspector MCP (inspector/mcp/src/lib/device-lease.mjs in the create-cmp
|
|
16
|
+
// repo — a check-only reader for connect_live / navigate_and_inspect). The two
|
|
17
|
+
// codebases ship separately and cannot import each other, so the contract
|
|
18
|
+
// lives verbatim in BOTH file headers, each pointing at the other. Changing
|
|
19
|
+
// anything below means changing it there too.
|
|
20
|
+
//
|
|
21
|
+
// Location <os.tmpdir()>/create-cmp/device-leases/<sanitized-serial>.json
|
|
22
|
+
// tmpdir on purpose: a lease must never survive a reboot.
|
|
23
|
+
// Sanitizing serial chars outside [A-Za-z0-9._-] become "_"
|
|
24
|
+
// ("emulator-5554" → emulator-5554.json,
|
|
25
|
+
// "192.168.1.5:5555" → 192.168.1.5_5555.json).
|
|
26
|
+
// Shape { "pid": number, "holder": string, "root": string,
|
|
27
|
+
// "serial": string, "acquiredAt": ISO-8601 string }
|
|
28
|
+
// `holder` is a human/agent-readable label naming WHO is driving
|
|
29
|
+
// ("verify lane e2eSmoke", "connect_live", "fleet-check scratch
|
|
30
|
+
// lane"); `root` is the holder's project root.
|
|
31
|
+
// Staleness a lease is DEAD (silently reclaimable) when EITHER
|
|
32
|
+
// - its pid is not alive — process.kill(pid, 0) throws ESRCH.
|
|
33
|
+
// EPERM means the process EXISTS under another user: ALIVE.
|
|
34
|
+
// - OR acquiredAt is older than MAX_LEASE_AGE_MS.
|
|
35
|
+
// An unparseable lease file (torn write from a crashed holder)
|
|
36
|
+
// counts as dead. Readers treat dead as free; only acquirers
|
|
37
|
+
// delete/overwrite.
|
|
38
|
+
// Writes atomic — temp file in the same directory + rename. After the
|
|
39
|
+
// rename the acquirer re-reads and confirms its OWN pid is in
|
|
40
|
+
// the file: two simultaneous renames resolve last-writer-wins,
|
|
41
|
+
// and the loser reports contention instead of believing it holds
|
|
42
|
+
// the device.
|
|
43
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Dependency-free Node. Every function takes an optional { dir } override so
|
|
46
|
+
// tests exercise real files in a temp dir without touching the machine's
|
|
47
|
+
// actual leases, and an optional { killImpl } so the EPERM-means-alive branch
|
|
48
|
+
// is testable without a foreign-user process.
|
|
49
|
+
|
|
50
|
+
import crypto from "node:crypto";
|
|
51
|
+
import fs from "node:fs";
|
|
52
|
+
import os from "node:os";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
|
|
55
|
+
// 30 minutes: the longest legitimate single holder is a full release-profile
|
|
56
|
+
// device phase on a cold emulator (installDebug + Maestro smoke +
|
|
57
|
+
// connectedDebugAndroidTest + installRelease + release smoke), observed in the
|
|
58
|
+
// low tens of minutes — a live holder is protected by the pid check anyway, so
|
|
59
|
+
// this cap only decides how long a crashed holder whose pid was RECYCLED by an
|
|
60
|
+
// unrelated long-lived process can wedge the device. 30 min bounds that to
|
|
61
|
+
// roughly one lane-length: long enough never to reclaim under a healthy run,
|
|
62
|
+
// short enough that the machine heals itself within the hour.
|
|
63
|
+
export const MAX_LEASE_AGE_MS = 30 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
/** Serial → safe file stem: anything outside [A-Za-z0-9._-] becomes "_". */
|
|
66
|
+
export function sanitizeSerial(serial) {
|
|
67
|
+
return String(serial).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The machine-global lease directory (override with { dir } in tests only). */
|
|
71
|
+
export function leaseDir(dir) {
|
|
72
|
+
return dir || path.join(os.tmpdir(), "create-cmp", "device-leases");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Absolute path of the lease file for one serial. */
|
|
76
|
+
export function leasePath(serial, { dir } = {}) {
|
|
77
|
+
return path.join(leaseDir(dir), `${sanitizeSerial(serial)}.json`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Is a pid alive? ESRCH = no such process → dead. EPERM = the process exists
|
|
82
|
+
* but belongs to another user → ALIVE (killing rights are not liveness).
|
|
83
|
+
* Any other error is treated as alive — when in doubt, never steal a lease.
|
|
84
|
+
*/
|
|
85
|
+
export function pidAlive(pid, { killImpl = process.kill.bind(process) } = {}) {
|
|
86
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
87
|
+
try {
|
|
88
|
+
killImpl(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return !(err && err.code === "ESRCH");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readLeaseFile(file) {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
98
|
+
} catch {
|
|
99
|
+
return null; // missing OR unparseable — both mean "no live lease here"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describeLease(lease, nowMs) {
|
|
104
|
+
const acquiredMs = Date.parse(lease.acquiredAt);
|
|
105
|
+
return {
|
|
106
|
+
holder: lease.holder ?? "unknown",
|
|
107
|
+
pid: lease.pid ?? null,
|
|
108
|
+
root: lease.root ?? null,
|
|
109
|
+
acquiredAt: lease.acquiredAt ?? null,
|
|
110
|
+
ageMs: Number.isFinite(acquiredMs) ? Math.max(0, nowMs - acquiredMs) : Number.POSITIVE_INFINITY,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function leaseDead(lease, nowMs, { killImpl } = {}) {
|
|
115
|
+
if (!pidAlive(lease.pid, { ...(killImpl ? { killImpl } : {}) })) return true;
|
|
116
|
+
const acquiredMs = Date.parse(lease.acquiredAt);
|
|
117
|
+
if (!Number.isFinite(acquiredMs)) return true; // no readable birth time — unverifiable, dead
|
|
118
|
+
return nowMs - acquiredMs >= MAX_LEASE_AGE_MS;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `"verify lane e2eSmoke" (pid 4711, /tmp/scratch-x, 2m ago)` — for reasons/errors. */
|
|
122
|
+
export function formatHolder(heldBy) {
|
|
123
|
+
if (!heldBy) return "an unknown holder";
|
|
124
|
+
const age =
|
|
125
|
+
!Number.isFinite(heldBy.ageMs) ? "age unknown"
|
|
126
|
+
: heldBy.ageMs < 60_000 ? `${Math.max(1, Math.round(heldBy.ageMs / 1000))}s ago`
|
|
127
|
+
: `${Math.round(heldBy.ageMs / 60_000)}m ago`;
|
|
128
|
+
return `"${heldBy.holder}" (pid ${heldBy.pid ?? "?"}, ${heldBy.root ?? "unknown root"}, ${age})`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Read the live lease on a serial, if any. Dead/stale leases read as null
|
|
133
|
+
* (free) — reading never deletes; reclaim-by-overwrite is the acquirer's job.
|
|
134
|
+
*
|
|
135
|
+
* @returns {{holder,pid,root,acquiredAt,ageMs}|null}
|
|
136
|
+
*/
|
|
137
|
+
export function readDeviceLease(serial, { dir, killImpl, now = Date.now } = {}) {
|
|
138
|
+
const lease = readLeaseFile(leasePath(serial, { dir }));
|
|
139
|
+
if (!lease) return null;
|
|
140
|
+
const nowMs = now();
|
|
141
|
+
if (leaseDead(lease, nowMs, { killImpl })) return null;
|
|
142
|
+
return describeLease(lease, nowMs);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Acquire the machine-global lease on one device serial.
|
|
147
|
+
*
|
|
148
|
+
* @param {{serial: string, holder: string, root: string, dir?: string,
|
|
149
|
+
* killImpl?: Function, now?: () => number}} opts
|
|
150
|
+
* @returns {{ok: true, handle: {file, serial, pid, acquiredAt}, reclaimed: object|null}
|
|
151
|
+
* | {ok: false, heldBy: {holder, pid, root, acquiredAt, ageMs}}}
|
|
152
|
+
* `reclaimed` names the dead lease this acquire silently replaced (a crashed
|
|
153
|
+
* run must never wedge the machine forever) so the acquiring run can note it
|
|
154
|
+
* in its own output.
|
|
155
|
+
*/
|
|
156
|
+
export function acquireDeviceLease({ serial, holder, root, dir, killImpl, now = Date.now } = {}) {
|
|
157
|
+
if (!serial) throw new Error("acquireDeviceLease: serial is required");
|
|
158
|
+
const d = leaseDir(dir);
|
|
159
|
+
fs.mkdirSync(d, { recursive: true });
|
|
160
|
+
const file = leasePath(serial, { dir });
|
|
161
|
+
const nowMs = now();
|
|
162
|
+
|
|
163
|
+
let reclaimed = null;
|
|
164
|
+
const existing = readLeaseFile(file);
|
|
165
|
+
if (existing) {
|
|
166
|
+
if (!leaseDead(existing, nowMs, { killImpl })) {
|
|
167
|
+
return { ok: false, heldBy: describeLease(existing, nowMs) };
|
|
168
|
+
}
|
|
169
|
+
reclaimed = describeLease(existing, nowMs); // dead — reclaim silently
|
|
170
|
+
} else if (fs.existsSync(file)) {
|
|
171
|
+
reclaimed = { holder: "unreadable lease (torn write)", pid: null, root: null, acquiredAt: null, ageMs: Number.POSITIVE_INFINITY };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const lease = {
|
|
175
|
+
pid: process.pid,
|
|
176
|
+
holder: String(holder || "unknown"),
|
|
177
|
+
root: String(root || process.cwd()),
|
|
178
|
+
serial: String(serial),
|
|
179
|
+
acquiredAt: new Date(nowMs).toISOString(),
|
|
180
|
+
};
|
|
181
|
+
// Atomic claim: temp file + rename means no reader ever sees a half-written
|
|
182
|
+
// lease, and two simultaneous acquirers cannot interleave bytes.
|
|
183
|
+
const tmp = path.join(d, `.${sanitizeSerial(serial)}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
184
|
+
fs.writeFileSync(tmp, `${JSON.stringify(lease, null, 2)}\n`);
|
|
185
|
+
fs.renameSync(tmp, file);
|
|
186
|
+
|
|
187
|
+
// Last-writer-wins detection: both racers reached the rename; whoever's bytes
|
|
188
|
+
// survived owns the device. Re-read and confirm it is US — the loser reports
|
|
189
|
+
// contention instead of driving a device someone else holds.
|
|
190
|
+
const confirm = readLeaseFile(file);
|
|
191
|
+
if (!confirm || confirm.pid !== lease.pid || confirm.acquiredAt !== lease.acquiredAt || confirm.holder !== lease.holder) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
heldBy: confirm
|
|
195
|
+
? describeLease(confirm, now())
|
|
196
|
+
: { holder: "unknown (lease vanished mid-acquire)", pid: null, root: null, acquiredAt: null, ageMs: Number.POSITIVE_INFINITY },
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, handle: { file, serial: lease.serial, pid: lease.pid, acquiredAt: lease.acquiredAt }, reclaimed };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Release a held lease. Idempotent and safe: missing file is fine, and a file
|
|
204
|
+
* that no longer carries OUR pid+acquiredAt belongs to a newer holder (they
|
|
205
|
+
* reclaimed us as stale, or won a race) — another holder's lease is NEVER
|
|
206
|
+
* deleted.
|
|
207
|
+
*/
|
|
208
|
+
export function releaseDeviceLease(handle) {
|
|
209
|
+
if (!handle || !handle.file) return;
|
|
210
|
+
const current = readLeaseFile(handle.file);
|
|
211
|
+
if (!current) return; // already gone (or unreadable — not provably ours, leave it)
|
|
212
|
+
if (current.pid !== handle.pid || current.acquiredAt !== handle.acquiredAt) return; // someone else's now
|
|
213
|
+
try {
|
|
214
|
+
fs.rmSync(handle.file, { force: true });
|
|
215
|
+
} catch {
|
|
216
|
+
/* releasing is best-effort; staleness reclaim is the backstop */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Run `fn(handle)` under the lease, releasing in a finally. Sync or async fn.
|
|
222
|
+
* A refused acquire is returned as-is ({ok:false, heldBy}) — the caller decides
|
|
223
|
+
* what contention means (the verify lane turns it into a SKIP, never a FAIL).
|
|
224
|
+
*/
|
|
225
|
+
export function withDeviceLease(opts, fn) {
|
|
226
|
+
const res = acquireDeviceLease(opts);
|
|
227
|
+
if (!res.ok) return res;
|
|
228
|
+
let out;
|
|
229
|
+
try {
|
|
230
|
+
out = fn(res.handle);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
releaseDeviceLease(res.handle);
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
if (out && typeof out.then === "function") {
|
|
236
|
+
return out.then(
|
|
237
|
+
(value) => {
|
|
238
|
+
releaseDeviceLease(res.handle);
|
|
239
|
+
return { ok: true, result: value, reclaimed: res.reclaimed };
|
|
240
|
+
},
|
|
241
|
+
(err) => {
|
|
242
|
+
releaseDeviceLease(res.handle);
|
|
243
|
+
throw err;
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
releaseDeviceLease(res.handle);
|
|
248
|
+
return { ok: true, result: out, reclaimed: res.reclaimed };
|
|
249
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The evidence ladder — the receipt's COARSE grade, derived, never declared.
|
|
2
|
+
//
|
|
3
|
+
// Receipts already grade themselves in fine print ("PASS (desktop-only)",
|
|
4
|
+
// "PASS (on-device: e2eSmoke+androidChecks)"). This module names the rungs so
|
|
5
|
+
// every surface that shows a receipt can say the same thing in one word:
|
|
6
|
+
//
|
|
7
|
+
// L0 "scaffold" — the scaffold profile's checks passed (stamp-time green
|
|
8
|
+
// build: build + unit tests + the pure-Node gates).
|
|
9
|
+
// L1 "desktop" — full static + JVM evidence: everything L0 proves PLUS
|
|
10
|
+
// conformance, golden trees, a11y, and the release COMPILE
|
|
11
|
+
// (releaseBuild) — a green lane with no on-device step run.
|
|
12
|
+
// L2 "device" — L1 plus at least one on-device EXECUTION step PASSed
|
|
13
|
+
// (e2eSmoke, androidChecks, or the live tokenDrift tier).
|
|
14
|
+
// L3 "release" — L2 plus releaseSmoke PASSed (the release APK installed
|
|
15
|
+
// and driven on a device).
|
|
16
|
+
//
|
|
17
|
+
// HONESTY RULES — the rung must be honest to a fault, it is the vocabulary
|
|
18
|
+
// evidence is sold in:
|
|
19
|
+
// - A rung is DERIVED from which steps actually ran and PASSED. It is never
|
|
20
|
+
// declared: the `profile` argument is deliberately NOT part of the
|
|
21
|
+
// derivation — a requested profile can never buy a rung its steps did not
|
|
22
|
+
// earn (it is accepted so callers state what was asked for vs. earned).
|
|
23
|
+
// - A SKIP never upgrades. A SKIPped device step does not count toward L2;
|
|
24
|
+
// a SKIPped releaseSmoke (e.g. unsigned keystore) is NOT L3. The label
|
|
25
|
+
// can never overclaim.
|
|
26
|
+
// - A FAILED lane has no rung: the rung is only computed for a PASS
|
|
27
|
+
// verdict; the receipt of a FAIL records evidenceLevel null.
|
|
28
|
+
// - A FAST-MODE lane has no rung either — not even L0. `verify --fast` is
|
|
29
|
+
// the inner loop, a signal rather than evidence, so a fast receipt must
|
|
30
|
+
// never be silently reused as if it were a full-lane result: pass the
|
|
31
|
+
// run's mode and "fast" derives null, always.
|
|
32
|
+
// - The rung is COARSE by design. The per-step list (and the existing
|
|
33
|
+
// strength string) stays the fine print alongside it — steps that may
|
|
34
|
+
// SKIP for honest configuration absence (approvals unreviewed, no
|
|
35
|
+
// exported schemas) are visible there; only the always-run steps gate
|
|
36
|
+
// the desktop rungs, and only executed PASSes gate the device rungs.
|
|
37
|
+
|
|
38
|
+
/** The scaffold profile's step set (verify.mjs stepsForProfile.scaffold). */
|
|
39
|
+
const SCAFFOLD_CORE = [
|
|
40
|
+
"specCoverage",
|
|
41
|
+
"approvals",
|
|
42
|
+
"componentStories",
|
|
43
|
+
"reachability",
|
|
44
|
+
"archDoc",
|
|
45
|
+
"schemaHistory",
|
|
46
|
+
"build",
|
|
47
|
+
"unitTests",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/** Steps every PASS must carry to claim even L0 — they run in every profile and never SKIP. */
|
|
51
|
+
const L0_REQUIRED = ["build", "unitTests"];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The steps that distinguish full desktop evidence (L1) from the scaffold
|
|
55
|
+
* checks. None of these can SKIP — they PASS or FAIL — so "PASSed" is exactly
|
|
56
|
+
* "ran green".
|
|
57
|
+
*/
|
|
58
|
+
const L1_REQUIRED = ["releaseBuild", "conformance", "goldenTrees", "a11y"];
|
|
59
|
+
|
|
60
|
+
/** On-device EXECUTION steps — the only steps that can earn L2. */
|
|
61
|
+
const DEVICE_EXECUTION = ["e2eSmoke", "tokenDrift", "androidChecks"];
|
|
62
|
+
|
|
63
|
+
/** The one step that can lift L2 to L3. */
|
|
64
|
+
const RELEASE_EXECUTION = "releaseSmoke";
|
|
65
|
+
|
|
66
|
+
const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release" };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Derive the receipt's evidence rung from the lane's step results.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
|
|
72
|
+
* as recorded on the receipt (verdict PASS | FAIL | SKIP per step)
|
|
73
|
+
* @param {string} [profile] the profile that was REQUESTED — recorded context
|
|
74
|
+
* only, never part of the derivation (see honesty rules above)
|
|
75
|
+
* @param {{mode?: string}} [opts] the run's mode ("full" | "fast"). "fast"
|
|
76
|
+
* derives null unconditionally — the inner loop earns no rung (see honesty
|
|
77
|
+
* rules above). Absent/other values mean full.
|
|
78
|
+
* @returns {{rung: "L0"|"L1"|"L2"|"L3", name: string, satisfiedBy: string[]}|null}
|
|
79
|
+
* null when any step FAILed (a failed lane has no rung), when the run was
|
|
80
|
+
* fast-mode (the inner loop is never evidence), or when even the L0 floor
|
|
81
|
+
* was not earned. `satisfiedBy` lists the PASSed steps the rung counts as
|
|
82
|
+
* its evidence, in lane order.
|
|
83
|
+
*/
|
|
84
|
+
export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
|
|
85
|
+
if (mode === "fast") return null; // the inner loop derives no rung — ever
|
|
86
|
+
const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
|
|
87
|
+
if (steps.some((s) => s.verdict === "FAIL")) return null; // a failed lane has no rung
|
|
88
|
+
const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
|
|
89
|
+
|
|
90
|
+
if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
|
|
91
|
+
|
|
92
|
+
const inLaneOrder = (names) => steps.filter((s) => names.has(s.name) && passed.has(s.name)).map((s) => s.name);
|
|
93
|
+
|
|
94
|
+
let rung = "L0";
|
|
95
|
+
const counted = new Set(SCAFFOLD_CORE);
|
|
96
|
+
|
|
97
|
+
if (L1_REQUIRED.every((name) => passed.has(name))) {
|
|
98
|
+
rung = "L1";
|
|
99
|
+
for (const name of L1_REQUIRED) counted.add(name);
|
|
100
|
+
|
|
101
|
+
// Only an EXECUTED (PASSed) device step lifts to L2 — a SKIP never does.
|
|
102
|
+
const deviceRan = DEVICE_EXECUTION.some((name) => passed.has(name));
|
|
103
|
+
if (deviceRan) {
|
|
104
|
+
rung = "L2";
|
|
105
|
+
for (const name of DEVICE_EXECUTION) counted.add(name);
|
|
106
|
+
|
|
107
|
+
// Only a PASSed releaseSmoke lifts to L3 — a SKIP (unsigned keystore,
|
|
108
|
+
// no device) never does.
|
|
109
|
+
if (passed.has(RELEASE_EXECUTION)) {
|
|
110
|
+
rung = "L3";
|
|
111
|
+
counted.add(RELEASE_EXECUTION);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { rung, name: RUNG_NAMES[rung], satisfiedBy: inLaneOrder(counted) };
|
|
117
|
+
}
|
|
@@ -51,17 +51,39 @@ export function scanSpecClauses(root) {
|
|
|
51
51
|
return clauses;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Which test tier a citing file belongs to, derived from its path. Tiers are
|
|
56
|
+
* the source-set/harness boundaries that decide what a citation can actually
|
|
57
|
+
* SEE: commonTest and desktopTest run on the JVM (blind to androidMain code),
|
|
58
|
+
* androidInstrumentedTest runs on a device, e2e flows drive the installed app.
|
|
59
|
+
* @param {string} relFile path relative to the project root
|
|
60
|
+
* @returns {"commonTest"|"desktopTest"|"androidInstrumentedTest"|"e2e"|"other"}
|
|
61
|
+
*/
|
|
62
|
+
export function tierForFile(relFile) {
|
|
63
|
+
const p = relFile.split(path.sep).join("/");
|
|
64
|
+
if (p.includes("/androidInstrumentedTest/")) return "androidInstrumentedTest";
|
|
65
|
+
if (p.includes("/commonTest/")) return "commonTest";
|
|
66
|
+
if (p.includes("/desktopTest/")) return "desktopTest";
|
|
67
|
+
if (p.startsWith("qa/e2e/")) return "e2e";
|
|
68
|
+
return "other";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Tiers whose tests run entirely on the host JVM — blind to platform source sets. */
|
|
72
|
+
export const DESKTOP_TIERS = Object.freeze(["commonTest", "desktopTest"]);
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* Every `// SPEC: ID[, ID…]` / `# SPEC: …` citation tag under composeApp/src
|
|
56
|
-
* and qa/e2e.
|
|
76
|
+
* and qa/e2e. Each entry carries the citing file's `tier` (see tierForFile).
|
|
57
77
|
* @param {string} root
|
|
58
|
-
* @returns {Array<{id: string, file: string, line: number}>}
|
|
78
|
+
* @returns {Array<{id: string, file: string, line: number, tier: string}>}
|
|
59
79
|
*/
|
|
60
80
|
export function scanCitations(root) {
|
|
61
81
|
const tags = [];
|
|
62
82
|
const searchDirs = [path.join(root, "composeApp/src"), path.join(root, "qa/e2e")];
|
|
63
83
|
const files = searchDirs.flatMap((d) => walkFiles(d, [".kt", ".kts", ".yaml", ".yml"]));
|
|
64
84
|
for (const f of files) {
|
|
85
|
+
const rel = path.relative(root, f);
|
|
86
|
+
const tier = tierForFile(rel);
|
|
65
87
|
fs.readFileSync(f, "utf8")
|
|
66
88
|
.split("\n")
|
|
67
89
|
.forEach((line, i) => {
|
|
@@ -73,8 +95,37 @@ export function scanCitations(root) {
|
|
|
73
95
|
.split(/[,\s]+/)
|
|
74
96
|
.map((s) => s.trim())
|
|
75
97
|
.filter((s) => CLAUSE_ID_RE.test(s));
|
|
76
|
-
for (const id of ids) tags.push({ id, file:
|
|
98
|
+
for (const id of ids) tags.push({ id, file: rel, line: i + 1, tier });
|
|
77
99
|
});
|
|
78
100
|
}
|
|
79
101
|
return tags;
|
|
80
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Per-clause tier visibility — report data only, never a pass/fail input
|
|
106
|
+
* (instrument before you police). For each cited clause: which tiers cite it.
|
|
107
|
+
* `desktopOnly` lists live clauses whose every citation is desktop-tier
|
|
108
|
+
* (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
|
|
109
|
+
* `summaryLine` is the one line the lane's specCoverage step (and any other
|
|
110
|
+
* consumer) can print verbatim; null when nothing is desktop-only.
|
|
111
|
+
* @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
|
|
112
|
+
* @param {Array<{id: string, tier: string}>} tags from scanCitations
|
|
113
|
+
* @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
|
|
114
|
+
*/
|
|
115
|
+
export function clauseTierCoverage(clauses, tags) {
|
|
116
|
+
const tiersByClause = {};
|
|
117
|
+
for (const t of tags) {
|
|
118
|
+
(tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
|
|
119
|
+
}
|
|
120
|
+
const desktopOnly = [...clauses.entries()]
|
|
121
|
+
.filter(([, c]) => !c.withdrawn)
|
|
122
|
+
.map(([id]) => id)
|
|
123
|
+
.filter((id) => {
|
|
124
|
+
const tiers = tiersByClause[id];
|
|
125
|
+
return tiers && tiers.every((t) => DESKTOP_TIERS.includes(t));
|
|
126
|
+
});
|
|
127
|
+
const summaryLine = desktopOnly.length
|
|
128
|
+
? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
|
|
129
|
+
: null;
|
|
130
|
+
return { tiersByClause, desktopOnly, summaryLine };
|
|
131
|
+
}
|