Skip to content

Commit 367786e

Browse files
committed
fix(@schematics/angular): use the JSON AST for changing JSON files in migration
Fix angular/angular-cli#10396
1 parent 42b5d16 commit 367786e

File tree

3 files changed

+248
-38
lines changed

3 files changed

+248
-38
lines changed

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

+121-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,90 @@ 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+
appendPropertyInAstObject(recorder, tsCfgAst, 'files', [polyfills]);
581+
} else {
582+
if (filesAstNode.value.indexOf(polyfills) == -1) {
583+
appendValueInAstArray(recorder, filesAstNode, polyfills);
584+
}
585+
}
586+
587+
host.commitUpdate(recorder);
561588
});
562589
};
563590
}
564591

565-
function updatePackageJson(packageManager?: string) {
592+
function updatePackageJson(config: CliConfig) {
566593
return (host: Tree, context: SchematicContext) => {
567594
const pkgPath = '/package.json';
568595
const buffer = host.read(pkgPath);
569596
if (buffer == null) {
570597
throw new SchematicsException('Could not read package.json');
571598
}
572-
const content = buffer.toString();
573-
const pkg = JSON.parse(content);
599+
const pkgAst = parseJsonAst(buffer.toString(), JsonParseMode.Strict);
574600

575-
if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
601+
if (pkgAst.kind != 'object') {
576602
throw new SchematicsException('Error reading package.json');
577603
}
578-
if (!pkg.devDependencies) {
579-
pkg.devDependencies = {};
604+
605+
const devDependenciesNode = findPropertyInAstObject(pkgAst, 'devDependencies');
606+
if (devDependenciesNode && devDependenciesNode.kind != 'object') {
607+
throw new SchematicsException('Error reading package.json; devDependency is not an object.');
580608
}
581609

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

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

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

591642
return host;
592643
};
@@ -597,19 +648,52 @@ function updateTsLintConfig(): Rule {
597648
const tsLintPath = '/tslint.json';
598649
const buffer = host.read(tsLintPath);
599650
if (!buffer) {
600-
return;
651+
return host;
601652
}
602-
const tsCfg = JSON.parse(buffer.toString());
653+
const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose);
603654

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

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

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

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

632720
return chain([
633721
migrateKarmaConfiguration(config),
634722
migrateConfiguration(config),
635723
updateSpecTsConfig(config),
636-
updatePackageJson(config.packageManager),
724+
updatePackageJson(config),
637725
updateTsLintConfig(),
638726
(host: Tree, context: SchematicContext) => {
639727
context.logger.warn(tags.oneLine`Some configuration options have been changed,

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

+57-5
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', () => {
@@ -685,13 +686,38 @@ describe('Migration to v6', () => {
685686
});
686687
});
687688

688-
describe('package.json', () => {
689+
fdescribe('package.json', () => {
689690
it('should add a dev dependency to @angular-devkit/build-angular', () => {
690691
tree.create(oldConfigPath, JSON.stringify(baseConfig, null, 2));
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)