diff --git a/CONTEXT.md b/CONTEXT.md index 18699859e..ff7058fef 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 `` 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//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` (`{ : [] }` — each cap's skills array, sorted, derived from the capability's `skills` declaration; consistency-gated against the hand-authored `CLUSTERS`) and `profileMembership` (`{ : { 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//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. diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index df7bd5e41..0335e5337 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -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, }; diff --git a/scripts/gen-capability-registry.cjs b/scripts/gen-capability-registry.cjs index a9cf1861d..24ab9cf72 100644 --- a/scripts/gen-capability-registry.cjs +++ b/scripts/gen-capability-registry.cjs @@ -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: { : [] } + * 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} 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: { : { tier: , profiles: [] } } + * 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} 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} 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 ────────────────────────────────────────────────────────── diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index fb204587a..218753063 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -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 ', () => { + 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): '), + 'Warning must use "profile(s): " 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)', + ); + }); +});