Skip to content

ci(macos): lint rule for .frame(maxWidth:) / .frame(maxHeight:) regressions#27554

Merged
ashleeradka merged 1 commit into
mainfrom
do/flexframe-lint
Apr 22, 2026
Merged

ci(macos): lint rule for .frame(maxWidth:) / .frame(maxHeight:) regressions#27554
ashleeradka merged 1 commit into
mainfrom
do/flexframe-lint

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

@ashleeradka ashleeradka commented Apr 22, 2026

Summary

  • Adds a CI lint (clients/scripts/check-flexframe.sh) that fails when a new .frame(maxWidth:) or .frame(maxHeight:) is introduced inside clients/macos/vellum-assistant/Features/Chat/ or Features/MainWindow/ (including Sidebar/ and Panels/ subdirectories).
  • Runs as a fast ubuntu-latest job on every PR touching clients/macos/** (no preview label gate — feedback in under a minute) and on main-branch CI.
  • Ships with a content-hashed allowlist of the 170 existing occurrences so the check passes on current main without modifying any Swift source. Links to LUM-1116.

Why this rule exists

.frame(maxWidth:) and .frame(maxHeight:) (any value, including bounded like maxWidth: 360) create _FlexFrameLayout, whose placement() queries each child's explicitAlignment via ViewDimensions.subscript. Nested FlexFrames recurse O(depth × children) per layout pass — in LazyVStack-backed chat hierarchies that's an O(n) motionVectors cascade over every row on every layout. The same cascade hits non-lazy hierarchies under transitions (see VELLUM-ASSISTANT-MACOS-66 / VELLUM-ASSISTANT-MACOS-KE).

This has been fixed manually 9+ times:

The rule is documented in clients/macos/AGENTS.md:277-286 but has been enforced only by human vigilance. LUM-1116 Phase 3 tracks converting the rule to a mechanical check — this PR.

How the lint works

  1. Detection: rg -n '\.frame\(\s*max(Width|Height)\s*:' across Features/Chat/ + Features/MainWindow/. Comment-only lines (// and ///) are filtered so prose like // ⚠️ No .frame(maxWidth:) in LazyVStack cells doesn't false-positive.
  2. Normalization: Each hit is reduced to <path>|<trimmed-line-content> — line numbers are intentionally NOT part of the key so entries survive unrelated line drift above them.
  3. Diff against allowlist: comm -23 (multiset-preserving) yields new occurrences vs. clients/scripts/flexframe-allowlist.txt.
  4. Error output: On failure, prints file:line:content for each new violation, a summary of safe alternatives, a pointer to AGENTS.md, and instructions for allowlisting if unavoidable.

Safe alternatives (also shown in the error message)

Scope (intentionally conservative)

Only Features/Chat/ and Features/MainWindow/ are linted. These are the directories where the cascade has caused user-visible hangs. A follow-up PR can widen scope to additional feature modules once this proves its value.

The lint does NOT fire on .frame(maxWidth: .infinity) with no trailing arguments at the source level — actually it does, but bounded maxWidth: / maxHeight: is where the wrong-usage pattern is mechanically clearest. We deliberately do NOT try to lint forms like .padding(.horizontal).background(Color.clear.frame(maxWidth: .infinity)) because distinguishing leaf-vs-subtree usage via regex is unreliable; the 170-entry allowlist already documents the legitimate leaf cases.

Adding to the allowlist

If a safe alternative genuinely can't preserve the required semantics (e.g. single-line Text truncation where HStack+Spacer breaks .lineLimit(1).truncationMode(.tail) — see #26220's QueuedMessageRow.swift), add the <path>|<trimmed-line> entry to clients/scripts/flexframe-allowlist.txt AND explain why in the PR description. The file's top-of-file comment documents the typical "leaf Text / Image / VIconView" rationale. Bulk regeneration after an intentional refactor: bash clients/scripts/check-flexframe.sh --update-baseline.

Files changed

  • clients/scripts/check-flexframe.sh (new) — lint script, bash 3.2 compatible, shellcheck clean
  • clients/scripts/flexframe-allowlist.txt (new) — 170 seed entries with documenting header
  • .github/workflows/pr-macos.yaml — adds flexframe-lint job (ubuntu-latest, unconditional, ~30s)
  • .github/workflows/ci-main-macos.yaml — adds flexframe-lint job to main-branch CI

No Swift source files are modified.

Verification

  • Ran bash clients/scripts/check-flexframe.sh locally against the current worktree: flexframe lint: OK (170 allowlisted, 0 new) → exit 0.
  • Test case: temporarily added .frame(maxWidth: 100) to a Chat/ view, confirmed the lint reported the exact file:line:content and exited 1, then reverted.
  • Line-drift test: shifted an allowlisted line down by 2 lines via prepended content — allowlist still matched, no new violation reported.
  • shellcheck clean; YAML validates.

Original prompt

Click to expand

Add a CI lint check that fails on new .frame(maxWidth:) / .frame(maxHeight:) usages inside clients/macos/vellum-assistant/Features/Chat/ and clients/macos/vellum-assistant/Features/MainWindow/ (including subdirectories — Sidebar/, Panels/).

Rationale (details for the PR description, not for the implementation):

Start with a conservative scope (Chat/ + MainWindow/); use an allowlist for existing intentional violations; the lint must pass on current main; do not modify Swift source; PR title: ci(macos): lint rule for .frame(maxWidth:) / .frame(maxHeight:) regressions; link LUM-1116.


Open in Devin Review

…ssions

Adds a fast ripgrep-based guard that fails CI when a new `.frame(maxWidth:)`
or `.frame(maxHeight:)` is introduced inside `Features/Chat/` or
`Features/MainWindow/`. These modifiers create `_FlexFrameLayout`, which
cascades `explicitAlignment` queries through descendants and has caused
multi-second hangs in LazyVStack-backed chat surfaces 9+ times
(PRs #24019, #24091, #24584, #24589, #25844, #25947, #26007, #26053,
#26092, #26220). The manual audit process missed regressions twice — this
lint enforces the AGENTS.md:277-286 rule mechanically. Tracked in LUM-1116.

Content-hash allowlist (`clients/scripts/flexframe-allowlist.txt`) seeded
with the 170 existing occurrences so the check passes on current main.
Entries are keyed on `<path>|<trimmed-line>` so unrelated line drift
doesn't break them.
@ashleeradka ashleeradka merged commit 9d51a2e into main Apr 22, 2026
7 checks passed
@ashleeradka ashleeradka deleted the do/flexframe-lint branch April 22, 2026 20:33
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f9fca266d1

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

# (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.

# 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

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

# 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
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.

Comment on lines +44 to +63
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
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.

ashleeradka added a commit that referenced this pull request Apr 22, 2026
#27556)

Addresses two Codex review findings on #27554:

P1: Main scan now uses `rg -U --multiline-dotall` so `.frame(` wrapped
across lines (opening paren on one line, `maxWidth:` on the next) is
caught. The prior single-line regex missed a real instance at
ChatLoadingSkeleton.swift:58-61 — now allowlisted.

P2: `grep -v` against the allowlist is now guarded with `|| true` so
a header-only allowlist (possible after a bulk cleanup or fresh
`--update-baseline` with zero violations) doesn't abort the script
under `set -euo pipefail`.

Tests:
- dry-run passes (172 allowlisted, 0 new)
- injected multi-line violation caught and exits 1
- injected single-line violation still caught (P1 regression check)
- empty allowlist + no violations = exit 0 (P2 new path)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ashleeradka added a commit that referenced this pull request Apr 22, 2026
Addresses Devin review finding on #27554: the notify-macos job's
status computation only checked needs.test.result and
needs.build.result — if flexframe-lint fails on main, the Slack
notification would still report success. Add flexframe-lint to
needs[] and to all three status branches (failure / cancelled /
skipped).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant