Files
msd-core/tests/policy-shell-pinning.test.cjs
Tom Boucher cec7e704d6 feat(#435): expand workflow-policy linter to full Cartesian matrix cross-product (#1050)
`expandRunsOn` enumerated a multi-axis `strategy.matrix` one key at a time,
producing partial realization contexts. A true `os × shell` matrix therefore
left `${{ matrix.shell }}` unresolvable against any `{ os: ... }`-only context,
firing spurious `UNRESOLVABLE_MATRIX` violations and leaving shell-pinning
coverage incomplete on Cartesian jobs.

Enumerate the full GitHub Actions cross-product of all base-list matrix keys
(every `matrix.<k>` array, excluding the `include`/`exclude` control keys) via
a named `cartesianProduct` helper. Each realization's context now carries a
value for every matrix key, so `${{ matrix.<key> }}` resolves per realization.
Single-axis matrices keep byte-for-byte identical output; only multi-axis
matrices change shape. The `include` and `exclude` blocks are unchanged
(full tuple-aware exclude is a documented out-of-scope follow-up).

Tests: updated the `os × shell` test to assert post-fix behavior (4 step
realizations, 2 WRONG_SHELL_FOR_OS, 0 UNRESOLVABLE_MATRIX); added a compliant
`os × node-version` cross-product test; added a fast-check property test that
the realization count equals the product of axis lengths and that no axis key
is dropped from any context.

Closes #435

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 13:36:47 -04:00

739 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const {
VIOLATION,
inspectWorkflow,
runPolicyLint,
} = require('../scripts/workflow-policy.cjs');
// ---------------------------------------------------------------------------
// Test 1 — Baseline: repo's current workflow files must yield ZERO violations
// (RED on origin/next; GREEN after YAML fixes are committed)
// ---------------------------------------------------------------------------
describe('baseline: repo workflows comply with H1 shell policy', () => {
test('runPolicyLint on .github/workflows produces zero violations', () => {
const workflowsDir = path.resolve(__dirname, '..', '.github', 'workflows');
const result = runPolicyLint({ workflowsDir });
if (result.violations.length > 0) {
const top10 = result.violations.slice(0, 10);
const msg = top10.map(v =>
` ${path.basename(v.filePath)}:${v.evidence.line} [${v.jobId}/${v.stepName}] runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`
).join('\n');
assert.fail(
`Expected 0 violations but found ${result.violations.length}. ` +
`Top violations (mechanism: each step on a macos-* or windows-* runner must use native shell):\n${msg}`
);
}
assert.strictEqual(
result.violations.length,
0,
'All workflow steps must comply with H1 shell policy'
);
});
});
// ---------------------------------------------------------------------------
// Test 2 — Positive synthetic: compliant workflow yields zero violations
// Three separate jobs, one per OS, each using H1-compliant shell configuration:
// ubuntu: no shell pin (runner default bash = policy bash)
// macos: job-level defaults.run.shell: zsh
// windows: no shell pin (runner default pwsh = policy pwsh)
// ---------------------------------------------------------------------------
describe('synthetic: fully-compliant per-OS jobs workflow', () => {
const COMPLIANT_YAML = `
name: Compliant Workflow
jobs:
linux-job:
runs-on: ubuntu-latest
steps:
- name: Run tests on ubuntu
run: npm test
macos-job:
runs-on: macos-latest
defaults:
run:
shell: zsh
steps:
- name: Run tests on macOS
run: npm test
windows-job:
runs-on: windows-latest
steps:
- name: Run tests on windows
run: npm test
`;
test('compliant per-OS workflow (ubuntu no pin, macos job-defaults zsh, windows no pin) produces zero violations', () => {
const result = inspectWorkflow(COMPLIANT_YAML, { filePath: '<synthetic-compliant>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
'Compliant per-OS workflow must have zero violations. Got: ' +
violations.map(v => `${v.runner}/${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 3 — Counter-test: macOS missing explicit zsh → MACOS_MISSING_EXPLICIT_ZSH
// (mechanism: macos-latest default is bash, not zsh; H1 requires explicit shell: zsh)
// ---------------------------------------------------------------------------
describe('counter-test: macOS step without explicit shell: zsh', () => {
const MACOS_NO_SHELL_YAML = `
name: macOS No Shell
jobs:
build:
runs-on: macos-latest
steps:
- name: Run tests
run: npm test
`;
test('macos-latest step with no shell pin produces exactly one MACOS_MISSING_EXPLICIT_ZSH violation', () => {
const result = inspectWorkflow(MACOS_NO_SHELL_YAML, { filePath: '<synthetic-macos-no-shell>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (MACOS_MISSING_EXPLICIT_ZSH on macos-latest) but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.MACOS_MISSING_EXPLICIT_ZSH,
`Expected violation type MACOS_MISSING_EXPLICIT_ZSH but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 4 — Counter-test: wrong shell for OS → WRONG_SHELL_FOR_OS
// (mechanism: windows-2025 default is pwsh; specifying shell: bash is a policy violation)
// ---------------------------------------------------------------------------
describe('counter-test: windows step with explicit shell: bash', () => {
const WINDOWS_BASH_YAML = `
name: Windows Bash
jobs:
build:
runs-on: windows-2025
steps:
- name: Run tests with wrong shell
shell: bash
run: npm test
`;
test('windows-2025 step with shell: bash produces exactly one WRONG_SHELL_FOR_OS violation', () => {
const result = inspectWorkflow(WINDOWS_BASH_YAML, { filePath: '<synthetic-windows-bash>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (WRONG_SHELL_FOR_OS on windows-2025) but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected violation type WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'windows-2025',
`Expected violation runner to be windows-2025 but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 5 — Counter-test: matrix expansion with shell: bash on every step
// (mechanism: ubuntu realizations are compliant since bash IS policy for ubuntu;
// macos → WRONG_SHELL_FOR_OS (explicit wrong pin); windows → WRONG_SHELL_FOR_OS)
// Expected: 2 violations total (1 macos + 1 windows), zero for ubuntu
// Note: MACOS_MISSING_EXPLICIT_ZSH fires only when NO shell is set at any level;
// here shell: bash is explicit, so WRONG_SHELL_FOR_OS is the correct subtype.
// ---------------------------------------------------------------------------
describe('counter-test: three-OS matrix with shell: bash on every step', () => {
const ALL_BASH_MATRIX_YAML = `
name: All Bash Matrix
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-2025]
steps:
- name: Run tests
shell: bash
run: npm test
`;
test('three-OS matrix with shell: bash produces exactly 2 WRONG_SHELL_FOR_OS violations (macos + windows), zero for ubuntu', () => {
const result = inspectWorkflow(ALL_BASH_MATRIX_YAML, { filePath: '<synthetic-all-bash-matrix>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
2,
`Expected exactly 2 violations (macos-latest + windows-2025) but got ${violations.length}: ` +
violations.map(v => `${v.runner}/${v.violation}`).join(', ')
);
const macosViolation = violations.find(v => v.runner === 'macos-latest');
assert.ok(
macosViolation,
'Expected a violation for macos-latest realization'
);
// When an explicit shell: bash is set on the step, the violation is WRONG_SHELL_FOR_OS
// (the explicit pin is wrong for the OS). MACOS_MISSING_EXPLICIT_ZSH only fires when
// there is NO shell set at any level and the runner default (bash) is inherited silently.
assert.strictEqual(
macosViolation.violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`macos-latest with explicit shell: bash should be WRONG_SHELL_FOR_OS (explicit wrong pin) but got ${macosViolation?.violation}`
);
const windowsViolation = violations.find(v => v.runner === 'windows-2025');
assert.ok(
windowsViolation,
'Expected a violation for windows-2025 realization'
);
assert.strictEqual(
windowsViolation.violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`windows-2025 violation should be WRONG_SHELL_FOR_OS but got ${windowsViolation?.violation}`
);
const ubuntuViolations = violations.filter(v => v.runner === 'ubuntu-latest');
assert.strictEqual(
ubuntuViolations.length,
0,
`ubuntu-latest should produce zero violations (bash is both runner default and policy) but got ${ubuntuViolations.length}`
);
});
});
// ---------------------------------------------------------------------------
// Test 6 — Counter-test: unknown runner → UNKNOWN_RUNNER
// (mechanism: self-hosted is not in POLICY, so runner cannot be validated)
// ---------------------------------------------------------------------------
describe('counter-test: self-hosted runner produces UNKNOWN_RUNNER violation', () => {
const SELF_HOSTED_YAML = `
name: Self-Hosted
jobs:
build:
runs-on: self-hosted
steps:
- name: Run build
run: npm build
`;
test('self-hosted runner step produces exactly one UNKNOWN_RUNNER violation', () => {
const result = inspectWorkflow(SELF_HOSTED_YAML, { filePath: '<synthetic-self-hosted>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 UNKNOWN_RUNNER violation but got ${violations.length}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.UNKNOWN_RUNNER,
`Expected violation type UNKNOWN_RUNNER but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'self-hosted',
`Expected violation runner to be self-hosted but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7a — Positive: matrix.include with shell key + defaults.run.shell: ${{ matrix.shell }}
// (mechanism: each realization carries its own shell value; the linter resolves
// the matrix expression against the realization context before checking policy)
// ---------------------------------------------------------------------------
describe('matrix.shell: ${{ matrix.shell }} resolves per realization — zero violations', () => {
const MATRIX_SHELL_YAML = `
name: Matrix Shell Positive
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: macos-latest
shell: zsh
- os: windows-2025
shell: pwsh
steps:
- name: Run tests
run: npm test
`;
test('matrix.include with shell:zsh for macOS + shell:pwsh for Windows + defaults.run.shell: ${{ matrix.shell }} yields zero violations', () => {
const result = inspectWorkflow(MATRIX_SHELL_YAML, { filePath: '<synthetic-matrix-shell-positive>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
'matrix.shell resolved per realization must produce zero violations. Got: ' +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 7b — Counter-test: matrix.include row with wrong shell value
// (mechanism: if a row's shell value doesn't match its OS policy, WRONG_SHELL_FOR_OS fires)
// ---------------------------------------------------------------------------
describe('matrix.shell: ${{ matrix.shell }} with wrong value per row — WRONG_SHELL_FOR_OS', () => {
const MATRIX_SHELL_WRONG_YAML = `
name: Matrix Shell Wrong Row
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: macos-latest
shell: bash
- os: windows-2025
shell: pwsh
steps:
- name: Run tests
run: npm test
`;
test('matrix.include row with shell:bash for macOS produces WRONG_SHELL_FOR_OS (bash is wrong for macOS)', () => {
const result = inspectWorkflow(MATRIX_SHELL_WRONG_YAML, { filePath: '<synthetic-matrix-shell-wrong>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
// macOS realization: shell resolves to bash → WRONG_SHELL_FOR_OS
// Windows realization: shell resolves to pwsh → compliant
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (macos-latest bash→WRONG_SHELL_FOR_OS) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation for macos-latest but got ${violations[0].runner}`
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7c — Counter-test: matrix.include row missing the shell key while
// defaults.run.shell: ${{ matrix.shell }} references it → UNRESOLVABLE_MATRIX
// ---------------------------------------------------------------------------
describe('matrix.shell expression references missing key — UNRESOLVABLE_MATRIX', () => {
const MATRIX_SHELL_MISSING_KEY_YAML = `
name: Matrix Shell Missing Key
jobs:
build:
runs-on: \${{ matrix.os }}
defaults:
run:
shell: \${{ matrix.shell }}
strategy:
matrix:
include:
- os: ubuntu-latest
node-version: 24
steps:
- name: Run tests
run: npm test
`;
test('matrix.include row without shell key while defaults.run.shell: ${{ matrix.shell }} → UNRESOLVABLE_MATRIX', () => {
const result = inspectWorkflow(MATRIX_SHELL_MISSING_KEY_YAML, { filePath: '<synthetic-matrix-shell-missing-key>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 UNRESOLVABLE_MATRIX violation but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].violation,
VIOLATION.UNRESOLVABLE_MATRIX,
`Expected UNRESOLVABLE_MATRIX but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'ubuntu-latest',
`Expected runner ubuntu-latest but got ${violations[0].runner}`
);
});
});
// ---------------------------------------------------------------------------
// Test 7 — Counter-test: workflow-level defaults.run.shell: zsh satisfies macOS H1
// (mechanism: resolution order puts workflow defaults above runner default;
// zsh at workflow level means macOS steps inherit it without step-level pin)
// ---------------------------------------------------------------------------
describe('counter-test: workflow-level defaults.run.shell: zsh satisfies macos-* H1', () => {
const WORKFLOW_DEFAULTS_ZSH_YAML = `
name: Workflow Defaults ZSH
defaults:
run:
shell: zsh
jobs:
build:
runs-on: macos-latest
steps:
- name: Run tests on macOS
run: npm test
`;
test('workflow-level shell: zsh + macos-latest + no step-level shell produces zero violations', () => {
const result = inspectWorkflow(WORKFLOW_DEFAULTS_ZSH_YAML, { filePath: '<synthetic-workflow-defaults-zsh>' });
assert.strictEqual(
result.workflowDefaultsShell,
'zsh',
`Expected workflowDefaultsShell to be zsh but got ${result.workflowDefaultsShell}`
);
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
0,
`Workflow-level shell: zsh must satisfy H1 for macos-latest steps (resolution-order rule). Got ${violations.length} violations: ` +
violations.map(v => `${v.violation}`).join(', ')
);
});
test('effective shell for macOS step is zsh when inherited from workflow defaults', () => {
const result = inspectWorkflow(WORKFLOW_DEFAULTS_ZSH_YAML, { filePath: '<synthetic-workflow-defaults-zsh>' });
const step = result.jobs[0]?.steps[0];
assert.ok(step, 'Expected at least one step');
assert.strictEqual(
step.effectiveShell,
'zsh',
`Expected effectiveShell to be zsh (inherited from workflow defaults) but got ${step.effectiveShell}`
);
assert.strictEqual(
step.stepShell,
null,
`Expected stepShell to be null (no step-level pin) but got ${step.stepShell}`
);
});
});
// ---------------------------------------------------------------------------
// Test 8a — Cartesian matrix os × shell — full cross-product expansion resolves
// matrix.shell per realization
// (mechanism: matrix.os: [macos-latest, macos-latest] with matrix.shell: [zsh, bash]
// and runs-on: ${{ matrix.os }}, step shell: ${{ matrix.shell }}.
// GitHub Actions realizes a 2×2 grid (4 jobs). After the Cartesian-product fix,
// expandRunsOn must produce 4 realizations, each carrying BOTH os AND shell in
// context so that ${{ matrix.shell }} resolves per realization.
//
// Expected post-fix behavior:
// - 4 total step-results (2 os × 2 shell)
// - exactly 2 WRONG_SHELL_FOR_OS violations — the two bash cells on macos-latest
// - ZERO UNRESOLVABLE_MATRIX violations (matrix.shell is now fully resolved)
// ---------------------------------------------------------------------------
describe('Cartesian matrix os × shell — full cross-product expansion resolves matrix.shell per realization', () => {
const CARTESIAN_MATRIX_YAML = `
name: Cartesian Matrix
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, macos-latest]
shell: [zsh, bash]
steps:
- name: Run tests
shell: \${{ matrix.shell }}
run: echo hi
`;
test('2×2 Cartesian product yields 4 step-results, 2 WRONG_SHELL_FOR_OS violations, 0 UNRESOLVABLE_MATRIX', () => {
const result = inspectWorkflow(CARTESIAN_MATRIX_YAML, { filePath: '<synthetic-cartesian-matrix>' });
const allSteps = result.jobs.flatMap(j => j.steps);
const violations = allSteps.filter(s => s.violation !== null);
const unresolvable = violations.filter(s => s.violation === VIOLATION.UNRESOLVABLE_MATRIX);
const wrongShell = violations.filter(s => s.violation === VIOLATION.WRONG_SHELL_FOR_OS);
// 4 step-results: 2 os values × 2 shell values = 4 realizations, each with 1 step
assert.strictEqual(
allSteps.length,
4,
`Expected 4 step-results (2×2 Cartesian product) but got ${allSteps.length}. All steps: ` +
allSteps.map(s => `runner=${s.runner} shell=${s.effectiveShell} violation=${s.violation}`).join(', ')
);
// Core regression proof: ZERO UNRESOLVABLE_MATRIX (matrix.shell now resolves)
assert.strictEqual(
unresolvable.length,
0,
`Expected 0 UNRESOLVABLE_MATRIX violations but got ${unresolvable.length}: ` +
unresolvable.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
// Exactly 2 WRONG_SHELL_FOR_OS: the two bash cells on macos-latest
assert.strictEqual(
wrongShell.length,
2,
`Expected exactly 2 WRONG_SHELL_FOR_OS violations (bash on macos-latest) but got ${wrongShell.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
for (const v of wrongShell) {
assert.strictEqual(
v.runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${v.runner}`
);
assert.strictEqual(
v.effectiveShell,
'bash',
`Expected effectiveShell to be bash (the violating cell) but got ${v.effectiveShell}`
);
}
});
});
// ---------------------------------------------------------------------------
// Test 8b — Cartesian matrix os × node-version — compliant cross-product carries
// extra axis without violations
// (mechanism: matrix.os: [ubuntu-latest, ubuntu-latest] with
// matrix.node-version: [22, 24], step shell: bash (literal).
// The cross-product yields 4 realizations. ubuntu-latest + bash = compliant.
// Pre-fix: only os is expanded → 2 step-results. Post-fix: 4 step-results.
// This is the fail-first signal for the Cartesian expansion fix.
// ---------------------------------------------------------------------------
describe('Cartesian matrix os × node-version — compliant cross-product yields 4 step-results with 0 violations', () => {
const CARTESIAN_COMPLIANT_YAML = `
name: Cartesian Compliant
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, ubuntu-latest]
node-version: [22, 24]
steps:
- name: Run tests
shell: bash
run: npm test
`;
test('2×2 Cartesian product yields 4 step-results (fail-first signal pre-fix: 2) and 0 violations', () => {
const result = inspectWorkflow(CARTESIAN_COMPLIANT_YAML, { filePath: '<synthetic-cartesian-compliant>' });
const allSteps = result.jobs.flatMap(j => j.steps);
const violations = allSteps.filter(s => s.violation !== null);
// Pre-fix: only os is expanded → 2 step-results; post-fix: 4
assert.strictEqual(
allSteps.length,
4,
`Expected 4 step-results (2 os × 2 node-version Cartesian product) but got ${allSteps.length}. ` +
`If you see 2, the Cartesian expansion fix is not yet applied. All steps: ` +
allSteps.map(s => `runner=${s.runner} shell=${s.effectiveShell} violation=${s.violation}`).join(', ')
);
// ubuntu-latest + literal bash = compliant; no violations expected
assert.strictEqual(
violations.length,
0,
`Expected 0 violations (ubuntu-latest + bash is compliant) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
});
});
// ---------------------------------------------------------------------------
// Test 8c — Property-based: for any 1–3 base-list axes with 1–3 values each,
// step count === Cartesian product of axis lengths
// (fast-check is a devDependency: fast-check ^4.8.0)
// ---------------------------------------------------------------------------
const fc = require('fast-check');
describe('property-based: step count equals product of all axis lengths for arbitrary base-list matrices', () => {
test('step count === product(axisLengths) for 1–3 axes with 1–3 values each', () => {
// Axis keys named k0, k1, k2; values restricted to [A-Za-z0-9-] to keep YAML well-formed.
// runs-on references ${{ matrix.k0 }}; the single step's shell references the
// LAST matrix axis key (${{ matrix.kLast }}) so that a dropped axis key would
// surface as UNRESOLVABLE_MATRIX rather than silently resolving.
const axisValueArb = fc.stringMatching(/^[A-Za-z][A-Za-z0-9-]{0,7}$/);
const axisArb = fc.array(axisValueArb, { minLength: 1, maxLength: 3 });
const matrixArb = fc.array(axisArb, { minLength: 1, maxLength: 3 });
fc.assert(
fc.property(matrixArb, (axes) => {
// Build YAML matrix block
const keys = axes.map((_, i) => `k${i}`);
const lastKey = keys[keys.length - 1];
const matrixLines = keys.map((k, i) => ` ${k}: [${axes[i].join(', ')}]`);
const yaml = [
'name: PropertyTest',
'jobs:',
' build:',
' runs-on: ${{ matrix.k0 }}',
' strategy:',
' matrix:',
...matrixLines,
' steps:',
' - name: Run tests',
` shell: \${{ matrix.${lastKey} }}`,
' run: echo hi',
].join('\n');
const result = inspectWorkflow(yaml, { filePath: '<property-test>' });
const allSteps = result.jobs.flatMap(j => j.steps);
const actualSteps = allSteps.length;
const expectedSteps = axes.reduce((acc, axis) => acc * axis.length, 1);
// Every realization must carry the last axis key in context so that
// ${{ matrix.kLast }} resolves. If the key is absent from any
// realization's context, effectiveShell fires UNRESOLVABLE_MATRIX.
const noUnresolvable = allSteps.every(
s => s.violation !== VIOLATION.UNRESOLVABLE_MATRIX,
);
return actualSteps === expectedSteps && noUnresolvable;
}),
{ seed: 42, numRuns: 100 }
);
});
});
// ---------------------------------------------------------------------------
// Test 8 — Counter-test: two macos-latest matrix.include rows where
// row 1 has shell: zsh (compliant) and row 2 has shell: bash (violation).
// Guards against the dedup bug where runner-label-only deduplication would
// collapse both rows into one, hiding the second row's policy violation.
// Expected: EXACTLY ONE WRONG_SHELL_FOR_OS violation (on the second row).
// ---------------------------------------------------------------------------
describe('counter-test: two macos-latest rows — dedup must not hide second row violation', () => {
const TWO_MACOS_ROWS_YAML = `
name: Two macOS Rows
jobs:
build:
runs-on: \${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
node-version: 22
shell: zsh
- os: macos-latest
node-version: 24
shell: bash
steps:
- name: Run tests
shell: \${{ matrix.shell }}
run: npm test
`;
test('two macos-latest matrix.include rows (zsh + bash) produce exactly one WRONG_SHELL_FOR_OS violation on the second row', () => {
const result = inspectWorkflow(TWO_MACOS_ROWS_YAML, { filePath: '<synthetic-two-macos-rows>' });
const violations = result.jobs
.flatMap(j => j.steps)
.filter(s => s.violation !== null);
assert.strictEqual(
violations.length,
1,
`Expected exactly 1 violation (second macos-latest row shell:bash → WRONG_SHELL_FOR_OS) but got ${violations.length}: ` +
violations.map(v => `runner=${v.runner} shell=${v.effectiveShell} type=${v.violation}`).join(', ')
);
assert.strictEqual(
violations[0].violation,
VIOLATION.WRONG_SHELL_FOR_OS,
`Expected WRONG_SHELL_FOR_OS but got ${violations[0].violation}`
);
assert.strictEqual(
violations[0].runner,
'macos-latest',
`Expected violation runner to be macos-latest but got ${violations[0].runner}`
);
assert.strictEqual(
violations[0].effectiveShell,
'bash',
`Expected effectiveShell to be bash (the violating row) but got ${violations[0].effectiveShell}`
);
});
});