@@ -67,49 +67,163 @@ function readText(url: URL): string {
6767 return readFileSync ( url , 'utf-8' ) ;
6868}
6969
70- function hasDependabotIgnoreEntry ( source : string , dependencyName : string ) : boolean {
71- let updateIndentation = - 1 ;
72- let ignoreIndentation = - 1 ;
70+ type DependabotIgnoreState = {
71+ ignoreIndentation : number ;
72+ updateIndentation : number ;
73+ } ;
74+
75+ type YamlLine = {
76+ indentation : number ;
77+ text : string ;
78+ } ;
79+
80+ type PackageManifest = {
81+ dependencies ?: Record < string , unknown > ;
82+ engines ?: Record < string , unknown > ;
83+ version ?: unknown ;
84+ } ;
85+
86+ function toYamlLine ( rawLine : string ) : YamlLine {
87+ return {
88+ indentation : rawLine . length - rawLine . trimStart ( ) . length ,
89+ text : rawLine . replace ( / \s + # .* $ / u, '' ) . trim ( ) ,
90+ } ;
91+ }
92+
93+ function isDependabotUpdate ( line : YamlLine ) : boolean {
94+ return / ^ - \s + p a c k a g e - e c o s y s t e m \s * : / u. test ( line . text ) ;
95+ }
96+
97+ function isNextUpdateEntry ( state : DependabotIgnoreState , line : YamlLine ) : boolean {
98+ return state . updateIndentation !== - 1
99+ && line . indentation <= state . updateIndentation
100+ && / ^ - \s + / u. test ( line . text ) ;
101+ }
102+
103+ function startsIgnoreBlock ( state : DependabotIgnoreState , line : YamlLine ) : boolean {
104+ return state . updateIndentation !== - 1
105+ && line . indentation > state . updateIndentation
106+ && / ^ i g n o r e \s * : \s * $ / u. test ( line . text ) ;
107+ }
108+
109+ function endsIgnoreBlock ( state : DependabotIgnoreState , line : YamlLine ) : boolean {
110+ return state . ignoreIndentation !== - 1 && line . indentation <= state . ignoreIndentation ;
111+ }
112+
113+ function nextDependabotIgnoreState ( state : DependabotIgnoreState , line : YamlLine ) : DependabotIgnoreState {
114+ if ( line . text === '' ) return state ;
115+ if ( isDependabotUpdate ( line ) ) return { updateIndentation : line . indentation , ignoreIndentation : - 1 } ;
116+ if ( isNextUpdateEntry ( state , line ) ) return { updateIndentation : - 1 , ignoreIndentation : - 1 } ;
117+ if ( startsIgnoreBlock ( state , line ) ) return { ...state , ignoreIndentation : line . indentation } ;
118+ if ( endsIgnoreBlock ( state , line ) ) return { ...state , ignoreIndentation : - 1 } ;
119+ return state ;
120+ }
121+
122+ function unquoteYamlScalar ( value : string ) : string {
123+ const trimmed = value . trim ( ) ;
124+ const quote = trimmed [ 0 ] ;
125+ return quote === '"' || quote === "'" ? trimmed . slice ( 1 , - 1 ) : trimmed ;
126+ }
127+
128+ function dependencyNameFromIgnoreItem ( line : YamlLine ) : string | undefined {
129+ const match = line . text . match ( / ^ - \s * d e p e n d e n c y - n a m e \s * : \s * ( .+ ?) \s * $ / u) ;
130+ return match === null ? undefined : unquoteYamlScalar ( match [ 1 ] ) ;
131+ }
132+
133+ function isIgnoredDependency ( state : DependabotIgnoreState , line : YamlLine , dependencyName : string ) : boolean {
134+ return state . ignoreIndentation !== - 1
135+ && line . indentation > state . ignoreIndentation
136+ && dependencyNameFromIgnoreItem ( line ) === dependencyName ;
137+ }
73138
139+ function hasDependabotIgnoreEntry ( source : string , dependencyName : string ) : boolean {
140+ let state : DependabotIgnoreState = { updateIndentation : - 1 , ignoreIndentation : - 1 } ;
74141 for ( const rawLine of source . split ( / \r ? \n / u) ) {
75- const lineWithoutComment = rawLine . replace ( / \s + # .* $ / u, '' ) ;
76- const line = lineWithoutComment . trim ( ) ;
77- if ( line === '' ) continue ;
78-
79- const indentation = rawLine . length - rawLine . trimStart ( ) . length ;
80- if ( / ^ - \s + p a c k a g e - e c o s y s t e m \s * : / u. test ( line ) ) {
81- updateIndentation = indentation ;
82- ignoreIndentation = - 1 ;
83- continue ;
84- }
85- if ( updateIndentation === - 1 ) continue ;
86- if ( indentation <= updateIndentation && / ^ - \s + / u. test ( line ) ) {
87- updateIndentation = - 1 ;
88- ignoreIndentation = - 1 ;
89- continue ;
90- }
91- if ( ignoreIndentation !== - 1 && indentation <= ignoreIndentation ) {
92- ignoreIndentation = - 1 ;
93- }
94- if ( indentation > updateIndentation && / ^ i g n o r e \s * : \s * $ / u. test ( line ) ) {
95- ignoreIndentation = indentation ;
96- continue ;
97- }
98- if ( ignoreIndentation === - 1 || indentation <= ignoreIndentation ) continue ;
99-
100- const match = line . match ( / ^ - \s * d e p e n d e n c y - n a m e \s * : \s * ( .+ ?) \s * $ / u) ;
101- if ( match === null ) continue ;
102- const value = match [ 1 ] . trim ( ) ;
103- const unquotedValue = ( value . startsWith ( '"' ) && value . endsWith ( '"' ) )
104- || ( value . startsWith ( "'" ) && value . endsWith ( "'" ) )
105- ? value . slice ( 1 , - 1 )
106- : value ;
107- if ( unquotedValue === dependencyName ) return true ;
142+ const line = toYamlLine ( rawLine ) ;
143+ state = nextDependabotIgnoreState ( state , line ) ;
144+ if ( isIgnoredDependency ( state , line , dependencyName ) ) return true ;
108145 }
109-
110146 return false ;
111147}
112148
149+ function assertPackageMetadata ( ) : void {
150+ const packageManifest = JSON . parse ( readText ( new URL ( '../../package.json' , import . meta. url ) ) ) as PackageManifest ;
151+ assert . equal ( packageManifest . version , '1.16.0' ) ;
152+ assert . equal ( packageManifest . engines ?. node , '>=20' ) ;
153+ assert . equal ( packageManifest . dependencies ?. [ 'express-rate-limit' ] , '^8.5.2' ) ;
154+ }
155+
156+ function assertReadmeNodePolicy ( readme : string ) : void {
157+ assert . ok ( readme . includes ( 'Node.js 20 remains supported but is deprecated and end-of-life.' ) ) ;
158+ assert . ok ( readme . includes ( 'Node.js 22 or later is recommended.' ) ) ;
159+ assert . ok ( readme . includes ( 'Node.js 20 will be removed only in a future major release.' ) ) ;
160+ }
161+
162+ function assertCiMatrix ( ci : string ) : void {
163+ const matrix = ci . match ( / ^ \s * n o d e - v e r s i o n : \s * \[ ( [ ^ \] ] + ) \] \s * $ / mu) ;
164+ assert . ok ( matrix , 'CI must declare an inline Node version matrix' ) ;
165+ assert . deepEqual (
166+ [ ...matrix [ 1 ] . matchAll ( / [ ' " ] ( [ ^ ' " ] + ) [ ' " ] / gu) ] . map ( ( match ) => match [ 1 ] ) ,
167+ [ '20' , '22' , '24' , '26.7.0' ] ,
168+ ) ;
169+ }
170+
171+ function assertCiCommonJob ( ci : string ) : void {
172+ const jobs = ci . slice ( ci . indexOf ( 'jobs:' ) ) ;
173+ assert . deepEqual ( ( jobs . match ( / ^ [ A - Z a - z 0 - 9 _ - ] + : \s * $ / gmu) ?? [ ] ) . map ( ( line ) => line . trim ( ) ) , [ 'test:' ] ) ;
174+ assert . match ( ci , / u s e s : \s * a c t i o n s \/ c h e c k o u t @ / u) ;
175+ assert . match ( ci , / u s e s : \s * a c t i o n s \/ s e t u p - n o d e @ / u) ;
176+ assert . match ( ci , / c a c h e : \s * [ ' " ] n p m [ ' " ] / u) ;
177+ for ( const command of [ / r u n : \s * n p m c i / u, / r u n : \s * n p m r u n l i n t / u, / r u n : \s * n p m r u n b u i l d / u, / r u n : \s * n p m r u n t e s t : c o v e r a g e / u] ) {
178+ assert . match ( ci , command ) ;
179+ }
180+ assert . doesNotMatch ( ci , / ^ \s * i n c l u d e \s * : / mu) ;
181+ assert . doesNotMatch ( ci , / ^ \s * i f \s * : / mu) ;
182+ assert . doesNotMatch ( ci , / c o n t i n u e - o n - e r r o r \s * : / u) ;
183+ }
184+
185+ function assertCodeqlActionPins ( workflow : string ) : void {
186+ const oldCodeqlSha = 'e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81' ;
187+ const newCodeqlSha = 'ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd' ;
188+ assert . ok ( ! workflow . includes ( oldCodeqlSha ) ) ;
189+ const references = [
190+ ...workflow . matchAll ( / u s e s : \s * g i t h u b \/ c o d e q l - a c t i o n \/ [ ^ @ \s ] + @ ( [ a - f 0 - 9 ] { 40 } ) \s * # \s * ( v [ ^ \s ] + ) / gu) ,
191+ ] ;
192+ assert . ok ( references . length > 0 , 'workflow must retain CodeQL action references' ) ;
193+ for ( const reference of references ) {
194+ assert . equal ( reference [ 1 ] , newCodeqlSha ) ;
195+ assert . equal ( reference [ 2 ] , 'v4.37.7' ) ;
196+ }
197+ }
198+
199+ function assertDockerLoginPin ( workflow : string ) : void {
200+ const oldDockerLoginSha = 'abd2ef45e78c5afb21d64d4ca52ee8550d9572c7' ;
201+ const newDockerLoginSha = 'dbcb813823bdd20940b903addbd779551569679f' ;
202+ assert . ok ( ! workflow . includes ( oldDockerLoginSha ) ) ;
203+ const references = [
204+ ...workflow . matchAll ( / u s e s : \s * d o c k e r \/ l o g i n - a c t i o n @ ( [ a - f 0 - 9 ] { 40 } ) \s * # \s * ( v [ ^ \s ] + ) / gu) ,
205+ ] ;
206+ assert . equal ( references . length , 1 ) ;
207+ assert . equal ( references [ 0 ] [ 1 ] , newDockerLoginSha ) ;
208+ assert . equal ( references [ 0 ] [ 2 ] , 'v4.6.0' ) ;
209+ }
210+
211+ function assertDependabotUnpdfPolicy ( dependabot : string ) : void {
212+ const ignoredUnpdfFixture = `updates:
213+ - package-ecosystem: npm
214+ ignore:
215+ - dependency-name: "unpdf" # controlled ignored package
216+ ` ;
217+ const allowedUnpdfFixture = `updates:
218+ - package-ecosystem: npm
219+ allow:
220+ - dependency-name: unpdf # controlled allowed package
221+ ` ;
222+ assert . ok ( hasDependabotIgnoreEntry ( ignoredUnpdfFixture , 'unpdf' ) ) ;
223+ assert . ok ( ! hasDependabotIgnoreEntry ( allowedUnpdfFixture , 'unpdf' ) ) ;
224+ assert . ok ( ! hasDependabotIgnoreEntry ( dependabot , 'unpdf' ) ) ;
225+ }
226+
113227function extractFences ( markdown : string , language : string ) : string [ ] {
114228 const blocks : string [ ] = [ ] ;
115229 let active : string [ ] | null = null ;
@@ -276,82 +390,15 @@ export async function runTests(): Promise<TestResult> {
276390 const dockerPublish = readText ( new URL ( '../../.github/workflows/docker-publish.yml' , import . meta. url ) ) ;
277391 const dockerRebuild = readText ( new URL ( '../../.github/workflows/docker-rebuild.yml' , import . meta. url ) ) ;
278392 const dependabot = readText ( new URL ( '../../.github/dependabot.yml' , import . meta. url ) ) ;
279- const packageManifest = JSON . parse ( readText ( new URL ( '../../package.json' , import . meta. url ) ) ) as {
280- dependencies ?: Record < string , unknown > ;
281- engines ?: Record < string , unknown > ;
282- version ?: unknown ;
283- } ;
284-
285- assert . equal ( packageManifest . version , '1.16.0' ) ;
286- assert . equal ( packageManifest . engines ?. node , '>=20' ) ;
287- assert . equal ( packageManifest . dependencies ?. [ 'express-rate-limit' ] , '^8.5.2' ) ;
288-
289- assert . ok ( readme . includes ( 'Node.js 20 remains supported but is deprecated and end-of-life.' ) ) ;
290- assert . ok ( readme . includes ( 'Node.js 22 or later is recommended.' ) ) ;
291- assert . ok ( readme . includes ( 'Node.js 20 will be removed only in a future major release.' ) ) ;
292-
293- const matrix = ci . match ( / ^ \s * n o d e - v e r s i o n : \s * \[ ( [ ^ \] ] + ) \] \s * $ / mu) ;
294- assert . ok ( matrix , 'CI must declare an inline Node version matrix' ) ;
295- assert . deepEqual (
296- [ ...matrix [ 1 ] . matchAll ( / [ ' " ] ( [ ^ ' " ] + ) [ ' " ] / gu) ] . map ( ( match ) => match [ 1 ] ) ,
297- [ '20' , '22' , '24' , '26.7.0' ] ,
298- ) ;
299- const jobs = ci . slice ( ci . indexOf ( 'jobs:' ) ) ;
300- assert . deepEqual ( ( jobs . match ( / ^ [ A - Z a - z 0 - 9 _ - ] + : \s * $ / gmu) ?? [ ] ) . map ( ( line ) => line . trim ( ) ) , [ 'test:' ] ) ;
301- assert . match ( ci , / u s e s : \s * a c t i o n s \/ c h e c k o u t @ / u) ;
302- assert . match ( ci , / u s e s : \s * a c t i o n s \/ s e t u p - n o d e @ / u) ;
303- assert . match ( ci , / c a c h e : \s * [ ' " ] n p m [ ' " ] / u) ;
304- for ( const command of [
305- / r u n : \s * n p m c i / u,
306- / r u n : \s * n p m r u n l i n t / u,
307- / r u n : \s * n p m r u n b u i l d / u,
308- / r u n : \s * n p m r u n t e s t : c o v e r a g e / u,
309- ] ) {
310- assert . match ( ci , command ) ;
311- }
312- assert . doesNotMatch ( ci , / ^ \s * i n c l u d e \s * : / mu) ;
313- assert . doesNotMatch ( ci , / ^ \s * i f \s * : / mu) ;
314- assert . doesNotMatch ( ci , / c o n t i n u e - o n - e r r o r \s * : / u) ;
315-
316- const oldCodeqlSha = 'e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81' ;
317- const newCodeqlSha = 'ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd' ;
318- for ( const workflow of [ codeql , scorecard ] ) {
319- assert . ok ( ! workflow . includes ( oldCodeqlSha ) ) ;
320- const codeqlReferences = [
321- ...workflow . matchAll ( / u s e s : \s * g i t h u b \/ c o d e q l - a c t i o n \/ [ ^ @ \s ] + @ ( [ a - f 0 - 9 ] { 40 } ) \s * # \s * ( v [ ^ \s ] + ) / gu) ,
322- ] ;
323- assert . ok ( codeqlReferences . length > 0 , 'workflow must retain CodeQL action references' ) ;
324- for ( const reference of codeqlReferences ) {
325- assert . equal ( reference [ 1 ] , newCodeqlSha ) ;
326- assert . equal ( reference [ 2 ] , 'v4.37.7' ) ;
327- }
328- }
329-
330- const oldDockerLoginSha = 'abd2ef45e78c5afb21d64d4ca52ee8550d9572c7' ;
331- const newDockerLoginSha = 'dbcb813823bdd20940b903addbd779551569679f' ;
332- for ( const workflow of [ dockerPublish , dockerRebuild ] ) {
333- assert . ok ( ! workflow . includes ( oldDockerLoginSha ) ) ;
334- const dockerLoginReferences = [
335- ...workflow . matchAll ( / u s e s : \s * d o c k e r \/ l o g i n - a c t i o n @ ( [ a - f 0 - 9 ] { 40 } ) \s * # \s * ( v [ ^ \s ] + ) / gu) ,
336- ] ;
337- assert . equal ( dockerLoginReferences . length , 1 ) ;
338- assert . equal ( dockerLoginReferences [ 0 ] [ 1 ] , newDockerLoginSha ) ;
339- assert . equal ( dockerLoginReferences [ 0 ] [ 2 ] , 'v4.6.0' ) ;
340- }
341-
342- const ignoredUnpdfFixture = `updates:
343- - package-ecosystem: npm
344- ignore:
345- - dependency-name: "unpdf" # controlled ignored package
346- ` ;
347- const allowedUnpdfFixture = `updates:
348- - package-ecosystem: npm
349- allow:
350- - dependency-name: unpdf # controlled allowed package
351- ` ;
352- assert . ok ( hasDependabotIgnoreEntry ( ignoredUnpdfFixture , 'unpdf' ) ) ;
353- assert . ok ( ! hasDependabotIgnoreEntry ( allowedUnpdfFixture , 'unpdf' ) ) ;
354- assert . ok ( ! hasDependabotIgnoreEntry ( dependabot , 'unpdf' ) ) ;
393+ assertPackageMetadata ( ) ;
394+ assertReadmeNodePolicy ( readme ) ;
395+ assertCiMatrix ( ci ) ;
396+ assertCiCommonJob ( ci ) ;
397+ assertCodeqlActionPins ( codeql ) ;
398+ assertCodeqlActionPins ( scorecard ) ;
399+ assertDockerLoginPin ( dockerPublish ) ;
400+ assertDockerLoginPin ( dockerRebuild ) ;
401+ assertDependabotUnpdfPolicy ( dependabot ) ;
355402 } , results ) ;
356403
357404 await testFunction ( 'cookbook exists and README links to it' , ( ) => {
0 commit comments