@jpeakai/prose-gates 0.1.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/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/bin.js +11008 -0
- package/dist/index.js +11015 -0
- package/package.json +59 -0
- package/src/bin.ts +10 -0
- package/src/check.ts +24 -0
- package/src/cli.ts +82 -0
- package/src/fix/engine.ts +84 -0
- package/src/fix/promote.ts +107 -0
- package/src/fix/spans.ts +75 -0
- package/src/fix/verify.ts +134 -0
- package/src/index.ts +10 -0
- package/src/model.ts +165 -0
- package/src/rules/index.ts +21 -0
- package/src/rules/interpunct.ts +19 -0
- package/src/rules/pg001-wrap.ts +79 -0
- package/src/rules/pg002-length.ts +44 -0
- package/src/rules/pg003-semicolon-list.ts +68 -0
- package/src/rules/pg004-em-dash.ts +117 -0
- package/src/rules/pg005-interpunct.ts +43 -0
- package/src/rules/pg006-interpunct-run.ts +84 -0
- package/src/rules/pg007-inline-enum.ts +119 -0
- package/src/rules/pg008-labelled-run.ts +94 -0
- package/src/rules/pg009-stacked-runs.ts +79 -0
- package/src/rules/types.ts +77 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// PG007: parenthesised enumerators inlined in one paragraph are a hidden
|
|
2
|
+
// list. Requiring the first two members of a family keeps citations and a
|
|
3
|
+
// lone "(a)" reference from firing. The fix only trusts a clean sequence:
|
|
4
|
+
// every marker in order, none skipped, none from another family.
|
|
5
|
+
|
|
6
|
+
import { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
|
|
7
|
+
import { sentenceSpan } from "../fix/spans.ts";
|
|
8
|
+
import type { DocModel, Range } from "../model.ts";
|
|
9
|
+
import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const ENUM_FAMILIES: Array<[string, string]> = [
|
|
12
|
+
["(a)", "(b)"],
|
|
13
|
+
["(1)", "(2)"],
|
|
14
|
+
["(i)", "(ii)"],
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const ROMAN = ["i", "ii", "iii", "iv", "v", "vi", "vii", "viii", "ix", "x", "xi", "xii"];
|
|
18
|
+
const LETTERS = [..."abcdefghijklmnopqrstuvwxyz"];
|
|
19
|
+
|
|
20
|
+
// The expected nth marker for the family the first marker opens.
|
|
21
|
+
const sequenceFor = (first: string): string[] | null => {
|
|
22
|
+
if (first === "a") return LETTERS;
|
|
23
|
+
if (first === "1") return Array.from({ length: 99 }, (_, i) => String(i + 1));
|
|
24
|
+
if (first === "i") return ROMAN;
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const MARKER = /\(([a-z]|[ivx]+|\d{1,2})\)(?=\s)/g;
|
|
29
|
+
|
|
30
|
+
interface Marker {
|
|
31
|
+
range: Range;
|
|
32
|
+
label: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Markers that open an item: preceded by whitespace or the start of a direct
|
|
36
|
+
// text node, and followed by whitespace. The parsed value must agree with the
|
|
37
|
+
// source, or an escape is hiding somewhere and the fixer refuses.
|
|
38
|
+
const markersIn = (src: string, texts: DocModel["paragraphs"][number]["directTexts"]): Marker[] | null => {
|
|
39
|
+
const out: Marker[] = [];
|
|
40
|
+
for (const t of texts) {
|
|
41
|
+
const slice = src.slice(t.start, t.end);
|
|
42
|
+
const opening = (s: string): RegExpMatchArray[] =>
|
|
43
|
+
[...s.matchAll(MARKER)].filter((m) => (m.index ?? 0) === 0 || /\s/.test(s[(m.index ?? 0) - 1] as string));
|
|
44
|
+
const inSlice = opening(slice);
|
|
45
|
+
if (inSlice.length !== opening(t.value).length) return null;
|
|
46
|
+
for (const m of inSlice) {
|
|
47
|
+
const start = t.start + (m.index ?? 0);
|
|
48
|
+
out.push({ range: [start, start + m[0].length], label: m[1] as string });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const fix = (doc: DocModel): Edit[] => {
|
|
55
|
+
const { src } = doc;
|
|
56
|
+
const edits: Edit[] = [];
|
|
57
|
+
for (const view of doc.paragraphs) {
|
|
58
|
+
const markers = markersIn(src, view.directTexts);
|
|
59
|
+
if (markers === null || markers.length < 2) continue;
|
|
60
|
+
const sequence = sequenceFor((markers[0] as Marker).label);
|
|
61
|
+
if (sequence === null || markers.some((m, i) => m.label !== sequence[i])) continue;
|
|
62
|
+
|
|
63
|
+
const first = markers[0] as Marker;
|
|
64
|
+
const last = markers[markers.length - 1] as Marker;
|
|
65
|
+
// Markers split across sentences are two references, not one list.
|
|
66
|
+
const between = view.directTexts.map((t) =>
|
|
67
|
+
src.slice(Math.max(t.start, first.range[1]), Math.min(t.end, last.range[0])),
|
|
68
|
+
);
|
|
69
|
+
if (between.some((s) => /[.!?]\s/.test(s))) continue;
|
|
70
|
+
const span = sentenceSpan(src, view, first.range[0], last.range[1]);
|
|
71
|
+
// Two markers joined only by a conjunction, with no colon to announce
|
|
72
|
+
// them, read as cross-references ("see (a) above and (b) below").
|
|
73
|
+
const announced = /:\s*$/.test(src.slice(span[0], first.range[0]));
|
|
74
|
+
const separated = /[,;]\s*$/.test(src.slice((markers[0] as Marker).range[1], (markers[1] as Marker).range[0]));
|
|
75
|
+
if (markers.length === 2 && !announced && !separated) continue;
|
|
76
|
+
const items = markers.map((m, i) => {
|
|
77
|
+
const end = i + 1 < markers.length ? (markers[i + 1] as Marker).range[0] : span[1];
|
|
78
|
+
return { text: cleanItem(src.slice(m.range[1], end), { last: i + 1 === markers.length }) };
|
|
79
|
+
});
|
|
80
|
+
const edit = promote({
|
|
81
|
+
rule: RULE.INLINE_ENUM,
|
|
82
|
+
src,
|
|
83
|
+
view,
|
|
84
|
+
span,
|
|
85
|
+
leadIn: cleanLeadIn(src.slice(span[0], first.range[0])),
|
|
86
|
+
items,
|
|
87
|
+
ordered: true,
|
|
88
|
+
});
|
|
89
|
+
if (edit) edits.push(edit);
|
|
90
|
+
}
|
|
91
|
+
return edits;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const check: Rule["check"] = ({ doc, file }) => {
|
|
95
|
+
const findings: Finding[] = [];
|
|
96
|
+
for (const p of doc.paragraphs) {
|
|
97
|
+
if (p.inTable) continue;
|
|
98
|
+
for (const [first, second] of ENUM_FAMILIES) {
|
|
99
|
+
if (p.prose.includes(first) && p.prose.includes(second)) {
|
|
100
|
+
findings.push({
|
|
101
|
+
file,
|
|
102
|
+
line: p.line,
|
|
103
|
+
rule: RULE.INLINE_ENUM,
|
|
104
|
+
message: `enumeration ${first} ${second} ... inlined in prose; promote to a bullet list`,
|
|
105
|
+
});
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const pg007: Rule = {
|
|
114
|
+
id: RULE.INLINE_ENUM,
|
|
115
|
+
category: "list",
|
|
116
|
+
summary: "enumeration markers (a)/(b) or (1)/(2) inlined in prose",
|
|
117
|
+
check,
|
|
118
|
+
fix,
|
|
119
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// PG008: one sentence stringing 3+ comma-separated segments each led by a
|
|
2
|
+
// label (an inline-code span, or an identifier like PG001 / 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 { cleanItem, cleanLeadIn, promote } from "../fix/promote.ts";
|
|
12
|
+
import { collapse, matchesInAll, sentenceSpans, within } from "../fix/spans.ts";
|
|
13
|
+
import { type DocModel, type ParagraphView, type Range, sentences } from "../model.ts";
|
|
14
|
+
import { type Edit, type Finding, RULE, type Rule } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
const LABELLED_SEGMENT = /^(?:and\s+|or\s+)?(?:[A-Za-z][\w\s-]{0,24}:\s*)?(?:CODE|[A-Z][A-Za-z]*\d[\w/-]*)\s+\S/;
|
|
17
|
+
|
|
18
|
+
// A source slice as the check sees it: code spans become one CODE token.
|
|
19
|
+
const asProse = (src: string, view: ParagraphView, range: Range): string => {
|
|
20
|
+
let out = "";
|
|
21
|
+
let at = range[0];
|
|
22
|
+
for (const [a, b] of view.codeRanges) {
|
|
23
|
+
if (b <= range[0] || a >= range[1]) continue;
|
|
24
|
+
out += `${src.slice(at, a)}CODE`;
|
|
25
|
+
at = b;
|
|
26
|
+
}
|
|
27
|
+
return collapse(out + src.slice(at, range[1])).trim();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const fix = (doc: DocModel): Edit[] => {
|
|
31
|
+
const { src } = doc;
|
|
32
|
+
const edits: Edit[] = [];
|
|
33
|
+
for (const view of doc.paragraphs) {
|
|
34
|
+
const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
|
|
35
|
+
const colons = matchesInAll(src, view.directTexts, /:/);
|
|
36
|
+
if (commas === null || colons === null) continue;
|
|
37
|
+
for (const span of sentenceSpans(src, view)) {
|
|
38
|
+
const inside = commas.filter((r) => within(r, span));
|
|
39
|
+
if (inside.length < 2) continue;
|
|
40
|
+
const bounds: Range[] = inside.map((c, i) => [i === 0 ? span[0] : (inside[i - 1] as Range)[1], c[0]]);
|
|
41
|
+
bounds.push([(inside[inside.length - 1] as Range)[1], span[1]]);
|
|
42
|
+
if (!bounds.every((b) => LABELLED_SEGMENT.test(asProse(src, view, b)))) continue;
|
|
43
|
+
|
|
44
|
+
// An intro colon in the first segment, before its label, is the lead-in.
|
|
45
|
+
const firstBound = bounds[0] as Range;
|
|
46
|
+
const colon = colons.find((c) => within(c, firstBound));
|
|
47
|
+
const intro = colon && /^[A-Za-z][\w\s-]{0,24}$/.test(collapse(src.slice(span[0], colon[0])).trim());
|
|
48
|
+
if (intro && colon) firstBound[0] = colon[1];
|
|
49
|
+
|
|
50
|
+
const items = bounds.map((b, i) => ({
|
|
51
|
+
text: cleanItem(src.slice(b[0], b[1]), { last: i + 1 === bounds.length }),
|
|
52
|
+
}));
|
|
53
|
+
const edit = promote({
|
|
54
|
+
rule: RULE.LABELLED_RUN,
|
|
55
|
+
src,
|
|
56
|
+
view,
|
|
57
|
+
span,
|
|
58
|
+
leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
|
|
59
|
+
items,
|
|
60
|
+
ordered: false,
|
|
61
|
+
});
|
|
62
|
+
if (edit) edits.push(edit);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return edits;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const check: Rule["check"] = ({ doc, file }) => {
|
|
70
|
+
const findings: Finding[] = [];
|
|
71
|
+
for (const p of doc.paragraphs) {
|
|
72
|
+
if (p.inTable) continue;
|
|
73
|
+
for (const s of sentences(p.prose)) {
|
|
74
|
+
const labelled = s.split(/,\s+/).filter((seg) => LABELLED_SEGMENT.test(seg)).length;
|
|
75
|
+
if (labelled >= 3) {
|
|
76
|
+
findings.push({
|
|
77
|
+
file,
|
|
78
|
+
line: p.line,
|
|
79
|
+
rule: RULE.LABELLED_RUN,
|
|
80
|
+
message: `comma-joined run of ${labelled} labelled items; promote to a bullet list`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return findings;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const pg008: Rule = {
|
|
89
|
+
id: RULE.LABELLED_RUN,
|
|
90
|
+
category: "list",
|
|
91
|
+
summary: "comma-joined labelled run (3+ labelled segments)",
|
|
92
|
+
check,
|
|
93
|
+
fix,
|
|
94
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
|
3
|
+
// needs every line to read `Label: a · b · c`, and builds one parent bullet
|
|
4
|
+
// per label with the items nested beneath it.
|
|
5
|
+
|
|
6
|
+
import { cleanItem, type PromotedItem, promote } from "../fix/promote.ts";
|
|
7
|
+
import { collapse, escaped, matchesInAll, within } from "../fix/spans.ts";
|
|
8
|
+
import type { DocModel, Range } from "../model.ts";
|
|
9
|
+
import { interpunctRuns } from "./interpunct.ts";
|
|
10
|
+
import { type Edit, RULE, type Rule } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
const fix = (doc: DocModel): Edit[] => {
|
|
13
|
+
const { src } = doc;
|
|
14
|
+
const edits: Edit[] = [];
|
|
15
|
+
const runs = interpunctRuns(doc).filter((r) => r.lines >= 2);
|
|
16
|
+
for (const view of doc.paragraphs) {
|
|
17
|
+
if (!runs.some((r) => r.range[0] === view.startOffset)) continue;
|
|
18
|
+
const seps = matchesInAll(src, view.directTexts, /[ \t]*·[ \t]*/);
|
|
19
|
+
const colons = matchesInAll(src, view.directTexts, /:/);
|
|
20
|
+
if (
|
|
21
|
+
seps === null ||
|
|
22
|
+
colons === null ||
|
|
23
|
+
seps.length !== (view.prose.match(/·/g) ?? []).length ||
|
|
24
|
+
seps.some(([s]) => escaped(src, s))
|
|
25
|
+
)
|
|
26
|
+
continue;
|
|
27
|
+
|
|
28
|
+
// Source lines of the paragraph, as absolute ranges.
|
|
29
|
+
const lines: Range[] = [];
|
|
30
|
+
let at = view.startOffset;
|
|
31
|
+
for (const line of view.raw.split("\n")) {
|
|
32
|
+
lines.push([at, at + line.length]);
|
|
33
|
+
at += line.length + 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parents: PromotedItem[] = [];
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
const lineSeps = seps.filter((s) => within(s, line));
|
|
39
|
+
const colon = colons.find((c) => within(c, line));
|
|
40
|
+
if (lineSeps.length === 0 || !colon || colon[1] > (lineSeps[0] as Range)[0]) break;
|
|
41
|
+
const label = collapse(src.slice(line[0], colon[0])).trim();
|
|
42
|
+
const cuts: Range[] = [colon, ...lineSeps];
|
|
43
|
+
const children = cuts.map((cut, i) => {
|
|
44
|
+
const end = i + 1 < cuts.length ? (cuts[i + 1] as Range)[0] : line[1];
|
|
45
|
+
return cleanItem(src.slice(cut[1], end), { last: i + 1 === cuts.length });
|
|
46
|
+
});
|
|
47
|
+
parents.push({ text: label, children });
|
|
48
|
+
}
|
|
49
|
+
if (parents.length !== lines.length) continue;
|
|
50
|
+
|
|
51
|
+
const edit = promote({
|
|
52
|
+
rule: RULE.STACKED_RUNS,
|
|
53
|
+
src,
|
|
54
|
+
view,
|
|
55
|
+
span: [view.startOffset, view.endOffset],
|
|
56
|
+
leadIn: null,
|
|
57
|
+
items: parents,
|
|
58
|
+
ordered: false,
|
|
59
|
+
});
|
|
60
|
+
if (edit) edits.push(edit);
|
|
61
|
+
}
|
|
62
|
+
return edits;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const pg009: Rule = {
|
|
66
|
+
id: RULE.STACKED_RUNS,
|
|
67
|
+
category: "list",
|
|
68
|
+
summary: "interpunct runs stacked on several lines (nested list)",
|
|
69
|
+
check: ({ file, runs }) =>
|
|
70
|
+
runs
|
|
71
|
+
.filter((r) => r.lines >= 2)
|
|
72
|
+
.map((r) => ({
|
|
73
|
+
file,
|
|
74
|
+
line: r.line,
|
|
75
|
+
rule: RULE.STACKED_RUNS,
|
|
76
|
+
message: `interpunct runs stacked on ${r.lines} lines; promote to a nested list`,
|
|
77
|
+
})),
|
|
78
|
+
fix,
|
|
79
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// The contract every rule implements: a check that reports findings, and an
|
|
2
|
+
// optional fixer that proposes edits. The fix engine, not the fixer, decides
|
|
3
|
+
// whether an edit is safe to keep.
|
|
4
|
+
|
|
5
|
+
import type { DocModel, ParagraphView, Range } from "../model.ts";
|
|
6
|
+
|
|
7
|
+
export const RULE = {
|
|
8
|
+
WRAP: "PG001",
|
|
9
|
+
LENGTH: "PG002",
|
|
10
|
+
SEMICOLON_LIST: "PG003",
|
|
11
|
+
EM_DASH: "PG004",
|
|
12
|
+
INTERPUNCT: "PG005",
|
|
13
|
+
INTERPUNCT_RUN: "PG006",
|
|
14
|
+
INLINE_ENUM: "PG007",
|
|
15
|
+
LABELLED_RUN: "PG008",
|
|
16
|
+
STACKED_RUNS: "PG009",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type RuleId = (typeof RULE)[keyof typeof RULE];
|
|
20
|
+
|
|
21
|
+
export interface Finding {
|
|
22
|
+
file: string;
|
|
23
|
+
line: number;
|
|
24
|
+
rule: RuleId;
|
|
25
|
+
message: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Paragraphs whose prose joins a run of items with 2+ interpunct separators.
|
|
29
|
+
// Shared by PG005, PG006 and PG009 as an explicit data dependency.
|
|
30
|
+
export interface InterpunctRun {
|
|
31
|
+
range: Range;
|
|
32
|
+
line: number;
|
|
33
|
+
separators: number;
|
|
34
|
+
lines: number; // source lines of the paragraph that carry a separator
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CheckContext {
|
|
38
|
+
doc: DocModel;
|
|
39
|
+
file: string;
|
|
40
|
+
maxWords: number;
|
|
41
|
+
runs: InterpunctRun[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// What the engine must be able to prove about the document after an edit.
|
|
45
|
+
export type Expectation =
|
|
46
|
+
// Whitespace only: the tree is unchanged once text whitespace is collapsed.
|
|
47
|
+
| { kind: "same-tree" }
|
|
48
|
+
// Glyphs swapped inside text: the tree keeps its shape and every word.
|
|
49
|
+
| { kind: "same-shape" }
|
|
50
|
+
// One paragraph becomes the blocks parsed from `fragment`, and nothing else
|
|
51
|
+
// in the document moves.
|
|
52
|
+
| { kind: "replace-paragraph"; view: ParagraphView; fragment: string; listItems: number };
|
|
53
|
+
|
|
54
|
+
export interface Edit {
|
|
55
|
+
rule: RuleId;
|
|
56
|
+
start: number;
|
|
57
|
+
end: number;
|
|
58
|
+
text: string;
|
|
59
|
+
expect: Expectation;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// What a rule guards, so related rules read as a group in help and RULES.md.
|
|
63
|
+
// sentence: how a sentence is laid out and how long it runs.
|
|
64
|
+
// list: a list hidden in running prose, promoted to a real markdown list.
|
|
65
|
+
// punctuation: a glyph that reads as generated text.
|
|
66
|
+
export const CATEGORIES = ["sentence", "list", "punctuation"] as const;
|
|
67
|
+
|
|
68
|
+
export type Category = (typeof CATEGORIES)[number];
|
|
69
|
+
|
|
70
|
+
export interface Rule {
|
|
71
|
+
id: RuleId;
|
|
72
|
+
category: Category;
|
|
73
|
+
summary: string;
|
|
74
|
+
check: (ctx: CheckContext) => Finding[];
|
|
75
|
+
// Absent for rules whose fix needs discretion (PG002).
|
|
76
|
+
fix?: (doc: DocModel) => Edit[];
|
|
77
|
+
}
|