Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

criv documentation

Browse the documentation, architecture decisions, and repository guides.

Architectural Decisions

Accepted decisions live in this directory as MADR-style notes named NNNN-kebab-title.md.

Index

  • ADR-0001 - Local CLI vault architecture
  • ADR-0002 - Docs and ADRs form the governance graph
  • ADR-0003 - Adopt proven foundation crates
  • ADR-0004 - Tree-sitter source graph
  • ADR-0005 - ast-grep policy search and enforcement
  • ADR-0006 - fff source index and incremental watch
  • ADR-0007 - Content-addressed state and diffing
  • ADR-0008 - Optional semantic note search
  • ADR-0009 - Obsidian plugin as state consumer
  • ADR-0010 - criv init installs agent runtime skills
  • ADR-0011 - Embed runtime skill templates as assets
  • ADR-0012 - ADR immutability enforcement
  • ADR-0013 - Mise managed hk hook toolchain
  • ADR-0014 - Tag triggered release binary workflow
  • ADR-0015 - Size optimized release profile
  • ADR-0016 - Conventional commit SemVer release automation
  • ADR-0017 - Deprecate Intel macOS release archives
  • ADR-0018 - Offline zizmor Actions security check
  • ADR-0019 - Export Usage CLI spec and help
  • ADR-0020 - Portable note wikilinks
  • ADR-0021 - Audit remediation boundaries
  • ADR-0022 - Hosted CI entry point
  • ADR-0023 - Do not track generated plugin bundles
  • ADR-0024 - Oxlint only JavaScript TypeScript enforcement
  • ADR-0025 - Init ships plugin source not generated bundle
  • ADR-0026 - Mermaid C4 diagrams as vault content
  • ADR-0027 - C4 diagram fenced block extraction scope
  • ADR-0028 - C4 standard alignment for Mermaid diagrams
  • ADR-0029 - Generated C4 Code architecture
  • ADR-0030 - DOT for generated Code architecture
  • ADR-0031 - Text first C4 architecture formats
  • ADR-0032 - .c4 files as architecture artifacts
  • ADR-0033 - Typed Wikilink source references
  • ADR-0034 - AST-aware source selectors
  • ADR-0035 - VS Code-compatible companion extension
  • ADR-0036 - VS Code extension test stack
  • ADR-0037 - VS Code JSON diagnostics in hooks
  • ADR-0038 - ADR Policy Rule Generation
  • ADR-0039 - Inline ADR Policy Rules
  • ADR-0040 - Inline Only ADR Policy Rules
  • ADR-0041 - ADR-Owned Policy Patterns
  • ADR-0042 - Shared Source Index Lifecycle
  • ADR-0043 - Hawk visibility analysis
  • ADR-0044 - Vault write confinement
  • ADR-0045 - Note line identity in generated state
  • ADR-0046 - No native linting in criv enforce
  • ADR-0047 - Semantic note search stays source only
  • ADR-0048 - 2026-07-25 audit findings not actioned
  • ADR-0049 - Checks defined in hk, not mise
  • ADR-0050 - Repeated Wikilinks are not a defect
  • ADR-0051 - Refresh generated agent skills explicitly
  • ADR-0052 - Harden generated skill refresh
  • ADR-0053 - Claude skills is a link
  • ADR-0054 - criv does not install Git hooks
  • ADR-0055 - Dependency auditing in hk checks
  • ADR-0056 - ADR policy patterns are the only registered patterns
  • ADR-0057 - Branch-local ADR publication and reconciliation
  • ADR-0058 - Embedded Git repository access
  • ADR-0059 - Accepted only ADR policy state
  • ADR-0060 - Parallel hosted validation and lean local hooks
  • ADR-0061 - Hook-owned local validation and direct CI profile
  • ADR-0062 - Canonical generated criv configuration
  • ADR-0063 - ADR reconciliation owns its commit
  • ADR-0064 - Typed query subcommands
  • ADR-0065 - Shared compiled policy scan plans
  • ADR-0066 - Capability-directed Wikilink and query resolution
  • ADR-0067 - Staged changes are a partial check scope
  • ADR-0068 - Bounded local snapshot lifecycle
  • ADR-0069 - Repeatable two tier performance evidence
  • ADR-0070 - Publish push performance evidence as Git notes
  • ADR-0071 - Make Wasm editor projections canonical
  • ADR-0072 - Keep performance observation outside core
  • ADR-0073 - Effective ADR governance and source reconciliation
  • ADR-0074 - LikeC4 as the architecture source and renderer
  • ADR-0075 - LikeC4 preview as the default C4 editor
  • ADR-0076 - Focused LikeC4 workspace navigation
  • ADR-0077 - C4 standard alignment for the LikeC4 workspace
  • ADR-0078 - The editor follows preview navigation
  • ADR-0079 - No fallback view in the C4 preview
  • ADR-0080 - Co-locate primary LikeC4 views with their models
  • ADR-0081 - Require material State store performance gains
  • ADR-0082 - No standalone search command
  • ADR-0083 - Own one loaded State revision per editor workspace
  • ADR-0084 - Require Windows hosted validation
  • ADR-0085 - Keep the optional editor viewer local
  • ADR-0086 - Bundle one editor viewer with criv
  • ADR-0087 - Keep editor setup out of init
  • ADR-0088 - Share the State wire document
  • ADR-0089 - Reconcile effective query and LikeC4 contracts
  • ADR-0090 - Enforce runtime boundary decisions
  • ADR-0091 - Enforce editor adapter boundaries
  • ADR-0092 - Transactional live watch generations
  • ADR-0093 - Recoverable State publication transactions
  • ADR-0094 - Automatic recoverable State publication
  • ADR-0095 - Operating-system watch session lock
  • ADR-0096 - Enforce editor preview revision lifecycle
  • ADR-0097 - Explicit source target lookup results
  • ADR-0098 - Own the latest VS Code check result
  • ADR-0099 - Enforce the shared LikeC4 and Wasm adapter boundary
  • ADR-0100 - Agent-authored language-independent C4 architecture
  • ADR-0101 - One reconciliation transaction owner
  • ADR-0102 - Request local query reverse indexes
  • ADR-0103 - Required repository self governance
  • ADR-0104 - Split the Obsidian host by lifecycle owner
  • ADR-0105 - Use owner-scoped Rust module layout
  • ADR-0106 - Enforce current runtime module paths
  • ADR-0107 - Group Obsidian host files by owner scope
  • ADR-0108 - Bounded hosted Rust compilation
  • ADR-0109 - Drop removed architecture configuration compatibility
  • ADR-0110 - Enforce runtime paths without architecture compatibility
  • ADR-0111 - File discovery compatibility contract
  • ADR-0112 - Direct ignore file discovery
  • ADR-0113 - Enforce runtime paths after file discovery replacement
  • ADR-0114 - Reconcile file discovery source scopes
  • ADR-0115 - Build Source catalog and graph from one read
  • ADR-0116 - Run release acceptance on the controlled local computer
  • ADR-0117 - Hosted automatic release acceptance
  • ADR-0118 - Bound hosted Source release scaling
  • ADR-0119 - First class Elixir language support
  • ADR-0120 - Reset the release baseline for default Elixir support
  • ADR-0121 - Separate performance evidence from automatic release
  • ADR-0122 - Use byte spans and LSP diagnostic ranges
  • ADR-0123 - Own reconciliation under the ADR module
  • ADR-0124 - Own C4 implementations under the C4 module
  • ADR-0125 - Own installation implementations under the Install module
  • ADR-0126 - Own one Source state per refresh
  • ADR-0127 - Own repository files behind one interface
  • ADR-0128 - Enforce runtime paths through Repository Files
  • ADR-0129 - Own Elixir graph meaning in the Elixir module
  • ADR-0130 - Share structured Source identity text
  • ADR-0131 - Publish verified documentation assets for native previews
  • ADR-0132 - Use aqua with a mise alias and GitHub fallback
  • ADR-0133 - Use exact lowercase documentation asset extensions
  • ADR-0134 - Parse the CLI with Usage
  • ADR-0135 - Answer agents with codes, fixes, and next commands
  • ADR-0136 - Retire the util module
  • ADR-0137 - Link creation guard follows the helper
  • ADR-0138 - Confine repository reads
  • ADR-0139 - Name the glob module for what it holds
  • ADR-0140 - Own the complete ADR change decision
  • ADR-0141 - Own ordinary refresh checks in the Watch generation
  • ADR-0142 - Use mbx for local Cargo caching
  • ADR-0143 - Lint docs prose with Vale
  • ADR-0144 - Apply strict lints across the workspace
  • ADR-0145 - Gate Rust dependencies with cargo-deny
  • ADR-0146 - Type diagnostic codes, graph kinds, and hash inputs

Domain Docs

How the engineering skills should consume this repo’s domain documentation when exploring the codebase. This is a single-context repo.

Before exploring, read these

  • CONTEXT.md at the repo root, if it exists.
  • docs/adr/ — read ADRs that touch the area you are about to work in. They are the primary record of how criv behaves and why. There are too many to read linearly; use the queries below to find the ones that govern your files.
  • AGENTS.md at the repo root — verification commands and repo conventions.

If CONTEXT.md doesn’t exist, proceed silently. Don’t flag its absence and don’t suggest creating it upfront. The /domain-modeling skill creates it lazily when terms actually get resolved.

File structure

/
├── AGENTS.md
├── docs/
│   ├── adr/                ← decisions, criv-governed
│   ├── agents/             ← this configuration
│   ├── architecture/       ← agent-authored LikeC4 workspace
│   └── query-reference.md
└── src/

Finding the right ADR

docs/adr/ is large enough that reading it linearly is the wrong move. Use criv:

criv query governing src/watch.rs      # which ADRs govern this file
criv query governs ADR-0007            # what an ADR controls
rg -n -i "<topic>" docs/               # search note text

Remove inactive worktrees

Run git worktree list before and after work that uses a separate worktree. Remove a clean worktree with wt remove as soon as its work is complete or the worktree is no longer active. Do not keep a research worktree only as a branch backup. Use wt remove --no-delete-branch <branch> when an unmerged branch must remain available. Never remove a dirty worktree without first preserving its changes.

ADR conventions specific to this repo

  • Accepted ADRs are immutable under ADR-0012. Never edit, delete, or rename one to change a decision. Write a new ADR with supersedes: instead.
  • Get the next number from criv query next-adr-id, never by guessing.
  • ADRs carry governs: path globs, and may carry inline policy.patterns ast-grep rules that criv check and criv enforce evaluate.
  • Everything under docs/ is a criv vault note and needs frontmatter with at least id, kind, and title, or criv check fails and blocks the commit.

Use the project’s vocabulary

When your output names a domain concept — an issue title, a refactor proposal, a hypothesis, a test name — use the term the ADRs already use. Don’t drift to synonyms.

If the concept you need isn’t named anywhere yet, that’s a signal: either you’re inventing language the project doesn’t use (reconsider) or there’s a real gap (note it for /domain-modeling).

Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:

Contradicts ADR-0007 (content-addressed state) — but worth reopening because…

Because accepted ADRs are immutable here, “reopening” concretely means writing a superseding ADR, not amending the old one.

Issue tracker: GitHub Issues

Issues and specs (you may know a spec as a PRD) for this repo live in GitHub Issues on TudorAndrei/criv, reached through the gh CLI.

When a skill says “publish to the issue tracker”

gh issue create --title "<title>" --body "<body>" --label "<label>"

When a skill says “fetch the relevant ticket”

gh issue view <number>
gh issue view <number> --comments
gh issue list --label ready-for-agent

The user will normally pass the issue number or URL directly.

When a skill says “comment on the ticket”

gh issue comment <number> --body "<body>"

PRs as a request surface

Off. Skills read and write issues only; incoming pull requests are not part of the triage queue. Flip this section on if that changes.

There is no second tracker

GitHub Issues is the only issue tracker for this repository. An ISSUES.md file at the repo root previously held a hand-curated audit index; it was migrated to GitHub Issues on 2026-07-30 and deleted. Do not recreate it, and do not add a parallel findings file — a second list drifts from the first.

Issues migrated from it carry a footer naming their original ISSUES.md number, because ADRs and commit messages written before the migration refer to those numbers.

Issue quality

The migrated issues set the bar for a substantial finding: a one-line statement of the defect, then Evidence with file:line citations, Impact, a Fix sketch, and Verification. Match that shape when filing something non-trivial. A finding without evidence someone else can check is not ready to file.

Deliver a ticket batch to main

Do not push each completed ticket directly to main. Keep one scoped Conventional Commit for each ticket on one integration branch. Run the required checks for the complete batch, then merge or push the batch to main one time.

Each successful CI run for a push to main starts the Automatic release workflow. That workflow permits one active run and one pending run. If a new run enters the same concurrency group, GitHub cancels the older pending run and keeps the newest pending run. The cancel-in-progress: false setting protects the active run only.

Do not start another normal delivery to main while an automatic release is active. Wait for the release to finish. An urgent repair can be an exception, but state the reason before the push.

Closing an issue

Terminal outcomes are decisions, and decisions in this repository live in docs/adr/. See docs/agents/triage-labels.md for the rule: closing an issue as done or as wontfix requires an ADR when it settles a question about how criv behaves.

Triage Labels

The skills speak in terms of five canonical triage roles. This file maps those roles to the label strings used in this repo’s GitHub Issues.

Label in mattpocock/skillsLabel in our trackerMeaning
needs-triageneeds-triageMaintainer needs to evaluate this issue
needs-infoneeds-infoWaiting on reporter for more information
ready-for-agentready-for-agentFully specified, ready for an AFK agent
ready-for-humanready-for-humanRequires human implementation
wontfixwontfixWill not be actioned

When a skill mentions a role (e.g. “apply the AFK-ready triage label”), use the corresponding label string from this table.

All five labels exist in the repository. Create none of them; gh label create fails on a label that is already there.

The rest of the taxonomy

Two more label families are in use, and a new issue should carry one from each.

FamilyLabels
priority:priority:high, priority:medium, priority:low, priority:very-low
scope:scope:adr, scope:ci, scope:cli, scope:init, scope:obsidian, scope:performance, scope:policy, scope:source, scope:state, scope:testing, scope:tooling, scope:vscode

Read the live list with gh label list before applying one, because the families grow.

Terminal states are decisions

This repository records decisions as ADRs under docs/adr/, and the two terminal triage outcomes are decisions.

Closing as done. When the work settled a question about how criv behaves — what a command does, what it refuses to do, what a generated artifact looks like — write an ADR before closing. Pure implementation of an already-decided behavior does not need one; changing or establishing a behavior does.

Closing as wontfix. A wontfix is a decision not to do something, and the reasoning is worth more than the label. Write an ADR recording what was proposed and why it was rejected, then close with a link to it.

Both cases follow the normal ADR workflow:

  • criv query next-adr-id for the number; never guess it.
  • Required frontmatter is id, kind: decision, title, status, date.
  • Use governs: for the path globs the decision controls.
  • Accepted ADRs are immutable under ADR-0012. To change a past decision, write a new ADR with supersedes: pointing at the old one — never edit it.
  • Run criv check before finishing.

An issue closed as done or wontfix should link the ADR that settled it, so the reasoning survives the issue being closed.

criv.toml configuration reference

criv.toml sits at the repository root. Every key has a default, so the file can be empty, and criv init writes a working one.

The CLI is the reference for commands and flags, as ADR-0134 decided. Configuration keys have no --help surface, so they are described here.

[vault]

KeyTypeDefaultMeaning
docsstringdocsThe directory that holds vault notes.
adrstringadrThe decision directory, relative to docs.

Both paths stay inside the repository. An absolute path or a .. component is refused.

[vault]
docs = "documentation"
adr = "decisions"

[source]

KeyTypeDefaultMeaning
rootsarray of strings["src", "lib"]Directories criv indexes for the source graph.
excludearray of glob strings["**/target/**", "**/node_modules/**"]Paths the source walk skips.

An exclude glob that matches a directory prunes the whole subtree, so a large build directory costs nothing to skip.

[source]
roots = ["src", "crates"]
exclude = ["**/target/**", "**/node_modules/**", "**/generated/**"]

[index]

KeyTypeDefaultMeaning
sourcebooleantrueWhether to build the source graph at all.

