@jpeakai/prose-gates 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +36 -4
  2. package/dist/bin.js +8400 -455
  3. package/dist/index.js +8428 -456
  4. package/dist/testing.js +18321 -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 +10 -3
  17. package/src/bin.ts +2 -2
  18. package/src/check.ts +40 -12
  19. package/src/cli.ts +197 -28
  20. package/src/config.ts +119 -0
  21. package/src/errors.ts +191 -0
  22. package/src/fix/engine.ts +30 -8
  23. package/src/fix/verify.ts +29 -0
  24. package/src/index.ts +38 -2
  25. package/src/interpunct.ts +7 -7
  26. package/src/model.ts +130 -1
  27. package/src/plugins/contract.ts +41 -0
  28. package/src/plugins/discover.ts +140 -0
  29. package/src/plugins/index.ts +103 -0
  30. package/src/plugins/load.ts +234 -0
  31. package/src/plugins/runtime.ts +15 -0
  32. package/src/project.ts +81 -0
  33. package/src/rules/index.ts +32 -20
  34. package/src/rules/{pg008-labelled-run.ts → list-comma-labelled-run.ts} +3 -3
  35. package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +2 -2
  36. package/src/rules/{pg006-interpunct-run.ts → list-interpunct-joined-run.ts} +2 -2
  37. package/src/rules/{pg003-semicolon-list.ts → list-semicolon-delimited-run.ts} +2 -2
  38. package/src/rules/{pg009-stacked-runs.ts → list-stacked-interpunct-runs.ts} +3 -3
  39. package/src/rules/{pg004-em-dash.ts → punctuation-em-dash-in-prose.ts} +3 -3
  40. package/src/rules/{pg005-interpunct.ts → punctuation-interpunct-in-prose.ts} +6 -6
  41. package/src/rules/registry.ts +133 -0
  42. package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +4 -4
  43. package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +3 -2
  44. package/src/rules/types.ts +62 -20
  45. package/src/testing.ts +60 -0
