@jpeakai/mermaidjs-lint 0.7.0 → 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.
- package/README.md +43 -109
- package/dist/bin.js +691 -578
- package/dist/index.js +691 -578
- package/dist/testing.js +471 -411
- package/dist/types/parse/contrast.d.ts +6 -1
- package/dist/types/parse/graph.d.ts +16 -0
- package/dist/types/rules/shared.d.ts +5 -1
- package/package.json +1 -1
- package/src/check.ts +13 -11
- package/src/cli.ts +153 -90
- package/src/files.ts +23 -18
- package/src/fix/engine.ts +82 -57
- package/src/fix/verify.ts +13 -8
- package/src/model.ts +1 -1
- package/src/parse/color.ts +1 -1
- package/src/parse/contrast.ts +308 -228
- package/src/parse/graph.ts +98 -0
- package/src/parse/metrics.ts +1 -1
- package/src/parse/stats.ts +111 -82
- package/src/plugins/discover.ts +13 -15
- package/src/plugins/index.ts +66 -37
- package/src/plugins/load.ts +94 -61
- package/src/rules/complexity.ts +1 -1
- package/src/rules/contrast-text-below-aa.ts +34 -21
- package/src/rules/parse-diagram-unparseable.ts +1 -1
- package/src/rules/parse-fence-unterminated.ts +5 -7
- package/src/rules/registry.ts +38 -16
- package/src/rules/shared.ts +8 -3
|
@@ -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
|
|
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
|
|
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
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
|
-
|
|
237
|
-
|
|
236
|
+
type Values = Parsed["values"];
|
|
237
|
+
|
|
238
|
+
const parseArgv = (argv: string[]): Parsed => {
|
|
238
239
|
try {
|
|
239
|
-
|
|
240
|
+
return parse(argv);
|
|
240
241
|
} catch (err) {
|
|
241
242
|
throw new FlagError("argv", err instanceof Error ? err.message : String(err));
|
|
242
243
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
251
|
+
return {
|
|
259
252
|
preset: values.preset,
|
|
260
253
|
profile: values.profile,
|
|
261
254
|
...(Object.keys(canvas).length > 0 && { canvas }),
|
|
262
255
|
};
|
|
263
|
-
|
|
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 (
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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 && !
|
|
290
|
+
if (positionals.length === 0 && !(values["list-rules"] || validating)) {
|
|
291
|
+
throw new FlagError("files", "no files given");
|
|
292
|
+
}
|
|
293
|
+
};
|
|
297
294
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
for (const line of
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
332
|
+
return files;
|
|
333
|
+
};
|
|
329
334
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
|
392
|
+
{ findings: all, files, plugins: registry.sources, skippedPlugins, canvasSetAside: setAside },
|
|
372
393
|
null,
|
|
373
394
|
2,
|
|
374
395
|
),
|
|
375
396
|
);
|
|
376
|
-
|
|
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()
|
|
83
|
+
} else if (st.isFile()) {
|
|
78
84
|
// An explicitly named file is honoured whatever its extension.
|
|
79
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
63
|
-
|
|
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 (
|
|
22
|
-
|
|
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 =>
|