1+ import { spawnSync } from "node:child_process" ;
2+ import {
3+ existsSync ,
4+ readdirSync ,
5+ readFileSync ,
6+ rmSync ,
7+ writeFileSync ,
8+ } from "node:fs" ;
9+ import { join , relative } from "node:path" ;
110import { describe , expect , test } from "bun:test" ;
211
312import {
13+ countScannedFiles ,
414 evaluateGuard ,
515 isAllowlisted ,
16+ isCoverageEnough ,
17+ isGuardPassing ,
618 loadAllowlist ,
19+ loadGuardConfig ,
720 parseAllowlistText ,
21+ parseGuardConfig ,
822 parseTsPruneLine ,
23+ validateAllowlistEntry ,
24+ validateAllowlistOwnership ,
25+ validateAllowlistText ,
926} from "./check-dead-exports.js" ;
1027
28+ const repoRoot = join ( import . meta. dir , ".." ) ;
29+
1130// A probe dead export in one of the scoped files must fail the guard: the
1231// exact-name exemptions cover only the five deferred-cleanup flags, never
1332// the whole module.
@@ -76,11 +95,11 @@ describe("scoped exemptions", () => {
7695 } ) ;
7796} ) ;
7897
79- // Allowlist entries that match no current ts-prune flag are stale: report
80- // them so the exemption is removed with the code it covered, without
81- // failing the gate on their own .
98+ // Allowlist entries that match no current ts-prune flag are stale: they fail
99+ // the gate so the exemption is removed with the code it covered. Warn-only
100+ // reporting let dead exemptions linger silently after the code was gone .
82101describe ( "stale allowlist entries" , ( ) => {
83- test ( "an entry matching nothing is reported as unused, not a violation " , ( ) => {
102+ test ( "an entry matching nothing is reported as unused and fails the gate " , ( ) => {
84103 const rules = parseAllowlistText (
85104 "src/auth/xai/usage.ts: fetchXaiUsage\n" +
86105 "src/gone.ts: vanishedExport\n" ,
@@ -91,9 +110,10 @@ describe("stale allowlist entries", () => {
91110 ) ;
92111 expect ( outcome . violations ) . toEqual ( [ ] ) ;
93112 expect ( outcome . unused ) . toEqual ( [ "src/gone.ts: vanishedExport" ] ) ;
113+ expect ( isGuardPassing ( outcome ) ) . toBe ( false ) ;
94114 } ) ;
95115
96- test ( "a fully fresh allowlist reports no unused entries " , ( ) => {
116+ test ( "a fully fresh allowlist passes the gate " , ( ) => {
97117 const rules = parseAllowlistText (
98118 "vendor/\nsrc/auth/xai/usage.ts: fetchXaiUsage\n" ,
99119 ) ;
@@ -104,6 +124,7 @@ describe("stale allowlist entries", () => {
104124 ) ;
105125 expect ( outcome . violations ) . toEqual ( [ ] ) ;
106126 expect ( outcome . unused ) . toEqual ( [ ] ) ;
127+ expect ( isGuardPassing ( outcome ) ) . toBe ( true ) ;
107128 } ) ;
108129
109130 test ( "a prefix entry counts as used when any flag falls under it" , ( ) => {
@@ -114,5 +135,280 @@ describe("stale allowlist entries", () => {
114135 ) ;
115136 expect ( outcome . violations ) . toEqual ( [ ] ) ;
116137 expect ( outcome . unused ) . toEqual ( [ ] ) ;
138+ expect ( isGuardPassing ( outcome ) ) . toBe ( true ) ;
139+ } ) ;
140+
141+ test ( "violations fail the gate" , ( ) => {
142+ const outcome = evaluateGuard ( [ ] , "src/new.ts:1 - freshDeadExport\n" ) ;
143+ expect ( outcome . violations ) . toEqual ( [ "src/new.ts: freshDeadExport" ] ) ;
144+ expect ( isGuardPassing ( outcome ) ) . toBe ( false ) ;
145+ } ) ;
146+ } ) ;
147+
148+ // Entry shapes the matcher would silently misinterpret must fail validation
149+ // instead: a mistyped exact entry must not decay into a prefix that matches
150+ // nothing, and a directory without its trailing slash must not pass as an
151+ // imprecise prefix.
152+ describe ( "allowlist entry shapes" , ( ) => {
153+ test ( "valid entries pass" , ( ) => {
154+ expect ( validateAllowlistEntry ( "vendor/" ) ) . toBeUndefined ( ) ;
155+ expect ( validateAllowlistEntry ( "src/auth/codex/usage.ts" ) ) . toBeUndefined ( ) ;
156+ expect (
157+ validateAllowlistEntry ( "src/auth/codex/usage.ts: fetchCodexUsage" ) ,
158+ ) . toBeUndefined ( ) ;
159+ expect (
160+ validateAllowlistEntry (
161+ "tests/fixtures/plugins/implement-feature/src/index.ts" ,
162+ ) ,
163+ ) . toBeUndefined ( ) ;
164+ } ) ;
165+
166+ test ( "slash-less directory prefixes fail" , ( ) => {
167+ expect ( validateAllowlistEntry ( "vendor" ) ) . toBeDefined ( ) ;
168+ expect ( validateAllowlistEntry ( "src/auth" ) ) . toBeDefined ( ) ;
169+ expect ( validateAllowlistEntry ( "/" ) ) . toBeDefined ( ) ;
170+ } ) ;
171+
172+ test ( "malformed exact entries fail instead of decaying into prefixes" , ( ) => {
173+ expect (
174+ validateAllowlistEntry ( "src/auth/codex/usage.ts: bad name!" ) ,
175+ ) . toBeDefined ( ) ;
176+ expect (
177+ validateAllowlistEntry ( "src/auth/codex/usage.ts: 123abc" ) ,
178+ ) . toBeDefined ( ) ;
179+ expect ( validateAllowlistEntry ( "src/auth/codex/usage.ts:" ) ) . toBeDefined ( ) ;
180+ expect ( validateAllowlistEntry ( "usage.ts: fetchCodexUsage" ) ) . toBeDefined ( ) ;
181+ } ) ;
182+
183+ test ( "entries with whitespace fail" , ( ) => {
184+ expect ( validateAllowlistEntry ( "src/has space/x.ts" ) ) . toBeDefined ( ) ;
185+ } ) ;
186+
187+ test ( "the checked-in allowlist passes shape validation" , ( ) => {
188+ const text = readFileSync (
189+ join ( repoRoot , "scripts" , "dead-export-allowlist.txt" ) ,
190+ "utf8" ,
191+ ) ;
192+ expect ( validateAllowlistText ( text ) ) . toEqual ( [ ] ) ;
193+ } ) ;
194+ } ) ;
195+
196+ // This repo has no CODEOWNERS, so the documented review convention is that
197+ // every entry block names its owning lane in the reason comment above it.
198+ // The gate enforces the reason comments; human review enforces the lane.
199+ describe ( "allowlist ownership" , ( ) => {
200+ test ( "an entry under a reason comment passes" , ( ) => {
201+ expect (
202+ validateAllowlistOwnership ( "# Owner: usage-data lane\nsrc/a.ts: Thing\n" ) ,
203+ ) . toEqual ( [ ] ) ;
204+ } ) ;
205+
206+ test ( "a reason block covers the contiguous entries below it" , ( ) => {
207+ expect (
208+ validateAllowlistOwnership (
209+ "# Owner: usage-data lane\nsrc/a.ts: Thing\nsrc/b.ts: Other\n" ,
210+ ) ,
211+ ) . toEqual ( [ ] ) ;
212+ } ) ;
213+
214+ test ( "an entry with no reason comment fails" , ( ) => {
215+ expect ( validateAllowlistOwnership ( "src/a.ts: Thing\n" ) ) . toEqual ( [
216+ "allowlist entry without a reason comment naming its owner: src/a.ts: Thing" ,
217+ ] ) ;
218+ } ) ;
219+
220+ test ( "a new section after a blank line needs its own reason" , ( ) => {
221+ expect (
222+ validateAllowlistOwnership (
223+ "# Owner: usage-data lane\nsrc/a.ts: Thing\n\nsrc/b.ts: Other\n" ,
224+ ) ,
225+ ) . toEqual ( [
226+ "allowlist entry without a reason comment naming its owner: src/b.ts: Other" ,
227+ ] ) ;
228+ } ) ;
229+
230+ test ( "a bare hash is not a reason" , ( ) => {
231+ expect ( validateAllowlistOwnership ( "#\nsrc/a.ts: Thing\n" ) ) . toEqual ( [
232+ "allowlist entry without a reason comment naming its owner: src/a.ts: Thing" ,
233+ ] ) ;
234+ } ) ;
235+
236+ test ( "the checked-in allowlist names an owner for every entry" , ( ) => {
237+ const text = readFileSync (
238+ join ( repoRoot , "scripts" , "dead-export-allowlist.txt" ) ,
239+ "utf8" ,
240+ ) ;
241+ expect ( validateAllowlistOwnership ( text ) ) . toEqual ( [ ] ) ;
242+ } ) ;
243+ } ) ;
244+
245+ // The scan invocation is pinned to scripts/dead-export-guard.json so it never
246+ // depends on ts-prune's working-directory config discovery, and the gate
247+ // fails closed when the scanned file count drops below the checked-in floor
248+ // instead of green-lighting a scan that looked at less code.
249+ describe ( "pinned scan invocation" , ( ) => {
250+ test ( "the checked-in config pins the project and a positive floor" , ( ) => {
251+ const config = loadGuardConfig ( ) ;
252+ expect ( config . tsconfig ) . toBe ( "tsconfig.json" ) ;
253+ expect ( config . tsPruneArgs ) . toEqual ( [ "-p" , "tsconfig.json" ] ) ;
254+ expect ( config . minScannedFiles ) . toBeGreaterThan ( 0 ) ;
255+ expect ( existsSync ( join ( repoRoot , config . tsconfig ) ) ) . toBe ( true ) ;
256+ } ) ;
257+
258+ test ( "parseGuardConfig rejects an unpinned or empty invocation" , ( ) => {
259+ const valid = {
260+ tsconfig : "tsconfig.json" ,
261+ tsPruneArgs : [ "-p" , "tsconfig.json" ] ,
262+ minScannedFiles : 1130 ,
263+ } ;
264+ expect ( parseGuardConfig ( valid ) ) . toEqual ( valid ) ;
265+ expect ( ( ) => parseGuardConfig ( { ...valid , tsPruneArgs : [ ] } ) ) . toThrow ( ) ;
266+ expect ( ( ) =>
267+ parseGuardConfig ( { ...valid , tsPruneArgs : [ "--ignore" , "x" ] } ) ,
268+ ) . toThrow ( ) ;
269+ expect ( ( ) =>
270+ parseGuardConfig ( {
271+ ...valid ,
272+ tsPruneArgs : [ "-p" , "tsconfig.other.json" ] ,
273+ } ) ,
274+ ) . toThrow ( ) ;
275+ } ) ;
276+
277+ test ( "parseGuardConfig rejects extra narrowing flags on a pinned invocation" , ( ) => {
278+ const valid = {
279+ tsconfig : "tsconfig.json" ,
280+ tsPruneArgs : [ "-p" , "tsconfig.json" ] ,
281+ minScannedFiles : 1130 ,
282+ } ;
283+ expect ( parseGuardConfig ( valid ) ) . toEqual ( valid ) ;
284+ const narrowed = [
285+ [ "-p" , "tsconfig.json" , "-i" , "src/.*" ] ,
286+ [ "-p" , "tsconfig.json" , "--ignore" , "src/.*" ] ,
287+ [ "-p" , "tsconfig.json" , "--error" ] ,
288+ [ "--ignore" , "src/.*" , "-p" , "tsconfig.json" ] ,
289+ ] ;
290+ for ( const tsPruneArgs of narrowed ) {
291+ expect ( ( ) => parseGuardConfig ( { ...valid , tsPruneArgs } ) ) . toThrow ( ) ;
292+ }
293+ } ) ;
294+
295+ test ( "parseGuardConfig rejects a missing floor" , ( ) => {
296+ const valid = {
297+ tsconfig : "tsconfig.json" ,
298+ tsPruneArgs : [ "-p" , "tsconfig.json" ] ,
299+ minScannedFiles : 1130 ,
300+ } ;
301+ for ( const floor of [ 0 , - 5 , 1.5 , "1130" , undefined ] ) {
302+ expect ( ( ) =>
303+ parseGuardConfig ( { ...valid , minScannedFiles : floor } ) ,
304+ ) . toThrow ( ) ;
305+ }
306+ expect ( ( ) => parseGuardConfig ( null ) ) . toThrow ( ) ;
307+ expect ( ( ) => parseGuardConfig ( [ ] ) ) . toThrow ( ) ;
308+ } ) ;
309+ } ) ;
310+
311+ describe ( "scan coverage floor" , ( ) => {
312+ test ( "counts below the floor fail, counts at or above pass" , ( ) => {
313+ expect ( isCoverageEnough ( 1129 , 1130 ) ) . toBe ( false ) ;
314+ expect ( isCoverageEnough ( 1130 , 1130 ) ) . toBe ( true ) ;
315+ expect ( isCoverageEnough ( 2000 , 1130 ) ) . toBe ( true ) ;
316+ } ) ;
317+
318+ test ( "the live program file count clears the checked-in floor" , ( ) => {
319+ const config = loadGuardConfig ( ) ;
320+ const scanned = countScannedFiles ( repoRoot , config . tsconfig ) ;
321+ expect ( scanned ) . toBeGreaterThanOrEqual ( config . minScannedFiles ) ;
322+ } , 120_000 ) ;
323+
324+ test ( "the checked-in floor stays tight to the live count" , ( ) => {
325+ const config = loadGuardConfig ( ) ;
326+ const scanned = countScannedFiles ( repoRoot , config . tsconfig ) ;
327+ expect ( scanned ) . toBeLessThan ( config . minScannedFiles * 1.1 ) ;
328+ } , 120_000 ) ;
329+ } ) ;
330+
331+ // The unit tests above prove the rule engine flags a probe; this one proves
332+ // the wired-up gate does. A temp probe export lands in the tsconfig-covered
333+ // scripts/ tree, the real guard runs as a subprocess, and the run must exit
334+ // nonzero naming the probe. The probe lives only for the test (never
335+ // committed) so the keep-alive check cannot itself become a dead export.
336+ describe ( "violation end to end" , ( ) => {
337+ test ( "a temp dead export fails the guard, which names it" , ( ) => {
338+ const probeFile = `dead-export-guard-probe-${ process . pid } .ts` ;
339+ const probeName = `deadExportGuardProbe${ process . pid } ` ;
340+ const probePath = join ( repoRoot , "scripts" , probeFile ) ;
341+ writeFileSync ( probePath , `export const ${ probeName } = 1;\n` ) ;
342+ try {
343+ const ran = spawnSync (
344+ process . execPath ,
345+ [ "scripts/check-dead-exports.ts" ] ,
346+ { cwd : repoRoot , encoding : "utf8" } ,
347+ ) ;
348+ expect ( ran . status ) . toBe ( 1 ) ;
349+ expect ( `${ ran . stdout } \n${ ran . stderr } ` ) . toContain (
350+ `${ probeFile } : ${ probeName } ` ,
351+ ) ;
352+ } finally {
353+ rmSync ( probePath , { force : true } ) ;
354+ }
355+ } , 120_000 ) ;
356+ } ) ;
357+
358+ // The purge deleted four fully-dead barrel files; a re-created barrel (or a
359+ // new import of its path) would silently resurrect the surface the guard was
360+ // built to shrink. Pin the paths, not the file text.
361+ describe ( "deleted barrels stay deleted" , ( ) => {
362+ const barrels = [
363+ "src/agent/directors/index.ts" ,
364+ "src/auth/codex/index.ts" ,
365+ "src/auth/xai/index.ts" ,
366+ "src/web/index.ts" ,
367+ ] ;
368+ const barrelDirs = barrels . map ( ( barrel ) =>
369+ barrel . slice ( 0 , - "/index.ts" . length ) ,
370+ ) ;
371+ const barrelTails = [
372+ "agent/directors/index" ,
373+ "auth/codex/index" ,
374+ "auth/xai/index" ,
375+ "web/index" ,
376+ ] ;
377+ const roots = [ "src" , "tests" , "evals" , "scripts" , "packages" ] ;
378+
379+ test ( "the barrel files do not exist" , ( ) => {
380+ for ( const barrel of barrels ) {
381+ expect ( existsSync ( join ( repoRoot , barrel ) ) ) . toBe ( false ) ;
382+ }
383+ } ) ;
384+
385+ test ( "no source file imports the deleted barrel paths" , ( ) => {
386+ const offenders : string [ ] = [ ] ;
387+ const walk = ( dir : string ) : void => {
388+ for ( const entry of readdirSync ( dir , { withFileTypes : true } ) ) {
389+ const absolute = join ( dir , entry . name ) ;
390+ if ( entry . isDirectory ( ) ) {
391+ walk ( absolute ) ;
392+ continue ;
393+ }
394+ if ( ! entry . name . endsWith ( ".ts" ) ) continue ;
395+ const rel = relative ( repoRoot , absolute ) . split ( "/" ) . join ( "/" ) ;
396+ const dirRel = rel . slice ( 0 , - entry . name . length - 1 ) ;
397+ const text = readFileSync ( absolute , "utf8" ) ;
398+ const specifiers = [
399+ ...text . matchAll ( / (?: f r o m \s * [ " ' ] | i m p o r t \s * \( \s * [ " ' ] ) ( [ ^ " ' ] + ) [ " ' ] / g) ,
400+ ] . map ( ( match ) => ( match [ 1 ] ?? "" ) . replace ( / \. (?: j s | t s ) $ / , "" ) ) ;
401+ for ( const spec of specifiers ) {
402+ const hitsBarrel =
403+ barrelTails . some (
404+ ( tail ) => spec === tail || spec . endsWith ( `/${ tail } ` ) ,
405+ ) ||
406+ ( spec === "./index" && barrelDirs . includes ( dirRel ) ) ;
407+ if ( hitsBarrel ) offenders . push ( `${ rel } : ${ spec } ` ) ;
408+ }
409+ }
410+ } ;
411+ for ( const root of roots ) walk ( join ( repoRoot , root ) ) ;
412+ expect ( offenders ) . toEqual ( [ ] ) ;
117413 } ) ;
118414} ) ;
0 commit comments