@markuplint/ml-core 5.0.0-rc.4 → 5.0.0-rc.6

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +0 -5
  3. package/lib/cursor-offset.js +0 -3
  4. package/lib/fix-applier.js +3 -9
  5. package/lib/ml-core.d.ts +33 -3
  6. package/lib/ml-core.js +276 -62
  7. package/lib/ml-dom/helper/accname.d.ts +0 -8
  8. package/lib/ml-dom/helper/accname.js +7 -10
  9. package/lib/ml-dom/node/attr.js +3 -1
  10. package/lib/ml-dom/node/block.d.ts +6 -0
  11. package/lib/ml-dom/node/block.js +6 -0
  12. package/lib/ml-dom/node/child-node.d.ts +0 -9
  13. package/lib/ml-dom/node/child-node.js +0 -9
  14. package/lib/ml-dom/node/document.d.ts +20 -1
  15. package/lib/ml-dom/node/document.js +24 -13
  16. package/lib/ml-dom/node/element-close-tag.d.ts +12 -0
  17. package/lib/ml-dom/node/element-close-tag.js +12 -0
  18. package/lib/ml-dom/node/element.d.ts +22 -0
  19. package/lib/ml-dom/node/element.js +37 -11
  20. package/lib/ml-dom/node/node-store.d.ts +0 -3
  21. package/lib/ml-dom/node/node-store.js +0 -3
  22. package/lib/ml-dom/node/node.d.ts +34 -1
  23. package/lib/ml-dom/node/node.js +34 -16
  24. package/lib/ml-dom/node/parent-node.js +0 -6
  25. package/lib/ml-dom/node/rule-mapper.d.ts +8 -0
  26. package/lib/ml-dom/node/rule-mapper.js +8 -0
  27. package/lib/ml-rule/ml-rule.d.ts +19 -0
  28. package/lib/ml-rule/ml-rule.js +38 -7
  29. package/lib/ml-rule/types.d.ts +110 -1
  30. package/lib/ml-rule/types.js +28 -1
  31. package/lib/ruleset/index.d.ts +2 -1
  32. package/lib/ruleset/index.js +2 -1
  33. package/lib/test/index.js +1 -1
  34. package/lib/types.d.ts +8 -1
  35. package/lib/virtual-rule.d.ts +10 -0
  36. package/lib/virtual-rule.js +1 -24
  37. package/package.json +13 -13
  38. package/ARCHITECTURE.ja.md +0 -676
  39. package/ARCHITECTURE.md +0 -726
  40. package/SKILL.md +0 -61
  41. package/docs/linting-pipeline.ja.md +0 -307
  42. package/docs/linting-pipeline.md +0 -307
  43. package/docs/maintenance.ja.md +0 -210
  44. package/docs/maintenance.md +0 -210
  45. package/docs/ml-dom/attr.ja.md +0 -103
  46. package/docs/ml-dom/attr.md +0 -103
  47. package/docs/ml-dom/block.ja.md +0 -272
  48. package/docs/ml-dom/block.md +0 -272
  49. package/docs/ml-dom/document.ja.md +0 -134
  50. package/docs/ml-dom/document.md +0 -134
  51. package/docs/ml-dom/element.ja.md +0 -161
  52. package/docs/ml-dom/element.md +0 -161
  53. package/docs/ml-dom/helpers.ja.md +0 -203
  54. package/docs/ml-dom/helpers.md +0 -203
  55. package/docs/ml-dom/node.ja.md +0 -199
  56. package/docs/ml-dom/node.md +0 -199
  57. package/docs/ml-dom/others.ja.md +0 -120
  58. package/docs/ml-dom/others.md +0 -120
  59. package/docs/ml-dom/overview.ja.md +0 -102
  60. package/docs/ml-dom/overview.md +0 -102
  61. package/docs/ml-dom/pretender.ja.md +0 -269
  62. package/docs/ml-dom/pretender.md +0 -269
  63. package/docs/ml-dom/rule-mapping.ja.md +0 -371
  64. package/docs/ml-dom/rule-mapping.md +0 -371
  65. package/docs/ml-dom.ja.md +0 -18
  66. package/docs/ml-dom.md +0 -18
  67. package/docs/rule-system.ja.md +0 -287
  68. package/docs/rule-system.md +0 -287
package/lib/ml-core.js CHANGED
@@ -4,6 +4,20 @@ import { applyFixes } from './fix-applier.js';
4
4
  import { Document } from './ml-dom/index.js';
5
5
  import { expandNamedNodeRules, expandNamedRules } from './virtual-rule.js';
6
6
  const resultLog = log.extend('result');
