fenge 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fenge",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "A CLI tool for code quality",
5
5
  "keywords": [
6
6
  "cli",
@@ -51,8 +51,8 @@
51
51
  "prettier": "3.6.2",
52
52
  "pretty-ms": "9.2.0",
53
53
  "yoctocolors": "2.1.2",
54
- "@fenge/eslint-config": "0.7.10",
55
- "@fenge/prettier-config": "0.3.9",
54
+ "@fenge/eslint-config": "0.7.12",
55
+ "@fenge/prettier-config": "0.3.10",
56
56
  "@fenge/tsconfig": "0.7.0",
57
57
  "prettier-ignore": "0.4.0"
58
58
  },
@@ -35,14 +35,27 @@ program
35
35
  )
36
36
  .argument("[paths...]", "dir or file paths to format and lint")
37
37
  .action(async (paths, options) => {
38
+ /**
39
+ * @type {{code: number, stdout: string, stderr: string, fixedFiles?: string[]}}
40
+ */
38
41
  let result = await format(paths, options);
39
- if (result === 0) {
42
+ result.stdout && console.log(result.stdout);
43
+ result.stderr && console.error(result.stderr);
44
+ if (result.code === 0) {
40
45
  result = await lint(paths, options);
41
- if (result === 0 && (options.fix || options.update)) {
42
- result = await format(paths, options);
46
+ result.stdout && console.log(result.stdout);
47
+ result.stderr && console.error(result.stderr);
48
+ if (
49
+ result.code === 0 &&
50
+ (options.fix || options.update) &&
51
+ result.fixedFiles?.length
52
+ ) {
53
+ result = await format(result.fixedFiles, options);
54
+ result.stdout && console.log(result.stdout);
55
+ result.stderr && console.error(result.stderr);
43
56
  }
44
57
  }
45
- process.exit(result);
58
+ process.exit(result.code);
46
59
  });
47
60
 
48
61
  program
@@ -61,7 +74,12 @@ program
61
74
  "print what command will be executed under the hood instead of executing",
62
75
  )
63
76
  .argument("[paths...]", "dir or file paths to lint")
64
- .action(async (paths, options) => process.exit(await lint(paths, options)));
77
+ .action(async (paths, options) => {
78
+ const { code, stdout, stderr } = await lint(paths, options);
79
+ stdout && console.log(stdout);
80
+ stderr && console.error(stderr);
81
+ process.exit(code);
82
+ });
65
83
 
66
84
  program
67
85
  .command("format")
@@ -78,7 +96,12 @@ program
78
96
  "print what command will be executed under the hood instead of executing",
79
97
  )
80
98
  .argument("[paths...]", "dir or file paths to format")
81
- .action(async (paths, options) => process.exit(await format(paths, options)));
99
+ .action(async (paths, options) => {
100
+ const { code, stdout, stderr } = await format(paths, options);
101
+ stdout && console.log(stdout);
102
+ stderr && console.error(stderr);
103
+ process.exit(code);
104
+ });
82
105
 
83
106
  program
84
107
  .command("install")
@@ -18,8 +18,9 @@ export async function format(
18
18
  default: useDefaultConfig = false,
19
19
  } = {},
