create-cmp-cli 0.13.0 → 0.14.1

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 (61) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1723 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +115 -1
  44. package/src/lib/harness-upgrade.mjs +193 -5
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +110 -2
  55. package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
  56. package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
  57. package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
  58. package/template/.gradle/8.11.1/gc.properties +0 -0
  59. package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  60. package/template/.gradle/buildOutputCleanup/cache.properties +0 -2
  61. package/template/.gradle/vcs-1/gc.properties +0 -0
@@ -0,0 +1,183 @@
1
+ // component-stories.mjs — the component ↔ story parity gate (the IMP-1
2
+ // screen↔registry parity idea, applied at component granularity).
3
+ //
4
+ // Every `@Composable fun` in `composeApp/src/commonMain/**/presentation/
5
+ // components/*.kt` must have a preview-registry story whose id is
6
+ // `component.<kebab-case-of-the-composable-name>` (AppHeader →
7
+ // "component.app-header"), registered in the desktopMain inspector sources
8
+ // (ComponentStories.kt, or PreviewRegistry.kt for generated conditional
9
+ // components like PlaceholderScreen). The gate fails BOTH directions, like
10
+ // specCoverage: a component with no story (the render pipeline is blind to
11
+ // it) and a story id with no component (a stale story surviving a rename).
12
+ //
13
+ // Detection is a pragmatic source scan, not a Kotlin front-end — the same
14
+ // stance as the console's components scan (inspector/mcp/src/lib/
15
+ // components.mjs, whose @Composable-window heuristic and kebab derivation
16
+ // this file mirrors; keep the two in sync).
17
+
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ /** PascalCase/camelCase → kebab-case: AppHeader → app-header, ListItemCard → list-item-card. */
22
+ export function kebabCase(name) {
23
+ return String(name)
24
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
25
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
26
+ .toLowerCase();
27
+ }
28
+
29
+ /** The registry story id a component of this name must register. */
30
+ export function componentStoryId(name) {
31
+ return `component.${kebabCase(name)}`;
32
+ }
33
+
34
+ // How far past an `@Composable` occurrence to look for the `fun Name(` it
35
+ // governs — mirrors the console scan's FUN_SEARCH_WINDOW.
36
+ const FUN_SEARCH_WINDOW = 500;
37
+
38
+ /**
39
+ * Every `@Composable fun Name(` declaration name in one file's text —
40
+ * includes private/internal composables (the console's Components page lists
41
+ * them, so the parity gate covers them too).
42
+ * @param {string} text
43
+ * @returns {string[]}
44
+ */
45
+ export function findComposableNames(text) {
46
+ const names = [];
47
+ const composableRe = /@Composable\b/g;
48
+ let m;
49
+ while ((m = composableRe.exec(text))) {
50
+ const window = text.slice(m.index, m.index + FUN_SEARCH_WINDOW);
51
+ const funMatch = window.match(/fun\s+(?:<[^>]*>\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(?:<[^>]*>)?\s*\(/);
52
+ if (funMatch) names.push(funMatch[1]);
53
+ }
54
+ return names;
55
+ }
56
+
57
+ function walkDirs(root, wanted) {
58
+ const out = [];
59
+ (function walk(dir) {
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir, { withFileTypes: true });
63
+ } catch {
64
+ return;
65
+ }
66
+ for (const e of entries) {
67
+ if (!e.isDirectory()) continue;
68
+ const p = path.join(dir, e.name);
69
+ if (e.name === wanted) out.push(p);
70
+ else walk(p);
71
+ }
72
+ })(root);
73
+ return out;
74
+ }
75
+
76
+ function ktFilesIn(dir) {
77
+ let entries;
78
+ try {
79
+ entries = fs.readdirSync(dir, { withFileTypes: true });
80
+ } catch {
81
+ return [];
82
+ }
83
+ return entries
84
+ .filter((e) => e.isFile() && e.name.endsWith(".kt"))
85
+ .map((e) => path.join(dir, e.name));
86
+ }
87
+
88
+ function walkKtFilesDeep(dir) {
89
+ const out = [];
90
+ let entries;
91
+ try {
92
+ entries = fs.readdirSync(dir, { withFileTypes: true });
93
+ } catch {
94
+ return out;
95
+ }
96
+ for (const e of entries) {
97
+ const p = path.join(dir, e.name);
98
+ if (e.isDirectory()) out.push(...walkKtFilesDeep(p));
99
+ else if (e.name.endsWith(".kt")) out.push(p);
100
+ }
101
+ return out;
102
+ }
103
+
104
+ const STORY_ID_RE = /"component\.([a-z0-9][a-z0-9.-]*)"/g;
105
+
106
+ /**
107
+ * Evaluate component ↔ story parity for a project root.
108
+ * SKIPs (never fails) when the surface doesn't exist: no components dir, or
109
+ * no desktopMain inspector dir (the `--no-inspector` scaffold has no preview
110
+ * registry to hold stories).
111
+ * @param {string} root project root (contains composeApp/)
112
+ * @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason?: string, details?: object}}
113
+ */
114
+ export function evaluateComponentStoryParity(root) {
115
+ const commonRoot = path.join(root, "composeApp", "src", "commonMain", "kotlin");
116
+ const desktopRoot = path.join(root, "composeApp", "src", "desktopMain", "kotlin");
117
+
118
+ const componentsDirs = walkDirs(commonRoot, "presentation")
119
+ .map((p) => path.join(p, "components"))
120
+ .filter((p) => fs.existsSync(p));
121
+ if (componentsDirs.length === 0) {
122
+ return { verdict: "SKIP", reason: "no presentation/components directory under commonMain — nothing to check" };
123
+ }
124
+
125
+ const inspectorDirs = walkDirs(desktopRoot, "inspector");
126
+ if (inspectorDirs.length === 0) {
127
+ return {
128
+ verdict: "SKIP",
129
+ reason: "no desktopMain inspector sources (preview harness not included) — no story registry to check against",
130
+ };
131
+ }
132
+
133
+ // Components: every @Composable in the registry dir (direct children only —
134
+ // the same surface the console's Components page scans).
135
+ const components = []; // { name, file }
136
+ for (const dir of componentsDirs) {
137
+ for (const file of ktFilesIn(dir)) {
138
+ const rel = path.relative(root, file).split(path.sep).join("/");
139
+ for (const name of findComposableNames(fs.readFileSync(file, "utf8"))) {
140
+ components.push({ name, file: rel });
141
+ }
142
+ }
143
+ }
144
+
145
+ // Stories: every quoted "component.<kebab>" id in the inspector sources.
146
+ const storyIds = new Set();
147
+ for (const dir of inspectorDirs) {
148
+ for (const file of walkKtFilesDeep(dir)) {
149
+ const text = fs.readFileSync(file, "utf8");
150
+ for (const match of text.matchAll(STORY_ID_RE)) {
151
+ storyIds.add(`component.${match[1]}`);
152
+ }
153
+ }
154
+ }
155
+
156
+ const expectedIds = new Map(components.map((c) => [componentStoryId(c.name), c]));
157
+ const missing = [...expectedIds.entries()].filter(([id]) => !storyIds.has(id));
158
+ const orphans = [...storyIds].filter((id) => !expectedIds.has(id)).sort();
159
+
160
+ const details = {
161
+ components: components.length,
162
+ stories: storyIds.size,
163
+ missing: missing.map(([id]) => id),
164
+ orphans,
165
+ };
166
+
167
+ if (missing.length === 0 && orphans.length === 0) {
168
+ return { verdict: "PASS", details };
169
+ }
170
+
171
+ const lines = ["Component ↔ story parity broken — the components registry and its preview stories have drifted apart:"];
172
+ for (const [id, c] of missing) {
173
+ lines.push(
174
+ ` [${c.name}] ${c.file} — no component story registered. Add ScreenPreview("${id}", …) to composeApp/src/desktopMain/**/inspector/ComponentStories.kt (kebab-case of the composable name).`,
175
+ );
176
+ }
177
+ for (const id of orphans) {
178
+ lines.push(
179
+ ` ["${id}"] story id has no matching @Composable in presentation/components — remove the stale story or fix the id.`,
180
+ );
181
+ }
182
+ return { verdict: "FAIL", reason: lines.join("\n"), details };
183
+ }
@@ -0,0 +1,179 @@
1
+ // determinism.mjs — the comparison half of the lane's determinism probe.
2
+ //
3
+ // ARCH-13 statically bans ambient time reads (Clock.System, LocalDate.now,
4
+ // TimeZone.currentSystemDefault) in APP code — but a library the app calls
5
+ // can still read the wall clock, and a golden test can still depend on the
6
+ // machine's timezone through a seam the static net cannot see. This project
7
+ // family has already been bitten: a golden tree green at 23:00 and red by
8
+ // morning, because a ViewModel was constructed without its injected clock.
9
+ //
10
+ // The probe (verify.mjs stepDeterminism) runs the JVM test tier TWICE under
11
+ // maximally-shifted timezones and fails iff the two runs' OUTCOMES differ.
12
+ // This module owns the two judgments that make that comparison honest:
13
+ //
14
+ // - WHAT COUNTS AS AN OUTCOME: a test's verdict (pass/fail/error/skip)
15
+ // and its failure output — never its duration. Durations are not parsed
16
+ // at all, so a timing wobble is structurally incapable of tripping the
17
+ // probe (the brief-level rule "duration is not a difference" is enforced
18
+ // by construction, not by filtering).
19
+ //
20
+ // - WHAT THE FAILURE MESSAGE MUST SAY: which test, which lane step owns
21
+ // it, and the observable difference between the two runs — never a bare
22
+ // "nondeterministic". A probe whose red is unactionable just teaches
23
+ // people to turn it off.
24
+
25
+ import fs from "node:fs";
26
+ import path from "node:path";
27
+
28
+ /**
29
+ * The two probe timezones — chosen so the two legs NEVER share a calendar
30
+ * date, at any instant:
31
+ *
32
+ * Etc/GMT+12 = UTC-12 (POSIX sign convention: Etc/GMT+N means UTC-N)
33
+ * Etc/GMT-14 = UTC+14 (the highest real-world offset, Line Islands)
34
+ *
35
+ * The offsets are 26 hours apart — more than a full day — so the two legs'
36
+ * local dates differ at EVERY moment of every day, and any date-derived
37
+ * value (a "today" default, a day-boundary bucket, a formatted date in a
38
+ * golden tree) is guaranteed to differ between the legs. A UTC-vs-UTC+14
39
+ * pair would NOT have this property: those legs share a date for ten hours
40
+ * of every day, so the probe's power would depend on what time you ran it —
41
+ * the exact class of flakiness it exists to hunt.
42
+ */
43
+ export const DETERMINISM_TIMEZONES = [
44
+ { tz: "Etc/GMT+12", label: "UTC-12" },
45
+ { tz: "Etc/GMT-14", label: "UTC+14" },
46
+ ];
47
+
48
+ const XML_ENTITIES = { "&lt;": "<", "&gt;": ">", "&quot;": '"', "&apos;": "'", "&amp;": "&" };
49
+
50
+ function unescapeXml(s) {
51
+ return s
52
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
53
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
54
+ .replace(/&(lt|gt|quot|apos|amp);/g, (m) => XML_ENTITIES[m]);
55
+ }
56
+
57
+ function attr(attrs, name) {
58
+ const m = attrs.match(new RegExp(`${name}="([^"]*)"`));
59
+ return m ? unescapeXml(m[1]) : null;
60
+ }
61
+
62
+ /**
63
+ * Parse one Gradle JUnit results directory into per-test outcomes.
64
+ * DELIBERATELY parses only verdict-bearing content: testcase identity,
65
+ * status, and failure/error text. `time="…"` attributes are never read, so
66
+ * two runs that differ only in duration produce identical outcome maps.
67
+ *
68
+ * @param {string} dir a test-results directory (TEST-*.xml files, flat)
69
+ * @returns {Record<string, {status: "pass"|"fail"|"error"|"skip", messages: string[]}>}
70
+ * keyed by `classname.name`; empty object when the directory is absent
71
+ * (the caller decides what an empty leg means — this parser never guesses)
72
+ */
73
+ export function parseJUnitOutcomes(dir) {
74
+ const outcomes = {};
75
+ if (!fs.existsSync(dir)) return outcomes;
76
+ for (const entry of fs.readdirSync(dir)) {
77
+ if (!entry.startsWith("TEST-") || !entry.endsWith(".xml")) continue;
78
+ const xml = fs.readFileSync(path.join(dir, entry), "utf8");
79
+ const caseRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
80
+ for (const m of xml.matchAll(caseRe)) {
81
+ const attrs = m[1];
82
+ const body = m[2] ?? "";
83
+ const classname = attr(attrs, "classname") ?? "";
84
+ const name = attr(attrs, "name") ?? "";
85
+ if (!classname && !name) continue;
86
+ let status = "pass";
87
+ const messages = [];
88
+ const childRe = /<(failure|error)\b([^>]*?)(?:\/>|>([\s\S]*?)<\/\1>)/g;
89
+ for (const c of body.matchAll(childRe)) {
90
+ status = c[1] === "error" ? "error" : "fail";
91
+ const message = attr(c[2], "message");
92
+ const text = c[3] ? unescapeXml(c[3]).trim() : "";
93
+ messages.push(message ?? text.split("\n")[0] ?? "");
94
+ }
95
+ if (status === "pass" && /<skipped\b/.test(body)) status = "skip";
96
+ outcomes[`${classname}.${name}`] = { status, messages };
97
+ }
98
+ }
99
+ return outcomes;
100
+ }
101
+
102
+ /**
103
+ * Which lane step owns a test class — so the probe's failure message names
104
+ * the step a reader would re-run, not just a class name. The patterns are
105
+ * the same filters the lane's own gradleTestStep calls use.
106
+ * @param {string} classname fully-qualified test class
107
+ * @returns {"goldenTrees"|"conformance"|"a11y"|"unitTests"}
108
+ */
109
+ export function laneStepForTestClass(classname) {
110
+ if (/GoldenTreeTest$/.test(classname)) return "goldenTrees";
111
+ if (/ArchitectureConformanceTest$/.test(classname)) return "conformance";
112
+ if (/A11yConformanceTest$/.test(classname)) return "a11y";
113
+ return "unitTests";
114
+ }
115
+
116
+ function classnameOf(testId) {
117
+ // testId is `classname.name`; the class is everything before the last dot
118
+ // segment that starts the (possibly backticked, space-bearing) test name.
119
+ // Kotlin test names contain dots rarely but spaces often — the classname
120
+ // never contains a space, so split at the first segment containing one,
121
+ // falling back to the last dot.
122
+ const spaceIdx = testId.indexOf(" ");
123
+ const scope = spaceIdx === -1 ? testId : testId.slice(0, spaceIdx);
124
+ const lastDot = scope.lastIndexOf(".");
125
+ return lastDot === -1 ? testId : testId.slice(0, lastDot);
126
+ }
127
+
128
+ /**
129
+ * Compare two legs' outcomes. Returns one entry per observable difference,
130
+ * each carrying everything the failure message must name: the test, the
131
+ * owning lane step, and what differed between the legs.
132
+ *
133
+ * Kinds:
134
+ * verdict-flip different status (pass/fail/error/skip)
135
+ * only-in-one-leg the test executed in one leg only
136
+ * failure-text-changed failed in BOTH legs, but with different output —
137
+ * a date-dependent assertion message is still a
138
+ * timezone leak even when both legs are red
139
+ *
140
+ * @param {Record<string, {status: string, messages: string[]}>} a leg A outcomes
141
+ * @param {Record<string, {status: string, messages: string[]}>} b leg B outcomes
142
+ * @param {string} labelA human label for leg A (e.g. "TZ=Etc/GMT+12 (UTC-12)")
143
+ * @param {string} labelB human label for leg B
144
+ * @returns {Array<{test: string, step: string, kind: string, detail: string}>}
145
+ */
146
+ export function compareOutcomes(a, b, labelA, labelB) {
147
+ const diffs = [];
148
+ const ids = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
149
+ for (const id of ids) {
150
+ const step = laneStepForTestClass(classnameOf(id));
151
+ const inA = a[id];
152
+ const inB = b[id];
153
+ if (!inA || !inB) {
154
+ const where = inA ? labelA : labelB;
155
+ const missing = inA ? labelB : labelA;
156
+ diffs.push({ test: id, step, kind: "only-in-one-leg", detail: `executed under ${where} but produced no result under ${missing}` });
157
+ continue;
158
+ }
159
+ if (inA.status !== inB.status) {
160
+ const firstLine = (inA.status === "pass" ? inB : inA).messages[0]?.split("\n")[0] ?? "";
161
+ diffs.push({
162
+ test: id,
163
+ step,
164
+ kind: "verdict-flip",
165
+ detail: `${inA.status.toUpperCase()} under ${labelA}, ${inB.status.toUpperCase()} under ${labelB}${firstLine ? `: ${firstLine}` : ""}`,
166
+ });
167
+ continue;
168
+ }
169
+ if (inA.status !== "pass" && inA.messages.join("\n") !== inB.messages.join("\n")) {
170
+ diffs.push({
171
+ test: id,
172
+ step,
173
+ kind: "failure-text-changed",
174
+ detail: `failed under both, with different output — ${labelA}: "${inA.messages[0]?.split("\n")[0] ?? ""}" vs ${labelB}: "${inB.messages[0]?.split("\n")[0] ?? ""}"`,
175
+ });
176
+ }
177
+ }
178
+ return diffs;
179
+ }
@@ -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
+ }