create-cmp-cli 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/package.json +1 -1
- package/template/.claude/skills/add-feature/SKILL.md +4 -0
- package/template/README.md +1 -1
- package/template/qa/lib/inputs-hash.mjs +28 -2
- package/template/qa/lib/receipt-validate.mjs +234 -0
- package/template/qa/receipt-check.mjs +7 -44
- package/template/qa/scaffold-feature.mjs +75 -2
package/README.md
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
**The AI delivery harness for Kotlin/Compose Multiplatform.**
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Gives AI coding agents *eyes* and a *machine-enforced definition of done* on mobile: scaffold a
|
|
8
|
+
green-building Android + iOS app in minutes, then let AI extend it — seeing every screen it
|
|
9
|
+
renders, and blocked from "done" without proof.
|
|
9
10
|
|
|
10
11
|
[](https://github.com/kvdm-co-pilot/create-cmp/actions/workflows/ci.yml)
|
|
11
12
|
[](https://www.npmjs.com/package/create-cmp-cli)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-cmp-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Create production mobile apps (Android + iOS, one Kotlin codebase) with AI — the delivery harness for Compose Multiplatform, the current generation of cross-platform (Google-backed KMP, iOS stable since May 2025). A deterministic, non-interactive generator that scaffolds a green-building app in minutes, then holds AI-driven changes to a machine-enforced verify lane with a committed evidence receipt. Every app carries a device-free UI preview loop (real screens rendered headlessly on save; changed-screen attribution and compile-error surfacing for coding agents, a live gallery for humans) plus agent-first docs (CLAUDE.md + AGENTS.md). Installs the `create-cmp` command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -62,6 +62,10 @@ at their `// cmp:anchor` markers, and writes `specs/<feature>.spec.md` with a de
|
|
|
62
62
|
set (`<FEATURE>-01..06`: loading, success, error, reload-after-failure, tap-navigates, golden
|
|
63
63
|
tree) — copied verbatim from the `home` exemplar's shape.
|
|
64
64
|
|
|
65
|
+
The stamped screen arrives **already wrapped in `BaseScreen { … }`** (SHELL-05): it is a
|
|
66
|
+
pushed NavHost destination, so unlike the tab exemplar it must handle its own insets — the
|
|
67
|
+
stamper does this for you; do not unwrap it.
|
|
68
|
+
|
|
65
69
|
If it exits non-zero, read the message — it is actionable (name already taken, an anchor marker
|
|
66
70
|
is missing, or a name isn't a valid Kotlin identifier). Do not hand-edit around a stamper
|
|
67
71
|
failure; if an anchor is genuinely missing from a shared file, that is a template defect worth
|
package/template/README.md
CHANGED
|
@@ -109,4 +109,4 @@ signal for a later one in CI.
|
|
|
109
109
|
|
|
110
110
|
---
|
|
111
111
|
|
|
112
|
-
Built with
|
|
112
|
+
[](https://github.com/kvdm-co-pilot/create-cmp) — the AI delivery harness for Compose Multiplatform. *(Just a static badge — delete this line if you prefer.)*
|
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
// qa/receipt-check.mjs (recomputes it to test validity) import this module so
|
|
5
5
|
// there is exactly one definition of the surface and the algorithm.
|
|
6
6
|
//
|
|
7
|
+
// SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
|
|
8
|
+
// create-cmp repo (the `cmp-receipts` package). The copy in a generated
|
|
9
|
+
// project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
|
|
10
|
+
// test/receipts-parity.test.mjs — edit the package source, then run
|
|
11
|
+
// `node scripts/sync-receipts.mjs`.
|
|
12
|
+
//
|
|
7
13
|
// See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
|
|
8
14
|
|
|
9
15
|
import { execSync } from "node:child_process";
|
|
@@ -49,14 +55,34 @@ function tryGitLsFiles(root) {
|
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
58
|
+
// Directory names the walk fallback must skip wherever they appear under a
|
|
59
|
+
// surface root. These mirror what the stamped .gitignore excludes: without
|
|
60
|
+
// this, a pre-`git init` hash (walk mode) includes composeApp/build/** and
|
|
61
|
+
// Gradle/Kotlin scratch that the post-`git init` hash (`git ls-files
|
|
62
|
+
// --exclude-standard`) excludes — so the stamp-time PASS receipt would read
|
|
63
|
+
// "INVALID — source changed" the moment the user runs `git init`, even though
|
|
64
|
+
// no source changed. Pre-git and post-git hashes must agree for identical
|
|
65
|
+
// source; that is the invariant the regression test pins.
|
|
66
|
+
const WALK_EXCLUDED_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules"]);
|
|
67
|
+
// File-level mirror of the same principle (OS/editor junk the .gitignore covers).
|
|
68
|
+
const WALK_EXCLUDED_FILES = new Set([".DS_Store"]);
|
|
69
|
+
const WALK_EXCLUDED_SUFFIXES = [".iml", ".log"];
|
|
70
|
+
|
|
71
|
+
function walkIncludesFile(name) {
|
|
72
|
+
if (WALK_EXCLUDED_FILES.has(name)) return false;
|
|
73
|
+
return !WALK_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
74
|
+
}
|
|
75
|
+
|
|
52
76
|
// Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
|
|
53
77
|
function walkAllFiles(dir) {
|
|
54
78
|
const out = [];
|
|
55
79
|
if (!fs.existsSync(dir)) return out;
|
|
56
80
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
57
81
|
const p = path.join(dir, entry.name);
|
|
58
|
-
if (entry.isDirectory())
|
|
59
|
-
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
if (WALK_EXCLUDED_DIRS.has(entry.name)) continue; // non-source scratch — see note above
|
|
84
|
+
out.push(...walkAllFiles(p));
|
|
85
|
+
} else if (entry.isFile() && walkIncludesFile(entry.name)) out.push(p);
|
|
60
86
|
}
|
|
61
87
|
return out;
|
|
62
88
|
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// The evidence-binding predicate and its service-grade extensions, as pure
|
|
2
|
+
// dependency-free functions. `evaluateReceipt` is the exact predicate the
|
|
3
|
+
// generated project's qa/receipt-check.mjs (and its Stop hook + CI) runs;
|
|
4
|
+
// the additional checks (freshness, execution plausibility, SKIP listing) are
|
|
5
|
+
// consumed by hosted validators that judge a receipt fetched from a repo
|
|
6
|
+
// tarball rather than the working tree.
|
|
7
|
+
//
|
|
8
|
+
// SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
|
|
9
|
+
// create-cmp repo (the `cmp-receipts` package). The copy in a generated
|
|
10
|
+
// project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
|
|
11
|
+
// test/receipts-parity.test.mjs — edit the package source, then run
|
|
12
|
+
// `node scripts/sync-receipts.mjs`.
|
|
13
|
+
//
|
|
14
|
+
// See docs/adr/0005-evidence-binding-by-inputs-hash.md for the why.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { computeInputsHash } from "./inputs-hash.mjs";
|
|
20
|
+
|
|
21
|
+
/** Where a generated project keeps its committed receipt, relative to root. */
|
|
22
|
+
export const RECEIPT_REL_PATH = "qa/evidence/latest.json";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read and parse the committed receipt for the project rooted at `root`.
|
|
26
|
+
* @param {string} root absolute path to the project root
|
|
27
|
+
* @returns {object|null} the parsed receipt, or null when absent/unparsable
|
|
28
|
+
*/
|
|
29
|
+
export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(fs.readFileSync(path.join(root, relPath), "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The core predicate: does this receipt validly attest the tree whose inputs
|
|
39
|
+
* hash `recompute()` returns? Reasons are the exact refusal strings the
|
|
40
|
+
* generated project's receipt-check CLI (and Stop hook) prints.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} receipt parsed receipt JSON
|
|
43
|
+
* @param {() => {hash: string, fileCount: number}} recompute lazily invoked —
|
|
44
|
+
* never called when the receipt fails structurally first (missing binding,
|
|
45
|
+
* FAIL verdict), so callers don't pay for a hash they don't need.
|
|
46
|
+
* @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
|
|
47
|
+
*/
|
|
48
|
+
export function evaluateReceipt(receipt, recompute) {
|
|
49
|
+
const profile = receipt.profile;
|
|
50
|
+
|
|
51
|
+
if (!receipt.inputs || typeof receipt.inputs.hash !== "string") {
|
|
52
|
+
return {
|
|
53
|
+
valid: false,
|
|
54
|
+
reason: `receipt predates evidence binding — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
|
|
55
|
+
profile,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (receipt.verdict === "FAIL") {
|
|
60
|
+
return {
|
|
61
|
+
valid: false,
|
|
62
|
+
reason: `the committed receipt is a FAIL (attesting profile: ${profile ?? "unknown"})`,
|
|
63
|
+
profile,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const recomputed = recompute();
|
|
68
|
+
|
|
69
|
+
if (receipt.inputs.hash !== recomputed.hash) {
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
reason: `source changed since the receipt — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
|
|
73
|
+
profile,
|
|
74
|
+
recomputed,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (receipt.verdict !== "PASS") {
|
|
79
|
+
return {
|
|
80
|
+
valid: false,
|
|
81
|
+
reason: `receipt verdict is "${receipt.verdict}", not PASS (attesting profile: ${profile ?? "unknown"})`,
|
|
82
|
+
profile,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Service-grade checks (hosted validators; the local predicate above does
|
|
90
|
+
// not enforce these — the tree it checks is by definition "now") ─────────
|
|
91
|
+
|
|
92
|
+
/** Default policy for hosted validation. Every knob is overridable. */
|
|
93
|
+
export const DEFAULT_POLICY = {
|
|
94
|
+
/** A receipt older than this no longer counts as fresh (hosted check only). */
|
|
95
|
+
maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 days
|
|
96
|
+
/**
|
|
97
|
+
* Executed (non-SKIP) gates must report at least this much total wall time.
|
|
98
|
+
* A "PASS" receipt whose executed gates sum to less cannot attest a real
|
|
99
|
+
* lane run — the tell for replayed/cached or hand-written verdicts
|
|
100
|
+
* (evidence must attest execution, not results).
|
|
101
|
+
*/
|
|
102
|
+
minExecutedMs: 5000,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Freshness: is the receipt's generatedAt within maxAgeMs of `now`?
|
|
107
|
+
* @returns {{ok: boolean, detail: string, ageMs?: number}}
|
|
108
|
+
*/
|
|
109
|
+
export function checkFreshness(receipt, { now = Date.now(), maxAgeMs = DEFAULT_POLICY.maxAgeMs } = {}) {
|
|
110
|
+
const generatedAt = Date.parse(receipt?.generatedAt ?? "");
|
|
111
|
+
if (Number.isNaN(generatedAt)) {
|
|
112
|
+
return { ok: false, detail: "receipt has no parsable generatedAt timestamp" };
|
|
113
|
+
}
|
|
114
|
+
const ageMs = now - generatedAt;
|
|
115
|
+
if (ageMs < -60_000) {
|
|
116
|
+
// A receipt from the future is a clock lie, not a rounding artifact.
|
|
117
|
+
return { ok: false, detail: `receipt claims a future generatedAt (${receipt.generatedAt})`, ageMs };
|
|
118
|
+
}
|
|
119
|
+
if (ageMs > maxAgeMs) {
|
|
120
|
+
const days = Math.floor(ageMs / 86_400_000);
|
|
121
|
+
return { ok: false, detail: `receipt is stale — generated ${days} day(s) ago, older than the ${Math.floor(maxAgeMs / 86_400_000)}-day freshness window`, ageMs };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true, detail: `receipt generated ${receipt.generatedAt}`, ageMs };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Execution plausibility: do the executed (non-SKIP) gates report durations a
|
|
128
|
+
* real lane run could produce? Catches replayed/cached greens and hand-edited
|
|
129
|
+
* receipts whose numbers were never lived.
|
|
130
|
+
* @returns {{ok: boolean, detail: string, executedMs?: number, executedSteps?: number}}
|
|
131
|
+
*/
|
|
132
|
+
export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_POLICY.minExecutedMs } = {}) {
|
|
133
|
+
const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
|
|
134
|
+
if (!steps || steps.length === 0) {
|
|
135
|
+
return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
|
|
136
|
+
}
|
|
137
|
+
const executed = steps.filter((s) => s && s.verdict !== "SKIP");
|
|
138
|
+
if (executed.length === 0) {
|
|
139
|
+
return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
|
|
140
|
+
}
|
|
141
|
+
let total = 0;
|
|
142
|
+
for (const step of executed) {
|
|
143
|
+
if (typeof step.durationMs !== "number" || !Number.isFinite(step.durationMs) || step.durationMs < 0) {
|
|
144
|
+
return { ok: false, detail: `step "${step.name ?? "?"}" reports an invalid duration (${step.durationMs}) — durations must be real, non-negative numbers` };
|
|
145
|
+
}
|
|
146
|
+
total += step.durationMs;
|
|
147
|
+
}
|
|
148
|
+
if (total < minExecutedMs) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
detail: `implausibly fast — executed gates report ${total}ms total, below the ${minExecutedMs}ms floor; a receipt this fast cannot attest a real lane run (evidence must attest execution)`,
|
|
152
|
+
executedMs: total,
|
|
153
|
+
executedSteps: executed.length,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, detail: `${executed.length} executed gate(s), ${total}ms total`, executedMs: total, executedSteps: executed.length };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* List the SKIPped steps with their honest reasons. SKIPs are reported, not
|
|
161
|
+
* failed — green-with-gaps must be visible, never silently equated with
|
|
162
|
+
* fully-verified (or silently punished).
|
|
163
|
+
* @returns {Array<{name: string, reason: string}>}
|
|
164
|
+
*/
|
|
165
|
+
export function listSkippedSteps(receipt) {
|
|
166
|
+
const steps = Array.isArray(receipt?.steps) ? receipt.steps : [];
|
|
167
|
+
return steps
|
|
168
|
+
.filter((s) => s && s.verdict === "SKIP")
|
|
169
|
+
.map((s) => ({ name: s.name ?? "?", reason: s.reason ?? "no reason recorded" }));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The hosted composite: validate the receipt found in an extracted repo tree
|
|
174
|
+
* (e.g. a tarball at a PR's head SHA) with the full service-grade policy.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} args
|
|
177
|
+
* @param {string} args.root absolute path to the extracted tree's project root
|
|
178
|
+
* @param {number} [args.now] epoch ms, for freshness (defaults to Date.now())
|
|
179
|
+
* @param {object} [args.policy] overrides for DEFAULT_POLICY
|
|
180
|
+
* @returns {{
|
|
181
|
+
* status: "missing"|"valid"|"invalid",
|
|
182
|
+
* reason: string,
|
|
183
|
+
* profile?: string,
|
|
184
|
+
* checks: Array<{id: string, ok: boolean, detail: string}>,
|
|
185
|
+
* skips: Array<{name: string, reason: string}>,
|
|
186
|
+
* }}
|
|
187
|
+
*/
|
|
188
|
+
export function validateReceiptForTree({ root, now = Date.now(), policy = {} } = {}) {
|
|
189
|
+
const effective = { ...DEFAULT_POLICY, ...policy };
|
|
190
|
+
const receipt = readReceipt(root);
|
|
191
|
+
|
|
192
|
+
if (receipt === null) {
|
|
193
|
+
return {
|
|
194
|
+
status: "missing",
|
|
195
|
+
reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the create-cmp evidence harness (that is not a failure)`,
|
|
196
|
+
checks: [{ id: "receipt-present", ok: false, detail: `no parsable receipt at ${RECEIPT_REL_PATH}` }],
|
|
197
|
+
skips: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const checks = [{ id: "receipt-present", ok: true, detail: RECEIPT_REL_PATH }];
|
|
202
|
+
const skips = listSkippedSteps(receipt);
|
|
203
|
+
|
|
204
|
+
// The core predicate (binding + verdict + hash), verbatim local semantics.
|
|
205
|
+
const core = evaluateReceipt(receipt, () => computeInputsHash(root));
|
|
206
|
+
checks.push({ id: "binding-and-hash", ok: core.valid, detail: core.reason });
|
|
207
|
+
|
|
208
|
+
// Service-grade extensions run regardless, so a failing receipt reports
|
|
209
|
+
// every violated rule at once (refusals name what failed, all of it).
|
|
210
|
+
const freshness = checkFreshness(receipt, { now, maxAgeMs: effective.maxAgeMs });
|
|
211
|
+
checks.push({ id: "freshness", ok: freshness.ok, detail: freshness.detail });
|
|
212
|
+
|
|
213
|
+
const plausibility = checkExecutionPlausibility(receipt, { minExecutedMs: effective.minExecutedMs });
|
|
214
|
+
checks.push({ id: "execution-plausibility", ok: plausibility.ok, detail: plausibility.detail });
|
|
215
|
+
|
|
216
|
+
const failed = checks.filter((c) => !c.ok);
|
|
217
|
+
if (failed.length > 0) {
|
|
218
|
+
return {
|
|
219
|
+
status: "invalid",
|
|
220
|
+
reason: failed.map((c) => c.detail).join("; "),
|
|
221
|
+
profile: core.profile,
|
|
222
|
+
checks,
|
|
223
|
+
skips,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
status: "valid",
|
|
229
|
+
reason: core.reason,
|
|
230
|
+
profile: core.profile,
|
|
231
|
+
checks,
|
|
232
|
+
skips,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -20,9 +20,9 @@ import path from "node:path";
|
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
21
|
|
|
22
22
|
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
23
|
+
import { evaluateReceipt, readReceipt } from "./lib/receipt-validate.mjs";
|
|
23
24
|
|
|
24
25
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
-
const RECEIPT_PATH = path.join(ROOT, "qa", "evidence", "latest.json");
|
|
26
26
|
|
|
27
27
|
const args = process.argv.slice(2);
|
|
28
28
|
const asHook = args.includes("--hook");
|
|
@@ -38,52 +38,15 @@ function readStdinJson() {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// The predicate itself lives in qa/lib/receipt-validate.mjs (vendored from the
|
|
42
|
+
// cmp-receipts package — one definition everywhere a receipt is judged); this
|
|
43
|
+
// CLI only reads the receipt and frames the exit codes.
|
|
41
44
|
function evaluate() {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
receipt = JSON.parse(fs.readFileSync(RECEIPT_PATH, "utf8"));
|
|
45
|
-
} catch {
|
|
45
|
+
const receipt = readReceipt(ROOT);
|
|
46
|
+
if (receipt === null) {
|
|
46
47
|
return { valid: false, reason: "no receipt — run `node qa/verify.mjs`", profile: undefined };
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
-
const profile = receipt.profile;
|
|
50
|
-
|
|
51
|
-
if (!receipt.inputs || typeof receipt.inputs.hash !== "string") {
|
|
52
|
-
return {
|
|
53
|
-
valid: false,
|
|
54
|
-
reason: `receipt predates evidence binding — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
|
|
55
|
-
profile,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (receipt.verdict === "FAIL") {
|
|
60
|
-
return {
|
|
61
|
-
valid: false,
|
|
62
|
-
reason: `the committed receipt is a FAIL (attesting profile: ${profile ?? "unknown"})`,
|
|
63
|
-
profile,
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const recomputed = computeInputsHash(ROOT);
|
|
68
|
-
|
|
69
|
-
if (receipt.inputs.hash !== recomputed.hash) {
|
|
70
|
-
return {
|
|
71
|
-
valid: false,
|
|
72
|
-
reason: `source changed since the receipt — re-run the lane (attesting profile: ${profile ?? "unknown"})`,
|
|
73
|
-
profile,
|
|
74
|
-
recomputed,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (receipt.verdict !== "PASS") {
|
|
79
|
-
return {
|
|
80
|
-
valid: false,
|
|
81
|
-
reason: `receipt verdict is "${receipt.verdict}", not PASS (attesting profile: ${profile ?? "unknown"})`,
|
|
82
|
-
profile,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
|
|
49
|
+
return evaluateReceipt(receipt, () => computeInputsHash(ROOT));
|
|
87
50
|
}
|
|
88
51
|
|
|
89
52
|
const result = evaluate();
|
|
@@ -212,7 +212,7 @@ const ALL_FILES = [
|
|
|
212
212
|
{ from: path.join(SRC("commonMain"), "domain/usecase/GetItemsUseCase.kt"), to: path.join(SRC("commonMain"), `domain/usecase/Get${E}sUseCase.kt`), presets: ["feature", "repository"] },
|
|
213
213
|
{ from: path.join(SRC("commonMain"), "data/remote/ItemRepositoryImpl.kt"), to: path.join(SRC("commonMain"), `data/remote/${E}RepositoryImpl.kt`), presets: ["feature", "repository"] },
|
|
214
214
|
{ from: path.join(SRC("commonTest"), "testing/fakes/FakeItemRepository.kt"), to: path.join(SRC("commonTest"), `testing/fakes/Fake${E}Repository.kt`), presets: ["feature", "repository"] },
|
|
215
|
-
{ from: path.join(SRC("commonMain"), "presentation/home/HomeScreen.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"] },
|
|
215
|
+
{ from: path.join(SRC("commonMain"), "presentation/home/HomeScreen.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}Screen.kt`), presets: ["feature", "screen"], wrapInBaseScreen: true },
|
|
216
216
|
{ from: path.join(SRC("commonMain"), "presentation/home/HomeViewModel.kt"), to: path.join(SRC("commonMain"), `presentation/${f}/${F}ViewModel.kt`), presets: ["feature", "screen"] },
|
|
217
217
|
{ from: path.join(SRC("commonTest"), "presentation/home/HomeViewModelTest.kt"), to: path.join(SRC("commonTest"), `presentation/${f}/${F}ViewModelTest.kt`), presets: ["feature", "screen"] },
|
|
218
218
|
{ from: path.join(SRC("desktopTest"), "presentation/home/HomeScreenTest.kt"), to: path.join(SRC("desktopTest"), `presentation/${f}/${F}ScreenTest.kt`), presets: ["feature", "screen"] },
|
|
@@ -292,6 +292,69 @@ function defaultSpec() {
|
|
|
292
292
|
`;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
// ── BaseScreen wrap (SHELL-05) ───────────────────────────────────────────────
|
|
296
|
+
// HomeScreen is a TAB — AppShell provides its BaseScreen at the shell layer.
|
|
297
|
+
// The stamped feature, however, is registered as a PUSHED NavHost destination,
|
|
298
|
+
// and SHELL-05 requires every such destination to compose inside BaseScreen
|
|
299
|
+
// (see DetailScreen for the pattern). Without this transform the stamped slice
|
|
300
|
+
// fails verify out of the box. Anchored on the exemplar's known shape; fails
|
|
301
|
+
// loudly if HomeScreen drifts (same discipline as the cmp:anchor markers).
|
|
302
|
+
function wrapScreenInBaseScreen(content, relPathForErrors) {
|
|
303
|
+
if (content.includes("BaseScreen")) return content; // already wrapped — idempotent
|
|
304
|
+
|
|
305
|
+
const lines = content.split("\n");
|
|
306
|
+
|
|
307
|
+
// 1. Import — mirror DetailScreen's ordering: presentation.components.BaseScreen
|
|
308
|
+
// sits immediately before the presentation.theme imports.
|
|
309
|
+
const themeImportIdx = lines.findIndex((l) => /^import .+\.presentation\.theme\./.test(l));
|
|
310
|
+
if (themeImportIdx === -1) {
|
|
311
|
+
die(
|
|
312
|
+
`no presentation.theme import found in ${relPathForErrors} — the HomeScreen exemplar ` +
|
|
313
|
+
"drifted from the shape this stamper wraps; cannot place the BaseScreen import.",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const importLine = lines[themeImportIdx].replace(
|
|
317
|
+
/^import (.+)\.presentation\.theme\..*$/,
|
|
318
|
+
"import $1.presentation.components.BaseScreen",
|
|
319
|
+
);
|
|
320
|
+
lines.splice(themeImportIdx, 0, importLine);
|
|
321
|
+
|
|
322
|
+
// 2. Root container start: the exemplar's body root is a top-level ` Column(`.
|
|
323
|
+
const rootIdx = lines.findIndex((l) => l === " Column(");
|
|
324
|
+
if (rootIdx === -1) {
|
|
325
|
+
die(
|
|
326
|
+
`root " Column(" not found in ${relPathForErrors} — the HomeScreen exemplar drifted ` +
|
|
327
|
+
"from the shape this stamper wraps in BaseScreen.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// 3. Root container end: the ` }` immediately before the function's closing `}`.
|
|
332
|
+
let funCloseIdx = -1;
|
|
333
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
334
|
+
if (lines[i] === "}") {
|
|
335
|
+
funCloseIdx = i;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (funCloseIdx === -1 || lines[funCloseIdx - 1] !== " }") {
|
|
340
|
+
die(
|
|
341
|
+
`could not locate the root container's closing brace in ${relPathForErrors} — the ` +
|
|
342
|
+
"HomeScreen exemplar drifted from the shape this stamper wraps in BaseScreen.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const rootCloseIdx = funCloseIdx - 1;
|
|
346
|
+
|
|
347
|
+
// 4. Wrap: indent the container block one level and enclose it in BaseScreen { }.
|
|
348
|
+
const indented = lines.slice(rootIdx, rootCloseIdx + 1).map((l) => (l.length ? ` ${l}` : l));
|
|
349
|
+
return [
|
|
350
|
+
...lines.slice(0, rootIdx),
|
|
351
|
+
" BaseScreen {",
|
|
352
|
+
...indented,
|
|
353
|
+
" }",
|
|
354
|
+
...lines.slice(rootCloseIdx + 1),
|
|
355
|
+
].join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
295
358
|
// ── Anchor injection (§5) ────────────────────────────────────────────────────
|
|
296
359
|
// Idempotent (skip if the feature's line is already present); fails loudly if
|
|
297
360
|
// an anchor marker is missing from the shared file. Each function is a pure
|
|
@@ -452,6 +515,11 @@ if (dryRun) {
|
|
|
452
515
|
for (const line of inj.diff.split("\n").filter(Boolean)) console.log(` + ${line}`);
|
|
453
516
|
}
|
|
454
517
|
}
|
|
518
|
+
if (FILES.some((file) => file.wrapInBaseScreen)) {
|
|
519
|
+
console.log(
|
|
520
|
+
`\n${F}Screen.kt is stamped wrapped in BaseScreen (SHELL-05 — pushed destinations wrap their own content).`,
|
|
521
|
+
);
|
|
522
|
+
}
|
|
455
523
|
if (writesSpec) {
|
|
456
524
|
console.log(`\nspecs/${f}.spec.md will be written with default clauses ${F_UPPER}-01..06.`);
|
|
457
525
|
} else {
|
|
@@ -465,7 +533,12 @@ if (dryRun) {
|
|
|
465
533
|
|
|
466
534
|
let filesWritten = 0;
|
|
467
535
|
for (const file of FILES) {
|
|
468
|
-
|
|
536
|
+
let contents = file.isDefaultSpec ? defaultSpec() : applyRename(fs.readFileSync(file.from, "utf8"));
|
|
537
|
+
if (file.wrapInBaseScreen) {
|
|
538
|
+
// Pushed destination: wrap the cloned tab-screen body so SHELL-05 passes
|
|
539
|
+
// out of the box (the tab exemplar relies on AppShell for its BaseScreen).
|
|
540
|
+
contents = wrapScreenInBaseScreen(contents, path.relative(ROOT, file.to));
|
|
541
|
+
}
|
|
469
542
|
fs.mkdirSync(path.dirname(file.to), { recursive: true });
|
|
470
543
|
fs.writeFileSync(file.to, contents);
|
|
471
544
|
filesWritten += 1;
|