@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/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
- export const buildDocModel = (src: string): DocModel => {
127
- const tree = parse(src);
128
- const paragraphs: ParagraphView[] = [];
129
- const texts: TextView[] = [];
130
- const fences: FenceView[] = [];
131
- const viewOf = new Map<Paragraph, ParagraphView>();
132
- const proseParts = new Map<Paragraph, string[]>();
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
- if (node.type === "code") {
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 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);
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")
@@ -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
- for (const item of target) {
78
- const found = pick(item);
79
- if (found) return found;
80
- }
81
- return null;
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
- 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
- }
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
  };
@@ -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
- 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)] };
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
- // Strict stops at the first failure. The others record it and carry on.
48
- const settle = (error: PluginLoadError, kind: SourceKind): void => {
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 (!set.failures.some((f) => f.source === error.source)) set.failures.push({ source: error.source, kind, error });
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
- const loaded: { plugin: Plugin; kind: SourceKind; spec: string }[] = [];
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
- // 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 = [];
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 { 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);
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
  };
@@ -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
- // Checks one rule's declaration and returns it, so a bad rule fails at load
53
- // rather than the first time it runs.
54
- const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
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 !== undefined) {
79
- const valid = isRecord(options) && Object.values(options).every((t) => OPTION_TYPES.has(t as OptionType));
80
- if (!valid) {
81
- throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
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: Record<string, string> = {};
149
- const owner: Record<string, string> = {};
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
- const mod = await importModule(source, runtime);
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) {