@ultime/codecraft 0.0.0-stage → 1.0.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/CHANGELOG.md +44 -0
- package/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +209 -2
- package/SECURITY.md +22 -0
- package/VERSION +1 -0
- package/adapters/README.md +10 -0
- package/adapters/claude/SKILL.md +40 -0
- package/adapters/generic/AGENTS.md +15 -0
- package/adapters/trae/SKILL.md +40 -0
- package/bin/codecraft.mjs +4 -0
- package/codecraft.config.example.json +15 -0
- package/docs/00-constitution.md +310 -0
- package/docs/01-family-cards.md +169 -0
- package/docs/02-ai-contract.md +68 -0
- package/docs/03-style-map.md +90 -0
- package/docs/04-gates.md +85 -0
- package/docs/05-patterns.md +243 -0
- package/docs/06-architecture.md +263 -0
- package/evals/golden-tasks.md +114 -0
- package/package.json +49 -4
- package/prompts/output.md +27 -0
- package/scenarios/audit-existing.md +41 -0
- package/scenarios/new-project.md +50 -0
- package/scenarios/refactor-review.md +31 -0
- package/scenarios/report-only.md +33 -0
- package/src/check.mjs +451 -0
- package/src/cli.mjs +384 -0
- package/src/gates.mjs +67 -0
- package/src/init.mjs +98 -0
- package/src/plan.mjs +35 -0
- package/templates/README.md +11 -0
- package/templates/app/README.md +28 -0
- package/templates/app/package.json +8 -0
- package/templates/library/CHANGELOG.md +3 -0
- package/templates/library/README.md +26 -0
- package/templates/library/compat.md +5 -0
- package/templates/library/package.json +14 -0
package/src/check.mjs
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'dist',
|
|
7
|
+
'build',
|
|
8
|
+
'target',
|
|
9
|
+
'.git',
|
|
10
|
+
'.local',
|
|
11
|
+
'.venv',
|
|
12
|
+
'venv',
|
|
13
|
+
'coverage',
|
|
14
|
+
'.next',
|
|
15
|
+
'.nuxt',
|
|
16
|
+
'.output',
|
|
17
|
+
'.turbo',
|
|
18
|
+
'.cache',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
const GENERATED_SKIP_DIRS = new Set(['node_modules', '.git', 'coverage', '.turbo', '.cache'])
|
|
22
|
+
const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte'])
|
|
23
|
+
const GENERATED_EXTENSIONS = new Set(['.ts', '.js', '.css', '.json', '.md'])
|
|
24
|
+
const ALLOWED_SEGMENTS = new Set(['external', 'generated', 'api', 'adapters'])
|
|
25
|
+
const GENERATED_MARKER = /生成物|generated|do not edit|勿手改|auto-generated|派生/i
|
|
26
|
+
const SECRET_EXTENSIONS = new Set([
|
|
27
|
+
'.ts',
|
|
28
|
+
'.tsx',
|
|
29
|
+
'.js',
|
|
30
|
+
'.jsx',
|
|
31
|
+
'.mjs',
|
|
32
|
+
'.cjs',
|
|
33
|
+
'.json',
|
|
34
|
+
'.md',
|
|
35
|
+
'.yml',
|
|
36
|
+
'.yaml',
|
|
37
|
+
'.sh',
|
|
38
|
+
'.toml',
|
|
39
|
+
'.ini',
|
|
40
|
+
])
|
|
41
|
+
const SECRET_SKIP_FILES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'])
|
|
42
|
+
/** 文档里公开的示例值,不当作真实凭据。 */
|
|
43
|
+
const SECRET_ALLOW = new Set(['AKIAIOSFODNN7EXAMPLE'])
|
|
44
|
+
const SECRET_PATTERNS = [
|
|
45
|
+
{ id: 'private key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
|
|
46
|
+
{ id: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
47
|
+
{ id: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
|
|
48
|
+
{ id: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
49
|
+
{ id: 'API secret key', re: /\bsk-[A-Za-z0-9]{20,}\b/ },
|
|
50
|
+
]
|
|
51
|
+
/** 只认内部文档特征:向上一级找根 docs,或 Ultime 的内部目录名;包自带的 docs/ 不算。 */
|
|
52
|
+
const DOC_PATH = /(?:^|[\s(`'"])(?:(?:\.\.\/)+docs\/|(?:docs\/)?(?:开发计划|设计系统|工程机制)\/)/
|
|
53
|
+
|
|
54
|
+
const IMPORT_PATTERNS = [
|
|
55
|
+
/\bfrom\s+['"]([^'"]+)['"]/g,
|
|
56
|
+
/\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
|
|
57
|
+
/^\s*import\s+['"]([^'"]+)['"]/gm,
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
export function check(root, options = {}) {
|
|
61
|
+
const absRoot = path.resolve(root)
|
|
62
|
+
const pack = options.pack ?? 'app'
|
|
63
|
+
if (pack === 'library') return checkLibrary(absRoot)
|
|
64
|
+
if (pack === 'design-system') return checkDesignSystem(absRoot)
|
|
65
|
+
return checkApp(absRoot)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkApp(root) {
|
|
69
|
+
const files = collectAppFiles(root)
|
|
70
|
+
const findings = []
|
|
71
|
+
for (const file of files) findings.push(...scanAppFile(file, root))
|
|
72
|
+
findings.push(...scanSecrets(root))
|
|
73
|
+
return { root, files: files.length, findings }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkLibrary(root) {
|
|
77
|
+
return checkPackages(root, { docPaths: false })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkDesignSystem(root) {
|
|
81
|
+
return checkPackages(root, { docPaths: true })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkPackages(root, options) {
|
|
85
|
+
const packages = findPackages(root)
|
|
86
|
+
const manifests = packages.map((pkg) => readJson(path.join(pkg, 'package.json')))
|
|
87
|
+
const workspaceNames = new Set(manifests.map((manifest) => manifest?.name).filter(Boolean))
|
|
88
|
+
const findings = []
|
|
89
|
+
let files = 0
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < packages.length; i += 1) {
|
|
92
|
+
const result = checkPackage(packages[i], manifests[i], workspaceNames)
|
|
93
|
+
files += result.files
|
|
94
|
+
findings.push(...result.findings)
|
|
95
|
+
if (options.docPaths) findings.push(...scanPackageDocs(packages[i]))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
findings.push(...scanSecrets(root))
|
|
99
|
+
return { root, files, findings }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function findPackages(root) {
|
|
103
|
+
const packagesDir = path.join(root, 'packages')
|
|
104
|
+
if (isDir(packagesDir)) {
|
|
105
|
+
return fs
|
|
106
|
+
.readdirSync(packagesDir, { withFileTypes: true })
|
|
107
|
+
.filter((entry) => entry.isDirectory())
|
|
108
|
+
.map((entry) => path.join(packagesDir, entry.name))
|
|
109
|
+
.filter((dir) => fs.existsSync(path.join(dir, 'package.json')))
|
|
110
|
+
}
|
|
111
|
+
if (fs.existsSync(path.join(root, 'package.json'))) return [root]
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkPackage(pkg, manifest, workspaceNames) {
|
|
116
|
+
const name = manifest?.name ?? ''
|
|
117
|
+
const deps = new Set([
|
|
118
|
+
...Object.keys(manifest?.dependencies ?? {}),
|
|
119
|
+
...Object.keys(manifest?.peerDependencies ?? {}),
|
|
120
|
+
...Object.keys(manifest?.devDependencies ?? {}),
|
|
121
|
+
])
|
|
122
|
+
const src = path.join(pkg, 'src')
|
|
123
|
+
const sourceFiles = isDir(src) ? walkFiles(src) : []
|
|
124
|
+
const findings = []
|
|
125
|
+
|
|
126
|
+
for (const file of sourceFiles) {
|
|
127
|
+
const text = read(file)
|
|
128
|
+
if (text == null) continue
|
|
129
|
+
for (const { spec, line } of collectImports(stripComments(text))) {
|
|
130
|
+
const message = libraryImportIssue(spec, file, pkg, name, deps, workspaceNames)
|
|
131
|
+
if (!message) continue
|
|
132
|
+
findings.push({
|
|
133
|
+
rule: 'A2',
|
|
134
|
+
severity: 'warn',
|
|
135
|
+
file: path.relative(pkg, file),
|
|
136
|
+
line,
|
|
137
|
+
message,
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const file of collectGeneratedCandidates(pkg)) {
|
|
143
|
+
const text = read(file)
|
|
144
|
+
if (text == null || hasGeneratedMarker(text)) continue
|
|
145
|
+
findings.push({
|
|
146
|
+
rule: 'A11',
|
|
147
|
+
severity: 'warn',
|
|
148
|
+
file: path.relative(pkg, file),
|
|
149
|
+
line: 1,
|
|
150
|
+
message: 'generated file lacks a "do not edit" marker; regenerate from source (A11)',
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { files: sourceFiles.length, findings }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function libraryImportIssue(spec, file, pkg, name, deps, workspaceNames) {
|
|
158
|
+
if (/(^|[/])apps([/]|$)/.test(spec)) {
|
|
159
|
+
return `imports app code "${spec}"; libraries must not depend on apps (A2)`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (spec.startsWith('.')) {
|
|
163
|
+
const target = path.resolve(path.dirname(file), spec)
|
|
164
|
+
const rel = path.relative(pkg, target)
|
|
165
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
166
|
+
return `relative import escapes package: "${spec}" (A2)`
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const dep = [...workspaceNames].find((candidate) => spec === candidate || spec.startsWith(`${candidate}/`))
|
|
172
|
+
if (!dep || dep === name || deps.has(dep)) return null
|
|
173
|
+
return `imports workspace package "${dep}" without declaring it in package.json (A2)`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function collectAppFiles(root) {
|
|
177
|
+
const files = []
|
|
178
|
+
const seen = new Set()
|
|
179
|
+
for (const dir of collectAppRoots(root)) walk(dir, files, seen)
|
|
180
|
+
return files
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function collectAppRoots(root) {
|
|
184
|
+
const roots = []
|
|
185
|
+
const apps = path.join(root, 'apps')
|
|
186
|
+
const packages = path.join(root, 'packages')
|
|
187
|
+
|
|
188
|
+
if (isDir(apps)) {
|
|
189
|
+
for (const entry of fs.readdirSync(apps, { withFileTypes: true })) {
|
|
190
|
+
if (!entry.isDirectory()) continue
|
|
191
|
+
const dir = path.join(apps, entry.name)
|
|
192
|
+
roots.push(isDir(path.join(dir, 'src')) ? path.join(dir, 'src') : dir)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (isDir(packages)) {
|
|
197
|
+
for (const entry of fs.readdirSync(packages, { withFileTypes: true })) {
|
|
198
|
+
if (!entry.isDirectory()) continue
|
|
199
|
+
const src = path.join(packages, entry.name, 'src')
|
|
200
|
+
if (isDir(src)) roots.push(src)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const src = path.join(root, 'src')
|
|
205
|
+
if (isDir(src)) roots.push(src)
|
|
206
|
+
if (!roots.length) roots.push(root)
|
|
207
|
+
return roots
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function walkFiles(dir) {
|
|
211
|
+
const files = []
|
|
212
|
+
walk(dir, files, new Set())
|
|
213
|
+
return files
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function walk(dir, files, seen) {
|
|
217
|
+
if (seen.has(dir)) return
|
|
218
|
+
seen.add(dir)
|
|
219
|
+
|
|
220
|
+
let entries
|
|
221
|
+
try {
|
|
222
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
223
|
+
} catch {
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const entry of entries) {
|
|
228
|
+
const full = path.join(dir, entry.name)
|
|
229
|
+
if (entry.isDirectory()) {
|
|
230
|
+
if (!SKIP_DIRS.has(entry.name)) walk(full, files, seen)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
if (!entry.isFile()) continue
|
|
234
|
+
if (!EXTENSIONS.has(path.extname(entry.name))) continue
|
|
235
|
+
if (isTestFile(full)) continue
|
|
236
|
+
files.push(full)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function collectGeneratedCandidates(pkg) {
|
|
241
|
+
const files = []
|
|
242
|
+
walkGenerated(pkg, files, new Set())
|
|
243
|
+
return files.filter(isGeneratedCandidate)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function walkGenerated(dir, files, seen) {
|
|
247
|
+
if (seen.has(dir)) return
|
|
248
|
+
seen.add(dir)
|
|
249
|
+
|
|
250
|
+
let entries
|
|
251
|
+
try {
|
|
252
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
253
|
+
} catch {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
const full = path.join(dir, entry.name)
|
|
259
|
+
if (entry.isDirectory()) {
|
|
260
|
+
if (!GENERATED_SKIP_DIRS.has(entry.name)) walkGenerated(full, files, seen)
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
if (entry.isFile()) files.push(full)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function scanAppFile(file, root) {
|
|
268
|
+
if (isAllowedImporter(file, root)) return []
|
|
269
|
+
|
|
270
|
+
const text = read(file)
|
|
271
|
+
if (text == null) return []
|
|
272
|
+
|
|
273
|
+
const findings = []
|
|
274
|
+
for (const { spec, line } of collectImports(stripComments(text))) {
|
|
275
|
+
if (!isExternalSpec(spec)) continue
|
|
276
|
+
findings.push({
|
|
277
|
+
rule: 'A17',
|
|
278
|
+
severity: 'warn',
|
|
279
|
+
file: path.relative(root, file),
|
|
280
|
+
line,
|
|
281
|
+
message: `imports external type "${spec}"; only Wrapper/API may import external/generated (A17)`,
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
return findings
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function collectImports(text) {
|
|
288
|
+
const imports = []
|
|
289
|
+
const seen = new Set()
|
|
290
|
+
|
|
291
|
+
for (const pattern of IMPORT_PATTERNS) {
|
|
292
|
+
for (const match of text.matchAll(pattern)) {
|
|
293
|
+
const spec = match[1]
|
|
294
|
+
const line = lineAt(text, match.index ?? 0)
|
|
295
|
+
const key = `${line}:${spec}`
|
|
296
|
+
if (seen.has(key)) continue
|
|
297
|
+
seen.add(key)
|
|
298
|
+
imports.push({ spec, line })
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return imports
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isGeneratedCandidate(file) {
|
|
306
|
+
const base = path.basename(file)
|
|
307
|
+
if (/\.generated\.[^.]+$/.test(base)) return true
|
|
308
|
+
if (['tokens.css', 'tokens.dtcg.json', 'tokens.js', 'tokens.d.ts'].includes(base)) {
|
|
309
|
+
return true
|
|
310
|
+
}
|
|
311
|
+
return file.split(path.sep).includes('generated') && GENERATED_EXTENSIONS.has(path.extname(base))
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasGeneratedMarker(text) {
|
|
315
|
+
return GENERATED_MARKER.test(text.split('\n').slice(0, 20).join('\n'))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isAllowedImporter(file, root) {
|
|
319
|
+
const segments = path.relative(root, file).split(path.sep)
|
|
320
|
+
return segments.some((segment) => ALLOWED_SEGMENTS.has(segment))
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isExternalSpec(spec) {
|
|
324
|
+
return /(^|[/@])(external|generated)([/]|$)/.test(spec)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isTestFile(file) {
|
|
328
|
+
return /(^|[/\\])(__tests__|tests?)[/\\]/.test(file) || /\.(test|spec)\.[^.]+$/.test(file)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function scanSecrets(root) {
|
|
332
|
+
const findings = []
|
|
333
|
+
for (const file of collectScanFiles(root)) {
|
|
334
|
+
if (SECRET_SKIP_FILES.has(path.basename(file))) continue
|
|
335
|
+
const text = read(file)
|
|
336
|
+
if (text == null) continue
|
|
337
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
338
|
+
const match = pattern.re.exec(text)
|
|
339
|
+
if (!match) continue
|
|
340
|
+
if (SECRET_ALLOW.has(match[0])) continue
|
|
341
|
+
findings.push({
|
|
342
|
+
rule: 'A21',
|
|
343
|
+
severity: 'warn',
|
|
344
|
+
file: path.relative(root, file),
|
|
345
|
+
line: lineAt(text, match.index ?? 0),
|
|
346
|
+
message: `possible ${pattern.id}; remove it and rotate the credential (A21)`,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return findings
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function collectScanFiles(root) {
|
|
354
|
+
const files = []
|
|
355
|
+
walkScan(root, files, new Set())
|
|
356
|
+
return files
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function walkScan(dir, files, seen) {
|
|
360
|
+
if (seen.has(dir)) return
|
|
361
|
+
seen.add(dir)
|
|
362
|
+
|
|
363
|
+
let entries
|
|
364
|
+
try {
|
|
365
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
366
|
+
} catch {
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
for (const entry of entries) {
|
|
371
|
+
const full = path.join(dir, entry.name)
|
|
372
|
+
if (entry.isDirectory()) {
|
|
373
|
+
if (!SKIP_DIRS.has(entry.name)) walkScan(full, files, seen)
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
if (!entry.isFile()) continue
|
|
377
|
+
if (SECRET_EXTENSIONS.has(path.extname(entry.name))) files.push(full)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function scanPackageDocs(pkg) {
|
|
382
|
+
const findings = []
|
|
383
|
+
const files = ['README.md', 'USAGE.md', 'CHANGELOG.md', 'compat.md']
|
|
384
|
+
.map((name) => path.join(pkg, name))
|
|
385
|
+
.filter((file) => fs.existsSync(file))
|
|
386
|
+
|
|
387
|
+
const src = path.join(pkg, 'src')
|
|
388
|
+
if (isDir(src)) {
|
|
389
|
+
const contracts = []
|
|
390
|
+
walkGenerated(src, contracts, new Set())
|
|
391
|
+
files.push(...contracts.filter((file) => path.basename(file) === 'contract.md'))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const file of files) {
|
|
395
|
+
const text = read(file)
|
|
396
|
+
if (text == null) continue
|
|
397
|
+
text.split('\n').forEach((line, index) => {
|
|
398
|
+
if (!DOC_PATH.test(line)) return
|
|
399
|
+
findings.push({
|
|
400
|
+
rule: 'A21',
|
|
401
|
+
severity: 'warn',
|
|
402
|
+
file: path.relative(pkg, file),
|
|
403
|
+
line: index + 1,
|
|
404
|
+
message: 'public package doc references internal docs; keep it self-contained (A21)',
|
|
405
|
+
})
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return findings
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function stripComments(text) {
|
|
413
|
+
return text
|
|
414
|
+
.replace(/<!--[\s\S]*?-->/g, '')
|
|
415
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
416
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, '$1')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function lineAt(text, index) {
|
|
420
|
+
let line = 1
|
|
421
|
+
for (let i = 0; i < index; i += 1) {
|
|
422
|
+
if (text.charCodeAt(i) === 10) line += 1
|
|
423
|
+
}
|
|
424
|
+
return line
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function read(file) {
|
|
428
|
+
try {
|
|
429
|
+
return fs.readFileSync(file, 'utf8')
|
|
430
|
+
} catch {
|
|
431
|
+
return null
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function readJson(file) {
|
|
436
|
+
const text = read(file)
|
|
437
|
+
if (text == null) return null
|
|
438
|
+
try {
|
|
439
|
+
return JSON.parse(text)
|
|
440
|
+
} catch {
|
|
441
|
+
return null
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isDir(target) {
|
|
446
|
+
try {
|
|
447
|
+
return fs.statSync(target).isDirectory()
|
|
448
|
+
} catch {
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
}
|