Set source = false for a documentation-only vault. Every criv query that needs source data then returns nothing.

[state]

KeyTypeDefaultMeaning
keepinteger20How many state snapshots to retain under .criv/snapshots/.

keep must be one or more. See the state reference for what a snapshot holds.

[enforce]

KeyTypeDefaultMeaning
stagesarray of strings["commit", "push", "ci"]Which stages criv enforce will run.

criv enforce --stage <name> fails when the stage is not listed. Remove a stage to disable its gate without uninstalling the hook.

[enforce]
stages = ["commit", "ci"]

enforce.import_policies

An import policy fails the enforcement gate when a file in scope imports something in deny. Each entry needs all three keys.

KeyTypeMeaning
idstringThe identifier reported with the violation.
scopearray of glob stringsFiles the policy applies to.
denyarray of stringsImport targets the scope must not use. A plain string matches the module or any child of it, so tokio also denies tokio::net. A glob matches the module path with :: written as /.
enforce.import_policies
id = "domain-stays-pure"
scope = ["src/domain/**"]
deny = ["reqwest", "tokio/net/*"]

A match reports the import-policy-violation code, and the repair is either to remove the import or to widen the policy. criv enforce --stage ci --format json carries the code and the repair as fields.

Removed keys

[patterns.*] is refused. Persistent named patterns belong in an ADR policy.patterns entry, addressed as ADR-NNNN/local-id. criv reports the replacement when it finds the old table.

Dependency evaluations

These evaluations cover foundation crates that are useful but not required for the current parser-backed implementation.

miette

Decision: defer.

miette is a good fit for a future human diagnostic renderer because it provides source snippets, labels, related diagnostics, and optional fancy reports. ADR-0122 defines the source-span contract, but it keeps renderer selection separate. Revisit miette after the main diagnostic producers preserve exact spans and a renderer change shows enough user value to justify the dependency.

Reference: https://docs.rs/miette/latest/miette/struct.SourceSpan.html

infer

Decision: use for the bounded documentation asset inventory.

infer detects file types from magic-number signatures and returns MIME and extension metadata. ADR-0131 requires the CLI to verify the file signature and the extension before it adds an asset to State. infer supplies this narrow check. content_inspector continues to classify Source text, and mime_guess continues to supply Source MIME hints. The asset inventory does not use either dependency as a security check.

Reference: https://lib.rs/crates/infer

serde_yaml_ng

Decision: keep serde_norway; do not add serde_yaml_ng now.

serde_yaml_ng is a serde-yaml fork and a viable alternate YAML backend, but the current implementation already replaced deprecated serde_yaml with serde_norway. Keep a single YAML parser until frontmatter compatibility tests show a concrete gap.

Reference: https://lib.rs/crates/serde_yaml_ng

camino

Decision: defer until repo-relative path APIs are refactored.

camino provides UTF-8 path types that avoid repeated lossy conversions. That matches criv’s repo-relative path invariant, but adopting it cleanly should be done as a focused path-type refactor across config, vault, state, search, and query modules.

Reference: https://docs.rs/camino

Cargo Audit Snapshot, 2026-07-23

Decision: document and monitor; do not add a failing cargo audit gate yet.

The pinned cargo-audit v0.22.2 command was run as cargo audit --no-fetch. It loaded 1,169 advisories from the local advisory database at commit 1abf7a8c1822223a38e99f652bc232071c44a86d (2026-07-23 09:15:03 +02:00) and scanned 461 locked packages. It reported four allowed warnings, all listed below. None was classified as a vulnerability by this run.

This is a dated, local posture snapshot rather than a hosted-audit baseline: --no-fetch intentionally does not update the advisory database, and the command warned that it could not open the crates.io index cache lock. A future policy gate needs a reproducible advisory-database update path before it can be relied on in CI.

Unsound APIs: git2 v0.20.4

RUSTSEC-2026-0183 reports potential undefined behavior when Remote::list() is called, and RUSTSEC-2026-0184 reports potential undefined behavior for a Signature obtained from a buffer-created BlameHunk. Both affect git2 v0.20.4, which reaches criv only through fff-search v0.10.1:

git2 v0.20.4 <- fff-search v0.10.1 <- criv

criv directly depends on git2 v0.21.0 with default features disabled for local repository discovery, tree/index/worktree diffs, commit traversal, and blob reads. It does not create transports or invoke the advisory APIs. The older git2 v0.20.4 remains an independent transitive dependency of fff-search; cargo tree -i git2@0.21.0 and cargo tree -i git2@0.20.4 distinguish the two paths.

The locally installed fff-search v0.10.1 source was inspected. Its Git path uses Repository::open, status enumeration, workdir, and status_file; a source search found no invocation of Remote::list, BlameHunk, or blame APIs. That is evidence that the two advisory call paths are not reached by the currently inspected source, not proof that text search alone can rule out every runtime path or upstream behavior.

Unmaintained crate: bincode

RUSTSEC-2025-0141 marks bincode v1.3.3 unmaintained. Its active default dependency path is:

bincode v1.3.3 <- heed-types v0.21.0 <- heed v0.22.1 <- fff-search v0.10.1 <- criv

Policy conclusion

The monitor-only decision is unchanged. The current findings are two unmaintained crates and two potentially unsound-but-unreached APIs, not a new vulnerability classification or demonstrated runtime exploit path. Do not add an audit ignore list or failing gate, and do not replace fff-search, without a separate approved decision. The embedded repository backend is governed by its own ADR; accepted audit-policy ADRs remain unmodified.

ADR-0055 subsequently runs this same command as a visible, non-blocking hk monitor. It does not change this Rust decision or make cargo audit a failing gate.

Embedded Git backend measurement, 2026-08-02

git2 v0.21.0 is a direct MIT OR Apache-2.0 dependency with default features disabled. Its native libgit2-sys v0.18.7+1.9.6 dependency has the same license expression. The resolved graph deliberately contains both git2 v0.21.0 for criv’s local repository boundary and git2 v0.20.4 through fff-search; Cargo resolves both wrappers onto the same libgit2-sys version.

Same-toolchain, clean, size-optimized release builds on this macOS host measured the main binary at 12,702,432 bytes in 1:14.62 and this branch at 12,737,152 bytes in 1:59.65. The embedded backend therefore adds 34,720 bytes (0.27%). The branch build is slower by 45.03 seconds in this cold local comparison; this is recorded for release review, not treated as a release blocker.

cargo audit --no-fetch on 2026-08-02 reported the same four allowed warnings: the two git2 v0.20.4 advisory paths and the existing bincode and paste maintenance warnings. It reported no advisory for direct git2 v0.21.0.

Evidence commands:

cargo audit --no-fetch
cargo metadata --format-version 1 | jq -r '.packages[] | select(.name == "git2" or .name == "libgit2-sys") | "\(.name) \(.version): \(.license // "NOASSERTION")"'
cargo tree -i git2@0.21.0
cargo tree -i git2@0.20.4
cargo tree -i bincode@1.3.3
cargo tree --all-features --target all -e features -i paste@1.0.15

File discovery resolution, 2026-08-16

ADR-0112 removes fff-search and its transitive git2 v0.20.4, heed, and bincode v1.3.3 path. Source, Vault, and Markdown selection now use the existing pure-Rust ignore, globset, and content_inspector dependencies. Direct git2 v0.21.0 remains the embedded repository boundary.

The dated audit and build measurements above remain historical evidence. Their instruction to keep fff-search was superseded only after the separate file- discovery contract, implementation decision, compatibility corpus, and release gates were accepted.

Blocking Rust dependency gate, 2026-10-07

ADR-0145 replaces the monitor-only cargo audit step with a blocking cargo deny step. On 2026-10-07, cargo deny --locked check loaded 1,294 advisories and reported advisories ok, bans ok, licenses ok, sources ok.

Release binaries are built with cargo auditable. A local release build had 158 dependencies embedded, and cargo audit bin read them.

Evidence commands:

cargo deny --locked check
cargo auditable build --locked --release --package criv --bin criv
cargo audit bin target/release/criv

Elixir source support

criv reads .ex and .exs files that are below the configured Source roots. The criv binary contains the Elixir Tree-sitter grammar. It does not start Elixir, Mix, or the BEAM. It does not read a Mix project to find more roots.

Files with .eex and .heex extensions are not Elixir source files. criv can select them as text when they are below a Source root, but it does not create Elixir symbols from them.

Source graph

The Elixir adapter records these module forms:

  • modules, protocols, and protocol implementations;
  • structs and exceptions;
  • behaviours and their function or macro callbacks.

It records public and private functions, macros, guards, delegates, clauses, default arities, guards, and specifications. One named callable uses its module, kind, name, and arity as its identity. For example:

lib/my_app.ex#module:My.App
lib/my_app.ex#module:My.App/fn:run/2
lib/my_app.ex#module:My.App/macro:build/1
lib/my_app.ex#module:My.App/guard:valid/1
lib/protocol.ex#impl:Enumerable/for:My.App/fn:reduce/3

Several clauses with the same name and arity have one identity. A default argument can create more than one arity.

Old short targets continue to work when they identify one symbol:

lib/my_app.ex#run
lib/my_app.ex#run/2
lib/my_app.ex#My.App.run/2

criv does not select one result when a short target has two valid meanings. Use the complete canonical selector in that case.

Directives and calls

criv records alias, import, require, and use directives. It resolves literal aliases, __MODULE__, static imports, remote calls, local calls, pipelines, named captures, literal apply/3 calls, and literal delegates. A pipeline adds its left value to the target arity.

Some Elixir behavior is available only after code runs or a macro expands. criv records these sites as dynamic when it cannot get one static target. This includes function-value calls, computed module names, general apply/3 calls, and code that a use macro injects.

If one syntax region is invalid, criv keeps safe sibling declarations. It does not claim symbols from an unsafe region.

Policy and editor names

Use language: elixir or language: ex in an ADR policy. Both names scan .ex and .exs files. State uses text/x-elixir as the MIME value, and both editors use elixir as the language name.

Elixir source support does not include EEx or HEEx analysis, framework analysis, Mix discovery, macro expansion, BEAM inspection, formatting, or linting. It also does not model named type declarations or general module-body execution.

The complete behavior and default grammar dependency are in ADR-0119. The current release model is in ADR-0121.

LikeC4 Integration

ADR-0100 defines the architecture contract. This document gives the implementation specification.

Source and ownership

One vault has one LikeC4 workspace at docs/architecture/. All architecture source files use the .c4 extension. LikeC4 owns the language grammar, model rules, layout, and visual output. The coding agent writes the source. It chooses clear element names, responsibilities, relationships, and view titles. criv does not create architecture source from source files, modules, imports, or programming languages.

The workspace can contain agent-authored models at every useful C4 level. The complete workspace describes one software architecture. Each view tells one focused story at one level. A title names the level and the system, container, component, or workflow in scope. A Code view is optional and zooms into one important component. It does not group the repository by programming language.

This repository keeps agent-authored Code models and their focused views under docs/architecture/code/. Each view explains one component or workflow instead of copying the complete source index.

A Code model must stay a true roll-up of the component model. If a module in component A imports a module in component B, the component model must also show a relationship from A to B. Cross-cutting helper modules, re-export barrels, and bundler shims stay outside the architecture; name them in a comment at the top of the Code model file.

The workspace has one LikeC4 project. LikeC4 merges every source file into one model. A domain file declares its elements and relationships once and also owns the primary named views that explain that domain. A large Code domain can own more than one focused view. Cross-domain runtime workflows stay in separate view files. This ownership lets an editor select the correct preview from the opened file path without copying model declarations.

docs/architecture/
  specification.c4        element kinds, tags, and global style groups
  systems.c4              people, systems, and the System Context view
  cli.c4                  CLI components and their primary view
  interactions.c4         cross-container relationships and Container view
  deployment.c4           deployment nodes and Deployment views
  obsidian.c4             Obsidian components and their primary view
  vscode.c4               VS Code components and their primary view
  shared-renderer.c4      renderer components and their primary view
  state-projection.c4     WebAssembly components and their primary view
  code/                   selected component implementations and Code views
  views/dynamic/          cross-domain runtime sequences

Scoped views provide System Context to Container to Component navigation. Selected containers and components use explicit navigateTo targets. View title paths group diagrams under Overview, Components, Code, Dynamic, and Deployment.

Two rules keep the levels readable. External people and systems carry the external tag, and every view greys them, so the system boundary is visible. A relationship label starts with a capital letter and a present-tense verb, and does not end with a preposition; it carries a technology when it crosses a process, a language, or a storage boundary.

Hosting belongs to the deployment model. A container diagram shows what a container depends on. It does not show which application process contains it.

The source graph can show modules, symbols, calls, and imports as evidence for the coding agent. These facts do not become C4 elements by themselves. The agent selects the architecture boundary, gives each selected element a useful name and responsibility, and links it to a stable source file or symbol. External package imports do not create placeholder nodes.

CLI validation flow

When the vault has at least one .c4 file, src/c4/likec4.rs starts the embedded assets/likec4-bridge.mjs program with the local Node.js command. The bridge resolves the local likec4 package. It does not use a global LikeC4 command and does not download a package.

The exact runtime contract is:

ItemRequired value
Node.js26.5.1
LikeC41.59.2
React19.2.8
React DOM19.2.8
Bridge protocol1
State schemacriv.state.v1
Process limit60 seconds
Standard output limit16 MiB
Standard error limit16 MiB

The bridge uses LikeC4.fromWorkspace, getErrors, and layoutedModel. It returns one JSON response with the exact runtime versions, revision, errors, layout model, sorted elements, sorted relations, sorted views, and sorted source links. Rust reads standard output and standard error at the same time. This prevents a pipe deadlock when a large layout model fills an operating-system pipe.

Rust rejects a timeout, too much output, invalid JSON, a protocol difference, or any runtime version difference. LikeC4 errors use repository paths and one-based lines in criv output. A vault with no .c4 file does not need Node.js or LikeC4.

A LikeC4 element can have one criv source anchor:

link ../../src/c4/likec4.rs 'source'

The source label is case-insensitive. The target is relative to the LikeC4 workspace. The bridge changes it to a repository-relative target. criv then uses its normal source-target resolver. A missing file, line, symbol, or pattern is an invalid-likec4-source error. Other LikeC4 links stay normal links.

State and editor flow

criv watch --once writes the normalized layout model to the top-level architecture field in .criv/state.json. The field has the bridge protocol, LikeC4 version, workspace path, revision, raw layout model, elements, relations, views, and source links.

The shared packages/criv-likec4 package adapts this state to LikeC4 React. It owns model replacement, stale-revision rejection, view selection, pan, zoom, search, source-link events, disposal, and SVG export. SVG export puts the LikeC4 shadow DOM in an SVG foreignObject, so the exported file contains the same LikeC4 view and styles.

The Obsidian and VS Code packages are host adapters. They read criv state, attach a monotonic host revision, select the host color scheme, and send source link events to the host file API. Normalized view records contain LikeC4’s optional sourcePath. Both hosts use it to select the view owned by the opened file without parsing .c4 source. The shared renderer handles LikeC4 onNavigateTo events and reports the selected view to the host control. VS Code remembers that selection across state refreshes.

There is no fallback diagram. A domain file opens the named views that it owns. A shared file such as specification.c4 can own no view and shows an explicit status message. The status message points to another architecture file that declares a named view; it does not assume that every view is under views/.

VS Code registers the read-only preview as the default .c4 editor; Reopen Editor With → Text Editor exposes the DSL. The repository recommends the official likec4.likec4-vscode extension for DSL language services and maps .c4 text documents to its likec4 language ID. That extension remains optional and does not own the default preview. Neither host starts Node.js. Their installed browser bundles contain the renderer and its assets. The VS Code webview permits only its local resources under a strict content security policy.

After an agent saves a .c4 file, the normal watch task validates the complete workspace and writes a new state model. Each host replaces the old model. A response with an old revision cannot replace a new model. A closed view always disposes its React root.

Installation and CI

The root package-lock.json is the only npm lockfile. Use the pinned Node.js version and run npm ci. Normal validation can then run without network access. CI must run the Rust workspace tests, the two editor test suites, criv check, and npm audit.

