diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ee1162 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +# PR 门禁。只保两件事(详见 Makefile 的 ci-syntax): +# G1 生成的代码能通过类型检查 +# G2 生成的代码没有重复声明 +# +# 本仓**没有 lint job**,是有意为之,不是漏配: +# · `npm run tslint` 当前 4017 条报错(生成侧 3943 + 手写侧 74),全部是风格问题, +# 无一影响运行。决定性证据:同一批代码 `tsc --noEmit` exit 0、输出 0 行。 +# · 加一个恒红的非阻断 job,只会在每个 PR 上留个红叉、制造「CI 坏了」的误解, +# 反而降低门禁的可信度。 +# · 生成代码的正确性由 ci-syntax 的 `tsc --noEmit` 承担;风格问题不影响 SDK 能否使用。 +# 要恢复 lint 门禁,前提是先把那 4017 条清零(改模板或改 tslint 配置),不是直接加 job。 +# +# 分支保护只需把 `ci-gate` 配成 required check,其余 job 增删都不影响它。 +# +# 原 workflow.yml 的 node 10/12/14 矩阵已去掉,不再在 PR 上跑: +# 那是「声明的下界还能不能装」的问题,与本次改动是否正确无关, +# 不该让每个 PR 都为它等三份构建。要验的时候手工跑一次即可。 + +on: + pull_request: + branches: [master] + push: + branches: [master] + +permissions: + contents: read + +jobs: + ci-syntax: + name: ci-syntax + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22' + # 用 npm install 而非 npm ci:仓里的 package-lock.json 是 lockfileVersion 1 + # (npm 6 时代格式),且其 root version 停在 0.2.25、package.json 已是 0.2.26, + # 本就不同步。npm ci 要求 lock 与 package.json 严格一致,拿它当门禁第一步, + # 等于让一个与本次改动无关的陈旧文件决定 PR 红绿。 + # npm install 是原 workflow.yml 一直在用、且已在 node 22/24 上实测通过的路径。 + - run: npm install + - run: make ci-syntax + + test: + name: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '22' + - run: npm install + - run: make test-cov + # codecov 是可观测性,不是门禁:它挂了不该把 PR 拦死。 + # 原 workflow.yml 是 fail_ci_if_error: true,等于把第三方服务的可用性 + # 接进了合并条件,这里摘掉,并再加一层 continue-on-error。 + - name: Upload coverage + continue-on-error: true + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + + ci-gate: + name: ci-gate + needs: [ci-syntax, test] + if: always() + runs-on: ubuntu-latest + steps: + - name: Aggregate + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" \ + || "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "ci-gate: 有 job 失败或被取消" + exit 1 + fi + echo "ci-gate: 全部通过" diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml deleted file mode 100644 index fddf2dc..0000000 --- a/.github/workflows/workflow.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Running Code Coverage - -on: [push, pull_request] - -jobs: - build: - - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [10.x, 12.x, 14.x] - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - with: - fetch-depth: 2 - - - name: Set up Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v1 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies - run: npm install - - - name: Run the tests - run: npm run coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: true diff --git a/Makefile b/Makefile index 564a693..56cf654 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,18 @@ test: test-cov: npm run coverage +# CI 门禁:生成代码必须能通过类型检查、且无重复声明。 +# G1 `tsc --noEmit` 覆盖 tsconfig include 的 src/**/*(含 services/ 下全部生成代码)。 +# 这个仓此前完全没有类型检查入口——`npm run build` 会写 lib/,不适合放进门禁。 +# G2 `tsc` 对同一文件内重复的 `export interface Foo` 走 declaration merging, +# 静默通过、不报错,所以 G1 抓不到重复声明,必须另加显式检测。 +# (对照 go:重复声明是编译错误,G1 天然覆盖,故 go 侧无需此闸。) +# 代码风格(tslint / prettier)不进门禁,理由见 .github/workflows/ci.yml。 +.PHONY: ci-syntax +ci-syntax: + npx tsc --noEmit + @bash $(CURDIR)/scripts/dup-check.sh ts src + lint: npm run tslint npm run prettier:check diff --git a/scripts/dup-check.sh b/scripts/dup-check.sh new file mode 100755 index 0000000..bb8774f --- /dev/null +++ b/scripts/dup-check.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# G2 重复闸 —— php / js 生成代码重复顶层声明检测。 +# +# 背景(实测,非推断): +# · php:`php -l` 能抓重复 `use`(编译期符号表冲突),但对同一文件内两个 +# `class Foo` 一律 exit 0 —— 那是运行期错误,lint 阶段看不到。 +# · js/ts:`tsc --noEmit` 对重复的 `export interface Foo` 走 declaration merging, +# 静默通过;只有同一 interface 内重复成员才报 TS2300。 +# 故两者都需要显式的重复声明检测。 +# +# 用法:dup-check.sh php <目录> +# dup-check.sh ts <目录> +# 退出码:0 = 无重复;1 = 发现重复(逐条打印 文件:声明名 出现次数) + +set -uo pipefail + +lang="${1:?用法: dup-check.sh <目录>}" +root="${2:?用法: dup-check.sh <目录>}" + +case "$lang" in + php) + ext='*.php' + # 顶层 class / interface / trait 声明;兼容 final / abstract 前缀 + pattern='^[[:space:]]*(final[[:space:]]+|abstract[[:space:]]+)?(class|interface|trait)[[:space:]]+[A-Za-z0-9_]+' + ;; + ts) + ext='*.ts' + # 导出的顶层 interface / class / type / enum 声明 + pattern='^export[[:space:]]+(interface|class|type|enum)[[:space:]]+[A-Za-z0-9_]+' + ;; + *) + echo "不支持的语言: $lang(仅 php / ts)" >&2 + exit 2 + ;; +esac + +findings=0 +scanned=0 + +while IFS= read -r f; do + scanned=$((scanned + 1)) + # 抽出声明名(每行最后一个标识符),排序后取重复项 + dups=$(grep -oE "$pattern" "$f" 2>/dev/null | awk '{print $NF}' | sort | uniq -d) + if [ -n "$dups" ]; then + while IFS= read -r name; do + [ -z "$name" ] && continue + n=$(grep -cE "$pattern[[:space:]]*\$|${pattern}([[:space:]]|\{)" "$f" 2>/dev/null || true) + lines=$(grep -nE "$pattern" "$f" | awk -v want="$name" '$NF==want || $0 ~ ("[[:space:]]"want"([[:space:]]|\\{|$)") {split($0,a,":"); printf "%s,", a[1]}') + echo " ${f}:${lines%,} 重复声明 '${name}'" + findings=$((findings + 1)) + done <<< "$dups" + fi +done < <(find "$root" -name "$ext" -type f) + +if [ "$findings" -gt 0 ]; then + echo "G2 重复闸失败:发现 $findings 处(扫描 $scanned 个文件)" + exit 1 +fi + +echo "G2 重复闸通过:$scanned 个文件无重复顶层声明" +exit 0