Skip to content

Commit df09951

Browse files
committed
fix(@schematics/angular): use the JSON AST for changing JSON files in migration
Fix #10396
1 parent 00e108c commit df09951

File tree

3 files changed

+248
-37
lines changed

3 files changed

+248
-37
lines changed

packages/schematics/angular/migrations/update-6/index.ts

+122-33
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,16 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
import { JsonObject, Path, join, normalize, tags } from '@angular-devkit/core';
8+
import {
9+
JsonObject,
10+
JsonParseMode,
11+
Path,
12+
join,
13+
normalize,
14+
parseJson,
15+
parseJsonAst,
16+
tags,
17+
} from '@angular-devkit/core';
918
import {
1019
Rule,
1120
SchematicContext,
@@ -16,6 +25,11 @@ import {
1625
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
1726
import { AppConfig, CliConfig } from '../../utility/config';
1827
import { latestVersions } from '../../utility/latest-versions';
28+
import {
29+
appendPropertyInAstObject,
30+
appendValueInAstArray,
31+
findPropertyInAstObject,
32+
} from './json-utils';
1933

2034
const defaults = {
2135
appRoot: 'src',
@@ -492,8 +506,6 @@ function extractProjectsConfig(config: CliConfig, tree: Tree): JsonObject {
492506
root: project.root,
493507
sourceRoot: project.root,
494508
projectType: 'application',
495-
cli: {},
496-
schematics: {},
497509
};
498510

499511
const e2eArchitect: JsonObject = {};
@@ -542,51 +554,91 @@ function updateSpecTsConfig(config: CliConfig): Rule {
542554
return (host: Tree, context: SchematicContext) => {
543555
const apps = config.apps || [];
544556
apps.forEach((app: AppConfig, idx: number) => {
545-
const tsSpecConfigPath =
546-
join(app.root as Path, app.testTsconfig || defaults.testTsConfig);
557+
const testTsConfig = app.testTsconfig || defaults.testTsConfig;
558+
const tsSpecConfigPath = join(normalize(app.root || ''), testTsConfig);
547559
const buffer = host.read(tsSpecConfigPath);
560+
548561
if (!buffer) {
549562
return;
550563
}
551-
const tsCfg = JSON.parse(buffer.toString());
552-
if (!tsCfg.files) {
553-
tsCfg.files = [];
564+
565+
566+
const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose);
567+
if (tsCfgAst.kind != 'object') {
568+
throw new SchematicsException('Invalid tsconfig. Was expecting an object');
554569
}
555570

556-
// Ensure the spec tsconfig contains the polyfills file
557-
if (tsCfg.files.indexOf(app.polyfills || defaults.polyfills) === -1) {
558-
tsCfg.files.push(app.polyfills || defaults.polyfills);
559-
host.overwrite(tsSpecConfigPath, JSON.stringify(tsCfg, null, 2));
571+
const filesAstNode = findPropertyInAstObject(tsCfgAst, 'files');
572+
if (filesAstNode && filesAstNode.kind != 'array') {
573+
throw new SchematicsException('Invalid tsconfig "files" property; expected an array.');
560574
}
575+
576+
const recorder = host.beginUpdate(tsSpecConfigPath);
577+
578+
const polyfills = app.polyfills || defaults.polyfills;
579+
if (!filesAstNode) {
580+
// Do nothing if the files array does not exist. This means exclude or include are
581+
// set and we shouldn't mess with that.
582+
} else {
583+
if (filesAstNode.value.indexOf(polyfills) == -1) {
584+
appendValueInAstArray(recorder, filesAstNode, polyfills);
585+
}
586+
}
587+
588+
host.commitUpdate(recorder);
561589
});
562590
};
563591
}
564592

565-
function updatePackageJson(packageManager?: string) {
593+
function updatePackageJson(config: CliConfig) {
566594
return (host: Tree, context: SchematicContext) => {
567595
const pkgPath = '/package.json';
568596
const buffer = host.read(pkgPath);
569597
if (buffer == null) {
570598
throw new SchematicsException('Could not read package.json');
571599
}
572-
const content = buffer.toString();
573-
const pkg = JSON.parse(content);
600+
const pkgAst = parseJsonAst(buffer.toString(), JsonParseMode.Strict);
574601

575-
if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
602+
if (pkgAst.kind != 'object') {
576603
throw new SchematicsException('Error reading package.json');
577604
}
578-
if (!pkg.devDependencies) {
579-
pkg.devDependencies = {};
605+
606+
const devDependenciesNode = findPropertyInAstObject(pkgAst, 'devDependencies');
607+
if (devDependenciesNode && devDependenciesNode.kind != 'object') {
608+
throw new SchematicsException('Error reading package.json; devDependency is not an object.');
580609
}
581610

582-
pkg.devDependencies['@angular-devkit/build-angular'] = latestVersions.DevkitBuildAngular;
611+
const recorder = host.beginUpdate(pkgPath);
612+
const depName = '@angular-devkit/build-angular';
613+
if (!devDependenciesNode) {
614+
// Haven't found the devDependencies key, add it to the root of the package.json.
615+
appendPropertyInAstObject(recorder, pkgAst, 'devDependencies', {
616+
[depName]: latestVersions.DevkitBuildAngular,
617+
});
618+
} else {
619+
// Check if there's a build-angular key.
620+
const buildAngularNode = findPropertyInAstObject(devDependenciesNode, depName);
621+
622+
if (!buildAngularNode) {
623+
// No build-angular package, add it.
624+
appendPropertyInAstObject(
625+
recorder,
626+
devDependenciesNode,
627+
depName,
628+
latestVersions.DevkitBuildAngular,
629+
);
630+
} else {
631+
const { end, start } = buildAngularNode;
632+
recorder.remove(start.offset, end.offset - start.offset);
633+
recorder.insertRight(start.offset, JSON.stringify(latestVersions.DevkitBuildAngular));
634+
}
635+
}
583636

584-
host.overwrite(pkgPath, JSON.stringify(pkg, null, 2));
637+
host.commitUpdate(recorder);
585638

586-
if (packageManager && !['npm', 'yarn', 'cnpm'].includes(packageManager)) {
587-
packageManager = undefined;
588-
}
589-
context.addTask(new NodePackageInstallTask({ packageManager }));
639+
context.addTask(new NodePackageInstallTask({
640+
packageManager: config.packageManager === 'default' ? undefined : config.packageManager,
641+
}));
590642

591643
return host;
592644
};
@@ -597,19 +649,52 @@ function updateTsLintConfig(): Rule {
597649
const tsLintPath = '/tslint.json';
598650
const buffer = host.read(tsLintPath);
599651
if (!buffer) {
600-
return;
652+
return host;
601653
}
602-
const tsCfg = JSON.parse(buffer.toString());
654+
const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose);
603655

604-
if (tsCfg.rules && tsCfg.rules['import-blacklist'] &&
605-
tsCfg.rules['import-blacklist'].indexOf('rxjs') !== -1) {
656+
if (tsCfgAst.kind != 'object') {
657+
return host;
658+
}
606659

607-
tsCfg.rules['import-blacklist'] = tsCfg.rules['import-blacklist']
608-
.filter((rule: string | boolean) => rule !== 'rxjs');
660+
const rulesNode = findPropertyInAstObject(tsCfgAst, 'rules');
661+
if (!rulesNode || rulesNode.kind != 'object') {
662+
return host;
663+
}
609664

610-
host.overwrite(tsLintPath, JSON.stringify(tsCfg, null, 2));
665+
const importBlacklistNode = findPropertyInAstObject(rulesNode, 'import-blacklist');
666+
if (!importBlacklistNode || importBlacklistNode.kind != 'array') {
667+
return host;
611668
}
612669

670+
const recorder = host.beginUpdate(tsLintPath);
671+
for (let i = 0; i < importBlacklistNode.elements.length; i++) {
672+
const element = importBlacklistNode.elements[i];
673+
if (element.kind == 'string' && element.value == 'rxjs') {
674+
const { start, end } = element;
675+
// Remove this element.
676+
if (i == importBlacklistNode.elements.length - 1) {
677+
// Last element.
678+
if (i > 0) {
679+
// Not first, there's a comma to remove before.
680+
const previous = importBlacklistNode.elements[i - 1];
681+
recorder.remove(previous.end.offset, end.offset - previous.end.offset);
682+
} else {
683+
// Only element, just remove the whole rule.
684+
const { start, end } = importBlacklistNode;
685+
recorder.remove(start.offset, end.offset - start.offset);
686+
recorder.insertLeft(start.offset, '[]');
687+
}
688+
} else {
689+
// Middle, just remove the whole node (up to next node start).
690+
const next = importBlacklistNode.elements[i + 1];
691+
recorder.remove(start.offset, next.start.offset - start.offset);
692+
}
693+
}
694+
}
695+
696+
host.commitUpdate(recorder);
697+
613698
return host;
614699
};
615700
}
@@ -627,13 +712,17 @@ export default function (): Rule {
627712
if (configBuffer == null) {
628713
throw new SchematicsException(`Could not find configuration file (${configPath})`);
629714
}
630-
const config = JSON.parse(configBuffer.toString());
715+
const config = parseJson(configBuffer.toString(), JsonParseMode.Loose);
716+
717+
if (typeof config != 'object' || Array.isArray(config) || config === null) {
718+
throw new SchematicsException('Invalid angular-cli.json configuration; expected an object.');
719+
}
631720

632721
return chain([
633722
migrateKarmaConfiguration(config),
634723
migrateConfiguration(config),
635724
updateSpecTsConfig(config),
636-
updatePackageJson(config.packageManager),
725+
updatePackageJson(config),
637726
updateTsLintConfig(),
638727
(host: Tree, context: SchematicContext) => {
639728
context.logger.warn(tags.oneLine`Some configuration options have been changed,

packages/schematics/angular/migrations/update-6/index_spec.ts

+56-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { JsonObject } from '@angular-devkit/core';
99
import { EmptyTree } from '@angular-devkit/schematics';
1010
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
1111
import * as path from 'path';
12+
import { latestVersions } from '../../utility/latest-versions';
1213

1314

1415
describe('Migration to v6', () => {
@@ -691,7 +692,32 @@ describe('Migration to v6', () => {
691692
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
692693
const content = tree.readContent('/package.json');
693694
const pkg = JSON.parse(content);
694-
expect(pkg.devDependencies['@angular-devkit/build-angular']).toBeDefined();
695+
expect(pkg.devDependencies['@angular-devkit/build-angular'])
696+
.toBe(latestVersions.DevkitBuildAngular);
697+
});
698+
699+
it('should add a dev dependency to @angular-devkit/build-angular (present)', () => {
700+
tree.create(oldConfigPath, JSON.stringify(baseConfig, null, 2));
701+
tree.overwrite('/package.json', JSON.stringify({
702+
devDependencies: {
703+
'@angular-devkit/build-angular': '0.0.0',
704+
},
705+
}, null, 2));
706+
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
707+
const content = tree.readContent('/package.json');
708+
const pkg = JSON.parse(content);
709+
expect(pkg.devDependencies['@angular-devkit/build-angular'])
710+
.toBe(latestVersions.DevkitBuildAngular);
711+
});
712+
713+
it('should add a dev dependency to @angular-devkit/build-angular (no dev)', () => {
714+
tree.create(oldConfigPath, JSON.stringify(baseConfig, null, 2));
715+
tree.overwrite('/package.json', JSON.stringify({}, null, 2));
716+
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
717+
const content = tree.readContent('/package.json');
718+
const pkg = JSON.parse(content);
719+
expect(pkg.devDependencies['@angular-devkit/build-angular'])
720+
.toBe(latestVersions.DevkitBuildAngular);
695721
});
696722
});
697723

@@ -702,7 +728,7 @@ describe('Migration to v6', () => {
702728
beforeEach(() => {
703729
tslintConfig = {
704730
rules: {
705-
'import-blacklist': ['rxjs'],
731+
'import-blacklist': ['some', 'rxjs', 'else'],
706732
},
707733
};
708734
});
@@ -712,8 +738,34 @@ describe('Migration to v6', () => {
712738
tree.create(tslintPath, JSON.stringify(tslintConfig, null, 2));
713739
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
714740
const tslint = JSON.parse(tree.readContent(tslintPath));
715-
const blacklist = tslint.rules['import-blacklist'];
716-
expect(blacklist).toEqual([]);
741+
expect(tslint.rules['import-blacklist']).toEqual(['some', 'else']);
742+
});
743+
744+
it('should remove "rxjs" from the "import-blacklist" rule (only)', () => {
745+
tree.create(oldConfigPath, JSON.stringify(baseConfig, null, 2));
746+
tslintConfig.rules['import-blacklist'] = ['rxjs'];
747+
tree.create(tslintPath, JSON.stringify(tslintConfig, null, 2));
748+
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
749+
const tslint = JSON.parse(tree.readContent(tslintPath));
750+
expect(tslint.rules['import-blacklist']).toEqual([]);
751+
});
752+
753+
it('should remove "rxjs" from the "import-blacklist" rule (first)', () => {
754+
tree.create(oldConfigPath, JSON.stringify(baseConfig, null, 2));
755+
tslintConfig.rules['import-blacklist'] = ['rxjs', 'else'];
756+
tree.create(tslintPath, JSON.stringify(tslintConfig, null, 2));
757+
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
758+
const tslint = JSON.parse(tree.readContent(tslintPath));
759+
expect(tslint.rules['import-blacklist']).toEqual(['else']);
760+
});
761+
762+
it('should remove "rxjs" from the "import-blacklist" rule (last)', () => {
763+
tree.create(oldConfigPath, JSON.stringify(baseConfig, null, 2));
764+
tslintConfig.rules['import-blacklist'] = ['some', 'rxjs'];
765+
tree.create(tslintPath, JSON.stringify(tslintConfig, null, 2));
766+
tree = schematicRunner.runSchematic('migration-01', defaultOptions, tree);
767+
const tslint = JSON.parse(tree.readContent(tslintPath));
768+
expect(tslint.rules['import-blacklist']).toEqual(['some']);
717769
});
718770

719771
it('should work if "rxjs" is not in the "import-blacklist" rule', () => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import { JsonAstArray, JsonAstNode, JsonAstObject, JsonValue } from '@angular-devkit/core';
9+
import { UpdateRecorder } from '@angular-devkit/schematics';
10+
11+
export function appendPropertyInAstObject(
12+
recorder: UpdateRecorder,
13+
node: JsonAstObject,
14+
propertyName: string,
15+
value: JsonValue,
16+
indent = 4,
17+
) {
18+
const indentStr = '\n' + new Array(indent + 1).join(' ');
19+
20+
if (node.properties.length > 0) {
21+
// Insert comma.
22+
const last = node.properties[node.properties.length - 1];
23+
recorder.insertRight(last.start.offset + last.text.replace(/\s+$/, '').length, ',');
24+
}
25+
26+
recorder.insertLeft(
27+
node.end.offset - 1,
28+
' '
29+
+ `"${propertyName}": ${JSON.stringify(value, null, 2).replace(/\n/g, indentStr)}`
30+
+ indentStr.slice(0, -2),
31+
);
32+
}
33+
34+
35+
export function appendValueInAstArray(
36+
recorder: UpdateRecorder,
37+
node: JsonAstArray,
38+
value: JsonValue,
39+
indent = 4,
40+
) {
41+
const indentStr = '\n' + new Array(indent + 1).join(' ');
42+
43+
if (node.elements.length > 0) {
44+
// Insert comma.
45+
const last = node.elements[node.elements.length - 1];
46+
recorder.insertRight(last.start.offset + last.text.replace(/\s+$/, '').length, ',');
47+
}
48+
49+
recorder.insertLeft(
50+
node.end.offset - 1,
51+
' '
52+
+ JSON.stringify(value, null, 2).replace(/\n/g, indentStr)
53+
+ indentStr.slice(0, -2),
54+
);
55+
}
56+
57+
58+
export function findPropertyInAstObject(
59+
node: JsonAstObject,
60+
propertyName: string,
61+
): JsonAstNode | null {
62+
let maybeNode: JsonAstNode | null = null;
63+
for (const property of node.properties) {
64+
if (property.key.value == propertyName) {
65+
maybeNode = property.value;
66+
}
67+
}
68+
69+
return maybeNode;
70+
}

0 commit comments

Comments
 (0)