@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
package/src/cli.ts
CHANGED
|
@@ -3,13 +3,20 @@
|
|
|
3
3
|
|
|
4
4
|
import { readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
-
import { checkModel
|
|
7
|
-
import {
|
|
6
|
+
import { checkModel } from "./check.ts";
|
|
7
|
+
import { FlagError, ProseGatesError, UsageError } from "./errors.ts";
|
|
8
|
+
import { fixMarkdownReport } from "./fix/engine.ts";
|
|
8
9
|
import { buildDocModel } from "./model.ts";
|
|
10
|
+
import type { PluginFailure } from "./plugins/index.ts";
|
|
11
|
+
import { setUp } from "./project.ts";
|
|
9
12
|
import { RULES } from "./rules/index.ts";
|
|
13
|
+
import type { LoadedSource, Registry } from "./rules/registry.ts";
|
|
10
14
|
import { CATEGORIES } from "./rules/types.ts";
|
|
11
15
|
|
|
12
|
-
const USAGE = `usage: prose-gates <file.md> [...more] [--fix] [--json] [--max-words N]
|
|
16
|
+
const USAGE = `usage: prose-gates <file.md> [...more] [--fix] [--json] [--max-words N] [--config FILE]
|
|
17
|
+
[--no-plugins | --lenient-plugins]
|
|
18
|
+
prose-gates --list-rules
|
|
19
|
+
prose-gates --validate-plugins [--json] [--config FILE]
|
|
13
20
|
|
|
14
21
|
Deterministic prose gates (* = autofixable):
|
|
15
22
|
${CATEGORIES.map((c) =>
|
|
@@ -25,58 +32,220 @@ markdown templates and their body is audited recursively (check-only).
|
|
|
25
32
|
|
|
26
33
|
--fix applies every fix it can prove safe, then reports what remains.
|
|
27
34
|
A fixer that is unsure leaves the text alone and the finding stays.
|
|
28
|
-
|
|
35
|
+
--config FILE reads that config instead of prose-gates.config.json or .mjs at the project root.
|
|
36
|
+
Plugins load on their own from .prose-gates/rules and from dependencies named prose-gates-plugin-*.
|
|
37
|
+
--no-plugins runs the built-in rules only, for a baseline or a repo you do not trust.
|
|
38
|
+
--lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
|
|
39
|
+
--validate-plugins loads and checks every plugin, reports every failure, and runs no check or fix.
|
|
40
|
+
--list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
|
|
41
|
+
Exits 1 when findings remain or a plugin fails to load, 2 on usage error.`;
|
|
29
42
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
interface Parsed {
|
|
44
|
+
values: {
|
|
45
|
+
fix?: boolean;
|
|
46
|
+
json?: boolean;
|
|
47
|
+
help?: boolean;
|
|
48
|
+
"max-words"?: string;
|
|
49
|
+
config?: string;
|
|
50
|
+
"no-plugins"?: boolean;
|
|
51
|
+
"lenient-plugins"?: boolean;
|
|
52
|
+
"validate-plugins"?: boolean;
|
|
53
|
+
"list-rules"?: boolean;
|
|
54
|
+
};
|
|
55
|
+
positionals: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const parse = (argv: string[]): Parsed =>
|
|
59
|
+
parseArgs({
|
|
60
|
+
args: argv,
|
|
61
|
+
options: {
|
|
62
|
+
fix: { type: "boolean", default: false },
|
|
63
|
+
json: { type: "boolean", default: false },
|
|
64
|
+
help: { type: "boolean", short: "h", default: false },
|
|
65
|
+
"max-words": { type: "string" },
|
|
66
|
+
config: { type: "string" },
|
|
67
|
+
"no-plugins": { type: "boolean", default: false },
|
|
68
|
+
"lenient-plugins": { type: "boolean", default: false },
|
|
69
|
+
"validate-plugins": { type: "boolean", default: false },
|
|
70
|
+
"list-rules": { type: "boolean", default: false },
|
|
71
|
+
},
|
|
72
|
+
allowPositionals: true,
|
|
73
|
+
strict: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
77
|
+
|
|
78
|
+
const namesOf = (sources: LoadedSource[]): string =>
|
|
79
|
+
sources.map((s) => `${s.namespace} (${plural(s.rules.length, "rule")})`).join(", ");
|
|
80
|
+
|
|
81
|
+
// The one line every run prints about third-party code, because loading it is
|
|
82
|
+
// automatic. It says what ran, or what was skipped, and prints nothing when the
|
|
83
|
+
// project has no plugins.
|
|
84
|
+
export const loadedLine = (sources: LoadedSource[], skipped: string[]): string | null => {
|
|
85
|
+
if (skipped.length > 0) return `prose-gates: plugins off; skipped ${skipped.join(", ")}`;
|
|
86
|
+
if (sources.length === 0) return null;
|
|
87
|
+
return `prose-gates: loaded ${namesOf(sources)}`;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// One line for each plugin a lenient run left out, with the named reason, and
|
|
91
|
+
// one for each config setting dropped because its plugin did not load.
|
|
92
|
+
export const skippedLines = (failures: PluginFailure[], ignored: string[]): string[] => [
|
|
93
|
+
...failures.map((f) => `prose-gates: skipped plugin ${f.source} [${f.error.code}]: ${reasonOf(f)}`),
|
|
94
|
+
...ignored.map((id) => `prose-gates: ignored config setting for ${id}, whose plugin did not load`),
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
// The reason without the "plugin <source>:" the error message begins with.
|
|
98
|
+
const reasonOf = (f: PluginFailure): string => f.error.message.replace(`plugin ${f.source}: `, "");
|
|
99
|
+
|
|
100
|
+
// Every active rule under its category, then where each plugin came from.
|
|
101
|
+
// Built-in categories come first, in their fixed order, then plugin ones.
|
|
102
|
+
export const listRules = (registry: Registry): string => {
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
for (const [category, description] of registry.categories) {
|
|
105
|
+
const rules = registry.rules.filter((r) => r.category === category);
|
|
106
|
+
if (rules.length === 0) continue;
|
|
107
|
+
lines.push(`${category}: ${description}`);
|
|
108
|
+
for (const r of rules) lines.push(` ${r.id}${r.fix ? "*" : " "} ${r.summary}`);
|
|
109
|
+
}
|
|
110
|
+
if (registry.sources.length > 0) {
|
|
111
|
+
lines.push("", "plugins:");
|
|
112
|
+
for (const s of registry.sources) lines.push(` ${s.namespace} (${s.kind}) ${s.spec}`);
|
|
113
|
+
}
|
|
114
|
+
return lines.join("\n");
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// The result of --validate-plugins: what loaded, every failure, and the exit
|
|
118
|
+
// code. Nothing here runs a check or a fix.
|
|
119
|
+
const validate = (
|
|
120
|
+
registry: Registry,
|
|
121
|
+
failures: PluginFailure[],
|
|
122
|
+
json: boolean,
|
|
123
|
+
): { code: number; stdout: string | null; stderr: string[] } => {
|
|
124
|
+
const sources = registry.sources.map((s) => ({ ...s }));
|
|
125
|
+
const failed = failures.map((f) => ({ source: f.source, kind: f.kind, code: f.error.code, message: reasonOf(f) }));
|
|
126
|
+
const code = failures.length > 0 ? 1 : 0;
|
|
127
|
+
if (json)
|
|
128
|
+
return {
|
|
129
|
+
code,
|
|
130
|
+
stdout: JSON.stringify({ valid: code === 0, plugins: sources, failures: failed }, null, 2),
|
|
131
|
+
stderr: [],
|
|
132
|
+
};
|
|
133
|
+
if (failures.length > 0) {
|
|
134
|
+
const total = failures.length + sources.length;
|
|
135
|
+
return {
|
|
136
|
+
code,
|
|
137
|
+
stdout: null,
|
|
138
|
+
stderr: [
|
|
139
|
+
`prose-gates: plugin validation failed, ${failures.length} of ${plural(total, "source")}`,
|
|
140
|
+
...failed.map((f) => ` [${f.code}] ${f.source}: ${f.message}`),
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const line =
|
|
145
|
+
sources.length === 0 ? "prose-gates: no plugins found" : `prose-gates: plugins valid: ${namesOf(registry.sources)}`;
|
|
146
|
+
return { code, stdout: line, stderr: [] };
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const run = async (argv: string[], cwd: string): Promise<number> => {
|
|
150
|
+
let parsed: Parsed;
|
|
33
151
|
try {
|
|
34
|
-
|
|
35
|
-
args: argv,
|
|
36
|
-
options: {
|
|
37
|
-
fix: { type: "boolean", default: false },
|
|
38
|
-
json: { type: "boolean", default: false },
|
|
39
|
-
help: { type: "boolean", short: "h", default: false },
|
|
40
|
-
"max-words": { type: "string" },
|
|
41
|
-
},
|
|
42
|
-
allowPositionals: true,
|
|
43
|
-
strict: true,
|
|
44
|
-
}));
|
|
152
|
+
parsed = parse(argv);
|
|
45
153
|
} catch (err) {
|
|
46
|
-
|
|
47
|
-
console.error(USAGE);
|
|
48
|
-
return 2;
|
|
154
|
+
throw new FlagError("argv", err instanceof Error ? err.message : String(err));
|
|
49
155
|
}
|
|
156
|
+
const { values, positionals } = parsed;
|
|
50
157
|
if (values.help) {
|
|
51
158
|
console.log(USAGE);
|
|
52
159
|
return 0;
|
|
53
160
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
161
|
+
// Absent means the rule's own option, then the default. Present is a flag
|
|
162
|
+
// that wins for this run.
|
|
163
|
+
const maxWords = values["max-words"] === undefined ? undefined : Number(values["max-words"]);
|
|
164
|
+
const listing = values["list-rules"];
|
|
165
|
+
const validating = values["validate-plugins"];
|
|
166
|
+
if (maxWords !== undefined && (Number.isNaN(maxWords) || maxWords < 1)) {
|
|
167
|
+
throw new FlagError("--max-words", `--max-words must be a number of at least 1, not "${values["max-words"]}"`);
|
|
168
|
+
}
|
|
169
|
+
if (values["no-plugins"] && values["lenient-plugins"]) {
|
|
170
|
+
throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
|
|
171
|
+
}
|
|
172
|
+
if (validating && (values["no-plugins"] || values.fix || listing)) {
|
|
173
|
+
throw new FlagError("--validate-plugins", "--validate-plugins takes no --no-plugins, --fix or --list-rules");
|
|
58
174
|
}
|
|
175
|
+
if (validating && positionals.length > 0) {
|
|
176
|
+
throw new FlagError("files", "--validate-plugins runs no check, so it takes no files");
|
|
177
|
+
}
|
|
178
|
+
if (positionals.length === 0 && !listing && !validating) throw new FlagError("files", "no files given");
|
|
179
|
+
|
|
180
|
+
const { registry, skipped, failures } = await setUp({
|
|
181
|
+
cwd,
|
|
182
|
+
configPath: values.config,
|
|
183
|
+
noPlugins: values["no-plugins"],
|
|
184
|
+
lenient: values["lenient-plugins"],
|
|
185
|
+
collect: validating,
|
|
186
|
+
});
|
|
59
187
|
|
|
188
|
+
if (validating) {
|
|
189
|
+
const result = validate(registry, failures, Boolean(values.json));
|
|
190
|
+
for (const line of result.stderr) console.error(line);
|
|
191
|
+
if (result.stdout) console.log(result.stdout);
|
|
192
|
+
return result.code;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const note = loadedLine(registry.sources, skipped);
|
|
196
|
+
if (note) console.error(note);
|
|
197
|
+
for (const line of skippedLines(failures, registry.ignoredSettings)) console.error(line);
|
|
198
|
+
if (listing) {
|
|
199
|
+
console.log(listRules(registry));
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
60
202
|
const perFile = await Promise.all(
|
|
61
203
|
positionals.map(async (file) => {
|
|
62
204
|
let src = await readFile(file, "utf8");
|
|
63
205
|
if (values.fix) {
|
|
64
|
-
const fixed =
|
|
206
|
+
const fixed = (await fixMarkdownReport(src, registry)).output;
|
|
65
207
|
if (fixed !== src) {
|
|
66
208
|
await writeFile(file, fixed);
|
|
67
209
|
src = fixed;
|
|
68
210
|
}
|
|
69
211
|
}
|
|
70
|
-
return checkModel(buildDocModel(src), file, maxWords);
|
|
212
|
+
return await checkModel(buildDocModel(src), file, maxWords, registry);
|
|
71
213
|
}),
|
|
72
214
|
);
|
|
73
215
|
const all = perFile.flat();
|
|
74
216
|
|
|
75
217
|
if (values.json) {
|
|
76
|
-
|
|
218
|
+
const skippedPlugins = failures.map((f) => ({
|
|
219
|
+
source: f.source,
|
|
220
|
+
kind: f.kind,
|
|
221
|
+
code: f.error.code,
|
|
222
|
+
message: reasonOf(f),
|
|
223
|
+
}));
|
|
224
|
+
console.log(
|
|
225
|
+
JSON.stringify({ findings: all, files: positionals.length, plugins: registry.sources, skippedPlugins }, null, 2),
|
|
226
|
+
);
|
|
77
227
|
} else {
|
|
78
228
|
for (const f of all) console.log(`${f.file}:${f.line} ${f.rule} ${f.message}`);
|
|
79
229
|
console.log(`${all.length} finding(s) in ${positionals.length} file(s)`);
|
|
80
230
|
}
|
|
81
231
|
return all.length > 0 ? 1 : 0;
|
|
82
232
|
};
|
|
233
|
+
|
|
234
|
+
// The line printed for a failure: its stable code, then the reason.
|
|
235
|
+
export const describeError = (err: unknown): string =>
|
|
236
|
+
err instanceof ProseGatesError
|
|
237
|
+
? `error[${err.code}]: ${err.message}`
|
|
238
|
+
: `error: ${err instanceof Error ? err.message : String(err)}`;
|
|
239
|
+
|
|
240
|
+
// A usage error prints its reason and the usage, and exits 2. Anything else is
|
|
241
|
+
// a runtime failure and propagates to bin.ts, which exits 1.
|
|
242
|
+
export const main = async (argv: string[] = process.argv.slice(2), cwd: string = process.cwd()): Promise<number> => {
|
|
243
|
+
try {
|
|
244
|
+
return await run(argv, cwd);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
if (!(err instanceof UsageError)) throw err;
|
|
247
|
+
console.error(describeError(err));
|
|
248
|
+
console.error(USAGE);
|
|
249
|
+
return 2;
|
|
250
|
+
}
|
|
251
|
+
};
|
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,191 @@
|
|
|
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
|
+
// The config parsed, but its top-level shape is wrong.
|
|
65
|
+
export class ConfigShapeError extends UsageError {
|
|
66
|
+
readonly file: string;
|
|
67
|
+
readonly key: string | null;
|
|
68
|
+
|
|
69
|
+
constructor(file: string, message: string, key: string | null = null) {
|
|
70
|
+
super(`${file}: ${message}`, "config-shape");
|
|
71
|
+
this.file = file;
|
|
72
|
+
this.key = key;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One rule's setting is not a severity, or a severity and options.
|
|
77
|
+
export class RuleSettingError extends UsageError {
|
|
78
|
+
readonly file: string;
|
|
79
|
+
readonly rule: string;
|
|
80
|
+
|
|
81
|
+
constructor(file: string, rule: string, message: string) {
|
|
82
|
+
super(`${file}: rule "${rule}" ${message}`, "rule-setting");
|
|
83
|
+
this.file = file;
|
|
84
|
+
this.rule = rule;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The config names a rule that is not loaded, which also catches a retired id.
|
|
89
|
+
export class UnknownRuleError extends UsageError {
|
|
90
|
+
readonly file: string;
|
|
91
|
+
readonly rule: string;
|
|
92
|
+
readonly known: string[];
|
|
93
|
+
|
|
94
|
+
constructor(file: string, rule: string, known: string[]) {
|
|
95
|
+
super(
|
|
96
|
+
`${file}: unknown rule "${rule}"; a retired or misspelt id cannot be switched off. Known rules: ${known.join(", ")}`,
|
|
97
|
+
"unknown-rule",
|
|
98
|
+
);
|
|
99
|
+
this.file = file;
|
|
100
|
+
this.rule = rule;
|
|
101
|
+
this.known = known;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A rule option the rule does not take, or a value of the wrong type or range.
|
|
106
|
+
export class RuleOptionError extends UsageError {
|
|
107
|
+
readonly file: string;
|
|
108
|
+
readonly rule: string;
|
|
109
|
+
readonly option: string;
|
|
110
|
+
|
|
111
|
+
constructor(file: string, rule: string, option: string, message: string) {
|
|
112
|
+
super(`${file}: ${message}`, "rule-option");
|
|
113
|
+
this.file = file;
|
|
114
|
+
this.rule = rule;
|
|
115
|
+
this.option = option;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- plugins: a source could not be loaded --------------------------------
|
|
120
|
+
|
|
121
|
+
// A source that failed to load. `source` is what the user would recognise, a
|
|
122
|
+
// package name or a path under the project root.
|
|
123
|
+
export class PluginLoadError extends ProseGatesError {
|
|
124
|
+
readonly source: string;
|
|
125
|
+
|
|
126
|
+
constructor(source: string, reason: string, code = "plugin") {
|
|
127
|
+
super(code, `plugin ${source}: ${reason}`);
|
|
128
|
+
this.source = source;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The module threw, or could not be found, when it was imported.
|
|
133
|
+
export class PluginImportError extends PluginLoadError {
|
|
134
|
+
constructor(source: string, reason: string) {
|
|
135
|
+
super(source, `could not be imported: ${reason}`, "plugin-import");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The module loaded but does not meet the contract: shape, version, namespace,
|
|
140
|
+
// category, or a rule's declaration.
|
|
141
|
+
export class PluginContractError extends PluginLoadError {
|
|
142
|
+
constructor(source: string, reason: string) {
|
|
143
|
+
super(source, reason, "plugin-contract");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Two plugins claim one namespace or one new category.
|
|
148
|
+
export class PluginConflictError extends PluginLoadError {
|
|
149
|
+
readonly other: string;
|
|
150
|
+
|
|
151
|
+
constructor(source: string, other: string, reason: string) {
|
|
152
|
+
super(source, reason, "plugin-conflict");
|
|
153
|
+
this.other = other;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A dependency the project declares, or a config lists, that is not installed.
|
|
158
|
+
export class PluginNotInstalledError extends PluginLoadError {
|
|
159
|
+
readonly root: string;
|
|
160
|
+
|
|
161
|
+
constructor(source: string, root: string) {
|
|
162
|
+
super(
|
|
163
|
+
source,
|
|
164
|
+
`is declared or listed but not installed; looked from ${root} upward. Install it, or remove it`,
|
|
165
|
+
"plugin-not-installed",
|
|
166
|
+
);
|
|
167
|
+
this.root = root;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A file the loader cannot read in this runtime, such as TypeScript on a
|
|
172
|
+
// runtime that cannot strip types.
|
|
173
|
+
export class PluginSourceError extends PluginLoadError {
|
|
174
|
+
constructor(source: string, reason: string) {
|
|
175
|
+
super(source, reason, "plugin-source");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- plugins: a loaded plugin misbehaved while running --------------------
|
|
180
|
+
|
|
181
|
+
// A fixer threw or returned something that is not a list of valid edits.
|
|
182
|
+
// Unlike a check, this stops the run, because carrying on after a corrupt edit
|
|
183
|
+
// is worse than stopping.
|
|
184
|
+
export class PluginFixerError extends ProseGatesError {
|
|
185
|
+
readonly rule: string;
|
|
186
|
+
|
|
187
|
+
constructor(rule: string, reason: string) {
|
|
188
|
+
super("plugin-fixer", `plugin rule ${rule} ${reason}`);
|
|
189
|
+
this.rule = rule;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/fix/engine.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// source they were computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
6
|
import { buildDocModel, parse } from "../model.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { BUILTIN, type Registry, ruleContext, TEXT_HELPERS } from "../rules/registry.ts";
|
|
8
8
|
import type { Edit } from "../rules/types.ts";
|
|
9
9
|
import { verify } from "./verify.ts";
|
|
10
10
|
|
|
@@ -27,7 +27,17 @@ const overlaps = (edits: Edit[]): boolean => {
|
|
|
27
27
|
return sorted.some((e, i) => i > 0 && e.start < (sorted[i - 1] as Edit).end);
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Offsets are whole numbers inside the source, in order, and the replacement
|
|
31
|
+
// is text. Anything else is a malformed edit from a fixer that miscounted.
|
|
32
|
+
const wellFormed = (e: Edit, length: number): boolean =>
|
|
33
|
+
Number.isInteger(e.start) &&
|
|
34
|
+
Number.isInteger(e.end) &&
|
|
35
|
+
e.start >= 0 &&
|
|
36
|
+
e.start <= e.end &&
|
|
37
|
+
e.end <= length &&
|
|
38
|
+
typeof e.text === "string";
|
|
39
|
+
|
|
40
|
+
export const fixMarkdownReport = async (src: string, registry: Registry = BUILTIN): Promise<FixReport> => {
|
|
31
41
|
const applied: Edit[] = [];
|
|
32
42
|
const refused: Edit[] = [];
|
|
33
43
|
// An edit refused once stays refused while its source slice is unchanged.
|
|
@@ -35,8 +45,8 @@ export const fixMarkdownReport = (src: string): FixReport => {
|
|
|
35
45
|
let current = src;
|
|
36
46
|
|
|
37
47
|
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
38
|
-
const
|
|
39
|
-
const before = { src: current, tree: doc.tree };
|
|
48
|
+
const ctx = ruleContext(buildDocModel(current), registry);
|
|
49
|
+
const before = { src: current, tree: ctx.doc.tree };
|
|
40
50
|
const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
41
51
|
|
|
42
52
|
const attempt = (edits: Edit[]): boolean => {
|
|
@@ -51,10 +61,21 @@ export const fixMarkdownReport = (src: string): FixReport => {
|
|
|
51
61
|
};
|
|
52
62
|
|
|
53
63
|
let progressed = false;
|
|
54
|
-
for (const rule of
|
|
55
|
-
const
|
|
56
|
-
(
|
|
57
|
-
|
|
64
|
+
for (const rule of registry.fixOrder) {
|
|
65
|
+
const proposed =
|
|
66
|
+
(await rule.fix?.({ ...ctx, options: registry.options.get(rule.id) ?? {}, helpers: TEXT_HELPERS })) ?? [];
|
|
67
|
+
// An edit that points outside the source cannot be proven, so it is
|
|
68
|
+
// refused before it is ever spliced. A built-in never proposes one.
|
|
69
|
+
const sound = proposed.filter((e) => {
|
|
70
|
+
if (wellFormed(e, current.length)) return true;
|
|
71
|
+
const key = `${e.rule}\u0000malformed\u0000${e.start}:${e.end}`;
|
|
72
|
+
if (!refusedKeys.has(key)) {
|
|
73
|
+
refusedKeys.add(key);
|
|
74
|
+
refused.push(e);
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
78
|
+
const edits = sound.filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
|
|
58
79
|
if (edits.length === 0) continue;
|
|
59
80
|
|
|
60
81
|
// Whitespace and glyph edits leave the tree's shape alone, so a rule's
|
|
@@ -81,4 +102,5 @@ export const fixMarkdownReport = (src: string): FixReport => {
|
|
|
81
102
|
throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
|
|
82
103
|
};
|
|
83
104
|
|
|
84
|
-
export const fixMarkdown = (src: string): string =>
|
|
105
|
+
export const fixMarkdown = async (src: string, registry: Registry = BUILTIN): Promise<string> =>
|
|
106
|
+
(await fixMarkdownReport(src, registry)).output;
|
package/src/fix/promote.ts
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// markdown list. Item text is sliced from the source, never re-serialised,
|
|
4
4
|
// so links, emphasis, escapes and code spans travel unchanged.
|
|
5
5
|
|
|
6
|
-
import type { ParagraphView
|
|
6
|
+
import type { ParagraphView } from "../model.ts";
|
|
7
7
|
import type { Edit, RuleId } from "../rules/types.ts";
|
|
8
|
-
import { collapse } from "
|
|
8
|
+
import { collapse, type Range, spansBetween } from "../text.ts";
|
|
9
9
|
|
|
10
10
|
export interface PromotedItem {
|
|
11
11
|
text: string;
|
|
@@ -46,6 +46,18 @@ export const cleanItem = (raw: string, { last }: { last: boolean }): string => {
|
|
|
46
46
|
return s;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
// A run of source spans as cleaned item text, ready to promote. Only the
|
|
50
|
+
// last item may shed a joining conjunction or a trailing full stop, so the
|
|
51
|
+
// spans must arrive in document order.
|
|
52
|
+
export const itemsOf = (src: string, spans: Range[]): string[] =>
|
|
53
|
+
spans.map((span, i) => cleanItem(src.slice(span[0], span[1]), { last: i + 1 === spans.length }));
|
|
54
|
+
|
|
55
|
+
// The spans between a run of cut points, as cleaned item text. This is the
|
|
56
|
+
// shared body of every hidden-list fixer: the rule decides where the cuts
|
|
57
|
+
// are, and this decides what each item says.
|
|
58
|
+
export const itemsBetween = (src: string, cuts: Range[], end: number): string[] =>
|
|
59
|
+
itemsOf(src, spansBetween(cuts, end));
|
|
60
|
+
|
|
49
61
|
export const cleanLeadIn = (raw: string): string | null => {
|
|
50
62
|
const s = collapse(raw)
|
|
51
63
|
.trim()
|