fix(#322): normalize surface manifest inputs before profile resolution (#325)

This commit is contained in:
Tom Boucher
2026-05-26 12:22:08 -04:00
committed by GitHub
parent e5a5a75938
commit 1f2ef131c5
3 changed files with 74 additions and 8 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 322
---
**`/gsd-surface` now accepts parsed manifest JSON input safely** — surface resolution normalizes manifest inputs before profile closure and list/apply operations so list/status/enable/disable no longer crash with `manifest.get is not a function`.

View File

@@ -108,6 +108,43 @@ function clustersToSkills(clusterNames, clusterMap) {
return result;
}
/**
* Normalize manifest inputs to the skill-dependency Map shape expected by
* resolveProfile/computeClosure.
*
* Supports:
* - canonical Map<string, string[]>
* - legacy plain object map: { [stem]: string[] }
* - parsed gsd-file-manifest.json object ({ files: { ... } }) by rebuilding
* the dependency manifest from the install source tree
*
* @param {string} runtimeConfigDir
* @param {Map<string, string[]>|Object} manifest
* @returns {Map<string, string[]>}
*/
function normalizeSkillManifest(runtimeConfigDir, manifest) {
if (manifest instanceof Map) {
return manifest;
}
if (!manifest || typeof manifest !== 'object') {
return new Map();
}
// Legacy/ad-hoc object map: stem -> requires[]
const arrayEntries = Object.entries(manifest).filter(([, value]) => Array.isArray(value));
if (arrayEntries.length > 0) {
return new Map(arrayEntries.map(([stem, deps]) => [stem, deps]));
}
// Parsed gsd-file-manifest.json shape: rebuild the dependency map from source.
if (manifest.files && typeof manifest.files === 'object') {
const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir);
return loadSkillsManifest(srcCommandsDir);
}
return new Map();
}
/**
* Resolve the effective surface to a typed profile-like object.
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
@@ -125,6 +162,7 @@ function clustersToSkills(clusterNames, clusterMap) {
*/
function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
const cm = clusterMap || CLUSTERS;
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
const surface = readSurface(runtimeConfigDir);
// Determine base profile name: from surface state or from .gsd-profile marker
@@ -135,7 +173,7 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
// Resolve base profile
const baseResolved = resolveProfile({
modes: baseProfileName.split(',').map(s => s.trim()),
manifest,
manifest: skillManifest,
});
// If full, we need to enumerate all skills from the manifest
@@ -143,7 +181,7 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
if (baseResolved.skills === '*') {
// Materialize all skill stems from manifest
skills = new Set();
for (const [key] of manifest) {
for (const [key] of skillManifest) {
if (!key.startsWith('_calls_agents_')) skills.add(key);
}
} else {
@@ -163,7 +201,7 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
const visited = new Set(addSet);
while (queue.length > 0) {
const stem = queue.pop();
const deps = manifest.get(stem) || [];
const deps = skillManifest.get(stem) || [];
for (const dep of deps) {
if (!visited.has(dep)) {
visited.add(dep);
@@ -183,7 +221,7 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
// Derive agents from skills
const agents = new Set();
for (const skillStem of skills) {
const agentRefs = manifest.get(`_calls_agents_${skillStem}`) || [];
const agentRefs = skillManifest.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) agents.add(agentStem);
}
@@ -208,11 +246,12 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) {
if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) {
throw new TypeError('applySurface runtimeConfigDir must match layout.configDir');
}
const resolved = resolveSurface(layout.configDir, manifest, clusterMap);
const skillManifest = normalizeSkillManifest(layout.configDir, manifest);
const resolved = resolveSurface(layout.configDir, skillManifest, clusterMap);
for (const kind of layout.kinds) {
const staged = kind.stage(resolved);
const dest = path.join(layout.configDir, kind.destSubpath);
_syncGsdDir(staged, dest, kind, manifest);
_syncGsdDir(staged, dest, kind, skillManifest);
}
return resolved;
}
@@ -384,11 +423,12 @@ function _syncGsdDir(stagedDir, destDir, kind, manifest) {
* @returns {{ enabled: string[], disabled: string[], tokenCost: number }}
*/
function listSurface(runtimeConfigDir, manifest, clusterMap) {
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
const skillManifest = normalizeSkillManifest(runtimeConfigDir, manifest);
const resolved = resolveSurface(runtimeConfigDir, skillManifest, clusterMap);
// All known stems from manifest (exclude _calls_agents_ meta keys)
const allStems = [];
for (const [key] of manifest) {
for (const [key] of skillManifest) {
if (!key.startsWith('_calls_agents_')) allStems.push(key);
}

View File

@@ -703,6 +703,27 @@ describe('CLUSTERS data structure', () => {
// ─── listSurface ─────────────────────────────────────────────────────────────
describe('listSurface', () => {
test('accepts parsed gsd-file-manifest JSON objects without crashing (#322)', () => {
const dir = tmpDir('gsd-surface-list-');
try {
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
writeActiveProfile(dir, 'core');
const diskManifestShape = {
version: '1.0.0',
timestamp: new Date().toISOString(),
mode: 'core',
files: {},
};
const result = listSurface(dir, diskManifestShape, CLUSTERS);
assert.ok(Array.isArray(result.enabled), 'enabled must be array');
assert.ok(Array.isArray(result.disabled), 'disabled must be array');
assert.ok(typeof result.tokenCost === 'number', 'tokenCost must be number');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('returns { enabled, disabled, tokenCost } structure', () => {
const dir = tmpDir('gsd-surface-list-');
try {