@marcusrbrown/infra 0.4.8 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/conventions.test.ts +215 -0
package/package.json
CHANGED
package/src/conventions.test.ts
CHANGED
|
@@ -71,6 +71,80 @@ export function findCrossOrgSecretsInherit(parsed: unknown): {jobId: string; use
|
|
|
71
71
|
return violations
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Detect dorny/paths-filter steps that use negation patterns without declaring
|
|
76
|
+
* `predicate-quantifier: every`. The default quantifier (`some`) applies OR-logic
|
|
77
|
+
* across patterns, which silently makes negations truthy whenever any other file
|
|
78
|
+
* matches — the opposite of the intended behaviour.
|
|
79
|
+
*
|
|
80
|
+
* Rule: any step using dorny/paths-filter that contains a filter pattern starting
|
|
81
|
+
* with `!` MUST also set `predicate-quantifier: every` in the same step's `with:` block.
|
|
82
|
+
*/
|
|
83
|
+
export interface PathsFilterQuantifierViolation {
|
|
84
|
+
file?: string
|
|
85
|
+
jobId: string
|
|
86
|
+
stepIndex: number
|
|
87
|
+
reason: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findPathsFilterQuantifierViolations(workflowText: string): PathsFilterQuantifierViolation[] {
|
|
91
|
+
const parsed = parseYaml(workflowText, {merge: true}) as unknown
|
|
92
|
+
if (typeof parsed !== 'object' || parsed === null) return []
|
|
93
|
+
const jobs = (parsed as {jobs?: Record<string, unknown>}).jobs
|
|
94
|
+
if (typeof jobs !== 'object' || jobs === null) return []
|
|
95
|
+
|
|
96
|
+
const violations: PathsFilterQuantifierViolation[] = []
|
|
97
|
+
|
|
98
|
+
for (const [jobId, jobRaw] of Object.entries(jobs)) {
|
|
99
|
+
if (typeof jobRaw !== 'object' || jobRaw === null) continue
|
|
100
|
+
const job = jobRaw as {steps?: unknown[]}
|
|
101
|
+
if (!Array.isArray(job.steps)) continue
|
|
102
|
+
|
|
103
|
+
for (const [index, stepRaw] of job.steps.entries()) {
|
|
104
|
+
if (typeof stepRaw !== 'object' || stepRaw === null) continue
|
|
105
|
+
const step = stepRaw as {uses?: unknown; with?: Record<string, unknown>}
|
|
106
|
+
if (typeof step.uses !== 'string') continue
|
|
107
|
+
if (!step.uses.startsWith('dorny/paths-filter')) continue
|
|
108
|
+
|
|
109
|
+
const withBlock = step.with ?? {}
|
|
110
|
+
const filtersRaw = withBlock.filters
|
|
111
|
+
if (typeof filtersRaw !== 'string') continue
|
|
112
|
+
|
|
113
|
+
// Parse the inner YAML of the filters block to inspect pattern lists
|
|
114
|
+
const filters = parseYaml(filtersRaw, {merge: true}) as unknown
|
|
115
|
+
if (typeof filters !== 'object' || filters === null) continue
|
|
116
|
+
|
|
117
|
+
let hasNegation = false
|
|
118
|
+
for (const patternsRaw of Object.values(filters as Record<string, unknown>)) {
|
|
119
|
+
const patterns = Array.isArray(patternsRaw) ? patternsRaw : [patternsRaw]
|
|
120
|
+
for (const p of patterns) {
|
|
121
|
+
if (typeof p === 'string' && p.startsWith('!')) {
|
|
122
|
+
hasNegation = true
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (hasNegation) break
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!hasNegation) continue
|
|
130
|
+
|
|
131
|
+
const quantifier = withBlock['predicate-quantifier']
|
|
132
|
+
if (quantifier !== 'every') {
|
|
133
|
+
violations.push({
|
|
134
|
+
jobId,
|
|
135
|
+
stepIndex: index,
|
|
136
|
+
reason:
|
|
137
|
+
quantifier === undefined
|
|
138
|
+
? `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`
|
|
139
|
+
: `job '${jobId}' step ${index} uses dorny/paths-filter with negation patterns but predicate-quantifier is '${String(quantifier)}' (must be 'every')`,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return violations
|
|
146
|
+
}
|
|
147
|
+
|
|
74
148
|
describe('repo conventions', () => {
|
|
75
149
|
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
76
150
|
const workflows = listWorkflowFiles('.yaml')
|
|
@@ -314,3 +388,144 @@ jobs:
|
|
|
314
388
|
expect(findCrossOrgSecretsInherit(parsed)).toEqual([])
|
|
315
389
|
})
|
|
316
390
|
})
|
|
391
|
+
|
|
392
|
+
describe('findPathsFilterQuantifierViolations', () => {
|
|
393
|
+
it('paths-filter with negations and predicate-quantifier: every → 0 violations', () => {
|
|
394
|
+
const yaml = `
|
|
395
|
+
jobs:
|
|
396
|
+
detect:
|
|
397
|
+
runs-on: ubuntu-latest
|
|
398
|
+
steps:
|
|
399
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
400
|
+
with:
|
|
401
|
+
predicate-quantifier: every
|
|
402
|
+
filters: |
|
|
403
|
+
app:
|
|
404
|
+
- 'apps/myapp/**'
|
|
405
|
+
- '!apps/myapp/**/*.md'
|
|
406
|
+
`
|
|
407
|
+
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
it('paths-filter with negations and missing predicate-quantifier → 1 violation', () => {
|
|
411
|
+
const yaml = `
|
|
412
|
+
jobs:
|
|
413
|
+
detect:
|
|
414
|
+
runs-on: ubuntu-latest
|
|
415
|
+
steps:
|
|
416
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
417
|
+
with:
|
|
418
|
+
filters: |
|
|
419
|
+
app:
|
|
420
|
+
- 'apps/myapp/**'
|
|
421
|
+
- '!apps/myapp/**/*.md'
|
|
422
|
+
`
|
|
423
|
+
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
424
|
+
expect(violations).toEqual([
|
|
425
|
+
{
|
|
426
|
+
jobId: 'detect',
|
|
427
|
+
stepIndex: 0,
|
|
428
|
+
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
|
|
429
|
+
},
|
|
430
|
+
])
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('paths-filter with negations and predicate-quantifier: some → 1 violation', () => {
|
|
434
|
+
const yaml = `
|
|
435
|
+
jobs:
|
|
436
|
+
detect:
|
|
437
|
+
runs-on: ubuntu-latest
|
|
438
|
+
steps:
|
|
439
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
440
|
+
with:
|
|
441
|
+
predicate-quantifier: some
|
|
442
|
+
filters: |
|
|
443
|
+
app:
|
|
444
|
+
- 'apps/myapp/**'
|
|
445
|
+
- '!apps/myapp/**/*.md'
|
|
446
|
+
`
|
|
447
|
+
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
448
|
+
expect(violations).toEqual([
|
|
449
|
+
{
|
|
450
|
+
jobId: 'detect',
|
|
451
|
+
stepIndex: 0,
|
|
452
|
+
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but predicate-quantifier is 'some' (must be 'every')`,
|
|
453
|
+
},
|
|
454
|
+
])
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('paths-filter without negations → 0 violations regardless of quantifier', () => {
|
|
458
|
+
const yaml = `
|
|
459
|
+
jobs:
|
|
460
|
+
detect:
|
|
461
|
+
runs-on: ubuntu-latest
|
|
462
|
+
steps:
|
|
463
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
464
|
+
with:
|
|
465
|
+
filters: |
|
|
466
|
+
app:
|
|
467
|
+
- 'apps/myapp/**'
|
|
468
|
+
- 'apps/myapp/**/*.ts'
|
|
469
|
+
`
|
|
470
|
+
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
it('bare-string negation filter without predicate-quantifier → 1 violation', () => {
|
|
474
|
+
const yaml = `
|
|
475
|
+
jobs:
|
|
476
|
+
detect:
|
|
477
|
+
runs-on: ubuntu-latest
|
|
478
|
+
steps:
|
|
479
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
480
|
+
with:
|
|
481
|
+
filters: |
|
|
482
|
+
cliproxy: '!apps/cliproxy/**/*.md'
|
|
483
|
+
`
|
|
484
|
+
const violations = findPathsFilterQuantifierViolations(yaml)
|
|
485
|
+
expect(violations).toEqual([
|
|
486
|
+
{
|
|
487
|
+
jobId: 'detect',
|
|
488
|
+
stepIndex: 0,
|
|
489
|
+
reason: `job 'detect' step 0 uses dorny/paths-filter with negation patterns but is missing predicate-quantifier: every`,
|
|
490
|
+
},
|
|
491
|
+
])
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
it('bare-string negation filter with predicate-quantifier: every → 0 violations', () => {
|
|
495
|
+
const yaml = `
|
|
496
|
+
jobs:
|
|
497
|
+
detect:
|
|
498
|
+
runs-on: ubuntu-latest
|
|
499
|
+
steps:
|
|
500
|
+
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
|
501
|
+
with:
|
|
502
|
+
predicate-quantifier: every
|
|
503
|
+
filters: |
|
|
504
|
+
cliproxy: '!apps/cliproxy/**/*.md'
|
|
505
|
+
`
|
|
506
|
+
expect(findPathsFilterQuantifierViolations(yaml)).toEqual([])
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
describe('dorny/paths-filter quantifier guard', () => {
|
|
511
|
+
it('tripwire: workflow glob resolves to at least one file (catches dot-dir glob regressions)', () => {
|
|
512
|
+
// `.github/` is a dot-directory; Bun.Glob skips dot-dirs by default unless `dot: true` is set.
|
|
513
|
+
const glob = new Bun.Glob('.github/workflows/**')
|
|
514
|
+
const files = [...glob.scanSync({cwd: REPO_ROOT, absolute: true, dot: true})]
|
|
515
|
+
expect(files.length).toBeGreaterThan(0)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('all workflow files using dorny/paths-filter with negations declare predicate-quantifier: every', async () => {
|
|
519
|
+
const files = listWorkflowFiles('.yaml')
|
|
520
|
+
expect(files.length).toBeGreaterThan(0)
|
|
521
|
+
|
|
522
|
+
const violations: PathsFilterQuantifierViolation[] = []
|
|
523
|
+
for (const file of files) {
|
|
524
|
+
const text = await Bun.file(file).text()
|
|
525
|
+
for (const v of findPathsFilterQuantifierViolations(text)) {
|
|
526
|
+
violations.push({...v, file: relative(REPO_ROOT, file)})
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
expect(violations).toEqual([])
|
|
530
|
+
})
|
|
531
|
+
})
|