laxy-verify 1.2.2 → 1.3.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,15 @@
1
+ export interface SecretScanResult {
2
+ passed: boolean;
3
+ findings: SecretFinding[];
4
+ filesScanned: number;
5
+ skipped: boolean;
6
+ }
7
+ export interface SecretFinding {
8
+ file: string;
9
+ line: number;
10
+ pattern: string;
11
+ preview: string;
12
+ }
13
+ export declare function runSecretScan(projectDir: string, options?: {
14
+ ignorePaths?: string[];
15
+ }): Promise<SecretScanResult>;
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runSecretScan = runSecretScan;
37
+ /**
38
+ * Secret/credential leak scanner.
39
+ *
40
+ * Scans source files for hardcoded secrets using regex patterns.
41
+ * Supports ignore paths and custom ignore patterns from .laxy.yml.
42
+ */
43
+ const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
45
+ const DEFAULT_IGNORE_DIRS = new Set([
46
+ "node_modules",
47
+ ".git",
48
+ ".next",
49
+ "dist",
50
+ "build",
51
+ ".laxy-tmp",
52
+ ".laxy-baselines",
53
+ "coverage",
54
+ ".cache",
55
+ "test",
56
+ "tests",
57
+ "__tests__",
58
+ "spec",
59
+ ]);
60
+ const SECRET_PATTERNS = [
61
+ { name: "AWS Access Key", regex: /AKIA[0-9A-Z]{16}/ },
62
+ { name: "AWS Secret Key", regex: /(?:aws_secret_access_key|AWS_SECRET_ACCESS_KEY)\s*[=:]\s*['"][A-Za-z0-9/+=]{40}['"]/ },
63
+ { name: "GitHub Token", regex: /ghp_[A-Za-z0-9]{36}/ },
64
+ { name: "GitHub OAuth", regex: /gho_[A-Za-z0-9]{36}/ },
65
+ { name: "GitHub App Token", regex: /(?:ghs_|ghu_)[A-Za-z0-9]{36}/ },
66
+ { name: "Slack Token", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
67
+ { name: "Slack Webhook", regex: /hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]+/ },
68
+ { name: "Private Key Block", regex: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/ },
69
+ { name: "Google API Key", regex: /AIza[0-9A-Za-z_-]{35}/ },
70
+ { name: "Google OAuth Token", regex: /[0-9]+-[a-z0-9_]{32}\.apps\.googleusercontent\.com/ },
71
+ { name: "Stripe Secret Key", regex: /sk_live_[0-9a-zA-Z]{24,}/ },
72
+ { name: "Stripe Publishable Key", regex: /pk_live_[0-9a-zA-Z]{24,}/ },
73
+ { name: "Twilio API Key", regex: /SK[0-9a-fA-F]{32}/ },
74
+ { name: "SendGrid API Key", regex: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/ },
75
+ { name: "Mailgun API Key", regex: /key-[0-9a-zA-Z]{32}/ },
76
+ { name: "Hardcoded Bearer Token", regex: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/ },
77
+ { name: "JWT-like Secret", regex: /['"]eyJ[A-Za-z0-9\-._~+/]+=*\.[A-Za-z0-9\-._~+/]+=*['"]/ },
78
+ { name: "Generic Secret Assignment", regex: /(?:secret|password|passwd|token|api_key|apikey|access_key|private_key)\s*[=:]\s*['"][^'"]{8,}['"]/i },
79
+ ];
80
+ const ALLOWLIST_PATTERNS = [
81
+ // Common false positives
82
+ /process\.env\./,
83
+ /import\.meta\.env\./,
84
+ /NEXT_PUBLIC_/,
85
+ /VITE_/,
86
+ /REACT_APP_/,
87
+ /NUXT_ENV_/,
88
+ /placeholder/i,
89
+ /example/i,
90
+ /your[_-]?(?:api|secret|key|token)/i,
91
+ /xxx+/,
92
+ /<[^>]+>/,
93
+ /\$\{\{.*\}\}/, // GitHub Actions / CI template variables
94
+ /secrets\.[A-Z_]+/, // GitHub Actions secrets references
95
+ /env\.[A-Z_]+/, // CI env variable references
96
+ /regex:\s*\//, // Regex pattern definitions in source code
97
+ /^\s*\/\//, // Single-line comments (// ...)
98
+ /^\s*\*/, // Block comment lines (* ...)
99
+ /^\s*<!--/, // HTML comment lines
100
+ ];
101
+ function shouldIgnoreLine(line) {
102
+ return ALLOWLIST_PATTERNS.some((p) => p.test(line));
103
+ }
104
+ /**
105
+ * Masks potential secret values in a line preview so that
106
+ * actual credentials are never exposed in JSON output or reports.
107
+ * Replaces quoted string contents after = or : with ***.
108
+ */
109
+ function maskSecret(line) {
110
+ return line
111
+ // Mask single-quoted values: = '...' or : '...'
112
+ .replace(/([=:])\s*'([^']{4,})'/g, "$1 '***'")
113
+ // Mask double-quoted values: = "..." or : "..."
114
+ .replace(/([=:])\s*"([^"]{4,})"/g, '$1 "***"')
115
+ // Mask bare tokens after Bearer
116
+ .replace(/(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/g, "$1***")
117
+ // Mask AKIA keys
118
+ .replace(/(AKIA)[0-9A-Z]{16}/g, "$1****************")
119
+ // Mask ghp_/gho_/ghs_/ghu_ tokens
120
+ .replace(/(gh[pous]_)[A-Za-z0-9]{36}/g, "$1************************************")
121
+ // Mask xoxb-/xoxp- etc tokens
122
+ .replace(/(xox[baprs]-)[A-Za-z0-9-]{10,}/g, "$1**********")
123
+ // Mask AIza keys
124
+ .replace(/(AIza)[0-9A-Za-z_-]{35}/g, "$1***********************************")
125
+ // Mask sk_live_/pk_live_ keys
126
+ .replace(/(s?k_live_)[0-9a-zA-Z]{24,}/g, "$1************************")
127
+ // Mask SK hex keys (Twilio)
128
+ .replace(/(SK)[0-9a-fA-F]{32}/g, "$1********************************")
129
+ // Mask key- prefix (Mailgun)
130
+ .replace(/(key-)[0-9a-zA-Z]{32}/g, "$1********************************")
131
+ // Mask SG. tokens (SendGrid)
132
+ .replace(/(SG\.)[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, "$1**********************.*******************************************");
133
+ }
134
+ function scanFile(filePath, relativePath, ignorePatterns) {
135
+ if (ignorePatterns.some((p) => relativePath.includes(p)))
136
+ return [];
137
+ const findings = [];
138
+ let content;
139
+ try {
140
+ content = fs.readFileSync(filePath, "utf-8");
141
+ }
142
+ catch {
143
+ return [];
144
+ }
145
+ const lines = content.split("\n");
146
+ for (let i = 0; i < lines.length; i++) {
147
+ const line = lines[i];
148
+ if (shouldIgnoreLine(line))
149
+ continue;
150
+ for (const { name, regex } of SECRET_PATTERNS) {
151
+ if (regex.test(line)) {
152
+ const preview = maskSecret(line.trim().slice(0, 120));
153
+ findings.push({
154
+ file: relativePath,
155
+ line: i + 1,
156
+ pattern: name,
157
+ preview,
158
+ });
159
+ break; // one finding per line max
160
+ }
161
+ }
162
+ }
163
+ return findings;
164
+ }
165
+ function walkDir(dir, rootDir, ignoreDirs, ignorePatterns) {
166
+ const results = [];
167
+ let entries;
168
+ try {
169
+ entries = fs.readdirSync(dir, { withFileTypes: true });
170
+ }
171
+ catch {
172
+ return results;
173
+ }
174
+ for (const entry of entries) {
175
+ if (ignoreDirs.has(entry.name))
176
+ continue;
177
+ if (entry.name.startsWith(".") && entry.name !== ".env")
178
+ continue;
179
+ const fullPath = path.join(dir, entry.name);
180
+ const relativePath = path.relative(rootDir, fullPath).replace(/\\/g, "/");
181
+ if (ignorePatterns.some((p) => relativePath.includes(p)))
182
+ continue;
183
+ if (entry.isDirectory()) {
184
+ results.push(...walkDir(fullPath, rootDir, ignoreDirs, ignorePatterns));
185
+ }
186
+ else if (entry.isFile()) {
187
+ const ext = path.extname(entry.name).toLowerCase();
188
+ if ([".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte", ".env", ".json", ".yml", ".yaml", ".toml", ".py", ".rb"].includes(ext) ||
189
+ entry.name === ".env" || entry.name.startsWith(".env.")) {
190
+ results.push(fullPath);
191
+ }
192
+ }
193
+ }
194
+ return results;
195
+ }
196
+ async function runSecretScan(projectDir, options) {
197
+ const ignorePatterns = options?.ignorePaths ?? [];
198
+ const ignoreDirs = new Set(DEFAULT_IGNORE_DIRS);
199
+ console.log(" Running secret scan...");
200
+ const files = walkDir(projectDir, projectDir, ignoreDirs, ignorePatterns);
201
+ const allFindings = [];
202
+ for (const file of files) {
203
+ const relativePath = path.relative(projectDir, file).replace(/\\/g, "/");
204
+ const findings = scanFile(file, relativePath, ignorePatterns);
205
+ allFindings.push(...findings);
206
+ }
207
+ const passed = allFindings.length === 0;
208
+ if (passed) {
209
+ console.log(` Secret scan: OK (${files.length} files scanned)`);
210
+ }
211
+ else {
212
+ console.log(` Secret scan: ${allFindings.length} finding(s) in ${files.length} files`);
213
+ for (const f of allFindings.slice(0, 5)) {
214
+ console.error(` ${f.file}:${f.line} [${f.pattern}]`);
215
+ }
216
+ }
217
+ return { passed, findings: allFindings, filesScanned: files.length, skipped: false };
218
+ }
@@ -1,9 +1,17 @@
1
- export interface SecurityAuditResult {
2
- totalVulnerabilities: number;
3
- critical: number;
4
- high: number;
5
- moderate: number;
6
- low: number;
7
- summary: string;
8
- }
9
- export declare function runSecurityAudit(cwd: string, timeoutMs?: number): Promise<SecurityAuditResult>;
1
+ export interface SecurityAuditResult {
2
+ totalVulnerabilities: number;
3
+ critical: number;
4
+ high: number;
5
+ moderate: number;
6
+ low: number;
7
+ summary: string;
8
+ missingHeaders: string[];
9
+ headerCheckUrl?: string;
10
+ headerCheckError?: string;
11
+ }
12
+ export declare function auditSecurityHeaders(url: string): Promise<{
13
+ missingHeaders: string[];
14
+ checkedUrl: string;
15
+ error?: string;
16
+ }>;
17
+ export declare function runSecurityAudit(cwd: string, appUrl?: string, timeoutMs?: number): Promise<SecurityAuditResult>;
@@ -1,64 +1,127 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.runSecurityAudit = runSecurityAudit;
4
- /**
5
- * npm audit wrapper for Pro/Pro+ security scanning.
6
- *
7
- * Runs `npm audit --json` in the project directory and extracts
8
- * severity counts + a short summary for the verification report.
9
- */
10
- const node_child_process_1 = require("node:child_process");
11
- async function runSecurityAudit(cwd, timeoutMs = 30000) {
12
- console.log(" Running security audit (npm audit)...");
13
- return new Promise((resolve) => {
14
- const chunks = [];
15
- const proc = process.platform === "win32"
16
- ? (0, node_child_process_1.spawn)(process.env.ComSpec || "cmd.exe", ["/d", "/c", "npm audit --json"], {
17
- stdio: ["ignore", "pipe", "pipe"],
18
- cwd,
19
- })
20
- : (0, node_child_process_1.spawn)("npm", ["audit", "--json"], {
21
- shell: true,
22
- stdio: ["ignore", "pipe", "pipe"],
23
- cwd,
24
- });
25
- const timer = setTimeout(() => {
26
- try {
27
- proc.kill();
28
- }
29
- catch { }
30
- resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0, summary: "Audit timed out" });
31
- }, timeoutMs);
32
- proc.stdout?.on("data", (chunk) => chunks.push(chunk.toString()));
33
- proc.stderr?.on("data", () => { }); // ignore stderr
34
- proc.on("exit", () => {
35
- clearTimeout(timer);
36
- const raw = chunks.join("");
37
- try {
38
- const json = JSON.parse(raw);
39
- // npm audit v2 format
40
- const meta = json.metadata?.vulnerabilities ?? json.vulnerabilities ?? {};
41
- const critical = meta.critical ?? 0;
42
- const high = meta.high ?? 0;
43
- const moderate = meta.moderate ?? 0;
44
- const low = meta.low ?? 0;
45
- const total = critical + high + moderate + low;
46
- const parts = [];
47
- if (critical > 0)
48
- parts.push(`${critical} critical`);
49
- if (high > 0)
50
- parts.push(`${high} high`);
51
- if (moderate > 0)
52
- parts.push(`${moderate} moderate`);
53
- if (low > 0)
54
- parts.push(`${low} low`);
55
- const summary = total === 0 ? "No known vulnerabilities" : parts.join(", ");
56
- console.log(` Security: ${summary}`);
57
- resolve({ totalVulnerabilities: total, critical, high, moderate, low, summary });
58
- }
59
- catch {
60
- resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0, summary: "Audit parse failed" });
61
- }
62
- });
63
- });
64
- }
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.auditSecurityHeaders = auditSecurityHeaders;
4
+ exports.runSecurityAudit = runSecurityAudit;
5
+ /**
6
+ * npm audit wrapper plus runtime security-header checks.
7
+ *
8
+ * The package audit catches known dependency vulnerabilities.
9
+ * The header audit adds shallow runtime coverage for common missing
10
+ * browser-enforced protections on the running app.
11
+ */
12
+ const node_child_process_1 = require("node:child_process");
13
+ async function runNpmAudit(cwd, timeoutMs) {
14
+ return new Promise((resolve) => {
15
+ const chunks = [];
16
+ const proc = process.platform === "win32"
17
+ ? (0, node_child_process_1.spawn)(process.env.ComSpec || "cmd.exe", ["/d", "/c", "npm audit --json"], {
18
+ stdio: ["ignore", "pipe", "pipe"],
19
+ cwd,
20
+ })
21
+ : (0, node_child_process_1.spawn)("npm", ["audit", "--json"], {
22
+ shell: true,
23
+ stdio: ["ignore", "pipe", "pipe"],
24
+ cwd,
25
+ });
26
+ const timer = setTimeout(() => {
27
+ try {
28
+ proc.kill();
29
+ }
30
+ catch { }
31
+ resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0 });
32
+ }, timeoutMs);
33
+ proc.stdout?.on("data", (chunk) => chunks.push(chunk.toString()));
34
+ proc.stderr?.on("data", () => { });
35
+ proc.on("exit", () => {
36
+ clearTimeout(timer);
37
+ try {
38
+ const json = JSON.parse(chunks.join(""));
39
+ const meta = json.metadata?.vulnerabilities ?? json.vulnerabilities ?? {};
40
+ const critical = meta.critical ?? 0;
41
+ const high = meta.high ?? 0;
42
+ const moderate = meta.moderate ?? 0;
43
+ const low = meta.low ?? 0;
44
+ resolve({
45
+ totalVulnerabilities: critical + high + moderate + low,
46
+ critical,
47
+ high,
48
+ moderate,
49
+ low,
50
+ });
51
+ }
52
+ catch {
53
+ resolve({ totalVulnerabilities: 0, critical: 0, high: 0, moderate: 0, low: 0 });
54
+ }
55
+ });
56
+ });
57
+ }
58
+ async function auditSecurityHeaders(url) {
59
+ const requiredHeaders = ["X-Frame-Options", "Content-Security-Policy"];
60
+ const urlObj = new URL(url);
61
+ if (urlObj.protocol === "https:") {
62
+ requiredHeaders.push("Strict-Transport-Security");
63
+ }
64
+ let response;
65
+ try {
66
+ response = await fetch(url, {
67
+ method: "HEAD",
68
+ redirect: "follow",
69
+ signal: AbortSignal.timeout(5000),
70
+ });
71
+ }
72
+ catch {
73
+ try {
74
+ response = await fetch(url, {
75
+ method: "GET",
76
+ redirect: "follow",
77
+ signal: AbortSignal.timeout(5000),
78
+ });
79
+ }
80
+ catch (error) {
81
+ return {
82
+ missingHeaders: [],
83
+ checkedUrl: url,
84
+ error: error instanceof Error ? error.message : String(error),
85
+ };
86
+ }
87
+ }
88
+ const missingHeaders = requiredHeaders.filter((headerName) => !response.headers.get(headerName));
89
+ return {
90
+ missingHeaders,
91
+ checkedUrl: response.url || url,
92
+ };
93
+ }
94
+ async function runSecurityAudit(cwd, appUrl, timeoutMs = 30000) {
95
+ console.log(" Running security audit (npm audit + runtime headers)...");
96
+ const [npmAudit, headerAudit] = await Promise.all([
97
+ runNpmAudit(cwd, timeoutMs),
98
+ appUrl ? auditSecurityHeaders(appUrl) : Promise.resolve(null),
99
+ ]);
100
+ const parts = [];
101
+ if (npmAudit.critical > 0)
102
+ parts.push(`${npmAudit.critical} critical`);
103
+ if (npmAudit.high > 0)
104
+ parts.push(`${npmAudit.high} high`);
105
+ if (npmAudit.moderate > 0)
106
+ parts.push(`${npmAudit.moderate} moderate`);
107
+ if (npmAudit.low > 0)
108
+ parts.push(`${npmAudit.low} low`);
109
+ const missingHeaders = headerAudit?.missingHeaders ?? [];
110
+ if (missingHeaders.length > 0) {
111
+ parts.push(`missing headers: ${missingHeaders.join(", ")}`);
112
+ }
113
+ if (headerAudit?.error) {
114
+ parts.push(`header check skipped: ${headerAudit.error}`);
115
+ }
116
+ const summary = parts.length > 0
117
+ ? parts.join(" | ")
118
+ : "No known vulnerabilities or missing security headers";
119
+ console.log(` Security: ${summary}`);
120
+ return {
121
+ ...npmAudit,
122
+ summary,
123
+ missingHeaders,
124
+ headerCheckUrl: headerAudit?.checkedUrl,
125
+ headerCheckError: headerAudit?.error,
126
+ };
127
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Deep SEO audit.
3
+ *
4
+ * Checks page-level SEO signals beyond Lighthouse's basic SEO category:
5
+ * title, meta description, canonical, robots, Open Graph, Twitter Card,
6
+ * JSON-LD structured data. Runs against the running dev server via HTTP.
7
+ */
8
+ export interface SeoDeepResult {
9
+ passed: boolean;
10
+ checks: SeoCheck[];
11
+ warningCount: number;
12
+ errorCount: number;
13
+ url: string;
14
+ skipped: boolean;
15
+ summary: string;
16
+ }
17
+ export interface SeoCheck {
18
+ key: string;
19
+ label: string;
20
+ passed: boolean;
21
+ value: string | null;
22
+ severity: "error" | "warning";
23
+ }
24
+ export declare function runSeoDeep(url: string): Promise<SeoDeepResult>;
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runSeoDeep = runSeoDeep;
4
+ async function fetchPageHtml(url) {
5
+ try {
6
+ const res = await fetch(url, {
7
+ signal: AbortSignal.timeout(10000),
8
+ headers: { "User-Agent": "laxy-verify/1.0" },
9
+ });
10
+ if (!res.ok)
11
+ return null;
12
+ return await res.text();
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ }
18
+ function extractMetaContent(html, nameAttr) {
19
+ // Match <meta name="..." content="..."> or <meta property="..." content="...">
20
+ const regex = new RegExp(`<meta\\s+(?:name|property)=["']${nameAttr}["']\\s+content=["']([^"']*)["']`, "i");
21
+ const match = html.match(regex);
22
+ if (match)
23
+ return match[1];
24
+ // Also try content before name/property
25
+ const regex2 = new RegExp(`<meta\\s+content=["']([^"']*)["']\\s+(?:name|property)=["']${nameAttr}["']`, "i");
26
+ const match2 = html.match(regex2);
27
+ return match2 ? match2[1] : null;
28
+ }
29
+ function extractTitle(html) {
30
+ const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
31
+ return match ? match[1].trim() : null;
32
+ }
33
+ function extractCanonical(html) {
34
+ const match = html.match(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i);
35
+ return match ? match[1] : null;
36
+ }
37
+ function extractRobotsMeta(html) {
38
+ return extractMetaContent(html, "robots");
39
+ }
40
+ function hasJsonLd(html) {
41
+ return /<script[^>]+type=["']application\/ld\+json["']/i.test(html);
42
+ }
43
+ async function runSeoDeep(url) {
44
+ console.log(" Running deep SEO audit...");
45
+ const html = await fetchPageHtml(url);
46
+ if (!html) {
47
+ return {
48
+ passed: true,
49
+ checks: [],
50
+ warningCount: 0,
51
+ errorCount: 0,
52
+ url,
53
+ skipped: true,
54
+ summary: "Skipped (could not fetch page)",
55
+ };
56
+ }
57
+ const checks = [];
58
+ // Title
59
+ const title = extractTitle(html);
60
+ checks.push({
61
+ key: "title",
62
+ label: "Page title",
63
+ passed: !!title && title.length > 0 && title.length <= 60,
64
+ value: title,
65
+ severity: "error",
66
+ });
67
+ // Meta description
68
+ const description = extractMetaContent(html, "description");
69
+ checks.push({
70
+ key: "description",
71
+ label: "Meta description",
72
+ passed: !!description && description.length > 0 && description.length <= 160,
73
+ value: description,
74
+ severity: "warning",
75
+ });
76
+ // Canonical
77
+ const canonical = extractCanonical(html);
78
+ checks.push({
79
+ key: "canonical",
80
+ label: "Canonical URL",
81
+ passed: !!canonical,
82
+ value: canonical,
83
+ severity: "warning",
84
+ });
85
+ // Robots
86
+ const robots = extractRobotsMeta(html);
87
+ const robotsNoindex = robots?.includes("noindex") ?? false;
88
+ checks.push({
89
+ key: "robots",
90
+ label: "Robots meta",
91
+ passed: !robotsNoindex,
92
+ value: robots ?? "(not set — defaults to index)",
93
+ severity: "error",
94
+ });
95
+ // Open Graph
96
+ const ogTitle = extractMetaContent(html, "og:title");
97
+ const ogDescription = extractMetaContent(html, "og:description");
98
+ const ogImage = extractMetaContent(html, "og:image");
99
+ const hasOg = ogTitle || ogDescription || ogImage;
100
+ checks.push({
101
+ key: "og",
102
+ label: "Open Graph tags",
103
+ passed: !!hasOg,
104
+ value: hasOg
105
+ ? [ogTitle && "title", ogDescription && "desc", ogImage && "image"].filter(Boolean).join(", ")
106
+ : null,
107
+ severity: "warning",
108
+ });
109
+ // Twitter Card
110
+ const twitterCard = extractMetaContent(html, "twitter:card");
111
+ const twitterTitle = extractMetaContent(html, "twitter:title");
112
+ const hasTwitter = twitterCard || twitterTitle;
113
+ checks.push({
114
+ key: "twitter",
115
+ label: "Twitter Card tags",
116
+ passed: !!hasTwitter,
117
+ value: hasTwitter
118
+ ? [twitterCard && "card", twitterTitle && "title"].filter(Boolean).join(", ")
119
+ : null,
120
+ severity: "warning",
121
+ });
122
+ // JSON-LD
123
+ const jsonLd = hasJsonLd(html);
124
+ checks.push({
125
+ key: "jsonld",
126
+ label: "JSON-LD structured data",
127
+ passed: jsonLd,
128
+ value: jsonLd ? "present" : null,
129
+ severity: "warning",
130
+ });
131
+ const errorCount = checks.filter((c) => !c.passed && c.severity === "error").length;
132
+ const warningCount = checks.filter((c) => !c.passed && c.severity === "warning").length;
133
+ const passed = errorCount === 0;
134
+ const parts = [];
135
+ if (errorCount > 0)
136
+ parts.push(`${errorCount} error(s)`);
137
+ if (warningCount > 0)
138
+ parts.push(`${warningCount} warning(s)`);
139
+ const summary = parts.length > 0
140
+ ? parts.join(", ")
141
+ : "All SEO checks passed";
142
+ console.log(` SEO deep: ${summary}`);
143
+ for (const check of checks.filter((c) => !c.passed)) {
144
+ console.error(` [${check.severity}] ${check.label}: ${check.value ?? "missing"}`);
145
+ }
146
+ return { passed, checks, warningCount, errorCount, url, skipped: false, summary };
147
+ }
@@ -0,0 +1,8 @@
1
+ export interface TypecheckResult {
2
+ passed: boolean;
3
+ errorCount: number;
4
+ errors: string[];
5
+ skipped: boolean;
6
+ durationMs: number;
7
+ }
8
+ export declare function runTypecheck(projectDir: string, timeoutMs?: number): Promise<TypecheckResult>;