agent-rule-cli 0.1.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.
@@ -0,0 +1,1329 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs')
4
+ const path = require('path')
5
+ const crypto = require('crypto')
6
+ const readline = require('readline')
7
+ const { execFileSync } = require('child_process')
8
+
9
+ const SCRIPT_DIR = __dirname
10
+ const PACKAGE = require('./package.json')
11
+ const COMMAND = 'npx agent-rule-cli'
12
+ const SHARED_TEMPLATE_DIR = path.join(SCRIPT_DIR, 'agent-rules-templates', 'shared')
13
+ const args = process.argv.slice(2)
14
+ const rootArgIndex = args.indexOf('--root')
15
+ if (rootArgIndex >= 0 && (!args[rootArgIndex + 1] || args[rootArgIndex + 1].startsWith('-'))) {
16
+ process.stderr.write('错误:--root 后需要提供项目目录。\n')
17
+ process.exit(1)
18
+ }
19
+ const ROOT = path.resolve(rootArgIndex >= 0 ? args[rootArgIndex + 1] : process.cwd())
20
+ const RULE_DIR = path.join(ROOT, '.agent-rules')
21
+ const VERIFY_ONLY = args.includes('--verify')
22
+ const STRICT = args.includes('--strict')
23
+ const NON_INTERACTIVE = args.includes('--defaults')
24
+ const SHOW_HELP = args.includes('--help') || args.includes('-h')
25
+ const NOW = new Date()
26
+ const VERIFIED_AT = NOW.toISOString().slice(0, 10)
27
+ const TIMESTAMP = NOW.toISOString().replace(/[-:]/g, '').replace(/\..+/, '')
28
+ const EXISTING_MANIFEST = (() => {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(path.join(RULE_DIR, 'project-facts.json'), 'utf8'))
31
+ } catch {
32
+ return null
33
+ }
34
+ })()
35
+
36
+ const MODULES = {
37
+ architecture: '架构与目录',
38
+ codeQuality: '代码质量与复用',
39
+ ui: 'UI',
40
+ api: 'API 与错误处理',
41
+ state: '状态与数据流',
42
+ security: '安全与性能',
43
+ testingGit: '测试与 Git 交付',
44
+ business: '业务规则'
45
+ }
46
+
47
+ const COVERAGE_CATALOG = {
48
+ architecture: [
49
+ ['architecture.identity', '项目身份、业务描述、类型和技术栈', ['project.name', 'project.description', 'project.kind', 'stack.technologies']],
50
+ ['architecture.directories', '页面、共享、API、状态和资源目录边界', ['policy.directoryBoundaries']],
51
+ ['architecture.newDirectories', '新增目录与模块边界', ['policy.newDirectories', 'policy.featureBoundary']],
52
+ ['architecture.language', '默认输出和文档语言', ['project.outputLanguage']]
53
+ ],
54
+ codeQuality: [
55
+ ['code.dataContract', '数据契约、新模块和存量治理策略', ['policy.dataContract', 'policy.legacyGovernance']],
56
+ ['code.modelPlacement', '类型、模型、mapper、normalizer、adapter 位置', ['policy.modelPlacement']],
57
+ ['code.indexMaintenance', '代码资产、复用候选和业务域地图维护', ['policy.indexMaintenance']],
58
+ ['code.encapsulation', '页面私有、领域共享和项目共享边界', ['policy.encapsulationBoundary']],
59
+ ['code.crossProject', '跨项目包和共享库策略', ['policy.crossProjectPackages']],
60
+ ['code.documentation', '注释、临时方案和复杂业务规则文档化', ['policy.documentation']]
61
+ ],
62
+ ui: [
63
+ ['ui.stack', '组件库、主题、样式和输入来源', ['ui.library', 'policy.uiDesignSource']],
64
+ ['ui.components', '组件目录、资产清单和共享准入', ['dir.components', 'policy.uiComponentBoundary']],
65
+ ['ui.layoutFeedback', '布局、浮层、加载和交互反馈', ['policy.uiLayoutFeedback']],
66
+ ['ui.forms', '表单、破坏性操作和失败保留', ['policy.uiFormBehavior']],
67
+ ['ui.presentation', '文案、响应式、可访问性、视觉变量、图标和验收', ['policy.uiPresentation', 'policy.uiVerification']]
68
+ ],
69
+ api: [
70
+ ['api.entryConfig', '统一请求入口、API 目录和基础请求配置', ['api.entry', 'api.library']],
71
+ ['api.errorModel', '错误对象、错误分类和状态码类型', ['policy.errorClassification']],
72
+ ['api.displayCatch', '默认展示和 catch 职责', ['policy.errorDisplay']],
73
+ ['api.silentCustom', '静默请求、自定义错误和后置回调', ['policy.silentRequest']],
74
+ ['api.auth', '认证失效、权限不足、清理和并发保护', ['policy.authSemantics', 'policy.authCleanup', 'policy.concurrentAuthFailure']],
75
+ ['api.lifecycle', '重试、轮询、取消、过期响应和防重复提交', ['policy.requestLifecycle']],
76
+ ['api.logging', '错误日志、可观测性和脱敏', ['policy.apiObservability']]
77
+ ],
78
+ state: [
79
+ ['state.solution', '状态管理方案和作用域边界', ['state.library', 'policy.globalStateBoundary']],
80
+ ['state.authority', '唯一事实源和服务端权威数据', ['policy.serverAuthority']],
81
+ ['state.transform', '接口转换、派生数据和枚举标准化', ['policy.stateTransformation']],
82
+ ['state.persistence', '持久化、版本、失效、清理和账号隔离', ['policy.persistence']],
83
+ ['state.transfer', '跨页面传递和 URL 参数边界', ['policy.crossPageData']],
84
+ ['state.asyncUi', '异步一致性和 UI 数据阶段', ['policy.asyncState']]
85
+ ],
86
+ security: [
87
+ ['security.credentials', '凭证、会话、清理和敏感字段', ['policy.sensitiveFields', 'policy.credentialLifecycle']],
88
+ ['security.exposure', 'URL、日志、错误、埋点、截图和提交记录限制', ['policy.dataExposure']],
89
+ ['security.permissions', '权限入口和前后端权限边界', ['auth.guardEntry', 'policy.permissionBoundary']],
90
+ ['security.paths', '部署路径、资源前缀、外链、下载和回调校验', ['policy.pathAndExternalUrl']],
91
+ ['security.dynamicContent', '上传、富文本、Markdown、动态 HTML 和预览', ['policy.dynamicContent']],
92
+ ['security.performance', '关键路径、性能预算、列表和大资源', ['policy.performancePaths', 'policy.performanceBudget']],
93
+ ['security.cacheConcurrency', '并发、轮询、高频事件、缓存和降级', ['policy.cacheAndConcurrency']]
94
+ ],
95
+ testingGit: [
96
+ ['git.protected', '受保护分支和禁止直接提交范围', ['git.protectedBranches']],
97
+ ['git.branches', '需求、修复、重构和实验分支命名', ['policy.branchNaming']],
98
+ ['git.commits', '提交格式、语言、WIP 和整理', ['policy.commitStyle', 'policy.wipCommits']],
99
+ ['git.delivery', 'PR、CI、发布、tag 和推送边界', ['policy.releaseBoundary']],
100
+ ['git.safety', '禁止提交文件和高风险 Git 授权', ['policy.gitSafety']],
101
+ ['testing.strategy', '单元、集成、E2E、lint、类型和构建策略', ['testing.commands', 'policy.testStrategy']],
102
+ ['testing.risk', '风险分级和最小验证范围', ['policy.highRiskGate']],
103
+ ['testing.flows', '核心业务链路和必须更新测试的场景', ['policy.coreFlows']],
104
+ ['testing.manual', '手动回归、UI 验证和环境不可用处理', ['policy.manualRegression']],
105
+ ['testing.boundaries', '关键数据、边界场景和剩余风险记录', ['policy.testBoundaries']]
106
+ ],
107
+ business: [
108
+ ['business.source', '权威业务规则来源', ['policy.businessRuleSource']],
109
+ ['business.domains', '业务域入口和代码地图', ['domain.map']],
110
+ ['business.risk', '高风险业务域和流程', ['policy.highRiskDomains']],
111
+ ['business.enums', '状态、枚举、权限码和状态码来源', ['policy.businessEnums']]
112
+ ]
113
+ }
114
+
115
+ const BUSINESS_CONTRACT_FACTS = {
116
+ api: ['policy.authSemantics'],
117
+ state: ['policy.serverAuthority'],
118
+ security: ['policy.permissionBoundary'],
119
+ testingGit: ['policy.coreFlows'],
120
+ business: ['policy.businessRuleSource', 'policy.highRiskDomains', 'policy.businessEnums']
121
+ }
122
+
123
+ const facts = []
124
+ const answers = {}
125
+ const moduleChoices = {}
126
+ let rl
127
+
128
+ const GENERATED_ARTIFACTS = [
129
+ 'AGENTS.md',
130
+ '.agent-rules/project-index.md',
131
+ '.agent-rules/project-summary.md',
132
+ '.agent-rules/project-architecture.md',
133
+ '.agent-rules/project-code-quality.md',
134
+ '.agent-rules/project-code-inventory.md',
135
+ '.agent-rules/project-reuse-candidates.md',
136
+ '.agent-rules/project-ui-rules.md',
137
+ '.agent-rules/project-api-error-handling.md',
138
+ '.agent-rules/project-state-data-flow.md',
139
+ '.agent-rules/project-security-performance.md',
140
+ '.agent-rules/project-testing-quality-gates.md',
141
+ '.agent-rules/project-git-delivery.md',
142
+ '.agent-rules/project-business-rules.md',
143
+ '.agent-rules/project-domain-map.md'
144
+ ]
145
+
146
+ function note(message) {
147
+ process.stdout.write(`\n\u001b[1;36m${message}\u001b[0m\n`)
148
+ }
149
+
150
+ function warn(message) {
151
+ process.stdout.write(`\u001b[1;33m${message}\u001b[0m\n`)
152
+ }
153
+
154
+ function exists(relative) {
155
+ return fs.existsSync(path.join(ROOT, relative))
156
+ }
157
+
158
+ function read(relative) {
159
+ try {
160
+ return fs.readFileSync(path.join(ROOT, relative), 'utf8')
161
+ } catch {
162
+ return ''
163
+ }
164
+ }
165
+
166
+ function readJson(relative) {
167
+ try {
168
+ return JSON.parse(read(relative))
169
+ } catch {
170
+ return null
171
+ }
172
+ }
173
+
174
+ function run(command, commandArgs = []) {
175
+ try {
176
+ return execFileSync(command, commandArgs, { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
177
+ } catch {
178
+ return ''
179
+ }
180
+ }
181
+
182
+ function evidencePath(value) {
183
+ if (typeof value !== 'string') return ''
184
+ const candidates = [value, value.split('#')[0]]
185
+ const colonPositions = [...value.matchAll(/:/g)].map(match => match.index)
186
+ for (const position of colonPositions) candidates.push(value.slice(0, position))
187
+ return candidates.find(candidate => candidate && exists(candidate)) || ''
188
+ }
189
+
190
+ function fingerprint(relative, mode = 'content') {
191
+ const full = path.join(ROOT, relative)
192
+ if (!fs.existsSync(full)) return null
193
+ const stat = fs.statSync(full)
194
+ if (mode === 'existence') return { path: relative, kind: stat.isDirectory() ? 'directory-exists' : 'file-exists' }
195
+ if (stat.isFile()) return { path: relative, kind: 'file', sha256: hashFile(full) }
196
+ if (stat.isDirectory()) {
197
+ const listing = listFiles(relative, 4).sort().join('\n')
198
+ return { path: relative, kind: 'directory', sha256: crypto.createHash('sha256').update(listing).digest('hex') }
199
+ }
200
+ return null
201
+ }
202
+
203
+ function addFact(id, module, value, status, source, evidence, noteText = '') {
204
+ if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) return
205
+ const existing = facts.find(item => item.id === id)
206
+ const fact = { id, module, value, status, source, evidence, verifiedAt: VERIFIED_AT }
207
+ const evidenceValues = Array.isArray(evidence) ? evidence : [evidence]
208
+ const evidenceMode = id.startsWith('dir.') || ['domain.map', 'testing.files'].includes(id) ? 'existence' : 'content'
209
+ const evidenceRefs = evidenceValues.map(evidencePath).filter(Boolean).map(relative => fingerprint(relative, evidenceMode)).filter(Boolean)
210
+ if (evidenceRefs.length) fact.evidenceRefs = evidenceRefs
211
+ if (noteText) fact.note = noteText
212
+ if (existing) Object.assign(existing, fact)
213
+ else facts.push(fact)
214
+ }
215
+
216
+ function fact(id) {
217
+ return facts.find(item => item.id === id)
218
+ }
219
+
220
+ function factValue(id, fallback = '') {
221
+ const item = fact(id)
222
+ return item && item.value !== undefined && item.value !== null ? item.value : fallback
223
+ }
224
+
225
+ function previousValue(id, fallback = '') {
226
+ const answer = EXISTING_MANIFEST && EXISTING_MANIFEST.answers && EXISTING_MANIFEST.answers[id]
227
+ if (answer && ['user-confirmed', 'not-applicable'].includes(answer.status)) return answer.value
228
+ const previousFact = EXISTING_MANIFEST && EXISTING_MANIFEST.facts && EXISTING_MANIFEST.facts.find(item => item.id === id && ['user-confirmed', 'not-applicable'].includes(item.status))
229
+ return previousFact && previousFact.value !== undefined && previousFact.value !== null ? previousFact.value : fallback
230
+ }
231
+
232
+ function markdownValue(value) {
233
+ if (Array.isArray(value)) return value.length ? value.map(item => `\`${typeof item === 'object' ? JSON.stringify(item) : item}\``).join('、') : '未定义'
234
+ if (value === true) return '是'
235
+ if (value === false) return '否'
236
+ if (value && typeof value === 'object') return `\`${JSON.stringify(value)}\``
237
+ return String(value || '未定义')
238
+ }
239
+
240
+ function listFiles(directory, maxDepth = 3) {
241
+ const base = path.join(ROOT, directory)
242
+ if (!fs.existsSync(base)) return []
243
+ const output = []
244
+ const walk = (current, depth) => {
245
+ if (depth > maxDepth) return
246
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
247
+ if (['node_modules', '.git', 'dist', 'build', 'target', 'vendor', '.venv', 'coverage', '.cache', '__pycache__', '.pytest_cache'].includes(entry.name)) continue
248
+ const full = path.join(current, entry.name)
249
+ if (entry.isDirectory()) walk(full, depth + 1)
250
+ else output.push(path.relative(ROOT, full))
251
+ if (output.length >= 500) return
252
+ }
253
+ }
254
+ walk(base, 0)
255
+ return output
256
+ }
257
+
258
+ function firstExisting(candidates, type = 'any') {
259
+ return candidates.find(candidate => {
260
+ const full = path.join(ROOT, candidate)
261
+ if (!fs.existsSync(full)) return false
262
+ if (type === 'file') return fs.statSync(full).isFile()
263
+ if (type === 'dir') return fs.statSync(full).isDirectory()
264
+ return true
265
+ }) || ''
266
+ }
267
+
268
+ function packageDependencies(pkg) {
269
+ return { ...((pkg && pkg.dependencies) || {}), ...((pkg && pkg.devDependencies) || {}), ...((pkg && pkg.peerDependencies) || {}) }
270
+ }
271
+
272
+ function scanProjectIdentity() {
273
+ const pkg = readJson('package.json')
274
+ let name = (pkg && pkg.name) || ''
275
+ let evidence = pkg && pkg.name ? 'package.json#name' : ''
276
+
277
+ if (!name && exists('pyproject.toml')) {
278
+ const match = read('pyproject.toml').match(/^name\s*=\s*["']([^"']+)/m)
279
+ name = match ? match[1] : ''
280
+ evidence = name ? 'pyproject.toml#name' : ''
281
+ }
282
+ if (!name && exists('Cargo.toml')) {
283
+ const match = read('Cargo.toml').match(/^name\s*=\s*["']([^"']+)/m)
284
+ name = match ? match[1] : ''
285
+ evidence = name ? 'Cargo.toml#package.name' : ''
286
+ }
287
+ if (!name && exists('go.mod')) {
288
+ const match = read('go.mod').match(/^module\s+([^\s]+)/m)
289
+ name = match ? match[1].split('/').pop() : ''
290
+ evidence = name ? 'go.mod#module' : ''
291
+ }
292
+ if (!name) {
293
+ name = path.basename(ROOT)
294
+ evidence = 'project directory name'
295
+ }
296
+
297
+ addFact('project.name', 'architecture', name, evidence === 'project directory name' ? 'inferred' : 'confirmed', 'repository', evidence)
298
+ }
299
+
300
+ function scanTechnology() {
301
+ const technologies = []
302
+ const evidence = []
303
+ const pkg = readJson('package.json')
304
+ const deps = packageDependencies(pkg)
305
+ const depMap = {
306
+ react: 'React', vue: 'Vue', angular: 'Angular', '@angular/core': 'Angular', next: 'Next.js', nuxt: 'Nuxt',
307
+ axios: 'Axios', redux: 'Redux', '@reduxjs/toolkit': 'Redux Toolkit', zustand: 'Zustand', vuex: 'Vuex', pinia: 'Pinia',
308
+ 'element-ui': 'Element UI', 'element-plus': 'Element Plus', antd: 'Ant Design', '@mui/material': 'MUI',
309
+ vite: 'Vite', webpack: 'Webpack', typescript: 'TypeScript', sass: 'Sass', 'node-sass': 'node-sass'
310
+ }
311
+ for (const [dep, label] of Object.entries(depMap)) {
312
+ if (deps[dep]) {
313
+ technologies.push(label)
314
+ evidence.push(`package.json:${dep}`)
315
+ }
316
+ }
317
+ const fileTech = [
318
+ ['pyproject.toml', 'Python'], ['requirements.txt', 'Python'], ['Pipfile', 'Python'], ['go.mod', 'Go'],
319
+ ['Cargo.toml', 'Rust'], ['pom.xml', 'Java/Maven'], ['build.gradle', 'Java/Gradle'], ['composer.json', 'PHP/Composer'],
320
+ ['Gemfile', 'Ruby'], ['mix.exs', 'Elixir'], ['Package.swift', 'Swift']
321
+ ]
322
+ for (const [file, label] of fileTech) {
323
+ if (exists(file) && !technologies.includes(label)) {
324
+ technologies.push(label)
325
+ evidence.push(file)
326
+ }
327
+ }
328
+ const csproj = fs.readdirSync(ROOT).find(file => file.endsWith('.csproj'))
329
+ if (csproj) {
330
+ technologies.push('.NET')
331
+ evidence.push(csproj)
332
+ }
333
+ addFact('stack.technologies', 'architecture', technologies, technologies.length ? 'confirmed' : 'undefined', 'dependency/config scan', evidence)
334
+ }
335
+
336
+ function scanDirectories() {
337
+ const definitions = {
338
+ 'dir.pages': ['src/views', 'src/pages', 'pages', 'lib/screens'],
339
+ 'dir.router': ['src/router', 'router', 'routes', 'config/routes'],
340
+ 'dir.components': ['src/components', 'components', 'shared/components', 'app/components'],
341
+ 'dir.utils': ['src/utils', 'utils', 'lib', 'app/lib', 'src/lib'],
342
+ 'dir.api': ['src/api', 'api', 'src/services', 'services', 'app/services'],
343
+ 'dir.state': ['src/store', 'store', 'src/stores', 'stores', 'state'],
344
+ 'dir.assets': ['src/assets', 'assets', 'public', 'static'],
345
+ 'dir.tests': ['tests', 'test', '__tests__', 'spec', 'src/__tests__']
346
+ }
347
+ for (const [id, candidates] of Object.entries(definitions)) {
348
+ const value = firstExisting(candidates, 'dir')
349
+ if (value) addFact(id, id === 'dir.tests' ? 'testingGit' : 'architecture', value, 'confirmed', 'filesystem', value)
350
+ }
351
+ }
352
+
353
+ function getGitSnapshot() {
354
+ if (!exists('.git')) return null
355
+ const current = run('git', ['branch', '--show-current'])
356
+ const branches = run('git', ['branch', '--format=%(refname:short)']).split('\n').filter(Boolean)
357
+ const remoteHead = run('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD']).replace(/^origin\//, '')
358
+ const defaultCandidate = remoteHead || (branches.includes('main') ? 'main' : branches.includes('master') ? 'master' : '')
359
+ const branchCandidates = [...new Set([remoteHead, branches.includes('main') ? 'main' : '', branches.includes('master') ? 'master' : '', current].filter(Boolean))]
360
+ return { current, branches, remoteHead, defaultCandidate, branchCandidates }
361
+ }
362
+
363
+ function scanGit() {
364
+ const snapshot = getGitSnapshot()
365
+ if (!snapshot) return
366
+ const { current, branches, remoteHead, defaultCandidate, branchCandidates } = snapshot
367
+ addFact('git.currentBranch', 'testingGit', current, 'confirmed', 'git', 'git branch --show-current')
368
+ addFact('git.branches', 'testingGit', branches, 'confirmed', 'git', 'git branch --format=%(refname:short)')
369
+ addFact('git.branchCandidates', 'testingGit', branchCandidates, 'confirmed', 'git', 'remote HEAD, conventional branches and current branch')
370
+ if (remoteHead) addFact('git.remoteHead', 'testingGit', remoteHead, 'confirmed', 'git', 'refs/remotes/origin/HEAD')
371
+ if (defaultCandidate) addFact('git.defaultBranchCandidate', 'testingGit', defaultCandidate, remoteHead ? 'confirmed' : 'inferred', 'git', remoteHead ? 'refs/remotes/origin/HEAD' : 'local branch convention')
372
+ }
373
+
374
+ function collectTestFiles(testDir) {
375
+ return testDir ? listFiles(testDir, 4).filter(file => /(?:spec|test)\.[^.]+$|_test\.[^.]+$/.test(file)).slice(0, 50) : []
376
+ }
377
+
378
+ function scanCommandsAndTests() {
379
+ const commands = []
380
+ const pkg = readJson('package.json')
381
+ if (pkg && pkg.scripts) {
382
+ const scriptEffects = (name, seen = new Set()) => {
383
+ if (seen.has(name)) return { writesSource: false, writesArtifacts: false, writesCache: false, longRunning: false }
384
+ seen.add(name)
385
+ const command = pkg.scripts[name] || ''
386
+ const category = /lint|format/.test(name) ? 'lint' : /test|spec/.test(name) ? 'test' : /build|compile/.test(name) ? 'build' : /dev|serve|start|preview|watch/.test(name) ? 'dev' : 'other'
387
+ const effects = {
388
+ writesSource: /--fix\b|--write\b|prettier\s+--write|ruff\s+.*--fix|\bsvgo\b/.test(command),
389
+ writesArtifacts: category === 'build' || /\b(?:webpack|vite|rollup|tsc|build)\b/.test(command),
390
+ writesCache: /--clearCache\b|\bclear-cache\b/.test(command),
391
+ longRunning: category === 'dev' || /--watch\b|\bserve\b/.test(command)
392
+ }
393
+ const dependencies = [...command.matchAll(/npm\s+run\s+([\w:.-]+)/g)].map(match => match[1])
394
+ for (const dependency of dependencies) {
395
+ const nested = scriptEffects(dependency, new Set(seen))
396
+ for (const key of Object.keys(effects)) effects[key] = effects[key] || nested[key]
397
+ }
398
+ return effects
399
+ }
400
+ for (const [name, command] of Object.entries(pkg.scripts)) {
401
+ const category = /lint|format/.test(name) ? 'lint' : /test|spec/.test(name) ? 'test' : /build|compile/.test(name) ? 'build' : /dev|serve|start/.test(name) ? 'dev' : 'other'
402
+ const effects = scriptEffects(name)
403
+ commands.push({ name: `npm run ${name}`, raw: command, category, ...effects, safeForAutomaticExecution: !effects.writesSource && !effects.longRunning })
404
+ }
405
+ }
406
+ const pythonConfig = `${read('pyproject.toml')}\n${read('requirements.txt')}\n${read('Pipfile')}`
407
+ if (/\bpytest\b|\[tool\.pytest/.test(pythonConfig)) commands.push({ name: 'pytest', category: 'test', writesSource: false, writesArtifacts: false, writesCache: true, longRunning: false, safeForAutomaticExecution: true, source: 'Python test configuration' })
408
+ if (/\bruff\b|\[tool\.ruff/.test(pythonConfig)) commands.push({ name: 'ruff check .', category: 'lint', writesSource: false, writesArtifacts: false, writesCache: true, longRunning: false, safeForAutomaticExecution: true, source: 'Python lint configuration' })
409
+ const ecosystemCommands = [
410
+ ['Cargo.toml', [{ name: 'cargo test', category: 'test' }, { name: 'cargo clippy', category: 'lint' }]],
411
+ ['go.mod', [{ name: 'go test ./...', category: 'test' }, { name: 'go vet ./...', category: 'lint' }]],
412
+ ['pom.xml', [{ name: 'mvn test', category: 'test' }]],
413
+ ['build.gradle', [{ name: './gradlew test', category: 'test' }]],
414
+ ['composer.json', [{ name: 'composer test', category: 'test' }]]
415
+ ]
416
+ for (const [file, candidates] of ecosystemCommands) {
417
+ if (exists(file)) commands.push(...candidates.map(command => ({ ...command, writesSource: false, writesArtifacts: true, writesCache: true, longRunning: false, safeForAutomaticExecution: true, source: file })))
418
+ }
419
+ const testDir = factValue('dir.tests')
420
+ const testFiles = collectTestFiles(testDir)
421
+ const commandEvidence = [pkg && pkg.scripts ? 'package.json#scripts' : '', ...ecosystemCommands.filter(([file]) => exists(file)).map(([file]) => file), /\bpytest\b/.test(pythonConfig) ? 'Python pytest configuration' : '', /\bruff\b/.test(pythonConfig) ? 'Python ruff configuration' : ''].filter(Boolean)
422
+ addFact('testing.commands', 'testingGit', commands, commands.length ? 'confirmed' : 'undefined', 'configuration scan', commandEvidence)
423
+ addFact('testing.files', 'testingGit', testFiles, testFiles.length ? 'confirmed' : 'undefined', 'filesystem', testDir || 'known test directories')
424
+ }
425
+
426
+ function scanFrontendAndState() {
427
+ const pkg = readJson('package.json')
428
+ const deps = packageDependencies(pkg)
429
+ const ui = [['element-ui', 'Element UI'], ['element-plus', 'Element Plus'], ['antd', 'Ant Design'], ['@mui/material', 'MUI'], ['@chakra-ui/react', 'Chakra UI'], ['vuetify', 'Vuetify']].find(([dep]) => deps[dep])
430
+ if (ui) addFact('ui.library', 'ui', ui[1], 'confirmed', 'dependency scan', `package.json:${ui[0]}`)
431
+ const state = [['vuex', 'Vuex'], ['pinia', 'Pinia'], ['@reduxjs/toolkit', 'Redux Toolkit'], ['redux', 'Redux'], ['zustand', 'Zustand'], ['mobx', 'MobX']].find(([dep]) => deps[dep])
432
+ if (state) addFact('state.library', 'state', state[1], 'confirmed', 'dependency scan', `package.json:${state[0]}`)
433
+ if (factValue('dir.state')) addFact('state.directory', 'state', factValue('dir.state'), 'confirmed', 'filesystem', factValue('dir.state'))
434
+ }
435
+
436
+ function scanApiAndAuth() {
437
+ const candidates = [
438
+ 'src/utils/request.js', 'src/utils/request.ts', 'src/utils/http.js', 'src/utils/http.ts', 'src/lib/http.ts',
439
+ 'src/api/client.ts', 'src/api/request.ts', 'app/services/http.ts', 'lib/api_client.dart'
440
+ ]
441
+ const entry = firstExisting(candidates, 'file')
442
+ const pkg = readJson('package.json')
443
+ const deps = packageDependencies(pkg)
444
+ const library = deps.axios ? 'Axios' : deps.ky ? 'ky' : deps['node-fetch'] ? 'node-fetch' : entry ? '项目自定义请求封装' : ''
445
+ if (entry) addFact('api.entry', 'api', entry, 'confirmed', 'filesystem', entry)
446
+ if (library) addFact('api.library', 'api', library, 'confirmed', 'dependency/source scan', entry || 'package.json')
447
+ if (!entry) return
448
+
449
+ const source = read(entry)
450
+ const timeoutMatch = source.match(/timeout\s*:\s*(\d+)/)
451
+ const withCredentialsMatch = source.match(/withCredentials\s*:\s*(true|false)/)
452
+ const successCodeMatch = source.match(/\.code\s*={2,3}\s*['"]([^'"]+)['"]/)
453
+ const timeout = timeoutMatch && timeoutMatch[1]
454
+ const withCredentials = withCredentialsMatch && withCredentialsMatch[1]
455
+ const successCode = successCodeMatch && successCodeMatch[1]
456
+ const statusCodes = [...source.matchAll(/status\s*={2,3}\s*(\d{3})/g)].map(match => Number(match[1]))
457
+ const headerNames = [...source.matchAll(/headers\[['"]([^'"]+)['"]\]/g)].map(match => match[1])
458
+ if (timeout) addFact('api.timeoutMs', 'api', Number(timeout), 'confirmed', 'source scan', `${entry}:timeout`)
459
+ if (withCredentials) addFact('api.withCredentials', 'api', withCredentials === 'true', 'confirmed', 'source scan', `${entry}:withCredentials`)
460
+ if (successCode) addFact('api.successBusinessCode', 'api', { value: successCode, type: 'string' }, 'confirmed', 'source scan', `${entry}:response interceptor`)
461
+ if (statusCodes.length) addFact('api.handledHttpStatuses', 'api', [...new Set(statusCodes)], 'confirmed', 'source scan', `${entry}:response interceptor`)
462
+ if (headerNames.length) addFact('api.headers', 'api', [...new Set(headerNames)], 'confirmed', 'source scan', `${entry}:request interceptor`)
463
+ const messageCalls = (source.match(/\bMessage\s*\(|\bMessage\.(?:error|warning|success)\s*\(/g) || []).length
464
+ const currentLogging = {
465
+ consoleCalls: (source.match(/console\.(?:log|error|warn|debug)\s*\(/g) || []).length,
466
+ logsRawResponse: /console\.(?:log|error|warn|debug)[\s\S]{0,160}\b(?:res|response)\b/.test(source),
467
+ logsRawError: /console\.(?:log|error|warn|debug)[\s\S]{0,160}\berror\b/.test(source)
468
+ }
469
+ const currentErrorObject = {
470
+ usesNativeError: /new\s+Error\s*\(/.test(source),
471
+ rejectsRawError: /Promise\.reject\s*\(\s*error\s*\)/.test(source),
472
+ hasStructuredErrorType: /class\s+\w*Error\b|new\s+(?:ApiError|HttpError|AppError)\b/.test(source)
473
+ }
474
+ const currentErrorPresentation = {
475
+ globalMessageCalls: messageCalls,
476
+ hasDuplicateSuppression: /isShowingError|messageShown|dedupe|singleFlight|authFailureHandled/.test(source)
477
+ }
478
+ addFact('api.currentLogging', 'api', currentLogging, 'confirmed', 'source scan', `${entry}:logging`)
479
+ addFact('api.currentErrorObject', 'api', currentErrorObject, 'confirmed', 'source scan', `${entry}:error rejection`)
480
+ addFact('api.currentErrorPresentation', 'api', currentErrorPresentation, 'confirmed', 'source scan', `${entry}:error presentation`)
481
+ const implementationGaps = []
482
+ if (currentLogging.logsRawResponse || currentLogging.logsRawError) implementationGaps.push('日志可能输出完整响应或原始错误,需核对脱敏目标')
483
+ if (!currentErrorObject.hasStructuredErrorType) implementationGaps.push('未检测到统一结构化错误类型')
484
+ if (messageCalls > 1 && !currentErrorPresentation.hasDuplicateSuppression) implementationGaps.push('存在多个全局提示分支,未检测到重复提示抑制机制')
485
+ if (statusCodes.includes(403)) {
486
+ const current403Behavior = {
487
+ clearsCredential: /removeToken|removeCookie|clearToken/.test(source),
488
+ resetsGlobalState: /dispatch\s*\([^)]*reset|commit\s*\([^)]*RESET/i.test(source),
489
+ redirectsToLogin: /(?:replace|push)\s*\([^)]*(?:login|signin)/is.test(source),
490
+ hasSingleFlightGuard: /isRedirecting|authFailureHandled|singleFlight|logoutPromise/.test(source)
491
+ }
492
+ addFact('auth.current403Behavior', 'api', current403Behavior, 'confirmed', 'source scan', `${entry}:HTTP 403 handler`)
493
+ if (!current403Behavior.resetsGlobalState) implementationGaps.push('HTTP 403 当前未重置全局登录状态')
494
+ if (!current403Behavior.hasSingleFlightGuard) implementationGaps.push('HTTP 403 当前缺少并发单次处理保护')
495
+ }
496
+ if (implementationGaps.length) addFact('api.implementationGaps', 'api', implementationGaps, 'confirmed', 'source scan', entry)
497
+
498
+ const authFile = firstExisting(['src/utils/auth.js', 'src/utils/auth.ts', 'src/auth.ts', 'app/auth.ts', 'lib/auth.dart'], 'file')
499
+ if (authFile) {
500
+ const authSource = read(authFile)
501
+ const cookieKeyMatch = authSource.match(/TokenKey\s*=\s*['"]([^'"]+)['"]/)
502
+ const cookieKey = cookieKeyMatch && cookieKeyMatch[1]
503
+ const storage = /Cookies\./.test(authSource) ? 'Cookie' : /localStorage/.test(authSource) ? 'localStorage' : /sessionStorage/.test(authSource) ? 'sessionStorage' : '项目自定义存储'
504
+ addFact('auth.storage', 'security', storage, 'confirmed', 'source scan', authFile)
505
+ if (cookieKey) addFact('auth.tokenKey', 'security', cookieKey, 'confirmed', 'source scan', `${authFile}:TokenKey`)
506
+ }
507
+ const guard = firstExisting(['src/permission.js', 'src/permission.ts', 'src/router/guards.ts', 'src/middleware/auth.ts', 'middleware/auth.ts'], 'file')
508
+ if (guard) addFact('auth.guardEntry', 'security', guard, 'confirmed', 'filesystem', guard)
509
+ }
510
+
511
+ function collectDomainMap(pageDir, apiDir) {
512
+ const domains = []
513
+ if (pageDir && exists(pageDir)) {
514
+ for (const entry of fs.readdirSync(path.join(ROOT, pageDir), { withFileTypes: true })) {
515
+ if (entry.isDirectory() && !entry.name.startsWith('.')) domains.push({ name: entry.name, pageRoot: path.join(pageDir, entry.name) })
516
+ }
517
+ }
518
+ const routeFiles = ['src/router/index.js', 'src/router/index.ts', 'routes/index.js', 'config/routes.js'].filter(exists)
519
+ const routePaths = []
520
+ for (const routeFile of routeFiles) {
521
+ const source = read(routeFile)
522
+ for (const match of source.matchAll(/path\s*:\s*['"](\/[^'"]*)['"]/g)) routePaths.push(match[1])
523
+ }
524
+ const apiFiles = apiDir ? listFiles(apiDir, 3).filter(file => /\.(js|ts|py|go|java|php|rb)$/.test(file)).slice(0, 100) : []
525
+ return { domains, routePaths: [...new Set(routePaths)], apiFiles, routeFiles }
526
+ }
527
+
528
+ function scanDomains() {
529
+ const pageDir = factValue('dir.pages')
530
+ const apiDir = factValue('dir.api')
531
+ const domainMap = collectDomainMap(pageDir, apiDir)
532
+ addFact('domain.map', 'business', { domains: domainMap.domains, routePaths: domainMap.routePaths, apiFiles: domainMap.apiFiles }, domainMap.domains.length || domainMap.routePaths.length || domainMap.apiFiles.length ? 'confirmed' : 'undefined', 'repository structure scan', [pageDir, ...domainMap.routeFiles, apiDir].filter(Boolean))
533
+ const businessDoc = firstExisting(['BUSINESS_RULES.md', 'docs/business.md', 'docs/business-rules.md', 'docs/domain.md'], 'file')
534
+ if (businessDoc) addFact('business.rulesDocument', 'business', businessDoc, 'confirmed', 'filesystem', businessDoc)
535
+ }
536
+
537
+ function scanAll() {
538
+ scanProjectIdentity()
539
+ scanTechnology()
540
+ scanDirectories()
541
+ scanGit()
542
+ scanCommandsAndTests()
543
+ scanFrontendAndState()
544
+ scanApiAndAuth()
545
+ scanDomains()
546
+ }
547
+
548
+ function makeReadline() {
549
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout })
550
+ }
551
+
552
+ function question(prompt) {
553
+ if (NON_INTERACTIVE) return Promise.resolve('')
554
+ return new Promise(resolve => rl.question(prompt, answer => resolve(answer.trim())))
555
+ }
556
+
557
+ async function askText(label, defaultValue = '') {
558
+ const suffix = defaultValue ? ` [${defaultValue}]` : ''
559
+ const answer = await question(`${label}${suffix}: `)
560
+ return answer || defaultValue
561
+ }
562
+
563
+ async function askYesNo(label, defaultYes = true) {
564
+ const answer = (await question(`${label} ${defaultYes ? '[Y/n]' : '[y/N]'}: `)).toLowerCase()
565
+ if (!answer) return defaultYes
566
+ return ['y', 'yes'].includes(answer)
567
+ }
568
+
569
+ async function askChoice(label, options, defaultIndex = 0) {
570
+ if (NON_INTERACTIVE) return options[defaultIndex]
571
+ process.stdout.write(`\n${label}\n`)
572
+ options.forEach((option, index) => process.stdout.write(` ${index + 1}) ${option}\n`))
573
+ while (true) {
574
+ const answer = await question(`请选择 [${defaultIndex + 1}]: `)
575
+ if (!answer) return options[defaultIndex]
576
+ const selected = Number(answer) - 1
577
+ if (selected >= 0 && selected < options.length) return options[selected]
578
+ }
579
+ }
580
+
581
+ function recordAnswer(id, module, value) {
582
+ const existing = fact(id)
583
+ const previousAnswer = EXISTING_MANIFEST && EXISTING_MANIFEST.answers && EXISTING_MANIFEST.answers[id]
584
+ if (NON_INTERACTIVE && previousAnswer && ['user-confirmed', 'not-applicable'].includes(previousAnswer.status) && JSON.stringify(previousAnswer.value) === JSON.stringify(value)) {
585
+ answers[id] = { ...previousAnswer, verifiedAt: VERIFIED_AT }
586
+ addFact(id, module, value, previousAnswer.status, 'wizard', 'preserved user answer')
587
+ return
588
+ }
589
+ if (NON_INTERACTIVE && existing && existing.status === 'confirmed' && JSON.stringify(existing.value) === JSON.stringify(value)) {
590
+ answers[id] = { value, status: 'confirmed', verifiedAt: VERIFIED_AT, source: existing.evidence }
591
+ return
592
+ }
593
+ const hasValue = value && !/未定义|未配置|暂无|需确认|后续补充/.test(String(value))
594
+ const status = !hasValue ? 'undefined' : /^不适用(?:[:,:,]|$)/.test(String(value)) ? 'not-applicable' : NON_INTERACTIVE ? 'inferred' : 'user-confirmed'
595
+ answers[id] = { value, status, verifiedAt: VERIFIED_AT }
596
+ addFact(id, module, value, answers[id].status, 'wizard', 'user answer')
597
+ }
598
+
599
+ function moduleFindings(module) {
600
+ return facts.filter(item => item.module === module).map(item => `- ${item.id}: ${markdownValue(item.value)}(${item.status};${Array.isArray(item.evidence) ? item.evidence.join(', ') : item.evidence})`).join('\n') || '- 未发现可确认事实。'
601
+ }
602
+
603
+ async function configureModule(module, questions, defaultEnabled = true) {
604
+ note(`模块:${MODULES[module]}`)
605
+ process.stdout.write(`${moduleFindings(module)}\n`)
606
+ const previousStatus = EXISTING_MANIFEST && EXISTING_MANIFEST.modules && EXISTING_MANIFEST.modules[module] && EXISTING_MANIFEST.modules[module].status
607
+ const enabled = await askYesNo(`是否配置${MODULES[module]}规则?`, previousStatus === 'ignored' ? false : defaultEnabled)
608
+ if (!enabled) {
609
+ moduleChoices[module] = 'ignored'
610
+ return
611
+ }
612
+ moduleChoices[module] = 'enabled'
613
+ for (const item of questions) {
614
+ const suggested = typeof item.defaultValue === 'function' ? item.defaultValue() : item.defaultValue
615
+ const value = await askText(item.label, previousValue(item.id, suggested))
616
+ recordAnswer(item.id, module, value)
617
+ }
618
+ }
619
+
620
+ function commandSummary() {
621
+ const commands = factValue('testing.commands', [])
622
+ if (!commands.length) return '未检测到,需人工确认'
623
+ const qualityCommands = commands.filter(command => ['lint', 'test', 'build'].includes(command.category))
624
+ return qualityCommands.filter(command => !command.longRunning).map(command => {
625
+ const effects = []
626
+ if (command.writesSource) effects.push('改写源码')
627
+ if (command.writesArtifacts) effects.push('写入产物')
628
+ if (command.writesCache) effects.push('写入缓存')
629
+ return `${command.name}${effects.length ? `(${effects.join('、')})` : ''}`
630
+ }).join(';')
631
+ }
632
+
633
+ async function collectAnswers() {
634
+ const hasExistingProjectEvidence = exists('.git') || ['package.json', 'pyproject.toml', 'Cargo.toml', 'go.mod', 'pom.xml', 'composer.json'].some(exists)
635
+ const previousKind = previousValue('project.kind', '')
636
+ const kindLabel = await askChoice('当前项目类型?', ['已有项目', '新项目'], previousKind ? (previousKind === 'new' ? 1 : 0) : hasExistingProjectEvidence ? 0 : 1)
637
+ const selectedKind = kindLabel === '已有项目' ? 'existing' : 'new'
638
+ const kindStatus = NON_INTERACTIVE && previousKind === selectedKind ? 'user-confirmed' : NON_INTERACTIVE ? 'inferred' : 'user-confirmed'
639
+ addFact('project.kind', 'architecture', selectedKind, kindStatus, 'wizard', previousKind === selectedKind ? 'preserved user answer' : 'user answer')
640
+ const projectName = await askText('项目名称', previousValue('project.name', factValue('project.name', path.basename(ROOT))))
641
+ recordAnswer('project.name', 'architecture', projectName)
642
+ recordAnswer('project.description', 'architecture', await askText('项目业务描述', previousValue('project.description', '未定义,新增场景需人工确认')))
643
+ recordAnswer('project.outputLanguage', 'architecture', await askText('AI 回复、规则文档、交付说明默认语言', previousValue('project.outputLanguage', '中文')))
644
+
645
+ await configureModule('architecture', [
646
+ { id: 'policy.directoryBoundaries', label: '页面、共享、API、状态和资源目录边界', defaultValue: '以扫描确认的目录为准;页面私有逻辑保持局部,稳定跨域能力进入共享目录' },
647
+ { id: 'policy.newDirectories', label: '新增目录策略', defaultValue: '允许在需要时新增,但必须遵守现有目录边界,不为未来复用提前设计结构' },
648
+ { id: 'policy.featureBoundary', label: '领域 / feature 组织边界', defaultValue: () => factValue('dir.pages') ? `优先沿用 ${factValue('dir.pages')} 下现有业务分组` : '未定义,新增场景需人工确认' }
649
+ ])
650
+ await configureModule('codeQuality', [
651
+ { id: 'policy.dataContract', label: '数据契约层策略', defaultValue: '按接口复杂度和既有模式决定;高风险外部数据必须显式标准化' },
652
+ { id: 'policy.legacyGovernance', label: '存量模块治理策略', defaultValue: '只做触达范围内的增量治理,除非明确要求,不一次性重构旧模块' },
653
+ { id: 'policy.modelPlacement', label: '类型、模型、mapper、normalizer、adapter 放置规则', defaultValue: '优先放在使用范围最低的位置;跨页面稳定复用后再提升到共享目录' },
654
+ { id: 'policy.indexMaintenance', label: '代码资产、复用候选和业务域地图维护规则', defaultValue: '新增或修改共享资产、重要流程和业务域时同步更新对应索引' },
655
+ { id: 'policy.encapsulationBoundary', label: '页面私有、领域共享和项目共享边界', defaultValue: '一次性逻辑保持局部;领域内复用放领域边界;稳定跨领域能力进入项目共享层' },
656
+ { id: 'policy.crossProjectPackages', label: '跨项目包 / 共享库策略', defaultValue: '除非已有明确建设计划,不为未来跨项目复用提前设计包结构' }
657
+ ,{ id: 'policy.documentation', label: '注释、临时方案和复杂业务规则文档化', defaultValue: '注释解释原因;临时方案记录风险和清理条件;复杂业务规则进入权威文档' }
658
+ ])
659
+ await configureModule('ui', [
660
+ { id: 'policy.uiDesignSource', label: '设计系统、主题、样式和输入来源', defaultValue: '优先需求与设计系统,其次既有页面和组件库默认行为' },
661
+ { id: 'policy.uiComponentBoundary', label: '页面私有与共享组件准入', defaultValue: '至少两个真实复用点或稳定基础能力才进入共享组件目录' },
662
+ { id: 'policy.uiLayoutFeedback', label: '布局、浮层、加载和交互反馈', defaultValue: '明确滚动与固定区域;区分首次、局部、分页和提交加载;防止重复触发' },
663
+ { id: 'policy.uiFormBehavior', label: '表单、破坏性操作和失败保留', defaultValue: '提交失败保留输入;不可逆操作二次确认;前端校验不替代后端' },
664
+ { id: 'policy.uiFallback', label: '缺少设计细节时的处理', defaultValue: '先检查既有页面模式,再遵循组件库默认行为;仅做最小一致性补全' },
665
+ { id: 'policy.uiPresentation', label: '文案、响应式、可访问性、视觉变量和图标', defaultValue: '沿用项目术语和视觉资产;验证焦点、对比度、点击区域、长文本和关键断点' },
666
+ { id: 'policy.uiVerification', label: 'UI 验证要求', defaultValue: '按影响范围验证目标设备、关键断点、加载态、空态和错误态' }
667
+ ], Boolean(fact('ui.library') || factValue('dir.pages')))
668
+ await configureModule('api', [
669
+ { id: 'policy.errorClassification', label: '错误对象、分类和状态码类型规范', defaultValue: '区分网络、超时、认证、权限、业务、服务端和解析错误;状态码按契约类型比较' },
670
+ { id: 'policy.errorDisplay', label: '默认错误展示机制', defaultValue: '优先沿用统一请求入口的既有机制,页面不得重复提示同一错误' },
671
+ { id: 'policy.authSemantics', label: '401、403 与业务权限码语义', defaultValue: '未定义,需由接口契约或业务负责人确认' },
672
+ { id: 'policy.authCleanup', label: '认证失效清理范围', defaultValue: '凭证、用户状态、权限状态、账号相关缓存和持久化数据' },
673
+ { id: 'policy.concurrentAuthFailure', label: '并发认证失效处理', defaultValue: '同一失效周期只允许一次提示、一次清理和一次登录跳转' },
674
+ { id: 'policy.silentRequest', label: '静默请求规则', defaultValue: '先检查统一请求封装;关键写操作和认证请求不得静默失败' }
675
+ ,{ id: 'policy.requestLifecycle', label: '重试、轮询、取消、过期响应和防重复提交', defaultValue: '按幂等性决定重试;轮询和订阅必须清理;写操作防重复;过期响应不得覆盖新状态' }
676
+ ,{ id: 'policy.apiObservability', label: 'API 日志、可观测性和脱敏', defaultValue: '记录必要上下文和高风险失败;禁止记录完整凭证、隐私和支付数据' }
677
+ ], Boolean(fact('api.entry')))
678
+ await configureModule('state', [
679
+ { id: 'policy.globalStateBoundary', label: '全局状态边界', defaultValue: '跨页面稳定共享数据进入全局状态;临时 UI、表单中间值和大型响应保持局部' },
680
+ { id: 'policy.serverAuthority', label: '必须以服务端为准的数据', defaultValue: '金额、系统时间、权限和关键业务状态;其他数据按业务域确认' },
681
+ { id: 'policy.persistence', label: '持久化策略', defaultValue: fact('auth.storage') ? `沿用已确认的 ${factValue('auth.storage')} 实现;新增持久化需定义失效与账号隔离` : '新增持久化前先检查现有实现,并定义失效与账号隔离' }
682
+ ,{ id: 'policy.stateTransformation', label: '接口转换、派生数据和枚举标准化', defaultValue: '外部数据按风险标准化;派生数据不重复存储;未知枚举保留安全兜底' }
683
+ ,{ id: 'policy.crossPageData', label: '跨页面传递和 URL 参数边界', defaultValue: '公开标识和筛选可进 URL;凭证、隐私、支付信息、复杂对象和大型 payload 禁止进入 URL' }
684
+ ,{ id: 'policy.asyncState', label: '异步一致性和 UI 数据阶段', defaultValue: '处理过期响应、并发覆盖、卸载清理、流程恢复;loading/empty/error/success 与真实阶段一致' }
685
+ ], Boolean(fact('state.library')))
686
+ await configureModule('security', [
687
+ { id: 'policy.sensitiveFields', label: '敏感字段清单', defaultValue: 'token、session、密钥、证件号、手机号、邮箱、地址、支付信息、生产账号和内部配置' },
688
+ { id: 'policy.credentialLifecycle', label: '凭证、会话和账号切换清理', defaultValue: '退出、失效、切换账号和权限变化时清理凭证、状态、缓存和持久化数据' },
689
+ { id: 'policy.dataExposure', label: 'URL、日志、错误、埋点、截图和提交记录限制', defaultValue: '敏感信息不得进入这些载体;日志仅保留脱敏诊断上下文' },
690
+ { id: 'policy.permissionBoundary', label: '权限入口和前后端边界', defaultValue: '前端权限仅控制展示和交互;后端必须独立鉴权;权限判断集中维护' },
691
+ { id: 'policy.pathAndExternalUrl', label: '部署路径、资源前缀、外链、下载和 callback URL', defaultValue: '沿用统一路径配置;外部目标必须校验可信来源,禁止直接拼接用户输入' },
692
+ { id: 'policy.dynamicContent', label: '上传、富文本、Markdown、动态 HTML 和预览', defaultValue: '按场景定义类型、大小、来源、净化、预览和下载策略;未启用能力明确标记不适用' },
693
+ { id: 'policy.performancePaths', label: '关键性能路径', defaultValue: '首屏、登录、核心列表、搜索和提交保存' },
694
+ { id: 'policy.performanceBudget', label: '性能预算、列表和大资源策略', defaultValue: '未量化时至少禁止明显退化;列表分页/虚拟化,大资源按需加载并限制体积' },
695
+ { id: 'policy.cacheAndConcurrency', label: '并发、轮询、高频事件、缓存和降级', defaultValue: '限制并发与无限轮询;高频事件节流/去抖;缓存定义失效、账号隔离、权限隔离和降级' }
696
+ ])
697
+ await configureModule('testingGit', [
698
+ { id: 'git.protectedBranches', label: `受保护分支(当前 ${factValue('git.currentBranch', '未知')},候选 ${markdownValue(factValue('git.branchCandidates', []))};多个用逗号分隔)`, defaultValue: '未定义,需人工确认' },
699
+ { id: 'policy.testStrategy', label: '自动化测试策略', defaultValue: factValue('testing.files', []).length ? '已有测试覆盖的行为发生变化时必须更新测试;新增高风险逻辑应补测试' : '项目暂无测试体系时不强行引入,使用项目定义的替代验证' },
700
+ { id: 'policy.highRiskGate', label: '高风险变更最小门禁', defaultValue: `执行非修改型检查、相关测试、构建和核心链路手动回归;当前命令:${commandSummary()}` },
701
+ { id: 'policy.branchNaming', label: '需求、修复、重构和实验分支命名', defaultValue: 'feat/、fix/、refactor/、chore/ + kebab-case;遵循仓库既有前缀' },
702
+ { id: 'policy.commitStyle', label: '提交信息格式', defaultValue: 'Conventional Commits + 项目默认语言描述' },
703
+ { id: 'policy.wipCommits', label: 'WIP 和最终提交整理', defaultValue: '开发中允许 WIP;最终交付前按项目要求合并或整理临时提交' },
704
+ { id: 'policy.releaseBoundary', label: 'PR、CI、发布、tag 和推送边界', defaultValue: '默认由人工或 CI 执行;AI 仅在用户明确要求和授权后执行' },
705
+ { id: 'policy.gitSafety', label: '禁止提交文件和高风险 Git 操作', defaultValue: '禁止敏感配置、依赖缓存和无关产物;强推、重写历史、删除分支和清空工作区需明确授权' },
706
+ { id: 'policy.coreFlows', label: '核心业务链路和必须更新测试的场景', defaultValue: '登录、权限、金额、支付、订单、提交、删除、路由、数据同步和全局共享行为' },
707
+ { id: 'policy.manualRegression', label: '手动回归、UI 验证和环境不可用记录', defaultValue: '记录环境、步骤、输入、预期、实际和结果;环境不可用时记录未验证项与原因' },
708
+ { id: 'policy.testBoundaries', label: '关键数据、边界场景和剩余风险', defaultValue: '覆盖空值、缺失字段、未知枚举、权限不足、重复提交、异常响应;交付时记录剩余风险' }
709
+ ])
710
+ await configureModule('business', [
711
+ { id: 'policy.businessRuleSource', label: '业务规则主文档或来源', defaultValue: factValue('business.rulesDocument', '未定义,新增业务语义时需人工确认') },
712
+ { id: 'policy.highRiskDomains', label: '高风险业务域', defaultValue: '金额、权限、审核、支付、订单、发布、删除、禁用和不可逆状态流转' },
713
+ { id: 'policy.businessEnums', label: '状态、枚举、权限码和状态码来源', defaultValue: '优先权威业务文档和接口契约;代码只能作为现状证据,未知语义需确认' }
714
+ ], Boolean(fact('domain.map') && fact('domain.map').status === 'confirmed'))
715
+ }
716
+
717
+ function moduleStatus(module) {
718
+ if (moduleChoices[module] === 'ignored') return { status: 'ignored', missing: [], coverage: [], dimensions: { strategy: 'ignored', repositoryFacts: 'ignored', businessContracts: 'ignored' } }
719
+ const coverage = (COVERAGE_CATALOG[module] || []).map(([id, label, factIds]) => {
720
+ const missingFacts = factIds.filter(factId => {
721
+ const item = fact(factId)
722
+ return !item || !['confirmed', 'user-confirmed', 'not-applicable'].includes(item.status)
723
+ })
724
+ return { id, label, factIds, status: missingFacts.length ? 'missing' : 'covered', missingFacts }
725
+ })
726
+ const missing = coverage.filter(item => item.status === 'missing').map(item => item.id)
727
+ const strategyCoverage = coverage.filter(item => item.factIds.some(id => id.startsWith('policy.')))
728
+ const strategy = strategyCoverage.length ? (strategyCoverage.some(item => item.status === 'missing') ? 'partial' : 'configured') : 'not-applicable'
729
+ const repositoryFacts = facts.filter(item => item.module === module && item.source !== 'wizard')
730
+ const repositoryStatus = !repositoryFacts.length ? 'not-found' : repositoryFacts.every(item => item.status === 'confirmed') ? 'confirmed' : 'partial'
731
+ const contractIds = BUSINESS_CONTRACT_FACTS[module] || []
732
+ const businessContracts = !contractIds.length ? 'not-applicable' : contractIds.every(id => {
733
+ const item = fact(id)
734
+ return item && ['confirmed', 'user-confirmed', 'not-applicable'].includes(item.status)
735
+ }) ? 'confirmed' : 'partial'
736
+ return { status: missing.length ? 'partial' : 'configured', missing, coverage, dimensions: { strategy, repositoryFacts: repositoryStatus, businessContracts } }
737
+ }
738
+
739
+ function statusLabel(status) {
740
+ return { configured: '已配置', partial: '部分配置', ignored: '已忽略', unconfigured: '未配置' }[status] || status
741
+ }
742
+
743
+ function coverageLabel(status) {
744
+ return { configured: '结构覆盖完整', partial: '结构部分覆盖', ignored: '已忽略', unconfigured: '未配置' }[status] || status
745
+ }
746
+
747
+ function dimensionLabel(dimension, status) {
748
+ const labels = {
749
+ strategy: { configured: '策略已配置', partial: '策略部分配置', 'not-applicable': '策略不适用', ignored: '策略已忽略' },
750
+ repositoryFacts: { confirmed: '仓库事实已确认', partial: '仓库事实部分确认', 'not-found': '未发现仓库事实', ignored: '仓库事实已忽略' },
751
+ businessContracts: { confirmed: '业务契约已确认', partial: '业务契约部分确认', 'not-applicable': '业务契约不适用', ignored: '业务契约已忽略' }
752
+ }
753
+ return (labels[dimension] && labels[dimension][status]) || status
754
+ }
755
+
756
+ function metadata(module) {
757
+ const sources = [...new Set(facts.filter(item => item.module === module).flatMap(item => Array.isArray(item.evidence) ? item.evidence : [item.evidence]).filter(Boolean))]
758
+ const state = moduleStatus(module)
759
+ return `> 结构覆盖:${coverageLabel(state.status)} \n> 策略状态:${dimensionLabel('strategy', state.dimensions.strategy)} \n> 仓库事实:${dimensionLabel('repositoryFacts', state.dimensions.repositoryFacts)} \n> 业务契约:${dimensionLabel('businessContracts', state.dimensions.businessContracts)} \n> 最后核验:${VERIFIED_AT} \n> 事实来源:${sources.length ? sources.map(source => `\`${source}\``).join('、') : '无已确认来源'} \n> 未覆盖项:${state.missing.length ? state.missing.map(item => `\`${item}\``).join('、') : '无'}\n`
760
+ }
761
+
762
+ function sourceFact(id, label) {
763
+ const item = fact(id)
764
+ if (!item) return `- ${label}:未定义。`
765
+ return `- ${label}:${markdownValue(item.value)}。来源:\`${Array.isArray(item.evidence) ? item.evidence.join(', ') : item.evidence}\`;状态:${item.status}。`
766
+ }
767
+
768
+ function write(relative, content) {
769
+ const full = path.join(ROOT, relative)
770
+ fs.mkdirSync(path.dirname(full), { recursive: true })
771
+ fs.writeFileSync(full, `${content.trim()}\n`)
772
+ }
773
+
774
+ function copyDirectory(source, target) {
775
+ fs.mkdirSync(target, { recursive: true })
776
+ for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
777
+ const sourcePath = path.join(source, entry.name)
778
+ const targetPath = path.join(target, entry.name)
779
+ if (entry.isDirectory()) copyDirectory(sourcePath, targetPath)
780
+ else fs.copyFileSync(sourcePath, targetPath)
781
+ }
782
+ }
783
+
784
+ function copyShared() {
785
+ if (!fs.existsSync(SHARED_TEMPLATE_DIR)) throw new Error(`缺少 shared 模板目录:${SHARED_TEMPLATE_DIR}`)
786
+ for (const file of fs.readdirSync(SHARED_TEMPLATE_DIR).filter(file => file.startsWith('shared-') && file.endsWith('.md'))) {
787
+ fs.copyFileSync(path.join(SHARED_TEMPLATE_DIR, file), path.join(RULE_DIR, file))
788
+ }
789
+ }
790
+
791
+ function renderStatusLines(modules) {
792
+ return modules.map(module => {
793
+ const state = moduleStatus(module)
794
+ const detail = state.missing.length ? `;未覆盖:${state.missing.join('、')}` : ''
795
+ const dimensions = state.dimensions ? `;${dimensionLabel('strategy', state.dimensions.strategy)};${dimensionLabel('repositoryFacts', state.dimensions.repositoryFacts)};${dimensionLabel('businessContracts', state.dimensions.businessContracts)}` : ''
796
+ return `- ${MODULES[module]}:${coverageLabel(state.status)}${dimensions}${detail}`
797
+ }).join('\n')
798
+ }
799
+
800
+ function renderAgents() {
801
+ write('AGENTS.md', `# Project Rules
802
+
803
+ 本项目规则位于 \`.agent-rules/\`。
804
+
805
+ 任何代码修改、规则维护、测试验证、Git 操作前,必须先读取 \`.agent-rules/project-index.md\` 和 \`.agent-rules/project-custom.md\`,并按任务路由读取必要规则。禁止无差别读取所有规则文件。`)
806
+ }
807
+
808
+ function ensureCustomRules() {
809
+ const relative = '.agent-rules/project-custom.md'
810
+ if (exists(relative)) return
811
+ write(relative, `# 项目人工补充规则
812
+
813
+ 本文件由项目维护者手工维护,生成器不会覆盖。
814
+
815
+ 仅记录无法由仓库扫描稳定推导、但已由项目负责人确认的特殊规则、例外和业务约束。
816
+
817
+ ## 当前补充
818
+
819
+ - 暂无。`)
820
+ }
821
+
822
+ function renderIndex() {
823
+ write('.agent-rules/project-index.md', `# 规则索引
824
+
825
+ 本文件是轻量入口。shared 是跨项目通用底线,project 是当前项目事实、策略和例外。
826
+
827
+ ## 1. 指令优先级
828
+
829
+ 1. 系统、平台和安全策略。
830
+ 2. 开发者、工具和 skill 强制指令。
831
+ 3. 用户本轮明确要求。
832
+ 4. 当前作用域的 \`AGENTS.md\` / \`CLAUDE.md\`。
833
+ 5. 已确认业务规则、接口契约和权威业务文档。
834
+ 6. 人工维护的 \`project-custom.md\`。
835
+ 7. 生成的当前项目 \`project-*\` 规则。
836
+ 8. \`shared-*\` 通用规则。
837
+ 9. 代码模式、历史对话和模型推断。
838
+
839
+ ## 2. 默认读取
840
+
841
+ 任务开始读取本文件和 \`project-custom.md\`,再按任务类型加载必要规则。不得为了保险全量读取。
842
+
843
+ ## 3. 任务路由
844
+
845
+ - 架构、目录、路由、页面新增:\`project-architecture.md\`、\`project-code-quality.md\`、\`shared-code-quality.md\`。
846
+ - UI、组件、样式、交互:\`project-ui-rules.md\`、\`project-architecture.md\`、\`shared-ui-rules.md\`。
847
+ - API、错误、登录失效、权限:\`project-api-error-handling.md\`、\`project-security-performance.md\`、\`project-state-data-flow.md\` 及对应 shared 文件。
848
+ - 状态、持久化、跨页面数据:\`project-state-data-flow.md\`、\`project-security-performance.md\` 及对应 shared 文件。
849
+ - 安全、性能、资源路径、外部跳转、上传和缓存:\`project-security-performance.md\`、\`project-architecture.md\`、\`shared-security-performance.md\`。
850
+ - 测试、构建、交付:\`project-testing-quality-gates.md\`、\`project-git-delivery.md\` 及对应 shared 文件。
851
+ - Git:\`project-git-delivery.md\`、\`shared-git-delivery.md\`。
852
+ - 业务:\`project-business-rules.md\`、\`project-domain-map.md\` 及权威业务文档。
853
+ - 代码资产、复用判断、抽象和重构审查:\`project-code-inventory.md\`、\`project-reuse-candidates.md\`、\`project-domain-map.md\`、\`project-code-quality.md\`、\`shared-code-quality.md\`。
854
+ - 规则维护:读取待修改 project 文件、对应 shared 文件、\`project-facts.json\`、相关事实来源和 \`shared-project-requirements-check.md\`。
855
+
856
+ ## 4. 决策顺序
857
+
858
+ 先读规则与事实清单,再检查指定入口和局部代码,存在明确模式则沿用;仅在新增业务语义、证据冲突、高风险或不可逆选择时询问用户。
859
+
860
+ ## 5. 模块状态
861
+
862
+ ${renderStatusLines(Object.keys(MODULES))}
863
+
864
+ ## 6. 事实有效性
865
+
866
+ - 事实清单:\`project-facts.json\`。
867
+ - 最后核验:${VERIFIED_AT}。
868
+ - 使用 \`${COMMAND} --verify\` 检查模板漂移、来源缺失和事实过期。
869
+
870
+ ## 7. 默认输出语言
871
+
872
+ AI 回复、规则文档、交付说明和代码注释默认使用${markdownValue(factValue('project.outputLanguage', '未定义,新增场景需人工确认'))}。`)
873
+ }
874
+
875
+ function renderSummary() {
876
+ write('.agent-rules/project-summary.md', `# 项目规则摘要
877
+
878
+ 本文件由 \`project-facts.json\` 生成,不作为独立事实源。
879
+
880
+ ## 项目
881
+
882
+ ${sourceFact('project.name', '项目名称')}
883
+ ${sourceFact('project.kind', '项目类型')}
884
+ ${sourceFact('stack.technologies', '技术栈')}
885
+ ${sourceFact('git.currentBranch', '当前分支')}
886
+ ${sourceFact('git.defaultBranchCandidate', '默认分支候选')}
887
+
888
+ ## 模块状态
889
+
890
+ ${renderStatusLines(Object.keys(MODULES))}
891
+
892
+ ## 使用原则
893
+
894
+ - 先检查规则指定入口和事实来源,再局部搜索。
895
+ - 已确认事实可直接沿用;推断事实需验证;未定义项只在影响当前任务时处理。
896
+ - 新业务语义、高风险冲突和不可逆选择才需要人工确认。`)
897
+ }
898
+
899
+ function renderProjectRules() {
900
+ const commands = factValue('testing.commands', [])
901
+ const testFiles = factValue('testing.files', [])
902
+ const domains = factValue('domain.map', { domains: [], routePaths: [], apiFiles: [] })
903
+
904
+ write('.agent-rules/project-architecture.md', `# 项目架构规则
905
+
906
+ ${metadata('architecture')}
907
+
908
+ ## 项目事实
909
+
910
+ ${sourceFact('project.name', '项目名称')}
911
+ ${sourceFact('project.description', '业务描述')}
912
+ ${sourceFact('stack.technologies', '技术栈')}
913
+ ${sourceFact('dir.pages', '页面目录')}
914
+ ${sourceFact('dir.router', '路由目录')}
915
+ ${sourceFact('dir.components', '共享组件目录')}
916
+ ${sourceFact('dir.utils', '工具目录')}
917
+ ${sourceFact('dir.api', 'API / service 目录')}
918
+ ${sourceFact('dir.state', '状态目录')}
919
+
920
+ ## 项目策略
921
+
922
+ ${sourceFact('project.outputLanguage', '默认输出语言')}
923
+ ${sourceFact('policy.directoryBoundaries', '目录边界')}
924
+ ${sourceFact('policy.newDirectories', '新增目录')}
925
+ ${sourceFact('policy.featureBoundary', '领域 / feature 边界')}`)
926
+
927
+ write('.agent-rules/project-code-quality.md', `# 项目代码质量补充规则
928
+
929
+ ${metadata('codeQuality')}
930
+
931
+ ${sourceFact('policy.dataContract', '数据契约层')}
932
+ ${sourceFact('policy.legacyGovernance', '存量治理')}
933
+ ${sourceFact('policy.modelPlacement', '模型与转换位置')}
934
+ ${sourceFact('policy.indexMaintenance', '索引维护')}
935
+ ${sourceFact('policy.encapsulationBoundary', '封装边界')}
936
+ ${sourceFact('policy.crossProjectPackages', '跨项目共享包')}
937
+ ${sourceFact('policy.documentation', '注释与文档化')}
938
+
939
+ 新增业务逻辑前按顺序检查 \`project-code-inventory.md\`、\`project-reuse-candidates.md\`、\`project-domain-map.md\`,索引不足时再做局部搜索。`)
940
+
941
+ const componentsDir = factValue('dir.components')
942
+ const componentFiles = componentsDir ? listFiles(componentsDir, 2).filter(file => /\.(vue|tsx?|jsx?|svelte)$/.test(file)).slice(0, 50) : []
943
+ write('.agent-rules/project-code-inventory.md', `# 项目代码资产索引
944
+
945
+ > 最后核验:${VERIFIED_AT}
946
+ > 来源:仓库目录扫描
947
+
948
+ ## 共享组件
949
+
950
+ ${componentFiles.length ? componentFiles.map(file => `- \`${file}\``).join('\n') : '- 暂无已确认共享组件。'}
951
+
952
+ ## 核心入口
953
+
954
+ ${sourceFact('api.entry', '统一请求入口')}
955
+ ${sourceFact('auth.guardEntry', '认证 / 路由守卫')}
956
+ ${sourceFact('state.directory', '状态目录')}`)
957
+
958
+ write('.agent-rules/project-reuse-candidates.md', `# 项目复用候选索引
959
+
960
+ > 最后核验:${VERIFIED_AT}
961
+
962
+ 暂无已确认候选项。发现重复业务判断、映射、校验、流程或 UI 结构时,应记录位置、语义和暂不抽象原因。`)
963
+
964
+ write('.agent-rules/project-ui-rules.md', `# 项目 UI 规则
965
+
966
+ ${metadata('ui')}
967
+
968
+ ${sourceFact('ui.library', 'UI 组件库')}
969
+ ${sourceFact('dir.components', '共享组件目录')}
970
+ ${sourceFact('policy.uiDesignSource', '设计与样式来源')}
971
+ ${sourceFact('policy.uiComponentBoundary', '组件边界')}
972
+ ${sourceFact('policy.uiLayoutFeedback', '布局与反馈')}
973
+ ${sourceFact('policy.uiFormBehavior', '表单与破坏性操作')}
974
+ ${sourceFact('policy.uiFallback', '缺少设计细节时')}
975
+ ${sourceFact('policy.uiPresentation', '文案、响应式与可访问性')}
976
+ ${sourceFact('policy.uiVerification', 'UI 验证')}
977
+
978
+ 先检查既有页面和组件;只有新增产品语义、视觉规范冲突或不可逆交互时才询问。`)
979
+
980
+ write('.agent-rules/project-api-error-handling.md', `# 项目 API 与错误处理规则
981
+
982
+ ${metadata('api')}
983
+
984
+ ## 已确认实现事实
985
+
986
+ ${sourceFact('api.entry', '统一请求入口')}
987
+ ${sourceFact('api.library', '请求库')}
988
+ ${sourceFact('api.timeoutMs', '超时毫秒')}
989
+ ${sourceFact('api.withCredentials', 'withCredentials')}
990
+ ${sourceFact('api.headers', '统一请求头')}
991
+ ${sourceFact('api.successBusinessCode', '成功业务码')}
992
+ ${sourceFact('api.handledHttpStatuses', '当前显式处理的 HTTP 状态')}
993
+ ${sourceFact('api.currentLogging', '当前日志行为')}
994
+ ${sourceFact('api.currentErrorObject', '当前错误对象')}
995
+ ${sourceFact('api.currentErrorPresentation', '当前错误提示')}
996
+ ${sourceFact('auth.current403Behavior', '当前 HTTP 403 行为')}
997
+
998
+ ## 已知实现差距
999
+
1000
+ ${sourceFact('api.implementationGaps', '扫描发现')}
1001
+
1002
+ ## 目标策略
1003
+
1004
+ ${sourceFact('policy.errorClassification', '错误分类与状态码类型')}
1005
+ ${sourceFact('policy.errorDisplay', '错误展示')}
1006
+ ${sourceFact('policy.authSemantics', '401 / 403 / 业务权限码语义')}
1007
+ ${sourceFact('policy.authCleanup', '认证失效清理')}
1008
+ ${sourceFact('policy.concurrentAuthFailure', '并发认证失效')}
1009
+ ${sourceFact('policy.silentRequest', '静默请求')}
1010
+ ${sourceFact('policy.requestLifecycle', '请求生命周期和防重复提交')}
1011
+ ${sourceFact('policy.apiObservability', 'API 可观测性与脱敏')}
1012
+
1013
+ ## 执行顺序
1014
+
1015
+ 先检查统一请求入口和认证守卫,沿用已确认模式;发现现状与目标策略不一致时记录为实现差距,不把现有缺陷提升为规则。
1016
+
1017
+ 若当前 403 行为未清理全局状态、未区分权限不足或没有并发单次处理保护,应明确标记为实现差距,不得写成目标规范。`)
1018
+
1019
+ write('.agent-rules/project-state-data-flow.md', `# 项目状态与数据流规则
1020
+
1021
+ ${metadata('state')}
1022
+
1023
+ ${sourceFact('state.library', '状态管理')}
1024
+ ${sourceFact('state.directory', '状态目录')}
1025
+ ${sourceFact('auth.storage', '当前认证持久化')}
1026
+ ${sourceFact('policy.globalStateBoundary', '全局状态边界')}
1027
+ ${sourceFact('policy.serverAuthority', '服务端权威数据')}
1028
+ ${sourceFact('policy.persistence', '持久化策略')}
1029
+ ${sourceFact('policy.stateTransformation', '数据转换与派生状态')}
1030
+ ${sourceFact('policy.crossPageData', '跨页面数据与 URL 边界')}
1031
+ ${sourceFact('policy.asyncState', '异步一致性与 UI 数据阶段')}
1032
+
1033
+ 异步一致性问题先检查现有调用链和状态模块;只有新增业务语义、事实冲突或不可恢复选择时询问。`)
1034
+
1035
+ write('.agent-rules/project-security-performance.md', `# 项目安全与性能规则
1036
+
1037
+ ${metadata('security')}
1038
+
1039
+ ${sourceFact('auth.storage', '凭证存储')}
1040
+ ${sourceFact('auth.tokenKey', 'token key')}
1041
+ ${sourceFact('auth.guardEntry', '认证 / 路由守卫')}
1042
+ ${sourceFact('policy.sensitiveFields', '敏感字段')}
1043
+ ${sourceFact('policy.credentialLifecycle', '凭证和会话生命周期')}
1044
+ ${sourceFact('policy.dataExposure', '敏感信息暴露限制')}
1045
+ ${sourceFact('policy.permissionBoundary', '权限边界')}
1046
+ ${sourceFact('policy.pathAndExternalUrl', '路径、资源与外部 URL')}
1047
+ ${sourceFact('policy.dynamicContent', '上传和动态内容')}
1048
+ ${sourceFact('policy.performancePaths', '关键性能路径')}
1049
+ ${sourceFact('policy.performanceBudget', '性能预算和大资源')}
1050
+ ${sourceFact('policy.cacheAndConcurrency', '并发、缓存和降级')}
1051
+
1052
+ 认证、权限和缓存变更必须同时检查 API、状态和安全规则。前端权限不能替代后端鉴权。`)
1053
+
1054
+ const commandLines = commands.length ? commands.map(command => `- \`${command.name}\`:${command.raw || command.source || '检测自项目配置'};类别:${command.category};改写源码:${command.writesSource ? '是' : '否'};写入产物:${command.writesArtifacts ? '是' : '否'};写入缓存:${command.writesCache ? '是' : '否'};长期运行:${command.longRunning ? '是' : '否'};适合自动执行:${command.safeForAutomaticExecution ? '是' : '否'}`).join('\n') : '- 未检测到验证命令。'
1055
+ write('.agent-rules/project-testing-quality-gates.md', `# 项目测试与质量门禁规则
1056
+
1057
+ ${metadata('testingGit')}
1058
+
1059
+ ## 已有测试
1060
+
1061
+ ${testFiles.length ? testFiles.map(file => `- \`${file}\``).join('\n') : '- 未检测到测试文件。'}
1062
+
1063
+ ## 可用命令
1064
+
1065
+ ${commandLines}
1066
+
1067
+ ## 策略
1068
+
1069
+ ${sourceFact('policy.testStrategy', '自动化测试')}
1070
+ ${sourceFact('policy.highRiskGate', '高风险门禁')}
1071
+ ${sourceFact('policy.coreFlows', '核心链路')}
1072
+ ${sourceFact('policy.manualRegression', '手动回归')}
1073
+ ${sourceFact('policy.testBoundaries', '边界场景与剩余风险')}
1074
+
1075
+ 不得把带 \`--fix\` / \`--write\` 的命令描述为纯验证。自动执行前必须同时检查源码、产物、缓存和长期运行副作用;项目不存在只读 lint 命令时,应明确记录,不得伪造命令。`)
1076
+
1077
+ write('.agent-rules/project-git-delivery.md', `# 项目 Git 与交付规则
1078
+
1079
+ ${metadata('testingGit')}
1080
+
1081
+ ${sourceFact('git.currentBranch', '当前分支')}
1082
+ ${sourceFact('git.branches', '本地分支')}
1083
+ ${sourceFact('git.defaultBranchCandidate', '默认分支候选')}
1084
+ ${sourceFact('git.protectedBranches', '受保护分支')}
1085
+ ${sourceFact('policy.branchNaming', '分支命名')}
1086
+ ${sourceFact('policy.commitStyle', '提交信息')}
1087
+ ${sourceFact('policy.wipCommits', 'WIP 与提交整理')}
1088
+ ${sourceFact('policy.releaseBoundary', 'PR、CI、发布和推送边界')}
1089
+ ${sourceFact('policy.gitSafety', 'Git 安全边界')}
1090
+
1091
+ 当前分支不等于受保护分支。无法从远端 HEAD 或仓库策略确认受保护分支时,必须询问用户。`)
1092
+
1093
+ write('.agent-rules/project-business-rules.md', `# 项目业务规则
1094
+
1095
+ ${metadata('business')}
1096
+
1097
+ ${sourceFact('business.rulesDocument', '业务规则文档')}
1098
+ ${sourceFact('policy.businessRuleSource', '业务规则来源')}
1099
+ ${sourceFact('policy.highRiskDomains', '高风险业务域')}
1100
+ ${sourceFact('policy.businessEnums', '状态、枚举和权限码来源')}
1101
+
1102
+ 仓库结构只能证明业务入口位置,不能证明金额、状态流转、审核、支付、订单等业务语义。`)
1103
+
1104
+ write('.agent-rules/project-domain-map.md', `# 项目业务域地图
1105
+
1106
+ ${metadata('business')}
1107
+
1108
+ ## 页面域
1109
+
1110
+ ${domains.domains && domains.domains.length ? domains.domains.map(domain => `- ${domain.name}:\`${domain.pageRoot}\``).join('\n') : '- 未检测到页面业务域。'}
1111
+
1112
+ ## 路由入口
1113
+
1114
+ ${domains.routePaths && domains.routePaths.length ? domains.routePaths.map(route => `- \`${route}\``).join('\n') : '- 未检测到路由入口。'}
1115
+
1116
+ ## API 文件
1117
+
1118
+ ${domains.apiFiles && domains.apiFiles.length ? domains.apiFiles.map(file => `- \`${file}\``).join('\n') : '- 未检测到 API 文件。'}
1119
+
1120
+ 本文件记录代码定位事实,不替代业务规则文档。`)
1121
+ }
1122
+
1123
+ function renderFacts() {
1124
+ const modules = Object.fromEntries(Object.keys(MODULES).map(module => [module, moduleStatus(module)]))
1125
+ const artifacts = Object.fromEntries(GENERATED_ARTIFACTS.filter(exists).map(relative => [relative, hashFile(path.join(ROOT, relative))]))
1126
+ write('.agent-rules/project-facts.json', JSON.stringify({
1127
+ schemaVersion: 2,
1128
+ generatorVersion: PACKAGE.version,
1129
+ coverageCatalogVersion: 1,
1130
+ generatedAt: NOW.toISOString(),
1131
+ staleAfterDays: 30,
1132
+ projectRoot: '.',
1133
+ modules,
1134
+ facts: facts.sort((a, b) => a.id.localeCompare(b.id)),
1135
+ answers,
1136
+ artifacts
1137
+ }, null, 2))
1138
+ }
1139
+
1140
+ function backupExisting() {
1141
+ if (fs.existsSync(RULE_DIR)) copyDirectory(RULE_DIR, path.join(ROOT, `.agent-rules.backup-${TIMESTAMP}`))
1142
+ if (fs.existsSync(path.join(ROOT, 'AGENTS.md'))) fs.copyFileSync(path.join(ROOT, 'AGENTS.md'), path.join(ROOT, `AGENTS.md.backup-${TIMESTAMP}`))
1143
+ }
1144
+
1145
+ function hashFile(file) {
1146
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
1147
+ }
1148
+
1149
+ function calculateManifestModule(module, manifest) {
1150
+ const stored = manifest.modules && manifest.modules[module]
1151
+ if (stored && stored.status === 'ignored') return { status: 'ignored', missing: [], coverage: [], dimensions: { strategy: 'ignored', repositoryFacts: 'ignored', businessContracts: 'ignored' } }
1152
+ const factMap = new Map((manifest.facts || []).map(item => [item.id, item]))
1153
+ const coverage = (COVERAGE_CATALOG[module] || []).map(([id, label, factIds]) => {
1154
+ const missingFacts = factIds.filter(factId => {
1155
+ const item = factMap.get(factId)
1156
+ return !item || !['confirmed', 'user-confirmed', 'not-applicable'].includes(item.status)
1157
+ })
1158
+ return { id, label, factIds, status: missingFacts.length ? 'missing' : 'covered', missingFacts }
1159
+ })
1160
+ const missing = coverage.filter(item => item.status === 'missing').map(item => item.id)
1161
+ const strategyCoverage = coverage.filter(item => item.factIds.some(id => id.startsWith('policy.')))
1162
+ const strategy = strategyCoverage.length ? (strategyCoverage.some(item => item.status === 'missing') ? 'partial' : 'configured') : 'not-applicable'
1163
+ const repositoryFacts = (manifest.facts || []).filter(item => item.module === module && item.source !== 'wizard')
1164
+ const repositoryStatus = !repositoryFacts.length ? 'not-found' : repositoryFacts.every(item => item.status === 'confirmed') ? 'confirmed' : 'partial'
1165
+ const contractIds = BUSINESS_CONTRACT_FACTS[module] || []
1166
+ const businessContracts = !contractIds.length ? 'not-applicable' : contractIds.every(id => {
1167
+ const item = factMap.get(id)
1168
+ return item && ['confirmed', 'user-confirmed', 'not-applicable'].includes(item.status)
1169
+ }) ? 'confirmed' : 'partial'
1170
+ return { status: missing.length ? 'partial' : 'configured', missing, coverage, dimensions: { strategy, repositoryFacts: repositoryStatus, businessContracts } }
1171
+ }
1172
+
1173
+ function verify() {
1174
+ const factsFile = path.join(RULE_DIR, 'project-facts.json')
1175
+ if (!fs.existsSync(factsFile)) throw new Error('缺少 .agent-rules/project-facts.json,请重新运行初始化器。')
1176
+ const manifest = JSON.parse(fs.readFileSync(factsFile, 'utf8'))
1177
+ const errors = []
1178
+ const warnings = []
1179
+ const validStatuses = new Set(['confirmed', 'user-confirmed', 'inferred', 'undefined', 'not-applicable'])
1180
+
1181
+ if (manifest.schemaVersion !== 2) errors.push(`不支持的 facts schemaVersion:${manifest.schemaVersion}`)
1182
+ if (!manifest.generatorVersion || !manifest.generatedAt || !Array.isArray(manifest.facts) || !manifest.modules || !manifest.artifacts) errors.push('project-facts.json 缺少必需字段。')
1183
+ const seenIds = new Set()
1184
+ for (const item of manifest.facts || []) {
1185
+ if (!item.id || !item.module || item.value === undefined || !item.status || !item.source || !item.verifiedAt) errors.push(`fact 结构不完整:${item.id || '<unknown>'}`)
1186
+ if (seenIds.has(item.id)) errors.push(`存在重复 fact ID:${item.id}`)
1187
+ seenIds.add(item.id)
1188
+ if (!validStatuses.has(item.status)) errors.push(`fact 状态非法:${item.id} -> ${item.status}`)
1189
+ }
1190
+ for (const template of fs.readdirSync(SHARED_TEMPLATE_DIR).filter(file => file.endsWith('.md'))) {
1191
+ const generated = path.join(RULE_DIR, template)
1192
+ if (!fs.existsSync(generated)) errors.push(`缺少 shared 文件:${template}`)
1193
+ else if (hashFile(generated) !== hashFile(path.join(SHARED_TEMPLATE_DIR, template))) errors.push(`shared 模板发生漂移:${template}`)
1194
+ }
1195
+ const catalogIds = Object.values(COVERAGE_CATALOG).flat().map(item => item[0]).sort()
1196
+ const checklistSource = fs.readFileSync(path.join(SHARED_TEMPLATE_DIR, 'shared-project-requirements-check.md'), 'utf8')
1197
+ const checklistIds = [...checklistSource.matchAll(/\[([a-z][\w.]+)\]/g)].map(match => match[1]).sort()
1198
+ if (JSON.stringify(catalogIds) !== JSON.stringify(checklistIds)) errors.push('coverage catalog 与 shared-project-requirements-check.md 的 requirement ID 不一致。')
1199
+ for (const item of manifest.facts || []) {
1200
+ for (const reference of item.evidenceRefs || []) {
1201
+ const current = fingerprint(reference.path, reference.kind && reference.kind.endsWith('-exists') ? 'existence' : 'content')
1202
+ if (!current) errors.push(`事实来源已不存在:${item.id} -> ${reference.path}`)
1203
+ else if (reference.sha256 && current.sha256 !== reference.sha256) errors.push(`事实来源已变化,需要重新生成:${item.id} -> ${reference.path}`)
1204
+ }
1205
+ }
1206
+ const manifestFact = id => (manifest.facts || []).find(item => item.id === id)
1207
+ const manifestValue = (id, fallback) => {
1208
+ const item = manifestFact(id)
1209
+ return item && item.value !== undefined ? item.value : fallback
1210
+ }
1211
+ const domainFact = manifestFact('domain.map')
1212
+ if (domainFact) {
1213
+ const currentDomainMap = collectDomainMap(manifestValue('dir.pages', ''), manifestValue('dir.api', ''))
1214
+ const comparable = { domains: currentDomainMap.domains, routePaths: currentDomainMap.routePaths, apiFiles: currentDomainMap.apiFiles }
1215
+ if (JSON.stringify(comparable) !== JSON.stringify(domainFact.value)) errors.push('业务域结构已变化,需要重新生成:domain.map')
1216
+ }
1217
+ const testFilesFact = manifestFact('testing.files')
1218
+ if (testFilesFact) {
1219
+ const currentTestFiles = collectTestFiles(manifestValue('dir.tests', ''))
1220
+ if (JSON.stringify(currentTestFiles) !== JSON.stringify(testFilesFact.value)) errors.push('测试文件结构已变化,需要重新生成:testing.files')
1221
+ }
1222
+ const ageDays = Math.floor((Date.now() - new Date(manifest.generatedAt).getTime()) / 86400000)
1223
+ if (ageDays > (manifest.staleAfterDays || 30)) warnings.push(`事实清单已超过 ${manifest.staleAfterDays || 30} 天未核验。`)
1224
+ const gitSnapshot = getGitSnapshot()
1225
+ if (gitSnapshot) {
1226
+ const gitComparisons = [
1227
+ ['git.currentBranch', gitSnapshot.current],
1228
+ ['git.branches', gitSnapshot.branches],
1229
+ ['git.remoteHead', gitSnapshot.remoteHead],
1230
+ ['git.branchCandidates', gitSnapshot.branchCandidates],
1231
+ ['git.defaultBranchCandidate', gitSnapshot.defaultCandidate]
1232
+ ]
1233
+ for (const [id, currentValue] of gitComparisons) {
1234
+ const recorded = manifestFact(id)
1235
+ if (!recorded && currentValue && (!Array.isArray(currentValue) || currentValue.length)) errors.push(`发现新的 Git 事实,需要重新生成:${id}`)
1236
+ else if (recorded && JSON.stringify(recorded.value) !== JSON.stringify(currentValue)) errors.push(`Git 事实已变化,需要重新生成:${id}`)
1237
+ }
1238
+ }
1239
+ const index = read('.agent-rules/project-index.md')
1240
+ for (const module of Object.keys(MODULES)) {
1241
+ const state = manifest.modules && manifest.modules[module]
1242
+ if (!state) {
1243
+ errors.push(`facts 缺少模块状态:${module}`)
1244
+ continue
1245
+ }
1246
+ const calculated = calculateManifestModule(module, manifest)
1247
+ if (state.status !== calculated.status || JSON.stringify((state.missing || []).slice().sort()) !== JSON.stringify(calculated.missing.slice().sort()) || JSON.stringify(state.dimensions || {}) !== JSON.stringify(calculated.dimensions || {})) errors.push(`模块 coverage 或状态维度计算不一致:${module}`)
1248
+ const storedCoverageIds = (state.coverage || []).map(item => item.id).sort()
1249
+ const catalogCoverageIds = (COVERAGE_CATALOG[module] || []).map(item => item[0]).sort()
1250
+ if (state.status !== 'ignored' && JSON.stringify(storedCoverageIds) !== JSON.stringify(catalogCoverageIds)) errors.push(`模块 coverage catalog 不完整:${module}`)
1251
+ const expected = `- ${MODULES[module]}:${coverageLabel(state.status)}`
1252
+ if (!index.includes(expected)) errors.push(`模块状态漂移:project-index.md 未包含“${expected}”`)
1253
+ if (state.status === 'partial') warnings.push(`${MODULES[module]}仍为部分配置:${(state.missing || []).join('、')}`)
1254
+ }
1255
+ for (const [relative, expectedHash] of Object.entries(manifest.artifacts || {})) {
1256
+ const full = path.join(ROOT, relative)
1257
+ if (!fs.existsSync(full)) errors.push(`生成产物缺失:${relative}`)
1258
+ else if (hashFile(full) !== expectedHash) errors.push(`生成产物已漂移:${relative};人工规则应写入 project-custom.md`)
1259
+ }
1260
+ const implementationGaps = manifestFact('api.implementationGaps')
1261
+ if (implementationGaps && Array.isArray(implementationGaps.value)) {
1262
+ implementationGaps.value.forEach(gap => warnings.push(`API 实现差距:${gap}`))
1263
+ }
1264
+ if (!exists('.agent-rules/project-custom.md')) warnings.push('缺少 project-custom.md,人工规则没有稳定保留位置。')
1265
+
1266
+ note('规则校验结果')
1267
+ errors.forEach(item => process.stdout.write(`错误:${item}\n`))
1268
+ warnings.forEach(item => process.stdout.write(`警告:${item}\n`))
1269
+ if (!errors.length && !warnings.length) process.stdout.write('通过:schema、coverage、shared、事实来源、生成产物和有效期均正常。\n')
1270
+ else if (!errors.length) process.stdout.write('校验完成:无结构错误,但仍有需要处理的警告。\n')
1271
+ if (errors.length) process.exitCode = 1
1272
+ else if (STRICT && warnings.length) process.exitCode = 2
1273
+ }
1274
+
1275
+ async function main() {
1276
+ if (SHOW_HELP) {
1277
+ process.stdout.write(`${PACKAGE.name} v${PACKAGE.version}\n\n用法:\n ${COMMAND} [--root <项目目录>]\n ${COMMAND} --verify [--strict] [--root <项目目录>]\n\n选项:\n --root 指定目标项目,默认当前目录\n --verify 检查 schema、coverage、模板、事实来源、产物和过期时间\n --strict verify 出现 partial、undefined、过期或其他警告时返回退出码 2\n --defaults 使用推荐默认值生成,所有未人工确认策略标记为 inferred\n --help 显示帮助\n`)
1278
+ return
1279
+ }
1280
+ if (!fs.existsSync(ROOT)) throw new Error(`项目目录不存在:${ROOT}`)
1281
+ if (VERIFY_ONLY) return verify()
1282
+
1283
+ note('企业级 AI 项目规则初始化向导')
1284
+ process.stdout.write(`项目目录:${ROOT}\n`)
1285
+ warn('shared 规则来自固定模板;project 规则由可追溯事实和用户策略生成。')
1286
+ if (!NON_INTERACTIVE) {
1287
+ makeReadline()
1288
+ if (!(await askYesNo('是否继续?', true))) {
1289
+ rl.close()
1290
+ return
1291
+ }
1292
+ }
1293
+
1294
+ scanAll()
1295
+ note('自动扫描摘要')
1296
+ process.stdout.write(`${facts.map(item => `- ${item.id}: ${markdownValue(item.value)}(${item.status})`).join('\n')}\n`)
1297
+ await collectAnswers()
1298
+ if (rl) rl.close()
1299
+
1300
+ note('模块覆盖状态')
1301
+ process.stdout.write(`${renderStatusLines(Object.keys(MODULES))}\n`)
1302
+ if (!NON_INTERACTIVE) {
1303
+ makeReadline()
1304
+ if (!(await askYesNo('确认备份现有规则并生成?', true))) {
1305
+ rl.close()
1306
+ return
1307
+ }
1308
+ rl.close()
1309
+ }
1310
+
1311
+ backupExisting()
1312
+ fs.mkdirSync(RULE_DIR, { recursive: true })
1313
+ copyShared()
1314
+ ensureCustomRules()
1315
+ renderAgents()
1316
+ renderIndex()
1317
+ renderSummary()
1318
+ renderProjectRules()
1319
+ renderFacts()
1320
+
1321
+ note('完成')
1322
+ process.stdout.write(`已生成规则。摘要:${path.join(RULE_DIR, 'project-summary.md')}\n`)
1323
+ verify()
1324
+ }
1325
+
1326
+ main().catch(error => {
1327
+ process.stderr.write(`错误:${error.message}\n`)
1328
+ process.exitCode = 1
1329
+ })