@timiagama/ecr 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +16 -0
  3. package/README.md +411 -7
  4. package/dist/bin.d.ts +20 -0
  5. package/dist/bin.d.ts.map +1 -0
  6. package/dist/bin.js +26 -0
  7. package/dist/bin.js.map +1 -0
  8. package/dist/cli/corpus-loader.d.ts +198 -0
  9. package/dist/cli/corpus-loader.d.ts.map +1 -0
  10. package/dist/cli/corpus-loader.js +351 -0
  11. package/dist/cli/corpus-loader.js.map +1 -0
  12. package/dist/cli/corpus-statistics.d.ts +113 -0
  13. package/dist/cli/corpus-statistics.d.ts.map +1 -0
  14. package/dist/cli/corpus-statistics.js +119 -0
  15. package/dist/cli/corpus-statistics.js.map +1 -0
  16. package/dist/cli/diagnostic-reporter.d.ts +103 -0
  17. package/dist/cli/diagnostic-reporter.d.ts.map +1 -0
  18. package/dist/cli/diagnostic-reporter.js +254 -0
  19. package/dist/cli/diagnostic-reporter.js.map +1 -0
  20. package/dist/cli/project-ignore.d.ts +113 -0
  21. package/dist/cli/project-ignore.d.ts.map +1 -0
  22. package/dist/cli/project-ignore.js +199 -0
  23. package/dist/cli/project-ignore.js.map +1 -0
  24. package/dist/cli/safe-text.d.ts +25 -0
  25. package/dist/cli/safe-text.d.ts.map +1 -0
  26. package/dist/cli/safe-text.js +34 -0
  27. package/dist/cli/safe-text.js.map +1 -0
  28. package/dist/cli/supervisor.d.ts +108 -0
  29. package/dist/cli/supervisor.d.ts.map +1 -0
  30. package/dist/cli/supervisor.js +139 -0
  31. package/dist/cli/supervisor.js.map +1 -0
  32. package/dist/cli.d.ts +247 -0
  33. package/dist/cli.d.ts.map +1 -0
  34. package/dist/cli.js +591 -0
  35. package/dist/cli.js.map +1 -0
  36. package/dist/corpus-validator.d.ts +183 -0
  37. package/dist/corpus-validator.d.ts.map +1 -0
  38. package/dist/corpus-validator.js +479 -0
  39. package/dist/corpus-validator.js.map +1 -0
  40. package/dist/document-identity-rule.d.ts +198 -0
  41. package/dist/document-identity-rule.d.ts.map +1 -0
  42. package/dist/document-identity-rule.js +244 -0
  43. package/dist/document-identity-rule.js.map +1 -0
  44. package/dist/ecr.d.ts +103 -0
  45. package/dist/ecr.d.ts.map +1 -0
  46. package/dist/ecr.js +216 -0
  47. package/dist/ecr.js.map +1 -0
  48. package/dist/glob-pattern.d.ts +94 -0
  49. package/dist/glob-pattern.d.ts.map +1 -0
  50. package/dist/glob-pattern.js +188 -0
  51. package/dist/glob-pattern.js.map +1 -0
  52. package/dist/heading-source-form.d.ts +77 -0
  53. package/dist/heading-source-form.d.ts.map +1 -0
  54. package/dist/heading-source-form.js +129 -0
  55. package/dist/heading-source-form.js.map +1 -0
  56. package/dist/identifier-grammar.d.ts +212 -0
  57. package/dist/identifier-grammar.d.ts.map +1 -0
  58. package/dist/identifier-grammar.js +273 -0
  59. package/dist/identifier-grammar.js.map +1 -0
  60. package/dist/index.d.ts +19 -0
  61. package/dist/index.d.ts.map +1 -0
  62. package/dist/index.js +11 -0
  63. package/dist/index.js.map +1 -0
  64. package/dist/inline-reference-rule.d.ts +557 -0
  65. package/dist/inline-reference-rule.d.ts.map +1 -0
  66. package/dist/inline-reference-rule.js +802 -0
  67. package/dist/inline-reference-rule.js.map +1 -0
  68. package/dist/meta-documents.d.ts +91 -0
  69. package/dist/meta-documents.d.ts.map +1 -0
  70. package/dist/meta-documents.js +133 -0
  71. package/dist/meta-documents.js.map +1 -0
  72. package/dist/per-document-visitor.d.ts +510 -0
  73. package/dist/per-document-visitor.d.ts.map +1 -0
  74. package/dist/per-document-visitor.js +1032 -0
  75. package/dist/per-document-visitor.js.map +1 -0
  76. package/dist/references-section-rule.d.ts +482 -0
  77. package/dist/references-section-rule.d.ts.map +1 -0
  78. package/dist/references-section-rule.js +777 -0
  79. package/dist/references-section-rule.js.map +1 -0
  80. package/dist/section-hierarchy-rule.d.ts +299 -0
  81. package/dist/section-hierarchy-rule.d.ts.map +1 -0
  82. package/dist/section-hierarchy-rule.js +482 -0
  83. package/dist/section-hierarchy-rule.js.map +1 -0
  84. package/dist/source-alignment.d.ts +41 -0
  85. package/dist/source-alignment.d.ts.map +1 -0
  86. package/dist/source-alignment.js +166 -0
  87. package/dist/source-alignment.js.map +1 -0
  88. package/dist/source-lines.d.ts +69 -0
  89. package/dist/source-lines.d.ts.map +1 -0
  90. package/dist/source-lines.js +104 -0
  91. package/dist/source-lines.js.map +1 -0
  92. package/dist/spec-version.d.ts +19 -0
  93. package/dist/spec-version.d.ts.map +1 -0
  94. package/dist/spec-version.js +19 -0
  95. package/dist/spec-version.js.map +1 -0
  96. package/dist/types.d.ts +219 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +11 -0
  99. package/dist/types.js.map +1 -0
  100. package/examples/docs/0. Orientation/0.0 - System Overview.md +34 -0
  101. package/examples/docs/0. Orientation/0.0.1 - Glossary.md +21 -0
  102. package/examples/docs/0. Orientation/0.0.2 - Quality Gates.md +22 -0
  103. package/examples/docs/3. Ingestion/3.1 - Ingestion - Validation Rules.md +33 -0
  104. package/examples/docs/4. Payments/4.10 - Settlement.md +22 -0
  105. package/examples/docs/4. Payments/4.2 - Payment Processing Contract.md +33 -0
  106. package/examples/docs/8. Orchestration/8.1 - Workflow Orchestration Contract.md +38 -0
  107. package/examples/docs/8. Orchestration/8.1.3 - Retry Policy.md +17 -0
  108. package/examples/docs/8. Orchestration/8.10 - Dead Letter Queue.md +20 -0
  109. package/examples/docs/README.md +7 -0
  110. package/package.json +65 -7
  111. package/protocol/navigation-protocol.md +223 -0
  112. package/spec/v2/1 - ECR - Structural Specification.md +1467 -0
  113. package/spec/v2/2 - ECR - User Guide.md +845 -0
  114. package/spec/v2/3 - Design Rationale - The Section Separator.md +243 -0
  115. package/src/bin.ts +30 -0
  116. package/src/cli/corpus-loader.ts +482 -0
  117. package/src/cli/corpus-statistics.ts +196 -0
  118. package/src/cli/diagnostic-reporter.ts +344 -0
  119. package/src/cli/project-ignore.ts +238 -0
  120. package/src/cli/safe-text.ts +39 -0
  121. package/src/cli/supervisor.ts +204 -0
  122. package/src/cli.ts +836 -0
  123. package/src/corpus-validator.ts +642 -0
  124. package/src/document-identity-rule.ts +422 -0
  125. package/src/ecr.ts +298 -0
  126. package/src/glob-pattern.ts +242 -0
  127. package/src/heading-source-form.ts +173 -0
  128. package/src/identifier-grammar.ts +380 -0
  129. package/src/index.ts +78 -0
  130. package/src/inline-reference-rule.ts +1235 -0
  131. package/src/meta-documents.ts +159 -0
  132. package/src/per-document-visitor.ts +1528 -0
  133. package/src/references-section-rule.ts +1182 -0
  134. package/src/section-hierarchy-rule.ts +705 -0
  135. package/src/source-alignment.ts +209 -0
  136. package/src/source-lines.ts +123 -0
  137. package/src/spec-version.ts +19 -0
  138. package/src/types.ts +267 -0
