#!/usr/bin/env bash set -euo pipefail root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" temp_dir="$(mktemp -d)" trap 'rm -rf "$temp_dir"' EXIT cat > "$temp_dir/source_contract_verifier.rb" <<'RUBY' def strip_comments(source) source.gsub(%r{//[^\n]*}, '').gsub(%r{/\*.*?\*/}m, '') end def normalized(source) strip_comments(source).gsub(/\s+/, ' ').gsub(/\s*([(),:])\s*/, '\1').strip end class ContractFailure < StandardError; end def require_contract(condition, message) raise ContractFailure, message unless condition end def verify_form(source, name_binding, phone_binding) text = normalized(source) require_contract(text.scan("GiftContactTextField(text:#{name_binding},rule:.name)").length == 1, 'name field contract') require_contract(text.scan("GiftContactTextField(text:#{phone_binding},rule:.phoneNumber)").length == 1, 'phone field contract') require_contract(!text.match?(%r{\bTextField\([^)]*#{Regexp.escape(name_binding)}|\bTextField\([^)]*#{Regexp.escape(phone_binding)}}), 'direct contact TextField binding') true end def can_submit_body(source) text = strip_comments(source) declaration = text.match(/\bvar\s+canSubmit\s*:\s*Bool\s*\{/) require_contract(declaration, 'canSubmit declaration') marker = declaration.begin(0) start = text.index('{', marker) depth = 1 index = start + 1 while index < text.length && depth > 0 depth += 1 if text[index] == '{' depth -= 1 if text[index] == '}' index += 1 end require_contract(depth == 0, 'canSubmit body is balanced') text[start + 1...index - 1] end def verify_model(source, name_property, phone_property) body = normalized(can_submit_body(source)) name_call = "GiftContactInputRule.name.acceptsSubmission(#{name_property})" phone_call = "GiftContactInputRule.phoneNumber.acceptsSubmission(#{phone_property})" expression = body.delete_prefix('return ').strip require_contract(!expression.start_with?('_ ='), 'discarded submission validation') require_contract(!expression.include?('return'), 'additional return statement') operands = expression.split('&&').map(&:strip) require_contract(operands.include?(name_call), 'name submission validation operand') require_contract(operands.include?(phone_call), 'phone submission validation operand') true end if ARGV.any? source = File.read(ARGV[0]) if ARGV[1].start_with?('$') verify_form(source, ARGV[1], ARGV[2]) else verify_model(source, ARGV[1], ARGV[2]) end exit end valid_form = <<~SWIFT struct Form { // fake GiftContactTextField(text: $wrong, rule: .name) var body: some View { GiftContactTextField( text: $name, rule: .name ) GiftContactTextField(text: $phoneNumber, rule: .phoneNumber) } } SWIFT verify_form(valid_form, '$name', '$phoneNumber') puts 'PASS gift-contact-input self-probe: multiline form contract' invalid_form = <<~SWIFT // GiftContactTextField(text: $name, rule: .name) // GiftContactTextField(text: $phoneNumber, rule: .phoneNumber) // TextField("", text: $name) // TextField("", text: $phoneNumber) SWIFT rejected = false begin verify_form(invalid_form, '$name', '$phoneNumber') abort 'FAIL gift-contact-input self-probe: commented/fake form unexpectedly passed' rescue ContractFailure rejected = true end abort 'FAIL gift-contact-input self-probe: commented/fake form unexpectedly passed' unless rejected puts 'PASS gift-contact-input self-probe: commented/fake form rejected' valid_model = <<~SWIFT var canSubmit : Bool { !isSubmitting && GiftContactInputRule.name.acceptsSubmission(name) && GiftContactInputRule.phoneNumber.acceptsSubmission(phoneNumber) && termsAgreed } SWIFT verify_model(valid_model, 'name', 'phoneNumber') puts 'PASS gift-contact-input self-probe: canSubmit body contract' explicit_model = <<~SWIFT var canSubmit : Bool { return hasNotSubmitted && GiftContactInputRule.name.acceptsSubmission(name) && GiftContactInputRule.phoneNumber.acceptsSubmission(phoneNumber) && privacyAgreed } SWIFT verify_model(explicit_model, 'name', 'phoneNumber') puts 'PASS gift-contact-input self-probe: explicit-return conjunction' invalid_model = <<~SWIFT var canSubmit : Bool { _ = GiftContactInputRule.name.acceptsSubmission(name) _ = GiftContactInputRule.phoneNumber.acceptsSubmission(phoneNumber) return true } SWIFT rejected = false begin verify_model(invalid_model, 'name', 'phoneNumber') abort 'FAIL gift-contact-input self-probe: fake canSubmit unexpectedly passed' rescue ContractFailure rejected = true end abort 'FAIL gift-contact-input self-probe: fake canSubmit unexpectedly passed' unless rejected puts 'PASS gift-contact-input self-probe: fake canSubmit rejected' RUBY ruby "$temp_dir/source_contract_verifier.rb" rule="$root/SodaLive/Sources/V2/Gift/Components/GiftContactInputRule.swift" if [[ -f "$rule" ]]; then swiftc -parse-as-library "$rule" "$root/work/tests/GiftContactInputCheck.swift" -o "$temp_dir/gift-contact-input" else swiftc -parse-as-library "$root/work/tests/GiftContactInputCheck.swift" -o "$temp_dir/gift-contact-input" fi "$temp_dir/gift-contact-input" component="$root/SodaLive/Sources/V2/Gift/Components/GiftContactTextField.swift" if [[ ! -f "$component" ]]; then echo "RED gift-contact-input: missing GiftContactTextField.swift" >&2 exit 1 fi for probe in 'markedTextRange' 'NSString' 'NSRange' '.numberPad'; do grep -Fq "$probe" "$component" || { echo "RED gift-contact-input: missing component contract $probe" >&2; exit 1; } done typography="$root/SodaLive/Sources/Extensions/FontModifier.swift" ruby - "$component" "$typography" <<'RUBY' def source(path) File.read(path).gsub(%r{//[^\n]*|/\*.*?\*/}m, '').gsub(/\s+/, ' ').strip end component = source(ARGV[0]) typography = source(ARGV[1]) abort 'RED gift-contact-input: hardcoded Font.preRegular bypasses localized font policy' if component.include?('Font.preRegular') abort 'RED gift-contact-input: missing shared body3 localized font resolver' unless component.include?('SodaTypography.body3.localizedFontName()') abort 'RED gift-contact-input: missing localized UIFont with system regular fallback' unless component.match?(/textField\.font\s*=\s*SodaTypography\.body3\.localizedFontName\(\)\s*\.flatMap\s*\{\s*UIFont\(name:\s*\$0,\s*size:\s*SodaTypography\.body3\.size\)\s*\}\s*\?\?\s*UIFont\.systemFont\(ofSize:\s*SodaTypography\.body3\.size,\s*weight:\s*\.regular\)/) abort 'RED gift-contact-input: SodaTypography must delegate localized font resolution to existing policy' unless typography.match?(/func localizedFontName\(locale:\s*Locale\s*=\s*\.autoupdatingCurrent\)\s*->\s*String\?\s*\{\s*(?:return\s+)?fontName\(for:\s*\.localized,\s*weight:\s*weight,\s*locale:\s*locale\)\s*\}/) puts 'PASS gift-contact-input: shared localized typography contract' RUBY sender="$root/SodaLive/Sources/V2/Gift/Send/Components/GiftSenderFormView.swift" recipient="$root/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressFormView.swift" sender_model="$root/SodaLive/Sources/V2/Gift/Send/GiftSendViewModel.swift" recipient_model="$root/SodaLive/Sources/V2/Gift/RecipientAddress/GiftRecipientAddressViewModel.swift" for file in "$sender" "$recipient" "$sender_model" "$recipient_model"; do [[ -f "$file" ]] || { echo "RED gift-contact-input: missing integration file $(basename "$file")" >&2; exit 1; } done for file in "$sender" "$recipient"; do if [[ "$file" == "$sender" ]]; then ruby "$temp_dir/source_contract_verifier.rb" "$file" '$name' '$phoneNumber' else ruby "$temp_dir/source_contract_verifier.rb" "$file" '$viewModel.recipientName' '$viewModel.recipientPhoneNumber' fi done for file in "$sender_model" "$recipient_model"; do if [[ "$file" == "$sender_model" ]]; then ruby "$temp_dir/source_contract_verifier.rb" "$file" senderName senderPhoneNumber else ruby "$temp_dir/source_contract_verifier.rb" "$file" recipientName recipientPhoneNumber fi done echo "PASS gift-contact-input: source contracts"