create-cmp-cli 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -26,8 +26,9 @@
26
26
  // COORDINATION (non-negotiable): two concurrent Gradle invocations against one
27
27
  // project corrupt each other's output (KSP cache collisions, half-written
28
28
  // classes dirs — a real 20+ bogus-failure cascade). The lane and the preview
29
- // daemon already coordinate via marker files under composeApp/build:
30
- // .cmp-lane-in-progress — stamped by verify.mjs for a run's duration
29
+ // daemon already coordinate via marker files:
30
+ // qa/.lane-in-progress — stamped by verify.mjs for a run's duration (core
31
+ // state; qa/lib/lane-markers.mjs)
31
32
  // .cmp-render-in-progress — stamped by the preview daemon while its Gradle
32
33
  // build is in flight
33
34
  // Watch mode participates as a third citizen: it never launches a run while a
@@ -51,17 +52,20 @@ import fs from "node:fs";
51
52
  import path from "node:path";
52
53
  import { fileURLToPath } from "node:url";
53
54
 
55
+ import { LANE_MARKER_REL, LANE_MARKER_STALE_MS, RENDER_MARKER_FRESH_MS, laneMarkerPath, renderMarkerPath } from "./lib/lane-markers.mjs";
56
+ import { resolveSpecModel } from "./lib/spec-model.mjs";
57
+
54
58
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
55
59
 
56
60
  // ── CLI ─────────────────────────────────────────────────────────────────────
57
61
 
