Skip to content

lint(no-raw-private-attribute): keep private data attributes in one owner module, and enforce with a baseline - #9214

Merged
cixzhang merged 9 commits into
mainfrom
lint/no-raw-private-attribute
Nov 1, 2026
Merged

cixzhang merged 9 commits into
mainfrom
lint/no-raw-private-attribute

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer impact

A contributor who needs a private styling hook writes data-astryx-<name> by hand: as a JSX attribute, a setAttribute call, or a selector inside stylex.create. Nothing stops it, so the same private attribute ends up spelled in many files (data-astryx-press alone is written at 45 sites across 14+ files). Renaming or reshaping one means hunting every copy, and a private attribute can drift into a public export or a doc without anyone noticing. Consumers are unaffected either way; this is about keeping Astryx's own source maintainable.

Intended invariant

architecture:public-component-api INV23 (current): each private data-astryx-* attribute has one owner module in the package that writes it, packages/<package>/src/privateAttributes/<name>.stylex.ts, that writes its name and selectors once and exports ready-made styles, setter props and readers. Outside the owner modules and tests, a hand-typed data-astryx- string, template, or JSX attribute name, or a dataAttr() call that names a private attribute, fails @astryx/no-raw-private-attribute; sites that predate the rule sit in its baseline, which only shrinks.

Change

  • New rule @astryx/no-raw-private-attribute in the internal ESLint plugin. Outside owner modules it reports:

    • a string literal containing data-astryx-<name> in any position (object key, call argument, selector in stylex.create / stylex.when.*);
    • a template literal that does, including one built from the namespace constant (data-${dataAttrNamespace}-…);
    • a JSX attribute named data-astryx-<name>;
    • a dataAttr('<name>') call naming a private attribute;
    • a name it cannot read (data-astryx-${part}, dataAttr(name)) as data-astryx-*.

    Owner modules are named in camelCase (focusOwner.stylex.ts owns data-astryx-focus-owner, as architecture:interaction-modality INV3 names it). Inside an owner module, only its own attribute may be written, and the name or a selector may not leave the module as a string: not as an exported or default-exported value, inside an exported object, array or function's return value, or through a module constant one of those uses, including a selector built from that constant in a template or with +. It stays allowed as an object key (setter props, stylex.create selectors) and a call argument (writers, closest/matches readers). Importing another package's owner module is reported.

  • Public allowlist (never reported), exactly the list INV23 names: data-astryx-theme, data-astryx-media, data-astryx-aspect-ratio-override, data-astryx-token, data-astryx-token-value, data-astryx-menu-press, data-astryx-pressable, data-astryx-edge-comp. A longer name that merely starts with a public one (data-astryx-theme-prose) is private. naming.ts, which builds every name, is not checked.

  • Enforcement, the same shape as no-internal-barrel-import and no-nullish-jsx-guard: eslint.config.js turns it on for packages/*/src (tests, stories and test helpers excluded, since INV23 lets tests name private attributes) at the strict-mode severity, with {baseline, root}.

    • no-raw-private-attribute.baseline.json lists the 92 sites in 41 files that predate the rule, once per site: core 90, lab 1, richtext 1. Biggest entries: data-astryx-press 45, data-astryx-popover-fallback-close 5, data-astryx-cell-gliding 4, data-astryx-id 4.
    • A new site, or one more site of a baselined attribute in the same file, fails strict lint; main stays green.
    • no-raw-private-attribute.baseline.mjs rewrites the baseline; --check verifies it.
  • Shared baseline matching: baselineAllowance() in rule-baseline.mjs is the one per-file, counted allowance; this rule and no-nullish-jsx-guard both call it.

  • README section for the rule. The rule is registered in the plugin but not added to the shared presets, like the barrel rule.

No source is migrated here; moving each attribute into its owner module is follow-up work, and each move shrinks the baseline.

Risk

None at runtime: lint-only, no package source or published output changes. The only effect is that a pull request adding a new hand-written private attribute fails lint in CI.

CI impact

Change
Runner class unchanged (existing lint job)
Job-minutes per PR update ~0 (one syntax-only rule over files the lint job already parses)
Wall-clock ~0
Runs/day unchanged

Evidence

  • Failure before the change: a new el.setAttribute('data-astryx-foo', '') in any shipped source passes lint in every mode.
  • Success after the change: the same line fails strict lint ("data-astryx-foo is a private data attribute…"). Strict lint over packages/*/src reports no errors with the baseline, and --check passes.
  • Product/runtime behavior verified unchanged: no package source changes.

Scope

  • No intended public API, product behavior, visual direction or policy change.
  • Product changes discovered during the work were removed or split. Moving each private attribute into its owner module is follow-up work. Refreshing INV23's known-gaps note (which still says the rule does not exist) is a separate record change that needs owner approval, so it stays out of this lint-only PR.
  • Public text and artifacts contain no internal Meta context.