The 2026-08-04 implementation measurements used Node.js 26.5.1 on an Apple arm64 development host and the repository model that existed during the test. Architecture changes can change its element, view, and source-link counts:

MeasureResult
Node.js empty-process start0.02 s
Full criv check with LikeC4 layout3.80 s
Obsidian production JavaScript2,764,888 bytes
VS Code extension JavaScript26,311 bytes
VS Code LikeC4 webview JavaScript2,294,423 bytes
Cold browser bundle transfer17.5 ms
Cold state transfer2.9 ms
Cold first LikeC4 view112.2 ms
Test SVG export188,499 bytes

The cold browser test used a local server and a new headless Chromium session. The first-view point was the first LikeC4 React Flow node in the renderer shadow root.

The lockfile license scan found no package without license data except the three local workspaces. The shared workspace now declares MIT. Production bundles keep esbuild legal comments. GPL and MPL packages in the lockfile are development tools and are not in the production renderer bundles. The npm audit after the lockfile update reports zero known vulnerabilities.

Repeatable Performance Evidence

Performance claims in criv use correctness invariants plus repeated external timing samples from an explicit release binary. One timing run on the development checkout is useful for exploration but is not project evidence.

Canonical workloads

The canonical workload set has two shapes from observed criv vaults and three deterministic Elixir acceptance shapes:

ManifestTierNotesSource filesSource bytesRelationshipsLinks and referencesPoliciesC4 artifactsChanged sources
barrs-small.tomlsmall2312273,94803001 of 12
criv-medium.tomlmedium771191,291,2180212441 of 119
elixir-mixed.tomlmedium1224524,288032111 of 24
elixir-parse-heavy.tomllarge41284,194,30408001 of 128
elixir-relationships.tomllarge41284,194,3048,1928001 of 128

The checked-in manifests retain repository revision, note split, language/file extension distribution, symbols, link categories, and exact changed fraction. Generated workload content is sanitized and deterministic; it reproduces the shape, not proprietary text or source.

The mixed Elixir workload contains .ex, .exs, Rust, and TypeScript files. The parse-heavy workload contains 96 .ex files and 32 .exs files with 8,192 callables. Every successful sample proves that each selected Elixir path and byte entered the Elixir source graph. These workloads record cost. They do not set an Elixir speed limit.

The relationship workload uses the same file, byte, and callable shape as the parse-heavy workload. Each callable makes one static remote call to another generated callable. Its manifest declares 8,192 relationships. Every source sample proves that the source graph contains this exact count. Every State publication sample also proves that State contains this exact count as calls edges. This evidence stays outside the normal validation gate.

The large representative file-discovery workload is the observed flowcopilot/ouro checkout. Evidence identifies its exact revision and full worktree inventory, including ignored generated files. The local inventory is content-addressed and is not committed because it contains repository paths. A strict APFS copy-on-write snapshot isolates each command run on the controlled macOS host. The runner resets tracked files, the Git index, its known live-test files, and generated criv State before each sample. Synthetic 9,000, 90,000, and 225,000-file trees test scaling and edge cases only. They are not representative user-project evidence.

Docker is an optional execution environment, not a workload. An explicitly invoked Testcontainers for Rust lane builds and runs the harness inside a digest-pinned image, then supplies these same manifests and generated vaults. A future observed large manifest can use that lane without making the container image, an arbitrary checked-out codebase, or container storage into the vault definition.

Required run identity

Harness runs require an explicit executable path and profile identity. Evidence records the canonical binary path and BLAKE3 digest, repository revision and dirty status, Cargo profile, rustc --version --verbose, operating system, release, architecture, processor model when available, UTC start time, harness schema, workload manifest bytes and digest, command case, declared cache state, and sample count.

Results from different binaries, workload manifests, profiles, or cache-state contracts are not combined. Machine differences remain visible metadata rather than being normalized into a claim of universal performance.

Containerized evidence additionally records the immutable image digest, Docker server identity, and container architecture. The release binary is built inside that pinned environment; a macOS or Windows host executable is never copied into a Linux container and mislabeled as the same artifact. Host and container rows are distinct machine identities and are not merged.

Sampling and isolation

Every command case declares cold or warm initial local state. A cold sample starts from a newly generated vault with no .criv/. A warm sample starts from another newly generated vault, performs its declared untimed seed command, and then measures the requested command. Each timed sample gets a separate vault; no sample inherits .criv files or generated outputs from another sample.

An untimed disposable warm-up precedes the recorded samples for each workload and case. The default sample count is five and the minimum evidence count is three. Failed commands remain raw sample rows with exit status and captured output references, make the harness fail, and are excluded from successful timing summaries rather than disappearing.

Raw and summarized evidence

Each run writes a new result directory. samples.jsonl contains one row per sample. Each row keeps identity, cache state, exit status, elapsed, user and system time, peak resident memory, selected source files and bytes, Elixir parse coverage, expected and parsed relationship counts, published State relationship counts, output digests, generated-state hashes, and snapshot hashes when present. summary.json groups compatible successful rows and reports sample count, minimum, median, maximum, and median absolute deviation without discarding raw values. report.html is a self-contained derived view with shared-scale timing ranges, exact-value tables, run identity, and workload provenance. It uses no external assets or services, and JSON remains the canonical evidence.

Cross-commit comparisons use separate result directories and freshly generated vaults. They compare rows only when workload digest, command case, cache state, sample count, profile, and machine identity match. Results are local artifacts and stay outside source control.

Core boundary

The harness observes criv only as a subprocess. Core code contains no performance environment protocol, spans, counters, or measurement artifact writer, and the harness uses the same ordinary release binary distributed to users. Process resource usage and output identities are the complete runtime observation surface.

Correctness tests may count work behind cfg(test) to prove invariants such as partition reuse or a single source enumeration. Those assertions are compiled only for tests and are not a runtime measurement API. Repeated timing samples support such claims but do not identify internal operations on their own.

Validation boundary

Performance runs are deliberately absent from normal hk hooks and hosted CI, as established by ADR-0049. Harness and generator smoke tests validate their contracts, while contributors invoke measurements explicitly through mise run perf.

The Docker/Testcontainers lane is ignored by default and has its own explicit entry point because it requires a Docker-API-compatible runtime and may need to acquire the pinned image. Testcontainers owns container startup and cleanup; the harness still owns sample isolation inside the container.

Push notes

Every successful repository push runs a separate, non-gating host measurement workflow and publishes its compact JSON summary to refs/notes/criv-performance. The GitHub job summary shows the headline values and links to a 30-day workflow artifact containing report.html alongside the complete raw result directory. The note names the artifact and report path. Fetch and inspect the durable note with:

git fetch origin refs/notes/criv-performance:refs/notes/criv-performance
git log --notes=criv-performance
git notes --ref=criv-performance show <commit>

The publisher uses the workflow’s scoped token, never force-pushes the notes ref, and retries after concurrent note updates. The job is not part of the CI aggregate or local hooks and does not require Docker.

The governing decision is ADR-0072.

Release boundary

Performance observation does not approve or block an automatic release. The Performance notes workflow records a compact host measurement after a push. Explicit mise run perf runs can use the full synthetic workload set or Ouro. The production CLI has no measurement API.

Automatic release runs deterministic quality and native archive checks. It builds one binary for each supported target, records its SHA-256 digest in release-manifest.json, verifies the archive on that target, and publishes the same bytes. It does not generate large fixtures, build a comparison baseline, or collect repeated samples. See ADR-0121 and releasing for the release sequence.

Pre-commit Manager Integration

criv does not install Git hooks or change core.hooksPath. Add criv to the hook manager already used by your repository. Run the commit commands in order:

criv watch --once
criv check --changed
criv enforce --stage commit

Add this command to a push hook:

criv enforce --stage push

Keep the commit commands serial. criv check --changed reads the state written by criv watch --once.

hk

local criv_check = new Step {
  check = "criv watch --once && criv check --changed"
}

local criv_enforce = new Step {
  check = "criv enforce --stage commit"
}

hooks {
  ["pre-commit"] {
    steps {
      ["criv-check"] = criv_check
      ["criv-enforce"] = criv_enforce
    }
  }

  ["pre-push"] {
    steps {
      ["criv-enforce"] = new Step { check = "criv enforce --stage push" }
    }
  }
}

lefthook

pre-commit:
  parallel: false
  commands:
    criv-watch:
      run: criv watch --once
    criv-check:
      run: criv check --changed
    criv-enforce:
      run: criv enforce --stage commit

pre-push:
  commands:
    criv-enforce:
      run: criv enforce --stage push

Husky

Run npx husky init, then replace .husky/pre-commit with:

criv watch --once
criv check --changed
criv enforce --stage commit

Add .husky/pre-push:

criv enforce --stage push

Python pre-commit

Add repository-local hooks to .pre-commit-config.yaml:

repos:
  - repo: local
    hooks:
      - id: criv-watch
        name: Refresh criv state
        entry: criv watch --once
        language: system
        pass_filenames: false
        stages: [pre-commit]
      - id: criv-check
        name: Check changed documentation
        entry: criv check --changed
        language: system
        pass_filenames: false
        stages: [pre-commit]
      - id: criv-enforce
        name: Enforce criv policy
        entry: criv enforce --stage commit
        language: system
        pass_filenames: false
        stages: [pre-commit]
      - id: criv-enforce-push
        name: Enforce criv push policy
        entry: criv enforce --stage push
        language: system
        pass_filenames: false
        stages: [pre-push]

Then run pre-commit install --hook-type pre-commit --hook-type pre-push.

simple-git-hooks

Add hook commands to package.json:

{
  "simple-git-hooks": {
    "pre-commit": "criv watch --once && criv check --changed && criv enforce --stage commit",
    "pre-push": "criv enforce --stage push"
  }
}

Run the manager install command after you update package.json.

See Tooling and Git Hooks for the hook-stage contract.

criv query reference

The CLI is the reference for criv query. Run these commands:

criv query --help
criv query <subcommand> --help

The help pages hold every subcommand, positional argument, flag, choice, and default. They come from the command tree in src/query.rs, so they cannot drift from the code.

Every query takes --format text, --format json, or --format ndjson, and --limit <N> to bound the answer before it is printed.

Why the CLI and not this file

criv runs in other people’s repositories. An agent working there has the installed binary and nothing else, because this file ships with the criv source and not with the tool. Documentation that only criv developers can read is not documentation of the tool.

Long help carries the detail. -h prints the short page, and --help prints the full one.

Generated reference material

To make a Markdown page or a manpage from the same command tree, pipe the exported spec into the usage CLI. README.md holds those commands and the version rule they need.

Releasing criv

The Automatic release GitHub Actions workflow owns release preparation, quality checks, native builds, verification, and publication. A successful CI run for a qualifying push to main starts it. No local computer or self-hosted runner is part of the release boundary.

The workflow uses Cocogitto to calculate the next SemVer version from Conventional Commits. It uses cargo-release to update all workspace Cargo versions, pushes one version commit, and runs the complete repository checks again on that exact commit. A push with no release change is a successful no-op. GitHub generates the release notes. The repository does not keep a generated changelog.

Preview the next automatically selected version:

mise run release-plan

Each native build lane builds the exact release commit once and uploads that binary. The package job does not rebuild it. It records the commit, version, target, archive, byte size, and SHA-256 digest in release-manifest.json. Native jobs verify each archive, the binary digest and version, one-shot State publication, and operation without a Git executable. The workflow then pushes both tags atomically, uploads a draft, adds provenance attestations, and publishes it.

Performance notes are separate, non-gating observations. Automatic release does not generate large synthetic workloads, compare a baseline, or collect repeated build samples. Use mise run perf for an explicit performance study.

Workspace and CI tests require default Elixir support to read and parse the complete selected .ex and .exs coverage set. There is no Elixir speed limit.

Release notes for this transition must state that the Elixir grammar is in the default binary. They must also state that criv reads configured .ex and .exs roots without Mix and does not support EEx, HEEx, or macro expansion.

If a run fails, use the exact prepared commit and tag shown in the run:

gh workflow run release.yml --ref main \
  -f commit=<full-release-commit> \
  -f tag=vX.Y.Z

The retry accepts an untagged prepared commit, matching tags, or a matching draft. It rejects tags that point to another commit. Current workflow tools can package binaries and the VS Code package from an older prepared release commit.

Download the exact native build artifacts from a run with:

gh run list --workflow release.yml
gh run download <run-id> \
  --pattern 'release-binary-*' \
  --dir release-binaries

The per-platform binaries and packaged release candidate remain as Actions artifacts for 90 days. Published archives, SHA256SUMS.txt, release-manifest.json, and attestations remain with the GitHub release. Download them without an Actions run ID:

gh release download vX.Y.Z --dir dist
gh attestation verify dist/criv-aarch64-apple-darwin.tar.gz \
  --repo TudorAndrei/criv

Conventional commits drive the automatic bump: fix produces a patch release, feat produces a minor release, and ! or BREAKING CHANGE: produces a major release. While criv is still in 0.y.z, Cocogitto will not automatically select 1.0.0; cut that intentionally with a manual versioned release if needed. This decision is captured in ADR-0016.

Releases remain Git-tag-only. Do not publish criv to crates.io until the CLI API, state schema compatibility policy, and installer story are stable enough to support registry consumers. The GitHub binary release is the authoritative distribution path for now.

When crates.io publishing is intentionally enabled later, confirm the package metadata first:

cargo package --workspace --allow-dirty
cargo publish --dry-run

Then run the matching cargo release command without --no-publish. Crates.io publishing remains manual for now. The prepared release commit and hosted tag publication do not publish to crates.io.

The file-discovery release removes Source frecency data from Rust and editor types and from new State output. The schema name stays criv.state.v1. New readers accept an older document that contains the extra frecency field and ignore that field. Release notes must identify this Rust source API change and the intentional file-selection corrections from ADR-0111.

Current tag names use:

  • vX.Y.Z for the root CLI crate.
  • criv-wasm-vX.Y.Z for the Wasm helper crate.

The workflow builds criv archives named by Rust target triple:

  • criv-x86_64-unknown-linux-gnu.tar.gz
  • criv-aarch64-unknown-linux-gnu.tar.gz
  • criv-aarch64-apple-darwin.tar.gz
  • criv-x86_64-pc-windows-msvc.zip

Intel macOS release archives are deprecated by ADR-0017 because the hosted Intel macOS runner is the slowest release job. Apple Silicon macOS remains the supported macOS binary target. Reintroduce criv-x86_64-apple-darwin.tar.gz only if there is measured user demand or a faster runner path.

Release assets should include SHA256SUMS.txt, GitHub build provenance attestations, and criv --version as the installer smoke test for future aqua and mise registry entries. This decision is captured in ADR-0014.

Each platform archive also includes vscode-criv.vsix next to the executable. The release workflow builds that package once and adds the same local-only viewer to every archive, as required by ADR-0087.

Release binaries use the workspace release profile in Cargo.toml: symbols are stripped, size optimization is enabled, LTO runs at link time, codegen uses one unit, and release panics abort. This keeps downloadable artifacts smaller without requiring nightly Rust or post-build binary packing. The profile decision is captured in ADR-0015.

The CLI embeds local Git repository access through its Rust dependency graph; the release artifact does not require a git executable for query-diff or enforcement repository reads. Release verification should keep a PATH-without- Git smoke test alongside the hosted core validation profile. The backend scope and dependency evidence are recorded by the embedded-repository-access ADR.

Cargo compile-time investigation

Research date: 2026-08-26.

Conclusion

Do not change the shared Cargo profiles now. First measure one cold build and one warm rebuild. The most useful local experiment is less debug information for the dev and test profiles. Keep the current hosted CI cache and job limits. They already match the current CI decision.

The release profile is not a compile-time target. It uses full LTO and one code-generation unit for small release files. Cargo states that LTO adds link time, and rustc states that one code-generation unit can compile more slowly. This is an accepted size trade-off in ADR-0015.

Current state

Cargo.toml has no dev or test profile settings. Cargo therefore uses incremental compilation, 256 code-generation units, and full debug information for normal local builds. Tests inherit the dev profile. Incremental data is stored under target/ and only applies to workspace members and path dependencies. Cargo profiles define these defaults.

