diff --git a/docs/env.md b/docs/env.md index e2c5a60a..35af7494 100644 --- a/docs/env.md +++ b/docs/env.md @@ -262,6 +262,7 @@ into a host command, and `--target` on env and host commands chooses the `host` | `hack host shell` | `--env`, `--scope`, `--target` | When the shell starts. | Start a new shell. | Runs on the host, not in a container. | | `hack config validate` | `--env` | Per invocation; resolves the native selection without reading managed env documents. | Not needed. | Authored native projects only; cannot be combined with `--file`. | | `hack config plan` | `--env` | Per invocation; metadata only (names, scopes, secret flags), never decrypted. | Not needed. | Authored native projects only. | +| `hack project target` | `--env`, `--service` | Per invocation, read-only; reports the overlay selection (explicit, project default or base) without reading env files or values. | Not needed. | Selection only: overlay existence is not checked; no runtime required. | | `hack session start` | `--env`, `--service` | When the workspace is created; the name gains `.env-` (`.env-base` for base spellings), and `--up` runs `hack up -d` with the same selection. | Start a new workspace or pass `--env` to `hack session exec`. | Injects into the workspace shell, not into containers. | | `hack session exec` | `--env`, `--service` | Per command; omitted reuses the selection encoded in the workspace name. | Each command resolves again. | Injects into the workspace command, not into containers. | diff --git a/docs/reference.md b/docs/reference.md index ae65b4fc..bb35f50d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -30,3 +30,4 @@ - [Integrations](./integrations.md) - [MCP tool contract](./reference/mcp-tools.md) — every MCP tool: annotations, inputs, argv, results, and the value-free env tools - [MCP logs contract](./reference/mcp-logs.md) — cutoff versus backend failure for the MCP log tools, and the cursor/handle decision +- [AX target and result contract](./reference/ax-target-result-contract.md) — provisional target schema, closed refusals and the read-only `hack project target` inspector diff --git a/docs/reference/ax-target-result-contract.md b/docs/reference/ax-target-result-contract.md new file mode 100644 index 00000000..4a6fe221 --- /dev/null +++ b/docs/reference/ax-target-result-contract.md @@ -0,0 +1,241 @@ +# AX target and result contract (provisional) + +Status: **provisional decision record** (HACK-1144, WU31). Every command name, +tool name, field, reason and retry value on this page can still change in the +agent-experience (AX) compatibility review. It is not a stable interface +promise. Fields and codes that the review must confirm are marked +_provisional_ below. + +This page records how Hack names the thing an agent wants to act on (the +**target**), how a read-only inspector reports it, and how adapters (CLI and +MCP) share one result shape. The first implementation is the read-only +inspector `hack project target` and its MCP tool `hack.project.target`. + +## Decisions + +1. **A target is an exact tuple, not a name.** It is the project, the checkout, + the runtime instance, an optional service, the env overlay selection and a + config generation, plus the selector kind and the observation time. A + project name alone is never a target. +2. **Git branch is not runtime identity.** The checkout reports its current Git + branch, but the runtime instance is a separate field. An explicit instance + selector (`--branch`) wins over the branch-derived default, and the + inspector never creates, switches or deletes Git branches or worktrees. +3. **References grant no authority.** A resolved target identifies scope only + (`authority: "none"`). A caller that acts on it must revalidate at its own + effect boundary, for example by passing the generation it inspected. +4. **Equal names are not merged.** Registration ownership is decided by the + exact checkout root (or a recorded linked worktree of a registration), never + by name. Two registrations that share a name are refused as + `ambiguous_project` when selected by name, and are never renamed. +5. **Generation covers Hack config only.** The generation fingerprints + `.hack/hack.config.json`, `.hack/hack.toml` and `.hack/docker-compose.yml`. + Ordinary source edits do not change it. Qualifying an immutable source + revision is a separate, explicit concern and is not part of this contract. +6. **No fake atomic snapshot.** The inspector reads the generation before and + after resolving the other fields. If it changed during the read, the result + is the `generation_changed_during_read` refusal, not a mixed snapshot. +7. **Snapshot reads have no side effects.** No registry write (not even the + last-seen touch other commands perform), no engine or Docker call, no + startup, repair or global sync, and no Git mutation. The only child + processes are read-only Git queries that existing project commands already + run. +8. **Readiness is not claimed.** The snapshot does not probe the runtime. It + reports `runtime: { observed: false, reason: "not_probed" }` instead of + guessing whether containers exist or are healthy. +9. **One semantics across adapters.** The MCP tool spawns the same CLI command + and passes selection through unresolved, so every resolution refusal comes + from the same code. There is no MCP-only selection policy. The MCP server + only checks input grammar before spawning; any value it accepts is then + judged by the CLI. +10. **Errors reuse existing codes.** Refusals reuse the stable CLI error codes + in `HACK_ERROR_CODES`. The closed `detail.reason` is the discriminator. + Adding a dedicated code (for example for staleness) is left to the + compatibility review. + +## Commands + +| Surface | Name (_provisional_) | Class | +| --- | --- | --- | +| CLI | `hack project target [--project=…\|--path=…] [--branch=…] [--service=…] [--env=…] [--expect-generation=…] [--json]` | read-only | +| MCP | `hack.project.target` (inputs `projectName`, `path`, `branch`, `service`, `env`, `expectGeneration`) | read-only | + +The MCP tool runs `hack project target --json` with the same options. Its +`data` is the CLI envelope, re-validated against this contract (`null` when +the CLI output does not match). `stdout` is withheld because `data` carries the +same envelope. + +## Result envelope + +The CLI prints exactly one envelope with `--json` and exits 0 on success and 1 +on a refusal. + +```json +{ + "ok": true, + "data": { + "schema": "hack.target.v0", + "provisional": true, + "observed_at": "2026-10-19T12:00:00.000Z", + "authority": "none", + "selector": { + "kind": "path", + "branch_explicit": false, + "env_explicit": false, + "expected_generation": null + }, + "project": { + "name": "alpha", + "registration": { "id": "alpha", "name": "alpha", "match": "worktree" }, + "project_dir": "/work/alpha-login/.hack" + }, + "checkout": { + "root": "/work/alpha-login", + "kind": "linked", + "git_branch": "feature/login" + }, + "instance": { + "branch": "feature-login", + "source": "worktree", + "compose_project": "alpha--feature-login" + }, + "service": null, + "services": ["api", "web"], + "env": { "overlay": null, "source": "base" }, + "generation": { + "id": "cfg-v0:0123456789abcdef0123456789abcdef", + "scope": "hack_config", + "inputs": [ + { "file": "hack.config.json", "present": true }, + { "file": "hack.toml", "present": false }, + { "file": "docker-compose.yml", "present": true } + ] + }, + "runtime": { "observed": false, "reason": "not_probed" } + } +} +``` + +| Field | Meaning | +| --- | --- | +| `selector.kind` | `project` (by registered name), `path` or `cwd` | +| `project.registration` | The registration that owns this exact checkout root (`match: root`) or lists it as a linked worktree (`match: worktree`); `null` when unregistered | +| `checkout.kind` | `primary`, `linked` (a linked Git worktree) or `not_git` | +| `instance.source` | `explicit` (`--branch`), `worktree` (derived from a linked worktree's branch) or `default` | +| `instance.branch` | The instance slug. An explicit `--branch` is slugged like `hack up --branch` (`Feature/X` becomes `feature-x`) | +| `instance.compose_project` | The Compose project name commands use for this instance (`--` for a branch instance) | +| `services` | Service names declared in the Compose file, sorted; `[]` when the file is absent or has no `services` key. A Compose file that is not YAML, whose top level is not a mapping, or whose `services` is not a mapping is refused as `compose_parse_failed` | +| `env.source` | `explicit`, `project_default` (configured default overlay) or `base`; selection only, overlay existence is not checked | +| `generation.id` | `cfg-v0:` plus 32 hex characters; pass it back as `--expect-generation` | + +All fields are _provisional_. + +## Refusals + +A refusal is `{ ok: false, error: { code, message, detail } }`. `message` is +fixed text that never repeats caller input, paths or registry values. +`detail` is `{ reason, stage, retry, provisional: true }`, plus +`observed_generation` for `stale_generation` only. + +| `reason` (_provisional_) | `code` | `stage` | `retry` | +| --- | --- | --- | --- | +| `conflicting_selectors` | `E_USAGE` | `selector` | `fix_selector` | +| `invalid_selector` | `E_USAGE` | `selector` | `fix_selector` | +| `project_not_found` | `E_PROJECT_NOT_FOUND` | `project` | `fix_selector` | +| `ambiguous_project` | `E_USAGE` | `project` | `fix_selector` | +| `registered_root_missing` | `E_PROJECT_NOT_FOUND` | `project` | `fix_project` | +| `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` | +| `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` | +| `service_not_found` | `E_SERVICE_NOT_FOUND` | `service` | `fix_selector` | +| `stale_generation` | `E_USAGE` | `generation` | `reinspect` | +| `generation_changed_during_read` | `E_USAGE` | `generation` | `reinspect` | +| `generation_input_too_large` | `E_CONFIG_INVALID` | `generation` | `fix_project` | +| `input_permission_denied` | `E_PERMISSION` | `project` | `fix_project` | +| `input_unreadable` | `E_CONFIG_INVALID` | `project` | `fix_project` | + +A filesystem fault while reading an input (for example a Compose file with no +read permission, or a directory where a config file belongs) is reported as +`input_permission_denied` (`EACCES`, `EPERM`) or `input_unreadable` (any other +filesystem error). The OS message is never surfaced, because it contains the +absolute path. + +`retry` is advice for the caller. Like suggested actions and application logs, +it carries no execution authority. + +The CLI parser refuses a malformed `--project` or `--env` value before the +inspector runs, as it does for every command. That refusal is +`{ ok: false, error: { code: "E_USAGE", message } }` with no `detail`. The MCP +server refuses grammar-invalid `branch`, `service`, `env`, `projectName` length +or `expectGeneration` before spawning, as `invalid_arguments` with +`spawned: false`. These are input-shape refusals; every resolution refusal +above is identical on both surfaces. + +These parser refusals are the only detail-less errors in the contract. A +non-filesystem fault outside the catalogue (a defect) still makes the CLI print +`E_UNEXPECTED` with free text and no `detail`. That envelope is off contract: +the MCP adapter accepts a detail-less error only when its code is `E_USAGE`, so +it returns `data: null` for any other. + +## Result semantics for future operations + +These rules are recorded for the operations that will consume targets (WU20, +WU21). The inspector itself only produces snapshots. + +- Separate **accepted work**, **terminal result** and **bounded observation**. + Work that failed but was observed successfully is not task success. +- Report partial results and freshness windows honestly; never present a + stitched read as an atomic snapshot. +- Preserve the compatible `ok`/`data`/`error`/`detail` envelope and documented + child exit codes. +- Never include secret values or ciphertext in inspect or env metadata. + +## Compatibility matrix + +| Concern | CLI `hack project target` | MCP `hack.project.target` | Evidence | +| --- | --- | --- | --- | +| Two projects, selection by name | yes | yes | paired fixture | +| Two branches (linked worktree default, explicit instance) | yes | yes | paired fixture | +| Explicit instance slug (`Feature/X` gives `--feature-x`) | yes | yes | paired fixture | +| Env selection: project default, explicit overlay, `base` | yes | yes | paired fixture | +| Conflicting selectors | `conflicting_selectors` | same | paired fixture | +| Stale generation | `stale_generation` | same | paired fixture | +| Source edit keeps generation | yes | yes | paired fixture | +| Ambiguous equal names, moved checkout, unknown project | closed refusals | same | paired fixture | +| 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) | +| 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 | +| No registry, state or Git writes | yes | yes (audit log only) | paired fixture | +| Malformed selector | `invalid_selector` / parser `E_USAGE` | `invalid_arguments`, not spawned | paired fixture | +| Drifted CLI output, detail-less non-`E_USAGE` error, data claiming authority | n/a | `data: null`; foreign fields dropped | stubbed CLI fixture | + +The fixtures live in `tests/project-target-inspect.test.ts`. + +## Known gaps + +- **Runtime readback.** The snapshot never observes containers, so it does not + prove an instance exists, is running or is healthy. Actual container and env + readback for two projects and two branches needs a host with an engine and + is not covered here. +- **Concurrent sessions and slow or failed probes.** Not exercised: the + inspector has no runtime probe yet. +- **Env overlays.** Selection only; overlay existence and resolved keys are not + checked. Use `hack env list` or `hack.project.env.keys` for key metadata. +- **Compose validity.** The inspector refuses a Compose file it cannot parse + as a YAML mapping, but it does not run `docker compose config`, so a file + that parses but that Compose would reject (for example an invalid service + definition) is still reported as a target. +- **Generation scope.** Worktree-local overrides, env files and native + `hack.project.json` inputs are not part of the generation. Native projects + are refused as `project_inputs_unsupported`. +- **Pagination and byte limits.** The result is small and unpaginated; the + rule that critical blockers survive pagination applies to later list + operations. +- **Capability-limited clients and unknown provenance.** Not modeled. +- WU20, WU21 and WU28 runtime and AX gates remain open. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f27f6501..533ea57d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -23,6 +23,7 @@ hack project [options] | Command | Summary | | --- | --- | | `hack project owner` | Inspect project ownership | +| `hack project target` | Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) | ### Options @@ -56,6 +57,31 @@ hack project owner [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack project target` + +Inspect the exact project, checkout, instance and service a command would target (read-only, provisional) + +### Usage + +```bash +hack project target [options] +``` + +### Options + +| Option | Description | +| --- | --- | +| `--path, -p ` | Run a project command against a repo path (overrides cwd search) | +| `--project ` | Target a registered project by name (from ~/.hack/projects.json) | +| `--branch ` | Run against a branch-specific instance (compose name + hostnames) | +| `--service ` | Also resolve one Compose service of the target | +| `--env ` | Select an env overlay by name; omit for the configured default overlay, or use 'base' or 'default' for base layers only | +| `--expect-generation ` | Refuse unless the project's config generation still equals this id | +| `--json` | Output JSON (machine-readable) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack init` diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md index 237bf124..2e8b10d2 100644 --- a/docs/reference/mcp-tools.md +++ b/docs/reference/mcp-tools.md @@ -38,6 +38,7 @@ Every tool sets `openWorldHint: false`. Mutating tools set `idempotentHint: fals | `hack.project.env.explain` | read-only | `hack env explain --json -- ` | | `hack.project.env.keys` | read-only | `hack env list --json` | | `hack.project.instances` | read-only | `hack projects --json --project=` | +| `hack.project.target` | read-only | `hack project target --json` (provisional) | ## Project selection @@ -195,6 +196,26 @@ projects and carries container labels, mounts and networks, which `data` omits. `stderr` is returned as captured. `found` is `false` when the project is registered but the CLI listing does not include it. +## `hack.project.target` + +Resolves the exact target a command would act on: `hack project target --json +[--project=…] [--path=…] [--branch=…] [--service=…] [--env=…] +[--expect-generation=…]`. The tool, its inputs and the result schema are +provisional; see the [AX target and result contract](./ax-target-result-contract.md). + +- Inputs: `projectName`, `path`, `branch`, `service`, `env` and + `expectGeneration`, all optional. +- Unlike the other project tools, selection is not resolved by the MCP server. + `projectName` and `path` are passed through, both of them when both are + given, so the CLI owns every resolution refusal (for example + `conflicting_selectors`) and the MCP result matches the CLI exactly. +- `data` is the CLI's JSON envelope, re-validated against the contract, or + `null` when the output does not match it. A detail-less error is accepted + only as a parser `E_USAGE` refusal, so an `E_UNEXPECTED` fault message never + reaches `data`. `stdout` is always `""`, because + `data` carries the same envelope. `stderr` is returned as captured. +- The command never probes the engine, writes the registry or changes Git. + ## Audit log Every spawned command appends one JSON line to `$HACK_HOME/mcp-audit.log` (by diff --git a/src/commands/project-admin.ts b/src/commands/project-admin.ts index c0d40bf0..521ea778 100644 --- a/src/commands/project-admin.ts +++ b/src/commands/project-admin.ts @@ -10,6 +10,7 @@ import { sanitizeProjectSlug, } from "../lib/project.ts"; import { display } from "../ui/display.ts"; +import { targetCommand, targetSpec } from "./project-target.ts"; const ownerShowOptions = [optPath, optProject, optJson] as const; @@ -37,7 +38,7 @@ const projectSpec = defineCommand({ group: "Project", options: [], positionals: [], - subcommands: [ownerSpec], + subcommands: [ownerSpec, targetSpec], } as const); const handleOwnerShow: CommandHandlerFor = async ({ @@ -90,7 +91,7 @@ const ownerCommand = { export const projectCommand = { ...projectSpec, - subcommands: [ownerCommand], + subcommands: [ownerCommand, targetCommand], } as const; async function resolveProjectTarget(input: { diff --git a/src/commands/project-target.ts b/src/commands/project-target.ts new file mode 100644 index 00000000..a98c46b0 --- /dev/null +++ b/src/commands/project-target.ts @@ -0,0 +1,114 @@ +import type { CommandHandlerFor } from "../cli/command.ts"; +import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; +import { + optBranch, + optEnv, + optJson, + optPath, + optProject, +} from "../cli/options.ts"; +import { + emitCliResult, + exitCodeForResult, + HackCliError, +} from "../lib/cli-result.ts"; +import { + inspectTarget, + type TargetInspectData, +} from "../lib/target-inspect.ts"; +import { display } from "../ui/display.ts"; + +const optTargetService = defineOption({ + name: "service", + type: "string", + long: "--service", + valueHint: "", + description: "Also resolve one Compose service of the target", +} as const); + +const optExpectGeneration = defineOption({ + name: "expectGeneration", + type: "string", + long: "--expect-generation", + valueHint: "", + description: + "Refuse unless the project's config generation still equals this id", +} as const); + +const targetOptions = [ + optPath, + optProject, + optBranch, + optTargetService, + optEnv, + optExpectGeneration, + optJson, +] as const; + +/** + * `hack project target`: read-only target inspector (HACK-1144). The name, + * options and result schema are provisional until the AX compatibility + * review; see docs/reference/ax-target-result-contract.md. + */ +export const targetSpec = defineCommand({ + name: "target", + summary: + "Inspect the exact project, checkout, instance and service a command would target (read-only, provisional)", + group: "Project", + options: targetOptions, + positionals: [], + subcommands: [], +} as const); + +const handleTarget: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const options = args.options; + const result = await inspectTarget({ + cwd: ctx.cwd, + selector: { + ...(options.project === undefined ? {} : { project: options.project }), + ...(options.path === undefined ? {} : { path: options.path }), + ...(options.branch === undefined ? {} : { branch: options.branch }), + ...(options.service === undefined ? {} : { service: options.service }), + ...(options.env === undefined ? {} : { env: options.env }), + ...(options.expectGeneration === undefined + ? {} + : { expectGeneration: options.expectGeneration }), + }, + }); + + if (options.json === true) { + emitCliResult({ result }); + return exitCodeForResult({ result }); + } + if (!result.ok) { + throw new HackCliError(result.error); + } + await display.kv({ title: "Target", entries: targetEntries(result.data) }); + return 0; +}; + +function targetEntries( + data: TargetInspectData +): readonly (readonly [string, string])[] { + return [ + ["Project", data.project.name], + [ + "Registration", + data.project.registration + ? `${data.project.registration.id} (${data.project.registration.match})` + : "unregistered", + ], + ["Checkout", `${data.checkout.root} (${data.checkout.kind})`], + ["Git branch", data.checkout.git_branch ?? "-"], + ["Instance", `${data.instance.compose_project} (${data.instance.source})`], + ["Service", data.service?.name ?? "-"], + ["Env overlay", `${data.env.overlay ?? "base"} (${data.env.source})`], + ["Generation", data.generation.id], + ["Runtime", "not probed"], + ]; +} + +export const targetCommand = withHandler(targetSpec, handleTarget); diff --git a/src/commands/project.ts b/src/commands/project.ts index 682a5140..7512381f 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -153,6 +153,7 @@ import { } from "../lib/cli-result.ts"; import { withComposeServiceEnvFingerprints } from "../lib/compose-env-fingerprint.ts"; import { renderManagedComposeEnvOverride } from "../lib/compose-managed-env.ts"; +import { resolveComposeProjectName } from "../lib/compose-project-name.ts"; import { type ComposeStartupBudgets, type ComposeStartupFailure, @@ -1039,43 +1040,6 @@ function emitRemoteLifecycleJson(opts: { }); } -async function resolveComposeProjectName(opts: { - readonly project: Awaited>; - readonly cfg?: Awaited>; -}): Promise { - const composeName = await readComposeProjectName(opts.project.composeFile); - if (composeName) { - return composeName; - } - - const derived = defaultProjectSlugFromPath(opts.project.projectRoot); - const cfgName = (opts.cfg?.name ?? derived).trim(); - return cfgName.length > 0 ? cfgName : derived; -} - -async function readComposeProjectName( - composeFile: string -): Promise { - const text = await readTextFile(composeFile); - if (!text) { - return null; - } - - let parsed: unknown; - try { - parsed = YAML.parse(text); - } catch { - return null; - } - - if (!isRecord(parsed)) { - return null; - } - const name = getString(parsed, "name"); - const trimmed = name?.trim() ?? ""; - return trimmed.length > 0 ? trimmed : null; -} - async function buildBranchComposeOverride(opts: { readonly project: Awaited>; readonly branch: string; diff --git a/src/lib/compose-project-name.ts b/src/lib/compose-project-name.ts new file mode 100644 index 00000000..09d89166 --- /dev/null +++ b/src/lib/compose-project-name.ts @@ -0,0 +1,58 @@ +import { YAML } from "bun"; +import { readTextFile } from "./fs.ts"; +import { getString, isRecord } from "./guards.ts"; +import type { ProjectConfig, ProjectContext } from "./project.ts"; +import { defaultProjectSlugFromPath } from "./project.ts"; + +/** + * Resolve the base Compose project name for a project's default instance. + * + * Precedence: the Compose file's top-level `name:`, then the project config + * `name`, then the slug derived from the project root directory. A branch + * instance appends `--` to this base (see + * {@link branchComposeProjectName}). The read is side-effect free. + */ +export async function resolveComposeProjectName(opts: { + readonly project: Pick; + readonly cfg?: Pick; +}): Promise { + const composeName = await readComposeProjectName(opts.project.composeFile); + if (composeName) { + return composeName; + } + + const derived = defaultProjectSlugFromPath(opts.project.projectRoot); + const cfgName = (opts.cfg?.name ?? derived).trim(); + return cfgName.length > 0 ? cfgName : derived; +} + +/** Compose project name of a branch instance: `--`. */ +export function branchComposeProjectName(opts: { + readonly base: string; + readonly branch: string; +}): string { + return `${opts.base}--${opts.branch}`; +} + +async function readComposeProjectName( + composeFile: string +): Promise { + const text = await readTextFile(composeFile); + if (!text) { + return null; + } + + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + return null; + } + + if (!isRecord(parsed)) { + return null; + } + const name = getString(parsed, "name"); + const trimmed = name?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} diff --git a/src/lib/target-inspect.ts b/src/lib/target-inspect.ts new file mode 100644 index 00000000..c4bdbf22 --- /dev/null +++ b/src/lib/target-inspect.ts @@ -0,0 +1,1174 @@ +import { createHash } from "node:crypto"; +import { readFile, realpath, stat } from "node:fs/promises"; +import { resolve } from "node:path"; +import { YAML } from "bun"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, +} from "../constants.ts"; +import { resolveEffectiveBranch } from "./branches.ts"; +import { HACK_ERROR_CODES, type HackErrorCode } from "./cli-result.ts"; +import { + branchComposeProjectName, + resolveComposeProjectName, +} from "./compose-project-name.ts"; +import { + isLinkedGitWorktree, + resolveGitCurrentBranch, +} from "./git-worktree.ts"; +import { isRecord } from "./guards.ts"; +import { + defaultProjectSlugFromPath, + findProjectContext, + findProjectContextAtRoot, + type ProjectContext, + parseEnvConfigSelection, + readProjectConfig, + readProjectDefaultEnvConfig, + resolveWorktreeAutoBranch, + sanitizeBranchSlug, + sanitizeProjectSlug, +} from "./project.ts"; +import { ProjectInputSelectionError } from "./project-input-selection.ts"; +import { + AmbiguousProjectNameError, + normalizeProjectName, + PROJECT_SELECTOR_PATTERN, +} from "./project-name.ts"; +import { + AmbiguousProjectIdError, + type RegisteredProject, + readProjectsRegistry, + selectRegisteredProjectByName, +} from "./projects-registry.ts"; + +/** + * Read-only target inspector shared by `hack project target` and the MCP + * `hack.project.target` tool (HACK-1144 / WU31). + * + * The inspector resolves one exact target (project, checkout, runtime + * instance, optional service, env overlay selection and a config generation) + * from local files only. It never starts, repairs, registers or touches + * anything: no registry write, no Docker/engine call, no Git mutation. The + * only child processes are read-only `git rev-parse`/`git branch + * --show-current` lookups that existing project commands already run. + * + * Contract status: every schema name, field, reason and retry value here is + * PROVISIONAL until the AX compatibility review. See + * docs/reference/ax-target-result-contract.md. + * + * A resolved target is a reference: it identifies scope and grants no + * authority. Callers that act on it must revalidate at their own effect + * boundary (for example by passing `expectGeneration`). + */ + +/** Provisional schema id carried by every successful result. */ +export const TARGET_INSPECT_SCHEMA = "hack.target.v0" as const; + +/** Prefix of a provisional config generation id. */ +const GENERATION_PREFIX = "cfg-v0:" as const; + +/** Exact grammar of a generation id accepted as an expectation. */ +export const TARGET_GENERATION_PATTERN = /^cfg-v0:[0-9a-f]{32}$/; + +/** + * Runtime instance selector grammar. Identical to the MCP branch identifier + * rule, so no value can start with `-` or carry `=`. + */ +export const TARGET_BRANCH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*$/; + +/** Compose service grammar, identical to the MCP service identifier rule. */ +export const TARGET_SERVICE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; + +/** Longest accepted identifier (branch, service). */ +export const TARGET_IDENTIFIER_MAX_LENGTH = 253; + +/** Hack config files a generation covers, in hashing order. */ +const GENERATION_INPUTS = [ + PROJECT_CONFIG_FILENAME, + PROJECT_CONFIG_LEGACY_FILENAME, + PROJECT_COMPOSE_FILENAME, +] as const; + +/** Bound on one generation input; a larger file is refused, not truncated. */ +const GENERATION_INPUT_MAX_BYTES = 4 * 1024 * 1024; + +/** Which resolution stage refused. Provisional. */ +export type TargetStage = + | "selector" + | "project" + | "instance" + | "service" + | "generation"; + +/** What a caller should do next. Provisional; carries no execution authority. */ +export type TargetRetry = "fix_selector" | "fix_project" | "reinspect"; + +type RefusalSpec = { + readonly code: HackErrorCode; + readonly stage: TargetStage; + readonly retry: TargetRetry; + readonly message: string; +}; + +/** + * Closed catalogue of inspector refusals. Messages are fixed, value-free text: + * they never repeat selectors, paths, names or registry values. Codes reuse + * existing CLI error codes; the `reason` is the stable discriminator. + */ +export const TARGET_REFUSALS = Object.freeze({ + conflicting_selectors: { + code: "E_USAGE", + stage: "selector", + retry: "fix_selector", + message: "Use only one of --project or --path.", + }, + invalid_selector: { + code: "E_USAGE", + stage: "selector", + retry: "fix_selector", + message: + "Invalid target selector. Check the project, branch, service and expected generation values.", + }, + project_not_found: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "Unknown project. Run 'hack projects' to see registered projects.", + }, + ambiguous_project: { + code: "E_USAGE", + stage: "project", + retry: "fix_selector", + message: + "Ambiguous project selection: multiple registrations match. Select by path and inspect 'hack projects' before renaming or pruning an entry.", + }, + registered_root_missing: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_project", + message: + "The registered project root no longer exists. Select the checkout by path or run 'hack projects' to inspect the registration.", + }, + path_not_found: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "Path not found.", + }, + no_project_context: { + code: "E_PROJECT_NOT_FOUND", + stage: "project", + retry: "fix_selector", + message: "No hack project found at the selected location.", + }, + project_inputs_unsupported: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: + "The selected project's inputs are not supported by this inspector (native or conflicting project files).", + }, + config_parse_failed: { + code: "E_CONFIG_PARSE", + stage: "project", + retry: "fix_project", + message: "The project config could not be parsed.", + }, + compose_parse_failed: { + code: "E_CONFIG_PARSE", + stage: "project", + retry: "fix_project", + message: "The project's Compose file could not be parsed.", + }, + detached_checkout: { + code: "E_USAGE", + stage: "instance", + retry: "fix_selector", + message: + "Detached linked worktree cannot select an instance implicitly. Pass a branch, or set worktree.auto_branch=false to target the base instance.", + }, + service_not_found: { + code: "E_SERVICE_NOT_FOUND", + stage: "service", + retry: "fix_selector", + message: + "The selected service is not declared in the project's Compose file.", + }, + stale_generation: { + code: "E_USAGE", + stage: "generation", + retry: "reinspect", + message: + "The project config generation differs from the expected generation. Inspect the target again before acting.", + }, + generation_changed_during_read: { + code: "E_USAGE", + stage: "generation", + retry: "reinspect", + message: + "The project config changed while the target was being read. Inspect the target again.", + }, + generation_input_too_large: { + code: "E_CONFIG_INVALID", + stage: "generation", + retry: "fix_project", + message: "A project config input exceeds the inspector size bound.", + }, + input_permission_denied: { + code: "E_PERMISSION", + stage: "project", + retry: "fix_project", + message: "A project input could not be read: permission denied.", + }, + input_unreadable: { + code: "E_CONFIG_INVALID", + stage: "project", + retry: "fix_project", + message: "A project input could not be read.", + }, +} as const satisfies Record); + +export type TargetRefusalReason = keyof typeof TARGET_REFUSALS; + +export const TARGET_REFUSAL_REASONS = Object.keys(TARGET_REFUSALS) as [ + TargetRefusalReason, + ...TargetRefusalReason[], +]; + +/** Caller selectors. All optional; `project` and `path` are exclusive. */ +export type TargetSelector = { + readonly project?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}; + +export type TargetInspectData = { + readonly schema: typeof TARGET_INSPECT_SCHEMA; + readonly provisional: true; + readonly observed_at: string; + /** References identify scope only; they never authorize an action. */ + readonly authority: "none"; + readonly selector: { + readonly kind: "project" | "path" | "cwd"; + readonly branch_explicit: boolean; + readonly env_explicit: boolean; + readonly expected_generation: string | null; + }; + readonly project: { + readonly name: string; + readonly registration: { + readonly id: string; + readonly name: string; + readonly match: "root" | "worktree"; + } | null; + readonly project_dir: string; + }; + readonly checkout: { + readonly root: string; + readonly kind: "primary" | "linked" | "not_git"; + /** Git branch is reported, never used as runtime identity by itself. */ + readonly git_branch: string | null; + }; + readonly instance: { + readonly branch: string | null; + readonly source: "explicit" | "worktree" | "default"; + readonly compose_project: string; + }; + readonly service: { readonly name: string } | null; + readonly services: readonly string[]; + readonly env: { + readonly overlay: string | null; + readonly source: "explicit" | "project_default" | "base"; + }; + readonly generation: { + readonly id: string; + readonly scope: "hack_config"; + readonly inputs: readonly { + readonly file: string; + readonly present: boolean; + }[]; + }; + /** The snapshot never probes the engine; readiness is not claimed. */ + readonly runtime: { readonly observed: false; readonly reason: "not_probed" }; +}; + +export type TargetRefusalDetail = { + readonly reason: TargetRefusalReason; + readonly stage: TargetStage; + readonly retry: TargetRetry; + readonly provisional: true; + /** Present only for `stale_generation`: the generation actually observed. */ + readonly observed_generation?: string; +}; + +export type TargetInspectEnvelope = + | { readonly ok: true; readonly data: TargetInspectData } + | { + readonly ok: false; + readonly error: { + readonly code: HackErrorCode; + readonly message: string; + readonly detail?: TargetRefusalDetail; + }; + }; + +class TargetRefusal extends Error { + readonly reason: TargetRefusalReason; + readonly observedGeneration: string | undefined; + + constructor(reason: TargetRefusalReason, observedGeneration?: string) { + super(TARGET_REFUSALS[reason].message); + this.reason = reason; + this.observedGeneration = observedGeneration; + } +} + +/** Build the closed refusal envelope for one reason. */ +export function targetRefusalEnvelope(opts: { + readonly reason: TargetRefusalReason; + readonly observedGeneration?: string; +}): TargetInspectEnvelope { + const spec = TARGET_REFUSALS[opts.reason]; + return { + ok: false, + error: { + code: spec.code, + message: spec.message, + detail: { + reason: opts.reason, + stage: spec.stage, + retry: spec.retry, + provisional: true, + ...(opts.observedGeneration === undefined + ? {} + : { observed_generation: opts.observedGeneration }), + }, + }, + }; +} + +/** + * Inspect one target. Returns a refusal envelope for every closed failure. + * 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. + * + * `afterResolve` is a test seam that runs after the target is resolved and + * before the generation is re-read; production callers never pass it. + */ +export async function inspectTarget(opts: { + readonly cwd: string; + readonly selector: TargetSelector; + readonly registryPath?: string; + readonly now?: () => Date; + readonly afterResolve?: () => Promise; +}): Promise { + try { + const data = await resolveTarget(opts); + return { ok: true, data }; + } catch (error: unknown) { + if (error instanceof TargetRefusal) { + return targetRefusalEnvelope({ + reason: error.reason, + ...(error.observedGeneration === undefined + ? {} + : { observedGeneration: error.observedGeneration }), + }); + } + const fault = filesystemFaultCode(error); + if (fault !== null) { + return targetRefusalEnvelope({ + reason: + fault === "EACCES" || fault === "EPERM" + ? "input_permission_denied" + : "input_unreadable", + }); + } + throw error; + } +} + +/** Shape of an errno code such as `EACCES` or `EISDIR`. */ +const ERRNO_CODE_PATTERN = /^E[A-Z0-9]+$/; + +/** + * The errno code of a Node/Bun filesystem or syscall error, or null for any + * other value. Such errors carry a string `code` and `syscall`; their + * `message` includes the absolute path, which is why it is never surfaced. + */ +function filesystemFaultCode(error: unknown): string | null { + if ( + isRecord(error) && + typeof error.code === "string" && + ERRNO_CODE_PATTERN.test(error.code) && + typeof error.syscall === "string" + ) { + return error.code; + } + return null; +} + +type ValidatedSelector = { + readonly project: string | null; + readonly path: string | null; + readonly branch: string | null; + readonly service: string | null; + /** undefined: use the project default; null: base layers only. */ + readonly env: string | null | undefined; + readonly expectGeneration: string | null; +}; + +function validateSelector(selector: TargetSelector): ValidatedSelector { + if (selector.project !== undefined && selector.path !== undefined) { + throw new TargetRefusal("conflicting_selectors"); + } + let project: string | null = null; + if (selector.project !== undefined) { + const trimmed = selector.project.trim(); + project = PROJECT_SELECTOR_PATTERN.test(trimmed) + ? normalizeProjectName(trimmed) + : null; + if (!project) { + throw new TargetRefusal("invalid_selector"); + } + } + let path: string | null = null; + if (selector.path !== undefined) { + if (selector.path.trim().length === 0) { + throw new TargetRefusal("invalid_selector"); + } + path = selector.path; + } + const branch = validateIdentifier(selector.branch, TARGET_BRANCH_PATTERN); + const service = validateIdentifier(selector.service, TARGET_SERVICE_PATTERN); + let env: string | null | undefined; + if (selector.env !== undefined) { + env = parseEnvConfigSelection(selector.env); + if (env === undefined) { + throw new TargetRefusal("invalid_selector"); + } + } + let expectGeneration: string | null = null; + if (selector.expectGeneration !== undefined) { + if (!TARGET_GENERATION_PATTERN.test(selector.expectGeneration)) { + throw new TargetRefusal("invalid_selector"); + } + expectGeneration = selector.expectGeneration; + } + return { project, path, branch, service, env, expectGeneration }; +} + +function validateIdentifier( + value: string | undefined, + pattern: RegExp +): string | null { + if (value === undefined) { + return null; + } + if (value.length > TARGET_IDENTIFIER_MAX_LENGTH || !pattern.test(value)) { + throw new TargetRefusal("invalid_selector"); + } + return value; +} + +type SelectedProject = { + readonly context: ProjectContext; + readonly kind: TargetInspectData["selector"]["kind"]; +}; + +async function resolveTarget(opts: { + readonly cwd: string; + readonly selector: TargetSelector; + readonly registryPath?: string; + readonly now?: () => Date; + readonly afterResolve?: () => Promise; +}): Promise { + const selector = validateSelector(opts.selector); + const registry = await readProjectsRegistry({ + registryPath: opts.registryPath, + }); + const selected = await selectProject({ + cwd: opts.cwd, + selector, + projects: registry.projects, + }); + const context = selected.context; + + const before = await readGeneration(context); + const cfg = await readProjectConfig(context); + if (cfg.parseError) { + throw new TargetRefusal("config_parse_failed"); + } + const services = await readComposeServiceNames(context.composeFile); + + const root = await canonicalPath(context.projectRoot); + const registration = await findRegistrationForRoot({ + projects: registry.projects, + root, + }); + + const linked = await isLinkedGitWorktree({ repoRoot: context.projectRoot }); + const gitBranch = + linked === null + ? null + : await resolveGitCurrentBranch({ repoRoot: context.projectRoot }); + + const explicitBranch = selector.branch + ? sanitizeBranchSlug(selector.branch) || "branch" + : null; + const effective = await resolveEffectiveBranch({ + explicitBranch, + projectRoot: context.projectRoot, + autoBranchEnabled: resolveWorktreeAutoBranch(cfg), + }); + if (effective.source === "detached-worktree") { + throw new TargetRefusal("detached_checkout"); + } + const baseCompose = await resolveComposeProjectName({ + project: context, + cfg, + }); + const instanceBranch = effective.branch; + + if (selector.service !== null && !services.includes(selector.service)) { + throw new TargetRefusal("service_not_found"); + } + + const env = await resolveEnvSelection({ context, requested: selector.env }); + + await opts.afterResolve?.(); + const after = await readGeneration(context); + if (after.id !== before.id) { + throw new TargetRefusal("generation_changed_during_read"); + } + if ( + selector.expectGeneration !== null && + selector.expectGeneration !== after.id + ) { + throw new TargetRefusal("stale_generation", after.id); + } + + const now = opts.now ?? (() => new Date()); + return { + schema: TARGET_INSPECT_SCHEMA, + provisional: true, + observed_at: now().toISOString(), + authority: "none", + selector: { + kind: selected.kind, + branch_explicit: selector.branch !== null, + env_explicit: selector.env !== undefined, + expected_generation: selector.expectGeneration, + }, + project: { + name: projectSlug({ cfgName: cfg.name, root: context.projectRoot }), + registration, + project_dir: context.projectDir, + }, + checkout: { + root: context.projectRoot, + kind: checkoutKind(linked), + git_branch: gitBranch, + }, + instance: { + branch: instanceBranch, + source: instanceSource(effective.source), + compose_project: instanceBranch + ? branchComposeProjectName({ + base: baseCompose, + branch: instanceBranch, + }) + : baseCompose, + }, + service: selector.service === null ? null : { name: selector.service }, + services, + env, + generation: { + id: after.id, + scope: "hack_config", + inputs: after.inputs, + }, + runtime: { observed: false, reason: "not_probed" }, + }; +} + +/** Project slug: config `name`, else the slug of the checkout directory. */ +function projectSlug(opts: { + readonly cfgName: string | undefined; + readonly root: string; +}): string { + const configured = opts.cfgName?.trim() ?? ""; + return configured.length > 0 + ? sanitizeProjectSlug(configured) + : defaultProjectSlugFromPath(opts.root); +} + +function checkoutKind( + linked: boolean | null +): TargetInspectData["checkout"]["kind"] { + if (linked === null) { + return "not_git"; + } + return linked ? "linked" : "primary"; +} + +function instanceSource( + source: "explicit" | "worktree" | "none" +): TargetInspectData["instance"]["source"] { + return source === "none" ? "default" : source; +} + +async function selectProject(opts: { + readonly cwd: string; + readonly selector: ValidatedSelector; + readonly projects: readonly RegisteredProject[]; +}): Promise { + if (opts.selector.project !== null) { + let match: RegisteredProject | null; + try { + match = selectRegisteredProjectByName({ + projects: opts.projects, + name: opts.selector.project, + }); + } catch (error: unknown) { + if ( + error instanceof AmbiguousProjectNameError || + error instanceof AmbiguousProjectIdError + ) { + throw new TargetRefusal("ambiguous_project"); + } + throw error; + } + if (!match) { + throw new TargetRefusal("project_not_found"); + } + if (!(await isDirectory(match.repoRoot))) { + throw new TargetRefusal("registered_root_missing"); + } + const context = await guardInputs(() => + findProjectContextAtRoot({ + projectRoot: match.repoRoot, + projectDirName: match.projectDirName, + }) + ); + if (!context) { + throw new TargetRefusal("no_project_context"); + } + return { context, kind: "project" }; + } + + const startDir = + opts.selector.path === null + ? opts.cwd + : resolve(opts.cwd, opts.selector.path); + if (opts.selector.path !== null && !(await isDirectory(startDir))) { + throw new TargetRefusal("path_not_found"); + } + const context = await guardInputs(() => 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 + * checkouts that share a name stay separate. Two owners refuse as ambiguous. + */ +async function findRegistrationForRoot(opts: { + readonly projects: readonly RegisteredProject[]; + readonly root: string; +}): Promise { + const byRoot: RegisteredProject[] = []; + const byWorktree: RegisteredProject[] = []; + for (const project of opts.projects) { + if ((await canonicalPath(project.repoRoot)) === opts.root) { + byRoot.push(project); + continue; + } + for (const worktree of project.worktrees ?? []) { + if ((await canonicalPath(worktree.path)) === opts.root) { + byWorktree.push(project); + break; + } + } + } + const candidates = byRoot.length > 0 ? byRoot : byWorktree; + if (candidates.length > 1) { + throw new TargetRefusal("ambiguous_project"); + } + const match = candidates[0]; + if (!match) { + return null; + } + return { + id: match.id, + name: match.name, + match: byRoot.length > 0 ? "root" : "worktree", + }; +} + +async function resolveEnvSelection(opts: { + readonly context: ProjectContext; + readonly requested: string | null | undefined; +}): Promise { + if (opts.requested === null) { + return { overlay: null, source: "base" }; + } + if (opts.requested !== undefined) { + return { overlay: opts.requested, source: "explicit" }; + } + const configured = await readProjectDefaultEnvConfig({ + projectDir: opts.context.projectDir, + }); + return configured + ? { overlay: configured, source: "project_default" } + : { overlay: null, source: "base" }; +} + +/** + * Service names declared in the Compose file, sorted. An absent file declares + * none, as does an empty `services:` key. A file that is present but is not YAML, whose top level is not a + * mapping, or whose `services` is not a mapping is refused as + * `compose_parse_failed`: lifecycle commands would fail on it, so it must not + * be reported as a target with no services. The generation read has already + * bounded the file's size. + */ +async function readComposeServiceNames( + composeFile: string +): Promise { + let text: string; + try { + text = await readFile(composeFile, "utf8"); + } catch (error: unknown) { + if (isMissing(error)) { + return []; + } + throw error; + } + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + throw new TargetRefusal("compose_parse_failed"); + } + if (!isRecord(parsed)) { + throw new TargetRefusal("compose_parse_failed"); + } + const services = parsed.services; + if (services === undefined || services === null) { + return []; + } + if (!isRecord(services)) { + throw new TargetRefusal("compose_parse_failed"); + } + return Object.keys(services).sort((left, right) => left.localeCompare(right)); +} + +type Generation = { + readonly id: string; + readonly inputs: TargetInspectData["generation"]["inputs"]; +}; + +/** + * Fingerprint of the Hack config inputs only. Ordinary source edits do not + * change it; editing hack.config.json, hack.toml or docker-compose.yml does. + * Absence is hashed distinctly from an empty file. + */ +async function readGeneration(context: ProjectContext): Promise { + const hash = createHash("sha256"); + const inputs: { file: string; present: boolean }[] = []; + for (const file of GENERATION_INPUTS) { + const bytes = await readBoundedInput(resolve(context.projectDir, file)); + hash.update(`${file}\n`); + if (bytes === null) { + hash.update("0\n"); + inputs.push({ file, present: false }); + continue; + } + hash.update(`1\n${bytes.byteLength}\n`); + hash.update(bytes); + inputs.push({ file, present: true }); + } + return { + id: `${GENERATION_PREFIX}${hash.digest("hex").slice(0, 32)}`, + inputs, + }; +} + +async function readBoundedInput(path: string): Promise { + let size: number; + try { + const info = await stat(path); + if (!info.isFile()) { + return null; + } + size = info.size; + } catch (error: unknown) { + if (isMissing(error)) { + return null; + } + throw error; + } + if (size > GENERATION_INPUT_MAX_BYTES) { + throw new TargetRefusal("generation_input_too_large"); + } + try { + const bytes = await readFile(path); + if (bytes.byteLength > GENERATION_INPUT_MAX_BYTES) { + throw new TargetRefusal("generation_input_too_large"); + } + return bytes; + } catch (error: unknown) { + if (isMissing(error)) { + return null; + } + throw error; + } +} + +function isMissing(error: unknown): boolean { + return ( + isRecord(error) && (error.code === "ENOENT" || error.code === "ENOTDIR") + ); +} + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch (error: unknown) { + if (isMissing(error)) { + return false; + } + throw error; + } +} + +async function canonicalPath(path: string): Promise { + try { + return await realpath(path); + } catch { + return resolve(path); + } +} + +// --------------------------------------------------------------------------- +// Envelope validation shared by adapters (the MCP tool re-validates the CLI's +// stdout through this, so a drifted or foreign payload is never passed on). +// --------------------------------------------------------------------------- + +const KNOWN_ERROR_CODES: ReadonlySet = new Set(HACK_ERROR_CODES); + +/** + * Parse a `hack project target --json` envelope. Returns a rebuilt envelope + * containing only contract fields, or null when the value does not match the + * provisional contract. The only error envelope accepted without a `detail` + * is an `E_USAGE` refusal (the CLI's shared option-grammar refusals, raised + * before the inspector runs). Any other detail-less error, such as an + * `E_UNEXPECTED` fault whose message is free OS text, is off contract and + * becomes null, so it is never passed on. + */ +export function parseTargetInspectEnvelope( + value: unknown +): TargetInspectEnvelope | null { + if (!isRecord(value)) { + return null; + } + if (value.ok === true) { + const data = parseData(value.data); + return data ? { ok: true, data } : null; + } + if (value.ok !== false || !isRecord(value.error)) { + return null; + } + const { code, message, detail } = value.error; + if ( + typeof code !== "string" || + !KNOWN_ERROR_CODES.has(code) || + typeof message !== "string" + ) { + return null; + } + const errorCode = code as HackErrorCode; + if (detail === undefined) { + return errorCode === "E_USAGE" + ? { ok: false, error: { code: errorCode, message } } + : null; + } + const parsedDetail = parseRefusalDetail(detail); + if (!parsedDetail) { + return null; + } + const spec = TARGET_REFUSALS[parsedDetail.reason]; + if (spec.code !== errorCode || spec.message !== message) { + return null; + } + return { + ok: false, + error: { code: errorCode, message, detail: parsedDetail }, + }; +} + +function parseRefusalDetail(value: unknown): TargetRefusalDetail | null { + if (!isRecord(value) || value.provisional !== true) { + return null; + } + const reason = value.reason; + if (typeof reason !== "string" || !Object.hasOwn(TARGET_REFUSALS, reason)) { + return null; + } + const typedReason = reason as TargetRefusalReason; + const spec = TARGET_REFUSALS[typedReason]; + if (value.stage !== spec.stage || value.retry !== spec.retry) { + return null; + } + const observed = value.observed_generation; + if ( + observed !== undefined && + (typedReason !== "stale_generation" || + typeof observed !== "string" || + !TARGET_GENERATION_PATTERN.test(observed)) + ) { + return null; + } + return { + reason: typedReason, + stage: spec.stage, + retry: spec.retry, + provisional: true, + ...(observed === undefined ? {} : { observed_generation: observed }), + }; +} + +function parseData(value: unknown): TargetInspectData | null { + if ( + !isRecord(value) || + value.schema !== TARGET_INSPECT_SCHEMA || + value.provisional !== true || + value.authority !== "none" || + typeof value.observed_at !== "string" + ) { + return null; + } + const selector = parseSelectorEcho(value.selector); + const project = parseProject(value.project); + const checkout = parseCheckout(value.checkout); + const instance = parseInstance(value.instance); + const service = parseService(value.service); + const services = parseStringList(value.services); + const env = parseEnv(value.env); + const generation = parseGeneration(value.generation); + const runtime = value.runtime; + if ( + !( + selector && + project && + checkout && + instance && + service !== undefined && + services && + env && + generation && + isRecord(runtime) && + runtime.observed === false && + runtime.reason === "not_probed" + ) + ) { + return null; + } + return { + schema: TARGET_INSPECT_SCHEMA, + provisional: true, + observed_at: value.observed_at, + authority: "none", + selector, + project, + checkout, + instance, + service, + services, + env, + generation, + runtime: { observed: false, reason: "not_probed" }, + }; +} + +function parseSelectorEcho( + value: unknown +): TargetInspectData["selector"] | null { + if ( + !isRecord(value) || + (value.kind !== "project" && + value.kind !== "path" && + value.kind !== "cwd") || + typeof value.branch_explicit !== "boolean" || + typeof value.env_explicit !== "boolean" + ) { + return null; + } + const expected = value.expected_generation; + if ( + expected !== null && + !(typeof expected === "string" && TARGET_GENERATION_PATTERN.test(expected)) + ) { + return null; + } + return { + kind: value.kind, + branch_explicit: value.branch_explicit, + env_explicit: value.env_explicit, + expected_generation: expected, + }; +} + +function parseProject(value: unknown): TargetInspectData["project"] | null { + if ( + !isRecord(value) || + typeof value.name !== "string" || + typeof value.project_dir !== "string" + ) { + return null; + } + const registration = value.registration; + if (registration === null) { + return { + name: value.name, + registration: null, + project_dir: value.project_dir, + }; + } + if ( + !isRecord(registration) || + typeof registration.id !== "string" || + typeof registration.name !== "string" || + (registration.match !== "root" && registration.match !== "worktree") + ) { + return null; + } + return { + name: value.name, + registration: { + id: registration.id, + name: registration.name, + match: registration.match, + }, + project_dir: value.project_dir, + }; +} + +function parseCheckout(value: unknown): TargetInspectData["checkout"] | null { + if ( + !isRecord(value) || + typeof value.root !== "string" || + (value.kind !== "primary" && + value.kind !== "linked" && + value.kind !== "not_git") || + !(value.git_branch === null || typeof value.git_branch === "string") + ) { + return null; + } + return { root: value.root, kind: value.kind, git_branch: value.git_branch }; +} + +function parseInstance(value: unknown): TargetInspectData["instance"] | null { + if ( + !isRecord(value) || + typeof value.compose_project !== "string" || + (value.source !== "explicit" && + value.source !== "worktree" && + value.source !== "default") || + !(value.branch === null || typeof value.branch === "string") + ) { + return null; + } + return { + branch: value.branch, + source: value.source, + compose_project: value.compose_project, + }; +} + +/** undefined means invalid; null is a valid "no service selected". */ +function parseService( + value: unknown +): TargetInspectData["service"] | undefined { + if (value === null) { + return null; + } + if (!isRecord(value) || typeof value.name !== "string") { + return undefined; + } + return { name: value.name }; +} + +function parseStringList(value: unknown): readonly string[] | null { + if (!Array.isArray(value)) { + return null; + } + const out: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + return null; + } + out.push(entry); + } + return out; +} + +function parseEnv(value: unknown): TargetInspectData["env"] | null { + if ( + !isRecord(value) || + (value.source !== "explicit" && + value.source !== "project_default" && + value.source !== "base") || + !(value.overlay === null || typeof value.overlay === "string") + ) { + return null; + } + return { overlay: value.overlay, source: value.source }; +} + +function parseGeneration( + value: unknown +): TargetInspectData["generation"] | null { + if ( + !isRecord(value) || + typeof value.id !== "string" || + !TARGET_GENERATION_PATTERN.test(value.id) || + value.scope !== "hack_config" || + !Array.isArray(value.inputs) + ) { + return null; + } + const inputs: { file: string; present: boolean }[] = []; + for (const entry of value.inputs) { + if ( + !isRecord(entry) || + typeof entry.file !== "string" || + typeof entry.present !== "boolean" + ) { + return null; + } + inputs.push({ file: entry.file, present: entry.present }); + } + return { id: value.id, scope: "hack_config", inputs }; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 2e672736..4526aa25 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -36,6 +36,10 @@ import { resolveRegisteredProjectByName, selectRegisteredProjectByName, } from "../lib/projects-registry.ts"; +import { + parseTargetInspectEnvelope, + TARGET_GENERATION_PATTERN, +} from "../lib/target-inspect.ts"; import { readLinesFromStream } from "../ui/lines.ts"; import type { McpCommandAdmission } from "./command-admission.ts"; import { superviseMcpCommand } from "./command-lifetime.ts"; @@ -202,6 +206,8 @@ const COMPOSE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/; /** Same rule for branch names, which may also carry `/` (the CLI slugs it). */ const BRANCH_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_./-]*$/; const IDENTIFIER_MAX_LENGTH = 253; +/** Longest path accepted by the target inspector tool. */ +const TARGET_PATH_MAX_LENGTH = 4096; const READ_ONLY_TOOL_ANNOTATIONS = Object.freeze({ readOnlyHint: true, @@ -1214,6 +1220,53 @@ function registerTools(opts: { }, }); + register({ + name: "hack.project.target", + title: "Inspect project target", + description: + "Resolve the exact project, checkout, runtime instance, optional service, env overlay and config generation that a command would target (hack project target --json). Read-only: no engine probe, registry write or repository change. Selection is resolved by the CLI, so refusals match the CLI exactly; data is the validated CLI envelope. Names and fields are provisional.", + annotations: READ_ONLY_TOOL_ANNOTATIONS, + inputSchema: { + projectName: z + .string() + .max(IDENTIFIER_MAX_LENGTH) + .describe("Registered project name (exclusive with path)") + .optional(), + path: z + .string() + .max(TARGET_PATH_MAX_LENGTH) + .describe( + "Checkout path, absolute or relative (exclusive with projectName)" + ) + .optional(), + ...branchInput, + service: composeIdentifierInput("Compose service to resolve").optional(), + env: composeIdentifierInput( + "Env overlay name; 'base' or 'default' selects base layers only" + ).optional(), + expectGeneration: z + .string() + .regex(TARGET_GENERATION_PATTERN) + .describe("Refuse unless the config generation still equals this id") + .optional(), + }, + handler: async (input, extra) => { + const result = await runHackCommand({ + context: opts.context, + signal: extra.signal, + tool: "hack.project.target", + args: buildTargetInspectArgs(input), + }); + + // `data` replaces stdout: it is the same envelope, re-validated + // against the provisional contract (null when it does not match). + return buildToolResult({ + result: { ...result, stdout: "" }, + data: parseTargetInspectEnvelope(parseJson(result.stdout)), + }); + }, + }); + installTypedToolDispatch({ server: opts.server, tools }); } @@ -1547,6 +1600,36 @@ export function buildDoctorArgs(opts: { ]; } +/** + * `hack project target --json` argv. Project selection is passed through + * unresolved (both selectors included when both are given) so the CLI owns + * every resolution refusal, including `conflicting_selectors`. + */ +export function buildTargetInspectArgs(opts: { + readonly projectName?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}): string[] { + return [ + "project", + "target", + "--json", + ...(opts.projectName === undefined + ? [] + : [`--project=${opts.projectName}`]), + ...(opts.path === undefined ? [] : [`--path=${opts.path}`]), + ...(opts.branch === undefined ? [] : [`--branch=${opts.branch}`]), + ...(opts.service === undefined ? [] : [`--service=${opts.service}`]), + ...(opts.env === undefined ? [] : [`--env=${opts.env}`]), + ...(opts.expectGeneration === undefined + ? [] + : [`--expect-generation=${opts.expectGeneration}`]), + ]; +} + /** `hack env explain --json` argv; the validated key follows `--`. */ export function buildEnvExplainArgs(opts: { readonly projectArgs: readonly string[]; diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 29314f5b..aeeb898b 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -813,6 +813,11 @@ test("tools/list publishes names, annotations and required inputs for every tool annotations: { title: "List project instances", ...READ_ONLY }, required: [], }, + { + name: "hack.project.target", + annotations: { title: "Inspect project target", ...READ_ONLY }, + required: [], + }, ]); const run = listed.tools.find((tool) => tool.name === "hack.project.run"); expect(run?.inputSchema.properties?.timeoutMs).toMatchObject({ diff --git a/tests/project-target-inspect.test.ts b/tests/project-target-inspect.test.ts new file mode 100644 index 00000000..16f41355 --- /dev/null +++ b/tests/project-target-inspect.test.ts @@ -0,0 +1,1005 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +import { + inspectTarget, + parseTargetInspectEnvelope, + TARGET_REFUSALS, + type TargetInspectEnvelope, + targetRefusalEnvelope, +} from "../src/lib/target-inspect.ts"; +import { buildTargetInspectArgs, createMcpServer } from "../src/mcp/server.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +// --------------------------------------------------------------------------- +// HACK-1144 / WU31: paired CLI and MCP fixtures for the read-only target +// inspector. Every case runs the source CLI directly AND through the MCP tool +// (whose child is the same source CLI), and both envelopes must be identical +// apart from the observation time. The fixture is a private temp root: its own +// HOME, HACK_HOME, registry and Git repositories. No Docker/OrbStack socket, +// keychain, real home or client config is touched; the CLI child gets an +// isolated Docker client config that selects no engine. +// --------------------------------------------------------------------------- + +const CLI_ENTRY = resolve(import.meta.dir, "..", "index.ts"); +const CASE_TIMEOUT_MS = 120_000; + +type Fixture = { + readonly root: string; + readonly state: string; + readonly alpha: string; + readonly beta: string; + readonly client: Client; + /** MCP client whose CLI command is a stub that prints `fake-envelope.json`. */ + readonly stubClient: Client; + readonly cliEnv: Record; + /** Registry bytes and mtime exactly as written before any CLI run. */ + readonly registryText: string; + readonly registryMtimeMs: number; +}; + +let fixture: Fixture; + +function gitEnv(home: string): Record { + return { + PATH: process.env.PATH ?? "", + HOME: home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "fixture", + GIT_AUTHOR_EMAIL: "fixture@example.invalid", + GIT_COMMITTER_NAME: "fixture", + GIT_COMMITTER_EMAIL: "fixture@example.invalid", + }; +} + +function runGit(opts: { + readonly cwd: string; + readonly home: string; + readonly args: readonly string[]; +}): string { + const result = Bun.spawnSync(["git", "-C", opts.cwd, ...opts.args], { + env: gitEnv(opts.home), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (result.exitCode !== 0) { + throw new Error( + `git ${opts.args.join(" ")} failed: ${result.stderr.toString()}` + ); + } + return result.stdout.toString(); +} + +async function writeProject(opts: { + readonly repo: string; + readonly name: string; + readonly services: readonly string[]; + readonly config?: Readonly>; +}): Promise { + await mkdir(join(opts.repo, ".hack"), { recursive: true }); + const services = opts.services + .map((service) => ` ${service}:\n image: busybox\n`) + .join(""); + await writeFile( + join(opts.repo, ".hack", "docker-compose.yml"), + `services:\n${services}` + ); + await writeFile( + join(opts.repo, ".hack", "hack.config.json"), + `${JSON.stringify({ name: opts.name, ...opts.config })}\n` + ); +} + +/** Larger than the inspector's 4 MiB bound on one generation input. */ +const OVERSIZED_INPUT_BYTES = 4 * 1024 * 1024 + 1024; + +function registryRow(opts: { + readonly id: string; + readonly name: string; + readonly repo: string; + readonly worktrees?: readonly { path: string; branch: string | null }[]; +}) { + return { + id: opts.id, + name: opts.name, + repoRoot: opts.repo, + projectDirName: ".hack", + projectDir: join(opts.repo, ".hack"), + createdAt: "2026-01-01T00:00:00.000Z", + ...(opts.worktrees + ? { + worktrees: opts.worktrees.map((entry) => ({ + ...entry, + lastSeenAt: "2026-01-01T00:00:00.000Z", + })), + } + : {}), + }; +} + +beforeAll(async () => { + // realpath: the macOS tmpdir is a symlink; the registry stores real roots. + const root = await realpath( + await mkdtemp(join(tmpdir(), "hack-target-inspect-")) + ); + const home = join(root, "user-home"); + const state = join(root, "state"); + await mkdir(home, { recursive: true }); + await mkdir(state, { recursive: true }); + + // Project alpha: a Git checkout with two linked worktrees. + const alpha = join(root, "alpha"); + await writeProject({ repo: alpha, name: "alpha", services: ["api", "web"] }); + runGit({ cwd: alpha, home, args: ["init", "-q", "-b", "main"] }); + runGit({ cwd: alpha, home, args: ["add", "-A"] }); + runGit({ cwd: alpha, home, args: ["commit", "-q", "-m", "fixture"] }); + runGit({ + cwd: alpha, + home, + args: ["worktree", "add", "-q", "-b", "feature/login", "../alpha-login"], + }); + runGit({ + cwd: alpha, + home, + args: ["worktree", "add", "-q", "--detach", "../alpha-detached"], + }); + + // Project beta: an independent, non-Git project. + const beta = join(root, "beta"); + await writeProject({ repo: beta, name: "beta", services: ["db"] }); + + // Two independent checkouts that share a name (never merged or renamed). + await writeProject({ + repo: join(root, "gamma-one"), + name: "gamma", + services: ["api"], + }); + await writeProject({ + repo: join(root, "gamma-two"), + name: "gamma", + services: ["api"], + }); + + // A registration whose checkout was moved away. + const moved = join(root, "moved"); + await writeProject({ repo: moved, name: "moved", services: ["api"] }); + + // A project with a configured default env overlay. + const delta = join(root, "delta"); + await writeProject({ + repo: delta, + name: "delta", + services: ["api"], + config: { env: { default_overlay: "staging" } }, + }); + + // One checkout owned by two registrations (a corrupt registry). + const shared = join(root, "shared"); + await writeProject({ repo: shared, name: "shared", services: ["api"] }); + + // Unregistered projects with malformed or oversized Hack inputs. + const brokenConfig = join(root, "broken-config"); + await writeProject({ repo: brokenConfig, name: "broken", services: ["api"] }); + await writeFile(join(brokenConfig, ".hack", "hack.config.json"), '{"name":'); + const brokenCompose = join(root, "broken-compose"); + await writeProject({ + repo: brokenCompose, + name: "broken", + services: ["api"], + }); + await writeFile( + join(brokenCompose, ".hack", "docker-compose.yml"), + "services: [unclosed\n" + ); + const listCompose = join(root, "list-compose"); + await writeProject({ repo: listCompose, name: "listed", services: ["api"] }); + await writeFile( + join(listCompose, ".hack", "docker-compose.yml"), + "services:\n - api\n" + ); + const oversized = join(root, "oversized"); + await writeProject({ repo: oversized, name: "oversized", services: ["api"] }); + await writeFile( + join(oversized, ".hack", "docker-compose.yml"), + `services:\n api:\n image: busybox\n#${"x".repeat(OVERSIZED_INPUT_BYTES)}\n` + ); + // Unreadable inputs: a directory where the Compose file belongs, and a + // Compose file with no read permission. Their OS errors carry the path. + const dirCompose = join(root, "dir-compose"); + await writeProject({ repo: dirCompose, name: "dir", services: ["api"] }); + await rm(join(dirCompose, ".hack", "docker-compose.yml")); + await mkdir(join(dirCompose, ".hack", "docker-compose.yml")); + const lockedCompose = join(root, "locked-compose"); + await writeProject({ + repo: lockedCompose, + name: "locked", + services: ["api"], + }); + await chmod(join(lockedCompose, ".hack", "docker-compose.yml"), 0o000); + + await mkdir(join(root, "outside"), { recursive: true }); + + await writeFile( + join(state, "projects.json"), + `${JSON.stringify({ + version: 1, + projects: [ + registryRow({ + id: "alpha", + name: "alpha", + repo: alpha, + worktrees: [ + { path: join(root, "alpha-login"), branch: "feature/login" }, + ], + }), + registryRow({ id: "beta", name: "beta", repo: beta }), + registryRow({ + id: "gamma-1", + name: "gamma", + repo: join(root, "gamma-one"), + }), + registryRow({ + id: "gamma-2", + name: "gamma", + repo: join(root, "gamma-two"), + }), + registryRow({ id: "moved", name: "moved", repo: moved }), + registryRow({ id: "delta", name: "delta", repo: delta }), + registryRow({ id: "shared-a", name: "shared-a", repo: shared }), + registryRow({ id: "shared-b", name: "shared-b", repo: shared }), + ], + })}\n` + ); + await rm(moved, { recursive: true, force: true }); + const registryText = await readFile(join(state, "projects.json"), "utf8"); + const registryMtimeMs = (await stat(join(state, "projects.json"))).mtimeMs; + + const wrapper = join(root, "hack-source-cli"); + await writeFile( + wrapper, + `#!/bin/sh\nexec '${process.execPath}' '${CLI_ENTRY}' "$@"\n` + ); + await chmod(wrapper, 0o755); + + const cliEnv = privateCliEnv({ root, env: { HACK_HOME: state } }); + const mcpEnv = privateCliEnv({ + root, + env: { HACK_HOME: state, HACK_MCP_COMMAND: wrapper }, + }); + const server = createMcpServer({ cwd: alpha, env: mcpEnv }); + const client = new Client({ name: "hack-target-inspect", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + + // A stub CLI that prints whatever envelope a test stores, so the MCP + // adapter's re-validation can be observed against drifted output. + const stub = join(root, "hack-stub-cli"); + await writeFile( + stub, + `#!/bin/sh\ncat '${join(root, "fake-envelope.json")}'\n` + ); + await chmod(stub, 0o755); + const stubState = join(root, "stub-state"); + await mkdir(stubState, { recursive: true }); + const stubServer = createMcpServer({ + cwd: alpha, + env: privateCliEnv({ + root, + env: { HACK_HOME: stubState, HACK_MCP_COMMAND: stub }, + }), + }); + const stubClient = new Client({ name: "hack-target-stub", version: "1" }); + const [stubClientTransport, stubServerTransport] = + InMemoryTransport.createLinkedPair(); + await stubServer.connect(stubServerTransport); + await stubClient.connect(stubClientTransport); + + fixture = { + root, + state, + alpha, + beta, + client, + stubClient, + cliEnv, + registryText, + registryMtimeMs, + }; +}, CASE_TIMEOUT_MS); + +afterAll(async () => { + await fixture?.client.close(); + await fixture?.stubClient.close(); + if (fixture?.root) { + await rm(fixture.root, { recursive: true, force: true }); + } +}, CASE_TIMEOUT_MS); + +type ToolInput = { + readonly projectName?: string; + readonly path?: string; + readonly branch?: string; + readonly service?: string; + readonly env?: string; + readonly expectGeneration?: string; +}; + +type McpPayload = { + readonly ok: boolean; + readonly exitCode: number; + readonly stdout: string; + readonly command: string; + readonly spawned?: false; + readonly data?: unknown; + readonly error?: { readonly reason: string }; +}; + +async function runCli(input: ToolInput): Promise<{ + readonly exitCode: number; + readonly envelope: unknown; +}> { + const proc = Bun.spawn( + [process.execPath, CLI_ENTRY, ...buildTargetInspectArgs(input)], + { + cwd: fixture.alpha, + env: fixture.cliEnv, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + proc.exited, + ]); + return { exitCode, envelope: JSON.parse(stdout) as unknown }; +} + +async function runMcp(input: ToolInput): Promise { + const result = await fixture.client.callTool({ + name: "hack.project.target", + arguments: input, + }); + return result.structuredContent as McpPayload; +} + +/** Drop the only intentionally nondeterministic field. */ +function comparable(envelope: unknown): unknown { + const parsed = parseTargetInspectEnvelope(envelope); + if (!parsed) { + return { invalid: envelope }; + } + if (!parsed.ok) { + return parsed; + } + const { observed_at: _observedAt, ...data } = parsed.data; + return { ok: true, data }; +} + +/** Run one case on both surfaces and require the same answer. */ +async function inspectBoth(input: ToolInput): Promise { + const cli = await runCli(input); + const mcp = await runMcp(input); + const parsed = parseTargetInspectEnvelope(cli.envelope); + if (!parsed) { + throw new Error(`CLI envelope off contract: ${JSON.stringify(cli)}`); + } + expect(comparable(mcp.data)).toEqual(comparable(cli.envelope)); + expect(mcp.ok).toBe(parsed.ok); + expect(mcp.exitCode).toBe(cli.exitCode); + expect(cli.exitCode).toBe(parsed.ok ? 0 : 1); + // The MCP tool never pre-resolves selection: every case spawned the CLI. + expect(mcp.spawned).toBeUndefined(); + expect(mcp.stdout).toBe(""); + return parsed; +} + +function expectRefusal( + envelope: TargetInspectEnvelope, + reason: keyof typeof TARGET_REFUSALS +): void { + expect(envelope).toEqual(targetRefusalEnvelope({ reason })); +} + +function okData(envelope: TargetInspectEnvelope) { + if (!envelope.ok) { + throw new Error(`expected a target, got ${JSON.stringify(envelope)}`); + } + return envelope.data; +} + +describe("paired CLI/MCP target fixtures", () => { + test( + "the session checkout resolves to the primary default instance", + async () => { + const data = okData(await inspectBoth({})); + expect(data.selector).toEqual({ + kind: "cwd", + branch_explicit: false, + env_explicit: false, + expected_generation: null, + }); + expect(data.project).toEqual({ + name: "alpha", + registration: { id: "alpha", name: "alpha", match: "root" }, + project_dir: join(fixture.alpha, ".hack"), + }); + expect(data.checkout).toEqual({ + root: fixture.alpha, + kind: "primary", + git_branch: "main", + }); + expect(data.instance).toEqual({ + branch: null, + source: "default", + compose_project: "alpha", + }); + expect(data.services).toEqual(["api", "web"]); + expect(data.service).toBeNull(); + expect(data.env).toEqual({ overlay: null, source: "base" }); + expect(data.authority).toBe("none"); + expect(data.runtime).toEqual({ observed: false, reason: "not_probed" }); + }, + CASE_TIMEOUT_MS + ); + + test( + "a second project is selected by name without leaking the first", + async () => { + const data = okData(await inspectBoth({ projectName: "beta" })); + expect(data.selector.kind).toBe("project"); + expect(data.project.registration).toEqual({ + id: "beta", + name: "beta", + match: "root", + }); + expect(data.checkout).toEqual({ + root: fixture.beta, + kind: "not_git", + git_branch: null, + }); + expect(data.instance.compose_project).toBe("beta"); + expect(data.services).toEqual(["db"]); + }, + CASE_TIMEOUT_MS + ); + + test( + "a linked worktree selects its own branch instance, not the primary", + async () => { + const data = okData( + await inspectBoth({ path: join(fixture.root, "alpha-login") }) + ); + expect(data.project.registration).toEqual({ + id: "alpha", + name: "alpha", + match: "worktree", + }); + expect(data.checkout).toEqual({ + root: join(fixture.root, "alpha-login"), + kind: "linked", + git_branch: "feature/login", + }); + expect(data.instance).toEqual({ + branch: "feature-login", + source: "worktree", + compose_project: "alpha--feature-login", + }); + }, + CASE_TIMEOUT_MS + ); + + test( + "an explicit instance wins and the Git branch is reported unchanged", + async () => { + const data = okData( + await inspectBoth({ + path: join(fixture.root, "alpha-login"), + branch: "review", + service: "api", + }) + ); + expect(data.selector.branch_explicit).toBe(true); + expect(data.checkout.git_branch).toBe("feature/login"); + expect(data.instance).toEqual({ + branch: "review", + source: "explicit", + compose_project: "alpha--review", + }); + expect(data.service).toEqual({ name: "api" }); + const branches = runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["branch", "--list", "--format=%(refname:short)"], + }); + expect(branches.trim().split("\n").sort()).toEqual([ + "feature/login", + "main", + ]); + }, + CASE_TIMEOUT_MS + ); + + test( + "an explicit instance is slugged like 'hack up --branch'", + async () => { + // `hack up --branch Feature/X` targets --feature-x. + const data = okData( + await inspectBoth({ projectName: "alpha", branch: "Feature/X" }) + ); + expect(data.instance).toEqual({ + branch: "feature-x", + source: "explicit", + compose_project: "alpha--feature-x", + }); + expect(data.checkout.git_branch).toBe("main"); + }, + CASE_TIMEOUT_MS + ); + + test( + "env selection: project default, explicit overlay and base agree", + async () => { + const byDefault = okData(await inspectBoth({ projectName: "delta" })); + expect(byDefault.selector.env_explicit).toBe(false); + expect(byDefault.env).toEqual({ + overlay: "staging", + source: "project_default", + }); + + const explicit = okData( + await inspectBoth({ projectName: "delta", env: "preview" }) + ); + expect(explicit.selector.env_explicit).toBe(true); + expect(explicit.env).toEqual({ overlay: "preview", source: "explicit" }); + + const base = okData( + await inspectBoth({ projectName: "delta", env: "base" }) + ); + expect(base.selector.env_explicit).toBe(true); + expect(base.env).toEqual({ overlay: null, source: "base" }); + }, + CASE_TIMEOUT_MS + ); + + test( + "malformed or oversized Hack inputs are refused, not reported empty", + async () => { + expectRefusal( + await inspectBoth({ path: join(fixture.root, "broken-config") }), + "config_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "broken-compose") }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ + path: join(fixture.root, "broken-compose"), + service: "api", + }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "list-compose") }), + "compose_parse_failed" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "oversized") }), + "generation_input_too_large" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "an unreadable input is a closed refusal without the OS message or path", + async () => { + const path = join(fixture.root, "dir-compose"); + expectRefusal(await inspectBoth({ path }), "input_unreadable"); + const cli = await runCli({ path }); + expect(JSON.stringify(cli.envelope)).not.toContain(fixture.root); + expect(JSON.stringify(cli.envelope)).not.toContain("EISDIR"); + }, + CASE_TIMEOUT_MS + ); + + // Root reads a mode-000 file, so the permission fault cannot be produced. + test.skipIf(process.getuid?.() === 0)( + "a permission-denied input is a closed refusal without the OS message", + async () => { + const path = join(fixture.root, "locked-compose"); + 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"); + }, + CASE_TIMEOUT_MS + ); + + test( + "two registrations that own one checkout are refused as ambiguous", + async () => { + expectRefusal( + await inspectBoth({ path: join(fixture.root, "shared") }), + "ambiguous_project" + ); + expectRefusal( + await inspectBoth({ projectName: "shared-a" }), + "ambiguous_project" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "conflicting selectors are refused identically", + async () => { + expectRefusal( + await inspectBoth({ projectName: "alpha", path: fixture.beta }), + "conflicting_selectors" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "closed project, instance and service refusals agree on both surfaces", + async () => { + expectRefusal( + await inspectBoth({ projectName: "gamma" }), + "ambiguous_project" + ); + expectRefusal( + await inspectBoth({ projectName: "moved" }), + "registered_root_missing" + ); + expectRefusal( + await inspectBoth({ projectName: "zeta" }), + "project_not_found" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "alpha-detached") }), + "detached_checkout" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "outside") }), + "no_project_context" + ); + expectRefusal( + await inspectBoth({ path: join(fixture.root, "absent") }), + "path_not_found" + ); + expectRefusal( + await inspectBoth({ projectName: "alpha", service: "nope" }), + "service_not_found" + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "a stale generation is refused; source edits keep the generation", + async () => { + const first = okData(await inspectBoth({ projectName: "beta" })); + const generation = first.generation.id; + + // An ordinary source edit does not invalidate the target. + await writeFile(join(fixture.beta, "app.ts"), "export const x = 1;\n"); + const unchanged = okData( + await inspectBoth({ projectName: "beta", expectGeneration: generation }) + ); + expect(unchanged.generation.id).toBe(generation); + expect(unchanged.selector.expected_generation).toBe(generation); + + // A Hack config edit does. + await writeFile( + join(fixture.beta, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "beta", dev_host: "beta.hack" })}\n` + ); + const stale = await inspectBoth({ + projectName: "beta", + expectGeneration: generation, + }); + const current = okData(await inspectBoth({ projectName: "beta" })); + expect(current.generation.id).not.toBe(generation); + expect(stale).toEqual( + targetRefusalEnvelope({ + reason: "stale_generation", + observedGeneration: current.generation.id, + }) + ); + }, + CASE_TIMEOUT_MS + ); + + test( + "both surfaces refuse a malformed instance selector before resolution", + async () => { + const cli = await runCli({ branch: "-x" }); + expect(cli.exitCode).toBe(1); + expect(cli.envelope).toEqual( + targetRefusalEnvelope({ reason: "invalid_selector" }) + ); + // MCP validates the same grammar at its input boundary: no child runs. + const mcp = await runMcp({ branch: "-x" }); + expect(mcp.ok).toBe(false); + expect(mcp.spawned).toBe(false); + expect(mcp.error?.reason).toBe("invalid_arguments"); + }, + CASE_TIMEOUT_MS + ); + + test( + "inspection writes nothing: no registry, state or Git change", + async () => { + // The baseline is the registry as beforeAll wrote it, not as it is now: + // every earlier paired case already ran the CLI (directly and through + // MCP) against this HACK_HOME, so a last-seen touch made by any of them + // would already be in a baseline read here and go unnoticed. + const registryPath = join(fixture.state, "projects.json"); + const worktreesBefore = runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["worktree", "list", "--porcelain"], + }); + await runCli({ projectName: "alpha", branch: "fresh-instance" }); + await runCli({ path: join(fixture.root, "alpha-login") }); + expect(await readFile(registryPath, "utf8")).toBe(fixture.registryText); + expect((await stat(registryPath)).mtimeMs).toBe(fixture.registryMtimeMs); + // Only the registry and the MCP audit log exist in the state dir. + expect((await readdir(fixture.state)).sort()).toEqual([ + "mcp-audit.log", + "projects.json", + ]); + expect(await readdir(join(fixture.alpha, ".hack"))).toEqual( + expect.not.arrayContaining([".branch", ".internal"]) + ); + expect( + runGit({ + cwd: fixture.alpha, + home: join(fixture.root, "user-home"), + args: ["worktree", "list", "--porcelain"], + }) + ).toBe(worktreesBefore); + }, + CASE_TIMEOUT_MS + ); +}); + +describe("MCP adapter re-validation", () => { + async function stubbedData(envelope: unknown): Promise { + await writeFile( + join(fixture.root, "fake-envelope.json"), + JSON.stringify(envelope) + ); + const result = await fixture.stubClient.callTool({ + name: "hack.project.target", + arguments: {}, + }); + const payload = result.structuredContent as McpPayload; + expect(payload.stdout).toBe(""); + return payload.data; + } + + test( + "drifted CLI output is never passed to MCP callers", + async () => { + // Off-contract success data becomes null. + expect( + await stubbedData({ + ok: true, + data: { schema: "hack.target.v0", provisional: true, leak: "x" }, + }) + ).toBeNull(); + // Foreign fields around a valid refusal are dropped. + const refusal = targetRefusalEnvelope({ reason: "service_not_found" }); + if (refusal.ok) { + throw new Error("expected a refusal envelope"); + } + expect( + await stubbedData({ + ...refusal, + leak: "x", + error: { ...refusal.error, leak: "x" }, + }) + ).toEqual(refusal); + // A refusal whose message no longer matches its reason becomes null. + expect( + await stubbedData({ + ...refusal, + error: { ...refusal.error, message: "drifted text" }, + }) + ).toBeNull(); + // A detail-less fault outside the catalogue carries free OS text. + expect( + await stubbedData({ + ok: false, + error: { + code: "E_UNEXPECTED", + message: "EACCES: permission denied, open '/x/.hack/y.yml'", + }, + }) + ).toBeNull(); + // Success data that claims authority or a runtime observation. + const valid = await validSuccessEnvelope(); + expect(await stubbedData(valid)).toEqual(valid); + expect( + await stubbedData({ + ok: true, + data: { ...valid.data, authority: "owner" }, + }) + ).toBeNull(); + }, + CASE_TIMEOUT_MS + ); +}); + +/** A real success envelope from the inspector, for validator mutations. */ +async function validSuccessEnvelope(): Promise<{ + readonly ok: true; + readonly data: Record; +}> { + const result = await inspectTarget({ + cwd: fixture.root, + registryPath: join(fixture.state, "projects.json"), + selector: { path: fixture.beta }, + }); + if (!result.ok) { + throw new Error(`expected a target, got ${JSON.stringify(result)}`); + } + return { ok: true, data: { ...result.data } }; +} + +describe("target inspector domain", () => { + test("a config change during the read is refused, not snapshotted", async () => { + const result = await inspectTarget({ + cwd: fixture.root, + registryPath: join(fixture.state, "projects.json"), + selector: { path: fixture.beta }, + afterResolve: async () => { + await writeFile( + join(fixture.beta, ".hack", "docker-compose.yml"), + "services:\n db:\n image: postgres\n" + ); + }, + }); + expect(result).toEqual( + targetRefusalEnvelope({ reason: "generation_changed_during_read" }) + ); + }); + + test("selectors are validated before any project lookup", async () => { + const registryPath = join(fixture.state, "projects.json"); + for (const selector of [ + { project: "a=b" }, + { branch: "--all" }, + { service: "a b" }, + { env: "X=secret" }, + { expectGeneration: "cfg-v0:zz" }, + { path: "" }, + ]) { + expect( + await inspectTarget({ cwd: fixture.root, registryPath, selector }) + ).toEqual(targetRefusalEnvelope({ reason: "invalid_selector" })); + } + }); + + test("refusal text never repeats caller input", () => { + for (const spec of Object.values(TARGET_REFUSALS)) { + expect(spec.message).not.toMatch( + /gamma|alpha|beta|zeta|secret|tmp|feature/ + ); + } + }); + + test("the adapter validator rejects drifted or foreign envelopes", () => { + const refusal = targetRefusalEnvelope({ reason: "service_not_found" }); + expect(parseTargetInspectEnvelope(refusal)).toEqual(refusal); + const drift = (patch: Record) => ({ + ok: false, + error: { + ...TARGET_REFUSALS.service_not_found, + detail: { + reason: "service_not_found", + stage: "service", + retry: "fix_selector", + provisional: true, + ...patch, + }, + }, + }); + expect(parseTargetInspectEnvelope(drift({ reason: "other" }))).toBeNull(); + expect( + parseTargetInspectEnvelope(drift({ retry: "reinspect" })) + ).toBeNull(); + expect( + parseTargetInspectEnvelope( + drift({ + observed_generation: "cfg-v0:0123456789abcdef0123456789abcdef", + }) + ) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ok: false, + error: { code: "E_NOT_A_CODE", message: "x" }, + }) + ).toBeNull(); + // The message and code must be the reason's own fixed values. + if (refusal.ok) { + throw new Error("expected a refusal envelope"); + } + expect( + parseTargetInspectEnvelope({ + ...refusal, + error: { ...refusal.error, message: "The service is missing." }, + }) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ...refusal, + error: { ...refusal.error, code: "E_USAGE" }, + }) + ).toBeNull(); + expect( + parseTargetInspectEnvelope({ + ok: true, + data: { schema: "hack.target.v1" }, + }) + ).toBeNull(); + }); + + test("the adapter validator only passes parser E_USAGE without detail", () => { + const usage = { + ok: false, + error: { code: "E_USAGE", message: "Unknown option '--x'." }, + }; + expect(parseTargetInspectEnvelope(usage)).toEqual(usage); + for (const code of ["E_UNEXPECTED", "E_PERMISSION", "E_CONFIG_INVALID"]) { + expect( + parseTargetInspectEnvelope({ + ok: false, + error: { code, message: "EISDIR: illegal operation on a directory" }, + }) + ).toBeNull(); + } + }); + + test("the adapter validator refuses data that claims authority or readiness", async () => { + const valid = await validSuccessEnvelope(); + expect(parseTargetInspectEnvelope(valid)).toEqual(valid); + const patched = (patch: Record) => + parseTargetInspectEnvelope({ + ok: true, + data: { ...valid.data, ...patch }, + }); + expect(patched({ authority: "owner" })).toBeNull(); + expect(patched({ authority: undefined })).toBeNull(); + expect( + patched({ runtime: { observed: true, reason: "not_probed" } }) + ).toBeNull(); + expect( + patched({ runtime: { observed: false, reason: "probed" } }) + ).toBeNull(); + expect(patched({ runtime: null })).toBeNull(); + }); +});