@mintlify/cli 4.0.1740 → 4.0.1741

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.
@@ -52,7 +52,8 @@ export const validateHandler = (argv_1, packageName_1, ...args_1) => __awaiter(v
52
52
  const _b = yield checkSite(baseDir, rules, {
53
53
  selected,
54
54
  localSchema: argv['local-schema'],
55
- }), { site } = _b, checked = __rest(_b, ["site"]);
55
+ fix: argv.fix,
56
+ }), { site, fixed } = _b, checked = __rest(_b, ["site", "fixed"]);
56
57
  let { diagnostics } = checked;
57
58
  const buildRules = rules.some(({ needsBuild }) => needsBuild);
58
59
  let build;
@@ -73,7 +74,7 @@ export const validateHandler = (argv_1, packageName_1, ...args_1) => __awaiter(v
73
74
  diagnostics = yield runBuildChecks(site, rules, diagnostics);
74
75
  build = { ran: true, passed: site.build.passed };
75
76
  }
76
- const report = toReport(diagnostics, build);
77
+ const report = toReport(diagnostics, build, argv.fix ? fixed : undefined);
77
78
  clearLogs();
78
79
  logReport(report);
79
80
  if (report.passed && build.ran && build.passed)
@@ -7,7 +7,11 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { checkFiles } from './files.js';
10
+ import { replaceVariables } from '@mintlify/common';
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { checkFiles, parseFile } from './files.js';
14
+ import { applyEdits, selectEdits, toPosition } from './mdx.js';
11
15
  import { buildFindings } from './rules/build.js';
12
16
  import { RULES } from './rules/index.js';
13
17
  import { PARSE_ERROR } from './rules/parseError.js';
@@ -64,37 +68,94 @@ const merge = (diagnostics, source, extra, key) => {
64
68
  }
65
69
  };
66
70
  const addParseErrors = (site, diagnostics, extra) => merge(diagnostics, PARSE_ERROR, extra.filter(({ file }) => !site.selected || site.selected.has(file)), ({ file }) => file);
67
- /** Runs every selected rule that doesn't need the build. */
68
- export const runChecks = (site, rules) => __awaiter(void 0, void 0, void 0, function* () {
69
- const fileRules = rules.filter((rule) => rule.scope === 'file');
70
- const diagnostics = yield checkFiles(site, fileRules);
71
+ const fileRulesOf = (rules) => rules.filter((rule) => rule.scope === 'file');
72
+ const runSiteRules = (site, rules, needsBuild, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
71
73
  for (const rule of rules) {
72
- if (rule.scope !== 'site' || rule.needsBuild)
74
+ if (rule.scope !== 'site' || Boolean(rule.needsBuild) !== needsBuild)
73
75
  continue;
74
76
  for (const finding of yield rule.check(site))
75
77
  diagnostics.push(toDiagnostic(rule, finding));
76
78
  }
79
+ });
80
+ /** Runs every selected rule that doesn't need the build. */
81
+ export const runChecks = (site, rules) => __awaiter(void 0, void 0, void 0, function* () {
82
+ const diagnostics = yield checkFiles(site, fileRulesOf(rules));
83
+ yield runSiteRules(site, rules, false, diagnostics);
77
84
  return sortDiagnostics(diagnostics);
78
85
  });
79
86
  /** Adds the build's findings to a site whose build has run. */
80
87
  export const runBuildChecks = (site, rules, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
81
- for (const rule of rules) {
82
- if (rule.scope !== 'site' || !rule.needsBuild)
83
- continue;
84
- for (const finding of yield rule.check(site))
85
- diagnostics.push(toDiagnostic(rule, finding));
86
- }
88
+ yield runSiteRules(site, rules, true, diagnostics);
87
89
  // The file pass already reported files it couldn't parse; the build adds the rest
88
90
  addParseErrors(site, diagnostics, buildFindings(site, 'parse-error'));
89
91
  return sortDiagnostics(diagnostics);
90
92
  });
