diff --git a/.dependency-cruiser.json b/.dependency-cruiser.json index 7b39d0ddf..4aa39be77 100644 --- a/.dependency-cruiser.json +++ b/.dependency-cruiser.json @@ -11,6 +11,15 @@ "path": "^packages/[^/]+/test/" } }, + { + "name": "not-to-spec", + "comment": "Don't allow dependencies to spec files", + "severity": "error", + "from": {}, + "to": { + "path": "\\.spec\\.[jt]s$" + } + }, { "name": "not-outside-package", "comment": "Don't allow packages to anything outside of packages", diff --git a/baselines/packages/wotan/api/index.d.ts b/baselines/packages/wotan/api/index.d.ts index b58595780..989fb45ce 100644 --- a/baselines/packages/wotan/api/index.d.ts +++ b/baselines/packages/wotan/api/index.d.ts @@ -11,10 +11,13 @@ export * from './src/services/default/line-switches'; export * from './src/services/default/message-handler'; export * from './src/services/default/resolver'; export * from './src/services/default/rule-loader-host'; +export * from './src/services/default/state-persistence'; export * from './src/services/cached-file-system'; export * from './src/services/configuration-manager'; +export * from './src/services/dependency-resolver'; export * from './src/services/formatter-loader'; export * from './src/services/processor-loader'; +export * from './src/services/program-state'; export * from './src/services/rule-loader'; export { parseGlobalOptions, ParsedGlobalOptions, GLOBAL_OPTIONS_SPEC } from './src/argparse'; export * from './src/baseline'; diff --git a/baselines/packages/wotan/api/packlist.txt b/baselines/packages/wotan/api/packlist.txt index 2e8802d11..c8064c366 100644 --- a/baselines/packages/wotan/api/packlist.txt +++ b/baselines/packages/wotan/api/packlist.txt @@ -93,12 +93,21 @@ src/services/default/resolver.js.map src/services/default/rule-loader-host.d.ts src/services/default/rule-loader-host.js src/services/default/rule-loader-host.js.map +src/services/default/state-persistence.d.ts +src/services/default/state-persistence.js +src/services/default/state-persistence.js.map +src/services/dependency-resolver.d.ts +src/services/dependency-resolver.js +src/services/dependency-resolver.js.map src/services/formatter-loader.d.ts src/services/formatter-loader.js src/services/formatter-loader.js.map src/services/processor-loader.d.ts src/services/processor-loader.js src/services/processor-loader.js.map +src/services/program-state.d.ts +src/services/program-state.js +src/services/program-state.js.map src/services/rule-loader.d.ts src/services/rule-loader.js src/services/rule-loader.js.map diff --git a/baselines/packages/wotan/api/src/argparse.d.ts b/baselines/packages/wotan/api/src/argparse.d.ts index b39d52d08..8bc9631bd 100644 --- a/baselines/packages/wotan/api/src/argparse.d.ts +++ b/baselines/packages/wotan/api/src/argparse.d.ts @@ -12,6 +12,7 @@ export declare const GLOBAL_OPTIONS_SPEC: { exclude: OptionParser.ParseFunction; project: OptionParser.ParseFunction; references: OptionParser.ParseFunction; + cache: OptionParser.ParseFunction; formatter: OptionParser.ParseFunction; fix: OptionParser.ParseFunction; extensions: OptionParser.ParseFunction; diff --git a/baselines/packages/wotan/api/src/linter.d.ts b/baselines/packages/wotan/api/src/linter.d.ts index 0cd38a475..d4034e5ed 100644 --- a/baselines/packages/wotan/api/src/linter.d.ts +++ b/baselines/packages/wotan/api/src/linter.d.ts @@ -20,5 +20,7 @@ export declare type UpdateFileCallback = (content: string, range: ts.TextChangeR export declare class Linter { constructor(ruleLoader: RuleLoader, logger: MessageHandler, deprecationHandler: DeprecationHandler, filterFactory: FindingFilterFactory); lintFile(file: ts.SourceFile, config: EffectiveConfiguration, programOrFactory?: ProgramFactory | ts.Program, options?: LinterOptions): ReadonlyArray; - lintAndFix(file: ts.SourceFile, content: string, config: EffectiveConfiguration, updateFile: UpdateFileCallback, iterations?: number, programFactory?: ProgramFactory, processor?: AbstractProcessor, options?: LinterOptions): LintAndFixFileResult; + lintAndFix(file: ts.SourceFile, content: string, config: EffectiveConfiguration, updateFile: UpdateFileCallback, iterations?: number, programFactory?: ProgramFactory, processor?: AbstractProcessor, options?: LinterOptions, + /** Initial set of findings from a cache. If provided, the initial linting is skipped and these findings are used for fixing. */ + findings?: readonly Finding[]): LintAndFixFileResult; } diff --git a/baselines/packages/wotan/api/src/runner.d.ts b/baselines/packages/wotan/api/src/runner.d.ts index 019fa5ee5..90cac8ffe 100644 --- a/baselines/packages/wotan/api/src/runner.d.ts +++ b/baselines/packages/wotan/api/src/runner.d.ts @@ -1,9 +1,9 @@ import { Linter } from './linter'; import { LintResult, DirectoryService, MessageHandler, FileFilterFactory, Severity } from '@fimbul/ymir'; -import * as ts from 'typescript'; import { ProcessorLoader } from './services/processor-loader'; import { CachedFileSystem } from './services/cached-file-system'; import { ConfigurationManager } from './services/configuration-manager'; +import { ProgramStateFactory } from './services/program-state'; export interface LintOptions { config: string | undefined; files: ReadonlyArray; @@ -13,15 +13,9 @@ export interface LintOptions { fix: boolean | number; extensions: ReadonlyArray | undefined; reportUselessDirectives: Severity | boolean | undefined; + cache: boolean; } export declare class Runner { - constructor(fs: CachedFileSystem, configManager: ConfigurationManager, linter: Linter, processorLoader: ProcessorLoader, directories: DirectoryService, logger: MessageHandler, filterFactory: FileFilterFactory); + constructor(fs: CachedFileSystem, configManager: ConfigurationManager, linter: Linter, processorLoader: ProcessorLoader, directories: DirectoryService, logger: MessageHandler, filterFactory: FileFilterFactory, programStateFactory: ProgramStateFactory); lintCollection(options: LintOptions): LintResult; } -declare module 'typescript' { - function matchFiles(path: string, extensions: ReadonlyArray, excludes: ReadonlyArray | undefined, includes: ReadonlyArray, useCaseSensitiveFileNames: boolean, currentDirectory: string, depth: number | undefined, getFileSystemEntries: (path: string) => ts.FileSystemEntries, realpath: (path: string) => string): string[]; - interface FileSystemEntries { - readonly files: ReadonlyArray; - readonly directories: ReadonlyArray; - } -} diff --git a/baselines/packages/wotan/api/src/services/default/state-persistence.d.ts b/baselines/packages/wotan/api/src/services/default/state-persistence.d.ts new file mode 100644 index 000000000..cd6884db6 --- /dev/null +++ b/baselines/packages/wotan/api/src/services/default/state-persistence.d.ts @@ -0,0 +1,7 @@ +import { CachedFileSystem } from '../cached-file-system'; +import { StatePersistence, StaticProgramState } from '@fimbul/ymir'; +export declare class DefaultStatePersistence implements StatePersistence { + constructor(fs: CachedFileSystem); + loadState(project: string): StaticProgramState | undefined; + saveState(project: string, state: StaticProgramState): void; +} diff --git a/baselines/packages/wotan/api/src/services/dependency-resolver.d.ts b/baselines/packages/wotan/api/src/services/dependency-resolver.d.ts new file mode 100644 index 000000000..257df3670 --- /dev/null +++ b/baselines/packages/wotan/api/src/services/dependency-resolver.d.ts @@ -0,0 +1,12 @@ +import * as ts from 'typescript'; +export interface DependencyResolver { + update(program: ts.Program, updatedFile: string): void; + getDependencies(fileName: string): ReadonlyMap; + getFilesAffectingGlobalScope(): readonly string[]; +} +export declare type DependencyResolverHost = Required> & { + useSourceOfProjectReferenceRedirect?(): boolean; +}; +export declare class DependencyResolverFactory { + create(host: DependencyResolverHost, program: ts.Program): DependencyResolver; +} diff --git a/baselines/packages/wotan/api/src/services/program-state.d.ts b/baselines/packages/wotan/api/src/services/program-state.d.ts new file mode 100644 index 000000000..7510a7b82 --- /dev/null +++ b/baselines/packages/wotan/api/src/services/program-state.d.ts @@ -0,0 +1,23 @@ +import * as ts from 'typescript'; +import { DependencyResolver, DependencyResolverFactory, DependencyResolverHost } from './dependency-resolver'; +import { Finding, StatePersistence } from '@fimbul/ymir'; +export interface ProgramState { + update(program: ts.Program, updatedFile: string): void; + getUpToDateResult(fileName: string, configHash: string): readonly Finding[] | undefined; + setFileResult(fileName: string, configHash: string, result: readonly Finding[]): void; + save(): void; +} +export declare class ProgramStateFactory { + constructor(resolverFactory: DependencyResolverFactory, statePersistence: StatePersistence); + create(program: ts.Program, host: ProgramStateHost & DependencyResolverHost, tsconfigPath: string): ProgramStateImpl; +} +export declare type ProgramStateHost = Pick; +declare const oldStateSymbol: unique symbol; +declare class ProgramStateImpl implements ProgramState { + constructor(host: ProgramStateHost, program: ts.Program, resolver: DependencyResolver, statePersistence: StatePersistence, project: string); + update(program: ts.Program, updatedFile: string): void; + getUpToDateResult(fileName: string, configHash: string): readonly Finding[] | undefined; + setFileResult(fileName: string, configHash: string, result: ReadonlyArray): void; + save(): void; +} +export {}; diff --git a/baselines/packages/wotan/test/program-state.spec.ts.md b/baselines/packages/wotan/test/program-state.spec.ts.md new file mode 100644 index 000000000..0aa83d06e --- /dev/null +++ b/baselines/packages/wotan/test/program-state.spec.ts.md @@ -0,0 +1,215 @@ +# Snapshot report for `packages/wotan/test/program-state.spec.ts` + +The actual snapshot is saved in `program-state.spec.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## saves old state + +> Snapshot 1 + + `cs: false␊ + files:␊ + - config: '1234'␊ + hash: '-2704852577'␊ + result: []␊ + - config: '1234'␊ + dependencies:␊ + ./c:␊ + - 0␊ + hash: '-5185547329'␊ + result: []␊ + - dependencies:␊ + ./c:␊ + - 0␊ + hash: '-5185547329'␊ + - dependencies:␊ + ./b:␊ + - 1␊ + ./d:␊ + - 2␊ + hash: '-2126001415'␊ + global: []␊ + lookup:␊ + a.ts: 3␊ + b.ts: 1␊ + c.ts: 0␊ + d.ts: 2␊ + options: '-1350339532'␊ + v: 1␊ + ` + +> Snapshot 2 + + `cs: false␊ + files:␊ + - config: '1234'␊ + hash: '-2704852577'␊ + result: []␊ + - config: '1234'␊ + dependencies:␊ + ./c:␊ + - 0␊ + hash: '-5185547329'␊ + result: []␊ + - dependencies:␊ + "\\0":␊ + - 3␊ + hash: '5381'␊ + - dependencies:␊ + ./e:␊ + - 2␊ + - 3␊ + hash: '910822549'␊ + - dependencies:␊ + ./b:␊ + - 1␊ + ./d:␊ + - 3␊ + hash: '-2126001415'␊ + global: []␊ + lookup:␊ + a.ts: 4␊ + b.ts: 1␊ + c.ts: 0␊ + d.ts: 3␊ + e.ts: 2␊ + options: '-1350339532'␊ + v: 1␊ + ` + +> Snapshot 3 + + `cs: false␊ + files:␊ + - hash: '-2704852577'␊ + - dependencies:␊ + ./c:␊ + - 0␊ + hash: '-5185547329'␊ + - dependencies:␊ + "\\0":␊ + - 3␊ + e: null␊ + hash: '8844149038'␊ + - dependencies:␊ + ./e:␊ + - 2␊ + - 3␊ + hash: '910822549'␊ + - dependencies:␊ + ./b:␊ + - 1␊ + ./d:␊ + - 3␊ + hash: '-2126001415'␊ + global:␊ + - 2␊ + lookup:␊ + a.ts: 4␊ + b.ts: 1␊ + c.ts: 0␊ + d.ts: 3␊ + e.ts: 2␊ + options: '-1350339532'␊ + v: 1␊ + ` + +## doesn't discard results from old state + +> Snapshot 1 + + `cs: false␊ + files:␊ + - config: '1234'␊ + hash: '-3360789062'␊ + result: []␊ + - config: '1234'␊ + hash: '574235295'␊ + result: []␊ + - dependencies:␊ + ./c:␊ + - 1␊ + hash: '-5185547329'␊ + global:␊ + - 1␊ + lookup:␊ + a.ts: 0␊ + b.ts: 2␊ + c.ts: 1␊ + options: '5864093'␊ + v: 1␊ + ` + +> Snapshot 2 + + `cs: false␊ + files:␊ + - hash: '-3360789062'␊ + - hash: '4830905933'␊ + - dependencies:␊ + ./c:␊ + - 1␊ + hash: '-5185547329'␊ + global:␊ + - 1␊ + lookup:␊ + a.ts: 0␊ + b.ts: 2␊ + c.ts: 1␊ + options: '5864093'␊ + v: 1␊ + ` + +## handles circular dependencies + +> Snapshot 1 + + `cs: false␊ + files:␊ + - config: '1234'␊ + hash: '-3360789062'␊ + result: []␊ + - dependencies:␊ + ./d:␊ + - 3␊ + hash: '-2335134369'␊ + - dependencies:␊ + ./a:␊ + - 4␊ + ./c:␊ + - 1␊ + ./d:␊ + - 3␊ + hash: '-10011708814'␊ + - dependencies:␊ + ./b:␊ + - 2␊ + ./e:␊ + - 0␊ + hash: '970287226'␊ + - dependencies:␊ + ./b:␊ + - 2␊ + ./c:␊ + - 1␊ + ./d:␊ + - 3␊ + ./e:␊ + - 0␊ + hash: '3028017583'␊ + - dependencies:␊ + ./a:␊ + - 4␊ + hash: '-5185549507'␊ + global: []␊ + lookup:␊ + a.ts: 4␊ + b.ts: 2␊ + c.ts: 1␊ + d.ts: 3␊ + e.ts: 0␊ + root.ts: 5␊ + options: '5864093'␊ + v: 1␊ + ` diff --git a/baselines/packages/wotan/test/program-state.spec.ts.snap b/baselines/packages/wotan/test/program-state.spec.ts.snap new file mode 100644 index 000000000..3432dcf60 Binary files /dev/null and b/baselines/packages/wotan/test/program-state.spec.ts.snap differ diff --git a/baselines/packages/wotan/test/runner.spec.ts.md b/baselines/packages/wotan/test/runner.spec.ts.md index 55132bf05..a5802d6d0 100644 --- a/baselines/packages/wotan/test/runner.spec.ts.md +++ b/baselines/packages/wotan/test/runner.spec.ts.md @@ -2,7 +2,7 @@ The actual snapshot is saved in `runner.spec.ts.snap`. -Generated by [AVA](https://ava.li). +Generated by [AVA](https://avajs.dev). ## multi-project @@ -39,3 +39,165 @@ Generated by [AVA](https://ava.li). }, ], ] + +## cache + + [ + [ + 'a.ts', + { + content: `console.log('foo');␊ + `, + findings: [], + fixes: 0, + }, + ], + [ + 'b.ts', + { + content: `debugger;␊ + // wotan-disable-next-line␊ + `, + findings: [ + { + end: { + character: 9, + line: 0, + position: 9, + }, + fix: { + replacements: [ + { + end: 9, + start: 0, + text: '', + }, + ], + }, + message: '\'debugger\' statements are forbidden.', + ruleName: 'no-debugger', + severity: 'error', + start: { + character: 0, + line: 0, + position: 0, + }, + }, + { + end: { + character: 26, + line: 1, + position: 36, + }, + fix: { + replacements: [ + { + end: 36, + start: 10, + text: '', + }, + ], + }, + message: 'Disable switch has no effect. All specified rules have no failures to disable.', + ruleName: 'useless-line-switch', + severity: 'error', + start: { + character: 0, + line: 1, + position: 10, + }, + }, + ], + fixes: 0, + }, + ], + ] + +## cache-outdated + + [ + [ + 'a.ts', + { + content: `console.log('foo');␊ + `, + findings: [], + fixes: 0, + }, + ], + [ + 'b.ts', + { + content: `debugger;␊ + // wotan-disable-next-line␊ + `, + findings: [ + { + end: { + character: 9, + line: 0, + position: 9, + }, + fix: { + replacements: [ + { + end: 9, + start: 0, + text: '', + }, + ], + }, + message: '\'debugger\' statements are forbidden.', + ruleName: 'no-debugger', + severity: 'error', + start: { + character: 0, + line: 0, + position: 0, + }, + }, + ], + fixes: 0, + }, + ], + ] + +## updated-state + + `files:␊ + - hash: '5901983184'␊ + - config: '-10079848193'␊ + hash: '5860249'␊ + result: []␊ + global:␊ + - 0␊ + lookup:␊ + a.ts: 0␊ + b.ts: 1␊ + options: '-10679607802'␊ + v: 1␊ + ` + +## cache-fix + + [ + [ + 'a.ts', + { + content: `console.log('foo');␊ + `, + findings: [], + fixes: 0, + }, + ], + [ + 'b.ts', + { + content: `␊ + ␊ + `, + findings: [], + fixes: 2, + }, + ], + ] diff --git a/baselines/packages/wotan/test/runner.spec.ts.snap b/baselines/packages/wotan/test/runner.spec.ts.snap index 4f4d644dc..1111b3a2f 100644 Binary files a/baselines/packages/wotan/test/runner.spec.ts.snap and b/baselines/packages/wotan/test/runner.spec.ts.snap differ diff --git a/baselines/packages/ymir/api/src/index.d.ts b/baselines/packages/ymir/api/src/index.d.ts index fef9657e3..b50857bdb 100644 --- a/baselines/packages/ymir/api/src/index.d.ts +++ b/baselines/packages/ymir/api/src/index.d.ts @@ -363,3 +363,44 @@ export interface FileFilter { /** @returns `true` if the file should be linted, false if it should be filtered out. Intended for use in `Array.prototype.filter`. */ filter(file: ts.SourceFile): boolean; } +export interface StatePersistence { + loadState(project: string): StaticProgramState | undefined; + saveState(project: string, state: StaticProgramState): void; +} +export declare abstract class StatePersistence { +} +export interface StaticProgramState { + /** Version of the cache format */ + readonly v: number; + /** TypeScript version */ + readonly ts: string; + /** Whether the state was created using case-sensitive file names */ + readonly cs: boolean; + /** Hash of compilerOptions */ + readonly options: string; + /** Maps filename to index in 'files' array */ + readonly lookup: Readonly>; + /** Index of files that affect global scope */ + readonly global: readonly number[]; + /** Information about all files in the program */ + readonly files: readonly StaticProgramState.FileState[]; +} +export declare namespace StaticProgramState { + interface FileState { + /** Hash of file contents */ + readonly hash: string; + /** + * Key: module specifier as referenced in the file, order may be random + * Value: - `null` if dependency could not be resolved + * - List of files (or rather their index) that the module specifier resolves to. + * That is the actual file at that path and/or files containing `declare module "..."` for that module specifier. + * May contain the current file. + * This list is ordered by the hash of the files ascending, + */ + readonly dependencies?: Readonly>; + /** The list of findings if this file has up-to-date results */ + readonly result?: readonly Finding[]; + /** Hash of the configuration used to produce `result` for this file */ + readonly config?: string; + } +} diff --git a/docs/api.md b/docs/api.md index 26f89672f..d619a2ad4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,11 +9,13 @@ There are several core services that are provided by Wotan through the Container * `CachedFileSystem` is a wrapper for the low level `FileSystem` service, which caches the file system layout. File contents are not cached. * `ConfigurationManager` is the place for everything related to configuration handling. Internally it uses `ConfigurationProvider` to find, load and parse configuration files. Parsed configuration files are cached. +* `DependencyResolverFactory` creates a service to determine how files in the program affect each other. * `FormatterLoader` loads core and custom formatters via `FormatterLoaderHost`. * `Linter` executes a given set of rules on a SourceFile. It automatically loads enabled rules using `RuleLoader` and filters out disabled findings using `FindingFilterFactory`. `Linter` can also automatically fix findings and return the fixed source code. It does not access the file system. * `ProcessorLoader` loads and caches processors using `Resolver`. +* `ProgramStateFactory` creates a service to get lint results for up-to-date files from cache and update the cache as necessary. Uses `StatePersistence` to load the cache for the current project. Uses `DependencyResolverFactory` to find out about file dependencies. * `RuleLoader` loads and caches core and custom rules via `RuleLoaderHost`. -* `Runner` is used to lint a collection of files. If you want to lint a project, you provide the path of one or more `tsconfig.json` and it creates the project internally. `Runner` loads the source code from the file system, loads configuration from `ConfigurationManager`, applies processors if specified in the configuration and lints all (matching) files using `Linter`. It uses `FileFilterFactory` to filter out non-user code. +* `Runner` is used to lint a collection of files. If you want to lint a project, you provide the path of one or more `tsconfig.json` and it creates the project internally. `Runner` loads the source code from the file system, loads configuration from `ConfigurationManager`, applies processors if specified in the configuration and lints all (matching) files using `Linter`. It uses `FileFilterFactory` to filter out non-user code. If caching is enabled, it uses `ProgramStateFactory` to load the cached results and update the cache. These core services use other abstractions for the low level tasks. That enables you to change the behavior of certain services without the need to implement the whole thing. The default implementations (targeting the Node.js runtime environment) are provided throug the ContainerModule `DEFAULT_DI_MODULE`. The default implementation is only used if there is no binding for the identifier. @@ -31,6 +33,7 @@ The default implementations (targeting the Node.js runtime environment) are prov * `MessageHandler` is used for user facing messages. `log` is called for the result of a command, `warn` is called everytime a warning event occurs and `error` is used to display exception messages. * `Resolver` (`NodeResolver`) is an abstraction for `require()` and `require.resolve()`. It's used to locate and load external resources (configuration, scripts, ...). * `RuleLoaderHost` (`NodeRuleLoader`) is used to resolve and require a rule. +* `StatePersistence` (`DefaultStatePersistence`) is responsible to load and save the cache for a given `tsconfig.json`. ## Example diff --git a/packages/mimir/src/rules/no-duplicate-case.ts b/packages/mimir/src/rules/no-duplicate-case.ts index 21407eb95..5227f50be 100644 --- a/packages/mimir/src/rules/no-duplicate-case.ts +++ b/packages/mimir/src/rules/no-duplicate-case.ts @@ -68,7 +68,7 @@ export class Rule extends AbstractRule { if (isBigIntLiteral(node)) return [formatPrimitive(prefixFn({base10Value: node.text.slice(0, -1), negative: false}))]; if (node.kind === ts.SyntaxKind.NullKeyword) - return [formatPrimitive(prefixFn(null))]; // tslint:disable-line:no-null-keyword + return [formatPrimitive(prefixFn(null))]; if (isIdentifier(node) && node.originalKeywordKind === ts.SyntaxKind.UndefinedKeyword) return [formatPrimitive(prefixFn(undefined))]; if (node.kind === ts.SyntaxKind.TrueKeyword) @@ -89,7 +89,7 @@ export class Rule extends AbstractRule { } else if (t.flags & ts.TypeFlags.Undefined) { result.add(formatPrimitive(prefixFn(undefined))); } else if (t.flags & ts.TypeFlags.Null) { - result.add(formatPrimitive(prefixFn(null))); // tslint:disable-line:no-null-keyword + result.add(formatPrimitive(prefixFn(null))); } else { return []; } diff --git a/packages/mithotyn/index.ts b/packages/mithotyn/index.ts index 196f7cf85..91ae78096 100644 --- a/packages/mithotyn/index.ts +++ b/packages/mithotyn/index.ts @@ -58,7 +58,7 @@ function createProxy( interceptor: Partial, log: (m: string) => void, ) { - const proxy = Object.create(null); // tslint:disable-line:no-null-keyword + const proxy = Object.create(null); for (const method of Object.keys(ls)) { if (typeof (interceptor)[method] === 'function') { proxy[method] = (...args: any[]) => { diff --git a/packages/valtyr/src/configuration-provider.ts b/packages/valtyr/src/configuration-provider.ts index 9ddc6dde1..44f64d231 100644 --- a/packages/valtyr/src/configuration-provider.ts +++ b/packages/valtyr/src/configuration-provider.ts @@ -44,7 +44,7 @@ export class TslintConfigurationProvider implements ConfigurationProvider { fileName = path.dirname(fileName); let result = this.cache.get(fileName); if (result === undefined && !this.cache.has(fileName)) { - result = TSLint.Configuration.findConfigurationPath(null, fileName); // tslint:disable-line:no-null-keyword + result = TSLint.Configuration.findConfigurationPath(null, fileName); const {root} = path.parse(fileName); // prevent infinite loop when result is on different drive const configDirname = result === undefined || root !== path.parse(result).root ? root : path.dirname(result); diff --git a/packages/wotan/README.md b/packages/wotan/README.md index cd74ef579..19765acc8 100644 --- a/packages/wotan/README.md +++ b/packages/wotan/README.md @@ -159,6 +159,7 @@ To detect unused or redundant comments you can use the `--report-useless-directi * `-p --project ` specifies the path to the `tsconfig.json` file to use. This option is used to find all files contained in your project. It also enables rules that require type information. This option can be used multiple times to specify multiple projects to lint. * `-r --references [true|false]` enables project references. Starting from the project specified with `-p --project` or the `tsconfig.json` in the current directory it will recursively follow all `"references"` and lint those projects. * `--report-useless-directives [true|false|error|warning|suggestion]` reports `// wotan-disable` and `// wotan-enable` comments that are redundant (i.e. rules are already disabled) or unused (there are no findings for the specified rules). Useless directives are reported as lint findings with the specified severity (`true` is converted to `error`). Those findings cannot be disabled by a disable comment. The findings are fixable which allow autofixing when used with the `--fix` option. +* `--cache` enables caching of lint results for projects. Can only be used with `-p --project` option. Read more about [caching](#caching). * `[...FILES]` specifies the files to lint. You can specify paths and glob patterns here. Note that all file paths are relative to the current working directory. Therefore `**/*.ts` doesn't match `../foo.ts`. @@ -233,6 +234,36 @@ Wotan respects these flags, too. That means it will not provide type information This ensures you won't get surprising lint findings caused by funky type inference in those files. You will still get reports for purely syntactic findings, i.e. rules that don't require type information. +### Caching + +Caching is done per project. Hence it requires type information. For every `tsconfig.json` it creates a `tsconfig.fimbullintercache` file that contains the state of the previous run. +The content of this file is not intended for use by other tools. All cache information is relative to the project directory. That means the cache can be checked into your VCS and reused by CI or your collegues. + +If a cache is available for a given project, Wotan can avoid linting files that are known to be unchanged. This can significantly reduce execution time. + +A file is treated as outdated and therefore linted if one of the following is true: + +* TypeScript version changed +* compilerOptions changed +* added, removed or outdated files that affect the global scope +* linter configuration for the file changed +* file has no cached lint result +* file content changed +* module resolution of imports or exports has changed + * dependency is added or removed + * ambient module or module augmentation is added, removed + * dependency is outdated + * if compilerOption `assumeChangesOnlyAffectDirectDependencies` is enabled, only checks direct dependencies + * otherwise recursively checks all transitive dependencies + +The following cases don't work well with caching: + +* rules accessing the file system or environment variables +* rules accessing other files in the project that are not global or dependencies of the current file +* linting the same project with different configurations -> only use caching for one of the configurations +* projects where all files are in the global scope +* updating the linter, rules or their (transitive) dependencies -> you need to manually remove the cache if you expect it to affect the lint result + ### Excluded Files If type information is available Wotan excludes all files you haven't written yourself. The following files are always excluded so you cannot explicitly include them: diff --git a/packages/wotan/index.ts b/packages/wotan/index.ts index b58595780..989fb45ce 100644 --- a/packages/wotan/index.ts +++ b/packages/wotan/index.ts @@ -11,10 +11,13 @@ export * from './src/services/default/line-switches'; export * from './src/services/default/message-handler'; export * from './src/services/default/resolver'; export * from './src/services/default/rule-loader-host'; +export * from './src/services/default/state-persistence'; export * from './src/services/cached-file-system'; export * from './src/services/configuration-manager'; +export * from './src/services/dependency-resolver'; export * from './src/services/formatter-loader'; export * from './src/services/processor-loader'; +export * from './src/services/program-state'; export * from './src/services/rule-loader'; export { parseGlobalOptions, ParsedGlobalOptions, GLOBAL_OPTIONS_SPEC } from './src/argparse'; export * from './src/baseline'; diff --git a/packages/wotan/package.json b/packages/wotan/package.json index ad1f90796..a6c300709 100644 --- a/packages/wotan/package.json +++ b/packages/wotan/package.json @@ -37,6 +37,7 @@ "@types/resolve": "^1.14.0", "@types/semver": "^7.0.0", "escape-string-regexp": "^4.0.0", + "memfs": "^3.2.0", "rimraf": "^3.0.0" }, "dependencies": { @@ -59,7 +60,7 @@ "semver": "^7.0.0", "stable": "^0.1.8", "tslib": "^2.0.0", - "tsutils": "^3.18.0" + "tsutils": "^3.20.0" }, "peerDependencies": { "typescript": ">= 4.0.0 || >= 4.2.0-dev" diff --git a/packages/wotan/src/argparse.ts b/packages/wotan/src/argparse.ts index 117c3b9f3..040b64ce6 100644 --- a/packages/wotan/src/argparse.ts +++ b/packages/wotan/src/argparse.ts @@ -54,6 +54,7 @@ export const GLOBAL_OPTIONS_SPEC = { exclude: OptionParser.Transform.withDefault(OptionParser.Factory.parsePrimitiveOrArray('string'), emptyArray), project: OptionParser.Transform.withDefault(OptionParser.Factory.parsePrimitiveOrArray('string'), emptyArray), references: OptionParser.Transform.withDefault(OptionParser.Factory.parsePrimitive('boolean'), false), + cache: OptionParser.Transform.withDefault(OptionParser.Factory.parsePrimitive('boolean'), false), formatter: OptionParser.Factory.parsePrimitive('string'), fix: OptionParser.Transform.withDefault(OptionParser.Factory.parsePrimitive('boolean', 'number'), false), extensions: OptionParser.Transform.map(OptionParser.Factory.parsePrimitiveOrArray('string'), sanitizeExtensionArgument), @@ -117,6 +118,9 @@ function parseLintCommand( case '--references': ({index: i, argument: result.references} = parseOptionalBoolean(args, i)); break; + case '--cache': + ({index: i, argument: result.cache} = parseOptionalBoolean(args, i)); + break; case '-e': case '--exclude': result.exclude = exclude; @@ -160,13 +164,16 @@ function parseLintCommand( } } + const usesProject = result.project.length !== 0 || result.files.length === 0; if (result.extensions !== undefined) { if (result.extensions.length === 0) { result.extensions = undefined; - } else if (result.project.length !== 0 || result.files.length === 0) { + } else if (usesProject) { throw new ConfigurationError("Options '--ext' and '--project' cannot be used together."); } } + if (result.cache && !usesProject) + throw new ConfigurationError("Option '--cache' can only be used together with '--project'"); return result; } diff --git a/packages/wotan/src/commands/save.ts b/packages/wotan/src/commands/save.ts index 74943504b..a0a46882d 100644 --- a/packages/wotan/src/commands/save.ts +++ b/packages/wotan/src/commands/save.ts @@ -21,6 +21,7 @@ class SaveCommandRunner extends AbstractCommandRunner { { ...this.options, ...config, + cache: config.cache || undefined, fix: config.fix || undefined, reportUselessDirectives: config.reportUselessDirectives || undefined, references: config.references || undefined, diff --git a/packages/wotan/src/di/core.module.ts b/packages/wotan/src/di/core.module.ts index 54a57abe2..7e677a106 100644 --- a/packages/wotan/src/di/core.module.ts +++ b/packages/wotan/src/di/core.module.ts @@ -7,6 +7,8 @@ import { Linter } from '../linter'; import { Runner } from '../runner'; import { ProcessorLoader } from '../services/processor-loader'; import { GlobalOptions } from '@fimbul/ymir'; +import { ProgramStateFactory } from '../services/program-state'; +import { DependencyResolverFactory } from '../services/dependency-resolver'; export function createCoreModule(globalOptions: GlobalOptions) { return new ContainerModule((bind) => { @@ -17,6 +19,8 @@ export function createCoreModule(globalOptions: GlobalOptions) { bind(ProcessorLoader).toSelf(); bind(Linter).toSelf(); bind(Runner).toSelf(); + bind(ProgramStateFactory).toSelf(); + bind(DependencyResolverFactory).toSelf(); bind(GlobalOptions).toConstantValue(globalOptions); }); } diff --git a/packages/wotan/src/di/default.module.ts b/packages/wotan/src/di/default.module.ts index e75930baa..d2360ff02 100644 --- a/packages/wotan/src/di/default.module.ts +++ b/packages/wotan/src/di/default.module.ts @@ -13,6 +13,7 @@ import { LineSwitchParser, BuiltinResolver, FileFilterFactory, + StatePersistence, } from '@fimbul/ymir'; import { NodeFormatterLoader } from '../services/default/formatter-loader-host'; import { NodeRuleLoader } from '../services/default/rule-loader-host'; @@ -26,6 +27,7 @@ import { DefaultConfigurationProvider } from '../services/default/configuration- import { DefaultLineSwitchParser, LineSwitchFilterFactory } from '../services/default/line-switches'; import { DefaultBuiltinResolver } from '../services/default/builtin-resolver'; import { DefaultFileFilterFactory } from '../services/default/file-filter'; +import { DefaultStatePersistence } from '../services/default/state-persistence'; export function createDefaultModule() { return new ContainerModule((bind, _unbind, isBound) => { @@ -55,5 +57,7 @@ export function createDefaultModule() { bind(BuiltinResolver).to(DefaultBuiltinResolver); if (!isBound(FileFilterFactory)) bind(FileFilterFactory).to(DefaultFileFilterFactory); + if (!isBound(StatePersistence)) + bind(StatePersistence).to(DefaultStatePersistence); }); } diff --git a/packages/wotan/src/linter.ts b/packages/wotan/src/linter.ts index cb55380f2..0b7417a2c 100644 --- a/packages/wotan/src/linter.ts +++ b/packages/wotan/src/linter.ts @@ -18,7 +18,7 @@ import { applyFixes } from './fix'; import * as debug from 'debug'; import { injectable } from 'inversify'; import { RuleLoader } from './services/rule-loader'; -import { calculateChangeRange, emptyArray, invertChangeRange } from './utils'; +import { calculateChangeRange, emptyArray, invertChangeRange, mapDefined } from './utils'; import { ConvertedAst, convertAst, isCompilerOptionEnabled, getTsCheckDirective } from 'tsutils'; const log = debug('wotan:linter'); @@ -108,13 +108,14 @@ export class Linter { programFactory?: ProgramFactory, processor?: AbstractProcessor, options: LinterOptions = {}, + /** Initial set of findings from a cache. If provided, the initial linting is skipped and these findings are used for fixing. */ + findings = this.getFindings(file, config, programFactory, processor, options), ): LintAndFixFileResult { let totalFixes = 0; - let findings = this.getFindings(file, config, programFactory, processor, options); for (let i = 0; i < iterations; ++i) { if (findings.length === 0) break; - const fixes = findings.map((f) => f.fix).filter((f: T | undefined): f is T => f !== undefined); + const fixes = mapDefined(findings, (f) => f.fix); if (fixes.length === 0) { log('No fixes'); break; diff --git a/packages/wotan/src/project-host.ts b/packages/wotan/src/project-host.ts index 88656457d..2eaa05957 100644 --- a/packages/wotan/src/project-host.ts +++ b/packages/wotan/src/project-host.ts @@ -12,12 +12,14 @@ const log = debug('wotan:projectHost'); const additionalExtensions = ['.json']; +// @internal export interface ProcessedFileInfo { originalName: string; originalContent: string; // TODO this should move into processor because this property is never updated, but the processor is processor: AbstractProcessor; } +// @internal export class ProjectHost implements ts.CompilerHost { private reverseMap = new Map(); private files: string[] = []; @@ -95,33 +97,34 @@ export class ProjectHost implements ts.CompilerHost { return result; } for (const entry of entries) { - const fileName = `${dir}/${entry.name}`; switch (entry.kind) { case FileKind.File: { + const fileName = `${dir}/${entry.name}`; if (!hasSupportedExtension(fileName, additionalExtensions)) { const c = this.config || this.tryFindConfig(fileName); const processor = c && this.configManager.getProcessor(c, fileName); if (processor) { const ctor = this.processorLoader.loadProcessor(processor); - const newName = fileName + - ctor.getSuffixForFile({ - fileName, - getSettings: () => this.configManager.getSettings(c!, fileName), - readFile: () => this.fs.readFile(fileName), - }); + const suffix = ctor.getSuffixForFile({ + fileName, + getSettings: () => this.configManager.getSettings(c!, fileName), + readFile: () => this.fs.readFile(fileName), + }); + const newName = fileName + suffix; + if (hasSupportedExtension(newName, additionalExtensions)) { - files.push(newName); + files.push(entry.name + suffix); this.reverseMap.set(newName, fileName); break; } } } - files.push(fileName); + files.push(entry.name); this.files.push(fileName); break; } case FileKind.Directory: - directories.push(fileName); + directories.push(entry.name); } } return result; @@ -212,10 +215,10 @@ export class ProjectHost implements ts.CompilerHost { public getDirectories(dir: string) { const cached = this.directoryEntries.get(dir); if (cached !== undefined) - return cached.directories.map((d) => path.posix.basename(d)); + return cached.directories.slice(); return mapDefined( this.fs.readDirectory(dir), - (entry) => entry.kind === FileKind.Directory ? path.join(dir, entry.name) : undefined, + (entry) => entry.kind === FileKind.Directory ? entry.name : undefined, ); } public getSourceFile(fileName: string, languageVersion: ts.ScriptTarget.JSON): ts.JsonSourceFile | undefined; @@ -291,10 +294,36 @@ export class ProjectHost implements ts.CompilerHost { ); } - public resolveModuleNames(names: string[], file: string, _?: unknown, reference?: ts.ResolvedProjectReference) { + public resolveModuleNames( + names: string[], + file: string, + _: unknown | undefined, + reference: ts.ResolvedProjectReference | undefined, + options: ts.CompilerOptions, + ) { const seen = new Map(); const resolve = (name: string) => - ts.resolveModuleName(name, file, this.compilerOptions, this, this.moduleResolutionCache, reference).resolvedModule; + ts.resolveModuleName(name, file, options, this, this.moduleResolutionCache, reference).resolvedModule; return names.map((name) => resolveCachedResult(seen, name, resolve)); } } + +// @internal +declare module 'typescript' { + function matchFiles( + path: string, + extensions: ReadonlyArray, + excludes: ReadonlyArray | undefined, + includes: ReadonlyArray, + useCaseSensitiveFileNames: boolean, + currentDirectory: string, + depth: number | undefined, + getFileSystemEntries: (path: string) => ts.FileSystemEntries, + realpath: (path: string) => string, + ): string[]; + + interface FileSystemEntries { + readonly files: ReadonlyArray; + readonly directories: ReadonlyArray; + } +} diff --git a/packages/wotan/src/runner.ts b/packages/wotan/src/runner.ts index 2397e1937..1032aaca0 100644 --- a/packages/wotan/src/runner.ts +++ b/packages/wotan/src/runner.ts @@ -9,11 +9,13 @@ import { MessageHandler, FileFilterFactory, Severity, + ReducedConfiguration, + EffectiveConfiguration, } from '@fimbul/ymir'; import * as path from 'path'; import * as ts from 'typescript'; import * as glob from 'glob'; -import { unixifyPath, hasSupportedExtension, addUnique, flatMap, hasParseErrors, invertChangeRange } from './utils'; +import { unixifyPath, hasSupportedExtension, addUnique, flatMap, hasParseErrors, invertChangeRange, djb2 } from './utils'; import { Minimatch, IMinimatch } from 'minimatch'; import { ProcessorLoader } from './services/processor-loader'; import { injectable } from 'inversify'; @@ -22,6 +24,7 @@ import { ConfigurationManager } from './services/configuration-manager'; import { ProjectHost } from './project-host'; import debug = require('debug'); import { normalizeGlob } from 'normalize-glob'; +import { ProgramStateFactory } from './services/program-state'; const log = debug('wotan:runner'); @@ -34,6 +37,7 @@ export interface LintOptions { fix: boolean | number; extensions: ReadonlyArray | undefined; reportUselessDirectives: Severity | boolean | undefined; + cache: boolean; } interface NormalizedOptions extends Pick> { @@ -55,6 +59,7 @@ export class Runner { private directories: DirectoryService, private logger: MessageHandler, private filterFactory: FileFilterFactory, + private programStateFactory: ProgramStateFactory, ) {} public lintCollection(options: LintOptions): LintResult { @@ -85,9 +90,10 @@ export class Runner { this.configManager, this.processorLoader, ); - for (let {files, program} of + for (let {files, program, configFilePath: tsconfigPath} of this.getFilesAndProgram(options.project, options.files, options.exclude, processorHost, options.references) ) { + const programState = options.cache ? this.programStateFactory.create(program, processorHost, tsconfigPath) : undefined; let invalidatedProgram = false; const factory: ProgramFactory = { getCompilerOptions() { @@ -114,7 +120,10 @@ export class Runner { const originalContent = mapped === undefined ? sourceFile.text : mapped.originalContent; let summary: FileSummary; const fix = shouldFix(sourceFile, options, originalName); + const configHash = programState === undefined ? undefined : createConfigHash(effectiveConfig, linterOptions); + const resultFromCache = programState?.getUpToDateResult(sourceFile.fileName, configHash!); if (fix) { + let updatedFile = false; summary = this.linter.lintAndFix( sourceFile, originalContent, @@ -127,6 +136,8 @@ export class Runner { if (hasErrors) { log("Autofixing caused syntax errors in '%s', rolling back", sourceFile.fileName); sourceFile = ts.updateSourceFile(sourceFile, oldContent, invertChangeRange(range)); + } else { + updatedFile = true; } // either way we need to store the new SourceFile as the old one is now corrupted processorHost.updateSourceFile(sourceFile); @@ -134,24 +145,31 @@ export class Runner { }, fix === true ? undefined : fix, factory, - mapped === undefined ? undefined : mapped.processor, + mapped?.processor, linterOptions, + // pass cached results so we can apply fixes from cache + resultFromCache, ); + if (updatedFile) + programState?.update(factory.getProgram(), sourceFile.fileName); } else { summary = { - findings: this.linter.getFindings( + findings: resultFromCache ?? this.linter.getFindings( sourceFile, effectiveConfig, factory, - mapped === undefined ? undefined : mapped.processor, + mapped?.processor, linterOptions, ), fixes: 0, content: originalContent, }; } + if (programState !== undefined && resultFromCache !== summary.findings) + programState.setFileResult(file, configHash!, summary.findings); yield [originalName, summary]; } + programState?.save(); } } @@ -241,7 +259,7 @@ export class Runner { exclude: ReadonlyArray, host: ProjectHost, references: boolean, - ): Iterable<{files: Iterable, program: ts.Program}> { + ): Iterable<{files: Iterable, program: ts.Program, configFilePath: string}> { const cwd = unixifyPath(this.directories.getCurrentDirectory()); if (projects.length !== 0) { projects = projects.map((configFile) => this.checkConfigDirectory(unixifyPath(path.resolve(cwd, configFile)))); @@ -269,7 +287,7 @@ export class Runner { const ex = exclude.map((p) => new Minimatch(p, {dot: true})); const projectsSeen: string[] = []; let filesOfPreviousProject: string[] | undefined; - for (const program of this.createPrograms(projects, host, projectsSeen, references, isFileIncluded)) { + for (const {program, configFilePath} of this.createPrograms(projects, host, projectsSeen, references, isFileIncluded)) { const ownFiles = []; const files: string[] = []; const fileFilter = this.filterFactory.create({program, host}); @@ -293,7 +311,7 @@ export class Runner { filesOfPreviousProject = ownFiles; if (files.length !== 0) - yield {files, program}; + yield {files, program, configFilePath}; } ensurePatternsMatch(nonMagicGlobs, ex, allMatchedFiles, projectsSeen); @@ -323,7 +341,7 @@ export class Runner { seen: string[], references: boolean, isFileIncluded: (fileName: string) => boolean, - ): Iterable { + ): Iterable<{program: ts.Program, configFilePath: string}> { for (const configFile of projects) { if (configFile === undefined) continue; @@ -349,7 +367,7 @@ export class Runner { // this is in a nested block to allow garbage collection while recursing const program = host.createProgram(commandLine.fileNames, commandLine.options, undefined, commandLine.projectReferences); - yield program; + yield {program, configFilePath}; if (references) resolvedReferences = program.getResolvedProjectReferences(); } @@ -430,21 +448,25 @@ function shouldFix(sourceFile: ts.SourceFile, options: Pick, return options.fix; } -declare module 'typescript' { - function matchFiles( - path: string, - extensions: ReadonlyArray, - excludes: ReadonlyArray | undefined, - includes: ReadonlyArray, - useCaseSensitiveFileNames: boolean, - currentDirectory: string, - depth: number | undefined, - getFileSystemEntries: (path: string) => ts.FileSystemEntries, - realpath: (path: string) => string, - ): string[]; +function createConfigHash(config: ReducedConfiguration, linterOptions: LinterOptions) { + return '' + djb2(JSON.stringify({ + rules: mapToObject(config.rules, stripRuleConfig), + settings: mapToObject(config.settings, identity), + ...linterOptions, + })); +} - interface FileSystemEntries { - readonly files: ReadonlyArray; - readonly directories: ReadonlyArray; - } +function mapToObject(map: ReadonlyMap, transform: (v: T) => U) { + const result: Record = {}; + for (const [key, value] of map) + result[key] = transform(value); + return result; +} + +function identity(v: T) { + return v; +} + +function stripRuleConfig({rulesDirectories: _ignored, ...rest}: EffectiveConfiguration.RuleConfig) { + return rest; } diff --git a/packages/wotan/src/services/default/state-persistence.ts b/packages/wotan/src/services/default/state-persistence.ts new file mode 100644 index 000000000..2c3608c41 --- /dev/null +++ b/packages/wotan/src/services/default/state-persistence.ts @@ -0,0 +1,52 @@ +import { injectable } from 'inversify'; +import { CachedFileSystem } from '../cached-file-system'; +import debug = require('debug'); +import * as yaml from 'js-yaml'; +import { StatePersistence, StaticProgramState } from '@fimbul/ymir'; + +const log = debug('wotan:statePersistence'); + +interface CacheFileContent { + v: number; + state: StaticProgramState; +} + +const CACHE_VERSION = 1; + +@injectable() +export class DefaultStatePersistence implements StatePersistence { + constructor(private fs: CachedFileSystem) {} + + public loadState(project: string): StaticProgramState | undefined { + const fileName = buildFilename(project); + if (!this.fs.isFile(fileName)) + return; + try { + log("Loading cache from '%s'", fileName); + const content = yaml.load(this.fs.readFile(fileName)); + if (content?.v !== CACHE_VERSION) { + log("Version mismatch: expected '%s', actual: '%s'", CACHE_VERSION, content?.v); + return; + } + return content.state; + } catch { + log("Error loading cache '%s'", fileName); + return; + } + } + + public saveState(project: string, state: StaticProgramState) { + const fileName = buildFilename(project); + log("Writing cache '%s'", fileName); + try { + const content: CacheFileContent = {state, v: CACHE_VERSION}; + this.fs.writeFile(fileName, yaml.dump(content, {indent: 2, sortKeys: true})); + } catch { + log("Error writing cache '%s'", fileName); + } + } +} + +function buildFilename(tsconfigPath: string) { + return tsconfigPath.replace(/.[^.]+$/, '.fimbullintercache'); +} diff --git a/packages/wotan/src/services/dependency-resolver.ts b/packages/wotan/src/services/dependency-resolver.ts new file mode 100644 index 000000000..78cf25597 --- /dev/null +++ b/packages/wotan/src/services/dependency-resolver.ts @@ -0,0 +1,278 @@ +import { injectable } from 'inversify'; +import * as ts from 'typescript'; +import { isModuleDeclaration, isNamespaceExportDeclaration, findImports, ImportKind } from 'tsutils'; +import { resolveCachedResult, getOutputFileNamesOfProjectReference, iterateProjectReferences, unixifyPath } from '../utils'; +import bind from 'bind-decorator'; +import * as path from 'path'; + +export interface DependencyResolver { + update(program: ts.Program, updatedFile: string): void; + getDependencies(fileName: string): ReadonlyMap; + getFilesAffectingGlobalScope(): readonly string[]; +} + +export type DependencyResolverHost = Required> & { + useSourceOfProjectReferenceRedirect?(): boolean; +}; + +@injectable() +export class DependencyResolverFactory { + public create(host: DependencyResolverHost, program: ts.Program): DependencyResolver { + return new DependencyResolverImpl(host, program); + } +} + +interface DependencyResolverState { + affectsGlobalScope: readonly string[]; + ambientModules: ReadonlyMap; + moduleAugmentations: ReadonlyMap; + patternAmbientModules: ReadonlyMap; +} + +class DependencyResolverImpl implements DependencyResolver { + private dependencies = new Map>(); + private fileToProjectReference: ReadonlyMap | undefined = undefined; + private fileMetadata = new Map(); + private compilerOptions = this.program.getCompilerOptions(); + private useSourceOfProjectReferenceRedirect = this.host.useSourceOfProjectReferenceRedirect?.() === true && + !this.compilerOptions.disableSourceOfProjectReferenceRedirect; + + private state: DependencyResolverState | undefined = undefined; + + constructor(private host: DependencyResolverHost, private program: ts.Program) {} + + public update(program: ts.Program, updatedFile: string) { + this.state = undefined; + this.dependencies.delete(updatedFile); + this.fileMetadata.delete(updatedFile); + this.program = program; + } + + private buildState(): DependencyResolverState { + const affectsGlobalScope = []; + const ambientModules = new Map(); + const patternAmbientModules = new Map(); + const moduleAugmentationsTemp = new Map(); + for (const file of this.program.getSourceFiles()) { + const meta = this.getFileMetaData(file.fileName); + if (meta.affectsGlobalScope) + affectsGlobalScope.push(file.fileName); + for (const ambientModule of meta.ambientModules) { + const map = meta.isExternalModule + ? moduleAugmentationsTemp + : ambientModule.includes('*') + ? patternAmbientModules + : ambientModules; + addToList(map, ambientModule, file.fileName); + } + } + + const moduleAugmentations = new Map(); + for (const [module, files] of moduleAugmentationsTemp) { + // if an ambient module with the same identifier exists, the augmentation always applies to that + const ambientModuleAffectingFiles = ambientModules.get(module); + if (ambientModuleAffectingFiles !== undefined) { + ambientModuleAffectingFiles.push(...files); + continue; + } + for (const file of files) { + const resolved = this.getExternalReferences(file).get(module); + // if an augmentation's identifier can be resolved from the declaring file, the augmentation applies to the resolved path + if (resolved != null) { // tslint:disable-line:triple-equals + addToList(moduleAugmentations, resolved, file); + } else { + // if a pattern ambient module matches the augmented identifier, the augmentation applies to that + const matchingPattern = getBestMatchingPattern(module, patternAmbientModules.keys()); + if (matchingPattern !== undefined) + addToList(patternAmbientModules, matchingPattern, file); + } + } + } + + return { + affectsGlobalScope, + ambientModules, + moduleAugmentations, + patternAmbientModules, + }; + } + + public getFilesAffectingGlobalScope() { + return (this.state ??= this.buildState()).affectsGlobalScope; + } + + public getDependencies(file: string) { + this.state ??= this.buildState(); + const result = new Map(); + { + const augmentations = this.state.moduleAugmentations.get(file); + if (augmentations !== undefined) + result.set('\0', augmentations); + } + for (const [identifier, resolved] of this.getExternalReferences(file)) { + const filesAffectingAmbientModule = this.state.ambientModules.get(identifier); + if (filesAffectingAmbientModule !== undefined) { + result.set(identifier, filesAffectingAmbientModule); + } else if (resolved !== null) { + const list = [resolved]; + const augmentations = this.state.moduleAugmentations.get(resolved); + if (augmentations !== undefined) + list.push(...augmentations); + result.set(identifier, list); + } else { + const pattern = getBestMatchingPattern(identifier, this.state.patternAmbientModules.keys()); + if (pattern !== undefined) { + result.set(identifier, this.state.patternAmbientModules.get(pattern)!); + } else { + result.set(identifier, null); + } + } + } + const meta = this.fileMetadata.get(file)!; + if (!meta.isExternalModule) + for (const ambientModule of meta.ambientModules) + result.set( + ambientModule, + this.state[ambientModule.includes('*') ? 'patternAmbientModules' : 'ambientModules'].get(ambientModule)!, + ); + + return result; + } + + private getFileMetaData(fileName: string) { + return resolveCachedResult(this.fileMetadata, fileName, this.collectMetaDataForFile); + } + + private getExternalReferences(fileName: string) { + return resolveCachedResult(this.dependencies, fileName, this.collectExternalReferences); + } + + @bind + private collectMetaDataForFile(fileName: string) { + return collectFileMetadata(this.program.getSourceFile(fileName)!); + } + + @bind + private collectExternalReferences(fileName: string): Map { + // TODO add tslib if importHelpers is enabled + const sourceFile = this.program.getSourceFile(fileName)!; + const references = new Set(findImports(sourceFile, ImportKind.All, false).map(({text}) => text)); + if (ts.isExternalModule(sourceFile)) + for (const augmentation of this.getFileMetaData(fileName).ambientModules) + references.add(augmentation); + const result = new Map(); + if (references.size === 0) + return result; + this.fileToProjectReference ??= createProjectReferenceMap(this.program.getResolvedProjectReferences()); + const arr = Array.from(references); + const resolved = + this.host.resolveModuleNames(arr, fileName, undefined, this.fileToProjectReference.get(fileName), this.compilerOptions); + for (let i = 0; i < resolved.length; ++i) { + const current = resolved[i]; + if (current === undefined) { + result.set(arr[i], null); + } else { + const projectReference = this.useSourceOfProjectReferenceRedirect + ? this.fileToProjectReference.get(current.resolvedFileName) + : undefined; + if (projectReference === undefined) { + result.set(arr[i], current.resolvedFileName); + } else if (projectReference.commandLine.options.outFile) { + // with outFile the files must be global anyway, so we don't care about the exact file + result.set(arr[i], projectReference.commandLine.fileNames[0]); + } else { + result.set(arr[i], getSourceOfProjectReferenceRedirect(current.resolvedFileName, projectReference)); + } + } + } + return result; + } +} + +function getBestMatchingPattern(moduleName: string, patternAmbientModules: Iterable) { + // TypeScript uses the pattern with the longest matching prefix + let longestMatchLength = -1; + let longestMatch: string | undefined; + for (const pattern of patternAmbientModules) { + if (moduleName.length < pattern.length - 1) + continue; // compare length without the wildcard first, to avoid false positives like 'foo' matching 'foo*oo' + const index = pattern.indexOf('*'); + if ( + index > longestMatchLength && + moduleName.startsWith(pattern.substring(0, index)) && + moduleName.endsWith(pattern.substring(index + 1)) + ) { + longestMatchLength = index; + longestMatch = pattern; + } + } + return longestMatch; +} + +function createProjectReferenceMap(references: ts.ResolvedProjectReference['references']) { + const result = new Map(); + for (const ref of iterateProjectReferences(references)) + for (const file of getOutputFileNamesOfProjectReference(ref)) + result.set(file, ref); + return result; +} + +function addToList(map: Map, key: string, value: T) { + const arr = map.get(key); + if (arr === undefined) { + map.set(key, [value]); + } else { + arr.push(value); + } +} + +interface MetaData { + affectsGlobalScope: boolean; + ambientModules: Set; + isExternalModule: boolean; +} + +function collectFileMetadata(sourceFile: ts.SourceFile): MetaData { + let affectsGlobalScope: boolean | undefined; + const ambientModules = new Set(); + const isExternalModule = ts.isExternalModule(sourceFile); + for (const statement of sourceFile.statements) { + if (statement.flags & ts.NodeFlags.GlobalAugmentation) { + affectsGlobalScope = true; + } else if (isModuleDeclaration(statement) && statement.name.kind === ts.SyntaxKind.StringLiteral) { + ambientModules.add(statement.name.text); + if (!isExternalModule && !affectsGlobalScope && statement.body !== undefined) { + // search for global augmentations in ambient module blocks + for (const s of (statement.body).statements) { + if (s.flags & ts.NodeFlags.GlobalAugmentation) { + affectsGlobalScope = true; + break; + } + } + } + + } else if (isNamespaceExportDeclaration(statement)) { + affectsGlobalScope = true; + } else if (affectsGlobalScope === undefined) { // files that only consist of ambient modules do not affect global scope + affectsGlobalScope = !isExternalModule; + } + } + return {ambientModules, isExternalModule, affectsGlobalScope: affectsGlobalScope === true}; +} + +function getSourceOfProjectReferenceRedirect(outputFileName: string, ref: ts.ResolvedProjectReference): string { + const options = ref.commandLine.options; + const projectDirectory = path.dirname(ref.sourceFile.fileName); + const origin = unixifyPath(path.resolve( + options.rootDir || projectDirectory, + path.relative(options.declarationDir || options.outDir || /* istanbul ignore next */ projectDirectory, outputFileName.slice(0, -5)), + )); + + for (const extension of ['.ts', '.tsx', '.js', '.jsx']) { + const name = origin + extension; + if (ref.commandLine.fileNames.includes(name)) + return name; + } + /* istanbul ignore next */ + return outputFileName; // should never happen +} diff --git a/packages/wotan/src/services/program-state.ts b/packages/wotan/src/services/program-state.ts new file mode 100644 index 000000000..ab160e1e6 --- /dev/null +++ b/packages/wotan/src/services/program-state.ts @@ -0,0 +1,554 @@ +import { injectable } from 'inversify'; +import * as ts from 'typescript'; +import { DependencyResolver, DependencyResolverFactory, DependencyResolverHost } from './dependency-resolver'; +import { resolveCachedResult, djb2, unixifyPath, emptyArray } from '../utils'; +import bind from 'bind-decorator'; +import { Finding, StatePersistence, StaticProgramState } from '@fimbul/ymir'; +import debug = require('debug'); +import { isCompilerOptionEnabled } from 'tsutils'; +import * as path from 'path'; + +const log = debug('wotan:programState'); + +export interface ProgramState { + update(program: ts.Program, updatedFile: string): void; + getUpToDateResult(fileName: string, configHash: string): readonly Finding[] | undefined; + setFileResult(fileName: string, configHash: string, result: readonly Finding[]): void; + save(): void; +} + +@injectable() +export class ProgramStateFactory { + constructor(private resolverFactory: DependencyResolverFactory, private statePersistence: StatePersistence) {} + + public create(program: ts.Program, host: ProgramStateHost & DependencyResolverHost, tsconfigPath: string) { + return new ProgramStateImpl(host, program, this.resolverFactory.create(host, program), this.statePersistence, tsconfigPath); + } +} + +export type ProgramStateHost = Pick; + +interface FileResults { + readonly config: string; + readonly result: ReadonlyArray; +} + +const enum DependencyState { + Unknown = 0, + Outdated = 1, + Ok = 2, +} + +const STATE_VERSION = 1; + +const oldStateSymbol = Symbol('oldState'); +class ProgramStateImpl implements ProgramState { + private projectDirectory = unixifyPath(path.dirname(this.project)); + private canonicalProjectDirectory = this.host.getCanonicalFileName(this.projectDirectory); + private optionsHash = computeCompilerOptionsHash(this.program.getCompilerOptions(), this.projectDirectory); + private assumeChangesOnlyAffectDirectDependencies = + isCompilerOptionEnabled(this.program.getCompilerOptions(), 'assumeChangesOnlyAffectDirectDependencies'); + private fileHashes = new Map(); + private fileResults = new Map(); + private relativePathNames = new Map(); + private [oldStateSymbol]: StaticProgramState | undefined; + private recheckOldState = true; + private dependenciesUpToDate: Uint8Array; + + constructor( + private host: ProgramStateHost, + private program: ts.Program, + private resolver: DependencyResolver, + private statePersistence: StatePersistence, + private project: string, + ) { + const oldState = this.statePersistence.loadState(project); + if (oldState?.v !== STATE_VERSION || oldState.ts !== ts.version || oldState.options !== this.optionsHash) { + this[oldStateSymbol] = undefined; + this.dependenciesUpToDate = new Uint8Array(0); + } else { + this[oldStateSymbol] = this.remapFileNames(oldState); + this.dependenciesUpToDate = new Uint8Array(oldState.files.length); + } + } + + /** get old state if global files didn't change */ + private tryReuseOldState() { + const oldState = this[oldStateSymbol]; + if (oldState === undefined || !this.recheckOldState) + return oldState; + const filesAffectingGlobalScope = this.resolver.getFilesAffectingGlobalScope(); + if (oldState.global.length !== filesAffectingGlobalScope.length) + return this[oldStateSymbol] = undefined; + const globalFilesWithHash = this.sortByHash(filesAffectingGlobalScope); + for (let i = 0; i < globalFilesWithHash.length; ++i) { + const index = oldState.global[i]; + if ( + globalFilesWithHash[i].hash !== oldState.files[index].hash || + !this.assumeChangesOnlyAffectDirectDependencies && + !this.fileDependenciesUpToDate(globalFilesWithHash[i].fileName, index, oldState) + ) + return this[oldStateSymbol] = undefined; + } + this.recheckOldState = false; + return oldState; + } + + public update(program: ts.Program, updatedFile: string) { + this.program = program; + this.resolver.update(program, updatedFile); + this.fileHashes.delete(updatedFile); + this.recheckOldState = true; + this.dependenciesUpToDate.fill(DependencyState.Unknown); + } + + private getFileHash(file: string) { + return resolveCachedResult(this.fileHashes, file, this.computeFileHash); + } + + @bind + private computeFileHash(file: string) { + return '' + djb2(this.program.getSourceFile(file)!.text); + } + + private getRelativePath(fileName: string) { + return resolveCachedResult(this.relativePathNames, fileName, this.makeRelativePath); + } + + @bind + private makeRelativePath(fileName: string) { + return unixifyPath(path.relative(this.canonicalProjectDirectory, this.host.getCanonicalFileName(fileName))); + } + + public getUpToDateResult(fileName: string, configHash: string) { + const oldState = this.tryReuseOldState(); + if (oldState === undefined) + return; + const index = this.lookupFileIndex(fileName, oldState); + if (index === undefined) + return; + const old = oldState.files[index]; + if ( + old.result === undefined || + old.config !== configHash || + old.hash !== this.getFileHash(fileName) || + !this.fileDependenciesUpToDate(fileName, index, oldState) + ) + return; + log('reusing state for %s', fileName); + return old.result; + } + + public setFileResult(fileName: string, configHash: string, result: ReadonlyArray) { + if (!this.isFileUpToDate(fileName)) { + log('File %s is outdated, merging current state into old state', fileName); + // we need to create a state where the file is up-to-date + // so we replace the old state with the current state + // this includes all results from old state that are still up-to-date and all file results if they are still valid + const newState = this[oldStateSymbol] = this.aggregate(); + this.recheckOldState = false; + this.fileResults = new Map(); + this.dependenciesUpToDate = new Uint8Array(newState.files.length).fill(DependencyState.Ok); + } + this.fileResults.set(fileName, {result, config: configHash}); + } + + private isFileUpToDate(fileName: string): boolean { + const oldState = this.tryReuseOldState(); + if (oldState === undefined) + return false; + const index = this.lookupFileIndex(fileName, oldState); + if (index === undefined || oldState.files[index].hash !== this.getFileHash(fileName)) + return false; + switch (this.dependenciesUpToDate[index]) { + case DependencyState.Unknown: + return this.fileDependenciesUpToDate(fileName, index, oldState); + case DependencyState.Ok: + return true; + case DependencyState.Outdated: + return false; + } + } + + private fileDependenciesUpToDate(fileName: string, index: number, oldState: StaticProgramState): boolean { + // File names that are waiting to be processed, each iteration of the loop processes one file + const fileNameQueue = [fileName]; + // For each entry in `fileNameQueue` this holds the index of that file in `oldState.files` + const indexQueue = [index]; + // If a file is waiting for its children to be processed, it is moved from `indexQueue` to `parents` + const parents: number[] = []; + // For each entry in `parents` this holds the number of children that still need to be processed for that file + const childCounts = []; + // For each entry in `parents` this holds the index in of the earliest circular dependency in `parents`. + // For example, a value of `[Number.MAX_SAFE_INTEGER, 0]` means that `parents[1]` has a dependency on `parents[0]` (the root file). + const circularDependenciesQueue: number[] = []; + // If a file has a circular on one of its parents, it is moved from `indexQueue` to the current cycle + // or creates a new cycle if its parent is not already in a cycle. + const cycles: Array> = []; + while (true) { + index = indexQueue.pop()!; + fileName = fileNameQueue.pop()!; + processFile: { + switch (this.dependenciesUpToDate[index]) { + case DependencyState.Outdated: + return markAsOutdated(parents, index, cycles, this.dependenciesUpToDate); + case DependencyState.Ok: + break processFile; + } + for (const cycle of cycles) { + if (cycle.has(index)) { + // we already know this is a circular dependency, skip this one and simply mark the parent as circular + setCircularDependency( + parents, + circularDependenciesQueue, + index, + cycles, + findCircularDependencyOfCycle(parents, circularDependenciesQueue, cycle), + ); + break processFile; + } + } + let earliestCircularDependency = Number.MAX_SAFE_INTEGER; + let childCount = 0; + const old = oldState.files[index]; + const dependencies = this.resolver.getDependencies(fileName); + const keys = old.dependencies === undefined ? emptyArray : Object.keys(old.dependencies); + + if (dependencies.size !== keys.length) + return markAsOutdated(parents, index, cycles, this.dependenciesUpToDate); + for (const key of keys) { + let newDeps = dependencies.get(key); + const oldDeps = old.dependencies![key]; + if (oldDeps === null) { + if (newDeps !== null) + return markAsOutdated(parents, index, cycles, this.dependenciesUpToDate); + continue; + } + if (newDeps === null) + return markAsOutdated(parents, index, cycles, this.dependenciesUpToDate); + newDeps = Array.from(new Set(newDeps)); + if (newDeps.length !== oldDeps.length) + return markAsOutdated(parents, index, cycles, this.dependenciesUpToDate); + const newDepsWithHash = this.sortByHash(newDeps); + for (let i = 0; i < newDepsWithHash.length; ++i) { + const oldDepState = oldState.files[oldDeps[i]]; + if (newDepsWithHash[i].hash !== oldDepState.hash) + return markAsOutdated(parents, index, cycles, this.dependenciesUpToDate); + if (!this.assumeChangesOnlyAffectDirectDependencies && fileName !== newDepsWithHash[i].fileName) { + const indexInQueue = parents.indexOf(oldDeps[i]); + if (indexInQueue === -1) { + // no circular dependency + fileNameQueue.push(newDepsWithHash[i].fileName); + indexQueue.push(oldDeps[i]); + ++childCount; + } else if (indexInQueue < earliestCircularDependency) { + earliestCircularDependency = indexInQueue; + } + } + } + } + + if (earliestCircularDependency !== Number.MAX_SAFE_INTEGER) { + earliestCircularDependency = + setCircularDependency(parents, circularDependenciesQueue, index, cycles, earliestCircularDependency); + } else if (childCount === 0) { + this.dependenciesUpToDate[index] = DependencyState.Ok; + } + if (childCount !== 0) { + parents.push(index); + childCounts.push(childCount); + circularDependenciesQueue.push(earliestCircularDependency); + continue; + } + } + // we only get here for files with no children to process + if (parents.length === 0) + return true; // only happens if the initial file has no dependencies or they are all already known as Ok + + while (--childCounts[childCounts.length - 1] === 0) { + index = parents.pop()!; + childCounts.pop(); + const earliestCircularDependency = circularDependenciesQueue.pop()!; + if (earliestCircularDependency >= parents.length) { + this.dependenciesUpToDate[index] = DependencyState.Ok; + if (earliestCircularDependency !== Number.MAX_SAFE_INTEGER) + for (const dep of cycles.pop()!) // cycle ends here + // update result for files that had a circular dependency on this one + this.dependenciesUpToDate[dep] = DependencyState.Ok; + } + if (parents.length === 0) + return true; + } + } + } + + public save() { + this.statePersistence.saveState(this.project, this.aggregate()); + } + + private aggregate(): StaticProgramState { + const oldState = this.tryReuseOldState(); + const lookup: Record = {}; + const mapToIndex = ({fileName}: {fileName: string}) => + lookup[this.host.getCanonicalFileName(this.relativePathNames.get(fileName)!)]; + const mapDependencies = (dependencies: ReadonlyMap) => { + if (dependencies.size === 0) + return; + const result: Record = {}; + for (const [key, f] of dependencies) + result[key] = f === null + ? null + : this.sortByHash(Array.from(new Set(f))).map(mapToIndex); + return result; + }; + const files: StaticProgramState.FileState[] = []; + const sourceFiles = this.program.getSourceFiles(); + for (let i = 0; i < sourceFiles.length; ++i) + lookup[this.host.getCanonicalFileName(this.getRelativePath(sourceFiles[i].fileName))] = i; + for (const file of sourceFiles) { + let results = this.fileResults.get(file.fileName); + if (results === undefined && oldState !== undefined) { + const index = this.lookupFileIndex(file.fileName, oldState); + if (index !== undefined) { + const old = oldState.files[index]; + if (old.result !== undefined) + results = old; + } + } + if (results !== undefined && !this.isFileUpToDate(file.fileName)) { + log('Discarding outdated results for %s', file.fileName); + results = undefined; + } + files.push({ + ...results, + hash: this.getFileHash(file.fileName), + dependencies: mapDependencies(this.resolver.getDependencies(file.fileName)), + }); + } + return { + files, + lookup, + v: STATE_VERSION, + ts: ts.version, + cs: this.host.useCaseSensitiveFileNames(), + global: this.sortByHash(this.resolver.getFilesAffectingGlobalScope()).map(mapToIndex), + options: this.optionsHash, + }; + } + + private sortByHash(fileNames: readonly string[]) { + return fileNames + .map((f) => ({fileName: f, hash: this.getFileHash(f)})) + .sort(compareHashKey); + } + + private lookupFileIndex(fileName: string, oldState: StaticProgramState): number | undefined { + fileName = this.host.getCanonicalFileName(this.getRelativePath(fileName)); + if (!oldState.cs && this.host.useCaseSensitiveFileNames()) + fileName = fileName.toLowerCase(); + return oldState.lookup[fileName]; + } + + private remapFileNames(oldState: StaticProgramState): StaticProgramState { + // only need to remap if oldState is case sensitive and current host is case insensitive + if (!oldState.cs || this.host.useCaseSensitiveFileNames()) + return oldState; + const lookup: Record = {}; + for (const [key, value] of Object.entries(oldState.lookup)) + lookup[this.host.getCanonicalFileName(key)] = value; + return {...oldState, lookup}; + } +} + +function findCircularDependencyOfCycle(parents: readonly number[], circularDependencies: readonly number[], cycle: ReadonlySet) { + for (let i = 0; i < parents.length; ++i) { + const dep = circularDependencies[i]; + if (dep !== Number.MAX_SAFE_INTEGER && cycle.has(parents[i])) + return dep; + } + /* istanbul ignore next */ + throw new Error('should never happen'); +} + +function setCircularDependency( + parents: readonly number[], + circularDependencies: number[], + self: number, + cycles: Array>, + earliestCircularDependency: number, +) { + let cyclesToMerge = 0; + for (let i = circularDependencies.length - 1, inCycle = false; i >= earliestCircularDependency; --i) { + const dep = circularDependencies[i]; + if (dep === Number.MAX_SAFE_INTEGER) { + inCycle = false; + } else { + if (!inCycle) { + ++cyclesToMerge; + inCycle = true; + } + if (dep === i) { + inCycle = false; // if cycle ends here, the next parent might start a new one + } else if (dep <= earliestCircularDependency) { + earliestCircularDependency = dep; + break; + } + } + } + let targetCycle; + if (cyclesToMerge === 0) { + targetCycle = new Set(); + cycles.push(targetCycle); + } else { + targetCycle = cycles[cycles.length - cyclesToMerge]; + while (--cyclesToMerge) + for (const d of cycles.pop()!) + targetCycle.add(d); + } + targetCycle.add(self); + for (let i = circularDependencies.length - 1; i >= earliestCircularDependency; --i) { + targetCycle.add(parents[i]); + circularDependencies[i] = earliestCircularDependency; + } + return earliestCircularDependency; +} + +function markAsOutdated(parents: readonly number[], index: number, cycles: ReadonlyArray>, results: Uint8Array) { + results[index] = DependencyState.Outdated; + for (index of parents) + results[index] = DependencyState.Outdated; + for (const cycle of cycles) + for (index of cycle) + results[index] = DependencyState.Outdated; + return false; +} + +function compareHashKey(a: {hash: string}, b: {hash: string}) { + return +(a.hash >= b.hash) - +(a.hash <= b.hash); +} + +const enum CompilerOptionKind { + Ignore = 0, + Value = 1, + Path = 2, + PathArray = 3, +} + +type KnownCompilerOptions = // tslint:disable-next-line:no-unused + {[K in keyof ts.CompilerOptions]: string extends K ? never : K} extends {[K in keyof ts.CompilerOptions]: infer P} ? P : never; + +type AdditionalCompilerOptions = 'pathsBasePath'; + +const compilerOptionKinds: Record = { + allowJs: CompilerOptionKind.Value, + allowSyntheticDefaultImports: CompilerOptionKind.Value, + allowUmdGlobalAccess: CompilerOptionKind.Value, + allowUnreachableCode: CompilerOptionKind.Value, + allowUnusedLabels: CompilerOptionKind.Value, + alwaysStrict: CompilerOptionKind.Value, + assumeChangesOnlyAffectDirectDependencies: CompilerOptionKind.Value, + baseUrl: CompilerOptionKind.Path, + charset: CompilerOptionKind.Value, + checkJs: CompilerOptionKind.Value, + composite: CompilerOptionKind.Value, + declaration: CompilerOptionKind.Value, + declarationDir: CompilerOptionKind.Path, + declarationMap: CompilerOptionKind.Value, + disableReferencedProjectLoad: CompilerOptionKind.Ignore, + disableSizeLimit: CompilerOptionKind.Value, + disableSourceOfProjectReferenceRedirect: CompilerOptionKind.Value, + disableSolutionSearching: CompilerOptionKind.Ignore, + downlevelIteration: CompilerOptionKind.Value, + emitBOM: CompilerOptionKind.Value, + emitDeclarationOnly: CompilerOptionKind.Value, + emitDecoratorMetadata: CompilerOptionKind.Value, + esModuleInterop: CompilerOptionKind.Value, + experimentalDecorators: CompilerOptionKind.Value, + forceConsistentCasingInFileNames: CompilerOptionKind.Value, + importHelpers: CompilerOptionKind.Value, + importsNotUsedAsValues: CompilerOptionKind.Value, + incremental: CompilerOptionKind.Value, + inlineSourceMap: CompilerOptionKind.Value, + inlineSources: CompilerOptionKind.Value, + isolatedModules: CompilerOptionKind.Value, + jsx: CompilerOptionKind.Value, + jsxFactory: CompilerOptionKind.Value, + jsxFragmentFactory: CompilerOptionKind.Value, + jsxImportSource: CompilerOptionKind.Value, + keyofStringsOnly: CompilerOptionKind.Value, + lib: CompilerOptionKind.Value, + locale: CompilerOptionKind.Value, + mapRoot: CompilerOptionKind.Value, + maxNodeModuleJsDepth: CompilerOptionKind.Value, + module: CompilerOptionKind.Value, + moduleResolution: CompilerOptionKind.Value, + newLine: CompilerOptionKind.Value, + noEmit: CompilerOptionKind.Value, + noEmitHelpers: CompilerOptionKind.Value, + noEmitOnError: CompilerOptionKind.Value, + noErrorTruncation: CompilerOptionKind.Value, + noFallthroughCasesInSwitch: CompilerOptionKind.Value, + noImplicitAny: CompilerOptionKind.Value, + noImplicitReturns: CompilerOptionKind.Value, + noImplicitThis: CompilerOptionKind.Value, + noImplicitUseStrict: CompilerOptionKind.Value, + noLib: CompilerOptionKind.Value, + noPropertyAccessFromIndexSignature: CompilerOptionKind.Value, + noResolve: CompilerOptionKind.Value, + noStrictGenericChecks: CompilerOptionKind.Value, + noUncheckedIndexedAccess: CompilerOptionKind.Value, + noUnusedLocals: CompilerOptionKind.Value, + noUnusedParameters: CompilerOptionKind.Value, + out: CompilerOptionKind.Value, + outDir: CompilerOptionKind.Path, + outFile: CompilerOptionKind.Path, + paths: CompilerOptionKind.Value, + pathsBasePath: CompilerOptionKind.Path, + preserveConstEnums: CompilerOptionKind.Value, + preserveSymlinks: CompilerOptionKind.Value, + project: CompilerOptionKind.Ignore, + reactNamespace: CompilerOptionKind.Value, + removeComments: CompilerOptionKind.Value, + resolveJsonModule: CompilerOptionKind.Value, + rootDir: CompilerOptionKind.Path, + rootDirs: CompilerOptionKind.PathArray, + skipDefaultLibCheck: CompilerOptionKind.Value, + skipLibCheck: CompilerOptionKind.Value, + sourceMap: CompilerOptionKind.Value, + sourceRoot: CompilerOptionKind.Value, + strict: CompilerOptionKind.Value, + strictBindCallApply: CompilerOptionKind.Value, + strictFunctionTypes: CompilerOptionKind.Value, + strictNullChecks: CompilerOptionKind.Value, + strictPropertyInitialization: CompilerOptionKind.Value, + stripInternal: CompilerOptionKind.Value, + suppressExcessPropertyErrors: CompilerOptionKind.Value, + suppressImplicitAnyIndexErrors: CompilerOptionKind.Value, + target: CompilerOptionKind.Value, + traceResolution: CompilerOptionKind.Value, + tsBuildInfoFile: CompilerOptionKind.Ignore, + typeRoots: CompilerOptionKind.PathArray, + types: CompilerOptionKind.Value, + useDefineForClassFields: CompilerOptionKind.Value, +}; + +function computeCompilerOptionsHash(options: ts.CompilerOptions, relativeTo: string) { + const obj: Record = {}; + for (const key of Object.keys(options).sort()) { + switch (compilerOptionKinds[key]) { + case CompilerOptionKind.Value: + obj[key] = options[key]; + break; + case CompilerOptionKind.Path: + obj[key] = makeRelativePath(options[key]); + break; + case CompilerOptionKind.PathArray: + obj[key] = (options[key]).map(makeRelativePath); + } + } + return '' + djb2(JSON.stringify(obj)); + + function makeRelativePath(p: string) { + return unixifyPath(path.relative(relativeTo, p)); + } +} diff --git a/packages/wotan/src/utils.ts b/packages/wotan/src/utils.ts index d16bf189c..0abe04c7d 100644 --- a/packages/wotan/src/utils.ts +++ b/packages/wotan/src/utils.ts @@ -212,3 +212,10 @@ function getOutFileDeclarationName(outFile: string) { // outFile ignores declarationDir return outFile.slice(0, -path.extname(outFile).length) + '.d.ts'; } + +export function djb2(str: string) { + let hash = 5381; + for (let i = 0; i < str.length; ++i) + hash = ((hash << 5) + hash) + str.charCodeAt(i); + return hash; +} diff --git a/packages/wotan/test/argparse.spec.ts b/packages/wotan/test/argparse.spec.ts index 6075cd91c..4ca70cb9a 100644 --- a/packages/wotan/test/argparse.spec.ts +++ b/packages/wotan/test/argparse.spec.ts @@ -17,6 +17,7 @@ test('parseGlobalOptions', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, ); @@ -33,6 +34,7 @@ test('parseGlobalOptions', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'ignores excess options', ); @@ -50,6 +52,7 @@ test('parseGlobalOptions', (t) => { fix: 10, extensions: ['.mjs'], reportUselessDirectives: false, + cache: false, }, ); @@ -66,6 +69,7 @@ test('parseGlobalOptions', (t) => { fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, ); @@ -82,6 +86,7 @@ test('parseGlobalOptions', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'invalid values are ignored', ); @@ -95,6 +100,9 @@ test('parseGlobalOptions', (t) => { t.is(parseGlobalOptions({reportUselessDirectives: 'off'}).reportUselessDirectives, false); t.is(parseGlobalOptions({reportUselessDirectives: true}).reportUselessDirectives, true); t.is(parseGlobalOptions({reportUselessDirectives: false}).reportUselessDirectives, false); + + t.is(parseGlobalOptions({cache: false}).cache, false); + t.is(parseGlobalOptions({cache: true}).cache, true); }); test('defaults to lint command', (t) => { @@ -112,6 +120,7 @@ test('defaults to lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, ); t.deepEqual( @@ -128,6 +137,7 @@ test('defaults to lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, ); }); @@ -147,6 +157,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'parses modules', ); @@ -165,6 +176,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'treats all arguments after -- as files', ); @@ -183,6 +195,7 @@ test('parses lint command', (t) => { fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'trims single quotes', ); @@ -201,6 +214,7 @@ test('parses lint command', (t) => { fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--fix argument is optional', ); @@ -219,6 +233,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--fix can be set to false', ); @@ -237,6 +252,7 @@ test('parses lint command', (t) => { fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--fix can be set to true', ); @@ -255,6 +271,7 @@ test('parses lint command', (t) => { fix: 10, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--fix can be set to any number', ); @@ -273,6 +290,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--project is accumulated', ); @@ -291,6 +309,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--exclude is accumulated', ); @@ -309,6 +328,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'files can be interspersed, specifying an option multiple times overrides its value', ); @@ -327,6 +347,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '-c specifies config', ); @@ -345,6 +366,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--config specifies config', ); @@ -363,6 +385,7 @@ test('parses lint command', (t) => { fix: false, extensions: ['.mjs', '.es6', '.esm'], reportUselessDirectives: false, + cache: false, }, '--ext can be comma separated, values are sanitized', ); @@ -381,6 +404,7 @@ test('parses lint command', (t) => { fix: false, extensions: ['.mjs', '.es6'], reportUselessDirectives: false, + cache: false, }, '--ext can occur multiple times', ); @@ -399,6 +423,7 @@ test('parses lint command', (t) => { fix: false, extensions: ['.esm', '.mjs', '.es6'], reportUselessDirectives: false, + cache: false, }, '--ext merges arrays', ); @@ -417,6 +442,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '-r switches project references', ); @@ -435,6 +461,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, '--references switches project references', ); @@ -466,6 +493,7 @@ test('parses lint command', (t) => { fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'overrides defaults', ); @@ -497,6 +525,7 @@ test('parses lint command', (t) => { fix: 10, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'uses defaults where not overridden', ); @@ -515,6 +544,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: true, + cache: false, }, 'value for --report-useless-directives is optional, default is true', ); @@ -533,6 +563,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: true, + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -551,6 +582,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -569,6 +601,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: true, + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -587,6 +620,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: 'error', + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -605,6 +639,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: 'warning', + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -623,6 +658,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: 'warning', + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -641,6 +677,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: 'suggestion', + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -659,6 +696,7 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: 'suggestion', + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); @@ -677,10 +715,87 @@ test('parses lint command', (t) => { fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, 'only parses severity or boolean as value for --report-useless-directives', ); + t.deepEqual( + parseArguments(['lint', '--cache', '-p', '.']), + { + command: CommandName.Lint, + modules: [], + config: undefined, + files: [], + exclude: [], + formatter: undefined, + project: ['.'], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: false, + cache: true, + }, + 'parses --cache with --project', + ); + + t.deepEqual( + parseArguments(['lint', '--cache', 'true', '-p', '.']), + { + command: CommandName.Lint, + modules: [], + config: undefined, + files: [], + exclude: [], + formatter: undefined, + project: ['.'], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: false, + cache: true, + }, + 'parses --cache with --project', + ); + + t.deepEqual( + parseArguments(['lint', '--cache', 'false', '-p', '.']), + { + command: CommandName.Lint, + modules: [], + config: undefined, + files: [], + exclude: [], + formatter: undefined, + project: ['.'], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: false, + cache: false, + }, + 'parses --cache with --project', + ); + + t.deepEqual( + parseArguments(['lint', '--cache']), + { + command: CommandName.Lint, + modules: [], + config: undefined, + files: [], + exclude: [], + formatter: undefined, + project: [], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: false, + cache: true, + }, + 'parses --cache with implicit --project', + ); + t.throws(() => parseArguments(['lint', '--foobar']), { message: "Unknown option '--foobar'." }); t.throws(() => parseArguments(['lint', '-m']), { message: "Option '-m' expects an argument." }); @@ -691,6 +806,7 @@ test('parses lint command', (t) => { t.throws(() => parseArguments(['lint', '--ext']), { message: "Option '--ext' expects an argument." }); t.throws(() => parseArguments(['lint', '--ext', 'mjs']), { message: "Options '--ext' and '--project' cannot be used together." }); t.throws(() => parseArguments(['lint', '--ext', 'mjs', '-p', '.']), { message: "Options '--ext' and '--project' cannot be used together." }); + t.throws(() => parseArguments(['lint', '--cache', 'a.ts']), { message: "Option '--cache' can only be used together with '--project'" }); }); test('parses save command', (t) => { @@ -721,6 +837,7 @@ test('parses save command', (t) => { fix: 10, extensions: undefined, reportUselessDirectives: false, + cache: false, }, ); }); diff --git a/packages/wotan/test/commands.spec.ts b/packages/wotan/test/commands.spec.ts index ab8b6f859..55745f4b4 100644 --- a/packages/wotan/test/commands.spec.ts +++ b/packages/wotan/test/commands.spec.ts @@ -170,6 +170,7 @@ test('SaveCommand', async (t) => { formatter: undefined, reportUselessDirectives: false, modules: [], + cache: false, }), { content: false, @@ -191,6 +192,7 @@ test('SaveCommand', async (t) => { formatter: undefined, reportUselessDirectives: false, modules: [], + cache: false, }, { project: 'foo', @@ -217,6 +219,7 @@ test('SaveCommand', async (t) => { formatter: undefined, reportUselessDirectives: true, modules: [], + cache: false, }, { project: 'foo.json', @@ -247,6 +250,7 @@ test('SaveCommand', async (t) => { formatter: undefined, reportUselessDirectives: false, modules: [], + cache: false, }, { other: 'foo', @@ -350,6 +354,7 @@ test('LintCommand', async (t) => { fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, container, ), @@ -372,6 +377,7 @@ test('LintCommand', async (t) => { fix: false, extensions: undefined, reportUselessDirectives: true, + cache: false, }, container, ), @@ -401,6 +407,7 @@ ERROR 2:1 useless-line-switch Disable switch has no effect. All specified rule fix: false, extensions: undefined, reportUselessDirectives: true, + cache: false, }, container, ), @@ -430,6 +437,7 @@ ERROR 2:1 useless-line-switch Disable switch has no effect. All specified rule fix: false, extensions: undefined, reportUselessDirectives: 'warning', + cache: false, }, container, ), @@ -459,6 +467,7 @@ WARNING 2:1 useless-line-switch Disable switch has no effect. All specified ru fix: true, extensions: undefined, reportUselessDirectives: true, + cache: false, }, container, ), @@ -485,6 +494,7 @@ WARNING 2:1 useless-line-switch Disable switch has no effect. All specified ru fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, container, ), @@ -517,6 +527,7 @@ ERROR 2:8 no-unused-expression This expression is unused. Did you mean to assi fix: true, extensions: undefined, reportUselessDirectives: false, + cache: false, }, container, ), @@ -543,6 +554,7 @@ ERROR 2:8 no-unused-expression This expression is unused. Did you mean to assi fix: false, extensions: undefined, reportUselessDirectives: false, + cache: false, }, container, ), diff --git a/packages/wotan/test/dependency-resolver.spec.ts b/packages/wotan/test/dependency-resolver.spec.ts new file mode 100644 index 000000000..23d98c03b --- /dev/null +++ b/packages/wotan/test/dependency-resolver.spec.ts @@ -0,0 +1,302 @@ +import 'reflect-metadata'; +import test, { ExecutionContext } from 'ava'; +import { Dirent } from 'fs'; +import { DirectoryJSON, Volume } from 'memfs'; +import * as ts from 'typescript'; +import { DependencyResolver, DependencyResolverFactory, DependencyResolverHost } from '../src/services/dependency-resolver'; +import { mapDefined, unixifyPath } from '../src/utils'; +import * as path from 'path'; + +function identity(v: T) { + return v; +} + +function setup(fileContents: DirectoryJSON, useSourceOfProjectReferenceRedirect?: boolean) { + const {root} = path.parse(unixifyPath(process.cwd())); + const vol = Volume.fromJSON(fileContents, root); + function fileExists(f: string) { + try { + return vol.statSync(f).isFile(); + } catch { + return false; + } + } + function readFile(f: string) { + try { + return vol.readFileSync(f, {encoding: 'utf8'}).toString(); + } catch { + return; + } + } + function readDirectory( + rootDir: string, + extensions: readonly string[], + excludes: readonly string[] | undefined, + includes: readonly string[], + depth?: number, + ) { + return ts.matchFiles( + rootDir, + extensions, + excludes, + includes, + true, + root, + depth, + (dir) => { + const files: string[] = []; + const directories: string[] = []; + const result: ts.FileSystemEntries = {files, directories}; + let entries; + try { + entries = vol.readdirSync(dir, {withFileTypes: true, encoding: 'utf8'}); + } catch { + return result; + } + for (const entry of entries) + (entry.isFile() ? files : directories).push(entry.name); + return result; + }, + identity, + ); + } + + const commandLine = ts.getParsedCommandLineOfConfigFile(root + 'tsconfig.json', {}, { + fileExists, + readFile, + readDirectory, + getCurrentDirectory() { return root; }, + useCaseSensitiveFileNames: true, + onUnRecoverableConfigFileDiagnostic() {}, + })!; + const resolutionCache = ts.createModuleResolutionCache(root, identity, commandLine.options); + const compilerHost: ts.CompilerHost & DependencyResolverHost = { + fileExists, + readFile, + readDirectory, + directoryExists(d) { + try { + return vol.statSync(d).isDirectory(); + } catch { + return false; + } + }, + getCanonicalFileName: identity, + getCurrentDirectory() { + return root; + }, + getNewLine() { + return '\n'; + }, + useCaseSensitiveFileNames() { + return true; + }, + useSourceOfProjectReferenceRedirect: useSourceOfProjectReferenceRedirect === undefined + ? undefined + : () => useSourceOfProjectReferenceRedirect, + getDefaultLibFileName: ts.getDefaultLibFilePath, + getSourceFile(fileName, languageVersion) { + const content = readFile(fileName); + return content === undefined ? undefined : ts.createSourceFile(fileName, content, languageVersion, true); + }, + writeFile() { + throw new Error('not implemented'); + }, + realpath: identity, + getDirectories(d) { + return mapDefined(vol.readdirSync(d, {encoding: 'utf8'}), (f) => { + return this.directoryExists!(d + '/' + f) ? f : undefined; + }); + }, + resolveModuleNames(modules, containingFile, _, redirectedReference, o) { + return modules.map( + (m) => ts.resolveModuleName(m, containingFile, o, this, resolutionCache, redirectedReference).resolvedModule); + }, + }; + const program = ts.createProgram({ + host: compilerHost, + options: commandLine.options, + rootNames: commandLine.fileNames, + projectReferences: commandLine.projectReferences, + }); + const dependencyResolver = new DependencyResolverFactory().create(compilerHost, program); + return {vol, compilerHost, program, dependencyResolver, root}; +} + +function assertAllDependenciesInProgram(resolver: DependencyResolver, program: ts.Program, t: ExecutionContext) { + for (const file of program.getSourceFiles()) { + const dependencies = resolver.getDependencies(file.fileName); + for (const deps of dependencies.values()) + if (deps !== null) + for (const dep of deps) + t.not(program.getSourceFile(dep), undefined); + } +} + +test('resolves imports', (t) => { + let {dependencyResolver, program, compilerHost, vol, root} = setup({ + 'tsconfig.json': JSON.stringify({compilerOptions: {moduleResolution: 'node', allowJs: true}, exclude: ['excluded.ts']}), + 'a.ts': 'import * as a from "./a"; import {b} from "./b"; import {c} from "c"; import {d} from "d"; import {e} from "e"; import {f} from "f"; import {f1} from "f1"; import {f2} from "f2"; import {g} from "g";', + 'b.ts': 'export const b = 1;', + 'node_modules/c/index.d.ts': 'export const c = 1;', + 'node_modules/f1/index.d.ts': 'export const f1 = 1;', + 'node_modules/g/index.d.ts': 'export const g = 1;', + 'empty.js': '', + 'ambient.ts': 'declare module "d" {export const d: number;} declare module "g" {}', + 'global.ts': 'declare var v = 1;', + 'other.ts': 'export {}; declare module "c" { export let other: number; }; declare module "d" {}; declare module "f1" {}; declare module "f2" {}; declare module "goo" {}; declare module "g" {};', + 'pattern.ts': 'declare module "f*"; declare module "goo*oo";', + 'declare-global.ts': 'export {}; declare global {}', + 'umd.d.ts': 'export let stuff: number; export as namespace Stuff;', + 'excluded.ts': 'declare module "e" {}; declare module "d" {};', + }); + + t.deepEqual(dependencyResolver.getFilesAffectingGlobalScope(), [root + 'declare-global.ts', root + 'global.ts', root + 'umd.d.ts']); + t.deepEqual(dependencyResolver.getDependencies(root + 'a.ts'), new Map([ + ['./a', [root + 'a.ts']], + ['./b', [root + 'b.ts']], + ['c', [root + 'node_modules/c/index.d.ts', root + 'other.ts']], + ['d', [root + 'ambient.ts', root + 'other.ts']], + ['e', null], + ['f', [root + 'pattern.ts', root + 'other.ts']], + ['f1', [root + 'node_modules/f1/index.d.ts', root + 'other.ts']], + ['f2', [root + 'pattern.ts', root + 'other.ts']], + ['g', [root + 'ambient.ts', root + 'other.ts']], + ])); + t.deepEqual(dependencyResolver.getDependencies(root + 'b.ts'), new Map()); + t.deepEqual(dependencyResolver.getDependencies(root + 'node_modules/c/index.d.ts'), new Map([['\0', [root + 'other.ts']]])); + t.deepEqual(dependencyResolver.getDependencies(root + 'node_modules/f1/index.d.ts'), new Map([['\0', [root + 'other.ts']]])); + t.deepEqual(dependencyResolver.getDependencies(root + 'empty.js'), new Map()); + t.deepEqual(dependencyResolver.getDependencies(root + 'ambient.ts'), new Map([ + ['d', [root + 'ambient.ts', root + 'other.ts']], + ['g', [root + 'ambient.ts', root + 'other.ts']], + ])); + t.deepEqual(dependencyResolver.getDependencies(root + 'global.ts'), new Map()); + t.deepEqual(dependencyResolver.getDependencies(root + 'declare-global.ts'), new Map()); + t.deepEqual(dependencyResolver.getDependencies(root + 'other.ts'), new Map([ + ['c', [root + 'node_modules/c/index.d.ts', root + 'other.ts']], + ['d', [root + 'ambient.ts', root + 'other.ts']], + ['f1', [root + 'node_modules/f1/index.d.ts', root + 'other.ts']], + ['f2', [root + 'pattern.ts', root + 'other.ts']], + ['goo', null], + ['g', [root + 'ambient.ts', root + 'other.ts']], + ])); + t.deepEqual(dependencyResolver.getDependencies(root + 'pattern.ts'), new Map([ + ['f*', [root + 'pattern.ts', root + 'other.ts']], + ['goo*oo', [root + 'pattern.ts']], + ])); + t.deepEqual(dependencyResolver.getDependencies(root + 'umd.d.ts'), new Map()); + assertAllDependenciesInProgram(dependencyResolver, program, t); + + vol.writeFileSync(root + 'empty.js', '/** @type {import("./b").b} */ var f = require("e");', {encoding: 'utf8'}); + program = ts.createProgram({ + oldProgram: program, + host: compilerHost, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + }); + dependencyResolver.update(program, root + 'empty.js'); + + t.deepEqual(dependencyResolver.getDependencies(root + 'empty.js'), new Map([ + ['./b', [root + 'b.ts']], + ['e', null], + ])); + t.deepEqual( + dependencyResolver.getFilesAffectingGlobalScope(), + [root + 'declare-global.ts', root + 'empty.js', root + 'global.ts', root + 'umd.d.ts'], + ); + assertAllDependenciesInProgram(dependencyResolver, program, t); + + vol.appendFileSync(root + 'b.ts', 'import "./excluded";'); + program = ts.createProgram({ + oldProgram: program, + host: compilerHost, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + }); + dependencyResolver.update(program, root + 'b.ts'); + + t.deepEqual(dependencyResolver.getDependencies(root + 'b.ts'), new Map([['./excluded', [root + 'excluded.ts']]])); + t.deepEqual(dependencyResolver.getDependencies(root + 'a.ts'), new Map([ + ['./a', [root + 'a.ts']], + ['./b', [root + 'b.ts']], + ['c', [root + 'node_modules/c/index.d.ts', root + 'other.ts']], + ['d', [root + 'excluded.ts', root + 'ambient.ts', root + 'other.ts']], + ['e', [root + 'excluded.ts']], + ['f', [root + 'pattern.ts', root + 'other.ts']], + ['f1', [root + 'node_modules/f1/index.d.ts', root + 'other.ts']], + ['f2', [root + 'pattern.ts', root + 'other.ts']], + ['g', [root + 'ambient.ts', root + 'other.ts']], + ])); + t.deepEqual(dependencyResolver.getDependencies(root + 'ambient.ts'), new Map([ + ['d', [root + 'excluded.ts', root + 'ambient.ts', root + 'other.ts']], + ['g', [root + 'ambient.ts', root + 'other.ts']], + ])); + assertAllDependenciesInProgram(dependencyResolver, program, t); +}); + +test('finds global augmentations in ambient modules', (t) => { + const {dependencyResolver, root} = setup({ + 'tsconfig.json': '{}', + 'a.ts': 'declare module "foo" { global { var v: number; } }', + }); + t.deepEqual(dependencyResolver.getFilesAffectingGlobalScope(), [root + 'a.ts']); +}); + +test('handles useSourceOfProjectReferenceRedirect', (t) => { + const {dependencyResolver, program, root} = setup( + { + 'tsconfig.json': JSON.stringify({references: [{path: 'a'}, {path: 'b'}, {path: 'c'}], include: ['src']}), + 'src/a.ts': 'export * from "../a/decl/src/a"; export * from "../a/decl/src/b"; export * from "../a/decl/src/c"; export * from "../a/decl/src/d";', + 'src/b.ts': 'export * from "../b/dist/file";', + 'src/c.ts': 'import "../c/dist/outfile', + 'src/d.ts': 'import "../d/file', + 'a/tsconfig.json': JSON.stringify( + {compilerOptions: {composite: true, allowJs: true, outDir: 'dist', declarationDir: 'decl'}, include: ['src']}, + ), + 'a/src/a.ts': 'export {}', + 'a/src/b.tsx': 'export {}', + 'a/src/c.js': 'export {}', + 'a/src/d.jsx': 'export {}', + 'b/tsconfig.json': JSON.stringify({compilerOptions: {composite: true, outDir: 'dist', rootDir: 'src'}}), + 'b/src/file.ts': 'export {};', + 'c/tsconfig.json': JSON.stringify({compilerOptions: {composite: true, outFile: 'dist/outfile.js'}, files: ['a.ts', 'b.ts']}), + 'c/a.ts': 'namespace foo {}', + 'c/b.ts': 'namespace foo {}', + 'd/tsconfig.json': JSON.stringify({compilerOptions: {composite: true}, files: ['file.ts']}), + 'd/file.ts': 'export {}', + }, + true, + ); + + t.deepEqual(dependencyResolver.getDependencies(root + 'src/a.ts'), new Map([ + ['../a/decl/src/a', [root + 'a/src/a.ts']], + ['../a/decl/src/b', [root + 'a/src/b.tsx']], + ['../a/decl/src/c', [root + 'a/src/c.js']], + ['../a/decl/src/d', [root + 'a/src/d.jsx']], + ])); + t.deepEqual(dependencyResolver.getDependencies(root + 'src/b.ts'), new Map([['../b/dist/file', [root + 'b/src/file.ts']]])); + t.deepEqual(dependencyResolver.getDependencies(root + 'src/c.ts'), new Map([['../c/dist/outfile', [root + 'c/a.ts']]])); + t.deepEqual(dependencyResolver.getDependencies(root + 'src/d.ts'), new Map([['../d/file', [root + 'd/file.ts']]])); + assertAllDependenciesInProgram(dependencyResolver, program, t); +}); + +test('handles disableSourceOfProjectReferenceRedirect', (t) => { + const {dependencyResolver, program, root} = setup( + { + 'tsconfig.json': JSON.stringify( + {references: [{path: 'a'}], compilerOptions: {disableSourceOfProjectReferenceRedirect: true}, include: ['src']}, + ), + 'src/a.ts': 'export * from "../a/dist/file";', + 'a/tsconfig.json': JSON.stringify({compilerOptions: {composite: true, outDir: 'dist', rootDir: 'src'}}), + 'a/src/file.ts': 'export {};', + 'a/dist/file.d.ts': 'export {};', + }, + true, + ); + + t.deepEqual(dependencyResolver.getDependencies(root + 'src/a.ts'), new Map([ + ['../a/dist/file', [root + 'a/dist/file.d.ts']], + ])); + assertAllDependenciesInProgram(dependencyResolver, program, t); +}); diff --git a/packages/wotan/test/fixtures/cache/.wotanrc.yaml b/packages/wotan/test/fixtures/cache/.wotanrc.yaml new file mode 100644 index 000000000..11bd2ecb7 --- /dev/null +++ b/packages/wotan/test/fixtures/cache/.wotanrc.yaml @@ -0,0 +1,4 @@ +rules: + no-debugger: error +settings: + foo: on diff --git a/packages/wotan/test/fixtures/cache/a.ts b/packages/wotan/test/fixtures/cache/a.ts new file mode 100644 index 000000000..81afa3157 --- /dev/null +++ b/packages/wotan/test/fixtures/cache/a.ts @@ -0,0 +1 @@ +console.log('foo'); diff --git a/packages/wotan/test/fixtures/cache/b.ts b/packages/wotan/test/fixtures/cache/b.ts new file mode 100644 index 000000000..30f41bed4 --- /dev/null +++ b/packages/wotan/test/fixtures/cache/b.ts @@ -0,0 +1,2 @@ +debugger; +// wotan-disable-next-line diff --git a/packages/wotan/test/fixtures/cache/tsconfig.fimbullintercache b/packages/wotan/test/fixtures/cache/tsconfig.fimbullintercache new file mode 100644 index 000000000..b14c4beb6 --- /dev/null +++ b/packages/wotan/test/fixtures/cache/tsconfig.fimbullintercache @@ -0,0 +1,53 @@ +state: + cs: false + files: + - config: '-10079848193' + hash: '5901983184' + result: [] + - config: '-10079848193' + hash: '-1055350110' + result: + - end: + character: 9 + line: 0 + position: 9 + fix: + replacements: + - end: 9 + start: 0 + text: '' + message: '''debugger'' statements are forbidden.' + ruleName: no-debugger + severity: error + start: + character: 0 + line: 0 + position: 0 + - end: + character: 26 + line: 1 + position: 36 + fix: + replacements: + - end: 36 + start: 10 + text: '' + message: >- + Disable switch has no effect. All specified rules have no failures + to disable. + ruleName: useless-line-switch + severity: error + start: + character: 0 + line: 1 + position: 10 + global: + - 1 + - 0 + lookup: + a.ts: 0 + b.ts: 1 + options: '-10679607802' + ts: 4.3.0-dev.20210210 + v: 1 +v: 1 diff --git a/packages/wotan/test/fixtures/cache/tsconfig.json b/packages/wotan/test/fixtures/cache/tsconfig.json new file mode 100644 index 000000000..db27c96f3 --- /dev/null +++ b/packages/wotan/test/fixtures/cache/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "noLib": true, + "types": [] + } +} diff --git a/packages/wotan/test/program-state.spec.ts b/packages/wotan/test/program-state.spec.ts new file mode 100644 index 000000000..0c8b26b9c --- /dev/null +++ b/packages/wotan/test/program-state.spec.ts @@ -0,0 +1,720 @@ +import 'reflect-metadata'; +import { DirectoryJSON, Volume } from 'memfs'; +import * as path from 'path'; +import { mapDefined, unixifyPath } from '../src/utils'; +import * as ts from 'typescript'; +import { Dirent } from 'fs'; +import { ProgramStateFactory, ProgramStateHost } from '../src/services/program-state'; +import { DependencyResolverFactory, DependencyResolverHost } from '../src/services/dependency-resolver'; +import { StatePersistence, StaticProgramState } from '@fimbul/ymir'; +import test from 'ava'; +import * as yaml from 'js-yaml'; + +function identity(v: T) { + return v; +} + +function setup( + fileContents: DirectoryJSON, + options: {subdir?: string, initialState?: StaticProgramState, caseSensitive?: boolean, symlinks?: ReadonlyArray<[string, string]>} = {}, +) { + const {root} = path.parse(unixifyPath(process.cwd())); + const cwd = options.subdir ? unixifyPath(path.join(root, options.subdir)) + '/' : root; + const vol = Volume.fromJSON(fileContents, cwd); + if (options.symlinks) + for (const [from, to] of options.symlinks) + vol.symlinkSync(cwd + to, cwd + from); + + function fileExists(f: string) { + try { + return vol.statSync(f).isFile(); + } catch { + return false; + } + } + function readFile(f: string) { + try { + return vol.readFileSync(f, {encoding: 'utf8'}).toString(); + } catch { + return; + } + } + function readDirectory( + rootDir: string, + extensions: readonly string[], + excludes: readonly string[] | undefined, + includes: readonly string[], + depth?: number, + ) { + return ts.matchFiles( + rootDir, + extensions, + excludes, + includes, + true, + cwd, + depth, + (dir) => { + const files: string[] = []; + const directories: string[] = []; + const result: ts.FileSystemEntries = {files, directories}; + let entries; + try { + entries = vol.readdirSync(dir, {withFileTypes: true, encoding: 'utf8'}); + } catch { + return result; + } + for (const entry of entries) + (entry.isFile() ? files : directories).push(entry.name); + return result; + }, + identity, + ); + } + + const tsconfigPath = cwd + 'tsconfig.json'; + const commandLine = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, { + fileExists, + readFile, + readDirectory, + getCurrentDirectory() { return cwd; }, + useCaseSensitiveFileNames: !!options.caseSensitive, + onUnRecoverableConfigFileDiagnostic(d) { + throw new Error(d.messageText); + }, + })!; + const resolutionCache = ts.createModuleResolutionCache(cwd, identity, commandLine.options); + const compilerHost: ts.CompilerHost & DependencyResolverHost & ProgramStateHost = { + fileExists, + readFile, + readDirectory, + directoryExists(d) { + try { + return vol.statSync(d).isDirectory(); + } catch { + return false; + } + }, + getCanonicalFileName: options.caseSensitive ? identity : (f) => f.toLowerCase(), + getCurrentDirectory() { + return cwd; + }, + getNewLine() { + return '\n'; + }, + useCaseSensitiveFileNames() { + return !!options.caseSensitive; + }, + getDefaultLibFileName: ts.getDefaultLibFilePath, + getSourceFile(fileName, languageVersion) { + const content = readFile(fileName); + return content === undefined ? undefined : ts.createSourceFile(fileName, content, languageVersion, true); + }, + writeFile() { + throw new Error('not implemented'); + }, + realpath(f) { + return root + (vol.realpathSync(f, {encoding: 'utf8'})).substr(1); + }, + getDirectories(d) { + return mapDefined(vol.readdirSync(d, {encoding: 'utf8'}), (f) => { + return this.directoryExists!(d + '/' + f) ? f : undefined; + }); + }, + resolveModuleNames(modules, containingFile, _, redirectedReference, o) { + return modules.map( + (m) => ts.resolveModuleName(m, containingFile, o, this, resolutionCache, redirectedReference).resolvedModule); + }, + }; + const program = ts.createProgram({ + host: compilerHost, + options: commandLine.options, + rootNames: commandLine.fileNames, + projectReferences: commandLine.projectReferences, + }); + const persistence: StatePersistence = { + loadState() { return options.initialState; }, + saveState() {}, + }; + const factory = new ProgramStateFactory(new DependencyResolverFactory(), persistence); + const programState = factory.create(program, compilerHost, tsconfigPath); + return {vol, compilerHost, program, programState, cwd, persistence, tsconfigPath, factory}; +} + +function generateOldState(configHash: string, ...args: Parameters) { + const {program, programState, persistence} = setup(...args); + for (const file of program.getSourceFiles()) + programState.setFileResult(file.fileName, configHash, []); + let savedState!: StaticProgramState; + persistence.saveState = (_, state) => savedState = state; + programState.save(); + return savedState; +} + +test('saves old state', (t) => { + let {programState, persistence, tsconfigPath, cwd, program, vol, compilerHost, factory} = setup({ + 'tsconfig.json': JSON.stringify({compilerOptions: {strict: true}}), + 'a.ts': 'import "./b"; import "./d";', + 'b.ts': 'import "./c";', + 'c.ts': 'export {}', + 'd.ts': 'export {}', + }, {subdir: 'foo'}); + let savedState: StaticProgramState | undefined; + persistence.saveState = (project, state) => { + t.is(project, tsconfigPath); + const {ts: tsVersion, ...rest} = state; + t.is(tsVersion, ts.version); + t.snapshot(yaml.dump(rest, {sortKeys: true})); + savedState = state; + }; + persistence.loadState = (project) => { + t.is(project, tsconfigPath); + return savedState; + }; + t.is(programState.getUpToDateResult(cwd + 'a.ts', ''), undefined); + + programState.setFileResult(cwd + 'a.ts', '1234', []); + programState.setFileResult(cwd + 'b.ts', '1234', []); + programState.setFileResult(cwd + 'c.ts', '1234', []); + programState.setFileResult(cwd + 'd.ts', '1234', []); + + vol.writeFileSync(cwd + 'd.ts', 'import "./c";'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'd.ts'); + + programState.save(); + t.not(savedState, undefined); + + programState = factory.create(program, compilerHost, tsconfigPath); + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '5678'), undefined); + + vol.writeFileSync(cwd + 'd.ts', 'import "./e"; declare module "./e" {}'); + vol.writeFileSync(cwd + 'e.ts', ''); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'd.ts'); + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'e.ts', '1234'), undefined); + + programState.save(); + + vol.writeFileSync(cwd + 'e.ts', 'var v: import("e");'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'e.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'e.ts', '1234'), undefined); + + savedState = undefined; + programState.save(); + t.not(savedState, undefined); +}); + +test('can start with a warm cache', (t) => { + const state = generateOldState( + '1234', + { + 'tsconfig.json': '{}', + 'A.ts': '', + 'b.ts': '', + 'C.ts': '', + 'd.ts': '', + }, + ); + const {programState, cwd} = setup( + { + 'tsconfig.json': '{}', + 'A.ts': '', + 'b.ts': '', + 'c.ts': '', + 'D.ts': '', + }, + { + subdir: 'foo', + initialState: state, + }, + ); + t.deepEqual(programState.getUpToDateResult(cwd + 'A.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'D.ts', '1234'), []); +}); + +test('can handle case-sensitive old state on case-insensitive system', (t) => { + const state = generateOldState( + '1234', + { + 'tsconfig.json': '{}', + 'A.ts': '', + 'b.ts': '', + }, + { + caseSensitive: true, + }, + ); + const {programState, cwd} = setup( + { + 'tsconfig.json': '{}', + 'a.ts': '', + 'b.ts': '', + }, + { + caseSensitive: false, + initialState: state, + }, + ); + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); +}); + +test('can handle case-insensitive old state on case-sensitive system', (t) => { + const state = generateOldState( + '1234', + { + 'tsconfig.json': '{}', + 'a.ts': '', + 'b.ts': '', + }, + { + caseSensitive: false, + }, + ); + const {programState, cwd} = setup( + { + 'tsconfig.json': '{}', + 'A.ts': '', + 'b.ts': '', + }, + { + caseSensitive: true, + initialState: state, + }, + ); + t.deepEqual(programState.getUpToDateResult(cwd + 'A.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); +}); + +test("doesn't discard results from old state", (t) => { + const state = generateOldState( + '1234', + { + 'tsconfig.json': '{}', + 'a.ts': 'export {};', + 'b.ts': 'export {};', + 'c.ts': 'var v;', + }, + ); + let {programState, cwd, persistence, vol, program, compilerHost} = setup( + { + 'tsconfig.json': '{}', + 'a.ts': 'export {};', + 'b.ts': 'export {};', + 'c.ts': 'var v;', + }, + { + initialState: state, + }, + ); + + persistence.saveState = (_, s) => t.deepEqual(s, state); + programState.save(); + + vol.writeFileSync(cwd + 'b.ts', 'import "./c";'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'b.ts'); + + persistence.saveState = (_, {ts: _ts, ...rest}) => t.snapshot(yaml.dump(rest, {sortKeys: true})); + programState.save(); + + vol.writeFileSync(cwd + 'c.ts', 'var v = 1;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'c.ts'); + + programState.save(); +}); + +test('uses relative paths for compilerOptions', (t) => { + const files = { + 'tsconfig.json': JSON.stringify({compilerOptions: {outDir: './dist', rootDirs: ['./src', './lib']}}), + 'a.ts': '', + }; + const state = generateOldState('', files, {subdir: 'a'}); + t.deepEqual(generateOldState('', files, {subdir: 'b/c'}), state); + + t.notDeepEqual( + generateOldState('', { + 'tsconfig.json': JSON.stringify({compilerOptions: {outDir: './out', rootDirs: ['./src', './lib']}}), + 'a.ts': '', + }, {subdir: 'a'}), + state, + ); +}); + +test('handles assumeChangesOnlyAffectDirectDependencies', (t) => { + const state = generateOldState('1234', { + 'tsconfig.json': JSON.stringify({compilerOptions: {assumeChangesOnlyAffectDirectDependencies: true}}), + 'a.ts': 'import "./b";', + 'b.ts': 'import "./c";', + 'c.ts': 'export {};', + 'global.ts': 'declare global {}; import "./b";', + }); + + const {programState, cwd} = setup( + { + 'tsconfig.json': JSON.stringify({compilerOptions: {assumeChangesOnlyAffectDirectDependencies: true}}), + 'a.ts': 'import "./b";', + 'b.ts': 'import "./c";', + 'c.ts': 'export {}; foo;', // <-- change here + 'global.ts': 'declare global {}; import "./b";', + }, + { + initialState: state, + }, + ); + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); + t.deepEqual(programState.getUpToDateResult(cwd + 'global.ts', '1234'), []); +}); + +test('handles circular dependencies', (t) => { + const files = { + 'tsconfig.json': '{}', + 'root.ts': 'import "./a";', + 'a.ts': 'import "./e"; import "./d"; import "./c"; import "./b";', + 'b.ts': 'import "./d"; import "./c"; import "./a";', + 'c.ts': 'import "./d";', + 'd.ts': 'import "./e"; import "./b";', + 'e.ts': 'export {};', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost, persistence} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'root.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'e.ts', '1234'), []); + + vol.appendFileSync(cwd + 'c.ts', 'foo;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'c.ts'); + + t.is(programState.getUpToDateResult(cwd + 'root.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.deepEqual(programState.getUpToDateResult(cwd + 'e.ts', '1234'), []); + + persistence.saveState = (_, {ts: _ts, ...rest}) => t.snapshot(yaml.dump(rest, {sortKeys: true})); + programState.save(); +}); + +test('handles multiple level of circular dependencies', (t) => { + const files = { + 'tsconfig.json': '{}', + 'a.ts': 'import "./c"; import "./b";', + 'b.ts': 'import "./a";', + 'c.ts': 'import "./e"; import "./d";', + 'd.ts': 'import "./c";', + 'e.ts': 'import "./f";', + 'f.ts': 'import "./d";', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'e.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'f.ts', '1234'), []); + + vol.appendFileSync(cwd + 'b.ts', 'foo;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'b.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'e.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'f.ts', '1234'), []); + + ({programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state})); + vol.appendFileSync(cwd + 'f.ts', 'foo;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'f.ts'); + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'e.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'f.ts', '1234'), undefined); +}); + +test('merges multiple level of circular dependencies', (t) => { + const files = { + 'tsconfig.json': '{}', + 'a.ts': 'import "./c"; import "./b";', + 'b.ts': 'import "./a";', + 'c.ts': 'import "./e"; import "./d";', + 'd.ts': 'import "./c";', + 'e.ts': 'import "./f";', + 'f.ts': 'import "./g"; import "./b";', + 'g.ts': 'export {};', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'e.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'f.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'g.ts', '1234'), []); + + vol.appendFileSync(cwd + 'g.ts', 'foo;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'g.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'e.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'f.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'g.ts', '1234'), undefined); +}); + +test('merges multiple level of circular dependencies II', (t) => { + const files = { + 'tsconfig.json': '{}', + 'a.ts': 'import "./e"; import "./b"; import "./a1";', + 'a1.ts': 'import "./a";', + 'b.ts': 'import "./b1";', + 'b1.ts': 'import "./c"; import "./b2"; import "./b";', + 'c.ts': 'import "./d";', + 'd.ts': 'import "./d2"; import "./d1";', + 'd1.ts': 'import "./d";', + 'd2.ts': 'import "./a";', + 'e.ts': 'export {};', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'a1.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b1.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd1.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'd2.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'e.ts', '1234'), []); + + vol.appendFileSync(cwd + 'e.ts', 'foo;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'e.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'a1.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b1.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd1.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'd2.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'e.ts', '1234'), undefined); +}); + +test('uses earliest circular dependency', (t) => { + const files = { + 'tsconfig.json': '{}', + 'a.ts': 'import "./b"; import "./a1";', + 'a1.ts': 'import "./a2";', + 'a2.ts': 'import "./a3";', + 'a3.ts': 'import "./a1"; import "./a"; import "./a2";', + 'b.ts': 'export {};', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'a1.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'a2.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'a3.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + + vol.appendFileSync(cwd + 'b.ts', 'foo;'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'b.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'a1.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'a2.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'a3.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); +}); + +test('detects when all files are still the same, but resolution changed', (t) => { + const files = { + 'tsconfig.json': JSON.stringify({exclude: ['deps'], compilerOptions: {moduleResolution: 'node'}}), + 'a.ts': 'import "x"; import "y";', + 'deps/x/index.ts': 'export let x;', + 'deps/y/index.ts': 'export let y;', + 'node_modules': null, + }; + const state = generateOldState('1234', files, {symlinks: [ + ['node_modules/x', 'deps/x'], + ['node_modules/y', 'deps/y'], + ]}); + + let {programState, cwd} = setup(files, {initialState: state, symlinks: [ + ['node_modules/x', 'deps/x'], + ['node_modules/y', 'deps/y'], + ]}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'deps/x/index.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'deps/y/index.ts', '1234'), []); + + ({programState, cwd} = setup(files, {initialState: state, symlinks: [ + ['node_modules/x', 'deps/y'], + ['node_modules/y', 'deps/x'], + ]})); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.deepEqual(programState.getUpToDateResult(cwd + 'deps/x/index.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'deps/y/index.ts', '1234'), []); +}); + +test('detects added module augmentation', (t) => { + const files = { + 'tsconfig.json': '{}', + 'a.ts': 'export {};', + 'b.ts': 'export {};', + 'c.ts': 'import "./a";', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + + vol.appendFileSync(cwd + 'b.ts', 'declare module "./a" {};'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'b.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); +}); + +test('changes in unresolved dependencies', (t) => { + const files = { + 'tsconfig.json': '{}', + 'a.ts': 'import "x";', + 'b.ts': 'import "y";', + 'c.ts': 'declare module "x";', + }; + const state = generateOldState('1234', files); + + let {programState, cwd, vol, program, compilerHost} = setup(files, {initialState: state}); + + t.deepEqual(programState.getUpToDateResult(cwd + 'a.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'b.ts', '1234'), []); + t.deepEqual(programState.getUpToDateResult(cwd + 'c.ts', '1234'), []); + + vol.writeFileSync(cwd + 'c.ts', 'declare module "y";'); + program = ts.createProgram({ + oldProgram: program, + options: program.getCompilerOptions(), + rootNames: program.getRootFileNames(), + host: compilerHost, + }); + programState.update(program, cwd + 'c.ts'); + + t.is(programState.getUpToDateResult(cwd + 'a.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'b.ts', '1234'), undefined); + t.is(programState.getUpToDateResult(cwd + 'c.ts', '1234'), undefined); +}); diff --git a/packages/wotan/test/runner.spec.ts b/packages/wotan/test/runner.spec.ts index b6e726ed6..b14345c01 100644 --- a/packages/wotan/test/runner.spec.ts +++ b/packages/wotan/test/runner.spec.ts @@ -6,8 +6,13 @@ import { createDefaultModule } from '../src/di/default.module'; import { Runner } from '../src/runner'; import * as path from 'path'; import { NodeFileSystem } from '../src/services/default/file-system'; -import { FileSystem, MessageHandler, DirectoryService, FileSummary } from '@fimbul/ymir'; +import { FileSystem, MessageHandler, DirectoryService, FileSummary, StatePersistence } from '@fimbul/ymir'; import { unixifyPath } from '../src/utils'; +import { Linter } from '../src/linter'; +import * as yaml from 'js-yaml'; +import { DefaultStatePersistence } from '../src/services/default/state-persistence'; +import * as ts from 'typescript'; +import { CachedFileSystem } from '../src/services/cached-file-system'; const directories: DirectoryService = { getCurrentDirectory() { return path.resolve('packages/wotan'); }, @@ -19,6 +24,7 @@ test('throws error on non-existing file', (t) => { const runner = container.get(Runner); t.throws( () => Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [ 'test/fixtures/invalid.js', // exists @@ -44,6 +50,7 @@ test('throws error on file not included in project', (t) => { const runner = container.get(Runner); t.throws( () => Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [ 'non-existent.js', // does not exist, but is excluded @@ -69,6 +76,7 @@ test('handles absolute paths with file system specific path separator', (t) => { container.load(createCoreModule({}), createDefaultModule()); const runner = container.get(Runner); const result = Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [ path.resolve('packages/wotan/test/project/setup/test.ts'), @@ -105,6 +113,7 @@ test('throws if no tsconfig.json can be found', (t) => { const {root} = path.parse(process.cwd()); t.throws( () => Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -120,6 +129,7 @@ test('throws if no tsconfig.json can be found', (t) => { const dir = path.join(__dirname, 'non-existent'); t.throws( () => Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -134,6 +144,7 @@ test('throws if no tsconfig.json can be found', (t) => { t.throws( () => Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -192,6 +203,7 @@ test('reports warnings while parsing tsconfig.json', (t) => { const runner = container.get(Runner); Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -205,6 +217,7 @@ test('reports warnings while parsing tsconfig.json', (t) => { warning = ''; Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -218,6 +231,7 @@ test('reports warnings while parsing tsconfig.json', (t) => { warning = ''; Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -233,6 +247,7 @@ test('reports warnings while parsing tsconfig.json', (t) => { warning = ''; Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -246,7 +261,7 @@ test('reports warnings while parsing tsconfig.json', (t) => { }); // TODO https://github.com/fimbullinter/wotan/issues/387 https://github.com/Microsoft/TypeScript/issues/26684 -test.skip('excludes symlinked typeRoots', (t) => { +test.failing('excludes symlinked typeRoots', (t) => { const container = new Container({defaultScope: BindingScopeEnum.Singleton}); container.bind(DirectoryService).toConstantValue(directories); interface FileMeta { @@ -344,6 +359,7 @@ test.skip('excludes symlinked typeRoots', (t) => { container.load(createCoreModule({}), createDefaultModule()); const runner = container.get(Runner); const result = Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -371,6 +387,7 @@ test('works with absolute and relative paths', (t) => { function testRunner(project: boolean) { const result = Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [ unixifyPath(path.resolve('packages/wotan/test/fixtures/paths/a.ts')), @@ -408,6 +425,7 @@ test('normalizes globs', (t) => { function testRunner(project: boolean) { const result = Array.from(runner.lintCollection({ + cache: false, config: undefined, files: [ '../paths/a.ts', @@ -435,6 +453,7 @@ test('supports linting multiple (overlapping) projects in one run', (t) => { const result = Array.from( runner.lintCollection({ + cache: false, config: undefined, files: [], exclude: [], @@ -448,3 +467,130 @@ test('supports linting multiple (overlapping) projects in one run', (t) => { ); t.snapshot(result, {id: 'multi-project'}); }); + +@injectable() +class TsVersionAgnosticStatePersistence extends DefaultStatePersistence { + constructor(fs: CachedFileSystem) { + super(fs); + } + public loadState(project: string) { + const result = super.loadState(project); + return result && {...result, ts: ts.version}; + } + public saveState() {} +} + +test('uses results from cache', (t) => { + const container = new Container({defaultScope: BindingScopeEnum.Singleton}); + container.bind(DirectoryService).toConstantValue(directories); + container.bind(StatePersistence).to(TsVersionAgnosticStatePersistence); + container.load(createCoreModule({}), createDefaultModule()); + container.get(Linter).getFindings = () => { throw new Error('should not be called'); }; + const runner = container.get(Runner); + + const result = Array.from( + runner.lintCollection({ + cache: true, + config: undefined, + files: [], + exclude: [], + project: ['test/fixtures/cache'], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: true, + }), + (entry): [string, FileSummary] => [unixifyPath(path.relative('packages/wotan/test/fixtures/cache', entry[0])), entry[1]], + ); + t.snapshot(result, {id: 'cache'}); +}); + +test('ignore cache if option is not enabled', (t) => { + const container = new Container({defaultScope: BindingScopeEnum.Singleton}); + container.bind(DirectoryService).toConstantValue(directories); + container.bind(StatePersistence).to(TsVersionAgnosticStatePersistence); + container.load(createCoreModule({}), createDefaultModule()); + container.get(StatePersistence).loadState = () => { throw new Error('should not be called'); }; + const runner = container.get(Runner); + + const result = Array.from( + runner.lintCollection({ + cache: false, + config: undefined, + files: [], + exclude: [], + project: ['test/fixtures/cache'], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: true, + }), + (entry): [string, FileSummary] => [unixifyPath(path.relative('packages/wotan/test/fixtures/cache', entry[0])), entry[1]], + ); + t.snapshot(result, {id: 'cache'}); +}); + +test('discards cache if config changes', (t) => { + const container = new Container({defaultScope: BindingScopeEnum.Singleton}); + container.bind(DirectoryService).toConstantValue(directories); + container.bind(StatePersistence).to(TsVersionAgnosticStatePersistence); + container.load(createCoreModule({}), createDefaultModule()); + const linter = container.get(Linter); + const getFindings = linter.getFindings; + const lintedFiles: string[] = []; + linter.getFindings = (...args) => { + lintedFiles.push(path.basename(args[0].fileName)); + return getFindings.apply(linter, args); + }; + const runner = container.get(Runner); + + const result = Array.from( + runner.lintCollection({ + cache: true, + config: undefined, + files: [], + exclude: [], + project: ['test/fixtures/cache'], + references: false, + fix: false, + extensions: undefined, + reportUselessDirectives: false, + }), + (entry): [string, FileSummary] => [unixifyPath(path.relative('packages/wotan/test/fixtures/cache', entry[0])), entry[1]], + ); + t.deepEqual(lintedFiles, ['a.ts', 'b.ts']); + t.snapshot(result, {id: 'cache-outdated'}); +}); + +test('cache and fix', (t) => { + const container = new Container({defaultScope: BindingScopeEnum.Singleton}); + container.bind(DirectoryService).toConstantValue(directories); + container.bind(StatePersistence).to(TsVersionAgnosticStatePersistence); + container.load(createCoreModule({}), createDefaultModule()); + container.get(StatePersistence).saveState = (_, {ts: _ts, cs: _cs, ...rest}) => t.snapshot(yaml.dump(rest, {sortKeys: true}), {id: 'updated-state'}); + const linter = container.get(Linter); + const getFindings = linter.getFindings; + const lintedFiles: string[] = []; + linter.getFindings = (...args) => { + lintedFiles.push(path.basename(args[0].fileName)); + return getFindings.apply(linter, args); + }; + const runner = container.get(Runner); + + const result = Array.from( + runner.lintCollection({ + cache: true, + config: undefined, + files: [], + exclude: [], + project: ['test/fixtures/cache'], + references: false, + fix: true, + extensions: undefined, + reportUselessDirectives: true, + }), + (entry): [string, FileSummary] => [unixifyPath(path.relative('packages/wotan/test/fixtures/cache', entry[0])), entry[1]], + ); + t.deepEqual(lintedFiles, ['b.ts']); + t.snapshot(result, {id: 'cache-fix'}); +}); diff --git a/packages/wotan/test/services.spec.ts b/packages/wotan/test/services.spec.ts index 79432a874..8df7ae4b1 100644 --- a/packages/wotan/test/services.spec.ts +++ b/packages/wotan/test/services.spec.ts @@ -11,6 +11,8 @@ import { DirectoryService, ConfigurationError, BuiltinResolver, + StatePersistence, + StaticProgramState, } from '@fimbul/ymir'; import { NodeDirectoryService } from '../src/services/default/directory-service'; import * as os from 'os'; @@ -31,6 +33,8 @@ import { DefaultDeprecationHandler } from '../src/services/default/deprecation-h import { FormatterLoader } from '../src/services/formatter-loader'; import { ProcessorLoader } from '../src/services/processor-loader'; import { satisfies } from 'semver'; +import * as yaml from 'js-yaml'; +import { DefaultStatePersistence } from '../src/services/default/state-persistence'; test('CacheFactory', (t) => { const cm = new DefaultCacheFactory(); @@ -427,3 +431,76 @@ test('ProcessorLoader', (t) => { r = require; t.throws(() => loader.loadProcessor('./fooBarBaz'), {message: /^Cannot find module '\.\/fooBarBaz'$/m}); }); + +test('StatePersistence', (t) => { + const container = new Container(); + container.bind(CachedFileSystem).toSelf(); + container.bind(CacheFactory).to(DefaultCacheFactory); + container.bind(StatePersistence).to(DefaultStatePersistence); + const state: StaticProgramState = { + cs: true, + files: [], + global: [], + lookup: {}, + options: '', + ts: ts.version, + v: 1, + }; + const fileContent = {state, v: 1}; + let written = false; + container.bind(FileSystem).toConstantValue({ + createDirectory() { throw new Error(); }, + deleteFile() { throw new Error(); }, + readDirectory() { throw new Error(); }, + realpath() { throw new Error(); }, + normalizePath: NodeFileSystem.normalizePath, + stat(f) { + switch (f) { + case './tsconfig-nonexist.fimbullintercache': + return { isDirectory() { return true; }, isFile() { return false; } }; + case './tsconfig-mismatch.fimbullintercache': + case './tsconfig-throws.fimbullintercache': + case './tsconfig-empty.fimbullintercache': + case './tsconfig-correct.fimbullintercache': + return { isDirectory() { return false; }, isFile() { return true; } }; + default: + throw t.fail('unexpected file ' + f); + } + }, + readFile(f) { + switch (f) { + case './tsconfig-correct.fimbullintercache': + return yaml.dump(fileContent); + case './tsconfig-mismatch.fimbullintercache': + return yaml.dump({state, v: 0}); + case './tsconfig-empty.fimbullintercache': + return ''; + case './tsconfig-throws.fimbullintercache': + throw new Error(); + default: + throw t.fail('unexpected file ' + f); + } + }, + writeFile(f, content) { + if (f === './tsconfig-throws.fimbullintercache') + throw new Error(); + t.is(f, './tsconfig-correct.fimbullintercache'); + t.deepEqual(yaml.load(content), fileContent); + written = true; + }, + }); + + const service = container.get(StatePersistence); + + t.is(service.loadState('./tsconfig-nonexist.json'), undefined); + t.is(service.loadState('./tsconfig-mismatch.json'), undefined); + t.is(service.loadState('./tsconfig-throws.json'), undefined); + t.is(service.loadState('./tsconfig-empty.json'), undefined); + t.deepEqual(service.loadState('./tsconfig-correct.json'), state); + + service.saveState('./tsconfig-throws.json', state); + t.is(written, false); + + service.saveState('./tsconfig-correct.json', state); + t.is(written, true); +}); diff --git a/packages/ymir/src/index.ts b/packages/ymir/src/index.ts index e97f13a5f..c5ab8f358 100644 --- a/packages/ymir/src/index.ts +++ b/packages/ymir/src/index.ts @@ -515,3 +515,46 @@ export interface FileFilter { /** @returns `true` if the file should be linted, false if it should be filtered out. Intended for use in `Array.prototype.filter`. */ filter(file: ts.SourceFile): boolean; } + +export interface StatePersistence { + loadState(project: string): StaticProgramState | undefined; + saveState(project: string, state: StaticProgramState): void; +} +export abstract class StatePersistence {} + +export interface StaticProgramState { + /** Version of the cache format */ + readonly v: number; + /** TypeScript version */ + readonly ts: string; + /** Whether the state was created using case-sensitive file names */ + readonly cs: boolean; + /** Hash of compilerOptions */ + readonly options: string; + /** Maps filename to index in 'files' array */ + readonly lookup: Readonly>; + /** Index of files that affect global scope */ + readonly global: readonly number[]; + /** Information about all files in the program */ + readonly files: readonly StaticProgramState.FileState[]; +} + +export namespace StaticProgramState { + export interface FileState { + /** Hash of file contents */ + readonly hash: string; + /** + * Key: module specifier as referenced in the file, order may be random + * Value: - `null` if dependency could not be resolved + * - List of files (or rather their index) that the module specifier resolves to. + * That is the actual file at that path and/or files containing `declare module "..."` for that module specifier. + * May contain the current file. + * This list is ordered by the hash of the files ascending, + */ + readonly dependencies?: Readonly>; + /** The list of findings if this file has up-to-date results */ + readonly result?: readonly Finding[]; + /** Hash of the configuration used to produce `result` for this file */ + readonly config?: string; + } +} diff --git a/tslint.json b/tslint.json index 30fcc81f1..b6d0021d1 100644 --- a/tslint.json +++ b/tslint.json @@ -54,7 +54,7 @@ "ignore-params" ], "no-irregular-whitespace": true, - "no-null-keyword": true, + "no-null-keyword": false, "no-return-await": true, "no-shadowed-variable": true, "no-sparse-arrays": true, diff --git a/yarn.lock b/yarn.lock index d0c7dc621..413c0aa7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1632,6 +1632,11 @@ fromentries@^1.2.0: resolved "https://registry.yarnpkg.com/fromentries/-/fromentries-1.3.2.tgz#e4bca6808816bf8f93b52750f1127f5a6fd86e3a" integrity sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg== +fs-monkey@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.1.tgz#4a82f36944365e619f4454d9fff106553067b781" + integrity sha512-fcSa+wyTqZa46iWweI7/ZiUfegOZl0SG8+dltIwFXo7+zYU9J9kpS3NB6pZcSlJdhvIwp81Adx2XhZorncxiaA== + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -2497,6 +2502,13 @@ mem@^8.0.0: map-age-cleaner "^0.1.3" mimic-fn "^3.1.0" +memfs@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.2.0.tgz#f9438e622b5acd1daa8a4ae160c496fdd1325b26" + integrity sha512-f/xxz2TpdKv6uDn6GtHee8ivFyxwxmPuXatBb1FBwxYNuVpbM3k/Y1Z+vC0mH/dIXXrukYfe3qe5J32Dfjg93A== + dependencies: + fs-monkey "1.0.1" + memory-fs@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c" @@ -3775,7 +3787,7 @@ tsutils@^2.29.0: dependencies: tslib "^1.8.1" -tsutils@^3.18.0, tsutils@^3.19.1, tsutils@^3.5.0, tsutils@^3.5.1: +tsutils@^3.19.1, tsutils@^3.20.0, tsutils@^3.5.0, tsutils@^3.5.1: version "3.20.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.20.0.tgz#ea03ea45462e146b53d70ce0893de453ff24f698" integrity sha512-RYbuQuvkhuqVeXweWT3tJLKOEJ/UUw9GjNEZGWdrLLlM+611o1gwLHBpxoFJKKl25fLprp2eVthtKs5JOrNeXg==