Files
clean-architecture-backend-…/notification-superpowers-package/validate_notification_docs.py
T

189 lines
8.9 KiB
Python
Executable File

#!/usr/bin/env python3
from __future__ import annotations
from collections import Counter
from pathlib import Path
import hashlib
import re
import sys
import zipfile
ROOT = Path('/mnt/data')
DESIGN = ROOT / 'notification-platform-design.md'
PLAN = ROOT / 'notification-platform-implementation-plan.md'
REPORT = ROOT / 'notification-superpowers-validation.md'
PACKAGE = ROOT / 'notification-superpowers-package.zip'
checks: list[tuple[str, bool, str]] = []
def check(name: str, condition: bool, detail: str = '') -> None:
checks.append((name, bool(condition), detail))
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
check('설계서 존재', DESIGN.is_file(), str(DESIGN))
check('구현 계획서 존재', PLAN.is_file(), str(PLAN))
if not DESIGN.is_file() or not PLAN.is_file():
return write_report()
design = DESIGN.read_text(encoding='utf-8')
plan = PLAN.read_text(encoding='utf-8')
design_lines = design.count('\n') + 1
plan_lines = plan.count('\n') + 1
check('설계서 최소 상세도', design_lines >= 3000, f'{design_lines:,} lines')
check('계획서 최소 상세도', plan_lines >= 4000, f'{plan_lines:,} lines')
check('설계서 코드 fence 균형', design.count('```') % 2 == 0, str(design.count('```')))
check('계획서 코드 fence 균형', plan.count('```') % 2 == 0, str(plan.count('```')))
required_design_terms = [
'NotificationRequest', 'RecipientDelivery', 'DeliveryAttempt',
'ProviderEvent', 'EvidenceLevel', 'SubmissionOutcome', 'DeliveryOutcome',
'AMBIGUOUS', 'append-only', 'FCM_FID', 'FCM_REGISTRATION_TOKEN_LEGACY',
'AES-256-GCM', 'HMAC-SHA-256', 'FOR UPDATE SKIP LOCKED',
'SMTP Adapter', 'Amazon SES Adapter', 'Twilio Adapter', 'FCM Adapter',
'APNs Adapter', 'Web Push Adapter', 'In-App Inbox', 'Reconciliation',
'N4 Admin Plane', '비지원 범위', '완료 정의'
]
for term in required_design_terms:
check(f'설계 핵심 계약: {term}', term in design)
check('exactlyOnce 단순 옵션 금지 명시',
'`exactlyOnce=true` 같은 단순 옵션을 두는 것은 잘못된 추상화' in design)
forbidden_design_claims = [
'guaranteedDelivery=true',
'APNs HTTP 200 = DELIVERED',
'FCM send success = DEVICE_DELIVERED',
]
for phrase in forbidden_design_claims:
check(f'금지 보장 부재: {phrase}', phrase not in design)
tasks = [int(x) for x in re.findall(r'^### Task (\d+):', plan, re.MULTILINE)]
check('Task 1~50 연속성', tasks == list(range(1, 51)), str(tasks))
task_chunks = re.split(r'(?=^### Task \d+:)', plan, flags=re.MULTILINE)[1:]
check('Task chunk 수', len(task_chunks) == 50, str(len(task_chunks)))
required_task_sections = [
'**Files:**', '**Interfaces:**', '**Implementation requirements:**',
'**Step 1:', '**Step 2:', '**Step 3:', '**Step 4:', '**Step 5:',
'Expected:', 'git commit -m'
]
for index, chunk in enumerate(task_chunks, 1):
for marker in required_task_sections:
check(f'Task {index} 필수 항목: {marker}', marker in chunk)
creates = re.findall(r'^- Create: `([^`]+)`', plan, re.MULTILINE)
duplicate_creates = sorted(path for path, count in Counter(creates).items() if count > 1)
check('Create 경로 중복 없음', not duplicate_creates, ', '.join(duplicate_creates))
check('Create 경로 충분성', len(creates) >= 200, str(len(creates)))
red_flags = {
'TODO': r'\bTODO\b',
'TBD': r'\bTBD\b',
'FIXME': r'\bFIXME\b',
'fill in details': r'fill in details',
'implement later': r'implement later',
'concrete assertion below': r'concrete assertion below',
'유사 작업 참조': r'Similar to Task',
}
for label, pattern in red_flags.items():
matches = re.findall(pattern, plan, re.IGNORECASE)
check(f'미확정 표현 없음: {label}', not matches, str(len(matches)))
key_plan_terms = [
'providerAcceptanceIsNotDelivery',
'concurrentSameRequestReturnsOneNotificationId',
'providerAcceptsThenResponseIsLostRecordsAmbiguousAndBlocksFallback',
'deliveredBeforeSentNeverDowngrades',
'fcmInstallationAndLegacyTokenAreDifferentTypes',
'http200IsProviderAcceptedNotDelivered',
'ttlHeaderIsRequiredAndAcceptanceIsNotDelivery',
'websocketFailureDoesNotRollbackInboxItem',
'metricTagsNeverContainHighCardinalityIdentifiers',
'acceptedThenResponseLossIsAmbiguousForEveryApplicableAdapter',
'notificationPerformanceTest',
]
for term in key_plan_terms:
check(f'계획 핵심 회귀 테스트: {term}', term in plan)
check('설계·계획 날짜 일치', '2026-08-10' in design and '2026-08-10' in plan)
check('Java 21 가정 명시', 'Java 21' in design and 'Java 21' in plan)
check('실제 저장소 부재 가정 명시', '실제 저장소가 제공되지 않아' in design)
check('Provider SDK 공개 금지', 'Provider SDK' in design and 'raw SDK client' in plan)
check('Core async CompletionStage', 'CompletionStage' in design and 'CompletionStage' in plan)
check('FCM FID 우선', 'FID 우선' in design and 'FCM primary target은 FID' in plan)
check('Ambiguous fallback 금지', 'ambiguousAttemptExists = true' in design and '`AMBIGUOUS` attempt가 있는 recipient' in plan)
check('ProviderEvent 원장', 'append-only ledger' in plan and 'ProviderEvent 원장' in design)
if PACKAGE.is_file():
try:
with zipfile.ZipFile(PACKAGE) as archive:
bad = archive.testzip()
names = set(archive.namelist())
required = {
'notification-superpowers-package/docs/superpowers/specs/2026-08-10-notification-platform-design.md',
'notification-superpowers-package/docs/superpowers/plans/2026-08-10-notification-platform-implementation-plan.md',
'notification-superpowers-package/README.md',
'notification-superpowers-package/VALIDATION.md',
'notification-superpowers-package/validate_notification_docs.py',
'notification-superpowers-package/MANIFEST.sha256',
}
check('ZIP CRC 무결성', bad is None, str(bad))
check('ZIP 필수 파일', required.issubset(names), str(sorted(required - names)))
except zipfile.BadZipFile as exc:
check('ZIP 열기', False, str(exc))
else:
check('ZIP 패키지 존재', False, str(PACKAGE))
return write_report(design_lines, plan_lines, len(creates))
def write_report(design_lines: int = 0, plan_lines: int = 0, create_count: int = 0) -> int:
passed = sum(1 for _, ok, _ in checks if ok)
failed = [(name, detail) for name, ok, detail in checks if not ok]
status = 'PASS' if not failed else 'FAIL'
rows = [
'# Notification Superpowers 문서 정적 검증', '',
f'- 결과: **{status}**',
f'- 실행 검사: **{len(checks)}개**',
f'- 통과: **{passed}개**',
f'- 실패: **{len(failed)}개**',
f'- 설계서: **{design_lines:,}행**',
f'- 구현 계획서: **{plan_lines:,}행**',
f'- 구현 Task: **50개**',
f'- Create 경로: **{create_count:,}개**',
f'- 설계 SHA-256: `{sha256(DESIGN) if DESIGN.exists() else "missing"}`',
f'- 계획 SHA-256: `{sha256(PLAN) if PLAN.exists() else "missing"}`',
'', '## 검사 결과', '',
'| 검사 | 결과 | 상세 |', '|---|---:|---|'
]
for name, ok, detail in checks:
safe = detail.replace('|', '\\|').replace('\n', ' ')[:500]
rows.append(f'| {name} | {"PASS" if ok else "FAIL"} | {safe} |')
rows.extend(['', '## 검증 범위', '',
'- 이 검증은 Markdown 설계서와 구현 계획서의 구조·정합성·필수 계약·경로 중복·미확정 표현·패키지 CRC를 검사한다.',
'- 실제 Backend Skeleton 저장소가 입력되지 않았으므로 Gradle compile, Provider sandbox, PostgreSQL integration, chaos, performance test 실행 결과는 포함하지 않는다.',
'- 구현 시에는 계획의 각 Task가 지정한 red-green TDD 명령을 실제 저장소에서 실행해야 한다.',
])
if failed:
rows.extend(['', '## 실패 항목', ''])
rows.extend(f'- **{name}**: {detail}' for name, detail in failed)
REPORT.write_text('\n'.join(rows) + '\n', encoding='utf-8')
print(f'{status}: {passed}/{len(checks)} checks passed')
if failed:
for name, detail in failed:
print(f'FAIL: {name}: {detail}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())