@ultime/codecraft 1.4.0 → 1.6.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 (70) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTRIBUTING.md +3 -0
  3. package/README.md +78 -39
  4. package/SKILL.md +13 -9
  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 +5 -5
  9. package/docs/01-family-cards.md +2 -2
  10. package/docs/02-ai-contract.md +14 -3
  11. package/docs/04-gates.md +21 -6
  12. package/docs/06-architecture.md +5 -3
  13. package/docs/07-ai-prompts.md +16 -7
  14. package/docs/08-audit-contract.md +141 -0
  15. package/docs/09-glossary.md +22 -0
  16. package/evals/cases.json +110 -0
  17. package/evals/fixtures/a11-marker/packages/core/dist/tokens.css +4 -0
  18. package/evals/fixtures/a11-marker/packages/core/package.json +1 -0
  19. package/evals/fixtures/a11-marker/packages/core/src/index.ts +1 -0
  20. package/evals/fixtures/a17-boundaries/codecraft.config.json +5 -0
  21. package/evals/fixtures/a17-boundaries/src/pages/home.ts +1 -0
  22. package/evals/fixtures/a17-default/src/pages/home.ts +1 -0
  23. package/evals/fixtures/a17-generated-data/src/icons/icons.ts +2 -0
  24. package/evals/fixtures/a17-generated-data/src/pages/home.ts +3 -0
  25. package/evals/fixtures/a17-generated-header/src/pages/home.ts +5 -0
  26. package/evals/fixtures/a17-generated-header/src/types/project.ts +4 -0
  27. package/evals/fixtures/a17-local/src/pages/home.ts +4 -0
  28. package/evals/fixtures/a17-local/src/types/local.ts +3 -0
  29. package/evals/fixtures/a17-local/src/utils/format.ts +3 -0
  30. package/evals/fixtures/a2-declared/packages/core/package.json +6 -0
  31. package/evals/fixtures/a2-declared/packages/core/src/index.ts +3 -0
  32. package/evals/fixtures/a2-declared/packages/kit/package.json +1 -0
  33. package/evals/fixtures/a2-workspace/packages/core/package.json +1 -0
  34. package/evals/fixtures/a2-workspace/packages/core/src/index.ts +3 -0
  35. package/evals/fixtures/a2-workspace/packages/kit/package.json +1 -0
  36. package/evals/fixtures/a21-doc-paths/packages/ui/README.md +5 -0
  37. package/evals/fixtures/a21-doc-paths/packages/ui/package.json +1 -0
  38. package/evals/fixtures/a21-doc-paths/packages/ui/src/index.ts +1 -0
  39. package/evals/fixtures/a21-placeholders/.env +2 -0
  40. package/evals/fixtures/a21-placeholders/server/application.yml +9 -0
  41. package/evals/fixtures/a21-placeholders/src/pages/home.ts +1 -0
  42. package/evals/fixtures/a21-plaintext/application.yml +5 -0
  43. package/evals/fixtures/a21-plaintext/src/main/java/Ali.java +3 -0
  44. package/evals/fixtures/a21-plaintext/src/pages/home.ts +1 -0
  45. package/evals/fixtures/a6-clean/src/pages/handled.ts +8 -0
  46. package/evals/fixtures/a6-empty-catch/src/pages/empty.ts +3 -0
  47. package/evals/fixtures/ignore-active/codecraft.config.json +5 -0
  48. package/evals/fixtures/ignore-active/src/pages/home.ts +1 -0
  49. package/evals/fixtures/ignore-expired/codecraft.config.json +5 -0
  50. package/evals/fixtures/ignore-expired/src/pages/home.ts +1 -0
  51. package/evals/fixtures/library-a11/dist/tokens.css +3 -0
  52. package/evals/fixtures/library-a11/package.json +4 -0
  53. package/evals/fixtures/library-a11/src/index.ts +1 -0
  54. package/evals/fixtures/zero-files/package.json +4 -0
  55. package/evals/golden-tasks.md +14 -1
  56. package/evals/run.mjs +75 -0
  57. package/package.json +4 -2
  58. package/prompts/output.md +19 -4
  59. package/scenarios/architecture-report.md +88 -0
  60. package/scenarios/audit-existing.md +15 -8
  61. package/scenarios/new-project.md +1 -1
  62. package/scenarios/refactor-review.md +1 -1
  63. package/src/check.mjs +290 -40
  64. package/src/cli.mjs +158 -8
  65. package/src/gates.mjs +28 -1
  66. package/src/plan.mjs +2 -0
  67. package/templates/README.md +3 -1
  68. package/templates/architecture-report.md +97 -0
  69. package/adapters/claude/SKILL.md +0 -42
  70. package/adapters/trae/SKILL.md +0 -42
