Skip to content

Rule expressions

A rule is a check a type declares about its own records, and most of them are one line of YAML. The line is an expression, kac evaluates it against every record of that type, and the fault it reports carries the rule's own id. Adding a check is adding YAML rather than editing the tool.

An expression asks a small, fixed set of questions about one record. It has no variables, no loops and no way to reach another record. That boundary is the point: a rule this small can be read by whoever trips it.

How a rule is declared

A rule fires when its expression is false. Write the expression as the condition that ought to hold, not as the fault:

rules:
  - id: symptoms-first
    description: Symptoms is the first section after the H1. That is how the reader finds the document.
    severity: error
    expr: "first_section() == 'Symptoms'"
    message: >
      Symptoms must be the first section. Someone reaching for this at 2am matches on what they are
      seeing, not on what the document is called.

description: is what the rule means, and the generated checks table on the type's page renders it. message: is what an author is told when the rule fires. One is a definition and the other is a diagnosis, so do not write the same sentence twice.

A rule with an expr: must also declare a severity, meaning the level it reports at, and a message. A rule that claims to be finished must be able to report.

A rule with no expr: keeps its id and description, declares no severity, and renders as an intention: a behaviour written down that no code answers to yet.

The grammar

It is frozen. Extend it only on a deliberate decision, and never for convenience.

expr    := implies
implies := or ( "implies" or )*          // A implies B  ≡  (not A) or B
or      := and ( "or" and )*
and     := cmp ( "and" cmp )*
cmp     := add ( ("=="|"!="|"<"|"<="|">"|">=") add )?
add     := mul ( ("+"|"-") mul )*        // needed only for `links() * 40` style ratios
mul     := unary ( ("*"|"/") unary )*
unary   := "not" unary | primary
primary := STRING | INT | call | "(" expr ")"
call    := IDENT "(" ( expr ("," expr)* )? ")"
  • Three types: string, int and bool. There are no boolean literals, so every condition starts from something the record says.
  • Strings are single-quoted, and a doubled quote is one quote, as in YAML and SQL. There are no backslash escapes. The strings that most need a quote in them are regular expressions, and a second layer of escaping over those is how they stop being readable.
  • A comparison is not chainable. 1 < words() < 40 is a sentence rather than a condition, and the parser declines it instead of choosing an associativity nobody asked for.
  • Dates compare as ISO strings, so field('a') >= field('b') works without a date type.
  • Division by zero gives zero. Nothing in the default types divides, and the operator exists because the grammar is frozen. A rule that trips over it reads as a threshold nobody meets.
  • No variables, user-defined functions, quantifiers or collections.

An expression is written as a string. Several rules are conditionals or ratios, and those read cleanly on one line and badly as a tree of nested YAML objects.

An absent field

field(...) returns either a string or nothing. A comparison where either side is absent is false, and != is the negation of ==, so != is true. That is one rule for every operator.

The consequence is an idiom. field('detected-at') >= field('occurred-at') fires on a record missing a value, where required-field has already said so in better words. So a rule about a field that may be absent guards it:

expr: "present('detected-at') and present('occurred-at') implies field('detected-at') >= field('occurred-at')"

This is why a written rule runs longer than a first sketch of it. The alternative is each operator guessing which way silence should fall, which trades one visible guard for a table of special cases nobody remembers.

When an expression is checked

kac parses and type-checks each expression before it opens a single record, and anything wrong stops the load and names the rule. That covers a syntax error, an unknown fact, the wrong number of arguments and a number too large to hold. It also covers a comparison between a number and text, arithmetic on text, and a whole expression that is not a yes/no question.

Without it, words() == 'three' compiles and then evaluates false for the life of the schema. That is a check which appears wired up and never fires, the exact failure this layer exists to end.

The facts an expression can ask for

These are the whole callable surface. Each reads what the parse pass already produced, so the evaluator never re-parses markdown. today() is the last row because it is the one that reads nothing about the record.

Function Returns Reads
field('name') string? a frontmatter scalar
present('name') bool whether that field has anything, scalar or list. False for a bare key and an empty list as for a missing one
entries('name') int how many entries that field carries. present() asks whether, this asks how many. Zero where the field is absent
field_matches('name', 're') bool that scalar against a pattern. False where absent, and the one pattern fact that sees frontmatter
entries_match('n', 'k', 're') bool key k of every object under field n against a pattern. True where the field is absent or has no such key
section('Title') bool whether an H2 of that name exists (case-insensitive)
section_count('Title') int how many times it appears. section() asks whether, this asks how many
first_section() string the first H2, or empty where there is none
links() int how many links the body has
words() int every heading and paragraph the record renders. Frontmatter and fenced code carry no inline content and fall out
matches('re') bool the body as written, code fences, link targets and markdown syntax included. Frontmatter is not read
section_matches('Title', 're') bool the same, bounded to one section, and false where the record has no such section
span('a', 'b') string the time between two timestamp fields, as an ISO 8601 duration in hours, minutes and seconds
days('a', 'b') int the whole days between two date fields, so a rule can compare a gap against a threshold
today() string the day the run happens, as an ISO date, so a rule compares it against a date field

span() answers in hours, never days. ISO 8601 gives a day no fixed length, so P1DT2H and PT26H are one span written two ways, and a rule comparing the text would take one and refuse the other. It answers with the empty string where either field is absent, is not a moment, or where the second is before the first. Each of those is another check's to report, so a rule asking for a span guards on them rather than reporting the same fault twice.

days() reads date fields, where span() reads timestamps. A date has no time of day to lose, so the count is exact and a rule compares it against a plain number. It answers zero where a field is absent, where a value is not a date, and where the second date is the earlier, so a rule asking for a gap guards on those the way one asking for a span does. A same-day gap is zero as well, and no caller here needs to tell the two apart.

today() answers with the day the run happens, which is how a rule asks whether a date the record carries has gone by. kac validate reads that day once and hands it down, so a corpus validated across midnight gives its first record and its last the same answer.

words() and matches() deliberately see different documents. One walks the rendered text and the other the source. That is what lets matches() find a credential pasted into a fenced block, the case those rules exist for. It also finds **MUST**, an obligation the rendered text would have flattened into an ordinary word.

Adding a fact

Reach for a fact, not for the grammar. Adding one means one method on the tool's Facts class, one row in its function table, and one row in the table above. A test holds this page's table against that function table, so the page and the tool cannot come apart quietly. The grammar never changes.

section_count() and field_matches() each serve a single rule today, and each answers a question the next corpus will ask again. That is the usual shape of a new fact, and the reason reaching for the grammar instead is almost always the wrong move.

A question that genuinely needs loops, joins or quantifiers is a rule written in C#, and tooling/README.md is where that is done.

Where to go next

What the schema is held to says what kac refuses in the pass that compiles these expressions.