diff --git a/.github/scripts/conventions.sh b/.github/scripts/conventions.sh new file mode 100755 index 0000000..b44f7bc --- /dev/null +++ b/.github/scripts/conventions.sh @@ -0,0 +1,463 @@ +#!/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 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. +# +# 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)) 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)") + } + } + } + + 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[[: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 +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..6f34e8f --- /dev/null +++ b/.github/scripts/tests/conventions-test.sh @@ -0,0 +1,350 @@ +#!/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) {} }' \ + '@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}:12: introduced: 13 for iOS is at or below the package floor" + +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. ---