periderm-cli 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,100 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { walk } from "./walk.js";
4
+ import { parseSource } from "./ast.js";
5
+ import { runChecks, PER_FILE_CHECK_COUNT } from "./checks.js";
6
+ import { runRepoChecks, REPO_WIDE_CHECK_COUNT } from "./repo-checks.js";
7
+ import { runPolicyChecks, POLICY_CHECK_COUNT } from "./policy-checks.js";
8
+ import { runSeoChecks, SEO_CHECK_COUNT } from "./seo-checks.js";
9
+ import { computeScores, computeVerdict } from "../report/verdict.js";
10
+ import { renderMarkdown } from "../report/markdown.js";
11
+ import { mapWithProgress } from "./progress.js";
12
+ export { formatScanPath } from "./progress.js";
13
+ export async function scan(root, options = {}) {
14
+ const { onProgress } = options;
15
+ const findings = [];
16
+ onProgress?.({ type: "discovering" });
17
+ const files = await walk(root);
18
+ onProgress?.({ type: "discovered", total: files.length });
19
+ const estimatedChecks = files.length * PER_FILE_CHECK_COUNT +
20
+ REPO_WIDE_CHECK_COUNT +
21
+ POLICY_CHECK_COUNT +
22
+ SEO_CHECK_COUNT;
23
+ let checksRun = 0;
24
+ const bumpChecks = (n) => {
25
+ checksRun += n;
26
+ onProgress?.({ type: "checks", current: checksRun, total: estimatedChecks });
27
+ };
28
+ let policySignalsChecked = 0;
29
+ await mapWithProgress(files, 6, async (abs) => {
30
+ let src;
31
+ try {
32
+ src = await fs.readFile(abs, "utf8");
33
+ }
34
+ catch {
35
+ bumpChecks(PER_FILE_CHECK_COUNT);
36
+ return;
37
+ }
38
+ if (src.length > 500_000) {
39
+ bumpChecks(PER_FILE_CHECK_COUNT);
40
+ return;
41
+ }
42
+ const rel = path.relative(root, abs);
43
+ const ast = parseSource(src, rel);
44
+ findings.push(...runChecks(src, rel, ast));
45
+ bumpChecks(PER_FILE_CHECK_COUNT);
46
+ }, (_abs, completed, total) => {
47
+ const rel = path.relative(root, _abs);
48
+ onProgress?.({ type: "file", file: rel, index: completed, total });
49
+ });
50
+ onProgress?.({ type: "phase", label: "Running repo-wide checks…" });
51
+ findings.push(...(await runRepoChecks(root, files)));
52
+ bumpChecks(REPO_WIDE_CHECK_COUNT);
53
+ onProgress?.({ type: "phase", label: "Cross-referencing privacy policy vs codebase…" });
54
+ const policyFindings = await runPolicyChecks(root, files);
55
+ findings.push(...policyFindings);
56
+ policySignalsChecked = policyFindings.length > 0 ? 12 : 8;
57
+ bumpChecks(POLICY_CHECK_COUNT);
58
+ onProgress?.({ type: "phase", label: "Checking SEO & social meta…" });
59
+ findings.push(...(await runSeoChecks(root, files)));
60
+ bumpChecks(SEO_CHECK_COUNT);
61
+ onProgress?.({ type: "phase", label: "Computing launch verdict…" });
62
+ const meta = {
63
+ perFileCheckCount: PER_FILE_CHECK_COUNT,
64
+ repoWideCheckCount: REPO_WIDE_CHECK_COUNT,
65
+ seoCheckCount: SEO_CHECK_COUNT,
66
+ policySignalsChecked,
67
+ totalChecksRun: checksRun,
68
+ };
69
+ const counts = { critical: 0, high: 0, medium: 0, low: 0 };
70
+ for (const f of findings)
71
+ counts[f.severity]++;
72
+ const scores = computeScores(findings);
73
+ const { score, verdict } = computeVerdict(counts, scores.confidence);
74
+ const projectName = path.basename(root);
75
+ const scannedAt = new Date().toISOString();
76
+ const markdown = renderMarkdown({
77
+ projectName,
78
+ scannedAt,
79
+ findings,
80
+ counts,
81
+ score,
82
+ scores,
83
+ verdict,
84
+ filesScanned: files.length,
85
+ markdown: "",
86
+ meta,
87
+ });
88
+ return {
89
+ projectName,
90
+ scannedAt,
91
+ filesScanned: files.length,
92
+ findings,
93
+ counts,
94
+ score,
95
+ scores,
96
+ verdict,
97
+ markdown,
98
+ meta,
99
+ };
100
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Cross-reference privacy/legal copy against what the codebase actually does.
3
+ * Catches the FTC-class failure mode: policy says one thing, app does another.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ async function readMaybe(p) {
8
+ try {
9
+ return await fs.readFile(p, "utf8");
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /** Signals that user data is sent to trackers, ad networks, or data brokers. */
16
+ const TRACKING_IN_CODE = [
17
+ [/gtag\s*\(|googletagmanager|google-analytics|GA_MEASUREMENT_ID|G-[A-Z0-9]{6,}/i, "Google Analytics / gtag"],
18
+ [/fbq\s*\(|facebook\.net\/en_US\/fbevents|connect\.facebook\.net/i, "Meta / Facebook Pixel"],
19
+ [/posthog\.(?:capture|init)|import\s+posthog/i, "PostHog analytics"],
20
+ [/mixpanel\.(?:track|init)|from\s+['"]mixpanel/i, "Mixpanel"],
21
+ [/analytics\.(?:track|page)|segment\.(?:track|page)|@segment\//i, "Segment analytics"],
22
+ [/amplitude\.(?:track|init)|@amplitude\//i, "Amplitude"],
23
+ [/hotjar|hj\s*\(|clarity\.ms|fullstory|logrocket/i, "Session recording / heatmap tool"],
24
+ [/doubleclick|googlesyndication|googleadservices|ads\.twitter|tiktok.*track/i, "Ad / retargeting network"],
25
+ [/linkedin\.com\/px|snap\.licdn|taboola|outbrain|criteo/i, "Ad / retargeting pixel"],
26
+ [/plausible\(|umami\.track|heap\.(?:track|load)/i, "Analytics SDK"],
27
+ ];
28
+ /** Strong privacy promises that contradict the signals above. */
29
+ const PRIVACY_PROMISES = [
30
+ [/(?:do|does|will|shall)\s+not\s+(?:share|sell|disclose|transfer)/i, "promises not to share/sell/disclose data"],
31
+ [/never\s+(?:share|sell|disclose|transfer)/i, "promises never to share/sell data"],
32
+ [/(?:remain|stays?|keeping?|keep)\s+(?:private|confidential)/i, "claims user data stays private"],
33
+ [/not\s+(?:shared|sold|disclosed|transferred)\s+(?:with|to)\s+third/i, "denies third-party sharing"],
34
+ [/(?:no|without)\s+third[- ]party\s+(?:sharing|access|tracking|data)/i, "denies third-party tracking/sharing"],
35
+ [/(?:do|does)\s+not\s+(?:use|employ|utilize)\s+(?:tracking|analytics|advertising|ad\s)/i, "denies use of tracking/analytics/ads"],
36
+ [/information\s+(?:will|shall)\s+(?:remain|stay)\s+private/i, "claims information will remain private"],
37
+ [/(?:personal\s+information|user\s+data).{0,40}(?:is\s+not|are\s+not|will\s+not\s+be)\s+sold/i, "claims personal data is not sold"],
38
+ [/(?:only|solely)\s+(?:for|to)\s+(?:provide|operate|improve)\s+(?:the\s+)?(?:service|app|site)/i, "claims data is used only to provide the service"],
39
+ [/(?:do|does)\s+not\s+(?:use|set)\s+cookies?\s+(?:for|to)\s+(?:track|advertis|market)/i, "denies tracking/ad cookies"],
40
+ [/(?:no|without)\s+(?:advertising|marketing)\s+cookies?/i, "denies advertising cookies"],
41
+ ];
42
+ const COOKIE_TRACKING_IN_CODE = [
43
+ [/document\.cookie\s*[+=]|cookies?\.set\s*\(/i, "sets cookies in JavaScript"],
44
+ [/localStorage\.setItem\s*\(\s*['"][^'"]*(?:track|analytics|pixel|utm|session|user)/i, "persists tracking identifiers in localStorage"],
45
+ [/sessionStorage\.setItem\s*\(\s*['"][^'"]*(?:track|analytics|pixel|utm|session|user)/i, "persists tracking identifiers in sessionStorage"],
46
+ ];
47
+ const TRACKING_DEPS = /posthog|mixpanel|segment|amplitude|google-analytics|@vercel\/analytics|plausible|heap-js|hotjar/i;
48
+ export const POLICY_CHECK_COUNT = 2;
49
+ export async function runPolicyChecks(root, files) {
50
+ const out = [];
51
+ const relFiles = files.map((f) => path.relative(root, f));
52
+ const legalPaths = relFiles.filter((f) => /(?:^|\/)routes\/.*(?:privacy|terms|cookie|policy|legal|compliance|eula|tos)/i.test(f)
53
+ || /(?:privacy|terms|cookie|policy|legal|compliance|eula|tos).*\.(?:tsx|jsx|md|html)$/i.test(path.basename(f)));
54
+ if (legalPaths.length === 0)
55
+ return out;
56
+ let legalText = "";
57
+ for (const rel of legalPaths) {
58
+ const content = await readMaybe(path.join(root, rel));
59
+ if (content)
60
+ legalText += `\n${content}`;
61
+ }
62
+ if (!legalText.trim())
63
+ return out;
64
+ const legalLower = legalText.toLowerCase();
65
+ // Collect what the codebase actually does
66
+ const codeSignals = new Map(); // label -> first file
67
+ const pkgRaw = await readMaybe(path.join(root, "package.json"));
68
+ if (pkgRaw && TRACKING_DEPS.test(pkgRaw)) {
69
+ codeSignals.set("tracking dependency in package.json", "package.json");
70
+ }
71
+ for (const rel of relFiles) {
72
+ if (!/\.(tsx|jsx|ts|js|html)$/.test(rel))
73
+ continue;
74
+ if (/node_modules|\.periderm|dist\/|build\//.test(rel))
75
+ continue;
76
+ const content = await readMaybe(path.join(root, rel));
77
+ if (!content)
78
+ continue;
79
+ for (const [re, label] of TRACKING_IN_CODE) {
80
+ if (re.test(content) && !codeSignals.has(label)) {
81
+ codeSignals.set(label, rel);
82
+ }
83
+ }
84
+ for (const [re, label] of COOKIE_TRACKING_IN_CODE) {
85
+ if (re.test(content) && !codeSignals.has(label)) {
86
+ codeSignals.set(label, rel);
87
+ }
88
+ }
89
+ }
90
+ if (codeSignals.size === 0)
91
+ return out;
92
+ // Flag outright contradictions: policy promises privacy, code sends data to trackers
93
+ const brokenPromises = [];
94
+ for (const [re, claim] of PRIVACY_PROMISES) {
95
+ if (re.test(legalLower))
96
+ brokenPromises.push(claim);
97
+ }
98
+ if (brokenPromises.length > 0) {
99
+ const trackingList = [...codeSignals.entries()]
100
+ .map(([label, file]) => `${label} (${file})`)
101
+ .join("; ");
102
+ const policyFile = legalPaths[0];
103
+ out.push({
104
+ id: "policy-code-mismatch",
105
+ category: "Legal & Compliance",
106
+ severity: "critical",
107
+ file: policyFile,
108
+ line: 1,
109
+ message: "Privacy policy contradicts what the codebase actually does.",
110
+ why: "Regulators (FTC, GDPR authorities) treat this as deceptive practice — saying data stays private while sending it to ad/analytics platforms has resulted in major fines and consent decrees.",
111
+ fix: "Either remove the tracking/ad integrations from your code, or rewrite the policy to accurately disclose every third party that receives user data, why, and how users can opt out.",
112
+ aiPrompt: `The privacy policy in ${policyFile} ${brokenPromises[0]}, but the codebase includes: ${trackingList}. ` +
113
+ `Audit every tracking/ad SDK, list them explicitly in the privacy policy, or remove them. ` +
114
+ `Do not leave contradictory language — this is an FTC enforcement pattern.`,
115
+ });
116
+ }
117
+ // Policy denies analytics entirely but code/package uses it (even without strong "never share" wording)
118
+ const deniesAnalytics = /(?:do|does)\s+not\s+(?:use|collect|employ).*analytics|no\s+analytics|without\s+analytics/i.test(legalLower);
119
+ if (deniesAnalytics && codeSignals.size > 0) {
120
+ out.push({
121
+ id: "policy-denies-analytics",
122
+ category: "Legal & Compliance",
123
+ severity: "critical",
124
+ file: legalPaths[0],
125
+ line: 1,
126
+ message: "Privacy policy denies analytics/tracking but the codebase includes tracking code.",
127
+ why: "Claiming 'no analytics' while running PostHog, GA, Meta Pixel, etc. is exactly the kind of mismatch regulators prosecute.",
128
+ fix: "Remove the 'no analytics' claim and document each tool, what it collects, and how to opt out — or remove the tracking code.",
129
+ aiPrompt: `In ${legalPaths[0]}, the policy denies analytics/tracking. ` +
130
+ `Found in code: ${[...codeSignals.keys()].join(", ")}. Align the policy with reality or remove the SDKs.`,
131
+ });
132
+ }
133
+ return out;
134
+ }
@@ -0,0 +1,26 @@
1
+ export function formatScanPath(rel, max = 52) {
2
+ if (rel.length <= max)
3
+ return rel;
4
+ const tail = rel.slice(-(max - 1));
5
+ return `…${tail}`;
6
+ }
7
+ /** Run async work with bounded concurrency and per-item progress. */
8
+ export async function mapWithProgress(items, concurrency, fn, onItem) {
9
+ if (items.length === 0)
10
+ return [];
11
+ const results = new Array(items.length);
12
+ let nextIndex = 0;
13
+ let completed = 0;
14
+ async function worker() {
15
+ while (nextIndex < items.length) {
16
+ const index = nextIndex++;
17
+ const item = items[index];
18
+ results[index] = await fn(item, index);
19
+ completed += 1;
20
+ onItem?.(item, completed, items.length);
21
+ }
22
+ }
23
+ const workers = Math.min(concurrency, items.length);
24
+ await Promise.all(Array.from({ length: workers }, () => worker()));
25
+ return results;
26
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Repo-wide checks: things the per-file pass can't see.
3
+ * No node_modules access — the walk() already filters them out.
4
+ */
5
+ import fs from "node:fs/promises";
6
+ import path from "node:path";
7
+ async function exists(p) {
8
+ try {
9
+ await fs.access(p);
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ async function readMaybe(p) {
17
+ try {
18
+ return await fs.readFile(p, "utf8");
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export const REPO_WIDE_CHECK_COUNT = 9;
25
+ export async function runRepoChecks(root, _files) {
26
+ const out = [];
27
+ const has = async (rel) => exists(path.join(root, rel));
28
+ // README
29
+ if (!(await has("README.md")) && !(await has("readme.md"))) {
30
+ out.push({
31
+ id: "missing-readme",
32
+ category: "Deployment & Testing Readiness",
33
+ severity: "medium",
34
+ file: "README.md", line: 1,
35
+ message: "README.md is missing.",
36
+ why: "New contributors and your future self can't tell what the project is or how to run it.",
37
+ fix: "Add a README with a one-line description, install, run, and deploy commands.",
38
+ aiPrompt: "Generate a concise README.md for this project: one-line description, prerequisites, install, dev, build, and deploy commands.",
39
+ });
40
+ }
41
+ // .env.example
42
+ if (!(await has(".env.example"))) {
43
+ out.push({
44
+ id: "missing-env-example",
45
+ category: "Deployment & Testing Readiness",
46
+ severity: "medium",
47
+ file: ".env.example", line: 1,
48
+ message: ".env.example is missing.",
49
+ why: "Anyone cloning the repo has to guess which env vars are required.",
50
+ fix: "Create a .env.example listing every env var with a placeholder value (no secrets).",
51
+ aiPrompt: "Scan the codebase for process.env.* and import.meta.env.* reads and produce a .env.example file listing every variable found with placeholder values.",
52
+ });
53
+ }
54
+ // public dir favicon
55
+ const pkgRaw = await readMaybe(path.join(root, "package.json"));
56
+ const looksLikeWeb = pkgRaw ? /react|vite|next|tanstack|svelte|vue/.test(pkgRaw) : false;
57
+ // Detect project type based on name/description to avoid false positives.
58
+ // We do NOT rely on "private": true because many SaaS landing pages are in private repos.
59
+ let isInternalAdmin = false;
60
+ let isCustomerPortal = false;
61
+ const evaluateName = (str) => {
62
+ if (/(?:^|_|-|\b)(admin|internal|backoffice|cms)(?:_|-|\b|$)/i.test(str))
63
+ isInternalAdmin = true;
64
+ if (/(?:^|_|-|\b)(portal|dashboard)(?:_|-|\b|$)/i.test(str))
65
+ isCustomerPortal = true;
66
+ };
67
+ if (pkgRaw) {
68
+ try {
69
+ const pkg = JSON.parse(pkgRaw);
70
+ evaluateName(pkg.name || "");
71
+ evaluateName(pkg.description || "");
72
+ }
73
+ catch { }
74
+ }
75
+ if (!isInternalAdmin && !isCustomerPortal) {
76
+ evaluateName(path.basename(root));
77
+ }
78
+ if (looksLikeWeb) {
79
+ const favicons = ["public/favicon.ico", "public/favicon.svg", "src/favicon.ico"];
80
+ let hasFavicon = false;
81
+ for (const p of favicons)
82
+ if (await has(p)) {
83
+ hasFavicon = true;
84
+ break;
85
+ }
86
+ if (!hasFavicon) {
87
+ out.push({
88
+ id: "missing-favicon",
89
+ category: "Polish & Embarrassment Risks",
90
+ severity: "medium",
91
+ file: "public/favicon.ico", line: 1,
92
+ message: "No favicon found.",
93
+ why: "Without a favicon the browser tab shows a generic blank icon. Looks unfinished.",
94
+ fix: "Add a favicon (favicon.ico or .svg) to /public and reference it from your root layout head.",
95
+ aiPrompt: "Add a favicon.svg to /public and a <link rel=\"icon\"> tag in the root layout's head().",
96
+ });
97
+ }
98
+ if (!isInternalAdmin && !isCustomerPortal) {
99
+ if (!(await has("public/robots.txt"))) {
100
+ out.push({
101
+ id: "missing-robots",
102
+ category: "SEO & Discoverability",
103
+ severity: "medium",
104
+ file: "public/robots.txt", line: 1,
105
+ message: "robots.txt is missing.",
106
+ why: "Search engines may crawl pages you don't want indexed, or miss pages you do.",
107
+ fix: "Add a public/robots.txt with at minimum: User-agent: * Allow: / Sitemap: https://your-domain/sitemap.xml",
108
+ aiPrompt: "Create public/robots.txt with sane defaults: allow all, and a Sitemap line pointing at /sitemap.xml.",
109
+ });
110
+ }
111
+ }
112
+ if (!isInternalAdmin) {
113
+ const legalFilePaths = _files.filter(f => /terms|privacy|cookie|policy|legal|compliance|eula|tos/i.test(path.basename(f)));
114
+ if (legalFilePaths.length === 0) {
115
+ out.push({
116
+ id: "missing-legal-docs",
117
+ category: "Legal & Compliance",
118
+ severity: "critical",
119
+ file: "root", line: 1,
120
+ message: "Missing Documents: Privacy Policy, Terms of Service completely absent.",
121
+ why: "Running a web app handling user data without legal policies creates massive liability.",
122
+ fix: "Create a Terms of Service and Privacy Policy, and link them in your footer.",
123
+ aiPrompt: "Generate a standard Privacy Policy and Terms of Service route for this application.",
124
+ });
125
+ }
126
+ else if (pkgRaw) {
127
+ let combinedSource = pkgRaw;
128
+ for (const abs of _files) {
129
+ const rel = path.relative(root, abs);
130
+ if (!/\.(tsx|jsx|ts|js)$/.test(rel))
131
+ continue;
132
+ const chunk = await readMaybe(abs);
133
+ if (chunk)
134
+ combinedSource += chunk;
135
+ }
136
+ const hasAuth = /supabase|clerk|firebase|next-auth|auth0|createClient.*auth/i.test(combinedSource);
137
+ const hasPayments = /stripe|braintree|paypal|lemonsqueezy/i.test(combinedSource);
138
+ const hasAnalytics = /posthog|mixpanel|segment|amplitude|google-analytics|gtag\s*\(|fbq\s*\(|plausible|hotjar/i.test(combinedSource);
139
+ let combinedLegalContent = "";
140
+ for (const p of legalFilePaths) {
141
+ const content = await readMaybe(p);
142
+ if (content)
143
+ combinedLegalContent += content.toLowerCase();
144
+ }
145
+ if (hasAuth && !/account|email|password|authentication|login/i.test(combinedLegalContent)) {
146
+ out.push({
147
+ id: "incomplete-legal-auth",
148
+ category: "Legal & Compliance",
149
+ severity: "high",
150
+ file: path.basename(legalFilePaths[0]), line: 1,
151
+ message: "Privacy Policy does not mention User Accounts or Authentication.",
152
+ why: "The app uses authentication dependencies, but the legal pages do not disclose the collection of account data (e.g. emails).",
153
+ fix: "Update your Privacy Policy to disclose what user data is collected upon account creation and how it is protected.",
154
+ aiPrompt: "Update the Privacy Policy to include a section about User Accounts, disclosing the collection of email addresses.",
155
+ });
156
+ }
157
+ if (hasPayments && !/payment|stripe|credit card|billing/i.test(combinedLegalContent)) {
158
+ out.push({
159
+ id: "incomplete-legal-payments",
160
+ category: "Legal & Compliance",
161
+ severity: "high",
162
+ file: path.basename(legalFilePaths[0]), line: 1,
163
+ message: "Privacy Policy does not mention Payments or Billing.",
164
+ why: "The app uses payment processing dependencies, but the legal pages do not disclose the handling of financial data.",
165
+ fix: "Update your Privacy Policy to disclose that payment information is collected and processed by third-party providers.",
166
+ aiPrompt: "Update the Privacy Policy to include a section about Payments, disclosing that payment data is handled securely by a processor.",
167
+ });
168
+ }
169
+ if (hasAnalytics && !/analytic|tracking|cookie|mixpanel|posthog/i.test(combinedLegalContent)) {
170
+ out.push({
171
+ id: "incomplete-legal-analytics",
172
+ category: "Legal & Compliance",
173
+ severity: "high",
174
+ file: path.basename(legalFilePaths[0]), line: 1,
175
+ message: "Privacy Policy does not mention Analytics or Tracking.",
176
+ why: "The app uses analytics dependencies, but the legal pages do not disclose tracking, cookies, or telemetry.",
177
+ fix: "Update your Privacy Policy to disclose the use of analytics tools and tracking cookies.",
178
+ aiPrompt: "Update the Privacy Policy to include a section about Analytics and Cookies, disclosing the use of tracking telemetry.",
179
+ });
180
+ }
181
+ if (!/gdpr|ccpa|california consumer|general data protection/i.test(combinedLegalContent)) {
182
+ out.push({
183
+ id: "missing-legal-compliance",
184
+ category: "Legal & Compliance",
185
+ severity: "high",
186
+ file: path.basename(legalFilePaths[0]), line: 1,
187
+ message: "Privacy Policy missing GDPR / CCPA compliance wording.",
188
+ why: "Failing to include required clauses for EU/CA users can result in severe FTC/regulatory fines and liabilities.",
189
+ fix: "Add sections explicitly covering GDPR and CCPA rights (right to delete, right to access).",
190
+ aiPrompt: "Update the Privacy Policy to include standard GDPR and CCPA data rights clauses.",
191
+ });
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return out;
197
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * SEO & social sharing checks — things that make links look broken on Twitter/Slack/iMessage.
3
+ */
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ async function exists(p) {
7
+ try {
8
+ await fs.access(p);
9
+ return true;
10
+ }
11
+ catch {
12
+ return false;
13
+ }
14
+ }
15
+ async function readMaybe(p) {
16
+ try {
17
+ return await fs.readFile(p, "utf8");
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function isMarketingRoute(rel) {
24
+ if (!/routes\//.test(rel))
25
+ return false;
26
+ if (/_authenticated|admin|dashboard|api\//.test(rel))
27
+ return false;
28
+ return /(?:^|\/)index\.(tsx|jsx)$/.test(rel)
29
+ || /routes\/(?:index|__root)\.(tsx|jsx)$/.test(rel)
30
+ || !/_authenticated|admin/.test(rel);
31
+ }
32
+ export const SEO_CHECK_COUNT = 5;
33
+ export async function runSeoChecks(root, files) {
34
+ const out = [];
35
+ const relFiles = files.map((f) => path.relative(root, f));
36
+ const pkgRaw = await readMaybe(path.join(root, "package.json"));
37
+ const looksLikeWeb = pkgRaw ? /react|vite|next|tanstack|svelte|vue/.test(pkgRaw) : false;
38
+ if (!looksLikeWeb)
39
+ return out;
40
+ const routeSources = [];
41
+ for (const rel of relFiles) {
42
+ if (!/routes\/.*\.(tsx|jsx)$/.test(rel))
43
+ continue;
44
+ const content = await readMaybe(path.join(root, rel));
45
+ if (content)
46
+ routeSources.push({ rel, content });
47
+ }
48
+ // ── og:image on public-facing routes ──────────────────────────────────
49
+ const socialMetaSource = await readMaybe(path.join(root, "src/lib/social-meta.ts"))
50
+ ?? await readMaybe(path.join(root, "src/lib/social-meta.tsx"));
51
+ const hasOgImageMeta = (content) => /og:image|property:\s*["']og:image["']/i.test(content)
52
+ || (/socialMeta|social-meta/.test(content) && !!socialMetaSource && /og:image/.test(socialMetaSource));
53
+ const marketingRoutes = routeSources.filter((r) => isMarketingRoute(r.rel));
54
+ const routesMissingOgImage = marketingRoutes.filter((r) => !hasOgImageMeta(r.content));
55
+ if (routesMissingOgImage.length > 0) {
56
+ const names = routesMissingOgImage.map((r) => r.rel).slice(0, 3).join(", ");
57
+ out.push({
58
+ id: "missing-og-image",
59
+ category: "SEO & Discoverability",
60
+ severity: "medium",
61
+ file: routesMissingOgImage[0].rel,
62
+ line: 1,
63
+ message: "Public route(s) missing og:image — link previews will show no image.",
64
+ why: "When someone shares your URL on Slack, Twitter, or iMessage, it looks broken without a preview image. First impressions matter.",
65
+ fix: "Add a 1200×630 og:image (PNG or JPG) to /public, then add { property: \"og:image\", content: \"https://your-domain.com/og.png\" } to the route head().",
66
+ aiPrompt: `In ${names}, add og:image and twitter:card meta tags pointing at a 1200×630 social preview image in /public.`,
67
+ });
68
+ }
69
+ // ── sitemap ───────────────────────────────────────────────────────────
70
+ const hasSitemap = (await exists(path.join(root, "public/sitemap.xml")))
71
+ || relFiles.some((f) => /sitemap\.(?:xml|ts|tsx|js)$/i.test(f));
72
+ const hasRobots = await exists(path.join(root, "public/robots.txt"));
73
+ if (hasRobots && !hasSitemap) {
74
+ const robots = await readMaybe(path.join(root, "public/robots.txt"));
75
+ if (robots && /sitemap/i.test(robots)) {
76
+ out.push({
77
+ id: "missing-sitemap",
78
+ category: "SEO & Discoverability",
79
+ severity: "medium",
80
+ file: "public/sitemap.xml",
81
+ line: 1,
82
+ message: "robots.txt references a sitemap but no sitemap file was found.",
83
+ why: "Search engines expect the sitemap URL in robots.txt to actually exist.",
84
+ fix: "Generate public/sitemap.xml listing your public routes, or remove the Sitemap line from robots.txt until you have one.",
85
+ aiPrompt: "Create public/sitemap.xml with URLs for all public marketing pages, or fix the robots.txt Sitemap URL.",
86
+ });
87
+ }
88
+ else if (marketingRoutes.length > 0) {
89
+ out.push({
90
+ id: "missing-sitemap",
91
+ category: "SEO & Discoverability",
92
+ severity: "low",
93
+ file: "public/sitemap.xml",
94
+ line: 1,
95
+ message: "No sitemap.xml found for a public-facing site.",
96
+ why: "Search engines discover pages faster with a sitemap. Without one, new pages may index slowly or not at all.",
97
+ fix: "Add public/sitemap.xml listing your public routes.",
98
+ aiPrompt: "Create public/sitemap.xml listing the landing page, docs, privacy, and terms routes.",
99
+ });
100
+ }
101
+ }
102
+ // ── broken asset references in head/links ───────────────────────────
103
+ const assetRefs = [];
104
+ for (const { rel, content } of routeSources) {
105
+ const re = /href:\s*["'](\/[^"']+\.(?:png|jpg|jpeg|webp|svg|ico))["']/gi;
106
+ let m;
107
+ while ((m = re.exec(content)))
108
+ assetRefs.push(m[1]);
109
+ }
110
+ for (const asset of [...new Set(assetRefs)]) {
111
+ const candidates = [
112
+ path.join(root, "public", asset.replace(/^\//, "")),
113
+ path.join(root, asset.replace(/^\//, "")),
114
+ ];
115
+ let found = false;
116
+ for (const c of candidates) {
117
+ if (await exists(c)) {
118
+ found = true;
119
+ break;
120
+ }
121
+ }
122
+ if (!found) {
123
+ out.push({
124
+ id: "missing-seo-asset",
125
+ category: "SEO & Discoverability",
126
+ severity: "medium",
127
+ file: routeSources.find((r) => r.content.includes(asset))?.rel ?? "routes",
128
+ line: 1,
129
+ message: `Head/link references ${asset} but the file does not exist.`,
130
+ why: "Broken apple-touch-icon or og:image URLs produce 404s and broken previews in browsers and social crawlers.",
131
+ fix: `Add the missing file to /public${asset} or remove the reference.`,
132
+ aiPrompt: `Create ${asset} in /public or remove the broken link reference from the route head().`,
133
+ });
134
+ }
135
+ }
136
+ // ── duplicate page titles ─────────────────────────────────────────────
137
+ const titles = new Map();
138
+ for (const { rel, content } of routeSources) {
139
+ const re = /(?:title:\s*["']([^"']+)["']|property:\s*["']og:title["'],\s*content:\s*["']([^"']+)["'])/g;
140
+ let m;
141
+ while ((m = re.exec(content))) {
142
+ const title = (m[1] || m[2] || "").trim();
143
+ if (!title || title.length < 8)
144
+ continue;
145
+ if (!titles.has(title))
146
+ titles.set(title, []);
147
+ titles.get(title).push(rel);
148
+ }
149
+ }
150
+ for (const [title, routes] of titles) {
151
+ const uniqueRoutes = [...new Set(routes)];
152
+ if (uniqueRoutes.length > 1) {
153
+ out.push({
154
+ id: "duplicate-page-title",
155
+ category: "SEO & Discoverability",
156
+ severity: "low",
157
+ file: uniqueRoutes[0],
158
+ line: 1,
159
+ message: `Duplicate page title "${title.slice(0, 50)}${title.length > 50 ? "…" : ""}" on ${uniqueRoutes.length} routes.`,
160
+ why: "Duplicate titles confuse search engines and make browser tabs indistinguishable.",
161
+ fix: "Give each route a unique, descriptive title in its head() meta.",
162
+ aiPrompt: `Routes ${uniqueRoutes.join(", ")} share the same title. Give each a unique title describing that page.`,
163
+ });
164
+ }
165
+ }
166
+ return out;
167
+ }
@@ -0,0 +1,16 @@
1
+ import fg from "fast-glob";
2
+ export async function walk(root) {
3
+ return fg([
4
+ "**/*.{ts,tsx,js,jsx,mjs,cjs}",
5
+ "!**/node_modules/**",
6
+ "!**/dist/**",
7
+ "!**/build/**",
8
+ "!**/.next/**",
9
+ "!**/.output/**",
10
+ "!**/.git/**",
11
+ "!**/coverage/**",
12
+ "!**/*.d.ts",
13
+ "!**/routeTree.gen.*",
14
+ "!**/packages/cli/**", // never scan the scanner itself
15
+ ], { cwd: root, absolute: true, dot: false });
16
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};