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: .plIf 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 .)"
doneEach 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-linkA 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 = 120The 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
- Linting non-markdown facts with external sources – the fork for facts that aren’t markdown documents.
- Configuring namespaces – where a rule’s namespace and
parameters live in
ctxgrd.toml. - CLI reference – the full
ctxgrd newandctxgrd rulesflag surface.