From 9bc96e672121b04f45698991d617a419524ad385 Mon Sep 17 00:00:00 2001 From: Selim Date: Sat, 11 Jul 2026 17:58:46 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(qa):=20OEM=20Validation=20Lab=20?= =?UTF-8?q?=E2=80=94=20host=20foundation=20(PR-1,=20cihazs=C4=B1z=20=C3=A7?= =?UTF-8?q?ekirdek)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cihaz gerektirmeyen ilk QA çekirdeği. Uygulama runtime'ına DOKUNMAZ (SystemBoot wiring yok, src/ import edilmez); yalnız Node built-in kullanır. ÇEKİRDEK - registry + definePhase sözleşmesi (duplicate id red, deterministik sıra) - değişmez context (immutable) + host yetenek tespiti (device.* ÜRETEMEZ) - fail-soft orchestrator: faz çökerse Lab çökmez, kalan fazlar koşar - coverage-aware scoring: atlanan test SKORU ŞİŞİRMEZ, coverage'ı düşürür - verdict: min(skor kapısı, coverage tavanı, profil tavanı) — yalnız AŞAĞI iner - bounded + redakte artefakt deposu; shell'siz, zaman-sınırlı exec YAPISAL KİLİTLER (kanıt olmadan iddia yok) - cihaz coverage 0 → verdict tavanı HOST_VERIFIED (skor 100 olsa bile) - host lane profili OEM_READY tavanı isteyemez (şema reddeder) - host lane'de adb/fastboot çağrısı context.exec tarafından REDDEDİLİR - safety-critical FAIL/INCOMPLETE veya blocker bulgu → REJECTED FAZ 1 — BUILD VALIDATION APK varlığı, SHA-256 (stream), paket adı, sürüm (version.properties hizası), izin yüzeyi, release-debuggable sızıntısı, apksigner imzası + mevcut scripts/verify-webview-compat.mjs yeniden kullanımı. Araç yoksa (aapt2/Java/APK) ilgili kontrol SKIPPED_NA — "koşamadı" ASLA "geçti" sayılmaz. İLK KOŞUNUN GERÇEK BULGUSU Sevk edilen APK, kaynak AndroidManifest.xml'de OLMAYAN 2 izin taşıyor (c2dm.permission.RECEIVE, DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION — Gradle manifest-merge ile kütüphanelerden). Sözleşmeye mergedPermissions olarak BELGELENDİ; kontrol zayıflatılmadı. RAPORLAR docs-local/qa-runs//: report.json (TEK gerçek kaynak) + QA/OEM/BUILD markdown (yalnız JSON'dan render) + artifacts/ + raw/. Git'e girmez (.gitignore). Sır + kişisel/makine yolu redaksiyonu (report.json'da ham mutlak yol YOK). KOMUTLAR: qa:oem:host · qa:oem:report · qa:oem:validate-profile TESTLER: 62 sözleşme testi (tam paket 3262 yeşil) DOKÜMAN: docs/OEM_VALIDATION_LAB_ARCHITECTURE.md + docs/qa/RUNBOOK LEDGER: #43 🟡 host'ta doğrulandı / 🔴 cihaz katmanı UYGULANMADI Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013JFgCEkmi1AVowXvZC57HY --- .gitignore | 4 + docs/DEVICE_VALIDATION_LEDGER.md | 1 + docs/OEM_VALIDATION_LAB_ARCHITECTURE.md | 429 +++++++++++++ docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md | 85 +++ package.json | 3 + qa/config/manifest-expectations.json | 62 ++ qa/core/artifact-store.mjs | 87 +++ qa/core/context.mjs | 247 ++++++++ qa/core/exec.mjs | 99 +++ qa/core/orchestrator.mjs | 131 ++++ qa/core/redact.mjs | 127 ++++ qa/core/registry.mjs | 99 +++ qa/core/result-types.mjs | 222 +++++++ qa/core/scoring.mjs | 112 ++++ qa/core/verdict.mjs | 116 ++++ qa/index.mjs | 225 +++++++ qa/phases/01-build-validation.mjs | 415 +++++++++++++ qa/phases/_phase.mjs | 48 ++ qa/profiles/_schema.mjs | 52 ++ qa/profiles/host-only.json | 14 + qa/reports/json-writer.mjs | 30 + qa/reports/markdown-writer.mjs | 202 ++++++ qa/reports/report-schema.mjs | 108 ++++ qa/thresholds/global.json | 47 ++ src/__tests__/oemValidationLab.test.ts | 795 ++++++++++++++++++++++++ 25 files changed, 3760 insertions(+) create mode 100644 docs/OEM_VALIDATION_LAB_ARCHITECTURE.md create mode 100644 docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md create mode 100644 qa/config/manifest-expectations.json create mode 100644 qa/core/artifact-store.mjs create mode 100644 qa/core/context.mjs create mode 100644 qa/core/exec.mjs create mode 100644 qa/core/orchestrator.mjs create mode 100644 qa/core/redact.mjs create mode 100644 qa/core/registry.mjs create mode 100644 qa/core/result-types.mjs create mode 100644 qa/core/scoring.mjs create mode 100644 qa/core/verdict.mjs create mode 100644 qa/index.mjs create mode 100644 qa/phases/01-build-validation.mjs create mode 100644 qa/phases/_phase.mjs create mode 100644 qa/profiles/_schema.mjs create mode 100644 qa/profiles/host-only.json create mode 100644 qa/reports/json-writer.mjs create mode 100644 qa/reports/markdown-writer.mjs create mode 100644 qa/reports/report-schema.mjs create mode 100644 qa/thresholds/global.json create mode 100644 src/__tests__/oemValidationLab.test.ts diff --git a/.gitignore b/.gitignore index 754751f1..22ac844c 100644 --- a/.gitignore +++ b/.gitignore @@ -118,3 +118,7 @@ tools/can-re/ # Canlı DB introspection çıktıları (hassas: grants/policies/row counts) — ASLA commit edilmez /docs-local/db-truth/ + +# OEM Validation Lab koşu çıktıları (ham cihaz/host komut çıktısı, APK hash'leri, +# yerel yollar) — redakte edilseler de git'e GİRMEZ; kanıt Ledger'a özetlenir. +/docs-local/qa-runs/ diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index 4987739f..5ed32c4e 100644 --- a/docs/DEVICE_VALIDATION_LEDGER.md +++ b/docs/DEVICE_VALIDATION_LEDGER.md @@ -17,6 +17,7 @@ | # | Özellik | Nerede | Kabul ölçütü (cihazda ne gözlemlenmeli) | Eklendi | |---|---------|--------|------------------------------------------|---------| +| 43 | **🟡 Host'ta doğrulandı / 🔴 Gerçek cihaz katmanı UYGULANMADI — OEM Validation Lab (Host Foundation, PR-1)** — Cihaz gerektirmeyen ilk çekirdek: faz registry + değişmez context + fail-soft orchestrator + **coverage-aware** skor + verdict merdiveni + `report.json` (tek gerçek kaynak) + 3 markdown rapor + `host-only` profil + **Faz 1 Build Validation** (APK varlığı/SHA-256/paket/sürüm/izin yüzeyi/debuggable/imza + mevcut `verify-webview-compat.mjs` yeniden kullanımı). **YAPISAL KİLİTLER:** cihaz coverage 0 iken skor 100 olsa bile verdict **HOST_VERIFIED**'i geçemez (`verdict.mjs` KAPI 2); `host` lane profili `OEM_READY` tavanı isteyemez (şema reddeder); `detectHostCapabilities` `device.*` üretemez (throw); host lane'de `adb`/`fastboot` çağrısı `context.exec` tarafından REDDEDİLİR. Atlanan kontrol **skora girmez, coverage'ı düşürür** — "koşamadı" ≠ "geçti". **İLK KOŞUNUN GERÇEK BULGUSU:** sevk edilen APK, kaynak `AndroidManifest.xml`'de OLMAYAN 2 izin taşıyor (`c2dm.permission.RECEIVE`, `DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` — Gradle manifest-merge ile kütüphanelerden) → sözleşmeye `mergedPermissions` olarak BELGELENDİ. **SystemBoot/uygulama runtime'ına DOKUNULMADI**; yeni bağımlılık YOK (yalnız Node built-in). ⚠️ **DÜRÜST SINIRLAR:** cihaz/performans/sensör/OBD fazları YOK (sonraki PR'lar); CI'a BAĞLANMADI (elle koşuluyor); Gradle build TETİKLENMEZ (mevcut APK'yı doğrular). | `qa/core/*` (9 modül), `qa/phases/01-build-validation.mjs`, `qa/profiles/host-only.json`, `qa/thresholds/global.json`, `qa/config/manifest-expectations.json`, `qa/reports/*`, `qa/index.mjs`, `docs/OEM_VALIDATION_LAB_ARCHITECTURE.md`, `docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md`, `src/__tests__/oemValidationLab.test.ts` (62 test), `package.json` (3 script), `.gitignore` | **Host tarafı (🟡 DOĞRULANDI — 2026-07-11):** `npm run qa:oem:host` tek komutla koşuyor · build doğrulama raporu üretiliyor (9/9 kontrol PASS) · `report.json` tek gerçek kaynak, markdown'lar ondan render ediliyor · host-only koşu **yalnız HOST_VERIFIED** üretiyor (skor 100/100 iken bile) · atlanan cihaz testleri skoru şişirmiyor · ham çıktılar git'e girmiyor (`docs-local/qa-runs/` gitignore) · secret + mutlak-yol redaksiyonu çalışıyor (report.json'da ham makine yolu YOK) · Windows'ta çalışıyor (`apksigner.bat` → cmd.exe köprüsü, JAVA_HOME tespiti). **🔴 CİHAZDA KANITLANACAK (sonraki PR'lar):** device transport (adb) · install/boot · runtime performans (FPS/PSS/termal, Mali-400) · sensör bütünlüğü · OBD canlı. Cihaz lane'i gelene kadar bu satır 🟢 YAPILAMAZ — Lab'in kendi kuralı bunu zaten yasaklıyor (device coverage 0 → tavan HOST_VERIFIED). | 2026-07-11 | | 42 | **🔴 Device Validation Pending — Orientation Sensor Demand Gating (PR 3/3)** — İki always-on tüketiciyi (gpsService compass + smartDrivingEngine accelerometer) talep-güdümlü hale getirir. **ACCEL (gerçek foreground demand-gate):** `_accelMagnitude` YALNIZ `detectDrivingMode` Kademe 4'te (OBD+GPS+decayed hepsi yok/stale) okunuyor → yardımcı son-çare. `_evaluateAccelDemand()` yalnız TAZE güvenilir hız kaynağı YOKKEN gate'ten `subscribeMotion` ile abone olur; taze kaynak (OBD **veya** GPS — `recordSpeed` ile beslenen `_lastSpeedEstimate`, yaş>DECAY_MAX_SEC=stale) varken release. `attachAccelerometer` fail-safe (kaynak yoksa acquire), `recordSpeed` her çağrıda re-eval, `detachAccelerometer` release. İvmeölçer TEK BAŞINA safety-critical/kesin hareket üretmez (yalnız Kademe 4 ipucu). Magic number YOK (DECAY_MAX_SEC mevcut sabit). **COMPASS (gpsService):** `_startCompassListener` ham `window.addEventListener` → gate `subscribeOrientationAbsolute/subscribeOrientation`; mevcut JS throttle (COMPASS_THROTTLE_MS, mevcut) + heading/smoothing/blend davranışı KORUNUR. Kazanım: **background auto-pause** (gate visibility hidden→fiziksel listener söker) + dedup. **YALIN:** yalnız 2 dosya değişti; yeni timer/rAF/polling YOK, Generic Sensor API YOK, yeni JS throttle YOK, yeni bağımlılık yok, import yan etkisiz; gate'in sahibi değil. ⚠️ **DÜRÜST SINIRLAMALAR:** (1) **Compass Settings ekranında KAPATILMADI** — foreground Settings-gating "harita ekranda mı" tüketici sinyali gerektirir (global görünüm store'u yok, MainLayout lokal `drawer` state'i; tüketiciye dokunmak KAPSAM DIŞI). Compass foreground davranışı bilinçli KORUNDU (map-heading-while-not-navigating bozulmasın). (2) **Accel mid-drive kaynak kaybı** (fresh sonra stale) re-acquire'ı sonraki `recordSpeed`/attach-döngüsünde olur (timer yok) — accel non-critical fallback + decay(20s)+Kademe5 kapsar. ⚠️ **Bu PR native samplingPeriod düşüşü İDDİA ETMEZ.** | `platform/gpsService.ts` (compass→gate), `platform/smartDrivingEngine.ts` (accel→gate+demand), `__tests__/orientationDemandGating.test.ts` (yeni, 26 test), `__tests__/orientationGateConsumerMigration.test.ts` (PR2 #21/#22 scope-kilidi yeni davranışa güncellendi) | Gerçek Xiaomi cihazda (önce/sonra): (1) sürüş sırasında (GPS/OBD taze) `dumpsys sensorservice` altında smartDriving accel `devicemotion` kaydı KALMAMASI; (2) GPS fix yok + OBD yok senaryosunda accel fallback'in ÇALIŞMASI (mod tespiti sürüyor); (3) background'da (app hidden) compass+accel fiziksel listener'ın SÖKÜLMESİ; (4) foreground dönüşünde geri bağlanması; (5) heading/pusula/navigation bearing DEĞİŞMEMESİ; (6) duplicate listener/cleanup leak OLMAMASI; (7) idle CPU (SensorsHandlerThread+CrRendererMain) sürüş+background senaryolarında düşüşü; (8) BASIC_JS/Mali-400'de regresyon yok. ⚠️ **Compass Settings-gating bu PR'da YOK (tüketici demand sinyali gerektirir — ayrı iş). Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 41 | **🔴 Device Validation Pending — Orientation Sensor Gate On-Demand Consumer Migration (PR 2/3)** — Yaşam-döngüsüyle açılıp kapanan DÖRT sensör tüketicisini ham `window.addEventListener` yerine Orientation Sensor Gate (#40) üzerinden çalıştırır. **Taşınanlar:** `arAlignmentService` (deviceorientationabsolute+deviceorientation+devicemotion → `subscribeOrientationAbsolute/subscribeOrientation/subscribeMotion`, 3 release ref, capture-fazı kaldırıldı=pencere-hedefli device event teslimatı faz-bağımsız), `dashcamService` (devicemotion → `subscribeMotion`, `_motionRelease`), `blackBoxService._startAccelerometer` (gate release aynen döner→`_accelUnsub` sözleşmesi değişmez), `deviceApi.subscribeToAccelerometer` (gate release döner→sentryEngine `_unsubAccel` yolu değişmez). **Model:** her tüketici gate release'i kendi lifecycle alanında tutar; start/stop İDEMPOTENT (mevcut guard'lar korundu); duplicate acquire yok; stop/dispose'ta release; gate'in SAHİBİ DEĞİL, gate'i dispose ETMEZ. **Davranış korundu:** handler'lar/callback sırası/işaretleri/smoothing/filtering/permission (feature-detect `'DeviceMotionEvent' in window` korundu, iOS requestPermission EKLENMEDİ) aynı; event payload aynı callback'e ulaşır. **Visibility:** tüketiciler kendi visibilitychange listener'ını EKLEMEZ (gate yönetir; çift listener yok). **Kazanım:** aynı ham event için birden fazla fiziksel JS listener yerine tek gate listener + background'da otomatik pause + temiz ownership/cleanup. **YALIN:** event başına yeni allocation yok, yeni timer/rAF/polling yok, callback gövdesi büyümedi, yeni bağımlılık yok, import yan etkisiz. ⚠️ **gpsService ve smartDrivingEngine bu PR'da DEĞİŞMEDİ** (hâlâ always-on) → **Settings ekranında native sensör kaydı ve native 60 Hz sampling DEVAM EDEBİLİR; bu PR native samplingPeriod'u düşürdüğünü İDDİA ETMEZ ve Settings CPU sorununu ÇÖZMEZ** — bunlar PR 3 kapsamı (always-on talep-güdümlü acquire). | `platform/arAlignmentService.ts`, `platform/dashcamService.ts`, `platform/security/blackBoxService.ts`, `platform/deviceApi.ts`, `__tests__/orientationGateConsumerMigration.test.ts` (yeni, 30 test) | Gerçek cihazda (AR navigasyon/dashcam/black-box/sentry aktifken): (1) AR/Dashcam/BlackBox/Sentry aktifken sensör olayları çalışıyor (heading/G-force/crash-detect aynı); (2) özellik durduğunda ilgili consumer release oluyor; (3) birden fazla consumer tek fiziksel listener paylaşıyor; (4) background'da (visibility hidden) fiziksel listener sökülüyor; (5) foreground dönüşünde yalnız aktif consumer geri bağlanıyor; (6) duplicate listener/cleanup leak yok; (7) BASIC_JS/Mali-400'de regresyon yok. ⚠️ **gpsService ve smartDrivingEngine bu PR KAPSAMI DIŞI; Settings ve native 60 Hz sorunu PR 3'te çözülür. Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | | 40 | **🔴 Device Validation Pending — Orientation Sensor Subscription Gate Foundation (PR 1/3)** — Cihaz yön/hareket Web event'leri (`deviceorientationabsolute`/`deviceorientation`/`devicemotion`) için MERKEZİ, ref-count'lu abonelik kapısı (multiplexer). Bugün bu event'lere ≥6 servis (gpsService compass, smartDrivingEngine, arAlignmentService, dashcamService, blackBoxService, deviceApi) AYRI `addEventListener` ile abone; ikisi (compass + accelerometer) kök layout'ta app-ömrü bağlı → Ayarlar dahil her ekranda sensör aktif (QA: `dumpsys sensorservice` samplingPeriod 16.67ms=60Hz, SensorsHandlerThread ~%24 + CrRendererMain ~%21). Kapı: **event türü başına TEK fiziksel window listener** (dedup); callback Set ile ref-count (ilk consumer→bağla, son→sök); `visibilityState!=='visible'`→fiziksel listener sökülür, görünür+aktif consumer→yeniden bağlanır; deterministik fan-out (insertion order, kararlı dizi snapshot'ı); **event başına YENİ array/object allocation YOK** (index'li döngü); callback exception İZOLE (fail-soft, diğerleri sürer); public API throw ETMEZ; bounded (kanal başı 64, aşım fail-soft no-op+droppedCount); dispose zero-leak + sonrası güvenli no-op; reset yeniden kullanılabilir. API: subscribeOrientationAbsolute/subscribeOrientation/subscribeMotion (idempotent release), getSubscriberCounts, getStatus, reset, dispose. **YALIN:** HİÇ import YOK (tam bağımsız→import yan etkisiz); modül seviyesinde listener kurulmaz (ilk abonelikte `visibilitychange` bağlanır); timer/rAF/polling YOK; Generic Sensor API YOK; JS throttle YOK; permission tüketicide kalır. ⚠️ **KAPSAM: foundation — HİÇBİR tüketici bu kapıya bağlanmadı (wiring YOK).** ⚠️ **Bu PR tek başına CPU veya samplingPeriod DÜŞÜRMEZ.** Gerçek kazanım PR 2 (on-demand tüketiciler) ve özellikle PR 3 (always-on compass + smartDriving talep-güdümlü acquire + background pause) wiring'inden SONRA ölçülebilir. **Legacy event API aktifken native sampling 60 Hz OLABİLİR** (frekans parametresi yok — kanıt: gpsService zaten JS'te 10Hz throttle'lı ama dumpsys 60Hz). | `platform/sensors/orientationSensorGate.ts` (yeni), `platform/sensors/index.ts` (yeni), `__tests__/orientationSensorGate.test.ts` (yeni, 30 test), `__tests__/regression.guards.test.ts` (gate zero-leak + bağımsızlık kilidi) | Gerçek cihazda (tüketici wiring PR 2/3 bağlandıktan SONRA): (1) Ayarlar ekranında `dumpsys sensorservice` altında CockpitOS için orientation/motion 60 Hz kaydı kalmaması; (2) background'da (visibility hidden) sensör kaydı kalmaması; (3) harita/navigation açılınca sensörün yeniden çalışması; (4) heading/pusula davranışının korunması; (5) duplicate listener ve cleanup sızıntısı olmaması; (6) idle CPU'nun (SensorsHandlerThread + CrRendererMain) Ayarlar ve background senaryolarında belirgin düşmesi; (7) BASIC_JS/Mali-400'de regresyon olmaması. ⚠️ **PR 1 tek başına ölçülebilir kazanç VERMEZ — foundation. Mali-400 cihaz doğrulaması bekliyor.** Test edilmeden 🟢 yapma. | 2026-07-11 | diff --git a/docs/OEM_VALIDATION_LAB_ARCHITECTURE.md b/docs/OEM_VALIDATION_LAB_ARCHITECTURE.md new file mode 100644 index 00000000..72aafe86 --- /dev/null +++ b/docs/OEM_VALIDATION_LAB_ARCHITECTURE.md @@ -0,0 +1,429 @@ +# OEM Validation Lab — Mimari + +> **Durum:** PR-1 (Host Foundation) uygulandı. Cihaz/performans/sensör/araç katmanları +> sonraki PR'lardadır — bu dokümanda **tasarlanmış ama henüz yazılmamış** olarak işaretlidir. +> Kütük: `docs/DEVICE_VALIDATION_LEDGER.md` · Kullanım: `docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md` + +--- + +## 1. Tasarım anayasası + +CarOS Pro bir head unit'e gömülüp **3. taraf üreticilere satılacak**. Bir OEM +"çalışıyor" demez; **kanıt** ister. Lab'in tek işi budur: bir yapının nereye kadar +kanıtlandığını **abartmadan** söylemek. + +| # | İlke | Neden (ihlal edilirse ne olur) | +|---|------|-------------------------------| +| A1 | **Kanıt yoksa iddia yok** | Cihazda koşmamış bir testin "yeşil" sayılması, sahada patlayan yazılımı "OEM_READY" diye satmaktır. | +| A2 | **Atlanan test skoru şişirmez** | Klasik runner'lar atlananı paydadan düşürür → cihaz testleri hiç koşmadan %100 çıkar. Burada skor ve **coverage AYRI** iki sayıdır; verdict ikisini birden ister. | +| A3 | **"Koşamadı" ≠ "geçti" ≠ "düştü"** | Araç eksikliği (Java/aapt2 yok) `SKIPPED_NA`, ortam çöküşü `INCOMPLETE`, gerçek kusur `FAIL`. Üçünü karıştırmak yanlış alarm veya sahte güven üretir. | +| A4 | **Verdict yalnız AŞAĞI iner** | Skor kapısı, coverage tavanı ve profil tavanı bağımsızdır; hiçbiri verdict'i yükseltemez. Cihaz kanıtı 0 iken skor 100 olsa dahi tavan `HOST_VERIFIED`. | +| A5 | **Fail-soft, çökmez** | Bozuk tek faz tüm kanıtı yok edemez: faz izole edilir, kalanlar koşar. | +| A6 | **Tek gerçek kaynak** | `report.json`. Markdown raporlar ondan **render** edilir; ikinci bir hesap yeri yoktur (ayrışma imkânsız). | +| A7 | **Ham çıktı git'e girmez** | Cihaz logu/APK hash'i/yerel yol → `docs-local/qa-runs/` (gitignore). Rapora giden her metin **redaksiyondan** geçer. | +| A8 | **Ürün runtime'ına dokunmaz** | Lab `src/` altındaki uygulamayı import etmez, SystemBoot'a bağlanmaz. Gözlemci olmayan gözlemci yoktur — ama etkisi sıfıra yakın olmalıdır. | +| A9 | **Yeni ağır bağımlılık yok** | Yalnız Node built-in (`node:fs`, `node:child_process`, `node:crypto`, `node:path`). Ticari lisans riski ve tedarik zinciri yüzeyi büyümez (CLAUDE.md §Lisans). | + +**Tasarım testi (her yeni faz için):** *"Bu faz bir OEM denetçisinin soracağı bir soruya +kanıtla cevap veriyor mu, yoksa sadece bir sayı mı üretiyor?"* + +--- + +## 2. Klasör yapısı + +``` +qa/ + core/ + result-types.mjs # durum sözlüğü: PHASE_RESULT, VERDICT, SEVERITY, CAPABILITY + registry.mjs # faz defteri + definePhase() sözleşmesi + context.mjs # değişmez koşu bağlamı + host yetenek tespiti + orchestrator.mjs # deterministik, izole, fail-soft faz koşumu + scoring.mjs # coverage-aware ağırlıklı skor + verdict.mjs # skor + coverage + profil → tek karar + artifact-store.mjs # bounded + redakte artefakt deposu + exec.mjs # shell'siz, zaman-sınırlı alt-süreç + redact.mjs # sır + kişisel yol redaksiyonu (son kapı) + phases/ + _phase.mjs # faz yazarının tek import'u (plugin sözleşmesi) + 01-build-validation.mjs + profiles/ + _schema.mjs # profil doğrulama (yapısal kilitler dahil) + host-only.json + thresholds/ + global.json # eşikler, ağırlık çarpanları, sınırlar + config/ + manifest-expectations.json # izin yüzeyi sözleşmesi + reports/ + report-schema.mjs # report.json şeması (v1) + doğrulama + json-writer.mjs # stream yazım + markdown-writer.mjs # SAF render (report → markdown) + index.mjs # runLab() + CLI (import yan etkisiz) + +docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md # nasıl koşulur, çıktı nasıl okunur +docs-local/qa-runs// # koşu çıktıları — GIT'E GİRMEZ +src/__tests__/oemValidationLab.test.ts # 62 sözleşme testi +``` + +**Neden `.mjs` ve neden `src/` dışında?** Lab bir **build/CI aracıdır**, ürünün parçası +değil. `src/` altına konsaydı `tsc -b` grafiğine ve Vite bundle'ına girerdi. `scripts/*.mjs` +(mevcut `verify-webview-compat.mjs`, `bump-version.mjs`) ile aynı konvansiyon. +**Testler** ise repo konvansiyonu gereği `src/__tests__/` altındadır (vitest `include` +oradan tarar) ve `.mjs` modüllerini doğrudan import eder — `tsconfig.app.json` +`src/__tests__`'i dışladığı için tip grafiğini kirletmez. + +--- + +## 3. Faz-yetenek matrisi + +Her faz, çalışabilmek için gereken **yetenekleri** (`requires`) beyan eder. Yetenek yoksa +faz **koşmaz** — ve bu bir başarı değil, bir **kanıt boşluğudur**. + +| Faz | PR | Kategori | Ağırlık | Gerektirdiği yetenek | Safety-critical | +|-----|----|----|---|----------------------|-----------------| +| `build-validation` | **PR-1 ✅** | build | 3 | `host.repo` | hayır | +| `static-security` | PR-2 | security | 2 (×2) | `host.repo` | hayır | +| `device-transport` | PR-3 | general | 2 | `device.adb` | hayır | +| `install-boot` | PR-3 | general | 3 | `device.adb`, `device.app` | **evet** | +| `runtime-performance` | PR-4 | performance | 3 (×2) | `device.app`, `device.perf` | hayır | +| `sensor-integrity` | PR-5 | vehicle | 3 (×2) | `device.sensors` | **evet** | +| `obd-live` | PR-6 | vehicle | 3 (×2) | `vehicle.obd` | **evet** | +| `safety-overlay` | PR-6 | vehicle | 3 (×2) | `device.app`, `vehicle.can` | **evet** | + +Yetenek uzayı (`CAPABILITY`): `host.{node,repo,dist,apk,aapt,apksigner,gradle,java}` · +`device.{adb,app,sensors,perf}` · `vehicle.{obd,can}`. + +**Yapısal kilit:** `detectHostCapabilities()` yalnız `host.*` döndürebilir — `device.*` +sızdırırsa **hata fırlatır**. Yani host lane'de cihaz yeteneği "kazara" doğru olamaz. + +--- + +## 4. Profil sistemi + +Profil = *"bu koşu neyi taahhüt ediyor?"* + +```jsonc +{ + "id": "host-only", + "lane": "host", // host | device | vehicle + "maxVerdict": "HOST_VERIFIED", // tavan + "phases": ["build-validation"], // koşulacak fazlar (allowlist) + "phaseTimeoutMs": 600000, + "build": { "variant": "debug", "buildIfMissing": false }, + "manualFallbacks": {} // { fazId: "elle nasıl doğrulanır" } +} +``` + +Geçersiz profil **sessizce varsayılana düşmez, REDDEDİLİR** (sessiz fallback = kimsenin +fark etmediği eksik kanıt). Ayrıca **yapısal kilit**: `lane: "host"` olan bir profil +`maxVerdict: "OEM_READY"` **isteyemez** — şema reddeder. + +Planlanan profiller: `host-only` (✅), `host-full` (PR-2, +statik güvenlik), +`device-bench` (PR-3/4, USB/ADB), `vehicle-live` (PR-6, gerçek araç), +`t507-manual` (ADB'siz head unit — desteklenmeyen fazlar `MANUAL_PENDING`). + +--- + +## 5. Threshold sistemi + +`qa/thresholds/global.json` — tek yer, versiyonlanabilir: + +| Anahtar | Anlamı | +|---------|--------| +| `minScore` | Her verdict için asgari skor (HOST_VERIFIED 70 · OEM_READY 85 · PRODUCTION 92 · FLAGSHIP 97) | +| `minDeviceCoverage` | OEM_READY 0.6 · PRODUCTION 0.8 · FLAGSHIP 0.95 | +| `minVehicleCoverage` | OEM_READY 0 · PRODUCTION 0.5 · FLAGSHIP 0.9 | +| `categoryMultipliers` | security/performance/vehicle **×2**, diğerleri ×1 | +| `resultScores` | PASS 1.0 · PASS_WITH_WARNINGS 0.75 · FAIL 0 · INCOMPLETE 0 | +| `artifacts` | maxFiles 200 · maxBytesPerFile 512 KB | +| `exec` | defaultTimeoutMs 120 s | + +Bir eşiği **düşürmek kanıt standardını düşürmektir** — PR'da gerekçelendirilir. + +--- + +## 6. Coverage-aware skor ve verdict modeli + +**İki bağımsız sayı:** + +- **Skor** = *yaptıklarının kalitesi.* `Σ(effectiveWeight × resultScore) / Σ(effectiveWeight)` + — yalnız **skorlanan** fazlar üzerinden (PASS, PASS_WITH_WARNINGS, FAIL, INCOMPLETE). +- **Coverage** = *ne kadarını yaptığın.* Alan başına `kanıt üreten faz / planlanan faz`. + `SKIPPED_NA` ve `MANUAL_PENDING` skora **girmez**, coverage'ı **düşürür**. + +**Kritik ayrıntı — boş küme "tam" sayılmaz:** hiç device fazı planlanmadıysa +`deviceCoverage = 0` (1 değil). Host-only koşunun OEM_READY üretememesi tam olarak bu +satırdan gelir; "vacuous truth" reddedilir. + +**Verdict = min(skor kapısı, coverage tavanı, profil tavanı)**, önce sert redler: + +``` +KAPI 1 (sert red): blocker bulgu · safety-critical FAIL/INCOMPLETE · skor < 70 → REJECTED +KAPI 2 (coverage): deviceCoverage == 0 → tavan HOST_VERIFIED + deviceCoverage/vehicleCoverage eşikleri → OEM/PRODUCTION/FLAGSHIP tavanı +KAPI 3 (profil): profile.maxVerdict +``` + +Örnek (gerçek PR-1 koşusu): 9/9 kontrol geçti → **skor 100** → ama device coverage 0 → +**verdict HOST_VERIFIED**. Skor asla verdict'i yükseltemez. + +--- + +## 7. `report.json` şeması (v1) + +```jsonc +{ + "schemaVersion": 1, + "runId": "2026-07-11T14-52-28Z", + "startedAt": "...", "finishedAt": "...", "durationMs": 0, + "profile": { "id", "name", "lane", "maxVerdict", "phases" }, + "environment": { "platform", "nodeVersion", "ci" }, + "capabilities": { + "detected": ["host.node", "host.repo", "..."], + "deviceLayerImplemented": false // PR-1'de DAİMA false — tüketici bunu görsün + }, + "verdict": { "value", "reasons": [], "caps": { "score", "coverage", "profile" } }, + "score": { "value", "weighted": { "earned", "possible" }, "counts": { ... } }, + "coverage": { "host": {planned, executed, skipped, manual, ratio}, "device": {...}, "vehicle": {...} }, + "phases": [{ + "id", "name", "order", "category", "weight", "effectiveWeight", "requires", + "safetyCritical", "result", "scored", "durationMs", + "findings": [{ "id", "severity", "title", "detail", "evidence", "remediation" }], + "artifacts": [{ "path", "bytes", "truncated", "dropped" }], + "metrics": { ... }, + "skippedReason", "manualFallback" + }], + "thresholds": { ... }, // koşunun hangi eşiklerle yargılandığı (tekrarlanabilirlik) + "logs": [ ... ] +} +``` + +Yazılmadan önce `validateReport()` kapısından geçer — bozuk rapor **diske yazılmaz**. + +--- + +## 8. Markdown raporları + +| Dosya | Okuyucu | İçerik | +|-------|---------|--------| +| `QA_REPORT.md` | mühendis | Her faz, her kontrol, her bulgu, artefakt referansları | +| `OEM_REPORT.md` | karar verici | Verdict, gerekçe, coverage, **"bu verdict ne DEMEK DEĞİL"**, kanıt boşlukları | +| `BUILD_REPORT.md` | sürüm sorumlusu | Paket kimliği: SHA-256, paket adı, sürüm, imza, izin yüzeyi | + +Üçü de **saf fonksiyonla** (`report → string`) üretilir: dosya okumaz, komut çalıştırmaz, +yeniden hesaplamaz. `npm run qa:oem:report` bunları mevcut `report.json`'dan yeniden üretir. + +--- + +## 9. Plugin / faz sözleşmesi + +```js +import { definePhase, phaseOutcome, check, finding, rollupChecks, + PHASE_RESULT, PHASE_CATEGORY, SEVERITY, CAPABILITY } from './_phase.mjs'; + +export default definePhase({ + id: 'runtime-performance', + name: 'Runtime Performance — FPS, bellek, termal', + order: 40, + weight: 3, + category: PHASE_CATEGORY.PERFORMANCE, // → ağırlık ×2 + requires: [CAPABILITY.DEVICE_APP, CAPABILITY.DEVICE_PERF], + safetyCritical: false, + async run(context) { + // context: immutable — repoRoot, profile, thresholds, capabilities, paths, + // exec(), artifacts, log(), has(), missing(), safePath() + return phaseOutcome({ result: PHASE_RESULT.PASS, findings: [], metrics: {}, artifacts: [] }); + }, +}); +``` + +**Faz ASLA:** `process.exit` çağırmaz · global state yazmaz · context'i mutasyona uğratmaz · +kendi verdict'ini belirlemez · uygulama runtime'ını import etmez. + +**Faz hata fırlatırsa:** orchestrator yakalar → faz `INCOMPLETE`, hata `artifacts/.error.txt`, +**kalan fazlar koşar**. Safety-critical bir faz patlarsa bulgu `blocker` olur → `REJECTED`. + +Faz zaman-sınırlıdır (`phaseTimeoutMs`); asılı kalan faz koşuyu kilitleyemez. + +--- + +## 10. ADB ve null-transport modeli + +PR-1'de **gerçek cihaz transport'u YOKTUR** ve bu **yapısal olarak** garanti altındadır: + +1. `context.exec()` — `lane: 'host'` profilinde `adb` / `fastboot` / `scrcpy` çağrısını + **reddeder** (throw). +2. `detectHostCapabilities()` — `device.*` yeteneği **üretemez** (üretirse hata fırlatır). +3. Test #37 — `qa/` kaynaklarında `exec('adb'…)` / `spawn('adb'…)` deseni **aranır ve + bulunmadığı doğrulanır**. + +**Null-transport:** cihaz gerektiren faz, cihaz yokken **koşmaz** — sessizce "geçti" +demez, `SKIPPED_NA` (veya profil manuel karşılık tanımlıyorsa `MANUAL_PENDING`) olur. +**T507 gibi ADB'si olmayan head unit'ler** için doğru araç budur: fazlar `MANUAL_PENDING` +olur, coverage düşer, verdict tavanı düşük kalır — ve bu **dürüst** sonuçtur. + +PR-3 transport arayüzü (tasarlandı, yazılmadı): `createTransport(profile)` → +`{ shell(), push(), pull(), install(), logcat(), isAlive() }`; `null-transport` implementasyonu +her çağrıda `SKIPPED_NA` üretir. + +--- + +## 11. npm komutları + +| Komut | Ne yapar | +|-------|----------| +| `npm run qa:oem:host` | Host lane koşusu (cihazsız). Verdict `REJECTED` ise **exit 1** (CI kapısı). | +| `npm run qa:oem:report` | Son koşunun `report.json`'undan markdown'ları yeniden üretir | +| `npm run qa:oem:validate-profile` | Tüm profilleri şemaya karşı doğrular (+ bilinmeyen faz kontrolü) | + +Planlanan: `qa:oem:device` (PR-3) · `qa:oem:vehicle` (PR-6) · `qa:oem:full`. + +--- + +## 12. Host CI lane + +`qa:oem:host` **Node built-in'lerden başka hiçbir şeye ihtiyaç duymaz**; APK/aapt2/Java +yoksa ilgili kontroller `SKIPPED_NA` olur ve koşu yine tamamlanır (fail-soft). + +Ubuntu CI runner'ında bugün: `dist/` derlenir → `web-dist` + `webview-compat` PASS; +APK yoksa APK kontrolleri `SKIPPED_NA` → faz `PASS_WITH_WARNINGS` (skor 75 ≥ 70) → +`HOST_VERIFIED`. **PR-1'de CI'a bağlanmadı** — mevcut `main.yml` üç işini (lint/test/build) +korur; QA lane'i ayrı bir PR'da eklenecek (kırmızı CI riski almadan). + +--- + +## 13. Device lane (PR-3+ — tasarlandı, yazılmadı) + +**Faz 1: mühendis bilgisayarı + USB/ADB.** Self-hosted GitHub device runner **sonraki faz**. + +``` +qa:oem:device → profil device-bench (lane: device) + 1. transport: adb devices → seri no (rapora REDAKTE girer) + 2. install: adb install -r (yalnız debug variant) + 3. boot: am start + logcat → ANR/FATAL taraması, boot süresi + 4. perf: dumpsys gfxinfo (jank), meminfo (PSS), termal + 5. sensor: sensör servisleri, GPS fix, OBD bağlantısı + 6. uninstall / temizlik (zorunlu — cihazda artık bırakma) +``` + +Cihaz coverage > 0 olduğunda verdict merdiveni **açılır** (test #38b bunu şimdiden kilitler). + +--- + +## 14. Ledger otomasyonu (tasarlandı) + +Bugün `DEVICE_VALIDATION_LEDGER.md` **elle** yazılıyor. Hedef: koşu bittiğinde +`report.json` → Ledger satırı önerisi üret (`qa:oem:ledger --propose`): + +- Verdict `HOST_VERIFIED` → 🔴 satırı **korunur** (cihaz kanıtı yok). +- Cihaz fazları PASS → ilgili 🔴 satırı için 🟢 önerisi + kanıt (`runId`, ölçüm). +- Cihazda FAIL → ❌ satırı önerisi + gözlemlenen hata. + +**Otomatik yazma YOK** — öneri üretir, insan onaylar (kütüğün değeri dürüstlüğünde). + +--- + +## 15. Gelecek genişlemeler + +Statik güvenlik taraması · APK boyut bütçesi (regresyon) · i18n eksik-anahtar taraması · +erişilebilirlik (dokunma hedefi ≥ 48dp) · offline harita bütünlüğü · lisans denetimi +(`license-checker` — kopyaleft kapısı, CLAUDE.md §Lisans) · Supabase RLS/grant denetimi. + +--- + +## 16. Atomik PR yol haritası + +| PR | Kapsam | Cihaz gerekli? | +|----|--------|----------------| +| **PR-1 ✅** | Çekirdek + Faz 1 Build Validation + host-only profil + raporlar + 62 test | hayır | +| PR-2 | Statik güvenlik fazı (sır taraması, izin gerekçesi, CSP/cleartext) + `host-full` profil | hayır | +| PR-3 | **Device transport** (adb) + install/boot fazı + `device-bench` profil | evet | +| PR-4 | Runtime performance (FPS/jank/PSS/termal) + Mali-400 bütçeleri | evet | +| PR-5 | Sensör bütünlüğü (GPS fix, orientation gate, sensör sanitizasyonu) | evet | +| PR-6 | Araç lane: OBD canlı + CAN + güvenlik overlay (reverse) | evet + araç | +| PR-7 | Scenario Engine + burn-in + OTA regresyon | evet | +| PR-8 | CI entegrasyonu (host lane) + self-hosted device runner | kısmen | + +Her PR: **additive**, mevcut fazları bozmaz, kendi testlerini getirir, Ledger'a 🔴 satır ekler. + +--- + +## 17. Araç Scenario Engine (tasarlandı) + +Tek tek PID okumak "zekâ" değildir (CLAUDE.md §8 Kapı). Scenario Engine, **senaryo** +düzeyinde doğrular: + +```jsonc +{ + "id": "cold-start-city", + "steps": [ + { "given": "ignition=off, coolant<40°C" }, + { "when": "ignition=on" }, + { "then": "soğuk-yol uyarısı 5 sn içinde görünür" }, + { "then": "Digital Twin hot-path'e GİRMEZ (3Hz bütçesi korunur)" } + ], + "requires": ["vehicle.obd"], + "safetyCritical": true +} +``` + +Senaryolar kayıttan (trip replay) veya canlı araçtan beslenir. **Reverse overlay**, +**aşırı ısınma**, **düşük yağ basıncı** senaryoları `safetyCritical: true` → düşerse +verdict doğrudan `REJECTED`. + +--- + +## 18. Burn-in testleri (tasarlandı) + +Head unit **saatlerce açık kalır** — 5 dakikalık test bunu kanıtlamaz. +`burn-in` fazı: N saat boyunca periyodik örnekleme → **bellek büyümesi** (PSS eğimi), +**FPS düşüşü**, **termal kısma**, **zombi timer/listener** (zero-leak invaryantı), +**localStorage yazım sıklığı** (I/O throttling invaryantı). Kabul: PSS eğimi ≈ 0, +FPS son çeyrekte ilk çeyreğin %90'ından düşük değil. + +--- + +## 19. OTA regresyon (tasarlandı) + +`ota:publish` ile yayınlanan sürüm, **bir önceki sürümün kanıt setini** düşürmemeli: +eski `report.json` + yeni `report.json` → **regresyon karşılaştırması** (skor düşüşü, +yeni blocker, coverage kaybı, APK boyut artışı). Kural: **coverage düşüren OTA yayınlanmaz.** + +--- + +## 20. Plugin certification (tasarlandı) + +3. taraf üretici kendi fazını ekleyebilmeli. Sertifikasyon kapısı: faz `definePhase` +sözleşmesine uyar · yan etkisiz import · bounded çalışma · `context` mutasyonu yok · +uygulama runtime'ını import etmez · kendi testlerini getirir. Doğrulayıcı: +`qa:oem:certify ` (statik + davranışsal kontrol). + +--- + +## 21. AI validation (tasarlandı) + +Asistan/Vehicle Brain katmanı **deterministik değildir** → klasik assert yetmez. +Yaklaşım: **altın senaryo seti** (sabit girdi → beklenen *aksiyon sınıfı*, tam metin değil) · +güvenlik kapısı testleri (kritik durumda online AI **devre dışı** — Companion Safety Kernel +PRE/POST gate) · halüsinasyon kapısı (araç verisi olmadan araç iddiası üretmemeli) · +gecikme bütçesi. Skorlama: aksiyon-doğruluğu oranı; **safety gate düşerse → REJECTED**. + +--- + +## 22. Enerji profilleri (tasarlandı) + +Head unit aracın aküsünden beslenir; kontak kapalıyken tüketim **sızıntıdır**. +`energy` fazı: kontak kapalı → CPU/wakelock/ağ tüketimi ≈ 0 · GPS/sensör abonelikleri +serbest bırakılmış (Orientation Sensor Gate — PR #56/57/58) · foreground service yalnız +gerekliyken açık. Ölçüm: `dumpsys batterystats` + wakelock sayacı. + +--- + +## Bilinen sınırlar (PR-1) + +- **Cihaz katmanı yok** — device/vehicle coverage yapısal olarak 0. +- **CI'a bağlı değil** — `qa:oem:host` elle koşuluyor (PR-8). +- **Gradle build tetiklenmiyor** — Lab mevcut APK'yı doğrular, üretmez (`buildIfMissing: false`). +- **Gradle `buildDir` override'ı makineye özgü** (`android/build.gradle` → `C:/Temp/...`): + APK repo dışında; Lab bunu okur ama rapora **redakte** (`/…`) yazar. +- **Redaksiyon savunma katmanıdır, kanıt değil** — bu yüzden `docs-local/qa-runs/` ayrıca + gitignore'dadır. diff --git a/docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md b/docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md new file mode 100644 index 00000000..a8c4fa9e --- /dev/null +++ b/docs/qa/OEM_VALIDATION_LAB_RUNBOOK.md @@ -0,0 +1,85 @@ +# OEM Validation Lab — Runbook + +Mimari: `docs/OEM_VALIDATION_LAB_ARCHITECTURE.md` · Kütük: `docs/DEVICE_VALIDATION_LEDGER.md` + +--- + +## Koşu + +```bash +npm run qa:oem:validate-profile # profilleri şemaya karşı doğrula (hızlı) +npm run qa:oem:host # host lane koşusu (cihaz GEREKMEZ) +npm run qa:oem:report # son report.json'dan markdown'ları yeniden üret +``` + +**Tam kanıt için önce:** + +```bash +npm run build # → dist/ (web-dist + webview-compat kontrolleri buna bağlı) +npm run apk:safe # → APK (paket/imza/izin kontrolleri buna bağlı) +npm run qa:oem:host +``` + +`build`/`apk:safe` koşulmazsa Lab **çökmez**: ilgili kontroller `SKIPPED_NA` olur, faz +`PASS_WITH_WARNINGS` alır ve raporda **kanıt boşluğu** olarak görünür. Atlanan kontrol +asla "geçti" sayılmaz. + +**Exit kodu:** verdict `REJECTED` → `1` (CI kapısı olarak kullanılabilir), aksi hâlde `0`. + +--- + +## Çıktı + +``` +docs-local/qa-runs// + report.json ← TEK GERÇEK KAYNAK + QA_REPORT.md ← mühendis: her faz, her kontrol, her bulgu + OEM_REPORT.md ← karar verici: verdict + "bu verdict ne DEMEK DEĞİL" + BUILD_REPORT.md ← sürüm sorumlusu: SHA-256, sürüm, imza, izin yüzeyi + artifacts/ ← faz hata dökümleri + raw/ ← ham komut çıktıları (aapt2, apksigner, compat) — REDAKTE +``` + +`docs-local/qa-runs/` **git'e girmez** (.gitignore). Kanıtı paylaşmak gerekirse +`report.json`'daki `runId` + ilgili ölçümü Ledger'a yaz. + +--- + +## Sonucu okuma + +**Verdict merdiveni:** `REJECTED` → `HOST_VERIFIED` → `OEM_READY` → `PRODUCTION_READY` → `FLAGSHIP_READY` + +| Gördüğün | Anlamı | Ne yapmalı | +|----------|--------|------------| +| 🟡 `HOST_VERIFIED` skor 100 | Host tarafı kusursuz — **ama cihazda hiçbir şey kanıtlanmadı** | Normal. Cihaz lane'i (PR-3) gelene kadar tavan budur. | +| ⛔ `REJECTED` | blocker bulgu · safety-critical faz düştü · skor < 70 | `OEM_REPORT.md` → "Kapatılması gerekenler" | +| `SKIPPED_NA` kontrol | Araç/artefakt yok (Java, aapt2, APK…) | Aracı kur veya APK üret; **kontrol geçmiş sayılmaz** | +| `INCOMPLETE` faz/kontrol | Koşmalıydı, kanıt üretemedi (çöktü/zaman aşımı) | `artifacts/.error.txt` veya `raw/` | +| `MANUAL_PENDING` | Bu cihazda otomatik doğrulanamaz (ör. T507 — ADB yok) | Elle doğrula, sonucu Ledger'a yaz | + +**Skor ≠ hazır.** Skor "yaptıklarının kalitesi", coverage "ne kadarını yaptığın". +Cihaz coverage 0 iken skor 100 olsa bile verdict `HOST_VERIFIED`'i **geçemez**. + +--- + +## Sözleşmeyi güncelleme + +`qa/config/manifest-expectations.json` — izin yüzeyi sözleşmesi. + +Yeni bir izin **bilinçli** eklendiyse: `allowedPermissions`'a ekle (kaynak manifest) veya +`mergedPermissions`'a ekle (Gradle manifest-merge ile kütüphaneden geliyorsa — hangi +kütüphane olduğunu **doğrula**). Kontrolü zayıflatmak için ekleme yapma: `apk-permissions` +kontrolünün amacı, kimsenin fark etmeden APK'ya izin sızmasını engellemektir. + +--- + +## Yeni faz ekleme + +1. `qa/phases/NN-adim.mjs` → `definePhase({ id, name, order, weight, category, requires, safetyCritical, run })` +2. `qa/index.mjs` → `BUILT_IN_PHASES` listesine ekle +3. İlgili profilin `phases` listesine ekle +4. `src/__tests__/oemValidationLab.test.ts` → fazın PASS/FAIL/SKIP yollarını kilitle +5. `npm run qa:oem:validate-profile` → yeşil olmalı + +Faz **cihaz** gerektiriyorsa `requires: [CAPABILITY.DEVICE_*]` yaz — host koşusunda +otomatik `SKIPPED_NA` olur ve device coverage'ı düşürür (istenen davranış). diff --git a/package.json b/package.json index 88c8fa80..df619301 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "test:e2e:headed": "playwright test --headed", "test:e2e:report": "playwright show-report", "setup:e2e": "playwright install --with-deps chromium", + "qa:oem:host": "node qa/index.mjs --profile host-only", + "qa:oem:report": "node qa/index.mjs --render-only", + "qa:oem:validate-profile": "node qa/index.mjs --validate-profile", "fetch:vosk": "node scripts/fetch-vosk-model.mjs", "release:bump": "node scripts/bump-version.mjs", "release:apk": "npm run build && npx cap sync android && cd android && gradlew assembleRelease", diff --git a/qa/config/manifest-expectations.json b/qa/config/manifest-expectations.json new file mode 100644 index 00000000..4f96df57 --- /dev/null +++ b/qa/config/manifest-expectations.json @@ -0,0 +1,62 @@ +{ + "_comment": "Build Validation fazının beklenti sözleşmesi. Bir izin bilinçli EKLENDİYSE burayı güncelle — fazı zayıflatma. Kaynak: android/app/src/main/AndroidManifest.xml (2026-07-11).", + "expectedPackage": "com.cockpitos.pro", + "allowedPermissions": [ + "android.permission.POST_NOTIFICATIONS", + "android.permission.INTERNET", + "android.permission.ACCESS_NETWORK_STATE", + "android.permission.ACCESS_WIFI_STATE", + "android.permission.CHANGE_WIFI_STATE", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.WAKE_LOCK", + "android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION", + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_SCAN", + "android.permission.RECORD_AUDIO", + "android.permission.CAMERA", + "android.permission.READ_CONTACTS", + "android.permission.WRITE_EXTERNAL_STORAGE", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.READ_MEDIA_VIDEO", + "android.permission.READ_MEDIA_AUDIO", + "android.permission.MANAGE_EXTERNAL_STORAGE", + "android.permission.WRITE_SETTINGS", + "android.permission.FOREGROUND_SERVICE", + "android.permission.FOREGROUND_SERVICE_LOCATION", + "android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", + "android.permission.REQUEST_INSTALL_PACKAGES" + ], + "_mergedComment": "Kaynak AndroidManifest.xml'de YOK ama sevk edilen APK'da VAR — Gradle manifest-merge ile kütüphanelerden gelir (Capacitor push-notifications/FCM ve AndroidX). İlk host koşusunda tespit edildi (2026-07-11). Bu liste 'kaynak manifest ≠ sevk edilen izin yüzeyi' gerçeğini BELGELEMEK içindir; yeni bir satır eklemeden önce izni gerçekten hangi kütüphanenin enjekte ettiğini doğrula.", + "mergedPermissions": [ + "com.google.android.c2dm.permission.RECEIVE", + "com.cockpitos.pro.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION" + ], + + "criticalPermissions": [ + "android.permission.MANAGE_EXTERNAL_STORAGE", + "android.permission.REQUEST_INSTALL_PACKAGES", + "android.permission.WRITE_SETTINGS", + "android.permission.RECORD_AUDIO", + "android.permission.CAMERA", + "android.permission.READ_CONTACTS", + "android.permission.ACCESS_BACKGROUND_LOCATION" + ], + "forbiddenPermissions": [ + "android.permission.READ_SMS", + "android.permission.SEND_SMS", + "android.permission.RECEIVE_SMS", + "android.permission.READ_CALL_LOG", + "android.permission.WRITE_CALL_LOG", + "android.permission.READ_PHONE_NUMBERS", + "android.permission.PROCESS_OUTGOING_CALLS", + "android.permission.QUERY_ALL_PACKAGES" + ], + "release": { + "debuggableForbidden": true, + "requireSignature": true + } +} diff --git a/qa/core/artifact-store.mjs b/qa/core/artifact-store.mjs new file mode 100644 index 00000000..5bc0d0cc --- /dev/null +++ b/qa/core/artifact-store.mjs @@ -0,0 +1,87 @@ +/** + * artifact-store.mjs — Koşu artefaktlarının SINIRLI (bounded) + REDAKTE deposu. + * + * NEDEN: + * - Ham komut çıktıları (gradle/aapt2/apksigner) rapora değil `raw/` altına yazılır; + * report.json yalnız artefakt REFERANSI taşır (şişmiş JSON yok). + * - Her yazım redaksiyondan geçer (sır + kişisel yol sızıntısı yok). + * - Dosya sayısı ve dosya başı boyut SINIRLI → kaçak faz diski doldurmaz. + * - Yazım yolu HER ZAMAN koşu klasörünün altındadır (path traversal engellenir). + * + * Dry-run modu (`baseDir: null`): hiçbir şey diske yazılmaz — testler ve + * `--validate-profile` gibi salt-kontrol akışları için. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; +import { redactText } from './redact.mjs'; + +export const MAX_ARTIFACTS = 200; +export const MAX_BYTES_PER_FILE = 512 * 1024; // 512 KB + +/** + * @param {{baseDir:string|null, repoRoot?:string, maxFiles?:number, maxBytesPerFile?:number}} opts + */ +export function createArtifactStore(opts = {}) { + const baseDir = opts.baseDir ?? null; + const repoRoot = opts.repoRoot ?? null; + const maxFiles = Number.isFinite(opts.maxFiles) ? opts.maxFiles : MAX_ARTIFACTS; + const maxBytesPerFile = Number.isFinite(opts.maxBytesPerFile) ? opts.maxBytesPerFile : MAX_BYTES_PER_FILE; + + /** @type {{path:string, bytes:number, truncated:boolean, dropped?:boolean}[]} */ + const written = []; + let dropped = 0; + + /** Göreli yolu koşu klasörü içine hapseder (traversal yok). */ + function safeTarget(relPath) { + if (typeof relPath !== 'string' || relPath.length === 0) throw new TypeError('artifact yolu zorunlu'); + if (isAbsolute(relPath)) throw new TypeError('artifact yolu göreli olmalı'); + const abs = resolve(baseDir, relPath); + const rel = relative(resolve(baseDir), abs); + if (rel.startsWith('..')) throw new TypeError(`artifact yolu koşu klasörü dışına çıkıyor: ${relPath}`); + return abs; + } + + return Object.freeze({ + /** + * Artefakt yazar. Döndürdüğü referans faz sonucuna eklenir. + * @returns {{path:string, bytes:number, truncated:boolean, dropped:boolean}} + */ + write(relPath, content) { + const redacted = redactText(String(content ?? ''), repoRoot); + const truncated = redacted.length > maxBytesPerFile; + const body = truncated + ? `${redacted.slice(0, maxBytesPerFile)}\n…[ARTEFAKT KESİLDİ — bounded ${maxBytesPerFile} bayt]` + : redacted; + + if (written.length >= maxFiles) { + dropped += 1; + return Object.freeze({ path: relPath, bytes: 0, truncated: false, dropped: true }); + } + + if (baseDir) { + const abs = safeTarget(relPath); + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, body, 'utf8'); + } + + const ref = Object.freeze({ + path: relPath.replace(/\\/g, '/'), + bytes: body.length, + truncated, + dropped: false, + }); + written.push(ref); + return ref; + }, + + /** Ham komut çıktısı → raw/ altına (aynı sınırlar + redaksiyon). */ + writeRaw(name, content) { + return this.write(join('raw', name).replace(/\\/g, '/'), content); + }, + + list() { return Object.freeze([...written]); }, + get count() { return written.length; }, + get droppedCount() { return dropped; }, + get baseDir() { return baseDir; }, + }); +} diff --git a/qa/core/context.mjs b/qa/core/context.mjs new file mode 100644 index 00000000..eaff84dd --- /dev/null +++ b/qa/core/context.mjs @@ -0,0 +1,247 @@ +/** + * context.mjs — Fazlara verilen DEĞİŞMEZ (immutable) koşu bağlamı. + * + * Bir faz context'i mutasyona uğratamaz (Object.freeze) — fazlar arası gizli + * kanal yok, koşu tekrarlanabilir. Faz yalnız `run(context)` alır ve sonuç DÖNER. + * + * Bağlam içeriği: + * - repoRoot, runId, startedAt, profile, thresholds + * - capabilities: TESPİT EDİLMİŞ yetenekler (host.* — PR-1'de device.* ASLA yok) + * - exec(): sınırlı, shell'siz komut çalıştırıcı (lane yasaklarına tabi) + * - artifacts: bounded + redakte artefakt deposu + * - log(): ilerleme satırı + * + * LANE GÜVENLİĞİ (PR-1 sözleşmesi): `lane: 'host'` profilinde `adb` çalıştırmak + * YAPISAL olarak yasaktır — exec() reddeder. Cihaz katmanı ayrı bir PR'da, + * `lane: 'device'` profili + transport modülüyle gelir. + */ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, basename } from 'node:path'; +import { runCommand, DEFAULT_TIMEOUT_MS } from './exec.mjs'; +import { createArtifactStore } from './artifact-store.mjs'; +import { CAPABILITY, capabilityDomain } from './result-types.mjs'; +import { redactPath } from './redact.mjs'; + +/** Host lane'de ASLA çalıştırılamayacak komutlar (cihaz katmanı sonraki PR). */ +const HOST_LANE_DENY = Object.freeze(['adb', 'fastboot', 'scrcpy']); + +/** + * Değişmez koşu bağlamı üretir. Tüm bağımlılıklar ENJEKTE edilir → testte + * gerçek dosya sistemi/süreç gerekmez. + */ +export function createContext({ + repoRoot, + runId, + startedAt, + profile, + thresholds, + capabilities = [], + paths = {}, + artifacts = null, + logger = null, + exec = runCommand, +}) { + if (!repoRoot || typeof repoRoot !== 'string') throw new TypeError('context.repoRoot zorunlu'); + if (!profile || typeof profile !== 'object') throw new TypeError('context.profile zorunlu'); + if (!thresholds || typeof thresholds !== 'object') throw new TypeError('context.thresholds zorunlu'); + + const caps = Object.freeze([...new Set(capabilities)]); + const capSet = new Set(caps); + const lane = profile.lane ?? 'host'; + const store = artifacts ?? createArtifactStore({ baseDir: null, repoRoot }); + const logLines = []; + + const context = { + repoRoot, + runId, + startedAt, + profile: Object.freeze({ ...profile }), + thresholds: Object.freeze(thresholds), + lane, + capabilities: caps, + // Tespit edilmiş host araç/artefakt yolları (apk, aapt2, apksigner, gradlew, dist). + // Fazlar bunları context'ten alır → testte ENJEKTE edilebilir, gerçek SDK gerekmez. + paths: Object.freeze({ apk: null, gradlew: null, aapt2: null, apksigner: null, dist: null, javaHome: null, ...paths }), + artifacts: store, + + /** Yetenek var mı? */ + has(capability) { return capSet.has(capability); }, + + /** Faz gereksinimlerinden EKSİK olanlar (boş dizi = hepsi var). */ + missing(required = []) { return required.filter((c) => !capSet.has(c)); }, + + /** repoRoot altındaki mutlak yol. */ + path(...parts) { return join(repoRoot, ...parts); }, + + /** Rapora yazılabilir (redakte) yol. */ + safePath(p) { return redactPath(p, repoRoot); }, + + /** + * Sınırlı komut çalıştırıcı. Lane yasağı ihlal edilirse THROW eder — + * orchestrator bunu yakalar, faz INCOMPLETE olur, Lab çökmez. + */ + async exec(command, args = [], opts = {}) { + const bin = basename(String(command)).replace(/\.(exe|bat|cmd)$/i, '').toLowerCase(); + if (lane === 'host' && HOST_LANE_DENY.includes(bin)) { + throw new Error(`Host lane'de '${bin}' çalıştırılamaz — cihaz katmanı bu PR'ın kapsamı dışında.`); + } + return exec(command, args, { + cwd: opts.cwd ?? repoRoot, + timeoutMs: opts.timeoutMs ?? thresholds?.exec?.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS, + env: opts.env, // ör. apksigner için JAVA_HOME + }); + }, + + log(message) { + const line = redactPath(String(message), repoRoot); + logLines.push(line); + if (logger) logger(line); + }, + + logs() { return Object.freeze([...logLines]); }, + }; + + return Object.freeze(context); +} + +/* ──────────────────────────────────────────────────────────────────────────── + * HOST YETENEK TESPİTİ — yalnız `host.*`. Bu fonksiyon device.* DÖNDÜREMEZ. + * ──────────────────────────────────────────────────────────────────────────── */ + +/** Android SDK build-tools'ta bir aracın (aapt2/apksigner) yolunu bulur; yoksa null. */ +export function findBuildTool(toolName, env = process.env) { + const roots = [ + env.ANDROID_HOME, + env.ANDROID_SDK_ROOT, + env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Android', 'Sdk') : null, + env.HOME ? join(env.HOME, 'Android', 'Sdk') : null, + env.HOME ? join(env.HOME, 'Library', 'Android', 'sdk') : null, + ].filter(Boolean); + + for (const root of roots) { + const bt = join(root, 'build-tools'); + if (!existsSync(bt)) continue; + let versions; + try { + // Sürüm klasörlerini büyükten küçüğe: en yeni build-tools tercih edilir. + versions = readdirSync(bt).sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + } catch { continue; } + for (const v of versions) { + for (const ext of ['.exe', '.bat', '']) { + const candidate = join(bt, v, `${toolName}${ext}`); + if (existsSync(candidate)) return candidate; + } + } + } + return null; +} + +/** + * Java kurulumu (JAVA_HOME kökü). apksigner bir Java aracıdır — Java yoksa + * "imza geçersiz" DEĞİL, "imza kontrolü koşulamadı" denir (yanlış alarm yasak). + * Bu makinede Java genelde Android Studio'nun gömülü JBR'ı ile gelir. + */ +export function findJavaHome(env = process.env) { + const candidates = [ + env.JAVA_HOME, + 'C:\\Program Files\\Android\\Android Studio\\jbr', + env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Programs', 'Android Studio', 'jbr') : null, + '/Applications/Android Studio.app/Contents/jbr/Contents/Home', + '/usr/lib/jvm/default-java', + ].filter(Boolean); + + for (const home of candidates) { + for (const exe of ['java.exe', 'java']) { + if (existsSync(join(home, 'bin', exe))) return home; + } + } + + // PATH üzerinde java (spawn etmeden, yalnız dosya sistemi kontrolü). + const pathDirs = String(env.PATH ?? env.Path ?? '').split(process.platform === 'win32' ? ';' : ':'); + for (const dir of pathDirs) { + if (!dir) continue; + for (const exe of ['java.exe', 'java']) { + if (existsSync(join(dir, exe))) return join(dir, '..'); // .../bin/java → JAVA_HOME + } + } + return null; +} + +/** Gradle wrapper yolu (Windows'ta .bat). */ +export function findGradlew(repoRoot) { + const win = join(repoRoot, 'android', 'gradlew.bat'); + const nix = join(repoRoot, 'android', 'gradlew'); + if (process.platform === 'win32' && existsSync(win)) return win; + if (existsSync(nix)) return nix; + return existsSync(win) ? win : null; +} + +/** + * `android/build.gradle` içindeki buildDir override'ını okur (bu repoda build + * çıktısı repo dışına — ör. C:/Temp/... — yönlendirilmiştir). Bulunamazsa null. + */ +export function readGradleBuildDir(repoRoot) { + const gradleFile = join(repoRoot, 'android', 'build.gradle'); + if (!existsSync(gradleFile)) return null; + try { + const text = readFileSync(gradleFile, 'utf8'); + const m = text.match(/buildDir\s*=\s*new\s+File\(\s*["']([^"']+)["']/); + if (!m) return null; + // "C:/Temp/carlauncher/${project.name}/build" → app modülü için project.name = "app" + return m[1].replace(/\$\{project\.name\}/g, 'app'); + } catch { + return null; + } +} + +/** APK aday yolları (öncelik sırasıyla). Sadece VAR OLAN dosyalar döner. */ +export function findApkCandidates(repoRoot, variant = 'debug') { + const candidates = []; + const gradleBuildDir = readGradleBuildDir(repoRoot); + if (gradleBuildDir) { + candidates.push(join(gradleBuildDir, 'outputs', 'apk', variant, `app-${variant}.apk`)); + } + candidates.push(join(repoRoot, 'android', 'app', 'build', 'outputs', 'apk', variant, `app-${variant}.apk`)); + return candidates.filter((p) => existsSync(p)); +} + +/** + * Host yeteneklerini tespit eder. Dosya sistemi + SDK yoklaması; komut ÇALIŞTIRMAZ. + * @returns {{capabilities:string[], paths:{apk:string|null, gradlew:string|null, aapt2:string|null, apksigner:string|null, dist:string|null}}} + */ +export function detectHostCapabilities(repoRoot, { variant = 'debug', env = process.env } = {}) { + const capabilities = [CAPABILITY.HOST_NODE]; + const paths = { apk: null, gradlew: null, aapt2: null, apksigner: null, dist: null, javaHome: null }; + + if (existsSync(join(repoRoot, 'package.json'))) capabilities.push(CAPABILITY.HOST_REPO); + + const distIndex = join(repoRoot, 'dist', 'index.html'); + if (existsSync(distIndex)) { + capabilities.push(CAPABILITY.HOST_DIST); + paths.dist = join(repoRoot, 'dist'); + } + + const apks = findApkCandidates(repoRoot, variant); + if (apks.length > 0) { + capabilities.push(CAPABILITY.HOST_APK); + paths.apk = apks[0]; + } + + const gradlew = findGradlew(repoRoot); + if (gradlew) { capabilities.push(CAPABILITY.HOST_GRADLE); paths.gradlew = gradlew; } + + const aapt2 = findBuildTool('aapt2', env); + if (aapt2) { capabilities.push(CAPABILITY.HOST_AAPT); paths.aapt2 = aapt2; } + + const apksigner = findBuildTool('apksigner', env); + if (apksigner) { capabilities.push(CAPABILITY.HOST_APKSIGNER); paths.apksigner = apksigner; } + + const javaHome = findJavaHome(env); + if (javaHome) { capabilities.push(CAPABILITY.HOST_JAVA); paths.javaHome = javaHome; } + + // ZORUNLU İNVARYANT: host tespiti device/vehicle yeteneği ÜRETEMEZ. + const leaked = capabilities.filter((c) => capabilityDomain(c) !== 'host'); + if (leaked.length > 0) throw new Error(`detectHostCapabilities host-dışı yetenek sızdırdı: ${leaked.join(', ')}`); + + return { capabilities, paths }; +} diff --git a/qa/core/exec.mjs b/qa/core/exec.mjs new file mode 100644 index 00000000..83cc226d --- /dev/null +++ b/qa/core/exec.mjs @@ -0,0 +1,99 @@ +/** + * exec.mjs — Sınırlı (bounded), shell'siz alt-süreç çalıştırıcı. + * + * NEDEN (CLAUDE.md §Zero-Leak + §Fail-soft): + * - Shell string birleştirme YOK → argüman dizisi (enjeksiyon + Windows tırnak cehennemi yok). + * - Her komut zaman-sınırlı; timeout'ta süreç öldürülür ve timer TEMİZLENİR (zombi yok). + * - Çıktı boyutu sınırlı (maxBuffer) → dev gradle logu belleği patlatmaz. + * - ASLA throw etmez: bulunamayan/başarısız komut { ok:false, code, error } döner → + * tek bir eksik host aracı tüm Lab'i çökertmez. + * + * Yan etkisiz: import edildiğinde hiçbir süreç başlatılmaz. + */ +import { spawn } from 'node:child_process'; + +export const DEFAULT_TIMEOUT_MS = 120_000; +const MAX_OUTPUT_BYTES = 1_000_000; // 1 MB — üstü kesilir (bounded) + +/** + * @param {string} command Çalıştırılacak dosya (shell YOK) + * @param {string[]} args Argümanlar (dizi — asla birleştirilmiş string değil) + * @param {{cwd?:string, timeoutMs?:number, env?:object}} opts + * @returns {Promise<{ok:boolean, code:number|null, signal:string|null, stdout:string, stderr:string, timedOut:boolean, truncated:boolean, error:string|null, durationMs:number}>} + */ +export function runCommand(command, args = [], opts = {}) { + const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS; + const started = Date.now(); + + return new Promise((resolve) => { + let child; + let settled = false; + let timer = null; + let timedOut = false; + let truncated = false; + let outBytes = 0; + const stdout = []; + const stderr = []; + + const finish = (payload) => { + if (settled) return; + settled = true; + if (timer) { clearTimeout(timer); timer = null; } // zero-leak: timer daima temizlenir + resolve(Object.freeze({ + ok: payload.ok, + code: payload.code ?? null, + signal: payload.signal ?? null, + stdout: stdout.join(''), + stderr: stderr.join(''), + timedOut, + truncated, + error: payload.error ?? null, + durationMs: Date.now() - started, + })); + }; + + const collect = (bucket) => (chunk) => { + if (truncated) return; + const text = String(chunk); + outBytes += text.length; + if (outBytes > MAX_OUTPUT_BYTES) { + truncated = true; + bucket.push('\n…[ÇIKTI KESİLDİ — bounded]'); + return; + } + bucket.push(text); + }; + + try { + // Windows: .bat/.cmd (ör. Android SDK'nın apksigner.bat) doğrudan spawn + // EDİLEMEZ (Node kısıtı) → cmd.exe /c ile çalıştırılır. shell:true KULLANILMAZ; + // argümanlar yine DİZİ olarak geçer (string birleştirme yok). + const isBatch = process.platform === 'win32' && /\.(bat|cmd)$/i.test(command); + const file = isBatch ? (process.env.COMSPEC ?? 'cmd.exe') : command; + const argv = isBatch ? ['/d', '/s', '/c', command, ...args] : args; + + child = spawn(file, argv, { + cwd: opts.cwd, + env: opts.env ?? process.env, + shell: false, // ASLA shell — argüman dizisi tek yol + windowsHide: true, + }); + } catch (e) { + finish({ ok: false, code: null, error: `spawn hatası: ${e?.message ?? e}` }); + return; + } + + child.stdout?.on('data', collect(stdout)); + child.stderr?.on('data', collect(stderr)); + child.on('error', (e) => finish({ ok: false, code: null, error: `çalıştırılamadı: ${e?.message ?? e}` })); + child.on('close', (code, signal) => finish({ ok: code === 0 && !timedOut, code, signal })); + + timer = setTimeout(() => { + timedOut = true; + try { child.kill('SIGKILL'); } catch { /* zaten ölmüş */ } + finish({ ok: false, code: null, error: `zaman aşımı (${timeoutMs}ms)` }); + }, timeoutMs); + // Timer olay döngüsünü canlı tutmasın — iş bitince process kapanabilsin. + if (typeof timer.unref === 'function') timer.unref(); + }); +} diff --git a/qa/core/orchestrator.mjs b/qa/core/orchestrator.mjs new file mode 100644 index 00000000..d4b4f54b --- /dev/null +++ b/qa/core/orchestrator.mjs @@ -0,0 +1,131 @@ +/** + * orchestrator.mjs — Fazları deterministik sırayla, İZOLE ve FAIL-SOFT koşar. + * + * GARANTİLER: + * 1) Bir faz throw ederse Lab ÇÖKMEZ → o faz INCOMPLETE olur, hata artefakta + * yazılır, KALAN fazlar koşmaya devam eder. (Tek bozuk plugin tüm kanıtı + * yok edemez.) + * 2) Faz `requires` yetenekleri eksikse ÇALIŞTIRILMAZ: + * - profilde manuel karşılığı varsa → MANUAL_PENDING + * - yoksa → SKIPPED_NA + * Her iki durumda da skora girmez, coverage'ı DÜŞÜRÜR. + * 3) Faz zaman-sınırlıdır (phaseTimeoutMs) — asılı kalan faz koşuyu kilitlemez. + * 4) Faz sonucu ŞEMAYA ZORLANIR (createPhaseResult) — eksik alan döndüren faz + * raporu bozamaz. + * 5) Orchestrator context'i MUTASYONA UĞRATMAZ ve faz çıktısını değiştirmez + * (girdi immutable). + */ +import { PHASE_RESULT, createPhaseResult, createFinding, SEVERITY } from './result-types.mjs'; +import { effectiveWeight } from './scoring.mjs'; + +const DEFAULT_PHASE_TIMEOUT_MS = 10 * 60_000; + +/** Faz koşusunu zaman sınırına bağlar; timer daima temizlenir (zero-leak). */ +function withTimeout(promise, ms, phaseId) { + let timer = null; + const guard = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Faz zaman aşımı: ${phaseId} (${ms}ms)`)), ms); + if (typeof timer.unref === 'function') timer.unref(); + }); + return Promise.race([promise, guard]).finally(() => { + if (timer) { clearTimeout(timer); timer = null; } + }); +} + +/** + * @param {ReturnType} registry + * @param {object} context createContext çıktısı (immutable) + * @returns {Promise} planlanan TÜM fazların sonucu (atlananlar dahil) + */ +export async function runPhases(registry, context) { + const profile = context.profile; + const thresholds = context.thresholds; + const phaseTimeout = Number.isFinite(profile?.phaseTimeoutMs) ? profile.phaseTimeoutMs : DEFAULT_PHASE_TIMEOUT_MS; + const phases = registry.list(profile?.phases ?? null); + const results = []; + + for (const phase of phases) { + const startedAt = Date.now(); + const base = { + id: phase.id, + name: phase.name, + order: phase.order, + category: phase.category, + weight: phase.weight, + effectiveWeight: effectiveWeight(phase, thresholds), + requires: phase.requires, + safetyCritical: phase.safetyCritical, + }; + + // ── Yetenek kapısı ──────────────────────────────────────────────────── + const missing = context.missing(phase.requires); + if (missing.length > 0) { + const manual = profile?.manualFallbacks?.[phase.id] ?? null; + const reason = `Gerekli yetenekler yok: ${missing.join(', ')}`; + context.log(`⏭️ ${phase.id} — ${reason}${manual ? ' → MANUAL_PENDING' : ' → SKIPPED_NA'}`); + results.push(createPhaseResult({ + ...base, + result: manual ? PHASE_RESULT.MANUAL_PENDING : PHASE_RESULT.SKIPPED_NA, + score: 0, + scored: false, + durationMs: 0, + skippedReason: reason, + manualFallback: manual, + findings: [createFinding({ + id: `${phase.id}.capability-gap`, + severity: SEVERITY.INFO, + title: `Kanıt boşluğu: ${phase.name}`, + detail: reason, + remediation: manual ?? 'Gerekli yetenek sağlanınca faz otomatik koşar (coverage yükselir).', + })], + })); + continue; + } + + // ── İzole koşu ──────────────────────────────────────────────────────── + context.log(`▶️ ${phase.id} — ${phase.name}`); + let outcome; + try { + outcome = await withTimeout(Promise.resolve(phase.run(context)), phaseTimeout, phase.id); + } catch (err) { + const message = err?.stack ? String(err.stack) : String(err?.message ?? err); + const ref = context.artifacts.write(`artifacts/${phase.id}.error.txt`, message); + context.log(`💥 ${phase.id} — faz hata verdi, izole edildi (Lab devam ediyor)`); + results.push(createPhaseResult({ + ...base, + result: PHASE_RESULT.INCOMPLETE, + score: 0, + scored: true, + durationMs: Date.now() - startedAt, + artifacts: [ref], + findings: [createFinding({ + id: `${phase.id}.exception`, + severity: phase.safetyCritical ? SEVERITY.BLOCKER : SEVERITY.MAJOR, + title: `Faz hata fırlattı: ${phase.name}`, + detail: String(err?.message ?? err).slice(0, 500), + evidence: ref.path, + remediation: 'Faz kodunu düzelt; hata artefaktına bak.', + })], + })); + continue; + } + + const result = outcome?.result ?? PHASE_RESULT.INCOMPLETE; + const scored = result !== PHASE_RESULT.SKIPPED_NA && result !== PHASE_RESULT.MANUAL_PENDING; + results.push(createPhaseResult({ + ...base, + result, + score: 0, // nihai puan scoring katmanında hesaplanır (tek yer) + scored, + durationMs: Date.now() - startedAt, + findings: outcome?.findings ?? [], + artifacts: outcome?.artifacts ?? [], + metrics: outcome?.metrics ?? {}, + skippedReason: outcome?.skippedReason ?? null, + manualFallback: outcome?.manualFallback ?? null, + })); + context.log(` └─ ${phase.id}: ${result}`); + } + + return results; +} diff --git a/qa/core/redact.mjs b/qa/core/redact.mjs new file mode 100644 index 00000000..deb70f17 --- /dev/null +++ b/qa/core/redact.mjs @@ -0,0 +1,127 @@ +/** + * redact.mjs — Rapora yazılan HER metin buradan geçer (gizli veri + kişisel yol sızıntısı yok). + * + * NEDEN: Lab, ham komut çıktısı (gradle, aapt2, apksigner, ileride adb) topluyor. + * Bu çıktılar keystore parolası, imza yolu, kullanıcı adı içeren mutlak yollar + * (C:\Users\\...), cihaz seri numarası, MAC ve token taşıyabilir. report.json + * ve markdown raporlar paylaşılabilir artefaktlardır → redaksiyon SON KAPIDIR. + * + * Yaklaşım: allowlist değil denylist + yol normalizasyonu. Bu yüzden "kesin" değil, + * "makul savunma"dır; ham cihaz çıktıları AYRICA git'e girmez (docs-local/qa-runs + * .gitignore'da). + * + * Yan etkisiz. + */ + +/** Gizli veri desenleri. Sıra önemli: uzun/spesifik olan önce. */ +const SECRET_PATTERNS = Object.freeze([ + // key=value / key: value biçiminde sır taşıyan anahtar adları + [/((?:api[_-]?key|apikey|secret|token|password|passwd|pwd|storepass|keypass|authorization|bearer)\s*[:=]\s*)(["']?)([^\s"',;]{4,})\2/gi, + (_m, k, q) => `${k}${q}${q}`], + // Supabase / JWT benzeri uzun tokenlar + [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/g, ''], + // Google/Gemini tarzı anahtarlar + [/\bAIza[0-9A-Za-z_-]{20,}\b/g, ''], + // sk-/gsk_ tarzı sağlayıcı anahtarları + [/\b(?:sk|gsk|xoxb|ghp)[-_][A-Za-z0-9_-]{16,}\b/g, ''], + // MAC adresi + [/\b[0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5}\b/g, ''], + // VIN (17 hane, I/O/Q yok) — araç kimliği kişisel veridir + [/\b[A-HJ-NPR-Z0-9]{17}\b/g, ''], + // Koordinat çifti (lat,lon) + [/\b-?\d{1,3}\.\d{4,}\s*,\s*-?\d{1,3}\.\d{4,}\b/g, ''], +]); + +/** Windows/POSIX kullanıcı dizinleri — kişisel yol. */ +const HOME_PATTERNS = Object.freeze([ + /[A-Za-z]:[\\/]Users[\\/][^\\/\s"']+/gi, + /[A-Za-z]:[\\/]Documents and Settings[\\/][^\\/\s"']+/gi, + /\/home\/[^/\s"']+/g, + /\/Users\/[^/\s"']+/g, +]); + +/** + * Mutlak yolu repo-göreli veya redakte edilmiş biçime çevirir. + * - repoRoot altındaysa → "repo:/src/foo.ts" + * - kullanıcı dizini altındaysa → "/..." + * - aksi halde → sadece son iki segment ("…/build-tools/aapt2.exe") + */ +export function redactPath(value, repoRoot) { + if (typeof value !== 'string' || value.length === 0) return value; + const norm = value.replace(/\\/g, '/'); + if (repoRoot) { + const root = String(repoRoot).replace(/\\/g, '/').replace(/\/+$/, ''); + if (root && norm.toLowerCase().startsWith(root.toLowerCase() + '/')) { + return `repo:/${norm.slice(root.length + 1)}`; + } + if (root && norm.toLowerCase() === root.toLowerCase()) return 'repo:/'; + } + for (const re of HOME_PATTERNS) { + re.lastIndex = 0; + if (re.test(norm)) { + re.lastIndex = 0; + return norm.replace(re, ''); + } + } + // Repo da ev dizini de değil ama MUTLAK (ör. C:/Temp/carlauncher/... build dizini + // override'ı, /opt/sdk/...): makineye özgü yol rapora tam hâliyle GİRMEZ — yalnız + // son iki segment bırakılır (dosya kimliği korunur, makine topolojisi sızmaz). + if (/^[A-Za-z]:\//.test(norm) || norm.startsWith('/')) { + const parts = norm.split('/').filter(Boolean); + if (parts.length > 2) return `/${parts.slice(-2).join('/')}`; + } + return norm; +} + +/** + * Serbest metinde (ham komut çıktısı) gizli veri + kişisel yol redaksiyonu. + * `repoRoot` verilirse repo yolları repo-göreli hale getirilir. + */ +export function redactText(value, repoRoot) { + if (typeof value !== 'string' || value.length === 0) return value; + let out = value; + + if (repoRoot) { + const root = String(repoRoot).replace(/\\/g, '/').replace(/\/+$/, ''); + if (root) { + // Hem "/" hem "\" ayraçlı yazımı yakala. + const esc = root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const rootRe = new RegExp(esc.replace(/\//g, '[\\\\/]'), 'gi'); + out = out.replace(rootRe, 'repo:'); + } + } + for (const re of HOME_PATTERNS) { + re.lastIndex = 0; + out = out.replace(re, ''); + } + // Repo/ev dışındaki sürücü-mutlak yollar (ör. C:/Temp/... gradle buildDir override): + // makine topolojisi rapora girmesin — son iki segment yeter. + out = out.replace(/\b[A-Za-z]:[\\/][^\s"',;)]{4,}/g, (match) => { + const norm = match.replace(/\\/g, '/'); + const parts = norm.split('/').filter(Boolean); + return parts.length > 2 ? `/${parts.slice(-2).join('/')}` : norm; + }); + + for (const [re, replacement] of SECRET_PATTERNS) { + re.lastIndex = 0; + out = out.replace(re, replacement); + } + return out; +} + +/** + * Nesne ağacını derinlemesine redakte eder (report.json son kapısı). + * Sadece string yaprakları dokunur; sayı/boolean/null olduğu gibi kalır. + * Döngüsel referans güvenli, derinlik sınırlı (bounded — sonsuz özyineleme yok). + */ +export function redactDeep(value, repoRoot, depth = 0, seen = new WeakSet()) { + if (depth > 12) return ''; + if (typeof value === 'string') return redactText(value, repoRoot); + if (value === null || typeof value !== 'object') return value; + if (seen.has(value)) return ''; + seen.add(value); + if (Array.isArray(value)) return value.map((v) => redactDeep(v, repoRoot, depth + 1, seen)); + const out = {}; + for (const [k, v] of Object.entries(value)) out[k] = redactDeep(v, repoRoot, depth + 1, seen); + return out; +} diff --git a/qa/core/registry.mjs b/qa/core/registry.mjs new file mode 100644 index 00000000..fbc3e389 --- /dev/null +++ b/qa/core/registry.mjs @@ -0,0 +1,99 @@ +/** + * registry.mjs — Faz defteri (plugin sözleşmesi). + * + * Fazlar `definePhase()` ile TANIMLANIR (saf veri + run fonksiyonu), registry'ye + * KAYDEDİLİR, orchestrator tarafından deterministik sırayla koşulur. + * + * Sözleşme (PR-1'de dondurulmuştur — sonraki fazlar bunu genişletemez, uyar): + * { id, name, order, weight, category, requires[], safetyCritical, run(context) } + * + * Kurallar: + * - Aynı id iki kez kaydedilemez (sessiz üzerine-yazma = sessiz kanıt kaybı). + * - Sıralama deterministik: order → id (aynı order'da alfabetik). Kayıt sırası + * sonucu ETKİLEMEZ (Set/Map iteration'a bağımlı olmayan tekrarlanabilir koşu). + * + * Yan etkisiz: bu modül import edildiğinde hiçbir faz otomatik kaydolmaz. + */ +import { PHASE_CATEGORY, CAPABILITIES } from './result-types.mjs'; + +/** + * Faz tanımlar ve doğrular. Geçersiz tanım = ERKEN çöküş (test-time), + * koşu sırasında sürpriz değil. + */ +export function definePhase(def) { + const { + id, + name, + order, + weight = 1, + category = PHASE_CATEGORY.GENERAL, + requires = [], + safetyCritical = false, + run, + } = def ?? {}; + + if (!id || typeof id !== 'string') throw new TypeError('phase.id zorunlu (string)'); + if (!name || typeof name !== 'string') throw new TypeError(`[${id}] phase.name zorunlu (string)`); + if (!Number.isFinite(order)) throw new TypeError(`[${id}] phase.order zorunlu (sayı)`); + if (!Number.isFinite(weight) || weight <= 0) throw new TypeError(`[${id}] phase.weight > 0 olmalı`); + if (!Object.values(PHASE_CATEGORY).includes(category)) throw new TypeError(`[${id}] geçersiz category: ${category}`); + if (!Array.isArray(requires)) throw new TypeError(`[${id}] phase.requires dizi olmalı`); + for (const cap of requires) { + if (!CAPABILITIES.includes(cap)) throw new TypeError(`[${id}] bilinmeyen capability: ${cap}`); + } + if (typeof safetyCritical !== 'boolean') throw new TypeError(`[${id}] phase.safetyCritical boolean olmalı`); + if (typeof run !== 'function') throw new TypeError(`[${id}] phase.run(context) fonksiyon olmalı`); + + return Object.freeze({ + id, + name, + order, + weight, + category, + requires: Object.freeze([...requires]), + safetyCritical, + run, + }); +} + +/** Boş faz defteri üretir. */ +export function createRegistry() { + /** @type {Map>} */ + const phases = new Map(); + + return Object.freeze({ + /** Faz kaydeder. Duplicate id → hata. */ + register(phase) { + if (!phase || typeof phase.id !== 'string') throw new TypeError('register(phase): geçersiz faz'); + if (phases.has(phase.id)) throw new Error(`Faz zaten kayıtlı: ${phase.id} (duplicate id yasak)`); + phases.set(phase.id, phase); + return phase; + }, + + /** Birden çok faz kaydeder. */ + registerAll(list) { + for (const p of list) this.register(p); + return this; + }, + + has(id) { return phases.has(id); }, + get(id) { return phases.get(id) ?? null; }, + get size() { return phases.size; }, + + /** + * Deterministik sırada faz listesi. `allowlist` verilirse (profil.phases) + * yalnız o id'ler döner; profildeki bilinmeyen id → hata (sessiz atlama yok). + */ + list(allowlist = null) { + let items = [...phases.values()]; + if (allowlist) { + for (const id of allowlist) { + if (!phases.has(id)) throw new Error(`Profil bilinmeyen faz istiyor: ${id}`); + } + const allowed = new Set(allowlist); + items = items.filter((p) => allowed.has(p.id)); + } + return items.sort((a, b) => (a.order - b.order) || a.id.localeCompare(b.id)); + }, + }); +} diff --git a/qa/core/result-types.mjs b/qa/core/result-types.mjs new file mode 100644 index 00000000..279ee9a3 --- /dev/null +++ b/qa/core/result-types.mjs @@ -0,0 +1,222 @@ +/** + * result-types.mjs — OEM Validation Lab'in durum sözlüğü (tek gerçek kaynak). + * + * NEDEN: Faz sonuçları, verdict merdiveni ve bulgu şiddetleri Lab'in HER katmanında + * (orchestrator, scoring, verdict, raporlar, testler) kullanılıyor. Tek dosyada, + * dondurulmuş sabitler olarak tutulur → bir katman "PASSED" yazıp diğeri "PASS" + * beklemesin. + * + * V8/Hidden-class notu (CLAUDE.md §V8): Faz sonucu ve bulgu nesneleri ŞABLON + * literal ile üretilir — tüm alanlar aynı sırada, eksik olanlar null. Dinamik + * property ekleme yok. + * + * Yan etkisiz: bu modül import edildiğinde hiçbir I/O yapılmaz. + */ + +/** Faz sonucu durumları. */ +export const PHASE_RESULT = Object.freeze({ + PASS: 'PASS', + PASS_WITH_WARNINGS: 'PASS_WITH_WARNINGS', + FAIL: 'FAIL', + SKIPPED_NA: 'SKIPPED_NA', + MANUAL_PENDING: 'MANUAL_PENDING', + INCOMPLETE: 'INCOMPLETE', +}); + +/** Tüm geçerli faz sonuçları (doğrulama için). */ +export const PHASE_RESULTS = Object.freeze(Object.values(PHASE_RESULT)); + +/** + * Verdict merdiveni — DÜŞÜKTEN YÜKSEĞE. Index = rütbe. + * Bir profil `maxVerdict` ile tavan koyar; coverage modeli tavanı AYRICA düşürebilir. + */ +export const VERDICT = Object.freeze({ + REJECTED: 'REJECTED', + HOST_VERIFIED: 'HOST_VERIFIED', + OEM_READY: 'OEM_READY', + PRODUCTION_READY: 'PRODUCTION_READY', + FLAGSHIP_READY: 'FLAGSHIP_READY', +}); + +/** Verdict rütbe sırası (düşük → yüksek). */ +export const VERDICT_LADDER = Object.freeze([ + VERDICT.REJECTED, + VERDICT.HOST_VERIFIED, + VERDICT.OEM_READY, + VERDICT.PRODUCTION_READY, + VERDICT.FLAGSHIP_READY, +]); + +/** Verdict rütbesi (bilinmeyen → 0 = REJECTED). */ +export function verdictRank(verdict) { + const i = VERDICT_LADDER.indexOf(verdict); + return i < 0 ? 0 : i; +} + +/** Bulgu şiddetleri. `blocker` → verdict daima REJECTED. */ +export const SEVERITY = Object.freeze({ + BLOCKER: 'blocker', + MAJOR: 'major', + MINOR: 'minor', + INFO: 'info', +}); + +export const SEVERITIES = Object.freeze(Object.values(SEVERITY)); + +/** + * Yetenek (capability) alan adları. Faz `requires` listesi bunlardan seçer. + * Alan öneki (nokta öncesi) coverage kovasını belirler: host / device / vehicle. + * + * PR-1'de yalnız `host.*` yetenekleri GERÇEKTEN tespit edilir. `device.*` ve + * `vehicle.*` isimleri burada TANIMLI ama hiçbir faz onları sağlamaz → host-only + * koşuda device coverage YAPISAL olarak 0'dır (bkz. scoring.mjs). + */ +export const CAPABILITY = Object.freeze({ + HOST_NODE: 'host.node', // Node runtime (daima var) + HOST_REPO: 'host.repo', // repo kökü okunabilir + HOST_DIST: 'host.dist', // dist/ derlenmiş web çıktısı mevcut + HOST_APK: 'host.apk', // derlenmiş APK dosyası mevcut + HOST_AAPT: 'host.aapt', // aapt2 bulunabildi + HOST_APKSIGNER: 'host.apksigner', // apksigner bulunabildi + HOST_GRADLE: 'host.gradle', // gradlew mevcut + HOST_JAVA: 'host.java', // JRE (apksigner JAVA olmadan koşmaz) + DEVICE_ADB: 'device.adb', // (PR-2) adb transport + DEVICE_APP: 'device.app', // (PR-2) uygulama cihazda kurulu + DEVICE_SENSORS: 'device.sensors', // (PR-3) sensör okuma + DEVICE_PERF: 'device.perf', // (PR-3) FPS/bellek/termal ölçümü + VEHICLE_OBD: 'vehicle.obd', // (PR-4) canlı araç telemetrisi + VEHICLE_CAN: 'vehicle.can', // (PR-4) CAN bus +}); + +export const CAPABILITIES = Object.freeze(Object.values(CAPABILITY)); + +/** Coverage kovaları — capability önekinden türetilir. */ +export const COVERAGE_DOMAINS = Object.freeze(['host', 'device', 'vehicle']); + +/** Bir capability'nin coverage kovası ("device.adb" → "device"). */ +export function capabilityDomain(capability) { + const dot = String(capability).indexOf('.'); + const domain = dot < 0 ? String(capability) : String(capability).slice(0, dot); + return COVERAGE_DOMAINS.includes(domain) ? domain : 'host'; +} + +/** Faz kategorileri — skor ağırlık çarpanı bunlara bağlanır (thresholds.categoryMultipliers). */ +export const PHASE_CATEGORY = Object.freeze({ + GENERAL: 'general', + BUILD: 'build', + SECURITY: 'security', + PERFORMANCE: 'performance', + VEHICLE: 'vehicle', + UX: 'ux', +}); + +/** + * Bulgu (finding) şablon literali — V8 hidden-class kararlılığı için TÜM alanlar + * her zaman aynı sırada üretilir. + */ +export function createFinding({ + id, + severity = SEVERITY.INFO, + title, + detail = null, + evidence = null, + remediation = null, +}) { + if (!id || typeof id !== 'string') throw new TypeError('finding.id zorunlu (string)'); + if (!SEVERITIES.includes(severity)) throw new TypeError(`geçersiz severity: ${severity}`); + if (!title || typeof title !== 'string') throw new TypeError('finding.title zorunlu (string)'); + return Object.freeze({ + id, + severity, + title, + detail, + evidence, + remediation, + }); +} + +/** + * Faz sonucu şablon literali. Orchestrator BU şekli garanti eder — bir faz + * eksik alan döndürse bile burada tamamlanır (fail-soft). + */ +export function createPhaseResult({ + id, + name, + order = 0, + category = PHASE_CATEGORY.GENERAL, + weight = 1, + effectiveWeight = null, + requires = [], + safetyCritical = false, + result = PHASE_RESULT.INCOMPLETE, + score = 0, + scored = false, + durationMs = 0, + findings = [], + artifacts = [], + metrics = {}, + skippedReason = null, + manualFallback = null, +}) { + if (!id || typeof id !== 'string') throw new TypeError('phaseResult.id zorunlu (string)'); + if (!PHASE_RESULTS.includes(result)) throw new TypeError(`geçersiz phase result: ${result}`); + return Object.freeze({ + id, + name: name ?? id, + order, + category, + weight, + effectiveWeight, + requires: Object.freeze([...requires]), + safetyCritical, + result, + score, + scored, + durationMs, + findings: Object.freeze([...findings]), + artifacts: Object.freeze([...artifacts]), + metrics: Object.freeze({ ...metrics }), + skippedReason, + manualFallback, + }); +} + +/** + * Faz İÇİ tekil kontrol (check) sonucu — Build Validation gibi çok-kontrollü + * fazlar bunları toplayıp tek faz sonucuna yuvarlar (bkz. rollupChecks). + */ +export function createCheck({ + id, + title, + status = PHASE_RESULT.INCOMPLETE, + detail = null, + severity = SEVERITY.INFO, + metrics = {}, +}) { + if (!id || typeof id !== 'string') throw new TypeError('check.id zorunlu (string)'); + if (!PHASE_RESULTS.includes(status)) throw new TypeError(`geçersiz check status: ${status}`); + return Object.freeze({ id, title: title ?? id, status, detail, severity, metrics: Object.freeze({ ...metrics }) }); +} + +/** + * Kontrolleri tek faz sonucuna yuvarlar. + * + * KURAL (skor şişirme yasağı): + * - Hiç kontrol çalışmadıysa → INCOMPLETE (kanıt yok; PASS DEĞİL) + * - Herhangi bir FAIL → FAIL + * - Atlanan (SKIPPED_NA / MANUAL_PENDING / INCOMPLETE) kontrol varsa → en fazla + * PASS_WITH_WARNINGS (tam PASS "her kanıt toplandı" demektir) + * - Hepsi PASS → PASS + */ +export function rollupChecks(checks) { + const executed = checks.filter( + (c) => c.status === PHASE_RESULT.PASS || + c.status === PHASE_RESULT.PASS_WITH_WARNINGS || + c.status === PHASE_RESULT.FAIL, + ); + if (executed.length === 0) return PHASE_RESULT.INCOMPLETE; + if (executed.some((c) => c.status === PHASE_RESULT.FAIL)) return PHASE_RESULT.FAIL; + const hasGap = checks.length !== executed.length || + executed.some((c) => c.status === PHASE_RESULT.PASS_WITH_WARNINGS); + return hasGap ? PHASE_RESULT.PASS_WITH_WARNINGS : PHASE_RESULT.PASS; +} diff --git a/qa/core/scoring.mjs b/qa/core/scoring.mjs new file mode 100644 index 00000000..90254b93 --- /dev/null +++ b/qa/core/scoring.mjs @@ -0,0 +1,112 @@ +/** + * scoring.mjs — Coverage-aware skor. + * + * ANAYASA (bu dosyanın var oluş sebebi): + * "Atlanan test skoru ŞİŞİRMEZ." Klasik QA runner'ları atlanan testi 'yeşil' sayar + * ya da paydadan düşürür → cihaz testleri hiç koşmadan %100 çıkar. Burada: + * + * 1) SKIPPED_NA / MANUAL_PENDING → SKORA GİRMEZ (ne pay ne payda) AMA + * COVERAGE'ı DÜŞÜRÜR. Skor "yaptıklarının kalitesi", coverage "ne kadarını + * yaptığın"dır. İkisi AYRI sayıdır ve verdict İKİSİNİ birden ister. + * 2) INCOMPLETE → skora 0 olarak GİRER (koşması beklenen faz kanıt üretemedi; + * bu bir bedava geçiş değil, bir başarısızlıktır). + * 3) Coverage payda = PLANLANAN faz sayısı (profilin koşmayı taahhüt ettiği), + * pay = GERÇEKTEN kanıt üreten (PASS / PASS_WITH_WARNINGS / FAIL) faz sayısı. + * Hiç device fazı planlanmadıysa deviceCoverage = 0 — "vacuously 100%" DEĞİL. + * Host-only koşunun OEM_READY üretememesi bu satırdan gelir. + * + * Ağırlık: effectiveWeight = weight × categoryMultiplier (security/performance/ + * vehicle → ×2). Safety-critical fazlar ayrıca verdict'te sert kapıdır. + */ +import { PHASE_RESULT, COVERAGE_DOMAINS, capabilityDomain } from './result-types.mjs'; + +/** Skora GİREN sonuçlar (payda). */ +const SCORED = Object.freeze([PHASE_RESULT.PASS, PHASE_RESULT.PASS_WITH_WARNINGS, PHASE_RESULT.FAIL, PHASE_RESULT.INCOMPLETE]); +/** KANIT ÜRETEN sonuçlar (coverage payı). */ +const EXECUTED = Object.freeze([PHASE_RESULT.PASS, PHASE_RESULT.PASS_WITH_WARNINGS, PHASE_RESULT.FAIL]); + +export function isScored(result) { return SCORED.includes(result); } +export function isExecuted(result) { return EXECUTED.includes(result); } + +/** Faz kategorisine göre ağırlık çarpanı (security/performance/vehicle → ×2). */ +export function effectiveWeight(phase, thresholds) { + const multipliers = thresholds?.categoryMultipliers ?? {}; + const mult = Number.isFinite(multipliers[phase.category]) ? multipliers[phase.category] : 1; + return phase.weight * mult; +} + +/** Sonucun 0..1 puan karşılığı (thresholds.resultScores ile ayarlanabilir). */ +export function resultScore(result, thresholds) { + const table = thresholds?.resultScores ?? {}; + const value = table[result]; + if (Number.isFinite(value)) return Math.max(0, Math.min(1, value)); + switch (result) { + case PHASE_RESULT.PASS: return 1; + case PHASE_RESULT.PASS_WITH_WARNINGS: return 0.75; + default: return 0; // FAIL, INCOMPLETE + } +} + +/** + * Bir fazın hangi coverage kovasına ait olduğunu belirler: + * `requires` içindeki EN YÜKSEK kova (vehicle > device > host). requires boşsa host. + * Gerekçe: cihaz gerektiren bir faz cihaz kanıtıdır; host'ta atlanması host + * coverage'ını değil DEVICE coverage'ını düşürmelidir. + */ +export function phaseDomain(phase) { + const domains = (phase.requires ?? []).map(capabilityDomain); + if (domains.includes('vehicle')) return 'vehicle'; + if (domains.includes('device')) return 'device'; + return 'host'; +} + +/** + * @param {Array} results createPhaseResult çıktıları (planlanan TÜM fazlar — atlananlar dahil) + * @param {object} thresholds + * @returns {{score:number, weighted:{earned:number,possible:number}, coverage:object, counts:object}} + */ +export function computeScore(results, thresholds) { + let earned = 0; + let possible = 0; + + const coverage = {}; + for (const d of COVERAGE_DOMAINS) coverage[d] = { planned: 0, executed: 0, skipped: 0, manual: 0, ratio: 0 }; + + const counts = { PASS: 0, PASS_WITH_WARNINGS: 0, FAIL: 0, SKIPPED_NA: 0, MANUAL_PENDING: 0, INCOMPLETE: 0 }; + + for (const r of results) { + counts[r.result] = (counts[r.result] ?? 0) + 1; + + const domain = phaseDomain(r); + const bucket = coverage[domain]; + bucket.planned += 1; + if (isExecuted(r.result)) bucket.executed += 1; + if (r.result === PHASE_RESULT.SKIPPED_NA) bucket.skipped += 1; + if (r.result === PHASE_RESULT.MANUAL_PENDING) bucket.manual += 1; + + if (!isScored(r.result)) continue; // SKIPPED_NA / MANUAL_PENDING → skor DIŞI + + const w = Number.isFinite(r.effectiveWeight) ? r.effectiveWeight : effectiveWeight(r, thresholds); + possible += w; + earned += w * resultScore(r.result, thresholds); + } + + for (const d of COVERAGE_DOMAINS) { + const b = coverage[d]; + // planned === 0 → ratio 0 (boş küme "tam kapsandı" SAYILMAZ). + b.ratio = b.planned > 0 ? b.executed / b.planned : 0; + } + + const score = possible > 0 ? (earned / possible) * 100 : 0; + + return Object.freeze({ + score: Math.round(score * 10) / 10, + weighted: Object.freeze({ earned: Math.round(earned * 1000) / 1000, possible: Math.round(possible * 1000) / 1000 }), + coverage: Object.freeze({ + host: Object.freeze(coverage.host), + device: Object.freeze(coverage.device), + vehicle: Object.freeze(coverage.vehicle), + }), + counts: Object.freeze(counts), + }); +} diff --git a/qa/core/verdict.mjs b/qa/core/verdict.mjs new file mode 100644 index 00000000..73cceca3 --- /dev/null +++ b/qa/core/verdict.mjs @@ -0,0 +1,116 @@ +/** + * verdict.mjs — Skor + coverage → tek OEM kararı. + * + * ANAYASA: "Cihaz kanıtı olmadan üretim iddiası olmaz." + * Verdict merdiveni yalnız AŞAĞI inebilen üç bağımsız kapıdan geçer: + * + * KAPI 1 — RED (sert): blocker bulgu · safety-critical faz FAIL/INCOMPLETE · + * asgari skorun altı → REJECTED. Coverage ne olursa olsun. + * KAPI 2 — COVERAGE TAVANI: deviceCoverage 0 ise tavan HOST_VERIFIED; + * vehicleCoverage 0 ise tavan (en fazla) OEM_READY. Skor 100 olsa bile + * YÜKSELTMEZ — kanıt yoksa iddia yok. + * KAPI 3 — PROFİL TAVANI: profile.maxVerdict (host-only → HOST_VERIFIED). + * + * Sonuç = min(skor merdiveni, coverage tavanı, profil tavanı). + * "Yalnız aşağı" olması yapısaldır: hiçbir kapı verdict'i YÜKSELTEMEZ. + */ +import { VERDICT, VERDICT_LADDER, verdictRank, PHASE_RESULT, SEVERITY } from './result-types.mjs'; + +/** Skorun tek başına izin verdiği en yüksek verdict. */ +function verdictFromScore(score, thresholds) { + const min = thresholds?.minScore ?? {}; + const ladder = [ + [VERDICT.FLAGSHIP_READY, min[VERDICT.FLAGSHIP_READY] ?? 97], + [VERDICT.PRODUCTION_READY, min[VERDICT.PRODUCTION_READY] ?? 92], + [VERDICT.OEM_READY, min[VERDICT.OEM_READY] ?? 85], + [VERDICT.HOST_VERIFIED, min[VERDICT.HOST_VERIFIED] ?? 70], + ]; + for (const [verdict, threshold] of ladder) { + if (score >= threshold) return verdict; + } + return VERDICT.REJECTED; +} + +/** Coverage'ın izin verdiği en yüksek verdict (kanıt tavanı). */ +function verdictFromCoverage(coverage, thresholds) { + const minDevice = thresholds?.minDeviceCoverage ?? {}; + const minVehicle = thresholds?.minVehicleCoverage ?? {}; + const device = coverage?.device?.ratio ?? 0; + const vehicle = coverage?.vehicle?.ratio ?? 0; + + // Cihaz kanıtı YOK → host doğrulamasının ötesine geçilemez. (Yapısal kilit.) + if (device <= 0) return VERDICT.HOST_VERIFIED; + + const meets = (verdict) => + device >= (minDevice[verdict] ?? 0) && + vehicle >= (minVehicle[verdict] ?? 0); + + if (meets(VERDICT.FLAGSHIP_READY)) return VERDICT.FLAGSHIP_READY; + if (meets(VERDICT.PRODUCTION_READY)) return VERDICT.PRODUCTION_READY; + if (meets(VERDICT.OEM_READY)) return VERDICT.OEM_READY; + return VERDICT.HOST_VERIFIED; +} + +/** İki verdict'in düşüğü. */ +function lower(a, b) { return verdictRank(a) <= verdictRank(b) ? a : b; } + +/** + * @param {{results:Array, score:number, coverage:object, thresholds:object, profile:object}} input + * @returns {{verdict:string, reasons:string[], caps:{score:string, coverage:string, profile:string}}} + */ +export function decideVerdict({ results = [], score = 0, coverage = {}, thresholds = {}, profile = {} }) { + const reasons = []; + + // ── KAPI 1: sert redler ────────────────────────────────────────────────── + const blockers = results.flatMap((r) => + (r.findings ?? []).filter((f) => f.severity === SEVERITY.BLOCKER).map((f) => ({ phase: r.id, finding: f })), + ); + if (blockers.length > 0) { + for (const b of blockers) reasons.push(`BLOCKER bulgu (${b.phase}): ${b.finding.title}`); + return Object.freeze({ + verdict: VERDICT.REJECTED, + reasons: Object.freeze(reasons), + caps: Object.freeze({ score: VERDICT.REJECTED, coverage: VERDICT.REJECTED, profile: profile.maxVerdict ?? VERDICT.FLAGSHIP_READY }), + }); + } + + const safetyFailures = results.filter( + (r) => r.safetyCritical && + (r.result === PHASE_RESULT.FAIL || r.result === PHASE_RESULT.INCOMPLETE), + ); + if (safetyFailures.length > 0) { + for (const r of safetyFailures) { + reasons.push(`Safety-critical faz kanıt üretemedi: ${r.id} → ${r.result}`); + } + return Object.freeze({ + verdict: VERDICT.REJECTED, + reasons: Object.freeze(reasons), + caps: Object.freeze({ score: VERDICT.REJECTED, coverage: VERDICT.REJECTED, profile: profile.maxVerdict ?? VERDICT.FLAGSHIP_READY }), + }); + } + + // ── KAPI 2 + 3: tavanlar ───────────────────────────────────────────────── + const scoreCap = verdictFromScore(score, thresholds); + const coverageCap = verdictFromCoverage(coverage, thresholds); + const profileCap = VERDICT_LADDER.includes(profile.maxVerdict) ? profile.maxVerdict : VERDICT.FLAGSHIP_READY; + + let verdict = lower(lower(scoreCap, coverageCap), profileCap); + + if (scoreCap === VERDICT.REJECTED) { + reasons.push(`Skor asgari eşiğin altında: ${score} < ${thresholds?.minScore?.[VERDICT.HOST_VERIFIED] ?? 70}`); + verdict = VERDICT.REJECTED; + } + if ((coverage?.device?.ratio ?? 0) <= 0) { + reasons.push('Cihaz coverage = 0 → tavan HOST_VERIFIED (gerçek cihaz kanıtı yok).'); + } + if (profileCap !== VERDICT.FLAGSHIP_READY) { + reasons.push(`Profil tavanı: ${profileCap} (profil: ${profile.id ?? 'bilinmiyor'}).`); + } + if (reasons.length === 0) reasons.push(`Tüm kapılar geçildi (skor ${score}).`); + + return Object.freeze({ + verdict, + reasons: Object.freeze(reasons), + caps: Object.freeze({ score: scoreCap, coverage: coverageCap, profile: profileCap }), + }); +} diff --git a/qa/index.mjs b/qa/index.mjs new file mode 100644 index 00000000..ac17eafc --- /dev/null +++ b/qa/index.mjs @@ -0,0 +1,225 @@ +/** + * index.mjs — OEM Validation Lab girişi (runner + CLI). + * + * KULLANIM + * node qa/index.mjs --profile host-only → koşu (qa:oem:host) + * node qa/index.mjs --validate-profile → profil şema doğrulaması (qa:oem:validate-profile) + * node qa/index.mjs --render-only [runDir] → mevcut report.json'dan markdown'ları yeniden üret (qa:oem:report) + * + * İMPORT YAN ETKİSİZ: bu dosyayı import etmek HİÇBİR şey çalıştırmaz. main() yalnız + * dosya DOĞRUDAN node ile çağrıldığında koşar (testler runLab'i doğrudan çağırır). + * + * KAPSAM (PR-1): host lane. Gerçek cihaz (adb) transport'u YOK — context.exec + * host lane'de adb'yi REDDEDER. + */ +import { readFileSync, existsSync, readdirSync, mkdirSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { createRegistry } from './core/registry.mjs'; +import { createContext, detectHostCapabilities } from './core/context.mjs'; +import { createArtifactStore } from './core/artifact-store.mjs'; +import { runPhases } from './core/orchestrator.mjs'; +import { computeScore } from './core/scoring.mjs'; +import { decideVerdict } from './core/verdict.mjs'; +import { buildReport } from './reports/report-schema.mjs'; +import { writeReportJson } from './reports/json-writer.mjs'; +import { writeMarkdownReports } from './reports/markdown-writer.mjs'; +import { validateProfile } from './profiles/_schema.mjs'; +import { VERDICT } from './core/result-types.mjs'; + +import buildValidationPhase from './phases/01-build-validation.mjs'; + +const QA_DIR = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(QA_DIR, '..'); + +/** PR-1'in faz kataloğu. Yeni faz = burada bir satır (+ profil listesine ekleme). */ +export const BUILT_IN_PHASES = Object.freeze([buildValidationPhase]); + +/** Kayıtlı fazlarla dolu registry. */ +export function createLabRegistry(phases = BUILT_IN_PHASES) { + const registry = createRegistry(); + for (const p of phases) registry.register(p); + return registry; +} + +export function loadProfile(profileId, repoRoot = REPO_ROOT) { + const file = join(repoRoot, 'qa', 'profiles', `${profileId}.json`); + if (!existsSync(file)) throw new Error(`Profil bulunamadı: ${profileId} (${file})`); + const profile = JSON.parse(readFileSync(file, 'utf8')); + const { valid, errors } = validateProfile(profile); + if (!valid) throw new Error(`Profil geçersiz (${profileId}):\n - ${errors.join('\n - ')}`); + return profile; +} + +export function loadThresholds(name = 'global', repoRoot = REPO_ROOT) { + const file = join(repoRoot, 'qa', 'thresholds', `${name}.json`); + if (!existsSync(file)) throw new Error(`Eşik dosyası bulunamadı: ${name}`); + return JSON.parse(readFileSync(file, 'utf8')); +} + +/** Koşu klasörü adı: 2026-07-11T15-04-22Z (dosya sistemi güvenli, sıralanabilir). */ +export function runIdFromDate(date) { + return date.toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z'); +} + +/** + * Lab'i koşar. Tüm bağımlılıklar enjekte edilebilir → testte diske yazmadan çalışır. + * + * @param {{profileId?:string, repoRoot?:string, outRoot?:string|null, now?:Date, registry?:object, logger?:function, env?:object}} opts + * @returns {Promise<{report:object, reportDir:string|null, verdict:string}>} + */ +export async function runLab(opts = {}) { + const repoRoot = opts.repoRoot ?? REPO_ROOT; + const profileId = opts.profileId ?? 'host-only'; + const now = opts.now ?? new Date(); + const runId = runIdFromDate(now); + const registry = opts.registry ?? createLabRegistry(); + const logger = opts.logger ?? ((line) => console.log(line)); + const env = opts.env ?? process.env; + + const profile = opts.profile ?? loadProfile(profileId, repoRoot); + const thresholds = opts.thresholds ?? loadThresholds('global', repoRoot); + + // outRoot === null → dry-run (diske yazma yok; testler böyle koşar) + const outRoot = opts.outRoot === null ? null : (opts.outRoot ?? join(repoRoot, 'docs-local', 'qa-runs')); + const reportDir = outRoot ? join(outRoot, runId) : null; + if (reportDir) mkdirSync(join(reportDir, 'artifacts'), { recursive: true }); + if (reportDir) mkdirSync(join(reportDir, 'raw'), { recursive: true }); + + const artifacts = createArtifactStore({ + baseDir: reportDir, + repoRoot, + maxFiles: thresholds?.artifacts?.maxFiles, + maxBytesPerFile: thresholds?.artifacts?.maxBytesPerFile, + }); + + // Yalnız HOST yetenekleri — device.* tespit edilmez (yapısal). + const detected = opts.capabilities + ? { capabilities: opts.capabilities, paths: opts.paths ?? {} } + : detectHostCapabilities(repoRoot, { variant: profile?.build?.variant ?? 'debug', env }); + const { capabilities, paths } = detected; + + const startedAt = now.toISOString(); + const context = createContext({ + repoRoot, runId, startedAt, profile, thresholds, capabilities, paths, artifacts, logger, + }); + + context.log(`🔬 OEM Validation Lab — profil: ${profile.name} · lane: ${profile.lane} · runId: ${runId}`); + context.log(` Yetenekler: ${capabilities.join(', ')}`); + + const results = await runPhases(registry, context); + const scoring = computeScore(results, thresholds); + const verdict = decideVerdict({ results, score: scoring.score, coverage: scoring.coverage, thresholds, profile }); + + const report = buildReport({ + runId, + startedAt, + finishedAt: new Date(now.getTime() + (results.reduce((s, r) => s + r.durationMs, 0))).toISOString(), + repoRoot, + profile, + thresholds, + capabilities, + results, + scoring, + verdict, + environment: { platform: process.platform, nodeVersion: process.version, ci: Boolean(env.CI) }, + logs: context.logs(), + }); + + if (reportDir) { + await writeReportJson(reportDir, report); + writeMarkdownReports(reportDir, report); + } + + return { report, reportDir, verdict: report.verdict.value }; +} + +/* ──────────────────────────────────────────────────────────────────────────── + * CLI + * ──────────────────────────────────────────────────────────────────────────── */ + +function parseArgs(argv) { + const args = { profile: 'host-only', mode: 'run', runDir: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--profile') args.profile = argv[++i] ?? 'host-only'; + else if (a === '--validate-profile') { args.mode = 'validate'; if (argv[i + 1] && !argv[i + 1].startsWith('--')) args.profile = argv[++i]; } + else if (a === '--render-only') { args.mode = 'render'; if (argv[i + 1] && !argv[i + 1].startsWith('--')) args.runDir = argv[++i]; } + } + return args; +} + +/** En son koşu klasörü (isim sıralanabilir → sonuncusu en yenisi). */ +function latestRunDir(repoRoot) { + const root = join(repoRoot, 'docs-local', 'qa-runs'); + if (!existsSync(root)) return null; + const dirs = readdirSync(root, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort(); + return dirs.length > 0 ? join(root, dirs[dirs.length - 1]) : null; +} + +async function main(argv) { + const args = parseArgs(argv); + + if (args.mode === 'validate') { + const dir = join(REPO_ROOT, 'qa', 'profiles'); + const files = readdirSync(dir).filter((f) => f.endsWith('.json')); + let bad = 0; + for (const f of files) { + const id = f.replace(/\.json$/, ''); + const profile = JSON.parse(readFileSync(join(dir, f), 'utf8')); + const { valid, errors } = validateProfile(profile); + if (valid) { + console.log(`✅ ${id} — geçerli (lane: ${profile.lane}, tavan: ${profile.maxVerdict})`); + } else { + bad++; + console.error(`❌ ${id} — GEÇERSİZ:\n - ${errors.join('\n - ')}`); + } + } + // Fazlar da doğrulanır: profil bilinmeyen faz isteyemez. + const registry = createLabRegistry(); + for (const f of files) { + const profile = JSON.parse(readFileSync(join(dir, f), 'utf8')); + for (const id of profile.phases ?? []) { + if (!registry.has(id)) { bad++; console.error(`❌ ${profile.id} — bilinmeyen faz: ${id}`); } + } + } + return bad === 0 ? 0 : 1; + } + + if (args.mode === 'render') { + const runDir = args.runDir ? resolve(args.runDir) : latestRunDir(REPO_ROOT); + if (!runDir || !existsSync(join(runDir, 'report.json'))) { + console.error('❌ report.json bulunamadı — önce `npm run qa:oem:host` koş.'); + return 2; + } + const report = JSON.parse(readFileSync(join(runDir, 'report.json'), 'utf8')); + const written = writeMarkdownReports(runDir, report); + console.log(`📄 Markdown raporlar report.json'dan yeniden üretildi:\n ${written.map((w) => w.replace(REPO_ROOT, 'repo:')).join('\n ')}`); + return 0; + } + + const { report, reportDir } = await runLab({ profileId: args.profile }); + const icon = report.verdict.value === VERDICT.REJECTED ? '⛔' : '🟡'; + console.log(''); + console.log(`${icon} VERDICT: ${report.verdict.value} · Skor: ${report.score.value}/100`); + for (const r of report.verdict.reasons) console.log(` • ${r}`); + if (reportDir) console.log(`\n📁 Rapor: ${reportDir.replace(REPO_ROOT, 'repo:')}`); + console.log(' (report.json tek gerçek kaynak — markdown\'lar ondan render edildi)'); + + return report.verdict.value === VERDICT.REJECTED ? 1 : 0; +} + +// Yalnız doğrudan çalıştırıldığında koş (import yan etkisiz). +const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) { + main(process.argv.slice(2)) + .then((code) => { process.exitCode = code; }) + .catch((err) => { + console.error(`💥 Lab çöktü: ${err?.message ?? err}`); + process.exitCode = 2; + }); +} diff --git a/qa/phases/01-build-validation.mjs b/qa/phases/01-build-validation.mjs new file mode 100644 index 00000000..6aab5d45 --- /dev/null +++ b/qa/phases/01-build-validation.mjs @@ -0,0 +1,415 @@ +/** + * 01-build-validation.mjs — FAZ 1: Build Validation (host-only, cihaz GEREKTİRMEZ). + * + * SORU: "Cihaza gidecek paket gerçekten bizim paketimiz mi, imzalı mı, beklenen + * izinlerle mi, eski head unit WebView'ında parse edilebilir mi?" + * + * MEVCUT ARAÇLARI YENİDEN KULLANIR (yeni bağımlılık YOK): + * - scripts/verify-webview-compat.mjs → ES2015 boot-sözdizimi kapısı (apk:safe'in kapısı) + * - android/build.gradle buildDir override → APK'nın gerçek yeri + * - version.properties → sürüm tek kaynağı + * - Android SDK build-tools: aapt2 (badging), apksigner (imza) + * + * FAIL-SOFT: aapt2/apksigner/APK/dist yoksa Lab ÇÖKMEZ — ilgili KONTROL + * SKIPPED_NA olur, coverage düşer, faz en fazla PASS_WITH_WARNINGS alır. + * "Kontrol koşamadı" ASLA "kontrol geçti" sayılmaz. + */ +import { existsSync, createReadStream, readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { + definePhase, phaseOutcome, check, finding, rollupChecks, + PHASE_RESULT, PHASE_CATEGORY, SEVERITY, CAPABILITY, +} from './_phase.mjs'; + +/* + * TESPİT TEK YERDE: APK / aapt2 / apksigner yollarını context TAŞIR + * (detectHostCapabilities → context.paths). Faz KENDİ BAŞINA arama YAPMAZ — + * yoksa enjekte edilen "araç yok" senaryosu sessizce gerçek SDK'ya düşer ve + * fail-soft davranışı test edilemez hale gelir (bu hatayı testler yakaladı). + */ + +/** Dosyanın SHA-256'sı — stream (büyük APK belleğe alınmaz). */ +export function sha256File(path) { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + const rs = createReadStream(path); + rs.on('error', reject); + rs.on('data', (chunk) => hash.update(chunk)); + rs.on('end', () => resolve(hash.digest('hex'))); + }); +} + +/** aapt2 badging çıktısını ayrıştırır. (Test edilebilir — dışa açık.) */ +export function parseBadging(stdout) { + const out = { + packageName: null, + versionCode: null, + versionName: null, + permissions: [], + debuggable: false, + }; + const pkg = stdout.match(/package:\s*name='([^']+)'\s*versionCode='([^']*)'\s*versionName='([^']*)'/); + if (pkg) { + out.packageName = pkg[1]; + out.versionCode = pkg[2]; + out.versionName = pkg[3]; + } + const permRe = /uses-permission:\s*name='([^']+)'/g; + let m; + while ((m = permRe.exec(stdout)) !== null) out.permissions.push(m[1]); + out.debuggable = /application-debuggable/.test(stdout); + return out; +} + +/** + * İzin farkı — sözleşme (manifest-expectations.json) ile APK'nın GERÇEK izin yüzeyi. + * `unexpectedCritical` = sözleşmede olmayan VE kritik/yasaklı → blocker. + */ +export function diffPermissions(found, expect) { + // Sözleşme = kaynak manifest izinleri ∪ manifest-merge ile kütüphanelerden GELEN + // izinler. (Kaynak manifest, sevk edilen APK'nın izin yüzeyine EŞİT DEĞİLDİR — + // bu farkı ilk host koşusu ortaya çıkardı.) + const allowedList = [...(expect?.allowedPermissions ?? []), ...(expect?.mergedPermissions ?? [])]; + const allowed = new Set(allowedList); + const critical = new Set(expect?.criticalPermissions ?? []); + const forbidden = new Set(expect?.forbiddenPermissions ?? []); + const unexpected = found.filter((p) => !allowed.has(p)); + return { + unexpected, + banned: found.filter((p) => forbidden.has(p)), + missing: allowedList.filter((p) => !found.includes(p)), + unexpectedCritical: unexpected.filter((p) => critical.has(p) || forbidden.has(p)), + }; +} + +/** version.properties tek-kaynak sürümü. */ +export function readVersionProperties(repoRoot) { + const file = join(repoRoot, 'android', 'version.properties'); + const alt = join(repoRoot, 'version.properties'); + const path = existsSync(file) ? file : (existsSync(alt) ? alt : null); + if (!path) return null; + try { + const text = readFileSync(path, 'utf8'); + const code = text.match(/VERSION_CODE\s*=\s*(\S+)/); + const name = text.match(/VERSION_NAME\s*=\s*(\S+)/); + return { versionCode: code?.[1] ?? null, versionName: name?.[1] ?? null, path }; + } catch { + return null; + } +} + +export default definePhase({ + id: 'build-validation', + name: 'Build Validation — paket bütünlüğü, imza, izin yüzeyi, WebView uyumu', + order: 10, + weight: 3, + category: PHASE_CATEGORY.BUILD, + requires: [CAPABILITY.HOST_REPO], // APK/dist/aapt YOKSA faz yine koşar; kontroller tek tek atlanır + safetyCritical: false, + + async run(context) { + const { repoRoot, profile } = context; + const variant = profile?.build?.variant ?? 'debug'; + const expect = loadExpectations(repoRoot); + const checks = []; + const findings = []; + const artifacts = []; + const metrics = { variant, apkBytes: null, sha256: null, packageName: null, versionName: null, versionCode: null, permissionCount: null }; + + /* ── 1) Web build çıktısı (dist/) ─────────────────────────────────── */ + const distIndex = join(repoRoot, 'dist', 'index.html'); + const hasDist = existsSync(distIndex); + checks.push(check({ + id: 'web-dist', + title: 'Web build çıktısı (dist/index.html)', + status: hasDist ? PHASE_RESULT.PASS : PHASE_RESULT.SKIPPED_NA, + detail: hasDist ? 'dist/index.html mevcut' : 'dist/ yok — `npm run build` çalıştırılmamış (kontrol atlandı, GEÇTİ SAYILMAZ)', + })); + + /* ── 2) Eski WebView boot-sözdizimi kapısı (mevcut script) ────────── */ + const compatScript = join(repoRoot, 'scripts', 'verify-webview-compat.mjs'); + if (hasDist && existsSync(compatScript)) { + const res = await context.exec(process.execPath, [compatScript], { timeoutMs: 60_000 }); + const ref = context.artifacts.writeRaw('compat-verify.txt', `${res.stdout}\n${res.stderr}`); + artifacts.push(ref); + checks.push(check({ + id: 'webview-compat', + title: 'Eski head unit WebView ES2015 boot kapısı (scripts/verify-webview-compat.mjs)', + status: res.ok ? PHASE_RESULT.PASS : PHASE_RESULT.FAIL, + detail: res.ok ? 'Modern sözdizimi sızıntısı yok' : `compat:verify düştü (exit ${res.code})`, + })); + if (!res.ok) { + findings.push(finding({ + id: 'build.webview-compat-fail', + severity: SEVERITY.BLOCKER, + title: 'Build çıktısı eski WebView\'da parse edilemiyor', + detail: 'verify-webview-compat.mjs düştü → Chrome 52-79 head unit\'lerde boot ölümü riski.', + evidence: ref.path, + remediation: 'Sızan modern sözdizimini bul (worker/bağımlılık), plugin-legacy hedefini koru.', + })); + } + } else { + checks.push(check({ + id: 'webview-compat', + title: 'Eski head unit WebView ES2015 boot kapısı', + status: PHASE_RESULT.SKIPPED_NA, + detail: hasDist ? 'scripts/verify-webview-compat.mjs bulunamadı' : 'dist/ yok — önce `npm run build`', + })); + } + + /* ── 3) APK varlığı ───────────────────────────────────────────────── */ + const apk = context.paths?.apk ?? null; + checks.push(check({ + id: 'apk-present', + title: `APK üretilmiş mi (${variant})`, + status: apk ? PHASE_RESULT.PASS : PHASE_RESULT.SKIPPED_NA, + detail: apk ? `APK bulundu: ${context.safePath(apk)}` : 'APK yok — `npm run apk:safe` çalıştırılmamış (kontrol atlandı, GEÇTİ SAYILMAZ)', + })); + + if (!apk) { + findings.push(finding({ + id: 'build.apk-missing', + severity: SEVERITY.MAJOR, + title: 'APK kanıtı yok', + detail: 'Paket bütünlüğü/imza/izin kontrolleri koşulamadı — build coverage eksik.', + remediation: '`npm run apk:safe` ile taze APK üret, sonra `npm run qa:oem:host` tekrar koş.', + })); + return finalize(checks, findings, artifacts, metrics); + } + + /* ── 4) SHA-256 (paket kimliği) ───────────────────────────────────── */ + try { + const { size } = await import('node:fs/promises').then((fs) => fs.stat(apk)); + const digest = await sha256File(apk); + metrics.apkBytes = size; + metrics.sha256 = digest; + checks.push(check({ + id: 'apk-sha256', title: 'APK SHA-256 (yayın izlenebilirliği)', + status: PHASE_RESULT.PASS, + detail: `${digest.slice(0, 16)}… (${(size / 1_048_576).toFixed(1)} MB)`, + metrics: { sha256: digest, bytes: size }, + })); + } catch (e) { + checks.push(check({ + id: 'apk-sha256', title: 'APK SHA-256', + status: PHASE_RESULT.INCOMPLETE, + detail: `Hash üretilemedi: ${String(e?.message ?? e).slice(0, 120)}`, + })); + } + + /* ── 5) aapt2 badging: paket / sürüm / izinler / debuggable ───────── */ + const aapt2 = context.paths?.aapt2 ?? null; + if (!aapt2) { + const na = 'aapt2 bulunamadı (Android SDK build-tools yok) — kontrol atlandı, GEÇTİ SAYILMAZ'; + checks.push(check({ id: 'apk-package', title: 'Paket adı doğru mu', status: PHASE_RESULT.SKIPPED_NA, detail: na })); + checks.push(check({ id: 'apk-version', title: 'Sürüm bilgisi okunuyor mu', status: PHASE_RESULT.SKIPPED_NA, detail: na })); + checks.push(check({ id: 'apk-permissions', title: 'İzin yüzeyi beklendiği gibi mi', status: PHASE_RESULT.SKIPPED_NA, detail: na })); + checks.push(check({ id: 'apk-debuggable', title: 'Release APK debug-only ayar taşıyor mu', status: PHASE_RESULT.SKIPPED_NA, detail: na })); + findings.push(finding({ + id: 'build.aapt2-missing', severity: SEVERITY.MINOR, + title: 'aapt2 yok — manifest kanıtı toplanamadı', + remediation: 'Android SDK build-tools kur veya ANDROID_HOME ayarla.', + })); + } else { + const res = await context.exec(aapt2, ['dump', 'badging', apk], { timeoutMs: 60_000 }); + const ref = context.artifacts.writeRaw('aapt2-badging.txt', `${res.stdout}\n${res.stderr}`); + artifacts.push(ref); + + if (!res.ok) { + checks.push(check({ + id: 'apk-package', title: 'Paket adı doğru mu', + status: PHASE_RESULT.INCOMPLETE, + detail: `aapt2 badging başarısız (exit ${res.code}${res.timedOut ? ', zaman aşımı' : ''})`, + })); + } else { + const badging = parseBadging(res.stdout); + metrics.packageName = badging.packageName; + metrics.versionName = badging.versionName; + metrics.versionCode = badging.versionCode; + metrics.permissionCount = badging.permissions.length; + + // 5a) paket adı + const pkgOk = badging.packageName === expect.expectedPackage; + checks.push(check({ + id: 'apk-package', title: 'Paket adı doğru mu', + status: pkgOk ? PHASE_RESULT.PASS : PHASE_RESULT.FAIL, + detail: `beklenen ${expect.expectedPackage}, bulunan ${badging.packageName ?? '—'}`, + })); + if (!pkgOk) { + findings.push(finding({ + id: 'build.package-mismatch', severity: SEVERITY.BLOCKER, + title: 'APK paket adı beklenenden farklı', + detail: `beklenen ${expect.expectedPackage}, bulunan ${badging.packageName ?? '—'}`, + evidence: ref.path, + remediation: 'android/app/build.gradle applicationId değerini kontrol et.', + })); + } + + // 5b) sürüm — version.properties ile hizalı mı + const vp = readVersionProperties(repoRoot); + const versionReadable = Boolean(badging.versionName && badging.versionCode); + const versionAligned = !vp || !vp.versionName || vp.versionName === badging.versionName; + checks.push(check({ + id: 'apk-version', title: 'Sürüm bilgisi okunuyor / tek kaynakla hizalı', + status: !versionReadable ? PHASE_RESULT.FAIL : (versionAligned ? PHASE_RESULT.PASS : PHASE_RESULT.PASS_WITH_WARNINGS), + detail: `APK ${badging.versionName ?? '—'} (code ${badging.versionCode ?? '—'})` + + (vp ? ` · version.properties ${vp.versionName ?? '—'}` : ' · version.properties yok'), + })); + if (versionReadable && !versionAligned) { + findings.push(finding({ + id: 'build.version-drift', severity: SEVERITY.MINOR, + title: 'APK sürümü version.properties ile hizalı değil (bayat APK?)', + detail: `APK ${badging.versionName} ≠ version.properties ${vp.versionName}`, + remediation: 'Taze APK üret (`npm run apk:safe`) — stale-APK tuzağı.', + })); + } + + // 5c) izin yüzeyi + const { unexpected, banned, missing, unexpectedCritical } = diffPermissions(badging.permissions, expect); + + const permStatus = (banned.length > 0 || unexpected.length > 0) + ? PHASE_RESULT.FAIL + : (missing.length > 0 ? PHASE_RESULT.PASS_WITH_WARNINGS : PHASE_RESULT.PASS); + checks.push(check({ + id: 'apk-permissions', title: 'İzin yüzeyi beklendiği gibi mi', + status: permStatus, + detail: `${badging.permissions.length} izin · beklenmeyen ${unexpected.length} · yasaklı ${banned.length} · manifest'te olup APK'da olmayan ${missing.length}`, + metrics: { unexpected, banned, missing }, + })); + if (banned.length > 0 || unexpectedCritical.length > 0) { + findings.push(finding({ + id: 'build.permission-critical', severity: SEVERITY.BLOCKER, + title: 'APK beklenmeyen KRİTİK/yasaklı izin taşıyor', + detail: [...banned, ...unexpectedCritical].join(', '), + evidence: ref.path, + remediation: 'İzni kaldır veya bilinçliyse qa/config/manifest-expectations.json sözleşmesini güncelle.', + })); + } else if (unexpected.length > 0) { + findings.push(finding({ + id: 'build.permission-drift', severity: SEVERITY.MAJOR, + title: 'APK sözleşmede olmayan izin taşıyor', + detail: unexpected.join(', '), + evidence: ref.path, + remediation: 'Bilinçliyse manifest-expectations.json güncelle; değilse (kütüphane sızıntısı) izni kaldır.', + })); + } + + // 5d) debug-only ayar release APK'da mı + const isRelease = variant === 'release'; + const debugLeak = isRelease && badging.debuggable && expect.release?.debuggableForbidden !== false; + checks.push(check({ + id: 'apk-debuggable', title: 'Release APK debug-only ayar taşıyor mu', + status: debugLeak ? PHASE_RESULT.FAIL : PHASE_RESULT.PASS, + detail: isRelease + ? (badging.debuggable ? 'android:debuggable=true — SIZINTI' : 'debuggable bayrağı yok') + : 'debug variant — kontrol uygulanmaz (release\'de zorunlu)', + })); + if (debugLeak) { + findings.push(finding({ + id: 'build.release-debuggable', severity: SEVERITY.BLOCKER, + title: 'Release APK debuggable', + detail: 'Üretim paketinde android:debuggable=true → uzaktan hata ayıklama yüzeyi açık.', + evidence: ref.path, + remediation: 'buildTypes.release içinde debuggable false olmalı.', + })); + } + } + } + + /* ── 6) İmza doğrulaması ────────────────────────────────────────────── + * ÜÇ AYRI DURUM (karıştırmak yanlış alarm üretir): + * (a) araç yok (apksigner/Java) → SKIPPED_NA — "koşamadık", APK suçlu değil + * (b) araç koştu, çuvalladı → INCOMPLETE — ortam sorunu, kanıt yok + * (c) araç koştu, imza geçersiz → FAIL — GERÇEK kusur + */ + const apksigner = context.paths?.apksigner ?? null; + const javaHome = context.paths?.javaHome ?? null; + + if (!apksigner || !javaHome) { + const eksik = !apksigner ? 'apksigner' : 'Java (JAVA_HOME)'; + checks.push(check({ + id: 'apk-signature', title: 'APK imzası doğrulanabiliyor mu', + status: PHASE_RESULT.SKIPPED_NA, + detail: `${eksik} bulunamadı — kontrol atlandı, GEÇTİ SAYILMAZ (APK kusurlu demek DEĞİL)`, + })); + findings.push(finding({ + id: 'build.apksigner-missing', severity: SEVERITY.MINOR, + title: `İmza kanıtı toplanamadı — ${eksik} yok`, + remediation: 'Android SDK build-tools kur; JAVA_HOME ayarla (Android Studio jbr yeterli).', + })); + } else { + const res = await context.exec(apksigner, ['verify', '--print-certs', apk], { + timeoutMs: 90_000, + env: { ...process.env, JAVA_HOME: javaHome }, + }); + const ref = context.artifacts.writeRaw('apksigner-verify.txt', `${res.stdout}\n${res.stderr}`); + artifacts.push(ref); + + const toolBroke = res.timedOut || res.code === null || Boolean(res.error) || + /JAVA_HOME is not set/i.test(res.stderr); + if (res.ok) { + checks.push(check({ + id: 'apk-signature', title: 'APK imzası doğrulanabiliyor mu', + status: PHASE_RESULT.PASS, + detail: 'apksigner verify: imza geçerli', + })); + } else if (toolBroke) { + checks.push(check({ + id: 'apk-signature', title: 'APK imzası doğrulanabiliyor mu', + status: PHASE_RESULT.INCOMPLETE, + detail: `apksigner çalıştırılamadı (${res.error ?? 'ortam hatası'}) — imza HAKKINDA hüküm YOK`, + })); + findings.push(finding({ + id: 'build.apksigner-broken', severity: SEVERITY.MINOR, + title: 'apksigner koşamadı — imza kanıtı yok', + detail: res.error ?? 'ortam hatası', + evidence: ref.path, + remediation: 'JAVA_HOME / build-tools kurulumunu kontrol et.', + })); + } else { + checks.push(check({ + id: 'apk-signature', title: 'APK imzası doğrulanabiliyor mu', + status: PHASE_RESULT.FAIL, + detail: `apksigner verify: imza GEÇERSİZ (exit ${res.code})`, + })); + findings.push(finding({ + id: 'build.signature-invalid', + severity: variant === 'release' ? SEVERITY.BLOCKER : SEVERITY.MAJOR, + title: 'APK imzası doğrulanamadı', + detail: `variant=${variant} · exit ${res.code}`, + evidence: ref.path, + remediation: 'İmza yapılandırmasını kontrol et (release için keystore zorunlu).', + })); + } + } + + return finalize(checks, findings, artifacts, metrics); + }, +}); + +function finalize(checks, findings, artifacts, metrics) { + const executed = checks.filter((c) => [PHASE_RESULT.PASS, PHASE_RESULT.PASS_WITH_WARNINGS, PHASE_RESULT.FAIL].includes(c.status)); + return phaseOutcome({ + result: rollupChecks(checks), + findings, + artifacts, + metrics: { + ...metrics, + checksPlanned: checks.length, + checksExecuted: executed.length, + checkCoverage: checks.length > 0 ? Math.round((executed.length / checks.length) * 100) / 100 : 0, + checks: checks.map((c) => ({ id: c.id, status: c.status, detail: c.detail })), + }, + }); +} + +/** Beklenti sözleşmesi (yoksa boş sözleşme → kontroller INCOMPLETE olur, sessizce geçmez). */ +function loadExpectations(repoRoot) { + const file = join(repoRoot, 'qa', 'config', 'manifest-expectations.json'); + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return { expectedPackage: null, allowedPermissions: [], criticalPermissions: [], forbiddenPermissions: [], release: {} }; + } +} diff --git a/qa/phases/_phase.mjs b/qa/phases/_phase.mjs new file mode 100644 index 00000000..a9f0d0e0 --- /dev/null +++ b/qa/phases/_phase.mjs @@ -0,0 +1,48 @@ +/** + * _phase.mjs — Faz yazarları için tek giriş noktası (plugin sözleşmesi). + * + * Bir faz dosyası SADECE şunu yapar: + * + * import { definePhase, phaseOutcome, check, finding, PHASE_RESULT } from './_phase.mjs'; + * export default definePhase({ + * id: 'my-phase', name: '…', order: 20, weight: 2, + * category: PHASE_CATEGORY.SECURITY, requires: [CAPABILITY.HOST_APK], + * safetyCritical: false, + * async run(context) { … return phaseOutcome({ result, findings, metrics, artifacts }); }, + * }); + * + * Faz ASLA: process.exit çağırmaz, global state yazmaz, context'i mutasyona + * uğratmaz, kendi verdict'ini belirlemez (skor/verdict çekirdeğin işi). + */ +export { + PHASE_RESULT, + PHASE_CATEGORY, + SEVERITY, + CAPABILITY, + createFinding as finding, + createCheck as check, + rollupChecks, +} from '../core/result-types.mjs'; + +export { definePhase } from '../core/registry.mjs'; + +/** + * Faz çıktısı zarfı. Alan sırası SABİT (V8 hidden-class kararlılığı). + */ +export function phaseOutcome({ + result, + findings = [], + artifacts = [], + metrics = {}, + skippedReason = null, + manualFallback = null, +}) { + return Object.freeze({ + result, + findings: Object.freeze([...findings]), + artifacts: Object.freeze([...artifacts]), + metrics: Object.freeze({ ...metrics }), + skippedReason, + manualFallback, + }); +} diff --git a/qa/profiles/_schema.mjs b/qa/profiles/_schema.mjs new file mode 100644 index 00000000..0ca166f1 --- /dev/null +++ b/qa/profiles/_schema.mjs @@ -0,0 +1,52 @@ +/** + * _schema.mjs — Profil doğrulama (harici bağımlılık YOK — el yazımı, bounded). + * + * Profil = "bu koşu neyi taahhüt ediyor": hangi lane (host/device), hangi fazlar, + * hangi verdict tavanı. Geçersiz profil SESSİZCE varsayılana düşmez — REDDEDİLİR. + * (Sessiz fallback = kimsenin fark etmediği eksik kanıt.) + */ +import { VERDICT_LADDER } from '../core/result-types.mjs'; + +export const LANES = Object.freeze(['host', 'device', 'vehicle']); + +/** + * @returns {{valid:boolean, errors:string[]}} + */ +export function validateProfile(profile) { + const errors = []; + const p = profile; + + if (!p || typeof p !== 'object' || Array.isArray(p)) { + return { valid: false, errors: ['Profil bir nesne olmalı'] }; + } + + if (!p.id || typeof p.id !== 'string') errors.push('id zorunlu (string)'); + if (!p.name || typeof p.name !== 'string') errors.push('name zorunlu (string)'); + + if (!LANES.includes(p.lane)) errors.push(`lane geçersiz: ${p.lane} (izinli: ${LANES.join(', ')})`); + + if (!VERDICT_LADDER.includes(p.maxVerdict)) { + errors.push(`maxVerdict geçersiz: ${p.maxVerdict} (izinli: ${VERDICT_LADDER.join(', ')})`); + } + + if (!Array.isArray(p.phases) || p.phases.length === 0) { + errors.push('phases boş olamaz (koşulacak faz listesi zorunlu)'); + } else if (p.phases.some((x) => typeof x !== 'string')) { + errors.push('phases yalnız faz id (string) içerebilir'); + } + + if (p.manualFallbacks != null && (typeof p.manualFallbacks !== 'object' || Array.isArray(p.manualFallbacks))) { + errors.push('manualFallbacks nesne olmalı ({ fazId: "manuel talimat" })'); + } + + if (p.phaseTimeoutMs != null && (!Number.isFinite(p.phaseTimeoutMs) || p.phaseTimeoutMs <= 0)) { + errors.push('phaseTimeoutMs pozitif sayı olmalı'); + } + + // YAPISAL KİLİT: host lane profili device/vehicle verdict iddia EDEMEZ. + if (p.lane === 'host' && VERDICT_LADDER.indexOf(p.maxVerdict) > VERDICT_LADDER.indexOf('HOST_VERIFIED')) { + errors.push(`host lane profili maxVerdict=${p.maxVerdict} isteyemez — cihaz kanıtı yok (en fazla HOST_VERIFIED)`); + } + + return { valid: errors.length === 0, errors }; +} diff --git a/qa/profiles/host-only.json b/qa/profiles/host-only.json new file mode 100644 index 00000000..1ed0d44c --- /dev/null +++ b/qa/profiles/host-only.json @@ -0,0 +1,14 @@ +{ + "_comment": "Cihaz gerektirmeyen lane. CI'da ve mühendis makinesinde koşar. Tavan HOST_VERIFIED — cihaz kanıtı olmadan daha üstü YAPISAL olarak imkânsız (qa/core/verdict.mjs KAPI 2 + profiles/_schema.mjs kilidi).", + "id": "host-only", + "name": "Host-Only Validation (cihazsız)", + "lane": "host", + "maxVerdict": "HOST_VERIFIED", + "phases": ["build-validation"], + "phaseTimeoutMs": 600000, + "build": { + "variant": "debug", + "buildIfMissing": false + }, + "manualFallbacks": {} +} diff --git a/qa/reports/json-writer.mjs b/qa/reports/json-writer.mjs new file mode 100644 index 00000000..de265108 --- /dev/null +++ b/qa/reports/json-writer.mjs @@ -0,0 +1,30 @@ +/** + * json-writer.mjs — report.json diske yazımı (stream). + * + * Büyük raporlarda tek seferde string birleştirip belleğe almamak için + * WriteStream kullanılır; stream daima kapatılır (zero-leak). + */ +import { mkdirSync, createWriteStream } from 'node:fs'; +import { join } from 'node:path'; +import { validateReport } from './report-schema.mjs'; + +/** + * @returns {Promise} yazılan dosyanın yolu + */ +export async function writeReportJson(outDir, report) { + // Şema ihlali SENKRON throw etmez — reddedilen promise döner (çağıranlar + // tek bir hata yolu görsün; sync/async karışımı fail-soft'u bozar). + const { valid, errors } = validateReport(report); + if (!valid) throw new Error(`report.json şema ihlali: ${errors.join('; ')}`); + + mkdirSync(outDir, { recursive: true }); + const target = join(outDir, 'report.json'); + + return new Promise((resolve, reject) => { + const stream = createWriteStream(target, { encoding: 'utf8' }); + stream.on('error', reject); + stream.on('finish', () => resolve(target)); + stream.write(JSON.stringify(report, null, 2)); + stream.end(); + }); +} diff --git a/qa/reports/markdown-writer.mjs b/qa/reports/markdown-writer.mjs new file mode 100644 index 00000000..fe585337 --- /dev/null +++ b/qa/reports/markdown-writer.mjs @@ -0,0 +1,202 @@ +/** + * markdown-writer.mjs — Markdown raporlar YALNIZCA report.json'dan render edilir. + * + * Bu dosyada dosya sistemi okuması, komut çalıştırma veya yeniden hesaplama YOKTUR: + * render fonksiyonları SAF'tır (report → string). Böylece "JSON şunu diyor ama + * markdown bunu yazıyor" ayrışması yapısal olarak imkânsızdır. + * + * Üç okuyucu, üç rapor: + * - QA_REPORT.md → mühendis: her faz, her bulgu, her artefakt + * - OEM_REPORT.md → karar verici: verdict, skor, coverage, ne eksik + * - BUILD_REPORT.md → paket kimliği: hash, sürüm, imza, izin yüzeyi + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const VERDICT_ICON = Object.freeze({ + REJECTED: '⛔', + HOST_VERIFIED: '🟡', + OEM_READY: '🟢', + PRODUCTION_READY: '🟢', + FLAGSHIP_READY: '🏆', +}); + +const RESULT_ICON = Object.freeze({ + PASS: '✅', + PASS_WITH_WARNINGS: '⚠️', + FAIL: '❌', + SKIPPED_NA: '⏭️', + MANUAL_PENDING: '🖐️', + INCOMPLETE: '🚧', +}); + +const SEVERITY_ICON = Object.freeze({ blocker: '⛔', major: '🔴', minor: '🟠', info: 'ℹ️' }); + +function pct(ratio) { return `${Math.round((ratio ?? 0) * 100)}%`; } + +function coverageTable(report) { + const c = report.coverage ?? {}; + const rows = ['host', 'device', 'vehicle'].map((d) => { + const b = c[d] ?? { planned: 0, executed: 0, skipped: 0, manual: 0, ratio: 0 }; + return `| ${d} | ${b.executed}/${b.planned} | ${pct(b.ratio)} | ${b.skipped} | ${b.manual} |`; + }); + return [ + '| Alan | Kanıt üreten / planlanan | Coverage | Atlanan | Manuel bekleyen |', + '|------|--------------------------|----------|---------|-----------------|', + ...rows, + ].join('\n'); +} + +/** Mühendis raporu — tam döküm. */ +export function renderQaReport(report) { + const lines = []; + lines.push('# OEM Validation Lab — QA Raporu', ''); + lines.push(`**Koşu:** \`${report.runId}\` · **Profil:** ${report.profile.name} (\`${report.profile.lane}\` lane)`); + lines.push(`**Verdict:** ${VERDICT_ICON[report.verdict.value] ?? ''} **${report.verdict.value}** · **Skor:** ${report.score.value}/100 · **Süre:** ${(report.durationMs / 1000).toFixed(1)}s`); + lines.push(''); + + lines.push('## Coverage', '', coverageTable(report), ''); + if (!report.capabilities.deviceLayerImplemented) { + lines.push('> ⚠️ **Cihaz katmanı bu koşuda YOK** (PR-1 host foundation). Device/vehicle coverage 0 → verdict tavanı `HOST_VERIFIED`.', ''); + } + + lines.push('## Fazlar', ''); + lines.push('| # | Faz | Sonuç | Ağırlık | Süre | Bulgu |'); + lines.push('|---|-----|-------|---------|------|-------|'); + for (const p of report.phases) { + lines.push(`| ${p.order} | ${p.name} | ${RESULT_ICON[p.result] ?? ''} ${p.result} | ${p.effectiveWeight ?? p.weight} | ${(p.durationMs / 1000).toFixed(1)}s | ${p.findings.length} |`); + } + lines.push(''); + + for (const p of report.phases) { + lines.push(`### ${RESULT_ICON[p.result] ?? ''} ${p.id} — ${p.result}`, ''); + if (p.skippedReason) lines.push(`**Atlanma sebebi:** ${p.skippedReason}`, ''); + if (p.manualFallback) lines.push(`**Manuel karşılık:** ${p.manualFallback}`, ''); + + const checks = p.metrics?.checks; + if (Array.isArray(checks) && checks.length > 0) { + lines.push('| Kontrol | Durum | Detay |', '|---------|-------|-------|'); + for (const c of checks) lines.push(`| ${c.id} | ${RESULT_ICON[c.status] ?? ''} ${c.status} | ${c.detail ?? ''} |`); + lines.push(''); + } + + if (p.findings.length > 0) { + lines.push('**Bulgular:**', ''); + for (const f of p.findings) { + lines.push(`- ${SEVERITY_ICON[f.severity] ?? ''} **${f.title}** (\`${f.severity}\`)`); + if (f.detail) lines.push(` - Detay: ${f.detail}`); + if (f.remediation) lines.push(` - Çözüm: ${f.remediation}`); + if (f.evidence) lines.push(` - Kanıt: \`${f.evidence}\``); + } + lines.push(''); + } + + if (p.artifacts.length > 0) { + lines.push('**Artefaktlar:** ' + p.artifacts.map((a) => `\`${a.path}\`${a.truncated ? ' (kesildi)' : ''}`).join(', '), ''); + } + } + + lines.push('---', '', `Şema v${report.schemaVersion} · report.json tek gerçek kaynaktır; bu dosya ondan render edilmiştir.`); + return lines.join('\n') + '\n'; +} + +/** Karar verici raporu — "satılabilir mi?" */ +export function renderOemReport(report) { + const v = report.verdict.value; + const lines = []; + lines.push('# OEM Hazırlık Raporu', ''); + lines.push(`## ${VERDICT_ICON[v] ?? ''} ${v}`, ''); + lines.push(`**Skor:** ${report.score.value}/100 · **Koşu:** \`${report.runId}\` · **Profil:** ${report.profile.name}`, ''); + + lines.push('### Karar gerekçesi', ''); + for (const r of report.verdict.reasons) lines.push(`- ${r}`); + lines.push(''); + + lines.push('### Kanıt kapsamı', '', coverageTable(report), ''); + + lines.push('### Bu verdict ne DEMEK DEĞİL', ''); + if (v === 'HOST_VERIFIED') { + lines.push('- ❌ Gerçek araçta/head unit\'te çalıştığı **kanıtlanmadı**.'); + lines.push('- ❌ Performans, termal, sensör, OBD davranışı **ölçülmedi**.'); + lines.push('- ✅ Yalnız: derleme çıktısı bütünlüğü host tarafında doğrulandı.'); + lines.push('- 📌 `OEM_READY` için gerçek cihaz lane\'i (sonraki PR) gerekir — skor ne olursa olsun.'); + } else if (v === 'REJECTED') { + lines.push('- Bu paket dağıtılamaz. Aşağıdaki blocker/eksikler kapatılmalı.'); + } + lines.push(''); + + const blockers = report.phases.flatMap((p) => p.findings.filter((f) => f.severity === 'blocker' || f.severity === 'major')); + if (blockers.length > 0) { + lines.push('### Kapatılması gerekenler', ''); + for (const f of blockers) lines.push(`- ${SEVERITY_ICON[f.severity]} **${f.title}** — ${f.remediation ?? f.detail ?? ''}`); + lines.push(''); + } + + const gaps = report.phases.filter((p) => p.result === 'SKIPPED_NA' || p.result === 'MANUAL_PENDING' || p.result === 'INCOMPLETE'); + if (gaps.length > 0) { + lines.push('### Kanıt boşlukları (skoru şişirmez — coverage düşürür)', ''); + for (const p of gaps) lines.push(`- ${RESULT_ICON[p.result]} \`${p.id}\` — ${p.skippedReason ?? 'kanıt üretilemedi'}`); + lines.push(''); + } + + return lines.join('\n') + '\n'; +} + +/** Paket kimliği raporu. */ +export function renderBuildReport(report) { + const build = report.phases.find((p) => p.id === 'build-validation'); + const lines = ['# Build Doğrulama Raporu', '']; + lines.push(`**Koşu:** \`${report.runId}\``, ''); + + if (!build) { + lines.push('> Build Validation fazı bu koşuda çalışmadı.', ''); + return lines.join('\n') + '\n'; + } + + const m = build.metrics ?? {}; + lines.push(`**Sonuç:** ${RESULT_ICON[build.result] ?? ''} ${build.result} · **Kontroller:** ${m.checksExecuted ?? 0}/${m.checksPlanned ?? 0} (coverage ${pct(m.checkCoverage)})`, ''); + + lines.push('## Paket kimliği', ''); + lines.push('| Alan | Değer |', '|------|-------|'); + lines.push(`| Variant | ${m.variant ?? '—'} |`); + lines.push(`| Paket | ${m.packageName ?? '— (aapt2 yok / APK yok)'} |`); + lines.push(`| Sürüm | ${m.versionName ?? '—'} (code ${m.versionCode ?? '—'}) |`); + lines.push(`| SHA-256 | \`${m.sha256 ?? '—'}\` |`); + lines.push(`| Boyut | ${m.apkBytes ? (m.apkBytes / 1_048_576).toFixed(1) + ' MB' : '—'} |`); + lines.push(`| İzin sayısı | ${m.permissionCount ?? '—'} |`); + lines.push(''); + + if (Array.isArray(m.checks) && m.checks.length > 0) { + lines.push('## Kontroller', '', '| Kontrol | Durum | Detay |', '|---------|-------|-------|'); + for (const c of m.checks) lines.push(`| ${c.id} | ${RESULT_ICON[c.status] ?? ''} ${c.status} | ${c.detail ?? ''} |`); + lines.push(''); + } + + if (build.findings.length > 0) { + lines.push('## Bulgular', ''); + for (const f of build.findings) { + lines.push(`- ${SEVERITY_ICON[f.severity] ?? ''} **${f.title}** — ${f.detail ?? ''}`); + if (f.remediation) lines.push(` - Çözüm: ${f.remediation}`); + } + lines.push(''); + } + + return lines.join('\n') + '\n'; +} + +/** Üç markdown raporu diske yazar (yalnız report'tan render). */ +export function writeMarkdownReports(outDir, report) { + mkdirSync(outDir, { recursive: true }); + const files = [ + ['QA_REPORT.md', renderQaReport(report)], + ['OEM_REPORT.md', renderOemReport(report)], + ['BUILD_REPORT.md', renderBuildReport(report)], + ]; + const written = []; + for (const [name, content] of files) { + const target = join(outDir, name); + writeFileSync(target, content, 'utf8'); + written.push(target); + } + return written; +} diff --git a/qa/reports/report-schema.mjs b/qa/reports/report-schema.mjs new file mode 100644 index 00000000..08e24e0e --- /dev/null +++ b/qa/reports/report-schema.mjs @@ -0,0 +1,108 @@ +/** + * report-schema.mjs — report.json TEK GERÇEK KAYNAK. + * + * Markdown raporlar bu nesneden RENDER edilir; kendi başlarına veri toplamazlar + * (bir sayı iki yerde hesaplanırsa er geç ikisi ayrışır). Şema versiyonlanır — + * ileride cihaz fazları eklendiğinde tüketiciler kırılmadan uyum sağlar. + */ +import { redactDeep } from '../core/redact.mjs'; +import { VERDICT } from '../core/result-types.mjs'; + +export const REPORT_SCHEMA_VERSION = 1; + +/** + * @returns {object} dondurulmuş, redakte edilmiş rapor + */ +export function buildReport({ + runId, + startedAt, + finishedAt, + repoRoot, + profile, + thresholds, + capabilities, + results, + scoring, + verdict, + environment, + logs = [], +}) { + const report = { + schemaVersion: REPORT_SCHEMA_VERSION, + runId, + startedAt, + finishedAt, + durationMs: Math.max(0, new Date(finishedAt).getTime() - new Date(startedAt).getTime()), + + profile: { + id: profile?.id ?? null, + name: profile?.name ?? null, + lane: profile?.lane ?? null, + maxVerdict: profile?.maxVerdict ?? null, + phases: profile?.phases ?? [], + }, + + environment: { + platform: environment?.platform ?? null, + nodeVersion: environment?.nodeVersion ?? null, + ci: environment?.ci ?? false, + }, + + capabilities: { + detected: [...(capabilities ?? [])], + // Cihaz katmanı bu PR'da YOK — tüketiciler bunu açıkça görsün. + deviceLayerImplemented: false, + }, + + verdict: { + value: verdict?.verdict ?? VERDICT.REJECTED, + reasons: verdict?.reasons ?? [], + caps: verdict?.caps ?? null, + }, + + score: { + value: scoring?.score ?? 0, + weighted: scoring?.weighted ?? { earned: 0, possible: 0 }, + counts: scoring?.counts ?? {}, + }, + + coverage: scoring?.coverage ?? {}, + + phases: (results ?? []).map((r) => ({ + id: r.id, + name: r.name, + order: r.order, + category: r.category, + weight: r.weight, + effectiveWeight: r.effectiveWeight, + requires: [...(r.requires ?? [])], + safetyCritical: r.safetyCritical, + result: r.result, + scored: r.scored, + durationMs: r.durationMs, + findings: (r.findings ?? []).map((f) => ({ ...f })), + artifacts: (r.artifacts ?? []).map((a) => ({ ...a })), + metrics: { ...(r.metrics ?? {}) }, + skippedReason: r.skippedReason ?? null, + manualFallback: r.manualFallback ?? null, + })), + + thresholds: thresholds ?? {}, + logs: [...logs], + }; + + // SON KAPI: sır + kişisel yol redaksiyonu (rapor paylaşılabilir artefakttır). + return Object.freeze(redactDeep(report, repoRoot)); +} + +/** Rapor bütünlük kontrolü (yazmadan önce). */ +export function validateReport(report) { + const errors = []; + if (!report || typeof report !== 'object') return { valid: false, errors: ['rapor nesne olmalı'] }; + if (report.schemaVersion !== REPORT_SCHEMA_VERSION) errors.push(`schemaVersion ${REPORT_SCHEMA_VERSION} olmalı`); + if (!report.runId) errors.push('runId zorunlu'); + if (!report.verdict?.value) errors.push('verdict.value zorunlu'); + if (!Array.isArray(report.phases)) errors.push('phases dizi olmalı'); + if (typeof report.score?.value !== 'number') errors.push('score.value sayı olmalı'); + return { valid: errors.length === 0, errors }; +} diff --git a/qa/thresholds/global.json b/qa/thresholds/global.json new file mode 100644 index 00000000..a9def423 --- /dev/null +++ b/qa/thresholds/global.json @@ -0,0 +1,47 @@ +{ + "_comment": "Skor/verdict eşikleri. Bir eşiği DÜŞÜRMEK kanıt standardını düşürmektir — PR'da gerekçelendir.", + + "minScore": { + "HOST_VERIFIED": 70, + "OEM_READY": 85, + "PRODUCTION_READY": 92, + "FLAGSHIP_READY": 97 + }, + + "minDeviceCoverage": { + "OEM_READY": 0.6, + "PRODUCTION_READY": 0.8, + "FLAGSHIP_READY": 0.95 + }, + + "minVehicleCoverage": { + "OEM_READY": 0, + "PRODUCTION_READY": 0.5, + "FLAGSHIP_READY": 0.9 + }, + + "categoryMultipliers": { + "general": 1, + "build": 1, + "ux": 1, + "security": 2, + "performance": 2, + "vehicle": 2 + }, + + "resultScores": { + "PASS": 1, + "PASS_WITH_WARNINGS": 0.75, + "FAIL": 0, + "INCOMPLETE": 0 + }, + + "artifacts": { + "maxFiles": 200, + "maxBytesPerFile": 524288 + }, + + "exec": { + "defaultTimeoutMs": 120000 + } +} diff --git a/src/__tests__/oemValidationLab.test.ts b/src/__tests__/oemValidationLab.test.ts new file mode 100644 index 00000000..016645f4 --- /dev/null +++ b/src/__tests__/oemValidationLab.test.ts @@ -0,0 +1,795 @@ +/** + * oemValidationLab.test.ts — OEM Validation Lab (PR-1 host foundation) sözleşme testleri. + * + * KİLİTLENEN İNVARYANTLAR (bunlar zayıflatılırsa Lab yalan söyler): + * · Atlanan test SKORU ŞİŞİRMEZ — coverage'ı düşürür. + * · Cihaz kanıtı olmadan OEM_READY/PRODUCTION_READY/FLAGSHIP_READY ÜRETİLEMEZ. + * · Safety-critical FAIL veya blocker bulgu → REJECTED (skor ne olursa olsun). + * · Faz çökerse Lab çökmez (izolasyon), kalan fazlar koşar. + * · report.json TEK gerçek kaynak; markdown ondan RENDER edilir. + * · PR-1'de gerçek cihaz (adb) komutu ÇALIŞTIRILMAZ — host lane yapısal olarak reddeder. + * · Raporlarda sır / kişisel mutlak yol sızıntısı YOK. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +import { + PHASE_RESULT, VERDICT, SEVERITY, CAPABILITY, PHASE_CATEGORY, + createPhaseResult, createFinding, rollupChecks, createCheck, verdictRank, +} from '../../qa/core/result-types.mjs'; +import { createRegistry, definePhase } from '../../qa/core/registry.mjs'; +import { createContext } from '../../qa/core/context.mjs'; +import { createArtifactStore } from '../../qa/core/artifact-store.mjs'; +import { runPhases } from '../../qa/core/orchestrator.mjs'; +import { computeScore } from '../../qa/core/scoring.mjs'; +import { decideVerdict } from '../../qa/core/verdict.mjs'; +import { runCommand } from '../../qa/core/exec.mjs'; +import { redactText, redactPath, redactDeep } from '../../qa/core/redact.mjs'; +import { validateProfile } from '../../qa/profiles/_schema.mjs'; +import { buildReport, validateReport } from '../../qa/reports/report-schema.mjs'; +import { writeReportJson } from '../../qa/reports/json-writer.mjs'; +import { renderQaReport, renderOemReport, renderBuildReport } from '../../qa/reports/markdown-writer.mjs'; +import buildPhase, { parseBadging, diffPermissions, sha256File } from '../../qa/phases/01-build-validation.mjs'; +import { runLab, createLabRegistry, loadProfile, loadThresholds, runIdFromDate } from '../../qa/index.mjs'; + +const REPO_ROOT = process.cwd(); +const THRESHOLDS = loadThresholds('global', REPO_ROOT); + +/* ── yardımcılar ─────────────────────────────────────────────────────────── */ + +const tmpDirs: string[] = []; +function tmp(): string { + const d = mkdtempSync(join(tmpdir(), 'oemlab-')); + tmpDirs.push(d); + return d; +} +afterEach(() => { + while (tmpDirs.length) { + const d = tmpDirs.pop()!; + try { rmSync(d, { recursive: true, force: true }); } catch { /* temizlik best-effort */ } + } +}); + +const HOST_PROFILE = { + id: 'test-host', name: 'Test Host', lane: 'host', + maxVerdict: VERDICT.HOST_VERIFIED, phases: null as string[] | null, manualFallbacks: {} as Record, +}; + + +function ctx(overrides: any = {}) { + return createContext({ + repoRoot: overrides.repoRoot ?? REPO_ROOT, + runId: 'test-run', + startedAt: '2026-07-11T12:00:00.000Z', + profile: { ...HOST_PROFILE, ...(overrides.profile ?? {}) }, + thresholds: THRESHOLDS, + capabilities: overrides.capabilities ?? [CAPABILITY.HOST_NODE, CAPABILITY.HOST_REPO], + paths: overrides.paths ?? {}, + artifacts: overrides.artifacts ?? createArtifactStore({ baseDir: null, repoRoot: REPO_ROOT }), + logger: () => {}, + exec: overrides.exec, + }); +} + + +function fakePhase(id: string, result: string, extra: any = {}) { + return definePhase({ + id, name: `Faz ${id}`, order: extra.order ?? 10, weight: extra.weight ?? 1, + category: extra.category ?? PHASE_CATEGORY.GENERAL, + requires: extra.requires ?? [], + safetyCritical: extra.safetyCritical ?? false, + run: extra.run ?? (() => ({ result, findings: extra.findings ?? [], artifacts: [], metrics: {} })), + }); +} + + +function phaseResult(id: string, result: string, extra: any = {}) { + return createPhaseResult({ + id, name: id, order: 1, + category: extra.category ?? PHASE_CATEGORY.GENERAL, + weight: extra.weight ?? 1, + effectiveWeight: extra.effectiveWeight ?? extra.weight ?? 1, + requires: extra.requires ?? [], + safetyCritical: extra.safetyCritical ?? false, + result, + findings: extra.findings ?? [], + }); +} + +/* ── 1-4: Registry ───────────────────────────────────────────────────────── */ + +describe('Registry — faz defteri', () => { + it('1. boş registry hiçbir faz döndürmez', () => { + const r = createRegistry(); + expect(r.size).toBe(0); + expect(r.list()).toEqual([]); + }); + + it('2. faz kaydedilir ve geri okunur', () => { + const r = createRegistry(); + r.register(fakePhase('a', PHASE_RESULT.PASS)); + expect(r.size).toBe(1); + expect(r.get('a')?.id).toBe('a'); + expect(r.has('a')).toBe(true); + }); + + it('3. aynı id iki kez kaydedilemez (sessiz üzerine-yazma yok)', () => { + const r = createRegistry(); + r.register(fakePhase('dup', PHASE_RESULT.PASS)); + expect(() => r.register(fakePhase('dup', PHASE_RESULT.FAIL))).toThrow(/duplicate|zaten kayıtlı/i); + }); + + it('4. fazlar order → id ile deterministik sıralanır (kayıt sırası önemsiz)', () => { + const r = createRegistry(); + r.register(fakePhase('z', PHASE_RESULT.PASS, { order: 30 })); + r.register(fakePhase('b', PHASE_RESULT.PASS, { order: 10 })); + r.register(fakePhase('a', PHASE_RESULT.PASS, { order: 10 })); + expect(r.list().map((p: { id: string }) => p.id)).toEqual(['a', 'b', 'z']); + }); +}); + +/* ── 5-10: Orchestrator durum modeli ─────────────────────────────────────── */ + +describe('Orchestrator — izolasyon ve durum modeli', () => { + it('5. faz exception fırlatırsa Lab çökmez, sonraki fazlar koşar', async () => { + const r = createRegistry(); + r.register(fakePhase('boom', PHASE_RESULT.PASS, { + order: 1, + run: () => { throw new Error('faz patladı'); }, + })); + r.register(fakePhase('sonraki', PHASE_RESULT.PASS, { order: 2 })); + + const results = await runPhases(r, ctx()); + expect(results).toHaveLength(2); + expect(results[0].result).toBe(PHASE_RESULT.INCOMPLETE); + expect(results[0].findings[0].title).toMatch(/hata fırlattı/i); + expect(results[1].result).toBe(PHASE_RESULT.PASS); // izolasyon: kalan faz koştu + }); + + it('6. PASS sonucu kaydedilir', async () => { + const r = createRegistry(); + r.register(fakePhase('ok', PHASE_RESULT.PASS)); + const results = await runPhases(r, ctx()); + expect(results[0].result).toBe(PHASE_RESULT.PASS); + expect(results[0].scored).toBe(true); + }); + + it('7. PASS_WITH_WARNINGS sonucu kaydedilir', async () => { + const r = createRegistry(); + r.register(fakePhase('warn', PHASE_RESULT.PASS_WITH_WARNINGS)); + const results = await runPhases(r, ctx()); + expect(results[0].result).toBe(PHASE_RESULT.PASS_WITH_WARNINGS); + }); + + it('8. FAIL sonucu kaydedilir', async () => { + const r = createRegistry(); + r.register(fakePhase('bad', PHASE_RESULT.FAIL)); + const results = await runPhases(r, ctx()); + expect(results[0].result).toBe(PHASE_RESULT.FAIL); + }); + + it('9. yeteneği olmayan faz SKIPPED_NA olur (koşmaz)', async () => { + let ran = false; + const r = createRegistry(); + r.register(fakePhase('cihaz', PHASE_RESULT.PASS, { + requires: [CAPABILITY.DEVICE_ADB], + run: () => { ran = true; return { result: PHASE_RESULT.PASS }; }, + })); + const results = await runPhases(r, ctx()); + expect(ran).toBe(false); + expect(results[0].result).toBe(PHASE_RESULT.SKIPPED_NA); + expect(results[0].skippedReason).toMatch(/device\.adb/); + }); + + it('10. manuel karşılığı olan atlanmış faz MANUAL_PENDING olur', async () => { + const r = createRegistry(); + r.register(fakePhase('cihaz', PHASE_RESULT.PASS, { requires: [CAPABILITY.DEVICE_ADB] })); + const results = await runPhases(r, ctx({ + profile: { manualFallbacks: { cihaz: 'T507: elle kontrol et (ADB yok)' } }, + })); + expect(results[0].result).toBe(PHASE_RESULT.MANUAL_PENDING); + expect(results[0].manualFallback).toMatch(/T507/); + }); +}); + +/* ── 11-17: Coverage & skor ──────────────────────────────────────────────── */ + +describe('Scoring — coverage-aware skor', () => { + it('11. coverage alan bazında hesaplanır (host/device/vehicle)', () => { + const s = computeScore([ + phaseResult('h1', PHASE_RESULT.PASS), + phaseResult('d1', PHASE_RESULT.SKIPPED_NA, { requires: [CAPABILITY.DEVICE_ADB] }), + phaseResult('d2', PHASE_RESULT.SKIPPED_NA, { requires: [CAPABILITY.DEVICE_PERF] }), + ], THRESHOLDS); + + expect(s.coverage.host.executed).toBe(1); + expect(s.coverage.host.ratio).toBe(1); + expect(s.coverage.device.planned).toBe(2); + expect(s.coverage.device.executed).toBe(0); + expect(s.coverage.device.ratio).toBe(0); + }); + + it('12. atlanan faz skoru ŞİŞİRMEZ (skora girmez) ama coverage düşürür', () => { + const onlyHost = computeScore([phaseResult('h1', PHASE_RESULT.PASS)], THRESHOLDS); + const withSkipped = computeScore([ + phaseResult('h1', PHASE_RESULT.PASS), + phaseResult('d1', PHASE_RESULT.SKIPPED_NA, { requires: [CAPABILITY.DEVICE_ADB] }), + ], THRESHOLDS); + + // Skor aynı (atlanan ne pay ne payda) — ama coverage YENİ bir boşluk gösteriyor. + expect(withSkipped.score).toBe(onlyHost.score); + expect(withSkipped.coverage.device.planned).toBe(1); + expect(withSkipped.coverage.device.ratio).toBe(0); + // Ve atlanan faz "geçmiş" sayılmıyor: + expect(withSkipped.counts.SKIPPED_NA).toBe(1); + expect(withSkipped.counts.PASS).toBe(1); + }); + + it('12b. INCOMPLETE skora 0 olarak GİRER (bedava geçiş yok)', () => { + const s = computeScore([ + phaseResult('a', PHASE_RESULT.PASS), + phaseResult('b', PHASE_RESULT.INCOMPLETE), + ], THRESHOLDS); + expect(s.score).toBe(50); + }); + + it('13. hiç faz planlanmayan alanın coverage oranı 0 olur (boş küme "tam" sayılmaz)', () => { + const s = computeScore([phaseResult('h1', PHASE_RESULT.PASS)], THRESHOLDS); + expect(s.coverage.device.planned).toBe(0); + expect(s.coverage.device.ratio).toBe(0); // 1 DEĞİL — vacuous truth reddedilir + expect(s.coverage.vehicle.ratio).toBe(0); + }); + + it('17. kategori çarpanı ağırlığı büyütür (security/performance/vehicle ×2)', () => { + const secFail = computeScore([ + phaseResult('build', PHASE_RESULT.PASS, { category: PHASE_CATEGORY.BUILD, weight: 1, effectiveWeight: 1 }), + phaseResult('sec', PHASE_RESULT.FAIL, { category: PHASE_CATEGORY.SECURITY, weight: 1, effectiveWeight: 2 }), + ], THRESHOLDS); + // 1×1 kazanıldı / (1 + 2) mümkün → 33.3 (ağırlıksız olsaydı 50 olurdu) + expect(secFail.score).toBeCloseTo(33.3, 1); + expect(secFail.weighted.possible).toBe(3); + }); + + it('17b. PASS_WITH_WARNINGS tam PASS\'ten düşük puanlanır', () => { + const pass = computeScore([phaseResult('a', PHASE_RESULT.PASS)], THRESHOLDS); + const warn = computeScore([phaseResult('a', PHASE_RESULT.PASS_WITH_WARNINGS)], THRESHOLDS); + expect(warn.score).toBeLessThan(pass.score); + expect(warn.score).toBe(75); + }); +}); + +/* ── 13-16, 38: Verdict ──────────────────────────────────────────────────── */ + +describe('Verdict — kanıt olmadan iddia yok', () => { + it('13. host-only koşu HOST_VERIFIED üretir', () => { + const results = [phaseResult('build', PHASE_RESULT.PASS)]; + const s = computeScore(results, THRESHOLDS); + const v = decideVerdict({ results, score: s.score, coverage: s.coverage, thresholds: THRESHOLDS, profile: HOST_PROFILE }); + expect(v.verdict).toBe(VERDICT.HOST_VERIFIED); + }); + + it('14. device coverage 0 iken skor 100 olsa bile OEM_READY ÜRETİLEMEZ', () => { + const results = [ + phaseResult('a', PHASE_RESULT.PASS), + phaseResult('b', PHASE_RESULT.PASS), + ]; + const s = computeScore(results, THRESHOLDS); + expect(s.score).toBe(100); + + // Profil tavanı KALDIRILSA bile coverage kapısı yükselmeyi engeller. + const v = decideVerdict({ + results, score: 100, coverage: s.coverage, thresholds: THRESHOLDS, + profile: { id: 'sinirsiz', maxVerdict: VERDICT.FLAGSHIP_READY }, + }); + expect(v.verdict).toBe(VERDICT.HOST_VERIFIED); + expect(v.caps.coverage).toBe(VERDICT.HOST_VERIFIED); + expect(v.reasons.join(' ')).toMatch(/Cihaz coverage = 0/); + }); + + it('15. safety-critical faz FAIL → REJECTED (skor yüksek olsa bile)', () => { + const results = [ + phaseResult('a', PHASE_RESULT.PASS, { weight: 10, effectiveWeight: 10 }), + phaseResult('safety', PHASE_RESULT.FAIL, { safetyCritical: true, weight: 1, effectiveWeight: 1 }), + ]; + const s = computeScore(results, THRESHOLDS); + expect(s.score).toBeGreaterThan(85); // skor yüksek… + const v = decideVerdict({ results, score: s.score, coverage: s.coverage, thresholds: THRESHOLDS, profile: HOST_PROFILE }); + expect(v.verdict).toBe(VERDICT.REJECTED); // …ama güvenlik kapısı kapalı + }); + + it('15b. safety-critical faz INCOMPLETE → REJECTED (kanıt üretemedi)', () => { + const results = [phaseResult('safety', PHASE_RESULT.INCOMPLETE, { safetyCritical: true })]; + const v = decideVerdict({ results, score: 100, coverage: computeScore(results, THRESHOLDS).coverage, thresholds: THRESHOLDS, profile: HOST_PROFILE }); + expect(v.verdict).toBe(VERDICT.REJECTED); + }); + + it('16. blocker bulgu → REJECTED', () => { + const results = [phaseResult('a', PHASE_RESULT.PASS_WITH_WARNINGS, { + findings: [createFinding({ id: 'x', severity: SEVERITY.BLOCKER, title: 'İmzasız release APK' })], + })]; + const s = computeScore(results, THRESHOLDS); + const v = decideVerdict({ results, score: s.score, coverage: s.coverage, thresholds: THRESHOLDS, profile: HOST_PROFILE }); + expect(v.verdict).toBe(VERDICT.REJECTED); + expect(v.reasons.join(' ')).toMatch(/BLOCKER/); + }); + + it('16b. düşük skor → REJECTED', () => { + const results = [phaseResult('a', PHASE_RESULT.FAIL)]; + const s = computeScore(results, THRESHOLDS); + const v = decideVerdict({ results, score: s.score, coverage: s.coverage, thresholds: THRESHOLDS, profile: HOST_PROFILE }); + expect(v.verdict).toBe(VERDICT.REJECTED); + }); + + it('38. cihazsız PRODUCTION_READY/FLAGSHIP_READY matematiksel olarak imkânsız', () => { + for (const score of [70, 85, 92, 97, 100]) { + const results = [phaseResult('a', PHASE_RESULT.PASS)]; + const coverage = computeScore(results, THRESHOLDS).coverage; + const v = decideVerdict({ + results, score, coverage, thresholds: THRESHOLDS, + profile: { id: 'x', maxVerdict: VERDICT.FLAGSHIP_READY }, + }); + expect(verdictRank(v.verdict)).toBeLessThanOrEqual(verdictRank(VERDICT.HOST_VERIFIED)); + } + }); + + it('38b. cihaz kanıtı GELİRSE merdiven açılır (model ileriye dönük doğru)', () => { + const results = [ + phaseResult('h', PHASE_RESULT.PASS), + phaseResult('d1', PHASE_RESULT.PASS, { requires: [CAPABILITY.DEVICE_ADB] }), + phaseResult('d2', PHASE_RESULT.PASS, { requires: [CAPABILITY.DEVICE_PERF] }), + ]; + const s = computeScore(results, THRESHOLDS); + expect(s.coverage.device.ratio).toBe(1); + const v = decideVerdict({ + results, score: s.score, coverage: s.coverage, thresholds: THRESHOLDS, + profile: { id: 'device', maxVerdict: VERDICT.OEM_READY }, + }); + expect(v.verdict).toBe(VERDICT.OEM_READY); + }); +}); + +/* ── 18, 39: Değişmezlik ─────────────────────────────────────────────────── */ + +describe('Immutability — girdi mutasyona uğramaz', () => { + it('18. context ve faz sonucu dondurulmuştur', async () => { + const c = ctx(); + expect(Object.isFrozen(c)).toBe(true); + expect(() => { (c as unknown as Record).repoRoot = 'hack'; }).toThrow(); + + const r = createRegistry(); + r.register(fakePhase('a', PHASE_RESULT.PASS)); + const results = await runPhases(r, c); + expect(Object.isFrozen(results[0])).toBe(true); + expect(Object.isFrozen(results[0].findings)).toBe(true); + }); + + it('39. orchestrator faz tanımını / context yeteneklerini mutasyona uğratmaz', async () => { + const phase = fakePhase('a', PHASE_RESULT.PASS, { requires: [CAPABILITY.HOST_REPO] }); + const beforeRequires = [...phase.requires]; + const c = ctx(); + const capsBefore = [...c.capabilities]; + + const r = createRegistry(); + r.register(phase); + await runPhases(r, c); + + expect([...phase.requires]).toEqual(beforeRequires); + expect([...c.capabilities]).toEqual(capsBefore); + expect(Object.isFrozen(phase)).toBe(true); + }); +}); + +/* ── 19, 20: Raporlar ────────────────────────────────────────────────────── */ + +describe('Raporlar — report.json tek gerçek kaynak', () => { + + function sampleReport(): any { + const results = [phaseResult('build-validation', PHASE_RESULT.PASS_WITH_WARNINGS, { + findings: [createFinding({ id: 'f1', severity: SEVERITY.MAJOR, title: 'APK kanıtı yok' })], + })]; + const scoring = computeScore(results, THRESHOLDS); + const verdict = decideVerdict({ results, score: scoring.score, coverage: scoring.coverage, thresholds: THRESHOLDS, profile: HOST_PROFILE }); + return buildReport({ + runId: '2026-07-11T12-00-00Z', + startedAt: '2026-07-11T12:00:00.000Z', + finishedAt: '2026-07-11T12:00:05.000Z', + repoRoot: REPO_ROOT, + profile: HOST_PROFILE, + thresholds: THRESHOLDS, + capabilities: [CAPABILITY.HOST_NODE], + results, scoring, verdict, + environment: { platform: 'win32', nodeVersion: 'v20.0.0', ci: false }, + logs: [], + }); + } + + it('19. report.json şema doğrulamasından geçer ve diske yazılır', async () => { + const report = sampleReport(); + expect(validateReport(report).valid).toBe(true); + + const dir = tmp(); + const path = await writeReportJson(dir, report); + expect(existsSync(path)).toBe(true); + + const parsed = JSON.parse(readFileSync(path, 'utf8')); + expect(parsed.schemaVersion).toBe(1); + expect(parsed.verdict.value).toBe(VERDICT.HOST_VERIFIED); + expect(parsed.capabilities.deviceLayerImplemented).toBe(false); + }); + + it('19b. bozuk rapor diske YAZILMAZ (şema kapısı)', async () => { + const dir = tmp(); + await expect(writeReportJson(dir, { schemaVersion: 99 })).rejects.toThrow(/şema ihlali/i); + expect(existsSync(join(dir, 'report.json'))).toBe(false); + }); + + it('20. markdown raporlar YALNIZCA report nesnesinden render edilir (saf fonksiyon)', () => { + const report = sampleReport(); + const qa = renderQaReport(report); + const oem = renderOemReport(report); + const build = renderBuildReport(report); + + // Verdict ve skor JSON'daki değerlerle birebir aynı — ikinci bir hesap yok. + expect(qa).toContain(report.verdict.value); + expect(qa).toContain(String(report.score.value)); + expect(oem).toContain(report.verdict.value); + expect(oem).toMatch(/Gerçek araçta.*kanıtlanmadı/s); + expect(build).toContain('Build Doğrulama Raporu'); + + // Aynı girdi → aynı çıktı (deterministik, yan etkisiz) + expect(renderQaReport(report)).toBe(qa); + }); +}); + +/* ── 21, 22: Profil ──────────────────────────────────────────────────────── */ + +describe('Profil — şema doğrulama', () => { + it('21. host-only.json geçerlidir ve tavanı HOST_VERIFIED\'dır', () => { + const profile = loadProfile('host-only', REPO_ROOT); + expect(validateProfile(profile).valid).toBe(true); + expect(profile.lane).toBe('host'); + expect(profile.maxVerdict).toBe(VERDICT.HOST_VERIFIED); + expect(profile.phases).toContain('build-validation'); + }); + + it('22. geçersiz profil REDDEDİLİR (sessiz varsayılana düşmez)', () => { + expect(validateProfile(null).valid).toBe(false); + expect(validateProfile({ id: 'x' }).valid).toBe(false); + expect(validateProfile({ id: 'x', name: 'X', lane: 'uzay', maxVerdict: VERDICT.HOST_VERIFIED, phases: ['a'] }).valid).toBe(false); + expect(validateProfile({ id: 'x', name: 'X', lane: 'host', maxVerdict: 'SUPER_READY', phases: ['a'] }).valid).toBe(false); + expect(validateProfile({ id: 'x', name: 'X', lane: 'host', maxVerdict: VERDICT.HOST_VERIFIED, phases: [] }).valid).toBe(false); + }); + + it('22b. host lane profili OEM_READY tavanı İSTEYEMEZ (yapısal kilit)', () => { + const bad = validateProfile({ id: 'hile', name: 'Hile', lane: 'host', maxVerdict: VERDICT.OEM_READY, phases: ['build-validation'] }); + expect(bad.valid).toBe(false); + expect(bad.errors.join(' ')).toMatch(/cihaz kanıtı yok/i); + }); + + it('22c. profil bilinmeyen faz isteyemez', () => { + const r = createLabRegistry(); + expect(() => r.list(['olmayan-faz'])).toThrow(/bilinmeyen faz/i); + }); +}); + +/* ── 23-29, 40: Build Validation fazı ────────────────────────────────────── */ + +describe('Faz 1 — Build Validation', () => { + it('23. APK bulunamazsa faz ÇÖKMEZ: kontrol SKIPPED_NA + "kanıt yok" bulgusu', async () => { + const repo = tmp(); + const out = await buildPhase.run(ctx({ + repoRoot: repo, + paths: { apk: null, aapt2: null, apksigner: null }, + })); + + const apkCheck = out.metrics.checks.find((c: { id: string }) => c.id === 'apk-present'); + expect(apkCheck.status).toBe(PHASE_RESULT.SKIPPED_NA); + expect(out.findings.some((f: { id: string }) => f.id === 'build.apk-missing')).toBe(true); + // Atlanan kontrol "geçti" SAYILMAZ → faz tam PASS olamaz + expect(out.result).not.toBe(PHASE_RESULT.PASS); + }); + + it('24. SHA-256 stream ile doğru üretilir', async () => { + const dir = tmp(); + const file = join(dir, 'fake.apk'); + writeFileSync(file, 'CarOS Pro APK içeriği'); + const expected = createHash('sha256').update('CarOS Pro APK içeriği').digest('hex'); + await expect(sha256File(file)).resolves.toBe(expected); + }); + + it('25. aapt2 badging çıktısından paket adı ayrıştırılır', () => { + const badging = parseBadging( + "package: name='com.cockpitos.pro' versionCode='7' versionName='1.2.3' platformBuildVersionName='14'", + ); + expect(badging.packageName).toBe('com.cockpitos.pro'); + }); + + it('26. sürüm (versionName/versionCode) ayrıştırılır', () => { + const badging = parseBadging("package: name='com.cockpitos.pro' versionCode='7' versionName='1.2.3'"); + expect(badging.versionCode).toBe('7'); + expect(badging.versionName).toBe('1.2.3'); + }); + + it('26b. debuggable bayrağı tespit edilir', () => { + expect(parseBadging('application-debuggable').debuggable).toBe(true); + expect(parseBadging("package: name='x'").debuggable).toBe(false); + }); + + it('27. izin farkı hesaplanır (beklenmeyen / yasaklı / eksik / kritik)', () => { + const expectContract = { + allowedPermissions: ['android.permission.INTERNET', 'android.permission.CAMERA'], + criticalPermissions: ['android.permission.CAMERA'], + forbiddenPermissions: ['android.permission.READ_SMS'], + }; + const diff = diffPermissions( + ['android.permission.INTERNET', 'android.permission.READ_SMS', 'android.permission.NFC'], + expectContract, + ); + expect(diff.banned).toEqual(['android.permission.READ_SMS']); + expect(diff.unexpected).toEqual(['android.permission.READ_SMS', 'android.permission.NFC']); + expect(diff.missing).toEqual(['android.permission.CAMERA']); + expect(diff.unexpectedCritical).toEqual(['android.permission.READ_SMS']); + }); + + it('28. apksigner yoksa imza kontrolü SKIPPED_NA olur (fail-soft, Lab çökmez)', async () => { + const repo = tmp(); + writeFileSync(join(repo, 'app.apk'), 'sahte-apk'); + const out = await buildPhase.run(ctx({ + repoRoot: repo, + paths: { apk: join(repo, 'app.apk'), aapt2: null, apksigner: null }, + })); + const sig = out.metrics.checks.find((c: { id: string }) => c.id === 'apk-signature'); + expect(sig.status).toBe(PHASE_RESULT.SKIPPED_NA); + expect(out.findings.some((f: { id: string }) => f.id === 'build.apksigner-missing')).toBe(true); + }); + + it('29. aapt2 yoksa manifest kontrolleri SKIPPED_NA olur (fail-soft)', async () => { + const repo = tmp(); + writeFileSync(join(repo, 'app.apk'), 'sahte-apk'); + const out = await buildPhase.run(ctx({ + repoRoot: repo, + paths: { apk: join(repo, 'app.apk'), aapt2: null, apksigner: null }, + })); + for (const id of ['apk-package', 'apk-version', 'apk-permissions', 'apk-debuggable']) { + expect(out.metrics.checks.find((c: { id: string }) => c.id === id).status).toBe(PHASE_RESULT.SKIPPED_NA); + } + // SHA-256 yine de üretilmiş olmalı (araçsız da olsa kanıt toplanır) + expect(out.metrics.sha256).toMatch(/^[0-9a-f]{64}$/); + }); + + it('40. faz MEVCUT araçları yeniden kullanır (scripts/verify-webview-compat.mjs)', () => { + const src = readFileSync(join(REPO_ROOT, 'qa', 'phases', '01-build-validation.mjs'), 'utf8'); + expect(src).toContain('verify-webview-compat.mjs'); + expect(src).toContain('version.properties'); + }); +}); + +/* ── 30: Exec ────────────────────────────────────────────────────────────── */ + +describe('Exec — sınırlı alt-süreç', () => { + it('30. zaman aşımında süreç öldürülür, timer temizlenir, throw ETMEZ', async () => { + const res = await runCommand(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { timeoutMs: 400 }); + expect(res.ok).toBe(false); + expect(res.timedOut).toBe(true); + expect(res.error).toMatch(/zaman aşımı/i); + expect(res.durationMs).toBeLessThan(5000); + }, 15_000); + + it('30b. bulunamayan komut throw ETMEZ (fail-soft)', async () => { + const res = await runCommand('kesinlikle-olmayan-komut-xyz', ['--v'], { timeoutMs: 2000 }); + expect(res.ok).toBe(false); + expect(res.error).toBeTruthy(); + }, 15_000); +}); + +/* ── 31-33: Redaksiyon ───────────────────────────────────────────────────── */ + +describe('Privacy — sır ve yol redaksiyonu', () => { + it('31. sırlar redakte edilir (api key / token / JWT / MAC)', () => { + const raw = [ + 'VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdef123', + 'api_key: AIzaSyD-1234567890abcdefghijklmnopqrs', + 'storePassword=SuperGizli123', + 'Bluetooth OBD: AA:BB:CC:DD:EE:FF', + ].join('\n'); + const out = redactText(raw, REPO_ROOT); + + expect(out).not.toContain('SuperGizli123'); + expect(out).not.toContain('AIzaSyD-1234567890abcdefghijklmnopqrs'); + expect(out).not.toContain('AA:BB:CC:DD:EE:FF'); + expect(out).toMatch(/REDACTED/); + }); + + it('32. mutlak yollar repo-göreli / / olur', () => { + expect(redactPath(join(REPO_ROOT, 'src', 'App.tsx'), REPO_ROOT)).toBe('repo:/src/App.tsx'); + expect(redactPath('C:\\Users\\selim\\Desktop\\gizli\\keystore.jks', '/baska/repo')).toBe('/Desktop/gizli/keystore.jks'); + expect(redactPath('/home/selim/.android/debug.keystore', '/baska/repo')).toBe('/.android/debug.keystore'); + // Repo/ev dışı makine yolu (gradle buildDir override) → topoloji sızmaz + expect(redactPath('C:/Temp/carlauncher/app/build/outputs/apk/debug/app-debug.apk', REPO_ROOT)) + .toBe('/debug/app-debug.apk'); + }); + + it('32c. rapor metnine sızan makine yolları da olur', () => { + const out = redactText('APK bulundu: C:/Temp/carlauncher/app/build/outputs/apk/debug/app-debug.apk', REPO_ROOT); + expect(out).not.toContain('C:/Temp/carlauncher'); + expect(out).toContain('/'); + }); + + it('32b. rapor ağacı derinlemesine redakte edilir (döngü güvenli)', () => { + const tree = { a: { b: ['token=SuperGizli123', 'C:\\Users\\selim\\x.txt'] }, n: 42 }; + const out = redactDeep(tree, REPO_ROOT) as { a: { b: string[] }, n: number }; + expect(out.a.b[0]).not.toContain('SuperGizli123'); + expect(out.a.b[1]).toContain(''); + expect(out.n).toBe(42); + }); + + it('33. Windows ters-eğik çizgili yollar da redakte edilir', () => { + const winPath = REPO_ROOT.replace(/\//g, '\\'); + const out = redactText(`Yol: ${winPath}\\android\\app.apk`, REPO_ROOT); + expect(out).not.toContain(winPath); + expect(out).toContain('repo:'); + }); +}); + +/* ── 34: gitignore ───────────────────────────────────────────────────────── */ + +describe('Git hijyeni', () => { + it('34. docs-local/qa-runs git\'e girmez', () => { + const gitignore = readFileSync(join(REPO_ROOT, '.gitignore'), 'utf8'); + expect(gitignore).toMatch(/docs-local\/qa-runs/); + }); +}); + +/* ── 35: Artefakt sınırları ──────────────────────────────────────────────── */ + +describe('Artefakt deposu — bounded', () => { + it('35. dosya sayısı ve dosya boyutu sınırlıdır (kaçak faz diski dolduramaz)', () => { + const dir = tmp(); + const store = createArtifactStore({ baseDir: dir, repoRoot: REPO_ROOT, maxFiles: 2, maxBytesPerFile: 50 }); + + store.write('raw/a.txt', 'x'.repeat(500)); + store.write('raw/b.txt', 'kısa'); + const third = store.write('raw/c.txt', 'taşan'); + + expect(store.count).toBe(2); + expect(third.dropped).toBe(true); + expect(store.droppedCount).toBe(1); + expect(existsSync(join(dir, 'raw', 'c.txt'))).toBe(false); + expect(readFileSync(join(dir, 'raw', 'a.txt'), 'utf8')).toMatch(/ARTEFAKT KESİLDİ/); + }); + + it('35b. artefakt koşu klasörü dışına yazamaz (path traversal)', () => { + const dir = tmp(); + const store = createArtifactStore({ baseDir: dir, repoRoot: REPO_ROOT }); + expect(() => store.write('../../kacak.txt', 'x')).toThrow(/dışına/i); + }); +}); + +/* ── 36, 37: PR-1 kapsam kilitleri ───────────────────────────────────────── */ + +describe('PR-1 kapsam kilitleri', () => { + it('36. qa/index.mjs import edilmesi hiçbir şey ÇALIŞTIRMAZ (yan etkisiz)', () => { + // Modül zaten yukarıda import edildi; koşu tetiklenmiş olsaydı burada + // rapor klasörü oluşurdu. + expect(typeof runLab).toBe('function'); + expect(typeof createLabRegistry).toBe('function'); + + const src = readFileSync(join(REPO_ROOT, 'qa', 'index.mjs'), 'utf8'); + expect(src).toMatch(/invokedDirectly/); // main() yalnız doğrudan çağrıda koşar + + // Import sırasında koşu klasörü ÜRETİLMEDİ: + expect(existsSync(join(REPO_ROOT, 'docs-local', 'qa-runs', runIdFromDate(new Date())))).toBe(false); + }); + + it('37. PR-1 hiçbir yerde gerçek cihaz (adb) komutu çalıştırmaz', async () => { + // (a) Kaynak taraması: qa/ altında adb çağrısı YOK. + const sources = [ + 'qa/index.mjs', 'qa/core/context.mjs', 'qa/core/exec.mjs', 'qa/core/orchestrator.mjs', + 'qa/core/registry.mjs', 'qa/core/scoring.mjs', 'qa/core/verdict.mjs', + 'qa/phases/01-build-validation.mjs', + ]; + for (const rel of sources) { + const src = readFileSync(join(REPO_ROOT, rel), 'utf8'); + // exec/spawn ile adb çağrısı arayan desen (yorumlardaki "adb" kelimesi serbest) + expect(src).not.toMatch(/exec\s*\(\s*['"`]adb/); + expect(src).not.toMatch(/spawn\s*\(\s*['"`]adb/); + expect(src).not.toMatch(/['"`]adb\s+(shell|devices|install)/); + } + + // (b) Davranışsal kilit: host lane adb'yi REDDEDER. + await expect(ctx().exec('adb', ['devices'])).rejects.toThrow(/host lane/i); + await expect(ctx().exec('C:\\sdk\\platform-tools\\adb.exe', ['shell', 'ls'])).rejects.toThrow(/host lane/i); + }); + + it('37b. faz kataloğunda cihaz fazı YOK (PR-1 host foundation)', () => { + const registry = createLabRegistry(); + for (const phase of registry.list()) { + for (const cap of phase.requires) { + expect(cap.startsWith('device.')).toBe(false); + expect(cap.startsWith('vehicle.')).toBe(false); + } + } + }); +}); + +/* ── Uçtan uca: runLab (dry-run, diske yazmaz) ───────────────────────────── */ + +describe('runLab — uçtan uca host koşusu', () => { + it('E2E. host-only profil ile koşar, HOST_VERIFIED tavanını aşmaz, rapor üretir', async () => { + const { report, reportDir } = await runLab({ + profileId: 'host-only', + repoRoot: REPO_ROOT, + outRoot: null, // dry-run: diske yazma yok + now: new Date('2026-07-11T12:00:00.000Z'), + logger: () => {}, + }); + + expect(reportDir).toBeNull(); + expect(validateReport(report).valid).toBe(true); + expect(report.profile.id).toBe('host-only'); + expect(verdictRank(report.verdict.value)).toBeLessThanOrEqual(verdictRank(VERDICT.HOST_VERIFIED)); + expect(report.coverage.device.ratio).toBe(0); + expect(report.capabilities.deviceLayerImplemented).toBe(false); + expect(report.phases.map((p: { id: string }) => p.id)).toContain('build-validation'); + expect(report.capabilities.detected.every((c: string) => c.startsWith('host.'))).toBe(true); + }, 120_000); + + it('E2E-b. koşu klasörü report.json + 3 markdown üretir', async () => { + const out = tmp(); + const { report, reportDir } = await runLab({ + profileId: 'host-only', + repoRoot: REPO_ROOT, + outRoot: out, + now: new Date('2026-07-11T12:00:00.000Z'), + logger: () => {}, + }); + + expect(reportDir).toBeTruthy(); + for (const f of ['report.json', 'QA_REPORT.md', 'OEM_REPORT.md', 'BUILD_REPORT.md']) { + expect(existsSync(join(reportDir!, f))).toBe(true); + } + const onDisk = JSON.parse(readFileSync(join(reportDir!, 'report.json'), 'utf8')); + expect(onDisk.verdict.value).toBe(report.verdict.value); // JSON = tek gerçek kaynak + }, 120_000); +}); + +/* ── Durum modeli yardımcıları ───────────────────────────────────────────── */ + +describe('rollupChecks — kontrol → faz sonucu', () => { + it('hiç kontrol koşmadıysa INCOMPLETE (PASS DEĞİL)', () => { + expect(rollupChecks([createCheck({ id: 'a', status: PHASE_RESULT.SKIPPED_NA })])).toBe(PHASE_RESULT.INCOMPLETE); + }); + + it('atlanan kontrol varsa en fazla PASS_WITH_WARNINGS', () => { + expect(rollupChecks([ + createCheck({ id: 'a', status: PHASE_RESULT.PASS }), + createCheck({ id: 'b', status: PHASE_RESULT.SKIPPED_NA }), + ])).toBe(PHASE_RESULT.PASS_WITH_WARNINGS); + }); + + it('hepsi PASS ise PASS', () => { + expect(rollupChecks([ + createCheck({ id: 'a', status: PHASE_RESULT.PASS }), + createCheck({ id: 'b', status: PHASE_RESULT.PASS }), + ])).toBe(PHASE_RESULT.PASS); + }); + + it('bir FAIL varsa FAIL', () => { + expect(rollupChecks([ + createCheck({ id: 'a', status: PHASE_RESULT.PASS }), + createCheck({ id: 'b', status: PHASE_RESULT.FAIL }), + ])).toBe(PHASE_RESULT.FAIL); + }); +}); + +/* ── mkdirSync kullanımı (lint: import kullanılıyor) ─────────────────────── */ +describe('Sanity', () => { + it('geçici klasör altyapısı çalışıyor', () => { + const d = tmp(); + mkdirSync(join(d, 'x'), { recursive: true }); + expect(existsSync(join(d, 'x'))).toBe(true); + }); +}); From d68766de98eba0462d159cfde0998af6554e98c8 Mon Sep 17 00:00:00 2001 From: Selim Date: Sat, 11 Jul 2026 18:04:57 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(qa):=20lane=20yasa=C4=9F=C4=B1=20POSIX'?= =?UTF-8?q?te=20Windows=20adb=20yolunu=20s=C4=B1zd=C4=B1r=C4=B1yordu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI (Ubuntu) yakaladı: basename('C:\sdk\platform-tools\adb.exe') POSIX'te tüm dizeyi döndürür (ters bölü ayraç DEĞİLDİR) → host lane'in adb yasağı Windows tarzı yolu tanımıyor, komut spawn edilmeye çalışılıyordu. Fix: ayraçlar basename'den ÖNCE normalleştirilir (platformdan bağımsız). Kilit genişletildi: ters-bölülü, POSIX-mutlak ve çıplak adb/fastboot yolları. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013JFgCEkmi1AVowXvZC57HY --- qa/core/context.mjs | 6 +++++- src/__tests__/oemValidationLab.test.ts | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/qa/core/context.mjs b/qa/core/context.mjs index eaff84dd..da3bb2af 100644 --- a/qa/core/context.mjs +++ b/qa/core/context.mjs @@ -81,7 +81,11 @@ export function createContext({ * orchestrator bunu yakalar, faz INCOMPLETE olur, Lab çökmez. */ async exec(command, args = [], opts = {}) { - const bin = basename(String(command)).replace(/\.(exe|bat|cmd)$/i, '').toLowerCase(); + // Ayraçları ÖNCE normalleştir: POSIX'te basename('C:\\sdk\\adb.exe') tüm + // dizeyi döndürür (ters bölü ayraç değildir) → Windows tarzı bir adb yolu + // Linux CI'da kapıdan SIZAR. (Bu hatayı CI yakaladı.) + const normalized = String(command).replace(/\\/g, '/'); + const bin = basename(normalized).replace(/\.(exe|bat|cmd)$/i, '').toLowerCase(); if (lane === 'host' && HOST_LANE_DENY.includes(bin)) { throw new Error(`Host lane'de '${bin}' çalıştırılamaz — cihaz katmanı bu PR'ın kapsamı dışında.`); } diff --git a/src/__tests__/oemValidationLab.test.ts b/src/__tests__/oemValidationLab.test.ts index 016645f4..a1250add 100644 --- a/src/__tests__/oemValidationLab.test.ts +++ b/src/__tests__/oemValidationLab.test.ts @@ -699,9 +699,12 @@ describe('PR-1 kapsam kilitleri', () => { expect(src).not.toMatch(/['"`]adb\s+(shell|devices|install)/); } - // (b) Davranışsal kilit: host lane adb'yi REDDEDER. + // (b) Davranışsal kilit: host lane adb'yi REDDEDER — yol biçimi ne olursa olsun. + // (Ters-bölülü Windows yolu POSIX CI'da kapıdan SIZIYORDU; bu kilit onu tutar.) await expect(ctx().exec('adb', ['devices'])).rejects.toThrow(/host lane/i); await expect(ctx().exec('C:\\sdk\\platform-tools\\adb.exe', ['shell', 'ls'])).rejects.toThrow(/host lane/i); + await expect(ctx().exec('/opt/android-sdk/platform-tools/adb', ['devices'])).rejects.toThrow(/host lane/i); + await expect(ctx().exec('fastboot', ['reboot'])).rejects.toThrow(/host lane/i); }); it('37b. faz kataloğunda cihaz fazı YOK (PR-1 host foundation)', () => {