20
20
  ) {
21
- return execAsync(
21
+ return execAsync("💃 Checking formatting")(
22
22
  [
23
+ process.execPath,
23
24
  await getPrettierPath(useDefaultConfig),
24
25
  // setup 3 ignore files
25
26
  ...[".gitignore", ".prettierignore", prettierignore]
@@ -37,7 +38,6 @@ export async function format(
37
38
  ),
38
39
  ],
39
40
  {
40
- topic: "💃 Checking formatting",
41
41
  dryRun,
42
42
  env: {
43
43
  ...(config && { FENGE_CONFIG: config }),
@@ -51,11 +51,10 @@ export async function format(
51
51
  * @param {boolean} useDefaultConfig
52
52
  */
53
53
  async function getPrettierPath(useDefaultConfig) {
54
- const builtinBinPath = await getBinPath("prettier");
55
54
  if (useDefaultConfig) {
56
- return builtinBinPath;
55
+ return await getBinPath("prettier");
57
56
  }
58
- return await getBinPath("prettier", process.cwd()).catch(
59
- () => builtinBinPath,
57
+ return await getBinPath("prettier", process.cwd()).catch(() =>
58
+ getBinPath("prettier"),
60
59
  );
61
60
  }
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
+ import { ESLint } from "eslint";
4
5
  import { dir, execAsync, getBinPath } from "../utils.js";
5
6
 
6
7
  /**
@@ -18,9 +19,11 @@ export async function lint(
18
19
  timing = false,
19
20
  } = {},
20
21
  ) {
21
- return execAsync(
22
+ const result = await execAsync("📏 Checking linting")(
22
23
  [
24
+ process.execPath,
23
25
  await getEslintPath(useDefaultConfig),
26
+ ...(timing ? [] : ["--format=json"]),
24
27
  "--config",
25
28
  path.join(dir(import.meta.url), "..", "config", "eslint.config.js"),
26
29
  ...(update || fix ? ["--fix"] : []),
@@ -29,7 +32,6 @@ export async function lint(
29
32
  ),
30
33
  ],
31
34
  {
32
- topic: "📏 Checking linting",
33
35
  dryRun,
34
36
  env: {
35
37
  ...(config && { FENGE_CONFIG: config }),
@@ -38,15 +40,50 @@ export async function lint(
38
40
  },
39
41
  },
40
42
  );
43
+ // Loading formatter in this way is not very elegant, but it's the only way (maybe).
44
+ const formatter = await new ESLint().loadFormatter("stylish");
45
+ const stdoutResult = await handleJson(formatter, result.stdout);
46
+ const stderrResult = await handleJson(formatter, result.stderr);
47
+ return {
48
+ ...result,
49
+ stdout: stdoutResult.content,
50
+ stderr: stderrResult.content,
51
+ fixedFiles: [
52
+ ...new Set([...stdoutResult.fixedFiles, ...stderrResult.fixedFiles]),
53
+ ],
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @param {import('eslint').ESLint.LoadedFormatter} formatter
59
+ * @param {string} str
60
+ */
61
+ async function handleJson(formatter, str) {
62
+ try {
63
+ /** @type {import('eslint').ESLint.LintResult[]} */
64
+ const lintResults = JSON.parse(str);
65
+ return {
66
+ fixedFiles: lintResults
67
+ .filter((lintResult) => lintResult.output)
68
+ .map((lintResult) => lintResult.filePath),
69
+ content: await formatter.format(lintResults),
70
+ };
71
+ } catch {
72
+ return {
73
+ fixedFiles: [],
74
+ content: str,
75
+ };
76
+ }
41
77
  }
42
78
 
43
79
  /**
44
80
  * @param {boolean} useDefaultConfig
45
81
  */
46
82
  async function getEslintPath(useDefaultConfig) {
47
- const builtinBinPath = await getBinPath("eslint");
48
83
  if (useDefaultConfig) {
49
- return builtinBinPath;
84
+ return await getBinPath("eslint");
50
85
  }
51
- return await getBinPath("eslint", process.cwd()).catch(() => builtinBinPath);
86
+ return await getBinPath("eslint", process.cwd()).catch(() =>
87
+ getBinPath("eslint"),
88
+ );
52
89
  }
package/src/utils.js CHANGED
@@ -42,30 +42,65 @@ export async function resolveConfig(module, loadPath) {
42
42
  : await searcher.search(process.cwd());
43
43
  }
44
44
 
45
+ /**
46
+ * Creates a spinner wrapper for async functions that shows progress and timing
47
+ * @template {readonly unknown[]} TArgs - The arguments type for the function
48
+ * @template {{ code: number }} TReturn - The return type that must have a code property
49
+ * @param {(...args: TArgs) => Promise<TReturn>} func - The async function to wrap
50
+ * @param {string} topic - The topic/description to show in the spinner
51
+ * @returns {(...args: TArgs) => Promise<TReturn>} The wrapped function with spinner
52
+ */
53
+ export function spin(func, topic) {
54
+ return async (...args) => {
55
+ const startTime = Date.now();
56
+ const spinner = ora(`${topic}...`).start();
57
+ /** @type {"succeed" | "fail"} */
58
+ let succeedOrFail = "fail";
59
+ try {
60
+ const result = await func(...args);
61
+ if (result.code === 0) succeedOrFail = "succeed";
62
+ return result;
63
+ } finally {
64
+ spinner[succeedOrFail](
65
+ `${topic} ${succeedOrFail} in ${colors.yellow(prettyMs(Date.now() - startTime))}`,
66
+ );
67
+ }
68
+ };
69
+ }
70
+
45
71
  /**
46
72
  * @param {string[]} command
47
- * @param {{topic: string, dryRun: boolean, env: Record<string, string>}} options
48
- * @returns {Promise<number>}
73
+ * @param {{dryRun: boolean, env: Record<string, string>}} options
74
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
49
75
  */
50
- export function execAsync(command, { topic, dryRun, env }) {
76
+ function execCmd(command, { dryRun, env }) {
51
77
  const [cmd, ...args] = command;
52
78
  if (!cmd) {
53
79
  return Promise.reject(new Error("cmd not found"));
54
80
  }
55
81
  if (dryRun) {
56
- process.stdout.write(`${colors.green(cmd)} ${args.join(" ")};\n\n`);
57
- return Promise.resolve(0);
82
+ return Promise.resolve({
83
+ code: 0,
84
+ stdout: `${colors.green(cmd)} ${args.join(" ")};\n`,
85
+ stderr: "",
86
+ });
58
87
  }
59
88
 
60
89
  return new Promise((resolve, reject) => {
61
- const startTime = Date.now();
62
- const spinner = ora(`${topic}...`).start();
63
90
  /**
64
91
  * @type {childProcess.ChildProcessWithoutNullStreams | undefined}
65
92
  */
66
93
  let cp = childProcess.spawn(cmd, args, {
67
94
  env: { FORCE_COLOR: "true", ...process.env, ...env },
68
95
  });
96
+
97
+ /**
98
+ * @param {NodeJS.Signals} signal
99
+ */
100
+ const listener = (signal) => cp && !cp.killed && cp.kill(signal);
101
+ process.on("SIGINT", listener);
102
+ process.on("SIGTERM", listener);
103
+
69
104
  let stdout = Buffer.alloc(0);
70
105
  let stderr = Buffer.alloc(0);
71
106
  cp.stdout.on("data", (data) => {
@@ -75,39 +110,37 @@ export function execAsync(command, { topic, dryRun, env }) {
75
110
  stderr = Buffer.concat([stderr, data]);
76
111
  });
77
112
  cp.on("error", (err) => {
113
+ process.removeListener("SIGINT", listener);
114
+ process.removeListener("SIGTERM", listener);
78
115
  reject(err);
79
116
  });
80
117
  // Why not listen to the 'exit' event?
81
118
  // 1. The 'close' event will always emit after 'exit' was already emitted, or 'error' if the child failed to spawn.
82
119
  // 2. The 'exit' event may or may not fire after an error has occurred.
83
120
  cp.on("close", (code, signal) => {
84
- if (code === 0) {
85
- spinner.succeed(
86
- `${topic} succeeded in ${colors.yellow(prettyMs(Date.now() - startTime))}`,
87
- );
88
- } else {
89
- spinner.fail(
90
- `${topic} failed in ${colors.yellow(prettyMs(Date.now() - startTime))}`,
91
- );
92
- }
93
- process.stdout.write(stdout);
94
- process.stderr.write(stderr);
121
+ const stdoutString = stdout.toString("utf8");
122
+ const stderrString = stderr.toString("utf8");
95
123
  // When the cp exited, we should clean cp and the buffer to prevent memory leak.
96
124
  stdout = Buffer.alloc(0);
97
125
  stderr = Buffer.alloc(0);
98
126
  cp = undefined;
99
- resolve(code ?? (signal ? 128 + os.constants.signals[signal] : 1));
100
- });
101
127
 
102
- /**
103
- * @param {NodeJS.Signals} signal
104
- */
105
- const listener = (signal) => cp && !cp.killed && cp.kill(signal);
106
- process.on("SIGINT", listener);
107
- process.on("SIGTERM", listener);
128
+ process.removeListener("SIGINT", listener);
129
+ process.removeListener("SIGTERM", listener);
130
+ resolve({
131
+ code: code ?? (signal ? 128 + os.constants.signals[signal] : 1),
132
+ stdout: stdoutString.trim(),
133
+ stderr: stderrString.trim(),
134
+ });
135
+ });
108
136
  });
109
137
  }
110
138
 
139
+ /**
140
+ * @param {string} topic
141
+ */
142
+ export const execAsync = (topic) => spin(execCmd, topic);
143
+
111
144
  /**
112
145
  * @param {string} moduleName `eslint` or `prettier` or `@commitlint/cli` or `lint-staged`
113
146
  * @param {string} from directory path or file path