Repository navigation
Conversation
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
|
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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
| moduleName := bs.ModuleName | ||
| if cache, err := analysis.LoadIndexStore(ctx, dirs.IndexStorePath(), ""); err == nil { | ||
| moduleName = moduleNameForSource(sourceFile, bs.ModuleName, cache) | ||
| moduleName = moduleNameForSettings(sourceFile, bs, cache) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| // 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 != "" { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
| if source == "" { | ||
| source = sourceFile | ||
| } | ||
| if _, err := build.RunBuildInfoCommand(pc, source, pc.BuildInfoDevice); err != nil { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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])) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| } | ||
| // 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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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>
|
Thanks for the review. As you suggested, this PR now covers static BuildInfo JSON only ( |
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.axercBUILD_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 runningxcodebuild;RunreturnsErrRebuildUnsupportedin this mode.hot_reload.go,simruntime_backend.go,service.go: keep the compile error instead of rebuilding; full rebuilds andpreview buildfail explicitly.pipeline.go,build/settings.go: take a source's module from BuildInfo first (works without an index store).build/config.go: explicitProjectRoot(); 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 ./...andmise run checkpass; new unit tests cover the review points.xcodebuildmode renders the same asf016a90(only run-to-run rendering noise), and a BuildInfo file with relative paths renders the same preview. Not re-checked yet:preview watchand a Bazel-built app.🤖 Generated with Claude Code