Skip to content

preview: accept BuildInfo JSON from other build systems (e.g. Bazel) - #43

Open
wani-prnd wants to merge 13 commits into
k-kohey:mainfrom
wani-prnd:build-info-input
Open

wani-prnd wants to merge 13 commits into
k-kohey:mainfrom
wani-prnd:build-info-input

Conversation

@wani-prnd

@wani-prnd wani-prnd commented Oct 1, 2026 •

Copy link
Copy Markdown

Summary

Lets axe preview apps built by other build systems (e.g. Bazel). Instead of running xcodebuild, axe reads a BuildInfo JSON file (--build-info <file> or .axerc BUILD_INFO) with the module's compiler arguments, sources, search paths, optional index store and the simulator app. The format is in the README.

As suggested in review, running a build command and rebuilding from it (BUILD_INFO_COMMAND) is split into a follow-up PR.

Changes

  • build/buildinfo.go, build/build.go: load BuildInfo (relative paths resolved against the project root) instead of running xcodebuild; Run returns ErrRebuildUnsupported in this mode.
  • hot_reload.go, simruntime_backend.go, service.go: keep the compile error instead of rebuilding; full rebuilds and preview build fail explicitly.
  • pipeline.go, build/settings.go: take a source's module from BuildInfo first (works without an index store).
  • build/config.go: explicit ProjectRoot(); the BuildInfo file keys the cache.
  • analysis/index_store.go, analysis/index_reader.go: resolve relative index store paths against the project root.
  • simulator.go: make the staged app writable (build outputs may be read-only).

Without --build-info / BUILD_INFO, behavior is unchanged.

Testing

  • go test ./... and mise run check pass; new unit tests cover the review points.
  • Manual oneshot capture on a sample app with SwiftPM modules: xcodebuild mode renders the same as f016a90 (only run-to-run rendering noise), and a BuildInfo file with relative paths renders the same preview. Not re-checked yet: preview watch and a Bazel-built app.

🤖 Generated with Claude Code

Adds a way to drive previews from build systems other than xcodebuild
(e.g. Bazel). Instead of running xcodebuild, axe reads the compiler
inputs, the app bundle and the index store from a BuildInfo JSON file:

- `--build-info <file>` / `.axerc` `BUILD_INFO=<file>`
- `.axerc` `BUILD_INFO_COMMAND=<command>`: a command that builds the app
  and prints BuildInfo JSON; `{file}` and `{device}` are substituted.
  Watch mode runs it again when a change needs a full rebuild.

Supporting changes, which leave the xcodebuild path unchanged:
- index store paths recorded relative to the workspace are resolved
  against the project root;
