create-cmp-cli 0.24.0 → 0.25.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 +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +10 -11
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +271 -37
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +34 -2
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +434 -53
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +175 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +80 -24
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +175 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +10 -11
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +271 -37
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +34 -2
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +434 -53
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +175 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +80 -24
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
|
@@ -10,19 +10,14 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Three concerns, kept separable:
|
|
12
12
|
// 1. The REGISTRY (`listGovernedArtifacts`) — artifact id -> resolved file list, in
|
|
13
|
-
// definition order
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// CONFIGURABLE — see
|
|
22
|
-
// `getExemplarFeature`/`resolveExemplarNames` below — defaulting to `home` so
|
|
23
|
-
// every ledger written before this config key existed keeps meaning what it
|
|
24
|
-
// meant. The registry is recomputed on every call — it reflects the tree as it
|
|
25
|
-
// stands, never a stale snapshot.
|
|
13
|
+
// definition order. WHICH artifacts, in what order, and how each is hashed is
|
|
14
|
+
// the PROFILE's (Stage 0 PR 5): the profile the manifest names exports
|
|
15
|
+
// `artifacts(root)`, composing its own entries with the neutral helpers below
|
|
16
|
+
// (feature briefs, feature designs, feature specs, the architecture artifact).
|
|
17
|
+
// Mobile's list — intent, briefs, architecture, exemplar-spec, exemplar-feature,
|
|
18
|
+
// design-system, components, designs, feature specs — lives in
|
|
19
|
+
// profiles/cmp/artifacts.mjs. The registry is recomputed on every call — it
|
|
20
|
+
// reflects the tree as it stands, never a stale snapshot.
|
|
26
21
|
// 2. STATE (`loadApprovals`/`saveApprovals`) — qa/approvals.json, the human's
|
|
27
22
|
// decisions: { artifact, status, hash, approvedAt, mode?, reopenedAt?, via?,
|
|
28
23
|
// reason? } plus the top-level `exemplarFeature` config key. Absent or corrupt is TOLERATED
|
|
@@ -51,18 +46,23 @@
|
|
|
51
46
|
//
|
|
52
47
|
// Consumers: qa/approve.mjs (the CLI — thin shell over this file), qa/verify.mjs
|
|
53
48
|
// (the `approvals` gate), qa/scaffold-feature.mjs (seeds a new feature's spec as
|
|
54
|
-
// unreviewed
|
|
49
|
+
// unreviewed; its exemplar names come from the cmp profile).
|
|
55
50
|
// The console (inspector/mcp/src/lib/approvals-bridge.mjs) calls this same library.
|
|
56
51
|
|
|
57
52
|
import { createHash } from "node:crypto";
|
|
58
53
|
import fs from "node:fs";
|
|
59
54
|
import path from "node:path";
|
|
60
55
|
|
|
61
|
-
import {
|
|
56
|
+
import { stripGeneratedSections } from "./arch-doc.mjs";
|
|
62
57
|
import { deriveAllFeatures, deriveFeatureStatus, listFeatureBriefs, parseFeatureBlock, stripFeatureBlock } from "./feature-brief.mjs";
|
|
58
|
+
import { resolveHarnessManifest } from "./harness-manifest.mjs";
|
|
59
|
+
import { loadProfileSync } from "./profile-loader.mjs";
|
|
63
60
|
|
|
64
61
|
export const APPROVALS_REL_PATH = "qa/approvals.json";
|
|
65
|
-
|
|
62
|
+
// Free to move: the reader is tolerant by construction — `parsed.schema ??
|
|
63
|
+
// APPROVALS_SCHEMA` — so a ledger written under the old name reads back
|
|
64
|
+
// unchanged and is rewritten under the new one at the next signature.
|
|
65
|
+
export const APPROVALS_SCHEMA = "prooflane-approvals/1";
|
|
66
66
|
// The JOURNAL (2026-07-28 flow audit, fix 1): qa/approvals.json is a mutable
|
|
67
67
|
// snapshot — every transition overwrites the row, so the ledger cannot answer
|
|
68
68
|
// "what happened while I was away?". The journal is the append-only memory
|
|
@@ -74,212 +74,160 @@ export const APPROVALS_SCHEMA = "cmp-approvals/1";
|
|
|
74
74
|
// invalidate the receipt for a tree whose code did not change.
|
|
75
75
|
export const APPROVALS_JOURNAL_REL_PATH = "qa/approvals.log.jsonl";
|
|
76
76
|
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
77
|
+
// ── The profile ─────────────────────────────────────────────────────────────
|
|
78
|
+
// WHICH artifacts a human signs, in what order, and how each is hashed is the
|
|
79
|
+
// profile's model (Stage 0 PR 5 — NORTH-STAR §6; profiles/cmp/artifacts.mjs
|
|
80
|
+
// holds mobile's six genesis artifacts). This file keeps the MECHANIC: an
|
|
81
|
+
// artifact is a path set with a hash; a signature binds the hash; status is
|
|
82
|
+
// derived on every read; reopen and accept are attributed; the gate reads it
|
|
83
|
+
// all. The neutral entries every profile shares — feature briefs (Decide),
|
|
84
|
+
// feature designs (one per brief with a surface) and feature specs — are
|
|
85
|
+
// built by the helpers below, which a profile's `artifacts(root)` composes.
|
|
84
86
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// package segment, e.g. "home"), E (PascalCase entity, e.g. "Item"). This is the
|
|
88
|
-
// SAME shape qa/scaffold-feature.mjs's ALL_FILES clones FROM (GENESIS-FLOW-DESIGN.md
|
|
89
|
-
// §1's "configurable exemplar") — the stamper imports this exact function so the
|
|
90
|
-
// clone-source list and the governed-artifact list can never drift from each other
|
|
91
|
-
// (single source of truth, not a parallel copy to keep in sync by hand).
|
|
92
|
-
// @param {string} F PascalCase feature name (e.g. "Home", "Favorites")
|
|
93
|
-
// @param {string} f lowercase package-segment name (e.g. "home", "favorites")
|
|
94
|
-
// @param {string} E PascalCase entity name (e.g. "Item", "Favorite")
|
|
95
|
-
// @returns {Array<{sourceSet: string, rel: string}>}
|
|
96
|
-
export function exemplarKotlinFileSet(F, f, E) {
|
|
97
|
-
return [
|
|
98
|
-
{ sourceSet: "commonMain", rel: `domain/model/${E}.kt` },
|
|
99
|
-
{ sourceSet: "commonMain", rel: `domain/repository/${E}Repository.kt` },
|
|
100
|
-
{ sourceSet: "commonMain", rel: `domain/usecase/Get${E}sUseCase.kt` },
|
|
101
|
-
{ sourceSet: "commonMain", rel: `data/remote/${E}RepositoryImpl.kt` },
|
|
102
|
-
{ sourceSet: "commonTest", rel: `testing/fakes/Fake${E}Repository.kt` },
|
|
103
|
-
{ sourceSet: "commonMain", rel: `presentation/${f}/${F}Screen.kt` },
|
|
104
|
-
{ sourceSet: "commonMain", rel: `presentation/${f}/${F}ViewModel.kt` },
|
|
105
|
-
{ sourceSet: "commonTest", rel: `presentation/${f}/${F}ViewModelTest.kt` },
|
|
106
|
-
{ sourceSet: "desktopTest", rel: `presentation/${f}/${F}ScreenTest.kt` },
|
|
107
|
-
{ sourceSet: "desktopTest", rel: `presentation/${f}/${F}GoldenTreeTest.kt` },
|
|
108
|
-
];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Naive de-pluralization, shared verbatim with qa/scaffold-feature.mjs's own
|
|
112
|
-
// entity-name default (a feature stamped without `--entity` gets this exact
|
|
113
|
-
// guess). Exported so both the stamper (deriving a NEW feature's entity) and this
|
|
114
|
-
// registry (guessing a CONFIGURED exemplar's entity from its feature name alone —
|
|
115
|
-
// see resolveExemplarNames) apply the identical heuristic. Unreliable for
|
|
116
|
-
// irregular nouns by design (the skill surfaces the guess for human override at
|
|
117
|
-
// stamp time); a wrong guess here simply fails to resolve files, which is refused
|
|
118
|
-
// (never fabricated), not silently wrong.
|
|
119
|
-
export function defaultEntityName(feature) {
|
|
120
|
-
if (feature.endsWith("ies") && feature.length > 3) return `${feature.slice(0, -3)}y`;
|
|
121
|
-
if (feature.endsWith("s") && !feature.endsWith("ss")) return feature.slice(0, -1);
|
|
122
|
-
return feature;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function toPascalCase(f) {
|
|
126
|
-
return f.charAt(0).toUpperCase() + f.slice(1);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function toUpperSnake(F) {
|
|
130
|
-
return F.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
|
|
131
|
-
}
|
|
87
|
+
/** The `feature-design:` id prefix — one place, so the CLI/console/gate never drift on it. */
|
|
88
|
+
export const FEATURE_DESIGN_PREFIX = "feature-design:";
|
|
132
89
|
|
|
133
90
|
/**
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* `home` (the default, and the only exemplar that predates configurability) is a
|
|
139
|
-
* hardcoded exception: its entity is `Item`, not derivable from `Home` by
|
|
140
|
-
* `defaultEntityName` (which would naively guess `Home`). Every OTHER exemplar is
|
|
141
|
-
* itself a feature that was stamped by qa/scaffold-feature.mjs, so its entity
|
|
142
|
-
* followed defaultEntityName(F) UNLESS it was stamped with an explicit `--entity`
|
|
143
|
-
* override — a choice this config key cannot see. In that mismatch case the guess
|
|
144
|
-
* is wrong and the file set simply fails to resolve (0 or partial files), which
|
|
145
|
-
* `resolveArtifactStatus`/`approveArtifact` already refuse rather than fabricate —
|
|
146
|
-
* the correct failure mode, not a special case to add here.
|
|
91
|
+
* The profile the manifest names, loaded synchronously — the registry is read
|
|
92
|
+
* from sync chains (the gate, the CLI, the console bridge). No manifest or no
|
|
93
|
+
* profile is a refusal by name, never an empty registry: an empty ledger that
|
|
94
|
+
* looked governed would be the vacuous PASS this file exists to refuse.
|
|
147
95
|
* @param {string} root
|
|
148
|
-
* @returns {
|
|
96
|
+
* @returns {object} the profile module
|
|
149
97
|
*/
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
return
|
|
98
|
+
function profileFor(root) {
|
|
99
|
+
const manifest = resolveHarnessManifest(root);
|
|
100
|
+
if (!manifest.ok) throw new Error(manifest.reason);
|
|
101
|
+
const loaded = loadProfileSync(root, manifest.manifest.profile);
|
|
102
|
+
if (!loaded.ok) throw new Error(loaded.reason);
|
|
103
|
+
return loaded.profile;
|
|
156
104
|
}
|
|
157
105
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// Mirrors qa/scaffold-feature.mjs's resolvePackage() primary path (the
|
|
169
|
-
// composeApp/build.gradle.kts namespace). Unlike the stamper, this NEVER dies —
|
|
170
|
-
// an unresolved package means the kotlin-rooted artifacts resolve to zero files.
|
|
171
|
-
// Zero resolution never CRASHES anything (the lane and the stamper stay up),
|
|
172
|
-
// but it is NOT benign for decisions: an approval over zero files would be the
|
|
173
|
-
// empty-input sha256 attesting nothing — a silent vacuous PASS, the exact
|
|
174
|
-
// failure mode this harness exists to kill (evidence must attest execution).
|
|
175
|
-
// So: approveArtifact REFUSES zero-file artifacts, and an already-approved
|
|
176
|
-
// artifact whose files stop resolving goes to changed-since-approval (FAIL),
|
|
177
|
-
// never PASS.
|
|
178
|
-
//
|
|
179
|
-
// IMPORTANT: detect "unresolved" by TOKEN SHAPE (`/^__[A-Z_]+__$/`), never by
|
|
180
|
-
// comparing against the literal string "__PACKAGE__". This file ships through
|
|
181
|
-
// the SAME scaffold pipeline that resolves that token — a literal comparison
|
|
182
|
-
// string is itself blindly text-substituted at stamp time (`replaceContents`
|
|
183
|
-
// does a global `"__PACKAGE__" -> config.package` replace over every template
|
|
184
|
-
// file's content, this one included), which would silently rewrite the
|
|
185
|
-
// sentinel into the real package and make the check always fail. A shape
|
|
186
|
-
// regex never spells the token out, so the pipeline has nothing to match.
|
|
187
|
-
const UNRESOLVED_TOKEN_RE = /^__[A-Z_]+__$/;
|
|
188
|
-
|
|
189
|
-
function resolvePackageDir(root) {
|
|
190
|
-
const gradleFile = path.join(root, "composeApp", "build.gradle.kts");
|
|
191
|
-
if (!fs.existsSync(gradleFile)) return null;
|
|
192
|
-
let contents;
|
|
106
|
+
/**
|
|
107
|
+
* May approvals be RECORDED in this tree? The core requires a manifest and a
|
|
108
|
+
* loadable profile; the profile may add its own test (`governable(root)` —
|
|
109
|
+
* mobile refuses the raw template, whose package is still a placeholder).
|
|
110
|
+
* Read-only status stays available anywhere.
|
|
111
|
+
* @param {string} root
|
|
112
|
+
* @returns {{ok: true} | {ok: false, reason: string}}
|
|
113
|
+
*/
|
|
114
|
+
export function isProjectGovernable(root) {
|
|
115
|
+
let profile;
|
|
193
116
|
try {
|
|
194
|
-
|
|
195
|
-
} catch {
|
|
196
|
-
return
|
|
117
|
+
profile = profileFor(root);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, reason: err && err.message ? err.message : String(err) };
|
|
197
120
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
121
|
+
if (typeof profile.governable === "function") {
|
|
122
|
+
const v = profile.governable(root);
|
|
123
|
+
if (!v || v.ok !== true) return { ok: false, reason: (v && v.reason) || `profile "${profile.id}" refuses to record approvals in this tree` };
|
|
124
|
+
}
|
|
125
|
+
return { ok: true };
|
|
201
126
|
}
|
|
202
127
|
|
|
203
|
-
|
|
204
|
-
const packageDir = resolvePackageDir(root);
|
|
205
|
-
if (!packageDir) return null;
|
|
206
|
-
return path.posix.join(KOTLIN_SOURCE_SETS[sourceSet], packageDir, rel);
|
|
207
|
-
}
|
|
128
|
+
// ── The neutral entries — helpers a profile's artifacts(root) composes ──────
|
|
208
129
|
|
|
209
130
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
131
|
+
* One `feature-brief:<name>` per doc under docs/features/ — LOCATION is the
|
|
132
|
+
* governance opt-in (CHANGE-FLOW-DESIGN.md §2); docs/proposals/ stays
|
|
133
|
+
* ungoverned harness-standards prose. They sit DIRECTLY after intent: the
|
|
134
|
+
* decide layer. At genesis the first feature's brief is drafted from the
|
|
135
|
+
* intent interview before architecture is even walked; post-genesis every
|
|
136
|
+
* decision-carrying change enters here. Approving one hashes the doc's bytes
|
|
137
|
+
* (with the declaration block stripped — hashFeatureBriefArtifact), so a
|
|
138
|
+
* signed brief cannot be quietly rewritten; acceptance is a LEDGER field on
|
|
139
|
+
* the same row (acceptFeature below), so the human's bookend never touches
|
|
140
|
+
* the signed bytes.
|
|
215
141
|
* @param {string} root
|
|
216
|
-
* @returns {boolean}
|
|
142
|
+
* @returns {Array<{id: string, label: string, files: string[], complete: boolean, hash: (root: string) => {hash: string, fileCount: number, missing: string[]}}>}
|
|
217
143
|
*/
|
|
218
|
-
export function
|
|
219
|
-
return
|
|
144
|
+
export function featureBriefArtifacts(root) {
|
|
145
|
+
return listFeatureBriefs(root).map((brief) => ({
|
|
146
|
+
id: `${FEATURE_BRIEF_PREFIX}${brief.name}`,
|
|
147
|
+
label: `Feature brief (${brief.rel})`,
|
|
148
|
+
files: [brief.rel],
|
|
149
|
+
complete: true,
|
|
150
|
+
hash: (r) => hashFeatureBriefArtifact(r, { id: `${FEATURE_BRIEF_PREFIX}${brief.name}`, files: [brief.rel] }),
|
|
151
|
+
}));
|
|
220
152
|
}
|
|
221
153
|
|
|
222
|
-
// ── Components glob ─────────────────────────────────────────────────────────
|
|
223
|
-
|
|
224
154
|
/**
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
155
|
+
* Feature designs (brief → design → spec → build, decided 2026-07-25): one
|
|
156
|
+
* `feature-design:<name>` per BRIEF with a surface — declared in the brief's
|
|
157
|
+
* cmp:feature block (`declares(block)`, so the gate exists before any file
|
|
158
|
+
* does) or evident on disk (`surfaceFiles(root, name)` non-empty). Signed on
|
|
159
|
+
* RENDERED output, never descriptions, BEFORE the behavior contract pins the
|
|
160
|
+
* form down. Briefs only, deliberately: legacy features never sprout
|
|
161
|
+
* retro-governance. With no surface files yet, `complete: false` —
|
|
162
|
+
* approveArtifact refuses, exactly right: you cannot sign a design that has
|
|
163
|
+
* nothing rendered. What a surface IS (screens, an API contract diff) is the
|
|
164
|
+
* profile's answer.
|
|
232
165
|
* @param {string} root
|
|
233
|
-
* @
|
|
166
|
+
* @param {{surfaceFiles: (root: string, name: string) => string[], declares: (block: object) => boolean, label?: (name: string) => string, complete?: (files: string[]) => boolean}} surface
|
|
167
|
+
* @returns {Array<{id: string, label: string, files: string[], complete: boolean}>}
|
|
234
168
|
*/
|
|
235
|
-
function
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
169
|
+
export function featureDesignArtifacts(root, surface) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const brief of listFeatureBriefs(root)) {
|
|
172
|
+
let declares = false;
|
|
173
|
+
try {
|
|
174
|
+
declares = Boolean(surface.declares(parseFeatureBlock(fs.readFileSync(path.join(root, brief.rel), "utf8"))));
|
|
175
|
+
} catch {
|
|
176
|
+
/* an unreadable brief declares nothing */
|
|
177
|
+
}
|
|
178
|
+
const files = surface.surfaceFiles(root, brief.name);
|
|
179
|
+
if (!declares && files.length === 0) continue;
|
|
180
|
+
const complete = surface.complete ? surface.complete(files) : files.length > 0;
|
|
181
|
+
const incompleteReason = complete ? undefined : surface.incompleteReason ? surface.incompleteReason(files) : undefined;
|
|
182
|
+
out.push({
|
|
183
|
+
id: `${FEATURE_DESIGN_PREFIX}${brief.name}`,
|
|
184
|
+
label: surface.label ? surface.label(brief.name) : `Feature design (${brief.name}, signed on rendered output)`,
|
|
185
|
+
files,
|
|
186
|
+
complete,
|
|
187
|
+
...(incompleteReason ? { incompleteReason } : {}),
|
|
188
|
+
});
|
|
243
189
|
}
|
|
244
|
-
return
|
|
245
|
-
.filter((e) => e.isFile() && e.name.endsWith(".kt"))
|
|
246
|
-
.map((e) => path.posix.join(dirRel, e.name))
|
|
247
|
-
.sort((a, b) => a.localeCompare(b));
|
|
190
|
+
return out;
|
|
248
191
|
}
|
|
249
192
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
193
|
+
/**
|
|
194
|
+
* One `feature-spec:<name>` per spec file present right now, minus the names
|
|
195
|
+
* the profile excludes (mobile: the base spec and the exemplar's own).
|
|
196
|
+
* @param {string} root
|
|
197
|
+
* @param {{specsDir?: string, exclude?: string[]}} [opts]
|
|
198
|
+
* @returns {Array<{id: string, label: string, files: string[], complete: boolean}>}
|
|
199
|
+
*/
|
|
200
|
+
export function featureSpecArtifacts(root, { specsDir = "specs", exclude = [] } = {}) {
|
|
201
|
+
const dir = path.join(root, ...specsDir.split("/"));
|
|
202
|
+
if (!fs.existsSync(dir)) return [];
|
|
203
|
+
return fs
|
|
204
|
+
.readdirSync(dir)
|
|
205
|
+
.filter((f) => f.endsWith(".spec.md") && !exclude.includes(f))
|
|
206
|
+
.sort((a, b) => a.localeCompare(b))
|
|
207
|
+
.map((file) => ({
|
|
208
|
+
id: `feature-spec:${file.slice(0, -".spec.md".length)}`,
|
|
209
|
+
label: `Feature spec (${specsDir}/${file})`,
|
|
210
|
+
files: [`${specsDir}/${file}`],
|
|
211
|
+
complete: true,
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
254
214
|
|
|
255
215
|
/**
|
|
256
|
-
* The
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* make every ViewModel edit during a legitimate build read as design drift.
|
|
216
|
+
* The `architecture` artifact: a structural spec plus the architecture doc
|
|
217
|
+
* with its generated sections stripped (hashArchitectureArtifact). Which
|
|
218
|
+
* files those are is the profile's; the hashing rule is the core's.
|
|
260
219
|
* @param {string} root
|
|
261
|
-
* @param {string
|
|
262
|
-
* @returns {string[]
|
|
220
|
+
* @param {{specRel: string, docRel: string}} files
|
|
221
|
+
* @returns {{id: string, label: string, files: string[], complete: boolean, hash: (root: string) => {hash: string, fileCount: number, missing: string[]}}}
|
|
263
222
|
*/
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
|
|
272
|
-
} catch {
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
for (const e of entries) {
|
|
276
|
-
const childRel = path.posix.join(rel, e.name);
|
|
277
|
-
if (e.isDirectory()) walk(childRel);
|
|
278
|
-
else if (e.isFile() && e.name.endsWith("Screen.kt")) out.push(childRel);
|
|
279
|
-
}
|
|
223
|
+
export function architectureArtifact(root, { specRel, docRel }) {
|
|
224
|
+
return {
|
|
225
|
+
id: "architecture",
|
|
226
|
+
label: `Architecture + structure (${specRel} + ${docRel}, generated sections stripped)`,
|
|
227
|
+
files: [specRel, docRel],
|
|
228
|
+
complete: true,
|
|
229
|
+
hash: (r) => hashArchitectureArtifact(r, { specRel, docRel }),
|
|
280
230
|
};
|
|
281
|
-
walk(dirRel);
|
|
282
|
-
return out.sort((a, b) => a.localeCompare(b));
|
|
283
231
|
}
|
|
284
232
|
|
|
285
233
|
// ── Registry ─────────────────────────────────────────────────────────────────
|
|
@@ -309,132 +257,21 @@ function listFeatureScreenFiles(root, name) {
|
|
|
309
257
|
* @returns {Array<{id: string, label: string, files: string[], complete: boolean}>}
|
|
310
258
|
*/
|
|
311
259
|
export function listGovernedArtifacts(root) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
label: `Intent brief (${INTENT_REL})`,
|
|
318
|
-
files: [INTENT_REL],
|
|
319
|
-
complete: true,
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
// Feature briefs (feature-brief.mjs): one `feature-brief:<name>` per doc
|
|
323
|
-
// under docs/features/ — LOCATION is the governance opt-in (CHANGE-FLOW-
|
|
324
|
-
// DESIGN.md §2); docs/proposals/ stays ungoverned harness-standards prose.
|
|
325
|
-
// They sit DIRECTLY after intent: the decide layer. At genesis the first
|
|
326
|
-
// feature's brief is drafted from the intent interview before architecture
|
|
327
|
-
// is even walked; post-genesis every decision-carrying change enters here.
|
|
328
|
-
// Approving one hashes the doc's bytes, so a signed brief cannot be quietly
|
|
329
|
-
// rewritten; acceptance is a LEDGER field on the same row (acceptFeature
|
|
330
|
-
// below), so the human's bookend never touches the signed bytes.
|
|
331
|
-
for (const brief of listFeatureBriefs(root)) {
|
|
332
|
-
artifacts.push({
|
|
333
|
-
id: `feature-brief:${brief.name}`,
|
|
334
|
-
label: `Feature brief (${brief.rel})`,
|
|
335
|
-
files: [brief.rel],
|
|
336
|
-
complete: true,
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
artifacts.push({
|
|
341
|
-
id: "architecture",
|
|
342
|
-
label: `Architecture + structure (${ARCHITECTURE_SPEC_REL} + ${ARCH_DOC_REL_PATH}, generated sections stripped)`,
|
|
343
|
-
// Hashed via hashArchitectureArtifact (spec bytes + stripped-doc content),
|
|
344
|
-
// NOT the generic hashArtifactFiles — this list is still the artifact's
|
|
345
|
-
// expected-files surface (missing-file refusal messages, "what governs
|
|
346
|
-
// this" bookkeeping), just not what gets hashed raw. See computeArtifactHash.
|
|
347
|
-
files: [ARCHITECTURE_SPEC_REL, ARCH_DOC_REL_PATH],
|
|
348
|
-
complete: true,
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
const { f: exemplarF, F: exemplarF_Pascal, E: exemplarE } = resolveExemplarNames(root);
|
|
352
|
-
const exemplarSpecRel = `specs/${exemplarF}.spec.md`;
|
|
353
|
-
const exemplarKotlinFiles = exemplarKotlinFileSet(exemplarF_Pascal, exemplarF, exemplarE);
|
|
354
|
-
|
|
355
|
-
// Spec-first: the exemplar's behavior clauses are confirmed BEFORE the slice
|
|
356
|
-
// is built — the definition order is the discipline, not just a display order.
|
|
357
|
-
artifacts.push({
|
|
358
|
-
id: "exemplar-spec",
|
|
359
|
-
label: `Exemplar spec (${exemplarSpecRel})`,
|
|
360
|
-
files: [exemplarSpecRel],
|
|
361
|
-
complete: true,
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
artifacts.push({
|
|
365
|
-
id: "exemplar-feature",
|
|
366
|
-
label: `Exemplar feature (${exemplarF} — the file set the stamper clones)`,
|
|
367
|
-
files: [
|
|
368
|
-
...exemplarKotlinFiles.map((f) => kotlinFile(root, f.sourceSet, f.rel)).filter(Boolean),
|
|
369
|
-
exemplarSpecRel,
|
|
370
|
-
],
|
|
371
|
-
complete: packageResolved,
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// UI-first: the design system LOCKS on the real exemplar (candidates render on
|
|
375
|
-
// real screens, never stubs), and the component vocabulary is DISTILLED from
|
|
376
|
-
// those screens — both follow the exemplar in the definition order.
|
|
377
|
-
artifacts.push({
|
|
378
|
-
id: "design-system",
|
|
379
|
-
label: "Design system (presentation/theme/Theme.kt, Tokens.kt)",
|
|
380
|
-
files: [
|
|
381
|
-
kotlinFile(root, "commonMain", "presentation/theme/Theme.kt"),
|
|
382
|
-
kotlinFile(root, "commonMain", "presentation/theme/Tokens.kt"),
|
|
383
|
-
].filter(Boolean),
|
|
384
|
-
complete: packageResolved,
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
artifacts.push({
|
|
388
|
-
id: "components",
|
|
389
|
-
label: "Components (presentation/components/*.kt)",
|
|
390
|
-
files: listComponentFiles(root),
|
|
391
|
-
complete: packageResolved,
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
// Feature designs (brief → design → spec → build, decided 2026-07-25): one
|
|
395
|
-
// `feature-design:<name>` per BRIEF with a UI surface — declared
|
|
396
|
-
// (`"screens": true` in the cmp:feature block, so the gate exists before any
|
|
397
|
-
// file does) or evident (presentation/<name>/*Screen.kt on disk). Signed on
|
|
398
|
-
// RENDERED output, never descriptions, BEFORE the behavior contract pins the
|
|
399
|
-
// form down. Briefs only, deliberately: legacy features (governed by
|
|
400
|
-
// exemplar-feature or nothing) never sprout retro-governance. With no screen
|
|
401
|
-
// files yet, `complete: false` — approveArtifact refuses, exactly right: you
|
|
402
|
-
// cannot sign a design that has nothing rendered.
|
|
403
|
-
for (const brief of listFeatureBriefs(root)) {
|
|
404
|
-
let declaresScreens = false;
|
|
405
|
-
try {
|
|
406
|
-
declaresScreens = parseFeatureBlock(fs.readFileSync(path.join(root, brief.rel), "utf8")).screens;
|
|
407
|
-
} catch {
|
|
408
|
-
/* an unreadable brief declares nothing */
|
|
409
|
-
}
|
|
410
|
-
const screenFiles = listFeatureScreenFiles(root, brief.name);
|
|
411
|
-
if (!declaresScreens && screenFiles.length === 0) continue;
|
|
412
|
-
artifacts.push({
|
|
413
|
-
id: `${FEATURE_DESIGN_PREFIX}${brief.name}`,
|
|
414
|
-
label: `Feature design (${brief.name} — presentation/${brief.name}/*Screen.kt, signed on rendered output)`,
|
|
415
|
-
files: screenFiles,
|
|
416
|
-
complete: packageResolved && screenFiles.length > 0,
|
|
417
|
-
});
|
|
260
|
+
const profile = profileFor(root);
|
|
261
|
+
if (typeof profile.artifacts !== "function") {
|
|
262
|
+
// A profile that declares no artifact model still has the Decide layer:
|
|
263
|
+
// briefs are the workflow loop's, not a stack's. Nothing else is assumed.
|
|
264
|
+
return featureBriefArtifacts(root);
|
|
418
265
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
for (const file of featureSpecs) {
|
|
427
|
-
const name = file.slice(0, -".spec.md".length);
|
|
428
|
-
artifacts.push({
|
|
429
|
-
id: `feature-spec:${name}`,
|
|
430
|
-
label: `Feature spec (specs/${file})`,
|
|
431
|
-
files: [`specs/${file}`],
|
|
432
|
-
complete: true,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
266
|
+
const list = profile.artifacts(root);
|
|
267
|
+
if (!Array.isArray(list)) throw new Error(`profile "${profile.id}" artifacts(root) must return an array`);
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
for (const a of list) {
|
|
270
|
+
if (!a || typeof a.id !== "string" || !a.id || !Array.isArray(a.files)) throw new Error(`profile "${profile.id}" returned a malformed artifact: ${JSON.stringify(a)}`);
|
|
271
|
+
if (seen.has(a.id)) throw new Error(`profile "${profile.id}" lists artifact "${a.id}" twice`);
|
|
272
|
+
seen.add(a.id);
|
|
435
273
|
}
|
|
436
|
-
|
|
437
|
-
return artifacts;
|
|
274
|
+
return list;
|
|
438
275
|
}
|
|
439
276
|
|
|
440
277
|
// ── Hashing (mirrors qa/lib/inputs-hash.mjs's computeInputsHash style) ───────
|
|
@@ -477,7 +314,7 @@ export function hashArtifactFiles(root, relFiles) {
|
|
|
477
314
|
|
|
478
315
|
/**
|
|
479
316
|
* The `architecture` artifact's hash basis (docs/proposals/architecture-document-
|
|
480
|
-
* standard.md §4.4):
|
|
317
|
+
* standard.md §4.4): the structural spec's raw bytes + the architecture doc
|
|
481
318
|
* with every `cmp:generated` marker's BODY stripped — `arch-doc.mjs`'s
|
|
482
319
|
* `stripGeneratedSections` is the ONE definition of "generated" for that doc,
|
|
483
320
|
* reused here rather than forked, so a new/changed marker id is understood
|
|
@@ -501,21 +338,22 @@ export function hashArtifactFiles(root, relFiles) {
|
|
|
501
338
|
* `cmp:generated` marker itself (structural, not generated content) — changes
|
|
502
339
|
* it, same as editing the spec.
|
|
503
340
|
* @param {string} root
|
|
341
|
+
* @param {{specRel: string, docRel: string}} files which spec and which doc — the profile's
|
|
504
342
|
* @returns {{ hash: string, fileCount: number, missing: string[] }}
|
|
505
343
|
*/
|
|
506
|
-
export function hashArchitectureArtifact(root) {
|
|
344
|
+
export function hashArchitectureArtifact(root, { specRel, docRel }) {
|
|
507
345
|
const rows = [];
|
|
508
346
|
const missing = [];
|
|
509
347
|
|
|
510
348
|
try {
|
|
511
|
-
const specBytes = fs.readFileSync(path.join(root,
|
|
512
|
-
rows.push([
|
|
349
|
+
const specBytes = fs.readFileSync(path.join(root, specRel));
|
|
350
|
+
rows.push([specRel, createHash("sha256").update(specBytes).digest("hex")]);
|
|
513
351
|
} catch {
|
|
514
|
-
missing.push(
|
|
352
|
+
missing.push(specRel);
|
|
515
353
|
}
|
|
516
354
|
|
|
517
355
|
try {
|
|
518
|
-
const docRaw = fs.readFileSync(path.join(root,
|
|
356
|
+
const docRaw = fs.readFileSync(path.join(root, docRel), "utf8");
|
|
519
357
|
// Normalize line endings BEFORE stripping: the marker grammar
|
|
520
358
|
// (`arch-doc.mjs`'s MARKER_BLOCK_RE) matches a literal `\n` right after
|
|
521
359
|
// `-->`, so CRLF content would fail to match at all and nothing would be
|
|
@@ -523,9 +361,9 @@ export function hashArchitectureArtifact(root) {
|
|
|
523
361
|
// hash itself.
|
|
524
362
|
const docNormalized = docRaw.replace(/\r\n/g, "\n");
|
|
525
363
|
const docStripped = stripGeneratedSections(docNormalized);
|
|
526
|
-
rows.push([
|
|
364
|
+
rows.push([docRel, createHash("sha256").update(docStripped, "utf8").digest("hex")]);
|
|
527
365
|
} catch {
|
|
528
|
-
missing.push(
|
|
366
|
+
missing.push(docRel);
|
|
529
367
|
}
|
|
530
368
|
|
|
531
369
|
// Code-unit sort for the same reason as hashArtifactFiles: hash order must
|
|
@@ -539,9 +377,11 @@ export function hashArchitectureArtifact(root) {
|
|
|
539
377
|
}
|
|
540
378
|
|
|
541
379
|
/**
|
|
542
|
-
* Recompute one artifact's hash — `
|
|
543
|
-
* (spec + stripped doc
|
|
544
|
-
*
|
|
380
|
+
* Recompute one artifact's hash — the artifact's own `hash(root)` when the
|
|
381
|
+
* profile attached one (the architecture artifact: spec + stripped doc), the
|
|
382
|
+
* brief rule for `feature-brief:*` (declaration block stripped — a core
|
|
383
|
+
* mechanic, so a bare {id, files} brief still hashes right), else
|
|
384
|
+
* `hashArtifactFiles(root, artifact.files)` (raw file bytes). The ONE dispatch point
|
|
545
385
|
* `resolveArtifactStatus`/`approveArtifact` both call, so the two never
|
|
546
386
|
* disagree about what "the architecture artifact's hash" means.
|
|
547
387
|
* @param {string} root
|
|
@@ -549,7 +389,7 @@ export function hashArchitectureArtifact(root) {
|
|
|
549
389
|
* @returns {{ hash: string, fileCount: number, missing: string[] }}
|
|
550
390
|
*/
|
|
551
391
|
function computeArtifactHash(root, artifact) {
|
|
552
|
-
if (artifact.
|
|
392
|
+
if (typeof artifact.hash === "function") return artifact.hash(root);
|
|
553
393
|
if (artifact.id.startsWith(FEATURE_BRIEF_PREFIX)) return hashFeatureBriefArtifact(root, artifact);
|
|
554
394
|
return hashArtifactFiles(root, artifact.files);
|
|
555
395
|
}
|
|
@@ -602,9 +442,10 @@ function hashFeatureBriefArtifact(root, artifact) {
|
|
|
602
442
|
* "unreviewed" and every exemplar lookup to the default (`home`). Never throws.
|
|
603
443
|
*
|
|
604
444
|
* `exemplarFeature` is `undefined` when the key is absent or not a non-empty
|
|
605
|
-
* string —
|
|
606
|
-
*
|
|
607
|
-
*
|
|
445
|
+
* string — a profile that uses the key (mobile's configurable exemplar,
|
|
446
|
+
* profiles/cmp/artifacts.mjs getExemplarFeature) resolves its own default, never
|
|
447
|
+
* this function, so every ledger written before the key existed keeps meaning
|
|
448
|
+
* what it meant (GENESIS-FLOW-DESIGN.md §1).
|
|
608
449
|
* @param {string} root
|
|
609
450
|
* @returns {{ schema: string, artifacts: Array<{artifact: string, status: string, hash: (string|null), approvedAt: (string|null), mode?: string, reopenedAt?: string}>, exemplarFeature: (string|undefined) }}
|
|
610
451
|
*/
|
|
@@ -698,20 +539,6 @@ export function readJournal(root) {
|
|
|
698
539
|
return events;
|
|
699
540
|
}
|
|
700
541
|
|
|
701
|
-
/**
|
|
702
|
-
* The configured exemplar feature's lowercase name (the package-segment form,
|
|
703
|
-
* e.g. `"home"`, `"favorites"`) — `qa/approvals.json`'s top-level
|
|
704
|
-
* `exemplarFeature` key, defaulting to `"home"` when absent (GENESIS-FLOW-DESIGN.md
|
|
705
|
-
* §1). This is the ONE function both `resolveExemplarNames` (registry) and
|
|
706
|
-
* qa/scaffold-feature.mjs (clone-source resolution) call — never read the raw key
|
|
707
|
-
* directly, so the default lives in exactly one place.
|
|
708
|
-
* @param {string} root
|
|
709
|
-
* @returns {string}
|
|
710
|
-
*/
|
|
711
|
-
export function getExemplarFeature(root) {
|
|
712
|
-
return loadApprovals(root).exemplarFeature ?? "home";
|
|
713
|
-
}
|
|
714
|
-
|
|
715
542
|
/**
|
|
716
543
|
* Seed one artifact as unreviewed if it isn't already recorded. Idempotent —
|
|
717
544
|
* a second call for the same id is a no-op. Used by qa/scaffold-feature.mjs to
|
|
@@ -909,9 +736,9 @@ export function approveArtifact(root, artifactId, options = {}) {
|
|
|
909
736
|
return {
|
|
910
737
|
ok: false,
|
|
911
738
|
reason:
|
|
912
|
-
`cannot approve "${artifactId}" — its file set cannot be fully resolved:
|
|
913
|
-
"the
|
|
914
|
-
`
|
|
739
|
+
`cannot approve "${artifactId}" — its file set cannot be fully resolved: ` +
|
|
740
|
+
`${artifact.incompleteReason || "the profile marked it incomplete (typically the project is not yet stamped, so source-rooted files cannot be located)"}; ` +
|
|
741
|
+
`only ${resolved.fileCount} file(s) resolved. ` +
|
|
915
742
|
"A partial or empty approval is vacuous (it attests nothing for the unresolved files) and is refused.",
|
|
916
743
|
};
|
|
917
744
|
}
|