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.
Files changed (54) hide show
  1. package/package.json +6 -2
  2. package/packages/harness/package.json +38 -0
  3. package/packages/harness/src/approve.mjs +247 -0
  4. package/packages/harness/src/arch-doc.mjs +69 -0
  5. package/packages/harness/src/comment.mjs +76 -0
  6. package/packages/harness/src/lib/a11y.mjs +113 -0
  7. package/packages/harness/src/lib/affected-tests.mjs +147 -0
  8. package/packages/harness/src/lib/approvals.mjs +1403 -0
  9. package/packages/harness/src/lib/arch-doc.mjs +451 -0
  10. package/packages/harness/src/lib/audit-cadence.mjs +290 -0
  11. package/packages/harness/src/lib/comments.mjs +252 -0
  12. package/packages/harness/src/lib/component-stories.mjs +183 -0
  13. package/packages/harness/src/lib/determinism.mjs +179 -0
  14. package/packages/harness/src/lib/device-lease.mjs +249 -0
  15. package/packages/harness/src/lib/evidence-badge.mjs +158 -0
  16. package/packages/harness/src/lib/evidence-level.mjs +117 -0
  17. package/packages/harness/src/lib/feature-brief.mjs +324 -0
  18. package/packages/harness/src/lib/flight-recorder.mjs +332 -0
  19. package/packages/harness/src/lib/harness-lock.mjs +147 -0
  20. package/packages/harness/src/lib/harness-region.mjs +159 -0
  21. package/packages/harness/src/lib/inputs-hash.mjs +194 -0
  22. package/packages/harness/src/lib/reachability.mjs +211 -0
  23. package/packages/harness/src/lib/receipt-validate.mjs +234 -0
  24. package/packages/harness/src/lib/render.mjs +254 -0
  25. package/packages/harness/src/lib/spec-coverage.mjs +131 -0
  26. package/packages/harness/src/lib/step-cache.mjs +221 -0
  27. package/packages/harness/src/lib/token-drift.mjs +94 -0
  28. package/packages/harness/src/lib/tree.mjs +108 -0
  29. package/packages/harness/src/preview-gallery.mjs +122 -0
  30. package/packages/harness/src/receipt-check.mjs +96 -0
  31. package/packages/harness/src/record-audit.mjs +83 -0
  32. package/packages/harness/src/refusal-demo.mjs +498 -0
  33. package/packages/harness/src/retrospective.mjs +51 -0
  34. package/packages/harness/src/scaffold-feature.mjs +723 -0
  35. package/packages/harness/src/setup-hooks.mjs +33 -0
  36. package/packages/harness/src/verify.mjs +1709 -0
  37. package/packages/harness/src/walkthrough.mjs +499 -0
  38. package/packages/harness/src/watch.mjs +622 -0
  39. package/packages/receipts/package.json +36 -0
  40. package/packages/receipts/src/index.mjs +16 -0
  41. package/packages/receipts/src/inputs-hash.mjs +194 -0
  42. package/packages/receipts/src/receipt-validate.mjs +234 -0
  43. package/src/commands/upgrade.mjs +96 -0
  44. package/src/lib/harness-upgrade.mjs +159 -2
  45. package/src/scaffold.mjs +60 -1
  46. package/template/AGENTS.md +5 -0
  47. package/template/CLAUDE.md +30 -0
  48. package/template/gitignore +8 -0
  49. package/template/qa/lib/harness-lock.mjs +147 -0
  50. package/template/qa/lib/harness-region.mjs +159 -0
  51. package/template/qa/lib/inputs-hash.mjs +1 -1
  52. package/template/qa/lib/receipt-validate.mjs +1 -1
  53. package/template/qa/preview-gallery.mjs +17 -2
  54. package/template/qa/verify.mjs +95 -1