91
- /** Loads the site and runs the selected rules. */
93
+ const parseLevel = (file, content, baseDir) => {
94
+ const parsed = parseFile(file, content, baseDir);
95
+ return 'error' in parsed ? 0 : parsed.approximate ? 1 : 2;
96
+ };
97
+ /** Applies every fix that doesn't overlap an earlier fix in the same file, writing each Markdown file only when its fixed content parses at least as well as before. */
98
+ export const applyFixes = (site, diagnostics) => __awaiter(void 0, void 0, void 0, function* () {
99
+ var _a;
100
+ const contents = new Map();
101
+ const read = (file) => __awaiter(void 0, void 0, void 0, function* () {
102
+ var _a;
103
+ if (!contents.has(file)) {
104
+ contents.set(file, yield fs.readFile(path.join(site.baseDir, file), 'utf-8'));
105
+ }
106
+ return (_a = contents.get(file)) !== null && _a !== void 0 ? _a : '';
107
+ });
108
+ const chosen = new Map();
109
+ const overlaps = ({ file, start, end }) => { var _a; return ((_a = chosen.get(file)) !== null && _a !== void 0 ? _a : []).some(({ fix }) => start < fix.end && fix.start < end); };
110
+ for (const { rule, fix } of diagnostics) {
111
+ if (!fix || overlaps(fix))
112
+ continue;
113
+ const content = yield read(fix.file);
114
+ const change = Object.assign(Object.assign({ rule, file: fix.file }, toPosition(content, fix.start)), { before: content.slice(fix.start, fix.end), after: fix.text });
115
+ chosen.set(fix.file, [...((_a = chosen.get(fix.file)) !== null && _a !== void 0 ? _a : []), { fix, change }]);
116
+ }
117
+ const snippets = new Set(site.files.filter(({ kind }) => kind === 'snippet').map(({ file }) => file));
118
+ const level = (file, content) => parseLevel(file, snippets.has(file) ? replaceVariables(content, site.variables) : content, site.baseDir);
119
+ const applied = [];
120
+ for (const [file, picks] of chosen) {
121
+ const content = yield read(file);
122
+ const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
123
+ if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
124
+ continue;
125
+ }
126
+ yield fs.writeFile(path.join(site.baseDir, file), updated);
127
+ applied.push(...picks.map(({ change }) => change));
128
+ }
129
+ return applied;
130
+ });
131
+ // Fixes can unlock others (a self-closed <br> lets a page parse), so --fix runs a few passes
132
+ const MAX_FIX_PASSES = 5;
133
+ /**
134
+ * Loads the site and runs the selected rules. With fix, applies the fixes, rechecks only the
135
+ * files that changed for further fixes, then runs every rule once more for the final report.
136
+ */
92
137
  export const checkSite = (baseDir, rules, options) => __awaiter(void 0, void 0, void 0, function* () {
138
+ const fixed = [];
93
139
  const site = yield loadSite(baseDir, options);
94
140
  const { selected } = options;
95
141
  if (selected && !site.files.some(({ file }) => selected.has(file))) {
96
142
  throw new UsageError(`none of the given files are pages or snippets of this site: ${[...selected].join(', ')}`);
97
143
  }
98
144
  const diagnostics = yield runChecks(site, rules);
99
- return { site, diagnostics };
145
+ if (!options.fix)
146
+ return { site, diagnostics, fixed };
147
+ // Only file rules offer fixes, so passes after the first recheck just the files that changed
148
+ let pending = diagnostics;
149
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
150
+ const changes = yield applyFixes(site, pending);
151
+ if (changes.length === 0)
152
+ break;
153
+ fixed.push(...changes);
154
+ const changed = new Set(changes.map(({ file }) => file));
155
+ pending = sortDiagnostics(yield checkFiles(Object.assign(Object.assign({}, site), { selected: changed }), fileRulesOf(rules)));
156
+ }
157
+ if (fixed.length === 0)
158
+ return { site, diagnostics, fixed };
159
+ const fixedSite = yield loadSite(baseDir, options);
160
+ return { site: fixedSite, diagnostics: yield runChecks(fixedSite, rules), fixed };
100
161
  });