@@ -0,0 +1,88 @@
1
+ # Architecture Report(代码架构分析报告)
2
+
3
+ > 定位:把代码架构讲清楚,形成一份人能读、能评审、能执行的 Markdown 文档。
4
+ > 范围:只做代码架构;业务能力、角色、业务流程、领域规则不在本 skill 范围。
5
+ > 关系:方法见 `docs/06-architecture.md`;证据门槛见 `docs/08-audit-contract.md`;模板见 `templates/architecture-report.md`。
6
+
7
+ ## 何时用
8
+
9
+ - 接手老项目,需要一份能读的代码架构说明。
10
+ - 重构 / 迁移 / 拆包前,先固定现状与约束。
11
+ - 新项目立项,需要目标架构、边界与阶段计划。
12
+ - 向人 / 客户汇报代码架构(不含业务架构)。
13
+
14
+ 不适用:业务能力地图、角色与流程、领域建模、UI 设计、运行时性能、渗透测试。
15
+
16
+ ## 输入
17
+
18
+ - 仓库路径 / 分支 / 目录范围。
19
+ - 模式:老项目 `Audit`;新项目 `Plan`;只出报告 `Audit`。
20
+ - 目标:体检 / 重构 / 迁移 / 新建立 / 汇报。
21
+ - 约束:能否跑命令、时间范围、必须保留的边界。
22
+ - 已知业务背景(可选):只作为上下文,不写成业务架构结论。
23
+
24
+ ## 先读
25
+
26
+ 1. 项目 `AGENTS.md` / `rules/`
27
+ 2. `docs/00-constitution.md`
28
+ 3. `docs/06-architecture.md`
29
+ 4. 只读相关族卡:`docs/01-family-cards.md`
30
+ 5. 审计模式:`docs/08-audit-contract.md`
31
+ 6. 输出:`prompts/output.md` + `templates/architecture-report.md`
32
+
33
+ ## 步骤
34
+
35
+ 1. **确认范围**:仓库 / 目录 / 包、只读声明、能否跑命令、本次不覆盖什么。
36
+ 2. **盘点**:技术栈、入口、页面 / 路由、模块、包、请求层、状态、类型、生成物、测试与门禁。
37
+ 3. **结构**(用表格 / 列表,不画图):
38
+ - 系统上下文:外部系统、运行时、部署单元。
39
+ - 容器 / 模块:职责、边界、依赖方向。
40
+ - 公共 API:哪些是契约,哪些是内部实现。
41
+ 4. **关键链路**:请求链、状态链、错误模型、数据流、生成链;每段给 `file:line`。
42
+ 5. **跑静态检查**:`codecraft check / audit`,记录 `files / findings / 覆盖范围`;写明 A17 / A21 盲区。
43
+ 6. **逐条判定**:A1–A21,标 `pass` / `pass(静态)` / `warn` / `fail` / `na`,每条带证据与局限。
44
+ 7. **风险与技术债**:按 P0–P3 排;区分「已发生」与「可能发生」。
45
+ 8. **演进计划**:
46
+ - 老项目:现状 → 目标 → 分阶段迁移,每阶段可回滚、有验收命令。
47
+ - 新项目:目标架构 → 边界 → 目录 / 契约 → 阶段计划。
48
+ 9. **未知项**:没有证据的角色、规则、运行时行为单列,不猜。
49
+
50
+ ## 输出
51
+
52
+ - 一份 Markdown 报告,按 `templates/architecture-report.md`。
53
+ - 默认不画图;用表格、列表、缩进表达结构。
54
+ - 如果后续需要图,图只作为视图,源仍写在 Markdown 里,不把图片当唯一真源。
55
+ - 报告落在项目 `docs/` 下;文件名建议 `<主题>-代码架构分析报告.md`,并纳入项目文档索引。
56
+
57
+ ## 证据
58
+
59
+ - 每条结论给 `path/to/file:line`,或可复现命令。
60
+ - 计数写检索式与统计口径(行 / 处 / 文件)。
61
+ - `0 findings` 只能写「静态检查未命中」,不能写成「架构通过」。
62
+ - 不写密钥、客户名、本机绝对路径;发现即停止并提示轮换。
63
+
64
+ ## 停止条件
65
+
66
+ - 无法只读访问仓库。
67
+ - 需要改动生产配置或运行服务才能判断。
68
+ - 发现凭证 / 隐私。
69
+ - 业务结论超出代码证据,必须由人确认。
70
+
71
+ ## 验收
72
+
73
+ - 报告结构与模板一致,结论 / 证据 / 风险 / 计划 / 未知项齐全。
74
+ - A1–A21 全覆盖或写 `na`;每条有证据。
75
+ - 明确写了「只做代码架构,不含业务架构」。
76
+ - Audit / Plan 模式未改任何文件。
77
+ - 计数与命令可复现。
78
+
79
+ ## 与其他场景的区别
80
+
81
+ | 场景 | 侧重 | 产出 |
82
+ |---|---|---|
83
+ | `audit-existing.md` | A1–A21 体检 | findings、差距、升级计划 |
84
+ | `new-project.md` | 新项目设计 | 目标架构、骨架、阶段计划 |
85
+ | `report-only.md` | 只要 findings | 只读问题清单 |
86
+ | 本场景 | 结构叙事 + 演进 | 一份完整代码架构分析报告 |
87
+
88
+ 本场景可以复用 `audit-existing` 的 findings,但不重复做业务架构。
@@ -15,17 +15,21 @@
15
15
 
