@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
@@ -1,6 +1,7 @@
1
- // PG001: a newline inside a paragraph whose preceding non-space character
2
- // does not end a sentence is a mid-sentence wrap. The fix reflows the
3
- // paragraph to one sentence per line.
1
+ // sentence-one-per-line: a newline inside a paragraph whose preceding non-space character
2
+ // does not end a sentence is a mid-sentence wrap, and a sentence break with no
3
+ // newline puts several sentences on one line. The fix reflows the paragraph to
4
+ // one sentence per line, so the check reports every paragraph the fix changes.
4
5
 
5
6
  import { isStacked, paragraphsOf } from "../interpunct.ts";
6
7
  import type { ParagraphView } from "../model.ts";
@@ -32,17 +33,35 @@ const wrapsIn = (view: ParagraphView): number[] => {
32
33
  return wraps;
33
34
  };
34
35
 
35
- // The paragraph rewritten with one sentence per line. Code spans are masked
36
- // character for character before the split, keeping whitespace, so offsets
37
- // stay aligned and a terminal inside a code span never starts a new line.
36
+ // The paragraph with each code span masked character for character, keeping
37
+ // whitespace, so offsets stay aligned and a terminal inside a code span never
38
+ // reads as the end of a sentence.
39
+ const masked = (view: ParagraphView): string =>
40
+ [...view.raw].map((ch, i) => (inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch)).join("");
41
+
42
+ // Offsets within the paragraph of a sentence break that has no newline in it,
43
+ // one per line, since one finding per line is enough to name it.
44
+ const packedIn = (view: ParagraphView): number[] => {
45
+ const lines = new Set<number>();
46
+ const at: number[] = [];
47
+ const text = masked(view);
48
+ for (const match of text.matchAll(SENTENCE_BREAK)) {
49
+ if (match[0].includes("\n")) continue;
50
+ const offset = match.index ?? 0;
51
+ const line = text.slice(0, offset).split("\n").length;
52
+ if (lines.has(line)) continue;
53
+ lines.add(line);
54
+ at.push(offset);
55
+ }
56
+ return at;
57
+ };
58
+
59
+ // The paragraph rewritten with one sentence per line.
38
60
  const reflow = (view: ParagraphView): string => {
39
61
  const indent = " ".repeat(view.startColumn - 1);
40
62
  const eol = view.raw.includes("\r\n") ? "\r\n" : "\n";
41
- const masked = [...view.raw]
42
- .map((ch, i) => (inRanges(view.startOffset + i, view.codeRanges) && !/\s/.test(ch) ? "x" : ch))
43
- .join("");
44
63
  const joined = collapse(view.raw);
45
- const joinedMask = collapse(masked);
64
+ const joinedMask = collapse(masked(view));
46
65
 
47
66
  const lines: string[] = [];
48
67
  let from = 0;
@@ -55,21 +74,29 @@ const reflow = (view: ParagraphView): string => {
55
74
  return lines.map((line, i) => (i === 0 ? line.trim() : indent + line.trim())).join(eol);
56
75
  };
57
76
 
77
+ const lineOf = (view: ParagraphView, at: number): number => view.line + view.raw.slice(0, at).split("\n").length - 1;
78
+
58
79
  const check: Rule["check"] = ({ doc, file }) =>
59
- proseParagraphs(doc).flatMap((view) =>
60
- wrapsIn(view).map((at) => ({
80
+ proseParagraphs(doc).flatMap((view) => [
81
+ ...wrapsIn(view).map((at) => ({
61
82
  file,
62
- line: view.line + view.raw.slice(0, at).split("\n").length - 1,
83
+ line: lineOf(view, at),
63
84
  rule: RULE.WRAP,
64
85
  message: "mid-sentence line wrap; write one sentence per line",
65
86
  })),
66
- );
87
+ ...packedIn(view).map((at) => ({
88
+ file,
89
+ line: lineOf(view, at),
90
+ rule: RULE.WRAP,
91
+ message: "several sentences on one line; write one sentence per line",
92
+ })),
93
+ ]);
67
94
 
68
95
  const fix: Rule["fix"] = ({ doc, runs }) => {
69
96
  const edits: Edit[] = [];
70
- // Interpunct runs stacked on several lines are a nested list PG009 either
97
+ // Interpunct runs stacked on several lines are a nested list list-stacked-interpunct-runs either
71
98
  // promotes or leaves for a human. Joining the lines would destroy the
72
- // structure and hand PG006 a flat run it would merge wrongly.
99
+ // structure and hand list-interpunct-joined-run a flat run it would merge wrongly.
73
100
  const stacked = new Set(paragraphsOf(doc, runs.filter(isStacked)));
74
101
  for (const view of proseParagraphs(doc)) {
75
102
  // A hard break is structure the author chose, so the paragraph is left
@@ -90,10 +117,10 @@ const fix: Rule["fix"] = ({ doc, runs }) => {
90
117
  return edits;
91
118
  };
92
119
 
93
- export const pg001: Rule = {
120
+ export const sentenceOnePerLine: Rule = {
94
121
  id: RULE.WRAP,
95
122
  category: "sentence",
96
- summary: "mid-sentence line wrap (one sentence per line)",
123
+ summary: "mid-sentence line wrap or several sentences on one line (one sentence per line)",
97
124
  check,
98
125
  fix,
99
126
  };
@@ -0,0 +1,57 @@
1
+ // sentence-word-budget-exceeded: sentences over the word budget are hard to unpack, especially for
2
+ // ESL readers and translators. Never autofixed: shortening a sentence is a
3
+ // rewrite, and a rewrite needs discretion. The message estimates the clauses
4
+ // in the sentence so the rewrite splits it rather than compressing it.
5
+
6
+ import { type LanguageSentence, sentenceOf } from "../language.ts";
7
+ import { proseParagraphs } from "../query.ts";
8
+ import { words } from "../text.ts";
9
+ import { RULE, type Rule } from "./types.ts";
10
+
11
+ // A clause boundary is a semicolon or colon with text after it, a comma
12
+ // before a coordinating conjunction, or a subordinating or relative word.
13
+ // ", which" matches once, through the subordinator alone. An Oxford comma in
14
+ // a list reads as a boundary too.
15
+ const CLAUSE_BOUNDARY =
16
+ /[;:](?=\s+\S)|,\s+(?:and|but|or|so|yet|nor)\b|\b(?:because|although|though|whereas|unless|whenever|wherever|while|when|if|which|who|whom|whose)\b/gi;
17
+
18
+ // Two signals, and the count is the larger. Every clause has a verb, so the
19
+ // verb phrases compromise finds catch a series of verbs and a clause joined
20
+ // with no marker word. Its tagger misses some verbs, such as "reparses", so
21
+ // the punctuation and marker words stay as a floor. The count is a guide,
22
+ // not a parse.
23
+ export const clausesOf = (sentence: LanguageSentence): number =>
24
+ Math.max(1 + [...sentence.text.matchAll(CLAUSE_BOUNDARY)].length, sentence.verbPhrases);
25
+
26
+ export const countClauses = (sentence: string): number => clausesOf(sentenceOf(sentence));
27
+
28
+ const excerpt = (sentence: string): string => (sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence);
29
+
30
+ const messageOf = (sentence: LanguageSentence, maxWords: number): string => {
31
+ const count = words(sentence.text);
32
+ const clauses = clausesOf(sentence);
33
+ const target = Math.max(2, clauses, Math.ceil(count / maxWords));
34
+ const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
35
+ return `sentence has ${count} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence.text)}"`;
36
+ };
37
+
38
+ // The message for one sentence, as a plugin's helpers give it.
39
+ export const lengthMessage = (sentence: string, maxWords: number): string => messageOf(sentenceOf(sentence), maxWords);
40
+
41
+ // Sentences come from each paragraph's language view, so an abbreviation or
42
+ // a decimal never ends one early.
43
+ const check: Rule["check"] = ({ doc, file, maxWords }) =>
44
+ proseParagraphs(doc).flatMap((view) =>
45
+ view.language.sentences
46
+ .filter((sentence) => words(sentence.text) > maxWords)
47
+ .map((sentence) => ({ file, line: view.line, rule: RULE.LENGTH, message: messageOf(sentence, maxWords) })),
48
+ );
49
+
50
+ // No fixer: shortening a sentence changes its words, per PRS-0006.
51
+ export const sentenceWordBudgetExceeded: Rule = {
52
+ id: RULE.LENGTH,
53
+ category: "sentence",
54
+ summary: "sentence longer than the word budget (default 25)",
55
+ options: { maxWords: "number" },
56
+ check,
57
+ };
@@ -6,18 +6,31 @@ 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
+ PARAGRAPH_LENGTH: "paragraph-sentence-budget-exceeded",
18
19
  } as const;
19
20
 
20
- export type RuleId = (typeof RULE)[keyof typeof RULE];
21
+ // A core id has no slash. A plugin id is always namespace/name, so the slash
22
+ // is what tells the two apart.
23
+ export type CoreRuleId = (typeof RULE)[keyof typeof RULE];
24
+
25
+ // A rule function may be synchronous or return a promise, so a rule can call a tool
26
+ // or a service. The engine awaits every call.
27
+ export type Awaitable<T> = T | Promise<T>;
28
+
29
+ export type PluginRuleId = `${string}/${string}`;
30
+
31
+ export const isPluginRuleId = (id: string): id is PluginRuleId => id.includes("/");
32
+
33
+ export type RuleId = CoreRuleId | PluginRuleId;
21
34
 
22
35
  export interface Finding {
23
36
  file: string;
@@ -26,23 +39,50 @@ export interface Finding {
26
39
  message: string;
27
40
  }
28
41
 
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.
42
+ // The data every rule shares, computed once per document and handed to it.
43
+ // Shared data is passed in rather than recomputed, so a check and a fix can
44
+ // never disagree about what the document holds. `runs` is every interpunct
45
+ // run. `reported` is the runs some enabled rule owns, so a rule that defers
46
+ // to another stops deferring when that rule is switched off.
32
47
  export interface RuleContext {
33
48
  doc: DocModel;
34
49
  runs: InterpunctRun[];
50
+ reported: InterpunctRun[];
51
+ }
52
+
53
+ // What a rule may be configured with. A rule names each option it accepts
54
+ // and the type of its value, and the config is checked against that.
55
+ export type OptionType = "number" | "string" | "boolean" | "string[]";
56
+
57
+ export type OptionSpec = Readonly<Record<string, OptionType>>;
58
+
59
+ export type RuleOptions = Readonly<Record<string, unknown>>;
60
+
61
+ // Plain functions a plugin can use without importing this package, so a plugin
62
+ // never depends on resolving the host from its own location.
63
+ export interface TextHelpers {
64
+ words: (text: string) => number;
65
+ sentences: (text: string) => string[];
66
+ lengthMessage: (sentence: string, maxWords: number) => string;
35
67
  }
36
68
 
37
69
  // A check also names the file it reports against, and the sentence budget.
70
+ // `earlier` is what the rules of the categories in its `after` reported, and
71
+ // is empty for a rule that runs after none.
38
72
  export interface CheckContext extends RuleContext {
39
73
  file: string;
40
74
  maxWords: number;
75
+ earlier: readonly Finding[];
76
+ options: RuleOptions;
77
+ helpers: TextHelpers;
41
78
  }
42
79
 
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;
80
+ // A fix needs nothing beyond the shared context and its own options. It
81
+ // proposes edits, and the engine alone decides which of them survive.
82
+ export interface FixContext extends RuleContext {
83
+ options: RuleOptions;
84
+ helpers: TextHelpers;
85
+ }
46
86
 
47
87
  // What the engine must be able to prove about the document after an edit.
48
88
  export type Expectation =
@@ -50,6 +90,9 @@ export type Expectation =
50
90
  | { kind: "same-tree" }
51
91
  // Glyphs swapped inside text: the tree keeps its shape and every word.
52
92
  | { kind: "same-shape" }
93
+ // The frontmatter is written differently and means the same: the YAML parses to
94
+ // identical data before and after, and nothing outside it moves.
95
+ | { kind: "same-frontmatter-data" }
53
96
  // One paragraph becomes the blocks parsed from `fragment`, and nothing else
54
97
  // in the document moves.
55
98
  | { kind: "replace-paragraph"; view: ParagraphView; fragment: string; listItems: number };
@@ -66,15 +109,22 @@ export interface Edit {
66
109
  // sentence: how a sentence is laid out and how long it runs.
67
110
  // list: a list hidden in running prose, promoted to a real markdown list.
68
111
  // punctuation: a glyph that reads as generated text.
69
- export const CATEGORIES = ["sentence", "list", "punctuation"] as const;
112
+ // paragraph: how many sentences a paragraph holds before a break.
113
+ // A plugin may add a category of its own by declaring it.
114
+ export const CATEGORIES = ["sentence", "paragraph", "list", "punctuation"] as const;
70
115
 
71
116
  export type Category = (typeof CATEGORIES)[number];
72
117
 
73
118
  export interface Rule {
74
119
  id: RuleId;
75
- category: Category;
120
+ category: string;
76
121
  summary: string;
77
- check: (ctx: CheckContext) => Finding[];
78
- // Absent for rules whose fix needs discretion (PG002).
79
- fix?: (ctx: FixContext) => Edit[];
122
+ // The options the rule accepts. Absent when it takes none.
123
+ options?: OptionSpec;
124
+ // Categories whose findings this rule reads, so its check runs after theirs.
125
+ // Absent for a rule that reads no other rule's findings.
126
+ after?: readonly string[];
127
+ check: (ctx: CheckContext) => Awaitable<Finding[]>;
128
+ // Absent for rules whose fix needs discretion (sentence-word-budget-exceeded).
129
+ fix?: (ctx: FixContext) => Awaitable<Edit[]>;
80
130
  }
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 ?? {}));
@@ -1,45 +0,0 @@
1
- // PG002: sentences over the word budget are hard to unpack, especially for
2
- // ESL readers and translators. Never autofixed: shortening a sentence is a
3
- // rewrite, and a rewrite needs discretion. The message estimates the clauses
4
- // in the sentence so the rewrite splits it rather than compressing it.
5
-
6
- import { proseSentences } from "../query.ts";
7
- import { words } from "../text.ts";
8
- import { RULE, type Rule } from "./types.ts";
9
-
10
- // A clause boundary is a semicolon or colon with text after it, a comma
11
- // before a coordinating conjunction, or a subordinating or relative word.
12
- // ", which" matches once, through the subordinator alone. The count is a
13
- // guide, not a parse: an Oxford comma in a list reads as a boundary too.
14
- const CLAUSE_BOUNDARY =
15
- /[;:](?=\s+\S)|,\s+(?:and|but|or|so|yet|nor)\b|\b(?:because|although|though|whereas|unless|whenever|wherever|while|when|if|which|who|whom|whose)\b/gi;
16
-
17
- export const countClauses = (sentence: string): number => 1 + [...sentence.matchAll(CLAUSE_BOUNDARY)].length;
18
-
19
- const excerpt = (sentence: string): string => (sentence.length > 60 ? `${sentence.slice(0, 60)}...` : sentence);
20
-
21
- export const lengthMessage = (sentence: string, maxWords: number): string => {
22
- const count = words(sentence);
23
- const clauses = countClauses(sentence);
24
- const target = Math.max(2, clauses, Math.ceil(count / maxWords));
25
- const clauseNote = `${clauses} potential clause${clauses === 1 ? "" : "s"}`;
26
- return `sentence has ${count} words (budget ${maxWords}) and ${clauseNote}; split it into about ${target} shorter sentences, one idea each, rather than compressing the wording: "${excerpt(sentence)}"`;
27
- };
28
-
29
- const check: Rule["check"] = ({ doc, file, maxWords }) =>
30
- proseSentences(doc)
31
- .filter(({ sentence }) => words(sentence) > maxWords)
32
- .map(({ view, sentence }) => ({
33
- file,
34
- line: view.line,
35
- rule: RULE.LENGTH,
36
- message: lengthMessage(sentence, maxWords),
37
- }));
38
-
39
- // No fixer: shortening a sentence changes its words, per PRS-0006.
40
- export const pg002: Rule = {
41
- id: RULE.LENGTH,
42
- category: "sentence",
43
- summary: "sentence longer than the word budget (default 25)",
44
- check,
45
- };
@@ -1,68 +0,0 @@
1
- // PG006: 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 { colonsBefore, colonsIn, sentenceSpan, splittableSeparators } from "../query.ts";
9
- import { first, last, type Range, words } from "../text.ts";
10
- import { type Edit, RULE, type Rule } from "./types.ts";
11
-
12
- // A last item this much longer than every other is a sentence that happens
13
- // to follow the list, not a member of it.
14
- const tailIsProse = (items: string[]): boolean =>
15
- words(last(items)) > Math.max(8, 3 * Math.max(...items.slice(0, -1).map(words)));
16
-
17
- // Without a colon, a first item longer than the rest ("Built with Bun")
18
- // carries a clause the list would strip of its meaning.
19
- const headIsProse = (items: string[]): boolean => words(first(items)) > Math.max(...items.slice(1).map(words));
20
-
21
- const check: Rule["check"] = ({ file, runs }) =>
22
- runs.filter(isFlat).map((run) => ({
23
- file,
24
- line: run.line,
25
- rule: RULE.INTERPUNCT_RUN,
26
- message: `interpunct-joined inline list (${run.separators + 1} items); promote to a bullet list`,
27
- }));
28
-
29
- const fix: Rule["fix"] = ({ doc, runs }) => {
30
- const { src } = doc;
31
- const edits: Edit[] = [];
32
- for (const view of paragraphsOf(doc, runs.filter(isFlat))) {
33
- const separators = splittableSeparators(src, view, RUN_SEPARATOR, GLYPH);
34
- const colons = colonsIn(src, view);
35
- if (separators === null || colons === null) continue;
36
-
37
- const span = sentenceSpan(src, view, first(separators)[0], last(separators)[1]);
38
- // The innermost colon before the first item, so a lead-in that itself
39
- // contains a colon keeps only its final clause.
40
- const colon = colonsBefore(colons, span, first(separators)[0]).pop();
41
- // With no colon to announce it, the run has to be the whole paragraph.
42
- if (!colon && (span[0] !== view.startOffset || span[1] !== view.endOffset)) continue;
43
-
44
- const cuts: Range[] = [colon ?? [span[0], span[0]], ...separators];
45
- const items = itemsBetween(src, cuts, span[1]);
46
- if (tailIsProse(items) || (!colon && headIsProse(items))) continue;
47
-
48
- const edit = promote({
49
- rule: RULE.INTERPUNCT_RUN,
50
- src,
51
- view,
52
- span,
53
- leadIn: colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
54
- items: items.map((text) => ({ text })),
55
- ordered: false,
56
- });
57
- if (edit) edits.push(edit);
58
- }
59
- return edits;
60
- };
61
-
62
- export const pg006: Rule = {
63
- id: RULE.INTERPUNCT_RUN,
64
- category: "list",
65
- summary: "interpunct-joined inline list (2+ separators)",
66
- check,
67
- fix,
68
- };
@@ -1,101 +0,0 @@
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 { 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
- const fix: Rule["fix"] = ({ doc }) => {
57
- const { src } = doc;
58
- const edits: Edit[] = [];
59
- for (const view of doc.paragraphs) {
60
- const commas = matchesInAll(src, view.directTexts, /,(?=\s)/);
61
- const colons = colonsIn(src, view);
62
- if (commas === null || colons === null) continue;
63
- for (const span of sentenceSpans(src, view)) {
64
- const inside = commas.filter((comma) => within(comma, span));
65
- if (inside.length < 2) continue;
66
- // The sentence start is the cut before the first segment, so the spans
67
- // between the cuts are exactly the comma-separated segments.
68
- const segments = spansBetween([[span[0], span[0]], ...inside], span[1]);
69
- if (!segments.every((segment) => LABELLED_SEGMENT.test(asProse(src, view, segment)))) continue;
70
-
71
- // An intro colon in the first segment, before its label, is the lead-in.
72
- // The first item then starts after it, so the intro is not repeated as
73
- // both the lead-in and the head of the first bullet.
74
- const opening = first(segments);
75
- const colon = colons.find((c) => within(c, opening));
76
- const intro = colon !== undefined && INTRO_ONLY.test(collapse(src.slice(span[0], colon[0])).trim());
77
- const items: Range[] = intro && colon ? [[colon[1], opening[1]], ...segments.slice(1)] : segments;
78
-
79
- const edit = promote({
80
- rule: RULE.LABELLED_RUN,
81
- src,
82
- view,
83
- span,
84
- leadIn: intro && colon ? cleanLeadIn(src.slice(span[0], colon[0])) : null,
85
- items: itemsOf(src, items).map((text) => ({ text })),
86
- ordered: false,
87
- });
88
- if (edit) edits.push(edit);
89
- break; // one promotion per paragraph; the engine re-parses and comes back
90
- }
91
- }
92
- return edits;
93
- };
94
-
95
- export const pg008: Rule = {
96
- id: RULE.LABELLED_RUN,
97
- category: "list",
98
- summary: "comma-joined labelled run (3+ labelled segments)",
99
- check,
100
- fix,
101
- };