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