* feat(#1431): runtime capability registry overlay (ADR-1244 Phase 2) Promote the registry from a frozen data file to loadRegistry({includeInstalled}), composing the first-party registry with a validated installed overlay (ADR-1244 D2): - Extract the conformance validator to a shared runtime-callable module (gsd-core/bin/lib/capability-validator.cjs); the generator re-exports it verbatim, guarded by a generative-parity test (no build-time/runtime drift). - capability-loader.cts: loadRegistry({includeInstalled}) composes first-party ∪ validated overlay from $GSD_HOME/.gsd/capabilities (global) and <root>/.gsd/capabilities (project) via the canonical buildRegistry. First-party always wins (id/skill/agent/config/command-family + reserved gsd-/anthropic- prefixes); full merged-set cross-capability validation; engines.gsd load-time re-gate (skip-with-warning); gate-kind capabilities FAIL CLOSED; fragment-path escapes rejected. - semverSatisfies (hand-written, no dep) for the engines.gsd gate, fail-closed. - Wire surface/state + loop to the overlay; loop injects a blocking gate for each skipped gate-kind overlay (fail-closed). - cwd-aware overlay config-key federation: config-loader _federatedConfigSchema(cwd) + config-schema isValidConfigKey(key, cwd) compose the overlay per loadConfig/ config-set call (never eager at module load, never wrong-cwd); first-party path unchanged with no cwd. - run-tests.cjs sandboxes GSD_HOME (idempotent — nested spawns reuse it) for test hermeticity; capability-loader.cjs git+eslint-ignored (tsc artifact); capability-validator.cjs stays linted (#551 migration coverage). Closes #1431 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#1431): add changeset for runtime capability registry overlay Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#1431): kill config-schema cwd-aware federation mutants (Stryker ≥52) The cwd-aware overlay config-key federation added to config-schema.cts (_capabilityConfigSchema(cwd) + isCapabilityConfigKey/isValidConfigKey cwd threading) introduced mutable surface uncovered by config-schema's mutation test set, dropping its score to 39.58% (below the 52 break threshold). Add a real-overlay-fixture describe block exercising every branch (cwd guard, overlay loadRegistry, found-branch, first-party fallback, cwd threading); local Stryker score 39.58% -> 77.08%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
367
src/capability-loader.cts
Normal file
367
src/capability-loader.cts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* capability-loader.cts — runtime Capability Registry overlay (ADR-1244 D2).
|
||||
*
|
||||
* Promotes the registry from a frozen data file to a module with an interface:
|
||||
*
|
||||
* loadRegistry({ includeInstalled }) -> composed registry
|
||||
*
|
||||
* It composes the **first-party frozen registry** (the committed, generated
|
||||
* `capability-registry.cjs`) with a **validated installed overlay** — third-party
|
||||
* capability manifests read at runtime from per-scope install roots:
|
||||
* - global: $GSD_HOME/.gsd/capabilities/<id>/capability.json (GSD_HOME defaults to ~)
|
||||
* - project: <projectRoot>/.gsd/capabilities/<id>/capability.json
|
||||
*
|
||||
* Invariants enforced over the merged set (first-party ∪ overlay):
|
||||
* - First-party always wins: an overlay whose `id`, owned skill/agent stem, or
|
||||
* federated config key collides with first-party (or uses a reserved `gsd-` /
|
||||
* `gsd-core-` / `anthropic-` id prefix) is rejected.
|
||||
* - Load-time re-gate (default-resilient): an overlay that fails validation or
|
||||
* whose `engines.gsd` does not satisfy the running GSD version is SKIPPED
|
||||
* with a warning — it never crashes the loop. EXCEPTION (per-hook-kind
|
||||
* policy): a skipped capability that declares a `gate` is recorded in
|
||||
* `_overlay.incompatibleGateCapIds` so the loop resolver can fail CLOSED for
|
||||
* that gate rather than silently proceeding as if it had passed.
|
||||
*
|
||||
* The merged registry is materialized by the canonical `buildRegistry`
|
||||
* (re-exported from the generator, which ships) over a cap-map reconstructed
|
||||
* from the frozen registry's capability objects plus the accepted overlay
|
||||
* capabilities — so every derived view (bySkill, byLoopPoint, configSchema,
|
||||
* capabilityClusters, profileMembership, …) is computed by exactly one builder
|
||||
* and cannot drift from the first-party path.
|
||||
*
|
||||
* Install never executes capability code here (staging/exec belongs to ADR-1244
|
||||
* D3/D5); this module only READS and VALIDATES declarations.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
type Registry = Record<string, unknown>;
|
||||
|
||||
interface CapManifest {
|
||||
id: string;
|
||||
role?: string;
|
||||
version?: string;
|
||||
skills?: string[];
|
||||
agents?: string[];
|
||||
commands?: Array<Record<string, unknown>>;
|
||||
config?: Record<string, unknown>;
|
||||
gates?: unknown[];
|
||||
engines?: { gsd?: string };
|
||||
}
|
||||
|
||||
interface ValidatorModule {
|
||||
validateCapability: (cap: unknown, id: string) => string[];
|
||||
/** Returns an error array (e.g. fragment path escapes the capability dir) — NOT a throw. */
|
||||
materializeHookFragments: (cap: unknown, capDir: string) => string[];
|
||||
validateAgainstContract: (cap: unknown, capId: string) => string[];
|
||||
validateConsumesGlobal: (capMap: Map<string, unknown>) => string[];
|
||||
validateCrossCapability: (capMap: Map<string, unknown>, centralKeys: Set<string>) => string[];
|
||||
}
|
||||
interface SemverModule {
|
||||
semverSatisfies: (version: unknown, range: unknown) => boolean;
|
||||
}
|
||||
interface ProjectRootModule {
|
||||
findProjectRoot: (startDir: string) => string | null;
|
||||
}
|
||||
interface GeneratorModule {
|
||||
buildRegistry: (capMap: Map<string, unknown>) => Registry;
|
||||
loadCentralConfigKeys: () => Set<string>;
|
||||
}
|
||||
|
||||
export interface LoadRegistryOptions {
|
||||
/** When true, compose the validated installed overlay on top of first-party. */
|
||||
includeInstalled?: boolean;
|
||||
/** Working directory used to locate the project-scoped overlay root. */
|
||||
cwd?: string;
|
||||
/** Override the global overlay home (defaults to GSD_HOME env or os.homedir()). */
|
||||
gsdHome?: string;
|
||||
/** Override the running GSD version used for engines.gsd satisfaction. */
|
||||
hostVersion?: string;
|
||||
}
|
||||
|
||||
export interface OverlaySkip {
|
||||
id: string;
|
||||
scope: 'global' | 'project';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BlockedGate {
|
||||
/** Loop extension point the skipped capability declared a gate at. */
|
||||
point: string;
|
||||
/** The skipped capability's id. */
|
||||
capId: string;
|
||||
/** Why the capability was skipped. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface OverlayMeta {
|
||||
/** Capabilities skipped at load, with the reason (surfaced to the user). */
|
||||
warnings: OverlaySkip[];
|
||||
/** Skipped capabilities that declared a gate — the loop must fail CLOSED for these. */
|
||||
incompatibleGateCapIds: string[];
|
||||
/**
|
||||
* Per-point fail-closed records: for each gate a skipped capability declared at
|
||||
* a known loop point, the loop resolver must inject a blocking gate at that
|
||||
* point rather than proceeding as if the gate had passed.
|
||||
*/
|
||||
blockedGates: BlockedGate[];
|
||||
}
|
||||
|
||||
const RESERVED_ID_PREFIX = /^(gsd-|gsd-core-|anthropic-)/;
|
||||
const GSD_HOME_DIRNAME = '.gsd';
|
||||
|
||||
function errMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
/** Resolve the running GSD version; fail-closed to '0.0.0' if it cannot be read. */
|
||||
function readHostVersion(): string {
|
||||
try {
|
||||
// gsd-core/bin/lib/ -> repo/package root is three levels up.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const pkg: { version?: string } = require('../../../package.json');
|
||||
return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
|
||||
} catch {
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered overlay install roots (global first, then project), deduped by
|
||||
* resolved absolute path so a single directory is never scanned twice (which
|
||||
* would otherwise self-report a spurious id collision when the project lives
|
||||
* under the GSD home, or in tests where both resolve to the same fixture).
|
||||
*/
|
||||
function overlayRoots(cwd: string, gsdHome?: string): Array<{ dir: string; scope: 'global' | 'project' }> {
|
||||
const roots: Array<{ dir: string; scope: 'global' | 'project' }> = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (dir: string, scope: 'global' | 'project'): void => {
|
||||
const resolved = path.resolve(dir);
|
||||
if (seen.has(resolved)) return;
|
||||
seen.add(resolved);
|
||||
roots.push({ dir: resolved, scope });
|
||||
};
|
||||
const home = gsdHome || process.env['GSD_HOME'] || os.homedir();
|
||||
add(path.join(home, GSD_HOME_DIRNAME, 'capabilities'), 'global');
|
||||
let projectRoot: string | null = null;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const projectRootMod: ProjectRootModule = require('./project-root.cjs');
|
||||
projectRoot = projectRootMod.findProjectRoot(cwd);
|
||||
} catch {
|
||||
projectRoot = null;
|
||||
}
|
||||
if (projectRoot) {
|
||||
add(path.join(projectRoot, GSD_HOME_DIRNAME, 'capabilities'), 'project');
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** Shallow-attach overlay diagnostics WITHOUT mutating the frozen registry module. */
|
||||
function withOverlayMeta(reg: Registry, meta: OverlayMeta): Registry {
|
||||
return Object.assign({}, reg, { _overlay: meta });
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the capability registry, optionally composing the installed overlay.
|
||||
*
|
||||
* @returns the registry object (same shape as `capability-registry.cjs`). When
|
||||
* overlays are considered, an `_overlay` field carries skip warnings and the
|
||||
* fail-closed gate list. With `includeInstalled` falsy, the frozen first-party
|
||||
* registry is returned unchanged (identity-stable).
|
||||
*/
|
||||
export function loadRegistry(options: LoadRegistryOptions = {}): Registry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const base: Registry = require('./capability-registry.cjs');
|
||||
if (!options.includeInstalled) return base;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const validator: ValidatorModule = require('./capability-validator.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const semver: SemverModule = require('./semver-compare.cjs');
|
||||
|
||||
const cwd = options.cwd || process.cwd();
|
||||
const hostVersion = options.hostVersion || readHostVersion();
|
||||
|
||||
const warnings: OverlaySkip[] = [];
|
||||
const incompatibleGateCapIds: string[] = [];
|
||||
const blockedGates: BlockedGate[] = [];
|
||||
const overlayCaps: CapManifest[] = [];
|
||||
|
||||
// First-party reservations — first-party always wins.
|
||||
const fpCaps = (base.capabilities ?? {}) as Record<string, unknown>;
|
||||
const fpBySkill = (base.bySkill ?? {}) as Record<string, unknown>;
|
||||
const fpByAgent = (base.byAgent ?? {}) as Record<string, unknown>;
|
||||
const fpConfigKeys = (base.configKeys ?? {}) as Record<string, unknown>;
|
||||
const fpConfigSchema = (base.configSchema ?? {}) as Record<string, unknown>;
|
||||
|
||||
const fpFamilies = (base.commandFamilies ?? {}) as Record<string, unknown>;
|
||||
const fpIds = new Set(Object.keys(fpCaps));
|
||||
const claimedSkills = new Set(Object.keys(fpBySkill));
|
||||
const claimedAgents = new Set(Object.keys(fpByAgent));
|
||||
const claimedConfig = new Set([...Object.keys(fpConfigKeys), ...Object.keys(fpConfigSchema)]);
|
||||
const claimedFamilies = new Set(Object.keys(fpFamilies));
|
||||
const acceptedIds = new Set<string>();
|
||||
|
||||
// Running merged cap-map (first-party ∪ accepted overlays). A candidate is
|
||||
// accepted only if the FULL cross-capability suite stays clean after adding it
|
||||
// (first-party alone is clean, so any new error is the candidate's fault) — the
|
||||
// overlay can never violate the same invariants the build-time generator enforces.
|
||||
const acceptedMap = new Map<string, unknown>(Object.entries(fpCaps));
|
||||
|
||||
// Generator (buildRegistry + central config keys) loaded lazily — only when at
|
||||
// least one overlay candidate exists, so the no-overlay fast path stays cheap.
|
||||
let generatorMod: GeneratorModule | null = null;
|
||||
const getGenerator = (): GeneratorModule => {
|
||||
if (generatorMod) return generatorMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const mod: GeneratorModule = require('../../../scripts/gen-capability-registry.cjs');
|
||||
generatorMod = mod;
|
||||
return mod;
|
||||
};
|
||||
let centralKeys: Set<string> | null = null;
|
||||
const getCentralKeys = (): Set<string> => {
|
||||
if (!centralKeys) {
|
||||
try {
|
||||
centralKeys = getGenerator().loadCentralConfigKeys();
|
||||
} catch {
|
||||
centralKeys = new Set<string>();
|
||||
}
|
||||
}
|
||||
return centralKeys;
|
||||
};
|
||||
|
||||
for (const root of overlayRoots(cwd, options.gsdHome)) {
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(root.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue; // no overlay dir at this scope — normal
|
||||
}
|
||||
for (const ent of entries) {
|
||||
if (!ent.isDirectory()) continue;
|
||||
const id = ent.name;
|
||||
const capDir = path.join(root.dir, id);
|
||||
const manifestPath = path.join(capDir, 'capability.json');
|
||||
|
||||
let cap: CapManifest;
|
||||
try {
|
||||
cap = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as CapManifest;
|
||||
} catch (e) {
|
||||
warnings.push({ id, scope: root.scope, reason: 'unreadable or invalid capability.json: ' + errMessage(e) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Points at which this capability declares a gate — used to fail CLOSED if
|
||||
// the capability is skipped (a skipped deploy gate must block, not pass).
|
||||
const gatePoints: string[] = Array.isArray(cap.gates)
|
||||
? (cap.gates as Array<Record<string, unknown>>)
|
||||
.map((g) => (g && typeof g === 'object' && typeof g.point === 'string' ? g.point : null))
|
||||
.filter((p): p is string => typeof p === 'string')
|
||||
: [];
|
||||
const declaresGate = gatePoints.length > 0;
|
||||
const skip = (reason: string): void => {
|
||||
warnings.push({ id, scope: root.scope, reason });
|
||||
if (declaresGate) {
|
||||
incompatibleGateCapIds.push(id);
|
||||
for (const point of gatePoints) blockedGates.push({ point, capId: id, reason });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Reserved namespace — third-party may not impersonate first-party.
|
||||
if (RESERVED_ID_PREFIX.test(id)) {
|
||||
skip('id uses a reserved first-party prefix (gsd-/gsd-core-/anthropic-)');
|
||||
continue;
|
||||
}
|
||||
// 2. Per-capability structural + version-envelope validation.
|
||||
const errs = validator.validateCapability(cap, id);
|
||||
if (errs.length) {
|
||||
skip('failed validation: ' + errs.join('; '));
|
||||
continue;
|
||||
}
|
||||
// 3. First-party wins + overlay/overlay de-dup on id, skill, agent, config key.
|
||||
if (fpIds.has(id) || acceptedIds.has(id)) {
|
||||
skip('id collides with an already-registered capability');
|
||||
continue;
|
||||
}
|
||||
const skills: string[] = Array.isArray(cap.skills) ? cap.skills : [];
|
||||
const agents: string[] = Array.isArray(cap.agents) ? cap.agents : [];
|
||||
const cfgKeys: string[] = cap.config && typeof cap.config === 'object' && !Array.isArray(cap.config)
|
||||
? Object.keys(cap.config) : [];
|
||||
const skillClash = skills.find((s) => claimedSkills.has(s));
|
||||
if (skillClash) { skip('owns skill "' + skillClash + '" already owned by another capability'); continue; }
|
||||
const agentClash = agents.find((a) => claimedAgents.has(a));
|
||||
if (agentClash) { skip('owns agent "' + agentClash + '" already owned by another capability'); continue; }
|
||||
const cfgClash = cfgKeys.find((k) => claimedConfig.has(k));
|
||||
if (cfgClash) { skip('owns config key "' + cfgClash + '" already owned by another capability'); continue; }
|
||||
const families: string[] = Array.isArray(cap.commands)
|
||||
? cap.commands
|
||||
.map((c) => (c && typeof c === 'object' && typeof c.family === 'string' ? c.family : null))
|
||||
.filter((f): f is string => typeof f === 'string')
|
||||
: [];
|
||||
const familyClash = families.find((f) => claimedFamilies.has(f));
|
||||
if (familyClash) { skip('owns command family "' + familyClash + '" already owned by another capability'); continue; }
|
||||
// 4. Load-time engines.gsd re-gate.
|
||||
const range = cap.engines?.gsd;
|
||||
if (typeof range === 'string' && range && !semver.semverSatisfies(hostVersion, range)) {
|
||||
skip('incompatible with GSD ' + hostVersion + ' (requires engines.gsd "' + range + '")');
|
||||
continue;
|
||||
}
|
||||
// 5. Materialize path-based hook fragments (resolved against the overlay dir).
|
||||
// materializeHookFragments RETURNS errors (e.g. a fragment path escaping the
|
||||
// capability dir) — capture them; an un-materializable fragment is a skip.
|
||||
let fragErrs: string[];
|
||||
try {
|
||||
fragErrs = validator.materializeHookFragments(cap, capDir) || [];
|
||||
} catch (e) {
|
||||
skip('hook fragment could not be materialized: ' + errMessage(e));
|
||||
continue;
|
||||
}
|
||||
if (fragErrs.length) {
|
||||
skip('invalid hook fragment: ' + fragErrs.join('; '));
|
||||
continue;
|
||||
}
|
||||
// 6. Full cross-capability validation over the merged set (the same invariants
|
||||
// the build-time generator enforces): contract roles, consumes-satisfiability,
|
||||
// owner-uniqueness, config-key exclusivity vs central schema, requires acyclicity
|
||||
// + tier-monotone. Incremental: add the candidate, validate, drop on any error.
|
||||
acceptedMap.set(id, cap);
|
||||
const crossErrs = [
|
||||
...validator.validateAgainstContract(cap, id),
|
||||
...validator.validateConsumesGlobal(acceptedMap),
|
||||
...validator.validateCrossCapability(acceptedMap, getCentralKeys()),
|
||||
];
|
||||
if (crossErrs.length) {
|
||||
acceptedMap.delete(id);
|
||||
skip('cross-capability validation failed: ' + crossErrs.slice(0, 3).join('; '));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accepted.
|
||||
overlayCaps.push(cap);
|
||||
acceptedIds.add(id);
|
||||
for (const s of skills) claimedSkills.add(s);
|
||||
for (const a of agents) claimedAgents.add(a);
|
||||
for (const k of cfgKeys) claimedConfig.add(k);
|
||||
for (const f of families) claimedFamilies.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
const meta: OverlayMeta = { warnings, incompatibleGateCapIds, blockedGates };
|
||||
|
||||
if (overlayCaps.length === 0) {
|
||||
// Nothing to compose. Return the frozen registry unchanged when there is
|
||||
// also nothing to report (identity-stable); otherwise attach diagnostics.
|
||||
if (warnings.length === 0) return base;
|
||||
return withOverlayMeta(base, meta);
|
||||
}
|
||||
|
||||
// Compose via the canonical builder so every derived view matches first-party.
|
||||
// acceptedMap already holds first-party ∪ accepted overlays (validated above).
|
||||
const merged = getGenerator().buildRegistry(acceptedMap);
|
||||
return withOverlayMeta(merged, meta);
|
||||
}
|
||||
|
||||
module.exports = { loadRegistry };
|
||||
@@ -477,13 +477,13 @@ function resolveCapabilityRuntimeState(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load registry (ADR-857 phase 4c) ────────────────────────────────────────
|
||||
// Load BEFORE resolveProfile and resolveSurface so both calls receive the
|
||||
// registry and capability-contributed skills are reflected in installed/surfaced.
|
||||
// No-op today (UI capability is tier:full → only adds to 'full', which returns
|
||||
// '*' regardless) but cutover-ready for future tier:core/standard capabilities.
|
||||
// ── Load registry (ADR-1244 D2 wiring) ──────────────────────────────────────
|
||||
// Load overlay-aware registry BEFORE resolveProfile and resolveSurface so both
|
||||
// calls receive the composed registry and installed third-party capabilities are
|
||||
// reflected in installed/surfaced state exactly like first-party capabilities.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
|
||||
const registry = loadRegistry({ includeInstalled: true, cwd });
|
||||
|
||||
// ── Resolve installed skills (from install profile) ──────────────────────────
|
||||
// Distinguish "no profile marker → default full" (legitimate) from a thrown
|
||||
|
||||
@@ -42,6 +42,10 @@ import federatedConfigModule = require('./federated-config.cjs');
|
||||
const { mergeFederatedConfig } = federatedConfigModule;
|
||||
// The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
|
||||
// Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
|
||||
// This is the FROZEN first-party registry — used as the test-seam default and the
|
||||
// fallback. Overlay (installed third-party) config-key federation is cwd-dependent
|
||||
// and composed PER loadConfig CALL by _federatedConfigSchema(cwd) below (ADR-1244 D2),
|
||||
// never eagerly at module load.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const _capabilityRegistryReal: { configSchema?: Record<string, unknown> } = require('./capability-registry.cjs');
|
||||
|
||||
@@ -349,11 +353,33 @@ function _applyFederatedValues(
|
||||
* When validKeys is non-empty, applies values into a shallow clone to avoid
|
||||
* mutating shared CONFIG_DEFAULTS/module constants.
|
||||
*/
|
||||
// Resolve the federated capability config-schema for a project (ADR-1244 D2).
|
||||
// A test override (via _setFederatedRegistryForTests) wins; otherwise, when a
|
||||
// project cwd is available, compose the installed overlay for THAT project —
|
||||
// LAZILY (never at module load, so a bare require never scans the filesystem and
|
||||
// the result is never cached for the wrong cwd) — falling back to the frozen
|
||||
// first-party schema when there is no cwd or the loader is unavailable.
|
||||
function _federatedConfigSchema(cwd?: string): Record<string, unknown> | undefined {
|
||||
if (_capabilityRegistry !== _capabilityRegistryReal) {
|
||||
return _capabilityRegistry.configSchema; // explicit test override
|
||||
}
|
||||
if (typeof cwd === 'string' && cwd) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
|
||||
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema;
|
||||
if (schema && typeof schema === 'object') return schema;
|
||||
} catch { /* fall back to first-party */ }
|
||||
}
|
||||
return _capabilityRegistryReal.configSchema;
|
||||
}
|
||||
|
||||
function _applyFederatedOverlay(
|
||||
baseConfig: Record<string, unknown>,
|
||||
userConfig: Record<string, unknown>,
|
||||
cwd?: string,
|
||||
): Record<string, unknown> {
|
||||
const _fedRegistrySchema = _capabilityRegistry.configSchema;
|
||||
const _fedRegistrySchema = _federatedConfigSchema(cwd);
|
||||
if (!_fedRegistrySchema || typeof _fedRegistrySchema !== 'object') return baseConfig;
|
||||
const _fedOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchema,
|
||||
@@ -508,7 +534,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
|
||||
|
||||
let _preWarningFedValidKeys: string[] = [];
|
||||
try {
|
||||
const _fedRegistrySchemaEarly = _capabilityRegistry.configSchema;
|
||||
const _fedRegistrySchemaEarly = _federatedConfigSchema(cwd);
|
||||
if (_fedRegistrySchemaEarly && typeof _fedRegistrySchemaEarly === 'object') {
|
||||
const _earlyOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchemaEarly,
|
||||
@@ -613,7 +639,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
|
||||
// ADR-857 phase 3b: federated config overlay
|
||||
try {
|
||||
if (_preWarningFedValidKeys.length > 0) {
|
||||
const _fedRegistrySchema = _capabilityRegistry.configSchema;
|
||||
const _fedRegistrySchema = _federatedConfigSchema(cwd);
|
||||
if (_fedRegistrySchema && typeof _fedRegistrySchema === 'object') {
|
||||
const _fedOverlay = mergeFederatedConfig({
|
||||
configSchema: _fedRegistrySchema,
|
||||
@@ -651,7 +677,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
|
||||
}
|
||||
// Branch C: .planning/ exists but no config.json and no root config — federated/builtin defaults
|
||||
try {
|
||||
return { config: _applyFederatedOverlay(defaults, {}), source: 'builtin-defaults', degraded: false };
|
||||
return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
|
||||
} catch {
|
||||
return { config: defaults, source: 'builtin-defaults', degraded: false };
|
||||
}
|
||||
@@ -692,14 +718,14 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
|
||||
};
|
||||
// Branch D: global-defaults
|
||||
try {
|
||||
return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults), source: 'global-defaults', degraded: false };
|
||||
return { config: _applyFederatedOverlay(_globalBaseCfg, globalDefaults, cwd), source: 'global-defaults', degraded: false };
|
||||
} catch {
|
||||
return { config: _globalBaseCfg, source: 'global-defaults', degraded: false };
|
||||
}
|
||||
} catch {
|
||||
// Branch E: no global defaults
|
||||
try {
|
||||
return { config: _applyFederatedOverlay(defaults, {}), source: 'builtin-defaults', degraded: false };
|
||||
return { config: _applyFederatedOverlay(defaults, {}, cwd), source: 'builtin-defaults', degraded: false };
|
||||
} catch {
|
||||
return { config: defaults, source: 'builtin-defaults', degraded: false };
|
||||
}
|
||||
|
||||
@@ -21,16 +21,33 @@ import {
|
||||
DYNAMIC_KEY_PATTERNS,
|
||||
} from './configuration.cjs';
|
||||
|
||||
// Frozen first-party capability config-schema — the fallback when no project cwd
|
||||
// is available (cwd-agnostic call sites).
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const capabilityRegistry = require('./capability-registry.cjs') as {
|
||||
configSchema?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function isCapabilityConfigKey(keyPath: string): boolean {
|
||||
// Resolve the capability config-schema for a project (ADR-1244 D2). When a cwd is
|
||||
// supplied, compose installed overlay capabilities for THAT project — LAZILY (never
|
||||
// at module load: a bare require of this module never scans the filesystem) —
|
||||
// falling back to the frozen first-party schema. Without a cwd, first-party only.
|
||||
function _capabilityConfigSchema(cwd?: string): Record<string, unknown> {
|
||||
if (typeof cwd === 'string' && cwd) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
||||
const loaderMod: { loadRegistry: (o?: Record<string, unknown>) => { configSchema?: Record<string, unknown> } } = require('./capability-loader.cjs');
|
||||
const schema = loaderMod.loadRegistry({ includeInstalled: true, cwd }).configSchema;
|
||||
if (schema && typeof schema === 'object') return schema;
|
||||
} catch { /* fall back to first-party */ }
|
||||
}
|
||||
const fp = capabilityRegistry.configSchema;
|
||||
return fp && typeof fp === 'object' ? fp : {};
|
||||
}
|
||||
|
||||
function isCapabilityConfigKey(keyPath: string, cwd?: string): boolean {
|
||||
if (typeof keyPath !== 'string') return false;
|
||||
const schema = capabilityRegistry.configSchema;
|
||||
if (!schema || typeof schema !== 'object') return false;
|
||||
return Object.prototype.hasOwnProperty.call(schema, keyPath);
|
||||
return Object.prototype.hasOwnProperty.call(_capabilityConfigSchema(cwd), keyPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,9 +65,9 @@ function isCentralConfigKey(keyPath: string): boolean {
|
||||
* Returns true if keyPath is a valid central, runtime-state, dynamic, or
|
||||
* federated Capability config key.
|
||||
*/
|
||||
function isValidConfigKey(keyPath: string): boolean {
|
||||
function isValidConfigKey(keyPath: string, cwd?: string): boolean {
|
||||
if (isCentralConfigKey(keyPath)) return true;
|
||||
return isCapabilityConfigKey(keyPath);
|
||||
return isCapabilityConfigKey(keyPath, cwd);
|
||||
}
|
||||
|
||||
export = {
|
||||
|
||||
@@ -560,7 +560,7 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
|
||||
|
||||
validateKnownConfigKeyPath(kp);
|
||||
|
||||
if (!isValidConfigKey(kp)) {
|
||||
if (!isValidConfigKey(kp, cwd)) {
|
||||
error(`Unknown config key: "${kp}". Valid keys: ${[...VALID_CONFIG_KEYS].sort().join(', ')}, agent_skills.<agent-type>, features.<feature_name>`, ERROR_REASON.CONFIG_INVALID_KEY);
|
||||
}
|
||||
|
||||
|
||||
@@ -492,9 +492,11 @@ function cmdLoopRenderHooks(
|
||||
warnings?: string[];
|
||||
capabilities: Array<{ id: string; enabled?: boolean; active: boolean }>;
|
||||
};
|
||||
// Registry is the static generated module — same object capability-state uses internally.
|
||||
// Load overlay-aware registry (ADR-1244 D2 wiring) so installed third-party
|
||||
// capabilities are visible to loop rendering exactly like first-party ones.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const registry = require('./capability-registry.cjs') as Record<string, unknown>;
|
||||
const { loadRegistry } = require('./capability-loader.cjs') as { loadRegistry: (opts?: Record<string, unknown>) => Record<string, unknown> };
|
||||
const registry = loadRegistry({ includeInstalled: true, cwd });
|
||||
const capabilityStatesById = new Map<string, { enabled?: boolean; active: boolean }>();
|
||||
for (const cap of state.capabilities || []) {
|
||||
capabilityStatesById.set(cap.id, cap);
|
||||
@@ -509,6 +511,27 @@ function cmdLoopRenderHooks(
|
||||
return;
|
||||
}
|
||||
|
||||
// ── ADR-1244 D2 fail-closed gate injection ────────────────────────────────────
|
||||
// For every skipped overlay capability that declared a gate at this point,
|
||||
// inject a synthetic BLOCKING gate into the resolved output so the loop HALTS
|
||||
// rather than silently proceeding as if the gate had passed. step/contribution
|
||||
// overlays that were skipped are left open (skip-open is correct for them).
|
||||
const overlayMeta = (registry as { _overlay?: { blockedGates?: Array<{ point: string; capId: string; reason: string }> } })['_overlay'];
|
||||
if (overlayMeta && Array.isArray(overlayMeta.blockedGates)) {
|
||||
for (const blocked of overlayMeta.blockedGates) {
|
||||
if (blocked.point === point) {
|
||||
const syntheticGate: ActiveHook = {
|
||||
capId: blocked.capId,
|
||||
kind: 'gate',
|
||||
blocking: true,
|
||||
onError: 'halt',
|
||||
check: `capability "${blocked.capId}" was skipped at load (${blocked.reason}); its gate at ${point} cannot be evaluated — failing closed`,
|
||||
};
|
||||
resolved.activeHooks.push(syntheticGate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --active-cap mode: print exactly 'true' or 'false' with no envelope
|
||||
if (activeCapId !== undefined) {
|
||||
const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
|
||||
|
||||
@@ -49,3 +49,126 @@ export function isSemverNewer(a: VersionInput, b: VersionInput): boolean {
|
||||
export function isStableTripletSemver(v: VersionInput): boolean {
|
||||
return /^\d+\.\d+\.\d+$/.test(String(v || '').replace(/^v/, ''));
|
||||
}
|
||||
|
||||
// ─── Range satisfaction (ADR-1244 D2 — engines.gsd load-time gate) ────────────
|
||||
//
|
||||
// A minimal, hand-written `semverSatisfies(version, range)` — deliberately NOT
|
||||
// the `semver` npm package (no new dependency / supply-chain surface in core,
|
||||
// consistent with this module's hand-written heritage). It supports the operator
|
||||
// subset capability `engines.gsd` ranges actually use: `>= <= > < =` (exact),
|
||||
// caret `^`, tilde `~`, OR via `||`, AND via whitespace, partials (`1`, `1.2`)
|
||||
// and wildcards (`*`, `1.x`). Satisfaction is computed on the numeric
|
||||
// major.minor.patch core (prerelease-insensitive), matching this module's
|
||||
// existing `toNumericTuple` policy. CRITICAL: any comparator it cannot parse
|
||||
// makes the whole check FAIL CLOSED (returns false) — an unparseable engines
|
||||
// range must never silently pass the load-time gate.
|
||||
|
||||
type RangeOp = '>=' | '<=' | '>' | '<' | '=';
|
||||
interface Primitive { op: RangeOp; t: SemverTuple; }
|
||||
|
||||
function compareTuples(a: SemverTuple, b: SemverTuple): CompareResult {
|
||||
if (a[0] !== b[0]) return a[0] > b[0] ? 1 : -1;
|
||||
if (a[1] !== b[1]) return a[1] > b[1] ? 1 : -1;
|
||||
if (a[2] !== b[2]) return a[2] > b[2] ? 1 : -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Parse a version-ish token into a tuple + how many leading numeric parts were
|
||||
// specified (0 = bare wildcard "*"/"x", 1 = "1", 2 = "1.2", 3 = "1.2.3").
|
||||
// Returns null if the token is not a parseable partial/full version.
|
||||
function parseVersionToken(token: string): { tuple: SemverTuple; specified: 0 | 1 | 2 | 3 } | null {
|
||||
const clean = token.trim().replace(/^v/, '').replace(/[-+].*$/, '');
|
||||
if (clean === '' || clean === '*' || clean === 'x' || clean === 'X') return { tuple: [0, 0, 0], specified: 0 };
|
||||
const parts = clean.split('.');
|
||||
if (parts.length > 3) return null;
|
||||
const nums: number[] = [];
|
||||
let sawWildcard = false;
|
||||
for (const p of parts) {
|
||||
if (p === 'x' || p === 'X' || p === '*') { sawWildcard = true; continue; }
|
||||
// A concrete segment after a wildcard ("1.x.2", "1.*.2") is malformed → fail closed.
|
||||
if (sawWildcard) return null;
|
||||
if (!/^\d+$/.test(p)) return null;
|
||||
nums.push(Number.parseInt(p, 10));
|
||||
}
|
||||
if (nums.length === 0) return { tuple: [0, 0, 0], specified: 0 };
|
||||
return { tuple: [nums[0] || 0, nums[1] || 0, nums[2] || 0], specified: nums.length as 1 | 2 | 3 };
|
||||
}
|
||||
|
||||
// Expand a single comparator into primitive (op, tuple) constraints, or null if
|
||||
// unparseable (→ fail closed).
|
||||
function expandComparator(c: string): Primitive[] | null {
|
||||
const trimmed = c.trim();
|
||||
if (trimmed === '' || trimmed === '*' || trimmed === 'x' || trimmed === 'X') return [{ op: '>=', t: [0, 0, 0] }];
|
||||
const m = /^(>=|<=|>|<|=|\^|~)?\s*(.+)$/.exec(trimmed);
|
||||
if (!m) return null;
|
||||
const op = m[1] || '';
|
||||
const pv = parseVersionToken(m[2]);
|
||||
if (!pv) return null;
|
||||
const { tuple, specified } = pv;
|
||||
const [maj, min, pat] = tuple;
|
||||
|
||||
if (op === '^') {
|
||||
let upper: SemverTuple;
|
||||
if (maj > 0) upper = [maj + 1, 0, 0];
|
||||
else if (min > 0) upper = [0, min + 1, 0];
|
||||
else upper = [0, 0, pat + 1];
|
||||
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
|
||||
}
|
||||
if (op === '~') {
|
||||
const upper: SemverTuple = specified >= 2 ? [maj, min + 1, 0] : [maj + 1, 0, 0];
|
||||
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
|
||||
}
|
||||
if (op === '' || op === '=') {
|
||||
if (specified === 0) return [{ op: '>=', t: [0, 0, 0] }]; // "*" → any
|
||||
if (specified === 3) return [{ op: '=', t: tuple }];
|
||||
const upper: SemverTuple = specified === 1 ? [maj + 1, 0, 0] : [maj, min + 1, 0];
|
||||
return [{ op: '>=', t: tuple }, { op: '<', t: upper }];
|
||||
}
|
||||
// >= <= > < with an explicit version
|
||||
if (specified === 0) return null; // e.g. ">=*" is meaningless → fail closed
|
||||
return [{ op: op as RangeOp, t: tuple }];
|
||||
}
|
||||
|
||||
function satisfiesPrimitive(v: SemverTuple, prim: Primitive): boolean {
|
||||
const cmp = compareTuples(v, prim.t);
|
||||
switch (prim.op) {
|
||||
case '>=': return cmp >= 0;
|
||||
case '<=': return cmp <= 0;
|
||||
case '>': return cmp > 0;
|
||||
case '<': return cmp < 0;
|
||||
case '=': return cmp === 0;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// One whitespace-separated comparator set (ANDed). Fail closed if any comparator
|
||||
// is unparseable.
|
||||
function satisfiesSet(v: SemverTuple, set: string): boolean {
|
||||
const trimmed = set.trim();
|
||||
if (trimmed === '') return false;
|
||||
const comparators = trimmed.split(/\s+/).filter(Boolean);
|
||||
if (comparators.length === 0) return false;
|
||||
for (const c of comparators) {
|
||||
const prims = expandComparator(c);
|
||||
if (prims === null) return false; // unparseable → fail closed
|
||||
for (const prim of prims) {
|
||||
if (!satisfiesPrimitive(v, prim)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does `version` satisfy the semver `range`? OR-composed across `||`, AND-composed
|
||||
* across whitespace. Fail-closed: an empty range, or any comparator this minimal
|
||||
* implementation cannot parse, returns false. Comparison is on the numeric
|
||||
* major.minor.patch core (prerelease tags are stripped, per `toNumericTuple`).
|
||||
*/
|
||||
export function semverSatisfies(version: VersionInput, range: VersionInput): boolean {
|
||||
const r = String(range == null ? '' : range).trim();
|
||||
if (r === '') return false;
|
||||
const v = toNumericTuple(version);
|
||||
const orSets = r.split('||').map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
if (orSets.length === 0) return false;
|
||||
return orSets.some((set) => satisfiesSet(v, set));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user