From 19656b8931df849568beb5ff038107389df7e8c6 Mon Sep 17 00:00:00 2001 From: Simo Kinnunen Date: Mon, 20 Jan 2025 15:14:30 +0900 Subject: [PATCH] feat: support tsconfig paths and package-relative imports [sc-22644] (#1006) * feat: async dependency collector * refactor: switch to a sync implementation to avoid changes to constructs * refactor: remove unnecessary try/catch * feat: look up original TS sources when dealing with folders as modules * fix: include package.json when resolving module folders * feat: let parser know how tsconfig paths were resolved for a file This optionally allows the parser to massage the file structure into an alternate output format. * feat: add jsconfig.json support * fix: supported module check was accidentally negated * feat: bundle relevant tsconfig/jsconfig.json files These files are currently not utilized by the backend but they might be in the near future. * feat: cache all source files and set up unique IDs for later dedup purposes * feat: add tests for tsconfig behavior * feat: more complete support for imports with extensions * chore: remove unused code * fix: remove mistakenly implemented useless package-relative path support * fix: remove unused variable * fix: remove unused variable * feat: cache common dependencies within a Session Shares a common Parser (or Parsers, one per runtime) within a Session and avoids unnecessary AST walks for filePaths the parser has already seen when parsing other entrypoints. Share PackageFilesResolver so that its file caches can be shared within the same Parser (which is now also shared within the Session), and cache its result per filePath to avoid duplicate work. Helps use cases where there are multiple entrypoints that share common libraries. * fix: remove allowImportingTsExtensions support Does not play well during a nested lookup when we're looking up a path that we've already resolved to a candidate with a .ts extension earlier in the process. Not a super useful feature anyway. * chore: empty out package-lock.json in fixtures, keep file * feat: support `index.json` which apparently works --- packages/cli/src/constructs/api-check.ts | 6 +- packages/cli/src/constructs/browser-check.ts | 6 +- .../cli/src/constructs/multi-step-check.ts | 6 +- packages/cli/src/constructs/project.ts | 18 + .../import-js-from-ts/dep1.js | 1 + .../import-js-from-ts/dep2.js | 1 + .../import-js-from-ts/dep3.ts | 1 + .../import-js-from-ts/entrypoint.ts | 3 + .../no-import-ts-from-js/dep1.ts | 1 + .../no-import-ts-from-js/dep2.js | 1 + .../no-import-ts-from-js/entrypoint.js | 4 + .../src/dep1.ts | 1 + .../src/dep2.ts | 1 + .../src/dep3.ts | 1 + .../src/entrypoint.ts | 3 + .../tsconfig.json | 11 + .../tsconfig-paths-sample-project/.gitignore | 1 + .../.node-version | 1 + .../checkly.config.ts | 22 ++ .../lib1/file1.ts | 1 + .../lib1/file2.ts | 1 + .../lib1/folder/file1.ts | 1 + .../lib1/folder/file2.ts | 1 + .../lib1/index.ts | 3 + .../lib1/package-lock.json | 1 + .../lib1/package.json | 12 + .../lib1/tsconfig.json | 18 + .../lib2/index.ts | 1 + .../lib3/foo/bar.ts | 1 + .../lib3/jsconfig.json | 9 + .../package-lock.json | 1 + .../package.json | 10 + .../src/entrypoint.ts | 3 + .../tsconfig.json | 17 + .../tsconfig-paths-unused/src/entrypoint.ts | 1 + .../tsconfig-paths-unused/tsconfig.json | 17 + .../__tests__/check-parser.spec.ts | 75 ++++ .../check-parser/package-files/extension.ts | 76 ++++ .../package-files/jsconfig-json-file.ts | 27 ++ .../package-files/json-source-file.ts | 27 ++ .../check-parser/package-files/loader.ts | 19 + .../check-parser/package-files/lookup.ts | 66 ++++ .../package-files/package-json-file.ts | 45 +++ .../check-parser/package-files/paths.ts | 262 +++++++++++++ .../check-parser/package-files/resolver.ts | 357 ++++++++++++++++++ .../check-parser/package-files/source-file.ts | 48 +++ .../package-files/tsconfig-json-file.ts | 165 ++++++++ .../cli/src/services/check-parser/parser.ts | 157 +++----- 48 files changed, 1400 insertions(+), 111 deletions(-) create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts create mode 100644 packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json create mode 100644 packages/cli/src/services/check-parser/package-files/extension.ts create mode 100644 packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts create mode 100644 packages/cli/src/services/check-parser/package-files/json-source-file.ts create mode 100644 packages/cli/src/services/check-parser/package-files/loader.ts create mode 100644 packages/cli/src/services/check-parser/package-files/lookup.ts create mode 100644 packages/cli/src/services/check-parser/package-files/package-json-file.ts create mode 100644 packages/cli/src/services/check-parser/package-files/paths.ts create mode 100644 packages/cli/src/services/check-parser/package-files/resolver.ts create mode 100644 packages/cli/src/services/check-parser/package-files/source-file.ts create mode 100644 packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts diff --git a/packages/cli/src/constructs/api-check.ts b/packages/cli/src/constructs/api-check.ts index 5f3a7224..24b10363 100644 --- a/packages/cli/src/constructs/api-check.ts +++ b/packages/cli/src/constructs/api-check.ts @@ -3,7 +3,6 @@ import { Check, CheckProps } from './check' import { HttpHeader } from './http-header' import { Session } from './project' import { QueryParam } from './query-param' -import { Parser } from '../services/check-parser/parser' import { pathToPosix } from '../services/util' import { printDeprecationWarning } from '../reporters/util' import { Content, Entrypoint } from './construct' @@ -352,10 +351,7 @@ export class ApiCheck extends Check { if (!runtime) { throw new Error(`${runtimeId} is not supported`) } - const parser = new Parser({ - supportedNpmModules: Object.keys(runtime.dependencies), - checkUnsupportedModules: Session.verifyRuntimeDependencies, - }) + const parser = Session.getParser(runtime) const parsed = parser.parse(absoluteEntrypoint) // Maybe we can get the parsed deps with the content immediately diff --git a/packages/cli/src/constructs/browser-check.ts b/packages/cli/src/constructs/browser-check.ts index ce139d16..2fc4b58e 100644 --- a/packages/cli/src/constructs/browser-check.ts +++ b/packages/cli/src/constructs/browser-check.ts @@ -1,7 +1,6 @@ import * as path from 'path' import { Check, CheckProps } from './check' import { Session } from './project' -import { Parser } from '../services/check-parser/parser' import { CheckConfigDefaults } from '../services/checkly-config-loader' import { pathToPosix } from '../services/util' import { Content, Entrypoint } from './construct' @@ -119,10 +118,7 @@ export class BrowserCheck extends Check { if (!runtime) { throw new Error(`${runtimeId} is not supported`) } - const parser = new Parser({ - supportedNpmModules: Object.keys(runtime.dependencies), - checkUnsupportedModules: Session.verifyRuntimeDependencies, - }) + const parser = Session.getParser(runtime) const parsed = parser.parse(entry) // Maybe we can get the parsed deps with the content immediately diff --git a/packages/cli/src/constructs/multi-step-check.ts b/packages/cli/src/constructs/multi-step-check.ts index 8e1500e9..83718930 100644 --- a/packages/cli/src/constructs/multi-step-check.ts +++ b/packages/cli/src/constructs/multi-step-check.ts @@ -1,7 +1,6 @@ import * as path from 'path' import { Check, CheckProps } from './check' import { Session } from './project' -import { Parser } from '../services/check-parser/parser' import { CheckConfigDefaults } from '../services/checkly-config-loader' import { pathToPosix } from '../services/util' import { Content, Entrypoint } from './construct' @@ -104,10 +103,7 @@ export class MultiStepCheck extends Check { if (!runtime) { throw new Error(`${runtimeId} is not supported`) } - const parser = new Parser({ - supportedNpmModules: Object.keys(runtime.dependencies), - checkUnsupportedModules: Session.verifyRuntimeDependencies, - }) + const parser = Session.getParser(runtime) const parsed = parser.parse(entry) // Maybe we can get the parsed deps with the content immediately diff --git a/packages/cli/src/constructs/project.ts b/packages/cli/src/constructs/project.ts index 169c96b1..31553451 100644 --- a/packages/cli/src/constructs/project.ts +++ b/packages/cli/src/constructs/project.ts @@ -1,5 +1,6 @@ import * as api from '../rest/api' import { CheckConfigDefaults } from '../services/checkly-config-loader' +import { Parser } from '../services/check-parser/parser' import { Construct } from './construct' import { ValidationError } from './validator-error' @@ -147,6 +148,7 @@ export class Session { static loadingChecklyConfigFile: boolean static checklyConfigFileConstructs?: Construct[] static privateLocations: PrivateLocationApi[] + static parsers = new Map() static registerConstruct (construct: Construct) { if (Session.project) { @@ -191,4 +193,20 @@ export class Session { } return Session.availableRuntimes[effectiveRuntimeId] } + + static getParser (runtime: Runtime): Parser { + const cachedParser = Session.parsers.get(runtime.name) + if (cachedParser !== undefined) { + return cachedParser + } + + const parser = new Parser({ + supportedNpmModules: Object.keys(runtime.dependencies), + checkUnsupportedModules: Session.verifyRuntimeDependencies, + }) + + Session.parsers.set(runtime.name, parser) + + return parser + } } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js new file mode 100644 index 00000000..d99d91de --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep1.js @@ -0,0 +1 @@ +export const value = 'hello' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js new file mode 100644 index 00000000..51a1e4c5 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep2.js @@ -0,0 +1 @@ +export const value = 'world' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts new file mode 100644 index 00000000..e2befb13 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/dep3.ts @@ -0,0 +1 @@ +export const value = 3 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts new file mode 100644 index 00000000..f80d396f --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/import-js-from-ts/entrypoint.ts @@ -0,0 +1,3 @@ +import { } from './dep1' +import { } from './dep2.js' +import { } from './dep3.js' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts new file mode 100644 index 00000000..d99d91de --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep1.ts @@ -0,0 +1 @@ +export const value = 'hello' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js new file mode 100644 index 00000000..51a1e4c5 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/dep2.js @@ -0,0 +1 @@ +export const value = 'world' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js new file mode 100644 index 00000000..7bee0690 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/no-import-ts-from-js/entrypoint.js @@ -0,0 +1,4 @@ +import { } from './dep1' +import { } from './dep1.ts' +import { } from './dep1.js' +import { } from './dep2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts new file mode 100644 index 00000000..062a3151 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep1.ts @@ -0,0 +1 @@ +export const value1 = 'value1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts new file mode 100644 index 00000000..2407fa53 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep2.ts @@ -0,0 +1 @@ +export const value2 = 'value2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts new file mode 100644 index 00000000..28b05968 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/dep3.ts @@ -0,0 +1 @@ +export const value3 = 'value3' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts new file mode 100644 index 00000000..5ab303a3 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/src/entrypoint.ts @@ -0,0 +1,3 @@ +export { value1 } from './dep1.ts' +export { value2 } from './dep2.js' +export { value3 } from './dep3' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json new file mode 100644 index 00000000..ba221115 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-allow-importing-ts-extensions/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": ".", + "noEmit": true, + "allowImportingTsExtensions": true + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version new file mode 100644 index 00000000..dc0bb0f4 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/.node-version @@ -0,0 +1 @@ +v22.12.0 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts new file mode 100644 index 00000000..3a9ab4b7 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/checkly.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'checkly' + +const config = defineConfig({ + projectName: 'TSConfig Paths Sample Project', + logicalId: 'tsconfig-paths-sample-project', + checks: { + frequency: 10, + locations: ['us-east-1'], + tags: ['mac'], + runtimeId: '2024.09', + checkMatch: '**/__checks__/**/*.check.ts', + browserChecks: { + testMatch: '**/__checks__/**/*.spec.ts', + }, + }, + cli: { + runLocation: 'us-east-1', + reporters: ['list'], + }, +}) + +export default config diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts new file mode 100644 index 00000000..e867f53f --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file1.ts @@ -0,0 +1 @@ +export const value = 'file1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts new file mode 100644 index 00000000..2c448345 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/file2.ts @@ -0,0 +1 @@ +export const value = 'file2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts new file mode 100644 index 00000000..7e2747b1 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file1.ts @@ -0,0 +1 @@ +export { value } from './file2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts new file mode 100644 index 00000000..2c448345 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/folder/file2.ts @@ -0,0 +1 @@ +export const value = 'file2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts new file mode 100644 index 00000000..538b6eeb --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/index.ts @@ -0,0 +1,3 @@ +export { value as value1 } from './file1' +export { value as value2 } from './file2' +export { value as value3 } from './folder/file1' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package-lock.json @@ -0,0 +1 @@ +{} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json new file mode 100644 index 00000000..8d48c93b --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/package.json @@ -0,0 +1,12 @@ +{ + "name": "@internal/lib", + "main": "dist/index.js", + "scripts": { + "clean": "rimraf ./dist", + "prepare": "npm run clean && tsc --build" + }, + "devDependencies": { + "rimraf": "^6.0.1", + "typescript": "^5.7.2" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json new file mode 100644 index 00000000..a9a89286 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib1/tsconfig.json @@ -0,0 +1,18 @@ +{ + "exclude": [ + "dist", + "node_modules" + ], + "include": [ + "./**/*.ts" + ], + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "outDir": "dist", + "declaration": true, + "sourceMap": true, + "declarationMap": true + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts new file mode 100644 index 00000000..ea677927 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib2/index.ts @@ -0,0 +1 @@ +export const name = 'lib2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts new file mode 100644 index 00000000..02d000b6 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/foo/bar.ts @@ -0,0 +1 @@ +export const value = 11517 diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json new file mode 100644 index 00000000..7eb29aae --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/lib3/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": "." + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package-lock.json @@ -0,0 +1 @@ +{} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json new file mode 100644 index 00000000..0b60ed9a --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/package.json @@ -0,0 +1,10 @@ +{ + "name": "tsconfig-paths-sample-project", + "dependencies": { + "checkly": "^4.15.0" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "typescript": "^5.7.2" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts new file mode 100644 index 00000000..31454212 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/src/entrypoint.ts @@ -0,0 +1,3 @@ +import { value1 } from '@internal/lib1' +import { value } from '@/foo/bar' +import { name } from 'lib2' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json new file mode 100644 index 00000000..b663f283 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-sample-project/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@internal/lib1": [ + "./lib1" + ], + "@/*": [ + "./lib3/*" + ] + } + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts new file mode 100644 index 00000000..c385a564 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/src/entrypoint.ts @@ -0,0 +1 @@ +export const value = 'nothing here' diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json new file mode 100644 index 00000000..b663f283 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/tsconfig-paths-unused/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "nodenext", + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@internal/lib1": [ + "./lib1" + ], + "@/*": [ + "./lib3/*" + ] + } + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts b/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts index 783e77a5..5612c7c6 100644 --- a/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts @@ -127,6 +127,81 @@ describe('dependency-parser - parser()', () => { ]) }) + it('should parse typescript dependencies using tsconfig', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-paths-sample-project', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([ + toAbsolutePath('lib1', 'file1.ts'), + toAbsolutePath('lib1', 'file2.ts'), + toAbsolutePath('lib1', 'folder', 'file1.ts'), + toAbsolutePath('lib1', 'folder', 'file2.ts'), + toAbsolutePath('lib1', 'index.ts'), + toAbsolutePath('lib1', 'package.json'), + toAbsolutePath('lib1', 'tsconfig.json'), + toAbsolutePath('lib2', 'index.ts'), + toAbsolutePath('lib3', 'foo', 'bar.ts'), + toAbsolutePath('tsconfig.json'), + ]) + }) + + it('should not include tsconfig if not needed', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-paths-unused', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([]) + }) + + it('should support importing ts extensions if allowed', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'tsconfig-allow-importing-ts-extensions', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('src', 'entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([ + toAbsolutePath('src', 'dep1.ts'), + toAbsolutePath('src', 'dep2.ts'), + toAbsolutePath('src', 'dep3.ts'), + ]) + }) + + it('should not import TS files from a JS file', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'no-import-ts-from-js', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + expect.assertions(1) + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { dependencies } = parser.parse(toAbsolutePath('entrypoint.js')) + } catch (err) { + expect(err).toMatchObject({ + missingFiles: [ + toAbsolutePath('dep1'), + toAbsolutePath('dep1.ts'), + toAbsolutePath('dep1.js'), + ], + }) + } + }) + + it('should import JS files from a TS file', () => { + const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'import-js-from-ts', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + }) + const { dependencies } = parser.parse(toAbsolutePath('entrypoint.ts')) + expect(dependencies.map(d => d.filePath).sort()).toEqual([ + toAbsolutePath('dep1.js'), + toAbsolutePath('dep2.js'), + toAbsolutePath('dep3.ts'), + ]) + }) + it('should handle ES Modules', () => { const toAbsolutePath = (...filepath: string[]) => path.join(__dirname, 'check-parser-fixtures', 'esmodules-example', ...filepath) const parser = new Parser({ diff --git a/packages/cli/src/services/check-parser/package-files/extension.ts b/packages/cli/src/services/check-parser/package-files/extension.ts new file mode 100644 index 00000000..d1510f5e --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/extension.ts @@ -0,0 +1,76 @@ +import path from 'node:path' + +export type CoreExtension = '.js' | '.mjs' | '.cjs' | '.json' + +export const CoreExtensions: CoreExtension[] = ['.js', '.mjs', '.cjs', '.json'] + +type CoreExtensionMapping = { + [key in CoreExtension]: string[] +} + +/** + * @see https://www.typescriptlang.org/docs/handbook/modules/reference.html#file-extension-substitution + */ +export const tsCoreExtensionLookupOrder: CoreExtensionMapping = { + '.js': ['.ts', '.tsx', '.js', '.jsx'], + '.mjs': ['.mts', '.mjs'], + '.cjs': ['.cts', '.cjs'], + '.json': ['.json'], +} + +export type TSExtension = '.ts' | '.mts' | '.tsx' + +export const TSExtensions: TSExtension[] = ['.ts', '.mts', '.tsx'] + +type TSExtensionMapping = { + [key in TSExtension]: string[] +} + +export const tsExtensionLookupOrder: TSExtensionMapping = { + '.ts': ['.ts'], + '.mts': ['.mts'], + '.tsx': ['.tsx'], +} + +function stripKnownSuffix (value: string, suffix: string): string { + return value.substring(0, value.length - suffix.length) +} + +export class FileExtPath { + filePath: string + ext: string + + private constructor (filePath: string, ext: string) { + this.filePath = filePath + this.ext = ext + } + + static fromFilePath (filePath: string) { + const ext = path.extname(filePath) + return new FileExtPath(filePath, ext) + } + + hasCoreExtension () { + return this.ext in tsCoreExtensionLookupOrder + } + + hasTypeScriptExtension () { + return this.ext in tsExtensionLookupOrder + } + + replaceExt (ext: string) { + return stripKnownSuffix(this.filePath, this.ext) + ext + } + + appendExt (ext: string) { + return this.filePath + ext + } + + resolve (...paths: string[]) { + return path.resolve(this.filePath, ...paths) + } + + self () { + return this.filePath + } +} diff --git a/packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts b/packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts new file mode 100644 index 00000000..95b7b49b --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/jsconfig-json-file.ts @@ -0,0 +1,27 @@ +import path from 'node:path' + +import { TSConfigFile, Schema } from './tsconfig-json-file' +import { JsonSourceFile } from './json-source-file' + +/** + * JSConfigFile is essentially the exact same as TSConfigFile but with + * allowJs implicitly enabled. + * + * While we could handle jsconfig.json with just TSConfigFile, it's not that + * much extra trouble to have a separate wrapper for it and doing it this way + * may enable some interesting features later. + */ +export class JSConfigFile extends TSConfigFile { + static FILENAME = 'jsconfig.json' + + static #id = 0 + readonly id = ++JSConfigFile.#id + + static filePath (dirPath: string) { + return path.join(dirPath, JSConfigFile.FILENAME) + } + + static loadFromJsonSourceFile (jsonFile: JsonSourceFile): JSConfigFile | undefined { + return new JSConfigFile(jsonFile) + } +} diff --git a/packages/cli/src/services/check-parser/package-files/json-source-file.ts b/packages/cli/src/services/check-parser/package-files/json-source-file.ts new file mode 100644 index 00000000..6dd5a350 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/json-source-file.ts @@ -0,0 +1,27 @@ +import { SourceFile } from './source-file' + +export class JsonSourceFile { + static #id = 0 + readonly id = ++JsonSourceFile.#id + + sourceFile: SourceFile + data: Schema + + private constructor (sourceFile: SourceFile, data: Schema) { + this.sourceFile = sourceFile + this.data = data + } + + public get meta () { + return this.sourceFile.meta + } + + static loadFromSourceFile (sourceFile: SourceFile): JsonSourceFile | undefined { + try { + const data: Schema = JSON.parse(sourceFile.contents) + + return new JsonSourceFile(sourceFile, data) + } catch (err: any) { + } + } +} diff --git a/packages/cli/src/services/check-parser/package-files/loader.ts b/packages/cli/src/services/check-parser/package-files/loader.ts new file mode 100644 index 00000000..b99a6bb1 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/loader.ts @@ -0,0 +1,19 @@ +export type LoadFile = (filePath: string) => T | undefined + +export class FileLoader { + loader: LoadFile + cache = new Map() + + constructor (loader: LoadFile) { + this.loader = loader + } + + load (filePath: string): T | undefined { + if (this.cache.has(filePath)) { + return this.cache.get(filePath) + } + const file = this.loader(filePath) + this.cache.set(filePath, file) + return file + } +} diff --git a/packages/cli/src/services/check-parser/package-files/lookup.ts b/packages/cli/src/services/check-parser/package-files/lookup.ts new file mode 100644 index 00000000..bfc106c4 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/lookup.ts @@ -0,0 +1,66 @@ +import { PackageJsonFile } from './package-json-file' +import { CoreExtension, FileExtPath, tsCoreExtensionLookupOrder, TSExtension, tsExtensionLookupOrder } from './extension' + +type Options = { + plainJs?: boolean +} + +export class LookupContext { + #plainJs: boolean + + constructor (options: Options) { + this.#plainJs = options.plainJs ?? false + } + + static forFilePath (filePath: string) { + return new LookupContext({ + plainJs: FileExtPath.fromFilePath(filePath).hasCoreExtension(), + }) + } + + collectLookupPaths (filePath: string): string[] { + const extPath = FileExtPath.fromFilePath(filePath) + + if (this.#plainJs) { + if (extPath.hasCoreExtension()) { + return [extPath.self()] + } + + return this.extlessCoreLookupPaths(extPath) + } + + if (extPath.hasCoreExtension()) { + const extensions = tsCoreExtensionLookupOrder[extPath.ext as CoreExtension] + return extensions.map(ext => extPath.replaceExt(ext)) + } + + if (extPath.hasTypeScriptExtension()) { + const extensions = tsExtensionLookupOrder[extPath.ext as TSExtension] + return extensions.map(ext => extPath.replaceExt(ext)) + } + + return this.extlessTSLookupPaths(extPath) + } + + private extlessCoreLookupPaths (extPath: FileExtPath): string[] { + return [ + extPath.appendExt('.js'), + extPath.appendExt('.mjs'), + extPath.appendExt('.cjs'), + extPath.appendExt('.json'), + extPath.resolve(PackageJsonFile.FILENAME), + extPath.resolve('index.js'), + extPath.resolve('index.mjs'), + extPath.resolve('index.cjs'), + extPath.resolve('index.json'), // Yes, this works. + ] + } + + private extlessTSLookupPaths (extPath: FileExtPath): string[] { + return this.extlessCoreLookupPaths(extPath).flatMap(filePath => { + const extPath = FileExtPath.fromFilePath(filePath) + const extensions = tsCoreExtensionLookupOrder[extPath.ext as CoreExtension] + return extensions.map(ext => extPath.replaceExt(ext)) + }) + } +} diff --git a/packages/cli/src/services/check-parser/package-files/package-json-file.ts b/packages/cli/src/services/check-parser/package-files/package-json-file.ts new file mode 100644 index 00000000..7a89ddcb --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/package-json-file.ts @@ -0,0 +1,45 @@ +import path from 'node:path' + +import { JsonSourceFile } from './json-source-file' + +type ExportCondition = + 'node-addons' | 'node' | 'import' | 'require' | 'module-sync' | 'default' + +type Schema = { + main?: string + exports?: string | string[] | Record | Record> +} + +export class PackageJsonFile { + static FILENAME = 'package.json' + + static #id = 0 + readonly id = ++PackageJsonFile.#id + + jsonFile: JsonSourceFile + basePath: string + mainPaths: string[] + + private constructor (jsonFile: JsonSourceFile) { + this.jsonFile = jsonFile + this.basePath = jsonFile.meta.dirname + + const fallbackMainPath = path.resolve(this.basePath, 'index.js') + + this.mainPaths = jsonFile.data.main !== undefined + ? [path.resolve(this.basePath, jsonFile.data.main), fallbackMainPath] + : [fallbackMainPath] + } + + public get meta () { + return this.jsonFile.meta + } + + static loadFromJsonSourceFile (jsonFile: JsonSourceFile): PackageJsonFile | undefined { + return new PackageJsonFile(jsonFile) + } + + static filePath (dirPath: string) { + return path.join(dirPath, PackageJsonFile.FILENAME) + } +} diff --git a/packages/cli/src/services/check-parser/package-files/paths.ts b/packages/cli/src/services/check-parser/package-files/paths.ts new file mode 100644 index 00000000..b3998d29 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/paths.ts @@ -0,0 +1,262 @@ +import path from 'node:path' + +type Paths = Record> + +class TargetPathSpec { + /** + * Prefix is the part of the path before an asterisk (wildcard), or the + * whole path if there's no asterisk. + * + * Examples of possible values: + * - `"./foo/*"` (from `"./foo/"`) + * - `"./bar/foo-"` (from `"./bar/foo-*.js"`) + * - `"./bar."` (from `"./bar.*.ts"`) + * - `""` (from `"*"`) + */ + prefix: string + + /** + * Suffix is the part of the path after the asterisk (wildcard), if any. + */ + suffix?: string + + protected constructor (prefix: string, suffix?: string) { + this.prefix = prefix + this.suffix = suffix + } + + toPath (joker?: string) { + if (this.suffix === undefined) { + return this.prefix + } + + if (joker === undefined) { + return this.prefix + this.suffix + } + + return this.prefix + joker + this.suffix + } + + static create (spec: string) { + const parts = spec.split('*', 2) + if (parts.length === 1) { + return new TargetPathSpec(spec) + } + const [prefix, suffix] = parts + return new TargetPathSpec(prefix, suffix) + } +} + +export type TargetPathResult = { + spec: TargetPathSpec, + path: string, +} + +class PathMatchResult { + ok: boolean + results: TargetPathResult[] + + private constructor (ok: boolean, results: TargetPathResult[]) { + this.ok = ok + this.results = results + } + + static some (results: TargetPathResult[]) { + return new PathMatchResult(true, results) + } + + static none () { + return new PathMatchResult(false, []) + } +} + +interface PathMatcher { + get prefixLength (): number + match (importPath: string): PathMatchResult +} + +class ExactPathMatcher implements PathMatcher { + prefix: string + target: TargetPathSpec[] + + constructor (prefix: string, target: TargetPathSpec[]) { + this.prefix = prefix + this.target = target + } + + get prefixLength (): number { + return this.prefix.length + } + + match (importPath: string): PathMatchResult { + if (importPath === this.prefix) { + return PathMatchResult.some(this.target.map(target => { + return { + spec: target, + path: target.toPath(), + } + })) + } + + return PathMatchResult.none() + } +} + +class WildcardPathMatcher implements PathMatcher { + prefix: string + suffix: string + target: TargetPathSpec[] + + constructor (prefix: string, suffix: string, target: TargetPathSpec[]) { + this.prefix = prefix + this.suffix = suffix + this.target = target + } + + get prefixLength (): number { + return this.prefix.length + } + + match (importPath: string): PathMatchResult { + if (importPath.startsWith(this.prefix) && importPath.endsWith(this.suffix)) { + const joker = importPath.substring(this.prefix.length, importPath.length - this.suffix.length) + return PathMatchResult.some(this.target.map(target => { + return { + spec: target, + path: target.toPath(joker), + } + })) + } + + return PathMatchResult.none() + } +} + +class SourcePathSpec { + /** + * Prefix is the part of the path before an asterisk (wildcard), or the + * whole path if there's no asterisk. + * + * Examples of possible values: + * - `"@/"` (from `"@/*"`) + * - `"app/foo-"` (from `"app/foo-*.js"`) + * - `"bar."` (from `"bar.*.ts"`) + * - `""` (from `"*"`) + */ + prefix: string + + /** + * Suffix is the part of the path after the asterisk (wildcard), if any. + */ + suffix?: string + + protected constructor (prefix: string, suffix?: string) { + this.prefix = prefix + this.suffix = suffix + } + + matcherForTarget (target: TargetPathSpec[]): PathMatcher { + if (this.suffix === undefined) { + return new ExactPathMatcher(this.prefix, target) + } + + return new WildcardPathMatcher(this.prefix, this.suffix, target) + } + + static create (spec: string) { + const parts = spec.split('*', 2) + if (parts.length === 1) { + return new SourcePathSpec(spec) + } + const [prefix, suffix] = parts + return new SourcePathSpec(prefix, suffix) + } +} + +type SourcePathSpecMatcher = { + spec: SourcePathSpec, + matcher: PathMatcher, +} + +export type SourcePathResult = { + spec: SourcePathSpec, + path: string +} + +export type PathResult = { + source: SourcePathResult + target: TargetPathResult +} + +export type ResolveResult = PathResult[] + +export class PathResolver { + baseUrl: string + matchers: SourcePathSpecMatcher[] + + private constructor (baseUrl: string, matchers: SourcePathSpecMatcher[]) { + this.baseUrl = baseUrl + + // Sort by longest prefix now, then we don't have to care about it later. + matchers.sort((a, b) => b.matcher.prefixLength - a.matcher.prefixLength) + + this.matchers = matchers + } + + resolve (importPath: string): ResolveResult { + for (const { spec, matcher } of this.matchers) { + const match = matcher.match(importPath) + if (match.ok) { + // We can just return the first match since matchers are already + // sorted by longest prefix. + return match.results.map(result => { + return { + source: { + spec, + path: importPath, + }, + target: result, + } + }) + } + } + + return [] + } + + static createFromPaths (baseUrl: string, paths: Paths): PathResolver { + const matchers: SourcePathSpecMatcher[] = [] + + for (const path in paths) { + matchers.push(PathResolver.matcherForPath(path, paths[path])) + } + + return new PathResolver(baseUrl, matchers) + } + + private static matcherForPath (spec: string, target: string[]): SourcePathSpecMatcher { + const pathSpec = SourcePathSpec.create(spec) + const matcher = pathSpec.matcherForTarget(target.map(TargetPathSpec.create)) + + return { + spec: pathSpec, + matcher, + } + } +} + +export function isLocalPath (importPath: string) { + if (importPath.startsWith('/')) { + return true + } + + if (importPath.startsWith('./')) { + return true + } + + if (importPath.startsWith('../')) { + return true + } + + return false +} diff --git a/packages/cli/src/services/check-parser/package-files/resolver.ts b/packages/cli/src/services/check-parser/package-files/resolver.ts new file mode 100644 index 00000000..072011b4 --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/resolver.ts @@ -0,0 +1,357 @@ +/* eslint-disable no-labels */ + +import path from 'node:path' + +import { SourceFile } from './source-file' +import { PackageJsonFile } from './package-json-file' +import { TSConfigFile } from './tsconfig-json-file' +import { JSConfigFile } from './jsconfig-json-file' +import { isLocalPath, PathResult } from './paths' +import { FileLoader, LoadFile } from './loader' +import { JsonSourceFile } from './json-source-file' +import { LookupContext } from './lookup' + +class PackageFilesCache { + #sourceFileCache = new FileLoader(SourceFile.loadFromFilePath) + + #jsonFileLoader (load: (jsonFile: JsonSourceFile) => T | undefined): LoadFile { + return filePath => { + const sourceFile = this.#sourceFileCache.load(filePath) + if (sourceFile === undefined) { + return + } + + const jsonFile = JsonSourceFile.loadFromSourceFile(sourceFile) + if (jsonFile === undefined) { + return + } + + return load(jsonFile) + } + } + + #packageJsonCache = new FileLoader(this.#jsonFileLoader(PackageJsonFile.loadFromJsonSourceFile)) + #tsconfigJsonCache = new FileLoader(this.#jsonFileLoader(TSConfigFile.loadFromJsonSourceFile)) + #jsconfigJsonCache = new FileLoader(this.#jsonFileLoader(JSConfigFile.loadFromJsonSourceFile)) + + sourceFile (filePath: string, context: LookupContext) { + for (const lookupPath of context.collectLookupPaths(filePath)) { + const sourceFile = this.#sourceFileCache.load(lookupPath) + if (sourceFile === undefined) { + continue + } + + return sourceFile + } + } + + packageJson (filePath: string) { + return this.#packageJsonCache.load(filePath) + } + + tsconfigJson (filePath: string) { + return this.#tsconfigJsonCache.load(filePath) + } + + jsconfigJson (filePath: string) { + return this.#jsconfigJsonCache.load(filePath) + } +} + +class PackageFiles { + packageJson?: PackageJsonFile + tsconfigJson?: TSConfigFile + jsconfigJson?: JSConfigFile + + satisfyFromDirPath (dirPath: string, cache: PackageFilesCache): boolean { + if (this.packageJson === undefined) { + this.packageJson = cache.packageJson(PackageJsonFile.filePath(dirPath)) + } + + if (this.tsconfigJson === undefined && this.jsconfigJson === undefined) { + this.tsconfigJson = cache.tsconfigJson(TSConfigFile.filePath(dirPath)) + } + + if (this.jsconfigJson === undefined && this.tsconfigJson === undefined) { + this.jsconfigJson = cache.jsconfigJson(JSConfigFile.filePath(dirPath)) + } + + return this.satisfied + } + + get satisfied (): boolean { + // Never satisfied until we find a package.json file. + if (this.packageJson === undefined) { + return false + } + + // Not satisfied until either a tsconfig.json or a jsconfig.json file + // is found. + if (this.tsconfigJson === undefined && this.jsconfigJson === undefined) { + return false + } + + return true + } +} + +type TSConfigFileLocalDependency = { + kind: 'tsconfig-file' + importPath: string + sourceFile: SourceFile + configFile: TSConfigFile +} + +type TSConfigResolvedPathLocalDependency = { + kind: 'tsconfig-resolved-path' + importPath: string + sourceFile: SourceFile + configFile: TSConfigFile + pathResult: PathResult +} + +type TSConfigBaseUrlRelativePathLocalDependency = { + kind: 'tsconfig-baseurl-relative-path' + importPath: string + configFile: TSConfigFile + sourceFile: SourceFile +} + +type RelativePathLocalDependency = { + kind: 'relative-path' + importPath: string + sourceFile: SourceFile +} + +type LocalDependency = + TSConfigFileLocalDependency | + TSConfigResolvedPathLocalDependency | + TSConfigBaseUrlRelativePathLocalDependency | + RelativePathLocalDependency + +type MissingDependency = { + importPath: string, + filePath: string, +} + +type ExternalDependency = { + importPath: string +} + +export type Dependencies = { + external: ExternalDependency[], + missing: MissingDependency[], + local: LocalDependency[], +} + +export class PackageFilesResolver { + cache = new PackageFilesCache() + + loadPackageFiles (filePath: string, options?: { root?: string }): PackageFiles { + const files = new PackageFiles() + + let currentPath = filePath + + while (true) { + const prevPath = currentPath + + currentPath = path.dirname(prevPath) + + // Bail out if we reach root. + if (prevPath === currentPath) { + break + } + + // Try to find all files and stop if we do. + if (files.satisfyFromDirPath(currentPath, this.cache)) { + break + } + + // Stop if we reach the user-specified root directory. + // TODO: I don't like a string comparison for this but it'll do for now. + if (currentPath === options?.root) { + break + } + } + + return files + } + + private resolveSourceFile (sourceFile: SourceFile, context: LookupContext): SourceFile[] { + if (sourceFile.meta.basename === PackageJsonFile.FILENAME) { + const packageJson = this.cache.packageJson(sourceFile.meta.filePath) + if (packageJson === undefined) { + // This should never happen unless the package.json is invalid or + // something. + return [sourceFile] + } + + // Go through each main path. A fallback path is included. If we can + // find a tsconfig for the main file, look it up and attempt to find + // the original TypeScript sources roughly the same way tsc does it. + for (const mainPath of packageJson.mainPaths) { + const { tsconfigJson, jsconfigJson } = this.loadPackageFiles(mainPath, { + root: packageJson.basePath, + }) + + // TODO: Prefer jsconfig.json when dealing with a JavaScript file. + for (const configJson of [tsconfigJson, jsconfigJson]) { + if (configJson === undefined) { + continue + } + + const candidatePaths = configJson.collectLookupPaths(mainPath).flatMap(filePath => { + return context.collectLookupPaths(filePath) + }) + for (const candidatePath of candidatePaths) { + const mainSourceFile = this.cache.sourceFile(candidatePath, context) + if (mainSourceFile === undefined) { + continue + } + + configJson.registerRelatedSourceFile(mainSourceFile) + + return [sourceFile, mainSourceFile, configJson.jsonFile.sourceFile] + } + } + + const mainSourceFile = this.cache.sourceFile(mainPath, context) + if (mainSourceFile === undefined) { + continue + } + + return [sourceFile, mainSourceFile] + } + + // TODO: Is this even useful without any code files? + return [sourceFile] + } + + return [sourceFile] + } + + resolveDependenciesForFilePath ( + filePath: string, + dependencies: string[], + ): Dependencies { + const resolved: Dependencies = { + external: [], + missing: [], + local: [], + } + + const dirname = path.dirname(filePath) + + const { tsconfigJson, jsconfigJson } = this.loadPackageFiles(filePath) + + const context = LookupContext.forFilePath(filePath) + + resolve: + for (const importPath of dependencies) { + if (isLocalPath(importPath)) { + const relativeDepPath = path.resolve(dirname, importPath) + const sourceFile = this.cache.sourceFile(relativeDepPath, context) + if (sourceFile !== undefined) { + const resolvedFiles = this.resolveSourceFile(sourceFile, context) + let found = false + for (const resolvedFile of resolvedFiles) { + resolved.local.push({ + kind: 'relative-path', + importPath, + sourceFile: resolvedFile, + }) + found = true + } + if (found) { + continue resolve + } + } + resolved.missing.push({ + importPath, + filePath: relativeDepPath, + }) + continue resolve + } + + for (const configJson of [tsconfigJson, jsconfigJson]) { + if (configJson === undefined) { + continue + } + + const resolvedPaths = configJson.resolvePath(importPath) + if (resolvedPaths.length > 0) { + let found = false + for (const { source, target } of resolvedPaths) { + const relativePath = path.resolve(configJson.basePath, target.path) + const sourceFile = this.cache.sourceFile(relativePath, context) + if (sourceFile !== undefined) { + const resolvedFiles = this.resolveSourceFile(sourceFile, context) + for (const resolvedFile of resolvedFiles) { + configJson.registerRelatedSourceFile(resolvedFile) + resolved.local.push({ + kind: 'tsconfig-resolved-path', + importPath, + sourceFile: resolvedFile, + configFile: configJson, + pathResult: { + source, + target, + }, + }) + resolved.local.push({ + kind: 'tsconfig-file', + importPath, + sourceFile: configJson.jsonFile.sourceFile, + configFile: configJson, + }) + found = true + } + if (found) { + // We're trying to find the first match out of many possible + // candidates. Stop once we find a match. + break + } + } + } + if (found) { + continue resolve + } + } + + if (configJson.baseUrl !== undefined) { + const relativePath = path.resolve(configJson.basePath, configJson.baseUrl, importPath) + const sourceFile = this.cache.sourceFile(relativePath, context) + if (sourceFile !== undefined) { + const resolvedFiles = this.resolveSourceFile(sourceFile, context) + let found = false + for (const resolvedFile of resolvedFiles) { + configJson.registerRelatedSourceFile(resolvedFile) + resolved.local.push({ + kind: 'tsconfig-baseurl-relative-path', + importPath, + sourceFile: resolvedFile, + configFile: configJson, + }) + resolved.local.push({ + kind: 'tsconfig-file', + importPath, + sourceFile: configJson.jsonFile.sourceFile, + configFile: configJson, + }) + found = true + } + if (found) { + continue resolve + } + } + } + } + + resolved.external.push({ + importPath, + }) + } + + return resolved + } +} diff --git a/packages/cli/src/services/check-parser/package-files/source-file.ts b/packages/cli/src/services/check-parser/package-files/source-file.ts new file mode 100644 index 00000000..d04bce2d --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/source-file.ts @@ -0,0 +1,48 @@ +import fs from 'node:fs' +import path from 'node:path' + +export class FileMeta { + filePath: string + dirname: string + basename: string + + private constructor (filePath: string, dirname: string, basename: string) { + this.filePath = filePath + this.dirname = dirname + this.basename = basename + } + + static fromFilePath (filePath: string): FileMeta { + return new FileMeta( + filePath, + path.dirname(filePath), + path.basename(filePath), + ) + } +} + +export class SourceFile { + static #id = 0 + readonly id = ++SourceFile.#id + + contents: string + meta: FileMeta + + private constructor (meta: FileMeta, contents: string) { + this.meta = meta + this.contents = contents + } + + static loadFromFilePath (filePath: string): SourceFile | undefined { + try { + const contents = fs.readFileSync(filePath, { + encoding: 'utf8', + }) + + const meta = FileMeta.fromFilePath(filePath) + + return new SourceFile(meta, contents) + } catch (err: any) { + } + } +} diff --git a/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts b/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts new file mode 100644 index 00000000..e47502cf --- /dev/null +++ b/packages/cli/src/services/check-parser/package-files/tsconfig-json-file.ts @@ -0,0 +1,165 @@ +import path from 'node:path' + +import { SourceFile } from './source-file' +import { JsonSourceFile } from './json-source-file' +import { PathResolver, ResolveResult } from './paths' + +type Module = + 'none' | 'commonjs' | 'amd' | 'system' | 'es6' | 'es2015' | 'es2020' | + 'es2022' | 'esnext' | 'node16' | 'nodenext' | 'preserve' + +type ModuleResolution = + 'classic' | 'node10' | 'node' | 'node16' | 'nodenext' | 'bundler' + +type Paths = Record> + +interface CompilerOptions { + module?: Module + moduleResolution?: ModuleResolution + baseUrl?: string + paths?: Paths + + /** + * If set, .js files will be emitted into this directory. + * + * If not set, .js files are placed right next to .ts files in the same + * folder. + * + * @see https://www.typescriptlang.org/tsconfig/#outDir + */ + outDir?: string + + /** + * If not set, rootDir is inferred to be the longest common path of all + * non-declaration input files, unless `composite: true`, in which case + * the inferred root is the directory containing the tsconfig.json file. + * + * @see https://www.typescriptlang.org/tsconfig/#rootDir + */ + rootDir?: string + + /** + * Allow multiple directions to act as a single root. Source files will be + * able to refer to files in other roots as if they were present in the same + * root. + * + * @see https://www.typescriptlang.org/tsconfig/#rootDirs + */ + rootDirs?: string[] + + /** + * If true, the default rootDir is the directory containing the + * tsconfig.json file. + * + * @see https://www.typescriptlang.org/tsconfig/#composite + */ + composite?: boolean +} + +export interface Schema { + compilerOptions?: CompilerOptions +} + +export class TSConfigFile { + static FILENAME = 'tsconfig.json' + + static #id = 0 + readonly id = ++TSConfigFile.#id + + jsonFile: JsonSourceFile + basePath: string + moduleResolution: string + baseUrl?: string + pathResolver: PathResolver + + relatedSourceFiles: SourceFile[] = [] + + protected constructor (jsonFile: JsonSourceFile) { + this.jsonFile = jsonFile + + this.basePath = jsonFile.meta.dirname + + this.moduleResolution = jsonFile.data.compilerOptions?.moduleResolution?.toLocaleLowerCase() ?? 'unspecified' + + const baseUrl = jsonFile.data.compilerOptions?.baseUrl + if (baseUrl !== undefined) { + this.baseUrl = path.resolve(this.jsonFile.meta.dirname, baseUrl) + } + + this.pathResolver = PathResolver.createFromPaths(this.baseUrl ?? '.', jsonFile.data.compilerOptions?.paths ?? {}) + } + + public get meta () { + return this.jsonFile.meta + } + + static loadFromJsonSourceFile (jsonFile: JsonSourceFile): TSConfigFile | undefined { + return new TSConfigFile(jsonFile) + } + + static filePath (dirPath: string) { + return path.join(dirPath, TSConfigFile.FILENAME) + } + + resolvePath (importPath: string): ResolveResult { + return this.pathResolver.resolve(importPath) + } + + collectLookupPaths (filePath: string): string[] { + let { + outDir, + rootDir, + rootDirs, + composite, + } = this.jsonFile.data.compilerOptions ?? {} + + const candidates = [] + + if (outDir === undefined) { + candidates.push(path.resolve(this.basePath, filePath)) + return candidates // Nothing more we can do. + } + + if (composite === undefined) { + composite = false + } + + // Inferred rootDir is tsconfig directory if composite === true. + if (rootDir === undefined && composite) { + rootDir = '.' + } + + // If we still don't have a root, we should calculate the longest common + // path among input files, but that's a lot of effort. Assume tsconfig + // directory and hope for the best. + if (rootDir === undefined) { + rootDir = '.' + } + + const absoluteOutDir = path.resolve(this.basePath, outDir) + const relativePath = path.relative(absoluteOutDir, filePath) + + // If the file is outside outDir, then assume we're looking for + // something that wasn't compiled using this tsconfig (or at all), and + // stop looking. + if (relativePath.startsWith('..')) { + candidates.push(path.resolve(this.basePath, filePath)) + return candidates + } + + candidates.push(path.resolve(this.basePath, rootDir, relativePath)) + + // Assume that our inferred (or user specified) rootDir is enough to cover + // the same conditions we'd have to infer rootDirs, and only add rootDirs + // if they're actually set. + for (const multiRootDir of rootDirs ?? []) { + candidates.push(path.resolve(this.basePath, multiRootDir, relativePath)) + } + + return candidates + } + + registerRelatedSourceFile (file: SourceFile) { + this.relatedSourceFiles.push(file) + } +} diff --git a/packages/cli/src/services/check-parser/parser.ts b/packages/cli/src/services/check-parser/parser.ts index 821b0c19..5ef668fa 100644 --- a/packages/cli/src/services/check-parser/parser.ts +++ b/packages/cli/src/services/check-parser/parser.ts @@ -4,6 +4,7 @@ import * as acorn from 'acorn' import * as walk from 'acorn-walk' import { Collector } from './collector' import { DependencyParseError } from './errors' +import { PackageFilesResolver, Dependencies } from './package-files/resolver' // Only import types given this is an optional dependency import type { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-estree' @@ -12,34 +13,13 @@ import type { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/typescript-est const ignore = (_node: any, _st: any, _c: any) => {} type Module = { - localDependencies: Array, - npmDependencies: Array + dependencies: Array, } type SupportedFileExtension = '.js' | '.mjs' | '.ts' const PACKAGE_EXTENSION = `${path.sep}package.json` -const JS_RESOLVE_ORDER = [ - '.js', - '.mjs', - PACKAGE_EXTENSION, - `${path.sep}index.js`, - // TODO: Check the module type in package.json to figure out the esm and common js file extensions - `${path.sep}index.mjs`, -] - -const TS_RESOLVE_ORDER = [ - '.ts', - '.js', - '.mjs', - PACKAGE_EXTENSION, - `${path.sep}index.ts`, - `${path.sep}index.js`, - // TODO: Check the module type in package.json to figure out the esm and common js file extensions - `${path.sep}index.mjs`, -] - const supportedBuiltinModules = [ 'assert', 'buffer', 'crypto', 'dns', 'fs', 'path', 'querystring', 'readline ', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'util', 'zlib', @@ -93,6 +73,12 @@ type ParserOptions = { export class Parser { supportedModules: Set checkUnsupportedModules: boolean + resolver = new PackageFilesResolver() + cache = new Map() // TODO: pass a npm matrix of supported npm modules // Maybe pass a cache so we don't have to fetch files separately all the time @@ -102,7 +88,7 @@ export class Parser { } parse (entrypoint: string) { - const { extension, content } = validateEntrypoint(entrypoint) + const { content } = validateEntrypoint(entrypoint) /* * The importing of files forms a directed graph. @@ -122,34 +108,51 @@ export class Parser { // Holds info about the main file and doesn't need to be parsed continue } - const { module, error } = Parser.parseDependencies(item.filePath, item.content) + + // This cache is only useful when there are multiple entrypoints with + // common files, as we make sure to not add the same file twice to + // bfsQueue. + const cache = this.cache.get(item.filePath) + const { module, error } = cache !== undefined + ? cache + : Parser.parseDependencies(item.filePath, item.content) + if (error) { + this.cache.set(item.filePath, { module, error }) collector.addParsingError(item.filePath, error.message) continue } + + const resolvedDependencies = cache?.resolvedDependencies ?? + this.resolver.resolveDependenciesForFilePath(item.filePath, module.dependencies) + + this.cache.set(item.filePath, { module, resolvedDependencies }) + if (this.checkUnsupportedModules) { - const unsupportedDependencies = module.npmDependencies.filter((dep) => !this.supportedModules.has(dep)) + const unsupportedDependencies = resolvedDependencies.external.flatMap(dep => { + if (!this.supportedModules.has(dep.importPath)) { + return [dep.importPath] + } else { + return [] + } + }) if (unsupportedDependencies.length) { collector.addUnsupportedNpmDependencies(item.filePath, unsupportedDependencies) } } - const localDependenciesResolvedPaths: Array<{filePath: string, content: string}> = [] - module.localDependencies.forEach((localDependency: string) => { - const filePath = path.join(path.dirname(item.filePath), localDependency) - try { - const deps = Parser.readDependency(filePath, extension) - localDependenciesResolvedPaths.push(...deps) - } catch (err: any) { - collector.addMissingFile(filePath) - } - }) - localDependenciesResolvedPaths.forEach(({ filePath, content }: {filePath: string, content: string}) => { + + for (const dep of resolvedDependencies.missing) { + collector.addMissingFile(dep.filePath) + } + + for (const dep of resolvedDependencies.local) { + const filePath = dep.sourceFile.meta.filePath if (collector.hasDependency(filePath)) { - return + continue } - collector.addDependency(filePath, content) - bfsQueue.push({ filePath, content }) - }) + collector.addDependency(filePath, dep.sourceFile.contents) + bfsQueue.push({ filePath, content: dep.sourceFile.contents }) + } } collector.validate() @@ -157,45 +160,9 @@ export class Parser { return collector.getItems() } - static readDependency (filePath: string, preferedExtenstion: SupportedFileExtension) { - // Read the specific file if it has an extension - if (preferedExtenstion === '.js') { - return Parser.tryReadFileExt(filePath, JS_RESOLVE_ORDER) - } else { - return Parser.tryReadFileExt(filePath, TS_RESOLVE_ORDER) - } - } - - static tryReadFileExt (filePath: string, exts: typeof JS_RESOLVE_ORDER | typeof TS_RESOLVE_ORDER) { - for (const extension of ['', ...exts]) { - try { - const deps = [] - const fullPath = filePath + extension - const content = fs.readFileSync(fullPath, { encoding: 'utf-8' }) - deps.push({ filePath: fullPath, content }) - if (extension === PACKAGE_EXTENSION) { - const { main } = JSON.parse(content) - // TODO: Check the module type to figure out the esm and common js file extensions - // It might be different than js and mjs - if (!main || !main.length) { - // No main is defined. This means package.json doesn't have a specific entry - continue - } - const mainFile = path.join(filePath, main) - deps.push({ - filePath: mainFile, content: fs.readFileSync(mainFile, { encoding: 'utf-8' }), - }) - } - return deps - } catch (err) {} - } - throw new Error(`Cannot find file ${filePath}`) - } - static parseDependencies (filePath: string, contents: string): { module: Module, error?: any } { - const localDependencies = new Set() - const npmDependencies = new Set() + const dependencies = new Set() const extension = path.extname(filePath) try { @@ -206,22 +173,23 @@ export class Parser { allowImportExportEverywhere: true, allowAwaitOutsideFunction: true, }) - walk.simple(ast, Parser.jsNodeVisitor(localDependencies, npmDependencies)) + walk.simple(ast, Parser.jsNodeVisitor(dependencies)) } else if (extension === '.ts') { const tsParser = getTsParser() const ast = tsParser.parse(contents, {}) // The AST from typescript-estree is slightly different from the type used by acorn-walk. // This doesn't actually cause problems (both are "ESTree's"), but we need to ignore type errors here. // @ts-ignore - walk.simple(ast, Parser.tsNodeVisitor(tsParser, localDependencies, npmDependencies)) + walk.simple(ast, Parser.tsNodeVisitor(tsParser, dependencies)) + } else if (extension === '.json') { + // No dependencies to check. } else { throw new Error(`Unsupported file extension for ${filePath}`) } } catch (err) { return { module: { - localDependencies: Array.from(localDependencies), - npmDependencies: Array.from(npmDependencies), + dependencies: Array.from(dependencies), }, error: err, } @@ -229,55 +197,54 @@ export class Parser { return { module: { - localDependencies: Array.from(localDependencies), - npmDependencies: Array.from(npmDependencies), + dependencies: Array.from(dependencies), }, } } - static jsNodeVisitor (localDependencies: Set, npmDependencies: Set): any { + static jsNodeVisitor (dependencies: Set): any { return { CallExpression (node: Node) { if (!Parser.isRequireExpression(node)) return const requireStringArg = Parser.getRequireStringArg(node) - Parser.registerDependency(requireStringArg, localDependencies, npmDependencies) + Parser.registerDependency(requireStringArg, dependencies) }, ImportDeclaration (node: any) { if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportNamedDeclaration (node: any) { if (node.source === null) return if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportAllDeclaration (node: any) { if (node.source === null) return if (node.source.type !== 'Literal') return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, } } - static tsNodeVisitor (tsParser: any, localDependencies: Set, npmDependencies: Set): any { + static tsNodeVisitor (tsParser: any, dependencies: Set): any { return { ImportDeclaration (node: TSESTree.ImportDeclaration) { // For now, we only support literal strings in the import statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportNamedDeclaration (node: TSESTree.ExportNamedDeclaration) { // The statement isn't importing another dependency if (node.source === null) return // For now, we only support literal strings in the export statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, ExportAllDeclaration (node: TSESTree.ExportAllDeclaration) { if (node.source === null) return // For now, we only support literal strings in the export statement if (node.source.type !== tsParser.TSESTree.AST_NODE_TYPES.Literal) return - Parser.registerDependency(node.source.value, localDependencies, npmDependencies) + Parser.registerDependency(node.source.value, dependencies) }, } } @@ -319,14 +286,12 @@ export class Parser { } } - static registerDependency (importArg: string | null, localDependencies: Set, npmDependencies: Set) { + static registerDependency (importArg: string | null, dependencies: Set) { // TODO: We currently don't support import path aliases, f.ex: `import { Something } from '@services/my-service'` if (!importArg) { // If there's no importArg, don't register a dependency - } else if (importArg.startsWith('/') || importArg.startsWith('./') || importArg.startsWith('../')) { - localDependencies.add(importArg) } else { - npmDependencies.add(importArg) + dependencies.add(importArg) } } }