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 initinstalls 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.mdat 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.mdat 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 inlinepolicy.patternsast-grep rules thatcriv checkandcriv enforceevaluate. - Everything under
docs/is a criv vault note and needs frontmatter with at leastid,kind, andtitle, orcriv checkfails 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/skills | Label in our tracker | Meaning |
|---|---|---|
needs-triage | needs-triage | Maintainer needs to evaluate this issue |
needs-info | needs-info | Waiting on reporter for more information |
ready-for-agent | ready-for-agent | Fully specified, ready for an AFK agent |
ready-for-human | ready-for-human | Requires human implementation |
wontfix | wontfix | Will 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.
| Family | Labels |
|---|---|
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-idfor 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 checkbefore 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]
| Key | Type | Default | Meaning |
|---|---|---|---|
docs | string | docs | The directory that holds vault notes. |
adr | string | adr | The 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]
| Key | Type | Default | Meaning |
|---|---|---|---|
roots | array of strings | ["src", "lib"] | Directories criv indexes for the source graph. |
exclude | array 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]
| Key | Type | Default | Meaning |
|---|---|---|---|
source | boolean | true | Whether 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]
| Key | Type | Default | Meaning |
|---|---|---|---|
keep | integer | 20 | How many state snapshots to retain under .criv/snapshots/. |
keep must be one or more. See the state reference for what
a snapshot holds.
[enforce]
| Key | Type | Default | Meaning |
|---|---|---|---|
stages | array 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.
| Key | Type | Meaning |
|---|---|---|
id | string | The identifier reported with the violation. |
scope | array of glob strings | Files the policy applies to. |
deny | array of strings | Import 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:
| Item | Required value |
|---|---|
| Node.js | 26.5.1 |
| LikeC4 | 1.59.2 |
| React | 19.2.8 |
| React DOM | 19.2.8 |
| Bridge protocol | 1 |
| State schema | criv.state.v1 |
| Process limit | 60 seconds |
| Standard output limit | 16 MiB |
| Standard error limit | 16 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.
Source links
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:
| Measure | Result |
|---|---|
| Node.js empty-process start | 0.02 s |
Full criv check with LikeC4 layout | 3.80 s |
| Obsidian production JavaScript | 2,764,888 bytes |
| VS Code extension JavaScript | 26,311 bytes |
| VS Code LikeC4 webview JavaScript | 2,294,423 bytes |
| Cold browser bundle transfer | 17.5 ms |
| Cold state transfer | 2.9 ms |
| Cold first LikeC4 view | 112.2 ms |
| Test SVG export | 188,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:
| Manifest | Tier | Notes | Source files | Source bytes | Relationships | Links and references | Policies | C4 artifacts | Changed sources |
|---|---|---|---|---|---|---|---|---|---|
barrs-small.toml | small | 23 | 12 | 273,948 | 0 | 3 | 0 | 0 | 1 of 12 |
criv-medium.toml | medium | 77 | 119 | 1,291,218 | 0 | 212 | 4 | 4 | 1 of 119 |
elixir-mixed.toml | medium | 12 | 24 | 524,288 | 0 | 32 | 1 | 1 | 1 of 24 |
elixir-parse-heavy.toml | large | 4 | 128 | 4,194,304 | 0 | 8 | 0 | 0 | 1 of 128 |
elixir-relationships.toml | large | 4 | 128 | 4,194,304 | 8,192 | 8 | 0 | 0 | 1 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.Zfor the root CLI crate.criv-wasm-vX.Y.Zfor the Wasm helper crate.
The workflow builds criv archives named by Rust target triple:
criv-x86_64-unknown-linux-gnu.tar.gzcriv-aarch64-unknown-linux-gnu.tar.gzcriv-aarch64-apple-darwin.tar.gzcriv-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
devortest. Cargo states that LTO increases link time. - Do not increase
opt-levelto 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
sccachestrategy. - 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
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
messageformatter or plugin in all three. - Go, Python, and Rust do not emit the format. godog, behave, pytest-bdd,
and cucumber-rs have no
messagewriter. 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.
sourceReferenceholds 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.
| Field | Purpose |
|---|---|
meta | Protocol version, implementation, runtime, OS, CPU, CI data. |
source | The complete text of one source file. |
gherkinDocument | The abstract syntax tree of one parsed source file. |
parseError | A Gherkin parse failure for one source file. |
pickle | One executable scenario, derived from the syntax tree. |
stepDefinition | One step definition, with its pattern and its source location. |
parameterType | One custom parameter type. |
undefinedParameterType | A parameter type that an expression uses but does not declare. |
hook | One hook, with its type and its source location. |
suggestion | Code snippets that could implement one undefined step. |
testRunStarted | The start of the run. |
testRunFinished | The end of the run, with a success boolean. |
testRunHookStarted | The start of one global hook execution. |
testRunHookFinished | The end of one global hook execution, with a result. |
testCase | The execution plan of one pickle, as a list of test steps. |
testCaseStarted | The start of one attempt of one test case. |
testCaseFinished | The end of one attempt, with a willBeRetried boolean. |
testStepStarted | The start of one test step. |
testStepFinished | The end of one test step, with its result. |
attachment | An embedded attachment, such as a screenshot. |
externalAttachment | An 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:
UNKNOWNPASSEDSKIPPEDPENDINGUNDEFINEDAMBIGUOUSFAILED
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:
pickle.steps[].idgives the identifier of a pickle step.testCase.testSteps[]holdspickleStepIdandstepDefinitionIds.stepDefinition.idmatches one entry ofstepDefinitionIds.stepDefinition.sourceReferencepoints at the code.
See
TestStep
and
StepDefinition.
stepDefinitionIds is a list, and the documentation states its cardinality:
- An
UNDEFINEDstep has an emptystepDefinitionIdslist. - An
AMBIGUOUSstep 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
JavaMethodReferencebecomes ajavaMethodobject, which holdsclassName,methodName, andmethodParameterTypes. It carries no line and no file. - A
StackTraceElementReferencebecomes ajavaStackTraceElementplus alocation. ThefileNameis 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:
- The report always gives
pickle.uri, so the file part of the identity is available. - For a Scenario Outline,
pickle.nameis the expanded name and is not the scenario name. A consumer that builds the identity frompickle.namewould produce a different identity than a consumer that reads the.featurefile. The identity must come fromastNodeIds[0]and thegherkinDocument. - One scenario produces many pickles when it has an
Examplestable. 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.
| Implementation | Emits messages | Exact flag | Since |
|---|---|---|---|
| cucumber-js (JavaScript) | Yes | --format message for stdout, or --format "message":"out.ndjson" for a file | 7.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 property | 6.0.0 |
| godog (Go) | No | none | not applicable |
| behave (Python) | No | none | not applicable |
| pytest-bdd (Python) | No | --cucumberjson=<path> writes legacy Cucumber JSON, not messages | not applicable |
| cucumber-rs (Rust) | No | none | not applicable |
| pytest-bdd-ng (Python, third party) | Yes | --messages-ndjson=PATH | 2.0.0 |
| cucumber-node (JavaScript) | Yes | Listed as a compatibility kit adopter | unconfirmed |
| Reqnroll (.NET) | Yes | Not researched; the README names it | 3.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:
- Do not reject a stream on an unknown
protocolVersion. Nothing in the protocol asks for that, and the reference reader does not do it. - Ignore unknown fields, and ignore unknown envelope keys.
- Treat every field as optional unless the schema marks it required, because an older producer will omit newer fields.
- 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 theEnvelopeschema. - 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
*Startedmessage comes before its matching*Finishedmessage. TestCaseStartedandTestCaseFinishedare insideTestRunStarted/TestRunFinished.TestStepStartedandTestStepFinishedare insideTestCaseStarted/TestCaseFinished.Attachmentmessages are insideTestStepStarted/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.
- 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. - 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. - Scenario status is a computation, not a field. criv must group
testStepFinishedbytestCaseStartedIdand take the most severe status. The severity order is published. - 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 callersfrom this report would work unevenly across languages. - 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.
- 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 thegherkinDocumentenvelope, or re-parse the.featurefile.
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.
- The report can carry a Git revision, but only from CI.
Meta.ci.githoldsremote,revision, and optionallybranchandtag. The compatibility kitmetasample shows a real one. ButMeta.ciis optional, and the@cucumber/ci-environmentlibrary returns a value only when it detects a CI server. See theci-environmentREADME andMeta.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. - The report carries no source content hash. There is no field that holds a
digest of the
.featurefile or of the step definition file.Source.dataholds the complete feature text, so criv could hashSource.dataitself 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. TestRunStarted.timestampgives the run time. It is required, so every report has a run time that does not depend on the file modification time.- The
UNDEFINEDsignal is available without running anything past parse.testCase.testSteps[].stepDefinitionIdsis empty for an unmatched step. So “a document describes behaviour with no code” is visible in the plan and not only in the result. - A retried scenario produces more than one attempt.
TestCaseStartedholdsattempt, andTestCaseFinishedholdswillBeRetried. criv must pick the last attempt, or it will report a stale failure for a scenario that later passed. - Parallel runs interleave. criv must not read the report as an ordered
log, and must not assume that the last
testStepFinishedin the file belongs to the last scenario. - 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:
cucumber/messagesatb9b8315, version34.2.1. The JSON schema and its generated documentation are the protocol.cucumber/compatibility-kitat49fc3c0. The reference message streams and the ordering contract.cucumber/cucumber-jsatf3f489f.cucumber/cucumber-rubyat720e267.cucumber/cucumber-jvmat0c59737.cucumber/godogat17b50c6.cucumber-rs/cucumberatd958725.behave/behaveata842816.pytest-dev/pytest-bdd,radish-bdd/radish, andelchupanebrej/pytest-bdd-ng, for the other Python options. These were read on their default branch and are not pinned.cucumber/ci-environment, for theMeta.cibehaviour. Read on its default branch and not pinned.
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.0is released. The changelog has the section, but the newest GitHub release tag ispytest-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.tomland the writer module only. - Whether the legacy
jsonformatter 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
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"}fortree-sitter-gherkin. Request:https://registry.npmjs.org/tree-sitter-gherkin. - The
tree-sitterGitHub organization holds nogherkinrepository. Request:gh api orgs/tree-sitter/repos. - The
tree-sitter-grammarsGitHub organization holds nogherkinrepository. Request:gh api orgs/tree-sitter-grammars/repos. nvim-treesitterhas nogherkinentry. Request:gh api search/code -f q="gherkin repo:nvim-treesitter/nvim-treesitter"returnstotal_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 orderfails. Any digit or punctuation fails.tag: $ => token(seq('@', /[a-z_]+/)). A tag must be lowercase.@Smokefails.@wip-2fails.feature: seq(optional($.tag), ...). One tag only.@billing @webfails.steps: seq(optional($.given_steps), $.when_step, $.then_steps). A scenario must hold at least oneWhenstep and at least oneThenstep, in that order. AGiven-only scenario fails.scenario_keywordischoice('Scenario', 'Example')with:. There is noScenario Outlinerule and noExamplesrule 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.
| Fact | Value |
|---|---|
| Licence | MIT OR Apache-2.0 |
| Latest release | 0.16.0, 2026-04-23 |
| Previous releases | 0.15.0 (2025-12-12), 0.14.0 (2023-07-14) |
| Downloads | 16,932,413 |
| MSRV | 1.88 |
| Runtime dependencies | peg 0.6.3, textwrap 0.16, thiserror 2.0, typed-builder 0.23 |
| Transitive normal crates | 53 |
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:
| Binary | Bytes |
|---|---|
| Baseline: read a file, print its length | 286,128 |
Same, plus gherkin::Feature::parse and a full AST walk | 385,696 |
| Delta | 99,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.3pullsicu_segmenter 2.3.0,icu_collections,icu_locale_core,icu_provider,zerovec,yoke, andpotential_utf. Most of the 53 transitive crates come from this one path.gherkinexposes no feature to droptextwrap.gherkinhas a defaultparserfeature that addstyped-builder. Turning it off removesFeature::parseas 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:
- Scenario Outline is not a distinct type. Both branches of the parser
rule scenario()build aScenario. Onlykeyworddiffers, andexamplesis non-empty. Seesrc/parser.rs, the two alternatives ofrule scenario(). There is noScenarioTypeenum in 0.16.0. - 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 crivfeature:symbol can carry no doc comment. - Tags lose the
@.rule tag() = "@" s:tag_char()+ { s.join("") }returns["billing", "web"], not["@billing", "@web"]. - Step keywords keep a trailing space.
keyword="Given ". Block keywords do not:keyword="Scenario",keyword="Scenario Outline". spanexcludes leading tags.span.startis captured after the tag list, sospanfor the reference Feature starts at byte 34, after# a leading commentand@billing @web.positionis the line and column of the keyword, 1-based.StepTypenormalizesAnd,But, and*. OnlyGiven,When, andThenexist. The literal keyword survives inStep::keyword. Version 0.16.0 also treats anAnd-like orBut-like keyword at the start of a scenario as aGivenstep (#53).- Doc string content keeps its leading and trailing newline.
docstring=Some("\na doc string\n").rule docstring()accepts both"""and```delimiters and runstextwrap::dedenton the body. This is the only use oftextwrapin 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:
| Input | Result |
|---|---|
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: header | Error at 1:14: {"unknown keyword"}. No symbols. |
| Empty file | Error 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:
-
An ADR that declares
language: gherkinfails to compile its policy.parse_languagecallsSupportLang::from_strand reportsunsupported ast-grep language `gherkin`. -
Even with a valid language,
find_policies_batchskips a.featurefile 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_pathmaps by extension and knows nofeature, 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.dllper 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. DynamicLangis a different type fromSupportLang.CompiledPolicywould 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::keywordorScenario::keyword. A kind derived from the keyword string would be localized, andscenario:would becomescénario:in a French vault.
Two limits remain:
BackgroundisContextein French and is reached through the samebackgroundfield, so it needs no keyword read.ScenarioandScenario Outlinecan only be told apart by the keyword string or byexamples.is_empty(). The keyword string is localized, soexamples.is_empty()is the only localization-safe test, and it is wrong for aScenario Outlinethat has noExamplesblock 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-gherkinrepositories is better than the two reviewed. OnlySamyAB/tree-sitter-gherkin(the Helix pin) andDevAbdullahUk/tree-sitter-gherkin(the most complete) were read. - The binary-size delta on Linux and Windows targets. Only
aarch64-apple-darwinwas measured. - Whether the
icu_*crates thattextwrappulls 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
gherkin0.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 officialtestdatacorpus incucumber/gherkin/testdata. - Whether
gherkinaccepts the Markdown-with-Gherkin format thatMARKDOWN_WITH_GHERKIN.mddefines. 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 platform | Rust target | Release asset |
|---|---|---|
| Linux x86-64 with glibc | x86_64-unknown-linux-gnu | criv-x86_64-unknown-linux-gnu.tar.gz |
| Linux ARM64 with glibc | aarch64-unknown-linux-gnu | criv-aarch64-unknown-linux-gnu.tar.gz |
| macOS Apple Silicon | aarch64-apple-darwin | criv-aarch64-apple-darwin.tar.gz |
| Windows x86-64 | x86_64-pc-windows-msvc | criv-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
| Path | Setup cost | Version and platform selection | Integrity | Metadata owner and update duty | Main failure modes | Result |
|---|---|---|---|---|---|---|
Direct GitHub asset or github:TudorAndrei/criv | Low, 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 backend | Current 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 file | criv 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 only | Very 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 plugin | High. 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 policy | Reject. |
| Aqua Standard Registry entry | Low 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 environments | The 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 security | The 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 registry | Higher 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 guidance | Reject. |
Integrity flow
The release workflow already has the necessary producer flow:
- It builds each target from one release commit.
- It packages the binary and the editor archive.
- It writes SHA-256 values to
SHA256SUMS.txtandrelease-manifest.json. - It verifies the archives on their target systems.
- It attests the archives, checksum file, and manifest with
.github/workflows/release.yml. - 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:
amd64becomesx86_64.arm64becomesaarch64.- Linux uses
unknown-linux-gnuandtar.gz. - macOS uses
apple-darwinandtar.gz. - Windows uses
pc-windows-msvcandzip.
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
- Fork
aquaproj/aqua-registry. - 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, createspkgs/TudorAndrei/criv/registry.yamlandpkgs/TudorAndrei/criv/pkg.yaml, creates a branch and commit, and runs its container tests. Official add-package process. - 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.txtby mistake. - Add the four exact
supported_envs, theSHA256SUMS.txtparser, and thegithub_artifact_attestations.signer_workflowvalue shown above if the generator does not add them. - Add a version override that supports
darwin/amd64throughv0.2.0. Do not add macOS Intel to the final override for current versions. - Confirm that the package exposes only
crivorcriv.exe. The bundledvscode-criv.vsixis data forcriv install-editor; it is not another command. - Run
argd t TudorAndrei/criv, then runargd grto update the merged root registry. Test the latest release on the four supported environments. Keep one old version inpkg.yamlonly if an old asset rule needs a version override. The aqua registry states thatpkg.yamlis test data, not the list of installable versions. Registry structure,pkg.yamlguide. - 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:
- Add
registry/criv.tomltojdx/misewith the exact TOML record in the conclusion. - Run
mise test-tool crivon 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. - 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.
- 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.Zroot 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 --versionoutput.
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.
Navigation between views
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:
- Pass
onNavigateTotoReactLikeC4. - Select the requested view and render it.
- Enable LikeC4 navigation buttons.
- Notify the host when the selected view changes, so the host view selector stays correct.
- 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.
Recommended file layout
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 contextOverview / ContainersComponents / CLIComponents / VS CodeComponents / ObsidianCode / CLI / Vault loadingCode / CLI / Refresh pipelineCode / CLI / Architecture exporterCode / CLI / State publicationCode / VS Code / PreviewCode / Obsidian / AdapterCode / Shared / LikeC4 renderer
Recommended implementation order
- Split the current
.c4source into the single-project folder structure. - Put one primary named view in each view file.
- Make the Container and Component views scoped.
- Add explicit Component-to-Code navigation where one target is clear.
- Add
onNavigateTosupport to the shared renderer. - Keep the VS Code and Obsidian selectors synchronized with renderer navigation.
- Select Obsidian’s initial view by the opened file’s
sourcePath. - Validate the complete workspace with
criv watch --onceandcriv 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:
| Stage | Commands |
|---|---|
| commit | criv watch --once, criv check --changed, criv enforce --stage commit |
| push | criv 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.