From 3f549f45f328b96c47cdb5dc1b714cb7182a530c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 21:22:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(ogar-render-askama):=20T2=20=E2=80=94=20Ts?= =?UTF-8?q?Interface=20askama=20template=20+=20emitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second real emitter in the artifact-kind kit (T2 per Northstar plan §3). Mirrors the shape of T1 (RustStruct, PR #78): same canonical input (`&ogar_vocab::Class`), same dispatch path through `for_kind`, different template + binding-struct + type mapping. Surface (additive): - `templates/dispatch/ts_interface.askama` — emits an `export interface X { ... }` + `export const CLASS_ID = 0xDDCC as const;` + canonical concept const. - `artifact_kinds::ts_interface::TsInterfaceEmitter` — typed binding via `#[derive(askama::Template)]`. Maps Rails-side type names to TS types (string/number/boolean/unknown), family edges to `number | null` (belongs_to/has_one) or `ReadonlyArray` (has_many/habtm). - `escape_ts_property` — quotes JS reserved + TS contextual keywords + non-bare-identifier names (Odoo `account.move.line` etc.). TS-side companion of Rust's `r#type` escape from codex P1 on #78. - Dispatcher wires the real emitter in place of the stub. Tests (+5): - ts_interface_emits_class_id_and_canonical_concept — proof of shape on project_work_item (CLASS_ID = 0x0102 as const, etc.). - ts_interface_maps_rails_types_to_ts — integer→number, string→string, text→string, against project_role. - ts_interface_emits_family_edges_as_arrays_and_nullable_ids — belongs_to/has_one as `number | null`, has_many as `ReadonlyArray`, asserted on billable_work_entry + project_actor. - ts_interface_handles_keyword_property_names_safely — project_actor's `type` attribute emits `"type": string;` (quoted JS-contextual), never unquoted `type: string;`. - ts_interface_quotes_dotted_property_names — fabricated `account.move.line` attribute must be quoted in the emitter output. Stub coverage updated: 3 kinds remain stubbed (SurrealqlTable, OpenapiSchema, NodeGuidRoutingArm) for T3-T5. 12/12 unit tests + 1 ignored doctest; workspace check + workspace test green. Per Northstar §1.6: template is mass-mail simple (bag of variables named by the template, supplied by `TsInterfaceCtx`). No template hardcodes a class name. --- .../src/artifact_kinds/mod.rs | 15 +- .../src/artifact_kinds/ts_interface.rs | 181 ++++++++++++++++++ crates/ogar-render-askama/src/lib.rs | 117 ++++++++++- .../templates/dispatch/ts_interface.askama | 31 +++ 4 files changed, 334 insertions(+), 10 deletions(-) create mode 100644 crates/ogar-render-askama/src/artifact_kinds/ts_interface.rs create mode 100644 crates/ogar-render-askama/templates/dispatch/ts_interface.askama diff --git a/crates/ogar-render-askama/src/artifact_kinds/mod.rs b/crates/ogar-render-askama/src/artifact_kinds/mod.rs index f08e117..e2ded05 100644 --- a/crates/ogar-render-askama/src/artifact_kinds/mod.rs +++ b/crates/ogar-render-askama/src/artifact_kinds/mod.rs @@ -9,17 +9,19 @@ //! let source = emitter.emit(&spec)?; //! ``` //! -//! Proof-of-shape phase: [`RustStruct`](rust_struct::RustStructEmitter) has -//! a real askama template + emitter; the other four kinds use [`Stub`] — -//! placeholder code that compiles and emits a marker comment so callers can -//! exercise the full pipeline (lookup + dispatch + return) without waiting -//! for every template to land. Concrete emitters arrive per-kind in -//! follow-on PRs (T2–T5 in the integration plan). +//! Real emitters (so far): [`RustStruct`](rust_struct::RustStructEmitter) +//! (T1, from PR #78) and [`TsInterface`](ts_interface::TsInterfaceEmitter) +//! (T2, this PR). Remaining kinds use [`Stub`] — placeholder code that +//! compiles and emits a marker comment so callers can exercise the full +//! pipeline (lookup + dispatch + return) without waiting for every +//! template to land. Concrete emitters arrive per-kind in follow-on PRs +//! T3–T5 from the Northstar plan §3. use crate::spec::{ArtifactKind, ArtifactSpec}; pub mod rust_struct; pub mod stub; +pub mod ts_interface; /// Contract every kind's emitter implements. pub trait ArtifactEmitter { @@ -34,6 +36,7 @@ pub trait ArtifactEmitter { pub fn for_kind(kind: ArtifactKind) -> Box { match kind { ArtifactKind::RustStruct => Box::new(rust_struct::RustStructEmitter), + ArtifactKind::TsInterface => Box::new(ts_interface::TsInterfaceEmitter), other => Box::new(stub::Stub { kind: other }), } } diff --git a/crates/ogar-render-askama/src/artifact_kinds/ts_interface.rs b/crates/ogar-render-askama/src/artifact_kinds/ts_interface.rs new file mode 100644 index 0000000..026e18a --- /dev/null +++ b/crates/ogar-render-askama/src/artifact_kinds/ts_interface.rs @@ -0,0 +1,181 @@ +//! `TsInterface` emitter — T2 from the Northstar plan §3. +//! +//! Lifts an [`ogar_vocab::Class`] into a TypeScript `interface` declaration +//! with a `CLASS_ID` const (the OGAR codebook id, `as const` so consumers +//! get the literal type). Mirrors the shape of +//! [`crate::artifact_kinds::rust_struct`] — different template, different +//! binding-struct field names, same canonical input. +//! +//! Per Northstar §1.6 (mass-mail templates): the template names variables, +//! the binding struct supplies them. The Rust ↔ TS difference lives in the +//! type mapping (`rails_to_ts_type`) and the identifier escape rules +//! (TypeScript allows almost anything as a property name, but a small set +//! of JS keywords is safest quoted) — the rest is the same shape. + +use askama::Template; + +use super::ArtifactEmitter; +use crate::spec::ArtifactSpec; +use ogar_vocab::{canonical_concept_id, AssociationKind}; + +#[derive(Template)] +#[template(path = "dispatch/ts_interface.askama", escape = "none")] +struct TsInterfaceCtx { + name: String, + concept_fn: String, + canonical_concept: String, + class_id_hex: String, + attributes: Vec, + associations: Vec, +} + +struct TsAttr { + name: String, + /// The property name as it appears in the emitted interface. Either + /// a bare identifier or a quoted string for safety on JS reserved + /// words. See [`escape_ts_property`]. + ts_name: String, + ts_type: String, + type_name: String, +} + +struct TsEdge { + name: String, + ts_name: String, + ts_type: String, + kind_label: String, + target: String, +} + +/// The concrete emitter for +/// [`ArtifactKind::TsInterface`](crate::ArtifactKind::TsInterface). +pub struct TsInterfaceEmitter; + +impl ArtifactEmitter for TsInterfaceEmitter { + fn emit(&self, spec: &ArtifactSpec<'_>) -> Result { + let class = spec.class; + let concept = class.canonical_concept.as_deref().unwrap_or(""); + let class_id_hex = canonical_concept_id(concept) + .map(|id| format!("0x{id:04X}")) + .unwrap_or_default(); + + let attributes = class + .attributes + .iter() + .map(|a| TsAttr { + name: a.name.clone(), + ts_name: escape_ts_property(&a.name), + ts_type: rails_to_ts_type(a.type_name.as_deref()), + type_name: a.type_name.clone().unwrap_or_default(), + }) + .collect(); + + let associations = class + .associations + .iter() + .map(|a| TsEdge { + name: a.name.clone(), + ts_name: escape_ts_property(&a.name), + ts_type: edge_ts_type(a), + kind_label: assoc_label(a.kind), + target: a.class_name.clone().unwrap_or_default(), + }) + .collect(); + + TsInterfaceCtx { + name: class.name.clone(), + concept_fn: concept.to_string(), + canonical_concept: concept.to_string(), + class_id_hex, + attributes, + associations, + } + .render() + } +} + +/// Quote JS reserved words / non-identifier names so the emitted TS +/// compiles. TypeScript accepts any string-literal property name in an +/// interface body (`"type": string;`), so escaping by quoting is the +/// cleanest safe move. Identifiers that are unambiguously bare (snake_case +/// without reserved-word collisions, no embedded dots / spaces) pass +/// through unchanged. +/// +/// Conservative list: the union of strict JS reserved words plus a few +/// contextual ones that consumers sometimes trip over (`type` in TS +/// declaration contexts; safe here because we're in an interface body, +/// but quoting costs nothing). +fn escape_ts_property(name: &str) -> String { + // Property names containing characters that can't appear in a bare + // identifier must be quoted (e.g. Odoo-style `"account.move.line"`). + let ident_safe = !name.is_empty() + && name + .chars() + .enumerate() + .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())); + const QUOTED: &[&str] = &[ + // Strict JS reserved words (subset that field names sometimes hit). + "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "enum", "export", "extends", + "false", "finally", "for", "function", "if", "import", "in", + "instanceof", "new", "null", "return", "super", "switch", "this", + "throw", "true", "try", "typeof", "var", "void", "while", "with", + "yield", + // ES strict-mode reserved. + "let", "static", "implements", "interface", "package", "private", + "protected", "public", + // TypeScript contextual keywords — bare in property position is + // technically accepted, but consumers' linters / downstream + // codegen often choke. Conservative quote: zero downside, removes + // a class of "compiles here, breaks at consumer" footguns. This + // is the TS-side companion of Rust's `r#type` escape (codex P1 + // on #78). + "type", "async", "await", "as", "from", "of", "is", "infer", + "keyof", "namespace", "satisfies", "readonly", + ]; + if !ident_safe || QUOTED.contains(&name) { + format!("\"{name}\"") + } else { + name.to_string() + } +} + +/// Map a producer-side Rails type name onto a TypeScript type. Coarse — +/// downstream consumers can specialise (e.g. `Decimal` strings vs +/// `number`); the canonical contract round-trips on the structural shape. +fn rails_to_ts_type(t: Option<&str>) -> String { + match t { + Some("string") | Some("text") => "string".into(), + Some("integer") | Some("big_integer") | Some("bigint") => "number".into(), + Some("float") | Some("double") => "number".into(), + Some("decimal") | Some("monetary") => "number".into(), + Some("boolean") | Some("bool") => "boolean".into(), + // ISO-8601 string at the canonical layer; consumers may parse to + // Date downstream. + Some("date") | Some("datetime") | Some("timestamp") => "string".into(), + Some("json") | Some("jsonb") => "unknown".into(), + Some(_) | None => "string".into(), + } +} + +fn edge_ts_type(a: &ogar_vocab::Association) -> String { + // Same coarse shape as the Rust emitter: belongs_to / has_one → fk id + // (nullable); has_many / habtm → array of fk ids. Concrete consumers + // can swap for typed object references downstream. + match a.kind { + AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => { + "ReadonlyArray".into() + } + _ => "number | null".into(), + } +} + +fn assoc_label(k: AssociationKind) -> String { + match k { + AssociationKind::BelongsTo => "belongs_to".into(), + AssociationKind::HasOne => "has_one".into(), + AssociationKind::HasMany => "has_many".into(), + AssociationKind::HasAndBelongsToMany => "has_and_belongs_to_many".into(), + _ => format!("{k:?}").to_ascii_lowercase(), + } +} diff --git a/crates/ogar-render-askama/src/lib.rs b/crates/ogar-render-askama/src/lib.rs index ac8616b..d908a6d 100644 --- a/crates/ogar-render-askama/src/lib.rs +++ b/crates/ogar-render-askama/src/lib.rs @@ -162,12 +162,11 @@ mod tests { #[test] fn stub_emits_marker_for_unimplemented_kinds() { - // The four stub kinds compile + emit a marker comment naming the - // kind + class. This is what lets the kit be wired end-to-end - // before every template has landed. + // The remaining stub kinds compile + emit a marker comment naming + // the kind + class. T1 (RustStruct) + T2 (TsInterface) now have + // real emitters; T3 / T4 / T5 are still stubbed (per Northstar §3). let class = project(); for kind in [ - ArtifactKind::TsInterface, ArtifactKind::SurrealqlTable, ArtifactKind::OpenapiSchema, ArtifactKind::NodeGuidRoutingArm, @@ -185,6 +184,116 @@ mod tests { } } + #[test] + fn ts_interface_emits_class_id_and_canonical_concept() { + // T2 proof of shape: render project_work_item — verify the emitted + // .ts source declares the right interface name + CLASS_ID const + // (`as const` for literal-type) + canonical concept const. + let class = project_work_item(); + let src = render(&class, ArtifactKind::TsInterface).unwrap(); + assert!( + src.contains("export interface ProjectWorkItem"), + "ts_interface should declare `export interface ProjectWorkItem`:\n{src}" + ); + assert!( + src.contains("export const CLASS_ID = 0x0102 as const;"), + "expected CLASS_ID = 0x0102 (as const) in:\n{src}" + ); + assert!( + src.contains("export const CANONICAL_CONCEPT = \"project_work_item\" as const;"), + "{src}" + ); + } + + #[test] + fn ts_interface_maps_rails_types_to_ts() { + // Coarse Rails→TS type mapping: integer → number, string → string, + // boolean → boolean. Pinned against project_role (carries `name`, + // `position` int, `permissions` text). + let class = project_role(); + let src = render(&class, ArtifactKind::TsInterface).unwrap(); + // `name: string` (Rails "string") + assert!(src.contains("name: string;"), "expected `name: string;` in:\n{src}"); + // `position: number` (Rails "integer") + assert!( + src.contains("position: number;"), + "expected `position: number;` in:\n{src}" + ); + // `permissions: string` (Rails "text") + assert!( + src.contains("permissions: string;"), + "expected `permissions: string;` in:\n{src}" + ); + } + + #[test] + fn ts_interface_emits_family_edges_as_arrays_and_nullable_ids() { + // belongs_to / has_one → `number | null` (nullable FK id) + // has_many / habtm → `ReadonlyArray` + let class = billable_work_entry(); + let src = render(&class, ArtifactKind::TsInterface).unwrap(); + // Every family edge name should appear as a TS property. + for edge in &class.associations { + assert!( + src.contains(&format!("{}: ", edge.name)), + "ts_interface missing family edge `{}`:\n{src}", + edge.name + ); + } + // At least one of each shape: + assert!( + src.contains("number | null"), + "expected at least one belongs_to/has_one as `number | null`:\n{src}" + ); + // (billable_work_entry's edges are all belongs_to today, so + // `ReadonlyArray` may not appear — assert via project_actor + // which has the `groups` / `users` has_many shape.) + let actor_src = render(&project_actor(), ArtifactKind::TsInterface).unwrap(); + assert!( + actor_src.contains("ReadonlyArray"), + "expected at least one has_many edge as `ReadonlyArray`:\n{actor_src}" + ); + } + + #[test] + fn ts_interface_handles_keyword_property_names_safely() { + // project_actor declares an attribute named `type` (the same hazard + // that bit Rust on PR #78). TypeScript accepts `type` as a property + // name in interface bodies, but quoting is the conservative move + // for any JS reserved word. Per `escape_ts_property`, `type` is in + // the conservative quote list -> emitted as `"type": string;`. + let class = project_actor(); + let src = render(&class, ArtifactKind::TsInterface).unwrap(); + assert!( + src.contains("\"type\": string;"), + "expected `\"type\": string;` (quoted JS-reserved name) in:\n{src}" + ); + assert!( + !src.contains("\n type: "), + "unquoted `type:` is a hazard; emitter must quote it:\n{src}" + ); + } + + #[test] + fn ts_interface_quotes_dotted_property_names() { + // Odoo-style identifiers (e.g. `account.move.line`) aren't valid + // bare TS property names — must be quoted. This is the same logic + // catching `escape_ts_property`'s non-identifier branch. + // Use a fabricated class with a dotted attribute name. + use ogar_vocab::{Attribute, Class as VocabClass, Language}; + let mut c = VocabClass::new("Synth"); + c.canonical_concept = Some("synth".to_string()); + c.language = Language::Unknown; + let mut a = Attribute::new("account.move.line"); + a.type_name = Some("string".to_string()); + c.attributes = vec![a]; + let src = render(&c, ArtifactKind::TsInterface).unwrap(); + assert!( + src.contains("\"account.move.line\": string;"), + "dotted property name must be quoted:\n{src}" + ); + } + #[test] fn rust_struct_escapes_keyword_attribute_names() { // Codex P1 on #78: `project_actor()` declares an attribute named diff --git a/crates/ogar-render-askama/templates/dispatch/ts_interface.askama b/crates/ogar-render-askama/templates/dispatch/ts_interface.askama new file mode 100644 index 0000000..f73d6eb --- /dev/null +++ b/crates/ogar-render-askama/templates/dispatch/ts_interface.askama @@ -0,0 +1,31 @@ +{# Render one canonical concept as a TypeScript interface + class-id constant. #} +{# Mass-mail simple: bag-of-variables substitution. Per the Northstar §1.6, #} +{# the template just names what it wants; the binding struct supplies it. #} +// +// {{ name }} — canonical class generated from `ogar_vocab::{{ concept_fn }}()`. +// DO NOT EDIT BY HAND. Re-render via `ogar-render-askama`. +// + +/** Canonical OGAR codebook id for [`{{ name }}`]. */ +{% if class_id_hex.len() > 0 -%} +export const CLASS_ID = {{ class_id_hex }} as const; +{% else -%} +// (no codebook id — concept not yet promoted) +{% endif %} + +/** Canonical concept name as in the OGAR codebook. */ +export const CANONICAL_CONCEPT = "{{ canonical_concept }}" as const; + +export interface {{ name }} { +{%- for attr in attributes %} + /** `{{ attr.name }}`{% if attr.type_name.len() > 0 %} (curator type: `{{ attr.type_name }}`){% endif %} */ + {{ attr.ts_name }}: {{ attr.ts_type }}; +{%- endfor %} +{%- if associations.len() > 0 %} + // Family edges (canonical relations): +{%- for edge in associations %} + /** {{ edge.kind_label }} `{{ edge.name }}`{% if edge.target.len() > 0 %} → `{{ edge.target }}`{% endif %} */ + {{ edge.ts_name }}: {{ edge.ts_type }}; +{%- endfor %} +{%- endif %} +}