Skip to content

fix(cli): keep --json stdout pure across the query commands - #719

Merged
ako merged 19 commits into
mainfrom
fix/json-output-purity
Sep 27, 2026
Merged

ako merged 19 commits into
mainfrom
fix/json-output-purity

Conversation

@ako

@ako ako commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Symptoms

Found by an agent-orientation audit on a large app (Evora Factory Management, MPR v1, 140MB). --json output did not parse:

  • refs, callers, callees, impact, structure, show, search and mxcli -c "select … from CATALOG.x" --json printed Connected to: …, catalog progress (Loading cached catalog…, and 40+ ✓ Table: N lines on a cold cache), a References to X header and a Found N result(s) count ahead of the payload.
  • An empty answer was a sentence ((no references found), (no results)) instead of [].
  • context ignored --json and printed markdown.
  • search took both --format json and --json, with --format json still leaking progress.
  • Found in the sweep: check --format json|sarif wrote its document to stderr, one document per phase, so stdout held only executor chatter. show catalog status and show widgets had no JSON path. Empty show data transformers / import mappings / export mappings printed a sentence. diff --json silently printed a text diff. A cold-cache search -q --format names printed the build's ✓ lines ahead of the names.

Cause

The executor has one writer, ctx.Output, and it carries both the payload and every status line. The mendixlabs#904 fix (progressSink, used by lint/report) works because those commands print their payload themselves, so the executor's writer can be pointed at stderr. For the query commands the executor prints the payload, through the same writer as the chatter. No writer choice at the cmd layer can separate the two, so the split has to happen inside the executor, one line at a time.

Fix

  • ExecContext.progress() returns Output in text mode, so interactive output is byte-for-byte unchanged. In JSON mode it returns the diagnostics stream (stderr). Connect, catalog load/build/warnings, catalog-query counts, and the refs/callers/callees/impact headers go through it.
  • Coordination with fix(search): warn when the source index was never built #716: this reuses the ExecContext.Diagnostics field and diagnostics() helper exactly as fix(search): warn when the source index was never built #716 adds them (identical hunk, nil means stderr). Whichever PR merges second should see a clean or trivial merge there.
  • writeEmptyResult: [] in JSON mode, with the sentence moved to stderr.
  • context --json wraps the markdown in {name, type, depth, context}, the same envelope shape as describe --json.
  • search: --json is canonical. It is the root flag, and every other query command (refs/callers/callees/impact/context/show/describe/select) already uses it. --format json still works as a deprecated alias (listed as such in --help), and it now sets the executor format, so its output is identical (tested).
  • check: structured formats now print one document on stdout that covers every phase. The exit code is unchanged. This moves the stream (stderr → stdout). No in-repo consumer read stderr (checked vscode-mdl, docs, scripts).
  • diff / diff-local refuse --json (stderr, exit 2) instead of ignoring it. --json is a root persistent flag, so every subcommand accepts it.
  • Under -q, a cold catalog build sends its per-table lines to stderr. They are moved, not dropped, because an 18-minute build needs a sign of life.

Tests + proof

cmd/mxcli/json_output_purity_test.go runs the real main() in a child process (reusing TestRunMainHelper), so stdout and stderr are really separate, os.Exit paths can be observed, and no cobra state leaks between rows:

  • TestJSONFlagKeepsStdoutPureJSON: a table of 25 subcommand invocations, including a cold-catalog run and empty results that must be []. The next command that prints through the executor belongs in this table.
  • TestJSONFlagErrorsGoToStderr: context/describe of a missing element, a bad catalog table, and diff --json. Each must exit non-zero with nothing non-JSON on stdout.
  • TestTextModeKeepsProgressOnStdout: the control. Text mode still shows Connected to:, headers and (no … found) on stdout.
  • TestSearchFormatJSONAliasIsAsPureAsJSONFlag, TestCheckStructuredOutputIsOneDocumentOnStdout, TestSearchQuietNamesIsOnlyNamesOnAColdCatalog
  • mdl/executor/progress_stream_test.go: unit tests for progress() and writeEmptyResult.

Proof by revert:

  • Before the fix, every --json row failed with the reported symptom, and the text control passed.
  • With progress() forced to return Output, all 18 then-present --json rows failed, plus the alias and error tests.
  • With cmd_check.go reverted, all 5 check rows failed (stdout is not JSON).
  • With the quiet build progress pointed back at Output, the names test failed on "✓ Modules: 2".

Evora verification (bin/mxcli <cmd> … | jq . >/dev/null, source-mode catalog)

