@ultime/codecraft 1.0.3 → 1.2.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 +15 -0
- package/CONTRIBUTING.md +12 -1
- package/README.md +93 -33
- package/SKILL.md +67 -0
- package/VERSION +1 -1
- package/adapters/README.md +2 -0
- package/adapters/claude/SKILL.md +3 -1
- package/adapters/generic/AGENTS.md +2 -0
- package/adapters/trae/SKILL.md +3 -1
- package/codecraft.config.example.json +8 -0
- package/docs/00-constitution.md +17 -5
- package/docs/01-family-cards.md +44 -21
- package/docs/02-ai-contract.md +7 -6
- package/docs/03-style-map.md +1 -0
- package/docs/04-gates.md +5 -0
- package/docs/05-patterns.md +1 -0
- package/docs/06-architecture.md +2 -0
- package/docs/07-ai-prompts.md +17 -1
- package/evals/golden-tasks.md +9 -0
- package/package.json +5 -3
- package/src/cli.mjs +153 -13
- package/src/gates.mjs +8 -5
- package/src/ignore.mjs +145 -0
- package/src/install.mjs +100 -0
- package/templates/app/README.md +2 -1
package/docs/04-gates.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Gates(两层门禁)
|
|
2
2
|
|
|
3
3
|
> Codecraft 只内置“规范性通用门禁”;业务门禁由项目实现,Codecraft 负责编排与汇总。
|
|
4
|
+
> 示例命令按 JS/TS 生态(`pnpm`)书写;gate 契约本身与语言无关,Node 项目同样适用。
|
|
4
5
|
|
|
5
6
|
## 1. 两层模型
|
|
6
7
|
|
|
@@ -30,12 +31,16 @@
|
|
|
30
31
|
"gates": [
|
|
31
32
|
{ "name": "typecheck", "run": "pnpm typecheck", "timeoutMs": 120000 },
|
|
32
33
|
{ "name": "test", "run": "pnpm test", "timeoutMs": 120000 }
|
|
34
|
+
],
|
|
35
|
+
"ignore": [
|
|
36
|
+
{ "rule": "A17", "file": "src/pages/legacy/**", "reason": "legacy page", "expires": "2026-12-31" }
|
|
33
37
|
]
|
|
34
38
|
}
|
|
35
39
|
```
|
|
36
40
|
|
|
37
41
|
- `pack`:`app` / `library` / `design-system` / `auto`;CLI `--pack` 优先。
|
|
38
42
|
- `gates`:数组;每项必须有 `run`,可选 `name` / `timeoutMs`。
|
|
43
|
+
- `ignore`:例外登记(A12);`rule` / `file` / `reason` 必填,`expires` 可选。过期条目不生效,报告中单列;安全规则 `A21` 不可豁免。
|
|
39
44
|
- 配置文件默认 `codecraft.config.json`;可用 `--config` 指定。
|
|
40
45
|
|
|
41
46
|
## 4. 按项目类型选门禁
|
package/docs/05-patterns.md
CHANGED
package/docs/06-architecture.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 用途:系统级 / 模块级 / 演进级的决策方法;代码级模式见 `05-patterns.md`。
|
|
4
4
|
> 原则:**方法先于模式;约束先于图;边界先于抽象。**
|
|
5
|
+
> 范围:方法本身与语言无关,可用于 JS/TS 前端与 Node 后端;文中目录与框架取舍以 JS/TS 为准。
|
|
5
6
|
> 术语:English(中文)。
|
|
6
7
|
|
|
7
8
|
## 0. 先分开六个词
|
|
@@ -104,6 +105,7 @@ src/
|
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
- 依赖只能向下;同层不互相 import;跨模块只走 public API。
|
|
108
|
+
- 结构默认与框架无关:原生能表达的先写原生,Vue / React 只作页面与组件的落点。
|
|
107
109
|
- `shared/api` + adapter + interceptors 是承重墙,不塞进 feature。
|
|
108
110
|
- server state 与 client state 分开:server state 用 query 层;client state 用 store / 状态机。
|
|
109
111
|
- 测试分层:纯 core 单测 → feature 行为测试 → 页面 smoke → API 契约测试。
|
package/docs/07-ai-prompts.md
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
# AI 使用提示词(Prompts)
|
|
2
2
|
|
|
3
3
|
> 用途:给使用者一份可以直接复制的说法,让 AI 按 Codecraft 的模式工作。
|
|
4
|
+
> 范围:提示词按 JS/TS 项目写;Node 后端只复用审计 / 架构 / 评审类提示词。
|
|
4
5
|
> 读法:先看「最小开场」,再按场景复制;提示词不替代规则,规则正文仍在 `docs/`。
|
|
5
6
|
> 关系:入口见 `adapters/`;输出格式见 `prompts/output.md`;约束见 `02-ai-contract.md`。
|
|
6
7
|
|
|
7
8
|
## 1. 先让 AI 找到 Codecraft
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
最快的方式:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @ultime/codecraft install
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
或走 npm 同步:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm i -D @ultime/codecraft skills-npm
|
|
20
|
+
npx skills-npm setup
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
装好后项目里会有 `.agents/skills/codecraft/SKILL.md`(以及检测到的 agent 目录)。
|
|
24
|
+
|
|
25
|
+
也可以手动在 agent 入口里留一条指针,不要复制规则:
|
|
10
26
|
|
|
11
27
|
- Trae:项目 `.trae/rules/codecraft.md`
|
|
12
28
|
- Claude:`CLAUDE.md`
|
package/evals/golden-tasks.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Golden Tasks(评估任务)
|
|
2
2
|
|
|
3
|
+
> 任务默认按 JS/TS 前端项目设计;Node 后端只复用审计 / 架构 / 评审类任务,数据库、缓存、并发不在本批评估内。
|
|
4
|
+
|
|
3
5
|
用于验证 skill 是否真的按规范工作。每条任务记录:结果、版本、误报 / 漏报。
|
|
4
6
|
|
|
5
7
|
## 1. 新项目架构计划
|
|
@@ -107,6 +109,13 @@
|
|
|
107
109
|
- 期望:命中 A21;只报文件与行号,不回显密钥;提示轮换。
|
|
108
110
|
- 失败信号:漏报;把密钥原文打印到报告;把普通变量名误报为密钥。
|
|
109
111
|
|
|
112
|
+
## 16. 安装到 agent
|
|
113
|
+
|
|
114
|
+
- 输入:空项目,无 agent 配置目录。
|
|
115
|
+
- 命令:`codecraft install --root . --agent generic`;再执行一次;最后 `--force`。
|
|
116
|
+
- 期望:生成 `.agents/skills/codecraft/SKILL.md`、`docs/`、`scenarios/`、`prompts/`;第二次 skip;`--force` 覆盖。
|
|
117
|
+
- 失败信号:复制到错误目录;覆盖用户已有内容;`SKILL.md` 引用断链。
|
|
118
|
+
|
|
110
119
|
## 评分
|
|
111
120
|
|
|
112
121
|
- 每条任务:pass / fail。
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultime/codecraft",
|
|
3
|
-
"version": "1.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "面向 JS/TS 的代码规范与架构设计(前端默认,Node 后端可复用结构规则),作为可安装的 skill 包。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
7
7
|
"code-standard",
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"audit",
|
|
11
11
|
"architecture",
|
|
12
12
|
"review",
|
|
13
|
-
"scaffold"
|
|
13
|
+
"scaffold",
|
|
14
|
+
"agent-skills"
|
|
14
15
|
],
|
|
15
16
|
"author": "UltimeLam",
|
|
16
17
|
"license": "MIT",
|
|
@@ -30,6 +31,7 @@
|
|
|
30
31
|
"codecraft": "bin/codecraft.mjs"
|
|
31
32
|
},
|
|
32
33
|
"files": [
|
|
34
|
+
"SKILL.md",
|
|
33
35
|
"bin",
|
|
34
36
|
"src",
|
|
35
37
|
"docs",
|
package/src/cli.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { check } from './check.mjs'
|
|
|
4
4
|
import { loadConfig, runGates } from './gates.mjs'
|
|
5
5
|
import { buildPlan, summarizePlan } from './plan.mjs'
|
|
6
6
|
import { applyInitPlan, buildInitPlan } from './init.mjs'
|
|
7
|
+
import { applyIgnores } from './ignore.mjs'
|
|
8
|
+
import { applyInstallPlan, buildInstallPlan, parseAgents } from './install.mjs'
|
|
7
9
|
|
|
8
10
|
const PACKAGE = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
9
11
|
const VERSION = PACKAGE.version
|
|
@@ -15,12 +17,18 @@ Commands:
|
|
|
15
17
|
audit check + configured gates; read-only report
|
|
16
18
|
plan Read-only upgrade plan from findings
|
|
17
19
|
init Scaffold an app or package (dry-run unless --apply)
|
|
20
|
+
install Install the skill into agent skills directories
|
|
18
21
|
version Print version
|
|
19
22
|
|
|
20
23
|
Options:
|
|
21
24
|
--root <path> Project root (default: cwd)
|
|
22
25
|
--pack app|library|design-system|auto Rule pack (default: auto)
|
|
23
26
|
--config <path> Config file (default: codecraft.config.json)
|
|
27
|
+
--agent auto|claude|trae|cursor|generic Install target, comma-separated (default: auto)
|
|
28
|
+
--global Install into the home directory
|
|
29
|
+
--dest <path> Explicit destination (relative to root / home)
|
|
30
|
+
--force Replace an existing codecraft skill directory
|
|
31
|
+
--dry-run Show the install plan without writing
|
|
24
32
|
--no-gates Skip gates in audit / plan
|
|
25
33
|
--with-gates Run configured gates in plan
|
|
26
34
|
--fail-fast Stop gates after the first failure
|
|
@@ -51,17 +59,53 @@ export function main(argv) {
|
|
|
51
59
|
}
|
|
52
60
|
|
|
53
61
|
const command = args.command || 'check'
|
|
54
|
-
if (!['check', 'audit', 'plan', 'init'].includes(command)) {
|
|
55
|
-
console.error(`codecraft: unknown command "${command}"`)
|
|
62
|
+
if (!['check', 'audit', 'plan', 'init', 'install'].includes(command)) {
|
|
63
|
+
console.error(`codecraft: unknown command "${command}"\n next: run codecraft --help`)
|
|
56
64
|
return 2
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
const root = path.resolve(process.cwd(), args.root)
|
|
60
68
|
if (!fs.existsSync(root)) {
|
|
61
|
-
console.error(`codecraft: root not found: ${root}`)
|
|
69
|
+
console.error(`codecraft: root not found: ${root}\n next: pass --root <project> or run from the project directory`)
|
|
62
70
|
return 2
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
if (command === 'install') {
|
|
74
|
+
let agents
|
|
75
|
+
try {
|
|
76
|
+
agents = parseAgents(args.agent)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error(`codecraft: ${error.message}`)
|
|
79
|
+
return 2
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const plan = buildInstallPlan(root, { agents, global: args.global, dest: args.dest })
|
|
83
|
+
const applied = args.dryRun ? null : applyInstallPlan(plan, { force: args.force })
|
|
84
|
+
const meta = {
|
|
85
|
+
version: VERSION,
|
|
86
|
+
mode: 'install',
|
|
87
|
+
root,
|
|
88
|
+
scope: args.global ? 'global' : 'project',
|
|
89
|
+
agent: args.agent,
|
|
90
|
+
dest: args.dest || null,
|
|
91
|
+
dryRun: args.dryRun,
|
|
92
|
+
targets: plan.length,
|
|
93
|
+
created: applied?.created.length ?? 0,
|
|
94
|
+
replaced: applied?.replaced.length ?? 0,
|
|
95
|
+
skipped: applied?.skipped.length ?? 0,
|
|
96
|
+
}
|
|
97
|
+
if (args.format === 'json') {
|
|
98
|
+
console.log(JSON.stringify({ ...meta, plan, applied }, null, 2))
|
|
99
|
+
} else {
|
|
100
|
+
console.log(formatInstall(meta, plan, applied))
|
|
101
|
+
}
|
|
102
|
+
return plan.length ? 0 : 1
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(`codecraft: ${error.message}`)
|
|
105
|
+
return 1
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
65
109
|
let config
|
|
66
110
|
try {
|
|
67
111
|
config = loadConfig(root, args.config)
|
|
@@ -72,13 +116,15 @@ export function main(argv) {
|
|
|
72
116
|
|
|
73
117
|
const pack = resolvePack(args.pack, config.config.pack, root)
|
|
74
118
|
if (!pack) {
|
|
75
|
-
console.error(
|
|
119
|
+
console.error(
|
|
120
|
+
`codecraft: invalid config pack "${config.config.pack}"\n next: use app, library, design-system or auto`,
|
|
121
|
+
)
|
|
76
122
|
return 2
|
|
77
123
|
}
|
|
78
124
|
|
|
79
125
|
if (command === 'init') {
|
|
80
126
|
if (args.apply && !args.name) {
|
|
81
|
-
console.error('codecraft: init --apply requires --name <name>')
|
|
127
|
+
console.error('codecraft: init --apply requires --name <name>\n next: add --name <demo>')
|
|
82
128
|
return 2
|
|
83
129
|
}
|
|
84
130
|
const plan = buildInitPlan(root, pack === 'design-system' ? 'library' : pack, args.name)
|
|
@@ -106,10 +152,12 @@ export function main(argv) {
|
|
|
106
152
|
const result = check(root, { pack })
|
|
107
153
|
const severity = args.ci ? 'error' : args.severity
|
|
108
154
|
const findings = result.findings.map((finding) => ({ ...finding, severity }))
|
|
155
|
+
const filtered = applyIgnores(findings, config.config.ignore ?? [])
|
|
156
|
+
const activeFindings = filtered.findings
|
|
109
157
|
const gates = shouldRunGates(command, args)
|
|
110
158
|
? runGates(root, config.config.gates ?? [], { failFast: args.failFast })
|
|
111
159
|
: []
|
|
112
|
-
const plan = command === 'plan' ? buildPlan(
|
|
160
|
+
const plan = command === 'plan' ? buildPlan(activeFindings) : []
|
|
113
161
|
const meta = {
|
|
114
162
|
version: VERSION,
|
|
115
163
|
mode: command,
|
|
@@ -119,7 +167,9 @@ export function main(argv) {
|
|
|
119
167
|
severity,
|
|
120
168
|
rules: pack === 'app' ? ['A17', 'A21'] : ['A2', 'A11', 'A21'],
|
|
121
169
|
files: result.files,
|
|
122
|
-
findings:
|
|
170
|
+
findings: activeFindings.length,
|
|
171
|
+
ignored: filtered.ignored.length,
|
|
172
|
+
expired: filtered.expired.length,
|
|
123
173
|
plan: plan.length,
|
|
124
174
|
gates: gates.length,
|
|
125
175
|
}
|
|
@@ -129,12 +179,16 @@ export function main(argv) {
|
|
|
129
179
|
JSON.stringify(
|
|
130
180
|
{
|
|
131
181
|
...meta,
|
|
132
|
-
findings,
|
|
182
|
+
findings: activeFindings,
|
|
183
|
+
ignored: filtered.ignored,
|
|
184
|
+
exceptions: filtered.exceptions,
|
|
133
185
|
plan,
|
|
134
186
|
gates,
|
|
135
187
|
summary: {
|
|
136
188
|
files: result.files,
|
|
137
|
-
findings:
|
|
189
|
+
findings: activeFindings.length,
|
|
190
|
+
ignored: filtered.ignored.length,
|
|
191
|
+
expired: filtered.expired.length,
|
|
138
192
|
plan: plan.length,
|
|
139
193
|
gates: gates.length,
|
|
140
194
|
gateFailures: gates.filter((gate) => gate.exitCode !== 0).length,
|
|
@@ -145,12 +199,14 @@ export function main(argv) {
|
|
|
145
199
|
),
|
|
146
200
|
)
|
|
147
201
|
} else if (command === 'plan') {
|
|
148
|
-
console.log(formatPlan(meta,
|
|
202
|
+
console.log(formatPlan(meta, activeFindings, plan, gates))
|
|
149
203
|
} else {
|
|
150
|
-
console.log(formatReport(meta,
|
|
204
|
+
console.log(formatReport(meta, activeFindings, filtered.ignored, filtered.expired, gates))
|
|
151
205
|
}
|
|
152
206
|
|
|
153
|
-
const failed =
|
|
207
|
+
const failed =
|
|
208
|
+
activeFindings.some((finding) => finding.severity === 'error') ||
|
|
209
|
+
gates.some((gate) => gate.exitCode !== 0)
|
|
154
210
|
return failed ? 1 : 0
|
|
155
211
|
}
|
|
156
212
|
|
|
@@ -180,6 +236,11 @@ function parseArgs(argv) {
|
|
|
180
236
|
failFast: false,
|
|
181
237
|
name: '',
|
|
182
238
|
apply: false,
|
|
239
|
+
agent: 'auto',
|
|
240
|
+
global: false,
|
|
241
|
+
dest: '',
|
|
242
|
+
force: false,
|
|
243
|
+
dryRun: false,
|
|
183
244
|
format: 'md',
|
|
184
245
|
severity: 'warn',
|
|
185
246
|
ci: false,
|
|
@@ -196,7 +257,12 @@ function parseArgs(argv) {
|
|
|
196
257
|
else if (arg === '--with-gates') args.withGates = true
|
|
197
258
|
else if (arg === '--fail-fast') args.failFast = true
|
|
198
259
|
else if (arg === '--apply') args.apply = true
|
|
260
|
+
else if (arg === '--global') args.global = true
|
|
261
|
+
else if (arg === '--force') args.force = true
|
|
262
|
+
else if (arg === '--dry-run') args.dryRun = true
|
|
199
263
|
else if (arg === '--name') args.name = argv[++i] ?? ''
|
|
264
|
+
else if (arg === '--agent') args.agent = argv[++i] ?? 'auto'
|
|
265
|
+
else if (arg === '--dest') args.dest = argv[++i] ?? ''
|
|
200
266
|
else if (arg === '--root') args.root = argv[++i] ?? '.'
|
|
201
267
|
else if (arg === '--pack') args.pack = argv[++i] ?? 'auto'
|
|
202
268
|
else if (arg === '--config') args.config = argv[++i] ?? ''
|
|
@@ -227,7 +293,7 @@ function fail(message) {
|
|
|
227
293
|
process.exit(2)
|
|
228
294
|
}
|
|
229
295
|
|
|
230
|
-
function formatReport(meta, findings, gates) {
|
|
296
|
+
function formatReport(meta, findings, ignored, expired, gates) {
|
|
231
297
|
const lines = header(meta)
|
|
232
298
|
|
|
233
299
|
if (!findings.length) {
|
|
@@ -239,6 +305,35 @@ function formatReport(meta, findings, gates) {
|
|
|
239
305
|
}
|
|
240
306
|
}
|
|
241
307
|
|
|
308
|
+
if (ignored.length) {
|
|
309
|
+
lines.push(
|
|
310
|
+
'',
|
|
311
|
+
'## Ignored',
|
|
312
|
+
'',
|
|
313
|
+
'| Rule | File | Line | Reason | Expires |',
|
|
314
|
+
'|---|---|---|---|---|',
|
|
315
|
+
)
|
|
316
|
+
for (const finding of ignored) {
|
|
317
|
+
lines.push(
|
|
318
|
+
`| ${finding.rule} | ${finding.file} | ${finding.line} | ${escapeCell(finding.reason)} | ${finding.expires ?? '—'} |`,
|
|
319
|
+
)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (expired.length) {
|
|
324
|
+
lines.push(
|
|
325
|
+
'',
|
|
326
|
+
'## Expired Exceptions',
|
|
327
|
+
'',
|
|
328
|
+
'| Rule | File | Expires | Reason |',
|
|
329
|
+
'|---|---|---|---|',
|
|
330
|
+
)
|
|
331
|
+
for (const exception of expired) {
|
|
332
|
+
lines.push(`| ${exception.rule} | ${exception.file} | ${exception.expires} | ${escapeCell(exception.reason)} |`)
|
|
333
|
+
}
|
|
334
|
+
lines.push('', '- Expired exceptions no longer suppress findings; renew the date or remove the entry.')
|
|
335
|
+
}
|
|
336
|
+
|
|
242
337
|
lines.push(...gateLines(gates))
|
|
243
338
|
return lines.join('\n')
|
|
244
339
|
}
|
|
@@ -329,6 +424,49 @@ function formatInit(meta, plan, applied) {
|
|
|
329
424
|
return lines.filter((line, index) => !(line === '' && lines[index - 1] === '')).join('\n')
|
|
330
425
|
}
|
|
331
426
|
|
|
427
|
+
function formatInstall(meta, plan, applied) {
|
|
428
|
+
const lines = [
|
|
429
|
+
'# Codecraft Install',
|
|
430
|
+
'',
|
|
431
|
+
`- Version: ${meta.version}`,
|
|
432
|
+
`- Mode: install`,
|
|
433
|
+
`- Scope: ${meta.scope}`,
|
|
434
|
+
`- Agent: ${meta.agent}`,
|
|
435
|
+
`- Root: ${meta.root}`,
|
|
436
|
+
`- Dest: ${meta.dest ?? '(auto)'}`,
|
|
437
|
+
`- Dry run: ${meta.dryRun}`,
|
|
438
|
+
`- Targets: ${meta.targets}`,
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
if (applied) {
|
|
442
|
+
lines.push(
|
|
443
|
+
`- Created: ${meta.created}`,
|
|
444
|
+
`- Replaced: ${meta.replaced}`,
|
|
445
|
+
`- Skipped: ${meta.skipped}`,
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!plan.length) {
|
|
450
|
+
lines.push('', '未找到可安装的目标;请用 `--agent` 指定,或先创建 agent 配置目录。')
|
|
451
|
+
} else {
|
|
452
|
+
lines.push('', '## Plan', '', '| Agent | Target | Action |', '|---|---|---|')
|
|
453
|
+
for (const entry of plan) {
|
|
454
|
+
lines.push(`| ${entry.agent} | ${entry.target} | ${entry.action} |`)
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
lines.push(
|
|
459
|
+
'',
|
|
460
|
+
'## Next',
|
|
461
|
+
'',
|
|
462
|
+
meta.dryRun
|
|
463
|
+
? '- Re-run without `--dry-run` to write the skill.'
|
|
464
|
+
: '- Restart the agent or reload skills;然后说:`用 Codecraft 审计本项目,只读,不改文件。`',
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return lines.join('\n')
|
|
468
|
+
}
|
|
469
|
+
|
|
332
470
|
function header(meta) {
|
|
333
471
|
return [
|
|
334
472
|
`# Codecraft ${title(meta.mode)}`,
|
|
@@ -342,6 +480,8 @@ function header(meta) {
|
|
|
342
480
|
`- Severity: ${meta.severity}`,
|
|
343
481
|
`- Files: ${meta.files}`,
|
|
344
482
|
`- Findings: ${meta.findings}`,
|
|
483
|
+
`- Ignored: ${meta.ignored ?? 0}`,
|
|
484
|
+
`- Expired exceptions: ${meta.expired ?? 0}`,
|
|
345
485
|
`- Gates: ${meta.gates}`,
|
|
346
486
|
'',
|
|
347
487
|
]
|
package/src/gates.mjs
CHANGED
|
@@ -1,30 +1,33 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { validateIgnore } from './ignore.mjs'
|
|
4
5
|
|
|
5
6
|
export function loadConfig(root, configPath) {
|
|
6
7
|
const file = configPath ? path.resolve(root, configPath) : path.join(root, 'codecraft.config.json')
|
|
7
8
|
|
|
8
|
-
if (!fs.existsSync(file)) return { file: null, config: {} }
|
|
9
|
+
if (!fs.existsSync(file)) return { file: null, config: { ignore: [] } }
|
|
9
10
|
|
|
10
11
|
let config
|
|
11
12
|
try {
|
|
12
13
|
config = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
13
14
|
} catch (error) {
|
|
14
|
-
throw new Error(
|
|
15
|
+
throw new Error(
|
|
16
|
+
`invalid config ${file}: ${error.message} (fix the JSON syntax, or remove the file to use defaults)`,
|
|
17
|
+
)
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
if (config.gates != null && !Array.isArray(config.gates)) {
|
|
18
|
-
throw new Error(`invalid config ${file}: "gates" must be an array`)
|
|
21
|
+
throw new Error(`invalid config ${file}: "gates" must be an array (each item: { name?, run, timeoutMs? })`)
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
for (const gate of config.gates ?? []) {
|
|
22
25
|
if (!gate || typeof gate.run !== 'string' || !gate.run.trim()) {
|
|
23
|
-
throw new Error(`invalid config ${file}: each gate needs a "run" string`)
|
|
26
|
+
throw new Error(`invalid config ${file}: each gate needs a "run" string (e.g. "pnpm test")`)
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
return { file, config }
|
|
30
|
+
return { file, config: { ...config, ignore: validateIgnore(config.ignore, file) } }
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
export function runGates(root, gates, options = {}) {
|
package/src/ignore.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 校验并规范化 ignore 条目。
|
|
5
|
+
* 每条必须有 rule / file / reason;expires 可选(YYYY-MM-DD)。
|
|
6
|
+
*/
|
|
7
|
+
export function validateIgnore(entries, configFile = 'config') {
|
|
8
|
+
if (entries == null) return []
|
|
9
|
+
if (!Array.isArray(entries)) {
|
|
10
|
+
throw new Error(
|
|
11
|
+
`invalid config ${configFile}: "ignore" must be an array (each item: { rule, file, reason, expires? })`,
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return entries.map((entry, index) => {
|
|
16
|
+
const at = `ignore[${index}]`
|
|
17
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
18
|
+
throw new Error(`invalid config ${configFile}: ${at} must be an object`)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const rule = typeof entry.rule === 'string' ? entry.rule.trim() : ''
|
|
22
|
+
if (!/^A\d+$/.test(rule)) {
|
|
23
|
+
throw new Error(`invalid config ${configFile}: ${at}.rule must be a rule id like "A17"`)
|
|
24
|
+
}
|
|
25
|
+
if (rule === 'A21') {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`invalid config ${configFile}: ${at}.rule A21 is a security rule and cannot be ignored (safety red line)`,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const file = typeof entry.file === 'string' ? entry.file.trim() : ''
|
|
32
|
+
if (!file) {
|
|
33
|
+
throw new Error(`invalid config ${configFile}: ${at}.file is required (path or glob, e.g. "src/pages/legacy/**")`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const reason = typeof entry.reason === 'string' ? entry.reason.trim() : ''
|
|
37
|
+
if (!reason) {
|
|
38
|
+
throw new Error(`invalid config ${configFile}: ${at}.reason is required (A12: every exception needs a reason)`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const expires = entry.expires == null || entry.expires === '' ? null : String(entry.expires).trim()
|
|
42
|
+
if (expires && !isDate(expires)) {
|
|
43
|
+
throw new Error(`invalid config ${configFile}: ${at}.expires must be YYYY-MM-DD`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { rule, file, reason, expires }
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 把 ignore 应用到 findings 上。
|
|
52
|
+
* 过期条目不生效,单独返回,供报告提示续期或删除。
|
|
53
|
+
*/
|
|
54
|
+
export function applyIgnores(findings, exceptions, options = {}) {
|
|
55
|
+
const today = options.today ?? localDate()
|
|
56
|
+
const active = []
|
|
57
|
+
const expired = []
|
|
58
|
+
|
|
59
|
+
for (const exception of exceptions) {
|
|
60
|
+
if (exception.expires && exception.expires < today) expired.push(exception)
|
|
61
|
+
else active.push(exception)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const kept = []
|
|
65
|
+
const ignored = []
|
|
66
|
+
|
|
67
|
+
for (const finding of findings) {
|
|
68
|
+
const matched = active.find(
|
|
69
|
+
(exception) => exception.rule === finding.rule && matchPath(exception.file, finding.file),
|
|
70
|
+
)
|
|
71
|
+
if (matched) ignored.push({ ...finding, reason: matched.reason, expires: matched.expires })
|
|
72
|
+
else kept.push(finding)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
findings: kept,
|
|
77
|
+
ignored,
|
|
78
|
+
exceptions: [
|
|
79
|
+
...active.map((exception) => ({ ...exception, status: 'active' })),
|
|
80
|
+
...expired.map((exception) => ({ ...exception, status: 'expired' })),
|
|
81
|
+
],
|
|
82
|
+
expired,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 支持 * / ** / ? 的路径匹配;不含通配符时按精确路径或目录前缀匹配。 */
|
|
87
|
+
export function matchPath(pattern, file) {
|
|
88
|
+
const normalizedPattern = normalize(pattern)
|
|
89
|
+
const normalizedFile = normalize(file)
|
|
90
|
+
|
|
91
|
+
if (!/[*?]/.test(normalizedPattern)) {
|
|
92
|
+
return (
|
|
93
|
+
normalizedFile === normalizedPattern ||
|
|
94
|
+
(normalizedPattern.endsWith('/') && normalizedFile.startsWith(normalizedPattern))
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return globToRegExp(normalizedPattern).test(normalizedFile)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalize(value) {
|
|
102
|
+
return String(value)
|
|
103
|
+
.replace(/\\/g, '/')
|
|
104
|
+
.replace(/^\.\//, '')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function globToRegExp(pattern) {
|
|
108
|
+
let source = '^'
|
|
109
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
110
|
+
const char = pattern[index]
|
|
111
|
+
if (char === '*') {
|
|
112
|
+
if (pattern[index + 1] === '*') {
|
|
113
|
+
index += 1
|
|
114
|
+
if (pattern[index + 1] === '/') {
|
|
115
|
+
source += '(?:.*/)?'
|
|
116
|
+
index += 1
|
|
117
|
+
} else {
|
|
118
|
+
source += '.*'
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
source += '[^/]*'
|
|
122
|
+
}
|
|
123
|
+
} else if (char === '?') {
|
|
124
|
+
source += '[^/]'
|
|
125
|
+
} else if ('\\^$+?.()|{}[]'.includes(char)) {
|
|
126
|
+
source += `\\${char}`
|
|
127
|
+
} else {
|
|
128
|
+
source += char
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return new RegExp(`${source}$`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isDate(value) {
|
|
135
|
+
if (!DATE.test(value)) return false
|
|
136
|
+
const parsed = new Date(`${value}T00:00:00Z`)
|
|
137
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function localDate() {
|
|
141
|
+
const now = new Date()
|
|
142
|
+
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
143
|
+
const day = String(now.getDate()).padStart(2, '0')
|
|
144
|
+
return `${now.getFullYear()}-${month}-${day}`
|
|
145
|
+
}
|