@ontrails/regrade 0.2.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/CHANGELOG.md +606 -0
- package/package.json +40 -0
- package/src/downstream/ast-rewrite.ts +1031 -0
- package/src/downstream/collect.ts +240 -0
- package/src/downstream/export-restructure.ts +1588 -0
- package/src/downstream/file-renames.ts +1677 -0
- package/src/downstream/package-source-artifact.ts +375 -0
- package/src/downstream/package-source-files.ts +195 -0
- package/src/downstream/package-source-manifest.ts +369 -0
- package/src/downstream/package-source.ts +237 -0
- package/src/downstream/report.ts +2085 -0
- package/src/downstream/scan-summary.ts +193 -0
- package/src/downstream/vocabulary-registry.ts +195 -0
- package/src/downstream/vocabulary.ts +3094 -0
- package/src/history-receipt.ts +847 -0
- package/src/index.ts +170 -0
- package/src/literal-transform.ts +124 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NotFoundError,
|
|
3
|
+
Result,
|
|
4
|
+
matchesAnyPathGlob,
|
|
5
|
+
trail,
|
|
6
|
+
} from '@ontrails/core';
|
|
7
|
+
import { collectSourceTree } from '@ontrails/source';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Downstream source collection (TRL-844).
|
|
12
|
+
*
|
|
13
|
+
* Walks an explicit downstream-repo root and collects the deterministic set of
|
|
14
|
+
* candidate source files a regrade may operate on, alongside the entries it
|
|
15
|
+
* skipped and why. This is the engine substrate for the downstream Regrade
|
|
16
|
+
* work; it has no public CLI and reads only the root it is given.
|
|
17
|
+
*
|
|
18
|
+
* The interesting decision logic — which entries are collected, recursed into,
|
|
19
|
+
* or skipped with a reason — lives in the pure {@link classifyDownstreamEntry}
|
|
20
|
+
* helper so it can be exercised without touching the filesystem.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Directory names never descended into during collection. */
|
|
24
|
+
export const DEFAULT_IGNORED_DIRECTORIES: readonly string[] = Object.freeze([
|
|
25
|
+
'.git',
|
|
26
|
+
'.trails',
|
|
27
|
+
'.turbo',
|
|
28
|
+
'dist',
|
|
29
|
+
'node_modules',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** Source file extensions collected by default. */
|
|
33
|
+
export const DEFAULT_SOURCE_EXTENSIONS: readonly string[] = Object.freeze([
|
|
34
|
+
'.ts',
|
|
35
|
+
'.tsx',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/** Kind of a raw directory entry as reported by the filesystem walk. */
|
|
39
|
+
export type DownstreamEntryKind = 'directory' | 'file' | 'other';
|
|
40
|
+
|
|
41
|
+
/** A collected candidate source file. */
|
|
42
|
+
export interface CollectedSource {
|
|
43
|
+
/** Root-relative POSIX path, used as the stable identity for the entry. */
|
|
44
|
+
readonly path: string;
|
|
45
|
+
/** Absolute path on disk. */
|
|
46
|
+
readonly absolutePath: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** An entry that was inspected but not collected, with a machine-readable reason. */
|
|
50
|
+
export interface SkippedSource {
|
|
51
|
+
/** Root-relative POSIX path of the skipped entry. */
|
|
52
|
+
readonly path: string;
|
|
53
|
+
/** Why the entry was skipped, e.g. `ignored-directory`. */
|
|
54
|
+
readonly reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Deterministic result of collecting downstream sources from a root. */
|
|
58
|
+
export interface DownstreamSourceCollection {
|
|
59
|
+
/** The root the collection ran against. */
|
|
60
|
+
readonly root: string;
|
|
61
|
+
/** Collected candidate source files, sorted by `path`. */
|
|
62
|
+
readonly files: readonly CollectedSource[];
|
|
63
|
+
/** Skipped entries with reasons, sorted by `path`. */
|
|
64
|
+
readonly skipped: readonly SkippedSource[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Options shared by the classifier and the filesystem walk. */
|
|
68
|
+
export interface DownstreamCollectionOptions {
|
|
69
|
+
/** Directory names to skip. Defaults to {@link DEFAULT_IGNORED_DIRECTORIES}. */
|
|
70
|
+
readonly ignoredDirectories?: readonly string[];
|
|
71
|
+
/** Source extensions to collect. Defaults to {@link DEFAULT_SOURCE_EXTENSIONS}. */
|
|
72
|
+
readonly extensions?: readonly string[];
|
|
73
|
+
/** Root-relative path globs to skip before collection. */
|
|
74
|
+
readonly exclude?: readonly string[];
|
|
75
|
+
/** Root-relative path globs to collect. Omit to collect all matching files. */
|
|
76
|
+
readonly include?: readonly string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Outcome of classifying a single directory entry. */
|
|
80
|
+
export type DownstreamEntryClassification =
|
|
81
|
+
| { readonly action: 'collect' }
|
|
82
|
+
| { readonly action: 'recurse' }
|
|
83
|
+
| { readonly action: 'skip'; readonly reason: string };
|
|
84
|
+
|
|
85
|
+
const extensionOf = (name: string): string => {
|
|
86
|
+
const dot = name.lastIndexOf('.');
|
|
87
|
+
return dot <= 0 ? '' : name.slice(dot);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const normalizeExtension = (extension: string): string =>
|
|
91
|
+
extension === '' || extension.startsWith('.') ? extension : `.${extension}`;
|
|
92
|
+
|
|
93
|
+
const collectionExtensions = (
|
|
94
|
+
extensions: readonly string[] | undefined
|
|
95
|
+
): readonly string[] =>
|
|
96
|
+
extensions === undefined
|
|
97
|
+
? DEFAULT_SOURCE_EXTENSIONS
|
|
98
|
+
: extensions.map(normalizeExtension);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Decide what to do with a single directory entry. Pure: no filesystem access,
|
|
102
|
+
* so collection policy can be tested directly with synthetic entries.
|
|
103
|
+
*/
|
|
104
|
+
export const classifyDownstreamEntry = (
|
|
105
|
+
name: string,
|
|
106
|
+
kind: DownstreamEntryKind,
|
|
107
|
+
options: DownstreamCollectionOptions = {}
|
|
108
|
+
): DownstreamEntryClassification => {
|
|
109
|
+
const ignoredDirectories =
|
|
110
|
+
options.ignoredDirectories ?? DEFAULT_IGNORED_DIRECTORIES;
|
|
111
|
+
const extensions = collectionExtensions(options.extensions);
|
|
112
|
+
|
|
113
|
+
if (kind === 'directory') {
|
|
114
|
+
return ignoredDirectories.includes(name)
|
|
115
|
+
? { action: 'skip', reason: 'ignored-directory' }
|
|
116
|
+
: { action: 'recurse' };
|
|
117
|
+
}
|
|
118
|
+
if (kind === 'other') {
|
|
119
|
+
return { action: 'skip', reason: 'unsupported-entry' };
|
|
120
|
+
}
|
|
121
|
+
return extensions.length === 0 || extensions.includes(extensionOf(name))
|
|
122
|
+
? { action: 'collect' }
|
|
123
|
+
: { action: 'skip', reason: 'unsupported-extension' };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const isImmutableRegradeHistoryDirectory = (path: string): boolean =>
|
|
127
|
+
path === '.trails/regrade/history' ||
|
|
128
|
+
path.endsWith('/.trails/regrade/history');
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Walk an explicit downstream root and collect candidate source files.
|
|
132
|
+
*
|
|
133
|
+
* Never throws: an unreadable root yields `null` (the trail maps that to a
|
|
134
|
+
* `NotFoundError`), and unreadable subdirectories are recorded as skipped
|
|
135
|
+
* entries. Output is deterministic — files and skipped entries are sorted by
|
|
136
|
+
* their root-relative POSIX path.
|
|
137
|
+
*/
|
|
138
|
+
export const collectDownstreamSources = (
|
|
139
|
+
root: string,
|
|
140
|
+
options: DownstreamCollectionOptions = {}
|
|
141
|
+
): DownstreamSourceCollection | null =>
|
|
142
|
+
collectSourceTree(root, {
|
|
143
|
+
classify: ({ kind, name, path }) => {
|
|
144
|
+
if (matchesAnyPathGlob(path, options.exclude)) {
|
|
145
|
+
return { action: 'skip', reason: 'ignored-glob' };
|
|
146
|
+
}
|
|
147
|
+
if (kind === 'directory' && isImmutableRegradeHistoryDirectory(path)) {
|
|
148
|
+
return { action: 'skip', reason: 'immutable-regrade-history' };
|
|
149
|
+
}
|
|
150
|
+
const classification = classifyDownstreamEntry(name, kind, options);
|
|
151
|
+
if (
|
|
152
|
+
classification.action === 'collect' &&
|
|
153
|
+
options.include !== undefined &&
|
|
154
|
+
options.include.length > 0 &&
|
|
155
|
+
!matchesAnyPathGlob(path, options.include)
|
|
156
|
+
) {
|
|
157
|
+
return { action: 'skip', reason: 'not-included-glob' };
|
|
158
|
+
}
|
|
159
|
+
return classification;
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const collectDownstreamSourcesInput = z.object({
|
|
164
|
+
exclude: z
|
|
165
|
+
.array(z.string())
|
|
166
|
+
.optional()
|
|
167
|
+
.describe('Root-relative path globs to skip before collection'),
|
|
168
|
+
extensions: z
|
|
169
|
+
.array(z.string())
|
|
170
|
+
.optional()
|
|
171
|
+
.describe('Source file extensions to collect (defaults to .ts and .tsx)'),
|
|
172
|
+
ignoredDirectories: z
|
|
173
|
+
.array(z.string())
|
|
174
|
+
.optional()
|
|
175
|
+
.describe('Directory names to skip during collection'),
|
|
176
|
+
include: z
|
|
177
|
+
.array(z.string())
|
|
178
|
+
.optional()
|
|
179
|
+
.describe('Root-relative path globs to collect'),
|
|
180
|
+
root: z
|
|
181
|
+
.string()
|
|
182
|
+
.describe('Absolute path to the downstream repo root to scan'),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
export const collectDownstreamSourcesOutput = z.object({
|
|
186
|
+
files: z
|
|
187
|
+
.array(
|
|
188
|
+
z.object({
|
|
189
|
+
absolutePath: z.string().describe('Absolute path on disk'),
|
|
190
|
+
path: z.string().describe('Root-relative POSIX path'),
|
|
191
|
+
})
|
|
192
|
+
)
|
|
193
|
+
.describe('Collected candidate source files, sorted by path'),
|
|
194
|
+
root: z.string().describe('Root the collection ran against'),
|
|
195
|
+
skipped: z
|
|
196
|
+
.array(
|
|
197
|
+
z.object({
|
|
198
|
+
path: z.string().describe('Root-relative POSIX path'),
|
|
199
|
+
reason: z.string().describe('Why the entry was skipped'),
|
|
200
|
+
})
|
|
201
|
+
)
|
|
202
|
+
.describe('Skipped entries with reasons, sorted by path'),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Engine trail that collects downstream source files from an explicit root.
|
|
207
|
+
*
|
|
208
|
+
* No examples are authored: the input is an absolute filesystem path, which
|
|
209
|
+
* cannot be encoded as a portable literal. Correctness is proven by the
|
|
210
|
+
* collector unit tests (synthetic classifier cases and temp-directory walks)
|
|
211
|
+
* and, from TRL-846, the committed Radio-shaped fixture.
|
|
212
|
+
*/
|
|
213
|
+
export const collectDownstreamSourcesTrail = trail(
|
|
214
|
+
'regrade.downstream.collect',
|
|
215
|
+
{
|
|
216
|
+
implementation: (input) => {
|
|
217
|
+
const collection = collectDownstreamSources(input.root, {
|
|
218
|
+
...(input.extensions === undefined
|
|
219
|
+
? {}
|
|
220
|
+
: { extensions: input.extensions }),
|
|
221
|
+
...(input.exclude === undefined ? {} : { exclude: input.exclude }),
|
|
222
|
+
...(input.include === undefined ? {} : { include: input.include }),
|
|
223
|
+
...(input.ignoredDirectories === undefined
|
|
224
|
+
? {}
|
|
225
|
+
: { ignoredDirectories: input.ignoredDirectories }),
|
|
226
|
+
});
|
|
227
|
+
if (collection === null) {
|
|
228
|
+
return Result.err(
|
|
229
|
+
new NotFoundError(
|
|
230
|
+
`Downstream root "${input.root}" could not be read as a directory.`
|
|
231
|
+
)
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
return Result.ok(collection);
|
|
235
|
+
},
|
|
236
|
+
input: collectDownstreamSourcesInput,
|
|
237
|
+
intent: 'read',
|
|
238
|
+
output: collectDownstreamSourcesOutput,
|
|
239
|
+
}
|
|
240
|
+
);
|