create-cmp-cli 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +11 -9
  2. package/bin/create-cmp.mjs +3 -0
  3. package/package.json +1 -1
  4. package/src/commands/upgrade.mjs +287 -0
  5. package/src/lib/harness-upgrade.mjs +364 -0
  6. package/src/lib/package-name.mjs +72 -0
  7. package/src/scaffold.mjs +7 -2
  8. package/template/.claude/settings.json +30 -0
  9. package/template/CLAUDE.md +51 -6
  10. package/template/README.md +4 -0
  11. package/template/composeApp/build.gradle.kts +44 -0
  12. package/template/composeApp/src/androidDebug/AndroidManifest.xml +9 -0
  13. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/PlatformBehaviorSeamTest.kt +277 -0
  14. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/RuntimeStateSeamTest.kt +308 -0
  15. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/AlarmAsserts.kt +152 -0
  16. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ConfigControl.kt +124 -0
  17. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/DozeControl.kt +113 -0
  18. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NetworkControl.kt +137 -0
  19. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/NotificationAsserts.kt +163 -0
  20. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/PermissionControl.kt +132 -0
  21. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/ProcessControl.kt +217 -0
  22. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/Shell.kt +79 -0
  23. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/SystemState.kt +113 -0
  24. package/template/composeApp/src/androidInstrumentedTest/kotlin/com/example/app/testing/TimeWarp.kt +114 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +5 -2
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +1 -1
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +1 -1
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +1 -1
  29. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +58 -0
  30. package/template/docs/ARCHITECTURE.md +41 -2
  31. package/template/docs/TESTING.md +165 -0
  32. package/template/gradle/libs.versions.toml +15 -0
  33. package/template/manifest.json +1 -0
  34. package/template/qa/evidence/schema.json +20 -2
  35. package/template/qa/lib/affected-tests.mjs +147 -0
  36. package/template/qa/lib/audit-cadence.mjs +290 -0
  37. package/template/qa/lib/determinism.mjs +179 -0
  38. package/template/qa/lib/device-lease.mjs +249 -0
  39. package/template/qa/lib/evidence-badge.mjs +158 -0
  40. package/template/qa/lib/evidence-level.mjs +117 -0
  41. package/template/qa/lib/flight-recorder.mjs +332 -0
  42. package/template/qa/lib/inputs-hash.mjs +16 -1
  43. package/template/qa/lib/spec-coverage.mjs +54 -3
  44. package/template/qa/lib/step-cache.mjs +221 -0
  45. package/template/qa/receipt-check.mjs +22 -2
  46. package/template/qa/record-audit.mjs +83 -0
  47. package/template/qa/retrospective.mjs +51 -0
  48. package/template/qa/scaffold-feature.mjs +20 -1
  49. package/template/qa/verify.mjs +934 -57
  50. package/template/qa/watch.mjs +622 -0
  51. package/template/specs/app-base.spec.md +11 -0
@@ -30,6 +30,7 @@
30
30
  "composeApp/src/androidMain/kotlin",
31
31
  "composeApp/src/androidDebug/kotlin",
32
32
  "composeApp/src/androidRelease/kotlin",
33
+ "composeApp/src/androidInstrumentedTest/kotlin",
33
34
  "composeApp/src/iosMain/kotlin",
34
35
  "composeApp/src/desktopMain/kotlin"
35
36
  ],
