-
Notifications
You must be signed in to change notification settings - Fork 95
Expand file tree
/
Copy pathverify-construct-dependencies.ts
More file actions
249 lines (222 loc) · 10.5 KB
/
verify-construct-dependencies.ts
File metadata and controls
249 lines (222 loc) · 10.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import * as process from 'process';
import * as path from 'path';
import * as fs from 'fs';
import * as lockfile from '@yarnpkg/lockfile';
type DepsClosure = {
repoDeps: Array<string>;
registryDeps: Array<string>;
};
type ConstructPackageConfiguration = {
packageName: string;
packageDir: string;
};
const CONSTRUCT_PACKAGE_CONFIGURATIONS: ConstructPackageConfiguration[] = [
{
packageName: '@aws-amplify/graphql-api-construct',
packageDir: 'amplify-graphql-api-construct',
},
{
packageName: '@aws-amplify/data-construct',
packageDir: 'amplify-data-construct',
},
];
const EXCLUSION_PATHS: string[][] = [
// Dependencies of ai-constructs below are only used for typings (compile time).
// They are not active at runtime. Therefore, can be skipped at bundling.
['@aws-amplify/ai-constructs', '@aws-amplify/plugin-types', '@aws-cdk/toolkit-lib'],
['@aws-amplify/graphql-conversation-transformer', '@aws-amplify/ai-constructs', 'json-schema-to-ts'],
];
const PACKAGES_DIR = 'packages';
const NON_JSII_DEPENDENCIES_FILENAME = 'nonJsiiDependencies.json';
const PACKAGE_JSON_FILENAME = 'package.json';
/**
* Check if a dependency should be excluded based on path match in dependency tree.
* @param depPath the current dependency path in dependency tree BFS
* @returns true if the given depPath should be excluded
*/
const shouldExcludeDependency = (depPath: string[]): boolean => {
if (!depPath.length) {
return false;
}
return EXCLUSION_PATHS.some((exclusionPath) => {
const exclusionPathString = exclusionPath.join(',');
const depPathString = depPath.join(',');
return depPathString.includes(exclusionPathString);
});
};
/**
* Return whether or not a given package directory name is a code package in the monorepo, we check if it's a directory, and has
* a package.json file.
* @param subDirectory the local path to check.
* @returns true if this appears to be a package directory
*/
const isPackageDirectory = (subDirectory: string): boolean => {
const packageDir = path.join(PACKAGES_DIR, subDirectory);
return fs.lstatSync(packageDir).isDirectory() && fs.existsSync(path.join(packageDir, PACKAGE_JSON_FILENAME));
};
/**
* Return a tuple of string to list of string for a given package name
* @param subDirectory the subDirectoy to pull dependencies for
* @returns a tuple of the package name to list of dependency names
*/
const getPackageDependencies = (subDirectory: string): [string, Array<string>] => {
const packageJsonContents = fs.readFileSync(path.join(PACKAGES_DIR, subDirectory, PACKAGE_JSON_FILENAME), 'utf-8');
const packageJson = JSON.parse(packageJsonContents);
return [
`${packageJson.name}@${packageJson.version}`,
packageJson.dependencies
? Object.entries(packageJson.dependencies).map(([packageName, semverPattern]) => `${packageName}@${semverPattern}`)
: [],
];
};
/**
* Search in the packages directory, reading all package.json files to retrieve the package names, mapped to the dependencies they have.
* @returns a mapping of local packages to their list of runtime dependencies.
*/
const getRepoPackages = (): Record<string, Array<string>> =>
Object.fromEntries(fs.readdirSync(PACKAGES_DIR).filter(isPackageDirectory).map(getPackageDependencies));
/**
* Given a set of input deps, compute the full closure of local and remote deps.
*
* Assumption: repoDeps will depend on registryDeps, but the inverse should not hold true.
* Reasoning: If this were the case, our release process is already not sufficient to ensure a single version
* of our package is included in the final bundled result.
* @param deps the input deps
* @returns the full set of registry and repo deps.
*/
const computeDepsClosure = (deps: string[]): DepsClosure => {
const repoPackageClosures = getRepoPackages();
const lockfileContents = lockfile.parse(fs.readFileSync('yarn.lock', 'utf8')).object;
const seenDeps = new Set<string>();
const closure: DepsClosure = { repoDeps: [], registryDeps: [] };
const traverse = (path: string[], currDep: string): void => {
if (shouldExcludeDependency([...path, stripSemver(currDep)]) || seenDeps.has(currDep)) {
return;
}
seenDeps.add(currDep);
const newPath = [...path, stripSemver(currDep)];
if (repoPackageClosures[currDep]) {
closure.repoDeps.push(currDep);
repoPackageClosures[currDep].forEach((nextDep) => traverse(newPath, nextDep));
} else {
closure.registryDeps.push(currDep);
const dependencies = lockfileContents[currDep].dependencies ?? {};
Object.entries(dependencies)
.map(([name, version]) => `${name}@${version}`)
.forEach((nextDep) => traverse(newPath, nextDep));
}
};
deps.forEach((dep) => traverse([], dep));
return closure;
};
/**
* Remove semver portion from package descriptor
* e.g. zod@^3.1.12 => zod, or @aws-amplify/graphql-transformer@1.1.2 => @aws-amplify/graphql-transformer.
* @param val value which we're going to remove semver string segment
* @returns the value without semver string segment
*/
const stripSemver = (val: string): string => val.split('@').slice(0, -1).join('@');
/**
* Remove semver portion from package descriptors
* e.g. zod@^3.1.12 => zod, or @aws-amplify/graphql-transformer@1.1.2 => @aws-amplify/graphql-transformer.
* @param vals values which we're going to remove semver string segments
* @returns the values without semver string segments
*/
const stripSemverString = (vals: string[]): string[] => vals.map((val: string) => val.split('@').slice(0, -1).join('@'));
/**
* Return the package.json file for the cdk construct.
* @returns the package.json file for the cdk construct.
*/
const getCdkConstructPackageJson = (contructPackageDir: string): any =>
JSON.parse(fs.readFileSync(path.join(PACKAGES_DIR, contructPackageDir, PACKAGE_JSON_FILENAME), 'utf-8'));
/**
* Return the package.json file for the monorepo root.
* @returns the package.json file for the monorepo root.
*/
const getRootPackageJson = (): any => JSON.parse(fs.readFileSync(PACKAGE_JSON_FILENAME, 'utf-8'));
/**
* Read the current versions of these packages from the package.json file for the cdk construct.
* @returns the deps decorate with current versions
*/
const attachCurrentVersions = (constructPackageDir: string, deps: string[]): string[] => {
const trackedDeps = new Set(deps);
return Object.entries(getCdkConstructPackageJson(constructPackageDir).dependencies)
.filter(([packageName]) => trackedDeps.has(packageName))
.map(([packageName, packageSemver]) => `${packageName}@${packageSemver}`);
};
/**
* Validate that there is a package scoped nohoist for each dependency required.
*/
const validateNohoistsAreConfigured = (constructPackageName: string, deps: string[]): string[] => {
const nohoistValues = new Set(getRootPackageJson().workspaces.nohoist);
return deps
.filter((dep) => !shouldExcludeDependency([constructPackageName, dep]))
.map((depName) => `${constructPackageName}/${depName}`)
.filter((depPath) => !nohoistValues.has(depPath))
.map((depPath) => `${depPath} not found in root package.json nohoist config`);
};
/**
* Validate that there is a runtime dependency in the construct for each dependency required.
*/
const validateConstructDependenciesAreConfigured = (constructPackageDir: string, deps: string[]): string[] => {
const dependencyKeys = new Set(Object.keys(getCdkConstructPackageJson(constructPackageDir).dependencies));
return deps
.filter((depName) => !dependencyKeys.has(depName))
.map((depName) => `${depName} not found in construct dependencies in ${constructPackageDir}`);
};
/**
* Deps don't seem to bundle when they're also in devDependencies, validate none of those exist.
* @returns a list of warning strings for incorrectly included devDependencies
*/
const validateConstructDevDependenciesAreConfigured = (constructPackageDir: string, deps: string[]): string[] => {
const devDependencyKeys = new Set(Object.keys(getCdkConstructPackageJson(constructPackageDir).devDependencies));
return deps
.filter((depName) => devDependencyKeys.has(depName))
.map((depName) => `${depName} found in construct devDependencies in ${constructPackageDir}`);
};
/**
* Validate that there is a bundled dependency in the construct for each dependency required.
*/
const validateConstructBundledDependenciesAreConfigured = (constructPackageDir: string, deps: string[]): string[] => {
const dependencyKeys = new Set(getCdkConstructPackageJson(constructPackageDir).bundledDependencies);
return deps
.filter((depName) => !dependencyKeys.has(depName))
.map((depName) => `${depName} not found in construct bundledDependencies in ${constructPackageDir}`);
};
/**
* Main entry point, this will invoke the following steps.
* FYI: This is pretty silly, but I think we need to feed the versions BACK form the construct package.json in order for
* lerna versioning to keep from blowing us up.
* 1. Compute the full closure of deps.
* 2. Ensure that a scoped nohoist is set on our root package.json for all of these libraries.
* 3. Ensure that the cdk construct package.json's dependencies include all of these libraries.
* 3. Ensure that the cdk construct package.json's bundledDependencies include all of these libraries.
*/
const main = (): void => {
try {
const validationErrors: string[] = [];
CONSTRUCT_PACKAGE_CONFIGURATIONS.forEach(({ packageName, packageDir }) => {
const nonJsiiDeps = JSON.parse(fs.readFileSync(path.join(PACKAGES_DIR, packageDir, NON_JSII_DEPENDENCIES_FILENAME), 'utf-8'));
const fullDepsClosure = computeDepsClosure(attachCurrentVersions(packageDir, nonJsiiDeps));
const dedupedDepListWithoutSemver: string[] = Array.from(
new Set([...stripSemverString(fullDepsClosure.repoDeps), ...stripSemverString(fullDepsClosure.registryDeps)]),
);
validationErrors.push(
...validateNohoistsAreConfigured(packageName, dedupedDepListWithoutSemver),
...validateConstructDependenciesAreConfigured(packageDir, dedupedDepListWithoutSemver),
...validateConstructBundledDependenciesAreConfigured(packageDir, dedupedDepListWithoutSemver),
...validateConstructDevDependenciesAreConfigured(packageDir, dedupedDepListWithoutSemver),
);
});
if (validationErrors.length > 0) {
console.error(`Caught Validation Errors: ${validationErrors.join('\n')}`);
process.exit(1);
}
console.log('Validated nohoist, package dependencies, and package bundled depencies were configured correctly.');
process.exit(0);
} catch (e) {
console.error('Caught exception while computing deps closure.', e);
process.exit(1);
}
};
main();