Skip to content

Index

checks

Validation check implementations.

This module contains all the built-in validation checks organized by category: - jinja: Template syntax and undefined variable checks - paths: File and directory existence checks - config: Configuration structure and value checks

Classes

FanOutWithoutDependenciesCheck

Check fan-out configs have dependencies (V206).

When fan-out stages are configured without dependencies, stages may execute out of order.

Functions
check
check(config, config_path, raw_yaml)

Fire when fan-out is configured without dependencies.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when fan-out is configured without dependencies."""
    if (
        config.sheet.fan_out_stage_map is not None
        and not config.sheet.dependencies
    ):
        return [
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    "Fan-out is configured but no sheet dependencies"
                    " are declared — stages may run out of order"
                ),
                line=find_line_in_yaml(raw_yaml, "fan_out"),
                suggestion=(
                    "Add sheet.dependencies to control"
                    " stage execution order"
                ),
                metadata={},
            )
        ]
    return []

FanOutWithoutParallelCheck

Check fan-out configs enable parallel execution (V207).

Fan-out instances are designed to run concurrently, but will execute sequentially if parallel mode is disabled.

Functions
check
check(config, config_path, raw_yaml)

Fire when fan-out is configured without parallel.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when fan-out is configured without parallel."""
    if (
        config.sheet.fan_out_stage_map is not None
        and config.parallel.enabled is False
    ):
        return [
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    "Fan-out is configured but parallel execution is"
                    " disabled — instances will run sequentially"
                ),
                line=find_line_in_yaml(raw_yaml, "parallel"),
                suggestion=(
                    "Enable parallel.enabled: true to run"
                    " fan-out instances concurrently"
                ),
                metadata={},
            )
        ]
    return []

FileExistsOnlyCheck

Check for file_exists-only validations (V205).

file_exists alone cannot detect stale files left from a previous run. Adding file_modified or content checks is recommended.

Functions
check
check(config, config_path, raw_yaml)

Fire when every validation is file_exists.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when every validation is file_exists."""
    if not config.validations:
        return []

    all_file_exists = all(
        v.type == "file_exists" for v in config.validations
    )
    if all_file_exists:
        return [
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    "All validations are file_exists"
                    " — stale files from previous runs will pass"
                ),
                suggestion=(
                    "Consider adding file_modified or content checks"
                    " to detect stale files"
                ),
                metadata={
                    "validation_count": str(len(config.validations)),
                },
            )
        ]
    return []

FormatSyntaxInTemplateCheck

Check for format-string syntax in Jinja templates (V202).

Prompt templates use Jinja ({{ name }}), not Python format strings ({name}). Only flags known built-in names to avoid false positives.

Functions
check
check(config, config_path, raw_yaml)

Scan prompt template for {builtin_name} patterns.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Scan prompt template for ``{builtin_name}`` patterns."""
    issues: list[ValidationIssue] = []

    template = config.prompt.template
    if not template:
        return issues

    for match in self._PATTERN.finditer(template):
        name = match.group(1)
        issues.append(
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    f"Format-string syntax '{{{name}}}' found in"
                    f" prompt template — use Jinja '{{{{ {name} }}}}'"
                ),
                line=find_line_in_yaml(raw_yaml, f"{{{name}}}"),
                context=match.group(0),
                suggestion=(
                    f"Use {{{{ {name} }}}} not {{{name}}}"
                    f" in Jinja templates"
                ),
                metadata={
                    "variable": name,
                },
            )
        )

    return issues

JinjaInValidationPathCheck

Check for Jinja syntax in validation paths (V201).

Validation paths use Python .format() syntax ({workspace}), not Jinja ({{ workspace }}). Using Jinja syntax causes paths to render literally with braces intact.

Functions
check
check(config, config_path, raw_yaml)

