@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,777 @@
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
+ import { IdentifierGrammar } from './identifier-grammar.js';
19
+ import { SourceLines } from './source-lines.js';
20
+ import { alignParsedToSource } from './source-alignment.js';
21
+ // ---------------------------------------------------------------------------
22
+ // Rule identifier constant
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Canonical rule identifier for the References Section Rule.
26
+ *
27
+ * Referenced as [ECR103] in the ECR specification (1#9.6).
28
+ */
29
+ export const REFERENCES_SECTION_RULE_ID = 'ECR103';
30
+ // ---------------------------------------------------------------------------
31
+ // Diagnostic causes (1#9.11 rules 3 and 4, 1#9.6)
32
+ // ---------------------------------------------------------------------------
33
+ /** The `data.cause` of a `## References` heading no recipe can find. */
34
+ export const REFERENCES_HEADING_SOURCE_FORM_CAUSE = 'references-heading-source-form';
35
+ /** The `data.cause` of a References section that is not at the document root. */
36
+ export const REFERENCES_PLACEMENT_CAUSE = 'references-placement';
37
+ /** The `data.cause` of a References entry the entry recipes cannot find. */
38
+ export const REFERENCES_ENTRY_SOURCE_FORM_CAUSE = 'references-entry-source-form';
39
+ /** The `data.cause` of an entry whose relationship parenthetical never closes. */
40
+ export const REFERENCES_ENTRY_UNBALANCED_CAUSE = 'references-entry-unbalanced';
41
+ /** The `data.cause` of a References section with no entries (reported as info). */
42
+ export const REFERENCES_EMPTY_CAUSE = 'references-empty';
43
+ /**
44
+ * The start of a References entry as the entry recipe reads it: optional
45
+ * indentation, a list marker, optional whitespace and an optional `[`. The
46
+ * target DocID must follow immediately.
47
+ */
48
+ const ENTRY_LINE_PREFIX = /^\s*(?:[-*+]|[0-9]+[.)])\s*\[?/;
49
+ /**
50
+ * What may follow the target DocID on its line, per the entry recipe
51
+ * `^\s*([-*+]|[0-9]+[.)])\s*\[?8\.1[^0-9.#]`.
52
+ */
53
+ const AFTER_ENTRY_DOC_ID = /^[^0-9.#]/;
54
+ // ---------------------------------------------------------------------------
55
+ // Diagnostic severity constant (module-level, non-exported)
56
+ // ---------------------------------------------------------------------------
57
+ /**
58
+ * The severity of every References Section Rule diagnostic except the
59
+ * empty-section finding, which is information (1#9.6, 1#12.3).
60
+ *
61
+ * Per 1#9.8, violation of any per-document structural invariant is an ERROR.
62
+ */
63
+ const REFERENCES_SECTION_DIAGNOSTIC_SEVERITY = 'error';
64
+ // ---------------------------------------------------------------------------
65
+ // Valid reference direction values (module-level, non-exported)
66
+ // ---------------------------------------------------------------------------
67
+ /**
68
+ * The set of valid direction labels for References section entries.
69
+ *
70
+ * Per 1#9.6, Direction must be one of: authority, dependency, constraint, contract.
71
+ */
72
+ const VALID_REFERENCE_DIRECTIONS = new Set([
73
+ 'authority',
74
+ 'dependency',
75
+ 'constraint',
76
+ 'contract',
77
+ ]);
78
+ // ---------------------------------------------------------------------------
79
+ // References heading text constant (module-level, non-exported)
80
+ // ---------------------------------------------------------------------------
81
+ /**
82
+ * The exact heading text that identifies the References section.
83
+ *
84
+ * Per 1#9.6, the heading must be exactly "References" at depth 2.
85
+ */
86
+ const REFERENCES_HEADING_TEXT = 'References';
87
+ // ---------------------------------------------------------------------------
88
+ // References heading depth constant (module-level, non-exported)
89
+ // ---------------------------------------------------------------------------
90
+ /**
91
+ * The required heading depth for the References section heading.
92
+ *
93
+ * Per 1#9.6, the References heading must be an H2 (depth 2).
94
+ */
95
+ const REFERENCES_HEADING_DEPTH = 2;
96
+ /**
97
+ * The References heading's whole source line, exactly (1#9.11 rule 4).
98
+ */
99
+ const REFERENCES_HEADING_LINE = `${'#'.repeat(REFERENCES_HEADING_DEPTH)} ${REFERENCES_HEADING_TEXT}`;
100
+ // ---------------------------------------------------------------------------
101
+ // Separator constants
102
+ // ---------------------------------------------------------------------------
103
+ /**
104
+ * Matches the separator in a References entry: a dash delimited by a single
105
+ * space on each side.
106
+ *
107
+ * The dash may be a hyphen-minus (U+002D), an en dash (U+2013) or an em dash
108
+ * (U+2014). Real-world corpora mix all three, and the variant carries no
109
+ * structural meaning, so all are accepted.
110
+ */
111
+ const SEPARATOR_PATTERN = / [-–—] /;
112
+ // ---------------------------------------------------------------------------
113
+ // Rule class
114
+ // ---------------------------------------------------------------------------
115
+ /**
116
+ * Validates the References Section Rule as defined in the ECR specification (1#9.6, 1#9.7, 1#9.8).
117
+ *
118
+ * The rule enforces that:
119
+ * - Exactly one `## References` heading exists in the document
120
+ * - A list node immediately follows the References heading
121
+ * - Each list item matches the required format:
122
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
123
+ * - TargetDocID is a valid DocID per the identifier grammar
124
+ * - Direction is one of: authority, dependency, constraint, contract
125
+ * - Title and Explanation are non-empty
126
+ * - Each separator is a dash: hyphen-minus, en dash or em dash
127
+ * - No duplicate TargetDocID entries exist
128
+ *
129
+ * For each valid list item, the rule extracts a {@link ReferenceEdge} with
130
+ * `fromDocId` set to the document's established DocID.
131
+ *
132
+ * Usage:
133
+ * 1. Construct a rule instance with the document URI, established DocID,
134
+ * and an {@link IdentifierGrammar}.
135
+ * 2. Call {@link evaluateHeading} for every heading node encountered during
136
+ * AST traversal. The rule uses this to detect `## References` headings.
137
+ * 3. Call {@link evaluateListItem} for every list item node encountered
138
+ * within the References section during AST traversal.
139
+ * 4. Call {@link finalise} after all nodes have been evaluated to obtain
140
+ * the complete result including any "missing References" diagnostics.
141
+ *
142
+ * @example
143
+ * ```ts
144
+ * const grammar = new IdentifierGrammar();
145
+ * const rule = new ReferencesSectionRule({
146
+ * uri: 'file:///doc.md',
147
+ * docId: '5.1',
148
+ * grammar,
149
+ * });
150
+ *
151
+ * rule.evaluateHeading({ depth: 1, text: '5.1 - My Document' });
152
+ * rule.evaluateHeading({ depth: 2, text: '5.1#1 - Section' });
153
+ * rule.evaluateHeading({ depth: 2, text: 'References' });
154
+ *
155
+ * rule.evaluateListItem({
156
+ * text: '3.1 - Scenario Authoring (authority - defines guardrail logic)',
157
+ * });
158
+ * rule.evaluateListItem({
159
+ * text: '8.1 - Orchestration Contract (constraint - retry semantics)',
160
+ * });
161
+ *
162
+ * const result: ReferencesSectionRuleResult = rule.finalise();
163
+ * ```
164
+ */
165
+ export class ReferencesSectionRule {
166
+ /** The opaque, host-provided URI identifying the document being validated. */
167
+ uri;
168
+ /** The established DocID for this document, used as `fromDocId` in extracted edges. */
169
+ docId;
170
+ /** The grammar instance used for DocID parsing and separator validation. */
171
+ grammar;
172
+ /** Diagnostics accumulated during evaluation. */
173
+ collectedDiagnostics;
174
+ /** Reference edges extracted from valid list items, in traversal order. */
175
+ collectedReferences;
176
+ /**
177
+ * Number of `## References` headings encountered during evaluation.
178
+ * Used to detect missing or duplicate References sections.
179
+ */
180
+ referencesHeadingCount;
181
+ /**
182
+ * Set of TargetDocIDs already encountered in the References section,
183
+ * for duplicate detection.
184
+ */
185
+ encounteredTargetDocIds;
186
+ /** The document's source by line; absent when none was supplied. */
187
+ sourceLines;
188
+ /**
189
+ * Whether the section was found somewhere other than the document root.
190
+ * Its placement is then the one thing reported: every line of a nested
191
+ * section starts with its container's prefix, so checking each entry's
192
+ * source form as well would report the same mistake once per entry.
193
+ */
194
+ sectionMisplaced;
195
+ /**
196
+ * Number of list items evaluated under the References heading, valid or
197
+ * not. Zero means the section declares no entries (1#9.6).
198
+ */
199
+ evaluatedListItemCount;
200
+ /**
201
+ * Constructs a new References Section Rule evaluator.
202
+ *
203
+ * @param options - Configuration including the document URI, established DocID,
204
+ * and grammar instance
205
+ */
206
+ constructor(options) {
207
+ this.uri = options.uri;
208
+ this.docId = options.docId;
209
+ this.grammar = options.grammar;
210
+ this.collectedDiagnostics = [];
211
+ this.collectedReferences = [];
212
+ this.referencesHeadingCount = 0;
213
+ this.encounteredTargetDocIds = new Set();
214
+ this.sourceLines =
215
+ options.sourceText === undefined ? undefined : new SourceLines(options.sourceText);
216
+ this.sectionMisplaced = false;
217
+ this.evaluatedListItemCount = 0;
218
+ }
219
+ /**
220
+ * Records where the References section sits, and checks its heading's
221
+ * source form (1#9.11 rules 3 and 4).
222
+ *
223
+ * Called once, by the traversal layer, for the section whose list it then
224
+ * feeds. A section nested in a blockquote, a list item or any other
225
+ * container is reported once as misplaced. One at the root has its heading
226
+ * line checked: it must be `## References` at the start of a line a search
227
+ * sees, because the recipe that reads a document's references is
228
+ * `^## References`.
229
+ *
230
+ * @param headingRange - Position of the `## References` heading
231
+ * @param nested - Whether the heading, or the list after it, is not a root child
232
+ */
233
+ evaluateSectionPlacement(headingRange, nested) {
234
+ if (nested) {
235
+ this.sectionMisplaced = true;
236
+ this.collectedDiagnostics.push(this.createDiagnostic(`The References section is nested inside another element. The heading and its ` +
237
+ `list must both be at the top level of the document, where the recipe ` +
238
+ `"^## References" finds them; move the section out of its container.`, headingRange, { cause: REFERENCES_PLACEMENT_CAUSE }));
239
+ return;
240
+ }
241
+ const obstruction = this.findHeadingObstruction(headingRange);
242
+ if (obstruction !== undefined) {
243
+ this.collectedDiagnostics.push(this.createDiagnostic(obstruction === 'lone-carriage-return'
244
+ ? `The References heading follows a lone carriage return (CR) line ending, so ` +
245
+ `to a search it is the middle of the line before, and "^## References" does ` +
246
+ `not find it. Save the file with LF or CRLF line endings.`
247
+ : `The References heading's line is not exactly "## References", so the ` +
248
+ `recipe "^## References" is not guaranteed to find it. Write two # ` +
249
+ `characters at the start of the line, one space and the word References, ` +
250
+ `with nothing else on the line: no formatting, closing hashes or trailing spaces.`, headingRange, { cause: REFERENCES_HEADING_SOURCE_FORM_CAUSE, obstruction }));
251
+ }
252
+ }
253
+ /**
254
+ * Evaluates a single heading node to detect `## References` headings.
255
+ *
256
+ * A heading is considered a References heading when its depth is exactly 2
257
+ * and its text content is exactly "References". The rule tracks how many
258
+ * such headings are encountered to enforce the "exactly one" constraint.
259
+ *
260
+ * Headings that do not match are ignored by this rule.
261
+ *
262
+ * @param headingNodeData - Data extracted from a heading AST node
263
+ */
264
+ evaluateHeading(headingNodeData) {
265
+ if (headingNodeData.depth !== REFERENCES_HEADING_DEPTH) {
266
+ return;
267
+ }
268
+ if (headingNodeData.text !== REFERENCES_HEADING_TEXT) {
269
+ return;
270
+ }
271
+ this.referencesHeadingCount = this.referencesHeadingCount + 1;
272
+ }
273
+ /**
274
+ * Evaluates a single list item node from within the References section.
275
+ *
276
+ * For each list item, the method validates:
277
+ * 1. The entry carries its separators (any dash variant is accepted)
278
+ * 2. The overall format matches:
279
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
280
+ * 3. TargetDocID parses as a valid DocID per the identifier grammar
281
+ * 4. Direction is one of the allowed enumeration values
282
+ * 5. Title is non-empty (after trimming)
283
+ * 6. Explanation is non-empty (after trimming)
284
+ * 7. TargetDocID has not been seen before in this References section
285
+ *
286
+ * When all validations pass, a {@link ReferenceEdge} is extracted with
287
+ * `fromDocId` set to the document's DocID.
288
+ *
289
+ * When any validation fails, an error diagnostic is emitted and no
290
+ * edge is extracted for that list item.
291
+ *
292
+ * @param listItemNodeData - Data extracted from a list item AST node
293
+ * within the References section
294
+ */
295
+ evaluateListItem(listItemNodeData) {
296
+ if (this.referencesHeadingCount === 0) {
297
+ return;
298
+ }
299
+ this.evaluatedListItemCount += 1;
300
+ const text = listItemNodeData.text;
301
+ const range = listItemNodeData.range;
302
+ // 1. Validate that the separators are present
303
+ const separatorDiagnostic = this.validateListItemSeparators(text, range);
304
+ if (separatorDiagnostic !== undefined) {
305
+ this.collectedDiagnostics.push(separatorDiagnostic);
306
+ return;
307
+ }
308
+ // 2. Parse the list item text into components
309
+ const parsedEntry = this.parseListItemText(text);
310
+ if (parsedEntry === 'unbalanced') {
311
+ this.collectedDiagnostics.push(this.createDiagnostic(`References entry is malformed: its relationship parenthetical is not balanced. ` +
312
+ `Matching its final ")" back to an opening "(" never closes, so the direction ` +
313
+ `and explanation cannot be located. Parentheses must be balanced within ` +
314
+ `"(direction - explanation)".`, range, { cause: REFERENCES_ENTRY_UNBALANCED_CAUSE }));
315
+ return;
316
+ }
317
+ if (parsedEntry === undefined) {
318
+ const diagnostic = this.createDiagnostic(`References entry does not match the required format: ` +
319
+ `DocID - Title (direction - explanation). ` +
320
+ `A hyphen, en dash or em dash may be used as each separator.`, range);
321
+ this.collectedDiagnostics.push(diagnostic);
322
+ return;
323
+ }
324
+ // 3. Validate TargetDocID is a valid DocID
325
+ const docIdParseResult = this.grammar.parseDocId(parsedEntry.targetDocId);
326
+ if (!docIdParseResult.valid) {
327
+ const diagnostic = this.createDiagnostic(`References entry target "${parsedEntry.targetDocId}" is not a valid DocID.`, range);
328
+ this.collectedDiagnostics.push(diagnostic);
329
+ return;
330
+ }
331
+ // 4. Validate direction
332
+ if (!this.tellValidDirection(parsedEntry.direction)) {
333
+ const diagnostic = this.createDiagnostic(`References entry direction "${String(parsedEntry.direction)}" is not valid. ` +
334
+ `Allowed values: authority, dependency, constraint, contract.`, range);
335
+ this.collectedDiagnostics.push(diagnostic);
336
+ return;
337
+ }
338
+ // 5. Validate title is non-empty
339
+ const trimmedTitle = parsedEntry.title.trim();
340
+ if (trimmedTitle.length === 0) {
341
+ const diagnostic = this.createDiagnostic(`References entry title must be non-empty.`, range);
342
+ this.collectedDiagnostics.push(diagnostic);
343
+ return;
344
+ }
345
+ // 6. Validate explanation is non-empty
346
+ const trimmedExplanation = parsedEntry.explanation.trim();
347
+ if (trimmedExplanation.length === 0) {
348
+ const diagnostic = this.createDiagnostic(`References entry explanation must be non-empty.`, range);
349
+ this.collectedDiagnostics.push(diagnostic);
350
+ return;
351
+ }
352
+ // 7. Check for duplicate TargetDocID
353
+ if (this.encounteredTargetDocIds.has(parsedEntry.targetDocId)) {
354
+ const diagnostic = this.createDiagnostic(`Duplicate TargetDocID "${parsedEntry.targetDocId}" in References section. ` +
355
+ `Each TargetDocID must appear only once.`, range);
356
+ this.collectedDiagnostics.push(diagnostic);
357
+ return;
358
+ }
359
+ // 8. Check the entry is where its recipes look (1#9.11 rule 4). Reported,
360
+ // but the edge is still extracted: withholding it would make every inline
361
+ // citation of the target undeclared, reporting one mistake many times.
362
+ const sourceFormDiagnostic = this.validateEntrySourceForm(parsedEntry, listItemNodeData);
363
+ if (sourceFormDiagnostic !== undefined) {
364
+ this.collectedDiagnostics.push(sourceFormDiagnostic);
365
+ }
366
+ // All validations passed -- extract ReferenceEdge
367
+ this.encounteredTargetDocIds.add(parsedEntry.targetDocId);
368
+ const referenceEdge = {
369
+ fromDocId: this.docId,
370
+ toDocId: parsedEntry.targetDocId,
371
+ direction: parsedEntry.direction,
372
+ explanation: parsedEntry.explanation,
373
+ title: parsedEntry.title,
374
+ };
375
+ this.collectedReferences.push(referenceEdge);
376
+ }
377
+ /**
378
+ * Indicates whether the References section heading has been detected.
379
+ *
380
+ * This is used by the traversal layer to determine when list items
381
+ * should be fed to this rule. Only list items immediately following
382
+ * the `## References` heading should be evaluated.
383
+ *
384
+ * @returns `true` if at least one `## References` heading has been encountered
385
+ */
386
+ tellReferencesHeadingDetected() {
387
+ return this.referencesHeadingCount > 0;
388
+ }
389
+ /**
390
+ * Finalises the rule evaluation and produces the complete result.
391
+ *
392
+ * This method must be called after all heading and list item nodes
393
+ * have been supplied via {@link evaluateHeading} and {@link evaluateListItem}.
394
+ * It analyses the count of References headings encountered and emits
395
+ * appropriate diagnostics:
396
+ *
397
+ * - Zero References headings: emits a "missing References section" error diagnostic
398
+ * - Multiple References headings: emits a "multiple References sections" error diagnostic
399
+ * - Exactly one References heading with no list items: emits an "empty section" info
400
+ * diagnostic, since an empty section is valid (1#9.6)
401
+ *
402
+ * @returns The complete rule result including all diagnostics and extracted reference edges
403
+ */
404
+ finalise() {
405
+ if (this.referencesHeadingCount === 0) {
406
+ const diagnostic = this.createDiagnostic('Document is missing a ## References section.');
407
+ this.collectedDiagnostics.push(diagnostic);
408
+ }
409
+ else if (this.referencesHeadingCount > 1) {
410
+ const diagnostic = this.createDiagnostic(`Document contains multiple References sections (found ${String(this.referencesHeadingCount)}), ` +
411
+ `but exactly one is required.`);
412
+ this.collectedDiagnostics.push(diagnostic);
413
+ }
414
+ else if (this.evaluatedListItemCount === 0) {
415
+ // Valid, and reported as information (1#9.6, 1#12.3): an empty section
416
+ // states that the document references nothing. The severity is set
417
+ // here, where the rule is defined, rather than downgraded afterwards by
418
+ // a caller matching this message's wording -- which reverted to an
419
+ // error for anyone using the rule directly, and would have done so for
420
+ // everyone the day the message was reworded.
421
+ const diagnostic = this.createDiagnostic('References section has no entries, which declares that this document ' +
422
+ 'references no other document. If it does, list each one beneath the ' +
423
+ '## References heading.', undefined, { cause: REFERENCES_EMPTY_CAUSE }, 'info');
424
+ this.collectedDiagnostics.push(diagnostic);
425
+ }
426
+ return {
427
+ diagnostics: this.collectedDiagnostics,
428
+ references: this.collectedReferences,
429
+ };
430
+ }
431
+ // -------------------------------------------------------------------------
432
+ // Private helpers
433
+ // -------------------------------------------------------------------------
434
+ /**
435
+ * Parses a list item text string into its component parts.
436
+ *
437
+ * Attempts to match the pattern:
438
+ * `TargetDocID <dash> Title " (" Direction <dash> Explanation ")"`
439
+ *
440
+ * The relationship parenthetical is located first, by matching the entry's
441
+ * final `)` to its opening `(` (1#9.6). Taking the last `" ("` instead read
442
+ * `defines retries (contract - policy)` as a `contract` edge, and failed
443
+ * outright on `defines retries (including backoff)`. Within the
444
+ * parenthetical, the first separator splits Direction from Explanation;
445
+ * before it, the first separator splits TargetDocID from Title, which may
446
+ * itself contain separators.
447
+ *
448
+ * @param text - The plain text content of a list item node
449
+ * @returns The parsed entry, `'unbalanced'` when the parenthetical never
450
+ * closes, or `undefined` when the entry is otherwise malformed
451
+ */
452
+ parseListItemText(text) {
453
+ const entry = text.trimEnd();
454
+ const location = ReferencesSectionRule.locateRelationshipParenthetical(entry);
455
+ if (location === 'unbalanced') {
456
+ return 'unbalanced';
457
+ }
458
+ if (location === 'missing') {
459
+ return undefined;
460
+ }
461
+ // Markdown renders any whitespace before the "(" as a space, including a
462
+ // line break; whether the label shares the marker's source line is the
463
+ // source-form check's question, not the parser's.
464
+ const prefixWithGap = entry.substring(0, location.open);
465
+ if (!/\s$/.test(prefixWithGap)) {
466
+ return undefined;
467
+ }
468
+ const lastParenOpenIndex = prefixWithGap.trimEnd().length;
469
+ // Extract the parenthetical content (between "(" and the final ")")
470
+ const parentheticalContent = entry.substring(location.open + 1, entry.length - 1);
471
+ // Step 3: Split the parenthetical on " - " to get Direction and Explanation
472
+ const parentheticalMatch = SEPARATOR_PATTERN.exec(parentheticalContent);
473
+ if (parentheticalMatch === null) {
474
+ return undefined;
475
+ }
476
+ const parentheticalSeparatorIndex = parentheticalMatch.index;
477
+ const parentheticalSeparatorLength = parentheticalMatch[0].length;
478
+ const directionCandidate = parentheticalContent.substring(0, parentheticalSeparatorIndex);
479
+ const explanationCandidate = parentheticalContent.substring(parentheticalSeparatorIndex + parentheticalSeparatorLength);
480
+ // Step 4: Extract the prefix portion (before the parenthetical)
481
+ const prefixPortion = entry.substring(0, lastParenOpenIndex);
482
+ // Step 5: Find the first " - " in the prefix to split DocID from Title
483
+ const prefixMatch = SEPARATOR_PATTERN.exec(prefixPortion);
484
+ if (prefixMatch === null) {
485
+ return undefined;
486
+ }
487
+ const firstSeparatorIndex = prefixMatch.index;
488
+ const targetDocIdCandidate = prefixPortion.substring(0, firstSeparatorIndex);
489
+ const titleCandidate = prefixPortion.substring(firstSeparatorIndex + prefixMatch[0].length);
490
+ return {
491
+ targetDocId: targetDocIdCandidate,
492
+ title: titleCandidate,
493
+ direction: directionCandidate,
494
+ explanation: explanationCandidate,
495
+ };
496
+ }
497
+ /**
498
+ * Maps an offset in a segment's parsed text to its offset in the segment's
499
+ * source, by the rule for the segment's kind.
500
+ *
501
+ * @param segment - A `text` or `code` segment
502
+ * @param source - The segment's source
503
+ * @param offset - An offset within its parsed text
504
+ * @returns The corresponding source offset, or `undefined` when a text
505
+ * segment could not be aligned
506
+ */
507
+ static locateInSegmentSource(segment, source, offset) {
508
+ if (segment.kind === 'code') {
509
+ return ReferencesSectionRule.locateInCodeSource(source, offset);
510
+ }
511
+ return alignParsedToSource(source, segment.text)?.[offset];
512
+ }
513
+ /**
514
+ * Maps an offset in an inline code span's content to its source.
515
+ *
516
+ * Code content takes no escapes or character references, so it is the
517
+ * source between the backtick fences, with one exception: when it both
518
+ * begins and ends with a space, one space is stripped from each side.
519
+ *
520
+ * @param source - The code span's source, fences included
521
+ * @param offset - An offset within its parsed content
522
+ * @returns The corresponding source offset
523
+ */
524
+ static locateInCodeSource(source, offset) {
525
+ const fence = /^`+/.exec(source)?.[0].length ?? 0;
526
+ const inner = source.slice(fence, source.length - fence);
527
+ const stripped = inner.length >= 2 && /^[ \r\n]/.test(inner) && /[ \r\n]$/.test(inner) && inner.trim() !== '';
528
+ return fence + (stripped ? 1 : 0) + offset;
529
+ }
530
+ /**
531
+ * Finds what, if anything, keeps `^## References` from finding the heading.
532
+ *
533
+ * The line must be `## References` exactly (1#9.11 rule 4). The check that
534
+ * numbered headings share allows anything after the identifier, since a
535
+ * title follows it; that let `## References ##` and trailing spaces pass
536
+ * here, where the specification allows nothing.
537
+ *
538
+ * @param headingRange - Position of the `## References` heading
539
+ * @returns The obstruction, or `undefined` when the heading is findable
540
+ */
541
+ findHeadingObstruction(headingRange) {
542
+ if (this.sourceLines === undefined || headingRange === undefined) {
543
+ return undefined;
544
+ }
545
+ const lineNumber = headingRange.start.line;
546
+ if (this.sourceLines.readLine(lineNumber) !== REFERENCES_HEADING_LINE) {
547
+ return 'form';
548
+ }
549
+ return this.sourceLines.tellStartsSearchLine(lineNumber) ? undefined : 'lone-carriage-return';
550
+ }
551
+ /**
552
+ * Locates an entry's relationship parenthetical by matching its final `)`
553
+ * to the `(` that opens it, scanning right to left and counting depth
554
+ * (1#9.6). The explanation may then hold parentheses of its own, and the
555
+ * title may hold unmatched ones, without either being mistaken for it.
556
+ *
557
+ * @param entry - The entry's text, without trailing whitespace
558
+ * @returns The opening `(`'s index; `'missing'` when the entry does not end
559
+ * with `)`; `'unbalanced'` when the final `)` is never matched
560
+ */
561
+ static locateRelationshipParenthetical(entry) {
562
+ if (!entry.endsWith(')')) {
563
+ return 'missing';
564
+ }
565
+ let depth = 0;
566
+ for (let index = entry.length - 1; index >= 0; index -= 1) {
567
+ const character = entry.charAt(index);
568
+ if (character === ')') {
569
+ depth += 1;
570
+ }
571
+ else if (character === '(') {
572
+ depth -= 1;
573
+ if (depth === 0) {
574
+ return { open: index };
575
+ }
576
+ }
577
+ }
578
+ return 'unbalanced';
579
+ }
580
+ /**
581
+ * Checks that an entry is where the References recipes look (1#9.11 rule 4).
582
+ *
583
+ * The entry recipe `^\s*([-*+]|[0-9]+[.)])\s*\[?8\.1[^0-9.#]` and the
584
+ * direction recipe, which adds `.*\(authority`, each read one line. So the
585
+ * list marker, the target DocID in literal characters and the direction
586
+ * label must all be on the entry's first line, and that line must be a
587
+ * line to a search engine. The explanation may wrap freely after the label.
588
+ *
589
+ * @param entry - The parsed entry
590
+ * @param item - The list item the entry was parsed from
591
+ * @returns A diagnostic when a recipe cannot find the entry, or `undefined`
592
+ */
593
+ validateEntrySourceForm(entry, item) {
594
+ const range = item.range;
595
+ if (this.sourceLines === undefined || range === undefined || this.sectionMisplaced) {
596
+ return undefined;
597
+ }
598
+ const lineNumber = range.start.line;
599
+ const line = this.sourceLines.readLine(lineNumber) ?? '';
600
+ const data = {
601
+ cause: REFERENCES_ENTRY_SOURCE_FORM_CAUSE,
602
+ targetDocId: entry.targetDocId,
603
+ };
604
+ if (!ReferencesSectionRule.tellEntryLineMatches(line, entry) ||
605
+ !this.tellLabelOpensFirstLineParenthetical(item, line, entry)) {
606
+ return this.createDiagnostic(`The References entry for "${entry.targetDocId}" is not written as the entry ` +
607
+ `recipes read it, so they do not find it. The list marker, the target DocID in ` +
608
+ `literal characters and the direction label must all be on one line -- ` +
609
+ `"- ${entry.targetDocId} - Title (${entry.direction} - ...)" -- without ` +
610
+ `formatting on the DocID. The explanation may continue on later lines.`, range, { ...data, obstruction: 'form' });
611
+ }
612
+ if (!this.sourceLines.tellStartsSearchLine(lineNumber)) {
613
+ return this.createDiagnostic(`The References entry for "${entry.targetDocId}" follows a lone carriage return ` +
614
+ `(CR) line ending, so to a search it is the middle of the line before, and the ` +
615
+ `entry recipes do not find it. Save the file with LF or CRLF line endings.`, range, { ...data, obstruction: 'lone-carriage-return' });
616
+ }
617
+ return undefined;
618
+ }
619
+ /**
620
+ * Reports whether an entry's first line begins as the entry recipe expects:
621
+ * a list marker, then the target DocID in literal characters.
622
+ *
623
+ * @param line - The list item's first source line
624
+ * @param entry - The parsed entry
625
+ * @returns `true` when the entry recipe matches the line's start
626
+ */
627
+ static tellEntryLineMatches(line, entry) {
628
+ const prefix = ENTRY_LINE_PREFIX.exec(line);
629
+ if (prefix === null) {
630
+ return false;
631
+ }
632
+ const rest = line.slice(prefix[0].length);
633
+ return (rest.startsWith(entry.targetDocId) &&
634
+ AFTER_ENTRY_DOC_ID.test(rest.slice(entry.targetDocId.length)));
635
+ }
636
+ /**
637
+ * Reports whether the entry's own relationship parenthetical opens on its
638
+ * first line, with the direction label written literally after the `(`.
639
+ *
640
+ * The `(` is the one the parser chose: found in the parsed entry by
641
+ * matching its final `)`, then traced to the source through the parsed
642
+ * text node that holds it. Two shortcuts both failed. Asking whether
643
+ * `(dependency` occurred anywhere on the line let a title such as
644
+ * `Target (dependency graph)` answer for a formatted label. Balancing the
645
+ * raw source separately picked a different `(` whenever markup added or
646
+ * removed a parenthesis -- `&#40;`, `**(…)**`, a link destination holding
647
+ * `)` -- rejecting valid entries and, in one case, excusing an invalid one.
648
+ *
649
+ * @param item - The list item, with its parsed segments
650
+ * @param line - The list item's first source line
651
+ * @param entry - The parsed entry
652
+ * @returns `true` when the label follows the entry's own `(` on its first line
653
+ */
654
+ tellLabelOpensFirstLineParenthetical(item, line, entry) {
655
+ if (item.segments === undefined || item.range === undefined || this.sourceLines === undefined) {
656
+ return true;
657
+ }
658
+ const location = ReferencesSectionRule.locateRelationshipParenthetical(item.text.trimEnd());
659
+ if (typeof location === 'string') {
660
+ return false;
661
+ }
662
+ const label = `(${entry.direction}`;
663
+ let segmentStart = 0;
664
+ for (const segment of item.segments) {
665
+ const segmentEnd = segmentStart + segment.text.length;
666
+ if (location.open < segmentEnd) {
667
+ // The `(` and the whole label must come from one node. A label split
668
+ // by formatting or a code fence -- `(**dependency**` or
669
+ // ``(`dependency` `` -- is not literal text after the `(`. Inline
670
+ // code holding the whole parenthetical is: 1#9.5 keeps citations out
671
+ // of code, but no rule keeps References entries out of it, and both
672
+ // recipes find `` `(dependency - x)` ``.
673
+ if (segment.kind === 'other' || location.open + label.length > segmentEnd) {
674
+ return false;
675
+ }
676
+ return this.tellSegmentHoldsLiteralLabel(segment, location.open - segmentStart, label, item.range.start.line, line);
677
+ }
678
+ segmentStart = segmentEnd;
679
+ }
680
+ return false;
681
+ }
682
+ /**
683
+ * Traces a parsed `(` to the source through its text node, and checks that
684
+ * the label follows it literally on the entry's first line.
685
+ *
686
+ * @param segment - The text segment holding the `(`
687
+ * @param offset - The `(`'s offset within the segment's parsed text
688
+ * @param label - `(` followed by the direction label
689
+ * @param firstLine - The entry's first line number
690
+ * @param line - That line's text
691
+ * @returns `true` when the source holds the label there, on that line
692
+ */
693
+ tellSegmentHoldsLiteralLabel(segment, offset, label, firstLine, line) {
694
+ if (segment.range === undefined || this.sourceLines === undefined) {
695
+ return false;
696
+ }
697
+ const source = this.sourceLines.slice(segment.range);
698
+ const at = source === undefined ? undefined : ReferencesSectionRule.locateInSegmentSource(segment, source, offset);
699
+ const segmentStart = this.sourceLines.offsetOf(segment.range.start);
700
+ const lineStart = this.sourceLines.offsetOf({ line: firstLine, character: 0 });
701
+ if (source === undefined || at === undefined || segmentStart === undefined || lineStart === undefined) {
702
+ return false;
703
+ }
704
+ return source.startsWith(label, at) && segmentStart + at < lineStart + line.length;
705
+ }
706
+ /**
707
+ * Validates that a list item carries a separator, and returns an error
708
+ * diagnostic when none is found.
709
+ *
710
+ * Any dash variant is accepted. Detection is delegated to
711
+ * {@link IdentifierGrammar.validateSeparator}.
712
+ *
713
+ * @param text - The plain text content of a list item node
714
+ * @param range - Optional positional range for the diagnostic
715
+ * @returns A diagnostic if a separator is invalid, or `undefined` if all separators are valid
716
+ */
717
+ validateListItemSeparators(text, range) {
718
+ const separatorResult = this.grammar.validateSeparator(text);
719
+ if (!separatorResult.valid) {
720
+ const detectedDescription = this.describeSeparator(separatorResult.detectedSeparator);
721
+ const diagnostic = this.createDiagnostic(`References entry is malformed: no separator found (expected ${detectedDescription}). ` +
722
+ `The required format is: DocID <dash> Title (direction <dash> explanation).`, range);
723
+ return diagnostic;
724
+ }
725
+ return undefined;
726
+ }
727
+ /**
728
+ * Validates that a direction string is one of the allowed
729
+ * {@link ReferenceDirection} values.
730
+ *
731
+ * @param candidate - The direction string extracted from the parenthetical
732
+ * @returns `true` if the candidate is a valid direction, `false` otherwise
733
+ */
734
+ tellValidDirection(candidate) {
735
+ return VALID_REFERENCE_DIRECTIONS.has(candidate);
736
+ }
737
+ /**
738
+ * Creates a diagnostic object for the References Section Rule.
739
+ *
740
+ * All diagnostics share the same rule ID ({@link REFERENCES_SECTION_RULE_ID})
741
+ * and document URI. Each is an error except the empty-section finding, which
742
+ * is information (1#9.6, 1#12.3).
743
+ *
744
+ * @param message - Human-readable description of the issue
745
+ * @param range - Optional positional range within the source document
746
+ * @param data - Optional machine-readable detail, such as a `cause`
747
+ * @param severity - Severity per 1#12; an error unless stated otherwise
748
+ * @returns A fully populated diagnostic object
749
+ */
750
+ createDiagnostic(message, range, data, severity = REFERENCES_SECTION_DIAGNOSTIC_SEVERITY) {
751
+ const diagnostic = {
752
+ ruleId: REFERENCES_SECTION_RULE_ID,
753
+ severity,
754
+ message,
755
+ uri: this.uri,
756
+ ...(range !== undefined ? { range } : {}),
757
+ ...(data !== undefined ? { data } : {}),
758
+ };
759
+ return diagnostic;
760
+ }
761
+ /**
762
+ * Produces a human-readable description of a detected separator string.
763
+ *
764
+ * Replaces non-visible characters with their Unicode names to make
765
+ * diagnostics more informative.
766
+ *
767
+ * @param separator - The detected separator string
768
+ * @returns A human-readable description suitable for inclusion in a diagnostic message
769
+ */
770
+ describeSeparator(separator) {
771
+ const withNamedCharacters = separator
772
+ .replace(/\u2013/g, 'en dash U+2013')
773
+ .replace(/\u2014/g, 'em dash U+2014');
774
+ return `"${withNamedCharacters}"`;
775
+ }
776
+ }
777
+ //# sourceMappingURL=references-section-rule.js.map