use std::marker::PhantomData;
use bevy_asset::AssetId;
use bevy_ecs::component::Components;
use bevy_ecs::entity::Entity;
use bevy_ecs::query::{Access, ComponentAccessKind};
use bevy_ecs::resource::Resource;
use bevy_ecs::world::World;
use crate::asset::{BrinkProgram, ProgramAsset};
use crate::batch::aggregate_access;
use crate::capability::CapabilityTable;
#[derive(Debug, Clone)]
pub struct ObservedAccess {
pub flow: Entity,
pub story: AssetId<ProgramAsset>,
pub binding: String,
pub access: Access,
}
#[derive(Resource)]
pub struct GroundTruthLog<M: Send + Sync + 'static = ()> {
entries: Vec<ObservedAccess>,
_marker: PhantomData<fn() -> M>,
}
impl<M: Send + Sync + 'static> Default for GroundTruthLog<M> {
fn default() -> Self {
Self {
entries: Vec::new(),
_marker: PhantomData,
}
}
}
impl<M: Send + Sync + 'static> GroundTruthLog<M> {
#[must_use]
pub fn entries(&self) -> &[ObservedAccess] {
&self.entries
}
pub fn reset(&mut self) {
self.entries.clear();
}
}
pub(crate) fn record<M: Send + Sync + 'static>(
world: &mut World,
flow: Entity,
binding: &str,
access: Access,
) {
let Some(story) = world.get::<BrinkProgram<M>>(flow).map(|p| p.handle.id()) else {
return;
};
world
.get_resource_or_insert_with(GroundTruthLog::<M>::default)
.entries
.push(ObservedAccess {
flow,
story,
binding: binding.to_string(),
access,
});
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccessKind {
Read,
Write,
}
impl std::fmt::Display for AccessKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Read => "read",
Self::Write => "write",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub flow: Entity,
pub story: AssetId<ProgramAsset>,
pub binding: String,
pub component: String,
pub kind: AccessKind,
}
impl std::fmt::Display for Violation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"flow {:?} binding `{}` {}s component `{}`, which story {:?}'s capability manifest never declares",
self.flow, self.binding, self.kind, self.component, self.story
)
}
}
#[must_use]
pub fn check<M: Send + Sync + 'static>(
log: &GroundTruthLog<M>,
cap_table: &CapabilityTable<M>,
components: &Components,
) -> Vec<Violation> {
let mut violations = Vec::new();
for entry in &log.entries {
let declared = cap_table
.access_for(entry.story)
.map(aggregate_access)
.unwrap_or_default();
if entry.access.is_subset(&declared) {
continue;
}
let Ok(iter) = entry.access.try_iter_access() else {
violations.push(Violation {
flow: entry.flow,
story: entry.story,
binding: entry.binding.clone(),
component: "<all components>".to_string(),
kind: AccessKind::Read,
});
continue;
};
for kind in iter {
let (id, access_kind) = match kind {
ComponentAccessKind::Exclusive(id) => (id, AccessKind::Write),
ComponentAccessKind::Shared(id) => (id, AccessKind::Read),
ComponentAccessKind::Archetypal(_) => continue,
};
let covered = match access_kind {
AccessKind::Write => declared.has_write(id),
AccessKind::Read => declared.has_read(id),
};
if covered {
continue;
}
let component = components
.get_name(id)
.map_or_else(|| format!("{id:?}"), |n| n.to_string());
violations.push(Violation {
flow: entry.flow,
story: entry.story,
binding: entry.binding.clone(),
component,
kind: access_kind,
});
}
}
violations
}
#[cfg(test)]
mod tests {
use bevy_ecs::component::Component;
use bevy_ecs::world::World;
use super::*;
#[derive(Component)]
struct Transform;
#[derive(Component)]
struct AudioSink;
fn story_id() -> AssetId<ProgramAsset> {
AssetId::<ProgramAsset>::invalid()
}
#[test]
fn subset_access_produces_no_violations() {
let mut world = World::new();
let transform_id = world.register_component::<Transform>();
let flow = world.spawn_empty().id();
let mut log = GroundTruthLog::<()>::default();
let mut observed = Access::default();
observed.add_read(transform_id);
log.entries.push(ObservedAccess {
flow,
story: story_id(),
binding: "get_position".to_string(),
access: observed,
});
let mut declared = Access::default();
declared.add_read(transform_id);
let mut table = crate::capability::ContainerAccessTable::default();
table.insert(
brink_format::DefinitionId::new(brink_format::DefinitionTag::Address, 0),
crate::capability::ContainerAccess {
access: declared,
..Default::default()
},
);
let mut cap_table = CapabilityTable::<()>::default();
cap_table.insert_for_test(story_id(), Ok(table));
let violations = check(&log, &cap_table, world.components());
assert!(violations.is_empty(), "{violations:?}");
}
#[test]
fn write_beyond_declared_read_is_a_named_violation() {
let mut world = World::new();
let transform_id = world.register_component::<Transform>();
let audio_id = world.register_component::<AudioSink>();
let flow = world.spawn_empty().id();
let mut log = GroundTruthLog::<()>::default();
let mut observed = Access::default();
observed.add_read(transform_id);
observed.add_write(audio_id);
log.entries.push(ObservedAccess {
flow,
story: story_id(),
binding: "play_and_reposition".to_string(),
access: observed,
});
let mut declared = Access::default();
declared.add_read(transform_id);
let mut table = crate::capability::ContainerAccessTable::default();
table.insert(
brink_format::DefinitionId::new(brink_format::DefinitionTag::Address, 0),
crate::capability::ContainerAccess {
access: declared,
..Default::default()
},
);
let mut cap_table = CapabilityTable::<()>::default();
cap_table.insert_for_test(story_id(), Ok(table));
let violations = check(&log, &cap_table, world.components());
assert_eq!(violations.len(), 1, "{violations:?}");
let v = &violations[0];
assert_eq!(v.flow, flow);
assert_eq!(v.binding, "play_and_reposition");
assert_eq!(v.kind, AccessKind::Write);
assert!(
v.component.contains("AudioSink"),
"violation should name the offending component: {v:?}"
);
}
#[test]
fn no_capability_table_at_all_flags_any_real_access() {
let mut world = World::new();
let transform_id = world.register_component::<Transform>();
let flow = world.spawn_empty().id();
let mut log = GroundTruthLog::<()>::default();
let mut observed = Access::default();
observed.add_read(transform_id);
log.entries.push(ObservedAccess {
flow,
story: story_id(),
binding: "get_position".to_string(),
access: observed,
});
let cap_table = CapabilityTable::<()>::default();
let violations = check(&log, &cap_table, world.components());
assert_eq!(
violations.len(),
1,
"no manifest wired at all means nothing is declared — any real access is a violation: {violations:?}"
);
}
}
#[cfg(test)]
mod scenario {
use bevy_app::Update;
use bevy_asset::Assets;
use bevy_ecs::component::Component;
use bevy_ecs::entity::Entity;
use bevy_ecs::system::{In, Query};
use brink_format::Value;
use std::collections::BTreeMap;
use super::*;
use crate::asset::{BrinkStoryAsset, LineTablesAsset};
use crate::capability::{CapabilityEffects, CapabilityManifest, CapabilityManifestExternal};
use crate::{
BrinkBindingsAppExt, BrinkCapabilityAppExt, BrinkFlowRequest, BrinkQueryInput,
advance_batch,
};
#[derive(Component)]
struct Enemy;
fn enemy_count(In((_entity, _args)): In<BrinkQueryInput>, q: Query<&Enemy>) -> Value {
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
reason = "test story, tiny enemy count"
)]
Value::Int(q.iter().count() as i32)
}
const STORY_SOURCE: &str =
"EXTERNAL enemy_count()\n-> start\n=== start ===\nEnemies near: {enemy_count()}.\n-> END\n";
fn drive_scenario(flow_count: usize, manifest: CapabilityManifest) -> bevy_app::App {
let mut app = crate::test_support::make_test_app();
app.bind_brink_query::<(), _, _>("enemy_count", enemy_count);
app.register_capability::<(), Enemy>("Enemy");
app.insert_resource(manifest);
app.add_systems(Update, advance_batch::<()>);
app.world_mut().spawn(Enemy);
let out = brink_compiler::compile("t.ink", move |p| {
if p == "t.ink" {
Ok(STORY_SOURCE.to_string())
} else {
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "x"))
}
})
.expect("scenario story should compile");
let mut inkb = Vec::new();
brink_format::write_inkb(&out.data, &mut inkb);
let loaded = brink_format::read_inkb(&inkb).expect("read_inkb");
let (program, tables) = brink_runtime::link(&loaded).expect("link");
let (_, initial_context) = brink_runtime::FlowInstance::new_at_root(&program);
let world = app.world_mut();
let program_handle = world
.resource_mut::<Assets<ProgramAsset>>()
.add(ProgramAsset {
program,
initial_context,
effect_rows: loaded.effect_rows,
});
let tables_handle = world
.resource_mut::<Assets<LineTablesAsset>>()
.add(LineTablesAsset { tables });
let story_handle = world
.resource_mut::<Assets<BrinkStoryAsset>>()
.add(BrinkStoryAsset {
program: program_handle,
line_tables: tables_handle,
});
let flows: Vec<Entity> = (0..flow_count)
.map(|_| {
app.world_mut()
.spawn(
BrinkFlowRequest::<()>::builder()
.story(story_handle.clone())
.build(),
)
.id()
})
.collect();
for _ in 0..12 {
app.update();
}
for flow in flows {
let unparked = app
.world()
.get::<crate::BrinkFlow<()>>(flow)
.is_some_and(|f| !f.inner.has_pending_external());
assert!(
unparked,
"flow {flow:?} should have resolved its pending external within the tick budget"
);
}
app
}
fn declaring_manifest() -> CapabilityManifest {
CapabilityManifest {
externals: vec![CapabilityManifestExternal {
name: "enemy_count".to_string(),
effects: CapabilityEffects {
reads: vec!["Enemy".to_string()],
writes: vec![],
detect: BTreeMap::new(),
},
}],
}
}
fn under_declaring_manifest() -> CapabilityManifest {
CapabilityManifest {
externals: vec![CapabilityManifestExternal {
name: "enemy_count".to_string(),
effects: CapabilityEffects::default(),
}],
}
}
#[test]
fn correctly_declared_manifest_checks_clean_across_flow_counts() {
for flow_count in [1usize, 3, 7] {
let app = drive_scenario(flow_count, declaring_manifest());
let log = app.world().resource::<GroundTruthLog<()>>();
assert_eq!(
log.entries().len(),
flow_count,
"expected one recorded dispatch per flow at flow_count={flow_count}"
);
let cap_table = app.world().resource::<CapabilityTable<()>>();
let violations = check(log, cap_table, app.world().components());
assert!(
violations.is_empty(),
"flow_count={flow_count}: {violations:?}"
);
}
}
#[test]
fn under_declared_manifest_flags_every_dispatch_by_flow_component_and_binding() {
let flow_count = 3;
let app = drive_scenario(flow_count, under_declaring_manifest());
let log = app.world().resource::<GroundTruthLog<()>>();
let cap_table = app.world().resource::<CapabilityTable<()>>();
let violations = check(log, cap_table, app.world().components());
assert_eq!(violations.len(), flow_count, "{violations:?}");
for v in &violations {
assert_eq!(v.binding, "enemy_count");
assert_eq!(v.kind, AccessKind::Read);
assert!(
v.component.contains("Enemy"),
"violation should name the Enemy component: {v:?}"
);
}
let mut named_flows: Vec<Entity> = violations.iter().map(|v| v.flow).collect();
named_flows.sort_unstable();
named_flows.dedup();
assert_eq!(named_flows.len(), flow_count);
}
}