Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci-main-macos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ jobs:
- 'clients/**'
- '.github/workflows/ci-main-macos.yaml'

flexframe-lint:
name: FlexFrame Lint
needs: [changes]
if: needs.changes.outputs.clients == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Ensure ripgrep is available
run: |
if ! command -v rg >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ripgrep
fi
rg --version | head -1

- name: FlexFrame guard
run: bash clients/scripts/check-flexframe.sh
Comment on lines +44 to +63
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 notify-macos job doesn't track flexframe-lint failures

The notify-macos job in ci-main-macos.yaml has needs: [changes, test, build] and only checks needs.test.result and needs.build.result for status computation (ci-main-macos.yaml:327-348). The new flexframe-lint job is not included in the needs list, so if it fails on main, the Slack notification will report success (assuming test/build pass). GitHub's workflow-level status will still show the failure, but the team won't get a Slack alert for it.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7cd117f (#27558) — added flexframe-lint to notify-macos.needs[] and to all three status branches (failure/cancelled/skipped). A main-branch flexframe-lint failure now triggers the Slack alert.


test:
name: macOS Tests
needs: [changes]
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/pr-macos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ on:
- 'clients/.periphery.yml'
- 'clients/.periphery_baseline.json'
- 'clients/scripts/periphery-scan.sh'
- 'clients/scripts/check-flexframe.sh'
- 'clients/scripts/flexframe-allowlist.txt'
- 'assistant/**'
- 'gateway/**'
- 'credential-executor/**'
Expand All @@ -21,6 +23,25 @@ concurrency:
cancel-in-progress: true

jobs:
flexframe-lint:
name: FlexFrame Lint
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Ensure ripgrep is available
run: |
if ! command -v rg >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ripgrep
fi
rg --version | head -1

- name: FlexFrame guard
run: bash clients/scripts/check-flexframe.sh

lint-unused:
if: contains(github.event.pull_request.labels.*.name, 'preview')
name: Lint Unused Code
Expand Down
241 changes: 241 additions & 0 deletions clients/scripts/check-flexframe.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
set -euo pipefail

# FlexFrame guardrail script
#
# Detects `.frame(maxWidth:)` / `.frame(maxHeight:)` usages in performance-sensitive
# chat/window directories. These modifiers create `_FlexFrameLayout`, which queries
# `explicitAlignment` on descendants — cascading O(depth × children) per layout pass
# and causing multi-second hangs in LazyVStack-backed hierarchies.
#
# See clients/macos/AGENTS.md (section "No `.frame(maxWidth:)` ... in LazyVStack/
# LazyHStack/LazyVGrid cell hierarchy") for the rule and safe alternatives.
#
# Safe alternatives:
# - .widthCap(N) — O(1) width cap via WidthCapLayout
# - .frame(width: N) — _FrameLayout, no alignment query
# - HStack { content; Spacer(minLength: 0) } / Spacer + content — alignment without FlexFrame
# - BottomAlignedMinHeightLayout — vertical equivalent
#
# Historical context: this cascade has been fixed 9+ times in chat-surface code
# (PRs #24019, #24091, #24584, #24589, #25844, #25947, #26007, #26053, #26092, #26220).
# The manual audit process missed regressions twice — this lint enforces the rule
# mechanically. Tracked in LUM-1116.
#
# Usage: check-flexframe.sh [--update-baseline]
#
# Baseline (allowlist) format — `clients/scripts/flexframe-allowlist.txt`:
# <path>|<trimmed-line-content>
# One entry per occurrence (multiplicity-preserving). Line numbers are intentionally
# NOT part of the key so the allowlist survives unrelated line drift.

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CLIENTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
REPO_ROOT="$(cd "$CLIENTS_DIR/.." && pwd)"
ALLOWLIST_FILE="$SCRIPT_DIR/flexframe-allowlist.txt"

UPDATE_BASELINE=0
for arg in "$@"; do
case "$arg" in
--update-baseline) UPDATE_BASELINE=1 ;;
-h|--help)
sed -n '2,25p' "$0" | sed 's/^# \?//'
exit 0 ;;
*) echo "Unknown argument: $arg" >&2; exit 1 ;;
esac
done

