Writing a plugin

How to share chaff rules as a package. Start with a rule pack: rules, word lists and styles written in YAML, with no code. Write code only for what words, a regular expression or parts of speech cannot say, such as counting or comparing.

A rule pack in YAML

A rule pack is a folder that holds a manifest and YAML files. The rules are written exactly as under custom_rules (Adding a rule), one file each. Nothing in a pack runs, so it can be read and reviewed like any settings file. The pack below is in examples/chaff-plugin-clear-requests.

chaff-plugin-clear-requests/
  package.json                      "chaff": { "apiVersion": 1 }  (or a chaff-plugin.yaml beside it)
  rules/vague-deadline.yaml         a rule
  rules/formal-register.yaml
  lexicons/ja/vague-deadline.yaml   its words in Japanese
  lexicons/en/vague-deadline.yaml   and in English
  styles/strict-requests.yaml       a preset

A words rule may name a word list instead of listing its words:

# rules/vague-deadline.yaml
id: vague-deadline
type: words
word_list: vague-deadline
name: { ja: 期限があいまいな依頼, en: A deadline that is not a date }
why: { ja: 読む人ごとに違う日を指します。, en: '"Soon" means a different day to each reader.' }
how_to_fix: { ja: 日付か、何日以内かを書きます。, en: 'Write a date, or a number of days.' }
example:
  ja: { before: 資料は近日中に送ってください。, after: 資料は 5 月 10 日までに送ってください。 }
  en: { before: Please send the slides soon., after: Please send the slides by 10 May. }
rewrite:
  depth: light
# lexicons/en/vague-deadline.yaml: one word or phrase per entry; { pattern, rewrite } also gives the form to use
- as soon as possible
- soon
- { pattern: in due course, rewrite: by a date }

The words come from the word list in the document’s language. A document in a language the pack has no list for does not pass silently: the rule is listed as not run, naming the list that is missing.

# chaff.yaml
plugins:
  - chaff-plugin-clear-requests # an installed package
  - ./chaff-rules # or a folder of your own
style: clear-requests/strict-requests # a preset the pack ships

