#!/usr/bin/env python3 from __future__ import annotations from pathlib import Path import csv, hashlib, json, re, sys root = Path(__file__).resolve().parents[1] errors: list[str] = [] warnings: list[str] = [] def fail(message: str) -> None: errors.append(message) def warn(message: str) -> None: warnings.append(message) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open('rb') as stream: for block in iter(lambda: stream.read(1024 * 1024), b''): digest.update(block) return digest.hexdigest() def read_csv(relative: str) -> list[dict[str, str]]: path = root / relative if not path.exists(): fail(f'missing CSV {relative}') return [] with path.open(encoding='utf-8-sig', newline='') as stream: return list(csv.DictReader(stream)) # JSON syntax and unresolved generation tokens. for path in root.rglob('*.json'): try: json.loads(path.read_text(encoding='utf-8-sig')) except Exception as exc: fail(f'JSON {path.relative_to(root)}: {exc}') for path in [root/'frontend/src/shared/ui', root/'docs/v13_0']: if not path.exists(): continue for item in path.rglob('*'): if item.is_file() and item.suffix.lower() in {'.ts','.vue','.cs','.md','.sql'}: text=item.read_text(encoding='utf-8') if re.search(r'__[A-Z][A-Z0-9_]+__', text): fail(f'unresolved template token: {item.relative_to(root)}') # WBS/debt/decision integrity. wbs = read_csv('docs/v13_0/08_DETAILED_WBS_MASTER.csv') if len(wbs) != 400: fail(f'WBS count {len(wbs)} != 400') wbs_ids = [row.get('WBS_ID','') for row in wbs] if any(not value for value in wbs_ids): fail('blank WBS ID') if len(wbs_ids) != len(set(wbs_ids)): fail('duplicate WBS IDs') for required in ('Task','Artifact','Acceptance_Evidence','Primary_Owner','PD','Gate','Risk','Status'): if any(not row.get(required,'').strip() for row in wbs): fail(f'blank WBS field: {required}') debts = read_csv('docs/v13_0/TECH_DEBT_REGISTER.csv') if len(debts) != 80: fail(f'tech debt count {len(debts)} != 80') decisions = read_csv('docs/v13_0/DECISION_LOG.csv') if len(decisions) != 52: fail(f'decision count {len(decisions)} != 52') # FE catalogue, adapter boundary and standard assets. catalogue = root/'frontend/src/shared/ui/screen-types/catalogue.ts' if not catalogue.exists(): fail('missing FE screen catalogue') else: text=catalogue.read_text(encoding='utf-8') for number in range(1,11): if f"id: 'T{number:02d}'" not in text: fail(f'missing T{number:02d}') for name in ('AppShellLayout','PageLayout','ReviewWorkbenchLayout','FormPageLayout','DashboardLayout'): if not (root/f'frontend/src/shared/ui/layouts/{name}.vue').exists(): fail(f'missing layout {name}') for name in ('KsButton','KsTextField','KsTextArea','KsSelect','KsCheckbox','KsDialog','KsStatusTag','KsDataGrid'): if not (root/f'frontend/src/shared/ui/components/{name}.vue').exists(): fail(f'missing standard component {name}') for path in (root/'frontend/src').rglob('*'): if path.suffix not in ('.ts','.vue'): continue text=path.read_text(encoding='utf-8') if re.search(r"from ['\"](primevue/|ag-grid)", text) and '/shared/ui/adapter/primevue/' not in path.as_posix(): fail(f'vendor import outside adapter: {path.relative_to(root)}') # Model-operation registry/contract/migration fail-closed checks. registry_path=root/'src/KArtSell.Modules.ModelOperations/Domain/ModelOperationDefinition.cs' registry=registry_path.read_text(encoding='utf-8') if registry_path.exists() else '' contract_path=root/'contracts/schedules/model-operations.v2.json' contract=json.loads(contract_path.read_text(encoding='utf-8')) if contract_path.exists() else {} migration_path=root/'db/migrations/0018_v13_model_feedback_loop.sql' migration=migration_path.read_text(encoding='utf-8') if migration_path.exists() else '' for number in range(31,39): code=f'J{number}' if f'"{code}"' not in registry: fail(f'missing registry {code}') if not any(item.get('operationCode')==code and item.get('enabledByDefault') is False for item in contract.get('operations',[])): fail(f'missing disabled contract {code}') if f"'{code}'" not in migration: fail(f'missing migration schedule {code}') if 'enabled, next_due_at' not in migration or migration.count(',2,false,') < 8: fail('J31-J38 schedules are not explicitly disabled') for marker in ('maturity_revision','supersedes_maturity_id','supersedes_hypothesis_id','prevent_append_only_change'): if marker not in migration: fail(f'missing append-only marker {marker}') boundary=(root/'src/KArtSell.Modules.ModelOperations/Domain/ModelOperationExecutionBoundary.cs').read_text(encoding='utf-8') if 'NO_AUTO_MODEL_OR_ORDER_MUTATION' not in boundary: fail('missing model operation fail-closed boundary') for forbidden in contract.get('automationBoundary',{}).get('forbidden',[]): if forbidden not in {'AUTO_PROMOTE','AUTO_ROLLBACK','AUTO_PARAMETER_CHANGE','AUTO_ORDER','KIS_SUBMISSION','CLIENT_PUBLICATION'}: fail(f'unexpected automation boundary token {forbidden}') # Current-request attachment byte-for-byte index. index_path=root/'attachments/SOURCE_INDEX_V13_0.json' if not index_path.exists(): fail('missing SOURCE_INDEX_V13_0.json') else: index=json.loads(index_path.read_text(encoding='utf-8')) files=index.get('files',[]) if len(files)!=5 or index.get('all_match') is not True: fail('current request attachment index is incomplete') for item in files: package_path=root/item['package_path'] if not package_path.exists(): fail(f"missing attachment {item['package_path']}") elif package_path.stat().st_size != item['size'] or sha256(package_path) != item['sha256']: fail(f"attachment hash mismatch {item['package_path']}") # Scaffolder and document evidence files. for relative in ( 'tools/scaffold_vertical_slice.py', 'templates/vertical-slice/backend/Endpoint.cs', 'templates/vertical-slice/frontend/pages/__FEATURE__Page.vue', 'SCAFFOLD_VALIDATION_V13_0.txt', 'FRONTEND_SYNTAX_VALIDATION_V13_0.txt', 'DOCX_QA_V13_0.txt', 'docs/v13_0/K-ArtSell_Aegis_v13_0_표준UI_지속모델운영_통합고도화_제안서.docx'): if not (root/relative).exists(): fail(f'missing evidence/artifact {relative}') syntax_path=root/'FRONTEND_SYNTAX_VALIDATION_V13_0.txt' if syntax_path.exists() and 'SYNTAX_ERRORS=0' not in syntax_path.read_text(encoding='utf-8'): fail('frontend syntax validation not clean') qa_path=root/'DOCX_QA_V13_0.txt' if qa_path.exists() and 'Visual inspection: PASS' not in qa_path.read_text(encoding='utf-8'): fail('DOCX visual QA not passed') # Runtime evidence remains deliberately external. if not (root/'frontend/pnpm-lock.yaml').exists(): warn('pnpm-lock.yaml missing; frozen frontend validation not possible') warn('.NET SDK/PostgreSQL runtime validation must be executed in approved CI') print(f'PASS={0 if errors else 1} WARN={len(warnings)} FAIL={len(errors)}') for message in warnings: print('WARN', message) for message in errors: print('FAIL', message) sys.exit(1 if errors else 0)