@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,173 @@
1
+ /**
2
+ * Heading Source Form
3
+ *
4
+ * Checks that a heading carrying a DocID or SectionID is written in the one
5
+ * source form the published recipes can find (1#9.11 rule 1): an ATX heading
6
+ * whose line begins with its `#` characters, then exactly one space, then the
7
+ * identifier in literal characters.
8
+ *
9
+ * The Markdown parser accepts far more than that. A setext heading, an
10
+ * indented one, one inside a blockquote, one with a tab or two spaces after
11
+ * the hashes, and one whose identifier is emphasised, escaped or written as a
12
+ * character reference all parse to the same identifier -- and none of them is
13
+ * found by `^# 8\.1[^0-9.#]` or `^#+ 8\.1#3([^0-9]|$)`. So the check reads the
14
+ * heading's own source line and asks the question the recipe asks.
15
+ *
16
+ * It also asks whether that line is a line at all to a search engine. Two
17
+ * things can hide a correctly written heading from `^`: a lone carriage
18
+ * return before it, which Markdown treats as a line ending and the engines do
19
+ * not, and a byte-order mark before the first line, which ripgrep skips and
20
+ * GNU grep does not.
21
+ *
22
+ * Shared by ECR101 (the H1) and ECR102 (every deeper heading).
23
+ */
24
+
25
+ import type { PositionRange } from './types.js';
26
+ import { SourceLines } from './source-lines.js';
27
+
28
+ /** The `data.cause` of a heading whose source form no recipe can find. */
29
+ export const HEADING_SOURCE_FORM_CAUSE: string = 'heading-source-form';
30
+
31
+ /**
32
+ * Why a heading cannot be found, carried as `data.obstruction`.
33
+ *
34
+ * - `form`: the heading line is not `#… identifier` from its first column.
35
+ * - `lone-carriage-return`: it is, but a lone CR precedes it, so to a search
36
+ * it is the middle of the line before.
37
+ * - `byte-order-mark`: it is, but it is the first line and a byte-order mark
38
+ * precedes it, which GNU grep reads as text before the `#`.
39
+ */
40
+ export type HeadingObstruction = 'form' | 'lone-carriage-return' | 'byte-order-mark';
41
+
42
+ /** The UTF-8 byte-order mark, as it appears at the start of decoded text. */
43
+ const BYTE_ORDER_MARK: string = '';
44
+
45
+ /**
46
+ * What may follow a DocID on its heading line, per the document recipe
47
+ * `^# 8\.1[^0-9.#]`: anything but a digit, `.` or `#`, so that `8.1` is not
48
+ * found as the start of `8.10`, `8.1.3` or `8.1#3`.
49
+ */
50
+ const AFTER_DOC_ID: RegExp = /^[^0-9.#]/;
51
+
52
+ /**
53
+ * What may follow a SectionID on its heading line, per the section recipe
54
+ * `^#+ 8\.1#3([^0-9]|$)`: anything but a digit, or the end of the line.
55
+ */
56
+ const AFTER_SECTION_ID: RegExp = /^(?:[^0-9]|$)/;
57
+
58
+ /**
59
+ * Tells whether headings are written where the recipes look for them.
60
+ */
61
+ export class HeadingSourceForm {
62
+ /** The document's source, by line; absent when none was supplied. */
63
+ private readonly lines: SourceLines | undefined;
64
+
65
+ /**
66
+ * @param sourceText - The document's raw Markdown. When absent, as in unit
67
+ * tests that supply only parsed heading text, there is
68
+ * no source to consult and every heading passes.
69
+ */
70
+ public constructor(sourceText: string | undefined) {
71
+ this.lines = sourceText === undefined ? undefined : new SourceLines(sourceText);
72
+ }
73
+
74
+ /**
75
+ * Finds what, if anything, stops the recipe for a heading from finding it.
76
+ *
77
+ * @param range - The heading node's range
78
+ * @param depth - The heading's depth, 1 for the H1
79
+ * @param identifier - The DocID or SectionID parsed from the heading
80
+ * @returns The obstruction, or `undefined` when the recipe would find it
81
+ */
82
+ public findObstruction(
83
+ range: PositionRange | undefined,
84
+ depth: number,
85
+ identifier: string,
86
+ ): HeadingObstruction | undefined {
87
+ if (this.lines === undefined || range === undefined) {
88
+ return undefined;
89
+ }
90
+
91
+ const lineNumber: number = range.start.line;
92
+ const line: string = this.lines.readLine(lineNumber) ?? '';
93
+
94
+ // A mark before the first line is reported only when it is what stands
95
+ // in the way: after it, the line must be a correct heading. A mark
96
+ // followed by a newline obstructs nothing, since the H1 then starts a
97
+ // later line, which both engines find.
98
+ if (lineNumber === 0 && line.startsWith(BYTE_ORDER_MARK)) {
99
+ return HeadingSourceForm.tellLineMatches(line.slice(1), depth, identifier)
100
+ ? 'byte-order-mark'
101
+ : 'form';
102
+ }
103
+
104
+ if (!HeadingSourceForm.tellLineMatches(line, depth, identifier)) {
105
+ return 'form';
106
+ }
107
+
108
+ return this.lines.tellStartsSearchLine(lineNumber) ? undefined : 'lone-carriage-return';
109
+ }
110
+
111
+ /**
112
+ * Reports whether a line reads as the recipe for its heading expects.
113
+ *
114
+ * @param line - The heading's source line
115
+ * @param depth - The heading's depth
116
+ * @param identifier - The DocID or SectionID parsed from the heading
117
+ * @returns `true` when the line is `#… identifier` followed by an allowed character
118
+ */
119
+ private static tellLineMatches(line: string, depth: number, identifier: string): boolean {
120
+ const prefix: string = `${'#'.repeat(depth)} ${identifier}`;
121
+
122
+ if (!line.startsWith(prefix)) {
123
+ return false;
124
+ }
125
+
126
+ const rest: string = line.slice(prefix.length);
127
+
128
+ return depth === 1 ? AFTER_DOC_ID.test(rest) : AFTER_SECTION_ID.test(rest);
129
+ }
130
+
131
+ /**
132
+ * The explanation a diagnostic gives for an unnavigable heading.
133
+ *
134
+ * @param obstruction - What stops the recipe from finding it
135
+ * @param depth - The heading's depth
136
+ * @param identifier - The DocID or SectionID parsed from the heading
137
+ * @returns A message naming the obstruction and the fix
138
+ */
139
+ public static explain(
140
+ obstruction: HeadingObstruction,
141
+ depth: number,
142
+ identifier: string,
143
+ ): string {
144
+ const hashes: string = '#'.repeat(depth);
145
+ const opening: string =
146
+ `The heading for "${identifier}" is not in the source form the navigation ` +
147
+ `recipes search for, so no search finds it.`;
148
+
149
+ switch (obstruction) {
150
+ case 'lone-carriage-return':
151
+ return (
152
+ `${opening} It follows a lone carriage return (CR) line ending, and search ` +
153
+ `tools start a new line only at a line feed, so to them it is the middle of ` +
154
+ `the line before. Save the file with LF or CRLF line endings.`
155
+ );
156
+ case 'byte-order-mark':
157
+ // Not `opening`: ripgrep skips the mark and does find this heading.
158
+ return (
159
+ `The heading for "${identifier}" is not discoverable by every published ` +
160
+ `recipe. The file begins with a UTF-8 byte-order mark, which sits before the ` +
161
+ `heading's # characters; ripgrep skips it, but GNU grep reads it as text there ` +
162
+ `and finds no heading. Save the file as UTF-8 without a byte-order mark.`
163
+ );
164
+ default:
165
+ return (
166
+ `${opening} Write it as an ATX heading at the start of its line -- ` +
167
+ `"${hashes} ${identifier} - ..." -- with one space after the # characters ` +
168
+ `and the identifier in literal characters, without formatting, escapes or ` +
169
+ `indentation.`
170
+ );
171
+ }
172
+ }
173
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * DocID and SectionID Grammar Parsing & Validation
3
+ *
4
+ * Implements the identifier grammar defined in the ECR specification (1#9.2).
5
+ *
6
+ * Grammar:
7
+ * DocID ::= Digit+ ("." Digit+)*
8
+ * SectionPath ::= Digit+ ("." Digit+)*
9
+ * SectionID ::= DocID "#" SectionPath
10
+ *
11
+ * All operations are deterministic and side-effect-free.
12
+ */
13
+
14
+ import type { DocID, SectionID } from './types.js';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Result types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Represents the outcome of parsing a string as a DocID.
22
+ *
23
+ * When parsing succeeds, `valid` is `true` and `docId` holds the parsed value.
24
+ * When parsing fails, `valid` is `false`.
25
+ */
26
+ export type DocIdParseResult =
27
+ | { readonly valid: true; readonly docId: DocID }
28
+ | { readonly valid: false };
29
+
30
+ /**
31
+ * Represents the outcome of parsing a string as a SectionID.
32
+ *
33
+ * When parsing succeeds, `valid` is `true`, `sectionId` holds the parsed value,
34
+ * and `docId` holds the DocID prefix that the SectionID extends.
35
+ * When parsing fails, `valid` is `false`.
36
+ */
37
+ export type SectionIdParseResult =
38
+ | {
39
+ readonly valid: true;
40
+ readonly sectionId: SectionID;
41
+ readonly docId: DocID;
42
+ /** The section path following the `#` separator, e.g. `3.1` in `8.1#3.1`. */
43
+ readonly sectionPath: string;
44
+ }
45
+ | { readonly valid: false };
46
+
47
+ /**
48
+ * Represents the outcome of extracting a SectionID and title from a sub-heading.
49
+ *
50
+ * When extraction succeeds, `valid` is `true` and both `sectionId` and `title`
51
+ * are present. When it fails, `valid` is `false`.
52
+ */
53
+ export type SectionHeadingParseResult =
54
+ | { readonly valid: true; readonly sectionId: SectionID; readonly title: string }
55
+ | { readonly valid: false };
56
+
57
+ /**
58
+ * Represents the outcome of extracting a DocID and title from an H1 heading string.
59
+ *
60
+ * When extraction succeeds, `valid` is `true` and both `docId` and `title` are
61
+ * present. When extraction fails, `valid` is `false`.
62
+ */
63
+ export type HeadingParseResult =
64
+ | { readonly valid: true; readonly docId: DocID; readonly title: string }
65
+ | { readonly valid: false };
66
+
67
+ /**
68
+ * Represents the outcome of validating a heading separator.
69
+ *
70
+ * `valid` is `true` when the heading carries a recognisable separator: a
71
+ * hyphen-minus, en dash or em dash after the identifier. When none is found,
72
+ * `valid` is `false` and `detectedSeparator` holds the canonical separator
73
+ * for use in the diagnostic message.
74
+ */
75
+ export type SeparatorValidationResult =
76
+ | { readonly valid: true }
77
+ | { readonly valid: false; readonly detectedSeparator: string };
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Internal constants
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Regular expression matching the DocID grammar: one or more numeric segments
85
+ * separated by dots. Each segment is one or more digits.
86
+ *
87
+ * Anchored to match the entire string.
88
+ */
89
+ const DOC_ID_PATTERN: RegExp = /^\d+(\.\d+)*$/;
90
+
91
+ /**
92
+ * Regular expression matching the SectionID grammar: a DocID, the
93
+ * separator `#`, and a section path of one or more dot-separated numeric
94
+ * segments.
95
+ *
96
+ * The separator is what makes an identifier unambiguous. Without it, `0.0.2.1`
97
+ * could denote section 1 of document `0.0.2` or section 2.1 of document `0.0`,
98
+ * and a corpus numbering its documents hierarchically will contain both.
99
+ *
100
+ * Group 1: the DocID. Group 2: the section path.
101
+ */
102
+ const SECTION_ID_PATTERN: RegExp = /^(\d+(?:\.\d+)*)#(\d+(?:\.\d+)*)$/;
103
+
104
+ /**
105
+ * Canonical separator, used when a separator must be rendered in a message.
106
+ */
107
+ const HYPHEN_MINUS_SEPARATOR: string = ' - ';
108
+
109
+ /**
110
+ * Regular expression matching a heading of the form `DocID <dash> Title`.
111
+ *
112
+ * The separator may be a hyphen-minus (U+002D), an en dash (U+2013) or an
113
+ * em dash (U+2014), with any amount of surrounding whitespace including none.
114
+ *
115
+ * Real-world corpora mix all three interchangeably, often within one document,
116
+ * and the separator carries no structural meaning: identity is carried entirely
117
+ * by the numeric identifier. Rejecting a dash variant would fail documents that
118
+ * are perfectly navigable, so all three are accepted.
119
+ *
120
+ * Group 1: the candidate DocID. Group 2: the title.
121
+ */
122
+ const HEADING_PATTERN: RegExp = /^(\d+(?:\.\d+)*)\s*[-–—]\s*(.+)$/;
123
+
124
+ /**
125
+ * Detects whether a heading carries any recognised separator, irrespective of
126
+ * which dash variant is used.
127
+ */
128
+ const ANY_SEPARATOR_PATTERN: RegExp = /\d\s*[-–—]\s*\S/;
129
+
130
+ /**
131
+ * Regular expression matching a sub-heading of the form `SectionID <dash> Title`.
132
+ *
133
+ * Group 1: the SectionID. Group 2: the title.
134
+ */
135
+ const SECTION_HEADING_PATTERN: RegExp =
136
+ /^(\d+(?:\.\d+)*#\d+(?:\.\d+)*)\s*[-\u2013\u2014]\s*(.+)$/;
137
+
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Identifier grammar parser
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * Provides parsing and validation for DocID and SectionID identifiers
145
+ * as defined in the ECR specification (1#9.2).
146
+ *
147
+ * Grammar:
148
+ * DocID ::= Digit+ ("." Digit+)*
149
+ * SectionPath ::= Digit+ ("." Digit+)*
150
+ * SectionID ::= DocID "#" SectionPath
151
+ *
152
+ * This class encapsulates all identifier-level grammar operations
153
+ * required by the ECR structural specification.
154
+ *
155
+ * @example
156
+ * ```ts
157
+ * const grammar = new IdentifierGrammar();
158
+ * const result = grammar.parseDocId('3.1');
159
+ * // { valid: true, docId: '3.1' }
160
+ * ```
161
+ */
162
+ export class IdentifierGrammar {
163
+ /**
164
+ * Parses a string and determines whether it conforms to the DocID grammar.
165
+ *
166
+ * A valid DocID consists of one or more numeric segments separated by dots,
167
+ * where each segment contains one or more digits.
168
+ *
169
+ * @param input - The string to parse
170
+ * @returns A parse result indicating validity and, on success, the parsed DocID
171
+ */
172
+ public parseDocId(input: string): DocIdParseResult {
173
+ const isValid: boolean = this.matchesDocIdGrammar(input);
174
+
175
+ if (isValid) {
176
+ return { valid: true, docId: input };
177
+ }
178
+
179
+ return { valid: false };
180
+ }
181
+
182
+ /**
183
+ * Parses a string and determines whether it conforms to the SectionID grammar.
184
+ *
185
+ * A SectionID is a DocID, the separator `#`, and a section path
186
+ * of one or more dot-separated numeric segments. The separator is mandatory:
187
+ * a dotted identifier with no `#` is a DocID, never a SectionID.
188
+ *
189
+ * @param input - The string to parse
190
+ * @returns A parse result indicating validity and, on success, the SectionID,
191
+ * the DocID it belongs to, and the section path within that document
192
+ */
193
+ public parseSectionId(input: string): SectionIdParseResult {
194
+ const match: RegExpExecArray | null = SECTION_ID_PATTERN.exec(input);
195
+
196
+ if (match === null) {
197
+ return { valid: false };
198
+ }
199
+
200
+ const docIdValue: string = match[1] ?? '';
201
+ const sectionPathValue: string = match[2] ?? '';
202
+
203
+ return {
204
+ valid: true,
205
+ sectionId: input,
206
+ docId: docIdValue,
207
+ sectionPath: sectionPathValue,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Determines whether a SectionID belongs to a given DocID.
213
+ *
214
+ * This is an exact comparison of the SectionID's document
215
+ * part against the supplied DocID. No prefix matching is involved, so a
216
+ * SectionID of document `8.1.3` does not belong to document `8.1`, and a
217
+ * SectionID of `8.10` does not belong to `8.1`.
218
+ *
219
+ * @param sectionId - The SectionID to check
220
+ * @param docId - The DocID the SectionID should belong to
221
+ * @returns `true` if the SectionID names a section of that document
222
+ */
223
+ public tellSectionIdExtendsDocId(sectionId: SectionID, docId: DocID): boolean {
224
+ const parseResult: SectionIdParseResult = this.parseSectionId(sectionId);
225
+
226
+ if (!parseResult.valid) {
227
+ return false;
228
+ }
229
+
230
+ return parseResult.docId === docId;
231
+ }
232
+
233
+ /**
234
+ * Counts the number of numeric segments in an identifier string.
235
+ *
236
+ * Segments are the numeric components separated by `"."`.
237
+ * For example, `"3.1.2"` has three segments.
238
+ *
239
+ * @param identifier - A DocID or SectionID string
240
+ * @returns The number of numeric segments
241
+ */
242
+ public showSegmentCount(identifier: string): number {
243
+ if (identifier === '') {
244
+ return 0;
245
+ }
246
+
247
+ const segments: readonly string[] = identifier.split('.');
248
+ return segments.length;
249
+ }
250
+
251
+ /**
252
+ * Counts the segments in a SectionID's section path.
253
+ *
254
+ * For `8.1#3.1` the section path is `3.1`, so the length is 2. The DocID's
255
+ * own depth does not contribute.
256
+ *
257
+ * @param sectionId - The SectionID to measure
258
+ * @returns The number of section-path segments, or 0 if the input is not a SectionID
259
+ */
260
+ public showSectionPathLength(sectionId: SectionID): number {
261
+ const parseResult: SectionIdParseResult = this.parseSectionId(sectionId);
262
+
263
+ if (!parseResult.valid) {
264
+ return 0;
265
+ }
266
+
267
+ return this.showSegmentCount(parseResult.sectionPath);
268
+ }
269
+
270
+ /**
271
+ * Computes the section-path length required of a heading at a given depth.
272
+ *
273
+ * A heading at Markdown depth *d* carries exactly *d - 1*
274
+ * section-path segments, independent of how deep its document's DocID is.
275
+ * An H2 carries one segment, an H3 two, and so on.
276
+ *
277
+ * @param headingDepth - The Markdown heading depth (2 for H2, 3 for H3, etc.)
278
+ * @returns The required number of section-path segments
279
+ */
280
+ public showExpectedSectionPathLength(headingDepth: number): number {
281
+ return headingDepth - 1;
282
+ }
283
+
284
+ /**
285
+ * Extracts the DocID and title from an H1 heading string.
286
+ *
287
+ * The heading must match the pattern: DocID <dash> Title, where the dash
288
+ * is a hyphen-minus, en dash or em dash with any surrounding whitespace.
289
+ * The title must be non-empty after trimming leading and trailing
290
+ * whitespace.
291
+ *
292
+ * @param headingText - The plain text content of the H1 heading node
293
+ * @returns A parse result indicating validity and, on success, the extracted DocID and title
294
+ */
295
+ public parseHeading(headingText: string): HeadingParseResult {
296
+ const match: RegExpExecArray | null = HEADING_PATTERN.exec(headingText.trim());
297
+
298
+ if (match === null) {
299
+ return { valid: false };
300
+ }
301
+
302
+ const candidateDocId: string = match[1] ?? '';
303
+ const title: string = match[2] ?? '';
304
+
305
+ const docIdResult: DocIdParseResult = this.parseDocId(candidateDocId);
306
+
307
+ if (!docIdResult.valid) {
308
+ return { valid: false };
309
+ }
310
+
311
+ const trimmedTitle: string = title.trim();
312
+
313
+ if (trimmedTitle.length === 0) {
314
+ return { valid: false };
315
+ }
316
+
317
+ return { valid: true, docId: docIdResult.docId, title };
318
+ }
319
+
320
+ /**
321
+ * Extracts the SectionID and title from a sub-heading string.
322
+ *
323
+ * A sub-heading carries a SectionID, so it must contain the `#` separator.
324
+ * An H1 carries a DocID and is parsed by {@link IdentifierGrammar.parseHeading}
325
+ * instead.
326
+ *
327
+ * @param headingText - The plain text content of a heading node at depth \>= 2
328
+ * @returns A parse result indicating validity and, on success, the SectionID and title
329
+ */
330
+ public parseSectionHeading(headingText: string): SectionHeadingParseResult {
331
+ const match: RegExpExecArray | null = SECTION_HEADING_PATTERN.exec(headingText.trim());
332
+
333
+ if (match === null) {
334
+ return { valid: false };
335
+ }
336
+
337
+ const sectionId: string = match[1] ?? '';
338
+ const title: string = match[2] ?? '';
339
+
340
+ if (title.trim().length === 0) {
341
+ return { valid: false };
342
+ }
343
+
344
+ return { valid: true, sectionId, title };
345
+ }
346
+
347
+ /**
348
+ * Validates that a heading string carries a separator between its
349
+ * identifier and its title.
350
+ *
351
+ * Per the ECR specification (1#9.3, 1#9.4), the separator may be a
352
+ * hyphen-minus (U+002D), an en dash (U+2013) or an em dash (U+2014), with
353
+ * any surrounding whitespace. Only a heading with no separator at all is
354
+ * invalid.
355
+ *
356
+ * @param headingText - The plain text content of a heading node
357
+ * @returns A validation result indicating whether the separator is valid
358
+ */
359
+ public validateSeparator(headingText: string): SeparatorValidationResult {
360
+ if (ANY_SEPARATOR_PATTERN.test(headingText)) {
361
+ return { valid: true };
362
+ }
363
+
364
+ return { valid: false, detectedSeparator: HYPHEN_MINUS_SEPARATOR };
365
+ }
366
+
367
+ // -------------------------------------------------------------------------
368
+ // Private helpers
369
+ // -------------------------------------------------------------------------
370
+
371
+ /**
372
+ * Tests whether a string matches the DocID grammar: `Digit+ ("." Digit+)*`.
373
+ *
374
+ * @param input - The string to test
375
+ * @returns `true` if the input matches the DocID grammar, `false` otherwise
376
+ */
377
+ private matchesDocIdGrammar(input: string): boolean {
378
+ return DOC_ID_PATTERN.test(input);
379
+ }
380
+ }
package/src/index.ts ADDED
@@ -0,0 +1,78 @@
1
+ export type {
2
+ DocID,
3
+ SectionID,
4
+ DiagnosticSeverity,
5
+ Position,
6
+ PositionRange,
7
+ Diagnostic,
8
+ ReferenceDirection,
9
+ InlineReferenceKind,
10
+ SectionNode,
11
+ ReferenceEdge,
12
+ InlineReferenceEdge,
13
+ ExtractedDocument,
14
+ LintInput,
15
+ LintResult,
16
+ CorpusDocumentEntry,
17
+ CorpusIndex,
18
+ CorpusResult,
19
+ } from "./types.js";
20
+
21
+ export { IdentifierGrammar } from "./identifier-grammar.js";
22
+ export type {
23
+ DocIdParseResult,
24
+ SectionIdParseResult,
25
+ HeadingParseResult,
26
+ SectionHeadingParseResult,
27
+ SeparatorValidationResult,
28
+ } from "./identifier-grammar.js";
29
+
30
+ export { DocumentIdentityRule, DOCUMENT_IDENTITY_RULE_ID } from "./document-identity-rule.js";
31
+ export type {
32
+ HeadingNodeData,
33
+ DocumentIdentity,
34
+ DocumentIdentityRuleResult,
35
+ DocumentIdentityRuleOptions,
36
+ } from "./document-identity-rule.js";
37
+
38
+ export { SectionHierarchyRule, SECTION_HIERARCHY_RULE_ID } from "./section-hierarchy-rule.js";
39
+ export type {
40
+ SectionHierarchyRuleOptions,
41
+ SectionHierarchyRuleResult,
42
+ } from "./section-hierarchy-rule.js";
43
+
44
+ export { ReferencesSectionRule, REFERENCES_SECTION_RULE_ID } from "./references-section-rule.js";
45
+ export type {
46
+ ListItemNodeData,
47
+ ListItemSegment,
48
+ ParsedReferenceEntry,
49
+ ReferencesSectionRuleResult,
50
+ ReferencesSectionRuleOptions,
51
+ } from "./references-section-rule.js";
52
+
53
+ export { InlineReferenceRule, INLINE_REFERENCE_RULE_ID } from "./inline-reference-rule.js";
54
+ export type {
55
+ TextNodeData,
56
+ InlineSegment,
57
+ InlineSegmentKind,
58
+ DetectedInlineReference,
59
+ InlineReferenceRuleResult,
60
+ InlineReferenceRuleOptions,
61
+ } from "./inline-reference-rule.js";
62
+
63
+ export {
64
+ PerDocumentVisitor,
65
+ UNPARSABLE_DOCUMENT_RULE_ID,
66
+ UNPARSABLE_DOCUMENT_CAUSE,
67
+ } from "./per-document-visitor.js";
68
+ export type {
69
+ PerDocumentVisitorOptions,
70
+ } from "./per-document-visitor.js";
71
+
72
+ export { CorpusValidator } from "./corpus-validator.js";
73
+
74
+ export { Ecr } from "./ecr.js";
75
+ export type { CorpusDocumentInput } from "./ecr.js";
76
+
77
+ export { MetaDocumentFilter, DEFAULT_META_DOCUMENT_NAMES } from "./meta-documents.js";
78
+ export { ECR_SPEC_VERSION } from "./spec-version.js";