Skip to content
This repository was archived by the owner on Oct 5, 2021. It is now read-only.

feat(core): keep track of original source file content by hashing #88

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/typewiz-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
57 changes: 33 additions & 24 deletions packages/typewiz-core/src/apply-types.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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);
}
}
}
17 changes: 12 additions & 5 deletions packages/typewiz-core/src/instrument.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; } }`,
),
);
});

Expand All @@ -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`,
),
);
});
Expand All @@ -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', () => {
Expand Down
57 changes: 40 additions & 17 deletions packages/typewiz-core/src/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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();');
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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(){
Expand All @@ -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;
}
Expand Down Expand Up @@ -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[]) {
}

Expand All @@ -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<number>) {
return a;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down
25 changes: 17 additions & 8 deletions packages/typewiz-core/src/transformer.ts
Original file line number Diff line number Diff line change
@@ -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<T>(value: T, filename: string, offset: number): T;
function track(value: any, filename: string, offset: number): any;
function track<T>(value: T, filename: string, offset: number, hash: string): T;
function track(value: any, filename: string, offset: number, hash: string): any;
}
`;

Expand Down Expand Up @@ -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),
],
),
);
Expand Down Expand Up @@ -158,6 +165,7 @@ function visitorFactory(
options: IInstrumentOptions,
semanticDiagnostics?: ReadonlyArray<ts.Diagnostic>,
) {
const hash = md5(source.getFullText());
const visitor: ts.Visitor = (originalNode: ts.Node): ts.Node | ts.Node[] => {
const node = ts.visitEachChild(originalNode, visitor, ctx);

Expand All @@ -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),
);
}

Expand All @@ -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),
);
}
}
Expand Down Expand Up @@ -223,6 +231,7 @@ function visitorFactory(
arg,
ts.createLiteral(source.fileName),
ts.createNumericLiteral(arg.getStart().toString()),
ts.createLiteral(hash),
],
),
);
Expand Down Expand Up @@ -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(
Expand Down
Loading