@jpeakai/prose-gates 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +36 -4
  2. package/dist/bin.js +8400 -455
  3. package/dist/index.js +8428 -456
  4. package/dist/testing.js +18321 -0
  5. package/examples/plugin-skills/README.md +60 -0
  6. package/examples/plugin-skills/index.mjs +14 -0
  7. package/examples/plugin-skills/package.json +12 -0
  8. package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
  9. package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
  10. package/examples/plugin-skills-typescript/README.md +42 -0
  11. package/examples/plugin-skills-typescript/index.ts +21 -0
  12. package/examples/plugin-skills-typescript/package.json +12 -0
  13. package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
  14. package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
  15. package/examples/plugin-skills-typescript/tsconfig.json +14 -0
  16. package/package.json +10 -3
  17. package/src/bin.ts +2 -2
  18. package/src/check.ts +40 -12
  19. package/src/cli.ts +197 -28
  20. package/src/config.ts +119 -0
  21. package/src/errors.ts +191 -0
  22. package/src/fix/engine.ts +30 -8
  23. package/src/fix/verify.ts +29 -0
  24. package/src/index.ts +38 -2
  25. package/src/interpunct.ts +7 -7
  26. package/src/model.ts +130 -1
  27. package/src/plugins/contract.ts +41 -0
  28. package/src/plugins/discover.ts +140 -0
  29. package/src/plugins/index.ts +103 -0
  30. package/src/plugins/load.ts +234 -0
  31. package/src/plugins/runtime.ts +15 -0
  32. package/src/project.ts +81 -0
  33. package/src/rules/index.ts +32 -20
  34. package/src/rules/{pg008-labelled-run.ts → list-comma-labelled-run.ts} +3 -3
  35. package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +2 -2
  36. package/src/rules/{pg006-interpunct-run.ts → list-interpunct-joined-run.ts} +2 -2
  37. package/src/rules/{pg003-semicolon-list.ts → list-semicolon-delimited-run.ts} +2 -2
  38. package/src/rules/{pg009-stacked-runs.ts → list-stacked-interpunct-runs.ts} +3 -3
  39. package/src/rules/{pg004-em-dash.ts → punctuation-em-dash-in-prose.ts} +3 -3
  40. package/src/rules/{pg005-interpunct.ts → punctuation-interpunct-in-prose.ts} +6 -6
  41. package/src/rules/registry.ts +133 -0
  42. package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +4 -4
  43. package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +3 -2
  44. package/src/rules/types.ts +62 -20
  45. package/src/testing.ts +60 -0
