@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/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. 按项目类型选门禁
@@ -1,6 +1,7 @@
1
1
  # 设计模式工具箱(可选)
2
2
 
3
3
  > 用途:AI 与人的「可选工具」,不是必背清单;按力(forces)选,不按名字选。
4
+ > 范围:模式本身与语言无关;第 4 / 5 节分别是 JS/TS 惯用与前端 / UI 专用。
4
5
  > 术语:English(中文)。
5
6
  > 原则:**先有痛点,再选模式;没有代价说明的模式,不许用。**
6
7
 
@@ -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 契约测试。
@@ -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
- 在项目的 agent 入口里留一条指针,不要复制规则:
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`
@@ -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.3",
4
- "description": "Language-agnostic code standard as an installable skill package.",
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('codecraft: invalid config pack; expected app, library or auto')
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(findings) : []
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: findings.length,
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: findings.length,
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, findings, plan, gates))
202
+ console.log(formatPlan(meta, activeFindings, plan, gates))
149
203
  } else {
150
- console.log(formatReport(meta, findings, gates))
204
+ console.log(formatReport(meta, activeFindings, filtered.ignored, filtered.expired, gates))
151
205
  }
152
206
 
153
- const failed = findings.some((finding) => finding.severity === 'error') || gates.some((gate) => gate.exitCode !== 0)
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(`invalid config ${file}: ${error.message}`)
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
+ }