@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,1235 @@
1
+ /**
2
+ * Inline Reference Rule (ECR104)
3
+ *
4
+ * Detects and validates inline references in document prose as defined
5
+ * in the ECR specification:
6
+ * - 1#9.5 -- Inline Reference Rules
7
+ * - 1#10.7 -- InlineReferenceEdge
8
+ * - 1#6.3 -- Inline References (overview)
9
+ *
10
+ * This rule operates on text node data supplied by the document visitor.
11
+ * The visitor handles AST filtering (code blocks, inline code,
12
+ * HTML, link URLs) and heading context tracking. This rule receives only
13
+ * text nodes that are valid for inline reference detection, along with
14
+ * the current section context.
15
+ *
16
+ * The rule detects `see TargetID` and `per TargetID` forms where:
17
+ * - The keyword (`see` or `per`) is preceded by a word boundary
18
+ * (start of string, whitespace, or punctuation such as `(`)
19
+ * - The keyword's first letter may be capitalised (`see`, `See`, `per`, `Per`)
20
+ * - TargetID is a DocID, optionally followed by `#` and a section path
21
+ * - TargetID is maximally matched and terminated by a non-digit/non-dot
22
+ * character or end of string (a period followed by a non-digit is
23
+ * treated as punctuation, not part of the TargetID)
24
+ *
25
+ * For each valid inline reference, the rule checks that the TargetID's
26
+ * parent DocID (or the TargetID itself, if it is a DocID) is declared
27
+ * in the References section or is a self-reference to the document's
28
+ * own DocID. An undeclared SectionID target (`per 3.1#2`) is an error: the
29
+ * `#` form never occurs in prose. An undeclared DocID target is only a
30
+ * warning, because on its own a document cannot tell `per 3.1` (a reference
31
+ * someone forgot to declare) from `per 60 seconds` (ordinary prose). The
32
+ * corpus validator, which knows which documents exist, raises the former to
33
+ * an error.
34
+ *
35
+ * Extracted artefacts:
36
+ * - {@link InlineReferenceEdge} for each valid inline reference whose
37
+ * parent DocID is declared or is a self-reference
38
+ */
39
+
40
+ import type {
41
+ DocID,
42
+ SectionID,
43
+ Diagnostic,
44
+ DiagnosticSeverity,
45
+ InlineReferenceEdge,
46
+ InlineReferenceKind,
47
+ Position,
48
+ PositionRange,
49
+ } from './types.js';
50
+ import type { IdentifierGrammar } from './identifier-grammar.js';
51
+ import { indexLineStarts } from './source-lines.js';
52
+ import { alignParsedToSource } from './source-alignment.js';
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Rule identifier constant
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Canonical rule identifier for the Inline Reference Rule.
60
+ *
61
+ * Referenced as [ECR104] in the ECR specification (1#9.5).
62
+ */
63
+ export const INLINE_REFERENCE_RULE_ID: string = 'ECR104';
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Input types
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Data extracted from a single text AST node, provided by the document
71
+ * visitor/traversal layer.
72
+ *
73
+ * The visitor filters out text nodes inside code blocks, inline code,
74
+ * HTML elements, and link URL portions before passing data to this rule.
75
+ * This rule only receives text nodes that are valid candidates for
76
+ * inline reference detection.
77
+ */
78
+ export interface TextNodeData {
79
+ /**
80
+ * Plain text content of the text node.
81
+ *
82
+ * @example "Guardrail requirements are enforced per 3.1#2."
83
+ */
84
+ readonly text: string;
85
+
86
+ /**
87
+ * Positional range of the text node within the source document.
88
+ * Optional; depends on whether the Markdown parser provides positional metadata.
89
+ */
90
+ readonly range?: PositionRange;
91
+ }
92
+
93
+ /**
94
+ * How a piece of an inline run takes part in recognition (1#9.5 rule 1).
95
+ *
96
+ * - `text`: a parsed text node. Keywords and candidates may be recognised in
97
+ * it, and only text can be literal in the source.
98
+ * - `code`: inline code. It may continue a candidate, but neither a keyword
99
+ * nor a candidate is ever recognised as starting inside it.
100
+ * - `break`: a hard break, an image, a `<br>` tag -- anything a reader sees
101
+ * as separating the words either side. Its text is a single space.
102
+ */
103
+ export type InlineSegmentKind = 'text' | 'code' | 'break';
104
+
105
+ /**
106
+ * One piece of the text a reader sees in an inline run: a paragraph, or
107
+ * anything else whose children are inline content.
108
+ *
109
+ * A Markdown text node ends wherever formatting begins, which is not where a
110
+ * word or an identifier ends. Recognising citations one text node at a time
111
+ * therefore missed or misread every citation that crossed formatting:
112
+ * `per 60**s**` became a reference, and `see <span>8.1#3</span>` vanished
113
+ * without a diagnostic. The run is scanned as a whole instead, and each
114
+ * segment records where its characters came from.
115
+ *
116
+ * Inline HTML other than a line break is transparent, so it contributes no
117
+ * segment at all.
118
+ */
119
+ export interface InlineSegment {
120
+ /** How the segment takes part in recognition. */
121
+ readonly kind: InlineSegmentKind;
122
+
123
+ /** The characters a reader sees. */
124
+ readonly text: string;
125
+
126
+ /** Position of the parsed text node, for a `text` segment. */
127
+ readonly range?: PositionRange;
128
+
129
+ /** MDAST types of the formatting spans enclosing it, outermost first. */
130
+ readonly wrappers: readonly string[];
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Result types
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * The complete result produced by finalising the Inline Reference Rule
139
+ * after all text nodes have been evaluated.
140
+ *
141
+ * Contains zero or more diagnostics and the list of successfully extracted
142
+ * inline reference edges.
143
+ */
144
+ export interface InlineReferenceRuleResult {
145
+ /** Diagnostics emitted during evaluation (errors for undeclared references). */
146
+ readonly diagnostics: readonly Diagnostic[];
147
+
148
+ /**
149
+ * Inline reference edges extracted from valid `see`/`per` forms,
150
+ * in document traversal order.
151
+ *
152
+ * Each edge represents a section-attributed relationship from the
153
+ * current section context to the referenced TargetID.
154
+ */
155
+ readonly inlineReferences: readonly InlineReferenceEdge[];
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Constructor options
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /**
163
+ * Configuration options for constructing an {@link InlineReferenceRule} instance.
164
+ */
165
+ export interface InlineReferenceRuleOptions {
166
+ /**
167
+ * The opaque, host-provided URI identifying the document being validated.
168
+ * Attached to all emitted diagnostics.
169
+ */
170
+ readonly uri: string;
171
+
172
+ /**
173
+ * The DocID established by the Document Identity Rule for this document.
174
+ * Used for self-reference detection: an inline reference whose target
175
+ * DocID (the TargetID itself, or the text before its `#`) equals this
176
+ * DocID needs no declaration in the References section.
177
+ */
178
+ readonly docId: DocID;
179
+
180
+ /**
181
+ * The {@link IdentifierGrammar} instance used for TargetID validation.
182
+ */
183
+ readonly grammar: IdentifierGrammar;
184
+
185
+ /**
186
+ * The set of DocIDs declared in the References section (the References Section Rule's output).
187
+ *
188
+ * Used to determine whether an inline reference's parent DocID has been
189
+ * declared. If a TargetID's parent DocID is not in this set and is not
190
+ * a self-reference, an undeclared-reference diagnostic is emitted.
191
+ */
192
+ readonly declaredDocIds: ReadonlySet<DocID>;
193
+
194
+ /**
195
+ * The document's raw Markdown source.
196
+ *
197
+ * 1#9.11 is the one rule stated over source rather than over the parsed
198
+ * tree, because a search reads the file and the parser does not. A citation
199
+ * written `see 8\\.1#3` parses to `see 8.1#3` and is invisible to every
200
+ * recipe; only the source shows the difference.
201
+ */
202
+ readonly sourceText: string;
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Diagnostic severity constant (module-level)
207
+ // ---------------------------------------------------------------------------
208
+
209
+ /**
210
+ * The severity of Inline Reference Rule diagnostics about targets that may be
211
+ * ordinary prose.
212
+ *
213
+ * Per 1#9.5, an undeclared DocID target (`per 60`, `see 8.1`) is a warning at
214
+ * document level: `see`/`per` followed by a number also occurs in ordinary
215
+ * prose. The corpus validator reports an error when the target turns out to be
216
+ * a real document. A wrapped reference is likewise a warning.
217
+ */
218
+ export const INLINE_REFERENCE_DIAGNOSTIC_SEVERITY: DiagnosticSeverity = 'warning';
219
+
220
+ /**
221
+ * The severity of an undeclared SectionID target (`see 8.1#3`).
222
+ *
223
+ * The `#` form never occurs in prose, so such a target is certainly a
224
+ * reference, and an undeclared one is an error without consulting the corpus.
225
+ */
226
+ export const UNDECLARED_SECTION_TARGET_SEVERITY: DiagnosticSeverity = 'error';
227
+
228
+ /**
229
+ * The `data.reason` carried by an undeclared-target diagnostic, which the
230
+ * corpus validator uses to find the warnings it must check against the index.
231
+ */
232
+ export const UNDECLARED_TARGET_REASON: string = 'undeclared-target';
233
+
234
+ /**
235
+ * The `data.cause` carried by a citation whose candidate is not a complete,
236
+ * conforming token and contains a `#` (1#9.5 rule 1).
237
+ */
238
+ export const MALFORMED_TARGET_CAUSE: string = 'malformed-target';
239
+
240
+ /**
241
+ * The `data.cause` carried by a citation whose keyword and identifier are not
242
+ * adjacent literal text on one source line (1#9.11 rule 2).
243
+ */
244
+ export const CITATION_SOURCE_FORM_CAUSE: string = 'citation-source-form';
245
+
246
+ /**
247
+ * Characters that may terminate a candidate identifier (1#9.5 rule 1).
248
+ *
249
+ * Whitespace, the end of the text, and sentence punctuation. A letter is
250
+ * deliberately absent: `see 1#1oops` never wrote an identifier at all.
251
+ */
252
+ const PERMITTED_TERMINATOR_PUNCTUATION: ReadonlySet<string> = new Set([
253
+ ',', ';', ':', ')', ']', '}', '"', "'", '!', '?',
254
+ ]);
255
+
256
+ /**
257
+ * Any whitespace, which may also terminate a candidate.
258
+ *
259
+ * Tested as a class rather than listed, so a non-breaking or em space ends an
260
+ * identifier as an ordinary space does. Listing only the ASCII four made
261
+ * `see 1#1` followed by a non-breaking space a malformed-target error, and
262
+ * silently dropped the edge for `see 1`.
263
+ */
264
+ const WHITESPACE: RegExp = /\s/u;
265
+
266
+ /**
267
+ * A word character, for deciding whether a keyword stands at a boundary.
268
+ *
269
+ * This is ripgrep's definition (Rust's `\w`), chosen after measuring both
270
+ * published engines against the same inputs. They do not agree:
271
+ *
272
+ * preceding character ripgrep grep -E (C.UTF-8)
273
+ * space, hyphen match match
274
+ * e, e-acute, digit, _ no no
275
+ * combining acute (U+0301) no MATCH
276
+ * connector (U+203F) no MATCH
277
+ * supplementary (U+10400) no MATCH
278
+ *
279
+ * Where they differ, ripgrep is the stricter: it treats marks, connector
280
+ * punctuation and supplementary letters as word characters. The looser cases
281
+ * are simply not recognised as citations -- no edge, and no diagnostic,
282
+ * because text like `e\u0301see 1#1` is not a citation anyone wrote
283
+ * deliberately.
284
+ *
285
+ * The published recipes no longer use `\b` at all: they bound a citation by
286
+ * ERE recipes open with `(\b|_)`: ripgrep's `\b` is this same definition, so
287
+ * every keyword the linter recognises is found, and `_` is admitted so that
288
+ * `_see 8.1_` -- ordinary emphasis -- is found too. Measured against 46
289
+ * preceding characters in both engines, that form has no case where the
290
+ * linter recognises a keyword and either engine misses it. The PCRE recipe
291
+ * keeps an ASCII lookbehind instead, because PCRE's `\b` counts `\u00b2` and `\u00bd` as
292
+ * word characters and would miss them. Where an engine matches a keyword
293
+ * this set refuses, that is a permitted search hit, not an edge (1#9.11).
294
+ */
295
+ const WORD_CHARACTER: RegExp = /[\p{Alphabetic}\p{M}\p{Nd}\p{Pc}\p{Join_Control}]/u;
296
+
297
+ /** Characters a candidate identifier may be built from. */
298
+ const CANDIDATE_CHARACTERS: RegExp = /[0-9.#]/;
299
+
300
+ /**
301
+ * Characters the published recipes refuse after a citation: an ASCII letter
302
+ * or digit. Anything else -- whitespace, punctuation, `_`, `*`, the `<` of a
303
+ * tag, the `&` of a character reference -- ends it.
304
+ */
305
+ const RECIPE_EXCLUDED_BOUNDARY: RegExp = /[0-9A-Za-z]/;
306
+
307
+ /** A character that ends a source line, which a recipe's `$` accepts. */
308
+ const LINE_BREAK_CHARACTER: RegExp = /[\n\r]/;
309
+
310
+ /** A `see`/`per` keyword at a word boundary, wherever it occurs. */
311
+ const KEYWORD_PATTERN: RegExp = /([Ss]ee|[Pp]er)/g;
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // Internal match type
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /**
318
+ * Represents a single inline reference match detected within a text node.
319
+ *
320
+ * This is an intermediate type used internally during evaluation. It holds
321
+ * the parsed components of a detected `see`/`per` form before they are
322
+ * validated against the declared References set and assembled into an
323
+ * {@link InlineReferenceEdge}.
324
+ */
325
+ export interface DetectedInlineReference {
326
+ /**
327
+ * The keyword form that introduced this inline reference,
328
+ * normalised to lowercase.
329
+ */
330
+ readonly kind: InlineReferenceKind;
331
+
332
+ /**
333
+ * The TargetID extracted from the text, maximally matched.
334
+ * A DocID (`8.1`) or a SectionID (`8.1#3.2`).
335
+ */
336
+ readonly targetId: string;
337
+
338
+ /** The keyword exactly as written, preserving its capitalisation. */
339
+ readonly keyword: string;
340
+
341
+ /**
342
+ * The candidate run of identifier characters, before conformance is tested.
343
+ *
344
+ * Held separately from {@link targetId} because a candidate that fails is
345
+ * never shortened into one that passes: `1#1#9` does not become `1#1`.
346
+ */
347
+ readonly candidate: string;
348
+
349
+ /** Whether the candidate conforms, once a single trailing `.` is allowed for. */
350
+ readonly conforms: boolean;
351
+
352
+ /** The whitespace between the keyword and the candidate, exactly as written. */
353
+ readonly gap: string;
354
+
355
+ /**
356
+ * Offset of the keyword's first character within the parsed text node.
357
+ *
358
+ * This is what ties a citation to its own source characters. Every approach
359
+ * that tried to avoid carrying it -- searching for matching text, counting
360
+ * occurrences, comparing lists of targets -- could be defeated by another
361
+ * citation, or by raw text that merely resembled one.
362
+ */
363
+ readonly index: number;
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Split citations (module-level)
368
+ // ---------------------------------------------------------------------------
369
+
370
+ /**
371
+ * Inline Markdown formatting that can split a citation, with the words used
372
+ * to describe each in a diagnostic.
373
+ */
374
+ const WRAPPER_DESCRIPTIONS: ReadonlyMap<string, string> = new Map([
375
+ ['link', 'a link'],
376
+ ['linkReference', 'a link'],
377
+ ['strong', 'bold text'],
378
+ ['emphasis', 'italic text'],
379
+ ['delete', 'strikethrough text'],
380
+ ]);
381
+
382
+ /**
383
+ * How a citation split by something other than formatting is described, by
384
+ * the kind of segment in the way. Where two text segments meet with nothing
385
+ * visible between them, only inline HTML can have separated them.
386
+ */
387
+ const SPLIT_DESCRIPTIONS: ReadonlyMap<string, string> = new Map([
388
+ ['code', 'inline code'],
389
+ ['break', 'a line break'],
390
+ ]);
391
+
392
+ /** What separates inline text segments when no segment is in the way. */
393
+ const TRANSPARENT_SPLIT_DESCRIPTION: string = 'inline HTML';
394
+
395
+ /** A text segment's source, located and aligned on first use. */
396
+ interface AlignedSegment {
397
+ /** The segment's raw source, when it could be located. */
398
+ readonly source: string | undefined;
399
+ /** Parsed-to-source offsets, when alignment succeeded. */
400
+ readonly offsets: readonly number[] | undefined;
401
+ }
402
+
403
+ /** What split a citation, as a diagnostic states it. */
404
+ interface SplitDescription {
405
+ /** Phrase completing `"see 8.1#3" …`, such as `is split by bold text`. */
406
+ readonly because: string;
407
+ /** Data the diagnostic carries in addition, such as the wrapper's type. */
408
+ readonly extra?: Readonly<Record<string, unknown>>;
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Rule class
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /**
416
+ * Detects and validates inline `see` and `per` references in document
417
+ * prose as defined in the ECR specification (1#9.5, 1#10.7).
418
+ *
419
+ * The rule enforces that:
420
+ * - Inline references use the `see TargetID` or `per TargetID` keyword forms
421
+ * - The keyword is preceded by a word boundary (start of string, whitespace,
422
+ * or punctuation) to prevent false positives from words like "oversee"
423
+ * or "hyperparameter"
424
+ * - The keyword's first letter may be capitalised (`See`, `Per`); the
425
+ * `kind` field normalises to lowercase
426
+ * - TargetID conforms to the identifier grammar and is maximally matched
427
+ * - The parent DocID of the TargetID is declared in the References section,
428
+ * or the TargetID is a self-reference to the document's own DocID
429
+ *
430
+ * For each valid inline reference whose parent DocID is declared (or is a
431
+ * self-reference), the rule extracts an {@link InlineReferenceEdge} with
432
+ * `fromId` set to the section context passed per call and `kind` normalised
433
+ * to lowercase.
434
+ *
435
+ * For undeclared references, the rule emits a warning diagnostic and does
436
+ * not extract an edge.
437
+ *
438
+ * Usage:
439
+ * 1. Construct a rule instance with the document URI, established DocID,
440
+ * grammar instance, and the set of declared DocIDs from the References section.
441
+ * 2. Call {@link evaluateTextNode} for every text node encountered during
442
+ * AST traversal (after the visitor has filtered out code/inlineCode/HTML/link
443
+ * URL contexts), passing the current section context.
444
+ * 3. Call {@link finalise} after all text nodes have been evaluated to obtain
445
+ * the complete result.
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * const grammar = new IdentifierGrammar();
450
+ * const rule = new InlineReferenceRule({
451
+ * uri: 'file:///doc.md',
452
+ * docId: '5.1',
453
+ * grammar,
454
+ * declaredDocIds: new Set(['3.1', '8.1']),
455
+ * });
456
+ *
457
+ * rule.evaluateTextNode(
458
+ * { text: 'Guardrail logic per 3.1#2 and retry semantics see 8.1.' },
459
+ * '5.1#1',
460
+ * );
461
+ *
462
+ * const result: InlineReferenceRuleResult = rule.finalise();
463
+ * // result.inlineReferences has two edges:
464
+ * // { fromId: '5.1#1', toId: '3.1#2', kind: 'per' }
465
+ * // { fromId: '5.1#1', toId: '8.1', kind: 'see' }
466
+ * ```
467
+ */
468
+ export class InlineReferenceRule {
469
+ /** The opaque, host-provided URI identifying the document being validated. */
470
+ private readonly uri: string;
471
+
472
+ /** The established DocID for this document, used for self-reference detection. */
473
+ private readonly docId: DocID;
474
+
475
+ /** The grammar instance used for TargetID validation. */
476
+ private readonly grammar: IdentifierGrammar;
477
+
478
+ /** The set of DocIDs declared in the References section. */
479
+ private readonly declaredDocIds: ReadonlySet<DocID>;
480
+
481
+ /** Diagnostics accumulated during evaluation. */
482
+ private readonly collectedDiagnostics: Diagnostic[];
483
+
484
+ /** Inline reference edges extracted from valid forms, in traversal order. */
485
+ private readonly collectedInlineReferences: InlineReferenceEdge[];
486
+
487
+ /** The document's raw source, for the source-form checks of 1#9.11. */
488
+ private readonly sourceText: string;
489
+
490
+ /** Absolute offset at which each source line begins. */
491
+ private readonly lineStarts: readonly number[];
492
+
493
+ /**
494
+ * Constructs a new Inline Reference Rule evaluator.
495
+ *
496
+ * @param options - Configuration including the document URI, established DocID,
497
+ * grammar instance, and declared DocIDs from the References section
498
+ */
499
+ public constructor(options: InlineReferenceRuleOptions) {
500
+ this.uri = options.uri;
501
+ this.docId = options.docId;
502
+ this.grammar = options.grammar;
503
+ this.declaredDocIds = options.declaredDocIds;
504
+ this.sourceText = options.sourceText;
505
+ this.lineStarts = indexLineStarts(options.sourceText);
506
+ this.collectedDiagnostics = [];
507
+ this.collectedInlineReferences = [];
508
+ }
509
+
510
+ /**
511
+ * Evaluates a single text node for inline `see`/`per` references.
512
+ *
513
+ * Scans the text for all occurrences of `see TargetID` or `per TargetID`
514
+ * where the keyword is preceded by a word boundary. For each detected
515
+ * inline reference:
516
+ *
517
+ * 1. Validates that the TargetID conforms to the identifier grammar
518
+ * 2. Determines the parent DocID of the TargetID
519
+ * 3. Checks whether the parent DocID is declared in the References section
520
+ * or is a self-reference to the document's own DocID
521
+ * 4. If declared or self-referencing, extracts an {@link InlineReferenceEdge}
522
+ * with `fromId` set to the provided `sectionContext`
523
+ * 5. If undeclared, emits a warning diagnostic and does not extract an edge
524
+ *
525
+ * Multiple inline references within a single text node are all independently
526
+ * detected and validated. Duplicate references are not deduplicated (that is
527
+ * a downstream concern).
528
+ *
529
+ * @param textNodeData - Data extracted from a text AST node
530
+ * @param sectionContext - The current heading identifier (DocID or SectionID)
531
+ * at the point where this text node appears in the document.
532
+ * Used as the `fromId` for any extracted edges.
533
+ */
534
+ public evaluateTextNode(
535
+ textNodeData: TextNodeData,
536
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
537
+ sectionContext: DocID | SectionID,
538
+ ): void {
539
+ this.evaluateInlineRun(
540
+ [
541
+ {
542
+ kind: 'text',
543
+ text: textNodeData.text,
544
+ wrappers: [],
545
+ ...(textNodeData.range !== undefined ? { range: textNodeData.range } : {}),
546
+ },
547
+ ],
548
+ sectionContext,
549
+ );
550
+ }
551
+
552
+ /**
553
+ * Evaluates one inline run -- a paragraph, say -- for inline references.
554
+ *
555
+ * Recognition reads the run as a reader sees it, across every node boundary
556
+ * (1#9.5 rule 1). Only then is the source consulted: a citation extracts an
557
+ * edge only when its keyword, space and identifier all lie in one text
558
+ * segment and are literal in the source there (1#9.11 rule 2). One that
559
+ * spans segments -- split by formatting, inline HTML, code or a break -- is
560
+ * reported, never passed over.
561
+ *
562
+ * @param segments - The run's visible text, in order, per {@link InlineSegment}
563
+ * @param sectionContext - Section the run belongs to, used as `fromId`
564
+ */
565
+ public evaluateInlineRun(
566
+ segments: readonly InlineSegment[],
567
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
568
+ sectionContext: DocID | SectionID,
569
+ ): void {
570
+ const visible: string = segments.map((segment: InlineSegment): string => segment.text).join('');
571
+ const owners: readonly number[] = InlineReferenceRule.indexSegmentOwners(segments);
572
+ const starts: readonly number[] = InlineReferenceRule.indexSegmentStarts(segments);
573
+ const aligned: Map<number, AlignedSegment> = new Map<number, AlignedSegment>();
574
+
575
+ for (const detected of this.detectInlineReferences(visible, segments, owners)) {
576
+ const keywordOwner: number = owners[detected.index] ?? 0;
577
+ const keywordSegment: InlineSegment | undefined = segments[keywordOwner];
578
+ const range: PositionRange | undefined = keywordSegment?.range;
579
+
580
+ if (!detected.conforms) {
581
+ this.reportNonConformingCandidate(detected, sectionContext, range);
582
+ continue;
583
+ }
584
+
585
+ const end: number =
586
+ detected.index + detected.keyword.length + detected.gap.length + detected.targetId.length;
587
+ const lastOwner: number = owners[end - 1] ?? keywordOwner;
588
+
589
+ if (lastOwner !== keywordOwner || keywordSegment === undefined) {
590
+ const split: SplitDescription = InlineReferenceRule.describeSplit(
591
+ segments,
592
+ keywordOwner,
593
+ lastOwner,
594
+ );
595
+
596
+ this.reportUnnavigableCitation(detected, sectionContext, range, split.because, split.extra);
597
+ continue;
598
+ }
599
+
600
+ let alignment: AlignedSegment | undefined = aligned.get(keywordOwner);
601
+
602
+ if (alignment === undefined) {
603
+ const source: string | undefined = this.sliceSource(keywordSegment.range);
604
+
605
+ alignment = {
606
+ source,
607
+ offsets:
608
+ source === undefined
609
+ ? undefined
610
+ : alignParsedToSource(source, keywordSegment.text),
611
+ };
612
+ aligned.set(keywordOwner, alignment);
613
+ }
614
+
615
+ const local: DetectedInlineReference = {
616
+ ...detected,
617
+ index: detected.index - (starts[keywordOwner] ?? 0),
618
+ };
619
+
620
+ if (!this.tellCitationIsLiteral(local, range, alignment.source, alignment.offsets)) {
621
+ this.reportUnnavigableCitation(detected, sectionContext, range);
622
+ continue;
623
+ }
624
+
625
+ if (!this.tellTargetIdDeclared(detected.targetId)) {
626
+ this.reportUndeclaredTarget(detected, sectionContext, range);
627
+ continue;
628
+ }
629
+
630
+ this.collectedInlineReferences.push({
631
+ fromId: sectionContext,
632
+ toId: detected.targetId,
633
+ kind: detected.kind,
634
+ });
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Records which segment each character of a run's visible text came from.
640
+ *
641
+ * @param segments - The run's segments
642
+ * @returns For each visible offset, the index of its segment
643
+ */
644
+ private static indexSegmentOwners(segments: readonly InlineSegment[]): readonly number[] {
645
+ const owners: number[] = [];
646
+
647
+ // One entry per UTF-16 unit, to line up with string offsets; iterating the
648
+ // text with for...of would step by code point instead.
649
+ segments.forEach((segment: InlineSegment, index: number): void => {
650
+ const start: number = owners.length;
651
+
652
+ owners.length = start + segment.text.length;
653
+ owners.fill(index, start);
654
+ });
655
+
656
+ return owners;
657
+ }
658
+
659
+ /**
660
+ * Records where each segment begins in a run's visible text.
661
+ *
662
+ * @param segments - The run's segments
663
+ * @returns The visible offset of each segment's first character
664
+ */
665
+ private static indexSegmentStarts(segments: readonly InlineSegment[]): readonly number[] {
666
+ const starts: number[] = [];
667
+ let offset: number = 0;
668
+
669
+ for (const segment of segments) {
670
+ starts.push(offset);
671
+ offset += segment.text.length;
672
+ }
673
+
674
+ return starts;
675
+ }
676
+
677
+ /**
678
+ * Says what split a citation across segments, for its diagnostic.
679
+ *
680
+ * Formatting is named when it is what differs: `see **8.1#3**` is split by
681
+ * bold text. Otherwise the kind of segment in the way is named, and where
682
+ * two text segments meet with nothing visible between them, only inline
683
+ * HTML can have separated them.
684
+ *
685
+ * @param segments - The run's segments
686
+ * @param first - Index of the segment holding the keyword
687
+ * @param last - Index of the segment holding the identifier's end
688
+ * @returns The phrase for the message, and any data it adds
689
+ */
690
+ private static describeSplit(
691
+ segments: readonly InlineSegment[],
692
+ first: number,
693
+ last: number,
694
+ ): SplitDescription {
695
+ const spanned: readonly InlineSegment[] = segments.slice(first, last + 1);
696
+ const shared: readonly string[] = spanned.reduce(
697
+ (common: readonly string[], segment: InlineSegment): readonly string[] =>
698
+ common.filter((wrapper: string, depth: number): boolean => segment.wrappers[depth] === wrapper),
699
+ spanned[0]?.wrappers ?? [],
700
+ );
701
+
702
+ for (const segment of spanned) {
703
+ const wrapper: string | undefined = segment.wrappers[shared.length];
704
+ const description: string | undefined =
705
+ wrapper === undefined ? undefined : WRAPPER_DESCRIPTIONS.get(wrapper);
706
+
707
+ if (wrapper !== undefined && description !== undefined) {
708
+ return { because: `is split by ${description}`, extra: { wrapper } };
709
+ }
710
+ }
711
+
712
+ const obstacle: InlineSegment | undefined = spanned.find(
713
+ (segment: InlineSegment): boolean => segment.kind !== 'text',
714
+ );
715
+ const description: string =
716
+ SPLIT_DESCRIPTIONS.get(obstacle?.kind ?? '') ?? TRANSPARENT_SPLIT_DESCRIPTION;
717
+
718
+ return { because: `is split by ${description}` };
719
+ }
720
+
721
+ /**
722
+ * Reports whether this citation is written literally at its own position.
723
+ *
724
+ * Only the keyword, the single space and the identifier are required to be
725
+ * literal. What follows them is not: a citation may be terminated by a
726
+ * character reference (`see 1#1&nbsp;here`) and remain perfectly findable,
727
+ * because a search stops at the identifier. Applying the parsed token's
728
+ * terminator rules to raw characters rejected exactly those documents.
729
+ *
730
+ * @param detected - The citation under test
731
+ * @param range - The node's range, absent in unit tests that supply bare text
732
+ * @param source - The node's raw source, when it could be located
733
+ * @param offsets - Parsed-to-source offset map, when alignment succeeded
734
+ * @returns `true` when the source carries this citation literally
735
+ */
736
+ private tellCitationIsLiteral(
737
+ detected: DetectedInlineReference,
738
+ range?: PositionRange,
739
+ source?: string,
740
+ offsets?: readonly number[],
741
+ ): boolean {
742
+ if (detected.gap !== ' ') {
743
+ return false;
744
+ }
745
+
746
+ // No range means no source to consult: the rule's unit tests supply bare
747
+ // text. That differs from having a range and failing to map it, which is
748
+ // treated as unverifiable below.
749
+ if (range === undefined) {
750
+ return true;
751
+ }
752
+
753
+ if (source === undefined || offsets === undefined) {
754
+ return false;
755
+ }
756
+
757
+ const at: number | undefined = offsets[detected.index];
758
+ const nodeStart: number | undefined = this.offsetOf(range.start);
759
+
760
+ if (at === undefined || nodeStart === undefined) {
761
+ return false;
762
+ }
763
+
764
+ return (
765
+ source.startsWith(`${detected.keyword} ${detected.targetId}`, at) &&
766
+ this.tellRecipeBoundsCitation(nodeStart + at, detected)
767
+ );
768
+ }
769
+
770
+ /**
771
+ * Reports whether the published recipes accept the characters either side
772
+ * of a citation in the source (1#9.11).
773
+ *
774
+ * Before the keyword, the recipes accept a word boundary or `_`: `_` is a
775
+ * word character to both engines, and `_see 8.1_` is ordinary emphasis.
776
+ * After the identifier they accept anything but an ASCII letter or digit.
777
+ * After a bare DocID a `#` is refused too, and a `.` is allowed only as the
778
+ * end of a sentence -- not before a letter, digit, `.` or `#` -- so that
779
+ * `8.1` is not found inside `8.1#3`, `8.1.2` or `8.1..2`; after a SectionID
780
+ * a `.` is allowed, since the recipe finds a section and those below it.
781
+ *
782
+ * The whole source is read, not the node's slice: a citation at the start
783
+ * of a node is preceded by whatever came before the node.
784
+ *
785
+ * @param start - Absolute source offset of the keyword
786
+ * @param detected - The citation, known to be literal at `start`
787
+ * @returns `true` when the recipe for this target would match here
788
+ */
789
+ private tellRecipeBoundsCitation(start: number, detected: DetectedInlineReference): boolean {
790
+ // `(\b|_)` before the keyword: `_`, or anything the word-boundary test
791
+ // accepts, which is ripgrep's own definition of `\b`.
792
+ const before: string = start > 0 ? this.sourceText.charAt(start - 1) : '';
793
+
794
+ if (before !== '_' && !this.tellAtWordBoundary(this.sourceText, start)) {
795
+ return false;
796
+ }
797
+
798
+ const end: number = start + detected.keyword.length + 1 + detected.targetId.length;
799
+ const next: string = this.sourceText.charAt(end);
800
+
801
+ if (next === '' || LINE_BREAK_CHARACTER.test(next)) {
802
+ return true;
803
+ }
804
+
805
+ if (RECIPE_EXCLUDED_BOUNDARY.test(next) || next === '#') {
806
+ return false;
807
+ }
808
+
809
+ if (next !== '.' || detected.targetId.includes('#')) {
810
+ return true;
811
+ }
812
+
813
+ const afterFullStop: string = this.sourceText.charAt(end + 1);
814
+
815
+ return (
816
+ !RECIPE_EXCLUDED_BOUNDARY.test(afterFullStop) &&
817
+ afterFullStop !== '.' &&
818
+ afterFullStop !== '#'
819
+ );
820
+ }
821
+
822
+ /**
823
+ * Reports a candidate that is not a complete, conforming identifier.
824
+ *
825
+ * The `#` decides whether anything is reported at all. Ordinary writing
826
+ * produces numbers after `see` and `per` constantly -- `per 60s`,
827
+ * `see 1..2` -- and never produces `1#1`. So a failed candidate carrying a
828
+ * separator was certainly meant as an identifier and is an error, while one
829
+ * without is prose and is passed over in silence.
830
+ *
831
+ * @param detected - The candidate that failed
832
+ * @param sectionContext - Section the text belongs to
833
+ * @param range - Positional range of the text node
834
+ */
835
+ private reportNonConformingCandidate(
836
+ detected: DetectedInlineReference,
837
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
838
+ sectionContext: DocID | SectionID,
839
+ range?: PositionRange,
840
+ ): void {
841
+ if (!detected.candidate.includes('#')) {
842
+ return;
843
+ }
844
+
845
+ this.collectedDiagnostics.push(
846
+ this.createDiagnostic(
847
+ `"${detected.keyword} ${detected.candidate}" is malformed: ` +
848
+ `"${detected.candidate}" is not a complete identifier. It is not read as a ` +
849
+ `reference to a shorter one.`,
850
+ range,
851
+ {
852
+ cause: MALFORMED_TARGET_CAUSE,
853
+ candidate: detected.candidate,
854
+ fromId: sectionContext,
855
+ kind: detected.kind,
856
+ },
857
+ 'error',
858
+ ),
859
+ );
860
+ }
861
+
862
+ /**
863
+ * Reports a citation whose source form no search can find (1#9.11 rule 2).
864
+ *
865
+ * Severity follows the same test as an undeclared target: an identifiable
866
+ * target -- a SectionID, a declared DocID, or the document's own -- was
867
+ * certainly meant as a citation, so an unfindable one is an error. A bare
868
+ * number naming nothing known cannot be told from emphasised prose, so it
869
+ * warns, and corpus validation raises it if the document turns out to exist.
870
+ *
871
+ * @param detected - The citation that is not literal in the source
872
+ * @param sectionContext - Section the text belongs to
873
+ * @param range - Positional range of the text node
874
+ * @param split - What split it, when it spans segments, e.g. `is split by bold text`
875
+ * @param extra - Data the diagnostic carries in addition
876
+ */
877
+ private reportUnnavigableCitation(
878
+ detected: DetectedInlineReference,
879
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
880
+ sectionContext: DocID | SectionID,
881
+ range?: PositionRange,
882
+ split?: string,
883
+ extra?: Readonly<Record<string, unknown>>,
884
+ ): void {
885
+ const identifiable: boolean =
886
+ detected.targetId.includes('#') || this.tellTargetIdDeclared(detected.targetId);
887
+ const because: string =
888
+ split === undefined
889
+ ? 'is not written as adjacent literal text on one line'
890
+ : `${split} rather than written as adjacent literal text on one line`;
891
+
892
+ this.collectedDiagnostics.push(
893
+ this.createDiagnostic(
894
+ `"${detected.keyword} ${detected.targetId}" ${because}, so no search finds it. ` +
895
+ `Put the keyword and the identifier on one line with a single space between ` +
896
+ `them, and without formatting or escapes.`,
897
+ range,
898
+ {
899
+ cause: CITATION_SOURCE_FORM_CAUSE,
900
+ reason: UNDECLARED_TARGET_REASON,
901
+ targetId: detected.targetId,
902
+ targetDocId: this.showTargetDocId(detected.targetId),
903
+ fromId: sectionContext,
904
+ kind: detected.kind,
905
+ ...(extra ?? {}),
906
+ },
907
+ identifiable ? 'error' : INLINE_REFERENCE_DIAGNOSTIC_SEVERITY,
908
+ ),
909
+ );
910
+ }
911
+
912
+ /**
913
+ * Reports a conforming, navigable citation whose DocID is not declared.
914
+ *
915
+ * @param detected - The citation
916
+ * @param sectionContext - Section the text belongs to
917
+ * @param range - Positional range of the text node
918
+ */
919
+ private reportUndeclaredTarget(
920
+ detected: DetectedInlineReference,
921
+ // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be a DocID or a SectionID
922
+ sectionContext: DocID | SectionID,
923
+ range?: PositionRange,
924
+ ): void {
925
+ const parentDocId: string = this.showTargetDocId(detected.targetId);
926
+ const quoted: string = `"${detected.kind} ${detected.targetId}"`;
927
+ const data: Readonly<Record<string, unknown>> = {
928
+ reason: UNDECLARED_TARGET_REASON,
929
+ targetId: detected.targetId,
930
+ targetDocId: parentDocId,
931
+ fromId: sectionContext,
932
+ kind: detected.kind,
933
+ };
934
+
935
+ const diagnostic: Diagnostic = detected.targetId.includes('#')
936
+ ? this.createDiagnostic(
937
+ `${quoted} targets DocID "${parentDocId}", which the References section ` +
938
+ `does not declare. Declare ${parentDocId} in References.`,
939
+ range,
940
+ data,
941
+ UNDECLARED_SECTION_TARGET_SEVERITY,
942
+ )
943
+ : this.createDiagnostic(
944
+ `${quoted} reads as a reference to DocID "${parentDocId}", which the ` +
945
+ `References section does not declare. If it is a reference, declare ` +
946
+ `${parentDocId} in References; if it is ordinary prose, it can be left as it is.`,
947
+ range,
948
+ data,
949
+ );
950
+
951
+ this.collectedDiagnostics.push(diagnostic);
952
+ }
953
+
954
+ /**
955
+ * Finalises the rule evaluation and produces the complete result.
956
+ *
957
+ * This method must be called after all text nodes have been supplied
958
+ * via {@link evaluateTextNode}. It returns the accumulated diagnostics
959
+ * and extracted inline reference edges.
960
+ *
961
+ * @returns The complete rule result including all diagnostics and
962
+ * extracted inline reference edges
963
+ */
964
+ public finalise(): InlineReferenceRuleResult {
965
+ return {
966
+ diagnostics: this.collectedDiagnostics,
967
+ inlineReferences: this.collectedInlineReferences,
968
+ };
969
+ }
970
+
971
+ // -------------------------------------------------------------------------
972
+ // Private helpers
973
+ // -------------------------------------------------------------------------
974
+
975
+ /**
976
+ * Finds every `see`/`per` citation candidate in a run's visible text.
977
+ *
978
+ * A keyword is recognised where a word boundary precedes it in the text a
979
+ * reader sees, and a candidate where digits follow it after whitespace.
980
+ * Neither is recognised when it starts inside inline code or a break: code
981
+ * may continue a candidate, but an author never cites through it (1#9.5).
982
+ *
983
+ * @param visible - The run's visible text
984
+ * @param segments - The run's segments
985
+ * @param owners - For each visible offset, the index of its segment
986
+ * @returns The candidates, in order of occurrence, indexed into `visible`
987
+ */
988
+ private detectInlineReferences(
989
+ visible: string,
990
+ segments: readonly InlineSegment[],
991
+ owners: readonly number[],
992
+ ): readonly DetectedInlineReference[] {
993
+ const detectedMatches: DetectedInlineReference[] = [];
994
+ const tellIsText = (offset: number): boolean =>
995
+ segments[owners[offset] ?? -1]?.kind === 'text';
996
+
997
+ KEYWORD_PATTERN.lastIndex = 0;
998
+
999
+ let keywordMatch: RegExpExecArray | null = KEYWORD_PATTERN.exec(visible);
1000
+
1001
+ while (keywordMatch !== null) {
1002
+ const keyword: string = keywordMatch[1] ?? '';
1003
+ const index: number = keywordMatch.index;
1004
+ const keywordIsText: boolean = Array.from(keyword, (_: string, offset: number): boolean =>
1005
+ tellIsText(index + offset),
1006
+ ).every(Boolean);
1007
+
1008
+ if (keywordIsText && this.tellAtWordBoundary(visible, index)) {
1009
+ const detected: DetectedInlineReference | undefined = this.readCandidate(
1010
+ visible,
1011
+ index,
1012
+ keyword,
1013
+ );
1014
+
1015
+ if (
1016
+ detected !== undefined &&
1017
+ tellIsText(index + keyword.length + detected.gap.length)
1018
+ ) {
1019
+ detectedMatches.push(detected);
1020
+ }
1021
+ }
1022
+
1023
+ keywordMatch = KEYWORD_PATTERN.exec(visible);
1024
+ }
1025
+
1026
+ return detectedMatches;
1027
+ }
1028
+
1029
+ /**
1030
+ * Reads the candidate identifier that follows a keyword, if there is one.
1031
+ *
1032
+ * The candidate is the maximal run of identifier characters, taken whole
1033
+ * before it is tested. Taking it whole is what stops a malformed identifier
1034
+ * decaying into a shorter valid one: `1#1#9` fails, rather than passing as
1035
+ * `1#1`.
1036
+ *
1037
+ * @param text - The run's visible text
1038
+ * @param keywordIndex - Index of the keyword's first character
1039
+ * @param keyword - The keyword exactly as written
1040
+ * @returns The candidate, or `undefined` when no digits follow the keyword
1041
+ */
1042
+ private readCandidate(
1043
+ text: string,
1044
+ keywordIndex: number,
1045
+ keyword: string,
1046
+ ): DetectedInlineReference | undefined {
1047
+ const afterKeyword: number = keywordIndex + keyword.length;
1048
+ let cursor: number = afterKeyword;
1049
+
1050
+ while (cursor < text.length && WHITESPACE.test(text.charAt(cursor))) {
1051
+ cursor += 1;
1052
+ }
1053
+
1054
+ const gap: string = text.slice(afterKeyword, cursor);
1055
+
1056
+ if (gap.length === 0 || !/[0-9]/.test(text.charAt(cursor))) {
1057
+ return undefined;
1058
+ }
1059
+
1060
+ const candidateStart: number = cursor;
1061
+
1062
+ while (cursor < text.length && CANDIDATE_CHARACTERS.test(text.charAt(cursor))) {
1063
+ cursor += 1;
1064
+ }
1065
+
1066
+ const candidate: string = text.slice(candidateStart, cursor);
1067
+ const terminator: string | undefined =
1068
+ cursor < text.length ? text.charAt(cursor) : undefined;
1069
+
1070
+ const terminated: boolean =
1071
+ terminator === undefined ||
1072
+ WHITESPACE.test(terminator) ||
1073
+ PERMITTED_TERMINATOR_PUNCTUATION.has(terminator);
1074
+
1075
+ const targetId: string = candidate.endsWith('.') ? candidate.slice(0, -1) : candidate;
1076
+ const conforms: boolean =
1077
+ terminated &&
1078
+ (this.grammar.parseDocId(targetId).valid || this.grammar.parseSectionId(targetId).valid);
1079
+
1080
+ return {
1081
+ kind: keyword.toLowerCase() as InlineReferenceKind,
1082
+ keyword,
1083
+ candidate,
1084
+ targetId,
1085
+ conforms,
1086
+ gap,
1087
+ index: keywordIndex,
1088
+ };
1089
+ }
1090
+
1091
+ /**
1092
+ * Reports whether a keyword at `index` stands at a word boundary.
1093
+ *
1094
+ * Reads whole code points: `charAt` would return half of a supplementary
1095
+ * character and misjudge it.
1096
+ *
1097
+ * @param text - The text being scanned
1098
+ * @param index - Index of the keyword's first character
1099
+ * @returns `true` when nothing word-like immediately precedes the keyword
1100
+ */
1101
+ private tellAtWordBoundary(text: string, index: number): boolean {
1102
+ if (index === 0) {
1103
+ return true;
1104
+ }
1105
+
1106
+ const before: number | undefined = text.codePointAt(index - 1);
1107
+
1108
+ if (before === undefined) {
1109
+ return true;
1110
+ }
1111
+
1112
+ // A low surrogate here means the preceding character is supplementary;
1113
+ // step back one more unit to read the whole code point.
1114
+ const isLowSurrogate: boolean = before >= 0xdc00 && before <= 0xdfff;
1115
+ const codePoint: number | undefined = isLowSurrogate
1116
+ ? text.codePointAt(index - 2)
1117
+ : before;
1118
+
1119
+ if (codePoint === undefined) {
1120
+ return true;
1121
+ }
1122
+
1123
+ return !WORD_CHARACTER.test(String.fromCodePoint(codePoint));
1124
+ }
1125
+
1126
+
1127
+ /**
1128
+ * Returns the raw source belonging to a node, when its range is known.
1129
+ *
1130
+ * @param range - The node's positional range
1131
+ * @returns The source slice, or `undefined` when no range was supplied
1132
+ */
1133
+ private sliceSource(range?: PositionRange): string | undefined {
1134
+ if (range === undefined) {
1135
+ return undefined;
1136
+ }
1137
+
1138
+ const startOffset: number | undefined = this.offsetOf(range.start);
1139
+ const endOffset: number | undefined = this.offsetOf(range.end);
1140
+
1141
+ if (startOffset === undefined || endOffset === undefined) {
1142
+ return undefined;
1143
+ }
1144
+
1145
+ return this.sourceText.slice(startOffset, endOffset);
1146
+ }
1147
+
1148
+ /**
1149
+ * Converts a line/character position into an absolute source offset.
1150
+ *
1151
+ * @param position - A position within the document
1152
+ * @returns The offset, or `undefined` when the line is out of range
1153
+ */
1154
+ private offsetOf(position: Position): number | undefined {
1155
+ const lineStart: number | undefined = this.lineStarts[position.line];
1156
+
1157
+ return lineStart === undefined ? undefined : lineStart + position.character;
1158
+ }
1159
+
1160
+ /**
1161
+ * Determines whether a TargetID's parent DocID is declared in the
1162
+ * References section or is a self-reference to the document's own DocID.
1163
+ *
1164
+ * The target's DocID is read directly — the TargetID itself, or the text
1165
+ * before its `#` — and compared exactly with the document's own DocID and
1166
+ * the declared set. No prefix matching is involved, so `8.1.3` is not
1167
+ * covered by a declaration of `8.1`.
1168
+ *
1169
+ * @param targetId - The TargetID to check
1170
+ * @returns `true` if the TargetID is declared or is a self-reference,
1171
+ * `false` if it is an undeclared reference
1172
+ */
1173
+ private tellTargetIdDeclared(targetId: string): boolean {
1174
+ const targetDocId: string = this.showTargetDocId(targetId);
1175
+
1176
+ // A reference into this document's own sections needs no declaration.
1177
+ if (targetDocId === this.docId) {
1178
+ return true;
1179
+ }
1180
+
1181
+ return this.declaredDocIds.has(targetDocId);
1182
+ }
1183
+
1184
+ /**
1185
+ * Extracts the DocID a TargetID refers to.
1186
+ *
1187
+ * This needs no inference: everything before the `#` separator is the
1188
+ * DocID, and an identifier with no separator is itself a DocID. The
1189
+ * separator removes the ambiguity a dotted-only grammar would have to
1190
+ * resolve by guessing.
1191
+ *
1192
+ * @param targetId - A DocID or SectionID as written in an inline reference
1193
+ * @returns The DocID portion of the identifier
1194
+ */
1195
+ private showTargetDocId(targetId: string): string {
1196
+ const separatorIndex: number = targetId.indexOf('#');
1197
+
1198
+ if (separatorIndex < 0) {
1199
+ return targetId;
1200
+ }
1201
+
1202
+ return targetId.substring(0, separatorIndex);
1203
+ }
1204
+
1205
+
1206
+ /**
1207
+ * Creates a diagnostic object for the Inline Reference Rule.
1208
+ *
1209
+ * All diagnostics share the same rule ID ({@link INLINE_REFERENCE_RULE_ID})
1210
+ * and document URI.
1211
+ *
1212
+ * @param message - Human-readable description of the issue
1213
+ * @param range - Optional positional range within the source document
1214
+ * @param data - Optional structured detail
1215
+ * @param severity - Defaults to {@link INLINE_REFERENCE_DIAGNOSTIC_SEVERITY}
1216
+ * @returns A fully populated diagnostic object
1217
+ */
1218
+ private createDiagnostic(
1219
+ message: string,
1220
+ range?: PositionRange,
1221
+ data?: Readonly<Record<string, unknown>>,
1222
+ severity: DiagnosticSeverity = INLINE_REFERENCE_DIAGNOSTIC_SEVERITY,
1223
+ ): Diagnostic {
1224
+ const diagnostic: Diagnostic = {
1225
+ ruleId: INLINE_REFERENCE_RULE_ID,
1226
+ severity,
1227
+ message,
1228
+ uri: this.uri,
1229
+ ...(range !== undefined ? { range } : {}),
1230
+ ...(data !== undefined ? { data } : {}),
1231
+ };
1232
+
1233
+ return diagnostic;
1234
+ }
1235
+ }