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
7 changes: 5 additions & 2 deletions docs/reference/ax-target-result-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ 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.
The filesystem guard refusals keep the fault's class and errno code (for example
`Error:EACCES`) only as an internal, value-free diagnostic for tests and
debugging; it never appears in the envelope or the printed error.

`retry` is advice for the caller. Like suggested actions and application logs,
it carries no execution authority.
Expand Down Expand Up @@ -225,8 +228,8 @@ WU21). The inspector itself only produces snapshots.
| 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, non-directory project dir) | `project_inputs_unsupported`, `adoption_state_unsafe`, `input_unreadable` | same | paired fixture |
| Unsearchable `.hack` or `.hack/.internal` on a never-adopted project | `input_permission_denied` (permission fix named first) | same | paired fixture and domain test (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; `EPERM` through the classifier seam) |
| Unsearchable `.hack` or `.hack/.internal` on a never-adopted project | `input_permission_denied` (permission fix named first) | same | paired fixture and domain test, each at mode `000` and `0644` on both directories (skipped as root; the message is checked unconditionally) |
| 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; `EPERM` through the classifier seam and an injected presence-check fault) |
| 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
50 changes: 47 additions & 3 deletions src/lib/native-compose-adoption-marker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ import {
sameFile,
} from "./native-compose-private-state.ts";
import { parseImportDocument } from "./native-config-import-parser.ts";
import {
attachValueFreeDiagnostic,
readValueFreeDiagnostic,
valueFreeErrorDiagnostic,
} from "./value-free-diagnostic.ts";

export type LegacyComposeAdoptionSelection =
| "prepared"
Expand All @@ -32,22 +37,56 @@ export const LEGACY_ADOPTION_SELECTION_MESSAGES = {
permission:
"Cannot inspect Hack project inputs: permission denied on .hack or a directory inside it. Make .hack readable and searchable by your user (for example chmod u+rwx .hack), then retry. Values omitted.",
} as const;
/** Private owner markers fence upgraded discovery without parsing authored inputs or contacting an engine. */
const DEFAULT_DIAGNOSTIC = "LegacyComposeAdoptionSelectionError";

/**
* Private owner markers fence upgraded discovery without parsing authored
* inputs or contacting an engine.
*
* The `permission` and `input-access` kinds are filesystem faults, so, like
* `ProjectInputAccessError`, they keep the fault only as a value-free
* diagnostic: its class name and errno code (for example `Error:EACCES`),
* never its message or path, as a non-enumerable own property. Read it with
* {@link legacyComposeAdoptionSelectionDiagnostic}. The `adoption` kind keeps
* none: its causes may come from parsed private state.
*/
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"
kind: keyof typeof LEGACY_ADOPTION_SELECTION_MESSAGES = "adoption",
diagnostic: string = DEFAULT_DIAGNOSTIC
) {
super({
code: kind === "permission" ? "E_PERMISSION" : "E_CONFIG_INVALID",
message: LEGACY_ADOPTION_SELECTION_MESSAGES[kind],
});
this.name = "LegacyComposeAdoptionSelectionError";
this.kind = kind;
if (kind !== "adoption") {
attachValueFreeDiagnostic({
target: this,
diagnostic,
fallback: DEFAULT_DIAGNOSTIC,
});
}
}
}

/**
* The value-free diagnostic of a `permission` or `input-access`
* {@link LegacyComposeAdoptionSelectionError} (for example `Error:ENOTDIR`),
* for tests and debugging. Undefined for any other value.
*/
export function legacyComposeAdoptionSelectionDiagnostic(
error: unknown
): string | undefined {
return error instanceof LegacyComposeAdoptionSelectionError
? readValueFreeDiagnostic(error)
: undefined;
}

