diff --git a/docs/reference/ax-target-result-contract.md b/docs/reference/ax-target-result-contract.md index 4a6fe2213..178aa312f 100644 --- a/docs/reference/ax-target-result-contract.md +++ b/docs/reference/ax-target-result-contract.md @@ -147,6 +147,7 @@ fixed text that never repeats caller input, paths or registry values. | `path_not_found` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | | `no_project_context` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | | `project_inputs_unsupported` | `E_CONFIG_INVALID` | `project` | `fix_project` | +| `adoption_state_unsafe` | `E_CONFIG_INVALID` | `project` | `fix_project` | | `config_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | | `compose_parse_failed` | `E_CONFIG_PARSE` | `project` | `fix_project` | | `detached_checkout` | `E_USAGE` | `instance` | `fix_selector` | @@ -163,6 +164,16 @@ read permission, or a directory where a config file belongs) is reported as filesystem error). The OS message is never surfaced, because it contains the absolute path. +The shared project input guards that every project command runs are mapped +too. Native or conflicting project files are `project_inputs_unsupported`. A +legacy adoption state that is unparseable, interrupted, or cannot be checked +(for example a project directory the caller cannot search) is +`adoption_state_unsafe`. A non-directory where the project directory belongs is +`input_unreadable`. When the guard cannot check whether a project input exists +(for example a `.dev` file, or a `.dev` directory the caller cannot search, +beside a valid `.hack`), the refusal is `input_permission_denied` (`EACCES`, +`EPERM`) or `input_unreadable` (any other fault), like a read fault. + `retry` is advice for the caller. Like suggested actions and application logs, it carries no execution authority. @@ -208,6 +219,8 @@ WU21). The inspector itself only produces snapshots. | Two registrations owning one checkout | `ambiguous_project` | same | paired fixture | | Malformed Hack config or Compose file, oversized input | `config_parse_failed`, `compose_parse_failed`, `generation_input_too_large` | same | paired fixture | | Unreadable input (directory in place of a file, no read permission) | `input_unreadable`, `input_permission_denied` | same | paired fixture (permission case skipped as root) | +| Input guard refusals (native/legacy conflict, unsafe adoption state, unsearchable or non-directory project dir) | `project_inputs_unsupported`, `adoption_state_unsafe`, `input_unreadable` | same | paired fixture (unsearchable case skipped as root) | +| Uncheckable input presence (`.dev` file or unsearchable `.dev` beside a valid `.hack`) | `input_unreadable`, `input_permission_denied` | same | paired fixture and domain test (unsearchable case skipped as root) | | Detached linked worktree, no project, missing path | closed refusals | same | paired fixture | | Unknown service | `service_not_found` | same | paired fixture | | Config changed during read | `generation_changed_during_read` | same code path | domain test | diff --git a/src/lib/native-compose-adoption-marker.ts b/src/lib/native-compose-adoption-marker.ts index 1231ca3e5..b10faddc9 100644 --- a/src/lib/native-compose-adoption-marker.ts +++ b/src/lib/native-compose-adoption-marker.ts @@ -34,6 +34,9 @@ export const LEGACY_ADOPTION_SELECTION_MESSAGES = { } as const; /** Private owner markers fence upgraded discovery without parsing authored inputs or contacting an engine. */ export class LegacyComposeAdoptionSelectionError extends HackCliError { + /** Which refusal this is, so readers can classify it without its message. */ + readonly kind: keyof typeof LEGACY_ADOPTION_SELECTION_MESSAGES; + constructor( kind: keyof typeof LEGACY_ADOPTION_SELECTION_MESSAGES = "adoption" ) { @@ -42,6 +45,7 @@ export class LegacyComposeAdoptionSelectionError extends HackCliError { message: LEGACY_ADOPTION_SELECTION_MESSAGES[kind], }); this.name = "LegacyComposeAdoptionSelectionError"; + this.kind = kind; } } function refuse(): never { diff --git a/src/lib/project-input-selection.ts b/src/lib/project-input-selection.ts index 10a44b8eb..1ddea5f7e 100644 --- a/src/lib/project-input-selection.ts +++ b/src/lib/project-input-selection.ts @@ -57,6 +57,27 @@ export class ProjectInputSelectionError extends HackCliError { } } +/** + * Stable, value-free refusal when a project input's presence cannot be + * checked (for example a file where `.dev` belongs, or a project directory the + * caller cannot search). Only ENOENT means absence, so the guard refuses + * rather than guessing. `access` classifies the fault without its OS message, + * which carries the absolute path and is therefore never kept. + */ +export class ProjectInputAccessError extends HackCliError { + readonly access: "permission" | "unreadable"; + + constructor(access: "permission" | "unreadable") { + super({ + code: "E_CONFIG_INVALID", + message: + "Cannot inspect Hack project inputs. Check filesystem permissions and paths before retrying. Values omitted.", + }); + this.name = "ProjectInputAccessError"; + this.access = access; + } +} + /** * Inspect only the named root, without parsing, following authored-file links, * searching ancestors, touching registration, or normalizing configuration. @@ -188,12 +209,12 @@ async function inputPresent(path: string): Promise { await lstat(path); return true; } catch (error: unknown) { - if (isRecord(error) && error.code === "ENOENT") { + const code = isRecord(error) ? error.code : undefined; + if (code === "ENOENT") { return false; } - throw new Error( - "Cannot inspect Hack project inputs. Check filesystem permissions and paths before retrying.", - { cause: error } + throw new ProjectInputAccessError( + code === "EACCES" || code === "EPERM" ? "permission" : "unreadable" ); } } diff --git a/src/lib/target-inspect.ts b/src/lib/target-inspect.ts index c4bdbf22d..2dd3f547b 100644 --- a/src/lib/target-inspect.ts +++ b/src/lib/target-inspect.ts @@ -18,6 +18,7 @@ import { resolveGitCurrentBranch, } from "./git-worktree.ts"; import { isRecord } from "./guards.ts"; +import { LegacyComposeAdoptionSelectionError } from "./native-compose-adoption-marker.ts"; import { defaultProjectSlugFromPath, findProjectContext, @@ -30,7 +31,10 @@ import { sanitizeBranchSlug, sanitizeProjectSlug, } from "./project.ts"; -import { ProjectInputSelectionError } from "./project-input-selection.ts"; +import { + ProjectInputAccessError, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; import { AmbiguousProjectNameError, normalizeProjectName, @@ -170,6 +174,13 @@ export const TARGET_REFUSALS = Object.freeze({ message: "The selected project's inputs are not supported by this inspector (native or conflicting project files).", }, + adoption_state_unsafe: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: + "The project's legacy adoption state is unreadable, interrupted or unsafe. Use explicit adoption recovery.", + }, config_parse_failed: { code: "E_CONFIG_PARSE", stage: "project", @@ -357,8 +368,11 @@ export function targetRefusalEnvelope(opts: { * A filesystem fault while reading inputs (for example a permission-denied * Compose file, or a directory where a config file belongs) is mapped to the * closed `input_permission_denied` / `input_unreadable` refusals, so the OS - * message and its absolute path never reach the caller. Only non-filesystem - * faults (programming errors) still throw. + * message and its absolute path never reach the caller. The shared project + * input guards' own refusals (native or conflicting inputs, an unsafe or + * unreadable legacy adoption state, an input whose presence cannot be checked) + * are mapped too, wherever they are raised. + * Only other faults (programming errors) still throw. * * `afterResolve` is a test seam that runs after the target is resolved and * before the generation is re-read; production callers never pass it. @@ -382,6 +396,10 @@ export async function inspectTarget(opts: { : { observedGeneration: error.observedGeneration }), }); } + const guarded = projectInputGuardReason(error); + if (guarded !== null) { + return targetRefusalEnvelope({ reason: guarded }); + } const fault = filesystemFaultCode(error); if (fault !== null) { return targetRefusalEnvelope({ @@ -395,6 +413,32 @@ export async function inspectTarget(opts: { } } +/** + * Closed reason for a refusal raised by the shared project input guards. Those + * guards run during discovery and again inside every config read, so they are + * classified here rather than at one call site. Their messages are value-free + * but are not catalogue text, so they are never passed through. + */ +function projectInputGuardReason(error: unknown): TargetRefusalReason | null { + if (error instanceof ProjectInputSelectionError) { + return "project_inputs_unsupported"; + } + if (error instanceof ProjectInputAccessError) { + return error.access === "permission" + ? "input_permission_denied" + : "input_unreadable"; + } + if (error instanceof LegacyComposeAdoptionSelectionError) { + if (error.kind === "permission") { + return "input_permission_denied"; + } + return error.kind === "input-access" + ? "input_unreadable" + : "adoption_state_unsafe"; + } + return null; +} + /** Shape of an errno code such as `EACCES` or `EISDIR`. */ const ERRNO_CODE_PATTERN = /^E[A-Z0-9]+$/; @@ -652,12 +696,10 @@ async function selectProject(opts: { if (!(await isDirectory(match.repoRoot))) { throw new TargetRefusal("registered_root_missing"); } - const context = await guardInputs(() => - findProjectContextAtRoot({ - projectRoot: match.repoRoot, - projectDirName: match.projectDirName, - }) - ); + const context = await findProjectContextAtRoot({ + projectRoot: match.repoRoot, + projectDirName: match.projectDirName, + }); if (!context) { throw new TargetRefusal("no_project_context"); } @@ -671,24 +713,13 @@ async function selectProject(opts: { if (opts.selector.path !== null && !(await isDirectory(startDir))) { throw new TargetRefusal("path_not_found"); } - const context = await guardInputs(() => findProjectContext(startDir)); + const context = await findProjectContext(startDir); if (!context) { throw new TargetRefusal("no_project_context"); } return { context, kind: opts.selector.path === null ? "cwd" : "path" }; } -async function guardInputs(read: () => Promise): Promise { - try { - return await read(); - } catch (error: unknown) { - if (error instanceof ProjectInputSelectionError) { - throw new TargetRefusal("project_inputs_unsupported"); - } - throw error; - } -} - /** * Registration that owns this exact checkout root: its primary root, or one of * its recorded linked worktrees. Name equality never matches, so independent diff --git a/tests/project-input-selection.test.ts b/tests/project-input-selection.test.ts index 3883edd3e..b9544e5b3 100644 --- a/tests/project-input-selection.test.ts +++ b/tests/project-input-selection.test.ts @@ -28,6 +28,7 @@ import { discoverProjectInputs, inspectProjectInputsAtRoot, NATIVE_PROJECT_FILENAME, + ProjectInputAccessError, } from "../src/lib/project-input-selection.ts"; const roots: string[] = []; @@ -197,6 +198,49 @@ test.each([ ); }); +async function inputAccessFault(root: string): Promise { + try { + await inspectProjectInputsAtRoot({ projectRoot: root }); + } catch (error: unknown) { + return error; + } + throw new Error("unexpected input selection success"); +} + +test("a .dev file beside valid .hack inputs is a typed, value-free access refusal", async () => { + const root = await fixture(); + await file(root, ".hack/docker-compose.yml"); + await file(root, ".dev", "not a directory"); + const error = await inputAccessFault(root); + expect(error).toBeInstanceOf(ProjectInputAccessError); + expect((error as ProjectInputAccessError).access).toBe("unreadable"); + expect((error as ProjectInputAccessError).code).toBe("E_CONFIG_INVALID"); + expect((error as Error).cause).toBeUndefined(); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain("ENOTDIR"); +}); + +// Root can search a mode-000 directory, so this fault cannot be produced. +test.skipIf(process.getuid?.() === 0)( + "an unsearchable .dev beside valid .hack inputs is a typed permission refusal", + async () => { + const root = await fixture(); + await file(root, ".hack/docker-compose.yml"); + const dev = join(root, ".dev"); + await mkdir(dev); + await chmod(dev, 0); + try { + const error = await inputAccessFault(root); + expect(error).toBeInstanceOf(ProjectInputAccessError); + expect((error as ProjectInputAccessError).access).toBe("permission"); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain("EACCES"); + } finally { + await chmod(dev, 0o700); + } + } +); + test("supported legacy discovery keeps primary-before-legacy precedence", async () => { const outer = await fixture(); await file(outer, ".hack/docker-compose.yml"); diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts index 16f41355c..b9442a415 100644 --- a/tests/project-target-inspect.test.ts +++ b/tests/project-target-inspect.test.ts @@ -232,6 +232,51 @@ beforeAll(async () => { services: ["api"], }); await chmod(join(lockedCompose, ".hack", "docker-compose.yml"), 0o000); + // Faults the shared project input guards raise before any input is read: + // an unparseable legacy adoption receipt, and a project directory the + // inspector cannot search (its mode is dropped inside the test). + const adoptionUnsafe = join(root, "adoption-unsafe"); + await writeProject({ + repo: adoptionUnsafe, + name: "adopt", + services: ["api"], + }); + const adoptionState = join( + adoptionUnsafe, + ".hack", + ".internal", + "legacy-compose-adoption-v1" + ); + await mkdir(adoptionState, { recursive: true, mode: 0o700 }); + await writeFile(join(adoptionState, "receipt.json"), "{not json", { + mode: 0o600, + }); + await writeProject({ + repo: join(root, "unsearchable"), + name: "unsearchable", + services: ["api"], + }); + // Native and legacy inputs side by side (a conflicting project family). + const nativeConflict = join(root, "native-conflict"); + await writeProject({ + repo: nativeConflict, + name: "native", + services: ["api"], + }); + await writeFile(join(nativeConflict, ".hack", "hack.project.json"), "{}\n"); + // A regular file where the project directory belongs. + const fileProjectDir = join(root, "file-project-dir"); + await mkdir(fileProjectDir, { recursive: true }); + await writeFile(join(fileProjectDir, ".hack"), "not a directory\n"); + // A valid .hack project whose legacy .dev input directory cannot be checked: + // a regular file where .dev belongs, and a .dev directory whose mode is + // dropped inside the test. + const devFile = join(root, "dev-file"); + await writeProject({ repo: devFile, name: "devfile", services: ["api"] }); + await writeFile(join(devFile, ".dev"), "not a directory\n"); + const devLocked = join(root, "dev-locked"); + await writeProject({ repo: devLocked, name: "devlocked", services: ["api"] }); + await mkdir(join(devLocked, ".dev"), { recursive: true }); await mkdir(join(root, "outside"), { recursive: true }); @@ -635,6 +680,83 @@ describe("paired CLI/MCP target fixtures", () => { CASE_TIMEOUT_MS ); + test( + "project input guard refusals are closed refusals, not detail-less errors", + async () => { + // An unsafe legacy adoption state used to escape as a detail-less + // E_CONFIG_INVALID, which the adapter turned into data: null. + expectRefusal( + await inspectBoth({ path: join(fixture.root, "adoption-unsafe") }), + "adoption_state_unsafe" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "native-conflict") }), + "project_inputs_unsupported" + ); + // A file where `.hack` belongs is an input access failure. + expectRefusal( + await inspectBoth({ path: join(fixture.root, "file-project-dir") }), + "input_unreadable" + ); + }, + CASE_TIMEOUT_MS + ); + + // Root can search a mode-000 directory, so this fault cannot be produced. + test.skipIf(process.getuid?.() === 0)( + "an unsearchable project directory is a closed refusal without the path", + async () => { + const path = join(fixture.root, "unsearchable"); + const projectDir = join(path, ".hack"); + await chmod(projectDir, 0o000); + try { + // An unsearchable .hack is a permission fault (#402's `permission` + // selection kind), not unsafe adoption state. + expectRefusal(await inspectBoth({ path }), "input_permission_denied"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EACCES"); + } finally { + await chmod(projectDir, 0o755); + } + }, + CASE_TIMEOUT_MS + ); + + test( + "a .dev file beside a valid .hack is a closed refusal without the path", + async () => { + // The input presence check used to escape as a detail-less E_UNEXPECTED. + expectRefusal( + await inspectBoth({ path: join(fixture.root, "dev-file") }), + "input_unreadable" + ); + const cli = await runCli({ path: join(fixture.root, "dev-file") }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("ENOTDIR"); + }, + CASE_TIMEOUT_MS + ); + + // Root can search a mode-000 directory, so this fault cannot be produced. + test.skipIf(process.getuid?.() === 0)( + "an unsearchable .dev beside a valid .hack is a closed refusal without the path", + async () => { + const path = join(fixture.root, "dev-locked"); + const devDir = join(path, ".dev"); + await chmod(devDir, 0o000); + try { + expectRefusal(await inspectBoth({ path }), "input_permission_denied"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EACCES"); + } finally { + await chmod(devDir, 0o755); + } + }, + CASE_TIMEOUT_MS + ); + test( "two registrations that own one checkout are refused as ambiguous", async () => { @@ -887,6 +1009,32 @@ describe("target inspector domain", () => { ); }); + test("the MCP argv carries every selector as one --name=value token", () => { + // The paired cases build the direct CLI argv with this same helper, so a + // dropped selector would change both sides alike. Pin the argv literally. + expect( + buildTargetInspectArgs({ + projectName: "alpha", + path: "/x", + branch: "feature/login", + service: "api", + env: "staging", + expectGeneration: "cfg-v0:0123456789abcdef0123456789abcdef", + }) + ).toEqual([ + "project", + "target", + "--json", + "--project=alpha", + "--path=/x", + "--branch=feature/login", + "--service=api", + "--env=staging", + "--expect-generation=cfg-v0:0123456789abcdef0123456789abcdef", + ]); + expect(buildTargetInspectArgs({})).toEqual(["project", "target", "--json"]); + }); + test("selectors are validated before any project lookup", async () => { const registryPath = join(fixture.state, "projects.json"); for (const selector of [