Skip to content

Commit ce323b5

Browse files
committed
refactor: ensure paths type safely passed around in ng-update
Schematics using the TypeScript compiler API have a significant issue with paths. The problem is that the TypeScript compiler API intends to rely fully on absolute paths, while ng-update and the devkit tree intend to rely on project-scoped/relative paths. This is problematic as it is straightforward to mix paths up, leading to unexpected errors. For example, computing a relative path between a devkit path and an absolute disk path results in a non-existent path. To fix this ambiguity, we improve type safety of paths by using branded primitives such as the `@angular-devkit/core` `Path` or an even more strict type called `WorkspacePath`. A normal string is considered an unresolved path and cannot be assigned to a `WorkspacePath`. Similarly a `WorkspacePath` cannot be assigned to a `string`. This ensures that resolved paths are not accidentally mixed with unresolved paths, and that we can safely pass around unresolved and resolved paths without loss of their semantics. This supposedly fixes #19270, where a devkit path and an absolute disk path result in an incorrectly resolved resource. In CLI apps it's possible to reference resources through absolute project-scoped paths. e.g. `/src/app/app.component.html`
1 parent e408228 commit ce323b5

22 files changed

+185
-95
lines changed

src/cdk/schematics/ng-update/devkit-file-system.ts

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,45 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {normalize} from '@angular-devkit/core';
9+
import {normalize, Path, relative} from '@angular-devkit/core';
1010
import {Tree, UpdateRecorder} from '@angular-devkit/schematics';
11-
import {relative} from 'path';
11+
import * as path from 'path';
1212
import {FileSystem} from '../update-tool/file-system';
1313

