@reteps/tree-sitter-htmlmustache 0.8.1 → 0.9.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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/browser/out/browser/index.d.ts +43 -0
  3. package/browser/out/browser/index.d.ts.map +1 -0
  4. package/browser/out/browser/index.mjs +3746 -0
  5. package/browser/out/browser/index.mjs.map +7 -0
  6. package/browser/out/core/collectErrors.d.ts +36 -0
  7. package/browser/out/core/collectErrors.d.ts.map +1 -0
  8. package/browser/out/core/configSchema.d.ts +63 -0
  9. package/browser/out/core/configSchema.d.ts.map +1 -0
  10. package/browser/out/core/customCodeTags.d.ts +34 -0
  11. package/browser/out/core/customCodeTags.d.ts.map +1 -0
  12. package/browser/out/core/diagnostic.d.ts +24 -0
  13. package/browser/out/core/diagnostic.d.ts.map +1 -0
  14. package/browser/out/core/embeddedRegions.d.ts +12 -0
  15. package/browser/out/core/embeddedRegions.d.ts.map +1 -0
  16. package/browser/out/core/formatting/classifier.d.ts +68 -0
  17. package/browser/out/core/formatting/classifier.d.ts.map +1 -0
  18. package/browser/out/core/formatting/embedded.d.ts +19 -0
  19. package/browser/out/core/formatting/embedded.d.ts.map +1 -0
  20. package/browser/out/core/formatting/formatters.d.ts +85 -0
  21. package/browser/out/core/formatting/formatters.d.ts.map +1 -0
  22. package/browser/out/core/formatting/index.d.ts +44 -0
  23. package/browser/out/core/formatting/index.d.ts.map +1 -0
  24. package/browser/out/core/formatting/ir.d.ts +100 -0
  25. package/browser/out/core/formatting/ir.d.ts.map +1 -0
  26. package/browser/out/core/formatting/mergeOptions.d.ts +18 -0
  27. package/browser/out/core/formatting/mergeOptions.d.ts.map +1 -0
  28. package/browser/out/core/formatting/printer.d.ts +18 -0
  29. package/browser/out/core/formatting/printer.d.ts.map +1 -0
  30. package/browser/out/core/formatting/utils.d.ts +39 -0
  31. package/browser/out/core/formatting/utils.d.ts.map +1 -0
  32. package/browser/out/core/grammar.d.ts +3 -0
  33. package/browser/out/core/grammar.d.ts.map +1 -0
  34. package/browser/out/core/htmlBalanceChecker.d.ts +23 -0
  35. package/browser/out/core/htmlBalanceChecker.d.ts.map +1 -0
  36. package/browser/out/core/mustacheChecks.d.ts +24 -0
  37. package/browser/out/core/mustacheChecks.d.ts.map +1 -0
  38. package/browser/out/core/nodeHelpers.d.ts +54 -0
  39. package/browser/out/core/nodeHelpers.d.ts.map +1 -0
  40. package/browser/out/core/ruleMetadata.d.ts +12 -0
  41. package/browser/out/core/ruleMetadata.d.ts.map +1 -0
  42. package/browser/out/core/selectorMatcher.d.ts +87 -0
  43. package/browser/out/core/selectorMatcher.d.ts.map +1 -0
  44. package/cli/out/main.js +333 -181
  45. package/package.json +21 -3
  46. package/src/browser/browser.test.ts +207 -0
  47. package/src/browser/index.ts +128 -0
  48. package/src/browser/tsconfig.json +18 -0
  49. package/src/core/collectErrors.ts +233 -0
  50. package/src/core/configSchema.ts +273 -0
  51. package/src/core/customCodeTags.ts +159 -0
  52. package/src/core/diagnostic.ts +45 -0
  53. package/src/core/embeddedRegions.ts +70 -0
  54. package/src/core/formatting/classifier.ts +549 -0
  55. package/src/core/formatting/embedded.ts +56 -0
  56. package/src/core/formatting/formatters.ts +1272 -0
  57. package/src/core/formatting/index.ts +185 -0
  58. package/src/core/formatting/ir.ts +202 -0
  59. package/src/core/formatting/mergeOptions.ts +34 -0
  60. package/src/core/formatting/printer.ts +242 -0
  61. package/src/core/formatting/utils.ts +193 -0
  62. package/src/core/grammar.ts +2 -0
  63. package/src/core/htmlBalanceChecker.ts +382 -0
  64. package/src/core/mustacheChecks.ts +504 -0
  65. package/src/core/nodeHelpers.ts +126 -0
  66. package/src/core/ruleMetadata.ts +63 -0
  67. package/src/core/selectorMatcher.ts +919 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Utility functions for formatting.
