Make install + surface read the registry's derived profileMembership/ capabilityClusters so a capability's tier drives what installs + surfaces. resolveProfile (when given the registry) unions capability skills for the profiles its tier implies before the requires: closure; resolveSurface merges capabilityClusters into the cluster map. bin/install.js, /gsd:surface, and the capability-state resolver all thread the registry. Shipped as a proven no-op: the UI capability is reconciled to tier:full (its skills were full-only in the hand-authored profiles), so it contributes only to the full profile (already the '*' sentinel) and core/standard are unchanged. Equivalence tests prove resolveProfile/resolveSurface/listSurface/staging/ capability-state are identical with vs without the registry; the core-alias staging path is verified equivalent (empty manifest → raw PROFILES.core). Closes #949 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<name> → 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) {
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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 `<name>`. 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 `<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 `<cluster>`. N skills added back to surface."
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
// ── 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<string>, agents: Set<string> }
|
||||
// (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<string, unknown>;
|
||||
// ── Resolve state ────────────────────────────────────────────────────────────
|
||||
|
||||
const result = resolveCapabilityState({
|
||||
registry,
|
||||
|
||||
@@ -163,16 +163,53 @@ interface ResolvedProfile {
|
||||
agents: Set<string>;
|
||||
}
|
||||
|
||||
interface CapabilityRegistry {
|
||||
capabilityClusters?: Record<string, string[]>;
|
||||
profileMembership?: Record<string, { tier: string; profiles: string[] }>;
|
||||
}
|
||||
|
||||
interface ResolveProfileOpts {
|
||||
modes?: string[];
|
||||
manifest?: Map<string, string[]>;
|
||||
_profilesOverride?: Record<string, string | readonly string[]>;
|
||||
/** 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<string, string | readonly string[]> = _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<string>, 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<string>), ...capSkills];
|
||||
const closure = computeClosure(baseWithCap, man);
|
||||
for (const s of closure) unionSkills.add(s);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
for (const name of clusterNames) {
|
||||
const members = (clusterMap as Record<string, ReadonlyArray<string> | 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<string,
|
||||
* 2. Remove skills in disabled clusters
|
||||
* 3. Add explicitAdds (and their transitive closure)
|
||||
* 4. Remove explicitRemoves (only the stem itself, no cascade)
|
||||
*
|
||||
* ADR-857 phase 4c: optional registry param. When present:
|
||||
* - capability clusters are merged into the effective cluster map so
|
||||
* capability-owned skill groups are disable-able.
|
||||
* - registry is threaded into resolveProfile so capability skills
|
||||
* participate in the base skill set and their requires: chains expand.
|
||||
*/
|
||||
function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
const cm = clusterMap || CLUSTERS;
|
||||
function resolveSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
// 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<string, string[]> = clusterMap || CLUSTERS;
|
||||
if (registry && registry.capabilityClusters && typeof registry.capabilityClusters === 'object') {
|
||||
const baseCm = cm;
|
||||
const capClusters = registry.capabilityClusters;
|
||||
const merged: Record<string, string[]> = { ...(baseCm as Record<string, string[]>) };
|
||||
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<string, readonly string[] | string[]>)[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<string, string[]
|
||||
? surface.baseProfile
|
||||
: (readActiveProfile(runtimeConfigDir) || 'full');
|
||||
|
||||
// Resolve base profile
|
||||
// Resolve base profile — thread registry so capability skills are included.
|
||||
const baseResolved = resolveProfile({
|
||||
modes: baseProfileName.split(',').map((s: string) => 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<string, string[]
|
||||
* Re-stage the active surface using the resolved layout.
|
||||
* Iterates layout.kinds and syncs each artifact kind to its destination.
|
||||
*/
|
||||
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
function applySurface(runtimeConfigDir: string, layout: Layout, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { name: string; skills: Set<string>; agents: Set<string> } {
|
||||
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<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>): { enabled: string[]; disabled: string[]; tokenCost: number } {
|
||||
function listSurface(runtimeConfigDir: string, manifest: Map<string, string[]> | object, clusterMap?: ClusterMap | Record<string, string[]>, registry?: { capabilityClusters?: Record<string, string[]>; profileMembership?: Record<string, { tier: string; profiles: string[] }> }): { 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[] = [];
|
||||
|
||||
786
tests/capability-consumption.test.cjs
Normal file
786
tests/capability-consumption.test.cjs
Normal file
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user