fix: enforce GSDError usage and activate unused error codes (#997)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-17 17:14:07 -06:00 committed by GitHub
parent 89ee5e439a
commit f2838d326f
12 changed files with 46 additions and 33 deletions

View file

@ -11,6 +11,7 @@
import { writeFileSync, writeSync, mkdirSync, readdirSync, unlinkSync, statSync, openSync, closeSync, constants } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import { GSDError, GSD_IO_ERROR } from "./errors.js";
const SEQ_PREFIX_RE = /^(\d+)-/;
import type { ExtensionContext } from "@gsd/pi-coding-agent";
@ -95,7 +96,7 @@ function nextActivityFilePath(
}
}
// Fallback: should never reach here in practice
throw new Error(`Failed to find available activity log sequence in ${activityDir}`);
throw new GSDError(GSD_IO_ERROR, `Failed to find available activity log sequence in ${activityDir}`);
}
export function saveActivityLog(

View file

@ -8,6 +8,7 @@
import { existsSync, cpSync, readFileSync, writeFileSync, readdirSync, mkdirSync, realpathSync, utimesSync, unlinkSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js";
import { execSync, execFileSync } from "node:child_process";
import {
@ -301,7 +302,8 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
} catch (err) {
// If chdir fails, the worktree was created but we couldn't enter it.
// Don't store originalBase -- caller can retry or clean up.
throw new Error(
throw new GSDError(
GSD_IO_ERROR,
`Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
@ -367,7 +369,8 @@ export function teardownAutoWorktree(
process.chdir(originalBasePath);
originalBase = null;
} catch (err) {
throw new Error(
throw new GSDError(
GSD_IO_ERROR,
`Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
);
}
@ -425,22 +428,22 @@ export function getAutoWorktreePath(basePath: string, milestoneId: string): stri
export function enterAutoWorktree(basePath: string, milestoneId: string): string {
const p = worktreePath(basePath, milestoneId);
if (!existsSync(p)) {
throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`);
throw new GSDError(GSD_IO_ERROR, `Auto-worktree for ${milestoneId} does not exist at ${p}`);
}
// Validate this is a real git worktree, not a stray directory (#695)
const gitPath = join(p, ".git");
if (!existsSync(gitPath)) {
throw new Error(`Auto-worktree path ${p} exists but is not a git worktree (no .git)`);
throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} exists but is not a git worktree (no .git)`);
}
try {
const content = readFileSync(gitPath, "utf8").trim();
if (!content.startsWith("gitdir: ")) {
throw new Error(`Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`);
throw new GSDError(GSD_GIT_ERROR, `Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`);
}
} catch (err) {
if (err instanceof Error && err.message.includes("worktree")) throw err;
throw new Error(`Auto-worktree path ${p} exists but .git is unreadable`);
throw new GSDError(GSD_IO_ERROR, `Auto-worktree path ${p} exists but .git is unreadable`);
}
const previousCwd = process.cwd();
@ -449,7 +452,8 @@ export function enterAutoWorktree(basePath: string, milestoneId: string): string
process.chdir(p);
originalBase = basePath;
} catch (err) {
throw new Error(
throw new GSDError(
GSD_IO_ERROR,
`Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
);
}

View file

@ -101,6 +101,7 @@ import {
getProjectTotals, formatCost, formatTokenCount,
} from "./metrics.js";
import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js";
import { GSDError, GSD_ARTIFACT_MISSING } from "./errors.js";
import { join } from "node:path";
import { sep as pathSep } from "node:path";
import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, unlinkSync, statSync } from "node:fs";
@ -2301,7 +2302,7 @@ async function dispatchNextUnit(
if (s.currentMilestoneId && isInAutoWorktree(s.basePath) && s.originalBasePath) {
try {
const roadmapPath = resolveMilestoneFile(s.originalBasePath, s.currentMilestoneId, "ROADMAP");
if (!roadmapPath) throw new Error(`Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
if (!roadmapPath) throw new GSDError(GSD_ARTIFACT_MISSING, `Cannot resolve ROADMAP file for milestone ${ s.currentMilestoneId }`);
const roadmapContent = readFileSync(roadmapPath, "utf-8");
const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
s.basePath = s.originalBasePath;

View file

@ -12,6 +12,7 @@ import { join, resolve } from 'node:path';
import type { Decision, Requirement } from './types.js';
import { resolveGsdRootFile } from './paths.js';
import { saveFile } from './files.js';
import { GSDError, GSD_STALE_STATE, GSD_IO_ERROR } from './errors.js';
// ─── Markdown Generators ──────────────────────────────────────────────────
@ -249,7 +250,7 @@ export async function updateRequirementInDb(
const existing = db.getRequirementById(id);
if (!existing) {
throw new Error(`Requirement ${id} not found`);
throw new GSDError(GSD_STALE_STATE, `Requirement ${id} not found`);
}
// Merge updates into existing
@ -331,7 +332,7 @@ export async function saveArtifactToDb(
const gsdDir = resolve(basePath, '.gsd');
const fullPath = resolve(basePath, '.gsd', opts.path);
if (!fullPath.startsWith(gsdDir)) {
throw new Error(`saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`);
throw new GSDError(GSD_IO_ERROR, `saveArtifactToDb: path escapes .gsd/ directory: ${opts.path}`);
}
await saveFile(fullPath, opts.content);
} catch (err) {

View file

@ -8,6 +8,7 @@
import { execFileSync, execFile } from "node:child_process";
import { resolve } from "node:path";
import { GSDError, GSD_PARSE_ERROR } from "./errors.js";
// ─── Types ──────────────────────────────────────────────────────────────────
@ -75,7 +76,7 @@ export async function getRecentlyChangedFiles(
try {
const days = Math.max(1, Math.floor(Number(sinceDays)));
if (!Number.isFinite(days)) throw new Error("invalid sinceDays");
if (!Number.isFinite(days)) throw new GSDError(GSD_PARSE_ERROR, "invalid sinceDays");
// Run all three queries concurrently — they read independent git state
const [logRaw, stagedRaw, statusRaw] = await Promise.all([

View file

@ -10,8 +10,6 @@
export const GSD_STALE_STATE = "GSD_STALE_STATE";
export const GSD_LOCK_HELD = "GSD_LOCK_HELD";
export const GSD_DISPATCH_FAILED = "GSD_DISPATCH_FAILED";
export const GSD_TIMEOUT = "GSD_TIMEOUT";
export const GSD_ARTIFACT_MISSING = "GSD_ARTIFACT_MISSING";
export const GSD_GIT_ERROR = "GSD_GIT_ERROR";
export const GSD_MERGE_CONFLICT = "GSD_MERGE_CONFLICT";

View file

@ -29,7 +29,7 @@ import {
nativeUpdateRef,
nativeAddPaths,
} from "./native-git-bridge.js";
import { GSDError, GSD_MERGE_CONFLICT } from "./errors.js";
import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
// ─── Types ─────────────────────────────────────────────────────────────────
@ -292,7 +292,7 @@ export function runGit(basePath: string, args: string[], options: { allowFailure
} catch (error) {
if (options.allowFailure) return "";
const message = error instanceof Error ? error.message : String(error);
throw new Error(`git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`);
throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}: ${filterGitSvnNoise(message)}`);
}
}

View file

@ -9,6 +9,7 @@ import { createRequire } from 'node:module';
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { Decision, Requirement } from './types.js';
import { GSDError, GSD_STALE_STATE } from './errors.js';
// Create a require function for loading native modules in ESM context
const _require = createRequire(import.meta.url);
@ -416,7 +417,7 @@ export function closeDatabase(): void {
* Runs a function inside a transaction. Rolls back on error.
*/
export function transaction<T>(fn: () => T): T {
if (!currentDb) throw new Error('gsd-db: No database open');
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.exec('BEGIN');
try {
const result = fn();
@ -434,7 +435,7 @@ export function transaction<T>(fn: () => T): T {
* Insert a decision. The `seq` field is auto-generated.
*/
export function insertDecision(d: Omit<Decision, 'seq'>): void {
if (!currentDb) throw new Error('gsd-db: No database open');
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`,
@ -495,7 +496,7 @@ export function getActiveDecisions(): Decision[] {
* Insert a requirement.
*/
export function insertRequirement(r: Requirement): void {
if (!currentDb) throw new Error('gsd-db: No database open');
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`,
@ -747,7 +748,7 @@ export function _resetProvider(): void {
* Insert or replace a decision. Uses the `id` UNIQUE constraint for idempotency.
*/
export function upsertDecision(d: Omit<Decision, 'seq'>): void {
if (!currentDb) throw new Error('gsd-db: No database open');
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
VALUES (:id, :when_context, :scope, :decision, :choice, :rationale, :revisable, :superseded_by)`,
@ -767,7 +768,7 @@ export function upsertDecision(d: Omit<Decision, 'seq'>): void {
* Insert or replace a requirement. Uses the `id` PK for idempotency.
*/
export function upsertRequirement(r: Requirement): void {
if (!currentDb) throw new Error('gsd-db: No database open');
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
VALUES (:id, :class, :status, :description, :why, :source, :primary_owner, :supporting_slices, :validation, :notes, :full_content, :superseded_by)`,
@ -813,7 +814,7 @@ export function insertArtifact(a: {
task_id: string | null;
full_content: string;
}): void {
if (!currentDb) throw new Error('gsd-db: No database open');
if (!currentDb) throw new GSDError(GSD_STALE_STATE, 'gsd-db: No database open');
currentDb.prepare(
`INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at)
VALUES (:path, :artifact_type, :milestone_id, :slice_id, :task_id, :full_content, :imported_at)`,

View file

@ -8,6 +8,7 @@
import { execSync, execFileSync } from "node:child_process";
import { existsSync, readFileSync, unlinkSync, rmSync } from "node:fs";
import { join } from "node:path";
import { GSDError, GSD_GIT_ERROR } from "./errors.js";
/** Env overlay that suppresses interactive git credential prompts and git-svn noise. */
const GIT_NO_PROMPT_ENV = {
@ -148,7 +149,7 @@ function gitExec(basePath: string, args: string[], allowFailure = false): string
}).trim();
} catch {
if (allowFailure) return "";
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}`);
}
}
@ -162,7 +163,7 @@ function gitFileExec(basePath: string, args: string[], allowFailure = false): st
}).trim();
} catch {
if (allowFailure) return "";
throw new Error(`git ${args.join(" ")} failed in ${basePath}`);
throw new GSDError(GSD_GIT_ERROR, `git ${args.join(" ")} failed in ${basePath}`);
}
}

View file

@ -19,6 +19,7 @@ import {
type MarketplaceDiscoveryResult,
type DiscoveredPlugin,
} from './marketplace-discovery.js';
import { GSDError, GSD_STALE_STATE } from './errors.js';
import {
NamespacedRegistry,
componentsFromDiscovery,
@ -252,7 +253,7 @@ export class PluginImporter {
componentFilter: (component: NamespacedComponent) => boolean
): NamespacedComponent[] {
if (!this.registry) {
throw new Error('Must call discover() before selectComponents()');
throw new GSDError(GSD_STALE_STATE, 'Must call discover() before selectComponents()');
}
return this.registry.getAll().filter(componentFilter);
@ -270,7 +271,7 @@ export class PluginImporter {
*/
validateImport(selected: NamespacedComponent[]): ValidationResult {
if (!this.registry) {
throw new Error('Must call discover() before validateImport()');
throw new GSDError(GSD_STALE_STATE, 'Must call discover() before validateImport()');
}
// Create a temporary resolver for the selected components

View file

@ -18,6 +18,7 @@
*/
import { readFileSync, readdirSync } from "node:fs";
import { GSDError, GSD_PARSE_ERROR } from "./errors.js";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
@ -87,10 +88,11 @@ export function loadPrompt(name: string, vars: Record<string, string> = {}): str
.map(m => m.slice(2, -2))
.filter(key => !(key in vars));
if (missing.length > 0) {
throw new Error(
throw new GSDError(
GSD_PARSE_ERROR,
`loadPrompt("${name}"): template declares {{${missing.join("}}, {{")}}}} but no value was provided. ` +
`This usually means the extension code in memory is older than the template on disk. ` +
`Restart pi to reload the extension.`
`Restart pi to reload the extension.`,
);
}
}

View file

@ -17,6 +17,7 @@
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
import {
nativeBranchDelete,
nativeBranchExists,
@ -121,14 +122,14 @@ export function worktreeBranchName(name: string): string {
export function createWorktree(basePath: string, name: string, opts: { branch?: string; startPoint?: string; reuseExistingBranch?: boolean } = {}): WorktreeInfo {
// Validate name: alphanumeric, hyphens, underscores only
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
throw new Error(`Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`);
throw new GSDError(GSD_PARSE_ERROR, `Invalid worktree name "${name}". Use only letters, numbers, hyphens, and underscores.`);
}
const wtPath = worktreePath(basePath, name);
const branch = opts.branch ?? worktreeBranchName(name);
if (existsSync(wtPath)) {
throw new Error(`Worktree "${name}" already exists at ${wtPath}`);
throw new GSDError(GSD_STALE_STATE, `Worktree "${name}" already exists at ${wtPath}`);
}
// Ensure the .gsd/worktrees/ directory exists
@ -151,7 +152,8 @@ export function createWorktree(basePath: string, name: string, opts: { branch?:
const branchInUse = worktreeEntries.some(entry => entry.branch === branch);
if (branchInUse) {
throw new Error(
throw new GSDError(
GSD_LOCK_HELD,
`Branch "${branch}" is already in use by another worktree. ` +
`Remove the existing worktree first with /worktree remove ${name}.`,
);
@ -432,12 +434,12 @@ export function mergeWorktreeToMain(basePath: string, name: string, commitMessag
const current = nativeGetCurrentBranch(basePath);
if (current !== mainBranch) {
throw new Error(`Must be on ${mainBranch} to merge. Currently on ${current}.`);
throw new GSDError(GSD_GIT_ERROR, `Must be on ${mainBranch} to merge. Currently on ${current}.`);
}
const result = nativeMergeSquash(basePath, branch);
if (!result.success) {
throw new Error(`Merge conflicts detected in: ${result.conflicts.join(", ")}`);
throw new GSDError(GSD_MERGE_CONFLICT, `Merge conflicts detected in: ${result.conflicts.join(", ")}`);
}
nativeCommit(basePath, commitMessage);