From 48d9cec6fed0b804905212b148316bcba464bb19 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 15 Jun 2026 15:08:58 -0400 Subject: [PATCH] refactor(#1268): re-home core re-export-spine squatters + migration-convergence lint (#1272) Re-home the 6 implementation functions squatting in the core.cjs re-export spine (ADR-857) into the modules whose interface they belong to, with core re-exporting them BY REFERENCE so all 32 callers + the shim-identity tests keep resolving unchanged: - worktree-safety: resolveWorktreeRoot, pruneOrphanedWorktrees - git-base-branch (broadened to the Git Query Module): gitWorktreeInfoInternal - agent-install-check (new leaf): getAgentsDir, checkAgentsInstalled - delete the _resetRuntimeWarningCacheForTests wrapper; consumers use a shared resetRuntimeWarningCaches() helper in tests/helpers.cjs Add scripts/lint-core-spine-imports.cjs (migration-convergence lint with a 30-importer allowlist, wired into lint:ci) so the staged spine retirement provably converges: CI fails on any new ./core import. Register the new generated agent-install-check.cjs in eslint-ignore + .gitignore + INVENTORY-MANIFEST.json. No behaviour change. First tranche (T0) of epic #1267. Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + CONTEXT.md | 9 +- docs/INVENTORY-MANIFEST.json | 1 + eslint.config.mjs | 1 + gsd-core/bin/lib/git-base-branch.cjs | 26 ++ package.json | 2 +- .../lint-core-spine-imports.allowlist.json | 35 +++ scripts/lint-core-spine-imports.cjs | 203 +++++++++++++ src/agent-install-check.cts | 159 ++++++++++ src/core.cts | 266 ++--------------- src/git-base-branch.cts | 32 ++ src/worktree-safety.cts | 44 +++ tests/agent-install-check.test.cjs | 242 +++++++++++++++ tests/feat-49-model-policy-presets.test.cjs | 13 +- tests/git-base-branch.test.cjs | 39 +++ tests/helpers.cjs | 16 +- ...issue-2517-runtime-aware-profiles.test.cjs | 33 +-- tests/lint-core-spine-imports.test.cjs | 279 ++++++++++++++++++ tests/worktree-safety.test.cjs | 53 ++++ 19 files changed, 1177 insertions(+), 277 deletions(-) create mode 100644 scripts/lint-core-spine-imports.allowlist.json create mode 100644 scripts/lint-core-spine-imports.cjs create mode 100644 src/agent-install-check.cts create mode 100644 tests/agent-install-check.test.cjs create mode 100644 tests/lint-core-spine-imports.test.cjs diff --git a/.gitignore b/.gitignore index fed1bfbaf..38fc86117 100644 --- a/.gitignore +++ b/.gitignore @@ -166,6 +166,7 @@ build/ /gsd-core/bin/lib/verify-command-router.cjs /gsd-core/bin/lib/init-command-router.cjs /gsd-core/bin/lib/agent-command-router.cjs +/gsd-core/bin/lib/agent-install-check.cjs /gsd-core/bin/lib/task-command-router.cjs /gsd-core/bin/lib/validate-command-router.cjs /gsd-core/bin/lib/workstream-inventory.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 27eb503d2..61e567635 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -95,7 +95,7 @@ Module owning project-root resolution from any starting directory. Walks the anc Module owning projection from project/workstream context to concrete `.planning` paths. Policy precedence is `explicit workstream > env workstream > env project > root`. Invalid workspace context is a validation error at this seam rather than a silent fallback. ### Worktree Safety Policy Module -CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`. Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. +CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`. Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. Planned (retiring the `core.cjs` spine, ADR-857): absorbs the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly instead of through Core. `gitWorktreeInfoInternal` does NOT move here — worktree-info detection belongs to the Git Query Module. ### Worktree Lifecycle Module Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`gsd-core/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`. @@ -103,8 +103,8 @@ Workflow contract seam covering agent worktree lifecycle orchestration rules. Th ### Worktree Root Resolution Adapter Module Adapter Module owning linked-worktree root mapping and metadata-prune policy (`git worktree prune` non-destructive default) for planning/workstream callers. -### Git Base-Branch Resolver Module -Module owning the single source of truth for detecting the repository's default/base branch (`gsd_run query git.base-branch`). Implements a full precedence ladder: (1) `git.base_branch` config override from `.planning/config.json`; (2) `git symbolic-ref --short refs/remotes/origin/HEAD`; (3) `git remote show origin` HEAD branch (authoritative when origin/HEAD is unset — the common case for `git init + remote add + fetch` without `set-head`); (4) local branch existence (`master` present and `main` absent → `master`; `main` present → `main`); (5) `"main"` last-resort default. All git subprocesses are bounded with timeouts (5–15 s) and degrade gracefully to the next tier; the function never throws. Replaces duplicated per-workflow bash detection that silently fell through to `:-main` on master repos (#1146). Source: `src/git-base-branch.cts` → `gsd-core/bin/lib/git-base-branch.cjs`. Wired into `execute-phase.md`, `quick.md`, `ship.md`, `complete-milestone.md`, and `pr-branch.md`. +### Git Query Module +Module owning bounded, never-throw git repository introspection — the single seam for read-only git queries that degrade gracefully rather than throwing. **Adapter 1 — base-branch detection** (`gsd_run query git.base-branch`): Implements a full precedence ladder: (1) `git.base_branch` config override from `.planning/config.json`; (2) `git symbolic-ref --short refs/remotes/origin/HEAD`; (3) `git remote show origin` HEAD branch (authoritative when origin/HEAD is unset — the common case for `git init + remote add + fetch` without `set-head`); (4) local branch existence (`master` present and `main` absent → `master`; `main` present → `main`); (5) `"main"` last-resort default. All git subprocesses are bounded with timeouts (5–15 s) and degrade gracefully to the next tier; the function never throws. Replaces duplicated per-workflow bash detection that silently fell through to `:-main` on master repos (#1146). **Adapter 2 — worktree-info detection**: `gitWorktreeInfoInternal` (`git rev-parse --is-inside-work-tree` + `--show-toplevel`), absorbed from the Core module when the `core.cjs` re-export spine was retired and aligned to this module's bounded-timeout / degrade-don't-throw convention (worktree-info detection is a query concern, distinct from the Worktree Safety Policy Module's lifecycle policy). Source: `src/git-base-branch.cts` → `gsd-core/bin/lib/git-base-branch.cjs`. Wired into `execute-phase.md`, `quick.md`, `ship.md`, `complete-milestone.md`, and `pr-branch.md`. ### Runtime Name Policy Module Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`. @@ -124,6 +124,9 @@ Module owning ROADMAP.md parsing: shipped-milestone slicing, current-milestone e ### Core Utilities Module Module owning the shared low-level utility primitives extracted from Core: POSIX path normalization (`toPosixPath`), filesystem scanning (`detectSubRepos`, `readSubdirectories`, `getPhaseFileStats`, `pathExistsInternal`), and small pure helpers (`generateSlugInternal`, `extractOneLinerFromBody`, `filterPlanFiles`, `filterSummaryFiles`, `extractCanonicalPlanId`, `timeAgo`). Depends only on Node built-ins and already-leafed modules (`phase-id` for `comparePhaseNum`, `planning-workspace` for `findContextMdIn`) — no `loadConfig`, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2c (#877) as the shared leaf that unblocks the phase-locator fs-search extraction (2d); `core.cjs` re-exports the public helpers for back-compat. Source of truth: `gsd-core/bin/lib/core-utils.cjs` (generated from `src/core-utils.cts`). +### Agent Install Check Module [Planned] +Module owning agent-presence resolution and verification, extracted from the Core module as the cleanup step that retires the `core.cjs` re-export spine (the final ADR-857 decomposition). Interface: `getAgentsDir(runtime?, env?)` — env-var-aware, runtime-aware agents-directory resolution (the `claude` runtime resolves `__dirname`-relative); `checkAgentsInstalled(...)` — multi-runtime agent-presence check that validates `gsd-file-manifest.json` completeness and confirms the declared agents exist on disk. Pure read/verify — no install-write side effects (writes remain the Installer Module's). Consumed by the Init Command Module, the verify workflow, and the docs workflow. Source of truth: `gsd-core/bin/lib/agent-install-check.cjs` (generated from `src/agent-install-check.cts`); replaces the two functions that squatted in `core.cts`. See Installer Module and ADR-857. + ### Config Loader Module Module owning project configuration loading: reads `.planning/config.json`, merges built-in defaults (`CONFIG_DEFAULTS`/`CANONICAL_CONFIG_DEFAULTS`), normalizes legacy keys, applies the active-workstream overlay, validates against the config schema, and warns on unknown keys/profile overrides (`loadConfig` plus its `_deepMergeConfig`/`isGitIgnored`/`_warnUnknownProfileOverrides` helpers). Depends only on leaf modules (`configuration`, `config-schema`, `planning-workspace`, `shell-command-projection`, `core-utils`, `model-catalog`) — no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2e (#885) as the prerequisite for the model-resolver extraction (the resolvers call `loadConfig`); `core.cjs` re-exports `loadConfig` for back-compat. Source of truth: `gsd-core/bin/lib/config-loader.cjs` (generated from `src/config-loader.cts`). diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 2b593e958..7510ac2d3 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -274,6 +274,7 @@ "active-workstream-store.cjs", "adr-parser.cjs", "agent-command-router.cjs", + "agent-install-check.cjs", "artifacts.cjs", "audit-command-router.cjs", "audit.cjs", diff --git a/eslint.config.mjs b/eslint.config.mjs index bda42a3e3..16f729a43 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -124,6 +124,7 @@ export default tseslint.config( 'gsd-core/bin/lib/verification-command-router.cjs', 'gsd-core/bin/lib/init-command-router.cjs', 'gsd-core/bin/lib/agent-command-router.cjs', + 'gsd-core/bin/lib/agent-install-check.cjs', 'gsd-core/bin/lib/task-command-router.cjs', 'gsd-core/bin/lib/validate-command-router.cjs', 'gsd-core/bin/lib/workstream-inventory.cjs', diff --git a/gsd-core/bin/lib/git-base-branch.cjs b/gsd-core/bin/lib/git-base-branch.cjs index a564379d7..b30cfda93 100644 --- a/gsd-core/bin/lib/git-base-branch.cjs +++ b/gsd-core/bin/lib/git-base-branch.cjs @@ -32,6 +32,7 @@ exports.trySymbolicRef = trySymbolicRef; exports.tryRemoteShow = tryRemoteShow; exports.tryLocalBranch = tryLocalBranch; exports.resolveBaseBranch = resolveBaseBranch; +exports.gitWorktreeInfoInternal = gitWorktreeInfoInternal; exports.cmdGitBaseBranch = cmdGitBaseBranch; const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); @@ -180,6 +181,31 @@ function resolveBaseBranch(cwd, deps) { // 5. Last-resort default return 'main'; } +/** + * Detect whether `cwd` sits inside a git worktree, and if so, return the + * absolute path of the worktree root. + */ +function gitWorktreeInfoInternal(cwd) { + try { + const insideResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 }); + if (insideResult.exitCode !== 0) { + return { inside: false, worktreeRoot: null }; + } + const insideStdout = String(insideResult.stdout || '').trim(); + if (insideStdout !== 'true') { + return { inside: false, worktreeRoot: null }; + } + const rootResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 }); + if (rootResult.exitCode !== 0) { + return { inside: true, worktreeRoot: null }; + } + const root = String(rootResult.stdout || '').trim(); + return { inside: true, worktreeRoot: root || null }; + } + catch { + return { inside: false, worktreeRoot: null }; + } +} // ─── CLI entry point ────────────────────────────────────────────────────────── /** * CLI command: `gsd-tools git base-branch` diff --git a/package.json b/package.json index 9a647afef..9cf158167 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "pretest:coverage": "npm run build:lib && npm run lint:skill-deps", "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/", "lint:fix": "eslint . --fix", - "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-core-spine-imports.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:windows-test-portability": "node scripts/lint-windows-test-portability.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", diff --git a/scripts/lint-core-spine-imports.allowlist.json b/scripts/lint-core-spine-imports.allowlist.json new file mode 100644 index 000000000..bdb738c1e --- /dev/null +++ b/scripts/lint-core-spine-imports.allowlist.json @@ -0,0 +1,35 @@ +{ + "_comment": "Files allowed to import the core re-export spine during the staged retirement (issue #1268). Entries are REMOVED as each tranche migrates a leaf; the file + this lint are deleted in T-final.", + "allow": [ + "gsd-core/bin/gsd-tools.cjs", + "src/agent-command-router.cts", + "src/audit-command-router.cts", + "src/capability-state.cts", + "src/capability-writer.cts", + "src/check-command-router.cts", + "src/commands.cts", + "src/config.cts", + "src/docs.cts", + "src/frontmatter.cts", + "src/gap-checker.cts", + "src/graphify-command-router.cts", + "src/gsd2-import.cts", + "src/init.cts", + "src/intel-command-router.cts", + "src/learnings.cts", + "src/loop-resolver.cts", + "src/milestone.cts", + "src/phase.cts", + "src/profile-output.cts", + "src/roadmap-command-router.cts", + "src/roadmap.cts", + "src/state.cts", + "src/task-command-router.cts", + "src/template.cts", + "src/uat.cts", + "src/verification.cts", + "src/verify.cts", + "src/workstream-inventory.cts", + "src/workstream.cts" + ] +} diff --git a/scripts/lint-core-spine-imports.cjs b/scripts/lint-core-spine-imports.cjs new file mode 100644 index 000000000..137028e54 --- /dev/null +++ b/scripts/lint-core-spine-imports.cjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Migration-convergence lint for the core re-export spine (issue #1268). + * + * The spine (core.cjs) is being staged for retirement: each tranche migrates a + * set of leaf importers to consume the individual modules directly. This lint + * makes CI RED the moment a NEW file starts importing the spine, preventing + * regressions as the tranche work proceeds. + * + * Allowlisted importers (the "T0 set") are files that already imported the + * spine when this lint was introduced. Entries are REMOVED from the allowlist + * as each tranche migrates a leaf. The allowlist + this script are deleted in + * T-final. + * + * Model: scripts/lint-package-identity-drift.cjs + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +// --------------------------------------------------------------------------- +// Core-spine import detection +// --------------------------------------------------------------------------- + +/** + * Matches any import form that targets the core spine at any relative depth, + * including the TS `import x = require(...)` form, dynamic imports, and bare + * side-effect imports. + * + * Patterns matched (illustrative): + * require('./core.cjs') + * require('./core') + * require('../core.cjs') + * require('./lib/core.cjs') + * require('../lib/core.cjs') + * require('../../lib/core.cjs') + * from './core.cjs' + * import core = require('../lib/core.cjs') + * import('./core.cjs') dynamic import + * await import('../lib/core.cjs') dynamic import + * import './core.cjs'; bare side-effect import + * + * Patterns NOT matched (boundary after bare `core` is quote or `.cjs`): + * require('./core-utils.cjs') + * require('./core-schema.cjs') + * + * The `import\s+` branch (bare side-effect) will NOT match + * `import x = require(...)` because a non-quote token follows `import `. + */ +const CORE_IMPORT_RE = /(?:require\(\s*|import\s*\(\s*|from\s+|import\s+)['"](?:\.\.?\/)+(?:lib\/)?core(?:\.cjs)?['"]/; + +/** + * Return true if `line` is a real (non-commented) core-spine import. + * @param {string} line + * @returns {boolean} + */ +function lineImportsSpine(line) { + if (line.trimStart().startsWith('//')) return false; + return CORE_IMPORT_RE.test(line); +} + +// --------------------------------------------------------------------------- +// Directory walking +// --------------------------------------------------------------------------- + +/** + * Recursively collect files under `dir` matching `extSet`, skipping + * `node_modules`, `.git`, `.memdb`, and any caller-supplied `skipDirs`. + * (The caller is responsible for scoping roots; this function does NOT itself + * skip `tests/` — that exclusion is achieved by not passing `tests/` as a root.) + * + * @param {string} dir - Absolute directory to walk. + * @param {Set} extSet - File extensions to include (e.g. new Set(['.cts','.cjs'])). + * @param {Set} skipDirs - Absolute paths of directories to skip entirely. + * @param {string[]} acc - Accumulator (modified in place). + * @returns {string[]} + */ +function walk(dir, extSet, skipDirs, acc) { + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return acc; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === '.memdb') continue; + if (skipDirs.has(full)) continue; + walk(full, extSet, skipDirs, acc); + } else if (entry.isFile() && extSet.has(path.extname(entry.name))) { + acc.push(full); + } + } + return acc; +} + +// --------------------------------------------------------------------------- +// Pure exported API +// --------------------------------------------------------------------------- + +/** + * Scan `roots` for source files that import the core spine but are not in + * `allowlistSet`. + * + * @param {string[]} roots - Absolute directories to scan. + * @param {Set} allowlistSet - Repo-relative POSIX paths that are + * allowed to import the spine (the T0 set). + * @returns {{ file: string, line: number }[]} + * Repo-relative POSIX file path and 1-based line number for each violation. + */ +function scanCoreSpineImports(roots, allowlistSet) { + const repoRoot = path.join(__dirname, '..'); + + // gsd-core/bin/lib is excluded (generated artefacts — double-counts src/). + const skipDirs = new Set([path.join(repoRoot, 'gsd-core', 'bin', 'lib')]); + + // src/*.cts → .cts only; gsd-core/bin/**/*.cjs → .cjs only. + // Accept either extension in each root; walk will naturally skip unsupported + // files via extSet filtering. + const extSet = new Set(['.cts', '.cjs']); + + const violations = []; + + for (const root of roots) { + const files = walk(root, extSet, skipDirs, []); + for (const abs of files) { + // Derive repo-relative POSIX path for allowlist lookup and reporting. + const rel = path.relative(repoRoot, abs).replace(/\\/g, '/'); + + let text; + try { + text = fs.readFileSync(abs, 'utf8'); + } catch { + continue; + } + + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lineImportsSpine(lines[i])) { + if (!allowlistSet.has(rel)) { + violations.push({ file: rel, line: i + 1 }); + } + // Do NOT break: report every spine-import line so no violation is masked. + } + } + } + } + + return violations; +} + +/** + * Load the allowlist JSON and return a Set of repo-relative paths. + * + * @param {string} allowlistPath - Absolute path to the JSON file. + * @returns {Set} + */ +function loadAllowlist(allowlistPath) { + const raw = fs.readFileSync(allowlistPath, 'utf8'); + const obj = JSON.parse(raw); + return new Set(obj.allow || []); +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +function main() { + const repoRoot = path.join(__dirname, '..'); + const allowlistPath = path.join(__dirname, 'lint-core-spine-imports.allowlist.json'); + + const allowlistSet = loadAllowlist(allowlistPath); + + const roots = [ + path.join(repoRoot, 'src'), + path.join(repoRoot, 'gsd-core', 'bin'), + ]; + + const violations = scanCoreSpineImports(roots, allowlistSet); + + if (violations.length === 0) { + process.stdout.write( + `ok core-spine-imports: ${allowlistSet.size} allowlisted importer(s), 0 new\n`, + ); + return; + } + + process.stderr.write('core-spine-imports: new file(s) importing the core re-export spine detected.\n'); + process.stderr.write('The spine (core.cjs) is being retired (issue #1268). Import the leaf module\n'); + process.stderr.write('directly instead, or add to the allowlist only if genuinely transitional:\n'); + for (const v of violations) { + process.stderr.write(` ${v.file}:${v.line}\n`); + } + process.stderr.write(`core-spine-imports: ${violations.length} violation(s)\n`); + process.exitCode = 1; +} + +if (require.main === module) main(); + +module.exports = { scanCoreSpineImports, loadAllowlist }; diff --git a/src/agent-install-check.cts b/src/agent-install-check.cts new file mode 100644 index 000000000..4a8dd3612 --- /dev/null +++ b/src/agent-install-check.cts @@ -0,0 +1,159 @@ +/** + * Agent Install Check — moved from core.cts (ADR-857 T0 #1268 phase rehome-core-squatters). + * + * Owns: + * - getAgentsDir(runtime?): string + * - checkAgentsInstalled(runtime?): AgentsInstalledResult + * + * Consumers that previously imported these via core.cjs continue to work: + * core.cjs re-exports both symbols BY REFERENCE from this module. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import modelProfiles = require('./model-profiles.cjs'); +const { MODEL_PROFILES } = modelProfiles; +import { getGlobalConfigDir } from './runtime-homes.cjs'; + +interface AgentsInstalledResult { + agents_installed: boolean; + missing_agents: string[]; + installed_agents: string[]; + incomplete_agents: string[]; + agents_dir: string; + agent_runtime: string; +} + +/** + * Resolve the agents directory for the given runtime. + * + * Priority: + * 1. GSD_AGENTS_DIR env var (explicit override, any runtime) + * 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/) + * This is correct for both repo runs and real installs (the runtime config dir's + * agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases. + * 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents + * + * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude' + */ +function getAgentsDir(runtime?: string): string { + if (process.env['GSD_AGENTS_DIR']) { + return process.env['GSD_AGENTS_DIR']; + } + const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude'); + if (resolved === 'claude') { + return path.join(__dirname, '..', '..', '..', 'agents'); + } + return path.join(getGlobalConfigDir(resolved), 'agents'); +} + +/** + * Check which GSD agents are installed on disk. + * + * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude' + */ +function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { + const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude'); + const agentsDir = getAgentsDir(resolvedRuntime); + const expectedAgents = Object.keys(MODEL_PROFILES); + const installed: string[] = []; + const missing: string[] = []; + + if (!fs.existsSync(agentsDir)) { + return { + agents_installed: false, + missing_agents: expectedAgents, + installed_agents: [], + incomplete_agents: [], + agents_dir: agentsDir, + agent_runtime: resolvedRuntime, + }; + } + + for (const agent of expectedAgents) { + const agentFile = path.join(agentsDir, `${agent}.md`); + const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`); + const agentFileCodex = path.join(agentsDir, `${agent}.toml`); + const agentFileKimiYaml = path.join(agentsDir, 'subagents', `${agent}.yaml`); + const agentFileKimiPrompt = path.join(agentsDir, 'subagents', `${agent}.md`); + const kimiAgentInstalled = + resolvedRuntime === 'kimi' && + fs.existsSync(agentFileKimiYaml) && + fs.existsSync(agentFileKimiPrompt); + if ( + fs.existsSync(agentFile) || + fs.existsSync(agentFileCopilot) || + fs.existsSync(agentFileCodex) || + kimiAgentInstalled + ) { + installed.push(agent); + } else { + missing.push(agent); + } + } + + // ── Manifest-backed completeness check ────────────────────────────────────── + // If a gsd-file-manifest.json exists alongside the agents dir (parent dir), + // verify that every manifest-tracked file for each expected agent is present + // on disk. Missing manifest-tracked files indicate an incomplete install even + // when the plain presence check above passed (e.g. .md present, .toml absent). + // If no manifest is found the check is a no-op (graceful for claude/bundled). + const incomplete: string[] = []; + const manifestPath = path.join(path.dirname(agentsDir), 'gsd-file-manifest.json'); + let manifestFiles: Record = {}; + try { + const raw = fs.readFileSync(manifestPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if ( + parsed !== null && + typeof parsed === 'object' && + 'files' in parsed && + typeof (parsed as Record)['files'] === 'object' && + (parsed as Record)['files'] !== null + ) { + manifestFiles = (parsed as Record>)['files']; + } + } catch { + // No manifest or unreadable — completeness check is skipped + } + + if (Object.keys(manifestFiles).length > 0) { + for (const agent of expectedAgents) { + // Find all manifest keys that belong to this agent: + // key must be "agents/." with no further path segments. + const agentPrefix = `agents/${agent}.`; + const agentManifestKeys = Object.keys(manifestFiles).filter(key => { + if (!key.startsWith(agentPrefix)) return false; + const rest = key.slice(agentPrefix.length); + // rest must be a bare extension (no slashes, non-empty) + return rest.length > 0 && !rest.includes('/'); + }); + if (agentManifestKeys.length === 0) { + // Agent not tracked in manifest — skip completeness check for this agent + continue; + } + const allPresent = agentManifestKeys.every(key => { + const basename = key.slice('agents/'.length); + return fs.existsSync(path.join(agentsDir, basename)); + }); + if (!allPresent) { + incomplete.push(agent); + } + } + } + + return { + agents_installed: installed.length > 0 && missing.length === 0 && incomplete.length === 0, + missing_agents: missing, + installed_agents: installed, + incomplete_agents: incomplete, + agents_dir: agentsDir, + agent_runtime: resolvedRuntime, + }; +} + +export = { + getAgentsDir, + checkAgentsInstalled, +}; diff --git a/src/core.cts b/src/core.cts index 3b1ee1491..9b0589105 100644 --- a/src/core.cts +++ b/src/core.cts @@ -6,9 +6,7 @@ * from the prior hand-written .cjs; only strict types are added. */ -import fs from 'node:fs'; -import path from 'node:path'; -import { execGit } from './shell-command-projection.cjs'; +// (fs, path, execGit removed — last callers relocated to leaf modules during #1268 T0 rehome) // eslint-disable-next-line @typescript-eslint/no-require-imports import ioModule = require('./io.cjs'); const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule; @@ -18,17 +16,10 @@ const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFro // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserModule = require('./roadmap-parser.cjs'); const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, getRoadmapPhaseInternal, getMilestoneInfo, getMilestonePhaseFilter } = roadmapParserModule; -// eslint-disable-next-line @typescript-eslint/no-require-imports -import modelProfiles = require('./model-profiles.cjs'); -const { MODEL_PROFILES, VALID_PHASE_TYPES: _VALID_PHASE_TYPES } = modelProfiles; import { RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, KNOWN_PROVIDERS, MODEL_ALIAS_MAP } from './model-catalog.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import worktreeSafety = require('./worktree-safety.cjs'); const { - resolveWorktreeContext, - parseWorktreePorcelain: parseWorktreePorcelainPolicy, - planWorktreePrune, - executeWorktreePrunePlan, inspectWorktreeHealth, } = worktreeSafety; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -60,7 +51,10 @@ const { import phaseLocatorModule = require('./phase-locator.cjs'); const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule; import { findProjectRoot } from './project-root.cjs'; -import { getGlobalConfigDir } from './runtime-homes.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import agentInstallCheck = require('./agent-install-check.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import gitBaseBranch = require('./git-base-branch.cjs'); // ─── Config Loader Module (extracted from core, ADR-857 phase 2e / #885) ───── // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -70,7 +64,6 @@ const { isGitIgnored, CONFIG_DEFAULTS, _warnUnknownProfileOverrides, - _resetRuntimeWarningCacheForTests, RUNTIME_OVERRIDE_TIERS, } = configLoaderModule; @@ -91,7 +84,6 @@ const { resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, - _resetModelPolicyWarningCacheForTests, } = modelResolverModule; // ─── Path helpers ──────────────────────────────────────────────────────────── @@ -106,60 +98,6 @@ const { // (from configLoaderModule) make them available to core-internal callers; // core.cjs re-exports loadConfig and isGitIgnored for back-compat. -// ─── Common path helpers ────────────────────────────────────────────────────── - -/** - * Resolve the main worktree root when running inside a git worktree. - * In a linked worktree, .planning/ lives in the main worktree, not in the linked one. - * Returns the main worktree path, or cwd if not in a worktree. - */ -function resolveWorktreeRoot(cwd: string): string { - const context = resolveWorktreeContext(cwd, { - existsSync: fs.existsSync, - }); - return context.effectiveRoot; -} - -/** - * Parse `git worktree list --porcelain` output into an array of - * { path, branch } objects. Entries with a detached HEAD (no branch line) - * are skipped because we cannot safely reason about their merge status. - * - * @param porcelain - raw output from git worktree list --porcelain - * @returns {{ path: string, branch: string }[]} - */ -function parseWorktreePorcelain(porcelain: string): Array<{ path: string; branch: string }> { - return parseWorktreePorcelainPolicy(porcelain); -} - -/** - * Clear stale worktree metadata references via `git worktree prune`. - * - * Destructive linked-worktree removal is disabled by default for safety. - * - * @param repoRoot - absolute path to the main (or any) worktree of - * the repository; used as `cwd` for git commands. - * @returns list of worktree paths that were removed (always empty) - */ -function pruneOrphanedWorktrees(repoRoot: string): string[] { - try { - const plan = planWorktreePrune( - repoRoot, - { allowDestructive: false }, - { parseWorktreePorcelain } - ); - const pruneResult = executeWorktreePrunePlan(plan) as { timedOut?: boolean } | null; - if (pruneResult && pruneResult.timedOut) { - process.stderr.write( - '[gsd-tools] WARNING: worktree health check degraded' + - ' — git worktree prune timed out after 10s.' + - ' Orphaned worktree metadata may remain until the next successful run.\n' - ); - } - } catch { /* never crash the caller */ } - return []; -} - // ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ─── // ─── Phase utilities (pure helpers re-exported from phase-id.cjs) ───────────── @@ -184,149 +122,17 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] { // — all imported via `roadmapParserModule` above; internal callers use the destructured bindings. // ─── Agent installation validation (#1371) ─────────────────────────────────── - -/** - * Resolve the agents directory for the given runtime. - * - * Priority: - * 1. GSD_AGENTS_DIR env var (explicit override, any runtime) - * 2. For claude runtime: __dirname-relative path (agents/ sibling of gsd-core/) - * This is correct for both repo runs and real installs (the runtime config dir's - * agents/ folder) because gsd-tools.cjs lives inside gsd-core/bin/ in both cases. - * 3. For non-claude runtimes: getGlobalConfigDir(runtime)/agents - * - * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude' - */ -function getAgentsDir(runtime?: string): string { - if (process.env['GSD_AGENTS_DIR']) { - return process.env['GSD_AGENTS_DIR']; - } - const resolved = runtime ?? (process.env['GSD_RUNTIME'] || 'claude'); - if (resolved === 'claude') { - return path.join(__dirname, '..', '..', '..', 'agents'); - } - return path.join(getGlobalConfigDir(resolved), 'agents'); -} - -interface AgentsInstalledResult { - agents_installed: boolean; - missing_agents: string[]; - installed_agents: string[]; - incomplete_agents: string[]; - agents_dir: string; - agent_runtime: string; -} - -/** - * Check which GSD agents are installed on disk. - * - * @param runtime - the active runtime name; defaults to GSD_RUNTIME env, then 'claude' - */ -function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { - const resolvedRuntime = runtime ?? (process.env['GSD_RUNTIME'] || 'claude'); - const agentsDir = getAgentsDir(resolvedRuntime); - const expectedAgents = Object.keys(MODEL_PROFILES); - const installed: string[] = []; - const missing: string[] = []; - - if (!fs.existsSync(agentsDir)) { - return { - agents_installed: false, - missing_agents: expectedAgents, - installed_agents: [], - incomplete_agents: [], - agents_dir: agentsDir, - agent_runtime: resolvedRuntime, - }; - } - - for (const agent of expectedAgents) { - const agentFile = path.join(agentsDir, `${agent}.md`); - const agentFileCopilot = path.join(agentsDir, `${agent}.agent.md`); - const agentFileCodex = path.join(agentsDir, `${agent}.toml`); - const agentFileKimiYaml = path.join(agentsDir, 'subagents', `${agent}.yaml`); - const agentFileKimiPrompt = path.join(agentsDir, 'subagents', `${agent}.md`); - const kimiAgentInstalled = - resolvedRuntime === 'kimi' && - fs.existsSync(agentFileKimiYaml) && - fs.existsSync(agentFileKimiPrompt); - if ( - fs.existsSync(agentFile) || - fs.existsSync(agentFileCopilot) || - fs.existsSync(agentFileCodex) || - kimiAgentInstalled - ) { - installed.push(agent); - } else { - missing.push(agent); - } - } - - // ── Manifest-backed completeness check ────────────────────────────────────── - // If a gsd-file-manifest.json exists alongside the agents dir (parent dir), - // verify that every manifest-tracked file for each expected agent is present - // on disk. Missing manifest-tracked files indicate an incomplete install even - // when the plain presence check above passed (e.g. .md present, .toml absent). - // If no manifest is found the check is a no-op (graceful for claude/bundled). - const incomplete: string[] = []; - const manifestPath = path.join(path.dirname(agentsDir), 'gsd-file-manifest.json'); - let manifestFiles: Record = {}; - try { - const raw = fs.readFileSync(manifestPath, 'utf8'); - const parsed: unknown = JSON.parse(raw); - if ( - parsed !== null && - typeof parsed === 'object' && - 'files' in parsed && - typeof (parsed as Record)['files'] === 'object' && - (parsed as Record)['files'] !== null - ) { - manifestFiles = (parsed as Record>)['files']; - } - } catch { - // No manifest or unreadable — completeness check is skipped - } - - if (Object.keys(manifestFiles).length > 0) { - for (const agent of expectedAgents) { - // Find all manifest keys that belong to this agent: - // key must be "agents/." with no further path segments. - const agentPrefix = `agents/${agent}.`; - const agentManifestKeys = Object.keys(manifestFiles).filter(key => { - if (!key.startsWith(agentPrefix)) return false; - const rest = key.slice(agentPrefix.length); - // rest must be a bare extension (no slashes, non-empty) - return rest.length > 0 && !rest.includes('/'); - }); - if (agentManifestKeys.length === 0) { - // Agent not tracked in manifest — skip completeness check for this agent - continue; - } - const allPresent = agentManifestKeys.every(key => { - const basename = key.slice('agents/'.length); - return fs.existsSync(path.join(agentsDir, basename)); - }); - if (!allPresent) { - incomplete.push(agent); - } - } - } - - return { - agents_installed: installed.length > 0 && missing.length === 0 && incomplete.length === 0, - missing_agents: missing, - installed_agents: installed, - incomplete_agents: incomplete, - agents_dir: agentsDir, - agent_runtime: resolvedRuntime, - }; -} +// getAgentsDir and checkAgentsInstalled moved to agent-install-check.cjs (T0 #1268). +// The destructured bindings above (from agentInstallCheck) make them available to +// core-internal callers; core.cjs re-exports both symbols by reference for back-compat. // ─── Model alias resolution ─────────────────────────────────────────────────── // RUNTIME_OVERRIDE_TIERS, _warnedConfigKeys, _warnUnknownProfileOverrides, and -// _resetRuntimeWarningCacheForTests moved to config-loader.cjs (ADR-857 phase 2e / #885). -// The destructured bindings above (from configLoaderModule) make them available to -// core-internal callers; _resetRuntimeWarningCacheForTests is re-exported for back-compat. +// _resetRuntimeWarningCacheForTests moved to config-loader.cjs (ADR-857 phase 2e / #885) +// and model-resolver.cjs (ADR-857 phase 2f / #888) respectively. +// _resetRuntimeWarningCacheForTests is NO LONGER re-exported from core.cjs; callers +// must import it directly from config-loader.cjs / model-resolver.cjs (or use +// the shared resetRuntimeWarningCaches() helper in tests/helpers.cjs). // resolveTierEntry, resolveModelPolicy, resolveModelInternal, VALID_GRANULARITIES, // resolveGranularityInternal, assertValidGranularityOverride, resolveModelForTier, @@ -342,38 +148,6 @@ function checkAgentsInstalled(runtime?: string): AgentsInstalledResult { // The destructured bindings above (from coreUtilsModule) make them available // to core-internal callers; core.cjs re-exports the public ones for back-compat. -// ─── Misc utilities (remaining in core) ────────────────────────────────────── - -interface GitWorktreeInfo { - inside: boolean; - worktreeRoot: string | null; -} - -/** - * Detect whether `cwd` sits inside a git worktree, and if so, return the - * absolute path of the worktree root. - */ -function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo { - try { - const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 }); - if (insideResult.exitCode !== 0) { - return { inside: false, worktreeRoot: null }; - } - const insideStdout = String(insideResult.stdout || '').trim(); - if (insideStdout !== 'true') { - return { inside: false, worktreeRoot: null }; - } - const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 }); - if (rootResult.exitCode !== 0) { - return { inside: true, worktreeRoot: null }; - } - const root = String(rootResult.stdout || '').trim(); - return { inside: true, worktreeRoot: root || null }; - } catch { - return { inside: false, worktreeRoot: null }; - } -} - // MilestoneInfo, MilestonePhaseFilter, getMilestoneInfo, getMilestonePhaseFilter // — all re-exported from roadmap-parser.cjs via roadmapParserModule above. @@ -417,12 +191,8 @@ export = { resolveTierEntry, resolveModelPolicy, KNOWN_PROVIDERS, - _resetRuntimeWarningCacheForTests: (): void => { - _resetRuntimeWarningCacheForTests(); - _resetModelPolicyWarningCacheForTests(); - }, pathExistsInternal, - gitWorktreeInfoInternal, + gitWorktreeInfoInternal: gitBaseBranch.gitWorktreeInfoInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, @@ -431,7 +201,7 @@ export = { replaceInCurrentMilestone, toPosixPath, extractOneLinerFromBody, - resolveWorktreeRoot, + resolveWorktreeRoot: worktreeSafety.resolveWorktreeRoot, // Deprecated re-exports — prefer direct import from planning-workspace.cjs withPlanningLock, findProjectRoot, @@ -449,9 +219,9 @@ export = { filterSummaryFiles, getPhaseFileStats, readSubdirectories, - getAgentsDir, - checkAgentsInstalled, + getAgentsDir: agentInstallCheck.getAgentsDir, + checkAgentsInstalled: agentInstallCheck.checkAgentsInstalled, timeAgo, - pruneOrphanedWorktrees, + pruneOrphanedWorktrees: worktreeSafety.pruneOrphanedWorktrees, inspectWorktreeHealth, }; diff --git a/src/git-base-branch.cts b/src/git-base-branch.cts index 965223520..a9e8dc4df 100644 --- a/src/git-base-branch.cts +++ b/src/git-base-branch.cts @@ -199,6 +199,38 @@ export function resolveBaseBranch( return 'main'; } +// ─── gitWorktreeInfoInternal (moved from core.cjs, ADR-857 T0 #1268) ───────── + +export interface GitWorktreeInfo { + inside: boolean; + worktreeRoot: string | null; +} + +/** + * Detect whether `cwd` sits inside a git worktree, and if so, return the + * absolute path of the worktree root. + */ +export function gitWorktreeInfoInternal(cwd: string): GitWorktreeInfo { + try { + const insideResult = execGitSeam(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 }); + if (insideResult.exitCode !== 0) { + return { inside: false, worktreeRoot: null }; + } + const insideStdout = String(insideResult.stdout || '').trim(); + if (insideStdout !== 'true') { + return { inside: false, worktreeRoot: null }; + } + const rootResult = execGitSeam(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 }); + if (rootResult.exitCode !== 0) { + return { inside: true, worktreeRoot: null }; + } + const root = String(rootResult.stdout || '').trim(); + return { inside: true, worktreeRoot: root || null }; + } catch { + return { inside: false, worktreeRoot: null }; + } +} + // ─── CLI entry point ────────────────────────────────────────────────────────── /** diff --git a/src/worktree-safety.cts b/src/worktree-safety.cts index a7e00af69..38f327cbc 100644 --- a/src/worktree-safety.cts +++ b/src/worktree-safety.cts @@ -1105,6 +1105,48 @@ function cmdWorktreeReapOrphans(cwd: string): void { // Unused exports kept for API compatibility void parseWorktreeListPaths; +// ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ───────────── + +/** + * Resolve the main worktree root when running inside a git worktree. + * In a linked worktree, .planning/ lives in the main worktree, not in the linked one. + * Returns the main worktree path, or cwd if not in a worktree. + */ +function resolveWorktreeRoot(cwd: string): string { + const context = resolveWorktreeContext(cwd, { + existsSync: fs.existsSync, + }); + return context.effectiveRoot; +} + +/** + * Clear stale worktree metadata references via `git worktree prune`. + * + * Destructive linked-worktree removal is disabled by default for safety. + * + * @param repoRoot - absolute path to the main (or any) worktree of + * the repository; used as `cwd` for git commands. + * @returns list of worktree paths that were removed (always empty) + */ +function pruneOrphanedWorktrees(repoRoot: string): string[] { + try { + const plan = planWorktreePrune( + repoRoot, + { allowDestructive: false }, + { parseWorktreePorcelain } + ); + const pruneResult = executeWorktreePrunePlan(plan) as { timedOut?: boolean } | null; + if (pruneResult && pruneResult.timedOut) { + process.stderr.write( + '[gsd-tools] WARNING: worktree health check degraded' + + ' — git worktree prune timed out after 10s.' + + ' Orphaned worktree metadata may remain until the next successful run.\n' + ); + } + } catch { /* never crash the caller */ } + return []; +} + export = { resolveWorktreeContext, parseWorktreePorcelain, @@ -1119,4 +1161,6 @@ export = { cmdWorktreeCleanupWave, reapOrphanWorktrees, cmdWorktreeReapOrphans, + resolveWorktreeRoot, + pruneOrphanedWorktrees, }; diff --git a/tests/agent-install-check.test.cjs b/tests/agent-install-check.test.cjs new file mode 100644 index 000000000..69ea849ec --- /dev/null +++ b/tests/agent-install-check.test.cjs @@ -0,0 +1,242 @@ +'use strict'; + +/** + * Agent Install Check Module — relocation tests (#1268 T0) + * + * Seam: gsd-core/bin/lib/agent-install-check.cjs + * Interface: getAgentsDir, checkAgentsInstalled + * + * Verifies: + * 1. Object identity — core re-exports by reference (not re-wrapped) + * 2. getAgentsDir behaviour: GSD_AGENTS_DIR override, claude path, non-claude path + * 3. checkAgentsInstalled behaviour against temp dirs via GSD_AGENTS_DIR: + * - missing dir → agents_installed:false, missing_agents = all expected + * - existing-but-empty dir → installed_agents:[], agents_installed:false + * - no manifest → completeness skipped (incomplete_agents empty) + * - partial manifest (agent.toml absent, agent.md present) → incomplete_agents includes agent + * - malformed manifest → no throw, completeness skipped + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const AGENT_INSTALL_CHECK_PATH = path.join( + __dirname, '..', 'gsd-core', 'bin', 'lib', 'agent-install-check.cjs' +); +const CORE_PATH = path.join( + __dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs' +); +const RUNTIME_HOMES_PATH = path.join( + __dirname, '..', 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs' +); + +const agentInstallCheck = require(AGENT_INSTALL_CHECK_PATH); +const core = require(CORE_PATH); +const { getGlobalConfigDir } = require(RUNTIME_HOMES_PATH); + +// Get EXPECTED_AGENTS from model-profiles (same source of truth) +const MODEL_PROFILES = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'model-profiles.cjs')).MODEL_PROFILES; +const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); + +// ─── Environment isolation ──────────────────────────────────────────────────── + +let savedAgentsDir; +let savedRuntime; + +beforeEach(() => { + savedAgentsDir = process.env['GSD_AGENTS_DIR']; + savedRuntime = process.env['GSD_RUNTIME']; + delete process.env['GSD_AGENTS_DIR']; + delete process.env['GSD_RUNTIME']; +}); + +afterEach(() => { + if (savedAgentsDir === undefined) { + delete process.env['GSD_AGENTS_DIR']; + } else { + process.env['GSD_AGENTS_DIR'] = savedAgentsDir; + } + if (savedRuntime === undefined) { + delete process.env['GSD_RUNTIME']; + } else { + process.env['GSD_RUNTIME'] = savedRuntime; + } +}); + +// ─── 1. Object identity ─────────────────────────────────────────────────────── + +describe('agent-install-check: object identity with core re-exports', () => { + test('core.getAgentsDir === agentInstallCheck.getAgentsDir (by reference)', () => { + assert.strictEqual( + core.getAgentsDir, + agentInstallCheck.getAgentsDir, + 'core.getAgentsDir must be the same function reference as agentInstallCheck.getAgentsDir' + ); + }); + + test('core.checkAgentsInstalled === agentInstallCheck.checkAgentsInstalled (by reference)', () => { + assert.strictEqual( + core.checkAgentsInstalled, + agentInstallCheck.checkAgentsInstalled, + 'core.checkAgentsInstalled must be the same function reference as agentInstallCheck.checkAgentsInstalled' + ); + }); +}); + +// ─── 2. getAgentsDir behaviour ──────────────────────────────────────────────── + +describe('getAgentsDir', () => { + test('GSD_AGENTS_DIR override takes priority', () => { + process.env['GSD_AGENTS_DIR'] = '/tmp/x'; + assert.strictEqual(agentInstallCheck.getAgentsDir(), '/tmp/x'); + assert.strictEqual(agentInstallCheck.getAgentsDir('cursor'), '/tmp/x'); + }); + + test('claude runtime returns __dirname-relative path', () => { + // getAgentsDir('claude') from both module and core must agree + const fromModule = agentInstallCheck.getAgentsDir('claude'); + const fromCore = core.getAgentsDir('claude'); + assert.strictEqual(fromModule, fromCore); + // Should end with /agents + assert.ok(fromModule.endsWith(path.sep + 'agents') || fromModule.endsWith('/agents'), + `Expected path to end with /agents, got: ${fromModule}`); + }); + + test('non-claude runtime returns getGlobalConfigDir(runtime)/agents', () => { + const runtime = 'cursor'; + const expected = path.join(getGlobalConfigDir(runtime), 'agents'); + assert.strictEqual(agentInstallCheck.getAgentsDir(runtime), expected); + }); + + test('GSD_RUNTIME env var is respected when no argument provided', () => { + process.env['GSD_RUNTIME'] = 'codex'; + const expected = path.join(getGlobalConfigDir('codex'), 'agents'); + assert.strictEqual(agentInstallCheck.getAgentsDir(), expected); + }); + + test('defaults to claude when no arg and no GSD_RUNTIME', () => { + const fromModule = agentInstallCheck.getAgentsDir(); + const fromClaude = agentInstallCheck.getAgentsDir('claude'); + assert.strictEqual(fromModule, fromClaude); + }); +}); + +// ─── 3. checkAgentsInstalled behaviour ─────────────────────────────────────── + +describe('checkAgentsInstalled', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gsd-agent-check-'); + // Point GSD_AGENTS_DIR at a path we control + process.env['GSD_AGENTS_DIR'] = path.join(tmpDir, 'agents'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('missing dir → agents_installed:false, missing_agents = all expected', () => { + // agents dir does not exist + const result = agentInstallCheck.checkAgentsInstalled(); + assert.strictEqual(result.agents_installed, false); + assert.deepStrictEqual(result.missing_agents, EXPECTED_AGENTS); + assert.deepStrictEqual(result.installed_agents, []); + assert.deepStrictEqual(result.incomplete_agents, []); + }); + + test('existing-but-empty dir → installed_agents:[], agents_installed:false', () => { + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + const result = agentInstallCheck.checkAgentsInstalled(); + assert.strictEqual(result.agents_installed, false); + assert.deepStrictEqual(result.installed_agents, []); + assert.ok(result.missing_agents.length > 0, 'missing_agents should not be empty'); + // No manifest → completeness skipped + assert.deepStrictEqual(result.incomplete_agents, []); + }); + + test('all agents present, no manifest → agents_installed:true, incomplete_agents:[]', () => { + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Write all expected agent .md files + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + + const result = agentInstallCheck.checkAgentsInstalled(); + assert.strictEqual(result.agents_installed, true); + assert.deepStrictEqual(result.missing_agents, []); + assert.deepStrictEqual(result.installed_agents, EXPECTED_AGENTS); + assert.deepStrictEqual(result.incomplete_agents, []); + }); + + test('partial manifest: agent.toml absent but agent.md present → incomplete_agents includes agent', () => { + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + // Write all agent .md files so presence check passes + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + + // Pick the first expected agent to make "incomplete" via manifest + const targetAgent = EXPECTED_AGENTS[0]; + + // Write manifest that tracks agent.toml for targetAgent (absent on disk) + // and tracks agent.md for all others (present) + const manifestFiles = {}; + for (const agent of EXPECTED_AGENTS) { + manifestFiles[`agents/${agent}.md`] = {}; + } + // Add a .toml for targetAgent to manifest (not present on disk) + manifestFiles[`agents/${targetAgent}.toml`] = {}; + + const manifest = { files: manifestFiles }; + fs.writeFileSync( + path.join(tmpDir, 'gsd-file-manifest.json'), + JSON.stringify(manifest) + ); + + const result = agentInstallCheck.checkAgentsInstalled(); + assert.ok(result.incomplete_agents.includes(targetAgent), + `Expected ${targetAgent} in incomplete_agents, got: ${JSON.stringify(result.incomplete_agents)}`); + assert.strictEqual(result.agents_installed, false, + 'agents_installed must be false when any agent is incomplete'); + }); + + test('malformed manifest → no throw, completeness skipped (incomplete_agents:[])', () => { + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Write all agent files + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + + // Write malformed manifest + fs.writeFileSync(path.join(tmpDir, 'gsd-file-manifest.json'), '{not json"'); + + let result; + assert.doesNotThrow(() => { + result = agentInstallCheck.checkAgentsInstalled(); + }); + // Malformed → completeness skipped → incomplete_agents empty + assert.deepStrictEqual(result.incomplete_agents, []); + // But presence check still passed + assert.strictEqual(result.agents_installed, true); + }); + + test('agents_dir and agent_runtime are returned in result', () => { + const agentsDir = path.join(tmpDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + + const result = agentInstallCheck.checkAgentsInstalled('cursor'); + // GSD_AGENTS_DIR overrides, so agents_dir = our tmp path + assert.strictEqual(result.agents_dir, agentsDir); + assert.strictEqual(result.agent_runtime, 'cursor'); + }); +}); diff --git a/tests/feat-49-model-policy-presets.test.cjs b/tests/feat-49-model-policy-presets.test.cjs index f8b85d893..5d43c07ae 100644 --- a/tests/feat-49-model-policy-presets.test.cjs +++ b/tests/feat-49-model-policy-presets.test.cjs @@ -64,14 +64,13 @@ const { resolveModelPolicy, resolveModelForTier, KNOWN_PROVIDERS, - _resetRuntimeWarningCacheForTests, } = require('../gsd-core/bin/lib/core.cjs'); // KNOWN_PROVIDERS must also be exported directly from model-catalog.cjs const modelCatalog = require('../gsd-core/bin/lib/model-catalog.cjs'); const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, resetRuntimeWarningCaches } = require('./helpers.cjs'); const makeTmp = (prefix) => createTempDir(`gsd-49-${prefix}-`); @@ -277,11 +276,11 @@ describe('#49 resolveModelInternal: model_policy in the resolution chain', () => let projectDir; beforeEach(() => { projectDir = makeTmp('internal'); - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); }); afterEach(() => { rmr(projectDir); - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); }); test('model_policy fires before model_profile_overrides when both are set (model_policy wins)', () => { @@ -396,7 +395,7 @@ describe('#49 resolveModelInternal: model_policy in the resolution chain', () => }); test('unmappable model_policy ID warns and falls back to the tier alias on claude (#1133)', () => { - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); writeConfig(projectDir, { runtime: 'claude', model_profile: 'balanced', @@ -527,7 +526,7 @@ describe('#49 resolveModelInternal: unknown provider warning behavior', () => { beforeEach(() => { projectDir = makeTmp('warnings'); - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); captured = []; origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk) => { captured.push(String(chunk)); return true; }; @@ -536,7 +535,7 @@ describe('#49 resolveModelInternal: unknown provider warning behavior', () => { afterEach(() => { process.stderr.write = origWrite; rmr(projectDir); - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); }); test('unknown provider in model_policy → falls through to model_profile_overrides, emits stderr warning once', () => { diff --git a/tests/git-base-branch.test.cjs b/tests/git-base-branch.test.cjs index 3ebfb4e46..064fd8e3f 100644 --- a/tests/git-base-branch.test.cjs +++ b/tests/git-base-branch.test.cjs @@ -259,3 +259,42 @@ describe('#1146: git.base-branch resolver', () => { } }); }); + +// ─── gitWorktreeInfoInternal: relocation identity + behaviour (#1268 T0) ───── + +const gitBaseBranch = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'git-base-branch.cjs')); +const core = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs')); +const { createTempGitProject, createTempDir } = require('./helpers.cjs'); + +describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => { + test('core.gitWorktreeInfoInternal === gitBaseBranch.gitWorktreeInfoInternal (by reference)', () => { + assert.strictEqual( + core.gitWorktreeInfoInternal, + gitBaseBranch.gitWorktreeInfoInternal, + 'core.gitWorktreeInfoInternal must be the same function reference as gitBaseBranch.gitWorktreeInfoInternal' + ); + }); + + test('gitWorktreeInfoInternal(createTempGitProject()) returns {inside:true, worktreeRoot:}', (t) => { + const dir = createTempGitProject('gsd-wt-info-'); + t.after(() => cleanup(dir)); + const result = gitBaseBranch.gitWorktreeInfoInternal(dir); + assert.strictEqual(result.inside, true, 'inside must be true for a git project dir'); + assert.ok(typeof result.worktreeRoot === 'string' && result.worktreeRoot.length > 0, + `worktreeRoot must be a non-empty string, got: ${JSON.stringify(result.worktreeRoot)}`); + }); + + test('gitWorktreeInfoInternal(createTempDir()) returns {inside:false, worktreeRoot:null} for a non-git dir', (t) => { + const dir = createTempDir('gsd-wt-info-nongit-'); + t.after(() => cleanup(dir)); + const result = gitBaseBranch.gitWorktreeInfoInternal(dir); + assert.strictEqual(result.inside, false, 'inside must be false for a non-git dir'); + assert.strictEqual(result.worktreeRoot, null, 'worktreeRoot must be null for a non-git dir'); + }); + + test('gitWorktreeInfoInternal never throws (non-git dir)', (t) => { + const dir = createTempDir('gsd-wt-info-nothrow-'); + t.after(() => cleanup(dir)); + assert.doesNotThrow(() => gitBaseBranch.gitWorktreeInfoInternal(dir)); + }); +}); diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 3e8c3d562..4fe19c474 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -399,4 +399,18 @@ async function waitFor(predicate, { timeoutMs = 10000, stepMs = 25, message = 'w } } -module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, TOOLS_PATH }; +/** + * Reset all runtime-warning caches in config-loader.cjs and model-resolver.cjs. + * + * Use this in beforeEach/afterEach hooks in tests that exercise warning-emission + * paths so that each test starts with a clean slate. Replaces the duplicated local + * `_resetRuntimeWarningCacheForTests` wrappers in individual test files. + */ +function resetRuntimeWarningCaches() { + const configLoader = require('../gsd-core/bin/lib/config-loader.cjs'); + const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs'); + configLoader._resetRuntimeWarningCacheForTests(); + modelResolver._resetModelPolicyWarningCacheForTests(); +} + +module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, TOOLS_PATH }; diff --git a/tests/issue-2517-runtime-aware-profiles.test.cjs b/tests/issue-2517-runtime-aware-profiles.test.cjs index c9f8a7006..48111e792 100644 --- a/tests/issue-2517-runtime-aware-profiles.test.cjs +++ b/tests/issue-2517-runtime-aware-profiles.test.cjs @@ -30,7 +30,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { createTempProject, cleanup } = require('./helpers.cjs'); +const { createTempProject, cleanup, resetRuntimeWarningCaches } = require('./helpers.cjs'); const { resolveModelInternal, @@ -38,7 +38,6 @@ const { resolveTierEntry, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, - _resetRuntimeWarningCacheForTests, } = require('../gsd-core/bin/lib/core.cjs'); const { renderEffortForRuntime } = require('../gsd-core/bin/lib/model-catalog.cjs'); const { isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); @@ -75,7 +74,7 @@ function restoreHome() { // ─── Backwards compatibility — no `runtime` set ───────────────────────────── describe('issue #2517: backwards compat — no runtime key set', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('balanced profile returns Claude alias when runtime absent', () => { @@ -117,7 +116,7 @@ describe('issue #2517: backwards compat — no runtime key set', () => { // ─── runtime: "claude" — no-op (preserves Claude-native semantics) ────────── describe('issue #2517: runtime "claude" is a no-op for resolution (finding #4)', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('runtime:"claude" + balanced returns the alias, not the resolved Claude ID', () => { @@ -162,7 +161,7 @@ describe('issue #2517: runtime "claude" is a no-op for resolution (finding #4)', // ─── runtime: "codex" — resolves tiers to Codex IDs + reasoning_effort ────── describe('issue #2517: runtime "codex" — Codex tier resolution', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('opus tier -> gpt-5.5 model; heavy-tier agent -> xhigh effort on codex', () => { @@ -228,7 +227,7 @@ describe('issue #2517: runtime "codex" — Codex tier resolution', () => { // ─── Precedence chain ─────────────────────────────────────────────────────── describe('issue #2517: precedence chain', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('per-agent model_overrides wins over runtime tier resolution', () => { @@ -283,7 +282,7 @@ describe('issue #2517: precedence chain', () => { // ─── Field-merge semantics — review findings #2 ───────────────────────────── describe('issue #2517: field-merge of overrides with built-in defaults (finding #2)', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('string-shorthand override: model is overridden; unified effort derives from routing tier', () => { @@ -366,7 +365,7 @@ describe('issue #2517: field-merge of overrides with built-in defaults (finding // ─── Unknown runtime render safety (finding #3 spirit) ────────────────────── describe('issue #2517: unknown runtime render param is null (effort does not leak to install path)', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('unknown runtime: model resolves via override; render param is null (no wire param leaked)', () => { @@ -402,7 +401,7 @@ describe('issue #2517: unknown runtime render param is null (effort does not lea // ─── Unknown runtime / unknown tier ───────────────────────────────────────── describe('issue #2517: unknown runtime + safe fallback', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('unknown runtime falls back to Claude-alias safe default (no Codex IDs leaked)', () => { @@ -479,7 +478,7 @@ describe('issue #2517: loadConfig warns on unknown runtime/tier (findings #10, # beforeEach(() => { isolateHome(); tmpDir = createTempProject(); - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); captured = []; origWrite = process.stderr.write.bind(process.stderr); process.stderr.write = (chunk) => { captured.push(String(chunk)); return true; }; @@ -540,7 +539,7 @@ describe('issue #2517: install end-to-end — per-project config reaches Codex T const { readGsdRuntimeProfileResolver, generateCodexAgentToml } = installMod; let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('readGsdRuntimeProfileResolver picks up runtime from .planning/config.json', () => { @@ -640,7 +639,7 @@ describe('issue #2517: RUNTIME_PROFILE_MAP single source of truth (finding #16)' // ─── Issue #2612: gemini runtime tier resolution ───────────────────────────── describe('issue #2612: runtime "gemini" — Gemini tier resolution', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('opus tier -> gemini-3.1-pro-preview', () => { @@ -668,7 +667,7 @@ describe('issue #2612: runtime "gemini" — Gemini tier resolution', () => { // ─── Issue #2612: qwen runtime tier resolution ─────────────────────────────── describe('issue #2612: runtime "qwen" — Qwen tier resolution', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('opus tier -> qwen3-max-2026-01-23', () => { @@ -696,7 +695,7 @@ describe('issue #2612: runtime "qwen" — Qwen tier resolution', () => { // ─── Issue #2612: opencode runtime tier resolution ─────────────────────────── describe('issue #2612: runtime "opencode" — OpenCode tier resolution', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('opus tier -> anthropic/claude-opus-4-8', () => { @@ -724,7 +723,7 @@ describe('issue #2612: runtime "opencode" — OpenCode tier resolution', () => { // ─── Issue #2612: copilot runtime tier resolution ──────────────────────────── describe('issue #2612: runtime "copilot" — Copilot tier resolution', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('opus tier -> claude-opus-4-8', () => { @@ -787,7 +786,7 @@ describe('issue #2612: Group B runtimes — no built-in map, use unknown-runtime const { createTempProject, cleanup } = require('./helpers.cjs'); isolateHome(); const tmpDir = createTempProject(); - _resetRuntimeWarningCacheForTests(); + resetRuntimeWarningCaches(); try { writeConfig(tmpDir, { runtime: 'cursor', model_profile: 'quality' }); // Should fall back to Claude alias, not emit a provider-specific ID @@ -803,7 +802,7 @@ describe('issue #2612: Group B runtimes — no built-in map, use unknown-runtime // ─── Issue #2612: Partial override merge for new runtimes ──────────────────── describe('issue #2612: partial override merge for new Group A runtimes', () => { let tmpDir; - beforeEach(() => { isolateHome(); tmpDir = createTempProject(); _resetRuntimeWarningCacheForTests(); }); + beforeEach(() => { isolateHome(); tmpDir = createTempProject(); resetRuntimeWarningCaches(); }); afterEach(() => { cleanup(tmpDir); restoreHome(); }); test('gemini.opus override wins; sonnet and haiku use built-in defaults', () => { diff --git a/tests/lint-core-spine-imports.test.cjs b/tests/lint-core-spine-imports.test.cjs new file mode 100644 index 000000000..67585602f --- /dev/null +++ b/tests/lint-core-spine-imports.test.cjs @@ -0,0 +1,279 @@ +'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/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index 74826e56a..6e91bedf4 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -18,10 +18,14 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); +const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs'); const WORKTREE_SAFETY_PATH = path.join( __dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs' ); +const CORE_PATH = path.join( + __dirname, '..', 'gsd-core', 'bin', 'lib', 'core.cjs' +); const { resolveWorktreeContext, @@ -1243,3 +1247,52 @@ describe('executeWorktreeWaveCleanupPlan', () => { assert.equal(calls.some((call) => call === 'branch -D worktree-agent-a1'), false); }); }); + +// ─── MOVE 2: resolveWorktreeRoot and pruneOrphanedWorktrees (#1268 T0) ──────── + +describe('worktree-safety: resolveWorktreeRoot and pruneOrphanedWorktrees relocation identity', () => { + const worktreeSafety = require(WORKTREE_SAFETY_PATH); + const core = require(CORE_PATH); + + test('core.resolveWorktreeRoot === worktreeSafety.resolveWorktreeRoot (by reference)', () => { + assert.strictEqual( + core.resolveWorktreeRoot, + worktreeSafety.resolveWorktreeRoot, + 'core.resolveWorktreeRoot must be the same function reference as worktreeSafety.resolveWorktreeRoot' + ); + }); + + test('core.pruneOrphanedWorktrees === worktreeSafety.pruneOrphanedWorktrees (by reference)', () => { + assert.strictEqual( + core.pruneOrphanedWorktrees, + worktreeSafety.pruneOrphanedWorktrees, + 'core.pruneOrphanedWorktrees must be the same function reference as worktreeSafety.pruneOrphanedWorktrees' + ); + }); +}); + +describe('worktree-safety: resolveWorktreeRoot behaviour', () => { + const worktreeSafety = require(WORKTREE_SAFETY_PATH); + + test('resolveWorktreeRoot(createTempGitProject()) returns a non-empty string', (t) => { + const dir = createTempGitProject('gsd-wt-root-'); + t.after(() => cleanup(dir)); + const result = worktreeSafety.resolveWorktreeRoot(dir); + assert.ok(typeof result === 'string' && result.length > 0, + `Expected non-empty string, got: ${JSON.stringify(result)}`); + }); +}); + +describe('worktree-safety: pruneOrphanedWorktrees behaviour', () => { + const worktreeSafety = require(WORKTREE_SAFETY_PATH); + + test('pruneOrphanedWorktrees(temp dir) returns [] and does not throw', (t) => { + const dir = createTempDir('gsd-prune-'); + t.after(() => cleanup(dir)); + let result; + assert.doesNotThrow(() => { + result = worktreeSafety.pruneOrphanedWorktrees(dir); + }); + assert.deepStrictEqual(result, []); + }); +});