@markuplint/ml-core 5.0.0-alpha.1 → 5.0.0-alpha.3

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 (46) hide show
  1. package/ARCHITECTURE.ja.md +158 -5
  2. package/ARCHITECTURE.md +209 -6
  3. package/CHANGELOG.md +25 -0
  4. package/docs/ml-dom/document.ja.md +7 -14
  5. package/docs/ml-dom/document.md +7 -14
  6. package/docs/ml-dom/element.ja.md +9 -24
  7. package/docs/ml-dom/element.md +9 -24
  8. package/docs/rule-system.ja.md +1 -1
  9. package/docs/rule-system.md +1 -1
  10. package/lib/cursor-offset.d.ts +13 -0
  11. package/lib/cursor-offset.js +34 -0
  12. package/lib/fix-applier.d.ts +32 -0
  13. package/lib/fix-applier.js +75 -0
  14. package/lib/index.d.ts +3 -0
  15. package/lib/index.js +2 -0
  16. package/lib/ml-core.d.ts +53 -8
  17. package/lib/ml-core.js +197 -59
  18. package/lib/ml-dom/helper/get-indent.d.ts +0 -1
  19. package/lib/ml-dom/helper/get-indent.js +5 -18
  20. package/lib/ml-dom/node/attr.d.ts +2 -13
  21. package/lib/ml-dom/node/attr.js +3 -35
  22. package/lib/ml-dom/node/block.js +2 -1
  23. package/lib/ml-dom/node/character-data.d.ts +35 -0
  24. package/lib/ml-dom/node/character-data.js +35 -6
  25. package/lib/ml-dom/node/document.d.ts +2 -27
  26. package/lib/ml-dom/node/document.js +7 -46
  27. package/lib/ml-dom/node/dom-token-list.d.ts +0 -1
  28. package/lib/ml-dom/node/dom-token-list.js +3 -3
  29. package/lib/ml-dom/node/element-close-tag.d.ts +1 -1
  30. package/lib/ml-dom/node/element-close-tag.js +2 -16
  31. package/lib/ml-dom/node/element.d.ts +2 -19
  32. package/lib/ml-dom/node/element.js +3 -65
  33. package/lib/ml-dom/token/token.d.ts +3 -18
  34. package/lib/ml-dom/token/token.js +7 -28
  35. package/lib/ml-rule/index.d.ts +1 -0
  36. package/lib/ml-rule/index.js +1 -0
  37. package/lib/ml-rule/ml-rule-context.d.ts +2 -31
  38. package/lib/ml-rule/ml-rule-context.js +18 -18
  39. package/lib/ml-rule/ml-rule.d.ts +6 -11
  40. package/lib/ml-rule/ml-rule.js +36 -37
  41. package/lib/ml-rule/rule-fixer.d.ts +20 -0
  42. package/lib/ml-rule/rule-fixer.js +38 -0
  43. package/lib/ml-rule/types.d.ts +1 -2
  44. package/lib/test/index.d.ts +1 -10
  45. package/lib/test/index.js +0 -11
  46. package/package.json +12 -12