3
+ */
4
+
5
+ import type { Node as SyntaxNode } from 'web-tree-sitter';
6
+
7
+ // Re-export getTagName from the canonical location
8
+ export { getTagName } from '../nodeHelpers.js';
9
+
10
+ /**
11
+ * Normalize text content - collapse horizontal whitespace while preserving line breaks.
12
+ */
13
+ export function normalizeText(text: string): string {
14
+ // Split by newlines, normalize each line, then rejoin
15
+ // This preserves intentional line breaks while collapsing spaces
16
+ return text
17
+ .split('\n')
18
+ .map((line) => line.replace(/[ \t]+/g, ' ').trim())
19
+ .filter((line, i, arr) => line || (i > 0 && i < arr.length - 1)) // Keep non-empty lines
20
+ .join('\n');
21
+ }
22
+
23
+ /**
24
+ * Get visible children of a node (excluding anonymous nodes starting with _).
25
+ */
26
+ export function getVisibleChildren(node: SyntaxNode): SyntaxNode[] {
27
+ const children: SyntaxNode[] = [];
28
+ for (let i = 0; i < node.childCount; i++) {
29
+ const child = node.child(i);
30
+ if (child && !child.type.startsWith('_')) {
31
+ children.push(child);
32
+ }
33
+ }
34
+ return children;
35
+ }
36
+
37
+ /**
38
+ * Calculate the indent level of a node based on its parent chain.
39
+ */
40
+ export function calculateIndentLevel(
41
+ node: SyntaxNode,
42
+ isBlockLevel: (node: SyntaxNode) => boolean,
43
+ hasImplicitEndTags: (nodes: SyntaxNode[]) => boolean,
44
+ getContentNodes: (node: SyntaxNode) => SyntaxNode[]
45
+ ): number {
46
+ let level = 0;
47
+ let current = node.parent;
48
+ while (current) {
49
+ if (isBlockLevel(current)) {
50
+ // Mustache sections only increase indentation if they have complete HTML
51
+ // (no implicit end tags crossing boundaries)
52
+ if (
53
+ current.type === 'mustache_section' ||
54
+ current.type === 'mustache_inverted_section'
55
+ ) {
56
+ const contentNodes = getContentNodes(current);
57
+ if (!hasImplicitEndTags(contentNodes)) {
58
+ level++;
59
+ }
60
+ } else {
61
+ level++;
62
+ }
63
+ }
64
+ current = current.parent;
65
+ }
66
+ return level;
67
+ }
68
+
69
+ /**
70
+ * Normalize whitespace inside a single mustache expression.
71
+ * Handles triple ({{{...}}}), prefixed ({{#, {{/, {{^, {{!, {{>), and plain ({{...}}).
72
+ * For multiline comments, preserves internal newlines, only normalizes space adjacent to delimiters.
73
+ */
74
+ export function normalizeMustacheWhitespace(raw: string, addSpaces: boolean): string {
75
+ const space = addSpaces ? ' ' : '';
76
+
77
+ // Triple mustache: {{{...}}}
78
+ const tripleMatch = raw.match(/^\{\{\{([\s\S]*)\}\}\}$/);
79
+ if (tripleMatch) {
80
+ const inner = tripleMatch[1].trim();
81
+ return `{{{${space}${inner}${space}}}}`;
82
+ }
83
+
84
+ // Prefixed: {{#, {{/, {{^, {{!, {{>
85
+ const prefixedMatch = raw.match(/^\{\{([#/^!>])([\s\S]*)\}\}$/);
86
+ if (prefixedMatch) {
87
+ const prefix = prefixedMatch[1];
88
+ const inner = prefixedMatch[2];
89
+
90
+ // Multiline comments: preserve internal newlines, always use spaces
91
+ if (prefix === '!' && inner.includes('\n')) {
92
+ const lines = inner.split('\n');
93
+ const first = lines[0].trimStart();
94
+ const last = lines[lines.length - 1].trimEnd();
95
+ if (lines.length === 1) {
96
+ return `{{${prefix} ${first} }}`;
97
+ }
98
+ const middle = lines.slice(1, -1);
99
+ return `{{${prefix} ${first}\n${middle.join('\n')}\n${last} }}`;
100
+ }
101
+
102
+ const trimmed = inner.trim();
103
+ // Comments always get spaces for readability, regardless of mustacheSpaces setting
104
+ const s = prefix === '!' ? ' ' : space;
105
+ return `{{${prefix}${s}${trimmed}${s}}}`;
106
+ }
107
+
108
+ // Plain: {{...}}
109
+ const plainMatch = raw.match(/^\{\{([\s\S]*)\}\}$/);
110
+ if (plainMatch) {
111
+ const inner = plainMatch[1].trim();
112
+ return `{{${space}${inner}${space}}}`;
113
+ }
114
+
115
+ return raw;
116
+ }
117
+
118
+ /**
119
+ * Normalize whitespace in ALL mustache expressions within a string.
120
+ * Used for force-inlined sections where the full section text (e.g. `{{#plural}}s{{/plural}}`)
121
+ * is emitted as one string.
122
+ */
123
+ export function normalizeMustacheWhitespaceAll(raw: string, addSpaces: boolean): string {
124
+ // Match triple mustache first, then double
125
+ return raw.replace(/\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}/g, (match) => {
126
+ return normalizeMustacheWhitespace(match, addSpaces);
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Check if a node is a format-ignore directive comment.
132
+ * Returns the directive type or null if not a directive.
133
+ */
134
+ export function getIgnoreDirective(
135
+ node: SyntaxNode
136
+ ): 'ignore' | 'ignore-start' | 'ignore-end' | null {
137
+ if (node.type !== 'html_comment' && node.type !== 'mustache_comment') {
138
+ return null;
139
+ }
140
+
141
+ let inner: string | null = null;
142
+
143
+ if (node.type === 'html_comment') {
144
+ // <!-- ... -->
145
+ const match = node.text.match(/^<!--([\s\S]*)-->$/);
146
+ if (match) {
147
+ inner = match[1].trim();
148
+ }
149
+ } else {
150
+ // {{! ... }}
151
+ const match = node.text.match(/^\{\{!([\s\S]*)\}\}$/);
152
+ if (match) {
153
+ inner = match[1].trim();
154
+ }
155
+ }
156
+
157
+ if (!inner) return null;
158
+
159
+ if (inner === 'htmlmustache-ignore') return 'ignore';
160
+ if (inner === 'htmlmustache-ignore-start') return 'ignore-start';
161
+ if (inner === 'htmlmustache-ignore-end') return 'ignore-end';
162
+
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * Find the smallest node that contains the entire range.
168
+ */
169
+ export function findContainingNode(
170
+ node: SyntaxNode,
171
+ startOffset: number,
172
+ endOffset: number
173
+ ): SyntaxNode | null {
174
+ if (node.startIndex > endOffset || node.endIndex < startOffset) {
175
+ return null;
176
+ }
177
+
178
+ // Check children first for a more specific match
179
+ for (let i = 0; i < node.childCount; i++) {
180
+ const child = node.child(i);
181
+ if (child && child.startIndex <= startOffset && child.endIndex >= endOffset) {
182
+ const deeper = findContainingNode(child, startOffset, endOffset);
183
+ if (deeper) return deeper;
184
+ }
185
+ }
186
+
187
+ // This node contains the range
188
+ if (node.startIndex <= startOffset && node.endIndex >= endOffset) {
189
+ return node;
190
+ }
191
+
192
+ return null;
193
+ }
@@ -0,0 +1,2 @@
1
+ /** Filename of the compiled grammar WASM, as shipped in this package. */
2
+ export const GRAMMAR_WASM_FILENAME = 'tree-sitter-htmlmustache.wasm';
@@ -0,0 +1,382 @@
1
+ import {
2
+ getTagName,
3
+ getSectionName,
4
+ getErroneousEndTagName,
5
+ } from './nodeHelpers.js';
6
+
7
+ // Minimal syntax node interface for balance checking.
8
+ // Compatible with web-tree-sitter's SyntaxNode.
9
+ export interface BalanceNode {
10
+ type: string;
11
+ text: string;
12
+ startPosition: { row: number; column: number };
13
+ endPosition: { row: number; column: number };
14
+ startIndex: number;
15
+ endIndex: number;
16
+ children: BalanceNode[];
17
+ }
18
+
19
+ export interface BalanceError {
20
+ node: BalanceNode;
21
+ message: string;
22
+ }
23
+
24
+ // --- Internal types ---
25
+
26
+ interface TagEvent {
27
+ type: 'open' | 'close';
28
+ tagName: string;
29
+ node: BalanceNode;
30
+ }
31
+
32
+ interface ConditionalFork {
33
+ type: 'fork';
34
+ sectionName: string;
35
+ truthy: PathItem[];
36
+ falsy: PathItem[];
37
+ }
38
+
39
+ type PathItem = TagEvent | ConditionalFork;
40
+
41
+ // --- Phase 1: Extract tag events from parse tree ---
42
+
43
+ // Re-export for consumers that import from this module
44
+ export { getSectionName } from './nodeHelpers.js';
45
+
46
+ function getTagNameLower(element: BalanceNode): string | null {
47
+ return getTagName(element)?.toLowerCase() ?? null;
48
+ }
49
+
50
+ function getErroneousEndTagNameLower(node: BalanceNode): string | null {
51
+ return getErroneousEndTagName(node)?.toLowerCase() ?? null;
52
+ }
53
+
54
+ function hasForcedEndTag(element: BalanceNode): boolean {
55
+ return element.children.some(c => c.type === 'html_forced_end_tag');
56
+ }
57
+
58
+ function extractFromNodes(nodes: BalanceNode[]): PathItem[] {
59
+ const items: PathItem[] = [];
60
+ for (const node of nodes) {
61
+ items.push(...extractFromNode(node));
62
+ }
63
+ return items;
64
+ }
65
+
66
+ function extractFromNode(node: BalanceNode): PathItem[] {
67
+ if (node.type === 'html_element') {
68
+ const contentChildren = node.children.filter(
69
+ c =>
70
+ c.type !== 'html_start_tag' &&
71
+ c.type !== 'html_end_tag' &&
72
+ c.type !== 'html_forced_end_tag',
73
+ );
74
+
75
+ if (hasForcedEndTag(node)) {
76
+ const tagName = getTagNameLower(node);
77
+ const items: PathItem[] = [];
78
+ if (tagName) {
79
+ const startTag = node.children.find(c => c.type === 'html_start_tag');
80
+ items.push({ type: 'open', tagName, node: startTag ?? node });
81
+ }
82
+ items.push(...extractFromNodes(contentChildren));
83
+ return items;
84
+ }
85
+
86
+ // Balanced or implicit close — recurse into content for inner forks
87
+ return extractFromNodes(contentChildren);
88
+ }
89
+
90
+ if (node.type === 'html_self_closing_tag') {
91
+ return [];
92
+ }
93
+
94
+ if (node.type === 'html_erroneous_end_tag') {
95
+ const tagName = getErroneousEndTagNameLower(node);
96
+ if (tagName) {
97
+ return [{ type: 'close', tagName, node }];
98
+ }
99
+ return [];
100
+ }
101
+
102
+ if (node.type === 'mustache_section') {
103
+ const sectionName = getSectionName(node);
104
+ if (sectionName) {
105
+ const contentChildren = node.children.filter(
106
+ c =>
107
+ c.type !== 'mustache_section_begin' &&
108
+ c.type !== 'mustache_section_end' &&
109
+ c.type !== 'mustache_erroneous_section_end',
110
+ );
111
+ return [
112
+ {
113
+ type: 'fork',
114
+ sectionName,
115
+ truthy: extractFromNodes(contentChildren),
116
+ falsy: [],
117
+ },
118
+ ];
119
+ }
120
+ return [];
121
+ }
122
+
123
+ if (node.type === 'mustache_inverted_section') {
124
+ const sectionName = getSectionName(node);
125
+ if (sectionName) {
126
+ const contentChildren = node.children.filter(
127
+ c =>
128
+ c.type !== 'mustache_inverted_section_begin' &&
129
+ c.type !== 'mustache_inverted_section_end' &&
130
+ c.type !== 'mustache_erroneous_inverted_section_end',
131
+ );
132
+ return [
133
+ {
134
+ type: 'fork',
135
+ sectionName,
136
+ truthy: [],
137
+ falsy: extractFromNodes(contentChildren),
138
+ },
139
+ ];
140
+ }
141
+ return [];
142
+ }
143
+
144
+ // For any other node type, recurse into children
145
+ return extractFromNodes(node.children);
146
+ }
147
+
148
+ // --- Phase 2: Merge adjacent same-named forks ---
149
+
150
+ function mergeAdjacentForks(items: PathItem[]): PathItem[] {
151
+ if (items.length === 0) return items;
152
+
153
+ const result: PathItem[] = [];
154
+ let i = 0;
155
+
156
+ while (i < items.length) {
157
+ const item = items[i];
158
+ if (item.type !== 'fork') {
159
+ result.push(item);
160
+ i++;
161
+ continue;
162
+ }
163
+
164
+ // Merge consecutive forks with the same section name
165
+ const truthy = [...item.truthy];
166
+ const falsy = [...item.falsy];
167
+ let j = i + 1;
168
+ while (j < items.length) {
169
+ const next = items[j];
170
+ if (next.type !== 'fork' || next.sectionName !== item.sectionName) break;
171
+ truthy.push(...next.truthy);
172
+ falsy.push(...next.falsy);
173
+ j++;
174
+ }
175
+
176
+ // Recursively merge within branches
177
+ result.push({
178
+ type: 'fork',
179
+ sectionName: item.sectionName,
180
+ truthy: mergeAdjacentForks(truthy),
181
+ falsy: mergeAdjacentForks(falsy),
182
+ });
183
+ i = j;
184
+ }
185
+
186
+ return result;
187
+ }
188
+
189
+ // --- Phase 3: Enumerate paths and validate balance ---
190
+
191
+ /**
192
+ * Check if a branch (list of PathItems) is self-balanced: all opens are matched
193
+ * by closes in LIFO order, leaving the stack empty. Nested forks are balanced
194
+ * only if both their branches are independently balanced (making the fork a no-op).
195
+ */
196
+ function isBranchBalanced(items: PathItem[]): boolean {
197
+ const stack: string[] = [];
198
+ for (const item of items) {
199
+ if (item.type === 'fork') {
200
+ if (!isBranchBalanced(item.truthy) || !isBranchBalanced(item.falsy)) {
201
+ return false;
202
+ }
203
+ } else if (item.type === 'open') {
204
+ stack.push(item.tagName);
205
+ } else {
206
+ if (stack.length === 0 || stack[stack.length - 1] !== item.tagName) {
207
+ return false;
208
+ }
209
+ stack.pop();
210
+ }
211
+ }
212
+ return stack.length === 0;
213
+ }
214
+
215
+ /**
216
+ * Collect only section names that affect HTML tag balance.
217
+ * A fork only matters if at least one of its branches is unbalanced.
218
+ * Forks where both branches are independently balanced (e.g. truthy has
219
+ * matched open/close pairs and falsy is empty) are no-ops and skipped.
220
+ * This reduces 2^N enumeration to only the relevant variables.
221
+ */
222
+ function collectSectionNames(items: PathItem[]): Set<string> {
223
+ const names = new Set<string>();
224
+ for (const item of items) {
225
+ if (item.type === 'fork') {
226
+ if (!isBranchBalanced(item.truthy) || !isBranchBalanced(item.falsy)) {
227
+ names.add(item.sectionName);
228
+ }
229
+ for (const name of collectSectionNames(item.truthy)) names.add(name);
230
+ for (const name of collectSectionNames(item.falsy)) names.add(name);
231
+ }
232
+ }
233
+ return names;
234
+ }
235
+
236
+ function flattenPath(items: PathItem[], assignment: Map<string, boolean>): TagEvent[] {
237
+ const events: TagEvent[] = [];
238
+ for (const item of items) {
239
+ if (item.type === 'fork') {
240
+ const value = assignment.get(item.sectionName) ?? true;
241
+ const branch = value ? item.truthy : item.falsy;
242
+ events.push(...flattenPath(branch, assignment));
243
+ } else {
244
+ events.push(item);
245
+ }
246
+ }
247
+ return events;
248
+ }
249
+
250
+ function formatCondition(assignment: Map<string, boolean>): string {
251
+ if (assignment.size === 0) return '';
252
+ const parts: string[] = [];
253
+ for (const [name, value] of assignment) {
254
+ parts.push(`${name} is ${value ? 'truthy' : 'falsy'}`);
255
+ }
256
+ return ` (when ${parts.join(', ')})`;
257
+ }
258
+
259
+ function validateBalance(events: TagEvent[], condition: string): BalanceError[] {
260
+ const errors: BalanceError[] = [];
261
+ const stack: TagEvent[] = [];
262
+
263
+ for (const event of events) {
264
+ if (event.type === 'open') {
265
+ stack.push(event);
266
+ } else {
267
+ if (stack.length === 0) {
268
+ errors.push({
269
+ node: event.node,
270
+ message: `Mismatched HTML end tag: </${event.tagName}>${condition}`,
271
+ });
272
+ } else {
273
+ const top = stack[stack.length - 1];
274
+ if (top.tagName !== event.tagName) {
275
+ errors.push({
276
+ node: event.node,
277
+ message: `Mismatched HTML end tag: </${event.tagName}>${condition}`,
278
+ });
279
+ } else {
280
+ stack.pop();
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ for (const event of stack) {
287
+ errors.push({
288
+ node: event.node,
289
+ message: `Unclosed HTML tag: <${event.tagName}>${condition}`,
290
+ });
291
+ }
292
+
293
+ return errors;
294
+ }
295
+
296
+ // --- Unclosed tag detection ---
297
+
298
+ const VOID_ELEMENTS = new Set([
299
+ 'area', 'base', 'basefont', 'bgsound', 'br', 'col', 'command',
300
+ 'embed', 'frame', 'hr', 'image', 'img', 'input', 'isindex',
301
+ 'keygen', 'link', 'menuitem', 'meta', 'nextid', 'param',
302
+ 'source', 'track', 'wbr',
303
+ ]);
304
+
305
+ const OPTIONAL_END_TAG_ELEMENTS = new Set([
306
+ 'li', 'dt', 'dd', 'p', 'colgroup',
307
+ 'rb', 'rt', 'rp', 'rtc',
308
+ 'optgroup', 'option',
309
+ 'tr', 'td', 'th',
310
+ 'thead', 'tbody', 'tfoot',
311
+ 'caption',
312
+ 'html', 'head', 'body',
313
+ ]);
314
+
315
+ export function checkUnclosedTags(rootNode: BalanceNode): BalanceError[] {
316
+ const errors: BalanceError[] = [];
317
+
318
+ function visit(node: BalanceNode) {
319
+ if (node.type === 'html_element') {
320
+ const hasEndTag = node.children.some(c => c.type === 'html_end_tag');
321
+ const hasForcedEnd = node.children.some(c => c.type === 'html_forced_end_tag');
322
+
323
+ if (!hasEndTag && !hasForcedEnd) {
324
+ const tagName = getTagNameLower(node);
325
+ if (tagName && !VOID_ELEMENTS.has(tagName) && !OPTIONAL_END_TAG_ELEMENTS.has(tagName)) {
326
+ const startTag = node.children.find(c => c.type === 'html_start_tag');
327
+ errors.push({
328
+ node: startTag ?? node,
329
+ message: `Unclosed HTML tag: <${tagName}>`,
330
+ });
331
+ }
332
+ }
333
+ }
334
+
335
+ for (const child of node.children) {
336
+ visit(child);
337
+ }
338
+ }
339
+
340
+ visit(rootNode);
341
+ return errors;
342
+ }
343
+
344
+ const MAX_SECTION_NAMES = 15;
345
+
346
+ export function checkHtmlBalance(rootNode: BalanceNode): BalanceError[] {
347
+ // Phase 1: Extract tag events
348
+ const rawItems = extractFromNode(rootNode);
349
+
350
+ // Phase 2: Merge adjacent same-named forks
351
+ const items = mergeAdjacentForks(rawItems);
352
+
353
+ // Phase 3: Enumerate all boolean paths and validate
354
+ const sectionNames = [...collectSectionNames(items)];
355
+ if (sectionNames.length > MAX_SECTION_NAMES) {
356
+ return []; // Safety valve: too many combinations
357
+ }
358
+
359
+ const allErrors: BalanceError[] = [];
360
+ const errorNodes = new Set<BalanceNode>();
361
+ const totalPaths = 1 << sectionNames.length;
362
+
363
+ for (let mask = 0; mask < totalPaths; mask++) {
364
+ const assignment = new Map<string, boolean>();
365
+ for (let i = 0; i < sectionNames.length; i++) {
366
+ assignment.set(sectionNames[i], (mask & (1 << i)) !== 0);
367
+ }
368
+
369
+ const events = flattenPath(items, assignment);
370
+ const condition = formatCondition(assignment);
371
+ const pathErrors = validateBalance(events, condition);
372
+
373
+ for (const error of pathErrors) {
374
+ if (!errorNodes.has(error.node)) {
375
+ errorNodes.add(error.node);
376
+ allErrors.push(error);
377
+ }
378
+ }
379
+ }
380
+
381
+ return allErrors;
382
+ }