1414
/**
1515
* File system that leverages the virtual tree from the CLI devkit. This file
1616
* system is commonly used by `ng update` migrations that run as part of the
1717
* Angular CLI.
1818
*/
19-
export class DevkitFileSystem implements FileSystem {
19+
export class DevkitFileSystem extends FileSystem<Path> {
2020
private _updateRecorderCache = new Map<string, UpdateRecorder>();
21+
private _workspaceFsPath: Path;
2122

22-
constructor(private _tree: Tree, private _workspaceFsPath: string) {}
23+
constructor(private _tree: Tree, workspaceFsPath: string) {
24+
super();
25+
this._workspaceFsPath = normalize(workspaceFsPath);
26+
}
2327

24-
resolve(fsFilePath: string) {
25-
return normalize(relative(this._workspaceFsPath, fsFilePath)) as string;
28+
resolve(...segments: string[]): Path {
29+
// Note: We use `posix.resolve` as the devkit paths are using posix separators.
30+
const resolvedPath = normalize(path.posix.resolve(...segments.map(normalize)));
31+
// If the resolved path points to the workspace root, then this is an absolute disk
32+
// path and we need to compute a devkit tree relative path.
33+
if (resolvedPath.startsWith(this._workspaceFsPath)) {
34+
return relative(this._workspaceFsPath, resolvedPath);
35+
}
36+
// Otherwise we know that the path is absolute (due to the resolve), and that it
37+
// refers to an absolute devkit tree path (like `/angular.json`). We keep those
38+
// unmodified as they are already resolved workspace paths.
39+
return resolvedPath;
2640
}
2741

28-
edit(fsFilePath: string) {
29-
const treeFilePath = this.resolve(fsFilePath);
30-
if (this._updateRecorderCache.has(treeFilePath)) {
31-
return this._updateRecorderCache.get(treeFilePath)!;
42+
edit(filePath: Path) {
43+
if (this._updateRecorderCache.has(filePath)) {
44+
return this._updateRecorderCache.get(filePath)!;
3245
}
33-
const recorder = this._tree.beginUpdate(treeFilePath);
34-
this._updateRecorderCache.set(treeFilePath, recorder);
46+
const recorder = this._tree.beginUpdate(filePath);
47+
this._updateRecorderCache.set(filePath, recorder);
3548
return recorder;
3649
}
3750

@@ -40,24 +53,24 @@ export class DevkitFileSystem implements FileSystem {
4053
this._updateRecorderCache.clear();
4154
}
4255

43-
exists(fsFilePath: string) {
44-
return this._tree.exists(this.resolve(fsFilePath));
56+
exists(filePath: Path) {
57+
return this._tree.exists(filePath);
4558
}
4659

47-
overwrite(fsFilePath: string, content: string) {
48-
this._tree.overwrite(this.resolve(fsFilePath), content);
60+
overwrite(filePath: Path, content: string) {
61+
this._tree.overwrite(filePath, content);
4962
}
5063

51-
create(fsFilePath: string, content: string) {
52-
this._tree.create(this.resolve(fsFilePath), content);
64+
create(filePath: Path, content: string) {
65+
this._tree.create(filePath, content);
5366
}
5467

55-
delete(fsFilePath: string) {
56-
this._tree.delete(this.resolve(fsFilePath));
68+
delete(filePath: Path) {
69+
this._tree.delete(filePath);
5770
}
5871

59-
read(fsFilePath: string) {
60-
const buffer = this._tree.read(this.resolve(fsFilePath));
72+
read(filePath: Path) {
73+
const buffer = this._tree.read(filePath);
6174
return buffer !== null ? buffer.toString() : null;
6275
}
6376
}

src/cdk/schematics/ng-update/devkit-migration-rule.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {join} from 'path';
1515
import {UpdateProject} from '../update-tool';
1616
import {MigrationCtor} from '../update-tool/migration';
1717
import {TargetVersion} from '../update-tool/target-version';
18+
import {WorkspacePath} from '../update-tool/file-system';
1819
import {getTargetTsconfigPath, getWorkspaceConfigGracefully} from '../utils/project-tsconfig-paths';
1920

2021
import {DevkitFileSystem} from './devkit-file-system';
@@ -72,7 +73,7 @@ export function createMigrationSchematicRule(
7273
// Keep track of all project source files which have been checked/migrated. This is
7374
// necessary because multiple TypeScript projects can contain the same source file and
7475
// we don't want to check these again, as this would result in duplicated failure messages.
75-
const analyzedFiles = new Set<string>();
76+
const analyzedFiles = new Set<WorkspacePath>();
7677
// The CLI uses the working directory as the base directory for the virtual file system tree.
7778
const workspaceFsPath = process.cwd();
7879
const fileSystem = new DevkitFileSystem(tree, workspaceFsPath);

src/cdk/schematics/ng-update/migrations/attribute-selectors.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import * as ts from 'typescript';
10+
import {WorkspacePath} from '../../update-tool/file-system';
1011
import {ResolvedResource} from '../../update-tool/component-resource-collector';
1112
import {Migration} from '../../update-tool/migration';
1213
import {AttributeSelectorUpgradeData} from '../data/attribute-selectors';
@@ -63,11 +64,13 @@ export class AttributeSelectorsMigration extends Migration<UpgradeData> {
6364
this.data.forEach(selector => {
6465
findAllSubstringIndices(literalText, selector.replace)
6566
.map(offset => literal.getStart() + offset)
66-
.forEach(start => this._replaceSelector(filePath, start, selector));
67+
.forEach(start => this._replaceSelector(
68+
this.fileSystem.resolve(filePath), start, selector));
6769
});
6870
}
6971

70-
private _replaceSelector(filePath: string, start: number, data: AttributeSelectorUpgradeData) {
72+
private _replaceSelector(filePath: WorkspacePath, start: number,
73+
data: AttributeSelectorUpgradeData) {
7174
this.fileSystem.edit(filePath)
7275
.remove(start, data.replace.length)
7376
.insertRight(start, data.replaceWith);

src/cdk/schematics/ng-update/migrations/class-names.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@ export class ClassNamesMigration extends Migration<UpgradeData> {
9797
/** Creates a failure and replacement for the specified identifier. */
9898
private _createFailureWithReplacement(identifier: ts.Identifier) {
9999
const classData = this.data.find(data => data.replace === identifier.text)!;
100+
const filePath = this.fileSystem.resolve(identifier.getSourceFile().fileName);
100101

101-
this.fileSystem.edit(identifier.getSourceFile().fileName)
102+
this.fileSystem.edit(filePath)
102103
.remove(identifier.getStart(), identifier.getWidth())
103104
.insertRight(identifier.getStart(), classData.replaceWith);
104105
}

src/cdk/schematics/ng-update/migrations/css-selectors.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import * as ts from 'typescript';
10+
import {WorkspacePath} from '../../update-tool/file-system';
1011
import {ResolvedResource} from '../../update-tool/component-resource-collector';
1112
import {Migration} from '../../update-tool/migration';
1213
import {CssSelectorUpgradeData} from '../data/css-selectors';
@@ -69,11 +70,11 @@ export class CssSelectorsMigration extends Migration<UpgradeData> {
6970

7071
findAllSubstringIndices(textContent, data.replace)
7172
.map(offset => node.getStart() + offset)
72-
.forEach(start => this._replaceSelector(filePath, start, data));
73+
.forEach(start => this._replaceSelector(this.fileSystem.resolve(filePath), start, data));
7374
});
7475
}
7576

76-
private _replaceSelector(filePath: string, start: number, data: CssSelectorUpgradeData) {
77+
private _replaceSelector(filePath: WorkspacePath, start: number, data: CssSelectorUpgradeData) {
7778
this.fileSystem.edit(filePath)
7879
.remove(start, data.replace.length)
7980
.insertRight(start, data.replaceWith);

src/cdk/schematics/ng-update/migrations/element-selectors.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import * as ts from 'typescript';
1010
import {ResolvedResource} from '../../update-tool/component-resource-collector';
11+
import {WorkspacePath} from '../../update-tool/file-system';
1112
import {Migration} from '../../update-tool/migration';
1213
import {ElementSelectorUpgradeData} from '../data/element-selectors';
1314
import {findAllSubstringIndices} from '../typescript/literal';
@@ -57,11 +58,13 @@ export class ElementSelectorsMigration extends Migration<UpgradeData> {
5758
this.data.forEach(selector => {
5859
findAllSubstringIndices(textContent, selector.replace)
5960
.map(offset => node.getStart() + offset)
60-
.forEach(start => this._replaceSelector(filePath, start, selector));
61+
.forEach(start => this._replaceSelector(
62+
this.fileSystem.resolve(filePath), start, selector));
6163
});
6264
}
6365

64-
private _replaceSelector(filePath: string, start: number, data: ElementSelectorUpgradeData) {
66+
private _replaceSelector(filePath: WorkspacePath, start: number,
67+
data: ElementSelectorUpgradeData) {
6568
this.fileSystem.edit(filePath)
6669
.remove(start, data.replace.length)
6770
.insertRight(start, data.replaceWith);

src/cdk/schematics/ng-update/migrations/input-names.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {WorkspacePath} from '../../update-tool/file-system';
910
import {findInputsOnElementWithAttr, findInputsOnElementWithTag} from '../html-parsing/angular';
1011
import {ResolvedResource} from '../../update-tool/component-resource-collector';
1112
import {Migration} from '../../update-tool/migration';
@@ -63,7 +64,8 @@ export class InputNamesMigration extends Migration<UpgradeData> {
6364
});
6465
}
6566

66-
private _replaceInputName(filePath: string, start: number, width: number, newName: string) {
67+
private _replaceInputName(filePath: WorkspacePath, start: number, width: number,
68+
newName: string) {
6769
this.fileSystem.edit(filePath)
6870
.remove(start, width)
6971
.insertRight(start, newName);

src/cdk/schematics/ng-update/migrations/output-names.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import {WorkspacePath} from '../../update-tool/file-system';
910
import {ResolvedResource} from '../../update-tool/component-resource-collector';
1011
import {Migration} from '../../update-tool/migration';
1112

@@ -49,7 +50,8 @@ export class OutputNamesMigration extends Migration<UpgradeData> {
4950
});
5051
}
5152

52-
private _replaceOutputName(filePath: string, start: number, width: number, newName: string) {
53+
private _replaceOutputName(filePath: WorkspacePath, start: number, width: number,
54+
newName: string) {
5355
this.fileSystem.edit(filePath)
5456
.remove(start, width)
5557
.insertRight(start, newName);

src/cdk/schematics/ng-update/migrations/property-names.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export class PropertyNamesMigration extends Migration<UpgradeData> {
3939
}
4040

4141
if (!data.whitelist || data.whitelist.classes.includes(typeName)) {
42-
this.fileSystem.edit(node.getSourceFile().fileName)
42+
this.fileSystem.edit(this.fileSystem.resolve(node.getSourceFile().fileName))
4343
.remove(node.name.getStart(), node.name.getWidth())
4444
.insertRight(node.name.getStart(), data.replaceWith);
4545
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {resolveBazelPath} from '@angular/cdk/schematics/testing';
2+
import {MIGRATION_PATH} from '../../../index.spec';
3+
import {createTestCaseSetup} from '../../../testing';
4+
5+
describe('ng-update external resource resolution', () => {
6+
7+
it('should properly resolve referenced resources in components', async () => {
8+
const {runFixers, writeFile, removeTempDir, appTree} = await createTestCaseSetup(
9+
'migration-v6', MIGRATION_PATH,
10+
[resolveBazelPath(__dirname, './external-resource-resolution_input.ts')]);
11+
12+
const testContent = `<div cdk-connected-overlay [origin]="test"></div>`;
13+
const expected = `<div cdk-connected-overlay [cdkConnectedOverlayOrigin]="test"></div>`;
14+
15+
writeFile('/projects/material/test.html', testContent);
16+
writeFile('/projects/cdk-testing/src/some-tmpl.html', testContent);
17+
writeFile('/projects/cdk-testing/src/test-cases/local.html', testContent);
18+
19+
await runFixers();
20+
21+
expect(appTree.readContent('/projects/material/test.html'))
22+
.toBe(expected, 'Expected absolute devkit tree paths to work.');
23+
expect(appTree.readContent('/projects/cdk-testing/src/some-tmpl.html'))
24+
.toBe(expected, 'Expected relative paths with parent segments to work.');
25+
expect(appTree.readContent('/projects/cdk-testing/src/test-cases/local.html'))
26+
.toBe(expected, 'Expected relative paths without explicit dot to work.');
27+
28+
removeTempDir();
29+
});
30+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {Component} from '@angular/core';
2+
3+
@Component({
4+
selector: 'test-cmp',
5+
templateUrl: '/projects/material/test.html',
6+
})
7+
export class MyTestComp {}
8+
9+
@Component({
10+
selector: 'test-cmp2',
11+
templateUrl: '../some-tmpl.html',
12+
})
13+
export class MyTestComp2 {}
14+
15+
@Component({
16+
selector: 'test-cmp3',
17+
templateUrl: 'local.html',
18+
})
19+
export class MyTestComp3 {}

src/cdk/schematics/testing/test-case-setup.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {getSystemPath, normalize} from '@angular-devkit/core';
9+
import {getSystemPath, normalize, Path} from '@angular-devkit/core';
1010
import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing';
1111
import * as virtualFs from '@angular-devkit/core/src/virtual-fs/host';
1212
import {HostTree, Tree} from '@angular-devkit/schematics';
@@ -224,13 +224,13 @@ export function defineJasmineTestCases(versionName: string, collectionFile: stri
224224
*/
225225
export function _patchTypeScriptDefaultLib(tree: Tree) {
226226
const _originalRead = tree.read;
227-
tree.read = function(filePath: string) {
227+
tree.read = function(filePath: Path) {
228228
// In case a file within the TypeScript package is requested, we read the file from
229229
// the real file system. This is necessary because within unit tests, the "typeScript"
230230
// package from within the Bazel "@npm" repository is used. The virtual tree can't be
231231
// used because the "@npm" repository directory is not part of the virtual file system.
232232
if (filePath.match(/node_modules[/\\]typescript/)) {
233-
return readFileSync(filePath);
233+
return readFileSync(getSystemPath(filePath));
234234
} else {
235235
return _originalRead.apply(this, arguments);
236236
}

src/cdk/schematics/update-tool/component-resource-collector.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {dirname, resolve} from 'path';
9+
import {dirname} from 'path';
1010
import * as ts from 'typescript';
11-
import {FileSystem} from './file-system';
11+
import {FileSystem, WorkspacePath} from './file-system';
1212
import {getAngularDecorators} from './utils/decorators';
1313
import {unwrapExpression} from './utils/functions';
1414
import {
@@ -28,7 +28,7 @@ export interface ResolvedResource {
2828
/** Whether the given resource is inline or not. */
2929
inline: boolean;
3030
/** Path to the file that contains this resource. */
31-
filePath: string;
31+
filePath: WorkspacePath;
3232
/**
3333
* Gets the character and line of a given position index in the resource.
3434
* If the resource is declared inline within a TypeScript source file, the line and
@@ -81,7 +81,8 @@ export class ComponentResourceCollector {
8181
}
8282

8383
const sourceFile = node.getSourceFile();
84-
const sourceFileName = sourceFile.fileName;
84+
const filePath = this._fileSystem.resolve(sourceFile.fileName);
85+
const sourceFileDirPath = dirname(sourceFile.fileName);
8586

8687
// Walk through all component metadata properties and determine the referenced
8788
// HTML templates (either external or inline)
@@ -91,7 +92,6 @@ export class ComponentResourceCollector {
9192
}
9293

9394
const propertyName = getPropertyNameText(property.name);
94-
const filePath = resolve(sourceFileName);
9595

9696
if (propertyName === 'styles' && ts.isArrayLiteralExpression(property.initializer)) {
9797
property.initializer.elements.forEach(el => {
@@ -100,7 +100,7 @@ export class ComponentResourceCollector {
100100
// not part of the template content.
101101
const templateStartIdx = el.getStart() + 1;
102102
this.resolvedStylesheets.push({
103-
filePath: filePath,
103+
filePath,
104104
container: node,
105105
content: el.text,
106106
inline: true,
@@ -119,7 +119,7 @@ export class ComponentResourceCollector {
119119
// not part of the template content.
120120
const templateStartIdx = property.initializer.getStart() + 1;
121121
this.resolvedTemplates.push({
122-
filePath: filePath,
122+
filePath,
123123
container: node,
124124
content: property.initializer.text,
125125
inline: true,
@@ -132,7 +132,7 @@ export class ComponentResourceCollector {
132132
if (propertyName === 'styleUrls' && ts.isArrayLiteralExpression(property.initializer)) {
133133
property.initializer.elements.forEach(el => {
134134
if (ts.isStringLiteralLike(el)) {
135-
const stylesheetPath = resolve(dirname(sourceFileName), el.text);
135+
const stylesheetPath = this._fileSystem.resolve(sourceFileDirPath, el.text);
136136

137137
// In case the stylesheet does not exist in the file system, skip it gracefully.
138138
if (!this._fileSystem.exists(stylesheetPath)) {
@@ -145,7 +145,8 @@ export class ComponentResourceCollector {
145145
}
146146

147147
if (propertyName === 'templateUrl' && ts.isStringLiteralLike(property.initializer)) {
148-
const templatePath = resolve(dirname(sourceFileName), property.initializer.text);
148+
const templateUrl = property.initializer.text;
149+
const templatePath = this._fileSystem.resolve(sourceFileDirPath, templateUrl);
149150

150151
// In case the template does not exist in the file system, skip this
151152
// external template.
@@ -169,7 +170,7 @@ export class ComponentResourceCollector {
169170
}
170171

171172
/** Resolves an external stylesheet by reading its content and computing line mappings. */
172-
resolveExternalStylesheet(filePath: string, container: ts.ClassDeclaration|null):
173+
resolveExternalStylesheet(filePath: WorkspacePath, container: ts.ClassDeclaration|null):
173174
ResolvedResource {
174175
const fileContent = this._fileSystem.read(filePath);
175176
const lineStartsMap = computeLineStartsMap(fileContent);

0 commit comments

Comments
 (0)