@md-code/react-component-uniqueness 0.1.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.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # @md-code/react-component-uniqueness
2
+
3
+ ESLint rule + CLI scanner that finds **duplicate React components** in your app code by comparing them against your canonical component library.
4
+
5
+ ## How it works
6
+
7
+ ```mermaid
8
+ flowchart TD
9
+ A["Canonical components<br/>(walk folders, extract signatures)"] --> B[("catalog.json")]
10
+ A --> CD["canon-dup<br/>canonicals matched against each other (strict thresholds)<br/>duplicates dropped → unique canonical set"]
11
+ CD --> A
12
+ C["App code<br/>(every JSX element)"] --> D
13
+
14
+ subgraph match["Match (4 matchers in parallel)"]
15
+ D --> M1["name<br/>same or fuzzy name"]
16
+ D --> M2["structural<br/>shared class tokens"]
17
+ D --> M3["family<br/>token-profile cluster"]
18
+ D --> M4["jsx-block<br/>subtree contains canonical"]
19
+ B --> M1
20
+ B --> M2
21
+ B --> M3
22
+ B --> M4
23
+ end
24
+
25
+ M1 --> F1
26
+ M2 --> F1
27
+ M3 --> F1
28
+ M4 --> F1
29
+
30
+ subgraph filter["Filter"]
31
+ F1["drop-usages<br/>app already imports the canonical"]
32
+ F1 --> F2["drop-child-element<br/>match is on a child, not the root"]
33
+ end
34
+
35
+ F2 --> E["report.md<br/>Markdown funnel of duplicates"]
36
+ CD -. "own section" .-> E
37
+ ```
38
+
39
+ ## Quick start
40
+
41
+ ```sh
42
+ bun add -D @md-code/react-component-uniqueness
43
+ ```
44
+
45
+ ```js
46
+ // eslint.config.js
47
+ const uniqueness = require("@md-code/react-component-uniqueness/plugin");
48
+
49
+ module.exports = [
50
+ {
51
+ files: ["**/*.tsx"],
52
+ plugins: { "md-code": uniqueness },
53
+ rules: {
54
+ "md-code/react-component-uniqueness": ["error", {
55
+ componentsFolder: [
56
+ { path: "core/ui/atoms", rank: 0 },
57
+ { path: "core/ui/formation", rank: 1 },
58
+ { path: "core/ui/partials", rank: 2 },
59
+ ],
60
+ catalogPath: "reports/component-catalog.json",
61
+ }],
62
+ },
63
+ },
64
+ ];
65
+ ```
66
+
67
+ The catalog is generated automatically on the first lint when the file is
68
+ missing (from the `componentsFolder` option) — it is gitignored and never
69
+ committed. You can also generate it explicitly:
70
+
71
+ ```sh
72
+ bunx md-code-react-component-uniqueness
73
+ ```
74
+
75
+ Run the linter as usual:
76
+
77
+ ```sh
78
+ bunx eslint "src/**/*.tsx"
79
+ ```
80
+
81
+ ## CLI
82
+
83
+ ```sh
84
+ bunx md-code-react-component-uniqueness [flags]
85
+ ```
86
+
87
+ | Flag | Description |
88
+ | --- | --- |
89
+ | `--roots a:b` | Scan roots (overrides `componentsFolder` from config) |
90
+ | `--out <file>` | Catalog output path (default `reports/component-catalog.json`) |
91
+ | `--check` | CI gate — exit 1 if catalog is stale |
92
+ | `--report [file]` | Write a Markdown duplicate report |
93
+ | `--verbose` | Include the "Filtered out" section in the report |
94
+ | `--app-roots a:b` | App-code roots for the report (default: repo root) |
95
+ | `--repo-root <dir>` | Repo root (default: auto-discovered) |
96
+
97
+ ## What it flags
98
+
99
+ | Tier | Meaning |
100
+ | --- | --- |
101
+ | `name` | App component has the same name as a canonical one |
102
+ | `name-fuzzy` | Name is a fuzzy match (e.g. `MyButton` vs `Button`) |
103
+ | `structural-exact` | ≥ 80% of the canonical's distinctive class tokens are shared |
104
+ | `structural-similar` | 40–80% shared |
105
+ | `family` | Clusters by token profile (hand-built library pieces) |
106
+ | `jsx-block` | A JSX subtree in app code contains the full tag-sequence of a canonical component |
107
+
108
+ ## Options (rule config)
109
+
110
+ | Option | Default | Notes |
111
+ | --- | --- | --- |
112
+ | `componentsFolder` | `[]` | Canonical dirs. `{ path, rank }` objects or plain strings |
113
+ | `catalogPath` | `reports/component-catalog.json` | Where the catalog lives |
114
+ | `thresholds` | built-in | `minShared`, `minRatioSameTag`, `minRatioAnyTag`, `exactRatio`, `familyMatchMin` |
115
+ | `exclude` | `node_modules, dist, build, .git` | Globs or regexes to skip |
116
+ | `include` | `[]` | If set, only matching files are linted |
117
+
118
+ ## Notes
119
+
120
+ - The catalog is **generated on demand** by the rule when the file is missing
121
+ (same code path as the CLI), so it can stay gitignored and CI works out of
122
+ the box. The CLI is still useful for `--report` and `--check`.
123
+ - The catalog is read once per ESLint process and cached.
124
+ - A missing or stale catalog logs a warning; lint never crashes.
125
+ - Everything is plain Node + `fs`/`path` — works identically on Linux, macOS, Windows.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ /**
5
+ * bin/react-component-uniqueness.js
6
+ *
7
+ * CLI entry point: runs the catalog scanner (scanner/main.js).
8
+ */
9
+
10
+ require("../scanner/main.js");
package/config.js ADDED
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * config.js
5
+ *
6
+ * The single place that knows the rule's defaults and normalizes the
7
+ * ESLint options into a runtime config object. Every error type reads from this
8
+ * object, so changing a default or taxonomy is a one-file edit.
9
+ *
10
+ * Paths are GENERIC: the registry and catalog locations are options that
11
+ * default to the conventional reports/ files under the repository root
12
+ * (the repo root is discovered by walking up for pnpm-workspace.yaml).
13
+ */
14
+
15
+ const path = require("node:path");
16
+ const { minimatch } = require("minimatch");
17
+ const { findRepoRoot } = require("./debt");
18
+
19
+ /** Raw interactive / form elements that are only legal inside the canonical packages. */
20
+ const DEFAULT_RAW_ELEMENTS = ["button", "label", "select", "textarea", "input", "form", "table", "dialog"];
21
+
22
+ /** Tags eligible for the behavior error type (plain building blocks + containers). */
23
+ const DEFAULT_BEHAVIOR_TAGS = [
24
+ "div",
25
+ "main",
26
+ "section",
27
+ "header",
28
+ "footer",
29
+ "nav",
30
+ "aside",
31
+ "span",
32
+ "ul",
33
+ "li",
34
+ "a",
35
+ "p",
36
+ "figure",
37
+ "img",
38
+ ];
39
+
40
+ /**
41
+ * Default file exclusions (repo-relative paths): build artifacts and
42
+ * dependency folders are never linted by the rule.
43
+ */
44
+ const DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/build/**", ".git/**"];
45
+
46
+ /** Class tokens that turn a plain element into a specific hand-rolled component (shadcn / MUI / antd taxonomy). */
47
+ const SPINNER_CLASS_RE = /(^|[\s"'`-])(spin(ning)?|loading|loader|pulse|shimmer|skeleton)([\s"'`-]|$)/i;
48
+ const MODAL_CLASS_RE = /(^|[\s"'`-])(modal|dialog|drawer|overlay|backdrop|popup)([\s"'`-]|$)/i;
49
+ const CARD_CLASS_RE = /(^|[\s"'`-])(card|paper|panel|tile)([\s"'`-]|$)/i;
50
+ const CARD_SECOND_RE = /(^|[\s"'`-])(shadow|rounded|border|elevation)([\s"'`-]|$)/i;
51
+ const CONTAINER_CLASS_RE =
52
+ /(^|[\s"'`-])(flex|grid|col(umn)?|row|justify-|items-|gap-|space-|stack|container|wrapper|layout)([\s"'`-]|$)/i;
53
+
54
+ /** role values that turn a plain element into a hand-rolled component. */
55
+ const ROLE_WHAT = {
56
+ dialog: "modal / dialog",
57
+ alertdialog: "modal / dialog",
58
+ menu: "dropdown / menu",
59
+ menubar: "dropdown / menu",
60
+ menuitem: "menu item (a hand-rolled control)",
61
+ tablist: "tabs",
62
+ tab: "tab (a hand-rolled control)",
63
+ slider: "slider",
64
+ tooltip: "tooltip",
65
+ progressbar: "progress indicator",
66
+ alert: "alert",
67
+ status: "live status region",
68
+ group: "accordion / collapsible",
69
+ switch: "switch (a hand-rolled control)",
70
+ checkbox: "checkbox (a hand-rolled control)",
71
+ radio: "radio (a hand-rolled control)",
72
+ button: "button (a hand-rolled control)",
73
+ };
74
+
75
+ /** Raw elements that ALSO go through the behavior error type (input type=range, select, dialog, ...). */
76
+ const RAW_BEHAVIOR_TAGS = new Set(["input", "select", "textarea", "form", "table", "dialog"]);
77
+
78
+ /** Event-handler attribute names counted as "actions" in a signature. */
79
+ const ACTION_ATTR_NAMES = new Set([
80
+ "onClick",
81
+ "onMouseDown",
82
+ "onMouseUp",
83
+ "onMouseEnter",
84
+ "onMouseLeave",
85
+ "onMouseMove",
86
+ "onTouchStart",
87
+ "onTouchEnd",
88
+ "onTouchMove",
89
+ "onFocus",
90
+ "onBlur",
91
+ "onChange",
92
+ "onInput",
93
+ "onSubmit",
94
+ "onKeyDown",
95
+ "onKeyUp",
96
+ "onKeyPress",
97
+ "onDragStart",
98
+ "onDragEnd",
99
+ "onDrop",
100
+ "onDragOver",
101
+ "onDoubleClick",
102
+ "onContextMenu",
103
+ ]);
104
+
105
+ /**
106
+ * Resolve a data-file option to an absolute path. A relative value is
107
+ * resolved against the repository root (or cwd as a last resort), so the
108
+ * default "reports/component-registry.json" works from any working directory.
109
+ */
110
+ function resolveDataPath(value, root) {
111
+ if (path.isAbsolute(value)) return value;
112
+ const base = root || process.cwd();
113
+ return path.resolve(base, value);
114
+ }
115
+
116
+ /**
117
+ * Normalize the ESLint options into a runtime config object.
118
+ *
119
+ * @param {object} [opts] the first rule option (may be undefined)
120
+ * @returns {object} config
121
+ */
122
+ function normalizeOptions(opts) {
123
+ const o = opts && typeof opts === "object" ? opts : {};
124
+ const root = findRepoRoot(process.cwd());
125
+
126
+ return {
127
+ // Error type 1: the registry of canonical component names -> allowed dirs.
128
+ registry: o.registry || null,
129
+ registryPath: resolveDataPath(o.registryPath || "reports/component-registry.json", root),
130
+
131
+ // Error type 3b: the catalog of canonical component element signatures.
132
+ catalog: o.catalog || null,
133
+ catalogPath: resolveDataPath(o.catalogPath || "reports/component-catalog.json", root),
134
+
135
+ // Dynamic catalog: signatures captured from a REAL rendered DOM
136
+ // (playground + getComputedStyle). Covers styles that are invisible
137
+ // statically — MUI emotion/sx, antd cssinjs. When includeDynamic is
138
+ // true the dynamic catalog entries are merged into the static one.
139
+ includeDynamic: o.includeDynamic === true,
140
+ dynamicCatalog: o.dynamicCatalog || null,
141
+ dynamicCatalogPath: resolveDataPath(o.dynamicCatalogPath || "reports/component-catalog-dynamic.json", root),
142
+
143
+ // Error type 2: raw elements to flag.
144
+ rawElements: new Set(o.rawElements || DEFAULT_RAW_ELEMENTS),
145
+
146
+ // Error type 3: tags eligible for behavior analysis.
147
+ behaviorTags: new Set(o.behaviorTags || DEFAULT_BEHAVIOR_TAGS),
148
+
149
+ // Component folders (raw HTML / styled.* legal inside; catalog source).
150
+ // Trailing slashes are normalized so "packages/components" and
151
+ // "packages/components/" mean the same folder.
152
+ // NO DEFAULT: the canonical layout is the consumer's knowledge — the
153
+ // ESLint config (or scanner) must pass the folders explicitly. Empty
154
+ // list = every file is "outside the component folders".
155
+ componentsFolder: normalizeFolders(o.componentsFolder || []),
156
+
157
+ // File filters: include (empty = everything) / exclude (build artifacts).
158
+ // Each entry is a minimatch glob or a regex ("..." or /.../ form).
159
+ include: o.include || [],
160
+ exclude: o.exclude || DEFAULT_EXCLUDE,
161
+
162
+ // Debt ledger section (relpath::ruleId -> count).
163
+ debt: o.debt || null,
164
+
165
+ // Repo root (for repo-relative paths in messages).
166
+ root,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Normalize a list of folder prefixes: posix separators, no leading "./",
172
+ * exactly one trailing slash. "packages/components" and "packages/components/"
173
+ * become the same string. Entries may also be objects: { path, rank } — rank
174
+ * orders the canonical layers (0 = lowest, e.g. atoms; higher = built on top,
175
+ * e.g. partials). String entries get their rank from their position.
176
+ *
177
+ * @param {Array<string|{path:string, rank?:number}>} folders
178
+ * @returns {string[]}
179
+ */
180
+ function normalizeFolders(folders) {
181
+ return (folders || []).map((f, i) => {
182
+ const raw = f && typeof f === "object" ? f.path : f;
183
+ let s = String(raw).trim().split(path.sep).join("/");
184
+ while (s.startsWith("./")) s = s.slice(2);
185
+ if (!s.endsWith("/")) s += "/";
186
+ return s;
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Map of normalized folder prefix -> rank. Object entries carry an explicit
192
+ * rank; string entries fall back to their position in the list.
193
+ *
194
+ * @param {Array<string|{path:string, rank?:number}>} folders
195
+ * @returns {Map<string, number>}
196
+ */
197
+ function folderRank(folders) {
198
+ const map = new Map();
199
+ (folders || []).forEach((f, i) => {
200
+ const isObj = f && typeof f === "object";
201
+ const raw = isObj ? f.path : f;
202
+ let s = String(raw).trim().split(path.sep).join("/");
203
+ while (s.startsWith("./")) s = s.slice(2);
204
+ if (!s.endsWith("/")) s += "/";
205
+ const r = isObj && typeof f.rank === "number" ? f.rank : i;
206
+ if (!map.has(s)) map.set(s, r);
207
+ });
208
+ return map;
209
+ }
210
+
211
+ /**
212
+ * True when a repo-relative path is inside any of the component folders
213
+ * (folders are normalized to carry a trailing slash).
214
+ */
215
+ function inComponentsFolder(relPath, componentsFolder) {
216
+ return componentsFolder.some((d) => relPath.startsWith(d));
217
+ }
218
+
219
+ /**
220
+ * Compile an include/exclude entry into a matcher. Accepts a minimatch glob
221
+ * ("packages/apps/**") or a regex ("^packages/apps/" or /packages\/apps/).
222
+ */
223
+ function compileMatcher(pattern) {
224
+ if (pattern instanceof RegExp) return (p) => pattern.test(p);
225
+ if (typeof pattern !== "string" || pattern.length === 0) return null;
226
+ if (pattern.startsWith("/") && pattern.endsWith("/") && pattern.length > 2) {
227
+ try {
228
+ const re = new RegExp(pattern.slice(1, -1));
229
+ return (p) => re.test(p);
230
+ } catch {
231
+ return null; // invalid regex — treat as glob
232
+ }
233
+ }
234
+ if (pattern.startsWith("^") && pattern.endsWith("$")) {
235
+ try {
236
+ const re = new RegExp(pattern);
237
+ return (p) => re.test(p);
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+ return (p) => minimatch(p, pattern);
243
+ }
244
+
245
+ /**
246
+ * True when the repo-relative file path should be skipped by the rule.
247
+ * A non-empty `include` list means the file must match at least one entry;
248
+ * any matching `exclude` entry always wins.
249
+ */
250
+ function isIgnored(relPath, include, exclude) {
251
+ for (const pattern of exclude || []) {
252
+ const m = compileMatcher(pattern);
253
+ if (m && m(relPath)) return true;
254
+ }
255
+ if (include && include.length > 0) {
256
+ return !include.some((pattern) => {
257
+ const m = compileMatcher(pattern);
258
+ return m ? m(relPath) : false;
259
+ });
260
+ }
261
+ return false;
262
+ }
263
+
264
+ /**
265
+ * True when a repo-relative path is exactly one of the given directories or
266
+ * lives inside any of them. Used by the registry error type to decide whether a
267
+ * declaration sits in its canonical directory.
268
+ */
269
+ function inCanonicalDir(relPath, dirs) {
270
+ return dirs.some((d) => relPath === d || relPath.startsWith(d + "/"));
271
+ }
272
+
273
+ module.exports = {
274
+ DEFAULT_RAW_ELEMENTS,
275
+ DEFAULT_BEHAVIOR_TAGS,
276
+ DEFAULT_EXCLUDE,
277
+ SPINNER_CLASS_RE,
278
+ MODAL_CLASS_RE,
279
+ CARD_CLASS_RE,
280
+ CARD_SECOND_RE,
281
+ CONTAINER_CLASS_RE,
282
+ ROLE_WHAT,
283
+ RAW_BEHAVIOR_TAGS,
284
+ ACTION_ATTR_NAMES,
285
+ normalizeOptions,
286
+ inComponentsFolder,
287
+ inCanonicalDir,
288
+ isIgnored,
289
+ folderRank,
290
+ };
package/debt.js ADDED
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * debt.js
5
+ *
6
+ * Generic debt-ledger mechanism (self-contained copy so this package is
7
+ * npm-installable on its own).
8
+ *
9
+ * Doctrine: a rule is TOTAL. Instance-specific findings go to a
10
+ * machine-generated debt ledger (reports/lint-debt.json), keyed
11
+ * "relpath::ruleId" -> count. The ledger is shrink-only: check mode fails if
12
+ * an entry grows or a new key appears.
13
+ *
14
+ * `applyDebt(context, ledger, ruleId)` returns a wrapped ESLint context whose
15
+ * `report` silently drops findings already recorded in the ledger.
16
+ */
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+
21
+ /**
22
+ * Load a debt ledger JSON file. Missing/invalid file -> empty ledger
23
+ * (strict mode: every finding is reported).
24
+ */
25
+ function loadLedger(filePath) {
26
+ try {
27
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
28
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
29
+ return raw;
30
+ }
31
+ } catch {
32
+ // missing or corrupt -> strict
33
+ }
34
+ return {};
35
+ }
36
+
37
+ /**
38
+ * Wrap an ESLint context so that `report` drops findings that are already
39
+ * recorded in the debt ledger.
40
+ *
41
+ * Ledger key: `<repo-relative path>::<ruleId>` (matches gen-lint-debt.mjs).
42
+ */
43
+ function applyDebt(context, ledger, ruleId) {
44
+ if (!ledger || Object.keys(ledger).length === 0) {
45
+ return context;
46
+ }
47
+
48
+ const rel = repoRelative(context);
49
+
50
+ const wrapped = Object.create(context);
51
+ Object.defineProperty(wrapped, "report", {
52
+ value: function (descriptor) {
53
+ const key = `${rel}::${ruleId}`;
54
+ if (Object.prototype.hasOwnProperty.call(ledger, key)) {
55
+ return; // known debt — already accounted for
56
+ }
57
+ context.report(descriptor);
58
+ },
59
+ });
60
+ return wrapped;
61
+ }
62
+
63
+ /**
64
+ * Resolve the repo-relative path of the file being linted.
65
+ * Walks up from the file looking for pnpm-workspace.yaml.
66
+ */
67
+ function repoRelative(context) {
68
+ const filePath = context.getFilename ? context.getFilename() : context.filename;
69
+ if (!filePath || filePath === "<input>") {
70
+ return "<unknown>";
71
+ }
72
+ const abs = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
73
+ let dir = path.dirname(abs);
74
+ // Walk to the filesystem root (unbounded): deeply nested apps (e.g.
75
+ // frontend/src/features/... in a monorepo) can be 9+ levels below the
76
+ // workspace root, so a fixed hop limit would miss pnpm-workspace.yaml.
77
+ for (;;) {
78
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
79
+ return path.relative(dir, abs).split(path.sep).join("/");
80
+ }
81
+ const parent = path.dirname(dir);
82
+ if (parent === dir) break;
83
+ dir = parent;
84
+ }
85
+ return abs.split(path.sep).join("/");
86
+ }
87
+
88
+ /**
89
+ * Walk up from a starting directory to the repo root (pnpm-workspace.yaml).
90
+ */
91
+ function findRepoRoot(startDir) {
92
+ let dir = startDir;
93
+ // Walk to the filesystem root (unbounded) — see repoRelative.
94
+ for (;;) {
95
+ if (fs.existsSync(path.join(dir, "pnpm-workspace.yaml"))) {
96
+ return dir;
97
+ }
98
+ const parent = path.dirname(dir);
99
+ if (parent === dir) break;
100
+ dir = parent;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ module.exports = { applyDebt, loadLedger, repoRelative, findRepoRoot };
package/dom.js ADDED
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * dom.js
5
+ *
6
+ * JSX element inspection: attribute lookup, className / style resolution,
7
+ * and Signature construction for a JSX element (tag, normalized CSS styles,
8
+ * actions, a11y markers, data-* attributes).
9
+ *
10
+ * Everything here works over an ESTree `JSXOpeningElement` node plus the
11
+ * eslint-scope scope of the element (dynamic values are resolved via
12
+ * resolve.js).
13
+ */
14
+
15
+ const sig = require("./signature");
16
+ const { ACTION_ATTR_NAMES } = require("./config");
17
+ const { resolveStringCandidates } = require("./resolve");
18
+
19
+ /** Find a JSX attribute; returns the attribute node or null. */
20
+ function attrOf(opening, name) {
21
+ return (opening.attributes || []).find((a) => a.type === "JSXAttribute" && a.name && a.name.name === name) || null;
22
+ }
23
+
24
+ /**
25
+ * Static attribute value as a string (role, contentEditable, ...).
26
+ * Returns null when the attribute is absent, `true` for a bare attribute,
27
+ * the literal string for a Literal value, and "<expr>" for a dynamic one.
28
+ */
29
+ function attrValueOf(opening, name) {
30
+ const attr = attrOf(opening, name);
31
+ if (!attr) return null;
32
+ if (attr.value == null) return true; // bare attribute, e.g. contentEditable
33
+ if (attr.value.type === "Literal") return String(attr.value.value ?? "");
34
+ return "<expr>";
35
+ }
36
+
37
+ /**
38
+ * Resolve a className attribute to candidate class strings (literal,
39
+ * variable, ternary, cn(), imported constant).
40
+ */
41
+ function classNameCandidates(opening, scope, fileDir) {
42
+ const attr = attrOf(opening, "className");
43
+ if (!attr || !attr.value) return [];
44
+ if (attr.value.type === "Literal") return [String(attr.value.value ?? "")];
45
+ if (attr.value.type === "JSXExpressionContainer") {
46
+ const c = resolveStringCandidates(attr.value.expression, scope, 0, fileDir);
47
+ return c || [];
48
+ }
49
+ return [];
50
+ }
51
+
52
+ /**
53
+ * Resolve a style attribute to a map of property name -> candidate string
54
+ * values (inline style object, variable, ternary, import).
55
+ */
56
+ function stylePropsOf(opening, scope, fileDir) {
57
+ const attr = attrOf(opening, "style");
58
+ if (!attr || !attr.value || attr.value.type !== "JSXExpressionContainer") return {};
59
+ const expr = attr.value.expression;
60
+ const out = {};
61
+ const collect = (e, depth) => {
62
+ if (!e || depth > 3) return;
63
+ if (e.type === "ObjectExpression") {
64
+ for (const p of e.properties) {
65
+ if (p.type !== "Property" || !p.key || !p.key.name) continue;
66
+ const c = resolveStringCandidates(p.value, scope, depth + 1, fileDir);
67
+ if (c) out[p.key.name] = (out[p.key.name] || []).concat(c);
68
+ }
69
+ return;
70
+ }
71
+ if (e.type === "Identifier") {
72
+ for (let s = scope; s; s = s.upper) {
73
+ const v = s.variables.find((x) => x.name === e.name);
74
+ if (!v) continue;
75
+ for (const def of v.defs || []) {
76
+ if (def && def.node && def.node.type === "VariableDeclarator" && def.node.init) collect(def.node.init, depth + 1);
77
+ }
78
+ }
79
+ return;
80
+ }
81
+ if (e.type === "ConditionalExpression") {
82
+ collect(e.consequent, depth + 1);
83
+ collect(e.alternate, depth + 1);
84
+ }
85
+ };
86
+ collect(expr, 0);
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * Build the flat Signature of a JSX element: tag, normalized CSS styles
92
+ * (tailwind className + inline style), actions (event handlers), a11y
93
+ * markers, data-* attributes.
94
+ */
95
+ function buildSignature(tag, opening, scope, fileDir) {
96
+ const styles = {};
97
+ for (const cls of classNameCandidates(opening, scope, fileDir)) {
98
+ Object.assign(styles, sig.twToStyles(cls));
99
+ }
100
+ for (const [prop, values] of Object.entries(stylePropsOf(opening, scope, fileDir))) {
101
+ const key = sig.camelToKebab(prop);
102
+ styles[key] = (styles[key] || []).concat(values);
103
+ }
104
+ const actions = [];
105
+ const a11y = [];
106
+ const data = [];
107
+ for (const attr of opening.attributes) {
108
+ if (attr.type !== "JSXAttribute" || !attr.name) continue;
109
+ const name = attr.name.name;
110
+ // Normalize the event handler to its action name the SAME way the
111
+ // catalog scanner does (scanner/extract.js: `aname.slice(2).toLowerCase()`),
112
+ // so `onClick` -> "click" on BOTH sides. Without the slice the runtime
113
+ // candidate carries "onclick" while the catalog stores "click", and the
114
+ // decision matrix's action-overlap check never matches.
115
+ if (ACTION_ATTR_NAMES.has(name)) actions.push(name.slice(2).toLowerCase());
116
+ else if (name === "role") a11y.push("role:" + String(attrValueOf(opening, "role")));
117
+ else if (name.startsWith("aria-")) a11y.push(name + ":" + String(attrValueOf(opening, name)));
118
+ else if (name === "type" && (tag === "input" || tag === "button")) a11y.push("type:" + String(attrValueOf(opening, "type")));
119
+ else if (name === "contentEditable") a11y.push("contentEditable");
120
+ else if (name.startsWith("data-")) data.push(name + ":" + String(attrValueOf(opening, name)));
121
+ }
122
+ return { tag, styles, actions, a11y, data };
123
+ }
124
+
125
+ /**
126
+ * Name of the nearest enclosing component declaration (the JSX element's
127
+ * owning component: `const Foo = () => <...>` / `function Foo() { return <...> }`
128
+ * / `export const Foo = ...`). Returns null for the file's top-level JSX.
129
+ * Used by includeDynamic to map an element to the rendered-DOM signature of
130
+ * the component it belongs to.
131
+ *
132
+ * @param {object} node any AST node inside the component
133
+ * @param {object} sourceCode the ESLint SourceCode (provides ancestors)
134
+ * @returns {string|null} the component name
135
+ */
136
+ function enclosingComponentName(node, sourceCode) {
137
+ const ancestors = sourceCode.getAncestors ? sourceCode.getAncestors(node) : [];
138
+ for (let i = ancestors.length - 1; i >= 0; i -= 1) {
139
+ const a = ancestors[i];
140
+ if (!a || !a.type) continue;
141
+ if (a.type === "VariableDeclarator" && a.id && a.id.name) {
142
+ const init = a.init;
143
+ if (init && (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")) return a.id.name;
144
+ }
145
+ if (a.type === "FunctionDeclaration" && a.id && a.id.name) return a.id.name;
146
+ if (a.type === "ClassDeclaration" && a.id && a.id.name) return a.id.name;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ module.exports = {
152
+ attrOf,
153
+ attrValueOf,
154
+ classNameCandidates,
155
+ stylePropsOf,
156
+ buildSignature,
157
+ enclosingComponentName,
158
+ };