@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 +125 -0
- package/bin/react-component-uniqueness.js +10 -0
- package/config.js +290 -0
- package/debt.js +105 -0
- package/dom.js +158 -0
- package/errorTypes/duplicate-component.js +105 -0
- package/errorTypes/layout-primitive.js +212 -0
- package/errorTypes/raw-html.js +97 -0
- package/errorTypes/styled-in-app.js +93 -0
- package/index.js +283 -0
- package/loaders.js +123 -0
- package/package.json +59 -0
- package/plugin.js +22 -0
- package/report.js +339 -0
- package/resolve.js +374 -0
- package/scanner/attribute.js +79 -0
- package/scanner/clusters.js +96 -0
- package/scanner/component.js +346 -0
- package/scanner/extract.js +256 -0
- package/scanner/filters/drop-child-element-matches.js +16 -0
- package/scanner/filters/drop-usages.js +14 -0
- package/scanner/filters/index.js +8 -0
- package/scanner/filters/registry.js +30 -0
- package/scanner/generate.js +58 -0
- package/scanner/load-config.js +109 -0
- package/scanner/main.js +439 -0
- package/scanner/matching.js +269 -0
- package/scanner/output.js +42 -0
- package/scanner/walk.js +37 -0
- package/signature.js +663 -0
- package/snapshots/README.md +69 -0
- package/snapshots/build-playground.mjs +132 -0
- package/snapshots/run.mjs +43 -0
- package/snapshots/snapshot.mjs +204 -0
- package/thresholds.js +35 -0
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.
|
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
|
+
};
|