Skip to content

fix(cli): report a missing project with one closed code across project commands - #427

Merged
roodboi merged 2 commits into
nextfrom
wf/g54-cli-docs-followups
Oct 23, 2026
Merged

roodboi merged 2 commits into
nextfrom
wf/g54-cli-docs-followups

Conversation

@roodboi

@roodboi roodboi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups to #394 (merged into next as d0c8184) for HACK-1371.

Rework (4d30eff): hosted test failure on e3d6917

Root cause. Fix 4 below moved the empty --env/--profile check ahead of authored dispatch, so hack run --profile "" on an authored native project now refuses with E_USAGE, not E_NATIVE_PROJECT_UNSUPPORTED. The source CLI run table in tests/native-authored-project-oneoff.test.ts still expected E_NATIVE_PROJECT_UNSUPPORTED for the ["--profile", ""] row. That failure also set the file's uncertain latch, so 4 later tests in the file failed as well (--profile dev, --env base, "public cancellation after run completion returns130…", and "unconfirmed run completion maps to the closed run refusal"). Hosted run 39846392499 reported 5 failures. My earlier focused run (33 files) did not include this file.

Fix (test only). Each row of that table now carries its expected refusal:

  • The --profile "" row expects ERROR: E_USAGE: ${NATIVE_RUN_EMPTY_SELECTION_REFUSAL} and no runtime attempt.
  • The --profile dev and --env base rows keep E_NATIVE_PROJECT_UNSUPPORTED and no runtime attempt.

No source file changed.

Proof.

  • bun test tests/native-authored-project-oneoff.test.ts: 50 pass, 0 fail. Before the fix: 45 pass, 5 fail.
  • Mutation: I swapped in src/commands/project.ts from origin/next (old dispatch order), and the --profile "" row failed. The two later rows then failed through the existing latch, as designed. I restored the file afterwards.
  • I searched the other tests for an empty --env/--profile expecting E_NATIVE_PROJECT_UNSUPPORTED. Only the logs and exec tables do that, and the early refusal applies only to run. native-authored-project-logs and native-authored-project-exec pass 91/91.

