@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
@@ -0,0 +1,12 @@
1
+ import { type ThresholdConfig } from "../parse/metrics.ts";
2
+ import { type Measured } from "./shared.ts";
3
+ import type { Rule, RuleId } from "./types.ts";
4
+ export interface Hit {
5
+ message: string;
6
+ remediation: string;
7
+ }
8
+ export declare const complexityRule: (spec: {
9
+ id: RuleId;
10
+ summary: string;
11
+ hit: (m: Measured, c: ThresholdConfig) => Hit | null;
12
+ }) => Rule;
@@ -0,0 +1,2 @@
1
+ import { type Rule } from "./types.ts";
2
+ export declare const contrastBorderBelowUiMinimum: Rule;
@@ -0,0 +1,2 @@
1
+ import { type Rule } from "./types.ts";
2
+ export declare const contrastDirectiveUnauditable: Rule;
@@ -0,0 +1,2 @@
1
+ import { type Rule } from "./types.ts";
2
+ export declare const contrastTextBelowAa: Rule;
@@ -0,0 +1,3 @@
1
+ import type { Rule } from "./types.ts";
2
+ export declare const RULES: Rule[];
3
+ export declare const FIX_ORDER: Rule[];
@@ -0,0 +1,2 @@
1
+ import { type Rule } from "./types.ts";
2
+ export declare const parseDiagramUnparseable: Rule;
@@ -0,0 +1,2 @@
1
+ import { type Rule } from "./types.ts";
2
+ export declare const parseFenceUnterminated: Rule;
@@ -0,0 +1,29 @@
1
+ import type { Config } from "../config.ts";
2
+ import { type Helpers, type Rule, type RuleId, type RuleOptions } from "./types.ts";
3
+ export declare const BUILTIN_CATEGORIES: ReadonlyMap<string, string>;
4
+ export declare const HELPERS: Helpers;
5
+ export interface LoadedSource {
6
+ kind: "local" | "package" | "config";
7
+ spec: string;
8
+ namespace: string;
9
+ rules: RuleId[];
10
+ }
11
+ export interface Registry {
12
+ rules: Rule[];
13
+ fixOrder: Rule[];
14
+ enabled: ReadonlySet<RuleId>;
15
+ options: ReadonlyMap<RuleId, RuleOptions>;
16
+ categories: ReadonlyMap<string, string>;
17
+ sources: LoadedSource[];
18
+ ignoredSettings: string[];
19
+ }
20
+ export interface RegistryInput {
21
+ rules: Rule[];
22
+ fixOrder: Rule[];
23
+ categories: ReadonlyMap<string, string>;
24
+ sources?: LoadedSource[];
25
+ config?: Config;
26
+ ignoreUnknownPluginIds?: boolean;
27
+ }
28
+ export declare const buildRegistry: (input: RegistryInput) => Registry;
29
+ export declare const BUILTIN: Registry;
@@ -0,0 +1,18 @@
1
+ import type { Diagram } from "../model.ts";
2
+ import { type ContrastResult, type Profile } from "../parse/contrast.ts";
3
+ import { type ComplexityMetrics, type ThresholdConfig } from "../parse/metrics.ts";
4
+ import type { DiagramStats } from "../parse/stats.ts";
5
+ import type { Finding, OptionSpec, RuleContext, RuleId } from "./types.ts";
6
+ export declare const COMPLEXITY_OPTIONS: OptionSpec;
7
+ export declare const CONTRAST_OPTIONS: OptionSpec;
8
+ export interface Measured {
9
+ stats: DiagramStats;
10
+ metrics: ComplexityMetrics;
11
+ }
12
+ export declare const measure: (diagram: Diagram, config: ThresholdConfig) => Promise<Measured | null>;
13
+ export declare const finding: (ctx: RuleContext, diagram: Diagram, rule: RuleId, message: string, remediation?: string) => Finding;
14
+ export declare const boundaryHint: (boundaries: string[]) => string;
15
+ export declare const splitTarget: (c: ThresholdConfig) => string;
16
+ export declare const profileOf: (ctx: RuleContext) => Profile;
17
+ export declare const contrastOf: (diagram: Diagram, profile: Profile) => ContrastResult;
18
+ export declare const fileLine: (diagram: Diagram, line: number) => number;
@@ -0,0 +1,67 @@
1
+ import type { DocModel } from "../model.ts";
2
+ import type { compositeOver, wcagRatio } from "../parse/color.ts";
3
+ import type { detectKeyword } from "../parse/stats.ts";
4
+ export declare const RULE: {
5
+ readonly UNPARSEABLE: "parse-diagram-unparseable";
6
+ readonly FENCE_UNTERMINATED: "parse-fence-unterminated";
7
+ readonly NODES_HARD: "complexity-node-count-hard-limit";
8
+ readonly NODES_COGNITIVE: "complexity-node-count-cognitive-limit";
9
+ readonly NODES_ACCEPTABLE: "complexity-node-count-acceptable-exceeded";
10
+ readonly VCS_CRITICAL: "complexity-visual-score-critical";
11
+ readonly VCS_ACCEPTABLE: "complexity-visual-score-acceptable-exceeded";
12
+ readonly NESTING: "complexity-subgraph-nesting-too-deep";
13
+ readonly TEXT: "contrast-text-below-aa";
14
+ readonly BORDER: "contrast-border-below-ui-minimum";
15
+ readonly UNAUDITABLE: "contrast-directive-unauditable";
16
+ };
17
+ export type CoreRuleId = (typeof RULE)[keyof typeof RULE];
18
+ export type Awaitable<T> = T | Promise<T>;
19
+ export type PluginRuleId = `${string}/${string}`;
20
+ export declare const isPluginRuleId: (id: string) => id is PluginRuleId;
21
+ export type RuleId = CoreRuleId | PluginRuleId;
22
+ export interface Finding {
23
+ file: string;
24
+ line: number;
25
+ endLine: number;
26
+ rule: RuleId;
27
+ message: string;
28
+ remediation?: string;
29
+ }
30
+ export type OptionType = "number" | "string" | "boolean" | "string[]";
31
+ export type OptionSpec = Readonly<Record<string, OptionType>>;
32
+ export type RuleOptions = Readonly<Record<string, unknown>>;
33
+ export interface Helpers {
34
+ detectKeyword: typeof detectKeyword;
35
+ wcagRatio: typeof wcagRatio;
36
+ compositeOver: typeof compositeOver;
37
+ }
38
+ export interface RuleContext {
39
+ doc: DocModel;
40
+ file: string;
41
+ options: RuleOptions;
42
+ helpers: Helpers;
43
+ }
44
+ export type CheckContext = RuleContext;
45
+ export type FixContext = RuleContext;
46
+ export type Expectation = {
47
+ kind: "same-diagrams";
48
+ } | {
49
+ kind: "same-tree";
50
+ };
51
+ export interface Edit {
52
+ rule: RuleId;
53
+ start: number;
54
+ end: number;
55
+ text: string;
56
+ expect: Expectation;
57
+ }
58
+ export declare const CATEGORIES: readonly ["parse", "complexity", "contrast"];
59
+ export type Category = (typeof CATEGORIES)[number];
60
+ export interface Rule {
61
+ id: RuleId;
62
+ category: string;
63
+ summary: string;
64
+ options?: OptionSpec;
65
+ check: (ctx: CheckContext) => Awaitable<Finding[]>;
66
+ fix?: (ctx: FixContext) => Awaitable<Edit[]>;
67
+ }
@@ -0,0 +1,15 @@
1
+ import { type RunFlags } from "./check.ts";
2
+ import { type FixReport } from "./fix/engine.ts";
3
+ import type { Plugin, PluginRule } from "./plugins/contract.ts";
4
+ import type { Finding, RuleOptions } from "./rules/types.ts";
5
+ export interface RunOptions {
6
+ options?: RuleOptions;
7
+ flags?: RunFlags;
8
+ file?: string;
9
+ }
10
+ export declare const localPlugin: (key: string, mod: {
11
+ default: PluginRule;
12
+ categories?: Record<string, string>;
13
+ }) => Plugin;
14
+ export declare const checkRule: (plugin: Plugin, key: string, src: string, run?: RunOptions) => Promise<Finding[]>;
15
+ export declare const fixRule: (plugin: Plugin, key: string, src: string, run?: RunOptions) => Promise<FixReport>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/mermaidjs-lint",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
6
6
  "license": "MIT",