Check validation paths for {{ markers.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check validation paths for ``{{`` markers."""
    issues: list[ValidationIssue] = []

    for i, validation in enumerate(config.validations):
        path_val = getattr(validation, "path", None)
        if path_val and "{{" in str(path_val):
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=(
                        f"Validation rule {i + 1} uses Jinja syntax"
                        f" in path: {path_val}"
                    ),
                    line=find_line_in_yaml(raw_yaml, str(path_val)),
                    context=str(path_val),
                    suggestion=(
                        "Use {workspace} not {{ workspace }}"
                        " in validation paths"
                    ),
                    metadata={
                        "validation_index": str(i),
                        "path": str(path_val),
                    },
                )
            )

    return issues

MissingDisableMcpCheck

Check that disable_mcp is enabled for Claude CLI (V209).

MCP servers can cause deadlocks in unattended orchestration. Disabling MCP provides faster, more reliable execution.

Functions
check
check(config, config_path, raw_yaml)

Fire when Claude CLI backend lacks disable_mcp.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when Claude CLI backend lacks disable_mcp."""
    if (
        config.backend.type == "claude_cli"
        and config.backend.disable_mcp is False
    ):
        return [
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    "Claude CLI backend without disable_mcp"
                    " may experience MCP deadlocks"
                ),
                line=find_line_in_yaml(raw_yaml, "disable_mcp"),
                suggestion=(
                    "Set backend.disable_mcp: true"
                    " to prevent MCP deadlocks"
                ),
                metadata={
                    "backend_type": config.backend.type,
                },
            )
        ]
    return []

MissingSkipPermissionsCheck

Check that skip_permissions is enabled for Claude CLI (V204).

Claude CLI will prompt for permission approval, which hangs in unattended mode.

Functions
check
check(config, config_path, raw_yaml)

Fire when Claude CLI backend lacks skip_permissions.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when Claude CLI backend lacks skip_permissions."""
    if (
        config.backend.type == "claude_cli"
        and config.backend.skip_permissions is False
    ):
        return [
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    "Claude CLI backend without skip_permissions"
                    " will hang waiting for permission approval"
                ),
                line=find_line_in_yaml(raw_yaml, "skip_permissions"),
                suggestion=(
                    "Set backend.skip_permissions: true"
                    " for unattended execution"
                ),
                metadata={
                    "backend_type": config.backend.type,
                },
            )
        ]
    return []

NoValidationsCheck

Check that at least one validation rule exists (V203).

Without validations, sheets always "pass" and you learn nothing about execution quality.

Functions
check
check(config, config_path, raw_yaml)

Fire when no validations are configured.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when no validations are configured."""
    if len(config.validations) == 0:
        return [
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message="No validation rules configured",
                suggestion=(
                    "Add at least one validation rule"
                    " — see docs/score-writing-guide.md"
                ),
                metadata={},
            )
        ]
    return []

SkipWhenSheetRangeCheck

Check that skip_when and skip_when_command keys are in-range (V212).

Each key in skip_when/skip_when_command must satisfy 1 ≤ k ≤ total_sheets. Out-of-range keys are silently ignored at runtime — they will never fire. This is a WARNING (non-blocking) because the config is executable, just suspicious.

Functions
check
check(config, config_path, raw_yaml)

Warn when skip_when or skip_when_command keys are out of range.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Warn when skip_when or skip_when_command keys are out of range."""
    issues: list[ValidationIssue] = []
    total = config.sheet.total_sheets

    for k in config.sheet.skip_when:
        if not (1 <= k <= total):
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=(
                        f"skip_when key {k} is out of range "
                        f"(valid: 1\u2013{total}); this rule will never fire"
                    ),
                    suggestion=(
                        f"Remove sheet {k} or adjust total_sheets / fan-out"
                    ),
                    metadata={SHEET_NUM_KEY: str(k), "source": "skip_when"},
                )
            )

    for k in config.sheet.skip_when_command:
        if not (1 <= k <= total):
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=(
                        f"skip_when_command key {k} is out of range "
                        f"(valid: 1\u2013{total}); this rule will never fire"
                    ),
                    suggestion=(
                        f"Remove sheet {k} or adjust total_sheets / fan-out"
                    ),
                    metadata={
                        SHEET_NUM_KEY: str(k),
                        "source": "skip_when_command",
                    },
                )
            )

    return issues

VariableShadowingCheck

Check for user variables that shadow built-ins (V208).

User-defined prompt variables that share names with Marianne's built-in variables will override the real values, causing unexpected behavior.

Functions
check
check(config, config_path, raw_yaml)

Fire when prompt.variables shadows a built-in name.

Source code in src/marianne/validation/checks/best_practices.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Fire when prompt.variables shadows a built-in name."""
    issues: list[ValidationIssue] = []

    for name in config.prompt.variables:
        if name in _BUILTIN_NAMES:
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=(
                        f"Variable '{name}' shadows the"
                        f" built-in {name}"
                    ),
                    line=find_line_in_yaml(raw_yaml, f"{name}:"),
                    suggestion=(
                        f"Rename variable '{name}'"
                        f" — it shadows the built-in {name}"
                    ),
                    metadata={
                        "variable": name,
                    },
                )
            )

    return issues

