diff --git a/sdk/src/query/phase-uat-passed.test.ts b/sdk/src/query/phase-uat-passed.test.ts index ac368b5c6..af7fef583 100644 --- a/sdk/src/query/phase-uat-passed.test.ts +++ b/sdk/src/query/phase-uat-passed.test.ts @@ -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 { diff --git a/sdk/src/query/phase-uat-passed.ts b/sdk/src/query/phase-uat-passed.ts index 381236c13..6474c39ac 100644 --- a/sdk/src/query/phase-uat-passed.ts +++ b/sdk/src/query/phase-uat-passed.ts @@ -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[]; }> { + 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 {