@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user