ci: имена job validate.yml — контракт, сверяемый с файлом; шарды из матрицы (#622)

- scripts/workflow-jobs.mjs: разбор job-уровня validate.yml (id, name,
  inline matrix) без зависимостей; незнакомые формы — громкая ошибка.
- ci-proof: JOB_RULES по id job с именем-контрактом; число и точные имена
  экземпляров — из матрицы YAML (константы count: 6 нет); resolveJobRules
  на каждом evaluateCiProof — переименование даёт failed с названной job;
  jobContractProblems — сверка в обе стороны, UNCONSUMED_JOBS для proof.
- validate-gate: MUTANT_JOB_PREFIX — из контракта ci-proof.
- e2e-gate: E2E_JOB_NAME — зеркало name: из houseplan-e2e e2e.yml
  (43899da5), распознавание выводится из шаблона; missing называет
  завершённые прогоны без job по контракту.
- тесты: фикстуры имён выводятся из validate.yml; новый
  test/workflow-jobs.test.mjs; 5 мутантов в реестре.

Issue: #622
User-Visible: no
This commit is contained in:
Claude
2026-09-24 01:35:25 +00:00
committed by claude[bot]
parent 0ca56b4450
commit 1514899746
12 changed files with 593 additions and 93 deletions
+68 -21
View File
@@ -5,11 +5,13 @@ import { deflateRawSync } from 'node:zlib';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence,
CI_PROOF_POLICIES, MUTANT_JOB_PREFIX, baselineReviewedRun, buildCiProof, evaluateCiProof, loadGithubProofContext, localEvidence,
parseReuseMarker, productTreeId, readCiProofArtifact, requiredCheckIds, selectCiProofVerdict,
} from '../scripts/ci-proof.mjs';
import { REUSE_JOBS } from '../scripts/check-inputs.mjs';
import { reuseKey } from '../scripts/gate-reuse.mjs';
import { jobInstanceNames, parseWorkflowJobs, validateJobs, VALIDATE_WORKFLOW_PATH } from '../scripts/workflow-jobs.mjs';
import { readFileSync } from 'node:fs';
export const SHA = 'a'.repeat(40);
export const TREE = 'b'.repeat(40);
@@ -33,27 +35,27 @@ function zipWith(proof) {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
}
// #622: имена job — из validate.yml, а не копией строк: тест, который держит
// свои имена, зеленеет и тогда, когда потребитель с файлом разошёлся.
const WORKFLOW = validateJobs();
const namesOf = (id) => jobInstanceNames(WORKFLOW.get(id));
const names = {
preflight: 'Предполёт: документация, провенанс, процесс',
changes: 'Классификация изменённых файлов',
reuse: 'Переиспользование: это дерево уже проверено',
frontend: 'Фронтенд: типы, юниты, мутанты, синхрон бандла',
hacs: 'HACS: валидация репозитория',
hassfest: 'Hassfest: манифест интеграции',
smokeDone: 'Смоки: все шарды зелёные',
golden: 'Golden-кадры против принятых эталонов',
performance: 'Перф-смок: бюджет времени кадра',
geometryParity: 'Геометрия: TS/Python parity исполнена',
backend: 'Бэкенд: pytest в Home Assistant',
preflight: namesOf('preflight')[0],
changes: namesOf('changes')[0],
reuse: namesOf('reuse')[0],
frontend: namesOf('frontend')[0],
hacs: namesOf('hacs')[0],
hassfest: namesOf('hassfest')[0],
smokeDone: namesOf('smoke_done')[0],
golden: namesOf('golden')[0],
performance: namesOf('performance_smoke')[0],
geometryParity: namesOf('geometry_parity')[0],
backend: namesOf('backend')[0],
};
const success = (name) => ({ name, conclusion: 'success' });
const mutantJobs = () => Array.from({ length: 6 }, (_, index) => (
success(`Мутанты по диффу (${index + 1}/6): затронутые свидетели краснеют`)
));
const smokeJobs = () => Array.from({ length: 3 }, (_, index) => (
success(`Смоки в браузере (шард ${index + 1} из 3)`)
));
const mutantJobs = () => namesOf('changed_mutants').map(success);
const smokeJobs = () => namesOf('smoke').map(success);
export function proofFixture({
id = 10, attempt = 2, full = true, mutants = true,
@@ -158,9 +160,9 @@ test('#601 AC3: release policy accepts a full proof without requested mutants; l
}
});
test('#541 AC: green dispatch without six executed mutant jobs proves neither review nor merge', () => {
test('#541 AC: green dispatch without the executed mutant jobs proves neither review nor merge', () => {
const fixture = proofFixture({ full: false, backend: false, integration: false });
fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith('Мутанты по диффу'));
fixture.jobs = fixture.jobs.filter((job) => !job.name.startsWith(MUTANT_JOB_PREFIX));
for (const policy of [CI_PROOF_POLICIES.review, CI_PROOF_POLICIES.merge]) {
const verdict = evaluateCiProof({ ...fixture, policy });
assert.equal(verdict.status, 'failed', policy.name);
@@ -168,6 +170,51 @@ test('#541 AC: green dispatch without six executed mutant jobs proves neither re
}
});
// #622 AC2: сколько mutant-jobs обязано исполниться — говорит матрица
// validate.yml. Тот же набор job против файла с семью шардами — не
// доказательство; семь job по файлу с семью — доказательство.
const WORKFLOW_TEXT = readFileSync(VALIDATE_WORKFLOW_PATH, 'utf8').replace(/\r\n/g, '\n');
const withMutantShards = (count) => {
const shards = Array.from({ length: count }, (_, i) => i + 1).join(', ');
const text = WORKFLOW_TEXT.replace(/(\n changed_mutants:\n[\s\S]*?\n shard: )\[[^\]]*\]/, `$1[${shards}]`);
assert.notEqual(text, WORKFLOW_TEXT, 'fixture must rewrite the changed_mutants matrix');
return parseWorkflowJobs(text, 'validate.yml (fixture)');
};
test('#622 AC2: the number of mutant shards comes from the validate.yml matrix, not a constant', () => {
assert.equal(namesOf('changed_mutants').length, WORKFLOW.get('changed_mutants').size);
const policy = CI_PROOF_POLICIES.review;
const fixture = proofFixture({ full: false, backend: false, integration: false });
assert.equal(evaluateCiProof({ ...fixture, policy }).status, 'green');
const oneShardShort = { ...fixture, jobs: fixture.jobs.filter((job) => job.name !== namesOf('changed_mutants').at(-1)) };
assert.match(evaluateCiProof({ ...oneShardShort, policy }).note, /mutants: claimed execution/);
const seven = withMutantShards(7);
const verdict = evaluateCiProof({ ...fixture, policy, workflowJobs: seven });
assert.equal(verdict.status, 'failed', 'six jobs do not prove a seven-shard matrix');
assert.match(verdict.note, /mutants: claimed execution/);
const sevenJobs = [
...fixture.jobs.filter((job) => !job.name.startsWith(MUTANT_JOB_PREFIX)),
...jobInstanceNames(seven.get('changed_mutants')).map(success),
];
assert.equal(sevenJobs.filter((job) => job.name.startsWith(MUTANT_JOB_PREFIX)).length, 7);
assert.equal(evaluateCiProof({ ...fixture, jobs: sevenJobs, policy, workflowJobs: seven }).status, 'green');
const duplicated = { ...fixture, jobs: [...fixture.jobs, success(namesOf('changed_mutants')[0])] };
assert.equal(evaluateCiProof({ ...duplicated, policy }).status, 'failed', 'an instance reported twice is ambiguous, not proof');
});
test('#622 AC1: a job renamed in validate.yml fails the proof with the named job, not as an absent execution', () => {
const fixture = proofFixture();
const renamed = WORKFLOW_TEXT.replace('name: "Бэкенд: pytest в Home Assistant"', 'name: "Бэкенд: pytest в HA"');
assert.notEqual(renamed, WORKFLOW_TEXT);
const workflowJobs = parseWorkflowJobs(renamed, 'validate.yml (fixture)');
// Имена job в прогоне — по тому же переименованному файлу: без сверки
// контракта такой прогон прошёл бы, а rules в скриптах молча устарели.
const jobs = fixture.jobs.map((job) => (job.name === names.backend ? { ...job, name: 'Бэкенд: pytest в HA' } : job));
const verdict = evaluateCiProof({ ...fixture, jobs, workflowJobs, policy: CI_PROOF_POLICIES.release });
assert.equal(verdict.status, 'failed');
assert.match(verdict.note, /job-name contract with validate\.yml is broken \(#622\): backend: validate\.yml job backend is named/);
});
test('#541 AC: SHA, tree, run attempt, event and proof inventories cannot drift', () => {
const fixture = proofFixture();
assert.equal(evaluateCiProof({ ...fixture, run: { ...fixture.run, attempt: 3 }, policy: CI_PROOF_POLICIES.release }).status, 'stale');
@@ -319,7 +366,7 @@ test('#573 AC1: baseline-only коммит — reused smoke/perf из красн
test('#573 AC3: красный smoke кандидата не прячется за зелёной golden — источник reuse обязан быть зелёной job', () => {
const fixture = baselineOnlyFixture();
fixture.reuseRuns.get('10:1').jobs = fixture.reuseRuns.get('10:1').jobs
.map((job) => (job.name.startsWith('Смоки в браузере (шард 2') ? { ...job, conclusion: 'failure' } : job));
.map((job) => (job.name === namesOf('smoke')[1] ? { ...job, conclusion: 'failure' } : job));
const verdict = evaluateCiProof({ ...fixture, policy: CI_PROOF_POLICIES.release });
assert.equal(verdict.status, 'failed');
assert.match(verdict.note, /smoke: source run does not verify/);
+34 -1
View File
@@ -2,7 +2,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { classifyRun, e2eGate, isOurRun, previousStable, realOps, TOKEN_HINT } from '../scripts/e2e-gate.mjs';
import {
classifyRun, E2E_JOB_NAME, e2eGate, e2eJobName, isNamedE2eJob, isOurRun, previousStable, realOps, TAG_SUITES, TOKEN_HINT,
} from '../scripts/e2e-gate.mjs';
const TAG = 'v1.74.0';
const ours = (suffix = '') => [{ name: `journeys · HP ${TAG} · HA stable${suffix}`, conclusion: 'success' }, { name: 'upgrade · HP stable · HA stable', conclusion: 'success' }];
@@ -182,3 +184,34 @@ test('#540: without --ref the gate behaves exactly as before — the tag is the
assert.equal(outcome.result, 'green');
assert.deepEqual(fake.dispatched, [[TAG, 'v1.73.0']]);
});
// #622: имя job живёт в чужом репозитории (Matysh/houseplan-e2e), поэтому
// тест держит зеркало его строки `name:` и доказывает две вещи: гейт узнаёт
// ровно те имена, которые этот шаблон порождает, и разрыв контракта виден
// в исходе громко, а не как молчаливое «не появился».
// Зеркало: houseplan-e2e .github/workflows/e2e.yml, job `e2e`, коммит 43899da5.
const E2E_YML_NAME_LINE = ' name: "${{ matrix.suite }} · HP ${{ matrix.ref }} · HA ${{ matrix.ha }}"';
test('#622: the job-name contract mirrors houseplan-e2e e2e.yml and drives recognition', () => {
assert.equal(` name: "${E2E_JOB_NAME}"`, E2E_YML_NAME_LINE, 'e2e-gate and the pinned e2e.yml line disagree — change both sides together');
for (const suite of [...TAG_SUITES, 'upgrade']) {
assert.equal(isNamedE2eJob({ name: e2eJobName({ suite, ref: TAG, ha: 'stable' }) }), true, suite);
}
assert.equal(isNamedE2eJob({ name: 'Матрица прогона' }), false);
for (const suite of TAG_SUITES) {
assert.equal(classifyRun([{ name: e2eJobName({ suite, ref: TAG, ha: '2026.9.1' }) }], TAG), 'ours', suite);
}
assert.equal(classifyRun([{ name: e2eJobName({ suite: 'upgrade', ref: TAG, ha: 'stable' }) }], TAG), 'foreign',
'upgrade carries upgrade_from, not the candidate');
assert.throws(() => e2eJobName({ suite: 'journeys', ref: TAG }), /no value for matrix\.ha/);
});
test('#622: completed runs whose jobs do not follow the contract make «missing» name the broken contract', async () => {
const renamed = run({ databaseId: 4, url: 'https://e2e/run/4' });
const fake = fakeOps({ snapshots: [[renamed]], jobsById: { 4: [{ name: 'Матрица прогона' }, { name: `journeys / ${TAG} / stable`, conclusion: 'success' }] } });
const outcome = await e2eGate({ tag: TAG, ops: fake.ops, appearMs: 5000, pollMs: 1000 });
assert.equal(outcome.result, 'missing');
assert.match(outcome.note, /1 завершённых прогона без job по контракту E2E_JOB_NAME/);
const nothing = await e2eGate({ tag: TAG, ops: fakeOps({ snapshots: [[]] }).ops, appearMs: 5000, pollMs: 1000 });
assert.doesNotMatch(nothing.note, /E2E_JOB_NAME/, 'no runs at all is plain missing');
});
+6 -7
View File
@@ -9,6 +9,10 @@ import {
MAX_ATTEMPTS, MAX_COMMAND_OUTPUT_BYTES, commentFor, decideMerge, mergeCandidate, realOps, sh,
} from '../scripts/merge-candidate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
// #622: имена и число экземпляров job — из validate.yml, не копией строк.
const WORKFLOW = validateJobs();
const mergeProofContext = (row, sha, tree) => {
const proof = buildCiProof({
@@ -25,13 +29,8 @@ const mergeProofContext = (row, sha, tree) => {
},
});
const success = (name) => ({ name, conclusion: 'success' });
return { proof, reuseRuns: new Map(), jobs: [
success('Предполёт: документация, провенанс, процесс'),
success('Классификация изменённых файлов'),
success('Переиспользование: это дерево уже проверено'),
success('Фронтенд: типы, юниты, мутанты, синхрон бандла'),
...Array.from({ length: 6 }, (_, i) => success(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)),
] };
return { proof, reuseRuns: new Map(), jobs: ['preflight', 'changes', 'reuse', 'frontend', 'changed_mutants']
.flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map(success)) };
};
// #492 §4 / §8.4: слияние точного кандидата. Таблица решений — на чистой
+6 -12
View File
@@ -7,10 +7,14 @@ import {
candidateExpectations, classifyValidateProofs, classifyValidateRuns, latestRelevantRun, workflowRunsUrl,
} from '../scripts/release-gate.mjs';
import { buildCiProof, localEvidence } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
const greenJob = (name) => ({ name, conclusion: 'success' });
// #622: имена и число экземпляров — из validate.yml, не копией строк.
const WORKFLOW = validateJobs();
const greenJobs = (...ids) => ids.flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map(greenJob));
const proofContext = ({ id, full = true, conclusion = 'success' }) => {
const needs = {
preflight: { result: 'success' }, changes: { result: 'success', outputs: {
@@ -24,18 +28,8 @@ const proofContext = ({ id, full = true, conclusion = 'success' }) => {
performance_smoke: { result: full ? 'success' : 'skipped' }, backend: { result: full ? 'success' : 'skipped' },
};
const proof = buildCiProof({ candidateSha: SHA, candidateTree: TREE, runId: id, attempt: 1, event: 'workflow_dispatch', needs });
const jobs = [
greenJob('Предполёт: документация, провенанс, процесс'),
greenJob('Классификация изменённых файлов'), greenJob('Переиспользование: это дерево уже проверено'),
greenJob('Фронтенд: типы, юниты, мутанты, синхрон бандла'),
...Array.from({ length: 6 }, (_, i) => greenJob(`Мутанты по диффу (${i + 1}/6): затронутые свидетели краснеют`)),
];
if (full) jobs.push(
greenJob('HACS: валидация репозитория'), greenJob('Hassfest: манифест интеграции'),
...Array.from({ length: 3 }, (_, i) => greenJob(`Смоки в браузере (шард ${i + 1} из 3)`)),
greenJob('Смоки: все шарды зелёные'), greenJob('Golden-кадры против принятых эталонов'),
greenJob('Перф-смок: бюджет времени кадра'), greenJob('Бэкенд: pytest в Home Assistant'),
);
const jobs = greenJobs('preflight', 'changes', 'reuse', 'frontend', 'changed_mutants');
if (full) jobs.push(...greenJobs('hacs', 'hassfest', 'smoke', 'smoke_done', 'golden', 'performance_smoke', 'backend'));
const run = {
databaseId: id, attempt: 1, status: 'completed', conclusion, event: 'workflow_dispatch',
headSha: SHA, url: `https://run/${id}`, startedAt: `2026-09-13T10:${id}:00Z`,
+7 -7
View File
@@ -4,18 +4,18 @@ import test from 'node:test';
import { validateGate, isMutantRun, provesMutants } from '../scripts/validate-gate.mjs';
import { buildCiProof } from '../scripts/ci-proof.mjs';
import { jobInstanceNames, validateJobs } from '../scripts/workflow-jobs.mjs';
const SHA = 'a'.repeat(40);
const TREE = 'b'.repeat(40);
/** Fake gh: a scripted list of run snapshots per call, a virtual clock. */
const MUTANT_JOBS = [1, 2, 3, 4, 5, 6].map((n) => ({ name: `Мутанты по диффу (${n}/6): затронутые свидетели краснеют`, conclusion: 'success' }));
const OTHER_JOBS = [{ name: 'Фронтенд: типы, юниты, мутанты, синхрон бандла', conclusion: 'success' }];
const BASE_JOBS = [
{ name: 'Предполёт: документация, провенанс, процесс', conclusion: 'success' },
{ name: 'Классификация изменённых файлов', conclusion: 'success' },
{ name: 'Переиспользование: это дерево уже проверено', conclusion: 'success' },
];
// #622: имена и число экземпляров job — из validate.yml, не копией строк.
const WORKFLOW = validateJobs();
const jobsOf = (...ids) => ids.flatMap((id) => jobInstanceNames(WORKFLOW.get(id)).map((name) => ({ name, conclusion: 'success' })));
const MUTANT_JOBS = jobsOf('changed_mutants');
const OTHER_JOBS = jobsOf('frontend');
const BASE_JOBS = jobsOf('preflight', 'changes', 'reuse');
function fakeOps({ snapshots, onRef = [], jobsById = {} }) {
let clock = 0;
+7 -3
View File
@@ -4,6 +4,8 @@ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'n
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { JOB_RULES } from '../scripts/ci-proof.mjs';
import { validateJobs } from '../scripts/workflow-jobs.mjs';
// #336. Воркфлоу — не текст, а контракт, и ломается он молча: висячая
// зависимость `needs` не роняет YAML, а просто навсегда пропускает job, и
@@ -493,8 +495,10 @@ test('журнал свидетелей changed_mutants: rerun продолжа
assert.match(restore, /key: mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/);
assert.match(restore, /mutation-ledger-\$\{\{ matrix\.shard \}\}-\$\{\{ github\.run_id \}\}-/, 'rerun обязан восстановить предыдущую попытку того же run');
assert.match(restore, /^\s+mutation-ledger-\$\{\{ matrix\.shard \}\}-\s*$/m, 'новый run обязан найти последний журнал шарда');
assert.match(job, /--changed="\$base\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json" --plan-only/);
assert.match(job, /--changed="\$BASE\.\.\$HEAD_SHA" --shard="\$SHARD\/6" \\\n\s+--ledger="artifacts\/mutation-ledger\/shard-\$SHARD\.json"/);
// #622: число шардов — из матрицы, а не отдельной шестёркой в тесте.
const shards = validateJobs().get('changed_mutants').size;
assert.match(job, new RegExp(`--changed="\\$base\\.\\.\\$HEAD_SHA" --shard="\\$SHARD/${shards}" \\\\\\n\\s+--ledger="artifacts/mutation-ledger/shard-\\$SHARD\\.json" --plan-only`));
assert.match(job, new RegExp(`--changed="\\$BASE\\.\\.\\$HEAD_SHA" --shard="\\$SHARD/${shards}" \\\\\\n\\s+--ledger="artifacts/mutation-ledger/shard-\\$SHARD\\.json"`));
// #518: журнал обязан восстанавливаться ДО плана, иначе план не увидит
// уже пойманных и шард заплатит за окружение впустую.
assert.ok(job.indexOf('actions/cache/restore@') < job.indexOf('name: План шарда'), 'restore журнала идёт до плана');
@@ -508,7 +512,7 @@ test('журнал свидетелей changed_mutants: rerun продолжа
test('#541: Validate всегда публикует proof точной попытки, а reuse раскрывает источник', () => {
const workflow = read('validate.yml');
const preflightName = workflow.match(/\n preflight:\n name: "([^"]+)"/)?.[1];
assert.equal(preflightName, 'Предполёт: документация, провенанс, процесс');
assert.equal(preflightName, JOB_RULES.preflight[0].name, '#622: имя — контракт ci-proof, не своя копия');
assert.ok(Buffer.byteLength(preflightName, 'utf8') <= 100,
'GitHub Jobs API truncates job names longer than 100 UTF-8 bytes');
assert.doesNotMatch(workflow, /with:\s*\{[^\n]*\$\{\{/,
+106
View File
@@ -0,0 +1,106 @@
// #622: контракт имён job validate.yml с потребителями (ci-proof, validate-gate).
// Имена job — единственное, по чему ревью, слияние и релиз узнают исполненную
// проверку; расхождение с файлом давало «claimed execution is absent» без
// единого красного теста. Здесь файл читается как есть и сверяется в обе
// стороны.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import {
jobInstanceNames, parseWorkflowJobs, staticNamePrefix, validateJobs, VALIDATE_WORKFLOW_PATH,
} from '../scripts/workflow-jobs.mjs';
import { JOB_RULES, MUTANT_JOB_PREFIX, UNCONSUMED_JOBS, jobContractProblems, resolveJobRules } from '../scripts/ci-proof.mjs';
import { MUTANT_JOB_PREFIX as GATE_MUTANT_PREFIX } from '../scripts/validate-gate.mjs';
const TEXT = readFileSync(VALIDATE_WORKFLOW_PATH, 'utf8').replace(/\r\n/g, '\n');
const JOBS = validateJobs();
test('#622: parser reads job ids, names and inline matrices of the structure validate.yml uses', () => {
const jobs = parseWorkflowJobs([
'name: top', 'on: push', 'jobs:',
' # comment', ' plain:', ' name: Просто имя # хвост', ' runs-on: ubuntu-latest',
' steps:', ' - name: не имя job', ' run: |', ' name: и не это',
' quoted:', ' needs: plain', ' name: "Шард ${{ matrix.shard }}/${{ matrix.os }} #1"',
' strategy:', ' fail-fast: false', ' matrix:', ' shard: [1, 2, 3]', " os: ['a', b]",
' runs-on: x',
'env:', ' name: not-a-job',
].join('\n'));
assert.deepEqual([...jobs.keys()], ['plain', 'quoted']);
assert.equal(jobs.get('plain').name, 'Просто имя');
assert.equal(jobs.get('plain').size, 1);
assert.equal(jobs.get('quoted').name, 'Шард ${{ matrix.shard }}/${{ matrix.os }} #1');
assert.equal(jobs.get('quoted').size, 6);
assert.equal(staticNamePrefix(jobs.get('quoted').name), 'Шард ');
assert.deepEqual(jobInstanceNames(jobs.get('quoted')).slice(0, 3), ["Шард 1/'a' #1", 'Шард 1/b #1', "Шард 2/'a' #1"]);
});
test('#622: forms the parser does not understand fail loudly instead of guessing', () => {
const job = (...lines) => ['jobs:', ' j:', ' name: J', ...lines].join('\n');
assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix:', ' include:', ' - a: 1')), /include is not supported/);
assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix: ${{ fromJSON(x) }}')), /matrix expressions are not supported/);
assert.throws(() => parseWorkflowJobs(job(' strategy:', ' matrix:', ' shard:', ' - 1')), /only inline lists/);
assert.throws(() => parseWorkflowJobs('jobs:\n j:\n runs-on: x\n'), /job j has no name/);
assert.throws(() => parseWorkflowJobs('jobs:\n j:\n name: A\n j:\n name: B\n'), /duplicate job id j/);
assert.throws(() => parseWorkflowJobs('on: push\n'), /no top-level "jobs:"/);
assert.throws(() => jobInstanceNames(parseWorkflowJobs('jobs:\n j:\n name: "A ${{ inputs.x }}"\n').get('j')), /not a declared matrix axis/);
});
test('#622 AC1: every job of validate.yml is declared in ci-proof with its current name, and every declaration exists', () => {
assert.deepEqual(jobContractProblems(JOBS), []);
assert.doesNotThrow(() => resolveJobRules(JOBS));
const declared = [...Object.values(JOB_RULES).flat().map((rule) => rule.job), ...Object.keys(UNCONSUMED_JOBS)];
assert.deepEqual([...declared].sort(), [...JOBS.keys()].sort());
});
test('#622 AC1: renaming ANY job name in validate.yml without touching the rules turns the contract red', () => {
const nameLines = [...TEXT.matchAll(/^ {4}name: .+$/gm)].map((m) => m[0]);
assert.equal(nameLines.length, JOBS.size, 'one job-level name line per job');
for (const line of nameLines) {
const renamed = TEXT.replace(line, line.replace(/name: ("?)/, 'name: $1Переименовано '));
const problems = jobContractProblems(parseWorkflowJobs(renamed, 'validate.yml (renamed)'));
assert.equal(problems.length, 1, `${line.trim()} → ${JSON.stringify(problems)}`);
assert.match(problems[0], /validate\.yml names it/);
}
const extra = TEXT.replace('\n proof:\n', '\n extra_job:\n name: Новая job\n runs-on: ubuntu-latest\n proof:\n');
assert.match(jobContractProblems(parseWorkflowJobs(extra)).join('\n'), /extra_job: validate\.yml job is in neither/);
});
test('#622: consumed rules resolve to the exact instance names GitHub reports, within the 100-byte API limit', () => {
const rules = resolveJobRules(JOBS);
for (const [id, list] of Object.entries(rules)) {
for (const rule of list) {
assert.equal(rule.names.length, JOBS.get(rule.job).size, `${id}/${rule.job}: one name per matrix instance`);
for (const name of rule.names) {
assert.ok(Buffer.byteLength(name, 'utf8') <= 100, `${name}: GitHub Jobs API truncates names longer than 100 UTF-8 bytes`);
assert.doesNotMatch(name, /\$\{\{/, `${name}: unexpanded expression`);
}
}
}
const all = [...JOBS.values()].flatMap(jobInstanceNames);
assert.equal(new Set(all).size, all.length, 'job instance names are unique across the workflow');
});
test('#622: the mutant prefix of validate-gate is the ci-proof contract and matches only changed_mutants', () => {
assert.equal(GATE_MUTANT_PREFIX, MUTANT_JOB_PREFIX);
for (const job of JOBS.values()) {
const hits = jobInstanceNames(job).filter((name) => name.startsWith(MUTANT_JOB_PREFIX));
assert.equal(hits.length, job.id === 'changed_mutants' ? job.size : 0, job.id);
}
});
test('#622 AC2: shard totals written in names and scripts of a sharded job equal its matrix size', () => {
for (const job of JOBS.values()) {
if (!job.matrix?.shard) continue;
const start = TEXT.indexOf(`\n ${job.id}:\n`);
const next = TEXT.slice(start + 1).search(/\n {2}[A-Za-z0-9_-]+:\n/);
const body = TEXT.slice(start, next < 0 ? undefined : start + 1 + next);
const totals = [
...body.matchAll(/matrix\.shard \}\}(?:\/| из )(\d+)/g),
...body.matchAll(/\$SHARD\/(\d+)/g),
...body.matchAll(/SHARDS: '?(\d+)'?/g),
].map((m) => Number(m[1]));
assert.ok(totals.length > 0, `${job.id}: shard total is written somewhere`);
for (const total of totals) assert.equal(total, job.size, `${job.id}: literal shard total ${total} vs matrix of ${job.size}`);
}
});