Hosted CI sets CARGO_INCREMENTAL=0, limits the core Cargo chain to two jobs, uses sccache, and caches only downloaded Cargo inputs. This implements ADR-0108. Do not enable incremental compilation in those jobs. The ADR records that it does not combine well with the CI compiler-result cache.

The workspace already keeps the Docker performance test package outside the normal workspace. This avoids its dependency graph in normal workspace builds, as ADR-0108 requires.

Measurement plan

Use the same Rust toolchain, target directory, and command for each comparison. Record an empty-target result and a result after one small edit in src/.

cargo build --timings
cargo test --workspace --timings

Cargo writes an HTML timing report with per-compilation duration and concurrency data to target/cargo-timings. The report is for human use, not for machine input. Cargo build timing reports

Run this baseline before each experiment:

cargo clean
cargo build --timings
# Change one small Rust implementation file.
cargo build --timings

Do not use cargo clean in the normal edit loop. It removes the incremental data that Cargo reuses for warm rebuilds.

Ranked experiments

1. Use line tables for local debug builds

Test this root manifest setting in a separate branch:

[profile.dev]
debug = "line-tables-only"

[profile.dev.package."*"]
debug = false

[profile.debugging]
inherits = "dev"
debug = true

line-tables-only keeps file names and line numbers for workspace backtraces but omits variable and parameter information. The dependency override omits their debug information. Full debug information is the current default for dev, so this can reduce compiler and linker work. The debugging profile is an opt-in full-debug path. Cargo documents this exact pattern and its faster code generation, faster linking, and smaller target directory trade-offs. Cargo build performance guide

Accept this only if the measured cold and warm times improve and the reduced debugging detail is acceptable. This project uses Rust 1.97.1, so the setting is available.

2. Use the local sccache cache for cold rebuilds

CI already uses sccache. A developer can also set RUSTC_WRAPPER=sccache in a personal Cargo configuration. Cargo supports a rustc-wrapper command and the RUSTC_WRAPPER environment variable. Cargo build wrapper configuration

Keep this as local setup, not a repository requirement. The sccache local cache is 10 GB by default and only supports one server at a time. Concurrent servers can cause build failures. sccache local cache rules

Measure a clean rebuild after a branch switch. Do not expect a large gain for one-file warm rebuilds. Cargo incremental compilation already targets that case.

3. Build the smallest relevant package set

The workspace includes the CLI, the Wasm helper, and the performance harness. Use the CLI package for an ordinary CLI edit:

cargo test --package criv

Use cargo test --workspace before a shared-crate, manifest, or release change. Cargo supports explicit package selection, so the smaller command does not build unrelated workspace members. Cargo package selection

4. Do not reduce code-generation units before measurement

The default incremental value is already 256. More units allow more parallel code generation and can reduce compile time, but can make generated code slower. One unit can improve generated code but can compile more slowly. Cargo code-generation units rustc code-generation units

Do not change this value for dev or test unless the timing report shows a clear CPU-parallelism limit. Do not change the release value. ADR-0015 owns its size result.

Rejected changes

  • Do not add LTO to dev or test. Cargo states that LTO increases link time.
  • Do not increase opt-level to make compilation faster. Higher optimization levels can increase compiler time. Cargo optimization levels
  • Do not enable Cargo incremental compilation in hosted CI. ADR-0108 owns the selected sccache strategy.
  • Do not cache the full target/ directory in CI. ADR-0108 rejects this due to cache size and transfer time.

Decision trigger

Create an ADR before changing Cargo.toml, hk.pkl, or the hosted workflow. ADR-0015 governs the release profile. ADR-0108 governs CI compilation and caching. A local-only developer guide can change without a new ADR if it does not change those shared settings.

Cucumber Messages Report Contract Research

Date: 2026-09-13

Issue: #203. Map: #198.

Question

What is the contract of the cucumber messages report, and can one consumer read it across the JavaScript, Ruby, Java, Go, Python, and Rust implementations?

Answer

The contract is stable, versioned, and well specified. One consumer can read it, but only for three of the six implementations that the ticket names.

  • JavaScript, Ruby, and Java emit the format. The flag is the message formatter or plugin in all three.
  • Go, Python, and Rust do not emit the format. godog, behave, pytest-bdd, and cucumber-rs have no message writer. They emit the legacy Cucumber JSON format, or their own format. One third-party Python runner, pytest-bdd-ng, does emit envelopes.
  • A consumer that reads the format works the same for all three emitters, with one exception. sourceReference holds a file path and a line for JavaScript and Ruby, but holds a Java class and method for Java. A step-to-file-and-line edge is therefore not portable.
  • No envelope holds a scenario result. The consumer must collect the step results of a test case and select the most severe one.

The reviewed version is cucumber messages 34.2.1, released 2026-08-05. The reviewed commit is b9b8315.

The encoding

A message stream is NDJSON. One line holds one JSON object. That object is always an Envelope. See the official Cucumber Messages README.

The design goal is a stream. The producer writes each message when the event happens, and the consumer can process the stream before the run stops. See the reason for the format.

The envelope types

Envelope holds 21 optional fields. Each field holds one message type. No field is required. See the Envelope schema and the Envelope documentation.

FieldPurpose
metaProtocol version, implementation, runtime, OS, CPU, CI data.
sourceThe complete text of one source file.
gherkinDocumentThe abstract syntax tree of one parsed source file.
parseErrorA Gherkin parse failure for one source file.
pickleOne executable scenario, derived from the syntax tree.
stepDefinitionOne step definition, with its pattern and its source location.
parameterTypeOne custom parameter type.
undefinedParameterTypeA parameter type that an expression uses but does not declare.
hookOne hook, with its type and its source location.
suggestionCode snippets that could implement one undefined step.
testRunStartedThe start of the run.
testRunFinishedThe end of the run, with a success boolean.
testRunHookStartedThe start of one global hook execution.
testRunHookFinishedThe end of one global hook execution, with a result.
testCaseThe execution plan of one pickle, as a list of test steps.
testCaseStartedThe start of one attempt of one test case.
testCaseFinishedThe end of one attempt, with a willBeRetried boolean.
testStepStartedThe start of one test step.
testStepFinishedThe end of one test step, with its result.
attachmentAn embedded attachment, such as a screenshot.
externalAttachmentAn attachment that a URL holds, added in 32.0.0.

Which envelope holds a scenario result

testStepFinished is the only envelope that holds a step result. testRunHookFinished holds a result for a global hook. No other envelope holds a result. See TestStepFinished.

testCaseFinished holds only testCaseStartedId, timestamp, and willBeRetried. It holds no result. See TestCaseFinished.

Therefore a consumer must compute the scenario result. Collect every testStepFinished that has the same testCaseStartedId, then select the most severe status. The official helper defines this severity order, from least severe to most severe:

UNKNOWN < PASSED < SKIPPED < PENDING < UNDEFINED < AMBIGUOUS < FAILED

See getWorstTestStepResult.ts.

testRunFinished.success gives the result of the complete run. The documentation says a run is successful when all steps passed or were skipped, all hooks passed, and no other exception occurred. See TestRunFinished.success.

The step statuses

TestStepResult.status is a closed enumeration of seven values. The schema lists them in this order:

  1. UNKNOWN
  2. PASSED
  3. SKIPPED
  4. PENDING
  5. UNDEFINED
  6. AMBIGUOUS
  7. FAILED

See the TestStepResult schema and the TestStepResultStatus documentation.

The schema order is also the severity order that getWorstTestStepResult uses.

UNKNOWN is a sentinel. The compatibility kit all-statuses sample produces the other six statuses and does not produce UNKNOWN. The official helper returns UNKNOWN only when it receives an empty list of results. See the all-statuses sample.

The compatibility kit states the run-level effect of UNDEFINED. The step is UNDEFINED, every step after it in the same scenario is SKIPPED, and the run fails. See the undefined feature.

TestStepResult also holds an optional message string and an optional exception object. exception holds type, message, and stackTrace. See Exception.

The step-to-code edge

The edge exists, but it is not portable.

The chain of identifiers

The chain has four links, and every link uses a string identifier:

  1. pickle.steps[].id gives the identifier of a pickle step.
  2. testCase.testSteps[] holds pickleStepId and stepDefinitionIds.
  3. stepDefinition.id matches one entry of stepDefinitionIds.
  4. stepDefinition.sourceReference points at the code.

See TestStep and StepDefinition.

stepDefinitionIds is a list, and the documentation states its cardinality:

  • An UNDEFINED step has an empty stepDefinitionIds list.
  • An AMBIGUOUS step has more than one entry.
  • A matched step has exactly one entry.

See the TestStep description.

stepDefinitionIds is optional, and it is absent when the test step comes from a hook and not from a pickle step. A hook test step holds hookId instead.

SourceReference is a union, and each language uses a different arm

SourceReference has four optional fields: uri, javaMethod, javaStackTraceElement, and location. All four are optional, so an empty SourceReference is valid. See the SourceReference documentation.

JavaScript writes uri and location.line. It writes no column. See emit_support_code_messages.ts.

Ruby writes uri from the file of the definition, and location.line from the first line of the definition. It writes no column. See step_definition.rb.

Java writes neither uri nor a portable line. createSourceReference has three branches:

  • A JavaMethodReference becomes a javaMethod object, which holds className, methodName, and methodParameterTypes. It carries no line and no file.
  • A StackTraceElementReference becomes a javaStackTraceElement plus a location. The fileName is a bare file name and not a path, and the code substitutes the literal string "Unknown" when the file name is absent.
  • Any other reference becomes new SourceReference(null, null, null, null), which is an empty object.

See CachingGlue.java.

The Java branch has a source comment that asks for a schema fix, so this is a known limit and not a defect of one release.

Conclusion on the edge

For JavaScript and Ruby, stepDefinitionIds plus sourceReference give a usable step-to-file-and-line edge. The line is the line of the step definition, and there is no column.

For Java, the same pair gives a step-to-class-and-method edge. A consumer must resolve the class and the method to a file itself, and criv would need Java symbol knowledge to do that. A file-and-line edge is therefore not available for Java from the report alone.

The uri value is a relative path in the JavaScript and Ruby samples, for example samples/minimal/minimal.ts. Whether the base of that path is always the working directory of the run is unconfirmed. Both implementations copy a path that their own loader produced, and neither documents the base.

How a pickle maps back to Gherkin

Pickle holds uri, name, an optional location, and a required astNodeIds list. See the Pickle documentation.

The scenario name

pickle.astNodeIds holds the identifiers of the syntax tree nodes of the pickle. For a plain scenario it holds one identifier, and that identifier is scenario.id. The rules sample shows this:

{"pickle":{"id":"22","name":"Not enough money","location":{"line":9,"column":5},"astNodeIds":["4"], ...}}

4 is the scenario.id of Example: Not enough money. See the rules sample.

pickle.name is not always equal to scenario.name. For a Scenario Outline with a parameterized name, the pickle name has the placeholder replaced. The examples-tables sample has the scenario name Eating cucumbers with <friends> friends and the pickle names Eating cucumbers with 11 friends, Eating cucumbers with 1 friends, and Eating cucumbers with 0 friends. See the examples-tables feature and its messages.

To get the scenario name, a consumer must read astNodeIds[0] and find that scenario in the gherkinDocument. pickle.name alone is not the scenario name.

The Examples row

For a pickle that an Examples table produced, astNodeIds holds two identifiers. The first is the Scenario node, and the second is the TableRow node. The documentation states this, and the sample confirms it:

{"pickle":{"id":"26","name":"Eating cucumbers","location":{"line":19,"column":7},"astNodeIds":["13","4"]}}
{"pickle":{"id":"30","name":"Eating cucumbers","location":{"line":20,"column":7},"astNodeIds":["13","5"]}}

Scenario 13 is Eating cucumbers. Rows 4 and 5 are the two body rows of Examples: These are passing. See Pickle.astNodeIds.

pickle.location points at the example row and not at the scenario keyword. The documentation says so, and line 19 in the sample is the first body row.

One limit is important. astNodeIds holds the TableRow identifier, but it does not hold the Examples block identifier. To name the Examples block, a consumer must walk the gherkinDocument, read scenario.examples[], and find the block whose tableBody[] contains that row identifier. In the sample, Examples: These are passing has id 7 and holds rows 4 and 5, and Examples: These are failing has id 12 and holds rows 9 and 10. Neither 7 nor 12 appears in any astNodeIds.

The Rule

Pickle has no ruleId field, and the Rule identifier does not appear in astNodeIds. The rules sample proves this: the three pickles carry only ["4"], ["9"], and ["15"], which are scenario identifiers, while the Rule identifiers are 10 and 17.

To find the Rule, a consumer must walk the gherkinDocument. The structure is feature.children[], where each FeatureChild holds an optional rule, an optional background, or an optional scenario. A Rule holds children[] of RuleChild, and each RuleChild holds an optional background or an optional scenario. A scenario is inside a Rule when its identifier appears under rule.children[].scenario.id. See FeatureChild and RuleChild.

Rule tags are inherited into pickle.tags, and each PickleTag holds the astNodeId of its Tag node. In the rules sample, pickle 32 carries [{"name":"@some-tag","astNodeId":"16"}], and node 16 is the tag of the second Rule. A tag is therefore an indirect route to the Rule, but only when the Rule has a tag. The tree walk is the reliable route.

Consequence for the criv scenario identity

Issue #200 decided that a scenario identity is the qualified name in kebab case, for example features/checkout.feature#scenario:place-an-order. Three facts of this contract touch that decision:

  1. The report always gives pickle.uri, so the file part of the identity is available.
  2. For a Scenario Outline, pickle.name is the expanded name and is not the scenario name. A consumer that builds the identity from pickle.name would produce a different identity than a consumer that reads the .feature file. The identity must come from astNodeIds[0] and the gherkinDocument.
  3. One scenario produces many pickles when it has an Examples table. A scenario identity therefore maps to a set of results, not to one result.

Which implementations emit the format

The official README names the emitters. It says that cucumber messages are currently sent by these versions, and that emitters for the other languages are not yet implemented. See the Message emitters section.

ImplementationEmits messagesExact flagSince
cucumber-js (JavaScript)Yes--format message for stdout, or --format "message":"out.ndjson" for a file7.0.0
cucumber-ruby (Ruby)Yes--format message --out report.ndjson. There is no message:file colon form.4.0.0
cucumber-jvm (Java)Yes--plugin message:report.ndjson or -p message:report.ndjson on the CLI, @CucumberOptions(plugin = {"message:report.ndjson"}), or the cucumber.plugin=message:report.ndjson property6.0.0
godog (Go)Nononenot applicable
behave (Python)Nononenot applicable
pytest-bdd (Python)No--cucumberjson=<path> writes legacy Cucumber JSON, not messagesnot applicable
cucumber-rs (Rust)Nononenot applicable
pytest-bdd-ng (Python, third party)Yes--messages-ndjson=PATH2.0.0
cucumber-node (JavaScript)YesListed as a compatibility kit adopterunconfirmed
Reqnroll (.NET)YesNot researched; the README names it3.0.0

The messages library version that each emitter depends on is close to current. cucumber-js depends on @cucumber/messages 34.2.1 on main. cucumber-jvm depends on io.cucumber:messages 34.2.1 on main and on 30.1.0 at the released v7.34.8. cucumber-ruby depends on cucumber-core, which requires cucumber-messages in the range > 31, < 35. So a report from a released cucumber-jvm can declare a protocol version several major releases behind a report from cucumber-js, and a criv reader must tolerate that spread.

JavaScript

The message formatter is built in. The official documentation says it “Outputs all the Cucumber Messages for the test run as newline-delimited JSON, which can then be consumed by other tools”. See docs/formatters.md.

The same document gives the syntax. A format takes one or two values. Without a second value the formatter prints to stdout. On the command line, the name and the path use : as a delimiter, and each side needs double quotes. In a configuration file, use { format: 'message', 'out.ndjson' }. See the top of docs/formatters.md.

The same document also says that the legacy json formatter is in maintenance mode and that message is the recommended structured output.

The formatter itself is one statement. It writes write(`${JSON.stringify(message)}\n`) for each message event, so the output is exactly the envelope objects with no wrapper. See src/formatter/builtin/message.ts. The formatter first shipped in 7.0.0.

Ruby

