@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,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
|
+
}
|