@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.
- package/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +3 -0
- package/README.md +78 -39
- package/SKILL.md +13 -9
- package/VERSION +1 -1
- package/adapters/README.md +3 -4
- package/codecraft.config.example.json +5 -1
- package/docs/00-constitution.md +5 -5
- package/docs/01-family-cards.md +2 -2
- package/docs/02-ai-contract.md +14 -3
- package/docs/04-gates.md +21 -6
- package/docs/06-architecture.md +5 -3
- package/docs/07-ai-prompts.md +16 -7
- package/docs/08-audit-contract.md +141 -0
- package/docs/09-glossary.md +22 -0
- package/evals/cases.json +110 -0
- package/evals/fixtures/a11-marker/packages/core/dist/tokens.css +4 -0
- package/evals/fixtures/a11-marker/packages/core/package.json +1 -0
- package/evals/fixtures/a11-marker/packages/core/src/index.ts +1 -0
- package/evals/fixtures/a17-boundaries/codecraft.config.json +5 -0
- package/evals/fixtures/a17-boundaries/src/pages/home.ts +1 -0
- package/evals/fixtures/a17-default/src/pages/home.ts +1 -0
- package/evals/fixtures/a17-generated-data/src/icons/icons.ts +2 -0
- package/evals/fixtures/a17-generated-data/src/pages/home.ts +3 -0
- package/evals/fixtures/a17-generated-header/src/pages/home.ts +5 -0
- package/evals/fixtures/a17-generated-header/src/types/project.ts +4 -0
- package/evals/fixtures/a17-local/src/pages/home.ts +4 -0
- package/evals/fixtures/a17-local/src/types/local.ts +3 -0
- package/evals/fixtures/a17-local/src/utils/format.ts +3 -0
- package/evals/fixtures/a2-declared/packages/core/package.json +6 -0
- package/evals/fixtures/a2-declared/packages/core/src/index.ts +3 -0
- package/evals/fixtures/a2-declared/packages/kit/package.json +1 -0
- package/evals/fixtures/a2-workspace/packages/core/package.json +1 -0
- package/evals/fixtures/a2-workspace/packages/core/src/index.ts +3 -0
- package/evals/fixtures/a2-workspace/packages/kit/package.json +1 -0
- package/evals/fixtures/a21-doc-paths/packages/ui/README.md +5 -0
- package/evals/fixtures/a21-doc-paths/packages/ui/package.json +1 -0
- package/evals/fixtures/a21-doc-paths/packages/ui/src/index.ts +1 -0
- package/evals/fixtures/a21-placeholders/.env +2 -0
- package/evals/fixtures/a21-placeholders/server/application.yml +9 -0
- package/evals/fixtures/a21-placeholders/src/pages/home.ts +1 -0
- package/evals/fixtures/a21-plaintext/application.yml +5 -0
- package/evals/fixtures/a21-plaintext/src/main/java/Ali.java +3 -0
- package/evals/fixtures/a21-plaintext/src/pages/home.ts +1 -0
- package/evals/fixtures/a6-clean/src/pages/handled.ts +8 -0
- package/evals/fixtures/a6-empty-catch/src/pages/empty.ts +3 -0
- package/evals/fixtures/ignore-active/codecraft.config.json +5 -0
- package/evals/fixtures/ignore-active/src/pages/home.ts +1 -0
- package/evals/fixtures/ignore-expired/codecraft.config.json +5 -0
- package/evals/fixtures/ignore-expired/src/pages/home.ts +1 -0
- package/evals/fixtures/library-a11/dist/tokens.css +3 -0
- package/evals/fixtures/library-a11/package.json +4 -0
- package/evals/fixtures/library-a11/src/index.ts +1 -0
- package/evals/fixtures/zero-files/package.json +4 -0
- package/evals/golden-tasks.md +14 -1
- package/evals/run.mjs +75 -0
- package/package.json +4 -2
- package/prompts/output.md +19 -4
- package/scenarios/architecture-report.md +88 -0
- package/scenarios/audit-existing.md +15 -8
- package/scenarios/new-project.md +1 -1
- package/scenarios/refactor-review.md +1 -1
- package/src/check.mjs +290 -40
- package/src/cli.mjs +158 -8
- package/src/gates.mjs +28 -1
- package/src/plan.mjs +2 -0
- package/templates/README.md +3 -1
- package/templates/architecture-report.md +97 -0
- package/adapters/claude/SKILL.md +0 -42
- 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.
|
|
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
|
-
-
|
|
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
|
+
- 计数可用文中命令复现
|
package/scenarios/new-project.md
CHANGED
|
@@ -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`(档位 / 七步法 /
|
|
20
|
+
5. `../docs/06-architecture.md`(档位 / 七步法 / 边界);决策用 ADR
|
|
21
21
|
6. 需要选型时:`../docs/05-patterns.md`
|
|
22
22
|
|
|
23
23
|
## 步骤
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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: '
|
|
388
|
+
rule: 'A6',
|
|
278
389
|
severity: 'warn',
|
|
279
390
|
file: path.relative(root, file),
|
|
280
|
-
line,
|
|
281
|
-
message:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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:
|
|
346
|
-
message: `
|
|
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 (
|
|
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) {
|