diff --git a/packages/api/schema/stryker-core.json b/packages/api/schema/stryker-core.json index 27da19829e..591e32a8c8 100644 --- a/packages/api/schema/stryker-core.json +++ b/packages/api/schema/stryker-core.json @@ -164,37 +164,6 @@ "minimum": 0, "maximum": 100 }, - "sandboxOptions": { - "type": "object", - "additionalProperties": false, - "properties": { - "fileHeaders": { - "type": "object", - "title": "SandboxFileHeaders", - "description": "Configure additional headers to be added to files inside your sandbox. These headers will be added after Stryker has instrumented your code with mutants, but before a test runner or build command is executed. This is used to ignore typescript compile errors and eslint warnings that might have been added in the process of instrumenting your code with mutants. The default setting should work for most use cases.", - "additionalProperties": { - "type": "string" - }, - "default": { - "**/*+(.js|.ts|.cjs|.mjs)?(x)": "/* eslint-disable */\n// @ts-nocheck\n" - } - }, - "stripComments": { - "description": "Configure files to be stripped of comments (either single line with `//` or multi line with `/**/`. These comments will be stripped after Stryker has instrumented your code with mutants, but before a test runner or build command is executed. This is used to remove any lingering `// @ts-check` or `// @ts-expect-error` comments that interfere with typescript compilation. The default setting allows comments to be stripped from all JavaScript and friend files in your sandbox, you can specify a different glob expression or set it to `false` to completely disable this behavior.", - "anyOf": [ - { - "enum": [ - false - ] - }, - { - "type": "string" - } - ], - "default": "**/*+(.js|.ts|.cjs|.mjs)?(x)" - } - } - }, "mutatorDescriptor": { "type": "object", "additionalProperties": false, @@ -235,6 +204,11 @@ "description": "decide whether or not to log warnings when additional stryker options are configured", "type": "boolean", "default": true + }, + "preprocessorErrors": { + "description": "decide whether or not to log warnings when a preprocessor error occurs. For example, when the disabling of type errors fails.", + "type": "boolean", + "default": true } } } @@ -364,10 +338,19 @@ "description": "The options for the html reporter", "$ref": "#/definitions/htmlReporterOptions" }, - "sandbox": { - "description": "Configure how the files in the sandbox behave. The sandbox is a copy of your source code where Stryker does mutation testing.", - "$ref": "#/definitions/sandboxOptions", - "default": {} + "disableTypeChecks": { + "description": "Configure a pattern that matches the files of which type checking has to be disabled. This is needed because Stryker will create (typescript) type errors when inserting the mutants in your code. Stryker disables type checking by inserting `// @ts-nocheck` atop those files and removing other `// @ts-xxx` directives (so they won't interfere with `@ts-nocheck`). The default setting allows these directives to be stripped from all JavaScript and friend files in `lib`, `src` and `test` directories. You can specify a different glob expression or set it to `false` to completely disable this behavior.", + "anyOf": [ + { + "enum": [ + false + ] + }, + { + "type": "string" + } + ], + "default": "{test,src,lib}/**/*.{js,ts,jsx,tsx,html,vue}" }, "symlinkNodeModules": { "description": "The 'symlinkNodeModules' value indicates whether Stryker should create a symbolic link to your current node_modules directory in the sandbox directories. This makes running your tests by Stryker behave more like your would run the tests yourself in your project directory. Only disable this setting if you really know what you are doing.", diff --git a/packages/core/src/config/OptionsValidator.ts b/packages/core/src/config/OptionsValidator.ts index d07e3f6860..cddec14c41 100644 --- a/packages/core/src/config/OptionsValidator.ts +++ b/packages/core/src/config/OptionsValidator.ts @@ -1,9 +1,9 @@ import os = require('os'); import Ajv = require('ajv'); -import { StrykerOptions, strykerCoreSchema, WarningOptions } from '@stryker-mutator/api/core'; +import { StrykerOptions, strykerCoreSchema } from '@stryker-mutator/api/core'; import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; -import { noopLogger, propertyPath, deepFreeze } from '@stryker-mutator/util'; +import { noopLogger, propertyPath, deepFreeze, PropertyPathBuilder } from '@stryker-mutator/util'; import { Logger } from '@stryker-mutator/api/logging'; import type { JSONSchema7 } from 'json-schema'; @@ -129,7 +129,7 @@ export function markUnknownOptions(options: StrykerOptions, schema: JSONSchema7, log.warn(`Unknown stryker config option "${unknownPropertyName}".`); }); - const p = `${propertyPath('warnings')}.${propertyPath('unknownOptions')}`; + const p = PropertyPathBuilder.create().prop('warnings').prop('unknownOptions').build(); log.warn(`Possible causes: * Is it a typo on your end? diff --git a/packages/core/src/di/coreTokens.ts b/packages/core/src/di/coreTokens.ts index cf7db38499..005d2b9277 100644 --- a/packages/core/src/di/coreTokens.ts +++ b/packages/core/src/di/coreTokens.ts @@ -1,6 +1,7 @@ export const checkerPool = 'checkerPool'; export const checkerFactory = 'checkerFactory'; export const checkerConcurrencyTokens = 'checkerConcurrencyTokens'; +export const disableTypeChecksHelper = 'disableTypeChecksHelper'; export const execa = 'execa'; export const cliOptions = 'cliOptions'; export const configReader = 'configReader'; diff --git a/packages/core/src/sandbox/create-preprocessor.ts b/packages/core/src/sandbox/create-preprocessor.ts index a3f9fd59ca..dd553480c9 100644 --- a/packages/core/src/sandbox/create-preprocessor.ts +++ b/packages/core/src/sandbox/create-preprocessor.ts @@ -1,16 +1,18 @@ import { tokens, Injector, commonTokens, PluginContext } from '@stryker-mutator/api/plugin'; +import { disableTypeChecks } from '@stryker-mutator/instrumenter'; + +import { coreTokens } from '../di'; + import { TSConfigPreprocessor } from './ts-config-preprocessor'; -import { FileHeaderPreprocessor } from './file-header-preprocessor'; import { FilePreprocessor } from './file-preprocessor'; import { MultiPreprocessor } from './multi-preprocessor'; -import { StripCommentsPreprocessor } from './strip-comments-preprocessor'; +import { DisableTypeChecksPreprocessor } from './disable-type-checks-preprocessor'; createPreprocessor.inject = tokens(commonTokens.injector); export function createPreprocessor(injector: Injector): FilePreprocessor { return new MultiPreprocessor([ - injector.injectClass(StripCommentsPreprocessor), + injector.provideValue(coreTokens.disableTypeChecksHelper, disableTypeChecks).injectClass(DisableTypeChecksPreprocessor), injector.injectClass(TSConfigPreprocessor), - injector.injectClass(FileHeaderPreprocessor), ]); } diff --git a/packages/core/src/sandbox/disable-type-checks-preprocessor.ts b/packages/core/src/sandbox/disable-type-checks-preprocessor.ts new file mode 100644 index 0000000000..15438f20e1 --- /dev/null +++ b/packages/core/src/sandbox/disable-type-checks-preprocessor.ts @@ -0,0 +1,61 @@ +import path = require('path'); + +import minimatch = require('minimatch'); +import { commonTokens, tokens } from '@stryker-mutator/api/plugin'; +import { File, StrykerOptions } from '@stryker-mutator/api/core'; +import type { disableTypeChecks } from '@stryker-mutator/instrumenter'; +import { Logger } from '@stryker-mutator/api/logging'; +import { propertyPath, PropertyPathBuilder } from '@stryker-mutator/util'; + +import { coreTokens } from '../di'; +import { isWarningEnabled } from '../utils/objectUtils'; + +import { FilePreprocessor } from './file-preprocessor'; + +/** + * Disabled type checking by inserting `@ts-nocheck` atop TS/JS files and removing other @ts-xxx directives from comments: + * @see https://github.com/stryker-mutator/stryker/issues/2438 + */ +export class DisableTypeChecksPreprocessor implements FilePreprocessor { + public static readonly inject = tokens(commonTokens.logger, commonTokens.options, coreTokens.disableTypeChecksHelper); + constructor(private readonly log: Logger, private readonly options: StrykerOptions, private readonly impl: typeof disableTypeChecks) {} + + public async preprocess(files: File[]): Promise { + if (this.options.disableTypeChecks === false) { + return files; + } else { + const pattern = path.resolve(this.options.disableTypeChecks); + let warningLogged = false; + const outFiles = await Promise.all( + files.map(async (file) => { + if (minimatch(path.resolve(file.name), pattern)) { + try { + return await this.impl(file, { plugins: this.options.mutator.plugins }); + } catch (err) { + if (isWarningEnabled('preprocessorErrors', this.options.warnings)) { + warningLogged = true; + this.log.warn( + `Unable to disable type checking for file "${ + file.name + }". Shouldn't type checking be disabled for this file? Consider configuring a more restrictive "${propertyPath( + 'disableTypeChecks' + )}" settings (or turn it completely off with \`false\`)`, + err + ); + } + return file; + } + } else { + return file; + } + }) + ); + if (warningLogged) { + this.log.warn( + `(disable "${PropertyPathBuilder.create().prop('warnings').prop('preprocessorErrors')}" to ignore this warning` + ); + } + return outFiles; + } + } +} diff --git a/packages/core/src/sandbox/file-header-preprocessor.ts b/packages/core/src/sandbox/file-header-preprocessor.ts deleted file mode 100644 index 91f2d35c95..0000000000 --- a/packages/core/src/sandbox/file-header-preprocessor.ts +++ /dev/null @@ -1,27 +0,0 @@ -import path = require('path'); - -import { File, StrykerOptions } from '@stryker-mutator/api/core'; -import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; -import minimatch = require('minimatch'); - -import { FilePreprocessor } from './file-preprocessor'; - -/** - * https://github.com/stryker-mutator/stryker/issues/2276 - */ -export class FileHeaderPreprocessor implements FilePreprocessor { - public static readonly inject = tokens(commonTokens.options); - - constructor(private readonly options: StrykerOptions) {} - - public async preprocess(files: File[]): Promise { - return files.map((file) => { - Object.entries(this.options.sandbox.fileHeaders).forEach(([pattern, header]) => { - if (minimatch(path.resolve(file.name), path.resolve(pattern))) { - file = new File(file.name, `${header}${file.textContent}`); - } - }); - return file; - }); - } -} diff --git a/packages/core/src/sandbox/strip-comments-preprocessor.ts b/packages/core/src/sandbox/strip-comments-preprocessor.ts deleted file mode 100644 index c23c8ae3ff..0000000000 --- a/packages/core/src/sandbox/strip-comments-preprocessor.ts +++ /dev/null @@ -1,33 +0,0 @@ -import path = require('path'); - -import { File, StrykerOptions } from '@stryker-mutator/api/core'; -import stripComments = require('strip-comments'); -import minimatch = require('minimatch'); -import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; - -import { FilePreprocessor } from './file-preprocessor'; - -/** - * Strips comments from files to get around issue: 2364. - * @see https://github.com/stryker-mutator/stryker/issues/2364 - */ -export class StripCommentsPreprocessor implements FilePreprocessor { - public static readonly inject = tokens(commonTokens.options); - - constructor(private readonly options: StrykerOptions) {} - - public async preprocess(files: File[]): Promise { - if (this.options.sandbox.stripComments === false) { - return files; - } else { - const pattern = path.resolve(this.options.sandbox.stripComments); - return files.map((file) => { - if (minimatch(path.resolve(file.name), pattern)) { - return new File(file.name, stripComments(file.textContent)); - } else { - return file; - } - }); - } - } -} diff --git a/packages/core/test/unit/sandbox/create-preprocessor.spec.ts b/packages/core/test/unit/sandbox/create-preprocessor.spec.ts index 64839bd876..2cd3b13a2d 100644 --- a/packages/core/test/unit/sandbox/create-preprocessor.spec.ts +++ b/packages/core/test/unit/sandbox/create-preprocessor.spec.ts @@ -17,13 +17,13 @@ describe(createPreprocessor.name, () => { assertions.expectTextFilesEqual(output, [new File(path.resolve('tsconfig.json'), '{\n "extends": "../../../tsconfig.settings.json"\n}')]); }); - it('should add a header to .ts files', async () => { - const output = await sut.preprocess([new File(path.resolve('app.ts'), 'foo.bar()')]); - assertions.expectTextFilesEqual(output, [new File(path.resolve('app.ts'), '/* eslint-disable */\n// @ts-nocheck\nfoo.bar()')]); + it('should disable type checking for .ts files', async () => { + const output = await sut.preprocess([new File(path.resolve('src/app.ts'), 'foo.bar()')]); + assertions.expectTextFilesEqual(output, [new File(path.resolve('src/app.ts'), '// @ts-nocheck\nfoo.bar()')]); }); it('should strip // @ts-expect-error (see https://github.com/stryker-mutator/stryker/issues/2364)', async () => { - const output = await sut.preprocess([new File(path.resolve('app.ts'), '// @ts-expect-error\nfoo.bar()')]); - assertions.expectTextFilesEqual(output, [new File(path.resolve('app.ts'), '/* eslint-disable */\n// @ts-nocheck\n\nfoo.bar()')]); + const output = await sut.preprocess([new File(path.resolve('src/app.ts'), '// @ts-expect-error\nfoo.bar()')]); + assertions.expectTextFilesEqual(output, [new File(path.resolve('src/app.ts'), '// @ts-nocheck\n// \nfoo.bar()')]); }); }); diff --git a/packages/core/test/unit/sandbox/disable-type-checks-preprocessor.spec.ts b/packages/core/test/unit/sandbox/disable-type-checks-preprocessor.spec.ts new file mode 100644 index 0000000000..1124a5e649 --- /dev/null +++ b/packages/core/test/unit/sandbox/disable-type-checks-preprocessor.spec.ts @@ -0,0 +1,74 @@ +import path = require('path'); + +import { File } from '@stryker-mutator/api/core'; +import { assertions, testInjector } from '@stryker-mutator/test-helpers'; +import sinon = require('sinon'); + +import { expect } from 'chai'; + +import { coreTokens } from '../../../src/di'; +import { DisableTypeChecksPreprocessor } from '../../../src/sandbox/disable-type-checks-preprocessor'; + +describe(DisableTypeChecksPreprocessor.name, () => { + let sut: DisableTypeChecksPreprocessor; + let disableTypeCheckingStub: sinon.SinonStub; + + beforeEach(() => { + disableTypeCheckingStub = sinon.stub(); + sut = testInjector.injector.provideValue(coreTokens.disableTypeChecksHelper, disableTypeCheckingStub).injectClass(DisableTypeChecksPreprocessor); + }); + + ['.ts', '.tsx', '.js', '.jsx', '.html', '.vue'].forEach((extension) => { + it(`should disable type checking a ${extension} file by default`, async () => { + const fileName = `src/app${extension}`; + const expectedFile = new File(path.resolve(fileName), 'output'); + const inputFile = new File(path.resolve(fileName), 'input'); + const input = [inputFile]; + disableTypeCheckingStub.resolves(expectedFile); + const output = await sut.preprocess(input); + expect(disableTypeCheckingStub).calledWith(inputFile); + assertions.expectTextFilesEqual(output, [expectedFile]); + }); + }); + + it('should be able to override "disableTypeChecks" glob pattern', async () => { + testInjector.options.disableTypeChecks = 'src/**/*.ts'; + const expectedFile = new File(path.resolve('src/app.ts'), 'output'); + const input = [new File(path.resolve('src/app.ts'), 'input')]; + disableTypeCheckingStub.resolves(expectedFile); + const output = await sut.preprocess(input); + assertions.expectTextFilesEqual(output, [expectedFile]); + }); + + it('should not disable type checking when the "disableTypeChecks" glob pattern does not match', async () => { + testInjector.options.disableTypeChecks = 'src/**/*.ts'; + const expectedFiles = [new File(path.resolve('test/app.spec.ts'), 'input')]; + disableTypeCheckingStub.resolves(new File('', 'not expected')); + const output = await sut.preprocess(expectedFiles); + assertions.expectTextFilesEqual(output, expectedFiles); + }); + + it('should not disable type checking if "disableTypeChecks" is set to `false`', async () => { + const input = [ + new File(path.resolve('src/app.ts'), '// @ts-expect-error\nfoo.bar();'), + new File(path.resolve('test/app.spec.ts'), '/* @ts-expect-error */\nfoo.bar();'), + new File(path.resolve('testResources/project/app.ts'), '/* @ts-expect-error */\nfoo.bar();'), + ]; + testInjector.options.disableTypeChecks = false; + const output = await sut.preprocess(input); + assertions.expectTextFilesEqual(output, input); + }); + + it('should not crash on error, instead log a warning', async () => { + const input = [new File('src/app.ts', 'input')]; + const expectedError = new Error('Expected error for testing'); + disableTypeCheckingStub.rejects(expectedError); + const output = await sut.preprocess(input); + expect(testInjector.logger.warn).calledWithExactly( + 'Unable to disable type checking for file "src/app.ts". Shouldn\'t type checking be disabled for this file? Consider configuring a more restrictive "disableTypeChecks" settings (or turn it completely off with `false`)', + expectedError + ); + expect(testInjector.logger.warn).calledWithExactly('(disable "warnings.preprocessorErrors" to ignore this warning'); + assertions.expectTextFilesEqual(output, input); + }); +}); diff --git a/packages/core/test/unit/sandbox/file-header-preprocessor.spec.ts b/packages/core/test/unit/sandbox/file-header-preprocessor.spec.ts deleted file mode 100644 index 96ee2f4d07..0000000000 --- a/packages/core/test/unit/sandbox/file-header-preprocessor.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import path = require('path'); - -import { testInjector, assertions } from '@stryker-mutator/test-helpers'; -import { File } from '@stryker-mutator/api/core'; - -import { FileHeaderPreprocessor } from '../../../src/sandbox/file-header-preprocessor'; - -const EXPECTED_DEFAULT_HEADER = '/* eslint-disable */\n// @ts-nocheck\n'; - -describe(FileHeaderPreprocessor.name, () => { - let sut: FileHeaderPreprocessor; - beforeEach(() => { - sut = testInjector.injector.injectClass(FileHeaderPreprocessor); - }); - - it('should add preprocess any js or friend file by default', async () => { - const inputContent = 'foo.bar()'; - const expectedOutputContent = `${EXPECTED_DEFAULT_HEADER}foo.bar()`; - const input = [ - new File('src/app.js', inputContent), - new File('test/app.spec.ts', inputContent), - new File('src/components/app.jsx', inputContent), - new File('src/components/app.tsx', inputContent), - new File('src/components/app.cjs', inputContent), - new File('src/components/app.mjs', inputContent), - ]; - const output = await sut.preprocess(input); - - assertions.expectTextFilesEqual(output, [ - new File('src/app.js', expectedOutputContent), - new File('test/app.spec.ts', expectedOutputContent), - new File('src/components/app.jsx', expectedOutputContent), - new File('src/components/app.tsx', expectedOutputContent), - new File('src/components/app.cjs', expectedOutputContent), - new File('src/components/app.mjs', expectedOutputContent), - ]); - }); - - it('should also match a full file name', async () => { - // Arrange - const input = [new File(path.resolve('src', 'app.ts'), 'foo.bar()')]; - testInjector.options.sandbox.fileHeaders = { - ['+(src|test)/**/*+(.js|.ts)?(x)']: '// @ts-nocheck\n', - }; - - // Act - const output = await sut.preprocess(input); - - // Assert - assertions.expectTextFilesEqual(output, [new File(path.resolve('src', 'app.ts'), '// @ts-nocheck\nfoo.bar()')]); - }); - - it('should not change unmatched files according to glob expression', async () => { - // Arrange - const input = [ - new File(path.resolve('src', 'app.ts'), 'foo.bar()'), - new File(path.resolve('testResources', 'app.ts'), '// test file example that should be ignored'), - ]; - testInjector.options.sandbox.fileHeaders = { - ['+(src|test)/**/*+(.js|.ts)?(x)']: '// @ts-nocheck\n', - }; - - // Act - const output = await sut.preprocess(input); - - // Assert - assertions.expectTextFilesEqual(output, [ - new File(path.resolve('src', 'app.ts'), '// @ts-nocheck\nfoo.bar()'), - new File(path.resolve('testResources', 'app.ts'), '// test file example that should be ignored'), - ]); - }); - - it('should allow multiple headers', async () => { - // Arrange - const input = [new File('src/app.ts', 'foo.bar()'), new File('src/components/app.component.js', 'baz.qux()')]; - testInjector.options.sandbox.fileHeaders = { - ['**/*.ts']: '// @ts-nocheck\n', - ['**/*.js']: '/* eslint-disable */\n', - }; - - // Act - const output = await sut.preprocess(input); - - // Assert - assertions.expectTextFilesEqual(output, [ - new File('src/app.ts', '// @ts-nocheck\nfoo.bar()'), - new File('src/components/app.component.js', '/* eslint-disable */\nbaz.qux()'), - ]); - }); - - it('should pass-through any other files', async () => { - const input = [new File('README.md', '# Foo')]; - const output = await sut.preprocess(input); - assertions.expectTextFilesEqual(output, [new File('README.md', '# Foo')]); - }); -}); diff --git a/packages/core/test/unit/sandbox/strip-comments-preprocessor.spec.ts b/packages/core/test/unit/sandbox/strip-comments-preprocessor.spec.ts deleted file mode 100644 index fdde9015ba..0000000000 --- a/packages/core/test/unit/sandbox/strip-comments-preprocessor.spec.ts +++ /dev/null @@ -1,54 +0,0 @@ -import path = require('path'); - -import { testInjector, assertions } from '@stryker-mutator/test-helpers'; -import { File } from '@stryker-mutator/api/core'; - -import { expect } from 'chai'; - -import { StripCommentsPreprocessor } from '../../../src/sandbox/strip-comments-preprocessor'; - -describe(StripCommentsPreprocessor.name, () => { - let sut: StripCommentsPreprocessor; - - beforeEach(() => { - sut = testInjector.injector.injectClass(StripCommentsPreprocessor); - }); - - it('should strip comments', async () => { - const input = [new File(path.resolve('src/app.ts'), '// @ts-expect-error\nfoo.bar();')]; - const output = await sut.preprocess(input); - assertions.expectTextFilesEqual(output, [new File(path.resolve('src/app.ts'), '\nfoo.bar();')]); - }); - - it('should not strip strings with comments in it', async () => { - const input = [new File(path.resolve('src/app.ts'), 'foo.bar("// @ts-expect-error");')]; - const output = await sut.preprocess(input); - assertions.expectTextFilesEqual(output, [new File(path.resolve('src/app.ts'), 'foo.bar("// @ts-expect-error");')]); - }); - - it('should only strip comments in that match the "sandbox.stripComments" glob expression', async () => { - const input = [ - new File(path.resolve('src/app.ts'), '// @ts-expect-error\nfoo.bar();'), - new File(path.resolve('test/app.spec.ts'), '/* @ts-expect-error */\nfoo.bar();'), - new File(path.resolve('testResources/project/app.ts'), '/* @ts-expect-error */\nfoo.bar();'), - ]; - testInjector.options.sandbox.stripComments = '+(src|test)/**/*.ts'; - const output = await sut.preprocess(input); - assertions.expectTextFilesEqual(output, [ - new File(path.resolve('src/app.ts'), '\nfoo.bar();'), - new File(path.resolve('test/app.spec.ts'), 'foo.bar();'), - new File(path.resolve('testResources/project/app.ts'), '/* @ts-expect-error */\nfoo.bar();'), - ]); - }); - - it('should not strip comments if "sandbox.stripComments" is set to `false`', async () => { - const input = [ - new File(path.resolve('src/app.ts'), '// @ts-expect-error\nfoo.bar();'), - new File(path.resolve('test/app.spec.ts'), '/* @ts-expect-error */\nfoo.bar();'), - new File(path.resolve('testResources/project/app.ts'), '/* @ts-expect-error */\nfoo.bar();'), - ]; - testInjector.options.sandbox.stripComments = false; - const output = await sut.preprocess(input); - expect(output).eq(input); - }); -}); diff --git a/packages/instrumenter/src/disable-type-checks.ts b/packages/instrumenter/src/disable-type-checks.ts new file mode 100644 index 0000000000..c131c96a1f --- /dev/null +++ b/packages/instrumenter/src/disable-type-checks.ts @@ -0,0 +1,82 @@ +import { types } from '@babel/core'; +import { File, Range } from '@stryker-mutator/api/core'; +import { notEmpty } from '@stryker-mutator/util'; + +import { InstrumenterOptions } from './instrumenter-options'; +import { createParser, getFormat } from './parsers'; +import { AstFormat, HtmlAst, JSAst, TSAst } from './syntax'; + +const commentDirectiveRegEx = /^(\s*)@(ts-[a-z-]+).*$/; +const tsDirectiveLikeRegEx = /@(ts-[a-z-]+)/; + +export async function disableTypeChecks(file: File, options: InstrumenterOptions) { + if (isJSFileWithoutTSDirectives(file)) { + // Performance optimization. Only parse the file when it has a change of containing a `// @ts-` directive + return new File(file.name, prefixWithNoCheck(file.textContent)); + } + const parse = createParser(options); + const ast = await parse(file.textContent, file.name); + switch (ast.format) { + case AstFormat.JS: + case AstFormat.TS: + return new File(file.name, disableTypeCheckingInBabelAst(ast)); + case AstFormat.Html: + return new File(file.name, disableTypeCheckingInHtml(ast)); + } +} + +function isJSFileWithoutTSDirectives(file: File) { + const format = getFormat(file.name); + return (format === AstFormat.TS || format === AstFormat.JS) && !tsDirectiveLikeRegEx.test(file.textContent); +} + +function disableTypeCheckingInBabelAst(ast: JSAst | TSAst): string { + return prefixWithNoCheck(removeTSDirectives(ast.rawContent, ast.root.comments)); +} + +function prefixWithNoCheck(code: string): string { + return `// @ts-nocheck\n${code}`; +} + +function disableTypeCheckingInHtml(ast: HtmlAst): string { + const sortedScripts = [...ast.root.scripts].sort((a, b) => a.root.start! - b.root.start!); + let currentIndex = 0; + let html = ''; + for (const script of sortedScripts) { + html += ast.rawContent.substring(currentIndex, script.root.start!); + html += '\n'; + html += prefixWithNoCheck(removeTSDirectives(script.rawContent, script.root.comments)); + html += '\n'; + currentIndex = script.root.end!; + } + html += ast.rawContent.substr(currentIndex); + return html; +} + +function removeTSDirectives(text: string, comments: Array | null): string { + const directiveRanges = comments + ?.map(tryParseTSDirective) + .filter(notEmpty) + .sort((a, b) => a[0] - b[0]); + if (directiveRanges) { + let currentIndex = 0; + let pruned = ''; + for (const directiveRange of directiveRanges) { + pruned += text.substring(currentIndex, directiveRange[0]); + currentIndex = directiveRange[1]; + } + pruned += text.substr(currentIndex); + return pruned; + } else { + return text; + } +} + +function tryParseTSDirective(comment: types.CommentBlock | types.CommentLine): Range | undefined { + const match = commentDirectiveRegEx.exec(comment.value); + if (match) { + const directiveStartPos = comment.start + match[1].length + 2; // +2 to account for the `//` or `/*` start character + return [directiveStartPos, directiveStartPos + match[2].length + 1]; + } + return undefined; +} diff --git a/packages/instrumenter/src/index.ts b/packages/instrumenter/src/index.ts index 088c6aeeec..0d4e43ad31 100644 --- a/packages/instrumenter/src/index.ts +++ b/packages/instrumenter/src/index.ts @@ -1,3 +1,4 @@ export * from './instrumenter'; export * from './instrument-result'; export * from './instrumenter-options'; +export * from './disable-type-checks'; diff --git a/packages/instrumenter/src/parsers/index.ts b/packages/instrumenter/src/parsers/index.ts index ff622327d3..bd9f1dc8fd 100644 --- a/packages/instrumenter/src/parsers/index.ts +++ b/packages/instrumenter/src/parsers/index.ts @@ -24,7 +24,7 @@ export function createParser(parserOptions: ParserOptions) { }; } -function getFormat(fileName: string, override: AstFormat | undefined): AstFormat { +export function getFormat(fileName: string, override?: AstFormat): AstFormat { if (override) { return override; } else { diff --git a/packages/instrumenter/src/printers/html-printer.ts b/packages/instrumenter/src/printers/html-printer.ts index ed8cae347a..58230c97cc 100644 --- a/packages/instrumenter/src/printers/html-printer.ts +++ b/packages/instrumenter/src/printers/html-printer.ts @@ -9,7 +9,6 @@ export const print: Printer = (ast, context) => { for (const script of sortedScripts) { html += ast.rawContent.substring(currentIndex, script.root.start!); html += '\n'; - html += '// @ts-nocheck\n'; html += context.print(script, context); html += '\n'; currentIndex = script.root.end!; diff --git a/packages/instrumenter/test/integration/disable-type-checks.it.spec.ts b/packages/instrumenter/test/integration/disable-type-checks.it.spec.ts new file mode 100644 index 0000000000..6ea4077485 --- /dev/null +++ b/packages/instrumenter/test/integration/disable-type-checks.it.spec.ts @@ -0,0 +1,40 @@ +import path from 'path'; +import { promises as fs } from 'fs'; + +import { expect } from 'chai'; +import chaiJestSnapshot from 'chai-jest-snapshot'; + +import { File } from '@stryker-mutator/api/core'; + +import { disableTypeChecks } from '../../src'; +import { createInstrumenterOptions } from '../helpers/factories'; + +const resolveTestResource = path.resolve.bind( + path, + __dirname, + '..' /* integration */, + '..' /* test */, + '..' /* dist */, + 'testResources', + 'disable-type-checks' +); + +describe(`${disableTypeChecks.name} integration`, () => { + it('should be able disable type checks of a type script file', async () => { + await arrangeAndActAssert('app.component.ts'); + }); + it('should be able disable type checks of an html file', async () => { + await arrangeAndActAssert('html-sample.html'); + }); + it('should be able disable type checks of a vue file', async () => { + await arrangeAndActAssert('vue-sample.vue'); + }); + + async function arrangeAndActAssert(fileName: string, options = createInstrumenterOptions()) { + const fullFileName = resolveTestResource(fileName); + const file = new File(fullFileName, await fs.readFile(fullFileName)); + const result = await disableTypeChecks(file, options); + chaiJestSnapshot.setFilename(resolveTestResource(`${fileName}.out.snap`)); + expect(result.textContent).matchSnapshot(); + } +}); diff --git a/packages/instrumenter/test/integration/printers.it.spec.ts b/packages/instrumenter/test/integration/printers.it.spec.ts index b61a64adf2..b24833f059 100644 --- a/packages/instrumenter/test/integration/printers.it.spec.ts +++ b/packages/instrumenter/test/integration/printers.it.spec.ts @@ -25,7 +25,7 @@ describe('parse and print integration', () => { } }); describe('html', () => { - it('should be able to print html files with multiple script tags and add // @ts-nocheck comments', async () => { + it('should be able to print html files with multiple script tags', async () => { await actArrangeAndAssert('hello-world'); }); diff --git a/packages/instrumenter/test/unit/disable-type-checks.spec.ts b/packages/instrumenter/test/unit/disable-type-checks.spec.ts new file mode 100644 index 0000000000..1c61cd3006 --- /dev/null +++ b/packages/instrumenter/test/unit/disable-type-checks.spec.ts @@ -0,0 +1,119 @@ +import { File } from '@stryker-mutator/api/core'; +import { assertions } from '@stryker-mutator/test-helpers'; +import sinon from 'sinon'; +import { expect } from 'chai'; + +import * as parsers from '../../src/parsers'; +import { disableTypeChecks } from '../../src'; + +describe(disableTypeChecks.name, () => { + describe('with TS or JS AST format', () => { + it('should prefix the file with `// @ts-nocheck`', async () => { + const inputFile = new File('foo.js', 'foo.bar();'); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual(actual, new File('foo.js', '// @ts-nocheck\nfoo.bar();')); + }); + + it('should not even parse the file if "@ts-" can\'t be found anywhere in the file (performance optimization)', async () => { + const createParserSpy = sinon.spy(parsers, 'createParser'); + const inputFile = new File('foo.js', 'foo.bar();'); + await disableTypeChecks(inputFile, { plugins: null }); + expect(createParserSpy).not.called; + }); + + it('should remove @ts directives from a JS file', async () => { + const inputFile = new File('foo.js', '// @ts-check\nfoo.bar();'); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual(actual, new File('foo.js', '// @ts-nocheck\n// \nfoo.bar();')); + }); + + it('should remove @ts directives from a TS file', async () => { + const inputFile = new File('foo.ts', '// @ts-check\nfoo.bar();'); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual(actual, new File('foo.ts', '// @ts-nocheck\n// \nfoo.bar();')); + }); + + it('should remove @ts directive from single line', async () => { + await arrangeActAssert('// @ts-check\nfoo.bar();', '// \nfoo.bar();'); + }); + + it('should not remove @ts comments which occur later on the comment line (since then they are not considered a directive)', async () => { + await arrangeActAssert('// this should be ignored: @ts-expect-error\nfoo.bar();'); + }); + + it('should remove @ts directive from multiline', async () => { + await arrangeActAssert('/* @ts-expect-error */\nfoo.bar();', '/* */\nfoo.bar();'); + }); + + describe('with string', () => { + it('should not remove @ts directive in double quoted string', async () => { + await arrangeActAssert('foo.bar("/* @ts-expect-error */")'); + }); + + it('should not remove @ts directive in double quoted string after escaped double quote', async () => { + await arrangeActAssert('foo.bar("foo \\"/* @ts-expect-error */")'); + }); + + it('should remove @ts directive after a string', async () => { + await arrangeActAssert('foo.bar("foo \\" bar "/* @ts-expect-error */,\nbaz.qux())', 'foo.bar("foo \\" bar "/* */,\nbaz.qux())'); + }); + + it('should not remove @ts directive in single quoted string', async () => { + await arrangeActAssert("foo.bar('/* @ts-expect-error */')"); + }); + }); + + describe('with regex literals', () => { + it('should not remove @ts directive inside the regex', async () => { + await arrangeActAssert('const regex = / \\/*@ts-check */'); + }); + + it('should remove @ts directives just after a regex', async () => { + await arrangeActAssert('const regex = / \\/*@ts-check */// @ts-expect-error\nfoo.bar()', 'const regex = / \\/*@ts-check */// \nfoo.bar()'); + }); + + it('should allow escape sequence inside the regex', async () => { + await arrangeActAssert('const regex = / \\/ /; // @ts-expect-error', 'const regex = / \\/ /; // '); + }); + + it('should allow `/` inside a character class', async () => { + await arrangeActAssert('const regex = / [/] /; // @ts-check', 'const regex = / [/] /; // '); + }); + }); + + describe('with template strings', () => { + it('should not remove @ts directive inside the literal', async () => { + await arrangeActAssert('const foo = `/*@ts-check */`'); + }); + }); + + async function arrangeActAssert(input: string, expectedOutput = input) { + const inputFile = new File('foo.tsx', input); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual(actual, new File('foo.tsx', `// @ts-nocheck\n${expectedOutput}`)); + } + }); + + describe('with HTML ast format', () => { + it('should prefix the script tags with `// @ts-nocheck`', async () => { + const inputFile = new File('foo.vue', ''); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual(actual, new File('foo.vue', '')); + }); + + it('should remove `// @ts` directives from script tags', async () => { + const inputFile = new File('foo.html', ''); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual( + actual, + new File('foo.html', '') + ); + }); + + it('should not remove `// @ts` from the html itself', async () => { + const inputFile = new File('foo.vue', ''); + const actual = await disableTypeChecks(inputFile, { plugins: null }); + assertions.expectTextFileEqual(actual, new File('foo.vue', '')); + }); + }); +}); diff --git a/packages/instrumenter/testResources/disable-type-checks/app.component.ts b/packages/instrumenter/testResources/disable-type-checks/app.component.ts new file mode 100644 index 0000000000..6ddba23c4b --- /dev/null +++ b/packages/instrumenter/testResources/disable-type-checks/app.component.ts @@ -0,0 +1,28 @@ +import {Component, HostListener, Inject} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; + +@Component({ + selector: 'ksw-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { + title = 'Kantishop'; + + constructor(@Inject(DOCUMENT) document) { + } + + @HostListener('window:scroll', ['$event']) + onWindowScroll(e) { + // @ts-expect-error + if (window.pageYOffset > document.getElementById('banner').offsetHeight) { + const element = document.getElementById('kanti-menu'); + element.classList.add('kanti-sticky'); + document.getElementsByTagName('main').item(0).setAttribute('style', 'margin-top: 50px'); + } else { + const element = document.getElementById('kanti-menu'); + element.classList.remove('kanti-sticky'); + document.getElementsByTagName('main').item(0).removeAttribute('style'); + } + } +} diff --git a/packages/instrumenter/testResources/disable-type-checks/app.component.ts.out.snap b/packages/instrumenter/testResources/disable-type-checks/app.component.ts.out.snap new file mode 100644 index 0000000000..0891124a73 --- /dev/null +++ b/packages/instrumenter/testResources/disable-type-checks/app.component.ts.out.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`disableTypeChecks integration should be able disable type checking of a type script file 1`] = ` +"// @ts-nocheck +import {Component, HostListener, Inject} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; + +@Component({ + selector: 'ksw-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { + title = 'Kantishop'; + + constructor(@Inject(DOCUMENT) document) { + } + + @HostListener('window:scroll', ['$event']) + onWindowScroll(e) { + // + if (window.pageYOffset > document.getElementById('banner').offsetHeight) { + const element = document.getElementById('kanti-menu'); + element.classList.add('kanti-sticky'); + document.getElementsByTagName('main').item(0).setAttribute('style', 'margin-top: 50px'); + } else { + const element = document.getElementById('kanti-menu'); + element.classList.remove('kanti-sticky'); + document.getElementsByTagName('main').item(0).removeAttribute('style'); + } + } +} +" +`; + +exports[`disableTypeChecks integration should be able disable type checks of a type script file 1`] = ` +"// @ts-nocheck +import {Component, HostListener, Inject} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; + +@Component({ + selector: 'ksw-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'] +}) +export class AppComponent { + title = 'Kantishop'; + + constructor(@Inject(DOCUMENT) document) { + } + + @HostListener('window:scroll', ['$event']) + onWindowScroll(e) { + // + if (window.pageYOffset > document.getElementById('banner').offsetHeight) { + const element = document.getElementById('kanti-menu'); + element.classList.add('kanti-sticky'); + document.getElementsByTagName('main').item(0).setAttribute('style', 'margin-top: 50px'); + } else { + const element = document.getElementById('kanti-menu'); + element.classList.remove('kanti-sticky'); + document.getElementsByTagName('main').item(0).removeAttribute('style'); + } + } +} +" +`; diff --git a/packages/instrumenter/testResources/disable-type-checks/html-sample.html b/packages/instrumenter/testResources/disable-type-checks/html-sample.html new file mode 100644 index 0000000000..dd95a9ab66 --- /dev/null +++ b/packages/instrumenter/testResources/disable-type-checks/html-sample.html @@ -0,0 +1,20 @@ + + + + + + + Document + + + + + + + \ No newline at end of file diff --git a/packages/instrumenter/testResources/disable-type-checks/html-sample.html.out.snap b/packages/instrumenter/testResources/disable-type-checks/html-sample.html.out.snap new file mode 100644 index 0000000000..f4eb2c49a9 --- /dev/null +++ b/packages/instrumenter/testResources/disable-type-checks/html-sample.html.out.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`disableTypeChecks integration should be able disable type checking of an html file 1`] = ` +" + + + + + + Document + + + + + + +" +`; + +exports[`disableTypeChecks integration should be able disable type checks of an html file 1`] = ` +" + + + + + + Document + + + + + + +" +`; diff --git a/packages/instrumenter/testResources/disable-type-checks/vue-sample.vue b/packages/instrumenter/testResources/disable-type-checks/vue-sample.vue new file mode 100644 index 0000000000..04293cd27a --- /dev/null +++ b/packages/instrumenter/testResources/disable-type-checks/vue-sample.vue @@ -0,0 +1,118 @@ + + + + + + + diff --git a/packages/instrumenter/testResources/disable-type-checks/vue-sample.vue.out.snap b/packages/instrumenter/testResources/disable-type-checks/vue-sample.vue.out.snap new file mode 100644 index 0000000000..96213eaf66 --- /dev/null +++ b/packages/instrumenter/testResources/disable-type-checks/vue-sample.vue.out.snap @@ -0,0 +1,129 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`disableTypeChecks integration should be able disable type checks of a vue file 1`] = ` +" + + + + + + +" +`; diff --git a/packages/instrumenter/testResources/instrumenter/html-sample.html.out.snap b/packages/instrumenter/testResources/instrumenter/html-sample.html.out.snap index fd1b9d3400..dd0ebdd788 100644 --- a/packages/instrumenter/testResources/instrumenter/html-sample.html.out.snap +++ b/packages/instrumenter/testResources/instrumenter/html-sample.html.out.snap @@ -12,7 +12,6 @@ exports[`instrumenter integration should be able to instrument html 1`] = ` diff --git a/packages/mocha-runner/src/MochaAdapter.ts b/packages/mocha-runner/src/MochaAdapter.ts index 0872078e0e..856d1a7f61 100644 --- a/packages/mocha-runner/src/MochaAdapter.ts +++ b/packages/mocha-runner/src/MochaAdapter.ts @@ -3,7 +3,7 @@ import fs = require('fs'); import { tokens, commonTokens } from '@stryker-mutator/api/plugin'; import { Logger } from '@stryker-mutator/api/logging'; -import { propertyPath } from '@stryker-mutator/util'; +import { PropertyPathBuilder } from '@stryker-mutator/util'; import { MochaOptions, MochaRunnerOptions } from '../src-generated/mocha-runner-options'; @@ -81,10 +81,9 @@ export class MochaAdapter { globPatterns, null, 2 - )}). Please specify the files (glob patterns) containing your tests in ${propertyPath( - 'mochaOptions', - 'spec' - )} in your config file.` + )}). Please specify the files (glob patterns) containing your tests in ${PropertyPathBuilder.create() + .prop('mochaOptions') + .prop('spec')} in your config file.` ); } return [...fileNames]; diff --git a/packages/mocha-runner/src/MochaOptionsLoader.ts b/packages/mocha-runner/src/MochaOptionsLoader.ts index d2f8e0d57a..6ea0165aa2 100644 --- a/packages/mocha-runner/src/MochaOptionsLoader.ts +++ b/packages/mocha-runner/src/MochaOptionsLoader.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { Logger } from '@stryker-mutator/api/logging'; import { commonTokens, tokens } from '@stryker-mutator/api/plugin'; -import { propertyPath } from '@stryker-mutator/util'; +import { PropertyPathBuilder } from '@stryker-mutator/util'; import { MochaOptions, MochaRunnerOptions } from '../src-generated/mocha-runner-options'; @@ -76,7 +76,7 @@ export default class MochaOptionsLoader { } else { this.log.debug( 'No mocha opts file found, not loading additional mocha options (%s was not defined).', - propertyPath('mochaOptions', 'opts') + PropertyPathBuilder.create().prop('mochaOptions').prop('opts').build() ); return {}; } diff --git a/packages/test-helpers/src/factory.ts b/packages/test-helpers/src/factory.ts index 0fac521916..54b7960113 100644 --- a/packages/test-helpers/src/factory.ts +++ b/packages/test-helpers/src/factory.ts @@ -75,6 +75,7 @@ export function pluginResolver(): sinon.SinonStubbedInstance { export const warningOptions = factoryMethod(() => ({ unknownOptions: true, + preprocessorErrors: true, })); export const killedMutantResult = factoryMethod(() => ({ diff --git a/packages/util/src/stringUtils.ts b/packages/util/src/stringUtils.ts index 7c8d10deea..31d57d553d 100644 --- a/packages/util/src/stringUtils.ts +++ b/packages/util/src/stringUtils.ts @@ -1,4 +1,3 @@ -import { notEmpty } from './notEmpty'; import { KnownKeys } from './KnownKeys'; /** @@ -10,12 +9,44 @@ export function normalizeWhitespaces(str: string) { } /** - * Given a base type, allows type safe access to the name (or deep path) of a property. - * @param prop The first property key - * @param prop2 The optional second property key. Add prop3, prop4, etc to this method signature when needed. + * Given a base type, allows type safe access to the name of a property. + * @param prop The property name */ -export function propertyPath(prop: keyof Pick>, prop2?: keyof Pick>[typeof prop]): string { - return [prop, prop2].filter(notEmpty).join('.'); +export function propertyPath(prop: keyof Pick>): string { + return prop.toString(); +} + +/** + * A helper class to allow you to get type safe access to the name of a deep property of `T` + * @example + * ```ts + * PropertyPathBuilder('warnings').prop('unknownOptions').build() + * ``` + */ +export class PropertyPathBuilder { + constructor(private readonly pathSoFar: string[]) {} + + public prop>(prop: TProp) { + return new PropertyPathBuilder>[TProp]>([...this.pathSoFar, prop.toString()]); + } + + /** + * Build the (deep) path to the property name + */ + public build(): string { + return this.pathSoFar.join('.'); + } + + /** + * Creates a new `PropertyPathBuilder` for type T + */ + public static create() { + return new PropertyPathBuilder([]); + } + + public toString(): string { + return this.build(); + } } /** diff --git a/packages/util/test/unit/stringUtils.spec.ts b/packages/util/test/unit/stringUtils.spec.ts index 802014c10c..1b29eeec18 100644 --- a/packages/util/test/unit/stringUtils.spec.ts +++ b/packages/util/test/unit/stringUtils.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { normalizeWhitespaces, propertyPath, escapeRegExpLiteral, escapeRegExp } from '../../src'; +import { normalizeWhitespaces, propertyPath, escapeRegExpLiteral, escapeRegExp, PropertyPathBuilder } from '../../src'; describe('stringUtils', () => { describe(normalizeWhitespaces.name, () => { @@ -17,15 +17,28 @@ describe('stringUtils', () => { }); }); - describe(propertyPath.name, () => { + describe(PropertyPathBuilder.name, () => { interface Foo { bar: { baz: string; }; + qux: { + quux: string; + }; + } + + it('should be able to point to a path', () => { + expect(PropertyPathBuilder.create().prop('bar').prop('baz').build()).eq('bar.baz'); + }); + }); + + describe(propertyPath.name, () => { + interface Foo { + bar: string; } it('should be able to point to a path', () => { - expect(propertyPath('bar', 'baz')).eq('bar.baz'); + expect(propertyPath('bar')).eq('bar'); }); }); diff --git a/perf/config/express/package.json b/perf/config/express/package.json index 4da6715f9c..586e5d1b4a 100644 --- a/perf/config/express/package.json +++ b/perf/config/express/package.json @@ -1,6 +1,7 @@ { "localDependencies": { "@stryker-mutator/core": "../../../packages/core", + "@stryker-mutator/instrumenter": "../../../packages/instrumenter", "@stryker-mutator/api": "../../../packages/api", "@stryker-mutator/mocha-runner": "../../../packages/mocha-runner", "@stryker-mutator/util": "../../../packages/util" diff --git a/perf/config/express/stryker.conf.json b/perf/config/express/stryker.conf.json index 378d855fd5..bbc46e84a7 100644 --- a/perf/config/express/stryker.conf.json +++ b/perf/config/express/stryker.conf.json @@ -1,8 +1,5 @@ { "$schema": "../../../packages/core/schema/stryker-schema.json", "testRunner": "mocha", - "coverageAnalysis": "perTest", - "sandbox": { - "stripComments": false - } + "coverageAnalysis": "perTest" } diff --git a/workspace.code-workspace b/workspace.code-workspace index 3698afcded..23695979f8 100644 --- a/workspace.code-workspace +++ b/workspace.code-workspace @@ -90,7 +90,7 @@ } ], "settings": { - "typescript.tsdk": "parent\\node_modules\\typescript\\lib", + "typescript.tsdk": "parent/node_modules/typescript/lib", "files.exclude": { ".git": true, ".tscache": true,