7
+ /**
8
+ * `ruleId` of a genuinely broken config (unresolved rule reference, plugin
9
+ * resolution failure, ...). Exported so consumers that need to recognize
10
+ * config-level violations (e.g. the CLI's per-run dedupe and failed-file
11
+ * counting in `packages/markuplint/src/cli/command.ts`) reference the same
12
+ * literal `MLCore.verify()` emits, instead of duplicating the string.
13
+ */
14
+ export const CONFIG_ERROR_RULE_ID = 'config-error';
15
+ /**
16
+ * `ruleId` of a deprecated-but-working rule name notice — see
17
+ * {@link CONFIG_ERROR_RULE_ID} for why this is exported rather than a
18
+ * private literal.
19
+ */
20
+ export const RULE_DEPRECATION_RULE_ID = 'rule-deprecation';
7
21
  /**
8
22
  * The core linting engine for markuplint.
9
23
  *
@@ -24,7 +38,24 @@ export class MLCore {
24
38
  #schemas;
25
39
  #ruleCommonSettings;
26
40
  #sourceCode;
41
+ /**
42
+ * Config-time errors (named-rule expansion, the `configErrors` fabric input).
43
+ * Set once per construction/`update()` and never mutated by re-parsing.
44
+ */
27
45
  #configErrors;
46
+ /**
47
+ * Deprecated-rule-name notices, kept structured and separate from
48
+ * `#configErrors` so `verify()` can report them under their own
49
+ * `rule-deprecation` ruleId. Set once per construction/`update()`, same
50
+ * lifecycle as `#configErrors`.
51
+ */
52
+ #ruleDeprecations;
53
+ /**
54
+ * Rule-mapping errors for the CURRENT document. Reset (not accumulated) on
55
+ * every `#createDocument()` so repeated `setCode()` calls don't duplicate
56
+ * them. See https://github.com/markuplint/markuplint/issues/3900.
57
+ */
58
+ #mappingErrors = [];
28
59
  /**
29
60
  * Pre-expansion nodeRules preserved for hot-reload.
30
61
  * When `update()` is called without a new ruleset, these are used as the
@@ -33,12 +64,8 @@ export class MLCore {
33
64
  */
34
65
  #originalNodeRules;
35
66
  #originalChildNodeRules;
36
- /**
37
- * Pre-computed namespace prefixes from wildcard disable entries.
38
- * e.g., `rules["a11y/*"]: false` yields `"a11y/"`.
39
- */
40
67
  #disabledNamespaces;