@@ -74,7 +74,7 @@ const tryParseContent = (content) => {
74
74
  return undefined;
75
75
  }
76
76
  };
77
- const parseFile = (file, content, baseDir) => {
77
+ export const parseFile = (file, content, baseDir) => {
78
78
  const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
79
79
  const tree = tryParseContent(content);
80
80
  if (tree)
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { addLog, ErrorLog, InfoLog, SuccessLog } from '@mintlify/previewing';
3
3
  import { Box, Text } from 'ink';
4
+ const preview = (value) => {
5
+ const text = JSON.stringify(value);
6
+ return text.length > 60 ? `${text.slice(0, 57)}..."` : text;
7
+ };
4
8
  const location = ({ line, column }) => line ? `${line}${column ? `:${column}` : ''}` : '';
5
9
  const DiagnosticLine = ({ diagnostic }) => {
6
10
  const at = location(diagnostic);
@@ -8,14 +12,21 @@ const DiagnosticLine = ({ diagnostic }) => {
8
12
  };
9
13
  const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
10
14
  export const logReport = (report) => {
11
- var _a, _b;
15
+ var _a, _b, _c;
16
+ for (const change of (_a = report.fixed) !== null && _a !== void 0 ? _a : []) {
17
+ addLog(_jsx(SuccessLog, { message: `fixed ${change.file}${change.line ? `:${change.line}` : ''} ${change.rule}: ${preview(change.before)} → ${preview(change.after)}` }));
18
+ }
12
19
  const byFile = new Map();
13
20
  for (const diagnostic of report.diagnostics) {
14
- (_b = (_a = byFile.get(diagnostic.file)) === null || _a === void 0 ? void 0 : _a.push(diagnostic)) !== null && _b !== void 0 ? _b : byFile.set(diagnostic.file, [diagnostic]);
21
+ (_c = (_b = byFile.get(diagnostic.file)) === null || _b === void 0 ? void 0 : _b.push(diagnostic)) !== null && _c !== void 0 ? _c : byFile.set(diagnostic.file, [diagnostic]);
15
22
  }
16
23
  for (const [file, diagnostics] of byFile) {
17
24
  addLog(_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: file }), diagnostics.map((diagnostic, index) => (_jsx(DiagnosticLine, { diagnostic: diagnostic }, index)))] }));
18
25
  }
26
+ const fixable = report.diagnostics.filter(({ fix }) => fix).length;
27
+ if (fixable > 0 && !report.fixed) {
28
+ addLog(_jsx(InfoLog, { message: `\`--fix\` would fix ${plural(fixable, 'issue')}.` }));
29
+ }
19
30
  const { build } = report;
20
31
  if (build.ran && build.passed)
21
32
  addLog(_jsx(SuccessLog, { message: "build passed" }));