@@ -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": { "enum": ["PASS", "FAIL", "SKIP"] },
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,290 @@
1
+ // audit-cadence.mjs — the mechanical nudge that keeps cmp-audit from
2
+ // depending on someone remembering.
3
+ //
4
+ // The adversarial platform-semantics audit (the cmp-audit skill) found six
5
+ // latent defects on its first real outing — and it only ran because a human
6
+ // happened to ask. This module is the cheapest honest replacement for that
7
+ // memory: the release profile's receipt lists which androidMain subsystems
8
+ // changed since their last RECORDED audit, so the ship-time surface itself
9
+ // says "these platform seams moved and nobody has interrogated them since".
10
+ //
11
+ // It is a REPORT, never a gate. Audit debt is a judgment call (a one-line
12
+ // rename is not six latent defects), so this file computes facts and the
13
+ // human decides — a FAIL here would train people to game the ledger, which
14
+ // would destroy the only thing it has: honesty.
15
+ //
16
+ // The ledger (qa/audits.jsonl) is append-only, one JSON object per line:
17
+ // subsystem, the commit sha the audit ran against, an ISO timestamp, and who
18
+ // or what recorded it. Recording is a CLAIM — "this subsystem, as of this
19
+ // commit, was audited" — so recordAudit() derives the sha from HEAD itself
20
+ // and refuses to record when the subsystem's files differ from HEAD: a
21
+ // record claiming a commit the audited bytes did not match would be the
22
+ // exact dishonesty the whole harness exists to prevent.
23
+ //
24
+ // "Subsystem" is DERIVED, never configured: the immediate package directory
25
+ // under the app's androidMain Kotlin source root (the root is resolved from
26
+ // the android namespace in composeApp/build.gradle.kts). This template is
27
+ // stamped into apps whose package names it cannot know; deriving from the
28
+ // tree is the only definition that survives that. Kotlin files sitting
29
+ // directly at the package root belong to no package directory and are
30
+ // reported under the literal name "(root)" rather than invented into one.
31
+
32
+ import { execSync } from "node:child_process";
33
+ import fs from "node:fs";
34
+ import path from "node:path";
35
+
36
+ export const AUDITS_REL_PATH = "qa/audits.jsonl";
37
+ export const AUDIT_RECORD_SCHEMA = "cmp-audit-record/1";
38
+
39
+ /** The pseudo-subsystem for Kotlin files directly at the androidMain package root. */
40
+ export const ROOT_SUBSYSTEM = "(root)";
41
+
42
+ function tryGit(root, cmd) {
43
+ try {
44
+ return execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function tryGitLines(root, cmd) {
51
+ try {
52
+ const out = execSync(`git ${cmd}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
53
+ return out.replace(/\n+$/, "").split("\n").filter(Boolean);
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Resolve the androidMain Kotlin package root for this app, relative to the
61
+ * project root — derived from the android `namespace` (falling back to
62
+ * `applicationId`) in composeApp/build.gradle.kts, never hardcoded.
63
+ * @param {string} root project root (absolute)
64
+ * @returns {{ok: true, rel: string}|{ok: false, reason: string}}
65
+ */
66
+ export function androidMainPackageRoot(root) {
67
+ let gradle;
68
+ try {
69
+ gradle = fs.readFileSync(path.join(root, "composeApp", "build.gradle.kts"), "utf8");
70
+ } catch {
71
+ return { ok: false, reason: "composeApp/build.gradle.kts not readable — cannot derive the app package" };
72
+ }
73
+ const pkg = gradle.match(/namespace\s*=\s*"([^"]+)"/)?.[1] ?? gradle.match(/applicationId\s*=\s*"([^"]+)"/)?.[1];
74
+ if (!pkg) {
75
+ return { ok: false, reason: "no android namespace/applicationId in composeApp/build.gradle.kts — cannot derive the app package" };
76
+ }
77
+ const rel = path.posix.join("composeApp/src/androidMain/kotlin", ...pkg.split("."));
78
+ if (!fs.existsSync(path.join(root, rel))) {
79
+ return { ok: false, reason: `androidMain has no Kotlin sources under the app package (${rel} absent)` };
80
+ }
81
+ return { ok: true, rel };
82
+ }
83
+
84
+ /**
85
+ * List this app's androidMain subsystems: the immediate directories under
86
+ * the package root (sorted), plus ROOT_SUBSYSTEM when Kotlin files sit
87
+ * directly at the root.
88
+ * @param {string} root project root (absolute)
89
+ * @param {string} pkgRootRel from androidMainPackageRoot()
90
+ * @returns {string[]}
91
+ */
92
+ export function listSubsystems(root, pkgRootRel) {
93
+ const abs = path.join(root, pkgRootRel);
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(abs, { withFileTypes: true });
97
+ } catch {
98
+ return [];
99
+ }
100
+ const names = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
101
+ if (entries.some((e) => e.isFile() && e.name.endsWith(".kt"))) names.push(ROOT_SUBSYSTEM);
102
+ return names;
103
+ }
104
+
105
+ /**
106
+ * Read the audit ledger. Absent is the honest "no audit ever recorded"
107
+ * state; malformed lines are counted, never silently dropped — the report
108
+ * says how many records it could not read instead of under-counting audits.
109
+ * @param {string} root project root (absolute)
110
+ * @returns {{entries: Array<{subsystem: string, sha: string, at: string, by: string}>, malformed: number}}
111
+ */
112
+ export function readAuditLedger(root) {
113
+ const p = path.join(root, AUDITS_REL_PATH);
114
+ if (!fs.existsSync(p)) return { entries: [], malformed: 0 };
115
+ let raw;
116
+ try {
117
+ raw = fs.readFileSync(p, "utf8");
118
+ } catch {
119
+ return { entries: [], malformed: 0 };
120
+ }
121
+ const entries = [];
122
+ let malformed = 0;
123
+ for (const line of raw.split("\n")) {
124
+ if (!line.trim()) continue;
125
+ try {
126
+ const e = JSON.parse(line);
127
+ if (e && typeof e === "object" && typeof e.subsystem === "string" && typeof e.sha === "string") entries.push(e);
128
+ else malformed += 1;
129
+ } catch {
130
+ malformed += 1;
131
+ }
132
+ }
133
+ return { entries, malformed };
134
+ }
135
+
136
+ /**
137
+ * Record an audit claim: append {subsystem, sha: HEAD, at, by} to the
138
+ * ledger. Honesty guards, each a refusal rather than a fabrication:
139
+ * - no git HEAD → refused (a claim about no commit is not a claim);
140
+ * - unknown subsystem → refused, naming the derived ones;
141
+ * - the subsystem's files differ from HEAD → refused (the record would
142
+ * claim HEAD while the audited bytes are something else — commit first).
143
+ * @param {string} root project root (absolute)
144
+ * @param {{subsystem: string, by?: string}} claim
145
+ * @returns {{ok: true, sha: string, entry: object}|{ok: false, reason: string}}
146
+ */
147
+ export function recordAudit(root, { subsystem, by }) {
148
+ const sha = tryGit(root, "rev-parse HEAD");
149
+ if (!sha) {
150
+ return { ok: false, reason: "no git history — an audit record is a claim about a specific commit, and there is none to claim against. Commit first." };
151
+ }
152
+ const pkgRoot = androidMainPackageRoot(root);
153
+ if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
154
+ const known = listSubsystems(root, pkgRoot.rel);
155
+ if (!known.includes(subsystem)) {
156
+ return { ok: false, reason: `unknown subsystem "${subsystem}" — derived subsystems under ${pkgRoot.rel}: ${known.join(", ") || "(none)"}` };
157
+ }
158
+ const scope = subsystem === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, subsystem);
159
+ const dirty = tryGitLines(root, `status --porcelain -- "${scope}"`) ?? [];
160
+ // For "(root)" the porcelain scope is the whole package root; narrow to
161
+ // files directly at the root so a dirty subsystem dir doesn't block a
162
+ // root-level record it has nothing to do with.
163
+ const relevantDirty =
164
+ subsystem === ROOT_SUBSYSTEM
165
+ ? dirty.filter((l) => {
166
+ const rel = l.slice(3).trim();
167
+ return path.posix.dirname(rel) === pkgRoot.rel;
168
+ })
169
+ : dirty;
170
+ if (relevantDirty.length > 0) {
171
+ return {
172
+ ok: false,
173
+ reason: `uncommitted changes under ${scope} — the record would claim commit ${sha.slice(0, 7)} but the audited files are not that commit. Commit (or revert) first, then record.`,
174
+ };
175
+ }
176
+ const entry = {
177
+ schema: AUDIT_RECORD_SCHEMA,
178
+ subsystem,
179
+ sha,
180
+ at: new Date().toISOString(),
181
+ by: by || tryGit(root, "config user.name") || "unknown",
182
+ };
183
+ const p = path.join(root, AUDITS_REL_PATH);
184
+ fs.mkdirSync(path.dirname(p), { recursive: true });
185
+ fs.appendFileSync(p, `${JSON.stringify(entry)}\n`);
186
+ return { ok: true, sha, entry };
187
+ }
188
+
189
+ /**
190
+ * The report itself: for every derived subsystem, what the ledger claims
191
+ * and what git says moved since that claim.
192
+ *
193
+ * Statuses, each phrased so the receipt can print the line verbatim:
194
+ * never-audited no ledger entry — says exactly that, implies no staleness
195
+ * changed androidMain files under it changed between the audited
196
+ * sha and HEAD (committed changes only — sha vs HEAD is
197
+ * the honest comparison; the working tree is not history)
198
+ * unchanged no committed change since the audited sha
199
+ * unknown-commit the ledger names a sha this repo's history does not
200
+ * contain — drift cannot be measured, and the report says
201
+ * so instead of guessing
202
+ *
203
+ * @param {string} root project root (absolute)
204
+ * @returns {{ok: false, reason: string}|{ok: true, packageRoot: string,
205
+ * subsystems: Array<{name: string, status: string, audit: object|null, changedFiles: number}>,
206
+ * lines: string[], summary: string, malformed: number}}
207
+ */
208
+ export function evaluateAuditCadence(root) {
209
+ if (!tryGit(root, "rev-parse HEAD")) {
210
+ // No git history: "changed since the last audit" has no meaning yet.
211
+ // Report NOTHING rather than guessing — an invented staleness signal
212
+ // would be worse than none.
213
+ return { ok: false, reason: "no git history — changed-since-audit cannot be measured" };
214
+ }
215
+ const pkgRoot = androidMainPackageRoot(root);
216
+ if (!pkgRoot.ok) return { ok: false, reason: pkgRoot.reason };
217
+ const subsystems = listSubsystems(root, pkgRoot.rel);
218
+ if (subsystems.length === 0) {
219
+ return { ok: false, reason: `no subsystems under ${pkgRoot.rel} — nothing to report` };
220
+ }
221
+
222
+ const { entries, malformed } = readAuditLedger(root);
223
+ // Last entry per subsystem wins: the ledger is append-only, so file order
224
+ // IS chronological order — trusted over the `at` timestamps, which are
225
+ // claims a machine's clock made, not facts git can vouch for.
226
+ const latest = new Map();
227
+ for (const e of entries) latest.set(e.subsystem, e);
228
+
229
+ const gitTop = tryGit(root, "rev-parse --show-toplevel");
230
+ // Realpath both sides before re-anchoring diff paths: git reports the
231
+ // toplevel with symlinks resolved (macOS: /var/… vs /private/var/…), and a
232
+ // mismatch here would silently mis-attribute every changed file.
233
+ let rootReal = root;
234
+ try {
235
+ rootReal = fs.realpathSync(root);
236
+ } catch {
237
+ rootReal = root;
238
+ }
239
+ const results = [];
240
+ const lines = [];
241
+ for (const name of subsystems) {
242
+ const audit = latest.get(name) ?? null;
243
+ if (!audit) {
244
+ results.push({ name, status: "never-audited", audit: null, changedFiles: 0 });
245
+ lines.push(`no audit recorded for ${name} — when it gets one (cmp-audit ${name}), record it: node qa/record-audit.mjs ${JSON.stringify(name)}`);
246
+ continue;
247
+ }
248
+ // A ledger sha is a CLAIM read from a file — validate its shape before it
249
+ // touches a shell, and resolve it against history before trusting it.
250
+ const shaShapeOk = typeof audit.sha === "string" && /^[0-9a-f]{4,40}$/i.test(audit.sha);
251
+ const shaKnown = shaShapeOk && Boolean(tryGit(root, `rev-parse --verify --quiet "${audit.sha}^{commit}"`));
252
+ if (!shaKnown) {
253
+ results.push({ name, status: "unknown-commit", audit, changedFiles: 0 });
254
+ lines.push(`${name}: last audit (${fmtWhen(audit)}) was recorded against ${audit.sha.slice(0, 12)}, which is not in this repo's history — drift since it cannot be measured`);
255
+ continue;
256
+ }
257
+ const scope = name === ROOT_SUBSYSTEM ? pkgRoot.rel : path.posix.join(pkgRoot.rel, name);
258
+ const changedRaw = tryGitLines(root, `diff --name-only ${audit.sha} HEAD -- "${scope}"`) ?? [];
259
+ // Diff paths come back relative to the git toplevel, which may sit above
260
+ // the project root; re-anchor before subsystem attribution.
261
+ const changed = changedRaw
262
+ .map((rel) => (gitTop ? path.relative(rootReal, path.resolve(gitTop, rel)).split(path.sep).join("/") : rel))
263
+ .filter((rel) => (name === ROOT_SUBSYSTEM ? path.posix.dirname(rel) === pkgRoot.rel : true));
264
+ if (changed.length > 0) {
265
+ results.push({ name, status: "changed", audit, changedFiles: changed.length });
266
+ lines.push(
267
+ `${name}: ${changed.length} androidMain file(s) changed since its last recorded audit (${audit.sha.slice(0, 7)}, ${fmtWhen(audit)}) — audit it (cmp-audit ${name}), then record: node qa/record-audit.mjs ${JSON.stringify(name)}`,
268
+ );
269
+ } else {
270
+ results.push({ name, status: "unchanged", audit, changedFiles: 0 });
271
+ }
272
+ }
273
+
274
+ const changedCount = results.filter((r) => r.status === "changed").length;
275
+ const neverCount = results.filter((r) => r.status === "never-audited").length;
276
+ const unchangedCount = results.filter((r) => r.status === "unchanged").length;
277
+ if (unchangedCount > 0) {
278
+ lines.push(`${unchangedCount} subsystem(s) unchanged since their last recorded audit: ${results.filter((r) => r.status === "unchanged").map((r) => r.name).join(", ")}`);
279
+ }
280
+ if (malformed > 0) {
281
+ lines.push(`${malformed} ledger line(s) in ${AUDITS_REL_PATH} could not be parsed and are not counted`);
282
+ }
283
+ const summary = `${changedCount} changed since audit · ${neverCount} never audited · ${unchangedCount} unchanged`;
284
+
285
+ return { ok: true, packageRoot: pkgRoot.rel, subsystems: results, lines, summary, malformed };
286
+ }
287
+
288
+ function fmtWhen(audit) {
289
+ return typeof audit.at === "string" ? audit.at.slice(0, 10) : "undated";
290
+ }
@@ -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
+ }