diff --git a/docs/api/EntryFilesAnalyser.md b/docs/api/EntryFilesAnalyser.md index dcff432..f86db79 100644 --- a/docs/api/EntryFilesAnalyser.md +++ b/docs/api/EntryFilesAnalyser.md @@ -21,6 +21,7 @@ The constructor options is described by the following TS interface interface EntryFilesAnalyserOptions { astAnalyzer?: AstAnalyser; loadExtensions?: (defaults: string[]) => string[]; + rootPath?: string | URL; } ``` @@ -30,8 +31,19 @@ Default files extensions are `.js`, `.cjs`, `.mjs` and `.node` ```ts declare class EntryFilesAnalyser { + public astAnalyzer: AstAnalyser; + public allowedExtensions: Set; + public dependencies: DiGraph>; + constructor(options?: EntryFilesAnalyserOptions); - analyse(entryFiles: (string | URL)[]): AsyncGenerator; + + /** + * Asynchronously analyze a set of entry files yielding analysis reports. + */ + analyse( + entryFiles: Iterable, + options?: RuntimeFileOptions + ): AsyncGenerator; } ``` diff --git a/index.js b/index.js index 765e892..e636dbb 100644 --- a/index.js +++ b/index.js @@ -12,10 +12,10 @@ function runASTAnalysis( options = Object.create(null) ) { process.emitWarning( - 'The runASTAnalysis API is deprecated and will be removed in v8. Please use the AstAnalyser class instead.', + "The runASTAnalysis API is deprecated and will be removed in v8. Please use the AstAnalyser class instead.", { - code: 'DeprecationWarning', - detail: 'The runASTAnalysis API is deprecated and will be removed in v8. Please use the AstAnalyser class instead.' + code: "DeprecationWarning", + detail: "The runASTAnalysis API is deprecated and will be removed in v8. Please use the AstAnalyser class instead." } ); @@ -43,10 +43,10 @@ async function runASTAnalysisOnFile( options = {} ) { process.emitWarning( - 'The runASTAnalysisOnFile API is deprecated and will be removed in v8. Please use the AstAnalyser class instead.', + "The runASTAnalysisOnFile API is deprecated and will be removed in v8. Please use the AstAnalyser class instead.", { - code: 'DeprecationWarning', - detail: 'The runASTAnalysisOnFile API is deprecated and will be removed in v8. Please use the AstAnalyser class instead.' + code: "DeprecationWarning", + detail: "The runASTAnalysisOnFile API is deprecated and will be removed in v8. Please use the AstAnalyser class instead." } ); diff --git a/package.json b/package.json index 56ef214..74777e6 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "dependencies": { "@nodesecure/estree-ast-utils": "^1.3.1", "@nodesecure/sec-literal": "^1.2.0", + "digraph-js": "^2.2.3", "estree-walker": "^3.0.1", "frequency-set": "^1.0.2", "is-minified-code": "^2.0.0", diff --git a/src/EntryFilesAnalyser.js b/src/EntryFilesAnalyser.js index ff7aa5c..1219afa 100644 --- a/src/EntryFilesAnalyser.js +++ b/src/EntryFilesAnalyser.js @@ -3,6 +3,9 @@ import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +// Import Third-party Dependencies +import { DiGraph } from "digraph-js"; + // Import Internal Dependencies import { AstAnalyser } from "./AstAnalyser.js"; @@ -10,60 +13,106 @@ import { AstAnalyser } from "./AstAnalyser.js"; const kDefaultExtensions = ["js", "cjs", "mjs", "node"]; export class EntryFilesAnalyser { + #rootPath = null; + constructor(options = {}) { - this.astAnalyzer = options.astAnalyzer ?? new AstAnalyser(); - const rawAllowedExtensions = options.loadExtensions - ? options.loadExtensions(kDefaultExtensions) + const { + astAnalyzer = new AstAnalyser(), + loadExtensions, + rootPath = null + } = options; + + this.astAnalyzer = astAnalyzer; + const rawAllowedExtensions = loadExtensions + ? loadExtensions(kDefaultExtensions) : kDefaultExtensions; this.allowedExtensions = new Set(rawAllowedExtensions); + this.#rootPath = options.rootPath === null ? + null : fileURLToPathExtended(rootPath); } async* analyse( entryFiles, - analyseFileOptions + options = {} ) { - this.analyzedDeps = new Set(); + this.dependencies = new DiGraph(); + + for (const entryFile of new Set(entryFiles)) { + const normalizedEntryFile = path.normalize( + fileURLToPathExtended(entryFile) + ); - for (const file of entryFiles) { - yield* this.#analyseFile(file, analyseFileOptions); + yield* this.#analyseFile( + normalizedEntryFile, + this.#getRelativeFilePath(normalizedEntryFile), + options + ); } } + #getRelativeFilePath(file) { + return this.#rootPath ? path.relative(this.#rootPath, file) : file; + } + async* #analyseFile( file, + relativeFile, options ) { - const filePath = file instanceof URL ? fileURLToPath(file) : file; - const report = await this.astAnalyzer.analyseFile(file, options); - - yield { url: filePath, ...report }; + this.dependencies.addVertex({ + id: relativeFile, + adjacentTo: [], + body: {} + }); + + const report = await this.astAnalyzer.analyseFile( + file, + options + ); + yield { file: relativeFile, ...report }; if (!report.ok) { return; } for (const [name] of report.dependencies) { - const depPath = await this.#getInternalDepPath( - name, - path.dirname(filePath) + const depFile = await this.#getInternalDepPath( + path.join(path.dirname(file), name) ); + if (depFile === null) { + continue; + } - if (depPath && !this.analyzedDeps.has(depPath)) { - this.analyzedDeps.add(depPath); - - yield* this.#analyseFile(depPath, options); + const depRelativeFile = this.#getRelativeFilePath(depFile); + if (!this.dependencies.hasVertex(depRelativeFile)) { + this.dependencies.addVertex({ + id: depRelativeFile, + adjacentTo: [], + body: {} + }); + + yield* this.#analyseFile( + depFile, + depRelativeFile, + options + ); } + + this.dependencies.addEdge({ + from: relativeFile, to: depRelativeFile + }); } } - async #getInternalDepPath(name, basePath) { - const depPath = path.join(basePath, name); - const existingExt = path.extname(name); + async #getInternalDepPath( + filePath + ) { + const fileExtension = path.extname(filePath); - if (existingExt === "") { + if (fileExtension === "") { for (const ext of this.allowedExtensions) { - const depPathWithExt = `${depPath}.${ext}`; + const depPathWithExt = `${filePath}.${ext}`; const fileExist = await this.#fileExists(depPathWithExt); if (fileExist) { @@ -72,22 +121,24 @@ export class EntryFilesAnalyser { } } else { - if (!this.allowedExtensions.has(existingExt.slice(1))) { + if (!this.allowedExtensions.has(fileExtension.slice(1))) { return null; } - const fileExist = await this.#fileExists(depPath); + const fileExist = await this.#fileExists(filePath); if (fileExist) { - return depPath; + return filePath; } } return null; } - async #fileExists(path) { + async #fileExists( + filePath + ) { try { - await fs.access(path, fs.constants.R_OK); + await fs.access(filePath, fs.constants.R_OK); return true; } @@ -100,3 +151,11 @@ export class EntryFilesAnalyser { } } } + +function fileURLToPathExtended( + file +) { + return file instanceof URL ? + fileURLToPath(file) : + file; +} diff --git a/test/EntryFilesAnalyser.spec.js b/test/EntryFilesAnalyser.spec.js index d3b02fc..3770229 100644 --- a/test/EntryFilesAnalyser.spec.js +++ b/test/EntryFilesAnalyser.spec.js @@ -1,12 +1,14 @@ // Import Node.js Dependencies import { describe, it } from "node:test"; import assert from "node:assert"; +import path from "node:path"; import { fileURLToPath } from "node:url"; // Import Internal Dependencies import { EntryFilesAnalyser, AstAnalyser } from "../index.js"; const FIXTURE_URL = new URL("fixtures/entryFiles/", import.meta.url); +const FIXTURE_URL_PATH = fileURLToPath(FIXTURE_URL); describe("EntryFilesAnalyser", () => { it("should analyze internal dependencies recursively", async(t) => { @@ -23,7 +25,7 @@ describe("EntryFilesAnalyser", () => { const reports = await fromAsync(generator); assert.deepEqual( - reports.map((report) => report.url), + reports.map((report) => report.file), [ entryUrl, new URL("deps/dep1.js", FIXTURE_URL), @@ -47,7 +49,7 @@ describe("EntryFilesAnalyser", () => { const reports = await fromAsync(generator); assert.deepEqual( - reports.map((report) => report.url), + reports.map((report) => report.file), [ entryUrl, new URL("deps/invalidDep.js", FIXTURE_URL), @@ -74,7 +76,7 @@ describe("EntryFilesAnalyser", () => { const reports = await fromAsync(generator); assert.deepEqual( - reports.map((report) => report.url), + reports.map((report) => report.file), [ entryUrl, new URL("deps/default.js", FIXTURE_URL), @@ -100,7 +102,7 @@ describe("EntryFilesAnalyser", () => { const reports = await fromAsync(generator); assert.deepEqual( - reports.map((report) => report.url), + reports.map((report) => report.file), [ entryUrl, new URL("deps/default.jsx", FIXTURE_URL), @@ -108,6 +110,53 @@ describe("EntryFilesAnalyser", () => { ].map((url) => fileURLToPath(url)) ); }); + + it("should detect recursive dependencies using DiGraph (with rootPath)", async() => { + const entryFilesAnalyser = new EntryFilesAnalyser({ + rootPath: FIXTURE_URL + }); + const entryUrl = new URL("recursive/A.js", FIXTURE_URL); + + const generator = entryFilesAnalyser.analyse( + [entryUrl] + ); + await fromAsync(generator); + + assert.deepEqual( + [...entryFilesAnalyser.dependencies.findCycles()], + [ + ["recursive/A.js", "recursive/B.js"].map((str) => path.normalize(str)) + ] + ); + + assert.deepEqual( + [ + ...entryFilesAnalyser.dependencies.getDeepChildren( + path.normalize("recursive/A.js"), 1 + ) + ], + [ + path.normalize("recursive/B.js") + ] + ); + }); + + it("should detect recursive dependencies using DiGraph but without rootPath everything is absolute", async() => { + const entryFilesAnalyser = new EntryFilesAnalyser(); + const entryUrl = new URL("recursive/A.js", FIXTURE_URL); + + const generator = entryFilesAnalyser.analyse( + [entryUrl] + ); + await fromAsync(generator); + + for (const [from, to] of [...entryFilesAnalyser.dependencies.findCycles()]) { + assert.ok(path.isAbsolute(from)); + assert.ok(path.isAbsolute(to)); + assert.ok(from.startsWith(FIXTURE_URL_PATH)); + assert.ok(to.startsWith(FIXTURE_URL_PATH)); + } + }); }); // TODO: replace with Array.fromAsync when droping Node.js 20 diff --git a/test/fixtures/entryFiles/recursive/A.js b/test/fixtures/entryFiles/recursive/A.js new file mode 100644 index 0000000..337101a --- /dev/null +++ b/test/fixtures/entryFiles/recursive/A.js @@ -0,0 +1,4 @@ +import { bar } from "./B.js"; + +export const foo = "bar"; +console.log(bar); diff --git a/test/fixtures/entryFiles/recursive/B.js b/test/fixtures/entryFiles/recursive/B.js new file mode 100644 index 0000000..c14c582 --- /dev/null +++ b/test/fixtures/entryFiles/recursive/B.js @@ -0,0 +1,4 @@ +import { foo } from "./A.js"; + +export const bar = "foo"; +console.log(foo); diff --git a/types/api.d.ts b/types/api.d.ts index 7b62f64..237190d 100644 --- a/types/api.d.ts +++ b/types/api.d.ts @@ -1,8 +1,12 @@ +// Third-party +import type { DiGraph, VertexDefinition, VertexBody } from "digraph-js"; +import { Statement } from "meriyah"; + +// Internal import { Warning, WarningName } from "./warnings.js"; -import { Statement } from "meriyah"; export { AstAnalyser, @@ -122,11 +126,6 @@ declare class AstAnalyser { ): ReportOnFile; } -interface EntryFilesAnalyserOptions { - astAnalyzer?: AstAnalyser; - loadExtensions?: (defaults: string[]) => string[]; -} - declare class SourceFile { constructor(source: string, options: any); addDependency( @@ -144,7 +143,17 @@ declare class SourceFile { walk(node: any): "skip" | null; } +interface EntryFilesAnalyserOptions { + astAnalyzer?: AstAnalyser; + loadExtensions?: (defaults: string[]) => string[]; + rootPath?: string | URL; +} + declare class EntryFilesAnalyser { + public astAnalyzer: AstAnalyser; + public allowedExtensions: Set; + public dependencies: DiGraph>; + constructor(options?: EntryFilesAnalyserOptions); /** @@ -153,7 +162,7 @@ declare class EntryFilesAnalyser { analyse( entryFiles: Iterable, options?: RuntimeFileOptions - ): AsyncGenerator; + ): AsyncGenerator; } declare class JsSourceParser implements SourceParser {