@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,1528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-Document Visitor
|
|
3
|
+
*
|
|
4
|
+
* Orchestrates all four ECR rules (ECR101--ECR104) over a single Markdown
|
|
5
|
+
* document, producing a composite {@link LintResult}.
|
|
6
|
+
*
|
|
7
|
+
* This is the integration layer that:
|
|
8
|
+
* 1. Parses Markdown text into an AST using `unified` + `remark-parse`
|
|
9
|
+
* 2. Walks the AST and dispatches nodes to the appropriate rules
|
|
10
|
+
* 3. Manages the rule initialisation dependency chain:
|
|
11
|
+
* - ECR101 must complete before ECR102/ECR103 can start
|
|
12
|
+
* - ECR103 must complete before ECR104 can start (to provide declared DocIDs)
|
|
13
|
+
* - If ECR101 fails, downstream rules are skipped entirely
|
|
14
|
+
* 4. Filters text nodes: excludes those inside `code`, `inlineCode`, `html`,
|
|
15
|
+
* or link URL contexts before feeding to ECR104
|
|
16
|
+
* 5. Tracks the current section context (heading identifier) for ECR104
|
|
17
|
+
* 6. Feeds list items within the References section to ECR103
|
|
18
|
+
* 7. Finalises all rules and assembles the composite {@link LintResult}
|
|
19
|
+
*
|
|
20
|
+
* Spec references:
|
|
21
|
+
* - 1#8 -- Visitor and State Model
|
|
22
|
+
* - 1#11.1 -- Pass 1: Per-Document Parse, Validate, Extract
|
|
23
|
+
* - 1#9.3 -- Document Identity Rule [ECR101]
|
|
24
|
+
* - 1#9.4 -- Section Structure Rules [ECR102]
|
|
25
|
+
* - 1#9.5 -- Inline Reference Rules [ECR104]
|
|
26
|
+
* - 1#9.6 -- References Section Rules [ECR103]
|
|
27
|
+
* - 1#10.2 -- LintResult
|
|
28
|
+
* - 1#10.4 -- ExtractedDocument
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Runtime imports (value imports for classes and functions)
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
import { unified } from 'unified';
|
|
36
|
+
import remarkParse from 'remark-parse';
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Type-only imports (verbatimModuleSyntax requires `import type`)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
import type {
|
|
43
|
+
DocID,
|
|
44
|
+
SectionID,
|
|
45
|
+
Diagnostic,
|
|
46
|
+
PositionRange,
|
|
47
|
+
LintInput,
|
|
48
|
+
LintResult,
|
|
49
|
+
ExtractedDocument,
|
|
50
|
+
SectionNode,
|
|
51
|
+
ReferenceEdge,
|
|
52
|
+
InlineReferenceEdge,
|
|
53
|
+
} from './types.js';
|
|
54
|
+
|
|
55
|
+
import type {
|
|
56
|
+
DocumentIdentityRuleResult,
|
|
57
|
+
DocumentIdentity,
|
|
58
|
+
HeadingNodeData,
|
|
59
|
+
} from './document-identity-rule.js';
|
|
60
|
+
|
|
61
|
+
import type {
|
|
62
|
+
SectionHierarchyRuleResult,
|
|
63
|
+
} from './section-hierarchy-rule.js';
|
|
64
|
+
|
|
65
|
+
import type {
|
|
66
|
+
ReferencesSectionRuleResult,
|
|
67
|
+
ListItemNodeData,
|
|
68
|
+
ListItemSegment,
|
|
69
|
+
} from './references-section-rule.js';
|
|
70
|
+
|
|
71
|
+
import type {
|
|
72
|
+
InlineReferenceRuleResult,
|
|
73
|
+
InlineSegment,
|
|
74
|
+
TextNodeData,
|
|
75
|
+
} from './inline-reference-rule.js';
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Value imports for rule classes and grammar
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
import { DocumentIdentityRule } from './document-identity-rule.js';
|
|
82
|
+
import { SectionHierarchyRule } from './section-hierarchy-rule.js';
|
|
83
|
+
import { ReferencesSectionRule } from './references-section-rule.js';
|
|
84
|
+
import { InlineReferenceRule } from './inline-reference-rule.js';
|
|
85
|
+
import { IdentifierGrammar } from './identifier-grammar.js';
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Local MDAST node type definitions
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generic MDAST node shape.
|
|
93
|
+
*
|
|
94
|
+
* Locally defined to avoid importing from `mdast` or `unist`, which are
|
|
95
|
+
* not directly resolvable as module specifiers in this project. The shapes
|
|
96
|
+
* match the runtime objects produced by `remark-parse`.
|
|
97
|
+
*/
|
|
98
|
+
interface MdastNode {
|
|
99
|
+
/** The node type string (e.g., `'heading'`, `'text'`, `'root'`). */
|
|
100
|
+
readonly type: string;
|
|
101
|
+
/** Child nodes, present on parent node types. */
|
|
102
|
+
readonly children?: readonly MdastNode[];
|
|
103
|
+
/** Heading depth (1--6), present only on heading nodes. */
|
|
104
|
+
readonly depth?: number;
|
|
105
|
+
/** Text value, present only on literal nodes (e.g., `text`, `code`). */
|
|
106
|
+
readonly value?: string;
|
|
107
|
+
/** Alternative text, present on image nodes. */
|
|
108
|
+
readonly alt?: string | null;
|
|
109
|
+
/** Positional metadata from the source document. */
|
|
110
|
+
readonly position?: MdastPosition;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* MDAST Root node shape.
|
|
115
|
+
*
|
|
116
|
+
* The root node is the top-level container returned by `remark-parse`.
|
|
117
|
+
* It always has `type: 'root'` and a `children` array.
|
|
118
|
+
*/
|
|
119
|
+
interface MdastRoot {
|
|
120
|
+
/** Discriminant: always `'root'`. */
|
|
121
|
+
readonly type: 'root';
|
|
122
|
+
/** The root's child nodes in document order. */
|
|
123
|
+
readonly children: readonly MdastNode[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Constructor options
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Configuration options for constructing a {@link PerDocumentVisitor} instance.
|
|
132
|
+
*/
|
|
133
|
+
export interface PerDocumentVisitorOptions {
|
|
134
|
+
/**
|
|
135
|
+
* The opaque, host-provided URI identifying the document being validated.
|
|
136
|
+
*
|
|
137
|
+
* Passed through to all rules and echoed in the {@link LintResult.input} field.
|
|
138
|
+
*/
|
|
139
|
+
readonly uri: string;
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Optional version tag for the document instance.
|
|
143
|
+
*
|
|
144
|
+
* Passed through to the {@link LintResult.input} field. When omitted, the
|
|
145
|
+
* resulting {@link LintInput.version} will be `undefined`.
|
|
146
|
+
*/
|
|
147
|
+
readonly version?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Internal result types for multi-pass orchestration
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Intermediate result from the headings pass (ECR101 + ECR102 + ECR103 heading detection).
|
|
156
|
+
*
|
|
157
|
+
* Produced by the first traversal of the AST, which feeds heading nodes
|
|
158
|
+
* to ECR101, ECR102, and ECR103, and list items within the References
|
|
159
|
+
* section to ECR103.
|
|
160
|
+
*/
|
|
161
|
+
interface HeadingsAndReferencesPassResult {
|
|
162
|
+
/** The identity result from ECR101. */
|
|
163
|
+
readonly identityResult: DocumentIdentityRuleResult;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The section hierarchy result from ECR102.
|
|
167
|
+
* Present only when ECR101 produced a valid DocID.
|
|
168
|
+
*/
|
|
169
|
+
readonly sectionsResult?: SectionHierarchyRuleResult;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The references section result from ECR103.
|
|
173
|
+
* Present only when ECR101 produced a valid DocID.
|
|
174
|
+
*/
|
|
175
|
+
readonly referencesResult?: ReferencesSectionRuleResult;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Intermediate result from the inline references pass (ECR104).
|
|
180
|
+
*
|
|
181
|
+
* Produced by the second traversal of the AST, which feeds filtered
|
|
182
|
+
* text nodes to ECR104 with section context tracking.
|
|
183
|
+
*/
|
|
184
|
+
interface InlineReferencesPassResult {
|
|
185
|
+
/** The inline reference result from ECR104. */
|
|
186
|
+
readonly inlineResult: InlineReferenceRuleResult;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// A document the parser could not read
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
/** Rule identifier reported when a document cannot be parsed at all. */
|
|
194
|
+
export const UNPARSABLE_DOCUMENT_RULE_ID: string = 'document/unparsable';
|
|
195
|
+
|
|
196
|
+
/** The `data.cause` of that diagnostic. */
|
|
197
|
+
export const UNPARSABLE_DOCUMENT_CAUSE: string = 'unparsable-document';
|
|
198
|
+
|
|
199
|
+
/** How much of the parser's own account of the failure to repeat. */
|
|
200
|
+
const UNPARSABLE_REASON_LIMIT: number = 200;
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Excluded ancestor node types for ECR104 filtering
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* MDAST node types whose descendant text nodes must be excluded from
|
|
208
|
+
* ECR104 inline reference detection.
|
|
209
|
+
*
|
|
210
|
+
* Per 1#9.5, text nodes inside code blocks, inline code, and HTML
|
|
211
|
+
* elements are not valid candidates for inline reference detection.
|
|
212
|
+
*/
|
|
213
|
+
export const EXCLUDED_ANCESTOR_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
214
|
+
'code',
|
|
215
|
+
'inlineCode',
|
|
216
|
+
'html',
|
|
217
|
+
]);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* MDAST node types whose children form one inline run: the text a reader
|
|
221
|
+
* sees, recognised as a whole (1#9.5 rule 1). Headings are excluded, since
|
|
222
|
+
* heading text is not subject to inline reference detection.
|
|
223
|
+
*/
|
|
224
|
+
const INLINE_RUN_NODE_TYPES: ReadonlySet<string> = new Set([
|
|
225
|
+
'paragraph',
|
|
226
|
+
'tableCell',
|
|
227
|
+
]);
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Inline HTML a reader sees as a line break: an opening `br` tag in any case,
|
|
231
|
+
* whatever follows its name -- `<br>`, `<BR/>`, `<br class="x">`. It separates
|
|
232
|
+
* the words either side of it. Other inline HTML does not, so it is
|
|
233
|
+
* transparent to recognition (1#9.5 rule 1).
|
|
234
|
+
*
|
|
235
|
+
* Only the tag's name is read. The parser has already delimited the whole
|
|
236
|
+
* tag as one node, so its attributes need no second parse here; matching
|
|
237
|
+
* them again assumed every `>` closed the tag, and `<br title="x > y">` was
|
|
238
|
+
* taken for transparent HTML.
|
|
239
|
+
*/
|
|
240
|
+
const LINE_BREAK_HTML: RegExp = /^<br(?=[\s/>])/i;
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// MDAST position type
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Shape of an MDAST position object with 1-based line and column numbers.
|
|
248
|
+
*/
|
|
249
|
+
interface MdastPosition {
|
|
250
|
+
/** Start position with 1-based line and column. */
|
|
251
|
+
readonly start: { readonly line: number; readonly column: number };
|
|
252
|
+
/** End position with 1-based line and column. */
|
|
253
|
+
readonly end: { readonly line: number; readonly column: number };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Where the `## References` heading was found: at the root, or inside a
|
|
258
|
+
* container, which is a placement violation (1#9.11 rule 3).
|
|
259
|
+
*/
|
|
260
|
+
interface ReferencesSectionLocation {
|
|
261
|
+
/** The `## References` heading node. */
|
|
262
|
+
readonly heading: MdastNode;
|
|
263
|
+
/** The children of its parent, in order: the root's, or a container's. */
|
|
264
|
+
readonly siblings: readonly MdastNode[];
|
|
265
|
+
/** The heading's index among them. */
|
|
266
|
+
readonly index: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Mutable wrapper that tracks the current section context identifier
|
|
271
|
+
* during the ECR104 inline references pass.
|
|
272
|
+
*
|
|
273
|
+
* The `current` field holds a {@link DocID} before any H2 heading is
|
|
274
|
+
* encountered, and a {@link SectionID} after a numbered heading is
|
|
275
|
+
* processed.
|
|
276
|
+
*/
|
|
277
|
+
interface SectionContextTracker {
|
|
278
|
+
/** The currently active section identifier (DocID or SectionID). */
|
|
279
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be DocID or SectionID
|
|
280
|
+
current: DocID | SectionID;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A list of siblings being walked, and how far through it the walk is. */
|
|
284
|
+
interface SiblingsFrame {
|
|
285
|
+
/** The siblings, in document order. */
|
|
286
|
+
readonly children: readonly MdastNode[];
|
|
287
|
+
/** Index of the next sibling to visit. */
|
|
288
|
+
readonly index: number;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A node waiting to be visited by the ECR104 inline references walk. */
|
|
292
|
+
interface InlineWalkEntry {
|
|
293
|
+
/** The node to visit. */
|
|
294
|
+
readonly node: MdastNode;
|
|
295
|
+
/**
|
|
296
|
+
* Whether an ancestor's type is one of {@link EXCLUDED_ANCESTOR_NODE_TYPES},
|
|
297
|
+
* which puts the node's text outside inline reference detection.
|
|
298
|
+
*/
|
|
299
|
+
readonly excluded: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** A node waiting to be visited by an inline segment walk, with its wrappers. */
|
|
303
|
+
interface SegmentWalkEntry {
|
|
304
|
+
/** The node to visit. */
|
|
305
|
+
readonly node: MdastNode;
|
|
306
|
+
/** Types of the formatting spans enclosing it, outermost first. */
|
|
307
|
+
readonly wrappers: readonly string[];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Per-Document Visitor class
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Orchestrates all four ECR rules (ECR101--ECR104) over a single Markdown
|
|
316
|
+
* document and produces a composite {@link LintResult}.
|
|
317
|
+
*
|
|
318
|
+
* The visitor is stateless between calls to {@link lint}. Each invocation
|
|
319
|
+
* produces a fresh result with no side effects on the visitor instance.
|
|
320
|
+
*
|
|
321
|
+
* The visitor creates its own {@link IdentifierGrammar} instance internally
|
|
322
|
+
* and manages the rule initialisation dependency chain:
|
|
323
|
+
* - ECR101 (Document Identity) runs first on all headings
|
|
324
|
+
* - If ECR101 produces a valid DocID, ECR102 (Section Hierarchy) and
|
|
325
|
+
* ECR103 (References Section) are initialised and fed their respective nodes
|
|
326
|
+
* - After ECR103 finalises, the set of declared DocIDs is extracted and
|
|
327
|
+
* used to initialise ECR104 (Inline Reference)
|
|
328
|
+
* - If ECR101 fails (no valid DocID), the result is returned early with
|
|
329
|
+
* `ok=false` and no `extracted` field
|
|
330
|
+
*
|
|
331
|
+
* Usage:
|
|
332
|
+
* ```ts
|
|
333
|
+
* const visitor = new PerDocumentVisitor({
|
|
334
|
+
* uri: 'file:///docs/3.1.md',
|
|
335
|
+
* version: 1,
|
|
336
|
+
* });
|
|
337
|
+
*
|
|
338
|
+
* const result: LintResult = visitor.lint('# 3.1 - My Document\n\n## References\n- 8.1 - ...');
|
|
339
|
+
* ```
|
|
340
|
+
*
|
|
341
|
+
* @example
|
|
342
|
+
* ```ts
|
|
343
|
+
* const visitor = new PerDocumentVisitor({ uri: 'file:///docs/5.1.md' });
|
|
344
|
+
* const result: LintResult = visitor.lint(markdownText);
|
|
345
|
+
*
|
|
346
|
+
* if (result.ok) {
|
|
347
|
+
* console.log('Document is valid:', result.extracted?.docId);
|
|
348
|
+
* } else {
|
|
349
|
+
* console.log('Diagnostics:', result.diagnostics);
|
|
350
|
+
* }
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
export class PerDocumentVisitor {
|
|
354
|
+
/**
|
|
355
|
+
* The opaque, host-provided URI identifying the document being validated.
|
|
356
|
+
*/
|
|
357
|
+
private readonly uri: string;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Optional version tag for the document instance.
|
|
361
|
+
*/
|
|
362
|
+
private readonly version: number | undefined;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The {@link IdentifierGrammar} instance shared across all rules
|
|
366
|
+
* within a single visitor. Created once during construction.
|
|
367
|
+
*/
|
|
368
|
+
private readonly grammar: IdentifierGrammar;
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Constructs a new Per-Document Visitor.
|
|
372
|
+
*
|
|
373
|
+
* Creates an internal {@link IdentifierGrammar} instance for use
|
|
374
|
+
* across all rule evaluations performed by this visitor.
|
|
375
|
+
*
|
|
376
|
+
* @param options - Configuration including the document URI and optional version
|
|
377
|
+
*/
|
|
378
|
+
public constructor(options: PerDocumentVisitorOptions) {
|
|
379
|
+
this.uri = options.uri;
|
|
380
|
+
this.version = options.version;
|
|
381
|
+
this.grammar = new IdentifierGrammar();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Lints a single Markdown document and produces a composite {@link LintResult}.
|
|
386
|
+
*
|
|
387
|
+
* Each call is stateless: the visitor creates fresh rule instances,
|
|
388
|
+
* parses the Markdown text into an AST, walks the AST to feed nodes
|
|
389
|
+
* to the appropriate rules, finalises all rules, and assembles the result.
|
|
390
|
+
*
|
|
391
|
+
* The orchestration proceeds in phases:
|
|
392
|
+
* 1. Parse the Markdown text into an MDAST {@link MdastRoot} node
|
|
393
|
+
* 2. First pass: walk headings and list items for ECR101, ECR102, ECR103
|
|
394
|
+
* 3. If ECR101 fails (no valid DocID), return early with `ok=false`
|
|
395
|
+
* 4. Second pass: walk text nodes for ECR104 (with ancestor filtering
|
|
396
|
+
* and section context tracking)
|
|
397
|
+
* 5. Assemble the composite {@link LintResult} from all rule results
|
|
398
|
+
*
|
|
399
|
+
* @param markdownText - The raw Markdown text of the document to lint
|
|
400
|
+
* @returns The composite lint result including diagnostics and, when a valid
|
|
401
|
+
* DocID is recovered, extracted structural artefacts
|
|
402
|
+
*/
|
|
403
|
+
public lint(markdownText: string): LintResult {
|
|
404
|
+
let root: MdastRoot;
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
root = this.parseMarkdown(markdownText);
|
|
408
|
+
} catch (error: unknown) {
|
|
409
|
+
return this.reportUnparsable(error);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const passOneResult: HeadingsAndReferencesPassResult =
|
|
413
|
+
this.executeHeadingsAndReferencesPass(root, markdownText);
|
|
414
|
+
|
|
415
|
+
// If ECR101 failed (no valid identity), return early
|
|
416
|
+
if (passOneResult.identityResult.identity === undefined) {
|
|
417
|
+
return this.assembleLintResult(
|
|
418
|
+
passOneResult.identityResult,
|
|
419
|
+
undefined,
|
|
420
|
+
undefined,
|
|
421
|
+
undefined,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ECR101 succeeded -- extract declaredDocIds from ECR103
|
|
426
|
+
const referencesResult: ReferencesSectionRuleResult | undefined =
|
|
427
|
+
passOneResult.referencesResult;
|
|
428
|
+
|
|
429
|
+
const referencesForExtraction: readonly ReferenceEdge[] =
|
|
430
|
+
referencesResult !== undefined ? referencesResult.references : [];
|
|
431
|
+
|
|
432
|
+
const declaredDocIds: ReadonlySet<DocID> =
|
|
433
|
+
this.extractDeclaredDocIds(referencesForExtraction);
|
|
434
|
+
|
|
435
|
+
// Run ECR104
|
|
436
|
+
const inlineResult: InlineReferencesPassResult =
|
|
437
|
+
this.executeInlineReferencesPass(
|
|
438
|
+
root,
|
|
439
|
+
passOneResult.identityResult.identity.docId,
|
|
440
|
+
declaredDocIds,
|
|
441
|
+
markdownText,
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
return this.assembleLintResult(
|
|
445
|
+
passOneResult.identityResult,
|
|
446
|
+
passOneResult.sectionsResult,
|
|
447
|
+
passOneResult.referencesResult,
|
|
448
|
+
inlineResult.inlineResult,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -------------------------------------------------------------------------
|
|
453
|
+
// Private: Markdown parsing
|
|
454
|
+
// -------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Parses raw Markdown text into an MDAST {@link MdastRoot} node.
|
|
458
|
+
*
|
|
459
|
+
* Uses `unified` with `remark-parse` to produce a standards-compliant
|
|
460
|
+
* MDAST tree with positional metadata attached to all nodes.
|
|
461
|
+
*
|
|
462
|
+
* The `unified().use(remarkParse).parse()` call returns a type that
|
|
463
|
+
* cannot be directly assigned to our local {@link MdastRoot} interface
|
|
464
|
+
* because `mdast` types are not importable as a module specifier in this
|
|
465
|
+
* project. The cast via `unknown` is safe because `remark-parse` always
|
|
466
|
+
* produces an MDAST Root node at runtime.
|
|
467
|
+
*
|
|
468
|
+
* @param markdownText - The raw Markdown text to parse
|
|
469
|
+
* @returns The parsed MDAST root node
|
|
470
|
+
*/
|
|
471
|
+
private parseMarkdown(markdownText: string): MdastRoot {
|
|
472
|
+
const root: MdastRoot = unified()
|
|
473
|
+
.use(remarkParse)
|
|
474
|
+
.parse(markdownText) as unknown as MdastRoot;
|
|
475
|
+
return root;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Reports a document the parser could not read.
|
|
480
|
+
*
|
|
481
|
+
* The parser walks a document's structure by recursion, inside a package
|
|
482
|
+
* this project does not control, so a deeply nested link label or image
|
|
483
|
+
* description can end the parse with a stack overflow. That must be one
|
|
484
|
+
* document's error, reported like any other, and never a crash that stops
|
|
485
|
+
* a corpus part way and leaves the rest unvalidated.
|
|
486
|
+
*
|
|
487
|
+
* @param error - What the parser threw
|
|
488
|
+
* @returns A failing result carrying one error diagnostic
|
|
489
|
+
*/
|
|
490
|
+
private reportUnparsable(error: unknown): LintResult {
|
|
491
|
+
const reason: string =
|
|
492
|
+
error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
493
|
+
|
|
494
|
+
return {
|
|
495
|
+
input: this.buildLintInput(),
|
|
496
|
+
ok: false,
|
|
497
|
+
diagnostics: [
|
|
498
|
+
{
|
|
499
|
+
severity: 'error',
|
|
500
|
+
ruleId: UNPARSABLE_DOCUMENT_RULE_ID,
|
|
501
|
+
message:
|
|
502
|
+
`Document could not be parsed, so none of it was validated ` +
|
|
503
|
+
`(${reason.slice(0, UNPARSABLE_REASON_LIMIT)}). Deeply nested Markdown is the ` +
|
|
504
|
+
`usual cause, because parsing it recurses.`,
|
|
505
|
+
uri: this.uri,
|
|
506
|
+
data: { cause: UNPARSABLE_DOCUMENT_CAUSE },
|
|
507
|
+
},
|
|
508
|
+
],
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// -------------------------------------------------------------------------
|
|
513
|
+
// Private: First pass -- headings, sections, and references
|
|
514
|
+
// -------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Executes the first AST traversal pass: headings, sections, and references.
|
|
518
|
+
*
|
|
519
|
+
* This pass walks the AST in document order and:
|
|
520
|
+
* - Feeds every heading node to ECR101 (`DocumentIdentityRule.evaluateHeading`)
|
|
521
|
+
* - Finalises ECR101 to determine whether a valid DocID was recovered
|
|
522
|
+
* - If a valid DocID is recovered:
|
|
523
|
+
* - Registers the root H1 heading with ECR102
|
|
524
|
+
* (`SectionHierarchyRule.registerRootHeading`)
|
|
525
|
+
* - Feeds headings with depth \>= 2 to ECR102
|
|
526
|
+
* (`SectionHierarchyRule.evaluateHeading`)
|
|
527
|
+
* - Feeds all headings to ECR103
|
|
528
|
+
* (`ReferencesSectionRule.evaluateHeading`)
|
|
529
|
+
* - Feeds list items within the References section to ECR103
|
|
530
|
+
* (`ReferencesSectionRule.evaluateListItem`)
|
|
531
|
+
* - Finalises ECR102 and ECR103
|
|
532
|
+
* - If no valid DocID is recovered, ECR102 and ECR103 are skipped
|
|
533
|
+
*
|
|
534
|
+
* The pass is structured so that all heading nodes are collected first
|
|
535
|
+
* for ECR101, then the collected headings are replayed for ECR102 and ECR103.
|
|
536
|
+
*
|
|
537
|
+
* @param root - The parsed MDAST root node
|
|
538
|
+
* @param sourceText - The document's raw Markdown, for the heading source-form check of 1#9.11
|
|
539
|
+
* @returns The intermediate result from the headings and references pass
|
|
540
|
+
*/
|
|
541
|
+
private executeHeadingsAndReferencesPass(
|
|
542
|
+
root: MdastRoot,
|
|
543
|
+
sourceText: string,
|
|
544
|
+
): HeadingsAndReferencesPassResult {
|
|
545
|
+
// Phase 1: Collect all headings and feed to ECR101
|
|
546
|
+
const collectedHeadings: HeadingNodeData[] = [];
|
|
547
|
+
|
|
548
|
+
this.collectHeadings(root, collectedHeadings);
|
|
549
|
+
|
|
550
|
+
const identityRule: DocumentIdentityRule = new DocumentIdentityRule({
|
|
551
|
+
uri: this.uri,
|
|
552
|
+
grammar: this.grammar,
|
|
553
|
+
sourceText,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
for (const headingData of collectedHeadings) {
|
|
557
|
+
identityRule.evaluateHeading(headingData);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const identityResult: DocumentIdentityRuleResult = identityRule.finalise();
|
|
561
|
+
|
|
562
|
+
// If ECR101 failed, return early -- no ECR102 or ECR103
|
|
563
|
+
if (identityResult.identity === undefined) {
|
|
564
|
+
return { identityResult };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Phase 2: ECR101 succeeded -- initialise ECR102 and ECR103
|
|
568
|
+
const docId: DocID = identityResult.identity.docId;
|
|
569
|
+
|
|
570
|
+
const sectionHierarchyRule: SectionHierarchyRule = new SectionHierarchyRule({
|
|
571
|
+
uri: this.uri,
|
|
572
|
+
docId,
|
|
573
|
+
grammar: this.grammar,
|
|
574
|
+
sourceText,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
const referencesSectionRule: ReferencesSectionRule = new ReferencesSectionRule({
|
|
578
|
+
uri: this.uri,
|
|
579
|
+
docId,
|
|
580
|
+
grammar: this.grammar,
|
|
581
|
+
sourceText,
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
// Feed collected headings to ECR102 and ECR103
|
|
585
|
+
for (const headingData of collectedHeadings) {
|
|
586
|
+
// ECR102: register root H1 or evaluate sub-headings.
|
|
587
|
+
// The `## References` heading is a structural heading owned by ECR103,
|
|
588
|
+
// not a numbered section heading — it must be excluded from ECR102
|
|
589
|
+
// to avoid a spurious "not a valid SectionID" diagnostic.
|
|
590
|
+
if (headingData.depth === 1) {
|
|
591
|
+
sectionHierarchyRule.registerRootHeading(headingData);
|
|
592
|
+
} else if (!this.tellReferencesHeading(headingData)) {
|
|
593
|
+
sectionHierarchyRule.evaluateHeading(headingData);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// ECR103: evaluate all headings for References heading detection
|
|
597
|
+
referencesSectionRule.evaluateHeading(headingData);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Phase 3: Walk the AST again to feed list items to ECR103
|
|
601
|
+
// after the References heading has been detected
|
|
602
|
+
this.feedListItemsToReferencesRule(root, referencesSectionRule);
|
|
603
|
+
|
|
604
|
+
// Finalise ECR102 and ECR103
|
|
605
|
+
const sectionsResult: SectionHierarchyRuleResult =
|
|
606
|
+
sectionHierarchyRule.finalise();
|
|
607
|
+
const referencesResult: ReferencesSectionRuleResult =
|
|
608
|
+
referencesSectionRule.finalise();
|
|
609
|
+
|
|
610
|
+
return {
|
|
611
|
+
identityResult,
|
|
612
|
+
sectionsResult,
|
|
613
|
+
referencesResult,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Collects all heading nodes from the AST into the provided array.
|
|
619
|
+
*
|
|
620
|
+
* Walks the AST depth-first in document order, extracting
|
|
621
|
+
* {@link HeadingNodeData} from every node with `type === 'heading'`.
|
|
622
|
+
*
|
|
623
|
+
* @param node - The root of the walk
|
|
624
|
+
* @param headings - The accumulator array for collected heading data
|
|
625
|
+
*/
|
|
626
|
+
private collectHeadings(
|
|
627
|
+
node: MdastNode,
|
|
628
|
+
headings: HeadingNodeData[],
|
|
629
|
+
): void {
|
|
630
|
+
const pending: MdastNode[] = [node];
|
|
631
|
+
|
|
632
|
+
while (pending.length > 0) {
|
|
633
|
+
const current: MdastNode | undefined = pending.pop();
|
|
634
|
+
|
|
635
|
+
if (current === undefined) {
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (current.type === 'heading' && current.depth !== undefined) {
|
|
640
|
+
headings.push(this.extractHeadingNodeData(current));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
PerDocumentVisitor.pushChildren(current, pending);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Reads a node's plain text: what a reader sees, with the formatting gone.
|
|
649
|
+
*
|
|
650
|
+
* @param node - The node to read
|
|
651
|
+
* @returns Its text, and its descendants' text, in order
|
|
652
|
+
*/
|
|
653
|
+
private static showNodeText(node: MdastNode): string {
|
|
654
|
+
// What `mdast-util-to-string` returns, gathered over a stack instead of
|
|
655
|
+
// by recursion: a node's own value, else an image's alternative text,
|
|
656
|
+
// else its children's text in order. That package recurses, so a heading
|
|
657
|
+
// of a few thousand nested emphasis spans -- which costs an author two
|
|
658
|
+
// characters a level -- ended the process inside it.
|
|
659
|
+
const parts: string[] = [];
|
|
660
|
+
const pending: MdastNode[] = [node];
|
|
661
|
+
|
|
662
|
+
while (pending.length > 0) {
|
|
663
|
+
const current: MdastNode | undefined = pending.pop();
|
|
664
|
+
|
|
665
|
+
if (current === undefined) {
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (current.value !== undefined) {
|
|
670
|
+
parts.push(current.value);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (typeof current.alt === 'string' && current.alt.length > 0) {
|
|
675
|
+
parts.push(current.alt);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
PerDocumentVisitor.pushChildren(current, pending);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
return parts.join('');
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Puts a node's children on a walk's stack so that they come off it in
|
|
687
|
+
* document order.
|
|
688
|
+
*
|
|
689
|
+
* Every walk over a document's nodes keeps its own stack rather than
|
|
690
|
+
* calling itself, because nesting in Markdown costs the author almost
|
|
691
|
+
* nothing: a few thousand nested blockquotes, or nested bold spans, fit in
|
|
692
|
+
* a few kilobytes, and recursion over them exhausts the call stack and
|
|
693
|
+
* takes the process with it. A stack on the heap has no such limit.
|
|
694
|
+
*
|
|
695
|
+
* @param node - The node whose children are to be walked
|
|
696
|
+
* @param pending - The stack to push onto, from which nodes are taken with `pop`
|
|
697
|
+
*/
|
|
698
|
+
private static pushChildren(node: MdastNode, pending: MdastNode[]): void {
|
|
699
|
+
const children: readonly MdastNode[] | undefined = node.children;
|
|
700
|
+
|
|
701
|
+
if (children === undefined) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
for (let index: number = children.length - 1; index >= 0; index -= 1) {
|
|
706
|
+
const child: MdastNode | undefined = children[index];
|
|
707
|
+
|
|
708
|
+
if (child !== undefined) {
|
|
709
|
+
pending.push(child);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Finds the References section and feeds its placement and entries to ECR103.
|
|
716
|
+
*
|
|
717
|
+
* The `## References` heading is looked for anywhere in the tree, not only
|
|
718
|
+
* among the root's children. A section nested in a blockquote or a list
|
|
719
|
+
* item used to be passed over, so its entries were silently discarded and
|
|
720
|
+
* the document reported as having an empty References section. It is now
|
|
721
|
+
* found, reported as misplaced (1#9.11 rule 3), and its entries still read.
|
|
722
|
+
*
|
|
723
|
+
* The entries are the items of the list that immediately follows the
|
|
724
|
+
* heading, or of a list that opens the container immediately following it
|
|
725
|
+
* -- which is also a placement violation.
|
|
726
|
+
*
|
|
727
|
+
* @param root - The parsed MDAST root node
|
|
728
|
+
* @param referencesSectionRule - The ECR103 rule instance to feed
|
|
729
|
+
*/
|
|
730
|
+
private feedListItemsToReferencesRule(
|
|
731
|
+
root: MdastRoot,
|
|
732
|
+
referencesSectionRule: ReferencesSectionRule,
|
|
733
|
+
): void {
|
|
734
|
+
if (!referencesSectionRule.tellReferencesHeadingDetected()) {
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const section: ReferencesSectionLocation | undefined = this.findReferencesSection(root);
|
|
739
|
+
|
|
740
|
+
if (section === undefined) {
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const next: MdastNode | undefined = section.siblings[section.index + 1];
|
|
745
|
+
const list: MdastNode | undefined = this.findReferencesList(next);
|
|
746
|
+
const nested: boolean = section.siblings !== root.children || (list !== undefined && list !== next);
|
|
747
|
+
|
|
748
|
+
referencesSectionRule.evaluateSectionPlacement(
|
|
749
|
+
this.mapPosition(section.heading.position),
|
|
750
|
+
nested,
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
for (const listItemNode of list?.children ?? []) {
|
|
754
|
+
if (listItemNode.type === 'listItem') {
|
|
755
|
+
referencesSectionRule.evaluateListItem(this.extractListItemNodeData(listItemNode));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Finds the list holding the References entries, given the node after the
|
|
762
|
+
* heading: that node itself, or a list opening the container it is, at any
|
|
763
|
+
* depth of nesting.
|
|
764
|
+
*
|
|
765
|
+
* Looking one container deep lost `> > - 8.1 - …`: the list was never
|
|
766
|
+
* found, so its entries were discarded and the section reported as empty.
|
|
767
|
+
*
|
|
768
|
+
* @param next - The node immediately following the `## References` heading
|
|
769
|
+
* @returns The entries' list, or `undefined` when there is none
|
|
770
|
+
*/
|
|
771
|
+
private findReferencesList(next: MdastNode | undefined): MdastNode | undefined {
|
|
772
|
+
let candidate: MdastNode | undefined = next;
|
|
773
|
+
|
|
774
|
+
while (candidate !== undefined && candidate.type !== 'heading') {
|
|
775
|
+
if (candidate.type === 'list') {
|
|
776
|
+
return candidate;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
candidate = candidate.children?.[0];
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
return undefined;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Finds the first `## References` heading in document order, at any depth.
|
|
787
|
+
*
|
|
788
|
+
* @param node - The node to search from
|
|
789
|
+
* @returns The heading and its position among its siblings, or `undefined`
|
|
790
|
+
*/
|
|
791
|
+
private findReferencesSection(node: MdastNode | MdastRoot): ReferencesSectionLocation | undefined {
|
|
792
|
+
// Each frame is a list of siblings and how far through it the walk is, so
|
|
793
|
+
// that a match can report the position among its siblings that the caller
|
|
794
|
+
// needs. Depth-first and pre-order, as the recursive form was.
|
|
795
|
+
const frames: SiblingsFrame[] = [{ children: node.children ?? [], index: 0 }];
|
|
796
|
+
|
|
797
|
+
while (frames.length > 0) {
|
|
798
|
+
const frame: SiblingsFrame | undefined = frames.pop();
|
|
799
|
+
|
|
800
|
+
if (frame === undefined) {
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const child: MdastNode | undefined = frame.children[frame.index];
|
|
805
|
+
|
|
806
|
+
if (child === undefined) {
|
|
807
|
+
// These siblings are exhausted, so the frame is not put back.
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// The rest of these siblings come after everything beneath this child,
|
|
812
|
+
// so they go back on the stack before it.
|
|
813
|
+
frames.push({ children: frame.children, index: frame.index + 1 });
|
|
814
|
+
|
|
815
|
+
if (child.type === 'heading' && this.tellReferencesHeading(this.extractHeadingNodeData(child))) {
|
|
816
|
+
return { heading: child, siblings: frame.children, index: frame.index };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
frames.push({ children: child.children ?? [], index: 0 });
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// -------------------------------------------------------------------------
|
|
826
|
+
// Private: Second pass -- inline references (ECR104)
|
|
827
|
+
// -------------------------------------------------------------------------
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Executes the second AST traversal pass: inline reference detection.
|
|
831
|
+
*
|
|
832
|
+
* This pass walks the AST in document order using a custom recursive
|
|
833
|
+
* traversal that tracks ancestor node types and:
|
|
834
|
+
* - Tracks the current section context (the most recently encountered
|
|
835
|
+
* heading identifier), starting with the DocID before any H2 heading
|
|
836
|
+
* - For each text node encountered, checks whether the node's ancestor
|
|
837
|
+
* chain includes any excluded node types (`code`, `inlineCode`, `html`)
|
|
838
|
+
* - Text nodes whose ancestors include excluded types are skipped
|
|
839
|
+
* - Valid text nodes are fed to ECR104
|
|
840
|
+
* (`InlineReferenceRule.evaluateTextNode`) with the current
|
|
841
|
+
* section context
|
|
842
|
+
* - After traversal, ECR104 is finalised
|
|
843
|
+
*
|
|
844
|
+
* @param root - The parsed MDAST root node
|
|
845
|
+
* @param docId - The document's established DocID
|
|
846
|
+
* @param declaredDocIds - The set of DocIDs declared in the References section
|
|
847
|
+
* @param sourceText - The document's raw Markdown, for the source-form checks of 1#9.11
|
|
848
|
+
* @returns The intermediate result from the inline references pass
|
|
849
|
+
*/
|
|
850
|
+
private executeInlineReferencesPass(
|
|
851
|
+
root: MdastRoot,
|
|
852
|
+
docId: DocID,
|
|
853
|
+
declaredDocIds: ReadonlySet<DocID>,
|
|
854
|
+
sourceText: string,
|
|
855
|
+
): InlineReferencesPassResult {
|
|
856
|
+
const inlineReferenceRule: InlineReferenceRule = new InlineReferenceRule({
|
|
857
|
+
uri: this.uri,
|
|
858
|
+
docId,
|
|
859
|
+
grammar: this.grammar,
|
|
860
|
+
declaredDocIds,
|
|
861
|
+
sourceText,
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// Track the current section context -- starts with the DocID.
|
|
865
|
+
// The mutable wrapper allows the recursive walk callback to update
|
|
866
|
+
// the section context as headings are encountered.
|
|
867
|
+
const sectionContext: SectionContextTracker = { current: docId };
|
|
868
|
+
|
|
869
|
+
// Walk the AST recursively, tracking ancestor types for exclusion filtering.
|
|
870
|
+
// When a heading is encountered, update the section context.
|
|
871
|
+
// When a text node is encountered (not excluded by ancestors), feed to ECR104.
|
|
872
|
+
this.walkNodesForInlineReferences(
|
|
873
|
+
root,
|
|
874
|
+
sectionContext,
|
|
875
|
+
inlineReferenceRule,
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
const inlineResult: InlineReferenceRuleResult =
|
|
879
|
+
inlineReferenceRule.finalise();
|
|
880
|
+
|
|
881
|
+
return { inlineResult };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Walks the AST to find inline runs for ECR104, tracking whether an
|
|
886
|
+
* excluded ancestor encloses the node and updating section context when
|
|
887
|
+
* headings are encountered.
|
|
888
|
+
*
|
|
889
|
+
* A paragraph is handed to ECR104 whole, as one inline run, because a
|
|
890
|
+
* citation is recognised in the text a reader sees and not one parsed text
|
|
891
|
+
* node at a time (1#9.5 rule 1). Its children are not walked separately.
|
|
892
|
+
*
|
|
893
|
+
* A hand-written depth-first, pre-order walk over an explicit stack: document
|
|
894
|
+
* order decides which section each run belongs to, and see
|
|
895
|
+
* {@link PerDocumentVisitor.pushChildren} for why the stack is not the call
|
|
896
|
+
* stack. Exclusion is carried down as one flag, because a node is excluded
|
|
897
|
+
* exactly when some ancestor's type is, so nothing is gained by keeping the
|
|
898
|
+
* ancestors themselves.
|
|
899
|
+
*
|
|
900
|
+
* @param root - The root of the walk
|
|
901
|
+
* @param sectionContext - Mutable wrapper holding the current section context
|
|
902
|
+
* @param inlineReferenceRule - The ECR104 rule instance to feed runs to
|
|
903
|
+
*/
|
|
904
|
+
private walkNodesForInlineReferences(
|
|
905
|
+
root: MdastNode,
|
|
906
|
+
sectionContext: SectionContextTracker,
|
|
907
|
+
inlineReferenceRule: InlineReferenceRule,
|
|
908
|
+
): void {
|
|
909
|
+
const pending: InlineWalkEntry[] = [{ node: root, excluded: false }];
|
|
910
|
+
|
|
911
|
+
while (pending.length > 0) {
|
|
912
|
+
const entry: InlineWalkEntry | undefined = pending.pop();
|
|
913
|
+
|
|
914
|
+
if (entry === undefined) {
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const { node, excluded } = entry;
|
|
919
|
+
|
|
920
|
+
if (node.type === 'heading' && node.depth !== undefined) {
|
|
921
|
+
sectionContext.current = this.determineSectionContext(
|
|
922
|
+
PerDocumentVisitor.showNodeText(node),
|
|
923
|
+
node.depth,
|
|
924
|
+
sectionContext.current,
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
// Do not walk into heading children for text node extraction;
|
|
928
|
+
// heading text is not subject to inline reference detection.
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
if (INLINE_RUN_NODE_TYPES.has(node.type) && node.children !== undefined) {
|
|
933
|
+
if (!excluded) {
|
|
934
|
+
const segments: InlineSegment[] = [];
|
|
935
|
+
|
|
936
|
+
for (const child of node.children) {
|
|
937
|
+
this.collectInlineSegments(child, [], segments);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
inlineReferenceRule.evaluateInlineRun(segments, sectionContext.current);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// A text node outside any run is not expected from the parser, but if one
|
|
947
|
+
// appears it is still evaluated, as a run of its own.
|
|
948
|
+
if (node.type === 'text' && node.value !== undefined) {
|
|
949
|
+
if (!excluded) {
|
|
950
|
+
const textNodeData: TextNodeData = this.extractTextNodeData(node);
|
|
951
|
+
inlineReferenceRule.evaluateTextNode(textNodeData, sectionContext.current);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
PerDocumentVisitor.pushInlineWalkChildren(node, excluded, pending);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Puts a node's children on the inline walk's stack, in document order,
|
|
963
|
+
* noting whether an excluded ancestor now encloses them.
|
|
964
|
+
*
|
|
965
|
+
* @param node - The node whose children are to be walked
|
|
966
|
+
* @param excluded - Whether an excluded ancestor already encloses the node
|
|
967
|
+
* @param pending - The stack to push onto
|
|
968
|
+
*/
|
|
969
|
+
private static pushInlineWalkChildren(
|
|
970
|
+
node: MdastNode,
|
|
971
|
+
excluded: boolean,
|
|
972
|
+
pending: InlineWalkEntry[],
|
|
973
|
+
): void {
|
|
974
|
+
const children: readonly MdastNode[] | undefined = node.children;
|
|
975
|
+
|
|
976
|
+
if (children === undefined) {
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const childrenExcluded: boolean = excluded || EXCLUDED_ANCESTOR_NODE_TYPES.has(node.type);
|
|
981
|
+
|
|
982
|
+
for (let index: number = children.length - 1; index >= 0; index -= 1) {
|
|
983
|
+
const child: MdastNode | undefined = children[index];
|
|
984
|
+
|
|
985
|
+
if (child !== undefined) {
|
|
986
|
+
pending.push({ node: child, excluded: childrenExcluded });
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Flattens one inline node into the segments a reader sees, in order.
|
|
993
|
+
*
|
|
994
|
+
* Text becomes a `text` segment carrying its position, and inline code a
|
|
995
|
+
* `code` segment. Formatting contributes its contents, each segment noting
|
|
996
|
+
* the spans around it. A hard break, an image, and a `<br>` tag become a
|
|
997
|
+
* `break` of one space, because a reader sees the words either side of them
|
|
998
|
+
* as separate. Any other inline HTML -- a comment, or a tag such as
|
|
999
|
+
* `<span>` -- contributes nothing, because it separates nothing:
|
|
1000
|
+
* `60<span>s</span>` reads as `60s`.
|
|
1001
|
+
*
|
|
1002
|
+
* @param node - An inline node
|
|
1003
|
+
* @param wrappers - Types of the formatting spans enclosing it, outermost first
|
|
1004
|
+
* @param segments - Accumulator the segments are appended to
|
|
1005
|
+
*/
|
|
1006
|
+
private collectInlineSegments(
|
|
1007
|
+
node: MdastNode,
|
|
1008
|
+
wrappers: readonly string[],
|
|
1009
|
+
segments: InlineSegment[],
|
|
1010
|
+
): void {
|
|
1011
|
+
const pending: SegmentWalkEntry[] = [{ node, wrappers }];
|
|
1012
|
+
|
|
1013
|
+
while (pending.length > 0) {
|
|
1014
|
+
const entry: SegmentWalkEntry | undefined = pending.pop();
|
|
1015
|
+
|
|
1016
|
+
if (entry === undefined) {
|
|
1017
|
+
break;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
this.collectInlineSegment(entry, segments, pending);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Turns one inline node into segments, or puts its children on the stack
|
|
1026
|
+
* to be turned into segments in their turn.
|
|
1027
|
+
*
|
|
1028
|
+
* @param entry - The node and the formatting spans enclosing it
|
|
1029
|
+
* @param segments - Accumulator the segments are appended to
|
|
1030
|
+
* @param pending - The walk's stack, which children are pushed onto in document order
|
|
1031
|
+
*/
|
|
1032
|
+
private collectInlineSegment(
|
|
1033
|
+
entry: SegmentWalkEntry,
|
|
1034
|
+
segments: InlineSegment[],
|
|
1035
|
+
pending: SegmentWalkEntry[],
|
|
1036
|
+
): void {
|
|
1037
|
+
const { node, wrappers } = entry;
|
|
1038
|
+
|
|
1039
|
+
if (node.type === 'text') {
|
|
1040
|
+
const range: PositionRange | undefined = this.mapPosition(node.position);
|
|
1041
|
+
|
|
1042
|
+
segments.push({
|
|
1043
|
+
kind: 'text',
|
|
1044
|
+
text: node.value ?? '',
|
|
1045
|
+
wrappers,
|
|
1046
|
+
...(range !== undefined ? { range } : {}),
|
|
1047
|
+
});
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
if (node.type === 'inlineCode') {
|
|
1052
|
+
segments.push({ kind: 'code', text: node.value ?? '', wrappers });
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (node.type === 'html' && !LINE_BREAK_HTML.test((node.value ?? '').trim())) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
if (node.type !== 'html' && node.children !== undefined) {
|
|
1061
|
+
const inner: readonly string[] = [...wrappers, node.type];
|
|
1062
|
+
|
|
1063
|
+
for (let index: number = node.children.length - 1; index >= 0; index -= 1) {
|
|
1064
|
+
const child: MdastNode | undefined = node.children[index];
|
|
1065
|
+
|
|
1066
|
+
if (child !== undefined) {
|
|
1067
|
+
pending.push({ node: child, wrappers: inner });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
segments.push({ kind: 'break', text: ' ', wrappers });
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// -------------------------------------------------------------------------
|
|
1078
|
+
// Private: Section context tracking
|
|
1079
|
+
// -------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Determines the current section context identifier for a given heading.
|
|
1083
|
+
*
|
|
1084
|
+
* The section context is the identifier (DocID or SectionID) of the most
|
|
1085
|
+
* recently encountered numbered heading. Before any H2 heading, the context
|
|
1086
|
+
* is the DocID. After an H2 or deeper heading, the context is the
|
|
1087
|
+
* SectionID of that heading.
|
|
1088
|
+
*
|
|
1089
|
+
* This method extracts the identifier from the heading text using the
|
|
1090
|
+
* grammar's separator convention. If the heading text does not contain
|
|
1091
|
+
* a valid identifier, the previous context is retained.
|
|
1092
|
+
*
|
|
1093
|
+
* @param headingText - The plain text content of the heading node
|
|
1094
|
+
* @param headingDepth - The Markdown heading depth (1--6)
|
|
1095
|
+
* @param currentContext - The current section context before this heading
|
|
1096
|
+
* @returns The updated section context identifier
|
|
1097
|
+
*/
|
|
1098
|
+
private determineSectionContext(
|
|
1099
|
+
headingText: string,
|
|
1100
|
+
headingDepth: number,
|
|
1101
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be DocID or SectionID
|
|
1102
|
+
currentContext: DocID | SectionID,
|
|
1103
|
+
): // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: return may be DocID or SectionID
|
|
1104
|
+
DocID | SectionID {
|
|
1105
|
+
// Only headings with depth >= 2 can change the section context
|
|
1106
|
+
// (H1 establishes the DocID, which is the initial context)
|
|
1107
|
+
if (headingDepth < 2) {
|
|
1108
|
+
return currentContext;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
// A sub-heading carries a SectionID, so it is parsed with the SectionID
|
|
1112
|
+
// heading grammar rather than the DocID one an H1 uses.
|
|
1113
|
+
const parseResult: ReturnType<IdentifierGrammar['parseSectionHeading']> =
|
|
1114
|
+
this.grammar.parseSectionHeading(headingText);
|
|
1115
|
+
|
|
1116
|
+
if (parseResult.valid) {
|
|
1117
|
+
return parseResult.sectionId;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
return currentContext;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// -------------------------------------------------------------------------
|
|
1124
|
+
// Private: References heading detection
|
|
1125
|
+
// -------------------------------------------------------------------------
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Determines whether a heading is the `## References` structural heading.
|
|
1129
|
+
*
|
|
1130
|
+
* The `## References` heading is owned by ECR103 and should not be
|
|
1131
|
+
* evaluated by ECR102 (Section Hierarchy), because it is not a numbered
|
|
1132
|
+
* section heading and would fail SectionID parsing.
|
|
1133
|
+
*
|
|
1134
|
+
* Per 1#9.6, the References heading is identified by depth === 2
|
|
1135
|
+
* and text content exactly equal to `"References"`.
|
|
1136
|
+
*
|
|
1137
|
+
* @param headingData - The heading node data to check
|
|
1138
|
+
* @returns `true` if the heading is the `## References` heading, `false` otherwise
|
|
1139
|
+
*/
|
|
1140
|
+
private tellReferencesHeading(headingData: HeadingNodeData): boolean {
|
|
1141
|
+
return headingData.depth === 2 && headingData.text === 'References';
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// -------------------------------------------------------------------------
|
|
1145
|
+
// Private: Text extraction from list items
|
|
1146
|
+
// -------------------------------------------------------------------------
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Extracts the plain text content from a list item AST node.
|
|
1150
|
+
*
|
|
1151
|
+
* Gathers the text of the list item's child nodes into a single plain text
|
|
1152
|
+
* string suitable for feeding to ECR103.
|
|
1153
|
+
*
|
|
1154
|
+
* @param listItemNode - The MDAST list item node to extract text from
|
|
1155
|
+
* @returns The extracted {@link ListItemNodeData} with text and optional range
|
|
1156
|
+
*/
|
|
1157
|
+
private extractListItemNodeData(
|
|
1158
|
+
listItemNode: MdastNode,
|
|
1159
|
+
): ListItemNodeData {
|
|
1160
|
+
const segments: ListItemSegment[] = [];
|
|
1161
|
+
|
|
1162
|
+
this.collectListItemSegments(listItemNode, segments);
|
|
1163
|
+
|
|
1164
|
+
const text: string = segments.map((segment: ListItemSegment): string => segment.text).join('');
|
|
1165
|
+
const range: PositionRange | undefined =
|
|
1166
|
+
this.mapPosition(listItemNode.position);
|
|
1167
|
+
|
|
1168
|
+
const listItemData: ListItemNodeData = {
|
|
1169
|
+
text,
|
|
1170
|
+
segments,
|
|
1171
|
+
...(range !== undefined ? { range } : {}),
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
return listItemData;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* Classifies a literal node for tracing its text back to the source.
|
|
1179
|
+
*
|
|
1180
|
+
* @param type - The node's MDAST type
|
|
1181
|
+
* @returns `text` for a text node, `code` for inline code, `other` otherwise
|
|
1182
|
+
*/
|
|
1183
|
+
private static showSegmentKind(type: string): ListItemSegment['kind'] {
|
|
1184
|
+
if (type === 'text') {
|
|
1185
|
+
return 'text';
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
return type === 'inlineCode' ? 'code' : 'other';
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Splits a list item's text into the parsed nodes it comes from.
|
|
1193
|
+
*
|
|
1194
|
+
* Follows `mdast-util-to-string` exactly -- a node's `value`, else an
|
|
1195
|
+
* image's `alt`, else its children in order -- so that joining the
|
|
1196
|
+
* segments reproduces the entry text the rule parses, and an offset in that
|
|
1197
|
+
* text identifies the node, and so the source, it came from.
|
|
1198
|
+
*
|
|
1199
|
+
* @param node - A node within the list item
|
|
1200
|
+
* @param segments - Accumulator the segments are appended to
|
|
1201
|
+
*/
|
|
1202
|
+
private collectListItemSegments(node: MdastNode, segments: ListItemSegment[]): void {
|
|
1203
|
+
const pending: MdastNode[] = [node];
|
|
1204
|
+
|
|
1205
|
+
while (pending.length > 0) {
|
|
1206
|
+
const current: MdastNode | undefined = pending.pop();
|
|
1207
|
+
|
|
1208
|
+
if (current === undefined) {
|
|
1209
|
+
break;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const range: PositionRange | undefined = this.mapPosition(current.position);
|
|
1213
|
+
const withRange: { readonly range?: PositionRange } = range !== undefined ? { range } : {};
|
|
1214
|
+
|
|
1215
|
+
if (current.value !== undefined) {
|
|
1216
|
+
segments.push({
|
|
1217
|
+
kind: PerDocumentVisitor.showSegmentKind(current.type),
|
|
1218
|
+
text: current.value,
|
|
1219
|
+
...withRange,
|
|
1220
|
+
});
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (typeof current.alt === 'string' && current.alt.length > 0) {
|
|
1225
|
+
segments.push({ kind: 'other', text: current.alt, ...withRange });
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
PerDocumentVisitor.pushChildren(current, pending);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// -------------------------------------------------------------------------
|
|
1234
|
+
// Private: Heading data extraction
|
|
1235
|
+
// -------------------------------------------------------------------------
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Extracts heading node data from an MDAST heading node.
|
|
1239
|
+
*
|
|
1240
|
+
* Reads the plain text content with {@link PerDocumentVisitor.showNodeText}
|
|
1241
|
+
* and maps the MDAST position to a {@link HeadingNodeData} structure
|
|
1242
|
+
* suitable for consumption by ECR101, ECR102, and ECR103.
|
|
1243
|
+
*
|
|
1244
|
+
* @param headingNode - The MDAST heading node to extract data from
|
|
1245
|
+
* @returns The extracted {@link HeadingNodeData} with depth, text, and optional range
|
|
1246
|
+
*/
|
|
1247
|
+
private extractHeadingNodeData(
|
|
1248
|
+
headingNode: MdastNode,
|
|
1249
|
+
): HeadingNodeData {
|
|
1250
|
+
const text: string = PerDocumentVisitor.showNodeText(headingNode);
|
|
1251
|
+
const range: PositionRange | undefined =
|
|
1252
|
+
this.mapPosition(headingNode.position);
|
|
1253
|
+
|
|
1254
|
+
const headingData: HeadingNodeData = {
|
|
1255
|
+
depth: headingNode.depth ?? 1,
|
|
1256
|
+
text,
|
|
1257
|
+
...(range !== undefined ? { range } : {}),
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
return headingData;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// -------------------------------------------------------------------------
|
|
1264
|
+
// Private: Text node data extraction
|
|
1265
|
+
// -------------------------------------------------------------------------
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Extracts text node data from an MDAST text node.
|
|
1269
|
+
*
|
|
1270
|
+
* Maps the MDAST text node's value and position to a {@link TextNodeData}
|
|
1271
|
+
* structure suitable for consumption by ECR104.
|
|
1272
|
+
*
|
|
1273
|
+
* @param textNode - The MDAST text node to extract data from
|
|
1274
|
+
* @returns The extracted {@link TextNodeData} with text and optional range
|
|
1275
|
+
*/
|
|
1276
|
+
private extractTextNodeData(
|
|
1277
|
+
textNode: MdastNode,
|
|
1278
|
+
): TextNodeData {
|
|
1279
|
+
const range: PositionRange | undefined =
|
|
1280
|
+
this.mapPosition(textNode.position);
|
|
1281
|
+
|
|
1282
|
+
const textNodeData: TextNodeData = {
|
|
1283
|
+
text: textNode.value ?? '',
|
|
1284
|
+
...(range !== undefined ? { range } : {}),
|
|
1285
|
+
};
|
|
1286
|
+
|
|
1287
|
+
return textNodeData;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// -------------------------------------------------------------------------
|
|
1291
|
+
// Private: Declared DocID extraction
|
|
1292
|
+
// -------------------------------------------------------------------------
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Extracts the set of declared DocIDs from a completed ECR103 result.
|
|
1296
|
+
*
|
|
1297
|
+
* Iterates over the extracted {@link ReferenceEdge} artefacts and
|
|
1298
|
+
* collects the `toDocId` field from each edge into a `ReadonlySet<DocID>`.
|
|
1299
|
+
* This set is used to initialise ECR104 for undeclared reference detection.
|
|
1300
|
+
*
|
|
1301
|
+
* @param references - The extracted reference edges from ECR103
|
|
1302
|
+
* @returns The set of declared DocIDs
|
|
1303
|
+
*/
|
|
1304
|
+
private extractDeclaredDocIds(
|
|
1305
|
+
references: readonly ReferenceEdge[],
|
|
1306
|
+
): ReadonlySet<DocID> {
|
|
1307
|
+
const declaredDocIds: Set<DocID> = new Set<DocID>();
|
|
1308
|
+
|
|
1309
|
+
for (const referenceEdge of references) {
|
|
1310
|
+
declaredDocIds.add(referenceEdge.toDocId);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
return declaredDocIds;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// -------------------------------------------------------------------------
|
|
1317
|
+
// Private: Position mapping
|
|
1318
|
+
// -------------------------------------------------------------------------
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Maps an MDAST position to a {@link PositionRange}, if available.
|
|
1322
|
+
*
|
|
1323
|
+
* MDAST positions use 1-based lines and 1-based columns, while
|
|
1324
|
+
* {@link PositionRange} uses 0-based lines and 0-based characters.
|
|
1325
|
+
* This method performs the conversion.
|
|
1326
|
+
*
|
|
1327
|
+
* @param mdastPosition - The MDAST position object, or `undefined` if
|
|
1328
|
+
* positional metadata is not available
|
|
1329
|
+
* @returns The mapped position range, or `undefined` if no position is available
|
|
1330
|
+
*/
|
|
1331
|
+
private mapPosition(
|
|
1332
|
+
mdastPosition: MdastPosition | undefined,
|
|
1333
|
+
): PositionRange | undefined {
|
|
1334
|
+
if (mdastPosition === undefined) {
|
|
1335
|
+
return undefined;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
const mappedRange: PositionRange = {
|
|
1339
|
+
start: {
|
|
1340
|
+
line: mdastPosition.start.line - 1,
|
|
1341
|
+
character: mdastPosition.start.column - 1,
|
|
1342
|
+
},
|
|
1343
|
+
end: {
|
|
1344
|
+
line: mdastPosition.end.line - 1,
|
|
1345
|
+
character: mdastPosition.end.column - 1,
|
|
1346
|
+
},
|
|
1347
|
+
};
|
|
1348
|
+
|
|
1349
|
+
return mappedRange;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// -------------------------------------------------------------------------
|
|
1353
|
+
// Private: Result assembly
|
|
1354
|
+
// -------------------------------------------------------------------------
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Assembles the composite {@link LintResult} from individual rule results.
|
|
1358
|
+
*
|
|
1359
|
+
* Aggregates diagnostics from all rules, determines the `ok` status
|
|
1360
|
+
* (true when no error-severity diagnostics are present), and constructs
|
|
1361
|
+
* the {@link ExtractedDocument} when a valid DocID was recovered.
|
|
1362
|
+
*
|
|
1363
|
+
* @param identityResult - The result from ECR101
|
|
1364
|
+
* @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
|
|
1365
|
+
* @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
|
|
1366
|
+
* @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
|
|
1367
|
+
* @returns The composite lint result
|
|
1368
|
+
*/
|
|
1369
|
+
private assembleLintResult(
|
|
1370
|
+
identityResult: DocumentIdentityRuleResult,
|
|
1371
|
+
sectionsResult: SectionHierarchyRuleResult | undefined,
|
|
1372
|
+
referencesResult: ReferencesSectionRuleResult | undefined,
|
|
1373
|
+
inlineResult: InlineReferenceRuleResult | undefined,
|
|
1374
|
+
): LintResult {
|
|
1375
|
+
// Aggregate all diagnostics
|
|
1376
|
+
const allDiagnostics: readonly Diagnostic[] = this.aggregateDiagnostics(
|
|
1377
|
+
identityResult,
|
|
1378
|
+
sectionsResult,
|
|
1379
|
+
referencesResult,
|
|
1380
|
+
inlineResult,
|
|
1381
|
+
);
|
|
1382
|
+
|
|
1383
|
+
const input: LintInput = this.buildLintInput();
|
|
1384
|
+
const isPassable: boolean = this.tellAllDiagnosticsPassable(allDiagnostics);
|
|
1385
|
+
|
|
1386
|
+
// Construct extracted document if identity is valid
|
|
1387
|
+
if (identityResult.identity !== undefined) {
|
|
1388
|
+
const identity: DocumentIdentity = identityResult.identity;
|
|
1389
|
+
const sections: readonly SectionNode[] =
|
|
1390
|
+
sectionsResult !== undefined ? sectionsResult.sections : [];
|
|
1391
|
+
const references: readonly ReferenceEdge[] =
|
|
1392
|
+
referencesResult !== undefined ? referencesResult.references : [];
|
|
1393
|
+
const inlineReferences: readonly InlineReferenceEdge[] =
|
|
1394
|
+
inlineResult !== undefined ? inlineResult.inlineReferences : [];
|
|
1395
|
+
|
|
1396
|
+
const extracted: ExtractedDocument = this.buildExtractedDocument(
|
|
1397
|
+
identity,
|
|
1398
|
+
sections,
|
|
1399
|
+
references,
|
|
1400
|
+
inlineReferences,
|
|
1401
|
+
);
|
|
1402
|
+
|
|
1403
|
+
return {
|
|
1404
|
+
input,
|
|
1405
|
+
ok: isPassable,
|
|
1406
|
+
diagnostics: allDiagnostics,
|
|
1407
|
+
extracted,
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return {
|
|
1412
|
+
input,
|
|
1413
|
+
ok: isPassable,
|
|
1414
|
+
diagnostics: allDiagnostics,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Aggregates diagnostics from all rule results into a single array.
|
|
1420
|
+
*
|
|
1421
|
+
* Collects diagnostics from ECR101, ECR102, ECR103, and ECR104 in that
|
|
1422
|
+
* order. Rules that did not run (undefined results) contribute no
|
|
1423
|
+
* diagnostics.
|
|
1424
|
+
*
|
|
1425
|
+
* @param identityResult - The result from ECR101
|
|
1426
|
+
* @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
|
|
1427
|
+
* @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
|
|
1428
|
+
* @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
|
|
1429
|
+
* @returns The aggregated array of all diagnostics
|
|
1430
|
+
*/
|
|
1431
|
+
private aggregateDiagnostics(
|
|
1432
|
+
identityResult: DocumentIdentityRuleResult,
|
|
1433
|
+
sectionsResult: SectionHierarchyRuleResult | undefined,
|
|
1434
|
+
referencesResult: ReferencesSectionRuleResult | undefined,
|
|
1435
|
+
inlineResult: InlineReferenceRuleResult | undefined,
|
|
1436
|
+
): readonly Diagnostic[] {
|
|
1437
|
+
const allDiagnostics: Diagnostic[] = [];
|
|
1438
|
+
|
|
1439
|
+
for (const diagnostic of identityResult.diagnostics) {
|
|
1440
|
+
allDiagnostics.push(diagnostic);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
if (sectionsResult !== undefined) {
|
|
1444
|
+
for (const diagnostic of sectionsResult.diagnostics) {
|
|
1445
|
+
allDiagnostics.push(diagnostic);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
if (referencesResult !== undefined) {
|
|
1450
|
+
for (const diagnostic of referencesResult.diagnostics) {
|
|
1451
|
+
allDiagnostics.push(diagnostic);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
if (inlineResult !== undefined) {
|
|
1456
|
+
for (const diagnostic of inlineResult.diagnostics) {
|
|
1457
|
+
allDiagnostics.push(diagnostic);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return allDiagnostics;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Constructs the {@link LintInput} descriptor for the result.
|
|
1466
|
+
*
|
|
1467
|
+
* Echoes the visitor's URI and optional version into the standard
|
|
1468
|
+
* input descriptor shape.
|
|
1469
|
+
*
|
|
1470
|
+
* @returns The lint input descriptor
|
|
1471
|
+
*/
|
|
1472
|
+
private buildLintInput(): LintInput {
|
|
1473
|
+
const input: LintInput = {
|
|
1474
|
+
uri: this.uri,
|
|
1475
|
+
...(this.version !== undefined ? { version: this.version } : {}),
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
return input;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* Determines the composite `ok` status from an aggregated diagnostics array.
|
|
1483
|
+
*
|
|
1484
|
+
* The result is `ok` (true) when no diagnostics have severity `"error"`.
|
|
1485
|
+
* Any error-severity diagnostic causes the result to be not-ok (false).
|
|
1486
|
+
*
|
|
1487
|
+
* @param diagnostics - The full array of aggregated diagnostics from all rules
|
|
1488
|
+
* @returns `true` if no error diagnostics are present, `false` otherwise
|
|
1489
|
+
*/
|
|
1490
|
+
private tellAllDiagnosticsPassable(
|
|
1491
|
+
diagnostics: readonly Diagnostic[],
|
|
1492
|
+
): boolean {
|
|
1493
|
+
return !diagnostics.some(
|
|
1494
|
+
(diagnostic: Diagnostic): boolean => diagnostic.severity === 'error',
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Constructs the {@link ExtractedDocument} from individual rule extraction
|
|
1500
|
+
* results.
|
|
1501
|
+
*
|
|
1502
|
+
* Assembles the document identity (DocID and title), section nodes,
|
|
1503
|
+
* reference edges, and inline reference edges into the canonical
|
|
1504
|
+
* extracted document shape.
|
|
1505
|
+
*
|
|
1506
|
+
* @param identity - The document identity from ECR101
|
|
1507
|
+
* @param sections - The section nodes from ECR102
|
|
1508
|
+
* @param references - The reference edges from ECR103
|
|
1509
|
+
* @param inlineReferences - The inline reference edges from ECR104
|
|
1510
|
+
* @returns The assembled extracted document
|
|
1511
|
+
*/
|
|
1512
|
+
private buildExtractedDocument(
|
|
1513
|
+
identity: DocumentIdentity,
|
|
1514
|
+
sections: readonly SectionNode[],
|
|
1515
|
+
references: readonly ReferenceEdge[],
|
|
1516
|
+
inlineReferences: readonly InlineReferenceEdge[],
|
|
1517
|
+
): ExtractedDocument {
|
|
1518
|
+
const extracted: ExtractedDocument = {
|
|
1519
|
+
docId: identity.docId,
|
|
1520
|
+
title: identity.title,
|
|
1521
|
+
sections,
|
|
1522
|
+
references,
|
|
1523
|
+
inlineReferences,
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
return extracted;
|
|
1527
|
+
}
|
|
1528
|
+
}
|