@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.
@@ -0,0 +1,69 @@
1
+ # snapshots/ — dynamic component catalog (headless-browser pipeline)
2
+
3
+ Static AST scanning cannot see styles/actions of **library wrappers**
4
+ (`<MuiButton>`, `<AntButton>`, `styled.button`, tailwind classes inside a
5
+ wrapper). This pipeline renders every component once in a headless browser
6
+ and captures what the DOM actually shows:
7
+
8
+ - rendered DOM tag (`MuiButton` → `button`, `Bn` → `button`, `MuiPaper` → `div`)
9
+ - computed styles (normalized: padding/margin shorthand, defaults filtered)
10
+ - click behavior (fiber `__reactProps$` walk — React delegates listeners to
11
+ the root, so `getEventListeners` is unusable)
12
+ - a11y attributes (`type`, `role`, `aria-label`, `data-testid`, `tabindex`)
13
+
14
+ ## Usage
15
+
16
+ ```sh
17
+ node <pkg>/snapshots/run.mjs --root <repoRoot> --manifest <repo>/playground/manifest.js
18
+ ```
19
+
20
+ 1. `build-playground.mjs` — esbuild bundle of the manifest → `dist/playground.html` + `dist/playground.js` (one page, every component rendered exactly once, `data-slot` per component, `data-snapshot-ready` flag after click-marker instrumentation).
21
+ 2. `snapshot.mjs` — puppeteer-core + chromium opens the page via **file:// (no HTTP server)**, waits for `data-snapshot-ready="1"`, captures per slot, writes `reports/component-catalog-dynamic.json`.
22
+
23
+ Confined browsers (snap chromium) cannot read `/tmp` or hidden dirs — the page
24
+ is copied to `~/rqu-snapshot-dist/` automatically (override with `SNAPSHOT_URL`
25
+ by passing a `--url` that the browser can already read).
26
+
27
+ ## Manifest format
28
+
29
+ CommonJS module exporting an array:
30
+
31
+ ```js
32
+ module.exports = [
33
+ { id: "can-button", label: "Button (canonical)",
34
+ import: ["/abs/path/Button.tsx", "Button"],
35
+ jsxTag: "Button", // the JSX tag the consumer writes (dynTagMap key)
36
+ props: { onClick: () => {}, children: "press" } },
37
+ ];
38
+ ```
39
+
40
+ `jsxTag` matters for renamed imports: `import { Button as Bn }` →
41
+ `jsxTag: "Bn"`. The rule resolves `Bn` → rendered `button` at lint time.
42
+
43
+ ## Rule side
44
+
45
+ ```js
46
+ rules: { "md/component-uniqueness": ["error", { includeDynamic: true }] }
47
+ ```
48
+
49
+ `includeDynamic` (default `false`) merges the dynamic catalog into the static
50
+ one:
51
+
52
+ - entries are deduped by `path` — a file already in the static catalog keeps
53
+ its static entry; dynamic only ADDS files the static pass could not fully see
54
+ - the rule resolves capitalized JSX tags (import aliases) to real DOM tags via
55
+ the `comp` field
56
+ - **only tag + styles are merged** (and styles only into thin candidates,
57
+ <3 style keys); actions/a11y are deliberately NOT merged — a plain
58
+ `<MuiButton>` without onClick must stay a plain import, not a "duplicate"
59
+ - without the dynamic catalog file the flag is a no-op (static behavior)
60
+
61
+ ## Env
62
+
63
+ | Var | Default |
64
+ | --- | --- |
65
+ | `SNAPSHOT_CHROME` | `/snap/bin/chromium` → `/usr/bin/chromium` → `google-chrome` |
66
+ | `SNAPSHOT_PUPPETEER` | consumer `node_modules/puppeteer-core`, else known hoisted paths |
67
+ | `ESBUILD_PATH` | consumer `node_modules/esbuild` |
68
+
69
+ `puppeteer-core` is an **optional** dependency — the static rule works without it.
@@ -0,0 +1,132 @@
1
+ import { createRequire } from "node:module";
2
+ import { writeFileSync, mkdirSync } from "node:fs";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ /**
7
+ * build-playground.mjs
8
+ *
9
+ * Builds the one-and-only playground page from a manifest:
10
+ * - every component is rendered exactly ONCE per run
11
+ * - the page is static (dist/playground.html + playground.js) — it can be
12
+ * snapshotted later by headless chromium with file://, no server needed
13
+ *
14
+ * Usage (from the consumer repo, e.g. /tmp/strict-test):
15
+ * node <pkg>/snapshots/build-playground.mjs --root <repoRoot> --manifest <path/to/manifest.js>
16
+ *
17
+ * The manifest is a CommonJS module exporting an array of:
18
+ * { id, label, import: [absModulePath, exportName], jsxTag?, props }
19
+ *
20
+ * Output: <root>/dist/playground.html + <root>/dist/playground.js
21
+ * (plus <root>/dist/tw.css if the manifest file lives next to an input.css —
22
+ * the consumer generates tw.css itself with @tailwindcss/cli when needed).
23
+ */
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const req = createRequire(join(here, "noop.js"));
27
+
28
+ function arg(name, dflt) {
29
+ const i = process.argv.indexOf(`--${name}`);
30
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
31
+ }
32
+ const root = arg("root", process.cwd());
33
+ const manifestPath = arg("manifest", join(root, "playground/manifest.js"));
34
+
35
+ // esbuild: consumer's node_modules first, then this package's, then env override.
36
+ const importFile = (p) => import(pathToFileURL(p).href);
37
+ let esbuild;
38
+ try {
39
+ esbuild = await importFile(req.resolve("esbuild"));
40
+ } catch {
41
+ try {
42
+ esbuild = await importFile(req.resolve(join(here, "..", "node_modules/esbuild/lib/main.js")));
43
+ } catch {
44
+ if (!process.env.ESBUILD_PATH) throw new Error("esbuild not found: install it or set ESBUILD_PATH");
45
+ const ep = process.env.ESBUILD_PATH;
46
+ esbuild = ep.startsWith("file:") || ep.startsWith("node:") ? await import(ep) : await importFile(ep);
47
+ }
48
+ }
49
+
50
+ const manifestUrl = manifestPath.startsWith("file:") ? manifestPath : pathToFileURL(manifestPath).href;
51
+ const manifest = (await import(manifestUrl)).default;
52
+ if (!Array.isArray(manifest) || !manifest.length) throw new Error(`manifest at ${manifestPath} must export a non-empty array`);
53
+
54
+ const dist = join(root, "dist");
55
+ mkdirSync(dist, { recursive: true });
56
+
57
+ function serProps(p) {
58
+ const parts = [];
59
+ for (const [k, v] of Object.entries(p || {})) {
60
+ if (typeof v === "function") parts.push(`${k}: () => {}`);
61
+ else parts.push(`${k}: ${JSON.stringify(v)}`);
62
+ }
63
+ return `{ ${parts.join(", ")} }`;
64
+ }
65
+
66
+ const imports = manifest.map((m, i) => `import { ${m.import[1]} as Comp${i} } from ${JSON.stringify(m.import[0])};`);
67
+ const slots = manifest.map((m, i) => ` { id: ${JSON.stringify(m.id)}, name: ${JSON.stringify(m.import[1])}, jsxTag: ${JSON.stringify(m.jsxTag || m.import[1])}, comp: Comp${i}, props: ${serProps(m.props)} },`).join("\n");
68
+
69
+ const entry = `import * as React from "react";
70
+ import { createRoot } from "react-dom/client";
71
+ ${imports.join("\n")}
72
+ const SLOTS = [
73
+ ${slots}
74
+ ];
75
+ function App() {
76
+ return React.createElement("div", { style: { fontFamily: "sans-serif", padding: 16 } },
77
+ SLOTS.map((s) =>
78
+ React.createElement("div", { key: s.id, "data-slot": s.id, "data-comp": s.name, "data-jsxtag": s.jsxTag, style: { margin: 12, padding: 8, border: "1px solid #ccc", borderRadius: 6 } },
79
+ [React.createElement("div", { style: { fontSize: 11, color: "#888", marginBottom: 6 } }, s.name + " [" + s.id + "]"),
80
+ React.createElement(s.comp, s.props)]
81
+ )
82
+ )
83
+ );
84
+ }
85
+ const root = document.getElementById("root");
86
+ createRoot(root).render(React.createElement(App));
87
+ // Click-marker instrumentation: React delegates listeners to its root
88
+ // container, so per-element getEventListeners is unavailable from page
89
+ // context. Read the onClick prop from the fiber props map (React 18: key
90
+ // starts with "__reactProps$") and mark the element with data-clickevent.
91
+ window.addEventListener("load", () => {
92
+ setTimeout(() => {
93
+ const app = document.querySelector("[data-slot]");
94
+ if (!app) return;
95
+ let key = null;
96
+ for (const k of Object.keys(app)) if (k.startsWith("__reactProps$")) { key = k; break; }
97
+ if (!key) return;
98
+ const marked = new WeakSet();
99
+ const mark = (el) => {
100
+ if (marked.has(el)) return;
101
+ marked.add(el);
102
+ const p = el[key];
103
+ if (p && typeof p.onClick === "function") el.setAttribute("data-clickevent", "1");
104
+ for (let c = el.firstElementChild; c; c = c.nextElementSibling) mark(c);
105
+ };
106
+ mark(root.firstElementChild);
107
+ document.body.setAttribute("data-snapshot-ready", "1");
108
+ }, 400);
109
+ });
110
+ `;
111
+
112
+ const entryPath = join(root, "playground/entry.generated.tsx");
113
+ writeFileSync(entryPath, entry);
114
+
115
+ const html = `<!doctype html>
116
+ <html><head><meta charset="utf-8"><link rel="stylesheet" href="tw.css"></head>
117
+ <body><div id="root"></div><script src="playground.js"></script>
118
+ <script>document.body.setAttribute("data-snapshot-ready","0");</script>
119
+ </body></html>`;
120
+ writeFileSync(join(dist, "playground.html"), html);
121
+
122
+ await esbuild.build({
123
+ entryPoints: [entryPath],
124
+ bundle: true,
125
+ format: "iife",
126
+ outfile: join(dist, "playground.js"),
127
+ jsx: "automatic",
128
+ define: { "process.env.NODE_ENV": '"production"' },
129
+ logLevel: "warning",
130
+ platform: "browser",
131
+ });
132
+ console.log(`PLAYGROUND BUNDLE OK -> ${join(dist, "playground.js")} (${manifest.length} slots)`);
@@ -0,0 +1,43 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { join, dirname } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { existsSync } from "node:fs";
5
+
6
+ /**
7
+ * run.mjs — one command for the whole dynamic-catalog pipeline.
8
+ *
9
+ * node <pkg>/snapshots/run.mjs --root <repoRoot> [--manifest <path>] [--url <file://...>]
10
+ *
11
+ * Steps:
12
+ * 1. build-playground.mjs — bundle the manifest into dist/playground.html + playground.js
13
+ * 2. snapshot.mjs — headless chromium (file://, no server) renders the page,
14
+ * captures computed styles + click markers per slot and
15
+ * writes reports/component-catalog-dynamic.json
16
+ *
17
+ * The consumer is responsible for dist/tw.css (e.g. @tailwindcss/cli) — the
18
+ * generated page links it, but the snapshot works without it (components
19
+ * that rely on tailwind classes will just capture fewer styles).
20
+ */
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url));
23
+
24
+ function arg(name, dflt) {
25
+ const i = process.argv.indexOf(`--${name}`);
26
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
27
+ }
28
+ const root = arg("root", process.cwd());
29
+ const manifest = arg("manifest", join(root, "playground/manifest.js"));
30
+ const url = arg("url", pathToFileURL(join(root, "dist", "playground.html")).href);
31
+
32
+ const node = process.execPath;
33
+ const env = { ...process.env };
34
+
35
+ console.log(`[run] 1/2 build playground (root=${root})`);
36
+ execFileSync(node, [join(here, "build-playground.mjs"), "--root", root, "--manifest", manifest], { stdio: "inherit", env });
37
+
38
+ console.log(`[run] 2/2 snapshot (url=${url})`);
39
+ execFileSync(node, [join(here, "snapshot.mjs"), "--root", root, "--url", url, "--manifest", manifest], { stdio: "inherit", env });
40
+
41
+ const out = join(root, "reports/component-catalog-dynamic.json");
42
+ if (!existsSync(out)) throw new Error(`expected output not found: ${out}`);
43
+ console.log(`[run] DONE -> ${out}`);
@@ -0,0 +1,204 @@
1
+ import { createRequire } from "node:module";
2
+ import { writeFileSync, mkdirSync, cpSync, existsSync } from "node:fs";
3
+ import { join, dirname, resolve, relative, basename } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ /**
7
+ * snapshot.mjs
8
+ *
9
+ * Headless-chromium snapshot of the built playground page ->
10
+ * reports/component-catalog-dynamic.json
11
+ *
12
+ * No HTTP server: the page is opened via file://. Confined browsers (e.g. the
13
+ * snap chromium) cannot read /tmp, so if --url points under /tmp the page is
14
+ * copied to a home-dir temp location first.
15
+ *
16
+ * The page must contain a <div id="root"> with [data-slot] children (see
17
+ * build-playground.mjs) and must set data-snapshot-ready="1" on <body> once
18
+ * React has rendered (the generated entry does this after click-marker
19
+ * instrumentation).
20
+ *
21
+ * Usage:
22
+ * node <pkg>/snapshots/snapshot.mjs --root <repoRoot> --url file:///abs/dist/playground.html \
23
+ * --manifest <path/to/manifest.js> [--out reports/component-catalog-dynamic.json]
24
+ *
25
+ * Env: SNAPSHOT_CHROME (default: /snap/bin/chromium, then /usr/bin/chromium,
26
+ * then /usr/bin/google-chrome), SNAPSHOT_PUPPETEER (path to puppeteer-core).
27
+ *
28
+ * Manifest entries may carry `jsxTag` — the JSX tag the consumer actually
29
+ * writes (e.g. "Bn" for `import { Button as Bn }`). The dynamic catalog keys
30
+ * on it (field `comp`), so the rule can resolve the tag at lint time.
31
+ */
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const req = createRequire(join(here, "noop.js"));
35
+
36
+ function arg(name, dflt) {
37
+ const i = process.argv.indexOf(`--${name}`);
38
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : dflt;
39
+ }
40
+ const root = arg("root", process.cwd());
41
+ const url = arg("url", null);
42
+ const manifestPath = arg("manifest", join(root, "playground/manifest.js"));
43
+ const outRel = arg("out", "reports/component-catalog-dynamic.json");
44
+ if (!url) throw new Error("--url is required (file:// URL of the built playground.html)");
45
+
46
+ // puppeteer-core: consumer first, then env override, then known hoisted locations.
47
+ const importFile = (p) => import(pathToFileURL(p).href);
48
+ let puppeteer;
49
+ try {
50
+ puppeteer = await importFile(req.resolve("puppeteer-core"));
51
+ } catch {
52
+ const candidates = [
53
+ process.env.SNAPSHOT_PUPPETEER,
54
+ "/home/llm/Documents/Work/fun/Jabberwock/node_modules/.pnpm/puppeteer-core@24.10.2/node_modules/puppeteer-core/lib/cjs/puppeteer/puppeteer-core.js",
55
+ ].filter(Boolean);
56
+ let lastErr;
57
+ for (const c of candidates) {
58
+ try {
59
+ puppeteer = await importFile(resolve(c));
60
+ break;
61
+ } catch (e) {
62
+ lastErr = e;
63
+ }
64
+ }
65
+ if (!puppeteer) throw new Error(`puppeteer-core not found (tried: ${candidates.join(", ")}): ${lastErr?.message}`);
66
+ }
67
+
68
+ const chrome =
69
+ process.env.SNAPSHOT_CHROME ||
70
+ [
71
+ "/snap/bin/chromium",
72
+ "/usr/bin/chromium",
73
+ "/usr/bin/chromium-browser",
74
+ "/usr/bin/google-chrome",
75
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
76
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
77
+ ].find((p) => existsSync(p));
78
+ if (!chrome) throw new Error("No chromium found. Set SNAPSHOT_CHROME.");
79
+
80
+ const manifestUrl = manifestPath.startsWith("file:") ? manifestPath : pathToFileURL(manifestPath).href;
81
+ const manifest = (await import(manifestUrl)).default;
82
+
83
+ // Confinement: snap chromium cannot read /tmp. Copy the page to a home-dir temp.
84
+ let pageUrl = url;
85
+ const urlPath = decodeURIComponent(new URL(url).pathname);
86
+ if (urlPath.startsWith("/tmp/")) {
87
+ // NOTE: snap chromium also refuses HIDDEN dirs — keep the name unhidden.
88
+ const dest = join(process.env.HOME, "rqu-snapshot-dist");
89
+ mkdirSync(dest, { recursive: true });
90
+ cpSync(dirname(urlPath), dest, { recursive: true });
91
+ // cpSync(distDir, existingDestDir) copies dist contents flat into dest
92
+ pageUrl = `file://${dest}/${basename(urlPath)}`;
93
+ console.log(`[snapshot] confined browser: page at ${pageUrl}`);
94
+ }
95
+
96
+ const browser = await puppeteer.default.launch({
97
+ headless: "new",
98
+ executablePath: chrome,
99
+ args: ["--no-sandbox", "--disable-gpu", "--window-size=1440,900"],
100
+ });
101
+ try {
102
+ const page = await browser.newPage();
103
+ await page.setViewport({ width: 1440, height: 900 });
104
+ const errors = [];
105
+ page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
106
+ page.on("console", (m) => m.type() === "error" && errors.push(`console: ${m.text()}`));
107
+ await page.goto(pageUrl, { waitUntil: "load", timeout: 30000 });
108
+ await page.waitForFunction("document.body.getAttribute('data-snapshot-ready') === '1'", { timeout: 20000 });
109
+
110
+ const raw = await page.evaluate(() => {
111
+ const props = [
112
+ "display", "flex-direction", "align-items", "justify-content", "gap",
113
+ "padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
114
+ "margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
115
+ "border", "border-radius", "box-shadow", "background-color", "color",
116
+ "font-size", "font-weight", "text-align", "text-transform", "letter-spacing",
117
+ "line-height", "width", "max-width", "height", "list-style",
118
+ ];
119
+ const slots = [...document.querySelectorAll("[data-slot][data-comp]")];
120
+ return slots.map((slot) => {
121
+ const el = slot.children[1] || slot.firstElementChild;
122
+ if (!el) return null;
123
+ const cs = getComputedStyle(el);
124
+ const styles = {};
125
+ for (const p of props) {
126
+ const v = cs.getPropertyValue(p);
127
+ if (v && v !== "none" && v !== "normal" && v !== "auto" && v !== "0px" && v !== "transparent") styles[p] = v;
128
+ }
129
+ const attrs = {};
130
+ for (const a of ["type", "role", "aria-label", "data-testid", "tabindex"]) {
131
+ const v = el.getAttribute(a);
132
+ if (v != null) attrs[a] = v;
133
+ }
134
+ const hasClick = !!el.closest("[data-clickevent]");
135
+ return {
136
+ id: slot.getAttribute("data-slot"),
137
+ comp: slot.getAttribute("data-comp"),
138
+ jsxTag: slot.getAttribute("data-jsxtag"),
139
+ tag: el.tagName.toLowerCase(),
140
+ styles,
141
+ attrs,
142
+ hasClick,
143
+ };
144
+ });
145
+ });
146
+
147
+ if (errors.length) console.error(`[snapshot] PAGE ERRORS:\n${errors.join("\n")}`);
148
+
149
+ // --- noise filter: computed-style defaults that carry no design signal ---
150
+ const NOISE = {
151
+ "flex-direction": (v) => v === "row",
152
+ "border": (v) => v.startsWith("0px solid") && /rgb\(0, 0, 0\)|transparent/.test(v),
153
+ "list-style": () => true, // default "outside none disc"
154
+ "width": () => true, // px layout noise
155
+ "height": () => true,
156
+ "max-width": (v) => v === "100%",
157
+ "display": (v) => v === "block", // default for div
158
+ };
159
+ const normalize = (s) => {
160
+ const out = {};
161
+ for (const [k, v] of Object.entries(s)) {
162
+ if (NOISE[k] && NOISE[k](v)) continue;
163
+ if (k === "padding" || k === "margin") {
164
+ const [t, r, b, l] = v.split(" ");
165
+ if (t === r && r === b && b === l) { out[k] = t; continue; }
166
+ if (l !== r) out[`margin` === k ? "margin-left" : "padding-left"] = l;
167
+ if (r !== l) out[`margin` === k ? "margin-right" : "padding-right"] = r;
168
+ if (t !== b) out[`margin` === k ? "margin-top" : "padding-top"] = t;
169
+ if (b !== t) out[`margin` === k ? "margin-bottom" : "padding-bottom"] = b;
170
+ continue;
171
+ }
172
+ out[k] = v;
173
+ }
174
+ return out;
175
+ };
176
+
177
+ const byId = new Map(raw.filter(Boolean).map((r) => [r.id, r]));
178
+ const components = manifest.map((m) => {
179
+ const r = byId.get(m.id);
180
+ if (!r) throw new Error(`snapshot missing slot ${m.id}`);
181
+ const styles = normalize(r.styles);
182
+ // a11y must be an ARRAY of "key:value" tokens to match the static
183
+ // catalog format (signature.js runs `new Set(entry.a11y)`).
184
+ const a11y = Object.entries(r.attrs).map(([k, v]) => `${k}:${v}`);
185
+ return {
186
+ name: m.label,
187
+ path: m.fileRel || relative(root, m.import[0]),
188
+ comp: m.jsxTag || r.jsxTag, // JSX tag the consumer writes (dynTagMap key)
189
+ origTag: r.comp, // export name (context)
190
+ tag: r.tag, // rendered DOM tag
191
+ styles,
192
+ actions: r.hasClick ? ["click"] : [],
193
+ a11y,
194
+ data: {},
195
+ };
196
+ });
197
+
198
+ const outPath = join(root, outRel);
199
+ mkdirSync(dirname(outPath), { recursive: true });
200
+ writeFileSync(outPath, JSON.stringify({ generatedAt: new Date().toISOString(), components }, null, 2));
201
+ console.log(`[snapshot] wrote ${outPath} (${components.length} components)`);
202
+ } finally {
203
+ await browser.close();
204
+ }
package/thresholds.js ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+
3
+ const DEFAULTS = {
4
+ minShared: 2,
5
+ minRatioSameTag: 0.4,
6
+ minRatioAnyTag: 0.5,
7
+ exactRatio: 0.8,
8
+ familyMatchMin: 0.35,
9
+ };
10
+
11
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
12
+
13
+ function resolveThresholds(opts) {
14
+ const o = opts && typeof opts === "object" ? opts : {};
15
+ const t = (o.thresholds && typeof o.thresholds === "object") ? o.thresholds : {};
16
+ const num = (key) => (typeof t[key] === "number" && Number.isFinite(t[key]) ? t[key] : DEFAULTS[key]);
17
+
18
+ return {
19
+ minShared: Math.max(1, Math.floor(num("minShared"))),
20
+ minRatioSameTag: clamp01(num("minRatioSameTag")),
21
+ minRatioAnyTag: clamp01(num("minRatioAnyTag")),
22
+ exactRatio: clamp01(num("exactRatio")),
23
+ familyMatchMin: clamp01(num("familyMatchMin")),
24
+ };
25
+ }
26
+
27
+ const CANON_DUP_STRICT = {
28
+ minShared: 3,
29
+ minRatioSameTag: 0.6,
30
+ minRatioAnyTag: 0.7,
31
+ exactRatio: 0.8,
32
+ familyMatchMin: 0.35,
33
+ };
34
+
35
+ module.exports = { resolveThresholds, DEFAULT_THRESHOLDS: DEFAULTS, CANON_DUP_STRICT };