@@ -29,15 +29,16 @@
29
29
  "bin": {
30
30
  "mermaidjs-lint": "dist/bin.js"
31
31
  },
32
+ "types": "./dist/types/index.d.ts",
32
33
  "exports": {
33
34
  ".": {
35
+ "types": "./dist/types/index.d.ts",
34
36
  "bun": "./src/index.ts",
35
- "types": "./src/index.ts",
36
37
  "default": "./dist/index.js"
37
38
  },
38
39
  "./testing": {
40
+ "types": "./dist/types/testing.d.ts",
39
41
  "bun": "./src/testing.ts",
40
- "types": "./src/testing.ts",
41
42
  "default": "./dist/testing.js"
42
43
  }
43
44
  },
@@ -51,6 +52,7 @@
51
52
  ],
52
53
  "dependencies": {
53
54
  "@mermaid-js/parser": "^1.1.0",
55
+ "@types/mdast": "^4",
54
56
  "colorjs.io": "^0.6.1",
55
57
  "happy-dom": "^20.9.0",
56
58
  "mdast-util-from-markdown": "^2.0.3",
@@ -64,7 +66,6 @@
64
66
  "@biomejs/biome": "^2",
65
67
  "@jpeakai/prose-gates": "^0.2.0",
66
68
  "@types/bun": "latest",
67
- "@types/mdast": "^4",
68
69
  "typescript": "^5"
69
70
  }
