Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/fbuild-build/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ tempfile = { workspace = true }

[dev-dependencies]
filetime = { workspace = true }
fbuild-test-support = { path = "../fbuild-test-support" }
122 changes: 122 additions & 0 deletions crates/fbuild-build/tests/stm32_acceptance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Acceptance gate for #205 AC#4 / closes #202: stm32f103c8 SPI auto-discovery.
//!
//! This integration test verifies that an stm32f103c8 Blink sketch which
//! `#include`s `<SPI.h>` builds with no manual library allowlist, and that
//! the bundled `Arduino_Core_STM32` SPI library is automatically discovered
//! by fbuild's library-selection layer.
//!
//! Run with:
//! `uv run soldr cargo test -p fbuild-build --test stm32_acceptance -- --ignored`
//!
//! Marked `#[ignore]` because it downloads the ARM GCC toolchain plus the
//! STM32duino cores (cached after first run) and performs a full firmware
//! build — too heavy for default `cargo test`.
//!
//! Acceptance criteria (#205 AC#4):
//! 1. The build succeeds.
//! 2. `compile_commands.json` references at least one source file under
//! the SPI library (substring `SPI`).
//! 3. The ELF contains a symbol whose mangled name contains `SPIClass`.

use std::path::{Path, PathBuf};

use fbuild_build::{BuildOrchestrator, BuildParams};
use fbuild_core::BuildProfile;
use fbuild_test_support::{CompileDb, ElfProbe};

#[test]
#[ignore = "downloads STM32duino + builds firmware; CI-only"]
fn stm32f103c8_blink_with_spi_auto_discovers_library_205_ac4() {
// Use a temporary project dir so we can write our own SPI-using sketch
// independent of whatever ships in the fixture.
let tmp = tempfile::TempDir::new().unwrap();
let project_dir = tmp.path();

std::fs::write(
project_dir.join("platformio.ini"),
"[env:stm32f103c8]\n\
platform = ststm32\n\
board = bluepill_f103c8\n\
framework = arduino\n",
)
.unwrap();

let src = project_dir.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("main.cpp"),
"#include <Arduino.h>\n\
#include <SPI.h>\n\
void setup() { SPI.begin(); }\n\
void loop() {}\n",
)
.unwrap();

let build_dir = project_dir.join(".fbuild/build");
let params = BuildParams {
project_dir: project_dir.to_path_buf(),
env_name: "stm32f103c8".to_string(),
clean: true,
profile: BuildProfile::Release,
build_dir: build_dir.clone(),
verbose: true,
jobs: None,
generate_compiledb: true,
compiledb_only: false,
log_sender: None,
symbol_analysis: false,
symbol_analysis_path: None,
no_timestamp: false,
src_dir: None,
pio_env: Default::default(),
extra_build_flags: Vec::new(),
watch_set_cache: None,
};

let orchestrator = fbuild_build::stm32::orchestrator::Stm32Orchestrator;
let result = orchestrator
.build(&params)
.expect("stm32f103c8 build with SPI must succeed");
assert!(result.success, "build did not report success");

let elf = result
.elf_path
.as_ref()
.expect("stm32 build must produce ELF");
let probe = ElfProbe::open(elf).expect("ELF parses");
assert!(
probe
.has_symbol_containing("SPIClass")
.expect("symbol query"),
"AC#4: SPIClass symbol must be present in ELF — closes #202"
);

let compdb = locate_compile_commands(&build_dir, "stm32f103c8")
.expect("compile_commands.json should land in build dir");
let db = CompileDb::from_path(&compdb).expect("parse compile_commands.json");
let spi_entries: Vec<_> = db.entries_matching("SPI").collect();
assert!(
!spi_entries.is_empty(),
"AC#4: compile_commands.json must reference an SPI library entry — \
closes #202; found {} entries with no SPI hit",
db.tu_count()
);
}

fn locate_compile_commands(build_dir: &Path, env: &str) -> Option<PathBuf> {
let candidates = [
build_dir.join(env).join("compile_commands.json"),
build_dir.join("compile_commands.json"),
];
for c in candidates {
if c.exists() {
return Some(c);
}
}
for entry in walkdir::WalkDir::new(build_dir).into_iter().flatten() {
if entry.file_name() == "compile_commands.json" {
return Some(entry.into_path());
}
}
None
}
131 changes: 131 additions & 0 deletions crates/fbuild-build/tests/teensylc_acceptance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Phase 6.a acceptance gate for issue #205 on the teensyLC Blink target.
//!
//! Runs the full TeensyOrchestrator build against the in-repo
//! `tests/platform/teensylc` fixture and asserts:
//!
//! * `.bss` size <= 3 KB (#205 AC#1).
//! * No `fnet_*`, `snooze_*`, `RadioHead`, or `mbedtls` symbols leaked into
//! the linked ELF (#205 AC#1, #204 regression guard).
//! * The Arduino/Teensy `setup` and `loop` symbols are present (#205 A-11).
//! * `compile_commands.json` has <= 250 translation units (was 451 pre-fix
//! per the #205 issue body).
//! * `compile_commands.json` references no `FNET`, `Snooze`, `RadioHead`, or
//! `mbedtls` files (#204 root-cause guard).
//!
//! This test downloads Teensyduino + arm-gcc on the first run and is
//! therefore CI-only — it is gated behind `#[ignore]` and runs via
//! `uv run soldr cargo test -p fbuild-build --test teensylc_acceptance -- --ignored`.

use std::path::PathBuf;

use fbuild_build::{BuildOrchestrator, BuildParams};
use fbuild_core::BuildProfile;
use fbuild_test_support::{CompileDb, ElfProbe};