@@ -0,0 +1,234 @@
1
+ // Loading: import a plugin module, check it has the shape the contract
2
+ // promises, and turn its rules into ordinary Rules. Every failure is a named
3
+ // PluginLoadError that says which source and why. These functions never skip
4
+ // anything themselves: they return or throw, and the caller decides whether a
5
+ // failed source stops the run (strict) or is reported and left out (lenient).
6
+
7
+ import { basename, extname } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import {
10
+ PluginConflictError,
11
+ PluginContractError,
12
+ PluginFixerError,
13
+ PluginImportError,
14
+ PluginLoadError,
15
+ PluginSourceError,
16
+ } from "../errors.ts";
17
+ import { BUILTIN_CATEGORIES } from "../rules/registry.ts";
18
+ import type { Edit, Finding, OptionType, Rule, RuleId } from "../rules/types.ts";
19
+ import { API_VERSION, CATEGORY_WORD, NAMESPACE, type Plugin, type PluginRule, RULE_KEY } from "./contract.ts";
20
+ import type { PluginSource } from "./discover.ts";
21
+ import { canImportTypeScript, type RuntimeInfo, TYPESCRIPT_HELP } from "./runtime.ts";
22
+
23
+ export { PluginLoadError };
24
+
25
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
26
+
27
+ const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
28
+
29
+ // TypeScript is imported as is, so it needs a runtime that can. CommonJS
30
+ // TypeScript is never supported, because the loader imports modules.
31
+ const checkRuntime = (source: PluginSource, runtime?: RuntimeInfo): void => {
32
+ const ext = extname(source.path);
33
+ if (ext === ".cts") {
34
+ throw new PluginSourceError(source.spec, "a local rule is an ES module, so use .ts or .mts, not .cts");
35
+ }
36
+ if ((ext === ".ts" || ext === ".mts") && !canImportTypeScript(runtime)) {
37
+ throw new PluginSourceError(source.spec, TYPESCRIPT_HELP);
38
+ }
39
+ };
40
+
41
+ const importModule = async (source: PluginSource, runtime?: RuntimeInfo): Promise<Record<string, unknown>> => {
42
+ checkRuntime(source, runtime);
43
+ try {
44
+ return (await import(pathToFileURL(source.path).href)) as Record<string, unknown>;
45
+ } catch (err) {
46
+ throw new PluginImportError(source.spec, message(err));
47
+ }
48
+ };
49
+
50
+ const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string[]"]);
51
+
52
+ // Checks one rule's declaration and returns it, so a bad rule fails at load
53
+ // rather than the first time it runs.
54
+ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
55
+ const where = `rule "${key}"`;
56
+ if (!isRecord(raw)) throw new PluginContractError(spec, `${where} must be an object`);
57
+ if (!RULE_KEY.test(key)) {
58
+ throw new PluginContractError(spec, `${where} is not a valid key; use lower-case words joined by hyphens`);
59
+ }
60
+ const { category, summary, options, check, fix } = raw;
61
+ if (typeof category !== "string" || !CATEGORY_WORD.test(category)) {
62
+ throw new PluginContractError(spec, `${where} needs a category of one lower-case word`);
63
+ }
64
+ if (!BUILTIN_CATEGORIES.has(category) && !own.has(category)) {
65
+ throw new PluginContractError(
66
+ spec,
67
+ `${where} uses the category "${category}", which this plugin does not declare in meta.categories`,
68
+ );
69
+ }
70
+ if (!key.startsWith(`${category}-`)) {
71
+ throw new PluginContractError(spec, `${where} must start with its category, as "${category}-short-name"`);
72
+ }
73
+ if (typeof summary !== "string" || summary === "") throw new PluginContractError(spec, `${where} needs a summary`);
74
+ if (typeof check !== "function") throw new PluginContractError(spec, `${where} needs a check function`);
75
+ if (fix !== undefined && typeof fix !== "function") {
76
+ throw new PluginContractError(spec, `${where} fix must be a function`);
77
+ }
78
+ if (options !== undefined) {
79
+ const valid = isRecord(options) && Object.values(options).every((t) => OPTION_TYPES.has(t as OptionType));
80
+ if (!valid) {
81
+ throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
82
+ }
83
+ }
84
+ return raw as unknown as PluginRule;
85
+ };
86
+
87
+ const checkCategories = (spec: string, raw: unknown): Record<string, string> => {
88
+ if (raw === undefined) return {};
89
+ if (!isRecord(raw)) throw new PluginContractError(spec, "categories must map a word to a description");
90
+ for (const [word, description] of Object.entries(raw)) {
91
+ if (!CATEGORY_WORD.test(word)) throw new PluginContractError(spec, `category "${word}" is not one lower-case word`);
92
+ if (BUILTIN_CATEGORIES.has(word)) {
93
+ throw new PluginContractError(spec, `category "${word}" is built in and cannot be declared`);
94
+ }
95
+ if (typeof description !== "string" || description === "") {
96
+ throw new PluginContractError(spec, `category "${word}" needs a one-line description`);
97
+ }
98
+ }
99
+ return raw as Record<string, string>;
100
+ };
101
+
102
+ // A plugin object is checked here, apart from how it was imported, so a test
103
+ // can hold the same plugin to the same contract the loader does.
104
+ export const parsePlugin = (spec: string, raw: unknown, reserved: ReadonlySet<string>): Plugin => {
105
+ if (!isRecord(raw) || !isRecord(raw.meta) || !isRecord(raw.rules)) {
106
+ throw new PluginContractError(spec, "the default export must be { meta, rules }");
107
+ }
108
+ const { name, namespace, apiVersion } = raw.meta;
109
+ if (apiVersion !== API_VERSION) {
110
+ throw new PluginContractError(
111
+ spec,
112
+ `targets plugin apiVersion ${JSON.stringify(apiVersion)}, but this prose-gates supports ${API_VERSION}`,
113
+ );
114
+ }
115
+ if (typeof name !== "string" || name === "") throw new PluginContractError(spec, "meta.name is required");
116
+ if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
117
+ throw new PluginContractError(spec, "meta.namespace must be lower-case words joined by hyphens");
118
+ }
119
+ if (reserved.has(namespace)) throw new PluginContractError(spec, `meta.namespace "${namespace}" is reserved`);
120
+ const categories = checkCategories(spec, raw.meta.categories);
121
+ const own = new Set(Object.keys(categories));
122
+ const rules = Object.fromEntries(
123
+ Object.entries(raw.rules).map(([key, rule]) => [key, checkRule(spec, key, rule, own)]),
124
+ );
125
+ return { meta: { name, namespace, apiVersion, categories }, rules };
126
+ };
127
+
128
+ // A package: the default export is a Plugin.
129
+ export const loadPackage = async (source: PluginSource, reserved: ReadonlySet<string>): Promise<Plugin> =>
130
+ parsePlugin(source.spec, (await importModule(source)).default, reserved);
131
+
132
+ export interface LocalLoad {
133
+ // The local files that loaded, as one plugin. Null when none did.
134
+ plugin: Plugin | null;
135
+ failures: PluginLoadError[];
136
+ }
137
+
138
+ // Local files: each default-exports one rule, keyed by its file name. They load
139
+ // as one plugin under the fixed namespace `local`, and a file may export
140
+ // `categories` to declare the word its rule uses. Each file fails on its own,
141
+ // so one bad file does not hide the others, and the caller gets every failure.
142
+ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo): Promise<LocalLoad> => {
143
+ const failures: PluginLoadError[] = [];
144
+ const fail = (err: unknown): void => {
145
+ if (!(err instanceof PluginLoadError)) throw err;
146
+ failures.push(err);
147
+ };
148
+ const categories: Record<string, string> = {};
149
+ const owner: Record<string, string> = {};
150
+ const loaded: { key: string; source: PluginSource; raw: unknown }[] = [];
151
+ for (const source of sources) {
152
+ try {
153
+ const mod = await importModule(source, runtime);
154
+ if (mod.default === undefined) {
155
+ throw new PluginContractError(source.spec, "has no default export; export one rule");
156
+ }
157
+ const declared = checkCategories(source.spec, mod.categories);
158
+ for (const [word, description] of Object.entries(declared)) {
159
+ if (categories[word] !== undefined && categories[word] !== description) {
160
+ throw new PluginConflictError(
161
+ source.spec,
162
+ owner[word] as string,
163
+ `declares category "${word}" with a different description than another local file`,
164
+ );
165
+ }
166
+ categories[word] = description;
167
+ owner[word] = source.spec;
168
+ }
169
+ loaded.push({ key: basename(source.path, extname(source.path)), source, raw: mod.default });
170
+ } catch (err) {
171
+ fail(err);
172
+ }
173
+ }
174
+ const own = new Set(Object.keys(categories));
175
+ const rules: Record<string, PluginRule> = {};
176
+ for (const { key, source, raw } of loaded) {
177
+ try {
178
+ rules[key] = checkRule(source.spec, key, raw, own);
179
+ } catch (err) {
180
+ fail(err);
181
+ }
182
+ }
183
+ const plugin: Plugin | null =
184
+ Object.keys(rules).length === 0 && failures.length > 0
185
+ ? null
186
+ : { meta: { name: "local", namespace: "local", apiVersion: API_VERSION, categories }, rules };
187
+ return { plugin, failures };
188
+ };
189
+
190
+ const validFinding = (f: unknown): f is { line: number; message: string } =>
191
+ isRecord(f) && Number.isInteger(f.line) && (f.line as number) >= 1 && typeof f.message === "string";
192
+
193
+ const EXPECTATIONS = new Set(["same-tree", "same-shape", "same-frontmatter-data", "replace-paragraph"]);
194
+
195
+ // A plugin rule becomes a Rule whose id is namespace/key. The plugin function may
196
+ // be synchronous or return a promise, and the wrapper awaits it. Its check cannot
197
+ // take the run down: a throw, a rejection or a malformed finding becomes a finding for that
198
+ // rule, and the other rules carry on. Its fixer is held to the same proof as a
199
+ // built-in, so a fixer that throws or returns nonsense stops the run loudly.
200
+ export const toRule = (namespace: string, key: string, rule: PluginRule): Rule => {
201
+ const id = `${namespace}/${key}` as RuleId;
202
+ const check: Rule["check"] = async (ctx) => {
203
+ const report = (why: string): Finding[] => [{ file: ctx.file, line: 1, rule: id, message: `plugin rule ${why}` }];
204
+ let found: unknown;
205
+ try {
206
+ found = await rule.check(ctx);
207
+ } catch (err) {
208
+ return report(`threw: ${message(err)}`);
209
+ }
210
+ if (!Array.isArray(found) || !found.every(validFinding)) {
211
+ return report("returned something other than a list of { line, message } findings");
212
+ }
213
+ return found.map((f) => ({ file: ctx.file, line: f.line, rule: id, message: f.message }));
214
+ };
215
+ const fix: Rule["fix"] = rule.fix
216
+ ? async (ctx) => {
217
+ let edits: unknown;
218
+ try {
219
+ edits = await rule.fix?.(ctx);
220
+ } catch (err) {
221
+ throw new PluginFixerError(id, `fixer threw: ${message(err)}`);
222
+ }
223
+ if (!Array.isArray(edits))
224
+ throw new PluginFixerError(id, "fixer returned something other than a list of edits");
225
+ return edits.map((e: Edit) => {
226
+ if (!isRecord(e) || !isRecord(e.expect) || !EXPECTATIONS.has(e.expect.kind as string)) {
227
+ throw new PluginFixerError(id, "fixer returned an edit with no valid expectation");
228
+ }
229
+ return { ...e, rule: id };
230
+ });
231
+ }
232
+ : undefined;
233
+ return { id, category: rule.category, summary: rule.summary, options: rule.options, check, ...(fix ? { fix } : {}) };
234
+ };
@@ -0,0 +1,15 @@
1
+ // What the running JavaScript can import. Bun runs TypeScript directly, and Node
2
+ // strips types from 22.18 (earlier with a flag, and not at all before 22.6). A
3
+ // local rule written in TypeScript therefore works under `bunx --bun` or a recent
4
+ // Node, and anywhere else it fails with a message that says how to fix it.
5
+
6
+ export interface RuntimeInfo {
7
+ versions: Record<string, string | undefined>;
8
+ features: Record<string, unknown>;
9
+ }
10
+
11
+ export const canImportTypeScript = (runtime: RuntimeInfo = process as unknown as RuntimeInfo): boolean =>
12
+ typeof runtime.versions.bun === "string" || Boolean(runtime.features.typescript);
13
+
14
+ export const TYPESCRIPT_HELP =
15
+ "TypeScript cannot be imported by this runtime. Run with Bun (bunx --bun @jpeakai/prose-gates), use Node 22.18 or newer, or write a .js or .mjs file";
package/src/project.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Setting up a run: find the project root, read its config, load the plugins,
2
+ // and build the registry every check and fix in the run uses. This happens
3
+ // once, before any file is read, so a bad config or plugin stops the run
4
+ // before it touches a document.
5
+
6
+ import { access } from "node:fs/promises";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { CONFIG_FILES, findConfig, loadConfig, NO_CONFIG } from "./config.ts";
9
+ import { ConfigNotFoundError } from "./errors.ts";
10
+ import { loadPlugins, type PluginFailure, type PluginMode } from "./plugins/index.ts";
11
+ import { FIX_ORDER, RULES } from "./rules/index.ts";
12
+ import { buildRegistry, type Registry } from "./rules/registry.ts";
13
+
14
+ export const LOCAL_DIR = ".prose-gates";
15
+
16
+ const exists = (path: string): Promise<boolean> =>
17
+ access(path).then(
18
+ () => true,
19
+ () => false,
20
+ );
21
+
22
+ // The nearest ancestor of the working directory that marks a project: a
23
+ // package.json, a config file or a .prose-gates directory. With none, the
24
+ // working directory itself, so a loose folder of notes still works.
25
+ export const findProjectRoot = async (cwd: string): Promise<string> => {
26
+ const markers = ["package.json", LOCAL_DIR, ...CONFIG_FILES];
27
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
28
+ for (const marker of markers) if (await exists(join(dir, marker))) return dir;
29
+ if (dirname(dir) === dir) return resolve(cwd);
30
+ }
31
+ };
32
+
33
+ export interface SetupOptions {
34
+ cwd: string;
35
+ configPath?: string;
36
+ // Built-ins only. Anything discovered is reported as skipped.
37
+ noPlugins?: boolean;
38
+ // Skip a plugin that fails to load and say so, instead of stopping the run.
39
+ lenient?: boolean;
40
+ // Load every plugin and report every failure, without stopping at the first.
41
+ collect?: boolean;
42
+ }
43
+
44
+ export interface Setup {
45
+ registry: Registry;
46
+ skipped: string[];
47
+ // Plugins left out because they failed. Empty unless the run was lenient or collecting.
48
+ failures: PluginFailure[];
49
+ }
50
+
51
+ export const setUp = async ({
52
+ cwd,
53
+ configPath,
54
+ noPlugins = false,
55
+ lenient = false,
56
+ collect = false,
57
+ }: SetupOptions): Promise<Setup> => {
58
+ const root = await findProjectRoot(cwd);
59
+ let file: string | null;
60
+ if (configPath !== undefined) {
61
+ file = resolve(cwd, configPath);
62
+ if (!(await exists(file))) throw new ConfigNotFoundError(configPath);
63
+ } else {
64
+ file = await findConfig(root);
65
+ }
66
+ const config = file ? await loadConfig(file) : NO_CONFIG;
67
+ let mode: PluginMode = "strict";
68
+ if (noPlugins || config.plugins === false) mode = "off";
69
+ else if (collect) mode = "collect";
70
+ else if (lenient || config.pluginLoading === "lenient") mode = "lenient";
71
+ const plugins = await loadPlugins(root, config, mode);
72
+ const registry = buildRegistry({
73
+ rules: [...RULES, ...plugins.rules],
74
+ fixOrder: [...FIX_ORDER, ...plugins.rules.filter((r) => r.fix)],
75
+ categories: plugins.categories,
76
+ sources: plugins.sources,
77
+ config,
78
+ ignoreUnknownPluginIds: mode === "lenient" || mode === "collect",
79
+ });
80
+ return { registry, skipped: plugins.skipped, failures: plugins.failures };
81
+ };
@@ -2,27 +2,39 @@
2
2
  // a Rule with its category, register it here, and add its section and
