@jpeakai/mermaidjs-lint 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.
Files changed (54) hide show
  1. package/README.md +7 -5
  2. package/dist/bin.js +174 -55
  3. package/dist/index.js +177 -55
  4. package/dist/testing.js +59 -23
  5. package/dist/types/bin.d.ts +2 -0
  6. package/dist/types/check.d.ts +10 -0
  7. package/dist/types/cli.d.ts +9 -0
  8. package/dist/types/config.d.ts +20 -0
  9. package/dist/types/errors.d.ts +88 -0
  10. package/dist/types/files.d.ts +8 -0
  11. package/dist/types/fix/engine.d.ts +10 -0
  12. package/dist/types/fix/verify.d.ts +12 -0
  13. package/dist/types/index.d.ts +18 -0
  14. package/dist/types/model.d.ts +27 -0
  15. package/dist/types/parse/color.d.ts +28 -0
  16. package/dist/types/parse/contrast.d.ts +35 -0
  17. package/dist/types/parse/metrics.d.ts +48 -0
  18. package/dist/types/parse/stats.d.ts +24 -0
  19. package/dist/types/plugins/contract.d.ts +24 -0
  20. package/dist/types/plugins/discover.d.ts +20 -0
  21. package/dist/types/plugins/index.d.ts +19 -0
  22. package/dist/types/plugins/load.d.ts +14 -0
  23. package/dist/types/plugins/runtime.d.ts +6 -0
  24. package/dist/types/project.d.ts +18 -0
  25. package/dist/types/rules/complexity-node-count-acceptable-exceeded.d.ts +1 -0
  26. package/dist/types/rules/complexity-node-count-cognitive-limit.d.ts +1 -0
  27. package/dist/types/rules/complexity-node-count-hard-limit.d.ts +1 -0
  28. package/dist/types/rules/complexity-subgraph-nesting-too-deep.d.ts +2 -0
  29. package/dist/types/rules/complexity-visual-score-acceptable-exceeded.d.ts +1 -0
  30. package/dist/types/rules/complexity-visual-score-critical.d.ts +1 -0
  31. package/dist/types/rules/complexity.d.ts +12 -0
  32. package/dist/types/rules/contrast-border-below-ui-minimum.d.ts +2 -0
  33. package/dist/types/rules/contrast-directive-unauditable.d.ts +2 -0
  34. package/dist/types/rules/contrast-text-below-aa.d.ts +2 -0
  35. package/dist/types/rules/index.d.ts +3 -0
  36. package/dist/types/rules/parse-diagram-unparseable.d.ts +2 -0
  37. package/dist/types/rules/parse-fence-unterminated.d.ts +2 -0
  38. package/dist/types/rules/registry.d.ts +29 -0
  39. package/dist/types/rules/shared.d.ts +18 -0
  40. package/dist/types/rules/types.d.ts +67 -0
  41. package/dist/types/testing.d.ts +15 -0
  42. package/package.json +5 -4
  43. package/src/cli.ts +47 -15
  44. package/src/errors.ts +46 -0
  45. package/src/files.ts +21 -2
  46. package/src/fix/engine.ts +22 -10
  47. package/src/fix/verify.ts +6 -1
  48. package/src/index.ts +5 -3
  49. package/src/parse/stats.ts +39 -11
  50. package/src/plugins/contract.ts +4 -2
  51. package/src/plugins/discover.ts +38 -15
  52. package/src/plugins/index.ts +5 -3
  53. package/src/plugins/load.ts +32 -1
  54. package/src/project.ts +3 -1
@@ -599,17 +599,20 @@ function adaptMindmap(db: AnyDb): Structure {
599
599
  return { nodeIds: labels, edges: Math.max(0, labels.length - 1), subgraphNames: [], maxDepth };
600
600
  }
601
601
 
602
+ type QuadrantText = { text?: string } | string | undefined;
602
603
  interface QuadrantData {
603
- quadrants?: Array<{ text?: { text?: string } | string }>;
604
+ quadrants?: Array<{ text?: QuadrantText }>;
605
+ points?: Array<{ text?: QuadrantText }>;
604
606
  }
