Skip to content

feat(rules): flag a text input inside ButtonGroup - #28

Merged
TheOrcDev merged 1 commit into
mainfrom
feat/button-group-holds-only-buttons
Aug 4, 2026
Merged

feat(rules): flag a text input inside ButtonGroup#28
TheOrcDev merged 1 commit into
mainfrom
feat/button-group-holds-only-buttons

Conversation

@TheOrcDev

@TheOrcDev TheOrcDev commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Adds button-group-holds-only-buttons (advisory, ruleset 2026.07.42), implementing plan 019.

The defect

shadcn's ButtonGroup joins its children into one shape — its own variants apply rounded-l-none and border-l-0 to every child after the first. Its entire answer to focus is:

[&>*]:focus-visible:relative [&>*]:focus-visible:z-10

focus-within appears zero times in it. It lifts the focused child above its neighbours so the child's own ring paints over the adjacent border. That's correct for buttons — identical geometry, identical rings, lifting one reads as "this segment".

It breaks around a text input. The input's ring is drawn around its own box including the edge flush against the button, so the ring runs vertically down the middle of a control that reads as a single pill, with the button hanging outside it.

InputGroup is the component for that composition: its wrapper carries has-[[data-slot=input-group-control]:focus-visible]:border-b-ring and InputGroupInput carries focus-visible:ring-0, so the child delegates its ring upward and the whole shape lights up. The rule points there rather than asking anyone to patch ButtonGroup.

Two findings that changed the implementation

It must descend the subtree, not read direct children. The shape that actually ships is ButtonGroup > FormControl > Input — the control is nested. A direct-children scan misses every real instance. That's the first test in the file.

The existing anatomy machinery was the wrong tool. evaluateComponentAnatomy reads direct children only (anatomy.ts:229), and it reports any unrecognised child as a violation. InputGroup has a closed anatomy so that fits; ButtonGroup is an open container that legitimately holds buttons, selects, separators and arbitrary wrappers, and would have false-positived on FormControl alone. Hence a purpose-built walk that reports only text entry.

Scope

Reports Input, Textarea, native <input> and <textarea> inside a ButtonGroup resolved through the ui alias. Radio, checkbox, submit, reset, range, image and hidden inputs are excluded — they're buttons, not text entry, and carry no offset ring. An unreadable type is treated as text, since that's the HTML default.

Burn-in: 9 real projects, 4 hits, 0 false positives

Project Result
orcdev advisory :: kit-newsletter-form.tsx:128 ✅ true positive
github-creature advisory :: github-form.tsx:109 ✅ true positive
star-history advisory :: repo-search.tsx:84 ✅ true positive
youtubetoblog advisory :: main-form.tsx:86 ✅ true positive
8bitcn, agent-starter-react, i-f-love-shadcn, shipper-club, sponsors-shipper-club pass ✅

All four hits verified by reading the source — every one is a genuine ButtonGroup > Input + Button.

Why it ships advisory despite a clean burn-in

Plan 019 said promote to maxScore: 2 on zero false positives, and the burn-in delivered zero. I'm not promoting, because the burn-in surfaced something the plan didn't anticipate: the pattern appears in 4 of 9 projects, and all nine share one author. "Zero false positives" here measures the rule against one person's idioms, which isn't an independent enough sample to start moving everyone's score — and the defect is cosmetic, so scoring it at 2 would misprice it next to focus-visible-not-suppressed (a real a11y failure) at 3.

Promotion stays a one-line change with the trap documented: fail() at confidence: "low" is silently auto-downgraded back to advisory (audit.ts:541), so maxScore, confidence and the result helper must move together. There's a test asserting the current calibration.

Verification

11 gates green. 10 new tests — the nested FormControl shape, direct Input, native <input>, Textarea, plus silence on segmented button groups, non-text controls, InputGroup, a non-ui-alias ButtonGroup, and a project with no components.json. Self-audit 100/100 A.

