Skip to content

feat: Native AOT build and PR smoke test (PR 2 of 3) - #192

Merged
HandyS11 merged 10 commits into
developfrom
feat/aot-native-smoke
Sep 14, 2026
Merged

HandyS11 merged 10 commits into
developfrom
feat/aot-native-smoke

Conversation

@HandyS11

Copy link
Copy Markdown
Owner

Summary

Second of three PRs from the Native AOT design (docs/superpowers/specs/2026-09-14-native-aot-design.md). The CLI and MCP server can now be published as Native AOT executables, and a new CI job checks every PR against the JIT build of the same commit. AOT stays opt-in: no project sets PublishAot, so dotnet pack and publish.yml are unchanged.

  • CLI (ProjGraph.Cli):
    • TrimmerRootAssembly for ProjGraph.Cli and Spectre.Console.Cli, which Spectre reflects over for commands and settings.
    • Two [UnconditionalSuppressMessage] attributes whose justifications cite the rooting and the smoke suite: IL3050 on Program.Main (CommandApp) and IL2067 on TypeRegistrar.Register.
    • EnableTrimAnalyzer/EnableAotAnalyzer on every build, and IlcTreatWarningsAsErrors=false so third-party ILC warnings don't fail publish.
  • MCP server (ProjGraph.Mcp): the same analyzers and IlcTreatWarningsAsErrors=false. PR 1 already removed the runtime blockers.
  • tests/ProjGraph.Tests.Smoke.Aot:
    • 28 parity tests, 18 CLI and 10 MCP, each running one input through the native build and the JIT build and requiring the same exit code, stdout, stderr, written diagram file, and MCP protocol results.
    • The tests skip unless PROJGRAPH_SMOKE_CLI_NATIVE/_CLI_REFERENCE/_MCP_NATIVE/_MCP_REFERENCE are set, so the existing matrix reports them as skipped. With PROJGRAPH_SMOKE_REQUIRED=true they never skip.
  • CI: a new aot-smoke job on ubuntu-latest installs clang/zlib1g-dev, builds Release, publishes both executables with -p:PublishAot=true, and runs the suite. It has a 30-minute timeout.
  • Docs: CLAUDE.md, CONTRIBUTING.md (including how to run the suite locally), and ARCHITECTURE.md.

The branch also carries the implementation plan (docs/superpowers/plans/2026-09-14-native-aot-pr2-native-build-smoke.md).

Results (linux-x64, SDK 10.0.401)

  • Native binaries: ProjGraph.Cli 36.6 MB and ProjGraph.Mcp 42.9 MB.
  • Smoke suite, native vs JIT: 28/28 passed in ~20 s, both with and without GITHUB_ACTIONS=true.
  • A clean clone running exactly the CI commands also passed 26/26. That run was before the two --help cases were added.
  • The existing suites are unchanged: 1,178/1,178 passed, the build has 0 warnings, and dotnet format is clean.
  • Harness self-checks:
    • JIT apphost vs JIT .dll passes.
    • A "native" binary replaced with /bin/true fails.
    • A missing variable with PROJGRAPH_SMOKE_REQUIRED=true fails instead of skipping.
  • The first native publishes failed as the analysis predicted:
    • The unmodified CLI failed on IL3050/IL2067.
    • The CLI with suppressions but without the trimmer roots crashed with Could not get settings type for command of type 'ProjGraph.Cli.Commands.VisualizeCommand', and the suite caught it.
    • The unmodified MCP project failed on third-party ILC warnings as errors.