command result exit
mxcli refs -p evora.mpr Encryption.Decrypt --json jq-ok 0
mxcli refs -p evora.mpr Nope.Nothing --json jq-ok ([]) 0
mxcli callers -p evora.mpr Encryption.Decrypt --json jq-ok 0
mxcli callers -p evora.mpr Encryption.Decrypt --transitive --json jq-ok 0
mxcli callees -p evora.mpr MxModelReflection.ACT_ShowMemberPage --json jq-ok 0
mxcli impact -p evora.mpr MxModelReflection.MxObjectType --json jq-ok 0
mxcli context -p evora.mpr MxModelReflection.MxObjectType --json jq-ok 0
mxcli context -p evora.mpr Nope.Nothing --json error → stderr, stdout empty 1
mxcli search -p evora.mpr Customer --json jq-ok 0
mxcli search -p evora.mpr Customer --format json jq-ok 0
mxcli search -p evora.mpr zzqqxxnomatch --json jq-ok ([]) 0
mxcli -p evora.mpr -c "select QualifiedName from CATALOG.microflows limit 5" --json jq-ok 0
mxcli -p evora.mpr -c "select … where Name = 'zz'" --json jq-ok ([]) 0
mxcli -p evora.mpr -c "show catalog status" --json jq-ok 0
mxcli -p evora.mpr -c "show catalog tables" --json jq-ok 0
mxcli show -p evora.mpr modules --json jq-ok 0
mxcli show -p evora.mpr entities --json jq-ok 0
mxcli show -p evora.mpr microflows MxModelReflection --json jq-ok 0
mxcli describe -p evora.mpr entity MxModelReflection.MxObjectType --json jq-ok 0
mxcli structure -p evora.mpr -d 1 --json jq-ok 0
mxcli check ok.mdl -p evora.mpr --json jq-ok 0
mxcli lint -p evora.mpr --json jq-ok 0
mxcli diff -p evora.mpr ok.mdl --json refused → stderr 2

Text mode on Evora (mxcli refs … Encryption.Decrypt) still prints Connected to: / Loading cached catalog / References to … on stdout.