EmptyPatternCheck

Check for empty patterns in validations (V106).

Functions
check
check(config, config_path, raw_yaml)

Check for empty patterns.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check for empty patterns."""
    issues: list[ValidationIssue] = []

    for i, validation in enumerate(config.validations):
        if (
            validation.type in ("content_contains", "content_regex")
            and validation.pattern is not None
            and validation.pattern.strip() == ""
        ):
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=(
                        f"Empty pattern in validation rule {i + 1}"
                        f" will match any content"
                    ),
                    suggestion="Add a meaningful pattern or remove this validation",
                    metadata={
                        "validation_index": str(i),
                    },
                )
            )

    return issues

InstrumentFallbackCheck

Check that instrument_fallbacks references resolve to known profiles (V211).

Warns when a fallback instrument name doesn't match any loaded instrument profile or score-level instrument alias. Same severity as V210 — the conductor may have instruments the validator doesn't know about.

Checks: - Score-level instrument_fallbacks - movements.N.instrument_fallbacks per-movement fallbacks - sheet.per_sheet_fallbacks per-sheet fallback overrides

Functions
check
check(config, config_path, raw_yaml)

Check all instrument fallback references against the loaded profile registry.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check all instrument fallback references against the loaded profile registry."""
    try:
        from marianne.instruments.loader import load_all_profiles

        known = set(load_all_profiles().keys())
    except Exception:
        _logger.debug("V211: could not load instrument profiles, skipping check")
        return []

    if not known:
        return []

    # Score-level instrument aliases are valid fallback targets
    score_instruments = set(config.instruments.keys())
    all_valid = known | score_instruments

    issues: list[ValidationIssue] = []

    # 1. Score-level instrument_fallbacks
    for name in config.instrument_fallbacks:
        if name not in all_valid:
            issues.append(self._make_issue(
                name,
                "score-level instrument_fallbacks",
                find_line_in_yaml(raw_yaml, name),
                all_valid,
            ))

    # 2. Movement-level instrument_fallbacks
    for mov_num, mov_def in config.movements.items():
        for name in mov_def.instrument_fallbacks:
            if name not in all_valid:
                issues.append(self._make_issue(
                    name,
                    f"movement {mov_num} instrument_fallbacks",
                    find_line_in_yaml(raw_yaml, name),
                    all_valid,
                ))

    # 3. Per-sheet fallbacks
    for sheet_num, fallback_list in config.sheet.per_sheet_fallbacks.items():
        for name in fallback_list:
            if name not in all_valid:
                issues.append(self._make_issue(
                    name,
                    f"sheet {sheet_num} per_sheet_fallbacks",
                    find_line_in_yaml(raw_yaml, name),
                    all_valid,
                ))

    return issues

InstrumentNameCheck

Check that instrument names resolve to known profiles (V210).

Warns when an instrument name doesn't match any loaded instrument profile. This catches typos (e.g., 'clause-code' instead of 'claude-code') at validation time rather than runtime.

Severity is WARNING (not ERROR) because the conductor may have instruments the validator doesn't know about — profiles loaded from other directories, dynamic instruments, etc. The warning is informational, not blocking.

Checks: - Top-level instrument: field - sheet.per_sheet_instruments per-sheet overrides - sheet.instrument_map batch assignments - movements.N.instrument per-movement overrides

Functions
check
check(config, config_path, raw_yaml)

