Skip to content

Expose the build model as JSON via --describe and --plan --json (#642) - #662

Open
phmatray wants to merge 10 commits into
Fallout-build:developfrom
phmatray:feat/642-expose-the-build-model-as-data-fallout-d
Open

Expose the build model as JSON via --describe and --plan --json (#642)#662
phmatray wants to merge 10 commits into
Fallout-build:developfrom
phmatray:feat/642-expose-the-build-model-as-data-fallout-d

Conversation

@phmatray

@phmatray phmatray commented Aug 27, 2026

Copy link
Copy Markdown

Implements #642.

Closes #642.

Executing the implementation plan task-by-task; the checklist below — and the plan on the issue — are ticked as each task lands. Opened as a draft — will be marked ready after the final task and a code-review pass.

Two constraints shaped the design, both verified in the tree:

  • BuildGraphUtility already projects targets and all four relation kinds into a schema-versioned document, so this extends that model additively rather than inventing a second one. SchemaVersion stays 1, which also delivers Emit declared build parameters into build-graph.json #499's parameters[].
  • BuildManager.Execute runs ToolRequirementService.EnsureToolRequirements (writes nuget.csproj, shells out to dotnet restore) before the IOnBuildInitialized hooks. So the "runs no external tool" criterion rules out another extension; the emission short-circuits above that line.

The commands land as the build-side parameters --describe and --plan --json, because CommandDispatcher routes bare arguments to the build and reserves :-prefixed tokens for CLI commands.

Plan

  • Task 1: Project tool requirements onto the target model
  • Task 2: Project declared parameters onto the model (also closes Emit declared build parameters into build-graph.json #499)
  • Task 3: --describe emits the model before tool requirements run
  • Task 4: --plan --json emits the resolved execution plan
  • Task 5: Machine-readable error envelope on failure
  • Task 6: Render --help from the shared model

--help is byte-identical to main

Verified by building main in a scratch worktree and diffing the output: the only difference is the version string. --help takes its displayed fields from the shared model (so the two views cannot drift) but keeps ordering — of targets, of each dependency line, and of parameters — from the declarations, because declaration order carries the pipeline reading a human wants.

Code review

A review of the branch surfaced 15 findings; all are addressed. The ones worth calling out:

  • The plan contradicted the executor. BuildExecutor.MarkTargetSkipped only skips when !target.Invoked, so --skip never stops an explicitly invoked target. The projection had no such guard and reported "skip": "via parameter" for it — a consumer driving CI off the plan would conclude nothing runs. Fixed, with a spec that pins the parity.
  • The gate was in the wrong place. It sat after the IOnBuildCreated extensions, so each misbehaving extension needed its own opt-out — and UpdateNotificationAttribute calls Console.ReadKey(), which would have deadlocked a piped --describe. The gate now fires before any extension runs, and the per-extension opt-out is gone. IsRequested reads each flag from the injected property or the raw arguments, because InjectParameterValuesAttribute is itself an IOnBuildCreated extension.
  • Build-level [Requires<T>] was invisible. That attribute targets Class/Interface only, so class-level requirements could never appear on a target and were missing entirely. They now project to a root-level toolRequirements[].
  • Type.FullName leaked the runtime version into the contract for constructed generics (List\1[[System.String, …, Version=10.0.0.0, …]]`). Generic arguments are now rendered recursively.
  • Dropped a list field that could never be false, gave the plan and error documents their own schema-version constants, and de-duplicated the serializer options, version lookup, and skip-reason string that had been copy-pasted.

Making standard output actually parseable

Running the feature revealed that the document was correct but unreachable through any shipped entry point — everything upstream of the build wrote to stdout too, so --describe could not be piped into a parser. Three fixes:

  • Fallout.Cli compiled the build project with its output inherited on stdout. For an introspection request that step now goes to standard error. Measured through the locally built CLI: 12 123 bytes of JSON on stdout, 8 582 bytes of build noise on stderr. Ordinary runs are unchanged — build output stays on stdout.
  • build.ps1 / build.sh (and the shipped templates, kept in sync) printed their banner and dotnet tool restore output to stdout. That is provisioning diagnostics, so it moves to standard error.
  • --help no longer aborts when stdout has no console. Console.BufferWidth throws IOException behind a pipe or on a console-less agent, which killed --help after the target list and before the parameters. The wrap width is cosmetic, so it now falls back to the existing 90-column cap. This closes --help crashes with IOException when stdout has no console handle #616.

Also: the emitted documents are newline-terminated, matching SchemaUtility's JSON.

⚠️ ./build.ps1 --describe stays unparseable until a Fallout release carrying the CLI fix is installed, because the script delegates to whatever dotnet fallout is on the machine. Nothing in this PR can change an already-installed tool.

Follow-ups

🤖 Generated with Claude Code

@phmatray
phmatray marked this pull request as ready for review August 27, 2026 11:20
@phmatray
phmatray requested a review from a team as a code owner August 27, 2026 11:20

@ChrisonSimtian ChrisonSimtian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a lot of this code is static methods and static classes. I think this one needs a bit more hands on from a human, the code quality unfortunately trips AI to think all-static is a good and preferred pattern.
might be worth to nudge your agent into the right direction and have it have another go 😊

@phmatray
phmatray force-pushed the feat/642-expose-the-build-model-as-data-fallout-d branch from 662c6f8 to 46c4456 Compare September 3, 2026 10:25
@phmatray
phmatray changed the base branch from main to develop September 3, 2026 10:25
Addresses the review: the introspection code was a static class over ambient
state, which the surrounding statics made easy to reach for but which cost
correctness and coverage.

BuildIntrospectionService is now a sealed class holding the resolved request.
BuildManager builds one with `For(build)` and reuses it at the gate, on the
failure path and before the outcome tables. Those three sites each used to
re-read the flags out of ParameterService, so a single run answered "is this
introspection?" three separate times from process-global state that nothing
kept in agreement. The invoked targets are read once now too, instead of once
for the planner and again inside GetDocument.

That also removes a test-only overload, and with it a real gap. Production
described a build through GetJsonString(build, targets), which projects the
class-level [Requires<T>] requirements; the specs called an overload taking an
explicit version, and that one passed no requirements at all. The specs were
asserting a document the product never emits, and the projection was uncovered.
BuildGraphUtility now takes the version on the same path production uses, and a
new spec pins a build-level requirement reaching the document.

The argument-based IsRequested stays static: the CLI asks it before a build
exists, and it is a pure function of its arguments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyD69qYha8hBMuja7opyj2
@phmatray

phmatray commented Sep 3, 2026

Copy link
Copy Markdown
Author

Fair call, and it turned out to be hiding a real bug rather than just style. Pushed in e6c358d.

BuildIntrospectionService is a sealed class now, holding the resolved request. BuildManager builds one with For(build) and reuses it at the gate, on the failure path, and before the outcome tables. Those three sites each re-read the flags out of ParameterService, so a single run answered "is this an introspection request?" three separate times from process-global state that nothing kept in agreement. The invoked targets are read once now too, rather than once for the planner and again inside GetDocument.

The part worth flagging: dropping the static shape removed a test-only overload, and that overload was papering over a coverage gap. Production described a build through GetJsonString(build, targets), which projects the class-level [Requires<T>] requirements. The specs called GetDescribeJson(build, targets, version), which routed to a GetModel overload passing no requirements — so the specs were asserting a document the product never emits, and that projection had no coverage at all. BuildGraphUtility now takes the version on the same path production uses, and there is a new spec pinning a build-level requirement reaching the document.

One thing I deliberately left static: IsRequested(IReadOnlyCollection<string>). The CLI calls it before a build process exists, and it is a pure function of its arguments, so an instance would only add ceremony. Happy to move it if you would rather it lived somewhere else.

I did not touch BuildGraphUtility's other members or BuildManager/BuildExecutor themselves — they are pre-existing static utilities and pure projections, and converting them looked like a bigger change than this PR should carry. Say the word if you want that too.

Full suite green: 849 passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants