diff --git a/packages/typewiz-core/package.json b/packages/typewiz-core/package.json index 4f7a8fa..44466d1 100644 --- a/packages/typewiz-core/package.json +++ b/packages/typewiz-core/package.json @@ -16,9 +16,13 @@ ], "dependencies": { "ajv": "^6.4.0", + "md5": "^2.2.1", "typescript": ">=2.4.2 <4.0.0" }, "engines": { "node": ">= 8.0.0" + }, + "devDependencies": { + "@types/md5": "^2.1.33" } } diff --git a/packages/typewiz-core/src/apply-types.ts b/packages/typewiz-core/src/apply-types.ts index d66392d..1c513f9 100644 --- a/packages/typewiz-core/src/apply-types.ts +++ b/packages/typewiz-core/src/apply-types.ts @@ -1,9 +1,10 @@ import * as fs from 'fs'; +import md5 = require('md5'); import * as path from 'path'; import * as ts from 'typescript'; import { getProgram, ICompilerOptions } from './compiler-helper'; import { applyReplacements, Replacement } from './replacement'; -import { ICollectedTypeInfo, ISourceLocation } from './type-collector-snippet'; +import { ICollectedTypeInfo, IFileTypeInfo, ISourceLocationAndType } from './type-collector-snippet'; import { TypewizError } from './typewiz-error'; export interface IApplyTypesOptions extends ICompilerOptions { @@ -15,9 +16,11 @@ export interface IApplyTypesOptions extends ICompilerOptions { prefix?: string; } -function findType(program?: ts.Program, typeName?: string, sourcePos?: ISourceLocation) { - if (program && sourcePos) { - const [sourceName, sourceOffset] = sourcePos; +function findType(program: ts.Program | undefined, typeInfo: string | ISourceLocationAndType) { + const typeName = typeof typeInfo === 'string' ? typeInfo : typeInfo[2]; + const sourceName = typeof typeInfo !== 'string' ? typeInfo[0] : null; + const sourceOffset = typeof typeInfo !== 'string' ? typeInfo[1] : null; + if (program && sourceName) { const typeChecker = program.getTypeChecker(); let foundType: string | null = null; function visit(node: ts.Node) { @@ -42,16 +45,23 @@ function findType(program?: ts.Program, typeName?: string, sourcePos?: ISourceLo export function applyTypesToFile( source: string, - typeInfo: ICollectedTypeInfo, + fileTypes: IFileTypeInfo, options: IApplyTypesOptions, program?: ts.Program, ) { const replacements = []; const prefix = options.prefix || ''; - for (const [, pos, types, opts] of typeInfo) { - const isOptional = source[pos - 1] === '?'; + let hadChanges = false; + for (const key of Object.keys(fileTypes)) { + if (!/^\d+$/.test(key)) { + continue; + } + hadChanges = true; + const offset = parseInt(key, 10); + const isOptional = source[offset - 1] === '?'; + const { types, parens, thisNeedsComma, thisType } = fileTypes[offset]; let sortedTypes = types - .map(([name, sourcePos]) => findType(program, name, sourcePos)) + .map((type) => findType(program, type)) .filter((t) => t) .sort(); if (isOptional) { @@ -63,34 +73,33 @@ export function applyTypesToFile( let thisPrefix = ''; let suffix = ''; - if (opts && opts.parens) { - replacements.push(Replacement.insert(opts.parens[0], '(')); + if (parens) { + replacements.push(Replacement.insert(parens[0], '(')); suffix = ')'; } - if (opts && opts.thisNeedsComma) { + if (thisNeedsComma) { suffix = ', '; } - if (opts && opts.thisType) { + if (thisType) { thisPrefix = 'this'; } - replacements.push(Replacement.insert(pos, thisPrefix + ': ' + prefix + sortedTypes.join('|') + suffix)); + replacements.push(Replacement.insert(offset, thisPrefix + ': ' + prefix + sortedTypes.join('|') + suffix)); } - return applyReplacements(source, replacements); + return hadChanges ? applyReplacements(source, replacements) : null; } export function applyTypes(typeInfo: ICollectedTypeInfo, options: IApplyTypesOptions = {}) { - const files: { [key: string]: typeof typeInfo } = {}; const program: ts.Program | undefined = getProgram(options); - for (const entry of typeInfo) { - const file = entry[0]; - if (!files[file]) { - files[file] = []; - } - files[file].push(entry); - } - for (const file of Object.keys(files)) { + for (const file of Object.keys(typeInfo)) { + const fileInfo = typeInfo[file]; const filePath = options.rootDir ? path.join(options.rootDir, file) : file; const source = fs.readFileSync(filePath, 'utf-8'); - fs.writeFileSync(filePath, applyTypesToFile(source, files[file], options, program)); + if (md5(source) !== fileInfo.hash) { + throw new Error('Hash mismatch! Source file has changed since type information was collected'); + } + const newContent = applyTypesToFile(source, fileInfo, options, program); + if (newContent) { + fs.writeFileSync(filePath, newContent); + } } } diff --git a/packages/typewiz-core/src/instrument.spec.ts b/packages/typewiz-core/src/instrument.spec.ts index 4a51e2c..ad608b5 100644 --- a/packages/typewiz-core/src/instrument.spec.ts +++ b/packages/typewiz-core/src/instrument.spec.ts @@ -10,19 +10,26 @@ describe('instrument', () => { it('should instrument function parameters without types', () => { const input = `function (a) { return 5; }`; expect(instrument(input, 'test.ts')).toContain( - astPrettyPrint(`function (a) { $_$twiz("a", a, 11, "test.ts", "{}"); return 5; }`), + astPrettyPrint( + `function (a) { $_$twiz(a, 11, "test.ts", "{}", "72f01d0740f3f0ac5bd0f708b639b578"); return 5; }`, + ), ); }); it('should instrument function with two parameters', () => { const input = `function (a, b) { return 5; }`; - expect(instrument(input, 'test.ts')).toContain(astPrettyPrint(`$_$twiz("b", b, 14, "test.ts", "{}");`).trim()); + expect(instrument(input, 'test.ts')).toContain( + astPrettyPrint(`$_$twiz(b, 14, "test.ts", "{}", "e1579a22f084265f8fb450beba126b53");`).trim(), + ); }); it('should instrument class method parameters', () => { const input = `class Foo { bar(a) { return 5; } }`; expect(instrument(input, 'test.ts')).toContain( - astPrettyPrint(`class Foo { bar(a) { $_$twiz("a", a, 17, "test.ts", "{}"); return 5; } }`), + astPrettyPrint( + // tslint:disable-next-line:max-line-length + `class Foo { bar(a) { $_$twiz(a, 17, "test.ts", "{}", "d8797d533a93b14ff250fc6b33511db2"); return 5; } }`, + ), ); }); @@ -40,7 +47,7 @@ describe('instrument', () => { const input = `function (a) { return 5; }`; expect(instrument(input, 'test.ts')).toContain( astPrettyPrint( - `declare function $_$twiz(name: string, value: any, pos: number, filename: string, opts: any): void`, + `declare function $_$twiz(value: any, pos: number, filename: string, opts: any, hash: string): void`, ), ); }); @@ -59,7 +66,7 @@ describe('instrument', () => { instrumentCallExpressions: true, skipTwizDeclarations: true, }), - ).toMatch(`foo($_$twiz.track(bar, "test.ts", 4))`); + ).toMatch(`foo($_$twiz.track(bar, "test.ts", 4, "82792dee0fbe844bbbea67736c26447d"))`); }); it('should not instrument numeric arguments in function calls', () => { diff --git a/packages/typewiz-core/src/integration.spec.ts b/packages/typewiz-core/src/integration.spec.ts index 6c0c39b..cb65557 100644 --- a/packages/typewiz-core/src/integration.spec.ts +++ b/packages/typewiz-core/src/integration.spec.ts @@ -13,7 +13,12 @@ jest.doMock('fs', () => mockFs); import { applyTypes, getTypeCollectorSnippet, IApplyTypesOptions, IInstrumentOptions, instrument } from './index'; -function typeWiz(input: string, typeCheck = false, options?: IApplyTypesOptions) { +interface IIntegrationTestOptions { + typeCheck?: boolean; + applyTwice?: boolean; +} + +function typeWiz(input: string, options?: IApplyTypesOptions & IIntegrationTestOptions) { // setup options to allow using the TypeChecker if (options && options.tsConfig) { options.tsCompilerHost = virtualCompilerHost(input, 'c:/test.ts'); @@ -34,11 +39,12 @@ function typeWiz(input: string, typeCheck = false, options?: IApplyTypesOptions) } as IInstrumentOptions); // Step 2: compile + add the type collector - const compiled = typeCheck - ? transpileSource(instrumented, 'test.ts') - : ts.transpile(instrumented, { - target: ts.ScriptTarget.ES2015, - }); + const compiled = + options && options.typeCheck + ? transpileSource(instrumented, 'test.ts') + : ts.transpile(instrumented, { + target: ts.ScriptTarget.ES2015, + }); // Step 3: evaluate the code, and collect the runtime type information const collectedTypes = vm.runInNewContext(getTypeCollectorSnippet() + compiled + ';$_$twiz.get();'); @@ -48,6 +54,10 @@ function typeWiz(input: string, typeCheck = false, options?: IApplyTypesOptions) mockFs.writeFileSync.mockImplementationOnce(() => 0); applyTypes(collectedTypes, options); + if (options && options.applyTwice) { + mockFs.readFileSync.mockReturnValue(mockFs.writeFileSync.mock.calls[0][1]); + applyTypes(collectedTypes, options); + } if (options && options.tsConfig) { expect(options.tsConfigHost!.readFile).toHaveBeenCalledWith(options.tsConfig); @@ -81,7 +91,7 @@ describe('function parameters', () => { greeter.sayGreeting(); `; - expect(typeWiz(input, true, { tsConfig: 'tsconfig.integration.json' })).toBe(` + expect(typeWiz(input, { typeCheck: true, tsConfig: 'tsconfig.integration.json' })).toBe(` class Greeter { text = "Hello World"; sayGreeting = greet; @@ -107,7 +117,7 @@ describe('function parameters', () => { greeter.sayGreeting('user'); `; - expect(typeWiz(input, true, { tsConfig: 'tsconfig.integration.json' })).toBe(` + expect(typeWiz(input, { typeCheck: true, tsConfig: 'tsconfig.integration.json' })).toBe(` class Greeter { text = "Hello World: "; sayGreeting = greet; @@ -134,7 +144,7 @@ describe('function parameters', () => { const greeter = new Greeter(); greeter.sayGreeting(); `; - expect(typeWiz(input, true, { tsConfig: 'tsconfig.integration.json' })).toBe(` + expect(typeWiz(input, { typeCheck: true, tsConfig: 'tsconfig.integration.json' })).toBe(` class Greeter { text: string; constructor(){ @@ -157,7 +167,7 @@ describe('function parameters', () => { greet('World'); `; - expect(typeWiz(input, true)).toBe(` + expect(typeWiz(input, { typeCheck: true })).toBe(` function greet(c: string) { return 'Hello ' + c; } @@ -314,7 +324,7 @@ describe('function parameters', () => { f(arr); `; - expect(typeWiz(input, false, { tsConfig: 'tsconfig.integration.json' })).toBe(` + expect(typeWiz(input, { tsConfig: 'tsconfig.integration.json' })).toBe(` function f(a: string[]) { } @@ -333,7 +343,7 @@ describe('function parameters', () => { f(promise); `; - expect(typeWiz(input, true, { tsConfig: 'tsconfig.integration.json' })).toBe(` + expect(typeWiz(input, { typeCheck: true, tsConfig: 'tsconfig.integration.json' })).toBe(` function f(a: Promise) { return a; } @@ -353,7 +363,7 @@ describe('function parameters', () => { f(val); `; - expect(typeWiz(input, true, { tsConfig: 'tsconfig.integration.json' })).toBe(` + expect(typeWiz(input, { typeCheck: true, tsConfig: 'tsconfig.integration.json' })).toBe(` function f(a: {}) { return a; } @@ -436,7 +446,7 @@ describe('class fields', () => { const foo = new Foo(); `; - expect(typeWiz(input, true)).toBe(` + expect(typeWiz(input, { typeCheck: true })).toBe(` class Foo { readonly someValue: number; constructor() { @@ -550,8 +560,21 @@ describe('regression tests', () => { }); }); -describe('apply-types options', () => { - describe('prefix', () => { +describe('apply-types behavior', () => { + it('should fail with an exception when invoked twice', () => { + const input = ` + function greet(name) { + return 'Hello, ' + name; + } + greet('Uri'); + `; + + expect(() => typeWiz(input, { applyTwice: true })).toThrowError( + 'Hash mismatch! Source file has changed since type information was collected', + ); + }); + + describe('prefix option', () => { it('should add the given prefix in front of the detected types', () => { const input = ` function greet(c) { @@ -560,7 +583,7 @@ describe('apply-types options', () => { greet('World'); `; - expect(typeWiz(input, false, { prefix: '/*auto*/' })).toBe(` + expect(typeWiz(input, { prefix: '/*auto*/' })).toBe(` function greet(c: /*auto*/string) { return 'Hello ' + c; } diff --git a/packages/typewiz-core/src/transformer.ts b/packages/typewiz-core/src/transformer.ts index 84246ba..162b94c 100644 --- a/packages/typewiz-core/src/transformer.ts +++ b/packages/typewiz-core/src/transformer.ts @@ -1,11 +1,12 @@ +import * as md5 from 'md5'; import * as ts from 'typescript'; import { IExtraOptions, IInstrumentOptions } from './instrument'; const declaration = ` - declare function $_$twiz(name: string, value: any, pos: number, filename: string, opts: any): void; + declare function $_$twiz(value: any, pos: number, filename: string, opts: any, hash: string): void; declare namespace $_$twiz { - function track(value: T, filename: string, offset: number): T; - function track(value: any, filename: string, offset: number): any; + function track(value: T, filename: string, offset: number, hash: string): T; + function track(value: any, filename: string, offset: number, hash: string): any; } `; @@ -112,17 +113,23 @@ function needsThisInstrumentation( ); } -function createTwizInstrumentStatement(name: string, fileOffset: number, filename: string, opts: IExtraOptions) { +function createTwizInstrumentStatement( + name: string, + fileOffset: number, + filename: string, + opts: IExtraOptions, + hash: string, +) { return ts.createStatement( ts.createCall( ts.createIdentifier('$_$twiz'), [], [ - ts.createLiteral(name), ts.createIdentifier(name), ts.createNumericLiteral(fileOffset.toString()), ts.createLiteral(filename), ts.createLiteral(JSON.stringify(opts)), + ts.createLiteral(hash), ], ), ); @@ -158,6 +165,7 @@ function visitorFactory( options: IInstrumentOptions, semanticDiagnostics?: ReadonlyArray, ) { + const hash = md5(source.getFullText()); const visitor: ts.Visitor = (originalNode: ts.Node): ts.Node | ts.Node[] => { const node = ts.visitEachChild(originalNode, visitor, ctx); @@ -175,7 +183,7 @@ function visitorFactory( } instrumentStatements.push( - createTwizInstrumentStatement('this', node.parameters.pos, source.fileName, opts), + createTwizInstrumentStatement('this', node.parameters.pos, source.fileName, opts, hash), ); } @@ -191,7 +199,7 @@ function visitorFactory( } const parameterName = getParameterName(param); instrumentStatements.push( - createTwizInstrumentStatement(parameterName, typeInsertionPos, source.fileName, opts), + createTwizInstrumentStatement(parameterName, typeInsertionPos, source.fileName, opts, hash), ); } } @@ -223,6 +231,7 @@ function visitorFactory( arg, ts.createLiteral(source.fileName), ts.createNumericLiteral(arg.getStart().toString()), + ts.createLiteral(hash), ], ), ); @@ -281,7 +290,7 @@ function visitorFactory( ), ], ts.createBlock([ - createTwizInstrumentStatement(node.name.text, typeInsertionPos, source.fileName, {}), + createTwizInstrumentStatement(node.name.text, typeInsertionPos, source.fileName, {}, hash), // assign value to privatePropName ts.createStatement( ts.createAssignment( diff --git a/packages/typewiz-core/src/type-collector-snippet.ts b/packages/typewiz-core/src/type-collector-snippet.ts index 9bc2db7..4581c2b 100644 --- a/packages/typewiz-core/src/type-collector-snippet.ts +++ b/packages/typewiz-core/src/type-collector-snippet.ts @@ -3,20 +3,23 @@ import { IExtraOptions } from './instrument'; class NestError extends Error {} export type ISourceLocation = [string, number]; /* filename, offset */ +export type ISourceLocationAndType = [string, number, string | null]; /* filename, offset, type */ -export type ICollectedTypeEntry = [ - string /* filename */, - number /* offset */, - Array<[string | undefined, ISourceLocation | undefined]> /* discovered types */, - IExtraOptions -]; +export interface ICollectedTypeEntry extends IExtraOptions { + types: Array; +} + +export interface IFileTypeInfo { + [key: number]: ICollectedTypeEntry; + hash: string; +} -export type ICollectedTypeInfo = ICollectedTypeEntry[]; +export interface IHashDictionary { + [key: string]: string; +} -interface IKey { - filename: string; - pos: number; - opts: ICollectedTypeInfo; +export interface ICollectedTypeInfo { + [key: string]: IFileTypeInfo; } let typeNameRunning = false; @@ -130,20 +133,50 @@ function escapeSpecialKey(key: string) { } return key; } -const logs: { [key: string]: Set } = {}; + +const collectedTypes: ICollectedTypeInfo = {}; const trackedObjects = new WeakMap(); -export function $_$twiz(name: string, value: any, pos: number, filename: string, optsJson: string) { +function getOrCreateFile(name: string, hash: string) { + if (!collectedTypes[name]) { + collectedTypes[name] = { + hash, + }; + } + return collectedTypes[name]; +} + +export function $_$twiz(value: any, pos: number, filename: string, optsJson: string, hash: string) { const opts = JSON.parse(optsJson) as ICollectedTypeInfo; - const objectDeclaration = trackedObjects.get(value); - const index = JSON.stringify({ filename, pos, opts } as IKey); + const objectDeclaration = trackedObjects.get(value) || null; try { const typeName = getTypeName(value); - if (!logs[index]) { - logs[index] = new Set(); + const fileInfo = getOrCreateFile(filename, hash); + if (!fileInfo[pos]) { + fileInfo[pos] = { + types: [], + ...opts, + }; + } + const typeOptions = fileInfo[pos].types; + if (objectDeclaration) { + const [source, offset] = objectDeclaration; + if ( + !typeOptions.find((item) => { + if (typeof item !== 'string') { + const [s, o, t] = item; + return s === source && o === offset && t === typeName; + } + return false; + }) + ) { + typeOptions.push([source, offset, typeName]); + } + } else if (typeName) { + if (typeOptions.indexOf(typeName) < 0) { + typeOptions.push(typeName); + } } - const typeSpec = JSON.stringify([typeName, objectDeclaration]); - logs[index].add(typeSpec); } catch (e) { if (e instanceof NestError) { // simply ignore the type @@ -156,14 +189,9 @@ export function $_$twiz(name: string, value: any, pos: number, filename: string, // tslint:disable:no-namespace export namespace $_$twiz { export const typeName = getTypeName; - export const get = () => { - return Object.keys(logs).map((key) => { - const { filename, pos, opts } = JSON.parse(key) as IKey; - const typeOptions = Array.from(logs[key]).map((v) => JSON.parse(v)); - return [filename, pos, typeOptions, opts] as ICollectedTypeEntry; - }); - }; - export const track = (value: any, filename: string, offset: number) => { + export const get = () => collectedTypes; + export const track = (value: any, filename: string, offset: number, hash: string) => { + getOrCreateFile(filename, hash); if (value && (typeof value === 'object' || typeof value === 'function')) { trackedObjects.set(value, [filename, offset]); } diff --git a/yarn.lock b/yarn.lock index df076ce..b1be91a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -672,6 +672,13 @@ "@types/node" "*" "@types/webpack" "*" +"@types/md5@^2.1.33": + version "2.1.33" + resolved "https://registry.yarnpkg.com/@types/md5/-/md5-2.1.33.tgz#8c8dba30df4ad0e92296424f08c4898dd808e8df" + integrity sha512-8+X960EtKLoSblhauxLKy3zzotagjoj3Jt1Tx9oaxUdZEPIBl+mkrUz6PNKpzJgkrKSN9YgkWTA29c0KnLshmA== + dependencies: + "@types/node" "*" + "@types/mime@*": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.0.tgz#5a7306e367c539b9f6543499de8dd519fac37a8b" @@ -1554,6 +1561,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +charenc@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc= + chokidar@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" @@ -1969,6 +1981,11 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" +crypt@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs= + cryptiles@2.x.x: version "2.0.5" resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-2.0.5.tgz#3bdfecdc608147c1c67202fa291e7dca59eaa3b8" @@ -3429,7 +3446,7 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" -is-buffer@^1.1.5: +is-buffer@^1.1.5, is-buffer@~1.1.1: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== @@ -4706,6 +4723,15 @@ matcher@^1.0.0: dependencies: escape-string-regexp "^1.0.4" +md5@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" + integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk= + dependencies: + charenc "~0.0.1" + crypt "~0.0.1" + is-buffer "~1.1.1" + mem@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"