Skip to content
Merged
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ This applies even at the end of sessions. Prepare the commit but wait for approv

When asked to 'stage and commit everything' or 'commit all changes', stage ALL modified/untracked files (`git add -A`), not just the files Claude edited in the current session.

**Commit-and-continue during approved plan execution:** when executing a plan the user has already approved, commit at each clean phase boundary (pre-commit checklist in `claude-notes/instructions/review.md` passed, full workspace tests green) without stopping to ask, and report the commit in the running summary. Waiting for approval is only required for commits outside approved plan execution, for dirty states, and always for pushing.

### Snapshot Test Changes

When a commit includes updated or new snapshot files (`.snap` files under `snapshots/`), **always explicitly document these changes** in the commit message and in conversation with the user. Snapshot changes can hide unwanted regressions. Specifically:
Expand Down
7 changes: 6 additions & 1 deletion claude-notes/instructions/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

**Read this file and complete the checklist before making any commit. Do not skip items.**

**When checklist is finished, stage your changes, report results to user and wait for approval before making the final commit.**
**When checklist is finished, stage your changes and report results to the user.** Whether to then commit without waiting depends on the mode of work:

- **Plan-driven execution the user has already approved** (a plan document with phases, user said "go ahead"): **commit-and-continue at clean phase boundaries.** A phase boundary is clean when the checklist passes and the full workspace test suite is green. Report the commit in the running summary; do not stop to ask. (Policy set 2026-08-10, project-profiles session.)
- **Anything else** (ad-hoc changes, work outside an approved plan, a phase that ended dirty — failing tests, skipped items, surprising snapshot diffs): report and **wait for approval before committing**.

Pushing always requires explicit approval regardless of mode (see CLAUDE.md).

## Determinism