Catalog regenerated to 60 rules. Two hardcoded counts needed updating — the packed smoke assertion (which plan 019 flagged) and test/public-api.test.ts:10 (which it did not; found by the test run).

Summary by CodeRabbit

  • New Features

    • Added an advisory rule that detects text inputs nested within ButtonGroup components.
    • Provides source locations and guidance to use InputGroup for text-entry controls.
    • Ignores valid non-text controls and does not affect scores.
  • Documentation

    • Updated the bundled rules catalog to version 2026.07.42, increasing the total to 60 rules.
    • Added guidance describing the new ButtonGroup rule.

ButtonGroup joins its children into one shape — its variants apply
rounded-l-none and border-l-0 to every child after the first — and its
whole answer to focus is [&>*]:focus-visible:relative plus
[&>*]:focus-visible:z-10, which lifts the focused child above its
neighbours rather than lighting the group. focus-within appears nowhere
in it. That is correct for buttons, which share a geometry and a ring.

Around a text input it reads as broken. The input's ring is drawn around
its own box, including the edge flush against the button, so the ring
runs down the middle of a control that looks like a single pill.
InputGroup is the component for that composition: its wrapper reacts to
descendant focus and InputGroupInput gives up its own ring. The rule
points there rather than asking anyone to patch ButtonGroup.

The rule descends the whole subtree instead of reading direct children,
because the shape that actually ships wraps the control in a form
primitive (ButtonGroup > FormControl > Input); a direct-children scan
misses every real instance. It reports only text entry — ButtonGroup is
an open container that legitimately holds buttons, selects, separators
and arbitrary wrappers, and radio, checkbox and submit inputs carry no
offset ring.

The existing anatomy machinery was the wrong tool: it reads direct
children only, and it reports any unrecognised child as a violation,
which an open container cannot satisfy.

Ships advisory. Burn-in across nine real shadcn projects found four
instances and no false positives, but all nine share one author, so the
sample is not independent enough to move anyone's score yet.

Ruleset 2026.07.42. Catalog regenerated to 60 rules; both hardcoded
counts updated (packed smoke and the public-API test).
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
shadscan Ready Ready Preview Aug 4, 2026 8:14pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

A new advisory rule detects text-entry controls nested within shadcn ButtonGroup components. The rule is registered in ruleset 2026.07.42, which now contains 60 rules. Tests cover positive, negative, and not-applicable cases.

ButtonGroup advisory rule

Layer / File(s) Summary
Rule detection and applicability
packages/cli/src/rules/button-group-holds-only-buttons.ts, packages/cli/src/anatomy.ts, packages/cli/test/button-group-holds-only-buttons.test.ts, plans/019-button-group-holds-only-buttons.md
The rule traverses nested ButtonGroup content, reports text-entry controls, excludes non-text controls and unrelated imports, and returns advisory, passing, or not-applicable results.
Default ruleset and catalog updates
packages/cli/src/rules/default-rules.ts, packages/cli/src/scan.ts, docs/rules.md, packages/cli/scripts/smoke-package.mjs, packages/cli/test/public-api.test.ts
The rule is added to the default ruleset. The bundled catalog changes to version 2026.07.42 with 60 rules.
Release records and verification documentation
CHANGELOG.md, plans/README.md
The changelog and plan index record the completed rule and related validation updates.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ProjectConfig
  participant SourceFiles
  participant buttonGroupHoldsOnlyButtonsRule
  participant AuditResult
  ProjectConfig->>buttonGroupHoldsOnlyButtonsRule: provide shadcn configuration
  SourceFiles->>buttonGroupHoldsOnlyButtonsRule: provide JSX ButtonGroup usages
  buttonGroupHoldsOnlyButtonsRule->>buttonGroupHoldsOnlyButtonsRule: traverse descendants and classify controls
  buttonGroupHoldsOnlyButtonsRule->>AuditResult: return audit result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a rule that flags text inputs inside ButtonGroup components.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/button-group-holds-only-buttons

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
packages/cli/test/public-api.test.ts (1)

