Skip to content

fix(cli): fail unresolved graph commands - #547

Open
Hiro-Chiba wants to merge 1 commit into
ix-infrastructure:mainfrom
Hiro-Chiba:fix/structured-resolution-failures
Open

fix(cli): fail unresolved graph commands#547
Hiro-Chiba wants to merge 1 commit into
ix-infrastructure:mainfrom
Hiro-Chiba:fix/structured-resolution-failures

Conversation

@Hiro-Chiba

@Hiro-Chiba Hiro-Chiba commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #546

Summary

Make unresolved graph targets a structured command failure instead of an empty successful result.

Type

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Test
  • CI

Changes

  • Route unresolved targets through the existing shared reporter.
  • Emit unresolved_target JSON and LLM records and set exit status 1.
  • Cover overview, impact, contains, callers, callees, imports, imported-by, depends, trace, and history.
  • Preserve existing resolution and successful output behavior.
  • Add parameterized regression coverage for all affected commands.

Compatibility and merge blocker

Do not merge this PR until the shipped plugin call sites retain and interpret stdout from non-zero CLI runs.

The current plugin PRs are still open. ix-cursor-plugin#26 and ix-gemini-plugin#29 cover the affected fallback paths in those plugins. The current ix-opencode-plugin#20, ix-openclaw-plugin#33, and ix-claude-plugin#37 primarily cover locate/docs paths and do not yet cover every call site expanded by this PR:

  • OpenCode still discards non-zero stdout in the direct impact, callers, and depends paths used by ix-impact, ix-neighbors, and ix-decide.
  • OpenClaw's hook runner in hooks/ix-utils.ts still rejects overview and impact before the hook can inspect stdout.
  • Claude's pre-edit ix impact path still treats a non-zero run with a body as a generic command failure.

The CLI change itself remains unchanged as requested in review; this section records why the PR must remain open.

Known behavior

  • The unified message changes from command-specific wording such as No entity resolved for to No entity found matching.
  • An ambiguous target still reaches the shared unresolved_target reporter because resolveFileOrEntity currently represents both ambiguity and a miss as null. The more accurate ambiguous_resolution split remains a separate decision noted in review.

Validation

  • npm test (84 files, 1456 passed, 3 skipped; parser smoke passed)
  • npm run typecheck
  • Plugin source audit across all six shipped plugin repositories
  • OpenClaw PR tests: 13 passed
  • Cursor PR tests: 17 passed, typecheck passed
  • Gemini PR-specific non-zero-exit tests: 5 passed; unrelated live CLI fallback tests require a running Ix instance
  • Claude PR-specific non-zero locate cases passed; the full harness has a pre-existing GNU mktemp --suffix dependency that does not run cleanly on macOS
  • OpenCode tests were not run locally because Bun is not installed; its PR code was inspected directly

Checklist

  • Tests pass
  • Smoke tests pass
  • No raw errors introduced
  • CLI output follows Ix format

Release checklist (if merging to main)

  • ix-cli/package.json version bumped
  • After merge: tag pushed (git tag vX.Y.Z && git push origin vX.Y.Z)
  • If backend changes: ix-memory-layer tagged and released first
  • If docker-compose.standalone.yml changed: verified curl | sh install works

@KageBinary

Copy link
Copy Markdown
Collaborator

Review — good consolidation, one wrong-code case to decide

Replacing ten hand-rolled llm-only handlers with reportUnresolvedTarget is the right move: it deletes two copies of llmUnresolved (callers.ts, imports.ts) and eight inline duplicates, and it gives --format json an actual payload where it previously got empty stdout and exit 0. The parameterised test covering all ten commands is the right way to keep them aligned.

Confirmed the text path is unchanged: reportUnresolvedTarget prints nothing for text, and the resolver has already written its human guidance to stderr, so the only difference for an interactive user is the exit code — which is the point.

The one thing worth deciding first

resolveFileOrEntity returns null for two conditions:

if (result.ambiguous) {
  printAmbiguous(symbol, result.result, opts);   // stderr: 'Ambiguous symbol "X": ...'
}
return null;

So an ambiguous symbol now emits, on stdout:

{ "error": "unresolved_target", "message": "No entity found matching \"X\"." }

while stderr says the opposite — it resolved to several candidates and the user should pass --pick. A machine consumer is told nothing was found when in fact too much was.

This is not new to your PRcontext, explain and read have done it since they adopted the helper, and you are propagating an existing behaviour rather than inventing one. But this PR takes it from 3 commands to 13, which is the point at which it stops being an oddity.

resolve.ts already has the right slug: diagnostics: [{ code: "ambiguous_resolution", ... }]. And #559 in this same stack draws exactly this distinction in prose — "Ambiguity is a different condition … it resolved to several candidates, which is an answer, not a miss." Two PRs open at once disagreeing about it is worth resolving now.

Two ways to go, both fine by me:

  1. Merge as-is and fix ambiguous_resolution across all 13 in a follow-up — the exit code is right either way, only the slug is wrong.
  2. Have resolveFileOrEntity signal which condition it hit, and give reportUnresolvedTarget a sibling. Bigger, but it is the actual fix.

