create-cmp-cli 0.2.0 → 0.3.1
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 +65 -27
- package/bin/create-cmp.mjs +2 -1
- package/options.schema.json +5 -2
- package/package.json +2 -2
- package/src/commands/create.mjs +26 -3
- package/src/lib/toggle.mjs +2 -2
- package/template/.claude/settings.json +12 -0
- package/template/.claude/skills/add-feature/SKILL.md +131 -0
- package/template/.claude/skills/add-repository/SKILL.md +106 -0
- package/template/.claude/skills/add-screen/SKILL.md +130 -0
- package/template/.github/workflows/verify.yml +30 -8
- package/template/.gradle/8.11.1/checksums/checksums.lock +0 -0
- package/template/.gradle/8.11.1/fileChanges/last-build.bin +0 -0
- package/template/.gradle/8.11.1/fileHashes/fileHashes.lock +0 -0
- package/template/.gradle/8.11.1/gc.properties +0 -0
- package/template/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
- package/template/.gradle/buildOutputCleanup/cache.properties +2 -0
- package/template/.gradle/vcs-1/gc.properties +0 -0
- package/template/CHANGELOG.md +14 -0
- package/template/CLAUDE.md +70 -0
- package/template/CONTRIBUTING.md +39 -0
- package/template/README.md +103 -0
- package/template/composeApp/build.gradle.kts +15 -8
- package/template/composeApp/src/commonMain/kotlin/com/example/app/di/AppModule.kt +4 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +10 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +10 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +6 -2
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +1 -0
- package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/Screen.kt +2 -1
- package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +26 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +37 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +104 -0
- package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +29 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/A11yConformanceTest.kt +64 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +143 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeGoldenTreeTest.kt +78 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +77 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/navigation/AppShellTest.kt +79 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/ComposeTestExt.kt +16 -0
- package/template/composeApp/src/desktopTest/kotlin/com/example/app/testing/StructuralTree.kt +63 -0
- package/template/docs/ARCHITECTURE.md +51 -0
- package/template/docs/TESTING.md +64 -0
- package/template/docs/adr/0001-adopt-the-create-cmp-harness-conventions.md +32 -0
- package/template/docs/adr/template.md +17 -0
- package/template/gitignore +8 -1
- package/template/gradle/libs.versions.toml +2 -0
- package/template/manifest.json +11 -8
- package/template/qa/e2e/README.md +29 -0
- package/template/qa/e2e/smoke.yaml +35 -0
- package/template/qa/evidence/schema.json +56 -0
- package/template/qa/golden/home.json +15 -0
- package/template/qa/lib/inputs-hash.mjs +112 -0
- package/template/qa/lib/token-drift.mjs +94 -0
- package/template/qa/receipt-check.mjs +113 -0
- package/template/qa/refusal-demo.mjs +491 -0
- package/template/qa/scaffold-feature.mjs +488 -0
- package/template/qa/verify.mjs +467 -0
- package/template/specs/README.md +33 -0
- package/template/specs/app-base.spec.md +30 -0
- package/template/specs/home.spec.md +18 -0
- package/template/qa/appium/README.md +0 -23
- package/template/qa/appium/lib/appium-client.mjs +0 -225
- package/template/qa/appium/package.json +0 -8
- package/template/qa/appium/run-android-smoke.mjs +0 -39
- package/template/tests/appium/cmp/conftest.py +0 -96
- package/template/tests/appium/cmp/test_smoke.py +0 -17
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// token-drift.mjs — pure, dependency-free comparison of a LIVE inspector tree's
|
|
2
|
+
// resolved design-token payloads against the declared design-system catalog.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors the comparison semantics of diffAgainstDesignSystem() in the cmp-inspector
|
|
5
|
+
// MCP server (inspector/mcp/src/lib/drift.mjs): for each node in the tree that carries
|
|
6
|
+
// a designToken payload ({tokens:[names], resolved:{facet:value}}), and for each
|
|
7
|
+
// declared token name on that node, resolve the expected value from the catalog
|
|
8
|
+
// (dimens checked before colors — same lookup order as the MCP) and compare it
|
|
9
|
+
// against every one of the node's resolved facet values. If NONE of them match the
|
|
10
|
+
// declared value, the node has drifted from what it claims to use.
|
|
11
|
+
//
|
|
12
|
+
// Matching is case/whitespace-normalized string equality (trim + lowercase) — the
|
|
13
|
+
// exact normalization the MCP's `normalize()` applies (so "#0A2540" == "#0a2540",
|
|
14
|
+
// but "72dp" would NOT equal "72.0dp" — the MCP does not do numeric-format
|
|
15
|
+
// normalization, so neither do we, to keep the comparison rule identical).
|
|
16
|
+
//
|
|
17
|
+
// No imports, no Node built-ins beyond what the runtime provides for free — pure
|
|
18
|
+
// object-in/object-out, unit-testable with plain objects.
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{colors?:Record<string,string>, dimens?:Record<string,string>}} declaredCatalog
|
|
22
|
+
* @param {{root:object}|object} tree the parsed /inspect/tree document (or a bare node)
|
|
23
|
+
* @returns {{checked:number, drifted:Array<{node:string, token:string, facet:string, expected:string, actual:string}>}}
|
|
24
|
+
*/
|
|
25
|
+
export function compareTokenDrift(declaredCatalog, tree) {
|
|
26
|
+
const colors = (declaredCatalog && declaredCatalog.colors) || {};
|
|
27
|
+
const dimens = (declaredCatalog && declaredCatalog.dimens) || {};
|
|
28
|
+
|
|
29
|
+
let checked = 0;
|
|
30
|
+
const drifted = [];
|
|
31
|
+
|
|
32
|
+
for (const { node, path } of walk(tree)) {
|
|
33
|
+
const dt = node && node.designToken;
|
|
34
|
+
if (!dt || !Array.isArray(dt.tokens) || dt.tokens.length === 0) continue;
|
|
35
|
+
|
|
36
|
+
const resolved = dt.resolved && typeof dt.resolved === "object" ? dt.resolved : {};
|
|
37
|
+
const resolvedEntries = Object.entries(resolved);
|
|
38
|
+
|
|
39
|
+
for (const token of dt.tokens) {
|
|
40
|
+
let declared;
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(dimens, token)) declared = dimens[token];
|
|
42
|
+
else if (Object.prototype.hasOwnProperty.call(colors, token)) declared = colors[token];
|
|
43
|
+
else continue; // token not in the declared catalog — nothing to diff against
|
|
44
|
+
|
|
45
|
+
checked += 1;
|
|
46
|
+
|
|
47
|
+
const declaredNorm = normalize(declared);
|
|
48
|
+
const matches = resolvedEntries.some(([, v]) => normalize(v) === declaredNorm);
|
|
49
|
+
if (matches) continue;
|
|
50
|
+
|
|
51
|
+
const [facet, actual] = pickFacetForReport(resolvedEntries);
|
|
52
|
+
drifted.push({
|
|
53
|
+
node: node.testTag || path,
|
|
54
|
+
token,
|
|
55
|
+
facet,
|
|
56
|
+
expected: declared,
|
|
57
|
+
actual,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { checked, drifted };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Depth-first walk yielding every node with a stable, dotted path. Accepts either a
|
|
66
|
+
// full tree ({schemaVersion, source, root}) or a bare node — same contract as the
|
|
67
|
+
// MCP's tree.mjs walk().
|
|
68
|
+
function* walk(tree) {
|
|
69
|
+
const root = tree && tree.root ? tree.root : tree;
|
|
70
|
+
if (!root || typeof root !== "object") return;
|
|
71
|
+
yield* walkNode(root, "root");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function* walkNode(node, path) {
|
|
75
|
+
yield { node, path };
|
|
76
|
+
const children = Array.isArray(node.children) ? node.children : [];
|
|
77
|
+
for (let i = 0; i < children.length; i++) {
|
|
78
|
+
yield* walkNode(children[i], `${path}.children[${i}]`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Case-insensitive, trimmed comparison — identical to the MCP's normalize().
|
|
83
|
+
function normalize(v) {
|
|
84
|
+
return String(v == null ? "" : v).trim().toLowerCase();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Best-effort single (facet, value) to blame in the drift report. If the node
|
|
88
|
+
// resolved exactly one facet, name it directly; otherwise join every facet/value
|
|
89
|
+
// so the reader sees everything the node actually resolved.
|
|
90
|
+
function pickFacetForReport(entries) {
|
|
91
|
+
if (entries.length === 1) return [entries[0][0], String(entries[0][1])];
|
|
92
|
+
if (entries.length === 0) return ["(none)", "(no resolved values)"];
|
|
93
|
+
return [entries.map(([k]) => k).join(","), entries.map(([, v]) => String(v)).join(", ")];
|
|
94
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The evidence-binding predicate — answers one question: does the committed
|
|
3
|
+
// receipt (qa/evidence/latest.json) validly attest THIS tree, right now?
|
|
4
|
+
//
|
|
5
|
+
// node qa/receipt-check.mjs [--hook] [--json]
|
|
6
|
+
//
|
|
7
|
+
// Both enforcement points reduce to this predicate: the local Stop hook
|
|
8
|
+
// (.claude/settings.json) calls it on every turn-end, and CI calls it before
|
|
9
|
+
// re-running the lane. See docs/adr/0005-evidence-binding-by-inputs-hash.md.
|
|
10
|
+
//
|
|
11
|
+
// VALID iff receipt.verdict === "PASS" && receipt.inputs.hash === recompute(tree)
|
|
12
|
+
// Exit codes (normal mode): VALID -> 0, INVALID -> 1.
|
|
13
|
+
// Exit codes (--hook mode, Claude Code Stop-hook protocol):
|
|
14
|
+
// stop_hook_active === true -> 0 (never block twice in a row)
|
|
15
|
+
// INVALID -> 2, reason on stderr (Claude Code's block-and-feed-back signal)
|
|
16
|
+
// VALID -> 0, silent
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { computeInputsHash } from "./lib/inputs-hash.mjs";
|
|
23
|
+
|
|
24
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
25
|
+
const RECEIPT_PATH = path.join(ROOT, "qa", "evidence", "latest.json");
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const asHook = args.includes("--hook");
|
|
29
|
+
const asJson = args.includes("--json");
|
|
30
|
+
|
|
31
|
+
function readStdinJson() {
|
|
32
|
+
try {
|
|
33
|
+
const raw = fs.readFileSync(0, "utf8");
|
|
34
|
+
if (!raw.trim()) return {};
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function evaluate() {
|
|
42
|
+
let receipt;
|
|
43
|
+
try {
|
|
44
|
+
receipt = JSON.parse(fs.readFileSync(RECEIPT_PATH, "utf8"));
|
|
45
|
+
} catch {
|
|
46
|
+
return { valid: false, reason: "no receipt — run `node qa/verify.mjs`", profile: undefined };
|
|
47
|
+
}
|
|
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 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = evaluate();
|
|
90
|
+
|
|
91
|
+
if (asHook) {
|
|
92
|
+
const hookInput = readStdinJson();
|
|
93
|
+
if (hookInput.stop_hook_active === true) {
|
|
94
|
+
process.exit(0);
|
|
95
|
+
}
|
|
96
|
+
if (!result.valid) {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`Not done: ${result.reason}. Run \`node qa/verify.mjs\` and commit the receipt, or see README §Verification enforcement to bypass.\n`,
|
|
99
|
+
);
|
|
100
|
+
process.exit(2);
|
|
101
|
+
}
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (asJson) {
|
|
106
|
+
console.log(JSON.stringify(result, null, 2));
|
|
107
|
+
} else if (result.valid) {
|
|
108
|
+
console.log(`VALID — ${result.reason}`);
|
|
109
|
+
} else {
|
|
110
|
+
console.error(`INVALID — ${result.reason}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
process.exit(result.valid ? 0 : 1);
|