@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.
Files changed (52) hide show
  1. package/README.md +45 -7
  2. package/dist/bin.js +35469 -6876
  3. package/dist/index.js +35566 -6939
  4. package/dist/testing.js +38824 -0
  5. package/examples/plugin-skills/README.md +60 -0
  6. package/examples/plugin-skills/index.mjs +14 -0
  7. package/examples/plugin-skills/package.json +12 -0
  8. package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
  9. package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
  10. package/examples/plugin-skills-typescript/README.md +42 -0
  11. package/examples/plugin-skills-typescript/index.ts +21 -0
  12. package/examples/plugin-skills-typescript/package.json +12 -0
  13. package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
  14. package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
  15. package/examples/plugin-skills-typescript/tsconfig.json +14 -0
  16. package/package.json +12 -3
  17. package/src/bin.ts +2 -2
  18. package/src/check.ts +53 -13
  19. package/src/cli.ts +243 -44
  20. package/src/cohesion.ts +70 -0
  21. package/src/concepts.ts +209 -0
  22. package/src/config.ts +119 -0
  23. package/src/errors.ts +212 -0
  24. package/src/fix/engine.ts +90 -50
  25. package/src/fix/verify.ts +60 -19
  26. package/src/index.ts +51 -2
  27. package/src/interpunct.ts +7 -7
  28. package/src/language.ts +107 -0
  29. package/src/model.ts +271 -68
  30. package/src/plugins/contract.ts +41 -0
  31. package/src/plugins/discover.ts +146 -0
  32. package/src/plugins/index.ts +136 -0
  33. package/src/plugins/load.ts +274 -0
  34. package/src/plugins/runtime.ts +15 -0
  35. package/src/project.ts +84 -0
  36. package/src/rules/index.ts +34 -20
  37. package/src/rules/list-comma-labelled-run.ts +117 -0
  38. package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +56 -45
  39. package/src/rules/list-interpunct-joined-run.ts +71 -0
  40. package/src/rules/{pg003-semicolon-list.ts → list-semicolon-delimited-run.ts} +2 -2
  41. package/src/rules/{pg009-stacked-runs.ts → list-stacked-interpunct-runs.ts} +3 -3
  42. package/src/rules/paragraph-sentence-budget-exceeded.ts +64 -0
  43. package/src/rules/{pg004-em-dash.ts → punctuation-em-dash-in-prose.ts} +3 -3
  44. package/src/rules/{pg005-interpunct.ts → punctuation-interpunct-in-prose.ts} +6 -6
  45. package/src/rules/registry.ts +143 -0
  46. package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +45 -18
  47. package/src/rules/sentence-word-budget-exceeded.ts +57 -0
  48. package/src/rules/types.ts +71 -21
  49. package/src/testing.ts +60 -0
  50. package/src/rules/pg002-length.ts +0 -45
  51. package/src/rules/pg006-interpunct-run.ts +0 -68
  52. package/src/rules/pg008-labelled-run.ts +0 -101