message is a built-in format, described as “Prints each message in NDJSON form, which can then be consumed by other tools”. The json entry in the same table says the JSON format is in maintenance mode and recommends the message formatter. See lib/cucumber/cli/options.rb and lib/cucumber/formatter/message.rb.

Ruby differs from Java in the flag shape. Ruby names the formatter with --format or -f, and it takes the output path from a separate --out or -o flag. There is no message:file colon form.

Java

message is a built-in plugin, described as “Logs cucumbers execution as a stream of json messages”. See the

cucumber-core README plugin list.

The plugin is selected with the cucumber.plugin property, which takes comma separated plugin strings in the form name:path. Cucumber reads the property from system properties, environment variables, @CucumberOptions, and cucumber.properties, in that order of precedence, and CLI arguments take precedence over all of them. See the cucumber-core README properties section.

With the JUnit Platform engine, the same property is passed as a system property, for example -Dcucumber.plugin=message:report.ndjson. See the JUnit Platform engine README.

Go

godog has no message formatter. Its formatter directory registers cucumber, events, junit, pretty, and progress. See the formatters directory.

The cucumber formatter produces the legacy Cucumber JSON format. Its own source comment points at the old relishapp JSON output formatter documentation. See fmt_cucumber.go.

The events formatter is a different NDJSON stream. Its registration string is “Produces JSON event stream, based on spec: 0.1.0”. That 0.1.0 is a godog specific number and is not a cucumber messages protocol version. See fmt_events.go.

godog does depend on github.com/cucumber/messages/go/v34, and it uses the message types for its internal Gherkin model. Emitting the message stream would therefore be a small change for godog, but it does not do it today. The request is tracked as an open issue, godog#341 “Implement message formatter”. godog also has no compatibility directory, so it does not run the compatibility kit.

Python

behave has no message formatter. Its formatter package holds json, null, plain, pretty, progress, rerun, steps, tags, and a few others. See the behave formatter package.

behave’s json formatter is behave’s own format, not cucumber messages and not the legacy Cucumber JSON format.

The compatibility kit does publish a Python package, but that package only distributes the reference test data for a Python consumer. It is not a Python cucumber implementation. See the compatibility kit Python README.

pytest-bdd emits the legacy Cucumber JSON format with --cucumberjson=<path>, not messages. See cucumber_json.py. radish does the same with --cucumber-json=<path>. See cucumber_json_writer.py.

There is one Python runner that emits envelopes, and it is a third-party fork of pytest-bdd. pytest-bdd-ng has a --messages-ndjson=PATH option and depends on the official cucumber-messages package. It is not in the cucumber organization. See the reporter entry point and its changelog.

An official Python library for the message types does exist, published as the cucumber-messages package from the same repository. It is a reader and a type set, not an emitter. See python/pyproject.toml.

Rust

cucumber-rs has no message writer. Its writer module holds basic, discard, fail_on_skipped, json, junit, libtest, normalize, or, out, repeat, summarize, and tee. See the writer directory.

Its json writer is the legacy Cucumber JSON format. Its own documentation comment links to cucumber/cucumber-json-schema. See src/writer/json.rs.

There is also no first-party Rust binding for cucumber messages. The cucumber/messages repository has language directories for C++, Dart, .NET, Elixir, Go, Java, JavaScript, Perl, PHP, Python, and Ruby, and no Rust directory. See the repository root. A crates.io search for a cucumber messages crate returned no such crate.

A Rust consumer must therefore define its own serde types from the JSON schema, or generate them.

Version and ordering guarantees

The schema is versioned

Meta.protocolVersion is required, and the documentation says it is the SemVer version number of the protocol. See Meta.protocolVersion.

The protocol version is the version of the cucumber/messages release. The repository changelog states that the project follows SemVer, and its most recent entry is 34.2.1, dated 2026-08-05. See the changelog.

The implementations copy the version of their messages library into the field. cucumber-js writes protocolVersion: messagesVersion, where messagesVersion is the version export of @cucumber/messages. See emit_support_code_messages.ts. The Java library reads it from a resource bundle at ProtocolVersion.java.

A real meta envelope from the compatibility kit shows the value:

{"meta":{"protocolVersion":"34.2.1","implementation":{"name":"fake-cucumber","version":"123.45.6"}, ...}}

How a consumer must behave on an unknown version

The project does not publish a required consumer behaviour for an unknown version. This is unconfirmed as a written rule. What the sources do give is a clear de-facto contract:

The forward-compatibility rule is on the producer side. The contributing guide tells schema authors to add new fields last and to never add a new field as required, “this will make the new code unable to read existing messages”. See CONTRIBUTING.md.

The official JavaScript reader does no version check and no validation at all. parseEnvelope is JSON.parse(json) as Envelope. See parseEnvelope.ts.

So the working rule for a consumer is:

  1. Do not reject a stream on an unknown protocolVersion. Nothing in the protocol asks for that, and the reference reader does not do it.
  2. Ignore unknown fields, and ignore unknown envelope keys.
  3. Treat every field as optional unless the schema marks it required, because an older producer will omit newer fields.
  4. Do not strict-validate against a pinned schema. Every schema file sets "additionalProperties": false, so an old schema rejects a newer valid envelope. Strict validation against a pinned schema is a forward-compatibility trap. See the Envelope schema.
  5. Treat a new enumeration value as unknown rather than as an error. Status is a closed enumeration today, and the schema author could add a value in a major release.

A consumer should also expect the enumeration to be stable. TestStepResultStatus has not changed in the reviewed changelog history.

Ordering is only a partial order

The compatibility kit states the ordering contract. Messages are only partially ordered. Scenarios that run in parallel may interleave their messages, and feature files may be parsed in parallel. Some orderings are not specified at all, and even TestRunStarted is not guaranteed to be the first message. The guarantees are:

  • A *Started message comes before its matching *Finished message.
  • TestCaseStarted and TestCaseFinished are inside TestRunStarted/TestRunFinished.
  • TestStepStarted and TestStepFinished are inside TestCaseStarted/TestCaseFinished.
  • Attachment messages are inside TestStepStarted/TestStepFinished.
  • Before a message is emitted, every message it refers to should already have been emitted.

See the “Ordering” section of the compatibility kit README.

A consumer must therefore index by identifier and must not rely on line order.

The compatibility kit is the reference

The Cucumber Compatibility Kit holds reference .ndjson files for 46 feature samples. A given feature, run with its step definitions, must emit the matching messages. cucumber-js, cucumber-node, cucumber-jvm, cucumber-ruby, and Reqnroll all use the kit. See the compatibility kit README.

This is what makes one consumer possible across the three emitters. The kit is also the best test fixture source for a criv reader, because the files are real output and are version controlled.

Example envelopes

All example lines below are verbatim from the compatibility kit, which generates them with fake-cucumber, the reference implementation in the kit devkit.

A passing scenario

The minimal sample has one scenario with one step. The complete stream is 12 lines, in this order: meta, source, gherkinDocument, pickle, stepDefinition, testRunStarted, testCase, testCaseStarted, testStepStarted, testStepFinished, testCaseFinished, testRunFinished. See the minimal sample.

The pickle:

{"pickle":{"id":"3","uri":"samples/minimal/minimal.feature","location":{"line":9,"column":3},"astNodeIds":["1"],"tags":[],"name":"cukes","language":"en","steps":[{"id":"2","text":"I have 42 cukes in my belly","type":"Context","astNodeIds":["0"]}]}}

The step definition, which carries the code location:

{"stepDefinition":{"id":"4","pattern":{"type":"CUCUMBER_EXPRESSION","source":"I have {int} cukes in my belly"},"sourceReference":{"uri":"samples/minimal/minimal.ts","location":{"line":3}}}}

The test case, which joins pickle step 2 to step definition 4:

{"testCase":{"id":"6","pickleId":"3","testSteps":[{"id":"7","pickleStepId":"2","stepDefinitionIds":["4"],"stepMatchArgumentsLists":[{"stepMatchArguments":[{"group":{"start":7,"value":"42"},"parameterTypeName":"int"}]}]}],"testRunStartedId":"5"}}

The result, which is the only line that holds a status:

{"testStepFinished":{"testCaseStartedId":"8","testStepId":"7","testStepResult":{"status":"PASSED","duration":{"seconds":0,"nanos":1000000}},"timestamp":{"seconds":0,"nanos":3000000}}}

The scenario end, which holds no status:

{"testCaseFinished":{"testCaseStartedId":"8","timestamp":{"seconds":0,"nanos":4000000},"willBeRetried":false}}

An undefined step

The undefined sample has four scenarios. See the undefined sample.

The test case has an empty stepDefinitionIds list, which is how a consumer detects an unmatched step before the run even starts:

{"testCase":{"id":"23","pickleId":"11","testSteps":[{"id":"24","pickleStepId":"10","stepDefinitionIds":[],"stepMatchArgumentsLists":[]}],"testRunStartedId":"22"}}

The result:

{"testStepFinished":{"testCaseStartedId":"33","testStepId":"24","testStepResult":{"status":"UNDEFINED","duration":{"seconds":0,"nanos":0}},"timestamp":{"seconds":0,"nanos":3000000}}}

A suggestion envelope arrives between the testStepStarted and the testStepFinished, and it gives the snippet that would implement the step:

{"suggestion":{"id":"34","pickleStepId":"10","snippets":[{"language":"typescript","code":"Given(\"a step that is yet to be defined\", () => {\n  return \"pending\"\n})"}]}}

The run then fails:

{"testRunFinished":{"testRunStartedId":"22","timestamp":{"seconds":0,"nanos":21000000},"success":false}}

Suggestion and Snippet were added to the protocol for this purpose. See Suggestion. This makes the report a direct answer to the “documented behaviour with no code” case, because it names the pickle step and offers the code.

What this means for criv

criv would be a consumer of this format, and ADR-0046 keeps criv out of the runner business. These facts follow from the contract.

  1. One reader works, for three languages. The envelope shape, the status enumeration, and the identifier graph are identical for cucumber-js, cucumber-ruby, and cucumber-jvm, and the compatibility kit enforces that. A criv reader needs no per-implementation branch, except for sourceReference.
  2. Rust has no first-party binding. criv must define its own serde types. The JSON schema is bundled as one file since messages 32.1.0, so generation is possible.
  3. Scenario status is a computation, not a field. criv must group testStepFinished by testCaseStartedId and take the most severe status. The severity order is published.
  4. A step-to-code edge is portable only for file plus line, and only for JavaScript and Ruby. For Java, criv would receive a class and a method. Building criv query callers from this report would work unevenly across languages.
  5. A scenario maps to a set of results. A Scenario Outline produces one pickle per Examples row, so a criv scenario symbol has many results, and a mixed outcome is normal.
  6. The Rule and the Examples name need the gherkinDocument. A criv reader that only reads the result envelopes cannot reconstruct a Rule or an Examples block name. It must also keep the gherkinDocument envelope, or re-parse the .feature file.

Facts that affect the freshness contract in issue 208

Issue #208 must decide what criv reports when a report is absent, old, or from a different revision. These contract facts constrain that design.

  1. The report can carry a Git revision, but only from CI. Meta.ci.git holds remote, revision, and optionally branch and tag. The compatibility kit meta sample shows a real one. But Meta.ci is optional, and the @cucumber/ci-environment library returns a value only when it detects a CI server. See the ci-environment README and Meta.ci. A revision match is therefore available for a CI-produced report and is not available for a developer’s local run. A freshness rule that needs a revision will work in CI and will degrade to a file timestamp locally.
  2. The report carries no source content hash. There is no field that holds a digest of the .feature file or of the step definition file. Source.data holds the complete feature text, so criv could hash Source.data itself and compare it to the working tree. That is a stronger staleness signal than the Git revision, because it works locally too, and it is per file.
  3. TestRunStarted.timestamp gives the run time. It is required, so every report has a run time that does not depend on the file modification time.
  4. The UNDEFINED signal is available without running anything past parse. testCase.testSteps[].stepDefinitionIds is empty for an unmatched step. So “a document describes behaviour with no code” is visible in the plan and not only in the result.
  5. A retried scenario produces more than one attempt. TestCaseStarted holds attempt, and TestCaseFinished holds willBeRetried. criv must pick the last attempt, or it will report a stale failure for a scenario that later passed.
  6. Parallel runs interleave. criv must not read the report as an ordered log, and must not assume that the last testStepFinished in the file belongs to the last scenario.
  7. Partial reports are normal. An aborted run produces a stream with no testRunFinished. A consumer sees this as a missing envelope, not as an error. A freshness rule needs a definition of an incomplete report.

Sources

All facts above cite a first-party source. The primary sources are:

Unconfirmed items

  • The base directory of SourceReference.uri. Neither cucumber-js nor cucumber-ruby documents whether the path is relative to the working directory of the run.
  • Any written rule for a consumer that meets an unknown protocolVersion. The de-facto contract above is inferred from the reference reader and from the contributing guide, not from a published statement.
  • The exact flag for cucumber-node. The compatibility kit names it as an adopter, but its own documentation was not reviewed.
  • Whether pytest-bdd-ng 2.4.0 is released. The changelog has the section, but the newest GitHub release tag is pytest-bdd-ng-2.3.1.
  • Whether the behave maintainers intend to add message support. No issue or roadmap entry was checked. The conclusion rests on the built-in formatter list only.
  • Whether the cucumber-rs maintainers intend to add a message writer. No maintainer statement was found. The conclusion rests on Cargo.toml and the writer module only.
  • Whether the legacy json formatter will be removed. Three implementations say it is in maintenance mode, and none gives a removal release.

Gherkin Grammar and ast-grep Language Contract

Date: 2026-09-13

Ticket: #202. Map: #198.

Question

criv extracts Source symbols with tree-sitter and evaluates ADR policy patterns with ast-grep. Gherkin support needs the exact contract of both.

Answer

ast-grep registers no Gherkin language, and no usable tree-sitter Gherkin grammar exists. Use the gherkin crate (MIT OR Apache-2.0, 0.16.0, 2026-04-23) for extraction. It costs 99,568 bytes of release binary. It gives Rust structs, not tree-sitter nodes, so it keeps no comments, it does not separate Scenario Outline from Scenario, and it returns nothing at all for a file with one bad keyword. Localization changes only a keyword string; it changes no type name.

An ADR policy.patterns rule therefore cannot reach a .feature file. Behaviour policy needs another mechanism.


Which Gherkin parser criv should use

There is no Gherkin in ast-grep, so a tree-sitter grammar buys nothing

The ast-grep-language dependency and criv’s own tree-sitter-* dependencies are separate lists. A tree-sitter grammar is only worth the cost when it fills both lists, as ADR-0119 did for Elixir. Gherkin can fill neither list, for the reasons below.

No tree-sitter-gherkin package is published anywhere

  • crates.io returns no result for tree-sitter-gherkin. Search: https://crates.io/api/v1/crates?q=tree-sitter-gherkin.
  • npm returns {"error":"Not found"} for tree-sitter-gherkin. Request: https://registry.npmjs.org/tree-sitter-gherkin.
  • The tree-sitter GitHub organization holds no gherkin repository. Request: gh api orgs/tree-sitter/repos.
  • The tree-sitter-grammars GitHub organization holds no gherkin repository. Request: gh api orgs/tree-sitter-grammars/repos.
  • nvim-treesitter has no gherkin entry. Request: gh api search/code -f q="gherkin repo:nvim-treesitter/nvim-treesitter" returns total_count: 0.

Only unpublished personal repositories exist. A GitHub search for tree-sitter-gherkin returns nine, the largest with 6 stars.

The grammar that Helix uses cannot parse ordinary Gherkin

Helix pins SamyAB/tree-sitter-gherkin at revision 43873ee8de16476635b48d52c46f5b6407cb5c09. See languages.toml, lines 4630-4639. The repository is MIT, 6 stars, last pushed 2024-07-04.

Its grammar.js rejects valid Gherkin:

  • title: $ => /[A-Z][a-zA-Z ]+/. A title must start with a capital letter and hold only letters and spaces. Pay with <method> fails. place an order fails. Any digit or punctuation fails.
  • tag: $ => token(seq('@', /[a-z_]+/)). A tag must be lowercase. @Smoke fails. @wip-2 fails.
  • feature: seq(optional($.tag), ...). One tag only. @billing @web fails.
  • steps: seq(optional($.given_steps), $.when_step, $.then_steps). A scenario must hold at least one When step and at least one Then step, in that order. A Given-only scenario fails.
  • scenario_keyword is choice('Scenario', 'Example') with :. There is no Scenario Outline rule and no Examples rule at all.
  • Every keyword is a literal English string. There is no # language: rule.