Every id the pack ships starts with its name (clear-requests/vague-deadline), which comes from the package’s name. A preset is a style (styles/*.yaml, as in Define your team’s writing rules). A pack cannot add a genre: the genres are one list that --genre, every rule’s use_for and the rule reference share, and a genre that came and went with a plugin would make the same --genre mean different things on different machines.

Starting one, and testing it

npx chaffjs init --plugin house          # chaff-plugin-house/: one rule, its word list in Japanese and English
cd chaff-plugin-house
npx chaffjs plugin-test .                # each rule on its own example, in each language

plugin-test loads the folder as chaff.yaml would, then runs every rule on its example: the before must give the rule a finding and the after must give it none. A rule that does not run in a language (no word list for it) fails, with the reason. The pack it makes has "test": "chaffjs plugin-test ." in package.json, so yarn test runs the same check.

Rules in code: two forms

FormWhen it fitsWhere it goes
A function ruleOne team adds one ruletype: module under custom_rules in chaff.yaml
A pluginRules, word lists and styles shared by several teamsa package chaff-plugin-<name>, or a file of your own

Either way, a rule is the same function: it takes the document and returns a list of findings. Types and helpers come from chaffjs/api.

import { API_VERSION, defineRule, definePlugin } from "chaffjs/api";

What the function is given

The function is (doc, options) => findings. doc holds the following. Every position is a place in doc.source, counted in UTF-16 units.

NameWhat it holds
source path languagethe whole text, the file, and the document’s language (ja or en)
sentencesthe sentences, each with its span (start and end) and text
sentences[].tokensthe words, with pos (a UPOS tag such as NOUN or VERB) and lemma; only with requires: [pos]
paragraphs sectionsparagraphs, and sections split at headings; a section has a depth and a heading
lists listItemsbulleted and numbered lists, and their items
links markupwhere the links are, and the markup (headings, images, link targets)
lexiconsword lists, by name

options.lexicon is the rule’s word_list, in the document’s language. doc is frozen and cannot be changed, so one rule cannot change what the next one reads.

What it returns

A finding is { start, end, values }; end and values may be left out. chaff counts the line and column, quotes the sentence there and puts the text from start to end into {matched} in the rule’s message. Each entry in values fills {name} in the message too. When nothing is found, return an empty list.

Step by step: a plugin

The example flags a sentence that leaves a date as TBD. The same plugin is in examples/chaff-plugin-example.

First, make a folder and a package.json. Name the package chaff-plugin-<name>.

{
  "name": "chaff-plugin-example",
  "type": "module",
  "main": "index.mjs",
  "peerDependencies": { "chaffjs": ">=0.19.0" }
}

Second, write the rule’s function. It reads only the document, and gives the same findings for the same document.

// rules/no-tbd-dates.mjs
export const noTbdDates = (doc) =>
  doc.sentences.flatMap((sentence) =>
    [...sentence.text.matchAll(/TBD|未定/gu)].map((match) => ({
      start: sentence.span.start + match.index,
      end: sentence.span.start + match.index + match[0].length,
    })),
  );

Third, export default a definePlugin from index.mjs. Each rule has a name, a reason, a fix and an example, as in custom_rules, and may write the other fields chaff’s own rules have (levels, use_for, group, summary, rewrite; see Adding a rule). name comes from the package’s name: chaff-plugin-example is example.

// index.mjs
import { definePlugin } from "chaffjs/api";
import { noTbdDates } from "./rules/no-tbd-dates.mjs";

export default definePlugin({
  name: "example",
  rules: [
    {
      id: "no-tbd-dates",
      level: "warning",
      name: { ja: "未定のままの日付", en: "A date left undecided" },
      why: { ja: "読み手は日付をもとに予定を立てます", en: "A reader plans around a date" },
      how_to_fix: { ja: "日付か、決める人と期限を書きます", en: "Write the date, or who decides it" },
      example: { before: { ja: "公開日は未定です。", en: "It is TBD." }, after: { ja: "公開日は 10 月 1 日です。", en: "It is on 1 October." } },
      detect: noTbdDates,
    },
  ],
});

Fourth, list it in the project’s chaff.yaml. Its rules are named with the plugin’s name in front.

plugins: [chaff-plugin-example]
rules:
  example/no-tbd-dates: strict

Findings, explain, rules --json, relax, stet, the baseline and SARIF treat it like chaff’s own rules. <!-- stet: example/no-tbd-dates --> silences one place.

Word lists and styles

A word list goes under lexicons, by language, and a rule names it with word_list. The function gets the list for the document’s language as options.lexicon. On a document in a language the list does not cover, the rule is listed as not run, with the reason.

lexicons: { weasel: { en: ["some say", "arguably"], ja: ["と言われている", "一般的に"] } },

An entry is a word, or { pattern, instead_of, rewrite, genres }. genres limits the entry to some genres or groups (genres: [legal], genres: [blog/tech]): elsewhere the rule is not given it. A genre chaff does not know, or an empty list, stops the run and names the entry (npx chaffjs genres lists them).

lexicons: { terms: { en: [{ pattern: "herein", rewrite: "in this contract", genres: ["legal"] }, "arguably"] } },

A style sets the levels of several rules at once, written as chaff’s own styles (styles/*.yaml) are. A project chooses it with style: example/careful.

styles: [{ id: "careful", name: "Careful with dates", summary: "An undecided date is an error", source: { title: "Our style guide", url: "https://example.com/style" }, rules: { "example/no-tbd-dates": "strict" } }],

Testing a plugin

npx chaffjs plugin-test <folder> runs each rule on its own example, for a code plugin as for a rule pack. A rule’s function only takes a document, so it can be tested without running chaff. Build the part of the document the function reads, call it and compare what it returns.

import { test } from "node:test";
import assert from "node:assert/strict";
import { noTbdDates } from "../rules/no-tbd-dates.mjs";

test("flags an undecided date", () => {
  const text = "The launch is TBD.";
  const doc = { source: text, sentences: [{ span: { start: 0, end: text.length }, text }] };
  assert.deepEqual(noTbdDates(doc), [{ start: 14, end: 17 }]);
});

To check it through chaff, run npx chaffjs sample.md in a folder with sample documents. Try samples that should be flagged and samples that should not.

When something is broken

What cannot be loaded stops the run before any document is read (exit code 1). Each message names the rule or plugin and the file.

chaff: chaff.yaml: plugins chaff-plugin-missing: not found. For a package, run yarn add chaff-plugin-missing in the folder chaff.yaml is in

A missing file, a file that cannot be loaded, another API version and a rule with a field missing each stop the run this way. So does a default export that is neither a function nor a plugin. A rule that silently does not run looks like a clean document.

When a function throws while reading a document, or returns findings of the wrong shape, only that rule stops. Every other rule runs to the end, and the stopped rule is listed as not run, with the reason.

      team-broken (the detector in ./chaff-rules/broken.mjs threw an error (Cannot read properties of undefined (reading 'length')))

Versions

The shape of chaffjs/api is the plugin API’s version, API_VERSION. It is 1. definePlugin and defineRule stamp the version they were made with. chaff refuses a plugin written for another version.

chaff: chaff.yaml: custom_rules team-broken: ./chaff-rules/broken.mjs was written for plugin API 2; this chaff has plugin API 1

A rule that default-exports a bare function carries no version and is read against the running chaff’s. To pin it, wrap it in defineRule({ detect }).

Security

Loading a plugin or a function rule runs its code, every time chaff runs where that chaff.yaml names it. Install only plugins you trust. A file outside the folder chaff.yaml is in is read only when written as an absolute path. That way a chaff.yaml copied from elsewhere cannot reach for code with ../../.

Rules for rules

  • The same document gives the same findings. Read no clock, randomness, files or network. chaff cannot check this; the author keeps to it.
  • Never change the document. Fixing it is the writer’s job.
  • Always return. chaff cannot stop a function that runs forever.
  • Leave nothing behind. The function runs in chaff’s own process. chaff cannot guard against timers, ending the process, or changes to built-in objects.
  • Leave meaning to chaff test. A rule decides only what a machine can.

Publishing

Copy examples/chaff-plugin-example to start. Name the package chaff-plugin-<name> (or @scope/chaff-plugin-<name>) and set definePlugin’s name to match. Keep chaffjs in peerDependencies, so the plugin uses the chaff the project runs. Point main, or exports["."], at the entry file; an exports with only an import condition is not found. Then run npm publish.