Skip to content

Commit ed8ac1a

Browse files
rix0rrrgithub-actions
andauthored
fix: exampleDependencies aren't used (#68)
Rosetta has a feature to include external packages into its example compilation dependency tree, so that you can write examples in your README using packages that depend on the package you're writing the README for. You couldn't have those dependencies in your actual dependency tree, but there is a special section in `package.json` that can do it. Schematically: ``` mydep ▲ │ mypackage < write a compilable example that uses 'myinteg' ▲ │ myinteg ``` Put the following into `package.json`: ```json { "jsiiRosetta": { "exampleDependencies": { "myinteg": "^1.2.3" } } } ``` While this feature was advertised, it didn't actually work: * The dependency closure tree was built, but it was never used during compilation (we erroneously set the compilation directory to the directory of the package the sample was found). * You could not specify `*` as a version number because a package we use, `semver-intersect`, doesn't support `*`. * We need to take additional care when mixing symlinked and installed packages that `peerDependency` (e.g., `constructs`). By the original closure directory construction method, we would end up with the wrong version of `aws-cdk-lib` in the dependency closure, and 2 copies of `constructs` (leading to the `Construct is not assignable to Construct` error). To solve all of this, fully crawl the dependency tree, build a `package.json` to install everything in one go, and do use the compilation directory that we build this way. Also in this PR: * Wrap `semver-intersect` with a helper function to handle one more case and fail gracefully if we encounter more cases it doesn't handle. * Add some debug printing and a `--no-cleanup` flag for easier debugging. --- By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license]. [Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0 --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: github-actions <[email protected]>
1 parent d747938 commit ed8ac1a

File tree

12 files changed

+277
-103
lines changed

12 files changed

+277
-103
lines changed

src/commands/extract.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ export interface ExtractOptions {
9292
* @default false
9393
*/
9494
readonly compressCacheToFile?: boolean;
95+
96+
/**
97+
* Cleanup temporary directories
98+
*
99+
* @default true
100+
*/
101+
readonly cleanup?: boolean;
95102
}
96103

