@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,117 @@
1
+ // list-comma-labelled-run: one sentence stringing 3+ comma-separated segments each led by a
2
+ // label (an inline-code span, or an identifier like sentence-one-per-line / MD025 / S4)
3
+ // WITH content after it is a hidden list of labelled items. Bare labels
4
+ // ("A, B, and C are interchangeable") are a subject enumeration, not a
5
+ // list. An optional "Rules: " style intro on the first segment, and a
6
+ // joining "and"/"or", are tolerated.
7
+ //
8
+ // The check counts labelled segments; the fix is stricter and needs every
9
+ // segment of the sentence to be one, so no stray clause becomes a bullet.
10
+
11
+ import { cleanLeadIn, itemsOf, promote } from "../fix/promote.ts";
12
+ import type { ParagraphView } from "../model.ts";
13
+ import { colonsIn, matchesInAll, proseSentences, sentenceSpans } from "../query.ts";
14
+ import { collapse, first, type Range, spansBetween, within } from "../text.ts";
15
+ import { type Edit, RULE, type Rule } from "./types.ts";
16
+
17
+ // A labelled segment, built from its parts so each one can be read on its
18
+ // own: the conjunction that joins the final segment, an optional "Rules: "
19
+ // intro on the first, the label itself, and the content that makes it an
20
+ // item rather than a bare mention.
21
+ const JOINER = "(?:and\\s+|or\\s+)?";
22
+ const INTRO = "(?:[A-Za-z][\\w\\s-]{0,24}:\\s*)?";
23
+ const LABEL = "(?:CODE|[A-Z][A-Za-z]*\\d[\\w/-]*)";
24
+ const CONTENT = "\\s+\\S";
25
+ const LABELLED_SEGMENT = new RegExp(`^${JOINER}${INTRO}${LABEL}${CONTENT}`);
26
+
27
+ // The intro that may precede the first label, as the lead-in test reads it.
28
+ const INTRO_ONLY = /^[A-Za-z][\w\s-]{0,24}$/;
29
+
30
+ // A source slice as the check sees it: code spans become one CODE token.
31
+ const asProse = (src: string, view: ParagraphView, range: Range): string => {
32
+ let out = "";
33
+ let at = range[0];
34
+ for (const [start, end] of view.codeRanges) {
35
+ if (end <= range[0] || start >= range[1]) continue;
36
+ out += `${src.slice(at, start)}CODE`;
37
+ at = end;
38
+ }
39
+ return collapse(out + src.slice(at, range[1])).trim();
40
+ };
41
+
42
+ const labelledSegments = (sentence: string): number =>
43
+ sentence.split(/,\s+/).filter((segment) => LABELLED_SEGMENT.test(segment)).length;
44
+
45
+ const check: Rule["check"] = ({ doc, file }) =>
46
+ proseSentences(doc)
47
+ .map(({ view, sentence }) => ({ view, labelled: labelledSegments(sentence) }))
48
+ .filter(({ labelled }) => labelled >= 3)
49
+ .map(({ view, labelled }) => ({
50
+ file,
51
+ line: view.line,
52
+ rule: RULE.LABELLED_RUN,
53
+ message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`,
54
+ }));
55
+
56
+ // The comma-separated segments of a sentence when every one is labelled, or
57
+ // null when the sentence has too few commas or carries a stray clause.
58
+ const labelledRun = (src: string, view: ParagraphView, commas: Range[], span: Range): Range[] | null => {
59
+ const inside = commas.filter((comma) => within(comma, span));
60
+ if (inside.length < 2) return null;
61
+ // The sentence start is the cut before the first segment, so the spans
62
+ // between the cuts are exactly the comma-separated segments.
63
+ const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
64
+ return segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment))) ? segments : null;
65
+ };
66
+
67
+ interface LabelledSentence {
68
+ src: string;
69
+ view: ParagraphView;
70
+ span: Range;
71
+ segments: Range[];
72
+ colons: Range[];
73
+ }
74
+
75
+ // An intro colon in the first segment, before its label, is the lead-in.
76
+ // The first item then starts after it, so the intro is not repeated as
77
+ // both the lead-in and the head of the first bullet.
78
+ const promoteRun = ({ src, view, span, segments, colons }: LabelledSentence): Edit | null => {
79
+ const opening = first(segments);
80
+ const colon = colons.find((c) => within(c, opening));
81
+ const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
82
+ const items: Range[] = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
83
+
84
+ return promote({
85
+ rule: RULE.LABELLED_RUN,
86
+ src,
87
+ view,
88
+ span,
89
+ leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
90
+ items: itemsOf(src, items).map((text) => ({ text })),
91
+ ordered: false,
92
+ });
93
+ };
94
+
95
+ // One promotion per paragraph: the first fully labelled sentence is the only
96
+ // one tried, and the engine re-parses and comes back for the rest.
97
+ const fixParagraph = (src: string, view: ParagraphView): Edit | null => {
98
+ const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
99
+ const colons = colonsIn(src, view);
100
+ if (commas === null || colons === null) return null;
101
+ for (const span of sentenceSpans(src, view)) {
102
+ const segments = labelledRun(src, view, commas, span);
103
+ if (segments !== null) return promoteRun({ src, view, span, segments, colons });
104
+ }
105
+ return null;
106
+ };
107
+
108
+ const fix: Rule["fix"] = ({ doc }) =>
109
+ doc.paragraphs.map((view) => fixParagraph(doc.src, view)).filter((edit): edit is Edit => edit !== null);
110
+
111
+ export const listCommaLabelledRun: Rule = {
112
+ id: RULE.LABELLED_RUN,
113
+ category: "list",
114
+ summary: "comma-joined labelled run (3+ labelled segments)",
115
+ check,
116
+ fix,
117
+ };
@@ -1,10 +1,10 @@
1
- // PG007: parenthesised enumerators inlined in one paragraph are a hidden
1
+ // list-inline-enumeration-markers: parenthesised enumerators inlined in one paragraph are a hidden
2
2
  // list. Requiring the first two members of a family keeps citations and a
3
3
  // lone "(a)" reference from firing. The fix only trusts a clean sequence:
4
4
  // every marker in order, none skipped, none from another family.
5
5
 
6
6
  import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
7
- import type { DirectText } from "../model.ts";
7
+ import type { DirectText, ParagraphView } from "../model.ts";
8
8
  import { proseParagraphs, sentenceSpan } from "../query.ts";
9
9
  import { first, last, type Range } from "../text.ts";
10
10
  import { type Edit, RULE, type Rule } from "./types.ts";
@@ -72,53 +72,64 @@ const check: Rule["check"] = ({ doc, file }) =>
72
72
  ];
73
73
  });
74
74
 
75
- const fix: Rule["fix"] = ({ doc }) => {
76
- const { src } = doc;
77
- const edits: Edit[] = [];
78
- for (const view of doc.paragraphs) {
79
- const markers = markersIn(src, view.directTexts);
80
- if (markers === null || markers.length < 2) continue;
81
- const sequence = sequenceFor(first(markers).label);
82
- if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) continue;
83
-
84
- const opener = first(markers);
85
- const closer = last(markers);
86
- // Markers split across sentences are two references, not one list.
87
- const between = view.directTexts.map((text) =>
88
- src.slice(Math.max(text.start, opener.range[1]), Math.min(text.end, closer.range[0])),
89
- );
90
- if (between.some((slice) => /[.!?]\s/.test(slice))) continue;
91
-
92
- const span = sentenceSpan(src, view, opener.range[0], closer.range[1]);
93
- // Two markers joined only by a conjunction, with no colon to announce
94
- // them, read as cross-references ("see (a) above and (b) below"). With
95
- // exactly two markers the closer is also the second, so the text between
96
- // them is the whole of the first item.
97
- if (markers.length === 2) {
98
- const announced = /:\s*$/.test(src.slice(span[0], opener.range[0]));
99
- const separated = /[,;]\s*$/.test(src.slice(opener.range[1], closer.range[0]));
100
- if (!announced && !separated) continue;
101
- }
75
+ // The paragraph's markers when they form a clean sequence: at least two, every
76
+ // one in order from the family the first opens, none skipped. Otherwise null.
77
+ const cleanSequence = (src: string, view: ParagraphView): Marker[] | null => {
78
+ const markers = markersIn(src, view.directTexts);
79
+ if (markers === null || markers.length < 2) return null;
80
+ const sequence = sequenceFor(first(markers).label);
81
+ if (sequence === null || markers.some((marker, i) => marker.label !== sequence[i])) return null;
82
+ return markers;
83
+ };
84
+
85
+ // Markers split across sentences are two references, not one list.
86
+ const splitAcrossSentences = (src: string, view: ParagraphView, opener: Marker, closer: Marker): boolean =>
87
+ view.directTexts
88
+ .map((text) => src.slice(Math.max(text.start, opener.range[1]), Math.min(text.end, closer.range[0])))
89
+ .some((slice) => /[.!?]\s/.test(slice));
90
+
91
+ // Two markers joined only by a conjunction, with no colon to announce
92
+ // them, read as cross-references ("see (a) above and (b) below"). With
93
+ // exactly two markers the closer is also the second, so the text between
94
+ // them is the whole of the first item.
95
+ const crossReference = (src: string, span: Range, markers: Marker[]): boolean => {
96
+ if (markers.length !== 2) return false;
97
+ const opener = first(markers);
98
+ const closer = last(markers);
99
+ const announced = /:\s*$/.test(src.slice(span[0], opener.range[0]));
100
+ const separated = /[,;]\s*$/.test(src.slice(opener.range[1], closer.range[0]));
101
+ return !announced && !separated;
102
+ };
102
103
 
103
- const edit = promote({
104
- rule: RULE.INLINE_ENUM,
104
+ const fixParagraph = (src: string, view: ParagraphView): Edit | null => {
105
+ const markers = cleanSequence(src, view);
106
+ if (markers === null) return null;
107
+ const opener = first(markers);
108
+ const closer = last(markers);
109
+ if (splitAcrossSentences(src, view, opener, closer)) return null;
110
+
111
+ const span = sentenceSpan(src, view, opener.range[0], closer.range[1]);
112
+ if (crossReference(src, span, markers)) return null;
113
+
114
+ return promote({
115
+ rule: RULE.INLINE_ENUM,
116
+ src,
117
+ view,
118
+ span,
119
+ leadIn: cleanLeadIn(src.slice(span[0], opener.range[0])),
120
+ items: itemsBetween(
105
121
  src,
106
- view,
107
- span,
108
- leadIn: cleanLeadIn(src.slice(span[0], opener.range[0])),
109
- items: itemsBetween(
110
- src,
111
- markers.map((marker) => marker.range),
112
- span[1],
113
- ).map((text) => ({ text })),
114
- ordered: true,
115
- });
116
- if (edit) edits.push(edit);
117
- }
118
- return edits;
122
+ markers.map((marker) => marker.range),
123
+ span[1],
124
+ ).map((text) => ({ text })),
125
+ ordered: true,
126
+ });
119
127
  };
120
128
 
121
- export const pg007: Rule = {
129
+ const fix: Rule["fix"] = ({ doc }) =>
130
+ doc.paragraphs.map((view) => fixParagraph(doc.src, view)).filter((edit): edit is Edit => edit !== null);
131
+
132
+ export const listInlineEnumerationMarkers: Rule = {
122
133
  id: RULE.INLINE_ENUM,
123
134
  category: "list",
124
135
  summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
@@ -0,0 +1,71 @@
1
+ // list-interpunct-joined-run: a single-line interpunct-joined run is a disguised flat list. The
2
+ // fix promotes it to bullets, taking a colon before the run as the lead-in.
3
+ // Without a colon the run must be the whole paragraph, or there is no way to
4
+ // tell where the first item starts.
5
+
6
+ import { cleanLeadIn, itemsBetween, promote } from "../fix/promote.ts";
7
+ import { GLYPH, isFlat, paragraphsOf, RUN_SEPARATOR } from "../interpunct.ts";
8
+ import type { ParagraphView } from "../model.ts";
9
+ import { colonsBefore, colonsIn, sentenceSpan, splittableSeparators } from "../query.ts";
10
+ import { first, last, type Range, words } from "../text.ts";
11
+ import { type Edit, RULE, type Rule } from "./types.ts";
12
+
13
+ // A last item this much longer than every other is a sentence that happens
14
+ // to follow the list, not a member of it.
15
+ const tailIsProse = (items: string[]): boolean =>
16
+ words(last(items)) > Math.max(8, 3 * Math.max(...items.slice(0, -1).map(words)));
17
+
18
+ // Without a colon, a first item longer than the rest ("Built with Bun")
19
+ // carries a clause the list would strip of its meaning.
20
+ const headIsProse = (items: string[]): boolean => words(first(items)) > Math.max(...items.slice(1).map(words));
21
+
22
+ const check: Rule["check"] = ({ file, runs }) =>
23
+ runs.filter(isFlat).map((run) => ({
24
+ file,
25
+ line: run.line,
26
+ rule: RULE.INTERPUNCT_RUN,
27
+ message: `interpunct-joined inline list (${run.separators + 1} items); promote to a bullet list`,
28
+ }));
29
+
30
+ // With no colon to announce it, the run has to be the whole paragraph.
31
+ const coversParagraph = (view: ParagraphView, span: Range): boolean =>
32
+ span[0] === view.startOffset && span[1] === view.endOffset;
33
+
34
+ const fixParagraph = (src: string, view: ParagraphView): Edit | null => {
35
+ const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
36
+ const colons = colonsIn(src, view);
37
+ if (separators === null || colons === null) return null;
38
+
39
+ const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
40
+ // The innermost colon before the first item, so a lead-in that itself
41
+ // contains a colon keeps only its final clause.
42
+ const colon = colonsBefore(colons, span, first(separators)[0]).pop();
43
+ if (!colon && !coversParagraph(view, span)) return null;
44
+
45
+ const cuts: Range[] = [colon ?? [span[0], span[0]], ...separators];
46
+ const items = itemsBetween(src, cuts, span[1]);
47
+ if (tailIsProse(items) || (!colon && headIsProse(items))) return null;
48
+
49
+ return promote({
50
+ rule: RULE.INTERPUNCT_RUN,
51
+ src,
52
+ view,
53
+ span,
54
+ leadIn: colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
55
+ items: items.map((text) => ({ text })),
56
+ ordered: false,
57
+ });
58
+ };
59
+
60
+ const fix: Rule["fix"] = ({ doc, runs }) =>
61
+ paragraphsOf(doc, runs.filter(isFlat))
62
+ .map((view) => fixParagraph(doc.src, view))
63
+ .filter((edit): edit is Edit => edit !== null);
64
+
65
+ export const listInterpunctJoinedRun: Rule = {
66
+ id: RULE.INTERPUNCT_RUN,
67
+ category: "list",
68
+ summary: "interpunct-joined inline list (2+ separators)",
69
+ check,
70
+ fix,
71
+ };
@@ -1,4 +1,4 @@
1
- // PG003: a run of items joined by semicolons is a wall of text hiding a
1
+ // list-semicolon-delimited-run: a run of items joined by semicolons is a wall of text hiding a
2
2
  // list. The fix needs a colon lead-in to know where the list starts, and
3
3
  // refuses without one.
4
4
 
@@ -51,7 +51,7 @@ const fix: Rule["fix"] = ({ doc }) => {
51
51
  return edits;
52
52
  };
53
53
 
54
- export const pg003: Rule = {
54
+ export const listSemicolonDelimitedRun: Rule = {
55
55
  id: RULE.SEMICOLON_LIST,
56
56
  category: "list",
57
57
  summary: "semicolon-delimited list (2+ ';' in one sentence)",
@@ -1,5 +1,5 @@
1
- // PG009: interpunct runs stacked on 2+ source lines of one paragraph are a
2
- // two-level structure, so PG009 replaces PG006 for that paragraph. The fix
1
+ // list-stacked-interpunct-runs: interpunct runs stacked on 2+ source lines of one paragraph are a
2
+ // two-level structure, so list-stacked-interpunct-runs replaces list-interpunct-joined-run for that paragraph. The fix
3
3
  // needs every line to read `Label: a · b · c`, and builds one parent bullet
4
4
  // per label with the items nested beneath it.
5
5
 
@@ -71,7 +71,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
71
71
  return edits;
72
72
  };
73
73
 
74
- export const pg009: Rule = {
74
+ export const listStackedInterpunctRuns: Rule = {
75
75
  id: RULE.STACKED_RUNS,
76
76
  category: "list",
77
77
  summary: "interpunct runs stacked on several lines (nested list)",
@@ -0,0 +1,64 @@
1
+ // paragraph-sentence-budget-exceeded: a paragraph of more sentences than the
2
+ // budget renders as a wall of text, even when every sentence is short. Never
3
+ // autofixed: where a paragraph breaks is a judgement about its topics. The
4
+ // message counts the topics and names the sentences to break after.
5
+ //
6
+ // It runs after the list rules. A list hidden in a paragraph is theirs to
7
+ // report, and promoting it often brings the paragraph within budget on its
8
+ // own, so a paragraph a list rule reports is counted only once the list is out.
9
+
10
+ import { gapCohesion, planBreaks } from "../cohesion.ts";
11
+ import type { LanguageSentence } from "../language.ts";
12
+ import type { ParagraphView } from "../model.ts";
13
+ import { proseParagraphs } from "../query.ts";
14
+ import { type Finding, RULE, type Rule } from "./types.ts";
15
+
16
+ export const DEFAULT_MAX_SENTENCES = 5;
17
+
18
+ // A list of sentence numbers as prose: "3", "3 and 6", "2, 3 and 6".
19
+ const series = (ns: number[]): string =>
20
+ ns.length === 1 ? `${ns[0]}` : `${ns.slice(0, -1).join(", ")} and ${ns.at(-1)}`;
21
+
22
+ const plural = (n: number, word: string): string => `${word}${n === 1 ? "" : "s"}`;
23
+
24
+ export const paragraphMessage = (sentences: readonly LanguageSentence[], maxSentences: number): string => {
25
+ const { topics, breaks } = planBreaks(gapCohesion(sentences), maxSentences);
26
+ const where = `break it after ${plural(breaks.length, "sentence")} ${series(breaks)}`;
27
+ const counted = `paragraph has ${sentences.length} sentences (budget ${maxSentences})`;
28
+ return topics === 1
29
+ ? `${counted} and one topic throughout; ${where}, where neighbouring sentences share the fewest words`
30
+ : `${counted} and about ${topics} topics; ${where}, one topic per paragraph`;
31
+ };
32
+
33
+ const budgetOf = (option: unknown): number => (typeof option === "number" ? option : DEFAULT_MAX_SENTENCES);
34
+
35
+ // Whether a list rule reported a line of this paragraph. A list rule may
36
+ // report the paragraph's first line or the line its run starts on.
37
+ const holdsListFinding = (view: ParagraphView, earlier: readonly Finding[]): boolean => {
38
+ const last = view.line + view.raw.split("\n").length - 1;
39
+ return earlier.some((f) => f.line >= view.line && f.line <= last);
40
+ };
41
+
42
+ const check: Rule["check"] = ({ doc, file, options, earlier }) => {
43
+ const maxSentences = budgetOf(options.maxSentences);
44
+ return proseParagraphs(doc)
45
+ .filter((view) => !holdsListFinding(view, earlier))
46
+ .map((view) => ({ view, sentences: view.language.sentences }))
47
+ .filter(({ sentences }) => sentences.length > maxSentences)
48
+ .map(({ view, sentences }) => ({
49
+ file,
50
+ line: view.line,
51
+ rule: RULE.PARAGRAPH_LENGTH,
52
+ message: paragraphMessage(sentences, maxSentences),
53
+ }));
54
+ };
55
+
56
+ // No fixer: breaking a paragraph is a judgement about its topics, per PRS-0030.
57
+ export const paragraphSentenceBudgetExceeded: Rule = {
58
+ id: RULE.PARAGRAPH_LENGTH,
59
+ category: "paragraph",
60
+ summary: `paragraph with more sentences than the budget (default ${DEFAULT_MAX_SENTENCES})`,
61
+ options: { maxSentences: "number" },
62
+ after: ["list"],
63
+ check,
64
+ };
@@ -1,4 +1,4 @@
1
- // PG004: an em-dash in prose. Code is exempt by construction, since only
1
+ // punctuation-em-dash-in-prose: an em-dash in prose. Code is exempt by construction, since only
2
2
  // text nodes are read. The fix is deliberately narrow: a pair of dashes
3
3
  // around an aside becomes parentheses, a lone dash becomes a colon, and
4
4
  // anything else stays reported for a human.
@@ -99,7 +99,7 @@ const replacementFor = (src: string, { dashes, hasColon }: Aside): string | null
99
99
  const closing = last(dashes);
100
100
 
101
101
  // A pair wraps an aside, so it becomes parentheses. An aside that reads as
102
- // "(b)" would plant a PG007 marker and hide the word from the fingerprint.
102
+ // "(b)" would plant a list-inline-enumeration-markers marker and hide the word from the fingerprint.
103
103
  if (dashes.length === 2) {
104
104
  const inner = src.slice(opening.range[1], closing.range[0]);
105
105
  return MARKER_LIKE.test(inner.trim()) ? null : ` (${inner}) `;
@@ -146,7 +146,7 @@ const fix: Rule["fix"] = ({ doc }) =>
146
146
  .map((aside) => rewrite(doc.src, aside))
147
147
  .filter((edit): edit is Edit => edit !== null);
148
148
 
149
- export const pg004: Rule = {
149
+ export const punctuationEmDashInProse: Rule = {
150
150
  id: RULE.EM_DASH,
151
151
  category: "punctuation",
152
152
  summary: "em-dash U+2014 in prose",
@@ -1,5 +1,5 @@
1
- // PG005: a stray interpunct in prose. One inside a reported run is left to
2
- // PG006 or PG009. The fix swaps a spaced interpunct for a comma; a glyph
1
+ // punctuation-interpunct-in-prose: a stray interpunct in prose. One inside a reported run is left to
2
+ // list-interpunct-joined-run or list-stacked-interpunct-runs. The fix swaps a spaced interpunct for a comma; a glyph
3
3
  // with no space on either side (a product name, a unit) is left alone.
4
4
 
5
5
  import { GLYPH, INTERPUNCT } from "../interpunct.ts";
@@ -8,11 +8,11 @@ import { inRanges } from "../text.ts";
8
8
  import { type Edit, RULE, type Rule } from "./types.ts";
9
9
 
10
10
  // Only a separator reads as a comma: the spaces on both sides are what make
11
- // the glyph a joiner rather than part of a name. PG006 and PG009 split on a
11
+ // the glyph a joiner rather than part of a name. list-interpunct-joined-run and list-stacked-interpunct-runs split on a
12
12
  // looser one, because a run has already proved itself a list.
13
13
  const SEPARATOR = /[ \t]+·[ \t]+/;
14
14
 
15
- const check: Rule["check"] = ({ doc, file, runs }) => {
15
+ const check: Rule["check"] = ({ doc, file, reported: runs }) => {
16
16
  const reported = runs.map((run) => run.range);
17
17
  return textsContaining(doc, INTERPUNCT)
18
18
  .filter((text) => text.start === undefined || !inRanges(text.start, reported))
@@ -24,7 +24,7 @@ const check: Rule["check"] = ({ doc, file, runs }) => {
24
24
  }));
25
25
  };
26
26
 
27
- const fix: Rule["fix"] = ({ doc, runs }) => {
27
+ const fix: Rule["fix"] = ({ doc, reported: runs }) => {
28
28
  const reported = runs.map((run) => run.range);
29
29
  const edits: Edit[] = [];
30
30
  for (const { start, end, value } of textsContaining(doc, INTERPUNCT)) {
@@ -43,7 +43,7 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
43
43
  return edits;
44
44
  };
45
45
 
46
- export const pg005: Rule = {
46
+ export const punctuationInterpunctInProse: Rule = {
47
47
  id: RULE.INTERPUNCT,
48
48
  category: "punctuation",
49
49
  summary: "interpunct U+00B7 in prose",
@@ -0,0 +1,143 @@
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 ConceptModel, NO_CONCEPTS } from "../concepts.ts";
7
+ import type { Config } from "../config.ts";
8
+ import { RuleOptionError, UnknownRuleError } from "../errors.ts";
9
+ import { interpunctRuns, isFlat } from "../interpunct.ts";
10
+ import type { DocModel } from "../model.ts";
11
+ import { sentences, words } from "../text.ts";
12
+ import { FIX_ORDER, RULES } from "./index.ts";
13
+ import { lengthMessage } from "./sentence-word-budget-exceeded.ts";
14
+ import {
15
+ isPluginRuleId,
16
+ type OptionType,
17
+ RULE,
18
+ type Rule,
19
+ type RuleContext,
20
+ type RuleId,
21
+ type RuleOptions,
22
+ } from "./types.ts";
23
+
24
+ export const BUILTIN_CATEGORIES: ReadonlyMap<string, string> = new Map([
25
+ ["sentence", "How a sentence is laid out and how long it runs"],
26
+ ["paragraph", "How many sentences a paragraph holds before a break"],
27
+ ["list", "A list hidden in running prose, promoted to a real markdown list"],
28
+ ["punctuation", "A glyph that reads as generated text"],
29
+ ]);
30
+
31
+ export const TEXT_HELPERS = { words, sentences, lengthMessage } as const;
32
+
33
+ // Where a group of rules came from, for the line each run prints.
34
+ export interface LoadedSource {
35
+ kind: "local" | "package" | "config";
36
+ spec: string;
37
+ namespace: string;
38
+ rules: RuleId[];
39
+ }
40
+
41
+ export interface Registry {
42
+ rules: Rule[];
43
+ fixOrder: Rule[];
44
+ enabled: ReadonlySet<RuleId>;
45
+ options: ReadonlyMap<RuleId, RuleOptions>;
46
+ categories: ReadonlyMap<string, string>;
47
+ sources: LoadedSource[];
48
+ // Config settings dropped because they name a plugin rule that did not load.
49
+ ignoredSettings: string[];
50
+ // The project's concept file, read once per run, which every document model carries.
51
+ concepts: ConceptModel;
52
+ }
53
+
54
+ export interface RegistryInput {
55
+ rules: Rule[];
56
+ fixOrder: Rule[];
57
+ categories: ReadonlyMap<string, string>;
58
+ sources?: LoadedSource[];
59
+ config?: Config;
60
+ // A lenient run drops a setting for an unknown plugin rule instead of failing.
61
+ ignoreUnknownPluginIds?: boolean;
62
+ concepts?: ConceptModel;
63
+ }
64
+
65
+ const typeOf = (value: unknown): OptionType | null => {
66
+ if (typeof value === "number" && Number.isFinite(value)) return "number";
67
+ if (typeof value === "string") return "string";
68
+ if (typeof value === "boolean") return "boolean";
69
+ if (Array.isArray(value) && value.every((v) => typeof v === "string")) return "string[]";
70
+ return null;
71
+ };
72
+
73
+ const checkOptions = (where: string, rule: Rule, options: RuleOptions): void => {
74
+ const accepted = Object.keys(rule.options ?? {});
75
+ for (const [name, value] of Object.entries(options)) {
76
+ const want = rule.options?.[name];
77
+ if (!want) {
78
+ const list = accepted.length > 0 ? `it accepts ${accepted.join(", ")}` : "it takes no options";
79
+ throw new RuleOptionError(where, rule.id, name, `rule "${rule.id}" has no option "${name}"; ${list}`);
80
+ }
81
+ if (typeOf(value) !== want) {
82
+ throw new RuleOptionError(where, rule.id, name, `option "${name}" of "${rule.id}" must be a ${want}`);
83
+ }
84
+ }
85
+ // A budget below one would fail every sentence or every paragraph.
86
+ for (const budget of ["maxWords", "maxSentences"]) {
87
+ if (budget in options && !((options[budget] as number) >= 1)) {
88
+ throw new RuleOptionError(where, rule.id, budget, `option "${budget}" of "${rule.id}" must be at least 1`);
89
+ }
90
+ }
91
+ };
92
+
93
+ export const buildRegistry = (input: RegistryInput): Registry => {
94
+ const { rules, fixOrder, categories, sources = [], config, ignoreUnknownPluginIds = false } = input;
95
+ const concepts = input.concepts ?? NO_CONCEPTS;
96
+ const off = new Set<string>();
97
+ const options = new Map<RuleId, RuleOptions>();
98
+ const ignored: string[] = [];
99
+ const where = config?.file ?? "config";
100
+ for (const [id, setting] of config?.rules ?? []) {
101
+ const rule = rules.find((r) => r.id === id);
102
+ if (!rule) {
103
+ // A plugin that was skipped cannot have its rules configured, so a lenient
104
+ // run drops the setting and says so. A built-in id is never forgiven.
105
+ if (ignoreUnknownPluginIds && isPluginRuleId(id)) {
106
+ ignored.push(id);
107
+ continue;
108
+ }
109
+ throw new UnknownRuleError(
110
+ where,
111
+ id,
112
+ rules.map((r) => r.id),
113
+ );
114
+ }
115
+ checkOptions(where, rule, setting.options);
116
+ if (setting.severity === "off") off.add(id);
117
+ else options.set(rule.id, setting.options);
118
+ }
119
+ const active = rules.filter((r) => !off.has(r.id));
120
+ return {
121
+ rules: active,
122
+ fixOrder: fixOrder.filter((r) => !off.has(r.id)),
123
+ enabled: new Set(active.map((r) => r.id)),
124
+ options,
125
+ categories,
126
+ sources,
127
+ ignoredSettings: ignored,
128
+ concepts,
129
+ };
130
+ };
131
+
132
+ export const BUILTIN: Registry = buildRegistry({ rules: RULES, fixOrder: FIX_ORDER, categories: BUILTIN_CATEGORIES });
133
+
134
+ // The data every rule shares, derived once per document. Interpunct runs are
135
+ // read by several rules, so computing them here keeps the fix engine from
136
+ // rebuilding them once per rule on every pass of the fixpoint loop. A run is
137
+ // owned by the flat or the stacked list rule, and it counts as reported only
138
+ // while its owner is on.
139
+ export const ruleContext = (doc: DocModel, registry: Registry = BUILTIN): RuleContext => {
140
+ const runs = interpunctRuns(doc);
141
+ const reported = runs.filter((run) => registry.enabled.has(isFlat(run) ? RULE.INTERPUNCT_RUN : RULE.STACKED_RUNS));
142
+ return { doc, runs, reported };
143
+ };