@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,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostic Reporting
|
|
3
|
+
*
|
|
4
|
+
* Renders validation results for a terminal or for a machine. The pretty form
|
|
5
|
+
* groups diagnostics by document and prints a `path:line:column` prefix so an
|
|
6
|
+
* editor can jump to them; the JSON form is the same data unformatted, for CI
|
|
7
|
+
* and for tooling built on top of the CLI.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { showControlCharacters } from './safe-text.js';
|
|
11
|
+
import { UNPARSABLE_DOCUMENT_RULE_ID } from '../per-document-visitor.js';
|
|
12
|
+
import type { CorpusResult, Diagnostic, DiagnosticSeverity } from '../types.js';
|
|
13
|
+
import type { CorpusStatisticsReport, DirectionCounts } from './corpus-statistics.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Output formats the CLI can produce.
|
|
17
|
+
*/
|
|
18
|
+
export type ReportFormat = 'pretty' | 'json';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A diagnostic together with the document it belongs to.
|
|
22
|
+
*/
|
|
23
|
+
interface LocatedDiagnostic {
|
|
24
|
+
/** Corpus-relative path of the document, or `(corpus)` when a diagnostic names no known document. */
|
|
25
|
+
readonly path: string;
|
|
26
|
+
/** The diagnostic itself. */
|
|
27
|
+
readonly diagnostic: Diagnostic;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Counts of diagnostics by severity.
|
|
32
|
+
*/
|
|
33
|
+
export interface SeverityTotals {
|
|
34
|
+
/** Diagnostics that make the corpus non-conforming. */
|
|
35
|
+
readonly errors: number;
|
|
36
|
+
/** Diagnostics that warrant attention but do not fail validation. */
|
|
37
|
+
readonly warnings: number;
|
|
38
|
+
/** Informational diagnostics. */
|
|
39
|
+
readonly infos: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Label used for a diagnostic whose URI matches no validated document. */
|
|
43
|
+
const CORPUS_SCOPE_LABEL: string = '(corpus)';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Formats corpus validation results and statistics for output.
|
|
47
|
+
*/
|
|
48
|
+
export class DiagnosticReporter {
|
|
49
|
+
/** The format to render in. */
|
|
50
|
+
private readonly format: ReportFormat;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Creates a reporter.
|
|
54
|
+
*
|
|
55
|
+
* @param format - The output format to produce
|
|
56
|
+
*/
|
|
57
|
+
public constructor(format: ReportFormat) {
|
|
58
|
+
this.format = format;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Renders a corpus validation result.
|
|
63
|
+
*
|
|
64
|
+
* @param corpusResult - The result to render
|
|
65
|
+
* @param excludedPaths - Paths skipped as meta-documents or by ignore pattern
|
|
66
|
+
* @param notFollowedPaths - Links beneath the corpus root that were not followed
|
|
67
|
+
* @returns The text to write to standard output
|
|
68
|
+
*/
|
|
69
|
+
public reportValidation(
|
|
70
|
+
corpusResult: CorpusResult,
|
|
71
|
+
excludedPaths: readonly string[],
|
|
72
|
+
notFollowedPaths: readonly string[],
|
|
73
|
+
): string {
|
|
74
|
+
const located: readonly LocatedDiagnostic[] = this.collectDiagnostics(corpusResult);
|
|
75
|
+
const totals: SeverityTotals = this.countSeverities(located);
|
|
76
|
+
|
|
77
|
+
if (this.format === 'json') {
|
|
78
|
+
return JSON.stringify(
|
|
79
|
+
{
|
|
80
|
+
documents: corpusResult.documents.length,
|
|
81
|
+
excluded: excludedPaths,
|
|
82
|
+
notFollowed: notFollowedPaths,
|
|
83
|
+
totals,
|
|
84
|
+
diagnostics: located.map((entry: LocatedDiagnostic) => ({
|
|
85
|
+
path: entry.path,
|
|
86
|
+
ruleId: entry.diagnostic.ruleId,
|
|
87
|
+
severity: entry.diagnostic.severity,
|
|
88
|
+
message: entry.diagnostic.message,
|
|
89
|
+
...(entry.diagnostic.range !== undefined
|
|
90
|
+
? {
|
|
91
|
+
// Positions are zero-based internally; editors are one-based.
|
|
92
|
+
line: entry.diagnostic.range.start.line + 1,
|
|
93
|
+
column: entry.diagnostic.range.start.character + 1,
|
|
94
|
+
}
|
|
95
|
+
: {}),
|
|
96
|
+
})),
|
|
97
|
+
},
|
|
98
|
+
null,
|
|
99
|
+
2,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return this.renderPretty(corpusResult, located, totals, excludedPaths, notFollowedPaths);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Renders a statistics report.
|
|
108
|
+
*
|
|
109
|
+
* @param statistics - The measured summary to render
|
|
110
|
+
* @param notFollowedPaths - Links beneath the corpus root that were not followed, and so not measured
|
|
111
|
+
* @returns The text to write to standard output
|
|
112
|
+
*/
|
|
113
|
+
public reportStatistics(
|
|
114
|
+
statistics: CorpusStatisticsReport,
|
|
115
|
+
notFollowedPaths: readonly string[],
|
|
116
|
+
): string {
|
|
117
|
+
if (this.format === 'json') {
|
|
118
|
+
return JSON.stringify({ ...statistics, notFollowed: notFollowedPaths }, null, 2);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const direction: DirectionCounts = statistics.referencesByDirection;
|
|
122
|
+
const links: readonly string[] = DiagnosticReporter.listNotFollowed(notFollowedPaths);
|
|
123
|
+
// A document the parser could not read contributes nothing to any of
|
|
124
|
+
// these numbers, so saying which ones, and that the numbers are therefore
|
|
125
|
+
// partial, is the difference between a measurement and a misreading.
|
|
126
|
+
const unparsable: readonly string[] =
|
|
127
|
+
statistics.unparsable.length === 0
|
|
128
|
+
? []
|
|
129
|
+
: [
|
|
130
|
+
` ${String(statistics.unparsable.length)} document(s) could not be parsed, ` +
|
|
131
|
+
'so they are not counted above:',
|
|
132
|
+
...statistics.unparsable.map((path: string): string => ` ${path}`),
|
|
133
|
+
' These statistics describe the rest of the corpus.',
|
|
134
|
+
'',
|
|
135
|
+
];
|
|
136
|
+
const lines: readonly string[] = [
|
|
137
|
+
'',
|
|
138
|
+
' Corpus structure',
|
|
139
|
+
' ────────────────',
|
|
140
|
+
` documents ${String(statistics.documents)}`,
|
|
141
|
+
` with a References section ${String(statistics.documentsWithReferences)}`,
|
|
142
|
+
` sections ${String(statistics.sections)}`,
|
|
143
|
+
'',
|
|
144
|
+
` unique DocIDs ${String(statistics.docIds)}`,
|
|
145
|
+
` unique SectionIDs ${String(statistics.sectionIds)}`,
|
|
146
|
+
'',
|
|
147
|
+
` References entries ${String(statistics.referenceEntries)}`,
|
|
148
|
+
` authority ${String(direction.authority)}`,
|
|
149
|
+
` constraint ${String(direction.constraint)}`,
|
|
150
|
+
` contract ${String(direction.contract)}`,
|
|
151
|
+
` dependency ${String(direction.dependency)}`,
|
|
152
|
+
'',
|
|
153
|
+
` inline see/per references ${String(statistics.inlineReferences)}`,
|
|
154
|
+
` section-precise ${String(statistics.sectionPreciseInlineReferences)}`,
|
|
155
|
+
'',
|
|
156
|
+
` total edges ${String(statistics.totalEdges)}`,
|
|
157
|
+
'',
|
|
158
|
+
...unparsable,
|
|
159
|
+
...links,
|
|
160
|
+
...(links.length > 0 ? [''] : []),
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
return showControlCharacters(lines.join('\n'));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Counts the documents the parser could not read.
|
|
168
|
+
*
|
|
169
|
+
* @param corpusResult - The result being reported
|
|
170
|
+
* @returns How many documents produced an unparsable-document diagnostic
|
|
171
|
+
*/
|
|
172
|
+
private static countUnparsable(corpusResult: CorpusResult): number {
|
|
173
|
+
return corpusResult.documents.filter((entry): boolean =>
|
|
174
|
+
entry.result.diagnostics.some(
|
|
175
|
+
(diagnostic: Diagnostic): boolean => diagnostic.ruleId === UNPARSABLE_DOCUMENT_RULE_ID,
|
|
176
|
+
),
|
|
177
|
+
).length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Lists the links a walk did not follow, by name: unlike an exclusion,
|
|
182
|
+
* nobody asked for them to be skipped.
|
|
183
|
+
*
|
|
184
|
+
* @param notFollowedPaths - Links beneath the corpus root that were not followed
|
|
185
|
+
* @returns Lines to print; none when there are no such links
|
|
186
|
+
*/
|
|
187
|
+
public static listNotFollowed(notFollowedPaths: readonly string[]): readonly string[] {
|
|
188
|
+
if (notFollowedPaths.length === 0) {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return [
|
|
193
|
+
` ${String(notFollowedPaths.length)} link(s) not followed:`,
|
|
194
|
+
...notFollowedPaths.map((path: string): string => ` ${path}`),
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Renders the human-readable form of a validation result.
|
|
200
|
+
*
|
|
201
|
+
* @param corpusResult - The result being rendered
|
|
202
|
+
* @param located - Diagnostics paired with their document paths
|
|
203
|
+
* @param totals - Severity counts across the corpus
|
|
204
|
+
* @param excludedPaths - Paths skipped during discovery
|
|
205
|
+
* @param notFollowedPaths - Links beneath the corpus root that were not followed
|
|
206
|
+
* @returns The text to write to standard output
|
|
207
|
+
*/
|
|
208
|
+
private renderPretty(
|
|
209
|
+
corpusResult: CorpusResult,
|
|
210
|
+
located: readonly LocatedDiagnostic[],
|
|
211
|
+
totals: SeverityTotals,
|
|
212
|
+
excludedPaths: readonly string[],
|
|
213
|
+
notFollowedPaths: readonly string[],
|
|
214
|
+
): string {
|
|
215
|
+
const lines: string[] = [''];
|
|
216
|
+
|
|
217
|
+
let currentPath: string | undefined = undefined;
|
|
218
|
+
for (const entry of located) {
|
|
219
|
+
if (entry.path !== currentPath) {
|
|
220
|
+
lines.push(` ${entry.path}`);
|
|
221
|
+
currentPath = entry.path;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const position: string =
|
|
225
|
+
entry.diagnostic.range !== undefined
|
|
226
|
+
? `${String(entry.diagnostic.range.start.line + 1)}:` +
|
|
227
|
+
String(entry.diagnostic.range.start.character + 1)
|
|
228
|
+
: '-';
|
|
229
|
+
|
|
230
|
+
lines.push(
|
|
231
|
+
` ${position.padEnd(8)} ${entry.diagnostic.severity.padEnd(7)} ` +
|
|
232
|
+
`${entry.diagnostic.ruleId.padEnd(10)} ${entry.diagnostic.message}`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (located.length > 0) {
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const documentCount: string = String(corpusResult.documents.length);
|
|
241
|
+
|
|
242
|
+
// "checked" covers every document the command attempted, which is not the
|
|
243
|
+
// same as every document having been validated: one the parser could not
|
|
244
|
+
// read was attempted and counted, but nothing in it was validated. Those
|
|
245
|
+
// are then stated outright, and their errors are in the total already.
|
|
246
|
+
const unparsableCount: number = DiagnosticReporter.countUnparsable(corpusResult);
|
|
247
|
+
const unparsableNote: string =
|
|
248
|
+
unparsableCount > 0
|
|
249
|
+
? ` ${String(unparsableCount)} document(s) could not be parsed.`
|
|
250
|
+
: '';
|
|
251
|
+
|
|
252
|
+
if (totals.errors === 0 && totals.warnings > 0) {
|
|
253
|
+
lines.push(
|
|
254
|
+
` ${documentCount} document(s) checked, no errors, ` +
|
|
255
|
+
`${String(totals.warnings)} warning(s).${unparsableNote}`,
|
|
256
|
+
);
|
|
257
|
+
} else if (totals.errors === 0) {
|
|
258
|
+
lines.push(` ${documentCount} document(s) checked, no errors.${unparsableNote}`);
|
|
259
|
+
} else {
|
|
260
|
+
lines.push(
|
|
261
|
+
` ${documentCount} document(s) checked: ` +
|
|
262
|
+
`${String(totals.errors)} error(s), ${String(totals.warnings)} warning(s).${unparsableNote}`,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (excludedPaths.length > 0) {
|
|
267
|
+
lines.push(` ${String(excludedPaths.length)} path(s) excluded.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
lines.push(...DiagnosticReporter.listNotFollowed(notFollowedPaths));
|
|
271
|
+
|
|
272
|
+
lines.push('');
|
|
273
|
+
|
|
274
|
+
// Every line here can carry text quoted from a document or a path from
|
|
275
|
+
// the disk, so the whole report is made safe to print in one place.
|
|
276
|
+
return showControlCharacters(lines.join('\n'));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Flattens per-document and corpus-wide diagnostics into one ordered list,
|
|
281
|
+
* grouped by document.
|
|
282
|
+
*
|
|
283
|
+
* A corpus-wide diagnostic still names the document it concerns — a
|
|
284
|
+
* duplicate DocID, an unresolved reference or a stale References title each
|
|
285
|
+
* belong to a specific file — so it is listed under that document, after the
|
|
286
|
+
* document's own diagnostics. Only a corpus-wide diagnostic whose URI matches
|
|
287
|
+
* no validated document falls back to the `(corpus)` label.
|
|
288
|
+
*
|
|
289
|
+
* @param corpusResult - The result to read
|
|
290
|
+
* @returns Diagnostics paired with the path they belong to, in document order
|
|
291
|
+
*/
|
|
292
|
+
private collectDiagnostics(corpusResult: CorpusResult): readonly LocatedDiagnostic[] {
|
|
293
|
+
const located: LocatedDiagnostic[] = [];
|
|
294
|
+
const documentUris: ReadonlySet<string> = new Set(
|
|
295
|
+
corpusResult.documents.map((entry): string => entry.uri),
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
for (const entry of corpusResult.documents) {
|
|
299
|
+
for (const diagnostic of entry.result.diagnostics) {
|
|
300
|
+
located.push({ path: entry.uri, diagnostic });
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
for (const diagnostic of corpusResult.diagnostics) {
|
|
304
|
+
if (diagnostic.uri === entry.uri) {
|
|
305
|
+
located.push({ path: entry.uri, diagnostic });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const diagnostic of corpusResult.diagnostics) {
|
|
311
|
+
if (!documentUris.has(diagnostic.uri)) {
|
|
312
|
+
located.push({ path: CORPUS_SCOPE_LABEL, diagnostic });
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return located;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Counts diagnostics by severity.
|
|
321
|
+
*
|
|
322
|
+
* @param located - The diagnostics to count
|
|
323
|
+
* @returns Totals per severity
|
|
324
|
+
*/
|
|
325
|
+
private countSeverities(located: readonly LocatedDiagnostic[]): SeverityTotals {
|
|
326
|
+
let errors: number = 0;
|
|
327
|
+
let warnings: number = 0;
|
|
328
|
+
let infos: number = 0;
|
|
329
|
+
|
|
330
|
+
for (const entry of located) {
|
|
331
|
+
const severity: DiagnosticSeverity = entry.diagnostic.severity;
|
|
332
|
+
|
|
333
|
+
if (severity === 'error') {
|
|
334
|
+
errors += 1;
|
|
335
|
+
} else if (severity === 'warning') {
|
|
336
|
+
warnings += 1;
|
|
337
|
+
} else {
|
|
338
|
+
infos += 1;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return { errors, warnings, infos };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Ignore File
|
|
3
|
+
*
|
|
4
|
+
* `.ecrignore` holds a project's ignore patterns, so they need not be repeated
|
|
5
|
+
* with `--ignore` on every command. Like `.gitignore` it lives at the project
|
|
6
|
+
* root, which the CLI takes to be the working directory, as Prettier and
|
|
7
|
+
* ESLint do; it does not search upwards.
|
|
8
|
+
*
|
|
9
|
+
* Patterns use the same glob rules as `--ignore`, one per line, with blank
|
|
10
|
+
* lines and `#` comments allowed. They are relative to the working directory,
|
|
11
|
+
* whereas `--ignore` patterns are relative to the directory being linted.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
17
|
+
|
|
18
|
+
/** The file's name, at the project root. */
|
|
19
|
+
export const PROJECT_IGNORE_FILENAME: string = '.ecrignore';
|
|
20
|
+
|
|
21
|
+
/** What reading the project ignore file found. */
|
|
22
|
+
export type ProjectIgnoreRead =
|
|
23
|
+
| { readonly kind: 'patterns'; readonly patterns: readonly string[] }
|
|
24
|
+
| { readonly kind: 'missing' }
|
|
25
|
+
| { readonly kind: 'unreadable'; readonly reason: string };
|
|
26
|
+
|
|
27
|
+
/** The `.ecrignore` entry an installation needs, or why none can be made. */
|
|
28
|
+
export type InstallExclusion =
|
|
29
|
+
| { readonly kind: 'pattern'; readonly pattern: string }
|
|
30
|
+
| { readonly kind: 'outside' }
|
|
31
|
+
| { readonly kind: 'invalid'; readonly reason: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Characters that would change a destination's meaning if written into
|
|
35
|
+
* `.ecrignore` as a literal path: glob wildcards, and line breaks.
|
|
36
|
+
*/
|
|
37
|
+
const UNSAFE_PATH_CHARACTERS: RegExp = /[*?\r\n]/;
|
|
38
|
+
|
|
39
|
+
/** A relative path that leaves the directory it is relative to. */
|
|
40
|
+
const PARENT_PREFIX: RegExp = /^\.\.(?:[\\/]|$)/;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How many names to try when creating the temporary file. A name collision
|
|
44
|
+
* needs 16 random hexadecimal digits to repeat, so one retry would do; a few
|
|
45
|
+
* cost nothing and keep a wedged directory from failing on chance alone.
|
|
46
|
+
*/
|
|
47
|
+
const TEMPORARY_NAME_ATTEMPTS: number = 5;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads, parses and updates a project's `.ecrignore`.
|
|
51
|
+
*/
|
|
52
|
+
export class ProjectIgnoreFile {
|
|
53
|
+
/** Path of the `.ecrignore` file. */
|
|
54
|
+
private readonly path: string;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a handle on the ignore file of a project.
|
|
58
|
+
*
|
|
59
|
+
* @param workingDirectory - The project root: the directory the command runs from
|
|
60
|
+
*/
|
|
61
|
+
public constructor(workingDirectory: string) {
|
|
62
|
+
this.path = join(workingDirectory, PROJECT_IGNORE_FILENAME);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads the ignore file's patterns. A missing file is normal; one that
|
|
67
|
+
* exists but cannot be read is reported, not treated as empty.
|
|
68
|
+
*
|
|
69
|
+
* @returns The patterns, or that there is no file, or why it could not be read
|
|
70
|
+
*/
|
|
71
|
+
public read(): ProjectIgnoreRead {
|
|
72
|
+
if (!existsSync(this.path)) {
|
|
73
|
+
return { kind: 'missing' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return { kind: 'patterns', patterns: ProjectIgnoreFile.parse(readFileSync(this.path, 'utf8')) };
|
|
78
|
+
} catch (error: unknown) {
|
|
79
|
+
return { kind: 'unreadable', reason: ProjectIgnoreFile.showReason(error) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Adds a pattern unless the file already holds it, keeping everything else
|
|
85
|
+
* in the file exactly as it was. Creates the file if it does not exist.
|
|
86
|
+
*
|
|
87
|
+
* The new contents are written to a temporary file beside it, which then
|
|
88
|
+
* replaces it, so a write that fails part-way leaves the existing patterns
|
|
89
|
+
* untouched rather than truncated.
|
|
90
|
+
*
|
|
91
|
+
* @param pattern - The pattern to add
|
|
92
|
+
* @returns Whether the pattern was added or was already there
|
|
93
|
+
* @throws When the file cannot be read or replaced; it is then unchanged
|
|
94
|
+
*/
|
|
95
|
+
public addPattern(pattern: string): 'added' | 'present' {
|
|
96
|
+
const existing: string = existsSync(this.path) ? readFileSync(this.path, 'utf8') : '';
|
|
97
|
+
|
|
98
|
+
if (ProjectIgnoreFile.parse(existing).includes(pattern)) {
|
|
99
|
+
return 'present';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const separator: string = existing === '' || existing.endsWith('\n') ? '' : '\n';
|
|
103
|
+
const temporaryPath: string = this.writeTemporaryFile(`${existing}${separator}${pattern}\n`);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
renameSync(temporaryPath, this.path);
|
|
107
|
+
} catch (error: unknown) {
|
|
108
|
+
ProjectIgnoreFile.removeQuietly(temporaryPath);
|
|
109
|
+
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return 'added';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Removes a temporary file this class created, ignoring any failure to do
|
|
118
|
+
* so: the error being reported is the write, and a leftover temporary file
|
|
119
|
+
* does not change what the user must do about it.
|
|
120
|
+
*
|
|
121
|
+
* @param path - Path of the temporary file
|
|
122
|
+
*/
|
|
123
|
+
private static removeQuietly(path: string): void {
|
|
124
|
+
try {
|
|
125
|
+
rmSync(path, { force: true });
|
|
126
|
+
} catch {
|
|
127
|
+
// Nothing useful can be done, and nothing more needs saying.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Writes the replacement contents to a temporary file beside the ignore
|
|
133
|
+
* file, which this call creates itself.
|
|
134
|
+
*
|
|
135
|
+
* The name is unpredictable and the file is created exclusively, so nothing
|
|
136
|
+
* already at that path is opened: a link planted there, pointing anywhere on
|
|
137
|
+
* the disk, makes creation fail rather than be written through. That also
|
|
138
|
+
* means only a file this call created is ever removed.
|
|
139
|
+
*
|
|
140
|
+
* @param contents - The complete new contents of the ignore file
|
|
141
|
+
* @returns Path of the temporary file, which now holds those contents
|
|
142
|
+
* @throws When no temporary file could be created
|
|
143
|
+
*/
|
|
144
|
+
private writeTemporaryFile(contents: string): string {
|
|
145
|
+
let lastError: unknown;
|
|
146
|
+
|
|
147
|
+
for (let attempt: number = 0; attempt < TEMPORARY_NAME_ATTEMPTS; attempt += 1) {
|
|
148
|
+
const candidate: string = `${this.path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
writeFileSync(candidate, contents, { encoding: 'utf8', flag: 'wx' });
|
|
152
|
+
return candidate;
|
|
153
|
+
} catch (error: unknown) {
|
|
154
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
155
|
+
// Something is already at that name. It is not this call's to
|
|
156
|
+
// read, write or remove: try another name.
|
|
157
|
+
lastError = error;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Anything else means the name was free, so whatever is there now was
|
|
162
|
+
// created by the call that just failed, perhaps written only in part.
|
|
163
|
+
ProjectIgnoreFile.removeQuietly(candidate);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw lastError;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Splits an ignore file into its patterns.
|
|
173
|
+
*
|
|
174
|
+
* @param text - The file's contents
|
|
175
|
+
* @returns Each pattern, trimmed, without blank lines or `#` comments
|
|
176
|
+
*/
|
|
177
|
+
public static parse(text: string): readonly string[] {
|
|
178
|
+
return text
|
|
179
|
+
.split(/\r?\n/)
|
|
180
|
+
.map((line: string): string => line.trim())
|
|
181
|
+
.filter((line: string): boolean => line !== '' && !line.startsWith('#'));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Works out the exact `.ecrignore` pattern that excludes an installation
|
|
186
|
+
* directory, and nothing else.
|
|
187
|
+
*
|
|
188
|
+
* @param workingDirectory - The project root
|
|
189
|
+
* @param destination - The directory being installed into
|
|
190
|
+
* @returns The pattern; or that the destination lies outside the project and needs none; or why no safe pattern exists
|
|
191
|
+
*/
|
|
192
|
+
public static readInstallExclusion(workingDirectory: string, destination: string): InstallExclusion {
|
|
193
|
+
const relativePath: string = relative(workingDirectory, destination);
|
|
194
|
+
|
|
195
|
+
if (relativePath === '') {
|
|
196
|
+
return {
|
|
197
|
+
kind: 'invalid',
|
|
198
|
+
reason: 'it is the working directory itself, and excluding it would exclude the whole project',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Another drive on Windows gives an absolute path; a parent gives `..`.
|
|
203
|
+
if (isAbsolute(relativePath) || PARENT_PREFIX.test(relativePath)) {
|
|
204
|
+
return { kind: 'outside' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const path: string = relativePath.replaceAll('\\', '/');
|
|
208
|
+
|
|
209
|
+
if (UNSAFE_PATH_CHARACTERS.test(path)) {
|
|
210
|
+
return { kind: 'invalid', reason: 'its name contains `*`, `?` or a line break, which .ecrignore would read as a pattern' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (path.startsWith('#')) {
|
|
214
|
+
return { kind: 'invalid', reason: 'its name starts with `#`, which .ecrignore would read as a comment' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (path !== path.trim()) {
|
|
218
|
+
return { kind: 'invalid', reason: 'its name starts or ends with a space, which .ecrignore would drop' };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { kind: 'pattern', pattern: `${path}/**` };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Describes a file-system error briefly.
|
|
226
|
+
*
|
|
227
|
+
* @param error - What was thrown
|
|
228
|
+
* @returns The error's code, such as `EACCES`, or its message
|
|
229
|
+
*/
|
|
230
|
+
public static showReason(error: unknown): string {
|
|
231
|
+
if (error instanceof Error) {
|
|
232
|
+
const code: unknown = (error as NodeJS.ErrnoException).code;
|
|
233
|
+
return typeof code === 'string' ? code : error.message;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return String(error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-Safe Text
|
|
3
|
+
*
|
|
4
|
+
* The CLI prints text it did not write: titles and headings quoted back from
|
|
5
|
+
* documents, and the paths of files it found. A terminal reads some characters
|
|
6
|
+
* as commands rather than as text — an escape sequence can clear the screen,
|
|
7
|
+
* move the cursor, recolour what is already there or overwrite a line — so a
|
|
8
|
+
* document could otherwise decide what the report of it appears to say.
|
|
9
|
+
*
|
|
10
|
+
* Anything printed as text is therefore written with those characters shown
|
|
11
|
+
* rather than obeyed. The JSON form needs no such treatment, because a
|
|
12
|
+
* consumer of it is not a terminal, and escaping there would change the data.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Characters a terminal acts on rather than displays: the C0 controls apart
|
|
17
|
+
* from tab and newline, delete, the C1 controls, and the bidirectional
|
|
18
|
+
* formatting characters, which reorder the text around them.
|
|
19
|
+
*/
|
|
20
|
+
// eslint-disable-next-line no-control-regex -- Matching control characters is the point.
|
|
21
|
+
const ACTED_ON_BY_TERMINALS: RegExp = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F--]/g;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shows the characters a terminal would act on, rather than letting it act on
|
|
25
|
+
* them, writing each as the `\uXXXX` escape that names it.
|
|
26
|
+
*
|
|
27
|
+
* Tab and newline are left alone: the report is made of lines, and neither
|
|
28
|
+
* can misrepresent what it says.
|
|
29
|
+
*
|
|
30
|
+
* @param text - Text about to be printed to a terminal
|
|
31
|
+
* @returns The same text with every acted-on character shown
|
|
32
|
+
*/
|
|
33
|
+
export function showControlCharacters(text: string): string {
|
|
34
|
+
return text.replace(
|
|
35
|
+
ACTED_ON_BY_TERMINALS,
|
|
36
|
+
(character: string): string =>
|
|
37
|
+
`\\u${character.codePointAt(0)?.toString(16).padStart(4, '0') ?? ''}`,
|
|
38
|
+
);
|
|
39
|
+
}
|