effect-v4-audit 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -1,18 +1,30 @@
1
1
  # effect-v4-audit
2
2
 
3
3
  Deterministic CLI audit for v3-era Effect APIs in Effect v4 migrations.
4
+ Built on Effect v4 beta CLI APIs (`effect/unstable/cli`).
4
5
 
5
- ## Install (local package)
6
+ ## Demo
7
+
8
+ ![effect-v4-audit demo](./demo/effect-v4-audit-demo.gif)
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ bunx effect-v4-audit --help
14
+ ```
15
+
16
+ ## Local Development
6
17
 
7
18
  ```bash
8
- cd /Users/af/effect-cli-ce/effect-smol/packages/tools/effect-v4-audit
19
+ git clone https://github.com/agustif/effect-v4-audit.git
20
+ cd effect-v4-audit
9
21
  bun install
10
22
  ```
11
23
 
12
- After install, run:
24
+ Then run:
13
25
 
14
26
  ```bash
15
- bun x effect-v4-audit --help
27
+ bun run ./src/bin.ts --help
16
28
  ```
17
29
 
18
30
  ## Usage
@@ -22,13 +34,15 @@ effect-v4-audit \
22
34
  --cwd . \
23
35
  --pattern "**/*.{ts,tsx,mts,cts}" \
24
36
  --ignore "**/*.gen.ts" \
25
- --format table \
37
+ --color auto \
38
+ --format diagnostic \
26
39
  --fail-on-findings
27
40
  ```
28
41
 
29
42
  ## Output
30
43
 
31
- - `table`: human-readable findings with path, line/column, and migration suggestion.
44
+ - `diagnostic`: rust-style diagnostics with source snippet and `help` hint.
45
+ - `table`: compact findings with path, line/column, and migration suggestion.
32
46
  - `json`: stable machine-readable report for CI.
33
47
 
34
48
  ## Guarantees
Binary file
package/package.json CHANGED
@@ -1,21 +1,23 @@
1
1
  {
2
2
  "name": "effect-v4-audit",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3.0"
7
7
  },
8
8
  "license": "MIT",
9
9
  "description": "Deterministic Effect v4 migration audit CLI",
10
- "homepage": "https://effect.website",
10
+ "homepage": "https://github.com/agustif/effect-v4-audit",
11
+ "bugs": {
12
+ "url": "https://github.com/agustif/effect-v4-audit/issues"
13
+ },
11
14
  "repository": {
12
15
  "type": "git",
13
- "url": "git+https://github.com/Effect-TS/effect-smol.git",
14
- "directory": "packages/tools/effect-v4-audit"
16
+ "url": "git+https://github.com/agustif/effect-v4-audit.git"
15
17
  },
16
18
  "sideEffects": [],
17
19
  "bin": {
18
- "effect-v4-audit": "./bin.js"
20
+ "effect-v4-audit": "bin.js"
19
21
  },
20
22
  "exports": {
21
23
  ".": "./src/index.ts",
@@ -24,6 +26,7 @@
24
26
  },
25
27
  "files": [
26
28
  "bin.js",
29
+ "demo/effect-v4-audit-demo.gif",
27
30
  "src/**/*.ts",
28
31
  "README.md"
29
32
  ],
@@ -34,9 +37,12 @@
34
37
  "coverage": "bun test --coverage"
35
38
  },
36
39
  "publishConfig": {
37
- "access": "public"
40
+ "access": "public",
41
+ "provenance": true
38
42
  },
39
43
  "dependencies": {
44
+ "@effect/platform-node": "4.0.0-beta.12",
45
+ "effect": "4.0.0-beta.12",
40
46
  "glob": "^13.0.0"
41
47
  }
42
48
  }
package/src/bin.ts CHANGED
@@ -1,157 +1,119 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
+ import * as NodeServices from "@effect/platform-node/NodeServices"
3
4
  import { readFile } from "node:fs/promises"
4
5
  import path from "node:path"
5
6
  import { glob } from "glob"
