@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,705 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section Hierarchy Rule (ECR102)
|
|
3
|
+
*
|
|
4
|
+
* Validates the section structure constraint defined in the ECR specification:
|
|
5
|
+
* - 1#9.4 -- Section Structure Rules
|
|
6
|
+
* - 1#9.8 -- Per-Document Structural Invariants ("SectionIDs unique within document")
|
|
7
|
+
*
|
|
8
|
+
* This rule consumes heading node data from an AST traversal and validates
|
|
9
|
+
* the section hierarchy for headings with depth \>= 2. It delegates identifier
|
|
10
|
+
* parsing and segment-count logic to {@link IdentifierGrammar} and emits
|
|
11
|
+
* {@link Diagnostic} objects for any violations.
|
|
12
|
+
*
|
|
13
|
+
* Validations performed:
|
|
14
|
+
* 1. A separator (any dash variant) must follow the identifier
|
|
15
|
+
* 2. Heading text prefix must parse as a valid SectionID
|
|
16
|
+
* 3. The SectionID's DocID must equal the document's DocID
|
|
17
|
+
* 4. The section path must have d - 1 segments, where d = heading depth
|
|
18
|
+
* 5. SectionIDs must be unique within the document (no duplicates)
|
|
19
|
+
* 6. Heading levels must not be skipped (e.g., H3 directly after H1 is invalid)
|
|
20
|
+
*
|
|
21
|
+
* Extracted artefacts:
|
|
22
|
+
* - {@link SectionNode} for each valid heading, with parentId derived from the heading stack
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { DocID, SectionID, Diagnostic, DiagnosticSeverity, PositionRange, SectionNode } from './types.js';
|
|
26
|
+
import type {
|
|
27
|
+
SectionIdParseResult,
|
|
28
|
+
SeparatorValidationResult,
|
|
29
|
+
} from './identifier-grammar.js';
|
|
30
|
+
import { IdentifierGrammar } from './identifier-grammar.js';
|
|
31
|
+
import type { HeadingNodeData } from './document-identity-rule.js';
|
|
32
|
+
import { HEADING_SOURCE_FORM_CAUSE, HeadingSourceForm } from './heading-source-form.js';
|
|
33
|
+
import type { HeadingObstruction } from './heading-source-form.js';
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Rule identifier constant
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Canonical rule identifier for the Section Hierarchy Rule.
|
|
41
|
+
*
|
|
42
|
+
* Referenced as [ECR102] in the ECR specification (1#9.4).
|
|
43
|
+
*/
|
|
44
|
+
export const SECTION_HIERARCHY_RULE_ID: string = 'ECR102';
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Constructor options
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Configuration options for constructing a {@link SectionHierarchyRule} instance.
|
|
52
|
+
*/
|
|
53
|
+
export interface SectionHierarchyRuleOptions {
|
|
54
|
+
/**
|
|
55
|
+
* The opaque, host-provided URI identifying the document being validated.
|
|
56
|
+
* Attached to all emitted diagnostics.
|
|
57
|
+
*/
|
|
58
|
+
readonly uri: string;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The DocID established by the {@link DocumentIdentityRule} for this document.
|
|
62
|
+
* Used as the required prefix for all SectionIDs and to compute expected segment counts.
|
|
63
|
+
*/
|
|
64
|
+
readonly docId: DocID;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The {@link IdentifierGrammar} instance used for SectionID parsing,
|
|
68
|
+
* prefix validation, segment counting, and separator validation.
|
|
69
|
+
*/
|
|
70
|
+
readonly grammar: IdentifierGrammar;
|
|
71
|
+
/**
|
|
72
|
+
* The document's raw Markdown, for the heading source-form check of 1#9.11
|
|
73
|
+
* rule 1. When absent, as in unit tests that supply only parsed heading
|
|
74
|
+
* text, the check is skipped.
|
|
75
|
+
*/
|
|
76
|
+
readonly sourceText?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Result type
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The complete result produced by finalising the Section Hierarchy Rule
|
|
85
|
+
* after all headings have been evaluated.
|
|
86
|
+
*
|
|
87
|
+
* Contains zero or more diagnostics and the list of successfully extracted
|
|
88
|
+
* section nodes, including the root H1 node.
|
|
89
|
+
*/
|
|
90
|
+
export interface SectionHierarchyRuleResult {
|
|
91
|
+
/** Diagnostics emitted during evaluation (errors for violations). */
|
|
92
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Section nodes extracted from valid headings, in document traversal order.
|
|
96
|
+
*
|
|
97
|
+
* Includes the root H1 node (whose id is the DocID and has no parentId)
|
|
98
|
+
* and all valid sub-heading nodes with their derived parentId.
|
|
99
|
+
*/
|
|
100
|
+
readonly sections: readonly SectionNode[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Internal heading stack entry
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Internal record tracking a heading in the depth-aware heading stack.
|
|
109
|
+
*
|
|
110
|
+
* The heading stack enables correct parent-child derivation across
|
|
111
|
+
* heading depth transitions. Each entry records the identifier and
|
|
112
|
+
* depth of a heading that may serve as the parent of subsequent
|
|
113
|
+
* deeper headings.
|
|
114
|
+
*/
|
|
115
|
+
interface HeadingStackEntry {
|
|
116
|
+
/** The DocID (for H1) or SectionID (for depth \>= 2) of this heading. */
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: DocID for H1, SectionID for depth >= 2
|
|
118
|
+
readonly id: DocID | SectionID;
|
|
119
|
+
|
|
120
|
+
/** The Markdown heading depth (1 for H1, 2 for H2, etc.). */
|
|
121
|
+
readonly depth: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Diagnostic severity constant
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The severity used for all Section Hierarchy Rule diagnostics.
|
|
130
|
+
*
|
|
131
|
+
* Per 1#9.8, violation of any per-document structural invariant is an ERROR.
|
|
132
|
+
*/
|
|
133
|
+
const SECTION_HIERARCHY_DIAGNOSTIC_SEVERITY: DiagnosticSeverity = 'error';
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Separator pattern
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Matches the separator between a SectionID and its title: a dash with any
|
|
141
|
+
* surrounding whitespace.
|
|
142
|
+
*
|
|
143
|
+
* The dash may be a hyphen-minus (U+002D), an en dash (U+2013) or an em dash
|
|
144
|
+
* (U+2014); the variant carries no structural meaning.
|
|
145
|
+
*/
|
|
146
|
+
const SECTION_SEPARATOR_PATTERN: RegExp = /\s*[-–—]\s*/;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Matches heading text that begins with a number, i.e. one that at least
|
|
150
|
+
* attempts an identifier.
|
|
151
|
+
*/
|
|
152
|
+
const STARTS_WITH_DIGIT_PATTERN: RegExp = /^\s*\d/;
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Rule class
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validates the Section Hierarchy Rule as defined in the ECR specification (1#9.4, 1#9.8).
|
|
160
|
+
*
|
|
161
|
+
* The rule enforces that every heading with depth \>= 2 has a valid SectionID
|
|
162
|
+
* whose DocID is the document's own, carries a separator, has a section path of
|
|
163
|
+
* one segment per heading level below the H1, and is unique within the document.
|
|
164
|
+
*
|
|
165
|
+
* For each valid heading, the rule extracts a {@link SectionNode} with its
|
|
166
|
+
* parentId derived from the heading stack (the nearest preceding heading at
|
|
167
|
+
* depth - 1).
|
|
168
|
+
*
|
|
169
|
+
* The root H1 heading is registered via {@link registerRootHeading} to establish
|
|
170
|
+
* the heading stack base and produce the root {@link SectionNode}.
|
|
171
|
+
*
|
|
172
|
+
* Usage:
|
|
173
|
+
* 1. Construct a rule instance with the document URI, established DocID,
|
|
174
|
+
* and an {@link IdentifierGrammar}.
|
|
175
|
+
* 2. Call {@link registerRootHeading} once with the H1 heading data to
|
|
176
|
+
* establish the root of the heading stack.
|
|
177
|
+
* 3. Call {@link evaluateHeading} for every heading node with depth \>= 2
|
|
178
|
+
* encountered during AST traversal.
|
|
179
|
+
* 4. Call {@link finalise} after all headings have been evaluated to obtain
|
|
180
|
+
* the complete result.
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```ts
|
|
184
|
+
* const grammar = new IdentifierGrammar();
|
|
185
|
+
* const rule = new SectionHierarchyRule({
|
|
186
|
+
* uri: 'file:///doc.md',
|
|
187
|
+
* docId: '3.1',
|
|
188
|
+
* grammar,
|
|
189
|
+
* });
|
|
190
|
+
*
|
|
191
|
+
* rule.registerRootHeading({ depth: 1, text: '3.1 - My Document' });
|
|
192
|
+
* rule.evaluateHeading({ depth: 2, text: '3.1#1 - Section One' });
|
|
193
|
+
* rule.evaluateHeading({ depth: 3, text: '3.1#1.1 - Sub-Section' });
|
|
194
|
+
* rule.evaluateHeading({ depth: 2, text: '3.1#2 - Section Two' });
|
|
195
|
+
*
|
|
196
|
+
* const result: SectionHierarchyRuleResult = rule.finalise();
|
|
197
|
+
* ```
|
|
198
|
+
*/
|
|
199
|
+
export class SectionHierarchyRule {
|
|
200
|
+
/** The opaque, host-provided URI identifying the document being validated. */
|
|
201
|
+
private readonly uri: string;
|
|
202
|
+
|
|
203
|
+
/** The established DocID for this document. */
|
|
204
|
+
private readonly docId: DocID;
|
|
205
|
+
|
|
206
|
+
/** The grammar instance used for SectionID parsing and validation. */
|
|
207
|
+
private readonly grammar: IdentifierGrammar;
|
|
208
|
+
|
|
209
|
+
/** Diagnostics accumulated during heading evaluation. */
|
|
210
|
+
private readonly collectedDiagnostics: Diagnostic[];
|
|
211
|
+
|
|
212
|
+
/** Section nodes extracted from valid headings, in traversal order. */
|
|
213
|
+
private readonly collectedSections: SectionNode[];
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Depth-aware heading stack for parent-child derivation.
|
|
217
|
+
*
|
|
218
|
+
* Maintained so that each entry's depth is strictly increasing from
|
|
219
|
+
* bottom to top. When a heading at depth d is encountered, all entries
|
|
220
|
+
* with depth \>= d are popped before the new entry is pushed.
|
|
221
|
+
*/
|
|
222
|
+
private readonly headingStack: HeadingStackEntry[];
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Set of SectionIDs already encountered, for duplicate detection.
|
|
226
|
+
*/
|
|
227
|
+
private readonly encounteredSectionIds: Set<SectionID>;
|
|
228
|
+
|
|
229
|
+
/** Checks each heading's source line against the section recipe. */
|
|
230
|
+
private readonly headingSourceForm: HeadingSourceForm;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Constructs a new Section Hierarchy Rule evaluator.
|
|
234
|
+
*
|
|
235
|
+
* @param options - Configuration including the document URI, established DocID,
|
|
236
|
+
* and grammar instance
|
|
237
|
+
*/
|
|
238
|
+
public constructor(options: SectionHierarchyRuleOptions) {
|
|
239
|
+
this.uri = options.uri;
|
|
240
|
+
this.docId = options.docId;
|
|
241
|
+
this.grammar = options.grammar;
|
|
242
|
+
this.headingSourceForm = new HeadingSourceForm(options.sourceText);
|
|
243
|
+
this.collectedDiagnostics = [];
|
|
244
|
+
this.collectedSections = [];
|
|
245
|
+
this.headingStack = [];
|
|
246
|
+
this.encounteredSectionIds = new Set<SectionID>();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Registers the root H1 heading to establish the heading stack base.
|
|
251
|
+
*
|
|
252
|
+
* This produces the root {@link SectionNode} (id = DocID, headingDepth = 1,
|
|
253
|
+
* no parentId) and pushes the H1 onto the heading stack so that subsequent
|
|
254
|
+
* depth-2 headings can derive their parentId.
|
|
255
|
+
*
|
|
256
|
+
* Must be called exactly once, before any calls to {@link evaluateHeading}.
|
|
257
|
+
*
|
|
258
|
+
* @param headingNodeData - Data extracted from the H1 heading AST node.
|
|
259
|
+
* The depth must be 1. The title text is extracted
|
|
260
|
+
* from the heading content after the separator.
|
|
261
|
+
*/
|
|
262
|
+
public registerRootHeading(headingNodeData: HeadingNodeData): void {
|
|
263
|
+
const title: string = this.extractHeadingTitle(headingNodeData.text);
|
|
264
|
+
|
|
265
|
+
const rootSection: SectionNode = {
|
|
266
|
+
id: this.docId,
|
|
267
|
+
title,
|
|
268
|
+
headingDepth: 1,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
this.collectedSections.push(rootSection);
|
|
272
|
+
this.updateHeadingStack(this.docId, 1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Evaluates a single heading node with depth \>= 2 against the section
|
|
277
|
+
* hierarchy constraints.
|
|
278
|
+
*
|
|
279
|
+
* For each heading, the method validates:
|
|
280
|
+
* 1. A separator (any dash variant) follows the identifier
|
|
281
|
+
* 2. The textual prefix parses as a valid SectionID
|
|
282
|
+
* 3. The SectionID's DocID equals the document's DocID
|
|
283
|
+
* 4. The section path has d - 1 segments, where d is the heading depth
|
|
284
|
+
* 5. The SectionID has not been seen before in this document
|
|
285
|
+
* 6. No heading levels are skipped (depth must not exceed parent depth + 1)
|
|
286
|
+
*
|
|
287
|
+
* When all validations pass, a {@link SectionNode} is extracted with its
|
|
288
|
+
* parentId derived from the heading stack (the nearest preceding heading
|
|
289
|
+
* at depth - 1), and the heading stack is updated.
|
|
290
|
+
*
|
|
291
|
+
* When any validation fails, an error diagnostic is emitted and the heading
|
|
292
|
+
* stack is still updated to maintain correct parent derivation for
|
|
293
|
+
* subsequent headings.
|
|
294
|
+
*
|
|
295
|
+
* Headings with depth === 1 are ignored (H1 is handled by
|
|
296
|
+
* {@link registerRootHeading}).
|
|
297
|
+
*
|
|
298
|
+
* @param headingNodeData - Data extracted from a heading AST node with depth \>= 2
|
|
299
|
+
*/
|
|
300
|
+
public evaluateHeading(headingNodeData: HeadingNodeData): void {
|
|
301
|
+
if (headingNodeData.depth === 1) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const headingText: string = headingNodeData.text;
|
|
306
|
+
const depth: number = headingNodeData.depth;
|
|
307
|
+
const range: PositionRange | undefined = headingNodeData.range;
|
|
308
|
+
|
|
309
|
+
// 1. Validate separator
|
|
310
|
+
const separatorDiagnostic: Diagnostic | undefined =
|
|
311
|
+
this.validateHeadingSeparator(headingText, range);
|
|
312
|
+
|
|
313
|
+
if (separatorDiagnostic !== undefined) {
|
|
314
|
+
this.collectedDiagnostics.push(separatorDiagnostic);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// 2. Extract SectionID candidate from prefix
|
|
319
|
+
const candidate: string = this.extractSectionIdCandidate(headingText);
|
|
320
|
+
|
|
321
|
+
// 3. Parse candidate as SectionID
|
|
322
|
+
const parseResult: SectionIdParseResult = this.grammar.parseSectionId(candidate);
|
|
323
|
+
|
|
324
|
+
if (!parseResult.valid) {
|
|
325
|
+
const diagnostic: Diagnostic = this.createDiagnostic(
|
|
326
|
+
`Heading prefix "${candidate}" does not parse as a valid SectionID.`,
|
|
327
|
+
range,
|
|
328
|
+
);
|
|
329
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const sectionId: SectionID = parseResult.sectionId;
|
|
334
|
+
|
|
335
|
+
// 3a. Verify the heading is where the section recipe looks (1#9.11 rule
|
|
336
|
+
// 1). Reported, but the checks below still run and the section is still
|
|
337
|
+
// extracted: withholding it would turn every citation of it into an
|
|
338
|
+
// unresolved target, reporting one mistake many times over.
|
|
339
|
+
const obstruction: HeadingObstruction | undefined = this.headingSourceForm.findObstruction(
|
|
340
|
+
range,
|
|
341
|
+
depth,
|
|
342
|
+
sectionId,
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
if (obstruction !== undefined) {
|
|
346
|
+
this.collectedDiagnostics.push({
|
|
347
|
+
...this.createDiagnostic(
|
|
348
|
+
HeadingSourceForm.explain(obstruction, depth, sectionId),
|
|
349
|
+
range,
|
|
350
|
+
sectionId,
|
|
351
|
+
),
|
|
352
|
+
data: { cause: HEADING_SOURCE_FORM_CAUSE, obstruction, sectionId },
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 4. Verify SectionID extends DocID
|
|
357
|
+
const extendsDocId: boolean = this.grammar.tellSectionIdExtendsDocId(
|
|
358
|
+
sectionId,
|
|
359
|
+
this.docId,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (!extendsDocId) {
|
|
363
|
+
const diagnostic: Diagnostic = this.createDiagnostic(
|
|
364
|
+
`SectionID "${sectionId}" belongs to a different document. ` +
|
|
365
|
+
`Headings in this document must be written as "${this.docId}#<section path>".`,
|
|
366
|
+
range,
|
|
367
|
+
sectionId,
|
|
368
|
+
);
|
|
369
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
370
|
+
this.updateHeadingStack(sectionId, depth);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// 5. Verify the section path has one segment per heading level below the H1
|
|
375
|
+
const actualPathLength: number = this.grammar.showSectionPathLength(sectionId);
|
|
376
|
+
const expectedPathLength: number = this.grammar.showExpectedSectionPathLength(depth);
|
|
377
|
+
|
|
378
|
+
if (actualPathLength !== expectedPathLength) {
|
|
379
|
+
const diagnostic: Diagnostic = this.createDiagnostic(
|
|
380
|
+
`SectionID "${sectionId}" has a section path of ${String(actualPathLength)} ` +
|
|
381
|
+
`segment(s), but heading depth ${String(depth)} requires ` +
|
|
382
|
+
`${String(expectedPathLength)}.`,
|
|
383
|
+
range,
|
|
384
|
+
sectionId,
|
|
385
|
+
);
|
|
386
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
387
|
+
this.updateHeadingStack(sectionId, depth);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// 6. Check for duplicate SectionIDs
|
|
392
|
+
if (this.encounteredSectionIds.has(sectionId)) {
|
|
393
|
+
const diagnostic: Diagnostic = this.createDiagnostic(
|
|
394
|
+
`Duplicate SectionID "${sectionId}". SectionIDs must be unique within a document.`,
|
|
395
|
+
range,
|
|
396
|
+
sectionId,
|
|
397
|
+
);
|
|
398
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
399
|
+
this.updateHeadingStack(sectionId, depth);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// 7. Check for skipped heading levels
|
|
404
|
+
const currentTopDepth: number = this.peekHeadingStackDepth();
|
|
405
|
+
|
|
406
|
+
if (depth > currentTopDepth + 1) {
|
|
407
|
+
const skippedFrom: number = currentTopDepth;
|
|
408
|
+
const diagnostic: Diagnostic = this.createDiagnostic(
|
|
409
|
+
`Heading depth ${String(depth)} skips level(s) after depth ${String(skippedFrom)}. ` +
|
|
410
|
+
`Each heading level must be introduced before its children.`,
|
|
411
|
+
range,
|
|
412
|
+
sectionId,
|
|
413
|
+
);
|
|
414
|
+
this.collectedDiagnostics.push(diagnostic);
|
|
415
|
+
this.updateHeadingStack(sectionId, depth);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: parent may be DocID or SectionID
|
|
420
|
+
const parentId: DocID | SectionID | undefined = this.lookUpParentId(depth);
|
|
421
|
+
|
|
422
|
+
// 8. Check that the section path continues its parent's path
|
|
423
|
+
const misplacedDiagnostic: Diagnostic | undefined = this.validatePathAgainstParent(
|
|
424
|
+
parseResult.sectionPath,
|
|
425
|
+
parentId,
|
|
426
|
+
sectionId,
|
|
427
|
+
range,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
if (misplacedDiagnostic !== undefined) {
|
|
431
|
+
this.collectedDiagnostics.push(misplacedDiagnostic);
|
|
432
|
+
this.updateHeadingStack(sectionId, depth);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// All validations passed — extract SectionNode
|
|
437
|
+
this.encounteredSectionIds.add(sectionId);
|
|
438
|
+
|
|
439
|
+
const title: string = this.extractHeadingTitle(headingText);
|
|
440
|
+
|
|
441
|
+
const sectionNode: SectionNode = {
|
|
442
|
+
id: sectionId,
|
|
443
|
+
title,
|
|
444
|
+
headingDepth: depth,
|
|
445
|
+
...(parentId !== undefined ? { parentId } : {}),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
this.collectedSections.push(sectionNode);
|
|
449
|
+
this.updateHeadingStack(sectionId, depth);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Finalises the rule evaluation and produces the complete result.
|
|
454
|
+
*
|
|
455
|
+
* This method must be called after all heading nodes have been supplied
|
|
456
|
+
* via {@link registerRootHeading} and {@link evaluateHeading}. It returns
|
|
457
|
+
* all accumulated diagnostics and extracted section nodes.
|
|
458
|
+
*
|
|
459
|
+
* @returns The complete rule result including all diagnostics and extracted sections
|
|
460
|
+
*/
|
|
461
|
+
public finalise(): SectionHierarchyRuleResult {
|
|
462
|
+
return {
|
|
463
|
+
diagnostics: this.collectedDiagnostics,
|
|
464
|
+
sections: this.collectedSections,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// -------------------------------------------------------------------------
|
|
469
|
+
// Private helpers
|
|
470
|
+
// -------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Validates that a heading string carries a separator, and returns an
|
|
474
|
+
* error diagnostic when none is found.
|
|
475
|
+
*
|
|
476
|
+
* Delegates to {@link IdentifierGrammar.validateSeparator}.
|
|
477
|
+
*
|
|
478
|
+
* @param headingText - The plain text content of the heading node
|
|
479
|
+
* @param range - Optional positional range for the diagnostic
|
|
480
|
+
* @returns A diagnostic if the separator is invalid, or `undefined` if valid
|
|
481
|
+
*/
|
|
482
|
+
private validateHeadingSeparator(
|
|
483
|
+
headingText: string,
|
|
484
|
+
range?: PositionRange,
|
|
485
|
+
): Diagnostic | undefined {
|
|
486
|
+
const separatorResult: SeparatorValidationResult =
|
|
487
|
+
this.grammar.validateSeparator(headingText);
|
|
488
|
+
|
|
489
|
+
if (separatorResult.valid) {
|
|
490
|
+
return undefined;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// A heading with no number at all is far more common than a numbered
|
|
494
|
+
// heading missing its dash, and deserves a message that says so.
|
|
495
|
+
if (!STARTS_WITH_DIGIT_PATTERN.test(headingText)) {
|
|
496
|
+
return this.createDiagnostic(
|
|
497
|
+
`Heading "${headingText.trim()}" is not numbered. Every sub-heading carries a SectionID: ` +
|
|
498
|
+
`the DocID, "#", and a section path, for example "${this.docId}#1 - ${headingText.trim()}".`,
|
|
499
|
+
range,
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const diagnostic: Diagnostic = this.createDiagnostic(
|
|
504
|
+
`Heading has no recognisable separator. ` +
|
|
505
|
+
`Expected: a number, a dash (-, – or —), then the title.`,
|
|
506
|
+
range,
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
return diagnostic;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Checks that a sub-section's path continues the path of the heading it
|
|
514
|
+
* sits under, so that a SectionID locates itself: `3.1#2.1` must sit under
|
|
515
|
+
* `3.1#2`, not under `3.1#1`.
|
|
516
|
+
*
|
|
517
|
+
* Only a parent that was itself extracted is checked against. A parent
|
|
518
|
+
* that failed validation has already been reported, and measuring its
|
|
519
|
+
* children against it would repeat that error rather than add one.
|
|
520
|
+
*
|
|
521
|
+
* @param sectionPath - The section path of the heading being evaluated
|
|
522
|
+
* @param parentId - The identifier of the heading it sits under, if any
|
|
523
|
+
* @param sectionId - The SectionID of the heading being evaluated
|
|
524
|
+
* @param range - Optional positional range for the diagnostic
|
|
525
|
+
* @returns A diagnostic if the path does not continue its parent's, or `undefined`
|
|
526
|
+
*/
|
|
527
|
+
private validatePathAgainstParent(
|
|
528
|
+
sectionPath: string,
|
|
529
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: parent may be DocID or SectionID
|
|
530
|
+
parentId: DocID | SectionID | undefined,
|
|
531
|
+
sectionId: SectionID,
|
|
532
|
+
range?: PositionRange,
|
|
533
|
+
): Diagnostic | undefined {
|
|
534
|
+
if (parentId === undefined || !this.encounteredSectionIds.has(parentId)) {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const parentParse: SectionIdParseResult = this.grammar.parseSectionId(parentId);
|
|
539
|
+
|
|
540
|
+
if (!parentParse.valid) {
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const segments: readonly string[] = sectionPath.split('.');
|
|
545
|
+
const ownPrefix: string = segments.slice(0, -1).join('.');
|
|
546
|
+
|
|
547
|
+
if (ownPrefix === parentParse.sectionPath) {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const expectedId: string = `${this.docId}#${parentParse.sectionPath}.${segments[segments.length - 1] ?? ''}`;
|
|
552
|
+
|
|
553
|
+
return this.createDiagnostic(
|
|
554
|
+
`SectionID "${sectionId}" sits under "${parentId}", so its section path must begin ` +
|
|
555
|
+
`"${parentParse.sectionPath}." (for example "${expectedId}").`,
|
|
556
|
+
range,
|
|
557
|
+
sectionId,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Extracts the SectionID candidate string from a heading's text.
|
|
563
|
+
*
|
|
564
|
+
* The candidate is the portion of the heading text before the first
|
|
565
|
+
* occurrence of a separator (any dash variant). If no separator
|
|
566
|
+
* is found, the entire trimmed text is returned as the candidate.
|
|
567
|
+
*
|
|
568
|
+
* @param headingText - The plain text content of the heading node
|
|
569
|
+
* @returns The SectionID candidate string
|
|
570
|
+
*/
|
|
571
|
+
private extractSectionIdCandidate(headingText: string): string {
|
|
572
|
+
const match: RegExpExecArray | null = SECTION_SEPARATOR_PATTERN.exec(headingText);
|
|
573
|
+
|
|
574
|
+
if (match === null) {
|
|
575
|
+
return headingText.trim();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return headingText.substring(0, match.index).trim();
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Extracts the title text from a heading's text.
|
|
583
|
+
*
|
|
584
|
+
* The title is the portion of the heading text after the first
|
|
585
|
+
* occurrence of a separator (any dash variant). If no separator
|
|
586
|
+
* is found, an empty string is returned.
|
|
587
|
+
*
|
|
588
|
+
* @param headingText - The plain text content of the heading node
|
|
589
|
+
* @returns The extracted title string
|
|
590
|
+
*/
|
|
591
|
+
private extractHeadingTitle(headingText: string): string {
|
|
592
|
+
const match: RegExpExecArray | null = SECTION_SEPARATOR_PATTERN.exec(headingText);
|
|
593
|
+
|
|
594
|
+
if (match === null) {
|
|
595
|
+
return '';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
return headingText.substring(match.index + match[0].length);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Looks up the parent identifier from the heading stack for a heading
|
|
603
|
+
* at the given depth.
|
|
604
|
+
*
|
|
605
|
+
* The parent is the nearest preceding heading whose depth is exactly
|
|
606
|
+
* `headingDepth - 1`. Returns `undefined` if no such heading exists
|
|
607
|
+
* in the stack (which should not happen for well-formed documents
|
|
608
|
+
* after the root H1 is registered).
|
|
609
|
+
*
|
|
610
|
+
* @param headingDepth - The depth of the heading whose parent is sought
|
|
611
|
+
* @returns The parent's identifier, or `undefined` if not found
|
|
612
|
+
*/
|
|
613
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: parent may be DocID or SectionID
|
|
614
|
+
private lookUpParentId(headingDepth: number): DocID | SectionID | undefined {
|
|
615
|
+
const targetDepth: number = headingDepth - 1;
|
|
616
|
+
|
|
617
|
+
for (let index: number = this.headingStack.length - 1; index >= 0; index--) {
|
|
618
|
+
const entry: HeadingStackEntry | undefined = this.headingStack[index];
|
|
619
|
+
|
|
620
|
+
if (entry?.depth === targetDepth) {
|
|
621
|
+
return entry.id;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return undefined;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Returns the depth of the topmost entry on the heading stack.
|
|
630
|
+
*
|
|
631
|
+
* Used to detect skipped heading levels: if the incoming heading depth
|
|
632
|
+
* exceeds the top-of-stack depth by more than 1, an intermediate heading
|
|
633
|
+
* level has been skipped.
|
|
634
|
+
*
|
|
635
|
+
* Returns 0 if the heading stack is empty (should not occur after
|
|
636
|
+
* {@link registerRootHeading} has been called).
|
|
637
|
+
*
|
|
638
|
+
* @returns The depth of the topmost heading stack entry, or 0 if empty
|
|
639
|
+
*/
|
|
640
|
+
private peekHeadingStackDepth(): number {
|
|
641
|
+
if (this.headingStack.length === 0) {
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const topEntry: HeadingStackEntry | undefined =
|
|
646
|
+
this.headingStack[this.headingStack.length - 1];
|
|
647
|
+
|
|
648
|
+
return topEntry?.depth ?? 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Updates the heading stack when a new heading is encountered.
|
|
653
|
+
*
|
|
654
|
+
* All entries with depth \>= the new heading's depth are removed,
|
|
655
|
+
* then the new heading is pushed. This ensures the stack always
|
|
656
|
+
* reflects the current structural nesting.
|
|
657
|
+
*
|
|
658
|
+
* @param id - The DocID or SectionID of the heading
|
|
659
|
+
* @param depth - The Markdown heading depth
|
|
660
|
+
*/
|
|
661
|
+
// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -- Semantically distinct: id may be DocID or SectionID
|
|
662
|
+
private updateHeadingStack(id: DocID | SectionID, depth: number): void {
|
|
663
|
+
while (this.headingStack.length > 0) {
|
|
664
|
+
const topEntry: HeadingStackEntry | undefined =
|
|
665
|
+
this.headingStack[this.headingStack.length - 1];
|
|
666
|
+
|
|
667
|
+
if (topEntry === undefined || topEntry.depth < depth) {
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this.headingStack.pop();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
this.headingStack.push({ id, depth });
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Creates a diagnostic object for the Section Hierarchy Rule.
|
|
680
|
+
*
|
|
681
|
+
* All diagnostics share the same rule ID ({@link SECTION_HIERARCHY_RULE_ID}),
|
|
682
|
+
* severity (error), and document URI.
|
|
683
|
+
*
|
|
684
|
+
* @param message - Human-readable description of the issue
|
|
685
|
+
* @param range - Optional positional range of the heading within the source document
|
|
686
|
+
* @param sectionId - Optional SectionID providing structural context
|
|
687
|
+
* @returns A fully populated diagnostic object
|
|
688
|
+
*/
|
|
689
|
+
private createDiagnostic(
|
|
690
|
+
message: string,
|
|
691
|
+
range?: PositionRange,
|
|
692
|
+
sectionId?: SectionID,
|
|
693
|
+
): Diagnostic {
|
|
694
|
+
const diagnostic: Diagnostic = {
|
|
695
|
+
ruleId: SECTION_HIERARCHY_RULE_ID,
|
|
696
|
+
severity: SECTION_HIERARCHY_DIAGNOSTIC_SEVERITY,
|
|
697
|
+
message,
|
|
698
|
+
uri: this.uri,
|
|
699
|
+
...(range !== undefined ? { range } : {}),
|
|
700
|
+
...(sectionId !== undefined ? { sectionId } : {}),
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
return diagnostic;
|
|
704
|
+
}
|
|
705
|
+
}
|