* feat(skill-deps): add requires: frontmatter to all 51 skills with cross-skill references Mechanical migration from docs/research/data/2026-05-12-skill-audit.json. Every skill whose body references another GSD skill now declares those dependencies in `requires:` YAML frontmatter (flow-style array). Notable: discuss-phase, plan-phase, and execute-phase all reference `phase`, which confirms the latent gap in MINIMAL_SKILL_ALLOWLIST — `phase` is pulled by the core loop but was never in the allowlist. The profile closure model (ADR-0010 Phase 1) resolves this automatically. Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(skill-surface-budget): add PROFILES map, resolveProfile, loadSkillsManifest, staging, marker IO Implements the Skill Surface Budget Module core (ADR-0010, Phase 1): - PROFILES Object.freeze map: core (6 skills), standard (~13), full ('*') - loadSkillsManifest: parses requires: frontmatter from commands/gsd/*.md into a Map<stem, string[]> without external YAML dep - resolveProfile({modes, manifest}): computes transitive closure over the requires: graph; composable (modes=['core','audit'] unions closures) - stageSkillsForProfile / stageAgentsForProfile: filesystem staging with same exit-cleanup machinery as the legacy stageSkillsForMode - readActiveProfile / writeActiveProfile: .gsd-profile marker round-trip - Back-compat shims preserved: MINIMAL_SKILL_ALLOWLIST, isMinimalMode, shouldInstallSkill (overloaded), stageSkillsForMode — all legacy tests pass The phase latent bug is now resolved by closure: discuss-phase, plan-phase, and execute-phase all require phase, so any profile including any of them automatically includes phase via transitive closure. Tests: 22 manifest+resolve, 9 stage, 10 marker (41 new tests, all green). Back-compat anchor: 80/80 passing. Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(skill-surface-budget): add lint-skill-deps.cjs CI gate and fix 19 missed requires: entries Two lint checks (scripts/lint-skill-deps.cjs): a) Frontmatter-body consistency: skill body references must appear in requires: b) Profile closure: every requires: dep of any profile skill must be in closure Running the lint revealed 19 body references missed by the audit JSON (the audit used static analysis; some bodies have conditional references). Fixed: complete-milestone: +audit-milestone, discuss-phase, plan-phase, execute-phase, new-milestone fast: +quick health: +thread map-codebase: +new-project, plan-phase new-milestone, new-project, review, ultraplan-phase: +plan-phase ship: +verify-work sketch, spike: +new-project verify-work: +execute-phase workstreams: +new-milestone, resume-work Wired into package.json as lint:skill-deps and added to pretest. 8 fixture-based tests: all green. Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(skill-surface-budget): wire --profile= arg, profile marker write/read in bin/install.js - Add --profile=<name> / --profile=<n1>,<n2> arg parsing (composable). Mutually exclusive with --minimal / --core-only (aliases for --profile=core). Default (no flag): full. - Import readActiveProfile / writeActiveProfile from install-profiles.cjs. - After writeManifest: persist active profile to .gsd-profile marker. - gsd update path: if no --profile flag given, read existing .gsd-profile marker so non-full profiles are not silently re-expanded to full (ADR-0010). - Update --help block to document --profile= with per-tier token costs. New test: install-minimal-backcompat.test.cjs (6 tests): - PROFILES.core === MINIMAL_SKILL_ALLOWLIST (contract) - --minimal writes .gsd-profile marker "core" - --profile=core, --profile=standard write correct markers - default install writes marker "full" Closes part of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add feat-3408-skill-profiles changelog fragment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(install-profiles): derive agents from skill body refs and wire into resolveProfile Deviation 1 of ADR-0010 phase 1b: tiered profiles (core, standard) now produce a non-empty agents Set instead of always returning empty. resolveProfile() scans each skill body for gsd-* agent name references (via new parseCallsAgents()), stores them in _calls_agents_<stem> manifest entries, and unions them across the resolved skill closure. stageAgentsForProfile() already checked resolvedProfile.agents — it now gets real data so tiered profiles install the correct subset of agents instead of zero. Closes #3408 (partial — Deviation 1 only) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(install): honor .gsd-profile marker on update, add resolveEffectiveProfile/mostRestrictiveProfile Deviation 2 of ADR-0010 phase 1b: the marker written during installation is now actually honored when re-running without explicit flags (e.g. gsd update). The dead-end logging block is replaced by resolveEffectiveProfile(), which picks the marker profile over 'full' when no explicit --profile= flag was given. The resolved profile is piped through to all 13 stageSkillsForMode dispatch sites (now _stageSkills) so updates install only the previously-chosen skill subset. --minimal retains its back-compat behavior (strict 6-skill allowlist, no closure) while writing 'core' to the marker. mostRestrictiveProfile() is exported for callers that need to reconcile disagreeing markers across runtimes (smallest skill set wins). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(surface): add CLUSTERS data + state IO module Add clusters.cjs with 10 named skill groups covering all 66 skills (verified by surface-clusters.test.cjs). Add surface.cjs with readSurface/ writeSurface atomic IO, resolveSurface, applySurface, and listSurface. Tests: 17 passing (11 state IO + 6 cluster integrity). Closes #3408 * docs(adr): add ADR-0011 Skill Surface Budget Module (Phase 1 accepted, Phase 2 amendment) Records the install-time profile staging decision (Phase 1, landed) and the runtime /gsd:surface cluster-toggle decision (Phase 2, in flight) as an amendment. Updates the ADR README index. Closes #3408 * docs(install-profiles): update module docblock for Phase 2 and ADR-0011 Corrects the ADR reference from 0010 to 0011, documents the three-profile model and back-compat aliases, adds resolveEffectiveProfile precedence rule, and notes the companion surface.cjs Phase 2 engine. * docs(context): add Skill Surface Budget Module canonical entry Adds the Domain terms entry for the Skill Surface Budget Module covering both Phase 1 (install-time profiles, .gsd-profile marker) and Phase 2 (runtime /gsd:surface cluster toggles, clusters.cjs, .gsd-surface.json), per ADR-0011 Consequences requirement. * feat(surface): add resolveSurface and applySurface engine + tests Tests cover: profile → surface equivalence, cluster disable/enable, explicitAdds transitive closure, applySurface file sync (add missing, remove superseded, preserve non-gsd files), listSurface token cost. 16 new tests passing. * docs(readme): document --profile= flag and /gsd:surface command Brief user-facing mention of install profiles (core/standard/full) and the /gsd:surface slash command in the Commands table. Points to ADR-0011 for details. * feat(surface): add /gsd:surface slash command runbook New skill: gsd:surface — runtime profile/cluster toggle without reinstall. Sub-commands: list, status, profile <name>, disable/enable <cluster>, reset. Persists state to .gsd-surface.json (independent of .gsd-profile). Description 96 chars (≤100 limit). lint:descriptions + lint:skill-deps: 0 violations. * feat(surface): add changeset fragment for /gsd:surface runtime toggle * feat(surface): add surface skill stem to utility cluster surface.md is a new skill; add it to the utility cluster so the surface-clusters.test.cjs coverage invariant stays satisfied. * docs(adr): fix ADR references to 0011 and record Phase 2 as shipped ADR-0010 number was already claimed by the file-operation-engine ADR; this ADR landed as 0011-skill-surface-budget-module.md. Update inline ADR references in clusters.cjs, surface.cjs, install-profiles.cjs, and the Phase 2 changeset to ADR-0011. Update the ADR Status section to record Phase 2 artifacts as shipped on this branch rather than "in progress". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(research): port skill-surface-budget memo and audit data ADR-0011 references docs/research/2026-05-12-skill-surface-budget.md and docs/research/data/2026-05-12-skill-audit.json, which only existed in the research worktree. Port both onto this branch so the ADR's References section resolves and reviewers can read the cluster taxonomy (§3.2), dependency topology (§3.1), and option grading (§4) that justify Phase 1 and Phase 2 decisions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(registration): register surface/clusters in INVENTORY, COMMANDS, and help.md - surface.md: convert allowed-tools from inline YAML array to block style (was parsed as a single tool name "[Read, Write, Bash]" by test harness) - docs/INVENTORY.md: add CLI module rows for clusters.cjs and surface.cjs; add Commands row for /gsd-surface; bump CLI Modules count 55→57, Commands 66→67 - docs/INVENTORY-MANIFEST.json: add entries for clusters.cjs, surface.cjs, and /gsd-surface (filename-based command key) - docs/COMMANDS.md: add ### `/gsd-surface` heading in Configuration Commands - get-shit-done/workflows/help.md: add /gsd:surface entry in Configuration section Fixes registration failures introduced by Phase 2 of #3408. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(surface,docs): scrub .claude leakage and escape hypothetical slash tokens Two PR regressions introduced earlier on this branch: 1. surface.cjs JSDoc comments contained the canonical paths (~/.claude/commands/gsd, ~/.claude/agents) as example values, which the cline-install leak regex (~\/\.claude\/(?:get-shit-done|commands|agents |hooks)) flagged as install-time path leaks. Reworded the docblocks to describe runtime-resolved paths without literal ~/.claude tokens. 2. The ported research memo proposed hypothetical Option C dispatchers using slash syntax (/gsd:milestone, /gsd:research). The docs-parity-live-registry test enforces that every slash-command token in docs/ resolves to a real command. Rewrote the Option C sketch without the slash prefix and added a clarifying note that the dispatchers are illustrative, not shipped. Targeted tests now pass: tests/cline-install.test.cjs and tests/docs-parity-live-registry.test.cjs both green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: remove raw output/source grep in lint tests * fix: close coderabbit profile and requires issues * test: align surface token-cost assertion wording * fix(install): align core profile alias and defer profile marker write --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
87
tests/install-minimal-backcompat.test.cjs
Normal file
87
tests/install-minimal-backcompat.test.cjs
Normal file
@@ -0,0 +1,87 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Back-compat regression: --minimal still produces the same file set as before
|
||||
* the profile model was introduced (modulo the phase-inclusion fix).
|
||||
*
|
||||
* Also verifies --profile=core is equivalent to --minimal.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const {
|
||||
MINIMAL_SKILL_ALLOWLIST,
|
||||
PROFILES,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const MANIFEST_NAME = 'gsd-file-manifest.json';
|
||||
|
||||
describe('install-minimal-backcompat: PROFILES.core matches MINIMAL_SKILL_ALLOWLIST', () => {
|
||||
test('PROFILES.core contains the same 6 skills as MINIMAL_SKILL_ALLOWLIST', () => {
|
||||
assert.deepStrictEqual(
|
||||
[...PROFILES.core].sort(),
|
||||
[...MINIMAL_SKILL_ALLOWLIST].sort(),
|
||||
'PROFILES.core must equal MINIMAL_SKILL_ALLOWLIST for back-compat',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('install-minimal-backcompat: --minimal and --profile=core produce the same manifest skill count', () => {
|
||||
function installAndGetManifest(extraArgs) {
|
||||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-backcompat-'));
|
||||
try {
|
||||
spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', targetDir, ...extraArgs],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
const manifestPath = path.join(targetDir, MANIFEST_NAME);
|
||||
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
|
||||
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const skillCount = new Set(
|
||||
Object.keys(m.files || {})
|
||||
.filter((k) => k.startsWith('skills/'))
|
||||
.map((k) => k.split('/')[1]),
|
||||
).size;
|
||||
// Read marker
|
||||
const markerPath = path.join(targetDir, '.gsd-profile');
|
||||
const profileMarker = fs.existsSync(markerPath)
|
||||
? fs.readFileSync(markerPath, 'utf8').trim()
|
||||
: null;
|
||||
return { mode: m.mode, skillCount, profileMarker };
|
||||
} finally {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('--minimal produces mode "minimal" with exactly 6 skills', () => {
|
||||
const r = installAndGetManifest(['--minimal']);
|
||||
assert.strictEqual(r.mode, 'minimal');
|
||||
assert.strictEqual(r.skillCount, 6);
|
||||
});
|
||||
|
||||
test('--minimal writes .gsd-profile marker with "core"', () => {
|
||||
const r = installAndGetManifest(['--minimal']);
|
||||
assert.strictEqual(r.profileMarker, 'core', '--minimal should write profile marker "core"');
|
||||
});
|
||||
|
||||
test('default (no flags) writes .gsd-profile marker with "full"', () => {
|
||||
const r = installAndGetManifest([]);
|
||||
assert.strictEqual(r.profileMarker, 'full', 'default install should write profile marker "full"');
|
||||
});
|
||||
|
||||
test('--profile=core writes .gsd-profile marker with "core"', () => {
|
||||
const r = installAndGetManifest(['--profile=core']);
|
||||
assert.strictEqual(r.profileMarker, 'core', '--profile=core should write profile marker "core"');
|
||||
});
|
||||
|
||||
test('--profile=standard writes .gsd-profile marker with "standard"', () => {
|
||||
const r = installAndGetManifest(['--profile=standard']);
|
||||
assert.strictEqual(r.profileMarker, 'standard', '--profile=standard should write profile marker "standard"');
|
||||
});
|
||||
});
|
||||
121
tests/install-profiles-manifest.test.cjs
Normal file
121
tests/install-profiles-manifest.test.cjs
Normal file
@@ -0,0 +1,121 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for loadSkillsManifest — parses requires: frontmatter from commands/gsd/*.md
|
||||
* and returns a Map<stem, string[]>.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
function createFixtureDir() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-manifest-fixture-'));
|
||||
return tmp;
|
||||
}
|
||||
|
||||
function writeSkill(dir, stem, frontmatter) {
|
||||
const content = `---\n${frontmatter}\n---\n\n# body\n`;
|
||||
fs.writeFileSync(path.join(dir, `${stem}.md`), content);
|
||||
}
|
||||
|
||||
describe('loadSkillsManifest', () => {
|
||||
test('returns a Map', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.ok(m instanceof Map, 'should return a Map');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skill with no requires: frontmatter maps to empty array', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help text');
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.ok(m.has('help'), 'help should be in manifest');
|
||||
assert.deepStrictEqual(m.get('help'), []);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skill with requires: single value maps to array of one', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkill(dir, 'add-tests', 'name: gsd:add-tests\ndescription: Add tests\nrequires: [phase]');
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.ok(m.has('add-tests'));
|
||||
assert.deepStrictEqual(m.get('add-tests'), ['phase']);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skill with requires: multiple values maps to full array', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkill(dir, 'plan-phase', 'name: gsd:plan-phase\ndescription: Plan\nrequires: [discuss-phase, phase, review, update]');
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.deepStrictEqual(m.get('plan-phase'), ['discuss-phase', 'phase', 'review', 'update']);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('ignores non-.md files in the dir', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkill(dir, 'help', 'name: gsd:help\ndescription: Help');
|
||||
fs.writeFileSync(path.join(dir, 'README.txt'), 'not a skill');
|
||||
fs.writeFileSync(path.join(dir, 'notes.json'), '{}');
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.ok(m.has('help'));
|
||||
assert.ok(!m.has('README'));
|
||||
assert.ok(!m.has('notes'));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('empty dir returns empty Map', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.strictEqual(m.size, 0);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('skill with requires: empty array maps to empty array', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkill(dir, 'explore', 'name: gsd:explore\ndescription: Explore\nrequires: []');
|
||||
const m = loadSkillsManifest(dir);
|
||||
assert.deepStrictEqual(m.get('explore'), []);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('loads real commands/gsd/ directory without throwing', () => {
|
||||
const realDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const m = loadSkillsManifest(realDir);
|
||||
assert.ok(m.size >= 60, `expected >=60 skills, got ${m.size}`);
|
||||
// discuss-phase requires [config, phase]
|
||||
const depsDP = m.get('discuss-phase');
|
||||
assert.ok(Array.isArray(depsDP), 'discuss-phase should be in manifest');
|
||||
assert.ok(depsDP.includes('phase'), 'discuss-phase should require phase');
|
||||
assert.ok(depsDP.includes('config'), 'discuss-phase should require config');
|
||||
// help has no requires
|
||||
assert.deepStrictEqual(m.get('help'), []);
|
||||
});
|
||||
});
|
||||
118
tests/install-profiles-marker.test.cjs
Normal file
118
tests/install-profiles-marker.test.cjs
Normal file
@@ -0,0 +1,118 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for readActiveProfile / writeActiveProfile marker persistence.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
readActiveProfile,
|
||||
writeActiveProfile,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
describe('readActiveProfile / writeActiveProfile', () => {
|
||||
test('write then read round-trips the profile name', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'standard');
|
||||
assert.strictEqual(readActiveProfile(dir), 'standard');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trips "core" profile', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
assert.strictEqual(readActiveProfile(dir), 'core');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trips composed profiles "core,audit"', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core,audit');
|
||||
assert.strictEqual(readActiveProfile(dir), 'core,audit');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trips "full"', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'full');
|
||||
assert.strictEqual(readActiveProfile(dir), 'full');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing marker file returns null (not throws)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
const result = readActiveProfile(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent directory returns null (not throws)', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'gsd-marker-no-exist-' + Date.now());
|
||||
const result = readActiveProfile(ghost);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('corrupt marker content (invalid chars) returns null', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-profile'), 'profile with spaces and !!!\n');
|
||||
const result = readActiveProfile(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('empty marker file returns null', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-profile'), '');
|
||||
const result = readActiveProfile(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeActiveProfile creates the directory if it does not exist', () => {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-base-'));
|
||||
const nested = path.join(base, 'skills', '.claude');
|
||||
try {
|
||||
writeActiveProfile(nested, 'standard');
|
||||
assert.ok(fs.existsSync(nested), 'directory should be created');
|
||||
assert.strictEqual(readActiveProfile(nested), 'standard');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('overwrites a previously written profile', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-marker-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
writeActiveProfile(dir, 'full');
|
||||
assert.strictEqual(readActiveProfile(dir), 'full');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
197
tests/install-profiles-resolve.test.cjs
Normal file
197
tests/install-profiles-resolve.test.cjs
Normal file
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for resolveProfile — computes transitive closure over the requires: graph.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
PROFILES,
|
||||
resolveProfile,
|
||||
loadSkillsManifest,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
describe('PROFILES map', () => {
|
||||
test('PROFILES is frozen', () => {
|
||||
assert.ok(Object.isFrozen(PROFILES));
|
||||
});
|
||||
|
||||
test('PROFILES has core, standard, full keys', () => {
|
||||
assert.ok('core' in PROFILES, 'PROFILES.core missing');
|
||||
assert.ok('standard' in PROFILES, 'PROFILES.standard missing');
|
||||
assert.ok('full' in PROFILES, 'PROFILES.full missing');
|
||||
});
|
||||
|
||||
test('PROFILES.core contains the 6 main-loop skills', () => {
|
||||
const core = PROFILES.core;
|
||||
assert.ok(Array.isArray(core), 'core should be an array');
|
||||
const sorted = [...core].sort();
|
||||
assert.deepStrictEqual(sorted, [
|
||||
'discuss-phase',
|
||||
'execute-phase',
|
||||
'help',
|
||||
'new-project',
|
||||
'plan-phase',
|
||||
'update',
|
||||
]);
|
||||
});
|
||||
|
||||
test('PROFILES.full is the sentinel "*"', () => {
|
||||
assert.strictEqual(PROFILES.full, '*');
|
||||
});
|
||||
|
||||
test('PROFILES.standard contains at least the core skills', () => {
|
||||
const core = new Set(PROFILES.core);
|
||||
const standard = PROFILES.standard;
|
||||
assert.ok(Array.isArray(standard), 'standard should be an array');
|
||||
for (const s of core) {
|
||||
assert.ok(standard.includes(s), `standard should include core skill: ${s}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PROFILES.standard has at least 10 skills', () => {
|
||||
assert.ok(PROFILES.standard.length >= 10, `standard should have >=10 skills, got ${PROFILES.standard.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveProfile', () => {
|
||||
test('defaults to full when called with no args', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ manifest });
|
||||
assert.strictEqual(result.name, 'full');
|
||||
assert.strictEqual(result.skills, '*');
|
||||
});
|
||||
|
||||
test('resolves core profile — returns 6+ skills (closure adds phase)', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['core'], manifest });
|
||||
assert.strictEqual(result.name, 'core');
|
||||
assert.ok(result.skills instanceof Set, 'skills should be a Set');
|
||||
// core has 6 base; closure adds phase (referenced by discuss/plan/execute/new-project)
|
||||
// and config (referenced by discuss-phase, new-project), and more
|
||||
assert.ok(result.skills.size >= 6, `core closure should have >=6 skills, got ${result.skills.size}`);
|
||||
// All 6 base skills must be present
|
||||
for (const s of PROFILES.core) {
|
||||
assert.ok(result.skills.has(s), `core closure should include ${s}`);
|
||||
}
|
||||
// phase must be included via closure (discuss-phase, plan-phase, etc. require it)
|
||||
assert.ok(result.skills.has('phase'), 'core closure must include phase (required by discuss/plan/execute-phase)');
|
||||
});
|
||||
|
||||
test('resolves standard profile — returns superset of core', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const coreResult = resolveProfile({ modes: ['core'], manifest });
|
||||
const stdResult = resolveProfile({ modes: ['standard'], manifest });
|
||||
assert.strictEqual(stdResult.name, 'standard');
|
||||
assert.ok(stdResult.skills instanceof Set);
|
||||
assert.ok(stdResult.skills.size >= coreResult.skills.size, 'standard should have >= skills than core');
|
||||
// All core closure skills must be in standard
|
||||
for (const s of coreResult.skills) {
|
||||
assert.ok(stdResult.skills.has(s), `standard must include core skill: ${s}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('resolves full profile — returns sentinel', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['full'], manifest });
|
||||
assert.strictEqual(result.name, 'full');
|
||||
assert.strictEqual(result.skills, '*');
|
||||
});
|
||||
|
||||
test('composable profiles — core,standard union is same as standard', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const stdResult = resolveProfile({ modes: ['standard'], manifest });
|
||||
const composed = resolveProfile({ modes: ['core', 'standard'], manifest });
|
||||
// name should reflect composed
|
||||
assert.ok(composed.name.includes('core') && composed.name.includes('standard'),
|
||||
`composed name should include both, got: ${composed.name}`);
|
||||
// skills union should equal standard (since core ⊂ standard)
|
||||
for (const s of stdResult.skills) {
|
||||
assert.ok(composed.skills.has(s), `composed should include standard skill: ${s}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('transitive closure: skill that requires phase pulls in phase', () => {
|
||||
// Build a minimal manifest: only discuss-phase requiring phase
|
||||
const manifest = new Map([
|
||||
['discuss-phase', ['phase']],
|
||||
['phase', []],
|
||||
['help', []],
|
||||
]);
|
||||
// Profile with only discuss-phase in base
|
||||
const miniProfiles = { core: ['discuss-phase', 'help'], full: '*', standard: ['discuss-phase', 'help'] };
|
||||
const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles });
|
||||
assert.ok(result.skills.has('phase'), 'phase should be pulled in via closure from discuss-phase');
|
||||
assert.ok(result.skills.has('discuss-phase'));
|
||||
assert.ok(result.skills.has('help'));
|
||||
});
|
||||
|
||||
test('deep transitive closure works (A→B→C pulls in C)', () => {
|
||||
const manifest = new Map([
|
||||
['a', ['b']],
|
||||
['b', ['c']],
|
||||
['c', []],
|
||||
]);
|
||||
const miniProfiles = { core: ['a'], full: '*', standard: ['a'] };
|
||||
const result = resolveProfile({ modes: ['core'], manifest, _profilesOverride: miniProfiles });
|
||||
assert.ok(result.skills.has('a'));
|
||||
assert.ok(result.skills.has('b'));
|
||||
assert.ok(result.skills.has('c'));
|
||||
});
|
||||
|
||||
test('resolveProfile result has agents Set', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['core'], manifest });
|
||||
assert.ok(result.agents instanceof Set, 'result should have agents Set');
|
||||
});
|
||||
|
||||
test('resolveProfile standard — agents Set is non-empty (plan-phase pulls gsd-planner etc)', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['standard'], manifest });
|
||||
assert.ok(result.agents instanceof Set, 'agents should be a Set');
|
||||
assert.ok(result.agents.size > 0, `standard profile should have >0 agents, got ${result.agents.size}`);
|
||||
// plan-phase is in standard and calls gsd-planner, gsd-plan-checker, gsd-phase-researcher
|
||||
assert.ok(result.agents.has('gsd-planner'), 'standard should include gsd-planner (called by plan-phase)');
|
||||
assert.ok(result.agents.has('gsd-plan-checker'), 'standard should include gsd-plan-checker (called by plan-phase)');
|
||||
});
|
||||
|
||||
test('resolveProfile full — agents is empty Set (full staging uses srcDir directly)', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = resolveProfile({ modes: ['full'], manifest });
|
||||
assert.strictEqual(result.skills, '*');
|
||||
// Full profile: agents Set is empty because stageAgentsForProfile uses srcDir directly
|
||||
assert.ok(result.agents instanceof Set, 'agents should still be a Set for full');
|
||||
});
|
||||
|
||||
test('agents are derived from skill body text — synthetic manifest', () => {
|
||||
// Build a synthetic manifest where plan-phase calls gsd-planner
|
||||
const manifest = new Map([
|
||||
['plan-phase', []],
|
||||
['phase', []],
|
||||
]);
|
||||
// Override with calls_agents map
|
||||
manifest.get('plan-phase'); // ensure it exists
|
||||
// Use the real loadSkillsManifest with REAL dir to verify body parsing works
|
||||
const realManifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const planPhaseAgents = realManifest.get('_calls_agents_plan-phase') ||
|
||||
// calls_agents may be stored as a separate key or on the manifest entry itself
|
||||
[];
|
||||
// This test validates that the real manifest has agent mappings for plan-phase
|
||||
// by checking resolveProfile computes agents correctly
|
||||
const result = resolveProfile({ modes: ['standard'], manifest: realManifest });
|
||||
assert.ok(result.agents.has('gsd-planner'), 'gsd-planner should be derived from plan-phase body');
|
||||
});
|
||||
|
||||
test('agents transitively closed — skill requiring plan-phase also gets its agents', () => {
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
// quick requires plan-phase (via requires: field or direct) and also calls gsd-planner directly
|
||||
// new-project requires plan-phase so inherits its agents
|
||||
const result = resolveProfile({ modes: ['standard'], manifest });
|
||||
// Since plan-phase is in standard, and plan-phase calls gsd-planner, gsd-planner must be present
|
||||
assert.ok(result.agents.has('gsd-planner'));
|
||||
});
|
||||
});
|
||||
181
tests/install-profiles-stage.test.cjs
Normal file
181
tests/install-profiles-stage.test.cjs
Normal file
@@ -0,0 +1,181 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for stageSkillsForProfile and stageAgentsForProfile.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
stageSkillsForProfile,
|
||||
stageAgentsForProfile,
|
||||
cleanupStagedSkills,
|
||||
resolveProfile,
|
||||
loadSkillsManifest,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
|
||||
function createFixtureSkillsDir() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-profile-'));
|
||||
for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'progress', 'help', 'phase']) {
|
||||
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
function createFixtureAgentsDir() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agents-profile-'));
|
||||
for (const name of ['gsd-planner', 'gsd-executor', 'gsd-code-reviewer']) {
|
||||
fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`);
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
describe('stageSkillsForProfile', () => {
|
||||
test('full profile (skills === "*") returns srcDir unchanged', () => {
|
||||
const src = createFixtureSkillsDir();
|
||||
try {
|
||||
const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() });
|
||||
assert.strictEqual(result, src);
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('profile with Set copies only member files', () => {
|
||||
const src = createFixtureSkillsDir();
|
||||
let staged;
|
||||
try {
|
||||
const skills = new Set(['plan-phase', 'help', 'phase']);
|
||||
staged = stageSkillsForProfile(src, { skills, agents: new Set() });
|
||||
assert.notStrictEqual(staged, src);
|
||||
const files = fs.readdirSync(staged).sort();
|
||||
assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']);
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
});
|
||||
|
||||
test('preserves file content byte-for-byte', () => {
|
||||
const src = createFixtureSkillsDir();
|
||||
const content = '# plan-phase special content\n\nsome body\n';
|
||||
fs.writeFileSync(path.join(src, 'plan-phase.md'), content);
|
||||
let staged;
|
||||
try {
|
||||
const skills = new Set(['plan-phase']);
|
||||
staged = stageSkillsForProfile(src, { skills, agents: new Set() });
|
||||
const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8');
|
||||
assert.strictEqual(copied, content);
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent srcDir returns srcDir unchanged', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'gsd-no-exist-' + Date.now());
|
||||
const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() });
|
||||
assert.strictEqual(result, ghost);
|
||||
});
|
||||
|
||||
test('empty skills Set produces empty staged dir', () => {
|
||||
const src = createFixtureSkillsDir();
|
||||
let staged;
|
||||
try {
|
||||
staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() });
|
||||
const files = fs.readdirSync(staged);
|
||||
assert.deepStrictEqual(files, []);
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('stageAgentsForProfile', () => {
|
||||
test('full profile (skills === "*") returns srcDir unchanged', () => {
|
||||
const src = createFixtureAgentsDir();
|
||||
try {
|
||||
const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() });
|
||||
assert.strictEqual(result, src);
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('non-full profile with empty agents Set produces empty staged dir', () => {
|
||||
const src = createFixtureAgentsDir();
|
||||
let staged;
|
||||
try {
|
||||
staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() });
|
||||
const files = fs.readdirSync(staged);
|
||||
assert.deepStrictEqual(files, [], 'no agents for non-full profile by default');
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
});
|
||||
|
||||
test('non-full profile with agents Set copies only member agent files', () => {
|
||||
const src = createFixtureAgentsDir();
|
||||
let staged;
|
||||
try {
|
||||
const agents = new Set(['gsd-planner']);
|
||||
staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents });
|
||||
const files = fs.readdirSync(staged).sort();
|
||||
assert.deepStrictEqual(files, ['gsd-planner.md']);
|
||||
} finally {
|
||||
fs.rmSync(src, { recursive: true, force: true });
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'gsd-agents-no-exist-' + Date.now());
|
||||
const result = stageAgentsForProfile(ghost, { skills: new Set(), agents: new Set() });
|
||||
assert.strictEqual(result, ghost);
|
||||
});
|
||||
|
||||
test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', () => {
|
||||
// Uses the real agents dir and commands dir
|
||||
if (!fs.existsSync(REAL_AGENTS_DIR) || !fs.existsSync(REAL_COMMANDS_DIR)) return;
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const resolved = resolveProfile({ modes: ['standard'], manifest });
|
||||
assert.ok(resolved.agents instanceof Set && resolved.agents.size > 0,
|
||||
'standard profile must have >0 agents (plan-phase calls gsd-planner etc)');
|
||||
let staged;
|
||||
try {
|
||||
staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved);
|
||||
const stagedFiles = new Set(
|
||||
fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3))
|
||||
);
|
||||
// Every file staged must be in resolved.agents
|
||||
for (const stem of stagedFiles) {
|
||||
assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`);
|
||||
}
|
||||
// Every agent in resolved.agents that exists in the real dir must be staged
|
||||
for (const agentStem of resolved.agents) {
|
||||
const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`));
|
||||
if (exists) {
|
||||
assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
});
|
||||
|
||||
test('full profile staging returns real agents dir unchanged', () => {
|
||||
if (!fs.existsSync(REAL_AGENTS_DIR)) return;
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const resolved = resolveProfile({ modes: ['full'], manifest });
|
||||
const result = stageAgentsForProfile(REAL_AGENTS_DIR, resolved);
|
||||
assert.strictEqual(result, REAL_AGENTS_DIR);
|
||||
});
|
||||
});
|
||||
196
tests/install-update-marker.test.cjs
Normal file
196
tests/install-update-marker.test.cjs
Normal file
@@ -0,0 +1,196 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for marker-driven profile re-application on `gsd update` (Deviation 2).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. resolveEffectiveProfile returns the marker's profile when no explicit flag given.
|
||||
* 2. Explicit --profile flag overrides the marker.
|
||||
* 3. Multi-runtime marker disagreement resolves to the most-restrictive profile.
|
||||
* 4. Missing marker falls back to 'full'.
|
||||
* 5. stageSkillsForProfile is called with the resolved profile (not 'full') on updates.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const {
|
||||
resolveEffectiveProfile,
|
||||
mostRestrictiveProfile,
|
||||
writeActiveProfile,
|
||||
readActiveProfile,
|
||||
resolveProfile,
|
||||
loadSkillsManifest,
|
||||
stageSkillsForProfile,
|
||||
cleanupStagedSkills,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
describe('resolveEffectiveProfile', () => {
|
||||
test('no explicit flag and no marker → returns "full"', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
|
||||
try {
|
||||
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
|
||||
assert.strictEqual(result, 'full');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no explicit flag but marker exists → returns marker profile', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'standard');
|
||||
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
|
||||
assert.strictEqual(result, 'standard');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('no explicit flag but core marker exists → returns "core"', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
|
||||
assert.strictEqual(result, 'core');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit --profile=full overrides a non-full marker', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
const result = resolveEffectiveProfile({ requestedProfileName: 'full', targetDir: dir });
|
||||
assert.strictEqual(result, 'full');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit --profile=standard overrides a core marker', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
const result = resolveEffectiveProfile({ requestedProfileName: 'standard', targetDir: dir });
|
||||
assert.strictEqual(result, 'standard');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('full marker falls back to "full" (not recorded as a restriction)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-reu-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'full');
|
||||
const result = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
|
||||
assert.strictEqual(result, 'full');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mostRestrictiveProfile', () => {
|
||||
test('returns "core" over "standard" (core is smaller)', () => {
|
||||
const result = mostRestrictiveProfile(['standard', 'core']);
|
||||
assert.strictEqual(result, 'core');
|
||||
});
|
||||
|
||||
test('returns "core" over "full"', () => {
|
||||
const result = mostRestrictiveProfile(['full', 'core']);
|
||||
assert.strictEqual(result, 'core');
|
||||
});
|
||||
|
||||
test('returns "standard" over "full"', () => {
|
||||
const result = mostRestrictiveProfile(['full', 'standard']);
|
||||
assert.strictEqual(result, 'standard');
|
||||
});
|
||||
|
||||
test('single profile returns that profile', () => {
|
||||
assert.strictEqual(mostRestrictiveProfile(['standard']), 'standard');
|
||||
assert.strictEqual(mostRestrictiveProfile(['full']), 'full');
|
||||
assert.strictEqual(mostRestrictiveProfile(['core']), 'core');
|
||||
});
|
||||
|
||||
test('empty array returns "full" (no restriction)', () => {
|
||||
assert.strictEqual(mostRestrictiveProfile([]), 'full');
|
||||
});
|
||||
|
||||
test('all same profile returns that profile', () => {
|
||||
assert.strictEqual(mostRestrictiveProfile(['standard', 'standard', 'standard']), 'standard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('marker-driven profile resolution end-to-end', () => {
|
||||
test('fresh install with --profile=standard writes marker, re-read resolves standard', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-e2e-'));
|
||||
try {
|
||||
// Simulate: fresh install with --profile=standard
|
||||
writeActiveProfile(dir, 'standard');
|
||||
// Simulate: re-run without flags (e.g. `gsd update`)
|
||||
const effective = resolveEffectiveProfile({ requestedProfileName: null, targetDir: dir });
|
||||
assert.strictEqual(effective, 'standard');
|
||||
// Verify the staged output contains only standard's skills
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const resolved = resolveProfile({ modes: [effective], manifest });
|
||||
assert.ok(resolved.skills instanceof Set, 'skills should be a Set for standard');
|
||||
assert.ok(resolved.skills.has('plan-phase'), 'standard should include plan-phase');
|
||||
// plan-phase should be staged
|
||||
let staged;
|
||||
try {
|
||||
staged = stageSkillsForProfile(REAL_COMMANDS_DIR, resolved);
|
||||
assert.notStrictEqual(staged, REAL_COMMANDS_DIR, 'should be a staged dir, not srcDir');
|
||||
const stagedFiles = fs.readdirSync(staged).map(f => f.slice(0, -3)); // strip .md
|
||||
assert.ok(stagedFiles.includes('plan-phase'), 'plan-phase.md should be staged');
|
||||
// full-only skills must NOT be staged
|
||||
const allFiles = fs.readdirSync(REAL_COMMANDS_DIR).map(f => f.slice(0, -3));
|
||||
const notInStandard = allFiles.filter(s => !resolved.skills.has(s));
|
||||
for (const stem of notInStandard) {
|
||||
assert.ok(!stagedFiles.includes(stem),
|
||||
`${stem} should not be staged in standard profile`);
|
||||
}
|
||||
} finally {
|
||||
if (staged) cleanupStagedSkills();
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('marker disagreement across runtimes → mostRestrictiveProfile picks smaller set', () => {
|
||||
const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rtA-'));
|
||||
const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rtB-'));
|
||||
try {
|
||||
writeActiveProfile(dirA, 'standard');
|
||||
writeActiveProfile(dirB, 'core');
|
||||
const profileA = readActiveProfile(dirA) || 'full';
|
||||
const profileB = readActiveProfile(dirB) || 'full';
|
||||
const resolved = mostRestrictiveProfile([profileA, profileB]);
|
||||
assert.strictEqual(resolved, 'core',
|
||||
'core is smaller than standard — most-restrictive wins');
|
||||
} finally {
|
||||
fs.rmSync(dirA, { recursive: true, force: true });
|
||||
fs.rmSync(dirB, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicit --profile=full overrides restrictive marker (no narrowing)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-override-'));
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
const effective = resolveEffectiveProfile({ requestedProfileName: 'full', targetDir: dir });
|
||||
assert.strictEqual(effective, 'full');
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const resolved = resolveProfile({ modes: [effective], manifest });
|
||||
assert.strictEqual(resolved.skills, '*', 'full profile should be sentinel');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
144
tests/lint-skill-deps.test.cjs
Normal file
144
tests/lint-skill-deps.test.cjs
Normal file
@@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Fixture-based tests for scripts/lint-skill-deps.cjs.
|
||||
*
|
||||
* Uses child_process.spawnSync to exercise the lint script as a black box,
|
||||
* passing fixture files via argv. This tests the actual exit codes and
|
||||
* error output the script produces.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const LINT_SCRIPT = path.join(__dirname, '..', 'scripts', 'lint-skill-deps.cjs');
|
||||
|
||||
function runLint(args = []) {
|
||||
return spawnSync(process.execPath, [LINT_SCRIPT, ...args], { encoding: 'utf8' });
|
||||
}
|
||||
|
||||
function createFixtureDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lint-deps-fixture-'));
|
||||
}
|
||||
|
||||
function writeSkillFile(dir, stem, { description = 'Test skill', requires = null, body = '' }) {
|
||||
let fm = `name: gsd:${stem}\ndescription: ${description}`;
|
||||
if (requires !== null) {
|
||||
fm += `\nrequires: [${requires.join(', ')}]`;
|
||||
}
|
||||
const content = `---\n${fm}\n---\n\n${body}\n`;
|
||||
fs.writeFileSync(path.join(dir, `${stem}.md`), content);
|
||||
}
|
||||
|
||||
describe('lint-skill-deps: frontmatter ↔ body consistency', () => {
|
||||
test('exits 0 when all skills have consistent requires: and references', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
// phase.md has no references to other skills
|
||||
writeSkillFile(dir, 'phase', { description: 'Phase skill', body: 'Use this to manage phases.' });
|
||||
// discuss-phase requires phase and references it in body
|
||||
writeSkillFile(dir, 'discuss-phase', {
|
||||
description: 'Discuss skill',
|
||||
requires: ['phase'],
|
||||
body: 'Invoke with /gsd:phase to manage phases.',
|
||||
});
|
||||
const result = runLint(['--dir', dir]);
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exits non-zero when body references gsd:phase but requires: is absent', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkillFile(dir, 'phase', { description: 'Phase skill', body: '' });
|
||||
writeSkillFile(dir, 'discuss-phase', {
|
||||
description: 'Discuss skill',
|
||||
// requires: absent — but body references /gsd:phase
|
||||
requires: null,
|
||||
body: 'Use /gsd:phase to manage phases.',
|
||||
});
|
||||
const result = runLint(['--dir', dir]);
|
||||
assert.notStrictEqual(result.status, 0, 'Should exit non-zero when requires: is missing but body has reference');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exits non-zero when body references gsd-phase but requires: does not include it', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkillFile(dir, 'phase', { description: 'Phase skill', body: '' });
|
||||
writeSkillFile(dir, 'discuss-phase', {
|
||||
description: 'Discuss skill',
|
||||
requires: ['config'], // has requires but missing 'phase'
|
||||
body: 'Use /gsd:phase to manage phases.',
|
||||
});
|
||||
const result = runLint(['--dir', dir]);
|
||||
assert.notStrictEqual(result.status, 0, 'Should exit non-zero for undeclared reference');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exits 0 when skill has no body references and no requires:', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkillFile(dir, 'help', { description: 'Help skill', body: 'Shows help.' });
|
||||
const result = runLint(['--dir', dir]);
|
||||
assert.strictEqual(result.status, 0);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('exits non-zero when body references unknown skill stem', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkillFile(dir, 'discuss-phase', {
|
||||
description: 'Discuss',
|
||||
requires: ['phase'],
|
||||
body: 'Use /gsd:phase.',
|
||||
});
|
||||
// Unknown skill reference should fail even if declared in requires.
|
||||
const result = runLint(['--dir', dir]);
|
||||
assert.notStrictEqual(result.status, 0, 'Unknown skill references must fail lint');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('lint-skill-deps: profile closure satisfaction', () => {
|
||||
test('exits 0 when run against real commands/gsd (all profiles closed or full)', () => {
|
||||
// This is the most important integration test: running lint on the real
|
||||
// commands/gsd/ directory with the real PROFILES must pass.
|
||||
const result = runLint();
|
||||
assert.strictEqual(result.status, 0,
|
||||
`lint-skill-deps failed on real codebase:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lint-skill-deps: script basics', () => {
|
||||
test('script is executable (no syntax errors)', () => {
|
||||
const result = spawnSync(process.execPath, ['--check', LINT_SCRIPT], { encoding: 'utf8' });
|
||||
assert.strictEqual(result.status, 0, `Syntax error in lint script: ${result.stderr}`);
|
||||
});
|
||||
|
||||
test('prints ok message on success', () => {
|
||||
const dir = createFixtureDir();
|
||||
try {
|
||||
writeSkillFile(dir, 'help', { description: 'Help skill', body: 'Shows help.' });
|
||||
const result = runLint(['--dir', dir]);
|
||||
assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
assert.strictEqual(result.stderr, '', `Expected empty stderr on success, got: ${result.stderr}`);
|
||||
assert.ok(result.stdout.length > 0, 'Expected non-empty stdout on success');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
165
tests/surface-apply.test.cjs
Normal file
165
tests/surface-apply.test.cjs
Normal file
@@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for applySurface — file sync behavior.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents');
|
||||
|
||||
function tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-apply-'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal fixture install dir structure.
|
||||
* Returns { runtimeConfigDir, commandsDir, agentsDir }.
|
||||
* runtimeConfigDir has a .gsd-source marker pointing to REAL_COMMANDS_DIR.
|
||||
*/
|
||||
function createFixtureRuntime() {
|
||||
const base = tmpDir();
|
||||
const runtimeConfigDir = path.join(base, 'config');
|
||||
const commandsDir = path.join(base, 'commands', 'gsd');
|
||||
const agentsDir = path.join(base, 'agents');
|
||||
fs.mkdirSync(runtimeConfigDir, { recursive: true });
|
||||
fs.mkdirSync(commandsDir, { recursive: true });
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
// Write source marker so surface.cjs can find the install source
|
||||
fs.writeFileSync(path.join(runtimeConfigDir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
||||
return { base, runtimeConfigDir, commandsDir, agentsDir };
|
||||
}
|
||||
|
||||
describe('applySurface', () => {
|
||||
test('core profile: only core skills appear in commandsDir', () => {
|
||||
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
|
||||
try {
|
||||
writeActiveProfile(runtimeConfigDir, 'core');
|
||||
writeSurface(runtimeConfigDir, {
|
||||
baseProfile: 'core',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
|
||||
|
||||
const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'));
|
||||
// Every file should be a real stem we know about
|
||||
for (const file of files) {
|
||||
assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`);
|
||||
}
|
||||
// At minimum core skills should be present
|
||||
const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update'];
|
||||
for (const stem of coreStems) {
|
||||
assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('removes superseded files when profile shrinks', () => {
|
||||
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
|
||||
try {
|
||||
// Start with standard: put some skill files in commandsDir
|
||||
writeActiveProfile(runtimeConfigDir, 'standard');
|
||||
writeSurface(runtimeConfigDir, {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
|
||||
|
||||
const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
|
||||
|
||||
// Now switch to core: skills not in core should be removed
|
||||
writeSurface(runtimeConfigDir, {
|
||||
baseProfile: 'core',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
|
||||
|
||||
const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')));
|
||||
|
||||
// core should be a subset of standard
|
||||
assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard');
|
||||
|
||||
// Files removed should not be in core set
|
||||
const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']);
|
||||
for (const file of afterCore) {
|
||||
const stem = file.slice(0, -3);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(REAL_COMMANDS_DIR, file)),
|
||||
`file in commandsDir not a real skill: ${file}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('leaves non-gsd .md files alone in agentsDir', () => {
|
||||
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
|
||||
try {
|
||||
// Place a non-gsd agent file in agentsDir
|
||||
const foreignAgent = path.join(agentsDir, 'my-custom-agent.md');
|
||||
fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8');
|
||||
|
||||
writeActiveProfile(runtimeConfigDir, 'core');
|
||||
writeSurface(runtimeConfigDir, {
|
||||
baseProfile: 'core',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
|
||||
|
||||
// Non-gsd file should still be there
|
||||
assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched');
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('adds missing skill files from install source', () => {
|
||||
const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime();
|
||||
try {
|
||||
// commandsDir starts empty
|
||||
writeActiveProfile(runtimeConfigDir, 'core');
|
||||
writeSurface(runtimeConfigDir, {
|
||||
baseProfile: 'core',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS);
|
||||
|
||||
// Core skills should now be present
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'help.md')),
|
||||
'help.md should be copied from install source'
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsDir, 'new-project.md')),
|
||||
'new-project.md should be copied from install source'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
94
tests/surface-clusters.test.cjs
Normal file
94
tests/surface-clusters.test.cjs
Normal file
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for CLUSTERS data structure integrity.
|
||||
* Verifies every cluster member is a real skill stem and all skills are covered.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { CLUSTERS, allClusteredSkills } = require('../get-shit-done/bin/lib/clusters.cjs');
|
||||
|
||||
const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
function realSkillStems() {
|
||||
const entries = fs.readdirSync(COMMANDS_DIR, { withFileTypes: true });
|
||||
return new Set(
|
||||
entries
|
||||
.filter(e => e.isFile() && e.name.endsWith('.md'))
|
||||
.map(e => e.name.slice(0, -3))
|
||||
);
|
||||
}
|
||||
|
||||
describe('CLUSTERS data structure', () => {
|
||||
test('no cluster is empty', () => {
|
||||
for (const [name, members] of Object.entries(CLUSTERS)) {
|
||||
assert.ok(members.length > 0, `cluster ${name} must not be empty`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every cluster member is a real skill stem in commands/gsd/', () => {
|
||||
const realStems = realSkillStems();
|
||||
const mismatches = [];
|
||||
for (const [cluster, members] of Object.entries(CLUSTERS)) {
|
||||
for (const stem of members) {
|
||||
if (!realStems.has(stem)) {
|
||||
mismatches.push(`${cluster}: "${stem}" not found in commands/gsd/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepStrictEqual(mismatches, [], `Cluster members missing from disk:\n${mismatches.join('\n')}`);
|
||||
});
|
||||
|
||||
test('union of all clusters covers every skill in commands/gsd/', () => {
|
||||
const realStems = realSkillStems();
|
||||
const clustered = allClusteredSkills();
|
||||
const uncategorized = [];
|
||||
for (const stem of realStems) {
|
||||
if (!clustered.has(stem)) uncategorized.push(stem);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
uncategorized,
|
||||
[],
|
||||
`Uncategorized skills (not in any cluster):\n${uncategorized.sort().join('\n')}`
|
||||
);
|
||||
});
|
||||
|
||||
test('CLUSTERS is frozen (immutable)', () => {
|
||||
assert.ok(Object.isFrozen(CLUSTERS), 'CLUSTERS must be frozen');
|
||||
for (const [name, members] of Object.entries(CLUSTERS)) {
|
||||
assert.ok(Object.isFrozen(members), `CLUSTERS.${name} must be frozen`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cluster names match expected set from research memo §3.2', () => {
|
||||
const expectedClusterNames = new Set([
|
||||
'core_loop',
|
||||
'audit_review',
|
||||
'milestone',
|
||||
'research_ideate',
|
||||
'workspace_state',
|
||||
'docs',
|
||||
'ui',
|
||||
'ai_eval',
|
||||
'ns_meta',
|
||||
'utility',
|
||||
]);
|
||||
const actualClusterNames = new Set(Object.keys(CLUSTERS));
|
||||
for (const name of expectedClusterNames) {
|
||||
assert.ok(actualClusterNames.has(name), `expected cluster "${name}" missing from CLUSTERS`);
|
||||
}
|
||||
});
|
||||
|
||||
test('allClusteredSkills returns a Set containing all cluster members', () => {
|
||||
const result = allClusteredSkills();
|
||||
assert.ok(result instanceof Set, 'allClusteredSkills() must return a Set');
|
||||
for (const members of Object.values(CLUSTERS)) {
|
||||
for (const stem of members) {
|
||||
assert.ok(result.has(stem), `allClusteredSkills() missing "${stem}"`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
143
tests/surface-list.test.cjs
Normal file
143
tests/surface-list.test.cjs
Normal file
@@ -0,0 +1,143 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for listSurface — enabled/disabled/tokenCost output.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { writeSurface, listSurface } = require('../get-shit-done/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
function tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-list-'));
|
||||
}
|
||||
|
||||
function readFrontmatterDescription(markdown) {
|
||||
const lines = markdown.split('\n');
|
||||
if (lines[0].trim() !== '---') return '';
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '---') break;
|
||||
const sep = line.indexOf(':');
|
||||
if (sep === -1) continue;
|
||||
const key = line.slice(0, sep).trim();
|
||||
if (key !== 'description') continue;
|
||||
return line.slice(sep + 1).trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
describe('listSurface', () => {
|
||||
test('returns { enabled, disabled, tokenCost } structure', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
// Write source marker so listSurface can find descriptions
|
||||
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
||||
writeActiveProfile(dir, 'core');
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = listSurface(dir, manifest, 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');
|
||||
assert.ok(result.tokenCost >= 0, 'tokenCost must be non-negative');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('core profile: enabled has fewer skills than full', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
||||
writeActiveProfile(dir, 'core');
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const coreList = listSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
// Core should have fewer enabled skills than total
|
||||
const totalStems = [...manifest.keys()].filter(k => !k.startsWith('_calls_agents_')).length;
|
||||
assert.ok(
|
||||
coreList.enabled.length < totalStems,
|
||||
'core should enable fewer skills than total'
|
||||
);
|
||||
assert.ok(coreList.disabled.length > 0, 'core should have some disabled skills');
|
||||
assert.ok(coreList.enabled.length + coreList.disabled.length === totalStems,
|
||||
'enabled + disabled must equal total stems');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('disabling utility cluster reduces enabled count', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
||||
writeActiveProfile(dir, 'standard');
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
|
||||
const beforeList = listSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
writeSurface(dir, {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: ['utility'],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const afterList = listSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
assert.ok(afterList.enabled.length <= beforeList.enabled.length,
|
||||
'disabling utility cluster should not increase enabled count');
|
||||
assert.ok(afterList.tokenCost <= beforeList.tokenCost,
|
||||
'disabling a cluster should not increase token cost');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('tokenCost is sum of description char lengths ÷ 4 for enabled skills', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
||||
writeActiveProfile(dir, 'core');
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = listSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
// Manually compute expected token cost for enabled skills
|
||||
let expected = 0;
|
||||
for (const stem of result.enabled) {
|
||||
const filePath = path.join(REAL_COMMANDS_DIR, `${stem}.md`);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
const markdown = fs.readFileSync(filePath, 'utf8');
|
||||
const description = readFrontmatterDescription(markdown);
|
||||
if (description) expected += Math.ceil(description.length / 4);
|
||||
}
|
||||
|
||||
assert.strictEqual(result.tokenCost, expected, 'tokenCost must equal sum of description lengths ÷ 4');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('enabled and disabled arrays are sorted', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8');
|
||||
writeActiveProfile(dir, 'standard');
|
||||
const manifest = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const result = listSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
assert.deepStrictEqual(result.enabled, [...result.enabled].sort());
|
||||
assert.deepStrictEqual(result.disabled, [...result.disabled].sort());
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
194
tests/surface-resolve.test.cjs
Normal file
194
tests/surface-resolve.test.cjs
Normal file
@@ -0,0 +1,194 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for resolveSurface — profile + cluster + explicit combinations.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { readSurface, writeSurface, resolveSurface } = require('../get-shit-done/bin/lib/surface.cjs');
|
||||
const { resolveProfile, loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
function tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-resolve-'));
|
||||
}
|
||||
|
||||
function realManifest() {
|
||||
return loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
}
|
||||
|
||||
describe('resolveSurface', () => {
|
||||
test('no surface state + core base profile → identical to resolveProfile core', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
const manifest = realManifest();
|
||||
const surfaceResolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
const profileResolved = resolveProfile({ modes: ['core'], manifest });
|
||||
|
||||
// Both should have same skill sets
|
||||
assert.ok(surfaceResolved.skills instanceof Set);
|
||||
assert.ok(profileResolved.skills instanceof Set);
|
||||
assert.deepStrictEqual(
|
||||
[...surfaceResolved.skills].sort(),
|
||||
[...profileResolved.skills].sort(),
|
||||
'surface with no state should equal profile resolution'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('standard base + disabledClusters:["utility"] removes utility skills', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeActiveProfile(dir, 'standard');
|
||||
writeSurface(dir, {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: ['utility'],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = realManifest();
|
||||
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
assert.ok(resolved.skills instanceof Set);
|
||||
// Utility cluster members should not be in the result
|
||||
for (const stem of CLUSTERS.utility) {
|
||||
// Only check stems that were actually in the standard profile
|
||||
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
|
||||
if (standardResolved.skills.has(stem)) {
|
||||
assert.ok(
|
||||
!resolved.skills.has(stem),
|
||||
`"${stem}" should be removed by disabling utility cluster`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicitAdds:["sketch"] adds sketch to a core install', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
writeSurface(dir, {
|
||||
baseProfile: 'core',
|
||||
disabledClusters: [],
|
||||
explicitAdds: ['sketch'],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = realManifest();
|
||||
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
assert.ok(resolved.skills instanceof Set);
|
||||
assert.ok(resolved.skills.has('sketch'), 'sketch must be in resolved skills');
|
||||
|
||||
// Transitive requires of sketch should also be present
|
||||
const sketchRequires = manifest.get('sketch') || [];
|
||||
for (const dep of sketchRequires) {
|
||||
assert.ok(resolved.skills.has(dep), `transitive dep "${dep}" of sketch must be present`);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('explicitRemoves removes individual skill stems', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeSurface(dir, {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: ['progress'],
|
||||
});
|
||||
writeActiveProfile(dir, 'standard');
|
||||
const manifest = realManifest();
|
||||
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
// standard includes 'progress', so removing it should take it out
|
||||
assert.ok(!resolved.skills.has('progress'), '"progress" must be removed by explicitRemoves');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('result is a Set<string> with name property', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
const manifest = realManifest();
|
||||
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
assert.ok(resolved.skills instanceof Set);
|
||||
assert.ok(typeof resolved.name === 'string');
|
||||
assert.ok(resolved.agents instanceof Set);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('surface with baseProfile overrides .gsd-profile marker', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeActiveProfile(dir, 'core');
|
||||
writeSurface(dir, {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
const manifest = realManifest();
|
||||
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
|
||||
|
||||
// Should use standard profile from surface state, not core from marker
|
||||
assert.deepStrictEqual(
|
||||
[...resolved.skills].sort(),
|
||||
[...standardResolved.skills].sort(),
|
||||
'surface baseProfile takes precedence over marker'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled cluster + explicitAdds can re-add specific skills from disabled cluster', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeSurface(dir, {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: ['workspace_state'],
|
||||
explicitAdds: ['capture'],
|
||||
explicitRemoves: [],
|
||||
});
|
||||
writeActiveProfile(dir, 'standard');
|
||||
const manifest = realManifest();
|
||||
const resolved = resolveSurface(dir, manifest, CLUSTERS);
|
||||
|
||||
// workspace_state is disabled, but capture is explicitly re-added
|
||||
assert.ok(resolved.skills.has('capture'), '"capture" must be present via explicitAdds');
|
||||
// Other workspace_state members that were in standard should be gone
|
||||
const standardResolved = resolveProfile({ modes: ['standard'], manifest });
|
||||
for (const stem of CLUSTERS.workspace_state) {
|
||||
if (stem === 'capture') continue;
|
||||
if (standardResolved.skills.has(stem)) {
|
||||
assert.ok(
|
||||
!resolved.skills.has(stem),
|
||||
`"${stem}" should be removed (workspace_state disabled, not explicitly re-added)`
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
165
tests/surface-state.test.cjs
Normal file
165
tests/surface-state.test.cjs
Normal file
@@ -0,0 +1,165 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Tests for readSurface / writeSurface — state IO round-trips.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const { readSurface, writeSurface } = require('../get-shit-done/bin/lib/surface.cjs');
|
||||
|
||||
function tmpDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-state-'));
|
||||
}
|
||||
|
||||
describe('readSurface / writeSurface', () => {
|
||||
test('round-trips a complete surface state', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
const state = {
|
||||
baseProfile: 'standard',
|
||||
disabledClusters: ['utility'],
|
||||
explicitAdds: ['sketch'],
|
||||
explicitRemoves: [],
|
||||
};
|
||||
writeSurface(dir, state);
|
||||
const read = readSurface(dir);
|
||||
assert.deepStrictEqual(read, state);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trips empty arrays', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
const state = {
|
||||
baseProfile: 'core',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: [],
|
||||
};
|
||||
writeSurface(dir, state);
|
||||
assert.deepStrictEqual(readSurface(dir), state);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('round-trips composed base profile', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
const state = {
|
||||
baseProfile: 'core,audit',
|
||||
disabledClusters: [],
|
||||
explicitAdds: [],
|
||||
explicitRemoves: ['health'],
|
||||
};
|
||||
writeSurface(dir, state);
|
||||
assert.deepStrictEqual(readSurface(dir), state);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('missing file returns null', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
const result = readSurface(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('non-existent directory returns null', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'gsd-surface-no-exist-' + Date.now());
|
||||
const result = readSurface(ghost);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('corrupt JSON returns null', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, '.gsd-surface.json'), '{not valid json', 'utf8');
|
||||
const result = readSurface(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('JSON missing baseProfile field returns null', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.gsd-surface.json'),
|
||||
JSON.stringify({ disabledClusters: [], explicitAdds: [], explicitRemoves: [] }),
|
||||
'utf8'
|
||||
);
|
||||
const result = readSurface(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('JSON with non-array disabledClusters returns null', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.gsd-surface.json'),
|
||||
JSON.stringify({ baseProfile: 'standard', disabledClusters: 'utility', explicitAdds: [], explicitRemoves: [] }),
|
||||
'utf8'
|
||||
);
|
||||
const result = readSurface(dir);
|
||||
assert.strictEqual(result, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('atomic write: result file is never a partial tmp file', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
const state = { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] };
|
||||
writeSurface(dir, state);
|
||||
// No .tmp.* files should remain
|
||||
const files = fs.readdirSync(dir);
|
||||
const tmpFiles = files.filter(f => f.includes('.tmp.'));
|
||||
assert.deepStrictEqual(tmpFiles, [], 'no tmp files should remain after write');
|
||||
// The canonical file exists
|
||||
assert.ok(files.includes('.gsd-surface.json'));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('second write overwrites first', () => {
|
||||
const dir = tmpDir();
|
||||
try {
|
||||
writeSurface(dir, { baseProfile: 'core', disabledClusters: [], explicitAdds: [], explicitRemoves: [] });
|
||||
writeSurface(dir, { baseProfile: 'standard', disabledClusters: ['utility'], explicitAdds: [], explicitRemoves: [] });
|
||||
const read = readSurface(dir);
|
||||
assert.strictEqual(read.baseProfile, 'standard');
|
||||
assert.deepStrictEqual(read.disabledClusters, ['utility']);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('writeSurface creates directory if it does not exist', () => {
|
||||
const base = tmpDir();
|
||||
const nested = path.join(base, 'skills', 'subdir');
|
||||
try {
|
||||
writeSurface(nested, { baseProfile: 'full', disabledClusters: [], explicitAdds: [], explicitRemoves: [] });
|
||||
assert.ok(fs.existsSync(nested));
|
||||
assert.ok(readSurface(nested) !== null);
|
||||
} finally {
|
||||
fs.rmSync(base, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user