16
16
  ## 步骤
17
17
 
18
- 1. 盘点:技术栈、目录、页面、请求层、状态、类型、测试。
19
- 2. 逐条对照 A1–A21;标 pass / warn / fail。
20
- 3. 找“历史实现”与“环节缺失”:哪些是习惯,哪些是必需。
21
- 4. 出差距清单:影响、成本、优先级。
22
- 5. 出升级计划:分阶段、可回滚、每阶段验收。
23
- 6. 风险:兼容、数据、权限、发布。
18
+ 1. 确认范围:仓库 / 分支 / 目录、只读声明、能否跑命令。
19
+ 2. 盘点:技术栈、目录、页面、请求层、状态、类型、测试。
20
+ 3. 先跑静态检查,记录 `files / findings` 与覆盖范围;写明 A17 / A21 盲区。
21
+ 4. 逐条对照 A1–A21;标 `pass` / `pass(静态)` / `warn` / `fail` / `na`,每条带证据。
22
+ 5. 做一致性检查:A4 / A8 / A11 / A17 / A21 与证据矛盾时降级。
23
+ 6. 找“历史实现”与“环节缺失”:哪些是习惯,哪些是必需。
24
+ 7. 出差距清单:影响、成本、优先级(P0–P3)。
25
+ 8. 出升级计划:分阶段、可回滚、每阶段验收;不写时间估算。
26
+ 9. 风险:兼容、数据、权限、发布。
24
27
 
25
28
  ## 输出
26
29
 
27
- - 体检报告
28
- - 差距清单(severity)
30
+ - 体检报告(按 `docs/08-audit-contract.md`)
31
+ - 逐条判定表(A1–A21,含证据与局限)
32
+ - 差距清单(P0–P3)
29
33
  - 升级计划
30
34
  - 回滚方案
31
35
 
@@ -38,4 +42,7 @@
38
42
  ## 验收
39
43
 
40
44
  - 只读;不改代码
45
+ - A1–A21 全覆盖或写明 `na`
41
46
  - 每条结论有文件路径 / 命令 / A-ID
47
+ - `0 findings` 已注明“仅静态未命中”,没有写成通过
48
+ - 计数可用文中命令复现
@@ -17,7 +17,7 @@
17
17
  2. `../docs/00-constitution.md`
18
18
  3. `../docs/01-family-cards.md`(只读相关族)
19
19
  4. `../docs/03-style-map.md`
20
- 5. `../docs/06-architecture.md`(档位 / 七步法 / 边界 / ADR)
20
+ 5. `../docs/06-architecture.md`(档位 / 七步法 / 边界);决策用 ADR
21
21
  6. 需要选型时:`../docs/05-patterns.md`
22
22
 
23
23
  ## 步骤
@@ -6,7 +6,7 @@
6
6
 
7
7
  ## 模式
8
8
 
9
- Apply(执行)或 Review(只评审)。
9
+ Apply 或 Review(只评审)。
10
10
 
11
11
  ## 步骤
12
12
 
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) {