@@ -355,6 +355,55 @@ struct OpenClawTypographyTests {
355355 #expect( offenders. isEmpty, Comment ( rawValue: offenders. joined ( separator: " \n " ) ) )
356356 }
357357
358+ @Test func `accessibility metadata text do es not require visual typography`() throws {
359+ let accessibilityTextSamples = [
360+ " .accessibilityLabel(Text(title)) " ,
361+ " Image(systemName: \" circle \" ).accessibilityLabel(Text(title)) " ,
362+ " .accessibilityLabel(Text(title)).accessibilityIdentifier( \" status \" ) " ,
363+ " Image(systemName: \" circle \" ).accessibilityLabel(Text(title)).accessibilityHint(Text(hint)) " ,
364+ " .accessibilityLabel(Text(self.statusLabel ?? LocalizedStringResource( \" Allowed \" ))) " ,
365+ " .accessibilityLabel(Text(title)) \n ) " ,
366+ " .accessibilityLabel( \n Text(title) \n ) " ,
367+ " Image(systemName: \" circle \" ) \n .accessibilityLabel( \n Text(title) \n ) " ,
368+ " .accessibilityValue( \n Text(value)) " ,
369+ " .accessibilityHint( \n \n Text(hint)) " ,
370+ ]
371+ for source in accessibilityTextSamples {
372+ let lines = source. components ( separatedBy: . newlines)
373+ let idx = try #require( lines. firstIndex { Self . isTextOrLabelCall ( $0) } )
374+ #expect( Self . isAccessibilityMetadataTextCall ( at: idx, in: lines) )
375+ }
376+
377+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 0 , in: [ " Text(title) " ] ) )
378+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 0 , in: [ " .accessibilityLabel(title) " ] ) )
379+
380+ let nearbyVisualText = [
381+ " .accessibilityLabel(Text(title)) " ,
382+ " Text(body) " ,
383+ ]
384+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 1 , in: nearbyVisualText) )
385+
386+ let sameLineVisualLabel = [
387+ " Label( \" Status \" , systemImage: \" circle \" ).accessibilityLabel(Text(status)) " ,
388+ ]
389+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 0 , in: sameLineVisualLabel) )
390+
391+ let trailingVisualText = [ " .accessibilityLabel(Text(status)); Text(body) " ]
392+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 0 , in: trailingVisualText) )
393+
394+ let modifierTextInString = [
395+ " let sample = \" .accessibilityLabel( \" " ,
396+ " Text(body) " ,
397+ ]
398+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 1 , in: modifierTextInString) )
399+
400+ let modifierTextInComment = [
401+ " // Example: .accessibilityLabel( " ,
402+ " Text(body) " ,
403+ ]
404+ #expect( !Self. isAccessibilityMetadataTextCall ( at: 1 , in: modifierTextInComment) )
405+ }
406+
358407 @Test func `secure fields do not use platform placeholder text`() throws {
359408 let offenders = try Self . swiftSourcesForTypographyAudit ( ) . flatMap { url -> [ String ] in
360409 let source = try String ( contentsOf: url, encoding: . utf8)
@@ -440,10 +489,11 @@ struct OpenClawTypographyTests {
440489 private static func unbrandedTextCallOffenders( ) throws -> [ String ] {
441490 let fontTokens = [ " OpenClawType " , " OpenClawChatTypography " ]
442491 // Accessibility-only Text is spoken, never rendered, so no branded font applies.
443- let allowedFragments = [ " .navigationTitle( " , " .alert( \" " , " .tabItem { Label( " , " .accessibilityLabel(Text( " ]
492+ let allowedFragments = [ " .navigationTitle( " , " .alert( \" " , " .tabItem { Label( " ]
444493 return try self . swiftSourcesForTypographyAudit ( ) . flatMap { url -> [ String ] in
445494 let source = try String ( contentsOf: url, encoding: . utf8)
446495 let lines = source. components ( separatedBy: . newlines)
496+ let accessibilityMetadataTextLines = self . accessibilityMetadataTextLines ( in: lines)
447497 return lines. indices. compactMap { idx -> String ? in
448498 let rawLine = lines [ idx]
449499 let line = rawLine. trimmingCharacters ( in: . whitespaces)
@@ -454,7 +504,10 @@ struct OpenClawTypographyTests {
454504 let hasLocalFont = fontTokens. contains { window. contains ( $0) }
455505 || self . hasAllowedBrandedFontParameter ( window, line: rawLine, in: url)
456506
457- if self . isTextOrLabelCall ( rawLine) , !hasLocalFont {
507+ if self . isTextOrLabelCall ( rawLine) ,
508+ !accessibilityMetadataTextLines. contains ( idx) ,
509+ !hasLocalFont
510+ {
458511 return " \( self . relativePath ( url) ) : \( idx + 1 ) : \( line) "
459512 }
460513
@@ -471,6 +524,170 @@ struct OpenClawTypographyTests {
471524 line. range ( of: #"\b(Text|Label)\s*\("# , options: . regularExpression) != nil
472525 }
473526
527+ private static func isAccessibilityMetadataTextCall( at idx: Int , in lines: [ String ] ) -> Bool {
528+ self . accessibilityMetadataTextLines ( in: lines) . contains ( idx)
529+ }
530+
531+ private static func accessibilityMetadataTextLines( in lines: [ String ] ) -> Set < Int > {
532+ let code = self . maskedSwiftCode ( lines. joined ( separator: " \n " ) )
533+ let textToken = Array ( " Text " . utf8)
534+ let labelToken = Array ( " Label " . utf8)
535+ let modifiers = [
536+ Array ( " .accessibilityLabel " . utf8) ,
537+ Array ( " .accessibilityValue " . utf8) ,
538+ Array ( " .accessibilityHint " . utf8) ,
539+ ]
540+ var callCounts : [ Int : Int ] = [ : ]
541+ var metadataCallCounts : [ Int : Int ] = [ : ]
542+ var line = 0
543+
544+ for offset in code. indices {
545+ if code [ offset] == 10 {
546+ line += 1
547+ continue
548+ }
549+ if self . callOpeningParenthesis ( after: textToken, at: offset, in: code) != nil ||
550+ self . callOpeningParenthesis ( after: labelToken, at: offset, in: code) != nil
551+ {
552+ callCounts [ line, default: 0 ] += 1
553+ }
554+
555+ for modifier in modifiers {
556+ guard let modifierOpen = self . callOpeningParenthesis ( after: modifier, at: offset, in: code)
557+ else { continue }
558+ let textOffset = self . skippingWhitespace ( in: code, after: modifierOpen)
559+ guard let textOpen = self . callOpeningParenthesis ( after: textToken, at: textOffset, in: code) ,
560+ let textClose = self . matchingParenthesis ( in: code, openingAt: textOpen)
561+ else { continue }
562+ let metadataClose = self . skippingWhitespace ( in: code, after: textClose)
563+ guard metadataClose < code. count, code [ metadataClose] == 41 else { continue }
564+
565+ // Exempt only the direct Text argument; visible calls sharing its source line stay audited.
566+ let textLine = line + code[ offset..< textOffset] . count ( where: { $0 == 10 } )
567+ metadataCallCounts [ textLine, default: 0 ] += 1
568+ }
569+ }
570+
571+ return Set ( metadataCallCounts. compactMap { line, count in
572+ callCounts [ line] == count ? line : nil
573+ } )
574+ }
575+
576+ private static func callOpeningParenthesis(
577+ after token: [ UInt8 ] ,
578+ at offset: Int ,
579+ in code: [ UInt8 ] ) -> Int ?
580+ {
581+ guard offset + token. count <= code. count,
582+ code [ offset..< ( offset + token. count) ] . elementsEqual ( token) ,
583+ token. first == 46 || offset == 0 || !self . isSwiftIdentifierByte ( code [ offset - 1 ] )
584+ else { return nil }
585+ let afterToken = offset + token. count
586+ guard afterToken == code. count || !self . isSwiftIdentifierByte ( code [ afterToken] ) else { return nil }
587+ let opening = self . skippingWhitespace ( in: code, after: afterToken - 1 )
588+ return opening < code. count && code [ opening] == 40 ? opening : nil
589+ }
590+
591+ private static func skippingWhitespace( in code: [ UInt8 ] , after offset: Int ) -> Int {
592+ var cursor = offset + 1
593+ while cursor < code. count, code [ cursor] == 9 || code [ cursor] == 10 || code [ cursor] == 13 || code [ cursor] == 32 {
594+ cursor += 1
595+ }
596+ return cursor
597+ }
598+
599+ private static func matchingParenthesis( in code: [ UInt8 ] , openingAt offset: Int ) -> Int ? {
600+ var depth = 0
601+ for cursor in offset..< code. count {
602+ if code [ cursor] == 40 {
603+ depth += 1
604+ } else if code [ cursor] == 41 {
605+ depth -= 1
606+ if depth == 0 { return cursor }
607+ }
608+ }
609+ return nil
610+ }
611+
612+ private static func isSwiftIdentifierByte( _ byte: UInt8 ) -> Bool {
613+ byte == 95 || ( 48 ... 57 ) . contains ( byte) || ( 65 ... 90 ) . contains ( byte) || ( 97 ... 122 ) . contains ( byte) || byte >= 128
614+ }
615+
616+ private static func maskedSwiftCode( _ source: String ) -> [ UInt8 ] {
617+ let sourceBytes = Array ( source. utf8)
618+ var code = sourceBytes
619+ var cursor = 0
620+
621+ func mask( _ range: Range < Int > ) {
622+ for offset in range where code [ offset] != 10 && code [ offset] != 13 {
623+ code [ offset] = 32
624+ }
625+ }
626+
627+ while cursor < sourceBytes. count {
628+ if cursor + 1 < sourceBytes. count, sourceBytes [ cursor] == 47 , sourceBytes [ cursor + 1 ] == 47 {
629+ let start = cursor
630+ while cursor < sourceBytes. count, sourceBytes [ cursor] != 10 {
631+ cursor += 1
632+ }
633+ mask ( start..< cursor)
634+ continue
635+ }
636+ if cursor + 1 < sourceBytes. count, sourceBytes [ cursor] == 47 , sourceBytes [ cursor + 1 ] == 42 {
637+ let start = cursor
638+ var depth = 1
639+ cursor += 2
640+ while cursor < sourceBytes. count, depth > 0 {
641+ if cursor + 1 < sourceBytes. count, sourceBytes [ cursor] == 47 , sourceBytes [ cursor + 1 ] == 42 {
642+ depth += 1
643+ cursor += 2
644+ } else if cursor + 1 < sourceBytes. count,
645+ sourceBytes [ cursor] == 42 ,
646+ sourceBytes [ cursor + 1 ] == 47
647+ {
648+ depth -= 1
649+ cursor += 2
650+ } else {
651+ cursor += 1
652+ }
653+ }
654+ mask ( start..< cursor)
655+ continue
656+ }
657+
658+ let start = cursor
659+ var hashCount = 0
660+ while cursor < sourceBytes. count, sourceBytes [ cursor] == 35 {
661+ hashCount += 1
662+ cursor += 1
663+ }
664+ guard cursor < sourceBytes. count, sourceBytes [ cursor] == 34 else {
665+ cursor = start + 1
666+ continue
667+ }
668+ let quoteCount = cursor + 2 < sourceBytes. count &&
669+ sourceBytes [ cursor + 1 ] == 34 && sourceBytes [ cursor + 2 ] == 34 ? 3 : 1
670+ cursor += quoteCount
671+ while cursor < sourceBytes. count {
672+ if hashCount == 0 , quoteCount == 1 , sourceBytes [ cursor] == 92 {
673+ cursor = min ( sourceBytes. count, cursor + 2 )
674+ continue
675+ }
676+ let closingEnd = cursor + quoteCount + hashCount
677+ if closingEnd <= sourceBytes. count,
678+ sourceBytes [ cursor..< ( cursor + quoteCount) ] . allSatisfy ( { $0 == 34 } ) ,
679+ sourceBytes [ ( cursor + quoteCount) ..< closingEnd] . allSatisfy ( { $0 == 35 } )
680+ {
681+ cursor = closingEnd
682+ break
683+ }
684+ cursor += 1
685+ }
686+ mask ( start..< cursor)
687+ }
688+ return code
689+ }
690+
474691 private static func isShorthandControlCall( _ line: String ) -> Bool {
475692 line. range (
476693 of: #"\b(Button|Link|Picker|Toggle|TextField|SecureField|Menu|DisclosureGroup|LabeledContent)\s*\(""# ,
0 commit comments