@@ -1,16 +1,11 @@
1
- export const toReport = (diagnostics, build) => {
1
+ export const toReport = (diagnostics, build, fixed) => {
2
2
  var _a;
3
3
  const byRule = {};
4
4
  for (const { rule } of diagnostics)
5
5
  byRule[rule] = ((_a = byRule[rule]) !== null && _a !== void 0 ? _a : 0) + 1;
6
6
  const errors = diagnostics.filter(({ severity }) => severity === 'error').length;
7
- return {
8
- passed: errors === 0,
9
- summary: { errors, warnings: diagnostics.length - errors, byRule },
10
- diagnostics: diagnostics.map(({ rule, group, severity, file, line, column, message, fix }) => (Object.assign(Object.assign(Object.assign(Object.assign({ rule,
7
+ return Object.assign({ passed: errors === 0, summary: { errors, warnings: diagnostics.length - errors, byRule }, diagnostics: diagnostics.map(({ rule, group, severity, file, line, column, message, fix }) => (Object.assign(Object.assign(Object.assign(Object.assign({ rule,
11
8
  group,
12
9
  severity,
13
- file }, (line !== undefined && { line })), (column !== undefined && { column })), { message }), (fix && { fix })))),
14
- build,
15
- };
10
+ file }, (line !== undefined && { line })), (column !== undefined && { column })), { message }), (fix && { fix })))), build }, (fixed && { fixed }));
16
11
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mintlify/cli",
3
- "version": "4.0.1740",
3
+ "version": "4.0.1741",
4
4
  "description": "The Mintlify CLI",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -101,5 +101,5 @@
101
101
  "vitest": "2.1.9",
102
102
  "vitest-mock-process": "1.0.4"
103
103
  },
104
- "gitHead": "17769e5210e7861c4b9c751f8b0cbf5e7bdd3143"
104
+ "gitHead": "4e0cdb6a51f1b140f02f0579f9e4c32592220434"
105
105
  }
package/src/cli.tsx CHANGED
@@ -401,6 +401,11 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
401
401
  description:
402
402
  'Turn off rules or groups (comma-separated), or all; parse errors are still reported',
403
403
  })
404
+ .option('fix', {
405
+ type: 'boolean',
406
+ default: false,
407
+ description: 'Apply the fixes and check again',
408
+ })
404
409
  .usage('usage: mint validate [options]')
