@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,642 @@
1
+ import type {
2
+ CorpusDocumentEntry,
3
+ CorpusIndex,
4
+ CorpusResult,
5
+ Diagnostic,
6
+ DocID,
7
+ ExtractedDocument,
8
+ SectionID,
9
+ } from './types.js';
10
+ import {
11
+ CITATION_SOURCE_FORM_CAUSE,
12
+ INLINE_REFERENCE_RULE_ID,
13
+ UNDECLARED_TARGET_REASON,
14
+ } from './inline-reference-rule.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Internal helper types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * An extracted document paired with its host-provided URI.
22
+ *
23
+ * Used internally to associate extraction results with their source URI
24
+ * during corpus-wide indexing and resolution.
25
+ */
26
+ interface IndexableDocument {
27
+ /** Opaque, host-provided URI of the source document. */
28
+ readonly uri: string;
29
+ /** Successfully extracted structural artefacts for this document. */
30
+ readonly extracted: ExtractedDocument;
31
+ }
32
+
33
+ /**
34
+ * Mutable accumulator for corpus-wide diagnostics collected during validation.
35
+ */
36
+ interface DiagnosticAccumulator {
37
+ /** The diagnostics collected so far. */
38
+ readonly items: Diagnostic[];
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // CorpusValidator
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Validates a corpus of ECR documents for corpus-wide structural integrity.
47
+ *
48
+ * Performs Pass 2 of the ECR validation algorithm (per 1#11.2):
49
+ * global uniqueness of DocIDs and SectionIDs, resolution of reference
50
+ * targets, and resolution of inline reference targets.
51
+ *
52
+ * This class is stateless. Each invocation of {@link validateCorpus}
53
+ * operates purely over the provided document entries and produces a
54
+ * self-contained {@link CorpusResult}.
55
+ */
56
+ class CorpusValidator {
57
+ /**
58
+ * Validates corpus-wide structural invariants across a set of
59
+ * per-document extraction results.
60
+ *
61
+ * Documents whose per-document lint result does not include a
62
+ * successfully extracted artefact are excluded from corpus-wide
63
+ * indexing and cross-reference resolution.
64
+ *
65
+ * Corpus-wide violations are reported as diagnostics with severity
66
+ * `'error'` (per 1#9.9, 1#12.2), except a stale References title, which
67
+ * is a `'warning'` (1#9.9 rule 5).
68
+ *
69
+ * @param documents - The per-document entries to validate as a corpus.
70
+ * @returns A {@link CorpusResult} containing per-document results,
71
+ * corpus-wide diagnostics, and an optional global identifier index.
72
+ */
73
+ public validateCorpus(
74
+ documents: readonly CorpusDocumentEntry[],
75
+ ): CorpusResult {
76
+ const indexableDocuments: readonly IndexableDocument[] =
77
+ this.collectIndexableDocuments(documents);
78
+
79
+ const accumulator: DiagnosticAccumulator = { items: [] };
80
+
81
+ // Step 1–3: Build indexes and validate global uniqueness
82
+ const docIdIndex: Map<DocID, string> =
83
+ this.buildDocIdIndex(indexableDocuments, accumulator);
84
+ const sectionIdIndex: Map<SectionID, string> =
85
+ this.buildSectionIdIndex(indexableDocuments, accumulator);
86
+
87
+ const hasDuplicateIdentifiers: boolean = accumulator.items.length > 0;
88
+
89
+ // Step 4: Resolve ReferenceEdge.toDocId targets, and warn where an
90
+ // entry's title has drifted from the target document's own title
91
+ this.resolveReferenceTargets(
92
+ indexableDocuments,
93
+ docIdIndex,
94
+ this.buildCanonicalTitleIndex(indexableDocuments),
95
+ accumulator,
96
+ );
97
+
98
+ // Step 5: Resolve InlineReferenceEdge.toId targets
99
+ this.resolveInlineReferenceTargets(
100
+ indexableDocuments,
101
+ docIdIndex,
102
+ sectionIdIndex,
103
+ accumulator,
104
+ );
105
+
106
+ // Step 6: Validate inline reference parent DocID declared in References
107
+ this.validateInlineReferenceParentDeclarations(
108
+ indexableDocuments,
109
+ docIdIndex,
110
+ accumulator,
111
+ );
112
+
113
+ // Step 6b: An undeclared inline target that names a real document is an error
114
+ this.escalateUndeclaredInlineTargets(documents, docIdIndex, accumulator);
115
+
116
+ if (hasDuplicateIdentifiers) {
117
+ return {
118
+ documents,
119
+ diagnostics: accumulator.items,
120
+ };
121
+ }
122
+
123
+ return {
124
+ documents,
125
+ index: this.buildCorpusIndex(docIdIndex, sectionIdIndex),
126
+ diagnostics: accumulator.items,
127
+ };
128
+ }
129
+
130
+ // -------------------------------------------------------------------------
131
+ // Private helpers — filtering
132
+ // -------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Filters the input document entries to only those with successfully
136
+ * extracted structural artefacts.
137
+ *
138
+ * Documents without an `extracted` field on their lint result are excluded
139
+ * from corpus-wide indexing and cross-reference resolution.
140
+ *
141
+ * @param documents - The full set of corpus document entries.
142
+ * @returns An array of indexable documents with guaranteed `extracted` fields.
143
+ */
144
+ private collectIndexableDocuments(
145
+ documents: readonly CorpusDocumentEntry[],
146
+ ): readonly IndexableDocument[] {
147
+ const indexable: IndexableDocument[] = [];
148
+
149
+ for (const entry of documents) {
150
+ if (entry.result.extracted !== undefined) {
151
+ indexable.push({
152
+ uri: entry.uri,
153
+ extracted: entry.result.extracted,
154
+ });
155
+ }
156
+ }
157
+
158
+ return indexable;
159
+ }
160
+
161
+ // -------------------------------------------------------------------------
162
+ // Private helpers — index building (Steps 1–3)
163
+ // -------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Builds a global DocID-to-URI index and emits error diagnostics for
167
+ * any duplicate DocIDs found across the corpus.
168
+ *
169
+ * Per 1#9.9: DocID MUST be globally unique. Violations are errors
170
+ * (per 1#12.2).
171
+ *
172
+ * @param indexableDocuments - Documents with successfully extracted artefacts.
173
+ * @param accumulator - Diagnostic accumulator for recording violations.
174
+ * @returns A map from each DocID to the URI of its first-encountered defining document.
175
+ */
176
+ private buildDocIdIndex(
177
+ indexableDocuments: readonly IndexableDocument[],
178
+ accumulator: DiagnosticAccumulator,
179
+ ): Map<DocID, string> {
180
+ const docIdIndex: Map<DocID, string> = new Map<DocID, string>();
181
+ const duplicateDocIds: Map<DocID, readonly string[]> = new Map<DocID, readonly string[]>();
182
+
183
+ for (const document of indexableDocuments) {
184
+ const docId: DocID = document.extracted.docId;
185
+ const existingUri: string | undefined = docIdIndex.get(docId);
186
+
187
+ if (existingUri !== undefined) {
188
+ const existing: readonly string[] | undefined =
189
+ duplicateDocIds.get(docId);
190
+
191
+ if (existing !== undefined) {
192
+ duplicateDocIds.set(docId, [...existing, document.uri]);
193
+ } else {
194
+ duplicateDocIds.set(docId, [existingUri, document.uri]);
195
+ }
196
+ } else {
197
+ docIdIndex.set(docId, document.uri);
198
+ }
199
+ }
200
+
201
+ for (const [docId, uris] of duplicateDocIds) {
202
+ for (const uri of uris) {
203
+ accumulator.items.push({
204
+ severity: 'error',
205
+ ruleId: 'corpus/duplicate-doc-id',
206
+ message: `Duplicate DocID '${docId}' found in multiple documents`,
207
+ uri,
208
+ docId,
209
+ data: { duplicateUris: uris },
210
+ });
211
+ }
212
+ }
213
+
214
+ return docIdIndex;
215
+ }
216
+
217
+ /**
218
+ * Builds a global SectionID-to-URI index and emits error diagnostics for
219
+ * any duplicate SectionIDs found across the corpus.
220
+ *
221
+ * Per 1#9.9: SectionID MUST be globally unique across corpus.
222
+ * Violations are errors (per 1#12.2).
223
+ *
224
+ * @param indexableDocuments - Documents with successfully extracted artefacts.
225
+ * @param accumulator - Diagnostic accumulator for recording violations.
226
+ * @returns A map from each SectionID to the URI of its first-encountered defining document.
227
+ */
228
+ private buildSectionIdIndex(
229
+ indexableDocuments: readonly IndexableDocument[],
230
+ accumulator: DiagnosticAccumulator,
231
+ ): Map<SectionID, string> {
232
+ const sectionIdIndex: Map<SectionID, string> = new Map<SectionID, string>();
233
+ const duplicateSectionIds: Map<SectionID, readonly string[]> =
234
+ new Map<SectionID, readonly string[]>();
235
+
236
+ for (const document of indexableDocuments) {
237
+ for (const section of document.extracted.sections) {
238
+ // Skip the root H1 node — its id is the DocID, handled by DocID index
239
+ if (section.headingDepth === 1) {
240
+ continue;
241
+ }
242
+
243
+ const sectionId: SectionID = section.id;
244
+ const existingUri: string | undefined = sectionIdIndex.get(sectionId);
245
+
246
+ if (existingUri !== undefined) {
247
+ const existing: readonly string[] | undefined =
248
+ duplicateSectionIds.get(sectionId);
249
+
250
+ if (existing !== undefined) {
251
+ duplicateSectionIds.set(sectionId, [...existing, document.uri]);
252
+ } else {
253
+ duplicateSectionIds.set(sectionId, [existingUri, document.uri]);
254
+ }
255
+ } else {
256
+ sectionIdIndex.set(sectionId, document.uri);
257
+ }
258
+ }
259
+ }
260
+
261
+ for (const [sectionId, uris] of duplicateSectionIds) {
262
+ for (const uri of uris) {
263
+ const baseDiagnostic: Diagnostic = {
264
+ severity: 'error',
265
+ ruleId: 'corpus/duplicate-section-id',
266
+ message: `Duplicate SectionID '${sectionId}' found in multiple documents`,
267
+ uri,
268
+ sectionId,
269
+ data: { duplicateUris: uris },
270
+ };
271
+
272
+ accumulator.items.push(baseDiagnostic);
273
+ }
274
+ }
275
+
276
+ return sectionIdIndex;
277
+ }
278
+
279
+ // -------------------------------------------------------------------------
280
+ // Private helpers — reference resolution (Steps 4–6)
281
+ // -------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Maps each DocID to the title in its document's H1.
285
+ *
286
+ * Where a DocID is duplicated, the first-encountered document wins, matching
287
+ * {@link CorpusValidator.buildDocIdIndex}. The duplicate is reported there.
288
+ *
289
+ * @param indexableDocuments - Documents with successfully extracted artefacts.
290
+ * @returns A map from each DocID to its canonical title.
291
+ */
292
+ private buildCanonicalTitleIndex(
293
+ indexableDocuments: readonly IndexableDocument[],
294
+ ): ReadonlyMap<DocID, string> {
295
+ const canonicalTitles: Map<DocID, string> = new Map<DocID, string>();
296
+
297
+ for (const document of indexableDocuments) {
298
+ if (!canonicalTitles.has(document.extracted.docId)) {
299
+ canonicalTitles.set(document.extracted.docId, document.extracted.title.trim());
300
+ }
301
+ }
302
+
303
+ return canonicalTitles;
304
+ }
305
+
306
+ /**
307
+ * Resolves all `ReferenceEdge.toDocId` targets against the global DocID
308
+ * index and emits error diagnostics for unresolved targets.
309
+ *
310
+ * Per 1#9.9 rule 3: Every TargetDocID in References MUST resolve to an
311
+ * existing DocID. Violations are errors (per 1#12.2).
312
+ *
313
+ * Per 1#9.9 rule 5: where the target resolves but the entry's title differs
314
+ * from the target's H1 title, a warning is emitted carrying the canonical
315
+ * title. Titles are not part of graph identity, so a stale title never
316
+ * fails validation.
317
+ *
318
+ * @param indexableDocuments - Documents with successfully extracted artefacts.
319
+ * @param docIdIndex - The global DocID-to-URI index.
320
+ * @param canonicalTitles - Each DocID's title as written in its own H1.
321
+ * @param accumulator - Diagnostic accumulator for recording violations.
322
+ */
323
+ private resolveReferenceTargets(
324
+ indexableDocuments: readonly IndexableDocument[],
325
+ docIdIndex: ReadonlyMap<DocID, string>,
326
+ canonicalTitles: ReadonlyMap<DocID, string>,
327
+ accumulator: DiagnosticAccumulator,
328
+ ): void {
329
+ for (const document of indexableDocuments) {
330
+ for (const reference of document.extracted.references) {
331
+ const targetExists: boolean = docIdIndex.has(reference.toDocId);
332
+
333
+ if (!targetExists) {
334
+ accumulator.items.push({
335
+ severity: 'error',
336
+ ruleId: 'corpus/unresolved-reference-target',
337
+ message: `References entry target DocID '${reference.toDocId}' does not resolve to any document in the corpus`,
338
+ uri: document.uri,
339
+ docId: document.extracted.docId,
340
+ data: { unresolvedDocId: reference.toDocId },
341
+ });
342
+ continue;
343
+ }
344
+
345
+ const canonicalTitle: string | undefined = canonicalTitles.get(reference.toDocId);
346
+ const referencedTitle: string = reference.title.trim();
347
+
348
+ if (canonicalTitle !== undefined && referencedTitle !== canonicalTitle) {
349
+ accumulator.items.push({
350
+ severity: 'warning',
351
+ ruleId: 'corpus/reference-title-mismatch',
352
+ message: `References entry for '${reference.toDocId}' is titled '${referencedTitle}', but that document's title is '${canonicalTitle}'`,
353
+ uri: document.uri,
354
+ docId: document.extracted.docId,
355
+ data: {
356
+ targetDocId: reference.toDocId,
357
+ referencedTitle,
358
+ canonicalTitle,
359
+ },
360
+ });
361
+ }
362
+ }
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Resolves all `InlineReferenceEdge.toId` targets against the union of
368
+ * DocID and SectionID indexes and emits error diagnostics for unresolved
369
+ * targets.
370
+ *
371
+ * Per 1#9.9 rule 4: Every inline TargetID MUST resolve to an existing
372
+ * SectionID. Violations are errors (per 1#12.2).
373
+ *
374
+ * Note: the spec says "existing SectionID" but the algorithm (1#11.2
375
+ * step 5) resolves against "the union of DocID and SectionID indexes",
376
+ * meaning a DocID is also a valid resolution target for inline references.
377
+ *
378
+ * @param indexableDocuments - Documents with successfully extracted artefacts.
379
+ * @param docIdIndex - The global DocID-to-URI index.
380
+ * @param sectionIdIndex - The global SectionID-to-URI index.
381
+ * @param accumulator - Diagnostic accumulator for recording violations.
382
+ */
383
+ private resolveInlineReferenceTargets(
384
+ indexableDocuments: readonly IndexableDocument[],
385
+ docIdIndex: ReadonlyMap<DocID, string>,
386
+ sectionIdIndex: ReadonlyMap<SectionID, string>,
387
+ accumulator: DiagnosticAccumulator,
388
+ ): void {
389
+ for (const document of indexableDocuments) {
390
+ for (const inlineReference of document.extracted.inlineReferences) {
391
+ const targetId: string = inlineReference.toId;
392
+ const resolvedInDocIds: boolean = docIdIndex.has(targetId);
393
+ const resolvedInSectionIds: boolean = sectionIdIndex.has(targetId);
394
+
395
+ if (!resolvedInDocIds && !resolvedInSectionIds) {
396
+ accumulator.items.push({
397
+ severity: 'error',
398
+ ruleId: 'corpus/unresolved-inline-target',
399
+ message: `Inline reference target '${targetId}' does not resolve to any DocID or SectionID in the corpus`,
400
+ uri: document.uri,
401
+ docId: document.extracted.docId,
402
+ data: {
403
+ unresolvedTargetId: targetId,
404
+ fromId: inlineReference.fromId,
405
+ kind: inlineReference.kind,
406
+ },
407
+ });
408
+ }
409
+ }
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Validates that for each extracted inline reference whose target is a
415
+ * SectionID, the DocID of that SectionID is declared in the referring
416
+ * document's References section.
417
+ *
418
+ * Per 1#9.5 rule 2 and 1#11.2 step 6: if an inline reference targets
419
+ * `X#Y`, the DocID `X` MUST appear in the References section of the
420
+ * referring document.
421
+ *
422
+ * The linter itself never extracts an undeclared edge, so through the
423
+ * {@link Ecr} facade this cannot fire; undeclared targets are handled by
424
+ * {@link CorpusValidator.escalateUndeclaredInlineTargets}. It guards hosts
425
+ * that assemble extracted documents themselves.
426
+ *
427
+ * @param indexableDocuments - Documents with successfully extracted artefacts.
428
+ * @param docIdIndex - The global DocID-to-URI index.
429
+ * @param accumulator - Diagnostic accumulator for recording violations.
430
+ */
431
+ private validateInlineReferenceParentDeclarations(
432
+ indexableDocuments: readonly IndexableDocument[],
433
+ docIdIndex: ReadonlyMap<DocID, string>,
434
+ accumulator: DiagnosticAccumulator,
435
+ ): void {
436
+ for (const document of indexableDocuments) {
437
+ const declaredTargetDocIds: ReadonlySet<DocID> =
438
+ this.collectDeclaredReferenceDocIds(document.extracted);
439
+
440
+ for (const inlineReference of document.extracted.inlineReferences) {
441
+ const targetId: string = inlineReference.toId;
442
+
443
+ // If the target is itself a known DocID, no parent-declaration
444
+ // check is needed — the inline reference targets the document
445
+ // directly.
446
+ if (docIdIndex.has(targetId)) {
447
+ continue;
448
+ }
449
+
450
+ // The target is a SectionID. Its DocID is the text before the `#`.
451
+ const parentDocId: DocID | undefined =
452
+ this.findParentDocId(targetId, docIdIndex);
453
+
454
+ if (parentDocId === undefined) {
455
+ // No parent DocID found in corpus — the unresolved-inline-target
456
+ // diagnostic already covers this case (step 5). Skip here.
457
+ continue;
458
+ }
459
+
460
+ // If the parent DocID is the current document itself, no
461
+ // cross-document dependency declaration is needed — the
462
+ // section is local.
463
+ if (parentDocId === document.extracted.docId) {
464
+ continue;
465
+ }
466
+
467
+ if (!declaredTargetDocIds.has(parentDocId)) {
468
+ accumulator.items.push({
469
+ severity: 'error',
470
+ ruleId: 'corpus/undeclared-inline-target',
471
+ message: `Inline reference to '${targetId}' targets undeclared DocID '${parentDocId}' — declare it in the References section`,
472
+ uri: document.uri,
473
+ docId: document.extracted.docId,
474
+ data: {
475
+ targetId,
476
+ parentDocId,
477
+ fromId: inlineReference.fromId,
478
+ kind: inlineReference.kind,
479
+ },
480
+ });
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ /**
487
+ * Raises a document-level undeclared-target warning to an error when the
488
+ * target turns out to be a document in the corpus.
489
+ *
490
+ * Per 1#9.5 rule 3: a single document cannot tell `per 3.1` (a reference
491
+ * whose declaration is missing) from `per 60 seconds` (prose), so ECR104
492
+ * only warns. Here the corpus is known. If DocID `3.1` exists, the author
493
+ * has referenced a real document without declaring it, which is an error.
494
+ * If no document `60` exists, the warning stands on its own.
495
+ *
496
+ * An undeclared SectionID target (`per 3.1#2`) is already an ECR104 error,
497
+ * so only warnings are considered here; raising it again would report the
498
+ * same mistake twice.
499
+ *
500
+ * @param documents - The per-document entries, including their diagnostics.
501
+ * @param docIdIndex - The global DocID-to-URI index.
502
+ * @param accumulator - Diagnostic accumulator for recording violations.
503
+ */
504
+ private escalateUndeclaredInlineTargets(
505
+ documents: readonly CorpusDocumentEntry[],
506
+ docIdIndex: ReadonlyMap<DocID, string>,
507
+ accumulator: DiagnosticAccumulator,
508
+ ): void {
509
+ for (const entry of documents) {
510
+ for (const warning of entry.result.diagnostics) {
511
+ const targetDocId: unknown = warning.data?.targetDocId;
512
+
513
+ if (
514
+ warning.ruleId !== INLINE_REFERENCE_RULE_ID ||
515
+ warning.severity !== 'warning' ||
516
+ warning.data?.reason !== UNDECLARED_TARGET_REASON ||
517
+ typeof targetDocId !== 'string' ||
518
+ !docIdIndex.has(targetDocId)
519
+ ) {
520
+ continue;
521
+ }
522
+
523
+ const targetId: unknown = warning.data.targetId;
524
+ const cause: unknown = warning.data.cause;
525
+
526
+ // A citation no search can find (1#9.5 rule 4) warns for the same
527
+ // reason an undeclared one does, and is raised here for the same
528
+ // reason. Declaring the target would not make it findable, so the
529
+ // message has to name the source form as well. Only an undeclared
530
+ // target ever warns, so both parts always apply.
531
+ const message: string =
532
+ cause === CITATION_SOURCE_FORM_CAUSE
533
+ ? `Inline reference to '${String(targetId)}' is not written as adjacent literal ` +
534
+ `text on one line, so no search finds it, and DocID '${targetDocId}' exists in ` +
535
+ `the corpus, so it is certainly a citation — write it as literal text and ` +
536
+ `declare it in the References section`
537
+ : `Inline reference to '${String(targetId)}' targets DocID '${targetDocId}', ` +
538
+ `which exists in the corpus but is not declared — declare it in the References section`;
539
+
540
+ accumulator.items.push({
541
+ severity: 'error',
542
+ ruleId: 'corpus/undeclared-inline-target',
543
+ message,
544
+ uri: entry.uri,
545
+ ...(warning.range !== undefined ? { range: warning.range } : {}),
546
+ ...(entry.result.extracted !== undefined ? { docId: entry.result.extracted.docId } : {}),
547
+ data: {
548
+ ...(cause !== undefined ? { cause } : {}),
549
+ targetId,
550
+ parentDocId: targetDocId,
551
+ fromId: warning.data.fromId,
552
+ kind: warning.data.kind,
553
+ },
554
+ });
555
+ }
556
+ }
557
+ }
558
+
559
+ // -------------------------------------------------------------------------
560
+ // Private helpers — index construction
561
+ // -------------------------------------------------------------------------
562
+
563
+ /**
564
+ * Converts the mutable maps into a frozen {@link CorpusIndex}.
565
+ *
566
+ * @param docIdIndex - The global DocID-to-URI map.
567
+ * @param sectionIdIndex - The global SectionID-to-URI map.
568
+ * @returns A frozen corpus index suitable for graph construction.
569
+ */
570
+ private buildCorpusIndex(
571
+ docIdIndex: ReadonlyMap<DocID, string>,
572
+ sectionIdIndex: ReadonlyMap<SectionID, string>,
573
+ ): CorpusIndex {
574
+ const docIds: Record<DocID, string> = {};
575
+ const sectionIds: Record<SectionID, string> = {};
576
+
577
+ for (const [docId, uri] of docIdIndex) {
578
+ docIds[docId] = uri;
579
+ }
580
+
581
+ for (const [sectionId, uri] of sectionIdIndex) {
582
+ sectionIds[sectionId] = uri;
583
+ }
584
+
585
+ return { docIds, sectionIds };
586
+ }
587
+
588
+ // -------------------------------------------------------------------------
589
+ // Private helpers — identifier utilities
590
+ // -------------------------------------------------------------------------
591
+
592
+ /**
593
+ * Collects the set of target DocIDs declared in a document's References
594
+ * section.
595
+ *
596
+ * @param extracted - The extracted document whose references are inspected.
597
+ * @returns A set of DocIDs declared as reference targets.
598
+ */
599
+ private collectDeclaredReferenceDocIds(
600
+ extracted: ExtractedDocument,
601
+ ): ReadonlySet<DocID> {
602
+ const declaredDocIds: Set<DocID> = new Set<DocID>();
603
+
604
+ for (const reference of extracted.references) {
605
+ declaredDocIds.add(reference.toDocId);
606
+ }
607
+
608
+ return declaredDocIds;
609
+ }
610
+
611
+ /**
612
+ * Finds the DocID a SectionID belongs to.
613
+ *
614
+ * This is a direct read rather than a search: everything before the `#`
615
+ * separator is the DocID. A dotted-only grammar would have to try
616
+ * progressively longer dotted prefixes against the index, which cannot
617
+ * distinguish section `1` of document `0.0.1` from section `1.3` of
618
+ * document `0.0`, and silently prefers whichever the corpus happens to
619
+ * contain.
620
+ *
621
+ * @param sectionId - The SectionID whose DocID is sought.
622
+ * @param docIdIndex - The global DocID-to-URI index, used to confirm the document exists.
623
+ * @returns The DocID the section belongs to, or `undefined` if it is not in the corpus.
624
+ */
625
+ private findParentDocId(
626
+ sectionId: string,
627
+ docIdIndex: ReadonlyMap<DocID, string>,
628
+ ): DocID | undefined {
629
+ const separatorIndex: number = sectionId.indexOf('#');
630
+
631
+ if (separatorIndex < 0) {
632
+ return undefined;
633
+ }
634
+
635
+ const docId: string = sectionId.substring(0, separatorIndex);
636
+
637
+ return docIdIndex.has(docId) ? docId : undefined;
638
+ }
639
+
640
+ }
641
+
642
+ export { CorpusValidator };