@bookedsolid/rea 0.35.0 → 0.36.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.
@@ -169,22 +169,19 @@ export const EXPECTED_HOOKS = [
169
169
  'blocked-paths-enforcer.sh',
170
170
  'changeset-security-gate.sh',
171
171
  'dangerous-bash-interceptor.sh',
172
- // 0.31.0 — `delegation-advisory.sh` is INTENTIONALLY NOT in this list.
173
- // It is the brand-new PostToolUse delegation-nudge hook this release
174
- // ships; adding it to EXPECTED_HOOKS would make `checkHooksInstalled`
175
- // hard-`fail` on every pre-0.31.0 consumer install (and on this repo's
176
- // own dogfood) the instant they upgrade the rea binary but before they
177
- // run `rea upgrade` to lay down the new hook file — a regression that
178
- // turns a green doctor red purely from upgrade lag. This mirrors the
179
- // staged rollout `delegation-capture.sh` itself used: the file-presence
180
- // entry joins EXPECTED_HOOKS in the SAME future minor that promotes
181
- // `checkDelegationAdvisoryHookRegistered` from `warn` to `fail`, once
182
- // consumers have had upgrade-lag time. Until then, a missing
183
- // `delegation-advisory.sh` surfaces only through that `warn`-tier
184
- // registration check — proportionate, since the hook is advisory at
185
- // runtime and never blocks a tool call. See `git blame` on the 0.29.0
186
- // round-2 P2 comment on `checkDelegationHookRegistered` for the prior
187
- // application of this exact discipline.
172
+ // 0.36.0 — `delegation-advisory.sh` PROMOTED to EXPECTED_HOOKS (charter
173
+ // follow-through from 0.31.0). Originally held out in 0.31.0 to give
174
+ // consumers an upgrade-lag window: adding a brand-new hook to
175
+ // EXPECTED_HOOKS would have hard-`fail`ed `checkHooksInstalled` on
176
+ // every pre-0.31.0 install the instant they bumped the rea binary, a
177
+ // regression that turns a green doctor red purely from upgrade lag.
178
+ // After 4 releases of propagation (0.32, 0.33, 0.34, 0.35), the lag
179
+ // window has closed — consumers running `rea upgrade` since 0.31.0
180
+ // have laid the hook down. Same ratchet `delegation-capture.sh` went
181
+ // through 0.29.0 0.30.0. Promotion happens in lockstep with
182
+ // `checkDelegationAdvisoryHookRegistered` flipping `warn` `fail`
183
+ // (see that function for the matching commentary).
184
+ 'delegation-advisory.sh',
188
185
  // 0.29.0 — delegation-telemetry MVP. The PreToolUse hook on
189
186
  // matcher `Agent|Skill` emits a `rea.delegation_signal` audit record
190
187
  // on every subagent / skill dispatch. Observational only — fails
@@ -1126,14 +1123,25 @@ export function checkDelegationHookRegistered(baseDir) {
1126
1123
  */
1127
1124
  export function checkDelegationAdvisoryHookRegistered(baseDir) {
1128
1125
  const label = 'delegation-advisory hook registered';
1129
- const ADVISORY = 'warn';
1126
+ // 0.36.0 promoted from `warn` (advisory in 0.31.0) to `fail` (hard)
1127
+ // per the staged-rollout ratchet. After 4 releases of upgrade-lag
1128
+ // propagation (0.32, 0.33, 0.34, 0.35), consumer installs that have
1129
+ // run `rea upgrade` since 0.31.0 already carry the PostToolUse
1130
+ // `Bash|Edit|Write|MultiEdit|NotebookEdit` group. Any install that
1131
+ // still lacks it after that window is genuinely missing the nudge
1132
+ // and `fail` is the proportionate signal. Companion change:
1133
+ // `delegation-advisory.sh` joined `EXPECTED_HOOKS` in the same
1134
+ // commit, so `checkHooksInstalled` also covers the file-presence +
1135
+ // executability checks now (this function still does both directly
1136
+ // as defense-in-depth, mirroring `checkDelegationHookRegistered`).
1137
+ const REFUSE = 'fail';
1130
1138
  const MATCHER = 'Bash|Edit|Write|MultiEdit|NotebookEdit';
1131
1139
  const settingsPath = path.join(baseDir, '.claude', 'settings.json');
1132
1140
  if (!fs.existsSync(settingsPath)) {
1133
1141
  return {
1134
1142
  label,
1135
- status: ADVISORY,
1136
- detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\` (advisory in 0.31.0)`,
1143
+ status: REFUSE,
1144
+ detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init\``,
1137
1145
  };
1138
1146
  }
1139
1147
  let parsed;
@@ -1143,7 +1151,7 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
1143
1151
  catch (e) {
1144
1152
  return {
1145
1153
  label,
1146
- status: ADVISORY,
1154
+ status: REFUSE,
1147
1155
  detail: e instanceof Error ? e.message : String(e),
1148
1156
  };
1149
1157
  }
@@ -1152,9 +1160,9 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
1152
1160
  if (group === undefined) {
1153
1161
  return {
1154
1162
  label,
1155
- status: ADVISORY,
1163
+ status: REFUSE,
1156
1164
  detail: `no PostToolUse group with matcher "${MATCHER}" found in .claude/settings.json — ` +
1157
- 'run `rea upgrade` to install (advisory in 0.31.0; promoted to fail in a later minor). ' +
1165
+ 'run `rea upgrade` to install. ' +
1158
1166
  'NOTE: the matcher INCLUDES Bash — the delegation nudge counts every write-class ' +
1159
1167
  'tool call, not just file edits.',
1160
1168
  };
@@ -1163,16 +1171,16 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
1163
1171
  if (!cmds.some((c) => c.includes('delegation-advisory.sh'))) {
1164
1172
  return {
1165
1173
  label,
1166
- status: ADVISORY,
1174
+ status: REFUSE,
1167
1175
  detail: `${MATCHER} matcher exists but no delegation-advisory.sh command found in its hooks list`,
1168
1176
  };
1169
1177
  }
1170
- // Round-2/3 P2: the registration string is present — now confirm the
1171
- // hook file it points at actually exists AND is executable. Because
1172
- // `delegation-advisory.sh` is intentionally out of `EXPECTED_HOOKS`
1173
- // for 0.31.0, `checkHooksInstalled` does NOT cover it; this is the
1174
- // only signal, so it owns both the presence and the `0o111` checks
1175
- // that `checkHooksInstalled` does for every other shipped hook.
1178
+ // 0.31.0 round-2/3 P2: the registration string is present — now
1179
+ // confirm the hook file it points at actually exists AND is
1180
+ // executable. Kept after the 0.36.0 EXPECTED_HOOKS promotion as
1181
+ // defense-in-depth (the same `0o111` check `checkHooksInstalled`
1182
+ // does, scoped to this hook so the failure message can name the
1183
+ // exact remediation rather than a generic "missing X" enumeration).
1176
1184
  const hookFile = path.join(baseDir, '.claude', 'hooks', 'delegation-advisory.sh');
1177
1185
  let hookStat;
1178
1186
  try {
@@ -1181,19 +1189,19 @@ export function checkDelegationAdvisoryHookRegistered(baseDir) {
1181
1189
  catch {
1182
1190
  return {
1183
1191
  label,
1184
- status: ADVISORY,
1192
+ status: REFUSE,
1185
1193
  detail: `${MATCHER} matcher references delegation-advisory.sh but the hook file is missing: ` +
1186
- `${hookFile} — run \`rea upgrade\` to lay it down (advisory in 0.31.0). ` +
1194
+ `${hookFile} — run \`rea upgrade\` to lay it down. ` +
1187
1195
  'Without the file every matching PostToolUse dispatch shells out to a nonexistent path.',
1188
1196
  };
1189
1197
  }
1190
1198
  if ((hookStat.mode & 0o111) === 0) {
1191
1199
  return {
1192
1200
  label,
1193
- status: ADVISORY,
1201
+ status: REFUSE,
1194
1202
  detail: `${MATCHER} matcher references delegation-advisory.sh but the hook file is not executable ` +
1195
1203
  `(mode=${(hookStat.mode & 0o777).toString(8)}): ${hookFile} — ` +
1196
- 'run `rea upgrade` or `chmod +x` it (advisory in 0.31.0). ' +
1204
+ 'run `rea upgrade` or `chmod +x` it. ' +
1197
1205
  'A non-executable hook cannot be launched by Claude Code from settings.json.',
1198
1206
  };
1199
1207
  }
@@ -1469,9 +1477,10 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
1469
1477
  checkDelegationHookRegistered(baseDir),
1470
1478
  // 0.31.0 — delegation-telemetry completion. The PostToolUse
1471
1479
  // `Bash|Edit|Write|MultiEdit|NotebookEdit` matcher group drives
1472
- // the delegation-advisory nudge hook. Advisory (warn) for 0.31.0
1473
- // same upgrade-lag ratchet checkDelegationHookRegistered went
1474
- // through in 0.29.0.
1480
+ // the delegation-advisory nudge hook. 0.36.0 — promoted warn →
1481
+ // fail (same upgrade-lag ratchet checkDelegationHookRegistered
1482
+ // went through in 0.29.0 → 0.30.0, after 4 release cycles of
1483
+ // propagation).
1475
1484
  checkDelegationAdvisoryHookRegistered(baseDir),
1476
1485
  ];
1477
1486
  // Non-git escape hatch: when `.git/` is absent, both git-hook checks are
@@ -467,6 +467,50 @@ function splitSegmentsRecursive(cmd, depth) {
467
467
  }
468
468
  return out;
469
469
  }
470
+ /**
471
+ * Test whether a flag token is a `-c`-class introducer.
472
+ *
473
+ * Bash accepts `-c` combined with any other short single-char flags in
474
+ * a single short-flag bundle: `-c`, `-lc`, `-lic`, `-cli`, `-lci`,
475
+ * `-cil`, `-ilc`, etc. The bash cmd-segments.sh `WRAP` regex lists
476
+ * a non-exhaustive defensive subset (`c|lc|lic|ic|cl|cli|li|il`) but
477
+ * bash itself accepts ANY short-flag bundle that contains a `c`.
478
+ *
479
+ * The TS detector mirrors bash semantics: a SHORT-flag bundle
480
+ * (`-letters`, single leading `-`) whose letter set contains `c` is
481
+ * an introducer. The separated `--c` long-flag form is also
482
+ * recognized for parity with the bash WRAP regex's `--c` alternation.
483
+ *
484
+ * Long flags (`--rcfile`, `--noprofile`, `--login`, `--init-file`)
485
+ * are NOT introducers regardless of whether they contain the letter
486
+ * `c` — bash's long-options namespace is disjoint from the `-c`
487
+ * payload-execute semantics.
488
+ *
489
+ * 0.36.0 audit-trail:
490
+ * - Charter item 4 / 0.34.0 codex round-7 P2 #1: pre-fix the test
491
+ * was `/c/i.test(flag.replace(/^--?/, ''))` which over-matched on
492
+ * every flag with a `c` in its name (`--rcfile`, `--noprofile`).
493
+ * - 0.36.0 codex round-1 P1: the first fix attempt used an explicit
494
+ * allowlist `Set` mirroring the bash WRAP regex's explicit
495
+ * alternation, which was a NARROWING vs the pre-fix behavior —
496
+ * valid combined-flag forms like `-lci`, `-cil`, `-ilc` were not
497
+ * in the allowlist and stopped unwrapping, reopening a bypass
498
+ * surface against env-file-protection / dependency-audit-gate /
499
+ * dangerous-bash matchers. This function restores parity with
500
+ * bash itself: any short-flag bundle containing `c` qualifies.
501
+ */
502
+ function isCDashIntroducer(flag) {
503
+ // Separated long-flag form (rare but bash accepts it).
504
+ if (flag === '--c')
505
+ return true;
506
+ // Short-flag bundle: single leading `-`, then one-or-more letters.
507
+ // The bundle is a `-c` introducer iff it contains the letter `c`
508
+ // (any position, any other-letters mix).
509
+ const m = /^-([A-Za-z]+)$/.exec(flag);
510
+ if (m === null)
511
+ return false;
512
+ return /c/i.test(m[1] ?? '');
513
+ }
470
514
  /**
471
515
  * Recognize a nested-shell wrapper segment and return the unquoted
472
516
  * payload string. Returns `null` when the segment is not a wrapper.
@@ -554,15 +598,29 @@ function extractNestedShellPayload(head) {
554
598
  return null;
555
599
  const flag = flagMatch[0] ?? '';
556
600
  cursor += flag.length;
557
- // Recognized flag-token shapes:
558
- // `-c` `-l` `-i` `-e` `-lc` `-lic` `-ic` `-cl` `-cli` `-li` `-il`
559
- // `--c` `--noprofile` (etc.) — we don't enforce the full list,
560
- // just that it's `-<letters>` or `--<letters>`.
601
+ // Recognized flag-token shapes (parity with cmd-segments.sh WRAP):
602
+ // - pre-flags (no `-c` yet): `-l`, `-i`, `-e`, `-li`, `-il`,
603
+ // `--noprofile`, `--rcfile`, `--login` (etc.)
604
+ // - `-c`-class introducer: exactly `-c`, `-lc`, `-lic`, `-cl`,
605
+ // `-cli`, `-li`, `-il`, `-ic` (the bash WRAP regex's
606
+ // `-(c|lc|lic|ic|cl|cli|li|il)` set), OR separated `--c`.
607
+ //
608
+ // 0.36.0 audit-trail (charter item 4 / 0.34.0 codex round-7 P2 #1):
609
+ // pre-fix the test `/c/i.test(flag.replace(/^--?/, ''))` treated
610
+ // ANY flag containing the letter `c` as a `-c` introducer. This
611
+ // false-positived on benign flags like `--rcfile`, `--noprofile`
612
+ // (with `c` in the name), causing the walker to "commit" to a -c
613
+ // unwrap, advance past the flag, and then either fail to find a
614
+ // quoted payload or unwrap something that was never a shell-payload
615
+ // body. Net effect: over-trigger of nested-shell unwrap, with
616
+ // downstream advisory matchers seeing payloads that weren't ever
617
+ // shell-payloads. Fix restricts the introducer set to the exact
618
+ // WRAP-regex shapes; any other flag shape continues the flag walk
619
+ // (still valid — pre-flags before `-c` are accepted) but does NOT
620
+ // mark `sawCFlag = true`.
561
621
  if (!/^--?[A-Za-z]+$/.test(flag))
562
622
  return null;
563
- // Does this flag contain `c` (the -c introducer letter)?
564
- // `--c` also counts (rare but bash accepts).
565
- if (/c/i.test(flag.replace(/^--?/, ''))) {
623
+ if (isCDashIntroducer(flag)) {
566
624
  sawCFlag = true;
567
625
  // Continue the loop — the payload is the NEXT non-flag token.
568
626
  // (Bash's argv parser stops walking flags as soon as it sees -c,
@@ -570,6 +628,8 @@ function extractNestedShellPayload(head) {
570
628
  // safety; the bash WRAP regex similarly tolerates trailing
571
629
  // flag-like tokens before the quoted body.)
572
630
  }
631
+ // Else: a pre-flag (e.g. `-l`, `--rcfile`, `--noprofile`) — keep
632
+ // walking; if a later token IS in `CDASH_INTRODUCERS` we'll fire.
573
633
  }
574
634
  if (!sawCFlag)
575
635
  return null;
@@ -127,7 +127,22 @@ const SECRET_PATTERNS = [
127
127
  {
128
128
  severity: 'HIGH',
129
129
  label: 'Supabase service role key (JWT)',
130
- regex: /SUPABASE_SERVICE_ROLE_KEY\s*=\s*["']?eyJ[A-Za-z0-9._-]{50,}/g,
130
+ // 0.36.0 audit-trail (charter item 5 / 0.34.0 codex round-7 P2 #2):
131
+ // restore byte-parity with the pre-0.34.0 bash body. The bash hook
132
+ // required a quote introducer (`["']`, no `?`); only quoted
133
+ // assignments matched HIGH. Pre-fix this TS regex made the quote
134
+ // OPTIONAL (`["']?`), which upgraded an unquoted `.env` line like
135
+ // `SUPABASE_SERVICE_ROLE_KEY=eyJ...` from MEDIUM advisory (matched
136
+ // by the lower-down `.env credential assignment` pattern) to HIGH
137
+ // blocking. That over-blocks legitimate `.env` files committed to
138
+ // public repos and breaks parity with consumers still on the bash
139
+ // body. Fix: drop the `?` so the quote is required, matching bash
140
+ // exactly. Unquoted assignments continue to fire MEDIUM via the
141
+ // `.env credential assignment` pattern below (line ~190); the only
142
+ // change is that they no longer ALSO fire HIGH here. The `[\"']`
143
+ // character class accepts both single and double quotes
144
+ // (mirroring the bash `["\'"'"']` literal).
145
+ regex: /SUPABASE_SERVICE_ROLE_KEY\s*=\s*["']eyJ[A-Za-z0-9._-]{50,}/g,
131
146
  },
132
147
  // ── MEDIUM severity (advisory) ───────────────────────────────────
133
148
  {
@@ -153,10 +168,57 @@ const SECRET_PATTERNS = [
153
168
  label: 'Hardcoded DB connection string with password',
154
169
  regex: /postgresql:\/\/[^:]+:[^@]{8,}@/g,
155
170
  },
171
+ {
172
+ severity: 'MEDIUM',
173
+ label: 'Supabase service role key (JWT, unquoted non-.env shape)',
174
+ // 0.36.0 codex round-2 P1 → round-3 P3 → round-4 P1 evolution.
175
+ //
176
+ // Background:
177
+ // - Round 1: 0.34.0-introduced HIGH regex had `["']?` (quote
178
+ // optional) which over-blocked unquoted .env lines vs the
179
+ // pre-0.34.0 bash baseline. Charter item 5 dropped the `?`.
180
+ // - Round 2 P1: dropping the `?` left a gap for unquoted forms
181
+ // outside `^FOO=` shape (`export FOO=…` etc.) that ALSO
182
+ // existed in the bash baseline. Added an unquoted-anywhere
183
+ // MEDIUM rule to close it.
184
+ // - Round 3 P3: unquoted-anywhere double-fired with the broader
185
+ // `.env credential assignment` MEDIUM on plain `^FOO=` lines.
186
+ // Narrowed to only fire on 5 specific shell-keyword prefixes
187
+ // (`export`, `readonly`, `declare`, `local`, `typeset`).
188
+ // - Round 4 P1: too narrow — left other unquoted shapes
189
+ // (Dockerfile `ENV FOO=…`, k8s manifests, ad-hoc shell
190
+ // `FOO=…; bar`) entirely unscanned.
191
+ //
192
+ // Round-4 resolution: fire on any unquoted assignment that is
193
+ // NOT at the start of a line (`^`). The `.env credential
194
+ // assignment` MEDIUM pattern owns `^FOO=…`; this rule owns
195
+ // everything else (`ENV FOO=…`, `export FOO=…`, `; FOO=…`,
196
+ // template-string `${FOO=…}`, etc.). Implemented via a
197
+ // multi-line regex with a look-behind for "anything except a
198
+ // line start". JS regex doesn't support a direct "not at line
199
+ // start" assertion, so we require at least one non-newline char
200
+ // before `SUPABASE_SERVICE_ROLE_KEY` on the same line.
201
+ //
202
+ // The `(?!["'])` look-ahead refuses when the value starts with
203
+ // a quote so the same secret isn't double-reported by the HIGH
204
+ // pattern above. Result: each secret produces exactly one
205
+ // MEDIUM finding regardless of shape, and the HIGH rule keeps
206
+ // exclusive ownership of quoted forms.
207
+ regex: /(?<=[^\n\r])\bSUPABASE_SERVICE_ROLE_KEY\s*=\s*(?!["'])eyJ[A-Za-z0-9._-]{50,}/gm,
208
+ },
156
209
  {
157
210
  severity: 'MEDIUM',
158
211
  label: 'Supabase anon key in non-client context',
159
- regex: /SUPABASE_ANON_KEY\s*=\s*["']?eyJ[A-Za-z0-9._-]{50,}/g,
212
+ // 0.36.0 audit-trail (charter item 5 / 0.34.0 codex round-7 P2 #2,
213
+ // sibling fix): same parity restoration as the SUPABASE_SERVICE_ROLE_KEY
214
+ // pattern above — bash hook required a quote introducer, TS pattern
215
+ // had it optional via `?`. Removed for byte-parity. Unquoted .env
216
+ // forms continue to be advisory via the broader `.env credential
217
+ // assignment` MEDIUM pattern (where SUPABASE_ANON_KEY is NOT one of
218
+ // the named keys — anon-key prose in unquoted .env is acceptable
219
+ // since anon keys are public; only QUOTED-in-source matches stay
220
+ // an advisory MEDIUM here).
221
+ regex: /SUPABASE_ANON_KEY\s*=\s*["']eyJ[A-Za-z0-9._-]{50,}/g,
160
222
  },
161
223
  ];
162
224
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.35.0",
3
+ "version": "0.36.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)",
@@ -95,8 +95,9 @@
95
95
  "scripts": {
96
96
  "build": "tsc -p tsconfig.build.json",
97
97
  "postinstall": "node scripts/postinstall.mjs",
98
- "lint": "pnpm run lint:regex && eslint .",
98
+ "lint": "pnpm run lint:regex && pnpm run lint:awk-quotes && eslint .",
99
99
  "lint:regex": "node scripts/lint-safe-regex.mjs",
100
+ "lint:awk-quotes": "node scripts/lint-awk-shim-quotes.mjs",
100
101
  "format": "prettier --write .",
101
102
  "format:check": "prettier --check .",
102
103
  "test": "pnpm run build && pnpm run test:dogfood && pnpm run test:bash-syntax && node scripts/run-vitest.mjs",
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+ // G — Static lint for `awk '...'` blocks embedded in bash hooks.
3
+ //
4
+ // 0.36.0 charter item 3 / 0.34.0 round-4 + round-6 regression class.
5
+ //
6
+ // # The class
7
+ //
8
+ // Bash hooks frequently embed an awk script inside a bash-single-quoted
9
+ // argument:
10
+ //
11
+ // awk '
12
+ // # awk comment
13
+ // { print $1 }
14
+ // '
15
+ //
16
+ // Bash single-quoted strings have one rule: NO escape sequences inside.
17
+ // The string ends at the next unescaped `'`. If any character inside the
18
+ // awk body is a literal `'`, bash terminates the string THERE — the rest
19
+ // of the awk body is then re-parsed as bash, almost always producing a
20
+ // `syntax error near unexpected token` or worse, silently shelling out
21
+ // to whatever follows.
22
+ //
23
+ // The 0.34.0 marathon hit this twice — once at round-4, once at round-6.
24
+ // The round-6 instance locked the entire repo (every Bash refused at
25
+ // hook parse time because every hook sourced `_lib/cmd-segments.sh`,
26
+ // which crashed at parse). Repair required out-of-session `git apply`.
27
+ //
28
+ // # The lint
29
+ //
30
+ // For each `*.sh` under `hooks/` and `.claude/hooks/` (dogfood mirror),
31
+ // find every `awk '<NL>` block opening (the awk-with-multiline-body
32
+ // shape that the marathon class triggers in), scan inward until the
33
+ // matching unescaped `'`, and flag any line inside that:
34
+ //
35
+ // - Starts with optional whitespace then `#` (a comment line in awk),
36
+ // - Contains a literal `'`.
37
+ //
38
+ // We deliberately do NOT lint inline awk one-liners (`awk '{ print $1 }'`
39
+ // on one line) because those have no comment lines by construction —
40
+ // the bug class only manifests in multi-line awk bodies.
41
+ //
42
+ // # Wired into `pnpm lint`
43
+ //
44
+ // `package.json#scripts.lint` chains `lint:awk-quotes` before eslint, in
45
+ // the same posture as `lint:regex`. A failure here means a `'` ended up
46
+ // in an awk comment in a shipped hook body; the diff that introduced it
47
+ // would have parse-failed the hook at runtime (the way 0.34.0 round-6
48
+ // did). CI catches it before it ships.
49
+ //
50
+ // Mirrors-coverage rationale: `.claude/hooks/` is rea's own dogfood
51
+ // mirror. `tools/check-dogfood-drift.mjs` already enforces byte-equality
52
+ // between `hooks/*.sh` and `.claude/hooks/*.sh`, but this lint runs
53
+ // BEFORE that gate during a typical edit cycle, and a drifted mirror
54
+ // could still ship if the drift gate is bypassed. Lint both for
55
+ // defense-in-depth.
56
+
57
+ import { readdirSync, readFileSync, existsSync } from 'node:fs';
58
+ import { fileURLToPath } from 'node:url';
59
+ import path from 'node:path';
60
+
61
+ const here = path.dirname(fileURLToPath(import.meta.url));
62
+ const repoRoot = path.resolve(here, '..');
63
+
64
+ // 0.36.0 codex round-5 P2 #1: extended coverage. Originally the
65
+ // SCAN_DIRS list only covered `hooks/` and `.claude/hooks/` — but the
66
+ // package also ships awk-heavy shell scripts in `.husky/` (e.g.
67
+ // `prepare-commit-msg`) and `templates/` (e.g.
68
+ // `local-review-gate.dogfood-staged.sh`). A bare-apostrophe regression
69
+ // in those surfaces would have shipped silently. Adding them here
70
+ // pulls them under the same gate. Each path is checked for existence
71
+ // in `listShellFiles` so a profile that omits the directory still
72
+ // works.
73
+ const SCAN_DIRS = [
74
+ path.join(repoRoot, 'hooks'),
75
+ path.join(repoRoot, 'hooks', '_lib'),
76
+ path.join(repoRoot, '.claude', 'hooks'),
77
+ path.join(repoRoot, '.claude', 'hooks', '_lib'),
78
+ // 0.36.0 codex round-5 P2 #1 additions.
79
+ path.join(repoRoot, '.husky'),
80
+ path.join(repoRoot, 'templates'),
81
+ ];
82
+
83
+ /**
84
+ * List shell-script files directly under the given directory
85
+ * (non-recursive). A file qualifies if it ends in `.sh` OR its
86
+ * first line is a `#!/...sh`/`#!/...bash` shebang (for extensionless
87
+ * husky hooks like `.husky/pre-push`).
88
+ *
89
+ * Returns empty array if the directory doesn't exist.
90
+ *
91
+ * 0.36.0 codex round-5 P2 #1: pre-fix required `.sh` extension, which
92
+ * skipped every `.husky/` file (they're shipped extensionless).
93
+ */
94
+ function listShellFiles(dir) {
95
+ if (!existsSync(dir)) return [];
96
+ const entries = readdirSync(dir, { withFileTypes: true });
97
+ const out = [];
98
+ for (const e of entries) {
99
+ if (!e.isFile()) continue;
100
+ const full = path.join(dir, e.name);
101
+ // Codex round-7 P2: `.patch` files are unified diffs (hunk-prefixed
102
+ // lines, comments interleaved with `+`/`-`/` `), NOT raw shell. The
103
+ // scanFile function only understands shell syntax, so feeding a
104
+ // patch through it generates false-positives on benign comment-
105
+ // hunks like `+# this isn't related to awk`. Skip patches; the
106
+ // hook body the patch SHIPS TO will be linted directly once
107
+ // applied, which is the more reliable signal anyway.
108
+ if (e.name.endsWith('.sh')) {
109
+ out.push(full);
110
+ continue;
111
+ }
112
+ // Extensionless: check shebang.
113
+ try {
114
+ const head = readFileSync(full, 'utf8').slice(0, 64);
115
+ if (/^#!.*\b(sh|bash|zsh|dash|ksh)\b/.test(head)) {
116
+ out.push(full);
117
+ }
118
+ } catch {
119
+ // unreadable — skip silently
120
+ }
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Scan a single `.sh` file for `awk '` opening blocks (multi-line body
127
+ * shape: `awk '` at end of a line, OR `awk '` followed by newline). For
128
+ * each open block, walk lines until the closing unescaped `'` and flag
129
+ * any comment line containing a literal `'`.
130
+ *
131
+ * Returns an array of `{file, line, content, reason}` findings.
132
+ */
133
+ function scanFile(file) {
134
+ const text = readFileSync(file, 'utf8');
135
+ const lines = text.split('\n');
136
+ const findings = [];
137
+
138
+ let inAwkBlock = false;
139
+ let awkStartLine = -1;
140
+
141
+ for (let i = 0; i < lines.length; i += 1) {
142
+ const line = lines[i];
143
+
144
+ if (!inAwkBlock) {
145
+ // Detect block opening: any line containing the `awk` keyword
146
+ // that opens an awk-arg single-quote which DOESN'T close on
147
+ // the same line. Real-corpus shapes that must trigger:
148
+ //
149
+ // awk ' ← bare
150
+ // ... | awk ' ← piped
151
+ // ... | awk -v key=val ' ← -v vars
152
+ // foo=$(awk -v a="$x" -v b="$y" ' ← multi-var
153
+ // awk -F: ' ← field-sep
154
+ // awk -v msg="can't" ' ← -v with `'` in DQ-arg
155
+ // awk 'BEGIN { ... } ← body starts on opener
156
+ // ... | awk 'BEGIN { x = 1 ← body starts on opener
157
+ //
158
+ // And must NOT trigger on:
159
+ //
160
+ // # Example: awk '...' ← shell comment about awk
161
+ // awk '{print $1}' ← one-liner (no multi-line)
162
+ //
163
+ // Algorithm:
164
+ // 1. Skip shell-comment lines (leading `#`).
165
+ // 2. Require the `awk` keyword somewhere on the line.
166
+ // 3. Strip benign bash quote-escape sequences.
167
+ // 4. Count remaining `'`. An odd count means the line opens
168
+ // an awk-arg that doesn't close on this line (multi-line
169
+ // body). An even count means every open is paired with a
170
+ // close on this line (one-liner — no multi-line bug
171
+ // class).
172
+ //
173
+ // 0.36.0 codex round-3 P2 #1: pre-fix opener was
174
+ // `/\bawk\b/ && /'\s*$/` which flipped on any prose line
175
+ // mentioning awk that happened to end in `'` (e.g. a comment
176
+ // like `# Example: awk '`). Shell-comment skip closes that
177
+ // false-positive path.
178
+ //
179
+ // 0.36.0 codex round-3 P2 #2: pre-fix opener required `'` at
180
+ // EOL, missing the `awk 'BEGIN { ... }` shape where the body
181
+ // starts on the same line as the opener. Odd-quote-count
182
+ // detection handles both shapes uniformly.
183
+ // Skip shell-comment lines — they may mention `awk` in prose
184
+ // (e.g. `# Example: awk '...'`) without being a real awk call.
185
+ const codeOnly = line.replace(/^\s+/, '');
186
+ if (codeOnly.startsWith('#')) continue;
187
+ if (!/\bawk\b/.test(line)) continue;
188
+ // Strip in order:
189
+ // - bash double-quoted spans (`"..."`) — bash treats `'`
190
+ // inside them as literal, NOT as quote terminators. Without
191
+ // this strip, `awk -v msg="can't" '` would count 2 `'`s
192
+ // and look balanced when it's actually 1 unclosed open.
193
+ // - benign quote-escape sequences (`'\''`, `'"'"'`, `''`).
194
+ // Order matters: strip `"..."` first because the `'"'"'`
195
+ // escape contains a DQ pair that would be wrongly consumed by
196
+ // the DQ-strip if applied second.
197
+ // Codex round-7 P1 fix: the prior `"[^"]*"` strip was too naive —
198
+ // a valid shell line like `awk -v msg="foo \"can't\" bar" '` has
199
+ // backslash-escaped quotes inside the double-quoted span. `[^"]*`
200
+ // stops at the first `"` (which is `\"`), the next `"` opens a
201
+ // new span, etc. The apostrophe from `can't` is left behind and
202
+ // the linter false-balances the quote count. Fix: walk DQ spans
203
+ // with proper escape handling — treat `\\` and `\"` as escapes,
204
+ // ANY other char between `"`s is literal.
205
+ let sanitizedOpener = line
206
+ .replace(/'"'"'/g, '')
207
+ .replace(/'\\''/g, '')
208
+ .replace(/''/g, '');
209
+ // Replace each `"..."` (with backslash-escape awareness) with `""`.
210
+ sanitizedOpener = sanitizedOpener.replace(/"(?:[^"\\]|\\[\s\S])*"/g, '""');
211
+ const quoteCount = (sanitizedOpener.match(/'/g) ?? []).length;
212
+ // Odd → opens a multi-line body. Even (incl. 0 / 2) → no
213
+ // unclosed open on this line (one-liner or no quote at all).
214
+ if (quoteCount % 2 === 1) {
215
+ inAwkBlock = true;
216
+ awkStartLine = i + 1; // 1-indexed for human-readable errors
217
+ // 0.36.0 codex round-4 P2 #1: when the body starts on the
218
+ // SAME line as the opener (`awk 'BEGIN { print "can't"`),
219
+ // any apostrophe-in-word shape already on that opener line
220
+ // MUST be checked too. Pre-fix the opener-detect branch
221
+ // flipped state and immediately `continue`d, leaving the
222
+ // opener line's body content unscanned.
223
+ //
224
+ // Locate the OPENING `'` (the LAST `'` in the sanitized
225
+ // line — `awk` is typically the last token before the
226
+ // opening quote, so any earlier `'`s are inside upstream
227
+ // shell commands like `printf '%s'`). Then run the
228
+ // apostrophe-in-word check on the text AFTER it (the awk
229
+ // body content). Word-boundary detection scopes the lint
230
+ // to the high-confidence bug shape (same discriminator as
231
+ // the body-line check above).
232
+ const openerIdx = sanitizedOpener.lastIndexOf("'");
233
+ const bodyOnOpenerLine = sanitizedOpener.slice(openerIdx + 1);
234
+ const apostropheInWord = /\b[A-Za-z][A-Za-z]*'[A-Za-z]/g;
235
+ const om = bodyOnOpenerLine.match(apostropheInWord);
236
+ if (om !== null) {
237
+ const strippedOpener = line.replace(/^\s+/, '');
238
+ const kind = strippedOpener.startsWith('#') ? 'comment' : 'code';
239
+ findings.push({
240
+ file,
241
+ line: i + 1,
242
+ content: line,
243
+ reason:
244
+ `awk-body ${kind} content on the OPENER line ` +
245
+ `contains an apostrophe-in-word shape (${om[0]}). ` +
246
+ `Bash terminates the \`awk '...'\` single-quoted ` +
247
+ `argument at the embedded \`'\`, splicing the rest ` +
248
+ `of the body into bash context; the hook parse-fails ` +
249
+ `at runtime (0.34.0 round-4 + round-6 class). ` +
250
+ `Rewrite without the apostrophe (e.g. \`cannot\` for ` +
251
+ `\`can't\`) or escape as \`'\\''\`.`,
252
+ awkStartLine,
253
+ });
254
+ // Bail out of block-mode — the bare `'` already
255
+ // terminated bash quoting at runtime.
256
+ inAwkBlock = false;
257
+ awkStartLine = -1;
258
+ }
259
+ }
260
+ continue;
261
+ }
262
+
263
+ // Inside awk block. Three things can happen on this line:
264
+ // 1. The line contains a BARE `'` somewhere (in code OR
265
+ // comment) that isn't a close → finding.
266
+ // 2. The line is the canonical block close → leave the block.
267
+ // 3. Neither — keep walking.
268
+ //
269
+ // Bare-quote definition: a `'` that isn't part of a known-safe
270
+ // bash escape sequence for embedding a literal apostrophe inside
271
+ // a single-quoted string. The three benign forms are:
272
+ // - `'\''` (close-quote, backslash-escaped quote, reopen-quote)
273
+ // - `'"'"'` (close-quote, double-quoted quote, reopen-quote)
274
+ // - `''` (close + reopen, injects NO byte — used in rea
275
+ // hook comments to quote literal-byte sequences like
276
+ // `\\\''` without breaking bash parsing).
277
+ // All three are fine in awk-internal context: bash terminates the
278
+ // single-quoted argument, emits a literal `'` (or no byte for
279
+ // `''`), and resumes single-quoting.
280
+ //
281
+ // 0.36.0 codex round-2 P2 #1 fix: pre-fix the bare-quote check
282
+ // only ran on comment lines (`stripped.startsWith('#')`). A code
283
+ // line like `BEGIN { print "can't" }` or `/can't/` parse-fails
284
+ // the same way — bash sees the `'` in `can't` regardless of
285
+ // whether awk parses the surrounding chars as a comment, string,
286
+ // or regex. Lint now scans every line in the block.
287
+ //
288
+ // Close detection: the rea hook bodies always close an `awk '`
289
+ // block with a `'` followed by a redirect / pipe / end-of-line
290
+ // / closing paren on a line that is OTHERWISE empty of awk-body
291
+ // text. Concretely: leading whitespace, then `'`, then optional
292
+ // `|`/`>`/`)`/whitespace/EOL. We detect close BEFORE running the
293
+ // bare-quote check on that line so a canonical-close line
294
+ // (` '`) doesn't itself trip a finding.
295
+ const sanitized = line
296
+ .replace(/'"'"'/g, '')
297
+ .replace(/'\\''/g, '')
298
+ .replace(/''/g, '');
299
+
300
+ if (!sanitized.includes("'")) {
301
+ // No bare `'` after stripping benign forms — no close, no bug.
302
+ continue;
303
+ }
304
+
305
+ // Detect the 0.34.0 round-4 + round-6 bug class specifically:
306
+ // an apostrophe-in-word shape like `can't`, `isn't`, `doesn't`
307
+ // — a `'` flanked by ASCII word chars on at least one side.
308
+ // That's the exact shape that broke the marathon (it appears
309
+ // naturally in English prose and slips past code review). Other
310
+ // possible bare-`'` shapes (e.g. `'X` at line start, where X is
311
+ // ASCII content) are genuinely ambiguous from the lint's POV —
312
+ // they may be the canonical close `'` followed by a bash
313
+ // continuation, the close of a bash quoted string, etc. We
314
+ // deliberately scope the lint to the high-confidence,
315
+ // demonstrated-historical-bug shape rather than risk
316
+ // false-positives on bash-grammar surface area we cannot parse.
317
+ //
318
+ // 0.36.0 codex round-4 P2 #2 resolution: pre-fix tried to
319
+ // distinguish close from bug structurally (by what preceded or
320
+ // followed the `'`). Both attempts produced false-positives on
321
+ // valid close shapes (`' "$arg"`, `END { print x }'`,
322
+ // `' | tr ...`). Word-boundary detection is the simplest
323
+ // discriminator that catches the exact bug class without
324
+ // tripping on legitimate bash continuation.
325
+ const apostropheInWord = /\b[A-Za-z][A-Za-z]*'[A-Za-z]/g;
326
+ const m = sanitized.match(apostropheInWord);
327
+ if (m !== null) {
328
+ const strippedLine = line.replace(/^\s+/, '');
329
+ const kind = strippedLine.startsWith('#') ? 'comment' : 'code';
330
+ findings.push({
331
+ file,
332
+ line: i + 1,
333
+ content: line,
334
+ reason:
335
+ `awk-body ${kind} line contains an apostrophe-in-word ` +
336
+ `shape (${m[0]}). Bash terminates the \`awk '...'\` ` +
337
+ `single-quoted argument at the embedded \`'\`, splicing ` +
338
+ `the rest of the body into bash context; the hook ` +
339
+ `parse-fails at runtime (0.34.0 round-4 + round-6 class). ` +
340
+ `Rewrite without the apostrophe (e.g. \`cannot\` for ` +
341
+ `\`can't\`) or escape as \`'\\''\`.`,
342
+ awkStartLine,
343
+ });
344
+ // Bail out of block-mode — the bare `'` already terminated
345
+ // bash quoting at runtime, so further lines are bash-parsed,
346
+ // not awk-parsed.
347
+ inAwkBlock = false;
348
+ awkStartLine = -1;
349
+ continue;
350
+ }
351
+
352
+ // Any other `'` shape: assume it's a legitimate close `'`
353
+ // followed by bash continuation. Leave the block.
354
+ inAwkBlock = false;
355
+ awkStartLine = -1;
356
+ }
357
+
358
+ return findings;
359
+ }
360
+
361
+ const allFindings = [];
362
+ for (const dir of SCAN_DIRS) {
363
+ for (const file of listShellFiles(dir)) {
364
+ allFindings.push(...scanFile(file));
365
+ }
366
+ }
367
+
368
+ if (allFindings.length === 0) {
369
+ // Quiet success — matches the posture of lint:regex.
370
+ process.exit(0);
371
+ }
372
+
373
+ console.error(
374
+ '[lint:awk-quotes] FAIL — bare single-quote in awk comment line ' +
375
+ '(0.34.0 round-4 + round-6 regression class):\n',
376
+ );
377
+ for (const f of allFindings) {
378
+ const rel = path.relative(repoRoot, f.file);
379
+ console.error(` ${rel}:${f.line} (awk block opened at line ${f.awkStartLine})`);
380
+ console.error(` ${f.content.trim()}`);
381
+ console.error(` → ${f.reason}\n`);
382
+ }
383
+ console.error(
384
+ `[lint:awk-quotes] ${allFindings.length} finding(s) across ${SCAN_DIRS.length} scan path(s).`,
385
+ );
386
+ process.exit(1);