From 672ac18ac76ac0aebb6792e906abea4e97c506e0 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Thu, 16 Jul 2026 17:51:09 +0200 Subject: [PATCH] fix(sim-control): narrow SimControlParseResult via type predicates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3fe0ea5 left `nuxt typecheck` failing on main: useLiveAtcSession.ts: Property 'reason' does not exist on type 'SimControlParseResult'. Cause: the union is discriminated by a boolean, and the project builds with `strict: false` (nuxt.config.ts). With strictNullChecks off, TypeScript does not treat `true`/`false` literal types as discriminants, so `if (r.matched) … else r.reason` never narrows. Nothing about the sim-control types themselves is wrong — the same three lines with any boolean-discriminated union fail identically. Adds isSimControlMatch/isSimControlRejection type predicates, which narrow regardless of strictNullChecks, and routes the one caller through them. Turning on strictNullChecks would fix it at the root but is a repo-wide change, not a bug fix. The pre-push hook was correctly refusing to push this; origin/main is clean, so the breakage never escaped. Co-Authored-By: Claude Opus 4.8 --- app/composables/useLiveAtcSession.ts | 9 +++++++-- shared/utils/simControl.ts | 23 ++++++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/composables/useLiveAtcSession.ts b/app/composables/useLiveAtcSession.ts index 2c90420..0cdd28e 100644 --- a/app/composables/useLiveAtcSession.ts +++ b/app/composables/useLiveAtcSession.ts @@ -8,6 +8,8 @@ import { useApi } from '~/composables/useApi' import type { useRadioSpeech } from '~/composables/useRadioSpeech' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' import { + isSimControlMatch, + isSimControlRejection, parseSimControl, simControlRejectionSpeech, simControlResultSpeech, @@ -331,11 +333,14 @@ export function useLiveAtcSession( // anchors already keep it from ever matching real ICAO phraseology either way. if (bridgeConnected.value) { const simResult = parseSimControl(transcript) - if (simResult.matched) { + if (isSimControlMatch(simResult)) { void sendSimControlCommand(simResult.command) return } - if (simResult.reason !== 'no_intent') { + // A rejection with a reason means the pilot clearly addressed their sim but + // got something wrong — tell them. 'no_intent' just means this was ordinary + // radio traffic, so it falls through to the flow untouched. + if (isSimControlRejection(simResult) && simResult.reason !== 'no_intent') { const reply = simControlRejectionSpeech(simResult.reason) scheduleControllerSpeech(reply) appendLogEntry('atc', reply, currentState.value?.id ?? '', { frequency: frequencies.value.active }) diff --git a/shared/utils/simControl.ts b/shared/utils/simControl.ts index 7099ec9..b2b6660 100644 --- a/shared/utils/simControl.ts +++ b/shared/utils/simControl.ts @@ -38,9 +38,26 @@ export type SimControlNoMatchReason = | 'missing_runway' | 'missing_airport' -export type SimControlParseResult = - | { matched: true; command: SimControlCommand; text: string } - | { matched: false; reason: SimControlNoMatchReason; text: string } +export type SimControlMatch = { matched: true; command: SimControlCommand; text: string } +export type SimControlRejection = { matched: false; reason: SimControlNoMatchReason; text: string } + +export type SimControlParseResult = SimControlMatch | SimControlRejection + +/** + * Narrowing helpers. This union is discriminated by a boolean, and the project + * builds with `strict: false` (nuxt.config.ts) — so `strictNullChecks` is off, + * and without it TypeScript does not treat `true`/`false` literal types as + * discriminants. `if (result.matched) … else result.reason` therefore does NOT + * narrow and fails to compile. An explicit type predicate narrows regardless, + * which is why callers must go through these rather than testing `.matched`. + */ +export function isSimControlMatch(result: SimControlParseResult): result is SimControlMatch { + return result.matched +} + +export function isSimControlRejection(result: SimControlParseResult): result is SimControlRejection { + return !result.matched +} /** Hard value ranges; anything outside is a refusal, never a clamp. */ export const SIM_CONTROL_LIMITS = {