carve 0.1.1

Clean-Slate Rewrite

Design for replacing this tool with a from-scratch implementation under permissive licensing. The alternative path - extending the current Python codebase incrementally - was documented separately in the originating fork's INCREMENTAL_DESIGN.md and is not carried into this clean-slate repo. This document assumes the rewrite path was chosen and focuses on what to build.

1. Goals

  1. Produce a compile_commands.json for clangd from a Bazel build graph, correctly de-Bazeled so clangd can introspect it without Bazel-specific environment.
  2. Incremental refresh by default: edit one source, only that action's entry regenerates.
  3. Native support for multiple projects writing into a shared CDB without clobbering each other.
  4. Refresh latency on the order of seconds for typical edits on large monorepos. Caveat: Layers A/B re-run bazel aquery on every refresh and therefore pay the graph-analysis cost regardless of edit size; the sidecar only saves re-scanning unchanged actions, not the query. Sub-second incremental refresh on huge repos is a Layer C property (per-action Bazel action-cache invalidation), not a Layer A promise. See section 3.1.
  5. Independent of the existing Hedron/helly25 codebase. No code derivation. Inventory of platform quirks is rederived from Bazel and clang source plus public issue history.
  6. Permissive license (Apache-2.0 or MIT) chosen at project inception.

2. Hard constraints

2.1 Library priority

When picking where a capability lives, prefer in this order:

  1. Standard C++23, if usable out of the box.
  2. Abseil, if usable out of the box.
  3. mbo, as a wrapper or extension when std or Abseil need smoothing for our use. Anything mbo lacks that we need we add upstream there, not locally in carve. This keeps the dependency surface controllable: we own the wrapper layer.

carve itself should rarely define general-purpose utilities. New utility helpers default to a contribution to mbo unless they are unambiguously specific to compile-commands extraction.

3. Architecture overview

Three composable layers, shipped in order. Each is independently useful; later layers add incrementality and remote-cache friendliness.

+-------------------+        +-------------------+        +-------------------+
| Layer A           |  -->   | Layer B           |  -->   | Layer C           |
| Single-shot tool  |        | Bazel rule wraps  |        | Aspect produces   |
| bazel run         |        | tool as a build   |        | per-action shards |
|                   |        | action            |        | aggregator merges |
+-------------------+        +-------------------+        +-------------------+

Critical property: every layer above shares the same data model and persisted formats. Adding a layer does not invalidate caches from lower layers.

3.1 Where the latency actually goes

Be honest about the cost model, because it drives the layering:

Practical consequence: the headline incrementality story is a Layer C deliverable. Layers A/B are "correct, shared-CDB-capable, and skip redundant scanning" - not "sub-second." Marketing and README copy should reflect this.

4. Component breakdown

4.1 carve (C++ binary)

The core tool. Single statically-linked binary. Subcommands:

carve refresh   [--output=PATH] [--project-id=ID] [--targets=PAT]   # Layer A entry point
carve aggregate [--shards=DIR] [--output=PATH] [--project-id=ID]    # Layer C entry point
carve shard     --action-key=KEY --command=FILE --source=PATH       # Layer C per-action invocation
carve prune     [--age=N]                                           # Sidecar GC

Internal modules:

Module Responsibility Notable deps
aquery Spawns bazel aquery --output=proto (binary), parses via linked analysis_v2.proto mbo subprocess helper (or std)
scan_deps In-process header dependency scan via clang::tooling::dependencies::DependencyScanningTool clangDependencyScanning
command Argv normalization and de-Bazeling patches (the quirk inventory) absl::strings, mbo path utils
sidecar Persistent action-keyed cache and bi-directional header index protobuf, mbo atomic-write
cdb Atomic JSON output, merge semantics mbo atomic-write
cli absl::Flags-driven subcommand dispatch absl::Flags

Each module is a self-contained Bazel package following the MBO Works house layout
(see RULES.md): carve/<module>/ with namespace carve::<module>,
a <module>_cc library, and a colocated <module>_test. The binary entry point
is //carve:carve (with a //:refresh alias for the documented bazel run).