41
- constructor({ parser, sourceCode, ruleset, rules, locale, schemas, ruleCommonSettings, parserOptions, severity, pretenders, filename, debug, configErrors, }) {
68
+ constructor({ parser, sourceCode, ruleset, rules, locale, schemas, ruleCommonSettings, parserOptions, severity, pretenders, filename, debug, configErrors, ruleDeprecations, }) {
42
69
  if (debug) {
43
70
  enableDebug();
44
71
  }
@@ -52,12 +79,11 @@ export class MLCore {
52
79
  this.#severity = severity;
53
80
  this.#pretenders = [...pretenders];
54
81
  this.#configErrors = [...(configErrors ?? [])];
82
+ this.#ruleDeprecations = [...(ruleDeprecations ?? [])];
55
83
  // Preserve pre-expansion nodeRules for hot-reload
56
84
  this.#originalNodeRules = ruleset.nodeRules ?? [];
57
85
  this.#originalChildNodeRules = ruleset.childNodeRules ?? [];
58
- // Expand named rule groups in the rules section
59
86
  const namedRulesResult = expandNamedRules(ruleset.rules ?? {}, rules);
60
- // Expand named nodeRules into virtual rules (using expanded rules as base)
61
87
  const allRulesForExpansion = [...rules, ...namedRulesResult.virtualRules];
62
88
  const nodeRuleResult = expandNamedNodeRules(this.#originalNodeRules, allRulesForExpansion);
63
89
  const childNodeRuleResult = expandNamedNodeRules(this.#originalChildNodeRules, allRulesForExpansion);
@@ -97,22 +123,21 @@ export class MLCore {
97
123
  *
98
124
  * @param fabric - Partial fabric with the properties to update
99
125
  */
100
- update({ parser, ruleset, rules, locale, schemas, parserOptions, configErrors }) {
126
+ update({ parser, ruleset, rules, locale, schemas, parserOptions, pretenders, configErrors, ruleDeprecations, }) {
101
127
  this.#parser = parser ?? this.#parser;
102
128
  this.#locale = locale ?? this.#locale;
103
129
  this.#schemas = schemas ?? this.#schemas;
130
+ this.#pretenders = pretenders ? [...pretenders] : this.#pretenders;
104
131
  this.#configErrors = [...(configErrors ?? [])];
132
+ this.#ruleDeprecations = [...(ruleDeprecations ?? [])];
105
133
  const baseRules = rules ? [...rules] : this.#rules.filter(r => !r.baseRuleId);
106
- // Use pre-expansion originals as fallback when ruleset is not provided
107
134
  const incomingNodeRules = ruleset?.nodeRules ?? this.#originalNodeRules;
108
135
  const incomingChildNodeRules = ruleset?.childNodeRules ?? this.#originalChildNodeRules;
109
- // Expand named rule groups in the rules section
110
136
  const incomingRules = ruleset?.rules ?? this.#ruleset.rules;
111
137
  const namedRulesResult = expandNamedRules(incomingRules, baseRules);
112
138
  const allRulesForExpansion = [...baseRules, ...namedRulesResult.virtualRules];
113
139
  const nodeRuleResult = expandNamedNodeRules(incomingNodeRules, allRulesForExpansion);
114
140
  const childNodeRuleResult = expandNamedNodeRules(incomingChildNodeRules, allRulesForExpansion);
115
- // Update originals if new data was provided
116
141
  if (ruleset?.nodeRules) {
117
142
  this.#originalNodeRules = ruleset.nodeRules;
118
143
  }
@@ -151,20 +176,24 @@ export class MLCore {
151
176
  log('verify: error %o', this.#document.message);
152
177
  return { violations, fixedCode: fix ? this.#sourceCode : undefined };
153
178
  }
179
+ this.#pushNonFatalParseErrors(violations);
154
180
  const definedRuleName = new Set(this.#rules.map(rule => rule.name));
155
181
  const setRuleNames = new Set([
156
182
  ...Object.keys(this.#ruleset.rules),
157
183
  ...this.#ruleset.nodeRules.flatMap(nodeRule => Object.keys(nodeRule.rules ?? {})),
158
184
  ...this.#ruleset.childNodeRules.flatMap(childNodeRule => Object.keys(childNodeRule.rules ?? {})),
159
185
  ]);
186
+ // Config-level violations are independent of the source code, so they are
187
+ // collected separately to also be included in `finalPassViolations`.
188
+ const configViolations = [];
160
189
  for (const setRuleName of setRuleNames) {
161
190
  // Skip wildcard patterns (e.g., "a11y/*") — they are namespace disable entries, not rule references
162
191
  if (setRuleName.endsWith('/*')) {
163
192
  continue;
164
193
  }
165
194
  if (!definedRuleName.has(setRuleName)) {
166
- violations.push({
167
- ruleId: 'config-error',
195
+ configViolations.push({
196
+ ruleId: CONFIG_ERROR_RULE_ID,
168
197
  severity: 'warning',
169
198
  message: `Rule not found: ${setRuleName}`,
170
199
  col: 1,
@@ -173,9 +202,9 @@ export class MLCore {
173
202
  });
174
203
  }
175
204
  }
176
- for (const error of this.#configErrors) {
177
- violations.push({
178
- ruleId: 'config-error',
205
+ for (const error of [...this.#configErrors, ...this.#mappingErrors]) {
206
+ configViolations.push({
207
+ ruleId: CONFIG_ERROR_RULE_ID,
179
208
  severity: 'warning',
180
209
  message: error.message,
181
210
  col: 1,
@@ -183,6 +212,20 @@ export class MLCore {
183
212
  raw: '',
184
213
  });
185
214
  }
215
+ const deprecationSeverity = this.#resolveDeprecationSeverity();
216
+ if (deprecationSeverity != null) {
217
+ for (const { deprecatedName, replacedBy } of this.#ruleDeprecations) {
218
+ configViolations.push({
219
+ ruleId: RULE_DEPRECATION_RULE_ID,
220
+ severity: deprecationSeverity,
221
+ message: `Rule "${deprecatedName}" is deprecated and will be removed in v6. Use ${replacedBy.join(', ')} instead.`,
222
+ col: 1,
223
+ line: 1,
224
+ raw: '',
225
+ });
226
+ }
227
+ }
228
+ violations.push(...configViolations);
186
229
  const ruleViolations = await this.#runAllRules(fix);
187
230
  violations.push(...ruleViolations);
188
231
  if (resultLog.enabled) {
@@ -200,7 +243,6 @@ export class MLCore {
200
243
  resultLog('Warning: %d', w);
201
244
  resultLog('Info: %d', i);
202
245
  }
203
- // Apply fixes if enabled
204
246
  let fixedCode;
205
247
  let fixSummary;
206
248
  if (fix) {
@@ -209,16 +251,28 @@ export class MLCore {
209
251
  const originalSourceCode = this.#sourceCode;
210
252
  const originalAst = this.#ast;
211
253
  const originalDocument = this.#document;
254
+ const originalMappingErrors = this.#mappingErrors;
212
255
  try {
213
256
  const fixResult = await this.#multiPassFix(violations);
214
257
  fixedCode = fixResult.code;
215
258
  fixSummary = fixResult.summary;
259
+ if (configViolations.length > 0 && fixSummary.finalPassViolations) {
260
+ // Config-level violations persist regardless of fixing;
261
+ // keep them visible in the post-fix violation list.
262
+ fixSummary = {
263
+ ...fixSummary,
264
+ finalPassViolations: [...configViolations, ...fixSummary.finalPassViolations],
265
+ };
266
+ }
216
267
  }
217
268
  finally {
218
- // Restore original state - verify() must be non-mutating
269
+ // Restore original state - verify() must be non-mutating.
270
+ // The fix loop re-parses, and each #createDocument resets
271
+ // #mappingErrors; restore it to the pre-fix document's.
219
272
  this.#sourceCode = originalSourceCode;
220
273
  this.#ast = originalAst;
221
274
  this.#document = originalDocument;
275
+ this.#mappingErrors = originalMappingErrors;
222
276
  }
223
277
  }
224
278
  else {
@@ -236,6 +290,10 @@ export class MLCore {
236
290
  return { violations, fixedCode, fixSummary };
237
291
  }
238
292
  #createDocument() {
293
+ // Reset up front: mapping errors belong to the document being (re)built,
294
+ // so a failed parse or build must not leave a previous document's errors
295
+ // behind. Repopulated below only on a successful build. See #3900.
296
+ this.#mappingErrors = [];
239
297
  if (!this.#ast) {
240
298
  return;
241
299
  }
@@ -247,8 +305,11 @@ export class MLCore {
247
305
  tagNameCaseSensitive: this.#parser.tagNameCaseSensitive,
248
306
  pretenders: this.#pretenders,
249
307
  });
250
- // Collect errors from rule mapping (e.g., invalid wildcard usage)
251
- this.#configErrors.push(...this.#ruleset.mappingErrors);
308
+ // Collect errors from rule mapping (e.g., invalid wildcard usage).
309
+ // Reset rather than append: the Document constructor regenerates
310
+ // `mappingErrors` on every call, so accumulating them would duplicate
311
+ // the same errors on each re-parse (e.g. via setCode). See #3900.
312
+ this.#mappingErrors = [...this.#ruleset.mappingErrors];
252
313
  this.#ruleset.mappingErrors.length = 0;
253
314
  }
254
315
  catch (error) {
@@ -260,14 +321,126 @@ export class MLCore {
260
321
  }
261
322
  }
262
323
  }
263
- #createParseError(message, line, col, raw) {
264
- if (this.#severity.parseError === false || this.#severity.parseError === 'off') {
324
+ /**
325
+ * Surfaces non-fatal parser conformance errors collected by the underlying
326
+ * parser (e.g., parse5's `onParseError` events on `MLASTDocument.parseErrors`)
327
+ * via the same `parse-error` violation channel as fatal `ParserError`s.
328
+ *
329
+ * Order contract (relied on by every rule spec under
330
+ * `@markuplint/rules/src/**`): each `parseErrors` entry is pushed in the
331
+ * order the parser emitted it, *before* any rule iteration runs. Tests
332
+ * that match on `toStrictEqual([...])` depend on this position.
333
+ *
334
+ * Severity resolution:
335
+ *
336
+ * - `severity.parseError` is a `Partial<Record<MLASTParseErrorCode, …>>` →
337
+ * each entry's `code` looks up its own severity; codes absent from the
338
+ * record default to `'off'` (suppressed).
339
+ * - `severity.parseError` is a single severity string/boolean → applied
340
+ * uniformly to every entry.
341
+ * - `severity.parseError` is unset → **all non-fatal codes are off**.
342
+ * Users must opt in explicitly.
343
+ *
344
+ * @param violations The verify-time violations array to mutate.
345
+ */
346
+ #pushNonFatalParseErrors(violations) {
347
+ const parseErrors = this.#ast?.parseErrors;
348
+ if (!parseErrors) {
349
+ return;
350
+ }
351
+ // Build the Set of parse5 codes that an active ml rule has claimed
352
+ // responsibility for via `meta.mirrorsParseErrorCodes`. The user's
353
+ // ruleset decides whether each rule's declaration is in scope:
354
+ //
355
+ // - Rule **set** in the ruleset (config !== undefined) — whatever the
356
+ // value (`true`, `false`, severity, object) — the user has expressed
357
+ // intent about this check. Honour the mirror declaration:
358
+ // - active: the rule itself will report violations for those codes
359
+ // - disabled (`false`): the user explicitly opted out, so the channel
360
+ // stays silent too — no surprise re-surfacing
361
+ // - Rule **not mentioned** (config === undefined) — pure default. The
362
+ // parse-error channel remains the channel of record for those codes
363
+ // and surfaces them when the user has opted in via `severity.parseError`.
364
+ //
365
+ // This keeps the responsibility clean: rule packages declare what they
366
+ // cover (static metadata); ml-core honours the user's ruleset choice;
367
+ // no per-node logic, no hard-coded code→rule map.
368
+ const mirroredCodes = new Set();
369
+ for (const rule of this.#rules) {
370
+ if (rule.mirrorsParseErrorCodes.length === 0) {
371
+ continue;
372
+ }
373
+ // The rule is "mentioned in the ruleset" if **either** the alias
374
+ // name OR the base rule name has an entry. Two entry styles exist:
375
+ //
376
+ // - Direct user configs use base rule names: `rules.attr-duplication`
377
+ // - Preset named nodeRules use alias names: `rules['html-standard/attr-duplication']`
378
+ //
379
+ // `MLRule` for a preset-aliased entry has `rule.name = 'html-standard/...'`
380
+ // and `rule.baseRuleId = 'attr-duplication'`; for a direct entry,
381
+ // `rule.name = 'attr-duplication'` and `baseRuleId` is undefined.
382
+ // Checking both names covers both styles.
383
+ const aliasConfig = this.#ruleset.rules[rule.name];
384
+ const baseConfig = rule.baseRuleId === undefined ? undefined : this.#ruleset.rules[rule.baseRuleId];
385
+ if (aliasConfig === undefined && baseConfig === undefined) {
386
+ continue;
387
+ }
388
+ for (const code of rule.mirrorsParseErrorCodes) {
389
+ mirroredCodes.add(code);
390
+ }
391
+ }
392
+ for (const parserError of parseErrors) {
393
+ if (mirroredCodes.has(parserError.code)) {
394
+ continue;
395
+ }
396
+ const violation = this.#createParseError(`Parser conformance error: ${parserError.code}`, parserError.startLine, parserError.startCol, parserError.raw, parserError.code);
397
+ if (violation) {
398
+ violations.push(violation);
399
+ }
400
+ }
401
+ }
402
+ /**
403
+ * Resolves `severity.deprecation`, honouring its single-value form only
404
+ * (unlike `severity.parseError`, there's no fixed enum of deprecated rule
405
+ * names to key a per-code `Record` on).
406
+ *
407
+ * Unlike `#createParseError`'s parse-error channel, this defaults to
408
+ * `'warning'` (not off/suppressed) when unset — the deprecation channel
409
+ * is being carved out of the always-on `config-error` channel, not
410
+ * introduced cold, so leaving the option unset must not silence a notice
411
+ * users already see today.
412
+ *
413
+ * @returns the resolved severity, or `null` if suppressed.
414
+ */
415
+ #resolveDeprecationSeverity() {
416
+ return resolveUniformSeverity(this.#severity.deprecation, 'warning');
417
+ }
418
+ /**
419
+ * Builds a `ruleId: 'parse-error'` violation, honouring
420
+ * `severity.parseError`.
421
+ *
422
+ * If `code` is supplied (non-fatal `parseErrors` entry) and the option is
423
+ * a `Partial<Record<code, severity>>`, the per-code value is used (absent
424
+ * codes default to `'off'`). If the option is unset, non-fatal entries
425
+ * are suppressed (opt-in only) while fatal errors (no `code`) still
426
+ * default to `'error'`.
427
+ *
428
+ * @returns the violation, or `null` if suppressed.
429
+ */
430
+ #createParseError(message, line, col, raw, code) {
431
+ const cfg = this.#severity.parseError;
432
+ // Fatal ParserErrors (no `code`) can't be targeted by the per-code
433
+ // Record form, so they fall back to `'error'` there; under the
434
+ // uniform form they default to `'error'` too, while non-fatal entries
435
+ // default to suppressed when unset — see `resolveUniformSeverity`.
436
+ const severity = typeof cfg === 'object'
437
+ ? code == null
438
+ ? 'error'
439
+ : resolveUniformSeverity(cfg[code], null)
440
+ : resolveUniformSeverity(cfg, code == null ? 'error' : null);
441
+ if (severity == null) {
265
442
  return null;
266
443
  }
267
- // Default severity is 'error'
268
- const severity = this.#severity.parseError === true || this.#severity.parseError == null
269
- ? 'error'
270
- : this.#severity.parseError;
271
444
  return {
272
445
  ruleId: 'parse-error',
273
446
  severity,
@@ -278,23 +451,29 @@ export class MLCore {
278
451
  };
279
452
  }
280
453
  /**
281
- * Iteratively applies fixes, re-parses, and re-verifies until no overlapping
282
- * fixes remain or the maximum pass count is reached (ESLint-style multi-pass loop).
454
+ * ESLint-style multi-pass fix loop (modeled after SourceCodeFixer).
283
455
  *
284
456
  * **Callers must save/restore `#sourceCode`, `#ast`, and `#document`** because
285
457
  * this method mutates them during intermediate re-parse steps.
286
- *
287
- * @param initialViolations - Violations from the first verification pass
288
- * @returns The final fixed source code and a summary of the fix process
289
458
  */
290
459
  async #multiPassFix(initialViolations) {
460
+ // Same safety cap as ESLint's SourceCodeFixer (10 passes).
291
461
  const MAX_FIX_PASSES = 10;
292
462
  let currentCode = this.#sourceCode;
293
- let previousCode;
294
463
  let fixes = extractFixes(initialViolations);
295
464
  let totalApplied = 0;
296
465
  let totalSkipped = 0;
297
466
  let firstPassEdits = [];
467
+ // The input code of each pass, keyed by code string, for N-pass cycle
468
+ // detection (A → B → A as well as longer cycles such as A → B → C → A).
469
+ const codeHistory = new Map([[currentCode, 0]]);
470
+ // Violations from the latest #runAllRules call, valid for `currentCode`.
471
+ // Reused by the final verification below to avoid a redundant re-run
472
+ // when the loop already re-verified the final code.
473
+ let latestViolations;
474
+ // The most recent code that is known to parse successfully. Used to roll
475
+ // back a pass whose output fails to parse.
476
+ let lastParsableCode = currentCode;
298
477
  let pass = 0;
299
478
  for (; pass < MAX_FIX_PASSES; pass++) {
300
479
  log('fix pass %d: %d fixes', pass, fixes.length);
@@ -312,31 +491,42 @@ export class MLCore {
312
491
  log('fix pass %d: output unchanged, stopping', pass);
313
492
  break;
314
493
  }
315
- // Cycle detection: if the output matches the code from two passes ago,
316
- // fixes are oscillating (A → B → A) and will never converge.
317
- if (previousCode !== undefined && result.output === previousCode) {
318
- log('fix pass %d: cycle detected (output matches pass %d), stopping', pass, pass - 2);
494
+ // Cycle detection: if the output matches the input of any earlier pass,
495
+ // fixes are oscillating (A → B → A, A → B → C → A, ...) and will never converge.
496
+ const cycleStart = codeHistory.get(result.output);
497
+ if (cycleStart !== undefined) {
498
+ log('fix pass %d: cycle detected (output matches the input of pass %d, cycle length %d), stopping', pass, cycleStart, pass + 1 - cycleStart);
319
499
  currentCode = result.output;
500
+ latestViolations = undefined;
320
501
  break;
321
502
  }
322
- previousCode = currentCode;
323
503
  currentCode = result.output;
324
- if (result.skipped.length === 0) {
325
- log('fix pass %d: all fixes applied, stopping', pass);
326
- break;
327
- }
328
- // --- Multi-pass path (only when overlapping fixes exist) ---
329
- log('fix pass %d: %d skipped, re-parsing for next pass', pass, result.skipped.length);
330
- const previousGoodCode = currentCode;
504
+ codeHistory.set(currentCode, pass + 1);
505
+ latestViolations = undefined;
506
+ // Parse the output immediately — for the next pass or for the final
507
+ // verification — so an unparsable output is always rolled back
508
+ // instead of being returned (and written to disk) broken.
331
509
  this.#sourceCode = currentCode;
332
510
  this.#parse();
333
511
  this.#createDocument();
334
512
  if (this.#document instanceof ParserError) {
335
- log('fix pass %d: produced unparsable code, reverting to previous state', pass);
336
- currentCode = previousGoodCode;
513
+ log('fix pass %d: produced unparsable code, reverting to the previous parsable code', pass);
514
+ currentCode = lastParsableCode;
515
+ totalApplied -= result.applied.length;
516
+ if (pass === 0) {
517
+ firstPassEdits = [];
518
+ }
337
519
  break;
338
520
  }
521
+ lastParsableCode = currentCode;
522
+ if (result.skipped.length === 0) {
523
+ log('fix pass %d: all fixes applied, stopping', pass);
524
+ break;
525
+ }
526
+ // --- Multi-pass path (only when overlapping fixes exist) ---
527
+ log('fix pass %d: %d skipped, re-running rules for next pass', pass, result.skipped.length);
339
528
  const newViolations = await this.#runAllRules(true);
529
+ latestViolations = newViolations;
340
530
  fixes = extractFixes(newViolations);
341
531
  if (fixes.length === 0) {
342
532
  log('fix pass %d: no more fixable violations, stopping', pass);
@@ -347,6 +537,29 @@ export class MLCore {
347
537
  if (reachedMaxPasses) {
348
538
  log('fix: reached maximum number of passes (%d), some fixes may not have been applied', MAX_FIX_PASSES);
349
539
  }
540
+ // Final verification (#3890): compute the violations that remain in the
541
+ // final code. When the loop already re-verified `currentCode`, reuse that
542
+ // result; otherwise re-run rules once (re-parsing first unless the parsed
543
+ // state already corresponds to `currentCode`).
544
+ let finalPassViolations;
545
+ if (totalApplied > 0) {
546
+ if (latestViolations === undefined) {
547
+ if (this.#sourceCode !== currentCode) {
548
+ this.#sourceCode = currentCode;
549
+ this.#parse();
550
+ this.#createDocument();
551
+ }
552
+ if (!(this.#document instanceof ParserError)) {
553
+ latestViolations = await this.#runAllRules(true);
554
+ }
555
+ }
556
+ if (latestViolations !== undefined) {
557
+ const collected = [];
558
+ this.#pushNonFatalParseErrors(collected);
559
+ collected.push(...latestViolations);
560
+ finalPassViolations = collected;
561
+ }
562
+ }
350
563
  return {
351
564
  code: currentCode,
352
565
  summary: {
@@ -355,16 +568,10 @@ export class MLCore {
355
568
  totalSkipped,
356
569
  reachedMaxPasses,
357
570
  firstPassEdits,
571
+ finalPassViolations,
358
572
  },
359
573
  };
360
574
  }
361
- /**
362
- * Executes all configured rules against the current document and collects violations.
363
- * Skips disabled rules and handles virtual rule disable conditions.
364
- *
365
- * @param fix - Whether to execute fix callbacks on violations
366
- * @returns All violations produced by the rule set
367
- */
368
575
  async #runAllRules(fix) {
369
576
  const violations = [];
370
577
  if (this.#document instanceof ParserError) {
@@ -425,16 +632,29 @@ export class MLCore {
425
632
  }
426
633
  }
427
634
  /**
428
- * Extracts namespace prefixes from wildcard disable entries in rules.
429
- * e.g., `{ "a11y/*": false }` yields `["a11y/"]`.
635
+ * Normalizes a single-value `Severity | 'off' | boolean | undefined` option
636
+ * (the shape `severity.deprecation` and `severity.parseError`'s uniform/
637
+ * per-code leaf values share) to a resolved severity or `null` (suppressed).
638
+ *
639
+ * Shared so the same three-way rule — `false`/`'off'` → suppressed, `true` →
640
+ * `'error'`, unset → `defaultWhenUnset` — isn't reimplemented at each call
641
+ * site as channels using this shape are added.
430
642
  */
643
+ function resolveUniformSeverity(cfg, defaultWhenUnset) {
644
+ if (cfg === false || cfg === 'off') {
645
+ return null;
646
+ }
647
+ if (cfg == null) {
648
+ return defaultWhenUnset;
649
+ }
650
+ return cfg === true ? 'error' : cfg;
651
+ }
431
652
  function extractDisabledNamespaces(rules) {
432
653
  return Object.entries(rules)
433
654
  .filter(([key, value]) => key.endsWith('/*') && value === false)
434
655
  .map(([key]) => key.slice(0, -1)); // "a11y/*" → "a11y/"
435
656
  }
436
657
  /**
437
- * Builds a mapping from base rule names to virtual rule names.
438
658
  * Used by nodeRules/childNodeRules to propagate settings (especially `false`)
439
659
  * to virtual rules created by NamedRuleGroups.
440
660
  */
@@ -456,12 +676,6 @@ virtualRules) {
456
676
  }
457
677
  return map;
458
678
  }
459
- /**
460
- * Collects all `FixData` from violations that have a fix callback result.
461
- *
462
- * @param violations - The violations to extract fixes from
463
- * @returns An array of `FixData` objects ready for `applyFixes()`
464
- */
465
679
  function extractFixes(violations) {
466
680
  const fixes = [];
467
681
  for (const v of violations) {
@@ -1,11 +1,3 @@
1
1
  import type { MLElement } from '../node/element.js';
2
2
  import type { ARIAVersion } from '@markuplint/ml-spec';
3
- /**
4
- * Computes the accessible name for an MLElement using the HTML-AAM algorithm.
5
- * Creates an MLCore-specific resolver that bridges MLElement to the AccnameResolver interface.
6
- *
7
- * @param el - The MLElement to compute the accessible name for
8
- * @param version - The ARIA specification version to use for role resolution
9
- * @returns The computed accessible name string, or an empty string on error
10
- */
11
3
  export declare function getAccname(el: MLElement<any, any>, version: ARIAVersion): string;
@@ -1,14 +1,6 @@
1
1
  import { computeAccessibleName, escapeCSS, getComputedRole, EMBEDDED_CONTROL_ROLES, isNativeEmbeddedControl, } from '@markuplint/ml-spec';
2
2
  import { log } from '../../debug.js';
3
3
  const accnameLog = log.extend('accname');
4
- /**
5
- * Computes the accessible name for an MLElement using the HTML-AAM algorithm.
6
- * Creates an MLCore-specific resolver that bridges MLElement to the AccnameResolver interface.
7
- *
8
- * @param el - The MLElement to compute the accessible name for
9
- * @param version - The ARIA specification version to use for role resolution
10
- * @returns The computed accessible name string, or an empty string on error
11
- */
12
4
  export function getAccname(
13
5
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
14
6
  el, version) {
@@ -18,8 +10,13 @@ el, version) {
18
10
  // environment differences (e.g., Deno lacking certain DOM APIs).
19
11
  // A single element's failure must not abort the entire linting process,
20
12
  // so we catch all errors and return an empty name (= "unnamed").
21
- // This is an intentional exception to the Tier 1 re-throw policy see
22
- // docs/architectures/ERROR-HANDLING.md § "accname computation errors".
13
+ // This is an intentional exception to the Tier 1 re-throw policy (see
14
+ // `isFatalError()` in `@markuplint/shared`): Tier-1-shaped errors here
15
+ // can be caused by the runtime environment, not only implementation bugs.
16
+ // The empty-string result is not converted to a violation either — a
17
+ // single element's accname failure must not create noise across
18
+ // unrelated rules, and "unnamed" still drives meaningful a11y rule
19
+ // violations downstream.
23
20
  try {
24
21
  const resolver = createMLCoreResolver(el, version);
25
22
  const result = computeAccessibleName(el, resolver);
@@ -131,7 +131,9 @@ export class MLAttr extends MLNode {
131
131
  this.isDirective = this._astToken.isDirective;
132
132
  this.isDuplicatable = this._astToken.isDuplicatable;
133
133
  }
134
- // IDL attribute resolution (after directivePatterns)
134
+ // IDL attribute resolution (after directivePatterns).
135
+ // Performed in the core, not in each parser: any spec opting in via
136
+ // `acceptedAttrNames` shares the same IDL-to-content-attribute mapping.
135
137
  if (ownElement.ownerMLDocument.specs.acceptedAttrNames && !this.isDirective) {
136
138
  const { contentAttrName, idlPropName } = searchIDLAttribute(this.#potentialName);
137
139
  if (contentAttrName && contentAttrName !== this.#potentialName) {
@@ -9,6 +9,12 @@ import { MLNode } from './node.js';
9
9
  * These nodes correspond to template engine constructs such as conditionals (`if`/`else`),
10
10
  * loops (`each`), and other preprocessor directives that are not part of standard HTML.
11
11
  *
12
+ * Serves as the bridge between template syntax and HTML content model
13
+ * validation: transparency keeps the wrapper invisible to DOM traversal so
14
+ * rules such as `permitted-contents` see the effective HTML children, while
15
+ * `blockBehavior` lets `conditionalChildNodes()` enumerate every possible
16
+ * rendering branch.
17
+ *
12
18
  * @template T - The rule configuration value type
13
19
  * @template O - The rule options type
14
20
  */
@@ -5,6 +5,12 @@ import { MLNode } from './node.js';
5
5
  * These nodes correspond to template engine constructs such as conditionals (`if`/`else`),
6
6
  * loops (`each`), and other preprocessor directives that are not part of standard HTML.
7
7
  *
8
+ * Serves as the bridge between template syntax and HTML content model
9
+ * validation: transparency keeps the wrapper invisible to DOM traversal so
10
+ * rules such as `permitted-contents` see the effective HTML children, while
11
+ * `blockBehavior` lets `conditionalChildNodes()` enumerate every possible
12
+ * rendering branch.
13
+ *
8
14
  * @template T - The rule configuration value type
9
15
  * @template O - The rule options type
10
16
  */
@@ -15,13 +15,4 @@ import type { PlainData, RuleConfigValue } from '@markuplint/ml-config';
15
15
  * @see https://dom.spec.whatwg.org/#idl-index
16
16
  */
17
17
  export type MLChildNode<T extends RuleConfigValue, O extends PlainData = undefined> = MLDocumentType<T, O> | MLCharacterData<T, O> | MLElement<T, O> | MLBlock<T, O>;
18
- /**
19
- * Determines whether the given node is a child node type
20
- * (DocumentType, CDATA, Comment, Text, Element, or preprocessor block).
21
- *
22
- * @template T - The rule configuration value type
23
- * @template O - The rule options type
24
- * @param node - The node to check
25
- * @returns True if the node is one of the child node types
26
- */
27
18
  export declare function isChildNode<T extends RuleConfigValue, O extends PlainData = undefined>(node: MLNode<T, O>): node is MLChildNode<T, O>;
@@ -1,12 +1,3 @@
1
- /**
2
- * Determines whether the given node is a child node type
3
- * (DocumentType, CDATA, Comment, Text, Element, or preprocessor block).
4
- *
5
- * @template T - The rule configuration value type
6
- * @template O - The rule options type
7
- * @param node - The node to check
8
- * @returns True if the node is one of the child node types
9
- */
10
1
  export function isChildNode(
11
2
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
12
3
  node) {