Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for browser environment #231

Merged
merged 6 commits into from
Dec 22, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `ShortCircuitCondition` detector: PR [#202](https://github.com/nowarp/misti/pull/202)
- `PreferredStdlibApi` detector now suggest some preferred replacements for cell methods
- Add Callgraph: PR [#185](https://github.com/nowarp/misti/pull/185)
- Support for browser environment: PR [#231](https://github.com/nowarp/misti/pull/231)
- `souffleEnabled` option to disable Souffle check execution: PR [#231](https://github.com/nowarp/misti/pull/231)
- Add function effects to Callgraph: PR [#227](https://github.com/nowarp/misti/pull/227)

### Changed
4 changes: 3 additions & 1 deletion examples/implicit-init/test/implicitInit.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { Driver, MistiResultWarnings } from "../../../src/cli";
import path from "path";
import { createNodeFileSystem } from "../../../src/vfs/createNodeFileSystem";

describe("ImplicitInit Detector Tests", () => {
const fs = createNodeFileSystem(process.cwd());
it("should detect an issue in the sample contract", async () => {
const tactConfigPath = path.resolve(
__dirname,
"project",
"tactConfig.json",
);
const config = path.resolve(__dirname, "project", "mistiConfig.json");
const driver = await Driver.create([tactConfigPath], { config });
const driver = await Driver.create([tactConfigPath], { config, fs });

expect(driver.detectors.length).toBe(1);
expect(driver.detectors[0].id).toBe("ImplicitInit");
6 changes: 5 additions & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@ import {
ResultReport,
} from "./result";
import { Logger } from "../internals/logger";
import { createNodeFileSystem } from "../vfs/createNodeFileSystem";
import { Command } from "commander";

/**
@@ -56,7 +57,10 @@ export async function runMistiCommand(
command: Command = createMistiCommand(),
): Promise<[Driver, MistiResult]> {
await command.parseAsync(args, { from: "user" });
const driver = await Driver.create(command.args, command.opts());
const driver = await Driver.create(command.args, {
...command.opts(),
fs: createNodeFileSystem(process.cwd()),
});
const result = await driver.execute();
return [driver, result];
}
43 changes: 30 additions & 13 deletions src/cli/driver.ts
Original file line number Diff line number Diff line change
@@ -17,11 +17,10 @@ import {
Severity,
} from "../internals/warnings";
import { Tool, findBuiltInTool } from "../tools/tool";
import fs from "fs";
import { VirtualFileSystem } from "../vfs/virtualFileSystem";
import ignore from "ignore";
import JSONbig from "json-bigint";
import path from "path";
import { setTimeout } from "timers/promises";

/**
* Manages the initialization and execution of detectors for analyzing compilation units.
@@ -33,6 +32,7 @@ export class Driver {
outputPath: string;
disabledDetectors: Set<string>;
colorizeOutput: boolean;
fs: VirtualFileSystem;
/**
* Compilation units representing the actual entrypoints of the analysis targets
* based on user's input. Might be empty if no paths are specified.
@@ -43,6 +43,7 @@ export class Driver {
outputFormat: OutputFormat;

private constructor(tactPaths: string[], options: CLIOptions) {
this.fs = options.fs;
this.ctx = new MistiContext(options);
this.cus = this.createCUs(tactPaths);
this.disabledDetectors = new Set(options.disabledDetectors ?? []);
@@ -81,7 +82,7 @@ export class Driver {
private createCUs(tactPaths: string[]): Map<ProjectName, CompilationUnit> {
return [...new Set(tactPaths)]
.reduce((acc, tactPath) => {
if (fs.statSync(tactPath).isDirectory()) {
if (this.fs.stat(tactPath).isDirectory()) {
const tactFiles = this.collectTactFiles(tactPath);
this.ctx.logger.debug(
`Collected Tact files from ${tactPath}:\n${tactFiles.map((tactFile) => "- " + tactFile).join("\n")}`,
@@ -94,7 +95,7 @@ export class Driver {
}, [] as string[])
.filter(
(tactPath) =>
fs.existsSync(tactPath) ||
this.fs.exists(tactPath) ||
(this.ctx.logger.error(`${tactPath} is not available`), false),
)
.reduce((acc, tactPath) => {
@@ -103,7 +104,7 @@ export class Driver {
let configManager: TactConfigManager;
if (tactPath.endsWith(".tact")) {
importGraph = ImportGraphBuilder.make(this.ctx, [tactPath]).build();
let projectRoot = importGraph.resolveProjectRoot();
let projectRoot = importGraph.resolveProjectRoot(this.fs);
if (projectRoot === undefined) {
projectRoot = path.dirname(tactPath);
this.ctx.logger.warn(
@@ -116,6 +117,7 @@ export class Driver {
projectRoot,
tactPath,
projectName,
this.fs,
);
const projectConfig = configManager.findProjectByName(projectName);
if (projectConfig === undefined) {
@@ -126,7 +128,12 @@ export class Driver {
].join("\n"),
);
}
const ast = parseTactProject(this.ctx, projectConfig, projectRoot);
const ast = parseTactProject(
this.ctx,
projectConfig,
projectRoot,
this.fs,
);
const cu = createIR(this.ctx, projectName, ast, importGraph);
acc.set(projectName, cu);
} else {
@@ -141,6 +148,7 @@ export class Driver {
this.ctx,
configProject,
configManager.getProjectRoot(),
this.fs,
);
const projectName = configProject.name as ProjectName;
const cu = createIR(this.ctx, projectName, ast, importGraph);
@@ -158,20 +166,20 @@ export class Driver {
*/
private collectTactFiles(dir: string): string[] {
let results: string[] = [];
const files = fs.readdirSync(dir);
const files = this.fs.readdir(dir);

// If .gitignore exists, use it to ignore files
const gitignorePath = findGitignore(dir);
const gitignorePath = findGitignore(dir, this.fs);
let ig = ignore();
if (gitignorePath) {
ig = ignore().add(fs.readFileSync(gitignorePath, "utf8"));
ig = ignore().add(this.fs.readFile(gitignorePath).toString("utf8"));
}

files.forEach((file) => {
const fullPath = path.join(dir, file);
const relativePath = path.relative(dir, fullPath);
if (!ig.ignores(relativePath) && !fullPath.includes("node_modules")) {
if (fs.statSync(fullPath).isDirectory()) {
if (this.fs.stat(fullPath).isDirectory()) {
results = results.concat(this.collectTactFiles(fullPath));
} else if (file.endsWith(".tact")) {
results.push(fullPath);
@@ -622,9 +630,18 @@ export class Driver {
`${cu.projectName}: Running ${detector.id} for ${cu.projectName}`,
);
try {
// Conditional import for setTimeout to support both Node.js and browser environments
let setTimeoutPromise: (ms: number, value?: any) => Promise<any>;

if (typeof window !== "undefined") {
setTimeoutPromise = (ms) =>
new Promise((resolve) => setTimeout(resolve, ms));
} else {
setTimeoutPromise = (await import("timers/promises")).setTimeout;
}
const warnings = await Promise.race([
detector.check(cu),
setTimeout(MistiEnv.MISTI_TIMEOUT, []).then(() => {
setTimeoutPromise(MistiEnv.MISTI_TIMEOUT, []).then(() => {
throw new Error(
`Detector ${detector.id} timed out after ${MistiEnv.MISTI_TIMEOUT}ms`,
);
@@ -668,11 +685,11 @@ export class Driver {
* @param startDir The directory to start searching from.
* @returns The path to the .gitignore file or null if not found.
*/
function findGitignore(startDir: string): string | null {
function findGitignore(startDir: string, fs: VirtualFileSystem): string | null {
let currentDir = startDir;
while (currentDir !== path.parse(currentDir).root) {
const gitignorePath = path.join(currentDir, ".gitignore");
if (fs.existsSync(gitignorePath)) {
if (fs.exists(gitignorePath)) {
return gitignorePath;
}
currentDir = path.dirname(currentDir);
6 changes: 6 additions & 0 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { ToolConfig, OutputFormat } from "./types";
import { Severity, parseSeverity } from "../internals/warnings";
import { createVirtualFileSystem } from "../vfs/createVirtualFileSystem";
import { VirtualFileSystem } from "../vfs/virtualFileSystem";
import { Option } from "commander";

export const STDOUT_PATH = "-";
@@ -13,6 +15,7 @@ export interface CLIOptions {
soufflePath: string;
souffleBinary: string;
souffleVerbose: boolean;
souffleEnabled: boolean;
tactStdlibPath: string | undefined;
verbose: boolean;
quiet: boolean;
@@ -23,6 +26,7 @@ export interface CLIOptions {
config: string | undefined;
newDetector: string | undefined;
listDetectors: boolean;
fs: VirtualFileSystem;
}

export const cliOptionDefaults: Required<CLIOptions> = {
@@ -34,6 +38,7 @@ export const cliOptionDefaults: Required<CLIOptions> = {
soufflePath: "/tmp/misti/souffle",
souffleBinary: "souffle",
souffleVerbose: false,
souffleEnabled: true,
tactStdlibPath: undefined,
verbose: false,
quiet: false,
@@ -44,6 +49,7 @@ export const cliOptionDefaults: Required<CLIOptions> = {
config: undefined,
newDetector: undefined,
listDetectors: false,
fs: createVirtualFileSystem("/", {}),
};

export const cliOptions = [
9 changes: 5 additions & 4 deletions src/createDetector.ts
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@
*
* @packageDocumentation
*/
import fs from "fs-extra";
import { createNodeFileSystem } from "./vfs/createNodeFileSystem";
import path from "path";

const TEMPLATE_PATH = path.join(
@@ -49,6 +49,7 @@ function getFileInfo(str: string): [string, string] {
* @return true if the detector was successfully created, false otherwise.
*/
export async function createDetector(nameOrPath: string): Promise<boolean> {
const fs = createNodeFileSystem(process.cwd());
const [dir, detectorName] = getFileInfo(nameOrPath);
if (!isCamelCase(detectorName)) {
console.error(
@@ -57,17 +58,17 @@ export async function createDetector(nameOrPath: string): Promise<boolean> {
return false;
}
const filepath = path.join(dir, `${detectorName}.ts`);
if (await fs.pathExists(filepath)) {
if (fs.exists(filepath)) {
console.error(`File already exists at ${filepath}`);
return false;
}
try {
const templateContent = await fs.readFile(TEMPLATE_PATH, "utf8");
const templateContent = fs.readFile(TEMPLATE_PATH).toString("utf8");
const content = templateContent.replace(
/__ClassName__/g,
capitalize(detectorName),
);
await fs.outputFile(filepath, content);
fs.writeFile(filepath, content);
console.log(
[
`Created ${filepath}\n`,
9 changes: 7 additions & 2 deletions src/internals/config.ts
Original file line number Diff line number Diff line change
@@ -5,7 +5,8 @@ import {
getEnabledDetectors,
DetectorName,
} from "../detectors/detector";
import * as fs from "fs";
import { createNodeFileSystem } from "../vfs/createNodeFileSystem";
import { VirtualFileSystem } from "../vfs/virtualFileSystem";
import { z } from "zod";

const DetectorConfigSchema = z.object({
@@ -57,22 +58,26 @@ export class MistiConfig {
public tactStdlibPath?: string;
public unusedPrefix: string;
public verbosity: "quiet" | "debug" | "default";
public fs: VirtualFileSystem;

constructor({
configPath = undefined,
detectors = undefined,
tools = undefined,
allDetectors = false,
fs = createNodeFileSystem(process.cwd()),
}: Partial<{
configPath?: string;
detectors?: string[];
tools?: ToolConfig[];
allDetectors: boolean;
fs: VirtualFileSystem;
}> = {}) {
let configData;
this.fs = fs;
if (configPath) {
try {
const configFileContents = fs.readFileSync(configPath, "utf8");
const configFileContents = fs.readFile(configPath).toString("utf8");
configData = JSON.parse(configFileContents);
} catch (err) {
if (err instanceof Error) {
8 changes: 5 additions & 3 deletions src/internals/context.ts
Original file line number Diff line number Diff line change
@@ -20,15 +20,17 @@ export class MistiContext {
* Initializes the context for Misti, setting up configuration and appropriate logger.
*/
constructor(options: CLIOptions = cliOptionDefaults) {
this.souffleAvailable = this.checkSouffleInstallation(
options.souffleBinary,
);
this.souffleAvailable = options.souffleEnabled
? this.checkSouffleInstallation(options.souffleBinary)
: false;

try {
this.config = new MistiConfig({
detectors: options.enabledDetectors,
tools: options.tools,
allDetectors: options.allDetectors,
configPath: options.config,
fs: options.fs,
});
} catch (err) {
throwZodError(err, {
3 changes: 1 addition & 2 deletions src/internals/ir/builders/imports.ts
Original file line number Diff line number Diff line change
@@ -22,7 +22,6 @@ import {
} from "@tact-lang/compiler/dist/grammar/ast";
import { ItemOrigin } from "@tact-lang/compiler/dist/grammar/grammar";
import tactGrammar from "@tact-lang/compiler/dist/grammar/grammar.ohm-bundle";
import fs from "fs";
import { Node, NonterminalNode } from "ohm-js";
import path from "path";

@@ -66,7 +65,7 @@ export class ImportGraphBuilder {

let fileContent = "";
try {
fileContent = fs.readFileSync(filePath, "utf8");
fileContent = this.ctx.config.fs.readFile(filePath).toString("utf8");
} catch {
this.ctx.logger.warn(
`Cannot find imported file: ${filePath}. The analysis might not work.`,
6 changes: 4 additions & 2 deletions src/internals/ir/imports.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IdxGenerator } from "./indices";
import { VirtualFileSystem } from "../../vfs/virtualFileSystem";
import { SrcInfo } from "@tact-lang/compiler/dist/grammar/ast";
import { ItemOrigin } from "@tact-lang/compiler/dist/grammar/grammar";
import path from "path";
@@ -95,9 +96,10 @@ export class ImportGraph {
* Resolves project root based on the import directives.
* The project root is a directory including all the imported files.
*
* @param fs The virtual file system used to manage and resolve file paths during the operation.
* @returns Project root directory or undefined if there are no user imports.
*/
public resolveProjectRoot(): string | undefined {
public resolveProjectRoot(fs: VirtualFileSystem): string | undefined {
let projectRoot: string | undefined;
this.nodes.forEach((node) => {
if (node.origin === "user") {
@@ -113,7 +115,7 @@ export class ImportGraph {
}
}
});
return projectRoot ? path.resolve(projectRoot) : undefined;
return projectRoot ? fs.resolve(projectRoot) : undefined;
}

/**
8 changes: 7 additions & 1 deletion src/internals/tact/config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { VirtualFileSystem } from "../../vfs/virtualFileSystem";
import { ExecutionException, throwZodError } from "../exceptions";
import { ProjectName } from "../ir";
import {
@@ -45,6 +46,7 @@ export class TactConfigManager {
* @param ctx Misti context.
* @param projectName Name of the project.
* @param contractPath Path to the Tact contract.
* @param vfs Virtual file system to manage interactions with the project files.
*/
public static fromContract(
projectRoot: string,
@@ -53,12 +55,16 @@ export class TactConfigManager {
contractPath,
".tact",
) as ProjectName,
vfs: VirtualFileSystem,
): TactConfigManager {
const absoluteProjectRoot = vfs.resolve(projectRoot);
const absoluteContractPath = path.resolve(projectRoot, contractPath);

const tactConfig: TactConfig = {
projects: [
{
name: projectName,
path: path.relative(projectRoot, contractPath),
path: path.relative(absoluteProjectRoot, absoluteContractPath),
output: "/tmp/misti/output", // never used
options: {
debug: false,
25 changes: 20 additions & 5 deletions src/internals/tact/parser.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,50 @@
import { getStdlibPath } from "./stdlib";
import { VirtualFileSystem } from "../../vfs/virtualFileSystem";
import { MistiContext } from "../context";
import { TactException } from "../exceptions";
import { getStdlibPath } from "./stdlib";
import { createNodeFileSystem } from "../../vfs/createNodeFileSystem";
import {
createVirtualFileSystem,
VirtualFileSystem as TactVirtualFileSystem,
} from "@tact-lang/compiler";
import { ConfigProject } from "@tact-lang/compiler/dist/config/parseConfig";
import { CompilerContext } from "@tact-lang/compiler/dist/context";
import { getRawAST } from "@tact-lang/compiler/dist/grammar/store";
import { AstStore } from "@tact-lang/compiler/dist/grammar/store";
import stdLibFiles from "@tact-lang/compiler/dist/imports/stdlib";
import { enableFeatures } from "@tact-lang/compiler/dist/pipeline/build";
import { precompile } from "@tact-lang/compiler/dist/pipeline/precompile";
import { createNodeFileSystem } from "@tact-lang/compiler/dist/vfs/createNodeFileSystem";

/**
* Parses the project defined in the Tact configuration file, generating its AST.
*
* @param mistiCtx Misti context
* @param projectRoot Absolute path to the root the project
* @param config The Tact configuration object: contents of the existing file or a generated object
* @param vfs Virtual file system to manage file interactions during parsing
* @returns A mapping of project names to their corresponding ASTs.
*/
export function parseTactProject(
mistiCtx: MistiContext,
projectConfig: ConfigProject,
projectRoot: string,
vfs: VirtualFileSystem,
): AstStore | never {
const project = createNodeFileSystem(projectRoot, false);
const stdlibPath = mistiCtx.config.tactStdlibPath ?? getStdlibPath();
const stdlib = createNodeFileSystem(stdlibPath, false);
let stdlib: VirtualFileSystem | TactVirtualFileSystem;

if (vfs.type === "local") {
stdlib = createNodeFileSystem(stdlibPath);
vfs = createNodeFileSystem(projectRoot);
} else {
stdlib = createVirtualFileSystem("@stdlib", stdLibFiles);
}

mistiCtx.logger.debug(`Parsing project ${projectConfig.name} ...`);
try {
let ctx = new CompilerContext();
ctx = enableFeatures(ctx, mistiCtx.logger, projectConfig);
ctx = precompile(ctx, project, stdlib, projectConfig.path);
ctx = precompile(ctx, vfs, stdlib, projectConfig.path);
return getRawAST(ctx);
} catch (error: unknown) {
throw TactException.make(error);
6 changes: 5 additions & 1 deletion src/internals/tact/stdlib.ts
Original file line number Diff line number Diff line change
@@ -42,5 +42,9 @@ export function definedInStdlib(
? DEFAULT_STDLIB_PATH_ELEMENTS
: stdlibPath.split("/").filter((part) => part !== "");
const filePath = typeof locOrPath === "string" ? locOrPath : locOrPath.file;
return filePath !== null && hasSubdirs(filePath, pathElements);

return (
filePath !== null &&
(filePath.startsWith("@stdlib") || hasSubdirs(filePath, pathElements))
);
}
125 changes: 125 additions & 0 deletions src/vfs/createNodeFileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { VirtualFileSystem, FileStat } from "./virtualFileSystem";
import { InternalException } from "../internals/exceptions";
import fs from "fs";
import path from "path";

/**
* Creates a Virtual File System backed by the local file system.
* This file system interacts directly with the host's disk storage.
*
* @param root - The root directory for the virtual file system.
* @param readonly - If true, prevents write operations. Default is true.
* @returns A VirtualFileSystem instance with local file system operations.
*/
export function createNodeFileSystem(
root: string,
readonly: boolean = true,
): VirtualFileSystem {
let normalizedRoot = path.normalize(root);
if (!normalizedRoot.endsWith(path.sep)) {
normalizedRoot += path.sep;
}

return {
/**
* The normalized root directory for the virtual file system.
*/
root: normalizedRoot,

/**
* The type of the virtual file system. In this case, it is "local".
*/
type: "local",

/**
* Checks if a file or directory exists at the specified path.
*
* @param filePath - The path to check existence for.
* @returns True if the file or directory exists, otherwise false.
*/
exists(filePath: string): boolean {
const resolvedPath = this.resolve(filePath);
return fs.existsSync(resolvedPath);
},

/**
* Resolves a given path to an absolute path within the virtual file system's root.
*
* @param filePath - One or more path segments to resolve.
* @returns The resolved absolute path.
*/
resolve(...filePath: string[]): string {
return path.normalize(path.resolve(normalizedRoot, ...filePath));
},

/**
* Reads a file from the virtual file system.
*
* @param filePath - The path of the file to read.
* @returns A Buffer containing the file's content.
*/
readFile(filePath: string): Buffer {
const resolvedPath = this.resolve(filePath);
return fs.readFileSync(resolvedPath);
},

/**
* Writes content to a file in the virtual file system.
* Creates necessary directories if they do not exist.
*
* @param filePath - The path of the file to write to.
* @param content - The content to write, as a Buffer or string.
* @throws An exception if the file system is in readonly mode.
*/
writeFile(filePath: string, content: Buffer | string): void {
if (readonly) {
throw InternalException.make(
`Cannot write to file "${filePath}": The file system is in readonly mode.`,
);
}
const resolvedPath = this.resolve(filePath);
// Ensure the directory exists
const dir = path.dirname(resolvedPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(resolvedPath, content);
},

/**
* Reads the contents of a directory in the virtual file system.
*
* @param dirPath - The path of the directory to read.
* @returns An array of filenames in the directory.
* @throws An error if the specified path is not a directory.
*/
readdir(dirPath: string): string[] {
const resolvedPath = this.resolve(dirPath);
if (!fs.statSync(resolvedPath).isDirectory()) {
throw InternalException.make(
`Path '${resolvedPath}' is not a directory`,
);
}
return fs.readdirSync(resolvedPath);
},

/**
* Retrieves the statistics of a file or directory.
*
* @param filePath - The path of the file or directory.
* @returns An object containing file/directory metadata.
*/
stat(filePath: string): FileStat {
const resolvedPath = this.resolve(filePath);
const stats = fs.statSync(resolvedPath);

return {
isFile: () => stats.isFile(),
isDirectory: () => stats.isDirectory(),
size: stats.size,
createdAt: stats.birthtime,
updatedAt: stats.mtime,
};
},
};
}
146 changes: 146 additions & 0 deletions src/vfs/createVirtualFileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import {
FileStat,
FileSystemTree,
VirtualFileSystem,
} from "./virtualFileSystem";
import { InternalException } from "../internals/exceptions";
import path from "path";

/**
* Creates a virtual file system for managing files only (no directories).
* This file system is entirely in-memory and does not interact with the host file system.
*
* @param root - The root directory for the virtual file system.
* @param fileSystemTree - The initial structure of the in-memory file system. Default is an empty object.
* @param readonly - If true, prevents write operations. Default is true.
* @returns A VirtualFileSystem instance for managing in-memory files.
*/
export function createVirtualFileSystem(
root: string,
fileSystemTree: FileSystemTree = {},
readonly: boolean = true,
): VirtualFileSystem {
let normalizedRoot = path.normalize(root);
if (!normalizedRoot.endsWith(path.sep)) {
normalizedRoot += path.sep;
}

const memoryFS = fileSystemTree;

return {
/**
* The normalized root directory for the virtual file system.
*/
root: normalizedRoot,

/**
* The type of the virtual file system. In this case, it is "inMemory".
*/
type: "inMemory",

/**
* Checks if a file exists at the specified path.
*
* @param filePath - The path to check existence for.
* @returns True if the file exists, otherwise false.
*/
exists(filePath: string): boolean {
const resolvedPath = this.resolve(filePath);
return resolvedPath in memoryFS;
},

/**
* Resolves a given path to an absolute path within the virtual file system's root.
*
* @param filePath - One or more path segments to resolve.
* @returns The resolved absolute path.
*/
resolve(...filePath: string[]): string {
return path.normalize(path.resolve(normalizedRoot, ...filePath));
},

/**
* Reads a file from the virtual file system.
*
* @param filePath - The path of the file to read.
* @returns A Buffer containing the file's content.
* @throws An error if the file does not exist or is not a file.
*/
readFile(filePath: string): Buffer {
const resolvedPath = this.resolve(filePath);
const file = memoryFS[resolvedPath];

if (!file || file.type !== "file") {
throw InternalException.make(
`File '${resolvedPath}' does not exist or is not a file`,
);
}

return Buffer.from(file.content, "utf-8");
},

/**
* Writes content to a file in the virtual file system.
*
* @param filePath - The path of the file to write to.
* @param content - The content to write, as a Buffer or string.
* @throws An error if the file system is in readonly mode.
*/
writeFile(filePath: string, content: Buffer | string): void {
if (readonly) {
throw InternalException.make(
`Cannot write to file "${filePath}": The file system is in readonly mode.`,
);
}
const resolvedPath = this.resolve(filePath);

// Write the file
memoryFS[resolvedPath] = {
type: "file",
content: content.toString(),
size:
typeof content === "string"
? Buffer.byteLength(content)
: content.length,
createdAt: memoryFS[resolvedPath]?.createdAt || new Date(),
updatedAt: new Date(),
};
},

/**
* Lists all file names in the virtual file system.
*
* @returns An array of file names.
*/
readdir(): string[] {
// List all file names in the memory file system
return Object.keys(memoryFS).filter(
(key) => memoryFS[key].type === "file",
);
},

/**
* Retrieves the statistics of a file.
*
* @param filePath - The path of the file.
* @returns An object containing file metadata such as size, creation date, and update date.
* @throws An error if the file does not exist.
*/
stat(filePath: string): FileStat {
const resolvedPath = this.resolve(filePath);
const node = memoryFS[resolvedPath];

if (!node) {
throw InternalException.make(`File '${resolvedPath}' does not exist`);
}

return {
isFile: () => node.type === "file",
isDirectory: () => false, // No directories in this implementation
size: node.size ?? 0,
createdAt: node.createdAt ?? new Date(),
updatedAt: node.updatedAt ?? new Date(),
};
},
};
}
39 changes: 39 additions & 0 deletions src/vfs/virtualFileSystem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type FileNode = {
type: "file";
content: string;
size?: number;
createdAt?: Date;
updatedAt?: Date;
};

type DirectoryNode = {
type: "directory";
size?: number;
createdAt?: Date;
updatedAt?: Date;
};

type FileSystemNode = FileNode | DirectoryNode;

export type FileSystemTree = Record<string, FileSystemNode>;

export type FileStat = {
isFile: () => boolean;
isDirectory: () => boolean;
size: number;
createdAt: Date;
updatedAt: Date;
};

type FileSystemBackend = "local" | "inMemory";

export type VirtualFileSystem = {
root: string;
type: FileSystemBackend;
resolve(...path: string[]): string;
exists(path: string): boolean;
readFile(path: string): Buffer;
writeFile(path: string, content: Buffer | string): void;
readdir: (path: string) => string[];
stat: (path: string) => FileStat;
};
29 changes: 20 additions & 9 deletions test/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { MistiConfig } from "../src/internals/config";
import * as fs from "fs";

jest.mock("fs");
import { createVirtualFileSystem } from "../src/vfs/createVirtualFileSystem";

describe("Config class", () => {
const MOCK_CONFIG_PATH = "./mistiConfig_mock.json";
@@ -13,13 +11,20 @@ describe("Config class", () => {
ignoredProjects: ["ignoredProject"],
});

const fs = createVirtualFileSystem(process.cwd(), {}, false);

beforeEach(() => {
jest.clearAllMocks();
});

it("should load and parse config file correctly", () => {
(fs.readFileSync as jest.Mock).mockReturnValue(MOCK_CONFIG_CONTENT);
const configInstance = new MistiConfig({ configPath: MOCK_CONFIG_PATH });
fs.readFile = jest
.fn()
.mockReturnValue(Buffer.from(MOCK_CONFIG_CONTENT, "utf8"));
const configInstance = new MistiConfig({
configPath: MOCK_CONFIG_PATH,
fs,
});
expect(configInstance.detectors).toEqual([
{ className: "ReadOnlyVariables" },
{ className: "ZeroAddress" },
@@ -28,10 +33,10 @@ describe("Config class", () => {
});

it("throws an error when the config file cannot be read", () => {
(fs.readFileSync as jest.Mock).mockImplementation(() => {
fs.readFile = jest.fn().mockImplementation(() => {
throw new Error("Failed to read file");
});
expect(() => new MistiConfig({ configPath: MOCK_CONFIG_PATH })).toThrow(
expect(() => new MistiConfig({ configPath: MOCK_CONFIG_PATH, fs })).toThrow(
"Failed to read file",
);
});
@@ -43,8 +48,14 @@ describe("Config class", () => {
{ detector: "ReadOnlyVariables", position: "file.tact:10:5" },
],
});
(fs.readFileSync as jest.Mock).mockReturnValue(configWithSuppressions);
const configInstance = new MistiConfig({ configPath: MOCK_CONFIG_PATH });
fs.readFile = jest
.fn()
.mockReturnValue(Buffer.from(configWithSuppressions, "utf8"));

const configInstance = new MistiConfig({
configPath: MOCK_CONFIG_PATH,
fs,
});
expect(configInstance.suppressions).toEqual([
{ detector: "ReadOnlyVariables", file: "file.tact", line: 10, col: 5 },
]);