Skip to content

Commit 42b732b

Browse files
zackeesclaude
andauthored
feat(library-selection): #205 Phase 6 acceptance gates + Phase 8 CLI/docs (#208)
Continues #205 on top of the foundation merged in PR #207. No-op for runtime behaviour; adds gates that *prove* the foundation behaves as specified, plus a diagnostic CLI and architecture docs. ## Phase 6 — acceptance gates (#[ignore]'d, CI-only) - `crates/fbuild-build/tests/teensylc_acceptance.rs` — builds teensyLC Blink end-to-end and asserts AC#1: * `.bss` <= 3 KB * No `fnet_` / `snooze_` / `RadioHead` / `mbedtls` symbol substrings (#204 regression guard) * `setup` / `loop` symbols present * `compile_commands.json` TU count <= 250 * No FNET / Snooze / RadioHead / mbedtls entries in compile DB - `crates/fbuild-build/tests/stm32_acceptance.rs` — builds an stm32f103c8 sketch that `#include`s `<SPI.h>` and asserts AC#4: * `SPIClass` symbol present in ELF * compile_commands.json contains an SPI library entry * No manual allowlist needed (closes #202) Both gates use `fbuild_test_support::{ElfProbe, CompileDb}` from PR #207 and are gated behind `#[ignore]` because they download real toolchains and Teensyduino / STM32duino frameworks. CI runs them via `cargo test -- --ignored`. ## Phase 8 — diagnostic CLI - `fbuild lib-select <project> -e <env>` — drives the resolver in-process and prints the selected library set. - `--explain` shows per-library trigger header + unresolved includes. - `--json` emits machine-readable output (`{ selected, unresolved, included_files, seeds, framework }`). - Mutually-exclusive flags (`conflicts_with`). - New `crates/fbuild-cli/src/lib_select.rs` (~420 LoC) owns the diagnostic flow without depending on `fbuild-build` — uses `fbuild-packages::library` directly to keep the CLI a leaf binary. - 3 new tests in `crates/fbuild-cli/tests/lib_select.rs` (help-output, missing-project exit code, flag-conflict). ## Phase 8 — architecture docs - `docs/architecture/library-selection.md` — new subsystem doc covering scanner / walker / resolver, path-prefix attribution, two-pass convergence, future Phase 4 cache. - `docs/CLAUDE.md` table updated. - `docs/INDEX.md` FAQ entries added. - `docs/architecture/README.md` lists the new doc. - `tasks/lessons.md` appended with the LDF-semantics lesson. ## Verification - `uv run soldr cargo check --workspace --all-targets` — green. - `uv run soldr cargo clippy --workspace --all-targets -- -D warnings` — green (only pre-existing `clippy.toml` MSRV info note). - `uv run soldr cargo fmt --all --check` — clean. - `RUSTDOCFLAGS="-D warnings" uv run soldr cargo doc --workspace --no-deps` — green. - `uv run soldr cargo test -p fbuild-cli` — 13 passed, 0 failed (3 new lib_select + existing). - Phase 6 acceptance tests `#[ignore]`'d; CI runs them with `--ignored`. ## Out of scope (still tracked) - Phase 4 — zccache K/V memoization (gated on zackees/zccache#130). - Phase 7 — perf gates wired into `bench/fastled-examples`. - Baseline numeric capture (`tasks/baseline-205.md` placeholder). Refs: #202, #204, #205, zackees/zccache#130 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4727500 commit 42b732b

13 files changed

Lines changed: 966 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/fbuild-build/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,4 @@ tempfile = { workspace = true }
2626

