1+ /* eslint-disable no-shadow -- nested cached-length for-loops intentionally reuse `i`/`length` names for the fleet-wide cached-loop idiom; renaming would diverge from the codebase pattern. */
12/**
2- * @file Canonical minimal test runner for socket-* repos. Scope modes:
3- * (default) Run tests covering files modified in the working tree vs HEAD.
4- * --staged Run tests covering files in the git index (pre-commit hook). --all
5- * Run the full test suite. Flags: --quiet Suppress progress output.
6- * Scope-to-tests mapping (adapt per repo layout):
3+ * @file Canonical minimal test runner for socket-* repos. Delegates the
4+ * scope-to-tests mapping to vitest itself rather than rolling a basename-
5+ * based mapper that would inevitably drift from the actual module graph.
76 *
8- * - Changed test files run themselves.
9- * - Changed source files under `packages/<pkg>/src/` run the sibling
10- * `packages/<pkg>/test/` folder. Non-workspace repos can adapt the
11- * resolveTestPatterns() function to their layout (e.g. single src/ + test/
12- * at root, or tests colocated with source).
13- * - Config / infrastructure changes escalate to the full suite. This is the
14- * minimal zero-dependency reference implementation. Larger repos
15- * (socket-registry, socket-sdk-js, socket-packageurl-js, etc.) use a richer
16- * version; this one keeps the same CLI contract so pre-commit hooks and CI
17- * work identically across repos.
7+ * Scope modes:
8+ *
9+ * - `(default)` — local-dev scope. Runs `vitest --changed`, vitest's
10+ * compare-vs-HEAD-with-uncommitted mode. Walks the actual import graph
11+ * so a change to a util shared by many tests runs every affected test
12+ * file, not the union of two guesses.
13+ * - `--staged` — pre-commit hook scope. Hands `git diff --cached` filenames
14+ * to `vitest related <files…> --run`. Same module-graph walk, but rooted
15+ * at the staged delta. The `--run` flag is mandatory: `vitest related`
16+ * defaults to watch mode just like the bare `vitest` invocation, which
17+ * would hang the pre-commit hook.
18+ * - `--all` — run the full suite (`vitest run`). Used in CI and on explicit
19+ * opt-in.
20+ *
21+ * Flags: `--quiet` / `--silent` suppress progress output.
22+ *
23+ * Config / infrastructure changes (`vitest.config*`, `tsconfig*`,
24+ * `.oxlintrc.json`, `.oxfmtrc.json`, `pnpm-lock.yaml`, `package.json`,
25+ * anything under `.config/` or `scripts/`) still escalate to `all` —
26+ * module-graph traversal doesn't capture config-derived discovery + alias
27+ * changes. See https://vitest.dev/guide/cli.html#vitest-related.
1828 */
1929
30+ // prefer-async-spawn: sync-required — top-level CLI runner; entire
31+ // flow is sync (test runner invocation + exit-code aggregation).
2032import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
2133import type { SpawnSyncOptions } from 'node:child_process'
22- import { existsSync } from 'node:fs'
2334import process from 'node:process'
2435import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
2536
@@ -33,6 +44,10 @@ const mode: 'staged' | 'all' | 'modified' = args.includes('--all')
3344 : 'modified'
3445const quiet = args . includes ( '--quiet' ) || args . includes ( '--silent' )
3546const stdio : SpawnSyncOptions [ 'stdio' ] = quiet ? 'pipe' : 'inherit'
47+ // On Windows, `pnpm` is a .cmd shim that Node refuses to exec directly via
48+ // spawnSync (CVE-2024-27980 hardening). Wrap through the shell on Windows
49+ // only; POSIX keeps direct invocation.
50+ const useShell = process . platform === 'win32'
3651
3752// Paths that, when changed, force the full suite to run.
3853const ESCALATION_PATTERNS = [
@@ -47,20 +62,16 @@ const ESCALATION_PATTERNS = [
4762 / ^ l o c k s t e p \. s c h e m a \. j s o n $ / ,
4863]
4964
50- export function getModifiedFiles ( ) : string [ ] {
51- return gitFiles ( [ 'diff' , '--name-only' , '--diff-filter=ACMR' , 'HEAD' ] )
52- }
53-
54- export function getStagedFiles ( ) : string [ ] {
55- return gitFiles ( [ 'diff' , '--cached' , '--name-only' , '--diff-filter=ACMR' ] )
65+ function log ( msg : string ) : void {
66+ if ( ! quiet ) {
67+ logger . log ( msg )
68+ }
5669}
5770
58- // spawnSync with array args — no shell interpolation. Matches the
59- // socket/prefer-spawn-over-execsync rule: a shell-string execSync makes
60- // every interpolated value an injection vector; the array form can't
61- // shell-expand its args.
62- export function gitFiles ( gitArgs : string [ ] ) : string [ ] {
63- const r = spawnSync ( 'git' , gitArgs , {
71+ function gitFiles ( args : string [ ] ) : string [ ] {
72+ // spawnSync with array args — no shell interpolation. Matches the
73+ // socket/prefer-spawn-over-execsync rule contract.
74+ const r = spawnSync ( 'git' , args , {
6475 stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
6576 stdioString : true ,
6677 } )
@@ -73,95 +84,63 @@ export function gitFiles(gitArgs: string[]): string[] {
7384 . filter ( s => s . length > 0 )
7485}
7586
76- export function log ( msg : string ) : void {
77- if ( ! quiet ) {
78- logger . log ( msg )
79- }
87+ function getStagedFiles ( ) : string [ ] {
88+ return gitFiles ( [ 'diff' , '--cached' , '--name-only' , '--diff-filter=ACMR' ] )
8089}
8190
82- /**
83- * Map changed files to vitest test patterns.
84- *
85- * Default implementation handles two common layouts:
86- *
87- * - Pnpm workspace: packages/<pkg>/src/... → packages/<pkg>/test
88- * - Single repo: src/... → test Adapt to your repo's layout if different.
89- */
90- export function resolveTestPatterns ( files : string [ ] ) : string [ ] {
91- const patterns = new Set < string > ( )
92- for ( let i = 0 , { length } = files ; i < length ; i += 1 ) {
93- const f = files [ i ]
94- // Test file itself.
95- if ( / \. t e s t \. ( m ? [ j t ] s ) $ / . test ( f ) ) {
96- patterns . add ( f )
97- continue
98- }
99- // Workspace source file. Only emit the pattern if the test dir exists;
100- // packages without a test/ directory are skipped rather than making
101- // vitest error on an unknown pattern.
102- const wsMatch = f . match ( / ^ ( p a c k a g e s \/ [ ^ / ] + ) \/ s r c \/ / )
103- if ( wsMatch && existsSync ( `${ wsMatch [ 1 ] } /test` ) ) {
104- patterns . add ( `${ wsMatch [ 1 ] } /test` )
105- continue
106- }
107- // Single-repo source file.
108- if ( f . startsWith ( 'src/' ) && existsSync ( 'test' ) ) {
109- patterns . add ( 'test' )
110- }
111- }
112- return [ ...patterns ]
91+ function getModifiedFiles ( ) : string [ ] {
92+ return gitFiles ( [ 'diff' , '--name-only' , '--diff-filter=ACMR' , 'HEAD' ] )
11393}
11494
115- export function runAll ( ) : number {
116- log ( 'Test scope: all' )
117- const r = spawnSync ( 'pnpm' , [ 'exec' , 'vitest' , 'run' ] , { stdio } )
118- if ( r . status === 0 ) {
119- log ( 'All tests passed' )
120- return 0
95+ function shouldEscalate ( files : string [ ] ) : boolean {
96+ for ( let i = 0 , { length } = files ; i < length ; i += 1 ) {
97+ const f = files [ i ] !
98+ for ( let i = 0 , { length } = ESCALATION_PATTERNS ; i < length ; i += 1 ) {
99+ const pattern = ESCALATION_PATTERNS [ i ] !
100+ if ( pattern . test ( f ) ) {
101+ return true
102+ }
103+ }
121104 }
122- log ( 'Tests failed' )
123- return 1
105+ return false
124106}
125107
126- export function runPatterns ( patterns : string [ ] ) : number {
127- if ( patterns . length === 0 ) {
128- log ( 'No tests to run; skipping.' )
129- return 0
130- }
131- log ( `Test scope: ${ mode } (${ patterns . length } pattern(s))` )
132- // --passWithNoTests: if a pattern produces zero matches (e.g. a freshly
133- // added package with an empty test dir, or a source change that doesn't
134- // touch any testable code), vitest treats it as success rather than a
135- // "no test files found" error. Scoped-by-default runs shouldn't fail
136- // just because the change didn't happen to touch a testable file.
108+ function runVitest ( vitestArgs : string [ ] , label : string ) : number {
109+ log ( `Test scope: ${ label } ` )
137110 const r = spawnSync (
138111 'pnpm' ,
139- [ 'exec' , 'vitest' , 'run' , '--passWithNoTests' , ...patterns ] ,
140- { stdio } ,
112+ [ 'exec' , 'vitest' , ...vitestArgs , '--config' , '.config/vitest.config.mts' ] ,
113+ // Windows shell-shim rationale: see useShell at file top.
114+ { shell : useShell , stdio } ,
141115 )
142- if ( r . status = == 0 ) {
143- log ( 'All tests passed ' )
144- return 0
116+ if ( r . status ! == 0 ) {
117+ log ( 'Tests failed ' )
118+ return 1
145119 }
146- log ( 'Tests failed ' )
147- return 1
120+ log ( 'All tests passed ' )
121+ return 0
148122}
149123
150- export function shouldEscalate ( files : string [ ] ) : boolean {
151- for ( let i = 0 , { length } = files ; i < length ; i += 1 ) {
152- const f = files [ i ]
153- for (
154- let j = 0 , { length : patternsLength } = ESCALATION_PATTERNS ;
155- j < patternsLength ;
156- j += 1
157- ) {
158- const pattern = ESCALATION_PATTERNS [ j ]
159- if ( pattern . test ( f ) ) {
160- return true
161- }
162- }
163- }
164- return false
124+ function runAll ( ) : number {
125+ return runVitest ( [ 'run' ] , 'all' )
126+ }
127+
128+ // --passWithNoTests: a scoped run where the changed files don't resolve
129+ // to any test file should succeed rather than error with "No test files
130+ // found". Keeps pre-commit hooks passing when an edit touches only
131+ // non-testable code.
132+ function runChanged ( ) : number {
133+ return runVitest ( [ 'run' , '--changed' , '--passWithNoTests' ] , 'changed' )
134+ }
135+
136+ function runRelated ( files : string [ ] ) : number {
137+ // `vitest related <files…>` defaults to watch mode; `--run` forces a
138+ // single non-watch execution. Pass the staged file list as positionals;
139+ // vitest walks the module graph from each.
140+ return runVitest (
141+ [ 'related' , ...files , '--run' , '--passWithNoTests' ] ,
142+ `staged (${ files . length } file(s))` ,
143+ )
165144}
166145
167146function main ( ) : void {
@@ -183,8 +162,14 @@ function main(): void {
183162 return
184163 }
185164
186- const patterns = resolveTestPatterns ( files )
187- process . exitCode = runPatterns ( patterns )
165+ if ( mode === 'staged' ) {
166+ process . exitCode = runRelated ( files )
167+ return
168+ }
169+
170+ // Working-tree changed → vitest's native --changed (it re-detects the
171+ // file list via git itself, including uncommitted edits).
172+ process . exitCode = runChanged ( )
188173}
189174
190175main ( )
0 commit comments