@jpeakai/prose-gates 0.1.1 → 0.2.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 +37 -4
- package/dist/bin.js +8472 -539
- package/dist/index.js +8500 -540
- package/dist/testing.js +18321 -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 +10 -3
- package/src/bin.ts +2 -2
- package/src/check.ts +40 -13
- package/src/cli.ts +197 -28
- package/src/config.ts +119 -0
- package/src/errors.ts +191 -0
- package/src/fix/engine.ts +31 -9
- package/src/fix/promote.ts +14 -2
- package/src/fix/verify.ts +29 -0
- package/src/index.ts +38 -2
- package/src/interpunct.ts +48 -0
- package/src/model.ts +129 -12
- package/src/plugins/contract.ts +41 -0
- package/src/plugins/discover.ts +140 -0
- package/src/plugins/index.ts +103 -0
- package/src/plugins/load.ts +234 -0
- package/src/plugins/runtime.ts +15 -0
- package/src/project.ts +81 -0
- package/src/query.ts +131 -0
- package/src/rules/index.ts +31 -12
- package/src/rules/list-comma-labelled-run.ts +101 -0
- package/src/rules/list-inline-enumeration-markers.ts +127 -0
- package/src/rules/list-interpunct-joined-run.ts +68 -0
- package/src/rules/list-semicolon-delimited-run.ts +60 -0
- package/src/rules/list-stacked-interpunct-runs.ts +80 -0
- package/src/rules/punctuation-em-dash-in-prose.ts +155 -0
- package/src/rules/punctuation-interpunct-in-prose.ts +52 -0
- package/src/rules/registry.ts +133 -0
- package/src/rules/sentence-one-per-line.ts +99 -0
- package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +22 -20
- package/src/rules/types.ts +70 -25
- package/src/testing.ts +60 -0
- package/src/text.ts +50 -0
- package/src/fix/spans.ts +0 -75
- package/src/rules/interpunct.ts +0 -19
- package/src/rules/pg001-wrap.ts +0 -79
- package/src/rules/pg003-semicolon-list.ts +0 -68
- package/src/rules/pg004-em-dash.ts +0 -117
- package/src/rules/pg005-interpunct.ts +0 -43
- package/src/rules/pg006-interpunct-run.ts +0 -84
- package/src/rules/pg007-inline-enum.ts +0 -119
- package/src/rules/pg008-labelled-run.ts +0 -94
- package/src/rules/pg009-stacked-runs.ts +0 -79
package/src/fix/verify.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { Node, Parent, Root } from "mdast";
|
|
7
7
|
import { visitParents as visit } from "unist-util-visit-parents";
|
|
8
|
+
import { parse as parseYaml } from "yaml";
|
|
8
9
|
import { parse } from "../model.ts";
|
|
9
10
|
import type { Edit } from "../rules/types.ts";
|
|
10
11
|
|
|
@@ -105,6 +106,26 @@ const fragmentShapeOk = (nodes: Node[], listItems: number): boolean => {
|
|
|
105
106
|
return items.length === listItems && items.every((i) => i.children[0]?.type === "paragraph");
|
|
106
107
|
};
|
|
107
108
|
|
|
109
|
+
const INVALID = Symbol("invalid frontmatter");
|
|
110
|
+
|
|
111
|
+
// What the leading YAML block means: its data as the yaml package reads it, so a
|
|
112
|
+
// rewrite that only changes how the data is written compares equal. Absent
|
|
113
|
+
// frontmatter is `undefined`, and a block that does not parse is INVALID.
|
|
114
|
+
const frontmatterData = (tree: Root): unknown => {
|
|
115
|
+
const first = tree.children[0];
|
|
116
|
+
if (first?.type !== "yaml") return undefined;
|
|
117
|
+
try {
|
|
118
|
+
return parseYaml(first.value);
|
|
119
|
+
} catch {
|
|
120
|
+
return INVALID;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// The tree with its leading frontmatter node taken off, so everything else can
|
|
125
|
+
// be compared on its own.
|
|
126
|
+
const withoutFrontmatter = (tree: Root): Root =>
|
|
127
|
+
tree.children[0]?.type === "yaml" ? { ...tree, children: tree.children.slice(1) } : tree;
|
|
128
|
+
|
|
108
129
|
export type Verdict = "accept" | "refuse";
|
|
109
130
|
|
|
110
131
|
export const verify = (edit: Edit, before: { src: string; tree: Root }, afterTree: Root): Verdict => {
|
|
@@ -122,6 +143,14 @@ export const verify = (edit: Edit, before: { src: string; tree: Root }, afterTre
|
|
|
122
143
|
if (expect.kind === "same-tree") {
|
|
123
144
|
return sameJson(normalise(before.tree), normalise(afterTree)) ? "accept" : "refuse";
|
|
124
145
|
}
|
|
146
|
+
if (expect.kind === "same-frontmatter-data") {
|
|
147
|
+
const was = frontmatterData(before.tree);
|
|
148
|
+
const now = frontmatterData(afterTree);
|
|
149
|
+
if (was === INVALID || now === INVALID || !sameJson(was, now)) return "refuse";
|
|
150
|
+
return sameJson(normalise(withoutFrontmatter(before.tree)), normalise(withoutFrontmatter(afterTree)))
|
|
151
|
+
? "accept"
|
|
152
|
+
: "refuse";
|
|
153
|
+
}
|
|
125
154
|
if (expect.kind === "same-shape") {
|
|
126
155
|
return sameJson(normalise(before.tree, { blankText: true }), normalise(afterTree, { blankText: true }))
|
|
127
156
|
? "accept"
|
package/src/index.ts
CHANGED
|
@@ -2,9 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
export { checkMarkdown, checkModel, DEFAULT_MAX_WORDS } from "./check.ts";
|
|
4
4
|
export { main } from "./cli.ts";
|
|
5
|
+
export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSetting, type Severity } from "./config.ts";
|
|
6
|
+
export * from "./errors.ts";
|
|
5
7
|
export { type FixReport, fixMarkdown, fixMarkdownReport } from "./fix/engine.ts";
|
|
6
8
|
export { FixInvariantError } from "./fix/verify.ts";
|
|
7
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
buildDocModel,
|
|
11
|
+
type DocModel,
|
|
12
|
+
type FrontmatterEntry,
|
|
13
|
+
type FrontmatterScalar,
|
|
14
|
+
type FrontmatterView,
|
|
15
|
+
type ScalarStyle,
|
|
16
|
+
} from "./model.ts";
|
|
17
|
+
export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
|
|
18
|
+
export { findProjectRoot, setUp } from "./project.ts";
|
|
8
19
|
export { FIX_ORDER, RULES } from "./rules/index.ts";
|
|
9
|
-
export
|
|
20
|
+
export {
|
|
21
|
+
BUILTIN,
|
|
22
|
+
BUILTIN_CATEGORIES,
|
|
23
|
+
buildRegistry,
|
|
24
|
+
type LoadedSource,
|
|
25
|
+
type Registry,
|
|
26
|
+
type RegistryInput,
|
|
27
|
+
} from "./rules/registry.ts";
|
|
28
|
+
export type {
|
|
29
|
+
Category,
|
|
30
|
+
CheckContext,
|
|
31
|
+
CoreRuleId,
|
|
32
|
+
Edit,
|
|
33
|
+
Expectation,
|
|
34
|
+
Finding,
|
|
35
|
+
FixContext,
|
|
36
|
+
OptionSpec,
|
|
37
|
+
OptionType,
|
|
38
|
+
PluginRuleId,
|
|
39
|
+
Rule,
|
|
40
|
+
RuleContext,
|
|
41
|
+
RuleId,
|
|
42
|
+
RuleOptions,
|
|
43
|
+
TextHelpers,
|
|
44
|
+
} from "./rules/types.ts";
|
|
10
45
|
export { CATEGORIES, RULE } from "./rules/types.ts";
|
|
46
|
+
export { sentences, words } from "./text.ts";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The interpunct run: the one piece of data derived from a whole document
|
|
2
|
+
// that more than one rule reads. list-interpunct-joined-run and list-stacked-interpunct-runs report a run, punctuation-interpunct-in-prose stays
|
|
3
|
+
// quiet inside one, and sentence-one-per-line leaves a stacked one unreflowed, so it is
|
|
4
|
+
// derived once per document and handed to every rule as data.
|
|
5
|
+
|
|
6
|
+
import type { DocModel, ParagraphView } from "./model.ts";
|
|
7
|
+
import { proseParagraphs } from "./query.ts";
|
|
8
|
+
import { countOf, type Range } from "./text.ts";
|
|
9
|
+
|
|
10
|
+
export const INTERPUNCT = "·";
|
|
11
|
+
|
|
12
|
+
// The glyph on its own, for counting, and the glyph with the space a run
|
|
13
|
+
// puts around it, for splitting. list-interpunct-joined-run and list-stacked-interpunct-runs both cut on the separator;
|
|
14
|
+
// punctuation-interpunct-in-prose is stricter and needs space on both sides, so it keeps its own.
|
|
15
|
+
export const GLYPH = /·/g;
|
|
16
|
+
export const RUN_SEPARATOR = /[ \t]*·[ \t]*/;
|
|
17
|
+
|
|
18
|
+
// A paragraph whose prose joins a run of items with 2+ interpunct separators.
|
|
19
|
+
export interface InterpunctRun {
|
|
20
|
+
range: Range;
|
|
21
|
+
line: number;
|
|
22
|
+
separators: number;
|
|
23
|
+
lines: number; // source lines of the paragraph that carry a separator
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const interpunctRuns = (doc: DocModel): InterpunctRun[] => {
|
|
27
|
+
const runs: InterpunctRun[] = [];
|
|
28
|
+
for (const view of proseParagraphs(doc)) {
|
|
29
|
+
const separators = countOf(view.prose, GLYPH);
|
|
30
|
+
if (separators < 2) continue;
|
|
31
|
+
const lines = view.raw.split("\n").filter((line) => line.includes(INTERPUNCT)).length;
|
|
32
|
+
runs.push({ range: [view.startOffset, view.endOffset], line: view.line, separators, lines });
|
|
33
|
+
}
|
|
34
|
+
return runs;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// A run on one source line is a flat list, which list-interpunct-joined-run owns. A run spread
|
|
38
|
+
// over two or more lines is a two-level structure, which list-stacked-interpunct-runs owns and
|
|
39
|
+
// sentence-one-per-line leaves unreflowed. The two predicates partition every run.
|
|
40
|
+
export const isFlat = (run: InterpunctRun): boolean => run.lines === 1;
|
|
41
|
+
|
|
42
|
+
export const isStacked = (run: InterpunctRun): boolean => run.lines >= 2;
|
|
43
|
+
|
|
44
|
+
// The paragraphs those runs were derived from. A run is keyed by the
|
|
45
|
+
// paragraph it spans, so this is how a rule gets from the runs it was handed
|
|
46
|
+
// back to the views its fixer needs.
|
|
47
|
+
export const paragraphsOf = (doc: DocModel, runs: InterpunctRun[]): ParagraphView[] =>
|
|
48
|
+
doc.paragraphs.filter((view) => runs.some((run) => run.range[0] === view.startOffset));
|
package/src/model.ts
CHANGED
|
@@ -9,6 +9,8 @@ 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 { Range } from "./text.ts";
|
|
12
14
|
|
|
13
15
|
// Fenced blocks in these languages hold markdown templates (a conventions or
|
|
14
16
|
// skeleton file shown verbatim): their prose is audited as embedded markdown
|
|
@@ -16,8 +18,6 @@ import { visitParents } from "unist-util-visit-parents";
|
|
|
16
18
|
// fence boundary.
|
|
17
19
|
const MARKDOWN_FENCE_LANGS = new Set(["markdown", "md"]);
|
|
18
20
|
|
|
19
|
-
export type Range = [number, number];
|
|
20
|
-
|
|
21
21
|
// A text node that is a direct child of its paragraph. Only these carry
|
|
22
22
|
// separators a fixer may split on: text inside a link, emphasis or code is
|
|
23
23
|
// cargo and moves whole.
|
|
@@ -48,7 +48,6 @@ export interface ParagraphView {
|
|
|
48
48
|
export interface TextView {
|
|
49
49
|
value: string;
|
|
50
50
|
line: number;
|
|
51
|
-
offset: number | undefined;
|
|
52
51
|
start: number | undefined;
|
|
53
52
|
end: number | undefined;
|
|
54
53
|
block: Node | undefined; // nearest paragraph, heading or table cell
|
|
@@ -61,12 +60,58 @@ export interface FenceView {
|
|
|
61
60
|
line: number; // line of the opening fence
|
|
62
61
|
}
|
|
63
62
|
|
|
63
|
+
// One top-level key of the frontmatter whose value is a string.
|
|
64
|
+
export interface FrontmatterEntry {
|
|
65
|
+
key: string;
|
|
66
|
+
value: string; // the scalar as text, folded lines joined
|
|
67
|
+
line: number; // file line where the value starts
|
|
68
|
+
start: number; // source offsets of the value, quotes included
|
|
69
|
+
end: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// How a YAML string is written, which a fixer needs to rewrite it safely.
|
|
73
|
+
export type ScalarStyle = "plain" | "double" | "single" | "literal" | "folded";
|
|
74
|
+
|
|
75
|
+
// A string value anywhere in the frontmatter, found by walking the whole YAML
|
|
76
|
+
// document in order. A value in a nested map or a list is here too, with the
|
|
77
|
+
// path that leads to it.
|
|
78
|
+
export interface FrontmatterScalar {
|
|
79
|
+
path: (string | number)[]; // keys and list indexes from the document root
|
|
80
|
+
key: string | null; // the nearest map key above it, at any depth
|
|
81
|
+
keyLine: number | null; // file line of that key
|
|
82
|
+
value: string; // the string as YAML reads it
|
|
83
|
+
line: number; // file line where the value starts
|
|
84
|
+
start: number; // absolute source offsets of the value, quotes included
|
|
85
|
+
end: number;
|
|
86
|
+
style: ScalarStyle;
|
|
87
|
+
indent: number; // leading spaces of the line the key, or the value, sits on
|
|
88
|
+
node: Scalar; // the yaml package node, for anything this view does not carry
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface FrontmatterView {
|
|
92
|
+
format: "yaml";
|
|
93
|
+
// The top-level string entries, kept for the common flat case.
|
|
94
|
+
entries: FrontmatterEntry[];
|
|
95
|
+
// Every string scalar at any depth, in document order.
|
|
96
|
+
scalars: FrontmatterScalar[];
|
|
97
|
+
// The whole parsed document, from the yaml package, for full traversal.
|
|
98
|
+
document: Document.Parsed;
|
|
99
|
+
// Absolute offset in `src` where the YAML text starts, so a node range from
|
|
100
|
+
// `document` maps to the source as offset + range.
|
|
101
|
+
offset: number;
|
|
102
|
+
// The file line of an absolute source offset inside the block.
|
|
103
|
+
lineOf: (absoluteOffset: number) => number;
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
export interface DocModel {
|
|
65
107
|
src: string;
|
|
66
108
|
tree: Root;
|
|
67
109
|
paragraphs: ParagraphView[];
|
|
68
110
|
texts: TextView[]; // every prose text node, including headings and lists
|
|
69
111
|
fences: FenceView[]; // embedded-markdown fences, audited recursively
|
|
112
|
+
// The leading YAML block as keys and values, read on first use so a document
|
|
113
|
+
// whose frontmatter is not valid YAML only fails the rules that ask for it.
|
|
114
|
+
readonly frontmatter: FrontmatterView | null;
|
|
70
115
|
}
|
|
71
116
|
|
|
72
117
|
export const parse = (src: string): Root =>
|
|
@@ -128,7 +173,6 @@ export const buildDocModel = (src: string): DocModel => {
|
|
|
128
173
|
texts.push({
|
|
129
174
|
value: t.value,
|
|
130
175
|
line: t.position?.start.line ?? 0,
|
|
131
|
-
offset: start,
|
|
132
176
|
start,
|
|
133
177
|
end,
|
|
134
178
|
block: ancestors.findLast((a) => a.type === "paragraph" || a.type === "heading" || a.type === "tableCell"),
|
|
@@ -152,14 +196,87 @@ export const buildDocModel = (src: string): DocModel => {
|
|
|
152
196
|
const view = viewOf.get(p);
|
|
153
197
|
if (view) view.prose = parts.join(" ").replace(/\s+/g, " ").trim();
|
|
154
198
|
}
|
|
155
|
-
|
|
199
|
+
let frontmatter: FrontmatterView | null | undefined;
|
|
200
|
+
return {
|
|
201
|
+
src,
|
|
202
|
+
tree,
|
|
203
|
+
paragraphs,
|
|
204
|
+
texts,
|
|
205
|
+
fences,
|
|
206
|
+
get frontmatter() {
|
|
207
|
+
frontmatter ??= readFrontmatter(src, tree);
|
|
208
|
+
return frontmatter;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
156
211
|
};
|
|
157
212
|
|
|
158
|
-
|
|
159
|
-
|
|
213
|
+
const STYLES: Record<string, ScalarStyle> = {
|
|
214
|
+
PLAIN: "plain",
|
|
215
|
+
QUOTE_DOUBLE: "double",
|
|
216
|
+
QUOTE_SINGLE: "single",
|
|
217
|
+
BLOCK_LITERAL: "literal",
|
|
218
|
+
BLOCK_FOLDED: "folded",
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// A leading YAML block as a whole document, plus the string scalars found by
|
|
222
|
+
// walking all of it. Only the first node of the tree can be frontmatter, and
|
|
223
|
+
// TOML is not read. A value that is not a string is left out of `scalars`
|
|
224
|
+
// rather than guessed at, and an alias is not followed. Invalid YAML throws with
|
|
225
|
+
// the line, so a rule that needs the keys cannot run on a block it could not read.
|
|
226
|
+
const readFrontmatter = (src: string, tree: Root): FrontmatterView | null => {
|
|
227
|
+
const node = tree.children[0];
|
|
228
|
+
const offset = node?.position?.start.offset;
|
|
229
|
+
if (node?.type !== "yaml" || offset == null || !node.position) return null;
|
|
230
|
+
const opening = node.position.start;
|
|
231
|
+
const contentStart = offset + src.slice(offset).indexOf("\n") + 1;
|
|
232
|
+
const lines = new LineCounter();
|
|
233
|
+
const doc = parseDocument(node.value, { lineCounter: lines });
|
|
234
|
+
const [problem] = doc.errors;
|
|
235
|
+
if (problem) {
|
|
236
|
+
const line = opening.line + (problem.linePos?.[0].line ?? 1);
|
|
237
|
+
throw new Error(`frontmatter YAML error at line ${line}: ${problem.message.split("\n")[0]}`);
|
|
238
|
+
}
|
|
239
|
+
const lineOf = (absolute: number): number => opening.line + lines.linePos(absolute - contentStart).line;
|
|
240
|
+
const indentOf = (relative: number): number => {
|
|
241
|
+
const lineStart = node.value.lastIndexOf("\n", relative - 1) + 1;
|
|
242
|
+
return /^ */.exec(node.value.slice(lineStart))?.[0].length ?? 0;
|
|
243
|
+
};
|
|
160
244
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
245
|
+
const scalars: FrontmatterScalar[] = [];
|
|
246
|
+
const walk = (value: unknown, path: (string | number)[], key: Scalar | null): void => {
|
|
247
|
+
if (isScalar(value)) {
|
|
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);
|
|
277
|
+
|
|
278
|
+
const entries: FrontmatterEntry[] = scalars
|
|
279
|
+
.filter((s) => s.path.length === 1 && typeof s.path[0] === "string")
|
|
280
|
+
.map((s) => ({ key: s.path[0] as string, value: s.value, line: s.line, start: s.start, end: s.end }));
|
|
281
|
+
return { format: "yaml", entries, scalars, document: doc, offset: contentStart, lineOf };
|
|
282
|
+
};
|
|
@@ -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,140 @@
|
|
|
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)) {
|
|
77
|
+
for (const item of target) {
|
|
78
|
+
const found = pick(item);
|
|
79
|
+
if (found) return found;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
if (isRecord(target)) {
|
|
84
|
+
for (const condition of ["node", "import", "default"]) {
|
|
85
|
+
if (condition in target) {
|
|
86
|
+
const found = pick(target[condition]);
|
|
87
|
+
if (found) return found;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const entryOf = (pkg: Record<string, unknown>): string => {
|
|
95
|
+
const exported = pkg.exports;
|
|
96
|
+
const hasMap = isRecord(exported) && Object.keys(exported).some((k) => k.startsWith("."));
|
|
97
|
+
const main = isRecord(exported) && "." in exported ? exported["."] : hasMap ? null : exported;
|
|
98
|
+
return pick(main) ?? (typeof pkg.main === "string" ? pkg.main : "index.js");
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const resolvePackage = async (root: string, name: string): Promise<string> => {
|
|
102
|
+
for (let dir = resolve(root); ; dir = dirname(dir)) {
|
|
103
|
+
const folder = join(dir, "node_modules", name);
|
|
104
|
+
const pkg = await readJson(join(folder, "package.json"));
|
|
105
|
+
if (pkg) return join(folder, entryOf(pkg));
|
|
106
|
+
if (dirname(dir) === dir) break;
|
|
107
|
+
}
|
|
108
|
+
throw new PluginNotInstalledError(name, root);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const declaredPlugins = async (root: string): Promise<string[]> => {
|
|
112
|
+
const pkg = await readJson(join(root, "package.json"));
|
|
113
|
+
if (!pkg) return [];
|
|
114
|
+
const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
|
|
115
|
+
const deps = pkg[field];
|
|
116
|
+
return isRecord(deps) ? Object.keys(deps) : [];
|
|
117
|
+
});
|
|
118
|
+
return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export const discoverSources = async (root: string, config: Config): Promise<Discovery> => {
|
|
122
|
+
const sources = await localSources(root);
|
|
123
|
+
const failures: PluginNotInstalledError[] = [];
|
|
124
|
+
const add = async (kind: SourceKind, spec: string, resolveIt: () => Promise<string>): Promise<void> => {
|
|
125
|
+
try {
|
|
126
|
+
sources.push({ kind, spec, path: await resolveIt() });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (!(err instanceof PluginNotInstalledError)) throw err;
|
|
129
|
+
failures.push(err);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
for (const name of await declaredPlugins(root)) await add("package", name, () => resolvePackage(root, name));
|
|
133
|
+
for (const spec of config.plugins || []) {
|
|
134
|
+
const path = spec.startsWith(".") || isAbsolute(spec);
|
|
135
|
+
await add("config", spec, () => (path ? Promise.resolve(resolve(root, spec)) : resolvePackage(root, spec)));
|
|
136
|
+
}
|
|
137
|
+
// A package both declared and listed in the config loads once.
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
return { sources: sources.filter((s) => !seen.has(s.path) && seen.add(s.path)), failures };
|
|
140
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Assembling plugins: discover the sources, load each one, and merge their
|
|
2
|
+
// rules and categories into the set the registry is built from. Namespaces and
|
|
3
|
+
// new category words are global, so two plugins cannot share either.
|
|
4
|
+
//
|
|
5
|
+
// How a failed source is treated is the mode. Strict throws the first failure,
|
|
6
|
+
// which is the default. Lenient leaves the failed source out and returns it, so
|
|
7
|
+
// the caller can say so. Collect does the same and is what validation uses, so
|
|
8
|
+
// one run reports every failure. Off loads nothing.
|
|
9
|
+
|
|
10
|
+
import type { Config } from "../config.ts";
|
|
11
|
+
import { PluginConflictError, PluginLoadError } from "../errors.ts";
|
|
12
|
+
import { BUILTIN_CATEGORIES, type LoadedSource } from "../rules/registry.ts";
|
|
13
|
+
import type { Rule } from "../rules/types.ts";
|
|
14
|
+
import type { Plugin } from "./contract.ts";
|
|
15
|
+
import { discoverSources, LOCAL_RULES, type SourceKind } from "./discover.ts";
|
|
16
|
+
import { loadLocal, loadPackage, toRule } from "./load.ts";
|
|
17
|
+
|
|
18
|
+
export type PluginMode = "off" | "strict" | "lenient" | "collect";
|
|
19
|
+
|
|
20
|
+
export interface PluginFailure {
|
|
21
|
+
source: string;
|
|
22
|
+
kind: SourceKind;
|
|
23
|
+
error: PluginLoadError;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PluginSet {
|
|
27
|
+
rules: Rule[];
|
|
28
|
+
categories: Map<string, string>;
|
|
29
|
+
sources: LoadedSource[];
|
|
30
|
+
// Specs found but not loaded, because the run asked for built-ins only.
|
|
31
|
+
skipped: string[];
|
|
32
|
+
// Sources left out because they failed. Always empty in strict mode, which throws.
|
|
33
|
+
failures: PluginFailure[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
|
|
37
|
+
const { sources: found, failures: unresolved } = await discoverSources(root, config);
|
|
38
|
+
const set: PluginSet = {
|
|
39
|
+
rules: [],
|
|
40
|
+
categories: new Map(BUILTIN_CATEGORIES),
|
|
41
|
+
sources: [],
|
|
42
|
+
skipped: [],
|
|
43
|
+
failures: [],
|
|
44
|
+
};
|
|
45
|
+
if (mode === "off") return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
|
|
46
|
+
|
|
47
|
+
// Strict stops at the first failure. The others record it and carry on.
|
|
48
|
+
const settle = (error: PluginLoadError, kind: SourceKind): void => {
|
|
49
|
+
if (mode === "strict") throw error;
|
|
50
|
+
if (!set.failures.some((f) => f.source === error.source)) set.failures.push({ source: error.source, kind, error });
|
|
51
|
+
};
|
|
52
|
+
for (const error of unresolved) settle(error, "package");
|
|
53
|
+
|
|
54
|
+
const loaded: { plugin: Plugin; kind: SourceKind; spec: string }[] = [];
|
|
55
|
+
const locals = found.filter((s) => s.kind === "local");
|
|
56
|
+
if (locals.length > 0) {
|
|
57
|
+
const local = await loadLocal(locals);
|
|
58
|
+
for (const error of local.failures) settle(error, "local");
|
|
59
|
+
if (local.plugin) loaded.push({ plugin: local.plugin, kind: "local", spec: LOCAL_RULES });
|
|
60
|
+
}
|
|
61
|
+
for (const source of found.filter((s) => s.kind !== "local")) {
|
|
62
|
+
try {
|
|
63
|
+
loaded.push({ plugin: await loadPackage(source, new Set(["local"])), kind: source.kind, spec: source.spec });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!(err instanceof PluginLoadError)) throw err;
|
|
66
|
+
settle(err, source.kind);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Namespaces and new categories are global. The first plugin to claim one
|
|
71
|
+
// keeps it, and a later claimant fails, naming both.
|
|
72
|
+
const namespaces = new Map<string, string>();
|
|
73
|
+
const declaredBy = new Map<string, string>();
|
|
74
|
+
const accepted: typeof loaded = [];
|
|
75
|
+
for (const entry of loaded) {
|
|
76
|
+
const { plugin, spec, kind } = entry;
|
|
77
|
+
const { namespace } = plugin.meta;
|
|
78
|
+
const words = Object.keys(plugin.meta.categories ?? {});
|
|
79
|
+
const taken = namespaces.get(namespace);
|
|
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);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
namespaces.set(namespace, spec);
|
|
90
|
+
for (const [word, description] of Object.entries(plugin.meta.categories ?? {})) {
|
|
91
|
+
declaredBy.set(word, spec);
|
|
92
|
+
set.categories.set(word, description);
|
|
93
|
+
}
|
|
94
|
+
accepted.push(entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const { plugin, kind, spec } of accepted) {
|
|
98
|
+
const rules = Object.entries(plugin.rules).map(([key, rule]) => toRule(plugin.meta.namespace, key, rule));
|
|
99
|
+
set.rules.push(...rules);
|
|
100
|
+
set.sources.push({ kind, spec, namespace: plugin.meta.namespace, rules: rules.map((r) => r.id) });
|
|
101
|
+
}
|
|
102
|
+
return set;
|
|
103
|
+
};
|