effect-v4-audit 0.1.0

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 ADDED
@@ -0,0 +1,44 @@
1
+ # effect-v4-audit
2
+
3
+ Deterministic CLI audit for v3-era Effect APIs in Effect v4 migrations.
4
+
5
+ ## Install (local package)
6
+
7
+ ```bash
8
+ cd /Users/af/effect-cli-ce/effect-smol/packages/tools/effect-v4-audit
9
+ bun install
10
+ ```
11
+
12
+ After install, run:
13
+
14
+ ```bash
15
+ bun x effect-v4-audit --help
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ effect-v4-audit \
22
+ --cwd . \
23
+ --pattern "**/*.{ts,tsx,mts,cts}" \
24
+ --ignore "**/*.gen.ts" \
25
+ --format table \
26
+ --fail-on-findings
27
+ ```
28
+
29
+ ## Output
30
+
31
+ - `table`: human-readable findings with path, line/column, and migration suggestion.
32
+ - `json`: stable machine-readable report for CI.
33
+
34
+ ## Guarantees
35
+
36
+ - deterministic file ordering and finding ordering
37
+ - deterministic summary counters and per-rule map ordering
38
+ - exits with code `1` only when `--fail-on-findings` is passed and findings exist
39
+
40
+ ## Limitations
41
+
42
+ - rule-based text scan; does not parse full TypeScript AST
43
+ - may flag strings/comments containing legacy API text
44
+ - rules are intentionally conservative and not a full migration codemod
package/bin.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "./src/bin.ts"
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "effect-v4-audit",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "engines": {
6
+ "bun": ">=1.3.0"
7
+ },
8
+ "license": "MIT",
9
+ "description": "Deterministic Effect v4 migration audit CLI",
10
+ "homepage": "https://effect.website",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/Effect-TS/effect-smol.git",
14
+ "directory": "packages/tools/effect-v4-audit"
15
+ },
16
+ "sideEffects": [],
17
+ "bin": {
18
+ "effect-v4-audit": "./bin.js"
19
+ },
20
+ "exports": {
21
+ ".": "./src/index.ts",
22
+ "./package.json": "./package.json",
23
+ "./*": "./src/*.ts"
24
+ },
25
+ "files": [
26
+ "bin.js",
27
+ "src/**/*.ts",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "audit": "bun run ./src/bin.ts",
32
+ "test": "bun test",
33
+ "check": "bun test --bail",
34
+ "coverage": "bun test --coverage"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "glob": "^13.0.0"
41
+ }
42
+ }
package/src/analyze.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { computeLineStarts, indexToLineColumn } from "./line-starts.ts"
2
+ import { defaultV4AuditRules } from "./rules.ts"
3
+ import type { V4AuditFinding, V4AuditReport, V4AuditRule, V4AuditSourceFile, V4AuditSummary } from "./types.ts"
4
+
5
+ const normalizePath = (path: string): string => path.split("\\").join("/")
6
+
7
+ const asGlobalRegExp = (pattern: RegExp): RegExp => {
8
+ const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`
9
+ return new RegExp(pattern.source, flags)
10
+ }
11
+
12
+ const compareFindings = (left: V4AuditFinding, right: V4AuditFinding): number =>
13
+ left.path.localeCompare(right.path) ||
14
+ left.line - right.line ||
15
+ left.column - right.column ||
16
+ left.ruleId.localeCompare(right.ruleId) ||
17
+ left.match.localeCompare(right.match)
18
+
19
+ const compareRules = (left: V4AuditRule, right: V4AuditRule): number => left.id.localeCompare(right.id)
20
+
21
+ const scanSourceForRule = (
22
+ source: V4AuditSourceFile,
23
+ rule: V4AuditRule
24
+ ): ReadonlyArray<V4AuditFinding> => {
25
+ const path = normalizePath(source.path)
26
+ const lineStarts = computeLineStarts(source.content)
27
+ const pattern = asGlobalRegExp(rule.pattern)
28
+ const findings: Array<V4AuditFinding> = []
29
+
30
+ let match: RegExpExecArray | null = null
31
+ while ((match = pattern.exec(source.content)) !== null) {
32
+ const position = indexToLineColumn(lineStarts, match.index)
33
+ findings.push({
34
+ ruleId: rule.id,
35
+ severity: rule.severity,
36
+ message: rule.message,
37
+ suggestion: rule.suggestion,
38
+ path,
39
+ line: position.line,
40
+ column: position.column,
41
+ match: match[0]
42
+ })
43
+
44
+ if (match[0].length === 0) {
45
+ pattern.lastIndex = pattern.lastIndex + 1
46
+ }
47
+ }
48
+
49
+ return findings
50
+ }
51
+
52
+ const buildByRuleSummary = (findings: ReadonlyArray<V4AuditFinding>): Readonly<Record<string, number>> => {
53
+ const summary = new Map<string, number>()
54
+ for (const finding of findings) {
55
+ summary.set(finding.ruleId, (summary.get(finding.ruleId) ?? 0) + 1)
56
+ }
57
+
58
+ const byRule: Record<string, number> = {}
59
+ for (const [ruleId, count] of [...summary.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
60
+ byRule[ruleId] = count
61
+ }
62
+ return byRule
63
+ }
64
+
65
+ const buildSummary = (
66
+ filesScanned: number,
67
+ findings: ReadonlyArray<V4AuditFinding>
68
+ ): V4AuditSummary => {
69
+ let errorCount = 0
70
+ let warningCount = 0
71
+
72
+ const filesWithFindings = new Set<string>()
73
+ for (const finding of findings) {
74
+ filesWithFindings.add(finding.path)
75
+ if (finding.severity === "error") {
76
+ errorCount++
77
+ } else {
78
+ warningCount++
79
+ }
80
+ }
81
+
82
+ return {
83
+ filesScanned,
84
+ filesWithFindings: filesWithFindings.size,
85
+ findingCount: findings.length,
86
+ errorCount,
87
+ warningCount,
88
+ byRule: buildByRuleSummary(findings)
89
+ }
90
+ }
91
+
92
+ export const analyzeV4Sources = (options: {
93
+ readonly sources: ReadonlyArray<V4AuditSourceFile>
94
+ readonly rules?: ReadonlyArray<V4AuditRule> | undefined
95
+ }): V4AuditReport => {
96
+ const rules = [...(options.rules ?? defaultV4AuditRules)].sort(compareRules)
97
+ const sources = [...options.sources].sort((a, b) => normalizePath(a.path).localeCompare(normalizePath(b.path)))
98
+
99
+ const findings = sources.flatMap((source) => rules.flatMap((rule) => scanSourceForRule(source, rule)))
100
+ findings.sort(compareFindings)
101
+
102
+ return {
103
+ version: 1,
104
+ summary: buildSummary(sources.length, findings),
105
+ findings
106
+ }
107
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFile } from "node:fs/promises"
4
+ import path from "node:path"
5
+ import { glob } from "glob"
6
+ import { analyzeV4Sources } from "./analyze.ts"
7
+ import { renderV4AuditJson, renderV4AuditTable } from "./render.ts"
8
+
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
+ const defaultIgnorePatterns = [
18
+ "**/node_modules/**",
19
+ "**/dist/**",
20
+ "**/coverage/**",
21
+ "**/.git/**",
22
+ "**/.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]
114
+ })
115
+
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 {
123
+ path: toPosix(file),
124
+ 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
+ })
155
+
156
+ process.exitCode = exitCode
157
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { analyzeV4Sources } from "./analyze.ts"
2
+ import { defaultV4AuditRules } from "./rules.ts"
3
+ import { renderV4AuditJson, renderV4AuditTable } from "./render.ts"
4
+
5
+ export * from "./types.ts"
6
+ export { analyzeV4Sources, defaultV4AuditRules, renderV4AuditJson, renderV4AuditTable }
@@ -0,0 +1,47 @@
1
+ export interface LineColumn {
2
+ readonly line: number
3
+ readonly column: number
4
+ }
5
+
6
+ export const computeLineStarts = (content: string): ReadonlyArray<number> => {
7
+ const starts: Array<number> = [0]
8
+
9
+ for (let i = 0; i < content.length; i++) {
10
+ if (content[i] === "\n") {
11
+ starts.push(i + 1)
12
+ }
13
+ }
14
+
15
+ return starts
16
+ }
17
+
18
+ export const indexToLineColumn = (lineStarts: ReadonlyArray<number>, index: number): LineColumn => {
19
+ let low = 0
20
+ let high = lineStarts.length - 1
21
+
22
+ while (low <= high) {
23
+ const mid = Math.floor((low + high) / 2)
24
+ const start = lineStarts[mid]
25
+ const nextStart = mid + 1 < lineStarts.length ? lineStarts[mid + 1] : Number.POSITIVE_INFINITY
26
+
27
+ if (index < start) {
28
+ high = mid - 1
29
+ continue
30
+ }
31
+
32
+ if (index >= nextStart) {
33
+ low = mid + 1
34
+ continue
35
+ }
36
+
37
+ return {
38
+ line: mid + 1,
39
+ column: index - start + 1
40
+ }
41
+ }
42
+
43
+ return {
44
+ line: lineStarts.length,
45
+ column: 1
46
+ }
47
+ }
package/src/render.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type { V4AuditReport } from "./types.ts"
2
+
3
+ export const renderV4AuditJson = (report: V4AuditReport): string => JSON.stringify(report, null, 2)
4
+
5
+ export const renderV4AuditTable = (report: V4AuditReport): string => {
6
+ if (report.summary.findingCount === 0) {
7
+ return `No v4-audit findings. Files scanned: ${report.summary.filesScanned}.`
8
+ }
9
+
10
+ const lines: Array<string> = [
11
+ `Findings: ${report.summary.findingCount} (errors: ${report.summary.errorCount}, warnings: ${report.summary.warningCount})`,
12
+ `Files scanned: ${report.summary.filesScanned}, files with findings: ${report.summary.filesWithFindings}`,
13
+ ""
14
+ ]
15
+
16
+ for (const finding of report.findings) {
17
+ lines.push(`[${finding.severity.toUpperCase()}] ${finding.ruleId} ${finding.path}:${finding.line}:${finding.column}`)
18
+ lines.push(` ${finding.message}`)
19
+ lines.push(` Suggestion: ${finding.suggestion}`)
20
+ lines.push(` Match: ${JSON.stringify(finding.match)}`)
21
+ lines.push("")
22
+ }
23
+
24
+ return lines.join("\n")
25
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,132 @@
1
+ import type { V4AuditRule } from "./types.ts"
2
+
3
+ const errorRule = (
4
+ id: string,
5
+ pattern: RegExp,
6
+ message: string,
7
+ suggestion: string
8
+ ): V4AuditRule => ({
9
+ id,
10
+ severity: "error",
11
+ pattern,
12
+ message,
13
+ suggestion
14
+ })
15
+
16
+ const warningRule = (
17
+ id: string,
18
+ pattern: RegExp,
19
+ message: string,
20
+ suggestion: string
21
+ ): V4AuditRule => ({
22
+ id,
23
+ severity: "warning",
24
+ pattern,
25
+ message,
26
+ suggestion
27
+ })
28
+
29
+ export const defaultV4AuditRules: ReadonlyArray<V4AuditRule> = [
30
+ errorRule(
31
+ "legacy-context-generic-tag",
32
+ /\bContext\.GenericTag\b/g,
33
+ "Context.GenericTag is a v3 API.",
34
+ "Use ServiceMap.Service instead."
35
+ ),
36
+ errorRule(
37
+ "legacy-context-tag",
38
+ /\bContext\.Tag\b/g,
39
+ "Context.Tag is a v3 API.",
40
+ "Use ServiceMap.Service instead."
41
+ ),
42
+ errorRule(
43
+ "legacy-context-reference",
44
+ /\bContext\.Reference\b/g,
45
+ "Context.Reference is a v3 API.",
46
+ "Use ServiceMap.Reference instead."
47
+ ),
48
+ errorRule(
49
+ "legacy-effect-tag",
50
+ /\bEffect\.Tag\s*\(/g,
51
+ "Effect.Tag is a v3 API.",
52
+ "Use ServiceMap.Service class syntax or function syntax."
53
+ ),
54
+ errorRule(
55
+ "legacy-effect-service",
56
+ /\bEffect\.Service\s*\(/g,
57
+ "Effect.Service is a v3 API.",
58
+ "Use ServiceMap.Service plus explicit Layer.effect wiring."
59
+ ),
60
+ errorRule(
61
+ "legacy-fiberref",
62
+ /\bFiberRef\./g,
63
+ "FiberRef APIs were replaced in v4.",
64
+ "Use References.* values or ServiceMap.Reference."
65
+ ),
66
+ errorRule(
67
+ "legacy-scope-extend",
68
+ /\bScope\.extend\b/g,
69
+ "Scope.extend was renamed in v4.",
70
+ "Use Scope.provide."
71
+ ),
72
+ errorRule(
73
+ "legacy-catch-all",
74
+ /\bEffect\.catchAll\b/g,
75
+ "Effect.catchAll was renamed in v4.",
76
+ "Use Effect.catch."
77
+ ),
78
+ errorRule(
79
+ "legacy-catch-all-cause",
80
+ /\bEffect\.catchAllCause\b/g,
81
+ "Effect.catchAllCause was renamed in v4.",
82
+ "Use Effect.catchCause."
83
+ ),
84
+ errorRule(
85
+ "legacy-catch-all-defect",
86
+ /\bEffect\.catchAllDefect\b/g,
87
+ "Effect.catchAllDefect was renamed in v4.",
88
+ "Use Effect.catchDefect."
89
+ ),
90
+ errorRule(
91
+ "legacy-catch-some",
92
+ /\bEffect\.catchSome\b/g,
93
+ "Effect.catchSome was renamed in v4.",
94
+ "Use Effect.catchFilter."
95
+ ),
96
+ errorRule(
97
+ "legacy-catch-some-cause",
98
+ /\bEffect\.catchSomeCause\b/g,
99
+ "Effect.catchSomeCause was renamed in v4.",
100
+ "Use Effect.catchCauseFilter."
101
+ ),
102
+ errorRule(
103
+ "legacy-catch-some-defect",
104
+ /\bEffect\.catchSomeDefect\b/g,
105
+ "Effect.catchSomeDefect was removed in v4.",
106
+ "Use Effect.catchDefect or Effect.catchCause depending on intent."
107
+ ),
108
+ errorRule(
109
+ "legacy-fork",
110
+ /\bEffect\.fork\s*\(/g,
111
+ "Effect.fork was renamed in v4.",
112
+ "Use Effect.forkChild."
113
+ ),
114
+ errorRule(
115
+ "legacy-fork-daemon",
116
+ /\bEffect\.forkDaemon\b/g,
117
+ "Effect.forkDaemon was renamed in v4.",
118
+ "Use Effect.forkDetach."
119
+ ),
120
+ errorRule(
121
+ "legacy-equal-equivalence",
122
+ /\bEqual\.equivalence\b/g,
123
+ "Equal.equivalence was renamed in v4.",
124
+ "Use Equal.asEquivalence."
125
+ ),
126
+ warningRule(
127
+ "unstable-effect-import",
128
+ /from\s+["']effect\/unstable\/[^"']+["']/g,
129
+ "Unstable module import detected.",
130
+ "Treat unstable APIs as non-semver-stable and isolate usage."
131
+ )
132
+ ]
package/src/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type V4AuditSeverity = "error" | "warning"
2
+
3
+ export interface V4AuditRule {
4
+ readonly id: string
5
+ readonly severity: V4AuditSeverity
6
+ readonly pattern: RegExp
7
+ readonly message: string
8
+ readonly suggestion: string
9
+ }
10
+
11
+ export interface V4AuditSourceFile {
12
+ readonly path: string
13
+ readonly content: string
14
+ }
15
+
16
+ export interface V4AuditFinding {
17
+ readonly ruleId: string
18
+ readonly severity: V4AuditSeverity
19
+ readonly message: string
20
+ readonly suggestion: string
21
+ readonly path: string
22
+ readonly line: number
23
+ readonly column: number
24
+ readonly match: string
25
+ }
26
+
27
+ export interface V4AuditSummary {
28
+ readonly filesScanned: number
29
+ readonly filesWithFindings: number
30
+ readonly findingCount: number
31
+ readonly errorCount: number
32
+ readonly warningCount: number
33
+ readonly byRule: Readonly<Record<string, number>>
34
+ }
35
+
36
+ export interface V4AuditReport {
37
+ readonly version: 1
38
+ readonly summary: V4AuditSummary
39
+ readonly findings: ReadonlyArray<V4AuditFinding>
40
+ }