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