Skip to content

Commit

Permalink
Create EntryFilesAnalyzer class to analyze a set of entry files (#258)
Browse files Browse the repository at this point in the history
* feat: create EntryFilesAnalyzer class to analyze a  set of entry files asynchronously, yielding analysis reports

* fix: return early if existing extension is not allowed

* refactor: avoid useless asignantions in tests files

* fix: fixtures must end with 1 blank line

* test: asserts all reports have been yielded at the end of tests

* fix: JsDoc for EntryFilesAnalyzer.analyze should have string|Url union

* types: add api types

* fix: import order

* fix: typo in mjs module, it is an externalDep

* fix: typo in .cjs .js  modules, it is an externalDep
  • Loading branch information
jean-michelet authored Apr 22, 2024
1 parent f1095a1 commit 55f52fa
Show file tree
Hide file tree
Showing 20 changed files with 255 additions and 0 deletions.
8 changes: 8 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {
AstAnalyser,
AstAnalyserOptions,

EntryFilesAnalyser,
EntryFilesAnalyserOptions,

SourceParser,
runASTAnalysis,
runASTAnalysisOnFile,
Expand All @@ -23,6 +28,9 @@ declare const warnings: Record<WarningName, Pick<WarningDefault, "experimental"
export {
warnings,
AstAnalyser,
AstAnalyserOptions,
EntryFilesAnalyser,
EntryFilesAnalyserOptions,
SourceParser,
runASTAnalysis,
runASTAnalysisOnFile,
Expand Down
99 changes: 99 additions & 0 deletions src/EntryFilesAnalyser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Import Node.js Dependencies
import fs from "node:fs/promises";
import path from "node:path";

// Import Internal Dependencies
import { AstAnalyser } from "./AstAnalyser.js";

const kDefaultExtensions = ["js", "cjs", "mjs", "node"];

export class EntryFilesAnalyser {
/**
* @constructor
* @param {object} [options={}]
* @param {AstAnalyser} [options.astAnalyzer=new AstAnalyser()]
* @param {function} [options.loadExtensions]
*/
constructor(options = {}) {
this.astAnalyzer = options.astAnalyzer ?? new AstAnalyser();
this.allowedExtensions = options.loadExtensions
? options.loadExtensions(kDefaultExtensions)
: kDefaultExtensions;
}

/**
* Asynchronously analyze a set of entry files yielding analysis reports.
*
* @param {(string | URL)[]} entryFiles
* @yields {Object} - Yields an object containing the analysis report for each file.
*/
async* analyse(entryFiles) {
this.analyzedDeps = new Set();

for (const file of entryFiles) {
yield* this.#analyzeFile(file);
}
}

async* #analyzeFile(file) {
const filePath = file instanceof URL ? file.pathname : file;
const report = await this.astAnalyzer.analyseFile(file);

yield { url: filePath, ...report };

if (!report.ok) {
return;
}

yield* this.#analyzeDeps(report.dependencies, path.dirname(filePath));
}

async* #analyzeDeps(deps, basePath) {
for (const [name] of deps) {
const depPath = await this.#getInternalDepPath(name, basePath);
if (depPath && !this.analyzedDeps.has(depPath)) {
this.analyzedDeps.add(depPath);

yield* this.#analyzeFile(depPath);
}
}
}

async #getInternalDepPath(name, basePath) {
const depPath = path.join(basePath, name);
const existingExt = path.extname(name);
if (existingExt !== "") {
if (!this.allowedExtensions.includes(existingExt.slice(1))) {
return null;
}

if (await this.#fileExists(depPath)) {
return depPath;
}
}

for (const ext of this.allowedExtensions) {
const depPathWithExt = `${depPath}.${ext}`;
if (await this.#fileExists(depPathWithExt)) {
return depPathWithExt;
}
}

return null;
}

async #fileExists(path) {
try {
await fs.access(path, fs.constants.F_OK);

return true;
}
catch (error) {
if (error.code !== "ENOENT") {
throw error;
}

return false;
}
}
}
95 changes: 95 additions & 0 deletions test/EntryFilesAnalyser.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Import Node.js Dependencies
import { describe, it } from "node:test";
import assert from "node:assert";

// Import Internal Dependencies
import { EntryFilesAnalyser } from "../src/EntryFilesAnalyser.js";
import { AstAnalyser } from "../src/AstAnalyser.js";

const FIXTURE_URL = new URL("fixtures/entryFiles/", import.meta.url);