Root cause (original commit e3d6917)

  1. Commands still reported a missing project with different codes. fix(cli): report missing projects with one closed code and refuse empty native run selections #394 gave project.ts a closed MissingProjectContextError, but other command files kept their own copy of requireProjectContext. For hack <cmd> -p <empty dir>:

    • branch add/list/remove/open, config get/set and project owner show threw a plain Error. They printed ERROR: Error: ..., a stack trace, and a stray └ line on stdout. project owner show --json reported E_UNEXPECTED.
    • env ... and host exec/shell threw CliUsageError("No .hack/ found. Run: hack init"). That reported E_USAGE and added a "See --help" hint.
    • tui (with a TTY) threw a plain Error.

    docs/cli.md said every project command reported E_PROJECT_NOT_FOUND, which was not true.

  2. The docs gave the wrong refusal for a short spelling. docs/cli.md said an option that only another command declares is refused as Option(s) not valid for "<command>". That is true only for long spellings. The per-command parser (fix(cli): parse options per resolved command so -p means --path again #370) reads another command's option only by its long name, so a short spelling the command does not declare is unknown to it. For example, hack doctor -f --json gives Unknown option '-f'. .... The old union parser gave Option(s) not valid for "doctor": --follow instead, and the fix(cli): parse options per resolved command so -p means --path again #370 differential notes did not record that difference.

  3. A mutant survived. Changing .every(...) to .some(...) in requireNonEmptyNativeRunOptions survived, because no test passed a profile list that mixes empty and real entries.

  4. Authored projects got the wrong refusal. On the native backend, hack run ran authored dispatch (tryNativeComposeCommand) before the empty check. So an authored hack.project.json project refused --profile "" with E_NATIVE_PROJECT_UNSUPPORTED instead of E_USAGE.

Fix

  1. I changed the commands to match the doc instead of narrowing the doc. A new src/lib/missing-project-error.ts exports MissingProjectContextError (E_PROJECT_NOT_FOUND, message No .hack/ (or legacy .dev/) found. Run: hack init). project.ts, branch.ts, config.ts, env.ts (which covers host), project-admin.ts and tui.ts now throw it. docs/cli.md lists the commands and the documented exceptions:
    • tui without a TTY refuses for that reason first.
    • env get prints one fixed stderr line, because its output is raw values.
    • config validate/plan/import/adopt are native configuration commands and keep their own refusals.
    • project target/task use the same code with their own message.
    • init, doctor, crash-capture, setup, mcp and agent do not need a project.
  2. I documented the short-spelling refusal in "Option parsing", and added it as a second difference in the fix(cli): parse options per resolved command so -p means --path again #370 old-versus-new notes. I checked the old behaviour by running the pre-fix(cli): parse options per resolved command so -p means --path again #370 tree (a25160d^) with the same arguments. Making the two match would mean reading other commands' short flags again, which brings back the -p/-d conflict that fix(cli): parse options per resolved command so -p means --path again #370 removed. So I documented the difference instead.
  3. No source change; new tests pin the behaviour (see Tests).
  4. I moved the empty check earlier, into refuseEmptyNativeRunSelection, which handleRun calls first when HACK_RUNTIME_BACKEND=native. Authored, adopted and legacy projects now all refuse an empty value with the same E_USAGE line, before any project is selected. The Compose backend is unchanged: there an empty --env still means the base layers. docs/guides/native-candidate.md states which check runs first and the web, rule.

docs/reference/cli.md is unchanged, because bun run docs:cli-reference produced no diff (the help output did not change).

Tests (each proven by mutation)

  • tests/missing-project-error-code.test.ts was rewritten:
    • A table classifies every command that declares --path, and one test asserts that the table equals the set of commands walked from CLI_SPEC. A new --path command therefore fails until someone classifies it. Mutation: dropping the crash-capture row fails the enumeration test.
    • Each project command is spawned against an empty dir (privateCliEnv, temp HOME) and must print the exact envelope or line, with no stack trace. The documented exceptions are pinned to their exact output.
    • Mutations: restoring the origin/next version of branch.ts fails 4 tests, config.ts fails 2, env.ts fails 12 (env and host), and project-admin.ts fails 1.
  • tests/cli-renderer-loading.test.ts: the tui-missing-project control (forced TTY) now expects ERROR: E_PROJECT_NOT_FOUND: No .hack/. Mutation: restoring the origin/next version of tui.ts fails it.
  • tests/cli-per-command-options.test.ts: every short spelling that some other command declares but doctor does not (-f, -d, -c, -H, -u, -t) throws Unknown option '<short>' on doctor. doctor --follow still parses, so the CLI can say it is not valid. Mutation: letting optionTableFor also read other commands' short spellings fails it.
  • tests/native-run-selection-cli.test.ts:
    • --profile "toolchain,", ",toolchain" and " , toolchain , " run against a project started with toolchain.
    • --profile "web," against toolchain gets the mismatch refusal, not the empty one.
    • Mutation .every to .some: all 4 fail.
  • tests/native-authored-command-dispatch.test.ts: on an authored project with the native backend, run with --profile "", --profile " , ", --env "" or --env= prints exactly ERROR: E_USAGE: <NATIVE_RUN_EMPTY_SELECTION_REFUSAL>, and neither the compiler nor the runtime runs. Mutation: disabling the early check fails all 4 (they report E_NATIVE_PROJECT_UNSUPPORTED).
  • tests/native-authored-project-oneoff.test.ts: the source CLI run table, as described in Rework.

Evidence

  • Bun 1.4.2 (mise x bun@1.4.2). Base origin/next d0c8184, which includes a25160d.
  • At 4d30eff: bun run --cwd packages/cli typecheck has 0 errors. Root bun run check has 0 errors and prints privacy-check: ok. biome check is clean on the changed test file.
  • Focused suites at 4d30eff, run on this Mac:
    • The 6 changed test files: 155/155 pass.
    • native-authored-project-logs and native-authored-project-exec: 91/91 pass.
    • An earlier run at e3d6917 passed 436/436 across 33 files: the branch, config, env, host, project owner/target/task, MCP, lifecycle-json, CLI parsing, help and reference-docs, renderer loading, closed-error output, native run selection, native one-off and authored dispatch suites. That run did not include native-authored-project-oneoff, which is how the failure got through.
  • bun run docs:cli-reference: no change. No consumer agent guidance or package.json scripts changed.
  • gitleaks 8.30.1 (gitleaks dir over a git log -p origin/next..HEAD export of both commits): no leaks.
  • Hosted CI at 4d30eff (run 39854071300): the Tests (Turbo) step of the test job passed. That step runs the full suite on a macOS runner. Every other job that had finished when I wrote this also passed; see the checks tab for the final state.
  • Full suite on the M3: not run. At 17:01 I queued it at exactly 4d30eff behind the shared m3-ci/.lock. M3 had about 67 GiB free then. The lock was held first by G41, then from 17:39 by G52, whose 10-round loaded loop was still running at 20:28. About a dozen other units were also waiting, some for over 9 hours. I did not break a lock I don't own. At 20:30 I stopped my own waiter, which never took the lock and never created a worktree or ref. The hosted Tests (Turbo) full-suite step passed at this exact head.

Gaps

  • The M3 full suite has not run at 4d30eff, because the shared lock queue never reached it (see Evidence). The hosted macOS Tests (Turbo) step ran the full suite at this head and passed.
  • On the Compose backend, an authored native-Compose run still resolves --env "" as the base env. This is unchanged and documented.
  • remote and gateway (unsupported) and internal keep their own missing-project errors.
  • tui with a real TTY is covered only by the renderer-loading fixture, which forces isTTY.

Refs HACK-1371

🤖 Generated with Claude Code

…t commands

Follow-ups to #394 (HACK-1371).

- branch, config get/set, env, host, project owner and tui now raise the
  shared MissingProjectContextError (E_PROJECT_NOT_FOUND) for a path without a
  project. branch, config and project owner printed a stack trace (and
  project owner reported E_UNEXPECTED under --json); env and host reported
  E_USAGE.
- docs/cli.md lists the commands and the exceptions, and
  tests/missing-project-error-code.test.ts pins every command that declares
  --path.
- Native run: the empty --env/--profile check now runs before authored
  dispatch, so an authored project refuses it with E_USAGE, not
  E_NATIVE_PROJECT_UNSUPPORTED.
- Pin that a profile list with a real entry ("web,") is compared, not refused
  as empty, which kills the .every -> .some mutant.
- Document that a short spelling only another command declares is refused as
  an unknown option, and record that difference from the old parser with the
  #370 notes.

Refs HACK-1371

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 22, 2026

Copy link
Copy Markdown

HACK-1371

@blacksmith-sh

This comment has been minimized.

…red project

The previous commit moved the empty --env/--profile check ahead of authored
dispatch, so `run --profile ""` on an authored native project is refused with
E_USAGE before dispatch. The source CLI run table in
tests/native-authored-project-oneoff.test.ts still expected
E_NATIVE_PROJECT_UNSUPPORTED for that row, and its failure set the file's
uncertainty latch, which failed four later tests in hosted CI.

Give each row its expected refusal: the empty profile row expects the
E_USAGE empty-selection line, and the --profile dev and --env base rows keep
E_NATIVE_PROJECT_UNSUPPORTED. No row starts the runtime.

Refs HACK-1371

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi
roodboi marked this pull request as ready for review October 23, 2026 01:24
@roodboi
roodboi merged commit ad37e5b into next Oct 23, 2026
16 checks passed
@roodboi
roodboi deleted the wf/g54-cli-docs-followups branch October 23, 2026 01:24
roodboi added a commit that referenced this pull request Oct 24, 2026
…ike .hack/.gitignore (#439)

## Summary

Follow-ups to #427 and #426 (merged into `next` as `a9a0348c`). Refs HACK-1378.

## Root cause and fix

1. **`info/exclude` read-only policy did not match `.hack/.gitignore`.** #426 made
`.hack/.gitignore`    read-only whenever the file has no write permission bit (`mode & 0o222 ===
0`), for every user,    root included. `ensureHackGitInfoExclude` (`src/lib/git-info-
exclude.ts`) still used    `access(W_OK)` alone. `access(2)` grants root write access whatever
the mode, and on macOS it also    grants write when an ACL allows it. So in those cases a 0444
`info/exclude` was replaced through    the rename, and the `docs/env.md` statement "the same
policy as for `info/exclude`" was wrong.    **Fix:** `ensureHackGitInfoExclude` now checks the
mode bits it just read first. When no write    bit is set, it leaves the file alone and logs a
debug line. When some write bit is set,    `access(2)` still decides. The probe is injectable
(`checkWriteAccess`), as in    `ensureHackDirGitignore`, so a test can model root. The JSDoc and
`docs/env.md` now state the    shared rule. They also add an ACL note: an ACL that grants write
to a 0444 file does not make it    writable for Hack, so add a write bit instead. 2.
**`docs/cli.md` showed only the console logger's form.** The missing-project refusal is printed
as `logger.error("E_PROJECT_NOT_FOUND: <message>")`. Only the console logger prints it as
`ERROR: E_PROJECT_NOT_FOUND: ...`. The doc now calls it one closed error line,
`E_PROJECT_NOT_FOUND: <message>`, and shows each logger's prefix, as observed with the source
CLI    (`hack ps -p <empty dir>`):    - console: `ERROR: E_PROJECT_NOT_FOUND: ...` on stderr;
- gum: a styled `ERROR` level on stderr;    - clack: a `│` guide line, then `■
E_PROJECT_NOT_FOUND: ...` on stdout.

The `MissingProjectContextError` JSDoc and the header of `tests/missing-project-error-
code.test.ts`    now say the same. That test pins the console form, because it runs with
`HACK_LOGGER=console`. 3. **Optional item, done.** The short-spelling parse test now covers `up`
from the #370    differential table, as well as `doctor`. `up` has its own short flags (`-p`,
`-d`), so the test    also checks that this does not make other commands' short flags known for
`up`.

## Tests

- `tests/git-info-exclude.test.ts`:
  - A 0444 `exclude` stays unchanged even when the injected probe always grants write (which
  models
    root). The test also checks that the probe is never called.
  - On macOS only: a 0444 `exclude` with a real `chmod +a "<user> allow write"` ACL stays
  unchanged.
    The test first checks that the ACL makes `access(W_OK)` succeed.
  - With a write bit set, the probe decides: a rejecting probe leaves the file unchanged, and a
    granting probe updates it and keeps mode 0644.
- `tests/cli-per-command-options.test.ts`: the short-spelling test is now a `test.each` over
  `doctor` and `up`.

Each new test was checked by mutation:

- **M1:** without the mode-bit check, the root-probe test and the ACL test fail. The other 8
  tests
  pass.
- **M2:** when the code ignores the injected probe and calls `access` directly, the probe test
  fails.
- **M3:** when `src/cli/command.ts` adds other commands' short flags to the parse table, both
  rows
  of the short-spelling test (`doctor` and `up`) fail.

## Evidence

Bun 1.4.2 (`mise x bun@1.4.2`). Base `origin/next` = `a9a0348c`. All local tests used a private
HOME, `HACK_HOME`, `DOCKER_CONFIG` and an unreachable `DOCKER_HOST`, with
`HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=1`.

- Root `bun run check` (includes `privacy:check: ok`): exit 0, 0 errors.
- `bun run --cwd packages/cli typecheck`: 0 errors. The changed files lint clean.
- Focused tests: 164/164 pass across `git-info-exclude`, `cli-per-command-options`,
  `missing-project-error-code`, `project-env-config`, `hack-gitignore`, `agent-instruction-
  source`
  and `cli-command`.
- On this Mac, a 0444 file with an ACL that allows write: `access(W_OK)` returns `EACCES` before
  the
  ACL and succeeds after it. This confirms the ACL case.
- gitleaks 8.30.1 `dir` scan of a `git log -p origin/next..HEAD` export: no leaks.
- No CLI surface, agent guidance, `package.json` scripts or toolchain pins changed. So
  `docs:cli-reference`, `generate:agent-plugins` and task re-pinning do not apply.
- Full suite on the M3: pending; it is running at `25c61913`, and this description will be
  updated with the result.

## Gaps

- The root case is modeled with an injected probe; no test ran as real root.
- The ACL test runs only on macOS. On Linux it is skipped, and the probe test covers the same
  branch.

Refs HACK-1378

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant