diff --git a/src/cli-options.ts b/src/cli-options.ts index cd9120ef3..8b48ab81b 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -54,6 +54,7 @@ export interface Options { cache: 'local' | 'github' | 'none'; failureMode: FailureMode; agent: Agent; + graph: boolean; } export const getOptions = (): Result => { @@ -201,7 +202,7 @@ export const getOptions = (): Result => { function getArgvOptions( script: ScriptReference, agent: Agent -): Pick { +): Pick { // The way command-line arguments are handled in npm, yarn, and pnpm are all // different. Our goal here is for ` --watch -- --extra` to behave the // same in all agents. @@ -219,6 +220,7 @@ function getArgvOptions( return { watch: process.env['npm_config_watch'] !== undefined, extraArgs: process.argv.slice(2), + graph: true, }; } case 'yarnClassic': { @@ -355,7 +357,7 @@ function findRemainingArgsFromNpmConfigArgv( */ function parseRemainingArgs( args: string[] -): Pick { +): Pick { let watch = false; let extraArgs: string[] = []; const unrecognized = []; @@ -381,5 +383,6 @@ function parseRemainingArgs( return { watch, extraArgs, + graph: true, }; } diff --git a/src/cli.ts b/src/cli.ts index 6dc025c66..bd6ebb770 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -92,6 +92,13 @@ const run = async (): Promise> => { if (!config.ok) { return config; } + if (options.graph) { + const {Graph} = await import('./graph.js'); + const graph = new Graph(config.value); + await graph.generate(); + return {ok: true, value: undefined}; + } + console.log('\n\n\n\n\nOH NO!\n\n\n\n\n\n'); const executor = new Executor( config.value, logger, diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 000000000..ad2241ec4 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,88 @@ +import {ScriptConfig} from './config.js'; +import {relative} from 'path'; + +interface Generator { + addEdge(from: string, to: string): void; + done(): void; +} + +class GraphViz implements Generator { + constructor() { + console.log('digraph {'); + } + + addEdge(from: string, to: string) { + console.log(` "${from}" -> "${to}";`); + } + + done() { + console.log('}'); + } +} + +class Mermaid implements Generator { + #nextId = 0; + readonly #labelToId = new Map(); + constructor() { + console.log('graph TD'); + } + + addEdge(from: string, to: string) { + console.log(` ${this.#format(from)} --> ${this.#format(to)}`); + } + + #format(label: string) { + let id = this.#labelToId.get(label); + if (id === undefined) { + id = this.#nextId++; + this.#labelToId.set(label, id); + // TODO: properly escape the label + return `${id}[${label}]`; + } + return String(id); + } + + done() {} +} + +export class Graph { + readonly #config: ScriptConfig; + readonly #cwd = process.cwd(); + readonly #generator: Generator; + constructor(config: ScriptConfig, kind: 'mermaid' | 'graphviz' = 'mermaid') { + this.#config = config; + if (kind === 'mermaid') { + this.#generator = new Mermaid(); + } else { + this.#generator = new GraphViz(); + } + } + + generate() { + const seen = new Set(); + const queue = [this.#config]; + let current; + while ((current = queue.pop())) { + if (seen.has(current)) { + continue; + } + seen.add(current); + for (const dependency of current.dependencies) { + this.#generator.addEdge( + this.#formatConfig(current), + this.#formatConfig(dependency.config) + ); + queue.push(dependency.config); + } + } + this.#generator.done(); + } + + #formatConfig(config: ScriptConfig) { + const rel = relative(this.#cwd, config.packageDir); + if (rel === '') { + return config.name; + } + return `${rel}:${config.name}`; + } +}