Testing

  • internal/eslint-plugin-astryx/no-raw-private-attribute.test.mjs (RuleTester):
    • flagged: string literal, template literal (plain and via dataAttrNamespace), JSX attribute, object key, selector inside stylex.create and stylex.when.ancestor, two attributes in one selector, data-astryx-theme-prose, dataAttr('id'), dataAttr(name), interpolated name and bare prefix, a Lab site, one site beyond the baseline count, a baseline for another file or attribute, an owner module writing another attribute or exporting its name or a selector, cross-package owner imports;
    • flagged in an owner module: the name or a selector as a default export, an exported object's value, an exported array element, an exported function's or arrow's return value, through a module constant, a selector built from that constant in a template or with +, and via dataAttr();
    • valid: every public attribute in every shape, the owner module's own selectors/keys/setters (core and richtext), owner readers and writers taking the name as a call argument (directly and through constants), a same-package owner import, naming.ts, and baselined sites.
  • Plugin suite: 33 files, 935 tests pass (including the unchanged no-nullish-jsx-guard tests on the shared allowance).
  • no-raw-private-attribute.baseline.mjs --check passes; it lints every packages/*/src file in scope with the repo config.
  • Strict eslint on Button, Theme and Lab's Schedule popover (baselined sites): 0 errors. A probe document.body.setAttribute('data-astryx-foo', '') added to Avatar fails strict lint with the rule's message, then removed.
  • The open focus-owner module (privateAttributes/focusOwner.stylex.ts) writes its own data-astryx-focus-owner without a report; a second attribute in that file (data-astryx-focus-owner-group) is reported, as one owner per attribute requires.
  • Prettier clean on every touched file.

@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 31, 2026 11:28pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026
@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 11:35 PM UTC

@github-actions

github-actions Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 3ea9137

Storybook · Sandbox · built from this PR's head

Package size: no published package changed; no new dependencies.

Not run on this PR: add ci:run-visual, ci:run-a11y or ci:run-rtl to run them.

Current run: CI concluded failure; parts of this report may be missing.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot 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.

Changes requested: the rule, its public list and the 92-site baseline match INV23, and the baseline check and plugin tests pass, but two small fixes are needed first.

  • The per-file baseline allowance in no-raw-private-attribute.js (lines 230-249) is a copy of the one in no-nullish-jsx-guard.js; move it into one shared helper that both rules call.
  • The owner-module export check misses export default 'data-astryx-press' and selectors exported inside an object, an array or a function's return value, so either report those or narrow the README and rule header to the shape it catches.

INV23's known-gaps note still says the rule does not exist yet and is worth updating in the same push.

[Automated review]

Comment on lines +230 to +249
const options = context.options[0] ?? {};
const root = options.root ?? context.cwd;
const relative = path
.relative(root, context.filename ?? context.getFilename())
.split(path.sep)
.join('/');
// Each listed attribute allows as many sites as it is listed; the next
// one is reported.
const allowance = new Map();
for (const attribute of options.baseline?.[relative] ?? []) {
allowance.set(attribute, (allowance.get(attribute) ?? 0) + 1);
}
function isAllowed(attribute) {
const left = allowance.get(attribute) ?? 0;
if (left === 0) {
return false;
}
allowance.set(attribute, left - 1);
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This block (options, repo-relative file, per-entry count map, isAllowed) is a line-for-line copy of no-nullish-jsx-guard.js lines 386-402. Could it move into one exported helper, in shared.js or beside runRuleBaseline, that both rules call, so a fix to baseline matching reaches both?

Comment on lines +285 to +300
function noteOwnerExport(node, names) {
if (ownedName == null || !names.includes(ownedName)) {
return;
}
let holder = node.parent;
while (unwrap(holder) !== holder) {
holder = holder.parent;
}
if (
holder?.type !== 'VariableDeclarator' ||
unwrap(holder.init) !== node
) {
return;
}
ownerExports.push({node, declarator: holder});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This only catches a string that is the whole initializer of an exported const. In an owner module, export default 'data-astryx-press', export const selectors = {pressed: '[data-astryx-press="on"]'}, an exported array, or an exported function returning the name all pass, while the README says the module may not export the raw name or a selector as a string. Either cover those shapes (object keys can stay allowed for setter props) with tests, or narrow the README and header wording.

@astracat-bot astracat-bot 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.

Changes requested: the rule, its public list and the 92-site baseline match INV23, both earlier points are addressed, and the plugin tests and baseline check pass. One gap remains: in an owner module, a selector built from the name constant in a template (export const PRESSED_SELECTOR = `[${ATTRIBUTE}="on"]`) is not reported, although the README and rule header say an export through a module constant is. Either let valueDestination follow a value out of a template slot (and a + operand) with RuleTester cases, or name that shape in Known limitations, the README and the INV23 gap note.

[Automated review]

* decide, or `null` when it is consumed some other way (an object key, a call
* argument, a member access).
*/
function valueDestination(node) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A value that lands in a template's ${} slot stops here as consumed, so in an owner module const ATTRIBUTE = 'data-astryx-press'; export const PRESSED_SELECTOR = `[${ATTRIBUTE}="on"]`; (or an exported function returning that template) passes with no report, while the README and header say an export through a module constant is reported. Could a TemplateLiteral expression slot and a + operand pass the value through, with RuleTester cases, or the shape go into Known limitations?

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026

