@jpeakai/prose-gates 0.1.2 → 0.3.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 +45 -7
- package/dist/bin.js +35469 -6876
- package/dist/index.js +35566 -6939
- package/dist/testing.js +38824 -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 +12 -3
- package/src/bin.ts +2 -2
- package/src/check.ts +53 -13
- package/src/cli.ts +243 -44
- package/src/cohesion.ts +70 -0
- package/src/concepts.ts +209 -0
- package/src/config.ts +119 -0
- package/src/errors.ts +212 -0
- package/src/fix/engine.ts +90 -50
- package/src/fix/verify.ts +60 -19
- package/src/index.ts +51 -2
- package/src/interpunct.ts +7 -7
- package/src/language.ts +107 -0
- package/src/model.ts +271 -68
- package/src/plugins/contract.ts +41 -0
- package/src/plugins/discover.ts +146 -0
- package/src/plugins/index.ts +136 -0
- package/src/plugins/load.ts +274 -0
- package/src/plugins/runtime.ts +15 -0
- package/src/project.ts +84 -0
- package/src/rules/index.ts +34 -20
- package/src/rules/list-comma-labelled-run.ts +117 -0
- package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +56 -45
- package/src/rules/list-interpunct-joined-run.ts +71 -0
- 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/paragraph-sentence-budget-exceeded.ts +64 -0
- 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 +143 -0
- package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +45 -18
- package/src/rules/sentence-word-budget-exceeded.ts +57 -0
- package/src/rules/types.ts +71 -21
- package/src/testing.ts +60 -0
- package/src/rules/pg002-length.ts +0 -45
- package/src/rules/pg006-interpunct-run.ts +0 -68
- package/src/rules/pg008-labelled-run.ts +0 -101
package/src/config.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The config file: which rules are off, what options each takes, and how
|
|
2
|
+
// strictly plugins load. It is optional, because everything auto-detected loads
|
|
3
|
+
// without it. A mistake in it is a named usage error, so a stale or misspelt
|
|
4
|
+
// rule id fails the run instead of silently doing nothing.
|
|
5
|
+
|
|
6
|
+
import { access, readFile } from "node:fs/promises";
|
|
7
|
+
import { extname, join } from "node:path";
|
|
8
|
+
import { pathToFileURL } from "node:url";
|
|
9
|
+
import { AmbiguousConfigError, ConfigFileError, ConfigShapeError, RuleSettingError, UsageError } from "./errors.ts";
|
|
10
|
+
import type { RuleOptions } from "./rules/types.ts";
|
|
11
|
+
|
|
12
|
+
export { UsageError };
|
|
13
|
+
|
|
14
|
+
export type Severity = "error" | "off";
|
|
15
|
+
|
|
16
|
+
// How a plugin that fails to load is treated. Strict stops the run. Lenient
|
|
17
|
+
// skips that plugin, says so, and carries on with the rest.
|
|
18
|
+
export type PluginLoading = "strict" | "lenient";
|
|
19
|
+
|
|
20
|
+
export interface RuleSetting {
|
|
21
|
+
severity: Severity;
|
|
22
|
+
options: RuleOptions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Config {
|
|
26
|
+
// Extra plugin paths or package names beyond what is auto-detected, or
|
|
27
|
+
// `false` for a built-ins-only run.
|
|
28
|
+
plugins: string[] | false;
|
|
29
|
+
pluginLoading: PluginLoading;
|
|
30
|
+
rules: Map<string, RuleSetting>;
|
|
31
|
+
// The file it came from, for messages. Null when there is no config.
|
|
32
|
+
file: string | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const NO_CONFIG: Config = { plugins: [], pluginLoading: "strict", rules: new Map(), file: null };
|
|
36
|
+
|
|
37
|
+
export const CONFIG_FILES = ["prose-gates.config.json", "prose-gates.config.mjs"] as const;
|
|
38
|
+
|
|
39
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
40
|
+
|
|
41
|
+
const parseSetting = (file: string, id: string, raw: unknown): RuleSetting => {
|
|
42
|
+
const [severity, options = {}] = Array.isArray(raw) ? raw : [raw];
|
|
43
|
+
if (Array.isArray(raw) && (raw.length < 1 || raw.length > 2)) {
|
|
44
|
+
throw new RuleSettingError(file, id, 'must be "error", "off" or [severity, options]');
|
|
45
|
+
}
|
|
46
|
+
if (severity !== "error" && severity !== "off") {
|
|
47
|
+
throw new RuleSettingError(file, id, `has severity ${JSON.stringify(severity)}; use "error" or "off"`);
|
|
48
|
+
}
|
|
49
|
+
if (!isRecord(options)) throw new RuleSettingError(file, id, "options must be an object");
|
|
50
|
+
return { severity, options };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const parseConfig = (file: string, raw: unknown): Config => {
|
|
54
|
+
if (!isRecord(raw)) throw new ConfigShapeError(file, "the config must be an object");
|
|
55
|
+
for (const key of Object.keys(raw)) {
|
|
56
|
+
if (key !== "plugins" && key !== "pluginLoading" && key !== "rules") {
|
|
57
|
+
throw new ConfigShapeError(
|
|
58
|
+
file,
|
|
59
|
+
`unknown key "${key}"; a config has "plugins", "pluginLoading" and "rules"`,
|
|
60
|
+
key,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const { plugins = [], pluginLoading = "strict", rules = {} } = raw;
|
|
65
|
+
const validPlugins = plugins === false || (Array.isArray(plugins) && plugins.every((p) => typeof p === "string"));
|
|
66
|
+
if (!validPlugins) throw new ConfigShapeError(file, '"plugins" must be a list of strings, or false', "plugins");
|
|
67
|
+
if (pluginLoading !== "strict" && pluginLoading !== "lenient") {
|
|
68
|
+
throw new ConfigShapeError(file, '"pluginLoading" must be "strict" or "lenient"', "pluginLoading");
|
|
69
|
+
}
|
|
70
|
+
if (!isRecord(rules)) throw new ConfigShapeError(file, '"rules" must be an object', "rules");
|
|
71
|
+
return {
|
|
72
|
+
plugins: plugins as string[] | false,
|
|
73
|
+
pluginLoading,
|
|
74
|
+
rules: new Map(Object.entries(rules).map(([id, value]) => [id, parseSetting(file, id, value)])),
|
|
75
|
+
file,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// The first config file at the root, or null. Two at once is ambiguous, so it
|
|
80
|
+
// is an error rather than a pick.
|
|
81
|
+
export const findConfig = async (root: string): Promise<string | null> => {
|
|
82
|
+
const found: string[] = [];
|
|
83
|
+
for (const name of CONFIG_FILES) {
|
|
84
|
+
const path = join(root, name);
|
|
85
|
+
if (
|
|
86
|
+
await access(path).then(
|
|
87
|
+
() => true,
|
|
88
|
+
() => false,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
found.push(path);
|
|
92
|
+
}
|
|
93
|
+
if (found.length > 1) throw new AmbiguousConfigError(root, found);
|
|
94
|
+
return found[0] ?? null;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const reason = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
98
|
+
|
|
99
|
+
export const loadConfig = async (file: string): Promise<Config> => {
|
|
100
|
+
if (extname(file) === ".json") {
|
|
101
|
+
let raw: unknown;
|
|
102
|
+
try {
|
|
103
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new ConfigFileError(file, reason(err));
|
|
106
|
+
}
|
|
107
|
+
return parseConfig(file, raw);
|
|
108
|
+
}
|
|
109
|
+
if (extname(file) === ".mjs") {
|
|
110
|
+
let mod: { default?: unknown };
|
|
111
|
+
try {
|
|
112
|
+
mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
|
|
113
|
+
} catch (err) {
|
|
114
|
+
throw new ConfigFileError(file, `could not be imported: ${reason(err)}`);
|
|
115
|
+
}
|
|
116
|
+
return parseConfig(file, mod.default);
|
|
117
|
+
}
|
|
118
|
+
throw new ConfigFileError(file, "a config is a .json or .mjs file");
|
|
119
|
+
};
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Every way a run can fail on purpose, as a class that says what went wrong.
|
|
2
|
+
// A usage error is the caller's to fix and exits 2. A plugin error exits 1.
|
|
3
|
+
// Each class carries a stable `code` and the fields a tool needs to act on it,
|
|
4
|
+
// so a wrapper can branch on the type instead of reading the message.
|
|
5
|
+
|
|
6
|
+
export class ProseGatesError extends Error {
|
|
7
|
+
readonly code: string;
|
|
8
|
+
|
|
9
|
+
constructor(code: string, message: string) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.name = new.target.name;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// --- usage: the invocation or the config is wrong -------------------------
|
|
17
|
+
|
|
18
|
+
export class UsageError extends ProseGatesError {
|
|
19
|
+
constructor(message: string, code = "usage") {
|
|
20
|
+
super(code, message);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// A flag that is missing or has a value it cannot take.
|
|
25
|
+
export class FlagError extends UsageError {
|
|
26
|
+
readonly flag: string;
|
|
27
|
+
|
|
28
|
+
constructor(flag: string, message: string) {
|
|
29
|
+
super(message, "flag");
|
|
30
|
+
this.flag = flag;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A config file that cannot be read or parsed, or is the wrong kind of file.
|
|
35
|
+
export class ConfigFileError extends UsageError {
|
|
36
|
+
readonly file: string;
|
|
37
|
+
|
|
38
|
+
constructor(file: string, message: string) {
|
|
39
|
+
super(`${file}: ${message}`, "config-file");
|
|
40
|
+
this.file = file;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --config named a file that is not there.
|
|
45
|
+
export class ConfigNotFoundError extends UsageError {
|
|
46
|
+
readonly path: string;
|
|
47
|
+
|
|
48
|
+
constructor(path: string) {
|
|
49
|
+
super(`--config ${path}: no such file`, "config-not-found");
|
|
50
|
+
this.path = path;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Two config files at one root, so neither can be picked.
|
|
55
|
+
export class AmbiguousConfigError extends UsageError {
|
|
56
|
+
readonly files: string[];
|
|
57
|
+
|
|
58
|
+
constructor(root: string, files: string[]) {
|
|
59
|
+
super(`more than one config file at ${root}: ${files.join(", ")}`, "config-ambiguous");
|
|
60
|
+
this.files = files;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A concept file at the project root that does not parse as one: no term
|
|
65
|
+
// headings, a repeated term, or a diagram that names something undefined.
|
|
66
|
+
export class ConceptsFileError extends UsageError {
|
|
67
|
+
readonly file: string;
|
|
68
|
+
|
|
69
|
+
constructor(file: string, message: string) {
|
|
70
|
+
super(`${file}: not a concept file, because ${message}`, "concepts-file");
|
|
71
|
+
this.file = file;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Both GLOSSARY.md and CONCEPTS.md at one root, so neither can be picked.
|
|
76
|
+
export class AmbiguousConceptsError extends UsageError {
|
|
77
|
+
readonly files: string[];
|
|
78
|
+
|
|
79
|
+
constructor(root: string, files: string[]) {
|
|
80
|
+
super(`more than one concept file at ${root}: ${files.join(", ")}`, "concepts-ambiguous");
|
|
81
|
+
this.files = files;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The config parsed, but its top-level shape is wrong.
|
|
86
|
+
export class ConfigShapeError extends UsageError {
|
|
87
|
+
readonly file: string;
|
|
88
|
+
readonly key: string | null;
|
|
89
|
+
|
|
90
|
+
constructor(file: string, message: string, key: string | null = null) {
|
|
91
|
+
super(`${file}: ${message}`, "config-shape");
|
|
92
|
+
this.file = file;
|
|
93
|
+
this.key = key;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// One rule's setting is not a severity, or a severity and options.
|
|
98
|
+
export class RuleSettingError extends UsageError {
|
|
99
|
+
readonly file: string;
|
|
100
|
+
readonly rule: string;
|
|
101
|
+
|
|
102
|
+
constructor(file: string, rule: string, message: string) {
|
|
103
|
+
super(`${file}: rule "${rule}" ${message}`, "rule-setting");
|
|
104
|
+
this.file = file;
|
|
105
|
+
this.rule = rule;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The config names a rule that is not loaded, which also catches a retired id.
|
|
110
|
+
export class UnknownRuleError extends UsageError {
|
|
111
|
+
readonly file: string;
|
|
112
|
+
readonly rule: string;
|
|
113
|
+
readonly known: string[];
|
|
114
|
+
|
|
115
|
+
constructor(file: string, rule: string, known: string[]) {
|
|
116
|
+
super(
|
|
117
|
+
`${file}: unknown rule "${rule}"; a retired or misspelt id cannot be switched off. Known rules: ${known.join(", ")}`,
|
|
118
|
+
"unknown-rule",
|
|
119
|
+
);
|
|
120
|
+
this.file = file;
|
|
121
|
+
this.rule = rule;
|
|
122
|
+
this.known = known;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A rule option the rule does not take, or a value of the wrong type or range.
|
|
127
|
+
export class RuleOptionError extends UsageError {
|
|
128
|
+
readonly file: string;
|
|
129
|
+
readonly rule: string;
|
|
130
|
+
readonly option: string;
|
|
131
|
+
|
|
132
|
+
constructor(file: string, rule: string, option: string, message: string) {
|
|
133
|
+
super(`${file}: ${message}`, "rule-option");
|
|
134
|
+
this.file = file;
|
|
135
|
+
this.rule = rule;
|
|
136
|
+
this.option = option;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- plugins: a source could not be loaded --------------------------------
|
|
141
|
+
|
|
142
|
+
// A source that failed to load. `source` is what the user would recognise, a
|
|
143
|
+
// package name or a path under the project root.
|
|
144
|
+
export class PluginLoadError extends ProseGatesError {
|
|
145
|
+
readonly source: string;
|
|
146
|
+
|
|
147
|
+
constructor(source: string, reason: string, code = "plugin") {
|
|
148
|
+
super(code, `plugin ${source}: ${reason}`);
|
|
149
|
+
this.source = source;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The module threw, or could not be found, when it was imported.
|
|
154
|
+
export class PluginImportError extends PluginLoadError {
|
|
155
|
+
constructor(source: string, reason: string) {
|
|
156
|
+
super(source, `could not be imported: ${reason}`, "plugin-import");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// The module loaded but does not meet the contract: shape, version, namespace,
|
|
161
|
+
// category, or a rule's declaration.
|
|
162
|
+
export class PluginContractError extends PluginLoadError {
|
|
163
|
+
constructor(source: string, reason: string) {
|
|
164
|
+
super(source, reason, "plugin-contract");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Two plugins claim one namespace or one new category.
|
|
169
|
+
export class PluginConflictError extends PluginLoadError {
|
|
170
|
+
readonly other: string;
|
|
171
|
+
|
|
172
|
+
constructor(source: string, other: string, reason: string) {
|
|
173
|
+
super(source, reason, "plugin-conflict");
|
|
174
|
+
this.other = other;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A dependency the project declares, or a config lists, that is not installed.
|
|
179
|
+
export class PluginNotInstalledError extends PluginLoadError {
|
|
180
|
+
readonly root: string;
|
|
181
|
+
|
|
182
|
+
constructor(source: string, root: string) {
|
|
183
|
+
super(
|
|
184
|
+
source,
|
|
185
|
+
`is declared or listed but not installed; looked from ${root} upward. Install it, or remove it`,
|
|
186
|
+
"plugin-not-installed",
|
|
187
|
+
);
|
|
188
|
+
this.root = root;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A file the loader cannot read in this runtime, such as TypeScript on a
|
|
193
|
+
// runtime that cannot strip types.
|
|
194
|
+
export class PluginSourceError extends PluginLoadError {
|
|
195
|
+
constructor(source: string, reason: string) {
|
|
196
|
+
super(source, reason, "plugin-source");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- plugins: a loaded plugin misbehaved while running --------------------
|
|
201
|
+
|
|
202
|
+
// A fixer threw or returned something that is not a list of valid edits.
|
|
203
|
+
// Unlike a check, this stops the run, because carrying on after a corrupt edit
|
|
204
|
+
// is worse than stopping.
|
|
205
|
+
export class PluginFixerError extends ProseGatesError {
|
|
206
|
+
readonly rule: string;
|
|
207
|
+
|
|
208
|
+
constructor(rule: string, reason: string) {
|
|
209
|
+
super("plugin-fixer", `plugin rule ${rule} ${reason}`);
|
|
210
|
+
this.rule = rule;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/fix/engine.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// edit that survives verification. Offsets are only ever valid for the
|
|
4
4
|
// source they were computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
|
+
import type { Root } from "mdast";
|
|
6
7
|
import { buildDocModel, parse } from "../model.ts";
|
|
7
|
-
import {
|
|
8
|
-
import type { Edit } from "../rules/types.ts";
|
|
8
|
+
import { BUILTIN, type Registry, ruleContext, TEXT_HELPERS } from "../rules/registry.ts";
|
|
9
|
+
import type { Edit, Rule, RuleContext } from "../rules/types.ts";
|
|
9
10
|
import { verify } from "./verify.ts";
|
|
10
11
|
|
|
11
12
|
const MAX_PASSES = 10_000;
|
|
@@ -27,58 +28,97 @@ const overlaps = (edits: Edit[]): boolean => {
|
|
|
27
28
|
return sorted.some((e, i) => i > 0 && e.start < (sorted[i - 1] as Edit).end);
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
31
|
+
// Offsets are whole numbers inside the source, in order, and the replacement
|
|
32
|
+
// is text. Anything else is a malformed edit from a fixer that miscounted.
|
|
33
|
+
const wellFormed = (e: Edit, length: number): boolean =>
|
|
34
|
+
Number.isInteger(e.start) &&
|
|
35
|
+
Number.isInteger(e.end) &&
|
|
36
|
+
e.start >= 0 &&
|
|
37
|
+
e.start <= e.end &&
|
|
38
|
+
e.end <= length &&
|
|
39
|
+
typeof e.text === "string";
|
|
36
40
|
|
|
41
|
+
// What the loop carries between passes. An edit refused once stays refused
|
|
42
|
+
// while its source slice is unchanged, which `refusedKeys` remembers.
|
|
43
|
+
interface FixState {
|
|
44
|
+
current: string;
|
|
45
|
+
applied: Edit[];
|
|
46
|
+
refused: Edit[];
|
|
47
|
+
refusedKeys: Set<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Before = { src: string; tree: Root };
|
|
51
|
+
|
|
52
|
+
const keyOf = (state: FixState, e: Edit): string =>
|
|
53
|
+
`${e.rule}\u0000${state.current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
54
|
+
|
|
55
|
+
// Splice the edits, re-parse, and keep the result only if every edit is
|
|
56
|
+
// proven against the tree the pass started from.
|
|
57
|
+
const attempt = (state: FixState, edits: Edit[], before: Before): boolean => {
|
|
58
|
+
const next = splice(state.current, edits);
|
|
59
|
+
const afterTree = parse(next);
|
|
60
|
+
if (!edits.every((e) => verify(e, before, afterTree) === "accept")) return false;
|
|
61
|
+
state.current = next;
|
|
62
|
+
state.applied.push(...edits);
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// An edit that points outside the source cannot be proven, so it is
|
|
67
|
+
// refused before it is ever spliced. A built-in never proposes one.
|
|
68
|
+
const keepWellFormed = (state: FixState, proposed: Edit[]): Edit[] =>
|
|
69
|
+
proposed.filter((e) => {
|
|
70
|
+
if (wellFormed(e, state.current.length)) return true;
|
|
71
|
+
const key = `${e.rule}\u0000malformed\u0000${e.start}:${e.end}`;
|
|
72
|
+
if (!state.refusedKeys.has(key)) {
|
|
73
|
+
state.refusedKeys.add(key);
|
|
74
|
+
state.refused.push(e);
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// The rule's edits that would change something and have not been refused.
|
|
80
|
+
const proposeEdits = async (state: FixState, rule: Rule, ctx: RuleContext, registry: Registry): Promise<Edit[]> => {
|
|
81
|
+
const proposed =
|
|
82
|
+
(await rule.fix?.({ ...ctx, options: registry.options.get(rule.id) ?? {}, helpers: TEXT_HELPERS })) ?? [];
|
|
83
|
+
const sound = keepWellFormed(state, proposed);
|
|
84
|
+
return sound.filter((e) => state.current.slice(e.start, e.end) !== e.text && !state.refusedKeys.has(keyOf(state, e)));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Whitespace and glyph edits leave the tree's shape alone, so a rule's
|
|
88
|
+
// whole batch can be proven in one re-parse. A batch that fails falls
|
|
89
|
+
// back to one edit at a time, so one bad edit never blocks the rest.
|
|
90
|
+
const applyRuleEdits = (state: FixState, edits: Edit[], before: Before): boolean => {
|
|
91
|
+
const batchable = edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");
|
|
92
|
+
if (batchable && attempt(state, edits, before)) return true;
|
|
93
|
+
for (const edit of edits) {
|
|
94
|
+
if (attempt(state, [edit], before)) return true;
|
|
95
|
+
state.refusedKeys.add(keyOf(state, edit));
|
|
96
|
+
state.refused.push(edit);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// One pass: rules in fix order until one edit lands. True when it did.
|
|
102
|
+
const runPass = async (state: FixState, registry: Registry): Promise<boolean> => {
|
|
103
|
+
const ctx = ruleContext(buildDocModel(state.current, registry.concepts), registry);
|
|
104
|
+
const before = { src: state.current, tree: ctx.doc.tree };
|
|
105
|
+
for (const rule of registry.fixOrder) {
|
|
106
|
+
const edits = await proposeEdits(state, rule, ctx, registry);
|
|
107
|
+
if (edits.length === 0) continue;
|
|
108
|
+
if (applyRuleEdits(state, edits, before)) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const fixMarkdownReport = async (src: string, registry: Registry = BUILTIN): Promise<FixReport> => {
|
|
114
|
+
const state: FixState = { current: src, applied: [], refused: [], refusedKeys: new Set<string>() };
|
|
37
115
|
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
41
|
-
|
|
42
|
-
const attempt = (edits: Edit[]): boolean => {
|
|
43
|
-
const next = splice(current, edits);
|
|
44
|
-
const afterTree = parse(next);
|
|
45
|
-
if (edits.every((e) => verify(e, before, afterTree) === "accept")) {
|
|
46
|
-
current = next;
|
|
47
|
-
applied.push(...edits);
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
return false;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
let progressed = false;
|
|
54
|
-
for (const rule of FIX_ORDER) {
|
|
55
|
-
const edits = (rule.fix?.(ctx) ?? []).filter(
|
|
56
|
-
(e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)),
|
|
57
|
-
);
|
|
58
|
-
if (edits.length === 0) continue;
|
|
59
|
-
|
|
60
|
-
// Whitespace and glyph edits leave the tree's shape alone, so a rule's
|
|
61
|
-
// whole batch can be proven in one re-parse. A batch that fails falls
|
|
62
|
-
// back to one edit at a time, so one bad edit never blocks the rest.
|
|
63
|
-
const batchable =
|
|
64
|
-
edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");
|
|
65
|
-
if (batchable && attempt(edits)) {
|
|
66
|
-
progressed = true;
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
for (const edit of edits) {
|
|
70
|
-
if (attempt([edit])) {
|
|
71
|
-
progressed = true;
|
|
72
|
-
break;
|
|
73
|
-
}
|
|
74
|
-
refusedKeys.add(keyOf(edit));
|
|
75
|
-
refused.push(edit);
|
|
76
|
-
}
|
|
77
|
-
if (progressed) break;
|
|
116
|
+
if (!(await runPass(state, registry))) {
|
|
117
|
+
return { output: state.current, applied: state.applied, refused: state.refused };
|
|
78
118
|
}
|
|
79
|
-
if (!progressed) return { output: current, applied, refused };
|
|
80
119
|
}
|
|
81
120
|
throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
|
|
82
121
|
};
|
|
83
122
|
|
|
84
|
-
export const fixMarkdown = (src: string): string =>
|
|
123
|
+
export const fixMarkdown = async (src: string, registry: Registry = BUILTIN): Promise<string> =>
|
|
124
|
+
(await fixMarkdownReport(src, registry)).output;
|
package/src/fix/verify.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Node, Parent, Root } from "mdast";
|
|
7
7
|
import { visitParents as visit } from "unist-util-visit-parents";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
8
9
|
import { parse } from "../model.ts";
|
|
9
10
|
import type { Edit } from "../rules/types.ts";
|
|
10
11
|
|
|
@@ -105,30 +106,70 @@ const fragmentShapeOk = (nodes: Node[], listItems: number): boolean => {
|
|
|
105
106
|
return items.length === listItems && items.every((i) => i.children[0]?.type === "paragraph");
|
|
106
107
|
};
|
|
107
108
|
|
|
109
|
+
const INVALID = Symbol("invalid frontmatter");
|
|
110
|
+
|
|
111
|
+
// What the leading YAML block means: its data as the yaml package reads it, so a
|
|
112
|
+
// rewrite that only changes how the data is written compares equal. Absent
|
|
113
|
+
// frontmatter is `undefined`, and a block that does not parse is INVALID.
|
|
114
|
+
const frontmatterData = (tree: Root): unknown => {
|
|
115
|
+
const first = tree.children[0];
|
|
116
|
+
if (first?.type !== "yaml") return undefined;
|
|
117
|
+
try {
|
|
118
|
+
return parseYaml(first.value);
|
|
119
|
+
} catch {
|
|
120
|
+
return INVALID;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// The tree with its leading frontmatter node taken off, so everything else can
|
|
125
|
+
// be compared on its own.
|
|
126
|
+
const withoutFrontmatter = (tree: Root): Root =>
|
|
127
|
+
tree.children[0]?.type === "yaml" ? { ...tree, children: tree.children.slice(1) } : tree;
|
|
128
|
+
|
|
108
129
|
export type Verdict = "accept" | "refuse";
|
|
109
130
|
|
|
110
|
-
|
|
111
|
-
|
|
131
|
+
// No word may be lost or moved. A mismatch is a fixer bug, so it throws and
|
|
132
|
+
// names the first word where the two fingerprints part.
|
|
133
|
+
const assertNoWordLost = (edit: Edit, beforeTree: Root, afterTree: Root): void => {
|
|
134
|
+
const lost = fingerprint(beforeTree).join(" ");
|
|
112
135
|
const kept = fingerprint(afterTree).join(" ");
|
|
113
|
-
if (lost
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
if (lost === kept) return;
|
|
137
|
+
const a = lost.split(" ");
|
|
138
|
+
const b = kept.split(" ");
|
|
139
|
+
const diverge = a.findIndex((w, i) => w !== b[i]);
|
|
140
|
+
const at = diverge === -1 ? a.length : diverge;
|
|
141
|
+
throw new FixInvariantError(edit, `word ${at} was "${a[at] ?? ""}", now "${b[at] ?? ""}"`);
|
|
142
|
+
};
|
|
120
143
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
const verdictOf = (ok: boolean): Verdict => (ok ? "accept" : "refuse");
|
|
145
|
+
|
|
146
|
+
// The frontmatter must mean the same data, and the rest of the tree must be
|
|
147
|
+
// untouched.
|
|
148
|
+
const sameFrontmatterData = (beforeTree: Root, afterTree: Root): Verdict => {
|
|
149
|
+
const was = frontmatterData(beforeTree);
|
|
150
|
+
const now = frontmatterData(afterTree);
|
|
151
|
+
if (was === INVALID || now === INVALID || !sameJson(was, now)) return "refuse";
|
|
152
|
+
return verdictOf(sameJson(normalise(withoutFrontmatter(beforeTree)), normalise(withoutFrontmatter(afterTree))));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type ReplaceParagraph = Extract<Edit["expect"], { kind: "replace-paragraph" }>;
|
|
156
|
+
|
|
157
|
+
// The paragraph must become exactly the fragment the fixer declared, parsed
|
|
158
|
+
// with the document's definitions, and nothing else may change.
|
|
159
|
+
const replacedParagraph = (expect: ReplaceParagraph, before: { src: string; tree: Root }, afterTree: Root): Verdict => {
|
|
130
160
|
const nodes = parseFragment(expect.fragment, definitionsOf(before.src, before.tree));
|
|
131
161
|
if (!fragmentShapeOk(nodes, expect.listItems)) return "refuse";
|
|
132
162
|
const expected = normalise(before.tree, { swap: [expect.view.node, nodes] });
|
|
133
|
-
return sameJson(expected, normalise(afterTree))
|
|
163
|
+
return verdictOf(sameJson(expected, normalise(afterTree)));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const verify = (edit: Edit, before: { src: string; tree: Root }, afterTree: Root): Verdict => {
|
|
167
|
+
assertNoWordLost(edit, before.tree, afterTree);
|
|
168
|
+
const expect = edit.expect;
|
|
169
|
+
if (expect.kind === "same-tree") return verdictOf(sameJson(normalise(before.tree), normalise(afterTree)));
|
|
170
|
+
if (expect.kind === "same-frontmatter-data") return sameFrontmatterData(before.tree, afterTree);
|
|
171
|
+
if (expect.kind === "same-shape") {
|
|
172
|
+
return verdictOf(sameJson(normalise(before.tree, { blankText: true }), normalise(afterTree, { blankText: true })));
|
|
173
|
+
}
|
|
174
|
+
return replacedParagraph(expect, before, afterTree);
|
|
134
175
|
};
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,58 @@
|
|
|
2
2
|
|
|
3
3
|
export { checkMarkdown, checkModel, DEFAULT_MAX_WORDS } from "./check.ts";
|
|
4
4
|
export { main } from "./cli.ts";
|
|
5
|
+
export {
|
|
6
|
+
CONCEPT_FILES,
|
|
7
|
+
type Concept,
|
|
8
|
+
type ConceptMention,
|
|
9
|
+
type ConceptModel,
|
|
10
|
+
type ConceptRelation,
|
|
11
|
+
KIND_OF,
|
|
12
|
+
loadConcepts,
|
|
13
|
+
NO_CONCEPTS,
|
|
14
|
+
parseConcepts,
|
|
15
|
+
} from "./concepts.ts";
|
|
16
|
+
export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSetting, type Severity } from "./config.ts";
|
|
17
|
+
export * from "./errors.ts";
|
|
5
18
|
export { type FixReport, fixMarkdown, fixMarkdownReport } from "./fix/engine.ts";
|
|
6
19
|
export { FixInvariantError } from "./fix/verify.ts";
|
|
7
|
-
export {
|
|
20
|
+
export type { LanguageSentence, LanguageView, LanguageWord } from "./language.ts";
|
|
21
|
+
export {
|
|
22
|
+
buildDocModel,
|
|
23
|
+
type DocModel,
|
|
24
|
+
type FrontmatterEntry,
|
|
25
|
+
type FrontmatterScalar,
|
|
26
|
+
type FrontmatterView,
|
|
27
|
+
type ParagraphView,
|
|
28
|
+
type ScalarStyle,
|
|
29
|
+
} from "./model.ts";
|
|
30
|
+
export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
|
|
31
|
+
export { findProjectRoot, setUp } from "./project.ts";
|
|
8
32
|
export { FIX_ORDER, RULES } from "./rules/index.ts";
|
|
9
|
-
export
|
|
33
|
+
export {
|
|
34
|
+
BUILTIN,
|
|
35
|
+
BUILTIN_CATEGORIES,
|
|
36
|
+
buildRegistry,
|
|
37
|
+
type LoadedSource,
|
|
38
|
+
type Registry,
|
|
39
|
+
type RegistryInput,
|
|
40
|
+
} from "./rules/registry.ts";
|
|
41
|
+
export type {
|
|
42
|
+
Category,
|
|
43
|
+
CheckContext,
|
|
44
|
+
CoreRuleId,
|
|
45
|
+
Edit,
|
|
46
|
+
Expectation,
|
|
47
|
+
Finding,
|
|
48
|
+
FixContext,
|
|
49
|
+
OptionSpec,
|
|
50
|
+
OptionType,
|
|
51
|
+
PluginRuleId,
|
|
52
|
+
Rule,
|
|
53
|
+
RuleContext,
|
|
54
|
+
RuleId,
|
|
55
|
+
RuleOptions,
|
|
56
|
+
TextHelpers,
|
|
57
|
+
} from "./rules/types.ts";
|
|
10
58
|
export { CATEGORIES, RULE } from "./rules/types.ts";
|
|
59
|
+
export { sentences, words } from "./text.ts";
|
package/src/interpunct.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The interpunct run: the one piece of data derived from a whole document
|
|
2
|
-
// that more than one rule reads.
|
|
3
|
-
// quiet inside one, and
|
|
2
|
+
// that more than one rule reads. list-interpunct-joined-run and list-stacked-interpunct-runs report a run, punctuation-interpunct-in-prose stays
|
|
3
|
+
// quiet inside one, and sentence-one-per-line leaves a stacked one unreflowed, so it is
|
|
4
4
|
// derived once per document and handed to every rule as data.
|
|
5
5
|
|
|
6
6
|
import type { DocModel, ParagraphView } from "./model.ts";
|
|
@@ -10,8 +10,8 @@ import { countOf, type Range } from "./text.ts";
|
|
|
10
10
|
export const INTERPUNCT = "·";
|
|
11
11
|
|
|
12
12
|
// The glyph on its own, for counting, and the glyph with the space a run
|
|
13
|
-
// puts around it, for splitting.
|
|
14
|
-
//
|
|
13
|
+
// puts around it, for splitting. list-interpunct-joined-run and list-stacked-interpunct-runs both cut on the separator;
|
|
14
|
+
// punctuation-interpunct-in-prose is stricter and needs space on both sides, so it keeps its own.
|
|
15
15
|
export const GLYPH = /·/g;
|
|
16
16
|
export const RUN_SEPARATOR = /[ \t]*·[ \t]*/;
|
|
17
17
|
|
|
@@ -34,9 +34,9 @@ export const interpunctRuns = (doc: DocModel): InterpunctRun[] => {
|
|
|
34
34
|
return runs;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
// A run on one source line is a flat list, which
|
|
38
|
-
// over two or more lines is a two-level structure, which
|
|
39
|
-
//
|
|
37
|
+
// A run on one source line is a flat list, which list-interpunct-joined-run owns. A run spread
|
|
38
|
+
// over two or more lines is a two-level structure, which list-stacked-interpunct-runs owns and
|
|
39
|
+
// sentence-one-per-line leaves unreflowed. The two predicates partition every run.
|
|
40
40
|
export const isFlat = (run: InterpunctRun): boolean => run.lines === 1;
|
|
41
41
|
|
|
42
42
|
export const isStacked = (run: InterpunctRun): boolean => run.lines >= 2;
|