@jpeakai/prose-gates 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -7
- package/dist/bin.js +35469 -6876
- package/dist/index.js +35566 -6939
- package/dist/testing.js +38824 -0
- package/examples/plugin-skills/README.md +60 -0
- package/examples/plugin-skills/index.mjs +14 -0
- package/examples/plugin-skills/package.json +12 -0
- package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
- package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
- package/examples/plugin-skills-typescript/README.md +42 -0
- package/examples/plugin-skills-typescript/index.ts +21 -0
- package/examples/plugin-skills-typescript/package.json +12 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
- package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
- package/examples/plugin-skills-typescript/tsconfig.json +14 -0
- package/package.json +12 -3
- package/src/bin.ts +2 -2
- package/src/check.ts +53 -13
- package/src/cli.ts +243 -44
- package/src/cohesion.ts +70 -0
- package/src/concepts.ts +209 -0
- package/src/config.ts +119 -0
- package/src/errors.ts +212 -0
- package/src/fix/engine.ts +90 -50
- package/src/fix/verify.ts +60 -19
- package/src/index.ts +51 -2
- package/src/interpunct.ts +7 -7
- package/src/language.ts +107 -0
- package/src/model.ts +271 -68
- package/src/plugins/contract.ts +41 -0
- package/src/plugins/discover.ts +146 -0
- package/src/plugins/index.ts +136 -0
- package/src/plugins/load.ts +274 -0
- package/src/plugins/runtime.ts +15 -0
- package/src/project.ts +84 -0
- package/src/rules/index.ts +34 -20
- package/src/rules/list-comma-labelled-run.ts +117 -0
- package/src/rules/{pg007-inline-enum.ts → list-inline-enumeration-markers.ts} +56 -45
- package/src/rules/list-interpunct-joined-run.ts +71 -0
- package/src/rules/{pg003-semicolon-list.ts → list-semicolon-delimited-run.ts} +2 -2
- package/src/rules/{pg009-stacked-runs.ts → list-stacked-interpunct-runs.ts} +3 -3
- package/src/rules/paragraph-sentence-budget-exceeded.ts +64 -0
- package/src/rules/{pg004-em-dash.ts → punctuation-em-dash-in-prose.ts} +3 -3
- package/src/rules/{pg005-interpunct.ts → punctuation-interpunct-in-prose.ts} +6 -6
- package/src/rules/registry.ts +143 -0
- package/src/rules/{pg001-wrap.ts → sentence-one-per-line.ts} +45 -18
- package/src/rules/sentence-word-budget-exceeded.ts +57 -0
- package/src/rules/types.ts +71 -21
- package/src/testing.ts +60 -0
- package/src/rules/pg002-length.ts +0 -45
- package/src/rules/pg006-interpunct-run.ts +0 -68
- package/src/rules/pg008-labelled-run.ts +0 -101
|
@@ -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
|
-
//
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
span,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
2
|
-
// two-level structure, so
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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.
|
|
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
|
|
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
|
+
};
|