Writing custom rules

Enforce a project-specific check ctxgrd doesn’t ship – in any language you can write an executable in – and wire it into a namespace alongside the built-in core.* rules.

Before you start: rules only see markdown

External rules run against .md documents that a namespace has already claimed. They do not run against source code, Turtle, YAML, or any other file format – a namespace pointed at non-markdown paths ingests nothing, no matter which rules you attach to it, and ctxgrd will tell you so:

$ ctxgrd --root .
warning[cfg.paths-skipped]: [STATUTE] paths match 12 files the walker skipped, and the namespace ingested no documents
  help: to lint non-markdown facts, emit them from a source (`ctxgrd docs sources`) — a namespace cannot claim them directly
  note: only .md files are ingested; the skipped files had: .pl

If your facts aren’t markdown – Prolog clauses, JIRA tickets, rows from an API – you want a source, not a rule. A source emits document envelopes ctxgrd can lint; see Linting non-markdown facts with external sources. Come back here once your facts are markdown documents, or if what you’re checking is a property of markdown you already have.

What a rule is

A rule is an executable that reads every document in its namespace on stdin, and writes zero or more diagnostics to stdout. ctxgrd runs it once per lint pass, feeding it the whole batch – not once per file.

1. Scaffold the rule

ctxgrd new rule adr.has-external-link "every ADR body contains an external link"
rules/adr/has-external-link/run

Next steps:
  • Implement the check in rules/adr/has-external-link/run (look for the `TODO:` line).
  • Add "adr.has-external-link" to the `rules` list of [ADR] in ctxgrd.toml.
  • Verify wiring:                  ctxgrd rules adr.has-external-link

Note: external rules only run against `.md` documents — see `ctxgrd docs rules`.

The rule code is <namespace>.<name>, and the directory path is the rule code – rules/adr/has-external-link/run is adr.has-external-link, always. This bijection is why activating a rule is a one-line config change and nothing else.

2. Read the stdin contract

Each line on stdin is a JSON object with a path (an absolute path to the document body – a real file for local documents, a materialized temp file for source-derived ones) and a context object carrying everything ctxgrd knows about the document:

{
  "path": "/abs/adrs/001-use-event-sourcing.md",
  "context": {
    "id": "ADR-001",
    "namespace": "ADR",
    "depends_on": ["PRD-001"],
    "metadata": { "id": "ADR-001", "title": "Use event sourcing", "status": "accepted" }
  }
}

context.metadata is the unified metadata map – frontmatter keys for local files, extra fields for source-derived documents. Read from it, not from the raw file, so the same rule works for both.

3. Write the check and emit diagnostics

A rule that flags any ADR body with no external link:

#!/usr/bin/env bash
set -euo pipefail

while IFS= read -r line; do
  path=$(printf '%s' "$line" | jq -r '.path')
  if grep -qE 'https?://' "$path"; then
    continue
  fi
  printf '{"path":%s,"severity":"warning","message":"ADR body contains no external links","line":0,"col":0}\n' \
    "$(printf '%s' "$path" | jq -Rs .)"
done

Each diagnostic line needs path (which document), severity ("error" or "warning"), message, line, and col. Don’t emit a code field – ctxgrd attaches the rule code from the directory path automatically. Exit 0 when the script ran cleanly, whether or not it found problems; a non-zero exit means the rule itself failed, and ctxgrd reports ext.runtime-error instead of trusting whatever partial output it produced.

4. Activate it

[ADR]
rules = [
  "core.frontmatter",
  "core.id",
  "adr.has-external-link",
]
ctxgrd --root .

5. Confirm the wiring, not just the syntax

Run the rule against a document you know should fail before trusting a clean result. ctxgrd rules <code> confirms the rule resolved into the namespace at all:

ctxgrd rules adr.has-external-link

A rule that never runs – because its namespace claimed nothing, per the .md-only restriction above – and a rule that runs and finds nothing wrong produce the identical clean report. The only way to tell them apart is to inject a known-bad document and watch the rule catch it.

6. Pass parameters, if the check needs them

[ADR."adr.min-consequences"]
min_items = 3
timeout_sec = 120

The sub-table serializes to JSON as CTXGRD_RULE_PARAMS. Read it with jq inside the script:

min=$(printf '%s' "${CTXGRD_RULE_PARAMS:-{\}}" | jq -r '.min_items // 1')

timeout_sec overrides the default 60-second timeout for the whole batch invocation – useful for a rule that shells out to something slow.

Next steps