create-cmp-cli 0.8.0 → 0.10.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 (85) hide show
  1. package/README.md +83 -9
  2. package/llms.txt +5 -1
  3. package/package.json +1 -1
  4. package/src/lib/adr-seed.mjs +178 -0
  5. package/src/lib/tabs.mjs +97 -4
  6. package/src/scaffold.mjs +52 -1
  7. package/template/.claude/skills/add-feature/SKILL.md +35 -10
  8. package/template/.claude/skills/add-repository/SKILL.md +1 -1
  9. package/template/.claude/skills/add-screen/SKILL.md +13 -7
  10. package/template/.githooks/pre-push +24 -0
  11. package/template/CLAUDE.md +213 -47
  12. package/template/README.md +32 -27
  13. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/CrashRecorder.kt +99 -0
  14. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/DbInspector.kt +144 -0
  15. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorCatalog.kt +19 -0
  16. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorHttpServer.kt +177 -21
  17. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/InspectorInit.kt +8 -4
  18. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/LiveSemanticsJson.kt +10 -0
  19. package/template/composeApp/src/androidDebug/kotlin/com/example/app/inspector/NavInspector.kt +62 -0
  20. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/AppResultCatching.kt +32 -0
  21. package/template/composeApp/src/commonMain/kotlin/com/example/app/data/remote/ItemRepositoryImpl.kt +9 -2
  22. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/model/DomainError.kt +21 -0
  23. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/repository/ItemRepository.kt +4 -1
  24. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/result/AppResult.kt +23 -0
  25. package/template/composeApp/src/commonMain/kotlin/com/example/app/domain/usecase/GetItemsUseCase.kt +4 -1
  26. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/brand/BrandMark.kt +75 -0
  27. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppBottomBar.kt +138 -0
  28. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppButton.kt +56 -0
  29. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppHeader.kt +63 -0
  30. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/AppIconButton.kt +48 -0
  31. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/BaseScreen.kt +16 -8
  32. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentStateContainer.kt +105 -0
  33. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ContentUiState.kt +18 -0
  34. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/EmptyState.kt +58 -0
  35. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ErrorState.kt +52 -0
  36. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ListItemCard.kt +77 -0
  37. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/ScreenColumn.kt +47 -0
  38. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/Shimmer.kt +90 -0
  39. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/components/TestTagAutomation.kt +9 -9
  40. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/DetailScreen.kt +5 -27
  41. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeScreen.kt +14 -70
  42. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/home/HomeViewModel.kt +33 -13
  43. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppNavHost.kt +22 -0
  44. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/AppShell.kt +7 -109
  45. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/navigation/NavInspectionHook.kt +31 -0
  46. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/profile/ProfileScreen.kt +4 -27
  47. package/template/composeApp/src/commonMain/kotlin/com/example/app/presentation/theme/Typography.kt +70 -6
  48. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/AppResultCatchingTest.kt +52 -0
  49. package/template/composeApp/src/commonTest/kotlin/com/example/app/data/remote/ItemRepositoryImplTest.kt +29 -4
  50. package/template/composeApp/src/commonTest/kotlin/com/example/app/domain/usecase/GetItemsUseCaseTest.kt +8 -6
  51. package/template/composeApp/src/commonTest/kotlin/com/example/app/presentation/home/HomeViewModelTest.kt +39 -27
  52. package/template/composeApp/src/commonTest/kotlin/com/example/app/testing/fakes/FakeItemRepository.kt +10 -6
  53. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/ComponentStories.kt +300 -0
  54. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewDaemon.kt +5 -0
  55. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewHarness.kt +91 -1
  56. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewRegistry.kt +37 -1
  57. package/template/composeApp/src/desktopMain/kotlin/com/example/app/inspector/PreviewSemanticsJson.kt +14 -1
  58. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +250 -16
  59. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ComponentConformanceTest.kt +84 -0
  60. package/template/composeApp/src/desktopTest/kotlin/com/example/app/presentation/home/HomeScreenTest.kt +36 -4
  61. package/template/docs/ARCHITECTURE.md +372 -34
  62. package/template/docs/TESTING.md +13 -5
  63. package/template/docs/adr/0002-maestro-over-appium-for-e2e.md +39 -0
  64. package/template/docs/adr/0003-jvm-desktop-target-is-harness-infrastructure.md +39 -0
  65. package/template/docs/adr/0004-fakes-not-mocks-for-unit-tests.md +48 -0
  66. package/template/qa/approvals.json +42 -0
  67. package/template/qa/approve.mjs +139 -0
  68. package/template/qa/arch-doc.mjs +69 -0
  69. package/template/qa/comment.mjs +76 -0
  70. package/template/qa/comments.json +4 -0
  71. package/template/qa/e2e/smoke.yaml +6 -0
  72. package/template/qa/golden/home.json +3 -3
  73. package/template/qa/lib/a11y.mjs +17 -8
  74. package/template/qa/lib/approvals.mjs +822 -0
  75. package/template/qa/lib/arch-doc.mjs +451 -0
  76. package/template/qa/lib/comments.mjs +252 -0
  77. package/template/qa/lib/component-stories.mjs +183 -0
  78. package/template/qa/lib/inputs-hash.mjs +5 -1
  79. package/template/qa/scaffold-feature.mjs +184 -67
  80. package/template/qa/setup-hooks.mjs +33 -0
  81. package/template/qa/verify.mjs +181 -15
  82. package/template/qa/walkthrough.mjs +499 -0
  83. package/template/specs/app-base.spec.md +49 -7
  84. package/template/specs/home.spec.md +7 -4
  85. package/template/specs/intent.md +50 -0