3
3
  // examples to RULES.md, which tests/rules-doc.test.ts holds to the code.
4
4
 
5
- import { interpunctRuns } from "../interpunct.ts";
6
- import type { DocModel } from "../model.ts";
7
- import { pg001 } from "./pg001-wrap.ts";
8
- import { pg002 } from "./pg002-length.ts";
9
- import { pg003 } from "./pg003-semicolon-list.ts";
10
- import { pg004 } from "./pg004-em-dash.ts";
11
- import { pg005 } from "./pg005-interpunct.ts";
12
- import { pg006 } from "./pg006-interpunct-run.ts";
13
- import { pg007 } from "./pg007-inline-enum.ts";
14
- import { pg008 } from "./pg008-labelled-run.ts";
15
- import { pg009 } from "./pg009-stacked-runs.ts";
16
- import type { Rule, RuleContext } from "./types.ts";
5
+ import { listCommaLabelledRun } from "./list-comma-labelled-run.ts";
6
+ import { listInlineEnumerationMarkers } from "./list-inline-enumeration-markers.ts";
7
+ import { listInterpunctJoinedRun } from "./list-interpunct-joined-run.ts";
8
+ import { listSemicolonDelimitedRun } from "./list-semicolon-delimited-run.ts";
9
+ import { listStackedInterpunctRuns } from "./list-stacked-interpunct-runs.ts";
10
+ import { punctuationEmDashInProse } from "./punctuation-em-dash-in-prose.ts";
11
+ import { punctuationInterpunctInProse } from "./punctuation-interpunct-in-prose.ts";
12
+ import { sentenceOnePerLine } from "./sentence-one-per-line.ts";
13
+ import { sentenceWordBudgetExceeded } from "./sentence-word-budget-exceeded.ts";
14
+ import type { Rule } from "./types.ts";
17
15
 
