@markuplint/ml-config 4.8.15 → 5.0.0-alpha.1

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.
@@ -87,8 +87,8 @@ type RuleConfig<T, O> = {
87
87
 
88
88
  ### NodeRule / ChildNodeRule
89
89
 
90
- - `NodeRule` -- CSS セレクタ、正規表現セレクタ、ARIA ロール、カテゴリ、obsolete フラグで対象ノードを限定し、ルール設定をオーバーライド
91
- - `ChildNodeRule` -- `NodeRule` と類似だが子ノードを対象とする。`inheritance` フラグで子孫への継承を制御
90
+ - `NodeRule` -- CSS セレクタ、正規表現セレクタ、ARIA ロール、カテゴリ、obsolete フラグで対象ノードを限定し、ルール設定をオーバーライド。オプションの `name`(`/` を含む必要あり)で named nodeRule として仮想ルールに展開可能。オプションの `specConformance`(`'normative'` または `'non-normative'`)を下流ツールやレポート向けのメタデータとして設定
91
+ - `ChildNodeRule` -- `NodeRule` と類似だが子ノードを対象とする。`inheritance` フラグで子孫への継承を制御。`name` と `specConformance`(メタデータ)による named nodeRule 展開もサポート
92
92
 
93
93
  ### Pretender 型
94
94
 
@@ -124,21 +124,21 @@ flowchart TD
124
124
 
125
125
  ### プロパティ別マージ戦略テーブル
126
126
 
127
- | プロパティ | 戦略 | ヘルパー関数 | 詳細 |
128
- | ---------------- | ----------------------- | ---------------------------------------------------- | --------------------------------------------- |
129
- | `plugins` | 結合+重複排除+正規化 | `concatArray(uniquely=true, comparePropName='name')` | 同名プラグインは settings を deep merge |
130
- | `parser` | オブジェクト deep merge | `mergeObject()` | 右辺優先、deepmerge ライブラリ使用 |
131
- | `parserOptions` | オブジェクト deep merge | `mergeObject()` | 同上 |
132
- | `specs` | オブジェクト deep merge | `mergeObject()` | 同上 |
133
- | `excludeFiles` | 結合+重複排除 | `concatArray(uniquely=true)` | 単純な値の重複排除 |
134
- | `severity` | オブジェクト deep merge | `mergeObject()` | parser と同様 |
135
- | `pretenders` | 形式変換+deep merge | `mergePretenders()` | 配列を PretenderDetails に変換後にマージ |
136
- | `rules` | ルール別マージ | `mergeRules()` → `mergeRule()` | **最も複雑 -- 次節で詳述** |
137
- | `nodeRules` | 結合(重複排除なし) | `concatArray()` | 両配列を単純連結 |
138
- | `childNodeRules` | 結合(重複排除なし) | `concatArray()` | nodeRules と同様 |
139
- | `overrideMode` | 右辺優先 | `b.overrideMode ?? a.overrideMode` | 単純な優先順位 |
140
- | `overrides` | キー別再帰マージ | `mergeOverrides()` | 各キーに対して `mergeConfig()` を再帰呼び出し |
141
- | `extends` | 結合→削除 | `concatArray()` | マージ後に結果から削除 |
127
+ | プロパティ | 戦略 | ヘルパー関数 | 詳細 |
128
+ | ---------------- | -------------------------- | ---------------------------------------------------- | --------------------------------------------- |
129
+ | `plugins` | 結合+重複排除+正規化 | `concatArray(uniquely=true, comparePropName='name')` | 同名プラグインは settings を shallow merge |
130
+ | `parser` | オブジェクト shallow merge | `mergeObject()` | `{...a, ...b}` で右辺優先 |
131
+ | `parserOptions` | オブジェクト shallow merge | `mergeObject()` | 同上 |
132
+ | `specs` | オブジェクト shallow merge | `mergeObject()` | 同上 |
133
+ | `excludeFiles` | 結合+重複排除 | `concatArray(uniquely=true)` | 単純な値の重複排除 |
134
+ | `severity` | オブジェクト shallow merge | `mergeObject()` | parser と同様 |
135
+ | `pretenders` | セマンティックマージ | `mergePretenders()` | files/imports: 上書き、data: 追加 |
136
+ | `rules` | ルール別マージ | `mergeRules()` → `mergeRule()` | **最も複雑 -- 次節で詳述** |
137
+ | `nodeRules` | 結合+名前で重複排除 | `concatArray(uniquely=true, comparePropName='name')` | 名前付きエントリは重複排除、無名は追加 |
138
+ | `childNodeRules` | 結合+名前で重複排除 | `concatArray(uniquely=true, comparePropName='name')` | nodeRules と同様 |
139
+ | `overrideMode` | 右辺優先 | `b.overrideMode ?? a.overrideMode` | 単純な優先順位 |
140
+ | `overrides` | キー別再帰マージ | `mergeOverrides()` | 各キーに対して `mergeConfig()` を再帰呼び出し |
141
+ | `extends` | 結合→削除 | `concatArray()` | マージ後に結果から削除 |
142
142
 
143
143
  ### mergeRule() -- ルールマージの詳細
144
144
 
@@ -146,7 +146,7 @@ flowchart TD
146
146
  mergeRule(a: Nullable<AnyRule>, b: AnyRule): AnyRule
147
147
  ```
148
148
 
149
- 最も複雑なマージロジックを処理する関数です。両方の入力はまず `optimizeRule()` で正規化されます(非推奨の `option` から `options` への移行を含む)。
149
+ 最も複雑なマージロジックを処理する関数です。両方の入力はまず `optimizeRule()` で正規化されます。
150
150
 
151
151
  ```mermaid
152
152
  flowchart TD
@@ -159,18 +159,16 @@ flowchart TD
159
159
  ChkBUndef -->|Yes| RetA["return a"]
160
160
  ChkBUndef -->|No| ChkBVal{"b は Value?\n(primitive/null/array)"}
161
161
  ChkBVal -->|Yes| ChkAVal{"a は Value?"}
162
- ChkAVal -->|Yes| ChkBothArr{"両方とも配列?"}
163
- ChkBothArr -->|Yes| RetConcat["return [...a, ...b]\n(連結)"]
164
- ChkBothArr -->|No| RetBVal["return b\n(右辺優先)"]
165
- ChkAVal -->|No| MergeValObj["a の severity/reason を保持\nvalue を置換\n(配列は連結)"]
162
+ ChkAVal -->|Yes| RetBVal["return b\n(右辺優先、\n配列も上書き)"]
163
+ ChkAVal -->|No| MergeValObj["a の severity/reason を保持\nvalue を b で上書き"]
166
164
  ChkBVal -->|No| MergeObj["severity: b ?? a\nvalue: b ?? a\noptions: mergeObject(a, b)\nreason: b ?? a"]
167
165
  ```
168
166
 
169
167
  **重要な設計判断:**
170
168
 
171
169
  1. **`false` は絶対無効化** -- override が `false`(または `{value: false}`)なら、base が何であっても結果は常に `false`
172
- 2. **配列値は連結** -- `["a","b"]` + `["c","d"]` は `["a","b","c","d"]` になり、extends チェーンでルールを段階的に追加可能
173
- 3. **options は deep merge** -- severity、value、reason は右辺優先だが、options のみ `mergeObject()`(deepmerge ライブラリによる deep merge)を使用
170
+ 2. **配列値は上書き** -- `["a","b"]` + `["c","d"]` は `["c","d"]` になる(右辺優先)。ESLint や Biome と一貫した動作
171
+ 3. **options は shallow merge** -- severity、value、reason は右辺優先だが、options のみ `mergeObject()`(`{...a, ...b}` による shallow merge)を使用
174
172
 
175
173
  ### ヘルパー関数
176
174
 
@@ -180,12 +178,12 @@ flowchart TD
180
178
 
181
179
  - `uniquely=false` -- 単純な連結、重複排除なし
182
180
  - `uniquely=true`、`comparePropName` なし -- 完全一致で重複排除
183
- - `uniquely=true`、`comparePropName` あり -- 指定プロパティ名で重複排除。同名オブジェクトは `mergeObject()` でマージ(例: プラグインの settings)
181
+ - `uniquely=true`、`comparePropName` あり -- 指定プロパティ名で重複排除。同名オブジェクトはオブジェクトスプレッドで shallow merge(例: プラグインの settings)
184
182
  - 空の結果には `undefined` を返す
185
183
 
186
184
  #### mergeObject(a, b)
187
185
 
188
- `deepmerge` ライブラリを使用した再帰的な deep merge。右辺の値が優先。結果から undefined プロパティを除去。
186
+ `{...a, ...b}` による shallow merge。トップレベルで右辺の値が優先。結果から undefined プロパティを除去。
189
187
 
190
188
  #### mergeOverrides(a, b)
191
189
 
@@ -193,7 +191,7 @@ flowchart TD
193
191
 
194
192
  #### mergePretenders(a, b)
195
193
 
196
- 配列形式の pretenders を正規化形式 `PretenderDetails`(`{data: [...]}`)に変換してから `mergeObject()` deep merge。
194
+ 配列形式の pretenders を正規化形式 `PretenderDetails`(`{data: [...]}`)に変換してからセマンティックマージ: `files`/`imports` は上書き(右辺優先)、`data` は追加(連結)。
197
195
 
198
196
  ## テンプレートレンダリングシステム
199
197
 
@@ -217,11 +215,11 @@ Mustache テンプレート文字列を提供されたデータでレンダリ
217
215
 
218
216
  ## ユーティリティ関数
219
217
 
220
- | 関数 | 目的 |
221
- | --------------------- | -------------------------------------------------------------------------------------------------------- |
222
- | `cleanOptions()` | 非推奨の `option` フィールドを `options` に正規化し、標準フィールドを抽出して undefined プロパティを除去 |
223
- | `isRuleConfigValue()` | 型ガード: プリミティブ、`null`、配列(= `RuleConfig` オブジェクトではない)に対して `true` を返す |
224
- | `deleteUndefProp()` | プレーンオブジェクトから `undefined` 値のプロパティをすべて in-place で削除 |
218
+ | 関数 | 目的 |
219
+ | --------------------- | ------------------------------------------------------------------------------------------------- |
220
+ | `cleanOptions()` | 標準フィールド(`severity`、`value`、`options`、`reason`)を抽出して undefined プロパティを除去 |
221
+ | `isRuleConfigValue()` | 型ガード: プリミティブ、`null`、配列(= `RuleConfig` オブジェクトではない)に対して `true` を返す |
222
+ | `deleteUndefProp()` | プレーンオブジェクトから `undefined` 値のプロパティをすべて in-place で削除 |
225
223
 
226
224
  ## 主要ソースファイル
227
225
 
@@ -239,7 +237,6 @@ Mustache テンプレート文字列を提供されたデータでレンダリ
239
237
  | `@markuplint/ml-ast` | `ParserOptions` 型(型のみ) |
240
238
  | `@markuplint/selector` | `RegexSelector` 型(再エクスポート) |
241
239
  | `@markuplint/shared` | `Nullable` ユーティリティ型 |
242
- | `deepmerge` | `mergeObject()` の deep merge 実装 |
243
240
  | `is-plain-object` | `deleteUndefProp()` でのプレーンオブジェクト判定 |
244
241
  | `mustache` | `provideValue()` のテンプレートレンダリングエンジン |
245
242
  | `type-fest` | `Writable` ユーティリティ型 |
@@ -277,6 +274,41 @@ flowchart LR
277
274
  - **`@markuplint/ml-core`** -- マージ済みの `OptimizedConfig` を受け取り、パース済みドキュメントにルールを適用
278
275
  - **`@markuplint/rules`** -- `Rule<T,O>` と `RuleConfig<T,O>` 型を使用してルール実装を定義
279
276
 
277
+ ## 設計判断
278
+
279
+ ### Flat Config を採用しない理由
280
+
281
+ ESLint の Flat Config アプローチを評価した結果、markuplint には不適と判断しました:
282
+
283
+ - **ESLint 自体が 2025年3月に Flat Config へ `extends` を再追加** -- 純粋な Flat アプローチは JavaScript ツールでも不十分だった
284
+ - **markuplint は JSON ベース** -- Flat Config は JavaScript を前提とする。HTML/マークアップ開発者(主要ユーザー層)にとって、JSON のスキーマ検証と言語非依存性は大きなメリット
285
+ - **markuplint の `nodeRules`/`childNodeRules` は CSS セレクタベース** -- Flat Config のファイルパターンモデルに対応する仕組みがない
286
+ - **ESLint v9 への移行はコミュニティに大きな痛みを与えた** -- 段階的な移行には自動化ツールと数年のエコシステム適応が必要だった
287
+
288
+ **結論:** JSON ベースの `extends` マージ戦略の改善が markuplint にとって最適なアプローチ。
289
+
290
+ ### マージ戦略の原則
291
+
292
+ 配列の扱いには論理的な区別があります:
293
+
294
+ | 配列の種類 | 例 | マージ動作 | 根拠 |
295
+ | ------------------------------ | -------------------------------------- | ---------- | ---------------------- |
296
+ | **トップレベルのコレクション** | `plugins`, `excludeFiles`, `nodeRules` | 累積 | 独立したアイテムの集合 |
297
+ | **ルール値** | `["allowed-tag-1", "allowed-tag-2"]` | 上書き | 1つのルールの設定値 |
298
+
299
+ これは ESLint と Biome の動作と一貫しており、ルール値はより具体的な設定で常に上書きされます。
300
+
301
+ ### Deep Merge ではなく Shallow Merge
302
+
303
+ | ツール | ルール options のマージ | 根拠 |
304
+ | ---------- | ----------------------------- | -------------------------------------------------------------------------- |
305
+ | ESLint | 完全リプレース | 最もシンプルだが驚きがある |
306
+ | Biome | Deep merge(Merge trait経由) | 完全な柔軟性、高い複雑性 |
307
+ | markuplint | **Shallow merge** | 中間地点: トップレベルのキーはマージ、ネストされたオブジェクトはリプレース |
308
+
309
+ `deepmerge` ライブラリを削除し、シンプルなオブジェクトスプレッド(`{...a, ...b}`)に置き換えました。markuplint の設定でマージされるすべてのオブジェクト(parser、specs、parserOptions、severity、プラグイン設定、ルールオプション)はフラットなキーバリューマップであるため、これで十分です。
310
+
280
311
  ## ドキュメントマップ
281
312
 
313
+ - [マイグレーションガイド](../../docs/migration/v4-v5/config.ja.md) -- メジャーバージョン間の破壊的変更
282
314
  - [メンテナンスガイド](docs/maintenance.ja.md) -- コマンド、レシピ、トラブルシューティング
package/ARCHITECTURE.md CHANGED
@@ -87,8 +87,8 @@ type RuleConfig<T, O> = {
87
87
 
88
88
  ### NodeRule / ChildNodeRule
89
89
 
90
- - `NodeRule` -- Targets specific nodes by CSS selector, regex selector, ARIA roles, categories, or obsolete flag, then overrides their rule settings
91
- - `ChildNodeRule` -- Similar to `NodeRule` but targets child nodes; includes an `inheritance` flag to control whether overrides propagate to descendants
90
+ - `NodeRule` -- Targets specific nodes by CSS selector, regex selector, ARIA roles, categories, or obsolete flag, then overrides their rule settings. Supports an optional `name` (must contain `/`) for named nodeRule expansion into virtual rules, and an optional `specConformance` (`'normative'` or `'non-normative'`) as metadata for downstream tools and reporting
91
+ - `ChildNodeRule` -- Similar to `NodeRule` but targets child nodes; includes an `inheritance` flag to control whether overrides propagate to descendants. Also supports `name` and `specConformance` (metadata) for named nodeRule expansion
92
92
 
93
93
  ### Pretender Types
94
94
 
@@ -124,21 +124,21 @@ flowchart TD
124
124
 
125
125
  ### Per-Property Merge Strategy Table
126
126
 
127
- | Property | Strategy | Helper Function | Details |
128
- | ---------------- | -------------------------------- | ---------------------------------------------------- | ------------------------------------------------- |
129
- | `plugins` | Concat + deduplicate + normalize | `concatArray(uniquely=true, comparePropName='name')` | Same-name plugins have their settings deep-merged |
130
- | `parser` | Object deep merge | `mergeObject()` | Right-side wins, uses deepmerge library |
131
- | `parserOptions` | Object deep merge | `mergeObject()` | Same as above |
132
- | `specs` | Object deep merge | `mergeObject()` | Same as above |
133
- | `excludeFiles` | Concat + deduplicate | `concatArray(uniquely=true)` | Simple value deduplication |
134
- | `severity` | Object deep merge | `mergeObject()` | Same as parser |
135
- | `pretenders` | Format conversion + deep merge | `mergePretenders()` | Array converted to PretenderDetails, then merged |
136
- | `rules` | Per-rule merge | `mergeRules()` then `mergeRule()` | **Most complex -- see next section** |
137
- | `nodeRules` | Concat (no deduplicate) | `concatArray()` | Both arrays simply concatenated |
138
- | `childNodeRules` | Concat (no deduplicate) | `concatArray()` | Same as nodeRules |
139
- | `overrideMode` | Right-side wins | `b.overrideMode ?? a.overrideMode` | Simple precedence |
140
- | `overrides` | Per-key recursive merge | `mergeOverrides()` | Calls `mergeConfig()` recursively for each key |
141
- | `extends` | Concat then delete | `concatArray()` | Removed from result after merge |
127
+ | Property | Strategy | Helper Function | Details |
128
+ | ---------------- | -------------------------------- | ---------------------------------------------------- | ---------------------------------------------------- |
129
+ | `plugins` | Concat + deduplicate + normalize | `concatArray(uniquely=true, comparePropName='name')` | Same-name plugins have their settings shallow-merged |
130
+ | `parser` | Object shallow merge | `mergeObject()` | Right-side wins via `{...a, ...b}` |
131
+ | `parserOptions` | Object shallow merge | `mergeObject()` | Same as above |
132
+ | `specs` | Object shallow merge | `mergeObject()` | Same as above |
133
+ | `excludeFiles` | Concat + deduplicate | `concatArray(uniquely=true)` | Simple value deduplication |
134
+ | `severity` | Object shallow merge | `mergeObject()` | Same as parser |
135
+ | `pretenders` | Semantic merge | `mergePretenders()` | files/imports: override, data: append |
136
+ | `rules` | Per-rule merge | `mergeRules()` then `mergeRule()` | **Most complex -- see next section** |
137
+ | `nodeRules` | Concat + deduplicate by name | `concatArray(uniquely=true, comparePropName='name')` | Named entries deduplicated; unnamed entries appended |
138
+ | `childNodeRules` | Concat + deduplicate by name | `concatArray(uniquely=true, comparePropName='name')` | Same as nodeRules |
139
+ | `overrideMode` | Right-side wins | `b.overrideMode ?? a.overrideMode` | Simple precedence |
140
+ | `overrides` | Per-key recursive merge | `mergeOverrides()` | Calls `mergeConfig()` recursively for each key |
141
+ | `extends` | Concat then delete | `concatArray()` | Removed from result after merge |
142
142
 
143
143
  ### mergeRule() -- Rule Merge Details
144
144
 
@@ -146,7 +146,7 @@ flowchart TD
146
146
  mergeRule(a: Nullable<AnyRule>, b: AnyRule): AnyRule
147
147
  ```
148
148
 
149
- This function handles the most complex merge logic. Both inputs are first normalized via `optimizeRule()` (which handles the deprecated `option` to `options` migration).
149
+ This function handles the most complex merge logic. Both inputs are first normalized via `optimizeRule()`.
150
150
 
151
151
  ```mermaid
152
152
  flowchart TD
@@ -159,18 +159,16 @@ flowchart TD
159
159
  ChkBUndef -->|Yes| RetA["return a"]
160
160
  ChkBUndef -->|No| ChkBVal{"b is Value?\n(primitive/null/array)"}
161
161
  ChkBVal -->|Yes| ChkAVal{"a is Value?"}
162
- ChkAVal -->|Yes| ChkBothArr{"Both arrays?"}
163
- ChkBothArr -->|Yes| RetConcat["return [...a, ...b]\n(concatenate)"]
164
- ChkBothArr -->|No| RetBVal["return b\n(right-side wins)"]
165
- ChkAVal -->|No| MergeValObj["Keep a's severity/reason\nReplace value\n(arrays concatenated)"]
162
+ ChkAVal -->|Yes| RetBVal["return b\n(right-side wins,\narrays override)"]
163
+ ChkAVal -->|No| MergeValObj["Keep a's severity/reason\nOverride value with b"]
166
164
  ChkBVal -->|No| MergeObj["severity: b ?? a\nvalue: b ?? a\noptions: mergeObject(a, b)\nreason: b ?? a"]
167
165
  ```
168
166
 
169
167
  **Key Design Decisions:**
170
168
 
171
169
  1. **`false` is absolute disable** -- If the override is `false` (or `{value: false}`), the result is always `false`, regardless of what the base config says
172
- 2. **Array values are concatenated** -- `["a","b"]` + `["c","d"]` results in `["a","b","c","d"]`, enabling incremental rule additions across extends chains
173
- 3. **options uses deep merge** -- While severity, value, and reason use right-side-wins precedence, options alone uses `mergeObject()` (deep merge via deepmerge library)
170
+ 2. **Array values override** -- `["a","b"]` + `["c","d"]` results in `["c","d"]` (right-side wins), consistent with ESLint and Biome behavior
171
+ 3. **options uses shallow merge** -- While severity, value, and reason use right-side-wins precedence, options uses `mergeObject()` (shallow merge via `{...a, ...b}`)
174
172
 
175
173
  ### Helper Functions
176
174
 
@@ -180,12 +178,12 @@ Concatenates two arrays with optional deduplication:
180
178
 
181
179
  - `uniquely=false` -- Simple concatenation, no deduplication
182
180
  - `uniquely=true`, no `comparePropName` -- Exact-match deduplication
183
- - `uniquely=true`, with `comparePropName` -- Deduplicates by the specified property name; when two objects share the same name, they are merged via `mergeObject()` (e.g., plugin settings)
181
+ - `uniquely=true`, with `comparePropName` -- Deduplicates by the specified property name; when two objects share the same name, they are shallow-merged via object spread (e.g., plugin settings)
184
182
  - Returns `undefined` for empty results
185
183
 
186
184
  #### mergeObject(a, b)
187
185
 
188
- Deep merges two objects using the `deepmerge` library. Right-side values take precedence. Removes undefined properties from the result.
186
+ Shallow merges two objects via `{...a, ...b}`. Right-side values take precedence at the top level. Removes undefined properties from the result.
189
187
 
190
188
  #### mergeOverrides(a, b)
191
189
 
@@ -193,7 +191,7 @@ Collects the union of all keys from both override records. For each key, calls `
193
191
 
194
192
  #### mergePretenders(a, b)
195
193
 
196
- Converts array-form pretenders to the normalized `PretenderDetails` form (`{data: [...]}`) before deep merging with `mergeObject()`.
194
+ Converts array-form pretenders to the normalized `PretenderDetails` form (`{data: [...]}`) then applies semantic merge: `files`/`imports` are overridden (right-side wins), `data` is appended (concatenated).
197
195
 
198
196
  ## Template Rendering System
199
197
 
@@ -217,11 +215,11 @@ This function is used by `nodeRules` and `childNodeRules` with `regexSelector`,
217
215
 
218
216
  ## Utility Functions
219
217
 
220
- | Function | Purpose |
221
- | --------------------- | --------------------------------------------------------------------------------------------------------- |
222
- | `cleanOptions()` | Normalizes deprecated `option` field to `options`, extracts standard fields, removes undefined properties |
223
- | `isRuleConfigValue()` | Type guard: returns `true` for primitives, `null`, and arrays (i.e., not a `RuleConfig` object) |
224
- | `deleteUndefProp()` | Removes all properties with `undefined` values from a plain object in-place |
218
+ | Function | Purpose |
219
+ | --------------------- | ------------------------------------------------------------------------------------------------- |
220
+ | `cleanOptions()` | Extracts standard fields (`severity`, `value`, `options`, `reason`), removes undefined properties |
221
+ | `isRuleConfigValue()` | Type guard: returns `true` for primitives, `null`, and arrays (i.e., not a `RuleConfig` object) |
222
+ | `deleteUndefProp()` | Removes all properties with `undefined` values from a plain object in-place |
225
223
 
226
224
  ## Key Source Files
227
225
 
@@ -234,15 +232,14 @@ This function is used by `nodeRules` and `childNodeRules` with `regexSelector`,
234
232
 
235
233
  ## External Dependencies
236
234
 
237
- | Dependency | Purpose |
238
- | ---------------------- | ------------------------------------------------- |
239
- | `@markuplint/ml-ast` | `ParserOptions` type (type-only) |
240
- | `@markuplint/selector` | `RegexSelector` type (re-exported) |
241
- | `@markuplint/shared` | `Nullable` utility type |
242
- | `deepmerge` | Deep merge implementation used by `mergeObject()` |
243
- | `is-plain-object` | Plain object detection in `deleteUndefProp()` |
244
- | `mustache` | Template rendering engine for `provideValue()` |
245
- | `type-fest` | `Writable` utility type |
235
+ | Dependency | Purpose |
236
+ | ---------------------- | ---------------------------------------------- |
237
+ | `@markuplint/ml-ast` | `ParserOptions` type (type-only) |
238
+ | `@markuplint/selector` | `RegexSelector` type (re-exported) |
239
+ | `@markuplint/shared` | `Nullable` utility type |
240
+ | `is-plain-object` | Plain object detection in `deleteUndefProp()` |
241
+ | `mustache` | Template rendering engine for `provideValue()` |
242
+ | `type-fest` | `Writable` utility type |
246
243
 
247
244
  ## Integration Points
248
245
 
@@ -277,6 +274,41 @@ flowchart LR
277
274
  - **`@markuplint/ml-core`** -- Receives the merged `OptimizedConfig` and applies rules to the parsed document
278
275
  - **`@markuplint/rules`** -- Uses `Rule<T,O>` and `RuleConfig<T,O>` types to define rule implementations
279
276
 
277
+ ## Design Decisions
278
+
279
+ ### Why Not Flat Config?
280
+
281
+ ESLint's Flat Config approach was evaluated and rejected for markuplint:
282
+
283
+ - **ESLint itself re-added `extends` to Flat Config in March 2025** -- The pure flat approach proved insufficient even for JavaScript tooling
284
+ - **markuplint is JSON-based** -- Flat Config assumes JavaScript. HTML/markup developers (the primary audience) benefit from JSON's schema validation and language-agnostic editing
285
+ - **markuplint's `nodeRules`/`childNodeRules` are CSS-selector-based** -- These have no equivalent in Flat Config's file-pattern model
286
+ - **ESLint v9 migration caused significant community pain** -- The gradual transition required automated migration tools and years of ecosystem adaptation
287
+
288
+ **Conclusion:** Improving the JSON-based `extends` merge strategy is the optimal approach for markuplint.
289
+
290
+ ### Merge Strategy Principles
291
+
292
+ There is a logical distinction between how arrays are handled:
293
+
294
+ | Array Type | Examples | Merge Behavior | Rationale |
295
+ | ------------------------- | -------------------------------------- | -------------- | -------------------------------------- |
296
+ | **Top-level collections** | `plugins`, `excludeFiles`, `nodeRules` | Accumulate | Independent items forming a collection |
297
+ | **Rule values** | `["allowed-tag-1", "allowed-tag-2"]` | Override | A single rule's configuration value |
298
+
299
+ This aligns with ESLint and Biome, where rule values are always overridden by the more specific config.
300
+
301
+ ### Shallow Merge over Deep Merge
302
+
303
+ | Tool | Rule options merge | Rationale |
304
+ | ---------- | ---------------------------- | --------------------------------------------------------------------- |
305
+ | ESLint | Complete replacement | Simplest, but can be surprising |
306
+ | Biome | Deep merge (via Merge trait) | Full flexibility, higher complexity |
307
+ | markuplint | **Shallow merge** | Middle ground: top-level keys are merged, nested objects are replaced |
308
+
309
+ The `deepmerge` library was removed in favor of simple object spread (`{...a, ...b}`). This is sufficient because all merged objects in markuplint config (parser, specs, parserOptions, severity, plugin settings, rule options) are flat key-value maps.
310
+
280
311
  ## Documentation Map
281
312
 
313
+ - [Migration Guide](../../docs/migration/v4-v5/config.md) -- Breaking changes between major versions
282
314
  - [Maintenance Guide](docs/maintenance.md) -- Commands, recipes, and troubleshooting
package/CHANGELOG.md CHANGED
@@ -3,6 +3,49 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.0.0-alpha.1](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.0...v5.0.0-alpha.1) (2026-02-22)
7
+
8
+ **Note:** Version bump only for package @markuplint/ml-config
9
+
10
+ # [5.0.0-alpha.0](https://github.com/markuplint/markuplint/compare/v4.14.1...v5.0.0-alpha.0) (2026-02-20)
11
+
12
+ ### Bug Fixes
13
+
14
+ - use explicit `export type` for type-only re-exports ([7c77c05](https://github.com/markuplint/markuplint/commit/7c77c05619518c8d18a183132040f5b2cd0ab6ec))
15
+
16
+ ### Code Refactoring
17
+
18
+ - **ml-config:** remove deprecated rule types ([e5d2b2d](https://github.com/markuplint/markuplint/commit/e5d2b2d6b5d7f6a060e1ea2160be97ad3ca02084))
19
+
20
+ - refactor(ml-config)!: change pretenders merge behavior ([e7b00ab](https://github.com/markuplint/markuplint/commit/e7b00abd80dd75a6060697b30d59d0371ae3694b))
21
+ - refactor(ml-config)!: change rule value array merge to override ([05c23ac](https://github.com/markuplint/markuplint/commit/05c23ace31a3429233b3411c8b95ae62438be6e5)), closes [#1104](https://github.com/markuplint/markuplint/issues/1104)
22
+ - refactor(ml-config)!: replace deepmerge with shallow merge ([15b4945](https://github.com/markuplint/markuplint/commit/15b494546b9016189a790b2ea49fcc2bb38c85c4))
23
+
24
+ ### Features
25
+
26
+ - **ml-config:** add name and specConformance properties to nodeRule types ([af53042](https://github.com/markuplint/markuplint/commit/af5304218f7a207a1d8e61464c81c42d5ee1bf01))
27
+ - **ml-config:** add named rule group types, merge logic, and type guards ([9f625fd](https://github.com/markuplint/markuplint/commit/9f625fdcd9bc821d2be53668ac0eb676597aa935))
28
+ - **ml-config:** add ruleCommonSettings.ariaVersion option ([f2cd713](https://github.com/markuplint/markuplint/commit/f2cd7132311c00c22d68c4685b4a280b77ee6463))
29
+
30
+ ### BREAKING CHANGES
31
+
32
+ - Pretender files/imports are now overridden instead
33
+ of deep-merged. Pretender data continues to be appended.
34
+
35
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
36
+
37
+ - Rule value arrays are now overridden instead of
38
+ - Object properties in config are now shallow-merged
39
+ instead of deep-merged. Nested objects within parser, specs, etc.
40
+ will be replaced entirely by the overriding config.
41
+
42
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
43
+
44
+ - **ml-config:** RuleV2, RuleConfigV2, AnyRuleV2 types are removed.
45
+ The deprecated `option` field is no longer supported; use `options`.
46
+
47
+ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
48
+
6
49
  ## [4.8.15](https://github.com/markuplint/markuplint/compare/@markuplint/ml-config@4.8.14...@markuplint/ml-config@4.8.15) (2026-02-10)
7
50
 
8
51
  **Note:** Version bump only for package @markuplint/ml-config
package/SKILL.md CHANGED
@@ -49,7 +49,7 @@ Add a new property to the Config type and implement its merge strategy. Follow r
49
49
 
50
50
  1. Read `src/merge-config.ts`
51
51
  2. Add the merge logic inside `mergeConfig()`, choosing the appropriate strategy:
52
- - `mergeObject()` for object deep merge
52
+ - `mergeObject()` for object shallow merge
53
53
  - `concatArray()` for array concatenation
54
54
  - `b.prop ?? a.prop` for simple right-side precedence
55
55
  3. If the property needs format conversion (like plugins or pretenders), implement a conversion helper
@@ -94,8 +94,8 @@ Modify the rule merge logic in `mergeRule()`. Follow recipe #3 in `docs/maintena
94
94
 
95
95
  1. Make changes to `mergeRule()`, paying attention to:
96
96
  - The `false` absolute disable behavior
97
- - Array concatenation for value arrays
98
- - Deep merge for options via `mergeObject()`
97
+ - Array values override (right-side wins)
98
+ - Shallow merge for options via `mergeObject()`
99
99
  - Right-side precedence for severity, value, reason
100
100
 
101
101
  ### Step 3: Verify
@@ -61,7 +61,7 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
61
61
  - トップレベル専用(`$schema`、`extends` のように)なら、`NoInherit` ユニオン型にプロパティ名を追加
62
62
  - ファイルパターンごとにオーバーライド可能にする場合はそのまま(`Omit<Config, NoInherit>` 経由で `Config` から継承)
63
63
  5. `src/merge-config.ts` を読み、`mergeConfig()` 関数の config オブジェクト内にマージロジックを追加:
64
- - オブジェクト deep merge: `newProp: mergeObject(a.newProp, b.newProp)`
64
+ - オブジェクト shallow merge: `newProp: mergeObject(a.newProp, b.newProp)`
65
65
  - 配列結合: `newProp: concatArray(a.newProp, b.newProp)`
66
66
  - 配列結合+重複排除: `newProp: concatArray(a.newProp, b.newProp, true)`
67
67
  - 単純な右辺優先: `newProp: b.newProp ?? a.newProp`(スプレッドで処理されるが、明示的な方が分かりやすい)
@@ -74,7 +74,7 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
74
74
  1. `src/merge-config.ts` を読み、`mergeConfig()` 関数内のプロパティを確認
75
75
  2. 現在の戦略を特定(`ARCHITECTURE.md` の戦略テーブルを参照)
76
76
  3. マージ呼び出しを置換。利用可能な戦略:
77
- - `mergeObject(a.prop, b.prop)` -- 右辺優先の deep merge
77
+ - `mergeObject(a.prop, b.prop)` -- 右辺優先の shallow merge
78
78
  - `concatArray(a.prop, b.prop)` -- 単純な配列結合
79
79
  - `concatArray(a.prop, b.prop, true)` -- 重複排除付き結合
80
80
  - `concatArray(a.prop, b.prop, true, 'name')` -- 名前付きプロパティで重複排除、同名オブジェクトをマージ
@@ -88,15 +88,15 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
88
88
 
89
89
  1. `src/merge-config.ts` を読み、`mergeRule()` 関数を確認
90
90
  2. 現在のフローを理解:
91
- - `optimizeRule()` が両方の入力を正規化(非推奨の `option` -> `options` を処理)
91
+ - `optimizeRule()` が両方の入力を正規化
92
92
  - `false` チェック: override が `false` または `{value: false}` なら常に `false` を返す
93
93
  - `undefined` チェック: 片方がない場合はもう片方を返す
94
- - 値型チェック: override が直接値(primitive/null/array)なら置換または連結
95
- - オブジェクト型マージ: severity/value/reason は右辺優先、options は deep merge
94
+ - 値型チェック: override が直接値(primitive/null/array)ならベース値を上書き
95
+ - オブジェクト型マージ: severity/value/reason は右辺優先、options は shallow merge
96
96
  3. 変更を加える際、主要な不変条件を保持:
97
97
  - `false` は常に絶対無効化になる必要がある
98
- - 配列値は連結される(置換ではない)
99
- - `options` は `mergeObject()` による deep merge が必要
98
+ - 配列値は上書き(右辺優先)であり、連結ではない
99
+ - `options` は `mergeObject()` による shallow merge が必要
100
100
  4. `src/merge-config.spec.ts` の既存テストが通ることを確認
101
101
  5. 変更後の動作に対する新しいテストケースを追加
102
102
  6. ビルド: `yarn build --scope @markuplint/ml-config`
@@ -107,10 +107,10 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
107
107
  1. `src/types.ts` を読み、`Pretender`、`PretenderDetails`、`OriginalNode` を確認
108
108
  2. 適切な型に新しいフィールドを追加
109
109
  3. `src/merge-config.ts` を読み、`mergePretenders()` を確認:
110
- - 配列形式を `{data: [...]}` に変換(`convertPretenersToDetails()`)
111
- - `mergeObject()` で deep merge
112
- - `PretenderDetails` の新しいフィールドは自動的に deep merge される
113
- - `Pretender`(`data` 配列内)の新しいフィールドは deepmerge の配列マージで処理
110
+ - 配列形式を `{data: [...]}` に変換(`toPretenderDetails()`)
111
+ - `files`/`imports` は上書き(右辺優先)
112
+ - `data` 配列は連結(追加)
113
+ - `PretenderDetails` の新しいフィールドは `mergePretenders()` 内で明示的に処理が必要
114
114
  4. `src/merge-config.spec.ts` にテストケースを追加
115
115
  5. ビルド: `yarn build --scope @markuplint/ml-config`
116
116
  6. テスト: `yarn test --scope @markuplint/ml-config`
@@ -145,20 +145,18 @@ yarn test --scope @markuplint/ml-config
145
145
  2. 両方の入力に値がある場合にヘルパー関数が `undefined` を返していないか検証
146
146
  3. `concatArray()` は空配列に対して `undefined` を返す -- 入力が空でないことを確認
147
147
 
148
- ### ルール値が上書きではなく連結される
148
+ ### ルール値が予期しない上書きになる
149
149
 
150
- **症状:** ルールの配列値が上書きされずに増え続ける。
150
+ **症状:** ルールの配列値がベースの値を保持せず、完全に置き換えられる。
151
151
 
152
- **原因:** `mergeRule()` はベースとオーバーライドの両方が配列の場合、設計上連結する。
152
+ **原因:** `mergeRule()` は設計上、配列値を上書きする(右辺優先)。これは ESLint や Biome と一貫した動作。
153
153
 
154
- **解決策:** 配列値を完全に上書きするには、override 側でオブジェクト形式を使用:
154
+ **解決策:** これは期待される動作です。両方の値が必要な場合は、単一の設定で手動で配列を統合:
155
155
 
156
156
  ```json
157
- { "value": ["new", "values"], "options": {} }
157
+ { "value": ["base-tag-1", "base-tag-2", "new-tag-1"], "options": {} }
158
158
  ```
159
159
 
160
- これにより連結ではなく値が完全に置換される。
161
-
162
160
  ### プラグインの settings がマージされない
163
161
 
164
162
  **症状:** 同名プラグインの2つの設定で、片方の settings しか反映されない。
@@ -61,7 +61,7 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
61
61
  - If it should be top-level only (like `$schema`, `extends`), add the property name to the `NoInherit` union type
62
62
  - If it should be overridable per file pattern, leave it as-is (it inherits from `Config` via `Omit<Config, NoInherit>`)
63
63
  5. Read `src/merge-config.ts` and add the merge logic inside the `mergeConfig()` function's config object:
64
- - For object deep merge: `newProp: mergeObject(a.newProp, b.newProp)`
64
+ - For object shallow merge: `newProp: mergeObject(a.newProp, b.newProp)`
65
65
  - For array concatenation: `newProp: concatArray(a.newProp, b.newProp)`
66
66
  - For array with deduplication: `newProp: concatArray(a.newProp, b.newProp, true)`
67
67
  - For simple right-side precedence: `newProp: b.newProp ?? a.newProp` (handled by the spread, but explicit is clearer)
@@ -74,7 +74,7 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
74
74
  1. Read `src/merge-config.ts` and locate the property in the `mergeConfig()` function
75
75
  2. Identify the current strategy (see the strategy table in `ARCHITECTURE.md`)
76
76
  3. Replace the merge call. Available strategies:
77
- - `mergeObject(a.prop, b.prop)` -- Deep merge with right-side precedence
77
+ - `mergeObject(a.prop, b.prop)` -- Shallow merge with right-side precedence
78
78
  - `concatArray(a.prop, b.prop)` -- Simple array concatenation
79
79
  - `concatArray(a.prop, b.prop, true)` -- Concat with deduplication
80
80
  - `concatArray(a.prop, b.prop, true, 'name')` -- Concat with deduplication by named property, merging same-name objects
@@ -88,15 +88,15 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
88
88
 
89
89
  1. Read `src/merge-config.ts` and locate the `mergeRule()` function
90
90
  2. Understand the current flow:
91
- - `optimizeRule()` normalizes both inputs (handles deprecated `option` -> `options`)
91
+ - `optimizeRule()` normalizes both inputs
92
92
  - `false` check: override `false` or `{value: false}` always returns `false`
93
93
  - `undefined` checks: missing side returns the other side
94
- - Value type check: if override is a direct value (primitive/null/array), it replaces or concatenates
95
- - Object type merge: severity/value/reason use right-side precedence, options use deep merge
94
+ - Value type check: if override is a direct value (primitive/null/array), it replaces the base value
95
+ - Object type merge: severity/value/reason use right-side precedence, options use shallow merge
96
96
  3. Make changes, preserving the key invariants:
97
97
  - `false` must always result in absolute disable
98
- - Array values must be concatenated (not replaced)
99
- - `options` must use deep merge via `mergeObject()`
98
+ - Array values must override (right-side wins), not concatenate
99
+ - `options` must use shallow merge via `mergeObject()`
100
100
  4. Verify existing tests pass in `src/merge-config.spec.ts`
101
101
  5. Add new test cases for the modified behavior
102
102
  6. Build: `yarn build --scope @markuplint/ml-config`
@@ -107,10 +107,10 @@ expect(exchangeValueOnRule({ value: '{{ var }}' }, { var: 'x' })).toStrictEqual(
107
107
  1. Read `src/types.ts` and locate `Pretender`, `PretenderDetails`, and `OriginalNode`
108
108
  2. Add new fields to the appropriate type
109
109
  3. Read `src/merge-config.ts` and check `mergePretenders()`:
110
- - It converts array form to `{data: [...]}` via `convertPretenersToDetails()`
111
- - Then deep merges with `mergeObject()`
112
- - New fields on `PretenderDetails` are automatically deep merged
113
- - New fields on `Pretender` (inside `data` array) are handled by deepmerge's array merge
110
+ - It converts array form to `{data: [...]}` via `toPretenderDetails()`
111
+ - `files`/`imports` are overridden (right-side wins)
112
+ - `data` arrays are concatenated (appended)
113
+ - New fields on `PretenderDetails` need explicit handling in `mergePretenders()`
114
114
  4. Add test cases in `src/merge-config.spec.ts`
115
115
  5. Build: `yarn build --scope @markuplint/ml-config`
116
116
  6. Test: `yarn test --scope @markuplint/ml-config`
@@ -145,20 +145,18 @@ yarn test --scope @markuplint/ml-config
145
145
  2. Verify the helper function does not return `undefined` when both inputs have values
146
146
  3. `concatArray()` returns `undefined` for empty arrays -- ensure the inputs are not empty
147
147
 
148
- ### Rule values are concatenated instead of replaced
148
+ ### Rule values unexpectedly replaced
149
149
 
150
- **Symptom:** A rule's array value keeps growing instead of being overwritten.
150
+ **Symptom:** A rule's array value is replaced entirely instead of keeping both base and override values.
151
151
 
152
- **Cause:** `mergeRule()` concatenates array values by design when both base and override are arrays.
152
+ **Cause:** `mergeRule()` overrides array values by design (right-side wins). This is consistent with ESLint and Biome behavior.
153
153
 
154
- **Solution:** To completely replace an array value, use the object form in the override:
154
+ **Solution:** This is the expected behavior. If you need both sets of values, manually combine the arrays in a single config:
155
155
 
156
156
  ```json
157
- { "value": ["new", "values"], "options": {} }
157
+ { "value": ["base-tag-1", "base-tag-2", "new-tag-1"], "options": {} }
158
158
  ```
159
159
 
160
- This replaces the value entirely instead of concatenating.
161
-
162
160
  ### Plugin settings are not merged
163
161
 
164
162
  **Symptom:** Two configs with the same plugin name result in settings from only one side.
package/lib/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export * from './merge-config.js';
2
2
  export * from './utils.js';
3
- export * from './types.js';
3
+ export type * from './types.js';
package/lib/index.js CHANGED
@@ -1,3 +1,2 @@
1
1
  export * from './merge-config.js';
2
2
  export * from './utils.js';
3
- export * from './types.js';
@@ -1,12 +1,13 @@
1
- import type { Config, AnyRule, AnyRuleV2, OptimizedConfig } from './types.js';
1
+ import type { Config, AnyRule, OptimizedConfig } from './types.js';
2
2
  import type { Nullable } from '@markuplint/shared';
3
3
  /**
4
- * Deep-merges two markuplint configurations into an optimized result.
4
+ * Merges two markuplint configurations into an optimized result.
5
5
  *
6
6
  * Plugins, arrays, and rules are merged with specific strategies:
7
- * - Plugins are concatenated and deduplicated by name
7
+ * - Plugins are concatenated and deduplicated by name (settings shallow-merged)
8
8
  * - Arrays (excludeFiles, nodeRules, childNodeRules) are concatenated
9
9
  * - Rules are merged per-key with right-side precedence
10
+ * - Objects (parser, specs, etc.) are shallow-merged
10
11
  * - The `extends` property is removed from the result when `b` is provided
11
12
  *
12
13
  * @param a - The base configuration
@@ -18,11 +19,12 @@ export declare function mergeConfig(a: Config, b?: Config): OptimizedConfig;
18
19
  * Merges two rule configurations with right-side precedence.
19
20
  *
20
21
  * If `b` is `false`, the rule is unconditionally disabled.
21
- * If `b` is a direct value, it replaces or extends `a`.
22
- * If both are full config objects, their properties are merged.
22
+ * If `b` is a direct value (including arrays), it overrides `a`.
23
+ * If both are full config objects, their properties are merged
24
+ * (severity/value/reason: right-side wins, options: shallow-merged).
23
25
  *
24
26
  * @param a - The base rule configuration (may be `null` or `undefined`)
25
27
  * @param b - The rule configuration to merge on top
26
28
  * @returns The merged rule configuration
27
29
  */
28
- export declare function mergeRule(a: Nullable<AnyRule | AnyRuleV2>, b: AnyRule | AnyRuleV2): AnyRule;
30
+ export declare function mergeRule(a: Nullable<AnyRule>, b: AnyRule): AnyRule;
@@ -1,12 +1,12 @@
1
- import deepmerge from 'deepmerge';
2
- import { deleteUndefProp, cleanOptions, isRuleConfigValue } from './utils.js';
1
+ import { deleteUndefProp, cleanOptions, isRuleConfigValue, isNamedRuleGroup } from './utils.js';
3
2
  /**
4
- * Deep-merges two markuplint configurations into an optimized result.
3
+ * Merges two markuplint configurations into an optimized result.
5
4
  *
6
5
  * Plugins, arrays, and rules are merged with specific strategies:
7
- * - Plugins are concatenated and deduplicated by name
6
+ * - Plugins are concatenated and deduplicated by name (settings shallow-merged)
8
7
  * - Arrays (excludeFiles, nodeRules, childNodeRules) are concatenated
9
8
  * - Rules are merged per-key with right-side precedence
9
+ * - Objects (parser, specs, etc.) are shallow-merged
10
10
  * - The `extends` property is removed from the result when `b` is provided
11
11
  *
12
12
  * @param a - The base configuration
@@ -19,6 +19,7 @@ export function mergeConfig(a, b) {
19
19
  const config = {
20
20
  ...a,
21
21
  ...b,
22
+ ruleCommonSettings: mergeObject(a.ruleCommonSettings, b.ruleCommonSettings),
22
23
  plugins: concatArray(a.plugins, b.plugins, true, 'name')?.map(plugin => {
23
24
  if (typeof plugin === 'string') {
24
25
  return {
@@ -36,8 +37,8 @@ export function mergeConfig(a, b) {
36
37
  rules: mergeRules(
37
38
  // TODO: Deep merge
38
39
  a.rules, b.rules),
39
- nodeRules: concatArray(a.nodeRules, b.nodeRules),
40
- childNodeRules: concatArray(a.childNodeRules, b.childNodeRules),
40
+ nodeRules: concatArray(a.nodeRules, b.nodeRules, true, 'name'),
41
+ childNodeRules: concatArray(a.childNodeRules, b.childNodeRules, true, 'name'),
41
42
  overrideMode: b.overrideMode ?? a.overrideMode,
42
43
  overrides: mergeOverrides(a.overrides, b.overrides),
43
44
  extends: concatArray(toReadonlyArray(a.extends), toReadonlyArray(b.extends)),
@@ -53,8 +54,9 @@ export function mergeConfig(a, b) {
53
54
  * Merges two rule configurations with right-side precedence.
54
55
  *
55
56
  * If `b` is `false`, the rule is unconditionally disabled.
56
- * If `b` is a direct value, it replaces or extends `a`.
57
- * If both are full config objects, their properties are merged.
57
+ * If `b` is a direct value (including arrays), it overrides `a`.
58
+ * If both are full config objects, their properties are merged
59
+ * (severity/value/reason: right-side wins, options: shallow-merged).
58
60
  *
59
61
  * @param a - The base rule configuration (may be `null` or `undefined`)
60
62
  * @param b - The rule configuration to merge on top
@@ -77,13 +79,9 @@ export function mergeRule(a, b) {
77
79
  }
78
80
  if (isRuleConfigValue(oB)) {
79
81
  if (isRuleConfigValue(oA)) {
80
- if (Array.isArray(oA) && Array.isArray(oB)) {
81
- return [...oA, ...oB];
82
- }
83
82
  return oB;
84
83
  }
85
- const value = Array.isArray(oA.value) && Array.isArray(oB) ? [...oA.value, ...oB] : oB;
86
- const res = cleanOptions({ ...oA, value });
84
+ const res = cleanOptions({ ...oA, value: oB });
87
85
  deleteUndefProp(res);
88
86
  return res;
89
87
  }
@@ -104,13 +102,25 @@ function mergePretenders(a, b) {
104
102
  if (!a && !b) {
105
103
  return;
106
104
  }
107
- const aDetails = a ? convertPretenersToDetails(a) : undefined;
108
- const bDetails = b ? convertPretenersToDetails(b) : undefined;
109
- const details = mergeObject(aDetails, bDetails) ?? {};
105
+ const aDetails = a ? toPretenderDetails(a) : undefined;
106
+ const bDetails = b ? toPretenderDetails(b) : undefined;
107
+ if (!aDetails) {
108
+ return bDetails;
109
+ }
110
+ if (!bDetails) {
111
+ return aDetails;
112
+ }
113
+ // files/imports: override (right-side wins)
114
+ // data: append (concatenate)
115
+ const details = {
116
+ files: bDetails.files ?? aDetails.files,
117
+ imports: bDetails.imports ?? aDetails.imports,
118
+ data: concatArray(aDetails.data, bDetails.data),
119
+ };
110
120
  deleteUndefProp(details);
111
121
  return details;
112
122
  }
113
- function convertPretenersToDetails(pretenders) {
123
+ function toPretenderDetails(pretenders) {
114
124
  if (isReadonlyArray(pretenders)) {
115
125
  return {
116
126
  data: pretenders,
@@ -148,7 +158,7 @@ function mergeObject(a, b) {
148
158
  if (b == null) {
149
159
  return a ?? undefined;
150
160
  }
151
- const res = deepmerge(a, b);
161
+ const res = { ...a, ...b };
152
162
  deleteUndefProp(res);
153
163
  return res;
154
164
  }
@@ -180,12 +190,7 @@ function concatArray(a, b, uniquely = false, comparePropName) {
180
190
  return;
181
191
  }
182
192
  const existed = newArray[existedIndex];
183
- const merged = mergeObject(existed, item);
184
- if (!merged) {
185
- newArray.push(item);
186
- return;
187
- }
188
- newArray.splice(existedIndex, 1, merged);
193
+ newArray.splice(existedIndex, 1, { ...existed, ...item });
189
194
  }
190
195
  // eslint-disable-next-line unicorn/no-array-for-each
191
196
  a?.forEach(concat);
@@ -205,6 +210,15 @@ function getName(item, comparePropName) {
205
210
  }
206
211
  return null;
207
212
  }
213
+ /**
214
+ * Merges two Rules dictionaries. Keys containing `/` use named rule group
215
+ * merge semantics ({@link mergeNamedRuleGroupEntry}); other keys use standard
216
+ * rule merge semantics ({@link mergeRule}).
217
+ *
218
+ * @param a - The base rules (lower priority)
219
+ * @param b - The override rules (higher priority)
220
+ * @returns The merged rules, or `undefined` if both inputs are nullish
221
+ */
208
222
  function mergeRules(a, b) {
209
223
  if (a == null) {
210
224
  return b && optimizeRules(b);
@@ -214,17 +228,62 @@ function mergeRules(a, b) {
214
228
  }
215
229
  const res = optimizeRules(a);
216
230
  for (const [key, rule] of Object.entries(b)) {
217
- const merged = mergeRule(res[key], rule);
218
- if (merged != null) {
219
- res[key] = merged;
231
+ if (key.includes('/')) {
232
+ // Named rule group key: special merge semantics
233
+ res[key] = mergeNamedRuleGroupEntry(res[key], rule);
234
+ }
235
+ else {
236
+ const merged = mergeRule(res[key], rule);
237
+ if (merged != null) {
238
+ res[key] = merged;
239
+ }
220
240
  }
221
241
  }
222
242
  deleteUndefProp(res);
223
243
  return Object.freeze(res);
224
244
  }
245
+ /**
246
+ * Merges a named rule group entry (key containing `/`).
247
+ *
248
+ * - `false` disables the group entirely
249
+ * - A partial override object (e.g., `{ severity: "warning" }`) is merged into the existing NamedRuleGroup
250
+ * - Otherwise, right side wins
251
+ *
252
+ * @param a - The existing entry from a lower-priority config (may be a NamedRuleGroup)
253
+ * @param b - The overriding entry from a higher-priority config
254
+ * @returns The merged entry
255
+ */
256
+ function mergeNamedRuleGroupEntry(a, b) {
257
+ // false disables the group
258
+ if (b === false) {
259
+ return false;
260
+ }
261
+ // Partial override: object without `rules` merging into an existing NamedRuleGroup
262
+ // Only merge valid NamedRuleGroup keys to avoid contamination from RuleConfig keys
263
+ if (typeof b === 'object' && b !== null && !isNamedRuleGroup(b) && a !== undefined && isNamedRuleGroup(a)) {
264
+ const bObj = b;
265
+ const override = {};
266
+ if ('severity' in bObj) {
267
+ override.severity = bObj.severity;
268
+ }
269
+ if ('specConformance' in bObj) {
270
+ override.specConformance = bObj.specConformance;
271
+ }
272
+ const merged = { ...a, ...override };
273
+ deleteUndefProp(merged);
274
+ return merged;
275
+ }
276
+ // Right side wins for everything else
277
+ return b;
278
+ }
225
279
  function optimizeRules(rules) {
226
280
  const res = {};
227
281
  for (const [key, rule] of Object.entries(rules)) {
282
+ // Pass through NamedRuleGroup entries without optimization
283
+ if (key.includes('/') && isNamedRuleGroup(rule)) {
284
+ res[key] = rule;
285
+ continue;
286
+ }
228
287
  const _rule = optimizeRule(rule);
229
288
  if (_rule != null) {
230
289
  res[key] = _rule;
package/lib/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ParserOptions } from '@markuplint/ml-ast';
2
+ import type { ARIAVersion } from '@markuplint/ml-spec';
2
3
  import type { RegexSelector } from '@markuplint/selector';
3
4
  import type { Nullable } from '@markuplint/shared';
4
5
  export type { RegexSelector } from '@markuplint/selector';
@@ -8,6 +9,7 @@ export type { RegexSelector } from '@markuplint/selector';
8
9
  */
9
10
  export type Config = {
10
11
  readonly $schema?: string;
12
+ readonly ruleCommonSettings?: RuleCommonSettings;
11
13
  readonly extends?: string | readonly string[];
12
14
  readonly plugins?: readonly (PluginConfig | string)[];
13
15
  readonly parser?: ParserConfig;
@@ -22,6 +24,13 @@ export type Config = {
22
24
  readonly overrideMode?: 'merge' | 'reset';
23
25
  readonly overrides?: Readonly<Record<string, OverrideConfig>>;
24
26
  };
27
+ /**
28
+ * Common settings applied globally to all rules.
29
+ * These values serve as fallbacks when individual rules do not specify their own.
30
+ */
31
+ export type RuleCommonSettings = {
32
+ readonly ariaVersion?: ARIAVersion;
33
+ };
25
34
  /**
26
35
  * A primitive scalar value that can appear in rule configuration.
27
36
  */
@@ -84,6 +93,10 @@ export type SpecConfig = {
84
93
  export type SeverityOptions = {
85
94
  readonly parseError?: Severity | 'off' | boolean;
86
95
  };
96
+ /**
97
+ * Normalized form of pretender configuration used after merging.
98
+ * Contains optional file references, import paths, and inline pretender data.
99
+ */
87
100
  export type PretenderDetails = {
88
101
  /**
89
102
  * @experimental
@@ -102,6 +115,11 @@ export type PretenderFileData = {
102
115
  readonly version: string;
103
116
  readonly data: readonly Pretender[];
104
117
  };
118
+ /**
119
+ * Defines a mapping from a custom element (matched by CSS selector) to a standard
120
+ * HTML element for linting purposes, allowing rules to treat custom components
121
+ * as if they were native elements.
122
+ */
105
123
  export type Pretender = {
106
124
  /**
107
125
  * Target node selectors
@@ -265,23 +283,48 @@ export interface PretenderScanOptions {
265
283
  * @template O - The type of the rule's options
266
284
  */
267
285
  export type Rule<T extends RuleConfigValue, O extends PlainData = undefined> = RuleConfig<T, O> | Readonly<T> | boolean;
268
- /**
269
- * @deprecated
270
- */
271
- export type RuleV2<T extends RuleConfigValue, O extends PlainData = undefined> = RuleConfigV2<T, O> | Readonly<T> | boolean;
272
286
  /**
273
287
  * A rule setting with any value and option types.
274
288
  */
275
289
  export type AnyRule = Rule<RuleConfigValue, PlainData>;
276
290
  /**
277
- * @deprecated
291
+ * A named rule group in the `rules` section.
292
+ * Keys containing `/` in `rules` are treated as named rule groups.
293
+ * Each group wraps one or more base rules under a namespace,
294
+ * enabling per-check control and spec conformance metadata.
295
+ *
296
+ * @example
297
+ * ```jsonc
298
+ * {
299
+ * "rules": {
300
+ * "a11y/id-duplication": {
301
+ * "specConformance": "normative",
302
+ * "rules": { "id-duplication": true }
303
+ * }
304
+ * }
305
+ * }
306
+ * ```
307
+ */
308
+ export type NamedRuleGroup = {
309
+ readonly specConformance?: SpecConformance;
310
+ /** User-applied severity override for all rules in the group */
311
+ readonly severity?: Severity;
312
+ readonly rules: BaseRules;
313
+ };
314
+ /**
315
+ * A dictionary mapping base rule names to their configurations.
316
+ * Does not accept {@link NamedRuleGroup} entries.
317
+ * Used inside {@link NamedRuleGroup}, {@link NodeRule}, and {@link ChildNodeRule}.
278
318
  */
279
- export type AnyRuleV2 = RuleV2<RuleConfigValue, PlainData>;
319
+ export type BaseRules = {
320
+ readonly [ruleName: string]: AnyRule;
321
+ };
280
322
  /**
281
323
  * A dictionary mapping rule names to their configurations.
324
+ * Keys containing `/` may be {@link NamedRuleGroup} entries.
282
325
  */
283
326
  export type Rules = {
284
- readonly [ruleName: string]: AnyRule;
327
+ readonly [ruleName: string]: AnyRule | NamedRuleGroup;
285
328
  };
286
329
  /**
287
330
  * Full configuration for a single rule, specifying severity, value, options, and reason.
@@ -299,25 +342,18 @@ export type RuleConfig<T extends RuleConfigValue, O extends PlainData = undefine
299
342
  /** A human-readable reason for this rule configuration, included in violation messages */
300
343
  readonly reason?: string;
301
344
  };
302
- /**
303
- * @deprecated
304
- */
305
- export type RuleConfigV2<T extends RuleConfigValue, O extends PlainData = undefined> = {
306
- readonly severity?: Severity;
307
- readonly value?: Readonly<T>;
308
- readonly reason?: string;
309
- /**
310
- * Old property
311
- *
312
- * @deprecated
313
- * @see {this.options}
314
- */
315
- readonly option?: Readonly<O>;
316
- };
317
345
  /**
318
346
  * The severity level of a lint violation.
319
347
  */
320
348
  export type Severity = 'error' | 'warning' | 'info';
349
+ /**
350
+ * The spec conformance classification of a rule, based on RFC 2119 keyword strength.
351
+ *
352
+ * - `'normative'` — derived from MUST/REQUIRED requirements in the HTML spec
353
+ * - `'non-normative'` — derived from SHOULD/RECOMMENDED requirements in the HTML spec
354
+ * - `undefined` — plugin/preset recommendation or user-defined (no spec backing)
355
+ */
356
+ export type SpecConformance = 'normative' | 'non-normative';
321
357
  /**
322
358
  * The value portion of a rule configuration. Can be a primitive scalar,
323
359
  * an array of scalars or objects, or `null` to represent no value.
@@ -325,23 +361,58 @@ export type Severity = 'error' | 'warning' | 'info';
325
361
  export type RuleConfigValue = PrimitiveScalar | readonly (PrimitiveScalar | Readonly<Record<string, any>>)[] | null;
326
362
  /**
327
363
  * A rule override that targets specific nodes by CSS selector, regex selector, ARIA roles, or categories.
364
+ *
365
+ * When a `name` is provided (must contain `/`), this becomes a **named nodeRule**
366
+ * that creates a virtual rule instance running independently from the base rule.
367
+ * Named nodeRules can be individually enabled/disabled via `rules["name/here"]: false`.
328
368
  */
329
369
  export type NodeRule = {
370
+ /**
371
+ * Alias name for this nodeRule, creating a virtual rule.
372
+ * Must contain `/` (e.g., `"a11y/img-has-alt"`).
373
+ * With a single non-false entry, this name is used directly.
374
+ * With multiple non-false entries, derived names (`name/baseRuleName`)
375
+ * are generated automatically, and this name becomes the group name.
376
+ */
377
+ readonly name?: string;
378
+ /**
379
+ * The spec conformance classification of this rule.
380
+ * Included in violations as metadata for downstream tools and reporting.
381
+ */
382
+ readonly specConformance?: SpecConformance;
330
383
  readonly selector?: string;
331
384
  readonly regexSelector?: RegexSelector;
332
385
  readonly categories?: readonly string[];
333
386
  readonly roles?: readonly string[];
334
387
  readonly obsolete?: boolean;
335
- readonly rules?: Rules;
388
+ /** Base rule settings. Does not accept {@link NamedRuleGroup} entries. */
389
+ readonly rules?: BaseRules;
336
390
  };
337
391
  /**
338
392
  * A rule override that targets child nodes of elements matching the selector.
393
+ *
394
+ * When a `name` is provided (must contain `/`), this becomes a **named childNodeRule**
395
+ * that creates a virtual rule instance, just like named nodeRules.
339
396
  */
340
397
  export type ChildNodeRule = {
398
+ /**
399
+ * Alias name for this childNodeRule, creating a virtual rule.
400
+ * Must contain `/` (e.g., `"a11y/heading-in-section"`).
401
+ * With a single non-false entry, this name is used directly.
402
+ * With multiple non-false entries, derived names (`name/baseRuleName`)
403
+ * are generated automatically, and this name becomes the group name.
404
+ */
405
+ readonly name?: string;
406
+ /**
407
+ * The spec conformance classification of this rule.
408
+ * Included in violations as metadata for downstream tools and reporting.
409
+ */
410
+ readonly specConformance?: SpecConformance;
341
411
  readonly selector?: string;
342
412
  readonly regexSelector?: RegexSelector;
343
413
  readonly inheritance?: boolean;
344
- readonly rules?: Rules;
414
+ /** Base rule settings. Does not accept {@link NamedRuleGroup} entries. */
415
+ readonly rules?: BaseRules;
345
416
  };
346
417
  /**
347
418
  * A violation report from a rule. Can report against a scope (node-based)
@@ -386,10 +457,18 @@ export type Scope<T extends RuleConfigValue, O extends PlainData = undefined> =
386
457
  * A fully resolved lint violation with all information needed for reporting.
387
458
  */
388
459
  export type Violation = {
460
+ /** The base rule ID (always the underlying rule name, for backwards compatibility) */
389
461
  readonly ruleId: string;
462
+ /**
463
+ * The display name of the rule. Present only on virtual rules (named nodeRules).
464
+ * For regular rules, use `ruleId` as the display name.
465
+ */
466
+ readonly name?: string;
390
467
  readonly severity: Severity;
391
468
  readonly message: string;
392
469
  readonly reason?: string;
470
+ /** The normative level of the rule that produced this violation */
471
+ readonly specConformance?: SpecConformance;
393
472
  readonly line: number;
394
473
  readonly col: number;
395
474
  readonly raw: string;
package/lib/utils.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AnyRule, AnyRuleV2, PlainData, RuleConfig, RuleConfigV2, RuleConfigValue } from './types.js';
1
+ import type { AnyRule, NamedRuleGroup, PlainData, RuleConfig, RuleConfigValue, Severity } from './types.js';
2
2
  /**
3
3
  * Renders a Mustache template with the provided data.
4
4
  *
@@ -18,16 +18,30 @@ export declare function provideValue(template: string, data: Readonly<Record<str
18
18
  * @param data - Key-value pairs for template variable replacement
19
19
  * @returns The rule with all template strings rendered, or `undefined` if rendering fails
20
20
  */
21
- export declare function exchangeValueOnRule(rule: AnyRule | AnyRuleV2, data: Readonly<Record<string, string>>): AnyRule | undefined;
21
+ export declare function exchangeValueOnRule(rule: AnyRule, data: Readonly<Record<string, string>>): AnyRule | undefined;
22
22
  /**
23
23
  * Normalizes a rule configuration by extracting the standard fields
24
24
  * (`severity`, `value`, `options`, `reason`) and removing `undefined` properties.
25
- * Also handles the deprecated `option` field by mapping it to `options`.
26
25
  *
27
26
  * @param rule - The rule configuration to normalize
28
27
  * @returns A clean rule configuration with only defined properties
29
28
  */
30
- export declare function cleanOptions(rule: RuleConfig<RuleConfigValue, PlainData> | RuleConfigV2<RuleConfigValue, PlainData>): RuleConfig<RuleConfigValue, PlainData>;
29
+ export declare function cleanOptions(rule: RuleConfig<RuleConfigValue, PlainData>): RuleConfig<RuleConfigValue, PlainData>;
30
+ /**
31
+ * Type guard that checks whether a value is a {@link NamedRuleGroup}.
32
+ * A NamedRuleGroup is an object with a `rules` property (and optionally `specConformance` and `severity`).
33
+ *
34
+ * @param v - The value to check
35
+ * @returns `true` if `v` is a named rule group
36
+ */
37
+ export declare function isNamedRuleGroup(v: unknown): v is NamedRuleGroup;
38
+ /**
39
+ * Type guard that checks whether a string is a valid {@link Severity} value.
40
+ *
41
+ * @param v - The string to check
42
+ * @returns `true` if `v` is "error", "warning", or "info"
43
+ */
44
+ export declare function isSeverity(v: string): v is Severity;
31
45
  /**
32
46
  * Type guard that checks whether a value is a {@link RuleConfigValue}
33
47
  * (i.e. a primitive, `null`, or an array) rather than a full {@link RuleConfig} object.
package/lib/utils.js CHANGED
@@ -44,7 +44,7 @@ export function exchangeValueOnRule(rule, data) {
44
44
  value: exchangeValue(result.value, data),
45
45
  };
46
46
  }
47
- const options = extractOptions(result);
47
+ const options = result.options;
48
48
  if (options != null && options !== '' && options !== 0) {
49
49
  const newOptions = exchangeOption(options, data);
50
50
  result = {
@@ -69,7 +69,6 @@ export function exchangeValueOnRule(rule, data) {
69
69
  /**
70
70
  * Normalizes a rule configuration by extracting the standard fields
71
71
  * (`severity`, `value`, `options`, `reason`) and removing `undefined` properties.
72
- * Also handles the deprecated `option` field by mapping it to `options`.
73
72
  *
74
73
  * @param rule - The rule configuration to normalize
75
74
  * @returns A clean rule configuration with only defined properties
@@ -78,12 +77,37 @@ export function cleanOptions(rule) {
78
77
  const res = {
79
78
  severity: rule.severity,
80
79
  value: rule.value,
81
- options: extractOptions(rule),
80
+ options: rule.options,
82
81
  reason: rule.reason,
83
82
  };
84
83
  deleteUndefProp(res);
85
84
  return res;
86
85
  }
86
+ /**
87
+ * Type guard that checks whether a value is a {@link NamedRuleGroup}.
88
+ * A NamedRuleGroup is an object with a `rules` property (and optionally `specConformance` and `severity`).
89
+ *
90
+ * @param v - The value to check
91
+ * @returns `true` if `v` is a named rule group
92
+ */
93
+ export function isNamedRuleGroup(v) {
94
+ return (v != null &&
95
+ typeof v === 'object' &&
96
+ !Array.isArray(v) &&
97
+ 'rules' in v &&
98
+ v.rules != null &&
99
+ typeof v.rules === 'object' &&
100
+ !Array.isArray(v.rules));
101
+ }
102
+ /**
103
+ * Type guard that checks whether a string is a valid {@link Severity} value.
104
+ *
105
+ * @param v - The string to check
106
+ * @returns `true` if `v` is "error", "warning", or "info"
107
+ */
108
+ export function isSeverity(v) {
109
+ return v === 'error' || v === 'warning' || v === 'info';
110
+ }
87
111
  /**
88
112
  * Type guard that checks whether a value is a {@link RuleConfigValue}
89
113
  * (i.e. a primitive, `null`, or an array) rather than a full {@link RuleConfig} object.
@@ -120,20 +144,6 @@ export function deleteUndefProp(obj) {
120
144
  }
121
145
  }
122
146
  }
123
- /**
124
- * Return options from `options` or `option`
125
- *
126
- * @param rule
127
- * @returns
128
- */
129
- function extractOptions(rule) {
130
- if ('options' in rule && rule.options != null) {
131
- return rule.options;
132
- }
133
- if ('option' in rule && rule.option != null) {
134
- return rule.option;
135
- }
136
- }
137
147
  function exchangeValue(rule, data) {
138
148
  if (rule == null) {
139
149
  return rule;
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "name": "@markuplint/ml-config",
3
- "version": "4.8.15",
3
+ "version": "5.0.0-alpha.1",
4
4
  "description": "JSON Schema and TypeScript types of markuplint configure JSON",
5
5
  "repository": "git@github.com:markuplint/markuplint.git",
6
6
  "author": "Yusuke Hirao <yusukehirao@me.com>",
7
7
  "license": "MIT",
8
+ "engines": {
9
+ "node": ">=22"
10
+ },
8
11
  "type": "module",
9
12
  "exports": {
10
13
  ".": {
@@ -24,15 +27,14 @@
24
27
  "clean": "tsc --build --clean tsconfig.build.json"
25
28
  },
26
29
  "dependencies": {
27
- "@markuplint/ml-ast": "4.4.11",
28
- "@markuplint/ml-spec": "4.10.2",
29
- "@markuplint/selector": "4.7.8",
30
- "@markuplint/shared": "4.4.13",
30
+ "@markuplint/ml-ast": "5.0.0-alpha.1",
31
+ "@markuplint/ml-spec": "5.0.0-alpha.1",
32
+ "@markuplint/selector": "5.0.0-alpha.1",
33
+ "@markuplint/shared": "5.0.0-alpha.1",
31
34
  "@types/mustache": "4.2.6",
32
- "deepmerge": "4.3.1",
33
35
  "is-plain-object": "5.0.0",
34
36
  "mustache": "4.2.0",
35
- "type-fest": "4.41.0"
37
+ "type-fest": "5.4.4"
36
38
  },
37
- "gitHead": "193ee7c1262bbed95424e38efdf1a8e56ff049f4"
39
+ "gitHead": "78a295e73a097a1ce09c777c06fa21ab68136387"
38
40
  }