70
71
  }
package/src/cli.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // The command line: check files, optionally fix them first. The process glue
2
2
  // lives in bin.ts so tests can call main() directly.
3
3
 
4
- import { readFile, writeFile } from "node:fs/promises";
5
- import { relative } from "node:path";
4
+ import { lstat, readFile, realpath, writeFile } from "node:fs/promises";
5
+ import { isAbsolute, relative, resolve } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
7
  import pkg from "../package.json" with { type: "json" };
8
8
  import { checkModel, type RunFlags } from "./check.ts";
9
- import { FlagError, MermaidLintError, UsageError } from "./errors.ts";
9
+ import { FixOutsideRootError, FlagError, MermaidLintError, UsageError } from "./errors.ts";
10
10
  import { collectFiles, DIAGRAM_FILE_EXTS, SKIP_DIRS } from "./files.ts";
11
11
  import { fixSourceReport } from "./fix/engine.ts";
12
12
  import { buildDocModel, DIAGRAM_EXTS, documentKind } from "./model.ts";
@@ -16,7 +16,7 @@ import type { PluginFailure } from "./plugins/index.ts";
16
16
  import { setUp } from "./project.ts";
17
17
  import { RULES } from "./rules/index.ts";
18
18
  import type { LoadedSource, Registry } from "./rules/registry.ts";
19
- import { CATEGORIES, type Finding } from "./rules/types.ts";
19
+ import { CATEGORIES, type Finding, type Rule } from "./rules/types.ts";
20
20
 
21
21
  // The invocation forms, printed alone after a usage error so the error is not
22
22
  // buried, and first in --help.
@@ -26,19 +26,25 @@ const SYNOPSIS = `usage: mermaidjs-lint <path...> [--fix] [--json] [--preset NAM
26
26
  mermaidjs-lint --validate-plugins [--json] [--config FILE]
27
27
  mermaidjs-lint --version | -h, --help`;
28
28
 