Validation

  • make build and make lint pass.
  • make test: the only failures are the .mpk-fixture tests in cmd/mxcli, mdl/executor, modelsdk/widgets/mpk and sdk/widgets/mpk (open …/*.mpk: no such file). This worktree is a sparse checkout that omits .mpk blobs. I reproduced the same failing set on the unmodified base with these changes set aside, so they are pre-existing and unrelated.

Out of scope / noticed

  • The 10,000-line output guard counts pretty-printed JSON lines. On Evora, -c "show widgets" --json writes the complete document and then exits 1 with output line limit exceeded.
  • mxcli exec script.mdl --json still mixes mutation messages ("Created entity …") with JSON on stdout.
  • The per-statement JSON shape of multi-statement -c runs is a stream of documents (jq accepts it; a single-document parser does not).
  • check -p with create association … from System.Nope passes the reference check. This is unrelated and was noticed while writing the test.

Finding appended to .claude/skills/fix-issue/findings/cmd-mxcli.jsonl. One sentence added to docs-wiki/bug-patterns/cli-contract-defects.md.

🤖 Generated with Claude Code

ako and others added 19 commits September 26, 2026 19:54
The visitor stores DescribeFragmentFromStmt.ContainerType as "PAGE"/"SNIPPET"
while describeFragmentFrom switched on "page"/"snippet" with no default, so
neither branch ran and every widget was reported missing ("not found in page
M.P" — without even naming the widget). Same casing split as ALTER PAGE (#402)
and ALTER STYLING (#631).

Normalise with strings.ToLower (the convention of the other consumers), make
an unrecognised container type an error instead of an empty widget list, and
name the widget in the not-found message. The new tests parse the statement
and dispatch it through the registry, so they pin the visitor/executor casing
contract that a hand-built lowercase AST could not see.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`search` needs only a full catalog, which indexes string literals but not
MDL source. On a project where `refresh catalog full source` had never run,
the source half of every search came back empty with no word — read by
agents as "no microflow/page mentions this".

search now checks the build mode the catalog records (not the row count, so
a built-but-empty index stays silent) and, below "source", prints a warning
naming `refresh catalog full source` on a new ExecContext.Diagnostics writer
(nil = stderr), keeping --format json stdout pure. The old unconditional
"Tip: refresh catalog source" on stdout is replaced by it. Table format now
delegates to execSearch up front instead of querying twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`show structure depth 1` (and its JSON form) filtered the catalog on
MicroflowType = 'microflow' / 'nanoflow', while the catalog builder
stores 'MICROFLOW' / 'NANOFLOW'. SQLite's `=` is case-sensitive, so both
counts were always empty, and the summary omits zero counts, so every
module appeared to have no flows at all.

The builder's values are now exported constants
(catalog.MicroflowTypeMicroflow/Nanoflow/Rule), used by the writer, by
the structure query and by the linter's DocumentNoun switch, so reader
and writer share one spelling.

The test runs the real catalog builder over a MockBackend and reads the
counts back through structureDepth1 / structureDepth1JSON, so it detects
a casing drift between writer and reader; reverting only the reader to
lower case makes it fail with the reported symptom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and mapped entities

The refs graph stopped at documents. `impact Module.Entity.Attr` answered
"not referenced" for an attribute a microflow writes and a page displays
(Evora: DigitalTwin.Machine.NumberOfIncidents), an enumeration had no inbound
edge at all, a workflow started only by a microflow had no caller, a page
navigating an association and a mapping mapping an entity were invisible.

- A raw-document walk over microflows, nanoflows, rules, pages, snippets,
  workflows and import/export mappings matches every string value against the
  names the model declares: whole-string matches are structured references
  (MemberChange.Attribute, AttributeRef.Attribute, EntityRefStep.Association,
  EnumerationType.Enumeration, ObjectMappingElement.Entity); inside
  expressions, association paths and qualified enumeration values.
  New kinds: member, type, value, mapping.
- XPath constraints resolve bare attribute names against their target entity
  (and its generalizations), association paths, and enum attributes compared
  to a literal (kind xpath). Page/snippet constraints had no target entity
  because resolveEntityRefFromBSON read a key no stored EntityRef carries.
- Entities -> enumerations from attribute types (kind type).
- WorkflowCallAction -> WORKFLOW (kind call).
- New types ATTRIBUTE, ENUMERATION, ENUMERATION_VALUE, IMPORT_MAPPING,
  EXPORT_MAPPING published in the lint-rule vocabulary; members kept off the
  graph_god_nodes asset side; CatalogSchemaVersion 15.

Not covered: a bare member named through a variable in a free-text
expression ($Order/Total), whose type is not known to the catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y answer says what was checked

`refs` and `impact` printed a row per edge, so a microflow with two retrieve
activities over an entity appeared twice (Evora: ProductionLine_Reset on
DigitalTwin.Machine); the impact summary counted those rows (MICROFLOW: 9 over
six microflows) and printed the types in map order, different between runs.

- select distinct, with a total order; the summary counts distinct elements
  per type, in type order, and the footer gives both numbers.
- impact/refs on an enumeration include the edges to its values, with a
  Target column naming which value.
- "(no impact - element is not referenced)" is gone. For an attribute or an
  enumeration value the message lists the sites that were checked and the
  ones that are not resolved (a member named through a variable in an
  expression; a decision branch on an enum), and says to run search first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e listed once

`context DigitalTwin.Machine` said "Related Entities: (none found)" for an
entity with five associations. The section read refs rows whose SOURCE is an
entity, but an association edge's source is the ASSOCIATION, so only
generalizations could ever match. It now reads both ends from the
associations table (present in a fast catalog too), plus the generalization
and the specializations.

Also: Direct Callers / Shown By / workflow callers list each source once, and
the enumeration context reads the same edge set as impact (type and value
uses), grouped into entities, flows and pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ring it

Studio Pro stores expressions exactly as typed, and a trailing newline left
in the expression editor is common. describe interpolated the stored text
verbatim, so `change $X (Status = Mod.Enum.Val` / `);` put the closing paren
or semicolon on a line of its own (297 such lines in Evora Factory
Management's microflows alone).

One helper, describeExpr (TrimSpace; interior newlines kept), now renders
every stored expression the describer emits: change/create members, set,
change-list values, aggregate/reduce, list operations, call microflow /
nanoflow / java / javascript / external action arguments, show page args,
log node + template params, show message params, REST/web service/DB query
params, while, decision and rule arguments, and page widget Visible/Editable
conditions, action arguments, datasource arguments and client template
parameters. It replaces the five ad-hoc TrimSuffix/TrimRight calls that each
covered one slot. The stored model is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`--json` output did not parse. refs, callers, callees, impact, structure,
show, search and `-c "select …"` printed "Connected to:", catalog
load/build progress, a header and a "Found N" count ahead of the payload;
an empty answer was a sentence ("(no references found)") instead of [];
`context` ignored --json; `check --format json` wrote its document to
stderr, one per phase; `diff --json` printed a text diff.

The executor writes the payload and its commentary through the same
ctx.Output, so the mendixlabs#904 mechanism (pick the executor's writer at the cmd
layer) cannot separate them for commands whose payload the executor
itself prints. Add ExecContext.progress(): Output in text mode (so
interactive output is unchanged), the diagnostics stream (stderr) when
Output carries JSON. The Diagnostics field is the one #716
introduces, with the same semantics.

- Status/progress in connect, catalog load/build, catalog queries and the
  refs/callers/callees/impact handlers goes through progress().
- Empty results go through writeEmptyResult: [] in JSON mode.
- context --json wraps the markdown in {name, type, depth, context}.
- show catalog status / show widgets gain a JSON path; empty listings of
  data transformers, import/export mappings and navigation follow the
  existing `&& ctx.Format != FormatJSON` idiom.
- search: --json is the canonical spelling; --format json is kept as a
  deprecated alias and now sets the executor format, so it is as clean.
- A cold-cache catalog build under -q moves its per-table lines to stderr
  instead of stdout (search -q --format names).
- check: structured formats emit one document on stdout covering every
  phase; the exit code is unchanged.
- diff / diff-local refuse --json (no JSON output) instead of ignoring it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… trimming expression whitespace

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	mdl/executor/cmd_search.go
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efix

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ako
ako merged commit ca11c8a into main Sep 27, 2026
15 checks passed
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