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
|
@@ -8,19 +8,27 @@
|
|
|
8
8
|
// Answered LOCALLY, offline, on every lane run. Needs nothing
|
|
9
9
|
// but the tree and this file.
|
|
10
10
|
//
|
|
11
|
-
// AUTHENTICITY "is my lane the real published
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// holding a receipt.
|
|
11
|
+
// AUTHENTICITY "is my lane the real published prooflane-harness@X?"
|
|
12
|
+
// NOT ANSWERED ANYWHERE IN THIS REPO — see below. The per-file
|
|
13
|
+
// map this lock records is what such a check would compare
|
|
14
|
+
// against a published artifact; nothing performs the comparison.
|
|
16
15
|
//
|
|
17
|
-
// Being honest about that split matters
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
16
|
+
// Being honest about that split matters, and this comment was not. It used to
|
|
17
|
+
// say `create-cmp upgrade --harness` performed the remote comparison. It does
|
|
18
|
+
// not and never has: that command reads the version out of a LOCAL
|
|
19
|
+
// packages/harness/package.json and re-locks from local bytes
|
|
20
|
+
// (src/commands/upgrade.mjs:410-413). The only registry call in the tree packs
|
|
21
|
+
// `create-cmp-cli@<v>` as an upgrade's merge base. So the sentence promising
|
|
22
|
+
// that "the attacker cannot change what the registry published" described a
|
|
23
|
+
// defence that does not exist — in the module whose whole job is to be precise
|
|
24
|
+
// about which question it answers, which is the failure this harness exists to
|
|
25
|
+
// refuse (ADR-0008, which found it).
|
|
26
|
+
//
|
|
27
|
+
// What is true: someone who edits the lane AND rewrites this lock defeats the
|
|
28
|
+
// local check — of course they do; it is a checksum, not a signature. Local
|
|
29
|
+
// integrity catches the accident and the drift (an agent "fixing" a lane file,
|
|
30
|
+
// a half-applied upgrade). It catches no lie, and until a remote comparison is
|
|
31
|
+
// built, nothing here does.
|
|
24
32
|
//
|
|
25
33
|
// The lock is deliberately NOT a .mjs file, so it is not part of the region it
|
|
26
34
|
// describes — a manifest inside its own manifest could never settle.
|
|
@@ -30,10 +38,16 @@
|
|
|
30
38
|
|
|
31
39
|
import fs from "node:fs";
|
|
32
40
|
import path from "node:path";
|
|
33
|
-
import { hashHarnessRegion, compareHarnessRegion } from "./harness-region.mjs";
|
|
41
|
+
import { hashHarnessRegion, compareHarnessRegion, isAdopterOwned } from "./harness-region.mjs";
|
|
34
42
|
|
|
35
43
|
export const LOCK_PATH = "qa/harness.lock.json";
|
|
36
|
-
|
|
44
|
+
// ADR-0007 deferred this one with a condition: the lock's schema string
|
|
45
|
+
// "finishes that journey with the package work, not in this PR". That work has
|
|
46
|
+
// landed — the lock has written `name: "prooflane-harness"` since the package
|
|
47
|
+
// rename, which is the split ADR-0007 itself pointed at as observable in the
|
|
48
|
+
// tree. The condition is met, so the journey finishes here. Nothing reads the
|
|
49
|
+
// field; `readHarnessLock` parses the file and never inspects it.
|
|
50
|
+
export const LOCK_SCHEMA = "prooflane-harness-lock/1";
|
|
37
51
|
|
|
38
52
|
/**
|
|
39
53
|
* Read the lock, or null when it is absent or unparsable. An unreadable lock
|
|
@@ -61,7 +75,7 @@ export function readHarnessLock(root) {
|
|
|
61
75
|
* @param {{name?: string, version: string}} harness identity to record
|
|
62
76
|
* @returns {{sha256: string, fileCount: number}}
|
|
63
77
|
*/
|
|
64
|
-
export function writeHarnessLock(root, { name = "
|
|
78
|
+
export function writeHarnessLock(root, { name = "prooflane-harness", version }) {
|
|
65
79
|
if (typeof version !== "string" || version.length === 0) {
|
|
66
80
|
throw new Error("writeHarnessLock: a harness version is required");
|
|
67
81
|
}
|
|
@@ -109,6 +123,8 @@ export function checkHarnessIntegrity(root) {
|
|
|
109
123
|
missing: [],
|
|
110
124
|
extra: [],
|
|
111
125
|
fileCount: region.fileCount,
|
|
126
|
+
engineFiles: region.engineFiles,
|
|
127
|
+
vacuous: region.engineFiles === 0,
|
|
112
128
|
};
|
|
113
129
|
}
|
|
114
130
|
|
|
@@ -123,6 +139,12 @@ export function checkHarnessIntegrity(root) {
|
|
|
123
139
|
missing: cmp.missing,
|
|
124
140
|
extra: cmp.extra,
|
|
125
141
|
fileCount: region.fileCount,
|
|
142
|
+
// ADR-0010: how much of this region is the LANE, and whether it is a lane
|
|
143
|
+
// at all. `status` is deliberately untouched — a region of three
|
|
144
|
+
// declarations genuinely IS unmodified since it was locked; it is simply
|
|
145
|
+
// not a lane, and that is a different question from the one status answers.
|
|
146
|
+
engineFiles: region.engineFiles,
|
|
147
|
+
vacuous: region.engineFiles === 0,
|
|
126
148
|
};
|
|
127
149
|
}
|
|
128
150
|
|
|
@@ -133,6 +155,19 @@ export function checkHarnessIntegrity(root) {
|
|
|
133
155
|
* @returns {string}
|
|
134
156
|
*/
|
|
135
157
|
export function describeIntegrity(r) {
|
|
158
|
+
// ADR-0010, AND THIS IS THE REACHABLE HALF OF IT. `status` is honest — a
|
|
159
|
+
// region of declarations really is unmodified since it was locked — so a
|
|
160
|
+
// caller rendering that status alone says "N files verified" over a lane that
|
|
161
|
+
// holds no lane. Every caller that checks a tree it does not LIVE in reaches
|
|
162
|
+
// this: `create-cmp upgrade --harness` and `harden` print it over an
|
|
163
|
+
// arbitrary project directory, `prooflane upgrade` over the adopter's root,
|
|
164
|
+
// and a hosted checker over a repo it fetched. The shipped lane cannot (it
|
|
165
|
+
// derives its root from its own location, so its own module is always in the
|
|
166
|
+
// region) — which is exactly why the fix belongs in the shared voice rather
|
|
167
|
+
// than in one step.
|
|
168
|
+
if (r.vacuous) {
|
|
169
|
+
return `${r.name ?? "harness"} ${r.version ?? "?"} — ${r.fileCount} file(s) locked and NONE of them engine code: this is not a lane, and nothing here can vouch for one`;
|
|
170
|
+
}
|
|
136
171
|
if (r.status === "intact") {
|
|
137
172
|
// The region digest rides beside the version: two lanes can carry the same
|
|
138
173
|
// package version with different content (create-cmp-showcase, 2026-09-03 —
|
|
@@ -144,9 +179,42 @@ export function describeIntegrity(r) {
|
|
|
144
179
|
if (r.status === "unlocked") {
|
|
145
180
|
return `no ${LOCK_PATH} — this app's lane version is unrecorded`;
|
|
146
181
|
}
|
|
182
|
+
// NAME THE FILES. This used to report counts only — "1 unrecorded" — while
|
|
183
|
+
// holding the paths in `r.extra` and never showing them. The first adopter to
|
|
184
|
+
// hit it was following our own README, whose step 1 writes a file that is in
|
|
185
|
+
// HARNESS_DECLARATIONS: a correct refusal they could not act on, because the
|
|
186
|
+
// one fact that makes it actionable was in the object and not in the sentence.
|
|
187
|
+
// Evidence-or-silence: a gate that refuses names what it refused over.
|
|
147
188
|
const parts = [];
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
189
|
+
const show = (list, label) => {
|
|
190
|
+
if (!list.length) return;
|
|
191
|
+
const head = list.slice(0, 3).join(", ");
|
|
192
|
+
parts.push(`${list.length} ${label}: ${head}${list.length > 3 ? `, +${list.length - 3} more` : ""}`);
|
|
193
|
+
};
|
|
194
|
+
show(r.modified, "modified");
|
|
195
|
+
show(r.missing, "missing");
|
|
196
|
+
show(r.extra, "unrecorded");
|
|
197
|
+
// WHICH command helps depends on WHOSE files differ, and the two answers are
|
|
198
|
+
// opposites. An adopter editing their own profile or declaration is doing the
|
|
199
|
+
// one thing the harness tells them to do (the profile header says "This file
|
|
200
|
+
// is YOURS"), and the cure is to re-take the lock. A machine-owned file
|
|
201
|
+
// differing is a fork, and re-taking the lock over THAT would make every
|
|
202
|
+
// later receipt vouch for code the harness has never seen. Naming one command
|
|
203
|
+
// for both is how the first adopter to edit their profile got a lane that
|
|
204
|
+
// could not be un-failed: `harness init` refused ("already exists") and
|
|
205
|
+
// `upgrade --harness` refused (no create-cmp.json).
|
|
206
|
+
//
|
|
207
|
+
// CONDITIONAL on purpose. `isAdopterOwned` is a name rule and cannot know
|
|
208
|
+
// whether a given profile is one the ENGINE vendors (`qa/lib/profiles/cmp/`
|
|
209
|
+
// in every stamped Compose app) — teaching this module a profile id is the
|
|
210
|
+
// coupling Stage 0 removed. So this offers the command rather than asserting
|
|
211
|
+
// the ownership; `create-cmp harness relock` is where the decision is made,
|
|
212
|
+
// and it refuses a shipped profile by name.
|
|
213
|
+
const differing = [...r.modified, ...r.missing, ...r.extra];
|
|
214
|
+
const fix = differing.every(isAdopterOwned)
|
|
215
|
+
? " — if these are yours (your profile, your declarations), re-lock with `create-cmp harness relock`"
|
|
216
|
+
: r.extra.length && !r.modified.length && !r.missing.length
|
|
217
|
+
? " — re-lock with `create-cmp upgrade --harness`, or remove the file if it should not be there"
|
|
218
|
+
: "";
|
|
219
|
+
return `${r.name ?? "harness"} ${r.version ?? "?"} — ${parts.join("; ")}${fix}`;
|
|
152
220
|
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// harness-manifest.mjs — WHICH STACK PROFILE this project uses, and where it
|
|
2
|
+
// keeps the things the lane reads. The lane's own reader of
|
|
3
|
+
// qa/harness-manifest.json.
|
|
4
|
+
//
|
|
5
|
+
// WHY THE LANE READS IT NOW. The manifest existed before this file — written
|
|
6
|
+
// for the console, read by seven console modules and zero lane modules — so
|
|
7
|
+
// the lane kept hardcoding one project's layout (composeApp/src, qa/e2e, the
|
|
8
|
+
// Compose step pack imported by name) while the console beside it had learned
|
|
9
|
+
// to ask. This closes that: the lane resolves the manifest first, loads the
|
|
10
|
+
// profile it names (qa/lib/profile-loader.mjs), and only then knows what a
|
|
11
|
+
// "step" or a "test root" is for this project.
|
|
12
|
+
//
|
|
13
|
+
// THERE IS NO DEFAULT PROFILE (decision 3, 2026-09-04). The Compose profile is
|
|
14
|
+
// a profile like any other; a privileged default would be the coupling this
|
|
15
|
+
// removes wearing a different name. So an ABSENT manifest is a refusal that
|
|
16
|
+
// names the command which writes one:
|
|
17
|
+
//
|
|
18
|
+
// a stamped app (create-cmp.json present) → create-cmp upgrade --harness
|
|
19
|
+
// derives it from what it knows
|
|
20
|
+
// a foreign repo, any stack → create-cmp harness init
|
|
21
|
+
// writes the manifest, a working
|
|
22
|
+
// profile, the surface and the
|
|
23
|
+
// lock, then runs Rule 0
|
|
24
|
+
//
|
|
25
|
+
// A PRESENT-BUT-MALFORMED manifest is refused too, naming every problem at
|
|
26
|
+
// once — never silently defaulted, for the reason the console's reader gives:
|
|
27
|
+
// a lane that falls back to the wrong layout reports an honest-looking absence
|
|
28
|
+
// of files that exist ten characters away.
|
|
29
|
+
//
|
|
30
|
+
// The shape is FLAT: the layout fields sit beside `schema` and `profile`, not
|
|
31
|
+
// under a nested `layout` key. The console's reader validates field-by-field
|
|
32
|
+
// and refuses unknown keys, and a stamped app must satisfy both readers with
|
|
33
|
+
// one file. (Stage 0.5 unifies the two readers; the flat shape is what both
|
|
34
|
+
// accept today.)
|
|
35
|
+
//
|
|
36
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/harness-manifest.mjs in the
|
|
37
|
+
// create-cmp repo. The copy in a generated project's qa/lib/ is vendored
|
|
38
|
+
// byte-identical at scaffold time — edit the package source, then run
|
|
39
|
+
// `node scripts/sync-harness.mjs`.
|
|
40
|
+
|
|
41
|
+
import fs from "node:fs";
|
|
42
|
+
import path from "node:path";
|
|
43
|
+
|
|
44
|
+
/** Where a project declares its profile and layout. Required for the lane to run. */
|
|
45
|
+
export const MANIFEST_REL_PATH = "qa/harness-manifest.json";
|
|
46
|
+
|
|
47
|
+
/** The manifest schema this lane writes and understands. */
|
|
48
|
+
export const MANIFEST_SCHEMA = "harness-manifest/2";
|
|
49
|
+
|
|
50
|
+
/** Layout fields that are single project-relative paths. */
|
|
51
|
+
export const LAYOUT_PATH_FIELDS = ["receipt", "architectureDoc", "specs", "approvals"];
|
|
52
|
+
/** Layout fields that are non-empty lists. `packs` is informational (console). */
|
|
53
|
+
export const LAYOUT_LIST_FIELDS = ["citationRoots", "packs"];
|
|
54
|
+
const META_FIELDS = ["schema", "profile"];
|
|
55
|
+
const KNOWN_FIELDS = new Set([...META_FIELDS, ...LAYOUT_PATH_FIELDS, ...LAYOUT_LIST_FIELDS]);
|
|
56
|
+
|
|
57
|
+
/** A profile id is one path segment, lowercase, dash-separated — it becomes a directory name. */
|
|
58
|
+
export const PROFILE_ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
59
|
+
|
|
60
|
+
function pathProblem(field, value) {
|
|
61
|
+
if (typeof value !== "string" || value.trim() === "") return `${field} must be a non-empty string`;
|
|
62
|
+
if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) return `${field} must be relative to the project root (got "${value}")`;
|
|
63
|
+
if (value.includes("\\")) return `${field} must use "/" separators (got "${value}")`;
|
|
64
|
+
if (value.split("/").some((seg) => seg === "..")) return `${field} may not escape the project root (got "${value}")`;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every contract violation in a parsed manifest, or [] when it is valid. Never
|
|
70
|
+
* throws — a caller prints all defects at once rather than the first.
|
|
71
|
+
* @param {unknown} parsed
|
|
72
|
+
* @returns {string[]}
|
|
73
|
+
*/
|
|
74
|
+
export function manifestProblems(parsed) {
|
|
75
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return ["the manifest must be a JSON object"];
|
|
76
|
+
const problems = [];
|
|
77
|
+
for (const key of Object.keys(parsed)) {
|
|
78
|
+
if (!KNOWN_FIELDS.has(key)) problems.push(`unknown field "${key}" (known: ${[...KNOWN_FIELDS].join(", ")})`);
|
|
79
|
+
}
|
|
80
|
+
if ("schema" in parsed && (typeof parsed.schema !== "string" || !parsed.schema.startsWith("harness-manifest/"))) {
|
|
81
|
+
problems.push(`schema must be a string of the form "harness-manifest/<n>" (got ${JSON.stringify(parsed.schema)})`);
|
|
82
|
+
}
|
|
83
|
+
// The profile is the one REQUIRED field: without it the lane cannot know
|
|
84
|
+
// what a step is for this project, and guessing is the bug this file closes.
|
|
85
|
+
if (!("profile" in parsed)) {
|
|
86
|
+
problems.push(`profile is required — add "profile": { "id": "<profile>" } naming the stack profile under qa/lib/profiles/`);
|
|
87
|
+
} else {
|
|
88
|
+
const p = parsed.profile;
|
|
89
|
+
if (!p || typeof p !== "object" || Array.isArray(p)) problems.push("profile must be an object { id, version? }");
|
|
90
|
+
else {
|
|
91
|
+
if (typeof p.id !== "string" || !PROFILE_ID_RE.test(p.id)) {
|
|
92
|
+
problems.push(`profile.id must match ${PROFILE_ID_RE} — it names a directory under qa/lib/profiles/ (got ${JSON.stringify(p.id)})`);
|
|
93
|
+
}
|
|
94
|
+
if ("version" in p && typeof p.version !== "string") problems.push("profile.version must be a string when present");
|
|
95
|
+
for (const key of Object.keys(p)) {
|
|
96
|
+
if (key !== "id" && key !== "version") problems.push(`profile has an unknown field "${key}" (known: id, version)`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const field of LAYOUT_PATH_FIELDS) {
|
|
101
|
+
if (!(field in parsed)) continue;
|
|
102
|
+
const p = pathProblem(field, parsed[field]);
|
|
103
|
+
if (p) problems.push(p);
|
|
104
|
+
}
|
|
105
|
+
for (const field of LAYOUT_LIST_FIELDS) {
|
|
106
|
+
if (!(field in parsed)) continue;
|
|
107
|
+
const list = parsed[field];
|
|
108
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
109
|
+
problems.push(`${field} must be a non-empty array of strings`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
list.forEach((entry, i) => {
|
|
113
|
+
const p =
|
|
114
|
+
field === "citationRoots"
|
|
115
|
+
? pathProblem(`${field}[${i}]`, entry)
|
|
116
|
+
: typeof entry === "string" && entry.trim()
|
|
117
|
+
? null
|
|
118
|
+
: `${field}[${i}] must be a non-empty string`;
|
|
119
|
+
if (p) problems.push(p);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return problems;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The refusal for a MISSING manifest — which names the command that writes
|
|
127
|
+
* one, because the two kinds of project get there differently.
|
|
128
|
+
* @param {string} root
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function absentManifestReason(root) {
|
|
132
|
+
const stamped = fs.existsSync(path.join(root, "create-cmp.json"));
|
|
133
|
+
const how = stamped
|
|
134
|
+
? "This is a create-cmp app: run `create-cmp upgrade --harness` and it will write the manifest from what it already knows."
|
|
135
|
+
: "Run `create-cmp harness init` — it writes the manifest, a working profile for this project, the verified surface and the lock, then proves the lane returns.";
|
|
136
|
+
return (
|
|
137
|
+
`${MANIFEST_REL_PATH} is missing — the lane cannot run without knowing which stack profile this project uses, ` +
|
|
138
|
+
`and there is no default. ${how}`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Resolve the project's manifest.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} root project root
|
|
146
|
+
* @returns {{ok: true, manifest: object, relPath: string}
|
|
147
|
+
* | {ok: false, absent: boolean, reason: string, relPath: string}}
|
|
148
|
+
* absent true when no file exists (the reason names the command that writes one)
|
|
149
|
+
* false when a file exists and is unusable (the reason names every problem)
|
|
150
|
+
*/
|
|
151
|
+
export function resolveHarnessManifest(root) {
|
|
152
|
+
const file = path.join(root, ...MANIFEST_REL_PATH.split("/"));
|
|
153
|
+
if (!fs.existsSync(file)) {
|
|
154
|
+
return { ok: false, absent: true, relPath: MANIFEST_REL_PATH, reason: absentManifestReason(root) };
|
|
155
|
+
}
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
159
|
+
} catch (err) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
absent: false,
|
|
163
|
+
relPath: MANIFEST_REL_PATH,
|
|
164
|
+
reason: `${MANIFEST_REL_PATH} is not valid JSON (${err && err.message ? err.message : String(err)}) — fix it; no layout is assumed while a manifest is present`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const problems = manifestProblems(parsed);
|
|
168
|
+
if (problems.length) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
absent: false,
|
|
172
|
+
relPath: MANIFEST_REL_PATH,
|
|
173
|
+
reason: `${MANIFEST_REL_PATH} is malformed: ${problems.join("; ")} — fix it; no layout is assumed while a manifest is present`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, manifest: parsed, relPath: MANIFEST_REL_PATH };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A manifest for a profile, with optional layout overrides — what `attach`
|
|
181
|
+
* writes after its interview and what a stamper ships.
|
|
182
|
+
* @param {string} profileId
|
|
183
|
+
* @param {object} [layout] layout fields to carry (validated on write)
|
|
184
|
+
* @returns {object}
|
|
185
|
+
*/
|
|
186
|
+
export function manifestFor(profileId, layout = {}) {
|
|
187
|
+
return { schema: MANIFEST_SCHEMA, profile: { id: profileId }, ...layout };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Write a manifest — after validating it. A writer that could put an invalid
|
|
192
|
+
* manifest on disk would hand the next lane run a refusal it created itself.
|
|
193
|
+
* @param {string} root
|
|
194
|
+
* @param {object} manifest
|
|
195
|
+
* @returns {{ok: true, relPath: string} | {ok: false, reason: string}}
|
|
196
|
+
*/
|
|
197
|
+
export function writeHarnessManifest(root, manifest) {
|
|
198
|
+
const problems = manifestProblems(manifest);
|
|
199
|
+
if (problems.length) return { ok: false, reason: `refusing to write an invalid manifest: ${problems.join("; ")}` };
|
|
200
|
+
try {
|
|
201
|
+
const file = path.join(root, ...MANIFEST_REL_PATH.split("/"));
|
|
202
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
203
|
+
fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
204
|
+
return { ok: true, relPath: MANIFEST_REL_PATH };
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return { ok: false, reason: err && err.message ? err.message : String(err) };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -64,6 +64,18 @@ export const HARNESS_DIRS = ["qa", "qa/lib"];
|
|
|
64
64
|
*/
|
|
65
65
|
export const HARNESS_TEST_DIR = "qa/test";
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* STACK PROFILES — qa/lib/profiles/<id>/**. Every `.mjs` under it, recursively,
|
|
69
|
+
* is machine-owned: a profile is the set of gates, tiers and steps the lane
|
|
70
|
+
* runs for this project, loaded by id from qa/harness-manifest.json
|
|
71
|
+
* (qa/lib/profile-loader.mjs). An edited profile that stayed outside the lock
|
|
72
|
+
* could redefine what "done" means and still have the lane vouch for the
|
|
73
|
+
* result — GATE-RULES Rule 2, the layer you changed cannot certify itself.
|
|
74
|
+
* Named and recursive on purpose, like HARNESS_TEST_DIR: a nested directory
|
|
75
|
+
* enters the region only by someone widening the rule here, never by shape.
|
|
76
|
+
*/
|
|
77
|
+
export const HARNESS_PROFILES_DIR = "qa/lib/profiles";
|
|
78
|
+
|
|
67
79
|
/**
|
|
68
80
|
* DECLARATIONS the lane READS to decide what it attests — locked for the same
|
|
69
81
|
* reason verify.mjs is. payment-blueprint's planted proof (2026-09-03): remove
|
|
@@ -77,6 +89,88 @@ export const HARNESS_TEST_DIR = "qa/test";
|
|
|
77
89
|
*/
|
|
78
90
|
export const HARNESS_DECLARATIONS = ["qa/verified-surface.json", "qa/harness-manifest.json"];
|
|
79
91
|
|
|
92
|
+
/**
|
|
93
|
+
* MACHINE-OWNED files that are not .mjs and not declarations. Today: the
|
|
94
|
+
* provenance record (lib/harness-source.mjs) — which artifact was vendored and
|
|
95
|
+
* where it came from, written by init and rewritten by upgrade.
|
|
96
|
+
*
|
|
97
|
+
* It is in the region for one reason: inside it, the record is inside
|
|
98
|
+
* `lock.files` and inside `inputs.hash`, so a hand-edited origin FAILs
|
|
99
|
+
* harnessIntegrity by name. Provenance is offered as no kind of check
|
|
100
|
+
* (ADR-0008), and that is precisely why it must not also be silently editable —
|
|
101
|
+
* a field nothing verifies and anyone can rewrite is worse than an absent one.
|
|
102
|
+
*
|
|
103
|
+
* It is deliberately NOT in HARNESS_DECLARATIONS, though it sits beside them:
|
|
104
|
+
* that list is what `harness relock` may re-baseline, and an adopter re-locking
|
|
105
|
+
* their own edited provenance is the one outcome this must never allow.
|
|
106
|
+
*
|
|
107
|
+
* Absent is fine everywhere — `listHarnessFiles` lists what exists, so a lane
|
|
108
|
+
* installed before this file existed has an unchanged region and an unchanged
|
|
109
|
+
* lock.
|
|
110
|
+
*/
|
|
111
|
+
export const HARNESS_GENERATED = ["qa/harness-source.json"];
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Of the locked region, WHICH files does the adopter author?
|
|
115
|
+
*
|
|
116
|
+
* The region is one lock but two kinds of file, and conflating them is what
|
|
117
|
+
* bricked the first foreign adopter (2026-09-05). `harness init` generates
|
|
118
|
+
* qa/lib/profiles/<id>/index.mjs with a header that says "This file is YOURS",
|
|
119
|
+
* and the README tells adopters to correct qa/harness-manifest.json and
|
|
120
|
+
* qa/verified-surface.json — then the first such edit FAILs harnessIntegrity
|
|
121
|
+
* with no command that could re-take the lock. Their profile is inside the
|
|
122
|
+
* lock for a good reason (an edited gate pack must not certify itself
|
|
123
|
+
* silently), so the answer is not to unlock it; it is to name the subset a
|
|
124
|
+
* re-lock may cover:
|
|
125
|
+
*
|
|
126
|
+
* qa/lib/profiles/<id>/** the profile they wrote — <id> REQUIRED, so a
|
|
127
|
+
* loose qa/lib/profiles/*.mjs stays machine-owned
|
|
128
|
+
* HARNESS_DECLARATIONS the two files the lane READS from them
|
|
129
|
+
*
|
|
130
|
+
* Everything else — every .mjs directly under qa/ and qa/lib/, and qa/test/**
|
|
131
|
+
* — is engine code. An edit there is a fork, and `create-cmp harness relock`
|
|
132
|
+
* refuses it rather than re-baselining a lane the harness has never seen.
|
|
133
|
+
*
|
|
134
|
+
* This rule is deliberately NAME-based and stack-free: it says nothing about
|
|
135
|
+
* which profile ids the engine itself vendors (`create-cmp harness relock`
|
|
136
|
+
* layers that on, from the harness package it ships with — the core does not
|
|
137
|
+
* learn a profile id). So a vendored lane can use this to point a failing
|
|
138
|
+
* adopter at the right command; the command is where the gate actually is.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
141
|
+
* @returns {boolean}
|
|
142
|
+
*/
|
|
143
|
+
export function isAdopterOwned(relPath) {
|
|
144
|
+
if (!isHarnessFile(relPath)) return false;
|
|
145
|
+
if (HARNESS_DECLARATIONS.includes(relPath)) return true;
|
|
146
|
+
const prefix = `${HARNESS_PROFILES_DIR}/`;
|
|
147
|
+
return relPath.startsWith(prefix) && relPath.slice(prefix.length).includes("/");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Is this region member ENGINE CODE — the lane itself, as opposed to what the
|
|
152
|
+
* lane reads about itself or the profile the adopter wrote?
|
|
153
|
+
*
|
|
154
|
+
* The three exclusions are the region's non-engine halves: the declarations
|
|
155
|
+
* (`qa/verified-surface.json`, `qa/harness-manifest.json`), the generated
|
|
156
|
+
* provenance record, and `qa/lib/profiles/<id>/**` — an adopter's own pack is
|
|
157
|
+
* theirs, and a region holding a profile and no spine is still a lane with no
|
|
158
|
+
* engine in it.
|
|
159
|
+
*
|
|
160
|
+
* ADR-0010: a region with zero of these is VACUOUS. It reads `intact` because
|
|
161
|
+
* it genuinely is unmodified since it was locked — it is simply not a lane.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} relPath project-relative path, "/"-separated
|
|
164
|
+
* @returns {boolean}
|
|
165
|
+
*/
|
|
166
|
+
export function isEngineModule(relPath) {
|
|
167
|
+
if (!isHarnessFile(relPath)) return false;
|
|
168
|
+
if (HARNESS_DECLARATIONS.includes(relPath)) return false;
|
|
169
|
+
if (HARNESS_GENERATED.includes(relPath)) return false;
|
|
170
|
+
if (relPath.startsWith(`${HARNESS_PROFILES_DIR}/`)) return false;
|
|
171
|
+
return relPath.endsWith(".mjs");
|
|
172
|
+
}
|
|
173
|
+
|
|
80
174
|
/**
|
|
81
175
|
* Is this project-relative path part of the machine-owned harness region?
|
|
82
176
|
* @param {string} relPath project-relative path, "/"-separated
|
|
@@ -85,8 +179,10 @@ export const HARNESS_DECLARATIONS = ["qa/verified-surface.json", "qa/harness-man
|
|
|
85
179
|
export function isHarnessFile(relPath) {
|
|
86
180
|
if (typeof relPath !== "string") return false;
|
|
87
181
|
if (HARNESS_DECLARATIONS.includes(relPath)) return true;
|
|
182
|
+
if (HARNESS_GENERATED.includes(relPath)) return true;
|
|
88
183
|
if (!relPath.endsWith(".mjs")) return false;
|
|
89
184
|
if (relPath.startsWith(`${HARNESS_TEST_DIR}/`)) return true;
|
|
185
|
+
if (relPath.startsWith(`${HARNESS_PROFILES_DIR}/`)) return true;
|
|
90
186
|
const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
|
91
187
|
return HARNESS_DIRS.includes(dir);
|
|
92
188
|
}
|
|
@@ -127,10 +223,11 @@ export function listHarnessFiles(root) {
|
|
|
127
223
|
}
|
|
128
224
|
}
|
|
129
225
|
walkMjs(path.join(root, HARNESS_TEST_DIR), HARNESS_TEST_DIR, found);
|
|
226
|
+
walkMjs(path.join(root, HARNESS_PROFILES_DIR), HARNESS_PROFILES_DIR, found);
|
|
130
227
|
// A declaration directly under qa/ is already seen by the scan above (it is
|
|
131
228
|
// a harness file by name); the explicit loop covers one that lives deeper.
|
|
132
229
|
// Deduplicated so no path is hashed twice.
|
|
133
|
-
for (const rel of HARNESS_DECLARATIONS) {
|
|
230
|
+
for (const rel of [...HARNESS_DECLARATIONS, ...HARNESS_GENERATED]) {
|
|
134
231
|
if (fs.existsSync(path.join(root, ...rel.split("/")))) found.push(rel);
|
|
135
232
|
}
|
|
136
233
|
return [...new Set(found)].sort();
|
|
@@ -161,7 +258,14 @@ export function hashHarnessRegion(root) {
|
|
|
161
258
|
files[rel] = h;
|
|
162
259
|
digest.update(rel, "utf8").update("\0").update(h, "utf8").update("\n");
|
|
163
260
|
}
|
|
164
|
-
return {
|
|
261
|
+
return {
|
|
262
|
+
sha256: digest.digest("hex"),
|
|
263
|
+
fileCount: rels.length,
|
|
264
|
+
// ADR-0010: how many of those files are the LANE. `fileCount` counts the
|
|
265
|
+
// region; this counts the engine in it, and zero is the vacuous self-vouch.
|
|
266
|
+
engineFiles: rels.filter(isEngineModule).length,
|
|
267
|
+
files,
|
|
268
|
+
};
|
|
165
269
|
}
|
|
166
270
|
|
|
167
271
|
/**
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// WHERE THIS LANE'S BYTES CAME FROM — ADR-0008's provenance, as a file.
|
|
2
|
+
//
|
|
3
|
+
// THE DECISION THIS IMPLEMENTS. ADR-0008 (accepted 2026-09-08) settled that the
|
|
4
|
+
// harness is ALWAYS vendored: pinning is how the bytes arrive, never how they
|
|
5
|
+
// are trusted, so there is one kind of receipt. What resolution adds is
|
|
6
|
+
// PROVENANCE — `source ∈ {local, registry, git}` — "recorded because the
|
|
7
|
+
// resolver knows it, and never counted as a check by anything."
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT BUYS, precisely, and it is one thing. A checker with a network needs
|
|
10
|
+
// to know WHICH artifact to fetch before comparing it to `lock.files`, and
|
|
11
|
+
// whether fetching one is meaningful at all. `name` + `version` + `source` are
|
|
12
|
+
// those fetch coordinates. Without them a reader cannot tell a receipt naming a
|
|
13
|
+
// version that was never published from one naming a version they can pull —
|
|
14
|
+
// and the published core HAS sat versions behind this tree, so both exist.
|
|
15
|
+
//
|
|
16
|
+
// IT IS NOT A CHECK, AND MUST NEVER READ AS ONE. It is exactly as forgeable as
|
|
17
|
+
// the `version` beside it. Nothing gates on it, no verdict consults it, and a
|
|
18
|
+
// notary that compares the lock against a registry reports that as ITS finding,
|
|
19
|
+
// dated and named — never as a property of the receipt (ADR-0008; NORTH-STAR
|
|
20
|
+
// §8.2's "notarisation is not examination").
|
|
21
|
+
//
|
|
22
|
+
// WHY IT IS A FILE IN THE LOCKED REGION rather than a field in the lock. Two
|
|
23
|
+
// reasons, and the second is the load-bearing one:
|
|
24
|
+
//
|
|
25
|
+
// 1. The lock cannot hash itself, so a `source` written there sits outside
|
|
26
|
+
// every digest — the one place an editor's change leaves no trace.
|
|
27
|
+
// 2. Inside the region it is inside `lock.files` AND inside `inputs.hash`,
|
|
28
|
+
// so editing it FAILs harnessIntegrity by name. Provenance is offered as
|
|
29
|
+
// no kind of check; that is no reason to make it silently editable.
|
|
30
|
+
//
|
|
31
|
+
// It is machine-owned, NOT adopter-owned: `harness relock` re-baselines the
|
|
32
|
+
// files an adopter authors, and provenance is not one of them. An adopter who
|
|
33
|
+
// hand-edits this file gets a refusal naming it, which is the correct outcome —
|
|
34
|
+
// the alternative is a lane that will re-lock a forged origin without comment.
|
|
35
|
+
//
|
|
36
|
+
// ABSENT MEANS UNRECORDED, never `local`. Every receipt minted before this
|
|
37
|
+
// existed has no provenance, and inventing one for them would be the precise
|
|
38
|
+
// falsehood the field was added to prevent.
|
|
39
|
+
import fs from "node:fs";
|
|
40
|
+
import path from "node:path";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The package whose bytes a lane is made of. Named here because four writers
|
|
44
|
+
* record it — `prooflane init`, `prooflane upgrade`, the stamp and
|
|
45
|
+
* `upgrade --harness` — and a provenance record naming the wrong package is
|
|
46
|
+
* exactly the falsehood this file exists to prevent.
|
|
47
|
+
*/
|
|
48
|
+
export const HARNESS_PKG_NAME = "prooflane-harness";
|
|
49
|
+
|
|
50
|
+
/** Project-relative path of the provenance record. */
|
|
51
|
+
export const SOURCE_PATH = "qa/harness-source.json";
|
|
52
|
+
|
|
53
|
+
export const SOURCE_SCHEMA = "prooflane-harness-source/1";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* How the bytes arrived. The enum is ADR-0008's and gains no fourth value here:
|
|
57
|
+
* an unknown origin is an ABSENT record, not a new word.
|
|
58
|
+
*
|
|
59
|
+
* registry a package manager placed them (resolved from node_modules)
|
|
60
|
+
* local copied from a path on this machine — a checkout, a workspace
|
|
61
|
+
* git cloned from a repository
|
|
62
|
+
*/
|
|
63
|
+
export const SOURCE_KINDS = Object.freeze(["local", "registry", "git"]);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read the record, or null when there is none or it is unusable.
|
|
67
|
+
*
|
|
68
|
+
* Unparseable reads as ABSENT rather than throwing: a malformed provenance file
|
|
69
|
+
* must not stop a lane from running, because provenance is not a check. The
|
|
70
|
+
* integrity gate is what notices the file changed; this reader's job is only to
|
|
71
|
+
* answer "what does it say", and "nothing usable" is a valid answer.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} root project root
|
|
74
|
+
* @returns {{name: string, version: string, source: string|null}|null}
|
|
75
|
+
*/
|
|
76
|
+
export function readHarnessSource(root) {
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(fs.readFileSync(path.join(root, ...SOURCE_PATH.split("/")), "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
84
|
+
const name = typeof parsed.name === "string" && parsed.name ? parsed.name : null;
|
|
85
|
+
const version = typeof parsed.version === "string" && parsed.version ? parsed.version : null;
|
|
86
|
+
if (!name || !version) return null;
|
|
87
|
+
const source = SOURCE_KINDS.includes(parsed.source) ? parsed.source : null;
|
|
88
|
+
return { name, version, source };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write the record. Deterministic by construction — no timestamp, no host, no
|
|
93
|
+
* user. A field that changes on every run would make the region's digest move
|
|
94
|
+
* for no reason and turn `harnessIntegrity` into noise; and the question this
|
|
95
|
+
* answers ("which artifact, from where") has no time in it.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} root project root
|
|
98
|
+
* @param {{name: string, version: string, source?: string|null}} rec
|
|
99
|
+
* @returns {{name: string, version: string, source: string|null}}
|
|
100
|
+
*/
|
|
101
|
+
export function writeHarnessSource(root, { name, version, source = null }) {
|
|
102
|
+
if (typeof name !== "string" || !name) throw new Error("writeHarnessSource: a package name is required");
|
|
103
|
+
if (typeof version !== "string" || !version) throw new Error("writeHarnessSource: a version is required");
|
|
104
|
+
const kind = SOURCE_KINDS.includes(source) ? source : null;
|
|
105
|
+
const body = { schema: SOURCE_SCHEMA, name, version, source: kind };
|
|
106
|
+
const abs = path.join(root, ...SOURCE_PATH.split("/"));
|
|
107
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
108
|
+
fs.writeFileSync(abs, `${JSON.stringify(body, null, 2)}\n`);
|
|
109
|
+
return { name, version, source: kind };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What a package manager's own record says about where a dependency came from,
|
|
114
|
+
* for the one project that has the answer: the adopter's lockfile.
|
|
115
|
+
*
|
|
116
|
+
* DERIVED, NEVER ASSUMED. The tempting shortcut is "it is in node_modules, so
|
|
117
|
+
* it came from the registry" — and that is false for the common local-tarball
|
|
118
|
+
* install, which is exactly how this repo's own Stage 1 gate installs the
|
|
119
|
+
* harness. A wrong provenance is worse than none: it tells a checker to fetch
|
|
120
|
+
* an artifact that was never published and to conclude something from failing.
|
|
121
|
+
* So the answer comes from `resolved` in the lockfile, and anything it does not
|
|
122
|
+
* cover reads as null — unrecorded.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} root project root
|
|
125
|
+
* @param {string} pkgName
|
|
126
|
+
* @returns {string|null} one of SOURCE_KINDS, or null when unknowable
|
|
127
|
+
*/
|
|
128
|
+
export function resolvedSourceKind(root, pkgName) {
|
|
129
|
+
let lock;
|
|
130
|
+
try {
|
|
131
|
+
lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const entry = lock?.packages?.[`node_modules/${pkgName}`] ?? lock?.dependencies?.[pkgName];
|
|
136
|
+
const resolved = typeof entry?.resolved === "string" ? entry.resolved : null;
|
|
137
|
+
if (!resolved) return null;
|
|
138
|
+
if (/^https?:\/\//.test(resolved)) return "registry";
|
|
139
|
+
if (/^file:/.test(resolved)) return "local";
|
|
140
|
+
if (/^git(\+|:)/.test(resolved)) return "git";
|
|
141
|
+
return null;
|
|
142
|
+
}
|