@jpeakai/prose-gates 0.1.1 → 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 +37 -4
- package/dist/bin.js +8472 -539
- package/dist/index.js +8500 -540
- 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 -13
- package/src/cli.ts +197 -28
- package/src/config.ts +119 -0
- package/src/errors.ts +191 -0
- package/src/fix/engine.ts +31 -9
- package/src/fix/promote.ts +14 -2
- package/src/fix/verify.ts +29 -0
- package/src/index.ts +38 -2
- package/src/interpunct.ts +48 -0
- package/src/model.ts +129 -12
- 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/query.ts +131 -0
- package/src/rules/index.ts +31 -12
- package/src/rules/list-comma-labelled-run.ts +101 -0
- package/src/rules/list-inline-enumeration-markers.ts +127 -0
- package/src/rules/list-interpunct-joined-run.ts +68 -0
- package/src/rules/list-semicolon-delimited-run.ts +60 -0
- package/src/rules/list-stacked-interpunct-runs.ts +80 -0
- package/src/rules/punctuation-em-dash-in-prose.ts +155 -0
- package/src/rules/punctuation-interpunct-in-prose.ts +52 -0
- package/src/rules/registry.ts +133 -0
- package/src/rules/sentence-one-per-line.ts +99 -0
- package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +22 -20
- package/src/rules/types.ts +70 -25
- package/src/testing.ts +60 -0
- package/src/text.ts +50 -0
- package/src/fix/spans.ts +0 -75
- package/src/rules/interpunct.ts +0 -19
- package/src/rules/pg001-wrap.ts +0 -79
- package/src/rules/pg003-semicolon-list.ts +0 -68
- package/src/rules/pg004-em-dash.ts +0 -117
- package/src/rules/pg005-interpunct.ts +0 -43
- package/src/rules/pg006-interpunct-run.ts +0 -84
- package/src/rules/pg007-inline-enum.ts +0 -119
- package/src/rules/pg008-labelled-run.ts +0 -94
- package/src/rules/pg009-stacked-runs.ts +0 -79
|
@@ -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/query.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Read-only questions about one document. Everything here takes the model, or
|
|
2
|
+
// a view out of it, and answers where something is or what may be read; none
|
|
3
|
+
// of it builds an edit. Both halves of a rule ask the same questions here, so
|
|
4
|
+
// a check and its fix can never disagree about what the document holds.
|
|
5
|
+
|
|
6
|
+
import type { DirectText, DocModel, ParagraphView, TextView } from "./model.ts";
|
|
7
|
+
import { countOf, type Range, sentences, within } from "./text.ts";
|
|
8
|
+
|
|
9
|
+
// --- what a rule may read -------------------------------------------------
|
|
10
|
+
|
|
11
|
+
// Table cells are exempt by construction, so every rule that reads running
|
|
12
|
+
// prose starts here rather than filtering for itself.
|
|
13
|
+
export const proseParagraphs = (doc: DocModel): ParagraphView[] => doc.paragraphs.filter((view) => !view.inTable);
|
|
14
|
+
|
|
15
|
+
// One sentence with the paragraph it came from, so a finding can name the
|
|
16
|
+
// line without the rule tracking it through a nested loop.
|
|
17
|
+
export interface ProseSentence {
|
|
18
|
+
view: ParagraphView;
|
|
19
|
+
sentence: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const proseSentences = (doc: DocModel): ProseSentence[] =>
|
|
23
|
+
proseParagraphs(doc).flatMap((view) => sentences(view.prose).map((sentence) => ({ view, sentence })));
|
|
24
|
+
|
|
25
|
+
// The prose text nodes holding a glyph. This is how a punctuation rule finds
|
|
26
|
+
// what it reports, and it reads headings and list items as well as
|
|
27
|
+
// paragraphs, because a stray glyph is wrong wherever it sits.
|
|
28
|
+
export const textsContaining = (doc: DocModel, glyph: string): TextView[] =>
|
|
29
|
+
doc.texts.filter((text) => text.value.includes(glyph));
|
|
30
|
+
|
|
31
|
+
// --- where something sits in the source -----------------------------------
|
|
32
|
+
|
|
33
|
+
// Absolute offsets of every match of `pattern` inside a text node, or null
|
|
34
|
+
// when the source slice and the parsed value disagree (an entity like
|
|
35
|
+
// `·` or an escape). A rule that cannot map a glyph back to the
|
|
36
|
+
// source refuses rather than guesses.
|
|
37
|
+
export const matchesIn = (src: string, text: DirectText, pattern: RegExp): Range[] | null => {
|
|
38
|
+
const slice = src.slice(text.start, text.end);
|
|
39
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
40
|
+
const inSlice = [...slice.matchAll(new RegExp(pattern.source, flags))];
|
|
41
|
+
const inValue = [...text.value.matchAll(new RegExp(pattern.source, flags))];
|
|
42
|
+
if (inSlice.length !== inValue.length) return null;
|
|
43
|
+
return inSlice.map((m) => [text.start + (m.index ?? 0), text.start + (m.index ?? 0) + m[0].length]);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const matchesInAll = (src: string, texts: DirectText[], pattern: RegExp): Range[] | null => {
|
|
47
|
+
const out: Range[] = [];
|
|
48
|
+
for (const t of texts) {
|
|
49
|
+
const found = matchesIn(src, t, pattern);
|
|
50
|
+
if (found === null) return null;
|
|
51
|
+
out.push(...found);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// A glyph the author escaped is literal text, never a separator to rewrite.
|
|
57
|
+
export const escaped = (src: string, offset: number): boolean => src[offset - 1] === "\\";
|
|
58
|
+
|
|
59
|
+
// Every separator in a paragraph that a fixer may split on, or null when one
|
|
60
|
+
// of them cannot be trusted. A separator is untrustworthy when the parsed
|
|
61
|
+
// value and the source slice disagree (an entity like `·`), when the
|
|
62
|
+
// author escaped it, or when it sits inside a link or emphasis rather than
|
|
63
|
+
// direct text. Splitting on a subset would silently drop an item, so the
|
|
64
|
+
// fixer refuses instead.
|
|
65
|
+
export const splittableSeparators = (
|
|
66
|
+
src: string,
|
|
67
|
+
view: ParagraphView,
|
|
68
|
+
separator: RegExp,
|
|
69
|
+
glyph: RegExp,
|
|
70
|
+
): Range[] | null => {
|
|
71
|
+
const found = matchesInAll(src, view.directTexts, separator);
|
|
72
|
+
if (found === null) return null;
|
|
73
|
+
if (found.length !== countOf(view.prose, glyph)) return null;
|
|
74
|
+
if (found.some(([start]) => escaped(src, start))) return null;
|
|
75
|
+
return found;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
// --- sentences, as source offsets -----------------------------------------
|
|
79
|
+
|
|
80
|
+
// Sentence boundaries a fixer trusts: a terminal followed by whitespace,
|
|
81
|
+
// inside a direct text node. Terminals inside links, emphasis or code never
|
|
82
|
+
// split a sentence. Each boundary is [end of terminal, start of next sentence].
|
|
83
|
+
const boundaries = (src: string, view: ParagraphView): Range[] => {
|
|
84
|
+
const out: Range[] = [];
|
|
85
|
+
for (const t of view.directTexts) {
|
|
86
|
+
const slice = src.slice(t.start, t.end);
|
|
87
|
+
for (const m of slice.matchAll(/[.!?]+(\s+)/g)) {
|
|
88
|
+
const at = t.start + (m.index ?? 0);
|
|
89
|
+
out.push([at + m[0].length - (m[1]?.length ?? 0), at + m[0].length]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return out.sort((a, b) => a[0] - b[0]);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// The sentence span that covers [from, to): from the start of the sentence
|
|
96
|
+
// holding `from` to the end of the sentence holding `to`.
|
|
97
|
+
export const sentenceSpan = (src: string, view: ParagraphView, from: number, to: number): Range => {
|
|
98
|
+
let start = view.startOffset;
|
|
99
|
+
let end = view.endOffset;
|
|
100
|
+
for (const [terminalEnd, nextStart] of boundaries(src, view)) {
|
|
101
|
+
if (nextStart <= from) start = nextStart;
|
|
102
|
+
if (terminalEnd >= to && end === view.endOffset) end = terminalEnd;
|
|
103
|
+
}
|
|
104
|
+
return [start, end];
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Every sentence span of a paragraph, in order.
|
|
108
|
+
export const sentenceSpans = (src: string, view: ParagraphView): Range[] => {
|
|
109
|
+
const spans: Range[] = [];
|
|
110
|
+
let start = view.startOffset;
|
|
111
|
+
for (const [terminalEnd, nextStart] of boundaries(src, view)) {
|
|
112
|
+
spans.push([start, terminalEnd]);
|
|
113
|
+
start = nextStart;
|
|
114
|
+
}
|
|
115
|
+
spans.push([start, view.endOffset]);
|
|
116
|
+
return spans;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// --- lead-ins -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
// The colons of a paragraph as source ranges, or null when one of them
|
|
122
|
+
// cannot be trusted. Every rule that promotes a list needs these to find the
|
|
123
|
+
// lead-in that announces it.
|
|
124
|
+
export const colonsIn = (src: string, view: ParagraphView): Range[] | null => matchesInAll(src, view.directTexts, /:/);
|
|
125
|
+
|
|
126
|
+
// The colons that could announce a list: inside `range`, and closing before
|
|
127
|
+
// the first item starts. Which one a rule takes is the only thing the list
|
|
128
|
+
// rules disagree about, so they pick from this rather than each writing the
|
|
129
|
+
// filter again.
|
|
130
|
+
export const colonsBefore = (colons: Range[], range: Range, item: number): Range[] =>
|
|
131
|
+
colons.filter((colon) => within(colon, range) && colon[1] <= item);
|
package/src/rules/index.ts
CHANGED
|
@@ -2,20 +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 {
|
|
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
14
|
import type { Rule } from "./types.ts";
|
|
15
15
|
|
|
16
|
-
export const RULES: Rule[] = [
|
|
16
|
+
export const RULES: Rule[] = [
|
|
17
|
+
sentenceOnePerLine,
|
|
18
|
+
sentenceWordBudgetExceeded,
|
|
19
|
+
listSemicolonDelimitedRun,
|
|
20
|
+
punctuationEmDashInProse,
|
|
21
|
+
punctuationInterpunctInProse,
|
|
22
|
+
listInterpunctJoinedRun,
|
|
23
|
+
listInlineEnumerationMarkers,
|
|
24
|
+
listCommaLabelledRun,
|
|
25
|
+
listStackedInterpunctRuns,
|
|
26
|
+
];
|
|
17
27
|
|
|
18
28
|
// The order fixers run in. Structure first: a promotion needs the separators
|
|
19
|
-
// 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
|
|
20
30
|
// turns interpuncts into commas. Reflow runs last, over the settled blocks.
|
|
21
|
-
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
|
+
];
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
// WITH content after it is a hidden list of labelled items. Bare labels
|
|
4
|
+
// ("A, B, and C are interchangeable") are a subject enumeration, not a
|
|
5
|
+
// list. An optional "Rules: " style intro on the first segment, and a
|
|
6
|
+
// joining "and"/"or", are tolerated.
|
|
7
|
+
//
|
|
8
|
+
// The check counts labelled segments; the fix is stricter and needs every
|
|
9
|
+
// segment of the sentence to be one, so no stray clause becomes a bullet.
|
|
10
|
+
|
|
11
|
+
import { cleanLeadIn, itemsOf, promote } from "../fix/promote.ts";
|
|
12
|
+
import type { ParagraphView } from "../model.ts";
|
|
13
|
+
import { colonsIn, matchesInAll, proseSentences, sentenceSpans } from "../query.ts";
|
|
14
|
+
import { collapse, first, type Range, spansBetween, within } from "../text.ts";
|
|
15
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
// A labelled segment, built from its parts so each one can be read on its
|
|
18
|
+
// own: the conjunction that joins the final segment, an optional "Rules: "
|
|
19
|
+
// intro on the first, the label itself, and the content that makes it an
|
|
20
|
+
// item rather than a bare mention.
|
|
21
|
+
const JOINER = "(?:and\\s+|or\\s+)?";
|
|
22
|
+
const INTRO = "(?:[A-Za-z][\\w\\s-]{0,24}:\\s*)?";
|
|
23
|
+
const LABEL = "(?:CODE|[A-Z][A-Za-z]*\\d[\\w/-]*)";
|
|
24
|
+
const CONTENT = "\\s+\\S";
|
|
25
|
+
const LABELLED_SEGMENT = new RegExp(`^${JOINER}${INTRO}${LABEL}${CONTENT}`);
|
|
26
|
+
|
|
27
|
+
// The intro that may precede the first label, as the lead-in test reads it.
|
|
28
|
+
const INTRO_ONLY = /^[A-Za-z][\w\s-]{0,24}$/;
|
|
29
|
+
|
|
30
|
+
// A source slice as the check sees it: code spans become one CODE token.
|
|
31
|
+
const asProse = (src: string, view: ParagraphView, range: Range): string => {
|
|
32
|
+
let out = "";
|
|
33
|
+
let at = range[0];
|
|
34
|
+
for (const [start, end] of view.codeRanges) {
|
|
35
|
+
if (end <= range[0] || start >= range[1]) continue;
|
|
36
|
+
out += `${src.slice(at, start)}CODE`;
|
|
37
|
+
at = end;
|
|
38
|
+
}
|
|
39
|
+
return collapse(out + src.slice(at, range[1])).trim();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const labelledSegments = (sentence: string): number =>
|
|
43
|
+
sentence.split(/,\s+/).filter((segment) => LABELLED_SEGMENT.test(segment)).length;
|
|
44
|
+
|
|
45
|
+
const check: Rule["check"] = ({ doc, file }) =>
|
|
46
|
+
proseSentences(doc)
|
|
47
|
+
.map(({ view, sentence }) => ({ view, labelled: labelledSegments(sentence) }))
|
|
48
|
+
.filter(({ labelled }) => labelled >= 3)
|
|
49
|
+
.map(({ view, labelled }) => ({
|
|
50
|
+
file,
|
|
51
|
+
line: view.line,
|
|
52
|
+
rule: RULE.LABELLED_RUN,
|
|
53
|
+
message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
const fix: Rule["fix"] = ({ doc }) => {
|
|
57
|
+
const { src } = doc;
|
|
58
|
+
const edits: Edit[] = [];
|
|
59
|
+
for (const view of doc.paragraphs) {
|
|
60
|
+
const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
|
|
61
|
+
const colons = colonsIn(src, view);
|
|
62
|
+
if (commas === null || colons === null) continue;
|
|
63
|
+
for (const span of sentenceSpans(src, view)) {
|
|
64
|
+
const inside = commas.filter((comma) => within(comma, span));
|
|
65
|
+
if (inside.length < 2) continue;
|
|
66
|
+
// The sentence start is the cut before the first segment, so the spans
|
|
67
|
+
// between the cuts are exactly the comma-separated segments.
|
|
68
|
+
const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
|
|
69
|
+
if (!segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment)))) continue;
|
|
70
|
+
|
|
71
|
+
// An intro colon in the first segment, before its label, is the lead-in.
|
|
72
|
+
// The first item then starts after it, so the intro is not repeated as
|
|
73
|
+
// both the lead-in and the head of the first bullet.
|
|
74
|
+
const opening = first(segments);
|
|
75
|
+
const colon = colons.find((c) => within(c, opening));
|
|
76
|
+
const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
|
|
77
|
+
const items: Range[] = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
|
|
78
|
+
|
|
79
|
+
const edit = promote({
|
|
80
|
+
rule: RULE.LABELLED_RUN,
|
|
81
|
+
src,
|
|
82
|
+
view,
|
|
83
|
+
span,
|
|
84
|
+
leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
|
|
85
|
+
items: itemsOf(src, items).map((text) => ({ text })),
|
|
86
|
+
ordered: false,
|
|
87
|
+
});
|
|
88
|
+
if (edit) edits.push(edit);
|
|
89
|
+
break; // one promotion per paragraph; the engine re-parses and comes back
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return edits;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const listCommaLabelledRun: Rule = {
|
|
96
|
+
id: RULE.LABELLED_RUN,
|
|
97
|
+
category: "list",
|
|
98
|
+
summary: "comma-joined labelled run (3+ labelled segments)",
|
|
99
|
+
check,
|
|
100
|
+
fix,
|
|
101
|
+
};
|