@timiagama/ecr 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +411 -7
  4. package/dist/bin.d.ts +20 -0
  5. package/dist/bin.d.ts.map +1 -0
  6. package/dist/bin.js +26 -0
  7. package/dist/bin.js.map +1 -0
  8. package/dist/cli/corpus-loader.d.ts +198 -0
  9. package/dist/cli/corpus-loader.d.ts.map +1 -0
  10. package/dist/cli/corpus-loader.js +351 -0
  11. package/dist/cli/corpus-loader.js.map +1 -0
  12. package/dist/cli/corpus-statistics.d.ts +113 -0
  13. package/dist/cli/corpus-statistics.d.ts.map +1 -0
  14. package/dist/cli/corpus-statistics.js +119 -0
  15. package/dist/cli/corpus-statistics.js.map +1 -0
  16. package/dist/cli/diagnostic-reporter.d.ts +103 -0
  17. package/dist/cli/diagnostic-reporter.d.ts.map +1 -0
  18. package/dist/cli/diagnostic-reporter.js +254 -0
  19. package/dist/cli/diagnostic-reporter.js.map +1 -0
  20. package/dist/cli/project-ignore.d.ts +113 -0
  21. package/dist/cli/project-ignore.d.ts.map +1 -0
  22. package/dist/cli/project-ignore.js +199 -0
  23. package/dist/cli/project-ignore.js.map +1 -0
  24. package/dist/cli/safe-text.d.ts +25 -0
  25. package/dist/cli/safe-text.d.ts.map +1 -0
  26. package/dist/cli/safe-text.js +34 -0
  27. package/dist/cli/safe-text.js.map +1 -0
  28. package/dist/cli/supervisor.d.ts +108 -0
  29. package/dist/cli/supervisor.d.ts.map +1 -0
  30. package/dist/cli/supervisor.js +139 -0
  31. package/dist/cli/supervisor.js.map +1 -0
  32. package/dist/cli.d.ts +247 -0
  33. package/dist/cli.d.ts.map +1 -0
  34. package/dist/cli.js +591 -0
  35. package/dist/cli.js.map +1 -0
  36. package/dist/corpus-validator.d.ts +183 -0
  37. package/dist/corpus-validator.d.ts.map +1 -0
  38. package/dist/corpus-validator.js +479 -0
  39. package/dist/corpus-validator.js.map +1 -0
  40. package/dist/document-identity-rule.d.ts +198 -0
  41. package/dist/document-identity-rule.d.ts.map +1 -0
  42. package/dist/document-identity-rule.js +244 -0
  43. package/dist/document-identity-rule.js.map +1 -0
  44. package/dist/ecr.d.ts +103 -0
  45. package/dist/ecr.d.ts.map +1 -0
  46. package/dist/ecr.js +216 -0
  47. package/dist/ecr.js.map +1 -0
  48. package/dist/glob-pattern.d.ts +94 -0
  49. package/dist/glob-pattern.d.ts.map +1 -0
  50. package/dist/glob-pattern.js +188 -0
  51. package/dist/glob-pattern.js.map +1 -0
  52. package/dist/heading-source-form.d.ts +77 -0
  53. package/dist/heading-source-form.d.ts.map +1 -0
  54. package/dist/heading-source-form.js +129 -0
  55. package/dist/heading-source-form.js.map +1 -0
  56. package/dist/identifier-grammar.d.ts +212 -0
  57. package/dist/identifier-grammar.d.ts.map +1 -0
  58. package/dist/identifier-grammar.js +273 -0
  59. package/dist/identifier-grammar.js.map +1 -0
  60. package/dist/index.d.ts +19 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +11 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/inline-reference-rule.d.ts +557 -0
  65. package/dist/inline-reference-rule.d.ts.map +1 -0
  66. package/dist/inline-reference-rule.js +802 -0
  67. package/dist/inline-reference-rule.js.map +1 -0
  68. package/dist/meta-documents.d.ts +91 -0
  69. package/dist/meta-documents.d.ts.map +1 -0
  70. package/dist/meta-documents.js +133 -0
  71. package/dist/meta-documents.js.map +1 -0
  72. package/dist/per-document-visitor.d.ts +510 -0
  73. package/dist/per-document-visitor.d.ts.map +1 -0
  74. package/dist/per-document-visitor.js +1032 -0
  75. package/dist/per-document-visitor.js.map +1 -0
  76. package/dist/references-section-rule.d.ts +482 -0
  77. package/dist/references-section-rule.d.ts.map +1 -0
  78. package/dist/references-section-rule.js +777 -0
  79. package/dist/references-section-rule.js.map +1 -0
  80. package/dist/section-hierarchy-rule.d.ts +299 -0
  81. package/dist/section-hierarchy-rule.d.ts.map +1 -0
  82. package/dist/section-hierarchy-rule.js +482 -0
  83. package/dist/section-hierarchy-rule.js.map +1 -0
  84. package/dist/source-alignment.d.ts +41 -0
  85. package/dist/source-alignment.d.ts.map +1 -0
  86. package/dist/source-alignment.js +166 -0
  87. package/dist/source-alignment.js.map +1 -0
  88. package/dist/source-lines.d.ts +69 -0
  89. package/dist/source-lines.d.ts.map +1 -0
  90. package/dist/source-lines.js +104 -0
  91. package/dist/source-lines.js.map +1 -0
  92. package/dist/spec-version.d.ts +19 -0
  93. package/dist/spec-version.d.ts.map +1 -0
  94. package/dist/spec-version.js +19 -0
  95. package/dist/spec-version.js.map +1 -0
  96. package/dist/types.d.ts +219 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +11 -0
  99. package/dist/types.js.map +1 -0
  100. package/examples/docs/0. Orientation/0.0 - System Overview.md +34 -0
  101. package/examples/docs/0. Orientation/0.0.1 - Glossary.md +21 -0
  102. package/examples/docs/0. Orientation/0.0.2 - Quality Gates.md +22 -0
  103. package/examples/docs/3. Ingestion/3.1 - Ingestion - Validation Rules.md +33 -0
  104. package/examples/docs/4. Payments/4.10 - Settlement.md +22 -0
  105. package/examples/docs/4. Payments/4.2 - Payment Processing Contract.md +33 -0
  106. package/examples/docs/8. Orchestration/8.1 - Workflow Orchestration Contract.md +38 -0
  107. package/examples/docs/8. Orchestration/8.1.3 - Retry Policy.md +17 -0
  108. package/examples/docs/8. Orchestration/8.10 - Dead Letter Queue.md +20 -0
  109. package/examples/docs/README.md +7 -0
  110. package/package.json +65 -7
  111. package/protocol/navigation-protocol.md +223 -0
  112. package/spec/v2/1 - ECR - Structural Specification.md +1467 -0
  113. package/spec/v2/2 - ECR - User Guide.md +845 -0
  114. package/spec/v2/3 - Design Rationale - The Section Separator.md +243 -0
  115. package/src/bin.ts +30 -0
  116. package/src/cli/corpus-loader.ts +482 -0
  117. package/src/cli/corpus-statistics.ts +196 -0
  118. package/src/cli/diagnostic-reporter.ts +344 -0
  119. package/src/cli/project-ignore.ts +238 -0
  120. package/src/cli/safe-text.ts +39 -0
  121. package/src/cli/supervisor.ts +204 -0
  122. package/src/cli.ts +836 -0
  123. package/src/corpus-validator.ts +642 -0
  124. package/src/document-identity-rule.ts +422 -0
  125. package/src/ecr.ts +298 -0
  126. package/src/glob-pattern.ts +242 -0
  127. package/src/heading-source-form.ts +173 -0
  128. package/src/identifier-grammar.ts +380 -0
  129. package/src/index.ts +78 -0
  130. package/src/inline-reference-rule.ts +1235 -0
  131. package/src/meta-documents.ts +159 -0
  132. package/src/per-document-visitor.ts +1528 -0
  133. package/src/references-section-rule.ts +1182 -0
  134. package/src/section-hierarchy-rule.ts +705 -0
  135. package/src/source-alignment.ts +209 -0
  136. package/src/source-lines.ts +123 -0
  137. package/src/spec-version.ts +19 -0
  138. package/src/types.ts +267 -0