Expand Down
436 changes: 436 additions & 0 deletions claude-notes/plans/2026-08-10-project-profiles-port.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion crates/pampa/src/lua/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ mod os_wasm;
mod pandoc_doc;
mod path;
mod quarto_api;
mod quarto_doc;
pub mod quarto_doc;
mod readwrite;
pub mod runtime;
pub mod shortcode;
Expand Down
5 changes: 4 additions & 1 deletion crates/pampa/src/lua/quarto_doc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ const UNSUPPORTED_FIELDS: &[&str] = &[

/// Check if FORMAT matches a query using TS Quarto's alias-based matching.
/// `format` is the current FORMAT global value, `query` is what the extension asked for.
fn is_format_match(format: &str, query: &str) -> bool {
/// Public: quarto-core's conditional-content transform reuses this for
/// `when-format` / `unless-format` (bd-fu16z22k) so Lua's
/// `quarto.doc.is_format` and the attribute syntax can never disagree.
pub fn is_format_match(format: &str, query: &str) -> bool {
// Exact match
if format == query {
return true;
Expand Down
40 changes: 29 additions & 11 deletions crates/quarto-core/src/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,17 +71,18 @@ use crate::transforms::{
AppendixStructureTransform, AttributionRenderTransform, AttributionViewerTransform,
AuthorsNormalizeTransform, CalloutResolveTransform, CalloutTransform,
CategoriesSidebarTransform, CodeBlockGenerateTransform, CodeBlockRenderTransform,
CrossrefIndexTransform, CrossrefRenderTransform, CrossrefResolveTransform,
DateNormalizeTransform, EquationLabelTransform, ExampleEmbedRenderTransform,
ExampleEmbedTransform, FloatRefTargetSugarTransform, FooterGenerateTransform,
FooterRenderTransform, FootnotesTransform, LinkRewriteTransform, ListingGenerateTransform,
ListingRenderTransform, MermaidRenderTransform, MetadataNormalizeTransform,
NavbarGenerateTransform, NavbarRenderTransform, PageNavGenerateTransform,
PageNavRenderTransform, ProofSugarTransform, ResourceCollectorTransform, SectionizeTransform,
ShortcodeResolveTransform, SidebarGenerateTransform, SidebarRenderTransform,
TableBootstrapClassTransform, TheoremSugarTransform, TitleBannerTransform, TitleBlockTransform,
TocGenerateTransform, TocRenderTransform, WebsiteBootstrapIconsTransform,
WebsiteCanonicalUrlTransform, WebsiteFaviconTransform, WebsiteTitlePrefixTransform,
ConditionalContentTransform, CrossrefIndexTransform, CrossrefRenderTransform,
CrossrefResolveTransform, DateNormalizeTransform, EquationLabelTransform,
ExampleEmbedRenderTransform, ExampleEmbedTransform, FloatRefTargetSugarTransform,
FooterGenerateTransform, FooterRenderTransform, FootnotesTransform, LinkRewriteTransform,
ListingGenerateTransform, ListingRenderTransform, MermaidRenderTransform,
MetadataNormalizeTransform, NavbarGenerateTransform, NavbarRenderTransform,
PageNavGenerateTransform, PageNavRenderTransform, ProofSugarTransform,
ResourceCollectorTransform, SectionizeTransform, ShortcodeResolveTransform,
SidebarGenerateTransform, SidebarRenderTransform, TableBootstrapClassTransform,
TheoremSugarTransform, TitleBannerTransform, TitleBlockTransform, TocGenerateTransform,
TocRenderTransform, WebsiteBootstrapIconsTransform, WebsiteCanonicalUrlTransform,
WebsiteFaviconTransform, WebsiteTitlePrefixTransform,
};

/// Well-known path for the default CSS artifact in WASM context.
Expand Down Expand Up @@ -1178,6 +1179,7 @@ pub fn build_transform_pipeline(
target_format: String,
variables: Option<quarto_pandoc_types::ConfigValue>,
project_env: hashlink::LinkedHashMap<String, String>,
quarto_profile: Option<String>,
) -> TransformPipeline {
let mut pipeline: TransformPipeline = TransformPipeline::new();

Expand All @@ -1194,6 +1196,11 @@ pub fn build_transform_pipeline(
let lua_format = crate::format::lua_format_for(&target_format).to_string();

// === NORMALIZATION PHASE ===
// Conditional content runs FIRST: hidden content must disappear
// before callouts assemble, shortcodes resolve (no spurious
// warnings from deliberately-excluded content), and long before
// crossref numbering (bd-fu16z22k Phase 4).
pipeline.push(Box::new(ConditionalContentTransform::new()));
pipeline.push(Box::new(CalloutTransform::new()));
pipeline.push(Box::new(CalloutResolveTransform::new()));
// Markdown-parse blessed website presentation config strings
Expand All @@ -1209,6 +1216,7 @@ pub fn build_transform_pipeline(
lua_format,
variables,
project_env,
quarto_profile,
)));
pipeline.push(Box::new(MetadataNormalizeTransform::new()));
// Date normalization (bd-gx9cic8z P4): resolves today/now/
Expand Down Expand Up @@ -1548,6 +1556,7 @@ pub fn build_q2_preview_transform_pipeline(
target_format: String,
variables: Option<quarto_pandoc_types::ConfigValue>,
project_env: hashlink::LinkedHashMap<String, String>,
quarto_profile: Option<String>,
) -> TransformPipeline {
let mut pipeline = build_transform_pipeline(
shortcode_paths,
Expand All @@ -1556,6 +1565,7 @@ pub fn build_q2_preview_transform_pipeline(
target_format,
variables,
project_env,
quarto_profile,
);
pipeline.retain_excluding(Q2_PREVIEW_TRANSFORM_EXCLUDED);
pipeline
Expand Down Expand Up @@ -2720,6 +2730,7 @@ mod tests {
"html".to_string(),
None,
Default::default(),
None,
);
let html_names: Vec<&str> = html.iter().map(|t| t.name()).collect();

Expand Down Expand Up @@ -3153,6 +3164,7 @@ mod tests {
"q2-preview".to_string(),
None,
Default::default(),
None,
);
let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect();
assert!(
Expand All @@ -3177,6 +3189,7 @@ mod tests {
"q2-preview".to_string(),
None,
Default::default(),
None,
);
let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect();
for required in [
Expand Down Expand Up @@ -3230,6 +3243,7 @@ mod tests {
"html".to_string(),
None,
Default::default(),
None,
);
let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect();

Expand Down Expand Up @@ -3299,6 +3313,7 @@ mod tests {
format.to_string(),
None,
Default::default(),
None,
);
let steps: Vec<(&str, TransformPhase)> =
pipeline.iter().map(|t| (t.name(), t.phase())).collect();
Expand Down Expand Up @@ -3347,6 +3362,7 @@ mod tests {
"q2-preview".to_string(),
None,
Default::default(),
None,
);
let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect();
for required in ["code-block-generate", "code-block-render"] {
Expand All @@ -3373,6 +3389,7 @@ mod tests {
format.to_string(),
None,
Default::default(),
None,
);
let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect();

Expand Down Expand Up @@ -3406,6 +3423,7 @@ mod tests {
format.to_string(),
None,
Default::default(),
None,
);
let names: Vec<&str> = pipeline.iter().map(|t| t.name()).collect();
assert!(
Expand Down
101 changes: 100 additions & 1 deletion crates/quarto-core/src/project/cache_key.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ use crate::document_profile::DOCUMENT_PROFILE_VERSION;
/// Manual key-version constant. Bump when a head-pipeline behavior
/// change alters what a profile records without changing
/// `DOCUMENT_PROFILE_VERSION`.
pub const PROFILE_KEY_VERSION: u32 = 1;
///
/// v2: the key domain gained project-profile inputs (active names +
/// overlay bytes, bd-fu16z22k).
pub const PROFILE_KEY_VERSION: u32 = 2;

/// Returns the Quarto build identifier baked into every cache key.
///
Expand Down Expand Up @@ -132,6 +135,22 @@ pub struct Pass1KeyInputs<'a> {
/// is `(name, raw-metadata-bytes)`. Empty when no extensions
/// apply.
pub extension_contributions: &'a [(String, Vec<u8>)],

/// Active **project-profile** names in activation order
/// (bd-fu16z22k). ⚠️ Both meanings of "profile" collide right
/// here: this field holds *project profiles* (`--profile` /
/// `QUARTO_PROFILE`), which are an input to the *DocumentProfile*
/// cache key this struct feeds — switching project profiles must
/// not serve stale pass-1 DocumentProfiles. Empty when none are
/// active.
pub active_config_profiles: &'a [String],

/// `(project-relative-path, raw-bytes)` of every profile overlay
/// (`_quarto-<name>.yml`) and `_quarto.yml.local` actually merged
/// into the project config, in merge order. Byte-level like
/// [`metadata_files`](Self::metadata_files): a comment-only edit
/// to an overlay correctly invalidates the key.
pub profile_config_files: &'a [(PathBuf, Vec<u8>)],
}

/// Compute the SHA-256 cache key for a `DocumentProfile`.
Expand Down Expand Up @@ -163,6 +182,23 @@ pub fn pass1_key(inputs: &Pass1KeyInputs<'_>) -> [u8; 32] {
// _quarto.yml bytes (empty slice when absent).
write_lp_bytes(&mut hasher, inputs.quarto_yml_bytes);

// Project-profile activation + overlay bytes (bd-fu16z22k). The
// name list is hashed even when no overlay files exist: two runs
// differing only in `--profile` must not share keys (conditional
// content will depend on the active set). Each list is
// count-prefixed so a name list can never alias a path/bytes
// pair from the file list. With both lists empty the stream
// gains only two zero counts, keeping profile-less keys cheap.
hasher.update((inputs.active_config_profiles.len() as u32).to_be_bytes());
for name in inputs.active_config_profiles {
write_lp_str(&mut hasher, name);
}
hasher.update((inputs.profile_config_files.len() as u32).to_be_bytes());
for (path, bytes) in inputs.profile_config_files {
write_lp_str(&mut hasher, &path.to_string_lossy());
write_lp_bytes(&mut hasher, bytes);
}

// Format-extension contributions, sorted by name (caller's
// responsibility). Hashing in any other order would change the
// key for the same set of contributions.
Expand Down Expand Up @@ -222,9 +258,72 @@ mod tests {
metadata_files: &[],
quarto_yml_bytes: b"",
extension_contributions: &[],
active_config_profiles: &[],
profile_config_files: &[],
}
}

#[test]
fn key_changes_on_active_profile_set() {
// Even with no overlay files on disk, a different --profile
// selection must change the key (bd-fu16z22k): conditional
// content depends on the active set.
let a = pass1_key(&minimal_inputs());
let names = vec!["prod".to_string()];
let mut tweaked = minimal_inputs();
tweaked.active_config_profiles = &names;
assert_ne!(a, pass1_key(&tweaked));
}

#[test]
fn key_changes_on_profile_order() {
// First-listed-wins makes activation ORDER semantic.
let ab = vec!["a".to_string(), "b".to_string()];
let ba = vec!["b".to_string(), "a".to_string()];
let mut a = minimal_inputs();
a.active_config_profiles = &ab;
let mut b = minimal_inputs();
b.active_config_profiles = &ba;
assert_ne!(pass1_key(&a), pass1_key(&b));
}

#[test]
fn key_changes_on_overlay_byte_change() {
let f_a = vec![(
PathBuf::from("_quarto-prod.yml"),
b"toc: true
"
.to_vec(),
)];
let f_b = vec![(
PathBuf::from("_quarto-prod.yml"),
b"toc: false
"
.to_vec(),
)];
let names = vec!["prod".to_string()];
let mut a = minimal_inputs();
a.active_config_profiles = &names;
a.profile_config_files = &f_a;
let mut b = minimal_inputs();
b.active_config_profiles = &names;
b.profile_config_files = &f_b;
assert_ne!(pass1_key(&a), pass1_key(&b));
}

#[test]
fn profile_name_list_cannot_alias_overlay_file_entry() {
// Count prefixes keep the two lists domain-separated: names
// ["p", "x"] must not hash like files [("p", b"x")].
let names = vec!["p".to_string(), "x".to_string()];
let files = vec![(PathBuf::from("p"), b"x".to_vec())];
let mut a = minimal_inputs();
a.active_config_profiles = &names;
let mut b = minimal_inputs();
b.profile_config_files = &files;
assert_ne!(pass1_key(&a), pass1_key(&b));
}

#[test]
fn key_is_deterministic_for_identical_inputs() {
let a = pass1_key(&minimal_inputs());
Expand Down
46 changes: 41 additions & 5 deletions crates/quarto-core/src/project/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
*/

//! Parser for project environment files (`_environment`,
//! `_environment.local`, `_environment.required`, and — once profiles
//! exist, bd-ev8mk1rp — `_environment-<profile>`).
//! `_environment.local`, `_environment.required`, and
//! `_environment-<profile>` for each active project profile
//! (bd-fu16z22k).
//!
//! Quarto 2 **never mutates the process environment**. Where Quarto 1
//! loads these files into the ambient env (`Deno.env.set`), q2 parses
Expand Down Expand Up @@ -220,11 +221,40 @@ pub fn check_required(
/// Load a project's environment files into a map, Q1-style.
///
/// Files considered, priority highest first: `_environment.local`,
/// `_environment-<profile>` per active profile (activation order
/// always empty until bd-ev8mk1rp lands render profiles), and
/// `_environment-<profile>` per active profile (activation order,
/// from `ProjectConfig::active_config_profiles` — bd-fu16z22k), and
/// `_environment`. Missing files are normal. `_environment.required`
/// contributes validation diagnostics only, never values.
///
/// Read `QUARTO_PROFILE` out of `_environment.local` /
/// `_environment` — Q1's `dotenvQuartoProfile` bootstrap
/// (bd-fu16z22k, Phase 3). `.local` wins; **profile variants are
/// deliberately not consulted** (no activation recursion — Q1
/// parity). Runs *before* profile resolution, so it cannot use the
/// project env map; parse diagnostics are dropped here and resurface
/// from the full loader on every document render.
pub fn dotenv_quarto_profile(
runtime: &dyn SystemRuntime,
project_dir: &std::path::Path,
) -> Option<String> {
let lookup = |name: &str| std::env::var(name).ok();
for name in ["_environment.local", "_environment"] {
let path = project_dir.join(name);
let Ok(content) = runtime.file_read_string(&path) else {
continue;
};
let parsed = parse_env_file(&content, &path.display().to_string(), &lookup);
if let Some(entry) = parsed
.entries
.into_iter()
.find(|e| e.key == crate::project::project_profile::QUARTO_PROFILE_VAR)
{
return Some(entry.value);
}
}
None
}

/// Project-scoped like `_variables.yml`: single-file renders get an
/// empty map (Q1 parity — env files load during project-context
/// creation there too).
Expand Down Expand Up @@ -310,7 +340,13 @@ pub fn subprocess_env_for_project(
project: &crate::project::ProjectContext,
) -> Vec<(String, String)> {
let mut diagnostics = Vec::new();
let map = load_project_environment(runtime, project, &[], &mut diagnostics);
let active_profile_names: Vec<String> = project
.config
.active_config_profiles
.iter()
.map(|p| p.name.clone())
.collect();
let map = load_project_environment(runtime, project, &active_profile_names, &mut diagnostics);
env_for_subprocess(&map)
}

Expand Down
Loading
Loading