Skip to content
Chock
Style rules (16 of 30)

This page is the documentation for unstable. Read the current version.

Style rules

A style rule is a tree-sitter query of the project’s own plus one bound. A write that breaks the bound is refused before a byte reaches the disk, and the agent is told which rule, which number and which line.

.{
    .style = .{
        .parsers = "TREE_SITTER_PARSERS",
        .grammars = .{
            .{ .language = "zig", .extensions = .{".zig"} },
        },
        .rules = .{
            .{
                .name = "comment-block",
                .query = "style/comments.scm",
                .max_lines = 10,
            },
        },
    },
}

style/comments.scm is a file in the project, in tree-sitter’s own query language:

(comment) @c

Together those say: no comment in a .zig file covers more than ten lines. The agent that writes an eleven line comment gets this back, and the file is unchanged:

write_file was refused by the style rule comment-block: a match covers 11
lines, and the rule permits 10, first at line 1.

The project writes the rules

Chock knows nothing about any language. A project says which grammars it has, writes its own queries, and picks the numbers. So a rule can be about anything a query can match, and nothing has to be added to Chock to enforce a house style it has never heard of.

One bound a rule

A rule with two bounds is two rules, so exactly one of these is written:

Bound What it counts
max_matches matches in the file. Zero refuses on the first one.
max_lines lines one match covers. A match on one row covers one line, so zero cannot be met and is refused when the block is read.
max_share lines at least one match covers, against the file’s own line count, as a percentage.

max_share counts a line once however many matches cover it. A query cursor walks a tree, so a parent’s match arrives before a child’s and a later row before an earlier one. The lines are sorted and swept before they are counted, which is why a nested match is not counted twice.

Refusing and reporting

A rule refuses by default. A rule the project is still adopting reports instead: the write lands and the agent is told anyway.

.{ .name = "long-function", .query = "style/fn.scm", .max_lines = 60, .decision = "report" },

A reported rule’s sentence is appended to what the tool call answers, under the line that says what was written.

What is checked, and what is not

Checked Not checked
write_file write_memory, because a note an agent keeps is not the project’s source
edit_file, against the content the edit would leave behind a file no grammar of this project covers
a file a person edits, because Chock gates the agent and not the person

The content is checked, not the file on disk, so an edit is measured against what it would leave behind and not against what was there.

A check that cannot finish refuses

A parser that fails, a parser that runs out of time, and a query file that cannot be read all refuse the write. A write Chock could not check is a write it will not make, and zero matches would otherwise read as a file that breaks no rule.

A grammar this host has not got is the one case that does not refuse. The rule did not run, the write goes ahead, and the style.check row says no_parser. That is a setup this host is missing rather than a check that failed, and a missing grammar would otherwise stop every write of that language.

The two parsers differ after a timeout. The command runs once a parse, so a timeout costs that one write. The wasm parser is one process a session, and a check it gave up on is still being worked on inside it, so a timeout stops that parser for the rest of the session and every write after it is refused by name. A parse that cannot finish in ten seconds is doing something no file in this repository does.

The parsers directory

parsers names an environment variable and never a path, because the paths change between setups. The shell exports it, and Chock looks under it for one file a language:

$TREE_SITTER_PARSERS/zig.so

Chock’s own dev shell builds that directory from nixpkgs and exports the variable. A project sets it up for its own shell, because a store path in chock.zon would change every time the grammar package is bumped. A variable that names no directory turns the rules off for the session and says so, and a directory with no file for one language leaves that language unchecked.

The parser runs in the sandbox

The parser is a program, and it runs where cat and grep run: the staged source, the query and an empty configuration file are bound read only, the grammar directory is bound read only, and nothing of anybody’s home directory decides which grammar answers. The command is given --config-path, --lib-path and --lang-name, so it reads no configuration of its own.

Whether a rule runs at all

One action covers the whole block:

.{ .action = "style.parse", .decision = .allow }

Chock ships no default for it, so a project that writes rules writes this rule too. Anything other than allow turns the rules off for the session and says so in one line, naming the decision and the action.

What the log holds

Each rule that ran appends a style.check event:

{"kind":"style.check","rule":"comment-block","path":"src/main.zig","language":"zig","count":1,"line":1,"outcome":"refused"}

outcome is permitted, refused, reported, no_parser, no_answer or no_query. The query text and the matched source are not in the event: the rule’s name says which query ran, and the matched text is the file itself. chock sessions show prints a line for each.

Bounds this has

Limit Value
Rules in a block 32
Grammars in a block 16
Extensions a grammar 8
Matches a query may answer with 4096, and more is a refusal
A query file 64KB
A file that is parsed 1MB

The other engine

The engine key picks the parser. command is the default and runs tree-sitter as a program. wasm runs a parser compiled to WebAssembly inside Chock’s own engine, which needs no tree-sitter on the host:

.style = .{
    .engine = "wasm",
    .grammars = .{ .{ .language = "zig", .extensions = .{".zig"} } },
    .rules = .{ .{ .name = "comment-block", .query = ".chock/style/comment.scm", .max_lines = 10 } },
}

A rule means the same thing whichever parser answered it. One test puts both over one file, with the same rules, and fails when the two answers differ.

Three things about this engine are worth knowing before turning it on.

The first write waits. The engine compiles every function in the module before the first answer, which is about half a minute on the machine this was built on, and the module holds 269 functions. The cost is paid once a session, because the parser is one process that stays alive, so later writes are answered in milliseconds. A write that arrives during the wait waits with it.

One language a session. A module carries tree-sitter’s runtime plus one grammar. modules names an environment variable holding a directory of <language>.wasm files, and the session takes the first grammar this project names that has one. A language with no module is a rule that did not run: that write is not checked, it is not refused, the style.check row says no_parser, and the session says so once. The command engine covers every grammar under its own directory, so a project with rules for several languages wants that one.

The module is built on its own. Chock’s dev shell builds it from nixpkgs and exports CHOCK_PARSER_MODULES. A variable that names no directory, or a directory with a module for none of the project’s grammars, turns the rules off for the session and says which.

The parser runs in the same sandbox the command runs in, started the way a plugin host is started: Chock itself and the module are each bound read only at one path, and the parse has no network and no other file. A host needs a sandbox of its own, and macOS has none that nests, so this engine is Linux only and a Mac takes the command. The session says so at the start.