diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json
index 1d623b611..55e98eaac 100644
--- a/scripts/lint-allow-test-rule-refs.allowlist.json
+++ b/scripts/lint-allow-test-rule-refs.allowlist.json
@@ -152,8 +152,6 @@
"tests/context-enrichment.test.cjs :: source-text-is-the-product",
"tests/contributor-standards.test.cjs :: source-text-is-the-product",
"tests/copilot-install.test.cjs :: integration-test-input",
- "tests/core.test.cjs :: architectural-invariant",
- "tests/core.test.cjs :: structural-regression-guard",
"tests/cursor-hooks.test.cjs :: source-text-is-the-product",
"tests/cursor-reviewer.test.cjs :: source-text-is-the-product",
"tests/debug-session-management.test.cjs :: source-text-is-the-product",
diff --git a/tests/backwards-compat-phase-id.test.cjs b/tests/backwards-compat-phase-id.test.cjs
index d1f343323..b2f71bfa8 100644
--- a/tests/backwards-compat-phase-id.test.cjs
+++ b/tests/backwards-compat-phase-id.test.cjs
@@ -28,7 +28,7 @@ const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, captureConsole } = require('./helpers.cjs');
-const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/core.cjs');
+const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
// ─── helpers ─────────────────────────────────────────────────────────────────
diff --git a/tests/bug-1829-inherit-model-profile.test.cjs b/tests/bug-1829-inherit-model-profile.test.cjs
index 9f32705c4..ea96f7b1a 100644
--- a/tests/bug-1829-inherit-model-profile.test.cjs
+++ b/tests/bug-1829-inherit-model-profile.test.cjs
@@ -24,7 +24,7 @@ const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
-const { resolveModelInternal } = require('../gsd-core/bin/lib/core.cjs');
+const { resolveModelInternal } = require('../gsd-core/bin/lib/model-resolver.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
diff --git a/tests/bug-2554-decimal-phase-filter.test.cjs b/tests/bug-2554-decimal-phase-filter.test.cjs
index 26e417e41..4e77453c6 100644
--- a/tests/bug-2554-decimal-phase-filter.test.cjs
+++ b/tests/bug-2554-decimal-phase-filter.test.cjs
@@ -15,7 +15,7 @@ const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { createTempProject, cleanup } = require('./helpers.cjs');
-const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/core.cjs');
+const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
describe('bug #2554 — getMilestonePhaseFilter decimal phase dirs', () => {
let tmpDir;
diff --git a/tests/bug-2638-sub-repos-canonical-location.test.cjs b/tests/bug-2638-sub-repos-canonical-location.test.cjs
index e4430f628..3254b4622 100644
--- a/tests/bug-2638-sub-repos-canonical-location.test.cjs
+++ b/tests/bug-2638-sub-repos-canonical-location.test.cjs
@@ -17,7 +17,7 @@ const path = require('path');
const { execFileSync } = require('child_process');
const { createTempProject, cleanup } = require('./helpers.cjs');
-const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
function makeSubRepo(parent, name) {
const dir = path.join(parent, name);
diff --git a/tests/bug-492-effort-manifest-fallback.test.cjs b/tests/bug-492-effort-manifest-fallback.test.cjs
index b66b9bee0..8127b45c4 100644
--- a/tests/bug-492-effort-manifest-fallback.test.cjs
+++ b/tests/bug-492-effort-manifest-fallback.test.cjs
@@ -29,7 +29,7 @@ const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
-const { resolveEffortInternal } = require('../gsd-core/bin/lib/core.cjs');
+const { resolveEffortInternal } = require('../gsd-core/bin/lib/model-resolver.cjs');
/** Create a bare temp directory with no .planning/ structure */
function createBareTmpDir(prefix = 'gsd-test-') {
diff --git a/tests/bug-730-milestone-phase-details-scope.test.cjs b/tests/bug-730-milestone-phase-details-scope.test.cjs
index c44ef4f20..2673af921 100644
--- a/tests/bug-730-milestone-phase-details-scope.test.cjs
+++ b/tests/bug-730-milestone-phase-details-scope.test.cjs
@@ -107,9 +107,9 @@ describe('bug #730 — milestone (Phase Details) section scope resolution', () =
// directly before any phases/ dir exists
// -------------------------------------------------------------------------
test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => {
- const core = require('../gsd-core/bin/lib/core.cjs');
+ const { getRoadmapPhaseInternal } = require('../gsd-core/bin/lib/roadmap-parser.cjs');
- const res = core.getRoadmapPhaseInternal(dir, '2');
+ const res = getRoadmapPhaseInternal(dir, '2');
assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`);
assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`);
assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`);
diff --git a/tests/capability-command-dispatch.test.cjs b/tests/capability-command-dispatch.test.cjs
index 4be8d1a2e..972263d68 100644
--- a/tests/capability-command-dispatch.test.cjs
+++ b/tests/capability-command-dispatch.test.cjs
@@ -534,7 +534,7 @@ describe('dispatchCapabilityCommand — non-ExitError from router → structured
assert.ok(msg.includes('fake.cjs'), 'message must name the module; got: ' + msg);
assert.ok(msg.includes('boom'), 'message must include original error message; got: ' + msg);
// Reason must be SDK_FAIL_FAST
- const { ERROR_REASON } = require('../gsd-core/bin/lib/core.cjs');
+ const { ERROR_REASON } = require('../gsd-core/bin/lib/io.cjs');
assert.strictEqual(reason, ERROR_REASON.SDK_FAIL_FAST, 'reason must be SDK_FAIL_FAST');
});
@@ -625,7 +625,7 @@ describe('dispatchCapabilityCommand — non-ExitError from router → structured
// ─── 10. Async router (thenable) → structured error ─────────────────────────
describe('dispatchCapabilityCommand — async router returns a Promise → structured error', () => {
- const { ERROR_REASON } = require('../gsd-core/bin/lib/core.cjs');
+ const { ERROR_REASON } = require('../gsd-core/bin/lib/io.cjs');
test('router returns Promise.resolve() → error() called with "must be synchronous" + SDK_FAIL_FAST', () => {
const errorCalls = [];
diff --git a/tests/config-loader.test.cjs b/tests/config-loader.test.cjs
index 387f6fe5e..25a65f8d0 100644
--- a/tests/config-loader.test.cjs
+++ b/tests/config-loader.test.cjs
@@ -27,7 +27,6 @@ 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;
@@ -50,25 +49,6 @@ function writeWorkstreamConfig(tmpDir, wsName, obj) {
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 ────────────────────────────────────────────
diff --git a/tests/core-utils.test.cjs b/tests/core-utils.test.cjs
index 22ac6aba5..86ca1cb02 100644
--- a/tests/core-utils.test.cjs
+++ b/tests/core-utils.test.cjs
@@ -30,50 +30,8 @@ const path = require('node:path');
const os = require('node:os');
const coreUtils = require('../gsd-core/bin/lib/core-utils.cjs');
-const core = require('../gsd-core/bin/lib/core.cjs');
const { cleanup } = require('./helpers.cjs');
-// ─── Shim-identity assertions ─────────────────────────────────────────────────
-
-describe('core-utils: shim-identity — core.cjs re-exports same function objects', () => {
- test('core.toPosixPath === coreUtils.toPosixPath', () => {
- assert.strictEqual(core.toPosixPath, coreUtils.toPosixPath);
- });
- test('core.detectSubRepos === coreUtils.detectSubRepos', () => {
- assert.strictEqual(core.detectSubRepos, coreUtils.detectSubRepos);
- });
- test('core.extractOneLinerFromBody === coreUtils.extractOneLinerFromBody', () => {
- assert.strictEqual(core.extractOneLinerFromBody, coreUtils.extractOneLinerFromBody);
- });
- test('core.pathExistsInternal === coreUtils.pathExistsInternal', () => {
- assert.strictEqual(core.pathExistsInternal, coreUtils.pathExistsInternal);
- });
- test('core.generateSlugInternal === coreUtils.generateSlugInternal', () => {
- assert.strictEqual(core.generateSlugInternal, coreUtils.generateSlugInternal);
- });
- test('core.filterPlanFiles === coreUtils.filterPlanFiles', () => {
- assert.strictEqual(core.filterPlanFiles, coreUtils.filterPlanFiles);
- });
- test('core.filterSummaryFiles === coreUtils.filterSummaryFiles', () => {
- assert.strictEqual(core.filterSummaryFiles, coreUtils.filterSummaryFiles);
- });
- test('core.getPhaseFileStats === coreUtils.getPhaseFileStats', () => {
- assert.strictEqual(core.getPhaseFileStats, coreUtils.getPhaseFileStats);
- });
- test('core.readSubdirectories === coreUtils.readSubdirectories', () => {
- assert.strictEqual(core.readSubdirectories, coreUtils.readSubdirectories);
- });
- test('core.timeAgo === coreUtils.timeAgo', () => {
- assert.strictEqual(core.timeAgo, coreUtils.timeAgo);
- });
- test('extractCanonicalPlanId is NOT re-exported from core', () => {
- assert.strictEqual(typeof core.extractCanonicalPlanId, 'undefined');
- });
- test('extractCanonicalPlanId IS exported from coreUtils', () => {
- assert.strictEqual(typeof coreUtils.extractCanonicalPlanId, 'function');
- });
-});
-
// ─── toPosixPath ─────────────────────────────────────────────────────────────
describe('toPosixPath', () => {
diff --git a/tests/core.test.cjs b/tests/core.test.cjs
deleted file mode 100644
index d76a18efd..000000000
--- a/tests/core.test.cjs
+++ /dev/null
@@ -1,2092 +0,0 @@
-// allow-test-rule: structural-regression-guard
-// Reads hook .js or bin/install.js source to assert structural invariants
-// (search array order, function wiring, path constants) that cannot be
-// verified by observing runtime outputs alone. Per CONTRIBUTING.md exception matrix.
-
-/**
- * GSD Tools Tests - core.cjs
- *
- * Tests for the foundational module's exports including regressions
- * for known bugs (REG-01: loadConfig model_overrides, REG-02: getRoadmapPhaseInternal export).
- */
-
-const { test, describe, beforeEach, afterEach } = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('fs');
-const path = require('path');
-const os = require('os');
-const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
-
-const {
- loadConfig,
- resolveModelInternal,
- escapeRegex,
- generateSlugInternal,
- reapStaleTempFiles,
- pathExistsInternal,
- getMilestoneInfo,
- getMilestonePhaseFilter,
- getRoadmapPhaseInternal,
- searchPhaseInDir,
- findPhaseInternal,
- findProjectRoot,
- detectSubRepos,
- planningDir,
- timeAgo,
-} = require('../gsd-core/bin/lib/core.cjs');
-
-// ─── loadConfig ────────────────────────────────────────────────────────────────
-
-describe('loadConfig', () => {
- let tmpDir;
- let originalCwd;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- originalCwd = process.cwd();
- });
-
- afterEach(() => {
- process.chdir(originalCwd);
- cleanup(tmpDir);
- });
-
- function writeConfig(obj) {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'config.json'),
- JSON.stringify(obj, null, 2)
- );
- }
-
- test('returns defaults when config.json is missing', () => {
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.model_profile, 'balanced');
- assert.strictEqual(config.commit_docs, true);
- assert.strictEqual(config.research, true);
- assert.strictEqual(config.plan_checker, true);
- assert.strictEqual(config.brave_search, false);
- assert.strictEqual(config.parallelization, true);
- assert.strictEqual(config.nyquist_validation, true);
- assert.strictEqual(config.text_mode, false);
- });
-
- test('reads model_profile from config.json', () => {
- writeConfig({ model_profile: 'quality' });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.model_profile, 'quality');
- });
-
- test('reads nested config keys', () => {
- writeConfig({ planning: { commit_docs: false } });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.commit_docs, false);
- });
-
- test('reads branching_strategy from git section', () => {
- writeConfig({ git: { branching_strategy: 'per-phase' } });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.branching_strategy, 'per-phase');
- });
-
- // Bug: loadConfig previously omitted model_overrides from return value
- test('returns model_overrides when present (REG-01)', () => {
- writeConfig({ model_overrides: { 'gsd-executor': 'opus' } });
- const config = loadConfig(tmpDir);
- assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
- });
-
- test('returns model_overrides as null when not in config', () => {
- writeConfig({ model_profile: 'balanced' });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.model_overrides, null);
- });
-
- test('reads response_language when set', () => {
- writeConfig({ response_language: 'Portuguese' });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.response_language, 'Portuguese');
- });
-
- test('returns response_language as null when not set', () => {
- writeConfig({ model_profile: 'balanced' });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.response_language, null);
- });
-
- test('returns defaults when config.json contains invalid JSON', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'config.json'),
- 'not valid json {{{{'
- );
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.model_profile, 'balanced');
- assert.strictEqual(config.commit_docs, true);
- });
-
- test('handles parallelization as boolean', () => {
- writeConfig({ parallelization: false });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.parallelization, false);
- });
-
- test('handles parallelization as object with enabled field', () => {
- writeConfig({ parallelization: { enabled: false } });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.parallelization, false);
- });
-
- test('prefers top-level keys over nested keys', () => {
- writeConfig({ commit_docs: false, planning: { commit_docs: true } });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.commit_docs, false);
- });
-
- test('warns on unknown config keys to stderr (#1535)', (t) => {
- writeConfig({ model_profile: 'quality', active_project: 'my-project', custom_flag: true });
- const origWrite = process.stderr.write;
- let stderrOutput = '';
- process.stderr.write = (chunk) => { stderrOutput += chunk; };
- t.after(() => { process.stderr.write = origWrite; });
- const config = loadConfig(tmpDir);
- // Known key still loads correctly
- assert.strictEqual(config.model_profile, 'quality');
- // Warning emitted for unknown keys
- assert.ok(stderrOutput.includes('active_project'), 'should warn about active_project');
- assert.ok(stderrOutput.includes('custom_flag'), 'should warn about custom_flag');
- assert.ok(stderrOutput.includes('ignored'), 'should mention keys will be ignored');
- });
-
- test('known config keys are derived from VALID_CONFIG_KEYS (not hardcoded)', () => {
- // Verify that loadConfig's unknown-key check uses config-set's VALID_CONFIG_KEYS
- // as its source of truth. If a new key is added to config-set, it should
- // automatically be recognized by loadConfig without a separate update.
- const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs');
- // Every top-level key from VALID_CONFIG_KEYS should be recognized
- const topLevelKeys = [...VALID_CONFIG_KEYS].map(k => k.split('.')[0]);
- // For value-validated keys (e.g. `runtime` enforces an enum at loadConfig
- // time, see #2517 review finding #10), seed a known-good value so the
- // value-validation warning doesn't fire — this test only checks that the
- // key NAME is recognized, not whether the value itself is valid.
- const KEY_VALID_VALUES = { runtime: 'codex' };
- for (const key of topLevelKeys) {
- const value = KEY_VALID_VALUES[key] ?? 'test-value';
- writeConfig({ [key]: value });
- const origWrite = process.stderr.write;
- let stderrOutput = '';
- process.stderr.write = (chunk) => { stderrOutput += chunk; };
- try {
- loadConfig(tmpDir);
- // Look only for the unknown-KEY warning shape, not any incidental match
- // (the value-validation warning emitted by #2517 mentions key names too).
- const unknownKeyWarning = stderrOutput.includes('unknown config key(s)') &&
- stderrOutput.includes(key);
- assert.ok(
- !unknownKeyWarning,
- `VALID_CONFIG_KEYS key "${key}" should not trigger unknown-key warning`
- );
- } finally {
- process.stderr.write = origWrite;
- }
- }
- });
-
- test('does not warn when all config keys are known', (t) => {
- writeConfig({ model_profile: 'balanced', workflow: { research: false }, git: { branching_strategy: 'per-phase' } });
- const origWrite = process.stderr.write;
- let stderrOutput = '';
- process.stderr.write = (chunk) => { stderrOutput += chunk; };
- t.after(() => { process.stderr.write = origWrite; });
- loadConfig(tmpDir);
- assert.strictEqual(stderrOutput, '', 'should not emit any warnings for valid config');
- });
-});
-
-// ─── loadConfig workstream config inheritance (#2714) ────────────────────────
-
-describe('loadConfig workstream config inheritance (#2714)', () => {
- let tmpDir;
- let originalEnv;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- originalEnv = process.env.GSD_WORKSTREAM;
- delete process.env.GSD_WORKSTREAM;
- });
-
- afterEach(() => {
- if (originalEnv !== undefined) {
- process.env.GSD_WORKSTREAM = originalEnv;
- } else {
- delete process.env.GSD_WORKSTREAM;
- }
- cleanup(tmpDir);
- });
-
- function writeRootConfig(obj) {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'config.json'),
- JSON.stringify(obj, null, 2)
- );
- }
-
- function writeWorkstreamConfig(wsName, obj) {
- const wsDir = path.join(tmpDir, '.planning', 'workstreams', wsName);
- fs.mkdirSync(wsDir, { recursive: true });
- fs.writeFileSync(
- path.join(wsDir, 'config.json'),
- JSON.stringify(obj, null, 2)
- );
- }
-
- test('workstream config inherits model_overrides from root when not defined in workstream', () => {
- writeRootConfig({ model_overrides: { 'gsd-executor': 'opus' } });
- writeWorkstreamConfig('feature-a', { model_profile: 'quality' });
- process.env.GSD_WORKSTREAM = 'feature-a';
- const config = loadConfig(tmpDir);
- assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
- assert.strictEqual(config.model_profile, 'quality');
- });
-
- test('workstream-specific keys override root config values', () => {
- writeRootConfig({ model_profile: 'balanced', model_overrides: { 'gsd-executor': 'opus' } });
- writeWorkstreamConfig('feature-b', { model_profile: 'speed', model_overrides: { 'gsd-executor': 'haiku' } });
- process.env.GSD_WORKSTREAM = 'feature-b';
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.model_profile, 'speed');
- assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'haiku' });
- });
-
- test('deep merge works for nested workflow.* keys', () => {
- writeRootConfig({ workflow: { research: false, auto_advance: true } });
- writeWorkstreamConfig('feature-c', { workflow: { auto_advance: false } });
- process.env.GSD_WORKSTREAM = 'feature-c';
- const config = loadConfig(tmpDir);
- // research inherited from root
- assert.strictEqual(config.research, false);
- // auto_advance overridden by workstream
- assert.strictEqual(config.auto_advance, false);
- });
-
- test('explicit null in workstream config overrides root value (PR #2717 null-override bug)', () => {
- writeRootConfig({ model_overrides: { 'gsd-executor': 'opus', 'gsd-planner': 'sonnet' } });
- writeWorkstreamConfig('feature-d', { model_overrides: null });
- process.env.GSD_WORKSTREAM = 'feature-d';
- const config = loadConfig(tmpDir);
- // null in workstream should override root, not fall back to root value
- assert.strictEqual(config.model_overrides, null);
- });
-
- test('workstream without config.json inherits root config', () => {
- writeRootConfig({ model_profile: 'quality', model_overrides: { 'gsd-executor': 'opus' } });
- // Create workstream dir without config.json
- fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-e'), { recursive: true });
- process.env.GSD_WORKSTREAM = 'feature-e';
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.model_profile, 'quality');
- assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' });
- });
-
- test('loadConfig does not mutate GSD_WORKSTREAM when workstream config is missing', () => {
- writeRootConfig({ model_profile: 'quality' });
- fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-f'), { recursive: true });
- process.env.GSD_WORKSTREAM = 'feature-f';
-
- const config = loadConfig(tmpDir);
-
- assert.strictEqual(config.model_profile, 'quality');
- assert.strictEqual(process.env.GSD_WORKSTREAM, 'feature-f');
- });
-
- test('loadConfig accepts workstreamContext.ws without requiring env mutation', () => {
- writeRootConfig({ model_profile: 'balanced' });
- writeWorkstreamConfig('feature-g', { model_profile: 'quality' });
- delete process.env.GSD_WORKSTREAM;
-
- const config = loadConfig(tmpDir, {
- workstreamContext: { ws: 'feature-g' },
- });
-
- assert.strictEqual(config.model_profile, 'quality');
- assert.strictEqual(process.env.GSD_WORKSTREAM, undefined);
- });
-});
-
-// ─── loadConfig commit_docs gitignore auto-detection (#1250) ──────────────────
-
-describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempGitProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- function writeConfig(obj) {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'config.json'),
- JSON.stringify(obj, null, 2)
- );
- }
-
- test('commit_docs defaults to false when .planning/ is gitignored and no explicit config', () => {
- fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
- // No commit_docs in config — should auto-detect
- writeConfig({ model_profile: 'balanced' });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.commit_docs, false,
- 'commit_docs should be false when .planning/ is gitignored and not explicitly set');
- });
-
- test('commit_docs defaults to true when .planning/ is NOT gitignored and no explicit config', () => {
- // No .gitignore, no commit_docs in config
- writeConfig({ model_profile: 'balanced' });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.commit_docs, true,
- 'commit_docs should default to true when .planning/ is not gitignored');
- });
-
- test('explicit commit_docs: false is respected even when .planning/ is not gitignored', () => {
- writeConfig({ commit_docs: false });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.commit_docs, false);
- });
-
- test('explicit commit_docs: true is respected even when .planning/ is gitignored', () => {
- fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
- writeConfig({ commit_docs: true });
- const config = loadConfig(tmpDir);
- assert.strictEqual(config.commit_docs, true,
- 'explicit commit_docs: true should override gitignore auto-detection');
- });
-
- test('commit_docs auto-detect works with no config.json', () => {
- // Remove config.json so loadConfig uses defaults
- try { fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json')); } catch {}
- fs.writeFileSync(path.join(tmpDir, '.gitignore'), '.planning/\n');
- const config = loadConfig(tmpDir);
- // When config.json is missing, loadConfig catches and returns defaults.
- // The gitignore check happens inside the try block, so with no config.json
- // the catch returns defaults (commit_docs: true). This is acceptable since
- // a project without config.json hasn't been initialized by GSD yet.
- assert.strictEqual(typeof config.commit_docs, 'boolean');
- });
-});
-
-// ─── resolveModelInternal ──────────────────────────────────────────────────────
-
-describe('resolveModelInternal', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- function writeConfig(obj) {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'config.json'),
- JSON.stringify(obj, null, 2)
- );
- }
-
- describe('model profile structural validation', () => {
- test('all known agents resolve to a valid string for each profile', () => {
- const knownAgents = ['gsd-planner', 'gsd-executor', 'gsd-phase-researcher', 'gsd-codebase-mapper'];
- const profiles = ['quality', 'balanced', 'budget', 'inherit'];
- const validValues = ['inherit', 'sonnet', 'haiku', 'opus'];
-
- for (const profile of profiles) {
- writeConfig({ model_profile: profile });
- for (const agent of knownAgents) {
- const result = resolveModelInternal(tmpDir, agent);
- assert.ok(
- validValues.includes(result),
- `profile=${profile} agent=${agent} returned unexpected value: ${result}`
- );
- }
- }
- });
-
- test('inherit profile forces all known agents to inherit model', () => {
- const knownAgents = ['gsd-planner', 'gsd-executor', 'gsd-phase-researcher', 'gsd-codebase-mapper'];
- writeConfig({ model_profile: 'inherit' });
- for (const agent of knownAgents) {
- assert.strictEqual(resolveModelInternal(tmpDir, agent), 'inherit');
- }
- });
- });
-
- describe('override precedence', () => {
- test('per-agent override takes precedence over profile', () => {
- writeConfig({
- model_profile: 'balanced',
- model_overrides: { 'gsd-executor': 'haiku' },
- });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'haiku');
- });
-
- test('opus override resolves to opus', () => {
- writeConfig({
- model_overrides: { 'gsd-executor': 'opus' },
- });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'opus');
- });
-
- test('agents not in override fall back to profile', () => {
- writeConfig({
- model_profile: 'quality',
- model_overrides: { 'gsd-executor': 'haiku' },
- });
- // gsd-planner not overridden, should use quality profile -> opus
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus');
- });
- });
-
- describe('edge cases', () => {
- test('returns sonnet for unknown agent type', () => {
- writeConfig({ model_profile: 'balanced' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet');
- });
-
- test('returns opus for unknown agent type with quality profile', () => {
- writeConfig({ model_profile: 'quality' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'opus');
- });
-
- test('returns haiku for unknown agent type with budget profile', () => {
- writeConfig({ model_profile: 'budget' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'haiku');
- });
-
- test('returns inherit for unknown agent type with inherit profile', () => {
- writeConfig({ model_profile: 'inherit' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'inherit');
- });
-
- test('defaults to balanced profile when model_profile missing', () => {
- writeConfig({});
- // balanced profile, gsd-planner -> opus
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'opus');
- });
- });
-
- describe('resolve_model_ids: "omit"', () => {
- test('returns empty string for known agents', () => {
- writeConfig({ resolve_model_ids: 'omit' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), '');
- });
-
- test('returns empty string for unknown agents', () => {
- writeConfig({ resolve_model_ids: 'omit' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), '');
- });
-
- test('still respects model_overrides even when omit', () => {
- writeConfig({
- resolve_model_ids: 'omit',
- model_overrides: { 'gsd-planner': 'openai/gpt-5.4' },
- });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'openai/gpt-5.4');
- });
-
- test('returns empty string with inherit profile', () => {
- writeConfig({ resolve_model_ids: 'omit', model_profile: 'inherit' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), '');
- });
- });
-
- describe('resolve_model_ids: true', () => {
- // Regression test for #2712: MODEL_ALIAS_MAP must track current model releases.
- test('opus alias resolves to claude-opus-4-8', () => {
- writeConfig({ resolve_model_ids: true, model_profile: 'quality' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'claude-opus-4-8');
- });
-
- test('sonnet alias resolves to claude-sonnet-4-6', () => {
- writeConfig({ resolve_model_ids: true, model_profile: 'balanced' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-executor'), 'claude-sonnet-4-6');
- });
-
- test('haiku alias resolves to claude-haiku-4-5', () => {
- writeConfig({ resolve_model_ids: true, model_profile: 'budget' });
- assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-codebase-mapper'), 'claude-haiku-4-5');
- });
- });
-});
-
-// ─── escapeRegex ───────────────────────────────────────────────────────────────
-
-describe('escapeRegex', () => {
- test('escapes dots', () => {
- assert.strictEqual(escapeRegex('file.txt'), 'file\\.txt');
- });
-
- test('escapes all special regex characters', () => {
- const input = '1.0 (alpha) [test] {ok} $100 ^start end$ a+b a*b a?b pipe|or back\\slash';
- const result = escapeRegex(input);
- // Verify each special char is escaped
- assert.ok(result.includes('\\.'));
- assert.ok(result.includes('\\('));
- assert.ok(result.includes('\\)'));
- assert.ok(result.includes('\\['));
- assert.ok(result.includes('\\]'));
- assert.ok(result.includes('\\{'));
- assert.ok(result.includes('\\}'));
- assert.ok(result.includes('\\$'));
- assert.ok(result.includes('\\^'));
- assert.ok(result.includes('\\+'));
- assert.ok(result.includes('\\*'));
- assert.ok(result.includes('\\?'));
- assert.ok(result.includes('\\|'));
- assert.ok(result.includes('\\\\'));
- });
-
- test('handles empty string', () => {
- assert.strictEqual(escapeRegex(''), '');
- });
-
- test('returns plain string unchanged', () => {
- assert.strictEqual(escapeRegex('hello'), 'hello');
- });
-});
-
-// ─── generateSlugInternal ──────────────────────────────────────────────────────
-
-describe('generateSlugInternal', () => {
- test('converts text to lowercase kebab-case', () => {
- assert.strictEqual(generateSlugInternal('Hello World'), 'hello-world');
- });
-
- test('removes special characters', () => {
- assert.strictEqual(generateSlugInternal('core.cjs Tests!'), 'core-cjs-tests');
- });
-
- test('trims leading and trailing hyphens', () => {
- assert.strictEqual(generateSlugInternal('---hello---'), 'hello');
- });
-
- test('returns null for null input', () => {
- assert.strictEqual(generateSlugInternal(null), null);
- });
-
- test('returns null for empty string', () => {
- assert.strictEqual(generateSlugInternal(''), null);
- });
-
- test('strips newlines and control characters', () => {
- assert.strictEqual(generateSlugInternal('hello\nworld'), 'hello-world');
- assert.strictEqual(generateSlugInternal('tab\there'), 'tab-here');
- });
-
- test('truncates to 60 characters', () => {
- const long = 'a'.repeat(100);
- const result = generateSlugInternal(long);
- assert.ok(result.length <= 60, `slug should be <=60 chars, got ${result.length}`);
- });
-});
-
-// ─── normalizePhaseName / comparePhaseNum ──────────────────────────────────────
-// NOTE: Comprehensive tests for normalizePhaseName and comparePhaseNum are in
-// phase.test.cjs (which covers all edge cases: hybrid, letter-suffix,
-// multi-level decimal, case-insensitive, directory-slug, and full sort order).
-// Removed duplicates here to keep a single authoritative test location.
-
-// ─── pathExistsInternal ────────────────────────────────────────────────────────
-
-describe('pathExistsInternal', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('returns true for existing path', () => {
- assert.strictEqual(pathExistsInternal(tmpDir, '.planning'), true);
- });
-
- test('returns false for non-existing path', () => {
- assert.strictEqual(pathExistsInternal(tmpDir, 'nonexistent'), false);
- });
-
- test('handles absolute paths', () => {
- assert.strictEqual(pathExistsInternal(tmpDir, tmpDir), true);
- });
-});
-
-// ─── getMilestoneInfo ──────────────────────────────────────────────────────────
-
-describe('getMilestoneInfo', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('extracts version and name from roadmap', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '# Roadmap\n\n## Roadmap v1.2: My Cool Project\n\nSome content'
- );
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v1.2');
- assert.strictEqual(info.name, 'My Cool Project');
- });
-
- test('returns defaults when roadmap missing', () => {
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v1.0');
- assert.strictEqual(info.name, 'milestone');
- });
-
- test('returns active milestone when shipped milestone is collapsed in details block', () => {
- const roadmap = [
- '# Milestones',
- '',
- '| Version | Status |',
- '|---------|--------|',
- '| v0.1 | Shipped |',
- '| v0.2 | Active |',
- '',
- '',
- 'v0.1 — Legacy Feature Parity (Shipped)
',
- '',
- '## Roadmap v0.1: Legacy Feature Parity',
- '',
- '### Phase 1: Core Setup',
- 'Some content about phase 1',
- '',
- ' ',
- '',
- '## Roadmap v0.2: Dashboard Overhaul',
- '',
- '### Phase 8: New Dashboard Layout',
- 'Some content about phase 8',
- ].join('\n');
- fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v0.2');
- assert.strictEqual(info.name, 'Dashboard Overhaul');
- });
-
- test('returns active milestone when multiple shipped milestones exist in details blocks', () => {
- const roadmap = [
- '# Milestones',
- '',
- '| Version | Status |',
- '|---------|--------|',
- '| v0.1 | Shipped |',
- '| v0.2 | Shipped |',
- '| v0.3 | Active |',
- '',
- '',
- 'v0.1 — Initial Release (Shipped)
',
- '',
- '## Roadmap v0.1: Initial Release',
- '',
- ' ',
- '',
- '',
- 'v0.2 — Feature Expansion (Shipped)
',
- '',
- '## Roadmap v0.2: Feature Expansion',
- '',
- ' ',
- '',
- '## Roadmap v0.3: Performance Tuning',
- '',
- '### Phase 12: Optimize Queries',
- ].join('\n');
- fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v0.3');
- assert.strictEqual(info.name, 'Performance Tuning');
- });
-
- test('returns defaults when roadmap has no heading matches', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '# Roadmap\n\nSome content without version headings'
- );
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v1.0');
- assert.strictEqual(info.name, 'milestone');
- });
-
- // Bug #2409: getMilestoneInfo must prefer STATE.md milestone: field over regex matching
- test('uses STATE.md milestone frontmatter when 🚧 is inside tag without bold (bug #2409)', () => {
- // STATE.md says v2.9, ROADMAP has 🚧 v2.9 inside (not bolded) — no bold regex match
- const roadmap = [
- '# Milestones',
- '',
- '- ✅ v2.2 Old Features — shipped 2026-04-03',
- '- 🚧 v2.9 Full-Pass Verification',
- '',
- '',
- '🚧 v2.9 Full-Pass Verification & Bug Fixing — IN PROGRESS
',
- '',
- '## Roadmap v2.9: Full-Pass Verification & Bug Fixing',
- '',
- '### Phase 1: Verification',
- '',
- ' ',
- '',
- '## Phase Details (v2.2 — Old Features)',
- '',
- '### Phase 1: Old Stuff',
- ].join('\n');
- fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'STATE.md'),
- '---\nmilestone: v2.9\n---\n\n# State\n'
- );
-
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v2.9',
- 'should return v2.9 from STATE.md, not v2.2 from a stale heading match');
- assert.ok(info.name.includes('Full-Pass') || info.name.includes('Verification'),
- `name should reference the v2.9 milestone, got: "${info.name}"`);
- });
-
- test('STATE.md milestone takes precedence over first ## heading match (bug #2409)', () => {
- // ROADMAP with multiple ## headings — without STATE.md anchoring, first match wins
- const roadmap = [
- '## Phase Details (v1.5–v2.1)',
- '',
- '## Roadmap v2.2: Old Milestone',
- '',
- '## Roadmap v2.9: Current Milestone',
- '',
- '### Phase 1: Alpha',
- ].join('\n');
- fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'STATE.md'),
- '---\nmilestone: v2.9\n---\n\n# State\n'
- );
-
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v2.9',
- 'should read v2.9 from STATE.md, not v2.2 from first ## heading');
- assert.strictEqual(info.name, 'Current Milestone');
- });
-
- // Bug found in code review of PR #2458: stateVersion early-return doesn't check if shipped
- test('falls through to new active milestone when STATE.md version is already shipped (✅ heading)', () => {
- // STATE.md still says v1.0 (stale), but v1.0 is marked ✅ in ROADMAP.md.
- // getMilestoneInfo must NOT return v1.0; it must fall through and detect v2.0.
- const roadmap = [
- '## v1.0 ✅ Initial Release: Done',
- '',
- '### Phase 1: Setup',
- '',
- '## v2.0: Active Milestone',
- '',
- '### Phase 2: Build',
- ].join('\n');
- fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'STATE.md'),
- '---\nmilestone: v1.0\n---\n\n# State\n'
- );
-
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v2.0',
- 'should return v2.0 (active milestone), not v1.0 (stale shipped milestone from STATE.md)');
- assert.strictEqual(info.name, 'Active Milestone');
- });
-
- test('falls through when STATE.md version matches ✅ heading in alternate position formats', () => {
- // ✅ can appear before the version: ## ✅ v1.0 Old Name
- const roadmap = [
- '## ✅ v1.0 Old Name',
- '',
- '## v2.0: New Stuff',
- ].join('\n');
- fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'STATE.md'),
- '---\nmilestone: v1.0\n---\n\n# State\n'
- );
-
- const info = getMilestoneInfo(tmpDir);
- assert.strictEqual(info.version, 'v2.0',
- 'should return v2.0, not stale v1.0 with ✅ prefix in heading');
- assert.strictEqual(info.name, 'New Stuff');
- });
-});
-
-// ─── searchPhaseInDir ──────────────────────────────────────────────────────────
-
-describe('searchPhaseInDir', () => {
- let tmpDir;
- let phasesDir;
-
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-test-'));
- phasesDir = path.join(tmpDir, 'phases');
- fs.mkdirSync(phasesDir, { recursive: true });
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('finds phase directory by normalized prefix', () => {
- fs.mkdirSync(path.join(phasesDir, '01-foundation'));
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_number, '01');
- assert.strictEqual(result.phase_name, 'foundation');
- });
-
- test('returns plans and summaries', () => {
- const phaseDir = path.join(phasesDir, '01-foundation');
- fs.mkdirSync(phaseDir);
- fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
- fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
- assert.ok(result.plans.includes('01-01-PLAN.md'));
- assert.ok(result.summaries.includes('01-01-SUMMARY.md'));
- assert.strictEqual(result.incomplete_plans.length, 0);
- });
-
- test('identifies incomplete plans', () => {
- const phaseDir = path.join(phasesDir, '01-foundation');
- fs.mkdirSync(phaseDir);
- fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1');
- fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2');
- fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
- assert.strictEqual(result.incomplete_plans.length, 1);
- assert.ok(result.incomplete_plans.includes('01-02-PLAN.md'));
- });
-
- test('treats prefix summary as complete for descriptive plan filename (#3101)', () => {
- const phaseDir = path.join(phasesDir, '01-foundation');
- fs.mkdirSync(phaseDir);
- fs.writeFileSync(path.join(phaseDir, '01-01-auth-hardening-PLAN.md'), '# Plan 1');
- fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
-
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
- assert.strictEqual(result.incomplete_plans.length, 0);
- });
-
- test('detects research and context files', () => {
- const phaseDir = path.join(phasesDir, '01-foundation');
- fs.mkdirSync(phaseDir);
- fs.writeFileSync(path.join(phaseDir, '01-RESEARCH.md'), '# Research');
- fs.writeFileSync(path.join(phaseDir, '01-CONTEXT.md'), '# Context');
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
- assert.strictEqual(result.has_research, true);
- assert.strictEqual(result.has_context, true);
- });
-
- test('returns null when phase not found', () => {
- fs.mkdirSync(path.join(phasesDir, '01-foundation'));
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '99');
- assert.strictEqual(result, null);
- });
-
- test('generates phase_slug from directory name', () => {
- fs.mkdirSync(path.join(phasesDir, '01-core-cjs-tests'));
- const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
- assert.strictEqual(result.phase_slug, 'core-cjs-tests');
- });
-});
-
-// ─── findPhaseInternal ─────────────────────────────────────────────────────────
-
-describe('findPhaseInternal', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('finds phase in current phases directory', () => {
- fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'));
- const result = findPhaseInternal(tmpDir, '1');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_number, '01');
- });
-
- test('returns null for non-existent phase', () => {
- const result = findPhaseInternal(tmpDir, '99');
- assert.strictEqual(result, null);
- });
-
- test('returns null for null phase', () => {
- const result = findPhaseInternal(tmpDir, null);
- assert.strictEqual(result, null);
- });
-
- test('searches archived milestones when not in current', () => {
- // Create archived milestone structure (no current phase match)
- const archiveDir = path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation');
- fs.mkdirSync(archiveDir, { recursive: true });
- const result = findPhaseInternal(tmpDir, '1');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.archived, 'v1.0');
- });
-});
-
-// ─── getRoadmapPhaseInternal ───────────────────────────────────────────────────
-
-describe('getRoadmapPhaseInternal', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- // Bug: getRoadmapPhaseInternal was missing from module.exports
- test('is exported from core.cjs (REG-02)', () => {
- assert.strictEqual(typeof getRoadmapPhaseInternal, 'function');
- // Also verify it works with a real roadmap (note: goal regex expects **Goal:** with colon inside bold)
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 1: Foundation\n**Goal:** Build the base\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '1');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_name, 'Foundation');
- assert.strictEqual(result.goal, 'Build the base');
- });
-
- test('extracts phase name and goal from roadmap', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 2: API Layer\n**Goal:** Create REST endpoints\n**Depends on**: Phase 1\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '2');
- assert.strictEqual(result.phase_name, 'API Layer');
- assert.strictEqual(result.goal, 'Create REST endpoints');
- });
-
- test('returns goal when Goal uses colon-outside-bold format', () => {
- // **Goal**: (colon outside bold) is now supported alongside **Goal:**
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 1: Foundation\n**Goal**: Build the base\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '1');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_name, 'Foundation');
- assert.strictEqual(result.goal, 'Build the base');
- });
-
- test('returns null when roadmap missing', () => {
- const result = getRoadmapPhaseInternal(tmpDir, '1');
- assert.strictEqual(result, null);
- });
-
- test('returns null when phase not in roadmap', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 1: Foundation\n**Goal**: Build the base\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '99');
- assert.strictEqual(result, null);
- });
-
- test('returns null for null phase number', () => {
- const result = getRoadmapPhaseInternal(tmpDir, null);
- assert.strictEqual(result, null);
- });
-
- test('extracts full section text', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 1: Foundation\n**Goal**: Build the base\n**Requirements**: TEST-01\nSome details here\n\n### Phase 2: API\n**Goal**: REST\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '1');
- assert.ok(result.section.includes('Phase 1: Foundation'));
- assert.ok(result.section.includes('Some details here'));
- // Should not include Phase 2 content
- assert.ok(!result.section.includes('Phase 2: API'));
- });
-
- // Bug #2391: zero-padded phase numbers ("03") must match unpadded ROADMAP headings ("Phase 3:")
- test('matches zero-padded phase number against unpadded ROADMAP heading (bug #2391)', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 3: Rotation Engine\n**Goal**: Build rotation\n**Requirements**: ROTA-01\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '03');
- assert.ok(result !== null, 'should find the phase with zero-padded input "03"');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_name, 'Rotation Engine');
- assert.strictEqual(result.goal, 'Build rotation');
- });
-
- test('matches double-zero-padded phase number against unpadded ROADMAP heading (bug #2391)', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 7: Final\n**Goal**: Ship it\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '007');
- assert.ok(result !== null, 'should find the phase with "007"');
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_name, 'Final');
- });
-
- test('unpadded lookup still works after fix (regression check)', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '### Phase 3: Rotation Engine\n**Goal**: Build rotation\n'
- );
- const result = getRoadmapPhaseInternal(tmpDir, '3');
- assert.ok(result !== null);
- assert.strictEqual(result.found, true);
- assert.strictEqual(result.phase_name, 'Rotation Engine');
- });
-});
-
-// ─── getMilestonePhaseFilter ────────────────────────────────────────────────────
-
-describe('getMilestonePhaseFilter', () => {
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('filters directories to only current milestone phases', () => {
- // ROADMAP lists only phases 5-7
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- [
- '## Roadmap v2.0: Next Release',
- '',
- '### Phase 5: Auth',
- '**Goal:** Add authentication',
- '',
- '### Phase 6: Dashboard',
- '**Goal:** Build dashboard',
- '',
- '### Phase 7: Polish',
- '**Goal:** Final polish',
- ].join('\n')
- );
-
- // Create phase dirs 1-7 on disk (leftover from previous milestones)
- for (let i = 1; i <= 7; i++) {
- const padded = String(i).padStart(2, '0');
- fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`));
- }
-
- const filter = getMilestonePhaseFilter(tmpDir);
-
- // Only phases 5, 6, 7 should match
- assert.strictEqual(filter('05-auth'), true);
- assert.strictEqual(filter('06-dashboard'), true);
- assert.strictEqual(filter('07-polish'), true);
-
- // Phases 1-4 should NOT match
- assert.strictEqual(filter('01-phase-1'), false);
- assert.strictEqual(filter('02-phase-2'), false);
- assert.strictEqual(filter('03-phase-3'), false);
- assert.strictEqual(filter('04-phase-4'), false);
- });
-
- test('returns pass-all filter when ROADMAP.md is missing', () => {
- const filter = getMilestonePhaseFilter(tmpDir);
-
- assert.strictEqual(filter('01-foundation'), true);
- assert.strictEqual(filter('99-anything'), true);
- });
-
- test('returns pass-all filter when ROADMAP has no phase headings', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '# Roadmap\n\nSome content without phases.\n'
- );
-
- const filter = getMilestonePhaseFilter(tmpDir);
-
- assert.strictEqual(filter('01-foundation'), true);
- assert.strictEqual(filter('05-api'), true);
- });
-
- test('handles letter-suffix phases (e.g. 3A)', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '## Roadmap v1.0: Current\n\n### Phase 3A: Sub-feature\n**Goal:** Sub work\n'
- );
-
- const filter = getMilestonePhaseFilter(tmpDir);
-
- assert.strictEqual(filter('03A-sub-feature'), true);
- assert.strictEqual(filter('03-main'), false);
- assert.strictEqual(filter('04-other'), false);
- });
-
- test('handles decimal phases (e.g. 5.1)', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '## Roadmap v1.0: Current\n\n### Phase 5: Main\n**Goal:** Main work\n\n### Phase 5.1: Patch\n**Goal:** Patch work\n'
- );
-
- const filter = getMilestonePhaseFilter(tmpDir);
-
- assert.strictEqual(filter('05-main'), true);
- assert.strictEqual(filter('05.1-patch'), true);
- assert.strictEqual(filter('04-other'), false);
- });
-
- test('returns false for non-phase directory names', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '## Roadmap v1.0: Current\n\n### Phase 1: Init\n**Goal:** Start\n'
- );
-
- const filter = getMilestonePhaseFilter(tmpDir);
-
- assert.strictEqual(filter('not-a-phase'), false);
- assert.strictEqual(filter('.gitkeep'), false);
- });
-
- test('phaseCount reflects ROADMAP phase count', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '## Roadmap v1.0: Current\n\n### Phase 5: Auth\n### Phase 6: Dashboard\n### Phase 7: Polish\n'
- );
-
- const filter = getMilestonePhaseFilter(tmpDir);
- assert.strictEqual(filter.phaseCount, 3);
- });
-
- test('phaseCount is 0 when ROADMAP is missing', () => {
- const filter = getMilestonePhaseFilter(tmpDir);
- assert.strictEqual(filter.phaseCount, 0);
- });
-
- test('phaseCount is 0 when ROADMAP has no phase headings', () => {
- fs.writeFileSync(
- path.join(tmpDir, '.planning', 'ROADMAP.md'),
- '# Roadmap\n\nSome content.\n'
- );
-
- const filter = getMilestonePhaseFilter(tmpDir);
- assert.strictEqual(filter.phaseCount, 0);
- });
-});
-
-// ─── Stale hook filter regression (#1200) ─────────────────────────────────────
-
-describe('stale hook filter', () => {
- test('filter should only match gsd-prefixed .js files', () => {
- const files = [
- 'gsd-check-update.js',
- 'gsd-context-monitor.js',
- 'gsd-prompt-guard.js',
- 'gsd-statusline.js',
- 'gsd-workflow-guard.js',
- 'guard-edits-outside-project.js', // user hook
- 'my-custom-hook.js', // user hook
- 'gsd-check-update.js.bak', // backup file
- 'README.md', // non-js file
- ];
-
- const gsdFilter = f => f.startsWith('gsd-') && f.endsWith('.js');
- const filtered = files.filter(gsdFilter);
-
- assert.deepStrictEqual(filtered, [
- 'gsd-check-update.js',
- 'gsd-context-monitor.js',
- 'gsd-prompt-guard.js',
- 'gsd-statusline.js',
- 'gsd-workflow-guard.js',
- ], 'should only include gsd-prefixed .js files');
-
- assert.ok(!filtered.includes('guard-edits-outside-project.js'), 'must not include user hooks');
- assert.ok(!filtered.includes('my-custom-hook.js'), 'must not include non-gsd hooks');
- });
-});
-
-// ─── stale hook path regression (#1249) ──────────────────────────────────────
-
-describe('stale hook path', () => {
- test('gsd-check-update.js checks configDir/hooks/ where hooks are actually installed (#1421)', () => {
- // The stale-hook scan logic lives in the worker (moved from inline -e template literal).
- // The worker receives configDir via env and constructs the hooksDir path.
- const content = fs.readFileSync(
- path.join(__dirname, '..', 'hooks', 'gsd-check-update-worker.js'), 'utf-8'
- );
- // Hooks are installed at configDir/hooks/ (e.g. ~/.claude/hooks/),
- // not configDir/gsd-core/hooks/ which doesn't exist (#1421)
- assert.ok(
- content.includes("path.join(configDir, 'hooks')"),
- 'stale hook check must look in configDir/hooks/ where hooks are actually installed'
- );
- });
-});
-
-// ─── shared cache directory regression (#1421) ─────────────────────────────────
-
-describe('shared cache directory (#1421)', () => {
- test('gsd-check-update.js writes cache to shared ~/.cache/gsd/ directory', () => {
- const content = fs.readFileSync(
- path.join(__dirname, '..', 'hooks', 'gsd-check-update.js'), 'utf-8'
- );
- // Cache must use a tool-agnostic path so statusline can find it
- // regardless of which runtime (Claude, Gemini, OpenCode) ran the check
- assert.ok(
- content.includes("path.join(homeDir, '.cache', 'gsd')"),
- 'check-update must write cache to ~/.cache/gsd/ (shared, tool-agnostic)'
- );
- });
-
- test('gsd-statusline.js reads the per-package shared cache and rejects foreign lineage (#1421/#607)', () => {
- const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js');
- const { updateCacheFileName, PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs');
-
- // Per-package filename embeds the package identity — no generic fallback
- assert.strictEqual(
- updateCacheFileName,
- 'gsd-update-check-opengsd-gsd-core.json',
- 'updateCacheFileName must be the per-package filename'
- );
-
- // The statusline must NOT reference a legacyCacheFile — the legacy fallback was removed
- // allow-test-rule: architectural-invariant
- const statuslineSrc = fs.readFileSync(
- path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'), 'utf-8'
- );
- assert.ok(
- !statuslineSrc.includes('legacyCacheFile'),
- 'gsd-statusline.js must not reference legacyCacheFile — legacy fallback was removed in #607'
- );
- assert.ok(
- statuslineSrc.includes(updateCacheFileName) || statuslineSrc.includes('updateCacheFileName'),
- 'gsd-statusline.js must reference the per-package updateCacheFileName'
- );
-
- // evaluateUpdateCache: foreign package_name → no update shown
- assert.deepStrictEqual(
- evaluateUpdateCache({ package_name: 'other-package', update_available: true }),
- { showUpdate: false, staleWarning: 'none' },
- 'foreign package_name must be rejected (lineage guard)'
- );
-
- // evaluateUpdateCache: absent package_name → no update shown
- assert.deepStrictEqual(
- evaluateUpdateCache({ update_available: true }),
- { showUpdate: false, staleWarning: 'none' },
- 'absent package_name must be rejected (lineage guard)'
- );
-
- // evaluateUpdateCache: null cache → no update shown
- assert.deepStrictEqual(
- evaluateUpdateCache(null),
- { showUpdate: false, staleWarning: 'none' },
- 'null cache must return no-update'
- );
-
- // evaluateUpdateCache: matching package_name + update_available:true → show update
- const result = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: true });
- assert.strictEqual(result.showUpdate, true,
- 'matching package_name with update_available:true must set showUpdate=true'
- );
-
- // evaluateUpdateCache: matching package_name + update_available:false → no update
- const noUpdate = evaluateUpdateCache({ package_name: PACKAGE_NAME, update_available: false });
- assert.strictEqual(noUpdate.showUpdate, false,
- 'matching package_name with update_available:false must not show update'
- );
- });
-});
-
-// ─── resolveWorktreeRoot ─────────────────────────────────────────────────────
-
-describe('resolveWorktreeRoot', () => {
- const { resolveWorktreeRoot } = require('../gsd-core/bin/lib/core.cjs');
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('returns cwd when not in a git repo', () => {
- assert.strictEqual(resolveWorktreeRoot(tmpDir), tmpDir);
- });
-
- test('returns cwd in a normal git repo (not a worktree)', () => {
- const { execSync: execSyncLocal } = require('child_process');
- execSyncLocal('git init', { cwd: tmpDir, stdio: 'pipe' });
- assert.strictEqual(resolveWorktreeRoot(tmpDir), tmpDir);
- });
-});
-
-// ─── resolveWorktreeRoot — linked worktree with .planning/ (#1315) ───────────
-
-describe('resolveWorktreeRoot with linked worktree .planning/', () => {
- const { resolveWorktreeRoot } = require('../gsd-core/bin/lib/core.cjs');
- const { execSync: execSyncLocal } = require('child_process');
- // On Windows CI, os.tmpdir() may return 8.3 short paths (RUNNER~1) while
- // git returns long paths (runneradmin). realpathSync.native resolves both.
- const normalizePath = (p) => {
- try { return fs.realpathSync.native(p); } catch { return fs.realpathSync(p); }
- };
-
- let mainDir;
- let worktreeDir;
-
- function initBareGitRepo() {
- const dir = normalizePath(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-main-')));
- execSyncLocal('git init', { cwd: dir, stdio: 'pipe' });
- execSyncLocal('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
- execSyncLocal('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
- execSyncLocal('git config commit.gpgsign false', { cwd: dir, stdio: 'pipe' });
- fs.writeFileSync(path.join(dir, 'README.md'), '# Main');
- execSyncLocal('git add -A', { cwd: dir, stdio: 'pipe' });
- execSyncLocal('git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
- return dir;
- }
-
- beforeEach(() => {
- mainDir = initBareGitRepo();
- worktreeDir = null;
- });
-
- afterEach(() => {
- if (worktreeDir) {
- try { execSyncLocal(`git worktree remove "${worktreeDir}" --force`, { cwd: mainDir, stdio: 'pipe' }); } catch { /* ok */ }
- cleanup(worktreeDir);
- }
- cleanup(mainDir);
- });
-
- test('returns linked worktree cwd when it has its own .planning/', () => {
- // Add .planning/ to main repo
- fs.mkdirSync(path.join(mainDir, '.planning'), { recursive: true });
-
- // Create a linked worktree
- worktreeDir = normalizePath(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-linked-')));
- cleanup(worktreeDir);
- execSyncLocal(`git worktree add "${worktreeDir}" -b test-linked`, { cwd: mainDir, stdio: 'pipe' });
-
- // Give the linked worktree its own .planning/
- fs.mkdirSync(path.join(worktreeDir, '.planning'), { recursive: true });
-
- // resolveWorktreeRoot should return the linked worktree dir, not the main repo
- const result = normalizePath(resolveWorktreeRoot(worktreeDir));
- assert.strictEqual(result, worktreeDir,
- 'linked worktree with .planning/ should resolve to itself, not the main repo');
- });
-
- test('returns main repo root when linked worktree has no .planning/', () => {
- // Create a linked worktree (no .planning/ in main or worktree)
- worktreeDir = normalizePath(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-linked-')));
- cleanup(worktreeDir);
- execSyncLocal(`git worktree add "${worktreeDir}" -b test-linked-no-plan`, { cwd: mainDir, stdio: 'pipe' });
-
- // resolveWorktreeRoot should return the main repo root
- const result = normalizePath(resolveWorktreeRoot(worktreeDir));
- const expected = normalizePath(mainDir);
- assert.strictEqual(result, expected,
- 'linked worktree without .planning/ should resolve to main repo root');
- });
-});
-
-// ─── monorepo worktree CWD preservation (#1283) ─────────────────────────────
-
-describe('monorepo worktree CWD preservation', () => {
- const { resolveWorktreeRoot } = require('../gsd-core/bin/lib/core.cjs');
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-monorepo-wt-'));
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('CWD with .planning/ skips worktree resolution (monorepo subdirectory)', () => {
- const subDir = path.join(tmpDir, 'service-alpha');
- fs.mkdirSync(path.join(subDir, '.planning'), { recursive: true });
- let cwd = subDir;
- if (!fs.existsSync(path.join(cwd, '.planning'))) {
- const worktreeRoot = resolveWorktreeRoot(cwd);
- if (worktreeRoot !== cwd) cwd = worktreeRoot;
- }
- assert.strictEqual(cwd, subDir, 'CWD with .planning/ must not be overridden by worktree resolution');
- });
-
- test('CWD without .planning/ still goes through worktree resolution', () => {
- let cwd = tmpDir;
- let worktreeResolutionCalled = false;
- if (!fs.existsSync(path.join(cwd, '.planning'))) {
- worktreeResolutionCalled = true;
- const worktreeRoot = resolveWorktreeRoot(cwd);
- if (worktreeRoot !== cwd) cwd = worktreeRoot;
- }
- assert.ok(worktreeResolutionCalled, 'worktree resolution must be called when .planning/ is absent');
- });
-});
-
-// ─── withPlanningLock ────────────────────────────────────────────────────────
-
-describe('withPlanningLock', () => {
- const { withPlanningLock, planningDir } = require('../gsd-core/bin/lib/core.cjs');
- let tmpDir;
-
- beforeEach(() => {
- tmpDir = createTempProject();
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- test('executes function and returns result', () => {
- const result = withPlanningLock(tmpDir, () => 42);
- assert.strictEqual(result, 42);
- // Lock file should be cleaned up
- assert.ok(!fs.existsSync(path.join(planningDir(tmpDir), '.lock')));
- });
-
- test('cleans up lock file even on error', () => {
- assert.throws(() => {
- withPlanningLock(tmpDir, () => { throw new Error('test'); });
- }, /test/);
- assert.ok(!fs.existsSync(path.join(planningDir(tmpDir), '.lock')));
- });
-
- test('recovers from stale lock (>30s old)', () => {
- const lockPath = path.join(tmpDir, '.planning', '.lock');
- // Create a stale lock
- fs.writeFileSync(lockPath, '{"pid":99999}');
- // Backdate the lock file by 31 seconds
- const staleTime = new Date(Date.now() - 31000);
- fs.utimesSync(lockPath, staleTime, staleTime);
-
- const result = withPlanningLock(tmpDir, () => 'recovered');
- assert.strictEqual(result, 'recovered');
- });
-});
-
-// ─── detectSubRepos ──────────────────────────────────────────────────────────
-
-describe('detectSubRepos', () => {
- let projectRoot;
-
- beforeEach(() => {
- projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-detect-test-'));
- });
-
- afterEach(() => {
- cleanup(projectRoot);
- });
-
- test('returns empty array when no child directories have .git', () => {
- fs.mkdirSync(path.join(projectRoot, 'src'));
- fs.mkdirSync(path.join(projectRoot, 'lib'));
- assert.deepStrictEqual(detectSubRepos(projectRoot), []);
- });
-
- test('detects directories with .git', () => {
- fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'scripts')); // no .git
- assert.deepStrictEqual(detectSubRepos(projectRoot), ['backend', 'frontend']);
- });
-
- test('returns sorted results', () => {
- fs.mkdirSync(path.join(projectRoot, 'zeta', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'alpha', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'mid', '.git'), { recursive: true });
- assert.deepStrictEqual(detectSubRepos(projectRoot), ['alpha', 'mid', 'zeta']);
- });
-
- test('skips hidden directories', () => {
- fs.mkdirSync(path.join(projectRoot, '.hidden', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'visible', '.git'), { recursive: true });
- assert.deepStrictEqual(detectSubRepos(projectRoot), ['visible']);
- });
-
- test('skips node_modules', () => {
- fs.mkdirSync(path.join(projectRoot, 'node_modules', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'app', '.git'), { recursive: true });
- assert.deepStrictEqual(detectSubRepos(projectRoot), ['app']);
- });
-});
-
-// ─── loadConfig sub_repos auto-sync ──────────────────────────────────────────
-
-describe('loadConfig sub_repos auto-sync', () => {
- let projectRoot;
-
- beforeEach(() => {
- projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-sync-test-'));
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- });
-
- afterEach(() => {
- cleanup(projectRoot);
- });
-
- test('migrates multiRepo: true to sub_repos array', () => {
- // Create config with legacy multiRepo flag
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ multiRepo: true, model_profile: 'quality' })
- );
- // Create sub-repos
- fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
-
- const config = loadConfig(projectRoot);
- assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
- assert.strictEqual(config.commit_docs, false);
-
- // Verify config was persisted to the canonical location (planning.sub_repos per #2561/#2638)
- const saved = JSON.parse(fs.readFileSync(path.join(projectRoot, '.planning', 'config.json'), 'utf-8'));
- assert.deepStrictEqual(saved.planning?.sub_repos, ['backend', 'frontend']);
- assert.strictEqual(saved.sub_repos, undefined, 'top-level sub_repos should not be written (#2638)');
- assert.strictEqual(saved.multiRepo, undefined, 'multiRepo should be removed');
- });
-
- test('adds newly detected repos to sub_repos', () => {
- fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: ['backend'] })
- );
-
- // Add a new repo
- fs.mkdirSync(path.join(projectRoot, 'frontend', '.git'), { recursive: true });
-
- const config = loadConfig(projectRoot);
- assert.deepStrictEqual(config.sub_repos, ['backend', 'frontend']);
- });
-
- test('removes repos that no longer have .git', () => {
- fs.mkdirSync(path.join(projectRoot, 'backend', '.git'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: ['backend', 'old-repo'] })
- );
-
- const config = loadConfig(projectRoot);
- assert.deepStrictEqual(config.sub_repos, ['backend']);
- });
-
- test('does not sync when sub_repos is empty and no repos detected', () => {
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: [] })
- );
-
- const config = loadConfig(projectRoot);
- assert.deepStrictEqual(config.sub_repos, []);
- });
-});
-
-// ─── findProjectRoot ─────────────────────────────────────────────────────────
-
-describe('findProjectRoot', () => {
- let projectRoot;
-
- beforeEach(() => {
- projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-root-test-'));
- });
-
- afterEach(() => {
- cleanup(projectRoot);
- });
-
- test('returns startDir when no .planning/ exists anywhere', () => {
- const subDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(subDir);
- assert.strictEqual(findProjectRoot(subDir), subDir);
- });
-
- test('returns startDir when .planning/ is in startDir itself', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- assert.strictEqual(findProjectRoot(projectRoot), projectRoot);
- });
-
- test('walks up to parent with .planning/ and sub_repos config listing this dir', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: ['backend', 'frontend'] })
- );
-
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(backendDir);
-
- assert.strictEqual(findProjectRoot(backendDir), projectRoot);
- });
-
- test('walks up from nested sub-repo subdirectory', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: ['backend', 'frontend'] })
- );
-
- const deepDir = path.join(projectRoot, 'backend', 'src', 'services');
- fs.mkdirSync(deepDir, { recursive: true });
-
- assert.strictEqual(findProjectRoot(deepDir), projectRoot);
- });
-
- test('walks up via legacy multiRepo flag', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ multiRepo: true })
- );
-
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
-
- assert.strictEqual(findProjectRoot(backendDir), projectRoot);
- });
-
- test('walks up via .git heuristic when no config exists', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- // No config.json at all
-
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
-
- assert.strictEqual(findProjectRoot(backendDir), projectRoot);
- });
-
- test('walks up from nested path inside sub-repo via .git heuristic', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
-
- // Sub-repo with .git at its root
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
-
- // Nested path deep inside the sub-repo
- const nestedDir = path.join(backendDir, 'src', 'modules', 'auth');
- fs.mkdirSync(nestedDir, { recursive: true });
-
- // isInsideGitRepo walks up and finds backend/.git
- assert.strictEqual(findProjectRoot(nestedDir), projectRoot);
- });
-
- test('walks up from nested path inside sub-repo via sub_repos config', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: ['backend'] })
- );
-
- // Nested path deep inside the sub-repo
- const nestedDir = path.join(projectRoot, 'backend', 'src', 'modules');
- fs.mkdirSync(nestedDir, { recursive: true });
-
- // With sub_repos config, it checks topSegment of relative path
- assert.strictEqual(findProjectRoot(nestedDir), projectRoot);
- });
-
- test('walks up from nested path via legacy multiRepo flag', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ multiRepo: true })
- );
-
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(path.join(backendDir, '.git'), { recursive: true });
-
- // Nested inside sub-repo — isInsideGitRepo walks up and finds backend/.git
- const nestedDir = path.join(backendDir, 'src');
- fs.mkdirSync(nestedDir, { recursive: true });
-
- assert.strictEqual(findProjectRoot(nestedDir), projectRoot);
- });
-
- test('does not walk up for dirs without .git when no sub_repos config', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
-
- const scriptsDir = path.join(projectRoot, 'scripts');
- fs.mkdirSync(scriptsDir);
-
- assert.strictEqual(findProjectRoot(scriptsDir), scriptsDir);
- });
-
- test('handles planning.sub_repos nested config format', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ planning: { sub_repos: ['backend'] } })
- );
-
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(backendDir);
-
- assert.strictEqual(findProjectRoot(backendDir), projectRoot);
- });
-
- test('returns startDir when sub_repos is empty and no .git', () => {
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.writeFileSync(
- path.join(projectRoot, '.planning', 'config.json'),
- JSON.stringify({ sub_repos: [] })
- );
-
- const backendDir = path.join(projectRoot, 'backend');
- fs.mkdirSync(backendDir);
-
- assert.strictEqual(findProjectRoot(backendDir), backendDir);
- });
-
- test('walks up from subdirectory when .git is at same level as .planning/ (single-repo)', () => {
- // Common single-repo layout: .git and .planning are siblings at project root
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
-
- // User cwd is a subdirectory (e.g., src/)
- const srcDir = path.join(projectRoot, 'src');
- fs.mkdirSync(srcDir, { recursive: true });
-
- // Should detect that parent has .planning/ and .git is at that same level
- assert.strictEqual(findProjectRoot(srcDir), projectRoot);
- });
-
- test('walks up from deep subdirectory when .git is at same level as .planning/', () => {
- // Single-repo: .git and .planning at root, cwd deep inside
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
-
- const deepDir = path.join(projectRoot, 'src', 'lib', 'utils');
- fs.mkdirSync(deepDir, { recursive: true });
-
- assert.strictEqual(findProjectRoot(deepDir), projectRoot);
- });
-
- test('returns startDir when .planning exists at same level (cwd is project root)', () => {
- // User is already at project root — no parent to walk up to
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
- fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
-
- assert.strictEqual(findProjectRoot(projectRoot), projectRoot);
- });
-
- test('does not walk past child with own .planning/ to workspace parent (#1362)', () => {
- // Workspace layout: parent has .planning/, child git repo also has .planning/
- // findProjectRoot should return the child (startDir), not the parent
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
-
- const childRepo = path.join(projectRoot, 'authenticator');
- fs.mkdirSync(path.join(childRepo, '.planning'), { recursive: true });
- fs.mkdirSync(path.join(childRepo, '.git'), { recursive: true });
-
- assert.strictEqual(findProjectRoot(childRepo), childRepo);
- });
-
- test('does not walk past nested dir whose git root has .planning/ (#1362)', () => {
- // Workspace layout: parent has .planning/, child git repo also has .planning/
- // cwd is deep inside child — should resolve to child root, not workspace root
- fs.mkdirSync(path.join(projectRoot, '.planning'), { recursive: true });
-
- const childRepo = path.join(projectRoot, 'authenticator');
- fs.mkdirSync(path.join(childRepo, '.planning'), { recursive: true });
- fs.mkdirSync(path.join(childRepo, '.git'), { recursive: true });
-
- const deepDir = path.join(childRepo, 'src', 'lib');
- fs.mkdirSync(deepDir, { recursive: true });
-
- assert.strictEqual(findProjectRoot(deepDir), childRepo);
- });
-});
-
-// ─── reapStaleTempFiles ─────────────────────────────────────────────────────
-//
-// Isolation strategy: reapStaleTempFiles always scans the shared GSD_TEMP_DIR
-// (os.tmpdir()/gsd) and has no { dir } option, so we cannot redirect it to a
-// per-test directory. Instead we isolate via a per-test unique prefix that
-// embeds a random hex token, guaranteeing no two concurrent test workers can
-// share the same prefix. Each test records every path it creates in `created`
-// and afterEach removes them unconditionally so a failed assertion cannot leak
-// files into a sibling test's prefix scan.
-
-describe('reapStaleTempFiles', () => {
- const gsdTmpDir = path.join(os.tmpdir(), 'gsd');
-
- // Unique token per describe-run so parallel test files never collide.
- const runToken = Math.random().toString(36).slice(2, 10);
-
- /** Paths created by the current test; cleaned up in afterEach. */
- let created = [];
- /** Per-test prefix derived from runToken + sequential counter. */
- let testPrefix;
- let testCounter = 0;
-
- beforeEach(() => {
- created = [];
- testCounter += 1;
- testPrefix = `gsd-core-reap-${runToken}-${testCounter}-`;
- fs.mkdirSync(gsdTmpDir, { recursive: true });
- });
-
- afterEach(() => {
- for (const p of created) {
- try {
- // eslint-disable-next-line local/no-raw-rmsync-in-tests -- afterEach cleanup of per-test fixture paths tracked in `created`
- fs.rmSync(p, { recursive: true, force: true });
- } catch {
- // Best-effort: already removed by the SUT or a prior cleanup
- }
- }
- created = [];
- });
-
- test('removes stale gsd-*.json files older than maxAgeMs', () => {
- const stalePath = path.join(gsdTmpDir, `${testPrefix}stale.json`);
- fs.writeFileSync(stalePath, '{}');
- created.push(stalePath); // guard against assertion failure leaking the file
- // Set mtime to 10 minutes ago so it exceeds the 5-minute maxAgeMs
- const oldTime = new Date(Date.now() - 10 * 60 * 1000);
- fs.utimesSync(stalePath, oldTime, oldTime);
-
- reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
-
- assert.ok(!fs.existsSync(stalePath), 'stale file should be removed by reapStaleTempFiles');
- });
-
- test('preserves fresh gsd-*.json files within maxAgeMs', () => {
- const freshPath = path.join(gsdTmpDir, `${testPrefix}fresh.json`);
- fs.writeFileSync(freshPath, '{}');
- created.push(freshPath); // afterEach will clean up regardless of assertion outcome
-
- reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
-
- assert.ok(fs.existsSync(freshPath), 'fresh file should be preserved by reapStaleTempFiles');
- });
-
- test('removes stale temp directories when present', () => {
- const staleDir = path.join(gsdTmpDir, `${testPrefix}dir`);
- fs.mkdirSync(staleDir, { recursive: true });
- fs.writeFileSync(path.join(staleDir, 'data.jsonl'), 'test');
- created.push(staleDir); // guard against assertion failure leaking the dir
- // Set mtime to 10 minutes ago so it exceeds the 5-minute maxAgeMs
- const oldTime = new Date(Date.now() - 10 * 60 * 1000);
- fs.utimesSync(staleDir, oldTime, oldTime);
-
- reapStaleTempFiles(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
-
- assert.ok(!fs.existsSync(staleDir), 'stale directory should be removed by reapStaleTempFiles');
- });
-
- test('does not throw when no entries match the prefix', () => {
- // Use a prefix that is guaranteed to match nothing (unique nonce appended)
- const absentPrefix = `gsd-core-reap-absent-${runToken}-`;
- assert.doesNotThrow(() => {
- reapStaleTempFiles(absentPrefix, { maxAgeMs: 0 });
- });
- });
-});
-
-// ─── planningDir ──────────────────────────────────────────────────────────────
-
-describe('planningDir', () => {
- const cwd = '/fake/repo';
- let savedProject, savedWorkstream;
-
- beforeEach(() => {
- savedProject = process.env.GSD_PROJECT;
- savedWorkstream = process.env.GSD_WORKSTREAM;
- delete process.env.GSD_PROJECT;
- delete process.env.GSD_WORKSTREAM;
- });
-
- afterEach(() => {
- if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
- else delete process.env.GSD_PROJECT;
- if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
- else delete process.env.GSD_WORKSTREAM;
- });
-
- test('returns .planning/ when neither project nor workstream is set', () => {
- const result = planningDir(cwd, null, null);
- assert.strictEqual(result, path.join(cwd, '.planning'));
- });
-
- test('returns .planning/{project}/ when project is set', () => {
- const result = planningDir(cwd, null, 'my-app');
- assert.strictEqual(result, path.join(cwd, '.planning', 'my-app'));
- });
-
- test('returns .planning/workstreams/{ws}/ when workstream is set', () => {
- const result = planningDir(cwd, 'feature-x', null);
- assert.strictEqual(result, path.join(cwd, '.planning', 'workstreams', 'feature-x'));
- });
-
- test('returns .planning/{project}/workstreams/{ws}/ when both are set', () => {
- const result = planningDir(cwd, 'feature-x', 'my-app');
- assert.strictEqual(result, path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'));
- });
-
- test('reads GSD_PROJECT from env when project param is undefined', () => {
- process.env.GSD_PROJECT = 'env-project';
- const result = planningDir(cwd);
- assert.strictEqual(result, path.join(cwd, '.planning', 'env-project'));
- });
-
- test('rejects path traversal in project name', () => {
- assert.throws(
- () => planningDir(cwd, null, '../../etc'),
- /invalid path characters/
- );
- });
-
- test('rejects forward slash in project name', () => {
- assert.throws(
- () => planningDir(cwd, null, 'foo/bar'),
- /invalid path characters/
- );
- });
-
- test('rejects backslash in project name', () => {
- assert.throws(
- () => planningDir(cwd, null, 'foo\\bar'),
- /invalid path characters/
- );
- });
-
- test('rejects path traversal in workstream name', () => {
- assert.throws(
- () => planningDir(cwd, '../../../tmp', null),
- /invalid path characters/
- );
- });
-});
-
-// ─── timeAgo ──────────────────────────────────────────────────────────────────
-
-describe('timeAgo', () => {
- const now = () => Date.now();
- const dateAt = (msAgo) => new Date(now() - msAgo);
-
- // ─── seconds boundary ───
- test('returns "just now" for dates under 5 seconds old', () => {
- assert.strictEqual(timeAgo(dateAt(0)), 'just now');
- assert.strictEqual(timeAgo(dateAt(4_000)), 'just now');
- });
-
- test('returns "N seconds ago" between 5 and 59 seconds', () => {
- assert.strictEqual(timeAgo(dateAt(5_000)), '5 seconds ago');
- assert.strictEqual(timeAgo(dateAt(30_000)), '30 seconds ago');
- assert.strictEqual(timeAgo(dateAt(59_000)), '59 seconds ago');
- });
-
- // ─── minutes boundary ───
- test('transitions to minutes at 60 seconds', () => {
- assert.strictEqual(timeAgo(dateAt(60_000)), '1 minute ago');
- });
-
- test('uses singular "1 minute ago" for exactly one minute', () => {
- assert.strictEqual(timeAgo(dateAt(60_000)), '1 minute ago');
- assert.strictEqual(timeAgo(dateAt(119_000)), '1 minute ago');
- });
-
- test('uses plural "N minutes ago" for 2-59 minutes', () => {
- assert.strictEqual(timeAgo(dateAt(120_000)), '2 minutes ago');
- assert.strictEqual(timeAgo(dateAt(5 * 60_000)), '5 minutes ago');
- assert.strictEqual(timeAgo(dateAt(59 * 60_000)), '59 minutes ago');
- });
-
- // ─── hours boundary ───
- test('transitions to hours at 60 minutes', () => {
- assert.strictEqual(timeAgo(dateAt(60 * 60_000)), '1 hour ago');
- });
-
- test('uses singular "1 hour ago" for exactly one hour', () => {
- assert.strictEqual(timeAgo(dateAt(60 * 60_000)), '1 hour ago');
- assert.strictEqual(timeAgo(dateAt(119 * 60_000)), '1 hour ago');
- });
-
- test('uses plural "N hours ago" for 2-23 hours', () => {
- assert.strictEqual(timeAgo(dateAt(2 * 3600_000)), '2 hours ago');
- assert.strictEqual(timeAgo(dateAt(23 * 3600_000)), '23 hours ago');
- });
-
- // ─── days boundary ───
- test('transitions to days at 24 hours', () => {
- assert.strictEqual(timeAgo(dateAt(24 * 3600_000)), '1 day ago');
- });
-
- test('uses singular "1 day ago" for exactly one day', () => {
- assert.strictEqual(timeAgo(dateAt(24 * 3600_000)), '1 day ago');
- });
-
- test('uses plural "N days ago" for 2-29 days', () => {
- assert.strictEqual(timeAgo(dateAt(2 * 86400_000)), '2 days ago');
- assert.strictEqual(timeAgo(dateAt(29 * 86400_000)), '29 days ago');
- });
-
- // ─── months boundary ───
- test('transitions to months at 30 days', () => {
- assert.strictEqual(timeAgo(dateAt(30 * 86400_000)), '1 month ago');
- });
-
- test('uses singular "1 month ago" for exactly one month (30 days)', () => {
- assert.strictEqual(timeAgo(dateAt(30 * 86400_000)), '1 month ago');
- assert.strictEqual(timeAgo(dateAt(59 * 86400_000)), '1 month ago');
- });
-
- test('uses plural "N months ago" for 2-11 months', () => {
- assert.strictEqual(timeAgo(dateAt(60 * 86400_000)), '2 months ago');
- assert.strictEqual(timeAgo(dateAt(180 * 86400_000)), '6 months ago');
- });
-
- // ─── years boundary ───
- test('transitions to years at 365 days', () => {
- assert.strictEqual(timeAgo(dateAt(365 * 86400_000)), '1 year ago');
- });
-
- test('uses singular "1 year ago" for exactly one year', () => {
- assert.strictEqual(timeAgo(dateAt(365 * 86400_000)), '1 year ago');
- });
-
- test('uses plural "N years ago" for 2+ years', () => {
- assert.strictEqual(timeAgo(dateAt(2 * 365 * 86400_000)), '2 years ago');
- assert.strictEqual(timeAgo(dateAt(10 * 365 * 86400_000)), '10 years ago');
- });
-
- // ─── edge cases ───
- test('handles future dates as "just now" (negative elapsed)', () => {
- // A date 5 seconds in the future has negative elapsed time, which floors to a negative
- // number of seconds and hits the "under 5 seconds" branch.
- assert.strictEqual(timeAgo(new Date(Date.now() + 5_000)), 'just now');
- });
-});
diff --git a/tests/defaults-json-fallback.test.cjs b/tests/defaults-json-fallback.test.cjs
index 1c22e9638..4cfec0a2f 100644
--- a/tests/defaults-json-fallback.test.cjs
+++ b/tests/defaults-json-fallback.test.cjs
@@ -13,7 +13,7 @@ const path = require('path');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
-const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
/** Create a bare temp dir (no .planning/) to simulate pre-project context */
function createBareTmpDir() {
diff --git a/tests/eslint-rules.test.cjs b/tests/eslint-rules.test.cjs
index 816a00f4a..2a4d82253 100644
--- a/tests/eslint-rules.test.cjs
+++ b/tests/eslint-rules.test.cjs
@@ -120,7 +120,7 @@ describe('no-source-grep rule', () => {
valid: [
{
code: `
- const mod = require('../gsd-core/bin/lib/core.cjs');
+ const mod = require('../gsd-core/bin/lib/io.cjs');
mod.someMethod();
`,
filename: 'tests/foo.test.cjs',
diff --git a/tests/feat-3023-model-phase-types.test.cjs b/tests/feat-3023-model-phase-types.test.cjs
index 53d4c365a..da19dcc1f 100644
--- a/tests/feat-3023-model-phase-types.test.cjs
+++ b/tests/feat-3023-model-phase-types.test.cjs
@@ -27,7 +27,7 @@ const path = require('node:path');
const {
resolveModelInternal,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
const {
AGENT_TO_PHASE_TYPE,
VALID_PHASE_TYPES,
@@ -254,7 +254,7 @@ describe('#3023 resolver: models. overrides profile-based tier', ()
// ─── #443 Unified effort: resolveEffortInternal + renderEffortForRuntime ────
-const { resolveEffortInternal } = require('../gsd-core/bin/lib/core.cjs');
+const { resolveEffortInternal } = require('../gsd-core/bin/lib/model-resolver.cjs');
const { renderEffortForRuntime } = require('../gsd-core/bin/lib/model-catalog.cjs');
describe('#3023 + #443: unified effort resolver (resolveEffortInternal) for Codex', () => {
diff --git a/tests/feat-3024-dynamic-routing.test.cjs b/tests/feat-3024-dynamic-routing.test.cjs
index 86ce52fc1..31a1be68f 100644
--- a/tests/feat-3024-dynamic-routing.test.cjs
+++ b/tests/feat-3024-dynamic-routing.test.cjs
@@ -49,7 +49,7 @@ const path = require('node:path');
const {
resolveModelInternal,
resolveModelForTier,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
const {
AGENT_DEFAULT_TIERS,
VALID_AGENT_TIERS,
diff --git a/tests/feat-443-effort-fast-mode.integration.test.cjs b/tests/feat-443-effort-fast-mode.integration.test.cjs
index d4c9aa5b5..12088f9d4 100644
--- a/tests/feat-443-effort-fast-mode.integration.test.cjs
+++ b/tests/feat-443-effort-fast-mode.integration.test.cjs
@@ -52,7 +52,7 @@ const {
resolveFastModeInternal,
resolveEffortForTier,
VALID_EFFORTS,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
const {
renderEffortForRuntime,
diff --git a/tests/feat-443-effort-fast-mode.test.cjs b/tests/feat-443-effort-fast-mode.test.cjs
index d9f56a83f..b9a4f8866 100644
--- a/tests/feat-443-effort-fast-mode.test.cjs
+++ b/tests/feat-443-effort-fast-mode.test.cjs
@@ -25,7 +25,7 @@ const {
resolveEffortInternal,
resolveFastModeInternal,
resolveEffortForTier,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
const {
renderEffortForRuntime,
diff --git a/tests/feat-49-model-policy-presets.test.cjs b/tests/feat-49-model-policy-presets.test.cjs
index 5d43c07ae..29ac95d1f 100644
--- a/tests/feat-49-model-policy-presets.test.cjs
+++ b/tests/feat-49-model-policy-presets.test.cjs
@@ -63,8 +63,10 @@ const {
resolveModelInternal,
resolveModelPolicy,
resolveModelForTier,
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
+const {
KNOWN_PROVIDERS,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-catalog.cjs');
// KNOWN_PROVIDERS must also be exported directly from model-catalog.cjs
const modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs');
diff --git a/tests/feat-68-per-phase-granularity.test.cjs b/tests/feat-68-per-phase-granularity.test.cjs
index 9159ee50e..5acfcf4d2 100644
--- a/tests/feat-68-per-phase-granularity.test.cjs
+++ b/tests/feat-68-per-phase-granularity.test.cjs
@@ -30,7 +30,7 @@ const path = require('node:path');
const {
resolveGranularityInternal,
VALID_GRANULARITIES,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
const commands = require('../gsd-core/bin/lib/commands.cjs');
const {
VALID_PHASE_TYPES,
diff --git a/tests/io.test.cjs b/tests/io.test.cjs
index 8663ac825..67199cccf 100644
--- a/tests/io.test.cjs
+++ b/tests/io.test.cjs
@@ -19,7 +19,6 @@ const os = require('node:os');
const fs = require('node:fs');
const io = require('../gsd-core/bin/lib/io.cjs');
-const core = require('../gsd-core/bin/lib/core.cjs');
// ─── ERROR_REASON constants ───────────────────────────────────────────────────
@@ -308,37 +307,6 @@ describe('reapStaleTempFiles (via io)', () => {
});
});
-// ─── core.cjs re-export shim parity ──────────────────────────────────────────
-
-describe('core.cjs re-export shims', () => {
- test('core.output is the same function as io.output', () => {
- assert.strictEqual(core.output, io.output);
- });
-
- test('core.error is the same function as io.error', () => {
- assert.strictEqual(core.error, io.error);
- });
-
- test('core.ERROR_REASON is the same object as io.ERROR_REASON', () => {
- assert.strictEqual(core.ERROR_REASON, io.ERROR_REASON);
- });
-
- test('core.setJsonErrorMode is the same function as io.setJsonErrorMode', () => {
- assert.strictEqual(core.setJsonErrorMode, io.setJsonErrorMode);
- });
-
- test('core.getJsonErrorMode is the same function as io.getJsonErrorMode', () => {
- assert.strictEqual(core.getJsonErrorMode, io.getJsonErrorMode);
- });
-
- test('core.reapStaleTempFiles is the same function as io.reapStaleTempFiles', () => {
- assert.strictEqual(core.reapStaleTempFiles, io.reapStaleTempFiles);
- });
-
- test('core.GSD_TEMP_DIR is the same value as io.GSD_TEMP_DIR', () => {
- assert.strictEqual(core.GSD_TEMP_DIR, io.GSD_TEMP_DIR);
- });
-});
// ─── bug #1008: output()/error() tolerate a full / slow non-blocking pipe ─────
//
diff --git a/tests/issue-2517-runtime-aware-profiles.test.cjs b/tests/issue-2517-runtime-aware-profiles.test.cjs
index 48111e792..d5528f8a4 100644
--- a/tests/issue-2517-runtime-aware-profiles.test.cjs
+++ b/tests/issue-2517-runtime-aware-profiles.test.cjs
@@ -36,9 +36,11 @@ const {
resolveModelInternal,
resolveEffortInternal,
resolveTierEntry,
+} = require('../gsd-core/bin/lib/model-resolver.cjs');
+const {
RUNTIME_PROFILE_MAP,
KNOWN_RUNTIMES,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/model-catalog.cjs');
const { renderEffortForRuntime } = require('../gsd-core/bin/lib/model-catalog.cjs');
const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
@@ -471,7 +473,7 @@ describe('issue #2517: VALID_CONFIG_KEYS schema', () => {
// ─── loadConfig validation warnings (review findings #10, #13) ──────────────
describe('issue #2517: loadConfig warns on unknown runtime/tier (findings #10, #13)', () => {
- const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+ const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
let tmpDir;
let origWrite;
let captured;
diff --git a/tests/lint-core-spine-imports.test.cjs b/tests/lint-core-spine-imports.test.cjs
deleted file mode 100644
index 67585602f..000000000
--- a/tests/lint-core-spine-imports.test.cjs
+++ /dev/null
@@ -1,279 +0,0 @@
-'use strict';
-process.env.GSD_TEST_MODE = '1';
-
-/**
- * Tests for scripts/lint-core-spine-imports.cjs (issue #1268).
- *
- * Behavioural, no source-grep. Asserts on structured return values from the
- * exported pure functions and on process exit codes from the CLI.
- */
-
-const { test, describe, afterEach } = require('node:test');
-const assert = require('node:assert/strict');
-const fs = require('node:fs');
-const path = require('node:path');
-const { spawnSync } = require('node:child_process');
-
-const { createTempDir, cleanup } = require('./helpers.cjs');
-
-const ROOT = path.join(__dirname, '..');
-const SCRIPT_PATH = path.join(ROOT, 'scripts', 'lint-core-spine-imports.cjs');
-const ALLOWLIST_PATH = path.join(ROOT, 'scripts', 'lint-core-spine-imports.allowlist.json');
-
-const { scanCoreSpineImports, loadAllowlist } = require(SCRIPT_PATH);
-
-// ---------------------------------------------------------------------------
-// Pure-function tests
-// ---------------------------------------------------------------------------
-
-describe('lint-core-spine-imports: scanCoreSpineImports (pure)', () => {
- let tmpDir;
-
- afterEach(() => {
- cleanup(tmpDir);
- tmpDir = undefined;
- });
-
- test('happy path: allowlisted importer is not a violation', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- // File that imports the core spine
- const importerPath = path.join(tmpDir, 'importer.cts');
- fs.writeFileSync(importerPath, [
- "import core = require('./core.cjs');",
- 'export = { x: 1 };',
- ].join('\n'), 'utf8');
-
- // File that does NOT import the core spine
- const cleanPath = path.join(tmpDir, 'clean.cts');
- fs.writeFileSync(cleanPath, [
- "import io = require('./io.cjs');",
- 'export = { y: 2 };',
- ].join('\n'), 'utf8');
-
- // Allowlist contains the importer's repo-relative path
- const importerRel = path.relative(ROOT, importerPath).replace(/\\/g, '/');
- const allowlistSet = new Set([importerRel]);
-
- const violations = scanCoreSpineImports([tmpDir], allowlistSet);
- assert.deepEqual(violations, []);
- });
-
- test('violation path: non-allowlisted importer is returned with correct file and 1-based line', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- // File with a blank first line, then the core import on line 2
- const importerPath = path.join(tmpDir, 'new-squatter.cts');
- fs.writeFileSync(importerPath, [
- '// a comment',
- "import core = require('./core.cjs');",
- 'export = {};',
- ].join('\n'), 'utf8');
-
- // Empty allowlist — importer is NOT allowed
- const violations = scanCoreSpineImports([tmpDir], new Set());
-
- assert.equal(violations.length, 1, 'expected exactly one violation');
- const v = violations[0];
- // File path is repo-relative POSIX
- const expectedRel = path.relative(ROOT, importerPath).replace(/\\/g, '/');
- assert.equal(v.file, expectedRel);
- // Line 2 (1-based): the second line contains the require
- assert.equal(v.line, 2);
- });
-
- test('require("../lib/core.cjs") form is detected', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'leaf.cjs');
- fs.writeFileSync(f, 'const core = require("../lib/core.cjs");\n', 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.equal(violations.length, 1);
- assert.equal(violations[0].line, 1);
- });
-
- test('require("./lib/core.cjs") form is detected', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'leaf.cjs');
- fs.writeFileSync(f, 'const core = require("./lib/core.cjs");\n', 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.equal(violations.length, 1);
- });
-
- test('clean file (no spine import) is never a violation', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'pure.cts');
- fs.writeFileSync(f, [
- "import io = require('./io.cjs');",
- "import roadmap = require('./roadmap-parser.cjs');",
- ].join('\n'), 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.deepEqual(violations, []);
- });
-
- test('commented-out core require is NOT a violation', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'commented.cts');
- fs.writeFileSync(f, [
- "// import core = require('./core.cjs');",
- "// const core = require('../lib/core.cjs');",
- "import io = require('./io.cjs');",
- ].join('\n'), 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.deepEqual(violations, [], 'commented-out requires should not count as violations');
- });
-
- test('unconventional relative path ../lib/core.cjs IS detected', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'deep-leaf.cjs');
- fs.writeFileSync(f, "const core = require('../lib/core.cjs');\n", 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.equal(violations.length, 1, 'expected exactly one violation for ../lib/core.cjs');
- assert.equal(violations[0].line, 1);
- });
-
- test('core-utils.cjs and core-schema.cjs are NOT detected as core spine imports', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'sibling.cts');
- fs.writeFileSync(f, [
- "import coreUtils = require('./core-utils.cjs');",
- "import coreSchema = require('./core-schema.cjs');",
- ].join('\n'), 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.deepEqual(violations, [], 'core-utils.cjs and core-schema.cjs should not match');
- });
-
- test('dynamic import("./core.cjs") form is detected', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'dyn-importer.cjs');
- fs.writeFileSync(f, [
- '// dynamic import form',
- "const core = await import('./core.cjs');",
- ].join('\n'), 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.equal(violations.length, 1, 'expected exactly one violation for dynamic import');
- assert.equal(violations[0].line, 2);
- });
-
- test('bare side-effect import \'./core.cjs\' form is detected', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'side-effect-importer.cjs');
- fs.writeFileSync(f, [
- '// bare side-effect import form',
- "import './core.cjs';",
- ].join('\n'), 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.equal(violations.length, 1, 'expected exactly one violation for bare side-effect import');
- assert.equal(violations[0].line, 2);
- });
-
- test('TS import x = require("./core.cjs") form is still flagged', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'ts-require.cts');
- fs.writeFileSync(f, "import core = require('./core.cjs');\n", 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.equal(violations.length, 1, 'TS import = require() should still be flagged');
- assert.equal(violations[0].line, 1);
- });
-
- test('import of core-utils.cjs (sibling) is NOT flagged even with new alternations', () => {
- tmpDir = createTempDir('lint-spine-test-');
-
- const f = path.join(tmpDir, 'sibling-check.cjs');
- fs.writeFileSync(f, [
- "const u = require('./core-utils.cjs');",
- "const cu = await import('./core-utils.cjs');",
- "import './core-utils.cjs';",
- ].join('\n'), 'utf8');
-
- const violations = scanCoreSpineImports([tmpDir], new Set());
- assert.deepEqual(violations, [], 'core-utils.cjs imports should not match even with new alternations');
- });
-});
-
-// ---------------------------------------------------------------------------
-// loadAllowlist
-// ---------------------------------------------------------------------------
-
-describe('lint-core-spine-imports: loadAllowlist', () => {
- let tmpDir;
-
- afterEach(() => {
- cleanup(tmpDir);
- tmpDir = undefined;
- });
-
- test('returns a Set of the allow entries', () => {
- tmpDir = createTempDir('lint-spine-al-');
- const jsonPath = path.join(tmpDir, 'allowlist.json');
- fs.writeFileSync(jsonPath, JSON.stringify({ allow: ['src/foo.cts', 'src/bar.cts'] }), 'utf8');
-
- const s = loadAllowlist(jsonPath);
- assert.ok(s instanceof Set);
- assert.ok(s.has('src/foo.cts'));
- assert.ok(s.has('src/bar.cts'));
- assert.equal(s.size, 2);
- });
-});
-
-// ---------------------------------------------------------------------------
-// T0-green / allowlist-completeness guard (real repo)
-// ---------------------------------------------------------------------------
-
-describe('lint-core-spine-imports: T0 allowlist covers all current importers', () => {
- test('scanCoreSpineImports against real src/ + gsd-core/bin/ returns [] with the shipped allowlist', () => {
- const roots = [
- path.join(ROOT, 'src'),
- path.join(ROOT, 'gsd-core', 'bin'),
- ];
- const allowlistSet = loadAllowlist(ALLOWLIST_PATH);
- const violations = scanCoreSpineImports(roots, allowlistSet);
-
- assert.deepEqual(
- violations,
- [],
- 'T0 allowlist is incomplete — new core-spine importers detected:\n' +
- violations.map((v) => ` ${v.file}:${v.line}`).join('\n'),
- );
- });
-});
-
-// ---------------------------------------------------------------------------
-// CLI contract
-// ---------------------------------------------------------------------------
-
-describe('lint-core-spine-imports: CLI', () => {
- test('clean repo → exit 0 and stdout contains the structured ok line', () => {
- const result = spawnSync(process.execPath, [SCRIPT_PATH], {
- cwd: ROOT,
- encoding: 'utf8',
- });
-
- assert.equal(
- result.status,
- 0,
- `expected exit 0 but got ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
- );
- assert.ok(
- result.stdout.includes('ok core-spine-imports:'),
- `stdout should contain "ok core-spine-imports:", got: ${result.stdout}`,
- );
- });
-});
diff --git a/tests/milestone-helper.test.cjs b/tests/milestone-helper.test.cjs
index 4c7834c32..82688a7a7 100644
--- a/tests/milestone-helper.test.cjs
+++ b/tests/milestone-helper.test.cjs
@@ -8,7 +8,7 @@ const assert = require('node:assert/strict');
const {
getMilestoneFromPhaseId,
getPhaseDirFromPhaseId,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/phase-id.cjs');
// ─── getMilestoneFromPhaseId ────────────────────────────────────────────────
diff --git a/tests/model-alias-map.test.cjs b/tests/model-alias-map.test.cjs
index 30e7d806c..d12f07afa 100644
--- a/tests/model-alias-map.test.cjs
+++ b/tests/model-alias-map.test.cjs
@@ -10,7 +10,7 @@
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
-const { MODEL_ALIAS_MAP } = require('../gsd-core/bin/lib/core.cjs');
+const { MODEL_ALIAS_MAP } = require('../gsd-core/bin/lib/model-catalog.cjs');
describe('MODEL_ALIAS_MAP (#1690 regression)', () => {
test('opus maps to claude-opus-4-8', () => {
diff --git a/tests/model-resolver.test.cjs b/tests/model-resolver.test.cjs
index 3ee4f068a..9f42093af 100644
--- a/tests/model-resolver.test.cjs
+++ b/tests/model-resolver.test.cjs
@@ -29,7 +29,6 @@ const { cleanup } = require('./helpers.cjs');
// ─── modules under test ───────────────────────────────────────────────────────
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
-const coreModule = require('../gsd-core/bin/lib/core.cjs');
const {
resolveTierEntry,
@@ -60,61 +59,6 @@ function writeConfig(tmpDir, obj) {
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
}
-// ─── shim identity ────────────────────────────────────────────────────────────
-
-describe('model-resolver shim identity', () => {
- test('core.resolveTierEntry === modelResolver.resolveTierEntry', () => {
- assert.strictEqual(coreModule.resolveTierEntry, modelResolver.resolveTierEntry);
- });
-
- test('core.resolveModelPolicy === modelResolver.resolveModelPolicy', () => {
- assert.strictEqual(coreModule.resolveModelPolicy, modelResolver.resolveModelPolicy);
- });
-
- test('core.resolveModelInternal === modelResolver.resolveModelInternal', () => {
- assert.strictEqual(coreModule.resolveModelInternal, modelResolver.resolveModelInternal);
- });
-
- test('core.VALID_GRANULARITIES === modelResolver.VALID_GRANULARITIES (same Set object)', () => {
- assert.strictEqual(coreModule.VALID_GRANULARITIES, modelResolver.VALID_GRANULARITIES);
- });
-
- test('core.resolveGranularityInternal === modelResolver.resolveGranularityInternal', () => {
- assert.strictEqual(coreModule.resolveGranularityInternal, modelResolver.resolveGranularityInternal);
- });
-
- test('core.assertValidGranularityOverride === modelResolver.assertValidGranularityOverride', () => {
- assert.strictEqual(coreModule.assertValidGranularityOverride, modelResolver.assertValidGranularityOverride);
- });
-
- test('core.resolveModelForTier === modelResolver.resolveModelForTier', () => {
- assert.strictEqual(coreModule.resolveModelForTier, modelResolver.resolveModelForTier);
- });
-
- test('core.VALID_EFFORTS === modelResolver.VALID_EFFORTS (same Array object)', () => {
- assert.strictEqual(coreModule.VALID_EFFORTS, modelResolver.VALID_EFFORTS);
- });
-
- test('core.EFFORT_SET === modelResolver.EFFORT_SET (same Set object)', () => {
- assert.strictEqual(coreModule.EFFORT_SET, modelResolver.EFFORT_SET);
- });
-
- test('core.nextEffort === modelResolver.nextEffort', () => {
- assert.strictEqual(coreModule.nextEffort, modelResolver.nextEffort);
- });
-
- test('core.resolveEffortInternal === modelResolver.resolveEffortInternal', () => {
- assert.strictEqual(coreModule.resolveEffortInternal, modelResolver.resolveEffortInternal);
- });
-
- test('core.resolveFastModeInternal === modelResolver.resolveFastModeInternal', () => {
- assert.strictEqual(coreModule.resolveFastModeInternal, modelResolver.resolveFastModeInternal);
- });
-
- test('core.resolveEffortForTier === modelResolver.resolveEffortForTier', () => {
- assert.strictEqual(coreModule.resolveEffortForTier, modelResolver.resolveEffortForTier);
- });
-});
// ─── resolveModelInternal ─────────────────────────────────────────────────────
diff --git a/tests/perf-315-loadconfig-subrepo-scan.test.cjs b/tests/perf-315-loadconfig-subrepo-scan.test.cjs
index 5a1faa362..f817175e6 100644
--- a/tests/perf-315-loadconfig-subrepo-scan.test.cjs
+++ b/tests/perf-315-loadconfig-subrepo-scan.test.cjs
@@ -28,7 +28,7 @@ const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
// Import loadConfig directly (sync, no CLI subprocess needed)
-const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
// ─── helpers ──────────────────────────────────────────────────────────────────
diff --git a/tests/phase-id.test.cjs b/tests/phase-id.test.cjs
index f16b95cee..f299ce4fd 100644
--- a/tests/phase-id.test.cjs
+++ b/tests/phase-id.test.cjs
@@ -22,7 +22,6 @@ const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
-const core = require('../gsd-core/bin/lib/core.cjs');
// ─── escapeRegex ─────────────────────────────────────────────────────────────
@@ -388,42 +387,3 @@ describe('getPhaseDirFromPhaseId', () => {
});
});
-// ─── core.cjs re-export shim identity assertions ──────────────────────────────
-
-describe('core.cjs re-export shim identity (single instance)', () => {
- test('core.escapeRegex === phaseId.escapeRegex', () => {
- assert.strictEqual(core.escapeRegex, phaseId.escapeRegex);
- });
-
- test('core.normalizePhaseName === phaseId.normalizePhaseName', () => {
- assert.strictEqual(core.normalizePhaseName, phaseId.normalizePhaseName);
- });
-
- test('core.comparePhaseNum === phaseId.comparePhaseNum', () => {
- assert.strictEqual(core.comparePhaseNum, phaseId.comparePhaseNum);
- });
-
- test('core.extractPhaseToken === phaseId.extractPhaseToken', () => {
- assert.strictEqual(core.extractPhaseToken, phaseId.extractPhaseToken);
- });
-
- test('core.phaseTokenMatches === phaseId.phaseTokenMatches', () => {
- assert.strictEqual(core.phaseTokenMatches, phaseId.phaseTokenMatches);
- });
-
- test('core.phaseMarkdownRegexSource === phaseId.phaseMarkdownRegexSource', () => {
- assert.strictEqual(core.phaseMarkdownRegexSource, phaseId.phaseMarkdownRegexSource);
- });
-
- test('core.phaseMarkdownRegexSourceExact === phaseId.phaseMarkdownRegexSourceExact', () => {
- assert.strictEqual(core.phaseMarkdownRegexSourceExact, phaseId.phaseMarkdownRegexSourceExact);
- });
-
- test('core.getMilestoneFromPhaseId === phaseId.getMilestoneFromPhaseId', () => {
- assert.strictEqual(core.getMilestoneFromPhaseId, phaseId.getMilestoneFromPhaseId);
- });
-
- test('core.getPhaseDirFromPhaseId === phaseId.getPhaseDirFromPhaseId', () => {
- assert.strictEqual(core.getPhaseDirFromPhaseId, phaseId.getPhaseDirFromPhaseId);
- });
-});
diff --git a/tests/phase-locator.test.cjs b/tests/phase-locator.test.cjs
index 5b7d2252f..125fe06a0 100644
--- a/tests/phase-locator.test.cjs
+++ b/tests/phase-locator.test.cjs
@@ -24,25 +24,8 @@ const fs = require('node:fs');
const path = require('node:path');
const phaseLocator = require('../gsd-core/bin/lib/phase-locator.cjs');
-const core = require('../gsd-core/bin/lib/core.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
-// ─── Shim-identity assertions ─────────────────────────────────────────────────
-
-describe('phase-locator: shim-identity — core.cjs re-exports same function objects', () => {
- test('core.findPhaseInternal === phaseLocator.findPhaseInternal', () => {
- assert.strictEqual(core.findPhaseInternal, phaseLocator.findPhaseInternal);
- });
-
- test('core.getArchivedPhaseDirs === phaseLocator.getArchivedPhaseDirs', () => {
- assert.strictEqual(core.getArchivedPhaseDirs, phaseLocator.getArchivedPhaseDirs);
- });
-
- test('core.searchPhaseInDir === phaseLocator.searchPhaseInDir', () => {
- assert.strictEqual(core.searchPhaseInDir, phaseLocator.searchPhaseInDir);
- });
-});
-
// ─── findPhaseInternal — basic active-phase lookup ────────────────────────────
describe('findPhaseInternal: active phase lookup', () => {
diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs
index bc7492ad2..2c0c7a2f8 100644
--- a/tests/phase.test.cjs
+++ b/tests/phase.test.cjs
@@ -2848,7 +2848,7 @@ Plans:
// comparePhaseNum and normalizePhaseName (imported directly)
// ─────────────────────────────────────────────────────────────────────────────
-const { comparePhaseNum, normalizePhaseName } = require('../gsd-core/bin/lib/core.cjs');
+const { comparePhaseNum, normalizePhaseName } = require('../gsd-core/bin/lib/phase-id.cjs');
describe('comparePhaseNum', () => {
test('sorts integer phases numerically', () => {
diff --git a/tests/planning-workspace.test.cjs b/tests/planning-workspace.test.cjs
index 9a0069630..6933ec51c 100644
--- a/tests/planning-workspace.test.cjs
+++ b/tests/planning-workspace.test.cjs
@@ -15,7 +15,7 @@ const {
setActiveWorkstream,
} = require('../gsd-core/bin/lib/planning-workspace.cjs');
-const core = require('../gsd-core/bin/lib/core.cjs');
+const planningWorkspaceDirect = require('../gsd-core/bin/lib/planning-workspace.cjs');
describe('planning-workspace: planningDir/planningPaths parity', () => {
const cwd = '/fake/repo';
@@ -147,7 +147,7 @@ describe('planning-workspace: lock seam', () => {
});
});
-describe('core compatibility adapter: planning workspace functions', () => {
+describe('planning-workspace direct: functions expose matching behavior', () => {
let savedSession;
beforeEach(() => {
@@ -160,26 +160,26 @@ describe('core compatibility adapter: planning workspace functions', () => {
else delete process.env.GSD_SESSION_KEY;
});
- test('core and planning-workspace expose matching behavior', () => {
+ test('planning-workspace functions work consistently', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-core-compat-'));
try {
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true });
- core.setActiveWorkstream(tmpDir, 'alpha');
- assert.strictEqual(core.getActiveWorkstream(tmpDir), 'alpha');
+ planningWorkspaceDirect.setActiveWorkstream(tmpDir, 'alpha');
+ assert.strictEqual(planningWorkspaceDirect.getActiveWorkstream(tmpDir), 'alpha');
assert.strictEqual(getActiveWorkstream(tmpDir), 'alpha');
assert.strictEqual(
- core.planningDir(tmpDir, 'feature-x', 'my-project'),
+ planningWorkspaceDirect.planningDir(tmpDir, 'feature-x', 'my-project'),
planningDir(tmpDir, 'feature-x', 'my-project')
);
assert.deepStrictEqual(
- core.planningPaths(tmpDir, 'feature-x'),
+ planningWorkspaceDirect.planningPaths(tmpDir, 'feature-x'),
planningPaths(tmpDir, 'feature-x')
);
setActiveWorkstream(tmpDir, null);
- assert.strictEqual(core.getActiveWorkstream(tmpDir), null);
+ assert.strictEqual(planningWorkspaceDirect.getActiveWorkstream(tmpDir), null);
} finally {
cleanup(tmpDir);
}
diff --git a/tests/post-planning-gaps-2493.test.cjs b/tests/post-planning-gaps-2493.test.cjs
index 7c7d6057a..b4665d1ae 100644
--- a/tests/post-planning-gaps-2493.test.cjs
+++ b/tests/post-planning-gaps-2493.test.cjs
@@ -382,7 +382,7 @@ describe('workflow.post_planning_gaps config (#2493)', () => {
test('CONFIG_DEFAULTS contains post_planning_gaps default true', () => {
// CONFIG_DEFAULTS is exported from core.cjs
- const { CONFIG_DEFAULTS } = require('../gsd-core/bin/lib/core.cjs');
+ const { CONFIG_DEFAULTS } = require('../gsd-core/bin/lib/config-loader.cjs');
assert.strictEqual(CONFIG_DEFAULTS.post_planning_gaps, true);
});
@@ -419,7 +419,7 @@ describe('workflow.post_planning_gaps config (#2493)', () => {
// in its return so callers can read config.post_planning_gaps regardless of whether
// config.json exists, has the workflow section, or sets the flat key.
test('loadConfig() returns post_planning_gaps default true when key absent', () => {
- const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+ const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
runGsdTools('config-ensure-section', tmpDir);
// Remove the key to simulate older configs that pre-date the toggle
const cfgPath = path.join(tmpDir, '.planning', 'config.json');
@@ -431,7 +431,7 @@ describe('workflow.post_planning_gaps config (#2493)', () => {
});
test('loadConfig() returns post_planning_gaps:false when workflow.post_planning_gaps=false', () => {
- const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+ const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
runGsdTools('config-ensure-section', tmpDir);
runGsdTools(['config-set', 'workflow.post_planning_gaps', 'false'], tmpDir);
const config = loadConfig(tmpDir);
@@ -439,7 +439,7 @@ describe('workflow.post_planning_gaps config (#2493)', () => {
});
test('loadConfig() returns post_planning_gaps:true when workflow.post_planning_gaps=true', () => {
- const { loadConfig } = require('../gsd-core/bin/lib/core.cjs');
+ const { loadConfig } = require('../gsd-core/bin/lib/config-loader.cjs');
runGsdTools('config-ensure-section', tmpDir);
runGsdTools(['config-set', 'workflow.post_planning_gaps', 'true'], tmpDir);
const config = loadConfig(tmpDir);
diff --git a/tests/prune-orphaned-worktrees.test.cjs b/tests/prune-orphaned-worktrees.test.cjs
index afb2718e7..514f4f062 100644
--- a/tests/prune-orphaned-worktrees.test.cjs
+++ b/tests/prune-orphaned-worktrees.test.cjs
@@ -16,7 +16,7 @@ const { createTempDir, cleanup } = require('./helpers.cjs');
// Lazy-loaded so tests can fail clearly when the export doesn't exist yet.
function getPruneOrphanedWorktrees() {
- const { pruneOrphanedWorktrees } = require('../gsd-core/bin/lib/core.cjs');
+ const { pruneOrphanedWorktrees } = require('../gsd-core/bin/lib/worktree-safety.cjs');
return pruneOrphanedWorktrees;
}
diff --git a/tests/roadmap-parser.test.cjs b/tests/roadmap-parser.test.cjs
index 43f5ca58f..53518884f 100644
--- a/tests/roadmap-parser.test.cjs
+++ b/tests/roadmap-parser.test.cjs
@@ -23,7 +23,6 @@ const fs = require('node:fs');
const path = require('node:path');
const roadmapParser = require('../gsd-core/bin/lib/roadmap-parser.cjs');
-const core = require('../gsd-core/bin/lib/core.cjs');
const { createTempProject, cleanup } = require('./helpers.cjs');
const {
@@ -46,28 +45,6 @@ function writeState(tmpDir, fields) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), lines.join('\n') + '\n');
}
-// ─── Shim-identity assertions ─────────────────────────────────────────────────
-
-describe('roadmap-parser: shim-identity — core.cjs re-exports same function objects', () => {
- test('core.extractCurrentMilestone === roadmapParser.extractCurrentMilestone', () => {
- assert.strictEqual(core.extractCurrentMilestone, roadmapParser.extractCurrentMilestone);
- });
- test('core.stripShippedMilestones === roadmapParser.stripShippedMilestones', () => {
- assert.strictEqual(core.stripShippedMilestones, roadmapParser.stripShippedMilestones);
- });
- test('core.replaceInCurrentMilestone === roadmapParser.replaceInCurrentMilestone', () => {
- assert.strictEqual(core.replaceInCurrentMilestone, roadmapParser.replaceInCurrentMilestone);
- });
- test('core.getRoadmapPhaseInternal === roadmapParser.getRoadmapPhaseInternal', () => {
- assert.strictEqual(core.getRoadmapPhaseInternal, roadmapParser.getRoadmapPhaseInternal);
- });
- test('core.getMilestoneInfo === roadmapParser.getMilestoneInfo', () => {
- assert.strictEqual(core.getMilestoneInfo, roadmapParser.getMilestoneInfo);
- });
- test('core.getMilestonePhaseFilter === roadmapParser.getMilestonePhaseFilter', () => {
- assert.strictEqual(core.getMilestonePhaseFilter, roadmapParser.getMilestonePhaseFilter);
- });
-});
// ─── stripShippedMilestones ───────────────────────────────────────────────────
diff --git a/tests/roadmap-phase-fallback.test.cjs b/tests/roadmap-phase-fallback.test.cjs
index beac818ec..de5653e86 100644
--- a/tests/roadmap-phase-fallback.test.cjs
+++ b/tests/roadmap-phase-fallback.test.cjs
@@ -231,7 +231,7 @@ describe('roadmap get-phase fallback to full ROADMAP.md (#1634)', () => {
// Regression: phase heading like "### Phase 12: v1.0 Tech-Debt Closure"
// was incorrectly treated as a milestone boundary because the greedy
// `.*v\d+\.\d+` subpattern in nextMilestonePattern matched it.
- const core = require('../gsd-core/bin/lib/core.cjs');
+ const core = require('../gsd-core/bin/lib/roadmap-parser.cjs');
writeState(tmpDir, 'v1.1');
const roadmap = `# Roadmap
@@ -262,7 +262,7 @@ describe('roadmap get-phase fallback to full ROADMAP.md (#1634)', () => {
test('extractCurrentMilestone handles PHASE/phase (case-insensitive) containing vX.Y (#2619 follow-up)', () => {
// CodeRabbit follow-up: the negative lookahead `(?!Phase\s+\S)` must be
// case-insensitive so PHASE/phase variants are also excluded.
- const core = require('../gsd-core/bin/lib/core.cjs');
+ const core = require('../gsd-core/bin/lib/roadmap-parser.cjs');
writeState(tmpDir, 'v1.1');
const roadmap = `# Roadmap
@@ -333,7 +333,7 @@ This phase covers:
describe('extractCurrentMilestone — closed-sibling heading selection (#145)', () => {
let tmpDir;
- const core = require('../gsd-core/bin/lib/core.cjs');
+ const core = require('../gsd-core/bin/lib/roadmap-parser.cjs');
beforeEach(() => {
tmpDir = createTempProject();
@@ -656,7 +656,7 @@ This is the only milestone.
describe('extractCurrentMilestone — boundary / active-override hardening (#145 follow-up)', () => {
let tmpDir;
- const core = require('../gsd-core/bin/lib/core.cjs');
+ const core = require('../gsd-core/bin/lib/roadmap-parser.cjs');
beforeEach(() => {
tmpDir = createTempProject();
diff --git a/tests/temp-subdir.test.cjs b/tests/temp-subdir.test.cjs
index 939fa9c86..8b7f86f8c 100644
--- a/tests/temp-subdir.test.cjs
+++ b/tests/temp-subdir.test.cjs
@@ -14,7 +14,7 @@ const os = require('os');
const {
reapStaleTempFiles,
-} = require('../gsd-core/bin/lib/core.cjs');
+} = require('../gsd-core/bin/lib/io.cjs');
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
@@ -121,14 +121,14 @@ describe('dedicated gsd temp subdirectory', () => {
// The legacy reap function should still clean old-location files
// We import it if exported, or verify the main reap handles both
- const core = require('../gsd-core/bin/lib/core.cjs');
- if (typeof core.reapStaleTempFilesLegacy === 'function') {
- core.reapStaleTempFilesLegacy(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
+ const ioModule = require('../gsd-core/bin/lib/io.cjs');
+ if (typeof ioModule.reapStaleTempFilesLegacy === 'function') {
+ ioModule.reapStaleTempFilesLegacy(testPrefix, { maxAgeMs: 5 * 60 * 1000 });
assert.ok(!fs.existsSync(oldLocationPath), 'legacy reap should clean old location');
} else {
// If no separate legacy function, the main output() should do a one-time
// migration sweep. We just verify the export shape is correct.
- assert.ok(typeof core.reapStaleTempFiles === 'function');
+ assert.ok(typeof ioModule.reapStaleTempFiles === 'function');
// Clean up manually since we're not testing migration here
fs.unlinkSync(oldLocationPath);
}
diff --git a/tests/workstream.test.cjs b/tests/workstream.test.cjs
index d5dca0bec..da2762a13 100644
--- a/tests/workstream.test.cjs
+++ b/tests/workstream.test.cjs
@@ -833,7 +833,7 @@ describe('path traversal rejection', () => {
});
describe('setActiveWorkstream rejects invalid names directly', () => {
- const { setActiveWorkstream } = require('../gsd-core/bin/lib/core.cjs');
+ const { setActiveWorkstream } = require('../gsd-core/bin/lib/planning-workspace.cjs');
for (const name of maliciousNames) {
test(`throws for ${name}`, () => {
assert.throws(