@@ -8,37 +8,68 @@ import { fileURLToPath } from "node:url";
88// Exports used only inside their own module ("(used in module)") are live
99// enough and do not count. New dead exports must be deleted, not allowlisted:
1010// the allowlist covers entry points, cross-lane ownership, plugin surfaces
11- // loaded by path, and ts-prune parser false positives only.
11+ // loaded by path, and ts-prune parser false positives only. Allowlist entries
12+ // that match no current ts-prune flag are reported as stale warnings so dead
13+ // exemptions cannot linger after the code they cover is gone.
1214
1315const here = dirname ( fileURLToPath ( import . meta. url ) ) ;
1416const repoRoot = dirname ( here ) ;
1517const allowlistPath = join ( here , "dead-export-allowlist.txt" ) ;
1618const tsPruneBin = join ( repoRoot , "node_modules" , ".bin" , "ts-prune" ) ;
1719
18- type AllowRule =
19- | { readonly kind : "prefix" ; readonly prefix : string }
20- | { readonly kind : "exact" ; readonly file : string ; readonly name : string } ;
20+ export type AllowRule =
21+ | {
22+ readonly kind : "prefix" ;
23+ readonly prefix : string ;
24+ readonly source : string ;
25+ }
26+ | {
27+ readonly kind : "exact" ;
28+ readonly file : string ;
29+ readonly name : string ;
30+ readonly source : string ;
31+ } ;
32+
33+ export interface DeadExport {
34+ readonly file : string ;
35+ readonly name : string ;
36+ }
2137
22- function loadAllowlist ( ) : AllowRule [ ] {
38+ export interface GuardOutcome {
39+ readonly dead : number ;
40+ readonly violations : string [ ] ;
41+ readonly unused : string [ ] ;
42+ }
43+
44+ export function parseAllowlistText ( text : string ) : AllowRule [ ] {
2345 const rules : AllowRule [ ] = [ ] ;
24- for ( const raw of readFileSync ( allowlistPath , "utf8" ) . split ( "\n" ) ) {
46+ for ( const raw of text . split ( "\n" ) ) {
2547 const line = raw . trim ( ) ;
2648 if ( line === "" || line . startsWith ( "#" ) ) continue ;
2749 const exact = line . match ( / ^ ( .+ ?) : ( [ A - Z a - z _ $ ] [ \w $ ] * ) $ / ) ;
2850 if ( exact ) {
2951 const file = exact [ 1 ] ;
3052 const name = exact [ 2 ] ;
3153 if ( file !== undefined && name !== undefined ) {
32- rules . push ( { kind : "exact" , file : file . trim ( ) , name : name . trim ( ) } ) ;
54+ rules . push ( {
55+ kind : "exact" ,
56+ file : file . trim ( ) ,
57+ name : name . trim ( ) ,
58+ source : line ,
59+ } ) ;
3360 }
3461 } else {
35- rules . push ( { kind : "prefix" , prefix : line } ) ;
62+ rules . push ( { kind : "prefix" , prefix : line , source : line } ) ;
3663 }
3764 }
3865 return rules ;
3966}
4067
41- function isAllowlisted (
68+ export function loadAllowlist ( ) : AllowRule [ ] {
69+ return parseAllowlistText ( readFileSync ( allowlistPath , "utf8" ) ) ;
70+ }
71+
72+ export function isAllowlisted (
4273 rules : AllowRule [ ] ,
4374 file : string ,
4475 name : string ,
@@ -50,35 +81,72 @@ function isAllowlisted(
5081 ) ;
5182}
5283
84+ // Parses one ts-prune output line. Returns undefined for blank lines,
85+ // unparseable lines, and exports used only inside their own module.
86+ export function parseTsPruneLine ( raw : string ) : DeadExport | undefined {
87+ const match = raw . match ( / ^ ( .* ?) : \d + - ( \S + ?) ( \s + \( u s e d i n m o d u l e \) ) ? $ / ) ;
88+ if ( ! match ) return undefined ;
89+ if ( match [ 3 ] !== undefined ) return undefined ;
90+ const file = match [ 1 ] ;
91+ const name = match [ 2 ] ;
92+ if ( file === undefined || name === undefined ) return undefined ;
93+ return { file, name } ;
94+ }
95+
96+ export function evaluateGuard (
97+ rules : AllowRule [ ] ,
98+ tsPruneStdout : string ,
99+ ) : GuardOutcome {
100+ const used = new Set < number > ( ) ;
101+ const violations : string [ ] = [ ] ;
102+ let dead = 0 ;
103+ for ( const raw of tsPruneStdout . split ( "\n" ) ) {
104+ const parsed = parseTsPruneLine ( raw ) ;
105+ if ( parsed === undefined ) continue ;
106+ dead += 1 ;
107+ const index = rules . findIndex ( ( rule ) =>
108+ rule . kind === "prefix"
109+ ? parsed . file . startsWith ( rule . prefix )
110+ : rule . file === parsed . file && rule . name === parsed . name ,
111+ ) ;
112+ if ( index === - 1 ) {
113+ violations . push ( `${ parsed . file } : ${ parsed . name } ` ) ;
114+ } else {
115+ used . add ( index ) ;
116+ }
117+ }
118+ const unused = rules
119+ . filter ( ( _ , index ) => ! used . has ( index ) )
120+ . map ( ( r ) => r . source ) ;
121+ return { dead, violations, unused } ;
122+ }
123+
53124function main ( ) : void {
54125 const rules = loadAllowlist ( ) ;
55126 const pruned = spawnSync ( tsPruneBin , [ ] , { cwd : repoRoot , encoding : "utf8" } ) ;
56127 if ( pruned . status !== 0 ) {
57128 console . error ( `ts-prune failed:\n${ pruned . stderr || pruned . stdout } ` ) ;
58129 process . exit ( 1 ) ;
59130 }
60- const violations : string [ ] = [ ] ;
61- let dead = 0 ;
62- for ( const raw of String ( pruned . stdout ) . split ( "\n" ) ) {
63- const match = raw . match ( / ^ ( .* ?) : \d + - ( \S + ?) ( \s + \( u s e d i n m o d u l e \) ) ? $ / ) ;
64- if ( ! match ) continue ;
65- if ( match [ 3 ] !== undefined ) continue ;
66- const file = match [ 1 ] ;
67- const name = match [ 2 ] ;
68- if ( file === undefined || name === undefined ) continue ;
69- dead += 1 ;
70- if ( ! isAllowlisted ( rules , file , name ) ) violations . push ( `${ file } : ${ name } ` ) ;
71- }
131+ const outcome = evaluateGuard ( rules , String ( pruned . stdout ) ) ;
72132 console . log (
73- `dead-export guard: ${ dead } consumer-less exports, ${ dead - violations . length } allowlisted, ${ violations . length } violations` ,
133+ `dead-export guard: ${ outcome . dead } consumer-less exports, ${ outcome . dead - outcome . violations . length } allowlisted, ${ outcome . violations . length } violations` ,
74134 ) ;
75- if ( violations . length > 0 ) {
135+ if ( outcome . unused . length > 0 ) {
136+ console . error (
137+ "Stale allowlist entries matching no dead export (remove them):\n" +
138+ outcome . unused . map ( ( entry ) => ` ${ entry } ` ) . join ( "\n" ) ,
139+ ) ;
140+ }
141+ if ( outcome . violations . length > 0 ) {
76142 console . error (
77143 "New dead exports (delete them or justify an allowlist entry):\n" +
78- violations . map ( ( v ) => ` ${ v } ` ) . join ( "\n" ) ,
144+ outcome . violations . map ( ( v ) => ` ${ v } ` ) . join ( "\n" ) ,
79145 ) ;
80146 process . exit ( 1 ) ;
81147 }
82148}
83149
84- main ( ) ;
150+ if ( import . meta. main ) {
151+ main ( ) ;
152+ }
0 commit comments