@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/model.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { frontmatter } from "micromark-extension-frontmatter";
|
|
|
10
10
|
import { gfm } from "micromark-extension-gfm";
|
|
11
11
|
import { visitParents } from "unist-util-visit-parents";
|
|
12
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";
|
|
13
15
|
import type { Range } from "./text.ts";
|
|
14
16
|
|
|
15
17
|
// Fenced blocks in these languages hold markdown templates (a conventions or
|
|
@@ -43,6 +45,8 @@ export interface ParagraphView {
|
|
|
43
45
|
inBlockquote: boolean;
|
|
44
46
|
inListItem: boolean;
|
|
45
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;
|
|
46
50
|
}
|
|
47
51
|
|
|
48
52
|
export interface TextView {
|
|
@@ -109,6 +113,8 @@ export interface DocModel {
|
|
|
109
113
|
paragraphs: ParagraphView[];
|
|
110
114
|
texts: TextView[]; // every prose text node, including headings and lists
|
|
111
115
|
fences: FenceView[]; // embedded-markdown fences, audited recursively
|
|
116
|
+
// The project's concepts, which every paragraph's language view names.
|
|
117
|
+
concepts: ConceptModel;
|
|
112
118
|
// The leading YAML block as keys and values, read on first use so a document
|
|
113
119
|
// whose frontmatter is not valid YAML only fails the rules that ask for it.
|
|
114
120
|
readonly frontmatter: FrontmatterView | null;
|
|
@@ -123,86 +129,132 @@ export const parse = (src: string): Root =>
|
|
|
123
129
|
mdastExtensions: [frontmatterFromMarkdown(["yaml", "toml"]), gfmFromMarkdown()],
|
|
124
130
|
});
|
|
125
131
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
+
};
|
|
133
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
|
+
};
|
|
134
242
|
visitParents(tree, (node: Node, ancestors: Node[]) => {
|
|
135
|
-
|
|
136
|
-
const c = node as Code;
|
|
137
|
-
if (c.lang != null && MARKDOWN_FENCE_LANGS.has(c.lang) && c.position) {
|
|
138
|
-
fences.push({ value: c.value, line: c.position.start.line });
|
|
139
|
-
}
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const parent = ancestors.at(-1) as Parent | undefined;
|
|
143
|
-
if (node.type === "paragraph") {
|
|
144
|
-
const p = node as Paragraph;
|
|
145
|
-
const pos = p.position;
|
|
146
|
-
if (!pos || pos.start.offset == null || pos.end.offset == null) return;
|
|
147
|
-
const view: ParagraphView = {
|
|
148
|
-
node: p,
|
|
149
|
-
raw: src.slice(pos.start.offset, pos.end.offset),
|
|
150
|
-
line: pos.start.line,
|
|
151
|
-
startOffset: pos.start.offset,
|
|
152
|
-
endOffset: pos.end.offset,
|
|
153
|
-
startColumn: pos.start.column,
|
|
154
|
-
prose: "",
|
|
155
|
-
codeRanges: [],
|
|
156
|
-
directTexts: [],
|
|
157
|
-
hasBreak: false,
|
|
158
|
-
inTable: ancestors.some((a) => a.type === "table" || a.type === "tableCell"),
|
|
159
|
-
inBlockquote: ancestors.some((a) => a.type === "blockquote"),
|
|
160
|
-
inListItem: ancestors.some((a) => a.type === "listItem"),
|
|
161
|
-
onlyChild: parent !== undefined && parent.children.length === 1,
|
|
162
|
-
};
|
|
163
|
-
paragraphs.push(view);
|
|
164
|
-
viewOf.set(p, view);
|
|
165
|
-
proseParts.set(p, []);
|
|
166
|
-
}
|
|
167
|
-
const paragraph = ancestors.findLast((a): a is Paragraph => a.type === "paragraph");
|
|
168
|
-
const view = paragraph ? viewOf.get(paragraph) : undefined;
|
|
169
|
-
if (node.type === "text") {
|
|
170
|
-
const t = node as Text;
|
|
171
|
-
const start = t.position?.start.offset;
|
|
172
|
-
const end = t.position?.end.offset;
|
|
173
|
-
texts.push({
|
|
174
|
-
value: t.value,
|
|
175
|
-
line: t.position?.start.line ?? 0,
|
|
176
|
-
start,
|
|
177
|
-
end,
|
|
178
|
-
block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell"),
|
|
179
|
-
});
|
|
180
|
-
if (paragraph) proseParts.get(paragraph)?.push(t.value);
|
|
181
|
-
if (view && parent === paragraph && start != null && end != null) {
|
|
182
|
-
view.directTexts.push({ value: t.value, start, end });
|
|
183
|
-
}
|
|
184
|
-
} else if (node.type === "inlineCode" && view) {
|
|
185
|
-
const c = node as InlineCode;
|
|
186
|
-
proseParts.get(paragraph as Paragraph)?.push("CODE");
|
|
187
|
-
if (c.position?.start.offset != null && c.position.end.offset != null) {
|
|
188
|
-
view.codeRanges.push([c.position.start.offset, c.position.end.offset]);
|
|
189
|
-
}
|
|
190
|
-
} else if (node.type === "break" && view) {
|
|
191
|
-
view.hasBreak = true;
|
|
192
|
-
}
|
|
243
|
+
visitNode(state, node, ancestors);
|
|
193
244
|
});
|
|
194
245
|
|
|
195
|
-
for (const [p, parts] of proseParts) {
|
|
196
|
-
const view = viewOf.get(p);
|
|
246
|
+
for (const [p, parts] of state.proseParts) {
|
|
247
|
+
const view = state.viewOf.get(p);
|
|
197
248
|
if (view) view.prose = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
198
249
|
}
|
|
199
250
|
let frontmatter: FrontmatterView | null | undefined;
|
|
200
251
|
return {
|
|
201
252
|
src,
|
|
202
253
|
tree,
|
|
203
|
-
paragraphs,
|
|
204
|
-
texts,
|
|
205
|
-
fences,
|
|
254
|
+
paragraphs: state.paragraphs,
|
|
255
|
+
texts: state.texts,
|
|
256
|
+
fences: state.fences,
|
|
257
|
+
concepts,
|
|
206
258
|
get frontmatter() {
|
|
207
259
|
frontmatter ??= readFrontmatter(src, tree);
|
|
208
260
|
return frontmatter;
|
|
@@ -218,6 +270,57 @@ const STYLES: Record<string, ScalarStyle> = {
|
|
|
218
270
|
BLOCK_FOLDED: "folded",
|
|
219
271
|
};
|
|
220
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
|
+
|
|
221
324
|
// A leading YAML block as a whole document, plus the string scalars found by
|
|
222
325
|
// walking all of it. Only the first node of the tree can be frontmatter, and
|
|
223
326
|
// TOML is not read. A value that is not a string is left out of `scalars`
|
|
@@ -242,38 +345,9 @@ const readFrontmatter = (src: string, tree: Root): FrontmatterView | null => {
|
|
|
242
345
|
return /^ */.exec(node.value.slice(lineStart))?.[0].length ?? 0;
|
|
243
346
|
};
|
|
244
347
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (typeof value.value !== "string" || !value.range) return;
|
|
249
|
-
const [from, to] = value.range;
|
|
250
|
-
const anchor = key?.range ? key.range[0] : from;
|
|
251
|
-
scalars.push({
|
|
252
|
-
path,
|
|
253
|
-
key: key && typeof key.value === "string" ? key.value : null,
|
|
254
|
-
keyLine: key?.range ? lineOf(contentStart + key.range[0]) : null,
|
|
255
|
-
value: value.value,
|
|
256
|
-
line: lineOf(contentStart + from),
|
|
257
|
-
start: contentStart + from,
|
|
258
|
-
end: contentStart + to,
|
|
259
|
-
style: STYLES[value.type ?? "PLAIN"] ?? "plain",
|
|
260
|
-
indent: indentOf(anchor),
|
|
261
|
-
node: value,
|
|
262
|
-
});
|
|
263
|
-
} else if (isMap(value)) {
|
|
264
|
-
for (const pair of value.items) {
|
|
265
|
-
if (!isPair(pair)) continue;
|
|
266
|
-
const k = isScalar(pair.key) ? pair.key : null;
|
|
267
|
-
const name = k ? String(k.value) : "";
|
|
268
|
-
walk(pair.value, [...path, name], k);
|
|
269
|
-
}
|
|
270
|
-
} else if (isSeq(value)) {
|
|
271
|
-
value.items.forEach((item, i) => {
|
|
272
|
-
walk(item, [...path, i], key);
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
};
|
|
276
|
-
walk(doc.contents, [], null);
|
|
348
|
+
const walk: ScalarWalk = { contentStart, lineOf, indentOf, scalars: [] };
|
|
349
|
+
walkYaml(walk, doc.contents, [], null);
|
|
350
|
+
const { scalars } = walk;
|
|
277
351
|
|
|
278
352
|
const entries: FrontmatterEntry[] = scalars
|
|
279
353
|
.filter((s) => s.path.length === 1 && typeof s.path[0] === "string")
|
package/src/plugins/discover.ts
CHANGED
|
@@ -73,20 +73,26 @@ const localSources = async (root: string): Promise<PluginSource[]> => {
|
|
|
73
73
|
// way a resolver does: the exports entry, then main, then index.js.
|
|
74
74
|
const pick = (target: unknown): string | null => {
|
|
75
75
|
if (typeof target === "string") return target;
|
|
76
|
-
if (Array.isArray(target))
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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;
|
|
82
86
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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;
|
|
90
96
|
}
|
|
91
97
|
return null;
|
|
92
98
|
};
|
package/src/plugins/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { PluginConflictError, PluginLoadError } from "../errors.ts";
|
|
|
12
12
|
import { BUILTIN_CATEGORIES, type LoadedSource } from "../rules/registry.ts";
|
|
13
13
|
import type { Rule } from "../rules/types.ts";
|
|
14
14
|
import type { Plugin } from "./contract.ts";
|
|
15
|
-
import { discoverSources, LOCAL_RULES, type SourceKind } from "./discover.ts";
|
|
15
|
+
import { discoverSources, LOCAL_RULES, type PluginSource, type SourceKind } from "./discover.ts";
|
|
16
16
|
import { loadLocal, loadPackage, toRule } from "./load.ts";
|
|
17
17
|
|
|
18
18
|
export type PluginMode = "off" | "strict" | "lenient" | "collect";
|
|
@@ -33,25 +33,25 @@ export interface PluginSet {
|
|
|
33
33
|
failures: PluginFailure[];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
failures: [],
|
|
44
|
-
};
|
|
45
|
-
if (mode === "off") return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
|
|
36
|
+
type Settle = (error: PluginLoadError, kind: SourceKind) => void;
|
|
37
|
+
|
|
38
|
+
interface LoadedPlugin {
|
|
39
|
+
plugin: Plugin;
|
|
40
|
+
kind: SourceKind;
|
|
41
|
+
spec: string;
|
|
42
|
+
}
|
|
46
43
|
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// Strict stops at the first failure. The others record it and carry on.
|
|
45
|
+
const settler =
|
|
46
|
+
(mode: PluginMode, failures: PluginFailure[]): Settle =>
|
|
47
|
+
(error, kind) => {
|
|
49
48
|
if (mode === "strict") throw error;
|
|
50
|
-
if (!
|
|
49
|
+
if (!failures.some((f) => f.source === error.source)) failures.push({ source: error.source, kind, error });
|
|
51
50
|
};
|
|
52
|
-
for (const error of unresolved) settle(error, "package");
|
|
53
51
|
|
|
54
|
-
|
|
52
|
+
// Local files load together as one plugin, then each package loads on its own.
|
|
53
|
+
const importSources = async (found: PluginSource[], settle: Settle): Promise<LoadedPlugin[]> => {
|
|
54
|
+
const loaded: LoadedPlugin[] = [];
|
|
55
55
|
const locals = found.filter((s) => s.kind === "local");
|
|
56
56
|
if (locals.length > 0) {
|
|
57
57
|
const local = await loadLocal(locals);
|
|
@@ -66,38 +66,71 @@ export const loadPlugins = async (root: string, config: Config, mode: PluginMode
|
|
|
66
66
|
settle(err, source.kind);
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
+
return loaded;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
interface Claims {
|
|
73
|
+
namespaces: Map<string, string>;
|
|
74
|
+
declaredBy: Map<string, string>;
|
|
75
|
+
}
|
|
69
76
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
77
|
+
// The earlier claimant and the reason, when a plugin reuses a namespace or a
|
|
78
|
+
// category word another plugin already holds. Null when it claims nothing taken.
|
|
79
|
+
const findClash = (plugin: Plugin, claims: Claims): { other: string; reason: string } | null => {
|
|
80
|
+
const { namespace } = plugin.meta;
|
|
81
|
+
const taken = claims.namespaces.get(namespace);
|
|
82
|
+
if (taken) return { other: taken, reason: `namespace "${namespace}" is already used by ${taken}` };
|
|
83
|
+
const clash = Object.keys(plugin.meta.categories ?? {}).find((w) => claims.declaredBy.has(w));
|
|
84
|
+
if (!clash) return null;
|
|
85
|
+
const other = claims.declaredBy.get(clash) as string;
|
|
86
|
+
return { other, reason: `category "${clash}" is already declared by ${other}` };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Namespaces and new categories are global. The first plugin to claim one
|
|
90
|
+
// keeps it, and a later claimant fails, naming both.
|
|
91
|
+
const acceptPlugins = (loaded: LoadedPlugin[], set: PluginSet, settle: Settle): LoadedPlugin[] => {
|
|
92
|
+
const claims: Claims = { namespaces: new Map(), declaredBy: new Map() };
|
|
93
|
+
const accepted: LoadedPlugin[] = [];
|
|
75
94
|
for (const entry of loaded) {
|
|
76
95
|
const { plugin, spec, kind } = entry;
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const clash = words.find((w) => declaredBy.has(w));
|
|
81
|
-
if (taken || clash) {
|
|
82
|
-
const other = (taken ?? declaredBy.get(clash as string)) as string;
|
|
83
|
-
const reason = taken
|
|
84
|
-
? `namespace "${namespace}" is already used by ${other}`
|
|
85
|
-
: `category "${clash}" is already declared by ${other}`;
|
|
86
|
-
settle(new PluginConflictError(spec, other, reason), kind);
|
|
96
|
+
const clash = findClash(plugin, claims);
|
|
97
|
+
if (clash) {
|
|
98
|
+
settle(new PluginConflictError(spec, clash.other, clash.reason), kind);
|
|
87
99
|
continue;
|
|
88
100
|
}
|
|
89
|
-
namespaces.set(namespace, spec);
|
|
101
|
+
claims.namespaces.set(plugin.meta.namespace, spec);
|
|
90
102
|
for (const [word, description] of Object.entries(plugin.meta.categories ?? {})) {
|
|
91
|
-
declaredBy.set(word, spec);
|
|
103
|
+
claims.declaredBy.set(word, spec);
|
|
92
104
|
set.categories.set(word, description);
|
|
93
105
|
}
|
|
94
106
|
accepted.push(entry);
|
|
95
107
|
}
|
|
108
|
+
return accepted;
|
|
109
|
+
};
|
|
96
110
|
|
|
111
|
+
// Each accepted plugin's rules join the set, and the source records which ids it gave.
|
|
112
|
+
const addRules = (set: PluginSet, accepted: LoadedPlugin[]): void => {
|
|
97
113
|
for (const { plugin, kind, spec } of accepted) {
|
|
98
114
|
const rules = Object.entries(plugin.rules).map(([key, rule]) => toRule(plugin.meta.namespace, key, rule));
|
|
99
115
|
set.rules.push(...rules);
|
|
100
116
|
set.sources.push({ kind, spec, namespace: plugin.meta.namespace, rules: rules.map((r) => r.id) });
|
|
101
117
|
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
|
|
121
|
+
const { sources: found, failures: unresolved } = await discoverSources(root, config);
|
|
122
|
+
const set: PluginSet = {
|
|
123
|
+
rules: [],
|
|
124
|
+
categories: new Map(BUILTIN_CATEGORIES),
|
|
125
|
+
sources: [],
|
|
126
|
+
skipped: [],
|
|
127
|
+
failures: [],
|
|
128
|
+
};
|
|
129
|
+
if (mode === "off") return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
|
|
130
|
+
|
|
131
|
+
const settle = settler(mode, set.failures);
|
|
132
|
+
for (const error of unresolved) settle(error, "package");
|
|
133
|
+
const loaded = await importSources(found, settle);
|
|
134
|
+
addRules(set, acceptPlugins(loaded, set, settle));
|
|
102
135
|
return set;
|
|
103
136
|
};
|
package/src/plugins/load.ts
CHANGED
|
@@ -49,15 +49,10 @@ const importModule = async (source: PluginSource, runtime?: RuntimeInfo): Promis
|
|
|
49
49
|
|
|
50
50
|
const OPTION_TYPES = new Set<OptionType>(["number", "string", "boolean", "string[]"]);
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
const
|
|
52
|
+
// A rule's category is one word, built in or declared by this plugin, and its
|
|
53
|
+
// key starts with it.
|
|
54
|
+
const checkRuleCategory = (spec: string, key: string, category: unknown, own: ReadonlySet<string>): void => {
|
|
55
55
|
const where = `rule "${key}"`;
|
|
56
|
-
if (!isRecord(raw)) throw new PluginContractError(spec, `${where} must be an object`);
|
|
57
|
-
if (!RULE_KEY.test(key)) {
|
|
58
|
-
throw new PluginContractError(spec, `${where} is not a valid key; use lower-case words joined by hyphens`);
|
|
59
|
-
}
|
|
60
|
-
const { category, summary, options, check, fix } = raw;
|
|
61
56
|
if (typeof category !== "string" || !CATEGORY_WORD.test(category)) {
|
|
62
57
|
throw new PluginContractError(spec, `${where} needs a category of one lower-case word`);
|
|
63
58
|
}
|
|
@@ -70,17 +65,34 @@ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<str
|
|
|
70
65
|
if (!key.startsWith(`${category}-`)) {
|
|
71
66
|
throw new PluginContractError(spec, `${where} must start with its category, as "${category}-short-name"`);
|
|
72
67
|
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// A rule's summary, functions and option types, checked after its category.
|
|
71
|
+
const checkRuleBody = (spec: string, key: string, raw: Record<string, unknown>): void => {
|
|
72
|
+
const where = `rule "${key}"`;
|
|
73
|
+
const { summary, options, check, fix } = raw;
|
|
73
74
|
if (typeof summary !== "string" || summary === "") throw new PluginContractError(spec, `${where} needs a summary`);
|
|
74
75
|
if (typeof check !== "function") throw new PluginContractError(spec, `${where} needs a check function`);
|
|
75
76
|
if (fix !== undefined && typeof fix !== "function") {
|
|
76
77
|
throw new PluginContractError(spec, `${where} fix must be a function`);
|
|
77
78
|
}
|
|
78
|
-
if (options
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
79
|
+
if (options === undefined) return;
|
|
80
|
+
const valid = isRecord(options) && Object.values(options).every((t) => OPTION_TYPES.has(t as OptionType));
|
|
81
|
+
if (!valid) {
|
|
82
|
+
throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
|
|
83
83
|
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Checks one rule's declaration and returns it, so a bad rule fails at load
|
|
87
|
+
// rather than the first time it runs.
|
|
88
|
+
const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
|
|
89
|
+
const where = `rule "${key}"`;
|
|
90
|
+
if (!isRecord(raw)) throw new PluginContractError(spec, `${where} must be an object`);
|
|
91
|
+
if (!RULE_KEY.test(key)) {
|
|
92
|
+
throw new PluginContractError(spec, `${where} is not a valid key; use lower-case words joined by hyphens`);
|
|
93
|
+
}
|
|
94
|
+
checkRuleCategory(spec, key, raw.category, own);
|
|
95
|
+
checkRuleBody(spec, key, raw);
|
|
84
96
|
return raw as unknown as PluginRule;
|
|
85
97
|
};
|
|
86
98
|
|
|
@@ -135,6 +147,50 @@ export interface LocalLoad {
|
|
|
135
147
|
failures: PluginLoadError[];
|
|
136
148
|
}
|
|
137
149
|
|
|
150
|
+
interface LocalCategories {
|
|
151
|
+
// Each word the local files declare, with its description.
|
|
152
|
+
categories: Record<string, string>;
|
|
153
|
+
// The file that first declared each word.
|
|
154
|
+
owner: Record<string, string>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface LocalFile {
|
|
158
|
+
key: string;
|
|
159
|
+
source: PluginSource;
|
|
160
|
+
raw: unknown;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Local files may each declare the same word, but only with the same description.
|
|
164
|
+
const mergeCategories = (source: PluginSource, declared: Record<string, string>, merged: LocalCategories): void => {
|
|
165
|
+
const { categories, owner } = merged;
|
|
166
|
+
for (const [word, description] of Object.entries(declared)) {
|
|
167
|
+
if (categories[word] !== undefined && categories[word] !== description) {
|
|
168
|
+
throw new PluginConflictError(
|
|
169
|
+
source.spec,
|
|
170
|
+
owner[word] as string,
|
|
171
|
+
`declares category "${word}" with a different description than another local file`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
categories[word] = description;
|
|
175
|
+
owner[word] = source.spec;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Imports one local file and merges the categories it declares. Its rule is
|
|
180
|
+
// checked later, once every file's categories are known.
|
|
181
|
+
const importLocalFile = async (
|
|
182
|
+
source: PluginSource,
|
|
183
|
+
merged: LocalCategories,
|
|
184
|
+
runtime?: RuntimeInfo,
|
|
185
|
+
): Promise<LocalFile> => {
|
|
186
|
+
const mod = await importModule(source, runtime);
|
|
187
|
+
if (mod.default === undefined) {
|
|
188
|
+
throw new PluginContractError(source.spec, "has no default export; export one rule");
|
|
189
|
+
}
|
|
190
|
+
mergeCategories(source, checkCategories(source.spec, mod.categories), merged);
|
|
191
|
+
return { key: basename(source.path, extname(source.path)), source, raw: mod.default };
|
|
192
|
+
};
|
|
193
|
+
|
|
138
194
|
// Local files: each default-exports one rule, keyed by its file name. They load
|
|
139
195
|
// as one plugin under the fixed namespace `local`, and a file may export
|
|
140
196
|
// `categories` to declare the word its rule uses. Each file fails on its own,
|
|
@@ -145,32 +201,16 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
|
|
|
145
201
|
if (!(err instanceof PluginLoadError)) throw err;
|
|
146
202
|
failures.push(err);
|
|
147
203
|
};
|
|
148
|
-
const categories:
|
|
149
|
-
const
|
|
150
|
-
const loaded: { key: string; source: PluginSource; raw: unknown }[] = [];
|
|
204
|
+
const merged: LocalCategories = { categories: {}, owner: {} };
|
|
205
|
+
const loaded: LocalFile[] = [];
|
|
151
206
|
for (const source of sources) {
|
|
152
207
|
try {
|
|
153
|
-
|
|
154
|
-
if (mod.default === undefined) {
|
|
155
|
-
throw new PluginContractError(source.spec, "has no default export; export one rule");
|
|
156
|
-
}
|
|
157
|
-
const declared = checkCategories(source.spec, mod.categories);
|
|
158
|
-
for (const [word, description] of Object.entries(declared)) {
|
|
159
|
-
if (categories[word] !== undefined && categories[word] !== description) {
|
|
160
|
-
throw new PluginConflictError(
|
|
161
|
-
source.spec,
|
|
162
|
-
owner[word] as string,
|
|
163
|
-
`declares category "${word}" with a different description than another local file`,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
categories[word] = description;
|
|
167
|
-
owner[word] = source.spec;
|
|
168
|
-
}
|
|
169
|
-
loaded.push({ key: basename(source.path, extname(source.path)), source, raw: mod.default });
|
|
208
|
+
loaded.push(await importLocalFile(source, merged, runtime));
|
|
170
209
|
} catch (err) {
|
|
171
210
|
fail(err);
|
|
172
211
|
}
|
|
173
212
|
}
|
|
213
|
+
const { categories } = merged;
|
|
174
214
|
const own = new Set(Object.keys(categories));
|
|
175
215
|
const rules: Record<string, PluginRule> = {};
|
|
176
216
|
for (const { key, source, raw } of loaded) {
|