10-10: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Name the expected catalog length.

The literal 60 is an unlabelled catalog contract. Define EXPECTED_RULE_CATALOG_LENGTH beside the suite and use it in the assertion.

Proposed change
+const EXPECTED_RULE_CATALOG_LENGTH = 60;
+
 describe("public library API", () => {
-    expect(publicApi.RULE_CATALOG).toHaveLength(60);
+    expect(publicApi.RULE_CATALOG).toHaveLength(
+      EXPECTED_RULE_CATALOG_LENGTH
+    );

As per coding guidelines, use meaningful names instead of magic numbers, extracting constants with descriptive names.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/test/public-api.test.ts` at line 10, Define an
EXPECTED_RULE_CATALOG_LENGTH constant beside the test suite in
public-api.test.ts, set it to the current catalog contract value, and replace
the literal 60 in the RULE_CATALOG length assertion with that named constant.

Source: Coding guidelines

plans/019-button-group-holds-only-buttons.md (1)

3-8: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the completed plan to match the implementation.

The plan says this rule uses evaluateComponentAnatomy and a ComponentAnatomyManifest. The shipped rule instead defines a custom findTextControl and scanFile traversal. Record this design choice and why the custom traversal was selected.

Also applies to: 130-151

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/019-button-group-holds-only-buttons.md` around lines 3 - 8, The
completed plan incorrectly describes the implementation as using
evaluateComponentAnatomy and ComponentAnatomyManifest. Update the plan to
document the shipped findTextControl and scanFile custom traversal, including
the rationale for choosing this targeted traversal instead of the shared anatomy
machinery.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/rules/button-group-holds-only-buttons.ts`:
- Around line 130-151: Update findTextControl’s visit traversal to inspect only
actual rendered JSX descendants, excluding JSX nested within opening-element
attributes such as callback expressions. Preserve detection through wrapper
elements like FormControl, and add the requested fixture verifying a ButtonGroup
with JSX inside onClick plus a Button child returns pass.

In `@plans/019-button-group-holds-only-buttons.md`:
- Line 48: Add a language info string to the opening fenced code blocks on lines
48, 56, and 69, using text or the most accurate syntax identifier for each
example, so the Markdown passes MD040.

In `@plans/README.md`:
- Line 25: Update the Plan 019 row in the plans index to include its landing
commit hash if the plan has merged; otherwise change its status from DONE to IN
PROGRESS. Preserve the existing plan link and metadata, and record the commit in
the designated landing-commit column.

---

Nitpick comments:
In `@packages/cli/test/public-api.test.ts`:
- Line 10: Define an EXPECTED_RULE_CATALOG_LENGTH constant beside the test suite
in public-api.test.ts, set it to the current catalog contract value, and replace
the literal 60 in the RULE_CATALOG length assertion with that named constant.

In `@plans/019-button-group-holds-only-buttons.md`:
- Around line 3-8: The completed plan incorrectly describes the implementation
as using evaluateComponentAnatomy and ComponentAnatomyManifest. Update the plan
to document the shipped findTextControl and scanFile custom traversal, including
the rationale for choosing this targeted traversal instead of the shared anatomy
machinery.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af5e8a01-bcab-49ba-a7b9-031b92ac69b5

📥 Commits

Reviewing files that changed from the base of the PR and between cfb0bda and 68c9702.

⛔ Files ignored due to path filters (1)
  • lib/generated/rule-catalog.json is excluded by !**/generated/**
📒 Files selected for processing (11)
  • CHANGELOG.md
  • docs/rules.md
  • packages/cli/scripts/smoke-package.mjs
  • packages/cli/src/anatomy.ts
  • packages/cli/src/rules/button-group-holds-only-buttons.ts
  • packages/cli/src/rules/default-rules.ts
  • packages/cli/src/scan.ts
  • packages/cli/test/button-group-holds-only-buttons.test.ts
  • packages/cli/test/public-api.test.ts
  • plans/019-button-group-holds-only-buttons.md
  • plans/README.md

Comment on lines +130 to +151
// Descends the whole subtree rather than reading direct children: the shape
// that actually ships wraps the control in a form primitive
// (ButtonGroup > FormControl > Input), and a direct-children scan misses it.
const visit = (node: Node): void => {
if (hit) {
return;
}

const element = getOpeningElement(node);

if (element && isTextControl(element, controls)) {
hit = {
label: `<${getJsxTagName(element)}>`,
line: getLineNumber(file, element),
};
return;
}

forEachChild(node, visit);
};

forEachChild(root, visit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
fd -a 'button-group-holds-only-buttons\.ts$' . || true

echo "== git status/stat =="
git status --short || true

echo "== file outline =="
file="$(fd 'button-group-holds-only-buttons\.ts$' . | head -n 1 || true)"
if [ -n "${file:-}" ]; then
  wc -l "$file"
  ast-grep outline "$file" || true
  echo "== relevant section =="
  sed -n '1,220p' "$file" | nl -ba
fi

echo "== imports/functions names in repo =="
rg -n "function forEachChild|declare .*forEachChild|forEachChild\(" . --glob '*.ts' --glob '*.tsx' | head -n 80

Repository: TheOrcDev/shadscan

Length of output: 1097


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Behavioral probe: inspect local tooling declarations and run a static AST read if TypeScript package is present locally.
echo "== local TS package availability =="
if [ -d node_modules/typescript ]; then
  node - <<'JS'
const ts = require('typescript');
const version = require('typescript/package.json').version;
console.log('typescript version', version);

const source = `
function ButtonGroup() {}
function Input() {}
function setPreview(x) {}
function setFallback() {}
<ButtonGroup onClick={() => setPreview(<Input />)}><Button /></ButtonGroup>
`;
const sf = ts.createSourceFile('probe.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);

console.log('root kind', ts.SyntaxKind[sf.kind]);
console.log('root statements count', sf.statements.length);

function visit(node, pad='') {
  const tag = ts.SyntaxKind[node.kind] + (node.pos === node.end ? '' : ` [${node.pos}..${node.end}]`);
  console.log(pad + tag);
  if (node.jsxElem && !ts.isNode(node)) throw new Error('non-node');
  if (node.kind === ts.SyntaxKind.JsxOpeningElement || node.kind === ts.SyntaxKind.JsxAttributes) {
    for (const child of node.getChildren(sf)) {
      visit(child, pad + '  ');
    }
  } else if (node.children && node.children.length) {
    for (const child of node.children) {
      if (!ts.isNode(child)) {
        console.log(pad + '    TEXT token', child.kind, typeof child.text);
        continue;
      }
      visit(child, pad + '  ');
    }
  }
}
const btn = sf.statements[3];
console.log('ButtonGroup element kind', ts.SyntaxKind[btn.kind]);
console.log('ButtonGroup attributes children:');
const attrs = btn.getElementsByTagName()[0].getTypeArgument ? null : btn.getTypeArgument; // not AST; fallback manual
console.log('has openingElement', !!btn.openingElement);
const el = btn.getElement().jsxElem || btn.openingElement || btn;
console.log('tag name', el.tagName && el.tagName.getText(sf));
for (const child of btn.getChildren(sf)) {
  console.log('child kind', ts.SyntaxKind[child.kind]);
}
const open = btn.openingElement;
console.log('opening element kind', open && ts.SyntaxKind[open.kind]);
for (const child of open.getChildren(sf)) {
  console.log('opening child kind', ts.SyntaxKind[child.kind], child.getText && child.getText(sf));
}
JS
else
  echo "no local node_modules/typescript"
fi

Repository: TheOrcDev/shadscan

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="./packages/cli/src/rules/button-group-holds-only-buttons.ts"

echo "== relevant source =="
tmp="$(mktemp)"
cat "$file" | awk '{printf "%5d  %s\n", NR, $0}' > "$tmp"
sed -n '1,244p' "$tmp"

echo "== TypeScript compiler package availability =="
node - <<'JS'
try {
  const typescript = require('typescript');
  console.log('available', typescript.version);
} catch (e) {
  console.log('missing', e.code || e.message);
}
JS

echo "== AST behavior probe if TypeScript is available =="
node - <<'JS'
try {
  const ts = require('typescript');
  const source = '<ButtonGroup onClick={() => setPreview(<Input />)}><Button /></ButtonGroup>';
  const sf = ts.createSourceFile('probe.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
  const btn = sf.statements[0];

  console.log('root statement kind', ts.SyntaxKind[btn.kind]);
  console.log('openingElement kind', ts.SyntaxKind[btn.openingElement.kind]);

  let forEachChildren = 0;
  let openingChildren = [];
  const visit = (node) => {
    forEachChildren++;
    if (node.kind === ts.SyntaxKind.JsxOpeningElement) {
      const tokens = [];
      btn.openingElement.forEachChild((child) => {
        tokens.push(ts.SyntaxKind[child.kind]);
      });
      openingChildren = tokens;
    }
    btn.openingElement.forEachChild(visit);
  };
  visit(btn.openingElement);
  console.log('openingChildCount', openingChildren.length);
  console.log('openingChildren', openingChildren.join(', '));
  console.log('jsxExpressionInsideProp count', btn.openingElement.forEachChild((child) => {
    return child.kind === ts.SyntaxKind.JsxExpression;
  }).filter(Boolean).length === undefined ? 'collector not returned' : 'collector used');

  // Collect JSX expressions attached to opening element attributes under a simple visitor pattern.
  const textControlNodes = [];
  const foundJsxExpr = [];
  ts.forEachChild(btn.openingElement, (child) => {
    if (child.kind === ts.SyntaxKind.JsxExpression) {
      foundJsxExpr.push(true);
    }
  });
  console.log('openingForEachChild finds jsx expressions', foundJsxExpr.length > 0);
} catch (e) {
  console.log('probe skipped', e.message);
}
JS

Repository: TheOrcDev/shadscan

Length of output: 8980


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
const ts = require('typescript');
const source = '<ButtonGroup onClick={() => setPreview(<Input />)}><Button /></ButtonGroup>';
const sf = ts.createSourceFile('probe.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const btn = sf.statements[0];

console.log('source files', sf.statements.length);
console.log('ButtonGroup open kind', ts.SyntaxKind[btn.openingElement.kind]);

const openingTokens = [];
btn.openingElement.forEachChild((child) => {
  openingTokens.push({
    kind: ts.SyntaxKind[child.kind],
    text: child.getText && child.getText(sf),
  });
});
console.log('opening foreach count', openingTokens.length);

const exprCount = openingTokens.filter((t) => t.kind === 'JsxExpression').length;
const exprContent = openingTokens
  .filter((t) => t.kind === 'JsxExpression')
  .map((t) => t.text)
  .join(',');
console.log('opening foreach jsx expression count', exprCount);
console.log('opening foreach jsx expression content', exprContent);

const jsxElementNodes = [];
function findJsxElements(node) {
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
    jsxElementNodes.push(ts.SyntaxKind[node.tagName.kind] + ':' + node.tagName.getText(sf));
  }
  if (ts.isElementAccessExpression(node)) {
    for (const child of node.getChildren(sf)) {
      findJsxElements(child);
    }
  } else if (node.getChildren) {
    for (const child of node.getChildren(sf)) {
      findJsxElements(child);
    }
  }
}
findJsxElements(btn.openingElement);
console.log('opening element find JSX descendants', jsxElementNodes.join(','));
JS

Repository: TheOrcDev/shadscan

Length of output: 911


🌐 Web query:

TypeScript forEachChild JsxOpeningLikeElement attributes children documentation

💡 Result:

In the TypeScript Compiler API, JsxOpeningLikeElement is a union type comprising JsxOpeningElement and JsxSelfClosingElement [1][2]. These elements represent the opening tag of a JSX element (e.g., <div...>) [1]. Attributes The attributes of a JsxOpeningLikeElement are accessed via the.attributes property, which returns an instance of JsxAttributes [1][2]. JsxAttributes itself extends ObjectLiteralExpressionBase and provides a.properties collection, which contains the individual attributes [1][3]. Each property in this collection is typically a JsxAttribute or a JsxSpreadAttribute [1][2]. You can traverse or manipulate these attributes by accessing the.properties array [3][4]. Children It is important to note that a JsxOpeningLikeElement does not directly contain a children property because it represents only the opening tag [1][2]. The children of a JSX expression belong to the parent JsxElement (or JsxFragment), which contains the opening element, the closing element, and an array of children [1][2]. To access the children associated with a JSX expression: 1. Locate the parent JsxElement or JsxFragment [1]. 2. Access the.children property on that parent node, which is a NodeArray of JsxChild nodes [1][2]. Using forEachChild The ts.forEachChild function is a utility that allows you to visit the child nodes of any AST node [5]. When you call forEachChild on a JsxOpeningLikeElement, it will visit its immediate AST children, such as its tagName and its JsxAttributes node [6][7]. It will not automatically traverse into the "children" of the JSX element (the content between the tags), as those are structurally separate from the opening element in the TypeScript AST [1]. If you need to retrieve all nodes in the AST hierarchy, the.getChildren method can be used, though forEachChild is generally preferred for efficient traversal during tree processing [5].

Citations:


🌐 Web query:

TypeScript compiler API forEachChild JsxOpeningElement JsxAttributes JsxExpression properties

💡 Result:

In the TypeScript Compiler API, traversing the AST is typically performed using the ts.forEachChild(node, callback) function [1][2]. When applied to a JsxOpeningElement, this function recursively visits the child nodes of the element [1][2]. JsxOpeningElement Structure The JsxOpeningElement interface represents the opening tag of a JSX element (e.g., <div...>) [3][4]. Its key properties include: tagName: A JsxTagNameExpression representing the name of the tag [4]. typeArguments: An optional NodeArray of type arguments [4]. attributes: A JsxAttributes object [4]. JsxAttributes and JsxExpression The attributes property of a JsxOpeningElement is of type JsxAttributes [4]. JsxAttributes acts as a container for the attributes defined within the opening tag [4]. JsxAttributes holds a properties array (inherited from ObjectLiteralExpressionBase), which contains instances of JsxAttribute or JsxSpreadAttribute [5][4]. If an attribute's value is a dynamic expression (e.g., prop={value}), that value is represented as a JsxExpression node [5][3]. Traversing Children When you use ts.forEachChild on a JsxOpeningElement, it iterates through its direct children, which typically include: The tagName [4]. The typeArguments (if present) [4]. The attributes (as a single JsxAttributes node) [4]. To inspect the individual attributes or expressions within the JsxAttributes node, you must either: 1. Continue the recursion into the JsxAttributes node using ts.forEachChild [1]. 2. Directly access the properties array on the attributes node (node.attributes.properties) to process JsxAttribute, JsxSpreadAttribute, or JsxExpression nodes specifically [5][4]. For example, when transforming or linting, you would typically check if a node is a JsxOpeningElement, then access its attributes.properties to handle individual attributes and their potential JsxExpression values [5].

Citations:


🌐 Web query:

TypeScript for...of AST node array children forEachChild jsx opening element attributes

💡 Result:

In the TypeScript Compiler API, a JsxOpeningElement node contains an attributes property, which holds a NodeArray of JSX attributes [1][2]. To access and iterate over these attributes, you should interact with the attributes property directly rather than relying solely on forEachChild [3][4]. Key points for working with JSX attributes and forEachChild: 1. Direct Access: The JsxOpeningElement interface includes a property named attributes [1][2]. This property is an instance of NodeArray (specifically, JsxAttributes), which contains the list of JSXAttribute or JsxSpreadAttribute nodes [5][4]. You can access this array directly (e.g., node.attributes) to iterate, filter, or modify the attributes [5][4]. 2. forEachChild Limitations: While ts.forEachChild is a standard utility for traversing an AST, it is designed to visit child nodes that are part of the structural tree [6][7]. In many cases, it may not automatically visit or yield specific NodeArray collections like the attributes list of a JSX element in the way you might expect for custom traversal logic [7][8]. If you need to perform operations on the attributes, it is best practice to access the attributes property explicitly [4]. 3. Example Usage: // Accessing attributes directly from a JsxOpeningElement node if (node.kind === ts.SyntaxKind.JsxOpeningElement || node.kind === ts.SyntaxKind.JsxSelfClosingElement) { const openingElement = node as ts.JsxOpeningLikeElement; openingElement.attributes.forEach((attr) => { // Logic for processing each JSXAttribute or JsxSpreadAttribute console.log(attr); }); } If you require a more comprehensive tree traversal that includes all nodes (including tokens and specific arrays), the.getChildren method on a Node can be used to retrieve all children, though it is more verbose and includes tokens that forEachChild often skips [7][8].

Citations:


Skip JSX inside ButtonGroup attribute expressions.

findTextControl() visits opening elements and uses forEachChild(), so dynamic JSX inside props such as onClick={() => setPreview(<Input />)} is treated as a descendant of ButtonGroup. Restrict the scan to actual rendered JSX children/attribute expressions and add a fixture where <ButtonGroup onClick={() => setPreview(<Input />)}><Button /></ButtonGroup> returns pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/rules/button-group-holds-only-buttons.ts` around lines 130 -
151, Update findTextControl’s visit traversal to inspect only actual rendered
JSX descendants, excluding JSX nested within opening-element attributes such as
callback expressions. Preserve detection through wrapper elements like
FormControl, and add the requested fixture verifying a ButtonGroup with JSX
inside onClick plus a Button child returns pass.

`-ml-px`, no `overflow-hidden`. The call site is clean. All the joining
happens inside `ButtonGroup` itself:

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Declare languages for the fenced examples.

Lines 48, 56, and 69 have opening fences without an info string. markdownlint-cli2 reports MD040 for each. Add text or an accurate syntax identifier.

Also applies to: 56-56, 69-69

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 48-48: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/019-button-group-holds-only-buttons.md` at line 48, Add a language info
string to the opening fenced code blocks on lines 48, 56, and 69, using text or
the most accurate syntax identifier for each example, so the Markdown passes
MD040.

Source: Linters/SAST tools

Comment thread plans/README.md
| [017](017-stats-page.md) | /stats page: npm usage charted with Bklit | P2 | M | - | DONE (`e2e0c51`) |
| [018](018-mcp-server.md) | MCP server: typed audit tools for coding agents | P1 | L | - | PLANNED |
| [018](018-mcp-server.md) | MCP server: typed audit tools for coding agents | P1 | L | - | DONE (`bb95ec7`) |
| [019](019-button-group-holds-only-buttons.md) | ButtonGroup holds buttons, not text inputs | P2 | S | - | DONE |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add the landing commit for Plan 019.

Line 5 says every completed plan records its landing commit. Line 25 marks Plan 019 DONE without one. If no landing commit exists yet, keep the status IN PROGRESS and update this row after merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/README.md` at line 25, Update the Plan 019 row in the plans index to
include its landing commit hash if the plan has merged; otherwise change its
status from DONE to IN PROGRESS. Preserve the existing plan link and metadata,
and record the commit in the designated landing-commit column.

@TheOrcDev
TheOrcDev merged commit 3fec902 into main Aug 4, 2026
8 checks passed
@TheOrcDev
TheOrcDev deleted the feat/button-group-holds-only-buttons branch August 4, 2026 20:25
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