@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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
|
|
6
|
+
const { isIgnored, DEFAULT_EXCLUDE } = require("../config");
|
|
7
|
+
const { walk } = require("./walk");
|
|
8
|
+
const { signaturesFromFile } = require("./extract");
|
|
9
|
+
const { buildDirNameMap, componentNameFor } = require("./attribute");
|
|
10
|
+
const { dedupeAndSort } = require("./output");
|
|
11
|
+
|
|
12
|
+
const relPosix = (repoRoot, abs) => path.relative(repoRoot, abs).split(path.sep).join("/");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Scan canonical roots and return the deduped catalog components array.
|
|
16
|
+
* Shared by the CLI (writes to file) and the ESLint rule (in-memory fallback).
|
|
17
|
+
*
|
|
18
|
+
* @param {string[]} roots repo-relative canonical directories
|
|
19
|
+
* @param {string} repoRoot absolute repo root
|
|
20
|
+
* @param {string[]} [include] minimatch globs (empty = everything not excluded)
|
|
21
|
+
* @param {string[]} [exclude] minimatch globs (default: node_modules, dist, build, .git)
|
|
22
|
+
* @param {string} [registryPath] optional registry file for dir-name attribution
|
|
23
|
+
* @returns {{ components: object[], scanned: number }}
|
|
24
|
+
*/
|
|
25
|
+
function generateCatalog(roots, repoRoot, include, exclude, registryPath) {
|
|
26
|
+
const ex = exclude || DEFAULT_EXCLUDE;
|
|
27
|
+
const inc = include || [];
|
|
28
|
+
const shouldSkip = (abs) => isIgnored(relPosix(repoRoot, abs), inc, ex);
|
|
29
|
+
const dirNameMap = buildDirNameMap(repoRoot, registryPath || null);
|
|
30
|
+
|
|
31
|
+
const components = [];
|
|
32
|
+
let scanned = 0;
|
|
33
|
+
|
|
34
|
+
for (const relRoot of roots) {
|
|
35
|
+
const rootDir = path.isAbsolute(relRoot) ? relRoot : path.resolve(repoRoot, relRoot);
|
|
36
|
+
if (!fs.existsSync(rootDir)) continue;
|
|
37
|
+
for (const file of walk(rootDir)) {
|
|
38
|
+
if (shouldSkip(file)) continue;
|
|
39
|
+
if (/\.(stories|test|spec)\.[tj]sx?$/.test(file)) continue;
|
|
40
|
+
scanned += 1;
|
|
41
|
+
let sigs;
|
|
42
|
+
try {
|
|
43
|
+
sigs = signaturesFromFile(file);
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const name = componentNameFor(file, repoRoot, dirNameMap);
|
|
48
|
+
const relFile = path.relative(repoRoot, file).split(path.sep).join("/");
|
|
49
|
+
for (const s of sigs) {
|
|
50
|
+
components.push({ name, path: relFile, ...s });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { components: dedupeAndSort(components), scanned };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { generateCatalog };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* scanner/load-config.js
|
|
5
|
+
*
|
|
6
|
+
* Loads the rule options from the consumer's own linter config so the CLI
|
|
7
|
+
* (bin/react-component-uniqueness.js) needs no arguments:
|
|
8
|
+
*
|
|
9
|
+
* 1. md-code-react-component-uniqueness.config.js (plain options object, CJS or ESM)
|
|
10
|
+
* 2. eslint.config.js / eslint.config.mjs / eslint.config.cjs / .eslintrc.*
|
|
11
|
+
* — the options of the "md-code/react-component-uniqueness"
|
|
12
|
+
* rule, found in any flat-config entry or in the legacy rules map.
|
|
13
|
+
*
|
|
14
|
+
* The CLI still wins: main.js applies args > config > defaults.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require("node:fs");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
|
|
20
|
+
const RULE_NAME = "md-code/react-component-uniqueness";
|
|
21
|
+
const RULE_SHORT = "md-code";
|
|
22
|
+
|
|
23
|
+
const FLAT_CANDIDATES = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts"];
|
|
24
|
+
const LEGACY_CANDIDATES = [".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml"];
|
|
25
|
+
|
|
26
|
+
function loadModule(file) {
|
|
27
|
+
const src = fs.readFileSync(file, "utf8");
|
|
28
|
+
if (file.endsWith(".json")) return JSON.parse(src);
|
|
29
|
+
if (/^\s*(export\s+(default|const|let|var)|import\s)/m.test(src)) {
|
|
30
|
+
const { pathToFileURL } = require("node:url");
|
|
31
|
+
return import(pathToFileURL(file).href).catch((esmErr) => {
|
|
32
|
+
try {
|
|
33
|
+
return require(file);
|
|
34
|
+
} catch {
|
|
35
|
+
throw esmErr;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return Promise.resolve().then(() => require(file));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function loadFlatOptions(file) {
|
|
43
|
+
const mod = await loadModule(file);
|
|
44
|
+
const list = mod && mod.default !== undefined ? mod.default : mod;
|
|
45
|
+
if (!Array.isArray(list)) return null;
|
|
46
|
+
for (const entry of list) {
|
|
47
|
+
if (!entry || typeof entry !== "object" || !entry.rules) continue;
|
|
48
|
+
const v = entry.rules[RULE_NAME] ?? entry.rules[RULE_SHORT];
|
|
49
|
+
if (v === undefined || v === null) continue;
|
|
50
|
+
if (Array.isArray(v)) return v[1] && typeof v[1] === "object" ? v[1] : {};
|
|
51
|
+
if (typeof v === "object") return v;
|
|
52
|
+
return {};
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadLegacyOptions(file) {
|
|
58
|
+
const mod = await loadModule(file);
|
|
59
|
+
const rules = (mod && mod.rules) || {};
|
|
60
|
+
const v = rules[RULE_NAME] ?? rules[RULE_SHORT];
|
|
61
|
+
if (v === undefined || v === null) return null;
|
|
62
|
+
if (Array.isArray(v)) return v[1] && typeof v[1] === "object" ? v[1] : {};
|
|
63
|
+
if (typeof v === "object") return v;
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Find the rule options in the consumer's config files (from dir upward).
|
|
69
|
+
*
|
|
70
|
+
* @param {string} dir absolute directory to start from
|
|
71
|
+
* @returns {Promise<{ options: object, source: string }|null>}
|
|
72
|
+
*/
|
|
73
|
+
async function loadRuleOptions(dir) {
|
|
74
|
+
let d = dir;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const standalone = path.join(d, "md-code-react-component-uniqueness.config.js");
|
|
77
|
+
if (fs.existsSync(standalone)) {
|
|
78
|
+
const mod = await loadModule(standalone);
|
|
79
|
+
const options = mod && mod.default !== undefined ? mod.default : mod;
|
|
80
|
+
if (options && typeof options === "object") return { options, source: standalone };
|
|
81
|
+
}
|
|
82
|
+
for (const name of FLAT_CANDIDATES) {
|
|
83
|
+
const file = path.join(d, name);
|
|
84
|
+
if (!fs.existsSync(file)) continue;
|
|
85
|
+
try {
|
|
86
|
+
const options = await loadFlatOptions(file);
|
|
87
|
+
if (options) return { options, source: file };
|
|
88
|
+
} catch {
|
|
89
|
+
// unreadable / unresolvable config — keep looking upward
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const name of LEGACY_CANDIDATES) {
|
|
93
|
+
const file = path.join(d, name);
|
|
94
|
+
if (!fs.existsSync(file)) continue;
|
|
95
|
+
try {
|
|
96
|
+
const options = await loadLegacyOptions(file);
|
|
97
|
+
if (options) return { options, source: file };
|
|
98
|
+
} catch {
|
|
99
|
+
// keep looking
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const parent = path.dirname(d);
|
|
103
|
+
if (parent === d) break;
|
|
104
|
+
d = parent;
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { loadRuleOptions, RULE_NAME };
|
package/scanner/main.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* scanner/main.js
|
|
6
|
+
*
|
|
7
|
+
* Orchestrator: scans the canonical packages, extracts component element
|
|
8
|
+
* signatures, attributes each to a component, and writes the catalog JSON
|
|
9
|
+
* that the ESLint rule matches app-code against.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scanner/main.js [options]
|
|
13
|
+
*
|
|
14
|
+
* Options:
|
|
15
|
+
* --roots <dir>[:<dir>...] REQUIRED. Scan roots (repo-relative or
|
|
16
|
+
* absolute) — the canonical component directories.
|
|
17
|
+
* --out <file> output path. Default: reports/component-catalog.json
|
|
18
|
+
* --registry <file> registry path (for name attribution).
|
|
19
|
+
* Default: reports/component-registry.json
|
|
20
|
+
* --repo-root <dir> repo root. Default: discovered by walking up
|
|
21
|
+
* for pnpm-workspace.yaml from cwd.
|
|
22
|
+
* --check do not write; exit 1 when the catalog would
|
|
23
|
+
* change (for CI gates).
|
|
24
|
+
* --report [file] write the component-level Markdown report of
|
|
25
|
+
* app-code duplicates. With a value: that path.
|
|
26
|
+
* Without: reports/component-duplicates-components.md
|
|
27
|
+
* --app-roots <dir>[:<dir>..] app-code roots scanned for the report.
|
|
28
|
+
* Default: the repo root (canonical packages
|
|
29
|
+
* are skipped).
|
|
30
|
+
* --verbose Include the "Filtered out" section in the
|
|
31
|
+
* report (hidden by default).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require("node:fs");
|
|
35
|
+
const path = require("node:path");
|
|
36
|
+
|
|
37
|
+
const { findRepoRoot } = require("../debt");
|
|
38
|
+
const { isIgnored, DEFAULT_EXCLUDE } = require("../config");
|
|
39
|
+
const { loadRuleOptions } = require("./load-config");
|
|
40
|
+
const { walk } = require("./walk");
|
|
41
|
+
const { componentsFromFile, isHtmlTag } = require("./component");
|
|
42
|
+
const { matchComponents, matchJsxBlocks, computeGenericTokens } = require("./matching");
|
|
43
|
+
const { clusterComponents, matchFamilies } = require("./clusters");
|
|
44
|
+
const { dedupeAndSort, writeCatalog } = require("./output");
|
|
45
|
+
const { generateCatalog } = require("./generate");
|
|
46
|
+
const { applyFilters, FILTERS } = require("./filters");
|
|
47
|
+
const { folderRank } = require("../config");
|
|
48
|
+
const { CANON_DUP_STRICT } = require("../thresholds");
|
|
49
|
+
|
|
50
|
+
function fail(message) {
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
console.error(`[react-component-uniqueness] ${message}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Parse command-line arguments into a config object.
|
|
58
|
+
*
|
|
59
|
+
* @param {string[]} argv process.argv.slice(2)
|
|
60
|
+
* @returns {object} { roots, out, registry, repoRoot, check }
|
|
61
|
+
*/
|
|
62
|
+
function parseArgs(argv) {
|
|
63
|
+
const cfg = { roots: null, out: null, registry: null, repoRoot: null, check: false, report: null, appRoots: null, verbose: false };
|
|
64
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
65
|
+
const a = argv[i];
|
|
66
|
+
if (a === "--check") {
|
|
67
|
+
cfg.check = true;
|
|
68
|
+
} else if (a === "--report") {
|
|
69
|
+
const next = argv[i + 1];
|
|
70
|
+
if (next && !next.startsWith("--")) {
|
|
71
|
+
cfg.report = argv[++i];
|
|
72
|
+
} else {
|
|
73
|
+
cfg.report = "reports/component-duplicates-components.md"; // default
|
|
74
|
+
}
|
|
75
|
+
} else if (a === "--roots") {
|
|
76
|
+
if (!argv[i + 1]) fail("--roots requires a value (colon-separated list of directories)");
|
|
77
|
+
cfg.roots = argv[++i].split(":").filter(Boolean);
|
|
78
|
+
} else if (a === "--out") {
|
|
79
|
+
if (!argv[i + 1]) fail("--out requires a value");
|
|
80
|
+
cfg.out = argv[++i];
|
|
81
|
+
} else if (a === "--registry") {
|
|
82
|
+
if (!argv[i + 1]) fail("--registry requires a value");
|
|
83
|
+
cfg.registry = argv[++i];
|
|
84
|
+
} else if (a === "--repo-root") {
|
|
85
|
+
if (!argv[i + 1]) fail("--repo-root requires a value");
|
|
86
|
+
cfg.repoRoot = argv[++i];
|
|
87
|
+
} else if (a === "--app-roots") {
|
|
88
|
+
if (!argv[i + 1]) fail("--app-roots requires a value (colon-separated list of directories)");
|
|
89
|
+
cfg.appRoots = argv[++i].split(":").filter(Boolean);
|
|
90
|
+
} else if (a === "--verbose") {
|
|
91
|
+
cfg.verbose = true;
|
|
92
|
+
} else if (a === "--help" || a === "-h") {
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.log("Usage: md-code-react-component-uniqueness [--roots a:b] [--out file] [--registry file] [--repo-root dir] [--check] [--report [file.md]] [--app-roots a:b] [--verbose] (report = component-level duplicate funnel)");
|
|
95
|
+
// eslint-disable-next-line no-console
|
|
96
|
+
console.log("Without --roots, the options are read from the consumer's eslint.config.js (rule md-code/react-component-uniqueness) or md-code-react-component-uniqueness.config.js.");
|
|
97
|
+
process.exit(0);
|
|
98
|
+
} else {
|
|
99
|
+
fail(`unknown argument: ${a} (see --help)`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return cfg;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Resolve a possibly-relative path against the repo root. */
|
|
106
|
+
function resolveAgainst(value, repoRoot) {
|
|
107
|
+
return path.isAbsolute(value) ? value : path.resolve(repoRoot, value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function main() {
|
|
111
|
+
const args = parseArgs(process.argv.slice(2));
|
|
112
|
+
const cwd = process.cwd();
|
|
113
|
+
|
|
114
|
+
const loaded = args.roots ? null : await loadRuleOptions(cwd);
|
|
115
|
+
const configOptions = loaded ? loaded.options : {};
|
|
116
|
+
if (loaded) {
|
|
117
|
+
// eslint-disable-next-line no-console
|
|
118
|
+
console.log(`[react-component-uniqueness] config from ${loaded.source}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const repoRoot = args.repoRoot ? path.resolve(cwd, args.repoRoot) : findRepoRoot(cwd) || cwd;
|
|
122
|
+
|
|
123
|
+
// No default scan roots: the repo layout is the consumer's knowledge.
|
|
124
|
+
// CLI --roots always wins over the rule options' componentsFolder.
|
|
125
|
+
const configRoots = (configOptions.componentsFolder || []).map((d) => (d && typeof d === "object" ? d.path : d)).map((d) => String(d).replace(/\\/g, "/").replace(/\/+$/, ""));
|
|
126
|
+
const roots = args.roots || configRoots;
|
|
127
|
+
if (!roots || roots.length === 0) {
|
|
128
|
+
fail("no scan roots: pass --roots <dir1>:<dir2> or set componentsFolder in the rule options (eslint.config.js / md-code-react-component-uniqueness.config.js)");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const outPath = resolveAgainst(args.out || configOptions.catalogPath || "reports/component-catalog.json", repoRoot);
|
|
132
|
+
const registryPath = args.registry ? resolveAgainst(args.registry, repoRoot) : path.join(repoRoot, "reports", "component-registry.json");
|
|
133
|
+
|
|
134
|
+
const include = configOptions.include || [];
|
|
135
|
+
const exclude = configOptions.exclude || DEFAULT_EXCLUDE;
|
|
136
|
+
const shouldSkip = (abs) => isIgnored(path.relative(repoRoot, abs).split(path.sep).join("/"), include, exclude);
|
|
137
|
+
|
|
138
|
+
const { components, scanned } = generateCatalog(roots, repoRoot, include, exclude, registryPath);
|
|
139
|
+
|
|
140
|
+
const final = dedupeAndSort(components);
|
|
141
|
+
|
|
142
|
+
if (args.check) {
|
|
143
|
+
let current = null;
|
|
144
|
+
try {
|
|
145
|
+
current = JSON.parse(fs.readFileSync(outPath, "utf8"));
|
|
146
|
+
} catch {
|
|
147
|
+
// missing catalog — a change (unless the scan produced nothing)
|
|
148
|
+
}
|
|
149
|
+
const same = current && JSON.stringify(current.components || []) === JSON.stringify(final);
|
|
150
|
+
if (!same) {
|
|
151
|
+
fail(`catalog is out of date (${final.length} signature(s) expected at ${path.relative(repoRoot, outPath)}); regenerate it (node scanner/main.js)`);
|
|
152
|
+
}
|
|
153
|
+
// eslint-disable-next-line no-console
|
|
154
|
+
console.log(`[react-component-uniqueness] catalog up to date (${final.length} signature(s))`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
writeCatalog(outPath, final);
|
|
159
|
+
const tagCount = new Set(final.map((c) => c.tag)).size;
|
|
160
|
+
// eslint-disable-next-line no-console
|
|
161
|
+
console.log(`[react-component-uniqueness] wrote ${path.relative(repoRoot, outPath)} (${final.length} signature(s), ${tagCount} tag(s), ${scanned} file(s) scanned)`);
|
|
162
|
+
|
|
163
|
+
if (args.report) {
|
|
164
|
+
const reportPath = path.isAbsolute(args.report) ? args.report : path.resolve(process.cwd(), args.report);
|
|
165
|
+
const comp = buildComponentReport(args, repoRoot, roots, shouldSkip, configOptions);
|
|
166
|
+
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
167
|
+
fs.writeFileSync(reportPath, renderComponentReport(comp, args.verbose));
|
|
168
|
+
// eslint-disable-next-line no-console
|
|
169
|
+
console.log(`[react-component-uniqueness] wrote report ${reportPath} (${comp.matches.length} duplicate component(s) across ${comp.files} file(s))`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rankOfPath(relPath, ranks) {
|
|
174
|
+
for (const [dir, rank] of [...ranks].sort((a, b) => b[0].length - a[0].length)) {
|
|
175
|
+
if (relPath.startsWith(dir)) return rank;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Walk the canonical roots: every file (for the canonicalFiles set) plus the
|
|
182
|
+
* parseable ones (stories/tests/specs excluded).
|
|
183
|
+
*/
|
|
184
|
+
function collectCanonicalFiles(roots, repoRoot, shouldSkip) {
|
|
185
|
+
const canonicalFiles = new Set();
|
|
186
|
+
const canonFiles = [];
|
|
187
|
+
for (const relRoot of roots) {
|
|
188
|
+
const rootDir = resolveAgainst(relRoot, repoRoot);
|
|
189
|
+
if (!fs.existsSync(rootDir)) continue;
|
|
190
|
+
for (const file of walk(rootDir)) {
|
|
191
|
+
if (shouldSkip(file)) continue;
|
|
192
|
+
const rel = path.relative(repoRoot, file).split(path.sep).join("/");
|
|
193
|
+
canonicalFiles.add(rel);
|
|
194
|
+
if (/\.(stories|test|spec)\.[tj]sx?$/.test(file)) continue;
|
|
195
|
+
canonFiles.push({ file, rel });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return { canonicalFiles, canonFiles };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse canonical components in two passes: the first pass discovers the
|
|
203
|
+
* canonical names, the second re-parses with them so `usesCanonical` is
|
|
204
|
+
* populated inside canonical files (needed by the wrapper rule).
|
|
205
|
+
*/
|
|
206
|
+
function parseCanonicalComponents(canonFiles, ranks) {
|
|
207
|
+
const parse = (names) => {
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const { file, rel } of canonFiles) {
|
|
210
|
+
let comps;
|
|
211
|
+
try {
|
|
212
|
+
comps = componentsFromFile(file, names);
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
for (const c of comps) out.push({ ...c, path: rel, rank: rankOfPath(rel, ranks) });
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
const first = parse(null);
|
|
221
|
+
|
|
222
|
+
return parse(new Set(first.map((c) => c.name)));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Canonical-vs-canonical pass: strict thresholds, no basename index, and the
|
|
227
|
+
* wrapper rule (a higher-rank component that uses the lower-rank one is a
|
|
228
|
+
* wrapper, not a duplicate). Returns the matches and the keys of the
|
|
229
|
+
* components that are duplicates themselves.
|
|
230
|
+
*/
|
|
231
|
+
function findCanonicalDuplicates(canonComponents, ranks, thresholds) {
|
|
232
|
+
const matches = [];
|
|
233
|
+
const dupKeys = new Set();
|
|
234
|
+
if (ranks.size === 0) return { matches, dupKeys };
|
|
235
|
+
|
|
236
|
+
const generic = computeGenericTokens(canonComponents);
|
|
237
|
+
const seenPairs = new Set();
|
|
238
|
+
const pushCanonDup = (a, b, tier, reason) => {
|
|
239
|
+
if (a.rank === null || b.rank === null || a.rank === b.rank) return;
|
|
240
|
+
const dup = a.rank > b.rank ? a : b;
|
|
241
|
+
const target = a.rank > b.rank ? b : a;
|
|
242
|
+
if ((dup.usesCanonical || []).includes(target.name)) return;
|
|
243
|
+
const key = [`${dup.path}:${dup.name}`, `${target.path}:${target.name}`].sort().join("|");
|
|
244
|
+
if (seenPairs.has(key)) return;
|
|
245
|
+
seenPairs.add(key);
|
|
246
|
+
dupKeys.add(`${dup.path}:${dup.name}`);
|
|
247
|
+
matches.push({
|
|
248
|
+
app: dup,
|
|
249
|
+
canon: target,
|
|
250
|
+
tier: "canon-dup",
|
|
251
|
+
direction: `move down to rank ${target.rank}`,
|
|
252
|
+
reason: tier === "jsx-block" ? `rank ${dup.rank} hand-draws the structure of rank ${target.rank} (${reason})` : `rank ${dup.rank} duplicates rank ${target.rank} (${reason})`,
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
const canonTh = { ...(thresholds || {}), ...CANON_DUP_STRICT };
|
|
256
|
+
for (const m of matchComponents(canonComponents, canonComponents, { skipSelf: true, skipBasename: true, thresholds: canonTh })) {
|
|
257
|
+
pushCanonDup(m.app, m.canon, m.tier, m.reason);
|
|
258
|
+
}
|
|
259
|
+
for (const m of matchJsxBlocks(canonComponents, canonComponents, generic, { skipSelf: true, thresholds: canonTh })) {
|
|
260
|
+
pushCanonDup(m.app, m.canon, "jsx-block", m.reason);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { matches, dupKeys };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Walk the app roots, excluding canonical dirs, tests, _medplum and iris. */
|
|
267
|
+
function collectAppFiles(appRoots, repoRoot, shouldSkip, skipDirs) {
|
|
268
|
+
const files = [];
|
|
269
|
+
let count = 0;
|
|
270
|
+
for (const relRoot of appRoots) {
|
|
271
|
+
const rootDir = resolveAgainst(relRoot, repoRoot);
|
|
272
|
+
if (!fs.existsSync(rootDir)) continue;
|
|
273
|
+
for (const file of walk(rootDir)) {
|
|
274
|
+
if (shouldSkip(file)) continue;
|
|
275
|
+
if ([...skipDirs].some((d) => file === d || file.startsWith(d + path.sep))) continue;
|
|
276
|
+
const rel = path.relative(repoRoot, file).split(path.sep).join("/");
|
|
277
|
+
if (/\.(stories|test|spec)\.[tj]sx?$/.test(file)) continue;
|
|
278
|
+
if (/_medplum\//.test(rel) || /(^|\/)iris\//.test(rel)) continue;
|
|
279
|
+
count += 1;
|
|
280
|
+
files.push({ file, rel });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { files, count };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Parse app files into components, filling `usesCanonical` from canonNames. */
|
|
287
|
+
function parseAppComponents(files, canonNames) {
|
|
288
|
+
const comps = [];
|
|
289
|
+
for (const { file, rel } of files) {
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = componentsFromFile(file, canonNames);
|
|
293
|
+
} catch {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
for (const c of parsed) comps.push({ ...c, path: rel });
|
|
297
|
+
}
|
|
298
|
+
return comps;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** All four app-vs-canonical matching tiers in one step. */
|
|
302
|
+
function matchAppComponents(appComponents, uniqueCanon, canonicalFiles, thresholds) {
|
|
303
|
+
const matches = matchComponents(appComponents, uniqueCanon, {
|
|
304
|
+
canonicalFiles,
|
|
305
|
+
skipWrappers: true,
|
|
306
|
+
thresholds,
|
|
307
|
+
isHtmlOnly: (c) => c.rootTags.length > 0 && c.rootTags.every((t) => isHtmlTag(t)) && (c.elementProps || []).every((e) => (e.tokens || []).length === 0),
|
|
308
|
+
});
|
|
309
|
+
for (const m of matchFamilies(appComponents, uniqueCanon, clusterComponents(uniqueCanon), { thresholds })) matches.push(m);
|
|
310
|
+
for (const m of matchJsxBlocks(appComponents, uniqueCanon, computeGenericTokens(uniqueCanon), { thresholds })) matches.push(m);
|
|
311
|
+
|
|
312
|
+
return matches;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The report pipeline, read top to bottom:
|
|
317
|
+
*
|
|
318
|
+
* scan canonical files
|
|
319
|
+
* -> parse canonical components (two passes)
|
|
320
|
+
* -> find canonical-vs-canonical duplicates (strict thresholds)
|
|
321
|
+
* -> keep only the unique canonicals
|
|
322
|
+
* -> scan app files (canonical dirs / tests / _medplum / iris excluded)
|
|
323
|
+
* -> parse app components
|
|
324
|
+
* -> match app vs unique canonicals (name / structural / family / jsx)
|
|
325
|
+
* -> apply filters
|
|
326
|
+
*/
|
|
327
|
+
function buildComponentReport(args, repoRoot, roots, shouldSkip, configOptions) {
|
|
328
|
+
const ranks = folderRank(configOptions.componentsFolder || []);
|
|
329
|
+
const thresholds = configOptions.thresholds;
|
|
330
|
+
|
|
331
|
+
const { canonicalFiles, canonFiles } = collectCanonicalFiles(roots, repoRoot, shouldSkip);
|
|
332
|
+
const canonComponents = parseCanonicalComponents(canonFiles, ranks);
|
|
333
|
+
const { matches: canonDup, dupKeys } = findCanonicalDuplicates(canonComponents, ranks, thresholds);
|
|
334
|
+
const uniqueCanon = canonComponents.filter((c) => !dupKeys.has(`${c.path}:${c.name}`));
|
|
335
|
+
|
|
336
|
+
const skipDirs = new Set(roots.map((r) => path.resolve(repoRoot, r)));
|
|
337
|
+
const { files, count } = collectAppFiles(args.appRoots || ["."], repoRoot, shouldSkip, skipDirs);
|
|
338
|
+
const appComponents = parseAppComponents(files, new Set(canonComponents.map((c) => c.name)));
|
|
339
|
+
|
|
340
|
+
const matches = matchAppComponents(appComponents, uniqueCanon, canonicalFiles, thresholds);
|
|
341
|
+
const { kept, dropped } = applyFilters(matches);
|
|
342
|
+
|
|
343
|
+
return { matches: kept, dropped, canonDup, files: count, canonCount: canonComponents.length, uniqueCanonCount: uniqueCanon.length, appCount: appComponents.length };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const TIERS = ["name", "name-fuzzy", "structural-exact", "structural-similar", "family", "jsx-block"];
|
|
347
|
+
const TIER_LABEL = {
|
|
348
|
+
name: "Name match",
|
|
349
|
+
"name-fuzzy": "Name contains canonical",
|
|
350
|
+
"structural-exact": "Structural (exact)",
|
|
351
|
+
"structural-similar": "Structural (similar)",
|
|
352
|
+
family: "Family (hand-built library piece)",
|
|
353
|
+
"jsx-block": "JSX block (canonical drawn by hand inside)",
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
function renderHeader(comp) {
|
|
357
|
+
const lines = [];
|
|
358
|
+
lines.push("# Component duplicates (component-level funnel)");
|
|
359
|
+
lines.push("");
|
|
360
|
+
lines.push(`Scanned ${comp.files} app file(s), ${comp.appCount} app component(s) against ${comp.canonCount} canonical component(s)${comp.uniqueCanonCount !== undefined && comp.uniqueCanonCount !== comp.canonCount ? ` (${comp.uniqueCanonCount} unique after removing ${comp.canonCount - comp.uniqueCanonCount} canonical duplicate(s))` : ""}.`);
|
|
361
|
+
lines.push(`Found ${comp.matches.length} duplicate component(s)${comp.dropped && comp.dropped.length > 0 ? ` (after filtering out ${comp.dropped.length} match(es))` : ""}.`);
|
|
362
|
+
lines.push("");
|
|
363
|
+
|
|
364
|
+
return lines;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderCanonDupSection(rows) {
|
|
368
|
+
const lines = [];
|
|
369
|
+
lines.push(`## Canonical duplicates (layered folders) (${rows.length})`);
|
|
370
|
+
lines.push("");
|
|
371
|
+
lines.push("A canonical component duplicates another canonical one. The duplicate must live in the LOWER layer (smaller rank) — the upper layer should wrap it, not re-draw it. Duplicates are removed from the canonical set before the app pass, so the app is only ever compared against unique canonicals.");
|
|
372
|
+
lines.push("");
|
|
373
|
+
lines.push("| Duplicate | Location | Duplicates canonical | Canonical location | Action | Why |");
|
|
374
|
+
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
375
|
+
const sorted = [...rows].sort((a, b) => a.canon.name.localeCompare(b.canon.name) || a.app.path.localeCompare(b.app.path));
|
|
376
|
+
for (const m of sorted) {
|
|
377
|
+
lines.push(`| ${m.app.name} | \`${m.app.path}:${m.app.line}\` | ${m.canon.name} | \`${m.canon.path}\` | ${m.direction} | ${m.reason} |`);
|
|
378
|
+
}
|
|
379
|
+
lines.push("");
|
|
380
|
+
|
|
381
|
+
return lines;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function renderTierSection(tier, rows) {
|
|
385
|
+
const lines = [];
|
|
386
|
+
lines.push(`## ${TIER_LABEL[tier]} (${rows.length})`);
|
|
387
|
+
lines.push("");
|
|
388
|
+
lines.push("| Local component | Location | Duplicates canonical | Canonical location | Why |");
|
|
389
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
390
|
+
const sorted = [...rows].sort((a, b) => a.canon.name.localeCompare(b.canon.name) || a.app.path.localeCompare(b.app.path));
|
|
391
|
+
for (const m of sorted) {
|
|
392
|
+
lines.push(`| ${m.app.name} | \`${m.app.path}:${m.app.line}\` | ${m.canon.name} | \`${m.canon.path}\` | ${m.reason} |`);
|
|
393
|
+
}
|
|
394
|
+
lines.push("");
|
|
395
|
+
|
|
396
|
+
return lines;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function renderFilteredSection(dropped) {
|
|
400
|
+
const lines = [];
|
|
401
|
+
const byFilter = new Map();
|
|
402
|
+
for (const d of dropped) {
|
|
403
|
+
if (!byFilter.has(d.filter)) byFilter.set(d.filter, []);
|
|
404
|
+
byFilter.get(d.filter).push(d);
|
|
405
|
+
}
|
|
406
|
+
for (const [filter, rows] of byFilter) {
|
|
407
|
+
lines.push(`## Filtered out by \`${filter}\` (${rows.length})`);
|
|
408
|
+
lines.push("");
|
|
409
|
+
lines.push("These matched a canonical component, but the filter decided they are not duplicates. See the reason per row.");
|
|
410
|
+
lines.push("");
|
|
411
|
+
lines.push("| Local component | Location | Canonical | Canonical location | Why dropped |");
|
|
412
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
413
|
+
const sorted = [...rows].sort((a, b) => a.match.canon.name.localeCompare(b.match.canon.name) || a.match.app.path.localeCompare(b.match.app.path));
|
|
414
|
+
for (const d of sorted) {
|
|
415
|
+
lines.push(`| ${d.match.app.name} | \`${d.match.app.path}:${d.match.app.line}\` | ${d.match.canon.name} | \`${d.match.canon.path}\` | ${d.reason} |`);
|
|
416
|
+
}
|
|
417
|
+
lines.push("");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return lines;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function renderComponentReport(comp, verbose) {
|
|
424
|
+
const lines = [];
|
|
425
|
+
lines.push(...renderHeader(comp));
|
|
426
|
+
if (comp.canonDup && comp.canonDup.length > 0) lines.push(...renderCanonDupSection(comp.canonDup));
|
|
427
|
+
for (const tier of TIERS) {
|
|
428
|
+
const rows = comp.matches.filter((m) => m.tier === tier);
|
|
429
|
+
if (rows.length === 0) continue;
|
|
430
|
+
lines.push(...renderTierSection(tier, rows));
|
|
431
|
+
}
|
|
432
|
+
if (verbose && comp.dropped && comp.dropped.length > 0) lines.push(...renderFilteredSection(comp.dropped));
|
|
433
|
+
|
|
434
|
+
return lines.join("\n");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
main().catch((err) => {
|
|
438
|
+
fail(err && err.message ? err.message : String(err));
|
|
439
|
+
});
|