58
62
  export const USAGE = `node qa/watch.mjs [--once] [--json] [--help]
59
63
 
60
- Resident watch mode — the inner verification loop. Watches composeApp/src,
61
- specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
64
+ Resident watch mode — the inner verification loop. Watches the project's
65
+ source roots, specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
62
66
  a save storm triggers ONE run, changes during a run coalesce into one
63
67
  follow-up). It defers while a verify lane or a preview-daemon render holds the
64
- project (the .cmp-*-in-progress markers under composeApp/build), so two Gradle
68
+ project (qa/.lane-in-progress and the eyes' own render marker), so two build
65
69
  invocations never collide.
66
70
 
67
71
  THIS IS NOT A GATE. It runs the fast tier only: every receipt records
@@ -93,23 +97,106 @@ export function parseWatchArgs(rawArgs) {
93
97
  }
94
98
 
95
99
  // ── The watch set ───────────────────────────────────────────────────────────
96
- // composeApp/src (the app), specs/ (the contract), qa/ (the harness itself —
97
- // a golden-tree or e2e-flow edit should re-verify too). NOT watched: build
98
- // output anywhere, qa/evidence/ (verify.mjs writes latest.json there on every
99
- // run watching your own output is an infinite loop), and dotfiles (VCS
100
- // internals, editor droppings, and the .cmp-*-in-progress markers themselves).
100
+ // The profile's source roots (the app), its specs directory (the contract),
101
+ // and qa/ (the harness itself — a golden-tree or flow edit should re-verify
102
+ // too). NOT watched: the build output directory the PROFILE declares, at any
103
+ // depth (see shouldIgnorePath); qa/evidence/ (verify.mjs writes latest.json
104
+ // there on every run watching your own output is an infinite loop); and
105
+ // dotfiles (VCS internals, editor droppings, and the in-progress markers
106
+ // themselves).
107
+
108
+ /**
109
+ * The trees this loop watches: the profile's own source roots and specs
110
+ * directory, plus qa/ (the harness judging itself — always core).
111
+ *
112
+ * Stage 0 PR 6b: this was `["composeApp/src", "specs", "qa"]`, so the inner
113
+ * loop in a repo whose code lives anywhere else watched two directories out of
114
+ * three and never fired on a source edit — a watcher that looks idle and is.
115
+ * A project with no usable manifest gets the core roots alone and the startup
116
+ * banner says so, rather than guessing a source layout.
117
+ * @param {string} root
118
+ * @returns {{roots: string[], degraded: string|null}}
119
+ */
120
+ export function watchRoots(root) {
121
+ const model = resolveSpecModel(root);
122
+ if (!model.ok) {
123
+ return { roots: ["specs", "qa"], degraded: model.reason };
124
+ }
125
+ return { roots: [...new Set([...model.model.sourceRoots, model.model.specsDir, "qa"])], degraded: null };
126
+ }
127
+
128
+ // ── Build output: the profile's word, not one stack's ───────────────────────
129
+ // The ignore set's whole job is to keep the loop from watching its own output.
130
+ // It used to find that output by the single literal directory name `build`,
131
+ // which is Gradle's — Rust writes `target/`, .NET `bin/` and `obj/`, Python
132
+ // `__pycache__`, Xcode `DerivedData`. Both halves were wrong, and both
133
+ // silently, with a watcher that looked like it was working:
134
+ //
135
+ // on those stacks the lane's own writes woke the watcher, which ran the lane
136
+ // again — a feedback loop, in the one process whose entire purpose is to fire
137
+ // on a human's edit
138
+ // and on EVERY stack a real source directory named `build/` (a build script
139
+ // package, a `build/` module, Python's `app/build/`) was never watched at
140
+ // all — the "watcher that looks idle and is" that Stage 0 PR 6b already
141
+ // fixed once for source ROOTS and left in place here
142
+ //
143
+ // The profile declares `layout.buildDir` (qa/lib/spec-model.mjs), so the name
144
+ // is available and was simply not asked for.
145
+ //
146
+ // THE DECLARED DIRECTORY'S NAME MATCHES AT ANY DEPTH, not just the one declared
147
+ // path. A profile declares its app module's output, but a multi-module tree has
148
+ // one such directory PER MODULE (Gradle writes one into every module; the
149
+ // harness's own qa/ subtrees get one too — see test/watch-loop.test.mjs's
150
+ // `qa/somewhere/build/out.json`). Ignoring only the declared path would re-open
151
+ // the feedback loop one directory over. Only that name matches, so a stack that
152
+ // calls its output `target` starts watching `build/` as the ordinary source it
153
+ // is there.
154
+ //
155
+ // That rule is deliberately BROADER than the declaration, and the price is
156
+ // named here rather than discovered: a profile declaring a nested `buildDir`
157
+ // gets its LEAF name ignored everywhere (`build/outputs` ignores every
158
+ // `outputs/` in the tree, not just that one). The startup banner prints the
159
+ // glob it settled on for exactly this reason — an ignore set a reader can see
160
+ // is one they can correct in `layout.buildDir`.
101
161
 
102
- export const WATCH_ROOTS = ["composeApp/src", "specs", "qa"];
162
+ /**
163
+ * The historical name, kept ONLY for a project whose profile could not be
164
+ * resolved or which declares no `layout.buildDir`. It is a FALLBACK and not a
165
+ * default in the approving sense — the startup banner names it, exactly as
166
+ * spec-model.mjs's `DEFAULT_GRAMMAR.isDefault` makes the borrowed citation
167
+ * grammar say so out loud. The two failures are not symmetric: watching your
168
+ * own output loops forever, while missing a source directory named `build/`
169
+ * costs one manual run. When we know nothing, we take the cheaper mistake.
170
+ */
171
+ export const FALLBACK_BUILD_OUTPUT_NAME = "build";
172
+
173
+ /**
174
+ * The directory NAMES this project's build output wears, from the profile's
175
+ * declaration. `composeApp/build` → ["build"]; `target` → ["target"].
176
+ * @param {{buildDir?: string|null}|null} [model] a SpecModel, or null when none resolved
177
+ * @returns {string[]}
178
+ */
179
+ export function buildOutputNames(model) {
180
+ const declared = typeof model?.buildDir === "string" ? model.buildDir : "";
181
+ const name = declared.replace(/\\/g, "/").split("/").filter(Boolean).pop();
182
+ return name ? [name] : [FALLBACK_BUILD_OUTPUT_NAME];
183
+ }
103
184
 
104
- /** Ignore predicate over a ROOT-relative path (forward slashes or backslashes). */
105
- export function shouldIgnorePath(rel) {
185
+ /**
186
+ * Ignore predicate over a ROOT-relative path (forward slashes or backslashes).
187
+ * @param {string} rel
188
+ * @param {{buildDir?: string|null}|null} [model] the project's SpecModel — supplies the
189
+ * build output name. Omitted/null falls back to FALLBACK_BUILD_OUTPUT_NAME.
190
+ */
191
+ export function shouldIgnorePath(rel, model = null) {
106
192
  const norm = String(rel).replace(/\\/g, "/");
107
193
  if (!norm) return true;
108
194
  const parts = norm.split("/");
109
- // Any dotted segment: .git, .gradle, .DS_Store, .cmp-lane-in-progress, …
195
+ // Any dotted segment: .git, .gradle, .DS_Store, .lane-in-progress, …
110
196
  if (parts.some((s) => s.startsWith("."))) return true;
111
- // Any build dir at any depth (composeApp/build, qa/**/build, ).
112
- if (parts.includes("build")) return true;
197
+ // This profile's build output, at any depth (see the note above).
198
+ const buildNames = buildOutputNames(model);
199
+ if (parts.some((s) => buildNames.includes(s))) return true;
113
200
  // The lane's own output — the one path that would make watch feed itself.
114
201
  if (norm === "qa/evidence" || norm.startsWith("qa/evidence/")) return true;
115
202
  return false;
@@ -133,10 +220,11 @@ export const POLL_MS = 2000; // marker-wait poll AND the no-recursive-watch fall
133
220
  // around its Gradle builds. Freshness is mtime-bounded so a crashed stamper
134
221
  // never wedges us.
135
222
 
136
- export const LANE_MARKER_REL = ["composeApp", "build", ".cmp-lane-in-progress"];
137
- export const RENDER_MARKER_REL = ["composeApp", "build", ".cmp-render-in-progress"];
138
- export const LANE_MARKER_STALE_MS = 30 * 60 * 1000; // preview-service.mjs's bound for this marker
139
- export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // verify.mjs's bound for this marker
223
+ // Paths and bounds are qa/lib/lane-markers.mjs's — the lane marker is core state
224
+ // under qa/, the render marker is the profile's (under its buildDir, or none).
225
+ // Re-exported because markerDecision's bounds are part of this module's
226
+ // contract (qa/watch.mjs --json consumers and the engine suite read them).
227
+ export { LANE_MARKER_STALE_MS, RENDER_MARKER_FRESH_MS };
140
228
 
141
229
  /**
142
230
  * The launch decision, pure: given the two markers' mtimes (null = absent) and
@@ -145,10 +233,10 @@ export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // verify.mjs's bound for t
145
233
  */
146
234
  export function markerDecision({ laneMtimeMs = null, renderMtimeMs = null, nowMs = Date.now() } = {}) {
147
235
  if (laneMtimeMs != null && nowMs - laneMtimeMs < LANE_MARKER_STALE_MS) {
148
- return { launch: false, reason: "a verify lane is in progress (.cmp-lane-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
236
+ return { launch: false, reason: `a verify lane is in progress (${LANE_MARKER_REL} is fresh) — deferring; changes coalesce into one run when it finishes` };
149
237
  }
150
238
  if (renderMtimeMs != null && nowMs - renderMtimeMs < RENDER_MARKER_FRESH_MS) {
151
- return { launch: false, reason: "the preview daemon has a Gradle build in flight (.cmp-render-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
239
+ return { launch: false, reason: "the preview daemon has a build in flight (.cmp-render-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
152
240
  }
153
241
  return { launch: true };
154
242
  }
@@ -304,8 +392,8 @@ function markerMtime(absPath) {
304
392
 
305
393
  function launchDecisionNow() {
306
394
  return markerDecision({
307
- laneMtimeMs: markerMtime(path.join(ROOT, ...LANE_MARKER_REL)),
308
- renderMtimeMs: markerMtime(path.join(ROOT, ...RENDER_MARKER_REL)),
395
+ laneMtimeMs: markerMtime(laneMarkerPath(ROOT)),
396
+ renderMtimeMs: markerMtime(renderMarkerPath(ROOT)),
309
397
  });
310
398
  }
311
399
 
@@ -383,7 +471,7 @@ function main() {
383
471
  currentChild = null;
384
472
  // A signal-killed child never ran verify's `finally` — clean the lane
385
473
  // marker it stamped so nothing defers on a ghost for 30 minutes.
386
- if (signal) clearMarkerIfOwnedBy(path.join(ROOT, ...LANE_MARKER_REL), child.pid);
474
+ if (signal) clearMarkerIfOwnedBy(laneMarkerPath(ROOT), child.pid);
387
475
  const durationMs = Date.now() - started;
388
476
  const receipt = parseReceipt(stdout);
389
477
  const rawTail = `${stdout}\n${stderr}`.split("\n").filter(Boolean);
@@ -453,7 +541,18 @@ function main() {
453
541
 
454
542
  const watchers = [];
455
543
  const pollTimers = [];
456
- const watchedRoots = WATCH_ROOTS.filter((rel) => fs.existsSync(path.join(ROOT, rel)));
544
+ const { roots: declaredRoots, degraded: rootsDegraded } = watchRoots(ROOT);
545
+ const watchedRoots = declaredRoots.filter((rel) => fs.existsSync(path.join(ROOT, rel)));
546
+ // The ignore set is this profile's too: WHICH directory holds build output is
547
+ // a stack fact (`layout.buildDir`), and a watcher that guesses it either
548
+ // feeds itself or skips real source. resolveSpecModel shares the module cache
549
+ // with watchRoots's own call, so this is a map lookup, not a second load.
550
+ const specModel = (() => {
551
+ const r = resolveSpecModel(ROOT);
552
+ return r.ok ? r.model : null;
553
+ })();
554
+ const ignoredBuildNames = buildOutputNames(specModel);
555
+ const ignorePath = (rel) => shouldIgnorePath(rel, specModel);
457
556
 
458
557
  // Poll fallback for platforms without recursive fs.watch: a full mtime scan
459
558
  // per tick, diffed against the previous one so changed paths still get
@@ -470,7 +569,7 @@ function main() {
470
569
  }
471
570
  for (const e of entries) {
472
571
  const rel = `${dirRel}/${e.name}`;
473
- if (shouldIgnorePath(rel)) continue;
572
+ if (ignorePath(rel)) continue;
474
573
  const abs = path.join(dirAbs, e.name);
475
574
  if (e.isDirectory()) walk(abs, rel);
476
575
  else {
@@ -511,7 +610,7 @@ function main() {
511
610
  try {
512
611
  const w = fs.watch(path.join(ROOT, rootRel), { recursive: true }, (_event, filename) => {
513
612
  const rel = filename ? `${rootRel}/${String(filename).replace(/\\/g, "/")}` : rootRel;
514
- if (shouldIgnorePath(rel)) return;
613
+ if (ignorePath(rel)) return;
515
614
  loop.change(rel);
516
615
  });
517
616
  w.on("error", () => {
@@ -531,9 +630,23 @@ function main() {
531
630
  }
532
631
 
533
632
  // Startup: what is watched, what is respected, what this is NOT.
633
+ // The ignore globs are PRINTED, never assumed: they name the directory this
634
+ // profile declared, so a reader can see at a glance that the loop is ignoring
635
+ // this stack's output and not some other stack's word for it.
636
+ const ignoreGlobs = [...ignoredBuildNames.map((n) => `**/${n}/**`), "qa/evidence/**", "dotfiles"];
534
637
  say("qa/watch.mjs — resident inner loop: runs `node qa/verify.mjs --fast` on save");
535
- say(`watching: ${watchedRoots.join(", ")} (ignoring **/build/**, qa/evidence/**, dotfiles)`);
536
- say("coordination: defers while composeApp/build/.cmp-lane-in-progress or .cmp-render-in-progress is fresh never two Gradle invocations against this project");
638
+ say(`watching: ${watchedRoots.join(", ")} (ignoring ${ignoreGlobs.join(", ")})`);
639
+ // Never watch a guessed layout silently: if the manifest could not be read,
640
+ // the source roots are unknown and this loop is watching less than it looks.
641
+ if (rootsDegraded) say(`NOTE: watching the core roots only — ${rootsDegraded}`);
642
+ // And never ignore a guessed layout silently either. A profile that declares
643
+ // no layout.buildDir leaves this loop on FALLBACK_BUILD_OUTPUT_NAME — one
644
+ // stack's convention, applied to a stack that never claimed it — so it says
645
+ // so, the way a borrowed citation grammar does (spec-model.mjs).
646
+ else if (specModel && typeof specModel.buildDir !== "string") {
647
+ say(`NOTE: this profile declares no layout.buildDir — ignoring **/${FALLBACK_BUILD_OUTPUT_NAME}/** as a fallback, which may be this project's real source`);
648
+ }
649
+ say(`coordination: defers while ${LANE_MARKER_REL} or the eyes' render marker is fresh — never two builds against this project`);
537
650
  say(`debounce: ${DEBOUNCE_MS}ms — a save storm triggers one run; changes during a run coalesce into one follow-up`);
538
651
  say(FOOTER);
539
652
  say("waiting for changes… (Ctrl-C to stop · --once for a single pass · --json for line-per-run output)");
@@ -541,9 +654,9 @@ function main() {
541
654
  event: "start",
542
655
  pid: process.pid,
543
656
  watching: watchedRoots,
544
- ignoring: ["**/build/**", "qa/evidence/**", "dotfiles"],
657
+ ignoring: ignoreGlobs,
545
658
  debounceMs: DEBOUNCE_MS,
546
- coordinates: [LANE_MARKER_REL.join("/"), RENDER_MARKER_REL.join("/")],
659
+ coordinates: [LANE_MARKER_REL, renderMarkerPath(ROOT) ? path.relative(ROOT, renderMarkerPath(ROOT)).split(path.sep).join("/") : null].filter(Boolean),
547
660
  runs: "node qa/verify.mjs --fast",
548
661
  note: FOOTER,
549
662
  });
@@ -598,7 +711,7 @@ function installSignalHandlers(stopWork, getChild, emit, say) {
598
711
  signalTree("SIGKILL");
599
712
  await Promise.race([exited, sleep(1000)]);
600
713
  }
601
- clearMarkerIfOwnedBy(path.join(ROOT, ...LANE_MARKER_REL), child.pid);
714
+ clearMarkerIfOwnedBy(laneMarkerPath(ROOT), child.pid);
602
715
  }
603
716
  say(`watch mode stopped (${sig}) — no receipt was made valid by watching; the done-gate is still one full \`node qa/verify.mjs\` run`);
604
717
  emit({ event: "shutdown", reason: sig });
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "@create-cmp/receipts",
2
+ "name": "prooflane-receipts",
3
3
  "version": "0.1.0",
4
4
  "description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM \u2014 the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
5
5
  "type": "module",
@@ -1,9 +1,9 @@
1
- // @create-cmp/receipts — validate create-cmp evidence receipts.
1
+ // prooflane-receipts — validate create-cmp evidence receipts.
2
2
  // Single source of truth for the inputs-hash algorithm and the receipt
3
3
  // predicate; vendored byte-identical into every generated project's qa/lib/
4
4
  // and consumed by hosted validators. Dependency-free ESM.
5
5
 
6
- export { computeInputsHash, VERIFIED_SURFACE } from "./inputs-hash.mjs";
6
+ export { computeInputsHash, defaultSurface } from "./inputs-hash.mjs";
7
7
  export {
8
8
  RECEIPT_REL_PATH,
9
9
  readReceipt,
@@ -5,7 +5,7 @@
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
7
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
- // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
8
+ // create-cmp repo (the `prooflane-receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
11
  // `node scripts/sync-harness.mjs`.
@@ -30,15 +30,46 @@ import path from "node:path";
30
30
  // looking the same is the worst failure this harness can have, so the surface
31
31
  // is now resolved per project (see resolveVerifiedSurface) and an empty one is
32
32
  // refused rather than hashed.
33
- export const VERIFIED_SURFACE = [
34
- "composeApp",
35
- "specs",
36
- "qa",
37
- "gradle/libs.versions.toml",
38
- "build.gradle.kts",
39
- "settings.gradle.kts",
40
- "gradle.properties",
41
- ];
33
+ /**
34
+ * The surface a project gets when it has NOT declared one.
35
+ *
36
+ * This used to be a constant: `composeApp`, `specs`, `qa`, and the Gradle files
37
+ * — one stack's directory names, in the module every adopter's receipt is
38
+ * computed by. Vendored into a repo whose code lives under `services/`, it
39
+ * matched `qa/` and `specs/` and nothing else, so the lane produced a valid,
40
+ * confident, SMALLER hash: a receipt attesting a fraction of a project while
41
+ * looking complete. The file's own header calls that the worst failure this
42
+ * harness can have, and then shipped it as the default.
43
+ *
44
+ * Derived from the tree instead, and deliberately WIDE. Over-attesting costs a
45
+ * hash that moves more often than it needs to; under-attesting silently drops
46
+ * files from what a receipt claims. Only one of those is a lie, so the error is
47
+ * taken in the safe direction. Build output and dependency directories are
48
+ * excluded because they are outputs, not the thing being attested.
49
+ *
50
+ * A project that wants a narrower or pinned surface DECLARES one — `harness
51
+ * init` writes it, and the template ships one, so this fallback is for repos
52
+ * that predate the declaration rather than the normal path.
53
+ *
54
+ * @param {string} root
55
+ * @returns {string[]}
56
+ */
57
+ export function defaultSurface(root) {
58
+ // The same construction as the walk — the universal floor plus what this
59
+ // repo's own .gitignore says — so the two agree by construction. qa-artifacts
60
+ // is the lane's own output and stays named.
61
+ const skip = new Set([...WALK_FLOOR, "qa-artifacts", ...gitignoredDirs(root), ...declaredIgnore(root)]);
62
+ let entries;
63
+ try {
64
+ entries = fs.readdirSync(root, { withFileTypes: true });
65
+ } catch {
66
+ return [];
67
+ }
68
+ return entries
69
+ .filter((e) => !e.name.startsWith(".") && !skip.has(e.name))
70
+ .map((e) => e.name)
71
+ .sort();
72
+ }
42
73
 