@@ -8,20 +8,18 @@ HTML/SVG/MathML element node. Extends `MLParentNode` and implements `Element`, `
8
8
 
9
9
  The element has multiple name-related properties that serve different purposes:
10
10
 
11
- | Property | HTML `<DIV>` | SVG `<foreignObject>` | Pretender (`MyButton` → `button`) |
12
- | --------------- | ------------------------------ | --------------------- | --------------------------------- |
13
- | `localName` | `"div"` | `"foreignObject"` | `"button"` |
14
- | `nodeName` | `"DIV"` | `"foreignObject"` | `"BUTTON"` |
15
- | `rawName` | `"DIV"` | `"foreignObject"` | `"MyButton"` |
16
- | `fixedNodeName` | `"DIV"` (or `"div"` after fix) | `"foreignObject"` | `"MyButton"` |
17
- | `tagName` | `"DIV"` | `"foreignObject"` | `"BUTTON"` |
11
+ | Property | HTML `<DIV>` | SVG `<foreignObject>` | Pretender (`MyButton` → `button`) |
12
+ | ----------- | ------------ | --------------------- | --------------------------------- |
13
+ | `localName` | `"div"` | `"foreignObject"` | `"button"` |
14
+ | `nodeName` | `"DIV"` | `"foreignObject"` | `"BUTTON"` |
15
+ | `rawName` | `"DIV"` | `"foreignObject"` | `"MyButton"` |
16
+ | `tagName` | `"DIV"` | `"foreignObject"` | `"BUTTON"` |
18
17
 
19
18
  **Rules:**
20
19
 
21
20
  - **`localName`**: HTML elements → lowercased. Foreign elements or non-`'html'` elementType → as-is. Pretender context → pretender's `localName`. If `tagNameCaseSensitive` is `true` → no lowercasing.
22
21
  - **`nodeName`**: HTML elements → uppercased (DOM convention). Foreign elements or non-`'html'` elementType → as-is from AST. Pretender context → pretender's `nodeName`.
23
22
  - **`rawName`**: Always the original AST `nodeName`, with no normalization and no pretender influence.
24
- - **`fixedNodeName`**: Starts as `rawName`. Updated by `fixNodeName(name)` when a lint fix modifies the tag name.
25
23
  - **`tagName`**: Same as `nodeName` (follows pretender context).
26
24
 
27
25
  ## Element Type Resolution
@@ -98,7 +96,6 @@ For comprehensive documentation on the pretender system's architecture, initiali
98
96
 
99
97
  | Method | Signature | Description |
100
98
  | ------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
101
- | `fixNodeName` | `fixNodeName(name: string): void` | Updates `fixedNodeName` for lint auto-fix of the tag name |
102
99
  | `getAccessibleName` | `getAccessibleName(version: ARIAVersion): string` | Computes the accessible name via `getAccname()` |
103
100
  | `toNormalizeString` | `toNormalizeString(): string` | Returns a normalized representation for comparison (cached). Recursively normalizes children and attributes. |
104
101
  | `nextElementSibling` | `get nextElementSibling: MLElement \| null` | Next sibling element |
@@ -134,7 +131,7 @@ Elements with `isOmitted === true` were implicitly inserted by the parser (e.g.,
134
131
 
135
132
  - Have no corresponding source tokens
136
133
  - Are skipped by `prevToken` (to maintain valid offset chains)
137
- - Return `raw` from `toString(fixed)` (no fix is applied since there's nothing in the source to fix)
134
+ - Return `raw` from `toString()` (there's nothing in the source to represent)
138
135
  - Are flattened by `getChildElementsAndTextNodeWithoutWhitespaces()`
139
136
 
140
137
  ## Close Tag
@@ -143,21 +140,9 @@ Elements with `isOmitted === true` were implicitly inserted by the parser (e.g.,
143
140
  | ---------- | --------------------------- | ------------------------------------------------------------------------------------------------ |
144
141
  | `closeTag` | `MLElementCloseTag \| null` | Paired close tag. `null` for void elements, self-closing elements, or when `endTag === 'never'`. |
145
142
 
146
- ## `toString(fixed?)`
143
+ ## `toString()`
147
144
 
148
- Reconstructs the element's source string with fixes applied.
149
-
150
- - `fixed=false` or pretender/omitted/`#`-prefixed nodeName → return `raw`
151
- - `fixed=true`:
152
- 1. Build a list of replaceable nodes: `[tagOpenChar + fixedNodeName, ...overriddenCommentNodes, ...attributes]`
153
- 2. For each node, splice `node.toString(true)` at the correct offset
154
- 3. Track cumulative offset differences for accurate positioning
155
-
156
- ```
157
- Original: <DIV class="foo" >
158
- Fixed: <div class="foo" >
159
- ^^^^ (fixedNodeName changed from "DIV" to "div")
160
- ```
145
+ Returns the element's raw source string. This method simply returns the original `raw` source text as it appeared in the parsed document. Fixes are no longer applied through DOM node mutation; instead, fix operations produce `TextEdit[]` via `RuleFixer`, and `FixApplier.applyFixes()` applies all edits directly to the source text.
161
146
 
162
147
  ## Other Properties
163
148
 
@@ -189,7 +189,7 @@ constructor(document: MLDocument<T, O>, locale: LocaleSet)
189
189
 
190
190
  ### `provide(): ProvidedContext`
191
191
 
192
- `RuleSeed.verify()` `RuleSeed.fix()` に渡されるコンテキストオブジェクトを返します:
192
+ `RuleSeed.verify()` に渡されるコンテキストオブジェクトを返します。自動修正ロジックは個々の `report()` 呼び出しのインライン `fix` コールバックとして提供されます:
193
193
 
