@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,705 @@
1
+ /**
2
+ * Section Hierarchy Rule (ECR102)
3
+ *
4
+ * Validates the section structure constraint defined in the ECR specification:
5
+ * - 1#9.4 -- Section Structure Rules
6
+ * - 1#9.8 -- Per-Document Structural Invariants ("SectionIDs unique within document")
7
+ *
8
+ * This rule consumes heading node data from an AST traversal and validates
9
+ * the section hierarchy for headings with depth \>= 2. It delegates identifier
10
+ * parsing and segment-count logic to {@link IdentifierGrammar} and emits
11
+ * {@link Diagnostic} objects for any violations.
12
+ *
13
+ * Validations performed:
14
+ * 1. A separator (any dash variant) must follow the identifier
15
+ * 2. Heading text prefix must parse as a valid SectionID
16
+ * 3. The SectionID's DocID must equal the document's DocID
17
+ * 4. The section path must have d - 1 segments, where d = heading depth
18
+ * 5. SectionIDs must be unique within the document (no duplicates)
19
+ * 6. Heading levels must not be skipped (e.g., H3 directly after H1 is invalid)
20
+ *
21
+ * Extracted artefacts:
22
+ * - {@link SectionNode} for each valid heading, with parentId derived from the heading stack
23
+ */
24
+
25
+ import type { DocID, SectionID, Diagnostic, DiagnosticSeverity, PositionRange, SectionNode } from './types.js';
26
+ import type {
27
+ SectionIdParseResult,
28
+ SeparatorValidationResult,
29
+ } from './identifier-grammar.js';
30
+ import { IdentifierGrammar } from './identifier-grammar.js';
31
+ import type { HeadingNodeData } from './document-identity-rule.js';
32
+ import { HEADING_SOURCE_FORM_CAUSE, HeadingSourceForm } from './heading-source-form.js';
33
+ import type { HeadingObstruction } from './heading-source-form.js';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Rule identifier constant
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Canonical rule identifier for the Section Hierarchy Rule.
41
+ *
42
+ * Referenced as [ECR102] in the ECR specification (1#9.4).
43
+ */
44
+ export const SECTION_HIERARCHY_RULE_ID: string = 'ECR102';
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Constructor options
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Configuration options for constructing a {@link SectionHierarchyRule} instance.
52
+ */
53
+ export interface SectionHierarchyRuleOptions {
54
+ /**
55
+ * The opaque, host-provided URI identifying the document being validated.
56
+ * Attached to all emitted diagnostics.
57
+ */
58
+ readonly uri: string;
59
+
60
+ /**
61
+ * The DocID established by the {@link DocumentIdentityRule} for this document.
62
+ * Used as the required prefix for all SectionIDs and to compute expected segment counts.
63
+ */
64
+ readonly docId: DocID;
65
+
66
+ /**
67
+ * The {@link IdentifierGrammar} instance used for SectionID parsing,
68
+ * prefix validation, segment counting, and separator validation.
69
+ */
70
+ readonly grammar: IdentifierGrammar;
71
+ /**
72
+ * The document's raw Markdown, for the heading source-form check of 1#9.11
73
+ * rule 1. When absent, as in unit tests that supply only parsed heading
74
+ * text, the check is skipped.
75
+ */
76
+ readonly sourceText?: string;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Result type
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * The complete result produced by finalising the Section Hierarchy Rule
85
+ * after all headings have been evaluated.
86
+ *
87
+ * Contains zero or more diagnostics and the list of successfully extracted
88
+ * section nodes, including the root H1 node.
89
+ */
90
+ export interface SectionHierarchyRuleResult {
91
+ /** Diagnostics emitted during evaluation (errors for violations). */
92
+ readonly diagnostics: readonly Diagnostic[];
93
+
94
+ /**
95
+ * Section nodes extracted from valid headings, in document traversal order.
96
+ *
97
+ * Includes the root H1 node (whose id is the DocID and has no parentId)
98
+ * and all valid sub-heading nodes with their derived parentId.
99
+ */
100
+ readonly sections: readonly SectionNode[];
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Internal heading stack entry
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * Internal record tracking a heading in the depth-aware heading stack.
109
+ *
110
+ * The heading stack enables correct parent-child derivation across
111
+ * heading depth transitions. Each entry records the identifier and
112
+ * depth of a heading that may serve as the parent of subsequent
113
+ * deeper headings.
114
+ */
115
+ interface HeadingStackEntry {
116
+ /** The DocID (for H1) or SectionID (for depth \>= 2) of this heading. */
117
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: DocID for H1, SectionID for depth >= 2
118
+ readonly id: DocID | SectionID;
119
+
120
+ /** The Markdown heading depth (1 for H1, 2 for H2, etc.). */
121
+ readonly depth: number;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Diagnostic severity constant
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * The severity used for all Section Hierarchy Rule diagnostics.
130
+ *
131
+ * Per 1#9.8, violation of any per-document structural invariant is an ERROR.
132
+ */
133
+ const SECTION_HIERARCHY_DIAGNOSTIC_SEVERITY: DiagnosticSeverity = 'error';
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Separator pattern
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * Matches the separator between a SectionID and its title: a dash with any
141
+ * surrounding whitespace.
142
+ *
143
+ * The dash may be a hyphen-minus (U+002D), an en dash (U+2013) or an em dash
144
+ * (U+2014); the variant carries no structural meaning.
145
+ */
146
+ const SECTION_SEPARATOR_PATTERN: RegExp = /\s*[-–—]\s*/;
147
+
148
+ /**
149
+ * Matches heading text that begins with a number, i.e. one that at least
150
+ * attempts an identifier.
151
+ */
152
+ const STARTS_WITH_DIGIT_PATTERN: RegExp = /^\s*\d/;
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Rule class
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Validates the Section Hierarchy Rule as defined in the ECR specification (1#9.4, 1#9.8).
160
+ *
161
+ * The rule enforces that every heading with depth \>= 2 has a valid SectionID
162
+ * whose DocID is the document's own, carries a separator, has a section path of
163
+ * one segment per heading level below the H1, and is unique within the document.
164
+ *
165
+ * For each valid heading, the rule extracts a {@link SectionNode} with its
166
+ * parentId derived from the heading stack (the nearest preceding heading at
167
+ * depth - 1).
168
+ *
169
+ * The root H1 heading is registered via {@link registerRootHeading} to establish
170
+ * the heading stack base and produce the root {@link SectionNode}.
171
+ *
172
+ * Usage:
173
+ * 1. Construct a rule instance with the document URI, established DocID,
174
+ * and an {@link IdentifierGrammar}.
175
+ * 2. Call {@link registerRootHeading} once with the H1 heading data to
176
+ * establish the root of the heading stack.
177
+ * 3. Call {@link evaluateHeading} for every heading node with depth \>= 2
178
+ * encountered during AST traversal.
179
+ * 4. Call {@link finalise} after all headings have been evaluated to obtain
180
+ * the complete result.
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * const grammar = new IdentifierGrammar();
185
+ * const rule = new SectionHierarchyRule({
186
+ * uri: 'file:///doc.md',
187
+ * docId: '3.1',
188
+ * grammar,
189
+ * });
190
+ *
191
+ * rule.registerRootHeading({ depth: 1, text: '3.1 - My Document' });
192
+ * rule.evaluateHeading({ depth: 2, text: '3.1#1 - Section One' });
193
+ * rule.evaluateHeading({ depth: 3, text: '3.1#1.1 - Sub-Section' });
194
+ * rule.evaluateHeading({ depth: 2, text: '3.1#2 - Section Two' });
195
+ *
196
+ * const result: SectionHierarchyRuleResult = rule.finalise();
197
+ * ```
198
+ */
199
+ export class SectionHierarchyRule {
200
+ /** The opaque, host-provided URI identifying the document being validated. */
201
+ private readonly uri: string;
202
+
203
+ /** The established DocID for this document. */
204
+ private readonly docId: DocID;
205
+
206
+ /** The grammar instance used for SectionID parsing and validation. */
207
+ private readonly grammar: IdentifierGrammar;
208
+
209
+ /** Diagnostics accumulated during heading evaluation. */
210
+ private readonly collectedDiagnostics: Diagnostic[];
211
+
212
+ /** Section nodes extracted from valid headings, in traversal order. */
213
+ private readonly collectedSections: SectionNode[];
214
+
215
+ /**
216
+ * Depth-aware heading stack for parent-child derivation.
217
+ *
218
+ * Maintained so that each entry's depth is strictly increasing from
219
+ * bottom to top. When a heading at depth d is encountered, all entries
220
+ * with depth \>= d are popped before the new entry is pushed.
221
+ */
222
+ private readonly headingStack: HeadingStackEntry[];
223
+
224
+ /**
225
+ * Set of SectionIDs already encountered, for duplicate detection.
226
+ */
227
+ private readonly encounteredSectionIds: Set<SectionID>;
228
+
229
+ /** Checks each heading's source line against the section recipe. */
230
+ private readonly headingSourceForm: HeadingSourceForm;
231
+
232
+ /**
233
+ * Constructs a new Section Hierarchy Rule evaluator.
234
+ *
235
+ * @param options - Configuration including the document URI, established DocID,
236
+ * and grammar instance
237
+ */
238
+ public constructor(options: SectionHierarchyRuleOptions) {
239
+ this.uri = options.uri;
240
+ this.docId = options.docId;
241
+ this.grammar = options.grammar;
242
+ this.headingSourceForm = new HeadingSourceForm(options.sourceText);
243
+ this.collectedDiagnostics = [];
244
+ this.collectedSections = [];
245
+ this.headingStack = [];
246
+ this.encounteredSectionIds = new Set<SectionID>();
247
+ }
248
+
249
+ /**
250
+ * Registers the root H1 heading to establish the heading stack base.
251
+ *
252
+ * This produces the root {@link SectionNode} (id = DocID, headingDepth = 1,
253
+ * no parentId) and pushes the H1 onto the heading stack so that subsequent
254
+ * depth-2 headings can derive their parentId.
255
+ *
256
+ * Must be called exactly once, before any calls to {@link evaluateHeading}.
257
+ *
258
+ * @param headingNodeData - Data extracted from the H1 heading AST node.
259
+ * The depth must be 1. The title text is extracted
260
+ * from the heading content after the separator.
261
+ */
262
+ public registerRootHeading(headingNodeData: HeadingNodeData): void {
263
+ const title: string = this.extractHeadingTitle(headingNodeData.text);
264
+
265
+ const rootSection: SectionNode = {
266
+ id: this.docId,
267
+ title,
268
+ headingDepth: 1,
269
+ };
270
+
271
+ this.collectedSections.push(rootSection);
272
+ this.updateHeadingStack(this.docId, 1);
273
+ }
274
+
275
+ /**
276
+ * Evaluates a single heading node with depth \>= 2 against the section
277
+ * hierarchy constraints.
278
+ *
279
+ * For each heading, the method validates:
280
+ * 1. A separator (any dash variant) follows the identifier
281
+ * 2. The textual prefix parses as a valid SectionID
282
+ * 3. The SectionID's DocID equals the document's DocID
283
+ * 4. The section path has d - 1 segments, where d is the heading depth
284
+ * 5. The SectionID has not been seen before in this document
285
+ * 6. No heading levels are skipped (depth must not exceed parent depth + 1)
286
+ *
287
+ * When all validations pass, a {@link SectionNode} is extracted with its
288
+ * parentId derived from the heading stack (the nearest preceding heading
289
+ * at depth - 1), and the heading stack is updated.
290
+ *
291
+ * When any validation fails, an error diagnostic is emitted and the heading
292
+ * stack is still updated to maintain correct parent derivation for
293
+ * subsequent headings.
294
+ *
295
+ * Headings with depth === 1 are ignored (H1 is handled by
296
+ * {@link registerRootHeading}).
297
+ *
298
+ * @param headingNodeData - Data extracted from a heading AST node with depth \>= 2
299
+ */
300
+ public evaluateHeading(headingNodeData: HeadingNodeData): void {
301
+ if (headingNodeData.depth === 1) {
302
+ return;
303
+ }
304
+
305
+ const headingText: string = headingNodeData.text;
306
+ const depth: number = headingNodeData.depth;
307
+ const range: PositionRange | undefined = headingNodeData.range;
308
+
309
+ // 1. Validate separator
310
+ const separatorDiagnostic: Diagnostic | undefined =
311
+ this.validateHeadingSeparator(headingText, range);
312
+
313
+ if (separatorDiagnostic !== undefined) {
314
+ this.collectedDiagnostics.push(separatorDiagnostic);
315
+ return;
316
+ }
317
+
318
+ // 2. Extract SectionID candidate from prefix
319
+ const candidate: string = this.extractSectionIdCandidate(headingText);
320
+
321
+ // 3. Parse candidate as SectionID
322
+ const parseResult: SectionIdParseResult = this.grammar.parseSectionId(candidate);
323
+
324
+ if (!parseResult.valid) {
325
+ const diagnostic: Diagnostic = this.createDiagnostic(
326
+ `Heading prefix "${candidate}" does not parse as a valid SectionID.`,
327
+ range,
328
+ );
329
+ this.collectedDiagnostics.push(diagnostic);
330
+ return;
331
+ }
332
+
333
+ const sectionId: SectionID = parseResult.sectionId;
334
+
335
+ // 3a. Verify the heading is where the section recipe looks (1#9.11 rule
336
+ // 1). Reported, but the checks below still run and the section is still
337
+ // extracted: withholding it would turn every citation of it into an
338
+ // unresolved target, reporting one mistake many times over.
339
+ const obstruction: HeadingObstruction | undefined = this.headingSourceForm.findObstruction(
340
+ range,
341
+ depth,
342
+ sectionId,
343
+ );
344
+
345
+ if (obstruction !== undefined) {
346
+ this.collectedDiagnostics.push({
347
+ ...this.createDiagnostic(
348
+ HeadingSourceForm.explain(obstruction, depth, sectionId),
349
+ range,
350
+ sectionId,
351
+ ),
352
+ data: { cause: HEADING_SOURCE_FORM_CAUSE, obstruction, sectionId },
353
+ });
354
+ }
355
+
356
+ // 4. Verify SectionID extends DocID
357
+ const extendsDocId: boolean = this.grammar.tellSectionIdExtendsDocId(
358
+ sectionId,
359
+ this.docId,
360
+ );
361
+
362
+ if (!extendsDocId) {
363
+ const diagnostic: Diagnostic = this.createDiagnostic(
364
+ `SectionID "${sectionId}" belongs to a different document. ` +
365
+ `Headings in this document must be written as "${this.docId}#<section path>".`,
366
+ range,
367
+ sectionId,
368
+ );
369
+ this.collectedDiagnostics.push(diagnostic);
370
+ this.updateHeadingStack(sectionId, depth);
371
+ return;
372
+ }
373
+
374
+ // 5. Verify the section path has one segment per heading level below the H1
375
+ const actualPathLength: number = this.grammar.showSectionPathLength(sectionId);
376
+ const expectedPathLength: number = this.grammar.showExpectedSectionPathLength(depth);
377
+
378
+ if (actualPathLength !== expectedPathLength) {
379
+ const diagnostic: Diagnostic = this.createDiagnostic(
380
+ `SectionID "${sectionId}" has a section path of ${String(actualPathLength)} ` +
381
+ `segment(s), but heading depth ${String(depth)} requires ` +
382
+ `${String(expectedPathLength)}.`,
383
+ range,
384
+ sectionId,
385
+ );
386
+ this.collectedDiagnostics.push(diagnostic);
387
+ this.updateHeadingStack(sectionId, depth);
388
+ return;
389
+ }
390
+
391
+ // 6. Check for duplicate SectionIDs
392
+ if (this.encounteredSectionIds.has(sectionId)) {
393
+ const diagnostic: Diagnostic = this.createDiagnostic(
394
+ `Duplicate SectionID "${sectionId}". SectionIDs must be unique within a document.`,
395
+ range,
396
+ sectionId,
397
+ );
398
+ this.collectedDiagnostics.push(diagnostic);
399
+ this.updateHeadingStack(sectionId, depth);
400
+ return;
401
+ }
402
+
403
+ // 7. Check for skipped heading levels
404
+ const currentTopDepth: number = this.peekHeadingStackDepth();
405
+
406
+ if (depth > currentTopDepth + 1) {
407
+ const skippedFrom: number = currentTopDepth;
408
+ const diagnostic: Diagnostic = this.createDiagnostic(
409
+ `Heading depth ${String(depth)} skips level(s) after depth ${String(skippedFrom)}. ` +
410
+ `Each heading level must be introduced before its children.`,
411
+ range,
412
+ sectionId,
413
+ );
414
+ this.collectedDiagnostics.push(diagnostic);
415
+ this.updateHeadingStack(sectionId, depth);
416
+ return;
417
+ }
418
+
419
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: parent may be DocID or SectionID
420
+ const parentId: DocID | SectionID | undefined = this.lookUpParentId(depth);
421
+
422
+ // 8. Check that the section path continues its parent's path
423
+ const misplacedDiagnostic: Diagnostic | undefined = this.validatePathAgainstParent(
424
+ parseResult.sectionPath,
425
+ parentId,
426
+ sectionId,
427
+ range,
428
+ );
429
+
430
+ if (misplacedDiagnostic !== undefined) {
431
+ this.collectedDiagnostics.push(misplacedDiagnostic);
432
+ this.updateHeadingStack(sectionId, depth);
433
+ return;
434
+ }
435
+
436
+ // All validations passed — extract SectionNode
437
+ this.encounteredSectionIds.add(sectionId);
438
+
439
+ const title: string = this.extractHeadingTitle(headingText);
440
+
441
+ const sectionNode: SectionNode = {
442
+ id: sectionId,
443
+ title,
444
+ headingDepth: depth,
445
+ ...(parentId !== undefined ? { parentId } : {}),
446
+ };
447
+
448
+ this.collectedSections.push(sectionNode);
449
+ this.updateHeadingStack(sectionId, depth);
450
+ }
451
+
452
+ /**
453
+ * Finalises the rule evaluation and produces the complete result.
454
+ *
455
+ * This method must be called after all heading nodes have been supplied
456
+ * via {@link registerRootHeading} and {@link evaluateHeading}. It returns
457
+ * all accumulated diagnostics and extracted section nodes.
458
+ *
459
+ * @returns The complete rule result including all diagnostics and extracted sections
460
+ */
461
+ public finalise(): SectionHierarchyRuleResult {
462
+ return {
463
+ diagnostics: this.collectedDiagnostics,
464
+ sections: this.collectedSections,
465
+ };
466
+ }
467
+
468
+ // -------------------------------------------------------------------------
469
+ // Private helpers
470
+ // -------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Validates that a heading string carries a separator, and returns an
474
+ * error diagnostic when none is found.
475
+ *
476
+ * Delegates to {@link IdentifierGrammar.validateSeparator}.
477
+ *
478
+ * @param headingText - The plain text content of the heading node
479
+ * @param range - Optional positional range for the diagnostic
480
+ * @returns A diagnostic if the separator is invalid, or `undefined` if valid
481
+ */
482
+ private validateHeadingSeparator(
483
+ headingText: string,
484
+ range?: PositionRange,
485
+ ): Diagnostic | undefined {
486
+ const separatorResult: SeparatorValidationResult =
487
+ this.grammar.validateSeparator(headingText);
488
+
489
+ if (separatorResult.valid) {
490
+ return undefined;
491
+ }
492
+
493
+ // A heading with no number at all is far more common than a numbered
494
+ // heading missing its dash, and deserves a message that says so.
495
+ if (!STARTS_WITH_DIGIT_PATTERN.test(headingText)) {
496
+ return this.createDiagnostic(
497
+ `Heading "${headingText.trim()}" is not numbered. Every sub-heading carries a SectionID: ` +
498
+ `the DocID, "#", and a section path, for example "${this.docId}#1 - ${headingText.trim()}".`,
499
+ range,
500
+ );
501
+ }
502
+
503
+ const diagnostic: Diagnostic = this.createDiagnostic(
504
+ `Heading has no recognisable separator. ` +
505
+ `Expected: a number, a dash (-, – or —), then the title.`,
506
+ range,
507
+ );
508
+
509
+ return diagnostic;
510
+ }
511
+
512
+ /**
513
+ * Checks that a sub-section's path continues the path of the heading it
514
+ * sits under, so that a SectionID locates itself: `3.1#2.1` must sit under
515
+ * `3.1#2`, not under `3.1#1`.
516
+ *
517
+ * Only a parent that was itself extracted is checked against. A parent
518
+ * that failed validation has already been reported, and measuring its
519
+ * children against it would repeat that error rather than add one.
520
+ *
521
+ * @param sectionPath - The section path of the heading being evaluated
522
+ * @param parentId - The identifier of the heading it sits under, if any
523
+ * @param sectionId - The SectionID of the heading being evaluated
524
+ * @param range - Optional positional range for the diagnostic
525
+ * @returns A diagnostic if the path does not continue its parent's, or `undefined`
526
+ */
527
+ private validatePathAgainstParent(
528
+ sectionPath: string,
529
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: parent may be DocID or SectionID
530
+ parentId: DocID | SectionID | undefined,
531
+ sectionId: SectionID,
532
+ range?: PositionRange,
533
+ ): Diagnostic | undefined {
534
+ if (parentId === undefined || !this.encounteredSectionIds.has(parentId)) {
535
+ return undefined;
536
+ }
537
+
538
+ const parentParse: SectionIdParseResult = this.grammar.parseSectionId(parentId);
539
+
540
+ if (!parentParse.valid) {
541
+ return undefined;
542
+ }
543
+
544
+ const segments: readonly string[] = sectionPath.split('.');
545
+ const ownPrefix: string = segments.slice(0, -1).join('.');
546
+
547
+ if (ownPrefix === parentParse.sectionPath) {
548
+ return undefined;
549
+ }
550
+
551
+ const expectedId: string = `${this.docId}#${parentParse.sectionPath}.${segments[segments.length - 1] ?? ''}`;
552
+
553
+ return this.createDiagnostic(
554
+ `SectionID "${sectionId}" sits under "${parentId}", so its section path must begin ` +
555
+ `"${parentParse.sectionPath}." (for example "${expectedId}").`,
556
+ range,
557
+ sectionId,
558
+ );
559
+ }
560
+
561
+ /**
562
+ * Extracts the SectionID candidate string from a heading's text.
563
+ *
564
+ * The candidate is the portion of the heading text before the first
565
+ * occurrence of a separator (any dash variant). If no separator
566
+ * is found, the entire trimmed text is returned as the candidate.
567
+ *
568
+ * @param headingText - The plain text content of the heading node
569
+ * @returns The SectionID candidate string
570
+ */
571
+ private extractSectionIdCandidate(headingText: string): string {
572
+ const match: RegExpExecArray | null = SECTION_SEPARATOR_PATTERN.exec(headingText);
573
+
574
+ if (match === null) {
575
+ return headingText.trim();
576
+ }
577
+
578
+ return headingText.substring(0, match.index).trim();
579
+ }
580
+
581
+ /**
582
+ * Extracts the title text from a heading's text.
583
+ *
584
+ * The title is the portion of the heading text after the first
585
+ * occurrence of a separator (any dash variant). If no separator
586
+ * is found, an empty string is returned.
587
+ *
588
+ * @param headingText - The plain text content of the heading node
589
+ * @returns The extracted title string
590
+ */
591
+ private extractHeadingTitle(headingText: string): string {
592
+ const match: RegExpExecArray | null = SECTION_SEPARATOR_PATTERN.exec(headingText);
593
+
594
+ if (match === null) {
595
+ return '';
596
+ }
597
+
598
+ return headingText.substring(match.index + match[0].length);
599
+ }
600
+
601
+ /**
602
+ * Looks up the parent identifier from the heading stack for a heading
603
+ * at the given depth.
604
+ *
605
+ * The parent is the nearest preceding heading whose depth is exactly
606
+ * `headingDepth - 1`. Returns `undefined` if no such heading exists
607
+ * in the stack (which should not happen for well-formed documents
608
+ * after the root H1 is registered).
609
+ *
610
+ * @param headingDepth - The depth of the heading whose parent is sought
611
+ * @returns The parent's identifier, or `undefined` if not found
612
+ */
613
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: parent may be DocID or SectionID
614
+ private lookUpParentId(headingDepth: number): DocID | SectionID | undefined {
615
+ const targetDepth: number = headingDepth - 1;
616
+
617
+ for (let index: number = this.headingStack.length - 1; index >= 0; index--) {
618
+ const entry: HeadingStackEntry | undefined = this.headingStack[index];
619
+
620
+ if (entry?.depth === targetDepth) {
621
+ return entry.id;
622
+ }
623
+ }
624
+
625
+ return undefined;
626
+ }
627
+
628
+ /**
629
+ * Returns the depth of the topmost entry on the heading stack.
630
+ *
631
+ * Used to detect skipped heading levels: if the incoming heading depth
632
+ * exceeds the top-of-stack depth by more than 1, an intermediate heading
633
+ * level has been skipped.
634
+ *
635
+ * Returns 0 if the heading stack is empty (should not occur after
636
+ * {@link registerRootHeading} has been called).
637
+ *
638
+ * @returns The depth of the topmost heading stack entry, or 0 if empty
639
+ */
640
+ private peekHeadingStackDepth(): number {
641
+ if (this.headingStack.length === 0) {
642
+ return 0;
643
+ }
644
+
645
+ const topEntry: HeadingStackEntry | undefined =
646
+ this.headingStack[this.headingStack.length - 1];
647
+
648
+ return topEntry?.depth ?? 0;
649
+ }
650
+
651
+ /**
652
+ * Updates the heading stack when a new heading is encountered.
653
+ *
654
+ * All entries with depth \>= the new heading's depth are removed,
655
+ * then the new heading is pushed. This ensures the stack always
656
+ * reflects the current structural nesting.
657
+ *
658
+ * @param id - The DocID or SectionID of the heading
659
+ * @param depth - The Markdown heading depth
660
+ */
661
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: id may be DocID or SectionID
662
+ private updateHeadingStack(id: DocID | SectionID, depth: number): void {
663
+ while (this.headingStack.length > 0) {
664
+ const topEntry: HeadingStackEntry | undefined =
665
+ this.headingStack[this.headingStack.length - 1];
666
+
667
+ if (topEntry === undefined || topEntry.depth < depth) {
668
+ break;
669
+ }
670
+
671
+ this.headingStack.pop();
672
+ }
673
+
674
+ this.headingStack.push({ id, depth });
675
+ }
676
+
677
+
678
+ /**
679
+ * Creates a diagnostic object for the Section Hierarchy Rule.
680
+ *
681
+ * All diagnostics share the same rule ID ({@link SECTION_HIERARCHY_RULE_ID}),
682
+ * severity (error), and document URI.
683
+ *
684
+ * @param message - Human-readable description of the issue
685
+ * @param range - Optional positional range of the heading within the source document
686
+ * @param sectionId - Optional SectionID providing structural context
687
+ * @returns A fully populated diagnostic object
688
+ */
689
+ private createDiagnostic(
690
+ message: string,
691
+ range?: PositionRange,
692
+ sectionId?: SectionID,
693
+ ): Diagnostic {
694
+ const diagnostic: Diagnostic = {
695
+ ruleId: SECTION_HIERARCHY_RULE_ID,
696
+ severity: SECTION_HIERARCHY_DIAGNOSTIC_SEVERITY,
697
+ message,
698
+ uri: this.uri,
699
+ ...(range !== undefined ? { range } : {}),
700
+ ...(sectionId !== undefined ? { sectionId } : {}),
701
+ };
702
+
703
+ return diagnostic;
704
+ }
705
+ }