97104
export async function extractAndInfuse(assemblyLocations: string[], options: ExtractOptions): Promise<ExtractResult> {
@@ -157,7 +164,10 @@ export async function extractSnippets(
157164
logging.info('Translating');
158165
const startTime = Date.now();
159166

160-
const result = await translator.translateAll(snippets);
167+
const result = await translator.translateAll(snippets, {
168+
compilationDirectory: options.compilationDirectory,
169+
cleanup: options.cleanup,
170+
});
161171

162172
const delta = (Date.now() - startTime) / 1000;
163173
logging.info(

src/jsii/assemblies.ts

Lines changed: 11 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import * as fs from 'node:fs';
33
import * as path from 'node:path';
44
import { loadAssemblyFromFile, loadAssemblyFromPath, findAssemblyFile } from '@jsii/spec';
55
import * as spec from '@jsii/spec';
6-
7-
import { findDependencyDirectory, isBuiltinModule } from '../find-utils';
86
import { fixturize } from '../fixtures';
97
import { extractTypescriptSnippetsFromMarkdown } from '../markdown/extract-snippets';
108
import {
@@ -17,9 +15,10 @@ import {
1715
CompilationDependency,
1816
INITIALIZER_METHOD_NAME,
1917
} from '../snippet';
18+
import { resolveDependenciesFromPackageJson } from '../snippet-dependencies';
2019
import { enforcesStrictMode } from '../strict';
2120
import { LanguageTablet, DEFAULT_TABLET_NAME, DEFAULT_TABLET_NAME_COMPRESSED } from '../tablets/tablets';
22-
import { fmap, mkDict, sortBy } from '../util';
21+
import { fmap, mkDict, pathExists, sortBy } from '../util';
2322

2423
/**
2524
* The JSDoc tag users can use to associate non-visible metadata with an example
@@ -341,41 +340,21 @@ function withProjectDirectory(dir: string, snippet: TypeScriptSnippet) {
341340
* The dependencies will be taken from the package.json, and will consist of:
342341
*
343342
* - The package itself
344-
* - The package's dependencies and peerDependencies
343+
* - The package's dependencies and peerDependencies (but NOT devDependencies). Will
344+
* symlink to the files on disk.
345345
* - Any additional dependencies declared in `jsiiRosetta.exampleDependencies`.
346346
*/
347347
async function withDependencies(asm: LoadedAssembly, snippet: TypeScriptSnippet): Promise<TypeScriptSnippet> {
348348
const compilationDependencies: Record<string, CompilationDependency> = {};
349349

350-
compilationDependencies[asm.assembly.name] = {
351-
type: 'concrete',
352-
resolvedDirectory: await fsPromises.realpath(asm.directory),
353-
};
350+
if (await pathExists(path.join(asm.directory, 'package.json'))) {
351+
compilationDependencies[asm.assembly.name] = {
352+
type: 'concrete',
353+
resolvedDirectory: await fsPromises.realpath(asm.directory),
354+
};
355+
}
354356

355-
Object.assign(
356-
compilationDependencies,
357-
mkDict(
358-
await Promise.all(
359-
Object.keys({ ...asm.packageJson?.dependencies, ...asm.packageJson?.peerDependencies })
360-
.filter((name) => !isBuiltinModule(name))
361-
.filter(
362-
(name) =>
363-
!asm.packageJson?.bundledDependencies?.includes(name) &&
364-
!asm.packageJson?.bundleDependencies?.includes(name),
365-
)
366-
.map(
367-
async (name) =>
368-
[
369-
name,
370-
{
371-
type: 'concrete',
372-
resolvedDirectory: await fsPromises.realpath(await findDependencyDirectory(name, asm.directory)),
373-
},
374-
] as const,
375-
),
376-
),
377-
),
378-
);
357+
Object.assign(compilationDependencies, await resolveDependenciesFromPackageJson(asm.packageJson, asm.directory));
379358

380359
Object.assign(
381360
compilationDependencies,

src/main.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ async function main() {
243243
describe: 'Compress the cache-to file',
244244
default: false,
245245
})
246+
.options('cleanup', {
247+
type: 'boolean',
248+
describe: 'Clean up temporary directories',
249+
default: true,
250+
})
246251
.conflicts('loose', 'strict')
247252
.conflicts('loose', 'fail'),
248253
wrapHandler(async (args) => {
@@ -269,6 +274,7 @@ async function main() {
269274
loose: args.loose,
270275
compressTablet: args['compress-tablet'],
271276
compressCacheToFile: args['compress-cache'],
277+
cleanup: args.cleanup,
272278
};
273279

274280
const result = args.infuse

src/rosetta-translator.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { TypeFingerprinter } from './jsii/fingerprinting';
55
import { TARGET_LANGUAGES } from './languages';
66
import * as logging from './logging';
77
import { TypeScriptSnippet, completeSource } from './snippet';
8-
import { collectDependencies, validateAvailableDependencies, prepareDependencyDirectory } from './snippet-dependencies';
8+
import {
9+
collectDependencies,
10+
validateAvailableDependencies,
11+
prepareDependencyDirectory,
12+
expandWithTransitiveDependencies,
13+
} from './snippet-dependencies';
914
import { snippetKey } from './tablets/key';
1015
import { LanguageTablet, TranslatedSnippet } from './tablets/tablets';
1116
import { translateAll, TranslateAllResult } from './translate_all';
@@ -168,6 +173,7 @@ export class RosettaTranslator {
168173
: { addToTablet: optionsOrAddToTablet };
169174

170175
const exampleDependencies = collectDependencies(snippets);
176+
await expandWithTransitiveDependencies(exampleDependencies);
171177

172178
let compilationDirectory;
173179
let cleanCompilationDir = false;
@@ -190,7 +196,11 @@ export class RosettaTranslator {
190196
} finally {
191197
process.chdir(origDir);
192198
if (cleanCompilationDir) {
193-
await fs.rm(compilationDirectory, { force: true, recursive: true });
199+
if (options.cleanup ?? true) {
200+
await fs.rm(compilationDirectory, { force: true, recursive: true });
201+
} else {
202+
logging.info(`Leaving directory uncleaned: ${compilationDirectory}`);
203+
}
194204
}
195205
}
196206

@@ -309,4 +319,9 @@ export interface TranslateAllOptions {
309319
* @default true
310320
*/
311321
readonly addToTablet?: boolean;
322+
323+
/**
324+
* @default true
325+
*/
326+
readonly cleanup?: boolean;
312327
}

src/snippet-dependencies.ts

Lines changed: 121 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import { promises as fsPromises } from 'node:fs';
33
import * as fs from 'node:fs';
44
import * as os from 'node:os';
55
import * as path from 'node:path';
6+
import { PackageJson } from '@jsii/spec';
67
import * as fastGlob from 'fast-glob';
78
import * as semver from 'semver';
89
import { intersect } from 'semver-intersect';
910

10-
import { findDependencyDirectory, findUp } from './find-utils';
11+
import { findDependencyDirectory, findUp, isBuiltinModule } from './find-utils';
1112
import * as logging from './logging';
1213
import { TypeScriptSnippet, CompilationDependency } from './snippet';
1314
import { mkDict, formatList, pathExists } from './util';
@@ -27,6 +28,72 @@ export function collectDependencies(snippets: TypeScriptSnippet[]) {
2728
return ret;
2829
}
2930

31+
/**
32+
* Add transitive dependencies of concrete dependencies to the array
33+
*
34+
* This is necessary to prevent multiple copies of transitive dependencies on disk, which
35+
* jsii-based packages might not deal with very well.
36+
*/
37+
export async function expandWithTransitiveDependencies(deps: Record<string, CompilationDependency>) {
38+
const pathsSeen = new Set<string>();
39+
const queue = Object.values(deps).filter(isConcrete);
40+
41+
let next = queue.shift();
42+
while (next) {
43+
await addDependenciesOf(next.resolvedDirectory);
44+
next = queue.shift();
45+
}
46+
47+
async function addDependenciesOf(dir: string) {
48+
if (pathsSeen.has(dir)) {
49+
return;
50+
}
51+
pathsSeen.add(dir);
52+
try {
53+
const pj: PackageJson = JSON.parse(
54+
await fsPromises.readFile(path.join(dir, 'package.json'), { encoding: 'utf-8' }),
55+
);
56+
for (const [name, dep] of Object.entries(await resolveDependenciesFromPackageJson(pj, dir))) {
57+
if (!deps[name]) {
58+
deps[name] = dep;
59+
queue.push(dep);
60+
}
61+
}
62+
} catch (e: any) {
63+
if (e.code === 'ENOENT') {
64+
return;
65+
}
66+
throw e;
67+
}
68+
}
69+
}
70+
71+
/**
72+
* Find the corresponding package directories for all dependencies in a package.json
73+
*/
74+
export async function resolveDependenciesFromPackageJson(packageJson: PackageJson | undefined, directory: string) {
75+
return mkDict(
76+
await Promise.all(
77+
Object.keys({ ...packageJson?.dependencies, ...packageJson?.peerDependencies })
78+
.filter((name) => !isBuiltinModule(name))
79+
.filter(
80+
(name) =>
81+
!packageJson?.bundledDependencies?.includes(name) && !packageJson?.bundleDependencies?.includes(name),
82+
)
83+
.map(
84+
async (name) =>
85+
[
86+
name,
87+
{
88+
type: 'concrete',
89+
resolvedDirectory: await fsPromises.realpath(await findDependencyDirectory(name, directory)),
90+
},
91+
] as const,
92+
),
93+
),
94+
);
95+
}
96+
3097
function resolveConflict(
3198
name: string,
3299
a: CompilationDependency,
@@ -47,7 +114,7 @@ function resolveConflict(
47114
// Intersect the ranges
48115
return {
49116
type: 'symbolic',
50-
versionRange: intersect(a.versionRange, b.versionRange),
117+
versionRange: myVersionIntersect(a.versionRange, b.versionRange),
51118
};
52119
}
53120

@@ -79,6 +146,7 @@ function resolveConflict(
79146
* It's a warning if this is not true, not an error.
80147
*/
81148
export async function validateAvailableDependencies(directory: string, deps: Record<string, CompilationDependency>) {
149+
logging.info(`Validating dependencies at ${directory}`);
82150
const failures = await Promise.all(
83151
Object.entries(deps).flatMap(async ([name, _dep]) => {
84152
try {
@@ -97,6 +165,27 @@ export async function validateAvailableDependencies(directory: string, deps: Rec
97165
}
98166
}
99167

168+
/**
169+
* Intersect two semver ranges
170+
*
171+
* The package we are using for this doesn't support all syntaxes yet.
172+
* Do some work on top.
173+
*/
174+
function myVersionIntersect(a: string, b: string): string {
175+
if (a === '*') {
176+
return b;
177+
}
178+
if (b === '*') {
179+
return a;
180+
}
181+
182+
try {
183+
return intersect(a, b);
184+
} catch (e: any) {
185+
throw new Error(`semver-intersect does not support either '${a}' or '${b}': ${e.message}`);
186+
}
187+
}
188+
100189
/**
101190
* Prepare a temporary directory with symlinks to all the dependencies we need.
102191
*
@@ -112,7 +201,7 @@ export async function prepareDependencyDirectory(deps: Record<string, Compilatio
112201
const monorepoPackages = await scanMonoRepos(concreteDirs);
113202

114203
const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'rosetta'));
115-
logging.info(`Preparing dependency closure at ${tmpDir}`);
204+
logging.info(`Preparing dependency closure at ${tmpDir} (-vv for more details)`);
116205

117206
// Resolved symbolic packages against monorepo
118207
const resolvedDeps = mkDict(
@@ -126,39 +215,38 @@ export async function prepareDependencyDirectory(deps: Record<string, Compilatio
126215
]),
127216
);
128217

129-
// Use 'npm install' only for the symbolic packages. For the concrete packages,
130-
// npm is going to try and find transitive dependencies as well and it won't know
131-
// about monorepos.
132-
const symbolicInstalls = Object.entries(resolvedDeps).flatMap(([name, dep]) =>
133-
isSymbolic(dep) ? [`${name}@${dep.versionRange}`] : [],
134-
);
135-
const linkedInstalls = mkDict(
136-
Object.entries(resolvedDeps).flatMap(([name, dep]) =>
137-
isConcrete(dep) ? [[name, dep.resolvedDirectory] as const] : [],
218+
const dependencies: Record<string, string> = {};
219+
for (const [name, dep] of Object.entries(resolvedDeps)) {
220+
if (isConcrete(dep)) {
221+
logging.debug(`${name} -> ${dep.resolvedDirectory}`);
222+
dependencies[name] = `file:${dep.resolvedDirectory}`;
223+
} else {
224+
logging.debug(`${name} @ ${dep.versionRange}`);
225+
dependencies[name] = dep.versionRange;
226+
}
227+
}
228+
229+
await fsPromises.writeFile(
230+
path.join(tmpDir, 'package.json'),
231+
JSON.stringify(
232+
{
233+
name: 'examples',
234+
version: '0.0.1',
235+
private: true,
236+
dependencies,
237+
},
238+
undefined,
239+
2,
138240
),
241+
{
242+
encoding: 'utf-8',
243+
},
139244
);
140245

141-
// Run 'npm install' on it
142-
if (symbolicInstalls.length > 0) {
143-
logging.debug(`Installing example dependencies: ${symbolicInstalls.join(' ')}`);
144-
cp.execSync(`npm install ${symbolicInstalls.join(' ')}`, { cwd: tmpDir, encoding: 'utf-8' });
145-
}
146-
147-
// Symlink the rest
148-
if (Object.keys(linkedInstalls).length > 0) {
149-
logging.debug(`Symlinking example dependencies: ${Object.values(linkedInstalls).join(' ')}`);
150-
const modDir = path.join(tmpDir, 'node_modules');
151-
await Promise.all(
152-
Object.entries(linkedInstalls).map(async ([name, source]) => {
153-
const target = path.join(modDir, name);
154-
if (!(await pathExists(target))) {
155-
// Package could be namespaced, so ensure the namespace dir exists
156-
await fsPromises.mkdir(path.dirname(target), { recursive: true });
157-
await fsPromises.symlink(source, target, 'dir');
158-
}
159-
}),
160-
);
161-
}
246+
// Run NPM install on this package.json. We need to include --force for packages
247+
// that have a symbolic version in the symlinked dev tree (like "0.0.0"), but have
248+
// actual version range dependencies from externally installed packages (like "^2.0.0").
249+
cp.execSync(`npm install --force --loglevel error`, { cwd: tmpDir, encoding: 'utf-8' });
162250

163251
return tmpDir;
164252
}
@@ -226,10 +314,6 @@ async function findMonoRepoGlobs(startingDir: string): Promise<Set<string>> {
226314
return ret;
227315
}
228316

229-
function isSymbolic(x: CompilationDependency): x is Extract<CompilationDependency, { type: 'symbolic' }> {
230-
return x.type === 'symbolic';
231-
}
232-
233317
function isConcrete(x: CompilationDependency): x is Extract<CompilationDependency, { type: 'concrete' }> {
234318
return x.type === 'concrete';
235319
}

src/snippet.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@ export enum SnippetParameters {
310310
/**
311311
* What directory to resolve fixtures in for this snippet (system parameter)
312312
*
313-
* Attached during processing, should not be used by authors.
313+
* Attached during processing, should not be used by authors. Does NOT imply
314+
* anything about the directory where we pretend to compile this file.
314315
*/
315316
$PROJECT_DIRECTORY = '$directory',
316317

0 commit comments

Comments
 (0)