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