@@ -0,0 +1,136 @@
1
+ // Assembling plugins: discover the sources, load each one, and merge their
2
+ // rules and categories into the set the registry is built from. Namespaces and
3
+ // new category words are global, so two plugins cannot share either.
4
+ //
5
+ // How a failed source is treated is the mode. Strict throws the first failure,
6
+ // which is the default. Lenient leaves the failed source out and returns it, so
7
+ // the caller can say so. Collect does the same and is what validation uses, so
8
+ // one run reports every failure. Off loads nothing.
9
+
10
+ import type { Config } from "../config.ts";
11
+ import { PluginConflictError, PluginLoadError } from "../errors.ts";
12
+ import { BUILTIN_CATEGORIES, type LoadedSource } from "../rules/registry.ts";
13
+ import type { Rule } from "../rules/types.ts";
14
+ import type { Plugin } from "./contract.ts";
15
+ import { discoverSources, LOCAL_RULES, type PluginSource, type SourceKind } from "./discover.ts";
16
+ import { loadLocal, loadPackage, toRule } from "./load.ts";
17
+
18
+ export type PluginMode = "off" | "strict" | "lenient" | "collect";
19
+
20
+ export interface PluginFailure {
21
+ source: string;
22
+ kind: SourceKind;
23
+ error: PluginLoadError;
24
+ }
25
+
26
+ export interface PluginSet {
27
+ rules: Rule[];
28
+ categories: Map<string, string>;
29
+ sources: LoadedSource[];
30
+ // Specs found but not loaded, because the run asked for built-ins only.
31
+ skipped: string[];
32
+ // Sources left out because they failed. Always empty in strict mode, which throws.
33
+ failures: PluginFailure[];
34
+ }
35
+
36
+ type Settle = (error: PluginLoadError, kind: SourceKind) => void;
37
+
38
+ interface LoadedPlugin {
39
+ plugin: Plugin;
40
+ kind: SourceKind;
41
+ spec: string;
42
+ }
43
+
44
+ // Strict stops at the first failure. The others record it and carry on.
45
+ const settler =
46
+ (mode: PluginMode, failures: PluginFailure[]): Settle =>
47
+ (error, kind) => {
48
+ if (mode === "strict") throw error;
49
+ if (!failures.some((f) => f.source === error.source)) failures.push({ source: error.source, kind, error });
50
+ };
51
+
52
+ // Local files load together as one plugin, then each package loads on its own.
53
+ const importSources = async (found: PluginSource[], settle: Settle): Promise<LoadedPlugin[]> => {
54
+ const loaded: LoadedPlugin[] = [];
55
+ const locals = found.filter((s) => s.kind === "local");
56
+ if (locals.length > 0) {
57
+ const local = await loadLocal(locals);
58
+ for (const error of local.failures) settle(error, "local");
59
+ if (local.plugin) loaded.push({ plugin: local.plugin, kind: "local", spec: LOCAL_RULES });
60
+ }
61
+ for (const source of found.filter((s) => s.kind !== "local")) {
62
+ try {
63
+ loaded.push({ plugin: await loadPackage(source, new Set(["local"])), kind: source.kind, spec: source.spec });
64
+ } catch (err) {
65
+ if (!(err instanceof PluginLoadError)) throw err;
66
+ settle(err, source.kind);
67
+ }
68
+ }
69
+ return loaded;
70
+ };
71
+
72
+ interface Claims {
73
+ namespaces: Map<string, string>;
74
+ declaredBy: Map<string, string>;
75
+ }
76
+
77
+ // The earlier claimant and the reason, when a plugin reuses a namespace or a
78
+ // category word another plugin already holds. Null when it claims nothing taken.
79
+ const findClash = (plugin: Plugin, claims: Claims): { other: string; reason: string } | null => {
80
+ const { namespace } = plugin.meta;
81
+ const taken = claims.namespaces.get(namespace);
82
+ if (taken) return { other: taken, reason: `namespace "${namespace}" is already used by ${taken}` };
83
+ const clash = Object.keys(plugin.meta.categories ?? {}).find((w) => claims.declaredBy.has(w));
84
+ if (!clash) return null;
85
+ const other = claims.declaredBy.get(clash) as string;
86
+ return { other, reason: `category "${clash}" is already declared by ${other}` };
87
+ };
88
+
89
+ // Namespaces and new categories are global. The first plugin to claim one
90
+ // keeps it, and a later claimant fails, naming both.
91
+ const acceptPlugins = (loaded: LoadedPlugin[], set: PluginSet, settle: Settle): LoadedPlugin[] => {
92
+ const claims: Claims = { namespaces: new Map(), declaredBy: new Map() };
93
+ const accepted: LoadedPlugin[] = [];
94
+ for (const entry of loaded) {
95
+ const { plugin, spec, kind } = entry;
96
+ const clash = findClash(plugin, claims);
97
+ if (clash) {
98
+ settle(new PluginConflictError(spec, clash.other, clash.reason), kind);
99
+ continue;
100
+ }
101
+ claims.namespaces.set(plugin.meta.namespace, spec);
102
+ for (const [word, description] of Object.entries(plugin.meta.categories ?? {})) {
103
+ claims.declaredBy.set(word, spec);
104
+ set.categories.set(word, description);
105
+ }
106
+ accepted.push(entry);
107
+ }
108
+ return accepted;
109
+ };
110
+
111
+ // Each accepted plugin's rules join the set, and the source records which ids it gave.
112
+ const addRules = (set: PluginSet, accepted: LoadedPlugin[]): void => {
113
+ for (const { plugin, kind, spec } of accepted) {
114
+ const rules = Object.entries(plugin.rules).map(([key, rule]) => toRule(plugin.meta.namespace, key, rule));
115
+ set.rules.push(...rules);
116
+ set.sources.push({ kind, spec, namespace: plugin.meta.namespace, rules: rules.map((r) => r.id) });
117
+ }
118
+ };
119
+
120
+ export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
121
+ const { sources: found, failures: unresolved } = await discoverSources(root, config);
122
+ const set: PluginSet = {
123
+ rules: [],
124
+ categories: new Map(BUILTIN_CATEGORIES),
125
+ sources: [],
126
+ skipped: [],
127
+ failures: [],
128
+ };
129
+ if (mode === "off") return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
130
+
131
+ const settle = settler(mode, set.failures);
132
+ for (const error of unresolved) settle(error, "package");
133
+ const loaded = await importSources(found, settle);
134
+ addRules(set, acceptPlugins(loaded, set, settle));
135
+ return set;
136
+ };
@@ -0,0 +1,274 @@
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
+ // A rule's category is one word, built in or declared by this plugin, and its
53
+ // key starts with it.
54
+ const checkRuleCategory = (spec: string, key: string, category: unknown, own: ReadonlySet<string>): void => {
55
+ const where = `rule "${key}"`;
56
+ if (typeof category !== "string" || !CATEGORY_WORD.test(category)) {
57
+ throw new PluginContractError(spec, `${where} needs a category of one lower-case word`);
58
+ }
59
+ if (!BUILTIN_CATEGORIES.has(category) && !own.has(category)) {
60
+ throw new PluginContractError(
61
+ spec,
62
+ `${where} uses the category "${category}", which this plugin does not declare in meta.categories`,
63
+ );
64
+ }
65
+ if (!key.startsWith(`${category}-`)) {
66
+ throw new PluginContractError(spec, `${where} must start with its category, as "${category}-short-name"`);
67
+ }
68
+ };
69
+
70
+ // A rule's summary, functions and option types, checked after its category.
71
+ const checkRuleBody = (spec: string, key: string, raw: Record<string, unknown>): void => {
72
+ const where = `rule "${key}"`;
73
+ const { summary, options, check, fix } = raw;
74
+ if (typeof summary !== "string" || summary === "") throw new PluginContractError(spec, `${where} needs a summary`);
75
+ if (typeof check !== "function") throw new PluginContractError(spec, `${where} needs a check function`);
76
+ if (fix !== undefined && typeof fix !== "function") {
77
+ throw new PluginContractError(spec, `${where} fix must be a function`);
78
+ }
79
+ if (options === undefined) return;
80
+ const valid = isRecord(options) && Object.values(options).every((t) => OPTION_TYPES.has(t as OptionType));
81
+ if (!valid) {
82
+ throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
83
+ }
84
+ };
85
+
86
+ // Checks one rule's declaration and returns it, so a bad rule fails at load
87
+ // rather than the first time it runs.
88
+ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
89
+ const where = `rule "${key}"`;
90
+ if (!isRecord(raw)) throw new PluginContractError(spec, `${where} must be an object`);
91
+ if (!RULE_KEY.test(key)) {
92
+ throw new PluginContractError(spec, `${where} is not a valid key; use lower-case words joined by hyphens`);
93
+ }
94
+ checkRuleCategory(spec, key, raw.category, own);
95
+ checkRuleBody(spec, key, raw);
96
+ return raw as unknown as PluginRule;
97
+ };
98
+
99
+ const checkCategories = (spec: string, raw: unknown): Record<string, string> => {
100
+ if (raw === undefined) return {};
101
+ if (!isRecord(raw)) throw new PluginContractError(spec, "categories must map a word to a description");
102
+ for (const [word, description] of Object.entries(raw)) {
103
+ if (!CATEGORY_WORD.test(word)) throw new PluginContractError(spec, `category "${word}" is not one lower-case word`);
104
+ if (BUILTIN_CATEGORIES.has(word)) {
105
+ throw new PluginContractError(spec, `category "${word}" is built in and cannot be declared`);
106
+ }
107
+ if (typeof description !== "string" || description === "") {
108
+ throw new PluginContractError(spec, `category "${word}" needs a one-line description`);
109
+ }
110
+ }
111
+ return raw as Record<string, string>;
112
+ };
113
+
114
+ // A plugin object is checked here, apart from how it was imported, so a test
115
+ // can hold the same plugin to the same contract the loader does.
116
+ export const parsePlugin = (spec: string, raw: unknown, reserved: ReadonlySet<string>): Plugin => {
117
+ if (!isRecord(raw) || !isRecord(raw.meta) || !isRecord(raw.rules)) {
118
+ throw new PluginContractError(spec, "the default export must be { meta, rules }");
119
+ }
120
+ const { name, namespace, apiVersion } = raw.meta;
121
+ if (apiVersion !== API_VERSION) {
122
+ throw new PluginContractError(
123
+ spec,
124
+ `targets plugin apiVersion ${JSON.stringify(apiVersion)}, but this prose-gates supports ${API_VERSION}`,
125
+ );
126
+ }
127
+ if (typeof name !== "string" || name === "") throw new PluginContractError(spec, "meta.name is required");
128
+ if (typeof namespace !== "string" || !NAMESPACE.test(namespace)) {
129
+ throw new PluginContractError(spec, "meta.namespace must be lower-case words joined by hyphens");
130
+ }
131
+ if (reserved.has(namespace)) throw new PluginContractError(spec, `meta.namespace "${namespace}" is reserved`);
132
+ const categories = checkCategories(spec, raw.meta.categories);
133
+ const own = new Set(Object.keys(categories));
134
+ const rules = Object.fromEntries(
135
+ Object.entries(raw.rules).map(([key, rule]) => [key, checkRule(spec, key, rule, own)]),
136
+ );
137
+ return { meta: { name, namespace, apiVersion, categories }, rules };
138
+ };
139
+
140
+ // A package: the default export is a Plugin.
141
+ export const loadPackage = async (source: PluginSource, reserved: ReadonlySet<string>): Promise<Plugin> =>
142
+ parsePlugin(source.spec, (await importModule(source)).default, reserved);
143
+
144
+ export interface LocalLoad {
145
+ // The local files that loaded, as one plugin. Null when none did.
146
+ plugin: Plugin | null;
147
+ failures: PluginLoadError[];
148
+ }
149
+
150
+ interface LocalCategories {
151
+ // Each word the local files declare, with its description.
152
+ categories: Record<string, string>;
153
+ // The file that first declared each word.
154
+ owner: Record<string, string>;
155
+ }
156
+
157
+ interface LocalFile {
158
+ key: string;
159
+ source: PluginSource;
160
+ raw: unknown;
161
+ }
162
+
163
+ // Local files may each declare the same word, but only with the same description.
164
+ const mergeCategories = (source: PluginSource, declared: Record<string, string>, merged: LocalCategories): void => {
165
+ const { categories, owner } = merged;
166
+ for (const [word, description] of Object.entries(declared)) {
167
+ if (categories[word] !== undefined && categories[word] !== description) {
168
+ throw new PluginConflictError(
169
+ source.spec,
170
+ owner[word] as string,
171
+ `declares category "${word}" with a different description than another local file`,
172
+ );
173
+ }
174
+ categories[word] = description;
175
+ owner[word] = source.spec;
176
+ }
177
+ };
178
+
179
+ // Imports one local file and merges the categories it declares. Its rule is
180
+ // checked later, once every file's categories are known.
181
+ const importLocalFile = async (
182
+ source: PluginSource,
183
+ merged: LocalCategories,
184
+ runtime?: RuntimeInfo,
185
+ ): Promise<LocalFile> => {
186
+ const mod = await importModule(source, runtime);
187
+ if (mod.default === undefined) {
188
+ throw new PluginContractError(source.spec, "has no default export; export one rule");
189
+ }
190
+ mergeCategories(source, checkCategories(source.spec, mod.categories), merged);
191
+ return { key: basename(source.path, extname(source.path)), source, raw: mod.default };
192
+ };
193
+
194
+ // Local files: each default-exports one rule, keyed by its file name. They load
195
+ // as one plugin under the fixed namespace `local`, and a file may export
196
+ // `categories` to declare the word its rule uses. Each file fails on its own,
197
+ // so one bad file does not hide the others, and the caller gets every failure.
198
+ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo): Promise<LocalLoad> => {
199
+ const failures: PluginLoadError[] = [];
200
+ const fail = (err: unknown): void => {
201
+ if (!(err instanceof PluginLoadError)) throw err;
202
+ failures.push(err);
203
+ };
204
+ const merged: LocalCategories = { categories: {}, owner: {} };
205
+ const loaded: LocalFile[] = [];
206
+ for (const source of sources) {
207
+ try {
208
+ loaded.push(await importLocalFile(source, merged, runtime));
209
+ } catch (err) {
210
+ fail(err);
211
+ }
212
+ }
213
+ const { categories } = merged;
214
+ const own = new Set(Object.keys(categories));
215
+ const rules: Record<string, PluginRule> = {};
216
+ for (const { key, source, raw } of loaded) {
217
+ try {
218
+ rules[key] = checkRule(source.spec, key, raw, own);
219
+ } catch (err) {
220
+ fail(err);
221
+ }
222
+ }
223
+ const plugin: Plugin | null =
224
+ Object.keys(rules).length === 0 && failures.length > 0
225
+ ? null
226
+ : { meta: { name: "local", namespace: "local", apiVersion: API_VERSION, categories }, rules };
227
+ return { plugin, failures };
228
+ };
229
+
230
+ const validFinding = (f: unknown): f is { line: number; message: string } =>
231
+ isRecord(f) && Number.isInteger(f.line) && (f.line as number) >= 1 && typeof f.message === "string";
232
+
233
+ const EXPECTATIONS = new Set(["same-tree", "same-shape", "same-frontmatter-data", "replace-paragraph"]);
234
+
235
+ // A plugin rule becomes a Rule whose id is namespace/key. The plugin function may
236
+ // be synchronous or return a promise, and the wrapper awaits it. Its check cannot
237
+ // take the run down: a throw, a rejection or a malformed finding becomes a finding for that
238
+ // rule, and the other rules carry on. Its fixer is held to the same proof as a
239
+ // built-in, so a fixer that throws or returns nonsense stops the run loudly.
240
+ export const toRule = (namespace: string, key: string, rule: PluginRule): Rule => {
241
+ const id = `${namespace}/${key}` as RuleId;
242
+ const check: Rule["check"] = async (ctx) => {
243
+ const report = (why: string): Finding[] => [{ file: ctx.file, line: 1, rule: id, message: `plugin rule ${why}` }];
244
+ let found: unknown;
245
+ try {
246
+ found = await rule.check(ctx);
247
+ } catch (err) {
248
+ return report(`threw: ${message(err)}`);
249
+ }
250
+ if (!Array.isArray(found) || !found.every(validFinding)) {
251
+ return report("returned something other than a list of { line, message } findings");
252
+ }
253
+ return found.map((f) => ({ file: ctx.file, line: f.line, rule: id, message: f.message }));
254
+ };
255
+ const fix: Rule["fix"] = rule.fix
256
+ ? async (ctx) => {
257
+ let edits: unknown;
258
+ try {
259
+ edits = await rule.fix?.(ctx);
260
+ } catch (err) {
261
+ throw new PluginFixerError(id, `fixer threw: ${message(err)}`);
262
+ }
263
+ if (!Array.isArray(edits))
264
+ throw new PluginFixerError(id, "fixer returned something other than a list of edits");
265
+ return edits.map((e: Edit) => {
266
+ if (!isRecord(e) || !isRecord(e.expect) || !EXPECTATIONS.has(e.expect.kind as string)) {
267
+ throw new PluginFixerError(id, "fixer returned an edit with no valid expectation");
268
+ }
269
+ return { ...e, rule: id };
270
+ });
271
+ }
272
+ : undefined;
273
+ return { id, category: rule.category, summary: rule.summary, options: rule.options, check, ...(fix ? { fix } : {}) };
274
+ };
@@ -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,84 @@
1
+ // Setting up a run: find the project root, read its config and its concept
2
+ // file, load the plugins, 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 { loadConcepts } from "./concepts.ts";
9
+ import { CONFIG_FILES, findConfig, loadConfig, NO_CONFIG } from "./config.ts";
10
+ import { ConfigNotFoundError } from "./errors.ts";
11
+ import { loadPlugins, type PluginFailure, type PluginMode } from "./plugins/index.ts";
12
+ import { FIX_ORDER, RULES } from "./rules/index.ts";
13
+ import { buildRegistry, type Registry } from "./rules/registry.ts";
14
+
15
+ export const LOCAL_DIR = ".prose-gates";
16
+
17
+ const exists = (path: string): Promise<boolean> =>
18
+ access(path).then(
19
+ () => true,
20
+ () => false,
21
+ );
22
+
23
+ // The nearest ancestor of the working directory that marks a project: a
24
+ // package.json, a config file or a .prose-gates directory. With none, the
25
+ // working directory itself, so a loose folder of notes still works.
26
+ export const findProjectRoot = async (cwd: string): Promise<string> => {
27
+ const markers = ["package.json", LOCAL_DIR, ...CONFIG_FILES];
28
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
29
+ for (const marker of markers) if (await exists(join(dir, marker))) return dir;
30
+ if (dirname(dir) === dir) return resolve(cwd);
31
+ }
32
+ };
33
+
34
+ export interface SetupOptions {
35
+ cwd: string;
36
+ configPath?: string;
37
+ // Built-ins only. Anything discovered is reported as skipped.
38
+ noPlugins?: boolean;
39
+ // Skip a plugin that fails to load and say so, instead of stopping the run.
40
+ lenient?: boolean;
41
+ // Load every plugin and report every failure, without stopping at the first.
42
+ collect?: boolean;
43
+ }
44
+
45
+ export interface Setup {
46
+ registry: Registry;
47
+ skipped: string[];
48
+ // Plugins left out because they failed. Empty unless the run was lenient or collecting.
49
+ failures: PluginFailure[];
50
+ }
51
+
52
+ export const setUp = async ({
53
+ cwd,
54
+ configPath,
55
+ noPlugins = false,
56
+ lenient = false,
57
+ collect = false,
58
+ }: SetupOptions): Promise<Setup> => {
59
+ const root = await findProjectRoot(cwd);
60
+ let file: string | null;
61
+ if (configPath !== undefined) {
62
+ file = resolve(cwd, configPath);
63
+ if (!(await exists(file))) throw new ConfigNotFoundError(configPath);
64
+ } else {
65
+ file = await findConfig(root);
66
+ }
67
+ const config = file ? await loadConfig(file) : NO_CONFIG;
68
+ let mode: PluginMode = "strict";
69
+ if (noPlugins || config.plugins === false) mode = "off";
70
+ else if (collect) mode = "collect";
71
+ else if (lenient || config.pluginLoading === "lenient") mode = "lenient";
72
+ const plugins = await loadPlugins(root, config, mode);
73
+ const concepts = await loadConcepts(root);
74
+ const registry = buildRegistry({
75
+ rules: [...RULES, ...plugins.rules],
76
+ fixOrder: [...FIX_ORDER, ...plugins.rules.filter((r) => r.fix)],
77
+ categories: plugins.categories,
78
+ sources: plugins.sources,
79
+ config,
80
+ ignoreUnknownPluginIds: mode === "lenient" || mode === "collect",
81
+ concepts,
82
+ });
83
+ return { registry, skipped: plugins.skipped, failures: plugins.failures };
84
+ };
@@ -2,27 +2,41 @@
2
2
  // a Rule with its category, register it here, and add its section and
