This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user