@prover-coder-ai/dist-deps-prune 1.0.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/.jscpd.json +16 -0
- package/CHANGELOG.md +13 -0
- package/README.md +41 -0
- package/biome.json +34 -0
- package/eslint.config.mts +305 -0
- package/eslint.effect-ts-check.config.mjs +220 -0
- package/linter.config.json +33 -0
- package/package.json +92 -0
- package/src/app/main.ts +28 -0
- package/src/app/program.ts +220 -0
- package/src/core/cli.ts +272 -0
- package/src/core/config.ts +86 -0
- package/src/core/errors.ts +66 -0
- package/src/core/glob.ts +87 -0
- package/src/core/json.ts +20 -0
- package/src/core/normalize.ts +90 -0
- package/src/core/package-json.ts +127 -0
- package/src/core/parse.ts +210 -0
- package/src/core/prune.ts +138 -0
- package/src/core/report.ts +145 -0
- package/src/core/types.ts +41 -0
- package/src/shell/builtins.ts +32 -0
- package/src/shell/command.ts +114 -0
- package/src/shell/config-file.ts +71 -0
- package/src/shell/package-json.ts +80 -0
- package/src/shell/release.ts +110 -0
- package/src/shell/scan.ts +180 -0
- package/tests/app/component-tagger.integration.test.ts +143 -0
- package/tests/app/release.test.ts +39 -0
- package/tests/app/test-helpers.ts +24 -0
- package/tests/core/parse.test.ts +91 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +32 -0
- package/vitest.config.ts +85 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { DependencyMap, PackageJson } from "./package-json.js"
|
|
2
|
+
import { omitDependencyFields } from "./package-json.js"
|
|
3
|
+
import type { UnusedByKind } from "./types.js"
|
|
4
|
+
|
|
5
|
+
// CHANGE: implement dependency pruning plan calculation
|
|
6
|
+
// WHY: provide deterministic diff generation and conservative safety mode
|
|
7
|
+
// QUOTE(TZ): "Удалять из dependencies всё, что отсутствует в USED"
|
|
8
|
+
// REF: req-prune-1
|
|
9
|
+
// SOURCE: n/a
|
|
10
|
+
// FORMAT THEOREM: ∀d ∈ prunable: d ∉ USED ∧ d ∉ KEEP
|
|
11
|
+
// PURITY: CORE
|
|
12
|
+
// EFFECT: n/a
|
|
13
|
+
// INVARIANT: peerDependencies are never removed
|
|
14
|
+
// COMPLEXITY: O(n) where n = total dependencies
|
|
15
|
+
|
|
16
|
+
export interface PruneOptions {
|
|
17
|
+
readonly used: ReadonlySet<string>
|
|
18
|
+
readonly keep: ReadonlySet<string>
|
|
19
|
+
readonly pruneDev: boolean
|
|
20
|
+
readonly pruneOptional: boolean
|
|
21
|
+
readonly conservative: boolean
|
|
22
|
+
readonly hasUncertainty: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface PrunePlan {
|
|
26
|
+
readonly unused: UnusedByKind
|
|
27
|
+
readonly prunable: UnusedByKind
|
|
28
|
+
readonly keptByRule: ReadonlyArray<string>
|
|
29
|
+
readonly nextPackageJson: PackageJson
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const emptyUnused: UnusedByKind = {
|
|
33
|
+
dependencies: [],
|
|
34
|
+
devDependencies: [],
|
|
35
|
+
optionalDependencies: [],
|
|
36
|
+
peerDependencies: []
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const listKeys = (map: DependencyMap | undefined): ReadonlyArray<string> => map === undefined ? [] : Object.keys(map)
|
|
40
|
+
|
|
41
|
+
const filterMap = (
|
|
42
|
+
map: DependencyMap | undefined,
|
|
43
|
+
remove: ReadonlySet<string>
|
|
44
|
+
): DependencyMap | undefined => {
|
|
45
|
+
if (map === undefined) {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
const entries = Object.entries(map).filter(([name]) => !remove.has(name))
|
|
49
|
+
if (entries.length === 0) {
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
const result: Record<string, string> = {}
|
|
53
|
+
for (const [name, version] of entries) {
|
|
54
|
+
result[name] = version
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const computeUnused = (
|
|
60
|
+
map: DependencyMap | undefined,
|
|
61
|
+
used: ReadonlySet<string>,
|
|
62
|
+
keep: ReadonlySet<string>
|
|
63
|
+
): ReadonlyArray<string> => listKeys(map).filter((name) => !used.has(name) && !keep.has(name))
|
|
64
|
+
|
|
65
|
+
const computeUnusedByKind = (pkg: PackageJson, options: PruneOptions): UnusedByKind => ({
|
|
66
|
+
dependencies: computeUnused(pkg.dependencies, options.used, options.keep),
|
|
67
|
+
devDependencies: computeUnused(pkg.devDependencies, options.used, options.keep),
|
|
68
|
+
optionalDependencies: computeUnused(pkg.optionalDependencies, options.used, options.keep),
|
|
69
|
+
peerDependencies: computeUnused(pkg.peerDependencies, options.used, options.keep)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const computePrunableByKind = (unused: UnusedByKind, options: PruneOptions): UnusedByKind => {
|
|
73
|
+
const hasUncertainty = options.conservative && options.hasUncertainty
|
|
74
|
+
return {
|
|
75
|
+
...emptyUnused,
|
|
76
|
+
dependencies: hasUncertainty ? [] : unused.dependencies,
|
|
77
|
+
devDependencies: options.pruneDev && !hasUncertainty ? unused.devDependencies : [],
|
|
78
|
+
optionalDependencies: options.pruneOptional && !hasUncertainty ? unused.optionalDependencies : [],
|
|
79
|
+
peerDependencies: []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const computeKeptByRule = (
|
|
84
|
+
unused: UnusedByKind,
|
|
85
|
+
prunable: UnusedByKind,
|
|
86
|
+
options: PruneOptions
|
|
87
|
+
): ReadonlyArray<string> => {
|
|
88
|
+
const keptExplicit = [...options.keep].filter((name) => !options.used.has(name))
|
|
89
|
+
return [
|
|
90
|
+
...keptExplicit,
|
|
91
|
+
...unused.dependencies.filter((name) => !prunable.dependencies.includes(name)),
|
|
92
|
+
...unused.devDependencies.filter((name) => !prunable.devDependencies.includes(name)),
|
|
93
|
+
...unused.optionalDependencies.filter((name) => !prunable.optionalDependencies.includes(name)),
|
|
94
|
+
...unused.peerDependencies
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const buildNextPackageJson = (pkg: PackageJson, prunable: UnusedByKind): PackageJson => {
|
|
99
|
+
const removeDependencies = new Set(prunable.dependencies)
|
|
100
|
+
const removeDev = new Set(prunable.devDependencies)
|
|
101
|
+
const removeOptional = new Set(prunable.optionalDependencies)
|
|
102
|
+
const nextDependencies = filterMap(pkg.dependencies, removeDependencies)
|
|
103
|
+
const nextDevDependencies = filterMap(pkg.devDependencies, removeDev)
|
|
104
|
+
const nextOptionalDependencies = filterMap(pkg.optionalDependencies, removeOptional)
|
|
105
|
+
const base = omitDependencyFields(pkg)
|
|
106
|
+
return {
|
|
107
|
+
...base,
|
|
108
|
+
...(nextDependencies === undefined ? {} : { dependencies: nextDependencies }),
|
|
109
|
+
...(nextDevDependencies === undefined ? {} : { devDependencies: nextDevDependencies }),
|
|
110
|
+
...(nextOptionalDependencies === undefined ? {} : { optionalDependencies: nextOptionalDependencies }),
|
|
111
|
+
...(pkg.peerDependencies === undefined ? {} : { peerDependencies: pkg.peerDependencies })
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the prune plan (analysis + actual removal) for a package.json.
|
|
117
|
+
*
|
|
118
|
+
* @param pkg - Parsed package.json.
|
|
119
|
+
* @param options - Prune options (used set, keep rules, mode flags).
|
|
120
|
+
* @returns PrunePlan with unused, prunable, and next package.json.
|
|
121
|
+
*
|
|
122
|
+
* @pure true
|
|
123
|
+
* @invariant peerDependencies remain unchanged
|
|
124
|
+
* @complexity O(n)
|
|
125
|
+
*/
|
|
126
|
+
export const buildPrunePlan = (pkg: PackageJson, options: PruneOptions): PrunePlan => {
|
|
127
|
+
const unused = computeUnusedByKind(pkg, options)
|
|
128
|
+
const prunable = computePrunableByKind(unused, options)
|
|
129
|
+
const keptByRule = computeKeptByRule(unused, prunable, options)
|
|
130
|
+
const nextPackageJson = buildNextPackageJson(pkg, prunable)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
unused,
|
|
134
|
+
prunable,
|
|
135
|
+
keptByRule,
|
|
136
|
+
nextPackageJson
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Match } from "effect"
|
|
2
|
+
import type { PrunePlan } from "./prune.js"
|
|
3
|
+
import type { Report, ScanOutcome, UnusedByKind, Warning } from "./types.js"
|
|
4
|
+
|
|
5
|
+
// CHANGE: build structured reports and render output formats
|
|
6
|
+
// WHY: keep reporting pure and deterministic across CLI modes
|
|
7
|
+
// QUOTE(TZ): "Отчёт в stdout (человекочитаемый)"
|
|
8
|
+
// REF: req-report-1
|
|
9
|
+
// SOURCE: n/a
|
|
10
|
+
// FORMAT THEOREM: ∀r: report(r).used is sorted
|
|
11
|
+
// PURITY: CORE
|
|
12
|
+
// EFFECT: n/a
|
|
13
|
+
// INVARIANT: Report.used is unique and sorted
|
|
14
|
+
// COMPLEXITY: O(n log n)
|
|
15
|
+
|
|
16
|
+
const compareStrings = (left: string, right: string): number => left.localeCompare(right)
|
|
17
|
+
|
|
18
|
+
const mergeSorted = (
|
|
19
|
+
left: ReadonlyArray<string>,
|
|
20
|
+
right: ReadonlyArray<string>
|
|
21
|
+
): ReadonlyArray<string> => {
|
|
22
|
+
if (left.length === 0) {
|
|
23
|
+
return right
|
|
24
|
+
}
|
|
25
|
+
if (right.length === 0) {
|
|
26
|
+
return left
|
|
27
|
+
}
|
|
28
|
+
const leftHead = left[0] ?? ""
|
|
29
|
+
const rightHead = right[0] ?? ""
|
|
30
|
+
if (compareStrings(leftHead, rightHead) <= 0) {
|
|
31
|
+
return [leftHead, ...mergeSorted(left.slice(1), right)]
|
|
32
|
+
}
|
|
33
|
+
return [rightHead, ...mergeSorted(left, right.slice(1))]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const mergeSort = (values: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
37
|
+
if (values.length <= 1) {
|
|
38
|
+
return [...values]
|
|
39
|
+
}
|
|
40
|
+
const mid = Math.floor(values.length / 2)
|
|
41
|
+
const left = mergeSort(values.slice(0, mid))
|
|
42
|
+
const right = mergeSort(values.slice(mid))
|
|
43
|
+
return mergeSorted(left, right)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const sortUnique = (values: ReadonlyArray<string>): ReadonlyArray<string> => mergeSort([...new Set(values)])
|
|
47
|
+
|
|
48
|
+
const sortUnused = (unused: UnusedByKind): UnusedByKind => ({
|
|
49
|
+
dependencies: sortUnique(unused.dependencies),
|
|
50
|
+
devDependencies: sortUnique(unused.devDependencies),
|
|
51
|
+
optionalDependencies: sortUnique(unused.optionalDependencies),
|
|
52
|
+
peerDependencies: sortUnique(unused.peerDependencies)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build a Report object from scan and prune results.
|
|
57
|
+
*
|
|
58
|
+
* @param scan - Scan outcome with used set and warnings.
|
|
59
|
+
* @param plan - Prune plan with unused lists and keptByRule.
|
|
60
|
+
* @returns Report ready for output.
|
|
61
|
+
*
|
|
62
|
+
* @pure true
|
|
63
|
+
* @invariant report.used is sorted and unique
|
|
64
|
+
* @complexity O(n log n)
|
|
65
|
+
*/
|
|
66
|
+
export const buildReport = (scan: ScanOutcome, plan: PrunePlan): Report => ({
|
|
67
|
+
used: sortUnique([...scan.used]),
|
|
68
|
+
unused: sortUnused(plan.unused),
|
|
69
|
+
keptByRule: sortUnique(plan.keptByRule),
|
|
70
|
+
warnings: scan.warnings,
|
|
71
|
+
stats: scan.stats
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const formatWarning = (warning: Warning): string =>
|
|
75
|
+
Match.value(warning).pipe(
|
|
76
|
+
Match.when({ type: "dynamic-import" }, (value) => `[dynamic-import] ${value.file}: ${value.expr}`),
|
|
77
|
+
Match.when({ type: "dynamic-require" }, (value) => `[dynamic-require] ${value.file}: ${value.expr}`),
|
|
78
|
+
Match.when({ type: "parse-error" }, (value) => `[parse-error] ${value.file}: ${value.error}`),
|
|
79
|
+
Match.exhaustive
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
const formatList = (title: string, values: ReadonlyArray<string>): ReadonlyArray<string> => {
|
|
83
|
+
if (values.length === 0) {
|
|
84
|
+
return [`${title}: (none)`]
|
|
85
|
+
}
|
|
86
|
+
return [title + ":", ...values.map((value) => ` - ${value}`)]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render a human-readable report.
|
|
91
|
+
*
|
|
92
|
+
* @param report - Report data.
|
|
93
|
+
* @returns Multi-line string for stdout.
|
|
94
|
+
*
|
|
95
|
+
* @pure true
|
|
96
|
+
* @invariant output lists all required sections
|
|
97
|
+
* @complexity O(n)
|
|
98
|
+
*/
|
|
99
|
+
export const renderHumanReport = (report: Report): string => {
|
|
100
|
+
const lines = [
|
|
101
|
+
...formatList("USED", report.used),
|
|
102
|
+
...formatList("Unused dependencies", report.unused.dependencies),
|
|
103
|
+
...formatList("Unused devDependencies", report.unused.devDependencies),
|
|
104
|
+
...formatList("Unused optionalDependencies", report.unused.optionalDependencies),
|
|
105
|
+
...formatList("Peer dependencies (reported only)", report.unused.peerDependencies)
|
|
106
|
+
]
|
|
107
|
+
const warningLines = report.warnings.length === 0
|
|
108
|
+
? ["Warnings: (none)"]
|
|
109
|
+
: [
|
|
110
|
+
"Warnings:",
|
|
111
|
+
...report.warnings
|
|
112
|
+
.map((warning) => formatWarning(warning))
|
|
113
|
+
.map((line) => ` - ${line}`)
|
|
114
|
+
]
|
|
115
|
+
const statsLines = [
|
|
116
|
+
`Stats: filesScanned=${report.stats.filesScanned}, importsFound=${report.stats.importsFound}`
|
|
117
|
+
]
|
|
118
|
+
return [...lines, ...warningLines, ...statsLines].join("\n")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Render report as JSON text.
|
|
123
|
+
*
|
|
124
|
+
* @param report - Report data.
|
|
125
|
+
* @returns JSON string.
|
|
126
|
+
*
|
|
127
|
+
* @pure true
|
|
128
|
+
* @invariant output matches the JSON schema described in the spec
|
|
129
|
+
* @complexity O(n)
|
|
130
|
+
*/
|
|
131
|
+
export const renderJsonReport = (report: Report): string =>
|
|
132
|
+
JSON.stringify(
|
|
133
|
+
{
|
|
134
|
+
used: report.used,
|
|
135
|
+
unused: {
|
|
136
|
+
dependencies: report.unused.dependencies,
|
|
137
|
+
devDependencies: report.unused.devDependencies
|
|
138
|
+
},
|
|
139
|
+
keptByRule: report.keptByRule,
|
|
140
|
+
warnings: report.warnings,
|
|
141
|
+
stats: report.stats
|
|
142
|
+
},
|
|
143
|
+
null,
|
|
144
|
+
2
|
|
145
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// CHANGE: define core domain types for scan results, warnings, and reports
|
|
2
|
+
// WHY: keep IO-free data structures reusable across CLI modes and tests
|
|
3
|
+
// QUOTE(TZ): "Генерирует отчёт и (по флагу) удаляет неиспользуемые зависимости."
|
|
4
|
+
// REF: req-report-types-1
|
|
5
|
+
// SOURCE: n/a
|
|
6
|
+
// FORMAT THEOREM: ∀r ∈ Report: r.used ⊆ Packages ∧ r.unused.dependencies ⊆ Packages
|
|
7
|
+
// PURITY: CORE
|
|
8
|
+
// EFFECT: n/a
|
|
9
|
+
// INVARIANT: warning.type ∈ {"dynamic-import","dynamic-require","parse-error"}
|
|
10
|
+
// COMPLEXITY: O(1)/O(1)
|
|
11
|
+
|
|
12
|
+
export type Warning =
|
|
13
|
+
| { readonly type: "dynamic-import"; readonly file: string; readonly expr: string }
|
|
14
|
+
| { readonly type: "dynamic-require"; readonly file: string; readonly expr: string }
|
|
15
|
+
| { readonly type: "parse-error"; readonly file: string; readonly error: string }
|
|
16
|
+
|
|
17
|
+
export interface ScanStats {
|
|
18
|
+
readonly filesScanned: number
|
|
19
|
+
readonly importsFound: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ScanOutcome {
|
|
23
|
+
readonly used: ReadonlySet<string>
|
|
24
|
+
readonly warnings: ReadonlyArray<Warning>
|
|
25
|
+
readonly stats: ScanStats
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UnusedByKind {
|
|
29
|
+
readonly dependencies: ReadonlyArray<string>
|
|
30
|
+
readonly devDependencies: ReadonlyArray<string>
|
|
31
|
+
readonly optionalDependencies: ReadonlyArray<string>
|
|
32
|
+
readonly peerDependencies: ReadonlyArray<string>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Report {
|
|
36
|
+
readonly used: ReadonlyArray<string>
|
|
37
|
+
readonly unused: UnusedByKind
|
|
38
|
+
readonly keptByRule: ReadonlyArray<string>
|
|
39
|
+
readonly warnings: ReadonlyArray<Warning>
|
|
40
|
+
readonly stats: ScanStats
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
|
|
3
|
+
import type { AppError } from "../core/errors.js"
|
|
4
|
+
import { fileError } from "../core/errors.js"
|
|
5
|
+
|
|
6
|
+
// CHANGE: provide Node builtin module set for filtering
|
|
7
|
+
// WHY: builtins must be excluded from USED dependency detection
|
|
8
|
+
// QUOTE(TZ): "Node builtins: fs, path, url, node:fs и т.д."
|
|
9
|
+
// REF: req-builtins-1
|
|
10
|
+
// SOURCE: n/a
|
|
11
|
+
// FORMAT THEOREM: ∀b ∈ builtins: b does not represent an external package
|
|
12
|
+
// PURITY: SHELL
|
|
13
|
+
// EFFECT: Effect<ReadonlySet<string>, AppError, never>
|
|
14
|
+
// INVARIANT: node: prefix is stripped
|
|
15
|
+
// COMPLEXITY: O(n)
|
|
16
|
+
|
|
17
|
+
export const loadBuiltinModules: Effect.Effect<ReadonlySet<string>, AppError> = Effect.tryPromise({
|
|
18
|
+
try: () => import("node:module"),
|
|
19
|
+
catch: (error) => fileError(String(error))
|
|
20
|
+
}).pipe(
|
|
21
|
+
Effect.map((module) => {
|
|
22
|
+
const result = new Set<string>()
|
|
23
|
+
for (const name of module.builtinModules) {
|
|
24
|
+
if (name.startsWith("node:")) {
|
|
25
|
+
result.add(name.slice("node:".length))
|
|
26
|
+
} else {
|
|
27
|
+
result.add(name)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return result
|
|
31
|
+
})
|
|
32
|
+
)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as Command from "@effect/platform/Command"
|
|
2
|
+
import type { CommandExecutor } from "@effect/platform/CommandExecutor"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as Either from "effect/Either"
|
|
5
|
+
import { pipe } from "effect/Function"
|
|
6
|
+
|
|
7
|
+
import type { AppError } from "../core/errors.js"
|
|
8
|
+
import { fileError } from "../core/errors.js"
|
|
9
|
+
|
|
10
|
+
// CHANGE: run external command with Effect CommandExecutor
|
|
11
|
+
// WHY: enable release mode with typed exit code handling
|
|
12
|
+
// QUOTE(TZ): "Запускает команду из --command"
|
|
13
|
+
// REF: req-command-1
|
|
14
|
+
// SOURCE: n/a
|
|
15
|
+
// FORMAT THEOREM: ∀c: run(c) = code → code ∈ ℕ
|
|
16
|
+
// PURITY: SHELL
|
|
17
|
+
// EFFECT: Effect<number, AppError, CommandExecutor>
|
|
18
|
+
// INVARIANT: stdout/stderr are inherited
|
|
19
|
+
// COMPLEXITY: O(n)
|
|
20
|
+
|
|
21
|
+
type QuoteState = "\"" | "'" | null
|
|
22
|
+
|
|
23
|
+
interface ParseStep {
|
|
24
|
+
readonly current: string
|
|
25
|
+
readonly quote: QuoteState
|
|
26
|
+
readonly index: number
|
|
27
|
+
readonly pushToken: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const consumeQuoted = (
|
|
31
|
+
input: string,
|
|
32
|
+
index: number,
|
|
33
|
+
current: string,
|
|
34
|
+
quote: QuoteState
|
|
35
|
+
): ParseStep => {
|
|
36
|
+
const char = input.charAt(index)
|
|
37
|
+
const nextChar = input.charAt(index + 1)
|
|
38
|
+
if (quote !== null && char === quote) {
|
|
39
|
+
return { current, quote: null, index: index + 1, pushToken: false }
|
|
40
|
+
}
|
|
41
|
+
if (quote === "\"" && char === "\\" && nextChar.length > 0) {
|
|
42
|
+
return { current: current + nextChar, quote, index: index + 2, pushToken: false }
|
|
43
|
+
}
|
|
44
|
+
return { current: current + char, quote, index: index + 1, pushToken: false }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const consumeUnquoted = (input: string, index: number, current: string): ParseStep => {
|
|
48
|
+
const char = input.charAt(index)
|
|
49
|
+
const nextChar = input.charAt(index + 1)
|
|
50
|
+
if (char === "\"" || char === "'") {
|
|
51
|
+
return { current, quote: char, index: index + 1, pushToken: false }
|
|
52
|
+
}
|
|
53
|
+
if (char.trim().length === 0) {
|
|
54
|
+
return { current: "", quote: null, index: index + 1, pushToken: current.length > 0 }
|
|
55
|
+
}
|
|
56
|
+
if (char === "\\" && nextChar.length > 0) {
|
|
57
|
+
return { current: current + nextChar, quote: null, index: index + 2, pushToken: false }
|
|
58
|
+
}
|
|
59
|
+
return { current: current + char, quote: null, index: index + 1, pushToken: false }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const splitCommandLine = (input: string): Either.Either<Array<string>, AppError> => {
|
|
63
|
+
let result: Array<string> = []
|
|
64
|
+
let current = ""
|
|
65
|
+
let quote: QuoteState = null
|
|
66
|
+
let index = 0
|
|
67
|
+
while (index < input.length) {
|
|
68
|
+
const step: ParseStep = quote
|
|
69
|
+
? consumeQuoted(input, index, current, quote)
|
|
70
|
+
: consumeUnquoted(input, index, current)
|
|
71
|
+
if (step.pushToken) {
|
|
72
|
+
result = [...result, current]
|
|
73
|
+
}
|
|
74
|
+
current = step.current
|
|
75
|
+
quote = step.quote
|
|
76
|
+
index = step.index
|
|
77
|
+
}
|
|
78
|
+
if (quote !== null) {
|
|
79
|
+
return Either.left(fileError("Unterminated quote in --command"))
|
|
80
|
+
}
|
|
81
|
+
if (current.length > 0) {
|
|
82
|
+
result = [...result, current]
|
|
83
|
+
}
|
|
84
|
+
return Either.right(result)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const runCommand = (
|
|
88
|
+
commandLine: string,
|
|
89
|
+
cwd: string
|
|
90
|
+
): Effect.Effect<number, AppError, CommandExecutor> =>
|
|
91
|
+
Effect.gen(function*(_) {
|
|
92
|
+
const parts = splitCommandLine(commandLine)
|
|
93
|
+
if (Either.isLeft(parts)) {
|
|
94
|
+
return yield* _(Effect.fail(parts.left))
|
|
95
|
+
}
|
|
96
|
+
if (parts.right.length === 0) {
|
|
97
|
+
return yield* _(Effect.fail(fileError("Empty --command")))
|
|
98
|
+
}
|
|
99
|
+
const [cmd, ...args] = parts.right
|
|
100
|
+
if (cmd === undefined) {
|
|
101
|
+
return yield* _(Effect.fail(fileError("Empty --command")))
|
|
102
|
+
}
|
|
103
|
+
const command = pipe(
|
|
104
|
+
Command.make(cmd, ...args),
|
|
105
|
+
Command.stdin("inherit"),
|
|
106
|
+
Command.stdout("inherit"),
|
|
107
|
+
Command.stderr("inherit"),
|
|
108
|
+
Command.workingDirectory(cwd)
|
|
109
|
+
)
|
|
110
|
+
const exitCode = yield* _(
|
|
111
|
+
Command.exitCode(command).pipe(Effect.mapError((error) => fileError(String(error))))
|
|
112
|
+
)
|
|
113
|
+
return Number(exitCode)
|
|
114
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { FileSystem as FileSystemService } from "@effect/platform/FileSystem"
|
|
2
|
+
import { FileSystem } from "@effect/platform/FileSystem"
|
|
3
|
+
import * as S from "@effect/schema/Schema"
|
|
4
|
+
import * as TreeFormatter from "@effect/schema/TreeFormatter"
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import { pipe } from "effect/Function"
|
|
7
|
+
|
|
8
|
+
import type { FileConfig } from "../core/config.js"
|
|
9
|
+
import type { AppError } from "../core/errors.js"
|
|
10
|
+
import { configError, fileError } from "../core/errors.js"
|
|
11
|
+
|
|
12
|
+
// CHANGE: decode .dist-deps-prune.json with schema validation
|
|
13
|
+
// WHY: keep boundary data typed and reject invalid config early
|
|
14
|
+
// QUOTE(TZ): "поддержать файл .dist-deps-prune.json"
|
|
15
|
+
// REF: req-config-file-1
|
|
16
|
+
// SOURCE: n/a
|
|
17
|
+
// FORMAT THEOREM: ∀c: decode(c) = Right(cfg) → cfg fields have correct types
|
|
18
|
+
// PURITY: SHELL
|
|
19
|
+
// EFFECT: Effect<FileConfig | undefined, AppError, FileSystem>
|
|
20
|
+
// INVARIANT: missing config yields undefined
|
|
21
|
+
// COMPLEXITY: O(n)
|
|
22
|
+
|
|
23
|
+
const RawConfigSchema = S.partial(
|
|
24
|
+
S.Struct({
|
|
25
|
+
keep: S.Array(S.String),
|
|
26
|
+
ignorePatterns: S.Array(S.String),
|
|
27
|
+
pruneDev: S.Boolean,
|
|
28
|
+
pruneOptional: S.Boolean,
|
|
29
|
+
patterns: S.Array(S.String)
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const ConfigSchema = S.parseJson(RawConfigSchema)
|
|
34
|
+
|
|
35
|
+
const decodeConfig = (raw: string): Effect.Effect<FileConfig, AppError> =>
|
|
36
|
+
pipe(
|
|
37
|
+
S.decodeUnknown(ConfigSchema)(raw),
|
|
38
|
+
Effect.map((config) => ({
|
|
39
|
+
...(config.keep === undefined ? {} : { keep: config.keep }),
|
|
40
|
+
...(config.ignorePatterns === undefined ? {} : { ignorePatterns: config.ignorePatterns }),
|
|
41
|
+
...(config.pruneDev === undefined ? {} : { pruneDev: config.pruneDev }),
|
|
42
|
+
...(config.pruneOptional === undefined ? {} : { pruneOptional: config.pruneOptional }),
|
|
43
|
+
...(config.patterns === undefined ? {} : { patterns: config.patterns })
|
|
44
|
+
})),
|
|
45
|
+
Effect.mapError((error) => configError(TreeFormatter.formatErrorSync(error)))
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
export const loadConfigFile = (
|
|
49
|
+
path: string | undefined,
|
|
50
|
+
explicit: boolean
|
|
51
|
+
): Effect.Effect<FileConfig | undefined, AppError, FileSystemService> =>
|
|
52
|
+
Effect.gen(function*(_) {
|
|
53
|
+
if (path === undefined) {
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const fs = yield* _(FileSystem)
|
|
57
|
+
const exists = yield* _(
|
|
58
|
+
fs.exists(path).pipe(Effect.mapError((error) => fileError(String(error))))
|
|
59
|
+
)
|
|
60
|
+
if (!exists) {
|
|
61
|
+
if (explicit) {
|
|
62
|
+
return yield* _(Effect.fail(fileError(`Config file not found: ${path}`)))
|
|
63
|
+
}
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const contents = yield* _(
|
|
67
|
+
fs.readFileString(path).pipe(Effect.mapError((error) => fileError(String(error))))
|
|
68
|
+
)
|
|
69
|
+
const decoded = yield* _(decodeConfig(contents))
|
|
70
|
+
return decoded
|
|
71
|
+
})
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { FileSystem as FileSystemService } from "@effect/platform/FileSystem"
|
|
2
|
+
import { FileSystem } from "@effect/platform/FileSystem"
|
|
3
|
+
import * as Schema from "@effect/schema/Schema"
|
|
4
|
+
import * as TreeFormatter from "@effect/schema/TreeFormatter"
|
|
5
|
+
import * as Effect from "effect/Effect"
|
|
6
|
+
import { pipe } from "effect/Function"
|
|
7
|
+
|
|
8
|
+
import type { AppError } from "../core/errors.js"
|
|
9
|
+
import { fileError } from "../core/errors.js"
|
|
10
|
+
import type { Json } from "../core/json.js"
|
|
11
|
+
import { decodePackageJson, isJsonObject } from "../core/package-json.js"
|
|
12
|
+
import type { PackageJson } from "../core/package-json.js"
|
|
13
|
+
|
|
14
|
+
// CHANGE: provide package.json read/write helpers with validation
|
|
15
|
+
// WHY: isolate filesystem IO while keeping typed dependency maps
|
|
16
|
+
// QUOTE(TZ): "package.json текущего пакета"
|
|
17
|
+
// REF: req-pkg-io-1
|
|
18
|
+
// SOURCE: n/a
|
|
19
|
+
// FORMAT THEOREM: ∀p: read(p) = Right(pkg) → pkg.dependencies values are strings
|
|
20
|
+
// PURITY: SHELL
|
|
21
|
+
// EFFECT: Effect<PackageJson, AppError, FileSystem>
|
|
22
|
+
// INVARIANT: JSON is validated before use
|
|
23
|
+
// COMPLEXITY: O(n)
|
|
24
|
+
|
|
25
|
+
const JsonSchema: Schema.Schema<Json> = Schema.suspend(() =>
|
|
26
|
+
Schema.Union(
|
|
27
|
+
Schema.Null,
|
|
28
|
+
Schema.Boolean,
|
|
29
|
+
Schema.Number,
|
|
30
|
+
Schema.String,
|
|
31
|
+
Schema.Array(JsonSchema),
|
|
32
|
+
Schema.Record({ key: Schema.String, value: JsonSchema })
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const JsonParseSchema = Schema.parseJson(JsonSchema)
|
|
37
|
+
|
|
38
|
+
const parseJson = (raw: string): Effect.Effect<Json, AppError> =>
|
|
39
|
+
pipe(
|
|
40
|
+
Schema.decodeUnknown(JsonParseSchema)(raw),
|
|
41
|
+
Effect.mapError((error) => fileError(TreeFormatter.formatErrorSync(error)))
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
export const readPackageJsonRaw = (
|
|
45
|
+
path: string
|
|
46
|
+
): Effect.Effect<string, AppError, FileSystemService> =>
|
|
47
|
+
Effect.gen(function*(_) {
|
|
48
|
+
const fs = yield* _(FileSystem)
|
|
49
|
+
return yield* _(
|
|
50
|
+
fs.readFileString(path).pipe(Effect.mapError((error) => fileError(String(error))))
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export const readPackageJson = (
|
|
55
|
+
path: string
|
|
56
|
+
): Effect.Effect<PackageJson, AppError, FileSystemService> =>
|
|
57
|
+
Effect.gen(function*(_) {
|
|
58
|
+
const raw = yield* _(readPackageJsonRaw(path))
|
|
59
|
+
const parsed = yield* _(parseJson(raw))
|
|
60
|
+
if (!isJsonObject(parsed)) {
|
|
61
|
+
return yield* _(Effect.fail(fileError("package.json must be an object")))
|
|
62
|
+
}
|
|
63
|
+
const decoded = decodePackageJson(parsed)
|
|
64
|
+
if (decoded._tag === "Left") {
|
|
65
|
+
return yield* _(Effect.fail(fileError(decoded.left.message)))
|
|
66
|
+
}
|
|
67
|
+
return decoded.right
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
export const writePackageJson = (
|
|
71
|
+
path: string,
|
|
72
|
+
pkg: PackageJson
|
|
73
|
+
): Effect.Effect<void, AppError, FileSystemService> =>
|
|
74
|
+
Effect.gen(function*(_) {
|
|
75
|
+
const fs = yield* _(FileSystem)
|
|
76
|
+
const payload = JSON.stringify(pkg, null, 2) + "\n"
|
|
77
|
+
yield* _(
|
|
78
|
+
fs.writeFileString(path, payload).pipe(Effect.mapError((error) => fileError(String(error))))
|
|
79
|
+
)
|
|
80
|
+
})
|