diff --git a/bin/install.js b/bin/install.js index 20865d067..81d4a698c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -324,6 +324,13 @@ const { stageAgentsForProfile, stageSkillsForRuntimeAsSkills, } = require(path.join(_gsdLibDir, 'install-profiles.cjs')); +// ADR-857 phase 4c: load capability registry (optional; missing → falls back to undefined) +let _capabilityRegistry; +try { + _capabilityRegistry = require(path.join(_gsdLibDir, 'capability-registry.cjs')); +} catch (_) { + _capabilityRegistry = undefined; +} const { applyInstallerMigrationPlan, discoverInstallerMigrations, @@ -10057,7 +10064,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { // @-references resolve correctly (#2376 Windows, #2831 macOS/Linux). // gsd update marker re-application (ADR-0010 Deviation 2): // Resolve which profile to use for this runtime's install: - // 1. --minimal / --core-only → back-compat path (stageSkillsForMode keeps strict core allowlist) + // 1. --minimal / --core-only → back-compat alias for the core profile // 2. Explicit --profile= → use it (overrides any marker) // 3. Marker exists in targetDir → honor it (prevents silent expansion on update) // 4. Else → 'full' (back-compat for fresh non-interactive installs) @@ -10066,8 +10073,16 @@ function install(isGlobal, runtime = 'claude', options = {}) { // differ, the caller may use mostRestrictiveProfile() across the per-runtime // results — here we resolve each runtime independently. // - // Note: --minimal uses stageSkillsForMode (back-compat: strict allowlist, no closure). - // Named profiles (--profile=X or marker-driven) use resolveProfile() for transitive closure. + // ADR-857 phase 4c: ALL profiles (including core/minimal) use stageSkillsForProfile + // with the registry-aware _resolvedProfile so future tier:core capabilities are + // staged on core installs. The 'minimal' back-compat distinction is now ONLY the + // empty manifest (core profile has no transitive deps); the registry IS consulted. + // MINIMAL is intentionally the same skill set as the 'core' profile + // (MINIMAL_ALLOWLIST_SET === Set(PROFILES.core)) — it is NOT a separately curated + // subset. Any future tier:core capability therefore DOES belong in a minimal/core + // install. Using stageSkillsForProfile(_resolvedProfile) honors the registry while + // keeping the effective skill set identical to the prior stageSkillsForMode path + // until a tier:core capability is registered. const _activeProfileName = hasMinimal ? 'core' // --minimal is a back-compat alias for the core profile; marker records 'core' : resolveEffectiveProfile({ @@ -10078,19 +10093,18 @@ function install(isGlobal, runtime = 'claude', options = {}) { const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full'; // Load the manifest and compute resolved profile for named profiles. // For --minimal/core: use an empty manifest (core profile has no transitive - // deps) to produce a resolvedProfile with the core skill set. This allows - // installRuntimeArtifacts to use stageSkillsForProfile uniformly across all - // profile modes without a null sentinel. + // deps) to produce a resolvedProfile with the core skill set. Registry IS + // consulted so tier:core capability skills are included when registered. const _commandsDir = path.join(src, 'commands', 'gsd'); const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir); const _resolvedProfile = resolveProfile({ modes: [_activeProfileName], manifest: _skillsManifest, + registry: _capabilityRegistry, }); - // Unified staging function: for --minimal uses stageSkillsForMode (back-compat); - // for named profiles uses stageSkillsForProfile (new API with transitive closure). + // Unified staging function: all profiles use stageSkillsForProfile with the + // registry-aware _resolvedProfile (ADR-857 phase 4c cutover). function _stageSkills(commandsGsdDir) { - if (_isCoreProfileAlias) return stageSkillsForMode(commandsGsdDir, _effectiveInstallMode); return stageSkillsForProfile(commandsGsdDir, _resolvedProfile); } function _stageAgents(agentsDir) { diff --git a/capabilities/ui/capability.json b/capabilities/ui/capability.json index 1d014923c..dbd4c2745 100644 --- a/capabilities/ui/capability.json +++ b/capabilities/ui/capability.json @@ -1,7 +1,7 @@ { "id": "ui", "role": "feature", "title": "UI design contracts", "description": "UI-SPEC design contract + retrospective UI audit for frontend phases.", - "tier": "standard", "requires": [], + "tier": "full", "requires": [], "skills": ["ui-phase", "ui-review"], "agents": ["gsd-ui-checker", "gsd-ui-auditor"], "hooks": [], diff --git a/commands/gsd/surface.md b/commands/gsd/surface.md index e1cf10ced..3ba1f4e84 100644 --- a/commands/gsd/surface.md +++ b/commands/gsd/surface.md @@ -36,8 +36,12 @@ Parse the first token of $ARGUMENTS: ## list / status -Call `listSurface(runtimeConfigDir, manifest, CLUSTERS)` from -`gsd-core/bin/lib/surface.cjs`. Display: +Load the capability registry and call `listSurface(runtimeConfigDir, manifest, CLUSTERS, registry)` from +`gsd-core/bin/lib/surface.cjs`. The registry is loaded via: +```js +const registry = require('gsd-core/bin/lib/capability-registry.cjs'); +``` +Display: ``` Enabled (N skills, ~T tokens): @@ -67,8 +71,9 @@ Install profile: standard (from .gsd-profile) 3. `writeSurface(runtimeConfigDir, surfaceState)`. 4. Resolve and re-apply: ```js + const registry = require('gsd-core/bin/lib/capability-registry.cjs'); const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS, registry); ``` 5. Confirm: "Surface updated to profile ``. N skills enabled." @@ -84,8 +89,9 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` 3. Add cluster to `surfaceState.disabledClusters` (deduplicate). 4. `writeSurface` → resolve layout → `applySurface`: ```js + const registry = require('gsd-core/bin/lib/capability-registry.cjs'); const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS, registry); ``` 5. Confirm: "Disabled cluster ``. N skills removed from surface." @@ -97,8 +103,9 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` 2. Remove cluster from `surfaceState.disabledClusters`. 3. `writeSurface` → resolve layout → `applySurface`: ```js + const registry = require('gsd-core/bin/lib/capability-registry.cjs'); const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS, registry); ``` 4. Confirm: "Enabled cluster ``. N skills added back to surface." diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 0335e5337..ec7582a47 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -12,7 +12,7 @@ const capabilities = { "role": "feature", "title": "UI design contracts", "description": "UI-SPEC design contract + retrospective UI audit for frontend phases.", - "tier": "standard", + "tier": "full", "requires": [], "skills": [ "ui-phase", @@ -239,9 +239,8 @@ const capabilityClusters = { const profileMembership = { "ui": { - "tier": "standard", + "tier": "full", "profiles": [ - "standard", "full" ] } diff --git a/src/capability-state.cts b/src/capability-state.cts index f488a75da..6867e0ff6 100644 --- a/src/capability-state.cts +++ b/src/capability-state.cts @@ -330,6 +330,14 @@ function cmdCapabilityState( } } + // ── Load registry (ADR-857 phase 4c) ──────────────────────────────────────── + // Load BEFORE resolveProfile and resolveSurface so both calls receive the + // registry and capability-contributed skills are reflected in installed/surfaced. + // No-op today (UI capability is tier:full → only adds to 'full', which returns + // '*' regardless) but cutover-ready for future tier:core/standard capabilities. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const registry = require('./capability-registry.cjs') as Record; + // ── Resolve installed skills (from install profile) ────────────────────────── // Distinguish "no profile marker → default full" (legitimate) from a thrown // error (surface as a warning and degrade gracefully — do NOT silently report @@ -342,6 +350,7 @@ function cmdCapabilityState( const resolvedInstall = resolveProfile({ modes: profileName.split(',').map((s: string) => s.trim()), manifest, + registry, }); installedSkills = resolvedInstall.skills; } catch (err: unknown) { @@ -358,7 +367,7 @@ function cmdCapabilityState( try { const commandsGsdDir = _resolveCommandsGsdDir(); const manifest = loadSkillsManifest(commandsGsdDir); - const surfaceResult = resolveSurface(resolvedConfigDir, manifest); + const surfaceResult = resolveSurface(resolvedConfigDir, manifest, undefined, registry); // resolveSurface returns { name, skills: Set, agents: Set } // (always a concrete Set — full profile is materialized) surfacedSkills = surfaceResult.skills instanceof Set @@ -380,9 +389,7 @@ function cmdCapabilityState( config = {}; } - // ── Load registry and resolve state ───────────────────────────────────────── - // eslint-disable-next-line @typescript-eslint/no-require-imports - const registry = require('./capability-registry.cjs') as Record; + // ── Resolve state ──────────────────────────────────────────────────────────── const result = resolveCapabilityState({ registry, diff --git a/src/install-profiles.cts b/src/install-profiles.cts index fcf5afb10..f847c88ab 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -163,16 +163,53 @@ interface ResolvedProfile { agents: Set; } +interface CapabilityRegistry { + capabilityClusters?: Record; + profileMembership?: Record; +} + interface ResolveProfileOpts { modes?: string[]; manifest?: Map; _profilesOverride?: Record; + /** ADR-857 phase 4c: optional capability registry; when present, capability + * skills are unioned into the base set for each resolved mode before closure. */ + registry?: CapabilityRegistry; +} + +/** + * Compute the capability skills to add for a given profile mode from the registry. + * Returns an array of skill stems contributed by capabilities whose profileMembership + * includes the given mode. Guards against prototype pollution and malformed registry. + */ +function _capabilitySkillsForMode(mode: string, registry: CapabilityRegistry): string[] { + const BANNED = ['__proto__', 'constructor', 'prototype']; + const clusters = registry.capabilityClusters; + const membership = registry.profileMembership; + if (!clusters || typeof clusters !== 'object' || !membership || typeof membership !== 'object') { + return []; + } + const result: string[] = []; + for (const capId of Object.keys(clusters)) { + if (BANNED.includes(capId)) continue; + const mem = membership[capId]; + if (!mem || typeof mem !== 'object') continue; + const profiles = mem.profiles; + if (!Array.isArray(profiles)) continue; + if (!profiles.includes(mode)) continue; + const skills = clusters[capId]; + if (!Array.isArray(skills)) continue; + for (const s of skills) { + if (typeof s === 'string' && s.length > 0) result.push(s); + } + } + return result; } /** * Resolve a profile (or composed profiles) to a typed result object. */ -function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOpts = {}): ResolvedProfile { +function resolveProfile({ modes, manifest, _profilesOverride, registry }: ResolveProfileOpts = {}): ResolvedProfile { const profiles: Record = _profilesOverride || PROFILES; const activeModes = (modes && modes.length > 0) ? modes : ['full']; const normalizedModes = activeModes @@ -201,7 +238,11 @@ function resolveProfile({ modes, manifest, _profilesOverride }: ResolveProfileOp // This profile is full — sentinel short-circuit return { name: 'full', skills: '*', agents: new Set() }; } - const closure = computeClosure(base as Iterable, man); + // ADR-857 phase 4c: union capability skills for this mode BEFORE closure so + // their requires: chains expand too. + const capSkills = registry ? _capabilitySkillsForMode(mode, registry) : []; + const baseWithCap: string[] = [...(base as Iterable), ...capSkills]; + const closure = computeClosure(baseWithCap, man); for (const s of closure) unionSkills.add(s); } diff --git a/src/surface.cts b/src/surface.cts index b3cf05bb5..bbd04141d 100644 --- a/src/surface.cts +++ b/src/surface.cts @@ -11,11 +11,18 @@ * Exports: * readSurface(runtimeConfigDir) * writeSurface(runtimeConfigDir, surfaceState) - * resolveSurface(runtimeConfigDir, manifest, clusterMap) - * applySurface(runtimeConfigDir, layout, manifest, clusterMap) - * listSurface(runtimeConfigDir, manifest, clusterMap) + * resolveSurface(runtimeConfigDir, manifest, clusterMap?, registry?) + * applySurface(runtimeConfigDir, layout, manifest, clusterMap?, registry?) + * listSurface(runtimeConfigDir, manifest, clusterMap?, registry?) * pruneSkillDirs(skillsDir, retainedNames, prefix, manifest) * + * The optional `registry` param (ADR-857 phase 4c) accepts the capability-registry + * object. When present, capability clusters are merged into the effective cluster + * map and the registry is threaded into resolveProfile so capability-contributed + * skills participate in the base set and disable-ability. Absent or undefined + * leaves behaviour identical to the pre-registry path (no-op for current registry + * where UI=full and the full profile returns '*' regardless). + * * ADR-457 build-at-publish: the hand-written bin/lib/surface.cjs collapsed * to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour * from the prior hand-written .cjs; only types are added. @@ -124,9 +131,9 @@ function clustersToSkills(clusterNames: string[], clusterMap: ClusterMap | Recor const result = new Set(); for (const name of clusterNames) { const members = (clusterMap as Record | undefined>)[name]; - if (members) { - for (const s of members) result.add(s); - } + // FIX 5: guard against non-iterable members — malformed registry must never throw + if (!Array.isArray(members)) continue; + for (const s of (members as string[])) result.add(s); } return result; } @@ -174,9 +181,46 @@ function normalizeSkillManifest(runtimeConfigDir: string, manifest: Map | object, clusterMap?: ClusterMap | Record): { name: string; skills: Set; agents: Set } { - const cm = clusterMap || CLUSTERS; +function resolveSurface(runtimeConfigDir: string, manifest: Map | object, clusterMap?: ClusterMap | Record, registry?: { capabilityClusters?: Record; profileMembership?: Record }): { name: string; skills: Set; agents: Set } { + // Merge capability clusters into the cluster map when registry is provided. + // The ADR-857 phase 4a HARD gate guarantees that when a capId matches a CLUSTERS + // key, the values are EQUAL — so the spread is idempotent for matching names. + // Defense-in-depth: if a capId collides with a hand-authored CLUSTERS key AND + // the values DIFFER (future drift bypassing the gate), prefer the hand-authored + // value so disable behavior is never silently changed by a stale registry entry. + // Also guard: skip entries whose value is not a string[] (malformed registry). + let cm: ClusterMap | Record = clusterMap || CLUSTERS; + if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') { + const baseCm = cm; + const capClusters = registry.capabilityClusters; + const merged: Record = { ...(baseCm as Record) }; + for (const capId of Object.keys(capClusters)) { + const val = capClusters[capId]; + // FIX 5: skip malformed (non-array) entries — never throw on bad registry + if (!Array.isArray(val)) continue; + // FIX 4: if the capId matches an existing cluster key, only override when + // the values are identical (guaranteed by 4a gate). If they differ, the + // hand-authored value wins — prefer known-correct disable behavior over + // a potentially stale registry entry. + if (Object.prototype.hasOwnProperty.call(baseCm, capId)) { + const existing = (baseCm as Record)[capId]; + if (!Array.isArray(existing)) { merged[capId] = val; continue; } + // Values differ → hand-authored wins (skip the override) + if (existing.length !== val.length || existing.some((v, i) => v !== val[i])) continue; + } + // Prototype-pollution guard (parity with _capabilitySkillsForMode in install-profiles.cts) + if (capId === '__proto__' || capId === 'constructor' || capId === 'prototype') continue; + merged[capId] = val; + } + cm = merged; + } const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest); const surface = readSurface(runtimeConfigDir); @@ -185,10 +229,11 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map s.trim()), manifest: skillManifest, + registry, }); // If full, we need to enumerate all skills from the manifest @@ -252,12 +297,12 @@ function resolveSurface(runtimeConfigDir: string, manifest: Map | object, clusterMap?: ClusterMap | Record): { name: string; skills: Set; agents: Set } { +function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map | object, clusterMap?: ClusterMap | Record, registry?: { capabilityClusters?: Record; profileMembership?: Record }): { name: string; skills: Set; agents: Set } { if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) { throw new TypeError('applySurface runtimeConfigDir must match layout.configDir'); } const skillManifest = normalizeSkillManifest(layout.configDir, manifest); - const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap); + const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap, registry); // Mirror installRuntimeArtifacts: skills kinds get per-runtime path rewrites // so SKILL.md bodies reference the install target (pathPrefix), not the // converter's default ~/.claude paths (#813). Computed lazily so command-only @@ -461,9 +506,9 @@ function _syncGsdDir(stagedDir: string, destDir: string, kind: ArtifactKind | st * Token cost = sum of description lengths ÷ 4 (mirrors audit script). * Descriptions are read from the install source (findInstallSourceRoot). */ -function listSurface(runtimeConfigDir: string, manifest: Map | object, clusterMap?: ClusterMap | Record): { enabled: string[]; disabled: string[]; tokenCost: number } { +function listSurface(runtimeConfigDir: string, manifest: Map | object, clusterMap?: ClusterMap | Record, registry?: { capabilityClusters?: Record; profileMembership?: Record }): { enabled: string[]; disabled: string[]; tokenCost: number } { const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest); - const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap); + const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap, registry); // All known stems from manifest (exclude _calls_agents_ meta keys) const allStems: string[] = []; diff --git a/tests/capability-consumption.test.cjs b/tests/capability-consumption.test.cjs new file mode 100644 index 000000000..6d8010f75 --- /dev/null +++ b/tests/capability-consumption.test.cjs @@ -0,0 +1,786 @@ +'use strict'; +/** + * capability-consumption.test.cjs — ADR-857 phase 4c + * + * Tests that resolveProfile and resolveSurface correctly CONSUME the capability + * registry (capabilityClusters + profileMembership) in an additive, no-op-when- + * current manner. + * + * Test categories: + * 1. RECONCILIATION — registry profileMembership.ui is {tier:'full',profiles:['full']} + * 2. EQUIVALENCE — registry present with REAL registry = same result as absent + * 3. FUNCTIONAL — synthetic registry adds capability skills at correct tiers + * 4. SURFACE EQUIVALENCE — resolveSurface with vs without real registry + * 5. SURFACE FUNCTIONAL — synthetic registry cluster merge + disable-ability + * 6. LIVE-PATH EQUIVALENCE — resolveSurface with disabledClusters/adds/removes, real registry + * 7. listSurface EQUIVALENCE — listSurface with vs without real registry + * 8. FIX-3 CORE INSTALL — synthetic tier:core capability skills in stageSkillsForProfile + * 9. FIX-4 DIVERGENCE GUARD — colliding capId + CLUSTERS name with diff value → hand-authored wins + * 10. FIX-5 MALFORMED-ARRAY — non-array capabilityClusters entry is skipped without throw + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { resolveProfile, loadSkillsManifest, stageSkillsForProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { resolveSurface, writeSurface, listSurface } = require('../gsd-core/bin/lib/surface.cjs'); +const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-cons-')); +} + +/** Compare two Sets for equality (membership only). */ +function setsEqual(a, b) { + if (!(a instanceof Set) || !(b instanceof Set)) return false; + if (a.size !== b.size) return false; + for (const x of a) if (!b.has(x)) return false; + return true; +} + +function setDiff(a, b) { + const extra = [...a].filter(x => !b.has(x)); + const missing = [...b].filter(x => !a.has(x)); + return { extra, missing }; +} + +// ─── 1. RECONCILIATION ────────────────────────────────────────────────────── + +describe('registry reconciliation: UI tier=full', () => { + test('profileMembership.ui.tier is "full"', () => { + assert.strictEqual( + realRegistry.profileMembership.ui.tier, + 'full', + 'After tier:full reconciliation, profileMembership.ui.tier must be "full"' + ); + }); + + test('profileMembership.ui.profiles contains only ["full"]', () => { + assert.deepStrictEqual( + realRegistry.profileMembership.ui.profiles, + ['full'], + 'tier:full capability should only appear in the full profile' + ); + }); + + test('capabilityClusters.ui is ["ui-phase","ui-review"]', () => { + assert.deepStrictEqual( + realRegistry.capabilityClusters.ui, + ['ui-phase', 'ui-review'], + 'capabilityClusters.ui should list both UI skills' + ); + }); +}); + +// ─── 2. EQUIVALENCE: resolveProfile ───────────────────────────────────────── +// +// For every named profile, resolveProfile with and without the real registry +// MUST produce identical skill and agent sets. +// +// The real registry's UI capability is tier:full → only included in 'full' profile. +// 'full' early-returns '*' (sentinel path) regardless of registry, so the real +// registry adds NOTHING for any current profile. This is the no-op proof. + +describe('resolveProfile equivalence: real registry is a no-op', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + + for (const profile of ['core', 'standard', 'full']) { + test(`profile="${profile}" — same skills with vs without real registry`, () => { + const without = resolveProfile({ modes: [profile], manifest }); + const withReg = resolveProfile({ modes: [profile], manifest, registry: realRegistry }); + + if (without.skills === '*') { + assert.strictEqual(withReg.skills, '*', + `${profile}: sentinel path must be preserved with registry`); + } else { + assert.ok( + setsEqual(without.skills, withReg.skills), + `${profile}: skills differ. Extra: ${[...setDiff(withReg.skills, without.skills).extra]}. Missing: ${[...setDiff(withReg.skills, without.skills).missing]}` + ); + assert.ok( + setsEqual(without.agents, withReg.agents), + `${profile}: agents differ` + ); + } + }); + } + + test('profile="core,standard" composed — no-op with real registry', () => { + const without = resolveProfile({ modes: ['core', 'standard'], manifest }); + const withReg = resolveProfile({ modes: ['core', 'standard'], manifest, registry: realRegistry }); + assert.ok( + setsEqual(without.skills, withReg.skills), + 'composed profile: skills must be identical with and without real registry' + ); + }); +}); + +// ─── 3. FUNCTIONAL: synthetic registry ────────────────────────────────────── + +describe('resolveProfile functional: synthetic registry adds capability skills', () => { + // Minimal manifest: foo-skill exists (capability-owned), help exists (profile base) + const manifest = new Map([ + ['help', []], + ['foo-skill', []], + ['bar-skill', ['foo-skill']], // bar depends on foo (transitive test) + ['_calls_agents_help', []], + ['_calls_agents_foo-skill', []], + ['_calls_agents_bar-skill', []], + ]); + // Synthetic registry: 'foo' capability is in core/standard/full + const syntheticRegistry = { + capabilityClusters: { foo: ['foo-skill'] }, + profileMembership: { foo: { tier: 'core', profiles: ['core', 'standard', 'full'] } }, + }; + // Override profiles so 'core' does NOT include foo-skill in its base list + const profilesOverride = { + core: ['help'], + standard: ['help'], + full: '*', + }; + + test('core profile WITHOUT registry: foo-skill absent', () => { + const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: profilesOverride }); + assert.ok(result.skills instanceof Set, 'skills must be a Set'); + assert.ok(!result.skills.has('foo-skill'), 'foo-skill should NOT be in core without registry'); + }); + + test('core profile WITH synthetic registry: foo-skill is included', () => { + const result = resolveProfile({ + modes: ['core'], + manifest, + _profilesOverride: profilesOverride, + registry: syntheticRegistry, + }); + assert.ok(result.skills instanceof Set, 'skills must be a Set'); + assert.ok(result.skills.has('foo-skill'), + 'foo-skill should be in core when capability registry maps it to core'); + assert.ok(result.skills.has('help'), + 'core base skill must still be present'); + }); + + test('standard profile WITH synthetic registry: foo-skill is included', () => { + const result = resolveProfile({ + modes: ['standard'], + manifest, + _profilesOverride: profilesOverride, + registry: syntheticRegistry, + }); + assert.ok(result.skills.has('foo-skill'), + 'foo-skill should be in standard when capability registry maps it to standard'); + }); + + test('transitive closure: capability skill that requires another skill pulls it in', () => { + const transitiveRegistry = { + capabilityClusters: { bar: ['bar-skill'] }, + profileMembership: { bar: { tier: 'core', profiles: ['core', 'standard', 'full'] } }, + }; + const result = resolveProfile({ + modes: ['core'], + manifest, + _profilesOverride: profilesOverride, + registry: transitiveRegistry, + }); + assert.ok(result.skills.has('bar-skill'), + 'bar-skill from capability cluster should be included'); + assert.ok(result.skills.has('foo-skill'), + 'foo-skill should be pulled in transitively (bar-skill requires foo-skill)'); + }); + + test('full profile with synthetic registry: returns sentinel (not skill set)', () => { + // full always returns '*' — synthetic registry must not break this + const result = resolveProfile({ + modes: ['full'], + manifest, + _profilesOverride: profilesOverride, + registry: syntheticRegistry, + }); + assert.strictEqual(result.skills, '*', + 'full profile must always return "*" sentinel regardless of registry'); + }); + + test('capability whose profiles do NOT include mode: skill not added', () => { + const fullOnlyRegistry = { + capabilityClusters: { foo: ['foo-skill'] }, + profileMembership: { foo: { tier: 'full', profiles: ['full'] } }, + }; + const result = resolveProfile({ + modes: ['core'], + manifest, + _profilesOverride: profilesOverride, + registry: fullOnlyRegistry, + }); + assert.ok(!result.skills.has('foo-skill'), + 'foo-skill must NOT appear in core when capability only maps to full'); + }); + + test('malformed registry (missing capabilityClusters) is tolerated — no throw', () => { + const badRegistry = { profileMembership: { foo: { tier: 'core', profiles: ['core'] } } }; + assert.doesNotThrow(() => { + resolveProfile({ modes: ['core'], manifest, _profilesOverride: profilesOverride, registry: badRegistry }); + }); + }); + + test('malformed registry (non-array skills) is tolerated — no throw', () => { + const badRegistry = { + capabilityClusters: { foo: 'not-an-array' }, + profileMembership: { foo: { tier: 'core', profiles: ['core'] } }, + }; + assert.doesNotThrow(() => { + resolveProfile({ modes: ['core'], manifest, _profilesOverride: profilesOverride, registry: badRegistry }); + }); + }); + + test('prototype pollution guard: __proto__ key in capabilityClusters is skipped', () => { + const pollutionRegistry = { + capabilityClusters: { __proto__: ['foo-skill'], foo: ['foo-skill'] }, + profileMembership: { __proto__: { tier: 'core', profiles: ['core'] }, foo: { tier: 'core', profiles: ['core'] } }, + }; + // Should not throw and should not corrupt Object.prototype + assert.doesNotThrow(() => { + resolveProfile({ + modes: ['core'], + manifest, + _profilesOverride: profilesOverride, + registry: pollutionRegistry, + }); + }); + }); +}); + +// ─── 4. SURFACE EQUIVALENCE ───────────────────────────────────────────────── + +describe('resolveSurface equivalence: real registry is a no-op', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + + function makeSurfaceDir(profile, disabledClusters) { + const dir = tmpDir(); + writeSurface(dir, { + baseProfile: profile, + disabledClusters: disabledClusters || [], + explicitAdds: [], + explicitRemoves: [], + }); + return dir; + } + + for (const profile of ['core', 'standard', 'full']) { + test(`profile="${profile}" surface — same skills with vs without real registry`, (t) => { + const dir1 = makeSurfaceDir(profile, []); + const dir2 = makeSurfaceDir(profile, []); + t.after(() => { cleanup(dir1); cleanup(dir2); }); + + const without = resolveSurface(dir1, manifest); + const withReg = resolveSurface(dir2, manifest, undefined, realRegistry); + + if (without.skills === '*' || withReg.skills === '*') { + // Both should be sets after resolveSurface materializes full + assert.ok( + setsEqual(without.skills, withReg.skills), + `${profile}: sentinel mismatch between with/without registry` + ); + } else { + assert.ok( + setsEqual(without.skills, withReg.skills), + `${profile}: surface skills differ. Extra: ${[...setDiff(withReg.skills, without.skills).extra]}. Missing: ${[...setDiff(withReg.skills, without.skills).missing]}` + ); + } + }); + } +}); + +// ─── 5. SURFACE FUNCTIONAL ────────────────────────────────────────────────── + +describe('resolveSurface functional: synthetic registry cluster merge', () => { + const manifest = new Map([ + ['help', []], + ['foo-skill', []], + ['_calls_agents_help', []], + ['_calls_agents_foo-skill', []], + ]); + + // resolveSurface calls resolveProfile internally without _profilesOverride, so we test + // the cluster merge path using disabledClusters + a full base profile (which materializes + // all manifest skills, so foo-skill is present before any cluster disable). + + + test('synthetic capability cluster is disable-able via disabledClusters', (t) => { + // Write a surface state that disables the synthetic capability cluster by its capId key. + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + // Use 'full' base profile — resolveSurface materializes all manifest skills. + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: ['foo'], // disable the synthetic capability cluster + explicitAdds: [], + explicitRemoves: [], + }); + + const syntheticRegistry = { + capabilityClusters: { foo: ['foo-skill'] }, + profileMembership: { foo: { tier: 'full', profiles: ['full'] } }, + }; + + const without = resolveSurface(dir, manifest); + const withReg = resolveSurface(dir, manifest, undefined, syntheticRegistry); + + // Without registry: 'foo' is not a known cluster key, so disabledClusters=['foo'] + // removes nothing → foo-skill stays enabled. + assert.ok(without.skills.has('foo-skill'), + 'without registry, foo-skill should remain (foo cluster unknown)'); + + // With registry: 'foo' cluster is known, disabledClusters=['foo'] removes foo-skill. + assert.ok(!withReg.skills.has('foo-skill'), + 'with registry, foo-skill should be disabled when its capability cluster is in disabledClusters'); + + // Non-capability skills must not be affected. + assert.ok(withReg.skills.has('help'), + 'help should remain enabled (not in foo capability cluster)'); + }); + + test('resolveSurface with absent registry still returns correct result', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + assert.doesNotThrow(() => { + resolveSurface(dir, manifest); + }); + }); + + test('resolveSurface with malformed registry.capabilityClusters is tolerated', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const badRegistry = { capabilityClusters: null, profileMembership: {} }; + assert.doesNotThrow(() => { + resolveSurface(dir, manifest, undefined, badRegistry); + }); + }); +}); + +// ─── 6. LIVE-PATH EQUIVALENCE ─────────────────────────────────────────────── +// +// FIX 6: prove no-op across the LIVE paths (disabledClusters, adds, removes, +// non-empty base profile) with the real registry vs without. + +describe('resolveSurface live-path equivalence: real registry is a no-op', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + + test('disabledClusters:["ui"] surface — same with vs without real registry', (t) => { + // 'ui' exists in CLUSTERS (the hand-authored map) so the registry's entry + // must be equal (4a gate) — disable outcome identical with or without registry. + const dir1 = tmpDir(); + const dir2 = tmpDir(); + t.after(() => { cleanup(dir1); cleanup(dir2); }); + + for (const dir of [dir1, dir2]) { + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: ['ui'], + explicitAdds: [], + explicitRemoves: [], + }); + } + + const without = resolveSurface(dir1, manifest); + const withReg = resolveSurface(dir2, manifest, undefined, realRegistry); + + assert.ok( + setsEqual(without.skills, withReg.skills), + `disabledClusters:["ui"] — skills differ. Extra: ${[...setDiff(withReg.skills, without.skills).extra]}. Missing: ${[...setDiff(withReg.skills, without.skills).missing]}` + ); + }); + + test('explicit adds — same with vs without real registry', (t) => { + const dir1 = tmpDir(); + const dir2 = tmpDir(); + t.after(() => { cleanup(dir1); cleanup(dir2); }); + + for (const dir of [dir1, dir2]) { + writeSurface(dir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: ['review'], + explicitRemoves: [], + }); + } + + const without = resolveSurface(dir1, manifest); + const withReg = resolveSurface(dir2, manifest, undefined, realRegistry); + + assert.ok( + setsEqual(without.skills, withReg.skills), + `explicit adds — skills differ. Extra: ${[...setDiff(withReg.skills, without.skills).extra]}. Missing: ${[...setDiff(withReg.skills, without.skills).missing]}` + ); + }); + + test('explicit removes — same with vs without real registry', (t) => { + const dir1 = tmpDir(); + const dir2 = tmpDir(); + t.after(() => { cleanup(dir1); cleanup(dir2); }); + + for (const dir of [dir1, dir2]) { + writeSurface(dir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: ['review'], + }); + } + + const without = resolveSurface(dir1, manifest); + const withReg = resolveSurface(dir2, manifest, undefined, realRegistry); + + assert.ok( + setsEqual(without.skills, withReg.skills), + `explicit removes — skills differ. Extra: ${[...setDiff(withReg.skills, without.skills).extra]}. Missing: ${[...setDiff(withReg.skills, without.skills).missing]}` + ); + }); +}); + +// ─── 7. listSurface EQUIVALENCE ───────────────────────────────────────────── +// +// FIX 6: prove listSurface is a no-op with real registry. + +describe('listSurface equivalence: real registry is a no-op', () => { + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + + for (const profile of ['core', 'standard', 'full']) { + test(`listSurface profile="${profile}" — enabled/disabled/tokenCost identical with vs without real registry`, (t) => { + const dir1 = tmpDir(); + const dir2 = tmpDir(); + t.after(() => { cleanup(dir1); cleanup(dir2); }); + + for (const dir of [dir1, dir2]) { + writeSurface(dir, { + baseProfile: profile, + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + } + + const without = listSurface(dir1, manifest); + const withReg = listSurface(dir2, manifest, undefined, realRegistry); + + assert.deepStrictEqual(without.enabled, withReg.enabled, + `${profile}: enabled list differs`); + assert.deepStrictEqual(without.disabled, withReg.disabled, + `${profile}: disabled list differs`); + assert.strictEqual(without.tokenCost, withReg.tokenCost, + `${profile}: tokenCost differs`); + }); + } + + test('listSurface with disabledClusters:["utility"] — identical with vs without real registry', (t) => { + const dir1 = tmpDir(); + const dir2 = tmpDir(); + t.after(() => { cleanup(dir1); cleanup(dir2); }); + + for (const dir of [dir1, dir2]) { + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: ['utility'], + explicitAdds: [], + explicitRemoves: [], + }); + } + + const without = listSurface(dir1, manifest); + const withReg = listSurface(dir2, manifest, undefined, realRegistry); + + assert.deepStrictEqual(without.enabled, withReg.enabled, + 'disabled utility cluster: enabled list differs'); + assert.deepStrictEqual(without.disabled, withReg.disabled, + 'disabled utility cluster: disabled list differs'); + }); +}); + +// ─── 8. FIX-3 CORE INSTALL ───────────────────────────────────────────────── +// +// FIX 6: stageSkillsForProfile with a registry-aware _resolvedProfile for the +// core profile includes synthetic tier:core capability skills. +// Today (real registry, tier:full): stageSkillsForProfile(core) == PROFILES.core. +// With a synthetic tier:core registry: capability skill is present in staged set. + +describe('FIX-3 core install: stageSkillsForProfile honors tier:core capabilities', () => { + test('real registry: core-profile staged set equals PROFILES.core (no-op today)', (_t) => { + const coreManifest = new Map(); // empty — core has no transitive deps + const { PROFILES } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const expectedCore = new Set(PROFILES.core); + + const resolvedWithReg = resolveProfile({ + modes: ['core'], + manifest: coreManifest, + registry: realRegistry, + }); + assert.ok(resolvedWithReg.skills instanceof Set, 'core profile must return a Set (not sentinel)'); + assert.ok( + setsEqual(resolvedWithReg.skills, expectedCore), + `core profile with real registry differs from PROFILES.core. Extra: ${[...setDiff(resolvedWithReg.skills, expectedCore).extra]}. Missing: ${[...setDiff(resolvedWithReg.skills, expectedCore).missing]}` + ); + }); + + test('synthetic tier:core registry: capability skill appears in stageSkillsForProfile output', (t) => { + // We cannot add a real skill file, but we can verify the resolved profile + // contains the synthetic skill from the registry — the staged set is profile-driven. + // The synthetic skill 'foo-skill' is not in commands/gsd, but resolveProfile WOULD + // include it. We verify the profile-level inclusion here; stageSkillsForProfile + // would then copy it if a file existed. + const syntheticManifest = new Map([ + ['help', []], + ['foo-skill', []], + ['_calls_agents_help', []], + ['_calls_agents_foo-skill', []], + ]); + const profilesOverride = { core: ['help'], standard: ['help'], full: '*' }; + const syntheticRegistry = { + capabilityClusters: { foo: ['foo-skill'] }, + profileMembership: { foo: { tier: 'core', profiles: ['core', 'standard', 'full'] } }, + }; + + const resolved = resolveProfile({ + modes: ['core'], + manifest: syntheticManifest, + _profilesOverride: profilesOverride, + registry: syntheticRegistry, + }); + assert.ok(resolved.skills instanceof Set, 'should return Set'); + assert.ok(resolved.skills.has('foo-skill'), + 'tier:core capability skill must appear in core install resolved profile (FIX-3)'); + + // Verify stageSkillsForProfile on a temp dir with a synthetic foo-skill.md + const synSrcDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-fix3-src-')); + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-fix3-stage-')); + t.after(() => { cleanup(synSrcDir); cleanup(stageDir); }); + + fs.writeFileSync(path.join(synSrcDir, 'help.md'), '---\ndescription: help\n---\n', 'utf8'); + fs.writeFileSync(path.join(synSrcDir, 'foo-skill.md'), '---\ndescription: foo\n---\n', 'utf8'); + + // stageSkillsForProfile returns a temp dir with files matching resolved.skills + const staged = stageSkillsForProfile(synSrcDir, resolved); + t.after(() => cleanup(staged)); + + const stagedFiles = fs.readdirSync(staged); + assert.ok(stagedFiles.includes('foo-skill.md'), + 'stageSkillsForProfile must include foo-skill.md for tier:core capability (FIX-3)'); + assert.ok(stagedFiles.includes('help.md'), + 'stageSkillsForProfile must include help.md (base skill)'); + }); + + test('FIX-3 documented behavior: minimal==core; tier:core capability IS in minimal', () => { + // MINIMAL_SKILL_ALLOWLIST === [...PROFILES.core] — explicitly verified. + // MINIMAL_ALLOWLIST_SET (internal) is new Set(MINIMAL_SKILL_ALLOWLIST). + // Any tier:core capability DOES belong in minimal/core install. + // This test documents and asserts that equivalence. + const { MINIMAL_SKILL_ALLOWLIST, PROFILES } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const minimalSet = new Set(MINIMAL_SKILL_ALLOWLIST); + const profilesCore = new Set(PROFILES.core); + assert.ok( + setsEqual(minimalSet, profilesCore), + 'MINIMAL_SKILL_ALLOWLIST must equal PROFILES.core — minimal IS the core profile' + ); + }); +}); + +// ─── 9. FIX-4 DIVERGENCE GUARD ───────────────────────────────────────────── +// +// FIX 6: when a capabilityClusters entry collides with a CLUSTERS key but has +// DIFFERENT values, the hand-authored CLUSTERS value must win (no silent override). + +describe('FIX-4 divergence guard: colliding capId uses hand-authored CLUSTERS value', () => { + // Construct a scenario where a capId matches a real CLUSTERS key but the + // registry reports a different (shorter) skill list. The disable behavior + // must use the hand-authored (longer/correct) list. + test('colliding capId with different value: hand-authored wins for disabledClusters', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + // Use 'utility' as the colliding cluster — it exists in the real CLUSTERS map. + // The hand-authored utility cluster has several members. + // The divergent registry would report a shorter list (fewer skills). + const { CLUSTERS } = require('../gsd-core/bin/lib/clusters.cjs'); + const realUtilitySkills = CLUSTERS.utility; // e.g. ['health', 'stats', ...] + assert.ok(Array.isArray(realUtilitySkills) && realUtilitySkills.length >= 1, + 'test pre-condition: utility cluster must have at least one skill'); + + // Create a divergent registry that reports a single-skill utility cluster + // (different from the real multi-skill one). + const divergentRegistry = { + capabilityClusters: { utility: [realUtilitySkills[0]] }, // only first skill + profileMembership: { utility: { tier: 'full', profiles: ['full'] } }, + }; + + // Build a minimal manifest covering the real utility skills + const manifestEntries = [['help', []]]; + const agentEntries = [['_calls_agents_help', []]]; + for (const s of realUtilitySkills) { + manifestEntries.push([s, []]); + agentEntries.push([`_calls_agents_${s}`, []]); + } + const manifest = new Map([...manifestEntries, ...agentEntries]); + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: ['utility'], + explicitAdds: [], + explicitRemoves: [], + }); + + // Without registry: uses hand-authored CLUSTERS.utility (all skills disabled) + const without = resolveSurface(dir, manifest); + // With divergent registry: hand-authored wins, so result is still all disabled + const withDiv = resolveSurface(dir, manifest, undefined, divergentRegistry); + + // All hand-authored utility skills must be disabled in BOTH cases. + for (const s of realUtilitySkills) { + assert.ok(!without.skills.has(s), + `without registry: ${s} must be disabled (utility cluster disabled)`); + assert.ok(!withDiv.skills.has(s), + `with divergent registry: ${s} must be disabled (hand-authored wins over divergent registry)`); + } + + // The results must be identical — divergent registry must not change outcomes. + assert.ok( + setsEqual(without.skills, withDiv.skills), + `divergent registry changed outcome vs hand-authored CLUSTERS — skills differ. Extra: ${[...setDiff(withDiv.skills, without.skills).extra]}. Missing: ${[...setDiff(withDiv.skills, without.skills).missing]}` + ); + }); + + test('non-colliding capId: new capability cluster is merged normally', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + const manifest = new Map([ + ['help', []], + ['novel-skill', []], + ['_calls_agents_help', []], + ['_calls_agents_novel-skill', []], + ]); + + // 'novel-cap' does NOT exist in CLUSTERS → no collision → merged normally + const nonCollidingRegistry = { + capabilityClusters: { 'novel-cap': ['novel-skill'] }, + profileMembership: { 'novel-cap': { tier: 'full', profiles: ['full'] } }, + }; + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: ['novel-cap'], + explicitAdds: [], + explicitRemoves: [], + }); + + const withReg = resolveSurface(dir, manifest, undefined, nonCollidingRegistry); + + // novel-skill must be disabled (novel-cap cluster is disabled and registry provided it) + assert.ok(!withReg.skills.has('novel-skill'), + 'novel-skill must be disabled via non-colliding capability cluster'); + assert.ok(withReg.skills.has('help'), + 'help must remain enabled'); + }); +}); + +// ─── 10. FIX-5 MALFORMED-ARRAY GUARD ─────────────────────────────────────── +// +// FIX 6: non-array capabilityClusters entries must be silently skipped, +// never causing a throw from clustersToSkills or resolveSurface. + +describe('FIX-5 malformed-array guard: non-array cluster values are skipped', () => { + const manifest = new Map([ + ['help', []], + ['foo-skill', []], + ['_calls_agents_help', []], + ['_calls_agents_foo-skill', []], + ]); + + test('non-array capabilityClusters value (string): no throw, foo-skill not disabled', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: ['foo'], + explicitAdds: [], + explicitRemoves: [], + }); + + const badRegistry = { + capabilityClusters: { foo: 'not-an-array' }, + profileMembership: { foo: { tier: 'full', profiles: ['full'] } }, + }; + + let result; + assert.doesNotThrow(() => { + result = resolveSurface(dir, manifest, undefined, badRegistry); + }, 'non-array cluster value must not throw'); + + // 'foo' cluster had a non-array value → skipped → disabledClusters=['foo'] + // removes nothing → foo-skill stays enabled. + assert.ok(result.skills.has('foo-skill'), + 'foo-skill must remain enabled when cluster value is non-array (FIX-5)'); + }); + + test('non-array capabilityClusters value (object): no throw', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + + const badRegistry = { + capabilityClusters: { foo: { not: 'an-array' } }, + profileMembership: { foo: { tier: 'full', profiles: ['full'] } }, + }; + + assert.doesNotThrow(() => { + resolveSurface(dir, manifest, undefined, badRegistry); + }, 'object-valued cluster must not throw (FIX-5)'); + }); + + test('null capabilityClusters value: no throw', (t) => { + const dir = tmpDir(); + t.after(() => cleanup(dir)); + + writeSurface(dir, { + baseProfile: 'full', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + + const badRegistry = { + capabilityClusters: { foo: null }, + profileMembership: { foo: { tier: 'full', profiles: ['full'] } }, + }; + + assert.doesNotThrow(() => { + resolveSurface(dir, manifest, undefined, badRegistry); + }, 'null cluster value must not throw (FIX-5)'); + }); +}); diff --git a/tests/capability-registry.test.cjs b/tests/capability-registry.test.cjs index 218753063..7b981dfb9 100644 --- a/tests/capability-registry.test.cjs +++ b/tests/capability-registry.test.cjs @@ -1707,13 +1707,14 @@ describe('ADR-857 phase 4a: profileMembership derivation', () => { ); }); - test('ui cap (tier standard) profileMembership is [standard, full]', () => { + test('ui cap (tier full after reconciliation) profileMembership is [full]', () => { + // ADR-857 phase 4c: ui tier changed from 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]', + ['full'], + 'ui (tier full) should have profiles [full] after reconciliation', ); }); @@ -1722,10 +1723,11 @@ describe('ADR-857 phase 4a: profileMembership derivation', () => { const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); assert.ok(registry.profileMembership, 'registry.profileMembership should exist'); + // After ADR-857 phase 4c reconciliation: ui is tier:full → profiles: ['full'] only assert.deepEqual( registry.profileMembership.ui, - { tier: 'standard', profiles: ['standard', 'full'] }, - 'profileMembership.ui should be { tier: standard, profiles: [standard, full] }', + { tier: 'full', profiles: ['full'] }, + 'profileMembership.ui should be { tier: full, profiles: [full] } after reconciliation', ); }); @@ -1735,62 +1737,62 @@ describe('ADR-857 phase 4a: profileMembership derivation', () => { 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'); + // After ADR-857 phase 4c reconciliation: ui is tier:full → profileMembership contains "full" + assert.ok(content.includes('"full"'), 'Generated file must contain "full" 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'); + // After ADR-857 phase 4c reconciliation: ui is tier:full → profiles: ['full'] only assert.deepEqual( registry.profileMembership.ui, - { tier: 'standard', profiles: ['standard', 'full'] }, - 'committed profileMembership.ui should be { tier: standard, profiles: [standard, full] }', + { tier: 'full', profiles: ['full'] }, + 'committed profileMembership.ui should be { tier: full, profiles: [full] } after reconciliation', ); }); }); 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. + test('ui (tier full) generates ZERO pending-reconciliation warnings (ADR-857 phase 4c reconciliation)', () => { + // After reconciliation: ui is tier:full. The full profile is '*' (every skill). + // The consistency gate must NOT fire for full-tier capabilities — their skills are + // always present in the full profile by definition. 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')); + const uiPhaseWarn = warnings.find((w) => w.includes('ui-phase')); + const uiReviewWarn = warnings.find((w) => w.includes('ui-review')); assert.ok( - uiPhaseWarn, - 'Expected warning for ui-phase skill in standard profile, got: ' + JSON.stringify(warnings), + !uiPhaseWarn, + 'No pending-reconciliation warning expected for ui-phase (tier:full), 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, + !uiReviewWarn, + 'No pending-reconciliation warning expected for ui-review (tier:full), got: ' + JSON.stringify(warnings), ); }); - 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. + test('ui reconciled: profileMembership.ui.profiles is ["full"] after tier:full reconciliation', () => { + // After reconciliation: ui is tier:full → profileMembership.ui.profiles = ['full'] only. + // ui-phase/ui-review are correctly absent from core/standard (they're full-only features). + // No pending-reconciliation warning fires because full='*' always satisfies the gate. const capMap = new Map([['ui', UI_CAP]]); const profiles = deriveProfileMembership(capMap); - assert.ok( - profiles.ui.profiles.includes('standard'), - 'ui profileMembership should include "standard"', + assert.deepStrictEqual( + profiles.ui.profiles, + ['full'], + 'After tier:full reconciliation, ui profileMembership should be ["full"] only', + ); + assert.strictEqual( + profiles.ui.tier, + 'full', + 'ui tier should be "full" after reconciliation', ); - // Confirm ui-phase is NOT in the hand-authored standard profile's effective skill set + // Confirm ui-phase is NOT in standard profile — that's expected and correct for full-tier skills. const { resolveProfile: rp } = require('../gsd-core/bin/lib/install-profiles.cjs'); const resolved = rp({ modes: ['standard'], manifest: new Map() }); assert.ok( @@ -1799,11 +1801,11 @@ describe('ADR-857 phase 4a: pending-reconciliation warnings (SOFT gate)', () => ); assert.ok( !resolved.skills.has('ui-phase'), - 'ui-phase should NOT be in hand-authored standard profile effective set (pending reconciliation)', + 'ui-phase should NOT be in hand-authored standard profile (correctly full-only after reconciliation)', ); assert.ok( !resolved.skills.has('ui-review'), - 'ui-review should NOT be in hand-authored standard profile effective set (pending reconciliation)', + 'ui-review should NOT be in hand-authored standard profile (correctly full-only after reconciliation)', ); }); @@ -1820,7 +1822,8 @@ describe('ADR-857 phase 4a: pending-reconciliation warnings (SOFT gate)', () => assert.ok(Array.isArray(warnings), 'runConsistencyGate must return an array'); }); - test('buildRegistry._reconciliationWarnings includes ui skill warnings', () => { + test('buildRegistry._reconciliationWarnings is empty for reconciled ui (tier:full)', () => { + // After reconciliation: ui is tier:full → no pending-reconciliation warnings. const capDir = makeTempCapDir({ ui: UI_CAP }); const { capMap } = loadAndValidate(new Set(), capDir); const registry = buildRegistry(capMap); @@ -1828,13 +1831,13 @@ describe('ADR-857 phase 4a: pending-reconciliation warnings (SOFT gate)', () => 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), + const uiWarnings = registry._reconciliationWarnings.filter( + (w) => w.includes('ui-phase') || w.includes('ui-review') ); - assert.ok( - registry._reconciliationWarnings.some((w) => w.includes('ui-review')), - 'Expected warning for ui-review in _reconciliationWarnings, got: ' + JSON.stringify(registry._reconciliationWarnings), + assert.deepStrictEqual( + uiWarnings, + [], + 'No reconciliation warnings expected for reconciled ui capability, got: ' + JSON.stringify(uiWarnings), ); });