@bookedsolid/rea 0.6.2 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bash
2
+ # dist-regression-gate.sh — class-level guard against "src/ changed, dist/ didn't"
3
+ #
4
+ # Generalizes BUG-013's trust-repair from a `[security]`-changeset-keyed gate
5
+ # (scripts/tarball-smoke.sh) to a gate that fires on ANY change set. Catches
6
+ # the 0.6.0 → 0.6.1 regression class: a release that ships dist/ byte-identical
7
+ # to the previous release despite src/ edits (i.e. dist/ was not rebuilt from
8
+ # the shipping commit).
9
+ #
10
+ # Bypass-resistant by construction:
11
+ # - Does not depend on changeset labels. A changeset-free PR that touches
12
+ # src/ without rebuilding dist/ still fails.
13
+ # - Does not depend on the release.yml rebuild step. That step is
14
+ # defense-in-depth at publish time; this gate fires on every PR and every
15
+ # push:main, so the regression is caught BEFORE it reaches the release
16
+ # branch.
17
+ #
18
+ # ## Algorithm
19
+ #
20
+ # 1. Read last-published version from `npm view @bookedsolid/rea version`.
21
+ # 2. Resolve the matching `v<version>` git tag. (Tag scheme verified across
22
+ # v0.1.0 … v0.6.2.)
23
+ # 3. Diff `src/` between HEAD and the tag. If unchanged, exit 0 — nothing
24
+ # to verify.
25
+ # 4. `npm pack @bookedsolid/rea@<version>` in a tempdir, hash the dist/
26
+ # tree in the extracted tarball.
27
+ # 5. Hash the local `dist/` tree the same way (assumes `pnpm build` has
28
+ # already run — CI enforces this, local runs need to pre-build).
29
+ # 6. If hashes are equal AND src/ changed → FAIL.
30
+ #
31
+ # ## Hash scheme
32
+ #
33
+ # `find dist -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256`
34
+ # matches release.yml's own hash recipe (lines 82, 130) so this gate and the
35
+ # post-publish verify step use the same digest. Per-file content sort, no
36
+ # mtime/permission bits — matches the logical-equality question we're asking.
37
+ #
38
+ # ## Exit codes
39
+ #
40
+ # 0 — pass, or skip (see "Skip surface" below)
41
+ # 1 — preflight failure: required tool missing from PATH, or dist/ directory
42
+ # absent at script start (run `pnpm build` first)
43
+ # 2 — REGRESSION — src/ changed vs last release but dist/ hash identical
44
+ #
45
+ # ## Skip surface (exit 0 without running the full check)
46
+ #
47
+ # The gate degrades to a clean skip on infrastructure failures so a transient
48
+ # registry outage or a malformed prior release does not pin every PR and
49
+ # every push:main run into red. All skip branches log a specific reason:
50
+ #
51
+ # - npm view found no published version → first-ever release, or registry
52
+ # outage at lookup time
53
+ # - matching git tag `v<version>` is not reachable and cannot be fetched
54
+ # from origin
55
+ # - src/ is byte-identical between HEAD and the tag → nothing to verify
56
+ # - npm pack against the previous version fails → registry outage, auth
57
+ # trouble, or the tarball was unpublished
58
+ # - npm pack succeeds but produces no `.tgz` → malformed pack output
59
+ # (rare; guarded separately from the pack-failure branch so the log
60
+ # reason stays specific)
61
+ # - the fetched tarball has no `package/dist/` → the baseline itself is
62
+ # broken; holding the next PR hostage to a bad prior release hurts more
63
+ # than it helps
64
+ #
65
+ # The release.yml rebuild-from-HEAD + post-publish tarball hash verification
66
+ # steps (see .github/workflows/release.yml lines 78-138) are the publish-time
67
+ # catching net that covers any case this gate skips. That layered defense is
68
+ # the point: this gate closes the common BUG-013 class on PR + push:main;
69
+ # the release workflow closes the rest at the moment it matters most.
70
+ #
71
+ # ## False-positive surface (known, documented)
72
+ #
73
+ # A whitespace-only edit in src/ that tsc compiles to byte-identical output
74
+ # WILL fail this gate. The failure message tells the committer to either:
75
+ # (a) include a meaningful src change whose dist artifact differs, or
76
+ # (b) `rm -rf dist && pnpm build && git add dist` to refresh timestamps
77
+ # inside dist — except the gate hashes content, not mtime, so (b) alone
78
+ # won't lift the failure.
79
+ #
80
+ # Adding an `[allow-noop-dist]` bypass marker was considered and rejected —
81
+ # it would re-open the BUG-013 attack surface for anyone who can open a PR.
82
+ # If you hit a legitimate noop-dist case, solve it by not committing the
83
+ # whitespace-only src change in isolation, or by combining it with a dist-
84
+ # affecting change in the same PR.
85
+ #
86
+ # ## Local usage
87
+ #
88
+ # pnpm build && scripts/dist-regression-gate.sh
89
+ #
90
+ # ## CI wiring
91
+ #
92
+ # Runs as its own ci.yml job after build. See .github/workflows/ci.yml
93
+ # `dist-regression` job for wiring.
94
+
95
+ set -euo pipefail
96
+
97
+ REPO_ROOT="$(cd -- "$(dirname -- "$0")/.." && pwd -P)"
98
+ cd "$REPO_ROOT"
99
+
100
+ log() { printf '[dist-regression] %s\n' "$*"; }
101
+ err() { printf '[dist-regression] %s\n' "$*" >&2; }
102
+
103
+ # Preflight — need npm, jq, git, shasum, tar.
104
+ for tool in npm jq git shasum tar; do
105
+ if ! command -v "$tool" >/dev/null 2>&1; then
106
+ err "FAIL — required tool not on PATH: $tool"
107
+ exit 1
108
+ fi
109
+ done
110
+
111
+ if [ ! -d "dist" ]; then
112
+ err "FAIL — dist/ not found. Run 'pnpm build' first."
113
+ exit 1
114
+ fi
115
+
116
+ # Resolve last published version from npm. Use --silent to suppress npm's
117
+ # own progress chatter; swallow stderr because we want a clean skip on a
118
+ # network error rather than a hard failure blocking every CI run.
119
+ PKG_NAME="$(jq -r '.name' package.json)"
120
+ PREV_VERSION="$(npm view "$PKG_NAME" version 2>/dev/null || true)"
121
+ if [ -z "$PREV_VERSION" ]; then
122
+ log "skip — no previous published version found for $PKG_NAME (network issue or first release)"
123
+ exit 0
124
+ fi
125
+ log "last published: $PKG_NAME@$PREV_VERSION"
126
+
127
+ # Resolve the matching git tag. In CI, actions/checkout fetches tags only
128
+ # when fetch-depth: 0 (see ci.yml). Locally, the tag should exist. If the
129
+ # tag isn't reachable, fetch it from origin; if the fetch fails (offline,
130
+ # first-ever release), skip — this is the same safety valve as the
131
+ # "no previous published version" branch.
132
+ PREV_TAG="v$PREV_VERSION"
133
+ if ! git rev-parse --verify --quiet "$PREV_TAG" >/dev/null 2>&1; then
134
+ # Try a shallow fetch of just that tag. Redirect stderr because a missing
135
+ # tag on origin is expected behaviour for a brand-new package; we degrade
136
+ # to "skip" rather than "fail".
137
+ if ! git fetch --quiet --depth=1 origin "refs/tags/${PREV_TAG}:refs/tags/${PREV_TAG}" 2>/dev/null; then
138
+ log "skip — tag $PREV_TAG not reachable (offline CI or tag pruned)"
139
+ exit 0
140
+ fi
141
+ fi
142
+ log "anchor tag: $PREV_TAG ($(git rev-parse --short "$PREV_TAG"))"
143
+
144
+ # Compare src/ trees. We use `git diff --name-only` so renames and mode-
145
+ # only changes count. If src/ is unchanged vs the tag, the dist/ gate has
146
+ # nothing to verify — a PR that touches only docs/hooks/CI should not fail
147
+ # this check.
148
+ SRC_CHANGED_COUNT="$(git diff --name-only "$PREV_TAG" HEAD -- src/ | wc -l | awk '{print $1}')"
149
+ if [ "$SRC_CHANGED_COUNT" -eq 0 ]; then
150
+ log "skip — src/ unchanged since $PREV_TAG"
151
+ exit 0
152
+ fi
153
+ log "src/ changes vs $PREV_TAG: $SRC_CHANGED_COUNT file(s)"
154
+
155
+ # Fetch the published tarball and compute its dist/ hash. Using `npm pack`
156
+ # against the published name is more stable than scraping the registry URL
157
+ # because npm handles auth + CDN redirects. --silent keeps the only stdout
158
+ # noise to the tarball filename.
159
+ WORK="$(mktemp -d -t rea-dist-regression-XXXXXX)"
160
+ trap 'rm -rf -- "$WORK"' EXIT HUP INT TERM
161
+
162
+ # Degrade-to-skip on infrastructure failures. `npm view` above already
163
+ # skips on network errors; `npm pack` and tarball-shape checks need to
164
+ # match, otherwise a registry outage or a broken prior release (e.g. if
165
+ # the shipping 0.6.1 tarball itself had been malformed rather than merely
166
+ # stale) would pin every PR run into red until the registry recovered or
167
+ # a new tarball was published. The release.yml rebuild+verify step
168
+ # remains the catching net at publish time, so skipping here does not
169
+ # re-open the BUG-013 attack surface for the merge-to-main path.
170
+ if ! ( cd "$WORK" && npm pack "${PKG_NAME}@${PREV_VERSION}" --silent >/dev/null 2>&1 ); then
171
+ log "skip — npm pack ${PKG_NAME}@${PREV_VERSION} failed (network issue or registry outage)"
172
+ exit 0
173
+ fi
174
+
175
+ TARBALL="$(find "$WORK" -maxdepth 1 -type f -name '*.tgz' | head -1)"
176
+ if [ -z "$TARBALL" ] || [ ! -f "$TARBALL" ]; then
177
+ log "skip — npm pack produced no tarball for ${PKG_NAME}@${PREV_VERSION} in $WORK"
178
+ exit 0
179
+ fi
180
+
181
+ mkdir -p "$WORK/extract"
182
+ tar -xzf "$TARBALL" -C "$WORK/extract"
183
+
184
+ if [ ! -d "$WORK/extract/package/dist" ]; then
185
+ log "skip — published tarball for $PREV_VERSION has no dist/ at package/dist (broken baseline)"
186
+ exit 0
187
+ fi
188
+
189
+ hash_tree() {
190
+ # $1 — directory holding `dist/`
191
+ # Match the recipe from .github/workflows/release.yml:82,130 exactly so
192
+ # this gate and the release-time verify step speak the same digest.
193
+ ( cd "$1" && find dist -type f -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | awk '{print $1}' )
194
+ }
195
+
196
+ PUBLISHED_HASH="$(hash_tree "$WORK/extract/package")"
197
+ CURRENT_HASH="$(hash_tree "$REPO_ROOT")"
198
+
199
+ log "published ($PREV_VERSION) dist/ hash: $PUBLISHED_HASH"
200
+ log "current dist/ hash: $CURRENT_HASH"
201
+
202
+ if [ "$PUBLISHED_HASH" = "$CURRENT_HASH" ]; then
203
+ err ""
204
+ err "FAIL — REGRESSION: src/ has $SRC_CHANGED_COUNT file change(s) vs $PREV_TAG"
205
+ err " but current dist/ hash is byte-identical to the published tarball."
206
+ err ""
207
+ err " This is the 0.6.0 → 0.6.1 regression class (BUG-013): dist/ was"
208
+ err " not rebuilt from HEAD. Running a fresh build should refresh the"
209
+ err " compiled output; if it does not, one or more src/ changes are"
210
+ err " whitespace-only and produce no dist/ delta — in that case,"
211
+ err " batch them with a change that DOES affect compiled output."
212
+ err ""
213
+ err " To diagnose locally:"
214
+ err " rm -rf dist && pnpm build"
215
+ err " scripts/dist-regression-gate.sh"
216
+ err ""
217
+ exit 2
218
+ fi
219
+
220
+ log "PASS — dist/ differs from $PREV_TAG baseline, as expected."