@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marcusrbrown/infra",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Infrastructure management CLI — deploy automation, health checks, and MCP bridge",
5
5
  "keywords": [
6
6
  "infra",
@@ -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
+ })