Hans Larsen 483cbe2665 refactor: add tslint-sonarts and a lot of tslint rules
This should clean up the code a bit.

Note: at first I added the no-useless-cast rule, but after getting frustrated
with it (as it has many false positive), I decided to remove the rule but some
useless casts were removed so I let those in the PR.
2018-07-10 15:07:36 -07:00

819 lines
26 KiB
TypeScript

/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {
JsonArray,
JsonObject,
JsonParseMode,
Path,
join,
logging,
normalize,
parseJson,
parseJsonAst,
tags,
} from '@angular-devkit/core';
import {
Rule,
SchematicContext,
SchematicsException,
Tree,
chain,
} from '@angular-devkit/schematics';
import { NodePackageInstallTask } from '@angular-devkit/schematics/tasks';
import { AppConfig, CliConfig } from '../../utility/config';
import {
NodeDependency,
NodeDependencyType,
addPackageJsonDependency,
} from '../../utility/dependencies';
import {
appendValueInAstArray,
findPropertyInAstObject,
} from '../../utility/json-utils';
import { latestVersions } from '../../utility/latest-versions';
const defaults = {
appRoot: 'src',
index: 'index.html',
main: 'main.ts',
polyfills: 'polyfills.ts',
tsConfig: 'tsconfig.app.json',
test: 'test.ts',
outDir: 'dist/',
karma: 'karma.conf.js',
protractor: 'protractor.conf.js',
testTsConfig: 'tsconfig.spec.json',
serverOutDir: 'dist-server',
serverMain: 'main.server.ts',
serverTsConfig: 'tsconfig.server.json',
};
function getConfigPath(tree: Tree): Path {
let possiblePath = normalize('.angular-cli.json');
if (tree.exists(possiblePath)) {
return possiblePath;
}
possiblePath = normalize('angular-cli.json');
if (tree.exists(possiblePath)) {
return possiblePath;
}
throw new SchematicsException('Could not find configuration file');
}
function migrateKarmaConfiguration(config: CliConfig): Rule {
return (host: Tree, context: SchematicContext) => {
context.logger.info(`Updating karma configuration`);
try {
const karmaPath = config && config.test && config.test.karma && config.test.karma.config
? config.test.karma.config
: defaults.karma;
const buffer = host.read(karmaPath);
if (buffer !== null) {
let content = buffer.toString();
// Replace the 1.0 files and preprocessor entries, with and without comma at the end.
// If these remain, they will cause the `ng test` to fail.
content = content.replace(`{ pattern: './src/test.ts', watched: false },`, '');
content = content.replace(`{ pattern: './src/test.ts', watched: false }`, '');
content = content.replace(`'./src/test.ts': ['@angular/cli'],`, '');
content = content.replace(`'./src/test.ts': ['@angular/cli']`, '');
content = content.replace(/angularCli[^}]*},?/, '');
// Replace 1.x plugin names.
content = content.replace(/@angular\/cli/g, '@angular-devkit/build-angular');
// Replace code coverage output path.
content = content.replace('reports',
`dir: require('path').join(__dirname, 'coverage'), reports`);
host.overwrite(karmaPath, content);
}
} catch { }
return host;
};
}
function migrateConfiguration(oldConfig: CliConfig, logger: logging.LoggerApi): Rule {
return (host: Tree, context: SchematicContext) => {
const oldConfigPath = getConfigPath(host);
const configPath = normalize('angular.json');
context.logger.info(`Updating configuration`);
const config: JsonObject = {
'$schema': './node_modules/@angular/cli/lib/config/schema.json',
version: 1,
newProjectRoot: 'projects',
projects: extractProjectsConfig(oldConfig, host, logger),
};
const defaultProject = extractDefaultProject(oldConfig);
if (defaultProject !== null) {
config.defaultProject = defaultProject;
}
const cliConfig = extractCliConfig(oldConfig);
if (cliConfig !== null) {
config.cli = cliConfig;
}
const schematicsConfig = extractSchematicsConfig(oldConfig);
if (schematicsConfig !== null) {
config.schematics = schematicsConfig;
}
const architectConfig = extractArchitectConfig(oldConfig);
if (architectConfig !== null) {
config.architect = architectConfig;
}
context.logger.info(`Removing old config file (${oldConfigPath})`);
host.delete(oldConfigPath);
context.logger.info(`Writing config file (${configPath})`);
host.create(configPath, JSON.stringify(config, null, 2));
return host;
};
}
function extractCliConfig(config: CliConfig): JsonObject | null {
const newConfig: JsonObject = {};
if (config.packageManager && config.packageManager !== 'default') {
newConfig['packageManager'] = config.packageManager;
}
if (config.warnings) {
if (config.warnings.versionMismatch !== undefined) {
newConfig.warnings = {
...((newConfig.warnings as JsonObject | null) || {}),
...{ versionMismatch: config.warnings.versionMismatch },
};
}
if (config.warnings.typescriptMismatch !== undefined) {
newConfig.warnings = {
...((newConfig.warnings as JsonObject | null) || {}),
...{ typescriptMismatch: config.warnings.typescriptMismatch },
};
}
}
return Object.getOwnPropertyNames(newConfig).length == 0 ? null : newConfig;
}
function extractSchematicsConfig(config: CliConfig): JsonObject | null {
let collectionName = '@schematics/angular';
if (!config || !config.defaults) {
return null;
}
// const configDefaults = config.defaults;
if (config.defaults && config.defaults.schematics && config.defaults.schematics.collection) {
collectionName = config.defaults.schematics.collection;
}
/**
* For each schematic
* - get the config
* - filter one's without config
* - combine them into an object
*/
// tslint:disable-next-line:no-any
const schematicConfigs: any = ['class', 'component', 'directive', 'guard',
'interface', 'module', 'pipe', 'service']
.map(schematicName => {
// tslint:disable-next-line:no-any
const schematicDefaults: JsonObject = (config.defaults as any)[schematicName] || null;
return {
schematicName,
config: schematicDefaults,
};
})
.filter(schematic => schematic.config !== null)
.reduce((all: JsonObject, schematic) => {
all[collectionName + ':' + schematic.schematicName] = schematic.config;
return all;
}, {});
const componentUpdate: JsonObject = {};
componentUpdate.prefix = '';
const componentKey = collectionName + ':component';
const directiveKey = collectionName + ':directive';
if (!schematicConfigs[componentKey]) {
schematicConfigs[componentKey] = {};
}
if (!schematicConfigs[directiveKey]) {
schematicConfigs[directiveKey] = {};
}
if (config.apps && config.apps[0]) {
schematicConfigs[componentKey].prefix = config.apps[0].prefix;
schematicConfigs[directiveKey].prefix = config.apps[0].prefix;
}
if (config.defaults) {
schematicConfigs[componentKey].styleext = config.defaults.styleExt;
}
return schematicConfigs;
}
function extractArchitectConfig(_config: CliConfig): JsonObject | null {
return null;
}
// This function is too big, but also really hard to refactor properly as the whole config
// depends on all parts of the config.
// tslint:disable-next-line:no-big-function
function extractProjectsConfig(
config: CliConfig, tree: Tree, logger: logging.LoggerApi,
): JsonObject {
const builderPackage = '@angular-devkit/build-angular';
const defaultAppNamePrefix = getDefaultAppNamePrefix(config);
const buildDefaults: JsonObject = config.defaults && config.defaults.build
? {
sourceMap: config.defaults.build.sourcemaps,
progress: config.defaults.build.progress,
poll: config.defaults.build.poll,
deleteOutputPath: config.defaults.build.deleteOutputPath,
preserveSymlinks: config.defaults.build.preserveSymlinks,
showCircularDependencies: config.defaults.build.showCircularDependencies,
commonChunk: config.defaults.build.commonChunk,
namedChunks: config.defaults.build.namedChunks,
} as JsonObject
: {};
const serveDefaults: JsonObject = config.defaults && config.defaults.serve
? {
port: config.defaults.serve.port,
host: config.defaults.serve.host,
ssl: config.defaults.serve.ssl,
sslKey: config.defaults.serve.sslKey,
sslCert: config.defaults.serve.sslCert,
proxyConfig: config.defaults.serve.proxyConfig,
} as JsonObject
: {};
const apps = config.apps || [];
// convert the apps to projects
const browserApps = apps.filter(app => app.platform !== 'server');
const serverApps = apps.filter(app => app.platform === 'server');
const projectMap = browserApps
// This function is too big, but also really hard to refactor properly as the whole config
// depends on all parts of the config.
// tslint:disable-next-line:no-big-function
.map((app, idx) => {
const defaultAppName = idx === 0 ? defaultAppNamePrefix : `${defaultAppNamePrefix}${idx}`;
const name = app.name || defaultAppName;
const outDir = app.outDir || defaults.outDir;
const appRoot = app.root || defaults.appRoot;
function _mapAssets(asset: string | JsonObject) {
if (typeof asset === 'string') {
return normalize(appRoot + '/' + asset);
} else {
if (asset.allowOutsideOutDir) {
logger.warn(tags.oneLine`
Asset with input '${asset.input}' was not migrated because it
uses the 'allowOutsideOutDir' option which is not supported in Angular CLI 6.
`);
return null;
} else if (asset.output) {
return {
glob: asset.glob,
input: normalize(appRoot + '/' + asset.input),
output: normalize('/' + asset.output as string),
};
} else {
return {
glob: asset.glob,
input: normalize(appRoot + '/' + asset.input),
output: '/',
};
}
}
}
function _buildConfigurations(): JsonObject {
const source = app.environmentSource;
const environments = app.environments;
const serviceWorker = app.serviceWorker;
const productionPartial = {
optimization: true,
outputHashing: 'all',
sourceMap: false,
extractCss: true,
namedChunks: false,
aot: true,
extractLicenses: true,
vendorChunk: false,
buildOptimizer: true,
...(serviceWorker ? {serviceWorker: true, ngswConfigPath: '/src/ngsw-config.json'} : {}),
...(app.budgets ? { budgets: app.budgets as JsonArray} : {}),
};
if (!environments) {
return { production: productionPartial };
}
const configurations = Object.keys(environments).reduce((acc, environment) => {
if (source === environments[environment]) {
return acc;
}
let isProduction = false;
const environmentContent = tree.read(app.root + '/' + environments[environment]);
if (environmentContent) {
isProduction = !!environmentContent.toString('utf-8')
// Allow for `production: true` or `production = true`. Best we can do to guess.
.match(/production['"]?\s*[:=]\s*true/);
}
let configurationName;
// We used to use `prod` by default as the key, instead we now use the full word.
// Try not to override the production key if it's there.
if (environment == 'prod' && !environments['production'] && isProduction) {
configurationName = 'production';
} else {
configurationName = environment;
}
acc[configurationName] = {
...(isProduction ? productionPartial : {}),
fileReplacements: [
{
replace: `${app.root}/${source}`,
with: `${app.root}/${environments[environment]}`,
},
],
};
return acc;
}, {} as JsonObject);
if (!configurations['production']) {
configurations['production'] = { ...productionPartial };
}
return configurations;
}
function _serveConfigurations(): JsonObject {
const environments = app.environments;
if (!environments) {
return {};
}
if (!architect) {
throw new Error();
}
const configurations = (architect.build as JsonObject).configurations as JsonObject;
return Object.keys(configurations).reduce((acc, environment) => {
acc[environment] = { browserTarget: `${name}:build:${environment}` };
return acc;
}, {} as JsonObject);
}
function _extraEntryMapper(extraEntry: string | JsonObject) {
let entry: string | JsonObject;
if (typeof extraEntry === 'string') {
entry = join(app.root as Path, extraEntry);
} else {
const input = join(app.root as Path, extraEntry.input as string || '');
entry = { input, lazy: extraEntry.lazy };
if (extraEntry.output) {
entry.bundleName = extraEntry.output;
}
}
return entry;
}
const projectRoot = join(normalize(appRoot), '..');
const project: JsonObject = {
root: projectRoot,
sourceRoot: appRoot,
projectType: 'application',
};
const architect: JsonObject = {};
project.architect = architect;
// Browser target
const buildOptions: JsonObject = {
// Make outputPath relative to root.
outputPath: outDir,
index: `${appRoot}/${app.index || defaults.index}`,
main: `${appRoot}/${app.main || defaults.main}`,
tsConfig: `${appRoot}/${app.tsconfig || defaults.tsConfig}`,
...(app.baseHref ? { baseHref: app.baseHref } : {}),
...buildDefaults,
};
if (app.polyfills) {
buildOptions.polyfills = appRoot + '/' + app.polyfills;
}
if (app.stylePreprocessorOptions
&& app.stylePreprocessorOptions.includePaths
&& Array.isArray(app.stylePreprocessorOptions.includePaths)
&& app.stylePreprocessorOptions.includePaths.length > 0) {
buildOptions.stylePreprocessorOptions = {
includePaths: app.stylePreprocessorOptions.includePaths
.map(includePath => join(app.root as Path, includePath)),
};
}
buildOptions.assets = (app.assets || []).map(_mapAssets).filter(x => !!x);
buildOptions.styles = (app.styles || []).map(_extraEntryMapper);
buildOptions.scripts = (app.scripts || []).map(_extraEntryMapper);
architect.build = {
builder: `${builderPackage}:browser`,
options: buildOptions,
configurations: _buildConfigurations(),
};
// Serve target
const serveOptions: JsonObject = {
browserTarget: `${name}:build`,
...serveDefaults,
};
architect.serve = {
builder: `${builderPackage}:dev-server`,
options: serveOptions,
configurations: _serveConfigurations(),
};
// Extract target
const extractI18nOptions: JsonObject = { browserTarget: `${name}:build` };
architect['extract-i18n'] = {
builder: `${builderPackage}:extract-i18n`,
options: extractI18nOptions,
};
const karmaConfig = config.test && config.test.karma
? config.test.karma.config || ''
: '';
// Test target
const testOptions: JsonObject = {
main: appRoot + '/' + app.test || defaults.test,
// Make karmaConfig relative to root.
karmaConfig,
};
if (app.polyfills) {
testOptions.polyfills = appRoot + '/' + app.polyfills;
}
if (app.testTsconfig) {
testOptions.tsConfig = appRoot + '/' + app.testTsconfig;
}
testOptions.scripts = (app.scripts || []).map(_extraEntryMapper);
testOptions.styles = (app.styles || []).map(_extraEntryMapper);
testOptions.assets = (app.assets || []).map(_mapAssets).filter(x => !!x);
if (karmaConfig) {
architect.test = {
builder: `${builderPackage}:karma`,
options: testOptions,
};
}
const tsConfigs: string[] = [];
const excludes: string[] = [];
let warnForLint = false;
if (config && config.lint && Array.isArray(config.lint)) {
config.lint.forEach(lint => {
if (lint.project) {
tsConfigs.push(lint.project);
} else {
warnForLint = true;
}
if (lint.exclude) {
if (typeof lint.exclude === 'string') {
excludes.push(lint.exclude);
} else {
lint.exclude.forEach(ex => excludes.push(ex));
}
}
});
}
if (warnForLint) {
logger.warn(`
Lint without 'project' was not migrated which is not supported in Angular CLI 6.
`);
}
const removeDupes = (items: string[]) => items.reduce((newItems, item) => {
if (newItems.indexOf(item) === -1) {
newItems.push(item);
}
return newItems;
}, <string[]> []);
// Tslint target
const lintOptions: JsonObject = {
tsConfig: removeDupes(tsConfigs).filter(t => t.indexOf('e2e') === -1),
exclude: removeDupes(excludes),
};
architect.lint = {
builder: `${builderPackage}:tslint`,
options: lintOptions,
};
// server target
const serverApp = serverApps
.filter(serverApp => app.root === serverApp.root && app.index === serverApp.index)[0];
if (serverApp) {
const serverOptions: JsonObject = {
outputPath: serverApp.outDir || defaults.serverOutDir,
main: serverApp.main || defaults.serverMain,
tsConfig: serverApp.tsconfig || defaults.serverTsConfig,
};
const serverTarget: JsonObject = {
builder: '@angular-devkit/build-angular:server',
options: serverOptions,
};
architect.server = serverTarget;
}
const e2eProject: JsonObject = {
root: join(projectRoot, 'e2e'),
sourceRoot: join(projectRoot, 'e2e'),
projectType: 'application',
};
const e2eArchitect: JsonObject = {};
// tslint:disable-next-line:max-line-length
const protractorConfig = config && config.e2e && config.e2e.protractor && config.e2e.protractor.config
? config.e2e.protractor.config
: '';
const e2eOptions: JsonObject = {
protractorConfig: protractorConfig,
devServerTarget: `${name}:serve`,
};
const e2eTarget: JsonObject = {
builder: `${builderPackage}:protractor`,
options: e2eOptions,
};
e2eArchitect.e2e = e2eTarget;
const e2eLintOptions: JsonObject = {
tsConfig: removeDupes(tsConfigs).filter(t => t.indexOf('e2e') !== -1),
exclude: removeDupes(excludes),
};
const e2eLintTarget: JsonObject = {
builder: `${builderPackage}:tslint`,
options: e2eLintOptions,
};
e2eArchitect.lint = e2eLintTarget;
if (protractorConfig) {
e2eProject.architect = e2eArchitect;
}
return { name, project, e2eProject };
})
.reduce((projects, mappedApp) => {
const {name, project, e2eProject} = mappedApp;
projects[name] = project;
projects[name + '-e2e'] = e2eProject;
return projects;
}, {} as JsonObject);
return projectMap;
}
function getDefaultAppNamePrefix(config: CliConfig) {
let defaultAppNamePrefix = 'app';
if (config.project && config.project.name) {
defaultAppNamePrefix = config.project.name;
}
return defaultAppNamePrefix;
}
function extractDefaultProject(config: CliConfig): string | null {
if (config.apps && config.apps[0]) {
const app = config.apps[0];
const defaultAppName = getDefaultAppNamePrefix(config);
const name = app.name || defaultAppName;
return name;
}
return null;
}
function updateSpecTsConfig(config: CliConfig): Rule {
return (host: Tree, context: SchematicContext) => {
const apps = config.apps || [];
apps.forEach((app: AppConfig, idx: number) => {
const testTsConfig = app.testTsconfig || defaults.testTsConfig;
const tsSpecConfigPath = join(normalize(app.root || ''), testTsConfig);
const buffer = host.read(tsSpecConfigPath);
if (!buffer) {
return;
}
const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose);
if (tsCfgAst.kind != 'object') {
throw new SchematicsException('Invalid tsconfig. Was expecting an object');
}
const filesAstNode = findPropertyInAstObject(tsCfgAst, 'files');
if (filesAstNode && filesAstNode.kind != 'array') {
throw new SchematicsException('Invalid tsconfig "files" property; expected an array.');
}
const recorder = host.beginUpdate(tsSpecConfigPath);
const polyfills = app.polyfills || defaults.polyfills;
if (!filesAstNode) {
// Do nothing if the files array does not exist. This means exclude or include are
// set and we shouldn't mess with that.
} else {
if (filesAstNode.value.indexOf(polyfills) == -1) {
appendValueInAstArray(recorder, filesAstNode, polyfills);
}
}
host.commitUpdate(recorder);
});
};
}
function updatePackageJson(config: CliConfig) {
return (host: Tree, context: SchematicContext) => {
const dependency: NodeDependency = {
type: NodeDependencyType.Dev,
name: '@angular-devkit/build-angular',
version: latestVersions.DevkitBuildAngular,
overwrite: true,
};
addPackageJsonDependency(host, dependency);
context.addTask(new NodePackageInstallTask({
packageManager: config.packageManager === 'default' ? undefined : config.packageManager,
}));
return host;
};
}
function updateTsLintConfig(): Rule {
return (host: Tree, context: SchematicContext) => {
const tsLintPath = '/tslint.json';
const buffer = host.read(tsLintPath);
if (!buffer) {
return host;
}
const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose);
if (tsCfgAst.kind != 'object') {
return host;
}
const rulesNode = findPropertyInAstObject(tsCfgAst, 'rules');
if (!rulesNode || rulesNode.kind != 'object') {
return host;
}
const importBlacklistNode = findPropertyInAstObject(rulesNode, 'import-blacklist');
if (!importBlacklistNode || importBlacklistNode.kind != 'array') {
return host;
}
const recorder = host.beginUpdate(tsLintPath);
for (let i = 0; i < importBlacklistNode.elements.length; i++) {
const element = importBlacklistNode.elements[i];
if (element.kind == 'string' && element.value == 'rxjs') {
const { start, end } = element;
// Remove this element.
if (i == importBlacklistNode.elements.length - 1) {
// Last element.
if (i > 0) {
// Not first, there's a comma to remove before.
const previous = importBlacklistNode.elements[i - 1];
recorder.remove(previous.end.offset, end.offset - previous.end.offset);
} else {
// Only element, just remove the whole rule.
const { start, end } = importBlacklistNode;
recorder.remove(start.offset, end.offset - start.offset);
recorder.insertLeft(start.offset, '[]');
}
} else {
// Middle, just remove the whole node (up to next node start).
const next = importBlacklistNode.elements[i + 1];
recorder.remove(start.offset, next.start.offset - start.offset);
}
}
}
host.commitUpdate(recorder);
return host;
};
}
function updateRootTsConfig(): Rule {
return (host: Tree, context: SchematicContext) => {
const tsConfigPath = '/tsconfig.json';
const buffer = host.read(tsConfigPath);
if (!buffer) {
return;
}
const tsCfgAst = parseJsonAst(buffer.toString(), JsonParseMode.Loose);
if (tsCfgAst.kind !== 'object') {
throw new SchematicsException('Invalid root tsconfig. Was expecting an object');
}
const compilerOptionsAstNode = findPropertyInAstObject(tsCfgAst, 'compilerOptions');
if (!compilerOptionsAstNode || compilerOptionsAstNode.kind != 'object') {
throw new SchematicsException(
'Invalid root tsconfig "compilerOptions" property; expected an object.',
);
}
if (
findPropertyInAstObject(compilerOptionsAstNode, 'baseUrl') &&
findPropertyInAstObject(compilerOptionsAstNode, 'module')
) {
return host;
}
const compilerOptions = compilerOptionsAstNode.value;
const { baseUrl = './', module = 'es2015'} = compilerOptions;
const validBaseUrl = ['./', '', '.'];
if (!validBaseUrl.includes(baseUrl as string)) {
const formattedBaseUrl = validBaseUrl.map(x => `'${x}'`).join(', ');
context.logger.warn(tags.oneLine
`Root tsconfig option 'baseUrl' is not one of: ${formattedBaseUrl}.
This might cause unexpected behaviour when generating libraries.`,
);
}
if (module !== 'es2015') {
context.logger.warn(
`Root tsconfig option 'module' is not 'es2015'. This might cause unexpected behaviour.`,
);
}
compilerOptions.module = module;
compilerOptions.baseUrl = baseUrl;
host.overwrite(tsConfigPath, JSON.stringify(tsCfgAst.value, null, 2));
return host;
};
}
export default function (): Rule {
return (host: Tree, context: SchematicContext) => {
if (host.exists('/.angular.json') || host.exists('/angular.json')) {
context.logger.info('Found a modern configuration file. Nothing to be done.');
return host;
}
const configPath = getConfigPath(host);
const configBuffer = host.read(normalize(configPath));
if (configBuffer == null) {
throw new SchematicsException(`Could not find configuration file (${configPath})`);
}
const config = parseJson(configBuffer.toString(), JsonParseMode.Loose);
if (typeof config != 'object' || Array.isArray(config) || config === null) {
throw new SchematicsException('Invalid angular-cli.json configuration; expected an object.');
}
return chain([
migrateKarmaConfiguration(config),
migrateConfiguration(config, context.logger),
updateSpecTsConfig(config),
updatePackageJson(config),
updateRootTsConfig(),
updateTsLintConfig(),
(host: Tree, context: SchematicContext) => {
context.logger.warn(tags.oneLine`Some configuration options have been changed,
please make sure to update any npm scripts which you may have modified.`);
return host;
},
]);
};
}