@@ -197,11 +197,14 @@ function capturePhaseComplete(cwd, phaseNum) {
|
||||
// directly and redirect output capture.
|
||||
const chunks = [];
|
||||
const origWrite = process.stdout.write.bind(process.stdout);
|
||||
const origErrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stdout.write = (chunk) => { chunks.push(chunk); return true; };
|
||||
process.stderr.write = () => true;
|
||||
try {
|
||||
cmdPhaseComplete(cwd, phaseNum, false);
|
||||
} finally {
|
||||
process.stdout.write = origWrite;
|
||||
process.stderr.write = origErrWrite;
|
||||
}
|
||||
return chunks.join('');
|
||||
}
|
||||
|
||||
@@ -109,8 +109,11 @@ describe('backwards-compat: legacy Phase N roadmap entries', () => {
|
||||
const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md');
|
||||
const before = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
// Trigger a load — must not silently migrate the file.
|
||||
getMilestonePhaseFilter(tmpDir);
|
||||
// Trigger a load — must not silently migrate the file. The deprecation
|
||||
// warning is covered by the previous test, so keep this fixture quiet.
|
||||
captureConsole(() => {
|
||||
getMilestonePhaseFilter(tmpDir);
|
||||
});
|
||||
|
||||
const after = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
assert.equal(after, before, 'ROADMAP.md must not be rewritten during load');
|
||||
|
||||
@@ -225,5 +225,20 @@ describe('bug-131: runNpm isolates HOME from the caller environment', () => {
|
||||
typeof env.npm_config_userconfig === 'string' && env.npm_config_userconfig.startsWith(env.HOME),
|
||||
`npm_config_userconfig ${env.npm_config_userconfig} should be under isolated HOME ${env.HOME}`,
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_loglevel,
|
||||
'error',
|
||||
'isolatedNpmEnv() should suppress npm notice/warn chatter in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.npm_config_update_notifier,
|
||||
'false',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices in test gates',
|
||||
);
|
||||
assert.equal(
|
||||
env.NO_UPDATE_NOTIFIER,
|
||||
'1',
|
||||
'isolatedNpmEnv() should disable npm update-notifier notices for npm versions that honor NO_UPDATE_NOTIFIER',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const { execFileSync } = require('child_process');
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, finishInstall } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
before(() => {
|
||||
@@ -63,13 +63,20 @@ describe('#2248: local Claude install does not clobber profile-level statusLine'
|
||||
// Phase 2: configure settings.local.json (mirrors installAllRuntimes → finalize)
|
||||
// #338: local Claude installs now write to settings.local.json, not settings.json.
|
||||
// shouldInstallStatusline=true mirrors what handleStatusline picks for a fresh install
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
true, // shouldInstallStatusline
|
||||
'claude',
|
||||
false // isGlobal=false → local install
|
||||
const { stdout } = captureConsole(() => {
|
||||
finishInstall(
|
||||
result.settingsPath,
|
||||
result.settings,
|
||||
result.statuslineCommand,
|
||||
true, // shouldInstallStatusline
|
||||
'claude',
|
||||
false // isGlobal=false -> local install
|
||||
);
|
||||
});
|
||||
assert.match(
|
||||
stdout,
|
||||
/Skipping statusLine for local install/,
|
||||
'Local install must explain that it skipped statusLine unless --force-statusline is passed'
|
||||
);
|
||||
|
||||
// #338: local installs write to settings.local.json, not settings.json
|
||||
|
||||
@@ -63,6 +63,8 @@ describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'## Roadmap v1.0: Current',
|
||||
'',
|
||||
'### Phase 01: One',
|
||||
'**Goal:** g',
|
||||
'',
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
* directory already exists with managed-shape content, skip the local copy
|
||||
* and emit a clear warning explaining the conflict avoidance.
|
||||
*
|
||||
* Tests are structural: they assert on the post-install filesystem shape
|
||||
* (existence and overlap count of typed paths), not on warning-message
|
||||
* substrings.
|
||||
* Tests assert on the post-install filesystem shape and capture the skip
|
||||
* warning so the full test log remains warning-clean.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
@@ -28,7 +27,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs');
|
||||
|
||||
const { install } = require('../bin/install.js');
|
||||
|
||||
@@ -78,8 +77,12 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
function runInstall(...args) {
|
||||
return captureConsole(() => install(...args));
|
||||
}
|
||||
|
||||
test('global install populates HOME/.gemini/commands/gsd', () => {
|
||||
install(true, 'gemini');
|
||||
runInstall(true, 'gemini');
|
||||
const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd');
|
||||
const files = listCommandFiles(globalCmds);
|
||||
assert.ok(
|
||||
@@ -90,14 +93,19 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma
|
||||
|
||||
test('local install after global does NOT populate PROJECT/.gemini/commands/gsd (avoids /gsd:* namespace conflict)', () => {
|
||||
// Step 1: global install
|
||||
install(true, 'gemini');
|
||||
runInstall(true, 'gemini');
|
||||
const globalCmds = path.join(tmpHome, '.gemini', 'commands', 'gsd');
|
||||
const globalFiles = listCommandFiles(globalCmds);
|
||||
assert.ok(globalFiles.length > 0, 'precondition: global install must succeed');
|
||||
|
||||
// Step 2: local install in a temp project
|
||||
process.chdir(tmpProject);
|
||||
install(false, 'gemini');
|
||||
const { stdout } = runInstall(false, 'gemini');
|
||||
assert.match(
|
||||
stdout,
|
||||
/Skipping commands\/gsd\/ for local install/,
|
||||
'local install must explain why it skips duplicate Gemini commands'
|
||||
);
|
||||
|
||||
// Assertion: the local commands/gsd/ directory must NOT exist (or must
|
||||
// be empty) so Gemini's conflict detection has nothing to rename. The
|
||||
@@ -117,7 +125,7 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma
|
||||
// No global install first — local should proceed normally so users who
|
||||
// only ever run --local still get GSD commands in their project.
|
||||
process.chdir(tmpProject);
|
||||
install(false, 'gemini');
|
||||
runInstall(false, 'gemini');
|
||||
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
@@ -145,7 +153,7 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma
|
||||
);
|
||||
|
||||
process.chdir(tmpProject);
|
||||
install(false, 'gemini');
|
||||
runInstall(false, 'gemini');
|
||||
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
@@ -171,7 +179,7 @@ describe('bug #3037: Gemini global+local install must not create duplicate comma
|
||||
);
|
||||
|
||||
process.chdir(tmpProject);
|
||||
install(false, 'gemini');
|
||||
runInstall(false, 'gemini');
|
||||
|
||||
const localCmds = path.join(tmpProject, '.gemini', 'commands', 'gsd');
|
||||
const localFiles = listCommandFiles(localCmds);
|
||||
|
||||
@@ -38,6 +38,7 @@ function run(args, cwd) {
|
||||
cwd,
|
||||
timeout: 15000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
}),
|
||||
ok: true,
|
||||
};
|
||||
|
||||
@@ -51,6 +51,7 @@ const {
|
||||
VALID_INSTALL_SURFACES,
|
||||
VALID_EXTENDED_HOOK_EVENTS,
|
||||
VALID_PERMISSION_WRITERS,
|
||||
validateRuntimeCompat,
|
||||
validateRuntimeBody,
|
||||
loadCentralConfigKeys,
|
||||
} = require('../scripts/gen-capability-registry.cjs');
|
||||
@@ -98,6 +99,11 @@ describe('UI pilot capability', () => {
|
||||
// capabilities.ui exists
|
||||
assert.ok(registry.capabilities.ui, 'registry.capabilities.ui should exist');
|
||||
assert.strictEqual(registry.version, SCHEMA_VERSION);
|
||||
assert.deepStrictEqual(
|
||||
registry.capabilities.ui.runtimeCompat,
|
||||
{ supported: ['*'], unsupported: [] },
|
||||
'feature runtime compatibility contract should be preserved in the registry',
|
||||
);
|
||||
|
||||
// bySkill maps ui-phase and ui-review to 'ui'
|
||||
assert.strictEqual(registry.bySkill['ui-phase'], 'ui');
|
||||
@@ -200,6 +206,32 @@ describe('validateCapability adversarial cases', () => {
|
||||
assert.ok(errors.some((e) => e.includes('role')));
|
||||
});
|
||||
|
||||
test('feature capability without runtimeCompat is rejected', () => {
|
||||
const cap = { ...UI_CAP };
|
||||
delete cap.runtimeCompat;
|
||||
const errors = validateCapability(cap, 'ui');
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('runtimeCompat')),
|
||||
'Expected missing runtimeCompat validation error, got: ' + JSON.stringify(errors),
|
||||
);
|
||||
});
|
||||
|
||||
test('runtimeCompat.supported must be a non-empty array', () => {
|
||||
const errors = validateRuntimeCompat('ui', { supported: [], unsupported: [] });
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('runtimeCompat.supported')),
|
||||
'Expected supported-array validation error, got: ' + JSON.stringify(errors),
|
||||
);
|
||||
});
|
||||
|
||||
test('runtimeCompat.supported wildcard cannot be mixed with runtime ids', () => {
|
||||
const errors = validateRuntimeCompat('ui', { supported: ['*', 'claude'], unsupported: [] });
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('wildcard')),
|
||||
'Expected wildcard validation error, got: ' + JSON.stringify(errors),
|
||||
);
|
||||
});
|
||||
|
||||
test('bad tier enum rejected', () => {
|
||||
const cap = { ...UI_CAP, tier: 'premium' };
|
||||
const errors = validateCapability(cap, 'ui');
|
||||
@@ -284,6 +316,40 @@ describe('validateCrossCapability adversarial cases', () => {
|
||||
assert.ok(errors.some((e) => e.includes('nonexistent-cap')));
|
||||
});
|
||||
|
||||
test('runtimeCompat explicit runtime ids must exist', () => {
|
||||
const cap = {
|
||||
...UI_CAP,
|
||||
runtimeCompat: { supported: ['claude', 'future-runtime'], unsupported: [] },
|
||||
};
|
||||
const runtime = {
|
||||
id: 'claude',
|
||||
role: 'runtime',
|
||||
title: 'Claude',
|
||||
description: 'Runtime fixture.',
|
||||
tier: 'core',
|
||||
requires: [],
|
||||
runtime: {
|
||||
configHome: { kind: 'dot-home', name: '.claude', env: ['CLAUDE_CONFIG_DIR'] },
|
||||
configFormat: 'settings-json',
|
||||
artifactLayout: { global: [], local: [] },
|
||||
commandStyle: 'slash-hyphen',
|
||||
hooksSurface: 'settings-json',
|
||||
sandboxTier: 'none',
|
||||
supportTier: 1,
|
||||
installSurface: 'settings-json',
|
||||
writesSharedSettings: true,
|
||||
permissionWriter: null,
|
||||
extendedHookEvents: [],
|
||||
},
|
||||
};
|
||||
const capMap = new Map([['ui', cap], ['claude', runtime]]);
|
||||
const errors = validateCrossCapability(capMap, new Set());
|
||||
assert.ok(
|
||||
errors.some((e) => e.includes('runtimeCompat.supported') && e.includes('future-runtime')),
|
||||
'Expected unknown runtimeCompat runtime error, got: ' + JSON.stringify(errors),
|
||||
);
|
||||
});
|
||||
|
||||
test('requires cycle rejected', () => {
|
||||
const capA = { ...UI_CAP, id: 'cap-a', tier: 'standard', requires: ['cap-b'] };
|
||||
const capB = {
|
||||
@@ -382,6 +448,7 @@ describe('topological step ordering', () => {
|
||||
title: 'Consumer',
|
||||
tier: 'full',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
hooks: [],
|
||||
@@ -1260,6 +1327,7 @@ describe('S1: fragment.path traversal guard', () => {
|
||||
description: 'Synthetic fixture for fragment path materialization.',
|
||||
tier: 'full',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
hooks: [],
|
||||
@@ -1304,6 +1372,7 @@ describe('S1: fragment.path traversal guard', () => {
|
||||
description: 'Synthetic fixture for step fragment materialization.',
|
||||
tier: 'standard',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: ['gsd-phase-researcher'],
|
||||
hooks: [],
|
||||
@@ -2674,6 +2743,7 @@ function makeCommandCap(id, commands) {
|
||||
description: 'Synthetic capability for ADR-959 command tests.',
|
||||
tier: 'full',
|
||||
requires: [],
|
||||
runtimeCompat: { supported: ['*'], unsupported: [] },
|
||||
skills: [],
|
||||
agents: [],
|
||||
hooks: [],
|
||||
|
||||
@@ -1137,7 +1137,7 @@ describe('getMilestonePhaseFilter', () => {
|
||||
test('handles letter-suffix phases (e.g. 3A)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'### Phase 3A: Sub-feature\n**Goal:** Sub work\n'
|
||||
'## Roadmap v1.0: Current\n\n### Phase 3A: Sub-feature\n**Goal:** Sub work\n'
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
@@ -1150,7 +1150,7 @@ describe('getMilestonePhaseFilter', () => {
|
||||
test('handles decimal phases (e.g. 5.1)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'### Phase 5: Main\n**Goal:** Main work\n\n### Phase 5.1: Patch\n**Goal:** Patch work\n'
|
||||
'## Roadmap v1.0: Current\n\n### Phase 5: Main\n**Goal:** Main work\n\n### Phase 5.1: Patch\n**Goal:** Patch work\n'
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
@@ -1163,7 +1163,7 @@ describe('getMilestonePhaseFilter', () => {
|
||||
test('returns false for non-phase directory names', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'### Phase 1: Init\n**Goal:** Start\n'
|
||||
'## Roadmap v1.0: Current\n\n### Phase 1: Init\n**Goal:** Start\n'
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
@@ -1175,7 +1175,7 @@ describe('getMilestonePhaseFilter', () => {
|
||||
test('phaseCount reflects ROADMAP phase count', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n'
|
||||
'## Roadmap v1.0: Current\n\n### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n'
|
||||
);
|
||||
|
||||
const filter = getMilestonePhaseFilter(tmpDir);
|
||||
|
||||
@@ -8,6 +8,9 @@ const path = require('path');
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PKG_PATH = path.join(ROOT, 'package.json');
|
||||
const INSTALL_PATH = path.join(ROOT, 'bin', 'install.js');
|
||||
const ACTIVE_GUIDANCE_PATHS = [
|
||||
'docs/contributing/bootstrap.md',
|
||||
];
|
||||
|
||||
function readPackageJson() {
|
||||
return JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
|
||||
@@ -45,3 +48,23 @@ test('enhancement #191: installer does not maintain gsd-sdk shim compatibility p
|
||||
assert.equal(/installSdkIfNeeded\(\{/.test(installJs), false,
|
||||
'bin/install.js must not run installSdkIfNeeded during installation');
|
||||
});
|
||||
|
||||
test('enhancement #191: root AGENTS.md is not an active source of truth', () => {
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(ROOT, 'AGENTS.md')),
|
||||
false,
|
||||
'root AGENTS.md must not exist; use CONTEXT.md and docs/adr/ as the repository source of truth',
|
||||
);
|
||||
});
|
||||
|
||||
test('enhancement #191: active contributor guidance does not reference retired SDK build steps', () => {
|
||||
for (const relPath of ACTIVE_GUIDANCE_PATHS) {
|
||||
const body = fs.readFileSync(path.join(ROOT, relPath), 'utf8');
|
||||
|
||||
assert.equal(
|
||||
/\bbuild:sdk\b|\bcd sdk\b|\bsdk\/dist\b|\bsdk\/src\b/.test(body),
|
||||
false,
|
||||
`${relPath} must not direct contributors or agents to use the retired SDK package workflow`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -296,6 +296,9 @@ function runNpm(args, options = {}) {
|
||||
HOME: _npmIsolatedHome,
|
||||
npm_config_cache: path.join(_npmIsolatedHome, '.npm'),
|
||||
npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'),
|
||||
npm_config_loglevel: 'error',
|
||||
npm_config_update_notifier: 'false',
|
||||
NO_UPDATE_NOTIFIER: '1',
|
||||
};
|
||||
const defaults = {
|
||||
encoding: 'utf-8',
|
||||
@@ -326,6 +329,9 @@ function isolatedNpmEnv() {
|
||||
HOME: _npmIsolatedHome,
|
||||
npm_config_cache: path.join(_npmIsolatedHome, '.npm'),
|
||||
npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'),
|
||||
npm_config_loglevel: 'error',
|
||||
npm_config_update_notifier: 'false',
|
||||
NO_UPDATE_NOTIFIER: '1',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,35 @@ for (const { runtime, scope, skillsSub, prefix } of NEST) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(`${runtime}: installed nested skill bodies contain no leaked Claude home paths`, () => {
|
||||
const skillsDir = path.join(tmpDir, skillsSub);
|
||||
const leakedPathRegex = /(?:~|\$HOME)\/\.claude\b/g;
|
||||
const leaks = [];
|
||||
|
||||
const scan = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
scan(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.endsWith('.md')) continue;
|
||||
const relPath = path.relative(skillsDir, fullPath);
|
||||
const content = fs.readFileSync(fullPath, 'utf8');
|
||||
const matches = content.match(leakedPathRegex);
|
||||
if (matches) leaks.push(`${relPath} (${matches.length})`);
|
||||
}
|
||||
};
|
||||
|
||||
scan(skillsDir);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
leaks,
|
||||
[],
|
||||
`${runtime} nested skills must not leak Claude home paths: ${leaks.join(', ')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3395,6 +3395,7 @@ function runPhaseComplete(tmpDir, { phase = '1', tolerateExit = false } = {}) {
|
||||
cwd: tmpDir,
|
||||
timeout: 60000,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
// A signal/timeout kill terminated the process before it finished writing —
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
* the STEP-0 golden matches the descriptor exactly and is left unchanged.
|
||||
*
|
||||
* Unknown runtime case:
|
||||
* ALLOWED_RUNTIMES guard throws TypeError BEFORE the descriptor lookup,
|
||||
* so unknown runtimes still throw:
|
||||
* Missing runtime descriptors throw the same TypeError as the old table:
|
||||
* TypeError: Unknown runtime: 'grok' — add to runtime-artifact-layout.cjs table
|
||||
*/
|
||||
|
||||
@@ -33,7 +32,10 @@ const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { resolveRuntimeArtifactLayout } = require(
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
resolveRuntimeArtifactLayoutFromRegistry,
|
||||
} = require(
|
||||
path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-artifact-layout.cjs'),
|
||||
);
|
||||
|
||||
@@ -262,10 +264,10 @@ for (const runtime of RUNTIMES) {
|
||||
}
|
||||
|
||||
// ── Unknown runtime ───────────────────────────────────────────────────────────
|
||||
// ALLOWED_RUNTIMES guard fires BEFORE descriptor lookup — reproduces old behaviour.
|
||||
// Missing descriptors reproduce the old loud-fail behaviour.
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)', () => {
|
||||
test('throws TypeError for grok (not in ALLOWED_RUNTIMES)', () => {
|
||||
test('throws TypeError for grok (no artifact layout descriptor)', () => {
|
||||
assert.throws(
|
||||
() => resolveRuntimeArtifactLayout('grok', FAKE_DIR, 'global'),
|
||||
(err) => {
|
||||
@@ -291,6 +293,48 @@ describe('resolveRuntimeArtifactLayout — unknown runtime (descriptor-driven)',
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRuntimeArtifactLayout — descriptor-only future runtime', () => {
|
||||
test('accepts a synthetic descriptor-backed runtime without a parallel allowlist update', () => {
|
||||
const registry = {
|
||||
runtimes: {
|
||||
futurecli: {
|
||||
runtime: {
|
||||
artifactLayout: {
|
||||
global: [
|
||||
{
|
||||
kind: 'commands',
|
||||
destSubpath: 'commands',
|
||||
prefix: 'gsd-',
|
||||
nesting: 'flat',
|
||||
recursive: false,
|
||||
converter: null,
|
||||
},
|
||||
],
|
||||
local: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const layout = resolveRuntimeArtifactLayoutFromRegistry(
|
||||
registry,
|
||||
'futurecli',
|
||||
FAKE_DIR,
|
||||
'global',
|
||||
);
|
||||
|
||||
assert.strictEqual(layout.runtime, 'futurecli');
|
||||
assert.strictEqual(layout.configDir, FAKE_DIR);
|
||||
assert.strictEqual(layout.scope, 'global');
|
||||
assert.strictEqual(layout.kinds.length, 1);
|
||||
assert.strictEqual(layout.kinds[0].kind, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].destSubpath, 'commands');
|
||||
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
|
||||
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scope default ─────────────────────────────────────────────────────────────
|
||||
// resolveRuntimeArtifactLayout(runtime, configDir) with no scope arg → 'global'.
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const path = require('node:path');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
resolveRuntimeConfigIntent,
|
||||
resolveInstallPlan,
|
||||
resolveInstallPlanFromRuntimes,
|
||||
ALLOWED_CONFIG_RUNTIMES,
|
||||
INSTALL_SURFACES,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs'));
|
||||
@@ -250,3 +252,55 @@ describe('INSTALL_SURFACES export', () => {
|
||||
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveInstallPlan — hooksSurface is descriptor-owned', () => {
|
||||
test('real descriptor-owned none surface is preserved for opencode and kilo', () => {
|
||||
assert.strictEqual(resolveInstallPlan('opencode').hooksSurface, 'none');
|
||||
assert.strictEqual(resolveInstallPlan('kilo').hooksSurface, 'none');
|
||||
});
|
||||
|
||||
test('synthetic descriptor resolves hooksSurface without runtime-name fallback', () => {
|
||||
const runtimes = {
|
||||
futurecli: {
|
||||
runtime: {
|
||||
installSurface: 'settings-json',
|
||||
writesSharedSettings: true,
|
||||
permissionWriter: null,
|
||||
hookEvents: 'claude',
|
||||
extendedHookEvents: ['Stop'],
|
||||
hooksSurface: 'settings-json',
|
||||
sandboxTier: 'none',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepStrictEqual(resolveInstallPlanFromRuntimes(runtimes, 'futurecli'), {
|
||||
runtime: 'futurecli',
|
||||
installSurface: 'settings-json',
|
||||
writesSharedSettings: true,
|
||||
finishPermissionWriter: null,
|
||||
hookEvents: 'claude',
|
||||
extendedHookEvents: ['Stop'],
|
||||
hooksSurface: 'settings-json',
|
||||
sandboxTier: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
test('missing hooksSurface fails loudly instead of falling back from runtime name', () => {
|
||||
const runtimes = {
|
||||
opencode: {
|
||||
runtime: {
|
||||
installSurface: 'settings-json',
|
||||
writesSharedSettings: true,
|
||||
permissionWriter: 'opencode',
|
||||
extendedHookEvents: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.throws(
|
||||
() => resolveInstallPlanFromRuntimes(runtimes, 'opencode'),
|
||||
/runtime\.hooksSurface/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,9 +24,11 @@ const { cleanup } = require('./helpers.cjs');
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
getGlobalConfigDir,
|
||||
getGlobalSkillsBase,
|
||||
resolveAntigravityGlobalDir,
|
||||
resolveKimiGlobalDir,
|
||||
resolveConfigHomeFromDescriptor,
|
||||
resolveSkillsBaseFromDescriptor,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs'));
|
||||
|
||||
const HOME = os.homedir();
|
||||
@@ -632,6 +634,48 @@ describe('descriptor-driven equivalence: unknown runtime fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('descriptor-driven global skills base', () => {
|
||||
test('hermes skills base is derived from descriptor artifact layout', () => {
|
||||
const saved = clearAllEnvKeys();
|
||||
try {
|
||||
assert.strictEqual(getGlobalSkillsBase('hermes'), path.join(HOME, '.hermes', 'skills', 'gsd'));
|
||||
} finally {
|
||||
restoreEnvKeys(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('kilo skills base is derived from configHome.skillsHome descriptor', () => {
|
||||
const saved = clearAllEnvKeys();
|
||||
try {
|
||||
assert.strictEqual(getGlobalSkillsBase('kilo'), path.join(HOME, '.kilo', 'skills'));
|
||||
} finally {
|
||||
restoreEnvKeys(saved);
|
||||
}
|
||||
});
|
||||
|
||||
test('synthetic runtime skillsHome descriptor resolves without a runtime-name branch', () => {
|
||||
const base = resolveSkillsBaseFromDescriptor(
|
||||
{
|
||||
kind: 'xdg',
|
||||
name: 'futurecli',
|
||||
env: ['FUTURE_CONFIG_DIR', 'FUTURE_CONFIG', 'XDG_CONFIG_HOME'],
|
||||
skillsHome: {
|
||||
kind: 'dot-home',
|
||||
name: '.futurecli',
|
||||
env: ['FUTURE_SKILLS_HOME'],
|
||||
},
|
||||
},
|
||||
{
|
||||
env: { FUTURE_SKILLS_HOME: '/custom/future-skills' },
|
||||
home: '/home/u',
|
||||
existsSync: () => false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(base, path.join('/custom/future-skills', 'skills'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── GOLDEN PARITY: getGlobalConfigDir via process.env for all 16 registry runtimes ──
|
||||
|
||||
describe('descriptor-driven parity: 14 non-probe registry runtimes × no-env-vars = golden defaults', () => {
|
||||
|
||||
@@ -732,8 +732,20 @@ describe('stateReplaceFieldWithFallback', () => {
|
||||
|
||||
test('returns content unchanged when neither field matches', () => {
|
||||
const content = '# State\n\n**Phase:** 3\n';
|
||||
const result = stateReplaceFieldWithFallback(content, 'Status', 'state', 'New');
|
||||
let warning = '';
|
||||
const origErrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk) => {
|
||||
warning += String(chunk);
|
||||
return true;
|
||||
};
|
||||
let result;
|
||||
try {
|
||||
result = stateReplaceFieldWithFallback(content, 'Status', 'state', 'New');
|
||||
} finally {
|
||||
process.stderr.write = origErrWrite;
|
||||
}
|
||||
assert.strictEqual(result, content, 'content should be unchanged');
|
||||
assert.match(warning, /STATE\.md field "Status"/, 'missing field warning should be emitted');
|
||||
});
|
||||
|
||||
test('prefers primary over fallback when both exist', () => {
|
||||
|
||||
Reference in New Issue
Block a user