Skip to content
Draft
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
20 changes: 10 additions & 10 deletions crates/spock-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,9 @@ enum GenTarget {
/// SPIKE (uhura#29): provider tables from an app declaration + the contract.
Provider {
file: PathBuf,
/// The app assembly declaration (.wire spike syntax).
/// The app's provider declaration (`.provider` spike syntax).
#[arg(long)]
app: PathBuf,
declaration: PathBuf,
/// The Uhura client project whose `uhura.toml` carries the package identity.
#[arg(long)]
uhura: PathBuf,
Expand Down Expand Up @@ -221,14 +221,14 @@ fn execute(command: Command) -> ExitCode {
program.contract(),
GenerationTarget::GraphqlSchema,
),
GenTarget::Provider { app, uhura, .. } => {
spock_cli::provider_gen::generate_from_contract(
program.contract(),
&app,
&uhura,
)
.map_err(anyhow::Error::msg)
}
GenTarget::Provider {
declaration, uhura, ..
} => spock_cli::provider_gen::generate_from_contract(
program.contract(),
&declaration,
&uhura,
)
.map_err(anyhow::Error::msg),
};
match artifact {
Ok(content) => emit(out, content),
Expand Down
52 changes: 28 additions & 24 deletions crates/spock-cli/src/provider_gen/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! Provider generation spike (uhura#29): .wire — a projection/contract language
//! over the Spock schema. Parses a .wire declaration, validates it against the
//! Provider generation spike (uhura#29): .provider — a projection/contract language
//! over the Spock schema. Parses a .provider declaration, validates it against the
//! compiler-emitted contract, and generates the provider artifacts: contract
//! types, view types, snapshot query, dispatch, refusals, assets, and the module.

Expand All @@ -8,13 +8,13 @@ use pest_derive::Parser;
use serde_json::Value;

#[derive(Parser)]
#[grammar = "provider_gen/wire.pest"]
struct WireParser;
#[grammar = "provider_gen/provider.pest"]
struct ProviderParser;

#[derive(Debug, PartialEq)]
pub struct Field {
pub name: String,
/// Original `.wire` type token (e.g. `post.id`) — used for schema validation.
/// Original `.provider` type token (e.g. `post.id`) — used for schema validation.
pub source: String,
pub ty: String,
}
Expand Down Expand Up @@ -104,7 +104,7 @@ pub struct SnapshotRead {
}

#[derive(Debug, Default)]
pub struct WireFile {
pub struct ProviderFile {
pub app: Option<String>,
/// Explicit video mappings from the fixtures block (no manifest source to derive from)
pub videos: Vec<(String, String)>,
Expand All @@ -125,7 +125,7 @@ impl std::fmt::Display for ParseError {
}
impl std::error::Error for ParseError {}

/// `.wire` field type → machine-side type name.
/// `.provider` field type → machine-side type name.
/// `<table>.id` projects to `<Table>Id`; scalars use the 0.4 prelude names.
fn machine_type(ty: &str) -> Result<String, ParseError> {
match ty {
Expand Down Expand Up @@ -213,10 +213,11 @@ fn asset_type(class: &str) -> Option<&'static str> {
}
}

pub fn parse(source: &str) -> Result<WireFile, ParseError> {
let mut pairs = WireParser::parse(Rule::file, source).map_err(|e| ParseError(e.to_string()))?;
pub fn parse(source: &str) -> Result<ProviderFile, ParseError> {
let mut pairs =
ProviderParser::parse(Rule::file, source).map_err(|e| ParseError(e.to_string()))?;
let file = pairs.next().expect("file rule");
let mut out = WireFile::default();
let mut out = ProviderFile::default();

for item in file.into_inner() {
match item.as_rule() {
Expand Down Expand Up @@ -449,7 +450,7 @@ pub fn policy_calls(policy: &str) -> Vec<CallSpec> {

/// Mutation → backend routing table (JSON). The runtime decides branches, RPC
/// arguments, and refusal routes from this table alone — it owns no logic.
pub fn generate_routing(file: &WireFile) -> String {
pub fn generate_routing(file: &ProviderFile) -> String {
let mut out = String::from("{\n");
for (i, m) in file.mutations.iter().enumerate() {
let kind = m.op.clone().unwrap_or_else(|| snake(&m.name));
Expand Down Expand Up @@ -506,9 +507,9 @@ pub fn generate_routing(file: &WireFile) -> String {
out
}

/// Schema validation: every table/fn/error a .wire file references must exist
/// Schema validation: every table/fn/error a `.provider` file references must exist
/// in the contract. Violations come back as a readable list (no silent passes).
pub fn validate_against(file: &WireFile, schema: &SpockSchema) -> Vec<String> {
pub fn validate_against(file: &ProviderFile, schema: &SpockSchema) -> Vec<String> {
let mut problems = Vec::new();
for read in &file.snapshot_reads {
if !schema.has_table(&read.table) {
Expand Down Expand Up @@ -598,7 +599,7 @@ fn screaming(name: &str) -> String {

/// Mutation surface → the adapter's `toBackendOperation` switch body (TS text).
/// kind = `op` override or snake_case(name); field mapping is derived from types.
pub fn generate_dispatch(file: &WireFile) -> Result<String, Vec<String>> {
pub fn generate_dispatch(file: &ProviderFile) -> Result<String, Vec<String>> {
let mut problems = Vec::new();
let Some(app) = &file.app else {
return Err(vec!["missing `app \"Name\";` declaration".to_string()]);
Expand Down Expand Up @@ -649,7 +650,7 @@ pub fn generate_dispatch(file: &WireFile) -> Result<String, Vec<String>> {
}

/// Route key → kebab-case refusal list. Duplicate routes are an error.
pub fn generate_refusals(file: &WireFile) -> Result<Vec<(String, Vec<String>)>, Vec<String>> {
pub fn generate_refusals(file: &ProviderFile) -> Result<Vec<(String, Vec<String>)>, Vec<String>> {
let mut problems = Vec::new();
let mut out: Vec<(String, Vec<String>)> = Vec::new();
for m in &file.mutations {
Expand All @@ -676,7 +677,7 @@ pub fn generate_refusals(file: &WireFile) -> Result<Vec<(String, Vec<String>)>,
/// under. Key tags are app data, so they are generated here rather than supplied by
/// the runtime, which must stay app-independent.
pub fn generate_provider_module(
file: &WireFile,
file: &ProviderFile,
schema: &SpockSchema,
module: &str,
) -> Result<String, Vec<String>> {
Expand Down Expand Up @@ -763,7 +764,7 @@ pub fn parse_manifest(source: &str) -> Result<Vec<(String, String)>, Vec<String>
}
}

/// Manifest entries + explicit .wire video mappings → Play logical-asset table (TS text).
/// Manifest entries + explicit .provider video mappings → Play logical-asset table (TS text).
/// Name collisions are an error.
pub fn generate_play_assets(
entries: &[(String, String)],
Expand Down Expand Up @@ -812,10 +813,10 @@ pub fn default_alias(table: &str) -> String {
pluralize(&camel(table))
}

/// .wire snapshot declaration + schema columns → the adapter's GraphQL snapshot document.
/// .provider snapshot declaration + schema columns → the adapter's GraphQL snapshot document.
/// FK and storage_object columns project as `name { id }`; the rest stay bare.
pub fn generate_snapshot_query(
file: &WireFile,
file: &ProviderFile,
schema: &SpockSchema,
) -> Result<String, Vec<String>> {
let mut problems = Vec::new();
Expand Down Expand Up @@ -868,7 +869,10 @@ fn pascal(name: &str) -> String {

/// View declaration → machine-side record types. If type derivation disagrees with
/// the schema, return a problem list instead of generating (no speculative output).
pub fn generate_view_types(file: &WireFile, schema: &SpockSchema) -> Result<String, Vec<String>> {
pub fn generate_view_types(
file: &ProviderFile,
schema: &SpockSchema,
) -> Result<String, Vec<String>> {
let mut problems = Vec::new();
let mut out = String::new();
for (i, view) in file.views.iter().enumerate() {
Expand Down Expand Up @@ -1072,7 +1076,7 @@ fn emit_variant(out: &mut String, name: &str, fields: &[Field]) {

/// Generate the machine-side contract declarations. Settlement's Accepted/Refused are
/// fixed by the 0.4 result vocabulary, so they are enforced; only extras follow the file.
pub fn generate_machine_types(file: &WireFile) -> String {
pub fn generate_machine_types(file: &ProviderFile) -> String {
let mut out = String::from("pub enum Mutation {\n");
for m in &file.mutations {
emit_variant(&mut out, &m.name, &m.fields);
Expand Down Expand Up @@ -1298,14 +1302,14 @@ pub fn read_module_identity(uhura_project: &std::path::Path) -> Result<String, S
/// client's own package identity; problems merge into one failure.
pub fn generate_from_contract<C: ::serde::Serialize>(
contract: &C,
app_declaration: &std::path::Path,
declaration: &std::path::Path,
uhura_project: &std::path::Path,
) -> Result<String, String> {
let json = serde_json::to_string(contract)
.map_err(|e| format!("contract serialization failed: {e}"))?;
let schema = extract_contract(&json).map_err(|p| p.join("\n"))?;
let source = std::fs::read_to_string(app_declaration)
.map_err(|e| format!("cannot read {}: {e}", app_declaration.display()))?;
let source = std::fs::read_to_string(declaration)
.map_err(|e| format!("cannot read {}: {e}", declaration.display()))?;
let file = parse(&source).map_err(|e| e.to_string())?;
let problems = validate_against(&file, &schema);
if !problems.is_empty() {
Expand Down
28 changes: 14 additions & 14 deletions crates/spock-cli/tests/provider_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
use spock_cli::provider_gen as pg;

const CONTRACT: &str = include_str!("provider_fixtures/contract.json");
const WIRE: &str = include_str!("provider_fixtures/instagram.wire");
const DECLARATION: &str = include_str!("provider_fixtures/instagram.provider");
const PROVIDER_TS: &str = include_str!("provider_fixtures/spock-provider.ts");
const MANIFEST: &str = include_str!("provider_fixtures/manifest.toml");

Expand All @@ -22,46 +22,46 @@ fn contract_exposes_the_storage_object_system_table() {

#[test]
fn declaration_validates_clean_against_the_contract() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let problems = pg::validate_against(&file, &schema());
assert!(problems.is_empty(), "{problems:?}");
}

#[test]
fn machine_contract_types_match_the_handwritten_machine_uhura() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let generated = pg::generate_machine_types(&file);
let golden = include_str!("provider_fixtures/machine-types.uhura");
assert_eq!(generated.trim(), golden.trim());
}

#[test]
fn view_types_match_the_handwritten_machine_uhura() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let generated = pg::generate_view_types(&file, &schema()).expect("views validate");
let golden = include_str!("provider_fixtures/view-types.uhura");
assert_eq!(generated.trim(), golden.trim());
}

#[test]
fn snapshot_query_matches_the_handwritten_adapter() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let generated = pg::generate_snapshot_query(&file, &schema()).expect("generates");
let golden = include_str!("provider_fixtures/snapshot-query.graphql");
assert_eq!(generated, golden);
}

#[test]
fn dispatch_switch_matches_the_handwritten_adapter() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let generated = pg::generate_dispatch(&file).expect("generates");
let golden = include_str!("provider_fixtures/dispatch-switch.ts");
assert_eq!(generated, golden);
}

#[test]
fn refusal_whitelist_semantically_matches_the_handwritten_table() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let mut generated = pg::generate_refusals(&file).expect("generates");
for (_, list) in generated.iter_mut() {
list.sort();
Expand Down Expand Up @@ -96,7 +96,7 @@ fn refusal_whitelist_semantically_matches_the_handwritten_table() {
#[test]
fn play_assets_match_the_handwritten_adapter() {
let entries = pg::parse_manifest(MANIFEST).expect("manifest parses");
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let generated = pg::generate_play_assets(&entries, &file.videos).expect("no collisions");
let golden = include_str!("provider_fixtures/play-assets.ts");
assert_eq!(generated, golden.trim_end());
Expand Down Expand Up @@ -133,7 +133,7 @@ const MODULE: &str = "app.instagram@1";
/// module constants, and the runtime contract must stay the four generic helpers.
#[test]
fn key_tags_are_generated_data_and_helpers_stay_app_independent() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");

assert!(
Expand All @@ -160,7 +160,7 @@ fn key_tags_are_generated_data_and_helpers_stay_app_independent() {
/// One generator keeps them from drifting apart.
#[test]
fn generated_key_tags_agree_with_the_generated_machine_types() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let machine = pg::generate_machine_types(&file);
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");
for ty in ["PostId", "UserId", "StoryId"] {
Expand Down Expand Up @@ -202,7 +202,7 @@ fn run_node(script: &str, args: &[&std::path::Path]) -> String {
/// `pnpm -C uhura/web build:provider`, so nothing here is optional.
#[test]
fn generated_module_drives_the_shared_runtime_under_node() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");
let path = write_module("runtime-tables.mjs", module);
let stdout = run_node("runtime-unit.mjs", &[&path]);
Expand All @@ -213,7 +213,7 @@ fn generated_module_drives_the_shared_runtime_under_node() {
/// the Uhura type the generated tables declare.
#[test]
fn the_runtime_is_app_independent_and_checks_key_types() {
let file = pg::parse(WIRE).expect("parse");
let file = pg::parse(DECLARATION).expect("parse");
let module = pg::generate_provider_module(&file, &schema(), MODULE).expect("generates");
let path = write_module("genericity-tables.mjs", module);
let source = std::path::PathBuf::from(concat!(
Expand Down Expand Up @@ -242,7 +242,7 @@ fn a_second_app_drives_the_same_runtime() {
{ "name": "retract_article", "errors": ["not_authorized"] }
]
}"#;
const BLOG_WIRE: &str = r#"
const BLOG_DECLARATION: &str = r#"
app "Blog";
snapshot app { cap 50 per table; read article; }
mutation SetPublished { article: article.id, published: bool }
Expand All @@ -251,7 +251,7 @@ fn a_second_app_drives_the_same_runtime() {
"#;

let schema = pg::extract_contract(BLOG_CONTRACT).expect("contract parses");
let file = pg::parse(BLOG_WIRE).expect("parse");
let file = pg::parse(BLOG_DECLARATION).expect("parse");
assert!(pg::validate_against(&file, &schema).is_empty());
let module = pg::generate_provider_module(&file, &schema, "app.blog@2").expect("generates");
assert!(module.contains("const ARTICLE_ID_TYPE = `${MODULE}::ArticleId`;"));
Expand Down
Loading