describe("EntryFilesAnalyser", () => {
it("should analyze internal dependencies recursively", async(t) => {
const entryFilesAnalyser = new EntryFilesAnalyser();
const entryUrl = new URL("entry.js", FIXTURE_URL);
const deepEntryUrl = new URL("deps/deepEntry.js", FIXTURE_URL);

t.mock.method(AstAnalyser.prototype, "analyseFile");
const generator = entryFilesAnalyser.analyse([entryUrl, deepEntryUrl]);

// First entry
await assertReport(generator, entryUrl, true);
await assertReport(generator, new URL("deps/dep1.js", FIXTURE_URL), true);
await assertReport(generator, new URL("shared.js", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/dep2.js", FIXTURE_URL), true);

// Second entry
await assertReport(generator, deepEntryUrl, true);
await assertReport(generator, new URL("deps/dep3.js", FIXTURE_URL), true);

await assertAllReportsYielded(generator);

// Check that shared dependencies are not analyzed several times
const calls = AstAnalyser.prototype.analyseFile.mock.calls;
assert.strictEqual(calls.length, 6);
});

it("should detect internal deps that failed to be analyzed", async() => {
const entryFilesAnalyser = new EntryFilesAnalyser();
const entryUrl = new URL("entryWithInvalidDep.js", FIXTURE_URL);

const generator = entryFilesAnalyser.analyse([entryUrl]);

await assertReport(generator, entryUrl, true);

const invalidDepReport = await generator.next();
assert.ok(!invalidDepReport.value.ok);
assert.strictEqual(invalidDepReport.value.url, new URL("deps/invalidDep.js", FIXTURE_URL).pathname);
assert.strictEqual(invalidDepReport.value.warnings[0].kind, "parsing-error");

await assertReport(generator, new URL("deps/dep1.js", FIXTURE_URL), true);
await assertReport(generator, new URL("shared.js", FIXTURE_URL), true);

await assertAllReportsYielded(generator);
});

it("should extends default extensions", async() => {
const entryFilesAnalyser = new EntryFilesAnalyser({
loadExtensions: (exts) => [...exts, "jsx"]
});
const entryUrl = new URL("entryWithVariousDepExtensions.js", FIXTURE_URL);
const generator = entryFilesAnalyser.analyse([entryUrl]);

await assertReport(generator, entryUrl, true);
await assertReport(generator, new URL("deps/default.js", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.cjs", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.mjs", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.node", FIXTURE_URL), true);
await assertReport(generator, new URL("deps/default.jsx", FIXTURE_URL), true);

await assertAllReportsYielded(generator);
});

it("should override default extensions", async() => {
const entryFilesAnalyser = new EntryFilesAnalyser({
loadExtensions: () => ["jsx"]
});
const entryUrl = new URL("entryWithVariousDepExtensions.js", FIXTURE_URL);
const generator = entryFilesAnalyser.analyse([entryUrl]);

await assertReport(generator, entryUrl, true);
await assertReport(generator, new URL("deps/default.jsx", FIXTURE_URL), true);

await assertAllReportsYielded(generator);
});

async function assertReport(generator, expectedUrl, expectedOk) {
const report = await generator.next();
assert.strictEqual(report.value.url, expectedUrl.pathname);
assert.strictEqual(report.value.ok, expectedOk);
}

async function assertAllReportsYielded(generator) {
assert.strictEqual((await generator.next()).value, undefined);
}
});
3 changes: 3 additions & 0 deletions test/fixtures/entryFiles/deps/deepEntry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require("./dep1");
require("./dep2");
require("./dep3");
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('externalDep')
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require('externalDep')
7 changes: 7 additions & 0 deletions test/fixtures/entryFiles/deps/default.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import React from 'react'

export default function Foo() {
return (
<div></div>
)
}
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import externalDep from 'externalDep';
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/default.node
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('some/addon');
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/deps/dep1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("../shared");
require("externalDep");
3 changes: 3 additions & 0 deletions test/fixtures/entryFiles/deps/dep2.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require("../shared");
require("../shared.js");
require("externalDep");
3 changes: 3 additions & 0 deletions test/fixtures/entryFiles/deps/dep3.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
require("../shared");
require("../shared.js");
require("externalDep");
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/invalidDep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@invalidJs
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/deps/validDep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require("externalDep");
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("./deps/dep1");
require("./deps/dep2.js"); // keep extension for testing purpose
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/entryWithInvalidDep.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("./deps/invalidDep");
require("./deps/dep1");
2 changes: 2 additions & 0 deletions test/fixtures/entryFiles/entryWithRequireDepWithExtension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require("./deps/dep1.js");
require("./deps/dep1");
5 changes: 5 additions & 0 deletions test/fixtures/entryFiles/entryWithVariousDepExtensions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require("./deps/default.js");
require("./deps/default.cjs");
require("./deps/default.mjs");
require("./deps/default.node");
require("./deps/default.jsx");
1 change: 1 addition & 0 deletions test/fixtures/entryFiles/shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require("externalDep");
17 changes: 17 additions & 0 deletions types/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { Statement } from "meriyah/dist/src/estree.js";

export {
AstAnalyser,
AstAnalyserOptions,
EntryFilesAnalyser,
EntryFilesAnalyserOptions,
SourceParser,
runASTAnalysis,
runASTAnalysisOnFile,
Expand Down Expand Up @@ -101,5 +104,19 @@ declare class AstAnalyser {
analyzeFile(pathToFile: string, options?: RuntimeFileOptions): Promise<ReportOnFile>;
}

interface EntryFilesAnalyserOptions {
astAnalyzer?: AstAnalyser;
loadExtensions?: (defaults: string[]) => string[];
}

declare class EntryFilesAnalyser {
constructor(options?: EntryFilesAnalyserOptions);

/**
* Asynchronously analyze a set of entry files yielding analysis reports.
*/
analyse(entryFiles: (string | URL)[]): AsyncGenerator<ReportOnFile & { url: string }>;
}

declare function runASTAnalysis(str: string, options?: RuntimeOptions & AstAnalyserOptions): Report;
declare function runASTAnalysisOnFile(pathToFile: string, options?: RuntimeFileOptions & AstAnalyserOptions): Promise<ReportOnFile>;

0 comments on commit 55f52fa

Please sign in to comment.