@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.
@@ -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
+ })