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
36 changes: 36 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import reactCompiler from 'eslint-plugin-react-compiler';
import astryxPlugin from './internal/eslint-plugin-astryx/index.js';
import {scope as internalBarrelScope} from './internal/eslint-plugin-astryx/no-internal-barrel-import.js';
import {scope as nullishGuardScope} from './internal/eslint-plugin-astryx/no-nullish-jsx-guard.js';
import {scope as privateAttributeScope} from './internal/eslint-plugin-astryx/no-raw-private-attribute.js';

/* global process */

Expand Down Expand Up @@ -59,6 +60,19 @@ const nullishGuardBaseline = JSON.parse(
),
);

// Private `data-astryx-*` attributes written by hand before
// `@astryx/no-raw-private-attribute` arrived, by repo-relative file, once per
// site. The list only shrinks.
const privateAttributeBaseline = JSON.parse(
readFileSync(
path.join(
import.meta.dirname,
'internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json',
),
'utf8',
),
);

// The internal plugin is plain untyped JS (kept out of the lint/type surface),
// so its inferred shape doesn't satisfy ESLint's strict `Plugin` type. One
// localized assertion here keeps every `plugins` entry below type-checked.
Expand Down Expand Up @@ -916,6 +930,28 @@ export default defineConfig(
],
},
},
// A private `data-astryx-*` attribute is written once, in its owner module
// under packages/<package>/src/privateAttributes, which exports ready-made
// styles, setter props and readers (`architecture:public-component-api`
// INV23, which also lists the public attributes the rule allows). The
// hand-written sites that predate this block are listed in the baseline,
// once per site, and that many stay allowed until they move into an owner
// module; one more is reported (an error in strict mode, so a pull request
// that adds one fails lint, while main stays green). Tests are out of scope:
// they assert the DOM a component renders.
{
files: privateAttributeScope.files,
ignores: privateAttributeScope.ignores,
plugins: {
'@astryx': astryxEslintPlugin,
},
rules: {
'@astryx/no-raw-private-attribute': [
reactSeverity,
{baseline: privateAttributeBaseline, root: import.meta.dirname},
],
},
},
// richtext — the pre-existing backlog, held at `warn` while it is worked
// off. Must come last so it overrides the blocks above.
//
Expand Down
52 changes: 52 additions & 0 deletions internal/eslint-plugin-astryx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,58 @@ removes them; remove an entry when you fix its import, and never add one.
rewrites the list from the source (`--check` verifies it), for example after
merging `main` into this rule's own branch or after a codemod.

### `@astryx/no-raw-private-attribute`

Reports a private `data-astryx-*` attribute written by hand outside its owner
module (`architecture:public-component-api` INV23). Each private attribute has
one owner module in the package that writes it,
`packages/<package>/src/privateAttributes/<name>.stylex.ts`, named in camelCase
(`focusOwner.stylex.ts` owns `data-astryx-focus-owner`), that writes its
name and selectors once and exports ready-made styles, setter props and the
runtime readers its package needs; every other file spreads those exports.

```ts
// flagged outside the owner module
el.setAttribute('data-astryx-popover-fallback-close', '');
const styles = stylex.create({
base: {transform: {default: null, '[data-astryx-press="on"]': 'scale(0.98)'}},
});
<div data-astryx-submenu-host="" />;
style.setAttribute(dataAttr('theme-prose'), name);

// fine: public attributes INV23 lists
<div data-astryx-theme="dark" />;
const scope = `[data-astryx-media="${surface}"]`;
el.setAttribute(dataAttr('theme'), name);
```

It checks string literals in any position (object keys, call arguments,
selectors inside `stylex.create` and `stylex.when.*`),
template literals (including `data-${dataAttrNamespace}-…`), JSX attribute
names, and `dataAttr()` calls. A name it cannot read (`data-astryx-${part}`,
`dataAttr(name)`) is reported as `data-astryx-*`. Inside an owner module only
its own attribute may be written, and the module may not export the raw name or
a selector as a string: not as an exported or default-exported value, not inside
an exported object, array or function's return value, and not through a module
constant one of those uses, including a selector built from that constant in a
template or with `+`. The name stays allowed there as an object key
(setter props and `stylex.create` selectors) and as a call argument (a writer,
or a `closest`/`matches` reader). An import of another package's owner module is
reported. The public attributes INV23 lists are never reported: `theme`,
`media`, `aspect-ratio-override`, `token`, `token-value`, `menu-press`,
`pressable` and `edge-comp`, each after `data-astryx-`. `naming.ts`, which
builds every name, is not checked.

