fix(quality): close registry silent-defeat paths
ci/woodpecker/pr/ci Pipeline was successful

This commit is contained in:
2026-08-01 11:28:28 -05:00
parent 83d2ecb224
commit 32b490a712
11 changed files with 889 additions and 52 deletions
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { deriveHistoryBoundary } from './gate-history.mjs';
const candidateKind = process.argv[2];
const root = process.cwd();
const boundary = deriveHistoryBoundary(root);
function revParse(revision) {
const result = spawnSync('git', ['rev-parse', revision], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
process.stderr.write(`history boundary control could not resolve ${revision}\n`);
process.exit(2);
}
return result.stdout.trim();
}
const candidates = {
head: revParse('HEAD'),
parent: revParse('HEAD^'),
introduction: boundary.introductionCommit,
};
if (!Object.hasOwn(candidates, candidateKind)) {
process.stderr.write(`unknown history boundary candidate ${String(candidateKind)}\n`);
process.exit(2);
}
const candidate = candidates[candidateKind];
if (candidate === boundary.activationCommit) {
process.stdout.write(`history boundary candidate ${candidateKind} matched derived activation\n`);
process.exit(0);
}
process.stderr.write(
`history boundary candidate ${candidateKind} rejected: derived activation is parent of registry introduction\n`,
);
process.exit(1);
+78 -27
View File
@@ -22,6 +22,33 @@ export async function listProspectiveCommits(root, activationCommit, head = 'HEA
return result.stdout.trim() ? result.stdout.trim().split('\n') : [];
}
export function deriveHistoryBoundary(root, head = 'HEAD') {
const introductions = git(root, [
'log',
'--first-parent',
'--diff-filter=A',
'--format=%H',
'--reverse',
head,
'--',
'gates/gates.manifest.json',
])
.stdout.trim()
.split('\n')
.filter(Boolean);
if (introductions.length === 0) {
throw new Error('history boundary cannot be derived: registry introduction is absent');
}
const introductionCommit = introductions[0];
const parent = git(root, ['rev-parse', `${introductionCommit}^`], { allowFailure: true });
if (parent.status !== 0 || !parent.stdout.trim()) {
throw new Error(
`history boundary cannot be derived: registry introduction ${introductionCommit} has no parent`,
);
}
return { activationCommit: parent.stdout.trim(), introductionCommit };
}
export async function readManifestAtCommit(root, commit) {
const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]);
return JSON.parse(result.stdout);
@@ -198,15 +225,8 @@ export async function replayCommit(root, commit) {
}
}
export function assessProviderEvidence(commit, pipelines) {
const matches = pipelines.filter((candidate) => candidate.commit === commit);
if (matches.length === 0) {
return {
state: 'absent',
detail:
'no retained provider record was supplied; retention expiry and never-ran are not inferred',
};
}
export function validateProviderEvidenceCollection(pipelines) {
if (!Array.isArray(pipelines)) return 'provider evidence collection is malformed';
const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']);
const stepStates = new Set([
'success',
@@ -217,9 +237,11 @@ export function assessProviderEvidence(commit, pipelines) {
'queued',
'skipped',
]);
const numbers = matches.map((candidate) => candidate.number);
const malformed = matches.some(
const malformed = pipelines.some(
(candidate) =>
!candidate ||
typeof candidate !== 'object' ||
Array.isArray(candidate) ||
typeof candidate.commit !== 'string' ||
candidate.commit.length === 0 ||
!Number.isInteger(candidate.number) ||
@@ -227,15 +249,43 @@ export function assessProviderEvidence(commit, pipelines) {
!Array.isArray(candidate.steps) ||
candidate.steps.some(
(step) =>
typeof step?.name !== 'string' ||
typeof step?.status !== 'string' ||
!step ||
typeof step !== 'object' ||
Array.isArray(step) ||
typeof step.name !== 'string' ||
typeof step.status !== 'string' ||
!stepStates.has(step.status),
),
);
if (malformed || new Set(numbers).size !== numbers.length) {
if (malformed) return 'provider records are malformed';
const ambiguousGateRecord = pipelines.find(
(candidate) => candidate.steps.filter((step) => step.name === 'gate-verify').length !== 1,
);
if (ambiguousGateRecord) {
const count = ambiguousGateRecord.steps.filter((step) => step.name === 'gate-verify').length;
return `provider record ${ambiguousGateRecord.number} has ambiguous gate-verify step count ${count}`;
}
const numbers = pipelines.map((candidate) => candidate.number);
if (new Set(numbers).size !== numbers.length) {
return 'duplicate pipeline identity across commits in provider evidence collection';
}
return undefined;
}
export function assessProviderEvidence(commit, pipelines) {
const collectionFailure = validateProviderEvidenceCollection(pipelines);
if (collectionFailure) {
return {
state: 'terminal-failure',
detail: 'provider records are malformed or have ambiguous pipeline numbers',
detail: collectionFailure,
};
}
const matches = pipelines.filter((candidate) => candidate.commit === commit);
if (matches.length === 0) {
return {
state: 'absent',
detail:
'no retained provider record was supplied; retention expiry and never-ran are not inferred',
};
}
const pipeline = [...matches].sort((left, right) => right.number - left.number)[0];
@@ -279,19 +329,16 @@ export async function verifyHistory({ root, manifest }) {
const failures = [];
const observations = [];
const head = git(root, ['rev-parse', 'HEAD']).stdout.trim();
if (!manifest.activationCommit) {
failures.push('history activationCommit is missing');
return { failures, observations };
}
const activationCheck = git(
root,
['merge-base', '--is-ancestor', manifest.activationCommit, head],
{ allowFailure: true },
);
if (activationCheck.status !== 0) {
if (Object.hasOwn(manifest, 'activationCommit')) {
failures.push(
`history activation commit ${manifest.activationCommit} is not an ancestor of ${head}`,
'author-controlled activationCommit is forbidden; history boundary is derived from the registry introduction',
);
}
let boundary;
try {
boundary = deriveHistoryBoundary(root, head);
} catch (error) {
failures.push(error.message);
return { failures, observations };
}
const onMain = isMainCommit(root, head);
@@ -311,7 +358,11 @@ export async function verifyHistory({ root, manifest }) {
}
const pipelines = onMain ? await loadProviderEvidence() : [];
const commits = await listProspectiveCommits(root, manifest.activationCommit, head);
const collectionFailure = validateProviderEvidenceCollection(pipelines);
if (collectionFailure) {
failures.push(`provider evidence collection invalid: ${collectionFailure}`);
}
const commits = await listProspectiveCommits(root, boundary.activationCommit, head);
for (const commit of commits) {
let commitManifest;
try {
+165 -1
View File
@@ -6,6 +6,7 @@ import test from 'node:test';
import {
assessProviderEvidence,
deriveHistoryBoundary,
listProspectiveCommits,
readManifestAtCommit,
replayCommit,
@@ -285,7 +286,7 @@ test('PR verification states the RM-60 boundary without executing an intermediat
try {
const result = await verifyHistory({
root,
manifest: { schemaVersion: 1, activationCommit: activation },
manifest: { schemaVersion: 1 },
});
assert.deepEqual(result.failures, []);
assert.ok(
@@ -305,6 +306,169 @@ test('PR verification states the RM-60 boundary without executing an intermediat
}
});
test('derived history boundary includes the registry-introduction commit', async () => {
const root = `${fixtureRoot}-derived-boundary`;
await rm(root, { recursive: true, force: true });
await mkdir(root, { recursive: true });
git(root, 'init', '-q');
git(root, 'config', 'user.name', 'gate-test');
git(root, 'config', 'user.email', '[email protected]');
await writeFile(path.join(root, 'baseline.txt'), 'baseline\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'baseline');
const baseline = git(root, 'rev-parse', 'HEAD');
await mkdir(path.join(root, 'gates'), { recursive: true });
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'registry introduction');
const introduction = git(root, 'rev-parse', 'HEAD');
await writeFile(path.join(root, 'later.txt'), 'later\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'later');
const head = git(root, 'rev-parse', 'HEAD');
assert.deepEqual(deriveHistoryBoundary(root, head), {
activationCommit: baseline,
introductionCommit: introduction,
});
assert.deepEqual(await listProspectiveCommits(root, baseline, head), [introduction, head]);
});
test('author-controlled activation seams cannot omit registry-era history', async () => {
const root = `${fixtureRoot}-activation-seam`;
await rm(root, { recursive: true, force: true });
await mkdir(path.join(root, 'gates'), { recursive: true });
git(root, 'init', '-q');
git(root, 'config', 'user.name', 'gate-test');
git(root, 'config', 'user.email', '[email protected]');
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'registry introduction');
const introduction = git(root, 'rev-parse', 'HEAD');
await writeFile(path.join(root, 'one.txt'), 'one\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'one');
await writeFile(path.join(root, 'two.txt'), 'two\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'two');
const head = git(root, 'rev-parse', 'HEAD');
const parent = git(root, 'rev-parse', 'HEAD^');
const previousBranch = process.env.CI_COMMIT_BRANCH;
process.env.CI_COMMIT_BRANCH = 'feature/activation-seam';
try {
for (const candidate of [head, parent, introduction]) {
const result = await verifyHistory({
root,
manifest: { schemaVersion: 1, activationCommit: candidate },
});
assert.match(result.failures.join('\n'), /author-controlled activationCommit.*forbidden/i);
}
} finally {
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
else process.env.CI_COMMIT_BRANCH = previousBranch;
}
});
test('globally invalid provider evidence fails when HEAD is the only prospective commit', async () => {
const root = `${fixtureRoot}-head-only-evidence`;
await rm(root, { recursive: true, force: true });
await mkdir(root, { recursive: true });
git(root, 'init', '-q');
git(root, 'config', 'user.name', 'gate-test');
git(root, 'config', 'user.email', '[email protected]');
await writeFile(path.join(root, 'baseline.txt'), 'baseline\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'baseline');
await mkdir(path.join(root, 'gates'), { recursive: true });
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n');
git(root, 'add', '.');
git(root, 'commit', '-m', 'registry introduction');
const head = git(root, 'rev-parse', 'HEAD');
const evidenceFile = path.join(root, 'provider-evidence.json');
await writeFile(
evidenceFile,
JSON.stringify([
{
commit: head,
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
{
commit: 'other-subject',
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
]),
);
const previousBranch = process.env.CI_COMMIT_BRANCH;
const previousEvidence = process.env.GATE_PROVIDER_EVIDENCE_FILE;
process.env.CI_COMMIT_BRANCH = 'main';
process.env.GATE_PROVIDER_EVIDENCE_FILE = evidenceFile;
try {
const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } });
assert.match(result.failures.join('\n'), /duplicate pipeline identity across commits/i);
} finally {
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
else process.env.CI_COMMIT_BRANCH = previousBranch;
if (previousEvidence === undefined) delete process.env.GATE_PROVIDER_EVIDENCE_FILE;
else process.env.GATE_PROVIDER_EVIDENCE_FILE = previousEvidence;
}
});
test('collection-wide validation rejects ambiguous gate steps on unrelated commits', () => {
const records = [
{
commit: 'target',
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
{
commit: 'unrelated',
number: 8,
status: 'success',
steps: [
{ name: 'gate-verify', status: 'success' },
{ name: 'gate-verify', status: 'failure' },
],
},
];
const result = assessProviderEvidence('target', records);
assert.equal(result.state, 'terminal-failure');
assert.match(result.detail, /ambiguous gate-verify step count/i);
});
test('provider evidence rejects non-object collection entries without crashing', () => {
for (const record of [null, [], 'text', 42]) {
const result = assessProviderEvidence('aaa', [record]);
assert.equal(result.state, 'terminal-failure');
assert.match(result.detail, /malformed/i);
}
});
test('provider evidence rejects duplicate pipeline identity across commits', () => {
const records = [
{
commit: 'aaa',
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
{
commit: 'bbb',
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
];
const result = assessProviderEvidence('aaa', records);
assert.equal(result.state, 'terminal-failure');
assert.match(result.detail, /duplicate pipeline.*across.*commit|global.*pipeline.*identity/i);
});
test('provider evidence distinguishes retained success, failure, and absent history', () => {
const pipelines = [
{
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { assessProviderEvidence } from './gate-history.mjs';
const records = [
{
commit: 'subject-a',
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
{
commit: 'subject-b',
number: 7,
status: 'success',
steps: [{ name: 'gate-verify', status: 'success' }],
},
];
const result = assessProviderEvidence('subject-a', records);
if (
result.state === 'terminal-failure' &&
/duplicate pipeline identity across commits/i.test(result.detail)
) {
process.stderr.write(`${result.detail}\n`);
process.exit(1);
}
process.stdout.write(
`cross-commit duplicate was not rejected: ${result.state} (${result.detail})\n`,
);
process.exit(0);
+236 -16
View File
@@ -288,6 +288,100 @@ function rejectDuplicateIds(values, label, failures) {
}
}
function requireString(value, label, failures, { allowEmpty = false } = {}) {
if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) {
failures.push(`${label}: expected ${allowEmpty ? 'a string' : 'a non-empty string'}`);
}
}
function validateStringArray(value, label, failures, { allowEmpty = false } = {}) {
if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) {
failures.push(`${label}: expected ${allowEmpty ? 'an array' : 'a non-empty array'}`);
return;
}
if (value.some((entry) => typeof entry !== 'string' || entry.length === 0)) {
failures.push(`${label}: entries must be non-empty strings`);
}
}
function validateOutcome(value, label, failures) {
rejectUnknownKeys(
value,
new Set(['exitCode', 'outputPattern', 'notOutputPattern']),
label,
failures,
);
if (typeof value?.exitCode !== 'number' || !Number.isInteger(value.exitCode)) {
failures.push(`${label}.exitCode: expected an integer`);
}
for (const key of ['outputPattern', 'notOutputPattern']) {
if (value?.[key] !== undefined && typeof value[key] !== 'string') {
failures.push(`${label}.${key}: expected a string`);
} else if (typeof value?.[key] === 'string' && value[key].trim().length === 0) {
failures.push(`${label}.${key}: expected a non-empty pattern`);
}
}
}
function validateFixture(fixture, label, failures) {
if (fixture === undefined) return;
rejectUnknownKeys(
fixture,
new Set(['writeFiles', 'replaceFiles', 'removePaths', 'copyPaths']),
label,
failures,
);
const writeFiles = Array.isArray(fixture?.writeFiles) ? fixture.writeFiles : [];
const replaceFiles = Array.isArray(fixture?.replaceFiles) ? fixture.replaceFiles : [];
for (const [index, entry] of writeFiles.entries()) {
rejectUnknownKeys(
entry,
new Set(['path', 'content', 'mode']),
`${label}.writeFiles[${index}]`,
failures,
);
requireString(entry?.path, `${label}.writeFiles[${index}].path`, failures);
if (typeof entry?.content !== 'string')
failures.push(`${label}.writeFiles[${index}].content: expected a string`);
if (entry?.mode !== undefined && (!Number.isInteger(entry.mode) || entry.mode < 0)) {
failures.push(`${label}.writeFiles[${index}].mode: expected a non-negative integer`);
}
}
for (const [index, entry] of replaceFiles.entries()) {
rejectUnknownKeys(
entry,
new Set(['path', 'find', 'replace']),
`${label}.replaceFiles[${index}]`,
failures,
);
requireString(entry?.path, `${label}.replaceFiles[${index}].path`, failures);
if (typeof entry?.find !== 'string')
failures.push(`${label}.replaceFiles[${index}].find: expected a string`);
if (typeof entry?.replace !== 'string')
failures.push(`${label}.replaceFiles[${index}].replace: expected a string`);
}
for (const key of ['removePaths', 'copyPaths']) {
if (fixture?.[key] !== undefined)
validateStringArray(fixture[key], `${label}.${key}`, failures, { allowEmpty: true });
}
for (const key of ['writeFiles', 'replaceFiles']) {
if (fixture?.[key] !== undefined && !Array.isArray(fixture[key])) {
failures.push(`${label}.${key}: expected an array`);
}
}
}
function validateEnvironment(environment, label, failures) {
if (environment === undefined) return;
if (!environment || typeof environment !== 'object' || Array.isArray(environment)) {
failures.push(`${label}: expected an object`);
return;
}
for (const [key, value] of Object.entries(environment)) {
if (!key || typeof value !== 'string') failures.push(`${label}.${key}: expected a string`);
}
}
function validateClosedSchema(manifest, failures) {
if (manifest.schemaVersion !== 1)
failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`);
@@ -295,7 +389,6 @@ function validateClosedSchema(manifest, failures) {
manifest,
new Set([
'schemaVersion',
'activationCommit',
'gateRoots',
'governingClaimFiles',
'coverageBoundary',
@@ -308,6 +401,43 @@ function validateClosedSchema(manifest, failures) {
'manifest',
failures,
);
validateStringArray(manifest.gateRoots, 'manifest.gateRoots', failures);
validateStringArray(manifest.governingClaimFiles, 'manifest.governingClaimFiles', failures, {
allowEmpty: true,
});
rejectUnknownKeys(
manifest.coverageBoundary,
new Set(['included', 'excluded', 'trackedBy']),
'coverageBoundary',
failures,
);
validateStringArray(manifest.coverageBoundary?.included, 'coverageBoundary.included', failures);
validateStringArray(manifest.coverageBoundary?.excluded, 'coverageBoundary.excluded', failures, {
allowEmpty: true,
});
requireString(manifest.coverageBoundary?.trackedBy, 'coverageBoundary.trackedBy', failures);
if (manifest.mergeAssertions !== undefined) {
rejectUnknownKeys(
manifest.mergeAssertions,
new Set([
'mode',
'providerEvidence',
'deferredReplayOwner',
'trustDependencies',
'postMergeResponse',
]),
'mergeAssertions',
failures,
);
for (const key of ['mode', 'providerEvidence', 'deferredReplayOwner', 'postMergeResponse']) {
requireString(manifest.mergeAssertions?.[key], `mergeAssertions.${key}`, failures);
}
validateStringArray(
manifest.mergeAssertions?.trustDependencies,
'mergeAssertions.trustDependencies',
failures,
);
}
rejectDuplicateIds(manifest.criteria, 'criterion', failures);
for (const criterion of manifest.criteria ?? []) {
rejectUnknownKeys(
@@ -324,6 +454,27 @@ function validateClosedSchema(manifest, failures) {
`criterion ${criterion.id}`,
failures,
);
for (const key of ['id', 'originalText', 'currentText', 'claimType', 'source']) {
requireString(criterion[key], `criterion ${criterion.id}.${key}`, failures);
}
if (!Array.isArray(criterion.meaningChanges)) {
failures.push(`criterion ${criterion.id}.meaningChanges: expected an array`);
}
for (const [index, change] of (criterion.meaningChanges ?? []).entries()) {
rejectUnknownKeys(
change,
new Set(['originalText', 'restatement', 'reason', 'finding', 'task', 'date']),
`criterion ${criterion.id}.meaningChanges[${index}]`,
failures,
);
for (const key of ['originalText', 'restatement', 'reason', 'finding', 'task', 'date']) {
requireString(
change?.[key],
`criterion ${criterion.id}.meaningChanges[${index}].${key}`,
failures,
);
}
}
if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) {
failures.push(`${criterion.id}: no declared exercising cases`);
} else {
@@ -343,6 +494,9 @@ function validateClosedSchema(manifest, failures) {
`prose claim ${claim.id}`,
failures,
);
for (const key of ['id', 'criterionId', 'caseRef']) {
requireString(claim[key], `GATE-CLAIM:${claim.id}.${key}`, failures);
}
if (typeof claim.caseRef !== 'string' || claim.caseRef.length === 0) {
failures.push(`GATE-CLAIM:${claim.id} has no declared exercising case`);
}
@@ -363,15 +517,22 @@ function validateClosedSchema(manifest, failures) {
`compatibility scenario ${scenario.id}`,
failures,
);
for (const key of ['id', 'construction']) {
requireString(scenario[key], `compatibility scenario ${scenario.id}.${key}`, failures);
}
if (!Array.isArray(scenario.caseRefs) || scenario.caseRefs.length === 0) {
failures.push(`${scenario.id}: compatibility construction has no referenced conditions`);
} else {
validateStringArray(scenario.caseRefs, `${scenario.id}.caseRefs`, failures);
}
if (!Array.isArray(scenario.invocation) || scenario.invocation.length === 0) {
failures.push(`${scenario.id}: compatibility construction invocation is missing`);
} else {
validateStringArray(scenario.invocation, `${scenario.id}.invocation`, failures);
}
if (typeof scenario.expected?.exitCode !== 'number') {
failures.push(`${scenario.id}: compatibility construction exact expected exit is missing`);
}
validateOutcome(scenario.expected, `${scenario.id}.expected`, failures);
validateEnvironment(scenario.environment, `${scenario.id}.environment`, failures);
validateFixture(scenario.fixture, `${scenario.id}.fixture`, failures);
}
rejectDuplicateIds(manifest.gates, 'gate', failures);
for (const gate of manifest.gates ?? []) {
@@ -389,13 +550,57 @@ function validateClosedSchema(manifest, failures) {
`gate ${gate.id}`,
failures,
);
requireString(gate.id, `gate ${gate.id}.id`, failures);
requireString(gate.source, `gate ${gate.id}.source`, failures);
rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures);
if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) {
failures.push(`${gate.id}: exact invocation is missing`);
} else {
validateStringArray(gate.invocation, `${gate.id}.invocation`, failures);
}
if (gate.discoveryAliases !== undefined) {
validateStringArray(gate.discoveryAliases, `${gate.id}.discoveryAliases`, failures, {
allowEmpty: true,
});
}
rejectUnknownKeys(
gate.deployment,
new Set(['kind', 'reason', 'source', 'path', 'unavailableOwner', 'observedSha256']),
`${gate.id}.deployment`,
failures,
);
if (!['none', 'file'].includes(gate.deployment?.kind)) {
failures.push(`${gate.id}: unsupported deployment kind ${String(gate.deployment?.kind)}`);
} else if (gate.deployment.kind === 'none') {
requireString(gate.deployment.reason, `${gate.id}.deployment.reason`, failures);
} else {
for (const key of ['source', 'path', 'unavailableOwner', 'observedSha256']) {
requireString(gate.deployment[key], `${gate.id}.deployment.${key}`, failures);
}
}
rejectUnknownKeys(
gate.inertMutation,
new Set(['file', 'find', 'replace', 'caseId', 'expected', 'sandboxFiles']),
`${gate.id}.inertMutation`,
failures,
);
for (const key of ['file', 'find', 'replace']) {
if (typeof gate.inertMutation?.[key] !== 'string') {
failures.push(`${gate.id}.inertMutation.${key}: expected a string`);
}
}
if (gate.inertMutation?.caseId !== undefined) {
requireString(gate.inertMutation.caseId, `${gate.id}.inertMutation.caseId`, failures);
}
if (gate.inertMutation?.sandboxFiles !== undefined) {
validateStringArray(
gate.inertMutation.sandboxFiles,
`${gate.id}.inertMutation.sandboxFiles`,
failures,
{ allowEmpty: true },
);
}
validateOutcome(gate.inertMutation?.expected, `${gate.id}.inertMutation.expected`, failures);
for (const gateCase of gate.cases ?? []) {
rejectUnknownKeys(
gateCase,
@@ -414,25 +619,40 @@ function validateClosedSchema(manifest, failures) {
`${gate.id}/${gateCase.id}`,
failures,
);
if (
typeof gateCase.required?.exitCode !== 'number' ||
typeof gateCase.actual?.exitCode !== 'number'
) {
failures.push(
`${gate.id}/${gateCase.id}: required and actual exact exit codes are mandatory`,
);
requireString(gateCase.id, `${gate.id}/${gateCase.id}.id`, failures);
validateStringArray(
gateCase.criterionIds,
`${gate.id}/${gateCase.id}.criterionIds`,
failures,
);
if (typeof gateCase.mustFail !== 'boolean') {
failures.push(`${gate.id}/${gateCase.id}.mustFail: expected a boolean`);
}
if (
gateCase.invocation &&
(!Array.isArray(gateCase.invocation) || gateCase.invocation.length === 0)
) {
failures.push(`${gate.id}/${gateCase.id}: case invocation must be non-empty`);
validateOutcome(gateCase.required, `${gate.id}/${gateCase.id}.required`, failures);
validateOutcome(gateCase.actual, `${gate.id}/${gateCase.id}.actual`, failures);
if (gateCase.invocation !== undefined) {
validateStringArray(gateCase.invocation, `${gate.id}/${gateCase.id}.invocation`, failures);
}
if (typeof gateCase.reasonPattern !== 'string') {
failures.push(`${gate.id}/${gateCase.id}.reasonPattern: expected a string`);
}
if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) {
failures.push(
`${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`,
);
}
validateEnvironment(gateCase.environment, `${gate.id}/${gateCase.id}.environment`, failures);
validateFixture(gateCase.fixture, `${gate.id}/${gateCase.id}.fixture`, failures);
if (gateCase.defect !== undefined) {
rejectUnknownKeys(
gateCase.defect,
new Set(['owner', 'reason']),
`${gate.id}/${gateCase.id}.defect`,
failures,
);
requireString(gateCase.defect.owner, `${gate.id}/${gateCase.id}.defect.owner`, failures);
requireString(gateCase.defect.reason, `${gate.id}/${gateCase.id}.defect.reason`, failures);
}
}
}
}
+111 -1
View File
@@ -18,7 +18,6 @@ async function fixture(name = 'case') {
function baseManifest() {
return {
schemaVersion: 1,
activationCommit: null,
gateRoots: ['gates'],
governingClaimFiles: [],
coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' },
@@ -157,6 +156,117 @@ test('duplicate stable ids and unsupported schema versions are rejected', async
assert.match(output(result), /duplicate criterion id META-CRIT-1/i);
});
test('misspelled nested assertion fields are rejected instead of becoming optional', async () => {
const root = await fixture('nested-schema-typo');
await writeGate(root);
const manifest = baseManifest();
manifest.gates[0].cases[0].required.outputPatern = 'META_REJECT';
manifest.gates[0].cases[0].actual.outputPatern = 'META_REJECT';
await writeManifest(root, manifest);
const result = verify(root);
assert.notEqual(result.status, 0);
assert.match(output(result), /required.*unknown field outputPatern/i);
assert.match(output(result), /actual.*unknown field outputPatern/i);
});
test('present outcome patterns cannot be empty assertion bypasses', async () => {
const root = await fixture('nested-schema-empty-patterns');
await writeGate(root);
const manifest = baseManifest();
manifest.gates[0].cases[0].required.outputPattern = '';
manifest.gates[0].cases[0].actual.notOutputPattern = ' ';
await writeManifest(root, manifest);
const result = verify(root, ['--structure-only']);
assert.notEqual(result.status, 0);
assert.match(output(result), /required.outputPattern: expected a non-empty pattern/i);
assert.match(output(result), /actual.notOutputPattern: expected a non-empty pattern/i);
});
test('nested discriminator and comparison fields reject wrong types', async () => {
const root = await fixture('nested-schema-types');
await writeGate(root);
const manifest = baseManifest();
manifest.gates[0].cases[0].mustFail = 'true';
manifest.gates[0].cases[0].required.exitCode = '7';
manifest.gates[0].cases[0].actual.outputPattern = 7;
await writeManifest(root, manifest);
const result = verify(root, ['--structure-only']);
assert.notEqual(result.status, 0);
assert.match(output(result), /mustFail: expected a boolean/i);
assert.match(output(result), /required.exitCode: expected an integer/i);
assert.match(output(result), /actual.outputPattern: expected a string/i);
});
test('recursive closed-schema guards reject unknown fields in every nested assertion object', async () => {
const source = JSON.parse(
await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'),
);
const checkout = source.gates.find((gate) => gate.id === 'checkout-preflight');
const stale = checkout.cases.find((gateCase) => gateCase.id === 'stale-build-lock');
const queue = source.gates.find((gate) => gate.id === 'ci-queue-wait');
const queueCase = queue.cases.find((gateCase) => gateCase.id === 'terminal-success');
const targets = [
['coverageBoundary', (manifest) => manifest.coverageBoundary],
['mergeAssertions', (manifest) => manifest.mergeAssertions],
[
'meaningChanges',
(manifest) =>
manifest.criteria.find((criterion) => criterion.meaningChanges.length).meaningChanges[0],
],
['proseClaims', (manifest) => manifest.proseClaims[0]],
['compatibility expected', (manifest) => manifest.compatibilityScenarios[0].expected],
[
'deployment',
(manifest) => manifest.gates.find((gate) => gate.id === 'ci-queue-wait').deployment,
],
['inertMutation', (manifest) => manifest.gates[0].inertMutation],
['inert expected', (manifest) => manifest.gates[0].inertMutation.expected],
['required', (manifest) => manifest.gates[0].cases[0].required],
['actual', (manifest) => manifest.gates[0].cases[0].actual],
[
'fixture',
(manifest) =>
manifest.gates
.find((gate) => gate.id === 'checkout-preflight')
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture,
],
[
'write entry',
(manifest) =>
manifest.gates
.find((gate) => gate.id === 'checkout-preflight')
.cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture.writeFiles[0],
],
[
'replace entry',
(manifest) =>
manifest.gates
.find((gate) => gate.id === 'checkout-preflight')
.cases.find((gateCase) => gateCase.id === 'criterion-misbinding').fixture.replaceFiles[0],
],
[
'defect',
(manifest) =>
manifest.gates
.find((gate) => gate.id === 'ci-queue-wait')
.cases.find((gateCase) => gateCase.id === 'terminal-success').defect,
],
];
assert.ok(stale.fixture && queueCase.defect);
for (const [name, select] of targets) {
const root = await fixture(`recursive-${name.replaceAll(' ', '-')}`);
const manifest = structuredClone(source);
select(manifest).unexpectedNestedField = true;
await writeManifest(root, manifest);
const result = verify(root, ['--structure-only']);
assert.notEqual(result.status, 0, `${name}: ${output(result)}`);
assert.match(output(result), /unknown field unexpectedNestedField/i, name);
}
});
test('manifest-controlled fixture paths cannot escape the sandbox', async () => {
const root = await fixture('path-traversal');
await writeGate(root);