@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.
- package/README.md +36 -4
- package/dist/bin.js +8400 -455
- package/dist/index.js +8428 -456
- package/dist/testing.js +18321 -0
- package/examples/plugin-skills/README.md +60 -0
- package/examples/plugin-skills/index.mjs +14 -0
- package/examples/plugin-skills/package.json +12 -0
- package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
- package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
- package/examples/plugin-skills-typescript/README.md +42 -0
- package/examples/plugin-skills-typescript/index.ts +21 -0
- package/examples/plugin-skills-typescript/package.json +12 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
- package/examples/plugin-skills-typescript/tsconfig.json +14 -0
- package/package.json +10 -3
- package/src/bin.ts +2 -2
- package/src/check.ts +40 -12
- package/src/cli.ts +197 -28
- package/src/config.ts +119 -0
- package/src/errors.ts +191 -0
- package/src/fix/engine.ts +30 -8
- package/src/fix/verify.ts +29 -0
- package/src/index.ts +38 -2
- package/src/interpunct.ts +7 -7
- package/src/model.ts +130 -1
- package/src/plugins/contract.ts +41 -0
- package/src/plugins/discover.ts +140 -0
- package/src/plugins/index.ts +103 -0
- package/src/plugins/load.ts +234 -0
- package/src/plugins/runtime.ts +15 -0
- package/src/project.ts +81 -0
- package/src/rules/index.ts +32 -20
- package/src/rules/{pg008-labelled-run.ts → list-comma-labelled-run.ts} +3 -3
- package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +2 -2
- package/src/rules/{pg006-interpunct-run.ts → list-interpunct-joined-run.ts} +2 -2
- package/src/rules/{pg003-semicolon-list.ts → list-semicolon-delimited-run.ts} +2 -2
- package/src/rules/{pg009-stacked-runs.ts → list-stacked-interpunct-runs.ts} +3 -3
- package/src/rules/{pg004-em-dash.ts → punctuation-em-dash-in-prose.ts} +3 -3
- package/src/rules/{pg005-interpunct.ts → punctuation-interpunct-in-prose.ts} +6 -6
- package/src/rules/registry.ts +133 -0
- package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +4 -4
- package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +3 -2
- package/src/rules/types.ts +62 -20
- 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
|
+
};
|
package/src/rules/index.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
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[] = [
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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[] = [
|
|
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
|
-
//
|
|
2
|
-
// label (an inline-code span, or an identifier like
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2
|
-
// two-level structure, so
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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.
|
|
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
|
|
46
|
+
export const punctuationInterpunctInProse: Rule = {
|
|
47
47
|
id: RULE.INTERPUNCT,
|
|
48
48
|
category: "punctuation",
|
|
49
49
|
summary: "interpunct U+00B7 in prose",
|