chore(repo): 仓侧治理门禁 — ECR/migration 身份唯一
将编号与 schema 完整性留在 Repository Governance,不扩 ESS/Loop;TRACEABILITY 锚定 Next ECR=041、Max Migration=000050。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## 2026-08-13
|
## 2026-08-13
|
||||||
|
|
||||||
|
- **仓侧治理最小补丁(非 ESS 内核):** `scripts/repo-governance-check.py`(migration 号唯一 · ECR identity 唯一);TRACEABILITY 增加 **Next ECR=`ECR-041`** · **Max Migration=`000050`**;`PROJECT_RULES` 写入预占/同号双义禁止/migration 分配流程;历史分叉 H1 固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`
|
||||||
- **Migration 撞号修复:** `000015`–`000023` 双文件冲突已重编号为唯一序列至 `000050`;说明见 `docs/MIGRATION_RENUMBER.md`
|
- **Migration 撞号修复:** `000015`–`000023` 双文件冲突已重编号为唯一序列至 `000050`;说明见 `docs/MIGRATION_RENUMBER.md`
|
||||||
- **Merge:** `merge/ops-local-into-main` — 合并本地 Ops 目录扩展(ECR-013A…040)与远程 main(ECR-009…016)
|
- **Merge:** `merge/ops-local-into-main` — 合并本地 Ops 目录扩展(ECR-013A…040)与远程 main(ECR-009…016)
|
||||||
注意:分叉期间 ECR-012/014/015/016 编号在两边含义不同,以 TRACEABILITY 双列说明为准
|
注意:分叉期间 ECR-012/014/015/016 编号在两边含义不同,以 TRACEABILITY 双列说明为准
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# ECR-012
|
# ECR-012-star
|
||||||
|
|
||||||
|
**Canonical-ID:** `ECR-012-star`
|
||||||
**Title:** 星座对齐收口(星盘 · 运势 outlook · 合盘)
|
**Title:** 星座对齐收口(星盘 · 运势 outlook · 合盘)
|
||||||
**Status:** Approved
|
**Status:** Approved
|
||||||
**Date:** 2026-08-07
|
**Date:** 2026-08-07
|
||||||
**Approved:** 2026-08-07(Human)
|
**Approved:** 2026-08-07(Human)
|
||||||
**Change Level: L2**(可见体验/契约文档对齐;栈与分层不变)
|
**Change Level: L2**(可见体验/契约文档对齐;栈与分层不变)
|
||||||
|
**Note:** 历史分叉曾误用 `# ECR-012`;仓库内 ECR identity 必须全局唯一,本文件永久身份为 `ECR-012-star`(不得再解释为 ECR-012)。
|
||||||
|
|
||||||
## Change
|
## Change
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# ECR-014
|
# ECR-014-plan
|
||||||
|
|
||||||
|
**Canonical-ID:** `ECR-014-plan`
|
||||||
|
**Note:** 历史分叉曾误用 `# ECR-014`;永久身份为 `ECR-014-plan`(不得再解释为 ECR-014)。
|
||||||
|
|
||||||
|
|
||||||
**Title:** MembershipPlan(会员套餐配置薄切片)
|
**Title:** MembershipPlan(会员套餐配置薄切片)
|
||||||
**Status:** **Closed**
|
**Status:** **Closed**
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
# ECR-015
|
# ECR-015-code
|
||||||
|
|
||||||
|
**Canonical-ID:** `ECR-015-code`
|
||||||
|
**Note:** 历史分叉曾误用 `# ECR-015`;永久身份为 `ECR-015-code`(不得再解释为 ECR-015)。
|
||||||
|
|
||||||
**Title:** RedemptionCode(兑换码薄切片)
|
**Title:** RedemptionCode(兑换码薄切片)
|
||||||
**Status:** **Closed**
|
**Status:** **Closed**
|
||||||
**Closed:** 2026-08-07(Loop continuous)
|
**Closed:** 2026-08-07(Loop continuous)
|
||||||
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-014 Closed
|
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-014-plan Closed
|
||||||
**Change Level:** L2
|
**Change Level:** L2
|
||||||
|
|
||||||
## Change
|
## Change
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
# ECR-016
|
# ECR-016-insight
|
||||||
|
|
||||||
|
**Canonical-ID:** `ECR-016-insight`
|
||||||
|
**Note:** 历史分叉曾误用 `# ECR-016`;永久身份为 `ECR-016-insight`(不得再解释为 ECR-016)。
|
||||||
|
|
||||||
**Title:** UserIntelligence(用户洞察只读薄切片)
|
**Title:** UserIntelligence(用户洞察只读薄切片)
|
||||||
**Status:** **Closed**
|
**Status:** **Closed**
|
||||||
**Closed:** 2026-08-07(Loop continuous)
|
**Closed:** 2026-08-07(Loop continuous)
|
||||||
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-015 Closed
|
**Parent:** WAVE0-FROZEN · **Predecessor:** ECR-015-code Closed
|
||||||
**Change Level:** L2
|
**Change Level:** L2
|
||||||
|
|
||||||
## Change
|
## Change
|
||||||
|
|||||||
@@ -52,10 +52,11 @@
|
|||||||
- **ESS Intake G0:** **FROZEN** · Validation **Active**(`$ESS_ROOT/ESS_INTAKE_0.2_FREEZE.md`)
|
- **ESS Intake G0:** **FROZEN** · Validation **Active**(`$ESS_ROOT/ESS_INTAKE_0.2_FREEZE.md`)
|
||||||
- **本仓:** `ess_intake: strict` · Retro FAIL · 单 ECR Context
|
- **本仓:** `ess_intake: strict` · Retro FAIL · 单 ECR Context
|
||||||
- **Ops foundation:** `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md`
|
- **Ops foundation:** `docs/WAVE0/` · `.ai/domain/boundary-rules.md` · `glossary.yaml` · Loop: `docs/WAVE0/LOOP_AUTHORIZATION.md`
|
||||||
- ECR: main 线 ECR-006–016 Closed;Ops 扩展 ECR-013A/B · 017–040(分叉编号见 TRACEABILITY)
|
- ECR: main 线 ECR-006–016 Closed;Ops 扩展 ECR-013A/B · 017–040;分叉已固定为 `ECR-012-star` / `014-plan` / `015-code` / `016-insight`(见 TRACEABILITY)
|
||||||
|
- **Next ECR / Max Migration:** 以 `docs/TRACEABILITY.md` 顶部锚点为准(仓侧治理,非 Loop)
|
||||||
- EXP: (无)
|
- EXP: (无)
|
||||||
- STATE: `docs/STATE/`(ECR-016 main · 以及 Ops 目录 Closed 条目)
|
- STATE: `docs/STATE/`(ECR-016 main · 以及 Ops 目录 Closed 条目)
|
||||||
- TRACEABILITY: `docs/TRACEABILITY.md`
|
- TRACEABILITY: `docs/TRACEABILITY.md` · 门禁 `scripts/repo-governance-check.py`
|
||||||
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
- ADR: `.ai/adr/0007-ess-ai-dual-track.md`
|
||||||
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**)
|
- Product status: `.ai/product/p1-status.md`(**P1 Complete**)· `.ai/product/p2-status.md`(**P2 Complete**)
|
||||||
- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile`
|
- Active Spec: `account-auth` · `explore-test` · `home` · `star-profile` · `life-rhythm` · `input-compliance` · `ops-system` · `profile`
|
||||||
|
|||||||
+46
-3
@@ -14,6 +14,46 @@ ess_intake: strict
|
|||||||
- **Execution Context:** 同时只绑定 **一个** Active ECR(或一个 L1 TASK);新主题 = OUT OF SCOPE,禁止顺手改码
|
- **Execution Context:** 同时只绑定 **一个** Active ECR(或一个 L1 TASK);新主题 = OUT OF SCOPE,禁止顺手改码
|
||||||
- **retro-ecr:** 本仓 **FAIL**(须 `Retrospective: true` + Original drift + Scope freeze + Process debt;不得伪装正序路径)。通用 ESS 默认为 WARN;脚本强制见后续门禁 PR,**规则即时约束 Agent**
|
- **retro-ecr:** 本仓 **FAIL**(须 `Retrospective: true` + Original drift + Scope freeze + Process debt;不得伪装正序路径)。通用 ESS 默认为 WARN;脚本强制见后续门禁 PR,**规则即时约束 Agent**
|
||||||
- **Loop / Adapter:** 不在本仓维护;已授权执行不扩展 ESS Intake 职责
|
- **Loop / Adapter:** 不在本仓维护;已授权执行不扩展 ESS Intake 职责
|
||||||
|
- **ESS 0.2.0-intake / Loop / Adapter:** 本仓视为 **FROZEN**;仓库治理问题不塞进 ESS 内核或 Loop
|
||||||
|
|
||||||
|
## Repository governance(仓侧 · 非 Loop)
|
||||||
|
|
||||||
|
独立于 ESS Loop。Merge / 开 ECR / 写 migration 前执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/repo-governance-check.py
|
||||||
|
python scripts/repo-governance-check.py --print-anchors
|
||||||
|
```
|
||||||
|
|
||||||
|
权威锚点:`docs/TRACEABILITY.md` 顶部 **Next ECR** · **Max Migration**。
|
||||||
|
|
||||||
|
### ECR identity(硬规则)
|
||||||
|
|
||||||
|
- **`ECR-NNN`(及带字母/后缀的完整 identity)在仓库历史内全局唯一。**
|
||||||
|
即使已 Closed,也**禁止**把同一编号重新解释成另一件事。
|
||||||
|
- **禁止同号双义**(例如两个文件都自称 `# ECR-012`)。
|
||||||
|
- 分叉只允许:**`ECR-012-star`** · **`ECR-013A`** · **`ECR-013B`** 等形式;不得再开第二个裸 `ECR-012`。
|
||||||
|
- **预占:** 开新 ECR 前读 TRACEABILITY **Next ECR**;占用后立即更新该锚点(或跑 `--print-anchors` 核对后写回)。
|
||||||
|
- **Canonical-ID:** 仅作历史分叉过渡字段;长期以唯一 H1 / identity 为准,不发展成并行别名体系。
|
||||||
|
- Agent **Single ECR Context** 绑定的必须是上述唯一 identity(查 TRACEABILITY,勿猜)。
|
||||||
|
|
||||||
|
### Migration stem(硬规则)
|
||||||
|
|
||||||
|
- `apps/api/migrations/` 下 **6 位版本号唯一**(禁止 `000015_foo` 与 `000015_bar` 并存)。
|
||||||
|
- **禁止**凭记忆递增。Create 流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Read repo state → Max Migration (TRACEABILITY / --print-anchors)
|
||||||
|
→ allocate next stem → write file → repo-governance-check PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
- 并行 branch + merge 是已知攻击面;合入含 migration 的变更必须过 uniqueness 检查。
|
||||||
|
- 已发生撞号的修复说明:`docs/MIGRATION_RENUMBER.md`(playbook 保留,本阶段不扩展)。
|
||||||
|
|
||||||
|
### L0 / L1 UI(轻规则 · 不过度治理)
|
||||||
|
|
||||||
|
无 API / DB / Domain 新概念 / 权限 / 新业务状态、且不改既定 Acceptance → 可 L0/L1,**不必**为大 ECR。
|
||||||
|
若改变**用户可见主路径**语义 → 对照相关 Active Feature Spec 做 consistency check(不必因此自动升 L2)。
|
||||||
|
|
||||||
## Domain invariants
|
## Domain invariants
|
||||||
|
|
||||||
@@ -42,10 +82,13 @@ ess_intake: strict
|
|||||||
- 提交 `config.local.yaml`、`.env`、密钥
|
- 提交 `config.local.yaml`、`.env`、密钥
|
||||||
- 同一 review turn Approve + Modify
|
- 同一 review turn Approve + Modify
|
||||||
- 在已绑定 ECR 的 Execution Context 内「顺便」做第二功能
|
- 在已绑定 ECR 的 Execution Context 内「顺便」做第二功能
|
||||||
|
- 同号双义 ECR;凭记忆分配 migration 版本号
|
||||||
|
- Retro 批量追认已 merge 行为;把仓库治理机制扩进 ESS Loop
|
||||||
|
|
||||||
## Completion
|
## Completion
|
||||||
|
|
||||||
1. 开场 ESS Mode 块(G0)通过且 `Action: CONTINUE`(或明确 L0)
|
1. 开场 ESS Mode 块(G0)通过且 `Action: CONTINUE`(或明确 L0)
|
||||||
2. ESS gate(有 ECR 时):`python scripts/ess-validate.py --phase <phase> --ecr ECR-xxx`
|
2. 仓侧(改 ECR / migration / merge 前):`python scripts/repo-governance-check.py` PASS
|
||||||
3. 本仓 DoD:`.ai/definition-of-done.md` + `.ai/review.md`
|
3. ESS gate(有 ECR 时):`python scripts/ess-validate.py --phase <phase> --ecr ECR-xxx`
|
||||||
4. 有行为变更时更新/尊重 `.ai/product/p1-status.md` 标签
|
4. 本仓 DoD:`.ai/definition-of-done.md` + `.ai/review.md`
|
||||||
|
5. 有行为变更时更新/尊重 `.ai/product/p1-status.md` 标签
|
||||||
|
|||||||
+20
-9
@@ -1,5 +1,16 @@
|
|||||||
# TRACEABILITY — 愈心谷
|
# TRACEABILITY — 愈心谷
|
||||||
|
|
||||||
|
## Repository anchors(仓侧治理 · 非 ESS Loop)
|
||||||
|
|
||||||
|
| Anchor | Value | Rule |
|
||||||
|
|--------|-------|------|
|
||||||
|
| **Next ECR** | `ECR-041` | 开新 ECR 前占用此号;写入 `docs/ECR/` 后把本行改为下一空闲裸号(或 `python scripts/repo-governance-check.py --print-anchors`) |
|
||||||
|
| **Max Migration** | `000050` | 新建 migration 必须读仓内实际 max 后 +1;禁止凭记忆;合入前 `repo-governance-check` PASS |
|
||||||
|
|
||||||
|
- ECR identity **全局唯一**(含已 Closed);禁止同号双义。分叉只用 `ECR-NNN-suffix` / `ECR-NNNA`。
|
||||||
|
- Migration **6 位版本号唯一**。撞号修复见 `docs/MIGRATION_RENUMBER.md`。
|
||||||
|
- 门禁:`python scripts/repo-governance-check.py`
|
||||||
|
|
||||||
| ID | Kind | Status | Links |
|
| ID | Kind | Status | Links |
|
||||||
|----|------|--------|-------|
|
|----|------|--------|-------|
|
||||||
| — | product | P1 Complete | `.ai/product/feature-map.md` · `feature-spec/` · `p1-status.md` |
|
| — | product | P1 Complete | `.ai/product/feature-map.md` · `feature-spec/` · `p1-status.md` |
|
||||||
@@ -17,18 +28,18 @@
|
|||||||
| ECR-009 | 运营商业加深 Ops-D(订单筛选 · 展示价 · 退款只读) | **Closed/Implemented** | Spec ops-commerce · BD-2026-009 · LOOP-RUN-002 |
|
| ECR-009 | 运营商业加深 Ops-D(订单筛选 · 展示价 · 退款只读) | **Closed/Implemented** | Spec ops-commerce · BD-2026-009 · LOOP-RUN-002 |
|
||||||
| ECR-010 | 运营系统 Ops-E(RBAC · 封禁 · 推送占位) | **Closed/Implemented** | Spec ops-system · BD-2026-010 |
|
| ECR-010 | 运营系统 Ops-E(RBAC · 封禁 · 推送占位) | **Closed/Implemented** | Spec ops-system · BD-2026-010 |
|
||||||
| ECR-011 | 昵称 · 首页贴士 · Self 唯一/合盘交叉校验 | **Closed** | TEST_REPORT · CODE_REVIEW · BD-2026-011 |
|
| ECR-011 | 昵称 · 首页贴士 · Self 唯一/合盘交叉校验 | **Closed** | TEST_REPORT · CODE_REVIEW · BD-2026-011 |
|
||||||
| ECR-012 | **(main)** 用户文字输入合规 textsafe / 40060 | **Closed** | Spec input-compliance · BD-2026-012 |
|
| ECR-012 | 用户文字输入合规 textsafe / 40060 | **Closed** | Spec input-compliance · BD-2026-012 · **identity 占用 ECR-012** |
|
||||||
| ECR-012-star | **(local)** 星座对齐收口(星盘 · outlook · 合盘) | **Implemented** | Spec star-profile · 分叉编号冲突,见 CHANGELOG merge 注 |
|
| ECR-012-star | 星座对齐收口(星盘 · outlook · 合盘) | **Implemented** | Spec star-profile · 历史误用 ECR-012 → 已固定为 **ECR-012-star** |
|
||||||
| ECR-013 | **(main)** 探索 curated 题库 A 集 · 结果回看 | **Closed** | Spec explore-test · BD-2026-013 |
|
| ECR-013 | 探索 curated 题库 A 集 · 结果回看 | **Closed** | Spec explore-test · BD-2026-013 |
|
||||||
| WAVE-0 | Ops Contract-First Foundation | **FROZEN** | `docs/WAVE0/` · HUMAN_REVIEW FREEZE |
|
| WAVE-0 | Ops Contract-First Foundation | **FROZEN** | `docs/WAVE0/` · HUMAN_REVIEW FREEZE |
|
||||||
| ECR-013A | Admin RBAC | **Closed** | Spec ops-rbac · BD-2026-013A · migration **000018**(原撞号 015,见 MIGRATION_RENUMBER) |
|
| ECR-013A | Admin RBAC | **Closed** | Spec ops-rbac · BD-2026-013A · migration **000018**(原撞号 015,见 MIGRATION_RENUMBER) |
|
||||||
| ECR-013B | AccountLifecycle | **Closed** | Spec ops-account-lifecycle · BD-2026-013B |
|
| ECR-013B | AccountLifecycle | **Closed** | Spec ops-account-lifecycle · BD-2026-013B |
|
||||||
| ECR-014 | **(main)** 时效刷新(时辰贴士 · star/rhythm 按日) | **Closed** | RETRO · BD-2026-014 |
|
| ECR-014 | 时效刷新(时辰贴士 · star/rhythm 按日) | **Closed** | RETRO · BD-2026-014 · **identity 占用 ECR-014** |
|
||||||
| ECR-014-plan | **(local)** MembershipPlan | **Closed** | Spec ops-membership-plan · 分叉编号冲突 |
|
| ECR-014-plan | MembershipPlan | **Closed** | Spec ops-membership-plan · 历史误用 ECR-014 → **ECR-014-plan** |
|
||||||
| ECR-015 | **(main)** 账号头像上传与媒体读取 | **Closed** | RETRO · BD-2026-015 |
|
| ECR-015 | 账号头像上传与媒体读取 | **Closed** | RETRO · BD-2026-015 · **identity 占用 ECR-015** |
|
||||||
| ECR-015-code | **(local)** RedemptionCode | **Closed** | Spec ops-redemption-code · 分叉编号冲突 |
|
| ECR-015-code | RedemptionCode | **Closed** | Spec ops-redemption-code · 历史误用 ECR-015 → **ECR-015-code** |
|
||||||
| ECR-016 | **(main)** MBTI OEJTS 标准/完整版 | **Closed** | RETRO · BD-2026-016 |
|
| ECR-016 | MBTI OEJTS 标准/完整版 | **Closed** | RETRO · BD-2026-016 · **identity 占用 ECR-016** |
|
||||||
| ECR-016-insight | **(local)** UserIntelligence | **Closed** | Spec ops-user-intelligence · 分叉编号冲突 |
|
| ECR-016-insight | UserIntelligence | **Closed** | Spec ops-user-intelligence · 历史误用 ECR-016 → **ECR-016-insight** |
|
||||||
| ECR-017 | AskOperations | **Closed** | Spec ops-ask-operations · BD-2026-017 |
|
| ECR-017 | AskOperations | **Closed** | Spec ops-ask-operations · BD-2026-017 |
|
||||||
| ECR-018 | Entitlement | **Closed** | Spec ops-entitlement · BD-2026-018 |
|
| ECR-018 | Entitlement | **Closed** | Spec ops-entitlement · BD-2026-018 |
|
||||||
| ECR-019 | ContentSafety | **Closed** | Spec ops-content-safety · BD-2026-019 |
|
| ECR-019 | ContentSafety | **Closed** | Spec ops-content-safety · BD-2026-019 |
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Repository governance checks — outside ESS Loop.
|
||||||
|
|
||||||
|
Validates:
|
||||||
|
1. Migration stem uniqueness under apps/api/migrations/
|
||||||
|
2. ECR identity uniqueness (H1 / Canonical-ID) under docs/ECR/
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/repo-governance-check.py
|
||||||
|
python scripts/repo-governance-check.py --print-anchors
|
||||||
|
|
||||||
|
Exit 0 = PASS; non-zero = FAIL (merge/gate blocker).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MIG_DIR = ROOT / "apps" / "api" / "migrations"
|
||||||
|
ECR_DIR = ROOT / "docs" / "ECR"
|
||||||
|
|
||||||
|
# Full stem: 000018_admin_rbac (with or without .up/.down)
|
||||||
|
MIG_STEM_RE = re.compile(r"^(\d{6}_.+?)(?:\.(?:up|down))?\.sql$", re.I)
|
||||||
|
# Numeric prefix only (collision surface across branches)
|
||||||
|
MIG_NUM_RE = re.compile(r"^(\d{6})_")
|
||||||
|
ECR_ID_RE = re.compile(r"^ECR-\d+[A-Z]?(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?$", re.I)
|
||||||
|
H1_RE = re.compile(r"^#\s+(ECR-\S+)", re.M)
|
||||||
|
CANON_RE = re.compile(r"(?im)^\*\*?Canonical-ID:\*\*?\s*`?(ECR-[A-Za-z0-9-]+)`?\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def migration_stems(paths: list[Path]) -> dict[str, list[Path]]:
|
||||||
|
by_stem: dict[str, list[Path]] = defaultdict(list)
|
||||||
|
for p in paths:
|
||||||
|
m = MIG_STEM_RE.match(p.name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
by_stem[m.group(1)].append(p)
|
||||||
|
return by_stem
|
||||||
|
|
||||||
|
|
||||||
|
def migration_numbers(paths: list[Path]) -> dict[str, list[str]]:
|
||||||
|
"""Map 6-digit version → distinct full stems (duplicate numbers = FAIL)."""
|
||||||
|
by_num: dict[str, set[str]] = defaultdict(set)
|
||||||
|
for p in paths:
|
||||||
|
m = MIG_STEM_RE.match(p.name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
stem = m.group(1)
|
||||||
|
num_m = MIG_NUM_RE.match(stem)
|
||||||
|
if not num_m:
|
||||||
|
continue
|
||||||
|
by_num[num_m.group(1)].add(stem)
|
||||||
|
return {k: sorted(v) for k, v in by_num.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def ecr_identity(path: Path) -> str | None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
canon = CANON_RE.search(text)
|
||||||
|
if canon:
|
||||||
|
return canon.group(1)
|
||||||
|
h1 = H1_RE.search(text)
|
||||||
|
if h1:
|
||||||
|
# Strip trailing punctuation from H1 token
|
||||||
|
return h1.group(1).rstrip(".:)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def max_migration_num(paths: list[Path]) -> int:
|
||||||
|
n = 0
|
||||||
|
for p in paths:
|
||||||
|
m = MIG_STEM_RE.match(p.name)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
num_m = MIG_NUM_RE.match(m.group(1))
|
||||||
|
if num_m:
|
||||||
|
n = max(n, int(num_m.group(1)))
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def next_ecr_suggestion(identities: list[str]) -> str:
|
||||||
|
"""Next unused bare ECR-NNN (ignores letter/suffix variants for suggestion only)."""
|
||||||
|
nums: list[int] = []
|
||||||
|
for i in identities:
|
||||||
|
m = re.match(r"ECR-(\d+)", i, re.I)
|
||||||
|
if m:
|
||||||
|
nums.append(int(m.group(1)))
|
||||||
|
nxt = (max(nums) + 1) if nums else 1
|
||||||
|
return f"ECR-{nxt:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def check() -> int:
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if not MIG_DIR.is_dir():
|
||||||
|
errors.append(f"missing migrations dir: {MIG_DIR}")
|
||||||
|
mig_files: list[Path] = []
|
||||||
|
else:
|
||||||
|
mig_files = sorted(MIG_DIR.glob("*.sql"))
|
||||||
|
|
||||||
|
if not ECR_DIR.is_dir():
|
||||||
|
errors.append(f"missing ECR dir: {ECR_DIR}")
|
||||||
|
ecr_files: list[Path] = []
|
||||||
|
else:
|
||||||
|
ecr_files = sorted(ECR_DIR.glob("ECR-*.md"))
|
||||||
|
|
||||||
|
# --- migrations ---
|
||||||
|
by_stem = migration_stems(mig_files)
|
||||||
|
for stem, files in sorted(by_stem.items()):
|
||||||
|
# Same stem with both .up/.down is OK if only those; identical duplicate names shouldn't happen
|
||||||
|
names = [f.name for f in files]
|
||||||
|
if len(files) > 2 or (len(files) == 2 and not _up_down_pair(names)):
|
||||||
|
if len(set(names)) != len(names):
|
||||||
|
errors.append(f"migration duplicate filename: {names}")
|
||||||
|
elif len(files) > 2:
|
||||||
|
errors.append(f"migration stem over-shared ({stem}): {names}")
|
||||||
|
|
||||||
|
by_num = migration_numbers(mig_files)
|
||||||
|
for num, stems in sorted(by_num.items()):
|
||||||
|
if len(stems) > 1:
|
||||||
|
errors.append(
|
||||||
|
f"migration number collision {num}: {', '.join(stems)} "
|
||||||
|
f"(allocate next from TRACEABILITY Max Migration; never reuse)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- ECR identity ---
|
||||||
|
by_id: dict[str, list[Path]] = defaultdict(list)
|
||||||
|
missing: list[Path] = []
|
||||||
|
invalid: list[tuple[Path, str]] = []
|
||||||
|
for p in ecr_files:
|
||||||
|
ident = ecr_identity(p)
|
||||||
|
if not ident:
|
||||||
|
missing.append(p)
|
||||||
|
continue
|
||||||
|
if not ECR_ID_RE.match(ident):
|
||||||
|
invalid.append((p, ident))
|
||||||
|
continue
|
||||||
|
by_id[ident].append(p)
|
||||||
|
|
||||||
|
for p in missing:
|
||||||
|
errors.append(f"ECR missing identity (H1 or Canonical-ID): {p.name}")
|
||||||
|
for p, ident in invalid:
|
||||||
|
errors.append(f"ECR invalid identity {ident!r} in {p.name}")
|
||||||
|
|
||||||
|
for ident, files in sorted(by_id.items()):
|
||||||
|
if len(files) > 1:
|
||||||
|
errors.append(
|
||||||
|
f"ECR identity collision {ident}: "
|
||||||
|
+ ", ".join(f.name for f in files)
|
||||||
|
+ " (ECR-NNN is globally unique; use ECR-NNN-suffix or ECR-NNNA)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft: bare number reserved — if ECR-012 and ECR-012-star both exist, OK;
|
||||||
|
# if two different files both declare ECR-012, caught above.
|
||||||
|
|
||||||
|
identities = list(by_id.keys())
|
||||||
|
max_mig = max_migration_num(mig_files)
|
||||||
|
next_ecr = next_ecr_suggestion(identities)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("REPO GOVERNANCE: FAIL")
|
||||||
|
for e in errors:
|
||||||
|
print(f" ERROR: {e}")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" WARN: {w}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("REPO GOVERNANCE: PASS")
|
||||||
|
print(f" migrations: {len(by_num)} unique numbers (max {max_mig:06d})")
|
||||||
|
print(f" ECR identities: {len(by_id)} unique")
|
||||||
|
print(f" suggested Next ECR: {next_ecr}")
|
||||||
|
print(f" suggested Max Migration: {max_mig:06d}")
|
||||||
|
for w in warnings:
|
||||||
|
print(f" WARN: {w}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _up_down_pair(names: list[str]) -> bool:
|
||||||
|
if len(names) != 2:
|
||||||
|
return False
|
||||||
|
a, b = sorted(names)
|
||||||
|
return a.endswith(".up.sql") and b.endswith(".down.sql") and a[: -len(".up.sql")] == b[: -len(".down.sql")]
|
||||||
|
|
||||||
|
|
||||||
|
def print_anchors() -> int:
|
||||||
|
mig_files = sorted(MIG_DIR.glob("*.sql")) if MIG_DIR.is_dir() else []
|
||||||
|
ecr_files = sorted(ECR_DIR.glob("ECR-*.md")) if ECR_DIR.is_dir() else []
|
||||||
|
identities = []
|
||||||
|
for p in ecr_files:
|
||||||
|
i = ecr_identity(p)
|
||||||
|
if i:
|
||||||
|
identities.append(i)
|
||||||
|
max_mig = max_migration_num(mig_files)
|
||||||
|
print(f"Next ECR: {next_ecr_suggestion(identities)}")
|
||||||
|
print(f"Max Migration: {max_mig:06d}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument(
|
||||||
|
"--print-anchors",
|
||||||
|
action="store_true",
|
||||||
|
help="Print Next ECR / Max Migration suggestions and exit 0",
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
if args.print_anchors:
|
||||||
|
return print_anchors()
|
||||||
|
return check()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user