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: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ jobs:
# - 27 after review_pr_anchor (cute-dbt#303)
# - 28 after agent_skill (cute-dbt#304)
# - 29 after macro_perspective (cute-dbt#265)
# - 30 after explore_macro_view (cute-dbt#345)
# The README is not a feature. The job name deliberately carries no
# number (cute-dbt#68 — the old `==6` display label went stale across
# several bumps): the enforced count lives ONLY in the `expected=`
Expand All @@ -359,7 +360,7 @@ jobs:
- name: features/*.feature count matches expected
run: |
set -euo pipefail
expected=29
expected=30
count=$(find features -maxdepth 1 -name '*.feature' | wc -l | tr -d ' ')
echo "feature-count: expected ${expected}, found ${count}"
if [ "$count" != "$expected" ]; then
Expand Down
53 changes: 53 additions & 0 deletions features/explore_macro_view.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Maps: cute-dbt#345 Slice 1 — the explorer macro view walking skeleton
# (epic cute-dbt#99). The explore verb gains a THIRD sub-page,
# `macro.html`, emitted ONLY when the `--pr-diff` changed a root-project
# macro. Slice 1 proves the seam end-to-end: the conditional emission,
# the nav round-trip, and the negative path that keeps the two-page
# output (and its byte-identity goldens) unchanged.
#
# The focused macro DAG (cute-dbt#345 Slice 3) and the filtered model +
# test directory (Slice 4) render INTO this shell in later slices; here
# the page is an honest empty placeholder.
#
# Founder respec pins inherited from cute-dbt#106: the explorer takes NO
# baseline manifest, ever — the changed-macro signal on `explore` is the
# `--pr-diff`. The `@no-baseline-usage-error` tag is belt-and-braces (the
# baseline-required-grep gate already allows explore invocations).
Feature: explore emits a macro-focus sub-page only when a root macro changed

Background:
Given an explore scenario

@no-baseline-usage-error
Scenario: a PR diff that changes a root-project macro emits macro.html
Given the explore manifest declares the model "stg_claims"
And the explore model "stg_claims" has source path "models/staging/stg_claims.sql"
And the explore manifest carries the root-project macro "add_dq_flags" at "macros/add_dq_flags.sql"
And the PR diff changes the explore file "macros/add_dq_flags.sql"
When I run cute-dbt explore on the macro manifest with the PR diff
Then the exit code is 0
And the explore out directory contains "macro.html"
And dag.html links to the macro-focus page
And tests.html links to the macro-focus page
And macro.html carries the macro-focus heading

@no-baseline-usage-error
Scenario: a PR diff that changes no root-project macro emits no macro.html
Given the explore manifest declares the model "stg_claims"
And the explore model "stg_claims" has source path "models/staging/stg_claims.sql"
And the explore manifest carries the root-project macro "add_dq_flags" at "macros/add_dq_flags.sql"
And the PR diff changes the explore file "models/staging/stg_claims.sql"
When I run cute-dbt explore on the macro manifest with the PR diff
Then the exit code is 0
And no macro.html is written
And dag.html does not link to the macro-focus page
And tests.html does not link to the macro-focus page

@no-baseline-usage-error
Scenario: explore without a PR diff emits no macro.html
Given the explore manifest declares the model "stg_claims"
And the explore manifest carries the root-project macro "add_dq_flags" at "macros/add_dq_flags.sql"
When I run cute-dbt explore on the macro manifest
Then the exit code is 0
And no macro.html is written
And dag.html does not link to the macro-focus page
6 changes: 4 additions & 2 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,9 @@ pre-push:
feature-count:
# Asserts the .feature-count invariant; the enforced count lives ONLY
# in the `expected=` line below (cute-dbt#68 — numbers in labels and
# comments go stale). History: bumped to 29 in cute-dbt#265 when
# comments go stale). History: bumped to 30 in cute-dbt#345 when
# explore_macro_view.feature joined the contract; previously
# bumped to 29 in cute-dbt#265 when
# macro_perspective.feature joined the contract; previously
# bumped to 28 in cute-dbt#304 when
# agent_skill.feature joined the contract; previously
Expand All @@ -113,7 +115,7 @@ pre-push:
# Mirrors the `feature-count` CI job; the README is not a feature.
run: |
set -euo pipefail
expected=29
expected=30
count=$(find features -maxdepth 1 -name '*.feature' | wc -l | tr -d ' ')
if [ "$count" != "$expected" ]; then
echo "feature-count: expected ${expected} .feature files, found ${count}"
Expand Down
94 changes: 76 additions & 18 deletions src/adapters/explore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,11 @@ struct ExploreDagTemplate<'a> {
/// Number of lineage nodes the change context marked (0 is honest:
/// a diff touching no model files still renders the count).
changed_count: usize,
/// `true` iff the run emitted `macro.html` (cute-dbt#345 — a
/// `--pr-diff` that changed a root-project macro). Gates the third
/// nav anchor so the no-macro goldens stay byte-identical (the
/// conditional-anchor contract, mirrored on every explore page).
has_macro_focus: bool,
}

/// askama binding for `templates/explore-tests.html`.
Expand All @@ -998,6 +1003,27 @@ struct ExploreTestsTemplate<'a> {
/// Pre-escaped JSON for the `cute-dbt-data` carrier (the full
/// [`ReportPayload`] — the `build_payload` reuse seam).
payload_json: &'a str,
/// `true` iff the run emitted `macro.html` (cute-dbt#345). Gates the
/// third nav anchor — see [`ExploreDagTemplate::has_macro_focus`].
has_macro_focus: bool,
}

/// askama binding for `templates/explore-macro.html`.
///
/// The third explore sub-page (cute-dbt#345, epic cute-dbt#99). Slice 1
/// is the walking skeleton: an empty, real, zero-egress-clean shell that
/// only the `--pr-diff`-changed-root-macro path emits, carrying the page
/// chassis and the nav round-trip back to `dag.html`/`tests.html`. The
/// focused macro DAG (Slice 3) and the filtered model+test directory
/// (Slice 4) render into this shell in later slices.
#[derive(Template)]
#[template(path = "explore-macro.html", escape = "html")]
struct ExploreMacroTemplate<'a> {
sakura_css: &'a str,
/// SHARED appearance engine (cute-dbt#242) — the page honors the
/// saved `cute-dbt.appearance.v1` appearance (read-only).
appearance_js: &'a str,
favicon_data_uri: &'a str,
}

/// Serialize `value` for safe embedding inside an HTML
Expand Down Expand Up @@ -1072,30 +1098,40 @@ fn explore_models(
.collect()
}

/// Render the two explore pages into `out_dir` (created if absent).
/// Render the explore pages into `out_dir` (created if absent).
///
/// Writes `dag.html` then `tests.html`; a failure on either write (or
/// on directory creation) surfaces the underlying [`io::Error`] —
/// the cli layer names `--out-dir` in the operator message. Template
/// rendering itself is compile-time-checked askama (the same
/// infallible-at-runtime posture as the report renderer).
/// Writes `dag.html` then `tests.html`, and — when `has_macro_focus` —
/// the third sub-page `macro.html` (cute-dbt#345). A failure on any
/// write (or on directory creation) surfaces the underlying
/// [`io::Error`] — the cli layer names `--out-dir` in the operator
/// message. Template rendering itself is compile-time-checked askama
/// (the same infallible-at-runtime posture as the report renderer).
///
/// `changed` is the optional `--pr-diff` change context (cute-dbt#106):
/// `Some(set)` marks the member lineage nodes and renders the header
/// count + legend chip; `None` renders exactly the pre-#106 no-context
/// page. Either way the full `models` set renders — context never
/// narrows scope.
///
/// `has_macro_focus` (cute-dbt#345) is the resolved signal that the run
/// carried a `--pr-diff` changing a root-project macro: `true` emits
/// `macro.html` and renders the third nav anchor on every page; `false`
/// keeps the two-page output (and its byte-identical goldens) unchanged.
/// The renderer takes the pre-resolved flag, not the raw changed-macro
/// id set — scope resolution stays in `cli/mod.rs`, so this layer is a
/// pure renderer (the explore-lane posture).
///
/// # Errors
///
/// Returns the underlying [`io::Error`] when `out_dir` cannot be
/// created or either page cannot be written.
/// created or any page cannot be written.
pub fn render_explore(
out_dir: &Path,
current: &Manifest,
models: &ModelInScopeSet,
changed: Option<&ModelInScopeSet>,
payload: &ReportPayload,
has_macro_focus: bool,
) -> io::Result<()> {
fs::create_dir_all(out_dir)?;

Expand Down Expand Up @@ -1136,6 +1172,7 @@ pub fn render_explore(
contract_version: EXPLORE_CONTRACT_VERSION,
has_change_context: changed.is_some(),
changed_count,
has_macro_focus,
}
.render()
.map_err(|err| io::Error::other(format!("render dag.html: {err}")))?;
Expand All @@ -1154,11 +1191,29 @@ pub fn render_explore(
test_count,
explore_tests_js: EXPLORE_TESTS_JS,
payload_json: &payload_json,
has_macro_focus,
}
.render()
.map_err(|err| io::Error::other(format!("render tests.html: {err}")))?;
fs::write(out_dir.join("tests.html"), tests_html)?;

// cute-dbt#345 Slice 1 — the third explore sub-page. Emitted only
// when the run carried a `--pr-diff` changing a root-project macro;
// the no-macro path writes just dag.html/tests.html (the goldens'
// shape). Slice 1 renders the walking-skeleton empty shell — the
// focused macro DAG (Slice 3) and the filtered directory (Slice 4)
// fill it in later.
if has_macro_focus {
let macro_html = ExploreMacroTemplate {
sakura_css: SAKURA_CSS,
appearance_js: APPEARANCE_JS,
favicon_data_uri: FAVICON_DATA_URI,
}
.render()
.map_err(|err| io::Error::other(format!("render macro.html: {err}")))?;
fs::write(out_dir.join("macro.html"), macro_html)?;
}

Ok(())
}

Expand Down Expand Up @@ -1817,7 +1872,7 @@ mod tests {
let payload = payload_for(&current, &models);
let dir = tmp_dir("both-pages");

render_explore(&dir, &current, &models, None, &payload).expect("explore renders");
render_explore(&dir, &current, &models, None, &payload, false).expect("explore renders");

let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html written");
let tests = fs::read_to_string(dir.join("tests.html")).expect("tests.html written");
Expand Down Expand Up @@ -1870,8 +1925,8 @@ mod tests {
let payload = payload_for(&current, &models);
let dir_a = tmp_dir("det-a");
let dir_b = tmp_dir("det-b");
render_explore(&dir_a, &current, &models, None, &payload).expect("first render");
render_explore(&dir_b, &current, &models, None, &payload).expect("second render");
render_explore(&dir_a, &current, &models, None, &payload, false).expect("first render");
render_explore(&dir_b, &current, &models, None, &payload, false).expect("second render");
for page in ["dag.html", "tests.html"] {
let a = fs::read(dir_a.join(page)).expect("page a");
let b = fs::read(dir_b.join(page)).expect("page b");
Expand All @@ -1887,7 +1942,7 @@ mod tests {
let models = all_models(&current);
let payload = payload_for(&current, &models);
let dir = tmp_dir("nested").join("deeper");
render_explore(&dir, &current, &models, None, &payload).expect("creates out-dir");
render_explore(&dir, &current, &models, None, &payload, false).expect("creates out-dir");
assert!(dir.join("dag.html").exists());
assert!(dir.join("tests.html").exists());
let _ = fs::remove_dir_all(dir.parent().expect("parent"));
Expand All @@ -1899,7 +1954,8 @@ mod tests {
let models = all_models(&current);
let payload = payload_for(&current, &models);
let dir = tmp_dir("empty");
render_explore(&dir, &current, &models, None, &payload).expect("empty manifest renders");
render_explore(&dir, &current, &models, None, &payload, false)
.expect("empty manifest renders");
let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html");
assert!(
dag.contains("\"nodes\":[]"),
Expand Down Expand Up @@ -1975,7 +2031,7 @@ mod tests {
let models = all_models(&current);
let payload = payload_for(&current, &models);
let dir = tmp_dir("cte-toggle");
render_explore(&dir, &current, &models, None, &payload).expect("explore renders");
render_explore(&dir, &current, &models, None, &payload, false).expect("explore renders");
let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html");
// The carrier embeds the per-model CTE DAG map.
assert!(dag.contains("\"cte_dags\":{"), "cte_dags carrier present");
Expand Down Expand Up @@ -2030,7 +2086,7 @@ mod tests {
let models = all_models(&current);
let payload = payload_for(&current, &models);
let dir = tmp_dir("tests-viewer");
render_explore(&dir, &current, &models, None, &payload).expect("explore renders");
render_explore(&dir, &current, &models, None, &payload, false).expect("explore renders");
let tests = fs::read_to_string(dir.join("tests.html")).expect("tests.html");
// The shared askama partial (report.html's test card) renders.
assert!(
Expand Down Expand Up @@ -2601,7 +2657,7 @@ mod tests {
let models = all_models(&current);
let payload = payload_for(&current, &models);
let dir = tmp_dir("contract-version");
render_explore(&dir, &current, &models, None, &payload).expect("explore renders");
render_explore(&dir, &current, &models, None, &payload, false).expect("explore renders");
let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html");
assert!(
dag.contains(&format!(
Expand Down Expand Up @@ -2706,7 +2762,8 @@ mod tests {
let payload = payload_for(&current, &models);
let changed = changed_set(&["model.shop.dim_orders"]);
let dir = tmp_dir("change-context");
render_explore(&dir, &current, &models, Some(&changed), &payload).expect("explore renders");
render_explore(&dir, &current, &models, Some(&changed), &payload, false)
.expect("explore renders");
let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html");
assert!(
dag.contains("1 changed in this diff"),
Expand Down Expand Up @@ -2739,7 +2796,8 @@ mod tests {
let payload = payload_for(&current, &models);
let empty = ModelInScopeSet::new();
let dir = tmp_dir("change-context-zero");
render_explore(&dir, &current, &models, Some(&empty), &payload).expect("explore renders");
render_explore(&dir, &current, &models, Some(&empty), &payload, false)
.expect("explore renders");
let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html");
assert!(dag.contains("0 changed in this diff"), "honest zero");
assert!(
Expand All @@ -2760,7 +2818,7 @@ mod tests {
let models = all_models(&current);
let payload = payload_for(&current, &models);
let dir = tmp_dir("no-change-context");
render_explore(&dir, &current, &models, None, &payload).expect("explore renders");
render_explore(&dir, &current, &models, None, &payload, false).expect("explore renders");
let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html");
assert!(
!dag.contains("<span class=\"legend-chip changed\">"),
Expand Down
Loading
Loading