@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,1032 @@
|
|
|
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
|
+
// Runtime imports (value imports for classes and functions)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
import { unified } from 'unified';
|
|
34
|
+
import remarkParse from 'remark-parse';
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Value imports for rule classes and grammar
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
import { DocumentIdentityRule } from './document-identity-rule.js';
|
|
39
|
+
import { SectionHierarchyRule } from './section-hierarchy-rule.js';
|
|
40
|
+
import { ReferencesSectionRule } from './references-section-rule.js';
|
|
41
|
+
import { InlineReferenceRule } from './inline-reference-rule.js';
|
|
42
|
+
import { IdentifierGrammar } from './identifier-grammar.js';
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// A document the parser could not read
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
/** Rule identifier reported when a document cannot be parsed at all. */
|
|
47
|
+
export const UNPARSABLE_DOCUMENT_RULE_ID = 'document/unparsable';
|
|
48
|
+
/** The `data.cause` of that diagnostic. */
|
|
49
|
+
export const UNPARSABLE_DOCUMENT_CAUSE = 'unparsable-document';
|
|
50
|
+
/** How much of the parser's own account of the failure to repeat. */
|
|
51
|
+
const UNPARSABLE_REASON_LIMIT = 200;
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Excluded ancestor node types for ECR104 filtering
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
/**
|
|
56
|
+
* MDAST node types whose descendant text nodes must be excluded from
|
|
57
|
+
* ECR104 inline reference detection.
|
|
58
|
+
*
|
|
59
|
+
* Per 1#9.5, text nodes inside code blocks, inline code, and HTML
|
|
60
|
+
* elements are not valid candidates for inline reference detection.
|
|
61
|
+
*/
|
|
62
|
+
export const EXCLUDED_ANCESTOR_NODE_TYPES = new Set([
|
|
63
|
+
'code',
|
|
64
|
+
'inlineCode',
|
|
65
|
+
'html',
|
|
66
|
+
]);
|
|
67
|
+
/**
|
|
68
|
+
* MDAST node types whose children form one inline run: the text a reader
|
|
69
|
+
* sees, recognised as a whole (1#9.5 rule 1). Headings are excluded, since
|
|
70
|
+
* heading text is not subject to inline reference detection.
|
|
71
|
+
*/
|
|
72
|
+
const INLINE_RUN_NODE_TYPES = new Set([
|
|
73
|
+
'paragraph',
|
|
74
|
+
'tableCell',
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* Inline HTML a reader sees as a line break: an opening `br` tag in any case,
|
|
78
|
+
* whatever follows its name -- `<br>`, `<BR/>`, `<br class="x">`. It separates
|
|
79
|
+
* the words either side of it. Other inline HTML does not, so it is
|
|
80
|
+
* transparent to recognition (1#9.5 rule 1).
|
|
81
|
+
*
|
|
82
|
+
* Only the tag's name is read. The parser has already delimited the whole
|
|
83
|
+
* tag as one node, so its attributes need no second parse here; matching
|
|
84
|
+
* them again assumed every `>` closed the tag, and `<br title="x > y">` was
|
|
85
|
+
* taken for transparent HTML.
|
|
86
|
+
*/
|
|
87
|
+
const LINE_BREAK_HTML = /^<br(?=[\s/>])/i;
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Per-Document Visitor class
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
/**
|
|
92
|
+
* Orchestrates all four ECR rules (ECR101--ECR104) over a single Markdown
|
|
93
|
+
* document and produces a composite {@link LintResult}.
|
|
94
|
+
*
|
|
95
|
+
* The visitor is stateless between calls to {@link lint}. Each invocation
|
|
96
|
+
* produces a fresh result with no side effects on the visitor instance.
|
|
97
|
+
*
|
|
98
|
+
* The visitor creates its own {@link IdentifierGrammar} instance internally
|
|
99
|
+
* and manages the rule initialisation dependency chain:
|
|
100
|
+
* - ECR101 (Document Identity) runs first on all headings
|
|
101
|
+
* - If ECR101 produces a valid DocID, ECR102 (Section Hierarchy) and
|
|
102
|
+
* ECR103 (References Section) are initialised and fed their respective nodes
|
|
103
|
+
* - After ECR103 finalises, the set of declared DocIDs is extracted and
|
|
104
|
+
* used to initialise ECR104 (Inline Reference)
|
|
105
|
+
* - If ECR101 fails (no valid DocID), the result is returned early with
|
|
106
|
+
* `ok=false` and no `extracted` field
|
|
107
|
+
*
|
|
108
|
+
* Usage:
|
|
109
|
+
* ```ts
|
|
110
|
+
* const visitor = new PerDocumentVisitor({
|
|
111
|
+
* uri: 'file:///docs/3.1.md',
|
|
112
|
+
* version: 1,
|
|
113
|
+
* });
|
|
114
|
+
*
|
|
115
|
+
* const result: LintResult = visitor.lint('# 3.1 - My Document\n\n## References\n- 8.1 - ...');
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```ts
|
|
120
|
+
* const visitor = new PerDocumentVisitor({ uri: 'file:///docs/5.1.md' });
|
|
121
|
+
* const result: LintResult = visitor.lint(markdownText);
|
|
122
|
+
*
|
|
123
|
+
* if (result.ok) {
|
|
124
|
+
* console.log('Document is valid:', result.extracted?.docId);
|
|
125
|
+
* } else {
|
|
126
|
+
* console.log('Diagnostics:', result.diagnostics);
|
|
127
|
+
* }
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
export class PerDocumentVisitor {
|
|
131
|
+
/**
|
|
132
|
+
* The opaque, host-provided URI identifying the document being validated.
|
|
133
|
+
*/
|
|
134
|
+
uri;
|
|
135
|
+
/**
|
|
136
|
+
* Optional version tag for the document instance.
|
|
137
|
+
*/
|
|
138
|
+
version;
|
|
139
|
+
/**
|
|
140
|
+
* The {@link IdentifierGrammar} instance shared across all rules
|
|
141
|
+
* within a single visitor. Created once during construction.
|
|
142
|
+
*/
|
|
143
|
+
grammar;
|
|
144
|
+
/**
|
|
145
|
+
* Constructs a new Per-Document Visitor.
|
|
146
|
+
*
|
|
147
|
+
* Creates an internal {@link IdentifierGrammar} instance for use
|
|
148
|
+
* across all rule evaluations performed by this visitor.
|
|
149
|
+
*
|
|
150
|
+
* @param options - Configuration including the document URI and optional version
|
|
151
|
+
*/
|
|
152
|
+
constructor(options) {
|
|
153
|
+
this.uri = options.uri;
|
|
154
|
+
this.version = options.version;
|
|
155
|
+
this.grammar = new IdentifierGrammar();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Lints a single Markdown document and produces a composite {@link LintResult}.
|
|
159
|
+
*
|
|
160
|
+
* Each call is stateless: the visitor creates fresh rule instances,
|
|
161
|
+
* parses the Markdown text into an AST, walks the AST to feed nodes
|
|
162
|
+
* to the appropriate rules, finalises all rules, and assembles the result.
|
|
163
|
+
*
|
|
164
|
+
* The orchestration proceeds in phases:
|
|
165
|
+
* 1. Parse the Markdown text into an MDAST {@link MdastRoot} node
|
|
166
|
+
* 2. First pass: walk headings and list items for ECR101, ECR102, ECR103
|
|
167
|
+
* 3. If ECR101 fails (no valid DocID), return early with `ok=false`
|
|
168
|
+
* 4. Second pass: walk text nodes for ECR104 (with ancestor filtering
|
|
169
|
+
* and section context tracking)
|
|
170
|
+
* 5. Assemble the composite {@link LintResult} from all rule results
|
|
171
|
+
*
|
|
172
|
+
* @param markdownText - The raw Markdown text of the document to lint
|
|
173
|
+
* @returns The composite lint result including diagnostics and, when a valid
|
|
174
|
+
* DocID is recovered, extracted structural artefacts
|
|
175
|
+
*/
|
|
176
|
+
lint(markdownText) {
|
|
177
|
+
let root;
|
|
178
|
+
try {
|
|
179
|
+
root = this.parseMarkdown(markdownText);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
return this.reportUnparsable(error);
|
|
183
|
+
}
|
|
184
|
+
const passOneResult = this.executeHeadingsAndReferencesPass(root, markdownText);
|
|
185
|
+
// If ECR101 failed (no valid identity), return early
|
|
186
|
+
if (passOneResult.identityResult.identity === undefined) {
|
|
187
|
+
return this.assembleLintResult(passOneResult.identityResult, undefined, undefined, undefined);
|
|
188
|
+
}
|
|
189
|
+
// ECR101 succeeded -- extract declaredDocIds from ECR103
|
|
190
|
+
const referencesResult = passOneResult.referencesResult;
|
|
191
|
+
const referencesForExtraction = referencesResult !== undefined ? referencesResult.references : [];
|
|
192
|
+
const declaredDocIds = this.extractDeclaredDocIds(referencesForExtraction);
|
|
193
|
+
// Run ECR104
|
|
194
|
+
const inlineResult = this.executeInlineReferencesPass(root, passOneResult.identityResult.identity.docId, declaredDocIds, markdownText);
|
|
195
|
+
return this.assembleLintResult(passOneResult.identityResult, passOneResult.sectionsResult, passOneResult.referencesResult, inlineResult.inlineResult);
|
|
196
|
+
}
|
|
197
|
+
// -------------------------------------------------------------------------
|
|
198
|
+
// Private: Markdown parsing
|
|
199
|
+
// -------------------------------------------------------------------------
|
|
200
|
+
/**
|
|
201
|
+
* Parses raw Markdown text into an MDAST {@link MdastRoot} node.
|
|
202
|
+
*
|
|
203
|
+
* Uses `unified` with `remark-parse` to produce a standards-compliant
|
|
204
|
+
* MDAST tree with positional metadata attached to all nodes.
|
|
205
|
+
*
|
|
206
|
+
* The `unified().use(remarkParse).parse()` call returns a type that
|
|
207
|
+
* cannot be directly assigned to our local {@link MdastRoot} interface
|
|
208
|
+
* because `mdast` types are not importable as a module specifier in this
|
|
209
|
+
* project. The cast via `unknown` is safe because `remark-parse` always
|
|
210
|
+
* produces an MDAST Root node at runtime.
|
|
211
|
+
*
|
|
212
|
+
* @param markdownText - The raw Markdown text to parse
|
|
213
|
+
* @returns The parsed MDAST root node
|
|
214
|
+
*/
|
|
215
|
+
parseMarkdown(markdownText) {
|
|
216
|
+
const root = unified()
|
|
217
|
+
.use(remarkParse)
|
|
218
|
+
.parse(markdownText);
|
|
219
|
+
return root;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Reports a document the parser could not read.
|
|
223
|
+
*
|
|
224
|
+
* The parser walks a document's structure by recursion, inside a package
|
|
225
|
+
* this project does not control, so a deeply nested link label or image
|
|
226
|
+
* description can end the parse with a stack overflow. That must be one
|
|
227
|
+
* document's error, reported like any other, and never a crash that stops
|
|
228
|
+
* a corpus part way and leaves the rest unvalidated.
|
|
229
|
+
*
|
|
230
|
+
* @param error - What the parser threw
|
|
231
|
+
* @returns A failing result carrying one error diagnostic
|
|
232
|
+
*/
|
|
233
|
+
reportUnparsable(error) {
|
|
234
|
+
const reason = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
235
|
+
return {
|
|
236
|
+
input: this.buildLintInput(),
|
|
237
|
+
ok: false,
|
|
238
|
+
diagnostics: [
|
|
239
|
+
{
|
|
240
|
+
severity: 'error',
|
|
241
|
+
ruleId: UNPARSABLE_DOCUMENT_RULE_ID,
|
|
242
|
+
message: `Document could not be parsed, so none of it was validated ` +
|
|
243
|
+
`(${reason.slice(0, UNPARSABLE_REASON_LIMIT)}). Deeply nested Markdown is the ` +
|
|
244
|
+
`usual cause, because parsing it recurses.`,
|
|
245
|
+
uri: this.uri,
|
|
246
|
+
data: { cause: UNPARSABLE_DOCUMENT_CAUSE },
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// -------------------------------------------------------------------------
|
|
252
|
+
// Private: First pass -- headings, sections, and references
|
|
253
|
+
// -------------------------------------------------------------------------
|
|
254
|
+
/**
|
|
255
|
+
* Executes the first AST traversal pass: headings, sections, and references.
|
|
256
|
+
*
|
|
257
|
+
* This pass walks the AST in document order and:
|
|
258
|
+
* - Feeds every heading node to ECR101 (`DocumentIdentityRule.evaluateHeading`)
|
|
259
|
+
* - Finalises ECR101 to determine whether a valid DocID was recovered
|
|
260
|
+
* - If a valid DocID is recovered:
|
|
261
|
+
* - Registers the root H1 heading with ECR102
|
|
262
|
+
* (`SectionHierarchyRule.registerRootHeading`)
|
|
263
|
+
* - Feeds headings with depth \>= 2 to ECR102
|
|
264
|
+
* (`SectionHierarchyRule.evaluateHeading`)
|
|
265
|
+
* - Feeds all headings to ECR103
|
|
266
|
+
* (`ReferencesSectionRule.evaluateHeading`)
|
|
267
|
+
* - Feeds list items within the References section to ECR103
|
|
268
|
+
* (`ReferencesSectionRule.evaluateListItem`)
|
|
269
|
+
* - Finalises ECR102 and ECR103
|
|
270
|
+
* - If no valid DocID is recovered, ECR102 and ECR103 are skipped
|
|
271
|
+
*
|
|
272
|
+
* The pass is structured so that all heading nodes are collected first
|
|
273
|
+
* for ECR101, then the collected headings are replayed for ECR102 and ECR103.
|
|
274
|
+
*
|
|
275
|
+
* @param root - The parsed MDAST root node
|
|
276
|
+
* @param sourceText - The document's raw Markdown, for the heading source-form check of 1#9.11
|
|
277
|
+
* @returns The intermediate result from the headings and references pass
|
|
278
|
+
*/
|
|
279
|
+
executeHeadingsAndReferencesPass(root, sourceText) {
|
|
280
|
+
// Phase 1: Collect all headings and feed to ECR101
|
|
281
|
+
const collectedHeadings = [];
|
|
282
|
+
this.collectHeadings(root, collectedHeadings);
|
|
283
|
+
const identityRule = new DocumentIdentityRule({
|
|
284
|
+
uri: this.uri,
|
|
285
|
+
grammar: this.grammar,
|
|
286
|
+
sourceText,
|
|
287
|
+
});
|
|
288
|
+
for (const headingData of collectedHeadings) {
|
|
289
|
+
identityRule.evaluateHeading(headingData);
|
|
290
|
+
}
|
|
291
|
+
const identityResult = identityRule.finalise();
|
|
292
|
+
// If ECR101 failed, return early -- no ECR102 or ECR103
|
|
293
|
+
if (identityResult.identity === undefined) {
|
|
294
|
+
return { identityResult };
|
|
295
|
+
}
|
|
296
|
+
// Phase 2: ECR101 succeeded -- initialise ECR102 and ECR103
|
|
297
|
+
const docId = identityResult.identity.docId;
|
|
298
|
+
const sectionHierarchyRule = new SectionHierarchyRule({
|
|
299
|
+
uri: this.uri,
|
|
300
|
+
docId,
|
|
301
|
+
grammar: this.grammar,
|
|
302
|
+
sourceText,
|
|
303
|
+
});
|
|
304
|
+
const referencesSectionRule = new ReferencesSectionRule({
|
|
305
|
+
uri: this.uri,
|
|
306
|
+
docId,
|
|
307
|
+
grammar: this.grammar,
|
|
308
|
+
sourceText,
|
|
309
|
+
});
|
|
310
|
+
// Feed collected headings to ECR102 and ECR103
|
|
311
|
+
for (const headingData of collectedHeadings) {
|
|
312
|
+
// ECR102: register root H1 or evaluate sub-headings.
|
|
313
|
+
// The `## References` heading is a structural heading owned by ECR103,
|
|
314
|
+
// not a numbered section heading — it must be excluded from ECR102
|
|
315
|
+
// to avoid a spurious "not a valid SectionID" diagnostic.
|
|
316
|
+
if (headingData.depth === 1) {
|
|
317
|
+
sectionHierarchyRule.registerRootHeading(headingData);
|
|
318
|
+
}
|
|
319
|
+
else if (!this.tellReferencesHeading(headingData)) {
|
|
320
|
+
sectionHierarchyRule.evaluateHeading(headingData);
|
|
321
|
+
}
|
|
322
|
+
// ECR103: evaluate all headings for References heading detection
|
|
323
|
+
referencesSectionRule.evaluateHeading(headingData);
|
|
324
|
+
}
|
|
325
|
+
// Phase 3: Walk the AST again to feed list items to ECR103
|
|
326
|
+
// after the References heading has been detected
|
|
327
|
+
this.feedListItemsToReferencesRule(root, referencesSectionRule);
|
|
328
|
+
// Finalise ECR102 and ECR103
|
|
329
|
+
const sectionsResult = sectionHierarchyRule.finalise();
|
|
330
|
+
const referencesResult = referencesSectionRule.finalise();
|
|
331
|
+
return {
|
|
332
|
+
identityResult,
|
|
333
|
+
sectionsResult,
|
|
334
|
+
referencesResult,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Collects all heading nodes from the AST into the provided array.
|
|
339
|
+
*
|
|
340
|
+
* Walks the AST depth-first in document order, extracting
|
|
341
|
+
* {@link HeadingNodeData} from every node with `type === 'heading'`.
|
|
342
|
+
*
|
|
343
|
+
* @param node - The root of the walk
|
|
344
|
+
* @param headings - The accumulator array for collected heading data
|
|
345
|
+
*/
|
|
346
|
+
collectHeadings(node, headings) {
|
|
347
|
+
const pending = [node];
|
|
348
|
+
while (pending.length > 0) {
|
|
349
|
+
const current = pending.pop();
|
|
350
|
+
if (current === undefined) {
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
if (current.type === 'heading' && current.depth !== undefined) {
|
|
354
|
+
headings.push(this.extractHeadingNodeData(current));
|
|
355
|
+
}
|
|
356
|
+
PerDocumentVisitor.pushChildren(current, pending);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Reads a node's plain text: what a reader sees, with the formatting gone.
|
|
361
|
+
*
|
|
362
|
+
* @param node - The node to read
|
|
363
|
+
* @returns Its text, and its descendants' text, in order
|
|
364
|
+
*/
|
|
365
|
+
static showNodeText(node) {
|
|
366
|
+
// What `mdast-util-to-string` returns, gathered over a stack instead of
|
|
367
|
+
// by recursion: a node's own value, else an image's alternative text,
|
|
368
|
+
// else its children's text in order. That package recurses, so a heading
|
|
369
|
+
// of a few thousand nested emphasis spans -- which costs an author two
|
|
370
|
+
// characters a level -- ended the process inside it.
|
|
371
|
+
const parts = [];
|
|
372
|
+
const pending = [node];
|
|
373
|
+
while (pending.length > 0) {
|
|
374
|
+
const current = pending.pop();
|
|
375
|
+
if (current === undefined) {
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
if (current.value !== undefined) {
|
|
379
|
+
parts.push(current.value);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (typeof current.alt === 'string' && current.alt.length > 0) {
|
|
383
|
+
parts.push(current.alt);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
PerDocumentVisitor.pushChildren(current, pending);
|
|
387
|
+
}
|
|
388
|
+
return parts.join('');
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Puts a node's children on a walk's stack so that they come off it in
|
|
392
|
+
* document order.
|
|
393
|
+
*
|
|
394
|
+
* Every walk over a document's nodes keeps its own stack rather than
|
|
395
|
+
* calling itself, because nesting in Markdown costs the author almost
|
|
396
|
+
* nothing: a few thousand nested blockquotes, or nested bold spans, fit in
|
|
397
|
+
* a few kilobytes, and recursion over them exhausts the call stack and
|
|
398
|
+
* takes the process with it. A stack on the heap has no such limit.
|
|
399
|
+
*
|
|
400
|
+
* @param node - The node whose children are to be walked
|
|
401
|
+
* @param pending - The stack to push onto, from which nodes are taken with `pop`
|
|
402
|
+
*/
|
|
403
|
+
static pushChildren(node, pending) {
|
|
404
|
+
const children = node.children;
|
|
405
|
+
if (children === undefined) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
409
|
+
const child = children[index];
|
|
410
|
+
if (child !== undefined) {
|
|
411
|
+
pending.push(child);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Finds the References section and feeds its placement and entries to ECR103.
|
|
417
|
+
*
|
|
418
|
+
* The `## References` heading is looked for anywhere in the tree, not only
|
|
419
|
+
* among the root's children. A section nested in a blockquote or a list
|
|
420
|
+
* item used to be passed over, so its entries were silently discarded and
|
|
421
|
+
* the document reported as having an empty References section. It is now
|
|
422
|
+
* found, reported as misplaced (1#9.11 rule 3), and its entries still read.
|
|
423
|
+
*
|
|
424
|
+
* The entries are the items of the list that immediately follows the
|
|
425
|
+
* heading, or of a list that opens the container immediately following it
|
|
426
|
+
* -- which is also a placement violation.
|
|
427
|
+
*
|
|
428
|
+
* @param root - The parsed MDAST root node
|
|
429
|
+
* @param referencesSectionRule - The ECR103 rule instance to feed
|
|
430
|
+
*/
|
|
431
|
+
feedListItemsToReferencesRule(root, referencesSectionRule) {
|
|
432
|
+
if (!referencesSectionRule.tellReferencesHeadingDetected()) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const section = this.findReferencesSection(root);
|
|
436
|
+
if (section === undefined) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const next = section.siblings[section.index + 1];
|
|
440
|
+
const list = this.findReferencesList(next);
|
|
441
|
+
const nested = section.siblings !== root.children || (list !== undefined && list !== next);
|
|
442
|
+
referencesSectionRule.evaluateSectionPlacement(this.mapPosition(section.heading.position), nested);
|
|
443
|
+
for (const listItemNode of list?.children ?? []) {
|
|
444
|
+
if (listItemNode.type === 'listItem') {
|
|
445
|
+
referencesSectionRule.evaluateListItem(this.extractListItemNodeData(listItemNode));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Finds the list holding the References entries, given the node after the
|
|
451
|
+
* heading: that node itself, or a list opening the container it is, at any
|
|
452
|
+
* depth of nesting.
|
|
453
|
+
*
|
|
454
|
+
* Looking one container deep lost `> > - 8.1 - …`: the list was never
|
|
455
|
+
* found, so its entries were discarded and the section reported as empty.
|
|
456
|
+
*
|
|
457
|
+
* @param next - The node immediately following the `## References` heading
|
|
458
|
+
* @returns The entries' list, or `undefined` when there is none
|
|
459
|
+
*/
|
|
460
|
+
findReferencesList(next) {
|
|
461
|
+
let candidate = next;
|
|
462
|
+
while (candidate !== undefined && candidate.type !== 'heading') {
|
|
463
|
+
if (candidate.type === 'list') {
|
|
464
|
+
return candidate;
|
|
465
|
+
}
|
|
466
|
+
candidate = candidate.children?.[0];
|
|
467
|
+
}
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Finds the first `## References` heading in document order, at any depth.
|
|
472
|
+
*
|
|
473
|
+
* @param node - The node to search from
|
|
474
|
+
* @returns The heading and its position among its siblings, or `undefined`
|
|
475
|
+
*/
|
|
476
|
+
findReferencesSection(node) {
|
|
477
|
+
// Each frame is a list of siblings and how far through it the walk is, so
|
|
478
|
+
// that a match can report the position among its siblings that the caller
|
|
479
|
+
// needs. Depth-first and pre-order, as the recursive form was.
|
|
480
|
+
const frames = [{ children: node.children ?? [], index: 0 }];
|
|
481
|
+
while (frames.length > 0) {
|
|
482
|
+
const frame = frames.pop();
|
|
483
|
+
if (frame === undefined) {
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
const child = frame.children[frame.index];
|
|
487
|
+
if (child === undefined) {
|
|
488
|
+
// These siblings are exhausted, so the frame is not put back.
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
// The rest of these siblings come after everything beneath this child,
|
|
492
|
+
// so they go back on the stack before it.
|
|
493
|
+
frames.push({ children: frame.children, index: frame.index + 1 });
|
|
494
|
+
if (child.type === 'heading' && this.tellReferencesHeading(this.extractHeadingNodeData(child))) {
|
|
495
|
+
return { heading: child, siblings: frame.children, index: frame.index };
|
|
496
|
+
}
|
|
497
|
+
frames.push({ children: child.children ?? [], index: 0 });
|
|
498
|
+
}
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
// -------------------------------------------------------------------------
|
|
502
|
+
// Private: Second pass -- inline references (ECR104)
|
|
503
|
+
// -------------------------------------------------------------------------
|
|
504
|
+
/**
|
|
505
|
+
* Executes the second AST traversal pass: inline reference detection.
|
|
506
|
+
*
|
|
507
|
+
* This pass walks the AST in document order using a custom recursive
|
|
508
|
+
* traversal that tracks ancestor node types and:
|
|
509
|
+
* - Tracks the current section context (the most recently encountered
|
|
510
|
+
* heading identifier), starting with the DocID before any H2 heading
|
|
511
|
+
* - For each text node encountered, checks whether the node's ancestor
|
|
512
|
+
* chain includes any excluded node types (`code`, `inlineCode`, `html`)
|
|
513
|
+
* - Text nodes whose ancestors include excluded types are skipped
|
|
514
|
+
* - Valid text nodes are fed to ECR104
|
|
515
|
+
* (`InlineReferenceRule.evaluateTextNode`) with the current
|
|
516
|
+
* section context
|
|
517
|
+
* - After traversal, ECR104 is finalised
|
|
518
|
+
*
|
|
519
|
+
* @param root - The parsed MDAST root node
|
|
520
|
+
* @param docId - The document's established DocID
|
|
521
|
+
* @param declaredDocIds - The set of DocIDs declared in the References section
|
|
522
|
+
* @param sourceText - The document's raw Markdown, for the source-form checks of 1#9.11
|
|
523
|
+
* @returns The intermediate result from the inline references pass
|
|
524
|
+
*/
|
|
525
|
+
executeInlineReferencesPass(root, docId, declaredDocIds, sourceText) {
|
|
526
|
+
const inlineReferenceRule = new InlineReferenceRule({
|
|
527
|
+
uri: this.uri,
|
|
528
|
+
docId,
|
|
529
|
+
grammar: this.grammar,
|
|
530
|
+
declaredDocIds,
|
|
531
|
+
sourceText,
|
|
532
|
+
});
|
|
533
|
+
// Track the current section context -- starts with the DocID.
|
|
534
|
+
// The mutable wrapper allows the recursive walk callback to update
|
|
535
|
+
// the section context as headings are encountered.
|
|
536
|
+
const sectionContext = { current: docId };
|
|
537
|
+
// Walk the AST recursively, tracking ancestor types for exclusion filtering.
|
|
538
|
+
// When a heading is encountered, update the section context.
|
|
539
|
+
// When a text node is encountered (not excluded by ancestors), feed to ECR104.
|
|
540
|
+
this.walkNodesForInlineReferences(root, sectionContext, inlineReferenceRule);
|
|
541
|
+
const inlineResult = inlineReferenceRule.finalise();
|
|
542
|
+
return { inlineResult };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Walks the AST to find inline runs for ECR104, tracking whether an
|
|
546
|
+
* excluded ancestor encloses the node and updating section context when
|
|
547
|
+
* headings are encountered.
|
|
548
|
+
*
|
|
549
|
+
* A paragraph is handed to ECR104 whole, as one inline run, because a
|
|
550
|
+
* citation is recognised in the text a reader sees and not one parsed text
|
|
551
|
+
* node at a time (1#9.5 rule 1). Its children are not walked separately.
|
|
552
|
+
*
|
|
553
|
+
* A hand-written depth-first, pre-order walk over an explicit stack: document
|
|
554
|
+
* order decides which section each run belongs to, and see
|
|
555
|
+
* {@link PerDocumentVisitor.pushChildren} for why the stack is not the call
|
|
556
|
+
* stack. Exclusion is carried down as one flag, because a node is excluded
|
|
557
|
+
* exactly when some ancestor's type is, so nothing is gained by keeping the
|
|
558
|
+
* ancestors themselves.
|
|
559
|
+
*
|
|
560
|
+
* @param root - The root of the walk
|
|
561
|
+
* @param sectionContext - Mutable wrapper holding the current section context
|
|
562
|
+
* @param inlineReferenceRule - The ECR104 rule instance to feed runs to
|
|
563
|
+
*/
|
|
564
|
+
walkNodesForInlineReferences(root, sectionContext, inlineReferenceRule) {
|
|
565
|
+
const pending = [{ node: root, excluded: false }];
|
|
566
|
+
while (pending.length > 0) {
|
|
567
|
+
const entry = pending.pop();
|
|
568
|
+
if (entry === undefined) {
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
const { node, excluded } = entry;
|
|
572
|
+
if (node.type === 'heading' && node.depth !== undefined) {
|
|
573
|
+
sectionContext.current = this.determineSectionContext(PerDocumentVisitor.showNodeText(node), node.depth, sectionContext.current);
|
|
574
|
+
// Do not walk into heading children for text node extraction;
|
|
575
|
+
// heading text is not subject to inline reference detection.
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (INLINE_RUN_NODE_TYPES.has(node.type) && node.children !== undefined) {
|
|
579
|
+
if (!excluded) {
|
|
580
|
+
const segments = [];
|
|
581
|
+
for (const child of node.children) {
|
|
582
|
+
this.collectInlineSegments(child, [], segments);
|
|
583
|
+
}
|
|
584
|
+
inlineReferenceRule.evaluateInlineRun(segments, sectionContext.current);
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
// A text node outside any run is not expected from the parser, but if one
|
|
589
|
+
// appears it is still evaluated, as a run of its own.
|
|
590
|
+
if (node.type === 'text' && node.value !== undefined) {
|
|
591
|
+
if (!excluded) {
|
|
592
|
+
const textNodeData = this.extractTextNodeData(node);
|
|
593
|
+
inlineReferenceRule.evaluateTextNode(textNodeData, sectionContext.current);
|
|
594
|
+
}
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
PerDocumentVisitor.pushInlineWalkChildren(node, excluded, pending);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Puts a node's children on the inline walk's stack, in document order,
|
|
602
|
+
* noting whether an excluded ancestor now encloses them.
|
|
603
|
+
*
|
|
604
|
+
* @param node - The node whose children are to be walked
|
|
605
|
+
* @param excluded - Whether an excluded ancestor already encloses the node
|
|
606
|
+
* @param pending - The stack to push onto
|
|
607
|
+
*/
|
|
608
|
+
static pushInlineWalkChildren(node, excluded, pending) {
|
|
609
|
+
const children = node.children;
|
|
610
|
+
if (children === undefined) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const childrenExcluded = excluded || EXCLUDED_ANCESTOR_NODE_TYPES.has(node.type);
|
|
614
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
615
|
+
const child = children[index];
|
|
616
|
+
if (child !== undefined) {
|
|
617
|
+
pending.push({ node: child, excluded: childrenExcluded });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Flattens one inline node into the segments a reader sees, in order.
|
|
623
|
+
*
|
|
624
|
+
* Text becomes a `text` segment carrying its position, and inline code a
|
|
625
|
+
* `code` segment. Formatting contributes its contents, each segment noting
|
|
626
|
+
* the spans around it. A hard break, an image, and a `<br>` tag become a
|
|
627
|
+
* `break` of one space, because a reader sees the words either side of them
|
|
628
|
+
* as separate. Any other inline HTML -- a comment, or a tag such as
|
|
629
|
+
* `<span>` -- contributes nothing, because it separates nothing:
|
|
630
|
+
* `60<span>s</span>` reads as `60s`.
|
|
631
|
+
*
|
|
632
|
+
* @param node - An inline node
|
|
633
|
+
* @param wrappers - Types of the formatting spans enclosing it, outermost first
|
|
634
|
+
* @param segments - Accumulator the segments are appended to
|
|
635
|
+
*/
|
|
636
|
+
collectInlineSegments(node, wrappers, segments) {
|
|
637
|
+
const pending = [{ node, wrappers }];
|
|
638
|
+
while (pending.length > 0) {
|
|
639
|
+
const entry = pending.pop();
|
|
640
|
+
if (entry === undefined) {
|
|
641
|
+
break;
|
|
642
|
+
}
|
|
643
|
+
this.collectInlineSegment(entry, segments, pending);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Turns one inline node into segments, or puts its children on the stack
|
|
648
|
+
* to be turned into segments in their turn.
|
|
649
|
+
*
|
|
650
|
+
* @param entry - The node and the formatting spans enclosing it
|
|
651
|
+
* @param segments - Accumulator the segments are appended to
|
|
652
|
+
* @param pending - The walk's stack, which children are pushed onto in document order
|
|
653
|
+
*/
|
|
654
|
+
collectInlineSegment(entry, segments, pending) {
|
|
655
|
+
const { node, wrappers } = entry;
|
|
656
|
+
if (node.type === 'text') {
|
|
657
|
+
const range = this.mapPosition(node.position);
|
|
658
|
+
segments.push({
|
|
659
|
+
kind: 'text',
|
|
660
|
+
text: node.value ?? '',
|
|
661
|
+
wrappers,
|
|
662
|
+
...(range !== undefined ? { range } : {}),
|
|
663
|
+
});
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (node.type === 'inlineCode') {
|
|
667
|
+
segments.push({ kind: 'code', text: node.value ?? '', wrappers });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
if (node.type === 'html' && !LINE_BREAK_HTML.test((node.value ?? '').trim())) {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (node.type !== 'html' && node.children !== undefined) {
|
|
674
|
+
const inner = [...wrappers, node.type];
|
|
675
|
+
for (let index = node.children.length - 1; index >= 0; index -= 1) {
|
|
676
|
+
const child = node.children[index];
|
|
677
|
+
if (child !== undefined) {
|
|
678
|
+
pending.push({ node: child, wrappers: inner });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
segments.push({ kind: 'break', text: ' ', wrappers });
|
|
684
|
+
}
|
|
685
|
+
// -------------------------------------------------------------------------
|
|
686
|
+
// Private: Section context tracking
|
|
687
|
+
// -------------------------------------------------------------------------
|
|
688
|
+
/**
|
|
689
|
+
* Determines the current section context identifier for a given heading.
|
|
690
|
+
*
|
|
691
|
+
* The section context is the identifier (DocID or SectionID) of the most
|
|
692
|
+
* recently encountered numbered heading. Before any H2 heading, the context
|
|
693
|
+
* is the DocID. After an H2 or deeper heading, the context is the
|
|
694
|
+
* SectionID of that heading.
|
|
695
|
+
*
|
|
696
|
+
* This method extracts the identifier from the heading text using the
|
|
697
|
+
* grammar's separator convention. If the heading text does not contain
|
|
698
|
+
* a valid identifier, the previous context is retained.
|
|
699
|
+
*
|
|
700
|
+
* @param headingText - The plain text content of the heading node
|
|
701
|
+
* @param headingDepth - The Markdown heading depth (1--6)
|
|
702
|
+
* @param currentContext - The current section context before this heading
|
|
703
|
+
* @returns The updated section context identifier
|
|
704
|
+
*/
|
|
705
|
+
determineSectionContext(headingText, headingDepth,
|
|
706
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: context may be DocID or SectionID
|
|
707
|
+
currentContext) {
|
|
708
|
+
// Only headings with depth >= 2 can change the section context
|
|
709
|
+
// (H1 establishes the DocID, which is the initial context)
|
|
710
|
+
if (headingDepth < 2) {
|
|
711
|
+
return currentContext;
|
|
712
|
+
}
|
|
713
|
+
// A sub-heading carries a SectionID, so it is parsed with the SectionID
|
|
714
|
+
// heading grammar rather than the DocID one an H1 uses.
|
|
715
|
+
const parseResult = this.grammar.parseSectionHeading(headingText);
|
|
716
|
+
if (parseResult.valid) {
|
|
717
|
+
return parseResult.sectionId;
|
|
718
|
+
}
|
|
719
|
+
return currentContext;
|
|
720
|
+
}
|
|
721
|
+
// -------------------------------------------------------------------------
|
|
722
|
+
// Private: References heading detection
|
|
723
|
+
// -------------------------------------------------------------------------
|
|
724
|
+
/**
|
|
725
|
+
* Determines whether a heading is the `## References` structural heading.
|
|
726
|
+
*
|
|
727
|
+
* The `## References` heading is owned by ECR103 and should not be
|
|
728
|
+
* evaluated by ECR102 (Section Hierarchy), because it is not a numbered
|
|
729
|
+
* section heading and would fail SectionID parsing.
|
|
730
|
+
*
|
|
731
|
+
* Per 1#9.6, the References heading is identified by depth === 2
|
|
732
|
+
* and text content exactly equal to `"References"`.
|
|
733
|
+
*
|
|
734
|
+
* @param headingData - The heading node data to check
|
|
735
|
+
* @returns `true` if the heading is the `## References` heading, `false` otherwise
|
|
736
|
+
*/
|
|
737
|
+
tellReferencesHeading(headingData) {
|
|
738
|
+
return headingData.depth === 2 && headingData.text === 'References';
|
|
739
|
+
}
|
|
740
|
+
// -------------------------------------------------------------------------
|
|
741
|
+
// Private: Text extraction from list items
|
|
742
|
+
// -------------------------------------------------------------------------
|
|
743
|
+
/**
|
|
744
|
+
* Extracts the plain text content from a list item AST node.
|
|
745
|
+
*
|
|
746
|
+
* Gathers the text of the list item's child nodes into a single plain text
|
|
747
|
+
* string suitable for feeding to ECR103.
|
|
748
|
+
*
|
|
749
|
+
* @param listItemNode - The MDAST list item node to extract text from
|
|
750
|
+
* @returns The extracted {@link ListItemNodeData} with text and optional range
|
|
751
|
+
*/
|
|
752
|
+
extractListItemNodeData(listItemNode) {
|
|
753
|
+
const segments = [];
|
|
754
|
+
this.collectListItemSegments(listItemNode, segments);
|
|
755
|
+
const text = segments.map((segment) => segment.text).join('');
|
|
756
|
+
const range = this.mapPosition(listItemNode.position);
|
|
757
|
+
const listItemData = {
|
|
758
|
+
text,
|
|
759
|
+
segments,
|
|
760
|
+
...(range !== undefined ? { range } : {}),
|
|
761
|
+
};
|
|
762
|
+
return listItemData;
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Classifies a literal node for tracing its text back to the source.
|
|
766
|
+
*
|
|
767
|
+
* @param type - The node's MDAST type
|
|
768
|
+
* @returns `text` for a text node, `code` for inline code, `other` otherwise
|
|
769
|
+
*/
|
|
770
|
+
static showSegmentKind(type) {
|
|
771
|
+
if (type === 'text') {
|
|
772
|
+
return 'text';
|
|
773
|
+
}
|
|
774
|
+
return type === 'inlineCode' ? 'code' : 'other';
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Splits a list item's text into the parsed nodes it comes from.
|
|
778
|
+
*
|
|
779
|
+
* Follows `mdast-util-to-string` exactly -- a node's `value`, else an
|
|
780
|
+
* image's `alt`, else its children in order -- so that joining the
|
|
781
|
+
* segments reproduces the entry text the rule parses, and an offset in that
|
|
782
|
+
* text identifies the node, and so the source, it came from.
|
|
783
|
+
*
|
|
784
|
+
* @param node - A node within the list item
|
|
785
|
+
* @param segments - Accumulator the segments are appended to
|
|
786
|
+
*/
|
|
787
|
+
collectListItemSegments(node, segments) {
|
|
788
|
+
const pending = [node];
|
|
789
|
+
while (pending.length > 0) {
|
|
790
|
+
const current = pending.pop();
|
|
791
|
+
if (current === undefined) {
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
const range = this.mapPosition(current.position);
|
|
795
|
+
const withRange = range !== undefined ? { range } : {};
|
|
796
|
+
if (current.value !== undefined) {
|
|
797
|
+
segments.push({
|
|
798
|
+
kind: PerDocumentVisitor.showSegmentKind(current.type),
|
|
799
|
+
text: current.value,
|
|
800
|
+
...withRange,
|
|
801
|
+
});
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
if (typeof current.alt === 'string' && current.alt.length > 0) {
|
|
805
|
+
segments.push({ kind: 'other', text: current.alt, ...withRange });
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
PerDocumentVisitor.pushChildren(current, pending);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
// -------------------------------------------------------------------------
|
|
812
|
+
// Private: Heading data extraction
|
|
813
|
+
// -------------------------------------------------------------------------
|
|
814
|
+
/**
|
|
815
|
+
* Extracts heading node data from an MDAST heading node.
|
|
816
|
+
*
|
|
817
|
+
* Reads the plain text content with {@link PerDocumentVisitor.showNodeText}
|
|
818
|
+
* and maps the MDAST position to a {@link HeadingNodeData} structure
|
|
819
|
+
* suitable for consumption by ECR101, ECR102, and ECR103.
|
|
820
|
+
*
|
|
821
|
+
* @param headingNode - The MDAST heading node to extract data from
|
|
822
|
+
* @returns The extracted {@link HeadingNodeData} with depth, text, and optional range
|
|
823
|
+
*/
|
|
824
|
+
extractHeadingNodeData(headingNode) {
|
|
825
|
+
const text = PerDocumentVisitor.showNodeText(headingNode);
|
|
826
|
+
const range = this.mapPosition(headingNode.position);
|
|
827
|
+
const headingData = {
|
|
828
|
+
depth: headingNode.depth ?? 1,
|
|
829
|
+
text,
|
|
830
|
+
...(range !== undefined ? { range } : {}),
|
|
831
|
+
};
|
|
832
|
+
return headingData;
|
|
833
|
+
}
|
|
834
|
+
// -------------------------------------------------------------------------
|
|
835
|
+
// Private: Text node data extraction
|
|
836
|
+
// -------------------------------------------------------------------------
|
|
837
|
+
/**
|
|
838
|
+
* Extracts text node data from an MDAST text node.
|
|
839
|
+
*
|
|
840
|
+
* Maps the MDAST text node's value and position to a {@link TextNodeData}
|
|
841
|
+
* structure suitable for consumption by ECR104.
|
|
842
|
+
*
|
|
843
|
+
* @param textNode - The MDAST text node to extract data from
|
|
844
|
+
* @returns The extracted {@link TextNodeData} with text and optional range
|
|
845
|
+
*/
|
|
846
|
+
extractTextNodeData(textNode) {
|
|
847
|
+
const range = this.mapPosition(textNode.position);
|
|
848
|
+
const textNodeData = {
|
|
849
|
+
text: textNode.value ?? '',
|
|
850
|
+
...(range !== undefined ? { range } : {}),
|
|
851
|
+
};
|
|
852
|
+
return textNodeData;
|
|
853
|
+
}
|
|
854
|
+
// -------------------------------------------------------------------------
|
|
855
|
+
// Private: Declared DocID extraction
|
|
856
|
+
// -------------------------------------------------------------------------
|
|
857
|
+
/**
|
|
858
|
+
* Extracts the set of declared DocIDs from a completed ECR103 result.
|
|
859
|
+
*
|
|
860
|
+
* Iterates over the extracted {@link ReferenceEdge} artefacts and
|
|
861
|
+
* collects the `toDocId` field from each edge into a `ReadonlySet<DocID>`.
|
|
862
|
+
* This set is used to initialise ECR104 for undeclared reference detection.
|
|
863
|
+
*
|
|
864
|
+
* @param references - The extracted reference edges from ECR103
|
|
865
|
+
* @returns The set of declared DocIDs
|
|
866
|
+
*/
|
|
867
|
+
extractDeclaredDocIds(references) {
|
|
868
|
+
const declaredDocIds = new Set();
|
|
869
|
+
for (const referenceEdge of references) {
|
|
870
|
+
declaredDocIds.add(referenceEdge.toDocId);
|
|
871
|
+
}
|
|
872
|
+
return declaredDocIds;
|
|
873
|
+
}
|
|
874
|
+
// -------------------------------------------------------------------------
|
|
875
|
+
// Private: Position mapping
|
|
876
|
+
// -------------------------------------------------------------------------
|
|
877
|
+
/**
|
|
878
|
+
* Maps an MDAST position to a {@link PositionRange}, if available.
|
|
879
|
+
*
|
|
880
|
+
* MDAST positions use 1-based lines and 1-based columns, while
|
|
881
|
+
* {@link PositionRange} uses 0-based lines and 0-based characters.
|
|
882
|
+
* This method performs the conversion.
|
|
883
|
+
*
|
|
884
|
+
* @param mdastPosition - The MDAST position object, or `undefined` if
|
|
885
|
+
* positional metadata is not available
|
|
886
|
+
* @returns The mapped position range, or `undefined` if no position is available
|
|
887
|
+
*/
|
|
888
|
+
mapPosition(mdastPosition) {
|
|
889
|
+
if (mdastPosition === undefined) {
|
|
890
|
+
return undefined;
|
|
891
|
+
}
|
|
892
|
+
const mappedRange = {
|
|
893
|
+
start: {
|
|
894
|
+
line: mdastPosition.start.line - 1,
|
|
895
|
+
character: mdastPosition.start.column - 1,
|
|
896
|
+
},
|
|
897
|
+
end: {
|
|
898
|
+
line: mdastPosition.end.line - 1,
|
|
899
|
+
character: mdastPosition.end.column - 1,
|
|
900
|
+
},
|
|
901
|
+
};
|
|
902
|
+
return mappedRange;
|
|
903
|
+
}
|
|
904
|
+
// -------------------------------------------------------------------------
|
|
905
|
+
// Private: Result assembly
|
|
906
|
+
// -------------------------------------------------------------------------
|
|
907
|
+
/**
|
|
908
|
+
* Assembles the composite {@link LintResult} from individual rule results.
|
|
909
|
+
*
|
|
910
|
+
* Aggregates diagnostics from all rules, determines the `ok` status
|
|
911
|
+
* (true when no error-severity diagnostics are present), and constructs
|
|
912
|
+
* the {@link ExtractedDocument} when a valid DocID was recovered.
|
|
913
|
+
*
|
|
914
|
+
* @param identityResult - The result from ECR101
|
|
915
|
+
* @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
|
|
916
|
+
* @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
|
|
917
|
+
* @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
|
|
918
|
+
* @returns The composite lint result
|
|
919
|
+
*/
|
|
920
|
+
assembleLintResult(identityResult, sectionsResult, referencesResult, inlineResult) {
|
|
921
|
+
// Aggregate all diagnostics
|
|
922
|
+
const allDiagnostics = this.aggregateDiagnostics(identityResult, sectionsResult, referencesResult, inlineResult);
|
|
923
|
+
const input = this.buildLintInput();
|
|
924
|
+
const isPassable = this.tellAllDiagnosticsPassable(allDiagnostics);
|
|
925
|
+
// Construct extracted document if identity is valid
|
|
926
|
+
if (identityResult.identity !== undefined) {
|
|
927
|
+
const identity = identityResult.identity;
|
|
928
|
+
const sections = sectionsResult !== undefined ? sectionsResult.sections : [];
|
|
929
|
+
const references = referencesResult !== undefined ? referencesResult.references : [];
|
|
930
|
+
const inlineReferences = inlineResult !== undefined ? inlineResult.inlineReferences : [];
|
|
931
|
+
const extracted = this.buildExtractedDocument(identity, sections, references, inlineReferences);
|
|
932
|
+
return {
|
|
933
|
+
input,
|
|
934
|
+
ok: isPassable,
|
|
935
|
+
diagnostics: allDiagnostics,
|
|
936
|
+
extracted,
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
return {
|
|
940
|
+
input,
|
|
941
|
+
ok: isPassable,
|
|
942
|
+
diagnostics: allDiagnostics,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Aggregates diagnostics from all rule results into a single array.
|
|
947
|
+
*
|
|
948
|
+
* Collects diagnostics from ECR101, ECR102, ECR103, and ECR104 in that
|
|
949
|
+
* order. Rules that did not run (undefined results) contribute no
|
|
950
|
+
* diagnostics.
|
|
951
|
+
*
|
|
952
|
+
* @param identityResult - The result from ECR101
|
|
953
|
+
* @param sectionsResult - The result from ECR102 (undefined if ECR101 failed)
|
|
954
|
+
* @param referencesResult - The result from ECR103 (undefined if ECR101 failed)
|
|
955
|
+
* @param inlineResult - The result from ECR104 (undefined if ECR101 failed)
|
|
956
|
+
* @returns The aggregated array of all diagnostics
|
|
957
|
+
*/
|
|
958
|
+
aggregateDiagnostics(identityResult, sectionsResult, referencesResult, inlineResult) {
|
|
959
|
+
const allDiagnostics = [];
|
|
960
|
+
for (const diagnostic of identityResult.diagnostics) {
|
|
961
|
+
allDiagnostics.push(diagnostic);
|
|
962
|
+
}
|
|
963
|
+
if (sectionsResult !== undefined) {
|
|
964
|
+
for (const diagnostic of sectionsResult.diagnostics) {
|
|
965
|
+
allDiagnostics.push(diagnostic);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (referencesResult !== undefined) {
|
|
969
|
+
for (const diagnostic of referencesResult.diagnostics) {
|
|
970
|
+
allDiagnostics.push(diagnostic);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
if (inlineResult !== undefined) {
|
|
974
|
+
for (const diagnostic of inlineResult.diagnostics) {
|
|
975
|
+
allDiagnostics.push(diagnostic);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return allDiagnostics;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Constructs the {@link LintInput} descriptor for the result.
|
|
982
|
+
*
|
|
983
|
+
* Echoes the visitor's URI and optional version into the standard
|
|
984
|
+
* input descriptor shape.
|
|
985
|
+
*
|
|
986
|
+
* @returns The lint input descriptor
|
|
987
|
+
*/
|
|
988
|
+
buildLintInput() {
|
|
989
|
+
const input = {
|
|
990
|
+
uri: this.uri,
|
|
991
|
+
...(this.version !== undefined ? { version: this.version } : {}),
|
|
992
|
+
};
|
|
993
|
+
return input;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Determines the composite `ok` status from an aggregated diagnostics array.
|
|
997
|
+
*
|
|
998
|
+
* The result is `ok` (true) when no diagnostics have severity `"error"`.
|
|
999
|
+
* Any error-severity diagnostic causes the result to be not-ok (false).
|
|
1000
|
+
*
|
|
1001
|
+
* @param diagnostics - The full array of aggregated diagnostics from all rules
|
|
1002
|
+
* @returns `true` if no error diagnostics are present, `false` otherwise
|
|
1003
|
+
*/
|
|
1004
|
+
tellAllDiagnosticsPassable(diagnostics) {
|
|
1005
|
+
return !diagnostics.some((diagnostic) => diagnostic.severity === 'error');
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Constructs the {@link ExtractedDocument} from individual rule extraction
|
|
1009
|
+
* results.
|
|
1010
|
+
*
|
|
1011
|
+
* Assembles the document identity (DocID and title), section nodes,
|
|
1012
|
+
* reference edges, and inline reference edges into the canonical
|
|
1013
|
+
* extracted document shape.
|
|
1014
|
+
*
|
|
1015
|
+
* @param identity - The document identity from ECR101
|
|
1016
|
+
* @param sections - The section nodes from ECR102
|
|
1017
|
+
* @param references - The reference edges from ECR103
|
|
1018
|
+
* @param inlineReferences - The inline reference edges from ECR104
|
|
1019
|
+
* @returns The assembled extracted document
|
|
1020
|
+
*/
|
|
1021
|
+
buildExtractedDocument(identity, sections, references, inlineReferences) {
|
|
1022
|
+
const extracted = {
|
|
1023
|
+
docId: identity.docId,
|
|
1024
|
+
title: identity.title,
|
|
1025
|
+
sections,
|
|
1026
|
+
references,
|
|
1027
|
+
inlineReferences,
|
|
1028
|
+
};
|
|
1029
|
+
return extracted;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
//# sourceMappingURL=per-document-visitor.js.map
|