Merge branch 'next' into kimi-runtime-support
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
// allow-test-rule: docs-parity
|
||||
// Extracts CONFIG_DEFAULTS keys from core.cjs source to verify planning-config.md
|
||||
// Extracts CONFIG_DEFAULTS keys from config-loader.cjs source to verify planning-config.md
|
||||
// stays in sync. The canonical list of defaults lives in source; there is no runtime
|
||||
// API to enumerate them. Source inspection is the only practical parity check here.
|
||||
// CONFIG_DEFAULTS was extracted from core.cjs into config-loader.cjs by ADR-857 phase 2e.
|
||||
|
||||
/**
|
||||
* Verify planning-config.md documents all config fields from source code.
|
||||
@@ -13,7 +14,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const REFERENCE_PATH = path.join(__dirname, '..', 'gsd-core', 'references', 'planning-config.md');
|
||||
const CORE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs');
|
||||
const CORE_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-loader.cjs');
|
||||
|
||||
describe('config-field-docs', () => {
|
||||
let content;
|
||||
@@ -59,12 +60,12 @@ describe('config-field-docs', () => {
|
||||
});
|
||||
|
||||
test('every CONFIG_DEFAULTS key appears in the doc', () => {
|
||||
// Extract CONFIG_DEFAULTS keys from core.cjs source
|
||||
// Extract CONFIG_DEFAULTS keys from config-loader.cjs source (moved from core.cjs by ADR-857 phase 2e)
|
||||
const coreSource = fs.readFileSync(CORE_PATH, 'utf-8');
|
||||
const defaultsMatch = coreSource.match(
|
||||
/const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\n\};/
|
||||
);
|
||||
assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in core.cjs');
|
||||
assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in config-loader.cjs');
|
||||
|
||||
const body = defaultsMatch[1];
|
||||
// Match property keys (word characters before the colon)
|
||||
|
||||
355
tests/config-loader.test.cjs
Normal file
355
tests/config-loader.test.cjs
Normal file
@@ -0,0 +1,355 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for config-loader.cjs (ADR-857 phase 2e / #885).
|
||||
*
|
||||
* Covers:
|
||||
* - loadConfig defaults when no config.json file exists
|
||||
* - loadConfig merges file values over defaults
|
||||
* - legacy-key normalization (branching_strategy → git.branching_strategy)
|
||||
* - workstream overlay (root → workstream inheritance)
|
||||
* - workstream-null fallback when workstream config is absent
|
||||
* - unknown-key warning dedup (_warnedUnknownConfigKeys deduplications)
|
||||
* - malformed JSON handling (falls back to defaults)
|
||||
* - shim identity: core.loadConfig === configLoader.loadConfig
|
||||
* - ADVERSARIAL fixtures: empty JSON, unknown keys, dynamic-prefix keys
|
||||
* like agent_skills.__proto__, scalars-where-objects-expected,
|
||||
* missing config file
|
||||
*/
|
||||
|
||||
const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── module under test ────────────────────────────────────────────────────────
|
||||
|
||||
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
|
||||
const coreModule = require('../gsd-core/bin/lib/core.cjs');
|
||||
|
||||
const { loadConfig, _resetRuntimeWarningCacheForTests } = configLoader;
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeTempProject(prefix = 'gsd-cfg-loader-test-') {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
function writeConfig(tmpDir, obj) {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
function writeWorkstreamConfig(tmpDir, wsName, obj) {
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', wsName);
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
fs.writeFileSync(path.join(wsDir, 'config.json'), JSON.stringify(obj, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// ─── shim identity ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('config-loader shim identity', () => {
|
||||
test('core.loadConfig === configLoader.loadConfig (same function object)', () => {
|
||||
assert.strictEqual(
|
||||
coreModule.loadConfig,
|
||||
configLoader.loadConfig,
|
||||
'core.cjs must re-export the same loadConfig function as config-loader.cjs'
|
||||
);
|
||||
});
|
||||
|
||||
test('core.isGitIgnored === configLoader.isGitIgnored (same function object)', () => {
|
||||
assert.strictEqual(
|
||||
coreModule.isGitIgnored,
|
||||
configLoader.isGitIgnored,
|
||||
'core.cjs must re-export the same isGitIgnored function as config-loader.cjs'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── defaults when no config.json ────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — defaults when no config.json', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('returns an object with expected default keys when config.json is absent', () => {
|
||||
const config = loadConfig(tmpDir);
|
||||
// Structural checks — should have canonical keys from CONFIG_DEFAULTS
|
||||
assert.ok('model_profile' in config, 'must have model_profile');
|
||||
assert.ok('commit_docs' in config, 'must have commit_docs');
|
||||
assert.ok('research' in config, 'must have research');
|
||||
assert.ok('branching_strategy' in config, 'must have branching_strategy');
|
||||
assert.ok('plan_checker' in config, 'must have plan_checker');
|
||||
assert.ok('verifier' in config, 'must have verifier');
|
||||
assert.ok('parallelization' in config, 'must have parallelization');
|
||||
assert.ok('sub_repos' in config, 'must have sub_repos');
|
||||
assert.ok('resolve_model_ids' in config, 'must have resolve_model_ids');
|
||||
});
|
||||
|
||||
test('model_profile default is "balanced"', () => {
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.model_profile, 'balanced');
|
||||
});
|
||||
|
||||
test('config.json present with empty object: agent_skills default is an empty object', () => {
|
||||
// agent_skills only appears in the return when a config.json is successfully parsed
|
||||
writeConfig(tmpDir, {});
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.deepEqual(config.agent_skills, {});
|
||||
});
|
||||
|
||||
test('config.json present with empty object: model_overrides default is null', () => {
|
||||
// model_overrides only appears in the return when a config.json is successfully parsed
|
||||
writeConfig(tmpDir, {});
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.model_overrides, null);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── file values merge over defaults ─────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — file values override defaults', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('model_profile from config.json overrides the default', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'quality' });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.model_profile, 'quality');
|
||||
});
|
||||
|
||||
test('workflow.research from nested config is returned', () => {
|
||||
writeConfig(tmpDir, { workflow: { research: 'deep' } });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.research, 'deep');
|
||||
});
|
||||
|
||||
test('top-level research is returned', () => {
|
||||
writeConfig(tmpDir, { research: 'minimal' });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.research, 'minimal');
|
||||
});
|
||||
|
||||
test('mode from config.json is returned', () => {
|
||||
writeConfig(tmpDir, { mode: 'autonomous' });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.mode, 'autonomous');
|
||||
});
|
||||
|
||||
test('model_overrides from config.json is returned', () => {
|
||||
writeConfig(tmpDir, { model_overrides: { planner: 'claude-opus-4-5' } });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.deepEqual(config.model_overrides, { planner: 'claude-opus-4-5' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── legacy-key normalization ─────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — legacy-key normalization', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('top-level branching_strategy is migrated to git.branching_strategy', () => {
|
||||
writeConfig(tmpDir, { branching_strategy: 'milestone' });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.branching_strategy, 'milestone');
|
||||
});
|
||||
|
||||
test('on-disk file has branching_strategy moved under git.* after migration', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ branching_strategy: 'phase' }, null, 2), 'utf-8');
|
||||
loadConfig(tmpDir);
|
||||
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.equal(onDisk.git?.branching_strategy, 'phase');
|
||||
assert.equal(onDisk.branching_strategy, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── workstream overlay ───────────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — workstream overlay', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('workstream config overrides root config', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'balanced' });
|
||||
writeWorkstreamConfig(tmpDir, 'ws-a', { model_profile: 'quality' });
|
||||
const config = loadConfig(tmpDir, { workstream: 'ws-a' });
|
||||
assert.equal(config.model_profile, 'quality');
|
||||
});
|
||||
|
||||
test('root-only keys are inherited by workstream config', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'balanced', research: 'deep' });
|
||||
writeWorkstreamConfig(tmpDir, 'ws-b', { mode: 'autonomous' });
|
||||
const config = loadConfig(tmpDir, { workstream: 'ws-b' });
|
||||
// Root's research should still be visible (inherited)
|
||||
assert.equal(config.research, 'deep');
|
||||
// Workstream's mode should override
|
||||
assert.equal(config.mode, 'autonomous');
|
||||
});
|
||||
|
||||
test('workstream-null fallback: root config used when workstream has no config.json', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'budget' });
|
||||
// Create workstream directory but no config.json
|
||||
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'ws-no-config');
|
||||
fs.mkdirSync(path.join(wsDir, 'phases'), { recursive: true });
|
||||
// loadConfig with missing workstream config.json should fall back to root
|
||||
const config = loadConfig(tmpDir, { workstream: 'ws-no-config' });
|
||||
assert.equal(config.model_profile, 'budget');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── unknown-key warning dedup ────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — unknown-key warning dedup', () => {
|
||||
let tmpDir;
|
||||
let originalStderrWrite;
|
||||
let stderrLines;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTempProject();
|
||||
stderrLines = [];
|
||||
originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk) => {
|
||||
stderrLines.push(String(chunk));
|
||||
return true;
|
||||
};
|
||||
// Reset the module-level dedup set so each test starts clean
|
||||
if (_resetRuntimeWarningCacheForTests) _resetRuntimeWarningCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
if (tmpDir) cleanup(tmpDir);
|
||||
tmpDir = null;
|
||||
});
|
||||
|
||||
test('unknown key produces a warning mentioning the key name', () => {
|
||||
writeConfig(tmpDir, { __gsd_unknown_sentinel__: true });
|
||||
loadConfig(tmpDir);
|
||||
const warnings = stderrLines.filter(l => l.includes('__gsd_unknown_sentinel__'));
|
||||
assert.ok(warnings.length >= 1, 'should warn about unknown key');
|
||||
});
|
||||
|
||||
test('calling loadConfig twice does not double-emit the same unknown-key warning', () => {
|
||||
writeConfig(tmpDir, { __gsd_dedup_test__: true });
|
||||
loadConfig(tmpDir);
|
||||
loadConfig(tmpDir);
|
||||
const warnings = stderrLines.filter(l => l.includes('__gsd_dedup_test__'));
|
||||
// Should appear at most once
|
||||
assert.ok(warnings.length <= 1, `warning emitted more than once: ${warnings.length} times`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── malformed JSON handling ──────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — malformed JSON', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('malformed config.json returns defaults without throwing', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, '{ invalid json !!', 'utf-8');
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.ok(typeof config === 'object' && config !== null, 'should return an object');
|
||||
assert.ok('model_profile' in config, 'should have model_profile key');
|
||||
});
|
||||
|
||||
test('empty config.json (empty braces) does not throw and returns defaults', () => {
|
||||
writeConfig(tmpDir, {});
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.equal(config.model_profile, 'balanced');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ADVERSARIAL fixtures ─────────────────────────────────────────────────────
|
||||
|
||||
describe('loadConfig — adversarial fixtures', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = makeTempProject(); });
|
||||
afterEach(() => { if (tmpDir) cleanup(tmpDir); tmpDir = null; });
|
||||
|
||||
test('agent_skills.__proto__ key in config does not pollute Object prototype', () => {
|
||||
// Write config with a prototype-pollution candidate key
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
// JSON.stringify won't serialize __proto__ as an own property;
|
||||
// write the raw string to simulate an adversarial file.
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
'{"agent_skills": {"__proto__": {"polluted": true}}}',
|
||||
'utf-8'
|
||||
);
|
||||
const before = ({}).polluted;
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
const after = ({}).polluted;
|
||||
assert.equal(before, after, 'Object prototype must not be polluted');
|
||||
// agent_skills should be the parsed value or an empty object — not throw
|
||||
assert.ok(typeof config.agent_skills === 'object', 'agent_skills should be an object');
|
||||
});
|
||||
|
||||
test('scalars-where-objects-expected: workflow is a string', () => {
|
||||
writeConfig(tmpDir, { workflow: 'invalid' });
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.ok(typeof config === 'object', 'should return an object');
|
||||
});
|
||||
|
||||
test('completely empty JSON file (just whitespace) falls back to defaults', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, ' ', 'utf-8');
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.ok('model_profile' in config);
|
||||
});
|
||||
|
||||
test('null JSON value (top-level null) falls back to defaults', () => {
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, 'null', 'utf-8');
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.ok('model_profile' in config);
|
||||
});
|
||||
|
||||
test('deeply nested unknown keys do not throw', () => {
|
||||
writeConfig(tmpDir, {
|
||||
workflow: {
|
||||
research: 'minimal',
|
||||
__unknown_nested__: { a: 1, b: { c: 2 } },
|
||||
},
|
||||
});
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.equal(config.research, 'minimal');
|
||||
});
|
||||
|
||||
test('dynamic-prefix key agent_skills.* with unusual value type does not throw', () => {
|
||||
writeConfig(tmpDir, { agent_skills: { 'my-skill': null } });
|
||||
let config;
|
||||
assert.doesNotThrow(() => { config = loadConfig(tmpDir); });
|
||||
assert.ok(typeof config.agent_skills === 'object');
|
||||
});
|
||||
|
||||
test('config with only unknown keys returns defaults for known keys', () => {
|
||||
writeConfig(tmpDir, { completly_unknown_a: 1, completly_unknown_b: 2 });
|
||||
const config = loadConfig(tmpDir);
|
||||
assert.equal(config.model_profile, 'balanced');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user