@@ -0,0 +1,822 @@
1
+ // The hash-bound human-approval data model (VERIFICATION-LAYER-DESIGN.md §2,
2
+ // extended by GENESIS-FLOW-DESIGN.md §1/§2/§3 — the genesis flow's registry,
3
+ // express lane, and reopen mechanics).
4
+ //
5
+ // Reuses ADR-0005's philosophy exactly (docs/adr/0005-evidence-binding-by-inputs-hash.md
6
+ // in the create-cmp repo): an approval is valid iff a stored content hash matches a
7
+ // recompute of the SAME files, right now. No new hashing idea — just applied to a
8
+ // smaller, human-curated surface (one governed artifact) instead of the whole
9
+ // verified tree.
10
+ //
11
+ // Three concerns, kept separable:
12
+ // 1. The REGISTRY (`listGovernedArtifacts`) — artifact id -> resolved file list, in
13
+ // GENESIS-FLOW-DESIGN.md §1 order: intent(0), architecture(1), exemplar-spec(2),
14
+ // exemplar-feature(3), design-system(4), components(5), then one
15
+ // `feature-spec:<name>` (6+) per non-base, non-exemplar spec file present in
16
+ // specs/ right now. (Spec-first: the exemplar's clauses are confirmed before the
17
+ // slice is built. UI-first: design system + components are distilled from the
18
+ // real screens, so they lock after the exemplar.) The exemplar (2/3) is
19
+ // CONFIGURABLE — see
20
+ // `getExemplarFeature`/`resolveExemplarNames` below — defaulting to `home` so
21
+ // every ledger written before this config key existed keeps meaning what it
22
+ // meant. The registry is recomputed on every call — it reflects the tree as it
23
+ // stands, never a stale snapshot.
24
+ // 2. STATE (`loadApprovals`/`saveApprovals`) — qa/approvals.json, the human's
25
+ // decisions: { artifact, status, hash, approvedAt, mode?, reopenedAt? } plus the
26
+ // top-level `exemplarFeature` config key. Absent or corrupt is TOLERATED
27
+ // (treated as empty / all-unreviewed / default exemplar) — this ledger must
28
+ // never crash the verify lane or the stamper.
29
+ //
30
+ // Ledger migration note (architecture-document-standard.md §4.4): there is no
31
+ // schema-version bump or migration step anywhere in this file today (schema
32
+ // stays `cmp-approvals/1`, additive-only — see GENESIS-FLOW-DESIGN.md §2's
33
+ // express-lane note) — a widened hash BASIS (e.g. the `architecture` artifact
34
+ // growing from spec-only to spec+stripped-doc) is handled the same honest way
35
+ // every other content change is: `resolveArtifactStatus` recomputes on every
36
+ // read and compares against the STORED hash. An approval recorded under the
37
+ // old (narrower) basis simply stops matching the new recompute the first time
38
+ // it's read after this change ships, and correctly reports
39
+ // "changed-since-approval" — never a silent, un-re-earned "approved". This is
40
+ // not a special case: it is the SAME mechanism that already invalidates an
41
+ // approval when the governed files themselves change; widening what counts as
42
+ // "the governed files" for one artifact is just another such change. No
43
+ // separate migration code path exists or is needed.
44
+ // 3. The GATE (`evaluateApprovalsGate`) — combines registry + state into one
45
+ // per-artifact status (unreviewed / approved / changed-since-approval /
46
+ // reopened) and one aggregate verdict (PASS/FAIL/SKIP) for the verify-lane step
47
+ // to report. `reopened` behaves like `unreviewed` for the gate (SKIP-warn,
48
+ // non-blocking) — sanctioned redesign is never drift.
49
+ //
50
+ // Consumers: qa/approve.mjs (the CLI — thin shell over this file), qa/verify.mjs
51
+ // (the `approvals` gate), qa/scaffold-feature.mjs (seeds a new feature's spec as
52
+ // unreviewed, and resolves its clone-FROM exemplar through `resolveExemplarNames`).
53
+ // The console (inspector/mcp/src/lib/approvals-bridge.mjs) calls this same library.
54
+
55
+ import { createHash } from "node:crypto";
56
+ import fs from "node:fs";
57
+ import path from "node:path";
58
+
59
+ import { ARCH_DOC_REL_PATH, stripGeneratedSections } from "./arch-doc.mjs";
60
+
61
+ export const APPROVALS_REL_PATH = "qa/approvals.json";
62
+ export const APPROVALS_SCHEMA = "cmp-approvals/1";
63
+
64
+ // Kotlin source-set roots, relative to project root — mirrors qa/scaffold-feature.mjs's
65
+ // SRC() helper (composeApp/src/<sourceSet>/kotlin/<packageDir>).
66
+ const KOTLIN_SOURCE_SETS = {
67
+ commonMain: "composeApp/src/commonMain/kotlin",
68
+ commonTest: "composeApp/src/commonTest/kotlin",
69
+ desktopTest: "composeApp/src/desktopTest/kotlin",
70
+ };
71
+
72
+ // The canonical 11-file EXEMPLAR SHAPE (10 kotlin files + 1 spec), parametrized by
73
+ // the exemplar's own names — F (PascalCase feature, e.g. "Home"), f (lowercase
74
+ // package segment, e.g. "home"), E (PascalCase entity, e.g. "Item"). This is the
75
+ // SAME shape qa/scaffold-feature.mjs's ALL_FILES clones FROM (GENESIS-FLOW-DESIGN.md
76
+ // §1's "configurable exemplar") — the stamper imports this exact function so the
77
+ // clone-source list and the governed-artifact list can never drift from each other
78
+ // (single source of truth, not a parallel copy to keep in sync by hand).
79
+ // @param {string} F PascalCase feature name (e.g. "Home", "Favorites")
80
+ // @param {string} f lowercase package-segment name (e.g. "home", "favorites")
81
+ // @param {string} E PascalCase entity name (e.g. "Item", "Favorite")
82
+ // @returns {Array<{sourceSet: string, rel: string}>}
83
+ export function exemplarKotlinFileSet(F, f, E) {
84
+ return [
85
+ { sourceSet: "commonMain", rel: `domain/model/${E}.kt` },
86
+ { sourceSet: "commonMain", rel: `domain/repository/${E}Repository.kt` },
87
+ { sourceSet: "commonMain", rel: `domain/usecase/Get${E}sUseCase.kt` },
88
+ { sourceSet: "commonMain", rel: `data/remote/${E}RepositoryImpl.kt` },
89
+ { sourceSet: "commonTest", rel: `testing/fakes/Fake${E}Repository.kt` },
90
+ { sourceSet: "commonMain", rel: `presentation/${f}/${F}Screen.kt` },
91
+ { sourceSet: "commonMain", rel: `presentation/${f}/${F}ViewModel.kt` },
92
+ { sourceSet: "commonTest", rel: `presentation/${f}/${F}ViewModelTest.kt` },
93
+ { sourceSet: "desktopTest", rel: `presentation/${f}/${F}ScreenTest.kt` },
94
+ { sourceSet: "desktopTest", rel: `presentation/${f}/${F}GoldenTreeTest.kt` },
95
+ ];
96
+ }
97
+
98
+ // Naive de-pluralization, shared verbatim with qa/scaffold-feature.mjs's own
99
+ // entity-name default (a feature stamped without `--entity` gets this exact
100
+ // guess). Exported so both the stamper (deriving a NEW feature's entity) and this
101
+ // registry (guessing a CONFIGURED exemplar's entity from its feature name alone —
102
+ // see resolveExemplarNames) apply the identical heuristic. Unreliable for
103
+ // irregular nouns by design (the skill surfaces the guess for human override at
104
+ // stamp time); a wrong guess here simply fails to resolve files, which is refused
105
+ // (never fabricated), not silently wrong.
106
+ export function defaultEntityName(feature) {
107
+ if (feature.endsWith("ies") && feature.length > 3) return `${feature.slice(0, -3)}y`;
108
+ if (feature.endsWith("s") && !feature.endsWith("ss")) return feature.slice(0, -1);
109
+ return feature;
110
+ }
111
+
112
+ function toPascalCase(f) {
113
+ return f.charAt(0).toUpperCase() + f.slice(1);
114
+ }
115
+
116
+ function toUpperSnake(F) {
117
+ return F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
118
+ }
119
+
120
+ /**
121
+ * Resolve the CONFIGURED exemplar's names — the ones the exemplar-feature/
122
+ * exemplar-spec governed artifacts (and qa/scaffold-feature.mjs's clone source)
123
+ * are built from.
124
+ *
125
+ * `home` (the default, and the only exemplar that predates configurability) is a
126
+ * hardcoded exception: its entity is `Item`, not derivable from `Home` by
127
+ * `defaultEntityName` (which would naively guess `Home`). Every OTHER exemplar is
128
+ * itself a feature that was stamped by qa/scaffold-feature.mjs, so its entity
129
+ * followed defaultEntityName(F) UNLESS it was stamped with an explicit `--entity`
130
+ * override — a choice this config key cannot see. In that mismatch case the guess
131
+ * is wrong and the file set simply fails to resolve (0 or partial files), which
132
+ * `resolveArtifactStatus`/`approveArtifact` already refuse rather than fabricate —
133
+ * the correct failure mode, not a special case to add here.
134
+ * @param {string} root
135
+ * @returns {{f: string, F: string, F_UPPER: string, E: string}}
136
+ */
137
+ export function resolveExemplarNames(root) {
138
+ const f = getExemplarFeature(root);
139
+ const F = toPascalCase(f);
140
+ const F_UPPER = toUpperSnake(F);
141
+ const E = f === "home" ? "Item" : defaultEntityName(F);
142
+ return { f, F, F_UPPER, E };
143
+ }
144
+
145
+ // Backward-compatible constants for the DEFAULT (`home`) exemplar — kept exported
146
+ // because they describe the shipped template's own exemplar shape independent of
147
+ // any project's configuration, and because they're the fixture the "stamping from
148
+ // home must be byte-identical" pin (test/genesis-flow.test.mjs) anchors to.
149
+ export const EXEMPLAR_FEATURE_KOTLIN_FILES = exemplarKotlinFileSet("Home", "home", "Item");
150
+ export const EXEMPLAR_SPEC_REL = "specs/home.spec.md";
151
+ export const ARCHITECTURE_SPEC_REL = "specs/app-base.spec.md";
152
+ export const INTENT_REL = "specs/intent.md";
153
+
154
+ // ── Package resolution ───────────────────────────────────────────────────────
155
+ // Mirrors qa/scaffold-feature.mjs's resolvePackage() primary path (the
156
+ // composeApp/build.gradle.kts namespace). Unlike the stamper, this NEVER dies —
157
+ // an unresolved package means the kotlin-rooted artifacts resolve to zero files.
158
+ // Zero resolution never CRASHES anything (the lane and the stamper stay up),
159
+ // but it is NOT benign for decisions: an approval over zero files would be the
160
+ // empty-input sha256 attesting nothing — a silent vacuous PASS, the exact
161
+ // failure mode this harness exists to kill (evidence must attest execution).
162
+ // So: approveArtifact REFUSES zero-file artifacts, and an already-approved
163
+ // artifact whose files stop resolving goes to changed-since-approval (FAIL),
164
+ // never PASS.
165
+ //
166
+ // IMPORTANT: detect "unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
167
+ // comparing against the literal string "__PACKAGE__". This file ships through
168
+ // the SAME scaffold pipeline that resolves that token — a literal comparison
169
+ // string is itself blindly text-substituted at stamp time (`replaceContents`
170
+ // does a global `"__PACKAGE__" -> config.package` replace over every template
171
+ // file's content, this one included), which would silently rewrite the
172
+ // sentinel into the real package and make the check always fail. A shape
173
+ // regex never spells the token out, so the pipeline has nothing to match.
174
+ const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
175
+
176
+ function resolvePackageDir(root) {
177
+ const gradleFile = path.join(root, "composeApp", "build.gradle.kts");
178
+ if (!fs.existsSync(gradleFile)) return null;
179
+ let contents;
180
+ try {
181
+ contents = fs.readFileSync(gradleFile, "utf8");
182
+ } catch {
183
+ return null;
184
+ }
185
+ const m = contents.match(/namespace\s*=\s*"([^"]+)"/);
186
+ if (!m || UNRESOLVED_TOKEN_RE.test(m[1])) return null;
187
+ return m[1].split(".").join("/");
188
+ }
189
+
190
+ function kotlinFile(root, sourceSet, rel) {
191
+ const packageDir = resolvePackageDir(root);
192
+ if (!packageDir) return null;
193
+ return path.posix.join(KOTLIN_SOURCE_SETS[sourceSet], packageDir, rel);
194
+ }
195
+
196
+ /**
197
+ * Is the project's package resolvable at all? False in the raw template (the
198
+ * namespace is still a placeholder token) and in any pre-stamp tree — the tell
199
+ * that this is not a generated project. The approve CLI refuses to WRITE
200
+ * approvals in such a tree (recording decisions against a template pollutes
201
+ * the template itself); read-only status remains available.
202
+ * @param {string} root
203
+ * @returns {boolean}
204
+ */
205
+ export function isPackageResolvable(root) {
206
+ return resolvePackageDir(root) !== null;
207
+ }
208
+
209
+ // ── Components glob ─────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * Sorted list of `presentation/components/*.kt` files under the resolved
213
+ * package, non-recursive (GENESIS-FLOW-DESIGN.md §1's `components` artifact — the
214
+ * component vocabulary conversation 3 approves). Package-unresolvable or a
215
+ * missing/empty directory both yield `[]` — resolveArtifactStatus/approveArtifact
216
+ * already treat a 0-file artifact as unresolvable ("a components glob matching
217
+ * zero files is unresolvable, not approvable-empty" — §1), so no special-casing
218
+ * is needed here beyond returning the honest (possibly empty) list.
219
+ * @param {string} root
220
+ * @returns {string[]} root-relative paths, sorted
221
+ */
222
+ function listComponentFiles(root) {
223
+ const dirRel = kotlinFile(root, "commonMain", "presentation/components");
224
+ if (!dirRel) return [];
225
+ let entries;
226
+ try {
227
+ entries = fs.readdirSync(path.join(root, dirRel), { withFileTypes: true });
228
+ } catch {
229
+ return [];
230
+ }
231
+ return entries
232
+ .filter((e) => e.isFile() && e.name.endsWith(".kt"))
233
+ .map((e) => path.posix.join(dirRel, e.name))
234
+ .sort((a, b) => a.localeCompare(b));
235
+ }
236
+
237
+ // ── Registry ─────────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * The governed-artifact registry, resolved against the project at `root` right
241
+ * now. GENESIS-FLOW-DESIGN.md §1 definition order — two ordering principles,
242
+ * one per artifact kind (the dogfooding-run correction):
243
+ * BEHAVIORAL artifacts are SPEC-FIRST — the exemplar's clauses are proposed
244
+ * and human-confirmed BEFORE the slice is built (exemplar-spec precedes
245
+ * exemplar-feature, matching add-feature's discipline).
246
+ * VISUAL artifacts are UI-FIRST — the design system and component vocabulary
247
+ * are distilled FROM the real screens, so they lock AFTER the exemplar
248
+ * exists (a provisional palette carries the build until then).
249
+ * Order: intent(0), architecture(1), exemplar-spec(2), exemplar-feature(3),
250
+ * design-system(4), components(5), then one feature-spec:<name> (6+) per
251
+ * non-base, non-CONFIGURED-exemplar spec present.
252
+ *
253
+ * `complete: false` marks an artifact whose kotlin-rooted files could NOT be
254
+ * resolved (unresolvable package — raw template / pre-stamp tree). Such an
255
+ * artifact's `files` list is empty or partial (spec files only), so hashing it
256
+ * would attest nothing (or only a fraction) of what the artifact governs —
257
+ * approveArtifact refuses it, and the status surfaces treat it as unresolvable.
258
+ * @param {string} root absolute path to the project root
259
+ * @returns {Array<{id: string, label: string, files: string[], complete: boolean}>}
260
+ */
261
+ export function listGovernedArtifacts(root) {
262
+ const artifacts = [];
263
+ const packageResolved = resolvePackageDir(root) !== null;
264
+
265
+ artifacts.push({
266
+ id: "intent",
267
+ label: `Intent brief (${INTENT_REL})`,
268
+ files: [INTENT_REL],
269
+ complete: true,
270
+ });
271
+
272
+ artifacts.push({
273
+ id: "architecture",
274
+ label: `Architecture + structure (${ARCHITECTURE_SPEC_REL} + ${ARCH_DOC_REL_PATH}, generated sections stripped)`,
275
+ // Hashed via hashArchitectureArtifact (spec bytes + stripped-doc content),
276
+ // NOT the generic hashArtifactFiles — this list is still the artifact's
277
+ // expected-files surface (missing-file refusal messages, "what governs
278
+ // this" bookkeeping), just not what gets hashed raw. See computeArtifactHash.
279
+ files: [ARCHITECTURE_SPEC_REL, ARCH_DOC_REL_PATH],
280
+ complete: true,
281
+ });
282
+
283
+ const { f: exemplarF, F: exemplarF_Pascal, E: exemplarE } = resolveExemplarNames(root);
284
+ const exemplarSpecRel = `specs/${exemplarF}.spec.md`;
285
+ const exemplarKotlinFiles = exemplarKotlinFileSet(exemplarF_Pascal, exemplarF, exemplarE);
286
+
287
+ // Spec-first: the exemplar's behavior clauses are confirmed BEFORE the slice
288
+ // is built — the definition order is the discipline, not just a display order.
289
+ artifacts.push({
290
+ id: "exemplar-spec",
291
+ label: `Exemplar spec (${exemplarSpecRel})`,
292
+ files: [exemplarSpecRel],
293
+ complete: true,
294
+ });
295
+
296
+ artifacts.push({
297
+ id: "exemplar-feature",
298
+ label: `Exemplar feature (${exemplarF} — the file set the stamper clones)`,
299
+ files: [
300
+ ...exemplarKotlinFiles.map((f) => kotlinFile(root, f.sourceSet, f.rel)).filter(Boolean),
301
+ exemplarSpecRel,
302
+ ],
303
+ complete: packageResolved,
304
+ });
305
+
306
+ // UI-first: the design system LOCKS on the real exemplar (candidates render on
307
+ // real screens, never stubs), and the component vocabulary is DISTILLED from
308
+ // those screens — both follow the exemplar in the definition order.
309
+ artifacts.push({
310
+ id: "design-system",
311
+ label: "Design system (presentation/theme/Theme.kt, Tokens.kt)",
312
+ files: [
313
+ kotlinFile(root, "commonMain", "presentation/theme/Theme.kt"),
314
+ kotlinFile(root, "commonMain", "presentation/theme/Tokens.kt"),
315
+ ].filter(Boolean),
316
+ complete: packageResolved,
317
+ });
318
+
319
+ artifacts.push({
320
+ id: "components",
321
+ label: "Components (presentation/components/*.kt)",
322
+ files: listComponentFiles(root),
323
+ complete: packageResolved,
324
+ });
325
+
326
+ const specsDir = path.join(root, "specs");
327
+ if (fs.existsSync(specsDir)) {
328
+ const featureSpecs = fs
329
+ .readdirSync(specsDir)
330
+ .filter((f) => f.endsWith(".spec.md") && f !== "app-base.spec.md" && f !== `${exemplarF}.spec.md`)
331
+ .sort((a, b) => a.localeCompare(b));
332
+ for (const file of featureSpecs) {
333
+ const name = file.slice(0, -".spec.md".length);
334
+ artifacts.push({
335
+ id: `feature-spec:${name}`,
336
+ label: `Feature spec (specs/${file})`,
337
+ files: [`specs/${file}`],
338
+ complete: true,
339
+ });
340
+ }
341
+ }
342
+
343
+ return artifacts;
344
+ }
345
+
346
+ // ── Hashing (mirrors qa/lib/inputs-hash.mjs's computeInputsHash style) ───────
347
+
348
+ /**
349
+ * sha256 over the sorted `(path, sha256(content))` list of `relFiles` that
350
+ * currently exist under `root`. Deterministic; missing files are reported, not
351
+ * fatal — the hash is simply over what's present.
352
+ * @param {string} root
353
+ * @param {string[]} relFiles
354
+ * @returns {{ hash: string, fileCount: number, missing: string[] }}
355
+ */
356
+ export function hashArtifactFiles(root, relFiles) {
357
+ // Code-unit sort (default String sort), NOT localeCompare: the hash depends
358
+ // on iteration order and ICU collation varies with the machine's locale —
359
+ // an approval recorded on one machine must verify on every other.
360
+ const files = [...new Set(relFiles)].sort();
361
+ const present = [];
362
+ const missing = [];
363
+ for (const relPath of files) {
364
+ try {
365
+ if (fs.statSync(path.join(root, relPath)).isFile()) {
366
+ present.push(relPath);
367
+ continue;
368
+ }
369
+ } catch {
370
+ /* fall through to missing */
371
+ }
372
+ missing.push(relPath);
373
+ }
374
+
375
+ const overall = createHash("sha256");
376
+ for (const relPath of present) {
377
+ const bytes = fs.readFileSync(path.join(root, relPath));
378
+ const fileSha = createHash("sha256").update(bytes).digest("hex");
379
+ overall.update(`${relPath}\0${fileSha}\n`);
380
+ }
381
+ return { hash: overall.digest("hex"), fileCount: present.length, missing };
382
+ }
383
+
384
+ /**
385
+ * The `architecture` artifact's hash basis (docs/proposals/architecture-document-
386
+ * standard.md §4.4): `${ARCHITECTURE_SPEC_REL}`'s raw bytes + `${ARCH_DOC_REL_PATH}`
387
+ * with every `cmp:generated` marker's BODY stripped — `arch-doc.mjs`'s
388
+ * `stripGeneratedSections` is the ONE definition of "generated" for that doc,
389
+ * reused here rather than forked, so a new/changed marker id is understood
390
+ * identically by the regenerator and this hash.
391
+ *
392
+ * The doc's content is also normalized `\r\n` -> `\n` before hashing (spec
393
+ * files are hashed as raw bytes like every other artifact — a checkout-induced
394
+ * EOL difference in a Markdown prose doc is exactly the kind of accident that
395
+ * must never read as "authored drift", but the .spec.md files this repo ships
396
+ * are LF already and their exact bytes are what the human actually reviewed).
397
+ *
398
+ * Same row-hash shape as `hashArtifactFiles` (`path\0sha256(bytes)\n`, rows
399
+ * sorted by path) so the two schemes read the same way in a hex dump — this is
400
+ * a SEPARATE function (not a generic `hashArtifactFiles` call) only because the
401
+ * doc's bytes must be transformed (stripped + normalized) before hashing, never
402
+ * hashed raw.
403
+ *
404
+ * Regenerating a marker section (`node qa/arch-doc.mjs`) changes only the
405
+ * stripped-away body, so this hash does not move. Editing authored prose
406
+ * anywhere else in the doc — including adding, removing, or reordering a
407
+ * `cmp:generated` marker itself (structural, not generated content) — changes
408
+ * it, same as editing the spec.
409
+ * @param {string} root
410
+ * @returns {{ hash: string, fileCount: number, missing: string[] }}
411
+ */
412
+ export function hashArchitectureArtifact(root) {
413
+ const rows = [];
414
+ const missing = [];
415
+
416
+ try {
417
+ const specBytes = fs.readFileSync(path.join(root, ARCHITECTURE_SPEC_REL));
418
+ rows.push([ARCHITECTURE_SPEC_REL, createHash("sha256").update(specBytes).digest("hex")]);
419
+ } catch {
420
+ missing.push(ARCHITECTURE_SPEC_REL);
421
+ }
422
+
423
+ try {
424
+ const docRaw = fs.readFileSync(path.join(root, ARCH_DOC_REL_PATH), "utf8");
425
+ // Normalize line endings BEFORE stripping: the marker grammar
426
+ // (`arch-doc.mjs`'s MARKER_BLOCK_RE) matches a literal `\n` right after
427
+ // `-->`, so CRLF content would fail to match at all and nothing would be
428
+ // stripped — normalize first so the strip is EOL-independent, same as the
429
+ // hash itself.
430
+ const docNormalized = docRaw.replace(/\r\n/g, "\n");
431
+ const docStripped = stripGeneratedSections(docNormalized);
432
+ rows.push([ARCH_DOC_REL_PATH, createHash("sha256").update(docStripped, "utf8").digest("hex")]);
433
+ } catch {
434
+ missing.push(ARCH_DOC_REL_PATH);
435
+ }
436
+
437
+ // Code-unit sort for the same reason as hashArtifactFiles: hash order must
438
+ // not depend on the machine's locale.
439
+ rows.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
440
+ const overall = createHash("sha256");
441
+ for (const [relPath, fileSha] of rows) {
442
+ overall.update(`${relPath}\0${fileSha}\n`);
443
+ }
444
+ return { hash: overall.digest("hex"), fileCount: rows.length, missing };
445
+ }
446
+
447
+ /**
448
+ * Recompute one artifact's hash — `hashArchitectureArtifact` for `architecture`
449
+ * (spec + stripped doc, its own basis), `hashArtifactFiles(root, artifact.files)`
450
+ * for every other artifact (raw file bytes). The ONE dispatch point
451
+ * `resolveArtifactStatus`/`approveArtifact` both call, so the two never
452
+ * disagree about what "the architecture artifact's hash" means.
453
+ * @param {string} root
454
+ * @param {{id: string, files: string[]}} artifact
455
+ * @returns {{ hash: string, fileCount: number, missing: string[] }}
456
+ */
457
+ function computeArtifactHash(root, artifact) {
458
+ return artifact.id === "architecture" ? hashArchitectureArtifact(root) : hashArtifactFiles(root, artifact.files);
459
+ }
460
+
461
+ // ── State (qa/approvals.json) ─────────────────────────────────────────────────
462
+
463
+ /**
464
+ * Load qa/approvals.json. Absent or corrupt (unparsable JSON, wrong shape) is
465
+ * TOLERATED — returns the empty state, which resolves every artifact as
466
+ * "unreviewed" and every exemplar lookup to the default (`home`). Never throws.
467
+ *
468
+ * `exemplarFeature` is `undefined` when the key is absent or not a non-empty
469
+ * string — callers resolve the default (`getExemplarFeature`), never this
470
+ * function directly, so every ledger written before this key existed keeps
471
+ * meaning what it meant (GENESIS-FLOW-DESIGN.md §1).
472
+ * @param {string} root
473
+ * @returns {{ schema: string, artifacts: Array<{artifact: string, status: string, hash: (string|null), approvedAt: (string|null), mode?: string, reopenedAt?: string}>, exemplarFeature: (string|undefined) }}
474
+ */
475
+ export function loadApprovals(root) {
476
+ const empty = { schema: APPROVALS_SCHEMA, artifacts: [], exemplarFeature: undefined };
477
+ const p = path.join(root, APPROVALS_REL_PATH);
478
+ let raw;
479
+ try {
480
+ raw = fs.readFileSync(p, "utf8");
481
+ } catch {
482
+ return empty;
483
+ }
484
+ try {
485
+ const parsed = JSON.parse(raw);
486
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.artifacts)) return empty;
487
+ const exemplarFeature =
488
+ typeof parsed.exemplarFeature === "string" && parsed.exemplarFeature.trim() !== ""
489
+ ? parsed.exemplarFeature.trim()
490
+ : undefined;
491
+ return { schema: parsed.schema ?? APPROVALS_SCHEMA, artifacts: parsed.artifacts, exemplarFeature };
492
+ } catch {
493
+ return empty;
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Write qa/approvals.json (deterministic key order, trailing newline).
499
+ * `exemplarFeature` is included only when the caller explicitly passes one
500
+ * (undefined is omitted, never written as a literal `null`/`"undefined"`) — every
501
+ * internal transition (approveArtifact, seedUnreviewed, approveAllDefaults,
502
+ * reopenArtifact) reloads and threads the CURRENT value through so a write never
503
+ * silently drops a previously-configured exemplar.
504
+ * @param {string} root
505
+ * @param {{ artifacts: Array<object>, exemplarFeature?: string }} state
506
+ */
507
+ export function saveApprovals(root, state) {
508
+ const p = path.join(root, APPROVALS_REL_PATH);
509
+ fs.mkdirSync(path.dirname(p), { recursive: true });
510
+ const out = { schema: APPROVALS_SCHEMA, artifacts: state.artifacts };
511
+ if (state.exemplarFeature !== undefined) out.exemplarFeature = state.exemplarFeature;
512
+ fs.writeFileSync(p, `${JSON.stringify(out, null, 2)}\n`);
513
+ }
514
+
515
+ /**
516
+ * The configured exemplar feature's lowercase name (the package-segment form,
517
+ * e.g. `"home"`, `"favorites"`) — `qa/approvals.json`'s top-level
518
+ * `exemplarFeature` key, defaulting to `"home"` when absent (GENESIS-FLOW-DESIGN.md
519
+ * §1). This is the ONE function both `resolveExemplarNames` (registry) and
520
+ * qa/scaffold-feature.mjs (clone-source resolution) call — never read the raw key
521
+ * directly, so the default lives in exactly one place.
522
+ * @param {string} root
523
+ * @returns {string}
524
+ */
525
+ export function getExemplarFeature(root) {
526
+ return loadApprovals(root).exemplarFeature ?? "home";
527
+ }
528
+
529
+ /**
530
+ * Seed one artifact as unreviewed if it isn't already recorded. Idempotent —
531
+ * a second call for the same id is a no-op. Used by qa/scaffold-feature.mjs to
532
+ * seed a new feature's spec (create-if-missing, tolerant when absent — this
533
+ * never throws, so a stamp is never blocked by the approvals ledger).
534
+ * @param {string} root
535
+ * @param {string} artifactId
536
+ * @returns {{ added: boolean }}
537
+ */
538
+ export function seedUnreviewed(root, artifactId) {
539
+ const state = loadApprovals(root);
540
+ if (state.artifacts.some((a) => a.artifact === artifactId)) return { added: false };
541
+ state.artifacts.push({ artifact: artifactId, status: "unreviewed", hash: null, approvedAt: null });
542
+ saveApprovals(root, state);
543
+ return { added: true };
544
+ }
545
+
546
+ // ── Status resolution ─────────────────────────────────────────────────────────
547
+
548
+ function shortHash(hash) {
549
+ return hash ? hash.slice(0, 8) : "none";
550
+ }
551
+
552
+ /**
553
+ * Resolve one artifact's live status: recompute its hash now and compare
554
+ * against the stored record (if any).
555
+ * - no stored record, or stored status !== "approved"/"reopened" -> "unreviewed"
556
+ * - stored status === "reopened" -> "reopened", UNCONDITIONALLY — a reopened
557
+ * artifact never re-derives "changed-since-approval" from further edits (there
558
+ * is no live approval to compare against once reopened; it's fluid again by
559
+ * definition until the next real approveArtifact call). This is the
560
+ * sanctioned-redesign-vs-drift asymmetry the reopen mechanic exists for
561
+ * (GENESIS-FLOW-DESIGN.md §2): only an `approved` artifact can go stale.
562
+ * - approved + hash still matches (over >0 files) -> "approved"
563
+ * - approved + hash no longer matches -> "changed-since-approval"
564
+ * - approved + artifact NOW unresolvable (0 files, or an incomplete kotlin
565
+ * file set) -> "changed-since-approval", UNCONDITIONALLY — even if the
566
+ * stored hash equals the recompute (a hand-written or legacy vacuous
567
+ * approval over the degraded set). An approval that covers none (or only a
568
+ * fraction) of what the artifact governs attests nothing and must never
569
+ * read as PASS.
570
+ * `resolvable` is false when the artifact resolves to 0 files right now OR its
571
+ * file set is incomplete (kotlin roots unresolvable — see listGovernedArtifacts).
572
+ * `mode` (e.g. `"defaults-accepted"`) and `reopenedAt` are surfaced only when the
573
+ * stored record actually carries them — never as an explicit `undefined` key, so
574
+ * structural equality checks against a plain unreviewed/approved status shape
575
+ * still hold.
576
+ * @returns {{id: string, label: string, status: string, hash: string, storedHash: (string|null), approvedAt: (string|null), fileCount: number, missing: string[], resolvable: boolean, mode?: string, reopenedAt?: string}}
577
+ */
578
+ export function resolveArtifactStatus(root, artifact, storedRecord) {
579
+ const recomputed = computeArtifactHash(root, artifact);
580
+ const resolvable = recomputed.fileCount > 0 && artifact.complete !== false;
581
+
582
+ if (storedRecord && storedRecord.status === "reopened") {
583
+ return {
584
+ id: artifact.id,
585
+ label: artifact.label,
586
+ status: "reopened",
587
+ hash: recomputed.hash,
588
+ storedHash: storedRecord.hash ?? null,
589
+ approvedAt: storedRecord.approvedAt ?? null,
590
+ fileCount: recomputed.fileCount,
591
+ missing: recomputed.missing,
592
+ resolvable,
593
+ reopenedAt: storedRecord.reopenedAt,
594
+ };
595
+ }
596
+
597
+ if (!storedRecord || storedRecord.status !== "approved") {
598
+ return {
599
+ id: artifact.id,
600
+ label: artifact.label,
601
+ status: "unreviewed",
602
+ hash: recomputed.hash,
603
+ storedHash: null,
604
+ approvedAt: null,
605
+ fileCount: recomputed.fileCount,
606
+ missing: recomputed.missing,
607
+ resolvable,
608
+ };
609
+ }
610
+ const changed = !resolvable || storedRecord.hash !== recomputed.hash;
611
+ return {
612
+ id: artifact.id,
613
+ label: artifact.label,
614
+ status: changed ? "changed-since-approval" : "approved",
615
+ hash: recomputed.hash,
616
+ storedHash: storedRecord.hash,
617
+ approvedAt: storedRecord.approvedAt,
618
+ fileCount: recomputed.fileCount,
619
+ missing: recomputed.missing,
620
+ resolvable,
621
+ ...(storedRecord.mode ? { mode: storedRecord.mode } : {}),
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Every governed artifact's live status, right now.
627
+ * @param {string} root
628
+ * @returns {Array<ReturnType<typeof resolveArtifactStatus>>}
629
+ */
630
+ export function getApprovalStatuses(root) {
631
+ const registry = listGovernedArtifacts(root);
632
+ const state = loadApprovals(root);
633
+ const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
634
+ return registry.map((artifact) => resolveArtifactStatus(root, artifact, byId.get(artifact.id)));
635
+ }
636
+
637
+ // ── Transitions ────────────────────────────────────────────────────────────────
638
+
639
+ /**
640
+ * Record an approval: recompute the artifact's hash now, stamp the time,
641
+ * upsert into qa/approvals.json. A fresh record always REPLACES the stored one
642
+ * wholesale (never merges) — so a real approval on a previously
643
+ * defaults-accepted or reopened artifact automatically clears `mode` and
644
+ * `reopenedAt`, with no separate "clear" step needed.
645
+ *
646
+ * REFUSES an unresolvable artifact — one that resolves to 0 files, or whose
647
+ * kotlin-rooted file set could not be resolved at all (`complete: false`). An
648
+ * approval over 0 files would record the empty-input sha256; an approval over
649
+ * a partial set would attest only a fraction of what the artifact governs.
650
+ * Both are silently vacuous — the exact failure mode this harness exists to
651
+ * kill (evidence must attest execution). Refusal cases: the project package is
652
+ * unresolvable (raw template / pre-stamp tree), the artifact's expected files
653
+ * are all missing on disk, or (a dynamic artifact, e.g. `components`) nothing
654
+ * currently matches its pattern.
655
+ * @param {string} root
656
+ * @param {string} artifactId
657
+ * @param {{mode?: string}} [options] `mode` (e.g. `"defaults-accepted"`) is
658
+ * stamped onto the record when the express lane approves a resolvable-but-
659
+ * unshaped artifact (GENESIS-FLOW-DESIGN.md §2). Omitted for a normal/real
660
+ * approval.
661
+ * @returns {{ok: true, artifact: string, hash: string, approvedAt: string, mode?: string} | {ok: false, reason: string}}
662
+ */
663
+ export function approveArtifact(root, artifactId, options = {}) {
664
+ const registry = listGovernedArtifacts(root);
665
+ const artifact = registry.find((a) => a.id === artifactId);
666
+ if (!artifact) {
667
+ const known = registry.map((a) => a.id).join(", ") || "(none — no governed artifacts resolved in this project)";
668
+ return { ok: false, reason: `unknown artifact "${artifactId}" — valid ids: ${known}` };
669
+ }
670
+ const resolved = computeArtifactHash(root, artifact);
671
+ if (artifact.complete === false) {
672
+ return {
673
+ ok: false,
674
+ reason:
675
+ `cannot approve "${artifactId}" — its file set cannot be fully resolved: the kotlin-rooted files are unresolvable because ` +
676
+ "the project package is not resolvable from composeApp/build.gradle.kts (likely the raw template or a pre-stamp tree — " +
677
+ `run this in a generated project); only ${resolved.fileCount} file(s) resolved. ` +
678
+ "A partial or empty approval is vacuous (it attests nothing for the unresolved files) and is refused.",
679
+ };
680
+ }
681
+ if (resolved.fileCount === 0) {
682
+ const reason =
683
+ artifact.files.length === 0
684
+ ? `cannot approve "${artifactId}" — it resolves to 0 files; nothing currently matches this artifact's pattern (nothing to approve yet). An approval over zero files is vacuous (the empty-input hash attests nothing) and is refused.`
685
+ : `cannot approve "${artifactId}" — it resolves to 0 files; its expected files are all missing on disk: ` +
686
+ `${artifact.files.join(", ")}. An approval over zero files is vacuous (the empty-input hash attests nothing) and is refused.`;
687
+ return { ok: false, reason };
688
+ }
689
+ const state = loadApprovals(root);
690
+ const others = state.artifacts.filter((a) => a.artifact !== artifactId);
691
+ const approvedAt = new Date().toISOString();
692
+ const record = { artifact: artifactId, status: "approved", hash: resolved.hash, approvedAt };
693
+ if (options.mode) record.mode = options.mode;
694
+ others.push(record);
695
+ saveApprovals(root, { artifacts: others, exemplarFeature: state.exemplarFeature });
696
+ return { ok: true, artifact: artifactId, hash: resolved.hash, approvedAt, ...(options.mode ? { mode: options.mode } : {}) };
697
+ }
698
+
699
+ /**
700
+ * Express lane (GENESIS-FLOW-DESIGN.md §2): approve every currently-resolvable,
701
+ * not-yet-approved governed artifact in one pass, each stamped
702
+ * `mode: "defaults-accepted"`. An artifact already `"approved"` (real OR a prior
703
+ * defaults-accepted run) is left untouched — the express lane never overwrites a
704
+ * standing approval, shaped or not. Unresolvable artifacts are SKIPPED with the
705
+ * exact refusal `approveArtifact` would have printed (never a silent skip).
706
+ * @param {string} root
707
+ * @returns {{ok: true, approved: string[], skipped: Array<{id: string, reason: string}>}}
708
+ */
709
+ export function approveAllDefaults(root) {
710
+ const registry = listGovernedArtifacts(root);
711
+ const state = loadApprovals(root);
712
+ const byId = new Map(state.artifacts.map((a) => [a.artifact, a]));
713
+ const approved = [];
714
+ const skipped = [];
715
+ for (const artifact of registry) {
716
+ const live = resolveArtifactStatus(root, artifact, byId.get(artifact.id));
717
+ if (live.status === "approved") continue; // already settled — never overwritten by the express lane
718
+ const result = approveArtifact(root, artifact.id, { mode: "defaults-accepted" });
719
+ if (result.ok) approved.push(artifact.id);
720
+ else skipped.push({ id: artifact.id, reason: result.reason });
721
+ }
722
+ return { ok: true, approved, skipped };
723
+ }
724
+
725
+ /**
726
+ * Reopen for redesign (GENESIS-FLOW-DESIGN.md §2): move an `approved` artifact
727
+ * (real or defaults-accepted — both are status `"approved"`) to `"reopened"`,
728
+ * recording `reopenedAt` and clearing any `mode` (a reopened artifact is fluid
729
+ * again, not "the defaults, still"). REFUSES an unknown id, and refuses any
730
+ * artifact whose LIVE status is not `"approved"` — reopening the unreviewed, the
731
+ * already-reopened, or a changed-since-approval artifact is meaningless (there is
732
+ * nothing sanctioned to walk back from).
733
+ * @param {string} root
734
+ * @param {string} artifactId
735
+ * @returns {{ok: true, artifact: string, reopenedAt: string} | {ok: false, reason: string}}
736
+ */
737
+ export function reopenArtifact(root, artifactId) {
738
+ const registry = listGovernedArtifacts(root);
739
+ const artifact = registry.find((a) => a.id === artifactId);
740
+ if (!artifact) {
741
+ const known = registry.map((a) => a.id).join(", ") || "(none — no governed artifacts resolved in this project)";
742
+ return { ok: false, reason: `unknown artifact "${artifactId}" — valid ids: ${known}` };
743
+ }
744
+ const state = loadApprovals(root);
745
+ const stored = state.artifacts.find((a) => a.artifact === artifactId);
746
+ const live = resolveArtifactStatus(root, artifact, stored);
747
+ if (live.status !== "approved") {
748
+ return {
749
+ ok: false,
750
+ reason: `cannot reopen "${artifactId}" — it is "${live.status}", not "approved". Only an approved artifact (shaped or defaults-accepted) can be reopened for redesign.`,
751
+ };
752
+ }
753
+ const others = state.artifacts.filter((a) => a.artifact !== artifactId);
754
+ const reopenedAt = new Date().toISOString();
755
+ const record = { artifact: artifactId, status: "reopened", hash: stored.hash, approvedAt: stored.approvedAt, reopenedAt };
756
+ others.push(record);
757
+ saveApprovals(root, { artifacts: others, exemplarFeature: state.exemplarFeature });
758
+ // `artifact` is the ID STRING — the same convention approveArtifact returns
759
+ // (one library, one shape; the console bridge relies on the symmetry).
760
+ return { ok: true, artifact: artifactId, reopenedAt };
761
+ }
762
+
763
+ // ── The verify-lane gate ─────────────────────────────────────────────────────
764
+
765
+ /**
766
+ * The `approvals` verify-lane gate's pure decision function (qa/verify.mjs
767
+ * wraps this in the step's name/duration bookkeeping — same split as
768
+ * compareTokenDrift/qa/lib/token-drift.mjs).
769
+ *
770
+ * Aggregate verdict:
771
+ * - any artifact "changed-since-approval" -> FAIL (names each + the
772
+ * re-approval command — NEVER names a merely-reopened artifact; see below)
773
+ * - else any artifact "unreviewed"/"reopened" -> SKIP (warns, non-blocking)
774
+ * - else (all approved + matching) -> PASS
775
+ *
776
+ * The sanctioned-redesign-vs-drift asymmetry (GENESIS-FLOW-DESIGN.md §2) lives
777
+ * right here: `reopened` is grouped with `unreviewed` as non-blocking pending
778
+ * work, `changed-since-approval` is checked FIRST and returns immediately — so a
779
+ * run with one reopened artifact and one genuinely drifted (changed-since-
780
+ * approval) artifact FAILs, and the FAIL reason names only the drifted one.
781
+ * @param {string} root
782
+ * @returns {{verdict: "PASS"|"FAIL"|"SKIP", reason: (string|undefined), statuses: Array<object>}}
783
+ */
784
+ export function evaluateApprovalsGate(root) {
785
+ const statuses = getApprovalStatuses(root);
786
+ const mismatched = statuses.filter((s) => s.status === "changed-since-approval");
787
+ const pending = statuses.filter((s) => s.status === "unreviewed" || s.status === "reopened");
788
+
789
+ if (mismatched.length > 0) {
790
+ const lines = ["Approval invalidated — a governed artifact changed after sign-off:"];
791
+ for (const s of mismatched) {
792
+ if (!s.resolvable) {
793
+ lines.push(
794
+ ` [${s.id}] ${s.label} — approved at ${shortHash(s.storedHash)}, but its files no longer fully resolve (${s.fileCount} present — deleted or unresolvable). Restore the files, then re-approve if the change was intended (approval over an unresolved file set is refused).`,
795
+ );
796
+ } else {
797
+ lines.push(
798
+ ` [${s.id}] ${s.label} — approved at ${shortHash(s.storedHash)}, now ${shortHash(s.hash)}. Re-approve: node qa/approve.mjs ${s.id}`,
799
+ );
800
+ }
801
+ }
802
+ return { verdict: "FAIL", reason: lines.join("\n"), statuses };
803
+ }
804
+
805
+ if (pending.length > 0) {
806
+ const lines = ["Governed artifacts awaiting human approval (non-blocking — approve when ready):"];
807
+ for (const s of pending) {
808
+ if (s.status === "reopened") {
809
+ lines.push(
810
+ ` [${s.id}] ${s.label} — reopened for redesign at ${s.reopenedAt} (non-blocking until re-approved). Approve: node qa/approve.mjs ${s.id}`,
811
+ );
812
+ } else if (!s.resolvable) {
813
+ lines.push(` [${s.id}] ${s.label} — unreviewed, currently unresolvable (${s.fileCount} of expected files resolved) — not approvable in this tree.`);
814
+ } else {
815
+ lines.push(` [${s.id}] ${s.label} — unreviewed. Approve: node qa/approve.mjs ${s.id}`);
816
+ }
817
+ }
818
+ return { verdict: "SKIP", reason: lines.join("\n"), statuses };
819
+ }
820
+
821
+ return { verdict: "PASS", reason: undefined, statuses };
822
+ }