diff --git a/docs/DEVICE_VALIDATION_LEDGER.md b/docs/DEVICE_VALIDATION_LEDGER.md index c57382bf..5b9b47df 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 | |---|---------|--------|------------------------------------------|---------| +| 29 | **🔴 Device Validation Pending — Deep Scan Runtime Service Foundation** — Yeni araç ilk kez bağlandığında çalışacak Deep Scan sürecinin merkezi runtime FOUNDATION katmanı: durum · faz · ilerleme · keşif sayaçları · olaylar · güvenli çalışma koşulları. `DeepScanRuntimeService` saf bir durum makinesidir — **araca TEK BİR sorgu bile göndermez**, hiçbir OBD/discovery/fingerprint/learning servisini import etmez (kaynak-metin kilidi: tek import `./deepScanModel`), timer/abonelik açmaz → **import edilmesi yan etki üretmez**. 9 durum (idle · waiting_for_ignition · preparing · scanning · analyzing · completed · paused · cancelled · failed) + 12 faz; fazlar AKTİF (araca sorgu: kimlik/protokol/ECU/PID/DID/firmware) ve OFFLINE (analiz/fingerprint/knowledge/evidence/change/rapor) olarak ayrılır. **Mod kararı:** önceki tamamlanmış tarama yoksa `FULL_SCAN`, varsa `CHANGE_CHECK` (aynı araca her bağlantıda tam tarama YOK). **KONTAK GÜVENLİĞİ (fail-closed):** depoda kontak/ACC yayan gerçek kaynak YOK (`IGNITION_OFF` yalnız mesaj sabiti, hiç emit edilmiyor) → `ignitionConfirmed` üç durumlu (`true`/`false`/`null=bilinmiyor`), yalnız `setIgnitionConfirmed()` ile dışarıdan beslenir, servis kontağı ASLA tahmin etmez; `true` değilse aktif faz açılmaz + aktif keşif kaydı reddedilir, yalnız offline fazlar çalışır. Progress deterministik + monotonik (0–100 clamp, geriye düşmez, complete→100, fail/cancel'da korunur, sahte ilerleme yok). Bounded: warnings(16) · listeners(32) · dedup anahtarları(4096). Snapshot + rapor + olay zarfı `Object.freeze`'li; VIN(17-karakter kapısı) · MAC · koordinat · ham CAN hex · API key snapshot'a giremez. **SystemBoot wiring YOK** (kilitli test) — gerçek tarama orchestration ve kalıcı geçmiş deposu AYRI PR kapsamı. | `platform/deepScan/deepScanModel.ts` (yeni), `platform/deepScan/deepScanRuntimeService.ts` (yeni), `platform/deepScan/index.ts` (yeni), `__tests__/deepScanRuntimeService.test.ts` (yeni, 57 test) | Gerçek araçta (orchestration PR'ı bağlandıktan SONRA): (1) **yeni araçta** `FULL_SCAN` + `isFirstScan=true` durumu oluşur; (2) **daha önce öğrenilmiş araçta** `CHANGE_CHECK` seçilir, tam tarama tekrarlanmaz; (3) kontak doğrulanmadan **aktif sorgu başlamaz** (status `waiting_for_ignition`'da kalır, ELM327 trafiği sıfır); (4) kontak kapalıyken araca sorgu **gönderilmez** (adb/ELM log'unda istek yok), yalnız analyzing/report fazları koşar; (5) progress monotonik — geri düşüş gözlenmez; (6) discovery sayaçları gerçek gözlemlerle **birebir** eşleşir (duplicate şişirmez); (7) cancel/pause/resume çalışır; (8) BASIC_JS tier'da CPU/FPS regresyonu yok (servis pasifken sıfır yük). ⚠️ **Restart sonrası kalıcılık BU PR'IN KAPSAMI DEĞİL** — `hasCompletedScanBefore` çağırandan gelir; süreç yeniden başlayınca durum `idle`'a döner. Test edilmeden 🟢 yapma. | 2026-07-10 | | 28 | **🔴 Device Validation Pending — Unified Assistant Context (OfflineAssistantContext)** — Voice Assistant · Companion AI · Safety Kernel · Offline Conversation · Vehicle Learning · Diagnostic AI · Maintenance AI katmanlarının ayrı ayrı okuduğu kaynakları TEK merkezde toplayan, periyodik + `Object.freeze`'li snapshot yayınlayan store. SAF kurucu (`buildSnapshot`, throw etmez, girdiyi mutate etmez) + fail-soft canlı adaptör (`readLiveSources`, her kaynak izole try/catch). Periyot 5 sn · DeviceTier=low → 10 sn; görev `runtimeManager.scheduleTask` üstünde `criticality:'NORMAL' + deferIdle:true` → hot-path'e (3Hz hız/RPM) GİRMEZ. Dirty-flag ile park hâlinde sıfır maliyet; ağır soğuk-yol bölümleri (identity/learning) 30 sn (low: 60 sn) aralıkla okunur. Gizlilik: VIN/MAC/GPS/ham PID-DID-CAN snapshot'a ASLA girmez (whitelist kurucu; navigasyon hedefi yalnız ad, ≤64 karakter). Hiçbir servise YAZMAZ; severity/driveSafe/diagnostic kararı DEĞİŞMEZ. **Additive: henüz hiçbir tüketiciye ve SystemBoot'a bağlı DEĞİL** → bu PR'da çalışma zamanı davranışı değişmez. Kaynağı olmayan alanlar dürüstçe `null`: `deepScan.*` (genel Deep Scan servisi YOK), `driver.fatigueScore`, `status.ignition`, `media.ducking`, `network.wifi/mobile` (yalnız `navigator.connection.type` kesinse dolar). | `src/store/useAssistantContextStore.ts` (yeni), `src/__tests__/assistantContextStore.test.ts` (yeni, 25 test) | Gerçek araçta (tüketiciye bağlandıktan SONRA): (1) snapshot mid/high tier'da ~5 sn, BASIC_JS/low'da ~10 sn (veya mod çarpanıyla daha seyrek) aralıkla yenilenir; (2) park hâlinde (OBD sessiz) tik sıfır ayırma yapar — CPU profilinde snapshot işi görünmez; (3) BASIC_JS tier'da FPS/CPU regresyonu YOK, hız/RPM göstergesi 3Hz akıcılığını korur; (4) snapshot içeriğinde VIN/MAC/koordinat/ham hex GÖRÜNMEZ (Expert Mode / log denetimi); (5) uzun sürüşte (>1 sa) bellek büyümesi gözlenmez (yalnız son snapshot tutulur); (6) OBD/DTC/tanı servislerinden biri düşse bile snapshot üretilmeye devam eder. ⚠️ Test edilmeden 🟢'a çekme. | 2026-07-10 | | 27 | **🔴 Device Validation Pending — Companion Safety Kernel & Response Verifier (PR-A)** — Asistanın online AI'dan BAĞIMSIZ, yerel + deterministik güvenlik katmanı. `assistantSafetyKernel` iki SAF fonksiyon (enjekte `SafetyContext`, throw etmez) + bir fail-soft canlı adaptör: **PRE-GATE** (`evaluatePreGate`) kritik durumda (motor aşırı ısı ≥118°C · yağ basıncı · fren · şanzıman · şarj/düşük voltaj <11.8V · tanı driveSafe=false veya severity=critical · uykululuk · reverse · cognitive≥PROTECTION) online çağrıyı ENGELLER → aktif fiziksel arızada doğrulanmış yerel şablon (`SAFETY_TEMPLATES`, mevcut `safety-overheat`/`safety-battery-oil` klipleri), cognitive/kernel-hatada offline'a düşer (fail-closed). **POST-GATE** (`verifyResponse`) online CHAT cevabını doğrular: kritik bağlamda tamamen atıp deterministic ile değiştirir · driveSafe=false'ta yanlış rahatlatma/telemetri çelişkisini reddeder · VIN/koordinat/JSON sızıntısını temizler · sürüşte kısaltır (≤200) parkta uzun (≤400). severity/driveSafe/diagnostic kararı DEĞİŞMEZ (yalnız okunur); online model kararı YÜKSELTEMEZ. `companionChatProvider`'a iki kanca (PRE/POST), **sağlayıcı sırası (Gemini→Groq→Haiku) ve grounding DEĞİŞMEZ**; xAI Grok/router/offline sohbet YOK. Girdi mutate edilmez, fail-soft, zero-alloc dostu. | `assistant/assistantSafetyKernel.ts` (yeni), `companion/companionChatProvider.ts` (PRE/POST wrapper + `companion_safety` route; iç zincir `runCompanionBrain`/`runCompanionChat`'e `allowOnline` geçidi), `assistantSafetyKernel.test.ts` (yeni, 30 test) | Gerçek araçta: (1) kritik hararet durumunda asistana soru sorulunca **online çağrı YAPILMAZ** (ağ log'unda istek yok) + deterministic güvenlik mesajı gelir; (2) online model "önemli değil/güvenle devam" dese bile kullanıcıya GEÇMEZ; (3) driveSafe/severity kararı değişmez; (4) TTS güvenlik mesajı gecikmeden çalar; (5) BASIC_JS tier'da FPS/CPU regresyonu yok; (6) kritik olmayan normal sohbet eskisi gibi online'a gider (sağlayıcı sırası korunur). ⚠️ Test edilmeden 🟢'a çekme. | 2026-07-10 | | 26 | **🔴 Device Validation Pending — Evidence Write Bridge (P2-6)** — P2-1→P2-5 öğrenme katmanları kuruldu ama Evidence Store'a runtime YAZICI yoktu (Integration Service yalnız `list()` okuyordu) → learning rozetleri / Expert özeti / diagnostic learning context BOŞ kalıyordu. Bu köprü eksik write-path'i bağlar: VKB güncellenince (discovery gözlemi + VID kimlik değişimi abonelikleri, debounce'lu cold-path) `vehicleLearningEngine.computeEvidence()` çağrılır → üretilen kanıtlar Evidence Store'a `save()` (idempotent reproject — createdAt korunur, observationCount ŞİŞMEZ, vehicleCount distinct) ile yazılır → Pattern/Integration Service `list()` ile okuyabilir. 3Hz OBD hot-path'e GİRMEZ; yalnız anlamlı kimlik/discovery değişiminde tetiklenir; BALANCED/HIGH 8sn, BASIC_JS(low) 20sn debounce; kapanışta flush + dispose; fail-soft (köprü hatası OBD/Discovery'ye SIZMAZ); bounded(store 512 LRU) + throttle'lı yazma + zero-leak. Confidence/decay/pattern-promotion FORMÜLLERİ · Discovery capture/queue · Fingerprint · Auto Learning · VKB · Manufacturer Intelligence · Diagnostic safety/severity/driveSafe · PID/DID registry DEĞİŞMEZ. Cloud/SQL/LLM/Native YOK. | `vehicleLearningEvidenceBridge.ts` (yeni), `system/SystemBoot.ts` (VKB'den SONRA cold-path wiring, `_reg` cleanup), `vehicleLearningEvidenceBridge.test.ts` (yeni, 12 test) | Gerçek araçta: (1) discovery sonrası Evidence Store DOLUYOR (list() boş değil); (2) uygulama restart sonrası evidence geri geliyor (kalıcı); (3) Dashboard learning rozeti görünüyor; (4) Expert "Araç Öğrenme" sayıları >0; (5) Diagnostic learning context doluyor; (6) duplicate gözlem vehicleCount'i ŞİŞİRMİYOR (distinct fingerprint); (7) BASIC_JS tier'da FPS/CPU regresyonu YOK (seyrek debounce + cold-path). ⚠️ Test edilmeden 🟢'a çekme. | 2026-07-10 | diff --git a/src/__tests__/deepScanRuntimeService.test.ts b/src/__tests__/deepScanRuntimeService.test.ts new file mode 100644 index 00000000..2e637921 --- /dev/null +++ b/src/__tests__/deepScanRuntimeService.test.ts @@ -0,0 +1,780 @@ +/** + * deepScanRuntimeService — Deep Scan runtime foundation testleri. + * + * Servis saf bir durum makinesidir (timer/abonelik/native yok) → enjekte edilen + * `now()` ile tamamen deterministik test edilir. Canlı servis mock'u GEREKMEZ. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/* Kaynak-metin kilitleri: runtime readFileSync yerine Vite `?raw` (transform-time + sabit → paralel koşumda flake'e bağışık; bkz. regression.guards.test.ts). */ +import systemBootSrc from '../platform/system/SystemBoot.ts?raw'; +import deepScanServiceSrc from '../platform/deepScan/deepScanRuntimeService.ts?raw'; + +import { + DeepScanRuntimeService, + deepScanRuntimeService, +} from '../platform/deepScan/deepScanRuntimeService'; +import { + canRunPhase, + clampProgress, + isActivePhase, + isCriticalPhase, + monotonicProgress, + normalizeFingerprintHash, + normalizeIgnition, + resolveScanMode, + sanitizeText, + ACTIVE_PHASES, + MAX_WARNINGS, + OFFLINE_PHASES, + type DeepScanEvent, +} from '../platform/deepScan/deepScanModel'; + +/** Geçerli 16-hane hex parmak izi. */ +const HASH = 'a1b2c3d4e5f60718'; + +let now = 1_000_000; +let svc: DeepScanRuntimeService; + +function makeService(): DeepScanRuntimeService { + return new DeepScanRuntimeService({ now: () => now }); +} + +/** Kontak doğrulanmış, taraması başlatılmış servis. */ +function startedWithIgnition(hasCompletedScanBefore = false): DeepScanRuntimeService { + const s = makeService(); + s.startScan({ vehicleFingerprintHash: HASH, hasCompletedScanBefore, ignitionConfirmed: true }); + return s; +} + +/** Olayları toplayan dinleyici. */ +function collector(s: DeepScanRuntimeService): { events: DeepScanEvent[]; unsub: () => void } { + const events: DeepScanEvent[] = []; + const unsub = s.subscribe((e) => events.push(e)); + return { events, unsub }; +} + +beforeEach(() => { now = 1_000_000; svc = makeService(); }); +afterEach(() => { svc.dispose(); vi.restoreAllMocks(); }); + +/* ══════════════════════════════════════════════════════════════════════════ + * 1. Başlangıç durumu + * ════════════════════════════════════════════════════════════════════════ */ + +describe('başlangıç durumu', () => { + it('idle ve tüm sayaçlar sıfır', () => { + const snap = svc.getSnapshot(); + + expect(snap.status).toBe('idle'); + expect(snap.scanId).toBeNull(); + expect(snap.mode).toBeNull(); + expect(snap.phase).toBeNull(); + expect(snap.progressPercent).toBe(0); + expect(snap.discoveredEcuCount).toBe(0); + expect(snap.discoveredPidCount).toBe(0); + expect(snap.discoveredDidCount).toBe(0); + expect(snap.newDiscoveriesCount).toBe(0); + expect(snap.changedFirmware).toBe(false); + expect(snap.changedEcu).toBe(false); + expect(snap.warnings).toEqual([]); + expect(snap.errorCode).toBeNull(); + expect(snap.reportSummary).toBeNull(); + }); + + it('kontak bilinmiyor (null) — açık VARSAYILMAZ', () => { + expect(svc.getSnapshot().ignitionConfirmed).toBeNull(); + expect(svc.getSnapshot().ignitionRequired).toBe(true); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 2-3. FULL_SCAN / CHANGE_CHECK kararı + * ════════════════════════════════════════════════════════════════════════ */ + +describe('tarama modu kararı', () => { + it('yeni araç (önceki tarama yok) → FULL_SCAN + isFirstScan=true', () => { + svc.startScan({ vehicleFingerprintHash: HASH, ignitionConfirmed: true }); + const snap = svc.getSnapshot(); + + expect(snap.mode).toBe('FULL_SCAN'); + expect(snap.isFirstScan).toBe(true); + }); + + it('öğrenilmiş araç (önceki tarama tamamlanmış) → CHANGE_CHECK + isFirstScan=false', () => { + svc.startScan({ vehicleFingerprintHash: HASH, hasCompletedScanBefore: true, ignitionConfirmed: true }); + const snap = svc.getSnapshot(); + + expect(snap.mode).toBe('CHANGE_CHECK'); + expect(snap.isFirstScan).toBe(false); + }); + + it('resolveScanMode saf kararı — undefined/false → FULL_SCAN, true → CHANGE_CHECK', () => { + expect(resolveScanMode(undefined)).toBe('FULL_SCAN'); + expect(resolveScanMode(false)).toBe('FULL_SCAN'); + expect(resolveScanMode(true)).toBe('CHANGE_CHECK'); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 4-6. Kontak güvenliği + * ════════════════════════════════════════════════════════════════════════ */ + +describe('kontak (ignition) güvenliği — fail-closed', () => { + it('kontak doğrulanmamışsa startScan → waiting_for_ignition + ignition_required', () => { + const { events } = collector(svc); + svc.startScan({ vehicleFingerprintHash: HASH }); // ignitionConfirmed verilmedi → null + + expect(svc.getSnapshot().status).toBe('waiting_for_ignition'); + expect(svc.getSnapshot().ignitionConfirmed).toBeNull(); + expect(events.map((e) => e.type)).toEqual(['scan_started', 'ignition_required']); + }); + + it('kontak kapalıyken (false) aktif scanning fazına GEÇİLEMEZ', () => { + svc.startScan({ vehicleFingerprintHash: HASH, ignitionConfirmed: false }); + const { events } = collector(svc); + + svc.updatePhase('ecu_discovery'); // aktif faz + + const snap = svc.getSnapshot(); + expect(snap.status).toBe('waiting_for_ignition'); + expect(snap.phase).toBeNull(); // faz DEĞİŞMEDİ + expect(snap.progressPercent).toBe(0); // sahte ilerleme yok + expect(events.some((e) => e.type === 'ignition_required')).toBe(true); + expect(events.some((e) => e.type === 'phase_changed')).toBe(false); + expect(snap.warnings.some((w) => w.startsWith('ignition_not_confirmed'))).toBe(true); + }); + + it('kontak BİLİNMİYORken (null) de aktif faz açılmaz — açık varsayılmaz', () => { + svc.startScan({ vehicleFingerprintHash: HASH, ignitionConfirmed: null }); + svc.updatePhase('standard_pid_discovery'); + + expect(svc.getSnapshot().phase).toBeNull(); + expect(svc.getSnapshot().status).toBe('waiting_for_ignition'); + }); + + it('kontak kapalıyken OFFLINE analiz fazına izin verilir', () => { + svc.startScan({ vehicleFingerprintHash: HASH, ignitionConfirmed: false }); + svc.updatePhase('capability_analysis'); + + const snap = svc.getSnapshot(); + expect(snap.phase).toBe('capability_analysis'); + expect(snap.status).toBe('analyzing'); + expect(snap.progressPercent).toBeGreaterThan(0); + }); + + it('kontak kapalıyken report_generation ve change_detection çalışır', () => { + svc.startScan({ ignitionConfirmed: false }); + svc.updatePhase('change_detection'); + expect(svc.getSnapshot().status).toBe('analyzing'); + svc.updatePhase('report_generation'); + expect(svc.getSnapshot().phase).toBe('report_generation'); + }); + + it('kontak kapalıyken AKTİF keşif kaydı (PID/DID/ECU/firmware) kabul edilmez', () => { + svc.startScan({ ignitionConfirmed: false }); + + svc.recordEcuDiscovery({ ecuAddress: '7E8' }); + svc.recordPidDiscovery({ pidOrDid: '0C' }); + svc.recordDidDiscovery({ pidOrDid: 'F190' }); + svc.recordFirmwareResult({ changed: true }); + + const snap = svc.getSnapshot(); + expect(snap.discoveredEcuCount).toBe(0); + expect(snap.discoveredPidCount).toBe(0); + expect(snap.discoveredDidCount).toBe(0); + expect(snap.changedFirmware).toBe(false); // araca sorgu gitmemiş sayılır + }); + + it('setIgnitionConfirmed(true) beklemedeki taramayı preparing yapar', () => { + svc.startScan({ ignitionConfirmed: null }); + expect(svc.getSnapshot().status).toBe('waiting_for_ignition'); + + svc.setIgnitionConfirmed(true); + + expect(svc.getSnapshot().status).toBe('preparing'); + expect(svc.getSnapshot().ignitionConfirmed).toBe(true); + }); + + it('tarama sırasında kontak kaybedilirse waiting_for_ignition\'a düşer', () => { + const s = startedWithIgnition(); + s.updatePhase('ecu_discovery'); + expect(s.getSnapshot().status).toBe('scanning'); + + s.setIgnitionConfirmed(false); + + expect(s.getSnapshot().status).toBe('waiting_for_ignition'); + s.dispose(); + }); + + it('canRunPhase: aktif fazlar yalnız true ile, offline fazlar her koşulda', () => { + for (const p of ACTIVE_PHASES) { + expect(canRunPhase(p, true)).toBe(true); + expect(canRunPhase(p, false)).toBe(false); + expect(canRunPhase(p, null)).toBe(false); + } + for (const p of OFFLINE_PHASES) { + expect(canRunPhase(p, true)).toBe(true); + expect(canRunPhase(p, false)).toBe(true); + expect(canRunPhase(p, null)).toBe(true); + } + }); + + it('normalizeIgnition yalnız gerçek boolean kabul eder', () => { + expect(normalizeIgnition(true)).toBe(true); + expect(normalizeIgnition(false)).toBe(false); + expect(normalizeIgnition(null)).toBeNull(); + expect(normalizeIgnition(undefined)).toBeNull(); + expect(normalizeIgnition(1)).toBeNull(); + expect(normalizeIgnition('true')).toBeNull(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 7-10. Faz + ilerleme modeli + * ════════════════════════════════════════════════════════════════════════ */ + +describe('faz ve ilerleme modeli', () => { + it('phase güncelleniyor ve status aktif/offline\'a göre ayarlanıyor', () => { + const s = startedWithIgnition(); + + s.updatePhase('protocol_detection'); + expect(s.getSnapshot().phase).toBe('protocol_detection'); + expect(s.getSnapshot().status).toBe('scanning'); + + s.updatePhase('knowledge_update'); + expect(s.getSnapshot().status).toBe('analyzing'); + s.dispose(); + }); + + it('progress 0..100 aralığına clamp edilir', () => { + expect(clampProgress(-50)).toBe(0); + expect(clampProgress(150)).toBe(100); + // Imkânsız değerler (NaN/Infinity/sayı olmayan) → 0 (CLAUDE.md sensör sanitizasyonu: + // sahte ilerleme üretmektense sıfır kabul et). + expect(clampProgress(NaN)).toBe(0); + expect(clampProgress(Infinity)).toBe(0); + expect(clampProgress('42' as unknown)).toBe(0); + expect(clampProgress(42)).toBe(42); + + const s = startedWithIgnition(); + s.updateProgress(500); + expect(s.getSnapshot().progressPercent).toBe(100); + s.dispose(); + }); + + it('progress geriye düşmez (monotonik)', () => { + expect(monotonicProgress(50, 30)).toBe(50); + expect(monotonicProgress(50, 70)).toBe(70); + + const s = startedWithIgnition(); + s.updateProgress(40); + s.updateProgress(10); + expect(s.getSnapshot().progressPercent).toBe(40); + s.dispose(); + }); + + it('faz değişiminde progress mantıklı artar, geri fazda düşmez', () => { + const s = startedWithIgnition(); + + s.updatePhase('vehicle_identity'); + const p1 = s.getSnapshot().progressPercent; + s.updatePhase('ecu_discovery'); + const p2 = s.getSnapshot().progressPercent; + expect(p2).toBeGreaterThan(p1); + + s.updatePhase('vehicle_identity'); // geri dönüş — progress DÜŞMEZ + expect(s.getSnapshot().progressPercent).toBe(p2); + s.dispose(); + }); + + it('completeScan → progress 100 + completed + rapor özeti', () => { + const s = startedWithIgnition(); + s.updatePhase('ecu_discovery'); + now += 5_000; + s.completeScan({ note: 'tarama tamam' }); + + const snap = s.getSnapshot(); + expect(snap.status).toBe('completed'); + expect(snap.progressPercent).toBe(100); + expect(snap.completedAt).toBe(now); + expect(snap.reportSummary).not.toBeNull(); + expect(snap.reportSummary!.mode).toBe('FULL_SCAN'); + expect(snap.reportSummary!.durationMs).toBe(5_000); + expect(snap.reportSummary!.note).toBe('tarama tamam'); + s.dispose(); + }); + + it('failed/cancelled durumunda mevcut progress KORUNUR (100 yazılmaz)', () => { + const a = startedWithIgnition(); + a.updateProgress(37); + a.failScan('ecu_timeout'); + expect(a.getSnapshot().progressPercent).toBe(37); + expect(a.getSnapshot().status).toBe('failed'); + a.dispose(); + + const b = startedWithIgnition(); + b.updateProgress(58); + b.cancelScan('user'); + expect(b.getSnapshot().progressPercent).toBe(58); + expect(b.getSnapshot().status).toBe('cancelled'); + b.dispose(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 11-16. Keşif sayaçları / değişiklik tespiti + * ════════════════════════════════════════════════════════════════════════ */ + +describe('keşif sayaçları', () => { + it('ECU discovery sayısı doğru', () => { + const s = startedWithIgnition(); + s.recordEcuDiscovery({ ecuAddress: '7E8' }); + s.recordEcuDiscovery({ ecuAddress: '7E9' }); + expect(s.getSnapshot().discoveredEcuCount).toBe(2); + s.dispose(); + }); + + it('PID discovery sayısı doğru (ECU başına ayrı sinyal)', () => { + const s = startedWithIgnition(); + s.recordPidDiscovery({ pidOrDid: '0C', ecuAddress: '7E8' }); + s.recordPidDiscovery({ pidOrDid: '0D', ecuAddress: '7E8' }); + s.recordPidDiscovery({ pidOrDid: '0C', ecuAddress: '7E9' }); // farklı ECU → ayrı + expect(s.getSnapshot().discoveredPidCount).toBe(3); + s.dispose(); + }); + + it('DID discovery sayısı doğru', () => { + const s = startedWithIgnition(); + s.recordDidDiscovery({ pidOrDid: 'F190' }); + s.recordDidDiscovery({ pidOrDid: 'F1A0' }); + expect(s.getSnapshot().discoveredDidCount).toBe(2); + s.dispose(); + }); + + it('duplicate keşif sayacı ŞİŞİRMEZ (normalize + dedup)', () => { + const s = startedWithIgnition(); + + s.recordEcuDiscovery({ ecuAddress: '7E8' }); + s.recordEcuDiscovery({ ecuAddress: '7e8' }); // küçük harf + s.recordEcuDiscovery({ ecuAddress: ' 0x7E8 ' }); // 0x + boşluk + s.recordPidDiscovery({ pidOrDid: '0C', ecuAddress: '7E8' }); + s.recordPidDiscovery({ pidOrDid: '0c', ecuAddress: '7E8' }); + s.recordDidDiscovery({ pidOrDid: 'F190' }); + s.recordDidDiscovery({ pidOrDid: 'f190' }); + + const snap = s.getSnapshot(); + expect(snap.discoveredEcuCount).toBe(1); + expect(snap.discoveredPidCount).toBe(1); + expect(snap.discoveredDidCount).toBe(1); + s.dispose(); + }); + + it('newDiscoveriesCount yalnız isNew=true olan TEKİL sinyalleri sayar', () => { + const s = startedWithIgnition(); + s.recordPidDiscovery({ pidOrDid: '0C', isNew: false }); + s.recordPidDiscovery({ pidOrDid: '78', isNew: true }); + s.recordPidDiscovery({ pidOrDid: '78', isNew: true }); // duplicate + s.recordDidDiscovery({ pidOrDid: 'F190', isNew: true }); + + expect(s.getSnapshot().newDiscoveriesCount).toBe(2); + s.dispose(); + }); + + it('firmware değişikliği işaretleniyor', () => { + const s = startedWithIgnition(); + s.recordFirmwareResult({ ecuAddress: '7E8', changed: false }); + expect(s.getSnapshot().changedFirmware).toBe(false); + s.recordFirmwareResult({ ecuAddress: '7E9', changed: true }); + expect(s.getSnapshot().changedFirmware).toBe(true); + + s.completeScan(); + expect(s.getSnapshot().reportSummary!.firmwareCheckedCount).toBe(2); + s.dispose(); + }); + + it('ECU değişikliği recordChangeDetection ile işaretleniyor (offline — kontak gerekmez)', () => { + svc.startScan({ ignitionConfirmed: false }); + const { events } = collector(svc); + + svc.recordChangeDetection({ changedEcu: true, reason: 'yeni ECU adresi' }); + + expect(svc.getSnapshot().changedEcu).toBe(true); + expect(events.some((e) => e.type === 'change_detected')).toBe(true); + }); + + it('recordChangeDetection değişiklik yoksa olay yaymaz', () => { + svc.startScan({ ignitionConfirmed: false }); + const { events } = collector(svc); + + svc.recordChangeDetection({ changedEcu: false, changedFirmware: false }); + + expect(events.some((e) => e.type === 'change_detected')).toBe(false); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 17. Warnings bounded + * ════════════════════════════════════════════════════════════════════════ */ + +describe('uyarılar (warnings)', () => { + it('bounded — tavanı aşmaz, en eskisi düşer', () => { + const s = startedWithIgnition(); + for (let i = 0; i < MAX_WARNINGS + 20; i++) { + s.reportPhaseFailure('capability_analysis', `err${i}`); + } + + const w = s.getSnapshot().warnings; + expect(w.length).toBe(MAX_WARNINGS); + expect(w[w.length - 1]).toContain(`err${MAX_WARNINGS + 19}`); // en yenisi korunur + s.dispose(); + }); + + it('kritik olmayan faz hatası taramayı düşürmez (fail-soft, atlanır)', () => { + const s = startedWithIgnition(); + s.updatePhase('ecu_discovery'); + s.reportPhaseFailure('firmware_inventory', 'no_response'); + + expect(s.getSnapshot().status).toBe('scanning'); // devam + expect(s.getSnapshot().warnings.some((x) => x.includes('phase_skipped'))).toBe(true); + s.dispose(); + }); + + it('kritik faz hatası (kimlik/protokol) taramayı failed yapar', () => { + expect(isCriticalPhase('vehicle_identity')).toBe(true); + expect(isCriticalPhase('protocol_detection')).toBe(true); + expect(isCriticalPhase('firmware_inventory')).toBe(false); + + const s = startedWithIgnition(); + s.reportPhaseFailure('protocol_detection', 'no_protocol'); + + expect(s.getSnapshot().status).toBe('failed'); + expect(s.getSnapshot().errorCode).toContain('protocol_detection'); + s.dispose(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 18-19. Dinleyiciler + * ════════════════════════════════════════════════════════════════════════ */ + +describe('olay aboneliği', () => { + it('subscribe/unsubscribe çalışıyor', () => { + const s = startedWithIgnition(); + const { events, unsub } = collector(s); + + s.updatePhase('ecu_discovery'); + const count = events.length; + expect(count).toBeGreaterThan(0); + + unsub(); + s.updateProgress(90); + expect(events.length).toBe(count); // artık olay gelmiyor + expect(s.listenerCount).toBe(0); + s.dispose(); + }); + + it('aynı dinleyici iki kez eklenirse duplicate oluşmaz', () => { + const s = startedWithIgnition(); + const fn = vi.fn(); + s.subscribe(fn); + s.subscribe(fn); + s.subscribe(fn); + + expect(s.listenerCount).toBe(1); + s.updateProgress(50); + expect(fn).toHaveBeenCalledTimes(1); + s.dispose(); + }); + + it('dinleyici hatası servisi çökertmez ve raporlanır', () => { + vi.spyOn(console, 'error').mockImplementation(() => { /* sessiz */ }); + const s = startedWithIgnition(); + const good = vi.fn(); + + s.subscribe(() => { throw new Error('dinleyici patladı'); }); + s.subscribe(good); + + expect(() => s.updateProgress(50)).not.toThrow(); + expect(good).toHaveBeenCalled(); // diğer dinleyici çalıştı + expect(s.getSnapshot().progressPercent).toBe(50); // durum bozulmadı + expect(s.getSnapshot().warnings.some((w) => w.startsWith('listener_error'))).toBe(true); + s.dispose(); + }); + + it('olay zarfı dondurulmuş snapshot taşır', () => { + const s = startedWithIgnition(); + const { events } = collector(s); + s.updateProgress(10); + + expect(Object.isFrozen(events[0])).toBe(true); + expect(Object.isFrozen(events[0].snapshot)).toBe(true); + s.dispose(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 20-25. Yaşam döngüsü + * ════════════════════════════════════════════════════════════════════════ */ + +describe('yaşam döngüsü', () => { + it('startScan idempotent — ikinci çağrı yeni scanId üretmez', () => { + svc.startScan({ vehicleFingerprintHash: HASH, ignitionConfirmed: true }); + const id = svc.getSnapshot().scanId; + + svc.startScan({ vehicleFingerprintHash: HASH, hasCompletedScanBefore: true, ignitionConfirmed: true }); + + expect(svc.getSnapshot().scanId).toBe(id); + expect(svc.getSnapshot().mode).toBe('FULL_SCAN'); // ilk karar korundu + }); + + it('cancel çalışıyor ve terminal durumda mutasyon kabul edilmez', () => { + const s = startedWithIgnition(); + s.updatePhase('ecu_discovery'); + s.cancelScan('kullanıcı iptali'); + + expect(s.getSnapshot().status).toBe('cancelled'); + + s.updateProgress(99); + s.updatePhase('report_generation'); + s.recordEcuDiscovery({ ecuAddress: '7EA' }); + + expect(s.getSnapshot().status).toBe('cancelled'); + expect(s.getSnapshot().progressPercent).toBeLessThan(99); + expect(s.getSnapshot().discoveredEcuCount).toBe(0); + s.dispose(); + }); + + it('pause/resume çalışıyor', () => { + const s = startedWithIgnition(); + s.updatePhase('ecu_discovery'); + const { events } = collector(s); + + s.pauseScan('termal'); + expect(s.getSnapshot().status).toBe('paused'); + s.updatePhase('standard_pid_discovery'); // duraklamışken faz değişmez + expect(s.getSnapshot().phase).toBe('ecu_discovery'); + + s.resumeScan(); + expect(s.getSnapshot().status).toBe('scanning'); + expect(events.map((e) => e.type)).toContain('scan_paused'); + expect(events.map((e) => e.type)).toContain('scan_resumed'); + s.dispose(); + }); + + it('duraklamışken kontak kaybedilirse resume aktif faza dönmez', () => { + const s = startedWithIgnition(); + s.updatePhase('ecu_discovery'); + s.pauseScan(); + s.setIgnitionConfirmed(false); + + s.resumeScan(); + + expect(s.getSnapshot().status).toBe('waiting_for_ignition'); + s.dispose(); + }); + + it('reset çalışıyor — durum idle\'a döner, dinleyiciler KORUNUR', () => { + const s = startedWithIgnition(); + s.recordEcuDiscovery({ ecuAddress: '7E8' }); + s.updateProgress(50); + const fn = vi.fn(); + s.subscribe(fn); + + s.reset(); + + const snap = s.getSnapshot(); + expect(snap.status).toBe('idle'); + expect(snap.scanId).toBeNull(); + expect(snap.progressPercent).toBe(0); + expect(snap.discoveredEcuCount).toBe(0); + expect(snap.ignitionConfirmed).toBeNull(); + expect(s.listenerCount).toBe(1); // dinleyici korundu + + s.startScan({ ignitionConfirmed: true }); // reset sonrası yeniden başlatılabilir + expect(s.getSnapshot().status).toBe('preparing'); + s.dispose(); + }); + + it('dispose zero-leak — dinleyiciler bırakılır, sonraki çağrılar no-op', () => { + const s = startedWithIgnition(); + const fn = vi.fn(); + s.subscribe(fn); + expect(s.listenerCount).toBe(1); + + s.dispose(); + + expect(s.listenerCount).toBe(0); + expect(s.isDisposed).toBe(true); + + s.startScan({ ignitionConfirmed: true }); + s.updateProgress(80); + s.recordEcuDiscovery({ ecuAddress: '7E8' }); + + expect(s.getSnapshot().status).toBe('idle'); + expect(s.getSnapshot().progressPercent).toBe(0); + expect(fn).not.toHaveBeenCalled(); + }); + + it('dispose idempotent', () => { + const s = startedWithIgnition(); + s.dispose(); + expect(() => s.dispose()).not.toThrow(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 26-27. Immutability + * ════════════════════════════════════════════════════════════════════════ */ + +describe('immutability', () => { + it('snapshot ve warnings dizisi dondurulmuştur', () => { + const s = startedWithIgnition(); + s.reportPhaseFailure('capability_analysis', 'x'); + const snap = s.getSnapshot(); + + expect(Object.isFrozen(snap)).toBe(true); + expect(Object.isFrozen(snap.warnings)).toBe(true); + + const mutable = snap as unknown as { progressPercent: number }; + try { mutable.progressPercent = 99; } catch { /* strict mode → TypeError */ } + expect(snap.progressPercent).not.toBe(99); + s.dispose(); + }); + + it('rapor özeti dondurulmuştur', () => { + const s = startedWithIgnition(); + s.completeScan(); + expect(Object.isFrozen(s.getSnapshot().reportSummary)).toBe(true); + s.dispose(); + }); + + it('girdi nesnelerini MUTASYONA UĞRATMAZ', () => { + const s = startedWithIgnition(); + const start = { vehicleFingerprintHash: HASH, hasCompletedScanBefore: true, ignitionConfirmed: true }; + const ecu = { ecuAddress: '7E8', isNew: true }; + const pid = { pidOrDid: '0C', ecuAddress: '7E8', isNew: true }; + const before = JSON.stringify([start, ecu, pid]); + + s.recordEcuDiscovery(ecu); + s.recordPidDiscovery(pid); + svc.startScan(start); + + expect(JSON.stringify([start, ecu, pid])).toBe(before); + s.dispose(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 28. Gizlilik + * ════════════════════════════════════════════════════════════════════════ */ + +describe('gizlilik — hassas veri snapshot\'a sızmaz', () => { + it('VIN parmak izi hash\'i olarak KABUL EDİLMEZ (17 karakter kapısı)', () => { + svc.startScan({ vehicleFingerprintHash: 'WF0AXXTTRAJA12345', ignitionConfirmed: true }); + + const snap = svc.getSnapshot(); + expect(snap.vehicleFingerprintHash).toBeNull(); + expect(JSON.stringify(snap)).not.toContain('WF0AXXTTRAJA12345'); + expect(snap.warnings).toContain('invalid_fingerprint_hash'); + }); + + it('geçerli hash korunur', () => { + svc.startScan({ vehicleFingerprintHash: HASH, ignitionConfirmed: true }); + expect(svc.getSnapshot().vehicleFingerprintHash).toBe(HASH); + }); + + it('normalizeFingerprintHash: VIN / serbest metin / kısa değer reddedilir', () => { + expect(normalizeFingerprintHash(HASH)).toBe(HASH); + expect(normalizeFingerprintHash('A1B2C3D4E5F60718')).toBe(HASH); // büyük harf → küçük + expect(normalizeFingerprintHash('WF0AXXTTRAJA12345')).toBeNull(); // 17 = VIN + expect(normalizeFingerprintHash('Renault Trafic')).toBeNull(); + expect(normalizeFingerprintHash('abc')).toBeNull(); + expect(normalizeFingerprintHash(42)).toBeNull(); + }); + + it('uyarı ve hata metinlerinden MAC / koordinat / ham CAN / secret temizlenir', () => { + const s = startedWithIgnition(); + s.reportPhaseFailure('capability_analysis', 'mac AA:BB:CC:DD:EE:FF'); + s.reportPhaseFailure('capability_analysis', 'konum 40.9901, 29.0250'); + s.reportPhaseFailure('capability_analysis', 'frame 7E803410C1AF8'); + s.failScan('key sk_live_abcdefghijklmnop'); + + const snap = s.getSnapshot(); + const json = JSON.stringify(snap); + expect(json).not.toContain('AA:BB:CC:DD:EE:FF'); + expect(json).not.toContain('40.9901'); + expect(json).not.toContain('7E803410C1AF8'); + expect(json).not.toContain('sk_live_abcdefghijklmnop'); + expect(json).toContain('[redacted]'); + s.dispose(); + }); + + it('sanitizeText saf fonksiyonu girdiyi bozmadan temizler', () => { + expect(sanitizeText('VIN WF0AXXTTRAJA12345 var')).toBe('VIN [redacted] var'); + expect(sanitizeText('mac 00:11:22:33:44:55')).toBe('mac [redacted]'); + expect(sanitizeText(123)).toBe(''); + expect(sanitizeText('temiz metin')).toBe('temiz metin'); + expect(sanitizeText('x'.repeat(300)).length).toBe(160); + }); + + it('snapshot ECU adresi / ham hex TAŞIMAZ — yalnız sayım', () => { + const s = startedWithIgnition(); + s.recordEcuDiscovery({ ecuAddress: '18DAF110' }); + s.recordPidDiscovery({ pidOrDid: '0C', ecuAddress: '18DAF110' }); + + const json = JSON.stringify(s.getSnapshot()); + expect(json).not.toContain('18DAF110'); + expect(s.getSnapshot().discoveredEcuCount).toBe(1); + s.dispose(); + }); +}); + +/* ══════════════════════════════════════════════════════════════════════════ + * 29-30. Yan etki yok / wiring yok + * ════════════════════════════════════════════════════════════════════════ */ + +describe('foundation güvencesi — yan etki ve wiring yok', () => { + it('SystemBoot Deep Scan servisini BAŞLATMAZ (wiring yok)', () => { + expect(systemBootSrc).not.toContain('deepScan'); + expect(systemBootSrc).not.toContain('DeepScan'); + }); + + it('modül import\'u yan etki üretmez — tekil servis idle ve dinleyicisiz', () => { + expect(deepScanRuntimeService.getSnapshot().status).toBe('idle'); + expect(deepScanRuntimeService.getSnapshot().scanId).toBeNull(); + expect(deepScanRuntimeService.listenerCount).toBe(0); + expect(deepScanRuntimeService.isDisposed).toBe(false); + }); + + it('pasif servis (tarama başlatılmamış) hiçbir mutasyonu kabul etmez', () => { + const s = makeService(); + + s.updatePhase('ecu_discovery'); + s.updateProgress(50); + s.recordEcuDiscovery({ ecuAddress: '7E8' }); + s.completeScan(); + s.pauseScan(); + + const snap = s.getSnapshot(); + expect(snap.status).toBe('idle'); + expect(snap.phase).toBeNull(); + expect(snap.progressPercent).toBe(0); + expect(snap.discoveredEcuCount).toBe(0); + expect(snap.reportSummary).toBeNull(); + s.dispose(); + }); + + it('deepScanRuntimeService hiçbir OBD/discovery servisini import etmez', () => { + const imports = deepScanServiceSrc.match(/from\s+'[^']+'/g) ?? []; + expect(imports).toEqual(["from './deepScanModel'"]); // TEK import: saf model + }); + + it('isActivePhase sınıflandırması aktif/offline fazları doğru ayırır', () => { + expect(isActivePhase('ecu_discovery')).toBe(true); + expect(isActivePhase('firmware_inventory')).toBe(true); + expect(isActivePhase('capability_analysis')).toBe(false); + expect(isActivePhase('report_generation')).toBe(false); + }); +}); diff --git a/src/platform/deepScan/deepScanModel.ts b/src/platform/deepScan/deepScanModel.ts new file mode 100644 index 00000000..c1fc1bf8 --- /dev/null +++ b/src/platform/deepScan/deepScanModel.ts @@ -0,0 +1,361 @@ +/** + * deepScanModel — Deep Scan durum/faz modeli ve SAF yardımcıları. + * + * Bu dosya YALNIZ tip + sabit + SAF fonksiyon içerir: servis import'u YOK, yan + * etki YOK, timer YOK. Böylece kural motoru (hangi faz kontak ister? progress + * tabanı nedir? uyarı metni nasıl temizlenir?) cihazsız, mock'suz test edilir. + * + * VİZYON (docs/CAROS_VEHICLE_INTELLIGENCE_ARCHITECTURE.md): Deep Scan, yeni bir + * araç ilk kez bağlandığında kapsamlı keşif yapar; sonucu kalıcı öğrenir; sonraki + * bağlantılarda tam tarama yerine yalnız DEĞİŞİKLİK KONTROLÜ yapar. Bu dosya o + * davranışın karar modelini (FULL_SCAN / CHANGE_CHECK) taşır. + * + * ⚠️ KONTAK (ignition): Bu depoda kontak/ACC durumunu yayan GERÇEK bir kaynak + * YOKTUR (`obdDiagnosticTypes.IGNITION_OFF` yalnız bir mesaj sabiti — hiçbir yerde + * emit edilmiyor). Bu yüzden `ignitionConfirmed` üç durumludur: `true` (dışarıdan + * doğrulandı) · `false` (dışarıdan kapalı bildirildi) · `null` (BİLİNMİYOR). + * `null` kontak AÇIK sayılmaz — aktif araç sorgusu gerektiren fazlar açılmaz. + * Kontak formülü BU PR'DA UYDURULMAZ. + */ + +/* ══════════════════════════════════════════════════════════════════════════ + * Durum / faz / mod + * ════════════════════════════════════════════════════════════════════════ */ + +export type DeepScanStatus = + | 'idle' + | 'waiting_for_ignition' + | 'preparing' + | 'scanning' + | 'analyzing' + | 'completed' + | 'paused' + | 'cancelled' + | 'failed'; + +export type DeepScanPhase = + | 'vehicle_identity' + | 'protocol_detection' + | 'ecu_discovery' + | 'standard_pid_discovery' + | 'manufacturer_did_discovery' + | 'firmware_inventory' + | 'capability_analysis' + | 'fingerprint_update' + | 'knowledge_update' + | 'evidence_update' + | 'change_detection' + | 'report_generation'; + +/** Tarama modu — yeni araç mı, öğrenilmiş araç mı. */ +export type DeepScanMode = 'FULL_SCAN' | 'CHANGE_CHECK'; + +/** Terminal (artık mutasyon kabul etmeyen) durumlar. */ +export const TERMINAL_STATUSES: readonly DeepScanStatus[] = ['completed', 'cancelled', 'failed']; + +export function isTerminalStatus(status: DeepScanStatus): boolean { + return TERMINAL_STATUSES.includes(status); +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Faz sınıflandırması — AKTİF (araca sorgu gönderir) vs OFFLINE (yerel analiz) + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Araca AKTİF sorgu gönderen fazlar. Bunlar YALNIZ `ignitionConfirmed === true` + * iken çalışabilir. `vehicle_identity` (Mode 09 VIN sorgusu) ve + * `firmware_inventory` (DID sorgusu) de araca istek gönderir → aktif sayılır. + */ +export const ACTIVE_PHASES: readonly DeepScanPhase[] = [ + 'vehicle_identity', + 'protocol_detection', + 'ecu_discovery', + 'standard_pid_discovery', + 'manufacturer_did_discovery', + 'firmware_inventory', +]; + +/** + * Araca hiç sorgu göndermeyen, önceden toplanmış veriyi işleyen fazlar. + * Kontak kapalı/bilinmiyorken de çalışabilirler. + */ +export const OFFLINE_PHASES: readonly DeepScanPhase[] = [ + 'capability_analysis', + 'fingerprint_update', + 'knowledge_update', + 'evidence_update', + 'change_detection', + 'report_generation', +]; + +/** Faz araca aktif sorgu gönderir mi. */ +export function isActivePhase(phase: DeepScanPhase): boolean { + return ACTIVE_PHASES.includes(phase); +} + +/** + * Kritik fazlar: bunlar başarısız olursa tarama anlamını yitirir → `failed`. + * Diğer fazların hatası uyarıya çevrilir ve faz ATLANABİLİR (fail-soft). + */ +export const CRITICAL_PHASES: readonly DeepScanPhase[] = [ + 'vehicle_identity', + 'protocol_detection', +]; + +export function isCriticalPhase(phase: DeepScanPhase): boolean { + return CRITICAL_PHASES.includes(phase); +} + +/** + * Bir faz verilen kontak durumunda çalıştırılabilir mi. + * `ignitionConfirmed === true` DEĞİLSE (false VEYA null=bilinmiyor) aktif fazlar + * REDDEDİLİR — "kontak açıkmış gibi varsayma" kuralı (fail-closed). + */ +export function canRunPhase(phase: DeepScanPhase, ignitionConfirmed: boolean | null): boolean { + if (!isActivePhase(phase)) return true; // offline faz: her koşulda serbest + return ignitionConfirmed === true; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * İlerleme (progress) modeli — deterministik + monotonik + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Faz tabanları: bir faza girildiğinde progress EN AZ bu değere çıkar (asla + * düşmez). Sahte ilerleme üretilmez — bunlar faz SIRASININ deterministik + * yansımasıdır; faz içi ince ilerleme yalnız `updateProgress()` ile ÖLÇÜLMÜŞ + * değer olarak gelir (gerçek orchestrator ayrı PR). + */ +export const PHASE_PROGRESS_FLOOR: Readonly> = { + vehicle_identity: 5, + protocol_detection: 12, + ecu_discovery: 20, + standard_pid_discovery: 35, + manufacturer_did_discovery: 50, + firmware_inventory: 62, + capability_analysis: 70, + fingerprint_update: 78, + knowledge_update: 84, + evidence_update: 89, + change_detection: 94, + report_generation: 97, +}; + +/** [0,100] aralığına sabitler; geçersiz sayı → 0. */ +export function clampProgress(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value)) return 0; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +} + +/** Monotonik ilerleme: yeni değer mevcuttan küçükse mevcut korunur. */ +export function monotonicProgress(current: number, next: unknown): number { + const c = clampProgress(current); + const n = clampProgress(next); + return n > c ? n : c; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Gizlilik — uyarı/özet metinlerinden hassas içerik temizlenir + * ════════════════════════════════════════════════════════════════════════ */ + +/** Bounded: en fazla bu kadar uyarı tutulur (en eskisi düşer). */ +export const MAX_WARNINGS = 16; +/** Bounded: en fazla bu kadar dinleyici (duplicate Set semantiğiyle zaten engellenir). */ +export const MAX_LISTENERS = 32; +/** Uyarı metni üst sınırı. */ +export const MAX_WARNING_CHARS = 160; + +const VIN_RE = /\b[A-HJ-NPR-Z0-9]{17}\b/g; +const MAC_RE = /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g; +const COORD_RE = /-?\d{1,3}\.\d{4,}\s*[,;]\s*-?\d{1,3}\.\d{4,}/g; +/** Ham CAN/OBD frame şüphesi: 8+ karakterlik sürekli hex öbeği. */ +const RAW_HEX_RE = /\b[0-9A-Fa-f]{8,}\b/g; +/** Anahtar/secret şüphesi: uzun base64/token benzeri diziler. */ +const SECRET_RE = /\b(?:sk|pk|api|key|token|bearer)[-_]?[A-Za-z0-9_-]{12,}\b/gi; + +/** + * Uyarı/özet metnini gizlilik açısından temizler: VIN · MAC · koordinat · ham hex + * (CAN frame) · API key/secret kalıpları `[redacted]` ile değiştirilir, sonuç + * kırpılır. SAF — girdiyi mutate etmez. Sıra önemli: SECRET önce (içinde hex + * öbeği barındırabilir), RAW_HEX en sonda. + */ +export function sanitizeText(input: unknown, maxChars: number = MAX_WARNING_CHARS): string { + if (typeof input !== 'string') return ''; + const cleaned = input + .replace(SECRET_RE, '[redacted]') + .replace(MAC_RE, '[redacted]') + .replace(VIN_RE, '[redacted]') + .replace(COORD_RE, '[redacted]') + .replace(RAW_HEX_RE, '[redacted]') + .trim(); + return cleaned.length > maxChars ? cleaned.slice(0, maxChars) : cleaned; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Snapshot / rapor / olaylar + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Tarama raporu özeti. Ham veri TAŞIMAZ — yalnız sayımlar ve bayraklar. + * (`useAssistantContextStore` deepScan bölümüyle alan-uyumlu: completed / + * progress / newDiscoveriesCount / changedFirmware / changedECU.) + */ +export interface DeepScanReportSummary { + readonly mode: DeepScanMode; + readonly ecuCount: number; + readonly pidCount: number; + readonly didCount: number; + readonly newDiscoveriesCount: number; + readonly firmwareCheckedCount: number; + readonly changedFirmware: boolean; + readonly changedEcu: boolean; + readonly warningCount: number; + /** Tarama süresi (ms) — `startedAt`→`completedAt` farkı. */ + readonly durationMs: number; + /** Serbest, TEMİZLENMİŞ not (opsiyonel). Ham veri içermez. */ + readonly note: string | null; +} + +/** Dondurulmuş, ham/gizli alan içermeyen tarama durumu. */ +export interface DeepScanSnapshot { + readonly scanId: string | null; + /** Araç parmak izi HASH'i (VIN değil — türetilmiş kimlik). */ + readonly vehicleFingerprintHash: string | null; + readonly status: DeepScanStatus; + readonly mode: DeepScanMode | null; + readonly phase: DeepScanPhase | null; + readonly progressPercent: number; + readonly startedAt: number | null; + readonly updatedAt: number | null; + readonly completedAt: number | null; + readonly isFirstScan: boolean; + /** Aktif fazlar için kontak gerekiyor mu (bu servis için daima true). */ + readonly ignitionRequired: boolean; + /** `null` = BİLİNMİYOR (gerçek kaynak yok) — açık VARSAYILMAZ. */ + readonly ignitionConfirmed: boolean | null; + readonly discoveredEcuCount: number; + readonly discoveredPidCount: number; + readonly discoveredDidCount: number; + readonly newDiscoveriesCount: number; + readonly changedFirmware: boolean; + readonly changedEcu: boolean; + readonly warnings: readonly string[]; + readonly errorCode: string | null; + readonly reportSummary: DeepScanReportSummary | null; +} + +/** Olay tipleri — `subscribe()` ile yayınlanır. */ +export type DeepScanEventType = + | 'scan_started' + | 'ignition_required' + | 'phase_changed' + | 'progress_changed' + | 'ecu_discovered' + | 'pid_discovered' + | 'did_discovered' + | 'firmware_checked' + | 'change_detected' + | 'scan_paused' + | 'scan_resumed' + | 'scan_completed' + | 'scan_failed' + | 'scan_cancelled'; + +/** Olay zarfı. `snapshot` dondurulmuş; `reason`/`code` temizlenmiş metindir. */ +export interface DeepScanEvent { + readonly type: DeepScanEventType; + readonly at: number; + readonly snapshot: DeepScanSnapshot; + readonly reason: string | null; +} + +export type DeepScanListener = (event: DeepScanEvent) => void; + +/* ══════════════════════════════════════════════════════════════════════════ + * Girdi tipleri (servis API'si) — hepsi salt-okunur, mutate EDİLMEZ + * ════════════════════════════════════════════════════════════════════════ */ + +export interface StartDeepScanInput { + /** Araç parmak izi hash'i (VIN DEĞİL). Bilinmiyorsa boş bırakılabilir. */ + readonly vehicleFingerprintHash?: string; + /** + * Bu araç için daha ÖNCE tamamlanmış bir Deep Scan var mı. Çağıran bilir + * (kalıcı geçmiş deposu BU PR'ın kapsamı DEĞİL). true → CHANGE_CHECK. + */ + readonly hasCompletedScanBefore?: boolean; + /** Kontak durumu — `true` dışında her değer aktif fazları KAPALI tutar. */ + readonly ignitionConfirmed?: boolean | null; +} + +export interface EcuDiscoveryInput { + readonly ecuAddress: string; + /** Katalogda olmayan / ilk kez görülen mi. */ + readonly isNew?: boolean; +} + +export interface SignalDiscoveryInput { + /** PID veya DID (hex, ör. '0C' / 'F190'). */ + readonly pidOrDid: string; + readonly ecuAddress?: string; + readonly isNew?: boolean; +} + +export interface FirmwareResultInput { + readonly ecuAddress?: string; + /** Değişiklik TESPİT EDİLDİ mi. Bu servis firmware sürümünü SAKLAMAZ. */ + readonly changed?: boolean; +} + +export interface ChangeDetectionInput { + readonly changedFirmware?: boolean; + readonly changedEcu?: boolean; + readonly reason?: string; +} + +/** `completeScan()` için opsiyonel not (sayımlar servis tarafından üretilir). */ +export interface CompleteDeepScanInput { + readonly note?: string; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Mod kararı (SAF) + * ════════════════════════════════════════════════════════════════════════ */ + +/** + * Tarama modunu belirler: + * - Daha önce TAMAMLANMIŞ tarama YOKSA → yeni/ilk araç → `FULL_SCAN` + * - Daha önce tamamlanmışsa → aynı araca her bağlantıda tam tarama YAPMA → + * `CHANGE_CHECK` + */ +export function resolveScanMode(hasCompletedScanBefore: boolean | undefined): DeepScanMode { + return hasCompletedScanBefore === true ? 'CHANGE_CHECK' : 'FULL_SCAN'; +} + +/** `isFirstScan` mod kararının ikizidir — tek kaynak, tutarlılık garantisi. */ +export function resolveIsFirstScan(hasCompletedScanBefore: boolean | undefined): boolean { + return hasCompletedScanBefore !== true; +} + +/** Kontak üç-durumlu normalizasyon: yalnız gerçek boolean kabul, aksi → null. */ +export function normalizeIgnition(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null; +} + +/** + * Parmak izi hash'i doğrulaması. `vehicleFingerprintService.fingerprintHash()` + * 16 haneli hex üretir; ileriye dönük olarak 8–64 hex kabul edilir. + * + * ⚠️ VIN SIZINTI KAPISI: VIN 17 karakterdir → 17 uzunluğundaki her girdi + * REDDEDİLİR. Hex olmayan her girdi de reddedilir (`null`). Bu sayede snapshot'a + * yanlışlıkla VIN/plaka/serbest metin yazılamaz. `sanitizeText()` burada + * KULLANILMAZ: o, uzun hex öbeklerini ham CAN frame sanıp hash'i imha ederdi. + */ +export function normalizeFingerprintHash(value: unknown): string | null { + if (typeof value !== 'string') return null; + const v = value.trim(); + if (v.length === 17) return null; // VIN uzunluğu — asla kabul etme + if (!/^[0-9a-fA-F]{8,64}$/.test(v)) return null; + return v.toLowerCase(); +} diff --git a/src/platform/deepScan/deepScanRuntimeService.ts b/src/platform/deepScan/deepScanRuntimeService.ts new file mode 100644 index 00000000..72483c33 --- /dev/null +++ b/src/platform/deepScan/deepScanRuntimeService.ts @@ -0,0 +1,585 @@ +/** + * deepScanRuntimeService — Deep Scan runtime FOUNDATION katmanı. + * + * NE YAPAR: Deep Scan sürecinin DURUMUNU, İLERLEMESİNİ, KEŞİF SAYAÇLARINI, + * OLAYLARINI ve GÜVENLİ ÇALIŞMA KOŞULLARINI yönetir. + * + * NE YAPMAZ (bilinçli — bu PR foundation'dır): + * - Gerçek ECU/PID/DID taraması BAŞLATMAZ. Araca TEK BİR sorgu bile göndermez. + * - Hiçbir discovery/OBD/fingerprint/learning servisini import ETMEZ → import + * edildiğinde YAN ETKİ ÜRETMEZ (timer yok, abonelik yok, native çağrı yok). + * - SystemBoot'a bağlanmaz (yanlışlıkla araca aktif sorgu gitmesin). + * - Kalıcı tarama geçmişi TUTMAZ (`hasCompletedScanBefore` çağırandan gelir). + * Kalıcılık ayrı PR kapsamıdır. + * + * KONTAK GÜVENLİĞİ (fail-closed): Bu depoda kontak/ACC durumunu yayan GERÇEK bir + * kaynak YOK. `ignitionConfirmed` üç durumlu (`true`/`false`/`null=bilinmiyor`) ve + * yalnız `setIgnitionConfirmed()` ile DIŞARIDAN beslenir — servis kontağı ASLA + * tahmin etmez. `true` DEĞİLSE: aktif faz (araca sorgu) açılmaz, keşif kaydı kabul + * edilmez; yalnız offline fazlar (analiz/rapor) çalışır. + * + * PERFORMANS: timer YOK, abonelik YOK, hot-path YOK. Pasifken (tarama yokken) yük + * sıfırdır — API çağrılmadıkça hiçbir kod çalışmaz. Tüm koleksiyonlar bounded. + * + * GİZLİLİK: snapshot'a ham VIN · MAC · koordinat · ham CAN frame · API key/secret + * GİRMEZ. ECU adresleri yalnız DEDUP anahtarı olarak bellekte tutulur, snapshot'a + * yalnız SAYIM olarak yansır. Serbest metinler (`warnings`, `errorCode`, rapor notu) + * `sanitizeText()` süzgecinden geçer. + * + * ZERO-LEAK: `dispose()` tüm dinleyicileri ve koleksiyonları temizler; sonrasında + * her API çağrısı güvenli no-op'tur (dangling referans snapshot yayınlayamaz). + */ + +import { + isActivePhase, + isCriticalPhase, + isTerminalStatus, + monotonicProgress, + normalizeFingerprintHash, + normalizeIgnition, + resolveIsFirstScan, + resolveScanMode, + sanitizeText, + canRunPhase, + MAX_LISTENERS, + MAX_WARNINGS, + PHASE_PROGRESS_FLOOR, + type ChangeDetectionInput, + type CompleteDeepScanInput, + type DeepScanEvent, + type DeepScanEventType, + type DeepScanListener, + type DeepScanMode, + type DeepScanPhase, + type DeepScanReportSummary, + type DeepScanSnapshot, + type DeepScanStatus, + type EcuDiscoveryInput, + type FirmwareResultInput, + type SignalDiscoveryInput, + type StartDeepScanInput, +} from './deepScanModel'; + +/** Dedup anahtar kümesi tavanı (DiscoveryCaptureService ile aynı mertebe). */ +export const MAX_DISCOVERY_KEYS = 4096; + +/** Enjekte edilebilir bağımlılıklar (test için; prod varsayılanı sistem saati). */ +export interface DeepScanRuntimeDeps { + now: () => number; +} + +const DEFAULT_DEPS: DeepScanRuntimeDeps = { now: () => Date.now() }; + +/** Normalize edilmiş hex kimliği (dedup anahtarı). Boş/geçersiz → ''. */ +function normHex(v: unknown): string { + if (typeof v !== 'string') return ''; + return v.trim().toUpperCase().replace(/\s+/g, '').replace(/^0X/, ''); +} + +/** Bounded Set ekleme. @returns gerçekten YENİ eklendi mi (duplicate → false). */ +function addBounded(set: Set, key: string): boolean { + if (!key) return false; + if (set.has(key)) return false; + if (set.size >= MAX_DISCOVERY_KEYS) return false; // tavan: sayaç şişmez, sızıntı yok + set.add(key); + return true; +} + +/* ══════════════════════════════════════════════════════════════════════════ + * Servis + * ════════════════════════════════════════════════════════════════════════ */ + +export class DeepScanRuntimeService { + private readonly _deps: DeepScanRuntimeDeps; + private readonly _listeners = new Set(); + + private _disposed = false; + private _scanCounter = 0; + + /* ── Durum ─────────────────────────────────────────────────────────────── */ + private _scanId: string | null = null; + private _hash: string | null = null; + private _status: DeepScanStatus = 'idle'; + private _mode: DeepScanMode | null = null; + private _phase: DeepScanPhase | null = null; + private _progress = 0; + private _startedAt: number | null = null; + private _updatedAt: number | null = null; + private _completedAt: number | null = null; + private _isFirstScan = true; + private _ignition: boolean | null = null; + private _changedFirmware = false; + private _changedEcu = false; + private _firmwareChecked = 0; + private _errorCode: string | null = null; + private _report: DeepScanReportSummary | null = null; + private _warnings: string[] = []; + + /** `pauseScan()` öncesi durum — `resumeScan()` buraya döner. */ + private _resumeStatus: DeepScanStatus | null = null; + + /* ── Keşif dedup kümeleri (snapshot'a yalnız SAYIM olarak yansır) ──────── */ + private _ecus = new Set(); + private _pids = new Set(); + private _dids = new Set(); + private _newKeys = new Set(); + + /** Dondurulmuş snapshot önbelleği — durum değişince geçersizleşir. */ + private _snapshot: DeepScanSnapshot | null = null; + + constructor(deps: Partial = {}) { + this._deps = { ...DEFAULT_DEPS, ...deps }; + } + + /* ══════════════════════════════════════════════════════════════════════ + * Dahili yardımcılar + * ════════════════════════════════════════════════════════════════════ */ + + private _now(): number { + try { return this._deps.now(); } catch { return 0; } + } + + /** Tarama şu an mutasyon kabul ediyor mu (başlamış + terminal değil + canlı). */ + private _isMutable(): boolean { + return !this._disposed && this._scanId !== null && !isTerminalStatus(this._status); + } + + /** Uyarı ekler (temizlenmiş + bounded, en eskisi düşer). Olay YAYINLAMAZ. */ + private _warn(message: string): void { + const clean = sanitizeText(message); + if (!clean) return; + this._warnings.push(clean); + if (this._warnings.length > MAX_WARNINGS) { + this._warnings.splice(0, this._warnings.length - MAX_WARNINGS); + } + this._snapshot = null; + } + + private _touch(): void { + this._updatedAt = this._now(); + this._snapshot = null; + } + + /** Dinleyicilere olay yayınlar. Bir dinleyicinin hatası servisi ÇÖKERTMEZ. */ + private _emit(type: DeepScanEventType, reason?: string): void { + if (this._disposed || this._listeners.size === 0) return; + const event: DeepScanEvent = Object.freeze({ + type, + at: this._now(), + snapshot: this.getSnapshot(), + reason: reason ? sanitizeText(reason) : null, + }); + // Kopya üstünde dön: dinleyici içinde unsubscribe çağrılırsa iterasyon bozulmaz. + for (const listener of [...this._listeners]) { + try { + listener(event); + } catch (err) { + console.error(`[DeepScan] dinleyici hatası (${type}) — servis etkilenmedi`, err); + this._warn(`listener_error:${type}`); // raporlanır, olay YENİDEN yayılmaz + } + } + } + + /** Kontak doğrulanmadan aktif işe girişildi → beklemeye al + uyarı + olay. */ + private _requireIgnition(context: string): void { + this._warn(`ignition_not_confirmed:${context}`); + if (this._status !== 'waiting_for_ignition') { + this._status = 'waiting_for_ignition'; + this._touch(); + } + this._emit('ignition_required', context); + } + + /* ══════════════════════════════════════════════════════════════════════ + * Snapshot (immutable) + * ════════════════════════════════════════════════════════════════════ */ + + getSnapshot(): DeepScanSnapshot { + if (this._snapshot) return this._snapshot; + this._snapshot = Object.freeze({ + scanId: this._scanId, + vehicleFingerprintHash: this._hash, + status: this._status, + mode: this._mode, + phase: this._phase, + progressPercent: this._progress, + startedAt: this._startedAt, + updatedAt: this._updatedAt, + completedAt: this._completedAt, + isFirstScan: this._isFirstScan, + ignitionRequired: true, // aktif fazlar için daima kontak gerekir + ignitionConfirmed: this._ignition, + discoveredEcuCount: this._ecus.size, + discoveredPidCount: this._pids.size, + discoveredDidCount: this._dids.size, + newDiscoveriesCount: this._newKeys.size, + changedFirmware: this._changedFirmware, + changedEcu: this._changedEcu, + warnings: Object.freeze([...this._warnings]), + errorCode: this._errorCode, + reportSummary: this._report, + }); + return this._snapshot; + } + + /* ══════════════════════════════════════════════════════════════════════ + * Yaşam döngüsü + * ════════════════════════════════════════════════════════════════════ */ + + /** + * Taramayı başlatır. İDEMPOTENT: `idle` dışındaki bir durumda çağrılırsa + * hiçbir şey değişmez (yeni scanId üretilmez) — yeniden başlatmak için önce + * `reset()` çağrılmalıdır. + * + * Mod: daha önce tamamlanmış tarama YOKSA `FULL_SCAN`, VARSA `CHANGE_CHECK` + * (aynı araca her bağlantıda tam tarama yapılmaz — vizyon kuralı). + * + * Kontak `true` değilse durum `waiting_for_ignition` olur ve `ignition_required` + * yayınlanır; aktif tarama OTOMATİK BAŞLAMAZ. + */ + startScan(input: StartDeepScanInput = {}): DeepScanSnapshot { + if (this._disposed) return this.getSnapshot(); + if (this._status !== 'idle') return this.getSnapshot(); // idempotent + + const now = this._now(); + this._scanCounter += 1; + this._scanId = `scan-${now}-${this._scanCounter}`; + this._hash = normalizeFingerprintHash(input.vehicleFingerprintHash); + if (input.vehicleFingerprintHash !== undefined && this._hash === null) { + this._warn('invalid_fingerprint_hash'); // VIN/serbest metin reddedildi + } + this._mode = resolveScanMode(input.hasCompletedScanBefore); + this._isFirstScan = resolveIsFirstScan(input.hasCompletedScanBefore); + this._ignition = normalizeIgnition(input.ignitionConfirmed); + this._phase = null; + this._progress = 0; + this._startedAt = now; + this._updatedAt = now; + this._completedAt = null; + this._errorCode = null; + this._report = null; + this._resumeStatus = null; + this._status = this._ignition === true ? 'preparing' : 'waiting_for_ignition'; + this._snapshot = null; + + this._emit('scan_started'); + if (this._ignition !== true) this._emit('ignition_required', 'start'); + return this.getSnapshot(); + } + + /** + * Kontak durumunu DIŞARIDAN besler — servisin kontağı öğrenebileceği TEK yol. + * `boolean` dışındaki her değer `null` (bilinmiyor) sayılır. + * + * `true` olunca beklemedeki tarama `preparing`'e geçer. `true` olmaktan çıkınca + * aktif fazdaki tarama `waiting_for_ignition`'a düşer (araca sorgu kesilir). + */ + setIgnitionConfirmed(value: boolean | null, reason?: string): DeepScanSnapshot { + if (this._disposed) return this.getSnapshot(); + const next = normalizeIgnition(value); + if (next === this._ignition) return this.getSnapshot(); + this._ignition = next; + this._touch(); + + if (!this._isMutable()) return this.getSnapshot(); + + if (next === true) { + if (this._status === 'waiting_for_ignition') { + this._status = 'preparing'; + this._touch(); + this._emit('scan_resumed', reason ?? 'ignition_confirmed'); + } + } else if (this._status === 'scanning' || this._status === 'preparing') { + // Kontak kaybedildi → aktif sorgu gerektiren durumdan çık (fail-closed). + this._status = 'waiting_for_ignition'; + this._touch(); + this._emit('ignition_required', reason ?? 'ignition_lost'); + } + return this.getSnapshot(); + } + + /* ══════════════════════════════════════════════════════════════════════ + * Faz / ilerleme + * ════════════════════════════════════════════════════════════════════ */ + + /** + * Fazı günceller. Aktif faz (araca sorgu gönderen) yalnız kontak doğrulanmışsa + * kabul edilir; aksi hâlde faz DEĞİŞMEZ, durum `waiting_for_ignition` olur. + * Offline fazlar (analiz/rapor) kontak kapalıyken de çalışır. + */ + updatePhase(phase: DeepScanPhase): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + if (this._status === 'paused') return this.getSnapshot(); // önce resume + + if (!canRunPhase(phase, this._ignition)) { + this._requireIgnition(`phase:${phase}`); + return this.getSnapshot(); + } + + const before = this._progress; + this._phase = phase; + this._progress = monotonicProgress(this._progress, PHASE_PROGRESS_FLOOR[phase]); + this._status = isActivePhase(phase) ? 'scanning' : 'analyzing'; + this._touch(); + + this._emit('phase_changed', phase); + if (this._progress !== before) this._emit('progress_changed', phase); + return this.getSnapshot(); + } + + /** + * Ölçülmüş ilerleme bildirir. [0,100] clamp + MONOTONİK (geriye düşmez). + * Sahte ilerleme üretilmez — değer çağırandan gelir. + */ + updateProgress(progress: number): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + const next = monotonicProgress(this._progress, progress); + if (next === this._progress) return this.getSnapshot(); + this._progress = next; + this._touch(); + this._emit('progress_changed'); + return this.getSnapshot(); + } + + /* ══════════════════════════════════════════════════════════════════════ + * Keşif kayıtları — kontak doğrulanmadan KABUL EDİLMEZ (fail-closed) + * ════════════════════════════════════════════════════════════════════ */ + + /** Kontak + mutasyon kapısı: aktif araç sorgusundan gelen kayıtlar için. */ + private _acceptActiveRecord(context: string): boolean { + if (!this._isMutable()) return false; + if (this._ignition !== true) { this._requireIgnition(context); return false; } + return true; + } + + recordEcuDiscovery(input: EcuDiscoveryInput): DeepScanSnapshot { + if (!this._acceptActiveRecord('ecu_discovery')) return this.getSnapshot(); + const key = normHex(input?.ecuAddress); + if (!addBounded(this._ecus, key)) return this.getSnapshot(); // duplicate → sayaç şişmez + if (input.isNew === true) this._newKeys.add(`ECU:${key}`); + this._touch(); + this._emit('ecu_discovered'); + return this.getSnapshot(); + } + + recordPidDiscovery(input: SignalDiscoveryInput): DeepScanSnapshot { + if (!this._acceptActiveRecord('pid_discovery')) return this.getSnapshot(); + const pid = normHex(input?.pidOrDid); + const key = `${normHex(input?.ecuAddress)}:${pid}`; + if (!pid || !addBounded(this._pids, key)) return this.getSnapshot(); + if (input.isNew === true) this._newKeys.add(`PID:${key}`); + this._touch(); + this._emit('pid_discovered'); + return this.getSnapshot(); + } + + recordDidDiscovery(input: SignalDiscoveryInput): DeepScanSnapshot { + if (!this._acceptActiveRecord('did_discovery')) return this.getSnapshot(); + const did = normHex(input?.pidOrDid); + const key = `${normHex(input?.ecuAddress)}:${did}`; + if (!did || !addBounded(this._dids, key)) return this.getSnapshot(); + if (input.isNew === true) this._newKeys.add(`DID:${key}`); + this._touch(); + this._emit('did_discovered'); + return this.getSnapshot(); + } + + /** Firmware sorgusu sonucu. Sürüm metni SAKLANMAZ — yalnız sayım + değişim bayrağı. */ + recordFirmwareResult(input: FirmwareResultInput): DeepScanSnapshot { + if (!this._acceptActiveRecord('firmware_inventory')) return this.getSnapshot(); + this._firmwareChecked += 1; + if (input?.changed === true) this._changedFirmware = true; + this._touch(); + this._emit('firmware_checked'); + return this.getSnapshot(); + } + + /** + * Değişiklik tespiti (CHANGE_CHECK'in çıktısı). OFFLINE bir karardır — önceden + * toplanmış veriyle karşılaştırma → kontak GEREKTİRMEZ. + */ + recordChangeDetection(input: ChangeDetectionInput): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + let changed = false; + if (input?.changedFirmware === true && !this._changedFirmware) { this._changedFirmware = true; changed = true; } + if (input?.changedEcu === true && !this._changedEcu) { this._changedEcu = true; changed = true; } + if (!changed) return this.getSnapshot(); + this._touch(); + this._emit('change_detected', input.reason); + return this.getSnapshot(); + } + + /** + * Bir fazın başarısızlığını bildirir (fail-soft): + * - KRİTİK faz (kimlik/protokol) → tarama `failed`. + * - Kritik olmayan faz → uyarı eklenir, faz ATLANIR, tarama devam eder. + */ + reportPhaseFailure(phase: DeepScanPhase, errorCode: string): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + if (isCriticalPhase(phase)) return this.failScan(`${phase}:${errorCode}`); + this._warn(`phase_skipped:${phase}:${errorCode}`); + this._touch(); + return this.getSnapshot(); + } + + /* ══════════════════════════════════════════════════════════════════════ + * Duraklat / sürdür / iptal / hata / tamamla + * ════════════════════════════════════════════════════════════════════ */ + + pauseScan(reason?: string): DeepScanSnapshot { + if (!this._isMutable() || this._status === 'paused') return this.getSnapshot(); + this._resumeStatus = this._status; + this._status = 'paused'; + this._touch(); + this._emit('scan_paused', reason); + return this.getSnapshot(); + } + + /** + * Duraklatmadan döner. Aktif bir fazda duraklanmışsa ve kontak ARTIK doğrulanmış + * değilse `waiting_for_ignition`'a düşer (araca sorgu yeniden başlamaz). + */ + resumeScan(): DeepScanSnapshot { + if (this._disposed || this._status !== 'paused') return this.getSnapshot(); + + const target = this._resumeStatus ?? 'preparing'; + this._resumeStatus = null; + + const needsIgnition = this._phase !== null && isActivePhase(this._phase); + if (needsIgnition && this._ignition !== true) { + this._status = 'waiting_for_ignition'; + this._touch(); + this._emit('ignition_required', 'resume'); + return this.getSnapshot(); + } + + this._status = target === 'paused' ? 'preparing' : target; + this._touch(); + this._emit('scan_resumed'); + return this.getSnapshot(); + } + + /** İptal — mevcut progress KORUNUR (sahte 100 yazılmaz). Terminal. */ + cancelScan(reason?: string): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + this._status = 'cancelled'; + this._touch(); + this._emit('scan_cancelled', reason); + return this.getSnapshot(); + } + + /** Hata — mevcut progress KORUNUR. Terminal. */ + failScan(errorCode: string): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + this._status = 'failed'; + this._errorCode = sanitizeText(errorCode, 64) || 'unknown_error'; + this._touch(); + this._emit('scan_failed', this._errorCode); + return this.getSnapshot(); + } + + /** Tamamlandı → progress 100, rapor özeti üretilir. Terminal. */ + completeScan(input: CompleteDeepScanInput = {}): DeepScanSnapshot { + if (!this._isMutable()) return this.getSnapshot(); + const now = this._now(); + this._status = 'completed'; + this._progress = 100; + this._completedAt = now; + this._report = Object.freeze({ + mode: this._mode ?? 'FULL_SCAN', + ecuCount: this._ecus.size, + pidCount: this._pids.size, + didCount: this._dids.size, + newDiscoveriesCount: this._newKeys.size, + firmwareCheckedCount: this._firmwareChecked, + changedFirmware: this._changedFirmware, + changedEcu: this._changedEcu, + warningCount: this._warnings.length, + durationMs: this._startedAt !== null ? Math.max(0, now - this._startedAt) : 0, + note: sanitizeText(input?.note) || null, + }); + this._touch(); + this._emit('scan_completed'); + return this.getSnapshot(); + } + + /* ══════════════════════════════════════════════════════════════════════ + * Olay aboneliği (bounded · duplicate'siz · fail-soft) + * ════════════════════════════════════════════════════════════════════ */ + + /** + * Olaylara abone olur. Aynı fonksiyon ikinci kez verilirse Set semantiğiyle + * DUPLICATE OLUŞMAZ. Tavan dolarsa abonelik reddedilir (no-op cleanup döner). + * @returns cleanup thunk (idempotent) + */ + subscribe(listener: DeepScanListener): () => void { + if (this._disposed || typeof listener !== 'function') return () => { /* no-op */ }; + if (!this._listeners.has(listener) && this._listeners.size >= MAX_LISTENERS) { + this._warn('listener_limit_reached'); + return () => { /* no-op */ }; + } + this._listeners.add(listener); + return () => { this._listeners.delete(listener); }; + } + + /** Test/teşhis: aktif dinleyici sayısı (zero-leak kanıtı). */ + get listenerCount(): number { + return this._listeners.size; + } + + /* ══════════════════════════════════════════════════════════════════════ + * Sıfırlama / temizlik + * ════════════════════════════════════════════════════════════════════ */ + + /** Durumu `idle`'a döndürür. Dinleyiciler KORUNUR (yeni tarama izlenebilsin). */ + reset(): void { + if (this._disposed) return; + this._scanId = null; + this._hash = null; + this._status = 'idle'; + this._mode = null; + this._phase = null; + this._progress = 0; + this._startedAt = null; + this._updatedAt = null; + this._completedAt = null; + this._isFirstScan = true; + this._ignition = null; + this._changedFirmware = false; + this._changedEcu = false; + this._firmwareChecked = 0; + this._errorCode = null; + this._report = null; + this._resumeStatus = null; + this._warnings = []; + this._ecus = new Set(); + this._pids = new Set(); + this._dids = new Set(); + this._newKeys = new Set(); + this._snapshot = null; + } + + /** + * Zero-leak temizlik: dinleyiciler ve tüm koleksiyonlar bırakılır. Sonrasında + * her API çağrısı güvenli no-op'tur. Timer/abonelik açılmadığı için temizlenecek + * başka kaynak YOKTUR. + */ + dispose(): void { + if (this._disposed) return; + this.reset(); + this._listeners.clear(); + this._disposed = true; + this._snapshot = null; + } + + get isDisposed(): boolean { + return this._disposed; + } +} + +/** + * Uygulama geneli tekil servis. Yapıcı YAN ETKİ ÜRETMEZ (timer/abonelik/native + * çağrı yok) → yalnız import edilmesi hiçbir davranış değiştirmez. SystemBoot'a + * BAĞLI DEĞİLDİR (bilinçli — gerçek orchestration ayrı PR). + */ +export const deepScanRuntimeService = new DeepScanRuntimeService(); diff --git a/src/platform/deepScan/index.ts b/src/platform/deepScan/index.ts new file mode 100644 index 00000000..ed95cdbd --- /dev/null +++ b/src/platform/deepScan/index.ts @@ -0,0 +1,15 @@ +/** + * deepScan — Deep Scan runtime foundation (barrel). + * + * Bu modülü import etmek YAN ETKİ ÜRETMEZ: timer açılmaz, abonelik kurulmaz, + * araca sorgu gönderilmez. Gerçek tarama orchestration'ı ve SystemBoot wiring'i + * bilinçli olarak KAPSAM DIŞIDIR (ayrı PR). + */ + +export * from './deepScanModel'; +export { + DeepScanRuntimeService, + deepScanRuntimeService, + MAX_DISCOVERY_KEYS, + type DeepScanRuntimeDeps, +} from './deepScanRuntimeService';