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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
scripts/lint-core-spine-imports.allowlist.json
Normal file
35
scripts/lint-core-spine-imports.allowlist.json
Normal 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"
|
||||
]
|
||||
}
|
||||
203
scripts/lint-core-spine-imports.cjs
Normal file
203
scripts/lint-core-spine-imports.cjs
Normal 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
159
src/agent-install-check.cts
Normal 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,
|
||||
};
|
||||
266
src/core.cts
266
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<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,
|
||||
};
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
242
tests/agent-install-check.test.cjs
Normal file
242
tests/agent-install-check.test.cjs
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
279
tests/lint-core-spine-imports.test.cjs
Normal file
279
tests/lint-core-spine-imports.test.cjs
Normal 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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user