@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,1032 @@
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
+ // Runtime imports (value imports for classes and functions)
32
+ // ---------------------------------------------------------------------------
33
+ import { unified } from 'unified';
34
+ import remarkParse from 'remark-parse';
35
+ // ---------------------------------------------------------------------------
36
+ // Value imports for rule classes and grammar
37
+ // ---------------------------------------------------------------------------
38
+ import { DocumentIdentityRule } from './document-identity-rule.js';
39
+ import { SectionHierarchyRule } from './section-hierarchy-rule.js';
40
+ import { ReferencesSectionRule } from './references-section-rule.js';
41
+ import { InlineReferenceRule } from './inline-reference-rule.js';
42
+ import { IdentifierGrammar } from './identifier-grammar.js';
43
+ // ---------------------------------------------------------------------------
44
+ // A document the parser could not read
45
+ // ---------------------------------------------------------------------------
46
+ /** Rule identifier reported when a document cannot be parsed at all. */
47
+ export const UNPARSABLE_DOCUMENT_RULE_ID = 'document/unparsable';
48
+ /** The `data.cause` of that diagnostic. */
49
+ export const UNPARSABLE_DOCUMENT_CAUSE = 'unparsable-document';
50
+ /** How much of the parser's own account of the failure to repeat. */
51
+ const UNPARSABLE_REASON_LIMIT = 200;
52
+ // ---------------------------------------------------------------------------
53
+ // Excluded ancestor node types for ECR104 filtering
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * MDAST node types whose descendant text nodes must be excluded from
57
+ * ECR104 inline reference detection.
58
+ *
59
+ * Per 1#9.5, text nodes inside code blocks, inline code, and HTML
60
+ * elements are not valid candidates for inline reference detection.
61
+ */
62
+ export const EXCLUDED_ANCESTOR_NODE_TYPES = new Set([
63
+ 'code',
64
+ 'inlineCode',
65
+ 'html',
66
+ ]);
67
+ /**
68
+ * MDAST node types whose children form one inline run: the text a reader
69
+ * sees, recognised as a whole (1#9.5 rule 1). Headings are excluded, since
70
+ * heading text is not subject to inline reference detection.
71
+ */
72
+ const INLINE_RUN_NODE_TYPES = new Set([
73
+ 'paragraph',
74
+ 'tableCell',
75
+ ]);
76
+ /**
77
+ * Inline HTML a reader sees as a line break: an opening `br` tag in any case,
78
+ * whatever follows its name -- `<br>`, `<BR/>`, `<br class="x">`. It separates
79
+ * the words either side of it. Other inline HTML does not, so it is
80
+ * transparent to recognition (1#9.5 rule 1).
81
+ *
82
+ * Only the tag's name is read. The parser has already delimited the whole
83
+ * tag as one node, so its attributes need no second parse here; matching
84
+ * them again assumed every `>` closed the tag, and `<br title="x > y">` was
85
+ * taken for transparent HTML.
86
+ */
87
+ const LINE_BREAK_HTML = /^<br(?=[\s/>])/i;
88
+ // ---------------------------------------------------------------------------
89
+ // Per-Document Visitor class
90
+ // ---------------------------------------------------------------------------
91
+ /**
92
+ * Orchestrates all four ECR rules (ECR101--ECR104) over a single Markdown
93
+ * document and produces a composite {@link LintResult}.
94
+ *
95
+ * The visitor is stateless between calls to {@link lint}. Each invocation
96
+ * produces a fresh result with no side effects on the visitor instance.
97
+ *
98
+ * The visitor creates its own {@link IdentifierGrammar} instance internally
99
+ * and manages the rule initialisation dependency chain:
100
+ * - ECR101 (Document Identity) runs first on all headings
101
+ * - If ECR101 produces a valid DocID, ECR102 (Section Hierarchy) and
102
+ * ECR103 (References Section) are initialised and fed their respective nodes
103
+ * - After ECR103 finalises, the set of declared DocIDs is extracted and
104
+ * used to initialise ECR104 (Inline Reference)
105
+ * - If ECR101 fails (no valid DocID), the result is returned early with
106
+ * `ok=false` and no `extracted` field
107
+ *
108
+ * Usage:
109
+ * ```ts
110
+ * const visitor = new PerDocumentVisitor({
111
+ * uri: 'file:///docs/3.1.md',
112
+ * version: 1,
113
+ * });
114
+ *
115
+ * const result: LintResult = visitor.lint('# 3.1 - My Document\n\n## References\n- 8.1 - ...');
116
+ * ```
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * const visitor = new PerDocumentVisitor({ uri: 'file:///docs/5.1.md' });
121
+ * const result: LintResult = visitor.lint(markdownText);
122
+ *
123
+ * if (result.ok) {
124
+ * console.log('Document is valid:', result.extracted?.docId);
125
+ * } else {
126
+ * console.log('Diagnostics:', result.diagnostics);
127
+ * }
128
+ * ```
129
+ */
130
+ export class PerDocumentVisitor {
131
+ /**
132
+ * The opaque, host-provided URI identifying the document being validated.
133
+ */
134
+ uri;
135
+ /**
136
+ * Optional version tag for the document instance.
137
+ */
138
+ version;
139
+ /**
140
+ * The {@link IdentifierGrammar} instance shared across all rules
141
+ * within a single visitor. Created once during construction.
142
+ */
143
+ grammar;
144
+ /**
145
+ * Constructs a new Per-Document Visitor.
146
+ *
147
+ * Creates an internal {@link IdentifierGrammar} instance for use
148
+ * across all rule evaluations performed by this visitor.
149
+ *
150
+ * @param options - Configuration including the document URI and optional version
151
+ */
152
+ constructor(options) {
153
+ this.uri = options.uri;
154
+ this.version = options.version;
155
+ this.grammar = new IdentifierGrammar();
156
+ }
157
+ /**
158
+ * Lints a single Markdown document and produces a composite {@link LintResult}.
159
+ *
160
+ * Each call is stateless: the visitor creates fresh rule instances,
161
+ * parses the Markdown text into an AST, walks the AST to feed nodes
162
+ * to the appropriate rules, finalises all rules, and assembles the result.
163
+ *
164
+ * The orchestration proceeds in phases:
165
+ * 1. Parse the Markdown text into an MDAST {@link MdastRoot} node
166
+ * 2. First pass: walk headings and list items for ECR101, ECR102, ECR103
167
+ * 3. If ECR101 fails (no valid DocID), return early with `ok=false`
168
+ * 4. Second pass: walk text nodes for ECR104 (with ancestor filtering
169
+ * and section context tracking)
170
+ * 5. Assemble the composite {@link LintResult} from all rule results
171
+ *
172
+ * @param markdownText - The raw Markdown text of the document to lint
173
+ * @returns The composite lint result including diagnostics and, when a valid
174
+ * DocID is recovered, extracted structural artefacts
175
+ */
176
+ lint(markdownText) {
177
+ let root;
178
+ try {
179
+ root = this.parseMarkdown(markdownText);
180
+ }
181
+ catch (error) {
182
+ return this.reportUnparsable(error);
183
+ }
184
+ const passOneResult = this.executeHeadingsAndReferencesPass(root, markdownText);
185
+ // If ECR101 failed (no valid identity), return early
186
+ if (passOneResult.identityResult.identity === undefined) {
187
+ return this.assembleLintResult(passOneResult.identityResult, undefined, undefined, undefined);
188
+ }
189
+ // ECR101 succeeded -- extract declaredDocIds from ECR103
190
+ const referencesResult = passOneResult.referencesResult;
191
+ const referencesForExtraction = referencesResult !== undefined ? referencesResult.references : [];
192
+ const declaredDocIds = this.extractDeclaredDocIds(referencesForExtraction);
193
+ // Run ECR104
194
+ const inlineResult = this.executeInlineReferencesPass(root, passOneResult.identityResult.identity.docId, declaredDocIds, markdownText);
195
+ return this.assembleLintResult(passOneResult.identityResult, passOneResult.sectionsResult, passOneResult.referencesResult, inlineResult.inlineResult);
196
+ }
197
+ // -------------------------------------------------------------------------
198
+ // Private: Markdown parsing
199
+ // -------------------------------------------------------------------------
200
+ /**
201
+ * Parses raw Markdown text into an MDAST {@link MdastRoot} node.
202
+ *
203
+ * Uses `unified` with `remark-parse` to produce a standards-compliant
204
+ * MDAST tree with positional metadata attached to all nodes.
205
+ *
206
+ * The `unified().use(remarkParse).parse()` call returns a type that
207
+ * cannot be directly assigned to our local {@link MdastRoot} interface
208
+ * because `mdast` types are not importable as a module specifier in this
209
+ * project. The cast via `unknown` is safe because `remark-parse` always
210
+ * produces an MDAST Root node at runtime.
211
+ *
212
+ * @param markdownText - The raw Markdown text to parse
213
+ * @returns The parsed MDAST root node
214
+ */
215
+ parseMarkdown(markdownText) {
216
+ const root = unified()
217
+ .use(remarkParse)
218
+ .parse(markdownText);
219
+ return root;
220
+ }
221
+ /**
222
+ * Reports a document the parser could not read.
223
+ *
224
+ * The parser walks a document's structure by recursion, inside a package
225
+ * this project does not control, so a deeply nested link label or image
226
+ * description can end the parse with a stack overflow. That must be one
227
+ * document's error, reported like any other, and never a crash that stops
228
+ * a corpus part way and leaves the rest unvalidated.
229
+ *
230
+ * @param error - What the parser threw
231
+ * @returns A failing result carrying one error diagnostic
232
+ */
233
+ reportUnparsable(error) {
234
+ const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
235
+ return {
236
+ input: this.buildLintInput(),
237
+ ok: false,
238
+ diagnostics: [
239
+ {
240
+ severity: 'error',
241
+ ruleId: UNPARSABLE_DOCUMENT_RULE_ID,
242
+ message: `Document could not be parsed, so none of it was validated ` +
243
+ `(${reason.slice(0, UNPARSABLE_REASON_LIMIT)}). Deeply nested Markdown is the ` +
244
+ `usual cause, because parsing it recurses.`,
245
+ uri: this.uri,
246
+ data: { cause: UNPARSABLE_DOCUMENT_CAUSE },
247
+ },
248
+ ],
249
+ };
250
+ }
251
+ // -------------------------------------------------------------------------
252
+ // Private: First pass -- headings, sections, and references
253
+ // -------------------------------------------------------------------------
254
+ /**
255
+ * Executes the first AST traversal pass: headings, sections, and references.
256
+ *
257
+ * This pass walks the AST in document order and:
258
+ * - Feeds every heading node to ECR101 (`DocumentIdentityRule.evaluateHeading`)
259
+ * - Finalises ECR101 to determine whether a valid DocID was recovered
260
+ * - If a valid DocID is recovered:
261
+ * - Registers the root H1 heading with ECR102
262
+ * (`SectionHierarchyRule.registerRootHeading`)
263
+ * - Feeds headings with depth \>= 2 to ECR102
264
+ * (`SectionHierarchyRule.evaluateHeading`)
265
+ * - Feeds all headings to ECR103
266
+ * (`ReferencesSectionRule.evaluateHeading`)
267
+ * - Feeds list items within the References section to ECR103
268
+ * (`ReferencesSectionRule.evaluateListItem`)
269
+ * - Finalises ECR102 and ECR103
270
+ * - If no valid DocID is recovered, ECR102 and ECR103 are skipped
271
+ *
272
+ * The pass is structured so that all heading nodes are collected first
273
+ * for ECR101, then the collected headings are replayed for ECR102 and ECR103.
274
+ *
275
+ * @param root - The parsed MDAST root node
276
+ * @param sourceText - The document's raw Markdown, for the heading source-form check of 1#9.11
277
+ * @returns The intermediate result from the headings and references pass
278
+ */
279
+ executeHeadingsAndReferencesPass(root, sourceText) {
280
+ // Phase 1: Collect all headings and feed to ECR101
281
+ const collectedHeadings = [];
282
+ this.collectHeadings(root, collectedHeadings);
283
+ const identityRule = new DocumentIdentityRule({
284
+ uri: this.uri,
285
+ grammar: this.grammar,
286
+ sourceText,
287
+ });
288
+ for (const headingData of collectedHeadings) {
289
+ identityRule.evaluateHeading(headingData);
290
+ }
291
+ const identityResult = identityRule.finalise();
292
+ // If ECR101 failed, return early -- no ECR102 or ECR103
293
+ if (identityResult.identity === undefined) {
294
+ return { identityResult };
295
+ }
296
+ // Phase 2: ECR101 succeeded -- initialise ECR102 and ECR103
297
+ const docId = identityResult.identity.docId;
298
+ const sectionHierarchyRule = new SectionHierarchyRule({
299
+ uri: this.uri,
300
+ docId,
301
+ grammar: this.grammar,
302
+ sourceText,
303
+ });
304
+ const referencesSectionRule = new ReferencesSectionRule({
305
+ uri: this.uri,
306
+ docId,
307
+ grammar: this.grammar,
308
+ sourceText,
309
+ });
310
+ // Feed collected headings to ECR102 and ECR103
311
+ for (const headingData of collectedHeadings) {
312
+ // ECR102: register root H1 or evaluate sub-headings.
313
+ // The `## References` heading is a structural heading owned by ECR103,
314
+ // not a numbered section heading — it must be excluded from ECR102
315
+ // to avoid a spurious "not a valid SectionID" diagnostic.
316
+ if (headingData.depth === 1) {
317
+ sectionHierarchyRule.registerRootHeading(headingData);
318
+ }
319
+ else if (!this.tellReferencesHeading(headingData)) {
320
+ sectionHierarchyRule.evaluateHeading(headingData);
321
+ }
322
+ // ECR103: evaluate all headings for References heading detection
323
+ referencesSectionRule.evaluateHeading(headingData);
324
+ }
325
+ // Phase 3: Walk the AST again to feed list items to ECR103
326
+ // after the References heading has been detected
327
+ this.feedListItemsToReferencesRule(root, referencesSectionRule);
328
+ // Finalise ECR102 and ECR103
329
+ const sectionsResult = sectionHierarchyRule.finalise();
330
+ const referencesResult = referencesSectionRule.finalise();
331
+ return {
332
+ identityResult,
333
+ sectionsResult,
334
+ referencesResult,
335
+ };
336
+ }
337
+ /**
338
+ * Collects all heading nodes from the AST into the provided array.
339
+ *
340
+ * Walks the AST depth-first in document order, extracting
341
+ * {@link HeadingNodeData} from every node with `type === 'heading'`.
342
+ *
343
+ * @param node - The root of the walk
344
+ * @param headings - The accumulator array for collected heading data
345
+ */
346
+ collectHeadings(node, headings) {
347
+ const pending = [node];
348
+ while (pending.length > 0) {
349
+ const current = pending.pop();
350
+ if (current === undefined) {
351
+ break;
352
+ }
353
+ if (current.type === 'heading' && current.depth !== undefined) {
354
+ headings.push(this.extractHeadingNodeData(current));
355
+ }
356
+ PerDocumentVisitor.pushChildren(current, pending);
357
+ }
358
+ }
359
+ /**
360
+ * Reads a node's plain text: what a reader sees, with the formatting gone.
361
+ *
362
+ * @param node - The node to read
363
+ * @returns Its text, and its descendants' text, in order
364
+ */
365
+ static showNodeText(node) {
366
+ // What `mdast-util-to-string` returns, gathered over a stack instead of
367
+ // by recursion: a node's own value, else an image's alternative text,
368
+ // else its children's text in order. That package recurses, so a heading
369
+ // of a few thousand nested emphasis spans -- which costs an author two
370
+ // characters a level -- ended the process inside it.
371
+ const parts = [];
372
+ const pending = [node];
373
+ while (pending.length > 0) {
374
+ const current = pending.pop();
375
+ if (current === undefined) {
376
+ break;
377
+ }
378
+ if (current.value !== undefined) {
379
+ parts.push(current.value);
380
+ continue;
381
+ }
382
+ if (typeof current.alt === 'string' && current.alt.length > 0) {
383
+ parts.push(current.alt);
384
+ continue;
385
+ }
386
+ PerDocumentVisitor.pushChildren(current, pending);
387
+ }
388
+ return parts.join('');
389
+ }
390
+ /**
391
+ * Puts a node's children on a walk's stack so that they come off it in
392
+ * document order.
393
+ *
394
+ * Every walk over a document's nodes keeps its own stack rather than
395
+ * calling itself, because nesting in Markdown costs the author almost
396
+ * nothing: a few thousand nested blockquotes, or nested bold spans, fit in
397
+ * a few kilobytes, and recursion over them exhausts the call stack and
398
+ * takes the process with it. A stack on the heap has no such limit.
399
+ *
400
+ * @param node - The node whose children are to be walked
401
+ * @param pending - The stack to push onto, from which nodes are taken with `pop`
402
+ */
403
+ static pushChildren(node, pending) {
404
+ const children = node.children;
405
+ if (children === undefined) {
406
+ return;
407
+ }
408
+ for (let index = children.length - 1; index >= 0; index -= 1) {
409
+ const child = children[index];
410
+ if (child !== undefined) {
411
+ pending.push(child);
412
+ }
413
+ }
414
+ }
415
+ /**
416
+ * Finds the References section and feeds its placement and entries to ECR103.
417
+ *
418
+ * The `## References` heading is looked for anywhere in the tree, not only
419
+ * among the root's children. A section nested in a blockquote or a list
420
+ * item used to be passed over, so its entries were silently discarded and
421
+ * the document reported as having an empty References section. It is now
422
+ * found, reported as misplaced (1#9.11 rule 3), and its entries still read.
423
+ *
424
+ * The entries are the items of the list that immediately follows the
425
+ * heading, or of a list that opens the container immediately following it
426
+ * -- which is also a placement violation.
427
+ *
428
+ * @param root - The parsed MDAST root node
429
+ * @param referencesSectionRule - The ECR103 rule instance to feed
430
+ */
431
+ feedListItemsToReferencesRule(root, referencesSectionRule) {
432
+ if (!referencesSectionRule.tellReferencesHeadingDetected()) {
433
+ return;
434
+ }
435
+ const section = this.findReferencesSection(root);
436
+ if (section === undefined) {
437
+ return;
438
+ }
439
+ const next = section.siblings[section.index + 1];
440
+ const list = this.findReferencesList(next);
441
+ const nested = section.siblings !== root.children || (list !== undefined && list !== next);
442
+ referencesSectionRule.evaluateSectionPlacement(this.mapPosition(section.heading.position), nested);
443
+ for (const listItemNode of list?.children ?? []) {
444
+ if (listItemNode.type === 'listItem') {
445
+ referencesSectionRule.evaluateListItem(this.extractListItemNodeData(listItemNode));
446
+ }
447
+ }
448
+ }
449
+ /**
450
+ * Finds the list holding the References entries, given the node after the
451
+ * heading: that node itself, or a list opening the container it is, at any
452
+ * depth of nesting.
453
+ *
454
+ * Looking one container deep lost `> > - 8.1 - …`: the list was never
455
+ * found, so its entries were discarded and the section reported as empty.
456
+ *
457
+ * @param next - The node immediately following the `## References` heading
458
+ * @returns The entries' list, or `undefined` when there is none
459
+ */
460
+ findReferencesList(next) {
461
+ let candidate = next;
462
+ while (candidate !== undefined && candidate.type !== 'heading') {
463
+ if (candidate.type === 'list') {
464
+ return candidate;
465
+ }
466
+ candidate = candidate.children?.[0];
467
+ }
468
+ return undefined;
469
+ }
470
+ /**
471
+ * Finds the first `## References` heading in document order, at any depth.
472
+ *
473
+ * @param node - The node to search from
474
+ * @returns The heading and its position among its siblings, or `undefined`
475
+ */
476
+ findReferencesSection(node) {
477
+ // Each frame is a list of siblings and how far through it the walk is, so
478
+ // that a match can report the position among its siblings that the caller
479
+ // needs. Depth-first and pre-order, as the recursive form was.
480
+ const frames = [{ children: node.children ?? [], index: 0 }];
481
+ while (frames.length > 0) {
482
+ const frame = frames.pop();
483
+ if (frame === undefined) {
484
+ break;
485
+ }
486
+ const child = frame.children[frame.index];
487
+ if (child === undefined) {
488
+ // These siblings are exhausted, so the frame is not put back.
489
+ continue;
490
+ }
491
+ // The rest of these siblings come after everything beneath this child,
492
+ // so they go back on the stack before it.
493
+ frames.push({ children: frame.children, index: frame.index + 1 });
494
+ if (child.type === 'heading' && this.tellReferencesHeading(this.extractHeadingNodeData(child))) {
495
+ return { heading: child, siblings: frame.children, index: frame.index };
496
+ }
497
+ frames.push({ children: child.children ?? [], index: 0 });
498
+ }
499
+ return undefined;
500
+ }
501
+ // -------------------------------------------------------------------------
502
+ // Private: Second pass -- inline references (ECR104)
503
+ // -------------------------------------------------------------------------
504
+ /**
505
+ * Executes the second AST traversal pass: inline reference detection.
506
+ *
507
+ * This pass walks the AST in document order using a custom recursive
508
+ * traversal that tracks ancestor node types and:
509
+ * - Tracks the current section context (the most recently encountered
510
+ * heading identifier), starting with the DocID before any H2 heading
511
+ * - For each text node encountered, checks whether the node's ancestor
512
+ * chain includes any excluded node types (`code`, `inlineCode`, `html`)
513
+ * - Text nodes whose ancestors include excluded types are skipped
514
+ * - Valid text nodes are fed to ECR104
515
+ * (`InlineReferenceRule.evaluateTextNode`) with the current
516
+ * section context
517
+ * - After traversal, ECR104 is finalised
518
+ *
519
+ * @param root - The parsed MDAST root node
520
+ * @param docId - The document's established DocID
521
+ * @param declaredDocIds - The set of DocIDs declared in the References section
522
+ * @param sourceText - The document's raw Markdown, for the source-form checks of 1#9.11
523
+ * @returns The intermediate result from the inline references pass
524
+ */
525
+ executeInlineReferencesPass(root, docId, declaredDocIds, sourceText) {
526
+ const inlineReferenceRule = new InlineReferenceRule({
527
+ uri: this.uri,
528
+ docId,
529
+ grammar: this.grammar,
530
+ declaredDocIds,
531
+ sourceText,
532
+ });
533
+ // Track the current section context -- starts with the DocID.
534
+ // The mutable wrapper allows the recursive walk callback to update
535
+ // the section context as headings are encountered.
536
+ const sectionContext = { current: docId };
537
+ // Walk the AST recursively, tracking ancestor types for exclusion filtering.
538
+ // When a heading is encountered, update the section context.
539
+ // When a text node is encountered (not excluded by ancestors), feed to ECR104.
540
+ this.walkNodesForInlineReferences(root, sectionContext, inlineReferenceRule);
541
+ const inlineResult = inlineReferenceRule.finalise();
542
+ return { inlineResult };
543
+ }
544
+ /**
545
+ * Walks the AST to find inline runs for ECR104, tracking whether an
546
+ * excluded ancestor encloses the node and updating section context when
547
+ * headings are encountered.
548
+ *
549
+ * A paragraph is handed to ECR104 whole, as one inline run, because a
550
+ * citation is recognised in the text a reader sees and not one parsed text
551
+ * node at a time (1#9.5 rule 1). Its children are not walked separately.
552
+ *
553
+ * A hand-written depth-first, pre-order walk over an explicit stack: document
554
+ * order decides which section each run belongs to, and see
555
+ * {@link PerDocumentVisitor.pushChildren} for why the stack is not the call
556
+ * stack. Exclusion is carried down as one flag, because a node is excluded
557
+ * exactly when some ancestor's type is, so nothing is gained by keeping the
558
+ * ancestors themselves.
559
+ *
560
+ * @param root - The root of the walk
561
+ * @param sectionContext - Mutable wrapper holding the current section context
562
+ * @param inlineReferenceRule - The ECR104 rule instance to feed runs to
563
+ */
564
+ walkNodesForInlineReferences(root, sectionContext, inlineReferenceRule) {
565
+ const pending = [{ node: root, excluded: false }];
566
+ while (pending.length > 0) {
567
+ const entry = pending.pop();
568
+ if (entry === undefined) {
569
+ break;
570
+ }
571
+ const { node, excluded } = entry;
572
+ if (node.type === 'heading' && node.depth !== undefined) {
573
+ sectionContext.current = this.determineSectionContext(PerDocumentVisitor.showNodeText(node), node.depth, sectionContext.current);
574
+ // Do not walk into heading children for text node extraction;
575
+ // heading text is not subject to inline reference detection.
576
+ continue;
577
+ }
578
+ if (INLINE_RUN_NODE_TYPES.has(node.type) && node.children !== undefined) {
579
+ if (!excluded) {
580
+ const segments = [];
581
+ for (const child of node.children) {
582
+ this.collectInlineSegments(child, [], segments);
583
+ }
584
+ inlineReferenceRule.evaluateInlineRun(segments, sectionContext.current);
585
+ }
586
+ continue;
587
+ }
588
+ // A text node outside any run is not expected from the parser, but if one
589
+ // appears it is still evaluated, as a run of its own.
590
+ if (node.type === 'text' && node.value !== undefined) {
591
+ if (!excluded) {
592
+ const textNodeData = this.extractTextNodeData(node);
593
+ inlineReferenceRule.evaluateTextNode(textNodeData, sectionContext.current);
594
+ }
595
+ continue;
596
+ }
597
+ PerDocumentVisitor.pushInlineWalkChildren(node, excluded, pending);
598
+ }
599
+ }
600
+ /**
601
+ * Puts a node's children on the inline walk's stack, in document order,
602
+ * noting whether an excluded ancestor now encloses them.
603
+ *
604
+ * @param node - The node whose children are to be walked
605
+ * @param excluded - Whether an excluded ancestor already encloses the node
606
+ * @param pending - The stack to push onto
607
+ */
608
+ static pushInlineWalkChildren(node, excluded, pending) {
609
+ const children = node.children;
610
+ if (children === undefined) {
611
+ return;
612
+ }
613
+ const childrenExcluded = excluded || EXCLUDED_ANCESTOR_NODE_TYPES.has(node.type);
614
+ for (let index = children.length - 1; index >= 0; index -= 1) {
615
+ const child = children[index];
616
+ if (child !== undefined) {
617
+ pending.push({ node: child, excluded: childrenExcluded });
618
+ }
619
+ }
620
+ }
621
+ /**
622
+ * Flattens one inline node into the segments a reader sees, in order.
623
+ *
624
+ * Text becomes a `text` segment carrying its position, and inline code a
625
+ * `code` segment. Formatting contributes its contents, each segment noting
626
+ * the spans around it. A hard break, an image, and a `<br>` tag become a
627
+ * `break` of one space, because a reader sees the words either side of them
628
+ * as separate. Any other inline HTML -- a comment, or a tag such as
629
+ * `<span>` -- contributes nothing, because it separates nothing:
630
+ * `60<span>s</span>` reads as `60s`.
631
+ *
632
+ * @param node - An inline node
633
+ * @param wrappers - Types of the formatting spans enclosing it, outermost first
634
+ * @param segments - Accumulator the segments are appended to
635
+ */
636
+ collectInlineSegments(node, wrappers, segments) {
637
+ const pending = [{ node, wrappers }];
638
+ while (pending.length > 0) {
639
+ const entry = pending.pop();
640
+ if (entry === undefined) {
641
+ break;
642
+ }
643
+ this.collectInlineSegment(entry, segments, pending);
644
+ }
645
+ }
646
+ /**
647
+ * Turns one inline node into segments, or puts its children on the stack
648
+ * to be turned into segments in their turn.
649
+ *
650
+ * @param entry - The node and the formatting spans enclosing it
651
+ * @param segments - Accumulator the segments are appended to
652
+ * @param pending - The walk's stack, which children are pushed onto in document order
653
+ */
654
+ collectInlineSegment(entry, segments, pending) {
655
+ const { node, wrappers } = entry;
656
+ if (node.type === 'text') {
657
+ const range = this.mapPosition(node.position);
658
+ segments.push({
659
+ kind: 'text',
660
+ text: node.value ?? '',
661
+ wrappers,
662
+ ...(range !== undefined ? { range } : {}),
663
+ });
664
+ return;
665
+ }
666
+ if (node.type === 'inlineCode') {
667
+ segments.push({ kind: 'code', text: node.value ?? '', wrappers });
668
+ return;
669
+ }
670
+ if (node.type === 'html' && !LINE_BREAK_HTML.test((node.value ?? '').trim())) {
671
+ return;
672
+ }
673
+ if (node.type !== 'html' && node.children !== undefined) {
674
+ const inner = [...wrappers, node.type];
675
+ for (let index = node.children.length - 1; index >= 0; index -= 1) {
676
+ const child = node.children[index];
677
+ if (child !== undefined) {
678
+ pending.push({ node: child, wrappers: inner });
679
+ }
680
+ }
681
+ return;
682
+ }
683
+ segments.push({ kind: 'break', text: ' ', wrappers });
684
+ }
685
+ // -------------------------------------------------------------------------
686
+ // Private: Section context tracking
687
+ // -------------------------------------------------------------------------
688
+ /**
689
+ * Determines the current section context identifier for a given heading.
690
+ *
691
+ * The section context is the identifier (DocID or SectionID) of the most
692
+ * recently encountered numbered heading. Before any H2 heading, the context
693
+ * is the DocID. After an H2 or deeper heading, the context is the
694
+ * SectionID of that heading.
695
+ *
696
+ * This method extracts the identifier from the heading text using the
697
+ * grammar's separator convention. If the heading text does not contain
698
+ * a valid identifier, the previous context is retained.
699
+ *
700
+ * @param headingText - The plain text content of the heading node
701
+ * @param headingDepth - The Markdown heading depth (1--6)
702
+ * @param currentContext - The current section context before this heading
703
+ * @returns The updated section context identifier
704
+ */
705
+ determineSectionContext(headingText, headingDepth,
706
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be DocID or SectionID
707
+ currentContext) {
708
+ // Only headings with depth >= 2 can change the section context
709
+ // (H1 establishes the DocID, which is the initial context)
710
+ if (headingDepth < 2) {
711
+ return currentContext;
712
+ }
713
+ // A sub-heading carries a SectionID, so it is parsed with the SectionID
714
+ // heading grammar rather than the DocID one an H1 uses.
715
+ const parseResult = this.grammar.parseSectionHeading(headingText);
716
+ if (parseResult.valid) {
717
+ return parseResult.sectionId;
718
+ }
719
+ return currentContext;
720
+ }
721
+ // -------------------------------------------------------------------------
722
+ // Private: References heading detection
723
+ // -------------------------------------------------------------------------
724
+ /**
725
+ * Determines whether a heading is the `## References` structural heading.
726
+ *
727
+ * The `## References` heading is owned by ECR103 and should not be
728
+ * evaluated by ECR102 (Section Hierarchy), because it is not a numbered
729
+ * section heading and would fail SectionID parsing.
730
+ *
731
+ * Per 1#9.6, the References heading is identified by depth === 2
732
+ * and text content exactly equal to `"References"`.
733
+ *
734
+ * @param headingData - The heading node data to check
735
+ * @returns `true` if the heading is the `## References` heading, `false` otherwise
736
+ */
737
+ tellReferencesHeading(headingData) {
738
+ return headingData.depth === 2 && headingData.text === 'References';
739
+ }
740
+ // -------------------------------------------------------------------------
741
+ // Private: Text extraction from list items
742
+ // -------------------------------------------------------------------------
743
+ /**
744
+ * Extracts the plain text content from a list item AST node.
745
+ *
746
+ * Gathers the text of the list item's child nodes into a single plain text
747
+ * string suitable for feeding to ECR103.
748
+ *
749
+ * @param listItemNode - The MDAST list item node to extract text from
750
+ * @returns The extracted {@link ListItemNodeData} with text and optional range
751
+ */
752
+ extractListItemNodeData(listItemNode) {
753
+ const segments = [];
754
+ this.collectListItemSegments(listItemNode, segments);
755
+ const text = segments.map((segment) => segment.text).join('');
756
+ const range = this.mapPosition(listItemNode.position);
757
+ const listItemData = {
758
+ text,
759
+ segments,
760
+ ...(range !== undefined ? { range } : {}),
761
+ };
762
+ return listItemData;
763
+ }
764
+ /**
765
+ * Classifies a literal node for tracing its text back to the source.
766
+ *
767
+ * @param type - The node's MDAST type
768
+ * @returns `text` for a text node, `code` for inline code, `other` otherwise
769
+ */
770
+ static showSegmentKind(type) {
771
+ if (type === 'text') {
772
+ return 'text';
773
+ }
774
+ return type === 'inlineCode' ? 'code' : 'other';
775
+ }
776
+ /**
777
+ * Splits a list item's text into the parsed nodes it comes from.
778
+ *
779
+ * Follows `mdast-util-to-string` exactly -- a node's `value`, else an
780
+ * image's `alt`, else its children in order -- so that joining the
781
+ * segments reproduces the entry text the rule parses, and an offset in that
782
+ * text identifies the node, and so the source, it came from.
783
+ *
784
+ * @param node - A node within the list item
785
+ * @param segments - Accumulator the segments are appended to
786
+ */
787
+ collectListItemSegments(node, segments) {
788
+ const pending = [node];
789
+ while (pending.length > 0) {
790
+ const current = pending.pop();
791
+ if (current === undefined) {
792
+ break;
793
+ }
794
+ const range = this.mapPosition(current.position);
795
+ const withRange = range !== undefined ? { range } : {};
796
+ if (current.value !== undefined) {
797
+ segments.push({
798
+ kind: PerDocumentVisitor.showSegmentKind(current.type),
799
+ text: current.value,
800
+ ...withRange,
801
+ });
802
+ continue;
803
+ }
804
+ if (typeof current.alt === 'string' && current.alt.length > 0) {
805
+ segments.push({ kind: 'other', text: current.alt, ...withRange });
806
+ continue;
807
+ }
808
+ PerDocumentVisitor.pushChildren(current, pending);
809
+ }
810
+ }
811
+ // -------------------------------------------------------------------------
812
+ // Private: Heading data extraction
813
+ // -------------------------------------------------------------------------
814
+ /**
815
+ * Extracts heading node data from an MDAST heading node.
816
+ *
817
+ * Reads the plain text content with {@link PerDocumentVisitor.showNodeText}
818
+ * and maps the MDAST position to a {@link HeadingNodeData} structure
819
+ * suitable for consumption by ECR101, ECR102, and ECR103.
820
+ *
821
+ * @param headingNode - The MDAST heading node to extract data from
822
+ * @returns The extracted {@link HeadingNodeData} with depth, text, and optional range
823
+ */
824
+ extractHeadingNodeData(headingNode) {
825
+ const text = PerDocumentVisitor.showNodeText(headingNode);
826
+ const range = this.mapPosition(headingNode.position);
827
+ const headingData = {
828
+ depth: headingNode.depth ?? 1,
829
+ text,
830
+ ...(range !== undefined ? { range } : {}),
831
+ };
832
+ return headingData;
833
+ }
834
+ // -------------------------------------------------------------------------
835
+ // Private: Text node data extraction
836
+ // -------------------------------------------------------------------------
837
+ /**
838
+ * Extracts text node data from an MDAST text node.
839
+ *
840
+ * Maps the MDAST text node's value and position to a {@link TextNodeData}
841
+ * structure suitable for consumption by ECR104.
842
+ *
843
+ * @param textNode - The MDAST text node to extract data from
844
+ * @returns The extracted {@link TextNodeData} with text and optional range
845
+ */
846
+ extractTextNodeData(textNode) {
847
+ const range = this.mapPosition(textNode.position);
848
+ const textNodeData = {
849
+ text: textNode.value ?? '',
850
+ ...(range !== undefined ? { range } : {}),
851
+ };
852
+ return textNodeData;
853
+ }
854
+ // -------------------------------------------------------------------------
855
+ // Private: Declared DocID extraction
856
+ // -------------------------------------------------------------------------
857
+ /**
858
+ * Extracts the set of declared DocIDs from a completed ECR103 result.
859
+ *
860
+ * Iterates over the extracted {@link ReferenceEdge} artefacts and
861
+ * collects the `toDocId` field from each edge into a `ReadonlySet<DocID>`.
862
+ * This set is used to initialise ECR104 for undeclared reference detection.
863
+ *
864
+ * @param references - The extracted reference edges from ECR103
865
+ * @returns The set of declared DocIDs
866
+ */
867
+ extractDeclaredDocIds(references) {
868
+ const declaredDocIds = new Set();
869
+ for (const referenceEdge of references) {
870
+ declaredDocIds.add(referenceEdge.toDocId);
871
+ }
872
+ return declaredDocIds;
873
+ }
874
+ // -------------------------------------------------------------------------
875
+ // Private: Position mapping
876
+ // -------------------------------------------------------------------------
877
+ /**
878
+ * Maps an MDAST position to a {@link PositionRange}, if available.
879
+ *
880
+ * MDAST positions use 1-based lines and 1-based columns, while
881
+ * {@link PositionRange} uses 0-based lines and 0-based characters.
882
+ * This method performs the conversion.
883
+ *
884
+ * @param mdastPosition - The MDAST position object, or `undefined` if
885
+ * positional metadata is not available
886
+ * @returns The mapped position range, or `undefined` if no position is available
887
+ */
888
+ mapPosition(mdastPosition) {
889
+ if (mdastPosition === undefined) {
890
+ return undefined;
891
+ }
892
+ const mappedRange = {
893
+ start: {
894
+ line: mdastPosition.start.line - 1,
895
+ character: mdastPosition.start.column - 1,
896
+ },
897
+ end: {
898
+ line: mdastPosition.end.line - 1,
899
+ character: mdastPosition.end.column - 1,
900
+ },
901
+ };
902
+ return mappedRange;
903
+ }
904
+ // -------------------------------------------------------------------------
905
+ // Private: Result assembly
906
+ // -------------------------------------------------------------------------
907
+ /**
908
+ * Assembles the composite {@link LintResult} from individual rule results.
909
+ *
910
+ * Aggregates diagnostics from all rules, determines the `ok` status
911
+ * (true when no error-severity diagnostics are present), and constructs
912
+ * the {@link ExtractedDocument} when a valid DocID was recovered.
913
+ *
914
+ * @param identityResult - The result from ECR101
915
+ * @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
916
+ * @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
917
+ * @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
918
+ * @returns The composite lint result
919
+ */
920
+ assembleLintResult(identityResult, sectionsResult, referencesResult, inlineResult) {
921
+ // Aggregate all diagnostics
922
+ const allDiagnostics = this.aggregateDiagnostics(identityResult, sectionsResult, referencesResult, inlineResult);
923
+ const input = this.buildLintInput();
924
+ const isPassable = this.tellAllDiagnosticsPassable(allDiagnostics);
925
+ // Construct extracted document if identity is valid
926
+ if (identityResult.identity !== undefined) {
927
+ const identity = identityResult.identity;
928
+ const sections = sectionsResult !== undefined ? sectionsResult.sections : [];
929
+ const references = referencesResult !== undefined ? referencesResult.references : [];
930
+ const inlineReferences = inlineResult !== undefined ? inlineResult.inlineReferences : [];
931
+ const extracted = this.buildExtractedDocument(identity, sections, references, inlineReferences);
932
+ return {
933
+ input,
934
+ ok: isPassable,
935
+ diagnostics: allDiagnostics,
936
+ extracted,
937
+ };
938
+ }
939
+ return {
940
+ input,
941
+ ok: isPassable,
942
+ diagnostics: allDiagnostics,
943
+ };
944
+ }
945
+ /**
946
+ * Aggregates diagnostics from all rule results into a single array.
947
+ *
948
+ * Collects diagnostics from ECR101, ECR102, ECR103, and ECR104 in that
949
+ * order. Rules that did not run (undefined results) contribute no
950
+ * diagnostics.
951
+ *
952
+ * @param identityResult - The result from ECR101
953
+ * @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
954
+ * @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
955
+ * @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
956
+ * @returns The aggregated array of all diagnostics
957
+ */
958
+ aggregateDiagnostics(identityResult, sectionsResult, referencesResult, inlineResult) {
959
+ const allDiagnostics = [];
960
+ for (const diagnostic of identityResult.diagnostics) {
961
+ allDiagnostics.push(diagnostic);
962
+ }
963
+ if (sectionsResult !== undefined) {
964
+ for (const diagnostic of sectionsResult.diagnostics) {
965
+ allDiagnostics.push(diagnostic);
966
+ }
967
+ }
968
+ if (referencesResult !== undefined) {
969
+ for (const diagnostic of referencesResult.diagnostics) {
970
+ allDiagnostics.push(diagnostic);
971
+ }
972
+ }
973
+ if (inlineResult !== undefined) {
974
+ for (const diagnostic of inlineResult.diagnostics) {
975
+ allDiagnostics.push(diagnostic);
976
+ }
977
+ }
978
+ return allDiagnostics;
979
+ }
980
+ /**
981
+ * Constructs the {@link LintInput} descriptor for the result.
982
+ *
983
+ * Echoes the visitor's URI and optional version into the standard
984
+ * input descriptor shape.
985
+ *
986
+ * @returns The lint input descriptor
987
+ */
988
+ buildLintInput() {
989
+ const input = {
990
+ uri: this.uri,
991
+ ...(this.version !== undefined ? { version: this.version } : {}),
992
+ };
993
+ return input;
994
+ }
995
+ /**
996
+ * Determines the composite `ok` status from an aggregated diagnostics array.
997
+ *
998
+ * The result is `ok` (true) when no diagnostics have severity `"error"`.
999
+ * Any error-severity diagnostic causes the result to be not-ok (false).
1000
+ *
1001
+ * @param diagnostics - The full array of aggregated diagnostics from all rules
1002
+ * @returns `true` if no error diagnostics are present, `false` otherwise
1003
+ */
1004
+ tellAllDiagnosticsPassable(diagnostics) {
1005
+ return !diagnostics.some((diagnostic) => diagnostic.severity === 'error');
1006
+ }
1007
+ /**
1008
+ * Constructs the {@link ExtractedDocument} from individual rule extraction
1009
+ * results.
1010
+ *
1011
+ * Assembles the document identity (DocID and title), section nodes,
1012
+ * reference edges, and inline reference edges into the canonical
1013
+ * extracted document shape.
1014
+ *
1015
+ * @param identity - The document identity from ECR101
1016
+ * @param sections - The section nodes from ECR102
1017
+ * @param references - The reference edges from ECR103
1018
+ * @param inlineReferences - The inline reference edges from ECR104
1019
+ * @returns The assembled extracted document
1020
+ */
1021
+ buildExtractedDocument(identity, sections, references, inlineReferences) {
1022
+ const extracted = {
1023
+ docId: identity.docId,
1024
+ title: identity.title,
1025
+ sections,
1026
+ references,
1027
+ inlineReferences,
1028
+ };
1029
+ return extracted;
1030
+ }
1031
+ }
1032
+ //# sourceMappingURL=per-document-visitor.js.map