3
3
  // examples to RULES.md, which tests/rules-doc.test.ts holds to the code.
4
4
 
5
- import { interpunctRuns } from "../interpunct.ts";
6
- import type { DocModel } from "../model.ts";
7
- import { pg001 } from "./pg001-wrap.ts";
8
- import { pg002 } from "./pg002-length.ts";
9
- import { pg003 } from "./pg003-semicolon-list.ts";
10
- import { pg004 } from "./pg004-em-dash.ts";
11
- import { pg005 } from "./pg005-interpunct.ts";
12
- import { pg006 } from "./pg006-interpunct-run.ts";
13
- import { pg007 } from "./pg007-inline-enum.ts";
14
- import { pg008 } from "./pg008-labelled-run.ts";
15
- import { pg009 } from "./pg009-stacked-runs.ts";
16
- import type { Rule, RuleContext } from "./types.ts";
5
+ import { listCommaLabelledRun } from "./list-comma-labelled-run.ts";
6
+ import { listInlineEnumerationMarkers } from "./list-inline-enumeration-markers.ts";
7
+ import { listInterpunctJoinedRun } from "./list-interpunct-joined-run.ts";
8
+ import { listSemicolonDelimitedRun } from "./list-semicolon-delimited-run.ts";
9
+ import { listStackedInterpunctRuns } from "./list-stacked-interpunct-runs.ts";
10
+ import { paragraphSentenceBudgetExceeded } from "./paragraph-sentence-budget-exceeded.ts";
11
+ import { punctuationEmDashInProse } from "./punctuation-em-dash-in-prose.ts";
12
+ import { punctuationInterpunctInProse } from "./punctuation-interpunct-in-prose.ts";
13
+ import { sentenceOnePerLine } from "./sentence-one-per-line.ts";
14
+ import { sentenceWordBudgetExceeded } from "./sentence-word-budget-exceeded.ts";
15
+ import type { Rule } from "./types.ts";
17
16
 
