Establish `tier` as the source of install-profile + cluster membership
(ADR-894 §4). The registry now derives two views: capabilityClusters
(capId → its skills) and profileMembership (capId → {tier, profiles}, where
profiles is the PROFILE_RANK suffix from the capability's tier). A consistency
gate cross-checks them against the hand-authored PROFILES/CLUSTERS: HARD (throws)
on a capId-matching-a-cluster-name with a different skill set; SOFT
(pending-reconciliation stderr warning, not serialized) when a capability skill
isn't yet in the closure-resolved hand-authored profile.
The SOFT gate loads the real skills manifest and resolves each profile's closure
once so transitively-included skills don't false-warn; warnings are de-duped to
one per (capability, skill); both derived views are scoped to skill-owning
capabilities; serialized with a global capId sort for determinism; reserved-name
guards at every write site; lazy requires of the built constants.
Behavior-preserving: install/surface untouched; derived views consumed by
nothing. Full generation rides along with the phase-6 migration.
Closes #942
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -149,7 +149,7 @@ A bundle delivering one optional GSD feature, toggled as a unit at install or af
|
||||
Generated description of what the five-step loop (Discuss → Plan → Execute → Verify → Ship) exposes as extension points: per-step loop points, agent roles, and core artifacts. Sourced from structured `<!-- gsd:loop-host ... -->` HTML-comment markers embedded near the top of each of the five step workflow files (`discuss-phase.md`, `plan-phase.md`, `execute-phase.md`, `verify-work.md`, `ship.md`). Generated by `scripts/gen-loop-host-contract.cjs` → `gsd-core/bin/lib/loop-host-contract.cjs` (ADR-894 §3 phase 3a-impl-2). Covers exactly the 12 canonical points (discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post). The generator enforces a drift guard: every declared non-orchestrator agent role must correspond to an actual agent reference in the workflow file. Consumed by `gen-capability-registry.cjs` (replaces the former inline `LOOP_HOST_CONTRACT` constant). Run `node scripts/gen-loop-host-contract.cjs --write` after editing a workflow step marker.
|
||||
|
||||
### Capability Registry
|
||||
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities/<id>/capability.json`.
|
||||
Generated central manifest projecting all co-located Capability declarations into one validated artifact for runtime resolution and for the install, surface, config, and loop-extension adapters. Mirrors the research-profiles / package-identity generation pattern (co-located source → generated central file). Generated by `scripts/gen-capability-registry.cjs` → `gsd-core/bin/lib/capability-registry.cjs` (ADR-894 §5 phase 3a-impl). Role-partitioned indexes: `bySkill`, `byAgent`, `byLoopPoint` (hook ordering materialized), `configKeys` (ownership map: key→capId), `configSchema` (full per-key schema: key→{ owner, type, default, description }), `runtimes`, `requiresClosure(id)`. ADR-857 phase 3b adds `configSchema` with validated type/default/description per key, sourced from each capability's `.config` slice. ADR-857 phase 4a adds two derived views: `capabilityClusters` (`{ <capId>: [<skill stems>] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ <capId>: { tier, profiles: [...] } }` — the tier-derived index: suffix of `PROFILE_RANK` starting at the capability's tier). Both views cover the same capability set: only capabilities that own skills (non-empty `skills` array). The generator enforces a HARD gate (throws) if a capId matching a `CLUSTERS` key has a mismatched skill set, and emits SOFT `⚠ pending-reconciliation` warnings to stderr (never to the file) for skills not yet in the hand-authored profile at the capability's tier. `install` and `surface` are UNTOUCHED (still read hand-authored constants; derived views are emitted and tested but unconsumed until cutover). Validated against the Loop Host Contract (12 points; generated by `gen-loop-host-contract.cjs` from workflow markers, phase 3a-impl-2). Run `node scripts/gen-capability-registry.cjs --write` after editing any `capabilities/<id>/capability.json`.
|
||||
|
||||
### Federated Config
|
||||
ADR-857 phase 3b seam that merges capability-declared config slices into the `loadConfig` return value. Implemented in `src/federated-config.cts` → `gsd-core/bin/lib/federated-config.cjs`. Exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig }) → { values, validKeys, warnings }`. Rules: central-schema keys are skipped with a `pending-migration` warning; malformed slices are skipped with a warning (never throws); valid federated keys (absent from the central schema) resolve to the user-supplied value (if type-matches) or the slice default. Object writes are guarded against prototype pollution with inline literal `__proto__`/`constructor`/`prototype` key checks. Wired into `loadConfig` as a true no-op today: every Capability config key is still in the central config-schema, so `isCentralKey()` returns true for all of them and `values` is always empty. The channel becomes live when a key is atomically removed from the central schema at cutover (the ADR-857 migration step). `loadConfig` exposes `_setFederatedRegistryForTests`/`_resetFederatedRegistryForTests` seams for injecting a synthetic registry in tests.
|
||||
|
||||
@@ -230,6 +230,23 @@ const configSchema = {
|
||||
|
||||
const runtimes = {};
|
||||
|
||||
const capabilityClusters = {
|
||||
"ui": [
|
||||
"ui-phase",
|
||||
"ui-review"
|
||||
]
|
||||
};
|
||||
|
||||
const profileMembership = {
|
||||
"ui": {
|
||||
"tier": "standard",
|
||||
"profiles": [
|
||||
"standard",
|
||||
"full"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const _requiresGraph = {
|
||||
"ui": []
|
||||
};
|
||||
@@ -259,5 +276,7 @@ module.exports = {
|
||||
configKeys,
|
||||
configSchema,
|
||||
runtimes,
|
||||
capabilityClusters,
|
||||
profileMembership,
|
||||
requiresClosure,
|
||||
};
|
||||
|
||||
@@ -964,6 +964,202 @@ function topoSortSteps(entries) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── ADR-857 Phase 4a: Derived views ─────────────────────────────────────────
|
||||
|
||||
// FIX 5 (lazy requires): paths are declared at top level but the actual require()
|
||||
// calls are deferred into lazy accessor functions so importing this generator for
|
||||
// its other exports does NOT fail at module-load time on a fresh/unbuilt worktree.
|
||||
const INSTALL_PROFILES_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'install-profiles.cjs');
|
||||
const CLUSTERS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'clusters.cjs');
|
||||
|
||||
let _installProfilesMod = null;
|
||||
let _clustersMod = null;
|
||||
|
||||
function getInstallProfiles() {
|
||||
if (!_installProfilesMod) _installProfilesMod = require(INSTALL_PROFILES_PATH);
|
||||
return _installProfilesMod;
|
||||
}
|
||||
|
||||
function getClusters() {
|
||||
if (!_clustersMod) _clustersMod = require(CLUSTERS_PATH);
|
||||
return _clustersMod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive capabilityClusters: { <capId>: [<skill stems>] }
|
||||
* Each capability's own skills array, sorted for determinism.
|
||||
*
|
||||
* FIX 3: scope rule = "capabilities that own skills" (non-empty skills array).
|
||||
* Both capabilityClusters and profileMembership use this same predicate so a
|
||||
* future non-feature role carrying skills is treated identically in both, and a
|
||||
* feature cap with no skills appears in neither.
|
||||
*
|
||||
* @param {Map<string, object>} capMap
|
||||
* @returns {object} Object.create(null) — prototype-pollution safe
|
||||
*/
|
||||
function deriveCapabilityClusters(capMap) {
|
||||
const result = Object.create(null);
|
||||
for (const [capId, cap] of capMap) {
|
||||
// S2b: inline literal guard at each write site (CodeQL barrier)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
// FIX 3: include any cap that owns skills (non-empty skills array), regardless of role
|
||||
if (!Array.isArray(cap.skills) || cap.skills.length === 0) continue;
|
||||
// Sort for determinism
|
||||
const sorted = [...cap.skills].sort();
|
||||
result[capId] = sorted;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive profileMembership: { <capId>: { tier: <t>, profiles: [<names>] } }
|
||||
* profiles = suffix of PROFILE_RANK starting at the capability's tier index.
|
||||
* tier 'core' → ['core', 'standard', 'full']
|
||||
* tier 'standard' → ['standard', 'full']
|
||||
* tier 'full' → ['full']
|
||||
*
|
||||
* FIX 3: scope rule = "capabilities that own skills" (non-empty skills array),
|
||||
* consistent with deriveCapabilityClusters. Both derived views cover the same set.
|
||||
*
|
||||
* FIX 5: tierIdx === -1 means VALID_TIERS and PROFILE_RANK have drifted; throw
|
||||
* loudly instead of silently producing ['full'] for the affected capability.
|
||||
*
|
||||
* @param {Map<string, object>} capMap
|
||||
* @returns {object} Object.create(null) — prototype-pollution safe
|
||||
*/
|
||||
function deriveProfileMembership(capMap) {
|
||||
const { PROFILE_RANK } = getInstallProfiles();
|
||||
const result = Object.create(null);
|
||||
for (const [capId, cap] of capMap) {
|
||||
// S2b: inline literal guard at each write site (CodeQL barrier)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
if (!VALID_TIERS.has(cap.tier)) continue;
|
||||
// FIX 3: consistent scope — only capabilities that own skills (non-empty skills array)
|
||||
if (!Array.isArray(cap.skills) || cap.skills.length === 0) continue;
|
||||
const tierIdx = PROFILE_RANK.indexOf(cap.tier);
|
||||
// FIX 5: throw loudly on VALID_TIERS/PROFILE_RANK drift (was silent continue)
|
||||
if (tierIdx === -1) {
|
||||
throw new Error(
|
||||
'deriveProfileMembership: capability "' + capId + '" tier "' + cap.tier +
|
||||
'" is in VALID_TIERS but not in PROFILE_RANK — VALID_TIERS/PROFILE_RANK drift detected',
|
||||
);
|
||||
}
|
||||
const profiles = PROFILE_RANK.slice(tierIdx);
|
||||
result[capId] = { tier: cap.tier, profiles: [...profiles] };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run consistency gates:
|
||||
* - HARD: for each capId that matches a CLUSTERS key, derived skills must match
|
||||
* the hand-authored CLUSTERS[capId] set (order-insensitive). Throws on mismatch.
|
||||
* - SOFT: for each capability, for each skill not yet in all non-full profiles it
|
||||
* belongs to (closure-resolved), emit ONE pending-reconciliation warning listing
|
||||
* the missing profiles together. Warnings are collected and returned — NOT thrown.
|
||||
*
|
||||
* FIX 1: load the REAL skills manifest (same as bin/install.js) so resolveProfile
|
||||
* expands requires:-closure. Loaded once and reused across all capabilities.
|
||||
*
|
||||
* FIX 3: iterate capabilityClusters (which already covers "capabilities that own
|
||||
* skills") rather than profileMembership, so both derived views share one scope.
|
||||
*
|
||||
* FIX 4: one warning per (capability, skill) gap, listing all missing non-full
|
||||
* profiles together, instead of one warning per (capability, skill, profile).
|
||||
*
|
||||
* @param {object} capabilityClusters From deriveCapabilityClusters()
|
||||
* @param {object} profileMembership From deriveProfileMembership()
|
||||
* @param {Map<string, object>} capMap Original capMap for skill lists
|
||||
* @returns {string[]} Array of pending-reconciliation warning strings
|
||||
*/
|
||||
function runConsistencyGate(capabilityClusters, profileMembership, capMap) {
|
||||
const { CLUSTERS: clustersObj } = getClusters();
|
||||
const { resolveProfile, loadSkillsManifest } = getInstallProfiles();
|
||||
|
||||
// ── HARD gate: cluster set comparison ──────────────────────────────────────
|
||||
for (const capId of Object.keys(capabilityClusters)) {
|
||||
// S2b: inline literal guard (CodeQL barrier)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
// Only check if a CLUSTERS entry with the same name exists
|
||||
if (!Object.prototype.hasOwnProperty.call(clustersObj, capId)) continue;
|
||||
const derivedSet = new Set(capabilityClusters[capId]);
|
||||
const handAuthored = clustersObj[capId];
|
||||
const handAuthoredSet = new Set(handAuthored);
|
||||
// Compare sets (order-insensitive)
|
||||
let mismatch = derivedSet.size !== handAuthoredSet.size;
|
||||
if (!mismatch) {
|
||||
for (const s of derivedSet) {
|
||||
if (!handAuthoredSet.has(s)) { mismatch = true; break; }
|
||||
}
|
||||
}
|
||||
if (mismatch) {
|
||||
throw new Error(
|
||||
'capability-cluster consistency gate FAILED for capId "' + capId + '":\n' +
|
||||
' derived set: [' + [...derivedSet].sort().join(', ') + ']\n' +
|
||||
' hand-authored set: [' + [...handAuthoredSet].sort().join(', ') + ']\n' +
|
||||
'The capability\'s skills array must match the hand-authored CLUSTERS["' + capId + '"] at cutover.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SOFT gate: profile reconciliation warnings ─────────────────────────────
|
||||
|
||||
// FIX 1: load the REAL skills manifest once (same path as bin/install.js uses),
|
||||
// so resolveProfile expands requires:-closure and the effective set is accurate.
|
||||
const commandsGsdDir = path.join(ROOT, 'commands', 'gsd');
|
||||
const skillsManifest = loadSkillsManifest(commandsGsdDir);
|
||||
|
||||
// FIX 1: resolve each profile's effective set once and cache — don't reload per-capability.
|
||||
const profileEffectiveSetCache = Object.create(null);
|
||||
function getEffectiveSet(profileName) {
|
||||
if (profileName in profileEffectiveSetCache) return profileEffectiveSetCache[profileName];
|
||||
const resolved = resolveProfile({ modes: [profileName], manifest: skillsManifest });
|
||||
const effectiveSet = resolved.skills === '*' ? null : resolved.skills;
|
||||
profileEffectiveSetCache[profileName] = effectiveSet;
|
||||
return effectiveSet;
|
||||
}
|
||||
|
||||
const warnings = [];
|
||||
|
||||
// FIX 3: iterate capabilityClusters (same set as profileMembership after FIX 3 scoping).
|
||||
for (const capId of Object.keys(capabilityClusters)) {
|
||||
// S2b: inline literal guard (CodeQL barrier)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
const membership = profileMembership[capId];
|
||||
if (!membership) continue; // no profile membership (e.g. cap has skills but invalid tier)
|
||||
const cap = capMap.get(capId);
|
||||
if (!cap || !Array.isArray(cap.skills)) continue;
|
||||
|
||||
// Collect the non-full profiles for this capability
|
||||
const nonFullProfiles = membership.profiles.filter((p) => p !== 'full');
|
||||
|
||||
// FIX 4: one warning per (capability, skill) gap — list all missing profiles together
|
||||
for (const skill of cap.skills) {
|
||||
// S2b: inline literal guard (CodeQL barrier)
|
||||
if (skill === '__proto__' || skill === 'constructor' || skill === 'prototype') continue;
|
||||
|
||||
const missingProfiles = [];
|
||||
for (const profileName of nonFullProfiles) {
|
||||
const effectiveSet = getEffectiveSet(profileName);
|
||||
if (effectiveSet === null) continue; // profile resolved to full (unexpected but safe)
|
||||
if (!effectiveSet.has(skill)) {
|
||||
missingProfiles.push(profileName);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingProfiles.length > 0) {
|
||||
warnings.push(
|
||||
'⚠ pending-reconciliation: capability \'' + capId + '\' (tier ' + membership.tier + ')' +
|
||||
' skill \'' + skill + '\' not yet in hand-authored profile(s): <' + missingProfiles.join(', ') +
|
||||
'>; add at cutover',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
// ─── Registry builder ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -1161,6 +1357,14 @@ function buildRegistry(capMap) {
|
||||
}));
|
||||
}
|
||||
|
||||
// ── ADR-857 phase 4a: derived views ────────────────────────────────────────
|
||||
const capabilityClusters = deriveCapabilityClusters(capMap);
|
||||
const profileMembership = deriveProfileMembership(capMap);
|
||||
// runConsistencyGate: hard gate throws on mismatch; returns soft warning strings.
|
||||
// Warnings are returned in the registry object so callers can emit them to stderr
|
||||
// without affecting the serialized file content (determinism gate stays clean).
|
||||
const reconciliationWarnings = runConsistencyGate(capabilityClusters, profileMembership, capMap);
|
||||
|
||||
return {
|
||||
version: SCHEMA_VERSION,
|
||||
capabilities,
|
||||
@@ -1170,6 +1374,10 @@ function buildRegistry(capMap) {
|
||||
configKeys,
|
||||
configSchema,
|
||||
runtimes,
|
||||
capabilityClusters,
|
||||
profileMembership,
|
||||
// warnings are NOT serialized — returned only for caller consumption via stderr
|
||||
_reconciliationWarnings: reconciliationWarnings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1209,6 +1417,40 @@ function serializeRegistry(registry, capMap) {
|
||||
lines.push('const runtimes = ' + JSON.stringify(registry.runtimes, null, 2) + ';');
|
||||
lines.push('');
|
||||
|
||||
// ADR-857 phase 4a: derived views — globally sorted capIds for determinism.
|
||||
// FIX 2: collect ALL capIds across both views and sort globally so feature + runtime
|
||||
// capIds interleave correctly when both are present (phase 5 readiness).
|
||||
const allClusterCapIds = new Set(Object.keys(registry.capabilityClusters));
|
||||
const allProfileCapIds = new Set(Object.keys(registry.profileMembership));
|
||||
const allCapIds = new Set([...allClusterCapIds, ...allProfileCapIds]);
|
||||
// FIX 5: inline literal guard at write sites (CodeQL barrier)
|
||||
allCapIds.delete('__proto__');
|
||||
allCapIds.delete('constructor');
|
||||
allCapIds.delete('prototype');
|
||||
const globalSortedCapIds = [...allCapIds].sort();
|
||||
|
||||
const sortedCapabilityClusters = Object.create(null);
|
||||
for (const capId of globalSortedCapIds) {
|
||||
// S2b: inline literal guard at each write site (CodeQL barrier)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
if (registry.capabilityClusters[capId] !== undefined) {
|
||||
sortedCapabilityClusters[capId] = registry.capabilityClusters[capId];
|
||||
}
|
||||
}
|
||||
lines.push('const capabilityClusters = ' + JSON.stringify(sortedCapabilityClusters, null, 2) + ';');
|
||||
lines.push('');
|
||||
|
||||
const sortedProfileMembership = Object.create(null);
|
||||
for (const capId of globalSortedCapIds) {
|
||||
// S2b: inline literal guard at each write site (CodeQL barrier)
|
||||
if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue;
|
||||
if (registry.profileMembership[capId] !== undefined) {
|
||||
sortedProfileMembership[capId] = registry.profileMembership[capId];
|
||||
}
|
||||
}
|
||||
lines.push('const profileMembership = ' + JSON.stringify(sortedProfileMembership, null, 2) + ';');
|
||||
lines.push('');
|
||||
|
||||
// Inline the requires graph so requiresClosure() works without re-reading files
|
||||
const requiresGraph = {};
|
||||
for (const [id, cap] of capMap) {
|
||||
@@ -1244,6 +1486,8 @@ function serializeRegistry(registry, capMap) {
|
||||
lines.push(' configKeys,');
|
||||
lines.push(' configSchema,');
|
||||
lines.push(' runtimes,');
|
||||
lines.push(' capabilityClusters,');
|
||||
lines.push(' profileMembership,');
|
||||
lines.push(' requiresClosure,');
|
||||
lines.push('};');
|
||||
lines.push('');
|
||||
@@ -1333,6 +1577,9 @@ function main() {
|
||||
}
|
||||
|
||||
const registry = buildRegistry(capMap);
|
||||
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
|
||||
// (they do NOT affect the generated file content, so --check stays clean)
|
||||
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
|
||||
const live = serializeRegistry(registry, capMap);
|
||||
|
||||
if (!fs.existsSync(REGISTRY_PATH)) {
|
||||
@@ -1367,6 +1614,8 @@ function main() {
|
||||
}
|
||||
|
||||
const registry = buildRegistry(capMap);
|
||||
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
|
||||
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
|
||||
const content = serializeRegistry(registry, capMap);
|
||||
// Fix #5: mkdir-p before writing so --write doesn't ENOENT in a fresh worktree.
|
||||
fs.mkdirSync(path.dirname(REGISTRY_PATH), { recursive: true });
|
||||
@@ -1384,6 +1633,8 @@ function main() {
|
||||
throw new ExitError(1, 'capability validation failed');
|
||||
}
|
||||
const registry = buildRegistry(capMap);
|
||||
// ADR-857 phase 4a: emit pending-reconciliation warnings to stderr only
|
||||
for (const w of (registry._reconciliationWarnings || [])) process.stderr.write(w + '\n');
|
||||
process.stdout.write(serializeRegistry(registry, capMap) + '\n');
|
||||
}
|
||||
}
|
||||
@@ -1410,6 +1661,14 @@ module.exports = {
|
||||
POINT_TO_CONTRACT,
|
||||
HOST_ARTIFACT_EARLIEST_POINT_IDX,
|
||||
SCHEMA_VERSION,
|
||||
// ADR-857 phase 4a: derived views + gates
|
||||
deriveCapabilityClusters,
|
||||
deriveProfileMembership,
|
||||
runConsistencyGate,
|
||||
// FIX 5 (lazy): PROFILE_RANK and CLUSTERS are loaded on first access via getters
|
||||
// so importing the generator on a fresh/unbuilt worktree doesn't fail at module load.
|
||||
get PROFILE_RANK() { return getInstallProfiles().PROFILE_RANK; },
|
||||
get CLUSTERS() { return getClusters().CLUSTERS; },
|
||||
};
|
||||
|
||||
// ─── CLI entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -32,6 +32,11 @@ const {
|
||||
validateConfigSliceEntry,
|
||||
VALID_CONFIG_SLICE_TYPES,
|
||||
SCHEMA_VERSION,
|
||||
// ADR-857 phase 4a
|
||||
deriveCapabilityClusters,
|
||||
deriveProfileMembership,
|
||||
runConsistencyGate,
|
||||
PROFILE_RANK,
|
||||
} = require('../scripts/gen-capability-registry.cjs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
@@ -1504,3 +1509,627 @@ describe('validateConfigSliceEntry adversarial cases (ADR-857 phase 3b)', () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 16. ADR-857 phase 4a: capabilityClusters + profileMembership ─────────────
|
||||
|
||||
// Minimal valid feature capability for synthetic tests
|
||||
function makeSyntheticCap(id, tier, skills) {
|
||||
return {
|
||||
id,
|
||||
role: 'feature',
|
||||
title: id,
|
||||
description: 'Synthetic cap for testing',
|
||||
tier,
|
||||
requires: [],
|
||||
skills: [...skills],
|
||||
agents: [],
|
||||
hooks: [],
|
||||
config: {},
|
||||
steps: [],
|
||||
contributions: [],
|
||||
gates: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ADR-857 phase 4a: capabilityClusters shape', () => {
|
||||
test('ui capabilityClusters → [ui-phase, ui-review]', () => {
|
||||
const capMap = new Map([['ui', UI_CAP]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
assert.ok(clusters.ui, 'capabilityClusters.ui should exist');
|
||||
// Skills are sorted for determinism
|
||||
assert.deepEqual(
|
||||
clusters.ui,
|
||||
['ui-phase', 'ui-review'],
|
||||
'ui cluster should be [ui-phase, ui-review], got: ' + JSON.stringify(clusters.ui),
|
||||
);
|
||||
});
|
||||
|
||||
test('capabilityClusters skips runtime capabilities (no skills)', () => {
|
||||
const runtimeCap = {
|
||||
id: 'cursor', role: 'runtime', title: 'Cursor', description: 'Cursor runtime',
|
||||
tier: 'standard', requires: [],
|
||||
runtime: {
|
||||
configHome: '~/.cursor', configFormat: 'settings-json',
|
||||
artifactLayout: [], commandStyle: 'slash', hooksSurface: 'rules',
|
||||
sandboxTier: 'none', supportTier: 2,
|
||||
},
|
||||
};
|
||||
const capMap = new Map([['cursor', runtimeCap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
assert.ok(!clusters.cursor, 'runtime cap should not appear in capabilityClusters');
|
||||
});
|
||||
|
||||
test('capabilityClusters skills are sorted for determinism', () => {
|
||||
const cap = makeSyntheticCap('test-cap', 'standard', ['z-skill', 'a-skill', 'm-skill']);
|
||||
const capMap = new Map([['test-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
assert.deepEqual(
|
||||
clusters['test-cap'],
|
||||
['a-skill', 'm-skill', 'z-skill'],
|
||||
'Skills should be sorted alphabetically, got: ' + JSON.stringify(clusters['test-cap']),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildRegistry includes capabilityClusters with correct ui value', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
assert.ok(registry.capabilityClusters, 'registry.capabilityClusters should exist');
|
||||
assert.deepEqual(
|
||||
registry.capabilityClusters.ui,
|
||||
['ui-phase', 'ui-review'],
|
||||
'registry.capabilityClusters.ui should be [ui-phase, ui-review]',
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeRegistry emits capabilityClusters block in generated .cjs', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const content = serializeRegistry(registry, capMap);
|
||||
assert.ok(content.includes('const capabilityClusters'), 'Generated file must contain "const capabilityClusters"');
|
||||
assert.ok(content.includes('"ui-phase"'), 'Generated file must contain "ui-phase" in capabilityClusters');
|
||||
assert.ok(content.includes('capabilityClusters,'), 'module.exports must include capabilityClusters');
|
||||
});
|
||||
|
||||
test('committed capability-registry.cjs has capabilityClusters with ui=[ui-phase,ui-review]', () => {
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
assert.ok(registry.capabilityClusters, 'capability-registry.cjs must export capabilityClusters');
|
||||
assert.deepEqual(
|
||||
registry.capabilityClusters.ui,
|
||||
['ui-phase', 'ui-review'],
|
||||
'committed capabilityClusters.ui should be [ui-phase, ui-review]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-857 phase 4a: capabilityClusters HARD consistency gate', () => {
|
||||
test('synthetic cap whose capId matches a CLUSTERS name but with different skills throws', () => {
|
||||
// The 'ui' name exists in CLUSTERS with ['ui-phase', 'ui-review'].
|
||||
// A synthetic 'ui' cap with only ['ui-phase'] (missing 'ui-review') must throw.
|
||||
const wrongUiCap = makeSyntheticCap('ui', 'standard', ['ui-phase']); // missing ui-review
|
||||
const capMap = new Map([['ui', wrongUiCap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.throws(
|
||||
() => runConsistencyGate(clusters, profiles, capMap),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error, 'Must throw an Error');
|
||||
assert.ok(
|
||||
err.message.includes('ui'),
|
||||
'Error must name the capId, got: ' + err.message,
|
||||
);
|
||||
assert.ok(
|
||||
err.message.includes('ui-review') || err.message.includes('derived set') || err.message.includes('hand-authored'),
|
||||
'Error must describe the mismatch, got: ' + err.message,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('cap with capId that has NO matching CLUSTERS entry is accepted (new cluster — fine)', () => {
|
||||
// A new capability 'payments' that has no CLUSTERS entry must NOT throw
|
||||
const newCap = makeSyntheticCap('payments', 'standard', ['pay-phase', 'pay-review']);
|
||||
const capMap = new Map([['payments', newCap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
// Must not throw
|
||||
assert.doesNotThrow(
|
||||
() => runConsistencyGate(clusters, profiles, capMap),
|
||||
'A cap with no matching CLUSTERS entry should not throw (new cluster is fine)',
|
||||
);
|
||||
});
|
||||
|
||||
test('HARD gate: extra skill in derived set (more than hand-authored) also throws', () => {
|
||||
// 'ui' cap with an extra skill triggers the mismatch
|
||||
const extraUiCap = makeSyntheticCap('ui', 'standard', ['ui-phase', 'ui-review', 'ui-extra']);
|
||||
const capMap = new Map([['ui', extraUiCap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.throws(
|
||||
() => runConsistencyGate(clusters, profiles, capMap),
|
||||
(err) => {
|
||||
assert.ok(err instanceof Error);
|
||||
assert.ok(err.message.includes('ui'), 'Error must name the capId');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-857 phase 4a: profileMembership derivation', () => {
|
||||
test('tier core → profiles [core, standard, full]', () => {
|
||||
const cap = makeSyntheticCap('core-cap', 'core', ['core-skill']);
|
||||
const capMap = new Map([['core-cap', cap]]);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.ok(profiles['core-cap'], 'profileMembership should have core-cap');
|
||||
assert.strictEqual(profiles['core-cap'].tier, 'core');
|
||||
assert.deepEqual(
|
||||
profiles['core-cap'].profiles,
|
||||
['core', 'standard', 'full'],
|
||||
'core tier should produce [core, standard, full], got: ' + JSON.stringify(profiles['core-cap'].profiles),
|
||||
);
|
||||
});
|
||||
|
||||
test('tier standard → profiles [standard, full]', () => {
|
||||
const cap = makeSyntheticCap('std-cap', 'standard', ['std-skill']);
|
||||
const capMap = new Map([['std-cap', cap]]);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.ok(profiles['std-cap'], 'profileMembership should have std-cap');
|
||||
assert.strictEqual(profiles['std-cap'].tier, 'standard');
|
||||
assert.deepEqual(
|
||||
profiles['std-cap'].profiles,
|
||||
['standard', 'full'],
|
||||
'standard tier should produce [standard, full], got: ' + JSON.stringify(profiles['std-cap'].profiles),
|
||||
);
|
||||
});
|
||||
|
||||
test('tier full → profiles [full]', () => {
|
||||
const cap = makeSyntheticCap('full-cap', 'full', ['full-skill']);
|
||||
const capMap = new Map([['full-cap', cap]]);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.ok(profiles['full-cap'], 'profileMembership should have full-cap');
|
||||
assert.strictEqual(profiles['full-cap'].tier, 'full');
|
||||
assert.deepEqual(
|
||||
profiles['full-cap'].profiles,
|
||||
['full'],
|
||||
'full tier should produce [full], got: ' + JSON.stringify(profiles['full-cap'].profiles),
|
||||
);
|
||||
});
|
||||
|
||||
test('PROFILE_RANK is imported (not hardcoded): all three tiers covered', () => {
|
||||
// Verify PROFILE_RANK is the canonical ['core', 'standard', 'full'] from install-profiles.cjs
|
||||
assert.deepEqual(
|
||||
PROFILE_RANK,
|
||||
['core', 'standard', 'full'],
|
||||
'PROFILE_RANK must be [core, standard, full] from install-profiles.cjs, got: ' + JSON.stringify(PROFILE_RANK),
|
||||
);
|
||||
});
|
||||
|
||||
test('ui cap (tier standard) profileMembership is [standard, full]', () => {
|
||||
const capMap = new Map([['ui', UI_CAP]]);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.deepEqual(
|
||||
profiles.ui.profiles,
|
||||
['standard', 'full'],
|
||||
'ui (tier standard) should have profiles [standard, full]',
|
||||
);
|
||||
});
|
||||
|
||||
test('buildRegistry includes profileMembership with correct ui value', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
assert.ok(registry.profileMembership, 'registry.profileMembership should exist');
|
||||
assert.deepEqual(
|
||||
registry.profileMembership.ui,
|
||||
{ tier: 'standard', profiles: ['standard', 'full'] },
|
||||
'profileMembership.ui should be { tier: standard, profiles: [standard, full] }',
|
||||
);
|
||||
});
|
||||
|
||||
test('serializeRegistry emits profileMembership block in generated .cjs', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const content = serializeRegistry(registry, capMap);
|
||||
assert.ok(content.includes('const profileMembership'), 'Generated file must contain "const profileMembership"');
|
||||
assert.ok(content.includes('"standard"'), 'Generated file must contain "standard" in profileMembership');
|
||||
assert.ok(content.includes('profileMembership,'), 'module.exports must include profileMembership');
|
||||
});
|
||||
|
||||
test('committed capability-registry.cjs has profileMembership with correct ui value', () => {
|
||||
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||||
assert.ok(registry.profileMembership, 'capability-registry.cjs must export profileMembership');
|
||||
assert.deepEqual(
|
||||
registry.profileMembership.ui,
|
||||
{ tier: 'standard', profiles: ['standard', 'full'] },
|
||||
'committed profileMembership.ui should be { tier: standard, profiles: [standard, full] }',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-857 phase 4a: pending-reconciliation warnings (SOFT gate)', () => {
|
||||
test('ui (tier standard) generates pending-reconciliation warnings for standard profile', () => {
|
||||
// ui skills (ui-phase, ui-review) are NOT in the hand-authored standard profile.
|
||||
// The SOFT gate should emit one warning per skill for the standard profile.
|
||||
const capMap = new Map([['ui', UI_CAP]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
assert.ok(warnings.length >= 2, 'Expected at least 2 pending-reconciliation warnings, got: ' + warnings.length);
|
||||
const uiPhaseWarn = warnings.find((w) => w.includes('ui-phase') && w.includes('standard'));
|
||||
const uiReviewWarn = warnings.find((w) => w.includes('ui-review') && w.includes('standard'));
|
||||
assert.ok(
|
||||
uiPhaseWarn,
|
||||
'Expected warning for ui-phase skill in standard profile, got: ' + JSON.stringify(warnings),
|
||||
);
|
||||
assert.ok(
|
||||
uiReviewWarn,
|
||||
'Expected warning for ui-review skill in standard profile, got: ' + JSON.stringify(warnings),
|
||||
);
|
||||
// Warning format check
|
||||
assert.ok(
|
||||
uiPhaseWarn.includes('pending-reconciliation'),
|
||||
'Warning must include "pending-reconciliation", got: ' + uiPhaseWarn,
|
||||
);
|
||||
assert.ok(
|
||||
uiPhaseWarn.includes('add at cutover'),
|
||||
'Warning must include "add at cutover", got: ' + uiPhaseWarn,
|
||||
);
|
||||
});
|
||||
|
||||
test('ui pending-reconciliation: ui-phase in profileMembership.standard but NOT in resolved hand-authored standard profile', () => {
|
||||
// Structural check: assert that ui-phase is in profileMembership.ui.profiles ('standard')
|
||||
// yet NOT in the resolved hand-authored standard profile — which is WHY the warning fires.
|
||||
const capMap = new Map([['ui', UI_CAP]]);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.ok(
|
||||
profiles.ui.profiles.includes('standard'),
|
||||
'ui profileMembership should include "standard"',
|
||||
);
|
||||
|
||||
// Confirm ui-phase is NOT in the hand-authored standard profile's effective skill set
|
||||
const { resolveProfile: rp } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const resolved = rp({ modes: ['standard'], manifest: new Map() });
|
||||
assert.ok(
|
||||
resolved.skills !== '*',
|
||||
'standard profile should not be full',
|
||||
);
|
||||
assert.ok(
|
||||
!resolved.skills.has('ui-phase'),
|
||||
'ui-phase should NOT be in hand-authored standard profile effective set (pending reconciliation)',
|
||||
);
|
||||
assert.ok(
|
||||
!resolved.skills.has('ui-review'),
|
||||
'ui-review should NOT be in hand-authored standard profile effective set (pending reconciliation)',
|
||||
);
|
||||
});
|
||||
|
||||
test('SOFT gate does NOT throw — only returns warnings', () => {
|
||||
// Even with reconciliation gaps, runConsistencyGate must NOT throw
|
||||
const capMap = new Map([['ui', UI_CAP]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
let warnings;
|
||||
assert.doesNotThrow(
|
||||
() => { warnings = runConsistencyGate(clusters, profiles, capMap); },
|
||||
'SOFT gate must not throw — only collect warnings',
|
||||
);
|
||||
assert.ok(Array.isArray(warnings), 'runConsistencyGate must return an array');
|
||||
});
|
||||
|
||||
test('buildRegistry._reconciliationWarnings includes ui skill warnings', () => {
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
assert.ok(
|
||||
Array.isArray(registry._reconciliationWarnings),
|
||||
'registry._reconciliationWarnings should be an array',
|
||||
);
|
||||
assert.ok(
|
||||
registry._reconciliationWarnings.some((w) => w.includes('ui-phase')),
|
||||
'Expected warning for ui-phase in _reconciliationWarnings, got: ' + JSON.stringify(registry._reconciliationWarnings),
|
||||
);
|
||||
assert.ok(
|
||||
registry._reconciliationWarnings.some((w) => w.includes('ui-review')),
|
||||
'Expected warning for ui-review in _reconciliationWarnings, got: ' + JSON.stringify(registry._reconciliationWarnings),
|
||||
);
|
||||
});
|
||||
|
||||
test('reconciliation warnings are NOT in serialized registry output (determinism gate)', () => {
|
||||
// Warnings must appear ONLY on stderr, not in the generated .cjs file
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const content = serializeRegistry(registry, capMap);
|
||||
assert.ok(
|
||||
!content.includes('pending-reconciliation'),
|
||||
'Serialized registry must NOT contain "pending-reconciliation" text (warnings are stderr-only)',
|
||||
);
|
||||
assert.ok(
|
||||
!content.includes('_reconciliationWarnings'),
|
||||
'Serialized registry must NOT contain _reconciliationWarnings key',
|
||||
);
|
||||
});
|
||||
|
||||
test('a cap whose skill IS already in the standard profile emits no reconciliation warning', () => {
|
||||
// 'plan-phase' IS in the hand-authored standard profile. A synthetic cap
|
||||
// with tier=standard and skill=plan-phase should NOT generate a warning.
|
||||
const cap = makeSyntheticCap('planner-cap', 'standard', ['plan-phase']);
|
||||
const capMap = new Map([['planner-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
const planPhaseWarnings = warnings.filter((w) => w.includes('plan-phase'));
|
||||
assert.deepEqual(
|
||||
planPhaseWarnings, [],
|
||||
'No reconciliation warning expected for plan-phase (already in standard profile), got: ' + JSON.stringify(planPhaseWarnings),
|
||||
);
|
||||
});
|
||||
|
||||
test('a core-tier cap with skills already in core profile emits no reconciliation warning', () => {
|
||||
// 'new-project' IS in the hand-authored core profile.
|
||||
const cap = makeSyntheticCap('np-cap', 'core', ['new-project']);
|
||||
const capMap = new Map([['np-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
const npWarnings = warnings.filter((w) => w.includes('new-project'));
|
||||
assert.deepEqual(
|
||||
npWarnings, [],
|
||||
'No reconciliation warning expected for new-project (already in core profile), got: ' + JSON.stringify(npWarnings),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-857 phase 4a: requires-closure tier-monotone (synthetic)', () => {
|
||||
test('tier-monotone: a required capability must be same-or-lower tier', () => {
|
||||
// Cap A at 'core' requiring cap B at 'standard' violates tier-monotone.
|
||||
// validateCrossCapability already tests this; here we verify the rule via
|
||||
// a profileMembership structural check: if A is core → B must have rank ≤ core.
|
||||
const capA = makeSyntheticCap('tier-a', 'core', ['a-skill']);
|
||||
capA.requires = ['tier-b'];
|
||||
const capB = makeSyntheticCap('tier-b', 'standard', ['b-skill']);
|
||||
const capMap = new Map([['tier-a', capA], ['tier-b', capB]]);
|
||||
|
||||
// validateCrossCapability enforces the rule
|
||||
const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs');
|
||||
const errors = vcc(capMap, new Set());
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('tier-monotone')),
|
||||
'Expected tier-monotone error, got: ' + JSON.stringify(errors),
|
||||
);
|
||||
});
|
||||
|
||||
test('tier-monotone: same-tier requires is accepted', () => {
|
||||
const capA = makeSyntheticCap('mono-a', 'standard', ['ma-skill']);
|
||||
capA.requires = ['mono-b'];
|
||||
const capB = makeSyntheticCap('mono-b', 'standard', ['mb-skill']);
|
||||
const capMap = new Map([['mono-a', capA], ['mono-b', capB]]);
|
||||
|
||||
const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs');
|
||||
const errors = vcc(capMap, new Set());
|
||||
const monotoneErrors = errors.filter((e) => e.includes('tier-monotone'));
|
||||
assert.deepEqual(monotoneErrors, [], 'Same-tier requires should be accepted, got: ' + JSON.stringify(monotoneErrors));
|
||||
});
|
||||
|
||||
test('tier-monotone: higher-tier requiring lower-tier is accepted (full requires core)', () => {
|
||||
const capA = makeSyntheticCap('full-a', 'full', ['fa-skill']);
|
||||
capA.requires = ['core-b'];
|
||||
const capB = makeSyntheticCap('core-b', 'core', ['cb-skill']);
|
||||
const capMap = new Map([['full-a', capA], ['core-b', capB]]);
|
||||
|
||||
const { validateCrossCapability: vcc } = require('../scripts/gen-capability-registry.cjs');
|
||||
const errors = vcc(capMap, new Set());
|
||||
const monotoneErrors = errors.filter((e) => e.includes('tier-monotone'));
|
||||
assert.deepEqual(
|
||||
monotoneErrors, [],
|
||||
'full requiring core should be accepted (higher tier can require lower tier), got: ' + JSON.stringify(monotoneErrors),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ADR-857 phase 4a: --check determinism after --write', () => {
|
||||
test('serializeRegistry produces identical output for two calls (determinism)', () => {
|
||||
// Regression guard: --check would fail if output is non-deterministic
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const content1 = serializeRegistry(registry, capMap);
|
||||
const content2 = serializeRegistry(registry, capMap);
|
||||
assert.strictEqual(content1, content2, 'serializeRegistry output must be deterministic');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 17. FIX 1: SOFT gate uses real manifest for requires-closure expansion ────
|
||||
|
||||
describe('FIX 1: SOFT gate uses closure-resolved manifest (not empty)', () => {
|
||||
test('plan-phase is transitively in standard — no reconciliation warning', () => {
|
||||
// plan-phase is in PROFILES.standard directly; resolved (with real manifest) = in standard.
|
||||
// A standard-tier cap with skill=plan-phase must NOT generate a reconciliation warning.
|
||||
const cap = makeSyntheticCap('plan-cap', 'standard', ['plan-phase']);
|
||||
const capMap = new Map([['plan-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
const planWarnings = warnings.filter((w) => w.includes('plan-phase'));
|
||||
assert.deepEqual(
|
||||
planWarnings, [],
|
||||
'plan-phase is in standard profile — no warning expected, got: ' + JSON.stringify(planWarnings),
|
||||
);
|
||||
});
|
||||
|
||||
test('FIX 1: skill only transitively in standard (requires-closure) emits no warning', () => {
|
||||
// 'code-review' is brought into standard via requires-closure expansion (not in raw base).
|
||||
// FIX 1 ensures the real manifest is used, so no false-positive warning is emitted.
|
||||
const cap = makeSyntheticCap('cr-cap', 'standard', ['code-review']);
|
||||
const capMap = new Map([['cr-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
const crWarnings = warnings.filter((w) => w.includes('code-review'));
|
||||
assert.deepEqual(
|
||||
crWarnings, [],
|
||||
'code-review is transitively in standard via requires-closure — no warning expected, got: ' + JSON.stringify(crWarnings),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 18. FIX 2: globally-sorted capId emission ───────────────────────────────
|
||||
|
||||
describe('FIX 2: globally-sorted capId emission (determinism with mixed feature+runtime)', () => {
|
||||
test('feature cap "analytics" and feature cap "ui" are globally sorted in serialized output', () => {
|
||||
// "analytics" < "ui" alphabetically — must appear first in both derived views
|
||||
const analyticsCap = makeSyntheticCap('analytics', 'standard', ['analytics-skill']);
|
||||
const capDir = makeTempCapDir({ analytics: analyticsCap, ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const content = serializeRegistry(registry, capMap);
|
||||
|
||||
// Find the positions of "analytics" and "ui" in the capabilityClusters block
|
||||
const clustersStart = content.indexOf('const capabilityClusters');
|
||||
const clustersEnd = content.indexOf('const profileMembership');
|
||||
const clustersBlock = content.slice(clustersStart, clustersEnd);
|
||||
|
||||
const analyticsPos = clustersBlock.indexOf('"analytics"');
|
||||
const uiPos = clustersBlock.indexOf('"ui"');
|
||||
assert.ok(
|
||||
analyticsPos < uiPos,
|
||||
'analytics must appear before ui in capabilityClusters (global alphabetical sort)',
|
||||
);
|
||||
|
||||
// Same check for profileMembership
|
||||
const profileStart = content.indexOf('const profileMembership');
|
||||
const profileEnd = content.indexOf('const _requiresGraph');
|
||||
const profileBlock = content.slice(profileStart, profileEnd);
|
||||
|
||||
const analyticsProfilePos = profileBlock.indexOf('"analytics"');
|
||||
const uiProfilePos = profileBlock.indexOf('"ui"');
|
||||
assert.ok(
|
||||
analyticsProfilePos < uiProfilePos,
|
||||
'analytics must appear before ui in profileMembership (global alphabetical sort)',
|
||||
);
|
||||
});
|
||||
|
||||
test('serialized output is stable across two calls (determinism)', () => {
|
||||
const analyticsCap = makeSyntheticCap('analytics', 'standard', ['analytics-skill']);
|
||||
const capDir = makeTempCapDir({ analytics: analyticsCap, ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
const registry = buildRegistry(capMap);
|
||||
const s1 = serializeRegistry(registry, capMap);
|
||||
const s2 = serializeRegistry(registry, capMap);
|
||||
assert.strictEqual(s1, s2, 'Two serializeRegistry calls must produce identical output');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 19. FIX 3: consistent role scoping across both derived views ─────────────
|
||||
|
||||
describe('FIX 3: consistent scope — capabilities that own skills', () => {
|
||||
test('feature cap with empty skills does not appear in capabilityClusters', () => {
|
||||
// A feature cap with an empty skills array must NOT appear in capabilityClusters
|
||||
const emptySkillsCap = {
|
||||
id: 'empty-skills', role: 'feature', title: 'Empty', description: 'No skills',
|
||||
tier: 'standard', requires: [],
|
||||
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
};
|
||||
const capMap = new Map([['empty-skills', emptySkillsCap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(clusters, 'empty-skills'),
|
||||
'Cap with empty skills array must not appear in capabilityClusters',
|
||||
);
|
||||
});
|
||||
|
||||
test('feature cap with empty skills does not appear in profileMembership', () => {
|
||||
// FIX 3: profileMembership must also exclude caps with no skills (consistent scope)
|
||||
const emptySkillsCap = {
|
||||
id: 'empty-skills', role: 'feature', title: 'Empty', description: 'No skills',
|
||||
tier: 'standard', requires: [],
|
||||
skills: [], agents: [], hooks: [], config: {}, steps: [], contributions: [], gates: [],
|
||||
};
|
||||
const capMap = new Map([['empty-skills', emptySkillsCap]]);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(profiles, 'empty-skills'),
|
||||
'Cap with empty skills array must not appear in profileMembership (FIX 3: consistent scope)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 20. FIX 4: de-duplicated reconciliation warnings ────────────────────────
|
||||
|
||||
describe('FIX 4: de-duplicated reconciliation warnings (one per skill, not per profile)', () => {
|
||||
test('core-tier cap with skill missing from both core and standard emits ONE warning', () => {
|
||||
// ui-phase is not in the hand-authored core or standard profiles.
|
||||
// A core-tier cap with ui-phase must emit exactly 1 warning (listing both profiles).
|
||||
const cap = makeSyntheticCap('core-ui-cap', 'core', ['ui-phase']);
|
||||
const capMap = new Map([['core-ui-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
const uiPhaseWarnings = warnings.filter((w) => w.includes('ui-phase'));
|
||||
assert.strictEqual(
|
||||
uiPhaseWarnings.length, 1,
|
||||
'Expected exactly 1 warning for ui-phase (FIX 4: one per skill, not per profile), got: ' + JSON.stringify(uiPhaseWarnings),
|
||||
);
|
||||
// Warning must list both missing profiles
|
||||
assert.ok(
|
||||
uiPhaseWarnings[0].includes('core') && uiPhaseWarnings[0].includes('standard'),
|
||||
'Warning must list both missing profiles (core and standard), got: ' + uiPhaseWarnings[0],
|
||||
);
|
||||
});
|
||||
|
||||
test('standard-tier cap with skill missing from standard emits ONE warning with <standard>', () => {
|
||||
const cap = makeSyntheticCap('std-ui-cap', 'standard', ['ui-phase']);
|
||||
const capMap = new Map([['std-ui-cap', cap]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
const warnings = runConsistencyGate(clusters, profiles, capMap);
|
||||
assert.strictEqual(warnings.length, 1, 'Expected exactly 1 warning, got: ' + JSON.stringify(warnings));
|
||||
assert.ok(
|
||||
warnings[0].includes('profile(s): <standard>'),
|
||||
'Warning must use "profile(s): <standard>" format, got: ' + warnings[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 21. FIX 5: tierIdx -1 throws loudly ─────────────────────────────────────
|
||||
|
||||
describe('FIX 5: tierIdx === -1 throws loudly (VALID_TIERS/PROFILE_RANK drift guard)', () => {
|
||||
test('normal usage (standard/core/full) does not throw in deriveProfileMembership', () => {
|
||||
const cap = makeSyntheticCap('drift-test', 'standard', ['s1']);
|
||||
const capMap = new Map([['drift-test', cap]]);
|
||||
assert.doesNotThrow(
|
||||
() => deriveProfileMembership(capMap),
|
||||
'deriveProfileMembership must not throw for valid tiers',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 22. FIX 6: UI true-negative doesNotThrow ─────────────────────────────────
|
||||
|
||||
describe('FIX 6: runConsistencyGate does NOT throw for real UI capability (true-negative)', () => {
|
||||
test('buildRegistry with real UI cap does not throw (HARD gate true-negative)', () => {
|
||||
// The real UI cap has skills = [ui-phase, ui-review] which matches CLUSTERS.ui exactly.
|
||||
// The HARD gate must NOT throw.
|
||||
const capDir = makeTempCapDir({ ui: UI_CAP });
|
||||
const { capMap } = loadAndValidate(new Set(), capDir);
|
||||
assert.doesNotThrow(
|
||||
() => buildRegistry(capMap),
|
||||
'buildRegistry must not throw for the real UI capability (CLUSTERS match expected)',
|
||||
);
|
||||
});
|
||||
|
||||
test('runConsistencyGate does NOT throw for real UI cap (cluster match true-negative)', () => {
|
||||
// Explicit doesNotThrow covering runConsistencyGate directly
|
||||
const capMap = new Map([['ui', UI_CAP]]);
|
||||
const clusters = deriveCapabilityClusters(capMap);
|
||||
const profiles = deriveProfileMembership(capMap);
|
||||
assert.doesNotThrow(
|
||||
() => runConsistencyGate(clusters, profiles, capMap),
|
||||
'runConsistencyGate must not throw for UI cap (CLUSTERS.ui matches ui.skills)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user