diff --git a/.changeset/thick-ties-hang.md b/.changeset/thick-ties-hang.md new file mode 100644 index 000000000..8b61c4fd6 --- /dev/null +++ b/.changeset/thick-ties-hang.md @@ -0,0 +1,39 @@ +--- +"skott": minor +--- + +Expose a new rendering module providing a programmatic access to terminal and web visualizations through skott's API. + +This is equivalent to use the CLI such as `skott --displayMode=webapp` but offers more flexibility for the runtime configuration which suffers from limitations when only using the CLI (some configurations are nearly impossible to represent using strings e.g. providing custom functions), this is why often authors tend to introduce runtime configuration files that CLIs can pick up automatically, thing that we want to avoid with skott, by unifying it's usage either coming from the API or CLI. + +**Using the rendering module** + +```js +import { defaultConfig } from "skott"; +import { Web, Terminal } from "skott/rendering"; + +await Web.renderWebApplication( + // skott runtime config + defaultConfig, + // application config + { + visualization: { + granularity: "module", + }, + watch: true, + port: 1111, + onListen: (port) => console.log(`Listening on port ${port}`), + open: true, + onOpenError: () => console.log(`Error when opening the browser`), + } +); + +await Terminal.renderTerminalApplication(defaultConfig, { + displayMode: "graph", + exitCodeOnCircularDependencies: 1, + showCircularDependencies: true, + showUnusedDependencies: true, + watch: true, +}); +``` + diff --git a/.github/workflows/skott.yml b/.github/workflows/skott.yml index 7dd94ce49..13988adb2 100644 --- a/.github/workflows/skott.yml +++ b/.github/workflows/skott.yml @@ -54,33 +54,34 @@ jobs: env: CI: true - - name: Run benchmarks - if: matrix.os == 'ubuntu-latest' && !contains(github.ref, 'main') && !contains(github.ref, 'release') - run: | - git config --local user.name "skott_bot" - git config --local user.email "skott.devtool@gmail.com" - git fetch origin - git pull origin ${{ github.head_ref }} --rebase - - pnpm -r benchmark + # - name: Run benchmarks + # if: matrix.os == 'ubuntu-latest' && !contains(github.ref, 'main') && !contains(github.ref, 'release') + # run: | + # git stash + # git config --local user.name "skott_bot" + # git config --local user.email "skott.devtool@gmail.com" + # git fetch origin + # git pull origin ${{ github.head_ref }} --rebase + + # pnpm -r benchmark - git status - git add . - git commit -m "benchmark: publish benchmark results from node_${{ matrix.node-version }}" - git pull origin ${{ github.head_ref }} --rebase - - if git diff --quiet; then - echo "No conflicts, proceeding with the push." - else - git checkout --theirs . - git add . - git rebase --continue - fi - - git push origin HEAD:${{ github.head_ref }} - env: - CI: true - NODE_VERSION: ${{ matrix.node-version }} + # git status + # git add . + # git commit -m "benchmark: publish benchmark results from node_${{ matrix.node-version }}" + # git pull origin ${{ github.head_ref }} --rebase + + # if git diff --quiet; then + # echo "No conflicts, proceeding with the push." + # else + # git checkout --theirs . + # git add . + # git rebase --continue + # fi + + # git push origin HEAD:${{ github.head_ref }} + # env: + # CI: true + # NODE_VERSION: ${{ matrix.node-version }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index fc3464cd6..c266dbcf6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules .pnpm-debug.log -.pnpm-store \ No newline at end of file +.pnpm-store +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index ad3a15779..db0f156be 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ✅ Deeply detects **circular dependencies** in an efficient way, with the ability to provide a max depth for the search -✅ Many **builtin visualization modes** including a web application or terminal-based outputs such as file-tree or graph views. +✅ Many **builtin visualization modes** including a web application or terminal-based outputs such as file-tree or graph views. Visualization modes can be rendered using both the CLI and API. ✅ Builtin **watch mode** updating the graph when file changes are detected. It works with all display modes (webapp and all CLIs visualization modes). Support all options of file ignoring/filtering from skott. diff --git a/packages/skott/README.md b/packages/skott/README.md index efcd1e304..e3a4c44b2 100644 --- a/packages/skott/README.md +++ b/packages/skott/README.md @@ -118,12 +118,18 @@ const { getStructure, getWorkspace, useGraph, findUnusedDependencies } = await s }); ``` +> [!NOTE] +> Starting from 0.34.0, skott visualization modes can be programatically registered using the API through the `rendering module` accessible through (`"skott/rendering"`) export. It allows all options to be provided (some options aren't accessible through the CLI) while having the ability to visualize the result of the API. An **[example of the rendering module can be found there](https://github.com/antoine-coulon/skott/blob/main/packages/skott/examples/api-rendering.ts)**. + More API **[examples can be found there](https://github.com/antoine-coulon/skott/blob/main/packages/skott/examples/api.ts)**. ## **Command line interface** skott exposes a CLI directly using features from the core library. All the options shown from the API can be used from the CLI, please use `skott --help` to see how to express them via the command line. +> [!NOTE] +> All skott's runtime configuration options might not be available through the CLI, especially options that expect non serializable values such as functions (`groupBy` option for instance) as skott does not support any runtime configuration file (such as `.skottrc`). However, skott provides everything through its API, including capabilities to programmatically render all the available display modes, more [can be found there](https://github.com/antoine-coulon/skott/blob/main/packages/skott/examples/api-rendering.ts). + When the library installed locally you can run: **Providing an entrypoint:** @@ -481,6 +487,93 @@ console.log(getWorkspace()); This feature could help creating a dependency graph only using manifests instead of parsing and traversing the whole source code graph using static analysis. +### Explore all the information through the Rendering module + +skott's API can be used to have a programmatic access to the project's graph and +all the information collected through the project analysis. + +However when it comes to visualizing that information, skott provides many display +modes that were mostly accessible through the CLI only. + +Since 0.34.0, skott provides a way to render these display modes while being +in the API context, allowing to have a better control over the configuration, +if it's depending on any other context (environment, output of other functions, etc). + +**Terminal application rendering** + +```js +import skott, { defaultConfig } from "skott"; +import { Web, Terminal } from "skott/rendering"; + +await Terminal.renderTerminalApplication(defaultConfig, { + displayMode: "graph", + exitCodeOnCircularDependencies: 1, + showCircularDependencies: true, + showUnusedDependencies: true, + watch: true +}); +``` + +**Web application rendering** + +When it comes to web application, two options are available: + +1. using `renderWebApplication` that just requires the runtime configuration, and +manages the lifecycle of skott internally. + +```js +await Web.renderWebApplication( + // skott runtime config + defaultConfig, + // application config + { + visualization: { + granularity: "module" + }, + watch: true, + port: 1111, + onListen: (port) => console.log(`Listening on port ${port}`), + open: true, + onOpenError: () => console.log(`Error when opening the browser`) + } +); +``` + +2. using `renderStandaloneWebApplication` that takes a factory function that +provides the skott instance, allowing to have a better control over +what is injected into the skott instance. That can become especially handy +when using plugins for external tools that need to alter the structure of the +graph before rendering it. As there is no plugin system in skott (yet), this +is a way to achieve a similar result. This is what we're using to build the +[Rush](https//rushjs.io) monorepo tool [skott plugin](https://github.com/antoine-coulon/krush). + +```js +// In that case it's just using skott, but could be anything mapping the graph +// to a different structure, as long as it respects the expected contract. +const factory = () => skott(defaultConfig); + +await Web.renderStandaloneWebApplication( + // factory function + factory, + // application config + { + visualization: { + granularity: "module" + }, + watch: { + cwd: process.cwd(), + ignorePattern: "tests/**/*", + fileExtensions: [".ts"], + verbose: true + }, + port: 1111, + onListen: (port) => console.log(`Listening on port ${port}`), + open: true, + onOpenError: () => console.log(`Error when opening the browser`) + } +); +``` + ## Contributors diff --git a/packages/skott/bin/cli-config.ts b/packages/skott/bin/cli-config.ts deleted file mode 100644 index 8ab44194b..000000000 --- a/packages/skott/bin/cli-config.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Either } from "effect"; - -export type CliOptions = { - entrypoint: string | undefined; -} & CliParameterOptions; - -export type CliParameterOptions = { - circularMaxDepth: number; - cwd: string; - displayMode: string; - exitCodeOnCircularDependencies: number; - fileExtensions: string; - ignorePattern: string; - includeBaseDir: boolean; - incremental: boolean; - manifest: string; - showCircularDependencies: boolean; - showUnusedDependencies: boolean; - trackBuiltinDependencies: boolean; - trackThirdPartyDependencies: boolean; - trackTypeOnlyDependencies: boolean; - tsconfig: string; - verbose: boolean; - watch: boolean; -}; - -function ensureNoIllegalConfigState({ - entrypoint, - cwd, - includeBaseDir, - watch, - displayMode, - showCircularDependencies, - showUnusedDependencies -}: CliOptions) { - if (entrypoint) { - if (cwd !== process.cwd()) { - return Either.left( - "`--cwd` can't be customized when providing an entrypoint" - ); - } - } else if (includeBaseDir) { - return Either.left( - "`--includeBaseDir` can only be used when providing an entrypoint" - ); - } - - /** - * Some `show*` params exist but are only relevant when using CLI-based. - * `--showCircularDependencies` is already supported in the webapp even without - * the flag. - * `--showUnusedDependencies` is not supported in the webapp yet but to enforce - * consistency, we don't allow it to be used with `--displayMode=webapp`. - */ - if (displayMode === "webapp") { - if (showCircularDependencies) { - return Either.left( - "`--showCircularDependencies` can't be used when using `--displayMode=webapp`" - ); - } - if (showUnusedDependencies) { - return Either.left( - "`--showUnusedDependencies` can't be used when using `--displayMode=webapp`" - ); - } - } - - if (watch) { - if ( - displayMode !== "webapp" && - displayMode !== "graph" && - displayMode !== "raw" && - displayMode !== "file-tree" - ) { - return Either.left( - "`--watch` needs either `raw`, `file-tree`, `graph` or `webapp` display mode" - ); - } - } - - return Either.right(void 0); -} - -export function ensureValidConfiguration( - entrypoint: string | undefined, - options: CliParameterOptions -): Either.Either { - return ensureNoIllegalConfigState({ entrypoint, ...options }); -} diff --git a/packages/skott/bin/cli.ts b/packages/skott/bin/cli.ts index 7de6bbe23..fe7374488 100644 --- a/packages/skott/bin/cli.ts +++ b/packages/skott/bin/cli.ts @@ -7,8 +7,7 @@ import { fileURLToPath } from "node:url"; import { Command } from "commander"; import { kExpectedModuleExtensions } from "../src/modules/resolvers/base-resolver.js"; - -import { main } from "./main.js"; +import { runTerminalApplicationFromCLI } from "../src/rendering/terminal/internal.js"; function readManifestVersion(): string { try { @@ -169,6 +168,8 @@ cli | ./node_modules/.bin/skott --showCircularDependencies --displayMode=raw --watch\n `) ) - .action((name, commandAndOptions) => main(name, commandAndOptions)); + .action((name, commandAndOptions) => + runTerminalApplicationFromCLI(name, commandAndOptions) + ); cli.parse(process.argv); diff --git a/packages/skott/bin/main.ts b/packages/skott/bin/main.ts deleted file mode 100644 index 9b2e1628a..000000000 --- a/packages/skott/bin/main.ts +++ /dev/null @@ -1,172 +0,0 @@ -import EventEmitter from "node:events"; -import path from "node:path"; -import { performance } from "node:perf_hooks"; - -import kleur from "kleur"; - -import type { SkottStructure } from "../index.js"; - -import { - ensureValidConfiguration, - type CliParameterOptions -} from "./cli-config.js"; -import { makeSkottRunner } from "./runner.js"; -import { renderStaticFile } from "./static-file.js"; -import { - displayCircularDependencies, - displayDependenciesReport -} from "./ui/console/dependencies.js"; -import { renderFileTree } from "./ui/display-modes/file-tree.js"; -import { renderGraph } from "./ui/display-modes/graph.js"; -import { - RenderManager, - CliComponent -} from "./ui/display-modes/render-manager.js"; -import { renderWebApplication } from "./ui/display-modes/webapp.js"; -import { registerWatchMode } from "./watch-mode.js"; - -function displayInitialGetStructureTime( - files: SkottStructure["files"], - startTime: number -) { - const filesWord = files.length > 1 ? "files" : "file"; - const timeTookStructure = `${(performance.now() - startTime).toFixed(3)}ms`; - - console.log( - `\n Processed ${kleur.bold().green(files.length)} ${filesWord} (${kleur - .magenta() - .bold(timeTookStructure)})` - ); -} - -export async function main( - entrypoint: string | undefined, - options: CliParameterOptions -): Promise { - const isValid = ensureValidConfiguration(entrypoint, options); - - if (isValid._tag === "Left") { - console.log(`\n ${kleur.bold().red(isValid.left)}`); - process.exit(1); - } - - const runSkott = makeSkottRunner(entrypoint, options); - - if (entrypoint) { - console.log( - `\n Running ${kleur.blue().bold("skott")} from entrypoint: ${kleur - .yellow() - .underline() - .bold(`${entrypoint}`)}` - ); - } else { - console.log( - `\n Running ${kleur.blue().bold("skott")} from current directory: ${kleur - .yellow() - .underline() - .bold(`${path.basename(options.cwd)}`)}` - ); - } - - if (options.incremental) { - console.log( - `\n ${kleur - .bold() - .yellow( - "`incremental` mode is experimental. Please report any issues you encounter." - )}` - ); - } - - const start = performance.now(); - let skottInstance = await runSkott(); - const { graph, files } = skottInstance.getStructure(); - displayInitialGetStructureTime(files, start); - - let watcherEmitter: EventEmitter | undefined; - let renderManager: RenderManager | undefined; - - if (options.watch) { - watcherEmitter = new EventEmitter(); - renderManager = new RenderManager(watcherEmitter); - } - - if (options.displayMode === "file-tree") { - const fileTreeComponent = new CliComponent(() => - renderFileTree(skottInstance, options) - ); - - renderManager?.renderOnChanges(fileTreeComponent); - } else if (options.displayMode === "graph") { - const graphComponent = new CliComponent(() => - renderGraph(skottInstance, options) - ); - - renderManager?.renderOnChanges(graphComponent); - } else if (options.displayMode === "webapp") { - const circularDepsComponent = new CliComponent(() => - displayCircularDependencies(skottInstance, { - ...options, - /** - * We only want to display the overview that is whether the graph is - * acyclic or not. Circular dependencies will be displayed within the webapp - * itself. - */ - showCircularDependencies: false - }) - ); - - renderManager?.renderOnChanges(circularDepsComponent); - - renderWebApplication({ - getSkottInstance: () => skottInstance, - options: { entrypoint, ...options }, - watcherEmitter - }); - } else if (options.displayMode === "raw") { - const circularDepsComponent = new CliComponent(() => - displayCircularDependencies(skottInstance, options) - ); - - renderManager?.renderOnChanges(circularDepsComponent); - } else { - // @TODO: check if this is a valid display mode if the registered plugin - // is registered. - await renderStaticFile(graph, options.displayMode); - } - - // Additional information we want to display when using the console UI - // To avoid redondant information, we don't display it when using the webapp - if (options.displayMode !== "webapp") { - await new Promise((resolve) => { - const depsReportComponent = new CliComponent(() => - displayDependenciesReport(skottInstance, options).then(resolve) - ); - - renderManager?.renderOnChanges(depsReportComponent); - }); - } - - if (options.watch) { - registerWatchMode({ - cwd: options.cwd, - ignorePattern: options.ignorePattern, - fileExtensions: options.fileExtensions.split(","), - onChangesDetected: (done) => { - runSkott().then((newSkottInstance) => { - skottInstance = newSkottInstance; - watcherEmitter!.emit("change"); - renderManager!.afterRenderingPhase(done); - }); - } - }); - } -} - -process.on("exit", (code) => { - console.log( - `\n ${kleur.bold().blue("skott")} exited with code ${kleur - .bold() - .yellow(code)}` - ); -}); diff --git a/packages/skott/bin/open-url.ts b/packages/skott/bin/open-url.ts deleted file mode 100644 index 0af17739b..000000000 --- a/packages/skott/bin/open-url.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ChildProcess, spawn } from "node:child_process"; -import { platform } from "node:process"; - -import isWsl from "is-wsl"; - -const supportedPlatforms = ["darwin", "win32", "linux"] as const; - -function selectPlatformBinary() { - let binary: string | undefined; - - switch (platform) { - case "darwin": - binary = "open"; - break; - case "win32": - binary = "explorer.exe"; - break; - case "linux": - binary = "xdg-open"; - break; - default: - throw new Error( - `Unsupported platform: ${ - process.platform - }. Supported platforms are: ${supportedPlatforms.join(", ")}` - ); - } - - return binary; -} - -export function open(url: string, callback: (error: Error) => void): void { - try { - let child: ChildProcess; - - if (isWsl) { - child = spawn("cmd.exe", ["/c", "start", url]); - } else { - const binary = selectPlatformBinary(); - child = spawn(binary, [url]); - } - - child.on("error", callback); - } catch (error) { - callback(error as Error); - } -} diff --git a/packages/skott/bin/runner.ts b/packages/skott/bin/runner.ts deleted file mode 100644 index 2fd58ba0e..000000000 --- a/packages/skott/bin/runner.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { performance } from "node:perf_hooks"; - -import kleur from "kleur"; -import type { Ora } from "ora"; -import ora from "ora"; - -import skott, { type SkottInstance } from "../index.js"; -import { kExpectedModuleExtensions } from "../src/modules/resolvers/base-resolver.js"; -import { EcmaScriptDependencyResolver } from "../src/modules/resolvers/ecmascript/resolver.js"; - -import type { CliParameterOptions } from "./cli-config.js"; - -export function makeSkottRunner( - entrypoint: string | undefined, - options: CliParameterOptions -): () => Promise { - let isFirstRun = true; - - const dependencyTracking = { - thirdParty: options.trackThirdPartyDependencies, - builtin: options.trackBuiltinDependencies, - typeOnly: options.trackTypeOnlyDependencies - }; - const fileExtensions = options.fileExtensions - .split(",") - .filter((ext) => kExpectedModuleExtensions.has(ext)); - const dependencyResolvers = [new EcmaScriptDependencyResolver()]; - - return async () => { - const startTime = performance.now(); - let spinner: Ora | undefined; - - try { - if (!options.verbose && isFirstRun) { - spinner = ora(`Initializing ${kleur.blue().bold("skott")}`).start(); - } - - isFirstRun = false; - - const skottResult = await skott({ - entrypoint: entrypoint ? entrypoint : undefined, - ignorePattern: options.ignorePattern, - incremental: options.incremental, - circularMaxDepth: options.circularMaxDepth ?? Number.POSITIVE_INFINITY, - includeBaseDir: options.includeBaseDir, - dependencyTracking, - fileExtensions, - tsConfigPath: options.tsconfig, - manifestPath: options.manifest, - dependencyResolvers, - cwd: options.cwd, - verbose: options.verbose - }); - - const timeTook = `${(performance.now() - startTime).toFixed(3)}ms`; - - if (spinner && isFirstRun) { - spinner.text = `Finished Skott initialization (${kleur - .magenta() - .bold(timeTook)})`; - spinner.color = "green"; - } - - spinner?.stop(); - - return skottResult; - } catch (error: any) { - if (spinner) { - spinner.stop(); - } - - if (error.message) { - console.log(`\n ${kleur.bold().red("Error: ".concat(error.message))}`); - } else { - console.log( - `\n ${kleur - .bold() - .red( - "Unexpected error. Please use `--verbose` flag and report" + - " an issue at https://github.com/antoine-coulon/skott/issues" - )}` - ); - } - - process.exitCode = 1; - - return undefined as never; - } - }; -} diff --git a/packages/skott/examples/rendering.ts b/packages/skott/examples/rendering.ts new file mode 100644 index 000000000..0d92b9bf3 --- /dev/null +++ b/packages/skott/examples/rendering.ts @@ -0,0 +1,83 @@ +/** + * skott's API can be used to have a programmatic access to the project's graph + * and all the information collected through the project analysis. + * + * However when it comes to visualizing that information, skott provides many display + * modes that were mostly accessible through the CLI only. + * + * Since 0.34.0, skott provides a way to render these display modes while being + * in the API context, allowing to have a better control over the configuration, + * if it's depending on any other context (environment, output of other functions, etc). + */ + +import skott, { defaultConfig } from "../index.js"; +// Should be imported as "skott/rendering" when being in a third-party context +import { Web, Terminal } from "../src/rendering/api.js"; + +async function _renderTerminalApplication() { + await Terminal.renderTerminalApplication(defaultConfig, { + displayMode: "graph", + exitCodeOnCircularDependencies: 1, + showCircularDependencies: true, + showUnusedDependencies: true, + watch: true + }); +} + +/** + * When it comes to web application, two options are available: + * 1. using `renderWebApplication` that just requires the runtime configuration, + * and manages the lifecycle of skott internally. + * 2. using `renderStandaloneWebApplication` that takes a factory function that + * provides the skott instance, allowing to have a better control over + * what is injected into the skott instance. That can become especially handy + * when using plugins for external tools that need to alter the structure of the + * graph before rendering it. As there is no plugin system in skott (yet), this + * is a way to achieve a similar result. + * This is what we're using to build the [Rush](https//rushjs.io) monorepo tool + * [skott plugin](https://github.com/antoine-coulon/krush). + */ +async function _renderWebApplication() { + await Web.renderWebApplication( + // skott runtime config + defaultConfig, + // application config + { + visualization: { + granularity: "module" + }, + watch: true, + port: 1111, + onListen: (port) => console.log(`Listening on port ${port}`), + open: true, + onOpenError: () => console.log(`Error when opening the browser`) + } + ); +} + +async function _renderStandaloneWebApplication() { + // In that case it's just using skott, but could be anything mapping the graph + // to a different structure, as long as it respects the expected contract. + const factory = () => skott(defaultConfig); + + await Web.renderStandaloneWebApplication( + // factory function + factory, + // application config + { + visualization: { + granularity: "module" + }, + watch: { + cwd: process.cwd(), + ignorePattern: "tests/**/*", + fileExtensions: [".ts"], + verbose: true + }, + port: 1111, + onListen: (port) => console.log(`Listening on port ${port}`), + open: true, + onOpenError: () => console.log(`Error when opening the browser`) + } + ); +} diff --git a/packages/skott/index.ts b/packages/skott/index.ts index b1fdeb312..c2b85d62c 100644 --- a/packages/skott/index.ts +++ b/packages/skott/index.ts @@ -1,61 +1,11 @@ -import { Option } from "effect"; +import type { InputConfig } from "./src/config.js"; +import { run } from "./src/instance.js"; +import type { SkottInstance } from "./src/skott.js"; -import { decodeInputConfig } from "./src/config.js"; -import { FileSystemReader } from "./src/filesystem/file-reader.js"; -import { FileSystemWriter } from "./src/filesystem/file-writer.js"; -import { FakeLogger, SkottLogger } from "./src/logger.js"; -import { ModuleWalkerSelector } from "./src/modules/walkers/common.js"; -import { Skott } from "./src/skott.js"; -import type { SkottConfig, SkottInstance } from "./src/skott.js"; - -interface InputConfig extends Partial> { - cwd?: string; - verbose?: boolean; - ignorePattern?: string; -} - -function raiseIllegalConfigException(configuration: string): never { - throw new Error(`Illegal configuration: ${configuration}`); -} - -function checkIllegalConfigs(config: Option.Option>): void { - if (Option.isSome(config)) { - const { entrypoint, includeBaseDir, cwd } = config.value; - - if (!entrypoint && includeBaseDir) { - raiseIllegalConfigException( - "`includeBaseDir` can only be used when providing an entrypoint" - ); - } - - if (entrypoint && cwd && cwd !== process.cwd()) { - raiseIllegalConfigException( - "`cwd` can't be customized when providing an entrypoint" - ); - } - } -} - -export default async function skott( +export default function skott( inputConfig: InputConfig | null = Object.create(null) ): Promise> { - const config = Option.fromNullable(inputConfig); - - checkIllegalConfigs(config); - - const { cwd, verbose, ignorePattern, ...skottConfig } = - decodeInputConfig(config); - const logger = verbose ? new SkottLogger() : new FakeLogger(); - - const skottInstance = await new Skott( - skottConfig, - new FileSystemReader({ cwd, ignorePattern }), - new FileSystemWriter(), - new ModuleWalkerSelector(), - logger - ).initialize(); - - return skottInstance; + return run(inputConfig); } export * from "./src/skott.js"; diff --git a/packages/skott/package.json b/packages/skott/package.json index 44bf7299f..09f53090a 100644 --- a/packages/skott/package.json +++ b/packages/skott/package.json @@ -23,7 +23,8 @@ ".": "./dist/index.js", "./filesystem/*": "./dist/src/filesystem/*.js", "./modules/*": "./dist/src/modules/*.js", - "./graph/*": "./dist/src/graph/*.js" + "./graph/*": "./dist/src/graph/*.js", + "./rendering": "./dist/src/rendering/api.js" }, "types": "./dist/index.d.ts", "scripts": { diff --git a/packages/skott/src/config.ts b/packages/skott/src/config.ts index bb664c1ea..165cfdac9 100644 --- a/packages/skott/src/config.ts +++ b/packages/skott/src/config.ts @@ -5,6 +5,36 @@ import * as D from "io-ts/lib/Decoder.js"; import { dependencyResolverDecoder } from "./modules/resolvers/base-resolver.js"; import { defaultConfig, type SkottConfig } from "./skott.js"; +export interface InputConfig extends Partial> { + cwd?: string; + verbose?: boolean; + ignorePattern?: string; +} + +function raiseIllegalConfigException(configuration: string): never { + throw new Error(`Illegal configuration: ${configuration}`); +} + +export function checkIllegalRuntimeConfigs( + config: Option.Option> +): void { + if (Option.isSome(config)) { + const { entrypoint, includeBaseDir, cwd } = config.value; + + if (!entrypoint && includeBaseDir) { + raiseIllegalConfigException( + "`includeBaseDir` can only be used when providing an entrypoint" + ); + } + + if (entrypoint && cwd && cwd !== process.cwd()) { + raiseIllegalConfigException( + "`cwd` can't be customized when providing an entrypoint" + ); + } + } +} + function withDefaultValue(defaultValue: T) { return (decoder: D.Decoder): D.Decoder => { return { @@ -60,6 +90,8 @@ const getConfig = () => ignorePattern: withDefaultValue("")(D.string) }); +export type RuntimeConfig = ReturnType; + export function decodeInputConfig( partialConfig: Option.Option>> ) { @@ -68,7 +100,7 @@ export function decodeInputConfig( Option.getOrNull, getConfig().decode, E.fold((decodeError) => { - throw new Error(`Invalid Skott config. Reason: ${D.draw(decodeError)}`); + throw new Error(`Invalid skott config. Reason: ${D.draw(decodeError)}`); }, identity) ); } diff --git a/packages/skott/src/filesystem/fake/file-writer.ts b/packages/skott/src/filesystem/fake/file-writer.ts new file mode 100644 index 000000000..0a487a0c6 --- /dev/null +++ b/packages/skott/src/filesystem/fake/file-writer.ts @@ -0,0 +1,22 @@ +import path from "node:path"; + +import type { FileWriter } from "../file-writer.js"; + +export class InMemoryFileWriter implements FileWriter { + async write(filePath: string, fileContent: string): Promise { + const memfs = await import("memfs"); + const baseDir = path.dirname(filePath); + + await new Promise((resolve) => { + memfs.fs.mkdir(baseDir, { recursive: true }, () => { + resolve(); + }); + }); + + await new Promise((resolve) => { + memfs.fs.writeFile(filePath, fileContent, () => { + resolve(); + }); + }); + } +} diff --git a/packages/skott/src/filesystem/file-writer.ts b/packages/skott/src/filesystem/file-writer.ts index 4001657bf..a85743165 100644 --- a/packages/skott/src/filesystem/file-writer.ts +++ b/packages/skott/src/filesystem/file-writer.ts @@ -5,25 +5,6 @@ export interface FileWriter { write(filePath: string, fileContent: string): Promise; } -export class InMemoryFileWriter implements FileWriter { - async write(filePath: string, fileContent: string): Promise { - const memfs = await import("memfs"); - const baseDir = path.dirname(filePath); - - await new Promise((resolve) => { - memfs.fs.mkdir(baseDir, { recursive: true }, () => { - resolve(); - }); - }); - - await new Promise((resolve) => { - memfs.fs.writeFile(filePath, fileContent, () => { - resolve(); - }); - }); - } -} - export class FileSystemWriter implements FileWriter { async write(filePath: string, fileContent: string): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); diff --git a/packages/skott/src/instance.ts b/packages/skott/src/instance.ts new file mode 100644 index 000000000..15bf11c30 --- /dev/null +++ b/packages/skott/src/instance.ts @@ -0,0 +1,52 @@ +import { Option } from "effect"; + +import { + checkIllegalRuntimeConfigs, + decodeInputConfig, + type InputConfig +} from "./config.js"; +import type { RuntimeConfig } from "./config.js"; +import { FileSystemReader } from "./filesystem/file-reader.js"; +import { FileSystemWriter } from "./filesystem/file-writer.js"; +import { FakeLogger, SkottLogger } from "./logger.js"; +import { ModuleWalkerSelector } from "./modules/walkers/common.js"; +import { Skott, type SkottInstance } from "./skott.js"; + +export function createRuntimeConfig( + inputConfig: InputConfig | null = Object.create(null) +): RuntimeConfig { + const config = Option.fromNullable(inputConfig); + + checkIllegalRuntimeConfigs(config); + + return decodeInputConfig(config); +} + +export function createInstance(runtimeConfig: RuntimeConfig): Skott { + const { cwd, verbose, ignorePattern, ...skottConfig } = + createRuntimeConfig(runtimeConfig); + const logger = verbose ? new SkottLogger() : new FakeLogger(); + + return new Skott( + skottConfig, + new FileSystemReader({ cwd, ignorePattern }), + new FileSystemWriter(), + new ModuleWalkerSelector(), + logger + ); +} + +export function run( + inputConfig: InputConfig | null = Object.create(null) +): Promise> { + const config = createRuntimeConfig(inputConfig); + const instance = createInstance(config); + + return instance.initialize(); +} + +export function runFromRuntimeConfig( + runtimeConfig: RuntimeConfig +): Promise> { + return createInstance(runtimeConfig).initialize(); +} diff --git a/packages/skott/src/rendering/api.ts b/packages/skott/src/rendering/api.ts new file mode 100644 index 000000000..efbfabe54 --- /dev/null +++ b/packages/skott/src/rendering/api.ts @@ -0,0 +1,2 @@ +export * as Web from "./webapp/api.js"; +export * as Terminal from "./terminal/api.js"; diff --git a/packages/skott/src/rendering/terminal/api.ts b/packages/skott/src/rendering/terminal/api.ts new file mode 100644 index 000000000..4ed18d8b5 --- /dev/null +++ b/packages/skott/src/rendering/terminal/api.ts @@ -0,0 +1,48 @@ +import kleur from "kleur"; + +import type { InputConfig } from "../../config.js"; +import { createRuntimeConfig } from "../../instance.js"; + +import { runTerminal } from "./runner.js"; +import { makeSkottRunner } from "./runner.js"; +import { + defaultTerminalConfig, + ensureNoIllegalTerminalConfig, + type TerminalConfig +} from "./terminal-config.js"; + +process.on("exit", (code) => { + console.log( + `\n ${kleur.bold().blue("skott")} exited with code ${kleur + .bold() + .yellow(code)}` + ); +}); + +export function renderTerminalApplication( + apiConfig: InputConfig, + options: TerminalConfig = defaultTerminalConfig +): Promise { + const runtimeConfig = createRuntimeConfig(apiConfig); + const terminalOptions: TerminalConfig = { + watch: options.watch ?? defaultTerminalConfig.watch, + displayMode: options.displayMode ?? defaultTerminalConfig.displayMode, + exitCodeOnCircularDependencies: options.exitCodeOnCircularDependencies ?? 1, + showCircularDependencies: + options.showCircularDependencies ?? + defaultTerminalConfig.showCircularDependencies, + showUnusedDependencies: + options.showUnusedDependencies ?? + defaultTerminalConfig.showUnusedDependencies + }; + const isTerminalConfigValid = ensureNoIllegalTerminalConfig(terminalOptions); + + if (isTerminalConfigValid._tag === "Left") { + console.log(`\n ${kleur.bold().red(isTerminalConfigValid.left)}`); + process.exit(1); + } + + const runSkott = makeSkottRunner(runtimeConfig); + + return runTerminal(runSkott, runtimeConfig, terminalOptions); +} diff --git a/packages/skott/src/rendering/terminal/internal.ts b/packages/skott/src/rendering/terminal/internal.ts new file mode 100644 index 000000000..2a1609871 --- /dev/null +++ b/packages/skott/src/rendering/terminal/internal.ts @@ -0,0 +1,56 @@ +import kleur from "kleur"; + +import { createRuntimeConfig } from "../../instance.js"; +import { kExpectedModuleExtensions } from "../../modules/resolvers/base-resolver.js"; + +import { runTerminal } from "./runner.js"; +import { makeSkottRunner } from "./runner.js"; +import { ensureNoIllegalTerminalConfig } from "./terminal-config.js"; + +export type CliOptions = { + entrypoint: string | undefined; +} & CliParameterOptions; + +export type CliParameterOptions = { + circularMaxDepth: number; + cwd: string; + displayMode: "raw" | "file-tree" | "graph" | "webapp"; + exitCodeOnCircularDependencies: number; + fileExtensions: string; + ignorePattern: string; + includeBaseDir: boolean; + incremental: boolean; + manifest: string; + showCircularDependencies: boolean; + showUnusedDependencies: boolean; + trackBuiltinDependencies: boolean; + trackThirdPartyDependencies: boolean; + trackTypeOnlyDependencies: boolean; + tsconfig: string; + verbose: boolean; + watch: boolean; +}; + +export function runTerminalApplicationFromCLI( + entrypoint: string | undefined, + options: CliParameterOptions +): Promise { + const isTerminalConfigValid = ensureNoIllegalTerminalConfig(options); + + if (isTerminalConfigValid._tag === "Left") { + console.log(`\n ${kleur.bold().red(isTerminalConfigValid.left)}`); + process.exit(1); + } + + const runtimeConfig = createRuntimeConfig({ + ...options, + entrypoint, + fileExtensions: options.fileExtensions + .split(",") + .filter((ext) => kExpectedModuleExtensions.has(ext)) + }); + + const runSkott = makeSkottRunner(runtimeConfig); + + return runTerminal(runSkott, runtimeConfig, options); +} diff --git a/packages/skott/src/rendering/terminal/runner.ts b/packages/skott/src/rendering/terminal/runner.ts new file mode 100644 index 000000000..58d3f6424 --- /dev/null +++ b/packages/skott/src/rendering/terminal/runner.ts @@ -0,0 +1,236 @@ +import EventEmitter from "node:events"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; + +import kleur from "kleur"; +import type { Ora } from "ora"; +import ora from "ora"; + +import type { SkottInstance, SkottStructure } from "../../../index.js"; +import type { RuntimeConfig } from "../../config.js"; +import { runFromRuntimeConfig } from "../../instance.js"; +import { registerWatchMode } from "../watch-mode.js"; + +import { renderStaticFile } from "./static-file.js"; +import type { TerminalConfig } from "./terminal-config.js"; +import { + displayCircularDependencies, + displayDependenciesReport +} from "./ui/console/dependencies.js"; +import { renderFileTree } from "./ui/renderers/file-tree.js"; +import { renderGraph } from "./ui/renderers/graph.js"; +import { RenderManager, CliComponent } from "./ui/renderers/render-manager.js"; +import { renderWebApplication } from "./ui/renderers/webapp.js"; + +export function makeSkottRunner( + config: RuntimeConfig +): () => Promise> { + let isFirstRun = true; + + if (config.entrypoint) { + console.log( + `\n Running ${kleur.blue().bold("skott")} from entrypoint: ${kleur + .yellow() + .underline() + .bold(`${config.entrypoint}`)}` + ); + } else { + console.log( + `\n Running ${kleur.blue().bold("skott")} from current directory: ${kleur + .yellow() + .underline() + .bold(`${path.basename(config.cwd)}`)}` + ); + } + + if (config.incremental) { + console.log( + `\n ${kleur + .bold() + .yellow( + "`incremental` mode is experimental. Please report any issues you encounter." + )}` + ); + } + + return async () => { + const startTime = performance.now(); + let spinner: Ora | undefined; + + try { + if (!config.verbose && isFirstRun) { + spinner = ora(`Initializing ${kleur.blue().bold("skott")}`).start(); + } + + isFirstRun = false; + + const skottResult = await runFromRuntimeConfig(config); + + const timeTook = `${(performance.now() - startTime).toFixed(3)}ms`; + + if (spinner && isFirstRun) { + spinner.text = `Finished Skott initialization (${kleur + .magenta() + .bold(timeTook)})`; + spinner.color = "green"; + } + + spinner?.stop(); + + return skottResult; + } catch (error: any) { + if (spinner) { + spinner.stop(); + } + + if (error.message) { + console.log(`\n ${kleur.bold().red("Error: ".concat(error.message))}`); + } else { + console.log( + `\n ${kleur + .bold() + .red( + "Unexpected error. Please use `--verbose` flag and report" + + " an issue at https://github.com/antoine-coulon/skott/issues" + )}` + ); + } + + process.exitCode = 1; + + return undefined as never; + } + }; +} + +function displayInitialGetStructureTime( + files: SkottStructure["files"], + startTime: number +) { + const filesWord = files.length > 1 ? "files" : "file"; + const timeTookStructure = `${(performance.now() - startTime).toFixed(3)}ms`; + + console.log( + `\n Processed ${kleur.bold().green(files.length)} ${filesWord} (${kleur + .magenta() + .bold(timeTookStructure)})` + ); +} + +export async function runTerminal( + run: () => Promise>, + runtimeConfig: RuntimeConfig, + terminalOptions: TerminalConfig +): Promise { + let skottInstance = await run(); + + const start = performance.now(); + const { graph, files } = skottInstance.getStructure(); + displayInitialGetStructureTime(files, start); + + let watcherEmitter: EventEmitter | undefined; + let renderManager: RenderManager | undefined; + + if (terminalOptions.watch) { + watcherEmitter = new EventEmitter(); + renderManager = new RenderManager(watcherEmitter); + } + + if (terminalOptions.displayMode === "file-tree") { + const fileTreeComponent = new CliComponent(() => + renderFileTree(skottInstance, { + circularMaxDepth: runtimeConfig.circularMaxDepth, + exitCodeOnCircularDependencies: + terminalOptions.exitCodeOnCircularDependencies, + showCircularDependencies: terminalOptions.showCircularDependencies + }) + ); + + renderManager?.renderOnChanges(fileTreeComponent); + } else if (terminalOptions.displayMode === "graph") { + const graphComponent = new CliComponent(() => + renderGraph(skottInstance, { + circularMaxDepth: runtimeConfig.circularMaxDepth, + exitCodeOnCircularDependencies: + terminalOptions.exitCodeOnCircularDependencies, + showCircularDependencies: terminalOptions.showCircularDependencies + }) + ); + + renderManager?.renderOnChanges(graphComponent); + } else if (terminalOptions.displayMode === "webapp") { + const circularDepsComponent = new CliComponent(() => + displayCircularDependencies(skottInstance, { + circularMaxDepth: runtimeConfig.circularMaxDepth, + exitCodeOnCircularDependencies: + terminalOptions.exitCodeOnCircularDependencies, + /** + * We only want to display the overview that is whether the graph is + * acyclic or not. Circular dependencies will be displayed within the webapp + * itself. + */ + showCircularDependencies: false + }) + ); + + renderManager?.renderOnChanges(circularDepsComponent); + + renderWebApplication({ + getSkottInstance: () => skottInstance, + options: { + entrypoint: runtimeConfig.entrypoint, + includeBaseDir: runtimeConfig.includeBaseDir, + tracking: runtimeConfig.dependencyTracking + }, + watcherEmitter + }); + } else if (terminalOptions.displayMode === "raw") { + const circularDepsComponent = new CliComponent(() => + displayCircularDependencies(skottInstance, { + circularMaxDepth: runtimeConfig.circularMaxDepth, + exitCodeOnCircularDependencies: + terminalOptions.exitCodeOnCircularDependencies, + showCircularDependencies: terminalOptions.showCircularDependencies + }) + ); + + renderManager?.renderOnChanges(circularDepsComponent); + } else { + // @TODO: check if this is a valid display mode if the registered plugin + // is registered. + await renderStaticFile(graph, terminalOptions.displayMode); + } + + // Additional information we want to display when using the console UI + // To avoid redondant information, we don't display it when using the webapp + if (terminalOptions.displayMode !== "webapp") { + await new Promise((resolve) => { + const depsReportComponent = new CliComponent(() => + displayDependenciesReport(skottInstance, { + showUnusedDependencies: terminalOptions.showUnusedDependencies, + trackBuiltinDependencies: runtimeConfig.dependencyTracking.builtin, + trackThirdPartyDependencies: + runtimeConfig.dependencyTracking.thirdParty + }).then(resolve) + ); + + renderManager?.renderOnChanges(depsReportComponent); + }); + } + + if (terminalOptions.watch) { + registerWatchMode({ + cwd: runtimeConfig.cwd, + ignorePattern: runtimeConfig.ignorePattern, + fileExtensions: runtimeConfig.fileExtensions, + verbose: true, + onChangesDetected: (done) => { + run().then((newSkottInstance) => { + skottInstance = newSkottInstance; + watcherEmitter!.emit("change"); + renderManager!.afterRenderingPhase(done); + }); + } + }); + } +} diff --git a/packages/skott/bin/static-file.ts b/packages/skott/src/rendering/terminal/static-file.ts similarity index 96% rename from packages/skott/bin/static-file.ts rename to packages/skott/src/rendering/terminal/static-file.ts index 33da65f7f..a85d19394 100644 --- a/packages/skott/bin/static-file.ts +++ b/packages/skott/src/rendering/terminal/static-file.ts @@ -3,7 +3,7 @@ import { createRequire } from "module"; import kleur from "kleur"; import ora from "ora"; -import type { SkottNode } from "../src/graph/node.js"; +import type { SkottNode } from "../../graph/node.js"; export async function renderStaticFile( graph: Record, diff --git a/packages/skott/src/rendering/terminal/terminal-config.ts b/packages/skott/src/rendering/terminal/terminal-config.ts new file mode 100644 index 000000000..b88444f55 --- /dev/null +++ b/packages/skott/src/rendering/terminal/terminal-config.ts @@ -0,0 +1,75 @@ +import { Either } from "effect"; +import * as D from "io-ts/lib/Decoder.js"; + +export interface TerminalConfig { + watch: boolean; + displayMode: "raw" | "file-tree" | "graph" | "webapp"; + showCircularDependencies: boolean; + showUnusedDependencies: boolean; + exitCodeOnCircularDependencies: number; +} + +export const defaultTerminalConfig: TerminalConfig = { + watch: false, + displayMode: "raw", + showCircularDependencies: false, + showUnusedDependencies: false, + exitCodeOnCircularDependencies: 1 +}; + +const terminalSchema = D.struct({ + watch: D.boolean, + displayMode: D.union( + D.literal("raw"), + D.literal("file-tree"), + D.literal("graph"), + D.literal("webapp") + ), + showCircularDependencies: D.boolean, + showUnusedDependencies: D.boolean, + exitCodeOnCircularDependencies: D.number +}); + +export function ensureNoIllegalTerminalConfig(options: TerminalConfig) { + const result = terminalSchema.decode(options); + + if (result._tag === "Left") { + return Either.left( + `Invalid terminal configuration: ${D.draw(result.left)}` + ); + } + /** + * Some `show*` params exist but are only relevant when using CLI-based. + * `--showCircularDependencies` is already supported in the webapp even without + * the flag. + * `--showUnusedDependencies` is not supported in the webapp yet but to enforce + * consistency, we don't allow it to be used with `--displayMode=webapp`. + */ + if (options.displayMode === "webapp") { + if (options.showCircularDependencies) { + return Either.left( + "`--showCircularDependencies` can't be used when using `--displayMode=webapp`" + ); + } + if (options.showUnusedDependencies) { + return Either.left( + "`--showUnusedDependencies` can't be used when using `--displayMode=webapp`" + ); + } + } + + if (options.watch) { + if ( + options.displayMode !== "webapp" && + options.displayMode !== "graph" && + options.displayMode !== "raw" && + options.displayMode !== "file-tree" + ) { + return Either.left( + "`--watch` needs either `raw`, `file-tree`, `graph` or `webapp` display mode" + ); + } + } + + return Either.right(void 0); +} diff --git a/packages/skott/bin/ui/console/dependencies.ts b/packages/skott/src/rendering/terminal/ui/console/dependencies.ts similarity index 94% rename from packages/skott/bin/ui/console/dependencies.ts rename to packages/skott/src/rendering/terminal/ui/console/dependencies.ts index f2e3516ce..b197abe35 100644 --- a/packages/skott/bin/ui/console/dependencies.ts +++ b/packages/skott/src/rendering/terminal/ui/console/dependencies.ts @@ -1,8 +1,7 @@ import kleur from "kleur"; -import type { SkottInstance } from "../../../index.js"; -import type { SkottNode } from "../../../src/graph/node.js"; -import type { CliParameterOptions } from "../../cli-config.js"; +import type { SkottInstance } from "../../../../../index.js"; +import type { SkottNode } from "../../../../graph/node.js"; import { makeIndents } from "./shared.js"; @@ -172,7 +171,11 @@ export function displayNoCircularDependenciesFound( export async function displayDependenciesReport( skottInstance: SkottInstance, - options: CliParameterOptions + options: { + showUnusedDependencies: boolean; + trackThirdPartyDependencies: boolean; + trackBuiltinDependencies: boolean; + } ) { if (options.showUnusedDependencies && !options.trackThirdPartyDependencies) { console.log( @@ -209,7 +212,11 @@ export async function displayDependenciesReport( export function displayCircularDependencies( skottInstance: SkottInstance, - options: CliParameterOptions + options: { + circularMaxDepth: number; + showCircularDependencies: boolean; + exitCodeOnCircularDependencies: number; + } ): string[][] { const circularDependencies: string[][] = []; const { findCircularDependencies, hasCircularDependencies } = diff --git a/packages/skott/bin/ui/console/shared.ts b/packages/skott/src/rendering/terminal/ui/console/shared.ts similarity index 100% rename from packages/skott/bin/ui/console/shared.ts rename to packages/skott/src/rendering/terminal/ui/console/shared.ts diff --git a/packages/skott/bin/ui/display-modes/file-tree.ts b/packages/skott/src/rendering/terminal/ui/renderers/file-tree.ts similarity index 88% rename from packages/skott/bin/ui/display-modes/file-tree.ts rename to packages/skott/src/rendering/terminal/ui/renderers/file-tree.ts index 8c2fb5288..a4b034553 100644 --- a/packages/skott/bin/ui/display-modes/file-tree.ts +++ b/packages/skott/src/rendering/terminal/ui/renderers/file-tree.ts @@ -3,8 +3,7 @@ import path from "node:path"; import { makeTreeStructure, type TreeStructure } from "fs-tree-structure"; import kleur from "kleur"; -import type { SkottInstance } from "../../../index.js"; -import type { CliParameterOptions } from "../../cli-config.js"; +import type { SkottInstance } from "../../../../../index.js"; import { displayCircularDependencies } from "../console/dependencies.js"; import { kLeftSeparator, makeIndents } from "../console/shared.js"; @@ -35,7 +34,11 @@ function render( export function renderFileTree( skottInstance: SkottInstance, - options: CliParameterOptions + options: { + circularMaxDepth: number; + showCircularDependencies: boolean; + exitCodeOnCircularDependencies: number; + } ) { const circularDeps = displayCircularDependencies(skottInstance, options); const filesInvolvedInCycles = circularDeps.flat(1); diff --git a/packages/skott/bin/ui/display-modes/graph.ts b/packages/skott/src/rendering/terminal/ui/renderers/graph.ts similarity index 90% rename from packages/skott/bin/ui/display-modes/graph.ts rename to packages/skott/src/rendering/terminal/ui/renderers/graph.ts index b7c7e64c3..b279d7f33 100644 --- a/packages/skott/bin/ui/display-modes/graph.ts +++ b/packages/skott/src/rendering/terminal/ui/renderers/graph.ts @@ -1,8 +1,7 @@ import kleur from "kleur"; -import type { SkottInstance } from "../../../index.js"; -import type { SkottNode, SkottNodeBody } from "../../../src/graph/node.js"; -import type { CliParameterOptions } from "../../cli-config.js"; +import type { SkottInstance } from "../../../../../index.js"; +import type { SkottNode, SkottNodeBody } from "../../../../graph/node.js"; import { displayCircularDependencies } from "../console/dependencies.js"; import { bytesToKB, kLeftSeparator, makeIndents } from "../console/shared.js"; @@ -69,7 +68,11 @@ function render( export function renderGraph( skottInstance: SkottInstance, - options: CliParameterOptions + options: { + circularMaxDepth: number; + showCircularDependencies: boolean; + exitCodeOnCircularDependencies: number; + } ) { const circularDeps = displayCircularDependencies(skottInstance, options); const filesInvolvedInCycles = circularDeps.flat(1); diff --git a/packages/skott/bin/ui/display-modes/render-manager.ts b/packages/skott/src/rendering/terminal/ui/renderers/render-manager.ts similarity index 100% rename from packages/skott/bin/ui/display-modes/render-manager.ts rename to packages/skott/src/rendering/terminal/ui/renderers/render-manager.ts diff --git a/packages/skott/bin/ui/display-modes/webapp.ts b/packages/skott/src/rendering/terminal/ui/renderers/webapp.ts similarity index 59% rename from packages/skott/bin/ui/display-modes/webapp.ts rename to packages/skott/src/rendering/terminal/ui/renderers/webapp.ts index 21e5e9e2d..3b57668e0 100644 --- a/packages/skott/bin/ui/display-modes/webapp.ts +++ b/packages/skott/src/rendering/terminal/ui/renderers/webapp.ts @@ -1,15 +1,12 @@ import type EventEmitter from "node:events"; -import path from "node:path"; -import compression from "compression"; import kleur from "kleur"; -import polka from "polka"; -import sirv from "sirv"; -import resolveWebAppStaticPath from "skott-webapp"; -import type { SkottInstance } from "../../../src/skott.js"; -import type { CliOptions } from "../../cli-config.js"; -import { open } from "../../open-url.js"; +import { + createHttpApp, + resolveEntrypointPath +} from "../../../../rendering/webapp/internal.js"; +import type { SkottInstance, SkottConfig } from "../../../../skott.js"; const trackingWithCommands = { builtin: { @@ -26,12 +23,6 @@ const trackingWithCommands = { } } as const; -function findSkottWebAppDirectory(): string { - const skottWebAppDirectory = resolveWebAppStaticPath(); - - return skottWebAppDirectory; -} - function renderSelectedTracking( option: keyof typeof trackingWithCommands, enabled: boolean @@ -51,42 +42,30 @@ function renderSelectedTracking( } } -function resolveEntrypointPath(options: CliOptions) { - const { entrypoint, includeBaseDir } = options; - let baseEntrypointPath: string | undefined; - - if (includeBaseDir && entrypoint) { - baseEntrypointPath = path.join(path.dirname(entrypoint), entrypoint); - } else if (entrypoint) { - baseEntrypointPath = path.basename(entrypoint); - } - - return baseEntrypointPath; -} - export function renderWebApplication(config: { getSkottInstance: () => SkottInstance; - options: CliOptions; + options: { + tracking: SkottConfig["dependencyTracking"]; + entrypoint: string | undefined; + includeBaseDir: boolean; + }; watcherEmitter?: EventEmitter; }): void { const entrypoint = resolveEntrypointPath(config.options); const { getSkottInstance, watcherEmitter } = config; - const skottWebAppPath = findSkottWebAppDirectory(); const dependencyTracking = { - thirdParty: config.options.trackThirdPartyDependencies, - builtin: config.options.trackBuiltinDependencies, - typeOnly: config.options.trackTypeOnlyDependencies + thirdParty: config.options.tracking.thirdParty, + builtin: config.options.tracking.builtin, + typeOnly: config.options.tracking.typeOnly }; for (const [key, value] of Object.entries(dependencyTracking)) { renderSelectedTracking(key as keyof typeof trackingWithCommands, value); } - const compress = compression(); - const assets = sirv(skottWebAppPath, { - immutable: true - }); - const app = polka().use(compress, assets); + const { app, listen } = createHttpApp( + process.env.SKOTT_PORT ? parseInt(process.env.SKOTT_PORT, 10) : 0 + ); app.get("/api/subscribe", (request, response) => { response.writeHead(200, { @@ -139,31 +118,5 @@ export function renderWebApplication(config: { ); }); - app.listen(process.env.SKOTT_PORT || 0); - - // @ts-expect-error - "port" exists - const bindedAddress = `http://localhost:${app.server?.address()?.port}`; - - console.log( - `\n ${kleur.bold(`💻 Web application is ready:`)} ${kleur - .bold() - .underline() - .magenta(`${bindedAddress}`)}` - ); - - open(bindedAddress, (error) => { - if (error) { - console.log( - `\n ${kleur - .red() - .bold( - `Could not automatically open the application on ${bindedAddress}. Reason: "${ - error.message ?? "unknown" - }"` - )} - \n ${kleur.yellow().bold("Application remains accessible manually")} - ` - ); - } - }); + listen({ autoOpen: true }); } diff --git a/packages/skott/bin/watch-mode.ts b/packages/skott/src/rendering/watch-mode.ts similarity index 74% rename from packages/skott/bin/watch-mode.ts rename to packages/skott/src/rendering/watch-mode.ts index 897def945..82f78f36b 100644 --- a/packages/skott/bin/watch-mode.ts +++ b/packages/skott/src/rendering/watch-mode.ts @@ -8,7 +8,7 @@ import kleur from "kleur"; // @ts-expect-error - no valid type definitions exist import gitignoreParse from "parse-gitignore"; -import { defaultIgnoredDirs } from "../src/modules/resolvers/base-resolver.js"; +import { defaultIgnoredDirs } from "../modules/resolvers/base-resolver.js"; export const watchModeStatus = { watching_for_changes: @@ -56,22 +56,40 @@ async function retrieveGitIgnoredEntries(cwd: string): Promise { ); } +function makeLogger(verbose: boolean) { + return { + log: (message: string) => { + if (!verbose) return; + console.log(message); + }, + stdout: (message: string) => { + if (!verbose) return; + process.stdout.write(message); + } + }; +} + +export interface WatchModeOptions { + cwd: string; + ignorePattern: string; + fileExtensions: string[]; + verbose?: boolean; + onChangesDetected: (doneSubscribersPropagation: () => void) => void; +} + export async function registerWatchMode({ cwd, ignorePattern, fileExtensions, + verbose, onChangesDetected -}: { - cwd: string; - ignorePattern: string; - fileExtensions: string[]; - onChangesDetected: (doneSubscribersPropagation: () => void) => void; -}) { +}: WatchModeOptions) { /** * For simplicity's sake, we only support discarding entries from the .gitignore * located at the provided cwd. */ const gitIgnoredEntries = await retrieveGitIgnoredEntries(cwd); + const logger = makeLogger(verbose ?? false); const listOfWatchableFileExtensions = fileExtensions .map(toDotlessExtension) @@ -90,13 +108,11 @@ export async function registerWatchMode({ ignoreList.push(ignorePattern); } - console.log( + logger.log( kleur.bold().grey("\n \n -------------skott(watch-mode)-------------") ); - console.log( - `\n ${kleur.bold().yellow(watchModeStatus.watching_for_changes)}` - ); + logger.log(`\n ${kleur.bold().yellow(watchModeStatus.watching_for_changes)}`); const uniqueIgnoreList = [...new Set(ignoreList)]; @@ -109,29 +125,31 @@ export async function registerWatchMode({ return watcher.subscribe( cwd, (_err, _events) => { - changesCount++; + if (verbose) { + changesCount++; - if (changesCount === 1) { - process.stdout.write("\n"); - } + if (changesCount === 1) { + logger.stdout("\n"); + } - clearTerminal(); + clearTerminal(); - if (changesCount > 1) { - process.stdout.clearLine(0); - process.stdout.cursorTo(0); + if (changesCount > 1) { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + } } onChangesDetected(() => { - console.log( + logger.log( kleur.bold().grey("\n \n <------------skott(watch-mode)------------>") ); - console.log( + logger.log( `\n ${kleur.bold().yellow(watchModeStatus.watching_for_changes)}` ); - process.stdout.write( + logger.stdout( `\n ${kleur .bold() .yellow(watchModeStatus.changes_detected)} ${printChangeCount()}` @@ -143,3 +161,8 @@ export async function registerWatchMode({ } ); } + +process.on("SIGINT", () => { + console.log(`\n ${kleur.bold().blue("skott")} was interrupted`); + process.exit(130); +}); diff --git a/packages/skott/src/rendering/webapp/api.ts b/packages/skott/src/rendering/webapp/api.ts new file mode 100644 index 000000000..5616568d3 --- /dev/null +++ b/packages/skott/src/rendering/webapp/api.ts @@ -0,0 +1,265 @@ +import EventEmitter from "events"; + +import type { InputConfig } from "../../config.js"; +import { createRuntimeConfig, runFromRuntimeConfig } from "../../instance.js"; +import type { SkottInstance, SkottStructure } from "../../skott.js"; +import { registerWatchMode } from "../watch-mode.js"; + +import { createHttpApp, resolveEntrypointPath } from "./internal.js"; + +export interface ApiResponses { + cycles: string[][]; + structure: SkottStructure & { + entrypoint: string | "none"; + cycles: string[][]; + }; + meta: { + granularity: "module" | "group"; + }; +} + +/** + * Renders skott web application using the api and application configurations. + * skott's manages its own lifecycle and internals will automatically be used by + * the rendering process to generate all the data source information then consumed + * by the web client. + * + * @param apiConfig Configuration that will be used by skott's internals to generate + * the whole data source consumed by the web client. This is equivalent to the + * runtime configuration accepted by skott entrypoint module. + * + * @param applicationConfig Application configuration used by the web application + * that will be rendered. + */ +export async function renderWebApplication( + apiConfig: InputConfig, + applicationConfig: { + port: number; + open: boolean; + visualization: { + granularity: "module" | "group"; + }; + watch: boolean; + onListen?: (port: number) => void; + onOpenError?: (error: Error) => void; + } +) { + const runtimeConfig = createRuntimeConfig(apiConfig); + const entrypoint = resolveEntrypointPath({ + entrypoint: runtimeConfig.entrypoint, + includeBaseDir: runtimeConfig.includeBaseDir + }); + const runSkott = () => runFromRuntimeConfig(runtimeConfig); + const { port, open, onListen, onOpenError, visualization, watch } = + applicationConfig; + const { app, listen } = createHttpApp(port); + + let skottInstance = await runSkott(); + + const watcherEmitter = applicationConfig.watch + ? new EventEmitter() + : undefined; + + if (watch) { + registerWatchMode({ + cwd: runtimeConfig.cwd, + ignorePattern: runtimeConfig.ignorePattern, + fileExtensions: runtimeConfig.fileExtensions, + verbose: true, + onChangesDetected: (done) => { + runSkott().then((newSkottInstance) => { + skottInstance = newSkottInstance; + watcherEmitter!.emit("change"); + done(); + }); + } + }); + } + + app.get("/api/subscribe", (request, response) => { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive" + }); + + const listener = () => { + /** + * For now, we don't send any specific data related to the changes that + * were detected, we just want to send a raw notification for the client + * to be able to refresh the visualization. + */ + response.write(`data: refresh\n\n`); + /** + * Response needs to be manually flushed to the client given that we are using + * `compression` middleware. Otherwise no events will be sent to the client. + * See the following comment to understand why we manually need to flush + * the response: + * https://github.com/lukeed/polka/issues/84#issuecomment-1902697935 + */ + response.flush(); + }; + + watcherEmitter?.on("change", listener); + + request.on("close", () => { + watcherEmitter?.removeListener("change", listener); + }); + }); + + app.get("/api/cycles", (_, response) => { + const cycles = skottInstance.useGraph().findCircularDependencies(); + + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(cycles)); + }); + + app.get("/api/analysis", (_, response) => { + const structure = skottInstance.getStructure(); + + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + ...structure, + entrypoint: entrypoint ?? "none", + cycles: [] + }) + ); + }); + + app.get("/api/meta", (_, response) => { + const meta = { visualization }; + + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(meta)); + }); + + listen({ + autoOpen: open, + onListen, + onOpenError + }); +} + +/** + * Renders a standalone skott web application using an arbitrary function that + * returns a SkottInstance interface. This is useful when generating skott-like + * information from plugins that need additional context to generate the graph, + * while waiting for skott to allow such behavior directly from its runtime config. + * + * @param fromInstance Factory function returning a SkottInstance interface that + * will be used as the data source for the web application. As long as the interface + * is compatible with the SkottInstance interface, the web client will work as expected. + * + * @param applicationConfig Application configuration used by the web application + * that will be rendered. + */ +export async function renderStandaloneWebApplication( + fromInstance: () => Promise>, + applicationConfig: { + port: number; + open: boolean; + visualization: { + granularity: "module" | "group"; + }; + watch: { + cwd: string; + ignorePattern: string; + fileExtensions: string[]; + verbose: boolean; + }; + onListen?: (port: number) => void; + onOpenError?: (error: Error) => void; + } +) { + const { port, open, onListen, onOpenError, visualization, watch } = + applicationConfig; + const { app, listen } = createHttpApp(port); + + let skottInstance = await fromInstance(); + const runSkott = () => fromInstance(); + + const watcherEmitter = applicationConfig.watch + ? new EventEmitter() + : undefined; + + if (watch) { + registerWatchMode({ + cwd: applicationConfig.watch.cwd, + ignorePattern: applicationConfig.watch.ignorePattern, + fileExtensions: applicationConfig.watch.fileExtensions, + verbose: applicationConfig.watch.verbose, + onChangesDetected: (done) => { + runSkott().then((newSkottInstance) => { + skottInstance = newSkottInstance; + watcherEmitter!.emit("change"); + done(); + }); + } + }); + } + + app.get("/api/subscribe", (request, response) => { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive" + }); + + const listener = () => { + /** + * For now, we don't send any specific data related to the changes that + * were detected, we just want to send a raw notification for the client + * to be able to refresh the visualization. + */ + response.write(`data: refresh\n\n`); + /** + * Response needs to be manually flushed to the client given that we are using + * `compression` middleware. Otherwise no events will be sent to the client. + * See the following comment to understand why we manually need to flush + * the response: + * https://github.com/lukeed/polka/issues/84#issuecomment-1902697935 + */ + response.flush(); + }; + + watcherEmitter?.on("change", listener); + + request.on("close", () => { + watcherEmitter?.removeListener("change", listener); + }); + }); + + app.get("/api/cycles", (_, response) => { + const cycles = skottInstance.useGraph().findCircularDependencies(); + + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(cycles)); + }); + + app.get("/api/analysis", (_, response) => { + const structure = skottInstance.getStructure(); + + response.setHeader("Content-Type", "application/json"); + response.end( + JSON.stringify({ + ...structure, + entrypoint: "none", + cycles: [] + }) + ); + }); + + app.get("/api/meta", (_, response) => { + const meta = { visualization }; + + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify(meta)); + }); + + listen({ + autoOpen: open, + onListen, + onOpenError + }); +} diff --git a/packages/skott/src/rendering/webapp/internal.ts b/packages/skott/src/rendering/webapp/internal.ts new file mode 100644 index 000000000..62e9663b3 --- /dev/null +++ b/packages/skott/src/rendering/webapp/internal.ts @@ -0,0 +1,151 @@ +import { ChildProcess, spawn } from "node:child_process"; +import path from "node:path"; +import { platform } from "node:process"; + +import compression from "compression"; +import isWsl from "is-wsl"; +import kleur from "kleur"; +import polka from "polka"; +import sirv from "sirv"; +import resolveWebAppStaticPath from "skott-webapp"; + +const supportedPlatforms = ["darwin", "win32", "linux"] as const; + +function selectPlatformBinary() { + let binary: string | undefined; + + switch (platform) { + case "darwin": + binary = "open"; + break; + case "win32": + binary = "explorer.exe"; + break; + case "linux": + binary = "xdg-open"; + break; + default: + throw new Error( + `Unsupported platform: ${ + process.platform + }. Supported platforms are: ${supportedPlatforms.join(", ")}` + ); + } + + return binary; +} + +function open(url: string, callback: (error: Error) => void) { + try { + let child: ChildProcess; + + if (isWsl) { + child = spawn("cmd.exe", ["/c", "start", url]); + } else { + const binary = selectPlatformBinary(); + child = spawn(binary, [url]); + } + + child.on("error", callback); + } catch (error) { + callback(error as Error); + } +} + +function findSkottWebAppDirectory() { + const skottWebAppDirectory = resolveWebAppStaticPath(); + + if (!skottWebAppDirectory) { + throw new Error( + "package 'skott-webapp' could not be found. Please install it as a dependency." + ); + } + + return skottWebAppDirectory; +} + +export function createHttpApp(port: number) { + const skottWebAppPath = findSkottWebAppDirectory(); + const compress = compression(); + const assets = sirv(skottWebAppPath, { + immutable: true + }); + const app = polka().use(compress, assets); + + return { + app, + listen: ( + inputOptions: + | { + autoOpen: false; + onListen?: (port: number) => void; + } + | { + autoOpen: true; + onListen?: (port: number) => void; + onOpenError?: (error: Error) => void; + } + ) => { + app.listen(port); + + // @ts-expect-error - port exists + const bindedAddress = `http://localhost:${app.server?.address()?.port}`; + + if (inputOptions.onListen) { + inputOptions.onListen(port); + } else { + console.log( + `\n ${kleur.bold(`💻 Web application is ready:`)} ${kleur + .bold() + .underline() + .magenta(`${bindedAddress}`)}` + ); + } + + if (!inputOptions.autoOpen) { + return; + } + + open(bindedAddress, (error) => { + if (error) { + if (inputOptions.onOpenError) { + inputOptions.onOpenError(error); + + return; + } + + console.log( + `\n ${kleur + .red() + .bold( + `Could not automatically open the application on ${bindedAddress}. Reason: "${ + error.message ?? "unknown" + }"` + )} + + \n ${kleur + .yellow() + .bold("Application remains accessible manually")} + ` + ); + } + }); + } + }; +} + +export function resolveEntrypointPath(options: { + entrypoint: string | undefined; + includeBaseDir: boolean; +}) { + const { entrypoint, includeBaseDir } = options; + let baseEntrypointPath: string | undefined; + + if (includeBaseDir && entrypoint) { + baseEntrypointPath = path.join(path.dirname(entrypoint), entrypoint); + } else if (entrypoint) { + baseEntrypointPath = path.basename(entrypoint); + } + + return baseEntrypointPath; +} diff --git a/packages/skott/test/integration/api.spec.ts b/packages/skott/test/integration/api/runner.spec.ts similarity index 72% rename from packages/skott/test/integration/api.spec.ts rename to packages/skott/test/integration/api/runner.spec.ts index 1ff257e33..7ae4ed7ed 100644 --- a/packages/skott/test/integration/api.spec.ts +++ b/packages/skott/test/integration/api/runner.spec.ts @@ -1,16 +1,15 @@ import { describe, expect, test } from "vitest"; -import skott from "../../index.js"; -import { FileSystemReader } from "../../src/filesystem/file-reader.js"; -import { InMemoryFileWriter } from "../../src/filesystem/file-writer.js"; -import { FakeLogger } from "../../src/logger.js"; -import { ModuleWalkerSelector } from "../../src/modules/walkers/common.js"; -import { Skott, defaultConfig } from "../../src/skott.js"; - -import { createRealFileSystem } from "./create-fs-sandbox.js"; - -describe("When running Skott using all real dependencies", () => { - describe("When providing various configurations", () => { +import skott from "../../../index.js"; +import { InMemoryFileWriter } from "../../../src/filesystem/fake/file-writer.js"; +import { FileSystemReader } from "../../../src/filesystem/file-reader.js"; +import { FakeLogger } from "../../../src/logger.js"; +import { ModuleWalkerSelector } from "../../../src/modules/walkers/common.js"; +import { Skott, defaultConfig } from "../../../src/skott.js"; +import { createRealFileSystem } from "../create-fs-sandbox.js"; + +describe.sequential("When running skott using all real dependencies", () => { + describe.sequential("When providing various configurations", () => { test("Should support empty config", async () => { const skottInstance = await skott(); @@ -26,7 +25,7 @@ describe("When running Skott using all real dependencies", () => { }); } - await expect(makeSkott()).rejects.toThrow( + await expect(async () => makeSkott()).rejects.toThrow( "Illegal configuration: `includeBaseDir` can only be used when providing an entrypoint" ); }); @@ -40,14 +39,14 @@ describe("When running Skott using all real dependencies", () => { }); } - await expect(makeSkott()).rejects.toThrow( + await expect(async () => makeSkott()).rejects.toThrow( "Illegal configuration: `cwd` can't be customized when providing an entrypoint" ); }); - describe("groupBy", () => { + describe.sequential("groupBy", () => { test("Should not allow `groupBy` to be a non-function", async () => { - await expect(() => + await expect(async () => skott({ // @ts-expect-error groupBy: "not-a-function" @@ -67,7 +66,7 @@ describe("When running Skott using all real dependencies", () => { }); }); - describe("When traversing files", () => { + describe.sequential("When traversing files", () => { test("Should ignore files listed in `.gitignore`", async () => { const fsRootDir = `skott-ignore-temp-fs`; @@ -107,8 +106,8 @@ describe("When running Skott using all real dependencies", () => { }); }); - describe("When using ignore pattern", () => { - describe("When running bulk analysis", () => { + describe.sequential("When using ignore pattern", () => { + describe.sequential("When running bulk analysis", () => { test("Should discard files with pattern relative to an absolute directory path", async () => { const fsRootDir = `skott-ignore-temp-fs`; @@ -177,23 +176,89 @@ describe("When running Skott using all real dependencies", () => { }); }); - describe("When there is module imports between files", () => { - test("Should discard files + ignore their imported files with pattern relative to the baseDir", async () => { + describe.sequential( + "When there is module imports between files", + () => { + test("Should discard files + ignore their imported files with pattern relative to the baseDir", async () => { + const fsRootDir = `skott-ignore-temp-fs`; + + const runSandbox = createRealFileSystem(fsRootDir, { + "skott-ignore-temp-fs/project-a/file.ts": `import ../util/dates`, + "skott-ignore-temp-fs/project-b/nested/file.ts": `import ../../util/dates`, + "skott-ignore-temp-fs/util/dates/index.ts": `console.log("hello world")` + }); + + expect.assertions(1); + + const skott = new Skott( + defaultConfig, + new FileSystemReader({ + cwd: fsRootDir, + ignorePattern: `util/dates/**/*` + }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + await runSandbox(async () => { + const { files } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(files).toEqual([ + "skott-ignore-temp-fs/project-a/file.ts", + "skott-ignore-temp-fs/project-b/nested/file.ts" + ]); + }); + }); + + test("Should ignore files + their relatively imported files with pattern relative to cwd", async () => { + const skott = new Skott( + defaultConfig, + new FileSystemReader({ + cwd: process.cwd(), + ignorePattern: `src/**/*` + }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + const { files } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(files.filter((f) => f.startsWith("src"))).toEqual([]); + }); + } + ); + }); + + describe.sequential( + "When running analysis starting from an entrypoint", + () => { + test("Should ignore files using ignore pattern relatively to the provided the base dir", async () => { const fsRootDir = `skott-ignore-temp-fs`; const runSandbox = createRealFileSystem(fsRootDir, { - "skott-ignore-temp-fs/project-a/file.ts": `import ../util/dates`, - "skott-ignore-temp-fs/project-b/nested/file.ts": `import ../../util/dates`, - "skott-ignore-temp-fs/util/dates/index.ts": `console.log("hello world")` + "skott-ignore-temp-fs/project-a/file.ts": ` + import "../lib/index"; + `, + "skott-ignore-temp-fs/lib/index.ts": `console.log("hello world")` }); expect.assertions(1); const skott = new Skott( - defaultConfig, + { + ...defaultConfig, + entrypoint: `${fsRootDir}/project-a/file.ts`, + includeBaseDir: true + }, new FileSystemReader({ cwd: fsRootDir, - ignorePattern: `util/dates/**/*` + ignorePattern: `lib/**/*` }), new InMemoryFileWriter(), new ModuleWalkerSelector(), @@ -205,112 +270,52 @@ describe("When running Skott using all real dependencies", () => { .initialize() .then(({ getStructure }) => getStructure()); - expect(files).toEqual([ - "skott-ignore-temp-fs/project-a/file.ts", - "skott-ignore-temp-fs/project-b/nested/file.ts" - ]); + expect(files).toEqual(["skott-ignore-temp-fs/project-a/file.ts"]); }); }); - test("Should ignore files + their relatively imported files with pattern realtive to cwd", async () => { + test("Should ignore files using ignore pattern without relying on provided the base dir", async () => { + const fsRootDir = `skott-ignore-temp-fs`; + + const runSandbox = createRealFileSystem(fsRootDir, { + "skott-ignore-temp-fs/project-a/file.ts": ` + import "../lib/index"; + import "../sub-folder/lib/index"; + `, + "skott-ignore-temp-fs/sub-folder/lib/index.ts": `console.log("hello world")`, + "skott-ignore-temp-fs/lib/index.ts": `console.log("hello world")` + }); + + expect.assertions(1); + const skott = new Skott( - defaultConfig, + { + ...defaultConfig, + entrypoint: `${fsRootDir}/project-a/file.ts`, + includeBaseDir: false + }, new FileSystemReader({ - cwd: process.cwd(), - ignorePattern: `src/**/*` + cwd: fsRootDir, + ignorePattern: `lib/**/*` }), new InMemoryFileWriter(), new ModuleWalkerSelector(), new FakeLogger() ); - const { files } = await skott - .initialize() - .then(({ getStructure }) => getStructure()); - - expect(files.filter((f) => f.includes("src"))).toEqual([]); - }); - }); - }); - - describe("When running analysis starting from an entrypoint", () => { - test("Should ignore files using ignore pattern relatively to the provided the base dir", async () => { - const fsRootDir = `skott-ignore-temp-fs`; - - const runSandbox = createRealFileSystem(fsRootDir, { - "skott-ignore-temp-fs/project-a/file.ts": ` - import "../lib/index"; - `, - "skott-ignore-temp-fs/lib/index.ts": `console.log("hello world")` - }); - - expect.assertions(1); - - const skott = new Skott( - { - ...defaultConfig, - entrypoint: `${fsRootDir}/project-a/file.ts`, - includeBaseDir: true - }, - new FileSystemReader({ - cwd: fsRootDir, - ignorePattern: `lib/**/*` - }), - new InMemoryFileWriter(), - new ModuleWalkerSelector(), - new FakeLogger() - ); - - await runSandbox(async () => { - const { files } = await skott - .initialize() - .then(({ getStructure }) => getStructure()); - - expect(files).toEqual(["skott-ignore-temp-fs/project-a/file.ts"]); - }); - }); - - test("Should ignore files using ignore pattern without relying on provided the base dir", async () => { - const fsRootDir = `skott-ignore-temp-fs`; - - const runSandbox = createRealFileSystem(fsRootDir, { - "skott-ignore-temp-fs/project-a/file.ts": ` - import "../lib/index"; - import "../sub-folder/lib/index"; - `, - "skott-ignore-temp-fs/sub-folder/lib/index.ts": `console.log("hello world")`, - "skott-ignore-temp-fs/lib/index.ts": `console.log("hello world")` - }); - - expect.assertions(1); - - const skott = new Skott( - { - ...defaultConfig, - entrypoint: `${fsRootDir}/project-a/file.ts`, - includeBaseDir: false - }, - new FileSystemReader({ - cwd: fsRootDir, - ignorePattern: `lib/**/*` - }), - new InMemoryFileWriter(), - new ModuleWalkerSelector(), - new FakeLogger() - ); - - await runSandbox(async () => { - const { files } = await skott - .initialize() - .then(({ getStructure }) => getStructure()); + await runSandbox(async () => { + const { files } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); - expect(files).toEqual(["file.ts", "../sub-folder/lib/index.ts"]); + expect(files).toEqual(["file.ts", "../sub-folder/lib/index.ts"]); + }); }); - }); - }); + } + ); }); - describe("When grouping is enabled", () => { + describe.sequential("When grouping is enabled", () => { const fsRootDir = `skott-ignore-temp-fs`; const runSandbox = createRealFileSystem(fsRootDir, { @@ -436,8 +441,8 @@ describe("When running Skott using all real dependencies", () => { .initialize() .then(({ getStructure }) => getStructure()); }) - ).rejects.toMatchInlineSnapshot( - '[Error: groupBy function must return a string or undefined, but returned "[object Object]" (for "skott-ignore-temp-fs/src/features/feature-b/index.js")]' + ).rejects.toThrowError( + 'groupBy function must return a string or undefined, but returned "[object Object]" (for "skott-ignore-temp-fs/src/features/feature-b/index.js")' ); }); diff --git a/packages/skott/test/integration/cli/cli.spec.ts b/packages/skott/test/integration/cli/cli.spec.ts index 0703e382b..d71a90061 100644 --- a/packages/skott/test/integration/cli/cli.spec.ts +++ b/packages/skott/test/integration/cli/cli.spec.ts @@ -21,7 +21,7 @@ const useTimeout = (time_ms: number) => AbortSignal.timeout(time_ms); const increaseTimeoutFactor = process.env.CI ? 3 : 1; -describe("When running skott cli", () => { +describe.sequential("When running skott cli", () => { beforeAll(async () => { await transpileCliExecutable(); }); @@ -277,6 +277,6 @@ describe("When running skott cli", () => { }); }); }, - { timeout: 30_000 } + 30_000 ); }); diff --git a/packages/skott/test/integration/cli/watch-expect.ts b/packages/skott/test/integration/cli/watch-expect.ts index 979603fe6..90393c1f2 100644 --- a/packages/skott/test/integration/cli/watch-expect.ts +++ b/packages/skott/test/integration/cli/watch-expect.ts @@ -1,7 +1,7 @@ import child_process from "node:child_process"; import fs from "node:fs"; -import { watchModeStatus } from "../../../bin/watch-mode.js"; +import { watchModeStatus } from "../../../src/rendering/watch-mode.js"; export function prepareFinalizer(entryPath: string) { return (done: () => void) => { diff --git a/packages/skott/test/integration/create-fs-sandbox.ts b/packages/skott/test/integration/create-fs-sandbox.ts index a65226b8e..1fdc6f345 100644 --- a/packages/skott/test/integration/create-fs-sandbox.ts +++ b/packages/skott/test/integration/create-fs-sandbox.ts @@ -15,18 +15,20 @@ export function createRealFileSystem< entries: Record<`${FsDelimiter}${RootDir}/${File}`, string> ) { async function make() { - try { - for (const [filePath, content] of Object.entries(entries)) { + for (const [filePath, content] of Object.entries(entries)) { + try { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content); - } - } catch {} + } catch {} + } } async function unmake() { - await fs.rm(fsRootDir, { recursive: true }); + try { + await fs.rm(fsRootDir, { recursive: true }); + } catch {} } return async (cb: AsyncCallback) => { diff --git a/packages/skott/test/integration/ecmascript/typescript.spec.ts b/packages/skott/test/integration/ecmascript/typescript.spec.ts index c5314f567..4516768a7 100644 --- a/packages/skott/test/integration/ecmascript/typescript.spec.ts +++ b/packages/skott/test/integration/ecmascript/typescript.spec.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, test } from "vitest"; +import { InMemoryFileWriter } from "../../../src/filesystem/fake/file-writer.js"; import { FileSystemReader } from "../../../src/filesystem/file-reader.js"; -import { InMemoryFileWriter } from "../../../src/filesystem/file-writer.js"; import { FakeLogger } from "../../../src/logger.js"; import { ModuleWalkerSelector } from "../../../src/modules/walkers/common.js"; import { Skott, defaultConfig } from "../../../src/skott.js"; @@ -9,7 +9,7 @@ import { fakeNodeBody } from "../../unit/shared.js"; import { createRealFileSystem, withRootDir } from "../create-fs-sandbox.js"; describe("When the extended config is coming from a third-party module", () => { - it("should resolve the path alias using the third-party config", async () => { + test("should resolve the path alias using the third-party config", async () => { const tsConfigRemote = { compilerOptions: { baseUrl: "./", @@ -70,7 +70,7 @@ describe("When the extended config is coming from a third-party module", () => { }); describe("When resolving modules with path aliases", () => { - it("Should only include file paths starting from the project base directory", async () => { + test("Should only include file paths starting from the project base directory", async () => { const tsConfig = { compilerOptions: { baseUrl: "src" diff --git a/packages/skott/test/integration/workspace.spec.ts b/packages/skott/test/integration/workspace.spec.ts index 83d59f18d..1b4cb9980 100644 --- a/packages/skott/test/integration/workspace.spec.ts +++ b/packages/skott/test/integration/workspace.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; +import { InMemoryFileWriter } from "../../src/filesystem/fake/file-writer.js"; import { FileSystemReader } from "../../src/filesystem/file-reader.js"; -import { InMemoryFileWriter } from "../../src/filesystem/file-writer.js"; import { FakeLogger } from "../../src/logger.js"; import { ModuleWalkerSelector } from "../../src/modules/walkers/common.js"; import { Skott, defaultConfig } from "../../src/skott.js"; diff --git a/packages/skott/test/unit/ecmascript/graph.spec.ts b/packages/skott/test/unit/ecmascript/graph.spec.ts index 05ab75d92..0a1390736 100644 --- a/packages/skott/test/unit/ecmascript/graph.spec.ts +++ b/packages/skott/test/unit/ecmascript/graph.spec.ts @@ -1,19 +1,20 @@ import * as memfs from "memfs"; import { describe, expect, it } from "vitest"; +import { InMemoryFileWriter } from "../../../src/filesystem/fake/file-writer.js"; import { FileReader } from "../../../src/filesystem/file-reader.js"; -import { InMemoryFileWriter } from "../../../src/filesystem/file-writer.js"; -import { SkottNode } from "../../../src/graph/node.js"; import { CollectLevel } from "../../../src/graph/traversal.js"; import { FakeLogger } from "../../../src/logger.js"; import { EcmaScriptDependencyResolver } from "../../../src/modules/resolvers/ecmascript/resolver.js"; import { ModuleWalkerSelector } from "../../../src/modules/walkers/common.js"; -import { Skott, SkottStructure } from "../../../src/skott.js"; +import { Skott, type SkottStructure } from "../../../src/skott.js"; import { buildSkottProjectUsingInMemoryFileExplorer, mountFakeFileSystem } from "../shared.js"; +import type { SkottNode } from "skott/graph/node"; + class InMemoryFileReaderWithFakeStats implements FileReader { read(filename: string): Promise { return new Promise((resolve) => { diff --git a/packages/skott/test/unit/ecmascript/unused.spec.ts b/packages/skott/test/unit/ecmascript/unused.spec.ts index e07c646c8..0487ee072 100644 --- a/packages/skott/test/unit/ecmascript/unused.spec.ts +++ b/packages/skott/test/unit/ecmascript/unused.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { InMemoryFileReader } from "../../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../../src/filesystem/fake/file-writer.js"; import { FakeLogger } from "../../../src/logger.js"; import { ModuleWalkerSelector } from "../../../src/modules/walkers/common.js"; import { defaultConfig, Skott, type SkottConfig } from "../../../src/skott.js"; diff --git a/packages/skott/test/unit/incremental/index.spec.ts b/packages/skott/test/unit/incremental/index.spec.ts index f9f3e4ded..8b658bbbd 100644 --- a/packages/skott/test/unit/incremental/index.spec.ts +++ b/packages/skott/test/unit/incremental/index.spec.ts @@ -5,14 +5,14 @@ import { createNodeHash } from "../../../src/cache/affected.js"; import { createInitialSkottNodeValue, kSkottCacheFileName, - SkottCache, - SkottCachedNode + type SkottCache, + type SkottCachedNode } from "../../../src/cache/handler.js"; import { InMemoryFileReader } from "../../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../../src/filesystem/fake/file-writer.js"; import { FakeLogger } from "../../../src/logger.js"; import { - ModuleWalker, + type ModuleWalker, ModuleWalkerSelector } from "../../../src/modules/walkers/common.js"; import { JavaScriptModuleWalker } from "../../../src/modules/walkers/ecmascript/index.js"; diff --git a/packages/skott/test/unit/plugins/custom-resolver.spec.ts b/packages/skott/test/unit/plugins/custom-resolver.spec.ts index 4291d2dd4..9c0c54d70 100644 --- a/packages/skott/test/unit/plugins/custom-resolver.spec.ts +++ b/packages/skott/test/unit/plugins/custom-resolver.spec.ts @@ -2,9 +2,9 @@ import { Option } from "effect"; import { describe, expect, test } from "vitest"; import { InMemoryFileReader } from "../../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../../src/filesystem/fake/file-writer.js"; import { FakeLogger } from "../../../src/logger.js"; -import { +import type { DependencyResolver, DependencyResolverOptions } from "../../../src/modules/resolvers/base-resolver.js"; diff --git a/packages/skott/test/unit/runner.spec.ts b/packages/skott/test/unit/runner.spec.ts index e592cdcf5..4c383945c 100644 --- a/packages/skott/test/unit/runner.spec.ts +++ b/packages/skott/test/unit/runner.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { InMemoryFileReader } from "../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../src/filesystem/fake/file-writer.js"; import { FakeLogger } from "../../src/logger.js"; import { ModuleWalkerSelector } from "../../src/modules/walkers/common.js"; import { defaultConfig, Skott } from "../../src/skott.js"; diff --git a/packages/skott/test/unit/shared.ts b/packages/skott/test/unit/shared.ts index 626c44653..93544adb0 100644 --- a/packages/skott/test/unit/shared.ts +++ b/packages/skott/test/unit/shared.ts @@ -1,7 +1,7 @@ import * as memfs from "memfs"; import { InMemoryFileReader } from "../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../src/filesystem/fake/file-writer.js"; import type { SkottNode } from "../../src/graph/node.js"; import { FakeLogger } from "../../src/logger.js"; import { kExpectedModuleExtensions } from "../../src/modules/resolvers/base-resolver.js"; diff --git a/packages/skott/test/unit/traversal.spec.ts b/packages/skott/test/unit/traversal.spec.ts index 18d3cb64b..9d2dc615b 100644 --- a/packages/skott/test/unit/traversal.spec.ts +++ b/packages/skott/test/unit/traversal.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { InMemoryFileReader } from "../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../src/filesystem/fake/file-writer.js"; import { CollectLevel } from "../../src/graph/traversal.js"; import { FakeLogger } from "../../src/logger.js"; import { ModuleWalkerSelector } from "../../src/modules/walkers/common.js"; diff --git a/packages/skott/test/unit/workspace.spec.ts b/packages/skott/test/unit/workspace.spec.ts index ca77ad16f..4c93c1cea 100644 --- a/packages/skott/test/unit/workspace.spec.ts +++ b/packages/skott/test/unit/workspace.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { InMemoryFileReader } from "../../src/filesystem/fake/file-reader.js"; -import { InMemoryFileWriter } from "../../src/filesystem/file-writer.js"; +import { InMemoryFileWriter } from "../../src/filesystem/fake/file-writer.js"; import { FakeLogger } from "../../src/logger.js"; import { ModuleWalkerSelector } from "../../src/modules/walkers/common.js"; import { Skott, defaultConfig } from "../../src/skott.js"; diff --git a/packages/skott/tsconfig.build.json b/packages/skott/tsconfig.build.json index fb166e0c1..a8fad4df5 100644 --- a/packages/skott/tsconfig.build.json +++ b/packages/skott/tsconfig.build.json @@ -1,7 +1,8 @@ { "extends": "@skottorg/config/tsconfig.build.json", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "rootDir": "./" }, "include": ["src/**/*.ts", "bin/**/*.ts", "index.ts"], "exclude": ["src/**/*.spec.ts"] diff --git a/packages/skott/tsconfig.test.json b/packages/skott/tsconfig.test.json index e3b3d4728..f2996bbf4 100644 --- a/packages/skott/tsconfig.test.json +++ b/packages/skott/tsconfig.test.json @@ -1,7 +1,8 @@ { "extends": "@skottorg/config/tsconfig.build.json", "compilerOptions": { - "outDir": "test_dist" + "outDir": "test_dist", + "rootDir": "./" }, "include": ["src/**/*.ts", "bin/**/*.ts", "index.ts"], "exclude": ["src/**/*.spec.ts"]