Skip to content

Commit e15eabe

Browse files
authored
Transitive Context Import Fix (#519)
* Add highlighting to directory trees * Remove obsolete option from markdown-tree and get tsconfig to include vitest setup files * Update docs on mocking * Fix not being able to add extra options when declaring dirtree code fence in markdown * Continue applying documentation fixes * Update some documentation * Apply lockfiles fix * Fix plotly bundle incorrectly importing context * Add an esbuild plugin to detect when context is being imported transitively from other bundles * Change the context detection plugin to work for both bundles and tabs * Update docs to talk about the need for isolating context dependent functions * Make sure that package.json isn't read if manifest validation fails * Properly handle root paths in convertPathsToPosix * Fix incorrect paths referring to test mocks * Update lint packages * Update incorrect linting configuration * Add paths configuration entry for shiki * Update docs about module contexts * Update devserver configuration to include bundles in hot reload * Use Richard's implementation for lockfile change detection * Use better memoization for exploring packages that have already been explored before * Update docs * Update broken tests and coverage inclusion * Fix incorrect actions code * Fix spelling * fix: grammar
1 parent a58d161 commit e15eabe

File tree

91 files changed

+2031
-1278
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+2031
-1278
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import * as utils from '../lockfiles.js';
3+
4+
vi.mock(import('../gitRoot.js'), () => ({
5+
gitRoot: 'root'
6+
}));
7+
8+
describe(utils.extractPackageName, () => {
9+
it('works with packages that start with @', () => {
10+
expect(utils.extractPackageName('@sourceacademy/tab-Rune@workspace:^'))
11+
.toEqual('@sourceacademy/tab-Rune');
12+
});
13+
14+
it('works with regular package names', () => {
15+
expect(utils.extractPackageName('lodash@npm:^4.17.20'))
16+
.toEqual('lodash');
17+
});
18+
19+
it('throws an error on an invalid package name', () => {
20+
expect(() => utils.extractPackageName('something weird'))
21+
.toThrowError('Invalid package name: something weird');
22+
});
23+
});
File renamed without changes.

.github/actions/src/info/__tests__/index.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import pathlib from 'path';
44
import * as core from '@actions/core';
55
import { describe, expect, test, vi } from 'vitest';
66
import * as git from '../../commons.js';
7-
import * as lockfiles from '../../lockfiles/index.js';
7+
import * as lockfiles from '../../lockfiles.js';
88
import { getAllPackages, getRawPackages, main } from '../index.js';
99

1010
const mockedCheckChanges = vi.spyOn(git, 'checkDirForChanges');

.github/actions/src/info/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { SummaryTableRow } from '@actions/core/lib/summary.js';
66
import packageJson from '../../../../package.json' with { type: 'json' };
77
import { checkDirForChanges, type PackageRecord, type RawPackageRecord } from '../commons.js';
88
import { gitRoot } from '../gitRoot.js';
9-
import { getPackagesWithResolutionChanges, hasLockFileChanged } from '../lockfiles/index.js';
9+
import { getPackagesWithResolutionChanges, hasLockFileChanged } from '../lockfiles.js';
1010
import { topoSortPackages } from './sorter.js';
1111

1212
const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;
@@ -16,7 +16,7 @@ const packageNameRE = /^@sourceacademy\/(.+?)-(.+)$/u;
1616
* an unprocessed format
1717
*/
1818
export async function getRawPackages(gitRoot: string, maxDepth?: number) {
19-
let packagesWithResolutionChanges: Set<string> | null = null;
19+
let packagesWithResolutionChanges: string[] | null = null;
2020

2121
// If there are lock file changes we need to set hasChanges to true for
2222
// that package even if that package's directory has no changes
@@ -43,7 +43,7 @@ export async function getRawPackages(gitRoot: string, maxDepth?: number) {
4343

4444
output[packageJson.name] = {
4545
directory: currentDir,
46-
hasChanges: packagesWithResolutionChanges?.has(packageJson.name) ?? hasChanges,
46+
hasChanges: hasChanges || !!packagesWithResolutionChanges?.includes(packageJson.name),
4747
package: packageJson
4848
};
4949
} catch (error) {

.github/actions/src/lockfiles.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import fs from 'fs/promises';
2+
import pathlib from 'path';
3+
import * as core from '@actions/core';
4+
import { getExecOutput } from '@actions/exec';
5+
import memoize from 'lodash/memoize.js';
6+
import { extractPkgsFromYarnLockV2 } from 'snyk-nodejs-lockfile-parser';
7+
import { gitRoot } from './gitRoot.js';
8+
9+
const packageNameRE = /^(.+)@.+$/;
10+
11+
/**
12+
* Lockfile specifications come in the form of package_name@resolution, but
13+
* we only want the package name. This function extracts that package name,
14+
* accounting for the fact that package names might start with '@'
15+
*/
16+
export function extractPackageName(raw: string) {
17+
const match = packageNameRE.exec(raw);
18+
if (!match) {
19+
throw new Error(`Invalid package name: ${raw}`);
20+
}
21+
22+
return match[1];
23+
}
24+
25+
/**
26+
* Parses and lockfile's contents and extracts all the different dependencies and
27+
* versions
28+
*/
29+
function processLockFileText(contents: string) {
30+
const lockFile = extractPkgsFromYarnLockV2(contents);
31+
const mappings = new Set<string>();
32+
for (const [pkgSpecifier, { resolution }] of Object.entries(lockFile)) {
33+
mappings.add(`${pkgSpecifier} -> ${resolution}`);
34+
}
35+
return mappings;
36+
}
37+
38+
/**
39+
* Retrieves the contents of the lockfile in the repo
40+
*/
41+
async function getCurrentLockFile() {
42+
const lockFilePath = pathlib.join(gitRoot, 'yarn.lock');
43+
const contents = await fs.readFile(lockFilePath, 'utf-8');
44+
return processLockFileText(contents);
45+
}
46+
47+
/**
48+
* Retrieves the contents of the lockfile on the master branch
49+
*/
50+
async function getMasterLockFile() {
51+
const { stdout, stderr, exitCode } = await getExecOutput(
52+
'git',
53+
[
54+
'--no-pager',
55+
'show',
56+
'origin/master:yarn.lock'
57+
],
58+
{ silent: true }
59+
);
60+
61+
if (exitCode !== 0) {
62+
core.error(stderr);
63+
throw new Error('git show exited with non-zero error-code');
64+
}
65+
66+
return processLockFileText(stdout);
67+
}
68+
69+
interface ResolutionSpec { pkgSpecifier: string, pkgName: string }
70+
71+
/**
72+
* Parsed output entry returned by `yarn why`
73+
*/
74+
interface YarnWhyOutput {
75+
value: string;
76+
children: {
77+
[locator: string]: {
78+
locator: string;
79+
descriptor: string;
80+
};
81+
};
82+
}
83+
84+
/**
85+
* Run `yarn why <package_name>` to see why a package is included
86+
* Don't use recursive (-R) since we want to build the graph ourselves
87+
* @function
88+
*/
89+
const runYarnWhy = memoize(async (pkgName: string) => {
90+
// Memoize the call so that we don't need to call yarn why multiple times for each package
91+
const { stdout: output, exitCode, stderr } = await getExecOutput('yarn', ['why', pkgName, '--json'], { silent: true });
92+
if (exitCode !== 0) {
93+
core.error(stderr);
94+
throw new Error(`yarn why for ${pkgName} failed!`);
95+
}
96+
97+
return output.split('\n').reduce<YarnWhyOutput[]>((res, each) => {
98+
each = each.trim();
99+
if (each === '') return res;
100+
101+
const pkg = JSON.parse(each) as YarnWhyOutput;
102+
return [...res, pkg];
103+
}, []);
104+
});
105+
106+
/**
107+
* Determines the names of the packages that have changed versions
108+
*/
109+
export async function getPackagesWithResolutionChanges() {
110+
const [currentLockFileMappings, masterLockFileMappings] = await Promise.all([
111+
getCurrentLockFile(),
112+
getMasterLockFile()
113+
]);
114+
115+
const changes = new Set(masterLockFileMappings);
116+
for (const edge of currentLockFileMappings) {
117+
changes.delete(edge);
118+
}
119+
120+
const frontier: ResolutionSpec[] = [];
121+
const changedDeps = new Set<string>();
122+
for (const edge of changes) {
123+
const [pkgSpecifier] = edge.split(' -> ');
124+
changedDeps.add(pkgSpecifier);
125+
frontier.push({
126+
pkgSpecifier,
127+
pkgName: extractPackageName(pkgSpecifier)
128+
});
129+
}
130+
131+
while (frontier.length > 0) {
132+
const { pkgName, pkgSpecifier } = frontier.shift()!;
133+
134+
const reasons = await runYarnWhy(pkgName);
135+
reasons.forEach(pkg => {
136+
if (changedDeps.has(pkg.value)) {
137+
// If we've already added this pkg specifier, don't need to explore it again
138+
return;
139+
}
140+
141+
const childrenSpecifiers = Object.values(pkg.children).map(({ descriptor }) => descriptor);
142+
if (!childrenSpecifiers.includes(pkgSpecifier)) return;
143+
144+
frontier.push({ pkgSpecifier: pkg.value, pkgName: extractPackageName(pkg.value) });
145+
changedDeps.add(pkg.value);
146+
});
147+
}
148+
149+
core.info('=== Summary of dirty monorepo packages ===\n');
150+
const pkgsToRebuild = [...changedDeps].filter(pkgSpecifier => pkgSpecifier.includes('@workspace:'));
151+
for (const pkgName of pkgsToRebuild) {
152+
core.info(`- ${pkgName}`);
153+
}
154+
155+
return pkgsToRebuild.map(extractPackageName);
156+
}
157+
158+
/**
159+
* Returns `true` if there are changes present in the given directory relative to
160+
* the master branch\
161+
* Used to determine if the lockfile has changed
162+
*/
163+
export const hasLockFileChanged = memoize(async () => {
164+
const { exitCode } = await getExecOutput(
165+
'git --no-pager diff --quiet origin/master -- yarn.lock',
166+
[],
167+
{
168+
failOnStdErr: false,
169+
ignoreReturnCode: true
170+
}
171+
);
172+
return exitCode !== 0;
173+
});

.github/actions/src/lockfiles/__tests__/lockfiles.test.ts

Lines changed: 0 additions & 109 deletions
This file was deleted.

.github/actions/src/lockfiles/index.ts

Lines changed: 0 additions & 30 deletions
This file was deleted.

0 commit comments

Comments
 (0)