feat(sdk): throw PhaseUatPassedError on missing projectDir (#3184)

Cycle 15 of ~15: introduces PhaseUatPassedError (extends
GSDError, classification Validation) with typed ERROR_CODE
enum. Missing-projectDir now fails fast with a typed error
rather than letting downstream code crash on a vague fs error.
This commit is contained in:
Tom Boucher
2026-05-18 22:56:04 -04:00
parent 7956d324c5
commit 99e81b2605
2 changed files with 38 additions and 2 deletions

View File

@@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { isPhaseUatPassed, REASON_CODE } from './phase-uat-passed.js';
import { isPhaseUatPassed, REASON_CODE, PhaseUatPassedError, ERROR_CODE } from './phase-uat-passed.js';
const UAT_PASS_CONTENT = `---
status: complete
@@ -306,6 +306,18 @@ result: pass
}
});
it("throws PhaseUatPassedError with PROJECT_DIR_MISSING code when projectDir does not exist", async () => {
const missingPath = `/nonexistent/path/that/should/never/be/real-${Date.now()}`;
let thrown: unknown;
try {
await isPhaseUatPassed(missingPath, '5');
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(PhaseUatPassedError);
expect((thrown as PhaseUatPassedError).code).toBe(ERROR_CODE.PROJECT_DIR_MISSING);
});
it("emits NO_ITEMS_EXTRACTED reason when UAT file has no parseable items, orphans, or placeholders", async () => {
const localTmp = await mkdtemp(join(tmpdir(), 'gsd-uat-c14-'));
try {

View File

@@ -5,11 +5,12 @@
* Non-pass items (result not literally 'pass') emit a typed NON_PASS_RESULT reason.
*/
import { readFile, readdir } from 'node:fs/promises';
import { readFile, readdir, stat } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { resolvePhaseDir } from './phase-list-queries.js';
import { extractFrontmatter } from './frontmatter.js';
import { parseVerificationFrontmatterItems } from './uat.js';
import { GSDError, ErrorClassification } from '../errors.js';
export const REASON_CODE = Object.freeze({
NON_PASS_RESULT: 'non_pass_result',
@@ -24,6 +25,17 @@ export const REASON_CODE = Object.freeze({
export type ReasonCode = typeof REASON_CODE[keyof typeof REASON_CODE];
export const ERROR_CODE = Object.freeze({
PROJECT_DIR_MISSING: 'project_dir_missing',
} as const);
export type ErrorCode = typeof ERROR_CODE[keyof typeof ERROR_CODE];
export class PhaseUatPassedError extends GSDError {
constructor(message: string, public readonly code: ErrorCode) {
super(message, ErrorClassification.Validation);
}
}
export type UatReason = {
code: ReasonCode;
file?: string;
@@ -115,6 +127,18 @@ export async function isPhaseUatPassed(
reasonsHuman: string[];
items: Record<string, unknown>[];
}> {
try {
await stat(projectDir);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
throw new PhaseUatPassedError(
`projectDir does not exist: ${projectDir}`,
ERROR_CODE.PROJECT_DIR_MISSING,
);
}
throw err;
}
const dir = await resolvePhaseDir(phase, projectDir, workstream);
if (!dir) {
return {