@bookedsolid/rea 0.6.1 → 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.1",
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."
@@ -181,6 +181,121 @@ echo "[smoke] → $AGENT_COUNT agents, $HOOK_COUNT hooks, $COMMAND_COUNT comma
181
181
  echo "[smoke] rea doctor"
182
182
  ./node_modules/.bin/rea doctor
183
183
 
184
+ # ---------------------------------------------------------------------------
185
+ # BUG-013 — security-claim content gate.
186
+ #
187
+ # If any changeset carries the `[security]` marker, the tarball MUST ship
188
+ # compiled evidence of the claimed fix. The rule:
189
+ #
190
+ # 1. Find every `.changeset/*.md` in the source tree that contains `[security]`
191
+ # 2. Assert AT LEAST ONE `*sanitize*.test.ts` or `*security*.test.ts` exists
192
+ # under `src/` (a "security-claim" changeset without a matching regression
193
+ # test is a marketing bullet, not a shipped fix)
194
+ # 3. For every such test file, extract the symbols it imports from the
195
+ # module under test (named imports from relative paths) and assert each
196
+ # symbol appears somewhere under `dist/`. Tests are excluded from the
197
+ # npm build (tsconfig.build.json), so a stale dist/ from a prior release
198
+ # would not contain the new symbol that the test exercises — this catches
199
+ # the 0.6.0→0.6.1 byte-identical dist/ regression that motivated BUG-013.
200
+ #
201
+ # Bypass-resistant: the gate keys on the changeset marker, not a flag the
202
+ # release author chooses. Narrow: no-op when no `[security]` changesets exist.
203
+ #
204
+ # Known limits (called out honestly rather than papered over):
205
+ # - The gate asserts the imported SYMBOLS are present in dist/. It does
206
+ # NOT assert those symbols are NEW vs. the previous published release.
207
+ # A test that imports only pre-existing symbols would satisfy the gate
208
+ # against a stale dist/. The two defense-in-depth layers that close
209
+ # this gap — `Rebuild dist/ from HEAD before publish` and
210
+ # `Verify published tarball dist/ matches CI-built dist/` — live in
211
+ # `.github/workflows/release.yml` (see `.rea/drafts-0.6.2/` for the
212
+ # pending hand-apply patch). The content gate here catches the
213
+ # 0.6.0→0.6.1 class of regression in the common case; the workflow
214
+ # hash check catches the adversarial case.
215
+ # - The gate does not tie a specific changeset to a specific test file.
216
+ # If a security changeset names BUG-X but the shipping security test
217
+ # covers BUG-Y, the gate passes. Mitigation is the same: the workflow
218
+ # hash verification plus human review of the changeset at PR time.
219
+ # ---------------------------------------------------------------------------
220
+ SEC_CHANGESETS="$(grep -l '\[security\]' "$REPO_ROOT"/.changeset/*.md 2>/dev/null || true)"
221
+ if [ -n "$SEC_CHANGESETS" ]; then
222
+ echo "[smoke] security-claim gate: $(printf '%s\n' "$SEC_CHANGESETS" | wc -l | awk '{print $1}') changeset(s) tagged [security]"
223
+
224
+ SEC_SRC_TESTS="$(cd "$REPO_ROOT" && find src -type f \( -name '*sanitize*.test.ts' -o -name '*security*.test.ts' \) 2>/dev/null | sort)"
225
+ if [ -z "$SEC_SRC_TESTS" ]; then
226
+ echo "[smoke] FAIL — [security] changeset present but no *sanitize*.test.ts or *security*.test.ts under src/" >&2
227
+ echo "[smoke] a security-claim changeset with no matching regression test is a trust violation" >&2
228
+ exit 2
229
+ fi
230
+
231
+ # For each security test, collect the named imports pulled from relative
232
+ # paths — those are the symbols under test and must be compiled into dist/.
233
+ # Example line we want to match:
234
+ # import { sanitizeHealthSnapshot, INJECTION_REDACTED_PLACEHOLDER } from './health';
235
+ # We ignore imports from bare package names ('vitest', 'node:fs', etc.).
236
+ MISSING_SYMBOLS=""
237
+ SYMBOL_COUNT=0
238
+ while IFS= read -r src_test; do
239
+ [ -z "$src_test" ] && continue
240
+ # Collect named imports from relative-path sources using perl for a
241
+ # multi-line regex. Output: one symbol per line.
242
+ # We intentionally skip:
243
+ # - `import type { ... }` — entire clause is type-only
244
+ # - `{ ..., type Foo, ... }` — inline type-only marker on a member
245
+ # TypeScript erases both at compile time, so asserting them against dist/
246
+ # would false-positive. Also skip `as` aliases (the aliased symbol is a
247
+ # local rebind, not the exported one we want to grep).
248
+ SYMBOLS="$(perl -0777 -ne '
249
+ while (/import(\s+type)?\s*\{([^}]+)\}\s*from\s*[\x27"](\.[^\x27"]+)[\x27"]/sg) {
250
+ next if $1; # whole clause is `import type { ... }` — skip
251
+ my $group = $2;
252
+ $group =~ s/\s+/ /g;
253
+ for my $sym (split /,/, $group) {
254
+ $sym =~ s/^\s+|\s+$//g;
255
+ next if $sym =~ /^type\s+/; # inline `type Foo` — skip
256
+ $sym =~ s/\s+as\s+\w+$//;
257
+ next unless $sym =~ /^\w+$/;
258
+ print "$sym\n";
259
+ }
260
+ }
261
+ ' "$REPO_ROOT/$src_test" | sort -u)"
262
+
263
+ while IFS= read -r sym; do
264
+ [ -z "$sym" ] && continue
265
+ SYMBOL_COUNT=$((SYMBOL_COUNT + 1))
266
+ # grep -r across dist/ — if the symbol does not appear anywhere, the
267
+ # build did not include the fix the test covers.
268
+ if ! grep -r --include='*.js' -l -F -w "$sym" "$REPO_ROOT/dist" >/dev/null 2>&1; then
269
+ MISSING_SYMBOLS="$MISSING_SYMBOLS
270
+ $sym (imported by $src_test)"
271
+ fi
272
+ done <<< "$SYMBOLS"
273
+ done <<< "$SEC_SRC_TESTS"
274
+
275
+ if [ -n "$MISSING_SYMBOLS" ]; then
276
+ echo "[smoke] FAIL — [security] changeset present but symbols under test are MISSING from dist/:" >&2
277
+ echo "[smoke] (dist/ may be stale — rebuild before publishing)" >&2
278
+ printf '%s\n' "$MISSING_SYMBOLS" >&2
279
+ exit 2
280
+ fi
281
+
282
+ # Codex review blocker #1 (2026-04-20) — a test file written with
283
+ # namespace/default/dynamic imports, or one that only imports from bare
284
+ # packages, produces zero symbols to check. Before this guard, the gate
285
+ # would pass with "0 symbols all present in dist/", re-opening the
286
+ # byte-identical-dist/ regression that BUG-013 was written to catch.
287
+ if [ "$SYMBOL_COUNT" -eq 0 ]; then
288
+ echo "[smoke] FAIL — [security] changeset present but no checkable symbols extracted" >&2
289
+ echo "[smoke] one or more src/**/(*sanitize*|*security*).test.ts files must use" >&2
290
+ echo "[smoke] the \`import { Named } from './relative'\` shape so the gate can" >&2
291
+ echo "[smoke] verify the symbol under test appears in compiled dist/." >&2
292
+ echo "[smoke] (namespace/default/dynamic-only imports can't be verified)" >&2
293
+ exit 2
294
+ fi
295
+
296
+ echo "[smoke] → $(printf '%s\n' "$SEC_SRC_TESTS" | wc -l | awk '{print $1}') security regression test(s), $SYMBOL_COUNT imported symbol(s) all present in dist/"
297
+ fi
298
+
184
299
  # Verify every declared public export resolves. If the exports map points at a
185
300
  # file that didn't ship in `files:`, this is where we catch it.
186
301
  echo "[smoke] resolve exports"