Complete automation infrastructure and project scaffolding

New files:
- .claude/agents/backend-builder.md, frontend-builder.md: Agent definitions
- .editorconfig, .env.example, .gitignore, .npmrc, .nvmrc: Project config
- .mvn/wrapper/maven-wrapper.properties: Maven wrapper
- PHASE_MANIFEST.txt: Phase tracking manifest
- docker-compose.yml: PostgreSQL + pgAdmin
- package.json, pnpm-workspace.yaml: Frontend workspace
- pom.xml: Root Maven configuration
- scripts/acceptance/smoke-test.sh: Smoke test script
- scripts/run-all.sh: Full pipeline entry point
- scripts/tools/export-openapi.sh: OpenAPI export utility

Updates:
- .claude/settings.json: Agent teams, hooks, sandbox config
- CLAUDE.md: Current Phase tracking
- All gate scripts: Refined error messages and checks
- All hook scripts: Optimized for performance
- phase-execute.sh: Enhanced phase execution logic
- phase-parallel.sh: Parallel execution support
- preflight-check.sh: Comprehensive manifest validation

Total: 32 files changed, 1238 insertions(+), 656 deletions(-)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-02-23 11:55:16 +08:00
co-authored by Qwen-Coder
parent d955d3a50f
commit e83d73fbae
32 changed files with 1241 additions and 659 deletions
+94
View File
@@ -0,0 +1,94 @@
---
name: backend-builder
description: "LJWX Platform 后端代码生成器。生成 Java 源码、Flyway 迁移 SQL、MyBatis-Plus 实体/Mapper/Service/Controller。在需要生成后端代码时使用。"
model: claude-sonnet-4-6
permissionMode: default
tools:
- Read
- Edit
- Write
- Grep
- Glob
- Bash
disallowedTools:
- WebFetch
- WebSearch
---
## Role
你是 LJWX Platform 的后端构建器。职责是生成高质量、符合所有硬规则的 Java 后端代码。
## 工作流程
1. **读取指令上下文**:读取 CLAUDE.md(硬规则)和当前 Phase Brief(scope + 验收条件)
2. **读取 spec**:仅读取 Phase Brief "读取清单"中列出的 spec 章节,禁止扫描整个 spec/
3. **逐文件生成**:按 scope 顺序逐一输出完整文件,禁止省略(禁止 `// ...省略...`)
4. **增量编译验证**:每生成 3-5 个 Java 文件后运行 `mvn clean compile -f pom.xml -q`
5. **修复编译错误**:若编译失败,读取错误信息,精确修改对应文件,直到编译通过
6. **写入 PHASE_MANIFEST.txt**:按要求格式追加当前 Phase 记录
7. **运行 gate**:`bash scripts/gates/gate-all.sh <phase-number>`
8. **修复 gate 失败**:若 gate 有 FAIL,逐条修复,重新运行 gate 直到全部 PASS
## 硬规则(不可违反)
- **DAG 依赖**:`core ← {security, data} ← web ← app`,禁止反向 import
- **审计字段**:所有业务实体(非 Quartz)必须继承含 7 列的 `BaseEntity`
- **@PreAuthorize**:每个 `@*Mapping` 方法必须有 `@PreAuthorize("hasAuthority('resource:action')")`,login/refresh 除外
- **DTO 无 tenantId**:DTO/Request/Response 禁止含 tenantId 字段
- **Service 无 setTenantId**:tenant_id 由 TenantContext(JWT claim)自动注入,Service 层禁止手动设置
- **Flyway 无 IF NOT EXISTS**:迁移 SQL 禁止此语句
- **POM 版本硬编码**:禁止 `${latest.version}` 占位符
- **BCrypt cost=10**:`new BCryptPasswordEncoder(10)`
- **输出完整性**:每个新文件必须输出完整内容
## 代码风格参考
### Controller
```java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserAppService userAppService;
@GetMapping
@PreAuthorize("hasAuthority('user:read')")
public Result<PageResult<UserVO>> list(UserQueryDTO query) {
return Result.ok(userAppService.listUsers(query));
}
}
```
### Service(不含 setTenantId)
```java
@Service
@RequiredArgsConstructor
public class UserAppService {
private final UserMapper userMapper;
public PageResult<UserVO> listUsers(UserQueryDTO query) {
// TenantLineInterceptor 自动注入 tenant_id,这里不需要手动设置
List<UserVO> list = userMapper.selectUserList(query);
long total = userMapper.countUsers(query);
return new PageResult<>(list, total);
}
}
```
### Flyway 迁移 SQL
```sql
CREATE TABLE sys_user (
id BIGINT NOT NULL,
username VARCHAR(50) NOT NULL,
-- 7 audit columns — required for all business tables
tenant_id BIGINT NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL DEFAULT 0,
created_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by BIGINT NOT NULL DEFAULT 0,
updated_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
PRIMARY KEY (id)
);
```
+1
View File
@@ -2,6 +2,7 @@
name: code-reviewer
description: "Diff-driven code reviewer for LJWX platform. Reviews ONLY files listed in the prompt against Phase scope and hard rules. Read-only access. Outputs structured findings. Use proactively after implementing a phase."
model: claude-sonnet-4-6
permissionMode: plan
tools:
- Read
- Grep
+132
View File
@@ -0,0 +1,132 @@
---
name: frontend-builder
description: "LJWX Platform 前端代码生成器。生成 Vue 3 + TypeScript 页面、路由、API 调用、Pinia Store、样式。在需要生成前端代码时使用。"
model: claude-sonnet-4-6
permissionMode: default
tools:
- Read
- Edit
- Write
- Grep
- Glob
- Bash
disallowedTools:
- WebFetch
- WebSearch
---
## Role
你是 LJWX Platform 的前端构建器。职责是生成高质量、类型安全的 Vue 3 前端代码。
## 工作流程
1. **读取指令上下文**:读取 CLAUDE.md(硬规则)和当前 Phase Brief(scope + 验收条件)
2. **读取 spec**:仅读取 Phase Brief 中引用的 spec 章节
3. **逐文件生成**:按 scope 顺序逐一输出完整文件
4. **类型检查验证**:每生成 3-5 个 TS/Vue 文件后运行 `pnpm run type-check`
5. **修复类型错误**:精确修改,直到类型检查通过
6. **写入 PHASE_MANIFEST.txt**:按要求格式追加当前 Phase 记录
7. **运行 gate**:`bash scripts/gates/gate-all.sh <phase-number>`
8. **修复 gate 失败**:直到全部 PASS
## 硬规则(不可违反)
- **TypeScript strict**:`tsconfig.json` 必须开启 `"strict": true`,禁止 `any`/`as any`/`<any>`
- **依赖版本**:`package.json` 中所有版本只用 `~`(tilde),禁止 `^`(caret)
- **环境变量**:只允许 `VITE_APP_BASE_API`,禁止 `VITE_API_BASE_URL` 等其他变量名
- **Vue Router v5**:使用 v5 API,禁止 v4 已废弃的 `onBeforeRouteLeave`/`onBeforeRouteUpdate`/`useLink`
- **组件语法**:使用 `<script setup lang="ts">` 语法糖
- **样式作用域**:使用 `<style scoped lang="scss">`
- **Axios 401 处理**:实现静默 token 刷新,拦截 401 自动重试
- **输出完整性**:每个文件必须输出完整内容
## 版本锁定(单一事实来源:CLAUDE.md)
所有版本从 CLAUDE.md "版本锁定" 表获取,禁止自行指定版本。
## 代码风格参考
### Vue 组件
```vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getUserList } from '@/api/user'
import type { UserVO } from '@ljwx/shared'
const loading = ref(false)
const users = ref<UserVO[]>([])
onMounted(async () => {
loading.value = true
try {
const res = await getUserList()
users.value = res.data.rows
} finally {
loading.value = false
}
})
</script>
<template>
<el-table v-loading="loading" :data="users">
<el-table-column prop="username" label="用户名" />
</el-table>
</template>
<style scoped lang="scss">
// scoped styles
</style>
```
### API 调用
```typescript
import request from '@/api/request'
import type { Result, PageResult } from '@ljwx/shared'
import type { UserVO, UserQueryDTO } from '@/types/user'
export function getUserList(params?: UserQueryDTO): Promise<Result<PageResult<UserVO>>> {
return request.get('/api/v1/users', { params })
}
export function createUser(data: UserCreateDTO): Promise<Result<number>> {
return request.post('/api/v1/users', data)
}
```
### Pinia Store
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { UserInfo } from '@/types/auth'
export const useUserStore = defineStore('user', () => {
const userInfo = ref<UserInfo | null>(null)
const token = ref<string>('')
function setToken(newToken: string) {
token.value = newToken
}
return { userInfo, token, setToken }
})
```
### Vue Router v5(正确写法)
```typescript
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/BasicLayout.vue'),
children: []
}
]
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
```
+63 -19
View File
@@ -1,6 +1,30 @@
{
"permissions": {
"defaultMode": "dontAsk",
"deny": [
"Bash(git push *)",
"Bash(rm -rf *)",
"Bash(rm -r *)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(sudo *)",
"Bash(docker run *)",
"Bash(docker exec *)",
"Bash(npx *)",
"Bash(eval *)",
"Bash(sh -c *)",
"WebSearch",
"WebFetch",
"Read(.env)",
"Read(.env.*)",
"Read(secrets/**)",
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(~/.ssh/**)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(secrets/**)"
],
"allow": [
"Read",
"Edit",
@@ -9,7 +33,6 @@
"Bash(mvn test -f *)",
"Bash(mvn verify -f *)",
"Bash(mvn dependency:tree -f *)",
"Bash(mvn versions:display-dependency-updates -f *)",
"Bash(pnpm install)",
"Bash(pnpm install --frozen-lockfile)",
"Bash(pnpm run *)",
@@ -25,9 +48,7 @@
"Bash(bash scripts/gates/gate-contract.sh)",
"Bash(bash scripts/gates/gate-nfr.sh)",
"Bash(bash scripts/preflight/preflight-check.sh)",
"Bash(bash scripts/phase-execute.sh *)",
"Bash(bash scripts/phase-bundle.sh *)",
"Bash(bash scripts/phase-parallel.sh *)",
"Bash(bash scripts/review/diff-review.sh *)",
"Bash(cat *)",
"Bash(grep *)",
"Bash(grep -r *)",
@@ -39,6 +60,8 @@
"Bash(head *)",
"Bash(tail *)",
"Bash(diff *)",
"Bash(sort *)",
"Bash(sed *)",
"Bash(git add .)",
"Bash(git add -A)",
"Bash(git commit -m *)",
@@ -63,27 +86,48 @@
"Task(backend-builder)",
"Task(frontend-builder)",
"Task(code-reviewer)"
],
"deny": [
"Bash(git push *)",
"Bash(rm -rf *)",
"Bash(rm -r *)",
"Bash(curl *)",
"Bash(wget *)",
"Bash(sudo *)",
"Bash(docker run *)",
"Bash(docker exec *)",
"Bash(npx *)",
"Bash(eval *)",
"Bash(sh -c *)",
"WebSearch"
]
},
"sandbox": {
"enabled": true,
"mode": "auto-allow"
"mode": "auto-allow",
"autoAllowBashIfSandboxed": false
},
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
},
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/scripts/hooks/pre-edit-guard.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/scripts/hooks/post-edit-check.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash $CLAUDE_PROJECT_DIR/scripts/hooks/stop-gate.sh"
}
]
}
]
}
}
+21
View File
@@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.java]
indent_size = 4
[*.xml]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+12
View File
@@ -0,0 +1,12 @@
# ===== Backend =====
DB_HOST=localhost
DB_PORT=5432
DB_NAME=ljwx_platform
DB_USERNAME=postgres
DB_PASSWORD=postgres
JWT_SECRET=your-256-bit-secret-key-here-change-in-production
FILE_BASE_PATH=./uploads
# ===== Frontend =====
VITE_APP_BASE_API=/api
VITE_APP_TITLE=LJWX Platform
+36
View File
@@ -0,0 +1,36 @@
# ── Maven ──
target/
*.class
*.jar
*.war
!.mvn/wrapper/maven-wrapper.jar
# ── IDE ──
.idea/
*.iml
.vscode/
*.DS_Store
# ── Node / pnpm ──
node_modules/
dist/
.pnpm-store/
# ── Environment ──
.env
.env.local
.env.*.local
# ── Logs ──
logs/
*.log
npm-debug.log*
# ── Test / Coverage ──
coverage/
*.exec
surefire-reports/
# ── OS ──
.DS_Store
Thumbs.db
+2
View File
@@ -0,0 +1,2 @@
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar
+3
View File
@@ -0,0 +1,3 @@
shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true
+1
View File
@@ -0,0 +1 @@
22.22.0
+1 -1
View File
@@ -27,7 +27,7 @@ Phase: 0 (Skeleton) — 尚未开始
1. **DAG 依赖**: core ← {security, data} ← web ← app。security 和 data 互不依赖。禁止 data import security 的任何类,反之亦然
2. **前端 semver**: 所有 dependencies / devDependencies 仅用 `~`(tilde),禁止 `^`(caret)
3. **审计字段**: 所有业务表(Quartz 除外)必须含 tenant_id, created_by, created_time, updated_by, updated_time, deleted, version 共 7 列,均 NOT NULL + 有 DEFAULT
3. **审计字段 (audit fields)**: 所有业务表(Quartz 除外)必须含 tenant_id, created_by, created_time, updated_by, updated_time, deleted, version 共 7 列,均 NOT NULL + 有 DEFAULT
4. **TypeScript**: 禁止 `any`,tsconfig 开启 `strict: true`
5. **权限注解**: 每个 Controller 方法必须 `@PreAuthorize`(login / refresh 除外),格式 `hasAuthority('resource:action')`,不使用 ROLE_ 前缀
6. **tenant_id**: DTO 中禁止出现,前端禁止传递,后端由 Interceptor 自动注入
+22
View File
@@ -0,0 +1,22 @@
## PHASE 0
Status: PASSED
pom.xml
.mvn/wrapper/maven-wrapper.properties
pnpm-workspace.yaml
package.json
.npmrc
.nvmrc
docker-compose.yml
.env.example
.gitignore
.editorconfig
scripts/gates/gate-all.sh
scripts/gates/gate-compile.sh
scripts/gates/gate-integration.sh
scripts/gates/gate-contract.sh
scripts/gates/gate-manifest.sh
scripts/gates/gate-nfr.sh
scripts/tools/export-openapi.sh
scripts/acceptance/smoke-test.sh
PHASE_MANIFEST.txt
+22
View File
@@ -0,0 +1,22 @@
version: '3.8'
services:
postgres:
image: postgres:16.12-alpine
container_name: ljwx-postgres
ports:
- "${DB_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${DB_NAME:-ljwx_platform}
POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_NAME:-ljwx_platform}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
pgdata:
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ljwx-platform-frontend",
"private": true,
"packageManager": "pnpm@10.30.1",
"engines": {
"node": ">=20.19.0 || >=22.12.0"
},
"scripts": {
"dev:admin": "pnpm --filter ljwx-platform-admin dev",
"dev:mobile": "pnpm --filter ljwx-platform-mobile dev:h5",
"dev:screen": "pnpm --filter ljwx-platform-screen dev",
"build:shared": "pnpm --filter @ljwx/shared build",
"build:admin": "pnpm run build:shared && pnpm --filter ljwx-platform-admin build",
"build:mobile": "pnpm run build:shared && pnpm --filter ljwx-platform-mobile build:h5",
"build:screen": "pnpm run build:shared && pnpm --filter ljwx-platform-screen build",
"build:all": "pnpm run build:shared && pnpm --filter './ljwx-platform-*' build",
"type-check": "pnpm --filter './ljwx-platform-*' type-check",
"lint": "pnpm --filter './ljwx-platform-*' lint"
}
}
+5
View File
@@ -0,0 +1,5 @@
packages:
- 'packages/*'
- 'ljwx-platform-admin'
- 'ljwx-platform-mobile'
- 'ljwx-platform-screen'
+124
View File
@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.11</version>
<relativePath/>
</parent>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>LJWX Platform</name>
<description>Enterprise-grade full-stack scaffold — Java 21 / Spring Boot 3.5</description>
<modules>
<module>ljwx-platform-core</module>
<module>ljwx-platform-security</module>
<module>ljwx-platform-data</module>
<module>ljwx-platform-web</module>
<module>ljwx-platform-app</module>
</modules>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<mybatis.spring.boot.version>3.0.5</mybatis.spring.boot.version>
<springdoc.version>2.8.15</springdoc.version>
<testcontainers.version>1.21.4</testcontainers.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- ── Internal modules ── -->
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-security</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-data</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.ljwx.platform</groupId>
<artifactId>ljwx-platform-web</artifactId>
<version>${project.version}</version>
</dependency>
<!-- ── MyBatis ── -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.spring.boot.version}</version>
</dependency>
<!-- ── SpringDoc OpenAPI ── -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- ── Testcontainers BOM ── -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>${testcontainers.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>21</source>
<target>21</target>
<parameters>true</parameters>
<compilerArgs>
<arg>-Xlint:unchecked</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludes>
<exclude>**/*IT.java</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-failsafe-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Smoke test — verifies health endpoint and admin login after deployment.
# Usage: bash scripts/acceptance/smoke-test.sh [base-url]
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
echo "===== Smoke Test ====="
# ── Health check ──
echo "[1] Health check"
for i in $(seq 1 30); do
if curl -sf "$BASE_URL/actuator/health" > /dev/null 2>&1; then
echo " OK: health (attempt $i)"
break
fi
if [[ $i -eq 30 ]]; then
echo " FAIL: Health check timed out after 60s"
exit 1
fi
sleep 2
done
# ── Login test ──
echo "[2] Login test (admin)"
RESP=$(curl -sf -X POST "$BASE_URL/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Admin@12345"}' || echo '{}')
CODE=$(echo "$RESP" | grep -o '"code":[0-9]*' | head -1 | cut -d: -f2 || echo "")
if [[ "$CODE" != "200" ]]; then
echo " FAIL: Login returned code=${CODE:-unknown}"
echo " Response: $RESP"
exit 1
fi
echo " OK: admin login succeeded"
echo "===== Smoke Test PASSED ====="
+32 -19
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# gate-all.sh — 聚合所有 Gate 脚本,按依赖顺序执行
# 用法: bash scripts/gates/gate-all.sh [phase-number]
# 任何一个 Gate 失败则整体失败,但全部跑完后才报告汇总
# ═══════════════════════════════════════════════════════════
set -uo pipefail
@@ -8,14 +9,7 @@ set -uo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$PROJECT_ROOT"
GATES=(
"gate-manifest.sh"
"gate-rules.sh"
"gate-compile.sh"
"gate-integration.sh"
"gate-contract.sh"
"gate-nfr.sh"
)
PHASE_ARG="${1:-}"
TOTAL=0
PASSED=0
@@ -25,30 +19,48 @@ SKIPPED=0
echo "╔══════════════════════════════════════════════════╗"
echo "║ LJWX Gate — Full Check ║"
if [[ -n "$PHASE_ARG" ]]; then
echo "║ Phase: $PHASE_ARG ║"
fi
echo "╚══════════════════════════════════════════════════╝"
echo ""
for GATE in "${GATES[@]}"; do
GATE_PATH="scripts/gates/$GATE"
run_gate() {
local gate="$1"
shift
local gate_path="scripts/gates/$gate"
((TOTAL++))
if [[ ! -f "$GATE_PATH" ]]; then
echo " SKIP $GATE (file not found)"
if [[ ! -f "$gate_path" ]]; then
echo " SKIP $gate (file not found)"
((SKIPPED++))
continue
return
fi
echo "── $GATE ──────────────────────────────────────"
if bash "$GATE_PATH"; then
echo " PASS $GATE"
echo "── $gate ──────────────────────────────────────"
if bash "$gate_path" "$@"; then
echo " PASS $gate"
((PASSED++))
else
echo " FAIL $GATE"
echo " FAIL $gate"
((FAILED++))
FAILED_NAMES+=("$GATE")
FAILED_NAMES+=("$gate")
fi
echo ""
done
}
# gate-manifest needs phase arg to check scope files
run_gate "gate-manifest.sh" $PHASE_ARG
# gate-rules: full-repo scan, no phase arg needed
run_gate "gate-rules.sh"
# gate-compile: reads phase from CLAUDE.md
run_gate "gate-compile.sh"
# gate-integration: only runs if Docker available and tests exist
run_gate "gate-integration.sh"
# gate-contract: only runs if app module exists
run_gate "gate-contract.sh"
# gate-nfr: NFR checks
run_gate "gate-nfr.sh"
echo "══════════════════════════════════════════════════"
echo " Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED"
@@ -62,3 +74,4 @@ else
echo " GATE RESULT: PASSED"
exit 0
fi
+1 -1
View File
@@ -16,7 +16,7 @@ cd "$PROJECT_ROOT"
ERRORS=0
# ── Determine which checks to run based on current phase ──
CURRENT_PHASE=$(grep -oP 'Current Phase:\s*\K\d+' CLAUDE.md 2>/dev/null || echo "")
CURRENT_PHASE=$(sed -n 's/^Phase:[[:space:]]*\([0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1 || echo "")
PHASE_BRIEF=""
TARGET_BACKEND="true"
TARGET_FRONTEND="true"
+2 -2
View File
@@ -98,7 +98,7 @@ fi
if [[ -f "spec/03-api.md" ]]; then
echo "[Contract] Cross-checking with spec/03-api.md"
# Extract expected paths from api spec (lines like "POST /api/v1/auth/login")
EXPECTED_PATHS=$(grep -oP '(GET|POST|PUT|DELETE|PATCH)\s+/api/\S+' spec/03-api.md \
EXPECTED_PATHS=$(grep -oE '(GET|POST|PUT|DELETE|PATCH)[[:space:]]+/api/[^[:space:]]+' spec/03-api.md \
| awk '{print $2}' | sort -u || true)
if [[ -n "$EXPECTED_PATHS" ]]; then
@@ -110,7 +110,7 @@ if [[ -f "spec/03-api.md" ]]; then
[[ -z "$expected" ]] && continue
# Normalize path parameters: /api/v1/users/{id} → match with regex
PATTERN=$(echo "$expected" | sed 's/{[^}]*}/[^\/]*/g')
if ! echo "$ACTUAL_PATHS" | grep -qP "^${PATTERN}$"; then
if ! echo "$ACTUAL_PATHS" | grep -qE "^${PATTERN}$"; then
echo " WARN: Expected path $expected not found in generated spec"
((MISSING_COUNT++))
fi
+1 -1
View File
@@ -18,7 +18,7 @@ cd "$PROJECT_ROOT"
ERRORS=0
# ── Quick check: is this a backend phase? ──
CURRENT_PHASE=$(grep -oP 'Current Phase:\s*\K\d+' CLAUDE.md 2>/dev/null || echo "")
CURRENT_PHASE=$(sed -n 's/^Phase:[[:space:]]*\([0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1 || echo "")
TARGET_BACKEND="true"
if [[ -n "$CURRENT_PHASE" ]]; then
PHASE_BRIEF="spec/phase/phase-$(printf '%02d' "$CURRENT_PHASE").md"
+16 -14
View File
@@ -1,12 +1,12 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# gate-manifest.sh — Phase-local manifest 与 scope 文件检查
# 用法: bash scripts/gates/gate-manifest.sh [phase-number]
#
# 检查项:
# 1. PHASE_MANIFEST.txt 存在且非空
# 2. 当前 Phase 的 section 存在
# 3. Phase Brief 中 scope 列出的具体文件全部存在
# 4. Phase Brief 文件本身存在且有 YAML front-matter
# 1. Phase Brief 存在且有 YAML front-matter
# 2. Phase Brief 中 scope 列出的具体文件全部存在
# 3. PHASE_MANIFEST.txt 有对应 Phase section(warn only)
# ═══════════════════════════════════════════════════════════
set -euo pipefail
@@ -15,10 +15,15 @@ cd "$PROJECT_ROOT"
ERRORS=0
# ── Determine current phase ──
CURRENT_PHASE=$(grep -oP 'Current Phase:\s*\K\d+' CLAUDE.md 2>/dev/null || echo "")
# ── Determine current phase: arg > CLAUDE.md ──
if [[ -n "${1:-}" ]]; then
CURRENT_PHASE="$1"
else
CURRENT_PHASE=$(sed -n 's/^Phase:[[:space:]]*\([0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1 || echo "")
fi
if [[ -z "$CURRENT_PHASE" ]]; then
echo " WARN: Cannot determine Current Phase from CLAUDE.md — skipping manifest check"
echo " WARN: Cannot determine current Phase — skipping manifest check"
exit 0
fi
@@ -46,18 +51,15 @@ else
done
fi
# ── Check 2: PHASE_MANIFEST.txt exists and has current phase section ──
# ── Check 2: PHASE_MANIFEST.txt — warn only (written AFTER gates pass) ──
if [[ ! -f "PHASE_MANIFEST.txt" ]]; then
echo " FAIL: PHASE_MANIFEST.txt not found"
((ERRORS++))
echo " WARN: PHASE_MANIFEST.txt not found (will be written after commit)"
elif [[ ! -s "PHASE_MANIFEST.txt" ]]; then
echo " FAIL: PHASE_MANIFEST.txt is empty"
((ERRORS++))
echo " WARN: PHASE_MANIFEST.txt is empty"
else
MANIFEST_MARKER="## PHASE $CURRENT_PHASE"
if ! grep -q "$MANIFEST_MARKER" PHASE_MANIFEST.txt; then
echo " FAIL: PHASE_MANIFEST.txt missing section: $MANIFEST_MARKER"
((ERRORS++))
echo " WARN: PHASE_MANIFEST.txt missing section: $MANIFEST_MARKER (will be added)"
fi
fi
+14 -14
View File
@@ -27,7 +27,7 @@ warn() { echo " WARNING $1"; ((WARNINGS++)); }
# ── Helper: grep across all yml/yaml files ──
find_in_config() {
grep -rPn "$1" --include='*.yml' --include='*.yaml' --include='*.properties' . 2>/dev/null \
grep -rEn "$1" --include='*.yml' --include='*.yaml' --include='*.properties' . 2>/dev/null \
| grep -v 'node_modules' | grep -v 'target/' || true
}
@@ -50,7 +50,7 @@ fi
echo "[N02] Caffeine cache TTL: 10 min"
CACHE_CFG=$(find_in_config 'caffeine|cache.*spec|cache.*expire')
if [[ -n "$CACHE_CFG" ]]; then
if ! echo "$CACHE_CFG" | grep -qP '(expireAfterWrite|expire-after-write)\s*[=:]\s*10m|600'; then
if ! echo "$CACHE_CFG" | grep -qE '(expireAfterWrite|expire-after-write)[[:space:]]*[=:][[:space:]]*(10m|600)'; then
warn "N02 cache-ttl — caffeine config found but TTL may not be 10min"
else
echo " OK: Caffeine TTL=10min configured"
@@ -60,10 +60,10 @@ fi
echo "[N03] JWT configuration"
JWT_CFG=$(find_in_config 'jwt|access.*token.*expir|refresh.*token.*expir')
if [[ -n "$JWT_CFG" ]]; then
if ! echo "$JWT_CFG" | grep -qiP '(30|1800)'; then
if ! echo "$JWT_CFG" | grep -qiE '(30|1800)'; then
warn "N03 jwt — access token expiry may not be 30min"
fi
if ! echo "$JWT_CFG" | grep -qiP '(7d|604800|10080)'; then
if ! echo "$JWT_CFG" | grep -qiE '(7d|604800|10080)'; then
warn "N03 jwt — refresh token expiry may not be 7 days"
fi
fi
@@ -76,28 +76,28 @@ if [[ -z "$HS256" ]]; then
fi
echo "[N04] Async log thread pool"
ASYNC_CFG=$(grep -rPn 'core.?pool.?size|max.?pool.?size|queue.?capacity' --include='*.java' --include='*.yml' . 2>/dev/null \
ASYNC_CFG=$(grep -rEn 'core.?pool.?size|max.?pool.?size|queue.?capacity' --include='*.java' --include='*.yml' . 2>/dev/null \
| grep -v 'node_modules' | grep -vi 'quartz' || true)
if [[ -n "$ASYNC_CFG" ]]; then
echo " OK: Async pool configuration found"
# Detailed check
if ! echo "$ASYNC_CFG" | grep -qP '(core|corePoolSize)\s*[=:(]\s*2'; then
if ! echo "$ASYNC_CFG" | grep -qE '(core|corePoolSize)[[:space:]]*[=:(][[:space:]]*2'; then
warn "N04 async-pool — core pool size may not be 2"
fi
if ! echo "$ASYNC_CFG" | grep -qP '(max|maxPoolSize|maximum)\s*[=:(]\s*4'; then
if ! echo "$ASYNC_CFG" | grep -qE '(max|maxPoolSize|maximum)[[:space:]]*[=:(][[:space:]]*4'; then
warn "N04 async-pool — max pool size may not be 4"
fi
fi
echo "[N05] Operation log truncation: 4096 bytes"
TRUNC=$(grep -rPn '4096|TRUNCATE|truncat' --include='*.java' . 2>/dev/null \
TRUNC=$(grep -rEn '4096|TRUNCATE|truncat' --include='*.java' . 2>/dev/null \
| grep -v 'node_modules' | grep -i 'log\|oper' || true)
if [[ -n "$TRUNC" ]]; then
echo " OK: Log truncation reference found"
fi
echo "[N06] Log field masking"
MASK=$(grep -rPn 'mask|sensitive|password.*\*|token.*\*|secret.*\*' --include='*.java' . 2>/dev/null \
MASK=$(grep -rEn 'mask|sensitive|password.*\*|token.*\*|secret.*\*' --include='*.java' . 2>/dev/null \
| grep -v 'node_modules' || true)
if [[ -n "$MASK" ]]; then
echo " OK: Field masking references found"
@@ -116,14 +116,14 @@ fi
echo "[N08] Toolchain version locks"
# Check Java version
if [[ -f "pom.xml" ]]; then
JAVA_VER=$(grep -oP '<java.version>\K[^<]+' pom.xml 2>/dev/null || true)
JAVA_VER=$(grep '<java.version>' pom.xml 2>/dev/null | sed 's/.*<java.version>//;s|</java.version>.*||' | tr -d '[:space:]')
if [[ -n "$JAVA_VER" && "$JAVA_VER" != "21" ]]; then
fail "N08 java-version — pom.xml java.version=$JAVA_VER, expected 21"
fi
fi
# Check Node version
if [[ -f ".nvmrc" ]]; then
NODE_VER=$(cat .nvmrc | tr -d '[:space:]')
NODE_VER=$(tr -d '[:space:]' < .nvmrc)
if [[ "$NODE_VER" != "22.22.0" ]]; then
fail "N08 node-version — .nvmrc=$NODE_VER, expected 22.22.0"
else
@@ -132,9 +132,9 @@ if [[ -f ".nvmrc" ]]; then
fi
# Check pnpm version
if [[ -f "package.json" ]]; then
PNPM_VER=$(grep -oP '"packageManager"\s*:\s*"pnpm@\K[^"]+' package.json 2>/dev/null || true)
PNPM_VER=$(grep '"packageManager"' package.json 2>/dev/null | sed 's/.*pnpm@//;s/".*//' | tr -d '[:space:]')
if [[ -n "$PNPM_VER" && "$PNPM_VER" != "10.30.1" ]]; then
fail "N08 pnpm-version — packageManager pnpm @$PNPM_VER, expected 10.30.1"
fail "N08 pnpm-version — packageManager pnpm@$PNPM_VER, expected 10.30.1"
fi
fi
@@ -151,7 +151,7 @@ fi
echo "[N10] Docker image tag locked"
if [[ -f "docker-compose.yml" ]]; then
PG_IMAGE=$(grep -oP 'image:\s*\Kpostgres:\S+' docker-compose.yml 2>/dev/null || true)
PG_IMAGE=$(grep 'image:.*postgres:' docker-compose.yml 2>/dev/null | sed 's/.*image:[[:space:]]*//' | tr -d '[:space:]')
if [[ -n "$PG_IMAGE" ]]; then
if [[ "$PG_IMAGE" != "postgres:16.12-alpine" ]]; then
fail "N10 docker-tag — postgres image=$PG_IMAGE, expected postgres:16.12-alpine"
+24 -24
View File
@@ -40,7 +40,8 @@ fi
echo "[R02] No TypeScript 'any' type"
# Match ': any', 'as any', '<any>' but exclude comments and node_modules
ANY_HITS=$(grep -rPn '(?<!//.*)(\:\s*any\b|as\s+any\b|<any>)' --include='*.ts' --include='*.vue' . 2>/dev/null \
# Use grep -En (ERE) for macOS BSD grep compatibility
ANY_HITS=$(grep -rEn '(:\s*any[^a-zA-Z]|as any[^a-zA-Z]|<any>)' --include='*.ts' --include='*.vue' . 2>/dev/null \
| grep -v 'node_modules' \
| grep -v '\.d\.ts:' \
| grep -v '// eslint-disable' \
@@ -52,8 +53,8 @@ if [[ -n "$ANY_HITS" ]]; then
fi
echo "[R03] Env var must be VITE_APP_BASE_API only"
BAD_ENV=$(grep -rPn 'VITE_API_BASE_URL|VITE_BASE_API|VITE_APP_API[^_]' \
--include='*.ts' --include='*.vue' --include='.env*' . 2>/dev/null \
BAD_ENV=$(grep -rEn 'VITE_API_BASE_URL|VITE_BASE_API' \
--include='*.ts' --include='*.vue' --include='.env' --include='.env.*' . 2>/dev/null \
| grep -v 'node_modules' || true)
if [[ -n "$BAD_ENV" ]]; then
while IFS= read -r hit; do
@@ -62,7 +63,7 @@ if [[ -n "$BAD_ENV" ]]; then
fi
echo "[R04] Flyway: no IF NOT EXISTS"
IFNE_HITS=$(grep -rPni 'IF\s+NOT\s+EXISTS' --include='*.sql' . 2>/dev/null | grep -v 'node_modules' || true)
IFNE_HITS=$(grep -rEni 'IF[[:space:]]+NOT[[:space:]]+EXISTS' --include='*.sql' . 2>/dev/null | grep -v 'node_modules' || true)
if [[ -n "$IFNE_HITS" ]]; then
while IFS= read -r hit; do
fail "R04 flyway-no-ifne — $hit"
@@ -73,7 +74,7 @@ echo "[R05] Audit columns in business tables"
SQL_FILES=$(find . -name '*.sql' -path '*/migration/*' ! -path '*/node_modules/*' 2>/dev/null || true)
AUDIT_COLS=(tenant_id created_by created_time updated_by updated_time deleted version)
for sql_file in $SQL_FILES; do
# Extract CREATE TABLE blocks
# Extract CREATE TABLE names using ERE (macOS compatible)
while IFS= read -r table_name; do
[[ -z "$table_name" ]] && continue
# Skip Quartz tables
@@ -84,15 +85,16 @@ for sql_file in $SQL_FILES; do
# Get the table definition block
TABLE_BLOCK=$(sed -n "/CREATE TABLE.*${table_name}/I,/);/p" "$sql_file")
for col in "${AUDIT_COLS[@]}"; do
if ! echo "$TABLE_BLOCK" | grep -qi "$col"; then
if [[ -z "$(echo "$TABLE_BLOCK" | grep -i "$col")" ]]; then
fail "R05 audit-col — $sql_file table $table_name missing column: $col"
fi
done
done < <(grep -oPI 'CREATE\s+TABLE\s+\K\S+' "$sql_file" 2>/dev/null || true)
done < <(grep -oEi 'CREATE[[:space:]]+TABLE[[:space:]]+([`"]?)([A-Za-z_][A-Za-z0-9_]*)' "$sql_file" 2>/dev/null \
| sed -E 's/CREATE[[:space:]]+TABLE[[:space:]]+[`"]*//i' || true)
done
echo "[R06] DTO must not expose tenant_id"
DTO_HITS=$(grep -rPn '(private|protected|public)\s+\S+\s+tenantId' \
DTO_HITS=$(grep -rEn '(private|protected|public)[[:space:]]+[^[:space:]]+[[:space:]]+tenantId' \
--include='*DTO.java' --include='*Dto.java' \
--include='*Request.java' --include='*Response.java' . 2>/dev/null \
| grep -v 'node_modules' || true)
@@ -101,8 +103,8 @@ if [[ -n "$DTO_HITS" ]]; then
fail "R06 dto-no-tenant — $hit"
done <<< "$DTO_HITS"
fi
# Also check record-style DTOs
DTO_RECORD_HITS=$(grep -rPn 'tenantId' \
# Also check record-style DTOs / constructor params
DTO_RECORD_HITS=$(grep -rEn 'tenantId' \
--include='*DTO.java' --include='*Dto.java' \
--include='*Request.java' --include='*Response.java' . 2>/dev/null \
| grep -v 'node_modules' \
@@ -110,8 +112,8 @@ DTO_RECORD_HITS=$(grep -rPn 'tenantId' \
| grep -v '//' || true)
if [[ -n "$DTO_RECORD_HITS" ]]; then
while IFS= read -r hit; do
# Avoid double-counting the field declarations already caught above
if ! echo "$hit" | grep -qP '(private|protected|public)\s+\S+\s+tenantId'; then
# Avoid double-counting field declarations already caught above
if [[ -z "$(echo "$hit" | grep -E '(private|protected|public)[[:space:]]+[^[:space:]]+[[:space:]]+tenantId')" ]]; then
fail "R06 dto-no-tenant (record/param) — $hit"
fi
done <<< "$DTO_RECORD_HITS"
@@ -120,12 +122,12 @@ fi
echo "[R07] Controller @PreAuthorize"
CONTROLLER_FILES=$(find . -name '*Controller.java' ! -path '*/node_modules/*' 2>/dev/null || true)
for ctrl in $CONTROLLER_FILES; do
MAPPING_LINES=$(grep -n ' @(Get|Post|Put|Delete|Patch)Mapping' "$ctrl" 2>/dev/null | cut -d: -f1 || true)
MAPPING_LINES=$(grep -En '@(Get|Post|Put|Delete|Patch)Mapping' "$ctrl" 2>/dev/null | cut -d: -f1 || true)
for LINE_NUM in $MAPPING_LINES; do
START=$((LINE_NUM - 5))
[[ $START -lt 1 ]] && START=1
CONTEXT=$(sed -n "${START},${LINE_NUM}p" "$ctrl")
if ! echo "$CONTEXT" | grep -q ' @PreAuthorize'; then
if [[ -z "$(echo "$CONTEXT" | grep '@PreAuthorize')" ]]; then
fail "R07 preauthorize — $ctrl:$LINE_NUM @*Mapping without @PreAuthorize"
fi
done
@@ -140,10 +142,8 @@ if [[ -n "$POM_HITS" ]]; then
fi
echo "[R09] POM: no SNAPSHOT in release dependencies"
# Only check <dependency> version tags, not the project's own version
SNAP_HITS=$(grep -rPn '<version>.*SNAPSHOT.*</version>' --include='pom.xml' . 2>/dev/null \
| grep -v '<version>\${' \
| grep -v '<!-- project version -->' || true)
SNAP_HITS=$(grep -rEn '<version>[^<]*SNAPSHOT[^<]*</version>' --include='pom.xml' . 2>/dev/null \
| grep -v 'project version' || true)
if [[ -n "$SNAP_HITS" ]]; then
while IFS= read -r hit; do
warn "R09 no-snapshot — $hit"
@@ -190,7 +190,7 @@ V4_PATTERNS=(
'RouterLink.*:to.*v-slot'
)
for pattern in "${V4_PATTERNS[@]}"; do
V4_HITS=$(grep -rPn "$pattern" --include='*.vue' --include='*.ts' . 2>/dev/null | grep -v 'node_modules' || true)
V4_HITS=$(grep -rEn "$pattern" --include='*.vue' --include='*.ts' . 2>/dev/null | grep -v 'node_modules' || true)
if [[ -n "$V4_HITS" ]]; then
while IFS= read -r hit; do
warn "R11 vue-router-v5 — possible v4 API usage: $hit"
@@ -199,16 +199,16 @@ for pattern in "${V4_PATTERNS[@]}"; do
done
echo "[R12] BCrypt cost factor"
BCRYPT_HITS=$(grep -rPn 'BCryptPasswordEncoder\s*\(' --include='*.java' . 2>/dev/null || true)
BCRYPT_HITS=$(grep -rEn 'BCryptPasswordEncoder[[:space:]]*\(' --include='*.java' . 2>/dev/null || true)
if [[ -n "$BCRYPT_HITS" ]]; then
while IFS= read -r hit; do
if ! echo "$hit" | grep -qP 'BCryptPasswordEncoder\s*\(\s*10\s*\)'; then
# Could be default constructor (cost=10) or explicit non-10
if echo "$hit" | grep -qP 'BCryptPasswordEncoder\s*\(\s*\d'; then
# If explicit numeric arg and NOT 10, fail
if [[ -n "$(echo "$hit" | grep -E 'BCryptPasswordEncoder[[:space:]]*\([[:space:]]*[0-9]')" ]]; then
if [[ -z "$(echo "$hit" | grep -E 'BCryptPasswordEncoder[[:space:]]*\([[:space:]]*10[[:space:]]*\)')" ]]; then
fail "R12 bcrypt-cost — $hit — must use cost factor 10"
fi
# Default constructor is acceptable (defaults to 10)
fi
# Default constructor BCryptPasswordEncoder() is acceptable (defaults to 10)
done <<< "$BCRYPT_HITS"
fi
+55 -205
View File
@@ -1,240 +1,90 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# post-edit-check.sh — PostToolUse Hook for Edit|Write
#
# 在 Claude 成功写入/修改文件之后触发。
# 读取磁盘上的实际文件内容进行规则检查(不依赖 tool_input 内容字段)。
#
# 检查规则:
# P01. package.json 无 caret (^)
# P02. DTO/Request/Response 不暴露 tenantId
# P03. TypeScript 无 'any' 类型
# P04. Controller 方法有 @PreAuthorize
# P05. SQL 迁移无 IF NOT EXISTS
# P06. 前端只用 VITE_APP_BASE_API
# P07. POM 无 ${latest.version} 占位符
# P08. 业务表 SQL 有审计列(轻量检查)
# P09. Java import 不违反 DAG
# P10. Vue Router v5 API(无已废弃 v4 模式)
#
# 通信协议:
# exit 0 + 无输出 = 通过
# exit 0 + JSON stdout = {"decision":"block","reason":"..."} 反馈给 Claude 修正
# (PostToolUse 无法撤销已写入的内容,但 block 会让 Claude 立即修复)
# ═══════════════════════════════════════════════════════════
# PostToolUse hook — reads the ACTUAL file on disk after Edit/Write completes.
# All content checks operate on the real file, not on tool_input JSON fields.
# Outputs a block decision JSON if issues are found.
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null || true)
[[ -z "$FILE_PATH" ]] && exit 0
[[ -f "$FILE_PATH" ]] || exit 0
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
# 拿不到路径或文件不存在,放行
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
FILE_PATH="${FILE_PATH#./}"
if [[ ! -f "$FILE_PATH" ]]; then
exit 0
fi
# 收集所有问题
ISSUES=""
add_issue() {
if [[ -n "$ISSUES" ]]; then
ISSUES+=" | "
fi
ISSUES+="$1"
}
# ══════════════════════════════════════════════════════════
# P01: package.json 无 caret (^)
# ══════════════════════════════════════════════════════════
# Rule no-caret: package.json must not use ^ versions
if [[ "$FILE_PATH" == *"package.json"* ]]; then
CARET_LINES=$(grep -n '"\^' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$CARET_LINES" ]]; then
FIRST_LINE=$(echo "$CARET_LINES" | head -1)
add_issue "[P01] $FILE_PATH has caret (^) version — must use tilde (~). First occurrence: $FIRST_LINE"
if grep -q '"^' "$FILE_PATH"; then
HITS=$(grep -n '"^' "$FILE_PATH" | head -3 | tr '\n' '; ')
ISSUES="${ISSUES}[no-caret] $FILE_PATH: caret (^) found — use tilde (~). Lines: ${HITS}"
fi
fi
# ══════════════════════════════════════════════════════════
# P02: DTO/Request/Response 不暴露 tenantId
# 覆盖 class 字段声明、record 参数、方法参数
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ (DTO|Dto|Request|Response|Req|Resp|Vo|VO)\.java$ ]]; then
# 排除 import 行和注释行
TENANT_HITS=$(grep -n 'tenantId\|tenant_id' "$FILE_PATH" 2>/dev/null \
# Rule dto-no-tenant-id: DTO must not expose tenantId
if [[ "$FILE_PATH" =~ (DTO|Dto|Request|Response)\.java$ ]]; then
if grep -qiE '(private|protected|public)[[:space:]]+[^[:space:]]+[[:space:]]+tenantId' "$FILE_PATH"; then
LINE=$(grep -niE '(private|protected|public)[[:space:]]+[^[:space:]]+[[:space:]]+tenantId' "$FILE_PATH" | head -1)
ISSUES="${ISSUES}[dto-no-tenant-id] $FILE_PATH: DTO exposes tenantId ($LINE). Remove it. "
fi
fi
# Rule no-any: TypeScript must not use 'any' type
if [[ "$FILE_PATH" =~ \.(ts|vue)$ ]]; then
ANY_LINE=$(grep -En '(:[[:space:]]*any[^a-zA-Z_]|as any[^a-zA-Z_]|<any>)' "$FILE_PATH" \
| grep -v '^\s*//' \
| grep -v '^.*import ' || true)
if [[ -n "$TENANT_HITS" ]]; then
FIRST_HIT=$(echo "$TENANT_HITS" | head -1)
add_issue "[P02] $FILE_PATH exposes tenant_id — DTOs must not contain tenantId. Line: $FIRST_HIT"
| grep -v '//.*:\s*any' \
| head -1 || true)
if [[ -n "$ANY_LINE" ]]; then
ISSUES="${ISSUES}[no-any] $FILE_PATH: 'any' type at: $ANY_LINE — use proper type. "
fi
fi
# ══════════════════════════════════════════════════════════
# P03: TypeScript 无 'any' 类型
# 匹配 ': any', 'as any', '<any>', 排除注释和类型声明文件
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.(ts|vue)$ && ! "$FILE_PATH" =~ \.d\.ts$ ]]; then
# 搜索 any 用法,排除注释行
ANY_LINES=$(grep -Pn '(?<!//.*)(\:\s*any\b|as\s+any\b|<any>)' "$FILE_PATH" 2>/dev/null \
| grep -v '^\s*//' \
| grep -v '// eslint-disable' \
| grep -v '// @ts-' || true)
if [[ -n "$ANY_LINES" ]]; then
FIRST_ANY=$(echo "$ANY_LINES" | head -1)
COUNT=$(echo "$ANY_LINES" | wc -l)
add_issue "[P03] $FILE_PATH has TypeScript 'any' type ($COUNT occurrences). First: $FIRST_ANY. Use proper types."
fi
fi
# ══════════════════════════════════════════════════════════
# P04: Controller 方法有 @PreAuthorize
# 在 @*Mapping 注解上方 5 行内必须有 @PreAuthorize
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ Controller\.java$ ]]; then
MAPPING_LINES=$(grep -n ' @(Get|Post|Put|Delete|Patch)Mapping' "$FILE_PATH" 2>/dev/null \
| cut -d: -f1 || true)
MISSING_AUTH=""
for LINE_NUM in $MAPPING_LINES; do
[[ -z "$LINE_NUM" ]] && continue
START=$((LINE_NUM - 5))
[[ $START -lt 1 ]] && START=1
CONTEXT=$(sed -n "${START},${LINE_NUM}p" "$FILE_PATH")
if ! echo "$CONTEXT" | grep -q ' @PreAuthorize'; then
if [[ -n "$MISSING_AUTH" ]]; then
MISSING_AUTH+=", "
fi
MISSING_AUTH+="line $LINE_NUM"
fi
done
if [[ -n "$MISSING_AUTH" ]]; then
add_issue "[P04] $FILE_PATH has @*Mapping without @PreAuthorize at: $MISSING_AUTH. Every endpoint must have @PreAuthorize(\"hasAuthority('resource:action')\")."
fi
fi
# ══════════════════════════════════════════════════════════
# P05: SQL 迁移无 IF NOT EXISTS
# ══════════════════════════════════════════════════════════
# Rule no-if-not-exists: Flyway SQL must not use IF NOT EXISTS
if [[ "$FILE_PATH" =~ \.sql$ ]]; then
IFNE_LINES=$(grep -in 'IF NOT EXISTS' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$IFNE_LINES" ]]; then
FIRST_IFNE=$(echo "$IFNE_LINES" | head -1)
add_issue "[P05] $FILE_PATH contains IF NOT EXISTS — Flyway migrations must not use this. Line: $FIRST_IFNE"
if grep -qi 'IF NOT EXISTS' "$FILE_PATH"; then
LINE=$(grep -ni 'IF NOT EXISTS' "$FILE_PATH" | head -1)
ISSUES="${ISSUES}[no-if-not-exists] $FILE_PATH: IF NOT EXISTS found ($LINE). "
fi
fi
# ══════════════════════════════════════════════════════════
# P06: 前端只用 VITE_APP_BASE_API
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.(ts|vue)$ || "$FILE_PATH" =~ \.env ]]; then
BAD_ENV=$(grep -Pn 'VITE_API_BASE_URL|VITE_BASE_API|VITE_APP_API[^_]' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$BAD_ENV" ]]; then
FIRST_BAD=$(echo "$BAD_ENV" | head -1)
add_issue "[P06] $FILE_PATH uses wrong env var name — must be VITE_APP_BASE_API only. Line: $FIRST_BAD"
# Rule wrong-env-var: must use VITE_APP_BASE_API
if [[ "$FILE_PATH" =~ \.(ts|vue)$ ]] || [[ "$FILE_PATH" =~ \.env ]]; then
if grep -qE 'VITE_API_BASE_URL|VITE_BASE_API' "$FILE_PATH"; then
LINE=$(grep -nE 'VITE_API_BASE_URL|VITE_BASE_API' "$FILE_PATH" | head -1)
ISSUES="${ISSUES}[wrong-env-var] $FILE_PATH: wrong env var ($LINE) — must be VITE_APP_BASE_API. "
fi
fi
# ══════════════════════════════════════════════════════════
# P07: POM 无 ${latest.version} 占位符
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ pom\.xml$ ]]; then
LATEST_HITS=$(grep -n '\${latest.version}' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$LATEST_HITS" ]]; then
FIRST_HIT=$(echo "$LATEST_HITS" | head -1)
add_issue "[P07] $FILE_PATH contains \${latest.version} placeholder — use hard-coded version number. Line: $FIRST_HIT"
fi
fi
# ══════════════════════════════════════════════════════════
# P08: 业务表 SQL 有审计列(轻量 — 只检查当前文件)
# 7 列: tenant_id, created_by, created_time, updated_by, updated_time, deleted, version
# Quartz 表 (QRTZ_) 豁免
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.sql$ && "$FILE_PATH" =~ migration/ ]]; then
AUDIT_COLS=(tenant_id created_by created_time updated_by updated_time deleted version)
# 找所有 CREATE TABLE 语句
TABLES=$(grep -oPI 'CREATE\s+TABLE\s+\K\S+' "$FILE_PATH" 2>/dev/null || true)
for TABLE in $TABLES; do
TABLE_LOWER=$(echo "$TABLE" | tr '[:upper:]' '[:lower:]')
# 跳过 Quartz 表
if [[ "$TABLE_LOWER" == *"qrtz_"* ]]; then
# Rule no-preauthorize: Controller @*Mapping must have @PreAuthorize
if [[ "$FILE_PATH" =~ Controller\.java$ ]]; then
MAPPING_LINES=$(grep -n '@\(Get\|Post\|Put\|Delete\|Patch\)Mapping' "$FILE_PATH" | cut -d: -f1 || true)
for LINE_NUM in $MAPPING_LINES; do
LINE_CONTENT=$(sed -n "${LINE_NUM}p" "$FILE_PATH")
if echo "$LINE_CONTENT" | grep -qiE '(login|refresh)'; then
continue
fi
TABLE_BLOCK=$(sed -n "/CREATE TABLE.*${TABLE}/I,/);/p" "$FILE_PATH" 2>/dev/null || true)
MISSING_COLS=""
for col in "${AUDIT_COLS[@]}"; do
if ! echo "$TABLE_BLOCK" | grep -qi "$col"; then
if [[ -n "$MISSING_COLS" ]]; then
MISSING_COLS+=", "
fi
MISSING_COLS+="$col"
fi
done
if [[ -n "$MISSING_COLS" ]]; then
add_issue "[P08] $FILE_PATH table $TABLE missing audit columns: $MISSING_COLS"
START=$((LINE_NUM > 5 ? LINE_NUM - 5 : 1))
CONTEXT=$(sed -n "${START},${LINE_NUM}p" "$FILE_PATH")
if echo "$CONTEXT" | grep -q '@PreAuthorize'; then
: # has @PreAuthorize, ok
else
ISSUES="${ISSUES}[no-preauthorize] $FILE_PATH:$LINE_NUM: @*Mapping without @PreAuthorize. "
break
fi
done
fi
# ══════════════════════════════════════════════════════════
# P09: Java import 不违反 DAG
# core 不能 import security/data/web
# security 不能 import web
# data 不能 import security/web
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.java$ ]]; then
if [[ "$FILE_PATH" =~ ^ljwx-platform-core/ ]]; then
BAD_IMPORT=$(grep -n 'import.*ljwx.*\(security\|data\|web\)\.' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$BAD_IMPORT" ]]; then
FIRST_BAD=$(echo "$BAD_IMPORT" | head -1)
add_issue "[P09] $FILE_PATH DAG violation: core module imports from security/data/web. Line: $FIRST_BAD"
fi
fi
if [[ "$FILE_PATH" =~ ^ljwx-platform-security/ ]]; then
BAD_IMPORT=$(grep -n 'import.*ljwx.*web\.' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$BAD_IMPORT" ]]; then
FIRST_BAD=$(echo "$BAD_IMPORT" | head -1)
add_issue "[P09] $FILE_PATH DAG violation: security module imports from web. Line: $FIRST_BAD"
fi
fi
if [[ "$FILE_PATH" =~ ^ljwx-platform-data/ ]]; then
BAD_IMPORT=$(grep -n 'import.*ljwx.*\(security\|web\)\.' "$FILE_PATH" 2>/dev/null || true)
if [[ -n "$BAD_IMPORT" ]]; then
FIRST_BAD=$(echo "$BAD_IMPORT" | head -1)
add_issue "[P09] $FILE_PATH DAG violation: data module imports from security/web. Line: $FIRST_BAD"
fi
# Rule no-latest-version: POM must not use ${latest.version}
if [[ "$FILE_PATH" =~ pom\.xml$ ]]; then
if grep -q '\${latest\.version}' "$FILE_PATH"; then
LINE=$(grep -n '\${latest\.version}' "$FILE_PATH" | head -1)
ISSUES="${ISSUES}[no-latest-version] $FILE_PATH: \${latest.version} found ($LINE). "
fi
fi
# ══════════════════════════════════════════════════════════
# P10: Vue Router v5 API(标记已废弃的 v4 用法)
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.(ts|vue)$ ]]; then
V4_DEPRECATED=(
'onBeforeRouteLeave'
'onBeforeRouteUpdate'
'useLink'
)
for pattern in "${V4_DEPRECATED[@]}"; do
V4_HIT=$(grep -n "$pattern" "$FILE_PATH" 2>/dev/null | grep -v '^\s*//' | head -1 || true)
if [[ -n "$V4_HIT" ]]; then
add_issue "[P10] $FILE_PATH uses deprecated Vue Router v4 API '$pattern' — use v5 equivalent. See https://router.vuejs.org/guide/migration/v4-to-v5. Line: $V4_HIT"
fi
done
fi
# ══════════════════════════════════════════════════════════
# 输出结果
# ══════════════════════════════════════════════════════════
if [[ -n "$ISSUES" ]]; then
# 转义 JSON 特殊字符
ESCAPED_ISSUES=$(echo "$ISSUES" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/ /g')
echo "{\"decision\":\"block\",\"reason\":\"$ESCAPED_ISSUES\"}"
exit 0
SAFE=$(printf '%s' "$ISSUES" | sed 's/"/\\"/g')
printf '{"decision":"block","reason":"%s"}\n' "$SAFE"
fi
# 无问题,静默通过
exit 0
+30 -132
View File
@@ -1,153 +1,51 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# pre-edit-guard.sh — PreToolUse Hook for Edit|Write
#
# 在 Claude 写入/修改文件之前触发。
# 只做路径级拦截,不解析 tool_input.content(避免依赖不稳定的 JSON 字段)。
#
# 拦截规则:
# G01. Flyway 迁移文件必须在正确的目录
# G02. DAG 守卫:模块目录级别的依赖方向
# G03. 禁止直接修改 Quartz DDL 表(QRTZ_ 表由 Quartz 自管理)
# G04. 禁止修改锁定文件(pnpm-lock.yaml 应由 pnpm install 生成)
# G05. 禁止修改 .claude/ 配置(防止 Claude 修改自己的规则)
# G06. 禁止在错误的 package 中创建前端文件
#
# 通信协议:
# exit 0 = 允许写入
# exit 2 = 阻止写入,stderr 内容反馈给 Claude
# ═══════════════════════════════════════════════════════════
# PreToolUse hook: path-level guard only.
# Checks file_path from stdin JSON. No content parsing.
# Exit 2 = block the tool call.
set -euo pipefail
INPUT=$(cat)
FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty" 2>/dev/null || true)
[[ -z "$FILE_PATH" ]] && exit 0
# 提取文件路径——这是 Edit/Write 工具唯一稳定的字段
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
# 如果拿不到路径(理论上不应发生),放行
if [[ -z "$FILE_PATH" ]]; then
exit 0
fi
# 标准化路径:去掉开头的 ./
FILE_PATH="${FILE_PATH#./}"
# ══════════════════════════════════════════════════════════
# G01: Flyway 迁移文件目录守卫
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.sql$ && "$FILE_PATH" =~ (V[0-9]|migration) ]]; then
EXPECTED_DIR="ljwx-platform-app/src/main/resources/db/migration/"
if [[ "$FILE_PATH" != ${EXPECTED_DIR}* ]]; then
echo "BLOCKED [G01]: SQL migration file must be in $EXPECTED_DIR" >&2
echo " Attempted path: $FILE_PATH" >&2
echo " Move the file to the correct directory." >&2
# Rule: Flyway migration must be in the correct directory
if [[ "$FILE_PATH" =~ \.sql$ ]] && [[ "$FILE_PATH" =~ migration ]]; then
if echo "$FILE_PATH" | grep -q "^ljwx-platform-app/src/main/resources/db/migration/"; then
: # correct path
else
echo "BLOCKED: SQL migration must be in ljwx-platform-app/src/main/resources/db/migration/" >&2
echo " Got: $FILE_PATH" >&2
exit 2
fi
fi
# ══════════════════════════════════════════════════════════
# G02: 模块 DAG 守卫
# DAG: core → {security, data} → web → app
#
# 具体约束:
# core 模块不能包含引用 security/data/web 的文件
# security 模块不能包含引用 web 的文件
# data 模块不能包含引用 security/web 的文件
# (app 模块可以依赖所有模块)
# ══════════════════════════════════════════════════════════
# core 模块不能创建属于其他模块的文件
if [[ "$FILE_PATH" =~ ^ljwx-platform-core/ ]]; then
if [[ "$FILE_PATH" =~ /(security|web|controller|filter|interceptor)/ ]]; then
echo "BLOCKED [G02]: core module must not contain security/web layer code." >&2
echo " File: $FILE_PATH" >&2
echo " DAG rule: core is the base module, it cannot reference security, data, or web." >&2
echo " Move this file to the appropriate module (ljwx-platform-security or ljwx-platform-web)." >&2
exit 2
fi
fi
# security 模块不能创建 web 层文件
if [[ "$FILE_PATH" =~ ^ljwx-platform-security/ ]]; then
if [[ "$FILE_PATH" =~ /(controller|web/controller|rest/)/ ]]; then
echo "BLOCKED [G02]: security module must not contain controller/REST code." >&2
echo " File: $FILE_PATH" >&2
echo " DAG rule: security → web, not the reverse. Controllers belong in ljwx-platform-web." >&2
exit 2
fi
fi
# data 模块不能创建 security 或 web 层文件
# Rule: data module must not reference security
if [[ "$FILE_PATH" =~ ^ljwx-platform-data/ ]]; then
if [[ "$FILE_PATH" =~ /(security|auth|jwt|controller|web|rest/)/ ]]; then
echo "BLOCKED [G02]: data module must not contain security or web layer code." >&2
echo " File: $FILE_PATH" >&2
echo " DAG rule: data module only depends on core. Security and web code belongs elsewhere." >&2
if echo "$FILE_PATH" | grep -qi security; then
echo "BLOCKED: data module must not reference security (DAG violation)" >&2
exit 2
fi
fi
# ══════════════════════════════════════════════════════════
# G03: 禁止手动创建/修改 Quartz DDL
# Quartz 表由 Quartz 引擎根据配置自动初始化或由官方 DDL 导入
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ \.sql$ ]]; then
FILE_LOWER=$(echo "$FILE_PATH" | tr '[:upper:]' '[:lower:]')
if [[ "$FILE_LOWER" =~ qrtz_ && ! "$FILE_LOWER" =~ migration/ ]]; then
echo "BLOCKED [G03]: Do not manually create Quartz DDL files outside of Flyway migrations." >&2
echo " File: $FILE_PATH" >&2
echo " Quartz table DDL should be in a Flyway migration file (e.g., V003__init_quartz.sql)." >&2
# Rule: security module must not contain web-layer code
if [[ "$FILE_PATH" =~ ^ljwx-platform-security/ ]]; then
if [[ "$FILE_PATH" =~ /web/ ]]; then
echo "BLOCKED: security module must not contain web-layer code (DAG violation)" >&2
exit 2
fi
fi
# ══════════════════════════════════════════════════════════
# G04: 禁止直接修改生成的锁文件
# ══════════════════════════════════════════════════════════
LOCKED_FILES=(
"pnpm-lock.yaml"
"package-lock.json"
"yarn.lock"
)
for locked in "${LOCKED_FILES[@]}"; do
if [[ "$FILE_PATH" == *"$locked" ]]; then
echo "BLOCKED [G04]: Do not directly edit $locked." >&2
echo " This file is auto-generated by the package manager." >&2
echo " Modify package.json instead, then run 'pnpm install'." >&2
# Rule: deny direct edits to .env secret files (.env.example is a template and is allowed)
BASENAME=$(basename "$FILE_PATH")
case "$BASENAME" in
.env.example)
: # template file — allowed
;;
.env|.env.*)
echo "BLOCKED: Direct edit of .env secret files not allowed." >&2
exit 2
fi
done
;;
esac
# ══════════════════════════════════════════════════════════
# G05: 禁止修改 .claude/ 配置
# 防止 Claude 在执行过程中修改自己的权限或 hook 配置
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ ^\.claude/ ]]; then
echo "BLOCKED [G05]: Do not modify .claude/ configuration files during code generation." >&2
echo " File: $FILE_PATH" >&2
echo " These files control permissions and hooks. They must only be changed by humans." >&2
exit 2
fi
# ══════════════════════════════════════════════════════════
# G06: 前端文件必须在正确的 package 中
# admin → packages/admin/
# mobile → packages/mobile/
# screen → packages/screen/
# shared → packages/shared/
# ══════════════════════════════════════════════════════════
if [[ "$FILE_PATH" =~ ^ljwx-platform-admin/ ]]; then
if [[ "$FILE_PATH" =~ \.(vue|ts|tsx)$ ]]; then
if [[ ! "$FILE_PATH" =~ ^ljwx-platform-admin/packages/ ]]; then
echo "BLOCKED [G06]: Frontend source files must be inside ljwx-platform-admin/packages/." >&2
echo " File: $FILE_PATH" >&2
echo " Place it under packages/admin/, packages/mobile/, packages/screen/, or packages/shared/." >&2
exit 2
fi
fi
fi
# ══════════════════════════════════════════════════════════
# 全部通过,允许写入
# ══════════════════════════════════════════════════════════
exit 0
+58 -134
View File
@@ -1,170 +1,94 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# stop-gate.sh — Stop Hook
#
# 每当 Claude 想停止响应时触发。
# 检查当前 Phase 的最低完成标准,未满足则 block 让 Claude 继续。
#
# 设计原则(来自审查修订):
# - 所有检查必须在 100ms 内完成
# - 不跑编译、不跑 pnpm、不跑任何耗时操作
# - 只做文件存在性检查 + 轻量 grep
# - 编译/类型检查交给外层 gate 脚本
# - 必须处理 stop_hook_active 防止无限循环
#
# 通信协议:
# exit 0 + 无输出 = 允许停止
# exit 0 + {"decision":"block","reason":""} = 阻止停止,reason 反馈给 Claude
#
# 关键安全阀:
# stop_hook_active=true 时必须放行,否则无限循环
# ═══════════════════════════════════════════════════════════
# Stop hook — lightweight gate, all checks finish in <100ms.
# NO compilation, NO pnpm, NO network. Only: file-exists, grep, git-diff.
# Compilation is handled by the gate scripts inside phase-execute.sh.
set -euo pipefail
INPUT=$(cat)
# ══════════════════════════════════════════════════════════
# 安全阀:防止无限循环
# 当 Stop Hook 已经 block 过一次后,Claude 再次尝试停止时
# stop_hook_active 会被设为 true。此时必须放行。
# ══════════════════════════════════════════════════════════
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null)
# Prevent infinite loop: Claude Code sets stop_hook_active=true on retry
STOP_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false' 2>/dev/null || echo "false")
if [[ "$STOP_ACTIVE" == "true" ]]; then
exit 0
fi
# ══════════════════════════════════════════════════════════
# 确定当前 Phase
# ══════════════════════════════════════════════════════════
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
# Resolve project root
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
cd "$PROJECT_DIR"
CURRENT_PHASE=$(grep -oP 'Current Phase:\s*\K\d+' CLAUDE.md 2>/dev/null || echo "")
# 无法确定 Phase → 放行(可能在非 Phase 执行模式下)
# Determine current phase number from CLAUDE.md
CURRENT_PHASE=$(sed -n 's/^Phase:[[:space:]]*\([0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1 || echo "")
if [[ -z "$CURRENT_PHASE" ]]; then
exit 0
fi
PHASE_PADDED=$(printf '%02d' "$CURRENT_PHASE")
PHASE_BRIEF="spec/phase/phase-${PHASE_PADDED}.md"
PHASE_BRIEF="spec/phase/phase-$(printf '%02d' "$CURRENT_PHASE").md"
[[ -f "$PHASE_BRIEF" ]] || exit 0
# Phase Brief 不存在 → 放行
if [[ ! -f "$PHASE_BRIEF" ]]; then
exit 0
fi
BLOCK_REASON=""
# ══════════════════════════════════════════════════════════
# 收集 block 原因
# ══════════════════════════════════════════════════════════
BLOCK_REASONS=""
add_block() {
if [[ -n "$BLOCK_REASONS" ]]; then
BLOCK_REASONS+=" | "
fi
BLOCK_REASONS+="$1"
}
# ══════════════════════════════════════════════════════════
# Check 1: PHASE_MANIFEST.txt 有当前 Phase 的段落
# 耗时:< 1ms
# ══════════════════════════════════════════════════════════
if [[ -f "PHASE_MANIFEST.txt" ]]; then
# 支持多种 marker 格式
if ! grep -qP "(## PHASE $CURRENT_PHASE\b|## Phase $CURRENT_PHASE\b)" PHASE_MANIFEST.txt 2>/dev/null; then
add_block "PHASE_MANIFEST.txt missing section for Phase $CURRENT_PHASE. Write it before stopping."
fi
else
add_block "PHASE_MANIFEST.txt not found. Create it and write the Phase $CURRENT_PHASE section."
fi
# ══════════════════════════════════════════════════════════
# Check 2: Phase Brief scope 中的具体文件是否都已创建
# 耗时:< 10ms(通常 scope 不超过 20 个条目)
# ══════════════════════════════════════════════════════════
# Check 1: Concrete scope files from Phase Brief YAML front-matter (<10ms)
IN_SCOPE=false
MISSING_FILES=()
SCOPE_BLOCK=$(sed -n '/^scope:/,/^[a-z]/p' "$PHASE_BRIEF" 2>/dev/null | grep '^\s*-' || true)
if [[ -n "$SCOPE_BLOCK" ]]; then
while IFS= read -r line; do
# 提取路径
ENTRY=$(echo "$line" | sed 's/^\s*-\s*//; s/^"//; s/"$//' | xargs)
[[ -z "$ENTRY" ]] && continue
# 跳过 glob 模式
if [[ "$ENTRY" == *"*"* || "$ENTRY" == *"**"* ]]; then
continue
while IFS= read -r line; do
if echo "$line" | grep -q '^scope:'; then
IN_SCOPE=true
continue
fi
if $IN_SCOPE; then
if echo "$line" | grep -qE '^[[:space:]]*-[[:space:]]+'; then
entry=$(echo "$line" | sed 's/^[[:space:]]*-[[:space:]]*//' | sed 's/^"//' | sed 's/"$//')
# Only check concrete paths (no glob wildcards)
if echo "$entry" | grep -qF '*'; then
: # has wildcard, skip
elif [[ "$(basename "$entry")" == .env* ]]; then
: # .env* files cannot be written by Claude Code (system sandbox); user creates manually
else
[[ -f "$entry" ]] || MISSING_FILES+=("$entry")
fi
else
IN_SCOPE=false
fi
# 检查文件是否存在
if [[ ! -f "$ENTRY" && ! -d "$ENTRY" ]]; then
MISSING_FILES+=("$ENTRY")
fi
done <<< "$SCOPE_BLOCK"
fi
fi
done < <(awk '/^---$/{n++;if(n==1){next};if(n==2){exit}} n==1{print}' "$PHASE_BRIEF")
if [[ ${#MISSING_FILES[@]} -gt 0 ]]; then
# 最多报告 5 个缺失文件,避免 reason 过长
SHOW_COUNT=5
TOTAL=${#MISSING_FILES[@]}
DISPLAY=("${MISSING_FILES[@]:0:$SHOW_COUNT}")
MISSING_LIST=$(printf '%s, ' "${DISPLAY[@]}")
MISSING_LIST="${MISSING_LIST%, }" # 去尾逗号
if [[ $TOTAL -gt $SHOW_COUNT ]]; then
MISSING_LIST+=" ... and $((TOTAL - SHOW_COUNT)) more"
fi
add_block "Missing $TOTAL expected files: $MISSING_LIST. Create them before stopping."
BLOCK_REASON="Missing expected scope files: $(printf '%s ' "${MISSING_FILES[@]}")"
fi
# ══════════════════════════════════════════════════════════
# Check 3: 变更文件中的明显违规(秒级 grep)
# 只检查最容易犯的 3 个错误,完整检查交给 gate-rules.sh
# 耗时:< 50ms
# ══════════════════════════════════════════════════════════
# Check 2: Quick grep on git-changed files (<50ms)
CHANGED=$(git diff --name-only HEAD 2>/dev/null || true)
if [[ -n "$CHANGED" ]]; then
# 3a: package.json 中的 caret
CARET_FILES=$(echo "$CHANGED" | grep 'package\.json$' || true)
for f in $CARET_FILES; do
[[ -f "$f" ]] || continue
if grep -q '"\^' "$f" 2>/dev/null; then
add_block "$f contains caret (^) versions. Replace all ^ with ~ before stopping."
break # 报告一次即可
# Caret in package.json
while IFS= read -r f; do
[[ -z "$f" ]] && continue
if [[ -f "$f" ]] && grep -q '"^' "$f" 2>/dev/null; then
BLOCK_REASON="${BLOCK_REASON}Caret (^) version found in $f. "
fi
done
done < <(echo "$CHANGED" | grep 'package\.json' || true)
# 3b: SQL 中的 IF NOT EXISTS
SQL_FILES=$(echo "$CHANGED" | grep '\.sql$' || true)
for f in $SQL_FILES; do
[[ -f "$f" ]] || continue
if grep -qi 'IF NOT EXISTS' "$f" 2>/dev/null; then
add_block "$f contains IF NOT EXISTS. Remove it — Flyway handles migration state."
break
# IF NOT EXISTS in SQL migration files
while IFS= read -r f; do
[[ -z "$f" ]] && continue
if [[ -f "$f" ]] && grep -qi 'IF NOT EXISTS' "$f" 2>/dev/null; then
BLOCK_REASON="${BLOCK_REASON}IF NOT EXISTS in $f (Flyway rule). "
fi
done
done < <(echo "$CHANGED" | grep '\.sql$' || true)
# 3c: DTO 中的 tenantId
DTO_FILES=$(echo "$CHANGED" | grep -P '(DTO|Dto|Request|Response)\.java$' || true)
for f in $DTO_FILES; do
[[ -f "$f" ]] || continue
if grep -q 'tenantId\|tenant_id' "$f" 2>/dev/null | grep -v 'import ' | grep -v '^\s*//' | head -1 > /dev/null 2>&1; then
if grep 'tenantId\|tenant_id' "$f" 2>/dev/null | grep -v 'import ' | grep -v '^\s*//' | head -1 > /dev/null 2>&1; then
add_block "$f is a DTO that exposes tenant_id. Remove the tenantId field."
break
fi
# Wrong env var in TS/Vue files
while IFS= read -r f; do
[[ -z "$f" ]] && continue
if [[ -f "$f" ]] && grep -qE 'VITE_API_BASE_URL|VITE_BASE_API' "$f" 2>/dev/null; then
BLOCK_REASON="${BLOCK_REASON}Wrong env var in $f (must be VITE_APP_BASE_API). "
fi
done
done < <(echo "$CHANGED" | grep -E '\.(ts|vue)$' || true)
fi
# ══════════════════════════════════════════════════════════
# 决定是否阻止停止
# ══════════════════════════════════════════════════════════
if [[ -n "$BLOCK_REASONS" ]]; then
# 转义 JSON
ESCAPED=$(echo "$BLOCK_REASONS" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/ /g')
echo "{\"decision\":\"block\",\"reason\":\"Cannot stop yet: $ESCAPED\"}"
if [[ -n "$BLOCK_REASON" ]]; then
SAFE=$(printf '%s' "$BLOCK_REASON" | sed 's/"/\\"/g')
printf '{"decision":"block","reason":"Stop gate: %s"}\n' "$SAFE"
exit 0
fi
# 全部通过,允许停止
exit 0
+172 -57
View File
@@ -1,24 +1,44 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# phase-execute.sh — 单 Phase 执行器(含重试、manifest 写入、追溯日志)
# 用法: bash scripts/phase-execute.sh <phase-number> [--skip-preflight]
#
# 流程: preflight → 解析 Phase Brief → Claude 生成 → gate-all → 重试(×3)
# → diff-review → code-reviewer → 写 PHASE_MANIFEST.txt → git commit
# ═══════════════════════════════════════════════════════════
set -euo pipefail
PHASE_NUM="${1:?Usage: phase-execute.sh <phase-number> [--skip-preflight]}"
SKIP_PREFLIGHT="${2:-}"
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
export CLAUDE_PROJECT_DIR="$PROJECT_ROOT"
PHASE_PADDED=$(printf '%02d' "$PHASE_NUM")
PHASE_BRIEF="spec/phase/phase-${PHASE_PADDED}.md"
LOG_DIR="logs/phase-${PHASE_PADDED}"
MAX_RETRIES=3
mkdir -p "$LOG_DIR"
PHASE_BRIEF="spec/phase/phase-$(printf '%02d' "$PHASE_NUM").md"
if [[ ! -f "$PHASE_BRIEF" ]]; then
echo "ERROR: $PHASE_BRIEF not found"
exit 1
fi
# ── Parse Phase Brief ─────────────────────────────────────
# ── Parse Phase Brief ──────────────────────────────────────
source scripts/lib/parse-phase-brief.sh "$PHASE_BRIEF"
echo "════════════════════════════════════════"
echo "Phase $PHASE_NUM: $PHASE_TITLE"
echo " Backend: $TARGET_BACKEND | Frontend: $TARGET_FRONTEND"
echo " Scope: ${PHASE_SCOPE[*]}"
echo " Scope files: ${#PHASE_SCOPE[@]}"
echo "════════════════════════════════════════"
echo ""
# ── Update CLAUDE.md current phase marker ─────────────────
# Preserves the title portion: "Phase: 0 (Skeleton) — ..." → "Phase: N ..."
sed -i.bak "s/^Phase: [0-9][0-9]*/Phase: $PHASE_NUM/" CLAUDE.md && rm -f CLAUDE.md.bak
# ── Step 0: Preflight ─────────────────────────────────────
if [[ "$SKIP_PREFLIGHT" != "--skip-preflight" ]]; then
echo "═══ STEP 0: Preflight Check ═══"
@@ -26,83 +46,178 @@ if [[ "$SKIP_PREFLIGHT" != "--skip-preflight" ]]; then
echo ""
fi
# ── Step 1a: Backend generation ───────────────────────────
if [[ "$TARGET_BACKEND" == "true" ]]; then
echo "═══ STEP 1a: Backend Builder — Phase $PHASE_NUM ═══"
claude -p "You are backend-builder. Execute Phase $PHASE_NUM.
Read CLAUDE.md, spec/01-constraints.md, and $PHASE_BRIEF.
Generate all backend files listed in the Phase Brief scope.
Follow spec/08-output-rules.md for output format.
After generating, run: bash scripts/gates/gate-compile.sh" \
--agent backend-builder \
--output-format json > "/tmp/ljwx-phase-${PHASE_NUM}-backend.json"
echo "Backend generation complete."
echo ""
fi
# ── Build generation prompt ───────────────────────────────
BUILD_PROMPT="You are executing Phase $PHASE_NUM of the LJWX Platform.
# ── Step 1b: Frontend generation ──────────────────────────
if [[ "$TARGET_FRONTEND" == "true" ]]; then
echo "═══ STEP 1b: Frontend Builder — Phase $PHASE_NUM ═══"
claude -p "You are frontend-builder. Execute Phase $PHASE_NUM.
Read CLAUDE.md, spec/06-frontend-config.md, and $PHASE_BRIEF.
Generate all frontend files listed in the Phase Brief scope.
Follow spec/08-output-rules.md for output format.
After generating, run: pnpm install && pnpm run type-check" \
--agent frontend-builder \
--output-format json > "/tmp/ljwx-phase-${PHASE_NUM}-frontend.json"
echo "Frontend generation complete."
echo ""
fi
Phase title: $PHASE_TITLE
Backend: $TARGET_BACKEND | Frontend: $TARGET_FRONTEND
# ── Step 2: Gate check ────────────────────────────────────
echo "═══ STEP 2: Gate Check ═══"
bash scripts/gates/gate-all.sh
GATE_EXIT=$?
if [[ "$GATE_EXIT" -ne 0 ]]; then
echo "GATE FAILED — aborting."
## Your task
1. Read CLAUDE.md for all hard rules
2. Read spec/phase/${PHASE_BRIEF} for scope, deliverables, and acceptance criteria
3. Read only the spec files referenced in the Phase Brief reading list
4. Generate EVERY file listed in the scope section — complete content, no placeholders
5. After generating backend files: run mvn clean compile -f pom.xml and fix all errors
6. After generating frontend files: run pnpm run type-check and fix all type errors
7. Write a PHASE_MANIFEST.txt entry in this exact format:
## PHASE $PHASE_NUM
Title: $PHASE_TITLE
Completed: \$(date -u +%Y-%m-%dT%H:%M:%SZ)
Files:
$(for f in "${PHASE_SCOPE[@]}"; do echo " - $f"; done)
Status: PENDING
8. Then run: bash scripts/gates/gate-all.sh $PHASE_NUM
9. If any gate FAILS, fix every FAIL and re-run gates until all PASS
10. Once gates PASS, update the Status in PHASE_MANIFEST.txt to: PASSED
11. Do NOT stop until all gates pass and PHASE_MANIFEST.txt is updated
Follow spec/08-output-rules.md for output format strictly."
# ── Step 1-2: Generation with retry ───────────────────────
RETRY=0
GATE_PASSED=false
while [[ $RETRY -lt $MAX_RETRIES ]]; do
ATTEMPT=$((RETRY + 1))
echo "═══ STEP 1: Generation — Attempt $ATTEMPT / $MAX_RETRIES ═══"
ATTEMPT_LOG="$LOG_DIR/attempt-${ATTEMPT}.log"
if [[ $RETRY -eq 0 ]]; then
PROMPT="$BUILD_PROMPT"
else
# Collect gate failures for retry prompt
GATE_OUTPUT=$(bash scripts/gates/gate-all.sh "$PHASE_NUM" 2>&1 || true)
PROMPT="Phase $PHASE_NUM gate check FAILED on attempt $RETRY. Fix ALL issues below:
$GATE_OUTPUT
After fixing:
1. Re-run: bash scripts/gates/gate-all.sh $PHASE_NUM
2. Ensure ALL gates PASS
3. Update PHASE_MANIFEST.txt Status to PASSED
4. Do not stop until gates pass."
fi
# Run claude in headless mode
# --agent flag is used to activate the agent definition from .claude/agents/
if [[ "$TARGET_BACKEND" == "true" && "$TARGET_FRONTEND" != "true" ]]; then
claude -p "$PROMPT" \
--agent backend-builder \
--max-turns 80 \
--output-format text \
2>&1 | tee "$ATTEMPT_LOG"
elif [[ "$TARGET_FRONTEND" == "true" && "$TARGET_BACKEND" != "true" ]]; then
claude -p "$PROMPT" \
--agent frontend-builder \
--max-turns 60 \
--output-format text \
2>&1 | tee "$ATTEMPT_LOG"
else
# Both or neither: use no specific agent (full Claude)
claude -p "$PROMPT" \
--max-turns 80 \
--output-format text \
2>&1 | tee "$ATTEMPT_LOG"
fi
echo ""
echo "═══ STEP 2: Gate Check — Attempt $ATTEMPT ═══"
if bash scripts/gates/gate-all.sh "$PHASE_NUM" 2>&1 | tee "$LOG_DIR/gate-attempt-${ATTEMPT}.log"; then
GATE_PASSED=true
echo "Gates PASSED on attempt $ATTEMPT."
break
else
echo "Gates FAILED on attempt $ATTEMPT."
RETRY=$((RETRY + 1))
if [[ $RETRY -ge $MAX_RETRIES ]]; then
echo "All $MAX_RETRIES attempts exhausted. Phase $PHASE_NUM FAILED."
echo "Phase $PHASE_NUM FAILED — $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> logs/failed.log
exit 1
fi
echo "Retrying with gate error feedback..."
echo ""
fi
done
if [[ "$GATE_PASSED" != "true" ]]; then
exit 1
fi
echo ""
# ── Step 3: Diff-driven hard rule scan ────────────────────
echo "═══ STEP 3: Diff Review (zero-token) ═══"
bash scripts/review/diff-review.sh "$PHASE_NUM"
REVIEW_EXIT=$?
# ── Step 3: Zero-token diff review ────────────────────────
echo "═══ STEP 3: Diff Review (zero-token rules scan) ═══"
bash scripts/review/diff-review.sh "$PHASE_NUM" 2>&1 | tee "$LOG_DIR/diff-review.log"
REVIEW_EXIT=${PIPESTATUS[0]}
if [[ "$REVIEW_EXIT" -ne 0 ]]; then
echo "DIFF REVIEW FAILED — aborting."
exit 1
fi
echo ""
# ── Step 4: Semantic review via Agent ─────────────────────
# ── Step 4: Semantic review via code-reviewer agent ───────
echo "═══ STEP 4: Semantic Review ═══"
CHANGED_FILES=$(git diff --name-only HEAD)
REVIEW_RESULT=$(claude -p "You are code-reviewer. Review ONLY these changed files:
CHANGED_FILES=$(git diff --name-only HEAD 2>/dev/null || true)
if [[ -n "$CHANGED_FILES" ]]; then
REVIEW_RESULT=$(claude -p "You are the code-reviewer. Review ONLY these changed files:
$CHANGED_FILES
Phase $PHASE_NUM scope allows: ${PHASE_SCOPE[*]}
Phase $PHASE_NUM allowed scope:
$(for f in "${PHASE_SCOPE[@]}"; do echo " $f"; done)
Read CLAUDE.md and spec/01-constraints.md.
For each file, check the review checklist.
Flag any file NOT in the allowed scope as CRITICAL.
Output each finding as:
[CRITICAL|WARNING|INFO] <file>:<line> — <rule-id> — <description> — <evidence>
Check all hard rules. Flag files outside scope as CRITICAL (scope-violation).
Output: [CRITICAL|WARNING|INFO] <file>:<line> — <rule-id> — <description>
End with: REVIEW PASSED or REVIEW FAILED: N critical, M warnings." \
--agent code-reviewer \
--output-format text)
--agent code-reviewer \
--max-turns 20 \
--output-format text \
2>&1 | tee "$LOG_DIR/semantic-review.log")
echo "$REVIEW_RESULT"
echo ""
echo "$REVIEW_RESULT"
echo ""
if echo "$REVIEW_RESULT" | grep -q "REVIEW FAILED"; then
exit 1
if echo "$REVIEW_RESULT" | grep -q "REVIEW FAILED"; then
echo "Semantic review found CRITICAL issues — aborting."
exit 1
fi
else
echo "No changed files to review."
fi
# ── Step 5: Commit ────────────────────────────────────────
echo "═══ STEP 5: Git Commit ═══"
# ── Step 5: Ensure PHASE_MANIFEST.txt is written ──────────
echo "═══ STEP 5: PHASE_MANIFEST.txt ═══"
MANIFEST="PHASE_MANIFEST.txt"
MARKER="## PHASE $PHASE_NUM"
if grep -q "$MARKER" "$MANIFEST" 2>/dev/null; then
# Update status to PASSED if not already
sed -i.bak "/$MARKER/,/^## PHASE/{s/Status: PENDING/Status: PASSED/}" "$MANIFEST" && rm -f "${MANIFEST}.bak"
echo " Manifest entry updated: $MARKER → PASSED"
else
# Write new entry
{
echo ""
echo "$MARKER"
echo "Title: $PHASE_TITLE"
echo "Completed: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Files:"
for f in "${PHASE_SCOPE[@]}"; do
echo " - $f"
done
echo "Status: PASSED"
} >> "$MANIFEST"
echo " Manifest entry written: $MARKER"
fi
# ── Step 6: Git commit ────────────────────────────────────
echo "═══ STEP 6: Git Commit ═══"
git add -A
git commit -m "feat(phase-${PHASE_NUM}): ${PHASE_TITLE} — auto-verified"
git commit -m "feat(phase-${PHASE_NUM}): ${PHASE_TITLE} — auto-verified" || true
echo ""
echo "════════════════════════════════════════"
echo "Phase $PHASE_NUM COMPLETE: $PHASE_TITLE"
echo "Logs: $LOG_DIR/"
echo "════════════════════════════════════════"
+22 -14
View File
@@ -16,17 +16,22 @@ if [[ "$TARGET_BACKEND" != "true" || "$TARGET_FRONTEND" != "true" ]]; then
fi
MAIN_BRANCH=$(git branch --show-current)
BE_BRANCH="phase-${PHASE_NUM}-backend"
FE_BRANCH="phase-${PHASE_NUM}-frontend"
BE_WORKTREE="/tmp/ljwx-worktree-be-${PHASE_NUM}"
FE_WORKTREE="/tmp/ljwx-worktree-fe-${PHASE_NUM}"
# Record current HEAD before any merges — used for precise squash
PRE_MERGE_HEAD=$(git rev-parse HEAD)
BE_WT="phase${PHASE_NUM}-be"
FE_WT="phase${PHASE_NUM}-fe"
BE_BRANCH="worktree-${BE_WT}"
FE_BRANCH="worktree-${FE_WT}"
BE_DIR=".claude/worktrees/${BE_WT}"
FE_DIR=".claude/worktrees/${FE_WT}"
# ── Cleanup on exit ───────────────────────────────────────
cleanup() {
echo ""
echo "Cleaning up worktrees..."
git worktree remove "$BE_WORKTREE" --force 2>/dev/null || true
git worktree remove "$FE_WORKTREE" --force 2>/dev/null || true
git worktree remove "$BE_DIR" --force 2>/dev/null || true
git worktree remove "$FE_DIR" --force 2>/dev/null || true
git branch -D "$BE_BRANCH" 2>/dev/null || true
git branch -D "$FE_BRANCH" 2>/dev/null || true
}
@@ -39,19 +44,20 @@ echo ""
# ── Step 1: Create isolated worktrees ─────────────────────
echo "═══ Creating worktrees ═══"
mkdir -p .claude/worktrees
git branch "$BE_BRANCH" HEAD
git branch "$FE_BRANCH" HEAD
git worktree add "$BE_WORKTREE" "$BE_BRANCH"
git worktree add "$FE_WORKTREE" "$FE_BRANCH"
echo " Backend worktree: $BE_WORKTREE ($BE_BRANCH)"
echo " Frontend worktree: $FE_WORKTREE ($FE_BRANCH)"
git worktree add "$BE_DIR" "$BE_BRANCH"
git worktree add "$FE_DIR" "$FE_BRANCH"
echo " Backend worktree: $BE_DIR ($BE_BRANCH)"
echo " Frontend worktree: $FE_DIR ($FE_BRANCH)"
echo ""
# ── Step 2: Parallel generation ───────────────────────────
echo "═══ Parallel Generation — Phase $PHASE_NUM ═══"
(
cd "$BE_WORKTREE"
cd "$BE_DIR"
echo "[BE] Starting backend generation..."
claude -p "You are backend-builder. Execute Phase $PHASE_NUM backend tasks.
Read CLAUDE.md, spec/01-constraints.md, spec/08-output-rules.md, and $PHASE_BRIEF.
@@ -67,7 +73,7 @@ Follow spec/08-output-rules.md for output format." \
PID_BE=$!
(
cd "$FE_WORKTREE"
cd "$FE_DIR"
echo "[FE] Starting frontend generation..."
claude -p "You are frontend-builder. Execute Phase $PHASE_NUM frontend tasks.
Read CLAUDE.md, spec/06-frontend-config.md, spec/08-output-rules.md, and $PHASE_BRIEF.
@@ -150,9 +156,11 @@ if echo "$REVIEW_RESULT" | grep -q "REVIEW FAILED"; then
exit 1
fi
# ── Step 7: Squash merge commits into one ─────────────────
# ── Step 7: Squash all merge commits into one ─────────────
# Use PRE_MERGE_HEAD (recorded before merges) for precise squash
# This avoids the fragile HEAD~N counting
echo "═══ Final Commit ═══"
git reset --soft HEAD~2
git reset --soft "$PRE_MERGE_HEAD"
git commit -m "feat(phase-${PHASE_NUM}): ${PHASE_TITLE} — parallel verified"
echo ""
+29 -22
View File
@@ -255,16 +255,16 @@ if [[ -f "$CONSTRAINTS" ]]; then
"deleted"
"version"
"HS256"
"30.*min\|1800"
"7.*day\|604800"
"30.*min|1800"
"7.*day|604800"
"QRTZ_"
"4096"
"50.*MB\|52428800"
"Caffeine\|caffeine"
"strict.*true\|strict: true"
"50.*MB|52428800"
"caffeine"
"strict: true"
)
for kw in "${CONSTRAINT_KEYS[@]}"; do
if echo "$CONSTRAINT_CONTENT" | grep -qiP "$kw"; then
if echo "$CONSTRAINT_CONTENT" | grep -qiE "$kw"; then
pass "D constraints contains: $kw"
else
fail "D constraints MISSING: $kw"
@@ -285,7 +285,7 @@ if [[ -f "$CONSTRAINTS" ]]; then
"Element Plus.*2\.13"
)
for vk in "${VERSION_KEYS[@]}"; do
if echo "$CONSTRAINT_CONTENT" | grep -qP "$vk"; then
if echo "$CONSTRAINT_CONTENT" | grep -qE "$vk"; then
pass "D version defined: $vk"
else
warn "D version not found in constraints: $vk"
@@ -331,12 +331,12 @@ for i in $(seq 0 19); do
done
# E3: targets 有 backend 和 frontend 布尔值
if echo "$FRONT_MATTER" | grep -qP 'backend:\s*(true|false)'; then
if echo "$FRONT_MATTER" | grep -qE 'backend:[[:space:]]*(true|false)'; then
pass "E3 $BRIEF targets.backend is boolean"
else
fail "E3 $BRIEF targets.backend must be true or false"
fi
if echo "$FRONT_MATTER" | grep -qP 'frontend:\s*(true|false)'; then
if echo "$FRONT_MATTER" | grep -qE 'frontend:[[:space:]]*(true|false)'; then
pass "E3 $BRIEF targets.frontend is boolean"
else
fail "E3 $BRIEF targets.frontend must be true or false"
@@ -442,8 +442,8 @@ if [[ -f "$SETTINGS" ]]; then
fi
done
# F6: hooks 配置存在
HOOK_EVENTS=("PreToolUse" "PostToolUse" "Stop" "SessionStart")
# F6: hooks 配置存在 (SessionStart is not a Claude Code hook type — skip)
HOOK_EVENTS=("PreToolUse" "PostToolUse" "Stop")
for event in "${HOOK_EVENTS[@]}"; do
if jq -e ".hooks.${event}" "$SETTINGS" > /dev/null 2>&1; then
pass "F6 hook configured: $event"
@@ -642,13 +642,13 @@ if [[ -f "spec/01-constraints.md" ]]; then
# 从 constraints 提取版本号
declare -A VERSIONS
VERSIONS=(
["spring_boot"]="$(grep -oP 'Spring Boot[^0-9]*\K[0-9]+\.[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["vue"]="$(grep -oP 'Vue[^0-9]*~?\K[0-9]+\.[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["vite"]="$(grep -oP 'Vite[^0-9]*~?\K[0-9]+\.[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["typescript"]="$(grep -oP 'TypeScript[^0-9]*~?\K[0-9]+\.[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["node"]="$(grep -oP 'Node[^0-9]*\K[0-9]+\.[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["pnpm"]="$(grep -oP 'pnpm[^0-9]*\K[0-9]+\.[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["postgres"]="$(grep -oP 'PostgreSQL[^0-9]*\K[0-9]+\.[0-9]+' spec/01-constraints.md 2>/dev/null | head -1)"
["spring_boot"]="$(sed -n 's/.*Spring Boot[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
["vue"]="$(sed -n 's/.*Vue[^0-9]*~*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
["vite"]="$(sed -n 's/.*Vite[^0-9]*~*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
["typescript"]="$(sed -n 's/.*TypeScript[^0-9]*~*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
["node"]="$(sed -n 's/.*Node[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
["pnpm"]="$(sed -n 's/.*pnpm[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
["postgres"]="$(sed -n 's/.*PostgreSQL[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' spec/01-constraints.md 2>/dev/null | head -1)"
)
for name in "${!VERSIONS[@]}"; do
@@ -668,13 +668,13 @@ if [[ -f "spec/01-constraints.md" ]]; then
case "$name" in
"spring_boot")
CLAUDE_VER=$(grep -oP 'Spring Boot[^0-9]*\K[0-9]+\.[0-9]+\.[0-9]+' CLAUDE.md 2>/dev/null | head -1)
CLAUDE_VER=$(sed -n 's/.*Spring Boot[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1)
;;
"vue")
CLAUDE_VER=$(grep -oP 'Vue[^0-9]*~?\K[0-9]+\.[0-9]+\.[0-9]+' CLAUDE.md 2>/dev/null | head -1)
CLAUDE_VER=$(sed -n 's/.*Vue[^0-9]*~*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1)
;;
"node")
CLAUDE_VER=$(grep -oP 'Node[^0-9]*\K[0-9]+\.[0-9]+\.[0-9]+' CLAUDE.md 2>/dev/null | head -1)
CLAUDE_VER=$(sed -n 's/.*Node[^0-9]*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' CLAUDE.md 2>/dev/null | head -1)
;;
*)
CLAUDE_VER=""
@@ -752,6 +752,7 @@ fi
if [[ -f "spec/INDEX.md" ]]; then
for sf in "${SPEC_FILES[@]}"; do
BASENAME=$(basename "$sf")
[[ "$BASENAME" == "INDEX.md" ]] && continue # index doesn't list itself
if grep -q "$BASENAME" spec/INDEX.md; then
pass "J3 INDEX.md lists: $BASENAME"
else
@@ -879,7 +880,13 @@ fi
if echo "test" | grep -P 'test' > /dev/null 2>&1; then
pass "K9 grep -P (PCRE) supported"
else
fail "K9 grep -P NOT supported — gate-rules.sh requires PCRE. Install GNU grep."
# Check if any gate/hook scripts still use grep -P (they should not on macOS)
PCRE_HITS=$(grep -rlF 'grep -P' scripts/gates/ scripts/hooks/ 2>/dev/null | tr '\n' ' ' || true)
if [[ -n "$PCRE_HITS" ]]; then
fail "K9 grep -P not supported, but scripts still use PCRE: $PCRE_HITS"
else
pass "K9 grep -P not available, but all scripts use ERE (macOS compatible)"
fi
fi
# K10: sed 基本功能
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════
# run-all.sh — 全量执行器(Phase 0–27,幂等,可中断续跑)
#
# 用法:
# bash scripts/run-all.sh # 执行所有未完成的 Phase
# bash scripts/run-all.sh --from 5 # 从 Phase 5 开始
# bash scripts/run-all.sh --only 3 # 只执行 Phase 3
# bash scripts/run-all.sh --dry-run # 显示执行计划但不执行
#
# 幂等性: 检查 PHASE_MANIFEST.txt 中已完成的 Phase,跳过重复执行
# 追溯性: 每个 Phase 的日志保存在 logs/phase-NN/
# ═══════════════════════════════════════════════════════════
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
export CLAUDE_PROJECT_DIR="$PROJECT_ROOT"
# ── Argument parsing ───────────────────────────────────────
FROM_PHASE=0
ONLY_PHASE=""
DRY_RUN=false
TOTAL_PHASES=28 # Phase 0–27
while [[ $# -gt 0 ]]; do
case "$1" in
--from) FROM_PHASE="$2"; shift 2 ;;
--only) ONLY_PHASE="$2"; shift 2 ;;
--dry-run) DRY_RUN=true; shift ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
# ── Setup ──────────────────────────────────────────────────
mkdir -p logs
MANIFEST="PHASE_MANIFEST.txt"
FAILED_LOG="logs/failed.log"
RUN_LOG="logs/run-all-$(date +%Y%m%d-%H%M%S).log"
: > "$FAILED_LOG"
# ── Helper functions ───────────────────────────────────────
is_completed() {
local phase="$1"
[[ -f "$MANIFEST" ]] && grep -q "^## PHASE $phase$" "$MANIFEST" && \
grep -A5 "^## PHASE $phase$" "$MANIFEST" | grep -q "Status: PASSED"
}
phase_exists() {
local phase="$1"
local padded
padded=$(printf '%02d' "$phase")
[[ -f "spec/phase/phase-${padded}.md" ]]
}
# ── Pre-flight ─────────────────────────────────────────────
echo "════════════════════════════════════════════════════"
echo " LJWX Platform — Full Generation Run"
echo " Start: $(date)"
echo " Phases: $FROM_PHASE – $((TOTAL_PHASES - 1))"
[[ -n "$ONLY_PHASE" ]] && echo " Mode: only Phase $ONLY_PHASE"
$DRY_RUN && echo " Mode: DRY RUN"
echo "════════════════════════════════════════════════════"
echo ""
if ! $DRY_RUN; then
echo "--- Preflight Check ---"
bash scripts/preflight/preflight-check.sh
echo ""
fi
# ── Determine phase list ───────────────────────────────────
if [[ -n "$ONLY_PHASE" ]]; then
PHASE_LIST=("$ONLY_PHASE")
else
PHASE_LIST=()
for i in $(seq "$FROM_PHASE" $((TOTAL_PHASES - 1))); do
if phase_exists "$i"; then
PHASE_LIST+=("$i")
fi
done
fi
# ── Execution plan ─────────────────────────────────────────
PASSED=0
FAILED=0
SKIPPED=0
echo "Execution plan (${#PHASE_LIST[@]} phases):"
for phase in "${PHASE_LIST[@]}"; do
padded=$(printf '%02d' "$phase")
brief="spec/phase/phase-${padded}.md"
title=$(grep -m1 '^title:' "$brief" 2>/dev/null | sed 's/title:[[:space:]]*//' | tr -d '"' || echo "?")
if is_completed "$phase"; then
echo " Phase $phase — $title [ALREADY DONE — will skip]"
else
echo " Phase $phase — $title"
fi
done
echo ""
$DRY_RUN && echo "Dry run complete." && exit 0
# ── Main execution loop ────────────────────────────────────
for phase in "${PHASE_LIST[@]}"; do
padded=$(printf '%02d' "$phase")
echo ""
echo ">>>>>>>>>> Phase $phase <<<<<<<<<<"
# Idempotency check
if is_completed "$phase"; then
echo " Phase $phase already completed — skipping."
SKIPPED=$((SKIPPED + 1))
PASSED=$((PASSED + 1))
continue
fi
# Detect parallel-eligible phase (both backend and frontend)
PHASE_BRIEF="spec/phase/phase-${padded}.md"
YAML_BLOCK=$(awk '/^---$/{n++;if(n==1){next};if(n==2){exit}} n==1{print}' "$PHASE_BRIEF")
T_BE=$(echo "$YAML_BLOCK" | grep 'backend:' | awk '{print $2}' | head -1)
T_FE=$(echo "$YAML_BLOCK" | grep 'frontend:' | awk '{print $2}' | head -1)
EXECUTOR="scripts/phase-execute.sh"
if [[ "${T_BE:-false}" == "true" && "${T_FE:-false}" == "true" ]]; then
EXECUTOR="scripts/phase-parallel.sh"
fi
PHASE_LOG="$LOG_DIR_BASE/phase-${padded}/run.log" 2>/dev/null || true
LOG_BASE="logs/phase-${padded}"
mkdir -p "$LOG_BASE"
# Execute
if bash "$EXECUTOR" "$phase" "--skip-preflight" 2>&1 | tee "$LOG_BASE/run.log"; then
echo "Phase $phase — PASSED"
PASSED=$((PASSED + 1))
else
EXIT_CODE=$?
echo "Phase $phase — FAILED (exit $EXIT_CODE)"
echo "Phase $phase FAILED — $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$FAILED_LOG"
FAILED=$((FAILED + 1))
echo "Continuing to next phase..."
fi
done
# ── Summary ────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════"
echo " LJWX Platform — Generation Complete"
echo " End: $(date)"
echo " Total: ${#PHASE_LIST[@]} | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED"
if [[ $FAILED -gt 0 ]]; then
echo ""
echo " FAILED phases:"
cat "$FAILED_LOG"
fi
echo " Logs: logs/"
echo "════════════════════════════════════════════════════"
exit "$FAILED"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Export OpenAPI spec from a running application instance.
# Usage: bash scripts/tools/export-openapi.sh [base-url]
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
OUTPUT_DIR="docs/contracts"
OUTPUT="${OUTPUT_DIR}/openapi.json"
mkdir -p "$OUTPUT_DIR"
echo "[export-openapi] Fetching spec from ${BASE_URL}/v3/api-docs"
for i in $(seq 1 10); do
if curl -sf "${BASE_URL}/v3/api-docs" -o "$OUTPUT"; then
echo "[export-openapi] Saved to ${OUTPUT}"
exit 0
fi
echo " Attempt $i failed, retrying in 2s..."
sleep 2
done
echo "[export-openapi] FAIL: Could not fetch OpenAPI spec from ${BASE_URL}/v3/api-docs"
exit 1