Smaller notes

  • The message text changes, from No entity resolved for "X". to No entity found matching "X". (and trace's Could not resolve X. to the same). Anything grepping the old strings breaks. Uniformity is worth it; just worth knowing it is a string change and not only a routing change.
  • callees and imported-by are registered from callers.ts / imports.ts, so the test list has the same loader twice under two names. That is correct, and not obvious — a one-line comment would save the next reader the double-take.

@KageBinary

Copy link
Copy Markdown
Collaborator

Blocking, on evidence — this has #559's problem, at ten times the surface

I was clearing the batch and went to merge this. It should not merge yet, and the reason is the one that already made #559 a draft.

#559 is held back because making ix locate exit 1 degrades shipped plugins until they are updated. This PR does the same thing to ten commands, and the plugins call them far more than they call locate.

The plugins discard stdout on a non-zero exit

Counted across the six plugin repos, in code — not prompt prose:

call site shape
ix-claude-plugin/hooks/ix-pre-edit.sh:54 RAW=$(ix impact "$REL_PATH" --format json …) || { … ix_log "FAILED ix impact exit=$_exit" … }
ix-claude-plugin/hooks/ix-read.sh:99–103 wait $_OV_PID || ix_capture_async … "overview failed"
ix-openclaw-plugin/hooks/ix-read/handler.ts:49,53 runIx(["overview", …]).catch(…), runIx(["impact", …]).catch(…)
ix-opencode-plugin/tools/ix-impact.ts:49 await $\ix impact …`intry/catchreturn unavailable(…)`
ix-opencode-plugin/tools/ix-neighbors.ts:124 await $\ix depends …`intry/catch`
ix-opencode-plugin/tools/ix-decide.ts:123 await $\ix impact …`intry/catch`

Bun's $ throws on non-zero; the shell hooks run under set -euo pipefail and branch on ||. So after this PR, an unresolved target on ix impact — the most-called of these — turns into FAILED ix impact exit=1 in the hook log, and the structured {error:"unresolved_target"} body this PR just added is the thing that gets thrown away. The user sees a generic "unavailable" where they used to see an empty-but-successful result. ix-pre-edit fires before every edit.

And the MCP layer does the same, worse

mcp/server.ts's runCommand prefers stderr over stdout when the exit is non-zero:

if (!result.ok) {
  const detail = result.stderr.trim() || result.stdout.trim() || `… failed without output`;
  return textResult(JSON.stringify({ error: detail, tool }), true);
}

reportUnresolvedTarget writes its record to stdout and nothing to stderr — but the resolver has already printed human guidance to stderr by then. So stderr.trim() wins, and ix_impact / ix_overview / ix_callers / ix_trace return {"error": "<resolver prose>"} instead of the clean error code=unresolved_target message="…" this PR emits. The structured record is built and then discarded, for all ten.

(ix_context, ix_explain and ix_read already behave this way, so this is not a regression in kind — but going from 3 commands to 13 is what makes it worth stopping for.)

What I would do

Put this in the same queue as #559after ix-opencode-plugin #20, ix-openclaw-plugin #33 and ix-claude-plugin #37, which are the PRs that teach the plugins to read a payload from a failed run. The change itself is right and I am not asking for edits to it.

Worth noting the two PRs also still disagree on the ambiguity slug (my earlier comment, and the comment in #559's diff). If the plugin work is landing anyway, fixing ambiguous_resolution in reportUnresolvedTarget before this merges would fix it once for all thirteen commands rather than thirteen times later.

Merged from this batch, for context

#549, #553, #555, #556, #558, #561, #562, #563, #564. This one, #543, #545, #551 and #559 stayed open — reasons on each.

@KageBinary

Copy link
Copy Markdown
Collaborator

Correction to my comment above: I listed three plugin repos with runners. Running the check systematically, it is five of the six.

ix impact alone, excluding .md and agent prompt strings:

ix-cursor-plugin/mcp/hooks/pre-edit.ts        runIx(["impact", …
ix-cursor-plugin/mcp/dist/hooks/pre-edit.js   runIx(["impact", …
ix-gemini-plugin/mcp/tools/ix_impact.ts       runIx(["impact", …
ix-gemini-plugin/mcp/tools/ix_query.ts        runIx(["impact", …
ix-claude-plugin/hooks/ix-pre-edit.sh         $(ix impact …
ix-opencode-plugin/tools/ix-impact.ts         $`ix impact …
ix-opencode-plugin/tools/ix-docs-tool.ts      $`ix impact …
ix-opencode-plugin/tools/ix-decide.ts         $`ix impact …

plus ix-openclaw-plugin/hooks/ix-read/handler.ts for overview and impact. So ix-cursor-plugin and ix-gemini-plugin are affected as well, and the three step-1 plugin PRs (#20 opencode, #33 openclaw, #37 claude) do not cover them.

That does not change the conclusion, it widens it: the plugin-side work this needs is five repos, not three.

The check itself is now written down in #565, along with why it is invisible from inside this repo.

KageBinary added a commit that referenced this pull request Aug 31, 2026
…565)

Twice now a PR has added a structured error body plus a matching non-zero
exit code, and the exit code is what stopped anyone from seeing the body
(#539, and #547 which is still open for this reason). The mechanism is not
visible from inside this repo, which is why it keeps recurring:

- plugins run `ix` through wrappers that discard stdout on a failed run --
  bun's `$` throws, the shell hooks branch on `||` under `set -euo pipefail`
- `runCommand` in mcp/server.ts prefers stderr over stdout when the exit is
  non-zero, so a record written to stdout is replaced by whatever prose the
  resolver already put on stderr

So the check has to happen in the plugin repos, and this documents it with
the grep that answers it. Verified the three patterns are exhaustive: no
plugin builds the command name from a variable, so a literal search finds
every call site. Spot-checked against three commands with known answers --
`impact` (8 runners across 4 repos), `read` and `doctor` (none; both only
appear in agent prompt prose, which is why they were safe to change).
@Hiro-Chiba

Copy link
Copy Markdown
Contributor Author

Thanks! I left the CLI code unchanged and only documented the plugin compatibility findings. I’ll leave the plugin-side follow-ups with you.

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.

[bug] graph commands exit 0 without JSON for unresolved targets

2 participants