199 lines
7.9 KiB
Bash
Executable File
199 lines
7.9 KiB
Bash
Executable File
#!/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"
|