diff --git a/find-duplicates.mjs b/find-duplicates.mjs
new file mode 100644
index 0000000..00dfa39
--- /dev/null
+++ b/find-duplicates.mjs
@@ -0,0 +1,333 @@
+#!/usr/bin/env node
+// find-duplicate-functions.mjs
+// Usage:
+// node find-duplicate-functions.mjs [rootDir] [--mode exact|struct] [--min-lines N] [--json]
+// Example:
+// node find-duplicate-functions.mjs . --mode struct --min-lines 3
+
+import fs from "fs";
+import path from "path";
+import * as url from "url";
+import ts from "typescript";
+
+const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
+
+/** -------- CLI OPTIONS -------- */
+const args = process.argv.slice(2);
+let rootDir = ".";
+let mode = "struct"; // "exact" | "struct"
+let minLines = 3;
+let outputJson = false;
+
+for (let i = 0; i < args.length; i++) {
+ const a = args[i];
+ if (!a.startsWith("--") && rootDir === ".") {
+ rootDir = a;
+ } else if (a === "--mode") {
+ mode = (args[++i] || "struct").toLowerCase();
+ if (!["exact", "struct"].includes(mode)) {
+ console.error("Invalid --mode. Use 'exact' or 'struct'.");
+ process.exit(1);
+ }
+ } else if (a === "--min-lines") {
+ minLines = parseInt(args[++i] || "3", 10);
+ } else if (a === "--json") {
+ outputJson = true;
+ }
+}
+
+/** -------- FILE DISCOVERY -------- */
+const DEFAULT_IGNORES = new Set([
+ "node_modules",
+ ".git",
+ ".next",
+ ".vercel",
+ "dist",
+ "build",
+ ".astro", // Astro's generated cache dir
+]);
+
+const VALID_EXTS = new Set([".ts", ".tsx", ".astro", ".mts", ".cts"]);
+
+function walk(dir) {
+ /** @type {string[]} */
+ const out = [];
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
+ for (const e of entries) {
+ const p = path.join(dir, e.name);
+ if (e.isDirectory()) {
+ if (DEFAULT_IGNORES.has(e.name)) continue;
+ out.push(...walk(p));
+ } else if (e.isFile() && VALID_EXTS.has(path.extname(e.name))) {
+ out.push(p);
+ }
+ }
+ return out;
+}
+
+/** -------- ASTRO CODE EXTRACTION --------
+ * Extract TS/JS code from:
+ * - frontmatter: --- ... ---
+ * -
+ */
+function extractCodeFromAstro(source) {
+ /** @type {{code:string, offset:number}[]} */
+ const blocks = [];
+
+ // Frontmatter (must be at top in Astro)
+ // Match the FIRST pair of --- ... ---
+ const fm = source.startsWith("---")
+ ? (() => {
+ const end = source.indexOf("\n---", 3);
+ if (end !== -1) {
+ const front = source.slice(3, end + 1); // include trailing \n
+ return { start: 0, end: end + 4, code: front };
+ }
+ return null;
+ })()
+ : null;
+ if (fm) {
+ // offset for line numbers is after the first '---\n'
+ blocks.push({ code: fm.code, offset: 4 }); // rough; we’ll fix line numbers via positions later
+ }
+
+ //
+ const scriptRe = /