Skip to content

Commit f9983f4

Browse files
authored
Merge pull request #177 from bmish/autofix
Implement autofixing
2 parents 5bb869f + f22130a commit f9983f4

File tree

6 files changed

+311
-3
lines changed

6 files changed

+311
-3
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ sinon has more than one version: ^1.17.7 (1 usage), ^9.0.3 (3 usages)
4040

4141
| Name | Description |
4242
| --- | --- |
43+
| `--fix` | Whether to autofix inconsistencies (using highest version present). |
4344
| `--ignore-dep` | Dependency to ignore mismatches for (option can be repeated). |
4445

4546
## Related

bin/check-dependency-version-consistency.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
calculateVersionsForEachDependency,
88
calculateMismatchingVersions,
99
filterOutIgnoredDependencies,
10+
fixMismatchingVersions,
1011
} from '../lib/dependency-versions.js';
1112
import { mismatchingVersionsToOutputLines } from '../lib/output.js';
1213
import { join, dirname } from 'node:path';
@@ -35,20 +36,29 @@ const program = new Command();
3536
program
3637
.version(getCurrentPackageVersion())
3738
.argument('<path>', 'path to workspace root')
39+
.option(
40+
'--fix',
41+
'Whether to autofix inconsistencies (using highest version present)',
42+
false
43+
)
3844
.option(
3945
'--ignore-dep <dependency>',
4046
'Dependency to ignore (option can be repeated)',
4147
collect,
4248
[]
4349
)
44-
.action(function (path, options: { ignoreDep: string[] }) {
50+
.action(function (path, options: { ignoreDep: string[]; fix: boolean }) {
4551
// Calculate.
4652
const dependencyVersions = calculateVersionsForEachDependency(path);
47-
const mismatchingVersions = filterOutIgnoredDependencies(
53+
let mismatchingVersions = filterOutIgnoredDependencies(
4854
calculateMismatchingVersions(dependencyVersions),
4955
options.ignoreDep
5056
);
5157

58+
if (options.fix) {
59+
mismatchingVersions = fixMismatchingVersions(path, mismatchingVersions);
60+
}
61+
5262
// Show output.
5363
if (mismatchingVersions.length > 0) {
5464
const outputLines = mismatchingVersionsToOutputLines(mismatchingVersions);

lib/dependency-versions.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { readFileSync, existsSync } from 'node:fs';
22
import type { PackageJson } from 'type-fest';
33
import { getPackageJsonPaths } from './workspace.js';
4+
import semver from 'semver';
5+
import editJsonFile from 'edit-json-file';
46

57
export type DependenciesToVersionsSeen = Map<
68
string,
@@ -158,3 +160,93 @@ export function filterOutIgnoredDependencies(
158160
!ignoredDependencies.includes(mismatchingVersion.dependency)
159161
);
160162
}
163+
164+
export function fixMismatchingVersions(
165+
root: string,
166+
mismatchingVersions: MismatchingDependencyVersions
167+
): MismatchingDependencyVersions {
168+
const packageJsonPaths = getPackageJsonPaths(root);
169+
170+
// Return any mismatching versions that are still present after attempting fixes.
171+
return mismatchingVersions
172+
.map((mismatchingVersion) => {
173+
const versions = mismatchingVersion.versions.map(
174+
(versionAndCount) => versionAndCount.version
175+
);
176+
let sortedVersions;
177+
try {
178+
sortedVersions = versions.sort(compareRanges);
179+
} catch {
180+
// Unable to sort so skip this dependency (return it to indicate it was not fixed).
181+
return mismatchingVersion;
182+
}
183+
const fixedVersion = sortedVersions[sortedVersions.length - 1]; // Highest version will be sorted to end of list.
184+
185+
for (const packageJsonPath of packageJsonPaths) {
186+
const packageJson: PackageJson = JSON.parse(
187+
readFileSync(packageJsonPath, 'utf-8')
188+
);
189+
190+
if (
191+
packageJson.devDependencies &&
192+
packageJson.devDependencies[mismatchingVersion.dependency] &&
193+
packageJson.devDependencies[mismatchingVersion.dependency] !==
194+
fixedVersion
195+
) {
196+
const packageJson = editJsonFile(packageJsonPath, { autosave: true });
197+
packageJson.set(
198+
`devDependencies.${mismatchingVersion.dependency}`,
199+
fixedVersion
200+
);
201+
}
202+
203+
if (
204+
packageJson.dependencies &&
205+
packageJson.dependencies[mismatchingVersion.dependency] &&
206+
packageJson.dependencies[mismatchingVersion.dependency] !==
207+
fixedVersion
208+
) {
209+
const packageJson = editJsonFile(packageJsonPath, { autosave: true });
210+
packageJson.set(
211+
`dependencies.${mismatchingVersion.dependency}`,
212+
fixedVersion
213+
);
214+
}
215+
}
216+
217+
// Fixed successfully.
218+
return undefined;
219+
})
220+
.filter((item) => item !== undefined) as MismatchingDependencyVersions;
221+
}
222+
223+
export function compareRanges(a: string, b: string): 0 | -1 | 1 {
224+
// Strip range and coerce to normalized version.
225+
const aVersion = semver.coerce(a.replace(/^[\^~]/, ''));
226+
const bVersion = semver.coerce(b.replace(/^[\^~]/, ''));
227+
if (!aVersion) {
228+
throw new Error(`Invalid Version: ${a}`);
229+
}
230+
if (!bVersion) {
231+
throw new Error(`Invalid Version: ${b}`);
232+
}
233+
234+
if (semver.eq(aVersion, bVersion)) {
235+
// Same version, but wider range considered higher.
236+
if (a.startsWith('^') && !b.startsWith('^')) {
237+
return 1;
238+
} else if (!a.startsWith('^') && b.startsWith('^')) {
239+
return -1;
240+
} else if (a.startsWith('~') && !b.startsWith('~')) {
241+
return 1;
242+
} else if (!a.startsWith('~') && b.startsWith('~')) {
243+
return -1;
244+
}
245+
246+
// Same version, same range.
247+
return 0;
248+
}
249+
250+
// Greater version considered higher.
251+
return semver.gt(aVersion, bVersion) ? 1 : -1;
252+
}

package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,24 @@
4040
},
4141
"dependencies": {
4242
"commander": "^8.1.0",
43+
"edit-json-file": "^1.6.0",
44+
"semver": "^7.3.5",
4345
"type-fest": "^2.1.0"
4446
},
4547
"devDependencies": {
48+
"@types/edit-json-file": "^1.6.0",
4649
"@types/mocha": "^9.0.0",
50+
"@types/mock-fs": "^4.13.1",
4751
"@types/node": "^16.0.0",
52+
"@types/semver": "^7.3.8",
4853
"@typescript-eslint/eslint-plugin": "^4.0.1",
4954
"@typescript-eslint/parser": "^4.0.1",
5055
"eslint": "^7.0.0",
5156
"eslint-plugin-node": "^11.1.0",
5257
"eslint-plugin-square": "^20.0.2",
5358
"markdownlint-cli": "^0.28.1",
5459
"mocha": "^9.1.1",
60+
"mock-fs": "^5.0.0",
5561
"npm-package-json-lint": "^5.2.3",
5662
"npm-run-all": "^4.1.5",
5763
"nyc": "^15.1.0",

test/lib/dependency-versions.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@ import {
33
calculateVersionsForEachDependency,
44
calculateMismatchingVersions,
55
filterOutIgnoredDependencies,
6+
fixMismatchingVersions,
7+
compareRanges,
68
} from '../../lib/dependency-versions.js';
7-
import { deepStrictEqual, throws } from 'node:assert';
9+
import { strictEqual, deepStrictEqual, throws } from 'node:assert';
810
import {
911
FIXTURE_PATH_VALID,
1012
FIXTURE_PATH_INCONSISTENT_VERSIONS,
1113
FIXTURE_PATH_NO_PACKAGES,
1214
FIXTURE_PATH_NO_DEPENDENCIES,
1315
FIXTURE_PATH_PACKAGE_MISSING_PACKAGE_JSON,
1416
} from '../fixtures/index.js';
17+
import mockFs from 'mock-fs';
18+
import { readFileSync } from 'node:fs';
19+
import type { PackageJson } from 'type-fest';
1520

1621
describe('Utils | dependency-versions', function () {
1722
describe('#calculateMismatchingVersions', function () {
@@ -115,4 +120,124 @@ describe('Utils | dependency-versions', function () {
115120
);
116121
});
117122
});
123+
124+
describe('#fixMismatchingVersions', function () {
125+
beforeEach(function () {
126+
// Create a mock workspace filesystem for temporary usage in this test because changes will be written to some files.
127+
mockFs({
128+
'package.json': '{"workspaces": ["scope1/*"]}',
129+
'scope1/package1': {
130+
'package.json':
131+
'{"dependencies": {"foo": "^1.0.0", "bar": "^3.0.0" }}',
132+
},
133+
'scope1/package2': {
134+
'package.json':
135+
'{"dependencies": {"foo": "^2.0.0", "bar": "invalidVersion" }}',
136+
},
137+
});
138+
});
139+
140+
afterEach(function () {
141+
mockFs.restore();
142+
});
143+
144+
it('fixes the fixable inconsistencies', function () {
145+
const mismatchingVersions = calculateMismatchingVersions(
146+
calculateVersionsForEachDependency('.')
147+
);
148+
const fixedMismatchingVersions = fixMismatchingVersions(
149+
'.',
150+
mismatchingVersions
151+
);
152+
153+
const packageJson1: PackageJson = JSON.parse(
154+
readFileSync('scope1/package1/package.json', 'utf-8')
155+
);
156+
const packageJson2: PackageJson = JSON.parse(
157+
readFileSync('scope1/package2/package.json', 'utf-8')
158+
);
159+
160+
strictEqual(
161+
packageJson1.dependencies && packageJson1.dependencies.foo,
162+
'^2.0.0',
163+
'updates the package1 `foo` version to the highest version'
164+
);
165+
strictEqual(
166+
packageJson1.dependencies && packageJson1.dependencies.bar,
167+
'^3.0.0',
168+
'does not change package1 `bar` version due to abnormal version present'
169+
);
170+
strictEqual(
171+
packageJson2.dependencies && packageJson2.dependencies.foo,
172+
'^2.0.0',
173+
'does not change package1 `foo` version since already at highest version'
174+
);
175+
strictEqual(
176+
packageJson2.dependencies && packageJson2.dependencies.bar,
177+
'invalidVersion',
178+
'does not change package1 `bar` version due to abnormal version present'
179+
);
180+
181+
deepStrictEqual(
182+
fixedMismatchingVersions,
183+
[
184+
{
185+
dependency: 'bar',
186+
versions: [
187+
{
188+
count: 1,
189+
version: '^3.0.0',
190+
},
191+
{
192+
count: 1,
193+
version: 'invalidVersion',
194+
},
195+
],
196+
},
197+
],
198+
'should return only the dependency that could not be fixed due to the abnormal version present'
199+
);
200+
});
201+
});
202+
203+
describe('#compareRanges', function () {
204+
it('correctly chooses the higher range', function () {
205+
// 1 (greater than)
206+
strictEqual(compareRanges('1.2.3', '1.2.2'), 1);
207+
strictEqual(compareRanges('5.0.0', '4.0.0'), 1);
208+
strictEqual(compareRanges('8.0.0-beta.1', '^7'), 1);
209+
strictEqual(compareRanges('^5.0.0', '4.0.0'), 1);
210+
strictEqual(compareRanges('^5.0.0', '^4.0.0'), 1);
211+
strictEqual(compareRanges('^5.0.0', '~4.0.0'), 1);
212+
strictEqual(compareRanges('^5.0.0', '~5.0.0'), 1);
213+
strictEqual(compareRanges('~5.0.0', '5.0.0'), 1);
214+
strictEqual(compareRanges('~5.0.0', '~4.0.0'), 1);
215+
216+
// -1 (less than)
217+
strictEqual(compareRanges('4.0.0', '5.0.0'), -1);
218+
strictEqual(compareRanges('5.0.0', '~5.0.0'), -1);
219+
strictEqual(compareRanges('^4.0.0', '^5.0.0'), -1);
220+
strictEqual(compareRanges('~4.0.0', '~5.0.0'), -1);
221+
strictEqual(compareRanges('~5.0.0', '^5.0.0'), -1);
222+
223+
// 0 (equal)
224+
strictEqual(compareRanges('6', '6'), 0);
225+
strictEqual(compareRanges('6.0', '6.0'), 0);
226+
strictEqual(compareRanges('6.0.0', '6.0.0'), 0);
227+
strictEqual(compareRanges('^6.0.0', '^6.0.0'), 0);
228+
strictEqual(compareRanges('v6', '6'), 0);
229+
strictEqual(compareRanges('~6.0.0', '~6.0.0'), 0);
230+
});
231+
232+
it('throws with invalid ranges', function () {
233+
throws(
234+
() => compareRanges('foo', '~6.0.0'),
235+
new Error('Invalid Version: foo')
236+
);
237+
throws(
238+
() => compareRanges('~6.0.0', 'foo'),
239+
new Error('Invalid Version: foo')
240+
);
241+
});
242+
});
118243
});

0 commit comments

Comments
 (0)