@ultime/codecraft 1.4.0 → 1.5.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +41 -32
  4. package/SKILL.md +2 -2
  5. package/VERSION +1 -1
  6. package/adapters/README.md +3 -4
  7. package/codecraft.config.example.json +5 -1
  8. package/docs/00-constitution.md +2 -2
  9. package/docs/02-ai-contract.md +12 -1
  10. package/docs/04-gates.md +21 -6
  11. package/docs/07-ai-prompts.md +5 -6
  12. package/docs/08-audit-contract.md +141 -0
  13. package/evals/cases.json +110 -0
  14. package/evals/fixtures/a11-marker/packages/core/dist/tokens.css +4 -0
  15. package/evals/fixtures/a11-marker/packages/core/package.json +1 -0
  16. package/evals/fixtures/a11-marker/packages/core/src/index.ts +1 -0
  17. package/evals/fixtures/a17-boundaries/codecraft.config.json +5 -0
  18. package/evals/fixtures/a17-boundaries/src/pages/home.ts +1 -0
  19. package/evals/fixtures/a17-default/src/pages/home.ts +1 -0
  20. package/evals/fixtures/a17-generated-data/src/icons/icons.ts +2 -0
  21. package/evals/fixtures/a17-generated-data/src/pages/home.ts +3 -0
  22. package/evals/fixtures/a17-generated-header/src/pages/home.ts +5 -0
  23. package/evals/fixtures/a17-generated-header/src/types/project.ts +4 -0
  24. package/evals/fixtures/a17-local/src/pages/home.ts +4 -0
  25. package/evals/fixtures/a17-local/src/types/local.ts +3 -0
  26. package/evals/fixtures/a17-local/src/utils/format.ts +3 -0
  27. package/evals/fixtures/a2-declared/packages/core/package.json +6 -0
  28. package/evals/fixtures/a2-declared/packages/core/src/index.ts +3 -0
  29. package/evals/fixtures/a2-declared/packages/kit/package.json +1 -0
  30. package/evals/fixtures/a2-workspace/packages/core/package.json +1 -0
  31. package/evals/fixtures/a2-workspace/packages/core/src/index.ts +3 -0
  32. package/evals/fixtures/a2-workspace/packages/kit/package.json +1 -0
  33. package/evals/fixtures/a21-doc-paths/packages/ui/README.md +5 -0
  34. package/evals/fixtures/a21-doc-paths/packages/ui/package.json +1 -0
  35. package/evals/fixtures/a21-doc-paths/packages/ui/src/index.ts +1 -0
  36. package/evals/fixtures/a21-placeholders/.env +2 -0
  37. package/evals/fixtures/a21-placeholders/server/application.yml +9 -0
  38. package/evals/fixtures/a21-placeholders/src/pages/home.ts +1 -0
  39. package/evals/fixtures/a21-plaintext/application.yml +5 -0
  40. package/evals/fixtures/a21-plaintext/src/main/java/Ali.java +3 -0
  41. package/evals/fixtures/a21-plaintext/src/pages/home.ts +1 -0
  42. package/evals/fixtures/a6-clean/src/pages/handled.ts +8 -0
  43. package/evals/fixtures/a6-empty-catch/src/pages/empty.ts +3 -0
  44. package/evals/fixtures/ignore-active/codecraft.config.json +5 -0
  45. package/evals/fixtures/ignore-active/src/pages/home.ts +1 -0
  46. package/evals/fixtures/ignore-expired/codecraft.config.json +5 -0
  47. package/evals/fixtures/ignore-expired/src/pages/home.ts +1 -0
  48. package/evals/fixtures/library-a11/dist/tokens.css +3 -0
  49. package/evals/fixtures/library-a11/package.json +4 -0
  50. package/evals/fixtures/library-a11/src/index.ts +1 -0
  51. package/evals/fixtures/zero-files/package.json +4 -0
  52. package/evals/golden-tasks.md +6 -0
  53. package/evals/run.mjs +75 -0
  54. package/package.json +4 -2
  55. package/prompts/output.md +15 -4
  56. package/scenarios/audit-existing.md +15 -8
  57. package/src/check.mjs +290 -40
  58. package/src/cli.mjs +158 -8
  59. package/src/gates.mjs +28 -1
  60. package/src/plan.mjs +2 -0
  61. package/adapters/claude/SKILL.md +0 -42
  62. package/adapters/trae/SKILL.md +0 -42