29
+ // One rule as help and --list-rules print it: the id padded to a column, the
30
+ // autofix marker inside it, then the summary, so every summary lines up.
31
+ const ruleRow = (r: Rule, width: number): string => `${`${r.id}${r.fix ? "*" : ""}`.padEnd(width)} ${r.summary}`;
32
+
33
+ const idWidth = (rules: Rule[]): number => Math.max(0, ...rules.map((r) => r.id.length + 1));
34
+
29
35
  const USAGE = `${SYNOPSIS}
30
36
 
31
37
  Deterministic Mermaid diagram gates (* = autofixable):
32
38
  ${CATEGORIES.map((c) =>
33
- [` ${c}:`, ...RULES.filter((r) => r.category === c).map((r) => ` ${r.id}${r.fix ? "*" : " "} ${r.summary}`)].join(
34
- "\n",
35
- ),
39
+ [` ${c}:`, ...RULES.filter((r) => r.category === c).map((r) => ` ${ruleRow(r, idWidth(RULES))}`)].join("\n"),
36
40
  ).join("\n")}
37
41
 
38
42
  A path is a file or a directory, and every path must exist. A file ending in
39
43
  ${DIAGRAM_EXTS.join(" or ")} is read as one raw diagram, and any other file is read as markdown
40
44
  whose mermaid fences are checked. A directory is walked for ${[...DIAGRAM_FILE_EXTS].join(", ")} files,
41
- skipping dot-folders and ${[...SKIP_DIRS].join(", ")}.
45
+ skipping dot-folders and ${[...SKIP_DIRS].join(", ")}. The walk does not follow a symbolic link,
46
+ and names each one it skipped. A link named as a path is read, but --fix does not write
47
+ through one whose target is outside the project root.
42
48
  Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lint/reference/rules/
43
49
 
44
50
  -p, --preset NAME sets the density for every complexity rule: ${PRESET_NAMES.join(", ")}
@@ -49,14 +55,16 @@ Examples of every rule, with fixes: https://jpeakai.github.io/jpai-mermaidjs-lin
49
55
  --fix applies every fix it can prove safe, then reports what remains.
50
56
  A fixer that is unsure leaves the text alone and the finding stays.
51
57
  --config FILE reads that config instead of mermaidjs-lint.config.json or .mjs at the project root.
52
- Plugins load on their own from .mermaidjs-lint/rules and from dependencies named mermaidjs-lint-plugin-*.
58
+ Plugins load on their own from .mermaidjs-lint/rules, from dependencies named mermaidjs-lint-plugin,
59
+ mermaidjs-lint-plugin-* or @scope/mermaidjs-lint-plugin(-*), and from the config's plugins list.
53
60
  --no-plugins runs the built-in rules only and loads no plugin. It reads a .json config as usual,
54
61
  and stops with a usage error rather than import a .mjs config, which is code.
55
62
  --lenient-plugins skips a plugin that fails to load and says so, instead of stopping the run.
56
- --validate-plugins loads and checks every plugin, reports every failure, and runs no check or fix.
63
+ --validate-plugins imports and checks every plugin, reports every failure, and runs no check or fix.
57
64
  --list-rules prints every active rule by category, including plugin rules, and where each plugin came from.
65
+ It imports the plugins to list them.
58
66
  --version prints the version, to quote in a bug report.
59
- Exits 0 when no findings remain, 1 when findings remain or a plugin fails to load (a lenient run
67
+ Exits 0 when no findings remain, 1 when findings remain, a plugin fails to load or the run fails (a lenient run
60
68
  skips a broken plugin, so it alone does not exit 1), and 2 on a usage error.`;
61
69
 
