Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/reference/ax-target-result-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -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.

Expand Down Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions src/lib/native-compose-adoption-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
) {
Expand All @@ -42,6 +45,7 @@ export class LegacyComposeAdoptionSelectionError extends HackCliError {
message: LEGACY_ADOPTION_SELECTION_MESSAGES[kind],
});
this.name = "LegacyComposeAdoptionSelectionError";
this.kind = kind;
}
}
function refuse(): never {
Expand Down
29 changes: 25 additions & 4 deletions src/lib/project-input-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -188,12 +209,12 @@ async function inputPresent(path: string): Promise<boolean> {
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"
);
}
}
73 changes: 52 additions & 21 deletions src/lib/target-inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.
Expand All @@ -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({
Expand All @@ -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]+$/;

Expand Down Expand Up @@ -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");
}
Expand All @@ -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<T>(read: () => Promise<T>): Promise<T> {
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
Expand Down
44 changes: 44 additions & 0 deletions tests/project-input-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
discoverProjectInputs,
inspectProjectInputsAtRoot,
NATIVE_PROJECT_FILENAME,
ProjectInputAccessError,
} from "../src/lib/project-input-selection.ts";

const roots: string[] = [];
Expand Down Expand Up @@ -197,6 +198,49 @@ test.each([
);
});

async function inputAccessFault(root: string): Promise<unknown> {
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");
Expand Down
Loading
Loading