@ModuleInfo { minPklVersion = "0.27.2" } module hk.Config import "pkl:base" as base /// The minimum hk version required by this config. /// /// If the running hk version is older, hk errors out while loading the config. /// /// Example: /// /// ```pkl /// min_hk_version = "1.0.0" /// ``` min_hk_version: String = "{{version | truncate(length=1)}}.0.0" typealias StashMethod = Boolean | "git" | "patch-file" | "none" // NB: Re-export Regex in case users use Config.Regex @Deprecated { since = "1.27.1" message = "Replace `Types.Regex` with `Regex` (pkl built-in)" replaceWith = "Regex" } const function Regex(s: String): Regex = base.Regex(s) class Script { linux: String? macos: String? windows: String? other: String? } /// A command executed directly without a shell. /// /// The first entry is the executable. Exact `{{files}}` and /// `{{workspace_files}}` entries expand to one argument per file. /// Structured commands cannot be combined with a step's `shell` or a string /// `prefix`. class Command { argv: List } typealias CommandEffect = "read" | "write" | "destructive" typealias DiagnosticFormat = "sarif" | "cargo-json" | "eslint-json" | "gcc" /// A command paired with its declared effect on user or project state. /// Commands that do not use CommandSpec remain valid and have an unknown effect. class CommandSpec { command: String | Script | Command effect: CommandEffect } /// Default: auto-detected /// /// Specifies the preferred default branch to compare against when hk needs a reference /// (e.g., suggestions in pre-commit warnings). /// If unset or empty, hk attempts to detect it via `origin/HEAD`, the current branch's remote, /// or falls back to `main`/`master` if they exist on the remote. /// /// Examples: /// /// ```pkl /// // Use a local branch name /// default_branch = "main" /// /// // Or a remote-qualified ref /// default_branch = "origin/main" /// ``` /// /// Notes: /// /// - Both local branch names (e.g., `main`) and remote-qualified refs (e.g., `origin/main`) are supported. /// - If omitted, hk will detect the default branch based on your repository's remotes and branches. default_branch: String? /// Environment variables can be set in hk.pkl for configuring the linters. /// /// ```pkl /// env { /// ["NODE_ENV"] = "production" /// } /// ``` env: Mapping = new Mapping {} /// Default: `(empty)` /// /// Global exclude patterns that apply to all hooks and steps. /// Files matching these patterns will be skipped from processing. /// Supports directory names, glob patterns, and regex patterns. /// /// ```pkl /// // Exclude specific directories /// exclude = List("node_modules", "dist", "build") /// /// // Exclude using glob patterns /// exclude = List("**/*.min.js", "**/*.map", "**/vendor/**") /// /// // Single pattern /// exclude = "node_modules" /// /// // Exclude using regex pattern (for complex matching) /// exclude = Regex(#".*\.(test|spec)\.(js|ts)$"#) /// ``` /// /// Notes: /// /// - Patterns from all configuration sources are unioned together /// - Simple directory names automatically match their contents /// (e.g., `"excluded"` matches `excluded/*` and `excluded/**`) /// - Can be overridden per-step with `.exclude` /// - Regex patterns use Rust regex syntax and match against full file paths exclude: (String | List | Regex)? /// Default: `true` /// /// Controls whether hk aborts remaining steps/groups after the first failure. /// /// - When `true`, as soon as a step fails, hk cancels pending steps in the same hook and returns the error. /// - When `false`, hk continues running other steps and reports all failures at the end. fail_fast: Boolean? /// Controls which skip reasons are displayed in the output /// By default, only profile-not-enabled messages are displayed /// Possible values: /// - "profile-not-enabled" /// - "profile-explicitly-disabled" /// - "no-command-for-run-type" /// - "no-files-to-process" /// - "condition-false" /// - "disabled-by-env" /// - "disabled-by-cli" /// - "missing-required-env" display_skip_reasons: List = List("profile-not-enabled") /// Which warning categories to show. Empty by default (no warnings shown). /// Available tags include: "missing-profiles" warnings: List = List() /// Warning tags to suppress. Allows hiding specific warning messages that you don't want to see. /// /// Available warning tags: /// - `missing-profiles`: Suppresses warnings about steps being skipped due to missing profiles /// /// Example: `hide_warnings = List("missing-profiles")` /// /// All hide configurations from different sources are **unioned** together. hide_warnings: List = List() /// The number of parallel processes that hk will use to execute steps concurrently. /// This affects performance by controlling how many linting/formatting tasks can run simultaneously. /// /// Set to `0` (default) to auto-detect based on CPU cores. jobs: UInt? /// Profiles to enable or disable. Profiles allow you to group steps that should run only in certain contexts (e.g., CI, slow tests). /// /// Prefix with `!` to explicitly disable a profile. /// /// Example usage: /// ```pkl /// profiles = List("ci", "slow") /// profiles = List("!slow") // explicitly disable /// ``` profiles: List = List() /// A list of hook names to skip entirely. This allows you to disable specific git hooks from running. /// /// For example: `skip_hooks = List("pre-commit", "pre-push")` would skip running those hooks completely. /// /// This is useful when you want to temporarily disable certain hooks while still keeping them configured in your `hk.pkl` file. /// Unlike `skip_steps` which skips individual steps, this skips the entire hook and all its steps. /// /// **All skip configurations from different sources are unioned together.** skip_hooks: List = List() /// A list of step names to skip when running hooks. This allows you to bypass specific linting or formatting tasks. /// /// For example: `skip_steps = List("lint", "test")` would skip any steps named "lint" or "test". /// /// **All skip configurations from different sources are unioned together.** skip_steps: List = List() /// When specified, overrides the [hook's `stage` key](https://hk.jdx.dev/configuration.html#hooks-hook-stage-boolean). /// /// This is useful when you want to manually review changes made by auto-fixers before including them in your commit. stage: Boolean? /// Number of backup patch files to keep per repository when using git stash. /// /// Each time git stash is used, hk creates a backup patch file in /// `$HK_STATE_DIR/patches/`. This setting controls how many of these /// backups are retained per repository (oldest are automatically deleted). /// /// Set to 0 to disable patch backup creation entirely. /// /// Default: 20 stash_backup_count: UInt? /// Directories containing their own hk config files, for monorepos. Entries may be /// literal directories or glob patterns (e.g. `"packages/*"`). /// /// Each subproject's hooks are merged into this config, scoped to its directory: /// step working directories and glob matching are relative to the subdirectory, /// step names are prefixed with `":"` (e.g. `packages/web:eslint`), and the /// subproject's `env` applies only to its own steps. /// /// This pairs well with [mise monorepo config roots](https://mise.jdx.dev/tasks/monorepo.html): /// each config root can own its linting config next to its code. /// /// ```pkl /// subprojects = List("subproject", "packages/*") /// ``` subprojects: List? /// Enables or disables reporting progress via OSC sequences to compatible terminals. terminal_progress: Boolean? /// Controls whether hk respects .gitignore and other ignore files when walking directories. /// /// Default: true walk_ignore: Boolean? /// Hooks define when and how linters are run. See [hooks](https://hk.jdx.dev/hooks) for more information. hooks: Mapping = new Mapping {} class Hook { /// Default: `false` (`true` for `fix` hook) /// /// If true, hk will run the fix command for each step (if it exists) to make modifications. fix: Boolean? /// Default: `true` /// /// If true, hk will automatically stage fixed files after `fix` commands run. /// /// This can be overridden via the `stage` [configuration setting](https://hk.jdx.dev/configuration.html#stage). /// Note that this means the value of `stage` in your `hk.pkl` takes precendence. stage: Boolean? /// Default: `"none"` /// /// - `"git"`: Use `git stash` to stash unstaged changes before running fix steps. /// - `"patch-file"`: Alias of `git` behavior for now. /// - `"none"`: Do not stash unstaged changes before running fix steps. /// - `true` (boolean): Alias of `"git"`. /// - `false` (boolean): Alias of `"none"`. /// /// Examples: /// /// ```pkl /// hooks { /// ["pre-commit"] { /// fix = true /// stash = true // boolean shorthand for git /// steps = linters /// } /// /// ["fix"] { /// fix = true /// stash = "none" // disable stashing /// steps = linters /// } /// } /// ``` stash: StashMethod? /// Environment variables specific to this hook. /// These are merged into each step's environment variables, with step-level env taking precedence. /// /// ```pkl /// hooks { /// ["pre-push"] { /// env { /// ["HK_PROFILES"] = "types" /// } /// steps = linters /// } /// } /// ``` env: Mapping = new Mapping {} /// Default: `false` /// /// If true, the hook will fail when fix commands modify files. /// This is useful with `stage = false` in pre-commit hooks to apply fixes /// but block the commit so you can review the changes before staging manually. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// fix = true /// stage = false /// fail_on_fix = true /// steps = linters /// } /// } /// ``` fail_on_fix: Boolean = false /// Command to run after the hook completes. Receives timing JSON in `HK_REPORT_JSON`. report: (String | Script)? /// Steps are the individual linters that make up a hook. /// They are executed in the order they are defined in parallel up to /// [`HK_JOBS`](https://hk.jdx.dev//configuration#hk-jobs) at a time. steps: Mapping = new Mapping {} } /// A positive file-selection clause used by `Step.match_any`. /// Within a selector, `glob` and `types` compose with AND semantics. class FileSelector { /// Glob or regex patterns to match. glob: (String | List | Regex)? /// File types to match. A file may match any listed type. types: List? } class Step { /// List of environment variables that must be set for this step to run. /// A variable is considered satisfied if it is present in the process environment, /// the global `env` block in hk.pkl, or the step's own `env` block. /// If any are missing, the step will be skipped with a clear message. required: List = List() /// Files the step should run on. By default this will only run this step if /// at least 1 staged file matches the glob or regex patterns. /// If no patterns are provided, the step will always run. /// /// ```pkl /// // Glob patterns /// ["prettier"] { /// glob = List("*.js", "*.ts") /// check = "prettier --check {{files}}" /// } /// /// // Single glob pattern /// ["eslint"] { /// glob = "*.js" /// check = "eslint {{files}}" /// } /// /// // Regex pattern for complex matching /// ["config-lint"] { /// glob = Types.Regex(#"^(config|settings).*\.(json|yaml|yml)$"#) /// check = "config-lint {{files}}" /// } /// ``` glob: (String | List | Regex)? /// Default: `(none)` /// /// Filter files by their type rather than just glob patterns. /// Matches files by extension, shebang, or content detection /// (OR logic - file must match ANY of the specified types). /// This is particularly useful for matching scripts without file extensions. /// /// ```pkl /// // Match Python files by extension AND shebang (including extensionless scripts) /// ["black"] { /// types = List("python") /// fix = "black {{files}}" /// } /// /// // Match shell scripts by extension or shebang /// ["shellcheck"] { /// types = List("shell") /// check = "shellcheck {{files}}" /// } /// /// // Match multiple types (OR logic) /// ["format-scripts"] { /// types = List("python", "shell", "ruby") /// fix = "format-script {{files}}" /// } /// /// // Combine types with glob patterns for more precise filtering /// ["format-src-python"] { /// glob = "src/**/*" // Only files in src/ /// types = List("python") // That are Python files /// fix = "black {{files}}" /// } /// ``` /// /// **Supported types include:** /// /// - **Languages:** `python`, `javascript`, `typescript`, `ruby`, `go`, `rust`, `java`, `kotlin`, `swift`, `c`, `c++`, `csharp`, `php`, "lua" /// - **Shells:** `shell`, `bash`, `zsh`, `fish`, `sh` /// - **Data formats:** `json`, `yaml`, `toml`, `xml`, `csv`, 'pkl' /// - **Markup:** `html`, `markdown`, `css`, 'asciidoc' /// - **Special:** `text`, `binary`, `executable`, `symlink`, `dockerfile` /// - **Images:** `image`, `png`, `jpeg`, `gif`, `svg`, `webp` /// - **Archives:** `archive`, `zip`, `tar`, `gzip` /// /// Types are detected using: /// 1. File extension (e.g., `.py` → `python`) /// 2. Shebang line (e.g., `#!/usr/bin/env python3` → `python`) /// 3. Special filenames (e.g., `Dockerfile` → `dockerfile`) /// 4. Content/magic number detection for binary files types: List? /// Match files using any of the supplied selector clauses. /// Selectors compose with OR semantics, while `glob` and `types` within /// each selector compose with AND semantics. /// /// `match_any` cannot be combined with the top-level `glob` or `types` /// fields. Each selector must contain a non-empty `glob` or `types` value. /// /// ```pkl /// ["shellcheck"] { /// match_any = List( /// new { glob = List("**/*.sh", "**/*.bash") }, /// new { types = List("sh", "bash") } /// ) /// check = "shellcheck {{files}}" /// } /// ``` match_any: List? /// Whether to include binary files (default: false) allow_binary: Boolean = false /// Whether to include symbolic links (default: false) allow_symlinks: Boolean = false /// A command to run that does not modify files. /// This typically is a "check" command like `eslint` or `prettier --check` /// that returns a non-zero exit code if there are errors. /// Parallelization works better with check commands than fix commands as no files are being modified. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// ["prettier"] { /// check = "prettier --check {{files}}" /// } /// } /// } /// ``` /// /// If you want to use a different check command for different operating systems, /// you can define a Script instead of a String: /// /// ```pkl /// hooks { /// ["pre-commit"] { /// ["prettier"] { /// check = new Script { /// linux = "prettier --check {{files}}" /// macos = "prettier --check {{files}}" /// windows = "prettier --check {{files}}" /// other = "prettier --check {{files}}" /// } /// } /// } /// } /// ``` /// /// Template variables: /// /// - `{{files}}`: A list of files to run the linter on. /// - `{{workspace}}`: When `workspace_indicator` is set and matched, this is the workspace directory path /// (e.g., `.` for the repo root or `packages/app`). /// - `{{workspace_indicator}}`: Full path to the matched workspace indicator file /// (e.g., `packages/app/package.json`). /// - `{{workspace_files}}`: A list of files relative to `{{workspace}}`. /// /// To bypass the shell and pass files as separate arguments, use `Command`: /// /// ```pkl /// check = new Command { /// argv = List("wc", "-c", "{{files}}") /// } /// ``` check: (String | Script | Command | CommandSpec)? /// A command that returns a list of files that need fixing. /// This is used to optimize the fix step when `check_first` is enabled. /// Instead of running the fix command on all files, it will only run on files that need fixing. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// ["prettier"] { /// check_list_files = "prettier --list-different {{files}}" /// } /// } /// } /// ``` check_list_files: (String | Script | Command | CommandSpec)? /// A command that shows the diff of what would be changed. /// This is an alternative to `check` that can provide more detailed information about what would be changed. /// /// When defined, hk will attempt to apply the diff output directly using `git apply` /// instead of running the fix command. This is more efficient for tools that /// produce standard unified diffs (black, ruff, shfmt, etc.). /// /// Falls back to running the fix command if patch application fails. check_diff: (String | Script | Command | CommandSpec)? /// Default: `false` /// /// If true, check mode first runs `check_diff` or `check_list_files` to /// identify failing files, then runs `check` only on those files. This keeps /// detailed diagnostics while avoiding commands and suggestions containing /// every file in a large repository. /// /// Requires `check` and at least one of `check_diff` or `check_list_files`. check_failed_files: Boolean = false /// A command to run that modifies files. /// This typically is a "fix" command like `eslint --fix` or `prettier --write`. /// Templates variables are the same as for `check`. /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] { /// fix = "prettier --write {{files}}" /// } /// } /// ``` /// /// By default, hk will use `fix` commands but this can be overridden by setting /// [`HK_FIX=0`](https://hk.jdx.dev/configuration#hk-fix) or running `hk run --check`. fix: (String | Script | Command | CommandSpec)? /// If true, hk will run the check step first and only run the fix step if the check step fails. check_first: Boolean = true /// Default: `false` /// /// If true, hk will run the linter on batches of files instead of all files at once. /// This takes advantage of parallel processing for otherwise single-threaded linters like eslint and prettier. /// /// ```pkl /// local linters = new Mapping { /// ["eslint"] { /// batch = true /// } /// } /// ``` batch: Boolean = false /// Default: `false` /// /// If true, hk will get a write lock instead of a read lock when running fix/fix_all. /// Use this if the tool has its own locking mechanism or you simply don't care /// if files may be written to by multiple linters simultaneously. stomp: Boolean = false /// If set, run the linter on workspaces only which are parent directories containing this filename. /// This is useful for tools that need to be run from a specific directory, like a project root. /// /// ```pkl /// local linters = new Mapping { /// ["cargo-clippy"] { /// glob = "*.rs" /// workspace_indicator = "Cargo.toml" /// check = "cargo clippy --manifest-path {{workspace_indicator}}" /// } /// } /// ``` /// /// In this example, given a file list like the following: /// /// ```text /// └── workspaces/ /// ├── proj1/ /// │ ├── Cargo.toml /// │ └── src/ /// │ ├── lib.rs /// │ └── main.rs /// └── proj2/ /// ├── Cargo.toml /// └── src/ /// ├── lib.rs /// └── main.rs /// ``` /// /// hk will run 1 step for each workspace even though multiple rs files are in each workspace: /// /// - `cargo clippy --manifest-path workspaces/proj1/Cargo.toml` /// - `cargo clippy --manifest-path workspaces/proj2/Cargo.toml` /// /// When `workspace_indicator` is used, the following template variables become available in commands and env: /// /// - `{{workspace}}`: the workspace directory path /// - `{{workspace_indicator}}`: the matched indicator file path /// - `{{workspace_files}}`: files relative to `{{workspace}}` /// /// For example, in a monorepo with Node packages: /// /// ```pkl /// local linters = new Mapping { /// ["npm-lint"] { /// glob = List("*.js", "*.jsx", "*.ts", "*.tsx") /// workspace_indicator = "package.json" /// check = "echo cd {{workspace}} && npm run lint -- {{workspace_files}}" /// fix = "echo cd {{workspace}} && npm run fix -- {{workspace_files}}" /// } /// } /// ``` workspace_indicator: String? /// If set, run commands with this prefix. Use a string for shell commands or /// a list of explicit arguments for structured commands. /// /// ```pkl /// local linters = new Mapping { /// ["eslint"] { /// prefix = "npm run" /// } /// } /// ``` prefix: (String | List)? /// If set, run the linter scripts in this directory. /// /// ```pkl /// local linters = new Mapping { /// ["eslint"] = (Builtins.eslint) { /// dir = "frontend" /// } /// } /// ``` /// /// `dir` is a template, so with `workspace_indicator` set it can follow each /// job's workspace instead of the command opening with a `cd`: /// /// ```pkl /// local linters = new Mapping { /// ["go-vet"] { /// glob = "**/*.go" /// workspace_indicator = "go.mod" /// dir = "{{workspace}}" /// check = "go vet ./..." /// } /// } /// ``` /// /// `{{files}}` is relative to the rendered directory, as it already is for a /// literal `dir`. /// /// Note that file selection — `glob` matching, `exclude`, and `stage` /// pathspecs — happens before hk knows which workspace a job will run in, so /// it can only use the literal part of `dir` that precedes the first template /// expression. `dir = "{{workspace}}"` therefore scopes nothing, and file /// selection is left to `glob` and `workspace_indicator`. dir: String? /// Profiles are a way to enable/disable linters based on the current profile. /// The linter will only run if its profile is in [`HK_PROFILE`](https://hk.jdx.dev/configuration#hk-profile). /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] = (Builtins.prettier) { /// profiles = List("slow") /// } /// } /// ``` /// /// Profiles can be prefixed with `!` to disable them. /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] = (Builtins.prettier) { /// profiles = List("!slow") /// } /// } /// ``` profiles: List? /// A list of steps that must finish before this step can run. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// steps { /// ["prettier"] { /// depends = List("eslint") /// } /// } /// } /// } /// ``` depends: (String | List) = List() /// If set, use this shell instead of the default `sh -o errexit -c`. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// steps { /// ["prettier"] { /// shell = "bash -o errexit -c" /// } /// } /// } /// } /// ``` shell: (String | Script)? /// A list of globs of files to add to the git index after running a fix step. /// /// When staging is enabled and the step has a `fix` command, this defaults to the step's /// glob. Set explicitly if your fix modifies different files than those it reads. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// steps { /// ["prettier"] { /// stage = List("*.js", "*.ts") /// } /// } /// } /// } /// ``` stage: (String | List)? /// Default: `false` /// /// If true, this step will wait for any previous steps to finish before running. /// No other steps will start until this one finishes. /// Under the hood this groups the previous steps into a group. /// Prefer declaring generated files with `stage` and letting hk own Git staging. /// Use this for legacy or third-party commands that access shared resources hk /// cannot infer, such as scripts that cannot avoid writing the Git index themselves. /// /// ```pkl /// hooks { /// ["pre-commit"] { /// steps { /// ["prelint"] { /// exclusive = true // blocks other steps from starting until this one finishes /// check = "mise run prelint" /// } /// // ... other steps will run in parallel ... /// ["postlint"] { /// exclusive = true // wait for all previous steps to finish before starting /// check = "mise run postlint" /// } /// } /// } /// } /// ``` exclusive: Boolean = false /// Files to exclude from the step. Supports glob patterns and regex patterns. /// Files matching these patterns will be skipped. /// /// ```pkl /// // Exclude with glob patterns /// ["prettier"] { /// glob = List("**/*.yaml") /// exclude = List("*.test.yaml", "*.fixture.yaml") /// check = "prettier --check {{files}}" /// } /// /// // Exclude with regex pattern for complex matching /// ["linter"] { /// glob = List("**/*") /// exclude = Types.Regex(#""" /// (?x) /// ^(vendor|dist|build)/.*$| /// .*\.(min|bundle)\.(js|css)$| /// .*\.generated\.(ts|js)$ /// """#) /// check = "custom-lint {{files}}" /// } /// ``` /// /// Notes: /// - Regex patterns use Rust regex syntax /// - The `(?x)` flag enables verbose mode for multi-line patterns with comments /// - Use raw strings (`#"..."#` or `#"""..."""#`) to avoid escaping backslashes exclude: (String | List | Regex) = List() /// Default: `false` /// /// If true, connects stdin/stdout/stderr to hk's execution. /// This implies `exclusive = true`. /// /// ```pkl /// local linters = new Mapping { /// ["show-warning"] { /// interactive = true /// check = "echo warning && read -p 'Press Enter to continue'" /// } /// } /// ``` interactive: Boolean = false /// If set, sends the template-expanded string to the command's stdin (mutually exclusive with `interactive`). /// Supports variable `file_list` (`list[str]`) and Tera builtins: https://keats.github.io/tera/docs/#built-ins. /// /// This is useful for tools which allow passing filenames via stdin (like xargs), to avoid hk's automatic batching. /// /// ```pkl /// local linters = new Mapping { /// ["hungry-command"] { /// stdin = " {{ files_list | join(sep='\n') }}" /// check = "xargs my-command" /// } /// } /// ``` stdin: String? /// If set, the jobs in this step will only run if this condition evaluates to true. /// This is evaluated per step-job (e.g. multiple times if hk batches, or if there are multiple workspaces) /// Evaluated with [`expr`](https://github.com/jdx/expr-rs). /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] { /// condition = "exec('test -f check.js')" /// } /// } /// ``` condition: String? /// If set, the step will only run if this condition evaluates to true. /// Evaluated once (regardless of batching or workspaces). /// Evaluated with [`expr`](https://github.com/jdx/expr-rs). /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] { /// condition = "exec('test -f check.js')" /// } /// } /// ``` step_condition: String? /// Default: `false` /// /// If true, the step will be hidden from output. /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] { /// hide = true /// } /// } /// ``` hide: Boolean = false /// Default: `"stderr"` /// /// Controls which stream(s) from the step’s command are captured and printed /// at the end of the hook run. This prints a single consolidated block per step /// that produced any output, with a header like `STEP_NAME stderr:`. /// /// - `"stderr"` (default): capture only standard error /// - `"stdout"`: capture only standard output /// - `"combined"`: capture both stdout and stderr interleaved (line-by-line as produced) /// - `"hide"`: capture nothing and print nothing for this step /// /// Examples: /// /// ```pkl /// hooks { /// ["check"] { /// steps { /// ["lint"] { /// check = "eslint {{files}}" /// output_summary = "combined" /// } /// ["format"] { /// check = "prettier --check {{files}}" /// output_summary = "stdout" /// } /// ["quiet-step"] { /// check = "echo noisy && echo warn 1>&2" /// output_summary = "hide" /// } /// } /// } /// } /// ``` output_summary: "stdout" | "stderr" | "combined" | "hide" = "stderr" /// Parse command output into normalized diagnostics for structured output and SARIF export. diagnostic_format: DiagnosticFormat? /// Tool name recorded on normalized diagnostics. Defaults to the step name. diagnostic_tool: String? /// Environment variables specific to this step. /// These are merged with the global environment variables. /// /// ```pkl /// local linters = new Mapping { /// ["prettier"] { /// env { /// ["NODE_ENV"] = "production" /// } /// } /// } /// ``` env: Mapping = new Mapping {} /// Define self-contained tests for a step, runnable via `hk test`. /// /// Key points: /// /// - Mapping is keyed by test name. /// - Supported run modes: `check` or `fix` (defaults to `check`). /// - `files` is optional; if omitted, it defaults to the keys of `write`. /// - `write` lets you create files before the test runs (paths can be relative to the sandbox or absolute). /// - `fixture` copies a directory into a temporary sandbox before the test runs. /// - `env` merges with the step’s `env` (test env wins on conflicts). /// - `before` is an optional shell command to run before the test's main command. /// If it fails (non-zero exit), the test fails immediately. /// - `after` is an optional shell command to run after the main command, before evaluating expectations. /// If it fails, the test fails and reports that failure. /// - `expect` supports: /// - `code` (default 0) /// - `stdout`, `stderr` substring checks /// - `files` full-file content assertions /// /// Template variables available in tests are the same as for steps, plus: /// /// - `{{files}}`, `{{globs}}`, `{{workspace}}`, `{{workspace_indicator}}` /// - `{{root}}`: project root /// - `{{tmp}}`: sandbox path used to execute the test /// /// Example: /// /// ```pkl /// hooks { /// ["check"] { /// steps { /// ["prettier"] { /// check = "prettier --check {{ files }}" /// fix = "prettier --write {{ files }}" /// tests { /// ["formats json via fix"] { /// run = "fix" /// write { ["{{tmp}}/a.json"] = "{\"b\":1}" } /// // files omitted -> defaults to write keys /// expect { files { ["{{tmp}}/a.json"] = "{\n \"b\": 1\n}\n" } } /// } /// ["check shows output"] { /// run = "check" /// files = List("{{tmp}}/a.json") /// env { ["FOO"] = "bar" } /// expect { stdout = "prettier" } /// } /// ["before generates file, after verifies contents"] { /// run = "fix" /// // before: generate an input file the step will process /// before = #"printf '{\"b\":1}' > {{tmp}}/raw.json"# /// // files: tell hk which file the step should operate on /// files = List("{{tmp}}/raw.json") /// // after: verify the contents using a shell assertion /// after = #"grep -q '\"b\": 1' {{tmp}}/raw.json"# /// // expect: full-file match after formatting /// expect { files { ["{{tmp}}/raw.json"] = "{\n \"b\": 1\n}\n" } } /// } /// } /// } /// } /// } /// } /// ``` /// /// Run tests with: /// /// ```bash /// hk test # all tests /// hk test --step prettier # only prettier's tests /// hk test --name formats json via fix /// hk test --list # list without running /// ``` tests: Mapping = new Mapping {} } class StepTest { /// What to run: "check", "fix", or "command" run: "check" | "fix" = "check" /// Files to pass to the command (used to render {{ files }}) files: (String | List)? /// Optional path to copy into a temp sandbox before running fixture: String? /// Inline files to write into the sandbox write: Mapping = new Mapping {} /// Command to run before executing the test command before: String? /// Extra environment variables for this test env: Mapping = new Mapping {} /// Command to run after the main command, before expectations (cleanup or extra verification) after: String? /// Whether to run in a temporary directory sandbox /// If true, always use a sandbox; if false, always use repo root /// If not specified, auto-detects based on whether files reference {{tmp}} tmpdir: Boolean? /// Expectations for the test result expect: StepTestExpect = new StepTestExpect {} } class StepTestExpect { /// Expected exit code (default 0) code = 0 /// Substring that must appear in stdout stdout: String? /// Substring that must appear in stderr stderr: String? /// Expected files after run: path => full contents (exact match) files: Mapping = new Mapping {} } class Group { /// Working directory inherited by child steps that do not set `dir`. /// /// This is copied to each child step as a default; child step values override it completely. dir: String? /// Command prefix inherited by child steps that do not set `prefix`. /// /// This is copied to each child step as a default; child step values override it completely. prefix: (String | List)? /// Workspace indicator inherited by child steps that do not set `workspace_indicator`. /// /// This is copied to each child step as a default; child step values override it completely. workspace_indicator: String? /// Shell inherited by child steps that do not set `shell`. /// /// This is copied to each child step as a default; child step values override it completely. shell: (String | Script)? /// Staging globs inherited by child steps that do not set `stage`. /// /// This is copied to each child step as a default; child step values override it completely. /// Group and step values are never merged. stage: (String | List)? /// Exclude patterns inherited by child steps that do not set `exclude`. /// /// This is copied to each child step as a default; child step values override it completely. /// Group and step values are never merged. exclude: (String | List | Regex)? /// Child steps in this group. steps: Mapping = new Mapping {} } output { renderer { converters { [Regex] = (r) -> new Mapping { ["_type"] = "regex" ["pattern"] = r.pattern } [Group] = (g) -> new Dynamic { _type = "group" ...g .toMap() .mapValues((k, v) -> if (k == "stage" && v is String) List(v) else v ) } [Step] = (s) -> new Dynamic { _type = "step" ...s .toMap() .mapValues((k, v) -> if ((k == "depends" || k == "stage") && v is String) List(v) // permits "s" instead of List("s") else if (k == "stash" && v is Boolean) if (v) "git" else "none" else v ) .toDynamic() } } } }