diff --git a/.changeset/nine-peaches-fry.md b/.changeset/nine-peaches-fry.md new file mode 100644 index 000000000..661b9af91 --- /dev/null +++ b/.changeset/nine-peaches-fry.md @@ -0,0 +1,5 @@ +--- +"skott": minor +--- + +Breaking Changes: move `findCircularDependencies`, `hasCircularDependencies`, `findLeaves` inside `useGraph` api encapsulation. diff --git a/packages/skott/README.md b/packages/skott/README.md index 160da1ff7..65aa4e472 100644 --- a/packages/skott/README.md +++ b/packages/skott/README.md @@ -4,7 +4,7 @@ ## How to use skott -### Install +## Install You can install skott either locally or globally ```bash @@ -13,7 +13,7 @@ npm install skott npm install skott -g ``` -### **Embedded Web Application** +## **Embedded Web Application** skott now embeds a new _display mode_ **"skott --displayMode=webapp"** allowing you to visualize more precisely dependencies and the links between them. Here is an overview of a subset from the graph generated for `fastify`: @@ -26,12 +26,12 @@ When `Circular dependencies` are found in the graph, they can also be toggled vi skott-webapp-with-cycles -### **JavaScript API** +## **JavaScript API** ```javascript import skott from "skott"; -const { getStructure, getWorkspace, findCircularDependencies, findParentsOf, findLeaves } = await skott({ +const { getStructure, getWorkspace, useGraph, findUnusedDependencies } = await skott({ /** * (Optional) Entrypoint of the project. If not provided, `skott` will search for all * supported files starting from the current working directory. @@ -44,7 +44,7 @@ const { getStructure, getWorkspace, findCircularDependencies, findParentsOf, fin * graph. * Defaults to `none`; */ - ignorePattern: "src/examples/**/*" + ignorePattern: "src/examples/**/*", /** * (Optional) Whether to run Skott using the incremental pattern. By setting "true", * Skott will create a `.skott/cache.json` file to only detect and re-process what @@ -89,7 +89,7 @@ const { getStructure, getWorkspace, findCircularDependencies, findParentsOf, fin thirdParty: true, builtin: true, typeOnly: true - }; + }, /** * (Optional) Provide a custom tsconfig file to help skott resolve path aliases. * When extending some other tsconfig files, skott will be able to parse @@ -118,7 +118,7 @@ const { getStructure, getWorkspace, findCircularDependencies, findParentsOf, fin }); ``` -### **Command line interface** +## **Command line interface** skott exposes a CLI directly using features from the core library. @@ -187,7 +187,7 @@ See all the options of the CLI running: $ skott --help ``` -## Examples +## API Documentation To initialize the dependency graph, the default exported function must be used first. Once executed, the default function returns a set of functions to retrieve some @@ -206,34 +206,68 @@ console.log(graph); // logs { "index.js": { id: "index.js", adjacentTo: [], bod console.log(files); // logs [ "index.js" ] ``` -### Search for circular dependencies +### Graph API + +To easily consume the graph that was emitted while exploring the project, skott exposes a graph API including various methods to traverse all the nodes, collect parent and children dependencies, find circular dependencies, and more. ```javascript import skott from "skott"; -const { findCircularDependencies, hasCircularDependencies } = await skott({ - entrypoint: "index.js", - // ...rest of the config -}); +const { useGraph } = await skott(); + +const { + getFileNode, + traverseFiles, + collectFilesDependencies, + collectFilesDependingOn, + findLeaves, + findCircularDependencies, + hasCircularDependencies  +} = useGraph(); +``` -// Imagine that starting from "index.js" skott detects a circular dependency -// between "core.js" and "utils.js" files +### Graph walking -console.log(findCircularDependencies()); // logs [ [ "core.js", "utils.js" ] ] -console.log(hasCircularDependencies()); // logs "true" +```javascript +const { useGraph } = await skott(); +const { traverseFiles } = useGraph(); + +// Starting from any node, walking the whole graph +for(const file of traverseFiles()) { + // SkottNode { } +} + +// Starting from a specifc node, walking the graph from it +for(const file of traverseFiles({ rootFile: "index.js" })) { + // SkottNode { } +} + +// By default, skott will collect "shallow first" files in a Breadth-First fashion +// meaning the iterator will first emit direct module imports for each visited node. +// If the traversal needs to be "deep first" instead i.e. you first want to go deep +// down through the graph until meeting a leaf you might want to use "deepFirst" option +// to turn the traversal into Depth-First search. + +for(const file of traverseFiles({ rootFile: "index.js", traversal: "deepFirst" })) { + // SkottNode { } +} ``` -### Search for unused dependencies using the graph generated +### Search for circular dependencies + ```javascript import skott from "skott"; -const { findUnusedDependencies } = await skott({ - entrypoint: "index.tsx", - // ...rest of the config +const { useGraph } = await skott({ + entrypoint: "index.js", }); +const { findCircularDependencies, hasCircularDependencies} = useGraph(); + +// Imagine that starting from "index.js" skott detects a circular dependency +// between "core.js" and "utils.js" files -const { thirdParty } = await findUnusedDependencies(); -console.log(thirdParty); // logs [ "rxjs", "lodash.difference" ] +console.log(findCircularDependencies()); // logs [ [ "core.js", "utils.js" ] ] +console.log(hasCircularDependencies()); // logs "true" ``` ### Search for leaves (nodes with no children) @@ -249,15 +283,15 @@ index.js ```javascript import skott from "skott"; -const { findLeaves } = await skott({ +const { useGraph } = await skott({ entrypoint: "leaf.js", - // ...rest of the config }); +const { findLeaves } = useGraph(); console.log(findLeaves()); // logs [ "leaf.js" ] ``` -### Deeply search for parent dependencies of a given node +### Deeply or Shallowly search for parent or children dependencies of a given node children.js @@ -277,13 +311,42 @@ index.js ```javascript import skott from "skott"; +import { CollectLevel } from "skott/graph/traversal"; -const { findParentsOf } = await skott({ +const { useGraph } = await skott({ entrypoint: "parent.js", - // ...rest of the config }); +const { collectFilesDependingOn, collectFilesDependencies } = useGraph(); + +// CollectLevel.Deep or CollectLevel.Shallow. In that case just one level so we can use Shallow + +console.log(collectFilesDependingOn("children.js", CollectLevel.Shallow)); +// logs [ SkottNode { id: "parent.js" } ] + +console.log(collectFilesDependencies("parent.js", CollectLevel.Shallow)); +// logs [ SkottNode { id: "children.js" } ] +``` + +### Find unused dependencies + +skott provides a way to walk through dependencies listed in the current working directory manifest (package.json) and compare them to what it founds and marked as "used" during the analysis. The "use" marking will be done when a third-party module appears to be imported in the source code that was walked. All the third-party dependencies that are not used in the traversed files will be returned as "unused". + +Additionnally to the source code analysis, skott integrates with [depcheck](https://github.com/depcheck/depcheck) allowing it to take a peak at "implicit" dependencies and emit hypothesis about whether some `devDependencies` are unused or not, by walking through most common config files. + +Note: finding precisely implicit dependencies is hard so please double check dependencies part of the `devDependencies` that are marked as "unused" by the analysis. If some `dependencies` (production deps) appear to be unused but are indeed used somewhere in the codebase, it could mean two things: + +- the input files pattern you provided to skott don't cover the parts of the graph where the dependency is used +- the dependency is used nowhere through the source code files walked, meaning that it should probably be moved to `devDependencies` or just get removed. + +In any case, `unused dependencies` just raise an alert so I would advise to double check before getting rid of a dependency. + +```javascript +import skott from "skott"; + +const { findUnusedDependencies } = await skott(); -console.log(findParentsOf("children.js")); // logs [ "parent.js" ] +const { thirdParty } = await findUnusedDependencies(); +// [ lodash, rxjs, typescript ] ``` ### Explore file node metadata diff --git a/packages/skott/bin/main.ts b/packages/skott/bin/main.ts index b8909cfba..57beb9d2e 100644 --- a/packages/skott/bin/main.ts +++ b/packages/skott/bin/main.ts @@ -69,7 +69,8 @@ function makeCircularDependenciesUI( options: CliOptions ): string[][] { const circularDependencies: string[][] = []; - const { findCircularDependencies, hasCircularDependencies } = skottInstance; + const { findCircularDependencies, hasCircularDependencies } = + skottInstance.useGraph(); // only find circular dependencies on-demand as it can be expensive if (options.showCircularDependencies) { diff --git a/packages/skott/bin/ui/webapp.ts b/packages/skott/bin/ui/webapp.ts index 30b537619..a01bd704f 100644 --- a/packages/skott/bin/ui/webapp.ts +++ b/packages/skott/bin/ui/webapp.ts @@ -75,7 +75,7 @@ export function openWebApplication( console.log(`\n ${kleur.italic("Prefetching data...")} `); srv.get("/api/cycles", (_, response: ServerResponse) => { - const cycles = skottInstance.findCircularDependencies(); + const cycles = skottInstance.useGraph().findCircularDependencies(); response.setHeader("Content-Type", "application/json"); response.end(JSON.stringify(cycles)); diff --git a/packages/skott/src/graph/traversal.ts b/packages/skott/src/graph/traversal.ts index bc7300c3f..7c021895f 100644 --- a/packages/skott/src/graph/traversal.ts +++ b/packages/skott/src/graph/traversal.ts @@ -1,5 +1,6 @@ import type { DiGraph } from "digraph-js"; import type { SkottNode } from "./node.js"; +import { SkottConfig } from "../skott.js"; export const CollectLevel = { Deep: "deep", @@ -10,6 +11,7 @@ export type CollectLevelValues = (typeof CollectLevel)[keyof typeof CollectLevel]; export interface TraversalApi { + getFileNode(id: string): SkottNode; traverseFiles: (options?: { rootFile?: string; moduleImportsCollection?: "deepFirst" | "shallowFirst"; @@ -22,6 +24,9 @@ export interface TraversalApi { rootFile: string, level: CollectLevelValues ) => SkottNode[]; + findLeaves: () => string[]; + findCircularDependencies: () => string[][]; + hasCircularDependencies: () => boolean; } const skottToDiGraphTraversal = { @@ -30,11 +35,14 @@ const skottToDiGraphTraversal = { } as const; export function makeTraversalApi( - graph: DiGraph> + graph: DiGraph>, + config: SkottConfig ): TraversalApi { const nodes = graph.toDict(); return { + getFileNode: (id) => nodes[id], + *traverseFiles(options) { const rootNode = options?.rootFile; const moduleImportsCollection = @@ -68,6 +76,24 @@ export function makeTraversalApi( } return Array.from(graph.getDeepParents(rootFile)).map((id) => nodes[id]); + }, + + hasCircularDependencies(): boolean { + return graph.hasCycles({ + maxDepth: config.circularMaxDepth ?? Number.POSITIVE_INFINITY + }); + }, + + findCircularDependencies(): SkottNode["id"][][] { + return graph.findCycles({ + maxDepth: config.circularMaxDepth ?? Number.POSITIVE_INFINITY + }); + }, + + findLeaves(): SkottNode["id"][] { + return Object.entries(nodes) + .filter(([_, node]) => node.adjacentTo.length === 0) + .map(([leafId]) => leafId); } }; } diff --git a/packages/skott/src/skott.ts b/packages/skott/src/skott.ts index 584e5cb2c..46cb7078d 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -84,13 +84,9 @@ export interface SkottInstance { useGraph: () => TraversalApi; getStructure: () => SkottStructure; getWorkspace: () => ManifestDependenciesByName; - findLeaves: () => string[]; - findCircularDependencies: () => string[][]; findUnusedDependencies: ( options?: ImplicitUnusedDependenciesOptions ) => Promise; - hasCircularDependencies: () => boolean; - findParentsOf: (node: string) => string[]; } export const defaultConfig = { @@ -405,32 +401,6 @@ export class Skott { } } - private hasCircularDependencies(): boolean { - return this.#projectGraph.hasCycles({ - maxDepth: this.config.circularMaxDepth ?? Number.POSITIVE_INFINITY - }); - } - - private circularDependencies(): string[][] { - return this.#projectGraph.findCycles({ - maxDepth: this.config.circularMaxDepth ?? Number.POSITIVE_INFINITY - }); - } - - private findLeaves(): string[] { - return Object.entries(this.#projectGraph.toDict()) - .filter(([_, node]) => node.adjacentTo.length === 0) - .map(([leafId]) => leafId); - } - - private findParentsOf(node: string): string[] { - const uniqueSetOfParents = new Set([ - ...this.#projectGraph.getDeepParents(node) - ]); - - return [...uniqueSetOfParents]; - } - private findThirdPartyDependenciesFromGraph(): string[] { const graphDependencies = new Set(); @@ -599,13 +569,9 @@ export class Skott { } return { - useGraph: () => makeTraversalApi(this.#projectGraph), + useGraph: () => makeTraversalApi(this.#projectGraph, this.config), getStructure: this.makeProjectStructure.bind(this), getWorkspace: () => this.#workspaceConfiguration.manifests, - findCircularDependencies: this.circularDependencies.bind(this), - hasCircularDependencies: this.hasCircularDependencies.bind(this), - findLeaves: this.findLeaves.bind(this), - findParentsOf: this.findParentsOf.bind(this), findUnusedDependencies: this.findUnusedDependencies.bind(this) }; } diff --git a/packages/skott/test/unit/ecmascript/graph.spec.ts b/packages/skott/test/unit/ecmascript/graph.spec.ts index 5fac72dc5..540e0c300 100644 --- a/packages/skott/test/unit/ecmascript/graph.spec.ts +++ b/packages/skott/test/unit/ecmascript/graph.spec.ts @@ -11,6 +11,8 @@ import { buildSkottProjectUsingInMemoryFileExplorer, mountFakeFileSystem } from "../shared"; +import { CollectLevel } from "../../../src/graph/traversal.js"; +import { SkottNode } from "../../../src/graph/node.js"; class InMemoryFileReaderWithFakeStats implements FileReader { read(filename: string): Promise { @@ -141,17 +143,23 @@ describe("When building the project structure independently of JavaScript or Typ new FakeLogger() ); - const skottInstance = await skott.initialize(); - expect(skottInstance.findParentsOf("d.js")).to.deep.equal([ + const { collectFilesDependingOn } = await skott + .initialize() + .then(({ useGraph }) => useGraph()); + + function extractIdsFromGraph(node: SkottNode["id"]) { + return collectFilesDependingOn(node, CollectLevel.Deep).map( + ({ id }) => id + ); + } + + expect(extractIdsFromGraph("d.js")).to.deep.equal([ "c.js", "b.js", "a.js" ]); - expect(skottInstance.findParentsOf("c.js")).to.deep.equal([ - "b.js", - "a.js" - ]); - expect(skottInstance.findParentsOf("a.js")).to.deep.equal([]); + expect(extractIdsFromGraph("c.js")).to.deep.equal(["b.js", "a.js"]); + expect(extractIdsFromGraph("a.js")).to.deep.equal([]); }); }); diff --git a/packages/skott/test/unit/shared.ts b/packages/skott/test/unit/shared.ts index feea27b28..8e16a847e 100644 --- a/packages/skott/test/unit/shared.ts +++ b/packages/skott/test/unit/shared.ts @@ -6,7 +6,8 @@ import { FakeLogger } from "../../src/logger.js"; import { kExpectedModuleExtensions } from "../../src/modules/resolvers/base-resolver.js"; import { EcmaScriptDependencyResolver } from "../../src/modules/resolvers/ecmascript/resolver.js"; import { ModuleWalkerSelector } from "../../src/modules/walkers/common.js"; -import { Skott, SkottNode } from "../../src/skott"; +import { Skott } from "../../src/skott.js"; +import type { SkottNode } from "../../src/graph/node.js"; interface UnwrappedSkottStructure { graph: Record; @@ -55,14 +56,15 @@ export async function buildSkottProjectUsingInMemoryFileExplorer({ new FakeLogger() ); const skottInstance = await skott.initialize(); + const graph = skottInstance.useGraph(); const structure = skottInstance.getStructure(); return { graph: structure.graph, files: structure.files, - circularDependencies: skottInstance.findCircularDependencies(), - hasCircularDependencies: skottInstance.hasCircularDependencies(), - leaves: skottInstance.findLeaves() + circularDependencies: graph.findCircularDependencies(), + hasCircularDependencies: graph.hasCircularDependencies(), + leaves: graph.findLeaves() }; } diff --git a/packages/skott/test/unit/api.spec.ts b/packages/skott/test/unit/traversal.spec.ts similarity index 100% rename from packages/skott/test/unit/api.spec.ts rename to packages/skott/test/unit/traversal.spec.ts