Check all instrument references against the loaded profile registry.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check all instrument references against the loaded profile registry."""
    # Load known instruments — gracefully degrade on failure
    try:
        from marianne.instruments.loader import load_all_profiles

        known = set(load_all_profiles().keys())
    except Exception:
        _logger.debug("V210: could not load instrument profiles, skipping check")
        return []

    if not known:
        return []

    # Score-level instrument aliases are valid references — they resolve
    # to profile names at build time via config.instruments[name].profile.
    score_instruments = set(config.instruments.keys())
    all_valid = known | score_instruments

    issues: list[ValidationIssue] = []

    # 1. Top-level instrument: field
    # Must check against all_valid (profiles + score aliases), not just
    # known (profiles only). A score can define instrument: my-alias and
    # instruments: { my-alias: { profile: claude-code } } — that's valid.
    if config.instrument and config.instrument not in all_valid:
        issues.append(self._make_issue(
            config.instrument,
            "score-level instrument",
            find_line_in_yaml(raw_yaml, "instrument:"),
            all_valid,
        ))

    # 2. Per-sheet instruments
    if config.sheet.per_sheet_instruments:
        for sheet_num, instr_name in config.sheet.per_sheet_instruments.items():
            if instr_name not in all_valid:
                issues.append(self._make_issue(
                    instr_name,
                    f"sheet {sheet_num} instrument",
                    find_line_in_yaml(raw_yaml, f"{sheet_num}:"),
                    all_valid,
                ))

    # 3. Instrument map
    if config.sheet.instrument_map:
        for instr_name in config.sheet.instrument_map:
            if instr_name not in all_valid:
                issues.append(self._make_issue(
                    instr_name,
                    "instrument_map entry",
                    find_line_in_yaml(raw_yaml, f"{instr_name}:"),
                    all_valid,
                ))

    # 4. Movement-level instruments
    if config.movements:
        for mov_num, mov_def in config.movements.items():
            if mov_def.instrument and mov_def.instrument not in all_valid:
                issues.append(self._make_issue(
                    mov_def.instrument,
                    f"movement {mov_num} instrument",
                    find_line_in_yaml(raw_yaml, f"{mov_num}:"),
                    all_valid,
                ))

    return issues

RegexPatternCheck

Check that regex patterns in validations compile (V007).

Invalid regex patterns will cause runtime errors during validation.

Functions
check
check(config, config_path, raw_yaml)

Check regex patterns in validations and rate limit detection.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check regex patterns in validations and rate limit detection."""
    issues: list[ValidationIssue] = []

    # Check validation rule patterns
    for i, validation in enumerate(config.validations):
        if validation.type == "content_regex" and validation.pattern:
            issue = self._check_pattern(
                validation.pattern,
                f"validation[{i}].pattern",
                self._find_pattern_line(raw_yaml, validation.pattern),
            )
            if issue:
                issues.append(issue)

    # Check rate limit detection patterns
    for i, pattern in enumerate(config.rate_limit.detection_patterns):
        issue = self._check_pattern(
            pattern,
            f"rate_limit.detection_patterns[{i}]",
            None,
        )
        if issue:
            issues.append(issue)

    return issues

TimeoutRangeCheck

Check timeout values are reasonable (V103/V104).

Warns about very short timeouts (may cause failures) or very long timeouts (may waste resources).

Functions
check
check(config, config_path, raw_yaml)

Check timeout values.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check timeout values."""
    issues: list[ValidationIssue] = []

    timeout = config.backend.timeout_seconds

    if timeout < self.MIN_REASONABLE_TIMEOUT:
        issues.append(
            ValidationIssue(
                check_id="V103",
                severity=ValidationSeverity.WARNING,
                message=f"Very short timeout ({timeout}s) may cause premature failures",
                line=find_line_in_yaml(raw_yaml, "timeout_seconds:"),
                suggestion=(
                    f"Consider timeout_seconds >="
                    f" {self.MIN_REASONABLE_TIMEOUT}"
                    f" for Claude CLI operations"
                ),
                metadata={
                    "timeout": str(timeout),
                    "threshold": str(self.MIN_REASONABLE_TIMEOUT),
                },
            )
        )

    if timeout > self.MAX_REASONABLE_TIMEOUT:
        issues.append(
            ValidationIssue(
                check_id="V104",
                severity=ValidationSeverity.INFO,
                message=(
                    f"Very long timeout ({timeout}s = {timeout / 3600:.1f}h)"
                    f" - consider if this is necessary"
                ),
                line=find_line_in_yaml(raw_yaml, "timeout_seconds:"),
                suggestion=(
                    "Long timeouts can tie up resources;"
                    " consider breaking into smaller tasks"
                ),
                metadata={
                    "timeout": str(timeout),
                    "threshold": str(self.MAX_REASONABLE_TIMEOUT),
                },
            )
        )

    return issues

ValidationTypeCheck

Check that validation rules have required fields (V008).

Ensures validations have the fields needed for their type.

Functions
check
check(config, config_path, raw_yaml)

Check validation rules have required fields.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check validation rules have required fields."""
    issues: list[ValidationIssue] = []

    required_fields = {
        "file_exists": ["path"],
        "file_modified": ["path"],
        "content_contains": ["path", "pattern"],
        "content_regex": ["path", "pattern"],
        "command_succeeds": ["command"],
    }

    for i, validation in enumerate(config.validations):
        required = required_fields.get(validation.type, [])
        missing = []

        for field in required:
            value = getattr(validation, field, None)
            if value is None or (isinstance(value, str) and not value.strip()):
                missing.append(field)

        if missing:
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=(
                        f"Validation rule {i + 1} ({validation.type})"
                        f" missing required fields: {', '.join(missing)}"
                    ),
                    suggestion=f"Add {', '.join(missing)} to the validation rule",
                    metadata={
                        "validation_index": str(i),
                        "validation_type": validation.type,
                        "missing_fields": ",".join(missing),
                    },
                )
            )

    return issues