package/src/ecr.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * ECR Public API -- Ecr Facade
3
+ *
4
+ * Top-level public API for the ECR structural linter.
5
+ *
6
+ * Derived from section 1#13 (Public Interface Contract) and
7
+ * section 1#10 (Output Schema) of the ECR Structural Specification (doc 1).
8
+ *
9
+ * This module provides the {@link Ecr} facade, which delegates
10
+ * per-document validation to {@link PerDocumentVisitor} and
11
+ * corpus-wide integrity checks to {@link CorpusValidator}.
12
+ *
13
+ * Per-document results are returned exactly as the visitor produces
14
+ * them: every severity is set by the rule that reports the finding.
15
+ *
16
+ * The facade applies one orchestration-level policy:
17
+ *
18
+ * **Cross-document duplicate SectionID detection**: The corpus
19
+ * validator only sees SectionIDs that survive per-document
20
+ * extraction (i.e., those that pass ECR102). A heading like
21
+ * `## 3.1#1 - X` inside a document with DocID `4.1` fails ECR102
22
+ * (it names another document) and is never extracted — yet its
23
+ * SectionID text is still globally significant. The facade
24
+ * performs an additional cross-document duplicate check by
25
+ * collecting SectionIDs from both extracted sections and
26
+ * per-document diagnostics that carry a `sectionId` field.
27
+ */
28
+
29
+ import type {
30
+ LintResult,
31
+ CorpusResult,
32
+ CorpusDocumentEntry,
33
+ Diagnostic,
34
+ SectionID,
35
+ } from "./types.js";
36
+
37
+ import { PerDocumentVisitor } from "./per-document-visitor.js";
38
+ import { CorpusValidator } from "./corpus-validator.js";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Input Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /**
45
+ * A single document supplied to corpus validation.
46
+ *
47
+ * Each entry provides the Markdown text and an opaque, host-provided URI
48
+ * that identifies the document instance.
49
+ */
50
+ export interface CorpusDocumentInput {
51
+ /** Opaque, host-provided URI identifying the document instance. */
52
+ readonly uri: string;
53
+
54
+ /** Raw Markdown text of the document. */
55
+ readonly markdownText: string;
56
+
57
+ /** Optional version tag for the document instance. */
58
+ readonly version?: number;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Constants
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * The corpus-level rule ID for duplicate SectionIDs detected by the facade.
67
+ */
68
+ const CORPUS_DUPLICATE_SECTION_ID_RULE_ID: string = 'corpus/duplicate-section-id';
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Public Facade
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /**
75
+ * Top-level public API for the ECR structural linter.
76
+ *
77
+ * Provides two operations:
78
+ * - Single-document linting via {@link Ecr.lintDocument}
79
+ * - Corpus-wide validation via {@link Ecr.validateCorpus}
80
+ *
81
+ * This class is stateless across invocations. Successive calls with
82
+ * identical inputs produce identical results.
83
+ */
84
+ export class Ecr {
85
+ /**
86
+ * Lint a single Markdown document against per-document ECR structural invariants.
87
+ *
88
+ * Creates a {@link PerDocumentVisitor} for the given document and delegates
89
+ * the linting operation to it.
90
+ *
91
+ * @param uri - Opaque, host-provided URI identifying the document instance.
92
+ * @param markdownText - Raw Markdown text of the document.
93
+ * @param version - Optional version tag propagated through to the result.
94
+ * @returns Structured lint result containing diagnostics and optionally extracted artefacts.
95
+ */
96
+ public lintDocument(
97
+ uri: string,
98
+ markdownText: string,
99
+ version?: number,
100
+ ): LintResult {
101
+ const visitor: PerDocumentVisitor = new PerDocumentVisitor({
102
+ uri,
103
+ ...(version !== undefined ? { version } : {}),
104
+ });
105
+
106
+ return visitor.lint(markdownText);
107
+ }
108
+
109
+ /**
110
+ * Validate a corpus of Markdown documents.
111
+ *
112
+ * Executes per-document linting on every document (Pass 1), then performs
113
+ * corpus-wide integrity checks on successfully linted documents (Pass 2).
114
+ *
115
+ * After the standard corpus validation, the facade performs an additional
116
+ * cross-document duplicate SectionID check that considers SectionIDs from
117
+ * per-document diagnostics (not just extracted sections), ensuring that
118
+ * headings rejected by ECR102 (e.g., wrong DocID prefix) still participate
119
+ * in global uniqueness enforcement.
120
+ *
121
+ * @param documents - Collection of documents to validate.
122
+ * @returns Corpus result containing per-document results, corpus-wide diagnostics, and an optional global identifier index.
123
+ */
124
+ public validateCorpus(
125
+ documents: readonly CorpusDocumentInput[],
126
+ ): CorpusResult {
127
+ // Pass 1: Per-document linting
128
+ const entries: readonly CorpusDocumentEntry[] = documents.map(
129
+ (document: CorpusDocumentInput): CorpusDocumentEntry => {
130
+ const lintResult: LintResult = this.lintDocument(
131
+ document.uri,
132
+ document.markdownText,
133
+ document.version,
134
+ );
135
+
136
+ return {
137
+ uri: document.uri,
138
+ result: lintResult,
139
+ };
140
+ },
141
+ );
142
+
143
+ // Pass 2: Corpus-wide validation (standard checks)
144
+ const validator: CorpusValidator = new CorpusValidator();
145
+ const baseCorpusResult: CorpusResult = validator.validateCorpus(entries);
146
+
147
+ // Pass 2b: Additional facade-level cross-document duplicate SectionID check
148
+ const additionalDiagnostics: readonly Diagnostic[] =
149
+ this.detectCrossDocumentDuplicateSectionIds(entries);
150
+
151
+ if (additionalDiagnostics.length === 0) {
152
+ return baseCorpusResult;
153
+ }
154
+
155
+ // Merge additional diagnostics into the corpus result
156
+ const mergedDiagnostics: readonly Diagnostic[] = [
157
+ ...baseCorpusResult.diagnostics,
158
+ ...additionalDiagnostics,
159
+ ];
160
+
161
+ const corpusResult: CorpusResult = {
162
+ documents: baseCorpusResult.documents,
163
+ diagnostics: mergedDiagnostics,
164
+ ...(baseCorpusResult.index !== undefined
165
+ ? { index: baseCorpusResult.index }
166
+ : {}),
167
+ };
168
+
169
+ return corpusResult;
170
+ }
171
+
172
+ // -------------------------------------------------------------------------
173
+ // Private: Cross-document duplicate SectionID detection
174
+ // -------------------------------------------------------------------------
175
+
176
+ /**
177
+ * Detects duplicate SectionIDs across documents by examining both
178
+ * extracted sections and per-document diagnostics.
179
+ *
180
+ * The standard {@link CorpusValidator} only checks extracted sections
181
+ * (those that pass ECR102). This method extends coverage to include
182
+ * SectionIDs mentioned in per-document diagnostics — for example,
183
+ * a heading `## 3.1#1 - X` inside a document with DocID `4.1` fails
184
+ * ECR102 (it names another document) but the SectionID `3.1#1` is still
185
+ * present in the diagnostic's `sectionId` field. If document `3.1` also
186
+ * defines `3.1#1`, that is a corpus-level duplicate.
187
+ *
188
+ * This method only emits diagnostics for duplicates NOT already
189
+ * detected by the standard corpus validator (i.e., duplicates involving
190
+ * at least one SectionID from a diagnostic rather than an extracted section).
191
+ *
192
+ * @param entries - The per-document lint entries from Pass 1
193
+ * @returns Additional corpus-level diagnostics for duplicate SectionIDs
194
+ */
195
+ private detectCrossDocumentDuplicateSectionIds(
196
+ entries: readonly CorpusDocumentEntry[],
197
+ ): readonly Diagnostic[] {
198
+ // Collect all SectionIDs per document from both extracted sections
199
+ // and per-document diagnostics
200
+ const sectionIdToUris: Map<SectionID, string[]> =
201
+ new Map<SectionID, string[]>();
202
+
203
+ for (const entry of entries) {
204
+ const documentSectionIds: Set<SectionID> = new Set<SectionID>();
205
+
206
+ // Collect from extracted sections
207
+ if (entry.result.extracted !== undefined) {
208
+ for (const section of entry.result.extracted.sections) {
209
+ // Skip root H1 nodes — their id is the DocID, handled separately
210
+ if (section.headingDepth === 1) {
211
+ continue;
212
+ }
213
+
214
+ documentSectionIds.add(section.id);
215
+ }
216
+ }
217
+
218
+ // Collect from per-document diagnostics that carry a sectionId field
219
+ for (const diagnostic of entry.result.diagnostics) {
220
+ if (diagnostic.sectionId !== undefined) {
221
+ documentSectionIds.add(diagnostic.sectionId);
222
+ }
223
+ }
224
+
225
+ // Register each SectionID from this document
226
+ for (const sectionId of documentSectionIds) {
227
+ const existingUris: string[] | undefined = sectionIdToUris.get(sectionId);
228
+
229
+ if (existingUris !== undefined) {
230
+ // Only add this URI if it is not already listed
231
+ // (a document may have both an extracted section and a diagnostic
232
+ // for the same SectionID)
233
+ if (!existingUris.includes(entry.uri)) {
234
+ existingUris.push(entry.uri);
235
+ }
236
+ } else {
237
+ sectionIdToUris.set(sectionId, [entry.uri]);
238
+ }
239
+ }
240
+ }
241
+
242
+ // Find duplicates — SectionIDs that appear in more than one document
243
+ const additionalDiagnostics: Diagnostic[] = [];
244
+
245
+ for (const [sectionId, uris] of sectionIdToUris) {
246
+ if (uris.length <= 1) {
247
+ continue;
248
+ }
249
+
250
+ // The corpus validator detects a duplicate when the SectionID
251
+ // appears in extracted.sections of 2+ documents. Count how many
252
+ // of the duplicate URIs have this SectionID in their extracted
253
+ // sections to avoid emitting redundant diagnostics.
254
+ let extractedCount: number = 0;
255
+
256
+ for (const uri of uris) {
257
+ const entryForUri: CorpusDocumentEntry | undefined = entries.find(
258
+ (candidateEntry: CorpusDocumentEntry): boolean =>
259
+ candidateEntry.uri === uri,
260
+ );
261
+
262
+ if (entryForUri?.result.extracted !== undefined) {
263
+ const hasExtractedSection: boolean =
264
+ entryForUri.result.extracted.sections.some(
265
+ (section): boolean =>
266
+ section.headingDepth !== 1 && section.id === sectionId,
267
+ );
268
+
269
+ if (hasExtractedSection) {
270
+ extractedCount = extractedCount + 1;
271
+ }
272
+ }
273
+ }
274
+
275
+ // If 2+ documents have this SectionID in their extracted sections,
276
+ // the corpus validator already detected the duplicate. Skip.
277
+ if (extractedCount >= 2) {
278
+ continue;
279
+ }
280
+
281
+ // Emit diagnostics for this duplicate
282
+ for (const uri of uris) {
283
+ const diagnosticForUri: Diagnostic = {
284
+ severity: 'error',
285
+ ruleId: CORPUS_DUPLICATE_SECTION_ID_RULE_ID,
286
+ message: `Duplicate SectionID '${sectionId}' found in multiple documents`,
287
+ uri,
288
+ sectionId,
289
+ data: { duplicateUris: uris },
290
+ };
291
+
292
+ additionalDiagnostics.push(diagnosticForUri);
293
+ }
294
+ }
295
+
296
+ return additionalDiagnostics;
297
+ }
298
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Glob Patterns
3
+ *
4
+ * The minimal glob syntax used by `--ignore` and `.ecrignore`: `*` matches any
5
+ * run of characters except `/`, `**` matches any run including `/`, a `**`
6
+ * immediately before a slash matches zero or more whole directories, and `?`
7
+ * matches one character other than `/`. Everything else matches literally, and
8
+ * a pattern must match a whole path.
9
+ *
10
+ * A pattern is matched by walking the path once for each part of the pattern,
11
+ * so the work is bounded by the pattern's length times the path's length. An
12
+ * equivalent regular expression is not: patterns such as `*a*a*a*a*a*a*a*ab`
13
+ * make a backtracking engine explore exponentially many ways to divide the
14
+ * path between the wildcards, which let a single crafted ignore pattern hang
15
+ * the linter. Matching here is a table, so no such pattern exists.
16
+ */
17
+
18
+ /** One part of a compiled pattern. */
19
+ type GlobPart =
20
+ /** Characters that must appear exactly. */
21
+ | { readonly kind: 'literal'; readonly text: string }
22
+ /** `?`: one character other than `/`. */
23
+ | { readonly kind: 'oneCharacter' }
24
+ /** `*`: any run of characters within one path segment. */
25
+ | { readonly kind: 'withinSegment' }
26
+ /** `**` not followed by `/`: any run of characters at all. */
27
+ | { readonly kind: 'acrossSegments' }
28
+ /** `**` before a slash: zero or more whole directories. */
29
+ | { readonly kind: 'wholeDirectories' };
30
+
31
+ /**
32
+ * A compiled glob pattern that can be matched against a path.
33
+ */
34
+ export class GlobPattern {
35
+ /** The pattern's parts, in order. */
36
+ private readonly parts: readonly GlobPart[];
37
+
38
+ /**
39
+ * Compiles a pattern.
40
+ *
41
+ * @param pattern - The glob pattern
42
+ */
43
+ public constructor(pattern: string) {
44
+ this.parts = GlobPattern.compile(pattern);
45
+ }
46
+
47
+ /**
48
+ * Determines whether a path matches the whole pattern.
49
+ *
50
+ * @param path - The path to match, using forward slashes
51
+ * @returns `true` when the pattern matches all of it
52
+ */
53
+ public matches(path: string): boolean {
54
+ const length: number = path.length;
55
+
56
+ // `reached[index]` says whether the parts considered so far can match the
57
+ // rest of the path from `index`. It starts as the answer for no parts at
58
+ // all: only a path already consumed to its end matches nothing more.
59
+ let reached: boolean[] = new Array<boolean>(length + 1).fill(false);
60
+ reached[length] = true;
61
+
62
+ // Each part is added in front of the answer for the parts after it, so
63
+ // they are considered last one first.
64
+ for (const part of [...this.parts].reverse()) {
65
+ reached = GlobPattern.matchPart(part, path, reached);
66
+ }
67
+
68
+ return reached[0] ?? false;
69
+ }
70
+
71
+ /**
72
+ * Works out, for every position in the path, whether one part followed by
73
+ * the parts after it can match the rest of the path from there.
74
+ *
75
+ * @param part - The part being added in front
76
+ * @param path - The path being matched
77
+ * @param following - The same answer for the parts after this one
78
+ * @returns The answer for this part and everything after it
79
+ */
80
+ private static matchPart(part: GlobPart, path: string, following: readonly boolean[]): boolean[] {
81
+ switch (part.kind) {
82
+ case 'literal':
83
+ return GlobPattern.matchLiteral(part.text, path, following);
84
+
85
+ case 'oneCharacter':
86
+ return GlobPattern.matchOneCharacter(path, following);
87
+
88
+ case 'withinSegment':
89
+ return GlobPattern.matchWildcard(path, following, true);
90
+
91
+ case 'acrossSegments':
92
+ return GlobPattern.matchWildcard(path, following, false);
93
+
94
+ case 'wholeDirectories':
95
+ return GlobPattern.matchWholeDirectories(path, following);
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Matches characters that must appear exactly.
101
+ *
102
+ * @param text - The characters the pattern gives
103
+ * @param path - The path being matched
104
+ * @param following - Where the parts after this one can match from
105
+ * @returns Where this part and everything after it can match from
106
+ */
107
+ private static matchLiteral(
108
+ text: string,
109
+ path: string,
110
+ following: readonly boolean[],
111
+ ): boolean[] {
112
+ const reached: boolean[] = new Array<boolean>(path.length + 1).fill(false);
113
+
114
+ for (let index: number = 0; index + text.length <= path.length; index += 1) {
115
+ reached[index] = path.startsWith(text, index) && (following[index + text.length] ?? false);
116
+ }
117
+
118
+ return reached;
119
+ }
120
+
121
+ /**
122
+ * Matches `?`: exactly one character, which may not be a separator.
123
+ *
124
+ * @param path - The path being matched
125
+ * @param following - Where the parts after this one can match from
126
+ * @returns Where this part and everything after it can match from
127
+ */
128
+ private static matchOneCharacter(path: string, following: readonly boolean[]): boolean[] {
129
+ const reached: boolean[] = new Array<boolean>(path.length + 1).fill(false);
130
+
131
+ for (let index: number = 0; index < path.length; index += 1) {
132
+ reached[index] = path[index] !== '/' && (following[index + 1] ?? false);
133
+ }
134
+
135
+ return reached;
136
+ }
137
+
138
+ /**
139
+ * Matches `*` or `**`: a run of any length.
140
+ *
141
+ * A wildcard either stops where it is and lets the parts after it match, or
142
+ * takes one more character and asks itself the same question. Answering
143
+ * from the end of the path backwards means each position is settled once,
144
+ * whereas a regular expression would try the divisions one after another.
145
+ *
146
+ * @param path - The path being matched
147
+ * @param following - Where the parts after this one can match from
148
+ * @param withinSegment - Whether the run must stay inside one path segment
149
+ * @returns Where this part and everything after it can match from
150
+ */
151
+ private static matchWildcard(
152
+ path: string,
153
+ following: readonly boolean[],
154
+ withinSegment: boolean,
155
+ ): boolean[] {
156
+ const reached: boolean[] = new Array<boolean>(path.length + 1).fill(false);
157
+
158
+ for (let index: number = path.length; index >= 0; index -= 1) {
159
+ const canTakeOneMore: boolean =
160
+ index < path.length && (!withinSegment || path[index] !== '/');
161
+
162
+ reached[index] =
163
+ (following[index] ?? false) || (canTakeOneMore && (reached[index + 1] ?? false));
164
+ }
165
+
166
+ return reached;
167
+ }
168
+
169
+ /**
170
+ * Matches a `**` immediately before a slash: zero or more whole directories.
171
+ *
172
+ * @param path - The path being matched
173
+ * @param following - Where the parts after this one can match from
174
+ * @returns Where this part and everything after it can match from
175
+ */
176
+ private static matchWholeDirectories(path: string, following: readonly boolean[]): boolean[] {
177
+ // Whole directories are taken whole, so this part matches either nothing
178
+ // at all, or a run of characters ending at a separator.
179
+ const endsAtSlash: boolean[] = new Array<boolean>(path.length + 1).fill(false);
180
+
181
+ for (let index: number = path.length - 1; index >= 0; index -= 1) {
182
+ endsAtSlash[index] =
183
+ (path[index] === '/' && (following[index + 1] ?? false)) ||
184
+ (endsAtSlash[index + 1] ?? false);
185
+ }
186
+
187
+ return endsAtSlash.map(
188
+ (endsHere: boolean, index: number): boolean => (following[index] ?? false) || endsHere,
189
+ );
190
+ }
191
+
192
+ /**
193
+ * Splits a pattern into its parts, gathering runs of ordinary characters
194
+ * into one literal.
195
+ *
196
+ * @param pattern - The glob pattern
197
+ * @returns The parts, in order
198
+ */
199
+ private static compile(pattern: string): readonly GlobPart[] {
200
+ const parts: GlobPart[] = [];
201
+ let literal: string = '';
202
+
203
+ /** Closes off any literal gathered so far. */
204
+ const flushLiteral = (): void => {
205
+ if (literal !== '') {
206
+ parts.push({ kind: 'literal', text: literal });
207
+ literal = '';
208
+ }
209
+ };
210
+
211
+ for (let index: number = 0; index < pattern.length; index += 1) {
212
+ const character: string = pattern[index] ?? '';
213
+
214
+ if (character === '*') {
215
+ flushLiteral();
216
+
217
+ if (pattern[index + 1] === '*' && pattern[index + 2] === '/') {
218
+ parts.push({ kind: 'wholeDirectories' });
219
+ index += 2;
220
+ } else if (pattern[index + 1] === '*') {
221
+ parts.push({ kind: 'acrossSegments' });
222
+ index += 1;
223
+ } else {
224
+ parts.push({ kind: 'withinSegment' });
225
+ }
226
+ continue;
227
+ }
228
+
229
+ if (character === '?') {
230
+ flushLiteral();
231
+ parts.push({ kind: 'oneCharacter' });
232
+ continue;
233
+ }
234
+
235
+ literal += character;
236
+ }
237
+
238
+ flushLiteral();
239
+
240
+ return parts;
241
+ }
242
+ }