function refuse(): never {
throw new LegacyComposeAdoptionSelectionError();
}
Expand Down Expand Up @@ -101,6 +140,7 @@ export async function inspectLegacyComposeAdoptionSelection(opts: {
}): Promise<LegacyComposeAdoptionSelection | null> {
const directories: HeldDirectory[] = [];
let stateRootFailure: StateRootFailure | null = null;
let stateRootDiagnostic = DEFAULT_DIAGNOSTIC;
try {
if (
!isRecord(opts) ||
Expand All @@ -120,6 +160,7 @@ export async function inspectLegacyComposeAdoptionSelection(opts: {
return null;
}
stateRootFailure = classifyStateRootFailure(error);
stateRootDiagnostic = valueFreeErrorDiagnostic(error);
throw error;
}
for (const path of [
Expand Down Expand Up @@ -157,7 +198,10 @@ export async function inspectLegacyComposeAdoptionSelection(opts: {
return status;
} catch {
if (stateRootFailure !== null) {
throw new LegacyComposeAdoptionSelectionError(stateRootFailure);
throw new LegacyComposeAdoptionSelectionError(
stateRootFailure,
stateRootDiagnostic
);
}
refuse();
} finally {
Expand Down
40 changes: 11 additions & 29 deletions src/lib/project-input-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import { HackCliError } from "./cli-result.ts";
import { pathExists } from "./fs.ts";
import { isRecord } from "./guards.ts";
import { assertLegacyComposeAdoptionSelectionStable } from "./native-compose-adoption-marker.ts";
import {
attachValueFreeDiagnostic,
readValueFreeDiagnostic,
valueFreeErrorDiagnostic,
} from "./value-free-diagnostic.ts";

export const NATIVE_PROJECT_FILENAME = "hack.project.json";
type ProjectDirName =
Expand Down Expand Up @@ -57,11 +62,6 @@ export class ProjectInputSelectionError extends HackCliError {
}
}

/** Non-enumerable property holding a {@link ProjectInputAccessError}'s diagnostic. */
const PROJECT_INPUT_ACCESS_DIAGNOSTIC_PROPERTY = "diagnosticCause";
const DIAGNOSTIC_NAME = /^[A-Za-z][A-Za-z0-9]{0,63}$/;
const DIAGNOSTIC_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
const DIAGNOSTIC = /^[A-Za-z][A-Za-z0-9]{0,63}(?::[A-Z][A-Z0-9_]{0,63})?$/;
const DEFAULT_DIAGNOSTIC = "ProjectInputAccessError";

/**
Expand Down Expand Up @@ -91,9 +91,10 @@ export class ProjectInputAccessError extends HackCliError {
});
this.name = "ProjectInputAccessError";
this.access = access;
Object.defineProperty(this, PROJECT_INPUT_ACCESS_DIAGNOSTIC_PROPERTY, {
value: DIAGNOSTIC.test(diagnostic) ? diagnostic : DEFAULT_DIAGNOSTIC,
enumerable: false,
attachValueFreeDiagnostic({
target: this,
diagnostic,
fallback: DEFAULT_DIAGNOSTIC,
});
}
}
Expand All @@ -105,15 +106,8 @@ export class ProjectInputAccessError extends HackCliError {
export function projectInputAccessDiagnostic(
error: unknown
): string | undefined {
if (!(error instanceof ProjectInputAccessError)) {
return undefined;
}
const diagnostic: unknown = Reflect.get(
error,
PROJECT_INPUT_ACCESS_DIAGNOSTIC_PROPERTY
);
return typeof diagnostic === "string" && DIAGNOSTIC.test(diagnostic)
? diagnostic
return error instanceof ProjectInputAccessError
? readValueFreeDiagnostic(error)
: undefined;
}

Expand All @@ -133,18 +127,6 @@ export function projectInputAccessFault(
);
}

/** Class name and identifier-shaped code of `error`; never its message. */
function valueFreeErrorDiagnostic(error: unknown): string {
if (!(error instanceof Error)) {
return error === null ? "null" : typeof error;
}
const name = DIAGNOSTIC_NAME.test(error.name) ? error.name : "Error";
const code: unknown = Reflect.get(error, "code");
return typeof code === "string" && DIAGNOSTIC_CODE.test(code)
? `${name}:${code}`
: name;
}

/**
* Inspect only the named root, without parsing, following authored-file links,
* searching ancestors, touching registration, or normalizing configuration.
Expand Down
51 changes: 51 additions & 0 deletions src/lib/value-free-diagnostic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Value-free diagnostics for closed refusals.
*
* A closed refusal keeps a fixed, value-free message, so the fault behind it
* would otherwise be lost to tests and debugging. These helpers keep only the
* fault's class name and identifier-shaped errno code (for example
* `Error:EACCES`), never its message or path, as a non-enumerable own property
* rather than `cause`. Envelopes, printed errors and serializers therefore stay
* unchanged.
*/

/** Non-enumerable own property holding a refusal's diagnostic. */
const DIAGNOSTIC_PROPERTY = "diagnosticCause";
const DIAGNOSTIC_NAME = /^[A-Za-z][A-Za-z0-9]{0,63}$/;
const DIAGNOSTIC_CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
const DIAGNOSTIC = /^[A-Za-z][A-Za-z0-9]{0,63}(?::[A-Z][A-Z0-9_]{0,63})?$/;

/** Class name and identifier-shaped code of `error`; never its message. */
export function valueFreeErrorDiagnostic(error: unknown): string {
if (!(error instanceof Error)) {
return error === null ? "null" : typeof error;
}
const name = DIAGNOSTIC_NAME.test(error.name) ? error.name : "Error";
const code: unknown = Reflect.get(error, "code");
return typeof code === "string" && DIAGNOSTIC_CODE.test(code)
? `${name}:${code}`
: name;
}

/**
* Attach `diagnostic` to `target` as a non-enumerable own property, or
* `fallback` when `diagnostic` is not identifier-shaped.
*/
export function attachValueFreeDiagnostic(opts: {
readonly target: object;
readonly diagnostic: string;
readonly fallback: string;
}): void {
Object.defineProperty(opts.target, DIAGNOSTIC_PROPERTY, {
value: DIAGNOSTIC.test(opts.diagnostic) ? opts.diagnostic : opts.fallback,
enumerable: false,
});
}

/** The attached diagnostic of `target`, or undefined when none is valid. */
export function readValueFreeDiagnostic(target: object): string | undefined {
const diagnostic: unknown = Reflect.get(target, DIAGNOSTIC_PROPERTY);
return typeof diagnostic === "string" && DIAGNOSTIC.test(diagnostic)
? diagnostic
: undefined;
}
Loading
Loading