607
+ // A node is every plotted point, plus every quadrant that carries a label. The
608
+ // four quadrant boxes are always drawn, so an unlabelled one adds nothing to
609
+ // read, while every point is a mark the reader must place.
605
610
  function adaptQuadrantChart(db: AnyDb): Structure {
606
611
  const data = callGetter<QuadrantData | null>(db, "getQuadrantData");
607
- const quadrants = data?.quadrants ?? [];
608
- const labels = quadrants.map((q) => {
609
- const t = q.text;
610
- return typeof t === "string" ? t : (t?.text ?? "");
611
- });
612
- return { nodeIds: labels, edges: 0, subgraphNames: [], maxDepth: 0 };
612
+ const textOf = (t: QuadrantText): string => (typeof t === "string" ? t : (t?.text ?? ""));
613
+ const quadrants = (data?.quadrants ?? []).map((q) => textOf(q.text)).filter((t) => t.trim() !== "");
614
+ const points = (data?.points ?? []).map((p, i) => textOf(p.text) || `point_${i}`);
615
+ return { nodeIds: [...quadrants, ...points], edges: 0, subgraphNames: [], maxDepth: 0 };
613
616
  }
614
617
 
615
618
  function adaptRequirement(db: AnyDb): Structure {
@@ -639,36 +642,56 @@ function adaptSankey(db: AnyDb): Structure {
639
642
  interface StateNode {
640
643
  id?: string;
641
644
  type?: string;
645
+ isGroup?: boolean;
646
+ parentId?: string;
642
647
  }
643
648
  interface StateEdge {
644
649
  id1?: string;
645
650
  id2?: string;
646
651
  }
652
+ // A composite state (`state A { ... }`) is a group that holds other states,
653
+ // so it counts as a subgraph too, and its nesting depth is read from each
654
+ // state's parentId chain. It stays a node as well, because it is drawn as a
655
+ // labelled state.
647
656
  function adaptState(db: AnyDb): Structure {
648
657
  // stateDiagram db exposes data on direct properties.
649
658
  const nodes = (db.nodes as StateNode[] | undefined) ?? [];
650
659
  const edges = (db.edges as StateEdge[] | undefined) ?? [];
660
+ const groups = nodes.filter((n) => n.isGroup === true);
661
+ const parentOf = new Map(nodes.map((n) => [n.id ?? "", n.parentId]));
662
+ let maxDepth = 0;
663
+ for (const g of groups) {
664
+ let depth = 1;
665
+ const seen = new Set<string>();
666
+ for (let cur = g.parentId; cur && !seen.has(cur); cur = parentOf.get(cur)) {
667
+ seen.add(cur);
668
+ depth++;
669
+ }
670
+ maxDepth = Math.max(maxDepth, depth);
671
+ }
651
672
  return {
652
673
  nodeIds: nodes.map((n) => n.id ?? ""),
653
674
  edges: edges.length,
654
- subgraphNames: [],
655
- maxDepth: 0,
675
+ subgraphNames: groups.map((g) => g.id ?? ""),
676
+ maxDepth,
656
677
  };
657
678
  }
658
679
 
659
680
  interface TimelineTask {
660
681
  task?: string;
661
682
  section?: string;
683
+ events?: string[];
662
684
  }
663
685
  interface TimelineSection {
664
686
  sectionName?: string;
665
687
  section?: string;
666
688
  }
667
689
  function adaptTimeline(db: AnyDb): Structure {
690
+ // Every period and every event under it is a box on the page.
668
691
  const tasks = callGetter<TimelineTask[]>(db, "getTasks") ?? [];
669
692
  const sections = callGetter<TimelineSection[]>(db, "getSections") ?? [];
670
693
  return {
671
- nodeIds: tasks.map((t) => t.task ?? ""),
694
+ nodeIds: tasks.flatMap((t) => [t.task ?? "", ...(t.events ?? []).map((e) => `${t.task ?? ""}: ${e}`)]),
672
695
  edges: 0,
673
696
  subgraphNames: sections.map((s) => s.sectionName ?? s.section ?? ""),
674
697
  maxDepth: sections.length > 0 ? 1 : 0,
@@ -714,11 +737,16 @@ function adaptVenn(db: AnyDb): Structure {
714
737
 
715
738
  interface XYChartData {
716
739
  xAxis?: { categories?: string[] };
740
+ plots?: Array<{ data?: unknown[] }>;
717
741
  }
742
+ // A node is an x position: a category, or, on a numeric x-axis, a point of the
743
+ // longest series.
718
744
  function adaptXychart(db: AnyDb): Structure {
719
745
  const data = callGetter<XYChartData | null>(db, "getXYChartData");
720
746
  const categories = data?.xAxis?.categories ?? [];
721
- return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
747
+ if (categories.length > 0) return { nodeIds: categories, edges: 0, subgraphNames: [], maxDepth: 0 };
748
+ const longest = Math.max(0, ...(data?.plots ?? []).map((p) => p.data?.length ?? 0));
749
+ return { nodeIds: Array.from({ length: longest }, (_, i) => `x_${i}`), edges: 0, subgraphNames: [], maxDepth: 0 };
722
750
  }
723
751
 
724
752
  function subgraphNestingDepth(subGraphs: Array<{ id?: string; nodes?: string[] }>): number {
@@ -37,5 +37,7 @@ export interface Plugin {
37
37
 
38
38
  // A one-word category and a namespace or rule key, as they appear in an id.
39
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-]*$/;
40
+ // Lower-case words joined by single hyphens: no leading, trailing or doubled
41
+ // hyphen, so `house-` and `a-` are refused.
42
+ export const NAMESPACE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
43
+ export const RULE_KEY = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
@@ -4,14 +4,16 @@
4
4
  // names a config lists. A directory of node_modules is never scanned, so only
5
5
  // what the project chose to depend on can run.
6
6
  //
7
- // A declared package that is not installed is a failure the caller decides
8
- // how to treat, not an exception, so a lenient run can skip it and a
9
- // validating run can report every one. A malformed package.json throws.
7
+ // A declared package that is not installed, or whose package.json is not a
8
+ // JSON object, is a failure the caller decides how to treat, not an exception,
9
+ // so a lenient run can skip it and a validating run can report every one. A
10
+ // malformed project package.json throws PackageJsonError, except in a run with
11
+ // plugins off, which loads no package it names.
10
12
 
11
13
  import { readdir, readFile } from "node:fs/promises";
12
14
  import { dirname, extname, isAbsolute, join, resolve } from "node:path";
13
15
  import type { Config } from "../config.ts";
14
- import { PluginNotInstalledError } from "../errors.ts";
16
+ import { PackageJsonError, PluginLoadError, PluginNotInstalledError, PluginSourceError } from "../errors.ts";
15
17
 
16
18
  export type SourceKind = "local" | "package" | "config";
17
19
 
@@ -23,10 +25,16 @@ export interface PluginSource {
23
25
  path: string;
24
26
  }
25
27
 
28
+ export interface UnresolvedSource {
29
+ kind: SourceKind;
30
+ error: PluginLoadError;
31
+ }
32
+
26
33
  export interface Discovery {
27
34
  sources: PluginSource[];
28
- // Sources found but not resolvable, such as a declared package that is not installed.
29
- failures: PluginNotInstalledError[];
35
+ // Sources found but not resolvable, such as a declared package that is not
36
+ // installed, each with the kind of source that named it.
37
+ failures: UnresolvedSource[];
30
38
  }
31
39
 
32
40
  export const LOCAL_RULES = join(".mermaidjs-lint", "rules");
@@ -49,7 +57,7 @@ const readJson = async (path: string): Promise<Record<string, unknown> | null> =
49
57
  } catch {
50
58
  // Reported below with the path.
51
59
  }
52
- throw new Error(`${path} is not a JSON object`);
60
+ throw new PackageJsonError(path);
53
61
  };
54
62
 
55
63
  // Every extension a local rule may have. Whether the runtime can import a
@@ -101,15 +109,27 @@ const entryOf = (pkg: Record<string, unknown>): string => {
101
109
  export const resolvePackage = async (root: string, name: string): Promise<string> => {
102
110
  for (let dir = resolve(root); ; dir = dirname(dir)) {
103
111
  const folder = join(dir, "node_modules", name);
104
- const pkg = await readJson(join(folder, "package.json"));
112
+ let pkg: Record<string, unknown> | null;
113
+ try {
114
+ pkg = await readJson(join(folder, "package.json"));
115
+ } catch (err) {
116
+ if (!(err instanceof PackageJsonError)) throw err;
117
+ throw new PluginSourceError(name, `its package.json at ${err.file} is not a JSON object`);
118
+ }
105
119
  if (pkg) return join(folder, entryOf(pkg));
106
120
  if (dirname(dir) === dir) break;
107
121
  }
108
122
  throw new PluginNotInstalledError(name, root);
109
123
  };
110
124
 
111
- const declaredPlugins = async (root: string): Promise<string[]> => {
112
- const pkg = await readJson(join(root, "package.json"));
125
+ const declaredPlugins = async (root: string, tolerateBadManifest: boolean): Promise<string[]> => {
126
+ let pkg: Record<string, unknown> | null;
127
+ try {
128
+ pkg = await readJson(join(root, "package.json"));
129
+ } catch (err) {
130
+ if (tolerateBadManifest && err instanceof PackageJsonError) return [];
131
+ throw err;
132
+ }
113
133
  if (!pkg) return [];
114
134
  const names = ["dependencies", "devDependencies", "optionalDependencies"].flatMap((field) => {
115
135
  const deps = pkg[field];
@@ -118,18 +138,21 @@ const declaredPlugins = async (root: string): Promise<string[]> => {
118
138
  return [...new Set(names)].filter((name) => PACKAGE_PATTERN.test(name)).sort();
119
139
  };
120
140
 
121
- export const discoverSources = async (root: string, config: Config): Promise<Discovery> => {
141
+ // A run with plugins off loads nothing, so a malformed project package.json,
142
+ // which could only name packages that run will not load, does not stop it.
143
+ export const discoverSources = async (root: string, config: Config, pluginsOff = false): Promise<Discovery> => {
122
144
  const sources = await localSources(root);
123
- const failures: PluginNotInstalledError[] = [];
145
+ const failures: UnresolvedSource[] = [];
124
146
  const add = async (kind: SourceKind, spec: string, resolveIt: () => Promise<string>): Promise<void> => {
125
147
  try {
126
148
  sources.push({ kind, spec, path: await resolveIt() });
127
149
  } catch (err) {
128
- if (!(err instanceof PluginNotInstalledError)) throw err;
129
- failures.push(err);
150
+ if (!(err instanceof PluginLoadError)) throw err;
151
+ failures.push({ kind, error: err });
130
152
  }
131
153
  };
132
- for (const name of await declaredPlugins(root)) await add("package", name, () => resolvePackage(root, name));
154
+ for (const name of await declaredPlugins(root, pluginsOff))
155
+ await add("package", name, () => resolvePackage(root, name));
133
156
  for (const spec of config.plugins || []) {
134
157
  const path = spec.startsWith(".") || isAbsolute(spec);
135
158
  await add("config", spec, () => (path ? Promise.resolve(resolve(root, spec)) : resolvePackage(root, spec)));
@@ -34,7 +34,7 @@ export interface PluginSet {
34
34
  }
35
35
 
36
36
  export const loadPlugins = async (root: string, config: Config, mode: PluginMode): Promise<PluginSet> => {
37
- const { sources: found, failures: unresolved } = await discoverSources(root, config);
37
+ const { sources: found, failures: unresolved } = await discoverSources(root, config, mode === "off");
38
38
  const set: PluginSet = {
39
39
  rules: [],
40
40
  categories: new Map(BUILTIN_CATEGORIES),
@@ -42,14 +42,16 @@ export const loadPlugins = async (root: string, config: Config, mode: PluginMode
42
42
  skipped: [],
43
43
  failures: [],
44
44
  };
45
- if (mode === "off") return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((e) => e.source)] };
45
+ if (mode === "off") {
46
+ return { ...set, skipped: [...found.map((s) => s.spec), ...unresolved.map((u) => u.error.source)] };
47
+ }
46
48
 
47
49
  // Strict stops at the first failure. The others record it and carry on.
48
50
  const settle = (error: PluginLoadError, kind: SourceKind): void => {
49
51
  if (mode === "strict") throw error;
50
52
  if (!set.failures.some((f) => f.source === error.source)) set.failures.push({ source: error.source, kind, error });
51
53
  };
52
- for (const error of unresolved) settle(error, "package");
54
+ for (const { error, kind } of unresolved) settle(error, kind);
53
55
 
54
56
  const loaded: { plugin: Plugin; kind: SourceKind; spec: string }[] = [];
55
57
  const locals = found.filter((s) => s.kind === "local");
@@ -49,6 +49,9 @@ 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
+ // Option names every rule shares with the built-ins. See checkRule.
53
+ const RESERVED_OPTIONS = ["preset", "profile"] as const;
54
+
52
55
  // Checks one rule's declaration and returns it, so a bad rule fails at load
53
56
  // rather than the first time it runs.
54
57
  const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<string>): PluginRule => {
@@ -80,6 +83,18 @@ const checkRule = (spec: string, key: string, raw: unknown, own: ReadonlySet<str
80
83
  if (!valid) {
81
84
  throw new PluginContractError(spec, `${where} options must map names to number, string, boolean or string[]`);
82
85
  }
86
+ // preset and profile keep their built-in meaning for every rule: the
87
+ // config checks the value against the built-in names, and --preset and
88
+ // --profile overwrite it. A rule may declare one to follow the run's
89
+ // setting, as a string, but cannot give the name another meaning.
90
+ for (const name of RESERVED_OPTIONS) {
91
+ if (name in options && (options as Record<string, unknown>)[name] !== "string") {
92
+ throw new PluginContractError(
93
+ spec,
94
+ `${where} declares option "${name}" as ${String((options as Record<string, unknown>)[name])}, but "${name}" is reserved for the built-in ${name}, which is a string set by --${name}; declare it as "string" to follow the run's ${name}, or pick another name`,
95
+ );
96
+ }
97
+ }
83
98
  }
84
99
  return raw as unknown as PluginRule;
85
100
  };
@@ -148,7 +163,23 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
148
163
  const categories: Record<string, string> = {};
149
164
  const owner: Record<string, string> = {};
150
165
  const loaded: { key: string; source: PluginSource; raw: unknown }[] = [];
166
+ // A rule's key is its file's base name, so two files with one base name
167
+ // (house-x.js and house-x.mjs) would claim one rule. Neither is imported,
168
+ // and the failure names both, rather than one silently replacing the other.
169
+ const keyOf = (source: PluginSource): string => basename(source.path, extname(source.path));
170
+ const byKey = new Map<string, PluginSource[]>();
171
+ for (const source of sources) byKey.set(keyOf(source), [...(byKey.get(keyOf(source)) ?? []), source]);
172
+ for (const [key, group] of byKey) {
173
+ if (group.length < 2) continue;
174
+ fail(
175
+ new PluginContractError(
176
+ (group[0] as PluginSource).spec,
177
+ `rule "${key}" is defined by more than one file: ${group.map((s) => s.spec).join(", ")}; each rule comes from exactly one file, so rename or remove all but one`,
178
+ ),
179
+ );
180
+ }
151
181
  for (const source of sources) {
182
+ if ((byKey.get(keyOf(source))?.length ?? 0) > 1) continue;
152
183
  try {
153
184
  const mod = await importModule(source, runtime);
154
185
  if (mod.default === undefined) {
@@ -166,7 +197,7 @@ export const loadLocal = async (sources: PluginSource[], runtime?: RuntimeInfo):
166
197
  categories[word] = description;
167
198
  owner[word] = source.spec;
168
199
  }
169
- loaded.push({ key: basename(source.path, extname(source.path)), source, raw: mod.default });
200
+ loaded.push({ key: keyOf(source), source, raw: mod.default });
170
201
  } catch (err) {
171
202
  fail(err);
172
203
  }
package/src/project.ts CHANGED
@@ -43,6 +43,8 @@ export interface SetupOptions {
43
43
  }
44
44
 
45
45
  export interface Setup {
46
+ // The project root the config and plugins were found from.
47
+ root: string;
46
48
  registry: Registry;
47
49
  skipped: string[];
48
50
  // Plugins left out because they failed. Empty unless the run was lenient or collecting.
@@ -84,5 +86,5 @@ export const setUp = async ({
84
86
  // rather than failing the run. A built-in id never is.
85
87
  ignoreUnknownPluginIds: mode !== "strict",
86
88
  });
87
- return { registry, skipped: plugins.skipped, failures: plugins.failures };
89
+ return { root, registry, skipped: plugins.skipped, failures: plugins.failures };
88
90
  };