18
- export const RULES: Rule[] = [pg001, pg002, pg003, pg004, pg005, pg006, pg007, pg008, pg009];
19
-
20
- // The data every rule shares, derived once per document. Interpunct runs are
21
- // read by four rules, so computing them here keeps the fix engine from
22
- // rebuilding them once per rule on every pass of the fixpoint loop.
23
- export const ruleContext = (doc: DocModel): RuleContext => ({ doc, runs: interpunctRuns(doc) });
17
+ export const RULES: Rule[] = [
18
+ sentenceOnePerLine,
19
+ sentenceWordBudgetExceeded,
20
+ paragraphSentenceBudgetExceeded,
21
+ listSemicolonDelimitedRun,
22
+ punctuationEmDashInProse,
23
+ punctuationInterpunctInProse,
24
+ listInterpunctJoinedRun,
25
+ listInlineEnumerationMarkers,
26
+ listCommaLabelledRun,
27
+ listStackedInterpunctRuns,
28
+ ];
24
29
 
25
30
  // The order fixers run in. Structure first: a promotion needs the separators
26
- // a glyph swap would erase, so PG009 and the list rules run before PG005
31
+ // a glyph swap would erase, so list-stacked-interpunct-runs and the list rules run before punctuation-interpunct-in-prose
27
32
  // turns interpuncts into commas. Reflow runs last, over the settled blocks.
28
- export const FIX_ORDER: Rule[] = [pg009, pg007, pg008, pg006, pg003, pg005, pg004, pg001];
33
+ export const FIX_ORDER: Rule[] = [
34
+ listStackedInterpunctRuns,
35
+ listInlineEnumerationMarkers,
36
+ listCommaLabelledRun,
37
+ listInterpunctJoinedRun,
38
+ listSemicolonDelimitedRun,
39
+ punctuationInterpunctInProse,
40
+ punctuationEmDashInProse,
41
+ sentenceOnePerLine,
42
+ ];