@camunda/design-system 0.63.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/src/components/ui/data-table.d.ts +17 -2
  2. package/dist/src/components/ui/data-table.d.ts.map +1 -1
  3. package/dist/src/components/ui/data-table.js +16 -7
  4. package/dist/src/components/ui/data-table.js.map +1 -1
  5. package/dist/src/components/ui/input.d.ts.map +1 -1
  6. package/dist/src/components/ui/input.js +7 -4
  7. package/dist/src/components/ui/input.js.map +1 -1
  8. package/dist/src/components/ui/select.d.ts.map +1 -1
  9. package/dist/src/components/ui/select.js +11 -8
  10. package/dist/src/components/ui/select.js.map +1 -1
  11. package/dist/src/components/ui/textarea.d.ts.map +1 -1
  12. package/dist/src/components/ui/textarea.js +8 -5
  13. package/dist/src/components/ui/textarea.js.map +1 -1
  14. package/eslint-plugin/index.d.ts +18 -0
  15. package/eslint-plugin/index.js +102 -0
  16. package/eslint-rules/button-has-type.js +56 -0
  17. package/eslint-rules/class-literal.js +20 -0
  18. package/eslint-rules/ds-imports.js +131 -0
  19. package/eslint-rules/icon-button-needs-label.js +110 -0
  20. package/eslint-rules/index.js +31 -0
  21. package/eslint-rules/navigation.js +295 -0
  22. package/eslint-rules/no-adhoc-typography.js +77 -0
  23. package/eslint-rules/no-deep-import.js +59 -0
  24. package/eslint-rules/no-direct-lucide-import.js +67 -0
  25. package/eslint-rules/no-hardcoded-i18n-prop.js +78 -0
  26. package/eslint-rules/no-hardcoded-user-facing-string.js +82 -0
  27. package/eslint-rules/no-primitive-tailwind-colors.js +36 -0
  28. package/eslint-rules/no-raw-color-in-classname.js +34 -0
  29. package/eslint-rules/prefer-link-props.js +107 -0
  30. package/eslint-rules/require-browsable-link.js +236 -0
  31. package/eslint-rules/require-shared-sidebar-provider.js +165 -0
  32. package/package.json +18 -3
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Flags a native `<button>` without an explicit static `type`. The HTML
3
+ * default is `type="submit"`, so a plain button inside a `<form>` submits it.
4
+ *
5
+ * A dynamic value (`type={kind}`) or a spread (`{...props}`) is trusted — the
6
+ * type may arrive through it. Statically known non-strings (`type={null}`,
7
+ * `type={true}`, bare `type`) are invalid: React can't turn them into a type. Only lowercase `button` host elements are
8
+ * checked; components (DS `Button`, `asChild` targets) own their own default.
9
+ */
10
+ import { attributeValueKind, findAttribute, hasSpread } from "./ds-imports.js";
11
+
12
+ const VALID_TYPES = new Set(["button", "submit", "reset"]);
13
+
14
+ export default {
15
+ meta: {
16
+ type: "problem",
17
+ docs: {
18
+ description:
19
+ 'Native <button> elements must set an explicit type (usually type="button").',
20
+ },
21
+ messages: {
22
+ missingType:
23
+ 'Native <button> without `type` defaults to "submit" inside a <form>. Add type="button" (or "submit"/"reset" when intended).',
24
+ invalidType:
25
+ '"{{value}}" is not a valid button type. Use "button", "submit" or "reset".',
26
+ },
27
+ schema: [],
28
+ },
29
+ create(context) {
30
+ return {
31
+ JSXOpeningElement(node) {
32
+ if (node.name.type !== "JSXIdentifier" || node.name.name !== "button") {
33
+ return;
34
+ }
35
+ const typeAttr = findAttribute(node, "type");
36
+ if (!typeAttr) {
37
+ if (!hasSpread(node)) {
38
+ context.report({ node, messageId: "missingType" });
39
+ }
40
+ return;
41
+ }
42
+ const { kind, value } = attributeValueKind(typeAttr.value);
43
+ if (kind === "dynamic") return;
44
+ if (kind === "string" && VALID_TYPES.has(value)) return;
45
+ context.report({
46
+ node: typeAttr,
47
+ messageId: "invalidType",
48
+ data: {
49
+ value:
50
+ kind === "string" ? value : context.sourceCode.getText(typeAttr),
51
+ },
52
+ });
53
+ },
54
+ };
55
+ },
56
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared helper for the DS `local/*` className rules: given a JSX attribute
3
+ * VALUE node, returns the string when the className is a plain string literal
4
+ * (`"a b"` or `{"a b"}`), else null. cn()/template-literal/cva classNames are
5
+ * intentionally not inspected — the rules only reason about static strings.
6
+ */
7
+ export function classLiteral(value) {
8
+ if (!value) return null;
9
+ if (value.type === "Literal" && typeof value.value === "string") {
10
+ return value.value;
11
+ }
12
+ if (
13
+ value.type === "JSXExpressionContainer" &&
14
+ value.expression.type === "Literal" &&
15
+ typeof value.expression.value === "string"
16
+ ) {
17
+ return value.expression.value;
18
+ }
19
+ return null;
20
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Shared helper for rules that target specific DS components: tracks which
3
+ * local JSX names were imported from a DS module, so a consumer's own
4
+ * `Button`/`DataTable` never trips a rule meant for the DS one. Handles
5
+ * aliased imports (`import { Button as DsButton } ...`).
6
+ *
7
+ * `importSources` matches a module specifier exactly or as a path prefix
8
+ * (`"@camunda/design-system"` also matches `".../carbon-compat"`). The icon
9
+ * proxy subpath (`…/icons`) is never tracked: it re-exports every Lucide
10
+ * glyph, and glyphs such as `Link` or `Table` must not be mistaken for the DS
11
+ * components of the same name.
12
+ */
13
+ export const DEFAULT_IMPORT_SOURCES = ["@camunda/design-system"];
14
+
15
+ export const importSourcesSchema = {
16
+ type: "object",
17
+ properties: {
18
+ importSources: {
19
+ type: "array",
20
+ items: { type: "string" },
21
+ minItems: 1,
22
+ uniqueItems: true,
23
+ },
24
+ },
25
+ additionalProperties: false,
26
+ };
27
+
28
+ export function matchesSource(source, importSources) {
29
+ return importSources.some(
30
+ (s) =>
31
+ source === s ||
32
+ (source.startsWith(`${s}/`) &&
33
+ !/^icons(\/|$)/.test(source.slice(s.length + 1))),
34
+ );
35
+ }
36
+
37
+ // Returns { ImportDeclaration, importedName(jsxNameNode), importedCallee(name) }
38
+ // — the visitor records imports; the lookups resolve a local JSX/callee name
39
+ // back to the name it was exported under, or null when it isn't a DS import.
40
+ export function trackDsImports(context) {
41
+ const importSources =
42
+ context.options[0]?.importSources ?? DEFAULT_IMPORT_SOURCES;
43
+ const locals = new Map();
44
+ return {
45
+ ImportDeclaration(node) {
46
+ if (!matchesSource(node.source.value, importSources)) return;
47
+ for (const spec of node.specifiers) {
48
+ if (spec.type !== "ImportSpecifier") continue;
49
+ const imported =
50
+ spec.imported.type === "Identifier"
51
+ ? spec.imported.name
52
+ : spec.imported.value;
53
+ locals.set(spec.local.name, imported);
54
+ }
55
+ },
56
+ importedName(nameNode) {
57
+ if (nameNode.type !== "JSXIdentifier") return null;
58
+ return locals.get(nameNode.name) ?? null;
59
+ },
60
+ importedCallee(localName) {
61
+ return locals.get(localName) ?? null;
62
+ },
63
+ };
64
+ }
65
+
66
+ // JSX attribute lookup by name on an opening element; spread attributes are
67
+ // skipped (callers treat an element with a spread as "may carry the prop").
68
+ export function findAttribute(openingElement, name) {
69
+ return openingElement.attributes.find(
70
+ (a) =>
71
+ a.type === "JSXAttribute" &&
72
+ a.name.type === "JSXIdentifier" &&
73
+ a.name.name === name,
74
+ );
75
+ }
76
+
77
+ // True when the prop is set to something that can be truthy: bare (`asChild`
78
+ // = true), a string, or a dynamic expression. Statically disabled values —
79
+ // `{false}`, `{null}`, `{undefined}`, `{}` — count as absent.
80
+ export function hasActiveAttribute(openingElement, name) {
81
+ const attr = findAttribute(openingElement, name);
82
+ if (!attr) return false;
83
+ if (attr.value == null) return true;
84
+ if (attr.value.type !== "JSXExpressionContainer") return true;
85
+ const e = attr.value.expression;
86
+ if (e.type === "JSXEmptyExpression") return false;
87
+ if (e.type === "Identifier" && e.name === "undefined") return false;
88
+ return !(e.type === "Literal" && (e.value === false || e.value === null));
89
+ }
90
+
91
+ export function hasSpread(openingElement) {
92
+ return openingElement.attributes.some((a) => a.type === "JSXSpreadAttribute");
93
+ }
94
+
95
+ // Classifies a JSX attribute value:
96
+ // { kind: "string", value } — statically known string (`"x"`, `{"x"}`, `{`x`}`)
97
+ // { kind: "invalid" } — statically known non-string: bare attribute
98
+ // (`<b attr>` = true), `{null}`, `{true}`, `{42}`,
99
+ // `{undefined}`, `{}`
100
+ // { kind: "dynamic" } — anything else (identifier, call, ternary, …)
101
+ export function attributeValueKind(value) {
102
+ if (value == null) return { kind: "invalid" };
103
+ const text = staticString(value);
104
+ if (text !== null) return { kind: "string", value: text };
105
+ if (value.type !== "JSXExpressionContainer") return { kind: "dynamic" };
106
+ const e = value.expression;
107
+ if (
108
+ e.type === "JSXEmptyExpression" ||
109
+ e.type === "Literal" ||
110
+ (e.type === "Identifier" && e.name === "undefined")
111
+ ) {
112
+ return { kind: "invalid" };
113
+ }
114
+ return { kind: "dynamic" };
115
+ }
116
+
117
+ // String value of a JSX attribute when statically known (`"x"`, `{"x"}`,
118
+ // `{`x`}` without expressions), else null.
119
+ export function staticString(value) {
120
+ if (!value) return null;
121
+ if (value.type === "Literal" && typeof value.value === "string") {
122
+ return value.value;
123
+ }
124
+ if (value.type !== "JSXExpressionContainer") return null;
125
+ const e = value.expression;
126
+ if (e.type === "Literal" && typeof e.value === "string") return e.value;
127
+ if (e.type === "TemplateLiteral" && e.expressions.length === 0) {
128
+ return e.quasis[0].value.cooked;
129
+ }
130
+ return null;
131
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Flags a DS `<Button size="icon…">` with no accessible name: no `aria-label`
3
+ * / `aria-labelledby` on it or on a descendant (the `asChild` link case), and
4
+ * no text among its children. Icon-only buttons
5
+ * without a name are the most common a11y violation in consumer apps.
6
+ *
7
+ * Only `Button` imported from a DS module is checked (see ds-imports.js).
8
+ * Conservative by design — it stays silent whenever the name may come from
9
+ * somewhere it cannot see: a spread, a dynamic `size`, or any `{expression}`
10
+ * child (a translated string, a label variable, a conditional).
11
+ */
12
+ import {
13
+ attributeValueKind,
14
+ findAttribute,
15
+ hasSpread,
16
+ importSourcesSchema,
17
+ staticString,
18
+ trackDsImports,
19
+ } from "./ds-imports.js";
20
+
21
+ const HAS_WORDS = /\p{L}/u;
22
+
23
+ // A name attribute counts unless it is statically empty: `aria-label=""`,
24
+ // `aria-label={null}`, bare `aria-label`. Dynamic values are trusted.
25
+ function hasNameAttribute(opening) {
26
+ return ["aria-label", "aria-labelledby"].some((name) => {
27
+ const attr = findAttribute(opening, name);
28
+ if (!attr) return false;
29
+ const { kind, value } = attributeValueKind(attr.value);
30
+ return kind === "dynamic" || (kind === "string" && value.trim() !== "");
31
+ });
32
+ }
33
+
34
+ function isAriaHidden(opening) {
35
+ const attr = findAttribute(opening, "aria-hidden");
36
+ if (!attr) return false;
37
+ const { kind, value } = attributeValueKind(attr.value);
38
+ return (
39
+ (kind === "string" && value === "true") ||
40
+ attr.value == null ||
41
+ (attr.value.type === "JSXExpressionContainer" &&
42
+ attr.value.expression.type === "Literal" &&
43
+ attr.value.expression.value === true)
44
+ );
45
+ }
46
+
47
+ // true when some descendant may provide the name: a letter-bearing JSXText,
48
+ // any expression container (its value is unknown statically), or an element
49
+ // carrying aria-label/aria-labelledby (the `asChild` link/anchor case).
50
+ function mayProvideName(children) {
51
+ return children.some((child) => {
52
+ if (child.type === "JSXText") return HAS_WORDS.test(child.value);
53
+ if (child.type === "JSXExpressionContainer") {
54
+ const e = child.expression;
55
+ if (e.type === "JSXEmptyExpression") return false;
56
+ // {null} / {false} / {true} / {undefined} render nothing; a string
57
+ // literal names only if it has letters. Anything dynamic is trusted.
58
+ if (e.type === "Identifier" && e.name === "undefined") return false;
59
+ if (e.type === "Literal") {
60
+ return typeof e.value === "string"
61
+ ? HAS_WORDS.test(e.value)
62
+ : typeof e.value === "number";
63
+ }
64
+ return true;
65
+ }
66
+ if (child.type === "JSXElement") {
67
+ // An aria-hidden subtree is excluded from the accessible name.
68
+ if (isAriaHidden(child.openingElement)) return false;
69
+ return (
70
+ hasNameAttribute(child.openingElement) || mayProvideName(child.children)
71
+ );
72
+ }
73
+ if (child.type === "JSXFragment") return mayProvideName(child.children);
74
+ return true;
75
+ });
76
+ }
77
+
78
+ export default {
79
+ meta: {
80
+ type: "problem",
81
+ docs: {
82
+ description:
83
+ 'A DS <Button size="icon…"> must have an accessible name (aria-label or aria-labelledby).',
84
+ },
85
+ messages: {
86
+ missingLabel:
87
+ 'Icon-only <Button size="{{size}}"> has no accessible name. Add aria-label="…" (or use <IconButton label="…">, which also renders a tooltip).',
88
+ },
89
+ schema: [importSourcesSchema],
90
+ },
91
+ create(context) {
92
+ const ds = trackDsImports(context);
93
+ return {
94
+ ImportDeclaration: ds.ImportDeclaration,
95
+ JSXElement(node) {
96
+ const opening = node.openingElement;
97
+ if (ds.importedName(opening.name) !== "Button") return;
98
+ const size = staticString(findAttribute(opening, "size")?.value);
99
+ if (!size?.startsWith("icon")) return;
100
+ if (hasSpread(opening)) return;
101
+ if (hasNameAttribute(opening) || mayProvideName(node.children)) return;
102
+ context.report({
103
+ node: opening,
104
+ messageId: "missingLabel",
105
+ data: { size },
106
+ });
107
+ },
108
+ };
109
+ },
110
+ };
@@ -0,0 +1,31 @@
1
+ // Registry of the `local/*` convention rules this repo's own lint enables
2
+ // (eslint.config.js). Rules that also make sense in product repos are shipped,
3
+ // from the same implementation files, by the consumer plugin in
4
+ // eslint-plugin/index.js — that plugin (not this registry) is what product
5
+ // repos extend and what scripts/audit-conventions.mjs runs on agent output.
6
+ import buttonHasType from "./button-has-type.js";
7
+ import iconButtonNeedsLabel from "./icon-button-needs-label.js";
8
+ import requireBrowsableLink from "./require-browsable-link.js";
9
+ import noPrimitiveTailwindColors from "./no-primitive-tailwind-colors.js";
10
+ import noRawColorInClassName from "./no-raw-color-in-classname.js";
11
+ import noAdhocTypography from "./no-adhoc-typography.js";
12
+ import requireSharedSidebarProvider from "./require-shared-sidebar-provider.js";
13
+ import noHardcodedUserFacingString from "./no-hardcoded-user-facing-string.js";
14
+
15
+ export const rules = {
16
+ "button-has-type": buttonHasType,
17
+ "icon-button-needs-label": iconButtonNeedsLabel,
18
+ "require-browsable-link": requireBrowsableLink,
19
+ "no-primitive-tailwind-colors": noPrimitiveTailwindColors,
20
+ "no-raw-color-in-classname": noRawColorInClassName,
21
+ "no-adhoc-typography": noAdhocTypography,
22
+ "require-shared-sidebar-provider": requireSharedSidebarProvider,
23
+ "no-hardcoded-user-facing-string": noHardcodedUserFacingString,
24
+ };
25
+
26
+ // ESLint flat-config plugin object. A SINGLE shared reference is required:
27
+ // flat config rejects redefining a plugin key with a different object when
28
+ // blocks' `files` overlap, so every config block must reuse this one object.
29
+ export const localPlugin = { rules };
30
+
31
+ export default localPlugin;
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Shared helpers for the link rules (require-browsable-link, prefer-link-props):
3
+ * decide whether a click handler visibly navigates, and find the DS
4
+ * "descriptor" objects (sidebar items, breadcrumb segments, …) that carry
5
+ * their own click/link props.
6
+ *
7
+ * "Navigates" = the handler (or a same-file function it calls, a few levels
8
+ * deep) calls `navigate(url)`, `router.push/replace`, `history.push/replace`
9
+ * (also nested: `props.history.push`, `store.router.push`),
10
+ * `location.assign/replace`, `window.open`, or assigns `location`/
11
+ * `location.href`. Not a destination a link could carry, so ignored:
12
+ * - `navigate(-1)` (history back);
13
+ * - navigation after an `await` in the same function (create, then open the
14
+ * new item — the URL doesn't exist before the click);
15
+ * - navigation inside a nested callback the handler only creates or passes
16
+ * on (`openConfirm({ onConfirm: () => navigate(url) })`, `.then(…)`).
17
+ *
18
+ * `navigationFunctions` adds app helpers: a bare name (`"goTo"`) matches
19
+ * `goTo(…)`; a dotted name (`"router.goTo"`) matches any `….router.goTo(…)`.
20
+ */
21
+ export const navigationSchemaProperties = {
22
+ navigationFunctions: {
23
+ type: "array",
24
+ items: { type: "string" },
25
+ uniqueItems: true,
26
+ },
27
+ };
28
+
29
+ const NAV_OBJECTS = new Set(["router", "history", "navigation"]);
30
+ const NAV_METHODS = new Set(["push", "replace", "navigate"]);
31
+ const LOCATION_METHODS = new Set(["assign", "replace"]);
32
+ const MAX_DEPTH = 3;
33
+ const FUNCTION_TYPES = new Set([
34
+ "ArrowFunctionExpression",
35
+ "FunctionExpression",
36
+ "FunctionDeclaration",
37
+ ]);
38
+
39
+ function isLocation(node) {
40
+ if (node.type === "Identifier") return node.name === "location";
41
+ return (
42
+ node.type === "MemberExpression" &&
43
+ !node.computed &&
44
+ node.property.name === "location" &&
45
+ node.object.type === "Identifier" &&
46
+ (node.object.name === "window" || node.object.name === "document")
47
+ );
48
+ }
49
+
50
+ function isHistoryBack(call) {
51
+ const arg = call.arguments[0];
52
+ return (
53
+ arg &&
54
+ ((arg.type === "Literal" && typeof arg.value === "number") ||
55
+ (arg.type === "UnaryExpression" && arg.argument.type === "Literal"))
56
+ );
57
+ }
58
+
59
+ // The navigating call's destination node, when it has one (`navigate(url)`).
60
+ function navigationTarget(node, navFns) {
61
+ if (node.type === "AssignmentExpression") {
62
+ const left = node.left;
63
+ if (isLocation(left)) return node;
64
+ if (
65
+ left.type === "MemberExpression" &&
66
+ !left.computed &&
67
+ left.property.name === "href" &&
68
+ isLocation(left.object)
69
+ ) {
70
+ return node;
71
+ }
72
+ return null;
73
+ }
74
+ if (node.type !== "CallExpression") return null;
75
+ const callee = node.callee;
76
+ if (callee.type === "Identifier") {
77
+ if (callee.name === "navigate" || navFns.has(callee.name)) {
78
+ return isHistoryBack(node) ? null : node;
79
+ }
80
+ return null;
81
+ }
82
+ if (callee.type !== "MemberExpression" || callee.computed) return null;
83
+ const method = callee.property.name;
84
+ const obj = callee.object;
85
+ // Last segment of the receiver: `history` in both `history.push` and
86
+ // `props.history.push`, `router` in `store.router.goTo`.
87
+ const objName =
88
+ obj.type === "Identifier"
89
+ ? obj.name
90
+ : obj.type === "MemberExpression" && !obj.computed
91
+ ? obj.property.name
92
+ : null;
93
+ if (objName && navFns.has(`${objName}.${method}`)) {
94
+ return isHistoryBack(node) ? null : node;
95
+ }
96
+ if (objName && NAV_OBJECTS.has(objName)) {
97
+ return NAV_METHODS.has(method) && !isHistoryBack(node) ? node : null;
98
+ }
99
+ if (obj.type === "Identifier" && obj.name === "window" && method === "open") {
100
+ return node;
101
+ }
102
+ if (isLocation(obj) && LOCATION_METHODS.has(method)) return node;
103
+ return null;
104
+ }
105
+
106
+ // True when `node` navigates through a built-in navigator (`navigate`,
107
+ // `history`/`router` push/replace, `window.open`, `location`), whose first
108
+ // argument is a URL — unlike an app helper (`goTo(route, params)`).
109
+ export function isBuiltinNavigation(node) {
110
+ return navigationTarget(node, new Set()) === node;
111
+ }
112
+
113
+ function findVariableInit(context, identifier) {
114
+ let scope = context.sourceCode.getScope(identifier);
115
+ while (scope) {
116
+ const variable = scope.set.get(identifier.name);
117
+ if (variable) {
118
+ const def = variable.defs[0];
119
+ if (!def) return null;
120
+ if (def.node.type === "FunctionDeclaration") return def.node;
121
+ if (def.node.type === "VariableDeclarator") return def.node.init;
122
+ return null;
123
+ }
124
+ scope = scope.upper;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ // `useCallback(fn, deps)` / `useMemo(() => value, deps)` → their first arg.
130
+ function unwrapHook(node) {
131
+ if (
132
+ node?.type === "CallExpression" &&
133
+ node.callee.type === "Identifier" &&
134
+ (node.callee.name === "useCallback" || node.callee.name === "useMemo") &&
135
+ node.arguments[0]
136
+ ) {
137
+ return node.arguments[0];
138
+ }
139
+ return node;
140
+ }
141
+
142
+ // Resolves an expression to the function it denotes (inline, or a same-file
143
+ // identifier, optionally wrapped in useCallback), else null.
144
+ export function resolveFunction(context, expr) {
145
+ let node = expr;
146
+ if (node?.type === "Identifier") node = findVariableInit(context, node);
147
+ node = unwrapHook(node);
148
+ return node && FUNCTION_TYPES.has(node.type) ? node : null;
149
+ }
150
+
151
+ // Depth-first walk. `visit` returns true to stop, "skip" to not descend into
152
+ // the node's children, anything else to continue.
153
+ function walk(node, visit) {
154
+ if (!node || typeof node.type !== "string") return false;
155
+ const verdict = visit(node);
156
+ if (verdict === true) return true;
157
+ if (verdict === "skip") return false;
158
+ for (const key of Object.keys(node)) {
159
+ if (key === "parent") continue;
160
+ const child = node[key];
161
+ if (Array.isArray(child)) {
162
+ if (child.some((c) => walk(c, visit))) return true;
163
+ } else if (child && typeof child.type === "string") {
164
+ if (walk(child, visit)) return true;
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+
170
+ // Returns the first navigating node reachable from `expr` (a handler
171
+ // expression), or null. Follows calls to same-file functions up to MAX_DEPTH.
172
+ export function findNavigation(context, expr, depth = 0, seen = new Set()) {
173
+ const fn = resolveFunction(context, expr);
174
+ if (!fn || seen.has(fn) || depth > MAX_DEPTH) return null;
175
+ seen.add(fn);
176
+ const navFns = new Set(context.options[0]?.navigationFunctions ?? []);
177
+ // Nested function bodies are deferred work (callbacks passed on, `.then`),
178
+ // not something this handler runs — skip them; called same-file functions
179
+ // are still followed through the Identifier-callee branch below.
180
+ const skipNested = (node) =>
181
+ node !== fn.body && FUNCTION_TYPES.has(node.type);
182
+ const awaitEnds = [];
183
+ walk(fn.body, (node) => {
184
+ if (skipNested(node)) return "skip";
185
+ if (node.type === "AwaitExpression") awaitEnds.push(node.range[1]);
186
+ return false;
187
+ });
188
+ let found = null;
189
+ walk(fn.body, (node) => {
190
+ if (skipNested(node)) return "skip";
191
+ const target = navigationTarget(node, navFns);
192
+ if (target) {
193
+ if (awaitEnds.some((end) => end <= target.range[0])) return false;
194
+ found = target;
195
+ return true;
196
+ }
197
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
198
+ found = findNavigation(context, node.callee, depth + 1, seen);
199
+ return Boolean(found);
200
+ }
201
+ return false;
202
+ });
203
+ return found;
204
+ }
205
+
206
+ // Navigation found through a JSX attribute's handler, or null.
207
+ export function attributeNavigation(context, attr) {
208
+ const value = attr?.value;
209
+ if (value?.type !== "JSXExpressionContainer") return null;
210
+ return findNavigation(context, value.expression);
211
+ }
212
+
213
+ // DS components whose props take descriptor objects carrying their own
214
+ // `onClick`/`navigate` + `linkProps`/`href` (sidebar items, breadcrumb
215
+ // segments and switcher entries, notifications).
216
+ const DESCRIPTOR_COMPONENTS = new Set([
217
+ "AppSidebar",
218
+ "NavBreadcrumb",
219
+ "NavBreadcrumbOverflow",
220
+ "NavBreadcrumbSwitcher",
221
+ "NavSwitcher",
222
+ "NotificationBell",
223
+ "NotificationsPanel",
224
+ ]);
225
+ const DESCRIPTOR_FACTORY = /^create[A-Za-z]*Descriptor$/;
226
+ const DESCRIPTOR_CLICK_PROPS = ["onClick", "navigate"];
227
+ const DESCRIPTOR_LINK_PROPS = new Set(["linkProps", "href", "to"]);
228
+ // Descriptor fields holding objects with a click but no link API
229
+ // (`BreadcrumbAction` under `actions`) — never descriptors to lint.
230
+ const NON_LINK_FIELDS = new Set(["actions"]);
231
+
232
+ function propertyName(prop) {
233
+ if (prop.type !== "Property" || prop.computed) return null;
234
+ if (prop.key.type === "Identifier") return prop.key.name;
235
+ return prop.key.type === "Literal" ? String(prop.key.value) : null;
236
+ }
237
+
238
+ // Visits every object literal reachable from `expr` (arrays, nested objects,
239
+ // `.map(() => ({…}))` callbacks, a same-file `const items = […]`, useMemo).
240
+ // Calls onDescriptor(object, clickProperty) for objects that have a click
241
+ // prop and no link prop; spreads count as "may carry the link prop".
242
+ export function forEachLinklessDescriptor(context, expr, onDescriptor) {
243
+ let root = expr;
244
+ if (root?.type === "Identifier") root = findVariableInit(context, root);
245
+ root = unwrapHook(root);
246
+ if (
247
+ root &&
248
+ FUNCTION_TYPES.has(root.type) &&
249
+ root.body.type !== "BlockStatement"
250
+ ) {
251
+ root = root.body;
252
+ }
253
+ walk(root, (node) => {
254
+ if (node.type === "Property" && NON_LINK_FIELDS.has(propertyName(node))) {
255
+ return "skip";
256
+ }
257
+ if (node.type !== "ObjectExpression") return false;
258
+ if (node.properties.some((p) => p.type === "SpreadElement")) return false;
259
+ // A link field set to null/false/undefined carries no link.
260
+ const hasLink = node.properties.some(
261
+ (p) =>
262
+ DESCRIPTOR_LINK_PROPS.has(propertyName(p)) &&
263
+ !(p.value.type === "Identifier" && p.value.name === "undefined") &&
264
+ !(
265
+ p.value.type === "Literal" &&
266
+ (p.value.value === null || p.value.value === false)
267
+ ),
268
+ );
269
+ if (hasLink) return false;
270
+ const click = node.properties.find((p) =>
271
+ DESCRIPTOR_CLICK_PROPS.includes(propertyName(p)),
272
+ );
273
+ if (click) onDescriptor(node, click);
274
+ return false;
275
+ });
276
+ }
277
+
278
+ // Every descriptor root in a JSX element / factory call from a DS module.
279
+ export function descriptorRoots(ds, node) {
280
+ if (node.type === "JSXOpeningElement") {
281
+ if (!DESCRIPTOR_COMPONENTS.has(ds.importedName(node.name) ?? "")) return [];
282
+ return node.attributes
283
+ .filter(
284
+ (a) =>
285
+ a.type === "JSXAttribute" &&
286
+ a.value?.type === "JSXExpressionContainer",
287
+ )
288
+ .map((a) => a.value.expression);
289
+ }
290
+ if (node.type === "CallExpression" && node.callee.type === "Identifier") {
291
+ const imported = ds.importedCallee(node.callee.name);
292
+ return imported && DESCRIPTOR_FACTORY.test(imported) ? node.arguments : [];
293
+ }
294
+ return [];
295
+ }