markuplint 5.3.0 → 5.5.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.
@@ -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
- const configFile = options.config &&
43
- (path.isAbsolute(options.config) ? options.config : path.resolve(process.cwd(), options.config));
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(options.maxCount);
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, so a document is retained past its file's own iteration only
73
- // when that computation will run (--suppress, or a non-empty suppressions
74
- // file in a normal run). Retaining every file's document otherwise keeps N
75
- // DOM trees reachable until the command ends.
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 at the end: deciding retention from one read and
79
- // applying from a later one could disagree if the file changed mid-run,
80
- // leaving `applySuppressions` without the node lists it needs.
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
- try {
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, ahead of the two whole-run passes that batch output
112
- // waits for: suppressions (applied once at the end, below, via
113
- // `applySuppressions`, since scope resolution and the "unused entry"
114
- // report need the complete result set) and `--max-count` truncation
115
- // (enforced by `collector.pushWithFile`'s running total across files,
116
- // including which file the limit is hit within and which later files are
117
- // skipped entirely). Printing per file ahead of either pass would show
118
- // violations, or omit a skipped-file notice, that the run's own summary
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 && options.maxCount > 0) {
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
- // Check if collector is already locked (max-count reached)
138
- if (collector.isLocked()) {
139
- log('Skipping file due to max-count limit: %s', file.path);
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 engine = new MLEngine(file, {
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,39 @@ 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
- processedFiles.push(result.filePath);
184
- filesContent.set(result.filePath, {
185
- sourceCode: result.sourceCode,
186
- fixedCode: result.fixedCode,
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
- const doc = engine.document;
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
249
  // MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
192
250
  // id, classList, parentElement, children are all present). The double cast is
193
251
  // needed because TypeScript can't verify structural compatibility between the
194
252
  // generic MLNode<T,O> and the plain PositionedNode interface at compile time.
195
- nodeLists.set(result.filePath, doc.nodeList);
253
+ nodeList = doc.nodeList;
254
+ if (isSuppressMode) {
255
+ nodeLists.set(result.filePath, nodeList);
256
+ }
196
257
  }
197
258
  }
198
259
  // In fix mode, report the violations remaining in the FIXED code
@@ -203,12 +264,24 @@ export async function command(files, options, apiOptions) {
203
264
  const violationsBeforeDedupe = fixDryRun
204
265
  ? result.violations
205
266
  : (result.fixSummary?.finalPassViolations ?? result.violations);
206
- const reportedViolations = dedupeConfigLevelViolations(violationsBeforeDedupe, seenConfigMessages);
267
+ // Once the limit is hit every later file is skipped, so the seed below
268
+ // is complete: nothing adds to `seenConfigMessages` after this point.
269
+ const seen = skipped ? (seenConfigMessagesOfSkipped ??= new Set(seenConfigMessages)) : seenConfigMessages;
270
+ const reportedViolations = dedupeConfigLevelViolations(violationsBeforeDedupe, seen);
271
+ // Suppressions apply to the file's whole violation list, before the
272
+ // collector sees it. Count-based matching needs every violation of
273
+ // the file; the `--max-count` budget must be spent on violations
274
+ // that will be reported; and the exit decision counts `keptViolations`
275
+ // of every linted file, so the report's truncation never decides it.
276
+ const { kept: keptViolations, unusedEntries } = isSuppressMode || isPruneMode
277
+ ? { kept: reportedViolations, unusedEntries: [] }
278
+ : applyFileSuppressions(result.filePath, reportedViolations, normalRunSuppressions, suppressionsFilePath, nodeList);
279
+ unusedSuppressionEntries.push(...unusedEntries);
207
280
  // Progressive出力が有効でJSON形式でない場合
208
281
  if (progressiveOutput && format !== 'json') {
209
282
  // 即座に出力
210
283
  output({
211
- violations: reportedViolations,
284
+ violations: [...keptViolations],
212
285
  filePath: result.filePath,
213
286
  sourceCode: result.sourceCode,
214
287
  fixedCode: result.fixedCode,
@@ -216,9 +289,11 @@ export async function command(files, options, apiOptions) {
216
289
  }, options);
217
290
  }
218
291
  // Add violations to collector
219
- collector.pushWithFile(result.filePath, ...reportedViolations);
220
- const errorCount = reportedViolations.filter(v => v.severity === 'error').length;
221
- const warningCount = reportedViolations.filter(v => v.severity === 'warning').length;
292
+ if (!skipped) {
293
+ collector.pushWithFile(result.filePath, ...keptViolations);
294
+ }
295
+ const errorCount = keptViolations.filter(v => v.severity === 'error').length;
296
+ const warningCount = keptViolations.filter(v => v.severity === 'warning').length;
222
297
  // Track total warning count across all files
223
298
  totalWarningCount += warningCount;
224
299
  if (!hasError && (errorCount > 0 || (warningCount > 0 && !options.allowWarnings))) {
@@ -261,7 +336,25 @@ export async function command(files, options, apiOptions) {
261
336
  process.stderr.write('No suppressions file found. Nothing to prune.\n');
262
337
  return false;
263
338
  }
264
- const pruned = pruneSuppressions(collectedViolationsByFile, existing, suppressionsFilePath);
339
+ // The collected violations are the whole truth only for the files this
340
+ // run had as targets; a file outside the targets has no violations
341
+ // here because it was never checked, not because it is clean. Only the
342
+ // entries of target files (linted, or excluded by the config, which
343
+ // leaves them without violations) are judged, and so are those of a
344
+ // file that no longer exists, which has none either; the others are
345
+ // kept as they are.
346
+ const lintedPaths = new Set([...processedFiles, ...ignoredFiles].map(filePath => toRelativePath(filePath, suppressionsFilePath)));
347
+ const lintedEntries = {};
348
+ const unlintedEntries = {};
349
+ for (const [relPath, rules] of Object.entries(existing)) {
350
+ const judged = lintedPaths.has(relPath) ||
351
+ !(await fs.access(toAbsolutePath(relPath, suppressionsFilePath)).then(() => true, () => false));
352
+ (judged ? lintedEntries : unlintedEntries)[relPath] = rules;
353
+ }
354
+ const pruned = {
355
+ ...unlintedEntries,
356
+ ...pruneSuppressions(collectedViolationsByFile, lintedEntries, suppressionsFilePath),
357
+ };
265
358
  await writeSuppressionsFile(suppressionsFilePath, pruned);
266
359
  const existingCount = Object.values(existing).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
267
360
  const prunedCount = Object.values(pruned).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
@@ -269,59 +362,28 @@ export async function command(files, options, apiOptions) {
269
362
  process.stderr.write(`[Experimental] Suppressions pruned: ${removedCount} entry/entries removed, ${prunedCount} remaining.\n`);
270
363
  return false;
271
364
  }
272
- // Normal lint mode: apply suppressions if file exists
273
- let outputViolationsByFile = collectedViolationsByFile;
274
- let suppressionsApplied = false;
275
- try {
276
- if (needsScope) {
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
- }
365
+ // Normal lint mode: the collector already holds the violations the
366
+ // suppressions leave, cut to `--max-count`; `hasError` and
367
+ // `totalWarningCount` were counted before that cut.
368
+ if (unusedSuppressionEntries.length > 0) {
369
+ process.stderr.write(`[Experimental] ${unusedSuppressionEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
295
370
  }
296
- catch (error) {
297
- if (isFatalError(error)) {
298
- throw error;
299
- }
300
- // Suppressions file does not exist or is unreadable, proceed normally
371
+ // Check if max-warnings limit is exceeded (ESLint compatible)
372
+ if (!hasError && options.maxWarnings >= 0 && totalWarningCount > options.maxWarnings) {
373
+ hasError = true;
301
374
  }
302
- // Output results
375
+ // Output results. The JSON branch returns early but with the same
376
+ // `hasError` as the other formats; see the `@returns` of this function.
303
377
  if (format === 'json') {
304
- if (suppressionsApplied) {
305
- // Build filtered array with filePath
306
- const jsonOutput = [];
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;
378
+ const jsonOutput = collector.toArray();
379
+ process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
380
+ return hasError;
319
381
  }
320
382
  // Progressive出力が無効の場合のみループ後に出力
321
383
  if (!progressiveOutput) {
322
384
  // Output per file - include processed files without violations
323
385
  for (const filePath of processedFiles) {
324
- const violations = outputViolationsByFile.get(filePath) || [];
386
+ const violations = collectedViolationsByFile.get(filePath) || [];
325
387
  const content = filesContent.get(filePath) || { sourceCode: '', fixedCode: '' };
326
388
  if (violations.length === 0 && !options.problemOnly) {
327
389
  log('Output reports');
@@ -357,7 +419,7 @@ export async function command(files, options, apiOptions) {
357
419
  let noticeCount = 0;
358
420
  let failedFileCount = 0;
359
421
  for (const filePath of processedFiles) {
360
- const violations = outputViolationsByFile.get(filePath) || [];
422
+ const violations = collectedViolationsByFile.get(filePath) || [];
361
423
  // A file whose only violations are config-level (`CONFIG_LEVEL_RULE_IDS`)
362
424
  // didn't fail on its own content — the config issue is reported once
363
425
  // for the whole run (see the dedupe above), not attributable to this
@@ -390,9 +452,5 @@ export async function command(files, options, apiOptions) {
390
452
  warningCount,
391
453
  noticeCount,
392
454
  }, 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
455
  return hasError;
398
456
  }
@@ -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 { text } from 'node:stream/consumers';
10
- import { verbosely } from '../debug.js';
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();
@@ -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
- // eslint-disable-next-line unicorn/no-process-exit
77
- process.exit(1);
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>;