Its Cargo.toml also states version = "0.0.1", tree-sitter = "~0.20.10", and repository = "https://github.com/tree-sitter/tree-sitter-gherkin", which does not exist. criv uses tree-sitter = "0.26.3", so the generated parser would need regeneration.

The most complete grammar is a one-day project

DevAbdullahUk/tree-sitter-gherkin (MIT) does cover Scenario Outline, Examples, doc strings, data tables, multiple tags, and comments. Its rules are source_file, comment, tag_list, tag, feature, rule_header, background, scenario, examples, step, step_keyword, step_text, quoted_string, parameter, text_fragment, name_line, description, narrative_line, description_line, data_table, table_row, table_cell, doc_string. It has no # language: rule.

gh api repos/DevAbdullahUk/tree-sitter-gherkin reports created_at: 2026-07-14T20:03:30Z and pushed_at: 2026-07-14T20:29:50Z, 0 stars, and 0 forks. It was created and abandoned in 27 minutes. It is not a maintenance story that criv can depend on.

The official cucumber parser has no Rust implementation

cucumber/gherkin is MIT, 403 stars, and active (pushed_at: 2026-09-13). Its top level holds c, cpp, dart, dotnet, elixir, go, java, javascript, perl, php, python, and ruby. There is no rust directory. Request: gh api repos/cucumber/gherkin/contents.

The parser is generated from gherkin.berp by the Berp tool. Generating a Rust target is new work, not a dependency.

Recommendation: the gherkin crate

gherkin 0.16.0, repository cucumber-rs/gherkin.

FactValue
LicenceMIT OR Apache-2.0
Latest release0.16.0, 2026-04-23
Previous releases0.15.0 (2025-12-12), 0.14.0 (2023-07-14)
Downloads16,932,413
MSRV1.88
Runtime dependenciespeg 0.6.3, textwrap 0.16, thiserror 2.0, typed-builder 0.23
Transitive normal crates53

Facts from https://crates.io/api/v1/crates/gherkin, https://crates.io/api/v1/crates/gherkin/0.16.0/dependencies, and the crate’s own Cargo.toml.orig.

Maintenance state: active but slow. Three releases in three years, with a 1,144-day gap between 0.14.0 and 0.15.0. The changelog shows real upstream alignment work in 0.15.0 and 0.16.0. It is the parser that the cucumber crate uses, which explains the download count. It is not the official cucumber parser; it is an independent pure-Rust reimplementation by the cucumber-rs organization.

One stale point: it pins peg = "0.6.3", released 2019. The current peg is 0.8.6 (2026-05-04). Source: https://crates.io/api/v1/crates/peg.

Size cost against ADR-0015

Measured, not estimated. Two binaries were built in one crate with exactly the release profile from ADR-0015 (strip = true, opt-level = "z", lto = true, codegen-units = 1, panic = "abort") on aarch64-apple-darwin, Rust edition 2024:

BinaryBytes
Baseline: read a file, print its length286,128
Same, plus gherkin::Feature::parse and a full AST walk385,696
Delta99,568

99,568 bytes is 1.01% of the 9,815,840-byte criv binary that ADR-0015 records. This is small, and ADR-0015 asks that future size work “start with dependency and feature analysis”. Two notes for that analysis:

  • textwrap 0.16.3 pulls icu_segmenter 2.3.0, icu_collections, icu_locale_core, icu_provider, zerovec, yoke, and potential_utf. Most of the 53 transitive crates come from this one path. gherkin exposes no feature to drop textwrap.
  • gherkin has a default parser feature that adds typed-builder. Turning it off removes Feature::parse as well, so criv must keep it.

Node shapes

The gherkin crate has no node type. It has Rust structs, so criv gets a typed tree, not a syntax tree. Field lists are from src/lib.rs at version 0.16.0.

Feature    keyword: String
           name: String
           description: Option<String>
           background: Option<Background>
           scenarios: Vec<Scenario>
           rules: Vec<Rule>
           tags: Vec<String>
           span: Span
           position: LineCol
           path: Option<PathBuf>

Rule       keyword, name, description, background: Option<Background>,
           scenarios: Vec<Scenario>, tags, span, position

Background keyword, name, description, steps: Vec<Step>, span, position

Scenario   keyword, name, description, steps: Vec<Step>,
           examples: Vec<Examples>, tags, span, position

Examples   keyword, name: Option<String>, description,
           table: Option<Table>, tags, span, position

Step       keyword: String, ty: StepType, value: String,
           docstring: Option<String>, table: Option<Table>, span, position

Table      rows: Vec<Vec<String>>, span, position

StepType   Given | When | Then

Span       start: usize, end: usize
LineCol    line: usize, col: usize

Observed output from the reference feature file below, produced by a binary that links gherkin 0.16.0:

# a leading comment
@billing @web
Feature: Checkout
  As a shopper I want to pay.

  Background: A signed-in shopper
    Given a signed-in shopper

  Rule: One order per basket
    Background: A full basket
      Given a basket with 2 items

    @smoke
    Scenario: Place an order
      Given a basket
      # an inline comment
      When I place the order
      """
      a doc string
      """
      Then the order exists
        | name  | value |
        | a     | 1     |

    Scenario Outline: Pay with <method>
      Given a basket
      When I pay with <method>
      Then the payment is <result>

      @slow
      Examples: Cards
        | method | result   |
        | visa   | accepted |
        | amex   | declined |
feature  keyword="Feature" name="Checkout" tags=["billing","web"]
         description=Some("As a shopper I want to pay.") background=true
         scenarios=0 rules=1 span=Span{start:34,end:730}
         position=LineCol{line:3,col:1}
  rule   keyword="Rule" name="One order per basket" tags=[]
         background=true scenarios=2 span=Span{start:150,end:730}
         position=LineCol{line:9,col:3}
    scenario keyword="Scenario" name="Place an order" tags=["smoke"]
             steps=3 examples=0 position=LineCol{line:14,col:5}
      step keyword="Given " ty=Given value="a basket"
      step keyword="When " ty=When value="I place the order"
           docstring=Some("\na doc string\n")
      step keyword="Then " ty=Then value="the order exists"
           table=Some([["name","value"],["a","1"]])
    scenario keyword="Scenario Outline" name="Pay with <method>" tags=[]
             steps=3 examples=1 position=LineCol{line:25,col:5}
      step keyword="Given " ty=Given value="a basket"
      step keyword="When " ty=When value="I pay with <method>"
      step keyword="Then " ty=Then value="the payment is <result>"
      examples keyword="Examples" name=Some("Cards") tags=["slow"]
               table=Some([["method","result"],["visa","accepted"],
                           ["amex","declined"]])

