diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index bd05ee0..cafe636 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 | |---|---------|--------|------------------------------------------|---------| +| 65 | **🔴 Device Validation Pending — Safe Offline Change Detection Handler (W5-3c-1)** — Deep Scan offline pass için İLK gerçek değer üreten handler eklendi: `createOfflineChangeDetectionHandler()` (fabrika; bağımlılıklar closure ile ENJEKTE). Handler ÖNCEDEN-VAROLAN iki baseline'ın ECU kümesi FARKINI hesaplar → `changedEcu`. PRIOR baseline = son TAM taramanın diske yazılı çıktısı (`deepScanPersistence.load` ile beslenecek — AYRI PR); CURRENT baseline = pasif öğrenilmiş gözlem (`vehicleKnowledgeBase.get` ile beslenecek — AYRI PR). **AKTİF SORGU YOK:** iki küme de zaten elde olan veri; araca tek CAN frame / OBD isteği / ECU-PID-DID sorgusu GÖNDERİLMEZ. **SAF + İZOLE:** handler `deepScanPersistence`/`vehicleKnowledgeBase`/runtime/native/HAL/EventBus'i DOĞRUDAN import ETMEZ (yalnız `PhaseContext`/`PhaseResult` tip import'u); `PhaseHandler` sözleşmesi ve `PhaseContext` DEĞİŞMEZ. **FAIL-CLOSED:** parmak izi yok / baseline eksik (null) / baseline BOŞ → değişim İDDİA EDİLMEZ (`changedEcu=false`, kategori reason). Persistence ECU listesi opsiyonel → boş baseline OLAĞAN; bunu "değişti" saymak sahte pozitif olurdu, o yüzden `baseline_incomplete` döner. **FIRMWARE DEĞERLENDİRİLMEZ:** `changedFirmware` DAİMA `false` (dürüst — pasif firmware kaynağı yok; ayrı PR). **BOUNDED + IMMUTABLE ÇIKTI:** yalnız `{status, changedEcu, changedFirmware, reason(kategori)}` — ham ECU/PID/DID listesi, firmware sürümü, VIN, GPS, secret, parmak izi hash'i TAŞIMAZ. **KORUMA BANDI (uçtan uca kanıtlı):** `completeScan`/`_finalize`/`_applyResult` aktif-kayıt yolu / runtime aktif-kayıt API'leri (`recordEcuDiscovery` vb.) / `saveSnapshot` ÇAĞRILMAZ → `hasCompletedFullScan`/`completedScanCount`/`changeCheckCount` DEĞİŞMEZ; `runtime.reset()` davranışı korunur (gerçek `runOfflinePass` ile koşturulan uçtan uca testte doğrulandı). Trigger/wiring DEĞİŞMEDİ; Event Bus/Capability/Assistant/Driver DNA/Prediction/Diagnostic section/SQL/Native/OBD/CAN/HAL'e DOKUNULMADI. **HOST-DOĞRULANDI:** 21 yeni test (changedEcu diff/normalizasyon, boş+null baseline fail-closed, firmware daima false, immutable+privacy, input immutable, fail-soft, cancel, kaynak-kilidi, uçtan uca guard band); tam suite **3841 yeşil (211 dosya)**, tsc `-b --noEmit` temiz, lint 0 error, production build OK. **AÇIKÇA:** (a) bu PR yalnız handler ekler; (b) trigger değiştirilmedi; (c) wiring değiştirilmedi; (d) aktif discovery yapılmıyor; (e) hiçbir ECU/PID/DID sorgusu gönderilmiyor; (f) handler henüz pass'e BAĞLI DEĞİL (W5-3c-2 adapter + W5-3c-3 bağlama ayrı PR). | `platform/deepScan/offlineChangeDetectionHandler.ts` (yeni — saf handler + `ChangeBaseline`/`ChangeDetectionReason`/`OfflineChangeDetectionDeps`), `platform/deepScan/index.ts` (barrel export), `__tests__/deepScanOfflineChangeDetection.test.ts` (yeni, 21 test) | Gerçek cihazda (Xiaomi 23090RA98I + en az bir low-tier K24/Mali-400/T507) — handler pass'e bağlandıktan SONRA (W5-3c-3): (1) gerçek araçta prior (son tam tarama) + current (pasif öğrenilmiş) baseline'lar dolu iken ECU seti değiştiğinde `changedEcu=true`, değişmediğinde `false`; (2) araç OBD/CAN trafiğinde bu handler kaynaklı ECU/PID/DID/Firmware isteği YOK (aktif sorgu üretmiyor); (3) pass sonrası `hasCompletedFullScan`/counter'lar değişmedi, runtime `idle`; (4) boş/eksik baseline'da sahte `changedEcu=true` ÜRETİLMİYOR (fail-closed); (5) low-tier'da ek CPU/RAM yükü ölçüm gürültüsü seviyesinde (saf set-diff, O(n)). Cihaz kanıtı olmadan 🟢 YAPMA. ⚠️ **Mali-400 cihaz doğrulaması bekliyor.** | 2026-07-13 | | 64 | **🔴 Device Validation Pending — Deep Scan Offline Pass Trigger + Runtime Wiring + Minimal Diagnostics (W5-3b)** — W5-3a'daki `runOfflinePass()` İLK kez üretim wiring'ine bağlandı. `platformCoreDeepScanWiring`'e tek deterministik giriş noktası `triggerDeepScanOfflinePass()` (hash/dedup guard + single-flight) + iptal (`cancelDeepScanOfflinePass`) + bounded teşhis (`getDeepScanOfflinePassStatus`) eklendi; SystemBoot **Wave 3**'te (Wave 2 ownership KURULDUKTAN SONRA) fire-and-forget çağrılır (`void ... .catch`; boot BLOKLANMAZ). **GUARD:** aynı guard anahtarı (varsayılan `deepscan.offline.boot`) EN FAZLA bir kez pass başlatır (duplicate YOK); yürüyen pass varken ikinci tetik reddedilir (single-flight); wiring restart'ında guard doğal sıfırlanır (durum wiring örneğine bağlı). **DAVRANIŞ DEĞİŞMEZ:** handler GEÇİLMEZ → tüm offline fazlar `skipped` → GERÇEK İŞ YAPILMAZ; change detection / knowledge / fingerprint / evidence / report ÇALIŞMAZ; aktif ECU/PID/DID/Firmware sorgusu YOK (runtime aktif-kayıt API'leri hiç çağrılmaz — gerçek zincir testiyle kanıtlı); runtime `idle → running → idle` döner (pass sonunda `runtime.reset()` W5-3a garantisi). **OWNERSHIP KORUNUR:** `OwnedOrchestrator` yüzeyi offline `runOfflinePass`/`cancelOfflinePass`'i gösterir ama AKTİF tarama API'lerini (`start`/`run`/`runNextPhase`) ASLA göstermez (compile-time; W5-3a kilidi bu davranışa GÜNCELLENDİ, kaldırılmadı). **TEŞHİS (bounded + immutable):** present/started/running/active/cancelled/triggerCount/lastRun/lastDuration/lastResult/lastReason/lastError/phaseCount/summaryAvailable — hepsi primitive/enum/sayı; VIN/GPS/secret/ham CAN-OBD/guard-anahtarı TAŞIMAZ. Event Bus / Capability Registry / HAL / Assistant / Driver DNA / Prediction / SQL / Supabase / Native / CAN / OBD DEĞİŞMEDİ (wiring bunları import ETMEZ). **HOST-DOĞRULANDI:** yeni trigger-wiring testleri (25+ senaryo: trigger/duplicate/hash-guard/tek-pass/single-flight/idle→running→idle/reset/summary/immutable/bounded/dispose/restart/cancel/timeout-iletimi/handler-yok/aktif-discovery-yok/privacy/input-immutable/Wave-sırası/EventBus-Capability değişmedi); W5-1 wiring fake'i + W5-3a 2 kilit yeni davranışa güncellendi; tam suite **3820 yeşil (210 dosya)**, tsc `-b --noEmit` temiz, lint 0 error, production build OK. **AÇIKÇA:** (a) gerçek handler YOK; (b) gerçek discovery YOK; (c) **W5-3c değer üreten (offline handler) PR'dır**; (d) aktif ECU sorgusu YOK (tip + faz + aktif-kayıt kapısı + handler-yok ile garanti); (e) Mali-400 doğrulaması bekliyor. | `platform/system/platformCoreDeepScanWiring.ts` (OwnedOrchestrator offline yüzey + `triggerDeepScanOfflinePass`/`cancelDeepScanOfflinePass`/`getDeepScanOfflinePassStatus` + bounded status), `platform/system/SystemBoot.ts` (Wave 3 trigger, fire-and-forget), `__tests__/deepScanOfflineTriggerWiring.test.ts` (yeni), `__tests__/platformCoreDeepScanWiring.test.ts` (fake offline yüzey), `__tests__/deepScanOfflinePass.test.ts` (2 kapsam kilidi güncellendi) | Gerçek cihazda (Xiaomi 23090RA98I + en az bir low-tier K24/Mali-400/T507): (1) boot'ta trigger TEK kez çalışır (`getDeepScanOfflinePassStatus().triggerCount=1`, duplicate boot/HMR çoğaltmıyor); (2) `runOfflinePass` çağrıldı ama araç OBD/CAN trafiğinde deep-scan kaynaklı ECU/PID/DID/Firmware isteği YOK (handler yok → aktif sorgu üretilmiyor); (3) pass sonrası runtime `idle` (`summaryAvailable=true`, `lastResult='ran'`); gerçek tarama sonradan başlayabiliyor; (4) boot süresi regresyonu yok (fire-and-forget → boot bloklanmıyor; hedef boot delta <10ms); (5) **Mali-400/K24 steady-state ek yük ölçüm gürültüsü seviyesinde** (pass tek-atış; timer/polling yok → ≈0); (6) background/foreground / restart'ta çift-pass veya leak yok; (7) teşhis raporunda offline pass status okunabilir (ADB'siz) — ⚠️ Not: `getDeepScanOfflinePassStatus` henüz `diagnosticSections`'a EKLENMEDİ (ayrı küçük teşhis PR'ı gerekir). Cihaz kanıtı olmadan 🟢 YAPMA. ⚠️ **Mali-400 doğrulaması bekliyor.** | 2026-07-13 | | 63 | **🔴 Device Validation Pending — Tanı Raporu Sanitize Sertleştirme (Diagnostics PR-2)** — PR-1'in (#80) açtığı denetimde ortaya çıkan ÜÇ sanitize boşluğu kapatıldı. **(1) `selfTest` bölümü sanitize'ı TAMAMEN atlıyordu:** `reportSelfTestSnapshot` raporu ham spread ediyordu (`{...base, selfTest}`) → deny-list, VIN/MAC/koordinat/api_key maskeleri ve derinlik tavanı bu bölüme UYGULANMIYORDU. Prob `detail` alanları SERBEST METİN taşır: ham `Error.message` (`selfTestEngine` runProbe catch), ham fetch hata metni (probeBackend) ve stack karesi/dosya yolu (probeIdleRenderLoop) → **maskesiz kanal**. Artık inspector ile AYNI kapıdan geçer: yeni `_sanitizeSection()` (tek kapı — yeni bölüm eklenince sanitize'ı atlamak imkânsız). Teşhis değeri KAYBOLMAZ: stack karesi (`FullMapView.tsx:814`) okunur kalır, yalnız VIN/MAC/koordinat/anahtar maskelenir. **(2) `DENY_KEYS` camelCase'e KÖRDÜ:** eşleşme `key.toLowerCase()` idi, set'te `'api_key'` vardı → `apiKey`.toLowerCase() = `'apikey'` ≠ `'api_key'` → **SIZIYORDU**. Anahtar artık NORMALİZE edilir (küçült + alfanümerik olmayanı at) ve **TAM EŞLEŞME** yapılır. Liste genişledi: `apikey · accesstoken · refreshtoken · authorization · bearer · secret · password · jwt · token · email` (+ mevcut konum/VIN/MAC/telefon). **⚠️ TAM eşleşme ZORUNLU — substring/prefix YASAK:** `lat` substring olsaydı `platform` (W4E runtime teşhis bölümünün TAMAMI) ve `detectedPlatform` düşerdi; `vin` prefix olsaydı `vinMasked` (VIN sızıntısını ÖNLEMEK için üretilen maskeli türev) silinirdi → koruma raporu boşaltırdı. False-positive taraması: 14 üretici dosyada 326 benzersiz anahtar tarandı, **meşru alan düşmüyor**. **(3) Cycle guard YOKTU:** özyinelemeyi sınırlayan tek şey MAX_DEPTH'ti. Eklenen guard **ANCESTOR-PATH** desenidir (girerken ekle, `finally` ile çıkarken sil) — naif "görülen nesneler" seti PAYLAŞILAN REFERANSI (aynı nesne iki KARDEŞ dalda = DAG) cycle sanıp ikinci dalı sessizce boşaltırdı (DTC `[null,null]` ile aynı sınıf sessiz kayıp). Cycle → `[CYCLE]` işareti (sessiz kayıp DEĞİL, görünür); dizi elemanıysa ELENMEZ (uzunluk korunur). **Public API artık THROW ETMEZ:** getter/Proxy patlarsa yalnız o DÜĞÜM `[UNREADABLE]` olur, kardeş alanlar akar (`_safeSection`'ın düğüm-bazlı karşılığı) → tek zehirli alan TÜM tanı raporunu öldüremez. **DEĞİŞMEYEN (bilinçli):** MAX_DEPTH=6 · MAX_ARRAY_LEN=20 · MAX_STR_LEN=2048 · mevcut MASKS (VIN/MAC/COORD/api_key=/token=) · UI · RPC · DB/migration · rıza · teslimat modeli (`'sent'` yalanı AYRI PR). **HOST-DOĞRULANDI:** 17 yeni davranış kilidi + 4 kaynak-metin kilidi (regresyon kasası; PR-1'in dizi kilidi kaldırılmadı, yeni doğru davranışa GÜNCELLENDİ), tam suite **3711 yeşil (206 dosya)**, tsc temiz, lint 0 error, build OK. **BYTE ÖLÇÜMÜ (en zengin fixture, aynı test, önce/sonra):** `self_test` **5157 B → 5119 B (−38 B)** — sanitize maskeleme sonrası hafif KÜÇÜLDÜ, şişme YOK; `dev_inspector` **5479 B → 5479 B (0)** — inspector zaten aynı kapıdan geçiyordu. | `platform/remoteLogService.ts` (`_normalizeKey` + genişletilmiş DENY_KEYS + ancestor-path cycle guard + düğüm-bazlı fail-soft + `_sanitizeSection` tek kapı; `reportSelfTestSnapshot` + `reportDiagnosticSnapshot` onu kullanır), `__tests__/diagnosticSanitizeHardening.test.ts` (yeni, 17 test), `__tests__/regression.guards.test.ts` (4 kilit; dizi kilidi güncellendi) | Gerçek cihazda: (1) **"Tanı Robotu" (self-test) raporu gönderiliyor** ve gövdesinde `selfTest.results[].detail` alanları MEVCUT (bölüm kaybolmadı, sanitize onu boşaltmadı); (2) `selfTest.summary`/`worst`/`env` dolu ve `results.length` = summary toplamı (MAX_ARRAY_LEN=20 kırpması YOK — bugün 14 prob, 6 pay var); (3) gerçek self-test raporunda **ham `Error.message`/fetch hata metni içinde VIN/MAC/koordinat/anahtar İZİ YOK**, maskeler (`[VIN]`/`[MAC]`/`[COORD]`/`api_key=[MASKED]`) görünüyorsa doğru çalışıyor; (4) `platform` (Event Bus/HAL sayaçları) ve `vidMirror.vehicle.vinMasked` bölümleri raporda **HÂLÂ VAR** (yeni deny listesi meşru alanları düşürmedi — TAM eşleşme kanıtı); (5) `triage` mevcut ve `ruleErrors=0`; (6) DTC varken `obdDeep.dtc.codes[0].code` dolu (PR-1 kazanımı bozulmadı); (7) raporda `[CYCLE]`/`[UNREADABLE]` işareti **görünmüyor** (üretimde dairesel/zehirli düğüm yok — görünürse üretici tarafta bug var demektir, teşhis edilir); (8) rapor gövdesinde `truncated` alanı YOK ve `octet_length` < 45 KB; (9) low-tier cihazda (K24/Mali-400 veya T507) self-test raporu gönderimi başarılı, ANR/donma yok; (10) Xiaomi'de CPU/RAM/boot regresyonu yok. **Gerçek cihazda self-test raporu görülmeden 🟢 YAPMA** — host testleri kodu kanıtlar, sahadaki gerçek `detail` metinlerini kanıtlamaz. | 2026-07-13 | | 62 | **🔴 Device Validation Pending — Tanı Raporu DTC Derinliği + Triyaj Null-Guard (Diagnostics PR-1)** — Denetimin (2026-07-12) KANITLI iki P0'ı kapatıldı. **(1) DTC kodları kabloda `null` gidiyordu:** `remoteLogService._deepSanitize` derinlik ≥ `MAX_DEPTH` olan KABI düşürür; gövde `depth=0` ile çağrıldığı için zincir `root(0)→obdDeep(1)→dtc(2)→codes[](3)→kod nesnesi(4)` **derinlik 4'te ölüyordu** → `codes:[null,null]`. Aynı derinlikteki `obdDeep.extended.samples[]` (katalog-dışı PID keşfi), `uiActivity.recent[].reasons[]`, `inspector.timeline[].signals/env` ve `inspector.runtime.workers[]` de düşüyordu. **`MAX_DEPTH` 4 → 6 (ÖLÇÜLMÜŞ, keyfi değil):** payload gövdesinin tüm bölümleri tarandı, en derin **kap** derinlik 4'te → 5 = minimum yeterli; 6 seçildi çünkü `sanitizeForRemote` ve inspector girişleri `depth=1`'den başlıyor (bir seviye pay) ve ölçülen payload'da derinlik ≥5'te KAP OLMADIĞI için **bugün 0 ek bayt** akıtır. Sanitize dizi dalı artık düşen elemanı **ELER** (`.map` ile yerinde bırakılan `undefined` JSON'da `null` oluyordu — sessiz "kod var ama boş" yalanı). **(2) DTC varken TRİYAJ TAMAMEN kayboluyordu:** `ruleObdDtc` null-guard'sız `c.code` okuyordu → TypeError → `buildTriageSnapshot` döngüsü kırılıyor → `_attachTriage`'ın yutucu catch'i `payload.triage`'ı HİÇ yazmıyor → admin **arızanın olduğu tam anda** "kritik bulgu yok" görüyordu (13 kuralın HEPSİ birden susuyordu: termal/bellek/güç/GPS/self-test dahil). Kural null-guard'landı (`dtc.count` "arıza var" kanıtıdır; kod METNİ yalnız zenginleştirme → liste boş/bozuk olsa da bulgu ÜRETİLİR) **+ motor seviyesinde KURAL İZOLASYONU** (bir kural patlarsa yalnız o kural düşer, diğerleri çalışır) **+ `ruleErrors` sayacı** (izolasyon sessiz olmasın — triyaj eksik çalıştıysa raporda görünür). **DEĞİŞMEYEN (bilinçli):** redaction/allowlist/deny-list/maskeler · `MAX_ARRAY_LEN`/`MAX_STR_LEN` · UI · RPC · DB/migration · rıza/önizleme · teslimat modeli (`'sent'` yalanı **PR-2'ye** bırakıldı). **HOST-DOĞRULANDI:** 13 yeni kilit yeşil (9 davranış + 4 kaynak-metin regresyon kasası), tam suite **3690 yeşil (205 dosya)**, tsc temiz, lint 0 error, build OK. **BYTE ÖLÇÜMÜ (en zengin fixture, aynı test, önce/sonra):** `support_snapshot` **3163 B → 4045 B (+882 B, +%27,9)** · `dev_inspector` **3747 B → 5480 B (+1733 B, +%46,3)** → ikisi de canlı sunucu tavanının (**64 KB**, migration 027) çok altında. | `platform/remoteLogService.ts` (MAX_DEPTH 4→6 + dizi dalı undefined filtresi), `platform/diagnosticTriage.ts` (ruleObdDtc null-guard + kural izolasyonu + `ruleErrors`), `__tests__/diagnosticDtcDepthTriage.test.ts` (yeni, 9 test), `__tests__/regression.guards.test.ts` (4 kaynak-metin kilidi) | **Gerçek araçta, DTC VARKEN** (arıza lambası yanan / kasıtlı DTC üretilmiş araç) gönderilen `support_snapshot` gövdesinde: (1) `obdDeep.dtc.codes[0].code` **dolu string** (`null` DEĞİL) ve `severity`/`system` dolu; (2) `codes` dizisinde **hiç `null` eleman yok**; (3) `payload.triage` **MEVCUT** ve `findings` içinde **`OBD_DTC_PRESENT`** bulgusu var (kritik DTC varsa `severity:'critical'`); (4) `triage.ruleErrors = 0`; (5) DTC varken diğer triyaj bulguları (termal/güç/GPS) **da** üretilmeye devam ediyor; (6) `obdDeep.extended.samples[0].pid` dolu (katalog-dışı PID keşfi artık sunucuya ulaşıyor); (7) Dev Inspector raporunda `inspector.timeline[0].signals.spd/rpm` **dolu**; (8) raporda VIN/MAC/koordinat/secret **YOK** (maskeler delinmedi); (9) gerçek rapor gövdesi **`truncated` alanı taşımıyor** ve `octet_length` < 45 KB (64 KB tavanı aşılırsa sunucu gövdeyi `{truncated:true}` kabuğuyla DEĞİŞTİRİR = %100 sessiz kayıp, üstelik UI yine "gönderildi" der — PR-2 konusu); (10) low-tier cihazda (K24/Mali-400 veya T507) rapor gönderimi başarılı, ANR/donma yok. **Araç + DTC olmadan 🟢 YAPMA** — host testleri kodu kanıtlar, gerçek DTC akışını kanıtlamaz. | 2026-07-13 | diff --git a/src/__tests__/deepScanOfflineChangeDetection.test.ts b/src/__tests__/deepScanOfflineChangeDetection.test.ts new file mode 100644 index 0000000..8c5f4e3 --- /dev/null +++ b/src/__tests__/deepScanOfflineChangeDetection.test.ts @@ -0,0 +1,305 @@ +/** + * deepScanOfflineChangeDetection.test.ts — W5-3c-1 "Safe Offline Change Detection Handler" kilitleri. + * + * KAPSAM: SAF handler iki ÖNCEDEN-VAROLAN baseline'ın ECU kümesi FARKINI hesaplar → `changedEcu`. + * Aktif ECU/PID/DID sorgusu YOK; persistence/counter DEĞİŞMEZ; firmware DEĞERLENDİRİLMEZ + * (`changedFirmware` daima false); çıktı bounded+immutable; gizlilik sızıntısı yok. + * + * Test izolasyonu: pure handler için fake baseline provider'lar; guard-band uçtan uca kanıtı + * için gerçek runtime+persistence+orchestrator üzerinden `runOfflinePass` ile koşturma. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + createOfflineChangeDetectionHandler, + createDeepScanOrchestrator, + DeepScanRuntimeService, + DeepScanPersistenceStore, + type ChangeBaseline, + type PhaseContext, + type PhaseResult, + type DeepScanSnapshot, + type DeepScanStoreIO, +} from '../platform/deepScan'; + +const HASH = 'a1b2c3d4e5f60718'; +const now = () => 4_000_000; + +const HANDLER_SRC = readFileSync( + join(process.cwd(), 'src', 'platform', 'deepScan', 'offlineChangeDetectionHandler.ts'), 'utf8'); + +/* ── Yardımcılar ──────────────────────────────────────────────────────────── */ + +function mkSnap(hash: string | null): DeepScanSnapshot { + return { + scanId: 'scan-1', vehicleFingerprintHash: hash, status: 'analyzing', mode: 'CHANGE_CHECK', + phase: 'change_detection', progressPercent: 0, startedAt: 1, updatedAt: 1, completedAt: null, + isFirstScan: false, ignitionRequired: true, ignitionConfirmed: null, + discoveredEcuCount: 0, discoveredPidCount: 0, discoveredDidCount: 0, newDiscoveriesCount: 0, + changedFirmware: false, changedEcu: false, warnings: [], errorCode: null, reportSummary: null, + }; +} + +function mkCtx(hash: string | null, cancelled = false): PhaseContext { + return Object.freeze({ + phase: 'change_detection', + mode: 'CHANGE_CHECK', + snapshot: mkSnap(hash), + isCancelled: () => cancelled, + }); +} + +const bl = (ecus: string[]): ChangeBaseline => ({ ecus }); + +function handlerWith(prior: ChangeBaseline | null, current: ChangeBaseline | null) { + return createOfflineChangeDetectionHandler({ + loadPriorBaseline: () => prior, + loadCurrentBaseline: () => current, + }); +} + +/* ═══════════════════════════════════════════════════════════════════════ + * 1) changedEcu doğru hesaplanıyor + * ═════════════════════════════════════════════════════════════════════ */ + +describe('W5-3c-1 — changedEcu diff', () => { + it('AYNI ECU kümesi → changedEcu=false, reason=no_change', async () => { + const r = await handlerWith(bl(['7E0', '7E8']), bl(['7E0', '7E8']))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('no_change'); + expect(r.status).toBe('success'); + }); + + it('ECU EKLENDİ → changedEcu=true, reason=ecu_set_changed', async () => { + const r = await handlerWith(bl(['7E0']), bl(['7E0', '7E8']))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(true); + expect(r.reason).toBe('ecu_set_changed'); + }); + + it('ECU KALDIRILDI → changedEcu=true', async () => { + const r = await handlerWith(bl(['7E0', '7E8']), bl(['7E0']))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(true); + }); + + it('NORMALİZASYON: sıra/harf/0x/boşluk farkı DEĞİŞİM SAYILMAZ', async () => { + const r = await handlerWith(bl(['7E0', '7e8']), bl([' 0x7E8 ', '7E0']))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('no_change'); + }); + + it('BOŞ prior baseline → changedEcu=false, reason=baseline_incomplete (sahte pozitif yok)', async () => { + const r = await handlerWith(bl([]), bl(['7E0']))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('baseline_incomplete'); + }); + + it('BOŞ current baseline → changedEcu=false, reason=baseline_incomplete', async () => { + const r = await handlerWith(bl(['7E0']), bl([]))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('baseline_incomplete'); + }); +}); + +/* ═══════════════════════════════════════════════════════════════════════ + * 2) Fail-closed: parmak izi / baseline yok + * ═════════════════════════════════════════════════════════════════════ */ + +describe('W5-3c-1 — fail-closed', () => { + it('PARMAK İZİ YOK (hash=null) → changedEcu=false, reason=no_fingerprint, provider ÇAĞRILMAZ', async () => { + const prior = vi.fn(() => bl(['7E0'])); + const current = vi.fn(() => bl(['7E0', '7E8'])); + const h = createOfflineChangeDetectionHandler({ loadPriorBaseline: prior, loadCurrentBaseline: current }); + const r = await h(mkCtx(null)) as PhaseResult; + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('no_fingerprint'); + expect(prior).not.toHaveBeenCalled(); + expect(current).not.toHaveBeenCalled(); + }); + + it('prior baseline null → reason=baseline_unavailable', async () => { + const r = await handlerWith(null, bl(['7E0']))(mkCtx(HASH)) as PhaseResult; + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('baseline_unavailable'); + }); + + it('current baseline null → reason=baseline_unavailable', async () => { + const r = await handlerWith(bl(['7E0']), null)(mkCtx(HASH)) as PhaseResult; + expect(r.reason).toBe('baseline_unavailable'); + }); +}); + +/* ═══════════════════════════════════════════════════════════════════════ + * 3) Firmware üretilmiyor · immutable · privacy · input immutable + * ═════════════════════════════════════════════════════════════════════ */ + +describe('W5-3c-1 — firmware / immutability / privacy', () => { + it('changedFirmware DAİMA false (bu PR firmware değerlendirmez)', async () => { + for (const [p, c] of [[['7E0'], ['7E0', '7E8']], [['7E0'], ['7E0']], [[], ['7E0']]] as const) { + const r = await handlerWith(bl([...p]), bl([...c]))(mkCtx(HASH)) as PhaseResult; + expect(r.changedFirmware).toBe(false); + } + const r2 = await handlerWith(null, null)(mkCtx(HASH)) as PhaseResult; + expect(r2.changedFirmware).toBe(false); + }); + + it('çıktı IMMUTABLE (frozen)', async () => { + const r = await handlerWith(bl(['7E0']), bl(['7E0', '7E8']))(mkCtx(HASH)) as PhaseResult; + expect(Object.isFrozen(r)).toBe(true); + }); + + it('çıktı YALNIZ {status, changedEcu, changedFirmware, reason} — ham liste/hash YOK', async () => { + const r = await handlerWith(bl(['7E0', '7E8']), bl(['7E0']))(mkCtx(HASH)) as PhaseResult; + expect(Object.keys(r).sort()).toEqual(['changedEcu', 'changedFirmware', 'reason', 'status']); + const blob = JSON.stringify(r); + expect(blob).not.toContain('7E0'); + expect(blob).not.toContain('7E8'); + expect(blob).not.toContain(HASH); // parmak izi hash'i taşınmaz + }); + + it('GİRDİ (ctx + baseline) MUTATE EDİLMEZ; provider hash alır', async () => { + const priorArr = Object.freeze(['7E0']); + const currentArr = Object.freeze(['7E0', '7E8']); + const seen: string[] = []; + const h = createOfflineChangeDetectionHandler({ + loadPriorBaseline: (hash) => { seen.push(hash); return { ecus: priorArr }; }, + loadCurrentBaseline: (hash) => { seen.push(hash); return { ecus: currentArr }; }, + }); + const ctx = mkCtx(HASH); + await h(ctx); + expect(seen).toEqual([HASH, HASH]); + expect(priorArr).toEqual(['7E0']); // baseline dizisi değişmedi + expect(currentArr).toEqual(['7E0', '7E8']); + expect(ctx.snapshot.vehicleFingerprintHash).toBe(HASH); // ctx değişmedi + }); +}); + +/* ═══════════════════════════════════════════════════════════════════════ + * 4) Fail-soft · cancel + * ═════════════════════════════════════════════════════════════════════ */ + +describe('W5-3c-1 — fail-soft & cancel', () => { + it('provider THROW → handler throw ETMEZ, reason=baseline_unavailable', async () => { + const h = createOfflineChangeDetectionHandler({ + loadPriorBaseline: () => { throw new Error('boom'); }, + loadCurrentBaseline: () => bl(['7E0']), + }); + const r = await h(mkCtx(HASH)) as PhaseResult; + expect(r.status).toBe('success'); + expect(r.changedEcu).toBe(false); + expect(r.reason).toBe('baseline_unavailable'); + }); + + it('CANCEL: ctx.isCancelled()=true → status=cancelled, provider ÇAĞRILMAZ', async () => { + const prior = vi.fn(() => bl(['7E0'])); + const h = createOfflineChangeDetectionHandler({ loadPriorBaseline: prior, loadCurrentBaseline: () => bl(['7E8']) }); + const r = await h(mkCtx(HASH, true)) as PhaseResult; + expect(r.status).toBe('cancelled'); + expect(prior).not.toHaveBeenCalled(); + }); + + it('isCancelled THROW → fail-soft (iptal sayılmaz, akış devam eder)', async () => { + const ctx: PhaseContext = Object.freeze({ + phase: 'change_detection', mode: 'CHANGE_CHECK', snapshot: mkSnap(HASH), + isCancelled: () => { throw new Error('cancel boom'); }, + }); + const r = await handlerWith(bl(['7E0']), bl(['7E0', '7E8']))(ctx) as PhaseResult; + expect(r.status).toBe('success'); + expect(r.changedEcu).toBe(true); + }); +}); + +/* ═══════════════════════════════════════════════════════════════════════ + * 5) Kaynak kilidi — saf handler (persistence/kb/runtime IMPORT ETMEZ) + * ═════════════════════════════════════════════════════════════════════ */ + +describe('W5-3c-1 — saf handler (kaynak kilidi)', () => { + it('yalnız TİP import eder; persistence/knowledge-base/runtime/native/HAL/EventBus IMPORT ETMEZ', () => { + const importLines = HANDLER_SRC.split('\n').filter((l) => /^\s*import\b/.test(l)).join('\n'); + expect(importLines).not.toMatch(/deepScanPersistence|deepScanRuntimeService|vehicleKnowledgeBase/i); + expect(importLines).not.toMatch(/obdService|nativePlugin|canbus|Capacitor|supabase|vehicleHal|eventBus|capabilityRegistry/i); + expect(importLines).toMatch(/import type/); // yalnız tip bağımlılığı + }); + + it('trigger/wiring dosyalarına DOKUNMAZ (handler bağımsız modül)', () => { + expect(HANDLER_SRC).not.toContain('platformCoreDeepScanWiring'); + expect(HANDLER_SRC).not.toContain('triggerDeepScanOfflinePass'); + expect(HANDLER_SRC).not.toContain('SystemBoot'); + }); +}); + +/* ═══════════════════════════════════════════════════════════════════════ + * 6) UÇTAN UCA guard band — gerçek runOfflinePass ile + * ═════════════════════════════════════════════════════════════════════ */ + +function memIO() { + const map = new Map(); + const io: DeepScanStoreIO = { + read: (k) => map.get(k) ?? null, + write: (k, v) => { map.set(k, v); }, + remove: (k) => { map.delete(k); }, + }; + return io; +} + +function realChain() { + const runtime = new DeepScanRuntimeService({ now }); + const persistence = new DeepScanPersistenceStore('k-cd', 16, 5000, memIO(), now); + const orch = createDeepScanOrchestrator({ runtime, persistence, now }); + return { runtime, persistence, orch }; +} + +describe('W5-3c-1 — uçtan uca guard band (runOfflinePass)', () => { + it('değişim VAR: summary.changedEcu=true; runtime reset; aktif-kayıt/persistence yazımı YOK; counter değişmez', async () => { + const { runtime, persistence, orch } = realChain(); + const handler = handlerWith(bl(['7E0']), bl(['7E0', '7E8'])); + + const ecu = vi.spyOn(runtime, 'recordEcuDiscovery'); + const pid = vi.spyOn(runtime, 'recordPidDiscovery'); + const did = vi.spyOn(runtime, 'recordDidDiscovery'); + const fw = vi.spyOn(runtime, 'recordFirmwareResult'); + const complete = vi.spyOn(persistence, 'completeScan'); + const save = vi.spyOn(persistence, 'saveSnapshot'); + + const summary = await orch.runOfflinePass({ vehicleFingerprintHash: HASH, handlers: { change_detection: handler } }); + + // Değer üretildi (changedEcu pass özetine yansıdı). + expect(summary.changedEcu).toBe(true); + const outcome = summary.outcomes.find((o) => o.phase === 'change_detection'); + expect(outcome?.status).toBe('success'); + + // AKTİF DISCOVERY YOK. + expect(ecu).not.toHaveBeenCalled(); + expect(pid).not.toHaveBeenCalled(); + expect(did).not.toHaveBeenCalled(); + expect(fw).not.toHaveBeenCalled(); + + // PERSISTENCE DEĞİŞMEZ / COUNTER KİRLENMEZ. + expect(complete).not.toHaveBeenCalled(); + expect(save).not.toHaveBeenCalled(); + expect(persistence.hasCompletedFullScan(HASH)).toBe(false); + expect(persistence.load(HASH)).toBeNull(); + + // RUNTIME RESET korunur (idle'a döner). + expect(runtime.getSnapshot().status).toBe('idle'); + expect(runtime.getSnapshot().scanId).toBeNull(); + }); + + it('değişim YOK: summary.changedEcu=false', async () => { + const { orch } = realChain(); + const handler = handlerWith(bl(['7E0', '7E8']), bl(['7E0', '7E8'])); + const summary = await orch.runOfflinePass({ vehicleFingerprintHash: HASH, handlers: { change_detection: handler } }); + expect(summary.changedEcu).toBe(false); + expect(summary.changedFirmware).toBe(false); + }); + + it('TIMEOUT: senkron/hızlı handler pass zaman sınırında success verir (timeout değil)', async () => { + const { orch } = realChain(); + const handler = handlerWith(bl(['7E0']), bl(['7E0', '7E8'])); + const summary = await orch.runOfflinePass({ vehicleFingerprintHash: HASH, phaseTimeoutMs: 50, handlers: { change_detection: handler } }); + const outcome = summary.outcomes.find((o) => o.phase === 'change_detection'); + expect(outcome?.status).toBe('success'); + expect(outcome?.status).not.toBe('timeout'); + }); +}); diff --git a/src/platform/deepScan/index.ts b/src/platform/deepScan/index.ts index 369b84d..5c6f212 100644 --- a/src/platform/deepScan/index.ts +++ b/src/platform/deepScan/index.ts @@ -73,3 +73,9 @@ export { type StartOrchestrationInput, type DeepScanOrchestratorDeps, } from './deepScanOrchestrator'; +export { + createOfflineChangeDetectionHandler, + type ChangeBaseline, + type ChangeDetectionReason, + type OfflineChangeDetectionDeps, +} from './offlineChangeDetectionHandler'; diff --git a/src/platform/deepScan/offlineChangeDetectionHandler.ts b/src/platform/deepScan/offlineChangeDetectionHandler.ts new file mode 100644 index 0000000..2836ff4 --- /dev/null +++ b/src/platform/deepScan/offlineChangeDetectionHandler.ts @@ -0,0 +1,137 @@ +/** + * offlineChangeDetectionHandler — Deep Scan offline pass için İLK gerçek değer üreten + * handler (W5-3c-1). YALNIZ `change_detection` offline fazı için. + * + * NE YAPAR: ÖNCEDEN-VAROLAN iki baseline'ın ECU kümesi FARKINI hesaplar → `changedEcu`. + * - PRIOR baseline: son TAM taramanın diske yazılmış çıktısı (deepScanPersistence.load + * ile beslenir — AYRI PR). + * - CURRENT baseline: pasif öğrenilmiş güncel gözlem (vehicleKnowledgeBase.get ile + * beslenir — AYRI PR). + * Deep-scan ARACA HİÇBİR sorgu göndermez: iki küme de zaten elde olan veridir. Aktif + * ECU/PID/DID sorgusu YOK · CAN frame YOK · OBD isteği YOK. + * + * NE YAPMAZ (bilinçli — W5-3c-1 yalnız SAF HANDLER'dır): + * - `deepScanPersistence`/`vehicleKnowledgeBase`'i DOĞRUDAN import ETMEZ → bağımlılıklar + * closure ile ENJEKTE edilir (test edilebilir, izole, kaynak-agnostik). + * - `completeScan`/`_finalize`/`_applyResult`/`saveSnapshot`/runtime aktif-kayıt API'leri + * ÇAĞIRMAZ (handler saf → yalnız enjekte provider'ları okur). `hasCompletedFullScan`/ + * `completedScanCount`/`changeCheckCount` DOKUNULMAZ. + * - Trigger/wiring DEĞİŞTİRMEZ · Event Bus/Capability/Assistant/Native/HAL'e DOKUNMAZ · + * firmware DEĞERLENDİRMEZ (`changedFirmware` daima `false` — pasif firmware kaynağı yok, + * dürüst fail-closed). + * + * FAIL-CLOSED: parmak izi yok / baseline eksik / baseline boş → değişim İDDİA EDİLMEZ + * (`changedEcu=false`). Sahte pozitif üretilmez (persistence ECU listesi opsiyonel olduğu + * için boş baseline OLAĞANDIR — bunu "değişti" saymak yanlış olur). + * + * GİZLİLİK: çıktı yalnız `{status, changedEcu, changedFirmware, reason(kategori)}` taşır — + * ham ECU/PID/DID listesi, firmware sürümü, VIN, koordinat, secret, parmak izi hash'i YOK. + */ + +import type { + PhaseContext, + PhaseHandler, + PhaseResult, + PhaseOutcomeStatus, +} from './deepScanOrchestrator'; + +/** Karşılaştırma baseline'ı — yalnız normalize ECU adres kimlikleri (ham response DEĞİL). */ +export interface ChangeBaseline { + readonly ecus: readonly string[]; +} + +/** Değişim kategorisi (ham veri DEĞİL — bounded enum). */ +export type ChangeDetectionReason = + | 'ecu_set_changed' // iki baseline'ın ECU kümesi FARKLI → değişim var + | 'no_change' // ECU kümeleri AYNI + | 'baseline_unavailable' // prior veya current baseline sağlanamadı (null) + | 'baseline_incomplete' // baseline'lardan biri BOŞ → anlamlı karşılaştırma yok + | 'no_fingerprint'; // araç parmak izi yok → kimlik yok, karşılaştırılamaz + +/** + * Enjekte edilen bağımlılıklar — closure ile geçilir; handler bunları DOĞRUDAN import + * etmez. Üretimde `loadPriorBaseline` deepScanPersistence.load'dan, `loadCurrentBaseline` + * vehicleKnowledgeBase.get'ten türetilir (ADAPTER ayrı PR — W5-3c-2). İkisi de SALT-OKUNUR. + */ +export interface OfflineChangeDetectionDeps { + readonly loadPriorBaseline: (vehicleFingerprintHash: string) => ChangeBaseline | null; + readonly loadCurrentBaseline: (vehicleFingerprintHash: string) => ChangeBaseline | null; +} + +/** Normalize ECU kümesi (dedup + upper + trim + `0x` at). Ham liste dışarı SIZMAZ. */ +function _normEcuSet(list: readonly string[] | undefined): Set { + const out = new Set(); + if (!Array.isArray(list)) return out; + for (const raw of list) { + if (typeof raw !== 'string') continue; + const key = raw.trim().toUpperCase().replace(/\s+/g, '').replace(/^0X/, ''); + if (key) out.add(key); + } + return out; +} + +/** İki küme farklı mı (simetrik fark boş değil mi). O(n). */ +function _setsDiffer(a: Set, b: Set): boolean { + if (a.size !== b.size) return true; + for (const x of a) if (!b.has(x)) return true; + return false; +} + +/** Bounded + dondurulmuş sonuç. `changedFirmware` daima false (bu PR firmware değerlendirmez). */ +function _result( + status: PhaseOutcomeStatus, + changedEcu: boolean, + reason: ChangeDetectionReason, +): PhaseResult { + return Object.freeze({ + status, + changedEcu, + changedFirmware: false, + reason, + }); +} + +/** + * Offline `change_detection` handler fabrikası. Bağımlılıklar closure ile enjekte edilir; + * dönen handler `PhaseHandler` sözleşmesine SADIKTIR (PhaseContext genişletilmez) ve SAF'tır + * (yan etkisiz, yalnız enjekte salt-okunur provider'ları okur). + */ +export function createOfflineChangeDetectionHandler( + deps: OfflineChangeDetectionDeps, +): PhaseHandler { + const loadPrior = deps.loadPriorBaseline; + const loadCurrent = deps.loadCurrentBaseline; + + return (ctx: PhaseContext): PhaseResult => { + // İptal önce — fail-soft; hiçbir hesap/okuma yapma. + try { if (ctx.isCancelled()) return _result('cancelled', false, 'no_change'); } catch { /* fail-soft */ } + + const hash = ctx?.snapshot?.vehicleFingerprintHash ?? null; + // FAIL-CLOSED: parmak izi yoksa araç kimliği yok → değişim iddia edilmez. + if (typeof hash !== 'string' || hash.length === 0) { + return _result('success', false, 'no_fingerprint'); + } + + let prior: ChangeBaseline | null = null; + let current: ChangeBaseline | null = null; + try { prior = loadPrior(hash); } catch { prior = null; } // fail-soft + try { current = loadCurrent(hash); } catch { current = null; } // fail-soft + + // FAIL-CLOSED: baseline'lardan biri yoksa karşılaştırılamaz. + if (!prior || !current) { + return _result('success', false, 'baseline_unavailable'); + } + + const priorSet = _normEcuSet(prior.ecus); + const currentSet = _normEcuSet(current.ecus); + + // FAIL-CLOSED: boş baseline anlamlı karşılaştırma vermez (persistence ECU listesi + // opsiyonel → boş OLAĞAN). Sahte pozitif üretme. + if (priorSet.size === 0 || currentSet.size === 0) { + return _result('success', false, 'baseline_incomplete'); + } + + const changedEcu = _setsDiffer(priorSet, currentSet); + return _result('success', changedEcu, changedEcu ? 'ecu_set_changed' : 'no_change'); + }; +}