create-cmp-cli 0.13.0 → 0.14.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/package.json +6 -2
- package/packages/harness/package.json +38 -0
- package/packages/harness/src/approve.mjs +247 -0
- package/packages/harness/src/arch-doc.mjs +69 -0
- package/packages/harness/src/comment.mjs +76 -0
- package/packages/harness/src/lib/a11y.mjs +113 -0
- package/packages/harness/src/lib/affected-tests.mjs +147 -0
- package/packages/harness/src/lib/approvals.mjs +1403 -0
- package/packages/harness/src/lib/arch-doc.mjs +451 -0
- package/packages/harness/src/lib/audit-cadence.mjs +290 -0
- package/packages/harness/src/lib/comments.mjs +252 -0
- package/packages/harness/src/lib/component-stories.mjs +183 -0
- package/packages/harness/src/lib/determinism.mjs +179 -0
- package/packages/harness/src/lib/device-lease.mjs +249 -0
- package/packages/harness/src/lib/evidence-badge.mjs +158 -0
- package/packages/harness/src/lib/evidence-level.mjs +117 -0
- package/packages/harness/src/lib/feature-brief.mjs +324 -0
- package/packages/harness/src/lib/flight-recorder.mjs +332 -0
- package/packages/harness/src/lib/harness-lock.mjs +147 -0
- package/packages/harness/src/lib/harness-region.mjs +159 -0
- package/packages/harness/src/lib/inputs-hash.mjs +194 -0
- package/packages/harness/src/lib/reachability.mjs +211 -0
- package/packages/harness/src/lib/receipt-validate.mjs +234 -0
- package/packages/harness/src/lib/render.mjs +254 -0
- package/packages/harness/src/lib/spec-coverage.mjs +131 -0
- package/packages/harness/src/lib/step-cache.mjs +221 -0
- package/packages/harness/src/lib/token-drift.mjs +94 -0
- package/packages/harness/src/lib/tree.mjs +108 -0
- package/packages/harness/src/preview-gallery.mjs +122 -0
- package/packages/harness/src/receipt-check.mjs +96 -0
- package/packages/harness/src/record-audit.mjs +83 -0
- package/packages/harness/src/refusal-demo.mjs +498 -0
- package/packages/harness/src/retrospective.mjs +51 -0
- package/packages/harness/src/scaffold-feature.mjs +723 -0
- package/packages/harness/src/setup-hooks.mjs +33 -0
- package/packages/harness/src/verify.mjs +1709 -0
- package/packages/harness/src/walkthrough.mjs +499 -0
- package/packages/harness/src/watch.mjs +622 -0
- package/packages/receipts/package.json +36 -0
- package/packages/receipts/src/index.mjs +16 -0
- package/packages/receipts/src/inputs-hash.mjs +194 -0
- package/packages/receipts/src/receipt-validate.mjs +234 -0
- package/src/commands/upgrade.mjs +96 -0
- package/src/lib/harness-upgrade.mjs +159 -2
- package/src/scaffold.mjs +60 -1
- package/template/AGENTS.md +5 -0
- package/template/CLAUDE.md +30 -0
- package/template/gitignore +8 -0
- package/template/qa/lib/harness-lock.mjs +147 -0
- package/template/qa/lib/harness-region.mjs +159 -0
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/preview-gallery.mjs +17 -2
- package/template/qa/verify.mjs +95 -1
|
@@ -42,6 +42,7 @@ import { spawnSync } from "node:child_process";
|
|
|
42
42
|
import { listFiles } from "./fsutil.mjs";
|
|
43
43
|
import { isBinaryPath } from "./tokens.mjs";
|
|
44
44
|
import { BACKUP_SUFFIX } from "./upgrade.mjs";
|
|
45
|
+
import { isHarnessFile } from "../../packages/harness/src/lib/harness-region.mjs";
|
|
45
46
|
|
|
46
47
|
/** Sidecar suffix for the new engine content beside a conflicted file. */
|
|
47
48
|
export const SIDECAR_SUFFIX = ".cmp-new";
|
|
@@ -62,6 +63,10 @@ export const EXCLUDED_PATTERNS = [
|
|
|
62
63
|
"create-cmp.json",
|
|
63
64
|
"qa/evidence/**",
|
|
64
65
|
"qa/approvals.json",
|
|
66
|
+
// Derived state, rewritten explicitly once the region has landed. Sweeping
|
|
67
|
+
// it would copy a stale manifest in, back up a value that was about to be
|
|
68
|
+
// replaced anyway, and count a guaranteed no-op as actionable work.
|
|
69
|
+
"qa/harness.lock.json",
|
|
65
70
|
"qa/comments.json",
|
|
66
71
|
"qa/golden/**",
|
|
67
72
|
".git/**",
|
|
@@ -172,7 +177,117 @@ export function mergeThreeWay(theirs, base, next) {
|
|
|
172
177
|
* @returns {{bucket:string, write:Buffer|null, sidecar:Buffer|null, remove:boolean}|null}
|
|
173
178
|
* null when the path is in neither base nor new (app-authored — invisible)
|
|
174
179
|
*/
|
|
180
|
+
|
|
181
|
+
/** Where a preserved local patch to machine-owned lane code is written. */
|
|
182
|
+
export const LOCAL_PATCH_PATH = "qa/harness-local.patch";
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Unified diff from `base` to `theirs`, rewritten so it applies against the
|
|
186
|
+
* project tree (`git apply qa/harness-local.patch`). git diff --no-index exits
|
|
187
|
+
* 1 when there ARE differences, which is the only case we call it in.
|
|
188
|
+
* @param {Buffer} base
|
|
189
|
+
* @param {Buffer} theirs
|
|
190
|
+
* @param {string} relPath project-relative path the patch should name
|
|
191
|
+
* @returns {string} patch text, or "" if git could not produce one
|
|
192
|
+
*/
|
|
193
|
+
export function diffPatch(base, theirs, relPath) {
|
|
194
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-harness-diff-"));
|
|
195
|
+
try {
|
|
196
|
+
const b = path.join(dir, "base");
|
|
197
|
+
const t = path.join(dir, "theirs");
|
|
198
|
+
fs.writeFileSync(b, base);
|
|
199
|
+
fs.writeFileSync(t, theirs);
|
|
200
|
+
const r = spawnSync("git", ["diff", "--no-index", "--no-color", "--", b, t], {
|
|
201
|
+
encoding: "utf8",
|
|
202
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
203
|
+
});
|
|
204
|
+
// 0 = identical (we never call it then), 1 = differences, >1 = trouble.
|
|
205
|
+
if (r.error || (r.status !== 0 && r.status !== 1) || !r.stdout) return "";
|
|
206
|
+
return r.stdout
|
|
207
|
+
.split("\n")
|
|
208
|
+
.map((line) => {
|
|
209
|
+
if (line.startsWith("diff --git ")) return `diff --git a/${relPath} b/${relPath}`;
|
|
210
|
+
if (line.startsWith("--- ")) return `--- a/${relPath}`;
|
|
211
|
+
if (line.startsWith("+++ ")) return `+++ b/${relPath}`;
|
|
212
|
+
return line;
|
|
213
|
+
})
|
|
214
|
+
.join("\n");
|
|
215
|
+
} finally {
|
|
216
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The decision table for MACHINE-OWNED lane files — a different question from
|
|
222
|
+
* the one decideFile asks about app-shaped files.
|
|
223
|
+
*
|
|
224
|
+
* Lane code is byte-identical in every create-cmp app and carries no app
|
|
225
|
+
* content, so it is a derived artifact, and the right operation on a derived
|
|
226
|
+
* artifact is REPLACE, not merge. Three-way merging it is what made upgrades
|
|
227
|
+
* expensive: the 0.13.0 pilots produced ~1,000 conflicted lines per app whose
|
|
228
|
+
* diffs contained zero app-specific tokens.
|
|
229
|
+
*
|
|
230
|
+
* The region is therefore always taken to the new engine's content. What
|
|
231
|
+
* varies is how honestly we account for what the app had:
|
|
232
|
+
*
|
|
233
|
+
* region-current already the new content → silent
|
|
234
|
+
* region-clean untouched since stamp → replace
|
|
235
|
+
* region-absorbed locally edited, but the edit is ALREADY in the new
|
|
236
|
+
* content (the app hand-mirrored engine work — the
|
|
237
|
+
* pilots' overwhelmingly common case) → replace, silent
|
|
238
|
+
* region-patched a genuine local fork of lane code → replace, AND
|
|
239
|
+
* preserve base→theirs as a patch so nothing is lost
|
|
240
|
+
* added / removed as for any other file
|
|
241
|
+
*
|
|
242
|
+
* "region-patched" deliberately does NOT block or merge. A local edit to lane
|
|
243
|
+
* code is a fork the app is maintaining; making it explicit (a reviewable
|
|
244
|
+
* patch file plus a loud report) is better than the status quo, where the
|
|
245
|
+
* divergence was invisible until it cost a thousand hand-resolved lines at
|
|
246
|
+
* the next upgrade.
|
|
247
|
+
*
|
|
248
|
+
* @param {object} params
|
|
249
|
+
* @param {string} params.relPath
|
|
250
|
+
* @param {Buffer|null} params.base
|
|
251
|
+
* @param {Buffer|null} params.next
|
|
252
|
+
* @param {Buffer|null} params.theirs
|
|
253
|
+
* @param {Function} [params.merge] injectable three-way merge (classifier only)
|
|
254
|
+
* @returns {{bucket:string, write:Buffer|null, sidecar:null, remove:boolean, patch?:string}|null}
|
|
255
|
+
*/
|
|
256
|
+
export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
|
|
257
|
+
const eq = (a, b) => a !== null && b !== null && a.equals(b);
|
|
258
|
+
const replace = (bucket, extra = {}) => ({
|
|
259
|
+
bucket,
|
|
260
|
+
write: next,
|
|
261
|
+
sidecar: null,
|
|
262
|
+
remove: false,
|
|
263
|
+
...extra,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
if (next === null) {
|
|
267
|
+
// The engine dropped this lane file. Nothing app-owned can live here, so
|
|
268
|
+
// there is no "orphaned" case to protect — remove it.
|
|
269
|
+
if (theirs === null) return { bucket: "current", write: null, sidecar: null, remove: false };
|
|
270
|
+
return { bucket: "region-removed", write: null, sidecar: null, remove: true };
|
|
271
|
+
}
|
|
272
|
+
if (theirs === null) return replace(base === null ? "added" : "region-restored");
|
|
273
|
+
if (eq(theirs, next)) return { bucket: "current", write: null, sidecar: null, remove: false };
|
|
274
|
+
if (base === null) return replace("region-patched", { patch: "" }); // no base to diff against
|
|
275
|
+
if (eq(theirs, base)) return replace("region-clean");
|
|
276
|
+
|
|
277
|
+
// The app edited lane code. Is that edit already carried by the new engine?
|
|
278
|
+
// A clean three-way merge landing exactly on `next` means the local change
|
|
279
|
+
// contributed nothing beyond it — the hand-mirror case.
|
|
280
|
+
const m = merge(theirs, base, next);
|
|
281
|
+
if (m.clean && m.content !== null && m.content.equals(next)) return replace("region-absorbed");
|
|
282
|
+
|
|
283
|
+
return replace("region-patched", { patch: diffPatch(base, theirs, relPath) });
|
|
284
|
+
}
|
|
285
|
+
|
|
175
286
|
export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
|
|
287
|
+
// Machine-owned lane files answer a different question — see decideRegionFile.
|
|
288
|
+
if (isHarnessFile(relPath) && !(base === null && next === null)) {
|
|
289
|
+
return decideRegionFile({ relPath, base, next, theirs, merge });
|
|
290
|
+
}
|
|
176
291
|
const none = (bucket) => ({ bucket, write: null, sidecar: null, remove: false });
|
|
177
292
|
const write = (bucket, content) => ({ bucket, write: content, sidecar: null, remove: false });
|
|
178
293
|
const conflict = () => ({ bucket: "conflicted", write: null, sidecar: next, remove: false });
|
|
@@ -255,6 +370,12 @@ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeT
|
|
|
255
370
|
added: 0,
|
|
256
371
|
removed: 0,
|
|
257
372
|
orphaned: 0,
|
|
373
|
+
// Machine-owned lane buckets (decideRegionFile).
|
|
374
|
+
"region-clean": 0,
|
|
375
|
+
"region-absorbed": 0,
|
|
376
|
+
"region-patched": 0,
|
|
377
|
+
"region-restored": 0,
|
|
378
|
+
"region-removed": 0,
|
|
258
379
|
};
|
|
259
380
|
const entries = [];
|
|
260
381
|
for (const relPath of [...rels].sort()) {
|
|
@@ -271,7 +392,7 @@ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeT
|
|
|
271
392
|
merge,
|
|
272
393
|
});
|
|
273
394
|
if (decision === null) continue;
|
|
274
|
-
counts[decision.bucket]
|
|
395
|
+
counts[decision.bucket] = (counts[decision.bucket] ?? 0) + 1;
|
|
275
396
|
entries.push({ relPath, ...decision });
|
|
276
397
|
}
|
|
277
398
|
return { entries, counts };
|
|
@@ -299,7 +420,13 @@ export function applyHarnessPlan(projectDir, entries) {
|
|
|
299
420
|
const deleted = [];
|
|
300
421
|
const sidecars = [];
|
|
301
422
|
const backups = [];
|
|
423
|
+
const patched = [];
|
|
424
|
+
const patchChunks = [];
|
|
302
425
|
for (const e of entries) {
|
|
426
|
+
if (e.bucket === "region-patched" && e.patch) {
|
|
427
|
+
patched.push(e.relPath);
|
|
428
|
+
patchChunks.push(e.patch.endsWith("\n") ? e.patch : `${e.patch}\n`);
|
|
429
|
+
}
|
|
303
430
|
const abs = path.join(projectDir, e.relPath);
|
|
304
431
|
if (e.sidecar !== null && e.sidecar !== undefined) {
|
|
305
432
|
const sidecarPath = abs + SIDECAR_SUFFIX;
|
|
@@ -328,7 +455,37 @@ export function applyHarnessPlan(projectDir, entries) {
|
|
|
328
455
|
}
|
|
329
456
|
}
|
|
330
457
|
}
|
|
331
|
-
|
|
458
|
+
// One patch file for the whole run — the app's genuine divergence from the
|
|
459
|
+
// lane, preserved rather than discarded. Written only when there is
|
|
460
|
+
// something to preserve, and only after the writes above succeeded.
|
|
461
|
+
let patchPath = null;
|
|
462
|
+
if (patchChunks.length > 0) {
|
|
463
|
+
const abs = path.join(projectDir, LOCAL_PATCH_PATH);
|
|
464
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
465
|
+
fs.writeFileSync(
|
|
466
|
+
abs,
|
|
467
|
+
`# Local changes to machine-owned lane code, preserved by \`create-cmp upgrade --harness\`.\n` +
|
|
468
|
+
`#\n` +
|
|
469
|
+
`# The lane was replaced with the new engine's version. These edits were NOT\n` +
|
|
470
|
+
`# re-applied — a local change to lane code is a fork this app is maintaining,\n` +
|
|
471
|
+
`# and re-applying it silently would hide that.\n` +
|
|
472
|
+
`#\n` +
|
|
473
|
+
`# This diff is against the lane you WERE on, so it may not apply cleanly to\n` +
|
|
474
|
+
`# the new one. That is not a defect in the patch: it is the merge this tool\n` +
|
|
475
|
+
`# deliberately declined to do behind your back. To attempt it:\n` +
|
|
476
|
+
`#\n` +
|
|
477
|
+
`# git apply --reject ${LOCAL_PATCH_PATH}\n` +
|
|
478
|
+
`#\n` +
|
|
479
|
+
`# What applies, applies; the rest lands in *.rej for you to judge. If it does\n` +
|
|
480
|
+
`# conflict, that IS the finding — the engine has moved under this fork.\n` +
|
|
481
|
+
`#\n` +
|
|
482
|
+
`# Better still: upstream the change so the next upgrade carries it for you.\n` +
|
|
483
|
+
`# Delete this file once you have decided.\n\n` +
|
|
484
|
+
patchChunks.join("\n"),
|
|
485
|
+
);
|
|
486
|
+
patchPath = LOCAL_PATCH_PATH;
|
|
487
|
+
}
|
|
488
|
+
return { written, created, deleted, sidecars, backups, patched, patchPath };
|
|
332
489
|
}
|
|
333
490
|
|
|
334
491
|
/**
|
package/src/scaffold.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
20
20
|
import { validate, formatErrors } from "./lib/schema.mjs";
|
|
21
21
|
import { validatePackageName } from "./lib/package-name.mjs";
|
|
22
22
|
import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
|
|
23
|
+
import { isHarnessFile, listHarnessFiles } from "../packages/harness/src/lib/harness-region.mjs";
|
|
24
|
+
import { writeHarnessLock } from "../packages/harness/src/lib/harness-lock.mjs";
|
|
23
25
|
import { renamePackageDirs } from "./lib/rename.mjs";
|
|
24
26
|
import {
|
|
25
27
|
stripFeatureBlocks,
|
|
@@ -78,7 +80,22 @@ export function loadManifest(templateDir) {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
/**
|
|
81
|
-
* Apply token replacement to every text file's CONTENT under projectDir
|
|
83
|
+
* Apply token replacement to every text file's CONTENT under projectDir —
|
|
84
|
+
* except the machine-owned harness region, which is COPIED, never stamped.
|
|
85
|
+
*
|
|
86
|
+
* The lane carries no app content, so there is nothing in it to stamp; running
|
|
87
|
+
* it through token replacement only ever corrupted it. Two live examples the
|
|
88
|
+
* region rule retires: qa/lib/approvals.mjs had to detect unresolved tokens by
|
|
89
|
+
* SHAPE because writing the literal `__PACKAGE__` in its own source got
|
|
90
|
+
* rewritten out from under it, and qa/scaffold-feature.mjs shipped an error
|
|
91
|
+
* message that meant to name the unresolved token and instead named the app's
|
|
92
|
+
* real package ("found com.acme.demo unresolved"). Anything app-specific the
|
|
93
|
+
* lane needs is read at RUNTIME from create-cmp.json.
|
|
94
|
+
*
|
|
95
|
+
* Skipping the region is also what makes it content-hashable: every stamped
|
|
96
|
+
* app carries byte-identical lane files, so a receipt can name the exact lane
|
|
97
|
+
* version that issued it and an app can prove its copy is unmodified offline.
|
|
98
|
+
* test/harness-parity.test.mjs pins that byte-equality through the scaffold.
|
|
82
99
|
*/
|
|
83
100
|
function replaceContents(projectDir, tokenMap, manifestRel) {
|
|
84
101
|
for (const file of listFiles(projectDir)) {
|
|
@@ -86,6 +103,7 @@ function replaceContents(projectDir, tokenMap, manifestRel) {
|
|
|
86
103
|
if (manifestRel && path.resolve(file) === path.resolve(path.join(projectDir, manifestRel))) {
|
|
87
104
|
continue;
|
|
88
105
|
}
|
|
106
|
+
if (isHarnessFile(path.relative(projectDir, file).split(path.sep).join("/"))) continue;
|
|
89
107
|
if (isBinaryPath(file)) continue;
|
|
90
108
|
let content;
|
|
91
109
|
try {
|
|
@@ -255,6 +273,41 @@ function applyAppNameSlug(projectDir, appName) {
|
|
|
255
273
|
* @param {string} projectDir
|
|
256
274
|
* @param {object} config validated engine config
|
|
257
275
|
*/
|
|
276
|
+
/**
|
|
277
|
+
* The harness version this engine ships — read from the package that owns the
|
|
278
|
+
* lane, NOT from the engine's own package.json. The two version independently:
|
|
279
|
+
* the lane changes far more often than the template's app shape, and fusing
|
|
280
|
+
* them is what forced an app-shape merge every time a lane fix shipped.
|
|
281
|
+
* @returns {string} semver, or "unknown" if the manifest is unreadable
|
|
282
|
+
*/
|
|
283
|
+
function harnessVersion() {
|
|
284
|
+
try {
|
|
285
|
+
return JSON.parse(
|
|
286
|
+
fs.readFileSync(path.join(REPO_ROOT, "packages/harness/package.json"), "utf8")
|
|
287
|
+
).version;
|
|
288
|
+
} catch {
|
|
289
|
+
return "unknown"; // best-effort — never fail the stamp over version metadata
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Record which lane this app carries, and a sha256 per machine-owned file, so
|
|
295
|
+
* the lane can prove offline on every run that it is unmodified since stamp.
|
|
296
|
+
* Runs LAST, after stamping and feature-stripping, so it hashes exactly the
|
|
297
|
+
* bytes the app will ship with.
|
|
298
|
+
* @param {string} projectDir
|
|
299
|
+
*/
|
|
300
|
+
function writeLaneLock(projectDir) {
|
|
301
|
+
const version = harnessVersion();
|
|
302
|
+
if (version === "unknown") return; // nothing honest to record
|
|
303
|
+
// No lane, no lock. A config that strips the harness (or a template that
|
|
304
|
+
// never carried one) must not get a manifest describing an empty region —
|
|
305
|
+
// it would attest nothing while creating the very qa/ directory the strip
|
|
306
|
+
// just removed.
|
|
307
|
+
if (listHarnessFiles(projectDir).length === 0) return;
|
|
308
|
+
writeHarnessLock(projectDir, { version });
|
|
309
|
+
}
|
|
310
|
+
|
|
258
311
|
function writeSpecOfRecord(projectDir, config) {
|
|
259
312
|
let engineVersion = "unknown";
|
|
260
313
|
try {
|
|
@@ -436,6 +489,12 @@ export async function scaffold(config, opts = {}) {
|
|
|
436
489
|
// are visible spec changes, not drift.
|
|
437
490
|
writeSpecOfRecord(projectDir, config);
|
|
438
491
|
|
|
492
|
+
// Lock the lane LAST — after stamping, feature-stripping and every other
|
|
493
|
+
// mutation — so the manifest hashes exactly the bytes this app will ship
|
|
494
|
+
// with. From here the app can prove offline, on every lane run, that its
|
|
495
|
+
// harness is the one it was given.
|
|
496
|
+
writeLaneLock(projectDir);
|
|
497
|
+
|
|
439
498
|
ok("Scaffold complete.");
|
|
440
499
|
|
|
441
500
|
// (f) verify gate
|
package/template/AGENTS.md
CHANGED
|
@@ -6,3 +6,8 @@ device-free **UI feedback loop** (render every real screen headlessly and see ex
|
|
|
6
6
|
what your edit changed) — lives in [CLAUDE.md](./CLAUDE.md).
|
|
7
7
|
|
|
8
8
|
Read CLAUDE.md before making changes. It applies to every coding agent, not only Claude.
|
|
9
|
+
|
|
10
|
+
One rule worth knowing before you touch anything: the `.mjs` files directly under `qa/`
|
|
11
|
+
and `qa/lib/` are **machine-owned harness code**, byte-identical in every create-cmp app
|
|
12
|
+
and hash-locked by `qa/harness.lock.json`. Editing them fails the lane's first step. Fix
|
|
13
|
+
the engine upstream instead — see "The lane is not yours to edit" in CLAUDE.md.
|
package/template/CLAUDE.md
CHANGED
|
@@ -112,6 +112,36 @@ Commit it with your change; git history is the audit ledger. Binary artifacts un
|
|
|
112
112
|
page reconstructs the full audit trail from the git log of `latest.json` — every commit is
|
|
113
113
|
one verified, attributed state — so committing each receipt is what builds the record.
|
|
114
114
|
|
|
115
|
+
## The lane is not yours to edit
|
|
116
|
+
|
|
117
|
+
Every `.mjs` file directly under `qa/` and `qa/lib/` is **machine-owned**: harness code
|
|
118
|
+
that is byte-identical in every create-cmp app and carries no app content at all. It
|
|
119
|
+
belongs to `create-cmp-harness`, versioned independently of the engine that stamped this
|
|
120
|
+
app's shape, and `qa/harness.lock.json` records a sha256 of every one of those files.
|
|
121
|
+
|
|
122
|
+
`node qa/verify.mjs` checks that lock first, on every run. Editing lane code fails the
|
|
123
|
+
`harnessIntegrity` step and names the file — because a lane that has been modified cannot
|
|
124
|
+
honestly vouch for itself. Without that check the receipt was unfalsifiable in one
|
|
125
|
+
specific way: force every step to PASS in `qa/verify.mjs` and the receipt still validated,
|
|
126
|
+
since the edited file was simply part of the hashed input surface.
|
|
127
|
+
|
|
128
|
+
**So: do not edit `qa/*.mjs` or `qa/lib/*.mjs`.** If the lane is wrong, the fix is
|
|
129
|
+
upstream in the engine, not here. If you genuinely must fork it, know that
|
|
130
|
+
`npx create-cmp-cli upgrade --harness` will replace the region and preserve your edits as
|
|
131
|
+
`qa/harness-local.patch` for you to re-apply or upstream — nothing is lost, but the fork
|
|
132
|
+
stops being invisible.
|
|
133
|
+
|
|
134
|
+
Everything else under `qa/` **is** yours: `approvals.json`, `comments.json`, `golden/`,
|
|
135
|
+
`evidence/`, and `e2e/*.yaml` (seeded once at stamp time, app-owned forever after). So is
|
|
136
|
+
`specs/`, and so is every line under `composeApp/src/`.
|
|
137
|
+
|
|
138
|
+
Upgrading the lane is safe to do unattended — it touches no app content and no signed
|
|
139
|
+
artifact:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
npx create-cmp-cli upgrade --harness
|
|
143
|
+
```
|
|
144
|
+
|
|
115
145
|
## Approvals — governed artifacts need a human's sign-off
|
|
116
146
|
|
|
117
147
|
Some artifacts are **governed**: a human approves them, and the approval is bound to the
|
package/template/gitignore
CHANGED
|
@@ -26,3 +26,11 @@ iosApp/*.xcworkspace/
|
|
|
26
26
|
iosApp/build/
|
|
27
27
|
xcuserdata/
|
|
28
28
|
*.xcuserstate
|
|
29
|
+
|
|
30
|
+
# `create-cmp upgrade`'s pre-write backups. In a git repo the previous commit IS
|
|
31
|
+
# the backup, so these are redundant the moment they are created — and committing
|
|
32
|
+
# them alongside the upgrade puts a stale copy of every touched file in history.
|
|
33
|
+
# Kept on disk (an upgrade run in a dirty or non-git tree still needs them),
|
|
34
|
+
# ignored by git. Delete them once you have reviewed the upgrade's diff.
|
|
35
|
+
*.bak-upgrade
|
|
36
|
+
*.cmp-new
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// qa/harness.lock.json — which lane this app carries, and whether it is intact.
|
|
2
|
+
//
|
|
3
|
+
// The lock is written at stamp time and rewritten by `create-cmp upgrade
|
|
4
|
+
// --harness`. It names the harness version and records a sha256 per
|
|
5
|
+
// machine-owned file, so two different questions get two different answers:
|
|
6
|
+
//
|
|
7
|
+
// INTEGRITY "is my lane unmodified since it was installed?"
|
|
8
|
+
// Answered LOCALLY, offline, on every lane run. Needs nothing
|
|
9
|
+
// but the tree and this file.
|
|
10
|
+
//
|
|
11
|
+
// AUTHENTICITY "is my lane the real published create-cmp-harness@X?"
|
|
12
|
+
// Answered REMOTELY, on request, by comparing this file's
|
|
13
|
+
// `sha256` against the published version's — `create-cmp
|
|
14
|
+
// upgrade --harness` does it, and so can any third party
|
|
15
|
+
// holding a receipt.
|
|
16
|
+
//
|
|
17
|
+
// Being honest about that split matters. Someone who edits the lane AND
|
|
18
|
+
// rewrites this lock defeats the local check — of course they do; it is a
|
|
19
|
+
// checksum, not a signature. What it cannot survive is the remote comparison,
|
|
20
|
+
// because the attacker cannot change what the registry published under that
|
|
21
|
+
// version number. Local integrity catches the accident and the drift (an
|
|
22
|
+
// agent "fixing" a lane file, a half-applied upgrade); the remote comparison
|
|
23
|
+
// catches the lie. Neither claim is stretched to cover the other's job.
|
|
24
|
+
//
|
|
25
|
+
// The lock is deliberately NOT a .mjs file, so it is not part of the region it
|
|
26
|
+
// describes — a manifest inside its own manifest could never settle.
|
|
27
|
+
//
|
|
28
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/harness-lock.mjs in the
|
|
29
|
+
// create-cmp repo — edit there, then run `node scripts/sync-harness.mjs`.
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import { hashHarnessRegion, compareHarnessRegion } from "./harness-region.mjs";
|
|
34
|
+
|
|
35
|
+
export const LOCK_PATH = "qa/harness.lock.json";
|
|
36
|
+
export const LOCK_SCHEMA = "cmp-harness-lock/1";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the lock, or null when it is absent or unparsable. An unreadable lock
|
|
40
|
+
* is not distinguished from a missing one on purpose: both mean "this tree
|
|
41
|
+
* cannot tell me what lane it carries", and both get the same honest verdict
|
|
42
|
+
* from checkHarnessIntegrity — unknown, never intact.
|
|
43
|
+
* @param {string} root project root
|
|
44
|
+
* @returns {object|null}
|
|
45
|
+
*/
|
|
46
|
+
export function readHarnessLock(root) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, LOCK_PATH), "utf8"));
|
|
49
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hash the tree's region and write the lock describing it.
|
|
57
|
+
* Called at stamp time and after an upgrade replaces the region — never by
|
|
58
|
+
* the lane itself, which must only ever READ the lock. A lane that rewrote
|
|
59
|
+
* its own manifest could not fail the integrity check it exists to run.
|
|
60
|
+
* @param {string} root project root
|
|
61
|
+
* @param {{name?: string, version: string}} harness identity to record
|
|
62
|
+
* @returns {{sha256: string, fileCount: number}}
|
|
63
|
+
*/
|
|
64
|
+
export function writeHarnessLock(root, { name = "create-cmp-harness", version }) {
|
|
65
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
66
|
+
throw new Error("writeHarnessLock: a harness version is required");
|
|
67
|
+
}
|
|
68
|
+
const region = hashHarnessRegion(root);
|
|
69
|
+
const lock = {
|
|
70
|
+
schema: LOCK_SCHEMA,
|
|
71
|
+
name,
|
|
72
|
+
version,
|
|
73
|
+
sha256: region.sha256,
|
|
74
|
+
fileCount: region.fileCount,
|
|
75
|
+
files: region.files,
|
|
76
|
+
};
|
|
77
|
+
const abs = path.join(root, LOCK_PATH);
|
|
78
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
79
|
+
fs.writeFileSync(abs, `${JSON.stringify(lock, null, 2)}\n`);
|
|
80
|
+
return { sha256: region.sha256, fileCount: region.fileCount };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Compare the tree's region against its lock.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} root project root
|
|
87
|
+
* @returns {{status: "intact"|"modified"|"unlocked", name: string|null,
|
|
88
|
+
* version: string|null, sha256: string, recordedSha256: string|null,
|
|
89
|
+
* modified: string[], missing: string[], extra: string[],
|
|
90
|
+
* fileCount: number}}
|
|
91
|
+
* status "unlocked" means no readable lock — an app stamped before locks
|
|
92
|
+
* existed, or one whose lock was deleted. Reported as its own state rather
|
|
93
|
+
* than folded into "modified": nothing is known to be wrong, but nothing is
|
|
94
|
+
* proven either, and a gate that cannot tell those apart teaches people to
|
|
95
|
+
* ignore it.
|
|
96
|
+
*/
|
|
97
|
+
export function checkHarnessIntegrity(root) {
|
|
98
|
+
const lock = readHarnessLock(root);
|
|
99
|
+
const region = hashHarnessRegion(root);
|
|
100
|
+
|
|
101
|
+
if (!lock || typeof lock.files !== "object" || lock.files === null) {
|
|
102
|
+
return {
|
|
103
|
+
status: "unlocked",
|
|
104
|
+
name: lock?.name ?? null,
|
|
105
|
+
version: typeof lock?.version === "string" ? lock.version : null,
|
|
106
|
+
sha256: region.sha256,
|
|
107
|
+
recordedSha256: typeof lock?.sha256 === "string" ? lock.sha256 : null,
|
|
108
|
+
modified: [],
|
|
109
|
+
missing: [],
|
|
110
|
+
extra: [],
|
|
111
|
+
fileCount: region.fileCount,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const cmp = compareHarnessRegion(root, lock);
|
|
116
|
+
return {
|
|
117
|
+
status: cmp.intact ? "intact" : "modified",
|
|
118
|
+
name: typeof lock.name === "string" ? lock.name : null,
|
|
119
|
+
version: typeof lock.version === "string" ? lock.version : null,
|
|
120
|
+
sha256: cmp.sha256,
|
|
121
|
+
recordedSha256: typeof lock.sha256 === "string" ? lock.sha256 : null,
|
|
122
|
+
modified: cmp.modified,
|
|
123
|
+
missing: cmp.missing,
|
|
124
|
+
extra: cmp.extra,
|
|
125
|
+
fileCount: region.fileCount,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One-line human summary of an integrity result — shared by the lane step and
|
|
131
|
+
* the upgrade command so both describe the same state the same way.
|
|
132
|
+
* @param {ReturnType<typeof checkHarnessIntegrity>} r
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function describeIntegrity(r) {
|
|
136
|
+
if (r.status === "intact") {
|
|
137
|
+
return `${r.name ?? "harness"} ${r.version ?? "?"} — ${r.fileCount} files verified`;
|
|
138
|
+
}
|
|
139
|
+
if (r.status === "unlocked") {
|
|
140
|
+
return `no ${LOCK_PATH} — this app's lane version is unrecorded`;
|
|
141
|
+
}
|
|
142
|
+
const parts = [];
|
|
143
|
+
if (r.modified.length) parts.push(`${r.modified.length} modified`);
|
|
144
|
+
if (r.missing.length) parts.push(`${r.missing.length} missing`);
|
|
145
|
+
if (r.extra.length) parts.push(`${r.extra.length} unrecorded`);
|
|
146
|
+
return `${r.name ?? "harness"} ${r.version ?? "?"} — ${parts.join(", ")}`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The harness region — which files in a stamped app are MACHINE-OWNED.
|
|
2
|
+
//
|
|
3
|
+
// A create-cmp app carries two kinds of file. App-owned files are the app: its
|
|
4
|
+
// screens, specs, goldens, approvals, e2e flows. Machine-owned files are the
|
|
5
|
+
// verify lane itself — executable harness code that is identical in every app
|
|
6
|
+
// ever stamped, carrying no app content whatsoever.
|
|
7
|
+
//
|
|
8
|
+
// Treating the second kind like the first is what made upgrades expensive: a
|
|
9
|
+
// three-way merge over 10k lines of engine code produced ~1,000 conflicted
|
|
10
|
+
// lines per app with ZERO app-specific tokens in them. The right operation for
|
|
11
|
+
// a derived artifact is replace, not merge. This module draws that line.
|
|
12
|
+
//
|
|
13
|
+
// The rule is deliberately mechanical, with no per-file list to keep in sync:
|
|
14
|
+
//
|
|
15
|
+
// machine-owned == the .mjs files directly under qa/ and qa/lib/
|
|
16
|
+
//
|
|
17
|
+
// Everything else under qa/ is app state (approvals.json, comments.json,
|
|
18
|
+
// evidence/, golden/) or app content (e2e/*.yaml — seeded once at stamp time,
|
|
19
|
+
// app-owned forever after, because apps edit their smoke flow as tabs change).
|
|
20
|
+
//
|
|
21
|
+
// Three consequences, each load-bearing:
|
|
22
|
+
//
|
|
23
|
+
// 1. NEVER STAMPED. The region is copied byte-identical from the engine —
|
|
24
|
+
// token replacement must not touch it. It used to: qa/lib/approvals.mjs
|
|
25
|
+
// carries a comment warning that a literal "__PACKAGE__" in lane source
|
|
26
|
+
// gets silently rewritten at stamp time, and qa/scaffold-feature.mjs
|
|
27
|
+
// shipped an error message that meant to name the unresolved token and
|
|
28
|
+
// instead named the app's real package. Anything app-specific the lane
|
|
29
|
+
// needs is read at RUNTIME from create-cmp.json.
|
|
30
|
+
//
|
|
31
|
+
// 2. VERIFIABLE. Because the copy is byte-identical to a known version, an
|
|
32
|
+
// app can prove offline that its lane is the real one. Without this a
|
|
33
|
+
// receipt is unfalsifiable: edit qa/verify.mjs to force every step green
|
|
34
|
+
// and the receipt still validates, since the edited file is simply part
|
|
35
|
+
// of the hashed surface.
|
|
36
|
+
//
|
|
37
|
+
// 3. REPLACEABLE. `create-cmp upgrade --harness` overwrites the region
|
|
38
|
+
// wholesale instead of merging it.
|
|
39
|
+
//
|
|
40
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/harness-region.mjs in the
|
|
41
|
+
// create-cmp repo. The copy in a generated project's qa/lib/ is vendored
|
|
42
|
+
// byte-identical at scaffold time — edit the package source, then run
|
|
43
|
+
// `node scripts/sync-harness.mjs`.
|
|
44
|
+
|
|
45
|
+
import { createHash } from "node:crypto";
|
|
46
|
+
import fs from "node:fs";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Directories whose direct `.mjs` children are machine-owned, relative to the
|
|
51
|
+
* project root. Direct children only — a nested directory added later is not
|
|
52
|
+
* silently swept into the region without someone editing this list.
|
|
53
|
+
*/
|
|
54
|
+
export const HARNESS_DIRS = ["qa", "qa/lib"];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is this project-relative path part of the machine-owned harness region?
|
|
58
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
export function isHarnessFile(relPath) {
|
|
62
|
+
if (typeof relPath !== "string" || !relPath.endsWith(".mjs")) return false;
|
|
63
|
+
const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
|
64
|
+
return HARNESS_DIRS.includes(dir);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every machine-owned file present under `root`, as project-relative posix
|
|
69
|
+
* paths, sorted — so the list (and any hash over it) is deterministic.
|
|
70
|
+
* @param {string} root project root
|
|
71
|
+
* @returns {string[]}
|
|
72
|
+
*/
|
|
73
|
+
export function listHarnessFiles(root) {
|
|
74
|
+
const found = [];
|
|
75
|
+
for (const dir of HARNESS_DIRS) {
|
|
76
|
+
let names;
|
|
77
|
+
try {
|
|
78
|
+
names = fs.readdirSync(path.join(root, dir), { withFileTypes: true });
|
|
79
|
+
} catch {
|
|
80
|
+
continue; // a project without qa/lib yet is not an error here
|
|
81
|
+
}
|
|
82
|
+
for (const ent of names) {
|
|
83
|
+
if (!ent.isFile()) continue;
|
|
84
|
+
const rel = `${dir}/${ent.name}`;
|
|
85
|
+
if (isHarnessFile(rel)) found.push(rel);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return found.sort();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** sha256 of one file's bytes, hex. */
|
|
92
|
+
function fileHash(abs) {
|
|
93
|
+
return createHash("sha256").update(fs.readFileSync(abs)).digest("hex");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Content hash of the whole region, plus the per-file hashes it was built from.
|
|
98
|
+
*
|
|
99
|
+
* The digest covers PATHS as well as content, so moving a file between the two
|
|
100
|
+
* harness directories changes the hash even if no byte of any file changed.
|
|
101
|
+
* NUL separators keep the encoding unambiguous — no filename can forge a
|
|
102
|
+
* boundary.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} root project root
|
|
105
|
+
* @returns {{sha256: string, fileCount: number, files: Record<string,string>}}
|
|
106
|
+
*/
|
|
107
|
+
export function hashHarnessRegion(root) {
|
|
108
|
+
const rels = listHarnessFiles(root);
|
|
109
|
+
const files = {};
|
|
110
|
+
const digest = createHash("sha256");
|
|
111
|
+
for (const rel of rels) {
|
|
112
|
+
const h = fileHash(path.join(root, rel));
|
|
113
|
+
files[rel] = h;
|
|
114
|
+
digest.update(rel, "utf8").update("\0").update(h, "utf8").update("\n");
|
|
115
|
+
}
|
|
116
|
+
return { sha256: digest.digest("hex"), fileCount: rels.length, files };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Compare a tree's region against a recorded manifest of per-file hashes.
|
|
121
|
+
* Reports WHICH files differ, not just that something did — an app that
|
|
122
|
+
* patched its lane needs to see the list, and an upgrade needs it to decide
|
|
123
|
+
* what to preserve.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} root project root
|
|
126
|
+
* @param {{sha256?: string, files?: Record<string,string>}} recorded
|
|
127
|
+
* @returns {{intact: boolean, sha256: string, modified: string[],
|
|
128
|
+
* missing: string[], extra: string[]}}
|
|
129
|
+
* modified present in both, different content
|
|
130
|
+
* missing recorded but absent from the tree
|
|
131
|
+
* extra present in the tree but not recorded
|
|
132
|
+
*/
|
|
133
|
+
export function compareHarnessRegion(root, recorded) {
|
|
134
|
+
const actual = hashHarnessRegion(root);
|
|
135
|
+
// `typeof null === "object"`, and an array would enumerate as index keys —
|
|
136
|
+
// a manifest that is absent or malformed must read as NOT intact, never crash
|
|
137
|
+
// the lane step that calls this.
|
|
138
|
+
const f = recorded?.files;
|
|
139
|
+
const expected = f && typeof f === "object" && !Array.isArray(f) ? f : {};
|
|
140
|
+
const modified = [];
|
|
141
|
+
const missing = [];
|
|
142
|
+
const extra = [];
|
|
143
|
+
|
|
144
|
+
for (const [rel, hash] of Object.entries(expected)) {
|
|
145
|
+
if (!(rel in actual.files)) missing.push(rel);
|
|
146
|
+
else if (actual.files[rel] !== hash) modified.push(rel);
|
|
147
|
+
}
|
|
148
|
+
for (const rel of Object.keys(actual.files)) {
|
|
149
|
+
if (!(rel in expected)) extra.push(rel);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
intact: modified.length === 0 && missing.length === 0 && extra.length === 0,
|
|
154
|
+
sha256: actual.sha256,
|
|
155
|
+
modified: modified.sort(),
|
|
156
|
+
missing: missing.sort(),
|
|
157
|
+
extra: extra.sort(),
|
|
158
|
+
};
|
|
159
|
+
}
|