@astracat-bot astracat-bot 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.

Approval recommended: the rule, its public list and the 92-site baseline match INV23, the owner-module export check now follows a value through a template slot and a + operand with tests, and the plugin tests, baseline check and lint pass. Two optional cleanups, noted inline: drop the .doc.mjs example from the rule header, since the repo config does not lint .mjs outside the CLI, and add templates that reach the namespace through anything but dataAttrNamespace or NAMESPACE to Known limitations.

[Automated review]

* Reported, outside the owner modules:
* - a string literal that contains `data-astryx-<name>`, in any position
* (an object key, a `setAttribute` argument, a selector inside
* `stylex.create` or `stylex.when.*`, a `.doc.mjs` description);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: the repo config ignores **/*.mjs outside the CLI (eslint.config.js:200), so a .doc.mjs description is never linted in practice, as the README and the INV23 gap note say. Dropping the .doc.mjs example here keeps the header in line with them.

* Public, never reported: the names INV23 lists (`PUBLIC_ATTRIBUTES`).
* `naming.ts`, which builds every `data-astryx-*` name, is not checked.
*
* Known limitations: a name assembled with `+` or read through

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: a template that reaches the namespace through anything but dataAttrNamespace or NAMESPACE (data-${naming.dataAttrNamespace}-press, an aliased import, data-${classPrefix}-press) is neither read nor reported as data-astryx-*. No shipped source does this today; naming it here would complete the list.

@astracat-bot astracat-bot 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.

Changes requested: the rule, its eight-name public list and the 92-site baseline match INV23, and the plugin tests, baseline check and strict lint pass, but the owner-module match rejects the module current spec already names. architecture:interaction-modality INV3 puts the focus-owner attribute in privateAttributes/focusOwner.stylex.ts, while the rule compares that camelCase file stem with the lowercased attribute name (no-raw-private-attribute.js:308, :326), so the module fails strict lint for its own data-astryx-focus-owner (9 errors on the version in #8869). Converting the stem to kebab case before comparing, naming owner paths in camelCase in the messages and README, and adding RuleTester cases for that path would fix it.

[Automated review]

if (NAMING_MODULE.test(filename)) {
return {};
}
const ownedName = OWNER_MODULE.exec(filename)?.[1] ?? 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.

The owned name is the file stem as written, but every attribute name is lowercased before the comparison at line 326, so a camelCase owner never matches. architecture:interaction-modality INV3 names privateAttributes/focusOwner.stylex.ts as the focus-owner owner, and that module gets foreignOwner for its own data-astryx-focus-owner (9 errors on the version in #8869). Converting the stem to kebab case here (focusOwner → focus-owner), with a RuleTester case for that path, would fix it.

@github-actions github-actions Bot removed the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026

@astracat-bot astracat-bot 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.

Approval recommended: the rule, its eight public names and the 92-site baseline match INV23, the owner-module match now accepts privateAttributes/focusOwner.stylex.ts with tests, and the plugin tests and baseline check pass. Two optional follow-ups: the description's note that #8869's focusOwner.stylex.ts lints clean holds only for its own attribute, since it still gets 5 reports for data-astryx-focus-owner-group. Comparing camelCase(name) with the file stem, instead of kebab-casing the stem, would let owner paths round-trip for digit-led names such as data-astryx-col-2 and stop accepting a kebab-case file name beside the camelCase one.

[Automated review]

// Owner modules are named in camelCase (`focusOwner.stylex.ts` owns
// `data-astryx-focus-owner`); compare in the attribute's kebab case.
const ownerStem = OWNER_MODULE.exec(filename)?.[1] ?? null;
const ownedName = ownerStem == null ? null : kebabCase(ownerStem);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: kebabCase cannot restore the hyphen camelCase drops before a digit, so for data-astryx-col-2 the message names col2.stylex.ts and a module there is then reported for its own attribute; a kebab-case focus-owner.stylex.ts is also accepted beside focusOwner.stylex.ts. Comparing camelCase(name) with ownerStem (here and in noteOwnerValue) fixes both. No attribute has a digit-led segment today.

@cixzhang
cixzhang merged commit 4abe2c0 into main Nov 1, 2026
88 of 90 checks passed
@github-actions
github-actions Bot deleted the lint/no-raw-private-attribute branch November 1, 2026 06:05

This branch was successfully deployed

1 active deployment
Preview — 3ea91377 Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant