@jpeakai/mermaidjs-lint 0.7.1 → 0.7.2

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.
@@ -60,6 +60,11 @@ export interface SequenceFeatures {
60
60
  }
61
61
  export declare const sequenceFeatures: (db: SequenceDb) => SequenceFeatures;
62
62
  export declare const scoreSequence: (source: string, profile: Profile, canvas?: Canvas) => Promise<ContrastResult>;
63
- export declare const scoreDiagram: (source: string, directives: StyleDirective[], profile: Profile, keyword: string | null, canvas?: Canvas) => Promise<ContrastResult>;
63
+ export interface ScoredDiagram {
64
+ source: string;
65
+ directives: StyleDirective[];
66
+ keyword: string | null;
67
+ }
68
+ export declare const scoreDiagram: ({ source, directives, keyword }: ScoredDiagram, profile: Profile, canvas?: Canvas) => Promise<ContrastResult>;
64
69
  export declare const detectProfile: (filePath: string) => Profile;
65
70
  export {};
@@ -0,0 +1,16 @@
1
+ export interface GraphNode {
2
+ id: string;
3
+ label: string;
4
+ parent: string | null;
5
+ }
6
+ export interface GraphEdge {
7
+ source: string;
8
+ target: string;
9
+ }
10
+ export interface DiagramGraph {
11
+ diagram_type: string;
12
+ nodes: GraphNode[];
13
+ edges: GraphEdge[];
14
+ subgraphs: GraphNode[];
15
+ }
16
+ export declare const extractGraph: (content: string) => Promise<DiagramGraph>;
@@ -10,7 +10,11 @@ export interface Measured {
10
10
  metrics: ComplexityMetrics;
11
11
  }
12
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;
13
+ export interface FindingText {
14
+ message: string;
15
+ remediation?: string | undefined;
16
+ }
17
+ export declare const finding: (ctx: RuleContext, diagram: Diagram, rule: RuleId, { message, remediation }: FindingText) => Finding;
14
18
  export declare const boundaryHint: (boundaries: string[]) => string;
15
19
  export declare const splitTarget: (c: ThresholdConfig) => string;
16
20
  export declare const profileOf: (ctx: RuleContext) => Profile;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jpeakai/mermaidjs-lint",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
5
  "description": "Deterministic lint gates and verified fixers for Mermaid diagrams in markdown",
6
6
  "license": "MIT",