#[test]
#[ignore = "downloads Teensyduino + builds firmware; CI-only"]
fn teensylc_blink_meets_205_acceptance_criteria() {
let project_dir = repo_fixture("teensylc");
let build_dir = tempfile::TempDir::new().unwrap();

let params = BuildParams {
project_dir: project_dir.clone(),
env_name: "teensyLC".to_string(),
clean: true,
profile: BuildProfile::Release,
build_dir: build_dir.path().to_path_buf(),
verbose: true,
jobs: None,
generate_compiledb: true,
compiledb_only: false,
log_sender: None,
symbol_analysis: false,
symbol_analysis_path: None,
no_timestamp: false,
src_dir: None,
pio_env: Default::default(),
extra_build_flags: Vec::new(),
watch_set_cache: None,
};

let result = fbuild_build::teensy::orchestrator::TeensyOrchestrator
.build(&params)
.expect("teensyLC build must succeed for acceptance gate");
assert!(result.success, "build did not report success");

// ── ELF probes (AC#1) ───────────────────────────────────────────────
let elf = result
.elf_path
.as_ref()
.expect("teensy build must produce ELF");
let probe = ElfProbe::open(elf).expect("ELF parses");
let bss = probe.section_size(".bss").expect("bss query");
assert!(bss <= 3 * 1024, "AC#1: .bss must be <= 3KB; got {bss}");

for forbidden in ["fnet_", "snooze_", "RadioHead", "mbedtls"] {
assert!(
!probe
.has_symbol_containing(forbidden)
.expect("symbol query"),
"AC#1: forbidden symbol substring '{forbidden}' present in ELF — \
#204 regression"
);
}
for required in ["setup", "loop"] {
assert!(
probe.has_symbol(required).expect("symbol query")
|| probe.has_symbol_containing(required).expect("symbol query"),
"A-11: required symbol '{required}' missing from ELF"
);
}
Comment on lines +74 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

has_symbol_containing("loop") / "setup" is overly permissive.

loop and setup are 4–5-char tokens that appear inside many mangled C++ symbol names (e.g. _ZN6Stream8setupXxx..., anything with loop in its identifier). The fallback substring match almost guarantees this assertion passes even on a build where Arduino's actual setup/loop are missing. Since these are extern "C" from main.cpp / .ino, has_symbol("setup") alone should be sufficient — the substring fallback can be dropped.

♻️ Suggested fix
     for required in ["setup", "loop"] {
         assert!(
-            probe.has_symbol(required).expect("symbol query")
-                || probe.has_symbol_containing(required).expect("symbol query"),
+            probe.has_symbol(required).expect("symbol query"),
             "A-11: required symbol '{required}' missing from ELF"
         );
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for required in ["setup", "loop"] {
assert!(
probe.has_symbol(required).expect("symbol query")
|| probe.has_symbol_containing(required).expect("symbol query"),
"A-11: required symbol '{required}' missing from ELF"
);
}
for required in ["setup", "loop"] {
assert!(
probe.has_symbol(required).expect("symbol query"),
"A-11: required symbol '{required}' missing from ELF"
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/fbuild-build/tests/teensylc_acceptance.rs` around lines 74 - 80, The
tests currently fall back to probe.has_symbol_containing(required), which is too
permissive for the short extern "C" names "setup" and "loop"; remove the
substring fallback and assert only probe.has_symbol(required).expect("symbol
query") for each required symbol (i.e. replace the assert condition that uses
has_symbol_containing with a direct has_symbol check for "setup" and "loop" in
the loop that iterates over required). Ensure the assertion message still
references the required variable correctly.


// ── compile_commands.json probes (AC#1, A-20..A-22) ─────────────────
let compdb_path = locate_compile_commands(build_dir.path(), "teensyLC")
.expect("compile_commands.json should land in build dir");
let db = CompileDb::from_path(&compdb_path).expect("parse compile_commands.json");
assert!(
db.tu_count() <= 250,
"AC#1: TU count must be <= 250; got {} entries",
db.tu_count()
);
let forbidden_hits = db.forbidden_present(&["FNET", "Snooze", "RadioHead", "mbedtls"]);
assert!(
forbidden_hits.is_empty(),
"AC#1 / #204: compile_commands.json must not include any of \
FNET/Snooze/RadioHead/mbedtls; found: {:?}",
forbidden_hits
);
}

fn repo_fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("tests/platform")
.join(name)
}

fn locate_compile_commands(build_dir: &std::path::Path, env: &str) -> Option<PathBuf> {
// Per fbuild's layout the file lives at one of:
// <build_dir>/<env>/compile_commands.json
// <build_dir>/compile_commands.json
// Search both, prefer the per-env path.
let candidates = [
build_dir.join(env).join("compile_commands.json"),
build_dir.join("compile_commands.json"),
];
for c in candidates {
if c.exists() {
return Some(c);
}
}
// Fallback: walk the build_dir for any compile_commands.json.
for entry in walkdir::WalkDir::new(build_dir).into_iter().flatten() {
if entry.file_name() == "compile_commands.json" {
return Some(entry.into_path());
}
}
None
}
2 changes: 2 additions & 0 deletions crates/fbuild-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"
fbuild-core = { path = "../fbuild-core" }
fbuild-config = { path = "../fbuild-config" }
fbuild-deploy = { path = "../fbuild-deploy" }
fbuild-library-select = { path = "../fbuild-library-select" }
fbuild-packages = { path = "../fbuild-packages" }
fbuild-paths = { path = "../fbuild-paths" }
tokio = { workspace = true }
Expand All @@ -28,3 +29,4 @@ ctrlc = "3.5.2"
blake3 = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
walkdir = { workspace = true }
Loading
Loading