7
+ import * as Console from "effect/Console"
8
+ import * as Effect from "effect/Effect"
9
+ import * as CliError from "effect/unstable/cli/CliError"
10
+ import * as Command from "effect/unstable/cli/Command"
11
+ import * as Flag from "effect/unstable/cli/Flag"
6
12
  import { analyzeV4Sources } from "./analyze.ts"
13
+ import { renderV4AuditDiagnostics, type V4AuditColorMode } from "./diagnostics.ts"
7
14
  import { renderV4AuditJson, renderV4AuditTable } from "./render.ts"
8
15
 
9
- interface CliOptions {
10
- readonly cwd: string
11
- readonly pattern: string
12
- readonly ignore: ReadonlyArray<string>
13
- readonly format: "table" | "json"
14
- readonly failOnFindings: boolean
15
- }
16
-
17
16
  const defaultIgnorePatterns = [
18
17
  "**/node_modules/**",
19
18
  "**/dist/**",
20
19
  "**/coverage/**",
21
20
  "**/.git/**",
22
21
  "**/.turbo/**"
23
- ] as const
24
-
25
- const usage = (): string => [
26
- "effect-v4-audit",
27
- "",
28
- "Usage:",
29
- " effect-v4-audit [--cwd <dir>] [--pattern <glob>] [--ignore <glob>] [--format table|json] [--fail-on-findings]",
30
- "",
31
- "Options:",
32
- " -c, --cwd <dir> Directory to scan (default: .)",
33
- " -p, --pattern <glob> Glob for files to scan (default: **/*.{ts,tsx,mts,cts})",
34
- " -i, --ignore <glob> Glob to ignore (repeat for multiple values)",
35
- " -f, --format <table|json> Output format (default: table)",
36
- " --fail-on-findings Exit with code 1 when findings exist",
37
- " -h, --help Show this help"
38
- ].join("\n")
39
-
40
- const parseNextValue = (args: ReadonlyArray<string>, index: number, flag: string): string => {
41
- const value = args[index + 1]
42
- if (!value || value.startsWith("-")) {
43
- throw new Error(`Missing value for ${flag}`)
44
- }
45
- return value
46
- }
47
-
48
- export const parseCliArgs = (args: ReadonlyArray<string>): CliOptions | { readonly help: true } => {
49
- let cwd = "."
50
- let pattern = "**/*.{ts,tsx,mts,cts}"
51
- const ignore: Array<string> = []
52
- let format: "table" | "json" = "table"
53
- let failOnFindings = false
54
-
55
- for (let index = 0; index < args.length; index++) {
56
- const arg = args[index]
57
-
58
- switch (arg) {
59
- case "-h":
60
- case "--help":
61
- return { help: true }
62
- case "-c":
63
- case "--cwd":
64
- cwd = parseNextValue(args, index, arg)
65
- index++
66
- break
67
- case "-p":
68
- case "--pattern":
69
- pattern = parseNextValue(args, index, arg)
70
- index++
71
- break
72
- case "-i":
73
- case "--ignore":
74
- ignore.push(parseNextValue(args, index, arg))
75
- index++
76
- break
77
- case "-f":
78
- case "--format": {
79
- const value = parseNextValue(args, index, arg)
80
- if (value !== "table" && value !== "json") {
81
- throw new Error(`Invalid format: ${value}`)
82
- }
83
- format = value
84
- index++
85
- break
86
- }
87
- case "--fail-on-findings":
88
- failOnFindings = true
89
- break
90
- default:
91
- throw new Error(`Unknown argument: ${arg}`)
92
- }
93
- }
94
-
95
- return {
96
- cwd,
97
- pattern,
98
- ignore,
99
- format,
100
- failOnFindings
101
- }
102
- }
103
-
104
- const toPosix = (file: string): string => file.split(path.sep).join("/")
105
-
106
- const readSources = async (options: CliOptions): Promise<ReadonlyArray<{ readonly path: string; readonly content: string }>> => {
107
- const root = path.resolve(options.cwd)
108
- const files = await glob(options.pattern, {
109
- cwd: root,
110
- dot: false,
111
- follow: false,
112
- nodir: true,
113
- ignore: [...defaultIgnorePatterns, ...options.ignore]
22
+ ]
23
+
24
+ const cwd = Flag.directory("cwd", { mustExist: true }).pipe(
25
+ Flag.withAlias("c"),
26
+ Flag.withDescription("Directory to scan"),
27
+ Flag.withDefault(".")
28
+ )
29
+
30
+ const pattern = Flag.string("pattern").pipe(
31
+ Flag.withAlias("p"),
32
+ Flag.withDescription("Glob for files to scan"),
33
+ Flag.withDefault("**/*.{ts,tsx,mts,cts}")
34
+ )
35
+
36
+ const ignore = Flag.string("ignore").pipe(
37
+ Flag.withAlias("i"),
38
+ Flag.withDescription("Glob pattern(s) to ignore (repeat --ignore for multiple values)"),
39
+ Flag.between(0, Infinity)
40
+ )
41
+
42
+ const format = Flag.choice("format", ["diagnostic", "table", "json"] as const).pipe(
43
+ Flag.withAlias("f"),
44
+ Flag.withDescription("Report output format"),
45
+ Flag.withDefault("diagnostic")
46
+ )
47
+
48
+ const color = Flag.choice("color", ["auto", "always", "never"] as const).pipe(
49
+ Flag.withDescription("ANSI color output mode"),
50
+ Flag.withDefault("auto")
51
+ )
52
+
53
+ const failOnFindings = Flag.boolean("fail-on-findings").pipe(
54
+ Flag.withDescription("Exit with error code when at least one finding exists")
55
+ )
56
+
57
+ const toUserError = (cause: unknown, fallback: string): CliError.UserError =>
58
+ new CliError.UserError({
59
+ cause: cause instanceof Error
60
+ ? cause.message
61
+ : typeof cause === "string"
62
+ ? cause
63
+ : fallback
114
64
  })
115
65
 
116
- const sorted = files.sort((a, b) => toPosix(a).localeCompare(toPosix(b)))
117
-
118
- return Promise.all(
119
- sorted.map(async (file) => {
120
- const absolute = path.join(root, file)
121
- const content = await readFile(absolute, "utf8")
122
- return {
66
+ const cli = Command.make("effect-v4-audit", {
67
+ cwd,
68
+ pattern,
69
+ ignore,
70
+ format,
71
+ color,
72
+ failOnFindings
73
+ }, (options) =>
74
+ Effect.gen(function*() {
75
+ const root = path.resolve(options.cwd)
76
+ const toPosix = path.sep === "/" ? (file: string) => file : (file: string) => file.split(path.sep).join("/")
77
+
78
+ const files = yield* Effect.tryPromise({
79
+ try: () =>
80
+ glob(options.pattern, {
81
+ cwd: root,
82
+ dot: false,
83
+ follow: false,
84
+ nodir: true,
85
+ ignore: [...defaultIgnorePatterns, ...options.ignore]
86
+ }),
87
+ catch: (cause) => toUserError(cause, "Failed to collect files")
88
+ }).pipe(Effect.map((files) => files.sort((a, b) => toPosix(a).localeCompare(toPosix(b)))))
89
+
90
+ const sources = yield* Effect.forEach(files, (file) =>
91
+ Effect.tryPromise({
92
+ try: () => readFile(path.join(root, file), "utf8"),
93
+ catch: (cause) => toUserError(cause, `Failed to read ${file}`)
94
+ }).pipe(Effect.map((content) => ({
123
95
  path: toPosix(file),
124
96
  content
125
- }
126
- })
127
- )
128
- }
129
-
130
- export const runCli = async (args: ReadonlyArray<string>): Promise<number> => {
131
- const parsed = parseCliArgs(args)
132
- if ("help" in parsed) {
133
- console.log(usage())
134
- return 0
135
- }
136
-
137
- const sources = await readSources(parsed)
138
- const report = analyzeV4Sources({ sources })
139
- const output = parsed.format === "json" ? renderV4AuditJson(report) : renderV4AuditTable(report)
140
- console.log(output)
141
-
142
- if (parsed.failOnFindings && report.summary.findingCount > 0) {
143
- return 1
144
- }
145
-
146
- return 0
147
- }
148
-
149
- if (import.meta.main) {
150
- const exitCode = await runCli(process.argv.slice(2)).catch((error: unknown) => {
151
- const message = error instanceof Error ? error.message : String(error)
152
- console.error(message)
153
- return 1
154
- })
97
+ }))))
98
+
99
+ const report = analyzeV4Sources({ sources })
100
+ const output = options.format === "json"
101
+ ? renderV4AuditJson(report)
102
+ : options.format === "table"
103
+ ? renderV4AuditTable(report)
104
+ : renderV4AuditDiagnostics(report, sources, { color: options.color as V4AuditColorMode })
105
+
106
+ yield* Console.log(output)
107
+
108
+ if (options.failOnFindings && report.summary.findingCount > 0) {
109
+ yield* Effect.sync(() => {
110
+ process.exitCode = 1
111
+ })
112
+ }
113
+ }))
114
+
115
+ const main = Command.run(cli, { version: "0.2.1" }).pipe(
116
+ Effect.provide(NodeServices.layer)
117
+ )
155
118
 
156
- process.exitCode = exitCode
157
- }
119
+ Effect.runPromise(main)
@@ -0,0 +1,124 @@
1
+ import type { V4AuditReport, V4AuditSourceFile } from "./types.ts"
2
+
3
+ export type V4AuditColorMode = "auto" | "always" | "never"
4
+
5
+ const resolveUseColor = (mode: V4AuditColorMode): boolean => {
6
+ if (mode === "always") {
7
+ return true
8
+ }
9
+
10
+ if (mode === "never") {
11
+ return false
12
+ }
13
+
14
+ const globalProcess = (globalThis as any).process
15
+ const hasProcess = typeof globalProcess === "object" && globalProcess !== null
16
+ if (!hasProcess) {
17
+ return false
18
+ }
19
+
20
+ if (globalProcess.env?.NO_COLOR === "1") {
21
+ return false
22
+ }
23
+
24
+ if (globalProcess.env?.FORCE_COLOR && globalProcess.env.FORCE_COLOR !== "0") {
25
+ return true
26
+ }
27
+
28
+ return globalProcess.stdout?.isTTY === true
29
+ }
30
+
31
+ const makeColor = (useColor: boolean) => {
32
+ if (!useColor) {
33
+ return {
34
+ bold: (text: string): string => text,
35
+ dim: (text: string): string => text,
36
+ red: (text: string): string => text,
37
+ yellow: (text: string): string => text,
38
+ blue: (text: string): string => text,
39
+ cyan: (text: string): string => text,
40
+ green: (text: string): string => text
41
+ }
42
+ }
43
+
44
+ return {
45
+ bold: (text: string): string => `\x1b[1m${text}\x1b[0m`,
46
+ dim: (text: string): string => `\x1b[2m${text}\x1b[0m`,
47
+ red: (text: string): string => `\x1b[31m${text}\x1b[0m`,
48
+ yellow: (text: string): string => `\x1b[33m${text}\x1b[0m`,
49
+ blue: (text: string): string => `\x1b[34m${text}\x1b[0m`,
50
+ cyan: (text: string): string => `\x1b[36m${text}\x1b[0m`,
51
+ green: (text: string): string => `\x1b[32m${text}\x1b[0m`
52
+ }
53
+ }
54
+
55
+ const lineAt = (content: string, line: number): string => {
56
+ const lines = content.split("\n")
57
+ if (line <= 0 || line > lines.length) {
58
+ return ""
59
+ }
60
+ return lines[line - 1] ?? ""
61
+ }
62
+
63
+ const markerWidth = (match: string): number => Math.max(1, match.trim().length)
64
+
65
+ const digits = (value: number): number => `${Math.max(1, value)}`.length
66
+
67
+ const padLeft = (value: string, width: number): string => `${" ".repeat(Math.max(0, width - value.length))}${value}`
68
+
69
+ export const renderV4AuditDiagnostics = (
70
+ report: V4AuditReport,
71
+ sources: ReadonlyArray<V4AuditSourceFile>,
72
+ options?: {
73
+ readonly color?: V4AuditColorMode | undefined
74
+ }
75
+ ): string => {
76
+ const color = makeColor(resolveUseColor(options?.color ?? "never"))
77
+
78
+ if (report.summary.findingCount === 0) {
79
+ return `No v4-audit findings. Files scanned: ${report.summary.filesScanned}.`
80
+ }
81
+
82
+ const sourceMap = new Map<string, string>()
83
+ for (const source of sources) {
84
+ sourceMap.set(source.path.split("\\").join("/"), source.content)
85
+ }
86
+
87
+ const lines: Array<string> = [
88
+ `Findings: ${report.summary.findingCount} (errors: ${report.summary.errorCount}, warnings: ${report.summary.warningCount})`,
89
+ `Files scanned: ${report.summary.filesScanned}, files with findings: ${report.summary.filesWithFindings}`,
90
+ ""
91
+ ]
92
+
93
+ for (const finding of report.findings) {
94
+ const severity = finding.severity === "error"
95
+ ? color.bold(color.red("error"))
96
+ : color.bold(color.yellow("warning"))
97
+ const ruleId = color.bold(color.cyan(finding.ruleId))
98
+ const sourceContent = sourceMap.get(finding.path)
99
+ const codeLine = sourceContent ? lineAt(sourceContent, finding.line) : ""
100
+ const lineNo = `${finding.line}`
101
+ const gutterWidth = digits(finding.line)
102
+ const marker = finding.severity === "error"
103
+ ? color.red("^".repeat(markerWidth(finding.match)))
104
+ : color.yellow("^".repeat(markerWidth(finding.match)))
105
+ const columnOffset = Math.max(0, finding.column - 1)
106
+
107
+ lines.push(`${severity}[${ruleId}]: ${finding.message}`)
108
+ lines.push(` ${color.blue("-->")} ${finding.path}:${finding.line}:${finding.column}`)
109
+ lines.push(`${color.dim(" ".repeat(gutterWidth))} ${color.blue("|")}`)
110
+
111
+ if (codeLine.length > 0) {
112
+ lines.push(`${color.dim(padLeft(lineNo, gutterWidth))} ${color.blue("|")} ${codeLine}`)
113
+ lines.push(`${color.dim(" ".repeat(gutterWidth))} ${color.blue("|")} ${" ".repeat(columnOffset)}${marker}`)
114
+ } else {
115
+ lines.push(`${color.dim(padLeft(lineNo, gutterWidth))} ${color.blue("|")} ${finding.match}`)
116
+ lines.push(`${color.dim(" ".repeat(gutterWidth))} ${color.blue("|")} ${marker}`)
117
+ }
118
+
119
+ lines.push(`${color.dim(" ".repeat(gutterWidth))} ${color.green("=")} ${color.green("help")}: ${finding.suggestion}`)
120
+ lines.push("")
121
+ }
122
+
123
+ return lines.join("\n")
124
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { analyzeV4Sources } from "./analyze.ts"
2
+ import { renderV4AuditDiagnostics } from "./diagnostics.ts"
2
3
  import { defaultV4AuditRules } from "./rules.ts"
3
4
  import { renderV4AuditJson, renderV4AuditTable } from "./render.ts"
4
5
 
5
6
  export * from "./types.ts"
6
- export { analyzeV4Sources, defaultV4AuditRules, renderV4AuditJson, renderV4AuditTable }
7
+ export { analyzeV4Sources, defaultV4AuditRules, renderV4AuditDiagnostics, renderV4AuditJson, renderV4AuditTable }