@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.
package/dist/cli/doctor.js
CHANGED
|
@@ -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.
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
// hard-`fail`
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
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
|
-
|
|
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:
|
|
1136
|
-
detail: `missing: ${settingsPath} — run \`rea upgrade\` or \`rea init
|
|
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:
|
|
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:
|
|
1163
|
+
status: REFUSE,
|
|
1156
1164
|
detail: `no PostToolUse group with matcher "${MATCHER}" found in .claude/settings.json — ` +
|
|
1157
|
-
'run `rea upgrade` to install
|
|
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:
|
|
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
|
-
//
|
|
1171
|
-
// hook file it points at actually exists AND is
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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.
|
|
1473
|
-
//
|
|
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
|
|
559
|
-
//
|
|
560
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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);
|