43
74
  // Paths EXCLUDED even though they fall under an included surface dir above.
44
75
  // qa/evidence and qa-artifacts are lane OUTPUTS — including them would make
@@ -74,6 +105,10 @@ const EXCLUDED_PREFIXES = [
74
105
  // un-proves the tree — the exact inversion this family of exclusions exists
75
106
  // to prevent.
76
107
  "qa/.agent-hold.json",
108
+ // The lane's own in-flight marker (qa/lib/lane-markers.mjs): stamped for a
109
+ // run's duration, rewritten at every step. Hashing it would make the lane
110
+ // invalidate its own receipt mid-run.
111
+ "qa/.lane-in-progress",
77
112
  "qa/evidence",
78
113
  "qa-artifacts",
79
114
  "qa/comments.json",
@@ -139,7 +174,111 @@ function tryGitLsFiles(root) {
139
174
  // "INVALID — source changed" the moment the user runs `git init`, even though
140
175
  // no source changed. Pre-git and post-git hashes must agree for identical
141
176
  // source; that is the invariant the regression test pins.
142
- const WALK_EXCLUDED_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules"]);
177
+ // A FLOOR, not the rule. This used to BE the rule, and it was one ecosystem's:
178
+ // it knew build/.gradle/.kotlin/.idea/node_modules and nothing of __pycache__,
179
+ // venv, target (Rust, Maven), bin and obj (.NET), vendor (Go, PHP), Pods or
180
+ // DerivedData. So a Python tree hashed one set of bytes before `git init` and a
181
+ // different set after, and the stamp-time PASS receipt read "source changed
182
+ // since the receipt" the instant a user ran `git init` — with no source change.
183
+ // That is the exact invariant the constant was written to hold, broken for
184
+ // every ecosystem but the first.
185
+ // THE FLOOR IS UNIVERSAL; EVERYTHING ELSE IS DECLARED. `.git` is git's, and
186
+ // `node_modules` is this lane's own runtime. Until 2026-09-08 this set also
187
+ // carried `build`, `.gradle`, `.kotlin`, `.idea` — one ecosystem's build output,
188
+ // applied to every tree. PATTERN: the repo's own ignore file is the truth
189
+ // (ripgrep, watchman and git itself all read .gitignore rather than a table);
190
+ // beneath it, the profile declares `layout.ignore` and `layout.buildDir`,
191
+ // written into qa/verified-surface.json as `ignore` so THIS package — the
192
+ // notary's, which must know no profile — reads a project fact, not a stack.
193
+ // WHY IT WORKS: the people who know the stack maintain the list, in the file
194
+ // they already maintain. HOW IT FAILS: a tree with no git, no .gitignore and no
195
+ // declaration hashes its build output — a hash that moves too often, which is
196
+ // the safe direction. WHAT WE DO: that case is the walk, and the walk prints
197
+ // its rules. Q5 (NORTH-STAR §10): git-mode hashing is untouched, and a cmp
198
+ // tree's .gitignore already lists these directories, so no receipt moves.
199
+ const WALK_FLOOR = new Set([".git", "node_modules"]);
200
+ const WALK_EXCLUDED_DIRS = WALK_FLOOR;
201
+
202
+ /**
203
+ * Directories the project DECLARED unhashable — qa/verified-surface.json's
204
+ * optional `ignore` list, written by `harness init` from `layout.ignore` and
205
+ * `layout.buildDir`, so the notary reads a project fact rather than a profile.
206
+ * @param {string} root
207
+ * @returns {Set<string>}
208
+ */
209
+ export function declaredIgnore(root) {
210
+ try {
211
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, SURFACE_CONFIG_REL), "utf8"));
212
+ const list = Array.isArray(parsed?.ignore) ? parsed.ignore : [];
213
+ return new Set(list.filter((x) => typeof x === "string" && x.trim() && !x.includes("..")).map((x) => x.replace(/^\.\//, "").replace(/\/$/, "")));
214
+ } catch {
215
+ return new Set();
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The directories THIS repo ignores, read from its own `.gitignore`.
221
+ *
222
+ * Walk mode exists only before `git init`, and its whole job is to agree with
223
+ * what `git ls-files --exclude-standard` will say afterwards. Maintaining a
224
+ * hand-written list of every ecosystem's build directory is a losing game and
225
+ * was already lost; reading the file git reads makes the two modes agree BY
226
+ * CONSTRUCTION rather than by vigilance.
227
+ *
228
+ * Deliberately a small subset of gitignore syntax — bare directory names, with
229
+ * or without a leading or trailing slash. Globs, negations and nested paths are
230
+ * left to git, which is running in every case that matters. Missing one costs a
231
+ * hash that moves too often; that is the safe direction, and the same one
232
+ * `defaultSurface` errs in.
233
+ *
234
+ * @param {string} root
235
+ * @returns {Set<string>}
236
+ */
237
+ export function gitignoredDirs(root) {
238
+ let text;
239
+ try {
240
+ text = fs.readFileSync(path.join(root, ".gitignore"), "utf8");
241
+ } catch {
242
+ return new Set();
243
+ }
244
+ const out = new Set();
245
+ for (const raw of text.split("\n")) {
246
+ const line = raw.trim();
247
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
248
+ if (line.includes("*") || line.includes("?") || line.includes("[")) continue;
249
+ const name = line.replace(/^\//, "").replace(/\/$/, "");
250
+ if (name && !name.includes("/")) out.add(name);
251
+ }
252
+ return out;
253
+ }
254
+ /**
255
+ * Files that are INPUTS TO THE RESOLUTION rather than members of the surface.
256
+ *
257
+ * `.gitignore` decides what is attested: `git ls-files --exclude-standard`
258
+ * honours it, and `gitignoredDirs` above makes the walk fallback honour it too.
259
+ * A file that decides the attested set and is not itself attested is a hole —
260
+ * edit one line and a directory silently leaves every future receipt, with
261
+ * nothing in the chain saying the coverage moved. That is the narrowing failure
262
+ * this module's header calls the worst one it can have, arriving through the
263
+ * side door.
264
+ *
265
+ * Attested STRUCTURALLY, outside `surfaceEntries`, because a declaration that
266
+ * could omit it would reopen exactly the hole: `defaultSurface` skips dotfiles,
267
+ * the shipped template surface lists none, and no adopter writing
268
+ * `qa/verified-surface.json` by hand would think to add it. It is not the
269
+ * adopter's to declare — it is the resolver's own input.
270
+ *
271
+ * Not locked: adopters edit `.gitignore` legitimately and often. Attestation is
272
+ * the right instrument — a change makes the receipt say "source changed", which
273
+ * is true, and re-running mints a valid one.
274
+ */
275
+ const RESOLUTION_INPUTS = Object.freeze([".gitignore"]);
276
+
277
+ /** @param {string} relPath @returns {boolean} */
278
+ function isResolutionInput(relPath) {
279
+ return RESOLUTION_INPUTS.includes(relPath);
280
+ }
281
+
143
282
  // File-level mirror of the same principle (OS/editor junk the .gitignore covers).
144
283
  const WALK_EXCLUDED_FILES = new Set([".DS_Store"]);
145
284
  const WALK_EXCLUDED_SUFFIXES = [".iml", ".log"];
@@ -150,14 +289,16 @@ function walkIncludesFile(name) {
150
289
  }
151
290
 
152
291
  // Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
153
- function walkAllFiles(dir) {
292
+ // `ignored` is the floor plus whatever this repo's own .gitignore names, so the
293
+ // pre-git hash agrees with the post-git one for any ecosystem, not just the first.
294
+ function walkAllFiles(dir, ignored = WALK_EXCLUDED_DIRS) {
154
295
  const out = [];
155
296
  if (!fs.existsSync(dir)) return out;
156
297
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
157
298
  const p = path.join(dir, entry.name);
158
299
  if (entry.isDirectory()) {
159
- if (WALK_EXCLUDED_DIRS.has(entry.name)) continue; // non-source scratch — see note above
160
- out.push(...walkAllFiles(p));
300
+ if (ignored.has(entry.name)) continue; // non-source scratch — see note above
301
+ out.push(...walkAllFiles(p, ignored));
161
302
  } else if (entry.isFile() && walkIncludesFile(entry.name)) out.push(p);
162
303
  }
163
304
  return out;
@@ -191,7 +332,7 @@ export function resolveVerifiedSurface(root) {
191
332
  try {
192
333
  raw = fs.readFileSync(p, "utf8");
193
334
  } catch {
194
- return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
335
+ return defaultSurface(root); // no declaration — the tree's own top level
195
336
  }
196
337
  let parsed;
197
338
  try {
@@ -208,32 +349,41 @@ export function resolveVerifiedSurface(root) {
208
349
 
209
350
  // Resolve the verified surface to a flat, sorted list of paths (relative to
210
351
  // root, POSIX-style `/` separators) that currently exist on disk.
211
- function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
352
+ function resolveSurfaceFiles(root, surfaceEntries) {
212
353
  const gitFiles = tryGitLsFiles(root);
213
354
 
214
355
  if (gitFiles) {
215
356
  return gitFiles
216
357
  .map((p) => p.split(path.sep).join("/"))
217
- .filter((relPath) => VERIFIED_SURFACE.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
358
+ .filter((relPath) => isResolutionInput(relPath) || surfaceEntries.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
218
359
  .filter((relPath) => !isExcluded(relPath))
219
360
  .filter((relPath) => fs.existsSync(path.join(root, relPath)) && fs.statSync(path.join(root, relPath)).isFile());
220
361
  }
221
362
 
222
363
  // Fallback: no git available — walk the surface directories directly so a
223
- // non-git scaffold still produces a stable hash.
364
+ // non-git scaffold still produces a stable hash. The ignore set is the floor
365
+ // PLUS this repo's own .gitignore, which is what `git ls-files
366
+ // --exclude-standard` will honour the moment the tree becomes a repo. Reading
367
+ // the same file is what makes the two modes agree by construction.
368
+ const ignored = new Set([...WALK_FLOOR, ...gitignoredDirs(root), ...declaredIgnore(root)]);
224
369
  const collected = [];
225
- for (const surface of VERIFIED_SURFACE) {
370
+ for (const surface of surfaceEntries) {
226
371
  const abs = path.join(root, surface);
227
372
  if (!fs.existsSync(abs)) continue;
228
373
  const stat = fs.statSync(abs);
229
374
  if (stat.isFile()) {
230
375
  collected.push(surface);
231
376
  } else if (stat.isDirectory()) {
232
- for (const file of walkAllFiles(abs)) {
377
+ for (const file of walkAllFiles(abs, ignored)) {
233
378
  collected.push(path.relative(root, file).split(path.sep).join("/"));
234
379
  }
235
380
  }
236
381
  }
382
+ // The resolver's own inputs, attested whatever the surface declares.
383
+ for (const rel of RESOLUTION_INPUTS) {
384
+ const abs = path.join(root, rel);
385
+ if (!collected.includes(rel) && fs.existsSync(abs) && fs.statSync(abs).isFile()) collected.push(rel);
386
+ }
237
387
  return collected.filter((relPath) => !isExcluded(relPath));
238
388
  }
239
389
 
@@ -246,7 +396,9 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
246
396
  * it belongs in qa/verified-surface.json. Sorted; [] when git is unavailable
247
397
  * (the walk fallback has no notion of "what git sees") or everything is
248
398
  * covered. Lane outputs (EXCLUDED_PREFIXES) are not "undeclared" — they are
249
- * excluded by decision.
399
+ * excluded by decision, and RESOLUTION_INPUTS are not undeclared either: they
400
+ * are attested structurally, so naming them here would report a file as
401
+ * unattested that the very same hash attests.
250
402
  * @param {string} root
251
403
  * @param {string[]} [surface] defaults to resolveVerifiedSurface(root)
252
404
  * @returns {string[]}
@@ -254,7 +406,8 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
254
406
  export function undeclaredTopLevel(root, surface = resolveVerifiedSurface(root)) {
255
407
  const gitFiles = tryGitLsFiles(root);
256
408
  if (!gitFiles) return [];
257
- const covered = (relPath) => surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath);
409
+ const covered = (relPath) =>
410
+ surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath) || isResolutionInput(relPath);
258
411
  const out = new Set();
259
412
  for (const raw of gitFiles) {
260
413
  const relPath = raw.split(path.sep).join("/");