diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index da94c3f46..9348ffafc 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 dde73cb23..bd7cae63c 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 2eecd914c..1f2711871 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 869207232..79f3377bf 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 f706c0502..a7c711f12 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"; @@ -13,8 +15,14 @@ import { Network } from "vis-network"; export function ActionMenu({ network, initNetwork, + graphMode, + groupedGraphAvailable, + onGraphModeChange, }: { network: Network | undefined; + groupedGraphAvailable: boolean; + graphMode: "full" | "grouped"; + onGraphModeChange: (mode: "full" | "grouped") => void; initNetwork: () => void; }) { const [simulation, setSimulation] = React.useState(false); @@ -65,6 +73,28 @@ export function ActionMenu({ > {simulation ? "Stop" : "Start"} simulation + {groupedGraphAvailable && ( + { + if (graphMode === "full") { + onGraphModeChange("grouped"); + } else { + onGraphModeChange("full"); + } + }} + icon={ + graphMode === "full" ? ( + + ) : ( + + ) + } + > + Use {graphMode === "full" ? "grouped" : "full"} graph + + )} diff --git a/apps/web/src/network/Network.tsx b/apps/web/src/network/Network.tsx index 7f6faf370..546a41e92 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); @@ -236,12 +250,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 } ); @@ -296,6 +315,9 @@ export default function GraphNetwork() { initNetwork(graphConfig)} + groupedGraphAvailable={!!appStore.getState().data.groupedGraph} + graphMode={graphMode} + onGraphModeChange={(graph) => toggleGraph(appStore)({ graph })} />
(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/src/config.ts b/packages/skott/src/config.ts index c0af9ea6d..88f424986 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 302d4239e..f0d024545 100644 --- a/packages/skott/src/skott.ts +++ b/packages/skott/src/skott.ts @@ -64,10 +64,32 @@ export interface SkottConfig { incremental: boolean; manifestPath: string; 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 or null if it does not belong to the group + */ + getGroup: (modulePath: string) => string | null; + }; + }; } export interface SkottStructure { graph: Record>; + groupedGraph?: Record>; files: string[]; } @@ -103,7 +125,8 @@ export const defaultConfig = { includeBaseDir: false, incremental: false, manifestPath: "package.json", - tsConfigPath: "tsconfig.json" + tsConfigPath: "tsconfig.json", + groups: undefined }; export interface WorkspaceConfiguration { @@ -115,6 +138,9 @@ 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 = { @@ -136,6 +162,13 @@ export class Skott { this.config, this.logger ); + + if (config.groups) { + this.#projectCompactGraph = new DiGraph< + SkottNode + >(); + this.#groupResolver = this.getGroupResolver(); + } } public getStructureCache(): SkottCache { @@ -210,17 +243,148 @@ 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 = group.slice(0, -2); + + if (resolvedNodePath.includes(resolvedGroupBasePath)) { + const subPathPos = + resolvedNodePath.indexOf(resolvedGroupBasePath) + + 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); + + if (!subGroupName) { + /** + * If `getGroup` returns null, it means that the node does not belong to the group + */ + return null; + } + + 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) => { + if (!body.contains.includes(node)) { + body.size += size; + body.contains.push(node); + } + }); + } 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: [], + contains: [node] + } + }); + } + } + } } private async linkNodes({ @@ -237,6 +401,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( @@ -493,6 +669,7 @@ export class Skott { return { graph: projectStructure, + groupedGraph: this.#projectCompactGraph?.toDict(), files: Object.keys(projectStructure) }; } diff --git a/packages/skott/test/integration/api.spec.ts b/packages/skott/test/integration/api.spec.ts index e318b9474..b4773aa4b 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", () => { @@ -86,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 () => {