From 2ec0c90e3cbd23a815a77f38bf4918512eaffb12 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 13 Oct 2023 23:04:48 +0700 Subject: [PATCH 01/11] Add groups configuration typings into public API types --- packages/skott/src/skott.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/skott/src/skott.ts b/packages/skott/src/skott.ts index 302d4239..e2e985b9 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -64,6 +64,30 @@ export interface SkottConfig { incremental: boolean; manifestPath: string; tsConfigPath: string; + /** + * Grouping rules, which will be used to create compacted vesrion of the graph + * + * Group can be either a string, string pattern or a configuration object + */ + groups?: { + [key: string]: string | { + /** + * Scope of the group + */ + basePath: string; + /** + * + * @param modulePath path to the module + * @returns group name, to which the module belongs + */ + getGroup: (modulePath: string) => string; + } | { + /** + * If set to true, this group of modules will be hidden in the graph + */ + hide: true; + }; + }; } export interface SkottStructure { From ea46de80e23526afd58b729785b3056613032afb Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 13 Oct 2023 23:07:32 +0700 Subject: [PATCH 02/11] Add groupedGraph types into public API --- packages/skott/src/skott.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/skott/src/skott.ts b/packages/skott/src/skott.ts index e2e985b9..ef37c20e 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -65,7 +65,7 @@ export interface SkottConfig { manifestPath: string; tsConfigPath: string; /** - * Grouping rules, which will be used to create compacted vesrion of the graph + * Grouping rules, which will be used to create compacted "grouped" vesrion of the graph * * Group can be either a string, string pattern or a configuration object */ @@ -92,6 +92,7 @@ export interface SkottConfig { export interface SkottStructure { graph: Record>; + groupedGraph?: Record>; files: string[]; } From b1613f9118c8049a575647d64e738157565d6238 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Fri, 13 Oct 2023 23:17:27 +0700 Subject: [PATCH 03/11] Add dummy graph type switch button --- apps/web/src/network/Action.tsx | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/web/src/network/Action.tsx b/apps/web/src/network/Action.tsx index f706c050..d08744c4 100644 --- a/apps/web/src/network/Action.tsx +++ b/apps/web/src/network/Action.tsx @@ -6,6 +6,8 @@ import { IconPlayerStop, IconRotate2, IconSettingsAutomation, + IconToggleLeft, + IconToggleRight, } from "@tabler/icons-react"; import React from "react"; import { Network } from "vis-network"; @@ -18,6 +20,7 @@ export function ActionMenu({ initNetwork: () => void; }) { const [simulation, setSimulation] = React.useState(false); + const [fullGraph, setGraphType] = React.useState(true); return (
{simulation ? "Stop" : "Start"} simulation + { + if (fullGraph) { + setGraphType(false); + } else { + setGraphType(true); + } + }} + icon={ + fullGraph ? ( + + ) : ( + + ) + } + > + Use {fullGraph ? "grouped" : "full"} graph + From f418a4a02573bdeefdbfd283921fcd05ed7d0e88 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Sat, 14 Oct 2023 01:10:11 +0700 Subject: [PATCH 04/11] Add straightforward validation of groups --- packages/skott/index.ts | 60 +++++++++++++- packages/skott/test/integration/api.spec.ts | 88 +++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/packages/skott/index.ts b/packages/skott/index.ts index a9b08d94..e1be9658 100644 --- a/packages/skott/index.ts +++ b/packages/skott/index.ts @@ -20,7 +20,7 @@ function raiseIllegalConfigException(configuration: string): never { function checkIllegalConfigs(config: O.Option>): void { if (O.isSome(config)) { - const { entrypoint, includeBaseDir, cwd } = config.value; + const { entrypoint, includeBaseDir, cwd, groups } = config.value; if (!entrypoint && includeBaseDir) { raiseIllegalConfigException( @@ -33,7 +33,65 @@ function checkIllegalConfigs(config: O.Option>): void { "`cwd` can't be customized when providing an entrypoint" ); } + + if (groups) { + const list = Object.entries(groups); + + for (const [groupName, group] of list) { + for (const [otherGroupName, otherGroup] of list) { + if (groupName === otherGroupName) { + continue; + } + + const resolvedPath = resolveGroupPath(group); + const otherResolvedPath = resolveGroupPath(otherGroup); + + if (resolvedPath && otherResolvedPath) { + if ( + resolvedPath === otherResolvedPath || + resolvedPath.includes(otherResolvedPath) || + otherResolvedPath.includes(resolvedPath) + ) { + raiseIllegalConfigException( + `Overlapping groups: ${groupName}, ${otherGroupName}` + ); + } + } + } + } + } + } +} + +function resolveGroupPath( + group: Exclude["groups"], undefined>[string] +): string { + let resolvedPath: string = ""; + + if (typeof group === "string") { + resolvedPath = group; + } else if ("basePath" in group) { + resolvedPath = group.basePath; } + + /* trim stuff */ + if (resolvedPath.startsWith(".")) { + resolvedPath = resolvedPath.slice(1); + } + + if (resolvedPath.startsWith("/")) { + resolvedPath = resolvedPath.slice(1); + } + + if (resolvedPath.endsWith("*")) { + resolvedPath = resolvedPath.slice(0, -1); + } + + if (resolvedPath.endsWith("/")) { + resolvedPath = resolvedPath.slice(0, -1); + } + + return resolvedPath; } export default async function skott( diff --git a/packages/skott/test/integration/api.spec.ts b/packages/skott/test/integration/api.spec.ts index e318b947..6713576c 100644 --- a/packages/skott/test/integration/api.spec.ts +++ b/packages/skott/test/integration/api.spec.ts @@ -44,6 +44,94 @@ describe("When running Skott using all real dependencies", () => { "Illegal configuration: `cwd` can't be customized when providing an entrypoint" ); }); + + describe("When providing a groups configuration", () => { + test("Should allow to provide a simple groups", async () => { + expect( + await skott({ + groups: { + groupA: "src/groupA", + groupB: "src/groupB" + } + }) + ).toBeDefined(); + }); + test("Should allow to provide a pattern based groups", async () => { + expect( + await skott({ + groups: { + features: "src/features/*", + widgets: "src/widgets/*" + } + }) + ).toBeDefined(); + }); + test("Should allow to provide a configuration based groups", async () => { + expect( + await skott({ + groups: { + features: { + basePath: "src/features", + getGroup: (filePath) => filePath.split("/")[2] + }, + widgets: "src/widgets/*" + } + }) + ).toBeDefined(); + }); + test("Should allow to hide a specific group", async () => { + expect( + await skott({ + groups: { + features: { + hide: true + }, + widgets: "src/widgets/*" + } + }) + ).toBeDefined(); + }); + + test("Should not allow overlapping group paths", async () => { + await expect( + skott({ + groups: { + widgetsA: "src/team-a/widgets/*", + teamA: "src/team-a/*" + } + }) + ).rejects.toThrow( + "Illegal configuration: Overlapping groups: widgetsA, teamA" + ); + }); + test("Should not allow overlapping group paths with config based groups", async () => { + await expect( + skott({ + groups: { + widgetsA: { + basePath: "src/team-a/widgets", + getGroup: (filePath) => filePath.split("/")[2] + }, + teamA: "src/team-a/*" + } + }) + ).rejects.toThrow( + "Illegal configuration: Overlapping groups: widgetsA, teamA" + ); + }); + test("Should not allow the same path twice", async () => { + await expect( + skott({ + groups: { + widgetsA: "src/team-a/widgets/*", + teamA: "src/team-a/widgets/*" + } + }) + ).rejects.toThrow( + "Illegal configuration: Overlapping groups: widgetsA, teamA" + ); + }); + }); }); describe("When traversing files", () => { From 4044d95501c5d65005cb1fa85ac4aa29c077576b Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Thu, 22 Feb 2024 00:27:16 +0700 Subject: [PATCH 05/11] Implement graph grouping logic --- packages/skott/src/skott.ts | 173 ++++++++++++++++++++++++++++++++---- 1 file changed, 154 insertions(+), 19 deletions(-) diff --git a/packages/skott/src/skott.ts b/packages/skott/src/skott.ts index ef37c20e..ca2598ee 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -66,27 +66,24 @@ export interface SkottConfig { tsConfigPath: string; /** * Grouping rules, which will be used to create compacted "grouped" vesrion of the graph - * + * * Group can be either a string, string pattern or a configuration object */ groups?: { - [key: string]: string | { - /** - * Scope of the group - */ - basePath: string; - /** - * - * @param modulePath path to the module - * @returns group name, to which the module belongs - */ - getGroup: (modulePath: string) => string; - } | { - /** - * If set to true, this group of modules will be hidden in the graph - */ - hide: true; - }; + [key: string]: + | string + | { + /** + * Scope of the group + */ + basePath: string; + /** + * + * @param modulePath path to the module + * @returns group name, to which the module belongs + */ + getGroup: (modulePath: string) => string; + }; }; } @@ -140,6 +137,8 @@ export interface WorkspaceConfiguration { export class Skott { #cacheHandler: SkottCacheHandler; #projectGraph = new DiGraph>(); + #projectCompactGraph: DiGraph> | null = null; + #groupResolver: (node: string) => string | null = () => null; #visitedNodes = new Set(); #baseDir = "."; #workspaceConfiguration: WorkspaceConfiguration = { @@ -161,6 +160,11 @@ export class Skott { this.config, this.logger ); + + if (config.groups) { + this.#projectCompactGraph = new DiGraph>(); + this.#groupResolver = this.getGroupResolver(); + } } public getStructureCache(): SkottCache { @@ -235,17 +239,135 @@ export class Skott { return normalizedNodePath; } + /** + * + * Converts `config.groups` into a single function that will return group name for provided path + * + * @returns (node: string) => string + */ + private getGroupResolver(): (node: string) => string | null { + const groups = this.config.groups; + + if (!groups) { + return (node) => node; + } + + const groupResolvers = Object.entries(groups).map(([groupName, group]) => { + if (typeof group === "string") { + /** + * Group is a string pattern like `src/core/*`, + * where `/*` will tell that all subdirectories of `src/core` are subgroups of `groupName` + */ + if (group.endsWith("/*")) { + return (node: string) => { + const resolvedNodePath = this.resolveNodePath(node); + const resolvedGroupBasePath = this.resolveNodePath( + group.slice(0, -2) + ); + if (resolvedNodePath.startsWith(resolvedGroupBasePath)) { + const subPathPos = resolvedGroupBasePath.length + 1; + const subGroupName = resolvedNodePath.slice( + subPathPos, + resolvedNodePath.indexOf("/", subPathPos) + ); + + return `${groupName}/${subGroupName}`; + } + + return null; + }; + } + + /** + * Group is a simple string pattern like `src/core` + */ + return (node: string) => { + const resolvedNodePath = this.resolveNodePath(node); + const resolvedGroupBasePath = this.resolveNodePath(group); + if (resolvedNodePath.includes(resolvedGroupBasePath)) { + return groupName; + } + + return null; + }; + } + + /** + * Group is a configuration object like `{ basePath: "src/core", getGroup: (node) => "core" }` + */ + return (node: string) => { + const resolvedBasePath = this.resolveNodePath(group.basePath); + const resolvedNodePath = this.resolveNodePath(node); + + if (resolvedNodePath.includes(resolvedBasePath)) { + const subGroupName = group.getGroup(resolvedNodePath); + + return `${groupName}/${subGroupName}`; + } + + return null; + }; + }); + + /** + * Universal group resolver that will try to resolve node using all group resolvers + */ + return (node: string) => { + for (const resolver of groupResolvers) { + const resolvedNode = resolver(node); + + if (resolvedNode) { + return resolvedNode; + } + } + + return null; + }; + } + private async addNode(node: string): Promise { + const size = await this.fileReader.stats(node); this.#projectGraph.addVertex({ id: this.resolveNodePath(node), adjacentTo: [], // @ts-ignore body: { - size: await this.fileReader.stats(node), + size, thirdPartyDependencies: [], builtinDependencies: [] } }); + + if (this.config.groups) { + const groupName = this.#groupResolver(node); + + if (groupName) { + if (this.#projectCompactGraph!.hasVertex(groupName)) { + /** + * Group vertex already exists, we need to add up the size of the new node + */ + this.#projectCompactGraph!.mergeVertexBody(groupName, (body) => { + body.size += size; + }); + } else { + /** + * Group vertex does not exist yet, we need to create it + * + * Initial size is the size of the first node + */ + this.#projectCompactGraph!.addVertex({ + id: groupName, + adjacentTo: [], + // @ts-expect-error + body: { + size, + thirdPartyDependencies: [], + builtinDependencies: [] + } + }); + } + } + } } private async linkNodes({ @@ -262,6 +384,18 @@ export class Skott { from: this.resolveNodePath(from, !useRelativeResolution), to: this.resolveNodePath(to) }); + + if (this.config.groups) { + const fromGroup = this.#groupResolver(from); + const toGroup = this.#groupResolver(to); + + if (fromGroup && toGroup) { + this.#projectCompactGraph!.addEdge({ + from: fromGroup, + to: toGroup + }); + } + } } private async findModuleDeclarations( @@ -518,6 +652,7 @@ export class Skott { return { graph: projectStructure, + groupedGraph: this.#projectCompactGraph?.toDict(), files: Object.keys(projectStructure) }; } From b25df1cc977be531c6ab4f03913e691e06c7a1e4 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Sat, 24 Feb 2024 22:36:35 +0700 Subject: [PATCH 06/11] Add test for groupedGraph object --- packages/skott/test/integration/api.spec.ts | 422 ++++++++++++++++++++ 1 file changed, 422 insertions(+) diff --git a/packages/skott/test/integration/api.spec.ts b/packages/skott/test/integration/api.spec.ts index 6713576c..b4773aa4 100644 --- a/packages/skott/test/integration/api.spec.ts +++ b/packages/skott/test/integration/api.spec.ts @@ -174,6 +174,428 @@ describe("When running Skott using all real dependencies", () => { }); }); + describe("When using groups", () => { + const fsRootDir = `skott-ignore-temp-fs`; + + const runSandbox = createRealFileSystem(fsRootDir, { + "skott-ignore-temp-fs/src/core/index.js": `export const N = 42;`, + "skott-ignore-temp-fs/src/features/feature-a/index.js": `import { N } from "../../core"; export const A = N;`, + "skott-ignore-temp-fs/src/features/feature-b/index.js": `import { N } from "../../core"; import { A } from "../feature-a"; console.log(A); export const B = N;`, + "skott-ignore-temp-fs/src/features/feature-c/a.js": `import { N } from "../../core"; import { A } from "../feature-a"; export { N, A };`, + "skott-ignore-temp-fs/src/features/feature-c/b.js": `import { B } from "./src/features/feature-b"; export { B };`, + "skott-ignore-temp-fs/src/features/feature-c/c.js": `import { N, A } from "./a"; import { B } from "./b"; export { N, A, B };`, + "skott-ignore-temp-fs/src/features/feature-c/index.js": `export { N as CN, A as CA, B as CB } from "./c";`, + "skott-ignore-temp-fs/index.js": `import { N } from "./src/core"; import { A } from "./src/features/feature-a"; import { B } from "./src/features/feature-b"; import * as C from "./src/features/feature-c"; console.log(N, A, B, C);` + }); + + test("Simple string pattern group should work", async () => { + expect.assertions(1); + + const skott = new Skott( + { + ...defaultConfig, + groups: { + core: "src/core", + featureA: "src/features/feature-a", + featureB: "src/features/feature-b", + featureC: "src/features/feature-c" + } + }, + new FileSystemReader({ cwd: fsRootDir, ignorePattern: "" }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + await runSandbox(async () => { + const { groupedGraph } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(groupedGraph).toEqual({ + core: { + id: "core", + adjacentTo: [], + body: { + size: 20, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: ["skott-ignore-temp-fs/src/core/index.js"] + } + }, + featureA: { + id: "featureA", + adjacentTo: ["core"], + body: { + size: 51, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-a/index.js" + ] + } + }, + featureB: { + id: "featureB", + adjacentTo: ["core", "featureA"], + body: { + size: 101, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-b/index.js" + ] + } + }, + featureC: { + id: "featureC", + adjacentTo: ["core", "featureA", "featureB"], + body: { + size: 261, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-c/index.js", + "skott-ignore-temp-fs/src/features/feature-c/c.js", + "skott-ignore-temp-fs/src/features/feature-c/a.js", + "skott-ignore-temp-fs/src/features/feature-c/b.js" + ] + } + } + }); + }); + }); + + test("String pattern with subgroups should work", async () => { + expect.assertions(1); + + const skott = new Skott( + { + ...defaultConfig, + groups: { + core: "src/core", + features: "src/features/*" + } + }, + new FileSystemReader({ cwd: fsRootDir, ignorePattern: "" }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + await runSandbox(async () => { + const { groupedGraph } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(groupedGraph).toEqual({ + core: { + id: "core", + adjacentTo: [], + body: { + size: 20, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: ["skott-ignore-temp-fs/src/core/index.js"] + } + }, + "features/feature-a": { + id: "features/feature-a", + adjacentTo: ["core"], + body: { + size: 51, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-a/index.js" + ] + } + }, + "features/feature-b": { + id: "features/feature-b", + adjacentTo: ["core", "features/feature-a"], + body: { + size: 101, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-b/index.js" + ] + } + }, + "features/feature-c": { + id: "features/feature-c", + adjacentTo: ["core", "features/feature-a", "features/feature-b"], + body: { + size: 261, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-c/index.js", + "skott-ignore-temp-fs/src/features/feature-c/c.js", + "skott-ignore-temp-fs/src/features/feature-c/a.js", + "skott-ignore-temp-fs/src/features/feature-c/b.js" + ] + } + } + }); + }); + }); + + describe("Configuration based group", () => { + test("Configuration based group should work (base case)", async () => { + expect.assertions(1); + + const skott = new Skott( + { + ...defaultConfig, + groups: { + core: "src/core", + features: { + basePath: "src/features", + getGroup: (filePath) => filePath.split("/")[3] + } + } + }, + new FileSystemReader({ cwd: fsRootDir, ignorePattern: "" }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + await runSandbox(async () => { + const { groupedGraph } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(groupedGraph).toEqual({ + core: { + id: "core", + adjacentTo: [], + body: { + size: 20, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: ["skott-ignore-temp-fs/src/core/index.js"] + } + }, + "features/feature-a": { + id: "features/feature-a", + adjacentTo: ["core"], + body: { + size: 51, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-a/index.js" + ] + } + }, + "features/feature-b": { + id: "features/feature-b", + adjacentTo: ["core", "features/feature-a"], + body: { + size: 101, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-b/index.js" + ] + } + }, + "features/feature-c": { + id: "features/feature-c", + adjacentTo: [ + "core", + "features/feature-a", + "features/feature-b" + ], + body: { + size: 261, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-c/index.js", + "skott-ignore-temp-fs/src/features/feature-c/c.js", + "skott-ignore-temp-fs/src/features/feature-c/a.js", + "skott-ignore-temp-fs/src/features/feature-c/b.js" + ] + } + } + }); + }); + }); + + test("If `getGroup` does not return group name, then node should be filtered out", async () => { + expect.assertions(1); + + const skott = new Skott( + { + ...defaultConfig, + groups: { + core: "src/core", + features: { + basePath: "src/features", + getGroup: (filePath) => { + if (filePath.includes("feature-a")) { + return null; + } + + return filePath.split("/")[3]; + } + } + } + }, + new FileSystemReader({ cwd: fsRootDir, ignorePattern: "" }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + await runSandbox(async () => { + const { groupedGraph } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(groupedGraph).toEqual({ + core: { + id: "core", + adjacentTo: [], + body: { + size: 20, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: ["skott-ignore-temp-fs/src/core/index.js"] + } + }, + "features/feature-b": { + id: "features/feature-b", + adjacentTo: ["core"], + body: { + size: 101, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-b/index.js" + ] + } + }, + "features/feature-c": { + id: "features/feature-c", + adjacentTo: ["core", "features/feature-b"], + body: { + size: 261, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-c/index.js", + "skott-ignore-temp-fs/src/features/feature-c/c.js", + "skott-ignore-temp-fs/src/features/feature-c/a.js", + "skott-ignore-temp-fs/src/features/feature-c/b.js" + ] + } + } + }); + }); + }); + }); + + test("Combination of different group configurations should work", async () => { + expect.assertions(1); + + const skott = new Skott( + { + ...defaultConfig, + groups: { + core: "src/core", + featureA: "src/features/feature-a", + features: { + basePath: "src/features", + getGroup: (filePath) => { + if (filePath.includes("feature-b")) { + return null; + } + + return filePath.split("/")[3]; + } + }, + /** + * If node is not matched by previous group, + * it should be tried against next one + */ + featuresBySubGroupPattern: "src/features/*" + } + }, + new FileSystemReader({ cwd: fsRootDir, ignorePattern: "" }), + new InMemoryFileWriter(), + new ModuleWalkerSelector(), + new FakeLogger() + ); + + await runSandbox(async () => { + const { groupedGraph } = await skott + .initialize() + .then(({ getStructure }) => getStructure()); + + expect(groupedGraph).toEqual({ + core: { + id: "core", + adjacentTo: [], + body: { + size: 20, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: ["skott-ignore-temp-fs/src/core/index.js"] + } + }, + featureA: { + id: "featureA", + adjacentTo: ["core"], + body: { + size: 51, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-a/index.js" + ] + } + }, + "featuresBySubGroupPattern/feature-b": { + id: "featuresBySubGroupPattern/feature-b", + adjacentTo: ["core", "featureA"], + body: { + size: 101, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-b/index.js" + ] + } + }, + "features/feature-c": { + id: "features/feature-c", + adjacentTo: [ + "core", + "featureA", + "featuresBySubGroupPattern/feature-b" + ], + body: { + size: 261, + thirdPartyDependencies: [], + builtinDependencies: [], + contains: [ + "skott-ignore-temp-fs/src/features/feature-c/index.js", + "skott-ignore-temp-fs/src/features/feature-c/c.js", + "skott-ignore-temp-fs/src/features/feature-c/a.js", + "skott-ignore-temp-fs/src/features/feature-c/b.js" + ] + } + } + }); + }); + }); + }); + describe("When using ignore pattern", () => { describe("When running bulk analysis", () => { test("Should discard files with pattern relative to an absolute directory path", async () => { From 4bde0aaa3e0271270ccd449d2527fe833da598a6 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Sat, 24 Feb 2024 22:36:56 +0700 Subject: [PATCH 07/11] Improve graph grouping implementation --- packages/skott/src/skott.ts | 38 ++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/skott/src/skott.ts b/packages/skott/src/skott.ts index ca2598ee..fd389963 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -80,9 +80,9 @@ export interface SkottConfig { /** * * @param modulePath path to the module - * @returns group name, to which the module belongs + * @returns group name, to which the module belongs or null if it does not belong to the group */ - getGroup: (modulePath: string) => string; + getGroup: (modulePath: string) => string | null; }; }; } @@ -137,7 +137,8 @@ export interface WorkspaceConfiguration { export class Skott { #cacheHandler: SkottCacheHandler; #projectGraph = new DiGraph>(); - #projectCompactGraph: DiGraph> | null = null; + #projectCompactGraph: DiGraph> | null = + null; #groupResolver: (node: string) => string | null = () => null; #visitedNodes = new Set(); #baseDir = "."; @@ -162,7 +163,9 @@ export class Skott { ); if (config.groups) { - this.#projectCompactGraph = new DiGraph>(); + this.#projectCompactGraph = new DiGraph< + SkottNode + >(); this.#groupResolver = this.getGroupResolver(); } } @@ -261,11 +264,13 @@ export class Skott { if (group.endsWith("/*")) { return (node: string) => { const resolvedNodePath = this.resolveNodePath(node); - const resolvedGroupBasePath = this.resolveNodePath( - group.slice(0, -2) - ); - if (resolvedNodePath.startsWith(resolvedGroupBasePath)) { - const subPathPos = resolvedGroupBasePath.length + 1; + const resolvedGroupBasePath = group.slice(0, -2); + + if (resolvedNodePath.includes(resolvedGroupBasePath)) { + const subPathPos = + resolvedNodePath.indexOf(resolvedGroupBasePath) + + resolvedGroupBasePath.length + + 1; const subGroupName = resolvedNodePath.slice( subPathPos, resolvedNodePath.indexOf("/", subPathPos) @@ -302,6 +307,13 @@ export class Skott { if (resolvedNodePath.includes(resolvedBasePath)) { const subGroupName = group.getGroup(resolvedNodePath); + if (!subGroupName) { + /** + * If `getGroup` returns null, it means that the node does not belong to the group + */ + return null; + } + return `${groupName}/${subGroupName}`; } @@ -347,7 +359,10 @@ export class Skott { * Group vertex already exists, we need to add up the size of the new node */ this.#projectCompactGraph!.mergeVertexBody(groupName, (body) => { - body.size += size; + if (!body.contains.includes(node)) { + body.size += size; + body.contains.push(node); + } }); } else { /** @@ -362,7 +377,8 @@ export class Skott { body: { size, thirdPartyDependencies: [], - builtinDependencies: [] + builtinDependencies: [], + contains: [node] } }); } From 61254d0d8003272cc98560269b118c58f98bbba2 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Sun, 25 Feb 2024 20:00:01 +0700 Subject: [PATCH 08/11] Support grouped graph views --- apps/web/src/App.tsx | 9 +++++++++ apps/web/src/core/network/actions.ts | 3 ++- apps/web/src/core/network/reducers.ts | 13 +++++++++++++ apps/web/src/core/network/update-configuration.ts | 12 ++++++++++++ apps/web/src/network/Action.tsx | 15 +++++++++------ apps/web/src/network/Network.tsx | 13 +++++++++---- apps/web/src/store/state.ts | 2 ++ 7 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index da94c3f4..9348ffaf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -25,6 +25,15 @@ function initializeStore(client: SkottHttpClient, dataStoreRef: AppStore) { data: nextDataValue, }; + if (nextDataValue.groupedGraph) { + /** + * Enforce the grouped graph view if the grouped graph is available + * + * "Full" graph might be way too heavy to render fast, so we default to the grouped graph which should be smaller. + */ + appStateValue.ui.network.graph = "grouped"; + } + dataStoreRef.setInitialState(appStateValue); }) .catch((exception) => { diff --git a/apps/web/src/core/network/actions.ts b/apps/web/src/core/network/actions.ts index dde73cb2..bd7cae63 100644 --- a/apps/web/src/core/network/actions.ts +++ b/apps/web/src/core/network/actions.ts @@ -7,4 +7,5 @@ export type NetworkActions = | { action: "update_configuration"; payload: NetworkLayout; - }; + } + | { action: "toggle_graph"; payload: "full" | "grouped" }; diff --git a/apps/web/src/core/network/reducers.ts b/apps/web/src/core/network/reducers.ts index 2eecd914..1f271187 100644 --- a/apps/web/src/core/network/reducers.ts +++ b/apps/web/src/core/network/reducers.ts @@ -27,6 +27,19 @@ function toggleDependencies(): AppReducer { }); } + if (event.action === "toggle_graph") { + return Option.some({ + data: state.data, + ui: { + ...state.ui, + network: { + ...state.ui.network, + graph: event.payload, + }, + }, + }); + } + if (event.action === "update_configuration") { return Option.some({ data: state.data, diff --git a/apps/web/src/core/network/update-configuration.ts b/apps/web/src/core/network/update-configuration.ts index 86920723..79f3377b 100644 --- a/apps/web/src/core/network/update-configuration.ts +++ b/apps/web/src/core/network/update-configuration.ts @@ -9,3 +9,15 @@ export function updateConfiguration(appStore: AppStore) { }); }; } + +export function toggleGraph(appStore: AppStore) { + return function (params: { graph: "full" | "grouped" }) { + appStore.dispatch( + { + action: "toggle_graph", + payload: params.graph, + }, + { notify: true } + ); + }; +} diff --git a/apps/web/src/network/Action.tsx b/apps/web/src/network/Action.tsx index d08744c4..45012f15 100644 --- a/apps/web/src/network/Action.tsx +++ b/apps/web/src/network/Action.tsx @@ -15,12 +15,15 @@ import { Network } from "vis-network"; export function ActionMenu({ network, initNetwork, + graphMode, + onGraphModeChange, }: { network: Network | undefined; + graphMode: "full" | "grouped"; + onGraphModeChange: (mode: "full" | "grouped") => void; initNetwork: () => void; }) { const [simulation, setSimulation] = React.useState(false); - const [fullGraph, setGraphType] = React.useState(true); return (
{ - if (fullGraph) { - setGraphType(false); + if (graphMode === "full") { + onGraphModeChange("grouped"); } else { - setGraphType(true); + onGraphModeChange("full"); } }} icon={ - fullGraph ? ( + graphMode === "full" ? ( ) : ( ) } > - Use {fullGraph ? "grouped" : "full"} graph + Use {graphMode === "full" ? "grouped" : "full"} graph diff --git a/apps/web/src/network/Network.tsx b/apps/web/src/network/Network.tsx index 7f6faf37..bd86da23 100644 --- a/apps/web/src/network/Network.tsx +++ b/apps/web/src/network/Network.tsx @@ -236,12 +236,17 @@ export default function GraphNetwork() { if (networkContainerRef.current) { subscription = appStore.store$ .pipe( - map(({ data }) => data), - distinctUntilChanged(isEqual) + distinctUntilChanged(({ data: prevData }, { data: currentData }) => + isEqual(prevData, currentData) + ) ) - .subscribe((data) => { + .subscribe(({ data, ui }) => { const { graphNodes, graphEdges } = makeNodesAndEdges( - Object.values(data.graph), + Object.values( + ui.network.graph === "grouped" && data.groupedGraph + ? data.groupedGraph + : data.graph + ), { entrypoint: data.entrypoint } ); diff --git a/apps/web/src/store/state.ts b/apps/web/src/store/state.ts index fa3053a2..d4bf1553 100644 --- a/apps/web/src/store/state.ts +++ b/apps/web/src/store/state.ts @@ -17,6 +17,7 @@ export interface UiState { glob: string; }; network: { + graph: "full" | "grouped"; dependencies: { circular: { active: boolean; @@ -51,6 +52,7 @@ export const storeDefaultValue = { glob: "", }, network: { + graph: "full", dependencies: { circular: { active: false, From d785b5e3c6b5486ef367c822b7a0409abd2215e4 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Sun, 25 Feb 2024 20:24:39 +0700 Subject: [PATCH 09/11] Connect Network graph mode to store --- apps/web/src/network/Network.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/web/src/network/Network.tsx b/apps/web/src/network/Network.tsx index bd86da23..a6635b02 100644 --- a/apps/web/src/network/Network.tsx +++ b/apps/web/src/network/Network.tsx @@ -30,7 +30,10 @@ import { } from "./dependencies"; import { ProgressLoader } from "@/network/ProgressLoader"; import { AppEffects, callUseCase, notify } from "@/store/store"; -import { updateConfiguration } from "@/core/network/update-configuration"; +import { + updateConfiguration, + toggleGraph, +} from "@/core/network/update-configuration"; import { storeDefaultValue } from "@/store/state"; export default function GraphNetwork() { @@ -46,6 +49,17 @@ export default function GraphNetwork() { const [graphConfig, setGraphConfig] = React.useState( appStore.getState().ui.network.layout ); + const [graphMode, setGraphMode] = React.useState<"full" | "grouped">( + appStore.getState().ui.network.graph + ); + + React.useEffect(() => { + const subscription = appStore.store$ + .pipe(map(({ ui }) => ui.network.graph)) + .subscribe(setGraphMode); + + return subscription.unsubscribe; + }); function focusOnNetworkNode(nodeId: string) { network?.selectNodes([nodeId], true); @@ -301,6 +315,8 @@ export default function GraphNetwork() { initNetwork(graphConfig)} + graphMode={graphMode} + onGraphModeChange={(graph) => toggleGraph(appStore)({ graph })} />
Date: Sun, 25 Feb 2024 20:26:02 +0700 Subject: [PATCH 10/11] Show option to change graph mode only if available --- apps/web/src/network/Action.tsx | 40 ++++++++++++++++++-------------- apps/web/src/network/Network.tsx | 1 + 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/web/src/network/Action.tsx b/apps/web/src/network/Action.tsx index 45012f15..a7c711f1 100644 --- a/apps/web/src/network/Action.tsx +++ b/apps/web/src/network/Action.tsx @@ -16,9 +16,11 @@ export function ActionMenu({ network, initNetwork, graphMode, + groupedGraphAvailable, onGraphModeChange, }: { network: Network | undefined; + groupedGraphAvailable: boolean; graphMode: "full" | "grouped"; onGraphModeChange: (mode: "full" | "grouped") => void; initNetwork: () => void; @@ -71,24 +73,28 @@ export function ActionMenu({ > {simulation ? "Stop" : "Start"} simulation - { - if (graphMode === "full") { - onGraphModeChange("grouped"); - } else { - onGraphModeChange("full"); + {groupedGraphAvailable && ( + { + if (graphMode === "full") { + onGraphModeChange("grouped"); + } else { + onGraphModeChange("full"); + } + }} + icon={ + graphMode === "full" ? ( + + ) : ( + + ) } - }} - icon={ - graphMode === "full" ? ( - - ) : ( - - ) - } - > - Use {graphMode === "full" ? "grouped" : "full"} graph - + > + Use {graphMode === "full" ? "grouped" : "full"} graph + + )} diff --git a/apps/web/src/network/Network.tsx b/apps/web/src/network/Network.tsx index a6635b02..546a41e9 100644 --- a/apps/web/src/network/Network.tsx +++ b/apps/web/src/network/Network.tsx @@ -315,6 +315,7 @@ export default function GraphNetwork() { initNetwork(graphConfig)} + groupedGraphAvailable={!!appStore.getState().data.groupedGraph} graphMode={graphMode} onGraphModeChange={(graph) => toggleGraph(appStore)({ graph })} /> From 5e9140eb9ac17731716736fc67b1632fe9f6bb33 Mon Sep 17 00:00:00 2001 From: AlexandrHoroshih Date: Mon, 26 Feb 2024 12:19:17 +0700 Subject: [PATCH 11/11] Make config.groups decodable --- packages/skott/src/config.ts | 6 ++++++ packages/skott/src/skott.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/skott/src/config.ts b/packages/skott/src/config.ts index c0af9ea6..88f42498 100644 --- a/packages/skott/src/config.ts +++ b/packages/skott/src/config.ts @@ -27,6 +27,12 @@ const config = D.struct({ typeOnly: D.boolean }) ), + groups: withDefaultValue(defaultConfig.groups)({ + /** + * Temporary placeholder, will be replaced by a proper decoder + */ + decode: (v) => v as any + }), fileExtensions: withDefaultValue(defaultConfig.fileExtensions)( D.array(D.literal(".js", ".ts", ".jsx", ".tsx", ".mjs", ".cjs")) ), diff --git a/packages/skott/src/skott.ts b/packages/skott/src/skott.ts index fd389963..f0d02454 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -125,7 +125,8 @@ export const defaultConfig = { includeBaseDir: false, incremental: false, manifestPath: "package.json", - tsConfigPath: "tsconfig.json" + tsConfigPath: "tsconfig.json", + groups: undefined }; export interface WorkspaceConfiguration {