create-cmp-cli 0.12.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.
@@ -0,0 +1,364 @@
1
+ // Core logic for `create-cmp upgrade --harness`: refresh the ENGINE-OWNED
2
+ // files of a stamped app after the engine improved.
3
+ //
4
+ // WHY: `create-cmp` stamps an app from template/ and walks away. When the
5
+ // engine later fixes a stamped file (a verify-lane bug, a Gradle wiring fix,
6
+ // a new hook), apps stamped from the OLD engine never receive it — the dead
7
+ // androidDebug/AndroidManifest.xml fixed at engine commit b972c19 was carried
8
+ // by every app this template ever stamped. This module closes that gap with a
9
+ // three-way merge, exactly like dpkg conffile handling or a Yeoman
10
+ // regeneration:
11
+ //
12
+ // base = what the engine WOULD have stamped at the version this app was
13
+ // stamped from (old template + CURRENT stamp pipeline — see below)
14
+ // new = what the CURRENT engine stamps
15
+ // theirs = the app's working tree today
16
+ //
17
+ // Both base and new are produced by stamping with the app's OWN recorded
18
+ // config (create-cmp.json), so tokens (package, app name, theme prefix)
19
+ // resolve identically and every base→new diff is pure engine change.
20
+ //
21
+ // NOTE on the base approximation: base is "old template + current pipeline".
22
+ // When the stamp PIPELINE itself changed between versions (tokenization,
23
+ // marker stripping), base can differ from what the old engine literally
24
+ // produced. This is deliberate — running old engine code against a current
25
+ // config is strictly worse — and only affects files whose tokenization
26
+ // changed; those surface as conflicts rather than silent clobbers.
27
+ //
28
+ // App-authored files (the app's own feature screens) appear in neither base
29
+ // nor new, so they are INVISIBLE to this sweep — by design. The sweep only
30
+ // ever considers paths the engine stamped at one version or the other.
31
+ //
32
+ // Pure decision logic lives here so tests can drive it with in-memory/temp
33
+ // fixtures and zero npm network access; CLI + npm-pack orchestration lives in
34
+ // src/commands/upgrade.mjs. No dependencies beyond the Node stdlib (git is
35
+ // already a hard requirement of this repo).
36
+
37
+ import fs from "node:fs";
38
+ import os from "node:os";
39
+ import path from "node:path";
40
+ import { spawnSync } from "node:child_process";
41
+
42
+ import { listFiles } from "./fsutil.mjs";
43
+ import { isBinaryPath } from "./tokens.mjs";
44
+ import { BACKUP_SUFFIX } from "./upgrade.mjs";
45
+
46
+ /** Sidecar suffix for the new engine content beside a conflicted file. */
47
+ export const SIDECAR_SUFFIX = ".cmp-new";
48
+
49
+ /**
50
+ * Hard exclusion list — the app's own state, or its secrets, that the engine
51
+ * also seeds. A diff here is noise or danger, never an upgrade. Matched
52
+ * against the project-relative path (posix separators):
53
+ * - a bare name (no `/`) matches that basename at ANY depth — this is what
54
+ * keeps `keystore.properties` and `google-services.json` excluded wherever
55
+ * they live (e.g. composeApp/google-services.json), because those files
56
+ * must never be read, written, or printed by this code;
57
+ * - `dir/**` matches everything under `dir` (root-anchored);
58
+ * - `**` + `/dir/` + `**` matches everything under a `dir` segment at any depth;
59
+ * - `**` + `/name` matches that basename at any depth.
60
+ */
61
+ export const EXCLUDED_PATTERNS = [
62
+ "create-cmp.json",
63
+ "qa/evidence/**",
64
+ "qa/approvals.json",
65
+ "qa/comments.json",
66
+ "qa/golden/**",
67
+ ".git/**",
68
+ "build/**",
69
+ "**/build/**",
70
+ ".gradle/**",
71
+ "local.properties",
72
+ "keystore.properties",
73
+ "google-services.json",
74
+ "**/GoogleService-Info.plist",
75
+ ];
76
+
77
+ /**
78
+ * Does one exclusion pattern match a project-relative posix path?
79
+ * @param {string} relPath project-relative path, "/"-separated
80
+ * @param {string} pattern one of EXCLUDED_PATTERNS (see grammar above)
81
+ * @returns {boolean}
82
+ */
83
+ export function matchesPattern(relPath, pattern) {
84
+ if (pattern.endsWith("/**")) {
85
+ const dir = pattern.slice(0, -3);
86
+ if (dir.startsWith("**/")) {
87
+ // "**/build/**": any DIRECTORY segment equal to the name.
88
+ const seg = dir.slice(3);
89
+ return relPath.split("/").slice(0, -1).includes(seg);
90
+ }
91
+ return relPath === dir || relPath.startsWith(dir + "/");
92
+ }
93
+ if (pattern.startsWith("**/")) {
94
+ const name = pattern.slice(3);
95
+ return relPath === name || relPath.endsWith("/" + name);
96
+ }
97
+ if (!pattern.includes("/")) {
98
+ // Bare name: basename match at any depth (never risk touching a nested
99
+ // secret because it wasn't at the root).
100
+ return relPath === pattern || relPath.endsWith("/" + pattern);
101
+ }
102
+ return relPath === pattern;
103
+ }
104
+
105
+ /**
106
+ * Is this project-relative path on the hard exclusion list?
107
+ * Checked BEFORE any file content is read — excluded files (state, secrets)
108
+ * are never opened by this module.
109
+ * @param {string} relPath project-relative path, "/"-separated
110
+ * @returns {boolean}
111
+ */
112
+ export function isExcludedPath(relPath) {
113
+ return EXCLUDED_PATTERNS.some((p) => matchesPattern(relPath, p));
114
+ }
115
+
116
+ /**
117
+ * Three-way merge via `git merge-file -p --diff3 <theirs> <base> <new>`.
118
+ * The three sides are written to temp files; git's exit code is 0 for a clean
119
+ * merge, >0 for the number of conflicts, <0 / spawn error for trouble — both
120
+ * of the latter are treated as a conflict (the caller then writes a sidecar
121
+ * instead of touching the app's file, so "treat as conflict" is always safe).
122
+ * @param {Buffer} theirs the app's current content
123
+ * @param {Buffer} base the old engine's stamped content
124
+ * @param {Buffer} next the current engine's stamped content
125
+ * @returns {{clean: boolean, content: Buffer|null}} merged content when clean
126
+ */
127
+ export function mergeThreeWay(theirs, base, next) {
128
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-harness-merge-"));
129
+ try {
130
+ const t = path.join(dir, "theirs");
131
+ const b = path.join(dir, "base");
132
+ const n = path.join(dir, "new");
133
+ fs.writeFileSync(t, theirs);
134
+ fs.writeFileSync(b, base);
135
+ fs.writeFileSync(n, next);
136
+ const r = spawnSync("git", ["merge-file", "-p", "--diff3", t, b, n], {
137
+ maxBuffer: 64 * 1024 * 1024,
138
+ });
139
+ if (r.error || r.status === null || r.status !== 0) {
140
+ return { clean: false, content: null };
141
+ }
142
+ return { clean: true, content: r.stdout };
143
+ } finally {
144
+ fs.rmSync(dir, { recursive: true, force: true });
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The decision table — one file, three sides, one verdict. Buckets:
150
+ * unchanged engine never changed it (base == new) → silent
151
+ * current app already matches the new engine → silent
152
+ * applied app never touched it, engine changed it → write new
153
+ * merged both changed, three-way merge is clean → write merged
154
+ * conflicted both changed the same region (or binary, or the app deleted
155
+ * a file the engine changed) → NEVER clobber: leave the app's
156
+ * file byte-for-byte alone, emit a `.cmp-new` sidecar with the
157
+ * new engine's content
158
+ * added new engine file absent from the app → write new
159
+ * removed engine deleted it and the app never touched it → delete
160
+ * orphaned engine deleted it but the app modified it → keep, report
161
+ *
162
+ * Binary files (isBinaryPath) never three-way merge: replace when the app
163
+ * never touched them, otherwise conflicted.
164
+ *
165
+ * @param {object} params
166
+ * @param {string} params.relPath project-relative path (for binary sniffing)
167
+ * @param {Buffer|null} params.base old engine's content (null = not stamped then)
168
+ * @param {Buffer|null} params.next current engine's content (null = engine deleted it)
169
+ * @param {Buffer|null} params.theirs the app's content (null = absent in the app)
170
+ * @param {(theirs:Buffer, base:Buffer, next:Buffer)=>{clean:boolean,content:Buffer|null}} [params.merge]
171
+ * three-way merge fn, injectable for tests (default: git merge-file)
172
+ * @returns {{bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}|null}
173
+ * null when the path is in neither base nor new (app-authored — invisible)
174
+ */
175
+ export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
176
+ const none = (bucket) => ({ bucket, write: null, sidecar: null, remove: false });
177
+ const write = (bucket, content) => ({ bucket, write: content, sidecar: null, remove: false });
178
+ const conflict = () => ({ bucket: "conflicted", write: null, sidecar: next, remove: false });
179
+ const eq = (a, b) => a !== null && b !== null && a.equals(b);
180
+
181
+ if (base !== null && next !== null) {
182
+ if (eq(base, next)) return none("unchanged"); // engine never changed it
183
+ // Engine changed it:
184
+ if (theirs === null) return conflict(); // app deleted it — never resurrect silently
185
+ if (eq(theirs, next)) return none("current"); // already up to date
186
+ if (eq(theirs, base)) return write("applied", next); // app never touched it
187
+ // All three differ:
188
+ if (isBinaryPath(relPath)) return conflict(); // binaries never merge
189
+ const m = merge(theirs, base, next);
190
+ if (m.clean && m.content !== null) {
191
+ // A merge that reproduces the app's file byte-for-byte means the app
192
+ // already carries the engine change (e.g. a previous --harness run) —
193
+ // report current, keep re-runs idempotent and quiet.
194
+ if (m.content.equals(theirs)) return none("current");
195
+ return write("merged", m.content);
196
+ }
197
+ return conflict();
198
+ }
199
+
200
+ if (base === null && next !== null) {
201
+ // New engine file.
202
+ if (theirs === null) return write("added", next);
203
+ if (eq(theirs, next)) return none("current");
204
+ return conflict(); // the app already has something different there
205
+ }
206
+
207
+ if (base !== null && next === null) {
208
+ // Engine deleted it.
209
+ if (theirs === null) return none("current"); // already gone
210
+ if (eq(theirs, base)) return { bucket: "removed", write: null, sidecar: null, remove: true };
211
+ return none("orphaned"); // app modified it — keep it, report it
212
+ }
213
+
214
+ return null; // in neither base nor new: app-authored, invisible to the sweep
215
+ }
216
+
217
+ function readIfPresent(dir, relPath) {
218
+ try {
219
+ return fs.readFileSync(path.join(dir, relPath));
220
+ } catch {
221
+ return null;
222
+ }
223
+ }
224
+
225
+ function toRel(root, abs) {
226
+ return path.relative(root, abs).split(path.sep).join("/");
227
+ }
228
+
229
+ /**
230
+ * Walk base ∪ new, classify every path through the decision table, and return
231
+ * the full plan. Excluded paths are counted WITHOUT ever reading their
232
+ * content (state files and secrets stay unopened). Reads the three trees but
233
+ * never writes anything — applying is applyHarnessPlan's job.
234
+ * @param {object} params
235
+ * @param {string} params.baseDir stamped tree of the old engine
236
+ * @param {string} params.newDir stamped tree of the current engine
237
+ * @param {string} params.projectDir the app's working tree
238
+ * @param {Function} [params.merge] three-way merge fn, injectable for tests
239
+ * @returns {{entries: Array<{relPath:string, bucket:string, write?:Buffer|null,
240
+ * sidecar?:Buffer|null, remove?:boolean}>,
241
+ * counts: Record<string, number>}}
242
+ */
243
+ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay }) {
244
+ const rels = new Set();
245
+ for (const f of listFiles(baseDir)) rels.add(toRel(baseDir, f));
246
+ for (const f of listFiles(newDir)) rels.add(toRel(newDir, f));
247
+
248
+ const counts = {
249
+ excluded: 0,
250
+ unchanged: 0,
251
+ current: 0,
252
+ applied: 0,
253
+ merged: 0,
254
+ conflicted: 0,
255
+ added: 0,
256
+ removed: 0,
257
+ orphaned: 0,
258
+ };
259
+ const entries = [];
260
+ for (const relPath of [...rels].sort()) {
261
+ if (isExcludedPath(relPath)) {
262
+ counts.excluded += 1;
263
+ entries.push({ relPath, bucket: "excluded", write: null, sidecar: null, remove: false });
264
+ continue;
265
+ }
266
+ const decision = decideFile({
267
+ relPath,
268
+ base: readIfPresent(baseDir, relPath),
269
+ next: readIfPresent(newDir, relPath),
270
+ theirs: readIfPresent(projectDir, relPath),
271
+ merge,
272
+ });
273
+ if (decision === null) continue;
274
+ counts[decision.bucket] += 1;
275
+ entries.push({ relPath, ...decision });
276
+ }
277
+ return { entries, counts };
278
+ }
279
+
280
+ /**
281
+ * Apply a plan to the app's working tree. Every file that gets written over
282
+ * or deleted is backed up first as `<file>${BACKUP_SUFFIX}` (same suffix as
283
+ * the version-catalog upgrade path, so one revert story covers both modes).
284
+ * Conflicted entries never touch the app's file — only the `.cmp-new` sidecar
285
+ * is written. Returns what happened so the CLI can print revert commands.
286
+ * @param {string} projectDir
287
+ * @param {Array<{relPath:string, bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}>} entries
288
+ * @returns {{written:string[], created:string[], deleted:string[],
289
+ * sidecars:string[], backups:string[]}}
290
+ * written rel paths overwritten (backup exists)
291
+ * created rel paths newly created (no previous content, no backup)
292
+ * deleted rel paths removed (backup exists)
293
+ * sidecars rel paths of `.cmp-new` files written beside conflicts
294
+ * backups rel paths that have a `${BACKUP_SUFFIX}` copy
295
+ */
296
+ export function applyHarnessPlan(projectDir, entries) {
297
+ const written = [];
298
+ const created = [];
299
+ const deleted = [];
300
+ const sidecars = [];
301
+ const backups = [];
302
+ for (const e of entries) {
303
+ const abs = path.join(projectDir, e.relPath);
304
+ if (e.sidecar !== null && e.sidecar !== undefined) {
305
+ const sidecarPath = abs + SIDECAR_SUFFIX;
306
+ fs.mkdirSync(path.dirname(sidecarPath), { recursive: true });
307
+ fs.writeFileSync(sidecarPath, e.sidecar);
308
+ sidecars.push(e.relPath + SIDECAR_SUFFIX);
309
+ continue;
310
+ }
311
+ if (e.remove) {
312
+ fs.copyFileSync(abs, abs + BACKUP_SUFFIX);
313
+ backups.push(e.relPath);
314
+ fs.rmSync(abs);
315
+ deleted.push(e.relPath);
316
+ continue;
317
+ }
318
+ if (e.write !== null && e.write !== undefined) {
319
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
320
+ if (fs.existsSync(abs)) {
321
+ fs.copyFileSync(abs, abs + BACKUP_SUFFIX);
322
+ backups.push(e.relPath);
323
+ fs.writeFileSync(abs, e.write);
324
+ written.push(e.relPath);
325
+ } else {
326
+ fs.writeFileSync(abs, e.write);
327
+ created.push(e.relPath);
328
+ }
329
+ }
330
+ }
331
+ return { written, created, deleted, sidecars, backups };
332
+ }
333
+
334
+ /**
335
+ * Reconstruct the engine config from a parsed create-cmp.json record.
336
+ * Key names differ slightly (record: name/bundleId ↔ config:
337
+ * appName/iosBundleId). Fields the record predates default to
338
+ * "feature absent" — a record written before a toggle existed describes an
339
+ * app whose tree does NOT carry that feature, so stamping without it mirrors
340
+ * the app best.
341
+ * @param {object} record parsed create-cmp.json
342
+ * @param {string} targetDir where the reconstructed stamp should land
343
+ * @returns {object} engine config (options.schema.json shape)
344
+ */
345
+ export function configFromSpecRecord(record, targetDir) {
346
+ return {
347
+ appName: record.name,
348
+ package: record.package,
349
+ iosBundleId: record.bundleId,
350
+ region: record.region ?? "us-central1",
351
+ themePrefix: record.themePrefix,
352
+ platforms: record.platforms ?? { android: true, ios: true },
353
+ firebase: record.firebase ?? { enabled: false },
354
+ room: record.room ?? false,
355
+ e2e: record.e2e ?? false,
356
+ inspector: record.inspector ?? false,
357
+ devClient: record.devClient ?? false,
358
+ tabs: record.tabs ?? [
359
+ { label: "Home", icon: "home" },
360
+ { label: "Profile", icon: "person" },
361
+ ],
362
+ targetDir,
363
+ };
364
+ }
@@ -388,4 +388,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
388
388
  | `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
389
389
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
390
390
  | `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
391
- | `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the release-APK Maestro smoke (`releaseSmoke`) |
391
+ | `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
392
+ | `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
393
+ | `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
394
+ | `node qa/retrospective.mjs` | How this project actually uses its harness, from `qa/flight-recorder.jsonl` (appended by every lane run): fast vs full ratio, verbatim SKIP reasons grouped, whether the device tier is ever reached, longest stretch with no full lane. States only what the journal recorded |
@@ -1,5 +1,9 @@
1
1
  # __APP_NAME__
2
2
 
3
+ <!-- cmp:generated evidence -->
4
+ [![No evidence receipt](https://img.shields.io/badge/evidence-none_yet-9E9E9E)](https://github.com/kvdm-co-pilot/create-cmp) — no verify receipt yet. Run `node qa/verify.mjs`.
5
+ <!-- /cmp:generated -->
6
+
3
7
  A Kotlin / Compose Multiplatform app, generated by
4
8
  [create-cmp](https://github.com/kvdm-co-pilot/create-cmp) with a **verification harness**:
5
9
  the architecture, testing conventions, and definition of done are enforced mechanically, not
@@ -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
+ }