create-cmp-cli 0.23.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 +46 -18
- 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 +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- 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 +38 -6
- 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 +778 -0
- 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 +180 -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} +230 -28
- 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/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- 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 +180 -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 +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- 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/agent-hold.mjs +234 -0
- 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 +38 -6
- 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 +778 -0
- 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 +180 -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} +230 -28
- 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/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- 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
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
// COORDINATION (non-negotiable): two concurrent Gradle invocations against one
|
|
27
27
|
// project corrupt each other's output (KSP cache collisions, half-written
|
|
28
28
|
// classes dirs — a real 20+ bogus-failure cascade). The lane and the preview
|
|
29
|
-
// daemon already coordinate via marker files
|
|
30
|
-
//
|
|
29
|
+
// daemon already coordinate via marker files:
|
|
30
|
+
// qa/.lane-in-progress — stamped by verify.mjs for a run's duration (core
|
|
31
|
+
// state; qa/lib/lane-markers.mjs)
|
|
31
32
|
// .cmp-render-in-progress — stamped by the preview daemon while its Gradle
|
|
32
33
|
// build is in flight
|
|
33
34
|
// Watch mode participates as a third citizen: it never launches a run while a
|
|
@@ -51,17 +52,20 @@ import fs from "node:fs";
|
|
|
51
52
|
import path from "node:path";
|
|
52
53
|
import { fileURLToPath } from "node:url";
|
|
53
54
|
|
|
55
|
+
import { LANE_MARKER_REL, LANE_MARKER_STALE_MS, RENDER_MARKER_FRESH_MS, laneMarkerPath, renderMarkerPath } from "./lib/lane-markers.mjs";
|
|
56
|
+
import { resolveSpecModel } from "./lib/spec-model.mjs";
|
|
57
|
+
|
|
54
58
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
55
59
|
|
|
56
60
|
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
57
61
|
|
|
58
62
|
export const USAGE = `node qa/watch.mjs [--once] [--json] [--help]
|
|
59
63
|
|
|
60
|
-
Resident watch mode — the inner verification loop. Watches
|
|
61
|
-
specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
|
|
64
|
+
Resident watch mode — the inner verification loop. Watches the project's
|
|
65
|
+
source roots, specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
|
|
62
66
|
a save storm triggers ONE run, changes during a run coalesce into one
|
|
63
67
|
follow-up). It defers while a verify lane or a preview-daemon render holds the
|
|
64
|
-
project (
|
|
68
|
+
project (qa/.lane-in-progress and the eyes' own render marker), so two build
|
|
65
69
|
invocations never collide.
|
|
66
70
|
|
|
67
71
|
THIS IS NOT A GATE. It runs the fast tier only: every receipt records
|
|
@@ -93,23 +97,106 @@ export function parseWatchArgs(rawArgs) {
|
|
|
93
97
|
}
|
|
94
98
|
|
|
95
99
|
// ── The watch set ───────────────────────────────────────────────────────────
|
|
96
|
-
//
|
|
97
|
-
// a golden-tree or
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
100
|
+
// The profile's source roots (the app), its specs directory (the contract),
|
|
101
|
+
// and qa/ (the harness itself — a golden-tree or flow edit should re-verify
|
|
102
|
+
// too). NOT watched: the build output directory the PROFILE declares, at any
|
|
103
|
+
// depth (see shouldIgnorePath); qa/evidence/ (verify.mjs writes latest.json
|
|
104
|
+
// there on every run — watching your own output is an infinite loop); and
|
|
105
|
+
// dotfiles (VCS internals, editor droppings, and the in-progress markers
|
|
106
|
+
// themselves).
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The trees this loop watches: the profile's own source roots and specs
|
|
110
|
+
* directory, plus qa/ (the harness judging itself — always core).
|
|
111
|
+
*
|
|
112
|
+
* Stage 0 PR 6b: this was `["composeApp/src", "specs", "qa"]`, so the inner
|
|
113
|
+
* loop in a repo whose code lives anywhere else watched two directories out of
|
|
114
|
+
* three and never fired on a source edit — a watcher that looks idle and is.
|
|
115
|
+
* A project with no usable manifest gets the core roots alone and the startup
|
|
116
|
+
* banner says so, rather than guessing a source layout.
|
|
117
|
+
* @param {string} root
|
|
118
|
+
* @returns {{roots: string[], degraded: string|null}}
|
|
119
|
+
*/
|
|
120
|
+
export function watchRoots(root) {
|
|
121
|
+
const model = resolveSpecModel(root);
|
|
122
|
+
if (!model.ok) {
|
|
123
|
+
return { roots: ["specs", "qa"], degraded: model.reason };
|
|
124
|
+
}
|
|
125
|
+
return { roots: [...new Set([...model.model.sourceRoots, model.model.specsDir, "qa"])], degraded: null };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Build output: the profile's word, not one stack's ───────────────────────
|
|
129
|
+
// The ignore set's whole job is to keep the loop from watching its own output.
|
|
130
|
+
// It used to find that output by the single literal directory name `build`,
|
|
131
|
+
// which is Gradle's — Rust writes `target/`, .NET `bin/` and `obj/`, Python
|
|
132
|
+
// `__pycache__`, Xcode `DerivedData`. Both halves were wrong, and both
|
|
133
|
+
// silently, with a watcher that looked like it was working:
|
|
134
|
+
//
|
|
135
|
+
// on those stacks the lane's own writes woke the watcher, which ran the lane
|
|
136
|
+
// again — a feedback loop, in the one process whose entire purpose is to fire
|
|
137
|
+
// on a human's edit
|
|
138
|
+
// and on EVERY stack a real source directory named `build/` (a build script
|
|
139
|
+
// package, a `build/` module, Python's `app/build/`) was never watched at
|
|
140
|
+
// all — the "watcher that looks idle and is" that Stage 0 PR 6b already
|
|
141
|
+
// fixed once for source ROOTS and left in place here
|
|
142
|
+
//
|
|
143
|
+
// The profile declares `layout.buildDir` (qa/lib/spec-model.mjs), so the name
|
|
144
|
+
// is available and was simply not asked for.
|
|
145
|
+
//
|
|
146
|
+
// THE DECLARED DIRECTORY'S NAME MATCHES AT ANY DEPTH, not just the one declared
|
|
147
|
+
// path. A profile declares its app module's output, but a multi-module tree has
|
|
148
|
+
// one such directory PER MODULE (Gradle writes one into every module; the
|
|
149
|
+
// harness's own qa/ subtrees get one too — see test/watch-loop.test.mjs's
|
|
150
|
+
// `qa/somewhere/build/out.json`). Ignoring only the declared path would re-open
|
|
151
|
+
// the feedback loop one directory over. Only that name matches, so a stack that
|
|
152
|
+
// calls its output `target` starts watching `build/` as the ordinary source it
|
|
153
|
+
// is there.
|
|
154
|
+
//
|
|
155
|
+
// That rule is deliberately BROADER than the declaration, and the price is
|
|
156
|
+
// named here rather than discovered: a profile declaring a nested `buildDir`
|
|
157
|
+
// gets its LEAF name ignored everywhere (`build/outputs` ignores every
|
|
158
|
+
// `outputs/` in the tree, not just that one). The startup banner prints the
|
|
159
|
+
// glob it settled on for exactly this reason — an ignore set a reader can see
|
|
160
|
+
// is one they can correct in `layout.buildDir`.
|
|
101
161
|
|
|
102
|
-
|
|
162
|
+
/**
|
|
163
|
+
* The historical name, kept ONLY for a project whose profile could not be
|
|
164
|
+
* resolved or which declares no `layout.buildDir`. It is a FALLBACK and not a
|
|
165
|
+
* default in the approving sense — the startup banner names it, exactly as
|
|
166
|
+
* spec-model.mjs's `DEFAULT_GRAMMAR.isDefault` makes the borrowed citation
|
|
167
|
+
* grammar say so out loud. The two failures are not symmetric: watching your
|
|
168
|
+
* own output loops forever, while missing a source directory named `build/`
|
|
169
|
+
* costs one manual run. When we know nothing, we take the cheaper mistake.
|
|
170
|
+
*/
|
|
171
|
+
export const FALLBACK_BUILD_OUTPUT_NAME = "build";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The directory NAMES this project's build output wears, from the profile's
|
|
175
|
+
* declaration. `composeApp/build` → ["build"]; `target` → ["target"].
|
|
176
|
+
* @param {{buildDir?: string|null}|null} [model] a SpecModel, or null when none resolved
|
|
177
|
+
* @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
export function buildOutputNames(model) {
|
|
180
|
+
const declared = typeof model?.buildDir === "string" ? model.buildDir : "";
|
|
181
|
+
const name = declared.replace(/\\/g, "/").split("/").filter(Boolean).pop();
|
|
182
|
+
return name ? [name] : [FALLBACK_BUILD_OUTPUT_NAME];
|
|
183
|
+
}
|
|
103
184
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Ignore predicate over a ROOT-relative path (forward slashes or backslashes).
|
|
187
|
+
* @param {string} rel
|
|
188
|
+
* @param {{buildDir?: string|null}|null} [model] the project's SpecModel — supplies the
|
|
189
|
+
* build output name. Omitted/null falls back to FALLBACK_BUILD_OUTPUT_NAME.
|
|
190
|
+
*/
|
|
191
|
+
export function shouldIgnorePath(rel, model = null) {
|
|
106
192
|
const norm = String(rel).replace(/\\/g, "/");
|
|
107
193
|
if (!norm) return true;
|
|
108
194
|
const parts = norm.split("/");
|
|
109
|
-
// Any dotted segment: .git, .gradle, .DS_Store, .
|
|
195
|
+
// Any dotted segment: .git, .gradle, .DS_Store, .lane-in-progress, …
|
|
110
196
|
if (parts.some((s) => s.startsWith("."))) return true;
|
|
111
|
-
//
|
|
112
|
-
|
|
197
|
+
// This profile's build output, at any depth (see the note above).
|
|
198
|
+
const buildNames = buildOutputNames(model);
|
|
199
|
+
if (parts.some((s) => buildNames.includes(s))) return true;
|
|
113
200
|
// The lane's own output — the one path that would make watch feed itself.
|
|
114
201
|
if (norm === "qa/evidence" || norm.startsWith("qa/evidence/")) return true;
|
|
115
202
|
return false;
|
|
@@ -133,10 +220,11 @@ export const POLL_MS = 2000; // marker-wait poll AND the no-recursive-watch fall
|
|
|
133
220
|
// around its Gradle builds. Freshness is mtime-bounded so a crashed stamper
|
|
134
221
|
// never wedges us.
|
|
135
222
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
223
|
+
// Paths and bounds are qa/lib/lane-markers.mjs's — the lane marker is core state
|
|
224
|
+
// under qa/, the render marker is the profile's (under its buildDir, or none).
|
|
225
|
+
// Re-exported because markerDecision's bounds are part of this module's
|
|
226
|
+
// contract (qa/watch.mjs --json consumers and the engine suite read them).
|
|
227
|
+
export { LANE_MARKER_STALE_MS, RENDER_MARKER_FRESH_MS };
|
|
140
228
|
|
|
141
229
|
/**
|
|
142
230
|
* The launch decision, pure: given the two markers' mtimes (null = absent) and
|
|
@@ -145,10 +233,10 @@ export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // verify.mjs's bound for t
|
|
|
145
233
|
*/
|
|
146
234
|
export function markerDecision({ laneMtimeMs = null, renderMtimeMs = null, nowMs = Date.now() } = {}) {
|
|
147
235
|
if (laneMtimeMs != null && nowMs - laneMtimeMs < LANE_MARKER_STALE_MS) {
|
|
148
|
-
return { launch: false, reason:
|
|
236
|
+
return { launch: false, reason: `a verify lane is in progress (${LANE_MARKER_REL} is fresh) — deferring; changes coalesce into one run when it finishes` };
|
|
149
237
|
}
|
|
150
238
|
if (renderMtimeMs != null && nowMs - renderMtimeMs < RENDER_MARKER_FRESH_MS) {
|
|
151
|
-
return { launch: false, reason: "the preview daemon has a
|
|
239
|
+
return { launch: false, reason: "the preview daemon has a build in flight (.cmp-render-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
|
|
152
240
|
}
|
|
153
241
|
return { launch: true };
|
|
154
242
|
}
|
|
@@ -304,8 +392,8 @@ function markerMtime(absPath) {
|
|
|
304
392
|
|
|
305
393
|
function launchDecisionNow() {
|
|
306
394
|
return markerDecision({
|
|
307
|
-
laneMtimeMs: markerMtime(
|
|
308
|
-
renderMtimeMs: markerMtime(
|
|
395
|
+
laneMtimeMs: markerMtime(laneMarkerPath(ROOT)),
|
|
396
|
+
renderMtimeMs: markerMtime(renderMarkerPath(ROOT)),
|
|
309
397
|
});
|
|
310
398
|
}
|
|
311
399
|
|
|
@@ -383,7 +471,7 @@ function main() {
|
|
|
383
471
|
currentChild = null;
|
|
384
472
|
// A signal-killed child never ran verify's `finally` — clean the lane
|
|
385
473
|
// marker it stamped so nothing defers on a ghost for 30 minutes.
|
|
386
|
-
if (signal) clearMarkerIfOwnedBy(
|
|
474
|
+
if (signal) clearMarkerIfOwnedBy(laneMarkerPath(ROOT), child.pid);
|
|
387
475
|
const durationMs = Date.now() - started;
|
|
388
476
|
const receipt = parseReceipt(stdout);
|
|
389
477
|
const rawTail = `${stdout}\n${stderr}`.split("\n").filter(Boolean);
|
|
@@ -453,7 +541,18 @@ function main() {
|
|
|
453
541
|
|
|
454
542
|
const watchers = [];
|
|
455
543
|
const pollTimers = [];
|
|
456
|
-
const
|
|
544
|
+
const { roots: declaredRoots, degraded: rootsDegraded } = watchRoots(ROOT);
|
|
545
|
+
const watchedRoots = declaredRoots.filter((rel) => fs.existsSync(path.join(ROOT, rel)));
|
|
546
|
+
// The ignore set is this profile's too: WHICH directory holds build output is
|
|
547
|
+
// a stack fact (`layout.buildDir`), and a watcher that guesses it either
|
|
548
|
+
// feeds itself or skips real source. resolveSpecModel shares the module cache
|
|
549
|
+
// with watchRoots's own call, so this is a map lookup, not a second load.
|
|
550
|
+
const specModel = (() => {
|
|
551
|
+
const r = resolveSpecModel(ROOT);
|
|
552
|
+
return r.ok ? r.model : null;
|
|
553
|
+
})();
|
|
554
|
+
const ignoredBuildNames = buildOutputNames(specModel);
|
|
555
|
+
const ignorePath = (rel) => shouldIgnorePath(rel, specModel);
|
|
457
556
|
|
|
458
557
|
// Poll fallback for platforms without recursive fs.watch: a full mtime scan
|
|
459
558
|
// per tick, diffed against the previous one so changed paths still get
|
|
@@ -470,7 +569,7 @@ function main() {
|
|
|
470
569
|
}
|
|
471
570
|
for (const e of entries) {
|
|
472
571
|
const rel = `${dirRel}/${e.name}`;
|
|
473
|
-
if (
|
|
572
|
+
if (ignorePath(rel)) continue;
|
|
474
573
|
const abs = path.join(dirAbs, e.name);
|
|
475
574
|
if (e.isDirectory()) walk(abs, rel);
|
|
476
575
|
else {
|
|
@@ -511,7 +610,7 @@ function main() {
|
|
|
511
610
|
try {
|
|
512
611
|
const w = fs.watch(path.join(ROOT, rootRel), { recursive: true }, (_event, filename) => {
|
|
513
612
|
const rel = filename ? `${rootRel}/${String(filename).replace(/\\/g, "/")}` : rootRel;
|
|
514
|
-
if (
|
|
613
|
+
if (ignorePath(rel)) return;
|
|
515
614
|
loop.change(rel);
|
|
516
615
|
});
|
|
517
616
|
w.on("error", () => {
|
|
@@ -531,9 +630,23 @@ function main() {
|
|
|
531
630
|
}
|
|
532
631
|
|
|
533
632
|
// Startup: what is watched, what is respected, what this is NOT.
|
|
633
|
+
// The ignore globs are PRINTED, never assumed: they name the directory this
|
|
634
|
+
// profile declared, so a reader can see at a glance that the loop is ignoring
|
|
635
|
+
// this stack's output and not some other stack's word for it.
|
|
636
|
+
const ignoreGlobs = [...ignoredBuildNames.map((n) => `**/${n}/**`), "qa/evidence/**", "dotfiles"];
|
|
534
637
|
say("qa/watch.mjs — resident inner loop: runs `node qa/verify.mjs --fast` on save");
|
|
535
|
-
say(`watching: ${watchedRoots.join(", ")} (ignoring
|
|
536
|
-
|
|
638
|
+
say(`watching: ${watchedRoots.join(", ")} (ignoring ${ignoreGlobs.join(", ")})`);
|
|
639
|
+
// Never watch a guessed layout silently: if the manifest could not be read,
|
|
640
|
+
// the source roots are unknown and this loop is watching less than it looks.
|
|
641
|
+
if (rootsDegraded) say(`NOTE: watching the core roots only — ${rootsDegraded}`);
|
|
642
|
+
// And never ignore a guessed layout silently either. A profile that declares
|
|
643
|
+
// no layout.buildDir leaves this loop on FALLBACK_BUILD_OUTPUT_NAME — one
|
|
644
|
+
// stack's convention, applied to a stack that never claimed it — so it says
|
|
645
|
+
// so, the way a borrowed citation grammar does (spec-model.mjs).
|
|
646
|
+
else if (specModel && typeof specModel.buildDir !== "string") {
|
|
647
|
+
say(`NOTE: this profile declares no layout.buildDir — ignoring **/${FALLBACK_BUILD_OUTPUT_NAME}/** as a fallback, which may be this project's real source`);
|
|
648
|
+
}
|
|
649
|
+
say(`coordination: defers while ${LANE_MARKER_REL} or the eyes' render marker is fresh — never two builds against this project`);
|
|
537
650
|
say(`debounce: ${DEBOUNCE_MS}ms — a save storm triggers one run; changes during a run coalesce into one follow-up`);
|
|
538
651
|
say(FOOTER);
|
|
539
652
|
say("waiting for changes… (Ctrl-C to stop · --once for a single pass · --json for line-per-run output)");
|
|
@@ -541,9 +654,9 @@ function main() {
|
|
|
541
654
|
event: "start",
|
|
542
655
|
pid: process.pid,
|
|
543
656
|
watching: watchedRoots,
|
|
544
|
-
ignoring:
|
|
657
|
+
ignoring: ignoreGlobs,
|
|
545
658
|
debounceMs: DEBOUNCE_MS,
|
|
546
|
-
coordinates: [LANE_MARKER_REL.
|
|
659
|
+
coordinates: [LANE_MARKER_REL, renderMarkerPath(ROOT) ? path.relative(ROOT, renderMarkerPath(ROOT)).split(path.sep).join("/") : null].filter(Boolean),
|
|
547
660
|
runs: "node qa/verify.mjs --fast",
|
|
548
661
|
note: FOOTER,
|
|
549
662
|
});
|
|
@@ -598,7 +711,7 @@ function installSignalHandlers(stopWork, getChild, emit, say) {
|
|
|
598
711
|
signalTree("SIGKILL");
|
|
599
712
|
await Promise.race([exited, sleep(1000)]);
|
|
600
713
|
}
|
|
601
|
-
clearMarkerIfOwnedBy(
|
|
714
|
+
clearMarkerIfOwnedBy(laneMarkerPath(ROOT), child.pid);
|
|
602
715
|
}
|
|
603
716
|
say(`watch mode stopped (${sig}) — no receipt was made valid by watching; the done-gate is still one full \`node qa/verify.mjs\` run`);
|
|
604
717
|
emit({ event: "shutdown", reason: sig });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
2
|
+
"name": "prooflane-receipts",
|
|
3
3
|
"version": "0.1.0",
|
|
4
4
|
"description": "Validate create-cmp evidence receipts: inputs-hash recompute, receipt-vs-tree binding, freshness, and execution-plausibility. Dependency-free ESM \u2014 the single source of truth vendored into every generated project's qa/lib/ and consumed by the hosted receipt check.",
|
|
5
5
|
"type": "module",
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
//
|
|
1
|
+
// prooflane-receipts — validate create-cmp evidence receipts.
|
|
2
2
|
// Single source of truth for the inputs-hash algorithm and the receipt
|
|
3
3
|
// predicate; vendored byte-identical into every generated project's qa/lib/
|
|
4
4
|
// and consumed by hosted validators. Dependency-free ESM.
|
|
5
5
|
|
|
6
|
-
export { computeInputsHash,
|
|
6
|
+
export { computeInputsHash, defaultSurface } from "./inputs-hash.mjs";
|
|
7
7
|
export {
|
|
8
8
|
RECEIPT_REL_PATH,
|
|
9
9
|
readReceipt,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// there is exactly one definition of the surface and the algorithm.
|
|
6
6
|
//
|
|
7
7
|
// SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
|
|
8
|
-
// create-cmp repo (the
|
|
8
|
+
// create-cmp repo (the `prooflane-receipts` package). The copy in a generated
|
|
9
9
|
// project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
|
|
10
10
|
// test/receipts-parity.test.mjs — edit the package source, then run
|
|
11
11
|
// `node scripts/sync-harness.mjs`.
|
|
@@ -30,15 +30,46 @@ import path from "node:path";
|
|
|
30
30
|
// looking the same is the worst failure this harness can have, so the surface
|
|
31
31
|
// is now resolved per project (see resolveVerifiedSurface) and an empty one is
|
|
32
32
|
// refused rather than hashed.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The surface a project gets when it has NOT declared one.
|
|
35
|
+
*
|
|
36
|
+
* This used to be a constant: `composeApp`, `specs`, `qa`, and the Gradle files
|
|
37
|
+
* — one stack's directory names, in the module every adopter's receipt is
|
|
38
|
+
* computed by. Vendored into a repo whose code lives under `services/`, it
|
|
39
|
+
* matched `qa/` and `specs/` and nothing else, so the lane produced a valid,
|
|
40
|
+
* confident, SMALLER hash: a receipt attesting a fraction of a project while
|
|
41
|
+
* looking complete. The file's own header calls that the worst failure this
|
|
42
|
+
* harness can have, and then shipped it as the default.
|
|
43
|
+
*
|
|
44
|
+
* Derived from the tree instead, and deliberately WIDE. Over-attesting costs a
|
|
45
|
+
* hash that moves more often than it needs to; under-attesting silently drops
|
|
46
|
+
* files from what a receipt claims. Only one of those is a lie, so the error is
|
|
47
|
+
* taken in the safe direction. Build output and dependency directories are
|
|
48
|
+
* excluded because they are outputs, not the thing being attested.
|
|
49
|
+
*
|
|
50
|
+
* A project that wants a narrower or pinned surface DECLARES one — `harness
|
|
51
|
+
* init` writes it, and the template ships one, so this fallback is for repos
|
|
52
|
+
* that predate the declaration rather than the normal path.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} root
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
export function defaultSurface(root) {
|
|
58
|
+
// The same construction as the walk — the universal floor plus what this
|
|
59
|
+
// repo's own .gitignore says — so the two agree by construction. qa-artifacts
|
|
60
|
+
// is the lane's own output and stays named.
|
|
61
|
+
const skip = new Set([...WALK_FLOOR, "qa-artifacts", ...gitignoredDirs(root), ...declaredIgnore(root)]);
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return entries
|
|
69
|
+
.filter((e) => !e.name.startsWith(".") && !skip.has(e.name))
|
|
70
|
+
.map((e) => e.name)
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
42
73
|
|
|
43
74
|
// Paths EXCLUDED even though they fall under an included surface dir above.
|
|
44
75
|
// qa/evidence and qa-artifacts are lane OUTPUTS — including them would make
|
|
@@ -69,6 +100,15 @@ const EXCLUDED_PREFIXES = [
|
|
|
69
100
|
"qa/.plan.json",
|
|
70
101
|
"qa/.request.json",
|
|
71
102
|
"qa/.plan-history.jsonl",
|
|
103
|
+
// Who is typing right now (qa/lib/agent-hold.mjs). A liveness declaration
|
|
104
|
+
// that could invalidate a receipt would mean an agent saying "I am here"
|
|
105
|
+
// un-proves the tree — the exact inversion this family of exclusions exists
|
|
106
|
+
// to prevent.
|
|
107
|
+
"qa/.agent-hold.json",
|
|
108
|
+
// The lane's own in-flight marker (qa/lib/lane-markers.mjs): stamped for a
|
|
109
|
+
// run's duration, rewritten at every step. Hashing it would make the lane
|
|
110
|
+
// invalidate its own receipt mid-run.
|
|
111
|
+
"qa/.lane-in-progress",
|
|
72
112
|
"qa/evidence",
|
|
73
113
|
"qa-artifacts",
|
|
74
114
|
"qa/comments.json",
|
|
@@ -134,7 +174,111 @@ function tryGitLsFiles(root) {
|
|
|
134
174
|
// "INVALID — source changed" the moment the user runs `git init`, even though
|
|
135
175
|
// no source changed. Pre-git and post-git hashes must agree for identical
|
|
136
176
|
// source; that is the invariant the regression test pins.
|
|
137
|
-
|
|
177
|
+
// A FLOOR, not the rule. This used to BE the rule, and it was one ecosystem's:
|
|
178
|
+
// it knew build/.gradle/.kotlin/.idea/node_modules and nothing of __pycache__,
|
|
179
|
+
// venv, target (Rust, Maven), bin and obj (.NET), vendor (Go, PHP), Pods or
|
|
180
|
+
// DerivedData. So a Python tree hashed one set of bytes before `git init` and a
|
|
181
|
+
// different set after, and the stamp-time PASS receipt read "source changed
|
|
182
|
+
// since the receipt" the instant a user ran `git init` — with no source change.
|
|
183
|
+
// That is the exact invariant the constant was written to hold, broken for
|
|
184
|
+
// every ecosystem but the first.
|
|
185
|
+
// THE FLOOR IS UNIVERSAL; EVERYTHING ELSE IS DECLARED. `.git` is git's, and
|
|
186
|
+
// `node_modules` is this lane's own runtime. Until 2026-09-08 this set also
|
|
187
|
+
// carried `build`, `.gradle`, `.kotlin`, `.idea` — one ecosystem's build output,
|
|
188
|
+
// applied to every tree. PATTERN: the repo's own ignore file is the truth
|
|
189
|
+
// (ripgrep, watchman and git itself all read .gitignore rather than a table);
|
|
190
|
+
// beneath it, the profile declares `layout.ignore` and `layout.buildDir`,
|
|
191
|
+
// written into qa/verified-surface.json as `ignore` so THIS package — the
|
|
192
|
+
// notary's, which must know no profile — reads a project fact, not a stack.
|
|
193
|
+
// WHY IT WORKS: the people who know the stack maintain the list, in the file
|
|
194
|
+
// they already maintain. HOW IT FAILS: a tree with no git, no .gitignore and no
|
|
195
|
+
// declaration hashes its build output — a hash that moves too often, which is
|
|
196
|
+
// the safe direction. WHAT WE DO: that case is the walk, and the walk prints
|
|
197
|
+
// its rules. Q5 (NORTH-STAR §10): git-mode hashing is untouched, and a cmp
|
|
198
|
+
// tree's .gitignore already lists these directories, so no receipt moves.
|
|
199
|
+
const WALK_FLOOR = new Set([".git", "node_modules"]);
|
|
200
|
+
const WALK_EXCLUDED_DIRS = WALK_FLOOR;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Directories the project DECLARED unhashable — qa/verified-surface.json's
|
|
204
|
+
* optional `ignore` list, written by `harness init` from `layout.ignore` and
|
|
205
|
+
* `layout.buildDir`, so the notary reads a project fact rather than a profile.
|
|
206
|
+
* @param {string} root
|
|
207
|
+
* @returns {Set<string>}
|
|
208
|
+
*/
|
|
209
|
+
export function declaredIgnore(root) {
|
|
210
|
+
try {
|
|
211
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, SURFACE_CONFIG_REL), "utf8"));
|
|
212
|
+
const list = Array.isArray(parsed?.ignore) ? parsed.ignore : [];
|
|
213
|
+
return new Set(list.filter((x) => typeof x === "string" && x.trim() && !x.includes("..")).map((x) => x.replace(/^\.\//, "").replace(/\/$/, "")));
|
|
214
|
+
} catch {
|
|
215
|
+
return new Set();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The directories THIS repo ignores, read from its own `.gitignore`.
|
|
221
|
+
*
|
|
222
|
+
* Walk mode exists only before `git init`, and its whole job is to agree with
|
|
223
|
+
* what `git ls-files --exclude-standard` will say afterwards. Maintaining a
|
|
224
|
+
* hand-written list of every ecosystem's build directory is a losing game and
|
|
225
|
+
* was already lost; reading the file git reads makes the two modes agree BY
|
|
226
|
+
* CONSTRUCTION rather than by vigilance.
|
|
227
|
+
*
|
|
228
|
+
* Deliberately a small subset of gitignore syntax — bare directory names, with
|
|
229
|
+
* or without a leading or trailing slash. Globs, negations and nested paths are
|
|
230
|
+
* left to git, which is running in every case that matters. Missing one costs a
|
|
231
|
+
* hash that moves too often; that is the safe direction, and the same one
|
|
232
|
+
* `defaultSurface` errs in.
|
|
233
|
+
*
|
|
234
|
+
* @param {string} root
|
|
235
|
+
* @returns {Set<string>}
|
|
236
|
+
*/
|
|
237
|
+
export function gitignoredDirs(root) {
|
|
238
|
+
let text;
|
|
239
|
+
try {
|
|
240
|
+
text = fs.readFileSync(path.join(root, ".gitignore"), "utf8");
|
|
241
|
+
} catch {
|
|
242
|
+
return new Set();
|
|
243
|
+
}
|
|
244
|
+
const out = new Set();
|
|
245
|
+
for (const raw of text.split("\n")) {
|
|
246
|
+
const line = raw.trim();
|
|
247
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
248
|
+
if (line.includes("*") || line.includes("?") || line.includes("[")) continue;
|
|
249
|
+
const name = line.replace(/^\//, "").replace(/\/$/, "");
|
|
250
|
+
if (name && !name.includes("/")) out.add(name);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Files that are INPUTS TO THE RESOLUTION rather than members of the surface.
|
|
256
|
+
*
|
|
257
|
+
* `.gitignore` decides what is attested: `git ls-files --exclude-standard`
|
|
258
|
+
* honours it, and `gitignoredDirs` above makes the walk fallback honour it too.
|
|
259
|
+
* A file that decides the attested set and is not itself attested is a hole —
|
|
260
|
+
* edit one line and a directory silently leaves every future receipt, with
|
|
261
|
+
* nothing in the chain saying the coverage moved. That is the narrowing failure
|
|
262
|
+
* this module's header calls the worst one it can have, arriving through the
|
|
263
|
+
* side door.
|
|
264
|
+
*
|
|
265
|
+
* Attested STRUCTURALLY, outside `surfaceEntries`, because a declaration that
|
|
266
|
+
* could omit it would reopen exactly the hole: `defaultSurface` skips dotfiles,
|
|
267
|
+
* the shipped template surface lists none, and no adopter writing
|
|
268
|
+
* `qa/verified-surface.json` by hand would think to add it. It is not the
|
|
269
|
+
* adopter's to declare — it is the resolver's own input.
|
|
270
|
+
*
|
|
271
|
+
* Not locked: adopters edit `.gitignore` legitimately and often. Attestation is
|
|
272
|
+
* the right instrument — a change makes the receipt say "source changed", which
|
|
273
|
+
* is true, and re-running mints a valid one.
|
|
274
|
+
*/
|
|
275
|
+
const RESOLUTION_INPUTS = Object.freeze([".gitignore"]);
|
|
276
|
+
|
|
277
|
+
/** @param {string} relPath @returns {boolean} */
|
|
278
|
+
function isResolutionInput(relPath) {
|
|
279
|
+
return RESOLUTION_INPUTS.includes(relPath);
|
|
280
|
+
}
|
|
281
|
+
|
|
138
282
|
// File-level mirror of the same principle (OS/editor junk the .gitignore covers).
|
|
139
283
|
const WALK_EXCLUDED_FILES = new Set([".DS_Store"]);
|
|
140
284
|
const WALK_EXCLUDED_SUFFIXES = [".iml", ".log"];
|
|
@@ -145,14 +289,16 @@ function walkIncludesFile(name) {
|
|
|
145
289
|
}
|
|
146
290
|
|
|
147
291
|
// Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
|
|
148
|
-
|
|
292
|
+
// `ignored` is the floor plus whatever this repo's own .gitignore names, so the
|
|
293
|
+
// pre-git hash agrees with the post-git one for any ecosystem, not just the first.
|
|
294
|
+
function walkAllFiles(dir, ignored = WALK_EXCLUDED_DIRS) {
|
|
149
295
|
const out = [];
|
|
150
296
|
if (!fs.existsSync(dir)) return out;
|
|
151
297
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
152
298
|
const p = path.join(dir, entry.name);
|
|
153
299
|
if (entry.isDirectory()) {
|
|
154
|
-
if (
|
|
155
|
-
out.push(...walkAllFiles(p));
|
|
300
|
+
if (ignored.has(entry.name)) continue; // non-source scratch — see note above
|
|
301
|
+
out.push(...walkAllFiles(p, ignored));
|
|
156
302
|
} else if (entry.isFile() && walkIncludesFile(entry.name)) out.push(p);
|
|
157
303
|
}
|
|
158
304
|
return out;
|
|
@@ -186,7 +332,7 @@ export function resolveVerifiedSurface(root) {
|
|
|
186
332
|
try {
|
|
187
333
|
raw = fs.readFileSync(p, "utf8");
|
|
188
334
|
} catch {
|
|
189
|
-
return
|
|
335
|
+
return defaultSurface(root); // no declaration — the tree's own top level
|
|
190
336
|
}
|
|
191
337
|
let parsed;
|
|
192
338
|
try {
|
|
@@ -203,32 +349,41 @@ export function resolveVerifiedSurface(root) {
|
|
|
203
349
|
|
|
204
350
|
// Resolve the verified surface to a flat, sorted list of paths (relative to
|
|
205
351
|
// root, POSIX-style `/` separators) that currently exist on disk.
|
|
206
|
-
function resolveSurfaceFiles(root,
|
|
352
|
+
function resolveSurfaceFiles(root, surfaceEntries) {
|
|
207
353
|
const gitFiles = tryGitLsFiles(root);
|
|
208
354
|
|
|
209
355
|
if (gitFiles) {
|
|
210
356
|
return gitFiles
|
|
211
357
|
.map((p) => p.split(path.sep).join("/"))
|
|
212
|
-
.filter((relPath) =>
|
|
358
|
+
.filter((relPath) => isResolutionInput(relPath) || surfaceEntries.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
|
|
213
359
|
.filter((relPath) => !isExcluded(relPath))
|
|
214
360
|
.filter((relPath) => fs.existsSync(path.join(root, relPath)) && fs.statSync(path.join(root, relPath)).isFile());
|
|
215
361
|
}
|
|
216
362
|
|
|
217
363
|
// Fallback: no git available — walk the surface directories directly so a
|
|
218
|
-
// non-git scaffold still produces a stable hash.
|
|
364
|
+
// non-git scaffold still produces a stable hash. The ignore set is the floor
|
|
365
|
+
// PLUS this repo's own .gitignore, which is what `git ls-files
|
|
366
|
+
// --exclude-standard` will honour the moment the tree becomes a repo. Reading
|
|
367
|
+
// the same file is what makes the two modes agree by construction.
|
|
368
|
+
const ignored = new Set([...WALK_FLOOR, ...gitignoredDirs(root), ...declaredIgnore(root)]);
|
|
219
369
|
const collected = [];
|
|
220
|
-
for (const surface of
|
|
370
|
+
for (const surface of surfaceEntries) {
|
|
221
371
|
const abs = path.join(root, surface);
|
|
222
372
|
if (!fs.existsSync(abs)) continue;
|
|
223
373
|
const stat = fs.statSync(abs);
|
|
224
374
|
if (stat.isFile()) {
|
|
225
375
|
collected.push(surface);
|
|
226
376
|
} else if (stat.isDirectory()) {
|
|
227
|
-
for (const file of walkAllFiles(abs)) {
|
|
377
|
+
for (const file of walkAllFiles(abs, ignored)) {
|
|
228
378
|
collected.push(path.relative(root, file).split(path.sep).join("/"));
|
|
229
379
|
}
|
|
230
380
|
}
|
|
231
381
|
}
|
|
382
|
+
// The resolver's own inputs, attested whatever the surface declares.
|
|
383
|
+
for (const rel of RESOLUTION_INPUTS) {
|
|
384
|
+
const abs = path.join(root, rel);
|
|
385
|
+
if (!collected.includes(rel) && fs.existsSync(abs) && fs.statSync(abs).isFile()) collected.push(rel);
|
|
386
|
+
}
|
|
232
387
|
return collected.filter((relPath) => !isExcluded(relPath));
|
|
233
388
|
}
|
|
234
389
|
|
|
@@ -241,7 +396,9 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
|
241
396
|
* it belongs in qa/verified-surface.json. Sorted; [] when git is unavailable
|
|
242
397
|
* (the walk fallback has no notion of "what git sees") or everything is
|
|
243
398
|
* covered. Lane outputs (EXCLUDED_PREFIXES) are not "undeclared" — they are
|
|
244
|
-
* excluded by decision
|
|
399
|
+
* excluded by decision, and RESOLUTION_INPUTS are not undeclared either: they
|
|
400
|
+
* are attested structurally, so naming them here would report a file as
|
|
401
|
+
* unattested that the very same hash attests.
|
|
245
402
|
* @param {string} root
|
|
246
403
|
* @param {string[]} [surface] defaults to resolveVerifiedSurface(root)
|
|
247
404
|
* @returns {string[]}
|
|
@@ -249,7 +406,8 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
|
|
|
249
406
|
export function undeclaredTopLevel(root, surface = resolveVerifiedSurface(root)) {
|
|
250
407
|
const gitFiles = tryGitLsFiles(root);
|
|
251
408
|
if (!gitFiles) return [];
|
|
252
|
-
const covered = (relPath) =>
|
|
409
|
+
const covered = (relPath) =>
|
|
410
|
+
surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath) || isResolutionInput(relPath);
|
|
253
411
|
const out = new Set();
|
|
254
412
|
for (const raw of gitFiles) {
|
|
255
413
|
const relPath = raw.split(path.sep).join("/");
|