@@ -0,0 +1,147 @@
1
+ // affected-tests.mjs — FAST-MODE-ONLY scoping of the unit-test suite to the
2
+ // tests plausibly affected by the working-tree change.
3
+ //
4
+ // The full lane always runs the whole suite; this module exists so the inner
5
+ // loop (`verify --fast`) doesn't pay for every test on a one-file edit. Its
6
+ // honesty contract:
7
+ //
8
+ // - FALSE NEGATIVES ARE ACCEPTABLE HERE — AND ONLY HERE. A filtered fast
9
+ // run can miss a cross-feature regression; that is tolerable purely
10
+ // because the full, unfiltered suite runs at the checkpoint (the full
11
+ // lane), where done is actually decided. No other gate gets this license.
12
+ // - FAIL OPEN, NEVER FAIL SILENT. No git, a failed git command, an unmapped
13
+ // change, a broad-impact change — every uncertain case runs EVERYTHING,
14
+ // and the caller reports which case it was in the step's output and the
15
+ // receipt, so a filtered run can never be mistaken for the full suite.
16
+ // - The BLAST-RADIUS ESCAPE HATCH is mandatory: some paths fan out too
17
+ // widely to subset safely (build files rewire compilation, DI rewires
18
+ // object graphs, theme/tokens and shared components render into every
19
+ // screen, qa/ is the harness judging itself, and anything outside
20
+ // composeApp/src is by definition not a scoped source edit). Any one such
21
+ // change disables filtering for the run.
22
+ //
23
+ // Pure functions over path lists — git access is injected/separate so the
24
+ // engine suite can test every branch with no repo state.
25
+
26
+ import { execSync } from "node:child_process";
27
+ import path from "node:path";
28
+
29
+ /**
30
+ * Lane OUTPUTS, excluded from the changed-set before any classification.
31
+ * The receipt (qa/evidence/) and hashed artifacts (qa-artifacts/) change on
32
+ * every lane run by design; counting them as "changes" would make the qa/**
33
+ * escape hatch self-triggering forever — run N's receipt forcing run N+1 to
34
+ * the full suite, permanently. They cannot affect a test outcome (the same
35
+ * principle as inputs-hash.mjs's EXCLUDED_PREFIXES: lane outputs are not
36
+ * verdict inputs).
37
+ */
38
+ export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts"];
39
+
40
+ function isLaneOutput(p) {
41
+ return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
42
+ }
43
+
44
+ /**
45
+ * The mandatory blast-radius escape hatch: paths whose change fans out too
46
+ * widely to subset the suite safely. Returns the human-readable category when
47
+ * `p` is broad-impact, else null. Checked in order; the first match names the
48
+ * reason.
49
+ * @param {string} p POSIX relpath from the project root
50
+ * @returns {string|null}
51
+ */
52
+ export function broadImpactReason(p) {
53
+ if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
54
+ return "build files rewire compilation";
55
+ }
56
+ if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
57
+ if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
58
+ if (p.includes("presentation/components/")) return "shared components render into every screen";
59
+ if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
60
+ if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
61
+ return null;
62
+ }
63
+
64
+ /**
65
+ * Derive the fast-mode unit-test filter from a list of changed paths.
66
+ *
67
+ * Mapping (deliberately simple and defensible): each changed `.kt` file under
68
+ * composeApp/src contributes its package's last segment — the parent
69
+ * directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
70
+ * template's package-mirrors-path conformance makes a package segment) — and
71
+ * the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
72
+ * class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
73
+ * the feature, which over-selects a little and under-maintains nothing.
74
+ *
75
+ * @param {string[]} changedPaths relpaths (either separator style) — tracked
76
+ * diffs plus untracked files, as from changedWorkingTreePaths()
77
+ * @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
78
+ * {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
79
+ * mode "all" ALWAYS carries the honest reason to report.
80
+ */
81
+ export function deriveAffectedFilter(changedPaths) {
82
+ const paths = [...new Set((changedPaths ?? [])
83
+ .filter((p) => typeof p === "string" && p.length > 0)
84
+ .map((p) => p.split(path.sep).join("/")))]
85
+ .filter((p) => !isLaneOutput(p))
86
+ .sort();
87
+
88
+ if (paths.length === 0) {
89
+ return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
90
+ }
91
+
92
+ for (const p of paths) {
93
+ const broad = broadImpactReason(p);
94
+ if (broad) {
95
+ return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
96
+ }
97
+ }
98
+
99
+ // Every remaining path is a scoped file under composeApp/src. Only .kt
100
+ // files map to test patterns; a change that maps to nothing (resources,
101
+ // manifests) falls open to the full suite below.
102
+ const ktPaths = paths.filter((p) => p.endsWith(".kt"));
103
+ const segments = new Set();
104
+ for (const p of ktPaths) {
105
+ const seg = path.posix.basename(path.posix.dirname(p));
106
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
107
+ }
108
+
109
+ if (segments.size === 0) {
110
+ return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
111
+ }
112
+
113
+ return {
114
+ mode: "filtered",
115
+ patterns: [...segments].sort().map((s) => `*${s}*`),
116
+ sourcePaths: ktPaths,
117
+ };
118
+ }
119
+
120
+ function defaultRunGit(args, root) {
121
+ try {
122
+ return execSync(`git ${args}`, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The working-tree change: tracked files differing from HEAD (staged or not)
130
+ * plus untracked-but-not-ignored files — the same "what will this commit
131
+ * touch" surface inputs-hash.mjs hashes.
132
+ *
133
+ * Returns null when git is unavailable or either command fails — the caller
134
+ * MUST treat null as "run everything" (fail open) and say so (never fail
135
+ * silent).
136
+ *
137
+ * @param {string} root project root
138
+ * @param {(args: string, root: string) => string|null} [runGit] injectable for tests
139
+ * @returns {string[]|null}
140
+ */
141
+ export function changedWorkingTreePaths(root, runGit = defaultRunGit) {
142
+ const diff = runGit("diff --name-only HEAD", root);
143
+ const untracked = runGit("ls-files --others --exclude-standard", root);
144
+ if (diff === null || untracked === null) return null;
145
+ const lines = (out) => out.replace(/\n+$/, "").split("\n").filter(Boolean);
146
+ return [...new Set([...lines(diff), ...lines(untracked)])];
147
+ }