@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,1182 @@
1
+ /**
2
+ * References Section Rule (ECR103)
3
+ *
4
+ * Validates the mandatory References section defined in the ECR specification:
5
+ * - 1#9.6 -- References Section Rules (Mandatory)
6
+ * - 1#9.7 -- Direction Label Semantics
7
+ * - 1#9.8 -- Per-Document Structural Invariants ("References section present exactly once",
8
+ * "No duplicate TargetDocID entries in References")
9
+ *
10
+ * This rule operates on heading node data to detect the `## References` heading,
11
+ * and on list item text content to parse and validate individual reference entries.
12
+ * It delegates identifier parsing to {@link IdentifierGrammar} and emits
13
+ * {@link Diagnostic} objects for any violations.
14
+ *
15
+ * Extracted artefacts:
16
+ * - {@link ReferenceEdge} for each valid list item in the References section
17
+ */
18
+
19
+ import type {
20
+ DocID,
21
+ Diagnostic,
22
+ DiagnosticSeverity,
23
+ PositionRange,
24
+ ReferenceEdge,
25
+ ReferenceDirection,
26
+ } from './types.js';
27
+ import { IdentifierGrammar } from './identifier-grammar.js';
28
+ import type { DocIdParseResult, SeparatorValidationResult } from './identifier-grammar.js';
29
+ import type { HeadingNodeData } from './document-identity-rule.js';
30
+ import type { HeadingObstruction } from './heading-source-form.js';
31
+ import { SourceLines } from './source-lines.js';
32
+ import { alignParsedToSource } from './source-alignment.js';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Rule identifier constant
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Canonical rule identifier for the References Section Rule.
40
+ *
41
+ * Referenced as [ECR103] in the ECR specification (1#9.6).
42
+ */
43
+ export const REFERENCES_SECTION_RULE_ID: string = 'ECR103';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Input types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * Data extracted from a single list item AST node within the References
51
+ * section, provided by the document traversal layer.
52
+ *
53
+ * The rule does not parse Markdown itself; it operates on list item
54
+ * data supplied by the visitor/traversal infrastructure.
55
+ */
56
+ export interface ListItemNodeData {
57
+ /**
58
+ * Plain text content of the list item node.
59
+ *
60
+ * Expected to match the pattern:
61
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
62
+ *
63
+ * @example "3.1 - Scenario Authoring (authority - defines guardrail logic)"
64
+ */
65
+ readonly text: string;
66
+
67
+ /**
68
+ * Positional range of the list item node within the source document.
69
+ * Optional; depends on whether the Markdown parser provides positional metadata.
70
+ */
71
+ readonly range?: PositionRange;
72
+
73
+ /**
74
+ * The parsed nodes that make up {@link text}, in order, joined to form it.
75
+ *
76
+ * Needed to locate the relationship parenthetical in the source. It is
77
+ * found in the parsed text, and only the parsed text knows which `(` it
78
+ * is: raw Markdown gains and loses parentheses through character
79
+ * references, formatting and link destinations, so balancing the source
80
+ * separately picked a different `(` and judged the wrong one. The segment
81
+ * holding the parsed `(` says where to look. When absent, as in unit tests
82
+ * that supply only text, the label's source position is not checked.
83
+ */
84
+ readonly segments?: readonly ListItemSegment[];
85
+ }
86
+
87
+ /**
88
+ * One parsed node's contribution to a References entry's text.
89
+ */
90
+ export interface ListItemSegment {
91
+ /**
92
+ * How the segment's characters map back to the source: `text` for a parsed
93
+ * text node, through escapes and character references; `code` for inline
94
+ * code, whose content is the source between its backtick fences; `other`
95
+ * for anything else that contributes text, such as the text of HTML, which
96
+ * is not traced.
97
+ */
98
+ readonly kind: 'text' | 'code' | 'other';
99
+
100
+ /** The characters the node contributes. */
101
+ readonly text: string;
102
+
103
+ /** The node's position in the source. */
104
+ readonly range?: PositionRange;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Result types
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * The complete result produced by finalising the References Section Rule
113
+ * after all headings and list items have been evaluated.
114
+ *
115
+ * Contains zero or more diagnostics and the list of successfully extracted
116
+ * reference edges.
117
+ */
118
+ export interface ReferencesSectionRuleResult {
119
+ /** Diagnostics emitted during evaluation (errors for violations). */
120
+ readonly diagnostics: readonly Diagnostic[];
121
+
122
+ /**
123
+ * Reference edges extracted from valid list items, in document traversal order.
124
+ *
125
+ * Each edge represents a document-level typed relationship from the current
126
+ * document to a referenced document.
127
+ */
128
+ readonly references: readonly ReferenceEdge[];
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Parsed reference entry (intermediate type)
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Represents the successfully parsed components of a single References
137
+ * section list item.
138
+ *
139
+ * This is an intermediate type used internally during evaluation. It holds
140
+ * the parsed fields before they are assembled into a {@link ReferenceEdge}.
141
+ */
142
+ export interface ParsedReferenceEntry {
143
+ /** The target DocID extracted from the list item. */
144
+ readonly targetDocId: DocID;
145
+
146
+ /** The title text extracted from the list item. */
147
+ readonly title: string;
148
+
149
+ /** The direction label extracted from the parenthetical. */
150
+ readonly direction: ReferenceDirection;
151
+
152
+ /** The explanation text extracted from the parenthetical. */
153
+ readonly explanation: string;
154
+ }
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // Constructor options
158
+ // ---------------------------------------------------------------------------
159
+
160
+ /**
161
+ * Configuration options for constructing a {@link ReferencesSectionRule} instance.
162
+ */
163
+ export interface ReferencesSectionRuleOptions {
164
+ /**
165
+ * The opaque, host-provided URI identifying the document being validated.
166
+ * Attached to all emitted diagnostics.
167
+ */
168
+ readonly uri: string;
169
+
170
+ /**
171
+ * The DocID established by the {@link DocumentIdentityRule} for this document.
172
+ * Used as the `fromDocId` field in all extracted {@link ReferenceEdge} objects.
173
+ */
174
+ readonly docId: DocID;
175
+
176
+ /**
177
+ * The {@link IdentifierGrammar} instance used for DocID parsing
178
+ * and separator validation.
179
+ */
180
+ readonly grammar: IdentifierGrammar;
181
+
182
+ /**
183
+ * The document's raw Markdown, for the References source-form checks of
184
+ * 1#9.11 rule 4. When absent, as in unit tests that supply only parsed
185
+ * text, those checks are skipped.
186
+ */
187
+ readonly sourceText?: string;
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Diagnostic causes (1#9.11 rules 3 and 4, 1#9.6)
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /** The `data.cause` of a `## References` heading no recipe can find. */
195
+ export const REFERENCES_HEADING_SOURCE_FORM_CAUSE: string = 'references-heading-source-form';
196
+
197
+ /** The `data.cause` of a References section that is not at the document root. */
198
+ export const REFERENCES_PLACEMENT_CAUSE: string = 'references-placement';
199
+
200
+ /** The `data.cause` of a References entry the entry recipes cannot find. */
201
+ export const REFERENCES_ENTRY_SOURCE_FORM_CAUSE: string = 'references-entry-source-form';
202
+
203
+ /** The `data.cause` of an entry whose relationship parenthetical never closes. */
204
+ export const REFERENCES_ENTRY_UNBALANCED_CAUSE: string = 'references-entry-unbalanced';
205
+
206
+ /** The `data.cause` of a References section with no entries (reported as info). */
207
+ export const REFERENCES_EMPTY_CAUSE: string = 'references-empty';
208
+
209
+ /**
210
+ * Where a References entry's relationship parenthetical begins, or why it
211
+ * cannot be located.
212
+ */
213
+ type ParentheticalLocation = { readonly open: number } | 'missing' | 'unbalanced';
214
+
215
+ /**
216
+ * The start of a References entry as the entry recipe reads it: optional
217
+ * indentation, a list marker, optional whitespace and an optional `[`. The
218
+ * target DocID must follow immediately.
219
+ */
220
+ const ENTRY_LINE_PREFIX: RegExp = /^\s*(?:[-*+]|[0-9]+[.)])\s*\[?/;
221
+
222
+ /**
223
+ * What may follow the target DocID on its line, per the entry recipe
224
+ * `^\s*([-*+]|[0-9]+[.)])\s*\[?8\.1[^0-9.#]`.
225
+ */
226
+ const AFTER_ENTRY_DOC_ID: RegExp = /^[^0-9.#]/;
227
+
228
+ // ---------------------------------------------------------------------------
229
+ // Diagnostic severity constant (module-level, non-exported)
230
+ // ---------------------------------------------------------------------------
231
+
232
+ /**
233
+ * The severity of every References Section Rule diagnostic except the
234
+ * empty-section finding, which is information (1#9.6, 1#12.3).
235
+ *
236
+ * Per 1#9.8, violation of any per-document structural invariant is an ERROR.
237
+ */
238
+ const REFERENCES_SECTION_DIAGNOSTIC_SEVERITY: DiagnosticSeverity = 'error';
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Valid reference direction values (module-level, non-exported)
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * The set of valid direction labels for References section entries.
246
+ *
247
+ * Per 1#9.6, Direction must be one of: authority, dependency, constraint, contract.
248
+ */
249
+ const VALID_REFERENCE_DIRECTIONS: ReadonlySet<ReferenceDirection> = new Set([
250
+ 'authority',
251
+ 'dependency',
252
+ 'constraint',
253
+ 'contract',
254
+ ] as const);
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // References heading text constant (module-level, non-exported)
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * The exact heading text that identifies the References section.
262
+ *
263
+ * Per 1#9.6, the heading must be exactly "References" at depth 2.
264
+ */
265
+ const REFERENCES_HEADING_TEXT: string = 'References';
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // References heading depth constant (module-level, non-exported)
269
+ // ---------------------------------------------------------------------------
270
+
271
+ /**
272
+ * The required heading depth for the References section heading.
273
+ *
274
+ * Per 1#9.6, the References heading must be an H2 (depth 2).
275
+ */
276
+ const REFERENCES_HEADING_DEPTH: number = 2;
277
+
278
+ /**
279
+ * The References heading's whole source line, exactly (1#9.11 rule 4).
280
+ */
281
+ const REFERENCES_HEADING_LINE: string = `${'#'.repeat(REFERENCES_HEADING_DEPTH)} ${REFERENCES_HEADING_TEXT}`;
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Separator constants
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Matches the separator in a References entry: a dash delimited by a single
289
+ * space on each side.
290
+ *
291
+ * The dash may be a hyphen-minus (U+002D), an en dash (U+2013) or an em dash
292
+ * (U+2014). Real-world corpora mix all three, and the variant carries no
293
+ * structural meaning, so all are accepted.
294
+ */
295
+ const SEPARATOR_PATTERN: RegExp = / [-–—] /;
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Rule class
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Validates the References Section Rule as defined in the ECR specification (1#9.6, 1#9.7, 1#9.8).
303
+ *
304
+ * The rule enforces that:
305
+ * - Exactly one `## References` heading exists in the document
306
+ * - A list node immediately follows the References heading
307
+ * - Each list item matches the required format:
308
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
309
+ * - TargetDocID is a valid DocID per the identifier grammar
310
+ * - Direction is one of: authority, dependency, constraint, contract
311
+ * - Title and Explanation are non-empty
312
+ * - Each separator is a dash: hyphen-minus, en dash or em dash
313
+ * - No duplicate TargetDocID entries exist
314
+ *
315
+ * For each valid list item, the rule extracts a {@link ReferenceEdge} with
316
+ * `fromDocId` set to the document's established DocID.
317
+ *
318
+ * Usage:
319
+ * 1. Construct a rule instance with the document URI, established DocID,
320
+ * and an {@link IdentifierGrammar}.
321
+ * 2. Call {@link evaluateHeading} for every heading node encountered during
322
+ * AST traversal. The rule uses this to detect `## References` headings.
323
+ * 3. Call {@link evaluateListItem} for every list item node encountered
324
+ * within the References section during AST traversal.
325
+ * 4. Call {@link finalise} after all nodes have been evaluated to obtain
326
+ * the complete result including any "missing References" diagnostics.
327
+ *
328
+ * @example
329
+ * ```ts
330
+ * const grammar = new IdentifierGrammar();
331
+ * const rule = new ReferencesSectionRule({
332
+ * uri: 'file:///doc.md',
333
+ * docId: '5.1',
334
+ * grammar,
335
+ * });
336
+ *
337
+ * rule.evaluateHeading({ depth: 1, text: '5.1 - My Document' });
338
+ * rule.evaluateHeading({ depth: 2, text: '5.1#1 - Section' });
339
+ * rule.evaluateHeading({ depth: 2, text: 'References' });
340
+ *
341
+ * rule.evaluateListItem({
342
+ * text: '3.1 - Scenario Authoring (authority - defines guardrail logic)',
343
+ * });
344
+ * rule.evaluateListItem({
345
+ * text: '8.1 - Orchestration Contract (constraint - retry semantics)',
346
+ * });
347
+ *
348
+ * const result: ReferencesSectionRuleResult = rule.finalise();
349
+ * ```
350
+ */
351
+ export class ReferencesSectionRule {
352
+ /** The opaque, host-provided URI identifying the document being validated. */
353
+ private readonly uri: string;
354
+
355
+ /** The established DocID for this document, used as `fromDocId` in extracted edges. */
356
+ private readonly docId: DocID;
357
+
358
+ /** The grammar instance used for DocID parsing and separator validation. */
359
+ private readonly grammar: IdentifierGrammar;
360
+
361
+ /** Diagnostics accumulated during evaluation. */
362
+ private readonly collectedDiagnostics: Diagnostic[];
363
+
364
+ /** Reference edges extracted from valid list items, in traversal order. */
365
+ private readonly collectedReferences: ReferenceEdge[];
366
+
367
+ /**
368
+ * Number of `## References` headings encountered during evaluation.
369
+ * Used to detect missing or duplicate References sections.
370
+ */
371
+ private referencesHeadingCount: number;
372
+
373
+ /**
374
+ * Set of TargetDocIDs already encountered in the References section,
375
+ * for duplicate detection.
376
+ */
377
+ private readonly encounteredTargetDocIds: Set<DocID>;
378
+
379
+ /** The document's source by line; absent when none was supplied. */
380
+ private readonly sourceLines: SourceLines | undefined;
381
+
382
+ /**
383
+ * Whether the section was found somewhere other than the document root.
384
+ * Its placement is then the one thing reported: every line of a nested
385
+ * section starts with its container's prefix, so checking each entry's
386
+ * source form as well would report the same mistake once per entry.
387
+ */
388
+ private sectionMisplaced: boolean;
389
+
390
+ /**
391
+ * Number of list items evaluated under the References heading, valid or
392
+ * not. Zero means the section declares no entries (1#9.6).
393
+ */
394
+ private evaluatedListItemCount: number;
395
+
396
+ /**
397
+ * Constructs a new References Section Rule evaluator.
398
+ *
399
+ * @param options - Configuration including the document URI, established DocID,
400
+ * and grammar instance
401
+ */
402
+ public constructor(options: ReferencesSectionRuleOptions) {
403
+ this.uri = options.uri;
404
+ this.docId = options.docId;
405
+ this.grammar = options.grammar;
406
+ this.collectedDiagnostics = [];
407
+ this.collectedReferences = [];
408
+ this.referencesHeadingCount = 0;
409
+ this.encounteredTargetDocIds = new Set<DocID>();
410
+ this.sourceLines =
411
+ options.sourceText === undefined ? undefined : new SourceLines(options.sourceText);
412
+ this.sectionMisplaced = false;
413
+ this.evaluatedListItemCount = 0;
414
+ }
415
+
416
+ /**
417
+ * Records where the References section sits, and checks its heading's
418
+ * source form (1#9.11 rules 3 and 4).
419
+ *
420
+ * Called once, by the traversal layer, for the section whose list it then
421
+ * feeds. A section nested in a blockquote, a list item or any other
422
+ * container is reported once as misplaced. One at the root has its heading
423
+ * line checked: it must be `## References` at the start of a line a search
424
+ * sees, because the recipe that reads a document's references is
425
+ * `^## References`.
426
+ *
427
+ * @param headingRange - Position of the `## References` heading
428
+ * @param nested - Whether the heading, or the list after it, is not a root child
429
+ */
430
+ public evaluateSectionPlacement(headingRange: PositionRange | undefined, nested: boolean): void {
431
+ if (nested) {
432
+ this.sectionMisplaced = true;
433
+ this.collectedDiagnostics.push(
434
+ this.createDiagnostic(
435
+ `The References section is nested inside another element. The heading and its ` +
436
+ `list must both be at the top level of the document, where the recipe ` +
437
+ `"^## References" finds them; move the section out of its container.`,
438
+ headingRange,
439
+ { cause: REFERENCES_PLACEMENT_CAUSE },
440
+ ),
441
+ );
442
+ return;
443
+ }
444
+
445
+ const obstruction: HeadingObstruction | undefined = this.findHeadingObstruction(headingRange);
446
+
447
+ if (obstruction !== undefined) {
448
+ this.collectedDiagnostics.push(
449
+ this.createDiagnostic(
450
+ obstruction === 'lone-carriage-return'
451
+ ? `The References heading follows a lone carriage return (CR) line ending, so ` +
452
+ `to a search it is the middle of the line before, and "^## References" does ` +
453
+ `not find it. Save the file with LF or CRLF line endings.`
454
+ : `The References heading's line is not exactly "## References", so the ` +
455
+ `recipe "^## References" is not guaranteed to find it. Write two # ` +
456
+ `characters at the start of the line, one space and the word References, ` +
457
+ `with nothing else on the line: no formatting, closing hashes or trailing spaces.`,
458
+ headingRange,
459
+ { cause: REFERENCES_HEADING_SOURCE_FORM_CAUSE, obstruction },
460
+ ),
461
+ );
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Evaluates a single heading node to detect `## References` headings.
467
+ *
468
+ * A heading is considered a References heading when its depth is exactly 2
469
+ * and its text content is exactly "References". The rule tracks how many
470
+ * such headings are encountered to enforce the "exactly one" constraint.
471
+ *
472
+ * Headings that do not match are ignored by this rule.
473
+ *
474
+ * @param headingNodeData - Data extracted from a heading AST node
475
+ */
476
+ public evaluateHeading(headingNodeData: HeadingNodeData): void {
477
+ if (headingNodeData.depth !== REFERENCES_HEADING_DEPTH) {
478
+ return;
479
+ }
480
+
481
+ if (headingNodeData.text !== REFERENCES_HEADING_TEXT) {
482
+ return;
483
+ }
484
+
485
+ this.referencesHeadingCount = this.referencesHeadingCount + 1;
486
+ }
487
+
488
+ /**
489
+ * Evaluates a single list item node from within the References section.
490
+ *
491
+ * For each list item, the method validates:
492
+ * 1. The entry carries its separators (any dash variant is accepted)
493
+ * 2. The overall format matches:
494
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
495
+ * 3. TargetDocID parses as a valid DocID per the identifier grammar
496
+ * 4. Direction is one of the allowed enumeration values
497
+ * 5. Title is non-empty (after trimming)
498
+ * 6. Explanation is non-empty (after trimming)
499
+ * 7. TargetDocID has not been seen before in this References section
500
+ *
501
+ * When all validations pass, a {@link ReferenceEdge} is extracted with
502
+ * `fromDocId` set to the document's DocID.
503
+ *
504
+ * When any validation fails, an error diagnostic is emitted and no
505
+ * edge is extracted for that list item.
506
+ *
507
+ * @param listItemNodeData - Data extracted from a list item AST node
508
+ * within the References section
509
+ */
510
+ public evaluateListItem(listItemNodeData: ListItemNodeData): void {
511
+ if (this.referencesHeadingCount === 0) {
512
+ return;
513
+ }
514
+
515
+ this.evaluatedListItemCount += 1;
516
+
517
+ const text: string = listItemNodeData.text;
518
+ const range: PositionRange | undefined = listItemNodeData.range;
519
+
520
+ // 1. Validate that the separators are present
521
+ const separatorDiagnostic: Diagnostic | undefined =
522
+ this.validateListItemSeparators(text, range);
523
+
524
+ if (separatorDiagnostic !== undefined) {
525
+ this.collectedDiagnostics.push(separatorDiagnostic);
526
+ return;
527
+ }
528
+
529
+ // 2. Parse the list item text into components
530
+ const parsedEntry: ParsedReferenceEntry | 'unbalanced' | undefined =
531
+ this.parseListItemText(text);
532
+
533
+ if (parsedEntry === 'unbalanced') {
534
+ this.collectedDiagnostics.push(
535
+ this.createDiagnostic(
536
+ `References entry is malformed: its relationship parenthetical is not balanced. ` +
537
+ `Matching its final ")" back to an opening "(" never closes, so the direction ` +
538
+ `and explanation cannot be located. Parentheses must be balanced within ` +
539
+ `"(direction - explanation)".`,
540
+ range,
541
+ { cause: REFERENCES_ENTRY_UNBALANCED_CAUSE },
542
+ ),
543
+ );
544
+ return;
545
+ }
546
+
547
+ if (parsedEntry === undefined) {
548
+ const diagnostic: Diagnostic = this.createDiagnostic(
549
+ `References entry does not match the required format: ` +
550
+ `DocID - Title (direction - explanation). ` +
551
+ `A hyphen, en dash or em dash may be used as each separator.`,
552
+ range,
553
+ );
554
+ this.collectedDiagnostics.push(diagnostic);
555
+ return;
556
+ }
557
+
558
+ // 3. Validate TargetDocID is a valid DocID
559
+ const docIdParseResult: DocIdParseResult = this.grammar.parseDocId(parsedEntry.targetDocId);
560
+
561
+ if (!docIdParseResult.valid) {
562
+ const diagnostic: Diagnostic = this.createDiagnostic(
563
+ `References entry target "${parsedEntry.targetDocId}" is not a valid DocID.`,
564
+ range,
565
+ );
566
+ this.collectedDiagnostics.push(diagnostic);
567
+ return;
568
+ }
569
+
570
+ // 4. Validate direction
571
+ if (!this.tellValidDirection(parsedEntry.direction)) {
572
+ const diagnostic: Diagnostic = this.createDiagnostic(
573
+ `References entry direction "${String(parsedEntry.direction)}" is not valid. ` +
574
+ `Allowed values: authority, dependency, constraint, contract.`,
575
+ range,
576
+ );
577
+ this.collectedDiagnostics.push(diagnostic);
578
+ return;
579
+ }
580
+
581
+ // 5. Validate title is non-empty
582
+ const trimmedTitle: string = parsedEntry.title.trim();
583
+
584
+ if (trimmedTitle.length === 0) {
585
+ const diagnostic: Diagnostic = this.createDiagnostic(
586
+ `References entry title must be non-empty.`,
587
+ range,
588
+ );
589
+ this.collectedDiagnostics.push(diagnostic);
590
+ return;
591
+ }
592
+
593
+ // 6. Validate explanation is non-empty
594
+ const trimmedExplanation: string = parsedEntry.explanation.trim();
595
+
596
+ if (trimmedExplanation.length === 0) {
597
+ const diagnostic: Diagnostic = this.createDiagnostic(
598
+ `References entry explanation must be non-empty.`,
599
+ range,
600
+ );
601
+ this.collectedDiagnostics.push(diagnostic);
602
+ return;
603
+ }
604
+
605
+ // 7. Check for duplicate TargetDocID
606
+ if (this.encounteredTargetDocIds.has(parsedEntry.targetDocId)) {
607
+ const diagnostic: Diagnostic = this.createDiagnostic(
608
+ `Duplicate TargetDocID "${parsedEntry.targetDocId}" in References section. ` +
609
+ `Each TargetDocID must appear only once.`,
610
+ range,
611
+ );
612
+ this.collectedDiagnostics.push(diagnostic);
613
+ return;
614
+ }
615
+
616
+ // 8. Check the entry is where its recipes look (1#9.11 rule 4). Reported,
617
+ // but the edge is still extracted: withholding it would make every inline
618
+ // citation of the target undeclared, reporting one mistake many times.
619
+ const sourceFormDiagnostic: Diagnostic | undefined = this.validateEntrySourceForm(
620
+ parsedEntry,
621
+ listItemNodeData,
622
+ );
623
+
624
+ if (sourceFormDiagnostic !== undefined) {
625
+ this.collectedDiagnostics.push(sourceFormDiagnostic);
626
+ }
627
+
628
+ // All validations passed -- extract ReferenceEdge
629
+ this.encounteredTargetDocIds.add(parsedEntry.targetDocId);
630
+
631
+ const referenceEdge: ReferenceEdge = {
632
+ fromDocId: this.docId,
633
+ toDocId: parsedEntry.targetDocId,
634
+ direction: parsedEntry.direction,
635
+ explanation: parsedEntry.explanation,
636
+ title: parsedEntry.title,
637
+ };
638
+
639
+ this.collectedReferences.push(referenceEdge);
640
+ }
641
+
642
+ /**
643
+ * Indicates whether the References section heading has been detected.
644
+ *
645
+ * This is used by the traversal layer to determine when list items
646
+ * should be fed to this rule. Only list items immediately following
647
+ * the `## References` heading should be evaluated.
648
+ *
649
+ * @returns `true` if at least one `## References` heading has been encountered
650
+ */
651
+ public tellReferencesHeadingDetected(): boolean {
652
+ return this.referencesHeadingCount > 0;
653
+ }
654
+
655
+ /**
656
+ * Finalises the rule evaluation and produces the complete result.
657
+ *
658
+ * This method must be called after all heading and list item nodes
659
+ * have been supplied via {@link evaluateHeading} and {@link evaluateListItem}.
660
+ * It analyses the count of References headings encountered and emits
661
+ * appropriate diagnostics:
662
+ *
663
+ * - Zero References headings: emits a "missing References section" error diagnostic
664
+ * - Multiple References headings: emits a "multiple References sections" error diagnostic
665
+ * - Exactly one References heading with no list items: emits an "empty section" info
666
+ * diagnostic, since an empty section is valid (1#9.6)
667
+ *
668
+ * @returns The complete rule result including all diagnostics and extracted reference edges
669
+ */
670
+ public finalise(): ReferencesSectionRuleResult {
671
+ if (this.referencesHeadingCount === 0) {
672
+ const diagnostic: Diagnostic = this.createDiagnostic(
673
+ 'Document is missing a ## References section.',
674
+ );
675
+ this.collectedDiagnostics.push(diagnostic);
676
+ } else if (this.referencesHeadingCount > 1) {
677
+ const diagnostic: Diagnostic = this.createDiagnostic(
678
+ `Document contains multiple References sections (found ${String(this.referencesHeadingCount)}), ` +
679
+ `but exactly one is required.`,
680
+ );
681
+ this.collectedDiagnostics.push(diagnostic);
682
+ } else if (this.evaluatedListItemCount === 0) {
683
+ // Valid, and reported as information (1#9.6, 1#12.3): an empty section
684
+ // states that the document references nothing. The severity is set
685
+ // here, where the rule is defined, rather than downgraded afterwards by
686
+ // a caller matching this message's wording -- which reverted to an
687
+ // error for anyone using the rule directly, and would have done so for
688
+ // everyone the day the message was reworded.
689
+ const diagnostic: Diagnostic = this.createDiagnostic(
690
+ 'References section has no entries, which declares that this document ' +
691
+ 'references no other document. If it does, list each one beneath the ' +
692
+ '## References heading.',
693
+ undefined,
694
+ { cause: REFERENCES_EMPTY_CAUSE },
695
+ 'info',
696
+ );
697
+ this.collectedDiagnostics.push(diagnostic);
698
+ }
699
+
700
+ return {
701
+ diagnostics: this.collectedDiagnostics,
702
+ references: this.collectedReferences,
703
+ };
704
+ }
705
+
706
+ // -------------------------------------------------------------------------
707
+ // Private helpers
708
+ // -------------------------------------------------------------------------
709
+
710
+ /**
711
+ * Parses a list item text string into its component parts.
712
+ *
713
+ * Attempts to match the pattern:
714
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
715
+ *
716
+ * The relationship parenthetical is located first, by matching the entry's
717
+ * final `)` to its opening `(` (1#9.6). Taking the last `" ("` instead read
718
+ * `defines retries (contract - policy)` as a `contract` edge, and failed
719
+ * outright on `defines retries (including backoff)`. Within the
720
+ * parenthetical, the first separator splits Direction from Explanation;
721
+ * before it, the first separator splits TargetDocID from Title, which may
722
+ * itself contain separators.
723
+ *
724
+ * @param text - The plain text content of a list item node
725
+ * @returns The parsed entry, `'unbalanced'` when the parenthetical never
726
+ * closes, or `undefined` when the entry is otherwise malformed
727
+ */
728
+ private parseListItemText(text: string): ParsedReferenceEntry | 'unbalanced' | undefined {
729
+ const entry: string = text.trimEnd();
730
+ const location: ParentheticalLocation =
731
+ ReferencesSectionRule.locateRelationshipParenthetical(entry);
732
+
733
+ if (location === 'unbalanced') {
734
+ return 'unbalanced';
735
+ }
736
+
737
+ if (location === 'missing') {
738
+ return undefined;
739
+ }
740
+
741
+ // Markdown renders any whitespace before the "(" as a space, including a
742
+ // line break; whether the label shares the marker's source line is the
743
+ // source-form check's question, not the parser's.
744
+ const prefixWithGap: string = entry.substring(0, location.open);
745
+
746
+ if (!/\s$/.test(prefixWithGap)) {
747
+ return undefined;
748
+ }
749
+
750
+ const lastParenOpenIndex: number = prefixWithGap.trimEnd().length;
751
+
752
+ // Extract the parenthetical content (between "(" and the final ")")
753
+ const parentheticalContent: string = entry.substring(
754
+ location.open + 1,
755
+ entry.length - 1,
756
+ );
757
+
758
+ // Step 3: Split the parenthetical on " - " to get Direction and Explanation
759
+ const parentheticalMatch: RegExpExecArray | null =
760
+ SEPARATOR_PATTERN.exec(parentheticalContent);
761
+
762
+ if (parentheticalMatch === null) {
763
+ return undefined;
764
+ }
765
+
766
+ const parentheticalSeparatorIndex: number = parentheticalMatch.index;
767
+ const parentheticalSeparatorLength: number = parentheticalMatch[0].length;
768
+
769
+ const directionCandidate: string = parentheticalContent.substring(
770
+ 0,
771
+ parentheticalSeparatorIndex,
772
+ );
773
+ const explanationCandidate: string = parentheticalContent.substring(
774
+ parentheticalSeparatorIndex + parentheticalSeparatorLength,
775
+ );
776
+
777
+ // Step 4: Extract the prefix portion (before the parenthetical)
778
+ const prefixPortion: string = entry.substring(0, lastParenOpenIndex);
779
+
780
+ // Step 5: Find the first " - " in the prefix to split DocID from Title
781
+ const prefixMatch: RegExpExecArray | null = SEPARATOR_PATTERN.exec(prefixPortion);
782
+
783
+ if (prefixMatch === null) {
784
+ return undefined;
785
+ }
786
+
787
+ const firstSeparatorIndex: number = prefixMatch.index;
788
+
789
+ const targetDocIdCandidate: string = prefixPortion.substring(
790
+ 0,
791
+ firstSeparatorIndex,
792
+ );
793
+ const titleCandidate: string = prefixPortion.substring(
794
+ firstSeparatorIndex + prefixMatch[0].length,
795
+ );
796
+
797
+ return {
798
+ targetDocId: targetDocIdCandidate,
799
+ title: titleCandidate,
800
+ direction: directionCandidate as ReferenceDirection,
801
+ explanation: explanationCandidate,
802
+ };
803
+ }
804
+
805
+ /**
806
+ * Maps an offset in a segment's parsed text to its offset in the segment's
807
+ * source, by the rule for the segment's kind.
808
+ *
809
+ * @param segment - A `text` or `code` segment
810
+ * @param source - The segment's source
811
+ * @param offset - An offset within its parsed text
812
+ * @returns The corresponding source offset, or `undefined` when a text
813
+ * segment could not be aligned
814
+ */
815
+ private static locateInSegmentSource(
816
+ segment: ListItemSegment,
817
+ source: string,
818
+ offset: number,
819
+ ): number | undefined {
820
+ if (segment.kind === 'code') {
821
+ return ReferencesSectionRule.locateInCodeSource(source, offset);
822
+ }
823
+
824
+ return alignParsedToSource(source, segment.text)?.[offset];
825
+ }
826
+
827
+ /**
828
+ * Maps an offset in an inline code span's content to its source.
829
+ *
830
+ * Code content takes no escapes or character references, so it is the
831
+ * source between the backtick fences, with one exception: when it both
832
+ * begins and ends with a space, one space is stripped from each side.
833
+ *
834
+ * @param source - The code span's source, fences included
835
+ * @param offset - An offset within its parsed content
836
+ * @returns The corresponding source offset
837
+ */
838
+ private static locateInCodeSource(source: string, offset: number): number {
839
+ const fence: number = /^`+/.exec(source)?.[0].length ?? 0;
840
+ const inner: string = source.slice(fence, source.length - fence);
841
+ const stripped: boolean =
842
+ inner.length >= 2 && /^[ \r\n]/.test(inner) && /[ \r\n]$/.test(inner) && inner.trim() !== '';
843
+
844
+ return fence + (stripped ? 1 : 0) + offset;
845
+ }
846
+
847
+ /**
848
+ * Finds what, if anything, keeps `^## References` from finding the heading.
849
+ *
850
+ * The line must be `## References` exactly (1#9.11 rule 4). The check that
851
+ * numbered headings share allows anything after the identifier, since a
852
+ * title follows it; that let `## References ##` and trailing spaces pass
853
+ * here, where the specification allows nothing.
854
+ *
855
+ * @param headingRange - Position of the `## References` heading
856
+ * @returns The obstruction, or `undefined` when the heading is findable
857
+ */
858
+ private findHeadingObstruction(
859
+ headingRange: PositionRange | undefined,
860
+ ): HeadingObstruction | undefined {
861
+ if (this.sourceLines === undefined || headingRange === undefined) {
862
+ return undefined;
863
+ }
864
+
865
+ const lineNumber: number = headingRange.start.line;
866
+
867
+ if (this.sourceLines.readLine(lineNumber) !== REFERENCES_HEADING_LINE) {
868
+ return 'form';
869
+ }
870
+
871
+ return this.sourceLines.tellStartsSearchLine(lineNumber) ? undefined : 'lone-carriage-return';
872
+ }
873
+
874
+ /**
875
+ * Locates an entry's relationship parenthetical by matching its final `)`
876
+ * to the `(` that opens it, scanning right to left and counting depth
877
+ * (1#9.6). The explanation may then hold parentheses of its own, and the
878
+ * title may hold unmatched ones, without either being mistaken for it.
879
+ *
880
+ * @param entry - The entry's text, without trailing whitespace
881
+ * @returns The opening `(`'s index; `'missing'` when the entry does not end
882
+ * with `)`; `'unbalanced'` when the final `)` is never matched
883
+ */
884
+ private static locateRelationshipParenthetical(entry: string): ParentheticalLocation {
885
+ if (!entry.endsWith(')')) {
886
+ return 'missing';
887
+ }
888
+
889
+ let depth: number = 0;
890
+
891
+ for (let index: number = entry.length - 1; index >= 0; index -= 1) {
892
+ const character: string = entry.charAt(index);
893
+
894
+ if (character === ')') {
895
+ depth += 1;
896
+ } else if (character === '(') {
897
+ depth -= 1;
898
+
899
+ if (depth === 0) {
900
+ return { open: index };
901
+ }
902
+ }
903
+ }
904
+
905
+ return 'unbalanced';
906
+ }
907
+
908
+ /**
909
+ * Checks that an entry is where the References recipes look (1#9.11 rule 4).
910
+ *
911
+ * The entry recipe `^\s*([-*+]|[0-9]+[.)])\s*\[?8\.1[^0-9.#]` and the
912
+ * direction recipe, which adds `.*\(authority`, each read one line. So the
913
+ * list marker, the target DocID in literal characters and the direction
914
+ * label must all be on the entry's first line, and that line must be a
915
+ * line to a search engine. The explanation may wrap freely after the label.
916
+ *
917
+ * @param entry - The parsed entry
918
+ * @param item - The list item the entry was parsed from
919
+ * @returns A diagnostic when a recipe cannot find the entry, or `undefined`
920
+ */
921
+ private validateEntrySourceForm(
922
+ entry: ParsedReferenceEntry,
923
+ item: ListItemNodeData,
924
+ ): Diagnostic | undefined {
925
+ const range: PositionRange | undefined = item.range;
926
+
927
+ if (this.sourceLines === undefined || range === undefined || this.sectionMisplaced) {
928
+ return undefined;
929
+ }
930
+
931
+ const lineNumber: number = range.start.line;
932
+ const line: string = this.sourceLines.readLine(lineNumber) ?? '';
933
+ const data: Readonly<Record<string, unknown>> = {
934
+ cause: REFERENCES_ENTRY_SOURCE_FORM_CAUSE,
935
+ targetDocId: entry.targetDocId,
936
+ };
937
+
938
+ if (
939
+ !ReferencesSectionRule.tellEntryLineMatches(line, entry) ||
940
+ !this.tellLabelOpensFirstLineParenthetical(item, line, entry)
941
+ ) {
942
+ return this.createDiagnostic(
943
+ `The References entry for "${entry.targetDocId}" is not written as the entry ` +
944
+ `recipes read it, so they do not find it. The list marker, the target DocID in ` +
945
+ `literal characters and the direction label must all be on one line -- ` +
946
+ `"- ${entry.targetDocId} - Title (${entry.direction} - ...)" -- without ` +
947
+ `formatting on the DocID. The explanation may continue on later lines.`,
948
+ range,
949
+ { ...data, obstruction: 'form' },
950
+ );
951
+ }
952
+
953
+ if (!this.sourceLines.tellStartsSearchLine(lineNumber)) {
954
+ return this.createDiagnostic(
955
+ `The References entry for "${entry.targetDocId}" follows a lone carriage return ` +
956
+ `(CR) line ending, so to a search it is the middle of the line before, and the ` +
957
+ `entry recipes do not find it. Save the file with LF or CRLF line endings.`,
958
+ range,
959
+ { ...data, obstruction: 'lone-carriage-return' },
960
+ );
961
+ }
962
+
963
+ return undefined;
964
+ }
965
+
966
+ /**
967
+ * Reports whether an entry's first line begins as the entry recipe expects:
968
+ * a list marker, then the target DocID in literal characters.
969
+ *
970
+ * @param line - The list item's first source line
971
+ * @param entry - The parsed entry
972
+ * @returns `true` when the entry recipe matches the line's start
973
+ */
974
+ private static tellEntryLineMatches(line: string, entry: ParsedReferenceEntry): boolean {
975
+ const prefix: RegExpExecArray | null = ENTRY_LINE_PREFIX.exec(line);
976
+
977
+ if (prefix === null) {
978
+ return false;
979
+ }
980
+
981
+ const rest: string = line.slice(prefix[0].length);
982
+
983
+ return (
984
+ rest.startsWith(entry.targetDocId) &&
985
+ AFTER_ENTRY_DOC_ID.test(rest.slice(entry.targetDocId.length))
986
+ );
987
+ }
988
+
989
+ /**
990
+ * Reports whether the entry's own relationship parenthetical opens on its
991
+ * first line, with the direction label written literally after the `(`.
992
+ *
993
+ * The `(` is the one the parser chose: found in the parsed entry by
994
+ * matching its final `)`, then traced to the source through the parsed
995
+ * text node that holds it. Two shortcuts both failed. Asking whether
996
+ * `(dependency` occurred anywhere on the line let a title such as
997
+ * `Target (dependency graph)` answer for a formatted label. Balancing the
998
+ * raw source separately picked a different `(` whenever markup added or
999
+ * removed a parenthesis -- `&#40;`, `**(…)**`, a link destination holding
1000
+ * `)` -- rejecting valid entries and, in one case, excusing an invalid one.
1001
+ *
1002
+ * @param item - The list item, with its parsed segments
1003
+ * @param line - The list item's first source line
1004
+ * @param entry - The parsed entry
1005
+ * @returns `true` when the label follows the entry's own `(` on its first line
1006
+ */
1007
+ private tellLabelOpensFirstLineParenthetical(
1008
+ item: ListItemNodeData,
1009
+ line: string,
1010
+ entry: ParsedReferenceEntry,
1011
+ ): boolean {
1012
+ if (item.segments === undefined || item.range === undefined || this.sourceLines === undefined) {
1013
+ return true;
1014
+ }
1015
+
1016
+ const location: ParentheticalLocation =
1017
+ ReferencesSectionRule.locateRelationshipParenthetical(item.text.trimEnd());
1018
+
1019
+ if (typeof location === 'string') {
1020
+ return false;
1021
+ }
1022
+
1023
+ const label: string = `(${entry.direction}`;
1024
+ let segmentStart: number = 0;
1025
+
1026
+ for (const segment of item.segments) {
1027
+ const segmentEnd: number = segmentStart + segment.text.length;
1028
+
1029
+ if (location.open < segmentEnd) {
1030
+ // The `(` and the whole label must come from one node. A label split
1031
+ // by formatting or a code fence -- `(**dependency**` or
1032
+ // ``(`dependency` `` -- is not literal text after the `(`. Inline
1033
+ // code holding the whole parenthetical is: 1#9.5 keeps citations out
1034
+ // of code, but no rule keeps References entries out of it, and both
1035
+ // recipes find `` `(dependency - x)` ``.
1036
+ if (segment.kind === 'other' || location.open + label.length > segmentEnd) {
1037
+ return false;
1038
+ }
1039
+
1040
+ return this.tellSegmentHoldsLiteralLabel(
1041
+ segment,
1042
+ location.open - segmentStart,
1043
+ label,
1044
+ item.range.start.line,
1045
+ line,
1046
+ );
1047
+ }
1048
+
1049
+ segmentStart = segmentEnd;
1050
+ }
1051
+
1052
+ return false;
1053
+ }
1054
+
1055
+ /**
1056
+ * Traces a parsed `(` to the source through its text node, and checks that
1057
+ * the label follows it literally on the entry's first line.
1058
+ *
1059
+ * @param segment - The text segment holding the `(`
1060
+ * @param offset - The `(`'s offset within the segment's parsed text
1061
+ * @param label - `(` followed by the direction label
1062
+ * @param firstLine - The entry's first line number
1063
+ * @param line - That line's text
1064
+ * @returns `true` when the source holds the label there, on that line
1065
+ */
1066
+ private tellSegmentHoldsLiteralLabel(
1067
+ segment: ListItemSegment,
1068
+ offset: number,
1069
+ label: string,
1070
+ firstLine: number,
1071
+ line: string,
1072
+ ): boolean {
1073
+ if (segment.range === undefined || this.sourceLines === undefined) {
1074
+ return false;
1075
+ }
1076
+
1077
+ const source: string | undefined = this.sourceLines.slice(segment.range);
1078
+ const at: number | undefined =
1079
+ source === undefined ? undefined : ReferencesSectionRule.locateInSegmentSource(segment, source, offset);
1080
+ const segmentStart: number | undefined = this.sourceLines.offsetOf(segment.range.start);
1081
+ const lineStart: number | undefined = this.sourceLines.offsetOf({ line: firstLine, character: 0 });
1082
+
1083
+ if (source === undefined || at === undefined || segmentStart === undefined || lineStart === undefined) {
1084
+ return false;
1085
+ }
1086
+
1087
+ return source.startsWith(label, at) && segmentStart + at < lineStart + line.length;
1088
+ }
1089
+
1090
+ /**
1091
+ * Validates that a list item carries a separator, and returns an error
1092
+ * diagnostic when none is found.
1093
+ *
1094
+ * Any dash variant is accepted. Detection is delegated to
1095
+ * {@link IdentifierGrammar.validateSeparator}.
1096
+ *
1097
+ * @param text - The plain text content of a list item node
1098
+ * @param range - Optional positional range for the diagnostic
1099
+ * @returns A diagnostic if a separator is invalid, or `undefined` if all separators are valid
1100
+ */
1101
+ private validateListItemSeparators(
1102
+ text: string,
1103
+ range?: PositionRange,
1104
+ ): Diagnostic | undefined {
1105
+ const separatorResult: SeparatorValidationResult =
1106
+ this.grammar.validateSeparator(text);
1107
+
1108
+ if (!separatorResult.valid) {
1109
+ const detectedDescription: string = this.describeSeparator(
1110
+ separatorResult.detectedSeparator,
1111
+ );
1112
+ const diagnostic: Diagnostic = this.createDiagnostic(
1113
+ `References entry is malformed: no separator found (expected ${detectedDescription}). ` +
1114
+ `The required format is: DocID <dash> Title (direction <dash> explanation).`,
1115
+ range,
1116
+ );
1117
+ return diagnostic;
1118
+ }
1119
+
1120
+ return undefined;
1121
+ }
1122
+
1123
+ /**
1124
+ * Validates that a direction string is one of the allowed
1125
+ * {@link ReferenceDirection} values.
1126
+ *
1127
+ * @param candidate - The direction string extracted from the parenthetical
1128
+ * @returns `true` if the candidate is a valid direction, `false` otherwise
1129
+ */
1130
+ private tellValidDirection(candidate: string): candidate is ReferenceDirection {
1131
+ return VALID_REFERENCE_DIRECTIONS.has(candidate as ReferenceDirection);
1132
+ }
1133
+
1134
+ /**
1135
+ * Creates a diagnostic object for the References Section Rule.
1136
+ *
1137
+ * All diagnostics share the same rule ID ({@link REFERENCES_SECTION_RULE_ID})
1138
+ * and document URI. Each is an error except the empty-section finding, which
1139
+ * is information (1#9.6, 1#12.3).
1140
+ *
1141
+ * @param message - Human-readable description of the issue
1142
+ * @param range - Optional positional range within the source document
1143
+ * @param data - Optional machine-readable detail, such as a `cause`
1144
+ * @param severity - Severity per 1#12; an error unless stated otherwise
1145
+ * @returns A fully populated diagnostic object
1146
+ */
1147
+ private createDiagnostic(
1148
+ message: string,
1149
+ range?: PositionRange,
1150
+ data?: Readonly<Record<string, unknown>>,
1151
+ severity: DiagnosticSeverity = REFERENCES_SECTION_DIAGNOSTIC_SEVERITY,
1152
+ ): Diagnostic {
1153
+ const diagnostic: Diagnostic = {
1154
+ ruleId: REFERENCES_SECTION_RULE_ID,
1155
+ severity,
1156
+ message,
1157
+ uri: this.uri,
1158
+ ...(range !== undefined ? { range } : {}),
1159
+ ...(data !== undefined ? { data } : {}),
1160
+ };
1161
+
1162
+ return diagnostic;
1163
+ }
1164
+
1165
+ /**
1166
+ * Produces a human-readable description of a detected separator string.
1167
+ *
1168
+ * Replaces non-visible characters with their Unicode names to make
1169
+ * diagnostics more informative.
1170
+ *
1171
+ * @param separator - The detected separator string
1172
+ * @returns A human-readable description suitable for inclusion in a diagnostic message
1173
+ */
1174
+ private describeSeparator(separator: string): string {
1175
+ const withNamedCharacters: string = separator
1176
+ .replace(/\u2013/g, 'en dash U+2013')
1177
+ .replace(/\u2014/g, 'em dash U+2014');
1178
+
1179
+ return `"${withNamedCharacters}"`;
1180
+ }
1181
+
1182
+ }