@@ -0,0 +1,133 @@
1
+ // The registry: the rules a run actually uses. It starts from the built-in
2
+ // catalogue, adds whatever plugins loaded, then applies the config, so a rule
3
+ // that is off loses its check and its fixer together. Nothing else reads the
4
+ // catalogue directly, which is what lets a plugin rule behave as a built-in.
5
+
6
+ import type { Config } from "../config.ts";
7
+ import { RuleOptionError, UnknownRuleError } from "../errors.ts";
8
+ import { interpunctRuns, isFlat } from "../interpunct.ts";
9
+ import type { DocModel } from "../model.ts";
10
+ import { sentences, words } from "../text.ts";
11
+ import { FIX_ORDER, RULES } from "./index.ts";
12
+ import { lengthMessage } from "./sentence-word-budget-exceeded.ts";
13
+ import {
14
+ isPluginRuleId,
15
+ type OptionType,
16
+ RULE,
17
+ type Rule,
18
+ type RuleContext,
19
+ type RuleId,
20
+ type RuleOptions,
21
+ } from "./types.ts";
22
+
23
+ export const BUILTIN_CATEGORIES: ReadonlyMap<string, string> = new Map([
24
+ ["sentence", "How a sentence is laid out and how long it runs"],
25
+ ["list", "A list hidden in running prose, promoted to a real markdown list"],
26
+ ["punctuation", "A glyph that reads as generated text"],
27
+ ]);
28
+
29
+ export const TEXT_HELPERS = { words, sentences, lengthMessage } as const;
30
+
31
+ // Where a group of rules came from, for the line each run prints.
32
+ export interface LoadedSource {
33
+ kind: "local" | "package" | "config";
34
+ spec: string;
35
+ namespace: string;
36
+ rules: RuleId[];
37
+ }
38
+
39
+ export interface Registry {
40
+ rules: Rule[];
41
+ fixOrder: Rule[];
42
+ enabled: ReadonlySet<RuleId>;
43
+ options: ReadonlyMap<RuleId, RuleOptions>;
44
+ categories: ReadonlyMap<string, string>;
45
+ sources: LoadedSource[];
46
+ // Config settings dropped because they name a plugin rule that did not load.
47
+ ignoredSettings: string[];
48
+ }
49
+
50
+ export interface RegistryInput {
51
+ rules: Rule[];
52
+ fixOrder: Rule[];
53
+ categories: ReadonlyMap<string, string>;
54
+ sources?: LoadedSource[];
55
+ config?: Config;
56
+ // A lenient run drops a setting for an unknown plugin rule instead of failing.
57
+ ignoreUnknownPluginIds?: boolean;
58
+ }
59
+
60
+ const typeOf = (value: unknown): OptionType | null => {
61
+ if (typeof value === "number" && Number.isFinite(value)) return "number";
62
+ if (typeof value === "string") return "string";
63
+ if (typeof value === "boolean") return "boolean";
64
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return "string[]";
65
+ return null;
66
+ };
67
+
68
+ const checkOptions = (where: string, rule: Rule, options: RuleOptions): void => {
69
+ const accepted = Object.keys(rule.options ?? {});
70
+ for (const [name, value] of Object.entries(options)) {
71
+ const want = rule.options?.[name];
72
+ if (!want) {
73
+ const list = accepted.length > 0 ? `it accepts ${accepted.join(", ")}` : "it takes no options";
74
+ throw new RuleOptionError(where, rule.id, name, `rule "${rule.id}" has no option "${name}"; ${list}`);
75
+ }
76
+ if (typeOf(value) !== want) {
77
+ throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be a ${want}`);
78
+ }
79
+ }
80
+ if ("maxWords" in options && !((options.maxWords as number) >= 1)) {
81
+ throw new RuleOptionError(where, rule.id, "maxWords", `option "maxWords" of "${rule.id}" must be at least 1`);
82
+ }
83
+ };
84
+
85
+ export const buildRegistry = (input: RegistryInput): Registry => {
86
+ const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
87
+ const off = new Set<string>();
88
+ const options = new Map<RuleId, RuleOptions>();
89
+ const ignored: string[] = [];
90
+ const where = config?.file ?? "config";
91
+ for (const [id, setting] of config?.rules ?? []) {
92
+ const rule = rules.find((r) => r.id === id);
93
+ if (!rule) {
94
+ // A plugin that was skipped cannot have its rules configured, so a lenient
95
+ // run drops the setting and says so. A built-in id is never forgiven.
96
+ if (ignoreUnknownPluginIds && isPluginRuleId(id)) {
97
+ ignored.push(id);
98
+ continue;
99
+ }
100
+ throw new UnknownRuleError(
101
+ where,
102
+ id,
103
+ rules.map((r) => r.id),
104
+ );
105
+ }
106
+ checkOptions(where, rule, setting.options);
107
+ if (setting.severity === "off") off.add(id);
108
+ else options.set(rule.id, setting.options);
109
+ }
110
+ const active = rules.filter((r) => !off.has(r.id));
111
+ return {
112
+ rules: active,
113
+ fixOrder: fixOrder.filter((r) => !off.has(r.id)),
114
+ enabled: new Set(active.map((r) => r.id)),
115
+ options,
116
+ categories,
117
+ sources,
118
+ ignoredSettings: ignored,
119
+ };
120
+ };
121
+
122
+ export const BUILTIN: Registry = buildRegistry({ rules: RULES, fixOrder: FIX_ORDER, categories: BUILTIN_CATEGORIES });
123
+
124
+ // The data every rule shares, derived once per document. Interpunct runs are
125
+ // read by several rules, so computing them here keeps the fix engine from
126
+ // rebuilding them once per rule on every pass of the fixpoint loop. A run is
127
+ // owned by the flat or the stacked list rule, and it counts as reported only
128
+ // while its owner is on.
129
+ export const ruleContext = (doc: DocModel, registry: Registry = BUILTIN): RuleContext => {
130
+ const runs = interpunctRuns(doc);
131
+ const reported = runs.filter((run) => registry.enabled.has(isFlat(run) ? RULE.INTERPUNCT_RUN : RULE.STACKED_RUNS));
132
+ return { doc, runs, reported };
133
+ };
@@ -1,4 +1,4 @@
1
- // PG001: a newline inside a paragraph whose preceding non-space character
1
+ // sentence-one-per-line: a newline inside a paragraph whose preceding non-space character
2
2
  // does not end a sentence is a mid-sentence wrap. The fix reflows the
3
3
  // paragraph to one sentence per line.
4
4
 
@@ -67,9 +67,9 @@ const check: Rule["check"] = ({ doc, file }) =>
67
67
 
68
68
  const fix: Rule["fix"] = ({ doc, runs }) => {
69
69
  const edits: Edit[] = [];
70
- // Interpunct runs stacked on several lines are a nested list PG009 either
70
+ // Interpunct runs stacked on several lines are a nested list list-stacked-interpunct-runs either
71
71
  // promotes or leaves for a human. Joining the lines would destroy the
72
- // structure and hand PG006 a flat run it would merge wrongly.
72
+ // structure and hand list-interpunct-joined-run a flat run it would merge wrongly.
73
73
  const stacked = new Set(paragraphsOf(doc, runs.filter(isStacked)));
74
74
  for (const view of proseParagraphs(doc)) {
75
75
  // A hard break is structure the author chose, so the paragraph is left
@@ -90,7 +90,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
90
90
  return edits;
91
91
  };
92
92
 
93
- export const pg001: Rule = {
93
+ export const sentenceOnePerLine: Rule = {
94
94
  id: RULE.WRAP,
95
95
  category: "sentence",
96
96
  summary: "mid-sentence line wrap (one sentence per line)",
@@ -1,4 +1,4 @@
1
- // PG002: sentences over the word budget are hard to unpack, especially for
1
+ // sentence-word-budget-exceeded: sentences over the word budget are hard to unpack, especially for
2
2
  // ESL readers and translators. Never autofixed: shortening a sentence is a
3
3
  // rewrite, and a rewrite needs discretion. The message estimates the clauses
4
4
  // in the sentence so the rewrite splits it rather than compressing it.
@@ -37,9 +37,10 @@ const check: Rule["check"] = ({ doc, file, maxWords }) =>
37
37
  }));
38
38
 
39
39
  // No fixer: shortening a sentence changes its words, per PRS-0006.
40
- export const pg002: Rule = {
40
+ export const sentenceWordBudgetExceeded: Rule = {
41
41
  id: RULE.LENGTH,
42
42
  category: "sentence",
43
43
  summary: "sentence longer than the word budget (default 25)",
44
+ options: { maxWords: "number" },
44
45
  check,
45
46
  };
@@ -6,18 +6,30 @@ import type { InterpunctRun } from "../interpunct.ts";
6
6
  import type { DocModel, ParagraphView } from "../model.ts";
7
7
 
8
8
  export const RULE = {
9
- WRAP: "PG001",
10
- LENGTH: "PG002",
11
- SEMICOLON_LIST: "PG003",
12
- EM_DASH: "PG004",
13
- INTERPUNCT: "PG005",
14
- INTERPUNCT_RUN: "PG006",
15
- INLINE_ENUM: "PG007",
16
- LABELLED_RUN: "PG008",
17
- STACKED_RUNS: "PG009",
9
+ WRAP: "sentence-one-per-line",
10
+ LENGTH: "sentence-word-budget-exceeded",
11
+ SEMICOLON_LIST: "list-semicolon-delimited-run",
12
+ EM_DASH: "punctuation-em-dash-in-prose",
13
+ INTERPUNCT: "punctuation-interpunct-in-prose",
14
+ INTERPUNCT_RUN: "list-interpunct-joined-run",
15
+ INLINE_ENUM: "list-inline-enumeration-markers",
16
+ LABELLED_RUN: "list-comma-labelled-run",
17
+ STACKED_RUNS: "list-stacked-interpunct-runs",
18
18
  } as const;
19
19
 
20
- export type RuleId = (typeof RULE)[keyof typeof RULE];
20
+ // A core id has no slash. A plugin id is always namespace/name, so the slash
21
+ // is what tells the two apart.
22
+ export type CoreRuleId = (typeof RULE)[keyof typeof RULE];
23
+
24
+ // A rule function may be synchronous or return a promise, so a rule can call a tool
25
+ // or a service. The engine awaits every call.
26
+ export type Awaitable<T> = T | Promise<T>;
27
+
28
+ export type PluginRuleId = `${string}/${string}`;
29
+
30
+ export const isPluginRuleId = (id: string): id is PluginRuleId => id.includes("/");
31
+
32
+ export type RuleId = CoreRuleId | PluginRuleId;
21
33
 
22
34
  export interface Finding {
23
35
  file: string;
@@ -26,23 +38,47 @@ export interface Finding {
26
38
  message: string;
27
39
  }
28
40
 
29
- // What every rule reads, computed once per document and handed to it. Shared
30
- // data is passed in rather than recomputed, so a check and a fix can never
31
- // disagree about what the document holds.
41
+ // The data every rule shares, computed once per document and handed to it.
42
+ // Shared data is passed in rather than recomputed, so a check and a fix can
43
+ // never disagree about what the document holds. `runs` is every interpunct
44
+ // run. `reported` is the runs some enabled rule owns, so a rule that defers
45
+ // to another stops deferring when that rule is switched off.
32
46
  export interface RuleContext {
33
47
  doc: DocModel;
34
48
  runs: InterpunctRun[];
49
+ reported: InterpunctRun[];
50
+ }
51
+
52
+ // What a rule may be configured with. A rule names each option it accepts
53
+ // and the type of its value, and the config is checked against that.
54
+ export type OptionType = "number" | "string" | "boolean" | "string[]";
55
+
56
+ export type OptionSpec = Readonly<Record<string, OptionType>>;
57
+
58
+ export type RuleOptions = Readonly<Record<string, unknown>>;
59
+
60
+ // Plain functions a plugin can use without importing this package, so a plugin
61
+ // never depends on resolving the host from its own location.
62
+ export interface TextHelpers {
63
+ words: (text: string) => number;
64
+ sentences: (text: string) => string[];
65
+ lengthMessage: (sentence: string, maxWords: number) => string;
35
66
  }
36
67
 
37
68
  // A check also names the file it reports against, and the sentence budget.
38
69
  export interface CheckContext extends RuleContext {
39
70
  file: string;
40
71
  maxWords: number;
72
+ options: RuleOptions;
73
+ helpers: TextHelpers;
41
74
  }
42
75
 
43
- // A fix needs nothing beyond the shared context. It proposes edits, and the
44
- // engine alone decides which of them survive verification.
45
- export type FixContext = RuleContext;
76
+ // A fix needs nothing beyond the shared context and its own options. It
77
+ // proposes edits, and the engine alone decides which of them survive.
78
+ export interface FixContext extends RuleContext {
79
+ options: RuleOptions;
80
+ helpers: TextHelpers;
81
+ }
46
82
 
47
83
  // What the engine must be able to prove about the document after an edit.
48
84
  export type Expectation =
@@ -50,6 +86,9 @@ export type Expectation =
50
86
  | { kind: "same-tree" }
51
87
  // Glyphs swapped inside text: the tree keeps its shape and every word.
52
88
  | { kind: "same-shape" }
89
+ // The frontmatter is written differently and means the same: the YAML parses to
90
+ // identical data before and after, and nothing outside it moves.
91
+ | { kind: "same-frontmatter-data" }
53
92
  // One paragraph becomes the blocks parsed from `fragment`, and nothing else
54
93
  // in the document moves.
55
94
  | { kind: "replace-paragraph"; view: ParagraphView; fragment: string; listItems: number };
@@ -66,15 +105,18 @@ export interface Edit {
66
105
  // sentence: how a sentence is laid out and how long it runs.
67
106
  // list: a list hidden in running prose, promoted to a real markdown list.
68
107
  // punctuation: a glyph that reads as generated text.
108
+ // A plugin may add a category of its own by declaring it.
69
109
  export const CATEGORIES = ["sentence", "list", "punctuation"] as const;
70
110
 
71
111
  export type Category = (typeof CATEGORIES)[number];
72
112
 
73
113
  export interface Rule {
74
114
  id: RuleId;
75
- category: Category;
115
+ category: string;
76
116
  summary: string;
77
- check: (ctx: CheckContext) => Finding[];
78
- // Absent for rules whose fix needs discretion (PG002).
79
- fix?: (ctx: FixContext) => Edit[];
117
+ // The options the rule accepts. Absent when it takes none.
118
+ options?: OptionSpec;
119
+ check: (ctx: CheckContext) => Awaitable<Finding[]>;
120
+ // Absent for rules whose fix needs discretion (sentence-word-budget-exceeded).
121
+ fix?: (ctx: FixContext) => Awaitable<Edit[]>;
80
122
  }
package/src/testing.ts ADDED
@@ -0,0 +1,60 @@
1
+ // A test helper for plugin authors: run one plugin rule over real strings, with
2
+ // the same loader checks, the same context and the same verified fix engine as
3
+ // a real run. It is a separate entry point so a plugin's tests import it and
4
+ // production code never does.
5
+
6
+ import { checkMarkdown } from "./check.ts";
7
+ import { type FixReport, fixMarkdownReport } from "./fix/engine.ts";
8
+ import type { Plugin, PluginRule } from "./plugins/contract.ts";
9
+ import { parsePlugin, toRule } from "./plugins/load.ts";
10
+ import { BUILTIN_CATEGORIES, buildRegistry, type Registry } from "./rules/registry.ts";
11
+ import type { Finding, RuleOptions } from "./rules/types.ts";
12
+
13
+ export interface RunOptions {
14
+ // The rule's own options, as a config would give them.
15
+ options?: RuleOptions;
16
+ // The sentence budget, as --max-words would give it.
17
+ maxWords?: number;
18
+ }
19
+
20
+ // Wraps a local rule module, with its optional `categories` export, as the
21
+ // plugin the loader would build from the file under the `local` namespace.
22
+ export const localPlugin = (key: string, mod: { default: PluginRule; categories?: Record<string, string> }): Plugin =>
23
+ parsePlugin(
24
+ "local",
25
+ {
26
+ meta: { name: "local", namespace: "local", apiVersion: 1, categories: mod.categories },
27
+ rules: { [key]: mod.default },
28
+ },
29
+ new Set(),
30
+ );
31
+
32
+ const registryFor = (plugin: Plugin, key: string, options: RuleOptions): Registry => {
33
+ const checked = parsePlugin(plugin.meta.name, plugin, new Set());
34
+ const rule = checked.rules[key];
35
+ if (!rule)
36
+ throw new Error(
37
+ `plugin "${plugin.meta.name}" has no rule "${key}"; it has ${Object.keys(checked.rules).join(", ")}`,
38
+ );
39
+ const built = toRule(checked.meta.namespace, key, rule);
40
+ return buildRegistry({
41
+ rules: [built],
42
+ fixOrder: built.fix ? [built] : [],
43
+ categories: new Map([...BUILTIN_CATEGORIES, ...Object.entries(checked.meta.categories ?? {})]),
44
+ config: {
45
+ plugins: [],
46
+ pluginLoading: "strict",
47
+ file: null,
48
+ rules: new Map([[built.id, { severity: "error", options }]]),
49
+ },
50
+ });
51
+ };
52
+
53
+ // The findings one rule of the plugin reports for a document.
54
+ export const checkRule = async (plugin: Plugin, key: string, src: string, run: RunOptions = {}): Promise<Finding[]> =>
55
+ checkMarkdown(src, "test.md", run.maxWords, registryFor(plugin, key, run.options ?? {}));
56
+
57
+ // The document after the rule's fixer has run to a fixpoint, and what was
58
+ // applied or refused. A fixer that loses a word throws, exactly as in a real run.
59
+ export const fixRule = async (plugin: Plugin, key: string, src: string, run: RunOptions = {}): Promise<FixReport> =>
60
+ fixMarkdownReport(src, registryFor(plugin, key, run.options ?? {}));