18
- export const RULES: Rule[] = [pg001, pg002, pg003, pg004, pg005, pg006, pg007, pg008, pg009];
19
-
20
- // The data every rule shares, derived once per document. Interpunct runs are
21
- // read by four rules, so computing them here keeps the fix engine from
22
- // rebuilding them once per rule on every pass of the fixpoint loop.
23
- export const ruleContext = (doc: DocModel): RuleContext => ({ doc, runs: interpunctRuns(doc) });
16
+ export const RULES: Rule[] = [
17
+ sentenceOnePerLine,
18
+ sentenceWordBudgetExceeded,
19
+ listSemicolonDelimitedRun,
20
+ punctuationEmDashInProse,
21
+ punctuationInterpunctInProse,
22
+ listInterpunctJoinedRun,
23
+ listInlineEnumerationMarkers,
24
+ listCommaLabelledRun,
25
+ listStackedInterpunctRuns,
26
+ ];
24
27
 
25
28
  // The order fixers run in. Structure first: a promotion needs the separators
26
- // a glyph swap would erase, so PG009 and the list rules run before PG005
29
+ // a glyph swap would erase, so list-stacked-interpunct-runs and the list rules run before punctuation-interpunct-in-prose
27
30
  // turns interpuncts into commas. Reflow runs last, over the settled blocks.
