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(