@ultime/codecraft 0.0.0-stage → 1.0.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/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
+ }