Merge remote-tracking branch 'upstream/next' into kimi-runtime-support
# Conflicts: # bin/install.js
This commit is contained in:
@@ -65,7 +65,8 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
|
||||
const envKeys = ['CLAUDE_CONFIG_DIR','CURSOR_CONFIG_DIR','GEMINI_CONFIG_DIR',
|
||||
'CODEX_HOME','COPILOT_CONFIG_DIR','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR',
|
||||
'AUGMENT_CONFIG_DIR','TRAE_CONFIG_DIR','QWEN_CONFIG_DIR','HERMES_HOME',
|
||||
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','KILO_CONFIG_DIR',
|
||||
'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','OPENCODE_CONFIG',
|
||||
'KILO_CONFIG_DIR','KILO_CONFIG',
|
||||
'XDG_CONFIG_HOME'];
|
||||
const saved = {};
|
||||
for (const k of envKeys) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
@@ -105,15 +106,19 @@ describe('bug #3126: runtime-homes env-var overrides', () => {
|
||||
});
|
||||
test('opencode uses XDG_CONFIG_HOME when OPENCODE_CONFIG_DIR absent', () => {
|
||||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
|
||||
withEnv('OPENCODE_CONFIG', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('opencode'), path.join('/xdg', 'opencode'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
test('kilo uses XDG_CONFIG_HOME when KILO_CONFIG_DIR absent', () => {
|
||||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
|
||||
withEnv('KILO_CONFIG', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', '/xdg', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join('/xdg', 'kilo'));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -186,6 +191,110 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => {
|
||||
// ── explicitDir override ──────────────────────────────────────────────────
|
||||
test('explicitDir absolute path is returned as-is (claude)', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('claude', '/tmp/x'), '/tmp/x');
|
||||
});
|
||||
|
||||
test('explicitDir with tilde is expanded (opencode)', () => {
|
||||
assert.strictEqual(
|
||||
getGlobalConfigDir('opencode', '~/foo'),
|
||||
path.join(os.homedir(), 'foo'),
|
||||
);
|
||||
});
|
||||
|
||||
test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => {
|
||||
withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('opencode', '/explicit/wins'), '/explicit/wins');
|
||||
});
|
||||
});
|
||||
|
||||
// ── opencode: OPENCODE_CONFIG file-path step ──────────────────────────────
|
||||
test('opencode: OPENCODE_CONFIG → path.dirname(expandTilde(value))', () => {
|
||||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||||
withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => {
|
||||
withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => {
|
||||
withEnv('OPENCODE_CONFIG', '/file/loses.json', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('opencode: OPENCODE_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
|
||||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||||
withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => {
|
||||
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('opencode: default ~/.config/opencode when no env vars set', () => {
|
||||
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
|
||||
withEnv('OPENCODE_CONFIG', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalConfigDir('opencode'),
|
||||
path.join(os.homedir(), '.config', 'opencode'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── kilo: KILO_CONFIG file-path step ─────────────────────────────────────
|
||||
test('kilo: KILO_CONFIG → path.dirname(expandTilde(value))', () => {
|
||||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||||
withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
|
||||
withEnv('KILO_CONFIG_DIR', '/dir/wins', () => {
|
||||
withEnv('KILO_CONFIG', '/file/loses.json', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('kilo: KILO_CONFIG takes precedence over XDG_CONFIG_HOME', () => {
|
||||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||||
withEnv('KILO_CONFIG', '/cfg/kilo.json', () => {
|
||||
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('kilo: default ~/.config/kilo when no env vars set', () => {
|
||||
withEnv('KILO_CONFIG_DIR', undefined, () => {
|
||||
withEnv('KILO_CONFIG', undefined, () => {
|
||||
withEnv('XDG_CONFIG_HOME', undefined, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalConfigDir('kilo'),
|
||||
path.join(os.homedir(), '.config', 'kilo'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('bug #3126: init.cjs uses runtime-homes not hardcoded .claude', () => {
|
||||
test('init.cjs has no hardcoded globalSkillsBase assignment to ~/.claude/skills', () => {
|
||||
const fs = require('node:fs');
|
||||
|
||||
133
tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs
Normal file
133
tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs
Normal file
@@ -0,0 +1,133 @@
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Runtime prompt/hook files are deployed verbatim — their text IS what the
|
||||
// runtime loads and executes. Asserting that text carries no retired `gsd-sdk`
|
||||
// reference tests the deployed contract, which no behavioral seam can observe
|
||||
// (there is no runtime API that enumerates "did any shipped prompt name the
|
||||
// removed SDK binary").
|
||||
|
||||
/**
|
||||
* Regression guard: no `gsd-sdk` references in runtime-facing surfaces (#339).
|
||||
*
|
||||
* The `@opengsd/gsd-sdk` package and its `gsd-sdk` binary were retired (ADR 0174,
|
||||
* #191). The bulk runtime cleanup is already done — this test locks it in so a
|
||||
* `gsd-sdk` / `GSD_SDK` reference cannot creep back into a shipped prompt or hook
|
||||
* and re-introduce drift between the documented surface and the supported
|
||||
* `gsd-tools` binary.
|
||||
*
|
||||
* Scope: runtime surfaces only — the prompts and hooks the installer ships into
|
||||
* a user's runtime config dir. Explicitly NOT covered here:
|
||||
* - `bin/install.js` — installer code, not a runtime-deployed prompt/hook
|
||||
* surface. (It carries zero `gsd-sdk` references today; the SDK-shim
|
||||
* verification subsystem was removed in #515 and the shim retired in #522.)
|
||||
* - `gsd-core/bin/` — executable library code, not deployed prompt text; it
|
||||
* may legitimately reference the SDK retirement in comments.
|
||||
* - `tests/`, `docs/`, `.changeset/`, CI/lint scripts — legitimately reference
|
||||
* the SDK retirement as history or detect its stale artifacts.
|
||||
*
|
||||
* Complements `tests/gsd-tools-path-refs.test.cjs`, which only catches the
|
||||
* `gsd-sdk query` binary-invocation form; this catches ANY runtime reference.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Runtime surfaces the installer ships. Each entry is { dir, exts } — dir is
|
||||
// repo-relative, exts is the set of file extensions whose text is deployed.
|
||||
const RUNTIME_SURFACES = [
|
||||
// .md prompts plus the non-.md runtime artifacts this dir also ships:
|
||||
// _runtime-launcher.snippet.sh (the canonical launcher synced into every hook
|
||||
// by scripts/sync-runtime-launcher.cjs) and discuss-phase/templates/*.json
|
||||
// (loaded at runtime by discuss-phase.md). Scanning only .md left these two
|
||||
// deployed files uncovered. (#691 review)
|
||||
{ dir: path.join('gsd-core', 'workflows'), exts: ['.md', '.sh', '.json'] },
|
||||
{ dir: path.join('gsd-core', 'references'), exts: ['.md'] },
|
||||
// Prompt surfaces the installer deep-copies and the runtime loads via
|
||||
// `@~/.claude/gsd-core/templates/*.md` anchors in workflows/commands; the
|
||||
// lone config.json under templates/ ships too. (#691 review)
|
||||
{ dir: path.join('gsd-core', 'templates'), exts: ['.md', '.json'] },
|
||||
{ dir: path.join('gsd-core', 'contexts'), exts: ['.md'] },
|
||||
{ dir: path.join('commands', 'gsd'), exts: ['.md'] },
|
||||
{ dir: 'agents', exts: ['.md'] },
|
||||
// Hooks ship as executable text (.js/.cjs/.sh). `hooks/dist/` is a gitignored
|
||||
// build artifact regenerated from these sources, so scanning the sources is
|
||||
// sufficient and avoids asserting against generated copies.
|
||||
{ dir: 'hooks', exts: ['.js', '.cjs', '.sh'], skipDirs: ['dist'] },
|
||||
];
|
||||
|
||||
// Matches every casing/separator variant of the retired SDK token:
|
||||
// gsd-sdk, gsd_sdk, GSD-SDK, GSD_SDK, etc.
|
||||
const SDK_REF = /gsd[-_]sdk/i;
|
||||
|
||||
/**
|
||||
* Recursively collect files under `absDir` whose extension is in `exts`,
|
||||
* skipping any directory name listed in `skipDirs`.
|
||||
*/
|
||||
function collectFiles(absDir, exts, skipDirs) {
|
||||
if (!fs.existsSync(absDir)) return [];
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (skipDirs.includes(entry.name)) continue;
|
||||
out.push(...collectFiles(path.join(absDir, entry.name), exts, skipDirs));
|
||||
} else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
|
||||
out.push(path.join(absDir, entry.name));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rel(file) {
|
||||
return path.relative(REPO_ROOT, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
describe('#339 no gsd-sdk references in runtime surfaces', () => {
|
||||
test('shipped prompts and hooks carry no retired gsd-sdk reference', () => {
|
||||
const violations = [];
|
||||
|
||||
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
|
||||
const files = collectFiles(path.join(REPO_ROOT, dir), exts, skipDirs);
|
||||
for (const file of files) {
|
||||
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (SDK_REF.test(lines[i])) {
|
||||
violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.strictEqual(
|
||||
violations.length,
|
||||
0,
|
||||
'Runtime surfaces must not reference the retired gsd-sdk binary/package — ' +
|
||||
'use gsd-tools instead.\nViolations:\n' + violations.join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
test('at least one file per configured extension is scanned (guards against an empty sweep)', () => {
|
||||
// A path typo, directory rename, or stale extension could silently make
|
||||
// collectFiles() return [] for part of a surface, turning the guard above
|
||||
// into a no-op that always passes. Checking per-surface isn't enough: for
|
||||
// gsd-core/workflows the .md files alone keep a per-surface count > 0, so
|
||||
// dropping .sh/.json would stop covering _runtime-launcher.snippet.sh and
|
||||
// discuss-phase/templates/*.json while the test stayed green. Assert each
|
||||
// configured extension actually resolves to scanned files. (#691 review)
|
||||
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
|
||||
for (const ext of exts) {
|
||||
const count = collectFiles(path.join(REPO_ROOT, dir), [ext], skipDirs).length;
|
||||
assert.ok(
|
||||
count > 0,
|
||||
`Runtime surface "${dir}" resolved to 0 "${ext}" files — the path may ` +
|
||||
'have moved or the extension is stale; update RUNTIME_SURFACES so the ' +
|
||||
'guard keeps covering it.'
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,9 +4,11 @@ const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const SCRIPT = path.join(ROOT, 'scripts', 'ci-test-scope.cjs');
|
||||
const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
|
||||
|
||||
function scopeFor(files) {
|
||||
const r = spawnSync(process.execPath, [SCRIPT, '--files', files.join(' ')], {
|
||||
@@ -18,25 +20,124 @@ function scopeFor(files) {
|
||||
}
|
||||
|
||||
describe('ci-test-scope.cjs', () => {
|
||||
test('docs-only changes mark code_changed and select docs-parity (new correct contract)', () => {
|
||||
test('docs-only changes: code_changed is false, product_changed false (skip matrix entirely)', () => {
|
||||
const result = scopeFor(['docs/usage.md']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.code_changed, false,
|
||||
`expected code_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for docs-only change, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, false);
|
||||
// docs-parity is NOT in targeted_tests when docs-only (it runs via docs-required.yml instead)
|
||||
assert.ok(
|
||||
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||||
`expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`,
|
||||
!result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||||
`docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow changes request full matrix and workflow contract tests', () => {
|
||||
test('root markdown only: code_changed is false, product_changed false', () => {
|
||||
const result = scopeFor(['README.md']);
|
||||
assert.strictEqual(result.code_changed, false,
|
||||
`expected code_changed=false for root markdown, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for root markdown, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('pipeline workflow (test.yml) — product_changed true, full_matrix true, workflow contract tests', () => {
|
||||
const result = scopeFor(['.github/workflows/test.yml']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for test.yml, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true);
|
||||
assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'));
|
||||
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke-workflow.test.cjs'));
|
||||
assert.ok(result.windows_tests.includes('tests/workflow-shell-pinning.test.cjs'));
|
||||
});
|
||||
|
||||
test('pipeline workflow (install-smoke.yml) — product_changed true, full_matrix true', () => {
|
||||
const result = scopeFor(['.github/workflows/install-smoke.yml']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for install-smoke.yml, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true);
|
||||
});
|
||||
|
||||
test('inert CI only (stale.yml) — code_changed true, product_changed false, full_matrix false', () => {
|
||||
const result = scopeFor(['.github/workflows/stale.yml']);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`expected code_changed=true for inert CI, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for inert CI, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, false,
|
||||
`expected full_matrix=false for inert CI, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'),
|
||||
`expected workflow-shell-pinning in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'),
|
||||
`expected policy-lint-shallow-checkout in targeted_tests for inert CI, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
});
|
||||
|
||||
test('TS runtime sources (src/semver.cts) — code_changed true, product_changed true, full_matrix false, semver tests targeted', () => {
|
||||
const result = scopeFor(['src/semver.cts']);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for src/ change, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, false,
|
||||
`expected full_matrix=false for src/-only change (TS runtime sources rule has no fullMatrix), got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.targeted_tests.includes('tests/semver-compare.test.cjs'),
|
||||
`expected semver-compare in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
});
|
||||
|
||||
test('product code (gsd-core/bin/lib/foo.cjs) — product_changed true', () => {
|
||||
const result = scopeFor(['gsd-core/bin/lib/foo.cjs']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for gsd-core/ change, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('unknown/new workflow defaults to pipeline (fail-safe) — product_changed true', () => {
|
||||
const result = scopeFor(['.github/workflows/brand-new-thing.yml']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true,
|
||||
`expected full_matrix=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('mixed docs + code — escalates to product_changed true', () => {
|
||||
// Use bin/gsd (installer rule, fullMatrix:true) to get a code file that reliably triggers full matrix.
|
||||
const result = scopeFor(['docs/x.md', 'bin/gsd']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for docs+code, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('inert CI (docs-required.yml) — includes shallow-checkout policy test, product_changed false', () => {
|
||||
const result = scopeFor(['.github/workflows/docs-required.yml']);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`expected code_changed=true for docs-required.yml, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for docs-required.yml, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, false,
|
||||
`expected full_matrix=false for docs-required.yml, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'),
|
||||
`expected policy-lint-shallow-checkout in targeted_tests for docs-required.yml, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
});
|
||||
|
||||
test('mixed docs + inert CI — code_changed true, product_changed false (inert lane)', () => {
|
||||
const result = scopeFor(['docs/x.md', '.github/workflows/stale.yml']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for docs+inert, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, false);
|
||||
});
|
||||
|
||||
test('mixed docs + src — product_changed true', () => {
|
||||
const result = scopeFor(['docs/x.md', 'src/semver.cts']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for docs+src, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('command changes request command tests without full parity matrix', () => {
|
||||
const result = scopeFor(['commands/gsd/plan-phase.md']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
@@ -54,6 +155,8 @@ describe('ci-test-scope.cjs', () => {
|
||||
test('installer-sensitive changes request full matrix and install tests', () => {
|
||||
const result = scopeFor(['bin/gsd']);
|
||||
assert.strictEqual(result.code_changed, true);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`expected product_changed=true for bin/gsd, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true);
|
||||
assert.ok(result.targeted_tests.includes('tests/install.test.cjs'));
|
||||
assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs'));
|
||||
@@ -120,25 +223,27 @@ describe('ci-test-scope superset invariant (#494)', () => {
|
||||
`expected full_matrix=true for tests/** change, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
// Facet B: docs/**, commands/**, agents/** → code_changed AND docs-parity selected
|
||||
test('B1: docs/adr change marks code_changed and selects docs-parity-live-registry', () => {
|
||||
// Facet B: commands/**, agents/** → code_changed AND docs-parity selected
|
||||
// docs/ is NO LONGER in this facet — docs-only PRs skip the matrix entirely.
|
||||
test('B1: docs/adr change: code_changed is false (docs skip matrix)', () => {
|
||||
const result = scopeFor(['docs/adr/22-plan-drift-guard.md']);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`expected code_changed=true for docs/** change, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.code_changed, false,
|
||||
`expected code_changed=false for docs/** change (matrix skip), got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for docs/** change, got: ${JSON.stringify(result)}`);
|
||||
// docs-parity is NOT in targeted_tests (handled by docs-required.yml)
|
||||
assert.ok(
|
||||
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||||
`expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`,
|
||||
!result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||||
`docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('B2: docs locale dir change marks code_changed and selects docs-parity-live-registry', () => {
|
||||
test('B2: docs locale dir change: code_changed is false (docs skip matrix)', () => {
|
||||
const result = scopeFor(['docs/ja-JP/USAGE.md']);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`expected code_changed=true for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(
|
||||
result.targeted_tests.some(t => t.includes('docs-parity-live-registry')),
|
||||
`expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`,
|
||||
);
|
||||
assert.strictEqual(result.code_changed, false,
|
||||
`expected code_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`);
|
||||
});
|
||||
|
||||
test('B3: commands/** change selects docs-parity-live-registry', () => {
|
||||
@@ -149,3 +254,138 @@ describe('ci-test-scope superset invariant (#494)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('INERT_WORKFLOWS allowlist integrity guard', () => {
|
||||
// Load the INERT_WORKFLOWS set from the script by spawning it and using --files
|
||||
// on a sentinel path, then separately verify the set contents via the filesystem.
|
||||
|
||||
// Known pipeline workflows that MUST NOT appear in INERT_WORKFLOWS.
|
||||
// Must stay in sync with PROTECTED_WORKFLOWS in scripts/ci-test-scope.cjs.
|
||||
const KNOWN_PIPELINE = [
|
||||
'test.yml',
|
||||
'install-smoke.yml',
|
||||
'mutation.yml',
|
||||
'security-scan.yml',
|
||||
'release.yml',
|
||||
];
|
||||
|
||||
// Canonical inert workflow list — reused by both tests below.
|
||||
const knownInert = [
|
||||
'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-label-issues.yml',
|
||||
'auto-branch.yml', 'auto-backmerge.yml', 'close-draft-prs.yml',
|
||||
'dismiss-unauthorized-pr-approvals.yml', 'pr-gate.yml', 'pr-target-validator.yml',
|
||||
'pr-template-format.yml', 'require-issue-link.yml', 'changeset-required.yml',
|
||||
'docs-required.yml', 'discord-changelog.yml',
|
||||
];
|
||||
|
||||
test('all entries in INERT_WORKFLOWS exist under .github/workflows/', () => {
|
||||
// We derive the inert set implicitly: any .github/workflows/*.yml that produces
|
||||
// full_matrix=false when passed alone is inert. We check the known inert names
|
||||
// against the filesystem instead.
|
||||
// The canonical list is in the script — we verify each named file exists.
|
||||
for (const name of knownInert) {
|
||||
const fullPath = path.join(WORKFLOWS_DIR, name);
|
||||
assert.ok(
|
||||
fs.existsSync(fullPath),
|
||||
`INERT_WORKFLOWS entry '${name}' does not exist at ${fullPath}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('known pipeline workflows are NOT treated as inert (product_changed true, full_matrix true)', () => {
|
||||
for (const name of KNOWN_PIPELINE) {
|
||||
const result = scopeFor([`.github/workflows/${name}`]);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`${name} must be pipeline (product_changed=true), got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true,
|
||||
`${name} must be pipeline (full_matrix=true), got: ${JSON.stringify(result)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Explicit per-workflow guard: each of the five protected workflows must route to
|
||||
// the full matrix. This documents intent and proves that PROTECTED_WORKFLOWS
|
||||
// enforcement is covered end-to-end via the spawn helper.
|
||||
test('all five PROTECTED_WORKFLOWS individually route to full matrix (tamper-evidence)', () => {
|
||||
const protected_ = [
|
||||
'test.yml',
|
||||
'install-smoke.yml',
|
||||
'mutation.yml',
|
||||
'security-scan.yml',
|
||||
'release.yml',
|
||||
];
|
||||
for (const name of protected_) {
|
||||
const result = scopeFor([`.github/workflows/${name}`]);
|
||||
assert.strictEqual(result.product_changed, true,
|
||||
`PROTECTED_WORKFLOW ${name}: expected product_changed=true, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, true,
|
||||
`PROTECTED_WORKFLOW ${name}: expected full_matrix=true, got: ${JSON.stringify(result)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every inert workflow produces code_changed=true, product_changed=false, and full_matrix=false', () => {
|
||||
for (const name of knownInert) {
|
||||
const result = scopeFor([`.github/workflows/${name}`]);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`${name}: expected code_changed=true`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`${name}: expected product_changed=false`);
|
||||
assert.strictEqual(result.full_matrix, false,
|
||||
`${name}: expected full_matrix=false`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('code_changed=false implies clean output invariant', () => {
|
||||
// Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests
|
||||
// must ALL be empty/false — even if a docs path coincidentally
|
||||
// matches a content rule via coarse substring (e.g. path.includes('install') or
|
||||
// path.includes('config')).
|
||||
|
||||
test('docs-only: code_changed=false → product_changed=false, full_matrix=false, empty targeted_tests', () => {
|
||||
const result = scopeFor(['docs/usage.md']);
|
||||
assert.strictEqual(result.code_changed, false);
|
||||
assert.strictEqual(result.product_changed, false);
|
||||
assert.strictEqual(result.full_matrix, false);
|
||||
assert.deepStrictEqual(result.targeted_tests, []);
|
||||
});
|
||||
|
||||
// docs/installer-migrations.md contains 'install' → would match the installer rule
|
||||
// via path.includes('install'). Normalization must suppress the contradictory output.
|
||||
test('docs/installer-migrations.md: code_changed=false AND product_changed=false AND full_matrix=false AND empty targeted_tests', () => {
|
||||
const result = scopeFor(['docs/installer-migrations.md']);
|
||||
assert.strictEqual(result.code_changed, false,
|
||||
`expected code_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false,
|
||||
`expected product_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.full_matrix, false,
|
||||
`expected full_matrix=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`);
|
||||
assert.deepStrictEqual(result.targeted_tests, [],
|
||||
`expected empty targeted_tests for docs/installer-migrations.md, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
});
|
||||
|
||||
// docs/how-to/configure-model-profiles.md contains 'config' → matches configuration rule.
|
||||
test('docs path matching config rule: code_changed=false → empty output (coarse-substring docs suppressed)', () => {
|
||||
const result = scopeFor(['docs/how-to/configure-model-profiles.md']);
|
||||
assert.strictEqual(result.code_changed, false,
|
||||
`expected code_changed=false, got: ${JSON.stringify(result)}`);
|
||||
assert.strictEqual(result.product_changed, false);
|
||||
assert.strictEqual(result.full_matrix, false);
|
||||
assert.deepStrictEqual(result.targeted_tests, []);
|
||||
});
|
||||
|
||||
// code_changed=true must produce >= 1 targeted_test or 'unit' fallback.
|
||||
test('code_changed=true implies non-empty targeted_tests', () => {
|
||||
for (const files of [
|
||||
['src/semver.cts'],
|
||||
['bin/gsd'],
|
||||
['.github/workflows/test.yml'],
|
||||
['.github/workflows/stale.yml'],
|
||||
]) {
|
||||
const result = scopeFor(files);
|
||||
assert.strictEqual(result.code_changed, true,
|
||||
`expected code_changed=true for ${files}, got: ${JSON.stringify(result)}`);
|
||||
assert.ok(result.targeted_tests.length >= 1,
|
||||
`expected >= 1 targeted_test for ${files}, got: ${JSON.stringify(result.targeted_tests)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,20 +30,21 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
getGlobalDir,
|
||||
getConfigDirFromHome,
|
||||
convertClaudeToCliineMarkdown,
|
||||
install,
|
||||
finishInstall,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
describe('Cline runtime directory mapping', () => {
|
||||
test('getDirName returns .cline for local installs', () => {
|
||||
assert.strictEqual(getDirName('cline'), '.cline');
|
||||
});
|
||||
|
||||
test('getGlobalDir returns ~/.cline for global installs', () => {
|
||||
assert.strictEqual(getGlobalDir('cline'), path.join(os.homedir(), '.cline'));
|
||||
test('getGlobalConfigDir returns ~/.cline for global installs', () => {
|
||||
assert.strictEqual(getGlobalConfigDir('cline'), path.join(os.homedir(), '.cline'));
|
||||
});
|
||||
|
||||
test('getConfigDirFromHome returns .cline fragment', () => {
|
||||
@@ -52,7 +53,7 @@ describe('Cline runtime directory mapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalDir (Cline)', () => {
|
||||
describe('getGlobalConfigDir (Cline)', () => {
|
||||
let originalClineConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -69,30 +70,30 @@ describe('getGlobalDir (Cline)', () => {
|
||||
|
||||
test('returns ~/.cline with no env var or explicit dir', () => {
|
||||
delete process.env.CLINE_CONFIG_DIR;
|
||||
const result = getGlobalDir('cline');
|
||||
const result = getGlobalConfigDir('cline');
|
||||
assert.strictEqual(result, path.join(os.homedir(), '.cline'));
|
||||
});
|
||||
|
||||
test('returns explicit dir when provided', () => {
|
||||
const result = getGlobalDir('cline', '/custom/cline-path');
|
||||
const result = getGlobalConfigDir('cline', '/custom/cline-path');
|
||||
assert.strictEqual(result, '/custom/cline-path');
|
||||
});
|
||||
|
||||
test('respects CLINE_CONFIG_DIR env var', () => {
|
||||
process.env.CLINE_CONFIG_DIR = '~/custom-cline';
|
||||
const result = getGlobalDir('cline');
|
||||
const result = getGlobalConfigDir('cline');
|
||||
assert.strictEqual(result, path.join(os.homedir(), 'custom-cline'));
|
||||
});
|
||||
|
||||
test('explicit dir takes priority over CLINE_CONFIG_DIR', () => {
|
||||
process.env.CLINE_CONFIG_DIR = '~/from-env';
|
||||
const result = getGlobalDir('cline', '/explicit/path');
|
||||
const result = getGlobalConfigDir('cline', '/explicit/path');
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('does not break other runtimes', () => {
|
||||
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
getGlobalDir,
|
||||
getConfigDirFromHome,
|
||||
convertClaudeToCodebuddyMarkdown,
|
||||
convertClaudeCommandToCodebuddySkill,
|
||||
@@ -25,6 +24,8 @@ const {
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
|
||||
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
||||
@@ -37,7 +38,7 @@ describe('CodeBuddy runtime directory mapping', () => {
|
||||
});
|
||||
|
||||
test('maps CodeBuddy to ~/.codebuddy for global installs', () => {
|
||||
assert.strictEqual(getGlobalDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
|
||||
assert.strictEqual(getGlobalConfigDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
|
||||
});
|
||||
|
||||
test('returns .codebuddy config fragments for local and global installs', () => {
|
||||
@@ -46,7 +47,7 @@ describe('CodeBuddy runtime directory mapping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalDir (CodeBuddy)', () => {
|
||||
describe('getGlobalConfigDir (CodeBuddy)', () => {
|
||||
let originalCodebuddyConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -63,30 +64,30 @@ describe('getGlobalDir (CodeBuddy)', () => {
|
||||
|
||||
test('returns ~/.codebuddy with no env var or explicit dir', () => {
|
||||
delete process.env.CODEBUDDY_CONFIG_DIR;
|
||||
const result = getGlobalDir('codebuddy');
|
||||
const result = getGlobalConfigDir('codebuddy');
|
||||
assert.strictEqual(result, path.join(os.homedir(), '.codebuddy'));
|
||||
});
|
||||
|
||||
test('returns explicit dir when provided', () => {
|
||||
const result = getGlobalDir('codebuddy', '/custom/codebuddy-path');
|
||||
const result = getGlobalConfigDir('codebuddy', '/custom/codebuddy-path');
|
||||
assert.strictEqual(result, '/custom/codebuddy-path');
|
||||
});
|
||||
|
||||
test('respects CODEBUDDY_CONFIG_DIR env var', () => {
|
||||
process.env.CODEBUDDY_CONFIG_DIR = '~/custom-codebuddy';
|
||||
const result = getGlobalDir('codebuddy');
|
||||
const result = getGlobalConfigDir('codebuddy');
|
||||
assert.strictEqual(result, path.join(os.homedir(), 'custom-codebuddy'));
|
||||
});
|
||||
|
||||
test('explicit dir takes priority over CODEBUDDY_CONFIG_DIR', () => {
|
||||
process.env.CODEBUDDY_CONFIG_DIR = '~/from-env';
|
||||
const result = getGlobalDir('codebuddy', '/explicit/path');
|
||||
const result = getGlobalConfigDir('codebuddy', '/explicit/path');
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('does not break other runtimes', () => {
|
||||
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
getGlobalDir,
|
||||
getConfigDirFromHome,
|
||||
claudeToCopilotTools,
|
||||
convertCopilotToolName,
|
||||
@@ -48,6 +47,8 @@ const {
|
||||
buildRuntimePromptText,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
|
||||
const _gsdLibDir = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
||||
@@ -70,9 +71,9 @@ describe('getDirName (Copilot)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getGlobalDir ───────────────────────────────────────────────────────────────
|
||||
// ─── getGlobalConfigDir ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('getGlobalDir (Copilot)', () => {
|
||||
describe('getGlobalConfigDir (Copilot)', () => {
|
||||
let originalCopilotConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -89,30 +90,30 @@ describe('getGlobalDir (Copilot)', () => {
|
||||
|
||||
test('returns ~/.copilot with no env var or explicit dir', () => {
|
||||
delete process.env.COPILOT_CONFIG_DIR;
|
||||
const result = getGlobalDir('copilot');
|
||||
const result = getGlobalConfigDir('copilot');
|
||||
assert.strictEqual(result, path.join(os.homedir(), '.copilot'));
|
||||
});
|
||||
|
||||
test('returns explicit dir when provided', () => {
|
||||
const result = getGlobalDir('copilot', '/custom/path');
|
||||
const result = getGlobalConfigDir('copilot', '/custom/path');
|
||||
assert.strictEqual(result, '/custom/path');
|
||||
});
|
||||
|
||||
test('respects COPILOT_CONFIG_DIR env var', () => {
|
||||
process.env.COPILOT_CONFIG_DIR = '~/custom-copilot';
|
||||
const result = getGlobalDir('copilot');
|
||||
const result = getGlobalConfigDir('copilot');
|
||||
assert.strictEqual(result, path.join(os.homedir(), 'custom-copilot'));
|
||||
});
|
||||
|
||||
test('explicit dir takes priority over COPILOT_CONFIG_DIR', () => {
|
||||
process.env.COPILOT_CONFIG_DIR = '~/env-path';
|
||||
const result = getGlobalDir('copilot', '/explicit/path');
|
||||
const result = getGlobalConfigDir('copilot', '/explicit/path');
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('does not break existing runtimes', () => {
|
||||
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
/**
|
||||
* Installer Module — Sections 1–5.
|
||||
*
|
||||
* Covers: getDirName/getGlobalDir/getConfigDirFromHome, per-runtime
|
||||
* Covers: getDirName/getGlobalConfigDir/getConfigDirFromHome, per-runtime
|
||||
* install/uninstall spot-checks (hermes/qwen/trae), uninstall skills
|
||||
* cleanup, Claude-reference leak tests, and Kilo-specific helpers.
|
||||
*
|
||||
@@ -34,7 +34,6 @@ const pkg = require('../package.json');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
getGlobalDir,
|
||||
getConfigDirFromHome,
|
||||
install,
|
||||
uninstall,
|
||||
@@ -46,13 +45,15 @@ const {
|
||||
configureKiloPermissions,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
const {
|
||||
RUNTIME_META,
|
||||
stripAnsi,
|
||||
walk,
|
||||
} = require('./helpers/install-shared.cjs');
|
||||
|
||||
// ─── Section 1: getDirName / getGlobalDir / getConfigDirFromHome ──────────────
|
||||
// ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ──────────
|
||||
|
||||
describe('getDirName — all runtimes', () => {
|
||||
for (const runtime of allRuntimes) {
|
||||
@@ -64,11 +65,14 @@ describe('getDirName — all runtimes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe('getGlobalDir — all runtimes default paths', () => {
|
||||
describe('getGlobalConfigDir — all runtimes default paths', () => {
|
||||
// Test the default (no env var, no explicit dir) for each runtime
|
||||
const ENV_KEYS = [
|
||||
'HERMES_HOME', 'QWEN_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR',
|
||||
'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME',
|
||||
'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME',
|
||||
'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR',
|
||||
'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR',
|
||||
'CLINE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR',
|
||||
'KILO_CONFIG', 'ANTIGRAVITY_CONFIG_DIR', 'XDG_CONFIG_HOME',
|
||||
];
|
||||
let savedEnv = {};
|
||||
|
||||
@@ -86,15 +90,15 @@ describe('getGlobalDir — all runtimes default paths', () => {
|
||||
}
|
||||
});
|
||||
|
||||
for (const runtime of allRuntimes) {
|
||||
test(`getGlobalDir('${runtime}') returns expected home-relative path`, () => {
|
||||
for (const runtime of allRuntimes.filter(runtime => runtime !== 'kimi')) {
|
||||
test(`getGlobalConfigDir('${runtime}') returns expected home-relative path`, () => {
|
||||
const expected = path.join(os.homedir(), RUNTIME_META[runtime].globalSuffix);
|
||||
assert.strictEqual(getGlobalDir(runtime), expected);
|
||||
assert.strictEqual(getGlobalConfigDir(runtime), expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
|
||||
describe('getGlobalConfigDir/getConfigDirFromHome — antigravity 2.x layout detection', () => {
|
||||
const saved = {};
|
||||
beforeEach(() => {
|
||||
saved.HOME = process.env.HOME;
|
||||
@@ -118,7 +122,7 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
assert.strictEqual(
|
||||
getGlobalDir('antigravity'),
|
||||
getGlobalConfigDir('antigravity'),
|
||||
path.join(home, '.gemini', 'antigravity-ide'),
|
||||
);
|
||||
assert.strictEqual(
|
||||
@@ -137,7 +141,7 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
assert.strictEqual(
|
||||
getGlobalDir('antigravity'),
|
||||
getGlobalConfigDir('antigravity'),
|
||||
path.join(home, '.gemini', 'antigravity-cli'),
|
||||
);
|
||||
assert.strictEqual(
|
||||
@@ -150,12 +154,12 @@ describe('getGlobalDir/getConfigDirFromHome — antigravity 2.x layout detection
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalDir — explicit configDir overrides env for all runtimes', () => {
|
||||
describe('getGlobalConfigDir — explicit configDir overrides env for all runtimes', () => {
|
||||
test('explicit dir overrides any env var for hermes', () => {
|
||||
const savedHome = process.env.HERMES_HOME;
|
||||
process.env.HERMES_HOME = '~/from-env';
|
||||
try {
|
||||
assert.strictEqual(getGlobalDir('hermes', '/explicit/hermes'), '/explicit/hermes');
|
||||
assert.strictEqual(getGlobalConfigDir('hermes', '/explicit/hermes'), '/explicit/hermes');
|
||||
} finally {
|
||||
if (savedHome !== undefined) process.env.HERMES_HOME = savedHome;
|
||||
else delete process.env.HERMES_HOME;
|
||||
@@ -166,7 +170,7 @@ describe('getGlobalDir — explicit configDir overrides env for all runtimes', (
|
||||
const saved = process.env.KILO_CONFIG_DIR;
|
||||
process.env.KILO_CONFIG_DIR = '~/from-env';
|
||||
try {
|
||||
assert.strictEqual(getGlobalDir('kilo', '/explicit/kilo'), '/explicit/kilo');
|
||||
assert.strictEqual(getGlobalConfigDir('kilo', '/explicit/kilo'), '/explicit/kilo');
|
||||
} finally {
|
||||
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
|
||||
else delete process.env.KILO_CONFIG_DIR;
|
||||
@@ -174,7 +178,7 @@ describe('getGlobalDir — explicit configDir overrides env for all runtimes', (
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalDir — HERMES_HOME env var', () => {
|
||||
describe('getGlobalConfigDir — HERMES_HOME env var', () => {
|
||||
let saved;
|
||||
beforeEach(() => { saved = process.env.HERMES_HOME; });
|
||||
afterEach(() => {
|
||||
@@ -184,11 +188,11 @@ describe('getGlobalDir — HERMES_HOME env var', () => {
|
||||
|
||||
test('respects HERMES_HOME env var (tilde-expanded)', () => {
|
||||
process.env.HERMES_HOME = '~/custom-hermes';
|
||||
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
|
||||
assert.strictEqual(getGlobalConfigDir('hermes'), path.join(os.homedir(), 'custom-hermes'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalDir — Kilo env var priority', () => {
|
||||
describe('getGlobalConfigDir — Kilo env var priority', () => {
|
||||
let savedEnv;
|
||||
beforeEach(() => {
|
||||
savedEnv = {
|
||||
@@ -209,23 +213,23 @@ describe('getGlobalDir — Kilo env var priority', () => {
|
||||
|
||||
test('respects KILO_CONFIG_DIR', () => {
|
||||
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
|
||||
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
|
||||
});
|
||||
|
||||
test('falls back to XDG_CONFIG_HOME/kilo', () => {
|
||||
process.env.XDG_CONFIG_HOME = '~/xdg-config';
|
||||
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'xdg-config', 'kilo'));
|
||||
});
|
||||
|
||||
test('uses dirname(KILO_CONFIG) when KILO_CONFIG_DIR unset', () => {
|
||||
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
|
||||
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'profiles', 'work'));
|
||||
});
|
||||
|
||||
test('KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
|
||||
process.env.KILO_CONFIG_DIR = '~/custom-kilo';
|
||||
process.env.KILO_CONFIG = '~/profiles/work/kilo.jsonc';
|
||||
assert.strictEqual(getGlobalDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
|
||||
assert.strictEqual(getGlobalConfigDir('kilo'), path.join(os.homedir(), 'custom-kilo'));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
373
tests/issue-766-plugin-manifest.test.cjs
Normal file
373
tests/issue-766-plugin-manifest.test.cjs
Normal file
@@ -0,0 +1,373 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression tests for issue #766: additive Claude Code plugin manifest.
|
||||
*
|
||||
* Asserts structural and semantic correctness of:
|
||||
* .claude-plugin/plugin.json — plugin manifest
|
||||
* hooks/hooks.json — plugin hook wiring
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs'));
|
||||
const pkg = require(path.join(ROOT, 'package.json'));
|
||||
const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs'));
|
||||
|
||||
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
|
||||
const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json');
|
||||
|
||||
// ─── Section A: plugin.json ───────────────────────────────────────────────────
|
||||
describe('A: .claude-plugin/plugin.json', () => {
|
||||
|
||||
let manifest;
|
||||
|
||||
test('exists and is valid JSON', () => {
|
||||
assert.ok(fs.existsSync(PLUGIN_JSON_PATH), '.claude-plugin/plugin.json must exist');
|
||||
const raw = fs.readFileSync(PLUGIN_JSON_PATH, 'utf-8');
|
||||
manifest = JSON.parse(raw); // throws on invalid JSON
|
||||
assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object');
|
||||
});
|
||||
|
||||
test('name equals identity.binName ("gsd-core")', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.name, identity.binName, `name should be "${identity.binName}"`);
|
||||
});
|
||||
|
||||
test('name is kebab-case, no colons, spaces, or uppercase', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.match(
|
||||
manifest.name,
|
||||
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
'name must be kebab-case (no colon, space, or uppercase) to be namespace-safe'
|
||||
);
|
||||
});
|
||||
|
||||
test('version matches package.json version', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.version, pkg.version, `.claude-plugin/plugin.json version (${manifest.version}) must match package.json version (${pkg.version}). When bumping the package version, update .claude-plugin/plugin.json \`version\` to match — Claude Code plugin --strict validation requires a version field and the plugin manifest must track the package version. (#766)`);
|
||||
});
|
||||
|
||||
test('repository equals identity.repoUrl', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.repository, identity.repoUrl, 'repository must equal identity.repoUrl');
|
||||
});
|
||||
|
||||
test('homepage equals identity.repoUrl', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.homepage, identity.repoUrl, 'homepage must equal identity.repoUrl');
|
||||
});
|
||||
|
||||
test('license matches package.json license', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.license, pkg.license, 'license must match package.json');
|
||||
});
|
||||
|
||||
test('author.name is a non-empty string', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.ok(
|
||||
manifest.author && typeof manifest.author.name === 'string' && manifest.author.name.trim().length > 0,
|
||||
'author.name must be a non-empty string'
|
||||
);
|
||||
});
|
||||
|
||||
test('commands field is "./commands/gsd/" and that dir exists with at least one .md file', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.commands, './commands/gsd/', 'commands must be "./commands/gsd/"');
|
||||
const resolvedDir = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.commands);
|
||||
assert.ok(fs.existsSync(resolvedDir), `resolved commands dir must exist: ${resolvedDir}`);
|
||||
const mdFiles = fs.readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(mdFiles.length > 0, `commands dir must contain at least one .md file`);
|
||||
});
|
||||
|
||||
test('hooks field is "./hooks/hooks.json" and that file exists', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.equal(manifest.hooks, './hooks/hooks.json', 'hooks must be "./hooks/hooks.json"');
|
||||
const resolvedHooks = path.resolve(path.dirname(PLUGIN_JSON_PATH), '..', manifest.hooks);
|
||||
assert.ok(fs.existsSync(resolvedHooks), `resolved hooks file must exist: ${resolvedHooks}`);
|
||||
});
|
||||
|
||||
test('no "$schema" key (intentionally omitted)', (t) => {
|
||||
if (!manifest) { t.skip('manifest could not be parsed'); return; }
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(manifest, '$schema'), 'plugin.json must NOT contain a $schema key');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section B: hooks/hooks.json ─────────────────────────────────────────────
|
||||
describe('B: hooks/hooks.json', () => {
|
||||
|
||||
let hooksConfig;
|
||||
|
||||
test('exists and is valid JSON with top-level "hooks" object', () => {
|
||||
assert.ok(fs.existsSync(HOOKS_JSON_PATH), 'hooks/hooks.json must exist');
|
||||
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
||||
hooksConfig = JSON.parse(raw);
|
||||
assert.ok(
|
||||
typeof hooksConfig === 'object' && hooksConfig !== null &&
|
||||
typeof hooksConfig.hooks === 'object' && hooksConfig.hooks !== null,
|
||||
'hooks.json must have a top-level "hooks" object'
|
||||
);
|
||||
});
|
||||
|
||||
test('every event name is one of: SessionStart, PreToolUse, PostToolUse', (t) => {
|
||||
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
||||
const validEvents = new Set(['SessionStart', 'PreToolUse', 'PostToolUse']);
|
||||
for (const eventName of Object.keys(hooksConfig.hooks)) {
|
||||
assert.ok(validEvents.has(eventName), `Unknown hook event: "${eventName}"`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every hook entry has type "command" and command contains ${CLAUDE_PLUGIN_ROOT}', (t) => {
|
||||
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
||||
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
|
||||
assert.ok(Array.isArray(eventEntries), `Event "${eventName}" must be an array`);
|
||||
for (const entry of eventEntries) {
|
||||
assert.ok(Array.isArray(entry.hooks), `Entry in "${eventName}" must have a hooks array`);
|
||||
for (const hook of entry.hooks) {
|
||||
assert.equal(hook.type, 'command', `All hook entries must have type "command" (got "${hook.type}")`);
|
||||
assert.ok(
|
||||
typeof hook.command === 'string' && hook.command.includes('${CLAUDE_PLUGIN_ROOT}'),
|
||||
`Hook command must contain "\${CLAUDE_PLUGIN_ROOT}": ${hook.command}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('every referenced script file exists on disk and its basename is in MANAGED_HOOKS', (t) => {
|
||||
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
||||
// Extract script path: substring after ${CLAUDE_PLUGIN_ROOT}/ up to next "
|
||||
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/([^"]+)/g;
|
||||
const allScripts = [];
|
||||
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
||||
for (const entry of eventEntries) {
|
||||
for (const hook of entry.hooks) {
|
||||
const matches = [...hook.command.matchAll(scriptPathRe)];
|
||||
for (const m of matches) {
|
||||
allScripts.push(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(allScripts.length > 0, 'Should have found at least one script path in hooks.json');
|
||||
for (const scriptPath of allScripts) {
|
||||
const fullPath = path.join(ROOT, scriptPath);
|
||||
assert.ok(fs.existsSync(fullPath), `Script referenced in hooks.json does not exist on disk: ${fullPath}`);
|
||||
const basename = path.basename(scriptPath);
|
||||
assert.ok(
|
||||
MANAGED_HOOKS.includes(basename),
|
||||
`Script basename "${basename}" is not listed in hooks/managed-hooks-registry.cjs MANAGED_HOOKS`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('all six always-on hooks are wired', (t) => {
|
||||
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
||||
const REQUIRED_HOOKS = [
|
||||
'gsd-check-update.js',
|
||||
'gsd-prompt-guard.js',
|
||||
'gsd-read-guard.js',
|
||||
'gsd-worktree-path-guard.js',
|
||||
'gsd-context-monitor.js',
|
||||
'gsd-read-injection-scanner.js',
|
||||
];
|
||||
// Collect all basenames wired in hooks.json
|
||||
const wiredBasenames = new Set();
|
||||
const scriptPathRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^"]+)/g;
|
||||
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
||||
for (const entry of eventEntries) {
|
||||
for (const hook of entry.hooks) {
|
||||
const matches = [...hook.command.matchAll(scriptPathRe)];
|
||||
for (const m of matches) {
|
||||
wiredBasenames.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const required of REQUIRED_HOOKS) {
|
||||
assert.ok(wiredBasenames.has(required), `Required hook "${required}" is not wired in hooks/hooks.json`);
|
||||
}
|
||||
});
|
||||
|
||||
test('gsd-context-monitor.js entry has timeout === 10', (t) => {
|
||||
if (!hooksConfig) { t.skip('hooks.json could not be parsed'); return; }
|
||||
let found = false;
|
||||
for (const eventEntries of Object.values(hooksConfig.hooks)) {
|
||||
for (const entry of eventEntries) {
|
||||
for (const hook of entry.hooks) {
|
||||
if (hook.command && hook.command.includes('gsd-context-monitor.js')) {
|
||||
found = true;
|
||||
assert.equal(hook.timeout, 10, 'gsd-context-monitor.js must have timeout === 10');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(found, 'gsd-context-monitor.js entry was not found in hooks.json');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section C: Optional CLI integration test ─────────────────────────────────
|
||||
describe('C: claude plugin validate (CLI integration)', () => {
|
||||
|
||||
const claudeAvailable = (() => {
|
||||
try {
|
||||
const result = spawnSync('claude', ['--version'], { encoding: 'utf-8', timeout: 5000 });
|
||||
return result.status === 0;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
test(
|
||||
'claude plugin validate . --strict exits 0 (skip if claude not on PATH)',
|
||||
{ skip: !claudeAvailable ? 'claude binary not available on PATH' : false },
|
||||
() => {
|
||||
const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
assert.equal(
|
||||
result.status,
|
||||
0,
|
||||
`claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Section D: Always-on hook contract (drift guard) ────────────────────────
|
||||
describe('D: always-on hook contract drift guard', () => {
|
||||
|
||||
/**
|
||||
* Parses hooks.json and builds a map:
|
||||
* event -> matcher (or '' for no-matcher) -> [{script, timeout}]
|
||||
*
|
||||
* script: basename of the .js/.sh file referenced in the command string
|
||||
* timeout: numeric value from hook.timeout, or undefined if absent
|
||||
*/
|
||||
function buildHookMap() {
|
||||
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
||||
const hooksConfig = JSON.parse(raw);
|
||||
const scriptRe = /\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/([^\s"]+)/;
|
||||
const map = {};
|
||||
for (const [eventName, eventEntries] of Object.entries(hooksConfig.hooks)) {
|
||||
map[eventName] = map[eventName] || {};
|
||||
for (const entry of eventEntries) {
|
||||
const matcher = entry.matcher || '';
|
||||
map[eventName][matcher] = map[eventName][matcher] || [];
|
||||
for (const hook of entry.hooks) {
|
||||
const m = hook.command.match(scriptRe);
|
||||
if (m) {
|
||||
map[eventName][matcher].push({
|
||||
script: m[1],
|
||||
timeout: hook.timeout,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
test('SessionStart: exactly one no-matcher group with gsd-check-update.js and no timeout', () => {
|
||||
const map = buildHookMap();
|
||||
const groups = map['SessionStart'];
|
||||
assert.ok(groups, 'SessionStart must be present in hooks.json');
|
||||
// There must be exactly one entry group (key '' = no matcher)
|
||||
const noMatcherHooks = groups[''];
|
||||
assert.ok(
|
||||
Array.isArray(noMatcherHooks) && noMatcherHooks.length === 1,
|
||||
`SessionStart no-matcher group must contain exactly one hook; got: ${JSON.stringify(noMatcherHooks)}`
|
||||
);
|
||||
const h = noMatcherHooks[0];
|
||||
assert.equal(h.script, 'gsd-check-update.js', 'SessionStart hook must be gsd-check-update.js');
|
||||
assert.equal(h.timeout, undefined, 'gsd-check-update.js must NOT have a timeout field');
|
||||
});
|
||||
|
||||
test('PreToolUse Write|Edit group: gsd-prompt-guard.js (timeout 5) + gsd-read-guard.js (timeout 5)', () => {
|
||||
const map = buildHookMap();
|
||||
const groups = map['PreToolUse'];
|
||||
assert.ok(groups, 'PreToolUse must be present in hooks.json');
|
||||
const hooks = groups['Write|Edit'];
|
||||
assert.ok(
|
||||
Array.isArray(hooks) && hooks.length === 2,
|
||||
`PreToolUse Write|Edit must have exactly 2 hooks; got: ${JSON.stringify(hooks)}`
|
||||
);
|
||||
assert.equal(hooks[0].script, 'gsd-prompt-guard.js', 'first hook must be gsd-prompt-guard.js');
|
||||
assert.equal(hooks[0].timeout, 5, 'gsd-prompt-guard.js must have timeout 5');
|
||||
assert.equal(hooks[1].script, 'gsd-read-guard.js', 'second hook must be gsd-read-guard.js');
|
||||
assert.equal(hooks[1].timeout, 5, 'gsd-read-guard.js must have timeout 5');
|
||||
});
|
||||
|
||||
test('PreToolUse Write|Edit|MultiEdit group: gsd-worktree-path-guard.js (timeout 5)', () => {
|
||||
const map = buildHookMap();
|
||||
const groups = map['PreToolUse'];
|
||||
assert.ok(groups, 'PreToolUse must be present in hooks.json');
|
||||
const hooks = groups['Write|Edit|MultiEdit'];
|
||||
assert.ok(
|
||||
Array.isArray(hooks) && hooks.length === 1,
|
||||
`PreToolUse Write|Edit|MultiEdit must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
||||
);
|
||||
assert.equal(hooks[0].script, 'gsd-worktree-path-guard.js', 'hook must be gsd-worktree-path-guard.js');
|
||||
assert.equal(hooks[0].timeout, 5, 'gsd-worktree-path-guard.js must have timeout 5');
|
||||
});
|
||||
|
||||
test('PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task group: gsd-context-monitor.js (timeout 10)', () => {
|
||||
const map = buildHookMap();
|
||||
const groups = map['PostToolUse'];
|
||||
assert.ok(groups, 'PostToolUse must be present in hooks.json');
|
||||
const hooks = groups['Bash|Edit|Write|MultiEdit|Agent|Task'];
|
||||
assert.ok(
|
||||
Array.isArray(hooks) && hooks.length === 1,
|
||||
`PostToolUse Bash|Edit|Write|MultiEdit|Agent|Task must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
||||
);
|
||||
assert.equal(hooks[0].script, 'gsd-context-monitor.js', 'hook must be gsd-context-monitor.js');
|
||||
assert.equal(hooks[0].timeout, 10, 'gsd-context-monitor.js must have timeout 10');
|
||||
});
|
||||
|
||||
test('PostToolUse Read group: gsd-read-injection-scanner.js (timeout 5)', () => {
|
||||
const map = buildHookMap();
|
||||
const groups = map['PostToolUse'];
|
||||
assert.ok(groups, 'PostToolUse must be present in hooks.json');
|
||||
const hooks = groups['Read'];
|
||||
assert.ok(
|
||||
Array.isArray(hooks) && hooks.length === 1,
|
||||
`PostToolUse Read must have exactly 1 hook; got: ${JSON.stringify(hooks)}`
|
||||
);
|
||||
assert.equal(hooks[0].script, 'gsd-read-injection-scanner.js', 'hook must be gsd-read-injection-scanner.js');
|
||||
assert.equal(hooks[0].timeout, 5, 'gsd-read-injection-scanner.js must have timeout 5');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section E: Config-gated hooks must be absent from hooks.json ─────────────
|
||||
describe('E: config-gated (opt-in) hooks must not appear in hooks.json', () => {
|
||||
|
||||
const CONFIG_GATED_HOOKS = [
|
||||
'gsd-workflow-guard.js',
|
||||
'gsd-validate-commit.sh',
|
||||
'gsd-graphify-update.sh',
|
||||
'gsd-session-state.sh',
|
||||
'gsd-phase-boundary.sh',
|
||||
'gsd-update-banner.js',
|
||||
'gsd-statusline.js',
|
||||
'gsd-check-update-worker.js',
|
||||
];
|
||||
|
||||
test('none of the config-gated hook basenames appear in hooks.json command strings', () => {
|
||||
const raw = fs.readFileSync(HOOKS_JSON_PATH, 'utf-8');
|
||||
// Check raw text — simple and resistant to structure changes
|
||||
for (const hookBasename of CONFIG_GATED_HOOKS) {
|
||||
assert.ok(
|
||||
!raw.includes(hookBasename),
|
||||
`Config-gated hook "${hookBasename}" must NOT appear in hooks/hooks.json ` +
|
||||
`(it is opt-in and must not run unconditionally on the plugin path)`
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
251
tests/runtime-config-adapter-registry.test.cjs
Normal file
251
tests/runtime-config-adapter-registry.test.cjs
Normal file
@@ -0,0 +1,251 @@
|
||||
'use strict';
|
||||
|
||||
// Tests for runtime-config-adapter-registry.cjs (issue #60).
|
||||
// TDD: this file is written BEFORE the implementation to establish the red state.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
resolveRuntimeConfigIntent,
|
||||
ALLOWED_CONFIG_RUNTIMES,
|
||||
INSTALL_SURFACES,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Source-of-truth table (mirrors the intent table in the brief exactly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EXPECTED_TABLE = [
|
||||
{ runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null },
|
||||
{ runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' },
|
||||
{ runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' },
|
||||
{ runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
{ runtime: 'kimi', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1: Table-lock — every row in EXPECTED_TABLE must match exactly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveRuntimeConfigIntent — table-lock', () => {
|
||||
for (const row of EXPECTED_TABLE) {
|
||||
test(`${row.runtime} resolves to expected intent`, () => {
|
||||
const intent = resolveRuntimeConfigIntent(row.runtime);
|
||||
assert.deepStrictEqual(intent, {
|
||||
runtime: row.runtime,
|
||||
installSurface: row.installSurface,
|
||||
writesSharedSettings: row.writesSharedSettings,
|
||||
finishPermissionWriter: row.finishPermissionWriter,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2: Unknown runtime fails loudly (AC#2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveRuntimeConfigIntent — unknown runtime throws TypeError', () => {
|
||||
test('throws TypeError for unknown string "grok"', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent('grok'), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for unknown string "xyzunknown"', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent('xyzunknown'), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for empty string ""', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent(''), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for undefined', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for "__proto__" (prototype-chain key)', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for "constructor" (prototype-chain key)', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent('constructor'), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for "hasOwnProperty" (prototype-chain key)', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent('hasOwnProperty'), TypeError);
|
||||
});
|
||||
|
||||
test('throws TypeError for "toString" (prototype-chain key)', () => {
|
||||
assert.throws(() => resolveRuntimeConfigIntent('toString'), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3: writesSharedSettings exclusion equivalence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('writesSharedSettings exclusion equivalence', () => {
|
||||
const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline', 'kimi']);
|
||||
|
||||
test('runtimes with writesSharedSettings===false are exactly the exclusion set', () => {
|
||||
const falseRuntimes = EXPECTED_TABLE
|
||||
.filter(r => r.writesSharedSettings === false)
|
||||
.map(r => r.runtime);
|
||||
assert.deepStrictEqual(new Set(falseRuntimes), EXPECTED_FALSE_SET);
|
||||
});
|
||||
|
||||
test('all other supported runtimes have writesSharedSettings===true', () => {
|
||||
const trueRuntimes = EXPECTED_TABLE
|
||||
.filter(r => r.writesSharedSettings === true)
|
||||
.map(r => r.runtime);
|
||||
for (const runtime of trueRuntimes) {
|
||||
assert.ok(!EXPECTED_FALSE_SET.has(runtime), `${runtime} should have writesSharedSettings true`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 4: finishPermissionWriter correctness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('finishPermissionWriter', () => {
|
||||
test('opencode -> "opencode"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode');
|
||||
});
|
||||
|
||||
test('kilo -> "kilo"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo');
|
||||
});
|
||||
|
||||
test('every other supported runtime -> null', () => {
|
||||
const nullExpected = EXPECTED_TABLE
|
||||
.filter(r => r.finishPermissionWriter === null)
|
||||
.map(r => r.runtime);
|
||||
for (const runtime of nullExpected) {
|
||||
assert.strictEqual(
|
||||
resolveRuntimeConfigIntent(runtime).finishPermissionWriter,
|
||||
null,
|
||||
`${runtime} should have finishPermissionWriter null`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 5: Distinct dedicated surfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('installSurface correctness', () => {
|
||||
test('codex -> "codex-toml"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('codex').installSurface, 'codex-toml');
|
||||
});
|
||||
|
||||
test('copilot -> "copilot-instructions"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('copilot').installSurface, 'copilot-instructions');
|
||||
});
|
||||
|
||||
test('cline -> "cline-rules"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules');
|
||||
});
|
||||
|
||||
test('cursor -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
test('windsurf -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('windsurf').installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
test('trae -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('trae').installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
test('kimi -> "profile-marker-only"', () => {
|
||||
assert.strictEqual(resolveRuntimeConfigIntent('kimi').installSurface, 'profile-marker-only');
|
||||
});
|
||||
|
||||
test('the 7 passthroughs + opencode + kilo -> "settings-json"', () => {
|
||||
const settingsJsonRuntimes = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo'];
|
||||
for (const runtime of settingsJsonRuntimes) {
|
||||
assert.strictEqual(
|
||||
resolveRuntimeConfigIntent(runtime).installSurface,
|
||||
'settings-json',
|
||||
`${runtime} should have installSurface "settings-json"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 6: Returned intent is a fresh object (no shared reference mutation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveRuntimeConfigIntent — fresh object each call', () => {
|
||||
test('mutating the returned object does not affect a subsequent resolve', () => {
|
||||
const first = resolveRuntimeConfigIntent('claude');
|
||||
first.installSurface = 'MUTATED';
|
||||
first.writesSharedSettings = false;
|
||||
|
||||
const second = resolveRuntimeConfigIntent('claude');
|
||||
assert.strictEqual(second.installSurface, 'settings-json');
|
||||
assert.strictEqual(second.writesSharedSettings, true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 7: Completeness (AC#4 table-driven) — ALLOWED_CONFIG_RUNTIMES
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ALLOWED_CONFIG_RUNTIMES completeness', () => {
|
||||
const EXPECTED_16 = new Set([
|
||||
'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy',
|
||||
'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae',
|
||||
'kimi',
|
||||
]);
|
||||
|
||||
test('ALLOWED_CONFIG_RUNTIMES contains exactly the 16 expected runtimes', () => {
|
||||
const runtimeSet = new Set(ALLOWED_CONFIG_RUNTIMES);
|
||||
assert.deepStrictEqual(runtimeSet, EXPECTED_16);
|
||||
});
|
||||
|
||||
test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => {
|
||||
for (const runtime of ALLOWED_CONFIG_RUNTIMES) {
|
||||
assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`);
|
||||
}
|
||||
});
|
||||
|
||||
test('ALLOWED_CONFIG_RUNTIMES has exactly 16 entries', () => {
|
||||
assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 16);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 8: INSTALL_SURFACES export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('INSTALL_SURFACES export', () => {
|
||||
const EXPECTED_SURFACES = new Set([
|
||||
'settings-json',
|
||||
'codex-toml',
|
||||
'copilot-instructions',
|
||||
'cline-rules',
|
||||
'profile-marker-only',
|
||||
]);
|
||||
|
||||
test('INSTALL_SURFACES contains exactly the 5 surface strings', () => {
|
||||
assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES);
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,9 @@ const assert = require('node:assert/strict');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { getGlobalDir } = require('../bin/install.js');
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
|
||||
describe('getGlobalDir (Windsurf)', () => {
|
||||
describe('getGlobalConfigDir (Windsurf)', () => {
|
||||
let originalWindsurfConfigDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -24,29 +24,29 @@ describe('getGlobalDir (Windsurf)', () => {
|
||||
});
|
||||
|
||||
test('returns ~/.codeium/windsurf with no env var or explicit dir', () => {
|
||||
const result = getGlobalDir('windsurf');
|
||||
const result = getGlobalConfigDir('windsurf');
|
||||
assert.strictEqual(result, path.join(os.homedir(), '.codeium', 'windsurf'));
|
||||
});
|
||||
|
||||
test('returns explicit dir when provided', () => {
|
||||
const result = getGlobalDir('windsurf', '/custom/windsurf-path');
|
||||
const result = getGlobalConfigDir('windsurf', '/custom/windsurf-path');
|
||||
assert.strictEqual(result, '/custom/windsurf-path');
|
||||
});
|
||||
|
||||
test('respects WINDSURF_CONFIG_DIR env var', () => {
|
||||
process.env.WINDSURF_CONFIG_DIR = '~/custom-windsurf';
|
||||
const result = getGlobalDir('windsurf');
|
||||
const result = getGlobalConfigDir('windsurf');
|
||||
assert.strictEqual(result, path.join(os.homedir(), 'custom-windsurf'));
|
||||
});
|
||||
|
||||
test('explicit dir takes priority over WINDSURF_CONFIG_DIR', () => {
|
||||
process.env.WINDSURF_CONFIG_DIR = '~/from-env';
|
||||
const result = getGlobalDir('windsurf', '/explicit/path');
|
||||
const result = getGlobalConfigDir('windsurf', '/explicit/path');
|
||||
assert.strictEqual(result, '/explicit/path');
|
||||
});
|
||||
|
||||
test('does not break other runtimes', () => {
|
||||
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude'));
|
||||
assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user