Deviations from the spec

  1. No PublishSingleFile condition on the MCP project. With it left unconditional, the AOT publish succeeds and produces the same 42,946,344-byte binary, so the condition is a no-op. PR 3 removes PublishSingleFile entirely.
  2. The native publish uses --artifacts-path. ProjGraph.Mcp is self-contained, so an in-place AOT publish reuses bin/Release/net10.0/linux-x64. That deletes the JIT apphost and rewrites its runtimeconfig.json with AOT feature switches, which corrupts the reference build.
  3. A reference variable can be a .dll or an executable. A .dll runs through the dotnet host. The MCP reference is ProjGraph.Mcp.dll from the self-contained build output.
  4. A fifth variable, PROJGRAPH_SMOKE_REQUIRED. Without it, a misspelled variable in CI would skip every test and leave the required job green.
  5. stderr is compared too, and --output runs share one path. Spectre wraps the Saved to <path> stderr line at 80 columns, so both builds write to the same path one after the other.
  6. Reference outcomes are pinned. Each CLI case asserts that the reference exits 0 (or non-zero for the two failure cases), and each MCP tool call asserts isError != true. Diagram cases also require non-empty output. A wrong path therefore can't pass as parity.
  7. Extra cases:
    • a malformed project (visualize, stats, and MCP get_project_stats warnings);
    • a .sln that lists a project twice;
    • ConcurrentDictionary/Barrier/[Required] types in a class diagram;
    • prompts/get;
    • server capabilities;
    • --help and visualize --help.
  8. Parameter name. The spec's options.inheritance is actually options.includeInheritance.
  9. Shared MCP servers. Disposing an SDK 2.2.0 stdio client takes about 5 s, so each test class shares one native and one reference server, started lazily. The suite takes about 20 s instead of about 2 minutes.
  10. Legacy .sln fixture. It also includes a solution folder, NestedProjects, and a classic C# project-type GUID.
  11. Output location. Native outputs go to git-ignored artifacts/native/* instead of the spec's out/*, because out/ isn't git-ignored.

Found and fixed during review

  • ANSI under GitHub Actions. Spectre.Console turns on ANSI color when GITHUB_ACTIONS=true. The stats normalizer then missed the Analysis time row, and both stats cases would have failed on the first CI run; local runs couldn't show it. The normalizer now strips escape sequences before deciding which row to drop, and the local verification step sets GITHUB_ACTIONS=true.
  • Hangs. MCP requests now carry a 2-minute cancellation token, and the job has timeout-minutes: 30, so a hung native server can't block CI for hours.

Notes

  • The native CLI publish prints an informational ILC: Method '[Spectre.Console.Cli]OpenCli.OpenCliParser.Parse(...)' will always throw because: Failed to load assembly 'NJsonSchema'. It comes from rooting Spectre's unused OpenCLI parser. It isn't a warning and doesn't affect any command.
  • The plan's narrative still quotes 16 CLI and 26 total tests. The final counts are 18 and 28, after the review added the two --help cases.
  • Deferred to the PR 3 plan:
    • capturing native MCP stderr in failure messages;
    • asserting the "native" variable isn't a JIT apphost;
    • narrowing the IL3050 suppression with a CreateApp() helper;
    • splitting the CI publish steps.

Exit criteria (spec, PR 2)

  • No project enables AOT: git grep -n "<PublishAot" -- '*.csproj' '*.props' '*.targets' is empty.
  • aot-smoke is green on this PR. Its first run on a hosted runner is this PR.
  • Maintainer: after the first green run, add aot-smoke as a required status check on develop.

🤖 Generated with Claude Code

HandyS11 and others added 8 commits September 14, 2026 21:13
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…parity cases

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Spectre.Console's GitHub enricher turns on ANSI colour under
GITHUB_ACTIONS=true, which children inherit, so the stats timing row
survived NormalizeStats and Stats_* failed on every aot-smoke run;
strip escapes only to decide which line to drop. Also give every MCP
request in McpParityTests a 2-minute CancellationToken so a deadlocked
server fails the test instead of hanging, add timeout-minutes: 30 to
the aot-smoke CI job, require diagram parity cases to produce
non-empty output, and add --help parity cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CONTRIBUTING.md's Native AOT prerequisite sentence was missing
zlib1g-dev, which the CI job installs, and didn't note the commands
are linux-x64. SmokeCommand.cs's "same SDK" comment was inaccurate for
a self-contained .dll, which uses its app-local runtime. The plan
document is updated to match: the git grep form that actually
distinguishes intentional csproj comments from a real PublishAot
property, the GITHUB_ACTIONS=true env needed to exercise Spectre's
GitHub Actions ANSI path locally, the artifacts/native deviation, and
the CliParityTests/McpParityTests code blocks as changed by the fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 14, 2026 20:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

There are reliability/correctness issues in the new test harness around timeout handling (combined MCP timeout budget and process timeout cleanup) that should be fixed before relying on the new CI gate.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

This PR implements the second stage of the Native AOT effort by making the CLI and MCP server publishable as Native AOT (opt-in via -p:PublishAot=true), and adding a smoke test suite plus a dedicated CI job that validates native vs JIT parity for the same commit.

Changes:

  • Added Tests.Smoke.Aot parity suite that runs the CLI and MCP server in both native and JIT modes and compares outcomes.
  • Enabled trim/AOT analyzers in ProjGraph.Cli and ProjGraph.Mcp, added necessary trimmer roots and analyzer suppressions for Spectre reflection usage.
  • Added an aot-smoke GitHub Actions job that publishes native executables and runs the parity suite on PRs.
File summaries
File Description
tests/ProjGraph.Tests.Smoke.Aot/ProjGraph.Tests.Smoke.Aot.csproj New smoke test project definition and dependencies
tests/ProjGraph.Tests.Smoke.Aot/CliParityTests.cs CLI native-vs-JIT parity test cases
tests/ProjGraph.Tests.Smoke.Aot/McpParityTests.cs MCP native-vs-JIT parity test cases
tests/ProjGraph.Tests.Smoke.Aot/Helpers/AotSmokeFactAttribute.cs Skippable fact attribute controlled by PROJGRAPH_SMOKE_* env vars
tests/ProjGraph.Tests.Smoke.Aot/Helpers/McpServerPair.cs Shared native/reference MCP server clients for test classes
tests/ProjGraph.Tests.Smoke.Aot/Helpers/ProcessRunner.cs Process execution helper capturing exit code/stdout/stderr
tests/ProjGraph.Tests.Smoke.Aot/Helpers/SmokeCommand.cs Launch abstraction supporting exe vs dotnet <dll>
tests/ProjGraph.Tests.Smoke.Aot/Helpers/SmokeEnvironment.cs Env var resolution + repo-root discovery for smoke runs
tests/ProjGraph.Tests.Smoke.Aot/Fixtures/legacy.sln Legacy solution fixture for CLI parsing coverage
src/ProjGraph.Cli/ProjGraph.Cli.csproj Trim/AOT analyzers, ILC warning policy, and trimmer roots for Spectre
src/ProjGraph.Cli/Program.cs Suppression for IL3050 on Main with AOT justification
src/ProjGraph.Cli/Infrastructure/DependencyInjection.cs Suppression for IL2067 on Spectre type registration path
src/ProjGraph.Mcp/ProjGraph.Mcp.csproj Trim/AOT analyzers and ILC warning policy for native publish
ProjGraph.slnx Adds the new smoke test project to the solution
.github/workflows/ci.yml Adds aot-smoke job that publishes native and runs parity suite
docs/superpowers/plans/2026-09-14-native-aot-pr2-native-build-smoke.md Implementation plan document for PR2
CONTRIBUTING.md Documents the smoke suite and how to run it locally
CLAUDE.md Updates test-project list to include the smoke suite
ARCHITECTURE.md Updates repo/test layout docs to include the smoke suite
Review details
  • Files reviewed: 19/19 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +56 to +61
catch (OperationCanceledException)
{
process.Kill(entireProcessTree: true);
throw new TimeoutException(
$"'{command.FileName} {string.Join(' ', arguments)}' did not exit within {Timeout}.");
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 685a29f. The reads no longer take the timeout token. On timeout the process tree is killed, its exit is awaited, and both reads are awaited, so neither task goes unobserved. Killing closes the pipes, so the reads complete right away, and the captured stderr (plus the full command, including the dotnet <dll> prefix) goes into the TimeoutException message. I checked this with a throwaway program running a hung bash -c '…; sleep 60' child under a 2 s timeout: the reads completed with the partial output at ~2.08 s, and no child process was left running.

Comment on lines +124 to +129
var (native, reference) = await servers.GetClientsAsync();
using var timeout = new CancellationTokenSource(RequestTimeout);

var referenceResult = await request(reference, timeout.Token);
AssertSameJson(await request(native, timeout.Token), referenceResult);
return referenceResult;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 685a29f. A new SendWithTimeoutAsync helper gives each request its own CancellationTokenSource(RequestTimeout), so the reference and native calls each get the full 2 minutes and a slow reference call can't shorten the native budget. The native suite still passes 28/28, with and without GITHUB_ACTIONS=true.

HandyS11 and others added 2 commits September 14, 2026 22:50
CodeQL autobuild selected the new tests/ProjGraph.Tests.Smoke.Aot/Fixtures/legacy.sln
fixture instead of ProjGraph.slnx, compiled nothing, and failed. Manual build mode
builds the real solution regardless of fixture files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…parately

- ProcessRunner: the stdout/stderr reads no longer take the timeout token. On a
  timeout the process tree is killed, its exit awaited, and both reads awaited, so
  the tasks are observed and the captured stderr is included in the TimeoutException.
- McpParityTests: the reference and native requests each get their own 2-minute
  CancellationTokenSource instead of sharing one budget.

Addresses Copilot review comments on #192.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@HandyS11
HandyS11 merged commit fe0f3fd into develop Sep 14, 2026
7 checks passed
@HandyS11
HandyS11 deleted the feat/aot-native-smoke branch September 14, 2026 21:01
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.

2 participants