markuplint 5.4.0 → 5.6.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.
- package/CHANGELOG.md +24 -0
- package/README.md +9 -6
- package/lib/api/ml-engine.js +25 -5
- package/lib/api/types.d.ts +5 -0
- package/lib/cli/bootstrap.d.ts +1 -1
- package/lib/cli/bootstrap.js +8 -3
- package/lib/cli/command.d.ts +18 -0
- package/lib/cli/command.js +176 -117
- package/lib/cli/index.d.ts +36 -0
- package/lib/cli/index.js +38 -66
- package/lib/cli/init/index.js +3 -3
- package/lib/cli/main.d.ts +14 -0
- package/lib/cli/main.js +171 -0
- package/lib/cli/output.js +3 -1
- package/lib/cli/search/index.js +2 -1
- package/lib/suppressions/compute-scope.d.ts +42 -3
- package/lib/suppressions/compute-scope.js +34 -18
- package/lib/suppressions/index.d.ts +1 -1
- package/lib/suppressions/merge-suppressions.d.ts +12 -1
- package/lib/suppressions/merge-suppressions.js +27 -7
- package/lib/suppressions/prune-suppressions.d.ts +3 -2
- package/lib/suppressions/prune-suppressions.js +3 -2
- package/lib/suppressions/suppressions-file.d.ts +5 -0
- package/lib/suppressions/suppressions-file.js +10 -1
- package/package.json +49 -16
package/lib/cli/command.js
CHANGED
|
@@ -2,10 +2,9 @@ import { promises as fs } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { ConfigProvider, resolveFiles } from '@markuplint/file-resolver';
|
|
4
4
|
import { ViolationCollector } from '@markuplint/ml-core';
|
|
5
|
-
import { isFatalError } from '@markuplint/shared';
|
|
6
5
|
import { MLEngine } from '../api/index.js';
|
|
7
6
|
import { log } from '../debug.js';
|
|
8
|
-
import { applySuppressions, generateSuppressions, mergeSuppressions, pruneSuppressions, readSuppressionsFile, resolveSuppressionsPath, writeSuppressionsFile, } from '../suppressions/index.js';
|
|
7
|
+
import { applySuppressions, generateSuppressions, mergeSuppressions, pruneSuppressions, readSuppressionsFile, resolveSuppressionsPath, toAbsolutePath, toRelativePath, writeSuppressionsFile, } from '../suppressions/index.js';
|
|
9
8
|
import { CONFIG_LEVEL_RULE_IDS, dedupeConfigLevelViolations } from '../dedupe-config-violations.js';
|
|
10
9
|
import { outputDryRunDiff } from './dry-run-output.js';
|
|
11
10
|
import { output, outputSummary } from './output.js';
|
|
@@ -25,6 +24,53 @@ function parseSeverityFlag(value) {
|
|
|
25
24
|
}
|
|
26
25
|
return undefined;
|
|
27
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Applies the suppressions of one file to that file's violations.
|
|
29
|
+
*
|
|
30
|
+
* Suppression is count-based per (file, rule), so it has to see every
|
|
31
|
+
* violation the file has, never a list `--max-count` already cut short:
|
|
32
|
+
* cut short, an entry would cover violations it does not cover in full, and
|
|
33
|
+
* would be called unused when what it covers was only left out of the
|
|
34
|
+
* report.
|
|
35
|
+
*
|
|
36
|
+
* @param filePath Absolute path of the linted file.
|
|
37
|
+
* @param violations Every violation of the file.
|
|
38
|
+
* @param suppressions The suppressions data read at the start of the run.
|
|
39
|
+
* @param suppressionsFilePath Absolute path of the suppressions file.
|
|
40
|
+
* @param nodeList The file's document node list, for scope-aware entries.
|
|
41
|
+
* @returns The violations the entries of the file do not suppress, and the
|
|
42
|
+
* entries of the file that suppressed nothing (`"<path>:<ruleId>"`).
|
|
43
|
+
*/
|
|
44
|
+
function applyFileSuppressions(filePath, violations, suppressions, suppressionsFilePath, nodeList) {
|
|
45
|
+
const relPath = toRelativePath(filePath, suppressionsFilePath);
|
|
46
|
+
const fileSuppressions = suppressions[relPath];
|
|
47
|
+
if (!fileSuppressions) {
|
|
48
|
+
return { kept: violations, unusedEntries: [] };
|
|
49
|
+
}
|
|
50
|
+
// An entry that cannot be applied fails the run: treating it as "suppresses
|
|
51
|
+
// nothing" would fail the build on violations the file is meant to
|
|
52
|
+
// suppress, with no word on why.
|
|
53
|
+
const { filtered, unusedEntries } = applySuppressions(new Map([[filePath, violations]]), { [relPath]: fileSuppressions }, suppressionsFilePath, { nodeLists: nodeList ? new Map([[filePath, nodeList]]) : undefined });
|
|
54
|
+
return { kept: filtered.get(filePath) ?? [], unusedEntries };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Runs the CLI over the given targets and writes the report in the format
|
|
58
|
+
* `--format` selects.
|
|
59
|
+
*
|
|
60
|
+
* @param files Files or source code to lint.
|
|
61
|
+
* @param options The parsed CLI flags.
|
|
62
|
+
* @param apiOptions Options passed through to every `MLEngine`.
|
|
63
|
+
* @returns `true` when the process must exit with `1`: an error, a warning
|
|
64
|
+
* with `--no-allow-warnings`, more warnings than `--max-warnings`, or a
|
|
65
|
+
* usage error such as no target files. The decision does not depend on
|
|
66
|
+
* `--format`: CI reads the exit code whatever format the report is in. Nor
|
|
67
|
+
* does it depend on `--max-count`, which only decides what the report shows:
|
|
68
|
+
* a violation the limit hides, and every violation of a file after the
|
|
69
|
+
* limit, still counts. (`--fix`, `--suppress` and `--prune-suppressions`
|
|
70
|
+
* ignore the limit altogether; see `maxCount` below.)
|
|
71
|
+
* `--show-config`, `--suppress` and `--prune-suppressions` do not lint for
|
|
72
|
+
* a verdict, so they return `false` whatever violations exist.
|
|
73
|
+
*/
|
|
28
74
|
export async function command(files, options, apiOptions) {
|
|
29
75
|
const fixDryRun = options.fixDryRun;
|
|
30
76
|
if (options.fix && fixDryRun) {
|
|
@@ -39,8 +85,12 @@ export async function command(files, options, apiOptions) {
|
|
|
39
85
|
process.stderr.write('Error: --suppress/--suppress-rule and --prune-suppressions cannot be used together.\n');
|
|
40
86
|
return true;
|
|
41
87
|
}
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
// `--max-count` limits what a lint report shows. `--fix` has to reach every
|
|
89
|
+
// file, and `--suppress` / `--prune-suppressions` write what the whole run
|
|
90
|
+
// found: a cut-short run would record too few violations, and would prune
|
|
91
|
+
// the entries of the files it never reached.
|
|
92
|
+
const maxCount = isSuppressMode || isPruneMode || fix ? 0 : options.maxCount;
|
|
93
|
+
const configFile = options.config;
|
|
44
94
|
const locale = options.locale;
|
|
45
95
|
const searchConfig = options.searchConfig;
|
|
46
96
|
const ignoreExt = options.ignoreExt;
|
|
@@ -61,41 +111,44 @@ export async function command(files, options, apiOptions) {
|
|
|
61
111
|
const format = options.format?.toLowerCase().trim();
|
|
62
112
|
let hasError = false;
|
|
63
113
|
let totalWarningCount = 0;
|
|
64
|
-
const collector = new ViolationCollector(
|
|
114
|
+
const collector = new ViolationCollector(maxCount);
|
|
65
115
|
const processedFiles = [];
|
|
66
116
|
const skippedFiles = [];
|
|
117
|
+
// Targets the engine returned no result for (excluded by the config).
|
|
118
|
+
// They have no violations because they are not linted, which is what
|
|
119
|
+
// `--prune-suppressions` needs to see for their entries.
|
|
120
|
+
const ignoredFiles = [];
|
|
67
121
|
// See `dedupeConfigLevelViolations` for why this Set needs to persist
|
|
68
122
|
// across the whole run (not per file).
|
|
69
123
|
const seenConfigMessages = new Set();
|
|
124
|
+
// The same for the files after the `--max-count` limit, which are not
|
|
125
|
+
// reported: they start from what the reported files already showed, so a
|
|
126
|
+
// config-level message counts once for the run's verdict, not once per
|
|
127
|
+
// skipped file, and the report's own Set is left as it is.
|
|
128
|
+
let seenConfigMessagesOfSkipped;
|
|
70
129
|
const filesContent = new Map();
|
|
71
130
|
// Suppressions scope computation is the only consumer of the parsed
|
|
72
|
-
// documents
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
131
|
+
// documents. A normal run applies a file's suppressions while that file is
|
|
132
|
+
// processed, so its document is not kept past the iteration; only
|
|
133
|
+
// --suppress, which generates scopes for the whole run once it is over,
|
|
134
|
+
// retains them. Retaining every file's document otherwise keeps N DOM
|
|
135
|
+
// trees reachable until the command ends.
|
|
76
136
|
//
|
|
77
137
|
// A normal run reads the suppressions file exactly once, here, and applies
|
|
78
|
-
// that same snapshot
|
|
79
|
-
//
|
|
80
|
-
//
|
|
138
|
+
// that same snapshot to every file. A file that cannot be read or parsed
|
|
139
|
+
// fails the run in every output mode: which mode prints the report must
|
|
140
|
+
// not decide whether a broken suppressions file is noticed.
|
|
81
141
|
const suppressionsFilePath = resolveSuppressionsPath(options.suppressionsLocation);
|
|
82
142
|
let normalRunSuppressions = {};
|
|
83
|
-
let suppressionsReadError;
|
|
84
143
|
if (!isSuppressMode && !isPruneMode) {
|
|
85
|
-
|
|
86
|
-
normalRunSuppressions = await readSuppressionsFile(suppressionsFilePath);
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
if (isFatalError(error)) {
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
// Proceed as if there were no suppressions; only the progressive
|
|
93
|
-
// output check below treats an unreadable file as an error.
|
|
94
|
-
suppressionsReadError = error;
|
|
95
|
-
}
|
|
144
|
+
normalRunSuppressions = await readSuppressionsFile(suppressionsFilePath);
|
|
96
145
|
}
|
|
97
146
|
const needsScope = isSuppressMode || Object.keys(normalRunSuppressions).length > 0;
|
|
98
147
|
const nodeLists = new Map();
|
|
148
|
+
// Entries of the suppressions file that suppressed nothing in a file this
|
|
149
|
+
// run linted. A file the run did not lint (outside the targets) cannot be
|
|
150
|
+
// judged, so its entries are never listed.
|
|
151
|
+
const unusedSuppressionEntries = [];
|
|
99
152
|
// Shared across every file in this run so its config cache — keyed by
|
|
100
153
|
// resolved config `names`, not by target file — actually helps: config
|
|
101
154
|
// loading/merging/plugin-resolution is done once per distinct config,
|
|
@@ -108,39 +161,28 @@ export async function command(files, options, apiOptions) {
|
|
|
108
161
|
...(parsedSeverityDeprecation != null && { deprecation: parsedSeverityDeprecation }),
|
|
109
162
|
};
|
|
110
163
|
// Progressive output prints each file's own violations as soon as that
|
|
111
|
-
// file is processed
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// skipped
|
|
118
|
-
//
|
|
119
|
-
// and exit code then contradict. Rather than duplicate that truncation
|
|
120
|
-
// and suppression logic in the progressive branch, fall back to batch
|
|
121
|
-
// output for the whole run whenever either is in play; --suppress and
|
|
122
|
-
// --prune-suppressions manage the suppressions file directly and are
|
|
123
|
-
// unaffected.
|
|
164
|
+
// file is processed. A file's suppressions are applied right there, so
|
|
165
|
+
// what is printed is what the run keeps. `--max-count` truncation is the
|
|
166
|
+
// one whole-run pass it cannot join: it is enforced by
|
|
167
|
+
// `collector.pushWithFile`'s running total across files, including which
|
|
168
|
+
// file the limit is hit within and which later files are skipped
|
|
169
|
+
// entirely. Printing per file ahead of it would show violations, or omit
|
|
170
|
+
// a skipped-file notice, that the run's own summary then contradicts, so
|
|
171
|
+
// the whole run falls back to batch output whenever it is in play.
|
|
124
172
|
let progressiveOutput = options.progressiveOutput;
|
|
125
|
-
if (progressiveOutput &&
|
|
173
|
+
if (progressiveOutput && maxCount > 0) {
|
|
126
174
|
progressiveOutput = false;
|
|
127
175
|
}
|
|
128
|
-
if (progressiveOutput && !isSuppressMode && !isPruneMode) {
|
|
129
|
-
if (suppressionsReadError != null) {
|
|
130
|
-
throw suppressionsReadError;
|
|
131
|
-
}
|
|
132
|
-
if (needsScope) {
|
|
133
|
-
progressiveOutput = false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
176
|
for (const file of fileList) {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
// A file after the `--max-count` limit is not reported, but it is still
|
|
178
|
+
// linted: the limit only decides what the report shows, and a skipped
|
|
179
|
+
// file's errors must reach the exit decision like any other file's.
|
|
180
|
+
const skipped = collector.isLocked();
|
|
181
|
+
if (skipped) {
|
|
182
|
+
log('Not reporting file due to max-count limit: %s', file.path);
|
|
140
183
|
skippedFiles.push(file.path);
|
|
141
|
-
continue;
|
|
142
184
|
}
|
|
143
|
-
const
|
|
185
|
+
const engineOptions = {
|
|
144
186
|
configFile,
|
|
145
187
|
fix,
|
|
146
188
|
locale,
|
|
@@ -151,7 +193,8 @@ export async function command(files, options, apiOptions) {
|
|
|
151
193
|
severity,
|
|
152
194
|
...apiOptions,
|
|
153
195
|
configProvider,
|
|
154
|
-
}
|
|
196
|
+
};
|
|
197
|
+
const engine = new MLEngine(file, engineOptions);
|
|
155
198
|
if (options.showConfig != null) {
|
|
156
199
|
const isDetails = options.showConfig === 'details';
|
|
157
200
|
const configSet = await engine.resolveConfig(false);
|
|
@@ -178,21 +221,40 @@ export async function command(files, options, apiOptions) {
|
|
|
178
221
|
}
|
|
179
222
|
const result = await engine.exec();
|
|
180
223
|
if (!result) {
|
|
224
|
+
ignoredFiles.push(file.path);
|
|
181
225
|
continue;
|
|
182
226
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
227
|
+
if (!skipped) {
|
|
228
|
+
processedFiles.push(result.filePath);
|
|
229
|
+
filesContent.set(result.filePath, {
|
|
230
|
+
sourceCode: result.sourceCode,
|
|
231
|
+
fixedCode: result.fixedCode,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
let nodeList;
|
|
188
235
|
if (needsScope) {
|
|
189
|
-
|
|
236
|
+
// `exec()` leaves the engine on the pre-fix document, but with --fix the
|
|
237
|
+
// violations below are those of the fixed code (see `violationsBeforeDedupe`),
|
|
238
|
+
// and a scope is found by their line and column. The node list has to be
|
|
239
|
+
// that of the fixed code, which a file-based engine cannot be moved onto
|
|
240
|
+
// (its file is read-only), so the fixed code is parsed by an engine of its own.
|
|
241
|
+
let scopeEngine = engine;
|
|
242
|
+
if (!fixDryRun && result.fixSummary?.finalPassViolations) {
|
|
243
|
+
const [fixedFile] = await resolveFiles([{ sourceCode: result.fixedCode, name: result.filePath }]);
|
|
244
|
+
scopeEngine = new MLEngine(fixedFile, { ...engineOptions, fix: false });
|
|
245
|
+
await scopeEngine.exec();
|
|
246
|
+
}
|
|
247
|
+
const doc = scopeEngine.document;
|
|
190
248
|
if (doc) {
|
|
191
|
-
// MLNode structurally satisfies PositionedNode (startLine, startCol,
|
|
192
|
-
// id, classList, parentElement, children
|
|
249
|
+
// MLNode structurally satisfies PositionedNode (startLine, startCol, endLine, endCol,
|
|
250
|
+
// localName, id, classList, parentElement, children, and closeTag on an element,
|
|
251
|
+
// are all present). The double cast is
|
|
193
252
|
// needed because TypeScript can't verify structural compatibility between the
|
|
194
253
|
// generic MLNode<T,O> and the plain PositionedNode interface at compile time.
|
|
195
|
-
|
|
254
|
+
nodeList = doc.nodeList;
|
|
255
|
+
if (isSuppressMode) {
|
|
256
|
+
nodeLists.set(result.filePath, nodeList);
|
|
257
|
+
}
|
|
196
258
|
}
|
|
197
259
|
}
|
|
198
260
|
// In fix mode, report the violations remaining in the FIXED code
|
|
@@ -203,12 +265,24 @@ export async function command(files, options, apiOptions) {
|
|
|
203
265
|
const violationsBeforeDedupe = fixDryRun
|
|
204
266
|
? result.violations
|
|
205
267
|
: (result.fixSummary?.finalPassViolations ?? result.violations);
|
|
206
|
-
|
|
268
|
+
// Once the limit is hit every later file is skipped, so the seed below
|
|
269
|
+
// is complete: nothing adds to `seenConfigMessages` after this point.
|
|
270
|
+
const seen = skipped ? (seenConfigMessagesOfSkipped ??= new Set(seenConfigMessages)) : seenConfigMessages;
|
|
271
|
+
const reportedViolations = dedupeConfigLevelViolations(violationsBeforeDedupe, seen);
|
|
272
|
+
// Suppressions apply to the file's whole violation list, before the
|
|
273
|
+
// collector sees it. Count-based matching needs every violation of
|
|
274
|
+
// the file; the `--max-count` budget must be spent on violations
|
|
275
|
+
// that will be reported; and the exit decision counts `keptViolations`
|
|
276
|
+
// of every linted file, so the report's truncation never decides it.
|
|
277
|
+
const { kept: keptViolations, unusedEntries } = isSuppressMode || isPruneMode
|
|
278
|
+
? { kept: reportedViolations, unusedEntries: [] }
|
|
279
|
+
: applyFileSuppressions(result.filePath, reportedViolations, normalRunSuppressions, suppressionsFilePath, nodeList);
|
|
280
|
+
unusedSuppressionEntries.push(...unusedEntries);
|
|
207
281
|
// Progressive出力が有効でJSON形式でない場合
|
|
208
282
|
if (progressiveOutput && format !== 'json') {
|
|
209
283
|
// 即座に出力
|
|
210
284
|
output({
|
|
211
|
-
violations:
|
|
285
|
+
violations: [...keptViolations],
|
|
212
286
|
filePath: result.filePath,
|
|
213
287
|
sourceCode: result.sourceCode,
|
|
214
288
|
fixedCode: result.fixedCode,
|
|
@@ -216,9 +290,11 @@ export async function command(files, options, apiOptions) {
|
|
|
216
290
|
}, options);
|
|
217
291
|
}
|
|
218
292
|
// Add violations to collector
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
293
|
+
if (!skipped) {
|
|
294
|
+
collector.pushWithFile(result.filePath, ...keptViolations);
|
|
295
|
+
}
|
|
296
|
+
const errorCount = keptViolations.filter(v => v.severity === 'error').length;
|
|
297
|
+
const warningCount = keptViolations.filter(v => v.severity === 'warning').length;
|
|
222
298
|
// Track total warning count across all files
|
|
223
299
|
totalWarningCount += warningCount;
|
|
224
300
|
if (!hasError && (errorCount > 0 || (warningCount > 0 && !options.allowWarnings))) {
|
|
@@ -261,7 +337,25 @@ export async function command(files, options, apiOptions) {
|
|
|
261
337
|
process.stderr.write('No suppressions file found. Nothing to prune.\n');
|
|
262
338
|
return false;
|
|
263
339
|
}
|
|
264
|
-
|
|
340
|
+
// The collected violations are the whole truth only for the files this
|
|
341
|
+
// run had as targets; a file outside the targets has no violations
|
|
342
|
+
// here because it was never checked, not because it is clean. Only the
|
|
343
|
+
// entries of target files (linted, or excluded by the config, which
|
|
344
|
+
// leaves them without violations) are judged, and so are those of a
|
|
345
|
+
// file that no longer exists, which has none either; the others are
|
|
346
|
+
// kept as they are.
|
|
347
|
+
const lintedPaths = new Set([...processedFiles, ...ignoredFiles].map(filePath => toRelativePath(filePath, suppressionsFilePath)));
|
|
348
|
+
const lintedEntries = {};
|
|
349
|
+
const unlintedEntries = {};
|
|
350
|
+
for (const [relPath, rules] of Object.entries(existing)) {
|
|
351
|
+
const judged = lintedPaths.has(relPath) ||
|
|
352
|
+
!(await fs.access(toAbsolutePath(relPath, suppressionsFilePath)).then(() => true, () => false));
|
|
353
|
+
(judged ? lintedEntries : unlintedEntries)[relPath] = rules;
|
|
354
|
+
}
|
|
355
|
+
const pruned = {
|
|
356
|
+
...unlintedEntries,
|
|
357
|
+
...pruneSuppressions(collectedViolationsByFile, lintedEntries, suppressionsFilePath),
|
|
358
|
+
};
|
|
265
359
|
await writeSuppressionsFile(suppressionsFilePath, pruned);
|
|
266
360
|
const existingCount = Object.values(existing).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
|
|
267
361
|
const prunedCount = Object.values(pruned).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
|
|
@@ -269,59 +363,28 @@ export async function command(files, options, apiOptions) {
|
|
|
269
363
|
process.stderr.write(`[Experimental] Suppressions pruned: ${removedCount} entry/entries removed, ${prunedCount} remaining.\n`);
|
|
270
364
|
return false;
|
|
271
365
|
}
|
|
272
|
-
// Normal lint mode:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const { filtered, unusedEntries } = applySuppressions(collectedViolationsByFile, normalRunSuppressions, suppressionsFilePath, { nodeLists });
|
|
278
|
-
outputViolationsByFile = filtered;
|
|
279
|
-
suppressionsApplied = true;
|
|
280
|
-
if (unusedEntries.length > 0) {
|
|
281
|
-
process.stderr.write(`[Experimental] ${unusedEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
|
|
282
|
-
}
|
|
283
|
-
// Recalculate hasError and totalWarningCount from filtered violations
|
|
284
|
-
hasError = false;
|
|
285
|
-
totalWarningCount = 0;
|
|
286
|
-
for (const violations of filtered.values()) {
|
|
287
|
-
const errorCount = violations.filter(v => v.severity === 'error').length;
|
|
288
|
-
const warningCount = violations.filter(v => v.severity === 'warning').length;
|
|
289
|
-
totalWarningCount += warningCount;
|
|
290
|
-
if (errorCount > 0 || (warningCount > 0 && !options.allowWarnings)) {
|
|
291
|
-
hasError = true;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
366
|
+
// Normal lint mode: the collector already holds the violations the
|
|
367
|
+
// suppressions leave, cut to `--max-count`; `hasError` and
|
|
368
|
+
// `totalWarningCount` were counted before that cut.
|
|
369
|
+
if (unusedSuppressionEntries.length > 0) {
|
|
370
|
+
process.stderr.write(`[Experimental] ${unusedSuppressionEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
|
|
295
371
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
// Suppressions file does not exist or is unreadable, proceed normally
|
|
372
|
+
// Check if max-warnings limit is exceeded (ESLint compatible)
|
|
373
|
+
if (!hasError && options.maxWarnings >= 0 && totalWarningCount > options.maxWarnings) {
|
|
374
|
+
hasError = true;
|
|
301
375
|
}
|
|
302
|
-
// Output results
|
|
376
|
+
// Output results. The JSON branch returns early but with the same
|
|
377
|
+
// `hasError` as the other formats; see the `@returns` of this function.
|
|
303
378
|
if (format === 'json') {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
for (const [filePath, violations] of outputViolationsByFile) {
|
|
308
|
-
for (const violation of violations) {
|
|
309
|
-
jsonOutput.push({ ...violation, filePath });
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
const jsonOutput = collector.toArray();
|
|
316
|
-
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
317
|
-
}
|
|
318
|
-
return false;
|
|
379
|
+
const jsonOutput = collector.toArray();
|
|
380
|
+
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
381
|
+
return hasError;
|
|
319
382
|
}
|
|
320
383
|
// Progressive出力が無効の場合のみループ後に出力
|
|
321
384
|
if (!progressiveOutput) {
|
|
322
385
|
// Output per file - include processed files without violations
|
|
323
386
|
for (const filePath of processedFiles) {
|
|
324
|
-
const violations =
|
|
387
|
+
const violations = collectedViolationsByFile.get(filePath) || [];
|
|
325
388
|
const content = filesContent.get(filePath) || { sourceCode: '', fixedCode: '' };
|
|
326
389
|
if (violations.length === 0 && !options.problemOnly) {
|
|
327
390
|
log('Output reports');
|
|
@@ -357,7 +420,7 @@ export async function command(files, options, apiOptions) {
|
|
|
357
420
|
let noticeCount = 0;
|
|
358
421
|
let failedFileCount = 0;
|
|
359
422
|
for (const filePath of processedFiles) {
|
|
360
|
-
const violations =
|
|
423
|
+
const violations = collectedViolationsByFile.get(filePath) || [];
|
|
361
424
|
// A file whose only violations are config-level (`CONFIG_LEVEL_RULE_IDS`)
|
|
362
425
|
// didn't fail on its own content — the config issue is reported once
|
|
363
426
|
// for the whole run (see the dedupe above), not attributable to this
|
|
@@ -390,9 +453,5 @@ export async function command(files, options, apiOptions) {
|
|
|
390
453
|
warningCount,
|
|
391
454
|
noticeCount,
|
|
392
455
|
}, options);
|
|
393
|
-
// Check if max-warnings limit is exceeded (ESLint compatible)
|
|
394
|
-
if (!hasError && options.maxWarnings >= 0 && totalWarningCount > options.maxWarnings) {
|
|
395
|
-
hasError = true;
|
|
396
|
-
}
|
|
397
456
|
return hasError;
|
|
398
457
|
}
|
package/lib/cli/index.d.ts
CHANGED
|
@@ -5,5 +5,41 @@
|
|
|
5
5
|
* `process.stderr.write()` — never `console.warn`/`console.error`/
|
|
6
6
|
* `console.log` — so stdout stays reserved for lint results
|
|
7
7
|
* (e.g. `--format JSON` output that may be piped to other tools).
|
|
8
|
+
*
|
|
9
|
+
* The process ends by setting `process.exitCode` and returning from `main()`,
|
|
10
|
+
* never by `process.exit()`. On POSIX a pipe is written asynchronously, and
|
|
11
|
+
* `process.exit()` drops what is still queued: a report larger than the pipe
|
|
12
|
+
* buffer reached `| jq` cut off at its first 128 KiB (#4554). Node.js says
|
|
13
|
+
* `process.exit()` forces the exit "even if there are still asynchronous
|
|
14
|
+
* operations pending that have not yet completed fully, including I/O
|
|
15
|
+
* operations to `process.stdout`", and asks to "set the `process.exitCode`
|
|
16
|
+
* and allow the process to exit naturally" instead
|
|
17
|
+
* (https://nodejs.org/docs/latest-v24.x/api/process.html#processexitcode).
|
|
18
|
+
* meow's own `--help` / `--version` handling calls `process.exit()` as well,
|
|
19
|
+
* so it is turned off (`autoHelp` / `autoVersion` in `bootstrap.ts`) and done
|
|
20
|
+
* here. Keep it that way: a new `process.exit()` in this layer brings the
|
|
21
|
+
* truncation back for whatever it follows.
|
|
22
|
+
*
|
|
23
|
+
* Standard input is read in two ways. The `-` operand reads it whatever
|
|
24
|
+
* stdout is (POSIX Utility Syntax Guidelines, Guideline 13: "the '-' operand
|
|
25
|
+
* should be used to mean only standard input" for operands that represent
|
|
26
|
+
* files to be opened for reading), which is what lets
|
|
27
|
+
* `cat a.html | markuplint - --format json | jq` work with both ends piped.
|
|
28
|
+
* With no operand it is read only when stdout is a TTY: without that
|
|
29
|
+
* condition a parent that starts the CLI with no arguments and does not close
|
|
30
|
+
* stdin (a child process spawned with an inherited, never-closed stdin) would
|
|
31
|
+
* wait for it forever. An empty stdin is answered with the help and exit code
|
|
32
|
+
* 1 in that case, and linted as an empty source with the `-` operand.
|
|
33
|
+
* Neither way takes the options that act on a file (`--fix`, `--search` and the
|
|
34
|
+
* suppressions options): there is no file to write or record for, and `--fix`
|
|
35
|
+
* once created one named `<AnonymousFile>` (#4560). Both reject them before the
|
|
36
|
+
* standard input is read, so an empty input gets that error, not the help.
|
|
37
|
+
* `--fix-dry-run` is not among them: it writes nothing and only prints the diff.
|
|
38
|
+
*
|
|
39
|
+
* The flags that end the run early (`--help`, `--version`, `--init`,
|
|
40
|
+
* `--create-rule`) are evaluated before the operands, so `-` next to them is
|
|
41
|
+
* ignored. A failure that is not fatal (`isFatalError()`) is reported on stderr
|
|
42
|
+
* with exit code 1; a fatal one is rethrown, and the top-level `await` ends the
|
|
43
|
+
* process with exit code 1 and the stack.
|
|
8
44
|
*/
|
|
9
45
|
export {};
|
package/lib/cli/index.js
CHANGED
|
@@ -5,70 +5,42 @@
|
|
|
5
5
|
* `process.stderr.write()` — never `console.warn`/`console.error`/
|
|
6
6
|
* `console.log` — so stdout stays reserved for lint results
|
|
7
7
|
* (e.g. `--format JSON` output that may be piped to other tools).
|
|
8
|
+
*
|
|
9
|
+
* The process ends by setting `process.exitCode` and returning from `main()`,
|
|
10
|
+
* never by `process.exit()`. On POSIX a pipe is written asynchronously, and
|
|
11
|
+
* `process.exit()` drops what is still queued: a report larger than the pipe
|
|
12
|
+
* buffer reached `| jq` cut off at its first 128 KiB (#4554). Node.js says
|
|
13
|
+
* `process.exit()` forces the exit "even if there are still asynchronous
|
|
14
|
+
* operations pending that have not yet completed fully, including I/O
|
|
15
|
+
* operations to `process.stdout`", and asks to "set the `process.exitCode`
|
|
16
|
+
* and allow the process to exit naturally" instead
|
|
17
|
+
* (https://nodejs.org/docs/latest-v24.x/api/process.html#processexitcode).
|
|
18
|
+
* meow's own `--help` / `--version` handling calls `process.exit()` as well,
|
|
19
|
+
* so it is turned off (`autoHelp` / `autoVersion` in `bootstrap.ts`) and done
|
|
20
|
+
* here. Keep it that way: a new `process.exit()` in this layer brings the
|
|
21
|
+
* truncation back for whatever it follows.
|
|
22
|
+
*
|
|
23
|
+
* Standard input is read in two ways. The `-` operand reads it whatever
|
|
24
|
+
* stdout is (POSIX Utility Syntax Guidelines, Guideline 13: "the '-' operand
|
|
25
|
+
* should be used to mean only standard input" for operands that represent
|
|
26
|
+
* files to be opened for reading), which is what lets
|
|
27
|
+
* `cat a.html | markuplint - --format json | jq` work with both ends piped.
|
|
28
|
+
* With no operand it is read only when stdout is a TTY: without that
|
|
29
|
+
* condition a parent that starts the CLI with no arguments and does not close
|
|
30
|
+
* stdin (a child process spawned with an inherited, never-closed stdin) would
|
|
31
|
+
* wait for it forever. An empty stdin is answered with the help and exit code
|
|
32
|
+
* 1 in that case, and linted as an empty source with the `-` operand.
|
|
33
|
+
* Neither way takes the options that act on a file (`--fix`, `--search` and the
|
|
34
|
+
* suppressions options): there is no file to write or record for, and `--fix`
|
|
35
|
+
* once created one named `<AnonymousFile>` (#4560). Both reject them before the
|
|
36
|
+
* standard input is read, so an empty input gets that error, not the help.
|
|
37
|
+
* `--fix-dry-run` is not among them: it writes nothing and only prints the diff.
|
|
38
|
+
*
|
|
39
|
+
* The flags that end the run early (`--help`, `--version`, `--init`,
|
|
40
|
+
* `--create-rule`) are evaluated before the operands, so `-` next to them is
|
|
41
|
+
* ignored. A failure that is not fatal (`isFatalError()`) is reported on stderr
|
|
42
|
+
* with exit code 1; a fatal one is rethrown, and the top-level `await` ends the
|
|
43
|
+
* process with exit code 1 and the stack.
|
|
8
44
|
*/
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
import { cli } from './bootstrap.js';
|
|
12
|
-
import { command } from './command.js';
|
|
13
|
-
import { initialize } from './init/index.js';
|
|
14
|
-
import { search } from './search/index.js';
|
|
15
|
-
/* eslint-disable unicorn/no-process-exit */
|
|
16
|
-
if (cli.flags.v) {
|
|
17
|
-
cli.showVersion(); // And exit successfully.
|
|
18
|
-
}
|
|
19
|
-
if (cli.flags.h) {
|
|
20
|
-
cli.showHelp(0); // And exit successfully.
|
|
21
|
-
}
|
|
22
|
-
if (cli.flags.verbose) {
|
|
23
|
-
verbosely();
|
|
24
|
-
}
|
|
25
|
-
if (cli.flags.init) {
|
|
26
|
-
await initialize().catch(error => {
|
|
27
|
-
process.stderr.write(error + '\n');
|
|
28
|
-
process.exit(1);
|
|
29
|
-
});
|
|
30
|
-
process.exit(0);
|
|
31
|
-
}
|
|
32
|
-
if (cli.flags.createRule) {
|
|
33
|
-
process.stderr.write("Use to run 'npx @markuplint/create-rule' instead of 'markuplint --create-rule'.\n");
|
|
34
|
-
process.exit(1);
|
|
35
|
-
}
|
|
36
|
-
const files = cli.input;
|
|
37
|
-
if (files.length > 0) {
|
|
38
|
-
if (cli.flags.search) {
|
|
39
|
-
await search(files, cli.flags, cli.flags.search).catch(error => {
|
|
40
|
-
process.stderr.write(error + '\n');
|
|
41
|
-
process.exit(1);
|
|
42
|
-
});
|
|
43
|
-
process.exit(0);
|
|
44
|
-
}
|
|
45
|
-
const hasError = await command(files, cli.flags).catch(error => {
|
|
46
|
-
throw error;
|
|
47
|
-
});
|
|
48
|
-
process.exit(hasError ? 1 : 0);
|
|
49
|
-
}
|
|
50
|
-
if (usePipe()) {
|
|
51
|
-
const stdin = await text(process.stdin).catch(error => {
|
|
52
|
-
// eslint-disable-next-line no-console
|
|
53
|
-
console.warn(error);
|
|
54
|
-
process.exit(1);
|
|
55
|
-
});
|
|
56
|
-
if (!stdin) {
|
|
57
|
-
// result is empty
|
|
58
|
-
cli.showHelp(1);
|
|
59
|
-
}
|
|
60
|
-
const hasError = await command([{ sourceCode: stdin }], {
|
|
61
|
-
...cli.flags,
|
|
62
|
-
ignoreExt: true,
|
|
63
|
-
}).catch(error => {
|
|
64
|
-
process.stderr.write(error + '\n');
|
|
65
|
-
process.exit(1);
|
|
66
|
-
});
|
|
67
|
-
process.exit(hasError ? 1 : 0);
|
|
68
|
-
}
|
|
69
|
-
// No arguments
|
|
70
|
-
cli.showHelp(1);
|
|
71
|
-
/* eslint-enable unicorn/no-process-exit */
|
|
72
|
-
function usePipe() {
|
|
73
|
-
return !process.stdin.isTTY && process.stdout.isTTY;
|
|
74
|
-
}
|
|
45
|
+
import { main } from './main.js';
|
|
46
|
+
process.exitCode = await main();
|
package/lib/cli/init/index.js
CHANGED
|
@@ -71,10 +71,10 @@ export async function initialize() {
|
|
|
71
71
|
if (autoInstall) {
|
|
72
72
|
process.stdout.write('Install the dependencies automatically\n');
|
|
73
73
|
const modules = selectModules(selectedLangs);
|
|
74
|
-
const result = await installModule(modules, true).catch(error_ => new Error(error_));
|
|
74
|
+
const result = await installModule(modules, true).catch(error_ => error_ instanceof Error ? error_ : new Error(String(error_)));
|
|
75
75
|
if (result instanceof Error) {
|
|
76
|
-
//
|
|
77
|
-
|
|
76
|
+
// `index.ts` reports it on stderr and returns exit code 1; see its module JSDoc.
|
|
77
|
+
throw result;
|
|
78
78
|
}
|
|
79
79
|
if (result.alreadyExists) {
|
|
80
80
|
process.stdout.write('Modules are installed already.\n');
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module cli/main
|
|
3
|
+
*
|
|
4
|
+
* The CLI run itself. `index.ts` is the entry point and owns the process boundary
|
|
5
|
+
* (the exit code and why `process.exit()` is not used); this module is separate so a spec
|
|
6
|
+
* can call `main()` without starting the process.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Runs the CLI and resolves to its exit code.
|
|
10
|
+
*
|
|
11
|
+
* Every branch returns a number: a branch that forgets to leaves `undefined`,
|
|
12
|
+
* and the `process.exitCode` that `output.ts` set (1) would leak out.
|
|
13
|
+
*/
|
|
14
|
+
export declare function main(): Promise<number>;
|