Skip to content

feat: add support for browser environment #231

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

Merged
merged 6 commits into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand Down
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");
Expand Down
6 changes: 5 additions & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ResultReport,
} from "./result";
import { Logger } from "../internals/logger";
import { createNodeFileSystem } from "../vfs/createNodeFileSystem";
import { Command } from "commander";

/**
Expand Down Expand Up @@ -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];
}
Expand Down
43 changes: 30 additions & 13 deletions src/cli/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 ?? []);
Expand Down Expand Up @@ -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")}`,
Expand All @@ -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) => {
Expand All @@ -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(
Expand All @@ -116,6 +117,7 @@ export class Driver {
projectRoot,
tactPath,
projectName,
this.fs,
);
const projectConfig = configManager.findProjectByName(projectName);
if (projectConfig === undefined) {
Expand All @@ -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 {
Expand All @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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`,
);
Expand Down Expand Up @@ -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);
Expand Down
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 = "-";
Expand All @@ -13,6 +15,7 @@ export interface CLIOptions {
soufflePath: string;
souffleBinary: string;
souffleVerbose: boolean;
souffleEnabled: boolean;
tactStdlibPath: string | undefined;
verbose: boolean;
quiet: boolean;
Expand All @@ -23,6 +26,7 @@ export interface CLIOptions {
config: string | undefined;
newDetector: string | undefined;
listDetectors: boolean;
fs: VirtualFileSystem;
}

export const cliOptionDefaults: Required<CLIOptions> = {
Expand All @@ -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,
Expand All @@ -44,6 +49,7 @@ export const cliOptionDefaults: Required<CLIOptions> = {
config: undefined,
newDetector: undefined,
listDetectors: false,
fs: createVirtualFileSystem("/", {}),
};

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

const TEMPLATE_PATH = path.join(
Expand Down Expand Up @@ -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(
Expand All @@ -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`,
Expand Down
9 changes: 7 additions & 2 deletions src/internals/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions src/internals/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
3 changes: 1 addition & 2 deletions src/internals/ir/builders/imports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.`,
Expand Down
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";
Expand Down Expand Up @@ -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") {
Expand All @@ -113,7 +115,7 @@ export class ImportGraph {
}
}
});
return projectRoot ? path.resolve(projectRoot) : undefined;
return projectRoot ? fs.resolve(projectRoot) : undefined;
}

/**
Expand Down
Loading
Loading