@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
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
|
+
});
|
package/src/model.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
|
9
9
|
import { frontmatter } from "micromark-extension-frontmatter";
|
|
10
10
|
import { gfm } from "micromark-extension-gfm";
|
|
11
11
|
import { visitParents } from "unist-util-visit-parents";
|
|
12
|
+
import { type Document, isMap, isPair, isScalar, isSeq, LineCounter, parseDocument, type Scalar } from "yaml";
|
|
13
|
+
import { type ConceptModel, NO_CONCEPTS } from "./concepts.ts";
|
|
14
|
+
import { type LanguageView, languageOf } from "./language.ts";
|
|
12
15
|
import type { Range } from "./text.ts";
|
|
13
16
|
|
|
14
17
|
// Fenced blocks in these languages hold markdown templates (a conventions or
|
|
@@ -42,6 +45,8 @@ export interface ParagraphView {
|
|
|
42
45
|
inBlockquote: boolean;
|
|
43
46
|
inListItem: boolean;
|
|
44
47
|
onlyChild: boolean; // the paragraph is its parent's only block
|
|
48
|
+
// Its sentences, words, tags and lemmas, read from `prose` on first use.
|
|
49
|
+
readonly language: LanguageView;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface TextView {
|
|
@@ -59,12 +64,60 @@ export interface FenceView {
|
|
|
59
64
|
line: number; // line of the opening fence
|
|
60
65
|
}
|
|
61
66
|
|
|
67
|
+
// One top-level key of the frontmatter whose value is a string.
|
|
68
|
+
export interface FrontmatterEntry {
|
|
69
|
+
key: string;
|
|
70
|
+
value: string; // the scalar as text, folded lines joined
|
|
71
|
+
line: number; // file line where the value starts
|
|
72
|
+
start: number; // source offsets of the value, quotes included
|
|
73
|
+
end: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// How a YAML string is written, which a fixer needs to rewrite it safely.
|
|
77
|
+
export type ScalarStyle = "plain" | "double" | "single" | "literal" | "folded";
|
|
78
|
+
|
|
79
|
+
// A string value anywhere in the frontmatter, found by walking the whole YAML
|
|
80
|
+
// document in order. A value in a nested map or a list is here too, with the
|
|
81
|
+
// path that leads to it.
|
|
82
|
+
export interface FrontmatterScalar {
|
|
83
|
+
path: (string | number)[]; // keys and list indexes from the document root
|
|
84
|
+
key: string | null; // the nearest map key above it, at any depth
|
|
85
|
+
keyLine: number | null; // file line of that key
|
|
86
|
+
value: string; // the string as YAML reads it
|
|
87
|
+
line: number; // file line where the value starts
|
|
88
|
+
start: number; // absolute source offsets of the value, quotes included
|
|
89
|
+
end: number;
|
|
90
|
+
style: ScalarStyle;
|
|
91
|
+
indent: number; // leading spaces of the line the key, or the value, sits on
|
|
92
|
+
node: Scalar; // the yaml package node, for anything this view does not carry
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FrontmatterView {
|
|
96
|
+
format: "yaml";
|
|
97
|
+
// The top-level string entries, kept for the common flat case.
|
|
98
|
+
entries: FrontmatterEntry[];
|
|
99
|
+
// Every string scalar at any depth, in document order.
|
|
100
|
+
scalars: FrontmatterScalar[];
|
|
101
|
+
// The whole parsed document, from the yaml package, for full traversal.
|
|
102
|
+
document: Document.Parsed;
|
|
103
|
+
// Absolute offset in `src` where the YAML text starts, so a node range from
|
|
104
|
+
// `document` maps to the source as offset + range.
|
|
105
|
+
offset: number;
|
|
106
|
+
// The file line of an absolute source offset inside the block.
|
|
107
|
+
lineOf: (absoluteOffset: number) => number;
|
|
108
|
+
}
|
|
109
|
+
|
|
62
110
|
export interface DocModel {
|
|
63
111
|
src: string;
|
|
64
112
|
tree: Root;
|
|
65
113
|
paragraphs: ParagraphView[];
|
|
66
114
|
texts: TextView[]; // every prose text node, including headings and lists
|
|
67
115
|
fences: FenceView[]; // embedded-markdown fences, audited recursively
|
|
116
|
+
// The project's concepts, which every paragraph's language view names.
|
|
117
|
+
concepts: ConceptModel;
|
|
118
|
+
// The leading YAML block as keys and values, read on first use so a document
|
|
119
|
+
// whose frontmatter is not valid YAML only fails the rules that ask for it.
|
|
120
|
+
readonly frontmatter: FrontmatterView | null;
|
|
68
121
|
}
|
|
69
122
|
|
|
70
123
|
export const parse = (src: string): Root =>
|
|
@@ -76,78 +129,228 @@ export const parse = (src: string): Root =>
|
|
|
76
129
|
mdastExtensions: [frontmatterFromMarkdown(["yaml", "toml"]), gfmFromMarkdown()],
|
|
77
130
|
});
|
|
78
131
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
132
|
+
// The views a single walk of the tree fills in, in document order.
|
|
133
|
+
interface WalkState {
|
|
134
|
+
src: string;
|
|
135
|
+
paragraphs: ParagraphView[];
|
|
136
|
+
texts: TextView[];
|
|
137
|
+
fences: FenceView[];
|
|
138
|
+
viewOf: Map<Paragraph, ParagraphView>;
|
|
139
|
+
proseParts: Map<Paragraph, string[]>;
|
|
140
|
+
concepts: ConceptModel;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// A markdown-tagged fence is kept as a template to audit; any other code is exempt.
|
|
144
|
+
const visitCode = (state: WalkState, c: Code): void => {
|
|
145
|
+
if (c.lang != null && MARKDOWN_FENCE_LANGS.has(c.lang) && c.position) {
|
|
146
|
+
state.fences.push({ value: c.value, line: c.position.start.line });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// A paragraph opens a view that its descendants fill in as the walk reaches them.
|
|
151
|
+
const visitParagraph = (state: WalkState, p: Paragraph, ancestors: Node[]): void => {
|
|
152
|
+
const pos = p.position;
|
|
153
|
+
if (!pos || pos.start.offset == null || pos.end.offset == null) return;
|
|
154
|
+
const parent = ancestors.at(-1) as Parent | undefined;
|
|
155
|
+
const view: ParagraphView = {
|
|
156
|
+
node: p,
|
|
157
|
+
raw: state.src.slice(pos.start.offset, pos.end.offset),
|
|
158
|
+
line: pos.start.line,
|
|
159
|
+
startOffset: pos.start.offset,
|
|
160
|
+
endOffset: pos.end.offset,
|
|
161
|
+
startColumn: pos.start.column,
|
|
162
|
+
prose: "",
|
|
163
|
+
codeRanges: [],
|
|
164
|
+
directTexts: [],
|
|
165
|
+
hasBreak: false,
|
|
166
|
+
inTable: ancestors.some((a) => a.type === "table" || a.type === "tableCell"),
|
|
167
|
+
inBlockquote: ancestors.some((a) => a.type === "blockquote"),
|
|
168
|
+
inListItem: ancestors.some((a) => a.type === "listItem"),
|
|
169
|
+
onlyChild: parent !== undefined && parent.children.length === 1,
|
|
170
|
+
// Tagging is the slowest step in a check, so it waits for the first read.
|
|
171
|
+
// By then the walk has joined the prose, which it is built from.
|
|
172
|
+
get language() {
|
|
173
|
+
language ??= languageOf(view.prose, state.concepts);
|
|
174
|
+
return language;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
let language: LanguageView | undefined;
|
|
178
|
+
state.paragraphs.push(view);
|
|
179
|
+
state.viewOf.set(p, view);
|
|
180
|
+
state.proseParts.set(p, []);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
// Every text node is prose; one directly under its paragraph is also a split point.
|
|
184
|
+
const visitText = (state: WalkState, t: Text, ancestors: Node[], paragraph: Paragraph | undefined): void => {
|
|
185
|
+
const start = t.position?.start.offset;
|
|
186
|
+
const end = t.position?.end.offset;
|
|
187
|
+
state.texts.push({
|
|
188
|
+
value: t.value,
|
|
189
|
+
line: t.position?.start.line ?? 0,
|
|
190
|
+
start,
|
|
191
|
+
end,
|
|
192
|
+
block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell"),
|
|
193
|
+
});
|
|
194
|
+
if (!paragraph) return;
|
|
195
|
+
state.proseParts.get(paragraph)?.push(t.value);
|
|
196
|
+
const view = state.viewOf.get(paragraph);
|
|
197
|
+
if (view && ancestors.at(-1) === paragraph && start != null && end != null) {
|
|
198
|
+
view.directTexts.push({ value: t.value, start, end });
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
// Inline code reads as one CODE token in the prose, and its range is exempt.
|
|
203
|
+
const visitInlineCode = (state: WalkState, c: InlineCode, paragraph: Paragraph, view: ParagraphView): void => {
|
|
204
|
+
state.proseParts.get(paragraph)?.push("CODE");
|
|
205
|
+
if (c.position?.start.offset != null && c.position.end.offset != null) {
|
|
206
|
+
view.codeRanges.push([c.position.start.offset, c.position.end.offset]);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// Route one node of the walk to the view it feeds.
|
|
211
|
+
const visitNode = (state: WalkState, node: Node, ancestors: Node[]): void => {
|
|
212
|
+
if (node.type === "code") {
|
|
213
|
+
visitCode(state, node as Code);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (node.type === "paragraph") {
|
|
217
|
+
visitParagraph(state, node as Paragraph, ancestors);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const paragraph = ancestors.findLast((a): a is Paragraph => a.type === "paragraph");
|
|
221
|
+
if (node.type === "text") {
|
|
222
|
+
visitText(state, node as Text, ancestors, paragraph);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const view = paragraph ? state.viewOf.get(paragraph) : undefined;
|
|
226
|
+
if (!(paragraph && view)) return;
|
|
227
|
+
if (node.type === "inlineCode") visitInlineCode(state, node as InlineCode, paragraph, view);
|
|
228
|
+
else if (node.type === "break") view.hasBreak = true;
|
|
229
|
+
};
|
|
86
230
|
|
|
231
|
+
export const buildDocModel = (src: string, concepts: ConceptModel = NO_CONCEPTS): DocModel => {
|
|
232
|
+
const tree = parse(src);
|
|
233
|
+
const state: WalkState = {
|
|
234
|
+
src,
|
|
235
|
+
paragraphs: [],
|
|
236
|
+
texts: [],
|
|
237
|
+
fences: [],
|
|
238
|
+
viewOf: new Map(),
|
|
239
|
+
proseParts: new Map(),
|
|
240
|
+
concepts,
|
|
241
|
+
};
|
|
87
242
|
visitParents(tree, (node: Node, ancestors: Node[]) => {
|
|
88
|
-
|
|
89
|
-
const c = node as Code;
|
|
90
|
-
if (c.lang != null && MARKDOWN_FENCE_LANGS.has(c.lang) && c.position) {
|
|
91
|
-
fences.push({ value: c.value, line: c.position.start.line });
|
|
92
|
-
}
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
const parent = ancestors.at(-1) as Parent | undefined;
|
|
96
|
-
if (node.type === "paragraph") {
|
|
97
|
-
const p = node as Paragraph;
|
|
98
|
-
const pos = p.position;
|
|
99
|
-
if (!pos || pos.start.offset == null || pos.end.offset == null) return;
|
|
100
|
-
const view: ParagraphView = {
|
|
101
|
-
node: p,
|
|
102
|
-
raw: src.slice(pos.start.offset, pos.end.offset),
|
|
103
|
-
line: pos.start.line,
|
|
104
|
-
startOffset: pos.start.offset,
|
|
105
|
-
endOffset: pos.end.offset,
|
|
106
|
-
startColumn: pos.start.column,
|
|
107
|
-
prose: "",
|
|
108
|
-
codeRanges: [],
|
|
109
|
-
directTexts: [],
|
|
110
|
-
hasBreak: false,
|
|
111
|
-
inTable: ancestors.some((a) => a.type === "table" || a.type === "tableCell"),
|
|
112
|
-
inBlockquote: ancestors.some((a) => a.type === "blockquote"),
|
|
113
|
-
inListItem: ancestors.some((a) => a.type === "listItem"),
|
|
114
|
-
onlyChild: parent !== undefined && parent.children.length === 1,
|
|
115
|
-
};
|
|
116
|
-
paragraphs.push(view);
|
|
117
|
-
viewOf.set(p, view);
|
|
118
|
-
proseParts.set(p, []);
|
|
119
|
-
}
|
|
120
|
-
const paragraph = ancestors.findLast((a): a is Paragraph => a.type === "paragraph");
|
|
121
|
-
const view = paragraph ? viewOf.get(paragraph) : undefined;
|
|
122
|
-
if (node.type === "text") {
|
|
123
|
-
const t = node as Text;
|
|
124
|
-
const start = t.position?.start.offset;
|
|
125
|
-
const end = t.position?.end.offset;
|
|
126
|
-
texts.push({
|
|
127
|
-
value: t.value,
|
|
128
|
-
line: t.position?.start.line ?? 0,
|
|
129
|
-
start,
|
|
130
|
-
end,
|
|
131
|
-
block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell"),
|
|
132
|
-
});
|
|
133
|
-
if (paragraph) proseParts.get(paragraph)?.push(t.value);
|
|
134
|
-
if (view && parent === paragraph && start != null && end != null) {
|
|
135
|
-
view.directTexts.push({ value: t.value, start, end });
|
|
136
|
-
}
|
|
137
|
-
} else if (node.type === "inlineCode" && view) {
|
|
138
|
-
const c = node as InlineCode;
|
|
139
|
-
proseParts.get(paragraph as Paragraph)?.push("CODE");
|
|
140
|
-
if (c.position?.start.offset != null && c.position.end.offset != null) {
|
|
141
|
-
view.codeRanges.push([c.position.start.offset, c.position.end.offset]);
|
|
142
|
-
}
|
|
143
|
-
} else if (node.type === "break" && view) {
|
|
144
|
-
view.hasBreak = true;
|
|
145
|
-
}
|
|
243
|
+
visitNode(state, node, ancestors);
|
|
146
244
|
});
|
|
147
245
|
|
|
148
|
-
for (const [p, parts] of proseParts) {
|
|
149
|
-
const view = viewOf.get(p);
|
|
246
|
+
for (const [p, parts] of state.proseParts) {
|
|
247
|
+
const view = state.viewOf.get(p);
|
|
150
248
|
if (view) view.prose = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
151
249
|
}
|
|
152
|
-
|
|
250
|
+
let frontmatter: FrontmatterView | null | undefined;
|
|
251
|
+
return {
|
|
252
|
+
src,
|
|
253
|
+
tree,
|
|
254
|
+
paragraphs: state.paragraphs,
|
|
255
|
+
texts: state.texts,
|
|
256
|
+
fences: state.fences,
|
|
257
|
+
concepts,
|
|
258
|
+
get frontmatter() {
|
|
259
|
+
frontmatter ??= readFrontmatter(src, tree);
|
|
260
|
+
return frontmatter;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const STYLES: Record<string, ScalarStyle> = {
|
|
266
|
+
PLAIN: "plain",
|
|
267
|
+
QUOTE_DOUBLE: "double",
|
|
268
|
+
QUOTE_SINGLE: "single",
|
|
269
|
+
BLOCK_LITERAL: "literal",
|
|
270
|
+
BLOCK_FOLDED: "folded",
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// What the walk of a YAML document needs to place each scalar in the source,
|
|
274
|
+
// and the list it fills in document order.
|
|
275
|
+
interface ScalarWalk {
|
|
276
|
+
contentStart: number; // absolute offset where the YAML text starts
|
|
277
|
+
lineOf: (absoluteOffset: number) => number;
|
|
278
|
+
indentOf: (relativeOffset: number) => number;
|
|
279
|
+
scalars: FrontmatterScalar[];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// A string scalar with a range is recorded; anything else is left out.
|
|
283
|
+
const visitScalar = (walk: ScalarWalk, value: Scalar, path: (string | number)[], key: Scalar | null): void => {
|
|
284
|
+
if (typeof value.value !== "string" || !value.range) return;
|
|
285
|
+
const { contentStart, lineOf } = walk;
|
|
286
|
+
const [from, to] = value.range;
|
|
287
|
+
const anchor = key?.range ? key.range[0] : from;
|
|
288
|
+
walk.scalars.push({
|
|
289
|
+
path,
|
|
290
|
+
key: key && typeof key.value === "string" ? key.value : null,
|
|
291
|
+
keyLine: key?.range ? lineOf(contentStart + key.range[0]) : null,
|
|
292
|
+
value: value.value,
|
|
293
|
+
line: lineOf(contentStart + from),
|
|
294
|
+
start: contentStart + from,
|
|
295
|
+
end: contentStart + to,
|
|
296
|
+
style: STYLES[value.type ?? "PLAIN"] ?? "plain",
|
|
297
|
+
indent: walk.indentOf(anchor),
|
|
298
|
+
node: value,
|
|
299
|
+
});
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
// Descend into a YAML node in order: a map by key, a list by index, keeping the
|
|
303
|
+
// nearest map key above each scalar.
|
|
304
|
+
const walkYaml = (walk: ScalarWalk, value: unknown, path: (string | number)[], key: Scalar | null): void => {
|
|
305
|
+
if (isScalar(value)) {
|
|
306
|
+
visitScalar(walk, value, path, key);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (isMap(value)) {
|
|
310
|
+
for (const pair of value.items) {
|
|
311
|
+
if (!isPair(pair)) continue;
|
|
312
|
+
const k = isScalar(pair.key) ? pair.key : null;
|
|
313
|
+
walkYaml(walk, pair.value, [...path, k ? String(k.value) : ""], k);
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (isSeq(value)) {
|
|
318
|
+
value.items.forEach((item, i) => {
|
|
319
|
+
walkYaml(walk, item, [...path, i], key);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// A leading YAML block as a whole document, plus the string scalars found by
|
|
325
|
+
// walking all of it. Only the first node of the tree can be frontmatter, and
|
|
326
|
+
// TOML is not read. A value that is not a string is left out of `scalars`
|
|
327
|
+
// rather than guessed at, and an alias is not followed. Invalid YAML throws with
|
|
328
|
+
// the line, so a rule that needs the keys cannot run on a block it could not read.
|
|
329
|
+
const readFrontmatter = (src: string, tree: Root): FrontmatterView | null => {
|
|
330
|
+
const node = tree.children[0];
|
|
331
|
+
const offset = node?.position?.start.offset;
|
|
332
|
+
if (node?.type !== "yaml" || offset == null || !node.position) return null;
|
|
333
|
+
const opening = node.position.start;
|
|
334
|
+
const contentStart = offset + src.slice(offset).indexOf("\n") + 1;
|
|
335
|
+
const lines = new LineCounter();
|
|
336
|
+
const doc = parseDocument(node.value, { lineCounter: lines });
|
|
337
|
+
const [problem] = doc.errors;
|
|
338
|
+
if (problem) {
|
|
339
|
+
const line = opening.line + (problem.linePos?.[0].line ?? 1);
|
|
340
|
+
throw new Error(`frontmatter YAML error at line ${line}: ${problem.message.split("\n")[0]}`);
|
|
341
|
+
}
|
|
342
|
+
const lineOf = (absolute: number): number => opening.line + lines.linePos(absolute - contentStart).line;
|
|
343
|
+
const indentOf = (relative: number): number => {
|
|
344
|
+
const lineStart = node.value.lastIndexOf("\n", relative - 1) + 1;
|
|
345
|
+
return /^ */.exec(node.value.slice(lineStart))?.[0].length ?? 0;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const walk: ScalarWalk = { contentStart, lineOf, indentOf, scalars: [] };
|
|
349
|
+
walkYaml(walk, doc.contents, [], null);
|
|
350
|
+
const { scalars } = walk;
|
|
351
|
+
|
|
352
|
+
const entries: FrontmatterEntry[] = scalars
|
|
353
|
+
.filter((s) => s.path.length === 1 && typeof s.path[0] === "string")
|
|
354
|
+
.map((s) => ({ key: s.path[0] as string, value: s.value, line: s.line, start: s.start, end: s.end }));
|
|
355
|
+
return { format: "yaml", entries, scalars, document: doc, offset: contentStart, lineOf };
|
|
153
356
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// What a plugin is: an ES module whose default export is a Plugin, or, for a
|
|
2
|
+
// local rule, one PluginRule per file. The context a plugin rule receives is
|
|
3
|
+
// the one the built-ins read, so a plugin rule is a built-in with a namespace.
|
|
4
|
+
|
|
5
|
+
import type { Awaitable, CheckContext, Edit, Finding, FixContext, OptionSpec } from "../rules/types.ts";
|
|
6
|
+
|
|
7
|
+
// Bumped when the contract changes in a way a plugin must notice. A plugin
|
|
8
|
+
// that targets another version fails to load with both numbers in the message.
|
|
9
|
+
export const API_VERSION = 1;
|
|
10
|
+
|
|
11
|
+
// What a plugin returns. The engine adds the rule id and the file to a finding,
|
|
12
|
+
// and the rule id to an edit, so a plugin cannot report as another rule.
|
|
13
|
+
export type PluginFinding = Pick<Finding, "line" | "message">;
|
|
14
|
+
|
|
15
|
+
export type PluginEdit = Omit<Edit, "rule">;
|
|
16
|
+
|
|
17
|
+
export interface PluginRule {
|
|
18
|
+
category: string;
|
|
19
|
+
summary: string;
|
|
20
|
+
options?: OptionSpec;
|
|
21
|
+
check: (ctx: CheckContext) => Awaitable<PluginFinding[]>;
|
|
22
|
+
fix?: (ctx: FixContext) => Awaitable<PluginEdit[]>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PluginMeta {
|
|
26
|
+
name: string;
|
|
27
|
+
namespace: string;
|
|
28
|
+
apiVersion: number;
|
|
29
|
+
// A new category the plugin adds, as the word and a one-line description.
|
|
30
|
+
categories?: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Plugin {
|
|
34
|
+
meta: PluginMeta;
|
|
35
|
+
rules: Record<string, PluginRule>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A one-word category and a namespace or rule key, as they appear in an id.
|
|
39
|
+
export const CATEGORY_WORD = /^[a-z]+$/;
|
|
40
|
+
export const NAMESPACE = /^[a-z][a-z0-9-]*$/;
|
|
41
|
+
export const RULE_KEY = /^[a-z][a-z0-9-]*$/;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Discovery: which plugin sources a project has. Three places, in this order:
|
|
2
|
+
// local rule files in .prose-gates/rules, packages the project declares as
|
|
3
|
+
// dependencies and whose name matches the plugin pattern, and the paths or
|
|
4
|
+
// names a config lists. A directory of node_modules is never scanned, so only
|
|
5
|
+
// what the project chose to depend on can run.
|
|
6
|
+
//
|
|
7
|
+
// A source that cannot be resolved is a failure the caller decides how to
|
|
8
|
+
// treat, not an exception, so a lenient run can skip it and a validating run
|
|
9
|
+
// can report every one.
|
|
10
|
+
|
|
11
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
import type { Config } from "../config.ts";
|
|
14
|
+
import { PluginNotInstalledError } from "../errors.ts";
|
|
15
|
+
|
|
16
|
+
export type SourceKind = "local" | "package" | "config";
|
|
17
|
+
|
|
18
|
+
export interface PluginSource {
|
|
19
|
+
kind: SourceKind;
|
|
20
|
+
// What the user would recognise: a package name or a path under the root.
|
|
21
|
+
spec: string;
|
|
22
|
+
// The file to import.
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface Discovery {
|
|
27
|
+
sources: PluginSource[];
|
|
28
|
+
// Sources found but not resolvable, such as a declared package that is not installed.
|
|
29
|
+
failures: PluginNotInstalledError[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const LOCAL_RULES = join(".prose-gates", "rules");
|
|
33
|
+
|
|
34
|
+
// prose-gates-plugin-acme, @acme/prose-gates-plugin-acme or @acme/prose-gates-plugin.
|
|
35
|
+
export const PACKAGE_PATTERN = /^(@[a-z0-9][a-z0-9._-]*\/)?prose-gates-plugin(-[a-z0-9-]+)?$/;
|
|
36
|
+
|
|
37
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
38
|
+
|
|
39
|
+
const readJson = async (path: string): Promise<Record<string, unknown> | null> => {
|
|
40
|
+
let text: string;
|
|
41
|
+
try {
|
|
42
|
+
text = await readFile(path, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const value: unknown = JSON.parse(text);
|
|
48
|
+
if (isRecord(value)) return value;
|
|
49
|
+
} catch {
|
|
50
|
+
// Reported below with the path.
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`${path} is not a JSON object`);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Every extension a local rule may have. Whether the runtime can import a
|
|
56
|
+
// TypeScript one is decided at load, so the reason is a named error.
|
|
57
|
+
const LOCAL_EXTENSIONS = new Set([".js", ".mjs", ".ts", ".mts", ".cts"]);
|
|
58
|
+
|
|
59
|
+
const localSources = async (root: string): Promise<PluginSource[]> => {
|
|
60
|
+
const dir = join(root, LOCAL_RULES);
|
|
61
|
+
let names: string[];
|
|
62
|
+
try {
|
|
63
|
+
names = (await readdir(dir)).sort();
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
return names
|
|
68
|
+
.filter((name) => LOCAL_EXTENSIONS.has(extname(name)) && !name.endsWith(".d.ts") && !name.endsWith(".d.mts"))
|
|
69
|
+
.map((name) => ({ kind: "local", spec: join(LOCAL_RULES, name), path: join(dir, name) }));
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// The file Node would import for a package, read from its package.json the
|
|
73
|
+
// way a resolver does: the exports entry, then main, then index.js.
|
|
74
|
+
const pick = (target: unknown): string | null => {
|
|
75
|
+
if (typeof target === "string") return target;
|
|
76
|
+
if (Array.isArray(target)) return pickFirst(target);
|
|
77
|
+
if (isRecord(target)) return pickCondition(target);
|
|
78
|
+
return null;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// A fallback array: the first entry that names a file.
|
|
82
|
+
const pickFirst = (items: unknown[]): string | null => {
|
|
83
|
+
for (const item of items) {
|
|
84
|
+
const found = pick(item);
|
|
85
|
+
if (found) return found;
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// A conditions object: the first condition Node would match that names a file.
|
|
91
|
+
const pickCondition = (target: Record<string, unknown>): string | null => {
|
|
92
|
+
for (const condition of ["node", "import", "default"]) {
|
|
93
|
+
if (!(condition in target)) continue;
|
|
94
|
+
const found = pick(target[condition]);
|
|
95
|
+
if (found) return found;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const entryOf = (pkg: Record<string, unknown>): string => {
|
|
101
|
+
const exported = pkg.exports;
|
|
102
|
+
const hasMap = isRecord(exported) && Object.keys(exported).some((k) => k.startsWith("."));
|
|
103
|
+
const main = isRecord(exported) && "." in exported ? exported["."] : hasMap ? null : exported;
|
|
104
|
+
return pick(main) ?? (typeof pkg.main === "string" ? pkg.main : "index.js");
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const resolvePackage = async (root: string, name: string): Promise<string> => {
|
|
108
|
+
for (let dir = resolve(root); ; dir = dirname(dir)) {
|
|
109
|
+
const folder = join(dir, "node_modules", name);
|
|
110
|
+
const pkg = await readJson(join(folder, "package.json"));
|
|
111
|
+
if (pkg) return join(folder, entryOf(pkg));
|
|
112
|
+
if (dirname(dir) === dir) break;
|
|
113
|
+
}
|
|
114
|
+
throw new PluginNotInstalledError(name, root);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const declaredPlugins = async (root: string): Promise<string[]> => {
|
|
118
|
+
const pkg = await readJson(join(root, "package.json"));
|
|
119
|
+
if (!pkg) return [];
|
|
120
|
+
const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
|
|
121
|
+
const deps = pkg[field];
|
|
122
|
+
return isRecord(deps) ? Object.keys(deps) : [];
|
|
123
|
+
});
|
|
124
|
+
return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const discoverSources = async (root: string, config: Config): Promise<Discovery> => {
|
|
128
|
+
const sources = await localSources(root);
|
|
129
|
+
const failures: PluginNotInstalledError[] = [];
|
|
130
|
+
const add = async (kind: SourceKind, spec: string, resolveIt: () => Promise<string>): Promise<void> => {
|
|
131
|
+
try {
|
|
132
|
+
sources.push({ kind, spec, path: await resolveIt() });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (!(err instanceof PluginNotInstalledError)) throw err;
|
|
135
|
+
failures.push(err);
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
for (const name of await declaredPlugins(root)) await add("package", name, () => resolvePackage(root, name));
|
|
139
|
+
for (const spec of config.plugins || []) {
|
|
140
|
+
const path = spec.startsWith(".") || isAbsolute(spec);
|
|
141
|
+
await add("config", spec, () => (path ? Promise.resolve(resolve(root, spec)) : resolvePackage(root, spec)));
|
|
142
|
+
}
|
|
143
|
+
// A package both declared and listed in the config loads once.
|
|
144
|
+
const seen = new Set<string>();
|
|
145
|
+
return { sources: sources.filter((s) => !seen.has(s.path) && seen.add(s.path)), failures };
|
|
146
|
+
};
|