Files
msd-core/get-shit-done/bin/lib/surface.cjs
Cristian Uibar c5657fcbfd fix(surface): default missing optional fields in readSurface, normalize writeSurface input (#3666)
* surface: default missing optional fields in readSurface, normalize writeSurface input

readSurface used to reject any .gsd-surface.json missing one of its four fields
and return null with no diagnostic, so the active surface silently degraded to
the 'full' profile. Optional array fields (disabledClusters, explicitAdds,
explicitRemoves) now default to [] when missing or wrong-typed; hard failures
(malformed JSON, non-object root, missing/non-string baseProfile) still return
null but emit a console.warn naming the file + reason.

writeSurface now normalizes its input to the full SurfaceState shape and
throws on missing baseProfile, so partial writes can no longer land on disk
and trip readSurface later. Tests extended to cover the new lenient and
warn-on-hard-fail behavior plus the writer guard.

Fixes #3662

* surface: reject whitespace-only baseProfile + migrate new tests to helpers.cjs

Applies CodeRabbit findings on PR #3666:

1. readSurface and writeSurface now reject baseProfile values that are blank
   after trim() (e.g. "   "), not only the empty string. Whitespace-only
   strings would split-by-comma to [''] downstream and silently produce an
   unresolvable profile mode. Both guards updated symmetrically; warn/error
   messages reworded to "missing, non-string, or blank".

2. tests/surface-state.test.cjs now uses createTempDir + cleanup from
   tests/helpers.cjs instead of local mkdtempSync + fs.rmSync, aligning with
   the repo coding guideline for root-level tests. The local tmpDir() helper
   delegates to createTempDir for backward-compat with the existing test
   bodies. Per-test cleanup calls swapped to cleanup(dir).

3. Added regression tests:
   - readSurface rejects whitespace-only baseProfile and warns
   - writeSurface rejects whitespace-only baseProfile, non-string baseProfile,
     and null surfaceState

Refs #3662

* surface: warn on unknown baseProfile mode names in read and write

Applies a Codex review finding on PR #3666:

readSurface and writeSurface used to accept any non-blank string as
baseProfile. A typo like {"baseProfile":"standrad"} would pass validation,
then resolveProfile() in install-profiles.cjs would silently fall back to
'full' with no diagnostic — the same silent-degradation symptom that #3662
was filed to fix, just through a different code path.

Both functions now split baseProfile by comma, validate each mode against
the registered PROFILES set ('core', 'standard', 'full'), and emit a single
[gsd] console.warn line that names the unknown modes and lists the valid
ones. The state is still parsed/written — resolveProfile() decides the
actual resolution fallback. Composed profiles where some modes are valid
and some are not warn only about the unknown subset.

Side note: the pre-existing 'round-trips composed base profile' test used
'core,audit' as a stand-in composed string. 'audit' is not a registered
profile (the three known profiles are 'core', 'standard', 'full'), so the
test was relying on the old lack of validation. Switched to 'core,standard'
to preserve the round-trip intent without producing diagnostic noise.

Refs #3662

* changeset: include blank/typo baseProfile in documented read failure cases

CodeRabbit minor finding on PR #3666 — the changeset wording only mentioned
"missing/non-string baseProfile" but the implementation also rejects blank
(including whitespace-only) baseProfile values, and warns on typo'd /
unknown profile mode names. Updated to match actual behavior.

* changeset: pr field should be PR number, not issue number

Codex review finding on PR #3666. The changeset's `pr: 3662` was the linked
issue (#3662), but the convention across other .changeset/*.md files is that
`pr:` carries the PR number. Verified by spot-checking other changesets
(2937 → pr: 3515, 3298 → pr: 3306, 3541 → pr: 3547 — all PR numbers).
Updated to `pr: 3666`. The body text still references the issue.

* surface: reject comma-only baseProfile + warn on wrong-typed optional fields

Three Gemini review findings on PR #3666 — all spirit-of-#3662 edge cases:

1. Comma-only baseProfile bypasses validation. readSurface used to accept
   baseProfile: ", ," because trim() returned "," (non-empty). Downstream
   resolveProfile() would split-and-filter to [] and silently fall back to
   'full'. Added effectiveProfileModes() helper that splits, trims, filters
   empty — both readSurface and writeSurface now reject when the result is
   empty. Same silent-degradation symptom as the original bug.

2. Wrong-typed optional fields silently coerced. readSurface used to coerce
   {disabledClusters: 42} to {disabledClusters: []} with no diagnostic.
   Now warns via mistypedOptionalFields() before normalizeSurfaceState()
   does the coercion, in both reader and writer.

3. Missing test coverage for the EACCES branch in readSurface. Added a
   chmod-000 unreadable-file test, skipped on Windows and root accounts
   (mode bits are ignored on those platforms).

48/48 surface tests pass. Final codex pass returned LGTM on the prior
state; these three additions strengthen the same lenient/loud contract.

Refs #3662
2026-05-18 00:14:07 -04:00

482 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Runtime surface module — ADR-0011 Phase 2 (Option B).
*
* Manages the runtime enable/disable surface state (the `.gsd-surface.json` marker in
* each runtime's skills dir) independently of the install-time profile marker
* (`.gsd-profile`). Runtime config locations are resolved by callers.
*
* Effective skill set = base profile ∪ explicitAdds − disabledClusters − explicitRemoves,
* then transitively closed via the manifest.
*
* Exports:
* readSurface(runtimeConfigDir)
* writeSurface(runtimeConfigDir, surfaceState)
* resolveSurface(runtimeConfigDir, manifest, clusterMap)
* applySurface(runtimeConfigDir, layout, manifest, clusterMap)
* listSurface(runtimeConfigDir, layout, manifest, clusterMap)
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { platformWriteSync } = require('./shell-command-projection.cjs');
const {
readActiveProfile,
resolveProfile,
stageSkillsForProfile,
stageAgentsForProfile,
loadSkillsManifest,
PROFILES,
} = require('./install-profiles.cjs');
const { CLUSTERS, allClusteredSkills } = require('./clusters.cjs');
const { findInstallSourceRoot } = require('./runtime-artifact-layout.cjs');
const SURFACE_FILE_NAME = '.gsd-surface.json';
const KNOWN_PROFILE_NAMES = new Set(Object.keys(PROFILES));
/**
* Split a `baseProfile` string (single name or comma-composed) into the
* non-empty trimmed mode list that resolveProfile() would see.
*
* @param {string} baseProfile
* @returns {string[]} effective modes after split/trim/empty-strip
*/
function effectiveProfileModes(baseProfile) {
return baseProfile
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0);
}
/**
* Inspect a `baseProfile` string (single name or comma-composed) and return
* any modes that aren't registered in PROFILES. Callers keep the raw string —
* resolveProfile() decides the resolution fallback.
*
* @param {string} baseProfile
* @returns {string[]} unknown modes
*/
function unknownProfileModes(baseProfile) {
return effectiveProfileModes(baseProfile).filter((m) => !KNOWN_PROFILE_NAMES.has(m));
}
/**
* Collect optional array fields that are present but not an array, so the
* reader and writer can emit a single warn diagnostic before coercing them
* to `[]` in normalizeSurfaceState. A missing field is *not* flagged — it
* defaults to `[]` silently (that's the lenient #3662 behavior).
*
* @param {Object} input
* @returns {string[]} field names with wrong type
*/
function mistypedOptionalFields(input) {
const wrong = [];
for (const field of ['disabledClusters', 'explicitAdds', 'explicitRemoves']) {
if (Object.prototype.hasOwnProperty.call(input, field) && !Array.isArray(input[field])) {
wrong.push(field);
}
}
return wrong;
}
// ---------------------------------------------------------------------------
// State IO
// ---------------------------------------------------------------------------
/**
* @typedef {Object} SurfaceState
* @property {string} baseProfile
* @property {string[]} disabledClusters
* @property {string[]} explicitAdds
* @property {string[]} explicitRemoves
*/
/**
* Normalize a partial SurfaceState into the full four-field shape.
* Missing or non-array optional fields default to []; baseProfile must already
* be a non-empty string (callers gate on that before normalizing).
*
* @param {Object} input
* @returns {SurfaceState}
*/
function normalizeSurfaceState(input) {
return {
baseProfile: input.baseProfile,
disabledClusters: Array.isArray(input.disabledClusters) ? input.disabledClusters.slice() : [],
explicitAdds: Array.isArray(input.explicitAdds) ? input.explicitAdds.slice() : [],
explicitRemoves: Array.isArray(input.explicitRemoves) ? input.explicitRemoves.slice() : [],
};
}
/**
* Read the surface state from a runtime config directory.
*
* Returns `null` only when there is no usable surface state:
* - file is absent (silent — expected when no profile has been pinned),
* - file is unreadable, malformed JSON, non-object root, or missing/invalid
* `baseProfile` (each of these emits a `console.warn` diagnostic so callers
* don't silently fall back to `'full'` with no explanation).
*
* Missing or wrong-typed optional array fields (`disabledClusters`,
* `explicitAdds`, `explicitRemoves`) default to `[]` — they are meaningfully
* empty and the writer/reader stayed symmetric only by accident before #3662.
*
* @param {string} runtimeConfigDir
* @returns {SurfaceState|null}
*/
function readSurface(runtimeConfigDir) {
const filePath = path.join(runtimeConfigDir, SURFACE_FILE_NAME);
let raw;
try {
raw = fs.readFileSync(filePath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return null;
console.warn(`[gsd] readSurface(${filePath}): unreadable (${err && (err.code || err.message)}); falling back to no surface state.`);
return null;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
console.warn(`[gsd] readSurface(${filePath}): malformed JSON (${err.message}); falling back to no surface state.`);
return null;
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
console.warn(`[gsd] readSurface(${filePath}): expected JSON object root; falling back to no surface state.`);
return null;
}
if (typeof parsed.baseProfile !== 'string' || effectiveProfileModes(parsed.baseProfile).length === 0) {
console.warn(`[gsd] readSurface(${filePath}): missing, non-string, blank, or comma-only 'baseProfile'; falling back to no surface state.`);
return null;
}
const unknownModes = unknownProfileModes(parsed.baseProfile);
if (unknownModes.length > 0) {
console.warn(`[gsd] readSurface(${filePath}): unknown profile mode(s) in 'baseProfile': ${unknownModes.join(', ')} (valid: ${[...KNOWN_PROFILE_NAMES].join(', ')}); resolveProfile() will skip unknowns and may fall back to 'full'.`);
}
const mistyped = mistypedOptionalFields(parsed);
if (mistyped.length > 0) {
console.warn(`[gsd] readSurface(${filePath}): optional field(s) with wrong type (expected array): ${mistyped.join(', ')}; coercing to [].`);
}
return normalizeSurfaceState(parsed);
}
/**
* Write the surface state atomically via the platform seam (mkdir + tmp+rename).
*
* Input is normalized to the full four-field shape so partial / hand-rolled
* objects cannot land on disk and trip readSurface later (#3662 symmetry fix).
* `baseProfile` is the only load-bearing field — callers must supply it as a
* non-empty string.
*
* @param {string} runtimeConfigDir
* @param {SurfaceState} surfaceState
*/
function writeSurface(runtimeConfigDir, surfaceState) {
if (!surfaceState || typeof surfaceState.baseProfile !== 'string' || effectiveProfileModes(surfaceState.baseProfile).length === 0) {
throw new TypeError("writeSurface: 'baseProfile' must be a non-blank string with at least one mode");
}
const unknownModes = unknownProfileModes(surfaceState.baseProfile);
if (unknownModes.length > 0) {
console.warn(`[gsd] writeSurface: unknown profile mode(s) in 'baseProfile': ${unknownModes.join(', ')} (valid: ${[...KNOWN_PROFILE_NAMES].join(', ')}); persisting anyway — resolveProfile() will skip unknowns.`);
}
const mistyped = mistypedOptionalFields(surfaceState);
if (mistyped.length > 0) {
console.warn(`[gsd] writeSurface: optional field(s) with wrong type (expected array): ${mistyped.join(', ')}; coercing to [].`);
}
const normalized = normalizeSurfaceState(surfaceState);
platformWriteSync(path.join(runtimeConfigDir, SURFACE_FILE_NAME), JSON.stringify(normalized, null, 2) + '\n');
}
// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------
/**
* Expand cluster names to skill stems using the provided clusterMap.
*
* @param {string[]} clusterNames
* @param {Object} clusterMap CLUSTERS or override
* @returns {Set<string>}
*/
function clustersToSkills(clusterNames, clusterMap) {
const result = new Set();
for (const name of clusterNames) {
const members = clusterMap[name];
if (members) {
for (const s of members) result.add(s);
}
}
return result;
}
/**
* Resolve the effective surface to a typed profile-like object.
* Shape: { name, skills: Set<string>|'*', agents: Set<string> }
*
* Resolution order:
* 1. Start with base profile resolved via resolveProfile()
* 2. Remove skills in disabled clusters
* 3. Add explicitAdds (and their transitive closure)
* 4. Remove explicitRemoves (only the stem itself, no cascade)
*
* @param {string} runtimeConfigDir
* @param {Map<string, string[]>} manifest
* @param {Object} [clusterMap] defaults to CLUSTERS
* @returns {{ name: string, skills: Set<string>, agents: Set<string> }}
*/
function resolveSurface(runtimeConfigDir, manifest, clusterMap) {
const cm = clusterMap || CLUSTERS;
const surface = readSurface(runtimeConfigDir);
// Determine base profile name: from surface state or from .gsd-profile marker
const baseProfileName = (surface && surface.baseProfile)
? surface.baseProfile
: (readActiveProfile(runtimeConfigDir) || 'full');
// Resolve base profile
const baseResolved = resolveProfile({
modes: baseProfileName.split(',').map(s => s.trim()),
manifest,
});
// If full, we need to enumerate all skills from the manifest
let skills;
if (baseResolved.skills === '*') {
// Materialize all skill stems from manifest
skills = new Set();
for (const [key] of manifest) {
if (!key.startsWith('_calls_agents_')) skills.add(key);
}
} else {
skills = new Set(baseResolved.skills);
}
if (surface) {
// Step 2: remove disabled cluster members
const disabledSkills = clustersToSkills(surface.disabledClusters, cm);
for (const s of disabledSkills) skills.delete(s);
// Step 3: add explicitAdds with transitive closure
if (surface.explicitAdds.length > 0) {
const addSet = new Set(surface.explicitAdds);
// Compute closure of adds
const queue = [...addSet];
const visited = new Set(addSet);
while (queue.length > 0) {
const stem = queue.pop();
const deps = manifest.get(stem) || [];
for (const dep of deps) {
if (!visited.has(dep)) {
visited.add(dep);
queue.push(dep);
}
}
}
for (const s of visited) skills.add(s);
}
// Step 4: remove explicitRemoves (stem only, no cascade)
for (const s of surface.explicitRemoves) {
skills.delete(s);
}
}
// Derive agents from skills
const agents = new Set();
for (const skillStem of skills) {
const agentRefs = manifest.get(`_calls_agents_${skillStem}`) || [];
for (const agentStem of agentRefs) agents.add(agentStem);
}
const name = surface ? `surface:${surface.baseProfile}` : `profile:${baseProfileName}`;
return { name, skills, agents };
}
// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------
/**
* Re-stage the active surface using the resolved layout.
* Iterates layout.kinds and syncs each artifact kind to its destination.
*
* @param {string} runtimeConfigDir
* @param {import('./runtime-artifact-layout.cjs').Layout} layout
* @param {Map<string, string[]>} manifest
* @param {Object} [clusterMap]
*/
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);
for (const kind of layout.kinds) {
const staged = kind.stage(resolved);
const dest = path.join(layout.configDir, kind.destSubpath);
_syncGsdDir(staged, dest, kind, manifest);
}
return resolved;
}
/**
* Sync destination directory from staged source.
*
* For 'commands' kind: iterate *.md files in destDir, remove if not in staged set.
* For 'agents' kind: same, but only remove files starting with 'gsd-' prefix.
* For 'skills' kind: iterate directories in destDir matching kind.prefix; add missing
* by copying recursively; remove dirs not in staged set. Preserves dirs not matching
* the prefix (user-owned skills).
*
* For Hermes (empty prefix): uses manifest membership to discriminate GSD-owned vs
* user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND
* not in staged set. User-owned (not in manifest) are always preserved.
*
* @param {string} stagedDir source (staged temp dir or original)
* @param {string} destDir runtime destination
* @param {import('./runtime-artifact-layout.cjs').ArtifactKind|'commands'|'agents'} kind
* @param {Map<string, string[]>} [manifest] optional; required for Hermes empty-prefix removal
*/
function _syncGsdDir(stagedDir, destDir, kind, manifest) {
if (!fs.existsSync(stagedDir)) return;
fs.mkdirSync(destDir, { recursive: true });
// Normalize: allow legacy string context for backward-compat with internal callers
const kindName = (typeof kind === 'string') ? kind : kind.kind;
const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-';
if (kindName === 'skills') {
// Skills kind: work with directories, not files.
// Each staged entry is a directory named ${prefix}${stem}.
const stagedDirs = new Set(
fs.readdirSync(stagedDir).filter(entry => {
return fs.statSync(path.join(stagedDir, entry)).isDirectory();
})
);
// Copy missing dirs from staged to dest
for (const dirName of stagedDirs) {
const destSubDir = path.join(destDir, dirName);
if (!fs.existsSync(destSubDir)) {
fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true });
} else {
// Overwrite to ensure content is current
fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true });
}
}
// Removal: discriminator depends on prefix shape.
// Non-empty prefix: GSD namespace IS the prefix; remove prefix-matching dirs not in staged set.
// Empty prefix (Hermes): GSD-owned = stem in manifest (i.e. canonically-shipped GSD skill).
// User-owned skills not in manifest are preserved.
// No manifest available: be conservative, don't remove anything.
const canonicalStems = manifest
? new Set([...manifest.keys()].filter(k => !k.startsWith('_calls_agents_')))
: null;
const destEntries = fs.readdirSync(destDir);
for (const entry of destEntries) {
const entryPath = path.join(destDir, entry);
if (!fs.statSync(entryPath).isDirectory()) continue;
let isGsdOwned;
if (kindPrefix !== '') {
isGsdOwned = entry.startsWith(kindPrefix);
} else if (canonicalStems) {
// Hermes: empty prefix, destSubpath is the namespace.
// GSD-owned iff the directory name (stem) appears in the canonical manifest.
isGsdOwned = canonicalStems.has(entry);
} else {
// No manifest available: be conservative, don't remove anything.
continue;
}
if (!isGsdOwned) continue; // preserve user-owned
if (stagedDirs.has(entry)) continue; // current GSD-owned, keep
try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {}
}
} else {
// commands / agents kind: work with .md files
const stagedFiles = new Set(
fs.readdirSync(stagedDir).filter(f => f.endsWith('.md'))
);
// Copy files from staged to dest (overwrite to keep content current)
for (const file of stagedFiles) {
fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, file));
}
// Remove gsd-only files from dest that aren't in staged set
// For commands dir: all .md files are gsd skills
// For agents dir: only gsd-* files
const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md'));
for (const file of destEntries) {
if (kindName === 'agents' && !file.startsWith('gsd-')) continue;
if (!stagedFiles.has(file)) {
try { fs.unlinkSync(path.join(destDir, file)); } catch {}
}
}
}
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
/**
* List the currently enabled and disabled skills with token cost.
*
* Token cost = sum of description lengths ÷ 4 (mirrors audit script).
* Descriptions are read from the install source (findInstallSourceRoot).
*
* @param {string} runtimeConfigDir
* @param {Map<string, string[]>} manifest
* @param {Object} [clusterMap]
* @returns {{ enabled: string[], disabled: string[], tokenCost: number }}
*/
function listSurface(runtimeConfigDir, manifest, clusterMap) {
const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap);
// All known stems from manifest (exclude _calls_agents_ meta keys)
const allStems = [];
for (const [key] of manifest) {
if (!key.startsWith('_calls_agents_')) allStems.push(key);
}
const enabledSet = resolved.skills instanceof Set ? resolved.skills : new Set(allStems);
const enabled = allStems.filter(s => enabledSet.has(s)).sort();
const disabled = allStems.filter(s => !enabledSet.has(s)).sort();
// Compute token cost by reading descriptions from the install source
const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir);
let tokenCost = 0;
for (const stem of enabled) {
const filePath = path.join(srcCommandsDir, `${stem}.md`);
try {
const content = fs.readFileSync(filePath, 'utf8');
const descMatch = content.match(/^description:\s*(.+)$/m);
if (descMatch) {
tokenCost += Math.ceil(descMatch[1].trim().length / 4);
}
} catch {}
}
return { enabled, disabled, tokenCost };
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
readSurface,
writeSurface,
resolveSurface,
applySurface,
listSurface,
// Exported for testing
_syncGsdDir,
};