package/src/check.ts CHANGED
@@ -84,6 +84,18 @@ const setsAside = (rule: Rule, options: Record<string, unknown>, flags: RunFlags
84
84
  canvasOptions(rule).some((name) => options[name] !== undefined) &&
85
85
  detectProfile(file) === "mkdocs-material";
86
86
 
87
+ // A canvas option with each side the flag gives laid over the configured one.
88
+ const mergeCanvas = (current: unknown, canvas: CanvasSetting): Record<string, string> => {
89
+ const merged: Record<string, string> = {
90
+ ...(typeof current === "object" && current !== null ? (current as Record<string, string>) : {}),
91
+ };
92
+ for (const side of THEMES) {
93
+ const value = canvas[side];
94
+ if (value !== undefined) merged[side] = value;
95
+ }
96
+ return merged;
97
+ };
98
+
87
99
  // The options a rule runs with: its configured options, then a flag for any
88
100
  // option the rule declares. A preset reaches the rule by its canonical name,
89
101
  // so a plugin rule never has to resolve an alias such as `high` or `H`. Given
@@ -105,17 +117,7 @@ export const optionsFor = (
105
117
  // canvas type, not only one named canvas.
106
118
  const canvas = flags.canvas;
107
119
  if (canvas && hasFlagCanvas(flags)) {
108
- for (const name of canvasOptions(rule)) {
109
- const current = options[name];
110
- const merged: Record<string, string> = {
111
- ...(typeof current === "object" && current !== null ? (current as Record<string, string>) : {}),
112
- };
113
- for (const side of THEMES) {
114
- const value = canvas[side];
115
- if (value !== undefined) merged[side] = value;
116
- }
117
- options[name] = merged;
118
- }
120
+ for (const name of canvasOptions(rule)) options[name] = mergeCanvas(options[name], canvas);
119
121
  }
120
122
  if (file !== undefined && setsAside(rule, options, flags, file)) {
121
123
  for (const name of canvasOptions(rule)) delete options[name];
package/src/cli.ts CHANGED
@@ -233,133 +233,154 @@ const refuseOutsideLinks = async (files: string[], root: string, cwd: string): P
233
233
  }
234
234
  };
235
235
 
236
- const run = async (argv: string[], cwd: string): Promise<number> => {
237
- let parsed: Parsed;
236
+ type Values = Parsed["values"];
237
+
238
+ const parseArgv = (argv: string[]): Parsed => {
238
239
  try {
239
- parsed = parse(argv);
240
+ return parse(argv);
240
241
  } catch (err) {
241
242
  throw new FlagError("argv", err instanceof Error ? err.message : String(err));
242
243
  }
243
- const { values, positionals } = parsed;
244
- if (values.help) {
245
- console.log(USAGE);
246
- return 0;
247
- }
248
- if (values.version) {
249
- console.log(pkg.version);
250
- return 0;
251
- }
252
- const listing = values["list-rules"];
253
- const validating = values["validate-plugins"];
244
+ };
245
+
246
+ const runFlagsOf = (values: Values): RunFlags => {
254
247
  const canvas = {
255
248
  ...(values["canvas-light"] !== undefined && { light: values["canvas-light"] }),
256
249
  ...(values["canvas-dark"] !== undefined && { dark: values["canvas-dark"] }),
257
250
  };
258
- const flags: RunFlags = {
251
+ return {
259
252
  preset: values.preset,
260
253
  profile: values.profile,
261
254
  ...(Object.keys(canvas).length > 0 && { canvas }),
262
255
  };
263
- checkRunFlags(flags, "--");
256
+ };
257
+
258
+ const checkPluginFlags = (values: Values): void => {
264
259
  if (values["no-plugins"] && values["lenient-plugins"]) {
265
260
  throw new FlagError("--lenient-plugins", "--no-plugins and --lenient-plugins contradict each other");
266
261
  }
267
- if (validating && (values["no-plugins"] || values.fix || listing)) {
262
+ if (values["validate-plugins"] && (values["no-plugins"] || values.fix || values["list-rules"])) {
268
263
  throw new FlagError("--validate-plugins", "--validate-plugins takes no --no-plugins, --fix or --list-rules");
269
264
  }
270
- const enable = categoryWords("--category", values.category);
271
- const disable = categoryWords("--disable-category", values["disable-category"]);
272
- // Validation loads every plugin whatever the category flags say, so it
273
- // refuses them rather than ignore them.
274
- if (validating && enable.length + disable.length > 0) {
275
- throw new FlagError("--validate-plugins", "--validate-plugins checks every plugin, so it takes no category flag");
276
- }
277
- // --list-rules prints the rules the config and the plugin flags leave
278
- // active. Any other flag or a path would be silently ignored, so it is refused.
279
- if (listing && !validating) {
280
- const ignored = [
281
- ...(["fix", "json", "preset", "profile", "canvas-light", "canvas-dark"] as const)
282
- .filter((name) => values[name] !== undefined && values[name] !== false)
283
- .map((name) => `--${name}`),
284
- ...(positionals.length > 0 ? ["a path"] : []),
285
- ];
286
- if (ignored.length > 0) {
287
- throw new FlagError(
288
- "--list-rules",
289
- `--list-rules takes only --config and --no-plugins or --lenient-plugins, not ${ignored.join(", ")}`,
290
- );
291
- }
265
+ };
266
+
267
+ // --list-rules prints the rules the config and the plugin flags leave
268
+ // active. Any other flag or a path would be silently ignored, so it is refused.
269
+ const checkListingFlags = (values: Values, positionals: string[]): void => {
270
+ const ignored = [
271
+ ...(["fix", "json", "preset", "profile", "canvas-light", "canvas-dark"] as const)
272
+ .filter((name) => values[name] !== undefined && values[name] !== false)
273
+ .map((name) => `--${name}`),
274
+ ...(positionals.length > 0 ? ["a path"] : []),
275
+ ];
276
+ if (ignored.length > 0) {
277
+ throw new FlagError(
278
+ "--list-rules",
279
+ `--list-rules takes only --config and --no-plugins or --lenient-plugins, not ${ignored.join(", ")}`,
280
+ );
292
281
  }
282
+ };
283
+
284
+ // The paths each mode takes: a check needs at least one, validation none.
285
+ const checkPositionals = (values: Values, positionals: string[]): void => {
286
+ const validating = values["validate-plugins"];
293
287
  if (validating && positionals.length > 0) {
294
288
  throw new FlagError("files", "--validate-plugins runs no check, so it takes no files");
295
289
  }
296
- if (positionals.length === 0 && !listing && !validating) throw new FlagError("files", "no files given");
290
+ if (positionals.length === 0 && !(values["list-rules"] || validating)) {
291
+ throw new FlagError("files", "no files given");
292
+ }
293
+ };
297
294
 
298
- const { root, registry, skipped, failures } = await setUp({
299
- cwd,
300
- configPath: values.config,
301
- noPlugins: values["no-plugins"],
302
- lenient: values["lenient-plugins"],
303
- collect: validating,
304
- selection: { enable, disable, prefix: "--" },
305
- });
295
+ interface CategoryWords {
296
+ enable: string[];
297
+ disable: string[];
298
+ }
306
299
 
307
- if (validating) {
308
- const result = validate(registry, failures, Boolean(values.json));
309
- for (const line of result.stderr) console.error(line);
310
- if (result.stdout) console.log(result.stdout);
311
- return result.code;
300
+ // The category flags as word lists, after every flag they combine with is checked.
301
+ const checkCombinations = (values: Values, positionals: string[]): CategoryWords => {
302
+ checkPluginFlags(values);
303
+ const enable = categoryWords("--category", values.category);
304
+ const disable = categoryWords("--disable-category", values["disable-category"]);
305
+ const validating = values["validate-plugins"];
306
+ // Validation loads every plugin whatever the category flags say, so it
307
+ // refuses them rather than ignore them.
308
+ if (validating && enable.length + disable.length > 0) {
309
+ throw new FlagError("--validate-plugins", "--validate-plugins checks every plugin, so it takes no category flag");
312
310
  }
311
+ if (values["list-rules"] && !validating) checkListingFlags(values, positionals);
312
+ checkPositionals(values, positionals);
313
+ return { enable, disable };
314
+ };
313
315
 
314
- const note = loadedLine(registry.sources, skipped);
315
- if (note) console.error(note);
316
- for (const line of skippedLines(failures, registry.ignoredSettings)) console.error(line);
317
- if (listing) {
318
- console.log(listRules(registry));
319
- return 0;
320
- }
316
+ const printValidation = (registry: Registry, failures: PluginFailure[], json: boolean): number => {
317
+ const result = validate(registry, failures, json);
318
+ for (const line of result.stderr) console.error(line);
319
+ if (result.stdout) console.log(result.stdout);
320
+ return result.code;
321
+ };
321
322
 
323
+ // The files the paths name, refusing an unreadable path or an empty walk, and
324
+ // naming each symbolic link the walk skipped.
325
+ const filesToCheck = (positionals: string[], cwd: string): string[] => {
322
326
  const { files, unreadable, symlinks } = collectFiles(positionals.map((p) => resolve(cwd, p)));
323
327
  if (unreadable.length > 0) throw new FlagError("files", `cannot read ${unreadable.join(", ")}`);
324
328
  for (const link of symlinks) {
325
329
  console.error(`mermaidjs-lint: skipped symbolic link ${display(link, cwd)}; name it as a path to check it`);
326
330
  }
327
331
  if (files.length === 0) throw new FlagError("files", "no matching files");
328
- if (values.fix) await refuseOutsideLinks(files, root, cwd);
332
+ return files;
333
+ };
329
334
 
330
- // A config canvas is set aside for a file the auto profile reads as
331
- // mkdocs-material, and one line names every such file, so the run says what
332
- // it scored without the canvas. The --json object carries the same paths, so
333
- // a job that reads only stdout still learns the canvas was dropped.
335
+ // A config canvas is set aside for a file the auto profile reads as
336
+ // mkdocs-material, and one line names every such file, so the run says what
337
+ // it scored without the canvas. The --json object carries the same paths, so
338
+ // a job that reads only stdout still learns the canvas was dropped.
339
+ const reportSetAside = (files: string[], flags: RunFlags, registry: Registry, cwd: string): string[] => {
334
340
  const setAside = files.filter((file) => canvasSetAside(file, flags, registry)).map((f) => display(f, cwd));
335
341
  if (setAside.length > 0) {
336
342
  console.error(
337
343
  `mermaidjs-lint: config canvas set aside for ${plural(setAside.length, "file")} scored under mkdocs-material: ${setAside.join(", ")}`,
338
344
  );
339
345
  }
346
+ return setAside;
347
+ };
340
348
 
341
- // Every rule, in check and in fix, sees the file as an absolute path, so a
342
- // rule that reads the path sees one form, and the auto profile is found
343
- // from the file's own folder whatever the process's working directory. The
344
- // report shows the path relative to cwd.
345
- const perFile = await Promise.all(
346
- files.map(async (file) => {
347
- let src = await readFile(file, "utf8");
348
- if (values.fix) {
349
- const fixed = (await fixSourceReport(src, file, flags, registry)).output;
350
- if (fixed !== src) {
351
- await writeFile(file, fixed);
352
- src = fixed;
353
- }
354
- }
355
- const shown = display(file, cwd);
356
- const found = await checkModel(buildDocModel(src, documentKind(file)), file, flags, registry);
357
- return found.map((f) => ({ ...f, file: shown }));
358
- }),
359
- );
360
- const all = perFile.flat();
349
+ interface FileRun {
350
+ flags: RunFlags;
351
+ registry: Registry;
352
+ fix: boolean;
353
+ cwd: string;
354
+ }
361
355
 
362
- if (values.json) {
356
+ // Every rule, in check and in fix, sees the file as an absolute path, so a
357
+ // rule that reads the path sees one form, and the auto profile is found
358
+ // from the file's own folder whatever the process's working directory. The
359
+ // report shows the path relative to cwd.
360
+ const checkFile = async (file: string, { flags, registry, fix, cwd }: FileRun): Promise<Finding[]> => {
361
+ let src = await readFile(file, "utf8");
362
+ if (fix) {
363
+ const fixed = (await fixSourceReport(src, file, flags, registry)).output;
364
+ if (fixed !== src) {
365
+ await writeFile(file, fixed);
366
+ src = fixed;
367
+ }
368
+ }
369
+ const shown = display(file, cwd);
370
+ const found = await checkModel(buildDocModel(src, documentKind(file)), file, flags, registry);
371
+ return found.map((f) => ({ ...f, file: shown }));
372
+ };
373
+
374
+ interface Report {
375
+ all: Finding[];
376
+ files: number;
377
+ registry: Registry;
378
+ failures: PluginFailure[];
379
+ setAside: string[];
380
+ }
381
+
382
+ const printReport = (json: boolean, { all, files, registry, failures, setAside }: Report): void => {
383
+ if (json) {
363
384
  const skippedPlugins = failures.map((f) => ({
364
385
  source: f.source,
365
386
  kind: f.kind,
@@ -368,15 +389,57 @@ const run = async (argv: string[], cwd: string): Promise<number> => {
368
389
  }));
369
390
  console.log(
370
391
  JSON.stringify(
371
- { findings: all, files: files.length, plugins: registry.sources, skippedPlugins, canvasSetAside: setAside },
392
+ { findings: all, files, plugins: registry.sources, skippedPlugins, canvasSetAside: setAside },
372
393
  null,
373
394
  2,
374
395
  ),
375
396
  );
376
- } else {
377
- for (const f of all) console.log(reportLine(f, f.file));
378
- console.log(`${all.length} finding(s) in ${files.length} file(s)`);
397
+ return;
379
398
  }
399
+ for (const f of all) console.log(reportLine(f, f.file));
400
+ console.log(`${all.length} finding(s) in ${files} file(s)`);
401
+ };
402
+
403
+ const run = async (argv: string[], cwd: string): Promise<number> => {
404
+ const { values, positionals } = parseArgv(argv);
405
+ if (values.help) {
406
+ console.log(USAGE);
407
+ return 0;
408
+ }
409
+ if (values.version) {
410
+ console.log(pkg.version);
411
+ return 0;
412
+ }
413
+ const flags = runFlagsOf(values);
414
+ checkRunFlags(flags, "--");
415
+ const { enable, disable } = checkCombinations(values, positionals);
416
+
417
+ const { root, registry, skipped, failures } = await setUp({
418
+ cwd,
419
+ configPath: values.config,
420
+ noPlugins: values["no-plugins"],
421
+ lenient: values["lenient-plugins"],
422
+ collect: values["validate-plugins"],
423
+ selection: { enable, disable, prefix: "--" },
424
+ });
425
+
426
+ if (values["validate-plugins"]) return printValidation(registry, failures, Boolean(values.json));
427
+
428
+ const note = loadedLine(registry.sources, skipped);
429
+ if (note) console.error(note);
430
+ for (const line of skippedLines(failures, registry.ignoredSettings)) console.error(line);
431
+ if (values["list-rules"]) {
432
+ console.log(listRules(registry));
433
+ return 0;
434
+ }
435
+
436
+ const files = filesToCheck(positionals, cwd);
437
+ const fix = Boolean(values.fix);
438
+ if (fix) await refuseOutsideLinks(files, root, cwd);
439
+ const setAside = reportSetAside(files, flags, registry, cwd);
440
+ const perFile = await Promise.all(files.map((file) => checkFile(file, { flags, registry, fix, cwd })));
441
+ const all = perFile.flat();
442
+ printReport(Boolean(values.json), { all, files: files.length, registry, failures, setAside });
380
443
  return all.length > 0 ? 1 : 0;
381
444
  };
382
445
 
package/src/files.ts CHANGED
@@ -17,6 +17,8 @@ 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
+ const isDiagramFile = (name: string): boolean => DIAGRAM_FILE_EXTS.has(extname(name).toLowerCase());
21
+
20
22
  // Whether a link resolves to a directory. A dangling link resolves to nothing.
21
23
  const linksToDir = (path: string): boolean => {
22
24
  try {
@@ -43,6 +45,24 @@ export const collectFiles = (paths: string[]): Collected => {
43
45
  const symlinks: string[] = [];
44
46
  const seen = new Set<string>();
45
47
 
48
+ // A file reached by more than one path is listed once.
49
+ const add = (abs: string): void => {
50
+ if (seen.has(abs)) return;
51
+ seen.add(abs);
52
+ files.push(abs);
53
+ };
54
+
55
+ const visit = (dir: string, entry: Dirent): void => {
56
+ const abs = join(dir, entry.name);
57
+ if (entry.isSymbolicLink()) {
58
+ if (!skipDir(entry.name) && (isDiagramFile(entry.name) || linksToDir(abs))) symlinks.push(abs);
59
+ } else if (entry.isDirectory()) {
60
+ if (!skipDir(entry.name)) walk(abs);
61
+ } else if (entry.isFile() && isDiagramFile(entry.name)) {
62
+ add(abs);
63
+ }
64
+ };
65
+
46
66
  const walk = (dir: string): void => {
47
67
  let entries: Dirent[];
48
68
  try {
@@ -51,21 +71,7 @@ export const collectFiles = (paths: string[]): Collected => {
51
71
  unreadable.push(dir);
52
72
  return;
53
73
  }
54
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
55
- const abs = join(dir, entry.name);
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()) {
61
- if (!skipDir(entry.name)) walk(abs);
62
- } else if (entry.isFile() && DIAGRAM_FILE_EXTS.has(extname(entry.name).toLowerCase())) {
63
- if (!seen.has(abs)) {
64
- seen.add(abs);
65
- files.push(abs);
66
- }
67
- }
68
- }
74
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) visit(dir, entry);
69
75
  };
70
76
 
71
77
  for (const p of paths) {
@@ -74,10 +80,9 @@ export const collectFiles = (paths: string[]): Collected => {
74
80
  const st = statSync(abs);
75
81
  if (st.isDirectory()) {
76
82
  walk(abs);
77
- } else if (st.isFile() && !seen.has(abs)) {
83
+ } else if (st.isFile()) {
78
84
  // An explicitly named file is honoured whatever its extension.
79
- seen.add(abs);
80
- files.push(abs);
85
+ add(abs);
81
86
  }
82
87
  } catch {
83
88
  unreadable.push(p);
package/src/fix/engine.ts CHANGED
@@ -5,9 +5,9 @@
5
5
 
6
6
  import { checkRunFlags, optionsFor, type RunFlags } from "../check.ts";
7
7
  import { FixNotConvergingError } from "../errors.ts";
8
- import { buildDocModel, documentKind } from "../model.ts";
8
+ import { buildDocModel, type DocModel, documentKind } from "../model.ts";
9
9
  import { BUILTIN, HELPERS, type Registry } from "../rules/registry.ts";
10
- import type { Edit } from "../rules/types.ts";
10
+ import type { Edit, Rule } from "../rules/types.ts";
11
11
  import { verify } from "./verify.ts";
12
12
 
13
13
  const MAX_PASSES = 1_000;
@@ -40,6 +40,73 @@ const wellFormed = (e: Edit, length: number): boolean =>
40
40
  e.end <= length &&
41
41
  typeof e.text === "string";
42
42
 
43
+ // The state one fix run carries from pass to pass.
44
+ interface FixRun {
45
+ file: string;
46
+ flags: RunFlags;
47
+ registry: Registry;
48
+ kind: "markdown" | "diagram";
49
+ current: string;
50
+ applied: Edit[];
51
+ refused: Edit[];
52
+ // An edit refused once stays refused while its source slice is unchanged.
53
+ refusedKeys: Set<string>;
54
+ }
55
+
56
+ const keyOf = (run: FixRun, e: Edit): string => `${e.rule}\u0000${run.current.slice(e.start, e.end)}\u0000${e.text}`;
57
+
58
+ // Splice the edits, re-parse, and keep the result only when every edit
59
+ // survives verification against the pass's model.
60
+ const attempt = async (run: FixRun, doc: DocModel, edits: Edit[]): Promise<boolean> => {
61
+ const next = splice(run.current, edits);
62
+ const after = buildDocModel(next, run.kind);
63
+ for (const e of edits) if ((await verify(e, doc, after)) !== "accept") return false;
64
+ run.current = next;
65
+ run.applied.push(...edits);
66
+ return true;
67
+ };
68
+
69
+ // The well-formed edits. A malformed one is refused, once per rule and span.
70
+ const soundEdits = (run: FixRun, proposed: Edit[]): Edit[] =>
71
+ proposed.filter((e) => {
72
+ if (wellFormed(e, run.current.length)) return true;
73
+ const key = `${e.rule}\u0000malformed\u0000${e.start}:${e.end}`;
74
+ if (!run.refusedKeys.has(key)) {
75
+ run.refusedKeys.add(key);
76
+ run.refused.push(e);
77
+ }
78
+ return false;
79
+ });
80
+
81
+ // Whether one rule's edits change the source, trying its batch first.
82
+ const applyRule = async (run: FixRun, doc: DocModel, rule: Rule): Promise<boolean> => {
83
+ const options = optionsFor(rule, run.registry.options.get(rule.id), run.flags, run.file);
84
+ const proposed = (await rule.fix?.({ doc, file: run.file, options, helpers: HELPERS })) ?? [];
85
+ const edits = soundEdits(run, proposed).filter(
86
+ (e) => run.current.slice(e.start, e.end) !== e.text && !run.refusedKeys.has(keyOf(run, e)),
87
+ );
88
+ if (edits.length === 0) return false;
89
+
90
+ // A rule's whole batch is proven with one re-parse, and a batch that
91
+ // fails falls back to one edit at a time, so one bad edit never blocks
92
+ // the rest.
93
+ if (edits.length > 1 && !overlaps(edits) && (await attempt(run, doc, edits))) return true;
94
+ for (const edit of edits) {
95
+ if (await attempt(run, doc, [edit])) return true;
96
+ run.refusedKeys.add(keyOf(run, edit));
97
+ run.refused.push(edit);
98
+ }
99
+ return false;
100
+ };
101
+
102
+ // One pass over the fixers in priority order. It returns the rule whose edit
103
+ // it kept, or undefined when no rule changed the source.
104
+ const runPass = async (run: FixRun): Promise<string | undefined> => {
105
+ const doc = buildDocModel(run.current, run.kind);
106
+ for (const rule of run.registry.fixOrder) if (await applyRule(run, doc, rule)) return rule.id;
107
+ return undefined;
108
+ };
109
+
43
110
  export const fixSourceReport = async (
44
111
  src: string,
45
112
  file: string,
@@ -47,67 +114,25 @@ export const fixSourceReport = async (
47
114
  registry: Registry = BUILTIN,
48
115
  ): Promise<FixReport> => {
49
116
  checkRunFlags(flags);
50
- const kind = documentKind(file);
51
- const applied: Edit[] = [];
52
- const refused: Edit[] = [];
53
- // An edit refused once stays refused while its source slice is unchanged.
54
- const refusedKeys = new Set<string>();
55
- let current = src;
117
+ const run: FixRun = {
118
+ file,
119
+ flags,
120
+ registry,
121
+ kind: documentKind(file),
122
+ current: src,
123
+ applied: [],
124
+ refused: [],
125
+ refusedKeys: new Set<string>(),
126
+ };
56
127
 
57
128
  // The rule whose edit each pass kept, so a run that never converges can name
58
129
  // the rules still proposing edits at the end.
59
130
  const keptBy: string[] = [];
60
131
 
61
132
  for (let pass = 0; pass < MAX_PASSES; pass++) {
62
- const doc = buildDocModel(current, kind);
63
- const keyOf = (e: Edit): string => `${e.rule}\u0000${current.slice(e.start, e.end)}\u0000${e.text}`;
64
-
65
- const attempt = async (edits: Edit[]): Promise<boolean> => {
66
- const next = splice(current, edits);
67
- const after = buildDocModel(next, kind);
68
- for (const e of edits) if ((await verify(e, doc, after)) !== "accept") return false;
69
- current = next;
70
- applied.push(...edits);
71
- return true;
72
- };
73
-
74
- let progressed = false;
75
- for (const rule of registry.fixOrder) {
76
- const options = optionsFor(rule, registry.options.get(rule.id), flags, file);
77
- const proposed = (await rule.fix?.({ doc, file, options, helpers: HELPERS })) ?? [];
78
- const sound = proposed.filter((e) => {
79
- if (wellFormed(e, current.length)) return true;
80
- const key = `${e.rule}\u0000malformed\u0000${e.start}:${e.end}`;
81
- if (!refusedKeys.has(key)) {
82
- refusedKeys.add(key);
83
- refused.push(e);
84
- }
85
- return false;
86
- });
87
- const edits = sound.filter((e) => current.slice(e.start, e.end) !== e.text && !refusedKeys.has(keyOf(e)));
88
- if (edits.length === 0) continue;
89
-
90
- // A rule's whole batch is proven with one re-parse, and a batch that
91
- // fails falls back to one edit at a time, so one bad edit never blocks
92
- // the rest.
93
- if (edits.length > 1 && !overlaps(edits) && (await attempt(edits))) {
94
- progressed = true;
95
- } else {
96
- for (const edit of edits) {
97
- if (await attempt([edit])) {
98
- progressed = true;
99
- break;
100
- }
101
- refusedKeys.add(keyOf(edit));
102
- refused.push(edit);
103
- }
104
- }
105
- if (progressed) {
106
- keptBy.push(rule.id);
107
- break;
108
- }
109
- }
110
- if (!progressed) return { output: current, applied, refused };
133
+ const kept = await runPass(run);
134
+ if (kept === undefined) return { output: run.current, applied: run.applied, refused: run.refused };
135
+ keptBy.push(kept);
111
136
  }
112
137
  // The last passes show who was still editing: a fixer that never converges
113
138
  // keeps one in every pass.
package/src/fix/verify.ts CHANGED
@@ -16,20 +16,25 @@ const OMIT = new Set(["position", "data"]);
16
16
  export const normalise = (node: Node, opts: { blankDiagrams?: boolean } = {}): Plain => {
17
17
  const out: Plain = {};
18
18
  const isDiagram = node.type === "code" && (node as { lang?: string }).lang === "mermaid";
19
+ const blank = isDiagram && Boolean(opts.blankDiagrams);
19
20
  for (const [key, value] of Object.entries(node)) {
20
21
  if (OMIT.has(key)) continue;
21
- if (key === "children" && Array.isArray(value)) {
22
- out.children = (value as Node[]).map((child) => normalise(child, opts));
23
- } else if (key === "value" && typeof value === "string") {
24
- if (isDiagram && opts.blankDiagrams) continue;
25
- out.value = node.type === "text" ? value.replace(/\s+/g, " ").trim() : value;
26
- } else {
27
- out[key] = value;
28
- }
22
+ if (blank && isStringValue(key, value)) continue;
23
+ out[key] = normaliseField(node, key, value, opts);
29
24
  }
30
25
  return out;
31
26
  };
32
27
 
28
+ const isStringValue = (key: string, value: unknown): value is string => key === "value" && typeof value === "string";
29
+
30
+ // One field of the copy: children normalised in turn, and a text node's value
31
+ // with its whitespace collapsed. Every other field is kept as it is.
32
+ const normaliseField = (node: Node, key: string, value: unknown, opts: { blankDiagrams?: boolean }): unknown => {
33
+ if (key === "children" && Array.isArray(value)) return (value as Node[]).map((child) => normalise(child, opts));
34
+ if (isStringValue(key, value)) return node.type === "text" ? value.replace(/\s+/g, " ").trim() : value;
35
+ return value;
36
+ };
37
+
33
38
  const sameJson = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
34
39
 
35
40
  const sameTree = (a: Root, b: Root, opts: { blankDiagrams?: boolean }): boolean =>