prjct-cli 1.7.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.7.1] - 2026-02-07
4
+
5
+ ### Bug Fixes
6
+
7
+ - add Zod validation on all storage reads (PRJ-279) (#140)
8
+
9
+
10
+ ## [1.7.1] - 2026-02-07
11
+
12
+ ### Bug Fix
13
+ - **Add Zod validation on all storage reads (PRJ-279)**: Created `safeRead<T>()` utility that wraps `JSON.parse` + `schema.safeParse()`. All 5 `StorageManager` subclasses (state, queue, ideas, shipped, metrics) now validate reads against their Zod schemas. Corrupted files produce a logged warning + `.backup` file instead of silently crashing downstream.
14
+
15
+ ### Implementation Details
16
+ Created `core/storage/safe-reader.ts` with a `ValidationSchema` interface decoupled from Zod generics to avoid strict type parameter matching. The `StorageManager` base class accepts an optional schema via constructor — subclasses pass their Zod schema with a single import + arg change. `safeRead` returns the raw parsed JSON (not Zod-transformed `result.data`) to preserve extra fields for forward compatibility. Also fixed `ShippedJsonSchema` which used `items` instead of `shipped` (pre-existing schema bug), and made `changes` optional to match actual data.
17
+
18
+ ### Learnings
19
+ - Zod's default `strip` mode silently drops unknown keys from `result.data` — must return raw JSON to preserve extra state.json fields (projectId, stack, domains, etc.)
20
+ - `ShippedJsonSchema` had `items` instead of `shipped` as the array key — pre-existing schema/data mismatch
21
+ - `ValidationSchema` interface avoids Zod generic constraints while still providing type-safe validation
22
+
23
+ ### Test Plan
24
+
25
+ #### For QA
26
+ 1. Create a valid `state.json` — verify it reads correctly with no warnings
27
+ 2. Corrupt a storage file with invalid JSON — verify `.backup` is created and defaults returned
28
+ 3. Write valid JSON with wrong schema — verify `.backup` and defaults
29
+ 4. Add extra fields not in schema — verify they are preserved after read
30
+ 5. Run `bun test` — verify all 438 tests pass (16 new for `safeRead`)
31
+
32
+ #### For Users
33
+ **What changed:** Storage reads are now validated against Zod schemas. Corrupted files no longer cause silent crashes.
34
+ **How to use:** No action needed — automatic.
35
+ **Breaking changes:** None.
36
+
3
37
  ## [1.7.0] - 2026-02-07
4
38
 
5
39
  ### Features
@@ -7,7 +41,6 @@
7
41
  - use relative timestamps to reduce token waste (PRJ-274) (#139)
8
42
  - use relative timestamps to reduce token waste (PRJ-274)
9
43
 
10
-
11
44
  ## [1.6.16] - 2026-02-07
12
45
 
13
46
  ### Improvement
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Safe Reader Tests
3
+ *
4
+ * Tests for Zod-validated storage reads:
5
+ * - Valid data passes through
6
+ * - Corrupted JSON creates .backup + returns null
7
+ * - Valid JSON with wrong schema creates .backup + returns null
8
+ * - Missing files return null (no backup)
9
+ * - Extra fields are preserved (forward compatibility)
10
+ */
11
+
12
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
13
+ import fs from 'node:fs/promises'
14
+ import os from 'node:os'
15
+ import path from 'node:path'
16
+ import { z } from 'zod'
17
+ import { safeRead } from '../../storage/safe-reader'
18
+
19
+ // =============================================================================
20
+ // Test Schema
21
+ // =============================================================================
22
+
23
+ const TestSchema = z.object({
24
+ name: z.string(),
25
+ count: z.number(),
26
+ items: z.array(z.string()),
27
+ })
28
+
29
+ type TestData = z.infer<typeof TestSchema>
30
+
31
+ // =============================================================================
32
+ // Setup
33
+ // =============================================================================
34
+
35
+ let tmpDir: string
36
+
37
+ beforeEach(async () => {
38
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prjct-safe-reader-test-'))
39
+ })
40
+
41
+ afterEach(async () => {
42
+ await fs.rm(tmpDir, { recursive: true, force: true })
43
+ })
44
+
45
+ // =============================================================================
46
+ // Tests
47
+ // =============================================================================
48
+
49
+ describe('safeRead', () => {
50
+ describe('valid data', () => {
51
+ it('should return validated data for valid JSON matching schema', async () => {
52
+ const filePath = path.join(tmpDir, 'valid.json')
53
+ const data: TestData = { name: 'test', count: 42, items: ['a', 'b'] }
54
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2))
55
+
56
+ const result = await safeRead<TestData>(filePath, TestSchema)
57
+
58
+ expect(result).toEqual(data)
59
+ })
60
+
61
+ it('should preserve extra fields not in schema', async () => {
62
+ const filePath = path.join(tmpDir, 'extra-fields.json')
63
+ const data = {
64
+ name: 'test',
65
+ count: 1,
66
+ items: [],
67
+ extraField: 'preserved',
68
+ nested: { deep: true },
69
+ }
70
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2))
71
+
72
+ const result = await safeRead<typeof data>(filePath, TestSchema)
73
+
74
+ expect(result).not.toBeNull()
75
+ expect(result!.name).toBe('test')
76
+ expect(result!.extraField).toBe('preserved')
77
+ expect(result!.nested).toEqual({ deep: true })
78
+ })
79
+
80
+ it('should not create .backup for valid data', async () => {
81
+ const filePath = path.join(tmpDir, 'no-backup.json')
82
+ const data: TestData = { name: 'ok', count: 0, items: [] }
83
+ await fs.writeFile(filePath, JSON.stringify(data))
84
+
85
+ await safeRead<TestData>(filePath, TestSchema)
86
+
87
+ const backupExists = await fs
88
+ .access(`${filePath}.backup`)
89
+ .then(() => true)
90
+ .catch(() => false)
91
+ expect(backupExists).toBe(false)
92
+ })
93
+ })
94
+
95
+ describe('missing files', () => {
96
+ it('should return null for non-existent file', async () => {
97
+ const result = await safeRead<TestData>(path.join(tmpDir, 'missing.json'), TestSchema)
98
+
99
+ expect(result).toBeNull()
100
+ })
101
+
102
+ it('should not create .backup for missing file', async () => {
103
+ const filePath = path.join(tmpDir, 'missing.json')
104
+ await safeRead<TestData>(filePath, TestSchema)
105
+
106
+ const backupExists = await fs
107
+ .access(`${filePath}.backup`)
108
+ .then(() => true)
109
+ .catch(() => false)
110
+ expect(backupExists).toBe(false)
111
+ })
112
+ })
113
+
114
+ describe('corrupted JSON', () => {
115
+ it('should return null for malformed JSON', async () => {
116
+ const filePath = path.join(tmpDir, 'malformed.json')
117
+ await fs.writeFile(filePath, 'not valid json {{{')
118
+
119
+ const result = await safeRead<TestData>(filePath, TestSchema)
120
+
121
+ expect(result).toBeNull()
122
+ })
123
+
124
+ it('should create .backup for malformed JSON', async () => {
125
+ const filePath = path.join(tmpDir, 'malformed.json')
126
+ const badContent = 'not valid json {{{'
127
+ await fs.writeFile(filePath, badContent)
128
+
129
+ await safeRead<TestData>(filePath, TestSchema)
130
+
131
+ const backup = await fs.readFile(`${filePath}.backup`, 'utf-8')
132
+ expect(backup).toBe(badContent)
133
+ })
134
+
135
+ it('should return null for empty file', async () => {
136
+ const filePath = path.join(tmpDir, 'empty.json')
137
+ await fs.writeFile(filePath, '')
138
+
139
+ const result = await safeRead<TestData>(filePath, TestSchema)
140
+
141
+ expect(result).toBeNull()
142
+ })
143
+ })
144
+
145
+ describe('valid JSON with wrong schema', () => {
146
+ it('should return null when required field is missing', async () => {
147
+ const filePath = path.join(tmpDir, 'missing-field.json')
148
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test' })) // missing count and items
149
+
150
+ const result = await safeRead<TestData>(filePath, TestSchema)
151
+
152
+ expect(result).toBeNull()
153
+ })
154
+
155
+ it('should create .backup when schema validation fails', async () => {
156
+ const filePath = path.join(tmpDir, 'wrong-schema.json')
157
+ const data = { name: 123, count: 'not a number', items: 'not an array' }
158
+ await fs.writeFile(filePath, JSON.stringify(data))
159
+
160
+ await safeRead<TestData>(filePath, TestSchema)
161
+
162
+ const backupExists = await fs
163
+ .access(`${filePath}.backup`)
164
+ .then(() => true)
165
+ .catch(() => false)
166
+ expect(backupExists).toBe(true)
167
+ })
168
+
169
+ it('should return null when field has wrong type', async () => {
170
+ const filePath = path.join(tmpDir, 'wrong-type.json')
171
+ await fs.writeFile(filePath, JSON.stringify({ name: 42, count: 1, items: [] }))
172
+
173
+ const result = await safeRead<TestData>(filePath, TestSchema)
174
+
175
+ expect(result).toBeNull()
176
+ })
177
+
178
+ it('should return null when array contains wrong types', async () => {
179
+ const filePath = path.join(tmpDir, 'wrong-array.json')
180
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test', count: 1, items: [1, 2, 3] }))
181
+
182
+ const result = await safeRead<TestData>(filePath, TestSchema)
183
+
184
+ expect(result).toBeNull()
185
+ })
186
+ })
187
+
188
+ describe('optional fields and defaults', () => {
189
+ it('should handle schema with optional fields', async () => {
190
+ const OptionalSchema = z.object({
191
+ name: z.string(),
192
+ description: z.string().optional(),
193
+ })
194
+
195
+ const filePath = path.join(tmpDir, 'optional.json')
196
+ await fs.writeFile(filePath, JSON.stringify({ name: 'test' }))
197
+
198
+ const result = await safeRead<z.infer<typeof OptionalSchema>>(filePath, OptionalSchema)
199
+
200
+ expect(result).not.toBeNull()
201
+ expect(result!.name).toBe('test')
202
+ expect(result!.description).toBeUndefined()
203
+ })
204
+
205
+ it('should handle schema with nullable fields', async () => {
206
+ const NullableSchema = z.object({
207
+ currentTask: z.object({ id: z.string() }).nullable(),
208
+ lastUpdated: z.string(),
209
+ })
210
+
211
+ const filePath = path.join(tmpDir, 'nullable.json')
212
+ await fs.writeFile(filePath, JSON.stringify({ currentTask: null, lastUpdated: '2026-01-01' }))
213
+
214
+ const result = await safeRead<z.infer<typeof NullableSchema>>(filePath, NullableSchema)
215
+
216
+ expect(result).not.toBeNull()
217
+ expect(result!.currentTask).toBeNull()
218
+ })
219
+ })
220
+
221
+ describe('integration with StorageManager pattern', () => {
222
+ it('should work with real StateJsonSchema', async () => {
223
+ // Import the actual schema used in production
224
+ const { StateJsonSchema } = await import('../../schemas/state')
225
+
226
+ const filePath = path.join(tmpDir, 'state.json')
227
+ const stateData = {
228
+ currentTask: null,
229
+ lastUpdated: '2026-02-07T00:00:00.000Z',
230
+ // Extra fields that exist in real state.json but not in schema
231
+ projectId: 'test-123',
232
+ stack: { language: 'TypeScript', framework: 'Hono' },
233
+ }
234
+ await fs.writeFile(filePath, JSON.stringify(stateData, null, 2))
235
+
236
+ const result = await safeRead<typeof stateData>(filePath, StateJsonSchema)
237
+
238
+ expect(result).not.toBeNull()
239
+ expect(result!.currentTask).toBeNull()
240
+ expect(result!.projectId).toBe('test-123') // Extra field preserved
241
+ })
242
+
243
+ it('should reject corrupted state data', async () => {
244
+ const { StateJsonSchema } = await import('../../schemas/state')
245
+
246
+ const filePath = path.join(tmpDir, 'bad-state.json')
247
+ // currentTask should be an object or null, not a number
248
+ const badData = { currentTask: 42, lastUpdated: '2026-02-07' }
249
+ await fs.writeFile(filePath, JSON.stringify(badData))
250
+
251
+ const result = await safeRead(filePath, StateJsonSchema)
252
+
253
+ expect(result).toBeNull()
254
+ // Backup should exist
255
+ const backupExists = await fs
256
+ .access(`${filePath}.backup`)
257
+ .then(() => true)
258
+ .catch(() => false)
259
+ expect(backupExists).toBe(true)
260
+ })
261
+ })
262
+ })
@@ -56,7 +56,7 @@ export const ShippedItemSchema = z.object({
56
56
  type: ShipTypeSchema,
57
57
  agent: z.string().optional(), // "fe+be", "be", "fe"
58
58
  description: z.string().optional(),
59
- changes: z.array(ShipChangeSchema),
59
+ changes: z.array(ShipChangeSchema).optional(),
60
60
  codeSnippets: z.array(z.string()).optional(),
61
61
  commit: CommitInfoSchema.optional(),
62
62
  codeMetrics: CodeMetricsSchema.optional(),
@@ -69,7 +69,7 @@ export const ShippedItemSchema = z.object({
69
69
  })
70
70
 
71
71
  export const ShippedJsonSchema = z.object({
72
- items: z.array(ShippedItemSchema),
72
+ shipped: z.array(ShippedItemSchema),
73
73
  lastUpdated: z.string(),
74
74
  })
75
75
 
@@ -104,6 +104,6 @@ export const safeParseShipped = (data: unknown) => ShippedJsonSchema.safeParse(d
104
104
  // =============================================================================
105
105
 
106
106
  export const DEFAULT_SHIPPED: ShippedJson = {
107
- items: [],
107
+ shipped: [],
108
108
  lastUpdated: '',
109
109
  }
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { generateUUID } from '../schemas'
9
+ import { IdeasJsonSchema } from '../schemas/ideas'
9
10
  import type { Idea, IdeaPriority, IdeaStatus, IdeasJson } from '../types'
10
11
  import { getTimestamp, toRelative } from '../utils/date-helper'
11
12
  import { StorageManager } from './storage-manager'
12
13
 
13
14
  class IdeasStorage extends StorageManager<IdeasJson> {
14
15
  constructor() {
15
- super('ideas.json')
16
+ super('ideas.json', IdeasJsonSchema)
16
17
  }
17
18
 
18
19
  protected getDefault(): IdeasJson {
@@ -18,13 +18,14 @@ import {
18
18
  estimateCostSaved,
19
19
  formatCost,
20
20
  type MetricsJson,
21
+ MetricsJsonSchema,
21
22
  } from '../schemas/metrics'
22
23
  import { getTimestamp } from '../utils/date-helper'
23
24
  import { StorageManager } from './storage-manager'
24
25
 
25
26
  class MetricsStorage extends StorageManager<MetricsJson> {
26
27
  constructor() {
27
- super('metrics.json')
28
+ super('metrics.json', MetricsJsonSchema)
28
29
  }
29
30
 
30
31
  protected getDefault(): MetricsJson {
@@ -7,12 +7,13 @@
7
7
 
8
8
  import { generateUUID } from '../schemas'
9
9
  import type { Priority, QueueJson, QueueTask, TaskSection } from '../schemas/state'
10
+ import { QueueJsonSchema } from '../schemas/state'
10
11
  import { getTimestamp } from '../utils/date-helper'
11
12
  import { StorageManager } from './storage-manager'
12
13
 
13
14
  class QueueStorage extends StorageManager<QueueJson> {
14
15
  constructor() {
15
- super('queue.json')
16
+ super('queue.json', QueueJsonSchema)
16
17
  }
17
18
 
18
19
  protected getDefault(): QueueJson {
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Safe Reader
3
+ *
4
+ * Wraps JSON.parse + Zod schema.safeParse() for validated storage reads.
5
+ * On corruption: logs warning, creates .backup, returns null.
6
+ *
7
+ * Uses schema for structural validation only — returns the raw parsed
8
+ * data (not Zod-transformed) to preserve extra fields that may exist
9
+ * in storage files but aren't in the schema yet (forward compatibility).
10
+ *
11
+ * @version 1.0.0
12
+ */
13
+
14
+ import fs from 'node:fs/promises'
15
+ import type { ZodError } from 'zod'
16
+ import { isNotFoundError } from '../types/fs'
17
+
18
+ /**
19
+ * Minimal interface for Zod-like validation.
20
+ * Decoupled from Zod generics to avoid strict type parameter matching.
21
+ */
22
+ export interface ValidationSchema {
23
+ safeParse(data: unknown): { success: boolean; error?: ZodError }
24
+ }
25
+
26
+ /**
27
+ * Read and validate a JSON file against a Zod schema.
28
+ *
29
+ * Flow:
30
+ * 1. Read file → if missing, return null
31
+ * 2. JSON.parse → if malformed JSON, backup + return null
32
+ * 3. schema.safeParse → if valid, return raw parsed data as T
33
+ * 4. If invalid but parseable JSON, backup + return null
34
+ *
35
+ * Returns raw parsed JSON (not Zod-transformed) to preserve extra fields.
36
+ *
37
+ * @returns Validated data or null if file is missing/corrupted
38
+ */
39
+ export async function safeRead<T>(filePath: string, schema: ValidationSchema): Promise<T | null> {
40
+ let content: string
41
+
42
+ // Step 1: Read file
43
+ try {
44
+ content = await fs.readFile(filePath, 'utf-8')
45
+ } catch (error) {
46
+ if (isNotFoundError(error)) {
47
+ return null
48
+ }
49
+ throw error
50
+ }
51
+
52
+ // Step 2: JSON.parse
53
+ let raw: unknown
54
+ try {
55
+ raw = JSON.parse(content)
56
+ } catch {
57
+ // Malformed JSON — backup and return null
58
+ await createBackup(filePath, content)
59
+ logCorruption(filePath, 'Malformed JSON')
60
+ return null
61
+ }
62
+
63
+ // Step 3: Validate against schema
64
+ const result = schema.safeParse(raw)
65
+ if (result.success) {
66
+ // Return raw data to preserve extra fields not in schema
67
+ return raw as T
68
+ }
69
+
70
+ // Step 4: Validation failed — backup and return null
71
+ await createBackup(filePath, content)
72
+ logCorruption(filePath, formatZodError(result.error!))
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Create a .backup of a corrupted file
78
+ */
79
+ async function createBackup(filePath: string, content: string): Promise<void> {
80
+ const backupPath = `${filePath}.backup`
81
+ try {
82
+ await fs.writeFile(backupPath, content, 'utf-8')
83
+ } catch {
84
+ // Best-effort backup — don't throw if it fails
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Log corruption warning to stderr
90
+ */
91
+ function logCorruption(filePath: string, reason: string): void {
92
+ console.error(`[prjct] Warning: Corrupted storage file: ${filePath}`)
93
+ console.error(`[prjct] Reason: ${reason}`)
94
+ console.error(`[prjct] A .backup file has been created. Returning defaults.`)
95
+ }
96
+
97
+ /**
98
+ * Format Zod error into a readable string
99
+ */
100
+ function formatZodError(error: ZodError): string {
101
+ return error.issues
102
+ .slice(0, 3)
103
+ .map((issue) => `${issue.path.join('.')}: ${issue.message}`)
104
+ .join('; ')
105
+ }
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { generateUUID } from '../schemas'
9
+ import { ShippedJsonSchema } from '../schemas/shipped'
9
10
  import type { ShippedFeature, ShippedJson } from '../types'
10
11
  import { getTimestamp, toRelative } from '../utils/date-helper'
11
12
  import { StorageManager } from './storage-manager'
12
13
 
13
14
  class ShippedStorage extends StorageManager<ShippedJson> {
14
15
  constructor() {
15
- super('shipped.json')
16
+ super('shipped.json', ShippedJsonSchema)
16
17
  }
17
18
 
18
19
  protected getDefault(): ShippedJson {
@@ -16,13 +16,14 @@ import type {
16
16
  Subtask,
17
17
  SubtaskSummary,
18
18
  } from '../schemas/state'
19
+ import { StateJsonSchema } from '../schemas/state'
19
20
  import { getTimestamp, toRelative } from '../utils/date-helper'
20
21
  import { md } from '../utils/markdown-builder'
21
22
  import { StorageManager } from './storage-manager'
22
23
 
23
24
  class StateStorage extends StorageManager<StateJson> {
24
25
  constructor() {
25
- super('state.json')
26
+ super('state.json', StateJsonSchema)
26
27
  }
27
28
 
28
29
  protected getDefault(): StateJson {
@@ -16,14 +16,17 @@ import pathManager from '../infrastructure/path-manager'
16
16
  import { isNotFoundError } from '../types/fs'
17
17
  import { TTLCache } from '../utils/cache'
18
18
  import { getTimestamp } from '../utils/date-helper'
19
+ import { safeRead, type ValidationSchema } from './safe-reader'
19
20
 
20
21
  export abstract class StorageManager<T> {
21
22
  protected filename: string
22
23
  protected cache: TTLCache<T>
24
+ protected schema: ValidationSchema | null
23
25
 
24
- constructor(filename: string) {
26
+ constructor(filename: string, schema?: ValidationSchema) {
25
27
  this.filename = filename
26
28
  this.cache = new TTLCache<T>({ ttl: 5000, maxSize: 50 })
29
+ this.schema = schema ?? null
27
30
  }
28
31
 
29
32
  /**
@@ -69,7 +72,9 @@ export abstract class StorageManager<T> {
69
72
  protected abstract getEventType(action: 'update' | 'create' | 'delete'): string
70
73
 
71
74
  /**
72
- * Read data from storage
75
+ * Read data from storage with optional Zod validation.
76
+ * When a schema is provided (via constructor), validates after JSON.parse.
77
+ * On corruption: creates .backup file, logs warning, returns default.
73
78
  */
74
79
  async read(projectId: string): Promise<T> {
75
80
  // Check cache first (with expiration)
@@ -80,13 +85,24 @@ export abstract class StorageManager<T> {
80
85
 
81
86
  const filePath = this.getStoragePath(projectId)
82
87
 
88
+ if (this.schema) {
89
+ // Validated read path
90
+ const data = await safeRead<T>(filePath, this.schema)
91
+ if (data !== null) {
92
+ this.cache.set(projectId, data)
93
+ return data
94
+ }
95
+ // File missing or corrupted — return default
96
+ return this.getDefault()
97
+ }
98
+
99
+ // Unvalidated fallback (for subclasses without a schema)
83
100
  try {
84
101
  const content = await fs.readFile(filePath, 'utf-8')
85
102
  const data = JSON.parse(content) as T
86
103
  this.cache.set(projectId, data)
87
104
  return data
88
105
  } catch (error) {
89
- // Return default if file doesn't exist or is invalid JSON
90
106
  if (isNotFoundError(error) || error instanceof SyntaxError) {
91
107
  return this.getDefault()
92
108
  }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
+ import { safeRead, type ValidationSchema } from '../storage/safe-reader'
3
4
  import { isNotFoundError } from '../types/fs'
4
5
 
5
6
  /**
@@ -18,12 +19,19 @@ interface ListFilesOptions {
18
19
  }
19
20
 
20
21
  /**
21
- * Read JSON file and parse
22
+ * Read JSON file and parse.
23
+ * When a Zod schema is provided, validates the data and creates a .backup on corruption.
22
24
  */
23
25
  export async function readJson<T = unknown>(
24
26
  filePath: string,
25
- defaultValue: T | null = null
27
+ defaultValue: T | null = null,
28
+ schema?: ValidationSchema
26
29
  ): Promise<T | null> {
30
+ if (schema) {
31
+ const data = await safeRead<T>(filePath, schema)
32
+ return data ?? defaultValue
33
+ }
34
+
27
35
  try {
28
36
  const content = await fs.readFile(filePath, 'utf-8')
29
37
  return JSON.parse(content) as T