@jpeakai/prose-gates 0.2.0 → 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 +16 -10
- package/dist/bin.js +21370 -722
- package/dist/index.js +21379 -724
- package/dist/testing.js +21058 -555
- package/package.json +3 -1
- package/src/check.ts +19 -7
- package/src/cli.ts +80 -50
- package/src/cohesion.ts +70 -0
- package/src/concepts.ts +209 -0
- package/src/errors.ts +21 -0
- package/src/fix/engine.ts +77 -59
- package/src/fix/verify.ts +39 -27
- package/src/index.ts +13 -0
- package/src/language.ts +107 -0
- package/src/model.ts +176 -102
- package/src/plugins/discover.ts +19 -13
- package/src/plugins/index.ts +66 -33
- package/src/plugins/load.ts +73 -33
- package/src/project.ts +5 -2
- package/src/rules/index.ts +2 -0
- package/src/rules/list-comma-labelled-run.ts +50 -34
- package/src/rules/list-inline-enumeration-markers.ts +54 -43
- package/src/rules/list-interpunct-joined-run.ts +31 -28
- package/src/rules/paragraph-sentence-budget-exceeded.ts +64 -0
- package/src/rules/registry.ts +12 -2
- package/src/rules/sentence-one-per-line.ts +41 -14
- package/src/rules/sentence-word-budget-exceeded.ts +27 -16
- package/src/rules/types.ts +9 -1
package/src/fix/engine.ts
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// edit that survives verification. Offsets are only ever valid for the
|
|
4
4
|
// source they were computed from, so every accepted edit restarts the pass.
|
|
5
5
|
|
|
6
|
+
import type { Root } from "mdast";
|
|
6
7
|
import { buildDocModel, parse } from "../model.ts";
|
|
7
8
|
import { BUILTIN, type Registry, ruleContext, TEXT_HELPERS } from "../rules/registry.ts";
|
|
8
|
-
import type { Edit } from "../rules/types.ts";
|
|
9
|
+
import type { Edit, Rule, RuleContext } from "../rules/types.ts";
|
|
9
10
|
import { verify } from "./verify.ts";
|
|
10
11
|
|
|
11
12
|
const MAX_PASSES = 10_000;
|
|
@@ -37,67 +38,84 @@ const wellFormed = (e: Edit, length: number): boolean =>
|
|
|
37
38
|
e.end <= length &&
|
|
38
39
|
typeof e.text === "string";
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
// What the loop carries between passes. An edit refused once stays refused
|
|
42
|
+
// while its source slice is unchanged, which `refusedKeys` remembers.
|
|
43
|
+
interface FixState {
|
|
44
|
+
current: string;
|
|
45
|
+
applied: Edit[];
|
|
46
|
+
refused: Edit[];
|
|
47
|
+
refusedKeys: Set<string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type Before = { src: string; tree: Root };
|
|
51
|
+
|
|
52
|
+
const keyOf = (state: FixState, e: Edit): string =>
|
|
53
|
+
`${e.rule}\u0000${state.current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
54
|
+
|
|
55
|
+
// Splice the edits, re-parse, and keep the result only if every edit is
|
|
56
|
+
// proven against the tree the pass started from.
|
|
57
|
+
const attempt = (state: FixState, edits: Edit[], before: Before): boolean => {
|
|
58
|
+
const next = splice(state.current, edits);
|
|
59
|
+
const afterTree = parse(next);
|
|
60
|
+
if (!edits.every((e) => verify(e, before, afterTree) === "accept")) return false;
|
|
61
|
+
state.current = next;
|
|
62
|
+
state.applied.push(...edits);
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// An edit that points outside the source cannot be proven, so it is
|
|
67
|
+
// refused before it is ever spliced. A built-in never proposes one.
|
|
68
|
+
const keepWellFormed = (state: FixState, proposed: Edit[]): Edit[] =>
|
|
69
|
+
proposed.filter((e) => {
|
|
70
|
+
if (wellFormed(e, state.current.length)) return true;
|
|
71
|
+
const key = `${e.rule}\u0000malformed\u0000${e.start}:${e.end}`;
|
|
72
|
+
if (!state.refusedKeys.has(key)) {
|
|
73
|
+
state.refusedKeys.add(key);
|
|
74
|
+
state.refused.push(e);
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
});
|
|
46
78
|
|
|
79
|
+
// The rule's edits that would change something and have not been refused.
|
|
80
|
+
const proposeEdits = async (state: FixState, rule: Rule, ctx: RuleContext, registry: Registry): Promise<Edit[]> => {
|
|
81
|
+
const proposed =
|
|
82
|
+
(await rule.fix?.({ ...ctx, options: registry.options.get(rule.id) ?? {}, helpers: TEXT_HELPERS })) ?? [];
|
|
83
|
+
const sound = keepWellFormed(state, proposed);
|
|
84
|
+
return sound.filter((e) => state.current.slice(e.start, e.end) !== e.text && !state.refusedKeys.has(keyOf(state, e)));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Whitespace and glyph edits leave the tree's shape alone, so a rule's
|
|
88
|
+
// whole batch can be proven in one re-parse. A batch that fails falls
|
|
89
|
+
// back to one edit at a time, so one bad edit never blocks the rest.
|
|
90
|
+
const applyRuleEdits = (state: FixState, edits: Edit[], before: Before): boolean => {
|
|
91
|
+
const batchable = edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");
|
|
92
|
+
if (batchable && attempt(state, edits, before)) return true;
|
|
93
|
+
for (const edit of edits) {
|
|
94
|
+
if (attempt(state, [edit], before)) return true;
|
|
95
|
+
state.refusedKeys.add(keyOf(state, edit));
|
|
96
|
+
state.refused.push(edit);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// One pass: rules in fix order until one edit lands. True when it did.
|
|
102
|
+
const runPass = async (state: FixState, registry: Registry): Promise<boolean> => {
|
|
103
|
+
const ctx = ruleContext(buildDocModel(state.current, registry.concepts), registry);
|
|
104
|
+
const before = { src: state.current, tree: ctx.doc.tree };
|
|
105
|
+
for (const rule of registry.fixOrder) {
|
|
106
|
+
const edits = await proposeEdits(state, rule, ctx, registry);
|
|
107
|
+
if (edits.length === 0) continue;
|
|
108
|
+
if (applyRuleEdits(state, edits, before)) return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const fixMarkdownReport = async (src: string, registry: Registry = BUILTIN): Promise<FixReport> => {
|
|
114
|
+
const state: FixState = { current: src, applied: [], refused: [], refusedKeys: new Set<string>() };
|
|
47
115
|
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
|
|
51
|
-
|
|
52
|
-
const attempt = (edits: Edit[]): boolean => {
|
|
53
|
-
const next = splice(current, edits);
|
|
54
|
-
const afterTree = parse(next);
|
|
55
|
-
if (edits.every((e) => verify(e, before, afterTree) === "accept")) {
|
|
56
|
-
current = next;
|
|
57
|
-
applied.push(...edits);
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
return false;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
let progressed = false;
|
|
64
|
-
for (const rule of registry.fixOrder) {
|
|
65
|
-
const proposed =
|
|
66
|
-
(await rule.fix?.({ ...ctx, options: registry.options.get(rule.id) ?? {}, helpers: TEXT_HELPERS })) ?? [];
|
|
67
|
-
// An edit that points outside the source cannot be proven, so it is
|
|
68
|
-
// refused before it is ever spliced. A built-in never proposes one.
|
|
69
|
-
const sound = proposed.filter((e) => {
|
|
70
|
-
if (wellFormed(e, current.length)) return true;
|
|
71
|
-
const key = `${e.rule}\u0000malformed\u0000${e.start}:${e.end}`;
|
|
72
|
-
if (!refusedKeys.has(key)) {
|
|
73
|
-
refusedKeys.add(key);
|
|
74
|
-
refused.push(e);
|
|
75
|
-
}
|
|
76
|
-
return false;
|
|
77
|
-
});
|
|
78
|
-
const edits = sound.filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
|
|
79
|
-
if (edits.length === 0) continue;
|
|
80
|
-
|
|
81
|
-
// Whitespace and glyph edits leave the tree's shape alone, so a rule's
|
|
82
|
-
// whole batch can be proven in one re-parse. A batch that fails falls
|
|
83
|
-
// back to one edit at a time, so one bad edit never blocks the rest.
|
|
84
|
-
const batchable =
|
|
85
|
-
edits.length > 1 && !overlaps(edits) && edits.every((e) => e.expect.kind !== "replace-paragraph");
|
|
86
|
-
if (batchable && attempt(edits)) {
|
|
87
|
-
progressed = true;
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
for (const edit of edits) {
|
|
91
|
-
if (attempt([edit])) {
|
|
92
|
-
progressed = true;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
refusedKeys.add(keyOf(edit));
|
|
96
|
-
refused.push(edit);
|
|
97
|
-
}
|
|
98
|
-
if (progressed) break;
|
|
116
|
+
if (!(await runPass(state, registry))) {
|
|
117
|
+
return { output: state.current, applied: state.applied, refused: state.refused };
|
|
99
118
|
}
|
|
100
|
-
if (!progressed) return { output: current, applied, refused };
|
|
101
119
|
}
|
|
102
120
|
throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
|
|
103
121
|
};
|
package/src/fix/verify.ts
CHANGED
|
@@ -128,36 +128,48 @@ const withoutFrontmatter = (tree: Root): Root =>
|
|
|
128
128
|
|
|
129
129
|
export type Verdict = "accept" | "refuse";
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// No word may be lost or moved. A mismatch is a fixer bug, so it throws and
|
|
132
|
+
// names the first word where the two fingerprints part.
|
|
133
|
+
const assertNoWordLost = (edit: Edit, beforeTree: Root, afterTree: Root): void => {
|
|
134
|
+
const lost = fingerprint(beforeTree).join(" ");
|
|
133
135
|
const kept = fingerprint(afterTree).join(" ");
|
|
134
|
-
if (lost
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
if (lost === kept) return;
|
|
137
|
+
const a = lost.split(" ");
|
|
138
|
+
const b = kept.split(" ");
|
|
139
|
+
const diverge = a.findIndex((w, i) => w !== b[i]);
|
|
140
|
+
const at = diverge === -1 ? a.length : diverge;
|
|
141
|
+
throw new FixInvariantError(edit, `word ${at} was "${a[at] ?? ""}", now "${b[at] ?? ""}"`);
|
|
142
|
+
};
|
|
141
143
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
144
|
+
const verdictOf = (ok: boolean): Verdict => (ok ? "accept" : "refuse");
|
|
145
|
+
|
|
146
|
+
// The frontmatter must mean the same data, and the rest of the tree must be
|
|
147
|
+
// untouched.
|
|
148
|
+
const sameFrontmatterData = (beforeTree: Root, afterTree: Root): Verdict => {
|
|
149
|
+
const was = frontmatterData(beforeTree);
|
|
150
|
+
const now = frontmatterData(afterTree);
|
|
151
|
+
if (was === INVALID || now === INVALID || !sameJson(was, now)) return "refuse";
|
|
152
|
+
return verdictOf(sameJson(normalise(withoutFrontmatter(beforeTree)), normalise(withoutFrontmatter(afterTree))));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
type ReplaceParagraph = Extract<Edit["expect"], { kind: "replace-paragraph" }>;
|
|
156
|
+
|
|
157
|
+
// The paragraph must become exactly the fragment the fixer declared, parsed
|
|
158
|
+
// with the document's definitions, and nothing else may change.
|
|
159
|
+
const replacedParagraph = (expect: ReplaceParagraph, before: { src: string; tree: Root }, afterTree: Root): Verdict => {
|
|
159
160
|
const nodes = parseFragment(expect.fragment, definitionsOf(before.src, before.tree));
|
|
160
161
|
if (!fragmentShapeOk(nodes, expect.listItems)) return "refuse";
|
|
161
162
|
const expected = normalise(before.tree, { swap: [expect.view.node, nodes] });
|
|
162
|
-
return sameJson(expected, normalise(afterTree))
|
|
163
|
+
return verdictOf(sameJson(expected, normalise(afterTree)));
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const verify = (edit: Edit, before: { src: string; tree: Root }, afterTree: Root): Verdict => {
|
|
167
|
+
assertNoWordLost(edit, before.tree, afterTree);
|
|
168
|
+
const expect = edit.expect;
|
|
169
|
+
if (expect.kind === "same-tree") return verdictOf(sameJson(normalise(before.tree), normalise(afterTree)));
|
|
170
|
+
if (expect.kind === "same-frontmatter-data") return sameFrontmatterData(before.tree, afterTree);
|
|
171
|
+
if (expect.kind === "same-shape") {
|
|
172
|
+
return verdictOf(sameJson(normalise(before.tree, { blankText: true }), normalise(afterTree, { blankText: true })));
|
|
173
|
+
}
|
|
174
|
+
return replacedParagraph(expect, before, afterTree);
|
|
163
175
|
};
|
package/src/index.ts
CHANGED
|
@@ -2,16 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
export { checkMarkdown, checkModel, DEFAULT_MAX_WORDS } from "./check.ts";
|
|
4
4
|
export { main } from "./cli.ts";
|
|
5
|
+
export {
|
|
6
|
+
CONCEPT_FILES,
|
|
7
|
+
type Concept,
|
|
8
|
+
type ConceptMention,
|
|
9
|
+
type ConceptModel,
|
|
10
|
+
type ConceptRelation,
|
|
11
|
+
KIND_OF,
|
|
12
|
+
loadConcepts,
|
|
13
|
+
NO_CONCEPTS,
|
|
14
|
+
parseConcepts,
|
|
15
|
+
} from "./concepts.ts";
|
|
5
16
|
export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSetting, type Severity } from "./config.ts";
|
|
6
17
|
export * from "./errors.ts";
|
|
7
18
|
export { type FixReport, fixMarkdown, fixMarkdownReport } from "./fix/engine.ts";
|
|
8
19
|
export { FixInvariantError } from "./fix/verify.ts";
|
|
20
|
+
export type { LanguageSentence, LanguageView, LanguageWord } from "./language.ts";
|
|
9
21
|
export {
|
|
10
22
|
buildDocModel,
|
|
11
23
|
type DocModel,
|
|
12
24
|
type FrontmatterEntry,
|
|
13
25
|
type FrontmatterScalar,
|
|
14
26
|
type FrontmatterView,
|
|
27
|
+
type ParagraphView,
|
|
15
28
|
type ScalarStyle,
|
|
16
29
|
} from "./model.ts";
|
|
17
30
|
export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
|
package/src/language.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The language model of a paragraph: its sentences, and each sentence's words
|
|
2
|
+
// with their part-of-speech tags and lemmas. Everything here takes a string and
|
|
3
|
+
// returns plain data. compromise does the work, pinned exactly so a finding
|
|
4
|
+
// never changes under an upgrade: it splits the sentences, tags each word and
|
|
5
|
+
// gives its lemma. A paragraph exposes this as its lazy `language` view, so a
|
|
6
|
+
// rule that never reads it pays nothing.
|
|
7
|
+
|
|
8
|
+
import nlp from "compromise";
|
|
9
|
+
import { type ConceptMention, type ConceptModel, mentionsIn, NO_CONCEPTS } from "./concepts.ts";
|
|
10
|
+
|
|
11
|
+
// One word of a sentence. `lemma` is lower case, so "Parsing" reads as "parse".
|
|
12
|
+
// `content` is true for a noun, verb or adjective that is not a pronoun or an
|
|
13
|
+
// auxiliary, and never for the CODE token that stands for inline code.
|
|
14
|
+
export interface LanguageWord {
|
|
15
|
+
text: string;
|
|
16
|
+
lemma: string;
|
|
17
|
+
tags: readonly string[];
|
|
18
|
+
content: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// One sentence, as the paragraph's prose holds it, with inline code as CODE.
|
|
22
|
+
// `terms` is the lemmas of its content words. `opener` is CODE when the
|
|
23
|
+
// sentence opens with inline code, else the lemma of a content word it opens
|
|
24
|
+
// with, else null. `refersBack` is true when its first word is a pronoun, a
|
|
25
|
+
// possessive, an adverb or a connective: "It", "Its", "Then", "However".
|
|
26
|
+
// `verbPhrases` is how many verb phrases compromise finds, with an auxiliary
|
|
27
|
+
// counted inside its verb, so "is shared" is one. `concepts` is each place it
|
|
28
|
+
// names a concept of the project's concept file, empty when it has none.
|
|
29
|
+
export interface LanguageSentence {
|
|
30
|
+
text: string;
|
|
31
|
+
words: readonly LanguageWord[];
|
|
32
|
+
terms: ReadonlySet<string>;
|
|
33
|
+
opener: string | null;
|
|
34
|
+
refersBack: boolean;
|
|
35
|
+
verbPhrases: number;
|
|
36
|
+
concepts: readonly ConceptMention[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LanguageView {
|
|
40
|
+
sentences: readonly LanguageSentence[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const CONTENT = new Set(["Noun", "Verb", "Adjective"]);
|
|
44
|
+
const FUNCTION = new Set(["Pronoun", "Possessive", "Auxiliary", "Copula", "Modal", "Determiner"]);
|
|
45
|
+
const REFERS_BACK = new Set(["Pronoun", "Possessive", "Adverb", "Conjunction"]);
|
|
46
|
+
|
|
47
|
+
// The shape compromise returns for a term once compute("root") has run.
|
|
48
|
+
interface TaggedTerm {
|
|
49
|
+
text: string;
|
|
50
|
+
normal: string;
|
|
51
|
+
root?: string;
|
|
52
|
+
tags: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const wordOf = (term: TaggedTerm): LanguageWord => ({
|
|
56
|
+
text: term.text,
|
|
57
|
+
lemma: (term.root ?? term.normal).toLowerCase(),
|
|
58
|
+
tags: term.tags,
|
|
59
|
+
content: term.text !== "CODE" && term.tags.some((t) => CONTENT.has(t)) && !term.tags.some((t) => FUNCTION.has(t)),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// One tagging pass gives the words and the verb phrases.
|
|
63
|
+
const tag = (sentence: string): { words: LanguageWord[]; verbPhrases: number } => {
|
|
64
|
+
const doc = nlp(sentence);
|
|
65
|
+
// A participle between a determiner, adjective or number and a noun
|
|
66
|
+
// describes the noun, as in "the signed tag", so it is no verb phrase.
|
|
67
|
+
doc.match("(#Determiner|#Adjective|#Value) [(#PastTense|#Participle)] #Noun", 0).tag("Adjective");
|
|
68
|
+
doc.compute("root");
|
|
69
|
+
// compromise's option types omit root, which compute("root") adds.
|
|
70
|
+
const rows = doc.json({ terms: { normal: true, root: true, tags: true } } as object) as { terms: TaggedTerm[] }[];
|
|
71
|
+
return { words: rows.flatMap((row) => row.terms).map(wordOf), verbPhrases: doc.verbs().length };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const openerOf = (first: LanguageWord | undefined): string | null => {
|
|
75
|
+
if (!first) return null;
|
|
76
|
+
if (first.text === "CODE") return "CODE";
|
|
77
|
+
return first.content ? first.lemma : null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// One sentence's words and what they say about cohesion, and the concepts it names.
|
|
81
|
+
const sentenceWith = (text: string, concepts: ConceptModel): LanguageSentence => {
|
|
82
|
+
const { words, verbPhrases } = tag(text);
|
|
83
|
+
const first = words[0];
|
|
84
|
+
return {
|
|
85
|
+
text,
|
|
86
|
+
words,
|
|
87
|
+
terms: new Set(words.filter((w) => w.content).map((w) => w.lemma)),
|
|
88
|
+
opener: openerOf(first),
|
|
89
|
+
refersBack: first?.tags.some((t) => REFERS_BACK.has(t)) ?? false,
|
|
90
|
+
verbPhrases,
|
|
91
|
+
concepts: mentionsIn(words, concepts),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// One sentence on its own, with no concepts. It takes one argument, so it is
|
|
96
|
+
// safe to pass to map.
|
|
97
|
+
export const sentenceOf = (text: string): LanguageSentence => sentenceWith(text, NO_CONCEPTS);
|
|
98
|
+
|
|
99
|
+
// The sentences of a paragraph's prose. compromise keeps abbreviations such
|
|
100
|
+
// as e.g. and Dr., decimals and urls inside the sentence they belong to, and
|
|
101
|
+
// still ends one after a number or before a lower-case word.
|
|
102
|
+
export const sentenceTexts = (prose: string): string[] =>
|
|
103
|
+
(nlp(prose).sentences().out("array") as string[]).map((s) => s.trim()).filter((s) => s.length > 0);
|
|
104
|
+
|
|
105
|
+
export const languageOf = (prose: string, concepts: ConceptModel = NO_CONCEPTS): LanguageView => ({
|
|
106
|
+
sentences: sentenceTexts(prose).map((s) => sentenceWith(s, concepts)),
|
|
107
|
+
});
|