create-cmp-cli 0.2.0 → 0.3.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 +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 +4 -0
- 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 +105 -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,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);
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The refusal demo (C7) — proves the gates REFUSE real violations, each named
|
|
3
|
+
// and blocking, rather than merely asserting it in prose.
|
|
4
|
+
//
|
|
5
|
+
// node qa/refusal-demo.mjs
|
|
6
|
+
//
|
|
7
|
+
// Operates on a throwaway scaffolded app in a temp dir (never this template,
|
|
8
|
+
// never the calling repo). For each of the four canonical violations
|
|
9
|
+
// (docs/M4-ENFORCEMENT-DESIGN.md §B):
|
|
10
|
+
//
|
|
11
|
+
// 1. inject the violation into the scaffold
|
|
12
|
+
// 2. run the narrowest gate that should catch it
|
|
13
|
+
// 3. assert the gate verdict is FAIL and the expected clause id appears in
|
|
14
|
+
// the failure output
|
|
15
|
+
// 4. assert `qa/receipt-check.mjs` goes INVALID (violation blocks "done")
|
|
16
|
+
// 5. `git checkout -- .` to revert before the next violation
|
|
17
|
+
//
|
|
18
|
+
// Emits a summary table and exits non-zero if ANY assertion fails — i.e. if a
|
|
19
|
+
// gate did NOT catch its violation. That is the actual finding this script
|
|
20
|
+
// exists to surface; it must never be papered over.
|
|
21
|
+
|
|
22
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
24
|
+
import os from "node:os";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
|
|
28
|
+
const TEMPLATE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
29
|
+
const REPO_ROOT = path.resolve(TEMPLATE_ROOT, "..");
|
|
30
|
+
const CREATE_CMP_BIN = path.join(REPO_ROOT, "bin", "create-cmp.mjs");
|
|
31
|
+
const GRADLEW = process.platform === "win32" ? "gradlew.bat" : "./gradlew";
|
|
32
|
+
|
|
33
|
+
function log(msg) {
|
|
34
|
+
process.stdout.write(`${msg}\n`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sh(cmd, cwd, opts = {}) {
|
|
38
|
+
const res = spawnSync(cmd, {
|
|
39
|
+
shell: true,
|
|
40
|
+
cwd,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
43
|
+
...opts,
|
|
44
|
+
});
|
|
45
|
+
return { ok: res.status === 0 && !res.error, status: res.status, out: `${res.stdout ?? ""}${res.stderr ?? ""}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function git(cwd, args) {
|
|
49
|
+
return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Step 0: scaffold a throwaway app ────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function scaffold() {
|
|
55
|
+
const tmpBase = fs.mkdtempSync(path.join(os.tmpdir(), "cmp-refusal-demo-"));
|
|
56
|
+
const projectDir = path.join(tmpBase, "RefusalDemo");
|
|
57
|
+
log(`Scaffolding a throwaway app at ${projectDir} (never touching ${TEMPLATE_ROOT})…`);
|
|
58
|
+
|
|
59
|
+
const res = sh(
|
|
60
|
+
`node "${CREATE_CMP_BIN}" "${projectDir}" --name RefusalDemo --package com.example.refusaldemo --no-ios --yes --no-verify`,
|
|
61
|
+
REPO_ROOT,
|
|
62
|
+
{ timeout: 5 * 60_000 },
|
|
63
|
+
);
|
|
64
|
+
if (!res.ok || !fs.existsSync(projectDir)) {
|
|
65
|
+
throw new Error(`Scaffold failed — cannot run the refusal demo without a scaffolded app:\n${res.out}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
git(projectDir, ["init", "-q"]);
|
|
69
|
+
git(projectDir, ["add", "-A"]);
|
|
70
|
+
git(projectDir, ["-c", "user.email=refusal-demo@example.com", "-c", "user.name=Refusal Demo", "commit", "-q", "-m", "init"]);
|
|
71
|
+
|
|
72
|
+
log("Establishing a green baseline (node qa/verify.mjs --profile scaffold)…");
|
|
73
|
+
const verify = sh("node qa/verify.mjs --profile scaffold", projectDir, { timeout: 10 * 60_000 });
|
|
74
|
+
if (!verify.ok) {
|
|
75
|
+
throw new Error(`Baseline scaffold did not PASS verify — cannot demonstrate refusal against a broken baseline:\n${verify.out}`);
|
|
76
|
+
}
|
|
77
|
+
git(projectDir, ["add", "-A"]);
|
|
78
|
+
git(projectDir, ["-c", "user.email=refusal-demo@example.com", "-c", "user.name=Refusal Demo", "commit", "-q", "-m", "green baseline"]);
|
|
79
|
+
|
|
80
|
+
return projectDir;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Selectors — find targets by content, never by hardcoded line numbers ───
|
|
84
|
+
|
|
85
|
+
// Any *Screen.kt under presentation/ that is not a shared shell piece —
|
|
86
|
+
// "the first screen composable" the spec calls for.
|
|
87
|
+
function findScreenComposable(projectDir) {
|
|
88
|
+
const presoDir = path.join(projectDir, "composeApp/src/commonMain/kotlin");
|
|
89
|
+
const candidates = walk(presoDir).filter(
|
|
90
|
+
(p) =>
|
|
91
|
+
p.replace(/\\/g, "/").includes("/presentation/") &&
|
|
92
|
+
/Screen\.kt$/.test(p) &&
|
|
93
|
+
!p.endsWith(`${path.sep}Screen.kt`) &&
|
|
94
|
+
!p.replace(/\\/g, "/").includes("/components/") &&
|
|
95
|
+
!p.replace(/\\/g, "/").includes("/navigation/"),
|
|
96
|
+
);
|
|
97
|
+
if (candidates.length === 0) {
|
|
98
|
+
throw new Error(`No screen composable found under ${presoDir} — cannot inject the color-literal violation.`);
|
|
99
|
+
}
|
|
100
|
+
candidates.sort();
|
|
101
|
+
return candidates[0];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A real class under data/ so the injected import resolves to something that exists.
|
|
105
|
+
function findDataLayerImport(projectDir) {
|
|
106
|
+
const dataDir = path.join(projectDir, "composeApp/src/commonMain/kotlin");
|
|
107
|
+
const candidates = walk(dataDir).filter((p) => p.replace(/\\/g, "/").includes("/data/") && p.endsWith(".kt"));
|
|
108
|
+
if (candidates.length === 0) {
|
|
109
|
+
throw new Error(`No file found under a data/ package in ${dataDir} — cannot inject the UI-to-data-import violation.`);
|
|
110
|
+
}
|
|
111
|
+
candidates.sort();
|
|
112
|
+
const target = candidates[0];
|
|
113
|
+
const text = fs.readFileSync(target, "utf8");
|
|
114
|
+
const pkgMatch = text.match(/^package\s+([\w.]+)/m);
|
|
115
|
+
const classMatch = text.match(/\bclass\s+(\w+)/);
|
|
116
|
+
if (!pkgMatch || !classMatch) {
|
|
117
|
+
throw new Error(`Could not determine package/class name from ${target} to build a realistic data-layer import.`);
|
|
118
|
+
}
|
|
119
|
+
return `import ${pkgMatch[1]}.${classMatch[1]}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A `// SPEC: <ID>`-tagged test in commonTest bound to exactly one clause, so
|
|
123
|
+
// removing it orphans that clause cleanly (no cascading orphans).
|
|
124
|
+
function findOrphanableSpecTest(projectDir) {
|
|
125
|
+
const testDir = path.join(projectDir, "composeApp/src/commonTest/kotlin");
|
|
126
|
+
const files = walk(testDir).filter((p) => p.endsWith(".kt"));
|
|
127
|
+
const tagRe = /^(\s*)\/\/\s*SPEC:\s*([A-Z][A-Z0-9]*-\d{2,})\s*$/;
|
|
128
|
+
const funcRe = /fun\s+`([^`]+)`\s*\(/;
|
|
129
|
+
|
|
130
|
+
const counts = new Map();
|
|
131
|
+
const found = [];
|
|
132
|
+
for (const file of files.sort()) {
|
|
133
|
+
const lines = fs.readFileSync(file, "utf8").split("\n");
|
|
134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
135
|
+
const m = lines[i].match(tagRe);
|
|
136
|
+
if (!m) continue;
|
|
137
|
+
const id = m[2];
|
|
138
|
+
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
139
|
+
// Find the @Test fun signature within the next few lines.
|
|
140
|
+
let funcLine = -1;
|
|
141
|
+
let funcName = null;
|
|
142
|
+
for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
|
|
143
|
+
const fm = lines[j].match(funcRe);
|
|
144
|
+
if (fm) {
|
|
145
|
+
funcLine = j;
|
|
146
|
+
funcName = fm[1];
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (funcLine === -1) continue;
|
|
151
|
+
found.push({ file, id, tagLine: i, funcName, indent: m[1] });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const singlyBound = found.filter((f) => counts.get(f.id) === 1);
|
|
156
|
+
if (singlyBound.length === 0) {
|
|
157
|
+
throw new Error("No singly-bound `// SPEC: <ID>` test found in commonTest — cannot demonstrate a clean orphan without cascading.");
|
|
158
|
+
}
|
|
159
|
+
singlyBound.sort((a, b) => (a.file === b.file ? a.id.localeCompare(b.id) : a.file.localeCompare(b.file)));
|
|
160
|
+
return singlyBound[0];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function walk(dir) {
|
|
164
|
+
const out = [];
|
|
165
|
+
if (!fs.existsSync(dir)) return out;
|
|
166
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
167
|
+
const p = path.join(dir, entry.name);
|
|
168
|
+
if (entry.isDirectory()) out.push(...walk(p));
|
|
169
|
+
else out.push(p);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── The four violations ─────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
// Finds the byte offset of the opening `{` of the first @Composable function
|
|
177
|
+
// body — robust to multi-line parameter lists (unlike a single-line regex).
|
|
178
|
+
function firstComposableBodyBraceEnd(text) {
|
|
179
|
+
const composableIdx = text.indexOf("@Composable");
|
|
180
|
+
if (composableIdx === -1) return -1;
|
|
181
|
+
const funIdx = text.indexOf("fun ", composableIdx);
|
|
182
|
+
if (funIdx === -1) return -1;
|
|
183
|
+
const braceIdx = text.indexOf("{", funIdx);
|
|
184
|
+
if (braceIdx === -1) return -1;
|
|
185
|
+
return braceIdx + 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function injectColorLiteral(projectDir) {
|
|
189
|
+
const target = findScreenComposable(projectDir);
|
|
190
|
+
let text = fs.readFileSync(target, "utf8");
|
|
191
|
+
if (!/import androidx\.compose\.ui\.graphics\.Color/.test(text)) {
|
|
192
|
+
text = text.replace(/^(package .+\n)/, `$1\nimport androidx.compose.ui.graphics.Color`);
|
|
193
|
+
}
|
|
194
|
+
// Insert a literal Color(0x...) use into the composable body — anywhere in
|
|
195
|
+
// a non-theme file trips ARCH-05's source scan.
|
|
196
|
+
const insertAt = firstComposableBodyBraceEnd(text);
|
|
197
|
+
if (insertAt === -1) {
|
|
198
|
+
throw new Error(`Could not locate a @Composable function body in ${target} to inject the color literal.`);
|
|
199
|
+
}
|
|
200
|
+
text = `${text.slice(0, insertAt)}\n val refusalDemoColor = Color(0xFF123456) // injected violation${text.slice(insertAt)}`;
|
|
201
|
+
fs.writeFileSync(target, text);
|
|
202
|
+
return { file: path.relative(projectDir, target) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function injectUiToDataImport(projectDir) {
|
|
206
|
+
const target = findScreenComposable(projectDir);
|
|
207
|
+
const importLine = findDataLayerImport(projectDir);
|
|
208
|
+
let text = fs.readFileSync(target, "utf8");
|
|
209
|
+
text = text.replace(/^(package .+\n)/, `$1\n${importLine} // injected violation`);
|
|
210
|
+
fs.writeFileSync(target, text);
|
|
211
|
+
return { file: path.relative(projectDir, target), importLine };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function injectDeletedSpecTest(projectDir) {
|
|
215
|
+
const found = findOrphanableSpecTest(projectDir);
|
|
216
|
+
const lines = fs.readFileSync(found.file, "utf8").split("\n");
|
|
217
|
+
|
|
218
|
+
// Remove the `// SPEC:` tag line and the following @Test + fun signature +
|
|
219
|
+
// its body (up to the matching closing brace at the function's own indent).
|
|
220
|
+
const startLine = found.tagLine;
|
|
221
|
+
let braceDepth = 0;
|
|
222
|
+
let endLine = -1;
|
|
223
|
+
let sawOpenBrace = false;
|
|
224
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
225
|
+
for (const ch of lines[i]) {
|
|
226
|
+
if (ch === "{") {
|
|
227
|
+
braceDepth++;
|
|
228
|
+
sawOpenBrace = true;
|
|
229
|
+
} else if (ch === "}") {
|
|
230
|
+
braceDepth--;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (sawOpenBrace && braceDepth === 0) {
|
|
234
|
+
endLine = i;
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (endLine === -1) {
|
|
239
|
+
throw new Error(`Could not find the end of test '${found.funcName}' in ${found.file} to remove it cleanly.`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const newLines = [...lines.slice(0, startLine), ...lines.slice(endLine + 1)];
|
|
243
|
+
fs.writeFileSync(found.file, newLines.join("\n"));
|
|
244
|
+
return { file: path.relative(projectDir, found.file), clause: found.id, test: found.funcName };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findHomeGoldenTest(projectDir) {
|
|
248
|
+
const testDir = path.join(projectDir, "composeApp/src/desktopTest/kotlin");
|
|
249
|
+
const candidates = walk(testDir).filter((p) => /GoldenTreeTest\.kt$/.test(p));
|
|
250
|
+
if (candidates.length === 0) {
|
|
251
|
+
throw new Error(`No *GoldenTreeTest.kt found under ${testDir} — cannot inject the structural-regression violation.`);
|
|
252
|
+
}
|
|
253
|
+
candidates.sort();
|
|
254
|
+
return candidates[0];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function injectStructuralRegression(projectDir) {
|
|
258
|
+
const goldenTest = findHomeGoldenTest(projectDir);
|
|
259
|
+
const testText = fs.readFileSync(goldenTest, "utf8");
|
|
260
|
+
const screenMatch = testText.match(/setContent\s*\{\s*MaterialTheme\s*\{\s*(\w+)\(/);
|
|
261
|
+
if (!screenMatch) {
|
|
262
|
+
throw new Error(`Could not locate the screen composable rendered by ${goldenTest} to mutate its structure.`);
|
|
263
|
+
}
|
|
264
|
+
const screenName = screenMatch[1];
|
|
265
|
+
const screenDir = path.join(projectDir, "composeApp/src/commonMain/kotlin");
|
|
266
|
+
const screenFile = walk(screenDir).find((p) => p.endsWith(`${path.sep}${screenName}.kt`));
|
|
267
|
+
if (!screenFile) {
|
|
268
|
+
throw new Error(`Could not find source file for composable '${screenName}' referenced by ${goldenTest}.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let text = fs.readFileSync(screenFile, "utf8");
|
|
272
|
+
// Mutate the structure without touching UPDATE_GOLDEN: add an extra text
|
|
273
|
+
// node right after the title's Text(...) call closes, so the semantics
|
|
274
|
+
// tree gains a sibling child the committed baseline does not have. Find
|
|
275
|
+
// the *matching* closing paren of that call (brace/paren-depth walk) —
|
|
276
|
+
// a lazy regex would splice mid-call and produce invalid Kotlin.
|
|
277
|
+
const titleCallStart = text.indexOf('text = "Home"');
|
|
278
|
+
if (titleCallStart === -1) {
|
|
279
|
+
throw new Error(`Could not find the Home title Text() node in ${screenFile} to mutate structurally.`);
|
|
280
|
+
}
|
|
281
|
+
const callOpenParen = text.lastIndexOf("Text(", titleCallStart);
|
|
282
|
+
if (callOpenParen === -1) {
|
|
283
|
+
throw new Error(`Could not find the opening Text( for the Home title in ${screenFile}.`);
|
|
284
|
+
}
|
|
285
|
+
let depth = 0;
|
|
286
|
+
let callCloseParen = -1;
|
|
287
|
+
for (let i = callOpenParen; i < text.length; i++) {
|
|
288
|
+
if (text[i] === "(") depth++;
|
|
289
|
+
else if (text[i] === ")") {
|
|
290
|
+
depth--;
|
|
291
|
+
if (depth === 0) {
|
|
292
|
+
callCloseParen = i;
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (callCloseParen === -1) {
|
|
298
|
+
throw new Error(`Could not find the matching close paren for the Home title Text() in ${screenFile}.`);
|
|
299
|
+
}
|
|
300
|
+
const insertAt = callCloseParen + 1;
|
|
301
|
+
text = `${text.slice(0, insertAt)}\n Text(text = "Injected structural regression")${text.slice(insertAt)}`;
|
|
302
|
+
fs.writeFileSync(screenFile, text);
|
|
303
|
+
return { file: path.relative(projectDir, screenFile), screen: screenName };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ── Gate runners ─────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
// Gradle's `--console=plain` prints only the test-class/method + a
|
|
309
|
+
// `java.lang.AssertionError at X.kt:NN` pointer to the console — never the
|
|
310
|
+
// actual assertion message (the clause-citing text lives in the JUnit XML
|
|
311
|
+
// report's <failure message="..."> attribute). Read it back and fold it into
|
|
312
|
+
// the output the assertion checks so the demo — and its evidence doc — quote
|
|
313
|
+
// the real gate-authored failure text, not a stack-trace stub.
|
|
314
|
+
function junitFailureMessages(projectDir) {
|
|
315
|
+
const dir = path.join(projectDir, "composeApp/build/test-results/desktopTest");
|
|
316
|
+
if (!fs.existsSync(dir)) return "";
|
|
317
|
+
const messages = [];
|
|
318
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.startsWith("TEST-") && f.endsWith(".xml"))) {
|
|
319
|
+
const xml = fs.readFileSync(path.join(dir, f), "utf8");
|
|
320
|
+
for (const m of xml.matchAll(/<failure message="([^"]*)"/g)) {
|
|
321
|
+
messages.push(
|
|
322
|
+
m[1]
|
|
323
|
+
.replace(/ /g, "\n")
|
|
324
|
+
.replace(/"/g, '"')
|
|
325
|
+
.replace(/</g, "<")
|
|
326
|
+
.replace(/>/g, ">")
|
|
327
|
+
.replace(/&/g, "&"),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return messages.join("\n---\n");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function runGradleTestGate(projectDir, testsFilter) {
|
|
335
|
+
const res = sh(`${GRADLEW} :composeApp:desktopTest --tests "${testsFilter}" --console=plain`, projectDir, { timeout: 10 * 60_000 });
|
|
336
|
+
const junit = junitFailureMessages(projectDir);
|
|
337
|
+
return { ...res, out: junit ? `${res.out}\n\n--- JUnit failure messages ---\n${junit}` : res.out };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function runConformance(projectDir) {
|
|
341
|
+
return runGradleTestGate(projectDir, "*ArchitectureConformanceTest");
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function runGoldenTrees(projectDir) {
|
|
345
|
+
return runGradleTestGate(projectDir, "*GoldenTreeTest");
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function runSpecCoverage(projectDir) {
|
|
349
|
+
return sh("node qa/verify.mjs --profile scaffold --json", projectDir, { timeout: 10 * 60_000 });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function runReceiptCheck(projectDir) {
|
|
353
|
+
return sh("node qa/receipt-check.mjs", projectDir);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function revert(projectDir) {
|
|
357
|
+
git(projectDir, ["checkout", "--", "."]);
|
|
358
|
+
git(projectDir, ["clean", "-fd", "--", "composeApp", "qa"]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── The four violation cases ─────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
const VIOLATIONS = [
|
|
364
|
+
{
|
|
365
|
+
id: 1,
|
|
366
|
+
name: "Hardcoded color literal",
|
|
367
|
+
expectedClause: "ARCH-05",
|
|
368
|
+
gate: "conformance",
|
|
369
|
+
inject: injectColorLiteral,
|
|
370
|
+
run: runConformance,
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
id: 2,
|
|
374
|
+
name: "UI→data import",
|
|
375
|
+
expectedClause: "ARCH-01",
|
|
376
|
+
gate: "conformance",
|
|
377
|
+
inject: injectUiToDataImport,
|
|
378
|
+
run: runConformance,
|
|
379
|
+
},
|
|
380
|
+
{
|
|
381
|
+
id: 3,
|
|
382
|
+
name: "Deleted / weakened spec test",
|
|
383
|
+
expectedClause: null, // determined at inject time (the clause id the removed test cited)
|
|
384
|
+
gate: "specCoverage",
|
|
385
|
+
inject: injectDeletedSpecTest,
|
|
386
|
+
run: runSpecCoverage,
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
id: 4,
|
|
390
|
+
name: "Undeclared structural regression",
|
|
391
|
+
expectedClause: "HOME-06",
|
|
392
|
+
gate: "goldenTrees",
|
|
393
|
+
inject: injectStructuralRegression,
|
|
394
|
+
run: runGoldenTrees,
|
|
395
|
+
},
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
function main() {
|
|
399
|
+
const projectDir = scaffold();
|
|
400
|
+
const results = [];
|
|
401
|
+
|
|
402
|
+
for (const v of VIOLATIONS) {
|
|
403
|
+
log(`\n── Violation ${v.id}: ${v.name} ──`);
|
|
404
|
+
let injectInfo;
|
|
405
|
+
try {
|
|
406
|
+
injectInfo = v.inject(projectDir);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
results.push({ ...v, expectedClause: v.expectedClause ?? "?", verdict: "ERROR", assertionPass: false, detail: `injection failed: ${err.message}` });
|
|
409
|
+
log(` INJECTION FAILED: ${err.message}`);
|
|
410
|
+
revert(projectDir);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const expectedClause = v.expectedClause ?? injectInfo.clause;
|
|
415
|
+
log(` Injected into ${injectInfo.file}${injectInfo.test ? ` (removed test: ${injectInfo.test}, clause ${injectInfo.clause})` : ""}`);
|
|
416
|
+
log(` Running gate: ${v.gate}…`);
|
|
417
|
+
|
|
418
|
+
const gateRes = v.run(projectDir);
|
|
419
|
+
const gateFailed = !gateRes.ok;
|
|
420
|
+
const clauseNamed = gateRes.out.includes(`[${expectedClause}]`) || gateRes.out.includes(expectedClause);
|
|
421
|
+
|
|
422
|
+
const receiptRes = runReceiptCheck(projectDir);
|
|
423
|
+
const receiptBlocked = !receiptRes.ok;
|
|
424
|
+
|
|
425
|
+
const assertionPass = gateFailed && clauseNamed && receiptBlocked;
|
|
426
|
+
|
|
427
|
+
results.push({
|
|
428
|
+
...v,
|
|
429
|
+
expectedClause,
|
|
430
|
+
verdict: gateFailed ? "FAIL" : "PASS",
|
|
431
|
+
clauseNamed,
|
|
432
|
+
receiptBlocked,
|
|
433
|
+
assertionPass,
|
|
434
|
+
gateOutputExcerpt: extractRelevantLines(gateRes.out, expectedClause),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
log(` Gate verdict: ${gateFailed ? "FAIL" : "PASS (did not catch it!)"}`);
|
|
438
|
+
log(` Clause "${expectedClause}" named in output: ${clauseNamed ? "yes" : "NO"}`);
|
|
439
|
+
log(` receipt-check blocked (non-zero exit): ${receiptBlocked ? "yes" : "NO"}`);
|
|
440
|
+
log(` Assertion: ${assertionPass ? "PASS" : "FAIL"}`);
|
|
441
|
+
|
|
442
|
+
revert(projectDir);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ── Summary ────────────────────────────────────────────────────────────────
|
|
446
|
+
log("\n\n=== Refusal demo summary (C7) ===\n");
|
|
447
|
+
const header = "# | Violation | Expected clause | Observed verdict | Assertion";
|
|
448
|
+
log(header);
|
|
449
|
+
log("-".repeat(header.length));
|
|
450
|
+
for (const r of results) {
|
|
451
|
+
log(
|
|
452
|
+
`${String(r.id).padEnd(1)} | ${r.name.padEnd(34)} | ${String(r.expectedClause).padEnd(15)} | ${String(r.verdict).padEnd(17)} | ${r.assertionPass ? "PASS" : "FAIL"}`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const allPass = results.every((r) => r.assertionPass);
|
|
457
|
+
log(`\n${allPass ? results.length : results.filter((r) => r.assertionPass).length}/${results.length} assertions PASS`);
|
|
458
|
+
|
|
459
|
+
if (!allPass) {
|
|
460
|
+
log("\nFAILING ASSERTIONS — a gate did NOT catch its violation as expected:");
|
|
461
|
+
for (const r of results.filter((r) => !r.assertionPass)) {
|
|
462
|
+
log(` #${r.id} ${r.name}: ${r.detail ?? `verdict=${r.verdict} clauseNamed=${r.clauseNamed} receiptBlocked=${r.receiptBlocked}`}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
log("\n--- Real gate failure messages observed ---");
|
|
467
|
+
for (const r of results) {
|
|
468
|
+
if (r.gateOutputExcerpt) {
|
|
469
|
+
log(`\n[#${r.id} ${r.name} — ${r.gate}]`);
|
|
470
|
+
log(r.gateOutputExcerpt);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
fs.rmSync(path.dirname(projectDir), { recursive: true, force: true });
|
|
475
|
+
|
|
476
|
+
process.exit(allPass ? 0 : 1);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function extractRelevantLines(out, clause) {
|
|
480
|
+
const lines = out.split("\n");
|
|
481
|
+
const idx = lines.findIndex((l) => l.includes(clause));
|
|
482
|
+
if (idx === -1) return lines.filter((l) => /FAIL|error/i.test(l)).slice(0, 8).join("\n");
|
|
483
|
+
return lines.slice(Math.max(0, idx - 2), idx + 10).join("\n");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
main();
|
|
488
|
+
} catch (err) {
|
|
489
|
+
process.stderr.write(`\nrefusal-demo aborted: ${err.message}\n`);
|
|
490
|
+
process.exit(1);
|
|
491
|
+
}
|