28
- export const FIX_ORDER: Rule[] = [pg009, pg007, pg008, pg006, pg003, pg005, pg004, pg001];
31
+ export const FIX_ORDER: Rule[] = [
32
+ listStackedInterpunctRuns,
33
+ listInlineEnumerationMarkers,
34
+ listCommaLabelledRun,
35
+ listInterpunctJoinedRun,
36
+ listSemicolonDelimitedRun,
37
+ punctuationInterpunctInProse,
38
+ punctuationEmDashInProse,
39
+ sentenceOnePerLine,
40
+ ];
@@ -1,5 +1,5 @@
1
- // PG008: one sentence stringing 3+ comma-separated segments each led by a
2
- // label (an inline-code span, or an identifier like PG001 / MD025 / S4)
1
+ // list-comma-labelled-run: one sentence stringing 3+ comma-separated segments each led by a
2
+ // label (an inline-code span, or an identifier like sentence-one-per-line / MD025 / S4)
3
3
  // WITH content after it is a hidden list of labelled items. Bare labels
4
4
  // ("A, B, and C are interchangeable") are a subject enumeration, not a
5
5
  // list. An optional "Rules: " style intro on the first segment, and a
@@ -92,7 +92,7 @@ const fix: Rule["fix"] = ({ doc }) => {
92
92
  return edits;
93
93
  };