2727
[dev-dependencies]
2828
filetime = { workspace = true }
29+
fbuild-test-support = { path = "../fbuild-test-support" }
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! Acceptance gate for #205 AC#4 / closes #202: stm32f103c8 SPI auto-discovery.
2+
//!
3+
//! This integration test verifies that an stm32f103c8 Blink sketch which
4+
//! `#include`s `<SPI.h>` builds with no manual library allowlist, and that
5+
//! the bundled `Arduino_Core_STM32` SPI library is automatically discovered
6+
//! by fbuild's library-selection layer.
7+
//!
8+
//! Run with:
9+
//! `uv run soldr cargo test -p fbuild-build --test stm32_acceptance -- --ignored`
10+
//!
11+
//! Marked `#[ignore]` because it downloads the ARM GCC toolchain plus the
12+
//! STM32duino cores (cached after first run) and performs a full firmware
13+
//! build — too heavy for default `cargo test`.
14+
//!
15+
//! Acceptance criteria (#205 AC#4):
16+
//! 1. The build succeeds.
17+
//! 2. `compile_commands.json` references at least one source file under
18+
//! the SPI library (substring `SPI`).
19+
//! 3. The ELF contains a symbol whose mangled name contains `SPIClass`.
20+
21+
use std::path::{Path, PathBuf};
22+
23+
use fbuild_build::{BuildOrchestrator, BuildParams};
24+
use fbuild_core::BuildProfile;
25+
use fbuild_test_support::{CompileDb, ElfProbe};
26+
27+
#[test]
28+
#[ignore = "downloads STM32duino + builds firmware; CI-only"]
29+
fn stm32f103c8_blink_with_spi_auto_discovers_library_205_ac4() {
30+
// Use a temporary project dir so we can write our own SPI-using sketch
31+
// independent of whatever ships in the fixture.
32+
let tmp = tempfile::TempDir::new().unwrap();
33+
let project_dir = tmp.path();
34+
35+
std::fs::write(
36+
project_dir.join("platformio.ini"),
37+
"[env:stm32f103c8]\n\
38+
platform = ststm32\n\
39+
board = bluepill_f103c8\n\
40+
framework = arduino\n",
41+
)
42+
.unwrap();
43+
44+
let src = project_dir.join("src");
45+
std::fs::create_dir_all(&src).unwrap();
46+
std::fs::write(
47+
src.join("main.cpp"),
48+
"#include <Arduino.h>\n\
49+
#include <SPI.h>\n\
50+
void setup() { SPI.begin(); }\n\
51+
void loop() {}\n",
52+
)
53+
.unwrap();
54+
55+
let build_dir = project_dir.join(".fbuild/build");
56+
let params = BuildParams {
57+
project_dir: project_dir.to_path_buf(),
58+
env_name: "stm32f103c8".to_string(),
59+
clean: true,
60+
profile: BuildProfile::Release,
61+
build_dir: build_dir.clone(),
62+
verbose: true,
63+
jobs: None,
64+
generate_compiledb: true,
65+
compiledb_only: false,
66+
log_sender: None,
67+
symbol_analysis: false,
68+
symbol_analysis_path: None,
69+
no_timestamp: false,
70+
src_dir: None,
71+
pio_env: Default::default(),
72+
extra_build_flags: Vec::new(),
73+
watch_set_cache: None,
74+
};
75+
76+
let orchestrator = fbuild_build::stm32::orchestrator::Stm32Orchestrator;
77+
let result = orchestrator
78+
.build(&params)
79+
.expect("stm32f103c8 build with SPI must succeed");
80+
assert!(result.success, "build did not report success");
81+
82+
let elf = result
83+
.elf_path
84+
.as_ref()
85+
.expect("stm32 build must produce ELF");
86+
let probe = ElfProbe::open(elf).expect("ELF parses");
87+
assert!(
88+
probe
89+
.has_symbol_containing("SPIClass")
90+
.expect("symbol query"),
91+
"AC#4: SPIClass symbol must be present in ELF — closes #202"
92+
);
93+
94+
let compdb = locate_compile_commands(&build_dir, "stm32f103c8")
95+
.expect("compile_commands.json should land in build dir");
96+
let db = CompileDb::from_path(&compdb).expect("parse compile_commands.json");
97+
let spi_entries: Vec<_> = db.entries_matching("SPI").collect();
98+
assert!(
99+
!spi_entries.is_empty(),
100+
"AC#4: compile_commands.json must reference an SPI library entry — \
101+
closes #202; found {} entries with no SPI hit",
102+
db.tu_count()
103+
);
104+
}
105+
106+
fn locate_compile_commands(build_dir: &Path, env: &str) -> Option<PathBuf> {
107+
let candidates = [
108+
build_dir.join(env).join("compile_commands.json"),
109+
build_dir.join("compile_commands.json"),
110+
];
111+
for c in candidates {
112+
if c.exists() {
113+
return Some(c);
114+
}
115+
}
116+
for entry in walkdir::WalkDir::new(build_dir).into_iter().flatten() {
117+
if entry.file_name() == "compile_commands.json" {
118+
return Some(entry.into_path());
119+
}
120+
}
121+
None
122+
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
//! Phase 6.a acceptance gate for issue #205 on the teensyLC Blink target.
2+
//!
3+
//! Runs the full TeensyOrchestrator build against the in-repo
4+
//! `tests/platform/teensylc` fixture and asserts:
5+
//!
6+
//! * `.bss` size <= 3 KB (#205 AC#1).
7+
//! * No `fnet_*`, `snooze_*`, `RadioHead`, or `mbedtls` symbols leaked into
8+
//! the linked ELF (#205 AC#1, #204 regression guard).
9+
//! * The Arduino/Teensy `setup` and `loop` symbols are present (#205 A-11).
10+
//! * `compile_commands.json` has <= 250 translation units (was 451 pre-fix
11+
//! per the #205 issue body).
12+
//! * `compile_commands.json` references no `FNET`, `Snooze`, `RadioHead`, or
13+
//! `mbedtls` files (#204 root-cause guard).
14+
//!
15+
//! This test downloads Teensyduino + arm-gcc on the first run and is
16+
//! therefore CI-only — it is gated behind `#[ignore]` and runs via
17+
//! `uv run soldr cargo test -p fbuild-build --test teensylc_acceptance -- --ignored`.
18+
19+
use std::path::PathBuf;
20+
21+
use fbuild_build::{BuildOrchestrator, BuildParams};
22+
use fbuild_core::BuildProfile;
23+
use fbuild_test_support::{CompileDb, ElfProbe};
24+
25+
#[test]
26+
#[ignore = "downloads Teensyduino + builds firmware; CI-only"]
27+
fn teensylc_blink_meets_205_acceptance_criteria() {
28+
let project_dir = repo_fixture("teensylc");
29+
let build_dir = tempfile::TempDir::new().unwrap();
30+
31+
let params = BuildParams {
32+
project_dir: project_dir.clone(),
33+
env_name: "teensyLC".to_string(),
34+
clean: true,
35+
profile: BuildProfile::Release,
36+
build_dir: build_dir.path().to_path_buf(),
37+
verbose: true,
38+
jobs: None,
39+
generate_compiledb: true,
40+
compiledb_only: false,
41+
log_sender: None,
42+
symbol_analysis: false,
43+
symbol_analysis_path: None,
44+
no_timestamp: false,
45+
src_dir: None,
46+
pio_env: Default::default(),
47+
extra_build_flags: Vec::new(),
48+
watch_set_cache: None,
49+
};
50+
51+
let result = fbuild_build::teensy::orchestrator::TeensyOrchestrator
52+
.build(&params)
53+
.expect("teensyLC build must succeed for acceptance gate");
54+
assert!(result.success, "build did not report success");
55+
56+
// ── ELF probes (AC#1) ───────────────────────────────────────────────
57+
let elf = result
58+
.elf_path
59+
.as_ref()
60+
.expect("teensy build must produce ELF");
61+
let probe = ElfProbe::open(elf).expect("ELF parses");
62+
let bss = probe.section_size(".bss").expect("bss query");
63+
assert!(bss <= 3 * 1024, "AC#1: .bss must be <= 3KB; got {bss}");
64+
65+
for forbidden in ["fnet_", "snooze_", "RadioHead", "mbedtls"] {
66+
assert!(
67+
!probe
68+
.has_symbol_containing(forbidden)
69+
.expect("symbol query"),
70+
"AC#1: forbidden symbol substring '{forbidden}' present in ELF — \
71+
#204 regression"
72+
);
73+
}
74+
for required in ["setup", "loop"] {
75+
assert!(
76+
probe.has_symbol(required).expect("symbol query")
77+
|| probe.has_symbol_containing(required).expect("symbol query"),
78+
"A-11: required symbol '{required}' missing from ELF"
79+
);
80+
}
81+
82+
// ── compile_commands.json probes (AC#1, A-20..A-22) ─────────────────
83+
let compdb_path = locate_compile_commands(build_dir.path(), "teensyLC")
84+
.expect("compile_commands.json should land in build dir");
85+
let db = CompileDb::from_path(&compdb_path).expect("parse compile_commands.json");
86+
assert!(
87+
db.tu_count() <= 250,
88+
"AC#1: TU count must be <= 250; got {} entries",
89+
db.tu_count()
90+
);
91+
let forbidden_hits = db.forbidden_present(&["FNET", "Snooze", "RadioHead", "mbedtls"]);
92+
assert!(
93+
forbidden_hits.is_empty(),
94+
"AC#1 / #204: compile_commands.json must not include any of \
95+
FNET/Snooze/RadioHead/mbedtls; found: {:?}",
96+
forbidden_hits
97+
);
98+
}
99+
100+
fn repo_fixture(name: &str) -> PathBuf {
101+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
102+
.parent()
103+
.unwrap()
104+
.parent()
105+
.unwrap()
106+
.join("tests/platform")
107+
.join(name)
108+
}
109+
110+
fn locate_compile_commands(build_dir: &std::path::Path, env: &str) -> Option<PathBuf> {
111+
// Per fbuild's layout the file lives at one of:
112+
// <build_dir>/<env>/compile_commands.json
113+
// <build_dir>/compile_commands.json
114+
// Search both, prefer the per-env path.
115+
let candidates = [
116+
build_dir.join(env).join("compile_commands.json"),
117+
build_dir.join("compile_commands.json"),
118+
];
119+
for c in candidates {
120+
if c.exists() {
121+
return Some(c);
122+
}
123+
}
124+
// Fallback: walk the build_dir for any compile_commands.json.
125+
for entry in walkdir::WalkDir::new(build_dir).into_iter().flatten() {
126+
if entry.file_name() == "compile_commands.json" {
127+
return Some(entry.into_path());
128+
}
129+
}
130+
None
131+
}

crates/fbuild-cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ path = "src/main.rs"
1414
fbuild-core = { path = "../fbuild-core" }
1515
fbuild-config = { path = "../fbuild-config" }
1616
fbuild-deploy = { path = "../fbuild-deploy" }
17+
fbuild-library-select = { path = "../fbuild-library-select" }
1718
fbuild-packages = { path = "../fbuild-packages" }
1819
fbuild-paths = { path = "../fbuild-paths" }
1920
tokio = { workspace = true }
@@ -28,3 +29,4 @@ ctrlc = "3.5.2"
2829
blake3 = { workspace = true }
2930
sha2 = { workspace = true }
3031
tempfile = { workspace = true }
32+
walkdir = { workspace = true }

0 commit comments

Comments
 (0)