Skip to content
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
13,553 changes: 6,021 additions & 7,532 deletions package-lock.json

Large diffs are not rendered by default.

63 changes: 6 additions & 57 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"@oclif/core": "3.27.0",
"@salesforce/code-analyzer-core": "0.38.1",
"@salesforce/code-analyzer-engine-api": "0.30.0",
"@salesforce/code-analyzer-eslint-engine": "0.34.0",
"@salesforce/code-analyzer-eslint-engine": "0.35.0",
"@salesforce/code-analyzer-flow-engine": "0.27.0",
"@salesforce/code-analyzer-pmd-engine": "0.31.0",
"@salesforce/code-analyzer-regex-engine": "0.28.0",
Expand All @@ -31,18 +31,16 @@
"@eslint/js": "^9.35.0",
"@oclif/plugin-help": "^6.2.33",
"@salesforce/cli-plugins-testkit": "^5.3.41",
"@types/jest": "^30.0.0",
"@types/tmp": "^0.2.6",
"@vitest/coverage-istanbul": "^3.2.4",
"eslint": "^9.35.0",
"eslint-plugin-sf-plugin": "^1.20.32",
"husky": "^9.1.7",
"jest": "^30.1.3",
"jest-junit": "^16.0.0",
"oclif": "^4.22.22",
"tmp": "^0.2.5",
"ts-jest": "^29.4.4",
"typescript": "^5.9.2",
"typescript-eslint": "^8.44.0"
"typescript-eslint": "^8.44.0",
"vite": "^7.1.5",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=20.0.0"
Expand Down Expand Up @@ -76,63 +74,14 @@
"topicSeparator": " ",
"flexibleTaxonomy": true
},
"jest": {
"testTimeout": 60000,
"collectCoverageFrom": [
"src/**/*.ts",
"!src/index.ts",
"!src/lib/Display.ts"
],
"coverageReporters": [
"lcov",
"json",
"text"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
},
"preset": "ts-jest",
"reporters": [
"default",
[
"jest-junit",
{
"outputDirectory": "reports",
"outputName": "report.xml"
}
],
[
"github-actions",
{
"silent": false
},
"summary"
]
],
"setupFiles": ["./test/setup-jest.ts"],
"testEnvironment": "node",
"testMatch": [
"<rootDir>/test/**/*.test.ts"
],
"testPathIgnorePatterns": [
"<rootDir>/node_modules/",
"<rootDir>/lib/",
"<rootDir>/dist/"
]
},
"repository": "forcedotcom/code-analyzer",
"scripts": {
"build": "tsc --build tsconfig.json",
"prepack": "rm -rf lib && tsc --build tsconfig.json && oclif manifest && oclif readme && npm shrinkwrap",
"postpack": "rm -f oclif.manifest.json oclif.lock npm-shrinkwrap.json",
"lint": "eslint ./src --max-warnings 0",
"version": "oclif readme && git add README.md",
"test": "tsc --build ./test/tsconfig.json --noEmit && jest --coverage",
"test": "tsc --build ./test/tsconfig.json --noEmit && vitest run --coverage",
"showcoverage": "open ./coverage/lcov-report/index.html",
"prepare": "husky"
}
Expand Down
43 changes: 36 additions & 7 deletions src/lib/utils/FileUtil.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,39 @@
import * as fs from 'node:fs';