Per the library priority (section 2.1), where mbo already covers a need we use it; where it does not, we contribute the missing piece to mbo rather than rolling a local helper inside carve. Examples of expected contributions (subject to whatever mbo already ships):

4.2 Scan-deps integration

Use clang::tooling::dependencies::DependencyScanningService and DependencyScanningTool from clangDependencyScanning. Key properties:

Expected speedup over forking clang -M per action: 5x to 10x on large repos based on public benchmarks of clang-scan-deps.

Linkage reality (resolved)

toolchains_llvm provides Carve's root-development compilation toolchain.
Carve's dependency-safe module extension downloads the matching full LLVM
distribution and supplies a thin wrapper over its headers and static archives. Linking
DependencyScanningTool required choosing one of:

  1. Build llvm-project from source under Bazel. Hermetic and correct, but a large/slow build that strains the "5-minute clone-to-working" goal in section 7.
  2. Hermetic prebuilt LLVM libs (a repo rule exposing cc_library targets for the needed Clang/LLVM static libs). Adds a dependency we must name and pin, but inherits whatever STL the prebuilt was built with - on Linux that is libstdc++, which a libc++ tool cannot link against.
  3. Local-install bridge (bazel-llvm-bridge, @local_llvm//:llvm_headers). Non-hermetic; breaks "works immediately after clone." Acceptable only as a dev fallback.

Decision (implemented). Option 2 via
bazel-contrib/toolchains_llvm
1.9.0 for root development, plus the official LLVM 22.1.8 distributions fetched
directly by Carve's module extension.
//third_party/llvm:clang_dependency_scanning wraps clang_headers and the
static archive closure recorded by the distribution's Clang and LLVM CMake
metadata. This removes both recurring LLVM compilation and runtime LLVM shared
library dependencies.

C++ ABI matching. The official Linux static archives are built for
libstdc++, so the Linux toolchain selects static libstdc++. The official macOS
archives use libc++, matched by the SDK libc++ selected by the toolchain. The
compiler, headers, archives, and standard-library selection therefore come from
one pinned toolchain definition without a Clang/LLVM shared-library boundary.

Decoupling: scan-deps is not required for a working CDB

clangd derives a translation unit's headers itself from the source + flags; compile_commands.json has one entry per source file, not per header. Scan-deps buys exactly two things: (a) the header→action index for incremental invalidation (section 4.4), and (b) optional header-entry emission so a header opens standalone. Neither is needed to emit a functional CDB.

Therefore Layer A can ship without in-process scan-deps - emit the CDB directly from aquery (the approach kiron1/bazel-compile-commands takes) - and add scan-deps purely for incrementality once the linkage spike succeeds. This decouples first user value from the project's biggest unknown and is the recommended sequencing.

Failure mode to handle: generated headers that have not been built. Scan-deps reports them as missing; we mirror the current tool's behavior of caching only when no headers are missing (see upstream refresh.template.py for the rationale - rederive, do not copy). User-facing implication to state honestly: for codegen-heavy repos a build (or at least header generation) must precede a complete scan; "no full build required" is a clangd property, not a guarantee that every generated header resolves on first refresh.

4.3 De-Bazeling patches (the quirk inventory)

The rewrite must rederive these. The current Python script is a useful checklist; the patch logic itself is rederived from primary sources.

Quirk Primary source for rederivation
Apple wrapped_clang, __BAZEL_XCODE_* substitutions Bazel tools/osx/crosstool/wrapped_clang.cc
Emscripten driver indirection emscripten emcc source, EM_COMPILER_WRAPPER hook
NVCC to clang flag translation NVCC compiler driver docs, clang driver source
MSVC /showIncludes locale strings Ninja issue 613, public MSVC documentation
ccache symlink resolution ccache docs
Windows command-line length workaround MSDN command-line limits
//external symlink, execroot trap Bazel output_directories docs
Execroot / absolute-path canonicalization (rewrite per-host bazel-out/cache paths to workspace-relative) Bazel output_directories docs; required for the cross-host determinism property (section 9)
parse_headers action filtering Bazel cc rules source
compiler_param_file feature disable Bazel cc features source
layering_check feature disable Bazel cc features source
-fno-canonical-system-headers strip Clang driver source, clangd#1004
-gcc-toolchain strip clangd#1248

This is roughly the full inventory. Each is a day or two of work, standalone-testable.

4.4 Sidecar storage

Two persistent files, in a single directory next to the CDB (default .carve-cache/):

entries-by-actionkey.binpb   # Binary proto: ActionRecord[]
headers-index.binpb          # Binary proto: HeaderIndex

Schema, defined in carve.proto. We use Protobuf Edition 2024 (current as of project start in 2026, GA since protoc 27.0 with later releases adding 2024-specific features). Edition 2024 gives us:

edition = "2024";
package carve;

message ActionRecord {
  string action_key = 1;
  repeated string sources = 2;
  repeated string headers = 3;
  repeated string command = 4;
  string project_id = 5 [features.field_presence = EXPLICIT];   // distinguish unset vs empty
  enum SourceKind {
    SOURCE_KIND_UNSPECIFIED = 0;
    PREPROCESSOR = 1;           // scan-deps result
    ASPECT_DECLARED = 2;        // future: from CcInfo
    ASPECT_M = 3;               // future: from aspect-scheduled -M
  }
  SourceKind source_kind = 6;
  int64 written_at = 7;         // unix seconds; for GC
}

message HeaderOwners {
  string header_path = 1;
  repeated string action_keys = 2;   // sorted; first is canonical owner
}

message HeaderIndex {
  repeated HeaderOwners owners = 1;
  uint32 schema_version = 2;
}

Binary proto for speed and schema discipline. schema_version lets future tool upgrades detect and rebuild stale sidecars. Editions also lets us upgrade to Edition 2026 (which enforces naming style by default) by changing one line; no schema migration if we name things conventionally from the start.

action_key stability caveat. The sidecar is keyed on aquery's action_key, which is stable run-to-run on a fixed Bazel version + configuration but churns across Bazel upgrades, toolchain changes, or --config changes. When the key space shifts, the diff in section 4.5 sees the entire own-project row set as removed+new and forces a full re-scan. schema_version covers tool-driven invalidation, not action-key churn. This is acceptable (correctness is preserved; only one slow refresh results) but must be documented so users are not surprised that switching --config triggers a full rebuild.

Fallback path: if a particular protoc release is fussy about Edition 2024 features, downgrade to edition = "2023"; and lose only the VIEW default and a few cosmetic features. We do not need to fall back to syntax = "proto3";.

4.5 Merge semantics

Each carve refresh invocation owns exactly the rows whose project_id matches. On refresh:

  1. Load current sidecar.
  2. Run aquery, get action set A_now.
  3. Partition own rows into unchanged (action_key in A_now with same content), changed (action_key in A_now, content differs), removed (action_key not in A_now).
  4. For changed and new actions in A_now: run scan-deps, build new records.
  5. For each affected header, update HeaderOwners. Canonical owner is lex-min of remaining action keys. This guarantees byte-stable header entries when the owner persists.
  6. Other projects' rows are untouched.
  7. Serialize sidecar atomically (write to .tmp, rename).
  8. Emit CDB from full sidecar (all projects), atomically.

This makes the cross-project combined CDB a property of the data model, not a feature toggle.

4.6 Bazel rule (cc_carve)

Layer B. Defined in cc_carve.bzl:

cc_carve(
    name = "compile_commands",
    targets = ["//..."],
    project_id = "main",                 # optional; defaults to workspace hash
    exclude_headers = "external",        # "all" | "external" | None
    exclude_external_sources = False,
)

Implementation runs carve refresh as a build action. Inputs: target labels (passed as args, not deps, because deps would force analysis of the whole world). Output: compile_commands.json plus the sidecar directory as declared outputs.

User runs bazel build //:compile_commands. Output appears under bazel-bin/, with a convenience symlink to the workspace root.

4.7 Aspect (Layer C)

Defined in cc_carve_aspect.bzl:

Aggregator (carve aggregate) reads the shards as inputs to a separate top-level action, merges into CDB and sidecar. Bazel's action cache handles per-shard invalidation; aggregator only rebuilds when the set of shards or their contents changes.

Layer C is opt-in: cc_carve(..., use_aspect = True). Layer A/B remain the default until C is proven on production repos.

Implemented (M5). A few specifics differ from the sketch above:

4.8 Python footprint

Per the constraint, kept as close to zero as possible:

Net runtime Python: zero. Net developer Python: at most one script run rarely.

5. Build and dependency tree

MODULE.bazel:

module(name = "mboworks_carve", version = "0.1.1")

bazel_dep(name = "mbo", version = "...")                      # mboworks/mbo. Pulls abseil and friends transitively
bazel_dep(name = "googletest", version = "1.15.2")
bazel_dep(name = "protobuf", version = "29.0")
bazel_dep(name = "rules_cc", version = "0.0.17")
bazel_dep(name = "rules_proto", version = "7.0.2")
bazel_dep(name = "zstd", version = "1.5.7.bcr.1")

# Prebuilt clang toolchain and matching static Clang/LLVM archives.
bazel_dep(name = "toolchains_llvm", version = "1.9.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
llvm.toolchain(name = "llvm_toolchain", llvm_version = "22.1.8")
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_llvm")
register_toolchains("@llvm_toolchain//:all")

mbo brings Abseil transitively, so we do not list Abseil separately. If a future mbo release stops re-exporting Abseil, add a direct bazel_dep then. Versions above are placeholders pinned at project bootstrap; lock to current-at-start releases.

C++ build:

Test layout:

6. CLI surface

Driven by absl::Flags. Subcommand pattern:

carve refresh [flags] [-- bazel-flags]
  --output=PATH                CDB path. Default: compile_commands.json at workspace root
  --project-id=ID              Override default (workspace path hash)
  --targets=PATTERN            Target pattern, repeatable. Default: //...
  --exclude-headers=MODE       all | external | none
  --exclude-external-sources   Bool
  --jobs=N                     Scan-deps parallelism. Default: hardware concurrency
  --bazel=PATH                 Path to bazel binary. Default: $PATH lookup
  -- ...                       Bazel flags forwarded to aquery

carve aggregate [flags]
  --shards=DIR                 Shard input directory (Layer C)
  --output=PATH
  --project-id=ID

carve shard [flags]
  --action-key=KEY
  --command-file=PATH          Argv as protobuf
  --source=PATH
  --out=PATH                   Shard output (ActionRecord protobuf)

carve prune [flags]
  --age=DAYS                   Drop rows older than this with no recent refresh
  --project-id=ID              Restrict to a project

All flags absl::Flags. Help auto-generated. No hand-rolled arg parsing.

7. Distribution and bootstrap

Two delivery modes:

  1. As a bzlmod dependency. Consumers add bazel_dep(name = "mboworks_carve") and load carve_refresh / carve_aspect_refresh from @mboworks_carve//rules:carve.bzl. First use builds carve itself with the consumer's toolchain but downloads LLVM as a prebuilt distribution. Carve links its static Clang/LLVM component archives; consumers do not compile llvm-project or configure a Carve-specific toolchain.
  2. As prebuilt binaries. Released for common platforms (darwin-arm64, darwin-x86_64, linux-x86_64, linux-arm64, windows-x86_64) via GitHub Releases. cc_carve rule downloads the appropriate binary for the host. Avoids the from-source build entirely for users on supported platforms.

Mode 2 matters for the editor-tooling use case: contributors want compile_commands.json working immediately after clone, not after a 5-minute LLVM toolchain build.

8. Coexistence with the current tool

Until the rewrite reaches parity:

Do not deprecate or break the existing path until validation passes on the corpus.

9. Testing strategy

Tests are written at every ring below, not just the unit level. One-shot manual
verification (running carve by hand, a throwaway diff) is allowed only as
planning input: it informs which committed test to write, and the change is
not done until that test exists. See the testing-discipline section in
AGENTS.md.

Concentric rings:

  1. Unit tests. Per module. Heavy on the command-patching logic since that is where regressions hide.
  2. Golden tests. Action input plus expected entry output. One golden per quirk in the inventory. Stored as protobuf text-format for diffability.
  3. Integration tests. Synthetic Bazel workspaces under testdata/. Drive carve refresh, snapshot the resulting CDB, diff against golden CDB. Cover:
    • Plain C++ library + binary
    • Generated headers (via genrule)
    • External dependency (via bazel_dep)
    • Apple-platform cross-compile (skipped on non-Apple)
    • Windows MSVC (skipped on non-Windows)
    • NVCC (skipped if no CUDA toolkit)
  4. Differential tests. Run rewrite and existing tool against the same workspace; diff output. Acceptable as a one-off validation harness, not in CI.
  5. Property tests. Idempotency (refresh twice in a row produces identical sidecar and CDB), determinism (refresh under stable input produces identical output across runs and across hosts of the same platform). Note: cross-host determinism holds only after execroot/absolute-path canonicalization (section 4.3) - raw aquery command lines embed per-host cache paths (/home/<user>/.cache/bazel/...) and are not byte-identical across machines. The property test must run on canonicalized output, and the canonicalization patch is a prerequisite, not an optional quirk.

9.1 Assertion strategy: match the model, not the serialization

Assert on the structured data, never on a serialized blob:

10. Open questions

11. Phasing

Current status and the up-to-date, dependency-ordered task breakdown live in
docs/IMPLEMENTATION_PLAN.md. The month-by-month
sketch below is the original design-time roadmap, kept for context.

Concrete sequence for the first six months:

Month Milestone
1 Repo bootstrap, MODULE.bazel, toolchain pin, hello-world carve binary, GTest wired up. LLVM-libs linkage spike (section 4.2): prove DependencyScanningTool links into a cc_binary. Resolved via prebuilt toolchains_llvm static component archives. This gates months 3–4.
2 aquery module + vendored proto parsing; basic command module with first three quirks (incl. execroot canonicalization); CDB writer. Layer A emits a working CDB straight from aquery, no scan-deps yet. Stand up a crude differential harness against Hedron on this repo.
3 Sidecar persistence, action-keyed diff, scan-deps integration (single-threaded) - assuming the month-1 spike succeeded; otherwise carry the no-scan-deps Layer A and reschedule.
4 Full quirk inventory ported, scan-deps parallelized, merge mode, Layer A feature-complete
5 cc_carve rule (Layer B), integration test corpus, differential test harness hardened
6 Public 0.1 release; begin Layer C prototype

This is aggressive but plausible if the implementer is focused. Real schedules slip; the layering ensures each month produces a shippable improvement. The deliberate move here is putting the riskiest dependency (LLVM linkage) and the most valuable validation artifact (differential harness) at the front, so a bad surprise lands in month 1–2 rather than month 3–5.

12. Out of scope

To prevent scope creep:

13. License

Apache-2.0. Selected for: permissive use, explicit patent grant, broad enterprise acceptance, compatibility with most consumers including the existing Hedron tool's downstream consumers who might want to switch.

Decision before code is written. Every file gets the SPDX identifier from commit zero.

The landscape carve enters. Useful for cross-referencing approaches, learning from past designs, and understanding where carve fits.

A few entries worth singling out before the full list:

14.1 Bazel-specific compile_commands.json generators

14.2 Notable forks of hedronvision/bazel-compile-commands-extractor

14.3 Compile commands generators for other build systems

14.4 Clang/LLVM tooling that consumes compile_commands.json

14.5 Bazel-clangd integration approaches NOT using compile_commands.json

14.6 Adjacent indexing / code-search systems

14.7 Higher-level editor integrations that depend on a CDB