VersionReferenceCheck

Check that evolved scores don't reference previous version paths (V009).

When a score evolves (e.g., v20 → v21), the new score file should have all references updated to the new version. This catches cases where the name, workspace, or other paths still reference the old version.

Functions
check
check(config, config_path, raw_yaml)

Check for stale version references in evolved scores.

Source code in src/marianne/validation/checks/config.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check for stale version references in evolved scores."""
    issues: list[ValidationIssue] = []

    # Extract version from filename (e.g., v21 from marianne-opus-evolution-v21.yaml)
    filename = config_path.name
    version_match = re.search(r"-v(\d+)\.yaml$", filename)
    if not version_match:
        # Not a versioned score file, skip this check
        return issues

    current_version = int(version_match.group(1))
    if current_version <= 1:
        # v1 has no previous version to check against
        return issues

    previous_version = current_version - 1
    prev_patterns = [
        f"-v{previous_version}",  # e.g., -v20
        f"v{previous_version}.0",  # e.g., v20.0
        f"v{previous_version}/",  # e.g., workspace-v20/
        f"-v{previous_version}/",  # e.g., evolution-workspace-v20/
        f"evolution-v{previous_version}",  # e.g., evolution-v20
    ]

    # Check the job name
    if f"-v{previous_version}" in config.name:
        issues.append(
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    f"Job name '{config.name}' references"
                    f" v{previous_version} but file is v{current_version}"
                ),
                line=find_line_in_yaml(raw_yaml, "name:"),
                suggestion=f"Update name to use v{current_version}",
                metadata={
                    "field": "name",
                    "current_version": str(current_version),
                    "previous_version": str(previous_version),
                },
            )
        )

    # Check the workspace path
    workspace_str = str(config.workspace)
    if f"-v{previous_version}" in workspace_str or f"v{previous_version}/" in workspace_str:
        issues.append(
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=(
                    f"Workspace path references v{previous_version}"
                    f" but file is v{current_version}"
                ),
                line=find_line_in_yaml(raw_yaml, "workspace:"),
                context=workspace_str,
                suggestion=f"Update workspace to use v{current_version}",
                metadata={
                    "field": "workspace",
                    "current_version": str(current_version),
                    "previous_version": str(previous_version),
                },
            )
        )

    # Scan raw YAML for other references (in comments, descriptions, etc.)
    for i, line in enumerate(raw_yaml.split("\n"), 1):
        # Skip lines that are defining the version progression history
        if "VERSION PROGRESSION:" in line or "→" in line or "->" in line:
            continue
        # Skip lines in comments that are documenting history
        if line.strip().startswith("#") and ("v" + str(previous_version - 1) in line):
            continue

        for pattern in prev_patterns:
            if pattern in line:
                # Check if this is just historical documentation
                hist_markers = [
                    "EVOLUTION FROM", "evolved from", "LEARNINGS",
                ]
                if any(hist in line for hist in hist_markers):
                    continue
                # Check if it's in the version progression list
                if (
                    f"v{previous_version}→v{current_version}" in line
                    or f"v{previous_version}->v{current_version}" in line
                ):
                    continue

                issues.append(
                    ValidationIssue(
                        check_id=self.check_id,
                        severity=ValidationSeverity.WARNING,
                        message=(
                            f"Line {i} references v{previous_version}"
                            f" - verify this is intentional"
                        ),
                        line=i,
                        context=line.strip()[:80],
                        suggestion=(
                    f"Update to v{current_version} if this should"
                    f" reference the current version"
                ),
                        metadata={
                            "pattern": pattern,
                            "current_version": str(current_version),
                            "previous_version": str(previous_version),
                        },
                    )
                )
                break  # Only one issue per line

    return issues

JinjaSyntaxCheck

Check for Jinja template syntax errors (V001).

Attempts to parse templates and reports syntax errors with line numbers and context. This catches issues like: - Unclosed blocks ({% if ... without {% endif %}) - Unclosed expressions ({{ ... without }}) - Invalid syntax inside blocks

Functions
check
check(config, config_path, raw_yaml)

Check Jinja syntax in templates.

Source code in src/marianne/validation/checks/jinja.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check Jinja syntax in templates."""
    issues: list[ValidationIssue] = []
    env = jinja2.Environment()

    # Check inline template
    if config.prompt.template:
        template_issues = self._check_template(
            config.prompt.template,
            "prompt.template",
            find_line_in_yaml(raw_yaml, "template:"),
            env,
        )
        issues.extend(template_issues)

    # Check external template file
    if config.prompt.template_file:
        template_path = resolve_path(config.prompt.template_file, config_path)
        if template_path.exists():
            try:
                template_content = template_path.read_text()
                template_issues = self._check_template(
                    template_content,
                    f"template_file ({template_path.name})",
                    None,
                    env,
                )
                issues.extend(template_issues)
            except Exception as e:
                issues.append(
                    ValidationIssue(
                        check_id=self.check_id,
                        severity=self.severity,
                        message=f"Could not read template file: {e}",
                        suggestion=f"Check file permissions for {template_path}",
                    )
                )

    return issues

