feat(#1138): make runtime descriptors authoritative (#1157)

This commit is contained in:
Tom Boucher
2026-06-13 01:49:25 -04:00
committed by GitHub
parent bd349aa88e
commit aec3374bc2
40 changed files with 696 additions and 154 deletions

View File

@@ -231,6 +231,7 @@ const KEBAB_RE = /^[a-z][a-z0-9-]*$/;
const VALID_ROLES = new Set(['feature', 'runtime']);
const VALID_TIERS = new Set(['core', 'standard', 'full']);
const VALID_ON_ERROR = new Set(['skip', 'halt']);
const RUNTIME_COMPAT_WILDCARD = '*';
/**
* Validate a single capability declaration.
@@ -363,9 +364,74 @@ function validateCommandEntry(capId, entry, prefix) {
return errors;
}
function validateRuntimeCompat(capId, runtimeCompat) {
const errors = [];
const ctx = 'capability "' + capId + '" runtimeCompat';
if (typeof runtimeCompat !== 'object' || runtimeCompat === null || Array.isArray(runtimeCompat)) {
errors.push(ctx + ' must be an object with supported and unsupported arrays');
return errors;
}
const validateRuntimeArray = (field, { allowWildcard }) => {
const value = runtimeCompat[field];
if (!Array.isArray(value)) {
errors.push(ctx + '.' + field + ' must be an array of runtime ids' + (allowWildcard ? ' or ["*"]' : ''));
return;
}
if (field === 'supported' && value.length === 0) {
errors.push(ctx + '.supported must be a non-empty array');
}
let hasWildcard = false;
for (let i = 0; i < value.length; i++) {
const entry = value[i];
if (typeof entry !== 'string' || entry.length === 0) {
errors.push(ctx + '.' + field + '[' + i + '] must be a non-empty string');
continue;
}
if (entry === '__proto__' || entry === 'constructor' || entry === 'prototype') {
errors.push(ctx + '.' + field + '[' + i + '] "' + entry + '" is a reserved name');
}
if (entry === RUNTIME_COMPAT_WILDCARD) {
if (!allowWildcard) {
errors.push(ctx + '.' + field + ' must not include wildcard "*"');
}
hasWildcard = true;
} else if (!KEBAB_RE.test(entry)) {
errors.push(ctx + '.' + field + '[' + i + '] must be a kebab-case runtime id or "*"');
}
}
if (hasWildcard && value.length > 1) {
errors.push(ctx + '.' + field + ' wildcard "*" cannot be mixed with runtime ids');
}
};
validateRuntimeArray('supported', { allowWildcard: true });
validateRuntimeArray('unsupported', { allowWildcard: false });
if (runtimeCompat.notes !== undefined) {
if (typeof runtimeCompat.notes !== 'object' || runtimeCompat.notes === null || Array.isArray(runtimeCompat.notes)) {
errors.push(ctx + '.notes must be an object of runtime id to string if present');
} else {
for (const [key, value] of Object.entries(runtimeCompat.notes)) {
if (key !== RUNTIME_COMPAT_WILDCARD && !KEBAB_RE.test(key)) {
errors.push(ctx + '.notes key "' + key + '" must be a kebab-case runtime id or "*"');
}
if (typeof value !== 'string' || value.length === 0) {
errors.push(ctx + '.notes["' + key + '"] must be a non-empty string');
}
}
}
}
return errors;
}
function validateFeatureBody(cap) {
const errors = [];
errors.push(...validateRuntimeCompat(cap.id || '(unknown)', cap.runtimeCompat));
if (!Array.isArray(cap.skills)) {
errors.push('skills must be an array of strings');
} else {
@@ -1430,6 +1496,39 @@ function validateCrossCapability(capMap, centralKeys) {
}
}
// runtimeCompat: explicit runtime ids must reference runtime capabilities.
// The wildcard "*" means descriptor-backed runtimes are supported by default.
const runtimeIds = new Set();
for (const [id, cap] of capMap) {
if (cap.role === 'runtime') runtimeIds.add(id);
}
for (const [capId, cap] of capMap) {
if (cap.role !== 'feature' || typeof cap.runtimeCompat !== 'object' || cap.runtimeCompat === null) continue;
for (const field of ['supported', 'unsupported']) {
const entries = Array.isArray(cap.runtimeCompat[field]) ? cap.runtimeCompat[field] : [];
for (const runtimeId of entries) {
if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
if (typeof runtimeId !== 'string' || runtimeId.length === 0) continue;
if (!runtimeIds.has(runtimeId)) {
errors.push(
'capability "' + capId + '" runtimeCompat.' + field +
' references unknown runtime "' + runtimeId + '"',
);
}
}
}
if (cap.runtimeCompat.notes && typeof cap.runtimeCompat.notes === 'object') {
for (const runtimeId of Object.keys(cap.runtimeCompat.notes)) {
if (runtimeId === RUNTIME_COMPAT_WILDCARD) continue;
if (!runtimeIds.has(runtimeId)) {
errors.push(
'capability "' + capId + '" runtimeCompat.notes references unknown runtime "' + runtimeId + '"',
);
}
}
}
}
// requires: acyclic
const cycleErrors = detectRequiresCycles(capMap);
errors.push(...cycleErrors);
@@ -2401,6 +2500,7 @@ module.exports = {
runConsistencyGate,
// ADR-959: command entry validation
validateCommandEntry,
validateRuntimeCompat,
// ADR-1016 phase 5a: runtime body validators + closed-vocab sets
validateConfigHome,
validateArtifactLayout,

View File

@@ -57,6 +57,11 @@ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
// https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories
// ). Raise to 600 s (the same ceiling the before() helper uses for pack+install).
const CHILD_TIMEOUT_MS = process.platform === 'win32' ? 600_000 : 120_000;
const QUIET_NPM_ENV = Object.freeze({
npm_config_loglevel: 'error',
npm_config_update_notifier: 'false',
NO_UPDATE_NOTIFIER: '1',
});
// ---------------------------------------------------------------------------
// Frozen result-code enum
@@ -304,7 +309,7 @@ function runSmoke({
// Use the caller-supplied npmEnv if provided (allows HOME isolation on Docker
// hosts where HOME may be unwritable — same pattern as runNpm() in helpers.cjs).
// Falls back to process.env to preserve existing CLI / programmatic behaviour. (#131)
const effectiveNpmEnv = npmEnv !== undefined ? npmEnv : process.env;
const effectiveNpmEnv = { ...(npmEnv !== undefined ? npmEnv : process.env), ...QUIET_NPM_ENV };
const installResult = spawnSync(
npmCmd,
['install', '-g', '--prefix', installPrefix, tarballPath],
@@ -570,6 +575,7 @@ function cliMain() {
encoding: 'utf-8',
shell: process.platform === 'win32',
timeout: CHILD_TIMEOUT_MS,
env: { ...process.env, ...QUIET_NPM_ENV },
},
).trim();
// npm pack outputs the filename on stdout (last line when verbose)