@@ -0,0 +1,1528 @@
1
+ /**
2
+ * Per-Document Visitor
3
+ *
4
+ * Orchestrates all four ECR rules (ECR101--ECR104) over a single Markdown
5
+ * document, producing a composite {@link LintResult}.
6
+ *
7
+ * This is the integration layer that:
8
+ * 1. Parses Markdown text into an AST using `unified` + `remark-parse`
9
+ * 2. Walks the AST and dispatches nodes to the appropriate rules
10
+ * 3. Manages the rule initialisation dependency chain:
11
+ * - ECR101 must complete before ECR102/ECR103 can start
12
+ * - ECR103 must complete before ECR104 can start (to provide declared DocIDs)
13
+ * - If ECR101 fails, downstream rules are skipped entirely
14
+ * 4. Filters text nodes: excludes those inside `code`, `inlineCode`, `html`,
15
+ * or link URL contexts before feeding to ECR104
16
+ * 5. Tracks the current section context (heading identifier) for ECR104
17
+ * 6. Feeds list items within the References section to ECR103
18
+ * 7. Finalises all rules and assembles the composite {@link LintResult}
19
+ *
20
+ * Spec references:
21
+ * - 1#8 -- Visitor and State Model
22
+ * - 1#11.1 -- Pass 1: Per-Document Parse, Validate, Extract
23
+ * - 1#9.3 -- Document Identity Rule [ECR101]
24
+ * - 1#9.4 -- Section Structure Rules [ECR102]
25
+ * - 1#9.5 -- Inline Reference Rules [ECR104]
26
+ * - 1#9.6 -- References Section Rules [ECR103]
27
+ * - 1#10.2 -- LintResult
28
+ * - 1#10.4 -- ExtractedDocument
29
+ */
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Runtime imports (value imports for classes and functions)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ import { unified } from 'unified';
36
+ import remarkParse from 'remark-parse';
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Type-only imports (verbatimModuleSyntax requires `import type`)
40
+ // ---------------------------------------------------------------------------
41
+
42
+ import type {
43
+ DocID,
44
+ SectionID,
45
+ Diagnostic,
46
+ PositionRange,
47
+ LintInput,
48
+ LintResult,
49
+ ExtractedDocument,
50
+ SectionNode,
51
+ ReferenceEdge,
52
+ InlineReferenceEdge,
53
+ } from './types.js';
54
+
55
+ import type {
56
+ DocumentIdentityRuleResult,
57
+ DocumentIdentity,
58
+ HeadingNodeData,
59
+ } from './document-identity-rule.js';
60
+
61
+ import type {
62
+ SectionHierarchyRuleResult,
63
+ } from './section-hierarchy-rule.js';
64
+
65
+ import type {
66
+ ReferencesSectionRuleResult,
67
+ ListItemNodeData,
68
+ ListItemSegment,
69
+ } from './references-section-rule.js';
70
+
71
+ import type {
72
+ InlineReferenceRuleResult,
73
+ InlineSegment,
74
+ TextNodeData,
75
+ } from './inline-reference-rule.js';
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Value imports for rule classes and grammar
79
+ // ---------------------------------------------------------------------------
80
+
81
+ import { DocumentIdentityRule } from './document-identity-rule.js';
82
+ import { SectionHierarchyRule } from './section-hierarchy-rule.js';
83
+ import { ReferencesSectionRule } from './references-section-rule.js';
84
+ import { InlineReferenceRule } from './inline-reference-rule.js';
85
+ import { IdentifierGrammar } from './identifier-grammar.js';
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Local MDAST node type definitions
89
+ // ---------------------------------------------------------------------------
90
+
91
+ /**
92
+ * Generic MDAST node shape.
93
+ *
94
+ * Locally defined to avoid importing from `mdast` or `unist`, which are
95
+ * not directly resolvable as module specifiers in this project. The shapes
96
+ * match the runtime objects produced by `remark-parse`.
97
+ */
98
+ interface MdastNode {
99
+ /** The node type string (e.g., `'heading'`, `'text'`, `'root'`). */
100
+ readonly type: string;
101
+ /** Child nodes, present on parent node types. */
102
+ readonly children?: readonly MdastNode[];
103
+ /** Heading depth (1--6), present only on heading nodes. */
104
+ readonly depth?: number;
105
+ /** Text value, present only on literal nodes (e.g., `text`, `code`). */
106
+ readonly value?: string;
107
+ /** Alternative text, present on image nodes. */
108
+ readonly alt?: string | null;
109
+ /** Positional metadata from the source document. */
110
+ readonly position?: MdastPosition;
111
+ }
112
+
113
+ /**
114
+ * MDAST Root node shape.
115
+ *
116
+ * The root node is the top-level container returned by `remark-parse`.
117
+ * It always has `type: 'root'` and a `children` array.
118
+ */
119
+ interface MdastRoot {
120
+ /** Discriminant: always `'root'`. */
121
+ readonly type: 'root';
122
+ /** The root's child nodes in document order. */
123
+ readonly children: readonly MdastNode[];
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Constructor options
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Configuration options for constructing a {@link PerDocumentVisitor} instance.
132
+ */
133
+ export interface PerDocumentVisitorOptions {
134
+ /**
135
+ * The opaque, host-provided URI identifying the document being validated.
136
+ *
137
+ * Passed through to all rules and echoed in the {@link LintResult.input} field.
138
+ */
139
+ readonly uri: string;
140
+
141
+ /**
142
+ * Optional version tag for the document instance.
143
+ *
144
+ * Passed through to the {@link LintResult.input} field. When omitted, the
145
+ * resulting {@link LintInput.version} will be `undefined`.
146
+ */
147
+ readonly version?: number;
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // Internal result types for multi-pass orchestration
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * Intermediate result from the headings pass (ECR101 + ECR102 + ECR103 heading detection).
156
+ *
157
+ * Produced by the first traversal of the AST, which feeds heading nodes
158
+ * to ECR101, ECR102, and ECR103, and list items within the References
159
+ * section to ECR103.
160
+ */
161
+ interface HeadingsAndReferencesPassResult {
162
+ /** The identity result from ECR101. */
163
+ readonly identityResult: DocumentIdentityRuleResult;
164
+
165
+ /**
166
+ * The section hierarchy result from ECR102.
167
+ * Present only when ECR101 produced a valid DocID.
168
+ */
169
+ readonly sectionsResult?: SectionHierarchyRuleResult;
170
+
171
+ /**
172
+ * The references section result from ECR103.
173
+ * Present only when ECR101 produced a valid DocID.
174
+ */
175
+ readonly referencesResult?: ReferencesSectionRuleResult;
176
+ }
177
+
178
+ /**
179
+ * Intermediate result from the inline references pass (ECR104).
180
+ *
181
+ * Produced by the second traversal of the AST, which feeds filtered
182
+ * text nodes to ECR104 with section context tracking.
183
+ */
184
+ interface InlineReferencesPassResult {
185
+ /** The inline reference result from ECR104. */
186
+ readonly inlineResult: InlineReferenceRuleResult;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // A document the parser could not read
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** Rule identifier reported when a document cannot be parsed at all. */
194
+ export const UNPARSABLE_DOCUMENT_RULE_ID: string = 'document/unparsable';
195
+
196
+ /** The `data.cause` of that diagnostic. */
197
+ export const UNPARSABLE_DOCUMENT_CAUSE: string = 'unparsable-document';
198
+
199
+ /** How much of the parser's own account of the failure to repeat. */
200
+ const UNPARSABLE_REASON_LIMIT: number = 200;
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Excluded ancestor node types for ECR104 filtering
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * MDAST node types whose descendant text nodes must be excluded from
208
+ * ECR104 inline reference detection.
209
+ *
210
+ * Per 1#9.5, text nodes inside code blocks, inline code, and HTML
211
+ * elements are not valid candidates for inline reference detection.
212
+ */
213
+ export const EXCLUDED_ANCESTOR_NODE_TYPES: ReadonlySet<string> = new Set([
214
+ 'code',
215
+ 'inlineCode',
216
+ 'html',
217
+ ]);
218
+
219
+ /**
220
+ * MDAST node types whose children form one inline run: the text a reader
221
+ * sees, recognised as a whole (1#9.5 rule 1). Headings are excluded, since
222
+ * heading text is not subject to inline reference detection.
223
+ */
224
+ const INLINE_RUN_NODE_TYPES: ReadonlySet<string> = new Set([
225
+ 'paragraph',
226
+ 'tableCell',
227
+ ]);
228
+
229
+ /**
230
+ * Inline HTML a reader sees as a line break: an opening `br` tag in any case,
231
+ * whatever follows its name -- `<br>`, `<BR/>`, `<br class="x">`. It separates
232
+ * the words either side of it. Other inline HTML does not, so it is
233
+ * transparent to recognition (1#9.5 rule 1).
234
+ *
235
+ * Only the tag's name is read. The parser has already delimited the whole
236
+ * tag as one node, so its attributes need no second parse here; matching
237
+ * them again assumed every `>` closed the tag, and `<br title="x > y">` was
238
+ * taken for transparent HTML.
239
+ */
240
+ const LINE_BREAK_HTML: RegExp = /^<br(?=[\s/>])/i;
241
+
242
+ // ---------------------------------------------------------------------------
243
+ // MDAST position type
244
+ // ---------------------------------------------------------------------------
245
+
246
+ /**
247
+ * Shape of an MDAST position object with 1-based line and column numbers.
248
+ */
249
+ interface MdastPosition {
250
+ /** Start position with 1-based line and column. */
251
+ readonly start: { readonly line: number; readonly column: number };
252
+ /** End position with 1-based line and column. */
253
+ readonly end: { readonly line: number; readonly column: number };
254
+ }
255
+
256
+ /**
257
+ * Where the `## References` heading was found: at the root, or inside a
258
+ * container, which is a placement violation (1#9.11 rule 3).
259
+ */
260
+ interface ReferencesSectionLocation {
261
+ /** The `## References` heading node. */
262
+ readonly heading: MdastNode;
263
+ /** The children of its parent, in order: the root's, or a container's. */
264
+ readonly siblings: readonly MdastNode[];
265
+ /** The heading's index among them. */
266
+ readonly index: number;
267
+ }
268
+
269
+ /**
270
+ * Mutable wrapper that tracks the current section context identifier
271
+ * during the ECR104 inline references pass.
272
+ *
273
+ * The `current` field holds a {@link DocID} before any H2 heading is
274
+ * encountered, and a {@link SectionID} after a numbered heading is
275
+ * processed.
276
+ */
277
+ interface SectionContextTracker {
278
+ /** The currently active section identifier (DocID or SectionID). */
279
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be DocID or SectionID
280
+ current: DocID | SectionID;
281
+ }
282
+
283
+ /** A list of siblings being walked, and how far through it the walk is. */
284
+ interface SiblingsFrame {
285
+ /** The siblings, in document order. */
286
+ readonly children: readonly MdastNode[];
287
+ /** Index of the next sibling to visit. */
288
+ readonly index: number;
289
+ }
290
+
291
+ /** A node waiting to be visited by the ECR104 inline references walk. */
292
+ interface InlineWalkEntry {
293
+ /** The node to visit. */
294
+ readonly node: MdastNode;
295
+ /**
296
+ * Whether an ancestor's type is one of {@link EXCLUDED_ANCESTOR_NODE_TYPES},
297
+ * which puts the node's text outside inline reference detection.
298
+ */
299
+ readonly excluded: boolean;
300
+ }
301
+
302
+ /** A node waiting to be visited by an inline segment walk, with its wrappers. */
303
+ interface SegmentWalkEntry {
304
+ /** The node to visit. */
305
+ readonly node: MdastNode;
306
+ /** Types of the formatting spans enclosing it, outermost first. */
307
+ readonly wrappers: readonly string[];
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Per-Document Visitor class
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /**
315
+ * Orchestrates all four ECR rules (ECR101--ECR104) over a single Markdown
316
+ * document and produces a composite {@link LintResult}.
317
+ *
318
+ * The visitor is stateless between calls to {@link lint}. Each invocation
319
+ * produces a fresh result with no side effects on the visitor instance.
320
+ *
321
+ * The visitor creates its own {@link IdentifierGrammar} instance internally
322
+ * and manages the rule initialisation dependency chain:
323
+ * - ECR101 (Document Identity) runs first on all headings
324
+ * - If ECR101 produces a valid DocID, ECR102 (Section Hierarchy) and
325
+ * ECR103 (References Section) are initialised and fed their respective nodes
326
+ * - After ECR103 finalises, the set of declared DocIDs is extracted and
327
+ * used to initialise ECR104 (Inline Reference)
328
+ * - If ECR101 fails (no valid DocID), the result is returned early with
329
+ * `ok=false` and no `extracted` field
330
+ *
331
+ * Usage:
332
+ * ```ts
333
+ * const visitor = new PerDocumentVisitor({
334
+ * uri: 'file:///docs/3.1.md',
335
+ * version: 1,
336
+ * });
337
+ *
338
+ * const result: LintResult = visitor.lint('# 3.1 - My Document\n\n## References\n- 8.1 - ...');
339
+ * ```
340
+ *
341
+ * @example
342
+ * ```ts
343
+ * const visitor = new PerDocumentVisitor({ uri: 'file:///docs/5.1.md' });
344
+ * const result: LintResult = visitor.lint(markdownText);
345
+ *
346
+ * if (result.ok) {
347
+ * console.log('Document is valid:', result.extracted?.docId);
348
+ * } else {
349
+ * console.log('Diagnostics:', result.diagnostics);
350
+ * }
351
+ * ```
352
+ */
353
+ export class PerDocumentVisitor {
354
+ /**
355
+ * The opaque, host-provided URI identifying the document being validated.
356
+ */
357
+ private readonly uri: string;
358
+
359
+ /**
360
+ * Optional version tag for the document instance.
361
+ */
362
+ private readonly version: number | undefined;
363
+
364
+ /**
365
+ * The {@link IdentifierGrammar} instance shared across all rules
366
+ * within a single visitor. Created once during construction.
367
+ */
368
+ private readonly grammar: IdentifierGrammar;
369
+
370
+ /**
371
+ * Constructs a new Per-Document Visitor.
372
+ *
373
+ * Creates an internal {@link IdentifierGrammar} instance for use
374
+ * across all rule evaluations performed by this visitor.
375
+ *
376
+ * @param options - Configuration including the document URI and optional version
377
+ */
378
+ public constructor(options: PerDocumentVisitorOptions) {
379
+ this.uri = options.uri;
380
+ this.version = options.version;
381
+ this.grammar = new IdentifierGrammar();
382
+ }
383
+
384
+ /**
385
+ * Lints a single Markdown document and produces a composite {@link LintResult}.
386
+ *
387
+ * Each call is stateless: the visitor creates fresh rule instances,
388
+ * parses the Markdown text into an AST, walks the AST to feed nodes
389
+ * to the appropriate rules, finalises all rules, and assembles the result.
390
+ *
391
+ * The orchestration proceeds in phases:
392
+ * 1. Parse the Markdown text into an MDAST {@link MdastRoot} node
393
+ * 2. First pass: walk headings and list items for ECR101, ECR102, ECR103
394
+ * 3. If ECR101 fails (no valid DocID), return early with `ok=false`
395
+ * 4. Second pass: walk text nodes for ECR104 (with ancestor filtering
396
+ * and section context tracking)
397
+ * 5. Assemble the composite {@link LintResult} from all rule results
398
+ *
399
+ * @param markdownText - The raw Markdown text of the document to lint
400
+ * @returns The composite lint result including diagnostics and, when a valid
401
+ * DocID is recovered, extracted structural artefacts
402
+ */
403
+ public lint(markdownText: string): LintResult {
404
+ let root: MdastRoot;
405
+
406
+ try {
407
+ root = this.parseMarkdown(markdownText);
408
+ } catch (error: unknown) {
409
+ return this.reportUnparsable(error);
410
+ }
411
+
412
+ const passOneResult: HeadingsAndReferencesPassResult =
413
+ this.executeHeadingsAndReferencesPass(root, markdownText);
414
+
415
+ // If ECR101 failed (no valid identity), return early
416
+ if (passOneResult.identityResult.identity === undefined) {
417
+ return this.assembleLintResult(
418
+ passOneResult.identityResult,
419
+ undefined,
420
+ undefined,
421
+ undefined,
422
+ );
423
+ }
424
+
425
+ // ECR101 succeeded -- extract declaredDocIds from ECR103
426
+ const referencesResult: ReferencesSectionRuleResult | undefined =
427
+ passOneResult.referencesResult;
428
+
429
+ const referencesForExtraction: readonly ReferenceEdge[] =
430
+ referencesResult !== undefined ? referencesResult.references : [];
431
+
432
+ const declaredDocIds: ReadonlySet<DocID> =
433
+ this.extractDeclaredDocIds(referencesForExtraction);
434
+
435
+ // Run ECR104
436
+ const inlineResult: InlineReferencesPassResult =
437
+ this.executeInlineReferencesPass(
438
+ root,
439
+ passOneResult.identityResult.identity.docId,
440
+ declaredDocIds,
441
+ markdownText,
442
+ );
443
+
444
+ return this.assembleLintResult(
445
+ passOneResult.identityResult,
446
+ passOneResult.sectionsResult,
447
+ passOneResult.referencesResult,
448
+ inlineResult.inlineResult,
449
+ );
450
+ }
451
+
452
+ // -------------------------------------------------------------------------
453
+ // Private: Markdown parsing
454
+ // -------------------------------------------------------------------------
455
+
456
+ /**
457
+ * Parses raw Markdown text into an MDAST {@link MdastRoot} node.
458
+ *
459
+ * Uses `unified` with `remark-parse` to produce a standards-compliant
460
+ * MDAST tree with positional metadata attached to all nodes.
461
+ *
462
+ * The `unified().use(remarkParse).parse()` call returns a type that
463
+ * cannot be directly assigned to our local {@link MdastRoot} interface
464
+ * because `mdast` types are not importable as a module specifier in this
465
+ * project. The cast via `unknown` is safe because `remark-parse` always
466
+ * produces an MDAST Root node at runtime.
467
+ *
468
+ * @param markdownText - The raw Markdown text to parse
469
+ * @returns The parsed MDAST root node
470
+ */
471
+ private parseMarkdown(markdownText: string): MdastRoot {
472
+ const root: MdastRoot = unified()
473
+ .use(remarkParse)
474
+ .parse(markdownText) as unknown as MdastRoot;
475
+ return root;
476
+ }
477
+
478
+ /**
479
+ * Reports a document the parser could not read.
480
+ *
481
+ * The parser walks a document's structure by recursion, inside a package
482
+ * this project does not control, so a deeply nested link label or image
483
+ * description can end the parse with a stack overflow. That must be one
484
+ * document's error, reported like any other, and never a crash that stops
485
+ * a corpus part way and leaves the rest unvalidated.
486
+ *
487
+ * @param error - What the parser threw
488
+ * @returns A failing result carrying one error diagnostic
489
+ */
490
+ private reportUnparsable(error: unknown): LintResult {
491
+ const reason: string =
492
+ error instanceof Error ? `${error.name}: ${error.message}` : String(error);
493
+
494
+ return {
495
+ input: this.buildLintInput(),
496
+ ok: false,
497
+ diagnostics: [
498
+ {
499
+ severity: 'error',
500
+ ruleId: UNPARSABLE_DOCUMENT_RULE_ID,
501
+ message:
502
+ `Document could not be parsed, so none of it was validated ` +
503
+ `(${reason.slice(0, UNPARSABLE_REASON_LIMIT)}). Deeply nested Markdown is the ` +
504
+ `usual cause, because parsing it recurses.`,
505
+ uri: this.uri,
506
+ data: { cause: UNPARSABLE_DOCUMENT_CAUSE },
507
+ },
508
+ ],
509
+ };
510
+ }
511
+
512
+ // -------------------------------------------------------------------------
513
+ // Private: First pass -- headings, sections, and references
514
+ // -------------------------------------------------------------------------
515
+
516
+ /**
517
+ * Executes the first AST traversal pass: headings, sections, and references.
518
+ *
519
+ * This pass walks the AST in document order and:
520
+ * - Feeds every heading node to ECR101 (`DocumentIdentityRule.evaluateHeading`)
521
+ * - Finalises ECR101 to determine whether a valid DocID was recovered
522
+ * - If a valid DocID is recovered:
523
+ * - Registers the root H1 heading with ECR102
524
+ * (`SectionHierarchyRule.registerRootHeading`)
525
+ * - Feeds headings with depth \>= 2 to ECR102
526
+ * (`SectionHierarchyRule.evaluateHeading`)
527
+ * - Feeds all headings to ECR103
528
+ * (`ReferencesSectionRule.evaluateHeading`)
529
+ * - Feeds list items within the References section to ECR103
530
+ * (`ReferencesSectionRule.evaluateListItem`)
531
+ * - Finalises ECR102 and ECR103
532
+ * - If no valid DocID is recovered, ECR102 and ECR103 are skipped
533
+ *
534
+ * The pass is structured so that all heading nodes are collected first
535
+ * for ECR101, then the collected headings are replayed for ECR102 and ECR103.
536
+ *
537
+ * @param root - The parsed MDAST root node
538
+ * @param sourceText - The document's raw Markdown, for the heading source-form check of 1#9.11
539
+ * @returns The intermediate result from the headings and references pass
540
+ */
541
+ private executeHeadingsAndReferencesPass(
542
+ root: MdastRoot,
543
+ sourceText: string,
544
+ ): HeadingsAndReferencesPassResult {
545
+ // Phase 1: Collect all headings and feed to ECR101
546
+ const collectedHeadings: HeadingNodeData[] = [];
547
+
548
+ this.collectHeadings(root, collectedHeadings);
549
+
550
+ const identityRule: DocumentIdentityRule = new DocumentIdentityRule({
551
+ uri: this.uri,
552
+ grammar: this.grammar,
553
+ sourceText,
554
+ });
555
+
556
+ for (const headingData of collectedHeadings) {
557
+ identityRule.evaluateHeading(headingData);
558
+ }
559
+
560
+ const identityResult: DocumentIdentityRuleResult = identityRule.finalise();
561
+
562
+ // If ECR101 failed, return early -- no ECR102 or ECR103
563
+ if (identityResult.identity === undefined) {
564
+ return { identityResult };
565
+ }
566
+
567
+ // Phase 2: ECR101 succeeded -- initialise ECR102 and ECR103
568
+ const docId: DocID = identityResult.identity.docId;
569
+
570
+ const sectionHierarchyRule: SectionHierarchyRule = new SectionHierarchyRule({
571
+ uri: this.uri,
572
+ docId,
573
+ grammar: this.grammar,
574
+ sourceText,
575
+ });
576
+
577
+ const referencesSectionRule: ReferencesSectionRule = new ReferencesSectionRule({
578
+ uri: this.uri,
579
+ docId,
580
+ grammar: this.grammar,
581
+ sourceText,
582
+ });
583
+
584
+ // Feed collected headings to ECR102 and ECR103
585
+ for (const headingData of collectedHeadings) {
586
+ // ECR102: register root H1 or evaluate sub-headings.
587
+ // The `## References` heading is a structural heading owned by ECR103,
588
+ // not a numbered section heading — it must be excluded from ECR102
589
+ // to avoid a spurious "not a valid SectionID" diagnostic.
590
+ if (headingData.depth === 1) {
591
+ sectionHierarchyRule.registerRootHeading(headingData);
592
+ } else if (!this.tellReferencesHeading(headingData)) {
593
+ sectionHierarchyRule.evaluateHeading(headingData);
594
+ }
595
+
596
+ // ECR103: evaluate all headings for References heading detection
597
+ referencesSectionRule.evaluateHeading(headingData);
598
+ }
599
+
600
+ // Phase 3: Walk the AST again to feed list items to ECR103
601
+ // after the References heading has been detected
602
+ this.feedListItemsToReferencesRule(root, referencesSectionRule);
603
+
604
+ // Finalise ECR102 and ECR103
605
+ const sectionsResult: SectionHierarchyRuleResult =
606
+ sectionHierarchyRule.finalise();
607
+ const referencesResult: ReferencesSectionRuleResult =
608
+ referencesSectionRule.finalise();
609
+
610
+ return {
611
+ identityResult,
612
+ sectionsResult,
613
+ referencesResult,
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Collects all heading nodes from the AST into the provided array.
619
+ *
620
+ * Walks the AST depth-first in document order, extracting
621
+ * {@link HeadingNodeData} from every node with `type === 'heading'`.
622
+ *
623
+ * @param node - The root of the walk
624
+ * @param headings - The accumulator array for collected heading data
625
+ */
626
+ private collectHeadings(
627
+ node: MdastNode,
628
+ headings: HeadingNodeData[],
629
+ ): void {
630
+ const pending: MdastNode[] = [node];
631
+
632
+ while (pending.length > 0) {
633
+ const current: MdastNode | undefined = pending.pop();
634
+
635
+ if (current === undefined) {
636
+ break;
637
+ }
638
+
639
+ if (current.type === 'heading' && current.depth !== undefined) {
640
+ headings.push(this.extractHeadingNodeData(current));
641
+ }
642
+
643
+ PerDocumentVisitor.pushChildren(current, pending);
644
+ }
645
+ }
646
+
647
+ /**
648
+ * Reads a node's plain text: what a reader sees, with the formatting gone.
649
+ *
650
+ * @param node - The node to read
651
+ * @returns Its text, and its descendants' text, in order
652
+ */
653
+ private static showNodeText(node: MdastNode): string {
654
+ // What `mdast-util-to-string` returns, gathered over a stack instead of
655
+ // by recursion: a node's own value, else an image's alternative text,
656
+ // else its children's text in order. That package recurses, so a heading
657
+ // of a few thousand nested emphasis spans -- which costs an author two
658
+ // characters a level -- ended the process inside it.
659
+ const parts: string[] = [];
660
+ const pending: MdastNode[] = [node];
661
+
662
+ while (pending.length > 0) {
663
+ const current: MdastNode | undefined = pending.pop();
664
+
665
+ if (current === undefined) {
666
+ break;
667
+ }
668
+
669
+ if (current.value !== undefined) {
670
+ parts.push(current.value);
671
+ continue;
672
+ }
673
+
674
+ if (typeof current.alt === 'string' && current.alt.length > 0) {
675
+ parts.push(current.alt);
676
+ continue;
677
+ }
678
+
679
+ PerDocumentVisitor.pushChildren(current, pending);
680
+ }
681
+
682
+ return parts.join('');
683
+ }
684
+
685
+ /**
686
+ * Puts a node's children on a walk's stack so that they come off it in
687
+ * document order.
688
+ *
689
+ * Every walk over a document's nodes keeps its own stack rather than
690
+ * calling itself, because nesting in Markdown costs the author almost
691
+ * nothing: a few thousand nested blockquotes, or nested bold spans, fit in
692
+ * a few kilobytes, and recursion over them exhausts the call stack and
693
+ * takes the process with it. A stack on the heap has no such limit.
694
+ *
695
+ * @param node - The node whose children are to be walked
696
+ * @param pending - The stack to push onto, from which nodes are taken with `pop`
697
+ */
698
+ private static pushChildren(node: MdastNode, pending: MdastNode[]): void {
699
+ const children: readonly MdastNode[] | undefined = node.children;
700
+
701
+ if (children === undefined) {
702
+ return;
703
+ }
704
+
705
+ for (let index: number = children.length - 1; index >= 0; index -= 1) {
706
+ const child: MdastNode | undefined = children[index];
707
+
708
+ if (child !== undefined) {
709
+ pending.push(child);
710
+ }
711
+ }
712
+ }
713
+
714
+ /**
715
+ * Finds the References section and feeds its placement and entries to ECR103.
716
+ *
717
+ * The `## References` heading is looked for anywhere in the tree, not only
718
+ * among the root's children. A section nested in a blockquote or a list
719
+ * item used to be passed over, so its entries were silently discarded and
720
+ * the document reported as having an empty References section. It is now
721
+ * found, reported as misplaced (1#9.11 rule 3), and its entries still read.
722
+ *
723
+ * The entries are the items of the list that immediately follows the
724
+ * heading, or of a list that opens the container immediately following it
725
+ * -- which is also a placement violation.
726
+ *
727
+ * @param root - The parsed MDAST root node
728
+ * @param referencesSectionRule - The ECR103 rule instance to feed
729
+ */
730
+ private feedListItemsToReferencesRule(
731
+ root: MdastRoot,
732
+ referencesSectionRule: ReferencesSectionRule,
733
+ ): void {
734
+ if (!referencesSectionRule.tellReferencesHeadingDetected()) {
735
+ return;
736
+ }
737
+
738
+ const section: ReferencesSectionLocation | undefined = this.findReferencesSection(root);
739
+
740
+ if (section === undefined) {
741
+ return;
742
+ }
743
+
744
+ const next: MdastNode | undefined = section.siblings[section.index + 1];
745
+ const list: MdastNode | undefined = this.findReferencesList(next);
746
+ const nested: boolean = section.siblings !== root.children || (list !== undefined && list !== next);
747
+
748
+ referencesSectionRule.evaluateSectionPlacement(
749
+ this.mapPosition(section.heading.position),
750
+ nested,
751
+ );
752
+
753
+ for (const listItemNode of list?.children ?? []) {
754
+ if (listItemNode.type === 'listItem') {
755
+ referencesSectionRule.evaluateListItem(this.extractListItemNodeData(listItemNode));
756
+ }
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Finds the list holding the References entries, given the node after the
762
+ * heading: that node itself, or a list opening the container it is, at any
763
+ * depth of nesting.
764
+ *
765
+ * Looking one container deep lost `> > - 8.1 - …`: the list was never
766
+ * found, so its entries were discarded and the section reported as empty.
767
+ *
768
+ * @param next - The node immediately following the `## References` heading
769
+ * @returns The entries' list, or `undefined` when there is none
770
+ */
771
+ private findReferencesList(next: MdastNode | undefined): MdastNode | undefined {
772
+ let candidate: MdastNode | undefined = next;
773
+
774
+ while (candidate !== undefined && candidate.type !== 'heading') {
775
+ if (candidate.type === 'list') {
776
+ return candidate;
777
+ }
778
+
779
+ candidate = candidate.children?.[0];
780
+ }
781
+
782
+ return undefined;
783
+ }
784
+
785
+ /**
786
+ * Finds the first `## References` heading in document order, at any depth.
787
+ *
788
+ * @param node - The node to search from
789
+ * @returns The heading and its position among its siblings, or `undefined`
790
+ */
791
+ private findReferencesSection(node: MdastNode | MdastRoot): ReferencesSectionLocation | undefined {
792
+ // Each frame is a list of siblings and how far through it the walk is, so
793
+ // that a match can report the position among its siblings that the caller
794
+ // needs. Depth-first and pre-order, as the recursive form was.
795
+ const frames: SiblingsFrame[] = [{ children: node.children ?? [], index: 0 }];
796
+
797
+ while (frames.length > 0) {
798
+ const frame: SiblingsFrame | undefined = frames.pop();
799
+
800
+ if (frame === undefined) {
801
+ break;
802
+ }
803
+
804
+ const child: MdastNode | undefined = frame.children[frame.index];
805
+
806
+ if (child === undefined) {
807
+ // These siblings are exhausted, so the frame is not put back.
808
+ continue;
809
+ }
810
+
811
+ // The rest of these siblings come after everything beneath this child,
812
+ // so they go back on the stack before it.
813
+ frames.push({ children: frame.children, index: frame.index + 1 });
814
+
815
+ if (child.type === 'heading' && this.tellReferencesHeading(this.extractHeadingNodeData(child))) {
816
+ return { heading: child, siblings: frame.children, index: frame.index };
817
+ }
818
+
819
+ frames.push({ children: child.children ?? [], index: 0 });
820
+ }
821
+
822
+ return undefined;
823
+ }
824
+
825
+ // -------------------------------------------------------------------------
826
+ // Private: Second pass -- inline references (ECR104)
827
+ // -------------------------------------------------------------------------
828
+
829
+ /**
830
+ * Executes the second AST traversal pass: inline reference detection.
831
+ *
832
+ * This pass walks the AST in document order using a custom recursive
833
+ * traversal that tracks ancestor node types and:
834
+ * - Tracks the current section context (the most recently encountered
835
+ * heading identifier), starting with the DocID before any H2 heading
836
+ * - For each text node encountered, checks whether the node's ancestor
837
+ * chain includes any excluded node types (`code`, `inlineCode`, `html`)
838
+ * - Text nodes whose ancestors include excluded types are skipped
839
+ * - Valid text nodes are fed to ECR104
840
+ * (`InlineReferenceRule.evaluateTextNode`) with the current
841
+ * section context
842
+ * - After traversal, ECR104 is finalised
843
+ *
844
+ * @param root - The parsed MDAST root node
845
+ * @param docId - The document's established DocID
846
+ * @param declaredDocIds - The set of DocIDs declared in the References section
847
+ * @param sourceText - The document's raw Markdown, for the source-form checks of 1#9.11
848
+ * @returns The intermediate result from the inline references pass
849
+ */
850
+ private executeInlineReferencesPass(
851
+ root: MdastRoot,
852
+ docId: DocID,
853
+ declaredDocIds: ReadonlySet<DocID>,
854
+ sourceText: string,
855
+ ): InlineReferencesPassResult {
856
+ const inlineReferenceRule: InlineReferenceRule = new InlineReferenceRule({
857
+ uri: this.uri,
858
+ docId,
859
+ grammar: this.grammar,
860
+ declaredDocIds,
861
+ sourceText,
862
+ });
863
+
864
+ // Track the current section context -- starts with the DocID.
865
+ // The mutable wrapper allows the recursive walk callback to update
866
+ // the section context as headings are encountered.
867
+ const sectionContext: SectionContextTracker = { current: docId };
868
+
869
+ // Walk the AST recursively, tracking ancestor types for exclusion filtering.
870
+ // When a heading is encountered, update the section context.
871
+ // When a text node is encountered (not excluded by ancestors), feed to ECR104.
872
+ this.walkNodesForInlineReferences(
873
+ root,
874
+ sectionContext,
875
+ inlineReferenceRule,
876
+ );
877
+
878
+ const inlineResult: InlineReferenceRuleResult =
879
+ inlineReferenceRule.finalise();
880
+
881
+ return { inlineResult };
882
+ }
883
+
884
+ /**
885
+ * Walks the AST to find inline runs for ECR104, tracking whether an
886
+ * excluded ancestor encloses the node and updating section context when
887
+ * headings are encountered.
888
+ *
889
+ * A paragraph is handed to ECR104 whole, as one inline run, because a
890
+ * citation is recognised in the text a reader sees and not one parsed text
891
+ * node at a time (1#9.5 rule 1). Its children are not walked separately.
892
+ *
893
+ * A hand-written depth-first, pre-order walk over an explicit stack: document
894
+ * order decides which section each run belongs to, and see
895
+ * {@link PerDocumentVisitor.pushChildren} for why the stack is not the call
896
+ * stack. Exclusion is carried down as one flag, because a node is excluded
897
+ * exactly when some ancestor's type is, so nothing is gained by keeping the
898
+ * ancestors themselves.
899
+ *
900
+ * @param root - The root of the walk
901
+ * @param sectionContext - Mutable wrapper holding the current section context
902
+ * @param inlineReferenceRule - The ECR104 rule instance to feed runs to
903
+ */
904
+ private walkNodesForInlineReferences(
905
+ root: MdastNode,
906
+ sectionContext: SectionContextTracker,
907
+ inlineReferenceRule: InlineReferenceRule,
908
+ ): void {
909
+ const pending: InlineWalkEntry[] = [{ node: root, excluded: false }];
910
+
911
+ while (pending.length > 0) {
912
+ const entry: InlineWalkEntry | undefined = pending.pop();
913
+
914
+ if (entry === undefined) {
915
+ break;
916
+ }
917
+
918
+ const { node, excluded } = entry;
919
+
920
+ if (node.type === 'heading' && node.depth !== undefined) {
921
+ sectionContext.current = this.determineSectionContext(
922
+ PerDocumentVisitor.showNodeText(node),
923
+ node.depth,
924
+ sectionContext.current,
925
+ );
926
+
927
+ // Do not walk into heading children for text node extraction;
928
+ // heading text is not subject to inline reference detection.
929
+ continue;
930
+ }
931
+
932
+ if (INLINE_RUN_NODE_TYPES.has(node.type) && node.children !== undefined) {
933
+ if (!excluded) {
934
+ const segments: InlineSegment[] = [];
935
+
936
+ for (const child of node.children) {
937
+ this.collectInlineSegments(child, [], segments);
938
+ }
939
+
940
+ inlineReferenceRule.evaluateInlineRun(segments, sectionContext.current);
941
+ }
942
+
943
+ continue;
944
+ }
945
+
946
+ // A text node outside any run is not expected from the parser, but if one
947
+ // appears it is still evaluated, as a run of its own.
948
+ if (node.type === 'text' && node.value !== undefined) {
949
+ if (!excluded) {
950
+ const textNodeData: TextNodeData = this.extractTextNodeData(node);
951
+ inlineReferenceRule.evaluateTextNode(textNodeData, sectionContext.current);
952
+ }
953
+
954
+ continue;
955
+ }
956
+
957
+ PerDocumentVisitor.pushInlineWalkChildren(node, excluded, pending);
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Puts a node's children on the inline walk's stack, in document order,
963
+ * noting whether an excluded ancestor now encloses them.
964
+ *
965
+ * @param node - The node whose children are to be walked
966
+ * @param excluded - Whether an excluded ancestor already encloses the node
967
+ * @param pending - The stack to push onto
968
+ */
969
+ private static pushInlineWalkChildren(
970
+ node: MdastNode,
971
+ excluded: boolean,
972
+ pending: InlineWalkEntry[],
973
+ ): void {
974
+ const children: readonly MdastNode[] | undefined = node.children;
975
+
976
+ if (children === undefined) {
977
+ return;
978
+ }
979
+
980
+ const childrenExcluded: boolean = excluded || EXCLUDED_ANCESTOR_NODE_TYPES.has(node.type);
981
+
982
+ for (let index: number = children.length - 1; index >= 0; index -= 1) {
983
+ const child: MdastNode | undefined = children[index];
984
+
985
+ if (child !== undefined) {
986
+ pending.push({ node: child, excluded: childrenExcluded });
987
+ }
988
+ }
989
+ }
990
+
991
+ /**
992
+ * Flattens one inline node into the segments a reader sees, in order.
993
+ *
994
+ * Text becomes a `text` segment carrying its position, and inline code a
995
+ * `code` segment. Formatting contributes its contents, each segment noting
996
+ * the spans around it. A hard break, an image, and a `<br>` tag become a
997
+ * `break` of one space, because a reader sees the words either side of them
998
+ * as separate. Any other inline HTML -- a comment, or a tag such as
999
+ * `<span>` -- contributes nothing, because it separates nothing:
1000
+ * `60<span>s</span>` reads as `60s`.
1001
+ *
1002
+ * @param node - An inline node
1003
+ * @param wrappers - Types of the formatting spans enclosing it, outermost first
1004
+ * @param segments - Accumulator the segments are appended to
1005
+ */
1006
+ private collectInlineSegments(
1007
+ node: MdastNode,
1008
+ wrappers: readonly string[],
1009
+ segments: InlineSegment[],
1010
+ ): void {
1011
+ const pending: SegmentWalkEntry[] = [{ node, wrappers }];
1012
+
1013
+ while (pending.length > 0) {
1014
+ const entry: SegmentWalkEntry | undefined = pending.pop();
1015
+
1016
+ if (entry === undefined) {
1017
+ break;
1018
+ }
1019
+
1020
+ this.collectInlineSegment(entry, segments, pending);
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Turns one inline node into segments, or puts its children on the stack
1026
+ * to be turned into segments in their turn.
1027
+ *
1028
+ * @param entry - The node and the formatting spans enclosing it
1029
+ * @param segments - Accumulator the segments are appended to
1030
+ * @param pending - The walk's stack, which children are pushed onto in document order
1031
+ */
1032
+ private collectInlineSegment(
1033
+ entry: SegmentWalkEntry,
1034
+ segments: InlineSegment[],
1035
+ pending: SegmentWalkEntry[],
1036
+ ): void {
1037
+ const { node, wrappers } = entry;
1038
+
1039
+ if (node.type === 'text') {
1040
+ const range: PositionRange | undefined = this.mapPosition(node.position);
1041
+
1042
+ segments.push({
1043
+ kind: 'text',
1044
+ text: node.value ?? '',
1045
+ wrappers,
1046
+ ...(range !== undefined ? { range } : {}),
1047
+ });
1048
+ return;
1049
+ }
1050
+
1051
+ if (node.type === 'inlineCode') {
1052
+ segments.push({ kind: 'code', text: node.value ?? '', wrappers });
1053
+ return;
1054
+ }
1055
+
1056
+ if (node.type === 'html' && !LINE_BREAK_HTML.test((node.value ?? '').trim())) {
1057
+ return;
1058
+ }
1059
+
1060
+ if (node.type !== 'html' && node.children !== undefined) {
1061
+ const inner: readonly string[] = [...wrappers, node.type];
1062
+
1063
+ for (let index: number = node.children.length - 1; index >= 0; index -= 1) {
1064
+ const child: MdastNode | undefined = node.children[index];
1065
+
1066
+ if (child !== undefined) {
1067
+ pending.push({ node: child, wrappers: inner });
1068
+ }
1069
+ }
1070
+
1071
+ return;
1072
+ }
1073
+
1074
+ segments.push({ kind: 'break', text: ' ', wrappers });
1075
+ }
1076
+
1077
+ // -------------------------------------------------------------------------
1078
+ // Private: Section context tracking
1079
+ // -------------------------------------------------------------------------
1080
+
1081
+ /**
1082
+ * Determines the current section context identifier for a given heading.
1083
+ *
1084
+ * The section context is the identifier (DocID or SectionID) of the most
1085
+ * recently encountered numbered heading. Before any H2 heading, the context
1086
+ * is the DocID. After an H2 or deeper heading, the context is the
1087
+ * SectionID of that heading.
1088
+ *
1089
+ * This method extracts the identifier from the heading text using the
1090
+ * grammar's separator convention. If the heading text does not contain
1091
+ * a valid identifier, the previous context is retained.
1092
+ *
1093
+ * @param headingText - The plain text content of the heading node
1094
+ * @param headingDepth - The Markdown heading depth (1--6)
1095
+ * @param currentContext - The current section context before this heading
1096
+ * @returns The updated section context identifier
1097
+ */
1098
+ private determineSectionContext(
1099
+ headingText: string,
1100
+ headingDepth: number,
1101
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be DocID or SectionID
1102
+ currentContext: DocID | SectionID,
1103
+ ): // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: return may be DocID or SectionID
1104
+ DocID | SectionID {
1105
+ // Only headings with depth >= 2 can change the section context
1106
+ // (H1 establishes the DocID, which is the initial context)
1107
+ if (headingDepth < 2) {
1108
+ return currentContext;
1109
+ }
1110
+
1111
+ // A sub-heading carries a SectionID, so it is parsed with the SectionID
1112
+ // heading grammar rather than the DocID one an H1 uses.
1113
+ const parseResult: ReturnType<IdentifierGrammar['parseSectionHeading']> =
1114
+ this.grammar.parseSectionHeading(headingText);
1115
+
1116
+ if (parseResult.valid) {
1117
+ return parseResult.sectionId;
1118
+ }
1119
+
1120
+ return currentContext;
1121
+ }
1122
+
1123
+ // -------------------------------------------------------------------------
1124
+ // Private: References heading detection
1125
+ // -------------------------------------------------------------------------
1126
+
1127
+ /**
1128
+ * Determines whether a heading is the `## References` structural heading.
1129
+ *
1130
+ * The `## References` heading is owned by ECR103 and should not be
1131
+ * evaluated by ECR102 (Section Hierarchy), because it is not a numbered
1132
+ * section heading and would fail SectionID parsing.
1133
+ *
1134
+ * Per 1#9.6, the References heading is identified by depth === 2
1135
+ * and text content exactly equal to `"References"`.
1136
+ *
1137
+ * @param headingData - The heading node data to check
1138
+ * @returns `true` if the heading is the `## References` heading, `false` otherwise
1139
+ */
1140
+ private tellReferencesHeading(headingData: HeadingNodeData): boolean {
1141
+ return headingData.depth === 2 && headingData.text === 'References';
1142
+ }
1143
+
1144
+ // -------------------------------------------------------------------------
1145
+ // Private: Text extraction from list items
1146
+ // -------------------------------------------------------------------------
1147
+
1148
+ /**
1149
+ * Extracts the plain text content from a list item AST node.
1150
+ *
1151
+ * Gathers the text of the list item's child nodes into a single plain text
1152
+ * string suitable for feeding to ECR103.
1153
+ *
1154
+ * @param listItemNode - The MDAST list item node to extract text from
1155
+ * @returns The extracted {@link ListItemNodeData} with text and optional range
1156
+ */
1157
+ private extractListItemNodeData(
1158
+ listItemNode: MdastNode,
1159
+ ): ListItemNodeData {
1160
+ const segments: ListItemSegment[] = [];
1161
+
1162
+ this.collectListItemSegments(listItemNode, segments);
1163
+
1164
+ const text: string = segments.map((segment: ListItemSegment): string => segment.text).join('');
1165
+ const range: PositionRange | undefined =
1166
+ this.mapPosition(listItemNode.position);
1167
+
1168
+ const listItemData: ListItemNodeData = {
1169
+ text,
1170
+ segments,
1171
+ ...(range !== undefined ? { range } : {}),
1172
+ };
1173
+
1174
+ return listItemData;
1175
+ }
1176
+
1177
+ /**
1178
+ * Classifies a literal node for tracing its text back to the source.
1179
+ *
1180
+ * @param type - The node's MDAST type
1181
+ * @returns `text` for a text node, `code` for inline code, `other` otherwise
1182
+ */
1183
+ private static showSegmentKind(type: string): ListItemSegment['kind'] {
1184
+ if (type === 'text') {
1185
+ return 'text';
1186
+ }
1187
+
1188
+ return type === 'inlineCode' ? 'code' : 'other';
1189
+ }
1190
+
1191
+ /**
1192
+ * Splits a list item's text into the parsed nodes it comes from.
1193
+ *
1194
+ * Follows `mdast-util-to-string` exactly -- a node's `value`, else an
1195
+ * image's `alt`, else its children in order -- so that joining the
1196
+ * segments reproduces the entry text the rule parses, and an offset in that
1197
+ * text identifies the node, and so the source, it came from.
1198
+ *
1199
+ * @param node - A node within the list item
1200
+ * @param segments - Accumulator the segments are appended to
1201
+ */
1202
+ private collectListItemSegments(node: MdastNode, segments: ListItemSegment[]): void {
1203
+ const pending: MdastNode[] = [node];
1204
+
1205
+ while (pending.length > 0) {
1206
+ const current: MdastNode | undefined = pending.pop();
1207
+
1208
+ if (current === undefined) {
1209
+ break;
1210
+ }
1211
+
1212
+ const range: PositionRange | undefined = this.mapPosition(current.position);
1213
+ const withRange: { readonly range?: PositionRange } = range !== undefined ? { range } : {};
1214
+
1215
+ if (current.value !== undefined) {
1216
+ segments.push({
1217
+ kind: PerDocumentVisitor.showSegmentKind(current.type),
1218
+ text: current.value,
1219
+ ...withRange,
1220
+ });
1221
+ continue;
1222
+ }
1223
+
1224
+ if (typeof current.alt === 'string' && current.alt.length > 0) {
1225
+ segments.push({ kind: 'other', text: current.alt, ...withRange });
1226
+ continue;
1227
+ }
1228
+
1229
+ PerDocumentVisitor.pushChildren(current, pending);
1230
+ }
1231
+ }
1232
+
1233
+ // -------------------------------------------------------------------------
1234
+ // Private: Heading data extraction
1235
+ // -------------------------------------------------------------------------
1236
+
1237
+ /**
1238
+ * Extracts heading node data from an MDAST heading node.
1239
+ *
1240
+ * Reads the plain text content with {@link PerDocumentVisitor.showNodeText}
1241
+ * and maps the MDAST position to a {@link HeadingNodeData} structure
1242
+ * suitable for consumption by ECR101, ECR102, and ECR103.
1243
+ *
1244
+ * @param headingNode - The MDAST heading node to extract data from
1245
+ * @returns The extracted {@link HeadingNodeData} with depth, text, and optional range
1246
+ */
1247
+ private extractHeadingNodeData(
1248
+ headingNode: MdastNode,
1249
+ ): HeadingNodeData {
1250
+ const text: string = PerDocumentVisitor.showNodeText(headingNode);
1251
+ const range: PositionRange | undefined =
1252
+ this.mapPosition(headingNode.position);
1253
+
1254
+ const headingData: HeadingNodeData = {
1255
+ depth: headingNode.depth ?? 1,
1256
+ text,
1257
+ ...(range !== undefined ? { range } : {}),
1258
+ };
1259
+
1260
+ return headingData;
1261
+ }
1262
+
1263
+ // -------------------------------------------------------------------------
1264
+ // Private: Text node data extraction
1265
+ // -------------------------------------------------------------------------
1266
+
1267
+ /**
1268
+ * Extracts text node data from an MDAST text node.
1269
+ *
1270
+ * Maps the MDAST text node's value and position to a {@link TextNodeData}
1271
+ * structure suitable for consumption by ECR104.
1272
+ *
1273
+ * @param textNode - The MDAST text node to extract data from
1274
+ * @returns The extracted {@link TextNodeData} with text and optional range
1275
+ */
1276
+ private extractTextNodeData(
1277
+ textNode: MdastNode,
1278
+ ): TextNodeData {
1279
+ const range: PositionRange | undefined =
1280
+ this.mapPosition(textNode.position);
1281
+
1282
+ const textNodeData: TextNodeData = {
1283
+ text: textNode.value ?? '',
1284
+ ...(range !== undefined ? { range } : {}),
1285
+ };
1286
+
1287
+ return textNodeData;
1288
+ }
1289
+
1290
+ // -------------------------------------------------------------------------
1291
+ // Private: Declared DocID extraction
1292
+ // -------------------------------------------------------------------------
1293
+
1294
+ /**
1295
+ * Extracts the set of declared DocIDs from a completed ECR103 result.
1296
+ *
1297
+ * Iterates over the extracted {@link ReferenceEdge} artefacts and
1298
+ * collects the `toDocId` field from each edge into a `ReadonlySet<DocID>`.
1299
+ * This set is used to initialise ECR104 for undeclared reference detection.
1300
+ *
1301
+ * @param references - The extracted reference edges from ECR103
1302
+ * @returns The set of declared DocIDs
1303
+ */
1304
+ private extractDeclaredDocIds(
1305
+ references: readonly ReferenceEdge[],
1306
+ ): ReadonlySet<DocID> {
1307
+ const declaredDocIds: Set<DocID> = new Set<DocID>();
1308
+
1309
+ for (const referenceEdge of references) {
1310
+ declaredDocIds.add(referenceEdge.toDocId);
1311
+ }
1312
+
1313
+ return declaredDocIds;
1314
+ }
1315
+
1316
+ // -------------------------------------------------------------------------
1317
+ // Private: Position mapping
1318
+ // -------------------------------------------------------------------------
1319
+
1320
+ /**
1321
+ * Maps an MDAST position to a {@link PositionRange}, if available.
1322
+ *
1323
+ * MDAST positions use 1-based lines and 1-based columns, while
1324
+ * {@link PositionRange} uses 0-based lines and 0-based characters.
1325
+ * This method performs the conversion.
1326
+ *
1327
+ * @param mdastPosition - The MDAST position object, or `undefined` if
1328
+ * positional metadata is not available
1329
+ * @returns The mapped position range, or `undefined` if no position is available
1330
+ */
1331
+ private mapPosition(
1332
+ mdastPosition: MdastPosition | undefined,
1333
+ ): PositionRange | undefined {
1334
+ if (mdastPosition === undefined) {
1335
+ return undefined;
1336
+ }
1337
+
1338
+ const mappedRange: PositionRange = {
1339
+ start: {
1340
+ line: mdastPosition.start.line - 1,
1341
+ character: mdastPosition.start.column - 1,
1342
+ },
1343
+ end: {
1344
+ line: mdastPosition.end.line - 1,
1345
+ character: mdastPosition.end.column - 1,
1346
+ },
1347
+ };
1348
+
1349
+ return mappedRange;
1350
+ }
1351
+
1352
+ // -------------------------------------------------------------------------
1353
+ // Private: Result assembly
1354
+ // -------------------------------------------------------------------------
1355
+
1356
+ /**
1357
+ * Assembles the composite {@link LintResult} from individual rule results.
1358
+ *
1359
+ * Aggregates diagnostics from all rules, determines the `ok` status
1360
+ * (true when no error-severity diagnostics are present), and constructs
1361
+ * the {@link ExtractedDocument} when a valid DocID was recovered.
1362
+ *
1363
+ * @param identityResult - The result from ECR101
1364
+ * @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
1365
+ * @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
1366
+ * @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
1367
+ * @returns The composite lint result
1368
+ */
1369
+ private assembleLintResult(
1370
+ identityResult: DocumentIdentityRuleResult,
1371
+ sectionsResult: SectionHierarchyRuleResult | undefined,
1372
+ referencesResult: ReferencesSectionRuleResult | undefined,
1373
+ inlineResult: InlineReferenceRuleResult | undefined,
1374
+ ): LintResult {
1375
+ // Aggregate all diagnostics
1376
+ const allDiagnostics: readonly Diagnostic[] = this.aggregateDiagnostics(
1377
+ identityResult,
1378
+ sectionsResult,
1379
+ referencesResult,
1380
+ inlineResult,
1381
+ );
1382
+
1383
+ const input: LintInput = this.buildLintInput();
1384
+ const isPassable: boolean = this.tellAllDiagnosticsPassable(allDiagnostics);
1385
+
1386
+ // Construct extracted document if identity is valid
1387
+ if (identityResult.identity !== undefined) {
1388
+ const identity: DocumentIdentity = identityResult.identity;
1389
+ const sections: readonly SectionNode[] =
1390
+ sectionsResult !== undefined ? sectionsResult.sections : [];
1391
+ const references: readonly ReferenceEdge[] =
1392
+ referencesResult !== undefined ? referencesResult.references : [];
1393
+ const inlineReferences: readonly InlineReferenceEdge[] =
1394
+ inlineResult !== undefined ? inlineResult.inlineReferences : [];
1395
+
1396
+ const extracted: ExtractedDocument = this.buildExtractedDocument(
1397
+ identity,
1398
+ sections,
1399
+ references,
1400
+ inlineReferences,
1401
+ );
1402
+
1403
+ return {
1404
+ input,
1405
+ ok: isPassable,
1406
+ diagnostics: allDiagnostics,
1407
+ extracted,
1408
+ };
1409
+ }
1410
+
1411
+ return {
1412
+ input,
1413
+ ok: isPassable,
1414
+ diagnostics: allDiagnostics,
1415
+ };
1416
+ }
1417
+
1418
+ /**
1419
+ * Aggregates diagnostics from all rule results into a single array.
1420
+ *
1421
+ * Collects diagnostics from ECR101, ECR102, ECR103, and ECR104 in that
1422
+ * order. Rules that did not run (undefined results) contribute no
1423
+ * diagnostics.
1424
+ *
1425
+ * @param identityResult - The result from ECR101
1426
+ * @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
1427
+ * @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
1428
+ * @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
1429
+ * @returns The aggregated array of all diagnostics
1430
+ */
1431
+ private aggregateDiagnostics(
1432
+ identityResult: DocumentIdentityRuleResult,
1433
+ sectionsResult: SectionHierarchyRuleResult | undefined,
1434
+ referencesResult: ReferencesSectionRuleResult | undefined,
1435
+ inlineResult: InlineReferenceRuleResult | undefined,
1436
+ ): readonly Diagnostic[] {
1437
+ const allDiagnostics: Diagnostic[] = [];
1438
+
1439
+ for (const diagnostic of identityResult.diagnostics) {
1440
+ allDiagnostics.push(diagnostic);
1441
+ }
1442
+
1443
+ if (sectionsResult !== undefined) {
1444
+ for (const diagnostic of sectionsResult.diagnostics) {
1445
+ allDiagnostics.push(diagnostic);
1446
+ }
1447
+ }
1448
+
1449
+ if (referencesResult !== undefined) {
1450
+ for (const diagnostic of referencesResult.diagnostics) {
1451
+ allDiagnostics.push(diagnostic);
1452
+ }
1453
+ }
1454
+
1455
+ if (inlineResult !== undefined) {
1456
+ for (const diagnostic of inlineResult.diagnostics) {
1457
+ allDiagnostics.push(diagnostic);
1458
+ }
1459
+ }
1460
+
1461
+ return allDiagnostics;
1462
+ }
1463
+
1464
+ /**
1465
+ * Constructs the {@link LintInput} descriptor for the result.
1466
+ *
1467
+ * Echoes the visitor's URI and optional version into the standard
1468
+ * input descriptor shape.
1469
+ *
1470
+ * @returns The lint input descriptor
1471
+ */
1472
+ private buildLintInput(): LintInput {
1473
+ const input: LintInput = {
1474
+ uri: this.uri,
1475
+ ...(this.version !== undefined ? { version: this.version } : {}),
1476
+ };
1477
+
1478
+ return input;
1479
+ }
1480
+
1481
+ /**
1482
+ * Determines the composite `ok` status from an aggregated diagnostics array.
1483
+ *
1484
+ * The result is `ok` (true) when no diagnostics have severity `"error"`.
1485
+ * Any error-severity diagnostic causes the result to be not-ok (false).
1486
+ *
1487
+ * @param diagnostics - The full array of aggregated diagnostics from all rules
1488
+ * @returns `true` if no error diagnostics are present, `false` otherwise
1489
+ */
1490
+ private tellAllDiagnosticsPassable(
1491
+ diagnostics: readonly Diagnostic[],
1492
+ ): boolean {
1493
+ return !diagnostics.some(
1494
+ (diagnostic: Diagnostic): boolean => diagnostic.severity === 'error',
1495
+ );
1496
+ }
1497
+
1498
+ /**
1499
+ * Constructs the {@link ExtractedDocument} from individual rule extraction
1500
+ * results.
1501
+ *
1502
+ * Assembles the document identity (DocID and title), section nodes,
1503
+ * reference edges, and inline reference edges into the canonical
1504
+ * extracted document shape.
1505
+ *
1506
+ * @param identity - The document identity from ECR101
1507
+ * @param sections - The section nodes from ECR102
1508
+ * @param references - The reference edges from ECR103
1509
+ * @param inlineReferences - The inline reference edges from ECR104
1510
+ * @returns The assembled extracted document
1511
+ */
1512
+ private buildExtractedDocument(
1513
+ identity: DocumentIdentity,
1514
+ sections: readonly SectionNode[],
1515
+ references: readonly ReferenceEdge[],
1516
+ inlineReferences: readonly InlineReferenceEdge[],
1517
+ ): ExtractedDocument {
1518
+ const extracted: ExtractedDocument = {
1519
+ docId: identity.docId,
1520
+ title: identity.title,
1521
+ sections,
1522
+ references,
1523
+ inlineReferences,
1524
+ };
1525
+
1526
+ return extracted;
1527
+ }
1528
+ }