@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
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Corpus Loading
3
+ *
4
+ * File discovery is the host's responsibility, not the linter's. This module
5
+ * is the CLI's implementation of that responsibility: it walks a directory,
6
+ * excludes meta-documents, and produces the inputs the linter consumes.
7
+ */
8
+
9
+ import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
10
+ import type { Stats } from 'node:fs';
11
+ import { isAbsolute, join, relative } from 'node:path';
12
+
13
+ import { MetaDocumentFilter } from '../meta-documents.js';
14
+ import type { CorpusDocumentInput } from '../ecr.js';
15
+
16
+ /**
17
+ * A path the walk reached but could not read.
18
+ */
19
+ export interface UnreadablePath {
20
+ /** Corpus-relative path, with forward slashes. */
21
+ readonly path: string;
22
+ /** Why it could not be read: the file system's error code, or its message. */
23
+ readonly reason: string;
24
+ }
25
+
26
+ /**
27
+ * The outcome of walking a corpus directory.
28
+ */
29
+ export interface LoadedCorpus {
30
+ /** Documents to validate, in path order. */
31
+ readonly documents: readonly CorpusDocumentInput[];
32
+ /**
33
+ * Corpus-relative paths excluded as meta-documents or by ignore pattern. A
34
+ * directory excluded whole by a pattern is listed once, with a trailing
35
+ * slash, and is not walked.
36
+ */
37
+ readonly excludedPaths: readonly string[];
38
+ /**
39
+ * Links beneath the corpus root that were not followed: every link to a
40
+ * directory, every link named like a document, and every link whose target
41
+ * is missing.
42
+ */
43
+ readonly notFollowedPaths: readonly string[];
44
+ /**
45
+ * Paths that could not be read. A corpus with any of these was not fully
46
+ * read, so its validation result cannot stand for the whole corpus.
47
+ */
48
+ readonly unreadablePaths: readonly UnreadablePath[];
49
+ /**
50
+ * Whether the project's `.ecrignore` excludes the corpus root itself, in
51
+ * which case nothing beneath it was walked.
52
+ */
53
+ readonly rootExcluded: boolean;
54
+ }
55
+
56
+ /**
57
+ * A project's own ignore patterns, from its `.ecrignore`, and the directory
58
+ * they are relative to.
59
+ */
60
+ export interface ProjectIgnore {
61
+ /** The project root: the directory the command runs from. */
62
+ readonly workingDirectory: string;
63
+ /** Patterns relative to the working directory. */
64
+ readonly patterns: readonly string[];
65
+ }
66
+
67
+ /**
68
+ * Directory-walking characters that mark a path as hidden on every platform
69
+ * this runs on. Hidden directories are skipped so that `.git`, `.obsidian` and
70
+ * similar never reach the linter.
71
+ */
72
+ const HIDDEN_PREFIX: string = '.';
73
+
74
+ /**
75
+ * The directory package managers install dependencies into. It is skipped
76
+ * wherever the walk meets it: an installed copy of this package carries the
77
+ * specification, whose DocIDs would collide with a project's own, and any
78
+ * other package's Markdown is not the project's documentation. A corpus root
79
+ * named explicitly is still walked, even inside one.
80
+ */
81
+ const DEPENDENCY_DIRECTORY: string = 'node_modules';
82
+
83
+ /**
84
+ * Extension identifying a Markdown document.
85
+ */
86
+ const MARKDOWN_EXTENSION: string = '.md';
87
+
88
+ /**
89
+ * Discovers and reads the Markdown documents of a corpus.
90
+ *
91
+ * Paths are reported relative to the corpus root and normalised to forward
92
+ * slashes, so that ignore patterns and diagnostics read the same on every
93
+ * platform.
94
+ *
95
+ * Symbolic links and junctions beneath the root are not followed, because the
96
+ * published recursive searches (`rg`, `grep -r`) do not follow them: a
97
+ * document reached only through a link would be validated but could not be
98
+ * found (1#9.11). A root that is itself a link is followed, as both search
99
+ * engines follow a link named on their command line.
100
+ */
101
+ export class CorpusLoader {
102
+ /** Absolute or relative path to the corpus root directory. */
103
+ private readonly corpusRoot: string;
104
+
105
+ /** Decides which discovered documents are excluded from validation. */
106
+ private readonly metaDocumentFilter: MetaDocumentFilter;
107
+
108
+ /**
109
+ * Applies the project's `.ecrignore` patterns, which are relative to the
110
+ * working directory rather than the corpus root; absent when there are none.
111
+ */
112
+ private readonly projectFilter: MetaDocumentFilter | undefined;
113
+
114
+ /** The directory the project's patterns are relative to. */
115
+ private readonly workingDirectory: string | undefined;
116
+
117
+ /**
118
+ * Creates a loader for one corpus directory.
119
+ *
120
+ * @param corpusRoot - Path to the directory to walk
121
+ * @param ignorePatterns - Glob patterns relative to the corpus root, from `--ignore`
122
+ * @param projectIgnore - The project's `.ecrignore` patterns, relative to the working directory
123
+ */
124
+ public constructor(
125
+ corpusRoot: string,
126
+ ignorePatterns: readonly string[] = [],
127
+ projectIgnore?: ProjectIgnore,
128
+ ) {
129
+ this.corpusRoot = corpusRoot;
130
+ this.metaDocumentFilter = new MetaDocumentFilter(ignorePatterns);
131
+ // No meta-document names: those are already applied by the corpus filter.
132
+ this.projectFilter =
133
+ projectIgnore === undefined ? undefined : new MetaDocumentFilter(projectIgnore.patterns, []);
134
+ this.workingDirectory = projectIgnore?.workingDirectory;
135
+ }
136
+
137
+ /**
138
+ * Walks the corpus root and reads every Markdown document that is not
139
+ * excluded.
140
+ *
141
+ * A path that cannot be read is recorded and the walk continues, so that
142
+ * every such path is reported at once.
143
+ *
144
+ * @returns The documents to validate, and the paths excluded, not followed, or not readable
145
+ */
146
+ public load(): LoadedCorpus {
147
+ const walk: CorpusWalk = new CorpusWalk();
148
+ const rootExcluded: boolean = this.tellProjectExcludesRoot();
149
+
150
+ if (!rootExcluded) {
151
+ this.walkDirectory(this.corpusRoot, walk);
152
+ }
153
+
154
+ // Sorted by corpus-relative path, so the order is the same on every
155
+ // platform whatever the path separator.
156
+ walk.documents.sort((left: CorpusDocumentInput, right: CorpusDocumentInput): number =>
157
+ CorpusLoader.comparePaths(left.uri, right.uri),
158
+ );
159
+ walk.excludedPaths.sort((left: string, right: string): number =>
160
+ CorpusLoader.comparePaths(left, right),
161
+ );
162
+ walk.notFollowedPaths.sort((left: string, right: string): number =>
163
+ CorpusLoader.comparePaths(left, right),
164
+ );
165
+ walk.unreadablePaths.sort((left: UnreadablePath, right: UnreadablePath): number =>
166
+ CorpusLoader.comparePaths(left.path, right.path),
167
+ );
168
+
169
+ return {
170
+ documents: walk.documents,
171
+ excludedPaths: walk.excludedPaths,
172
+ notFollowedPaths: walk.notFollowedPaths,
173
+ unreadablePaths: walk.unreadablePaths,
174
+ rootExcluded,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Orders two corpus-relative paths by their UTF-16 code units, as
180
+ * `Array.prototype.sort` does by default.
181
+ *
182
+ * @param left - A path
183
+ * @param right - Another path
184
+ * @returns Negative, zero or positive, as for a sort comparator
185
+ */
186
+ private static comparePaths(left: string, right: string): number {
187
+ if (left === right) {
188
+ return 0;
189
+ }
190
+
191
+ return left < right ? -1 : 1;
192
+ }
193
+
194
+ /**
195
+ * Walks one directory, in name order.
196
+ *
197
+ * @param directory - Directory to walk
198
+ * @param walk - What the walk has found so far
199
+ */
200
+ private walkDirectory(directory: string, walk: CorpusWalk): void {
201
+ let entryNames: string[];
202
+
203
+ try {
204
+ entryNames = readdirSync(directory).sort((left: string, right: string): number =>
205
+ CorpusLoader.comparePaths(left, right),
206
+ );
207
+ } catch (error: unknown) {
208
+ walk.recordUnreadable(this.toCorpusRelativePath(directory), error);
209
+ return;
210
+ }
211
+
212
+ for (const entryName of entryNames) {
213
+ if (entryName.startsWith(HIDDEN_PREFIX)) {
214
+ continue;
215
+ }
216
+
217
+ this.walkEntry(join(directory, entryName), entryName, walk);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Walks one directory entry: a link is recorded and not followed, a
223
+ * directory is descended into unless an ignore pattern excludes it whole,
224
+ * and a Markdown file is read unless it is excluded.
225
+ *
226
+ * @param entryPath - Path of the entry
227
+ * @param entryName - The entry's own name
228
+ * @param walk - What the walk has found so far
229
+ */
230
+ private walkEntry(entryPath: string, entryName: string, walk: CorpusWalk): void {
231
+ const relativePath: string = this.toCorpusRelativePath(entryPath);
232
+ let entryStats: Stats;
233
+
234
+ try {
235
+ entryStats = lstatSync(entryPath);
236
+ } catch (error: unknown) {
237
+ if (this.tellExcludedWhateverItIs(relativePath, entryPath)) {
238
+ walk.excludedPaths.push(relativePath);
239
+ return;
240
+ }
241
+
242
+ walk.recordUnreadable(relativePath, error);
243
+ return;
244
+ }
245
+
246
+ if (entryStats.isSymbolicLink()) {
247
+ this.recordLink(entryPath, entryName, relativePath, walk);
248
+ return;
249
+ }
250
+
251
+ if (entryStats.isDirectory()) {
252
+ if (
253
+ entryName === DEPENDENCY_DIRECTORY ||
254
+ this.metaDocumentFilter.excludesDirectory(relativePath) ||
255
+ this.tellProjectExcludesDirectory(entryPath)
256
+ ) {
257
+ walk.excludedPaths.push(`${relativePath}/`);
258
+ return;
259
+ }
260
+
261
+ this.walkDirectory(entryPath, walk);
262
+ return;
263
+ }
264
+
265
+ if (!CorpusLoader.tellMarkdownName(entryName)) {
266
+ return;
267
+ }
268
+
269
+ if (
270
+ this.metaDocumentFilter.shouldExclude(relativePath) ||
271
+ this.tellProjectExcludesFile(entryPath)
272
+ ) {
273
+ walk.excludedPaths.push(relativePath);
274
+ return;
275
+ }
276
+
277
+ try {
278
+ walk.documents.push({
279
+ uri: relativePath,
280
+ markdownText: readFileSync(entryPath, 'utf8'),
281
+ });
282
+ } catch (error: unknown) {
283
+ walk.recordUnreadable(relativePath, error);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Records a link without following it. An ignore pattern covering it
289
+ * excludes it instead; a link to a file that is not Markdown is of no
290
+ * interest, as it could never be a document.
291
+ *
292
+ * @param entryPath - Path of the link
293
+ * @param entryName - The link's own name
294
+ * @param relativePath - The link's corpus-relative path
295
+ * @param walk - What the walk has found so far
296
+ */
297
+ private recordLink(
298
+ entryPath: string,
299
+ entryName: string,
300
+ relativePath: string,
301
+ walk: CorpusWalk,
302
+ ): void {
303
+ if (this.tellExcludedWhateverItIs(relativePath, entryPath)) {
304
+ walk.excludedPaths.push(relativePath);
305
+ return;
306
+ }
307
+
308
+ if (!CorpusLoader.tellMarkdownName(entryName) && CorpusLoader.tellLinksToFile(entryPath)) {
309
+ return;
310
+ }
311
+
312
+ walk.notFollowedPaths.push(relativePath);
313
+ }
314
+
315
+ /**
316
+ * Determines whether an ignore pattern of either kind covers a path whose
317
+ * kind cannot or need not be known, such as a link or a vanished entry.
318
+ *
319
+ * @param relativePath - Corpus-relative path
320
+ * @param entryPath - Path of the entry, for the project's patterns
321
+ * @returns `true` when a file pattern or a whole-directory pattern covers it
322
+ */
323
+ private tellExcludedWhateverItIs(relativePath: string, entryPath: string): boolean {
324
+ return (
325
+ this.metaDocumentFilter.excludesDirectory(relativePath) ||
326
+ this.metaDocumentFilter.matchesIgnorePattern(relativePath) ||
327
+ this.tellProjectExcludesDirectory(entryPath) ||
328
+ this.tellProjectExcludesFile(entryPath)
329
+ );
330
+ }
331
+
332
+ /**
333
+ * Determines whether a `.ecrignore` pattern excludes the corpus root, either
334
+ * itself or through a directory above it: with `ecr/**` ignored, a root of
335
+ * `ecr/sub` is excluded as surely as `ecr`.
336
+ *
337
+ * @returns `true` when the root, or any directory between it and the project root, is excluded whole
338
+ */
339
+ private tellProjectExcludesRoot(): boolean {
340
+ const projectPath: string | undefined = this.toProjectRelativePath(this.corpusRoot);
341
+
342
+ if (projectPath === undefined || projectPath === '') {
343
+ return false;
344
+ }
345
+
346
+ const segments: readonly string[] = projectPath.split('/');
347
+
348
+ return segments.some((_segment: string, index: number): boolean =>
349
+ this.projectFilter?.excludesDirectory(segments.slice(0, index + 1).join('/')) === true,
350
+ );
351
+ }
352
+
353
+ /**
354
+ * Determines whether a `.ecrignore` pattern excludes a whole directory.
355
+ *
356
+ * @param directoryPath - Path of the directory
357
+ * @returns `true` when the project's patterns cover everything beneath it
358
+ */
359
+ private tellProjectExcludesDirectory(directoryPath: string): boolean {
360
+ const projectPath: string | undefined = this.toProjectRelativePath(directoryPath);
361
+
362
+ return projectPath !== undefined && projectPath !== '' &&
363
+ this.projectFilter?.excludesDirectory(projectPath) === true;
364
+ }
365
+
366
+ /**
367
+ * Determines whether a `.ecrignore` pattern excludes a file.
368
+ *
369
+ * @param filePath - Path of the file
370
+ * @returns `true` when one of the project's patterns matches it
371
+ */
372
+ private tellProjectExcludesFile(filePath: string): boolean {
373
+ const projectPath: string | undefined = this.toProjectRelativePath(filePath);
374
+
375
+ return projectPath !== undefined && this.projectFilter?.matchesIgnorePattern(projectPath) === true;
376
+ }
377
+
378
+ /**
379
+ * Converts a path into one relative to the working directory, with forward
380
+ * slashes, which is what `.ecrignore` patterns are matched against.
381
+ *
382
+ * @param path - A path
383
+ * @returns The path relative to the working directory, or `undefined` when there are no project patterns or the path lies outside the project
384
+ */
385
+ private toProjectRelativePath(path: string): string | undefined {
386
+ if (this.workingDirectory === undefined) {
387
+ return undefined;
388
+ }
389
+
390
+ const projectPath: string = relative(this.workingDirectory, path);
391
+
392
+ // The project's patterns say nothing about paths outside the project.
393
+ if (isAbsolute(projectPath) || /^\.\.(?:[\\/]|$)/.test(projectPath)) {
394
+ return undefined;
395
+ }
396
+
397
+ return projectPath.replaceAll('\\', '/');
398
+ }
399
+
400
+ /**
401
+ * Determines whether a name is a Markdown document's.
402
+ *
403
+ * @param entryName - A file or link name
404
+ * @returns `true` for a `.md` name, in any case
405
+ */
406
+ private static tellMarkdownName(entryName: string): boolean {
407
+ return entryName.toLowerCase().endsWith(MARKDOWN_EXTENSION);
408
+ }
409
+
410
+ /**
411
+ * Determines whether a link resolves to something other than a directory.
412
+ *
413
+ * @param linkPath - Path of the link
414
+ * @returns `true` when the target exists and is not a directory
415
+ */
416
+ private static tellLinksToFile(linkPath: string): boolean {
417
+ try {
418
+ return !statSync(linkPath).isDirectory();
419
+ } catch {
420
+ return false;
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Converts an absolute path into a corpus-relative path with forward slashes.
426
+ *
427
+ * @param absolutePath - A path beneath the corpus root
428
+ * @returns The path relative to the corpus root
429
+ */
430
+ private toCorpusRelativePath(absolutePath: string): string {
431
+ const withoutRoot: string = absolutePath.startsWith(this.corpusRoot)
432
+ ? absolutePath.slice(this.corpusRoot.length)
433
+ : absolutePath;
434
+
435
+ return withoutRoot
436
+ .split('\\')
437
+ .join('/')
438
+ .replace(/^\/+/, '');
439
+ }
440
+ }
441
+
442
+ /**
443
+ * What one walk of a corpus has found so far.
444
+ */
445
+ class CorpusWalk {
446
+ /** Documents read, in the order reached. */
447
+ public readonly documents: CorpusDocumentInput[] = [];
448
+
449
+ /** Paths excluded as meta-documents or by ignore pattern. */
450
+ public readonly excludedPaths: string[] = [];
451
+
452
+ /** Links recorded and not followed. */
453
+ public readonly notFollowedPaths: string[] = [];
454
+
455
+ /** Paths reached but not readable. */
456
+ public readonly unreadablePaths: UnreadablePath[] = [];
457
+
458
+ /**
459
+ * Records a path that could not be read.
460
+ *
461
+ * @param path - The corpus-relative path; empty for the corpus root itself
462
+ * @param error - What reading it threw
463
+ */
464
+ public recordUnreadable(path: string, error: unknown): void {
465
+ this.unreadablePaths.push({ path: path === '' ? '.' : path, reason: CorpusWalk.showReason(error) });
466
+ }
467
+
468
+ /**
469
+ * Describes a file-system error briefly.
470
+ *
471
+ * @param error - What was thrown
472
+ * @returns The error's code, such as `ENOENT`, or its message
473
+ */
474
+ private static showReason(error: unknown): string {
475
+ if (error instanceof Error) {
476
+ const code: unknown = (error as NodeJS.ErrnoException).code;
477
+ return typeof code === 'string' ? code : error.message;
478
+ }
479
+
480
+ return String(error);
481
+ }
482
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Corpus Statistics
3
+ *
4
+ * Counts the structure ECR makes explicit, so that a reader can measure their
5
+ * own corpus rather than take anyone's figures on trust. Every number here is
6
+ * derived from validated artefacts, not from a regular expression over the
7
+ * source, so the same corpus always yields the same counts.
8
+ */
9
+
10
+ import { UNPARSABLE_DOCUMENT_RULE_ID } from '../per-document-visitor.js';
11
+ import type { CorpusResult, ReferenceDirection, ExtractedDocument } from '../types.js';
12
+
13
+ /**
14
+ * Counts of References-section entries by their declared direction.
15
+ */
16
+ export interface DirectionCounts {
17
+ /** Entries declaring the cited document as an authority. */
18
+ readonly authority: number;
19
+ /** Entries declaring the cited document as a constraint. */
20
+ readonly constraint: number;
21
+ /** Entries declaring the cited document as a contract. */
22
+ readonly contract: number;
23
+ /** Entries declaring the cited document as a dependency. */
24
+ readonly dependency: number;
25
+ }
26
+
27
+ /**
28
+ * A measured summary of an ECR corpus.
29
+ */
30
+ export interface CorpusStatisticsReport {
31
+ /** Documents validated, excluding meta-documents. */
32
+ readonly documents: number;
33
+ /** Documents that carry at least one References entry. */
34
+ readonly documentsWithReferences: number;
35
+ /** Sections extracted across the corpus, excluding the root H1 of each document. */
36
+ readonly sections: number;
37
+ /** Unique DocIDs found in the corpus. */
38
+ readonly docIds: number;
39
+ /** Unique SectionIDs found in the corpus. */
40
+ readonly sectionIds: number;
41
+ /** References-section entries, the document-level edges. */
42
+ readonly referenceEntries: number;
43
+ /** References-section entries broken down by direction. */
44
+ readonly referencesByDirection: DirectionCounts;
45
+ /** Inline `see`/`per` references. */
46
+ readonly inlineReferences: number;
47
+ /** Inline references naming a precise section rather than a whole document. */
48
+ readonly sectionPreciseInlineReferences: number;
49
+ /** Every edge in the corpus: References entries plus inline references. */
50
+ readonly totalEdges: number;
51
+ /**
52
+ * Whether every input document was parsed successfully. This does not imply
53
+ * ECR conformance or complete extraction.
54
+ *
55
+ * A document that was parsed but has no recoverable DocID contributes
56
+ * nothing to any count, and one with structural errors contributes whatever
57
+ * did pass validation; both leave this `true`, because both were parsed.
58
+ * Only a document the parser could not read at all makes it `false`, and
59
+ * {@link unparsable} names those documents.
60
+ */
61
+ readonly complete: boolean;
62
+ /**
63
+ * Documents the parser could not read at all, which contribute nothing.
64
+ * A document with ordinary validation errors is not listed here: it still
65
+ * contributes whatever passed validation.
66
+ */
67
+ readonly unparsable: readonly string[];
68
+ }
69
+
70
+ /**
71
+ * Derives a statistical summary from a corpus validation result.
72
+ *
73
+ * Only documents that produced extracted artefacts contribute, which means
74
+ * every document with a valid H1 DocID. A document with other structural
75
+ * errors still counts, and contributes whatever was extracted from it: a
76
+ * heading or References entry that could not be read at all adds nothing,
77
+ * while one reported for something other than its structure -- a heading
78
+ * whose source form breaks the navigation guarantee, say -- is extracted and
79
+ * counted like any other. A document with no recoverable DocID contributes
80
+ * nothing at all.
81
+ */
82
+ export class CorpusStatistics {
83
+ /** The validation result to summarise. */
84
+ private readonly corpusResult: CorpusResult;
85
+
86
+ /**
87
+ * Creates a summariser for one validation result.
88
+ *
89
+ * @param corpusResult - The result produced by validating the corpus
90
+ */
91
+ public constructor(corpusResult: CorpusResult) {
92
+ this.corpusResult = corpusResult;
93
+ }
94
+
95
+ /**
96
+ * Computes the summary.
97
+ *
98
+ * @returns Counts describing the corpus's explicit structure
99
+ */
100
+ public summarise(): CorpusStatisticsReport {
101
+ const extractedDocuments: readonly ExtractedDocument[] = this.collectExtractedDocuments();
102
+ const unparsable: readonly string[] = this.collectUnparsableDocuments();
103
+
104
+ let documentsWithReferences: number = 0;
105
+ let sections: number = 0;
106
+ let referenceEntries: number = 0;
107
+ let inlineReferences: number = 0;
108
+ let sectionPreciseInlineReferences: number = 0;
109
+
110
+ const referencesByDirection: Record<ReferenceDirection, number> = {
111
+ authority: 0,
112
+ constraint: 0,
113
+ contract: 0,
114
+ dependency: 0,
115
+ };
116
+
117
+ for (const document of extractedDocuments) {
118
+ if (document.references.length > 0) {
119
+ documentsWithReferences += 1;
120
+ }
121
+
122
+ // The root H1 is a node but not a section, so it is not counted here.
123
+ sections += Math.max(document.sections.length - 1, 0);
124
+ referenceEntries += document.references.length;
125
+ inlineReferences += document.inlineReferences.length;
126
+
127
+ for (const reference of document.references) {
128
+ referencesByDirection[reference.direction] += 1;
129
+ }
130
+
131
+ for (const inlineReference of document.inlineReferences) {
132
+ if (inlineReference.toId.includes('#')) {
133
+ sectionPreciseInlineReferences += 1;
134
+ }
135
+ }
136
+ }
137
+
138
+ return {
139
+ documents: extractedDocuments.length,
140
+ documentsWithReferences,
141
+ sections,
142
+ docIds: this.countIndexEntries(this.corpusResult.index?.docIds),
143
+ sectionIds: this.countIndexEntries(this.corpusResult.index?.sectionIds),
144
+ referenceEntries,
145
+ referencesByDirection: { ...referencesByDirection },
146
+ inlineReferences,
147
+ sectionPreciseInlineReferences,
148
+ totalEdges: referenceEntries + inlineReferences,
149
+ complete: unparsable.length === 0,
150
+ unparsable,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Gathers the extracted artefacts of every document that produced them.
156
+ *
157
+ * @returns Extracted documents, in corpus order
158
+ */
159
+ private collectExtractedDocuments(): readonly ExtractedDocument[] {
160
+ const extracted: ExtractedDocument[] = [];
161
+
162
+ for (const entry of this.corpusResult.documents) {
163
+ if (entry.result.extracted !== undefined) {
164
+ extracted.push(entry.result.extracted);
165
+ }
166
+ }
167
+
168
+ return extracted;
169
+ }
170
+
171
+ /**
172
+ * Names the documents the parser could not read, so that a reader knows
173
+ * what these counts leave out rather than taking them for the whole corpus.
174
+ *
175
+ * @returns Their URIs, in corpus order
176
+ */
177
+ private collectUnparsableDocuments(): readonly string[] {
178
+ return this.corpusResult.documents
179
+ .filter((entry): boolean =>
180
+ entry.result.diagnostics.some(
181
+ (diagnostic): boolean => diagnostic.ruleId === UNPARSABLE_DOCUMENT_RULE_ID,
182
+ ),
183
+ )
184
+ .map((entry): string => entry.uri);
185
+ }
186
+
187
+ /**
188
+ * Counts the entries of a corpus index map.
189
+ *
190
+ * @param index - A DocID or SectionID index, absent when indexing did not succeed
191
+ * @returns The number of entries, or 0 when the index is absent
192
+ */
193
+ private countIndexEntries(index: Readonly<Record<string, string>> | undefined): number {
194
+ return index === undefined ? 0 : Object.keys(index).length;
195
+ }
196
+ }