JinjaUndefinedVariableCheck

Check for undefined template variables (V101).

Warns about variables used in templates that aren't defined in the config's variables section or the built-in sheet context. Uses fuzzy matching to suggest corrections for typos.

Functions
check
check(config, config_path, raw_yaml)

Check for undefined variables in templates.

Source code in src/marianne/validation/checks/jinja.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check for undefined variables in templates."""
    issues: list[ValidationIssue] = []
    env = jinja2.Environment()

    # Collect all defined variables
    defined_vars = set(self.BUILTIN_VARIABLES)
    defined_vars.update(config.prompt.variables.keys())

    # Check inline template
    if config.prompt.template:
        var_issues = self._check_undefined_vars(
            config.prompt.template,
            "prompt.template",
            defined_vars,
            env,
        )
        issues.extend(var_issues)

    # Check external template file
    if config.prompt.template_file:
        template_path = resolve_path(config.prompt.template_file, config_path)
        if template_path.exists():
            try:
                template_content = template_path.read_text()
                var_issues = self._check_undefined_vars(
                    template_content,
                    f"template_file ({template_path.name})",
                    defined_vars,
                    env,
                )
                issues.extend(var_issues)
            except Exception as exc:
                issues.append(ValidationIssue(
                    check_id="V101",
                    severity=ValidationSeverity.WARNING,
                    message=f"Cannot read template file '{template_path.name}' "
                            f"for undefined variable check: {exc}",
                    suggestion="Ensure the template file is readable.",
                ))

    return issues

PreludeCadenzaFileCheck

Check that prelude/cadenza files exist when paths are static (V108).

Only checks non-templated paths (no Jinja {{). Templated paths are resolved at execution time and cannot be validated statically. This is a WARNING because files might be created before execution.

Functions
check
check(config, config_path, raw_yaml)

Check prelude and cadenza file/directory paths.

Source code in src/marianne/validation/checks/paths.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check prelude and cadenza file/directory paths."""
    issues: list[ValidationIssue] = []

    for item in config.sheet.prelude:
        issues.extend(self._check_item(item, "prelude", config, config_path, raw_yaml))
    for sheet_num, items in config.sheet.cadenzas.items():
        for item in items:
            issues.extend(
                self._check_item(
                    item, f"cadenza (sheet {sheet_num})", config, config_path, raw_yaml
                )
            )

    return issues

SkillFilesExistCheck

Check that files referenced in validation commands exist (V107).

This is a WARNING because skill files might be optional.

Functions
check
check(config, config_path, raw_yaml)

Check files referenced in validations.