405
410
  .epilogue(
406
411
  [
@@ -410,7 +415,10 @@ export const cli = ({ packageName = 'mint' }: { packageName?: string }) => {
410
415
  ].join('\n')
411
416
  )
412
417
  .example('mint validate', 'run every check and the build')
413
- .example('mint validate --files "guides/**/*.mdx"', 'check some pages')
418
+ .example(
419
+ 'mint validate --files "guides/**/*.mdx" --fix',
420
+ 'check some pages and apply fixes'
421
+ )
414
422
  .example(
415
423
  'mint validate --disable all --enable content',
416
424
  'run the content rules only; missing-file and import-error still run the build'
@@ -16,6 +16,7 @@ export type ValidateArgs = {
16
16
  build: boolean;
17
17
  enable?: string[];
18
18
  disable?: string[];
19
+ fix: boolean;
19
20
  };
20
21
 
21
22
  export const EXIT = { passed: 0, failed: 1, crashed: 2 } as const;
@@ -53,9 +54,10 @@ export const validateHandler = async (
53
54
  const selected = await resolveSelection(baseDir, argv.files);
54
55
  addLog(<SpinnerLog message="validating..." />);
55
56
 
56
- const { site, ...checked } = await checkSite(baseDir, rules, {
57
+ const { site, fixed, ...checked } = await checkSite(baseDir, rules, {
57
58
  selected,
58
59
  localSchema: argv['local-schema'],
60
+ fix: argv.fix,
59
61
  });
60
62
  let { diagnostics } = checked;
61
63
 
@@ -82,7 +84,7 @@ export const validateHandler = async (
82
84
  build = { ran: true, passed: site.build.passed };
83
85
  }
84
86
 
85
- const report = toReport(diagnostics, build);
87
+ const report = toReport(diagnostics, build, argv.fix ? fixed : undefined);
86
88
  clearLogs();
87
89
  logReport(report);
88
90
  if (report.passed && build.ran && build.passed) await sendPublishPrompt();
@@ -1,12 +1,19 @@
1
- import { checkFiles } from './files.js';
1
+ import { replaceVariables } from '@mintlify/common';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { checkFiles, parseFile } from './files.js';
6
+ import { applyEdits, selectEdits, toPosition } from './mdx.js';
2
7
  import { buildFindings } from './rules/build.js';
3
8
  import { RULES } from './rules/index.js';
4
9
  import { PARSE_ERROR } from './rules/parseError.js';
5
10
  import { loadSite } from './site.js';
6
11
  import {
7
12
  GROUPS,
13
+ type Change,
8
14
  type Diagnostic,
9
15
  type DiagnosticSource,
16
+ type FileEdit,
10
17
  type FileRule,
11
18
  type Finding,
12
19
  type Rule,
@@ -87,35 +94,103 @@ const addParseErrors = (site: Site, diagnostics: Diagnostic[], extra: Finding[])
87
94
  ({ file }) => file
88
95
  );
89
96
 
90
- /** Runs every selected rule that doesn't need the build. */
91
- export const runChecks = async (site: Site, rules: Rule[]) => {
92
- const fileRules = rules.filter((rule): rule is FileRule => rule.scope === 'file');
93
- const diagnostics = await checkFiles(site, fileRules);
97
+ const fileRulesOf = (rules: Rule[]) =>
98
+ rules.filter((rule): rule is FileRule => rule.scope === 'file');
94
99
 
100
+ const runSiteRules = async (
101
+ site: Site,
102
+ rules: Rule[],
103
+ needsBuild: boolean,
104
+ diagnostics: Diagnostic[]
105
+ ) => {
95
106
  for (const rule of rules) {
96
- if (rule.scope !== 'site' || rule.needsBuild) continue;
107
+ if (rule.scope !== 'site' || Boolean(rule.needsBuild) !== needsBuild) continue;
97
108
  for (const finding of await rule.check(site)) diagnostics.push(toDiagnostic(rule, finding));
98
109
  }
110
+ };
111
+
112
+ /** Runs every selected rule that doesn't need the build. */
113
+ export const runChecks = async (site: Site, rules: Rule[]) => {
114
+ const diagnostics = await checkFiles(site, fileRulesOf(rules));
115
+ await runSiteRules(site, rules, false, diagnostics);
99
116
  return sortDiagnostics(diagnostics);
100
117
  };
101
118
 
102
119
  /** Adds the build's findings to a site whose build has run. */
103
120
  export const runBuildChecks = async (site: Site, rules: Rule[], diagnostics: Diagnostic[]) => {
104
- for (const rule of rules) {
105
- if (rule.scope !== 'site' || !rule.needsBuild) continue;
106
- for (const finding of await rule.check(site)) diagnostics.push(toDiagnostic(rule, finding));
107
- }
121
+ await runSiteRules(site, rules, true, diagnostics);
108
122
  // The file pass already reported files it couldn't parse; the build adds the rest
109
123
  addParseErrors(site, diagnostics, buildFindings(site, 'parse-error'));
110
124
  return sortDiagnostics(diagnostics);
111
125
  };
112
126
 
113
- /** Loads the site and runs the selected rules. */
127
+ const parseLevel = (file: string, content: string, baseDir: string) => {
128
+ const parsed = parseFile(file, content, baseDir);
129
+ return 'error' in parsed ? 0 : parsed.approximate ? 1 : 2;
130
+ };
131
+
132
+ /** Applies every fix that doesn't overlap an earlier fix in the same file, writing each Markdown file only when its fixed content parses at least as well as before. */
133
+ export const applyFixes = async (site: Site, diagnostics: Diagnostic[]): Promise<Change[]> => {
134
+ const contents = new Map<string, string>();
135
+ const read = async (file: string) => {
136
+ if (!contents.has(file)) {
137
+ contents.set(file, await fs.readFile(path.join(site.baseDir, file), 'utf-8'));
138
+ }
139
+ return contents.get(file) ?? '';
140
+ };
141
+ const chosen = new Map<string, { fix: FileEdit; change: Change }[]>();
142
+ const overlaps = ({ file, start, end }: FileEdit) =>
143
+ (chosen.get(file) ?? []).some(({ fix }) => start < fix.end && fix.start < end);
144
+
145
+ for (const { rule, fix } of diagnostics) {
146
+ if (!fix || overlaps(fix)) continue;
147
+ const content = await read(fix.file);
148
+ const change = {
149
+ rule,
150
+ file: fix.file,
151
+ ...toPosition(content, fix.start),
152
+ before: content.slice(fix.start, fix.end),
153
+ after: fix.text,
154
+ };
155
+ chosen.set(fix.file, [...(chosen.get(fix.file) ?? []), { fix, change }]);
156
+ }
157
+
158
+ const snippets = new Set(
159
+ site.files.filter(({ kind }) => kind === 'snippet').map(({ file }) => file)
160
+ );
161
+ const level = (file: string, content: string) =>
162
+ parseLevel(
163
+ file,
164
+ snippets.has(file) ? replaceVariables(content, site.variables) : content,
165
+ site.baseDir
166
+ );
167
+
168
+ const applied: Change[] = [];
169
+ for (const [file, picks] of chosen) {
170
+ const content = await read(file);
171
+ const updated = applyEdits(content, selectEdits(picks.map(({ fix }) => fix)));
172
+ if (/\.mdx?$/.test(file) && level(file, updated) < level(file, content)) {
173
+ continue;
174
+ }
175
+ await fs.writeFile(path.join(site.baseDir, file), updated);
176
+ applied.push(...picks.map(({ change }) => change));
177
+ }
178
+ return applied;
179
+ };
180
+
181
+ // Fixes can unlock others (a self-closed <br> lets a page parse), so --fix runs a few passes
182
+ const MAX_FIX_PASSES = 5;
183
+
184
+ /**
185
+ * Loads the site and runs the selected rules. With fix, applies the fixes, rechecks only the
186
+ * files that changed for further fixes, then runs every rule once more for the final report.
187
+ */
114
188
  export const checkSite = async (
115
189
  baseDir: string,
116
190
  rules: Rule[],
117
- options: { selected?: ReadonlySet<string>; localSchema: boolean }
191
+ options: { selected?: ReadonlySet<string>; localSchema: boolean; fix: boolean }
118
192
  ) => {
193
+ const fixed: Change[] = [];
119
194
  const site = await loadSite(baseDir, options);
120
195
  const { selected } = options;
121
196
  if (selected && !site.files.some(({ file }) => selected.has(file))) {
@@ -124,5 +199,18 @@ export const checkSite = async (
124
199
  );
125
200
  }
126
201
  const diagnostics = await runChecks(site, rules);
127
- return { site, diagnostics };
202
+ if (!options.fix) return { site, diagnostics, fixed };
203
+
204
+ // Only file rules offer fixes, so passes after the first recheck just the files that changed
205
+ let pending = diagnostics;
206
+ for (let pass = 0; pass < MAX_FIX_PASSES; pass++) {
207
+ const changes = await applyFixes(site, pending);
208
+ if (changes.length === 0) break;
209
+ fixed.push(...changes);
210
+ const changed = new Set(changes.map(({ file }) => file));
211
+ pending = sortDiagnostics(await checkFiles({ ...site, selected: changed }, fileRulesOf(rules)));
212
+ }
213
+ if (fixed.length === 0) return { site, diagnostics, fixed };
214
+ const fixedSite = await loadSite(baseDir, options);
215
+ return { site: fixedSite, diagnostics: await runChecks(fixedSite, rules), fixed };
128
216
  };
@@ -99,7 +99,7 @@ const tryParseContent = (content: string): Root | undefined => {
99
99
  }
100
100
  };
101
101
 
102
- const parseFile = (file: string, content: string, baseDir: string): Parsed => {
102
+ export const parseFile = (file: string, content: string, baseDir: string): Parsed => {
103
103
  const context = { file, content, baseDir, frontmatter: readFrontmatter(content) };
104
104
  const tree = tryParseContent(content);
105
105
  if (tree) return { context: { ...context, tree }, approximate: false };
@@ -4,6 +4,11 @@ import { Box, Text } from 'ink';
4
4
  import type { Report } from './report.js';
5
5
  import type { Diagnostic } from './types.js';
6
6
 
7
+ const preview = (value: string) => {
8
+ const text = JSON.stringify(value);
9
+ return text.length > 60 ? `${text.slice(0, 57)}..."` : text;
10
+ };
11
+
7
12
  const location = ({ line, column }: Pick<Diagnostic, 'line' | 'column'>) =>
8
13
  line ? `${line}${column ? `:${column}` : ''}` : '';
9
14
 
@@ -27,6 +32,14 @@ const DiagnosticLine = ({ diagnostic }: { diagnostic: Diagnostic }) => {
27
32
  const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
28
33
 
29
34
  export const logReport = (report: Report) => {
35
+ for (const change of report.fixed ?? []) {
36
+ addLog(
37
+ <SuccessLog
38
+ message={`fixed ${change.file}${change.line ? `:${change.line}` : ''} ${change.rule}: ${preview(change.before)} → ${preview(change.after)}`}
39
+ />
40
+ );
41
+ }
42
+
30
43
  const byFile = new Map<string, Diagnostic[]>();
31
44
  for (const diagnostic of report.diagnostics) {
32
45
  byFile.get(diagnostic.file)?.push(diagnostic) ?? byFile.set(diagnostic.file, [diagnostic]);
@@ -42,6 +55,11 @@ export const logReport = (report: Report) => {
42
55
  );
43
56
  }
44
57
 
58
+ const fixable = report.diagnostics.filter(({ fix }) => fix).length;
59
+ if (fixable > 0 && !report.fixed) {
60
+ addLog(<InfoLog message={`\`--fix\` would fix ${plural(fixable, 'issue')}.`} />);
61
+ }
62
+
45
63
  const { build } = report;
46
64
  if (build.ran && build.passed) addLog(<SuccessLog message="build passed" />);
47
65
  else if (build.ran && report.passed)
@@ -1,4 +1,4 @@
1
- import type { Diagnostic } from './types.js';
1
+ import type { Change, Diagnostic } from './types.js';
2
2
 
3
3
  export type BuildStatus =
4
4
  | { ran: true; passed: boolean }
@@ -9,9 +9,15 @@ export type Report = {
9
9
  summary: { errors: number; warnings: number; byRule: Record<string, number> };
10
10
  diagnostics: Diagnostic[];
11
11
  build: BuildStatus;
12
+ /** With --fix: the edits that were applied. */
13
+ fixed?: Change[];
12
14
  };
13
15
 
14
- export const toReport = (diagnostics: Diagnostic[], build: BuildStatus): Report => {
16
+ export const toReport = (
17
+ diagnostics: Diagnostic[],
18
+ build: BuildStatus,
19
+ fixed?: Change[]
20
+ ): Report => {
15
21
  const byRule: Record<string, number> = {};
16
22
  for (const { rule } of diagnostics) byRule[rule] = (byRule[rule] ?? 0) + 1;
17
23
  const errors = diagnostics.filter(({ severity }) => severity === 'error').length;
@@ -29,5 +35,6 @@ export const toReport = (diagnostics: Diagnostic[], build: BuildStatus): Report
29
35
  ...(fix && { fix }),
30
36
  })),
31
37
  build,
38
+ ...(fixed && { fixed }),
32
39
  };
33
40
  };
@@ -11,6 +11,16 @@ export type Edit = { start: number; end: number; text: string };
11
11
 
12
12
  export type FileEdit = Edit & { file: string };
13
13
 
14
+ /** A fix --fix applied: where it was and the text it replaced. */
15
+ export type Change = {
16
+ rule: string;
17
+ file: string;
18
+ line?: number;
19
+ column?: number;
20
+ before: string;
21
+ after: string;
22
+ };
23
+
14
24
  export type Diagnostic = {
15
25
  rule: string;
16
26
  group: Group;