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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-15 15:08:58 -04:00
committed by GitHub
parent 51b23c248a
commit 48d9cec6fe
19 changed files with 1177 additions and 277 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -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`).

View File

@@ -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",

View File

@@ -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',

View File

@@ -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`

View File

@@ -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",

View File

@@ -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"
]
}

View File

@@ -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<string>} extSet - File extensions to include (e.g. new Set(['.cts','.cjs'])).
* @param {Set<string>} 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<string>} 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<string>}
*/
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 };

159
src/agent-install-check.cts Normal file
View File

@@ -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<string, unknown> = {};
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<string, unknown>)['files'] === 'object' &&
(parsed as Record<string, unknown>)['files'] !== null
) {
manifestFiles = (parsed as Record<string, Record<string, unknown>>)['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/<agentName>.<ext>" 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,
};

View File

@@ -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<string, unknown> = {};
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<string, unknown>)['files'] === 'object' &&
(parsed as Record<string, unknown>)['files'] !== null
) {
manifestFiles = (parsed as Record<string, Record<string, unknown>>)['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/<agentName>.<ext>" 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,
};

View File

@@ -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 ──────────────────────────────────────────────────────────
/**

View File

@@ -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,
};

View File

@@ -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');
});
});

View File

@@ -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', () => {

View File

@@ -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:<non-empty string>}', (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));
});
});

View File

@@ -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 };

View File

@@ -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', () => {

View File

@@ -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}`,
);
});
});

View File

@@ -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, []);
});
});