Source code in src/marianne/validation/checks/paths.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check files referenced in validations."""
    issues: list[ValidationIssue] = []

    for i, validation in enumerate(config.validations):
        # Skip validations with template variables in path
        # Check file_exists validations - these are expected to be created
        # so we don't warn about them. Check other types.
        if (
            validation.path
            and "{" not in validation.path
            and validation.type in ("content_contains", "content_regex")
        ):
            file_path = resolve_path(Path(validation.path), config_path)

            # Only warn if it's an absolute path that doesn't exist
            # Relative paths might be created during execution
            if file_path.is_absolute() and not file_path.exists():
                issues.append(
                    ValidationIssue(
                        check_id=self.check_id,
                        severity=self.severity,
                        message=(
                            f"File referenced in validation {i + 1}"
                            f" does not exist: {file_path}"
                        ),
                        suggestion="Ensure file will be created before this validation runs",
                        metadata={
                            "validation_index": str(i),
                            "path": str(file_path),
                        },
                    )
                )

    return issues

SystemPromptFileCheck

Check that system_prompt_file exists if specified (V004).

Functions
check
check(config, config_path, raw_yaml)

Check system_prompt_file exists.

Source code in src/marianne/validation/checks/paths.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check system_prompt_file exists."""
    issues: list[ValidationIssue] = []

    if config.backend.system_prompt_file:
        sys_prompt_path = resolve_path(
            config.backend.system_prompt_file, config_path
        )

        if not sys_prompt_path.exists():
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=f"System prompt file not found: {sys_prompt_path}",
                    line=find_line_in_yaml(raw_yaml, "system_prompt_file:"),
                    suggestion="Create the system prompt file or fix the path",
                    metadata={
                        "expected_path": str(sys_prompt_path),
                    },
                )
            )

    return issues

TemplateFileExistsCheck

Check that template_file exists if specified (V003).

Functions
check
check(config, config_path, raw_yaml)

Check template_file exists.

Source code in src/marianne/validation/checks/paths.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check template_file exists."""
    issues: list[ValidationIssue] = []

    if config.prompt.template_file:
        template_path = resolve_path(config.prompt.template_file, config_path)

        if not template_path.exists():
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=f"Template file not found: {template_path}",
                    line=find_line_in_yaml(raw_yaml, "template_file:"),
                    suggestion="Create the template file or fix the path",
                    metadata={
                        "expected_path": str(template_path),
                    },
                )
            )
        elif not template_path.is_file():
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=f"Template path is not a file: {template_path}",
                    line=find_line_in_yaml(raw_yaml, "template_file:"),
                    suggestion="Ensure template_file points to a file, not a directory",
                )
            )

    return issues

WorkingDirectoryCheck

Check that working_directory is valid if specified (V005).

Functions
check
check(config, config_path, raw_yaml)

Check working_directory is valid.

Source code in src/marianne/validation/checks/paths.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check working_directory is valid."""
    issues: list[ValidationIssue] = []

    if config.backend.working_directory:
        working_dir = resolve_path(
            config.backend.working_directory, config_path
        )

        if not working_dir.exists():
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=f"Working directory does not exist: {working_dir}",
                    line=find_line_in_yaml(raw_yaml, "working_directory:"),
                    suggestion=f"Create directory: mkdir -p {working_dir}",
                    auto_fixable=True,
                    metadata={
                        "path": str(working_dir),
                    },
                )
            )
        elif not working_dir.is_dir():
            issues.append(
                ValidationIssue(
                    check_id=self.check_id,
                    severity=self.severity,
                    message=f"Working directory path is not a directory: {working_dir}",
                    line=find_line_in_yaml(raw_yaml, "working_directory:"),
                    suggestion="Ensure path points to a directory, not a file",
                )
            )

    return issues

WorkspaceParentExistsCheck

Check that workspace parent directory exists (V002).

The workspace itself will be created, but its parent must exist. This is auto-fixable by creating the parent directories.

Functions
check
check(config, config_path, raw_yaml)

Check workspace parent exists.

Source code in src/marianne/validation/checks/paths.py
def check(
    self,
    config: JobConfig,
    config_path: Path,
    raw_yaml: str,
) -> list[ValidationIssue]:
    """Check workspace parent exists."""
    issues: list[ValidationIssue] = []

    workspace = resolve_path(config.workspace, config_path)
    parent = workspace.parent

    if not parent.exists():
        issues.append(
            ValidationIssue(
                check_id=self.check_id,
                severity=self.severity,
                message=f"Workspace parent directory does not exist: {parent}",
                line=find_line_in_yaml(raw_yaml, "workspace:"),
                suggestion=f"Create parent directory: mkdir -p {parent}",
                auto_fixable=True,
                metadata={
                    "path": str(parent),
                    "workspace": str(workspace),
                },
            )
        )

    return issues