export async function exists(filename: string): Promise<boolean> {
try {
await fs.promises.access(filename, fs.constants.F_OK);
return true;
} catch (_e) {
return false;
}
export interface FileSystem {
exists(file: string): Promise<boolean>

writeFileSync(file: string, contents: string): void

createWriteStream(file: string): Promise<WriteStream>;
}

export interface WriteStream {
write(content: string): void
end(): void
}

export class RealFileSystem implements FileSystem {
async exists(file: string): Promise<boolean> {
try {
await fs.promises.access(file, fs.constants.F_OK);
return true;
} catch (_e) {
return false;
}
}

writeFileSync(file: string, contents: string): void {
fs.writeFileSync(file, contents);
}

createWriteStream(file: string): Promise<WriteStream> {
// We return a promise so that we can await for the stream to be opened
// if we want before proceeding with writes.
return new Promise((resolve, reject) => {
const stream: fs.WriteStream = fs.createWriteStream(file);
stream.once('open', () => resolve(stream));
stream.once('error', (err) => reject(err));
});
}
}
17 changes: 12 additions & 5 deletions src/lib/utils/StylingUtil.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import ansis from 'ansis';
import {Ansis} from 'ansis';

/**
* For now, the styling methods only accept objects if all of their keys correspond to string values. This puts the
* burden of formatting non-string properties on the caller.
*/
type Styleable = null | undefined | {[key: string]: string|string[]};

export const __ANSIS = {
// The default Ansis instance uses environment variables to decide whether to add decoration or not.
// We use this holder so that in testing we can swap out this instance with new Ansis(3) to force color in order
// to make the tests more robust.
instance: new Ansis() // This auto detects whether to use color or not
}

export function toStyledHeaderAndBody(header: string, body: Styleable, keys?: string[]): string {
const styledHeader: string = toStyledHeader(header);
const styledBody: string = indent(toStyledPropertyList(body, keys));
return `${styledHeader}\n${styledBody}`;
}

export function toStyledHeader(header: string): string {
return `${ansis.dim('===')} ${ansis.bold(header)}`;
return `${__ANSIS.instance.dim('===')} ${__ANSIS.instance.bold(header)}`;
}

export function makeGrey(str: string): string {
return ansis.dim(str);
return __ANSIS.instance.dim(str);
}

export function toStyledPropertyList(body: Styleable, selectedKeys?: string[]): string {
Expand All @@ -28,7 +35,7 @@ export function toStyledPropertyList(body: Styleable, selectedKeys?: string[]):
const longestKeyLength = Math.max(...keysToPrint.map(k => k.length));

const styleProperty = (key: string, value: string|string[]): string => {
const keyPortion = `${ansis.blue(key)}:`;
const keyPortion = `${__ANSIS.instance.blue(key)}:`;
const keyValueGap = ' '.repeat(longestKeyLength - key.length + 1);
if (typeof value === 'string') {
const valuePortion = value.replace('\n', `\n${' '.repeat(longestKeyLength + 2)}`);
Expand All @@ -45,4 +52,4 @@ export function toStyledPropertyList(body: Styleable, selectedKeys?: string[]):

export function indent(text: string, indentLength: number = 4): string {
return text.replace(/^.+/gm, m => m.length > 0 ? ' '.repeat(indentLength) + m : m);
}
}
17 changes: 9 additions & 8 deletions src/lib/writers/ConfigWriter.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import path from 'node:path';
import fs from 'node:fs';
import * as path from 'node:path';
import {ConfigModel, OutputFormat} from '../models/ConfigModel';
import {BundleName, getMessage} from '../messages';
import {Display} from '../Display';
import {exists} from '../utils/FileUtil';
import {FileSystem, RealFileSystem} from '../utils/FileUtil';

export interface ConfigWriter {
write(model: ConfigModel): Promise<boolean>;
Expand All @@ -13,27 +12,29 @@ export class ConfigFileWriter implements ConfigWriter {
private readonly file: string;
private readonly format: OutputFormat;
private readonly display: Display;
private readonly fileSystem: FileSystem;

private constructor(file: string, format: OutputFormat, display: Display) {
private constructor(file: string, format: OutputFormat, display: Display, fileSystem: FileSystem) {
this.file = file;
this.format = format;
this.display = display;
this.fileSystem = fileSystem;
}

public async write(model: ConfigModel): Promise<boolean> {
// Only write to the file if it doesn't already exist, or if the user confirms that they want to overwrite it.
if (!(await exists(this.file)) || await this.display.confirm(getMessage(BundleName.ConfigWriter, 'prompt.overwrite-existing-file', [this.file]))) {
fs.writeFileSync(this.file, model.toFormattedOutput(this.format));
if (!(await this.fileSystem.exists(this.file)) || await this.display.confirm(getMessage(BundleName.ConfigWriter, 'prompt.overwrite-existing-file', [this.file]))) {
this.fileSystem.writeFileSync(this.file, model.toFormattedOutput(this.format));
return true;
} else {
return false;
}
}

public static fromFile(file: string, display: Display): ConfigFileWriter {
public static fromFile(file: string, display: Display, fileSystem: FileSystem = new RealFileSystem()): ConfigFileWriter {
const ext = path.extname(file).toLowerCase();
if (ext === '.yaml' || ext === '.yml') {
return new ConfigFileWriter(file, OutputFormat.RAW_YAML, display);
return new ConfigFileWriter(file, OutputFormat.RAW_YAML, display, fileSystem);
} else {
throw new Error(getMessage(BundleName.ConfigWriter, 'error.unrecognized-file-format', [file]));
}
Expand Down
14 changes: 7 additions & 7 deletions src/lib/writers/LogWriter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import {CodeAnalyzerConfig} from '@salesforce/code-analyzer-core';
import {Clock, RealClock, formatToDateTimeString} from '../utils/DateTimeUtils';
import {FileSystem, RealFileSystem, WriteStream} from '../utils/FileUtil';

export interface LogWriter {
writeToLog(message: string): void;
Expand All @@ -10,10 +10,10 @@ export interface LogWriter {
}

export class LogFileWriter implements LogWriter {
private readonly writeStream: NodeJS.WritableStream;
private readonly writeStream: WriteStream;
private readonly destination: string;

private constructor(writeStream: NodeJS.WritableStream, destination: string) {
private constructor(writeStream: WriteStream, destination: string) {
this.writeStream = writeStream;
this.destination = destination;
}
Expand All @@ -30,13 +30,13 @@ export class LogFileWriter implements LogWriter {
this.writeStream.end();
}

public static async fromConfig(config: CodeAnalyzerConfig, clock: Clock = new RealClock()): Promise<LogFileWriter> {
public static async fromConfig(config: CodeAnalyzerConfig, clock: Clock = new RealClock(), fileSystem: FileSystem = new RealFileSystem()): Promise<LogFileWriter> {
const logFolder = config.getLogFolder();

// Use the current timestamp to make sure each transaction has a unique logfile. If we want to reuse logfiles,
// or just have one running logfile, we can change this.
const logFile = path.join(logFolder, `sfca-${formatToDateTimeString(clock.now())}.log`);
// 'w' flag causes the file to be created if it doesn't already exist.
const fh = await fs.open(logFile, 'w');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we don't need this fs.open - thus also solving the file handle not closed issue.

return new LogFileWriter(fh.createWriteStream({}), logFile);

return new LogFileWriter(await fileSystem.createWriteStream(logFile), logFile);
}
}
14 changes: 8 additions & 6 deletions src/lib/writers/ResultsWriter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as fs from 'node:fs';
import path from 'node:path';
import * as path from 'node:path';
import {OutputFormat, RunResults} from '@salesforce/code-analyzer-core';
import {BundleName, getMessage} from '../messages';
import {FileSystem, RealFileSystem} from '../utils/FileUtil';

export interface ResultsWriter {
write(results: RunResults): void;
Expand All @@ -18,16 +18,17 @@ export class CompositeResultsWriter implements ResultsWriter {
this.writers.forEach(w => w.write(results));
}

public static fromFiles(files: string[]): CompositeResultsWriter {
return new CompositeResultsWriter(files.map(f => new ResultsFileWriter(f)));
public static fromFiles(files: string[], fileSystem: FileSystem = new RealFileSystem()): CompositeResultsWriter {
return new CompositeResultsWriter(files.map(f => new ResultsFileWriter(f, fileSystem)));
}
}

export class ResultsFileWriter implements ResultsWriter {
private readonly file: string;
private readonly format: OutputFormat;
private readonly fileSystem: FileSystem;

public constructor(file: string) {
public constructor(file: string, fileSystem: FileSystem = new RealFileSystem()) {
this.file = file;
const ext = path.extname(file).toLowerCase();
if (ext === '.csv') {
Expand All @@ -44,9 +45,10 @@ export class ResultsFileWriter implements ResultsWriter {
} else {
throw new Error(getMessage(BundleName.ResultsWriter, 'error.unrecognized-file-format', [file]));
}
this.fileSystem = fileSystem;
}

public write(results: RunResults): void {
fs.writeFileSync(this.file, results.toFormattedOutput(this.format));
this.fileSystem.writeFileSync(this.file, results.toFormattedOutput(this.format));
}
}
18 changes: 10 additions & 8 deletions src/lib/writers/RulesWriter.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import * as fs from 'node:fs';
import * as path from "path";
import { OutputFormat, RuleSelection } from "@salesforce/code-analyzer-core";
import path from "path";
import { BundleName, getMessage } from "../messages";
import { FileSystem, RealFileSystem } from "../utils/FileUtil";

export interface RulesWriter {
write(rules: RuleSelection): void;
}


export class CompositeRulesWriter implements RulesWriter {
private readonly writers: RulesWriter[] = [];

Expand All @@ -19,30 +18,33 @@ export class CompositeRulesWriter implements RulesWriter {
this.writers.forEach(w => w.write(rules));
}

public static fromFiles(files: string[]): CompositeRulesWriter {
return new CompositeRulesWriter(files.map(f => new RulesFileWriter(f)));
public static fromFiles(files: string[], fileSystem: FileSystem = new RealFileSystem()): CompositeRulesWriter {
return new CompositeRulesWriter(files.map(f => new RulesFileWriter(f, fileSystem)));
}
}

export class RulesFileWriter implements RulesWriter {
private readonly file: string;
private readonly format: OutputFormat;
private readonly fileSystem: FileSystem;

public constructor(file: string) {
public constructor(file: string, fileSystem: FileSystem = new RealFileSystem()) {
this.file = file;
const ext = path.extname(file).toLowerCase();

const ext = path.extname(file).toLowerCase();
if (ext === '.json') {
this.format = OutputFormat.JSON;
} else if (ext === '.csv') {
this.format = OutputFormat.CSV;
} else {
throw new Error(getMessage(BundleName.RulesWriter, 'error.unrecognized-file-format', [file]));
}

this.fileSystem = fileSystem;
}

public write(ruleSelection: RuleSelection): void {
const contents = ruleSelection.toFormattedOutput(this.format);
fs.writeFileSync(this.file, contents);
this.fileSystem.writeFileSync(this.file, contents);
}
}
Loading