package/src/check.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { matchPath } from './ignore.mjs'
3
4
 
4
5
  const SKIP_DIRS = new Set([
5
6
  'node_modules',
@@ -16,6 +17,10 @@ const SKIP_DIRS = new Set([
16
17
  '.output',
17
18
  '.turbo',
18
19
  '.cache',
20
+ '.agents',
21
+ '.trae',
22
+ '.claude',
23
+ '.cursor',
19
24
  ])
20
25
 
21
26
  const GENERATED_SKIP_DIRS = new Set(['node_modules', '.git', 'coverage', '.turbo', '.cache'])
@@ -23,6 +28,9 @@ const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue'
23
28
  const GENERATED_EXTENSIONS = new Set(['.ts', '.js', '.css', '.json', '.md'])
24
29
  const ALLOWED_SEGMENTS = new Set(['external', 'generated', 'api', 'adapters'])
25
30
  const GENERATED_MARKER = /生成物|generated|do not edit|勿手改|auto-generated|派生/i
31
+ /** 只认文件头里的只读声明,避免把正文里的「派生」当成生成物。 */
32
+ const GENERATED_HEADER =
33
+ /@generated|auto-generated|generated by|do not edit|本文件由.{0,60}生成|此文件由.{0,60}生成|本文件[^\n]{0,20}勿手改|此文件[^\n]{0,20}勿手改|由.{0,60}生成[^\n]{0,10}勿手改/i
26
34
  const SECRET_EXTENSIONS = new Set([
27
35
  '.ts',
28
36
  '.tsx',
@@ -37,6 +45,11 @@ const SECRET_EXTENSIONS = new Set([
37
45
  '.sh',
38
46
  '.toml',
39
47
  '.ini',
48
+ '.properties',
49
+ '.xml',
50
+ '.java',
51
+ '.kt',
52
+ '.gradle',
40
53
  ])
41
54
  const SECRET_SKIP_FILES = new Set(['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'])
42
55
  /** 文档里公开的示例值,不当作真实凭据。 */
@@ -44,10 +57,33 @@ const SECRET_ALLOW = new Set(['AKIAIOSFODNN7EXAMPLE'])
44
57
  const SECRET_PATTERNS = [
45
58
  { id: 'private key', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/ },
46
59
  { id: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
60
+ { id: 'Aliyun access key', re: /\bLTAI[0-9A-Za-z]{12,}\b/ },
47
61
  { id: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/ },
48
62
  { id: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
49
63
  { id: 'API secret key', re: /\bsk-[A-Za-z0-9]{20,}\b/ },
50
64
  ]
65
+ /** 只在配置 / 后端源码里找明文赋值,避免把业务代码里的普通字符串当密钥。 */
66
+ const ASSIGNMENT_EXTENSIONS = new Set([
67
+ '.yml',
68
+ '.yaml',
69
+ '.properties',
70
+ '.ini',
71
+ '.toml',
72
+ '.env',
73
+ '.conf',
74
+ '.config',
75
+ '.java',
76
+ '.kt',
77
+ '.gradle',
78
+ '.sh',
79
+ ])
80
+ const SECRET_ASSIGN = /(?:^|[\s(])([A-Za-z0-9_.-]+)\s*(:|=)(?!=)\s*(.+?)\s*$/
81
+ const SECRET_KEY = /(?:^|[._-])(password|passwd|pwd|secret|appsecret|api[._-]?key|access[._-]?key|token)(?:$|[._-])/i
82
+ /** 代码里的 token 多数是运行时变量;后端源码只认强口令 / 密钥类字段。 */
83
+ const SECRET_KEY_CODE = /(?:^|[._-])(password|passwd|pwd|secret|appsecret|api[._-]?key|access[._-]?key)(?:$|[._-])/i
84
+ const SECRET_KEY_EXCLUDE = /(url|uri|type|name|expires?|expiry|ttl|header|prefix|suffix|length|count|timeout)$/i
85
+ const SECRET_PLACEHOLDER =
86
+ /^(?:\$\{.*\}|\$[A-Za-z_][A-Za-z0-9_]*|#\{.*\}|%.*%|<.*>|\*+|-+|null|true|false|none|undefined|todo.*|xxx+|[a-z]*example[a-z]*|placeholder.*|your[-_].*|change[-_]?me|process\.env\..*|os\.environ.*|System\..*|getenv.*)$/i
51
87
  /** 只认内部文档特征:向上一级找根 docs,或 Ultime 的内部目录名;包自带的 docs/ 不算。 */
52
88
  const DOC_PATH = /(?:^|[\s(`'"])(?:(?:\.\.\/)+docs\/|(?:docs\/)?(?:开发计划|设计系统|工程机制)\/)/
53
89
 
@@ -57,48 +93,84 @@ const IMPORT_PATTERNS = [
57
93
  /^\s*import\s+['"]([^'"]+)['"]/gm,
58
94
  ]
59
95
 
96
+ const EMPTY_CATCH = /\bcatch\s*(?:\([^)]*\))?\s*\{([\s\S]*?)\}/g
97
+
60
98
  export function check(root, options = {}) {
61
99
  const absRoot = path.resolve(root)
62
100
  const pack = options.pack ?? 'app'
63
- if (pack === 'library') return checkLibrary(absRoot)
64
- if (pack === 'design-system') return checkDesignSystem(absRoot)
65
- return checkApp(absRoot)
101
+ if (pack === 'library') return checkLibrary(absRoot, options)
102
+ if (pack === 'design-system') return checkDesignSystem(absRoot, options)
103
+ return checkApp(absRoot, options)
66
104
  }
67
105
 
68
- function checkApp(root) {
69
- const files = collectAppFiles(root)
106
+ function checkApp(root, options = {}) {
107
+ const isExcluded = createExcluder(root, options.exclude)
108
+ const files = collectAppFiles(root).filter((file) => !isExcluded(file) && !isMinified(file))
109
+ const generated = collectGeneratedFiles(files)
70
110
  const findings = []
71
- for (const file of files) findings.push(...scanAppFile(file, root))
72
- findings.push(...scanSecrets(root))
111
+ for (const file of files) findings.push(...scanAppFile(file, root, options.boundaries, generated))
112
+ findings.push(...scanSecrets(root, isExcluded))
73
113
  return { root, files: files.length, findings }
74
114
  }
75
115
 
76
- function checkLibrary(root) {
77
- return checkPackages(root, { docPaths: false })
116
+ function checkLibrary(root, options = {}) {
117
+ return checkPackages(root, {
118
+ docPaths: false,
119
+ boundaries: options.boundaries,
120
+ exclude: options.exclude,
121
+ })
78
122
  }
79
123
 
80
- function checkDesignSystem(root) {
81
- return checkPackages(root, { docPaths: true })
124
+ function checkDesignSystem(root, options = {}) {
125
+ return checkPackages(root, {
126
+ docPaths: true,
127
+ boundaries: options.boundaries,
128
+ exclude: options.exclude,
129
+ })
82
130
  }
83
131
 
84
132
  function checkPackages(root, options) {
85
- const packages = findPackages(root)
133
+ const isExcluded = createExcluder(root, options.exclude)
134
+ const packages = findPackages(root).filter((pkg) => !isExcluded(pkg))
86
135
  const manifests = packages.map((pkg) => readJson(path.join(pkg, 'package.json')))
87
136
  const workspaceNames = new Set(manifests.map((manifest) => manifest?.name).filter(Boolean))
88
137
  const findings = []
89
138
  let files = 0
90
139
 
91
140
  for (let i = 0; i < packages.length; i += 1) {
92
- const result = checkPackage(packages[i], manifests[i], workspaceNames)
141
+ const result = checkPackage(packages[i], manifests[i], workspaceNames, isExcluded)
93
142
  files += result.files
94
143
  findings.push(...result.findings)
95
- if (options.docPaths) findings.push(...scanPackageDocs(packages[i]))
144
+ if (options.docPaths) findings.push(...scanPackageDocs(packages[i], isExcluded))
96
145
  }
97
146
 
98
- findings.push(...scanSecrets(root))
147
+ findings.push(...scanSecrets(root, isExcluded))
99
148
  return { root, files, findings }
100
149
  }
101
150
 
151
+ /**
152
+ * 扫描级排除:在收集文件时就跳过,不产生 findings。
153
+ * 与 ignore 不同,它不能豁免已发现的问题,只用于不扫描(例如测试 fixture、第三方目录)。
154
+ */
155
+ function createExcluder(root, patterns = []) {
156
+ const list = (patterns ?? [])
157
+ .map((pattern) => String(pattern).replace(/\\/g, '/').replace(/^\.\//, ''))
158
+ .filter(Boolean)
159
+ if (!list.length) return () => false
160
+
161
+ return (file) => {
162
+ const relative = path.relative(root, file).split(path.sep).join('/')
163
+ return list.some((pattern) => matchesExclude(pattern, relative))
164
+ }
165
+ }
166
+
167
+ function matchesExclude(pattern, relative) {
168
+ if (matchPath(pattern, relative)) return true
169
+ if (/[*?]/.test(pattern)) return false
170
+ const base = pattern.replace(/\/$/, '')
171
+ return relative === base || relative.startsWith(`${base}/`)
172
+ }
173
+
102
174
  function findPackages(root) {
103
175
  const packagesDir = path.join(root, 'packages')
104
176
  if (isDir(packagesDir)) {
@@ -112,7 +184,7 @@ function findPackages(root) {
112
184
  return []
113
185
  }
114
186
 
115
- function checkPackage(pkg, manifest, workspaceNames) {
187
+ function checkPackage(pkg, manifest, workspaceNames, isExcluded = () => false) {
116
188
  const name = manifest?.name ?? ''
117
189
  const deps = new Set([
118
190
  ...Object.keys(manifest?.dependencies ?? {}),
@@ -120,7 +192,9 @@ function checkPackage(pkg, manifest, workspaceNames) {
120
192
  ...Object.keys(manifest?.devDependencies ?? {}),
121
193
  ])
122
194
  const src = path.join(pkg, 'src')
123
- const sourceFiles = isDir(src) ? walkFiles(src) : []
195
+ const sourceFiles = (isDir(src) ? walkFiles(src) : []).filter(
196
+ (file) => !isExcluded(file) && !isMinified(file),
197
+ )
124
198
  const findings = []
125
199
 
126
200
  for (const file of sourceFiles) {
@@ -137,9 +211,10 @@ function checkPackage(pkg, manifest, workspaceNames) {
137
211
  message,
138
212
  })
139
213
  }
214
+ findings.push(...scanEmptyCatch(text, file, pkg))
140
215
  }
141
216
 
142
- for (const file of collectGeneratedCandidates(pkg)) {
217
+ for (const file of collectGeneratedCandidates(pkg).filter((file) => !isExcluded(file))) {
143
218
  const text = read(file)
144
219
  if (text == null || hasGeneratedMarker(text)) continue
145
220
  findings.push({
@@ -180,6 +255,21 @@ function collectAppFiles(root) {
180
255
  return files
181
256
  }
182
257
 
258
+ function collectGeneratedFiles(files) {
259
+ const generated = new Map()
260
+ for (const file of files) {
261
+ const text = read(file)
262
+ if (text == null || !hasGeneratedHeader(text)) continue
263
+ generated.set(path.resolve(file), { types: exportsTypes(text) })
264
+ }
265
+ return generated
266
+ }
267
+
268
+ /** A17 只关心外部类型契约;生成的数据 / 资源模块不算。 */
269
+ function exportsTypes(text) {
270
+ return /\bexport\s+(?:declare\s+)?(?:interface|type|enum)\b/.test(text)
271
+ }
272
+
183
273
  function collectAppRoots(root) {
184
274
  const roots = []
185
275
  const apps = path.join(root, 'apps')
@@ -264,21 +354,42 @@ function walkGenerated(dir, files, seen) {
264
354
  }
265
355
  }
266
356
 
267
- function scanAppFile(file, root) {
268
- if (isAllowedImporter(file, root)) return []
269
-
357
+ function scanAppFile(file, root, boundaries, generated) {
270
358
  const text = read(file)
271
359
  if (text == null) return []
360
+ if (generated?.has(path.resolve(file))) return []
272
361
 
273
362
  const findings = []
274
- for (const { spec, line } of collectImports(stripComments(text))) {
275
- if (!isExternalSpec(spec)) continue
363
+ if (!isAllowedImporter(file, root)) {
364
+ for (const { spec, line } of collectImports(stripComments(text))) {
365
+ const generatedImport = generated?.size ? resolveGeneratedImport(spec, file, root, generated) : false
366
+ if (!generatedImport && !isExternalSpec(spec, boundaries)) continue
367
+ findings.push({
368
+ rule: 'A17',
369
+ severity: 'warn',
370
+ file: path.relative(root, file),
371
+ line,
372
+ message: generatedImport
373
+ ? `imports generated type "${spec}"; access it through Wrapper/API (A17)`
374
+ : `imports external type "${spec}"; only Wrapper/API may import external/generated (A17)`,
375
+ })
376
+ }
377
+ }
378
+ findings.push(...scanEmptyCatch(text, file, root))
379
+ return findings
380
+ }
381
+
382
+ function scanEmptyCatch(text, file, root) {
383
+ const findings = []
384
+ const source = stripComments(text)
385
+ for (const match of source.matchAll(EMPTY_CATCH)) {
386
+ if (match[1].trim()) continue
276
387
  findings.push({
277
- rule: 'A17',
388
+ rule: 'A6',
278
389
  severity: 'warn',
279
390
  file: path.relative(root, file),
280
- line,
281
- message: `imports external type "${spec}"; only Wrapper/API may import external/generated (A17)`,
391
+ line: lineAt(source, match.index ?? 0),
392
+ message: 'empty catch swallows failures; return a Result or handle the error (A6)',
282
393
  })
283
394
  }
284
395
  return findings
@@ -315,41 +426,174 @@ function hasGeneratedMarker(text) {
315
426
  return GENERATED_MARKER.test(text.split('\n').slice(0, 20).join('\n'))
316
427
  }
317
428
 
429
+ function hasGeneratedHeader(text) {
430
+ return GENERATED_HEADER.test(text.split('\n').slice(0, 3).join('\n'))
431
+ }
432
+
433
+ function resolveGeneratedImport(spec, importer, root, generated) {
434
+ const target = resolveImportFile(spec, importer, root)
435
+ if (!target) return false
436
+ return generated.get(path.resolve(target))?.types === true
437
+ }
438
+
439
+ function resolveImportFile(spec, importer, root) {
440
+ const bases = []
441
+ if (spec.startsWith('.')) {
442
+ bases.push(path.resolve(path.dirname(importer), spec))
443
+ } else if (/^[@~#]{1,2}\//.test(spec)) {
444
+ const rest = spec.replace(/^[@~#]{1,2}\//, '')
445
+ const srcRoot = findSrcRoot(importer)
446
+ if (srcRoot) bases.push(path.resolve(srcRoot, rest))
447
+ bases.push(path.resolve(root, rest), path.resolve(root, 'src', rest))
448
+ } else {
449
+ return null
450
+ }
451
+
452
+ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.vue', '.svelte', '.d.ts']
453
+ for (const base of bases) {
454
+ for (const extension of extensions) {
455
+ if (isFile(`${base}${extension}`)) return `${base}${extension}`
456
+ }
457
+ for (const extension of extensions) {
458
+ const index = path.join(base, `index${extension}`)
459
+ if (isFile(index)) return index
460
+ }
461
+ }
462
+ return null
463
+ }
464
+
465
+ function findSrcRoot(file) {
466
+ const parts = path.dirname(file).split(path.sep)
467
+ const index = parts.lastIndexOf('src')
468
+ return index === -1 ? null : parts.slice(0, index + 1).join(path.sep) || path.sep
469
+ }
470
+
471
+ function isFile(target) {
472
+ try {
473
+ return fs.statSync(target).isFile()
474
+ } catch {
475
+ return false
476
+ }
477
+ }
478
+
318
479
  function isAllowedImporter(file, root) {
319
480
  const segments = path.relative(root, file).split(path.sep)
320
481
  return segments.some((segment) => ALLOWED_SEGMENTS.has(segment))
321
482
  }
322
483
 
323
- function isExternalSpec(spec) {
324
- return /(^|[/@])(external|generated)([/]|$)/.test(spec)
484
+ function isExternalSpec(spec, boundaries) {
485
+ if (/(^|[/@])(external|generated)([/]|$)/.test(spec)) return true
486
+ const patterns = [...(boundaries?.external ?? []), ...(boundaries?.generated ?? [])]
487
+ return patterns.some((pattern) => matchesBoundary(spec, pattern))
488
+ }
489
+
490
+ function matchesBoundary(spec, pattern) {
491
+ if (!pattern) return false
492
+ if (spec === pattern || spec.startsWith(`${pattern.replace(/\/$/, '')}/`)) return true
493
+ return /[*?]/.test(pattern) && matchPath(pattern, spec)
325
494
  }
326
495
 
327
496
  function isTestFile(file) {
328
497
  return /(^|[/\\])(__tests__|tests?)[/\\]/.test(file) || /\.(test|spec)\.[^.]+$/.test(file)
329
498
  }
330
499
 
331
- function scanSecrets(root) {
500
+ /** 压缩 / 构建产物:结构规则跳过(A21 仍扫描),避免与源码重复报同一问题。 */
501
+ function isMinified(file) {
502
+ const text = read(file)
503
+ if (text == null) return false
504
+ const lines = text.split('\n')
505
+ if (lines.length > 3) return false
506
+ return lines.some((line) => line.length > 1000)
507
+ }
508
+
509
+ function scanSecrets(root, isExcluded = () => false) {
332
510
  const findings = []
511
+ const seen = new Set()
333
512
  for (const file of collectScanFiles(root)) {
513
+ if (isExcluded(file)) continue
334
514
  if (SECRET_SKIP_FILES.has(path.basename(file))) continue
515
+ if (isTestFile(file)) continue
335
516
  const text = read(file)
336
517
  if (text == null) continue
518
+
337
519
  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({
520
+ for (const match of text.matchAll(globalize(pattern.re))) {
521
+ if (SECRET_ALLOW.has(match[0])) continue
522
+ pushSecret(findings, seen, {
523
+ rule: 'A21',
524
+ severity: 'warn',
525
+ file: path.relative(root, file),
526
+ line: lineAt(text, match.index ?? 0),
527
+ message: `possible ${pattern.id}; remove it and rotate the credential (A21)`,
528
+ })
529
+ }
530
+ }
531
+
532
+ const kind = assignmentKind(file)
533
+ if (!kind) continue
534
+ text.split('\n').forEach((line, index) => {
535
+ const assignment = parseSecretAssignment(line, kind)
536
+ if (!assignment) return
537
+ pushSecret(findings, seen, {
342
538
  rule: 'A21',
343
539
  severity: 'warn',
344
540
  file: path.relative(root, file),
345
- line: lineAt(text, match.index ?? 0),
346
- message: `possible ${pattern.id}; remove it and rotate the credential (A21)`,
541
+ line: index + 1,
542
+ message: `plaintext ${assignment.key}; move it to env / config center and rotate (A21)`,
347
543
  })
348
- }
544
+ })
349
545
  }
350
546
  return findings
351
547
  }
352
548
 
549
+ function globalize(re) {
550
+ return new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`)
551
+ }
552
+
553
+ function pushSecret(findings, seen, finding) {
554
+ const key = `${finding.file}:${finding.line}`
555
+ if (seen.has(key)) return
556
+ seen.add(key)
557
+ findings.push(finding)
558
+ }
559
+
560
+ function parseSecretAssignment(line, kind) {
561
+ const trimmed = line.trim()
562
+ if (!trimmed || /^(#|\/\/|\*|<!--)/.test(trimmed)) return null
563
+ const match = SECRET_ASSIGN.exec(line)
564
+ if (!match) return null
565
+ const key = match[1]
566
+ const keyPattern = kind === 'code' ? SECRET_KEY_CODE : SECRET_KEY
567
+ if (!keyPattern.test(key) || SECRET_KEY_EXCLUDE.test(key)) return null
568
+ if (kind === 'code' && !/^["']/.test(match[3].trim())) return null
569
+ const value = cleanSecretValue(match[3])
570
+ if (value.length < 6 || SECRET_PLACEHOLDER.test(value)) return null
571
+ return { key }
572
+ }
573
+
574
+ function cleanSecretValue(raw) {
575
+ const trimmed = raw.trim()
576
+ const quoted = /^(["'`])([\s\S]*?)\1/.exec(trimmed)
577
+ const value = quoted ? quoted[2] : trimmed.split(/\s+/)[0]
578
+ return value
579
+ .replace(/\s+#.*$/, '')
580
+ .replace(/[;,]\s*$/, '')
581
+ .trim()
582
+ }
583
+
584
+ function assignmentKind(file) {
585
+ const base = path.basename(file)
586
+ if (base === '.env' || base.startsWith('.env.')) return 'config'
587
+ const extension = path.extname(base)
588
+ if (extension === '.java' || extension === '.kt') return 'code'
589
+ return ASSIGNMENT_EXTENSIONS.has(extension) ? 'config' : null
590
+ }
591
+
592
+ function isSecretFile(name) {
593
+ if (name === '.env' || name.startsWith('.env.')) return true
594
+ return SECRET_EXTENSIONS.has(path.extname(name))
595
+ }
596
+
353
597
  function collectScanFiles(root) {
354
598
  const files = []
355
599
  walkScan(root, files, new Set())
@@ -374,11 +618,11 @@ function walkScan(dir, files, seen) {
374
618
  continue
375
619
  }
376
620
  if (!entry.isFile()) continue
377
- if (SECRET_EXTENSIONS.has(path.extname(entry.name))) files.push(full)
621
+ if (isSecretFile(entry.name)) files.push(full)
378
622
  }
379
623
  }
380
624
 
381
- function scanPackageDocs(pkg) {
625
+ function scanPackageDocs(pkg, isExcluded = () => false) {
382
626
  const findings = []
383
627
  const files = ['README.md', 'USAGE.md', 'CHANGELOG.md', 'compat.md']
384
628
  .map((name) => path.join(pkg, name))
@@ -392,6 +636,7 @@ function scanPackageDocs(pkg) {
392
636
  }
393
637
 
394
638
  for (const file of files) {
639
+ if (isExcluded(file)) continue
395
640
  const text = read(file)
396
641
  if (text == null) continue
397
642
  text.split('\n').forEach((line, index) => {
@@ -411,11 +656,16 @@ function scanPackageDocs(pkg) {
411
656
 
412
657
  function stripComments(text) {
413
658
  return text
414
- .replace(/<!--[\s\S]*?-->/g, '')
415
- .replace(/\/\*[\s\S]*?\*\//g, '')
659
+ .replace(/<!--[\s\S]*?-->/g, maskComment)
660
+ .replace(/\/\*[\s\S]*?\*\//g, maskComment)
416
661
  .replace(/(^|[^:])\/\/[^\n]*/g, '$1')
417
662
  }
418
663
 
664
+ /** 用等长空白替换注释,保留换行,避免行号漂移。 */
665
+ function maskComment(match) {
666
+ return match.replace(/[^\n]/g, ' ')
667
+ }
668
+
419
669
  function lineAt(text, index) {
420
670
  let line = 1
421
671
  for (let i = 0; i < index; i += 1) {