warble-cli 0.15.4

Warble CLI — compile profiles, dispatch to the Claude Code or Vercel target, render dashboards, emit manifests, run eval.
Documentation
//! End-to-end `model_has_timestamp` enforcement (Phase 3 litmus, risk #3): an assertive component
//! that declares `model_has_timestamp` must fail to compile against a semantic layer whose models
//! carry NO temporal column — proving the precondition really gates (compile-fail), not a placeholder.
//! The mirror case (bound to jaffle-wren, whose `orders` has a DATE column) is the monitor-agent
//! golden, which compiles. Together they are the "one model with a timestamp, one without" pair the
//! litmus calls for.

use std::fs;
use std::path::Path;

use warble_cli::compile_project_to_ir;

/// The bound layer as a prepared-context document: one model whose columns are all non-temporal.
/// It PARSES (so `mdl_parseable` holds) while `model_has_timestamp` is false — the inversion this
/// test turns on. Generated by warble's own writer rather than hand-authored: a numeric column
/// becomes an implicit *metric* and only the TEXT column becomes a dimension, which a plausible
/// hand-written document gets wrong.
const TIMESTAMPLESS_LAYER: &str = r#"{
  "context_version": 2,
  "parseable": true,
  "metrics": [
    {
      "name": "id",
      "owner": "widgets",
      "declared": false,
      "additivity": null
    },
    {
      "name": "amount",
      "owner": "widgets",
      "declared": false,
      "additivity": null
    }
  ],
  "dimensions": [
    {
      "name": "category",
      "owner": "widgets",
      "is_temporal": false
    }
  ],
  "models": [
    {
      "name": "widgets",
      "has_timestamp": false,
      "columns": [
        "id",
        "category",
        "amount"
      ]
    }
  ],
  "lineage": {
    "nodes": [
      {
        "id": "model:widgets",
        "kind": "model"
      }
    ],
    "edges": []
  },
  "lineage_diagnostics": [],
  "analysis": {
    "impact": {
      "model:widgets": {
        "downstream": [],
        "severity": {
          "rank": 0,
          "name": "none"
        }
      }
    },
    "consumers": {
      "queries": 0,
      "dashboards": 0
    }
  }
}"#;

/// Write a one-component Warble project bound to the layer above, whose component
/// declares the `model_has_timestamp` precondition (the monitor_freshness gate).
fn write_monitor_fixture(dir: &Path) {
    fs::create_dir_all(dir.join("context")).unwrap();
    fs::create_dir_all(dir.join("components/monitor/steps")).unwrap();
    fs::write(
        dir.join("profile.yml"),
        "profile: fixture\ncontext:\n  project: ./context/binding.yml\ncomponents:\n  - use: monitor\n    bind:\n      model: widgets\n",
    )
    .unwrap();
    fs::write(dir.join("context/context.json"), TIMESTAMPLESS_LAYER).unwrap();
    fs::write(
        dir.join("context/binding.yml"),
        "kind: prepared\nproject: widgets-layer\ndocument: context/context.json\n",
    )
    .unwrap();
    fs::write(
        dir.join("components/monitor/component.yml"),
        r#"
id: monitor
verb: monitor_freshness
type: assertive
realization_kind: tool
binding_mode: pinned
context_precondition:
  - { predicate: model_has_timestamp, args: { model: "$param:model" } }
params:
  - { name: model, bind: required }
llm_steps:
  # Deliberately still single-step, unlike the real hub/components/monitor_freshness (which now
  # splits into read_freshness + assess_severity so assess_severity's consumes/when.target resolve
  # against a real producer): this fixture's compile call is meant to fail at the earlier
  # `model_has_timestamp` context_precondition gate, strictly before llm_steps consumes/produces are
  # ever resolved, so assess_severity's dangling `freshness_reading` reference here is inert and does
  # not need the same fix.
  - { name: assess_severity, tier: cheap, conditional: true, prompt_ref: steps/assess_severity.md,
      when: { guard: on_flag, target: freshness_reading.stale } }
trigger: { kind: scheduled }
guardrails:
  - { name: read_only_execution, locked: true }
required_capabilities: [scheduler, sql_execution:read_only, notify_channel, llm:cheap]
borrowed_actions: [notify_slack]
effect:
  render_blocks: [status]
  outcome:
    kind: assertion
    verdict_type: freshness_verdict
    emits: [freshness_breach]
"#,
    )
    .unwrap();
    fs::write(
        dir.join("components/monitor/steps/assess_severity.md"),
        "Classify the breach severity.\n",
    )
    .unwrap();
}

#[test]
fn monitoring_a_model_without_a_timestamp_fails_to_compile() {
    let project = tempfile::tempdir().unwrap();
    write_monitor_fixture(project.path());

    let err = compile_project_to_ir(project.path())
        .expect_err("monitoring freshness on a model with no timestamp column must be refused");
    assert!(
        err.contains("model_has_timestamp") && err.contains("not satisfied"),
        "expected a model_has_timestamp rejection, got: {err}"
    );
}