@@ -25,6 +25,19 @@ import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'
2525const WIN32 = process . platform === 'win32'
2626const logger = getDefaultLogger ( )
2727
28+ // Pull the numeric exit code out of a lib-spawn rejection. The rejection
29+ // carries `{ code }` — a number for a normal non-zero exit, a string (e.g.
30+ // 'ENOENT') for a spawn failure. Non-numeric → 1 (treat as a generic failure).
31+ function exitCodeOf ( e : unknown ) : number {
32+ if ( e && typeof e === 'object' && 'code' in e ) {
33+ const { code } = e as { code : unknown }
34+ if ( typeof code === 'number' ) {
35+ return code
36+ }
37+ }
38+ return 1
39+ }
40+
2841async function run (
2942 cmd : string ,
3043 args : string [ ] ,
@@ -38,37 +51,34 @@ async function run(
3851 shell : WIN32 ,
3952 stdio : 'inherit' ,
4053 } )
41- if ( result . code !== 0 && required ) {
42- logger . error ( `${ label || cmd } failed (exit ${ result . code } )` )
43- return result . code
44- }
45- if ( result . code !== 0 ) {
46- // Non-blocking: log warning and continue.
47- logger . warn ( `${ label || cmd } : exited ${ result . code } (non-blocking)` )
48- }
49- return 0
54+ return result . code ?? 0
5055 } catch ( e ) {
51- const msg = e instanceof Error ? e . message : String ( e )
52- if ( ! required ) {
53- logger . warn ( `${ label || cmd } : ${ msg } (non-blocking)` )
54- return 0
56+ // The lib `spawn` REJECTS on a non-zero exit (carrying `{ code }`), so a
57+ // "failing" command lands here, not the resolved branch. Surface the real
58+ // exit code instead of throwing — the caller decides what non-zero means.
59+ // Throwing here would abort the pipeline and skip later steps (notably
60+ // ai-lint-fix after `lint --fix` exits non-zero with AI-fixable errors).
61+ const code = exitCodeOf ( e )
62+ if ( required ) {
63+ logger . error ( `${ label || cmd } failed (exit ${ code } )` )
64+ } else {
65+ logger . warn ( `${ label || cmd } : exited ${ code } (non-blocking)` )
5566 }
56- throw e
67+ return code
5768 }
5869}
5970
6071async function main ( ) : Promise < void > {
61- // Step 1: Lint fix — delegates to scripts/fleet/lint.mts which runs both
62- // oxfmt and oxlint. Forward extra argv so `--all` / `--staged` /
63- // explicit file paths reach the lint runner unchanged.
64- const lintExit = await run (
65- 'pnpm' ,
66- [ 'run' , 'lint' , '--fix' , ...process . argv . slice ( 2 ) ] ,
67- { label : 'lint --fix' } ,
68- )
69- if ( lintExit ) {
70- process . exitCode = lintExit
71- }
72+ // Lint fix (oxfmt + oxlint via scripts/fleet/lint.mts). Forward extra argv so
73+ // `--all` / `--staged` / explicit file paths reach the runner unchanged.
74+ // NON-required: oxlint can't autofix custom socket/* JS-plugin rules, so a
75+ // non-zero exit is the EXPECTED case — those violations are what ai-lint-fix
76+ // (below) handles, and gating the pipeline here would skip it when it's needed
77+ // most. The real pass/fail is the verify run at the end of this function.
78+ await run ( 'pnpm' , [ 'run' , 'lint' , '--fix' , ...process . argv . slice ( 2 ) ] , {
79+ label : 'lint --fix' ,
80+ required : false ,
81+ } )
7282
7383 // Step 2: zizmor — fixes GitHub Actions workflow security issues.
7484 // Only runs if .github/ directory exists (some repos don't have workflows).
@@ -107,6 +117,15 @@ async function main(): Promise<void> {
107117 required : false ,
108118 } ,
109119 )
120+
121+ // Verify: re-run lint (no --fix) to set the real exit code. `fix` succeeds
122+ // only when nothing remains after the deterministic + AI passes; a lingering
123+ // violation (or a genuine lint crash) surfaces here as a non-zero exit.
124+ process . exitCode = await run (
125+ 'pnpm' ,
126+ [ 'run' , 'lint' , ...process . argv . slice ( 2 ) ] ,
127+ { label : 'lint (verify)' , required : false } ,
128+ )
110129}
111130
112131main ( ) . catch ( ( e : unknown ) => {
0 commit comments