62
70
  interface Parsed {
@@ -122,11 +130,12 @@ export const skippedLines = (failures: PluginFailure[], ignored: string[]): stri
122
130
  // Every active rule under its category, then where each plugin came from.
123
131
  export const listRules = (registry: Registry): string => {
124
132
  const lines: string[] = [];
133
+ const width = idWidth(registry.rules);
125
134
  for (const [category, description] of registry.categories) {
126
135
  const rules = registry.rules.filter((r) => r.category === category);
127
136
  if (rules.length === 0) continue;
128
137
  lines.push(`${category}: ${description}`);
129
- for (const r of rules) lines.push(` ${r.id}${r.fix ? "*" : " "} ${r.summary}`);
138
+ for (const r of rules) lines.push(` ${ruleRow(r, width)}`);
130
139
  }
131
140
  if (registry.sources.length > 0) {
132
141
  lines.push("", "plugins:");
@@ -180,6 +189,19 @@ const display = (file: string, cwd: string): string => {
180
189
  return !rel || rel.startsWith("..") ? file : rel;
181
190
  };
182
191
 
192
+ // --fix writes through a symbolic link to its target, so a link whose target
193
+ // sits outside the project root would let a run edit a file in another tree.
194
+ // Every such link is refused before any file is written.
195
+ const refuseOutsideLinks = async (files: string[], root: string, cwd: string): Promise<void> => {
196
+ const realRoot = await realpath(root);
197
+ for (const file of files) {
198
+ if (!(await lstat(file)).isSymbolicLink()) continue;
199
+ const target = await realpath(file);
200
+ const rel = relative(realRoot, target);
201
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new FixOutsideRootError(display(file, cwd), target, root);
202
+ }
203
+ };
204
+
183
205
  const run = async (argv: string[], cwd: string): Promise<number> => {
184
206
  let parsed: Parsed;
185
207
  try {
@@ -215,7 +237,7 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
215
237
  }
216
238
  if (positionals.length === 0 && !listing && !validating) throw new FlagError("files", "no files given");
217
239
 
218
- const { registry, skipped, failures } = await setUp({
240
+ const { root, registry, skipped, failures } = await setUp({
219
241
  cwd,
220
242
  configPath: values.config,
221
243
  noPlugins: values["no-plugins"],
@@ -238,10 +260,18 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
238
260
  return 0;
239
261
  }
240
262
 
241
- const { files, unreadable } = collectFiles(positionals.map((p) => (p.startsWith("/") ? p : `${cwd}/${p}`)));
263
+ const { files, unreadable, symlinks } = collectFiles(positionals.map((p) => resolve(cwd, p)));
242
264
  if (unreadable.length > 0) throw new FlagError("files", `cannot read ${unreadable.join(", ")}`);
265
+ for (const link of symlinks) {
266
+ console.error(`mermaidjs-lint: skipped symbolic link ${display(link, cwd)}; name it as a path to check it`);
267
+ }
243
268
  if (files.length === 0) throw new FlagError("files", "no matching files");
269
+ if (values.fix) await refuseOutsideLinks(files, root, cwd);
244
270
 
271
+ // Every rule, in check and in fix, sees the file as an absolute path, so a
272
+ // rule that reads the path sees one form, and the auto profile is found
273
+ // from the file's own folder whatever the process's working directory. The
274
+ // report shows the path relative to cwd.
245
275
  const flags: RunFlags = { preset: values.preset, profile: values.profile };
246
276
  const perFile = await Promise.all(
247
277
  files.map(async (file) => {
@@ -253,7 +283,9 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
253
283
  src = fixed;
254
284
  }
255
285
  }
256
- return checkModel(buildDocModel(src, documentKind(file)), display(file, cwd), flags, registry);
286
+ const shown = display(file, cwd);
287
+ const found = await checkModel(buildDocModel(src, documentKind(file)), file, flags, registry);
288
+ return found.map((f) => ({ ...f, file: shown }));
257
289
  }),
258
290
  );
259
291
  const all = perFile.flat();
package/src/errors.ts CHANGED
@@ -130,6 +130,52 @@ export class RuleOptionError extends UsageError {
130
130
  }
131
131
  }
132
132
 
133
+ // --fix would write through a symbolic link whose target sits outside the
134
+ // project root, so the run stops before any file is written.
135
+ export class FixOutsideRootError extends UsageError {
136
+ readonly file: string;
137
+ readonly target: string;
138
+
139
+ constructor(file: string, target: string, root: string) {
140
+ super(
141
+ `${file} is a symbolic link to ${target}, outside the project root ${root}; --fix does not write through it. Fix the target in its own project, or name it directly`,
142
+ "fix-outside-root",
143
+ );
144
+ this.file = file;
145
+ this.target = target;
146
+ }
147
+ }
148
+
149
+ // --- runtime: the project or a fixer is in a state the run cannot use -------
150
+
151
+ // A package.json the run reads, the project's own or an installed plugin's, is
152
+ // not a JSON object. Exits 1, like any runtime failure.
153
+ export class PackageJsonError extends MermaidLintError {
154
+ readonly file: string;
155
+
156
+ constructor(file: string) {
157
+ super("package-json", `${file} is not a JSON object; fix or remove it`);
158
+ this.file = file;
159
+ }
160
+ }
161
+
162
+ // A fixer, built-in or plugin, kept proposing edits that verified, so the fix
163
+ // never reached a fixpoint. A fixer must be idempotent: once its edit is
164
+ // applied, it proposes nothing for the same text. Nothing is written.
165
+ export class FixNotConvergingError extends MermaidLintError {
166
+ readonly file: string;
167
+ readonly rules: string[];
168
+
169
+ constructor(file: string, passes: number, rules: string[]) {
170
+ super(
171
+ "fix-not-converging",
172
+ `${file}: --fix did not converge after ${passes} passes; ${rules.join(", ")} kept proposing edits. A fixer must propose nothing once its edit is applied`,
173
+ );
174
+ this.file = file;
175
+ this.rules = rules;
176
+ }
177
+ }
178
+
133
179
  // --- plugins: a source could not be loaded --------------------------------
134
180
 
135
181
  // A source that failed to load. `source` is what the user would recognise, a
package/src/files.ts CHANGED
@@ -17,15 +17,30 @@ export const SKIP_DIRS = new Set(["node_modules", "vendor", "dist", "build", "tm
17
17
 
18
18
  const skipDir = (name: string): boolean => name.startsWith(".") || SKIP_DIRS.has(name);
19
19
 
20
+ // Whether a link resolves to a directory. A dangling link resolves to nothing.
21
+ const linksToDir = (path: string): boolean => {
22
+ try {
23
+ return statSync(path).isDirectory();
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
28
+
20
29
  export interface Collected {
21
30
  files: string[];
22
31
  // Paths that could not be read, which the caller reports as usage errors.
23
32
  unreadable: string[];
33
+ // Symbolic links a directory walk passed over, to files or folders alike.
34
+ // A walk never follows a link, so it cannot leave the tree it was given or
35
+ // loop, and the caller says which it skipped so a gate never passes in
36
+ // silence over a file it did not read. A link named as a path is followed.
37
+ symlinks: string[];
24
38
  }
25
39
 
26
40
  export const collectFiles = (paths: string[]): Collected => {
27
41
  const files: string[] = [];
28
42
  const unreadable: string[] = [];
43
+ const symlinks: string[] = [];
29
44
  const seen = new Set<string>();
30
45
 
31
46
  const walk = (dir: string): void => {
@@ -38,7 +53,11 @@ export const collectFiles = (paths: string[]): Collected => {
38
53
  }
39
54
  for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
40
55
  const abs = join(dir, entry.name);
41
- if (entry.isDirectory()) {
56
+ if (entry.isSymbolicLink()) {
57
+ if (!skipDir(entry.name) && (DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase()) || linksToDir(abs))) {
58
+ symlinks.push(abs);
59
+ }
60
+ } else if (entry.isDirectory()) {
42
61
  if (!skipDir(entry.name)) walk(abs);
43
62
  } else if (entry.isFile() && DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase())) {
44
63
  if (!seen.has(abs)) {
@@ -64,5 +83,5 @@ export const collectFiles = (paths: string[]): Collected => {
64
83
  unreadable.push(p);
65
84
  }
66
85
  }
67
- return { files, unreadable };
86
+ return { files, unreadable, symlinks };
68
87
  };
package/src/fix/engine.ts CHANGED
@@ -4,12 +4,14 @@
4
4
  // computed from, so every accepted edit restarts the pass.
5
5
 
6
6
  import { optionsFor, type RunFlags } from "../check.ts";
7
+ import { FixNotConvergingError } from "../errors.ts";
7
8
  import { buildDocModel, documentKind } from "../model.ts";
8
9
  import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
9
10
  import type { Edit } from "../rules/types.ts";
10
11
  import { verify } from "./verify.ts";
11
12
 
12
13
  const MAX_PASSES = 1_000;
14
+ const RECENT_PASSES = 50;
13
15
 
14
16
  export interface FixReport {
15
17
  output: string;
@@ -51,6 +53,10 @@ export const fixSourceReport = async (
51
53
  const refusedKeys = new Set<string>();
52
54
  let current = src;
53
55
 
56
+ // The rule whose edit each pass kept, so a run that never converges can name
57
+ // the rules still proposing edits at the end.
58
+ const keptBy: string[] = [];
59
+
54
60
  for (let pass = 0; pass < MAX_PASSES; pass++) {
55
61
  const doc = buildDocModel(current, kind);
56
62
  const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
@@ -85,21 +91,27 @@ export const fixSourceReport = async (
85
91
  // the rest.
86
92
  if (edits.length > 1 && !overlaps(edits) && (await attempt(edits))) {
87
93
  progressed = true;
88
- break;
89
- }
90
- for (const edit of edits) {
91
- if (await attempt([edit])) {
92
- progressed = true;
93
- break;
94
+ } else {
95
+ for (const edit of edits) {
96
+ if (await attempt([edit])) {
97
+ progressed = true;
98
+ break;
99
+ }
100
+ refusedKeys.add(keyOf(edit));
101
+ refused.push(edit);
94
102
  }
95
- refusedKeys.add(keyOf(edit));
96
- refused.push(edit);
97
103
  }
98
- if (progressed) break;
104
+ if (progressed) {
105
+ keptBy.push(rule.id);
106
+ break;
107
+ }
99
108
  }
100
109
  if (!progressed) return { output: current, applied, refused };
101
110
  }
102
- throw new Error(`fix did not converge after ${MAX_PASSES} passes; a fixer keeps proposing edits`);
111
+ // The last passes show who was still editing: a fixer that never converges
112
+ // keeps one in every pass.
113
+ const lingering = [...new Set(keptBy.slice(-RECENT_PASSES))].sort();
114
+ throw new FixNotConvergingError(file, MAX_PASSES, lingering);
103
115
  };
104
116
 
105
117
  export const fixSource = async (
package/src/fix/verify.ts CHANGED
@@ -57,9 +57,14 @@ const sameDiagrams = async (before: DocModel, after: DocModel): Promise<boolean>
57
57
  return true;
58
58
  };
59
59
 
60
+ // A raw diagram file (.mmd, .mermaid) has no markdown around its diagram, so
61
+ // its tree is an empty root and comparing trees would prove nothing. There,
62
+ // same-tree also demands what same-diagrams does: the diagram is still there
63
+ // and parses to the same structure.
60
64
  export const verify = async (edit: Edit, before: DocModel, after: DocModel): Promise<Verdict> => {
61
65
  if (edit.expect.kind === "same-tree") {
62
- return sameTree(before.tree, after.tree, {}) ? "accept" : "refuse";
66
+ if (!sameTree(before.tree, after.tree, {})) return "refuse";
67
+ if (before.kind === "markdown" && after.kind === "markdown") return "accept";
63
68
  }
64
69
  return (await sameDiagrams(before, after)) ? "accept" : "refuse";
65
70
  };
package/src/index.ts CHANGED
@@ -4,14 +4,16 @@ export { checkMarkdown, checkModel, type RunFlags } from "./check.ts";
4
4
  export { main } from "./cli.ts";
5
5
  export { type Config, loadConfig, type PluginLoading, parseConfig, type RuleSetting, type Severity } from "./config.ts";
6
6
  export * from "./errors.ts";
7
- export { collectFiles } from "./files.ts";
7
+ export { type Collected, collectFiles } from "./files.ts";
8
8
  export { type FixReport, fixSource, fixSourceReport } from "./fix/engine.ts";
9
9
  export { buildDocModel, DIAGRAM_EXTS, type Diagram, type DocModel, documentKind } from "./model.ts";
10
10
  export type { ContrastAssessment, StyleDirective, ValueSpan } from "./parse/color.ts";
11
- export { PRESETS, thresholdsFrom } from "./parse/metrics.ts";
11
+ export { PRESETS, type Preset, type ThresholdConfig, thresholdsFrom } from "./parse/metrics.ts";
12
12
  export { type DiagramStats, detectKeyword, extractStats } from "./parse/stats.ts";
13
13
  export type { Plugin, PluginEdit, PluginFinding, PluginMeta, PluginRule } from "./plugins/contract.ts";
14
- export { findProjectRoot, setUp } from "./project.ts";
14
+ export type { SourceKind } from "./plugins/discover.ts";
15
+ export type { PluginFailure } from "./plugins/index.ts";
16
+ export { findProjectRoot, type Setup, type SetupOptions, setUp } from "./project.ts";
15
17
  export { FIX_ORDER, RULES } from "./rules/index.ts";
16
18
  export {
17
19
  BUILTIN,