@elitedcs/ghl-mcp 3.56.0 → 3.58.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,364 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * audit.mjs — the mandatory pre-launch REVIEW REQUIRED report.
4
+ *
5
+ * Runs on every clone, clean or not. Nothing here is auto-fixed: the whole
6
+ * point is to surface what belongs to the ORIGINAL business so a human decides.
7
+ * Three review classes plus the exclusions:
8
+ *
9
+ * 1. MUST REPOINT — destinations that route leads, bookings or money
10
+ * 2. REVIEW: other people's content — names, addresses, testimonials, likenesses
11
+ * 3. REVIEW: inherited + regulated claims — FDA/medical, ratings, counts, awards, pricing
12
+ *
13
+ * Usage:
14
+ * node audit.mjs --dir <clone-dir> [--facts facts.json] [--out REVIEW-REQUIRED.md]
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { parseArgs, readJson, walk, isTextFile, ext, humanBytes, nowIso, resolveRun } from "./lib.mjs";
20
+
21
+ const args = parseArgs(process.argv.slice(2));
22
+ if (!args.dir) {
23
+ console.error("usage: audit.mjs --dir <clone-dir> [--facts facts.json] [--out REVIEW-REQUIRED.md]");
24
+ process.exit(1);
25
+ }
26
+ const RUN = resolveRun(String(args.dir));
27
+ const DIR = RUN.site;
28
+ const REPORTS = RUN.reports;
29
+ fs.mkdirSync(REPORTS, { recursive: true });
30
+ const OUT = path.resolve(String(args.out && args.out !== true ? args.out : path.join(REPORTS, "REVIEW-REQUIRED.md")));
31
+ const facts = args.facts && args.facts !== true ? readJson(path.resolve(String(args.facts))) : {};
32
+
33
+ const RULES = [
34
+ // ---- 1. MUST REPOINT ----------------------------------------------------
35
+ { s: "repoint", id: "form-action", label: "Form action", re: /<form[^>]*\saction=["']([^"']+)["']/gi, why: "Submissions go wherever this points — the original owner's CRM until you change it." },
36
+ { s: "repoint", id: "webhook", label: "Webhook / API endpoint", re: /https?:\/\/[^\s"'`)]*(?:webhook|hooks?\.|leadconnector|msgsndr|zapier|make\.com|n8n|formspree|netlify\.app\/\.netlify\/functions)[^\s"'`)]*/gi, why: "A live lead destination belonging to someone else." },
37
+ { s: "repoint", id: "api-path", label: "Same-site API path", re: /["'`](\/api\/[A-Za-z0-9._/-]+)["'`]/g, why: "The clone's form posts here; the backing function must exist on YOUR host or submissions vanish." },
38
+ { s: "repoint", id: "stripe-key", label: "Stripe publishable key", re: /pk_(?:live|test)_[A-Za-z0-9]{10,}/g, why: "Charges would land in the original owner's Stripe account. Never ship this." },
39
+ { s: "repoint", id: "payment-link", label: "Payment / checkout link", re: /https?:\/\/(?:buy\.stripe\.com|checkout\.stripe\.com|book\.stripe\.com|www\.paypal\.com\/[^\s"'`)]*|squareup\.com\/[^\s"'`)]*)[^\s"'`)]*/gi, why: "Money flows to whoever owns this link." },
40
+ { s: "repoint", id: "booking", label: "Booking / calendar embed", re: /https?:\/\/[^\s"'`)]*(?:calendly\.com|acuityscheduling\.com|api\.leadconnectorhq\.com\/widget\/(?:booking|appointment)|widget\.leadconnectorhq|cal\.com)[^\s"'`)]*/gi, why: "Appointments book onto the original owner's calendar." },
41
+ { s: "repoint", id: "ga", label: "Google Analytics / GTM ID", re: /\b(?:G-[A-Z0-9]{6,12}|UA-\d{4,}-\d+|GTM-[A-Z0-9]{4,})\b/g, why: "Your client's traffic reports into a stranger's analytics property." },
42
+ { s: "repoint", id: "meta-pixel", label: "Meta pixel ID", re: /fbq\(\s*["']init["']\s*,\s*["'](\d{8,})["']/gi, why: "Conversions attribute to the original owner's ad account." },
43
+ { s: "repoint", id: "other-pixel", label: "Other tracking (TikTok/Clarity/Hotjar)", re: /(?:ttq\.load\(\s*["'][^"']+["']|clarity\.ms\/tag\/[A-Za-z0-9]+|static\.hotjar\.com\/c\/hotjar-\d+)/gi, why: "Third-party tracking still wired to the original account." },
44
+ { s: "repoint", id: "financing", label: "Consumer financing link", re: /https?:\/\/[^\s"'`)]*(?:mysynchrony\.com|greensky|wellsfargo\.com\/.*(?:credit|financing)|affirm\.com|klarna\.com|acimacredit|foundationfinance)[^\s"'`)]*/gi, why: "A merchant-specific financing application. Applications and the resulting revenue are tied to the ORIGINAL owner's merchant account." },
45
+ { s: "repoint", id: "review-widget", label: "Reviews widget", re: /https?:\/\/[^\s"'`)]*(?:elfsight\.com|trustindex|birdeye|podium\.com|reviewsonmyweb|shopperapproved)[^\s"'`)]*/gi, why: "Loads the ORIGINAL business's reviews at runtime. No file edit changes it — it must be repointed or removed." },
46
+ { s: "repoint", id: "chat", label: "Chat widget", re: /https?:\/\/[^\s"'`)]*(?:tawk\.to|intercom|drift\.com|crisp\.chat|tidio)[^\s"'`)]*/gi, why: "Chats route to the original owner's inbox." },
47
+ { s: "repoint", id: "mailto", label: "mailto: address", re: /mailto:([^\s"'`)<]+)/gi, why: "Emails go to the original business." },
48
+ { s: "repoint", id: "tel", label: "tel: number", re: /tel:\+?[\d().\s-]{7,}/gi, why: "Calls ring the original business." },
49
+ { s: "repoint", id: "social", label: "Social / review profile", re: /https?:\/\/(?:www\.)?(?:facebook\.com|instagram\.com|tiktok\.com|linkedin\.com|youtube\.com|g\.page|goo\.gl\/maps|maps\.app\.goo\.gl|yelp\.com)\/[^\s"'`)]*/gi, why: "Links to the original owner's accounts and reviews." },
50
+ { s: "repoint", id: "map", label: "Map embed", re: /https?:\/\/(?:www\.)?google\.com\/maps\/embed[^\s"'`)]*/gi, why: "Shows the original business's location." },
51
+
52
+ // ---- 2. OTHER PEOPLE'S CONTENT -----------------------------------------
53
+ { scope: "text", s: "people", id: "street", label: "Street address", re: /\b\d{2,6}\s+(?:[NSEW]\.?\s+)?[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3}\s+(?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Dr|Drive|Ln|Lane|Way|Pkwy|Parkway|Hwy|Highway|Ct|Court|Ste|Suite|Unit|Pl|Place|Ter|Terrace)\b\.?(?:\s*(?:Ste|Suite|Unit|#)\s*[A-Za-z0-9-]+)?/g, why: "A physical address belonging to the original business." },
54
+ { scope: "text", s: "people", id: "testimonial", label: "Testimonial / review text", re: /(?:"[^"]{40,400}"|“[^”]{40,400}”|‘[^’]{40,400}’)/g, why: "Quoted words from the original business's clients — their statement, not your client's.", limit: 12 },
55
+ { scope: "text", s: "people", id: "attribution", label: "Testimonial attribution", re: /(?:^|[>\s])[-—–]\s*[A-Z][a-z]+(?:\s+[A-Z][a-z.]+)?\s*(?:,|\||$)/gm, why: "A named person credited with a quote." },
56
+ { s: "people", id: "person-image", label: "Person / before-after image", re: /["'`][^"'`\s]*(?:before|after|patient|client|testimonial|headshot|staff|team|owner|founder|dr[-_])[^"'`\s]*\.(?:png|jpe?g|webp|avif|gif)["'`]/gi, why: "A real person's likeness. Before/after health images carry the heaviest liability." },
57
+ { s: "people", id: "license-number", label: "Contractor / professional license number", re: /\b(?:ROC|CSLB|LIC|License|Lic\.?|Reg(?:istration)?|Permit)[\s#:]*\u00a0?\s*\d{4,10}\b/gi, why: "A state license or registration number issued to the ORIGINAL business. Publishing it under another name misrepresents licensure — in the trades and in health care this is the single most serious item on the page." },
58
+ { scope: "text", s: "people", id: "credential", label: "Named credential", re: /\b[A-Z][a-z]+\s+[A-Z][a-z]+,\s*(?:MD|DO|DC|RN|NP|PA-C|DDS|LMT|PhD|MSN|FNP)\b/g, why: "A licensed professional's name and credential." },
59
+
60
+ // ---- 3. INHERITED + REGULATED CLAIMS ------------------------------------
61
+ { scope: "text", s: "claims", id: "regulated", label: "REGULATED health/efficacy claim", re: /\b(?:FDA[- ](?:approved|cleared|registered)|FDA\b|clinically (?:proven|shown|validated|tested)|doctor[- ]recommended|physician[- ]recommended|board[- ]certified|medically (?:proven|supervised)|prescription[- ]strength|HIPAA[- ]compliant|scientifically proven|cures?\b|treats?\s+(?:your\s+)?(?:disease|cancer|diabetes)|no side effects|100%\s*(?:safe|effective)|guaranteed results|pain[- ]free guarantee)/gi, why: "A regulated claim about a DIFFERENT business's product, staff or protocol. Substantiation does not transfer with a copy-paste — FTC/FDA exposure sits with whoever publishes it." },
62
+ { scope: "text", s: "claims", id: "rating", label: "Star rating / review count", re: /\b(?:[45](?:\.\d)?\s*(?:\/\s*5|stars?|-star)|\d{1,3}(?:,\d{3})*\+?\s*(?:5[- ]star\s*)?reviews?)\b/gi, why: "Ratings earned by the original business." },
63
+ { scope: "text", s: "claims", id: "count", label: "Client / result count", re: /\b\d{1,3}(?:,\d{3})*\+?\s*(?:happy\s+)?(?:clients?|customers?|patients?|members?|sessions?|treatments?|pounds?\s+lost|lbs\s+lost)\b/gi, why: "A results claim about the original business's book of business." },
64
+ { scope: "text", s: "claims", id: "tenure", label: "Years in business", re: /\b(?:since\s+(?:19|20)\d{2}|(?:over\s+)?\d{1,2}\+?\s*years?\s+(?:of\s+)?(?:experience|in business|serving))\b/gi, why: "Tenure the new business has not earned." },
65
+ { scope: "text", s: "claims", id: "award", label: "Award / superlative", re: /\b(?:#1\b|number one\b|award[- ]winning|best of\s+[A-Z][a-z]+|voted\s+best|top[- ]rated)\b/gi, why: "An award or superlative belonging to the original business." },
66
+ { scope: "text", s: "claims", id: "price", label: "Price / offer amount", re: /\$\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?(?:\s*(?:\/|per\s)\s*(?:mo|month|session|visit|year))?/g, why: "The original business's pricing — confirm it matches your client's offer.", limit: 15 },
67
+ { scope: "text", s: "claims", id: "guarantee", label: "Guarantee / refund promise", re: /\b(?:\d{1,3}[- ]day\s+(?:money[- ]back\s+)?guarantee|money[- ]back guarantee|risk[- ]free|full refund)\b/gi, why: "A promise your client must actually be willing and able to honor." },
68
+ ];
69
+
70
+ const files = walk(DIR).filter((f) => isTextFile(f));
71
+
72
+ const findings = new Map(); // key: section|id -> { rule, hits: [{file,line,snippet}] }
73
+ const MAX_PER_RULE = 25;
74
+
75
+ /** Visible page text: what a human actually reads. */
76
+ function visibleText(html) {
77
+ return html
78
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
79
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
80
+ .replace(/<!--[\s\S]*?-->/g, " ")
81
+ .replace(/<[^>]+>/g, " ")
82
+ .replace(/&nbsp;/gi, " ")
83
+ .replace(/&amp;/gi, "&")
84
+ .replace(/&quot;/gi, '"')
85
+ .replace(/&#39;/g, "'")
86
+ .replace(/[ \t]+/g, " ");
87
+ }
88
+
89
+ /**
90
+ * Copy that lives inside a JS bundle (SPAs keep every headline there).
91
+ * Keeps human sentences, drops minified code: needs spaces, letters, and no
92
+ * code punctuation.
93
+ */
94
+ function jsCopyStrings(js) {
95
+ const out = [];
96
+ for (const m of js.matchAll(/(["'`])((?:\\.|(?!\1)[^\\\r\n]){25,400})\1/g)) {
97
+ const v = m[2];
98
+ if (!/[A-Za-z]{3}/.test(v)) continue;
99
+ if ((v.match(/ /g) || []).length < 4) continue;
100
+ if (/[{}=;<>]|\)\s*\{|=>|function |var |const |return /.test(v)) continue;
101
+ out.push(v);
102
+ }
103
+ return out.join("\n");
104
+ }
105
+
106
+ for (const file of files) {
107
+ const rel = path.relative(DIR, file);
108
+ let text;
109
+ try {
110
+ text = fs.readFileSync(file, "utf8");
111
+ } catch {
112
+ continue;
113
+ }
114
+ const e = ext(file);
115
+ // Text-scope rules read what a visitor reads — never minified internals.
116
+ const derived =
117
+ [".html", ".htm"].includes(e) ? visibleText(text) :
118
+ [".js", ".mjs"].includes(e) ? jsCopyStrings(text) :
119
+ "";
120
+ const lineStarts = [];
121
+ {
122
+ let idx = 0;
123
+ for (const line of text.split("\n")) {
124
+ lineStarts.push(idx);
125
+ idx += line.length + 1;
126
+ }
127
+ }
128
+ const lineOf = (pos) => {
129
+ let lo = 0, hi = lineStarts.length - 1, ans = 0;
130
+ while (lo <= hi) {
131
+ const mid = (lo + hi) >> 1;
132
+ if (lineStarts[mid] <= pos) { ans = mid; lo = mid + 1; } else hi = mid - 1;
133
+ }
134
+ return ans + 1;
135
+ };
136
+
137
+ for (const rule of RULES) {
138
+ const scope = rule.scope || "raw";
139
+ const haystack = scope === "text" ? derived : text;
140
+ if (!haystack) continue;
141
+ const key = `${rule.s}|${rule.id}`;
142
+ const re = new RegExp(rule.re.source, rule.re.flags.includes("g") ? rule.re.flags : rule.re.flags + "g");
143
+ let m;
144
+ const seen = new Set();
145
+ while ((m = re.exec(haystack)) !== null) {
146
+ if (m[0].length === 0) { re.lastIndex++; continue; }
147
+ const raw = (m[1] || m[0]).trim();
148
+ const snippet = raw.replace(/\s+/g, " ").slice(0, 160);
149
+ if (!snippet || seen.has(snippet)) continue;
150
+ seen.add(snippet);
151
+ if (!findings.has(key)) findings.set(key, { rule, hits: [] });
152
+ const rec = findings.get(key);
153
+ if (rec.hits.length >= (rule.limit || MAX_PER_RULE)) continue;
154
+ const pos = scope === "text" ? text.indexOf(raw.slice(0, 40)) : m.index;
155
+ rec.hits.push({ file: rel, line: lineOf(pos >= 0 ? pos : 0), snippet });
156
+ }
157
+ }
158
+ }
159
+
160
+ // ---- context from the mirror + substitution runs ---------------------------
161
+ let mirror = null, subs = null;
162
+ try { mirror = readJson(path.join(REPORTS, "run-report.json")); } catch {}
163
+ try { subs = readJson(path.join(REPORTS, "substitution-report.json")); } catch {}
164
+
165
+ const SECTIONS = [
166
+ { key: "repoint", title: "1. MUST REPOINT — leads, bookings and money", lead: "Every item below still points at the ORIGINAL owner. Anything left unchanged sends your client's leads, appointments, tracking or payments to someone else." },
167
+ { key: "people", title: "2. REVIEW — other people's content", lead: "Names, addresses, likenesses and words that belong to the original business and its clients. Replace or remove; do not simply delete blocks you have not looked at." },
168
+ { key: "claims", title: "3. REVIEW — inherited and regulated claims", lead: "Factual assertions about a DIFFERENT business. Regulated health/efficacy claims are the highest-risk items on this page: the substantiation does not travel with the copy." },
169
+ ];
170
+
171
+ const md = [];
172
+ md.push("# REVIEW REQUIRED — before this page takes real traffic");
173
+ md.push("");
174
+ md.push(`Generated ${nowIso()} by GHL Command clone-site.`);
175
+ md.push("");
176
+ if (mirror) {
177
+ md.push("| | |");
178
+ md.push("|---|---|");
179
+ md.push(`| Source | ${mirror.source?.url || "(unknown)"} |`);
180
+ md.push(`| Rights declared | **${mirror.rights?.declaration || "(not recorded)"}**${mirror.rights?.note ? ` — "${mirror.rights.note}"` : ""} |`);
181
+ md.push(`| Declaration recorded | ${mirror.generatedAt} (user's statement; this tool does not verify ownership) |`);
182
+ md.push(`| Files mirrored | ${mirror.inventory?.files ?? "?"} (${mirror.inventory?.totalHuman ?? "?"}) |`);
183
+ md.push("");
184
+ }
185
+
186
+ let totalFindings = 0;
187
+ for (const sec of SECTIONS) {
188
+ const recs = [...findings.values()].filter((f) => f.rule.s === sec.key);
189
+ md.push(`## ${sec.title}`);
190
+ md.push("");
191
+ md.push(sec.lead);
192
+ md.push("");
193
+ if (recs.length === 0) {
194
+ md.push("_Nothing detected._ Detection is pattern-based — read the page yourself before launch.");
195
+ md.push("");
196
+ continue;
197
+ }
198
+ for (const rec of recs) {
199
+ totalFindings += rec.hits.length;
200
+ md.push(`### ${rec.rule.label} (${rec.hits.length}${rec.hits.length >= (rec.rule.limit || MAX_PER_RULE) ? "+" : ""})`);
201
+ md.push("");
202
+ md.push(`${rec.rule.why}`);
203
+ md.push("");
204
+ for (const h of rec.hits) md.push(`- \`${h.file}:${h.line}\` — ${h.snippet.replace(/\|/g, "\\|")}`);
205
+ md.push("");
206
+ }
207
+ }
208
+
209
+ // Internal navigation that has no page in this clone. Standard for a single-page
210
+ // mirror, and the first thing a client clicks. Worth naming explicitly.
211
+ const dangling = new Set();
212
+ // After a rebrand, internal nav is often absolute on the NEW domain. Treat those
213
+ // hosts as our own, or every internal link looks "external" and goes unchecked.
214
+ const ownHosts = new Set();
215
+ try {
216
+ const subsForHosts = readJson(path.join(REPORTS, "substitution-report.json"));
217
+ for (const h of subsForHosts.hits || []) {
218
+ if (!/^domain/.test(h.label) || !h.new) continue;
219
+ const host = String(h.new).replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
220
+ if (host) {
221
+ ownHosts.add(host);
222
+ ownHosts.add(host.replace(/^www\./, ""));
223
+ ownHosts.add("www." + host.replace(/^www\./, ""));
224
+ }
225
+ }
226
+ } catch {}
227
+
228
+ for (const file of files) {
229
+ if (![".html", ".htm"].includes(ext(file))) continue;
230
+ const text = fs.readFileSync(file, "utf8");
231
+ for (const m of text.matchAll(/href=["']([^"'#]+)["']/gi)) {
232
+ let href = m[1].trim();
233
+ if (/^(mailto:|tel:|javascript:|data:)/i.test(href)) continue;
234
+ let p2 = null;
235
+ if (href.startsWith("/")) p2 = href;
236
+ else if (/^https?:\/\//i.test(href)) {
237
+ let u = null;
238
+ try { u = new URL(href); } catch { continue; }
239
+ if (!ownHosts.has(u.hostname.toLowerCase())) continue; // truly external — the repoint rules cover it
240
+ p2 = u.pathname;
241
+ }
242
+ else if (!href.includes(":")) p2 = "/" + href;
243
+ if (!p2) continue;
244
+ p2 = p2.split("?")[0].replace(/\/+$/, "");
245
+ if (!p2 || p2 === "") continue;
246
+ const rel = p2.replace(/^\/+/, "");
247
+ const candidates = [rel, rel + ".html", path.posix.join(rel, "index.html")];
248
+ if (!candidates.some((c) => fs.existsSync(path.join(DIR, c)))) dangling.add(p2);
249
+ }
250
+ }
251
+
252
+ // Brand baked into image files. Text substitution cannot reach pixels: the logo,
253
+ // and any graphic carrying the old phone number or URL, still belong to the
254
+ // original business after a "clean" rebrand. Live-caught 2026-07-30 — the header
255
+ // logo still read the original name, phone and domain on an otherwise clean clone.
256
+ const loose = (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "");
257
+ const brandedAssets = [];
258
+ try {
259
+ const mirrorForAssets = readJson(path.join(REPORTS, "run-report.json"));
260
+ const subsForAssets = (() => { try { return readJson(path.join(REPORTS, "substitution-report.json")); } catch { return { hits: [] }; } })();
261
+ const needles = [...new Set((subsForAssets.hits || [])
262
+ .map((h) => loose(h.old))
263
+ .filter((v) => v.length >= 6))];
264
+ const imageExt = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".bmp", ".pdf"];
265
+ for (const a of mirrorForAssets.assets || []) {
266
+ if (!imageExt.includes(ext(a.local))) continue;
267
+ const base = loose(path.posix.basename(a.local));
268
+ const brandHit = needles.find((n) => base.includes(n));
269
+ const looksIdentity = /logo|header|banner|watermark|letterhead|business.?card|truck|van|sign/.test(path.posix.basename(a.local).toLowerCase());
270
+ if (brandHit || looksIdentity) {
271
+ brandedAssets.push({ local: a.local, reason: brandHit ? `filename carries the old brand ("${brandHit}")` : "filename suggests an identity asset (logo/banner/signage)" });
272
+ }
273
+ }
274
+ } catch {}
275
+ if (brandedAssets.length) {
276
+ md.push("### Brand baked into image files (" + brandedAssets.length + ")");
277
+ md.push("");
278
+ md.push("**Substitution cannot edit pixels.** These image files carry the original business's name, phone number, web address or signage inside the artwork. On a rebranded page the text reads correctly while the logo still advertises the original company — the single most visible way a clone gives itself away, and a straight trademark problem if it ships. Replace the logo and any graphic containing contact details with the new client's artwork.");
279
+ md.push("");
280
+ for (const b of brandedAssets.slice(0, 40)) md.push(`- \`${b.local}\` — ${b.reason}`);
281
+ if (brandedAssets.length > 40) md.push(`- …and ${brandedAssets.length - 40} more`);
282
+ md.push("");
283
+ }
284
+
285
+ md.push("## 4. Excluded, oversized and unresolved");
286
+ md.push("");
287
+ if (dangling.size) {
288
+ md.push(`**Internal links with no page in this clone:** ${dangling.size}`);
289
+ md.push("");
290
+ md.push("This is normal for a single-page mirror — the nav still lists the whole original site. Either mirror those pages too, point the links somewhere real, or trim the nav. As it stands they are the first thing a visitor clicks and the first thing that 404s.");
291
+ md.push("");
292
+ for (const d of [...dangling].sort().slice(0, 30)) md.push(`- ${d}`);
293
+ if (dangling.size > 30) md.push(`- …and ${dangling.size - 30} more`);
294
+ md.push("");
295
+ }
296
+ if (mirror) {
297
+ if ((mirror.oversized || []).length) {
298
+ md.push(`**Oversized — NOT downloaded (route to object storage, never hot-link the source):** ${mirror.oversized.length}`);
299
+ md.push("");
300
+ for (const o of mirror.oversized) md.push(`- ${humanBytes(o.bytes)} — ${o.url}`);
301
+ md.push("");
302
+ }
303
+ if ((mirror.skippedForeignOrMissing || []).length) {
304
+ md.push(`**Excluded — the origin served HTML where an asset should be** (missing, or another tenant's file): ${mirror.skippedForeignOrMissing.length}`);
305
+ md.push("");
306
+ for (const s of mirror.skippedForeignOrMissing.slice(0, 25)) md.push(`- ${s.url}`);
307
+ md.push("");
308
+ }
309
+ if ((mirror.failed || []).length) {
310
+ md.push(`**Failed to fetch:** ${mirror.failed.length}`);
311
+ md.push("");
312
+ for (const f of mirror.failed.slice(0, 25)) md.push(`- [${f.status}] ${f.url}`);
313
+ md.push("");
314
+ }
315
+ if ((mirror.externalRefsLeft || []).length) {
316
+ md.push(`**Third-party references left as-is** (fonts, SDKs, analytics loaders): ${mirror.externalRefsLeft.length}`);
317
+ md.push("");
318
+ for (const u of mirror.externalRefsLeft.slice(0, 25)) md.push(`- ${u}`);
319
+ md.push("");
320
+ }
321
+ }
322
+ if (subs) {
323
+ const noReplacement = (subs.hits || []).filter((h) => !h.new);
324
+ if (noReplacement.length) {
325
+ md.push(`**Present in the page with no replacement value supplied:** ${noReplacement.length}`);
326
+ md.push("");
327
+ for (const h of noReplacement) md.push(`- ${h.label}: \`${h.old}\` ×${h.total}`);
328
+ md.push("");
329
+ }
330
+ }
331
+ if (!mirror && !subs) {
332
+ md.push("_No mirror/substitution reports found in this directory._");
333
+ md.push("");
334
+ }
335
+
336
+ md.push("---");
337
+ md.push("");
338
+ md.push("**Everything above describes the original business, not your client. Replace or remove each item before this page takes real traffic.**");
339
+ md.push("");
340
+ md.push("This report is pattern-based and deliberately over-inclusive. It is not legal advice, and it is not a rights check: you declared your authorization at step 0 and you are responsible for that declaration and for what you publish.");
341
+ md.push("");
342
+
343
+ fs.writeFileSync(OUT, md.join("\n"));
344
+
345
+ // ---- console summary -------------------------------------------------------
346
+ const counts = {};
347
+ for (const sec of SECTIONS) {
348
+ counts[sec.key] = [...findings.values()].filter((f) => f.rule.s === sec.key).reduce((n, f) => n + f.hits.length, 0);
349
+ }
350
+ const regulated = findings.get("claims|regulated");
351
+ const out = [];
352
+ out.push("");
353
+ out.push("PRE-LAUNCH AUDIT COMPLETE");
354
+ out.push(` MUST REPOINT (leads/bookings/money) : ${counts.repoint}`);
355
+ out.push(` Other people's content : ${counts.people}`);
356
+ out.push(` Inherited + regulated claims : ${counts.claims}`);
357
+ if (regulated) out.push(` ⚠ REGULATED CLAIMS FLAGGED : ${regulated.hits.length} — highest-risk items on the page`);
358
+ if (brandedAssets.length) out.push(` ⚠ Images with the ORIGINAL brand baked in (logo etc.): ${brandedAssets.length} — substitution cannot fix these`);
359
+ if (dangling.size) out.push(` Internal links with no page in this clone: ${dangling.size}`);
360
+ out.push(` report: ${OUT}`);
361
+ out.push("");
362
+ out.push(" Show the user this report before deploying. Never auto-delete a finding.");
363
+ out.push("");
364
+ console.log(out.join("\n"));