# Scope: performance-sensitive chat + main window feature directories.
# Conservative by design; expand in a follow-up if this proves valuable.
SCAN_DIRS=(
"clients/macos/vellum-assistant/Features/Chat/"
"clients/macos/vellum-assistant/Features/MainWindow/"
)

# Matches .frame(maxWidth: ...) or .frame(maxHeight: ...) — any value.
# Rust-regex compatible (no lookaround) so it works with ripgrep's default
# engine; we strip comment-only lines in a second pass below.
PATTERN='\.frame\(\s*max(Width|Height)\s*:'

cd "$REPO_ROOT"

if ! command -v rg >/dev/null 2>&1; then
echo "ERROR: ripgrep (rg) is required but not found in PATH." >&2
echo " macOS: brew install ripgrep" >&2
echo " Ubuntu: apt-get install ripgrep" >&2
exit 2
fi

# Collect raw hits with line numbers, then drop comment-only lines
# (lines whose first non-whitespace is `//` or `///`). AGENTS.md-style
# warnings like `// ⚠️ No .frame(maxWidth:) in LazyVStack cells` would
# otherwise false-positive.
RAW_HITS=$(rg -n --no-heading "$PATTERN" "${SCAN_DIRS[@]}" 2>/dev/null \
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enable multiline matching in flexframe detection

The main scan uses rg -n --no-heading "$PATTERN" without -U/--multiline, so it only matches when .frame( and maxWidth/maxHeight are on the same line. A Swift call formatted as .frame( on one line and maxWidth: on the next line is silently missed, which lets new FlexFrame regressions bypass this guardrail entirely in the exact directories this lint is meant to protect.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 588c3ae (#27556) — main scan now uses rg -U --multiline-dotall so .frame( wrapped across lines is caught. Verified by catching a previously-missed instance at ChatLoadingSkeleton.swift:58-61 (now allowlisted) plus an injected multi-line test case.

| grep -vE '^[^:]+:[0-9]+:[[:space:]]*//' \
|| true)

# Build the comparison set: `<path>|<trimmed-content>` (no line number, whitespace stripped).
# Preserves multiplicity via plain `sort` (not `sort -u`).
normalize() {
# Input: clients/.../Foo.swift:42: .frame(maxWidth: .infinity)
# Output: clients/.../Foo.swift|.frame(maxWidth: .infinity)
sed -E 's/^([^:]+):[0-9]+:[[:space:]]*/\1|/'
}

OBSERVED_NORMALIZED=""
if [[ -n "$RAW_HITS" ]]; then
OBSERVED_NORMALIZED=$(printf '%s\n' "$RAW_HITS" | normalize | sort)
fi

# --update-baseline: rewrite the allowlist to match the current observed set.
# Use sparingly; every entry is a TODO to eventually convert to a safe alternative.
if [[ "$UPDATE_BASELINE" == "1" ]]; then
{
cat <<'HEADER'
# FlexFrame allowlist — intentional `.frame(maxWidth:)` / `.frame(maxHeight:)` usages.
#
# Each line is `<path>|<trimmed-line-content>` for one occurrence. Line numbers
# are intentionally omitted so entries survive unrelated line drift.
#
# Why an entry is here (typical reasons):
# - Leaf view (Text / Image / VIconView) where `_FlexFrameLayout`'s cascade
# bottoms out immediately — cost is O(0), so the alignment-query concern
# is purely theoretical. (e.g. `.frame(maxWidth: .infinity, alignment: .leading)`
# wrapping a single `Text` with `.lineLimit(1).truncationMode(.tail)` — a
# configuration that `HStack + Spacer` breaks.)
# - Top-level container outside any Lazy* hierarchy where an explicit
# fill-parent semantic is load-bearing.
# - Sheet / modal / detail panel surfaces rendered eagerly (no lazy container
# and no animated transition in the parent).
#
# Adding a new entry: BEFORE allowlisting, first try a safe alternative:
# .widthCap(N), .frame(width: N), HStack+Spacer, BottomAlignedMinHeightLayout.
# If and only if none of those preserve required semantics (truncation, exact
# alignment, fill-parent for a modal root), add the entry and a one-line note
# in the PR description explaining why. The default answer is "use a safe
# alternative"; this file is a last resort, not a general escape hatch.
#
# Regenerate this file after an intentional bulk refactor with:
# bash clients/scripts/check-flexframe.sh --update-baseline
#
# See clients/macos/AGENTS.md §§ "No `.frame(maxWidth:)` ... in LazyVStack/
# LazyHStack/LazyVGrid cell hierarchy" for the underlying rule.
HEADER
if [[ -n "$OBSERVED_NORMALIZED" ]]; then
printf '%s\n' "$OBSERVED_NORMALIZED"
fi
} > "$ALLOWLIST_FILE"
COUNT=$(printf '%s\n' "$OBSERVED_NORMALIZED" | grep -cE '.' || true)
echo "Wrote $COUNT allowlist entries to $ALLOWLIST_FILE"
exit 0
fi

# Load the allowlist (strip comments + blank lines), preserving multiplicity.
ALLOWLIST_ENTRIES=""
if [[ -f "$ALLOWLIST_FILE" ]]; then
ALLOWLIST_ENTRIES=$(grep -vE '^([[:space:]]*#|[[:space:]]*$)' "$ALLOWLIST_FILE" | sort)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle empty allowlist content without aborting

With set -euo pipefail, this assignment exits the script when the allowlist has only comments/blank lines because grep -vE ... returns status 1 for "no matches." That means once all violations are cleaned up (or after generating a header-only baseline), normal lint runs fail before comparison logic, turning a fully-clean state into a CI failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 588c3ae (#27556) — allowlist load now tolerates grep -v's exit-1 on no-match via || true. Verified that a header-only allowlist with zero violations exits 0 (previously aborted on pipefail).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing || true on allowlist grep causes script crash when all allowlist entries are removed

Under set -euo pipefail (line 2), the grep -vE on line 136 exits with code 1 when it finds no non-comment/non-blank lines (i.e., the allowlist file exists but all actual entries have been removed, leaving only the header comments). With pipefail, the pipeline grep ... | sort inherits grep's non-zero exit, and set -e terminates the script with no error message. This silently crashes the CI job.

Every other grep in the script that can legitimately match zero lines is protected with || true (clients/scripts/check-flexframe.sh:75, clients/scripts/check-flexframe.sh:128, clients/scripts/check-flexframe.sh:185), but this one is missing the guard. The bug will manifest when the team eventually fixes all allowlisted violations and removes the entries — the lint CI job will start failing on every PR, with no diagnostic output.

Suggested change
ALLOWLIST_ENTRIES=$(grep -vE '^([[:space:]]*#|[[:space:]]*$)' "$ALLOWLIST_FILE" | sort)
ALLOWLIST_ENTRIES=$(grep -vE '^([[:space:]]*#|[[:space:]]*$)' "$ALLOWLIST_FILE" | sort || true)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 588c3ae (#27556) — same issue Codex flagged. Used an if-guard + || true before the sort rather than || true after the pipe, but equivalent effect: header-only allowlist with zero violations now exits 0 instead of aborting on pipefail.

fi

# New violations = observed - allowlist (multiset difference preserved by `comm -23`).
NEW_NORMALIZED=""
if [[ -n "$OBSERVED_NORMALIZED" ]]; then
NEW_NORMALIZED=$(comm -23 \
<(printf '%s\n' "$OBSERVED_NORMALIZED") \
<(printf '%s\n' "$ALLOWLIST_ENTRIES"))
fi

# Stale allowlist entries = allowlist - observed. Warn (don't fail) so the
# allowlist shrinks as code is cleaned up.
STALE_NORMALIZED=""
if [[ -n "$ALLOWLIST_ENTRIES" ]]; then
STALE_NORMALIZED=$(comm -13 \
<(printf '%s\n' "${OBSERVED_NORMALIZED:-}") \
<(printf '%s\n' "$ALLOWLIST_ENTRIES"))
fi

# Count non-blank lines. `grep -c` handles trailing-newline edge cases correctly
# (unlike `printf '%s' | wc -l` which undercounts by 1 when there's no final \n).
count_nonblank() {
if [[ -z "${1:-}" ]]; then echo 0; return; fi
printf '%s\n' "$1" | grep -cE '.'
}

NEW_COUNT=$(count_nonblank "${NEW_NORMALIZED:-}")
STALE_COUNT=$(count_nonblank "${STALE_NORMALIZED:-}")

# Map each new normalized violation back to the raw `file:line:content` hits
# for a useful diagnostic. A given `<path>|<content>` may map to multiple
# raw lines; we want exactly `new_count[key]` of them printed per key.
#
# Implemented with two temp files to stay compatible with bash 3.2 (macOS
# default — no associative arrays).
print_new_violations() {
local budget_file sorted_budget_file
budget_file=$(mktemp)
sorted_budget_file=$(mktemp)
# shellcheck disable=SC2064
trap "rm -f '$budget_file' '$sorted_budget_file'" RETURN

# Build a "budget" of how many instances of each normalized key are NEW.
printf '%s\n' "$NEW_NORMALIZED" \
| grep -vE '^$' \
| sort \
| uniq -c \
| sed -E 's/^[[:space:]]*([0-9]+)[[:space:]]+/\1\t/' \
> "$sorted_budget_file" || true

# Walk raw hits in source order; for each raw line, look up its normalized
# key's remaining budget and emit if > 0, decrementing as we go.
while IFS= read -r raw; do
[[ -z "$raw" ]] && continue
normalized=$(printf '%s\n' "$raw" | normalize)
# Lookup current remaining budget for this key.
remaining=$(awk -F'\t' -v k="$normalized" '$2 == k { print $1; exit }' "$sorted_budget_file")
if [[ -n "$remaining" && "$remaining" -gt 0 ]]; then
echo " $raw"
# Decrement.
awk -F'\t' -v k="$normalized" 'BEGIN{OFS=FS}
$2 == k { $1 = $1 - 1 }
{ print }
' "$sorted_budget_file" > "$budget_file"
mv "$budget_file" "$sorted_budget_file"
fi
done <<< "$RAW_HITS"
}

if [[ "$NEW_COUNT" -gt 0 ]]; then
echo "=== flexframe lint: $NEW_COUNT new violation(s) ==="
echo
echo " .frame(maxWidth:) / .frame(maxHeight:) create _FlexFrameLayout, which queries"
echo " explicitAlignment on descendants and cascades O(depth × children) per layout"
echo " pass. This causes multi-second hangs in LazyVStack-backed chat hierarchies."
echo
echo " Safe alternatives (see clients/macos/AGENTS.md §§ 'No .frame(maxWidth:) ...'):"
echo " .widthCap(N) — O(1) width cap"
echo " .frame(width: N) — _FrameLayout, no alignment query"
echo " HStack { content; Spacer(minLength: 0) } — leading alignment, no FlexFrame"
echo " HStack { Spacer(minLength: 0); content } — trailing alignment, no FlexFrame"
echo " BottomAlignedMinHeightLayout — vertical fill, no FlexFrame"
echo
echo " If none of the above preserve the required semantics (e.g. single-line Text"
echo " truncation, modal-root fill-parent), add an entry to:"
echo " clients/scripts/flexframe-allowlist.txt"
echo " and explain why in your PR description."
echo
echo " New violation(s):"
print_new_violations
echo
if [[ "$STALE_COUNT" -gt 0 ]]; then
echo " Note: $STALE_COUNT allowlist entry/entries no longer match any code"
echo " (likely from prior cleanup). Run to tidy:"
echo " bash clients/scripts/check-flexframe.sh --update-baseline"
fi
exit 1
fi

OBSERVED_COUNT=$(count_nonblank "${OBSERVED_NORMALIZED:-}")
echo "flexframe lint: OK ($OBSERVED_COUNT allowlisted, 0 new)"
if [[ "$STALE_COUNT" -gt 0 ]]; then
echo " Note: $STALE_COUNT stale allowlist entry/entries — run with --update-baseline to prune."
fi
exit 0
Loading