From 219118ae9e086678b165285a22397ea745a4abc1 Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:45:05 +0300 Subject: [PATCH 1/2] ci: asset and convention checks Add a Linux CI job that runs .github/scripts/conventions.sh and its test suite. It checks DashIcon raw values and Color.dash tokens against the asset catalog, literal asset names and the bundle they are looked up in, colors built from components, the bundle of localized strings, and availability checks at or below the Package.swift floor. Fix what it finds on master: RadioButtonRow's checkbox style asked for imagesets that do not exist and drew nothing; ThumbView's shadow color moves to a SwitchThumbShadow token with the same value. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/conventions.sh | 456 ++++++++++++++++++ .github/scripts/tests/conventions-test.sh | 347 +++++++++++++ .github/workflows/ci.yml | 20 +- CLAUDE.md | 6 + .../DashUIKit/Components/RadioButtonRow.swift | 2 +- .../Switch/Components/ThumbView.swift | 4 +- .../DashUIKit/Foundation/Color+DashUI.swift | 1 + .../SwitchThumbShadow.colorset/Contents.json | 20 + docs/lists-and-rows.md | 4 +- 9 files changed, 854 insertions(+), 6 deletions(-) create mode 100755 .github/scripts/conventions.sh create mode 100755 .github/scripts/tests/conventions-test.sh create mode 100644 Sources/DashUIKit/Resources/Media.xcassets/Colors/Switch/SwitchThumbShadow.colorset/Contents.json diff --git a/.github/scripts/conventions.sh b/.github/scripts/conventions.sh new file mode 100755 index 0000000..ecd7550 --- /dev/null +++ b/.github/scripts/conventions.sh @@ -0,0 +1,456 @@ +#!/usr/bin/env bash +# Checks the conventions in CLAUDE.md that a scanner can decide, so a PR breaking +# one fails here instead of in review: +# +# 1. every DashIcon raw value has an imageset, and every imageset is named by a +# DashIcon case or a literal (rule 3); no name twice; +# 2. the same for dashAsset("…") tokens in Color+DashUI.swift and colorsets; +# 3. asset names written as literals (Image("…", bundle:), .custom("…", bundle:), +# Color("…", bundle:), UIImage(named: "…", in:), …) exist, and are looked up +# in the package bundle (.module / .dashUIKit), not the host app's; +# 4. every imageset and colorset has a valid Contents.json naming at least one +# image or color, every image it names exists, and no catalog folder provides +# a namespace (names would gain a "Folder/" prefix); +# 5. no color built from components (red:, white:, hue:, gray:, .sRGB, hex, +# #colorLiteral) outside Color+DashUI.swift, where tokens are defined; +# 6. every NSLocalizedString / String(localized:) / AttributedString(localized:) +# call with a literal key passes the package bundle; +# 7. no @available / #available / #unavailable for an iOS or macOS version at or +# below the Package.swift floor: the floor already covers it. Newer versions +# may still be annotated or checked. +# +# Calls are parsed with their arguments (labels, nesting, several lines); string +# contents and `//` comments are ignored, interpolated code is scanned. Not +# handled: /* block comments */, raw and multi-line string literals, names that +# are interpolated ("icon-\(x)"), concatenated or passed through a variable +# (Image(name) may be an ImageResource; only UIImage(named:) and .custom are +# checked for their bundle then), a .custom(…) that is not after DashIconSource, +# `dash:` or an `…icon:` label, NSImage(named:), SwiftUI Text("…") and +# LocalizedStringResource localization. Icon_DashUI.swift is +# expected to keep each `case` list on one line. +# Run from the repository root (tests: .github/scripts/tests/conventions-test.sh). +# Every rule runs; exits 1 if any reported a violation, 2 if it could not run. +set -euo pipefail + +scripts=$(cd "$(dirname "$0")" && pwd) +src=Sources/DashUIKit +catalog=$src/Resources/Media.xcassets +icons=$src/Foundation/Icon_DashUI.swift +colors=$src/Foundation/Color+DashUI.swift + +command -v jq > /dev/null || { echo "conventions.sh needs jq" >&2; exit 2; } +for required in Package.swift "$catalog" "$icons" "$colors"; do + [ -e "$required" ] || { echo "conventions.sh: ${required} not found; run from the repository root" >&2; exit 2; } +done + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# One violation per line: path line message. +errors=$work/errors +: > "$errors" + +# report PATH LINE MESSAGE +report() { + printf '%s\t%s\t%s\n' "$1" "$2" "$3" >> "$errors" +} + +# list_sets EXT: "name path" for every *.EXT folder in the catalog. +list_sets() { + find "$catalog" -type d -name "*.$1" -print0 | sort -z | + while IFS= read -r -d '' dir; do + name=${dir##*/} + printf '%s\t%s\n' "${name%."$1"}" "$dir" + done +} + +# Platform floors as major.minor; macOS falls back to 0 (flag nothing) if undeclared. +ios_floor=$("$scripts/ios-floor.sh" Package.swift) || exit 2 +mac_floor=$(sed 's#//.*##' Package.swift | { grep -oE '\.macOS\(\.v[0-9]+(_[0-9]+)?\)' || true; } | head -n 1 | + sed -E 's/.*\.v([0-9]+)(_([0-9]+))?\)/\1.\3/; s/\.$/.0/') +if [ -z "$mac_floor" ] && sed 's#//.*##' Package.swift | grep -q '\.macOS('; then + echo "conventions.sh: could not read the .macOS(.vNN) platform in Package.swift" >&2 + exit 2 +fi +mac_floor=${mac_floor:-0} + +imagesets=$work/imagesets +colorsets=$work/colorsets +list_sets imageset > "$imagesets" +list_sets colorset > "$colorsets" + +# Swift scan (rules 3, 5, 6, 7). Prints "R file line message" +# for a violation and "L kind name file line" for each +# literal asset name, checked against the catalog below. +scan=$work/scan +# shellcheck disable=SC2016 # awk fields, not shell variables +find "$src" -name '*.swift' -print0 | sort -z | + xargs -0 awk -v exempt="$colors" -v ios_floor="$ios_floor" -v mac_floor="$mac_floor" ' + # mask(s): string contents become spaces, so a match in M is always code. + # Interpolated code ("\(x)") stays visible, its "\(" written as " (". + function mask(s, i, c, instr, n, pd, out) { + out = ""; instr = 0; n = 0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (instr) { + if (c == "\\") { + if (substr(s, i + 1, 1) == "(") { out = out " ("; n++; pd[n] = 1; instr = 0 } + else out = out " " + i++; continue + } + if (c == "\"") instr = 0 + out = out (c == "\"" ? c : " ") + continue + } + if (c == "\"") { instr = 1; out = out c; continue } + if (n > 0 && c == "(") pd[n]++ + else if (n > 0 && c == ")" && --pd[n] == 0) { n--; instr = 1 } + out = out c + } + return out + } + # pkg(v): v names the package bundle. + function pkg(v) { return trim(v) ~ /^((Bundle[[:space:]]*)?\.(module|dashUIKit)|#bundle)$/ } + function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s } + function out(line, message) { print "R\t" file "\t" line "\t" message } + + # parse(li, col): the arguments of the call whose "(" is at M[li], col. + # Fills nargs, alabel[k] ("" when unlabeled), atext[k] (source), amask[k], + # and atop[k]: the source outside nested calls and subscripts. + function parse(li, col, d, j, c, line, ml) { + nargs = 1; alabel[1] = ""; atext[1] = ""; amask[1] = ""; atop[1] = "" + d = 1; line = li; j = col + 1 + while (line <= nl) { + ml = M[line] + for (; j <= length(ml); j++) { + c = substr(ml, j, 1) + if (c == "(" || c == "[" || c == "{") d++ + else if (c == ")" || c == "]" || c == "}") { if (--d == 0) return } + if (d == 1 && c == ",") { + nargs++; alabel[nargs] = ""; atext[nargs] = ""; amask[nargs] = ""; atop[nargs] = "" + continue + } + if (d == 1 && c == ":" && alabel[nargs] == "" && amask[nargs] ~ /^[[:space:]]*[A-Za-z_][A-Za-z0-9_]*[[:space:]]*$/) { + alabel[nargs] = trim(amask[nargs]); atext[nargs] = ""; amask[nargs] = ""; atop[nargs] = "" + continue + } + atext[nargs] = atext[nargs] substr(L[line], j, 1) + amask[nargs] = amask[nargs] c + if (d == 1) atop[nargs] = atop[nargs] substr(L[line], j, 1) + } + atext[nargs] = atext[nargs] " "; amask[nargs] = amask[nargs] " "; atop[nargs] = atop[nargs] " " + line++; j = 1 + } + } + function arg(label, k) { + for (k = 1; k <= nargs; k++) if (alabel[k] == label) return k + return 0 + } + # bundle_ok(label, li): the bundle argument names the package bundle. + function bundle_ok(label, li, k, v) { + k = arg(label) + if (k == 0) { out(li, "asset looked up in the host app bundle: pass " label ": .dashUIKit"); return } + v = trim(amask[k]) + if (!pkg(v)) out(li, label ": " v " is not the package bundle; use .dashUIKit") + } + + # Rule 3: literal asset names. + function assets(li, ml, base, call, before, open, kind, label, k, s, n, name) { + ml = M[li]; base = 0 + while (match(substr(ml, base + 1), /(^|[^A-Za-z0-9_])(Image|UIImage|Color|UIColor|Label|Button|Toggle|Menu|Tab|ContentUnavailableView)[[:space:]]*(\.init)?[[:space:]]*\(|\.custom[[:space:]]*\(/)) { + call = substr(ml, base + RSTART, RLENGTH) + before = substr(ml, 1, base + RSTART - 1) + open = base + RSTART + RLENGTH - 1 + base = open + parse(li, open) + if (call ~ /Label|Button|Toggle|Menu|Tab|ContentUnavailableView/) { + # These take an asset name only from the host app bundle. + k = arg("image") + if (k && trim(amask[k]) ~ /^"/) out(li, "image: \"…\" is looked up in the host app bundle; pass a label view with Image(…, bundle: .dashUIKit)") + continue + } + kind = (call ~ /Color/) ? "colorset" : "imageset" + label = (call ~ /UI/) ? "in" : "bundle" + if (call ~ /UI/) { if (alabel[1] != "named") continue } + else if (call ~ /Image/) { if (alabel[1] != "" && alabel[1] != "decorative") continue } + else if (alabel[1] != "") continue + # Other enums have a .custom case too: .custom counts only where a + # DashIconSource is expected (after `DashIconSource`, `dash:` or an + # `…icon:` / `…Icon:` label). + if (call ~ /custom/ && before !~ /(DashIconSource[[:space:]]*=?|(dash|[A-Za-z]*[Ii]con):)[[:space:]]*$/) continue + # A pattern (`case .custom(let name, let bundle)`) is not a lookup. + if (amask[1] ~ /^[[:space:]]*(let|var)[[:space:]]/) continue + # "a" + b is an expression, not a name. + s = (index(amask[1], "+") ? "" : atop[1]); n = 0 + while (match(s, /"([^"\\]|\\.)*"/)) { + name = substr(s, RSTART + 1, RLENGTH - 2) + s = substr(s, RSTART + RLENGTH) + if (name == "" || index(name, "\\")) continue + print "L\t" kind "\t" name "\t" file "\t" li + n++ + } + if (n) bundle_ok(label, li) + # UIImage(named: name) without in: always looks in the host app bundle, + # and so does DashIconSource.custom(name) (bundle defaults to nil). + else if (!arg(label) && call ~ /UI|custom/) + out(li, "asset looked up in the host app bundle: pass " label ": .dashUIKit") + } + } + + # Rule 5: colors from components. + function colors(li, ml, base, open, first, k, hit) { + if (file == exempt) return + ml = M[li]; base = 0 + if (ml ~ /#colorLiteral/) out(li, "hardcoded color: add a token to Foundation/Color+DashUI.swift backed by a colorset and use Color.dash.") + while (match(substr(ml, base + 1), /((^|[^A-Za-z0-9_])(Color|UIColor|NSColor|CGColor|CIColor)[[:space:]]*(\.init)?|\.init)[[:space:]]*\(/)) { + open = base + RSTART + RLENGTH - 1 + base = open + parse(li, open) + first = trim(amask[1]); hit = (alabel[1] == "" && first ~ /^\.(sRGB|sRGBLinear|displayP3)$/) + for (k = 1; k <= nargs; k++) + if (alabel[k] ~ /^(red|white|hue|gray|displayP3Red|srgbRed|hex|calibratedRed|calibratedWhite|calibratedHue|deviceRed|deviceWhite|deviceHue|genericGamma22White)$/) hit = 1 + if (hit) + out(li, "hardcoded color: add a token to Foundation/Color+DashUI.swift backed by a colorset and use Color.dash.") + } + } + + # Rule 6: localized strings. + function localized(li, ml, base, call, open) { + ml = M[li]; base = 0 + while (match(substr(ml, base + 1), /(^|[^A-Za-z0-9_])(NSLocalizedString|(Attributed)?String[[:space:]]*(\.init)?)[[:space:]]*\(/)) { + call = substr(ml, base + RSTART, RLENGTH) + open = base + RSTART + RLENGTH - 1 + base = open + parse(li, open) + # String(localized: resource) takes no bundle; only a literal key needs one. + if (call !~ /NSLocalizedString/ && (alabel[1] != "localized" || trim(amask[1]) !~ /^"/)) continue + k = arg("bundle") + if (k == 0 || !pkg(amask[k])) + out(li, "localized string without bundle: .module is looked up in the host app bundle") + } + } + + # Rule 7: availability at or below the floor. + # le(a, b): version a <= version b. + function le(a, b, x, y, i) { + split(a, x, "."); split(b, y, ".") + for (i = 1; i <= 3; i++) if (x[i] + 0 != y[i] + 0) return x[i] + 0 < y[i] + 0 + return 1 + } + function available(li, ml, base, open, k, t, platform, version, floor) { + ml = M[li]; base = 0 + while (match(substr(ml, base + 1), /[@#](available|unavailable)[[:space:]]*\(/)) { + open = base + RSTART + RLENGTH - 1 + base = open + parse(li, open) + for (k = 1; k <= nargs; k++) { + if (alabel[k] != "") continue + t = trim(amask[k]); version = "" + if (match(t, /^[A-Za-z]+/)) platform = substr(t, 1, RLENGTH) + else continue + if (match(t, /[[:space:]][0-9][0-9.]*$/)) version = trim(substr(t, RSTART)) + else if (k == 1 && t == platform && arg("introduced")) version = trim(amask[arg("introduced")]) + if (version == "") continue + if (platform ~ /^(iOS|macCatalyst)(ApplicationExtension)?$/) floor = ios_floor + else if (platform ~ /^macOS(ApplicationExtension)?$/) floor = mac_floor + else continue + if (le(version, floor)) + out(li, "availability check for " platform " " version " is at or below the package floor (" floor "), declared once in Package.swift; drop it (CLAUDE.md, Deployment target)") + } + } + } + + function flush( i, cut) { + for (i = 1; i <= nl; i++) { + assets(i); colors(i); localized(i); available(i) + } + nl = 0 + } + FNR == 1 { if (nl) flush(); file = FILENAME } + { + m = mask($0) + cut = index(m, "//") + line = $0 + if (cut) { m = substr(m, 1, cut - 1); line = substr(line, 1, cut - 1) } + nl++; L[nl] = line; M[nl] = m + } + END { if (nl) flush() } + ' > "$scan" || { echo "conventions.sh: the Swift scan failed" >&2; exit 2; } + +awk -F'\t' '$1 == "R" { print $2 "\t" $3 "\t" $4 }' "$scan" >> "$errors" +literals=$work/literals +awk -F'\t' '$1 == "L" { print $2 "\t" $3 "\t" $4 "\t" $5 }' "$scan" > "$literals" + +# check_sets KIND SETS DECLARED USED: SETS is "name path", DECLARED is +# "name file line" (the enum or token list), USED is extra names that +# keep a set from being an orphan. UNIQUE (any value) also fails a name declared +# twice; two color tokens may alias one colorset, two icon cases may not. +check_sets() { + local kind=$1 sets=$2 declared=$3 used=$4 unique=${5:-} + if [ ! -s "$sets" ]; then + report "$catalog" 1 "no ${kind} found in the catalog" + return + fi + cut -f1 "$sets" | sort | uniq -d | while IFS= read -r name; do + awk -F'\t' -v n="$name" '$1 == n { print $2 }' "$sets" | while IFS= read -r dir; do + report "$dir/Contents.json" 1 "${kind} \"${name}\" exists in more than one folder; the catalog is flat, so only one of them is used" + done + done + [ -z "$unique" ] || cut -f1 "$declared" | sort | uniq -d | while IFS= read -r name; do + awk -F'\t' -v n="$name" '$1 == n { print $2 "\t" $3 }' "$declared" | while IFS=$'\t' read -r file line; do + report "$file" "$line" "\"${name}\" is declared more than once" + done + done + awk -F'\t' 'FILENAME == ARGV[1] { have[$1] = 1; next } !($1 in have) { print $2 "\t" $3 "\t" $1 }' "$sets" "$declared" | + while IFS=$'\t' read -r file line name; do + report "$file" "$line" "no ${kind} named \"${name}\" in ${catalog}" + done + awk -F'\t' 'FILENAME != ARGV[3] { named[$1] = 1; next } !($1 in named) { print $1 "\t" $2 }' "$declared" "$used" "$sets" | + while IFS=$'\t' read -r name dir; do + report "$dir/Contents.json" 1 "${kind} \"${name}\" is not referenced by any declaration or literal" + done +} + +check_literals() { + local kind=$1 sets=$2 + awk -F'\t' -v k="$kind" 'FILENAME == ARGV[1] { have[$1] = 1; next } $1 == k && !($2 in have) { print $3 "\t" $4 "\t" $2 }' "$sets" "$literals" | + while IFS=$'\t' read -r file line name; do + report "$file" "$line" "no ${kind} named \"${name}\" in ${catalog}" + done +} + +# 1. Icons: `case a = "x"`, `case a` (raw value is the name), `case a, b = "y"`, +# read only directly inside an enum whose declaration (up to its "{", on one line +# or several) names DashIconAsset. Lines of any other shape (`case let …`, +# associated values) are not icons. +icon_values=$work/icon-values +awk -v f="$icons" ' + function cases(text, ln, item, one, value) { + sub(/^[[:space:]]+/, "", text) + sub(/[[:space:]]*\}[[:space:]]*$/, "", text) + if (text !~ /^case[[:space:]]+[A-Za-z_]/) return + sub(/^case[[:space:]]+/, "", text) + item = "[A-Za-z_][A-Za-z0-9_]*([[:space:]]*=[[:space:]]*\"[^\"]*\")?" + if (text !~ ("^" item "([[:space:]]*,[[:space:]]*" item ")*[[:space:]]*$")) return + while (match(text, item)) { + one = substr(text, RSTART, RLENGTH) + text = substr(text, RSTART + RLENGTH) + if (match(one, /"[^"]*"/)) value = substr(one, RSTART + 1, RLENGTH - 2) + else value = one + print value "\t" f "\t" ln + } + } + { + line = $0 + sub(/[[:space:]]*\/\/.*$/, "", line) + opens = gsub(/\{/, "{", line); closes = gsub(/\}/, "}", line) + if (header == "" && line ~ /(^|[^A-Za-z0-9_])enum[[:space:]]+[A-Za-z_]/) header = " " + if (header != "") { + header = header " " line + brace = index(line, "{") + if (brace) { + if (substr(header, 1, index(header, "{")) ~ /DashIconAsset/) { + icon_depth = depth + 1 + cases(substr(line, brace + 1), FNR) + } + header = "" + } + depth += opens - closes + if (icon_depth && depth < icon_depth) icon_depth = 0 + next + } + if (icon_depth && depth == icon_depth) cases(line, FNR) + depth += opens - closes + if (icon_depth && depth < icon_depth) icon_depth = 0 + }' "$icons" > "$icon_values" +icon_used=$work/icon-used +awk -F'\t' '$1 == "imageset" { print $2 }' "$literals" > "$icon_used" +check_sets imageset "$imagesets" "$icon_values" "$icon_used" unique +check_literals imageset "$imagesets" + +# 2. Color tokens. +color_tokens=$work/color-tokens +awk -v f="$colors" ' + { + line = $0 + sub(/[[:space:]]*\/\/.*$/, "", line) + while (match(line, /dashAsset\("[^"]+"\)/)) { + print substr(line, RSTART + 11, RLENGTH - 13) "\t" f "\t" FNR + line = substr(line, RSTART + RLENGTH) + } + }' "$colors" > "$color_tokens" +color_used=$work/color-used +awk -F'\t' '$1 == "colorset" { print $2 }' "$literals" > "$color_used" +check_sets colorset "$colorsets" "$color_tokens" "$color_used" +check_literals colorset "$colorsets" + +# 4. Set contents and namespaces. One jq run reads every Contents.json; jq stops +# at the first file it cannot parse, so those are found one by one first. +manifests=$work/manifests +: > "$manifests" +while IFS=$'\t' read -r name dir; do + if [ -s "$dir/Contents.json" ]; then + printf '%s\0' "$dir/Contents.json" >> "$manifests" + elif [ -f "$dir/Contents.json" ]; then + report "$dir/Contents.json" 1 "\"${name}\" has an empty Contents.json" + else + report "$dir" 1 "\"${name}\" has no Contents.json" + fi +done < <(cat "$imagesets" "$colorsets") +find "$catalog" -name Contents.json ! -path '*.imageset/*' ! -path '*.colorset/*' -print0 >> "$manifests" + +valid=$work/valid +if xargs -0 jq empty < "$manifests" 2> /dev/null; then + cp "$manifests" "$valid" +else + : > "$valid" + while IFS= read -r -d '' contents; do + if jq empty "$contents" 2> /dev/null; then + printf '%s\0' "$contents" >> "$valid" + else + report "$contents" 1 "Contents.json is not valid JSON" + fi + done < "$manifests" +fi + +# Per file: "count n" for a set (images with a file, or colors), "file +# name" per listed image, "namespace", or "shape" when it is not an object. +# shellcheck disable=SC2016 # jq variables, not shell ones +xargs -0 jq -r ' + input_filename as $f + | if type != "object" then "\($f)\tshape" + else + (if ($f | test("\\.(imageset|colorset)/Contents\\.json$")) + then "\($f)\tcount\t\(if ($f | test("imageset/")) then [.images | arrays | .[] | objects | .filename | strings] else [.colors | arrays | .[] | objects | .color | objects] end | length)" + else empty end), + (.images | arrays | .[] | objects | .filename | strings | "\($f)\tfile\t\(.)"), + (if (.properties | objects | .["provides-namespace"]) == true or (.properties | objects | .["provides-namespace"]) == "true" + then "\($f)\tnamespace" else empty end) + end' < "$valid" | + while IFS=$'\t' read -r contents what value; do + dir=${contents%/Contents.json} + case $what in + shape) report "$contents" 1 "Contents.json is not a JSON object" ;; + count) [ "$value" -gt 0 ] || report "$contents" 1 "\"$(basename "$dir")\" defines no image or color" ;; + file) [ -f "$dir/$value" ] || report "$contents" 1 "lists \"${value}\", which is not in the imageset" ;; + namespace) report "$contents" 1 "folder provides a namespace, so its assets resolve as \"Folder/name\" and DashIcon / Color.dash names stop matching" ;; + esac + done || { echo "conventions.sh: reading Contents.json failed" >&2; exit 2; } + +if [ ! -s "$errors" ]; then + echo "Conventions: OK ($(wc -l < "$imagesets" | tr -d ' ') imagesets, $(wc -l < "$colorsets" | tr -d ' ') colorsets)" + exit 0 +fi + +sort -t$'\t' -k1,1 -k2,2n "$errors" | while IFS=$'\t' read -r file line message; do + echo "${file}:${line}: ${message}" + if [ -n "${GITHUB_ACTIONS:-}" ]; then + # Workflow commands escape % everywhere, and : and , in properties. + message=${message//%/%25} + property=${file//%/%25} + property=${property//:/%3A} + property=${property//,/%2C} + echo "::error file=${property},line=${line}::${message}" + fi +done +echo "Conventions: $(wc -l < "$errors" | tr -d ' ') violation(s)" +exit 1 diff --git a/.github/scripts/tests/conventions-test.sh b/.github/scripts/tests/conventions-test.sh new file mode 100755 index 0000000..3e2d45b --- /dev/null +++ b/.github/scripts/tests/conventions-test.sh @@ -0,0 +1,347 @@ +#!/usr/bin/env bash +# Pins every rule of conventions.sh: the real tree must pass, and each case breaks +# a copy of it once (a fixture file, an appended declaration, or an edited asset) +# and expects a failure naming the right place, or a pass for allowed shapes. +# Cases add their own code instead of editing components, so they keep working as +# the library changes. Run from the repository root. +set -euo pipefail + +root=$(pwd) +script=$root/.github/scripts/conventions.sh +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +src=Sources/DashUIKit +catalog=$src/Resources/Media.xcassets +fixture=$src/Fixture.swift +failures=0 + +fresh() { + rm -rf "$work/tree" + mkdir -p "$work/tree" + cp -R "$root/Sources" "$work/tree/" + cp "$root/Package.swift" "$work/tree/" +} + +# first_set EXT [UNDER]: path of the first *.EXT set (optionally under a folder). +first_set() { + find "$work/tree/$catalog${2:+/$2}" -type d -name "*.$1" | sort | head -n 1 +} + +# set_name PATH: the set's name without its extension. +set_name() { + local base=${1##*/} + echo "${base%.*}" +} + +# write_fixture LINE...: replaces the fixture file with these lines. +write_fixture() { + printf '%s\n' "$@" > "$work/tree/$fixture" +} + +# expect NAME fail|pass [NEEDLE...]: runs the script on the copy; a failure must +# exit 1 and print every needle. +expect() { + local name=$1 want=$2 output status=0 needle missing="" + shift 2 + output=$(cd "$work/tree" && "$script" 2>&1) || status=$? + for needle in "$@"; do + [[ "$output" == *"$needle"* ]] || missing="${missing} missing: ${needle}"$'\n' + done + if { [ "$want" = pass ] && [ "$status" -eq 0 ]; } || + { [ "$want" = fail ] && [ "$status" -eq 1 ] && [ -z "$missing" ]; }; then + echo "ok ${name}" + else + echo "FAIL ${name}: expected ${want}, got exit ${status}" + printf '%s' "$missing" + printf '%s\n' "$output" | sed 's/^/ /' + failures=$((failures + 1)) + fi +} + +export GITHUB_ACTIONS= + +fresh +expect "the real tree passes" pass + +fresh +img=$(set_name "$(first_set imageset)") +col=$(set_name "$(first_set colorset)") + +# 1. Icons ↔ imagesets. +fresh; dir=$(first_set imageset); mv "$dir" "${dir%/*}/${img}-renamed.imageset" +expect "renamed imageset" fail \ + "no imageset named \"${img}\"" \ + "imageset \"${img}-renamed\" is not referenced" + +fresh; printf '%s\n' 'enum FixtureIcon: String, CaseIterable, DashIconAsset {' \ + ' case ghost = "no-such-icon"' \ + ' case noSuchBare' \ + ' case listA = "nope-a", listB' \ + " case dup = \"${img}\"" \ + ' func f() { switch self { case let x where x == .ghost: break; default: break } }' \ + '}' >> "$work/tree/$src/Foundation/Icon_DashUI.swift" +expect "enum cases without assets" fail \ + 'no imageset named "no-such-icon"' \ + 'no imageset named "noSuchBare"' \ + 'no imageset named "nope-a"' \ + 'no imageset named "listB"' \ + "\"${img}\" is declared more than once" + +fresh; mkdir "$work/tree/$catalog/Fixture & Folder"; cp -R "$(first_set imageset)" "$work/tree/$catalog/Fixture & Folder/" +expect "duplicate imageset in a folder with spaces" fail \ + "Fixture & Folder/${img}.imageset/Contents.json:1: imageset \"${img}\" exists in more than one folder" + +fresh; printf '%s\n' 'public enum Wrapped: String, CaseIterable,' \ + ' DashIconAsset' \ + '{' \ + ' case ghost = "no-such-wrapped"' \ + ' enum Size {' \ + ' case small' \ + ' }' \ + ' var size: Int {' \ + ' switch self {' \ + ' case ghost: return 1' \ + ' }' \ + ' }' \ + '}' \ + 'public enum OneLine: String, CaseIterable, DashIconAsset { case ghost = "no-such-one-line" }' >> "$work/tree/$src/Foundation/Icon_DashUI.swift" +expect "icon enums with wrapped and one-line headers" fail \ + 'no imageset named "no-such-wrapped"' 'no imageset named "no-such-one-line"' +status=0; output=$(cd "$work/tree" && "$script" 2>&1) || status=$? +if [ "$status" -eq 1 ] && [[ "$output" != *'"small"'* ]]; then echo "ok nested enum cases are not icons" +else echo "FAIL nested enum cases are not icons: exit ${status}"; printf '%s\n' "$output" | sed 's/^/ /'; failures=$((failures + 1)); fi + +fresh; printf '%s\n' 'enum IconSize {' ' case small, large' '}' >> "$work/tree/$src/Foundation/Icon_DashUI.swift" +echo "let fixtureAlias = dashAsset(\"${col}\")" >> "$work/tree/$src/Foundation/Color+DashUI.swift" +expect "helper enum among icons; color token alias" pass + +fresh; find "$work/tree/$catalog" -type d -name '*.imageset' -exec rm -rf {} + +expect "no imagesets at all" fail 'no imageset found' + +# 2. Color tokens ↔ colorsets. +fresh; dir=$(first_set colorset); cp -R "$dir" "${dir%/*}/OrphanColor.colorset" +expect "orphan colorset" fail 'colorset "OrphanColor" is not referenced' + +fresh; echo 'let fixtureToken = dashAsset("NoSuchToken")' >> "$work/tree/$src/Foundation/Color+DashUI.swift" +expect "token without colorset" fail 'Color+DashUI.swift:' 'no colorset named "NoSuchToken"' + +fresh; echo "let fixtureToken = 1 // was dashAsset(\"NoSuchToken\")" >> "$work/tree/$src/Foundation/Color+DashUI.swift" +dir=$(first_set colorset); cp -R "$dir" "${dir%/*}/LiteralOnly.colorset" +write_fixture 'let a = Color("LiteralOnly", bundle: .module)' +expect "commented token; colorset named only by a literal" pass + +fresh; find "$work/tree/$catalog" -type d -name '*.colorset' -exec rm -rf {} + +expect "no colorsets at all" fail 'no colorset found' + +# 3. Literal asset references. +fresh; write_fixture \ + 'let a = DashIconSource.custom(flag ? "nope-one" : name, bundle: .dashUIKit)' \ + 'let b = Color("NoSuchColor", bundle: .module)' \ + "let c = Image(\"${img}\")" \ + "let d = UIImage(named: \"${img}\")" \ + "let e = Color(\"${col}\")" \ + "let f = UIColor(named: \"${col}\", in: nil, compatibleWith: nil)" \ + "let g = Image(\"${img}\", bundle: .main)" \ + 'let h = Image(decorative: "nope-two", bundle: .module)' \ + 'let i = UIImage(named: "nope-three", in: .module, with: nil)' \ + 'let j = UIColor(named: "NoSuchColor2", in: .dashUIKit, compatibleWith: nil)' \ + 'let k = Image(' \ + ' "nope-four",' \ + ' bundle : .module)' \ + 'let l = SwiftUI.Color.init("NoSuchColor3", bundle: .module)' \ + 'let m = UIImage(named: style.iconName)' \ + 'let n = Image.init("nope-five")' \ + 'let o = Label("Title", image: "nope-six")' \ + 'let p = Image(dash: .custom(style.iconName))' \ + 'let q = DashButton(title: "x", leadingIcon: .custom(name))' \ + 'let r: DashIconSource = .custom(name)' \ + 'let s = Tab("Home", image: "nope-tab")' +expect "literal asset names" fail \ + "${fixture}:1: no imageset named \"nope-one\"" \ + "${fixture}:2: no colorset named \"NoSuchColor\"" \ + "${fixture}:3: asset looked up in the host app bundle" \ + "${fixture}:4: asset looked up in the host app bundle" \ + "${fixture}:5: asset looked up in the host app bundle" \ + "${fixture}:6: in: nil is not the package bundle" \ + "${fixture}:7: bundle: .main is not the package bundle" \ + "${fixture}:8: no imageset named \"nope-two\"" \ + "${fixture}:9: no imageset named \"nope-three\"" \ + "${fixture}:10: no colorset named \"NoSuchColor2\"" \ + "${fixture}:11: no imageset named \"nope-four\"" \ + "${fixture}:14: no colorset named \"NoSuchColor3\"" \ + "${fixture}:15: asset looked up in the host app bundle" \ + "${fixture}:16: asset looked up in the host app bundle" \ + "${fixture}:17: image: \"…\" is looked up in the host app bundle" \ + "${fixture}:18: asset looked up in the host app bundle: pass bundle" \ + "${fixture}:19: asset looked up in the host app bundle: pass bundle" \ + "${fixture}:20: asset looked up in the host app bundle: pass bundle" \ + "${fixture}:21: image: \"…\" is looked up in the host app bundle" + +fresh; write_fixture \ + 'let a = Font.custom("Inter", size: 12)' \ + 'let b = Image(dash: .custom("icon-\(n)", bundle: .dashUIKit))' \ + 'let c = Image(systemName: "xmark").accessibilityLabel(NSLocalizedString("Close", bundle: .module, comment: ""))' \ + 'let d = Image(dash: source).overlay(Text("Done", bundle: .module))' \ + "let e = Image(\"${img}\", bundle: Bundle.dashUIKit)" \ + 'let f = Color(uiColor: .systemBackground).opacity(0.5)' \ + 'let g = Image(String(format: "menu-%@", x), bundle: .module)' \ + "let h = Image(icons[\"key\"] ?? \"${img}\", bundle: .module)" \ + 'let i = Color(UIColor(named: "'"${col}"'", in: .module, compatibleWith: nil)!)' \ + 'let j = Image(dash: .custom("icon-\(flag ? "a" : "b")", bundle: .dashUIKit))' \ + 'let k = Image(.resourceName)' \ + "let l = Image(\"${img}\", bundle: #bundle)" \ + 'let m = Image("icon-" + name, bundle: .module)' \ + 'let n = Image(icon.resource)' \ + 'let o = Label("Title", systemImage: "xmark")' \ + 'func p(s: DashIconSource) { switch s { case let .custom(name, bundle): _ = (name, bundle); default: break } }' \ + 'func q(s: DashIconSource) { if case .custom(_, let b) = s { _ = b } }' \ + 'let r: Set = [.medium, .custom(Half.self)]' \ + 'let t = Button(style: .custom(style))' \ + 'let u = Placement.custom("Compact")' +expect "calls that are not literal asset lookups" pass + +# 4. Set contents and namespaces. +fresh; dir=$(first_set imageset); png=$(jq -r '.images[] | .filename // empty' "$dir/Contents.json" | head -n 1); rm "$dir/$png" +expect "image file missing" fail "lists \"${png}\", which is not in the imageset" + +fresh; dir=$(first_set imageset); echo '{"images":[],"info":{"version":1}}' > "$dir/Contents.json" +expect "imageset without images" fail "\"${img}.imageset\" defines no image or color" + +fresh; rm "$(first_set imageset)/Contents.json" +expect "imageset without Contents.json" fail "\"${img}\" has no Contents.json" + +fresh; : > "$(first_set colorset)/Contents.json" +expect "empty Contents.json" fail "\"${col}\" has an empty Contents.json" + +fresh; echo '{"colors":[]}' > "$(first_set colorset)/Contents.json" +expect "colorset without colors" fail "\"${col}.colorset\" defines no image or color" + +fresh; echo '{' > "$(first_set colorset)/Contents.json"; echo '[]' > "$(first_set imageset)/Contents.json" +expect "invalid and non-object JSON" fail 'Contents.json is not valid JSON' 'Contents.json is not a JSON object' + +fresh; mkdir "$work/tree/$catalog/Fixture NS" "$work/tree/$catalog/Fixture NS 2" +echo '{"properties":{"provides-namespace":true}}' > "$work/tree/$catalog/Fixture NS/Contents.json" +echo '{"properties":{"provides-namespace":"true"}}' > "$work/tree/$catalog/Fixture NS 2/Contents.json" +expect "namespaced folders" fail \ + 'Fixture NS/Contents.json:1: folder provides a namespace' \ + 'Fixture NS 2/Contents.json:1: folder provides a namespace' + +# 5. Hardcoded colors. +fresh; write_fixture \ + 'let a = Color(red: 1, green: 0, blue: 0)' \ + 'let b = Color.init(red: 1, green: 0, blue: 0)' \ + 'let c: Color = .init(white: 0.5)' \ + 'let d = Color(uiColor: UIColor(red: 1, green: 0, blue: 0, alpha: 1))' \ + 'let e = Color(' \ + ' hue: 0.5, saturation: 1, brightness: 1)' \ + 'let f = CGColor(gray: 0.5, alpha: 1)' \ + 'let g = Color(.sRGB, red: 1, green: 0, blue: 0)' \ + 'let h = Color(hex: "#ffffff")' \ + 'let i = #colorLiteral(red: 1, green: 0, blue: 0, alpha: 1)' \ + 'let j = NSColor(displayP3Red: 1, green: 0, blue: 0, alpha: 1)' \ + 'let k = UIColor(white: 1, alpha: 1)' \ + 'let l = Color(Color.RGBColorSpace.sRGB, red: 1, green: 0, blue: 0)' \ + 'let m = Color(red : 0.1, green: 0, blue: 0)' \ + 'let n = Text("\(Color(red: 1, green: 0, blue: 0))")' +expect "colors built from components" fail \ + "${fixture}:1: hardcoded color" "${fixture}:2: hardcoded color" "${fixture}:3: hardcoded color" \ + "${fixture}:4: hardcoded color" "${fixture}:5: hardcoded color" "${fixture}:7: hardcoded color" \ + "${fixture}:8: hardcoded color" "${fixture}:9: hardcoded color" "${fixture}:10: hardcoded color" \ + "${fixture}:11: hardcoded color" "${fixture}:12: hardcoded color" "${fixture}:13: hardcoded color" \ + "${fixture}:14: hardcoded color" "${fixture}:15: hardcoded color" + +fresh; write_fixture \ + '// Color(red: 1, green: 0, blue: 0)' \ + 'let a = Text("Color(red: 1)") // Color(red: 1)' +expect "comments and strings are not code" pass + +# 6. Localized strings. +fresh; write_fixture \ + 'let a = NSLocalizedString(' \ + ' "multi-line",' \ + ' comment: "")' \ + 'let b = NSLocalizedString("a", bundle: .module, comment: "") + NSLocalizedString("b", comment: "")' \ + 'let c = foo() // NSLocalizedString(' \ + 'let d = NSLocalizedString("d", comment: "")' \ + 'let e = String(localized: "e")' \ + 'let f = NSLocalizedString("f", bundle: .main, comment: "")' \ + 'let g = NSLocalizedString("g", comment: f(bundle: .module))' \ + 'let h = Text("x \(String(localized: "y"))")' \ + 'let i = AttributedString(localized: "i")' +expect "localized strings without the package bundle" fail \ + "${fixture}:1: localized string" "${fixture}:4: localized string" "${fixture}:6: localized string" \ + "${fixture}:7: localized string" "${fixture}:8: localized string" "${fixture}:9: localized string" \ + "${fixture}:10: localized string" "${fixture}:11: localized string" + +fresh; write_fixture \ + 'let a = NSLocalizedString("a (b", bundle: Bundle.dashUIKit, comment: ")")' \ + 'let b = String(localized: "c", bundle: .module)' \ + 'let c = String(describing: x)' \ + 'let d = NSLocalizedString("d", bundle : .module, comment: "")' \ + 'let e = String(localized: "e", bundle: #bundle)' \ + 'let f = MyNSLocalizedString("f")' \ + 'let g = String(localized: resource)' +expect "localized strings with the package bundle" pass + +# 7. Availability. +fresh; write_fixture \ + '@available( iOS 13, *)' \ + 'struct A {}' \ + '@available(iOS, introduced: 13)' \ + 'struct B {}' \ + '@available(macCatalystApplicationExtension 13, *)' \ + 'struct C {}' \ + '@available(' \ + ' macOS 10.15, *)' \ + 'struct D {}' \ + 'func e() { if #available(iOS 13, *) {} }' \ + 'func f() { if #unavailable(iOS 13) {} }' +expect "@available at or below the floor" fail \ + "${fixture}:1: availability check for iOS 13 is at or below the package floor" \ + "${fixture}:3: availability check for iOS 13 is at or below" \ + "${fixture}:5: availability check for macCatalystApplicationExtension 13 is at or below" \ + "${fixture}:7: availability check for macOS 10.15 is at or below the package floor" \ + "${fixture}:10: availability check for iOS 13 is at or below" \ + "${fixture}:11: availability check for iOS 13 is at or below" + +fresh; write_fixture \ + '@available(*, deprecated, message: "x")' \ + '@available(macOS, unavailable)' \ + 'struct A {}' \ + '@available(iOS 99, macOS 99, *)' \ + 'struct B {}' \ + '@available(visionOS 1, *)' \ + 'struct C {}' \ + 'func d() { if #unavailable(iOS 99.0) {} }' +expect "deprecated, unavailable and above the floor are allowed" pass + +# Package.swift without a macOS platform: nothing to compare macOS against. +fresh; perl -0pi -e 's/,\s*\.macOS\(\.v\d+\)//' "$work/tree/Package.swift" +write_fixture '@available(macOS 10.15, *)' 'struct A {}' +expect "no macOS floor declared" pass + +# A macOS platform in a shape the script cannot read is "could not run" too. +fresh; perl -pi -e 's/\.macOS\(\.v\d+(_\d+)?\)/.macOS("15.0")/' "$work/tree/Package.swift" +status=0; (cd "$work/tree" && "$script" > /dev/null 2>&1) || status=$? +if [ "$status" -eq 2 ]; then echo "ok unreadable macOS floor exits 2"; else echo "FAIL unreadable macOS floor: exit ${status}"; failures=$((failures + 1)); fi + +# An unreadable iOS floor is "could not run", not a violation. +fresh; perl -pi -e 's/\.iOS\(\.v\d+(_\d+)?\)/.iOS("18.0")/' "$work/tree/Package.swift" +status=0; (cd "$work/tree" && "$script" > /dev/null 2>&1) || status=$? +if [ "$status" -eq 2 ]; then echo "ok unreadable iOS floor exits 2"; else echo "FAIL unreadable iOS floor: exit ${status}"; failures=$((failures + 1)); fi + +# Output: workflow annotations escape the path. +fresh; printf '%s\n' 'let a = Color(red: 1, green: 0, blue: 0)' > "$work/tree/$src/Fix,ture.swift" +GITHUB_ACTIONS=true expect "annotation escapes the path" fail \ + "::error file=${src}/Fix%2Cture.swift,line=1::hardcoded color" + +# Wrong directory: exit 2, not a pass. +status=0; (cd "$work" && "$script" > /dev/null 2>&1) || status=$? +if [ "$status" -eq 2 ]; then echo "ok wrong directory exits 2"; else echo "FAIL wrong directory: exit ${status}"; failures=$((failures + 1)); fi + +if [ "$failures" -gt 0 ]; then + echo "${failures} case(s) failed" + exit 1 +fi +echo "All cases passed" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a03887..2f48e98 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,24 @@ jobs: CODE_SIGNING_ALLOWED=NO \ test + conventions: + name: "Conventions: assets, colors, strings" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Asset catalog ↔ DashIcon / Color.dash, asset and string bundles, hardcoded + # colors, @available at or below the floor (see the script header). + - name: Check conventions + run: .github/scripts/conventions.sh + + # Breaks a copy of the tree once per rule, so a rule that stops matching fails here. + - name: Test the conventions script + run: .github/scripts/tests/conventions-test.sh + actionlint: name: Lint CI config (actionlint, shellcheck) runs-on: ubuntu-latest @@ -92,4 +110,4 @@ jobs: # actionlint only checks inline run: blocks. - name: Run shellcheck on scripts - run: shellcheck .github/scripts/*.sh + run: shellcheck .github/scripts/*.sh .github/scripts/tests/*.sh diff --git a/CLAUDE.md b/CLAUDE.md index 11a8799..1c5c7ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,12 @@ Sources/DashUIKit/ - **Localization:** user-facing strings use `NSLocalizedString(_, bundle: .module, comment:)`. - Components are **stateless/value-driven** — state (`@Binding`) and callbacks live with the host. Don't add view models or persistence here. +- CI enforces the mechanical ones with `.github/scripts/conventions.sh`: `DashIcon` ↔ + imagesets, `Color.dash` tokens ↔ colorsets, literal asset names and their bundle, no + colors built from components (`Color(red:…)`, hex), `NSLocalizedString` / + `String(localized:)` with the package bundle, no `@available` at or below the floor. + It does not check system colors (`Color.red`) or `Text("…")` keys. Run it from the repo + root before pushing. ## Build / preview diff --git a/Sources/DashUIKit/Components/RadioButtonRow.swift b/Sources/DashUIKit/Components/RadioButtonRow.swift index 734b77c..a7b5b22 100644 --- a/Sources/DashUIKit/Components/RadioButtonRow.swift +++ b/Sources/DashUIKit/Components/RadioButtonRow.swift @@ -87,7 +87,7 @@ public struct RadioButtonRow: View { .frame(width: isSelected ? 21 : 24, height: isSelected ? 21 : 24) .padding(.trailing, isSelected ? 2 : 0) case .checkbox: - Image(dash: .custom(isSelected ? "icon_checkbox_square_checked" : "icon_checkbox_square", bundle: .dashUIKit)) + (isSelected ? DashIcon.Checkbox.checkmarkChecked : .checkmarkUnchecked).image .resizable() .frame(width: 24, height: 24) } diff --git a/Sources/DashUIKit/Components/Switch/Components/ThumbView.swift b/Sources/DashUIKit/Components/Switch/Components/ThumbView.swift index c5e352a..8e45494 100644 --- a/Sources/DashUIKit/Components/Switch/Components/ThumbView.swift +++ b/Sources/DashUIKit/Components/Switch/Components/ThumbView.swift @@ -20,8 +20,8 @@ struct ThumbView: View { .frame(width: 39, height: 24) .background(Constants.switchThumbFill) .cornerRadius(Constants.switchThumbRadius) - .shadow(color: Color(red: 0.1, green: 0.13, blue: 0.15).opacity(0.06), radius: 1, x: 0, y: 1) - .shadow(color: Color(red: 0.1, green: 0.13, blue: 0.15).opacity(0.1), radius: 1.5, x: 0, y: 1) + .shadow(color: Color.dash.switchThumbShadow.opacity(0.06), radius: 1, x: 0, y: 1) + .shadow(color: Color.dash.switchThumbShadow.opacity(0.1), radius: 1.5, x: 0, y: 1) } } diff --git a/Sources/DashUIKit/Foundation/Color+DashUI.swift b/Sources/DashUIKit/Foundation/Color+DashUI.swift index 93bf72d..bc25f84 100644 --- a/Sources/DashUIKit/Foundation/Color+DashUI.swift +++ b/Sources/DashUIKit/Foundation/Color+DashUI.swift @@ -164,6 +164,7 @@ public enum DashColors { // MARK: Switch public static var switchThumbFill: Color { dashAsset("SwitchThumbFill") } + public static var switchThumbShadow: Color { dashAsset("SwitchThumbShadow") } public static var switchTrackFillOff: Color { dashAsset("SwitchTrackFillOff") } public static var switchTrackFillOffDisabled: Color { dashAsset("SwitchTrackFillOffDisabled") } public static var switchTrackFillOn: Color { dashAsset("SwitchTrackFillOn") } diff --git a/Sources/DashUIKit/Resources/Media.xcassets/Colors/Switch/SwitchThumbShadow.colorset/Contents.json b/Sources/DashUIKit/Resources/Media.xcassets/Colors/Switch/SwitchThumbShadow.colorset/Contents.json new file mode 100644 index 0000000..9f89e19 --- /dev/null +++ b/Sources/DashUIKit/Resources/Media.xcassets/Colors/Switch/SwitchThumbShadow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.150", + "green" : "0.130", + "red" : "0.100" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/docs/lists-and-rows.md b/docs/lists-and-rows.md index 3bf3df7..71a279c 100644 --- a/docs/lists-and-rows.md +++ b/docs/lists-and-rows.md @@ -166,8 +166,8 @@ RadioButtonRow( ``` **`Style`** — `.radio` (a ring that thickens/fills blue when selected) or `.checkbox` -(uses the bundled `icon_checkbox_square[_checked]` assets). Row min-height is 60 with a -subtitle, otherwise 54; the whole row is the tap target. +(draws `DashIcon.Checkbox.checkmarkChecked` / `.checkmarkUnchecked`). Row min-height is +60 with a subtitle, otherwise 54; the whole row is the tap target. --- From b7dd96f8af73a8ffb3780e80ac3d52c18495337c Mon Sep 17 00:00:00 2001 From: romchornyi <270373998+romchornyi@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:24:20 +0300 Subject: [PATCH 2/2] ci: keep deprecations in the @available hint, accept spaced dashAsset calls An introduced: version at or below the floor next to deprecated:, obsoleted:, renamed: or message: now asks to remove only introduced:, so following the hint no longer drops a deprecation. dashAsset( "Name" ) with spaces is read as a token. The header now says NSLocalizedString is checked for every key. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/conventions.sh | 17 ++++++++++++----- .github/scripts/tests/conventions-test.sh | 9 ++++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/scripts/conventions.sh b/.github/scripts/conventions.sh index ecd7550..b44f7bc 100755 --- a/.github/scripts/conventions.sh +++ b/.github/scripts/conventions.sh @@ -13,8 +13,9 @@ # a namespace (names would gain a "Folder/" prefix); # 5. no color built from components (red:, white:, hue:, gray:, .sRGB, hex, # #colorLiteral) outside Color+DashUI.swift, where tokens are defined; -# 6. every NSLocalizedString / String(localized:) / AttributedString(localized:) -# call with a literal key passes the package bundle; +# 6. every NSLocalizedString call, and every String(localized:) / +# AttributedString(localized:) call with a literal key, passes the package +# bundle; # 7. no @available / #available / #unavailable for an iOS or macOS version at or # below the Package.swift floor: the floor already covers it. Newer versions # may still be annotated or checked. @@ -253,7 +254,11 @@ find "$src" -name '*.swift' -print0 | sort -z | if (platform ~ /^(iOS|macCatalyst)(ApplicationExtension)?$/) floor = ios_floor else if (platform ~ /^macOS(ApplicationExtension)?$/) floor = mac_floor else continue - if (le(version, floor)) + if (!le(version, floor)) continue + # introduced: next to deprecated:/obsoleted:/… : only introduced: can go. + if (arg("deprecated") || arg("obsoleted") || arg("renamed") || arg("message")) + out(li, "introduced: " version " for " platform " is at or below the package floor (" floor "); remove only introduced:, keep the rest of the attribute") + else out(li, "availability check for " platform " " version " is at or below the package floor (" floor "), declared once in Package.swift; drop it (CLAUDE.md, Deployment target)") } } @@ -373,9 +378,11 @@ awk -v f="$colors" ' { line = $0 sub(/[[:space:]]*\/\/.*$/, "", line) - while (match(line, /dashAsset\("[^"]+"\)/)) { - print substr(line, RSTART + 11, RLENGTH - 13) "\t" f "\t" FNR + while (match(line, /dashAsset[[:space:]]*\([[:space:]]*"[^"]+"[[:space:]]*\)/)) { + call = substr(line, RSTART, RLENGTH) line = substr(line, RSTART + RLENGTH) + match(call, /"[^"]+"/) + print substr(call, RSTART + 1, RLENGTH - 2) "\t" f "\t" FNR } }' "$colors" > "$color_tokens" color_used=$work/color-used diff --git a/.github/scripts/tests/conventions-test.sh b/.github/scripts/tests/conventions-test.sh index 3e2d45b..6f34e8f 100755 --- a/.github/scripts/tests/conventions-test.sh +++ b/.github/scripts/tests/conventions-test.sh @@ -123,7 +123,7 @@ expect "no imagesets at all" fail 'no imageset found' fresh; dir=$(first_set colorset); cp -R "$dir" "${dir%/*}/OrphanColor.colorset" expect "orphan colorset" fail 'colorset "OrphanColor" is not referenced' -fresh; echo 'let fixtureToken = dashAsset("NoSuchToken")' >> "$work/tree/$src/Foundation/Color+DashUI.swift" +fresh; echo 'let fixtureToken = dashAsset( "NoSuchToken" )' >> "$work/tree/$src/Foundation/Color+DashUI.swift" expect "token without colorset" fail 'Color+DashUI.swift:' 'no colorset named "NoSuchToken"' fresh; echo "let fixtureToken = 1 // was dashAsset(\"NoSuchToken\")" >> "$work/tree/$src/Foundation/Color+DashUI.swift" @@ -296,14 +296,17 @@ fresh; write_fixture \ ' macOS 10.15, *)' \ 'struct D {}' \ 'func e() { if #available(iOS 13, *) {} }' \ - 'func f() { if #unavailable(iOS 13) {} }' + 'func f() { if #unavailable(iOS 13) {} }' \ + '@available(iOS, introduced: 13, deprecated: 99, message: "Use X")' \ + 'struct G {}' expect "@available at or below the floor" fail \ "${fixture}:1: availability check for iOS 13 is at or below the package floor" \ "${fixture}:3: availability check for iOS 13 is at or below" \ "${fixture}:5: availability check for macCatalystApplicationExtension 13 is at or below" \ "${fixture}:7: availability check for macOS 10.15 is at or below the package floor" \ "${fixture}:10: availability check for iOS 13 is at or below" \ - "${fixture}:11: availability check for iOS 13 is at or below" + "${fixture}:11: availability check for iOS 13 is at or below" \ + "${fixture}:12: introduced: 13 for iOS is at or below the package floor" fresh; write_fixture \ '@available(*, deprecated, message: "x")' \