- the module of a source file is taken from BuildInfo when present;
- the staged app copy is made writable (build outputs may be read-only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012DB94MuxYdqJBkqHEEgqN7
@wani-prnd
wani-prnd marked this pull request as ready for review October 1, 2026 06:44
@k-kohey

k-kohey commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Thanks for contributing! I'll review this PR in a few days.

if device == "" {
device = "generic"
}
command := strings.NewReplacer("{file}", shellQuote(sourceFile), "{device}", shellQuote(device)).Replace(pc.BuildInfoCommand)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This allows shell injection when the template quotes a placeholder, e.g. ./build "{file}": the inserted single quotes are literal inside double quotes, so a filename such as $(printf INJECTED).swift executes command substitution (reproduced locally). Please pass file/device values as separate arguments or environment variables instead of interpolating them into shell source, and add a regression test for quoted placeholders.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for catching this — you're right that single-quoting the value doesn't help once the template itself wraps the placeholder in double quotes.

I removed the placeholder substitution entirely. BUILD_INFO_COMMAND is now passed to /bin/sh -c unchanged, and the values are provided only through the environment as AXE_PREVIEW_FILE and AXE_PREVIEW_DEVICE, so they never become part of the shell source and are never evaluated, regardless of how the command quotes them. The README example is now:

BUILD_INFO_COMMAND=./tools/preview-build-info "$AXE_PREVIEW_FILE" --device "$AXE_PREVIEW_DEVICE"

A command that still contains {file} / {device} is rejected when .axerc is loaded, with a message pointing to the variables to use instead (shell parameters such as ${file} are not treated as placeholders).

Regression tests in cmd/internal/preview/build/buildinfo_test.go:

  • TestRunBuildInfoCommandDoesNotEvaluateValues — file and device values containing $(printf INJECTED), $(touch PWNED), backticks, single quotes and spaces, with unquoted and double-quoted references; asserts the values arrive verbatim and no command substitution runs. These cases fail against the previous implementation.
  • TestCheckBuildInfoCommandRejectsPlaceholders — {file}, "{file}" and {device} are rejected; ${file} is accepted.

Changes are in 704c4e8 and 0bc77b5.

wani-prnd and others added 2 commits October 14, 2026 11:00
Substituting {file} and {device} into the shell source let a quoted
placeholder such as `./build "{file}"` evaluate command substitutions in
the file name. The command now receives the values as AXE_PREVIEW_FILE and
AXE_PREVIEW_DEVICE and is run unchanged, so they are never evaluated.
A command still using {file}/{device} fails with the replacement to use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
Checking the former placeholders when the command first runs let a
stale .axerc that also sets BUILD_INFO pass startup and fail only on the
first watch-mode rebuild. Check it where .axerc is read instead, and do
not mistake shell parameters such as ${file} for a placeholder.

The failure message also names the file and device the command ran
with, which the command text no longer shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
Comment thread cmd/internal/preview/pipeline.go Outdated
moduleName := bs.ModuleName
if cache, err := analysis.LoadIndexStore(ctx, dirs.IndexStorePath(), ""); err == nil {
moduleName = moduleNameForSource(sourceFile, bs.ModuleName, cache)
moduleName = moduleNameForSettings(sourceFile, bs, cache)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

indexStorePath is optional, but main-only captures ignore BuildInfo module mappings when index loading fails. With HostApp.app and SourceModules[source] = "FeatureUI", I reproduced a thunk importing HostApp. Please resolve explicit BuildInfo mappings independently of the index store and keep host-app identity separate from source-module resolution. Add a regression test without an index store.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed (3e053f1): the BuildInfo SourceModules mapping is used first, the index store only when it has no entry, and the host app module only as a last fallback. Regression test: TestCompileMainOnlyPipelineUsesBuildInfoModuleWithoutIndexStore.

Comment thread cmd/internal/preview/build/build.go Outdated
// Prepare runs the full build pipeline: fetch settings, optionally build,
// and extract compiler paths. This is the high-level entry point.
func Prepare(ctx context.Context, pc ProjectConfig, dirs ProjectDirs, reuse bool, r Runner) (*Result, error) {
if pc.BuildInfoPath != "" {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

With only BUILD_INFO_COMMAND, serve/report/build reach this function without BuildInfoPath; only oneshot/watch call resolveBuildInfo. I reproduced xcodebuild being called with empty project/scheme arguments. Please share one build-layer preparation/rebuild path rather than copy command execution across modes. Unsupported modes should fail explicitly. Static JSON support and command/rebuild support could also be split into separate PRs.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Without the command, every mode prepares through build.Prepare, which loads the static BuildInfo, so xcodebuild is no longer called with empty arguments. build.Run now returns ErrRebuildUnsupported in BuildInfo mode: watch/serve keep the original compile error, and preview build fails explicitly. For the follow-up I plan to support the command in oneshot/watch first and have serve/report fail explicitly — would you expect serve/report in the same PR?

Comment thread cmd/internal/preview/hot_reload.go Outdated
if source == "" {
source = sourceFile
}
if _, err := build.RunBuildInfoCommand(pc, source, pc.BuildInfoDevice); err != nil {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

With --build-info old.json plus BUILD_INFO_COMMAND, initial preparation skips the command, but rebuild runs it and discards its output path. I reproduced NewApp output being ignored in favor of OldApp settings. Please apply consistent precedence: clear the overridden command, or document combined usage and load the returned path. Test initial preparation and rebuild together.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This combination no longer exists in this PR. In the follow-up, initial preparation and rebuild will read the same source: the flag overrides .axerc, and setting both BUILD_INFO and BUILD_INFO_COMMAND will be an error, tested together.

return "", err
}
h := sha256.Sum256([]byte(pc.Root + "\x00" + sourceFile))
path := filepath.Join(cacheDir, "axe", fmt.Sprintf("build-info-%x.json", h[:8]))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The cache key excludes device/session despite {device} affecting output. I reproduced DEVICE-B overwriting DEVICE-A output for the same root/source. Concurrent callers also write directly to this file. Please return parsed BuildInfo or use session-scoped output; intentional sharing needs a defined key and synchronized, atomic publication. Adding the device alone still leaves same-device callers sharing the file.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Will address in the follow-up: the command's stdout will be parsed and returned directly, so there is no shared cache file. That also removes the device and concurrent-writer collisions you reproduced.

Comment thread cmd/internal/preview/build/config.go Outdated
Root string
// BuildInfoDevice is the {device} value BuildInfoCommand runs with; BuildInfoSource the {file} value. Kept so
// that watch mode can run the command again when a change needs a rebuild.
BuildInfoDevice string

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Could configuration, invocation inputs, and results be separate? BuildInfoPath represents both user input and generated output, while source/device retain execution state. Preparer caches one project-level result, but this command can depend on source/device. Please define cache ownership and sharing before integrating serve/report. Small request/result types would suffice without a large backend framework.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Will address in the follow-up with small types: a BuildInfo source (file or command) in the config, a per-call request (source, device), and the existing build.Result. This PR already removes the execution state from ProjectConfig.

device = "generic"
}
command := strings.NewReplacer("{file}", shellQuote(sourceFile), "{device}", shellQuote(device)).Replace(pc.BuildInfoCommand)
cmd := exec.Command("/bin/sh", "-c", command)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Please accept the caller context and use the existing process-group cancellation mechanism. Watch rebuild blocks here, but cancellation cannot reach this exec.Command. Add a test covering cancellation and child-process cleanup.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Will address in the follow-up: the command will run via procgroup.Command with the caller's context, with a cancellation test covering child processes. Watch rebuilds will pass their own context through.

Comment thread cmd/internal/preview/build/config.go Outdated
}
// Build info mode without a project: a path inside the root, so that filepath.Dir
// (used to derive the project root) yields the root itself.
return filepath.Join(pc.Root, ".axe-build-info")

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Optional cleanup, not a reproduced failure: could project root and cache identity be explicit? Returning a fictitious file for callers to apply filepath.Dir makes non-Xcode configuration depend on a path workaround.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done (2cd44b5): ProjectRoot() replaces filepath.Dir(PrimaryPath()), and PrimaryPath() is only the cache key (the BuildInfo file in BuildInfo mode). Relative paths in BuildInfo are also resolved against ProjectRoot(). Test: TestProjectConfigPaths.

wani-prnd and others added 10 commits October 20, 2026 14:46
Split as suggested in review: this PR keeps static BuildInfo JSON
(--build-info / .axerc BUILD_INFO); running a build command and
rebuilding from it move to a follow-up PR.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
compileMainOnlyPipeline only consulted BuildInfo SourceModules when the index
store loaded, so without one a thunk imported the host app (bs.ModuleName)
instead of the source's module. Look up the BuildInfo mapping first, read the
index store only when it has none, and use the host app name as the last fallback.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
build.Run is the one xcodebuild rebuild path that bypassed Prepare (watch thunk-compile
retry, serve optimistic rebuild) and would run xcodebuild with empty project/scheme under
--build-info. Reject it there with an explicit error; Prepare, Preparer and `preview build`
already go through PrepareFromBuildInfo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
With --build-info, a failed thunk compile in watch (switchFile) and serve (compileInitialThunk)
used to retry via build.Run, whose build-info error replaced the real Swift compile error.
Skip the xcodebuild retry there and return the original error; serve's main-only fallback
still runs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
PrimaryPath returned a fictitious <Root>/.axe-build-info in build info mode so
that callers applying filepath.Dir would land on Root. Add ProjectRoot for the
root and let PrimaryPath return Root as the cache identity, so neither depends
on a path workaround. xcodebuild mode results and cache hashes are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
build.Run now returns one sentinel (ErrRebuildUnsupported) for apps described by
build info, instead of each caller checking UsesBuildInfo itself. The main-only
pipeline resolves its module through moduleNameForSettings rather than copying
the precedence rules. Tests reuse buildTestCache and writeFixtureFile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfZJfq3WusJRBCM4hiD12F
- rebuildAndRelaunch: return ErrRebuildUnsupported before the "building" status is sent, so serve clients are not left in that phase.
- PrimaryPath: use the BuildInfo file in build info mode so its cache never shares a directory with the xcodebuild cache of an auto-detected project.
- PrepareFromBuildInfo: remove the previous index store link when the build info names none, instead of reading a stale index.
- preview build: fail explicitly with --build-info instead of printing "Build complete".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
BuildInfo paths were used as written, so relative ones depended on the cache
directory or process cwd. Resolve them against ProjectRoot when loading, and run
the index reader in the source root so its relative-path handling matches the
base LoadIndexStore uses for paths recorded in the index store.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Resolve module map paths from the Settings that already parsed them
instead of scanning swiftc arguments a second time, share one helper for
the "relative to the project root" rule, and drop the redundant branch
around the index reader's working directory.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Share the SourceModules clone, the include/framework de-dup and the main-only module lookup,
drop the redundant Abs guards, comments and duplicated tests, and shorten the README section.
No behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@wani-prnd

Copy link
Copy Markdown
Author

Thanks for the review. As you suggested, this PR now covers static BuildInfo JSON only (--build-info / .axerc BUILD_INFO); running a build command (BUILD_INFO_COMMAND) moves to a follow-up PR that will address points 2–6.

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