The repo config turns it on for `packages/*/src`, outside tests, stories and
test helpers, at the strict-mode severity (an error in CI). The repo config does
not lint `.mjs` outside the CLI, so `.doc.mjs` text stays with public-API
review. Sites that existed when the rule arrived are listed by file in
`no-raw-private-attribute.baseline.json`, once per site, and that many stay
allowed until they move into an owner module; remove an entry when you move its
site, and never add one.
`node internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.mjs`
rewrites the list from the source (`--check` verifies it).

### `@astryx/no-core-internal-import`

Reports imports of Core's internal subpaths (`@astryxdesign/core/internal` and
Expand Down
3 changes: 3 additions & 0 deletions internal/eslint-plugin-astryx/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* - no-light-dark-outside-theme: Flags CSS light-dark() in component source (a light/dark decision belongs to the theme layer, where a token pair reaches both schemes in every theme)
* - no-core-internal-import: Keeps Core's internal subpaths (@astryxdesign/core/internal/*) inside Core and Lab
* - no-internal-barrel-import: Flags a package-internal relative import that resolves to a folder's index (a barrel) instead of the defining module
* - no-raw-private-attribute: Flags a private data-astryx-* attribute written by hand outside its owner module in packages/<package>/src/privateAttributes (INV23 lists the public ones)
* - no-raw-color: Flags a raw colour value (hex, rgb(), hsl(), oklch(), …) anywhere in component source, including inside light-dark()/color-mix(), behind a const, in a template literal, or as a var() fallback — the shapes no-hardcoded-styles cannot see
*
* Philosophy: Strict for agents (CI), lenient for humans (local dev)
Expand Down Expand Up @@ -64,6 +65,7 @@ import i18nKeyFormatRule from './i18n-key-format.js';
import requireTableSectionRule from './require-table-section.js';
import noLightDarkOutsideThemeRule from './no-light-dark-outside-theme.js';
import noRawColorRule from './no-raw-color.js';
import noRawPrivateAttributeRule from './no-raw-private-attribute.js';

// =============================================================================
// Rule: no-hardcoded-styles
Expand Down Expand Up @@ -368,6 +370,7 @@ const plugin = {
'require-table-section': requireTableSectionRule,
'no-light-dark-outside-theme': noLightDarkOutsideThemeRule,
'no-raw-color': noRawColorRule,
'no-raw-private-attribute': noRawPrivateAttributeRule,
},
configs: {},
};
Expand Down
18 changes: 2 additions & 16 deletions internal/eslint-plugin-astryx/no-nullish-jsx-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
* are not flagged — the guarded branch is not a JSX element.
*/

import path from 'node:path';
import {baselineAllowance} from './rule-baseline.mjs';