Seven facts that the design must absorb:

  1. Scenario Outline is not a distinct type. Both branches of the parser rule scenario() build a Scenario. Only keyword differs, and examples is non-empty. See src/parser.rs, the two alternatives of rule scenario(). There is no ScenarioType enum in 0.16.0.
  2. Comments are discarded. Every comment rule in the PEG grammar is wrapped in quiet!{} and the result is dropped: rule comment() = quiet!{comment_no_nl() nl_eof()}. No struct has a comment field. A criv feature: symbol can carry no doc comment.
  3. Tags lose the @. rule tag() = "@" s:tag_char()+ { s.join("") } returns ["billing", "web"], not ["@billing", "@web"].
  4. Step keywords keep a trailing space. keyword="Given ". Block keywords do not: keyword="Scenario", keyword="Scenario Outline".
  5. span excludes leading tags. span.start is captured after the tag list, so span for the reference Feature starts at byte 34, after # a leading comment and @billing @web. position is the line and column of the keyword, 1-based.
  6. StepType normalizes And, But, and *. Only Given, When, and Then exist. The literal keyword survives in Step::keyword. Version 0.16.0 also treats an And-like or But-like keyword at the start of a scenario as a Given step (#53).
  7. Doc string content keeps its leading and trailing newline. docstring=Some("\na doc string\n"). rule docstring() accepts both """ and ``` delimiters and runs textwrap::dedent on the body. This is the only use of textwrap in the crate.

Error recovery on an incomplete file

There is none. Feature::parse returns Result<Feature, ParseError>, and Feature::parse_path returns Result<Feature, ParseFileError>. A failure gives no partial Feature, so criv would get zero symbols from the file. Measured cases:

InputResult
Truncated last step (When I)Parses. The partial step becomes value="I".
Scenario with no name (Scenario:)Parses. name="".
One typed keyword (Scenaro:)Error at 8:20: {"unknown keyword"}. No symbols.
No Feature: headerError at 1:14: {"unknown keyword"}. No symbols.
Empty fileError at 2:1: {...}. No symbols.

ParseError exposes only a Display impl, "Error at {line}:{col}: {expected:?}". Its position and expected fields are private, so criv cannot read the location without parsing the message string. GherkinEnv also holds a last_error and a fatal_error for EnvError::UnsupportedLanguage, EnvError::UnknownKeyword, and EnvError::InconsistentCellCount, but those are crate-private (pub(crate) last_error).

This contradicts the Elixir precedent. ADR-0119 requires that criv “use the partial Tree-sitter result for an Elixir file with parse errors” and that “safe declarations before and after the error remain available”. A PEG parser cannot do this. A typed keyword in one scenario silently removes every scenario in the file from the graph.

The truncated case is worse than a clean failure: the file parses, and the half-written step enters the graph with wrong text.

Whether ast-grep registers a Gherkin language

No. This is the decisive finding.

ast-grep-language exposes a closed enum, not a registry. All 28 variants of SupportLang in version 0.44.1, which criv pins, are:

Bash, C, Cpp, CSharp, Css, Dart, Go, Elixir, Haskell, Hcl, Html, Java,
JavaScript, Json, Kotlin, Lua, Markdown, Nix, Php, Python, Ruby, Rust,
Scala, Solidity, Swift, Tsx, TypeScript, Yaml

Source: SupportLang in ast-grep-language-0.44.1/src/lib.rs, and SupportLang::all_langs() in the same file. The published crate has one module per language and there is no gherkin.rs. A grep -n feature over that file returns nothing, so no extension table maps .feature. The official supported-languages reference names no Gherkin, no Cucumber, and no .feature extension for the current release either. (Its table also omits Dart, which the enum does hold, so the enum is the authority.)

What this means for criv today

criv’s src/structural.rs types the language as SupportLang:

#![allow(unused)]
fn main() {
pub struct CompiledPolicy {
    language: SupportLang,
    matcher: CompiledMatcher,
}
}

Two consequences follow, both already in the code:

  1. An ADR that declares language: gherkin fails to compile its policy. parse_language calls SupportLang::from_str and reports unsupported ast-grep language `gherkin` .

  2. Even with a valid language, find_policies_batch skips a .feature file with no diagnostic:

    #![allow(unused)]
    fn main() {
    for source_file in vault.source_files() {
        let Some(language) = SupportLang::from_path(source_file) else {
            continue;
        };
    }

    SupportLang::from_path maps by extension and knows no feature, so the file is dropped silently.

The dynamic-language loading path

ast-grep does have one, and it does not fit criv.

ast-grep-dynamic (MIT, 0.45.3, 2026-08-31, https://crates.io/crates/ast-grep-dynamic) provides DynamicLang, CustomLang, and DynamicLang::register(Vec<Registration>). A Registration holds lang_name, lib_path, symbol, meta_var_char, expando_char, and extensions. Registration calls libloading::Library and dlopens a compiled tree-sitter dynamic library. The comment on register states it “should be called exactly once before use”, so it is a process-global one-shot.

The user-facing form is customLanguages in sgconfig.yml (documentation, sgconfig reference):

customLanguages:
  mojo:
    libraryPath: mojo.so
    extensions: [mojo]
    outlineRules: outline/mojo.yml
    expandoChar: _

Three blockers:

  • It needs a compiled .so, .dylib, or .dll per target triple. criv ships one static binary per platform under ADR-0014. Shipping a sidecar library is a release-workflow change, not a dependency change.
  • DynamicLang is a different type from SupportLang. CompiledPolicy would need a language enum of its own.
  • It still needs a tree-sitter Gherkin grammar to compile, and none is usable.

The third path, for completeness

ast_grep_core::Language is a public trait (ast-grep-core-0.44.1/src/language.rs) with kind_to_id, field_to_id, and build_pattern. criv could implement it for a statically linked Gherkin grammar and avoid both SupportLang and the dynamic loader. This is the only path that keeps one static binary, but it too needs a usable tree-sitter Gherkin grammar, so it is blocked by the same missing grammar.

Conclusion for behaviour policy

An ADR policy.patterns rule cannot reach a feature file. Behaviour policy needs another mechanism. criv’s own Language enum (src/source/graph.rs, line 332) is independent of SupportLang, so the Source-graph half of #199 is unaffected: adding a Gherkin variant beside Rust, TypeScript, JavaScript, Python, Go, and Elixir needs no ast-grep change.

Whether keyword localization changes the node names

No for the gherkin crate. Yes in effect for any tree-sitter grammar.

The gherkin crate loads the official keyword table. Its src/languages.json is a copy of gherkin-languages.json from the official repository, and build.rs generates a keyword table per language code with heck, quote, and syn. GherkinEnv::new(language) selects the table, and a # language: xx first line switches it at parse time (rule language_directive() in src/parser.rs).

Measured, with the same structure as the reference file above and a # language: fr header:

feature  keyword="Fonctionnalité" name="Paiement" tags=["facturation"]
  rule   keyword="Règle" name="Une commande par panier"
    scenario keyword="Scénario" name="Passer une commande" tags=["rapide"]
      step keyword="Soit " ty=Given value="un panier"
      step keyword="Quand " ty=When value="je passe la commande"
      step keyword="Alors " ty=Then value="la commande existe"
    scenario keyword="Plan du Scénario" name="Payer avec <methode>"
      step keyword="Soit " ty=Given value="un panier"
      examples keyword="Exemples" name=Some("Cartes")

The struct names are unchanged. StepType still resolves to Given, When, and Then. Only the keyword: String field carries localized text. The design rule follows directly:

Derive a criv symbol kind from the Rust type, never from Step::keyword or Scenario::keyword. A kind derived from the keyword string would be localized, and scenario: would become scénario: in a French vault.

Two limits remain:

  • Background is Contexte in French and is reached through the same background field, so it needs no keyword read.
  • Scenario and Scenario Outline can only be told apart by the keyword string or by examples.is_empty(). The keyword string is localized, so examples.is_empty() is the only localization-safe test, and it is wrong for a Scenario Outline that has no Examples block yet.

For a tree-sitter grammar the answer is different. Every grammar reviewed hardcodes English keyword literals ('Feature', 'Given ') and none has a # language: rule, so a localized file does not parse at all. A grammar that did support localization would have to either add one node kind per language or use a scanner, and node names are the whole ast-grep pattern surface.

Reproduction

Every measurement above comes from one throwaway crate:

[package]
name = "sizetest"
version = "0.1.0"
edition = "2024"

[dependencies]
gherkin = "0.16.0"

bin
name = "sizetest"
path = "src/main.rs"

bin
name = "baseline"
path = "src/baseline.rs"

[profile.release]
strip = true
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"

sizetest calls gherkin::Feature::parse and prints every field. baseline reads the file and prints its length. Build with cargo build --release and compare with stat -f %z.

Unconfirmed

  • Whether any of the seven remaining personal tree-sitter-gherkin repositories is better than the two reviewed. Only SamyAB/tree-sitter-gherkin (the Helix pin) and DevAbdullahUk/tree-sitter-gherkin (the most complete) were read.
  • The binary-size delta on Linux and Windows targets. Only aarch64-apple-darwin was measured.
  • Whether the icu_* crates that textwrap pulls in are already in criv’s dependency graph. If they are, the real delta is smaller than 99,568 bytes. This needs a measurement inside criv, not in a throwaway crate.
  • Whether gherkin 0.16.0 handles a data table with an escaped \| cell the same way the official parser does. The changelog claims 0.15.0 fixed escape sequences (#47), but this was not tested against the official testdata corpus in cucumber/gherkin/testdata.
  • Whether gherkin accepts the Markdown-with-Gherkin format that MARKDOWN_WITH_GHERKIN.md defines. This is out of scope for #198 under ADR-0032, so it was not tested.

Installer registry distribution

Research date: 2026-08-21.

Conclusion

Use the aqua Standard Registry as the main shared package record. Then add a criv short name to the mise registry. The mise record must use aqua first and the direct GitHub backend second:

backends = ["aqua:TudorAndrei/criv", "github:TudorAndrei/criv"]
bins = ["criv"]
description = "Keep repository documentation connected to the code it describes"
test = { cmd = "criv --version", expected = "criv {{version}}" }
version_order = "semver"

Keep github:TudorAndrei/criv as the direct and authoritative fallback. Do not make an asdf plugin, a vfox plugin, a custom mise backend, or a private aqua registry.

This route gives these user paths after the external registry changes merge:

[tools]
criv = "latest"
packages:
  - name: TudorAndrei/criv@v0.10.1

The first form is the lowest-cost mise setup. The second form is the native aqua setup. The present direct GitHub form continues to work before and after the registry work:

[tools."github:TudorAndrei/criv"]
version = "latest"

The mise registry can reject a tool that it does not find notable. Its current contribution guide says that a new tool can be rejected for this reason. Thus, the mise short name is a request, not a result that criv controls. An aqua entry still gives a maintained package record for aqua and for mise through the full aqua:TudorAndrei/criv name. Do not make a plugin if the mise short-name request is rejected. The mise contribution guide states both the notability rule and the accepted backend tiers.

Present release contract

The README already uses the direct mise GitHub backend. The release guide and the release workflow define four native archives, one checksum file, one release manifest, and GitHub build provenance attestations.

The current release is v0.10.1. Its official GitHub release record has these supported targets:

User platformRust targetRelease asset
Linux x86-64 with glibcx86_64-unknown-linux-gnucriv-x86_64-unknown-linux-gnu.tar.gz
Linux ARM64 with glibcaarch64-unknown-linux-gnucriv-aarch64-unknown-linux-gnu.tar.gz
macOS Apple Siliconaarch64-apple-darwincriv-aarch64-apple-darwin.tar.gz
Windows x86-64x86_64-pc-windows-msvccriv-x86_64-pc-windows-msvc.zip

The same record has SHA256SUMS.txt and release-manifest.json. It reports a SHA-256 digest for each release asset. It also reports immutable: false for the release. GitHub release record for v0.10.1.

The release workflow attests all four archives, the checksum file, and the manifest. GitHub states that artifact attestations connect an artifact to its source and build instructions. It also states that verification is necessary to get this benefit. GitHub artifact attestation model.

An isolated check with mise 2026.8.10 installed v0.10.1 on macOS ARM64 from the direct GitHub backend. Mise selected the Apple Silicon archive, calculated its checksum, verified its GitHub artifact attestation, extracted it, and got criv 0.10.1 from criv --version. No release-layout change is necessary for the direct path.

The current target list is an intentional limit. The registry data must not claim support for Linux musl, macOS Intel, Windows ARM64, or another target that the current release does not contain. Historical releases v0.1.1 and v0.2.0 also contain criv-x86_64-apple-darwin.tar.gz. The aqua entry must keep this old macOS Intel support in a version override. It must not claim that the current release has this target. GitHub release record for v0.2.0.

Option comparison

PathSetup costVersion and platform selectionIntegrityMetadata owner and update dutyMain failure modesResult
Direct GitHub asset or github:TudorAndrei/crivLow, but users must know the repository name. A manual download also needs PATH setup.GitHub Releases owns the version list. Mise strips the normal v tag prefix, excludes prereleases by default, and scores release assets by operating system, CPU, C library, and archive type. Mise GitHub backendCurrent mise verifies GitHub artifact attestations by default when they exist. A mise lock file can store checksums and verified provenance. GitHub backend attestation setting, mise lock filecriv owns only its release assets and tags. Each user owns any special backend options. No central package record exists.The name is long. An asset-name change can change automatic selection. GitHub API limits or an attestation-service failure can stop a first install. The current release is not immutable.Keep as the fallback and bootstrap path.
Mise registry entry with the GitHub backend onlyVery low: mise use criv.The mise record supplies the short name and test. The GitHub backend still owns version discovery and asset selection.It has the same attestation and lock-file behavior as the direct GitHub form.The mise repository owns registry/criv.toml. criv must update it if the command, repository, release scheme, or backend changes. No per-version edit is necessary while the contract stays stable.The mise maintainer can reject the tool for low notability. Registry data in a mise release can lag the source registry. It gives no native aqua entry.Useful, but incomplete by itself.
New asdf or vfox pluginHigh. A plugin adds executable install logic, releases, tests, and a separate trust boundary.The plugin must implement version listing, download selection, and installation.Security depends on plugin ownership and plugin code.criv or a third party must maintain a second software project.The mise registry does not accept new asdf or vfox entries. The plugin can drift from the release workflow. Mise backend acceptance policyReject.
Aqua Standard Registry entryLow for aqua users and low for mise users through aqua:.Aqua gets the latest version from GitHub Releases by default. The package record maps the exact four supported environments to the four assets. Aqua version source, supported environmentsThe record can read SHA256SUMS.txt and require the release workflow as the GitHub attestation signer. Aqua supports this attestation field from version 2.35.0. Mise uses the aqua record with checksum verification and native attestation verification. Aqua checksum record, Aqua GitHub attestations, mise aqua securityThe aqua registry owns pkgs/TudorAndrei/criv/registry.yaml, its test data, and the merged registry. criv must update the entry only when its release contract changes.A registry snapshot can lag. Direct aqua checksum enforcement depends on the user’s checksum settings. Unsupported platforms must fail clearly. An attestation API failure can stop installation.Select as the main shared record.
Private aqua registryHigher than the Standard Registry. Users must add and trust another registry source.criv would own registry publication and versioning.It can use the same checksum and attestation fields, but users must approve the non-standard registry.criv must run and release the registry.More setup, one more release stream, and one more availability dependency. Aqua recommends the Standard Registry for public tools. Aqua custom-registry guidanceReject.

Integrity flow

The release workflow already has the necessary producer flow:

  1. It builds each target from one release commit.
  2. It packages the binary and the editor archive.
  3. It writes SHA-256 values to SHA256SUMS.txt and release-manifest.json.
  4. It verifies the archives on their target systems.
  5. It attests the archives, checksum file, and manifest with .github/workflows/release.yml.
  6. It publishes the GitHub release.

The aqua record must connect to that flow with this data after argd s generates the package files:

asset: criv-{{.Arch}}-{{.OS}}.{{.Format}}
format: tar.gz
replacements:
  amd64: x86_64
  arm64: aarch64
  darwin: apple-darwin
  linux: unknown-linux-gnu
  windows: pc-windows-msvc
overrides:
  - goos: windows
    format: zip
supported_envs:
  - linux/amd64
  - linux/arm64
  - darwin/arm64
  - windows/amd64
checksum:
  type: github_release
  asset: SHA256SUMS.txt
  file_format: regexp
  algorithm: sha256
  pattern:
    checksum: ^(\b[A-Fa-f0-9]{64}\b)
    file: "^\\b[A-Fa-f0-9]{64}\\b\\s+(\\S+)$"
github_artifact_attestations:
  signer_workflow: TudorAndrei/criv/.github/workflows/release.yml

The generated asset mapping must produce these target triples:

  • amd64 becomes x86_64.
  • arm64 becomes aarch64.
  • Linux uses unknown-linux-gnu and tar.gz.
  • macOS uses apple-darwin and tar.gz.
  • Windows uses pc-windows-msvc and zip.

The generated record must also have a version_overrides item through v0.2.0. That item must add darwin/amd64 to supported_envs. The final override for current versions must use only the four environments shown above. This keeps old installations valid without restoring macOS Intel for new releases.

The SHA256SUMS.txt format has two spaces between the digest and asset name. The regular expressions above also accept other whitespace. This agrees with the aqua checksum parser contract.

Do not add github_immutable_release to the aqua record. Aqua abandoned this feature in version 2.60.1, and the current criv release is not immutable. Aqua security feature status. GitHub has a separate release-integrity command for repositories that enable immutable releases. This can be a later release hardening task, but it is not a registry prerequisite. GitHub release integrity verification.

For direct aqua use, checksum verification is disabled in user configuration by default. Aqua recommends enabled: true and require_checksum: true, and it recommends that users commit aqua-checksums.json. GitHub artifact attestation verification has an explicit disable flag and runs when the package record has the signer field. Aqua checksum user settings, aqua configuration.

For mise use, enable mise.lock in projects that require repeatable installs. The lock file stores the exact version, URL, checksum, size, and available provenance. Strict lock mode can prevent a new GitHub or registry lookup during installation. Mise lock-file contract.

Exact implementation route

1. Add the aqua package

  1. Fork aquaproj/aqua-registry.
  2. Run argd s TudorAndrei/criv. Do not hand-write the first package record. The Standard Registry requires this command for a new GitHub Release package. It reads the GitHub releases and assets, creates pkgs/TudorAndrei/criv/registry.yaml and pkgs/TudorAndrei/criv/pkg.yaml, creates a branch and commit, and runs its container tests. Official add-package process.
  3. Review all generated versions and asset rules. The repository has both a root CLI tag and a Wasm tag, but only root CLI GitHub Releases are valid for this package. Do not use an asset allow list unless the generator includes unrelated assets. An allow list can remove SHA256SUMS.txt by mistake.
  4. Add the four exact supported_envs, the SHA256SUMS.txt parser, and the github_artifact_attestations.signer_workflow value shown above if the generator does not add them.
  5. Add a version override that supports darwin/amd64 through v0.2.0. Do not add macOS Intel to the final override for current versions.
  6. Confirm that the package exposes only criv or criv.exe. The bundled vscode-criv.vsix is data for criv install-editor; it is not another command.
  7. Run argd t TudorAndrei/criv, then run argd gr to update the merged root registry. Test the latest release on the four supported environments. Keep one old version in pkg.yaml only if an old asset rule needs a version override. The aqua registry states that pkg.yaml is test data, not the list of installable versions. Registry structure, pkg.yaml guide.
  8. Open the aqua-registry pull request. The Standard Registry covers Windows, macOS, and Linux on AMD64 and ARM64. The final criv rule must use only the four targets in the current release. Aqua registry support policy.

No new criv release is necessary unless the registry tests find a packaging defect.

2. Add the mise short name

After the aqua package is in a released Standard Registry version:

  1. Add registry/criv.toml to jdx/mise with the exact TOML record in the conclusion.
  2. Run mise test-tool criv on the pull request. The mise registry requires a reliable test with {{version}}. It accepts aqua and GitHub as preferred backends and uses the first available backend. Mise tool-entry requirements, backend priority.
  3. In the pull request, give the current release date, release cadence, download count, and repository use evidence. The mise maintainer requests a short popularity summary for notability review.
  4. If mise rejects the short name, stop. Keep the aqua and direct GitHub forms. Do not replace them with a plugin.

By default, mise uses the aqua registry snapshot that is built into its own release. Users can opt in to registry_floating to check the current official aqua registry and the current mise shorthand registry first. A normal new mise release removes this lag without a criv change. Mise aqua registry behavior, mise registry behavior.

3. Change criv documentation after acceptance

Only after the mise entry is available, change the README primary example to criv = "latest". Keep github:TudorAndrei/criv as a documented fallback. Add the aqua package form to the release or installation guide. Do not promise an unsupported platform.

4. Maintain the entries

No registry edit is necessary for a normal new version. Both backends discover versions from GitHub Releases. The aqua registry updater checks for package updates and can open update pull requests. Aqua maintainers review and merge those changes. Standard Registry update workflow. Update the external records before the first release that changes one of these facts:

  • the vX.Y.Z root tag pattern;
  • the repository owner or name;
  • an archive name, format, or root executable;
  • a supported operating system or CPU;
  • the checksum file name or line format;
  • the attesting workflow path;
  • the criv --version output.

For each release, keep the present release checks: archive verification, criv --version, SHA256SUMS.txt, the manifest, and GitHub artifact attestations. If one of these checks fails, do not change registry metadata to hide the release defect.

LikeC4 Workspace and Navigation Research

Date: 2026-08-04

Question

Can criv split one LikeC4 architecture workspace into many files and folders, and can users navigate between its focused views?

Answer

Yes. Keep one LikeC4 project at docs/architecture/. Split its model and views into as many .c4 files and folders as needed. LikeC4 merges the files into one model. Use named scoped views and explicit navigateTo rules for drill-down.

Criv must also connect LikeC4’s onNavigateTo event to its selected view. The current criv renderer does not do this. Moving files will make each opened file more useful, but moving files alone will not enable diagram navigation.

This research applies to LikeC4 1.59.2, which criv pins. The reviewed official source is commit 67b696e77ef8f97f43e435819a019ff8cc637cec.

One project can use many files

A likec4.config.json file makes its folder a project root. LikeC4 includes .c4 files in that folder and its subfolders. See the official project configuration documentation, the workspace scanner, and the file-system filter.

All source files in one project are merged into one architecture model. An element can be declared in one file and extended in another file. This is the intended way to keep one large model in small files. See Extending model.

Files in the same project do not need import statements. The import keyword is for separate LikeC4 projects. A nested likec4.config.json starts a separate project, and its files stop being part of the parent project. Cross-project imports currently support only top-level elements. Therefore, criv must not use nested projects only to organize C4 levels. See Multi-projects.

Criv already gives the complete docs/architecture/ path to LikeC4.fromWorkspace, so a recursive file split fits the current compiler bridge. See assets/likec4-bridge.mjs and src/c4/likec4.rs.

Source folders and view folders are different

Physical folders organize .c4 source. The official LikeC4 site can also group its diagram tree by source file or source folder because every view has a sourcePath. See the official diagram tree implementation.

The portable DSL feature for navigation folders is the view title. A / in a title creates a folder path. A views 'Folder name' block supplies a common folder for all views in the block. See Organize views.

For example:

views 'CLI' {
  view cliComponents of criv.cli {
    title 'Components'
    include *
  }
}

The source folder and the title folder should agree. This gives useful organization in both the official LikeC4 tools and criv hosts.

All navigation targets must be named views. A named view has a stable identifier that LikeC4 can reference and use in a URL. See Views.

Automatic drill-down with scoped views

A view declared as view <id> of <element> becomes a scoped view for that element. When another view shows the element, LikeC4 can navigate to its default scoped view. If an element has more than one scoped view, their source order selects the default. See the official scoped-view documentation and assignNavigateTo.

This is a good fit for the main C4 drill-down path:

System Context -> Containers -> Components

The containers view should be of criv. Each Component view should be of its container. A click on criv, criv.cli, criv.vscodeExtension, or criv.obsidianPlugin can then open the next level.

Explicit navigation

A view can override an element’s target:

include criv.cli.refreshPipeline with {
  navigateTo codeRefreshPipeline
}

This is useful when one component has many Code views, or when the desired view is not its first scoped view. See custom view navigation.

A model relationship can navigate to a dynamic view. A relationship included in one view can also get a view-specific navigateTo target. See relationship navigation and relationship customization.

Dynamic-view steps can navigate to other dynamic views. This supports a high-level scenario followed by a detailed scenario. See Dynamic view navigation.

Dynamic views are not required for the normal C4 level path. Scoped element views are the simpler mechanism for Context, Container, Component, and Code drill-down.

What the renderer must do

LikeC4 computes navigateTo targets in the layout model, but embedded hosts must handle the navigation event. ReactLikeC4 accepts onNavigateTo. LikeC4 enables node and relationship navigation only when this callback exists. It also enables history buttons only when both showNavigationButtons and onNavigateTo are active. See the official LikeC4Diagram implementation and its feature setup.

The official VS Code extension sends navigation from the webview to the host. The host updates the current view and sends that view back to the webview. See the official VS Code preview and panel state update.

Criv uses ReactLikeC4, but it passes no onNavigateTo callback. It passes a browser property through an unsafe type cast, but browser belongs to LikeC4View, not ReactLikeC4. Thus, this property does not provide navigation. See packages/criv-likec4/src/renderer.ts and the official ReactLikeC4 properties.

The shared criv renderer needs these changes:

  1. Pass onNavigateTo to ReactLikeC4.
  2. Select the requested view and render it.
  3. Enable LikeC4 navigation buttons.
  4. Notify the host when the selected view changes, so the host view selector stays correct.
  5. Keep the current source-link action separate from drill-down navigation.

The VS Code host already selects a view from the opened file’s sourcePath. This makes one view per source file the safest initial layout. See previewModel.ts.

The Obsidian adapter currently opens the first view in the model and does not select by the opened file’s sourcePath. It must supply the file-owned view when the workspace is split. See main.ts.

Keep one project and use one primary view per view file:

docs/architecture/
  specification.c4
  model/
    landscape.c4
    cli.c4
    vscode.c4
    obsidian.c4
    relationships.c4
  views/
    overview/
      system-context.c4
      containers.c4
    components/
      cli.c4
      vscode.c4
      obsidian.c4
    code/
      cli-vault-loading.c4
      cli-refresh-pipeline.c4
      cli-architecture-exporter.c4
      cli-state-publication.c4
      vscode-preview.c4
      obsidian-adapter.c4
      shared-likec4-renderer.c4

Use these title folders:

  • Overview / System context
  • Overview / Containers
  • Components / CLI
  • Components / VS Code
  • Components / Obsidian
  • Code / CLI / Vault loading
  • Code / CLI / Refresh pipeline
  • Code / CLI / Architecture exporter
  • Code / CLI / State publication
  • Code / VS Code / Preview
  • Code / Obsidian / Adapter
  • Code / Shared / LikeC4 renderer
  1. Split the current .c4 source into the single-project folder structure.
  2. Put one primary named view in each view file.
  3. Make the Container and Component views scoped.
  4. Add explicit Component-to-Code navigation where one target is clear.
  5. Add onNavigateTo support to the shared renderer.
  6. Keep the VS Code and Obsidian selectors synchronized with renderer navigation.
  7. Select Obsidian’s initial view by the opened file’s sourcePath.
  8. Validate the complete workspace with criv watch --once and criv check.

Conclusion

The split is supported and is the correct design. Use one LikeC4 project, many small source files, one primary view per view file, scoped views for C4 drill-down, and explicit navigateTo rules for Component-to-Code links. Do not use nested LikeC4 projects for this purpose.

The split and renderer navigation belong in the same change. Without the renderer callback, the new view links will exist in the model but will not work in criv’s VS Code or Obsidian previews.

Local State And Snapshot Reference

criv watch --once and live watch publication write the current graph to .criv/state.json. They also keep content-addressed local snapshots for criv query diff. The snapshot store is ignored local data; source control remains the durable history boundary.

Configure the maximum number of distinct local snapshots in criv.toml:

[state]
keep = 20

keep must be a positive integer and defaults to 20. The bound is applied after every successful state publication. Publishing identical content again does not consume another slot, but makes that hash the newest publication. The snapshot named by .criv/latest is always protected.

Each source-index entry contains path and mime. New State output does not contain frecency. A current reader accepts an older criv.state.v1 document that has the extra field and ignores it. The schema name does not change because stored JSON readers already accept unknown fields. Rust users of criv-state-wire::SourceIndexEntry must remove direct use of the deleted field when they update.

Snapshot retention is automatic. The CLI does not expose snapshot list or prune commands. Corrupt recognized snapshots fail closed and are not deleted automatically.

criv query diff <a> <b> resolves latest and retained hashes from the local store. Other values remain embedded Git-ref lookups of .criv/state.json. Pruning local snapshots does not inspect or alter Git refs.

The lifecycle and recovery contract are defined by ADR-0094. The Source entry change is defined by ADR-0112.

Tooling and Git Hooks

The repository uses mise.toml to install the project hook runner and hk.pkl to define the hook behavior. criv.toml also declares project-level tooling files, including Hawk’s hawk.toml, as source roots. The decision record is ADR-0013.

Run the initial setup with:

mise install

The mise postinstall hook runs hk install --mise, so Git hook execution goes through mise x and uses the pinned tool versions, including hk, Rust 1.97.1, and actionlint. GitHub Actions security analysis is pinned here too: zizmor checks workflow, composite action, and Dependabot definitions for risky CI/CD patterns. Hawk uses the same Rust toolchain to check whether public Rust APIs are required by the shipped criv binary. Release tooling is also pinned here: Cocogitto calculates the next SemVer version from conventional commits, and cargo-release updates Cargo workspace versions. hk 2 evaluates hk.pkl with its bundled Pkl evaluator, so no separate pkl CLI is needed.

The top-level steps block in hk.pkl runs in the check, fix, and pre-commit hooks. It holds the gatekeepers (merge-conflict markers, files over 500 KB outside brag-output/, and private keys), the formatters, and the workflow and shell linters. pre-commit adds gitleaks on the staged changes, and check adds gitleaks on the Git history. hk fix does not stage its changes; pass --stage to stage them.

Workflow YAML under .github/workflows/ is checked with actionlint in pre-commit and the full check hook. zizmor runs in offline mode in the same hooks so local validation does not require a GitHub token or network access. This follow-up hook decision is ADR-0018.

Prose lint

Vale checks the prose in docs/. .vale.ini applies the built-in Vale style, so no vale sync step is necessary. The pre-commit hook runs Vale on changed files under docs/. The hosted vale CI job runs vale-cli/vale-action on all of docs/. To run it locally:

mise exec -- vale docs

If Vale flags a correct project word, add it to .vale/styles/config/vocabularies/criv/accept.txt. Add the (?i) prefix to a common word. Do not add it to a proper noun or an acronym, so Vale enforces the case. ADR-0143 records this decision.

Local Rust cache

Mise 2026.8.16 or later routes this project’s cargo commands through Mr. Boxington mbx 1.9.0. Run these commands after pulling the configuration:

mise install
mise reshim
mise exec -- cargo test --locked --workspace

An active mise shell also wraps plain cargo commands. Git hooks use the same wrapper through hk install --mise. Do not run mbx setup --global for this project. The checked-in configuration supplies the wrapper.

mbx shares compiler results between local worktrees. Its default cache policy controls incremental compilation. Worktrunk still copies target/ with shared filesystem blocks, and its removal hook still deletes each worktree’s copy. The wrapper sets MBX_TARGET_VIEWS=0 to keep these independent directories. Do not migrate a criv worktree to an mbx-managed target while this copy policy is in use. Give simultaneous Cargo builds separate output directories, for example --target-dir target/clippy for Clippy beside workspace tests.

The wrapper bypasses mbx when CI or GITHUB_ACTIONS is nonempty. Hosted builds keep the compiler cache policy from ADR-0108. Hawk also sets MBX_DISABLE=1, clears RUSTC_WRAPPER, and keeps target/hawk for its build files.

Inspect the shared cache and preview its cleanup with:

mise exec -- mbx cache stats
mise exec -- mbx gc --dry-run

Deleting a worktree does not delete the shared compiler cache. mbx collects eligible cache entries under its own retention policy. Cache byte counts do not measure physical space saved by filesystem block sharing.

To bypass mbx for one command, including a local release or debugger build:

MBX_DISABLE=1 mise exec -- cargo build --release

To remove automatic wrapping, remove [wrappers.cargo] and [wrappers.cargo.env] and the [env] entry for MBX_DISABLE from mise.toml. Then run mise reshim. Existing target/ directories remain valid Cargo output directories. The mbx tool pin and shared cache can be removed separately.

ADR-0142 records this integration. Issue #196 holds the proposed performance comparison. A local trial on 8 September 2026 used three runs per case on macOS/APFS, Rust 1.97.1, and mbx 1.9.0, before integration with PR #197. The values below are medians. Estimated disk allocation fell from 2.47 GiB to 2.04 GiB, including the mbx cache. Empty-cache tests took 275 s with mbx versus 78 s with plain Cargo; simultaneous tests and Clippy took 238 s versus 46 s. Warm and small-edit test times showed no clear gain. Disk estimates used free-space changes after deleting isolated trial outputs; they do not predict long-term cache growth. Use MBX_DISABLE=1 when build speed is the priority for these workloads.

Running criv from a hook runner

criv does not install Git hooks and does not set core.hooksPath, per ADR-0054. Setting that config replaces the hook directory wholesale, which silently disables whichever runner the repository already uses. Wire criv into your own runner instead.

The commands are stable:

StageCommands
commitcriv watch --once, criv check --changed, criv enforce --stage commit
pushcriv enforce --stage push

watch --once refreshes .criv/state.json before the changed check. The changed check is a staged, read-only partial fast path; plain criv check remains the full manual and hosted authority per ADR-0067. Run commit commands in the listed order.

With hk, as this repository does in hk.pkl:

local criv_check_changed = new Step {
  glob = List("**/*.md", "criv.toml")
  check_first = true
  check = "criv check --changed"
  fix = "criv check --fix"
}

local criv_enforce_commit = new Step {
  check = "criv enforce --stage commit"
}

hooks {
  ["pre-commit"] {
    fix = true
    steps {
      ["criv-check"] = criv_check_changed
      ["criv-enforce"] = criv_enforce_commit
    }
  }
}

With lefthook, in lefthook.yml:

pre-commit:
  parallel: false
  commands:
    criv-watch:
      run: criv watch --once
    criv-check:
      run: criv check --changed
    criv-enforce:
      run: criv enforce --stage commit

pre-push:
  commands:
    criv-enforce:
      run: criv enforce --stage push

Keep parallel: false for the commit stage: check reads the state watch writes, so they must not overlap.

Manual task entry points are:

mise run commit-msg -- .git/COMMIT_EDITMSG
mise run pre-commit
mise run pre-push
mise run fix
mise run perf
mise run vscode-build
mise run vscode-package
mise run release-plan
mise run release-auto

The hosted release workflow calls release-auto after CI passes. The command uses Cocogitto to prepare and push the version commit. Hosted jobs build, package, verify, attest, and publish that exact commit. Normal local development needs only release-plan; release-auto remains the workflow helper and a diagnostic entry point. See releasing for the full sequence.

Pre-commit and pre-push are the automatic local validation boundary. Agents and contributors do not need to replay them with an aggregate command after each commit. The former local aggregate task has been removed.

Hosted CI invokes the complete hk check profile directly. Every step hk runs is defined in hk.pkl with its command inline, per ADR-0049. To inspect or debug one core step explicitly:

hk check --all --plan                        # list every step
hk check --all --step hawk

Pre-push runs Clippy, workspace tests, Hawk, and criv push enforcement. Hawk is kept in both pre-push and the hosted CI profile so unnecessary public Rust APIs fail before hosted CI during normal local development.

mise run perf generates isolated vaults from the checked-in barrs-small and criv-medium manifests, then measures an explicit release binary. Build that binary first and identify its profile at the command line:

cargo build --release
mise run perf -- --binary target/release/criv --profile release

The script records repeated samples for cold, warm, and changed watch --once, validation, CI enforcement, docs-only next-adr-id, orphan-docs, and nodes --kind doc queries, the source-backed nodes --kind code --without-docs query, and a no-op snapshot diff. Five samples are collected by default; use --samples to change the count or repeat --case to select cases:

mise run perf -- --binary target/release/criv --profile release --samples 9
mise run perf -- --binary target/release/criv --profile release --case check

Every recorded sample receives a fresh generated vault. Warm cases perform an untimed state-building run inside that vault before the recorded command, and the changed case mutates the manifest’s declared number of supported source files without changing their sizes. A unique result directory preserves the run identity, exact manifest copies, raw JSONL samples, stdout/stderr, and a JSON summary with min/median/max and median absolute deviation. Failed samples remain in the raw evidence and make the harness fail.

mise run perf-container runs one ignored smoke case through Testcontainers. It builds criv and the harness inside a digest-pinned Rust Linux image. Docker is only an optional execution environment: the same checked-in manifests and generator define the vaults, results have their own container machine identity, and this task is not part of hooks or hosted CI. A Docker-API-compatible runtime is required. Large-workload measurement remains deferred.

Obsidian and VS Code validation are hosted-CI suites rather than automatic hk steps. Contributors changing a companion should run its package scripts directly. Run npm ci from the repository root first. The Obsidian companion commands are:

npm audit --workspace criv-obsidian-plugin --audit-level=high
npm --prefix .obsidian/plugins/criv run format:check
npm --prefix .obsidian/plugins/criv run lint
npm --prefix .obsidian/plugins/criv test
npm --prefix .obsidian/plugins/criv run build:wasm
npm --prefix .obsidian/plugins/criv run build:plugin
npm --prefix .obsidian/plugins/criv run build

The combined build preserves the release and manual entry point by running the Wasm build followed by the plugin build. The Wasm command invokes wasm-pack through mise exec cargo:wasm-pack@0.15.0, so the repository’s pinned tools are used rather than whichever Rust or wasm-pack appears first in the shell. It writes criv_wasm.js, criv_wasm_bg.js, and criv_wasm_bg.wasm under .obsidian/plugins/criv/pkg/. The plugin bundle keeps that runtime import external, so a distributable plugin must carry the generated pkg/ directory beside main.js.

If Obsidian reports that the criv Wasm runtime is unavailable, run the combined build, confirm those three runtime files exist, and reload the plugin. A state schema or parse error is separate: regenerate .criv/state.json with criv watch --once instead of rebuilding Wasm.

The VS Code-compatible extension lives in extensions/vscode-criv. Its local tasks are exposed through mise and npm:

npm audit --workspace vscode-criv --audit-level=high
npm --prefix extensions/vscode-criv run build
npm --prefix extensions/vscode-criv run test
npm --prefix extensions/vscode-criv run test:integration
npm --prefix extensions/vscode-criv run lint
npm --prefix extensions/vscode-criv run format:check
npm --prefix extensions/vscode-criv run package

The integration command launches the VS Code extension test host and fails on JSON diagnostics for extension manifest and language configuration files. Its short temporary user-data and extension-installation paths avoid macOS IPC path limits. Hosted CI runs this command under Xvfb; it is not part of local hooks.

The extension uses a read-only LikeC4 preview as the default .c4 editor. It selects the named view owned by the opened file. There is no fallback view: a file that owns none states so and points at a file that declares a view. Navigating inside the diagram opens the file that owns the target view in the same editor group, so the tab tracks the diagram. Use Reopen Editor With → Text Editor to edit the DSL. The preview webview uses packaged extension resources and does not use a CDN or a global LikeC4 command. Run criv watch --once after architecture or extension source changes. This keeps architecture state current. The coding agent authors the repository Code model and views under docs/architecture/code/, so watch does not replace them.

npm --prefix extensions/vscode-criv run package and mise run vscode-package build a local vscode-criv.vsix without publishing. mise run vscode-install builds that VSIX and installs it into the local editor; reload the window afterwards. The prepublish hook builds the Node.js Wasm target into extensions/vscode-criv/pkg/; the VSIX includes criv_wasm.js and criv_wasm_bg.wasm from that directory. If the extension reports an unavailable Wasm runtime, rebuild build:wasm, recreate the VSIX, confirm those files are present in the package, and reload the editor window. Regenerate invalid state with criv watch --once; do not treat a state-validation error as a runtime packaging failure.

The optional viewer is local-only under ADR-0087. The project does not publish it to the VS Code Marketplace or Open VSX. Release archives put the one viewer package next to the criv executable. Install it with an explicit editor selection:

criv install-editor --editor code
criv install-editor --editor cursor

Use --dry-run to validate the bundled viewer and local editor command without changing editor state.

Use hk validate after editing hk.pkl. Prefer hk built-ins when one exists; the commit message hook uses Builtins.check_conventional_commit instead of a shell wrapper around hk util check-conventional-commit.

The hawk hk step runs Hawk with warnings denied against the shipped criv CLI. The criv-wasm crate is excluded because its exported functions are consumed by the separately built Wasm artifacts, outside the CLI binary’s Cargo graph. The pre-push and full check hooks run Hawk whenever Rust sources, Cargo metadata, or its configuration change. Hawk uses target/hawk for its instrumented Cargo artifacts so its compiler work stays isolated from the other parallel checks. This policy is captured in ADR-0043 and the local/hosted validation boundary is ADR-0061.