194
194
  ```typescript
195
195
  {
@@ -189,7 +189,7 @@ Creates translator from locale, stores document reference.
189
189
 
190
190
  ### `provide(): ProvidedContext`
191
191
 
192
- Returns the context object passed to `RuleSeed.verify()` and `RuleSeed.fix()`:
192
+ Returns the context object passed to `RuleSeed.verify()`. Auto-fix logic is provided as an inline `fix` callback on individual `report()` calls:
193
193
 
194
194
  ```typescript
195
195
  {
@@ -0,0 +1,13 @@
1
+ import type { TextEdit } from '@markuplint/ml-config';
2
+ /**
3
+ * Computes the new cursor offset after text edits have been applied.
4
+ *
5
+ * For each edit before the cursor: delta += text.length - (end - start).
6
+ * If the cursor falls inside a replaced range [start, end), it is placed
7
+ * at start + text.length (immediately after the replacement).
8
+ *
9
+ * @param appliedEdits - Applied edits sorted by range[0] ascending
10
+ * @param cursorOffset - Original 0-based cursor offset
11
+ * @returns New cursor offset in the fixed code
12
+ */
13
+ export declare function computeCursorOffset(appliedEdits: readonly TextEdit[], cursorOffset: number): number;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Computes the new cursor offset after text edits have been applied.
3
+ *
4
+ * For each edit before the cursor: delta += text.length - (end - start).
5
+ * If the cursor falls inside a replaced range [start, end), it is placed
6
+ * at start + text.length (immediately after the replacement).
7
+ *
8
+ * @param appliedEdits - Applied edits sorted by range[0] ascending
9
+ * @param cursorOffset - Original 0-based cursor offset
10
+ * @returns New cursor offset in the fixed code
11
+ */
12
+ export function computeCursorOffset(appliedEdits, cursorOffset) {
13
+ let newOffset = cursorOffset;
14
+ for (const edit of appliedEdits) {
15
+ const [start, end] = edit.range;
16
+ const delta = edit.text.length - (end - start);
17
+ if (start > cursorOffset) {
18
+ // Edit is after cursor — no effect
19
+ break;
20
+ }
21
+ if (end <= cursorOffset) {
22
+ // Edit is entirely before cursor — shift by delta.
23
+ // Range is half-open [start, end), so cursor at `end` is outside the edit.
24
+ newOffset += delta;
25
+ }
26
+ else {
27
+ // Cursor falls inside the replaced range [start, end)
28
+ newOffset = start + edit.text.length;
29
+ break;
30
+ }
31
+ }
32
+ // Defensive guard: should never go negative with well-formed, non-overlapping edits
33
+ return Math.max(0, newOffset);
34
+ }
@@ -0,0 +1,32 @@
1
+ import type { FixData, TextEdit } from '@markuplint/ml-config';
2
+ /**
3
+ * The result of applying fixes to source code.
4
+ */
5
+ export type FixResult = {
6
+ /** The resulting source code after applying fixes */
7
+ readonly output: string;
8
+ /** Fixes that were successfully applied */
9
+ readonly applied: readonly FixData[];
10
+ /** Fixes that were skipped due to overlapping ranges */
11
+ readonly skipped: readonly FixData[];
12
+ /** Flat list of successfully applied edits, sorted by range[0] ascending */
13
+ readonly appliedEdits: readonly TextEdit[];
14
+ };
15
+ /**
16
+ * Applies a set of text edits to the source code.
17
+ *
18
+ * **Constraint**: Edits within a single FixData must not overlap each other,
19
+ * and should ideally be ordered by range[0]. Inter-FixData overlap is handled
20
+ * by the skip mechanism, but intra-FixData overlap leads to undefined behavior.
21
+ *
22
+ * Algorithm (modeled after ESLint's SourceCodeFixer):
23
+ * 1. Flatten all FixData.edits into individual edits, each tagged with its parent FixData
24
+ * 2. Sort by range[0] ascending (ties broken by range[1] descending)
25
+ * 3. Apply edits sequentially; skip any edit whose range overlaps a previously applied edit
26
+ * 4. Classify each FixData as applied (all edits applied) or skipped (any edit skipped)
27
+ *
28
+ * @param sourceCode - The original source code
29
+ * @param fixes - The fix data to apply
30
+ * @returns The result containing the fixed code and applied/skipped classification
31
+ */
32
+ export declare function applyFixes(sourceCode: string, fixes: readonly FixData[]): FixResult;
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Applies a set of text edits to the source code.
3
+ *
4
+ * **Constraint**: Edits within a single FixData must not overlap each other,
5
+ * and should ideally be ordered by range[0]. Inter-FixData overlap is handled
6
+ * by the skip mechanism, but intra-FixData overlap leads to undefined behavior.
7
+ *
8
+ * Algorithm (modeled after ESLint's SourceCodeFixer):
9
+ * 1. Flatten all FixData.edits into individual edits, each tagged with its parent FixData
10
+ * 2. Sort by range[0] ascending (ties broken by range[1] descending)
11
+ * 3. Apply edits sequentially; skip any edit whose range overlaps a previously applied edit
12
+ * 4. Classify each FixData as applied (all edits applied) or skipped (any edit skipped)
13
+ *
14
+ * @param sourceCode - The original source code
15
+ * @param fixes - The fix data to apply
16
+ * @returns The result containing the fixed code and applied/skipped classification
17
+ */
18
+ export function applyFixes(sourceCode, fixes) {
19
+ if (fixes.length === 0) {
20
+ return { output: sourceCode, applied: [], skipped: [], appliedEdits: [] };
21
+ }
22
+ // Tag each edit with its parent FixData index
23
+ const taggedEdits = [];
24
+ for (const [i, fix] of fixes.entries()) {
25
+ for (const edit of fix.edits) {
26
+ taggedEdits.push({ edit, fixIndex: i });
27
+ }
28
+ }
29
+ // Sort: range[0] ascending, then range[1] descending (so larger ranges come first at the same start)
30
+ taggedEdits.sort((a, b) => {
31
+ const startDiff = a.edit.range[0] - b.edit.range[0];
32
+ // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
33
+ return startDiff || b.edit.range[1] - a.edit.range[1];
34
+ });
35
+ // Track which FixData indices had at least one skipped edit
36
+ const skippedFixIndices = new Set();
37
+ const appliedEdits = [];
38
+ let lastAppliedEnd = -1;
39
+ const parts = [];
40
+ let cursor = 0;
41
+ for (const { edit, fixIndex } of taggedEdits) {
42
+ const [start, end] = edit.range;
43
+ // Overlap check: if this edit starts before the end of the last applied edit, skip it.
44
+ // Also skip if a sibling edit from the same FixData was already skipped —
45
+ // edits within a single FixData are atomic (all-or-nothing).
46
+ if (start < lastAppliedEnd || skippedFixIndices.has(fixIndex)) {
47
+ skippedFixIndices.add(fixIndex);
48
+ continue;
49
+ }
50
+ // Append the source text between the last edit and this one
51
+ parts.push(sourceCode.slice(cursor, start), edit.text);
52
+ appliedEdits.push(edit);
53
+ cursor = end;
54
+ lastAppliedEnd = end;
55
+ }
56
+ // Append remaining source text
57
+ parts.push(sourceCode.slice(cursor));
58
+ // Classify FixData as applied or skipped
59
+ const applied = [];
60
+ const skipped = [];
61
+ for (const [i, fix] of fixes.entries()) {
62
+ if (skippedFixIndices.has(i)) {
63
+ skipped.push(fix);
64
+ }
65
+ else {
66
+ applied.push(fix);
67
+ }
68
+ }
69
+ return {
70
+ output: parts.join(''),
71
+ applied,
72
+ skipped,
73
+ appliedEdits,
74
+ };
75
+ }
package/lib/index.d.ts CHANGED
@@ -2,6 +2,9 @@ export type { RuleInfo, RuleConfig, RuleConfigValue } from '@markuplint/ml-confi
2
2
  export { ariaSpecs, contentModelCategoryToTagNames, getAttrSpecs, getComputedRole, getImplicitRole, getPermittedRoles, getRoleSpec, getSpec, resolveNamespace, } from '@markuplint/ml-spec';
3
3
  export { Ruleset } from './ruleset/index.js';
4
4
  export { enableDebug } from './debug.js';
5
+ export { computeCursorOffset } from './cursor-offset.js';
6
+ export { applyFixes } from './fix-applier.js';
7
+ export type { FixResult } from './fix-applier.js';
5
8
  export { getIndent } from './ml-dom/helper/get-indent.js';
6
9
  export * from './convert-ruleset.js';
7
10
  export * from './ml-core.js';
package/lib/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { ariaSpecs, contentModelCategoryToTagNames, getAttrSpecs, getComputedRole, getImplicitRole, getPermittedRoles, getRoleSpec, getSpec, resolveNamespace, } from '@markuplint/ml-spec';
2
2
  export { Ruleset } from './ruleset/index.js';
3
3
  export { enableDebug } from './debug.js';
4
+ export { computeCursorOffset } from './cursor-offset.js';
5
+ export { applyFixes } from './fix-applier.js';
4
6
  export { getIndent } from './ml-dom/helper/get-indent.js';
5
7
  export * from './convert-ruleset.js';
6
8
  export * from './ml-core.js';
package/lib/ml-core.d.ts CHANGED
@@ -1,7 +1,45 @@
1
1
  import type { MLFabric } from './types.js';
2
- import type { PlainData, RuleConfigValue, Violation } from '@markuplint/ml-config';
2
+ import type { PlainData, RuleConfigValue, TextEdit, Violation } from '@markuplint/ml-config';
3
3
  import { ParserError } from '@markuplint/parser-utils';
4
4
  import { Document } from './ml-dom/index.js';
5
+ /**
6
+ * Summary of the multi-pass fix process.
7
+ */
8
+ export type FixSummary = {
9
+ /** Number of fix passes executed (i.e., the number of times applyFixes was called) */
10
+ readonly passCount: number;
11
+ /** Total fixes applied across all passes */
12
+ readonly totalApplied: number;
13
+ /** Total fixes skipped (overlap) across all passes */
14
+ readonly totalSkipped: number;
15
+ /** Whether the maximum pass count was reached */
16
+ readonly reachedMaxPasses: boolean;
17
+ /**
18
+ * Applied edits from the FIRST pass only.
19
+ * These reference the original source code offsets, making them suitable
20
+ * for cursor offset computation via {@link computeCursorOffset}.
21
+ * Note: `firstPassEdits.length` may differ from `totalApplied` when
22
+ * multiple passes are executed.
23
+ */
24
+ readonly firstPassEdits: readonly TextEdit[];
25
+ };
26
+ /**
27
+ * Options for {@link MLCore.verify}.
28
+ */
29
+ export type VerifyOptions = {
30
+ readonly fix?: boolean;
31
+ };
32
+ /**
33
+ * The result of running {@link MLCore.verify}.
34
+ */
35
+ export type VerifyResult = {
36
+ /** Violations found during verification */
37
+ readonly violations: readonly Violation[];
38
+ /** The source code after applying fixes. `undefined` when fix is not enabled. */
39
+ readonly fixedCode: string | undefined;
40
+ /** Fix process summary. Present when fix=true. */
41
+ readonly fixSummary?: FixSummary;
42
+ };
5
43
  /**
6
44
  * Parameters for constructing an {@link MLCore} instance.
7
45
  * Extends {@link MLFabric} with the source code, filename, and debug flag.
@@ -26,7 +64,7 @@ export declare class MLCore {
26
64
  /**
27
65
  * The parsed document, or a {@link ParserError} if parsing failed.
28
66
  */
29
- get document(): ParserError | Document<RuleConfigValue, PlainData>;
67
+ get document(): Document<RuleConfigValue, PlainData> | ParserError;
30
68
  /**
31
69
  * Replaces the source code and re-parses the document.
32
70
  *
@@ -46,11 +84,18 @@ export declare class MLCore {
46
84
  * If the document failed to parse, a single parse-error violation is returned
47
85
  * (unless parse errors are suppressed via severity options).
48
86
  *
49
- * @param fix - Whether to attempt auto-fixing violations
50
- * @returns An array of violations found during verification
87
+ * When `fix` is true, fix callbacks are executed and the resulting TextEdits
88
+ * are applied via a multi-pass loop to produce `fixedCode`.
89
+ *
90
+ * **Important**: `violations` reflects the *first* pass only, while `fixedCode`
91
+ * may be the result of multiple fix passes. This means some violations in the
92
+ * array may already be resolved in `fixedCode`, and new violations introduced
93
+ * during later passes are not included in the array. Callers needing an accurate
94
+ * violation list for the fixed code should re-verify the output.
95
+ *
96
+ * @param fixOrOptions - Whether to attempt auto-fixing violations, or an options object
97
+ * @returns Violations from the initial analysis and the (possibly fixed) source code
51
98
  */
52
- verify(fix?: boolean): Promise<Violation[]>;
53
- private _createDocument;
54
- private _createParseError;
55
- private _parse;
99
+ verify(fix?: boolean): Promise<VerifyResult>;
100
+ verify(options?: VerifyOptions): Promise<VerifyResult>;
56
101
  }