94
94
 
95
- export const pg008: Rule = {
95
+ export const listCommaLabelledRun: Rule = {
96
96
  id: RULE.LABELLED_RUN,
97
97
  category: "list",
98
98
  summary: "comma-joined labelled run (3+ labelled segments)",
@@ -1,4 +1,4 @@
1
- // PG007: parenthesised enumerators inlined in one paragraph are a hidden
1
+ // list-inline-enumeration-markers: parenthesised enumerators inlined in one paragraph are a hidden
2
2
  // list. Requiring the first two members of a family keeps citations and a
3
3
  // lone "(a)" reference from firing. The fix only trusts a clean sequence:
4
4
  // every marker in order, none skipped, none from another family.
@@ -118,7 +118,7 @@ const fix: Rule["fix"] = ({ doc }) => {
118
118
  return edits;
119
119
  };
120
120
 
121
- export const pg007: Rule = {
121
+ export const listInlineEnumerationMarkers: Rule = {
122
122
  id: RULE.INLINE_ENUM,
123
123
  category: "list",
124
124
  summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
@@ -1,4 +1,4 @@
1
- // PG006: a single-line interpunct-joined run is a disguised flat list. The
1
+ // list-interpunct-joined-run: a single-line interpunct-joined run is a disguised flat list. The
2
2
  // fix promotes it to bullets, taking a colon before the run as the lead-in.
3
3
  // Without a colon the run must be the whole paragraph, or there is no way to
4
4
  // tell where the first item starts.
@@ -59,7 +59,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
59
59
  return edits;
60
60
  };
61
61
 
62
- export const pg006: Rule = {
62
+ export const listInterpunctJoinedRun: Rule = {
63
63
  id: RULE.INTERPUNCT_RUN,
64
64
  category: "list",
65
65
  summary: "interpunct-joined inline list (2+ separators)",
@@ -1,4 +1,4 @@
1
- // PG003: a run of items joined by semicolons is a wall of text hiding a
1
+ // list-semicolon-delimited-run: a run of items joined by semicolons is a wall of text hiding a
2
2
  // list. The fix needs a colon lead-in to know where the list starts, and
3
3
  // refuses without one.
4
4
 
@@ -51,7 +51,7 @@ const fix: Rule["fix"] = ({ doc }) => {
51
51
  return edits;
52
52
  };
53
53
 
54
- export const pg003: Rule = {
54
+ export const listSemicolonDelimitedRun: Rule = {
55
55
  id: RULE.SEMICOLON_LIST,
56
56
  category: "list",
57
57
  summary: "semicolon-delimited list (2+ ';' in one sentence)",
@@ -1,5 +1,5 @@
1
- // PG009: interpunct runs stacked on 2+ source lines of one paragraph are a
2
- // two-level structure, so PG009 replaces PG006 for that paragraph. The fix
1
+ // list-stacked-interpunct-runs: interpunct runs stacked on 2+ source lines of one paragraph are a
2
+ // two-level structure, so list-stacked-interpunct-runs replaces list-interpunct-joined-run for that paragraph. The fix
3
3
  // needs every line to read `Label: a · b · c`, and builds one parent bullet
4
4
  // per label with the items nested beneath it.
5
5
 
@@ -71,7 +71,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
71
71
  return edits;
72
72
  };
73
73
 
74
- export const pg009: Rule = {
74
+ export const listStackedInterpunctRuns: Rule = {
75
75
  id: RULE.STACKED_RUNS,
76
76
  category: "list",
77
77
  summary: "interpunct runs stacked on several lines (nested list)",
@@ -1,4 +1,4 @@
1
- // PG004: an em-dash in prose. Code is exempt by construction, since only
1
+ // punctuation-em-dash-in-prose: an em-dash in prose. Code is exempt by construction, since only
2
2
  // text nodes are read. The fix is deliberately narrow: a pair of dashes
3
3
  // around an aside becomes parentheses, a lone dash becomes a colon, and
4
4
  // anything else stays reported for a human.
@@ -99,7 +99,7 @@ const replacementFor = (src: string, { dashes, hasColon }: Aside): string | null
99
99
  const closing = last(dashes);
100
100
 
101
101
  // A pair wraps an aside, so it becomes parentheses. An aside that reads as
102
- // "(b)" would plant a PG007 marker and hide the word from the fingerprint.
102
+ // "(b)" would plant a list-inline-enumeration-markers marker and hide the word from the fingerprint.
103
103
  if (dashes.length === 2) {
104
104
  const inner = src.slice(opening.range[1], closing.range[0]);
105
105
  return MARKER_LIKE.test(inner.trim()) ? null : ` (${inner}) `;
@@ -146,7 +146,7 @@ const fix: Rule["fix"] = ({ doc }) =>
146
146
  .map((aside) => rewrite(doc.src, aside))
147
147
  .filter((edit): edit is Edit => edit !== null);
148
148
 
149
- export const pg004: Rule = {
149
+ export const punctuationEmDashInProse: Rule = {
150
150
  id: RULE.EM_DASH,
151
151
  category: "punctuation",
152
152
  summary: "em-dash U+2014 in prose",
@@ -1,5 +1,5 @@
1
- // PG005: a stray interpunct in prose. One inside a reported run is left to
2
- // PG006 or PG009. The fix swaps a spaced interpunct for a comma; a glyph
1
+ // punctuation-interpunct-in-prose: a stray interpunct in prose. One inside a reported run is left to
2
+ // list-interpunct-joined-run or list-stacked-interpunct-runs. The fix swaps a spaced interpunct for a comma; a glyph
3
3
  // with no space on either side (a product name, a unit) is left alone.
4
4
 
5
5
  import { GLYPH, INTERPUNCT } from "../interpunct.ts";
@@ -8,11 +8,11 @@ import { inRanges } from "../text.ts";
8
8
  import { type Edit, RULE, type Rule } from "./types.ts";
9
9
 
10
10
  // Only a separator reads as a comma: the spaces on both sides are what make
11
- // the glyph a joiner rather than part of a name. PG006 and PG009 split on a
11
+ // the glyph a joiner rather than part of a name. list-interpunct-joined-run and list-stacked-interpunct-runs split on a
12
12
  // looser one, because a run has already proved itself a list.
13
13
  const SEPARATOR = /[ \t]+·[ \t]+/;
14
14
 
15
- const check: Rule["check"] = ({ doc, file, runs }) => {
15
+ const check: Rule["check"] = ({ doc, file, reported: runs }) => {
16
16
  const reported = runs.map((run) => run.range);
17
17
  return textsContaining(doc, INTERPUNCT)
18
18
  .filter((text) => text.start === undefined || !inRanges(text.start, reported))
@@ -24,7 +24,7 @@ const check: Rule["check"] = ({ doc, file, runs }) => {
24
24
  }));
25
25
  };
26
26
 
27
- const fix: Rule["fix"] = ({ doc, runs }) => {
27
+ const fix: Rule["fix"] = ({ doc, reported: runs }) => {
28
28
  const reported = runs.map((run) => run.range);
29
29
  const edits: Edit[] = [];
30
30
  for (const { start, end, value } of textsContaining(doc, INTERPUNCT)) {
@@ -43,7 +43,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
43
43
  return edits;
44
44
  };
45
45
 
46
- export const pg005: Rule = {
46
+ export const punctuationInterpunctInProse: Rule = {
47
47
  id: RULE.INTERPUNCT,
48
48
  category: "punctuation",
49
49
  summary: "interpunct U+00B7 in prose",