@ontrails/config 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 +366 -0
- package/README.md +262 -0
- package/package.json +37 -0
- package/src/app-config.ts +318 -0
- package/src/collect.ts +117 -0
- package/src/compose.ts +47 -0
- package/src/config-resource.ts +32 -0
- package/src/define-config.ts +126 -0
- package/src/derive/env.ts +108 -0
- package/src/derive/example.ts +222 -0
- package/src/derive/helpers.ts +158 -0
- package/src/derive/index.ts +3 -0
- package/src/derive/json-schema.ts +137 -0
- package/src/derive-fields.ts +252 -0
- package/src/derive-provenance.ts +240 -0
- package/src/doctor.ts +238 -0
- package/src/extensions.ts +51 -0
- package/src/index.ts +74 -0
- package/src/merge.ts +43 -0
- package/src/path-boundary.ts +40 -0
- package/src/ref.ts +38 -0
- package/src/registry.ts +33 -0
- package/src/resolve.ts +196 -0
- package/src/secret-heuristics.ts +13 -0
- package/src/trails/config-check.ts +95 -0
- package/src/trails/config-describe.ts +44 -0
- package/src/trails/config-init.ts +96 -0
- package/src/trails-config-file.ts +136 -0
- package/src/trails-conventions.ts +240 -0
- package/src/workspace-config-collection.ts +371 -0
- package/src/workspace-config-source.ts +532 -0
- package/src/workspace-config.ts +552 -0
- package/src/zod-utils.ts +152 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { canonicalBoundaryPath, isWithinBoundary } from './path-boundary.js';
|
|
5
|
+
|
|
6
|
+
import { ValidationError } from '@ontrails/core';
|
|
7
|
+
|
|
8
|
+
export const trailsConfigModuleCandidates = [
|
|
9
|
+
'trails.config.ts',
|
|
10
|
+
'trails.config.mts',
|
|
11
|
+
'trails.config.js',
|
|
12
|
+
'trails.config.mjs',
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export const trailsConfigDataCandidates = [
|
|
16
|
+
'trails.config.json',
|
|
17
|
+
'trails.config.jsonc',
|
|
18
|
+
'trails.config.yaml',
|
|
19
|
+
'trails.config.toml',
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export const trailsConfigFileCandidates = [
|
|
23
|
+
...trailsConfigModuleCandidates,
|
|
24
|
+
...trailsConfigDataCandidates,
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export const trailsLocalConfigModuleCandidates = [
|
|
28
|
+
'trails.config.local.ts',
|
|
29
|
+
'trails.config.local.mts',
|
|
30
|
+
'trails.config.local.js',
|
|
31
|
+
'trails.config.local.mjs',
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const trailsLocalConfigDataCandidates = [
|
|
35
|
+
'trails.config.local.json',
|
|
36
|
+
'trails.config.local.jsonc',
|
|
37
|
+
'trails.config.local.yaml',
|
|
38
|
+
'trails.config.local.toml',
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export const trailsLocalConfigFileCandidates = [
|
|
42
|
+
...trailsLocalConfigModuleCandidates,
|
|
43
|
+
...trailsLocalConfigDataCandidates,
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export const trailsLockFileName = 'trails.lock' as const;
|
|
47
|
+
|
|
48
|
+
/** Conventional app entry relative to a configured app root. */
|
|
49
|
+
export const trailsAppEntryRelativePath = 'src/app.ts' as const;
|
|
50
|
+
|
|
51
|
+
export const trailsSourceRootCandidates = ['src/trails', 'trails'] as const;
|
|
52
|
+
|
|
53
|
+
export type TrailsProjectRootMarker =
|
|
54
|
+
| 'config'
|
|
55
|
+
| 'explicit'
|
|
56
|
+
| 'fallback'
|
|
57
|
+
| 'lock'
|
|
58
|
+
| 'source';
|
|
59
|
+
|
|
60
|
+
export interface TrailsProjectRootResolution {
|
|
61
|
+
readonly marker: TrailsProjectRootMarker;
|
|
62
|
+
readonly markerPath?: string | undefined;
|
|
63
|
+
readonly rootDir: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FindTrailsProjectRootOptions {
|
|
67
|
+
/** Inclusive discovery ceiling owned by the current collection. */
|
|
68
|
+
readonly boundaryDir?: string | undefined;
|
|
69
|
+
readonly startDir?: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ResolveTrailsProjectRootOptions extends FindTrailsProjectRootOptions {
|
|
73
|
+
readonly explicitRootDir?: string | undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isFile = (path: string): boolean => {
|
|
77
|
+
try {
|
|
78
|
+
return statSync(path).isFile();
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const isDirectory = (path: string): boolean => {
|
|
85
|
+
try {
|
|
86
|
+
return statSync(path).isDirectory();
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const firstExistingCandidate = (
|
|
93
|
+
rootDir: string,
|
|
94
|
+
candidates: readonly string[]
|
|
95
|
+
): string | undefined =>
|
|
96
|
+
candidates.map((entry) => resolve(rootDir, entry)).find(isFile);
|
|
97
|
+
|
|
98
|
+
const existingCandidates = (
|
|
99
|
+
rootDir: string,
|
|
100
|
+
candidates: readonly string[]
|
|
101
|
+
): readonly string[] =>
|
|
102
|
+
candidates.map((entry) => resolve(rootDir, entry)).filter(isFile);
|
|
103
|
+
|
|
104
|
+
export const findTrailsConfigPaths = (rootDir: string): readonly string[] =>
|
|
105
|
+
existingCandidates(rootDir, trailsConfigFileCandidates);
|
|
106
|
+
|
|
107
|
+
export const findTrailsLocalConfigPaths = (
|
|
108
|
+
rootDir: string
|
|
109
|
+
): readonly string[] =>
|
|
110
|
+
existingCandidates(rootDir, trailsLocalConfigFileCandidates);
|
|
111
|
+
|
|
112
|
+
export const findTrailsConfigModulePath = ({
|
|
113
|
+
configPath,
|
|
114
|
+
rootDir,
|
|
115
|
+
}: {
|
|
116
|
+
readonly configPath?: string | undefined;
|
|
117
|
+
readonly rootDir: string;
|
|
118
|
+
}): string | undefined => {
|
|
119
|
+
if (configPath !== undefined) {
|
|
120
|
+
return resolve(rootDir, configPath);
|
|
121
|
+
}
|
|
122
|
+
return firstExistingCandidate(rootDir, trailsConfigFileCandidates);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const findTrailsLocalConfigModulePath = (
|
|
126
|
+
rootDir: string
|
|
127
|
+
): string | undefined =>
|
|
128
|
+
firstExistingCandidate(rootDir, trailsLocalConfigFileCandidates);
|
|
129
|
+
|
|
130
|
+
const firstExistingSourceRoot = (rootDir: string): string | undefined =>
|
|
131
|
+
trailsSourceRootCandidates
|
|
132
|
+
.map((entry) => join(rootDir, entry))
|
|
133
|
+
.find(isDirectory);
|
|
134
|
+
|
|
135
|
+
const findProjectRootMarkerIn = (
|
|
136
|
+
rootDir: string
|
|
137
|
+
): Omit<TrailsProjectRootResolution, 'rootDir'> | undefined => {
|
|
138
|
+
const configPath = findTrailsConfigModulePath({ rootDir });
|
|
139
|
+
if (configPath !== undefined) {
|
|
140
|
+
return { marker: 'config', markerPath: configPath };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lockPath = join(rootDir, trailsLockFileName);
|
|
144
|
+
if (isFile(lockPath)) {
|
|
145
|
+
return { marker: 'lock', markerPath: lockPath };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return undefined;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const findSourceRootMarkerIn = (
|
|
152
|
+
rootDir: string
|
|
153
|
+
): Omit<TrailsProjectRootResolution, 'rootDir'> | undefined => {
|
|
154
|
+
const sourcePath = firstExistingSourceRoot(rootDir);
|
|
155
|
+
if (sourcePath !== undefined) {
|
|
156
|
+
return { marker: 'source', markerPath: sourcePath };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return undefined;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const findTrailsProjectRoot = ({
|
|
163
|
+
boundaryDir,
|
|
164
|
+
startDir = process.cwd(),
|
|
165
|
+
}: FindTrailsProjectRootOptions = {}):
|
|
166
|
+
| TrailsProjectRootResolution
|
|
167
|
+
| undefined => {
|
|
168
|
+
let current = resolve(startDir);
|
|
169
|
+
const boundary = boundaryDir === undefined ? undefined : resolve(boundaryDir);
|
|
170
|
+
const lexicalBoundary = boundary ?? current;
|
|
171
|
+
const canonicalBoundary =
|
|
172
|
+
boundary === undefined ? undefined : canonicalBoundaryPath(boundary);
|
|
173
|
+
let canonicalCurrent =
|
|
174
|
+
canonicalBoundary === undefined
|
|
175
|
+
? undefined
|
|
176
|
+
: canonicalBoundaryPath(current);
|
|
177
|
+
if (canonicalBoundary !== undefined && canonicalCurrent !== undefined) {
|
|
178
|
+
if (!isWithinBoundary(canonicalBoundary, canonicalCurrent)) {
|
|
179
|
+
throw new ValidationError(
|
|
180
|
+
`Trails project-root discovery start directory "${current}" is outside collection boundary "${boundary}".`,
|
|
181
|
+
{ context: { boundaryDir: boundary, startDir: current } }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
current = resolve(
|
|
185
|
+
lexicalBoundary,
|
|
186
|
+
relative(canonicalBoundary, canonicalCurrent)
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
let sourceFallback: TrailsProjectRootResolution | undefined;
|
|
190
|
+
|
|
191
|
+
while (true) {
|
|
192
|
+
const marker = findProjectRootMarkerIn(current);
|
|
193
|
+
if (marker !== undefined) {
|
|
194
|
+
return { ...marker, rootDir: current };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const sourceMarker = findSourceRootMarkerIn(current);
|
|
198
|
+
if (sourceMarker !== undefined) {
|
|
199
|
+
sourceFallback = { ...sourceMarker, rootDir: current };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
canonicalBoundary !== undefined &&
|
|
204
|
+
canonicalCurrent === canonicalBoundary
|
|
205
|
+
) {
|
|
206
|
+
return sourceFallback;
|
|
207
|
+
}
|
|
208
|
+
const currentForParent = canonicalCurrent ?? current;
|
|
209
|
+
const parent = dirname(currentForParent);
|
|
210
|
+
if (parent === currentForParent) {
|
|
211
|
+
return sourceFallback;
|
|
212
|
+
}
|
|
213
|
+
if (canonicalBoundary === undefined) {
|
|
214
|
+
current = parent;
|
|
215
|
+
} else {
|
|
216
|
+
canonicalCurrent = parent;
|
|
217
|
+
current = resolve(lexicalBoundary, relative(canonicalBoundary, parent));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export const resolveTrailsProjectRoot = ({
|
|
223
|
+
boundaryDir,
|
|
224
|
+
explicitRootDir,
|
|
225
|
+
startDir = process.cwd(),
|
|
226
|
+
}: ResolveTrailsProjectRootOptions = {}): TrailsProjectRootResolution => {
|
|
227
|
+
if (explicitRootDir !== undefined) {
|
|
228
|
+
return {
|
|
229
|
+
marker: 'explicit',
|
|
230
|
+
rootDir: resolve(startDir, explicitRootDir),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
findTrailsProjectRoot({ boundaryDir, startDir }) ?? {
|
|
236
|
+
marker: 'fallback',
|
|
237
|
+
rootDir: resolve(startDir),
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
};
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { ValidationError } from '@ontrails/core';
|
|
5
|
+
import { collectSourceTree } from '@ontrails/source';
|
|
6
|
+
import type {
|
|
7
|
+
SourceCollectionBoundaryReason,
|
|
8
|
+
SourceCollectionDecision,
|
|
9
|
+
} from '@ontrails/source';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
findTrailsConfigPaths,
|
|
13
|
+
trailsConfigFileCandidates,
|
|
14
|
+
} from './trails-conventions.js';
|
|
15
|
+
import { canonicalBoundaryPath, isWithinBoundary } from './path-boundary.js';
|
|
16
|
+
|
|
17
|
+
const ignoredCollectionDirectories = new Set([
|
|
18
|
+
'.cache',
|
|
19
|
+
'.turbo',
|
|
20
|
+
'coverage',
|
|
21
|
+
'dist',
|
|
22
|
+
'node_modules',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const configCandidateNames = new Set<string>(trailsConfigFileCandidates);
|
|
26
|
+
|
|
27
|
+
type ConfigCollectionBoundaryReason =
|
|
28
|
+
| SourceCollectionBoundaryReason
|
|
29
|
+
| 'unreadable-git-boundary';
|
|
30
|
+
|
|
31
|
+
interface ConfigCollectionBoundary {
|
|
32
|
+
readonly canonicalPath: string;
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly reason: ConfigCollectionBoundaryReason;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const configCollectionBoundaryReasons = new Set<string>([
|
|
38
|
+
'nested-repository',
|
|
39
|
+
'nested-worktree',
|
|
40
|
+
'submodule-boundary',
|
|
41
|
+
'unreadable-git-boundary',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const isConfigCollectionBoundaryReason = (
|
|
45
|
+
reason: string
|
|
46
|
+
): reason is ConfigCollectionBoundaryReason =>
|
|
47
|
+
configCollectionBoundaryReasons.has(reason);
|
|
48
|
+
|
|
49
|
+
const collectionBoundaries = (
|
|
50
|
+
rootDir: string,
|
|
51
|
+
skipped: readonly { readonly path: string; readonly reason: string }[]
|
|
52
|
+
): readonly ConfigCollectionBoundary[] =>
|
|
53
|
+
skipped
|
|
54
|
+
.filter(
|
|
55
|
+
(
|
|
56
|
+
entry
|
|
57
|
+
): entry is typeof entry & {
|
|
58
|
+
readonly reason: ConfigCollectionBoundaryReason;
|
|
59
|
+
} => isConfigCollectionBoundaryReason(entry.reason)
|
|
60
|
+
)
|
|
61
|
+
.map((entry) => {
|
|
62
|
+
const path = resolve(rootDir, entry.path);
|
|
63
|
+
return {
|
|
64
|
+
canonicalPath: canonicalBoundaryPath(path),
|
|
65
|
+
path,
|
|
66
|
+
reason: entry.reason,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const assertReadableCollectionEvidence = (
|
|
71
|
+
boundaryDir: string,
|
|
72
|
+
skipped: readonly { readonly path: string; readonly reason: string }[]
|
|
73
|
+
): void => {
|
|
74
|
+
const uncertain = skipped.filter(
|
|
75
|
+
(entry) =>
|
|
76
|
+
entry.reason.startsWith('unreadable-') &&
|
|
77
|
+
entry.reason !== 'unreadable-git-boundary'
|
|
78
|
+
);
|
|
79
|
+
if (uncertain.length > 0) {
|
|
80
|
+
throw new ValidationError(
|
|
81
|
+
`Unable to prove static project identity inside "${boundaryDir}" because collection evidence is unreadable.`,
|
|
82
|
+
{ context: { boundaryDir: resolve(boundaryDir), skipped: uncertain } }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const validateOneConfigPerDirectory = (
|
|
88
|
+
configPaths: readonly string[]
|
|
89
|
+
): void => {
|
|
90
|
+
const pathsByLexicalDirectory = new Map<string, string[]>();
|
|
91
|
+
const pathsByCanonicalDirectory = new Map<string, string[]>();
|
|
92
|
+
for (const configPath of configPaths) {
|
|
93
|
+
for (const [directory, pathsByDirectory] of [
|
|
94
|
+
[dirname(resolve(configPath)), pathsByLexicalDirectory],
|
|
95
|
+
[dirname(canonicalBoundaryPath(configPath)), pathsByCanonicalDirectory],
|
|
96
|
+
] as const) {
|
|
97
|
+
const paths = pathsByDirectory.get(directory) ?? [];
|
|
98
|
+
paths.push(configPath);
|
|
99
|
+
pathsByDirectory.set(directory, paths);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const pathsByDirectory of [
|
|
103
|
+
pathsByLexicalDirectory,
|
|
104
|
+
pathsByCanonicalDirectory,
|
|
105
|
+
]) {
|
|
106
|
+
for (const paths of pathsByDirectory.values()) {
|
|
107
|
+
if (paths.length > 1) {
|
|
108
|
+
throw new ValidationError(
|
|
109
|
+
`Multiple Trails config files found: ${paths.join(', ')}. Keep one config file per project root.`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const dedupeConfigPaths = (
|
|
117
|
+
configPaths: readonly string[]
|
|
118
|
+
): readonly string[] => {
|
|
119
|
+
const pathsByCanonicalIdentity = new Map<string, string>();
|
|
120
|
+
for (const configPath of configPaths) {
|
|
121
|
+
pathsByCanonicalIdentity.set(canonicalBoundaryPath(configPath), configPath);
|
|
122
|
+
}
|
|
123
|
+
return [...pathsByCanonicalIdentity.values()];
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const collectConfigBoundariesThroughPaths = (
|
|
127
|
+
boundaryDir: string,
|
|
128
|
+
targetPaths: readonly string[]
|
|
129
|
+
): readonly ConfigCollectionBoundary[] => {
|
|
130
|
+
const canonicalBoundary = canonicalBoundaryPath(boundaryDir);
|
|
131
|
+
const canonicalTargets = targetPaths.map(canonicalBoundaryPath);
|
|
132
|
+
const collection = collectSourceTree(canonicalBoundary, {
|
|
133
|
+
classify: (entry) => {
|
|
134
|
+
if (entry.kind !== 'directory') {
|
|
135
|
+
return { action: 'skip', reason: 'not-target-ancestor' };
|
|
136
|
+
}
|
|
137
|
+
const canonicalEntry = canonicalBoundaryPath(
|
|
138
|
+
resolve(canonicalBoundary, entry.path)
|
|
139
|
+
);
|
|
140
|
+
return canonicalTargets.some((target) =>
|
|
141
|
+
isWithinBoundary(canonicalEntry, target)
|
|
142
|
+
)
|
|
143
|
+
? { action: 'recurse' }
|
|
144
|
+
: { action: 'skip', reason: 'not-target-ancestor' };
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
if (collection === null) {
|
|
148
|
+
throw new ValidationError(
|
|
149
|
+
`Unable to read static project identity discovery boundary "${boundaryDir}".`,
|
|
150
|
+
{ context: { boundaryDir: resolve(boundaryDir) } }
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
assertReadableCollectionEvidence(boundaryDir, collection.skipped);
|
|
154
|
+
return collectionBoundaries(canonicalBoundary, collection.skipped);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const assertPathDoesNotTraverseCollectionEdge = (
|
|
158
|
+
boundaryDir: string,
|
|
159
|
+
targetPath: string,
|
|
160
|
+
label: string
|
|
161
|
+
): void => {
|
|
162
|
+
const canonicalTarget = canonicalBoundaryPath(targetPath);
|
|
163
|
+
const collectionEdge = collectConfigBoundariesThroughPaths(boundaryDir, [
|
|
164
|
+
canonicalTarget,
|
|
165
|
+
]).find((boundary) =>
|
|
166
|
+
isWithinBoundary(boundary.canonicalPath, canonicalTarget)
|
|
167
|
+
);
|
|
168
|
+
if (collectionEdge !== undefined) {
|
|
169
|
+
throw new ValidationError(
|
|
170
|
+
`Static project identity ${label} "${targetPath}" traverses a ${collectionEdge.reason} collection edge at "${collectionEdge.path}".`,
|
|
171
|
+
{
|
|
172
|
+
context: {
|
|
173
|
+
boundaryDir: resolve(boundaryDir),
|
|
174
|
+
boundaryPath: collectionEdge.path,
|
|
175
|
+
boundaryReason: collectionEdge.reason,
|
|
176
|
+
targetPath,
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const isFollowedDirectory = (path: string): boolean => {
|
|
184
|
+
try {
|
|
185
|
+
return statSync(path).isDirectory();
|
|
186
|
+
} catch {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
export const findConfigPathsThroughBoundary = (
|
|
192
|
+
startDir: string,
|
|
193
|
+
boundaryDir: string
|
|
194
|
+
): readonly string[] => {
|
|
195
|
+
const start = resolve(startDir);
|
|
196
|
+
const boundary = resolve(boundaryDir);
|
|
197
|
+
if (!isFollowedDirectory(start)) {
|
|
198
|
+
throw new ValidationError(
|
|
199
|
+
`Static project identity start directory "${start}" is not an existing directory.`,
|
|
200
|
+
{ context: { boundaryDir: boundary, startDir: start } }
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const canonicalBoundary = canonicalBoundaryPath(boundary);
|
|
204
|
+
let canonicalCurrent = canonicalBoundaryPath(start);
|
|
205
|
+
if (!isWithinBoundary(canonicalBoundary, canonicalCurrent)) {
|
|
206
|
+
throw new ValidationError(
|
|
207
|
+
`Static project identity start directory "${start}" is outside discovery boundary "${boundary}".`,
|
|
208
|
+
{ context: { boundaryDir: boundary, startDir: start } }
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
assertPathDoesNotTraverseCollectionEdge(boundary, canonicalCurrent, 'target');
|
|
212
|
+
const found: string[] = [];
|
|
213
|
+
while (true) {
|
|
214
|
+
const current = resolve(
|
|
215
|
+
boundary,
|
|
216
|
+
relative(canonicalBoundary, canonicalCurrent)
|
|
217
|
+
);
|
|
218
|
+
const paths = dedupeConfigPaths(findTrailsConfigPaths(current));
|
|
219
|
+
validateOneConfigPerDirectory(paths);
|
|
220
|
+
if (paths[0] !== undefined) {
|
|
221
|
+
found.push(paths[0]);
|
|
222
|
+
}
|
|
223
|
+
if (canonicalCurrent === canonicalBoundary) {
|
|
224
|
+
return found;
|
|
225
|
+
}
|
|
226
|
+
const parent = dirname(canonicalCurrent);
|
|
227
|
+
if (parent === canonicalCurrent) {
|
|
228
|
+
return found;
|
|
229
|
+
}
|
|
230
|
+
canonicalCurrent = parent;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const isFollowedFile = (path: string): boolean => {
|
|
235
|
+
try {
|
|
236
|
+
return statSync(path).isFile();
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export const collectConfigPathsWithinBoundary = (
|
|
243
|
+
boundaryDir: string,
|
|
244
|
+
includedRootDirs: readonly string[] = [],
|
|
245
|
+
collectionBoundaryDir: string = boundaryDir
|
|
246
|
+
): readonly string[] => {
|
|
247
|
+
const boundary = resolve(boundaryDir);
|
|
248
|
+
const collectionBoundary = resolve(collectionBoundaryDir);
|
|
249
|
+
const canonicalBoundary = canonicalBoundaryPath(boundary);
|
|
250
|
+
const canonicalCollectionBoundary = canonicalBoundaryPath(collectionBoundary);
|
|
251
|
+
if (!isWithinBoundary(canonicalCollectionBoundary, canonicalBoundary)) {
|
|
252
|
+
throw new ValidationError(
|
|
253
|
+
`Static project identity boundary "${boundary}" is outside collection boundary "${collectionBoundary}".`,
|
|
254
|
+
{
|
|
255
|
+
context: {
|
|
256
|
+
boundaryDir: boundary,
|
|
257
|
+
collectionBoundaryDir: collectionBoundary,
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const includedRoots = includedRootDirs.map((rootDir) => resolve(rootDir));
|
|
263
|
+
const visitedCanonicalDirectories = new Set([canonicalCollectionBoundary]);
|
|
264
|
+
const recurseDirectoryOnce = (
|
|
265
|
+
absoluteEntryPath: string
|
|
266
|
+
): SourceCollectionDecision => {
|
|
267
|
+
const canonicalEntryPath = canonicalBoundaryPath(absoluteEntryPath);
|
|
268
|
+
if (visitedCanonicalDirectories.has(canonicalEntryPath)) {
|
|
269
|
+
return { action: 'skip', reason: 'already-visited-directory' };
|
|
270
|
+
}
|
|
271
|
+
visitedCanonicalDirectories.add(canonicalEntryPath);
|
|
272
|
+
return { action: 'recurse' };
|
|
273
|
+
};
|
|
274
|
+
const collection = collectSourceTree(collectionBoundary, {
|
|
275
|
+
classify: (entry) => {
|
|
276
|
+
const absoluteEntryPath = resolve(collectionBoundary, entry.path);
|
|
277
|
+
if (!isWithinBoundary(boundary, absoluteEntryPath)) {
|
|
278
|
+
const traversesTowardBoundary = isWithinBoundary(
|
|
279
|
+
absoluteEntryPath,
|
|
280
|
+
boundary
|
|
281
|
+
);
|
|
282
|
+
return traversesTowardBoundary &&
|
|
283
|
+
(entry.kind === 'directory' ||
|
|
284
|
+
(entry.kind === 'other' && isFollowedDirectory(absoluteEntryPath)))
|
|
285
|
+
? recurseDirectoryOnce(absoluteEntryPath)
|
|
286
|
+
: { action: 'skip', reason: 'outside-selected-boundary' };
|
|
287
|
+
}
|
|
288
|
+
const isIncludedRootAncestor = includedRoots.some((rootDir) =>
|
|
289
|
+
isWithinBoundary(absoluteEntryPath, rootDir)
|
|
290
|
+
);
|
|
291
|
+
const isWithinIncludedRoot = includedRoots.some((rootDir) =>
|
|
292
|
+
isWithinBoundary(rootDir, absoluteEntryPath)
|
|
293
|
+
);
|
|
294
|
+
if (configCandidateNames.has(entry.name)) {
|
|
295
|
+
return entry.kind === 'file' || entry.kind === 'other'
|
|
296
|
+
? { action: 'collect' }
|
|
297
|
+
: { action: 'skip', reason: 'invalid-trails-config-candidate' };
|
|
298
|
+
}
|
|
299
|
+
if (
|
|
300
|
+
entry.kind === 'directory' &&
|
|
301
|
+
ignoredCollectionDirectories.has(entry.name) &&
|
|
302
|
+
!(isIncludedRootAncestor || isWithinIncludedRoot)
|
|
303
|
+
) {
|
|
304
|
+
return { action: 'skip', reason: 'ignored-directory' };
|
|
305
|
+
}
|
|
306
|
+
if (entry.kind === 'directory') {
|
|
307
|
+
return recurseDirectoryOnce(absoluteEntryPath);
|
|
308
|
+
}
|
|
309
|
+
if (entry.kind === 'other' && isFollowedDirectory(absoluteEntryPath)) {
|
|
310
|
+
const canonicalEntryPath = canonicalBoundaryPath(absoluteEntryPath);
|
|
311
|
+
return isWithinBoundary(canonicalBoundary, canonicalEntryPath)
|
|
312
|
+
? recurseDirectoryOnce(absoluteEntryPath)
|
|
313
|
+
: { action: 'skip', reason: 'outside-selected-boundary' };
|
|
314
|
+
}
|
|
315
|
+
return { action: 'skip', reason: 'not-trails-config' };
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
if (collection === null) {
|
|
319
|
+
throw new ValidationError(
|
|
320
|
+
`Unable to read static project identity discovery boundary "${collectionBoundaryDir}".`,
|
|
321
|
+
{ context: { boundaryDir: collectionBoundary } }
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
assertReadableCollectionEvidence(collectionBoundaryDir, collection.skipped);
|
|
325
|
+
const configPaths: string[] = [];
|
|
326
|
+
for (const file of collection.files) {
|
|
327
|
+
if (!isFollowedFile(file.absolutePath)) {
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
const canonicalConfigPath = canonicalBoundaryPath(file.absolutePath);
|
|
331
|
+
if (!isWithinBoundary(canonicalBoundary, canonicalConfigPath)) {
|
|
332
|
+
throw new ValidationError(
|
|
333
|
+
`Static project identity config marker "${file.absolutePath}" resolves outside discovery boundary "${boundary}".`,
|
|
334
|
+
{
|
|
335
|
+
context: {
|
|
336
|
+
boundaryDir: boundary,
|
|
337
|
+
canonicalConfigPath,
|
|
338
|
+
configPath: file.absolutePath,
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
assertPathDoesNotTraverseCollectionEdge(
|
|
344
|
+
collectionBoundary,
|
|
345
|
+
canonicalConfigPath,
|
|
346
|
+
'config marker'
|
|
347
|
+
);
|
|
348
|
+
configPaths.push(file.absolutePath);
|
|
349
|
+
}
|
|
350
|
+
const paths = dedupeConfigPaths(configPaths);
|
|
351
|
+
validateOneConfigPerDirectory(paths);
|
|
352
|
+
return paths;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
export const combineConfigPaths = (
|
|
356
|
+
collectedPaths: readonly string[],
|
|
357
|
+
selectedPaths: readonly string[]
|
|
358
|
+
): readonly string[] => {
|
|
359
|
+
const pathsByCanonicalIdentity = new Map(
|
|
360
|
+
collectedPaths.map((path) => [canonicalBoundaryPath(path), path])
|
|
361
|
+
);
|
|
362
|
+
for (const selectedPath of selectedPaths) {
|
|
363
|
+
pathsByCanonicalIdentity.set(
|
|
364
|
+
canonicalBoundaryPath(selectedPath),
|
|
365
|
+
selectedPath
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
const locatedPaths = [...pathsByCanonicalIdentity.values()];
|
|
369
|
+
validateOneConfigPerDirectory(locatedPaths);
|
|
370
|
+
return locatedPaths;
|
|
371
|
+
};
|