130 lines
7.0 KiB
Python
130 lines
7.0 KiB
Python
#!/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)
|