@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.
Files changed (58) hide show
  1. package/README.md +37 -4
  2. package/dist/bin.js +8472 -539
  3. package/dist/index.js +8500 -540
  4. package/dist/testing.js +18321 -0
  5. package/examples/plugin-skills/README.md +60 -0
  6. package/examples/plugin-skills/index.mjs +14 -0
  7. package/examples/plugin-skills/package.json +12 -0
  8. package/examples/plugin-skills/rules/frontmatter-description-multiline-string.mjs +63 -0
  9. package/examples/plugin-skills/rules/frontmatter-description-word-budget.mjs +35 -0
  10. package/examples/plugin-skills-typescript/README.md +42 -0
  11. package/examples/plugin-skills-typescript/index.ts +21 -0
  12. package/examples/plugin-skills-typescript/package.json +12 -0
  13. package/examples/plugin-skills-typescript/rules/frontmatter-description-multiline-string.ts +68 -0
  14. package/examples/plugin-skills-typescript/rules/frontmatter-description-word-budget.ts +40 -0
  15. package/examples/plugin-skills-typescript/tsconfig.json +14 -0
  16. package/package.json +10 -3
  17. package/src/bin.ts +2 -2
  18. package/src/check.ts +40 -13
  19. package/src/cli.ts +197 -28
  20. package/src/config.ts +119 -0
  21. package/src/errors.ts +191 -0
  22. package/src/fix/engine.ts +31 -9
  23. package/src/fix/promote.ts +14 -2
  24. package/src/fix/verify.ts +29 -0
  25. package/src/index.ts +38 -2
  26. package/src/interpunct.ts +48 -0
  27. package/src/model.ts +129 -12
  28. package/src/plugins/contract.ts +41 -0
  29. package/src/plugins/discover.ts +140 -0
  30. package/src/plugins/index.ts +103 -0
  31. package/src/plugins/load.ts +234 -0
  32. package/src/plugins/runtime.ts +15 -0
  33. package/src/project.ts +81 -0
  34. package/src/query.ts +131 -0
  35. package/src/rules/index.ts +31 -12
  36. package/src/rules/list-comma-labelled-run.ts +101 -0
  37. package/src/rules/list-inline-enumeration-markers.ts +127 -0
  38. package/src/rules/list-interpunct-joined-run.ts +68 -0
  39. package/src/rules/list-semicolon-delimited-run.ts +60 -0
  40. package/src/rules/list-stacked-interpunct-runs.ts +80 -0
  41. package/src/rules/punctuation-em-dash-in-prose.ts +155 -0
  42. package/src/rules/punctuation-interpunct-in-prose.ts +52 -0
  43. package/src/rules/registry.ts +133 -0
  44. package/src/rules/sentence-one-per-line.ts +99 -0
  45. package/src/rules/{pg002-length.ts → sentence-word-budget-exceeded.ts} +22 -20
  46. package/src/rules/types.ts +70 -25
  47. package/src/testing.ts +60 -0
  48. package/src/text.ts +50 -0
  49. package/src/fix/spans.ts +0 -75
  50. package/src/rules/interpunct.ts +0 -19
  51. package/src/rules/pg001-wrap.ts +0 -79
  52. package/src/rules/pg003-semicolon-list.ts +0 -68
  53. package/src/rules/pg004-em-dash.ts +0 -117
  54. package/src/rules/pg005-interpunct.ts +0 -43
  55. package/src/rules/pg006-interpunct-run.ts +0 -84
  56. package/src/rules/pg007-inline-enum.ts +0 -119
  57. package/src/rules/pg008-labelled-run.ts +0 -94
  58. 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 { buildDocModel, type DocModel } from "./model.ts";
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 type { Category, Edit, Finding, Rule, RuleId } from "./rules/types.ts";
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
- return { src, tree, paragraphs, texts, fences };
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
- export const inRanges = (offset: number, ranges: Range[]): boolean =>
159
- ranges.some(([a, b]) => offset >= a && offset < b);
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
- export const sentences = (text: string): string[] =>
162
- text
163
- .split(/(?<=[.!?])\s+/)
164
- .map((s) => s.trim())
165
- .filter((s) => s.length > 0);
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
+ };