/**
* Where the repository config enforces the rule with its baseline; the
Expand Down Expand Up @@ -383,23 +383,9 @@ const rule = {
return {};
}
const sourceCode = context.sourceCode ?? context.getSourceCode();
const options = context.options[0] ?? {};
const root = options.root ?? context.cwd;
const relative = path.relative(root, filename).split(path.sep).join('/');
// Each listed value allows as many checks as it is listed; the next one
// is reported.
const allowance = new Map();
for (const value of options.baseline?.[relative] ?? []) {
allowance.set(value, (allowance.get(value) ?? 0) + 1);
}
function isAllowed(valueText) {
const left = allowance.get(valueText) ?? 0;
if (left === 0) {
return false;
}
allowance.set(valueText, left - 1);
return true;
}
const isAllowed = baselineAllowance(context);

function reportHandRolled(chain, value) {
const valueText = sourceCode.getText(value);
Expand Down
160 changes: 160 additions & 0 deletions internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
{
"packages/core/src/Button/Button.tsx": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/Chat/ChatComposerInput.tsx": [
"data-astryx-dictation-interim"
],
"packages/core/src/Chat/ChatLayout.tsx": ["data-astryx-scrolling"],
"packages/core/src/Chat/useChatComposerTokens.ts": ["data-astryx-token-id"],
"packages/core/src/Chat/useChatDictation.ts": [
"data-astryx-dictation-interim"
],
"packages/core/src/Chat/useChatStreamScroll.ts": [
"data-astryx-chat-following",
"data-astryx-chat-following-style"
],
"packages/core/src/Chat/useTriggerMenu.tsx": ["data-astryx-trigger-anchor"],
"packages/core/src/CheckboxInput/CheckboxInput.tsx": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/ClickableCard/ClickableCard.tsx": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/CodeBlock/highlightRanges.ts": [
"data-astryx-highlight-dynamic"
],
"packages/core/src/CodeBlock/highlightStyles.ts": [
"data-astryx-highlight-styles"
],
"packages/core/src/ComplexSelector/ComplexSelector.tsx": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/DateInput/nativeDateSegments.ts": [
"data-astryx-date-probe"
],
"packages/core/src/DropdownMenu/DropdownMenu.tsx": [
"data-astryx-submenu-host",
"data-astryx-submenu-host",
"data-astryx-submenu-regions"
],
"packages/core/src/DropdownMenu/DropdownMenuSubMenu.tsx": [
"data-astryx-submenu-regions",
"data-astryx-submenu-regions"
],
"packages/core/src/InputGroup/groupStyles.ts": [
"data-astryx-input-group-item",
"data-astryx-input-group-item"
],
"packages/core/src/Layer/layerSlideRules.ts": [
"data-astryx-clears-anchor",
"data-astryx-layer-scrolls"
],
"packages/core/src/MultiSelector/MultiSelector.tsx": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/NavItem/navItemStyles.stylex.ts": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/Popover/Popover.tsx": [
"data-astryx-popover-fallback-close",
"data-astryx-popover-fallback-close"
],
"packages/core/src/Popover/usePopover.tsx": [
"data-astryx-popover-fallback-close",
"data-astryx-popover-fallback-close"
],
"packages/core/src/RadioList/RadioListItem.tsx": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/SelectableCard/SelectableCard.tsx": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/Selector/Selector.tsx": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/Switch/Switch.tsx": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/TabList/Tab.tsx": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/Table/glidingDivider.ts": [
"data-astryx-cell-gliding",
"data-astryx-cell-gliding",
"data-astryx-cell-gliding",
"data-astryx-row-gliding",
"data-astryx-row-gliding",
"data-astryx-table-pinned"
],
"packages/core/src/Table/plugins/stickyColumns/useTableStickyColumns.tsx": [
"data-astryx-cell-gliding",
"data-astryx-table-pinned",
"data-astryx-table-pinned"
],
"packages/core/src/Thumbnail/Thumbnail.tsx": [
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/Toast/useToast.tsx": ["data-astryx-toast-fallback"],
"packages/core/src/TopNav/TopNavItem.tsx": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/gestures/haptics.ts": ["data-astryx-haptic-switch"],
"packages/core/src/gestures/touchActionRule.ts": ["data-astryx-touch-action"],
"packages/core/src/hooks/useAnnounce.ts": [
"data-astryx-live-region",
"data-astryx-modal-live-region"
],
"packages/core/src/hooks/useMenuPress.ts": ["data-astryx-menu-press-surface"],
"packages/core/src/theme/Theme.tsx": [
"data-astryx-id",
"data-astryx-id",
"data-astryx-id",
"data-astryx-id",
"data-astryx-theme-prose",
"data-astryx-theme-prose"
],
"packages/core/src/theme/syntax/SyntaxTheme.tsx": [
"data-astryx-syntax-theme"
],
"packages/core/src/utils/interactionOverlay.stylex.ts": [
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press",
"data-astryx-press"
],
"packages/core/src/utils/pressFeedback.ts": ["data-astryx-press"],
"packages/lab/src/Schedule/useScheduleViewPopover.ts": [
"data-astryx-popover-fallback-close"
],
"packages/richtext/src/CodeSyntaxPlugin.tsx": [
"data-astryx-richtext-code-syntax"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file no-raw-private-attribute.baseline.mjs
* @description Writes (or, with --check, verifies) the baseline that
* `@astryx/no-raw-private-attribute` allows: each private `data-astryx-*`
* attribute, per file, once per site that writes it by hand today. Run it
* after moving sites into an owner module under
* `packages/core/src/privateAttributes`, or after merging main into a branch
* that changes the rule, so the list only names sites that still exist.
*
* node internal/eslint-plugin-astryx/no-raw-private-attribute.baseline.mjs [--check]
*/

import fs from 'node:fs';
import path from 'node:path';
import {scope} from './no-raw-private-attribute.js';
import {runRuleBaseline} from './rule-baseline.mjs';

const root = path.resolve(import.meta.dirname, '../..');

await runRuleBaseline({
ruleId: '@astryx/no-raw-private-attribute',
scope,
targets: fs
.readdirSync(path.join(root, 'packages'))
.filter(name => fs.existsSync(path.join(root, 'packages', name, 'src')))
.map(name => `packages/${name}/src`),
baselineFile: path.join(
import.meta.dirname,
'no-raw-private-attribute.baseline.json',
),
// Owner-module reports (an exported name) start differently and are never
// baselined.
entryFromMessage: message =>
/^`(data-astryx-[^`]*)`/.exec(message)?.[1] ?? null,
unique: false,
noun: 'private-attribute sites',
});
Loading
Loading