189 lines
8.9 KiB
Python
Executable File
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())
|