From d5d878033b688b702a28e77594365b1eb751e64b Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Mon, 3 Aug 2026 16:02:10 +0000 Subject: [PATCH] fix(swift): type an optional field and read through its force-unwrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift cannot declare a stored property with neither a type nor an initializer, so its "declare now, assign in init" idiom is an OPTIONAL field read back through a force-unwrap. That shape resolved nothing, and it was broken in two independent places — each alone leaves it broken: 1. `var a: Outer?` parses as `type_annotation(optional_type(user_type(…)))`, but the property-annotation pattern required the `user_type` to be a DIRECT child, so an optional field was never typed at all. The pattern added here captures the INNER `type_identifier`, so the binding is `Outer` without relying on `stripOptional` reducing an `Outer?` spelling. 2. `self.a!` is a `postfix_expression`, which the receiver walk did not peel, so even a typed field could not be read through the unwrap. For (2), `postfix_expression` is NOT added to `TRANSPARENT_RECEIVER_WRAPPERS` outright: unlike TypeScript's `non_null_expression` — which is only ever `!` — Swift's node also carries user-defined postfix operators, which can return anything. Peeling those would type the receiver as the operand and mint a confidently WRONG owner, the failure mode compound-receiver.ts calls strictly worse than no edge. So the peel is operator-gated: transparent only when the node's text ends in `!`, which is provably type-preserving. Verified: force-unwrap `self.a!.inner()`, optional chain `self.b?.inner()`, and the plain annotated field all resolve; previously only the plain one did. The gate keeps this off every other language — `postfix_expression` is not a node type the other grammars produce here — and the full resolver + CFG suite is green at 3166 passed / 0 failed, against a 3165 baseline. Refs #2807 Co-Authored-By: Claude Opus 5 (1M context) --- .../core/ingestion/languages/swift/query.ts | 17 ++++++++++ .../src/core/ingestion/utils/call-analysis.ts | 32 ++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/gitnexus/src/core/ingestion/languages/swift/query.ts b/gitnexus/src/core/ingestion/languages/swift/query.ts index 78c1efa41..1d50be2df 100644 --- a/gitnexus/src/core/ingestion/languages/swift/query.ts +++ b/gitnexus/src/core/ingestion/languages/swift/query.ts @@ -107,6 +107,23 @@ const SWIFT_SCOPE_QUERY = ` (type_annotation (user_type (type_identifier) @type-binding.type))) @type-binding.annotation +;; Optional property annotations: \`var owner: Owner?\` (#2807). The pattern +;; above requires the \`user_type\` to be a DIRECT child of the annotation; +;; an optional inserts an \`optional_type\` level between them, so an optional +;; field was never typed at all and \`self.owner!.method()\` could not resolve +;; its receiver. Declaring a field optional and assigning it later is the +;; idiomatic Swift way to express a field that has no value at init time, so +;; this is the common shape, not an edge case. +;; +;; The INNER \`type_identifier\` is captured, so the binding is \`Owner\` with no +;; reliance on \`stripOptional\` reducing a \`Owner?\` spelling. +(property_declaration + name: (pattern + bound_identifier: (simple_identifier) @type-binding.name) + (type_annotation + (optional_type + (user_type (type_identifier) @type-binding.type)))) @type-binding.annotation + ;; ── Type bindings — stored / local-var constructor inference: ;; \`let p = Product(...)\` (constructor) and \`let u = getUser()\` ;; (free-call result; chain-follow resolves getUser → its return type). diff --git a/gitnexus/src/core/ingestion/utils/call-analysis.ts b/gitnexus/src/core/ingestion/utils/call-analysis.ts index 86be8b8cd..6281a1fbf 100644 --- a/gitnexus/src/core/ingestion/utils/call-analysis.ts +++ b/gitnexus/src/core/ingestion/utils/call-analysis.ts @@ -561,6 +561,32 @@ const TRANSPARENT_RECEIVER_WRAPPERS = new Set([ 'parenthesized_expression', // `(svc)` ]); +/** + * Wrappers that are transparent only for SOME operators, keyed by the operator + * text that makes them so. + * + * Swift force-unwrap (`self.a!`) is the exact semantic of TypeScript's + * `non_null_expression` above — it yields the wrapped type — but Swift parses it + * as the general `postfix_expression`, which ALSO carries user-defined postfix + * operators. Those can return anything, so peeling the node type unconditionally + * would type the receiver as the operand and could produce a confidently wrong + * owner. Reading the operator keeps the peel to the case that is provably + * type-preserving. + */ +const OPERATOR_GATED_RECEIVER_WRAPPERS = new Map([ + ['postfix_expression', '!'], // Swift `self.a!` +]); + +/** Is `node` a wrapper that denotes exactly what its operand denotes? */ +function isTransparentReceiverWrapper(node: SyntaxNode): boolean { + if (TRANSPARENT_RECEIVER_WRAPPERS.has(node.type)) return true; + const operator = OPERATOR_GATED_RECEIVER_WRAPPERS.get(node.type); + if (operator === undefined) return false; + // The operator is an anonymous token, so it is not in `namedChildren`; the + // node's own text is the reliable place to read it. + return node.text.trimEnd().endsWith(operator); +} + /** * Iteration bound for the wrapper peel. Its OWN constant, not `MAX_CHAIN_DEPTH`. * @@ -575,11 +601,7 @@ const MAX_TRANSPARENT_WRAPPER_DEPTH = 3; /** Peel transparent wrappers off a base receiver node. */ function unwrapTransparentReceiver(node: SyntaxNode): SyntaxNode { let current = node; - for ( - let i = 0; - i < MAX_TRANSPARENT_WRAPPER_DEPTH && TRANSPARENT_RECEIVER_WRAPPERS.has(current.type); - i++ - ) { + for (let i = 0; i < MAX_TRANSPARENT_WRAPPER_DEPTH && isTransparentReceiverWrapper(current); i++) { const inner = current.namedChildren?.find((c) => c !== null); if (inner === undefined || inner === null) break; current = inner;