prodlint 0.7.2 → 0.8.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.
package/README.md CHANGED
@@ -4,16 +4,16 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/prodlint.svg)](https://www.npmjs.com/package/prodlint)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
6
 
7
- The linter for vibe-coded apps.
7
+ Production readiness for vibe-coded apps.
8
8
 
9
- Static analysis for vibe-coded apps. Catches the production bugs that Cursor, v0, Bolt, and Copilot write — hallucinated imports, missing auth, hardcoded secrets, unvalidated server actions, and more. Zero config, no LLM, 52 rules, under 100ms.
9
+ Static analysis for vibe-coded apps. Flags the security, reliability, performance, and AI quality issues that Cursor, v0, Bolt, and Copilot create — hallucinated imports, missing auth, hardcoded secrets, unvalidated server actions, and more. Zero config, no LLM, 52 rules, under 100ms.
10
10
 
11
11
  ```bash
12
12
  npx prodlint
13
13
  ```
14
14
 
15
15
  ```
16
- prodlint v0.7.1
16
+ prodlint v0.8.0
17
17
  Scanned 148 files · 3 critical · 5 warnings
18
18
 
19
19
  src/app/api/checkout/route.ts
@@ -40,9 +40,9 @@ npx prodlint
40
40
 
41
41
  ## Why?
42
42
 
43
- Vibe coding is the fastest way to build. It's also the fastest way to ship hardcoded secrets, hallucinated packages, missing auth, and XSS vectors to production. These pass type-checks and look correct — but they aren't.
43
+ Vibe coding is the fastest way to build. Shipping fast means knowing your code is production-ready — not just that it compiles. Hardcoded secrets, hallucinated packages, missing auth, and XSS vectors pass type-checks and look correct — but they aren't ready for production.
44
44
 
45
- prodlint catches what TypeScript and ESLint miss: **the bugs AI coding tools consistently write**.
45
+ prodlint checks what TypeScript and ESLint don't: **whether your vibe-coded app is ready for production**.
46
46
 
47
47
  ## Install
48
48
 
@@ -66,7 +66,7 @@ npm i -g prodlint # Global install
66
66
 
67
67
  ### Security (27 rules)
68
68
 
69
- | Rule | What it catches |
69
+ | Rule | What it checks |
70
70
  |------|----------------|
71
71
  | `secrets` | API keys, tokens, passwords hardcoded in source |
72
72
  | `auth-checks` | API routes with no authentication |
@@ -98,7 +98,7 @@ npm i -g prodlint # Global install
98
98
 
99
99
  ### Reliability (11 rules)
100
100
 
101
- | Rule | What it catches |
101
+ | Rule | What it checks |
102
102
  |------|----------------|
103
103
  | `hallucinated-imports` | Imports of packages not in package.json |
104
104
  | `error-handling` | Async operations without try/catch |
@@ -114,7 +114,7 @@ npm i -g prodlint # Global install
114
114
 
115
115
  ### Performance (6 rules)
116
116
 
117
- | Rule | What it catches |
117
+ | Rule | What it checks |
118
118
  |------|----------------|
119
119
  | `no-sync-fs` | `readFileSync` in API routes |
120
120
  | `no-n-plus-one` | Database calls inside loops |
@@ -125,7 +125,7 @@ npm i -g prodlint # Global install
125
125
 
126
126
  ### AI Quality (8 rules)
127
127
 
128
- | Rule | What it catches |
128
+ | Rule | What it checks |
129
129
  |------|----------------|
130
130
  | `ai-smells` | `any` types, `console.log`, TODO comments piling up |
131
131
  | `placeholder-content` | Lorem ipsum, example emails, "your-api-key-here" left in production code |
@@ -219,13 +219,49 @@ claude mcp add prodlint npx prodlint-mcp
219
219
 
220
220
  Ask your AI: *"Run prodlint on this project"* and it calls the `scan` tool directly.
221
221
 
222
+ ## Site Score
223
+
224
+ Check any deployed website for AI agent-readiness — 14 checks covering emerging standards like llms.txt, TDMRep, AgentCard, AI-Disclosure, HTTP Signatures (RFC 9421), and more.
225
+
226
+ ```bash
227
+ npx prodlint --web example.com
228
+ npx prodlint --web example.com --json # JSON output
229
+ ```
230
+
231
+ ```
232
+ prodlint site score
233
+ example.com · 14 checks
234
+
235
+ Score: 42 C ████████░░░░░░░░░░░░
236
+
237
+ ✗ AI-Disclosure Header 0/10 No AI-Disclosure header found.
238
+ ✗ Content-Usage Directives 0/10 No Content-Usage directives found.
239
+ ✗ TDMRep 0/10 No TDMRep found.
240
+ ✗ A2A AgentCard 0/5 No agent-card.json found.
241
+ ✗ ai.txt 0/5 No ai.txt found at site root.
242
+ ! llms.txt 2/5 llms.txt found but missing key sections.
243
+ ✓ robots.txt 10/10 robots.txt found with 15 rules.
244
+ ✓ Sitemap 10/10 Valid sitemap with 42 URLs.
245
+ ✓ Structured Data 10/10 Found JSON-LD structured data.
246
+ ✓ OpenGraph 10/10 Complete OpenGraph tags found.
247
+ ✓ Page Speed 5/5 Loaded in 0.8s.
248
+ ✓ AI Bot Directives 5/5 AI-specific bot rules found.
249
+ ✓ WebMCP Tools 0/5 No WebMCP tools detected.
250
+
251
+ 7 passed · 5 failed · 1 warnings
252
+
253
+ Full results: https://prodlint.com/score?url=example.com
254
+ ```
255
+
256
+ Or check your score interactively at [prodlint.com/score](https://prodlint.com/score).
257
+
222
258
  ## For AI Tools
223
259
 
224
260
  - **LLM-friendly docs**: [prodlint.com/llms.txt](https://prodlint.com/llms.txt) — concise project summary for LLMs
225
261
  - **Full reference**: [prodlint.com/llms-full.txt](https://prodlint.com/llms-full.txt) — all 52 rules with details
226
262
  - **MCP setup guide**: [prodlint.com/mcp](https://prodlint.com/mcp) — detailed editor setup for Claude Code, Cursor, Windsurf
227
263
 
228
- prodlint is designed specifically for AI-generated code patterns. Every rule targets bugs that AI coding tools consistently produce — not style nits.
264
+ prodlint is designed specifically for AI-generated code patterns. Every rule checks for production issues that AI coding tools consistently create — not style nits.
229
265
 
230
266
  ## Suppression
231
267
 
package/action.yml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: 'Prodlint'
2
- description: 'The linter for vibe-coded apps — catch what AI coding tools miss'
2
+ description: 'Production readiness for vibe-coded apps — check your AI code before you ship'
3
3
  branding:
4
4
  icon: 'shield'
5
5
  color: 'green'
@@ -101,7 +101,7 @@ runs:
101
101
  const d = JSON.parse(fs.readFileSync('/tmp/prodlint-result.json', 'utf8'));
102
102
  const esc = s => String(s).replace(/[[\]()\\*_\`<>]/g, c => '\\\\' + c);
103
103
  const lines = [];
104
- lines.push('## ' + '$EMOJI' + ' Prodlint Score: **' + d.overallScore + '/100**');
104
+ lines.push('## ' + '$EMOJI' + ' Production Readiness: **' + d.overallScore + '/100**');
105
105
  lines.push('');
106
106
  lines.push('| Category | Score | Issues |');
107
107
  lines.push('|----------|-------|--------|');
package/dist/cli.js CHANGED
@@ -720,7 +720,8 @@ var SECRET_PATTERNS = [
720
720
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
721
721
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
722
722
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
723
- { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
723
+ { name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
724
+ { name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
724
725
  { name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
725
726
  { name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
726
727
  { name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
@@ -4484,6 +4485,295 @@ function renderBar(score) {
4484
4485
  const color = scoreColor(score);
4485
4486
  return color("\u2588".repeat(filled)) + pc.dim("\u2591".repeat(empty));
4486
4487
  }
4488
+ var STATUS_ICONS = {
4489
+ pass: pc.green,
4490
+ fail: pc.red,
4491
+ warn: pc.yellow,
4492
+ info: pc.blue
4493
+ };
4494
+ var STATUS_SYMBOLS = {
4495
+ pass: "\u2713",
4496
+ fail: "\u2717",
4497
+ warn: "!",
4498
+ info: "i"
4499
+ };
4500
+ function reportWebPretty(result) {
4501
+ const lines = [];
4502
+ lines.push("");
4503
+ lines.push(pc.bold(" prodlint site score"));
4504
+ lines.push(pc.dim(` ${result.domain} \xB7 ${result.summary.totalChecks} checks`));
4505
+ lines.push("");
4506
+ const overallColor = scoreColor(result.overallScore);
4507
+ const bar = renderBar(result.overallScore);
4508
+ lines.push(` ${pc.bold("Score:")} ${overallColor(pc.bold(`${result.overallScore}`))} ${overallColor(result.grade)} ${bar}`);
4509
+ lines.push("");
4510
+ const order = { fail: 0, warn: 1, info: 2, pass: 3 };
4511
+ const sorted = [...result.checks].sort((a, b) => (order[a.status] ?? 9) - (order[b.status] ?? 9));
4512
+ for (const check of sorted) {
4513
+ const color = STATUS_ICONS[check.status] ?? pc.dim;
4514
+ const symbol = STATUS_SYMBOLS[check.status] ?? "?";
4515
+ const pts = `${check.points}/${check.maxPoints}`;
4516
+ lines.push(` ${color(symbol)} ${check.name.padEnd(28)} ${pc.dim(pts.padStart(6))} ${pc.dim(check.details || "")}`);
4517
+ }
4518
+ lines.push("");
4519
+ const parts = [];
4520
+ if (result.summary.passed > 0) parts.push(pc.green(`${result.summary.passed} passed`));
4521
+ if (result.summary.failed > 0) parts.push(pc.red(`${result.summary.failed} failed`));
4522
+ if (result.summary.warnings > 0) parts.push(pc.yellow(`${result.summary.warnings} warnings`));
4523
+ lines.push(` ${parts.join(pc.dim(" \xB7 "))}`);
4524
+ lines.push("");
4525
+ lines.push(pc.dim(` Full results: https://prodlint.com/score?url=${encodeURIComponent(result.domain)}`));
4526
+ lines.push("");
4527
+ return lines.join("\n");
4528
+ }
4529
+
4530
+ // src/web-scanner/checks.ts
4531
+ function make(id, name, description, maxPoints, severity, status, details) {
4532
+ return {
4533
+ id,
4534
+ name,
4535
+ description,
4536
+ status,
4537
+ severity,
4538
+ details,
4539
+ points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4540
+ maxPoints
4541
+ };
4542
+ }
4543
+ function checkRobotsTxt(ctx) {
4544
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4545
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4546
+ }
4547
+ function checkRobotsAiDirectives(ctx) {
4548
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4549
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4550
+ const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4551
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4552
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4553
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4554
+ }
4555
+ function checkContentUsage(ctx) {
4556
+ const hasHeader = ctx.headers["content-usage"] != null;
4557
+ const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4558
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4559
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4560
+ }
4561
+ function checkLlmsTxt(ctx) {
4562
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4563
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4564
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4565
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4566
+ }
4567
+ function checkTdmRep(ctx) {
4568
+ const hasWK = ctx.tdmRep != null;
4569
+ const hasHeader = ctx.headers["tdm-reservation"] != null;
4570
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4571
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4572
+ }
4573
+ function checkAiDisclosure(ctx) {
4574
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4575
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4576
+ }
4577
+ function checkAgentCard(ctx) {
4578
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4579
+ try {
4580
+ const card = JSON.parse(ctx.agentCard);
4581
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4582
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4583
+ } catch {
4584
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4585
+ }
4586
+ }
4587
+ function checkAiTxt(ctx) {
4588
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4589
+ const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4590
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4591
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4592
+ }
4593
+ function checkWebMCP(ctx) {
4594
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4595
+ const hasToolname = /toolname=/i.test(ctx.html);
4596
+ const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4597
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4598
+ const count = (ctx.html.match(/toolname=/gi) || []).length;
4599
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4600
+ }
4601
+ function checkStructuredData(ctx) {
4602
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4603
+ const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4604
+ const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4605
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4606
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4607
+ }
4608
+ function checkOpenGraph(ctx) {
4609
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4610
+ const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4611
+ const passed = checks.filter(Boolean).length;
4612
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4613
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4614
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4615
+ }
4616
+ function checkSitemap(ctx) {
4617
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4618
+ const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4619
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4620
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4621
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4622
+ }
4623
+ function checkHttpSignatures(ctx) {
4624
+ const hasDirectory = ctx.httpSigDirectory != null;
4625
+ const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4626
+ const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4627
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4628
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4629
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4630
+ }
4631
+ function checkPageSpeed(ctx) {
4632
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4633
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4634
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4635
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4636
+ }
4637
+ var allChecks = [
4638
+ checkRobotsTxt,
4639
+ checkRobotsAiDirectives,
4640
+ checkContentUsage,
4641
+ checkLlmsTxt,
4642
+ checkAiTxt,
4643
+ checkTdmRep,
4644
+ checkAiDisclosure,
4645
+ checkAgentCard,
4646
+ checkWebMCP,
4647
+ checkHttpSignatures,
4648
+ checkStructuredData,
4649
+ checkOpenGraph,
4650
+ checkSitemap,
4651
+ checkPageSpeed
4652
+ ];
4653
+
4654
+ // src/web-scanner/index.ts
4655
+ function getGrade(score) {
4656
+ if (score >= 80) return "A";
4657
+ if (score >= 60) return "B";
4658
+ if (score >= 40) return "C";
4659
+ if (score >= 20) return "D";
4660
+ return "F";
4661
+ }
4662
+ function getDomain(url) {
4663
+ try {
4664
+ return new URL(url).hostname;
4665
+ } catch {
4666
+ return url;
4667
+ }
4668
+ }
4669
+ var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
4670
+ function isPrivateHost(hostname) {
4671
+ if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4672
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4673
+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4674
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4675
+ if (ipv4) {
4676
+ const [, a, b] = ipv4.map(Number);
4677
+ if (a === 10 || a === 127 || a === 0) return true;
4678
+ if (a === 172 && b >= 16 && b <= 31) return true;
4679
+ if (a === 192 && b === 168) return true;
4680
+ if (a === 169 && b === 254) return true;
4681
+ if (a === 100 && b >= 64 && b <= 127) return true;
4682
+ if (a === 198 && (b === 18 || b === 19)) return true;
4683
+ }
4684
+ return false;
4685
+ }
4686
+ function validateRedirectUrl(responseUrl) {
4687
+ try {
4688
+ const parsed = new URL(responseUrl);
4689
+ return !isPrivateHost(parsed.hostname);
4690
+ } catch {
4691
+ return false;
4692
+ }
4693
+ }
4694
+ async function fetchText(url, timeout = 8e3) {
4695
+ try {
4696
+ const c = new AbortController();
4697
+ const t = setTimeout(() => c.abort(), timeout);
4698
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4699
+ clearTimeout(t);
4700
+ if (r.url && !validateRedirectUrl(r.url)) return null;
4701
+ return r.ok ? await r.text() : null;
4702
+ } catch {
4703
+ return null;
4704
+ }
4705
+ }
4706
+ async function fetchHeaders(url, timeout = 8e3) {
4707
+ try {
4708
+ const c = new AbortController();
4709
+ const t = setTimeout(() => c.abort(), timeout);
4710
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4711
+ clearTimeout(t);
4712
+ if (r.url && !validateRedirectUrl(r.url)) return {};
4713
+ const h = {};
4714
+ r.headers.forEach((v, k) => {
4715
+ h[k.toLowerCase()] = v;
4716
+ });
4717
+ return h;
4718
+ } catch {
4719
+ return {};
4720
+ }
4721
+ }
4722
+ async function fetchWithTiming(url, timeout = 15e3) {
4723
+ try {
4724
+ const c = new AbortController();
4725
+ const t = setTimeout(() => c.abort(), timeout);
4726
+ const start = Date.now();
4727
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4728
+ const html = await r.text();
4729
+ clearTimeout(t);
4730
+ if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4731
+ return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4732
+ } catch {
4733
+ return { html: null, loadTimeMs: null };
4734
+ }
4735
+ }
4736
+ function normalizeUrl(input) {
4737
+ let url = input.trim();
4738
+ if (!/^https?:\/\//i.test(url)) {
4739
+ url = `https://${url}`;
4740
+ }
4741
+ const parsed = new URL(url);
4742
+ return parsed.origin;
4743
+ }
4744
+ async function runWebScan(targetUrl) {
4745
+ const domain = getDomain(targetUrl);
4746
+ const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
4747
+ fetchText(`${targetUrl}/robots.txt`),
4748
+ fetchText(`${targetUrl}/llms.txt`),
4749
+ fetchText(`${targetUrl}/ai.txt`),
4750
+ fetchText(`${targetUrl}/.well-known/tdmrep.json`),
4751
+ fetchText(`${targetUrl}/.well-known/agent-card.json`),
4752
+ fetchText(`${targetUrl}/sitemap.xml`),
4753
+ fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
4754
+ fetchHeaders(targetUrl),
4755
+ fetchWithTiming(targetUrl)
4756
+ ]);
4757
+ const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
4758
+ const checks = allChecks.map((fn) => fn(ctx));
4759
+ const totalPoints = checks.reduce((s, c) => s + c.points, 0);
4760
+ const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
4761
+ const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
4762
+ return {
4763
+ url: targetUrl,
4764
+ domain,
4765
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
4766
+ overallScore,
4767
+ grade: getGrade(overallScore),
4768
+ checks,
4769
+ summary: {
4770
+ passed: checks.filter((c) => c.status === "pass").length,
4771
+ failed: checks.filter((c) => c.status === "fail").length,
4772
+ warnings: checks.filter((c) => c.status === "warn").length,
4773
+ totalChecks: checks.length
4774
+ }
4775
+ };
4776
+ }
4487
4777
 
4488
4778
  // src/cli.ts
4489
4779
  var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
@@ -4495,6 +4785,7 @@ async function main() {
4495
4785
  ignore: { type: "string", multiple: true, default: [] },
4496
4786
  "min-severity": { type: "string", default: "info" },
4497
4787
  quiet: { type: "boolean", default: false },
4788
+ web: { type: "boolean", default: false },
4498
4789
  help: { type: "boolean", short: "h", default: false },
4499
4790
  version: { type: "boolean", short: "v", default: false }
4500
4791
  }
@@ -4507,6 +4798,15 @@ async function main() {
4507
4798
  console.log(getVersion());
4508
4799
  process.exit(0);
4509
4800
  }
4801
+ if (values.web) {
4802
+ const url = positionals[0];
4803
+ if (!url) {
4804
+ console.error("Usage: npx prodlint --web <url>");
4805
+ process.exit(2);
4806
+ }
4807
+ await runWebScan2(url, { json: values.json });
4808
+ return;
4809
+ }
4510
4810
  const targetPath = positionals[0] ?? ".";
4511
4811
  const minSeverity = values["min-severity"] ?? "info";
4512
4812
  const result = await scan({
@@ -4524,18 +4824,40 @@ async function main() {
4524
4824
  process.exit(1);
4525
4825
  }
4526
4826
  }
4827
+ async function runWebScan2(url, opts) {
4828
+ let normalizedUrl;
4829
+ try {
4830
+ normalizedUrl = normalizeUrl(url);
4831
+ } catch {
4832
+ console.error("Invalid URL:", url);
4833
+ process.exit(2);
4834
+ }
4835
+ const hostname = new URL(normalizedUrl).hostname;
4836
+ if (isPrivateHost(hostname)) {
4837
+ console.error("Cannot scan private/internal hosts.");
4838
+ process.exit(2);
4839
+ }
4840
+ const result = await runWebScan(normalizedUrl);
4841
+ if (opts.json) {
4842
+ console.log(JSON.stringify(result, null, 2));
4843
+ } else {
4844
+ console.log(reportWebPretty(result));
4845
+ }
4846
+ }
4527
4847
  function printHelp() {
4528
4848
  console.log(`
4529
4849
  prodlint - The linter for vibe-coded apps
4530
4850
 
4531
4851
  Usage:
4532
4852
  npx prodlint [path] [options]
4853
+ npx prodlint --web <url>
4533
4854
 
4534
4855
  Options:
4535
4856
  --json Output results as JSON
4536
4857
  --ignore <pattern> Glob patterns to ignore (can be repeated)
4537
4858
  --min-severity <level> Minimum severity to show: critical, warning, info (default: info)
4538
4859
  --quiet Suppress badge and summary
4860
+ --web Get your site's prodlint score (14 AI agent checks)
4539
4861
  -h, --help Show this help message
4540
4862
  -v, --version Show version
4541
4863
 
@@ -4546,6 +4868,8 @@ function printHelp() {
4546
4868
  npx prodlint --ignore "*.test" Ignore test files
4547
4869
  npx prodlint --min-severity warning Only warnings and criticals
4548
4870
  npx prodlint --quiet No badge output
4871
+ npx prodlint --web example.com Site score
4872
+ npx prodlint --web example.com --json Site score with JSON output
4549
4873
  `);
4550
4874
  }
4551
4875
  main().catch((err) => {
package/dist/index.d.ts CHANGED
@@ -73,4 +73,47 @@ declare function scan(options: ScanOptions): Promise<ScanResult>;
73
73
 
74
74
  declare const rules: Rule[];
75
75
 
76
- export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult, type Severity, rules, scan };
76
+ type CheckStatus = "pass" | "fail" | "warn" | "info";
77
+ type CheckSeverity = "critical" | "high" | "medium" | "low";
78
+ interface ScanCheck {
79
+ id: string;
80
+ name: string;
81
+ description: string;
82
+ status: CheckStatus;
83
+ severity: CheckSeverity;
84
+ points: number;
85
+ maxPoints: number;
86
+ details?: string;
87
+ }
88
+ interface CheckContext {
89
+ url: string;
90
+ domain: string;
91
+ robotsTxt: string | null;
92
+ llmsTxt: string | null;
93
+ aiTxt: string | null;
94
+ tdmRep: string | null;
95
+ agentCard: string | null;
96
+ sitemapXml: string | null;
97
+ httpSigDirectory: string | null;
98
+ headers: Record<string, string>;
99
+ html: string | null;
100
+ loadTimeMs: number | null;
101
+ }
102
+ interface WebScanResult {
103
+ url: string;
104
+ domain: string;
105
+ scannedAt: string;
106
+ overallScore: number;
107
+ grade: string;
108
+ checks: ScanCheck[];
109
+ summary: {
110
+ passed: number;
111
+ failed: number;
112
+ warnings: number;
113
+ totalChecks: number;
114
+ };
115
+ }
116
+
117
+ declare function runWebScan(targetUrl: string): Promise<WebScanResult>;
118
+
119
+ export { type Category, type CategoryScore, type FileContext, type Finding, type ProjectContext, type Rule, type ScanOptions, type ScanResult, type Severity, type CheckContext as WebCheckContext, type ScanCheck as WebScanCheck, type WebScanResult, rules, runWebScan, scan };
package/dist/index.js CHANGED
@@ -715,7 +715,8 @@ var SECRET_PATTERNS = [
715
715
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
716
716
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
717
717
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
718
- { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
718
+ { name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
719
+ { name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
719
720
  { name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
720
721
  { name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
721
722
  { name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
@@ -4385,8 +4386,249 @@ async function scan(options) {
4385
4386
  summary
4386
4387
  };
4387
4388
  }
4389
+
4390
+ // src/web-scanner/checks.ts
4391
+ function make(id, name, description, maxPoints, severity, status, details) {
4392
+ return {
4393
+ id,
4394
+ name,
4395
+ description,
4396
+ status,
4397
+ severity,
4398
+ details,
4399
+ points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4400
+ maxPoints
4401
+ };
4402
+ }
4403
+ function checkRobotsTxt(ctx) {
4404
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4405
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4406
+ }
4407
+ function checkRobotsAiDirectives(ctx) {
4408
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4409
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4410
+ const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4411
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4412
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4413
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4414
+ }
4415
+ function checkContentUsage(ctx) {
4416
+ const hasHeader = ctx.headers["content-usage"] != null;
4417
+ const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4418
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4419
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4420
+ }
4421
+ function checkLlmsTxt(ctx) {
4422
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4423
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4424
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4425
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4426
+ }
4427
+ function checkTdmRep(ctx) {
4428
+ const hasWK = ctx.tdmRep != null;
4429
+ const hasHeader = ctx.headers["tdm-reservation"] != null;
4430
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4431
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4432
+ }
4433
+ function checkAiDisclosure(ctx) {
4434
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4435
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4436
+ }
4437
+ function checkAgentCard(ctx) {
4438
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4439
+ try {
4440
+ const card = JSON.parse(ctx.agentCard);
4441
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4442
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4443
+ } catch {
4444
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4445
+ }
4446
+ }
4447
+ function checkAiTxt(ctx) {
4448
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4449
+ const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4450
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4451
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4452
+ }
4453
+ function checkWebMCP(ctx) {
4454
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4455
+ const hasToolname = /toolname=/i.test(ctx.html);
4456
+ const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4457
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4458
+ const count = (ctx.html.match(/toolname=/gi) || []).length;
4459
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4460
+ }
4461
+ function checkStructuredData(ctx) {
4462
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4463
+ const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4464
+ const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4465
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4466
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4467
+ }
4468
+ function checkOpenGraph(ctx) {
4469
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4470
+ const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4471
+ const passed = checks.filter(Boolean).length;
4472
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4473
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4474
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4475
+ }
4476
+ function checkSitemap(ctx) {
4477
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4478
+ const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4479
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4480
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4481
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4482
+ }
4483
+ function checkHttpSignatures(ctx) {
4484
+ const hasDirectory = ctx.httpSigDirectory != null;
4485
+ const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4486
+ const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4487
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4488
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4489
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4490
+ }
4491
+ function checkPageSpeed(ctx) {
4492
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4493
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4494
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4495
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4496
+ }
4497
+ var allChecks = [
4498
+ checkRobotsTxt,
4499
+ checkRobotsAiDirectives,
4500
+ checkContentUsage,
4501
+ checkLlmsTxt,
4502
+ checkAiTxt,
4503
+ checkTdmRep,
4504
+ checkAiDisclosure,
4505
+ checkAgentCard,
4506
+ checkWebMCP,
4507
+ checkHttpSignatures,
4508
+ checkStructuredData,
4509
+ checkOpenGraph,
4510
+ checkSitemap,
4511
+ checkPageSpeed
4512
+ ];
4513
+
4514
+ // src/web-scanner/index.ts
4515
+ function getGrade(score) {
4516
+ if (score >= 80) return "A";
4517
+ if (score >= 60) return "B";
4518
+ if (score >= 40) return "C";
4519
+ if (score >= 20) return "D";
4520
+ return "F";
4521
+ }
4522
+ function getDomain(url) {
4523
+ try {
4524
+ return new URL(url).hostname;
4525
+ } catch {
4526
+ return url;
4527
+ }
4528
+ }
4529
+ var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
4530
+ function isPrivateHost(hostname) {
4531
+ if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4532
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4533
+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4534
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4535
+ if (ipv4) {
4536
+ const [, a, b] = ipv4.map(Number);
4537
+ if (a === 10 || a === 127 || a === 0) return true;
4538
+ if (a === 172 && b >= 16 && b <= 31) return true;
4539
+ if (a === 192 && b === 168) return true;
4540
+ if (a === 169 && b === 254) return true;
4541
+ if (a === 100 && b >= 64 && b <= 127) return true;
4542
+ if (a === 198 && (b === 18 || b === 19)) return true;
4543
+ }
4544
+ return false;
4545
+ }
4546
+ function validateRedirectUrl(responseUrl) {
4547
+ try {
4548
+ const parsed = new URL(responseUrl);
4549
+ return !isPrivateHost(parsed.hostname);
4550
+ } catch {
4551
+ return false;
4552
+ }
4553
+ }
4554
+ async function fetchText(url, timeout = 8e3) {
4555
+ try {
4556
+ const c = new AbortController();
4557
+ const t = setTimeout(() => c.abort(), timeout);
4558
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4559
+ clearTimeout(t);
4560
+ if (r.url && !validateRedirectUrl(r.url)) return null;
4561
+ return r.ok ? await r.text() : null;
4562
+ } catch {
4563
+ return null;
4564
+ }
4565
+ }
4566
+ async function fetchHeaders(url, timeout = 8e3) {
4567
+ try {
4568
+ const c = new AbortController();
4569
+ const t = setTimeout(() => c.abort(), timeout);
4570
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4571
+ clearTimeout(t);
4572
+ if (r.url && !validateRedirectUrl(r.url)) return {};
4573
+ const h = {};
4574
+ r.headers.forEach((v, k) => {
4575
+ h[k.toLowerCase()] = v;
4576
+ });
4577
+ return h;
4578
+ } catch {
4579
+ return {};
4580
+ }
4581
+ }
4582
+ async function fetchWithTiming(url, timeout = 15e3) {
4583
+ try {
4584
+ const c = new AbortController();
4585
+ const t = setTimeout(() => c.abort(), timeout);
4586
+ const start = Date.now();
4587
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4588
+ const html = await r.text();
4589
+ clearTimeout(t);
4590
+ if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4591
+ return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4592
+ } catch {
4593
+ return { html: null, loadTimeMs: null };
4594
+ }
4595
+ }
4596
+ async function runWebScan(targetUrl) {
4597
+ const domain = getDomain(targetUrl);
4598
+ const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
4599
+ fetchText(`${targetUrl}/robots.txt`),
4600
+ fetchText(`${targetUrl}/llms.txt`),
4601
+ fetchText(`${targetUrl}/ai.txt`),
4602
+ fetchText(`${targetUrl}/.well-known/tdmrep.json`),
4603
+ fetchText(`${targetUrl}/.well-known/agent-card.json`),
4604
+ fetchText(`${targetUrl}/sitemap.xml`),
4605
+ fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
4606
+ fetchHeaders(targetUrl),
4607
+ fetchWithTiming(targetUrl)
4608
+ ]);
4609
+ const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
4610
+ const checks = allChecks.map((fn) => fn(ctx));
4611
+ const totalPoints = checks.reduce((s, c) => s + c.points, 0);
4612
+ const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
4613
+ const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
4614
+ return {
4615
+ url: targetUrl,
4616
+ domain,
4617
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
4618
+ overallScore,
4619
+ grade: getGrade(overallScore),
4620
+ checks,
4621
+ summary: {
4622
+ passed: checks.filter((c) => c.status === "pass").length,
4623
+ failed: checks.filter((c) => c.status === "fail").length,
4624
+ warnings: checks.filter((c) => c.status === "warn").length,
4625
+ totalChecks: checks.length
4626
+ }
4627
+ };
4628
+ }
4388
4629
  export {
4389
4630
  rules,
4631
+ runWebScan,
4390
4632
  scan
4391
4633
  };
4392
4634
  //# sourceMappingURL=index.js.map
package/dist/mcp.js CHANGED
@@ -724,7 +724,8 @@ var SECRET_PATTERNS = [
724
724
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
725
725
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
726
726
  { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
727
- { name: "OpenAI API key", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
727
+ { name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
728
+ { name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
728
729
  { name: "GitHub token", pattern: /gh[ps]_[A-Za-z0-9_]{36,}/ },
729
730
  { name: "GitHub fine-grained token", pattern: /github_pat_[A-Za-z0-9_]{22,}/ },
730
731
  { name: "Generic API key assignment", pattern: /(?:api_key|apikey|api_secret|secret_key|private_key)\s*[=:]\s*['"][a-zA-Z0-9_\-]{20,}['"]/ },
@@ -4395,6 +4396,254 @@ async function scan(options) {
4395
4396
  };
4396
4397
  }
4397
4398
 
4399
+ // src/web-scanner/checks.ts
4400
+ function make(id, name, description, maxPoints, severity, status, details) {
4401
+ return {
4402
+ id,
4403
+ name,
4404
+ description,
4405
+ status,
4406
+ severity,
4407
+ details,
4408
+ points: status === "pass" ? maxPoints : status === "warn" ? Math.floor(maxPoints / 2) : 0,
4409
+ maxPoints
4410
+ };
4411
+ }
4412
+ function checkRobotsTxt(ctx) {
4413
+ if (!ctx.robotsTxt) return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "fail", "No robots.txt found.");
4414
+ return make("robots_txt", "robots.txt", "robots.txt exists and is accessible", 5, "medium", "pass", "robots.txt found.");
4415
+ }
4416
+ function checkRobotsAiDirectives(ctx) {
4417
+ if (!ctx.robotsTxt) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No robots.txt found. AI bots have no guidance.");
4418
+ const agents = ["GPTBot", "ChatGPT-User", "ClaudeBot", "Claude-Web", "Google-Extended", "PerplexityBot", "Bytespider", "CCBot", "anthropic-ai", "cohere-ai"];
4419
+ const found = agents.filter((a) => ctx.robotsTxt.toLowerCase().includes(a.toLowerCase()));
4420
+ if (found.length === 0) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "fail", "No AI-specific user-agent directives found.");
4421
+ if (found.length < 3) return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "warn", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4422
+ return make("robots_ai_directives", "AI robots.txt Directives", "AI bot user-agents in robots.txt", 15, "critical", "pass", `Found ${found.length} AI bot directive(s): ${found.join(", ")}.`);
4423
+ }
4424
+ function checkContentUsage(ctx) {
4425
+ const hasHeader = ctx.headers["content-usage"] != null;
4426
+ const hasInRobots = ctx.robotsTxt?.toLowerCase().includes("content-usage") ?? false;
4427
+ if (!hasHeader && !hasInRobots) return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "fail", "No Content-Usage directives found.");
4428
+ return make("content_usage", "Content-Usage (IETF aipref)", "Content-Usage header or directives", 10, "high", "pass", hasHeader ? `Header: ${ctx.headers["content-usage"]}` : "Found in robots.txt.");
4429
+ }
4430
+ function checkLlmsTxt(ctx) {
4431
+ if (!ctx.llmsTxt) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "fail", "No llms.txt found.");
4432
+ const lines = ctx.llmsTxt.split("\n").filter((l) => l.trim().length > 0);
4433
+ if (lines.length < 3) return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "warn", "llms.txt found but appears minimal.");
4434
+ return make("llms_txt", "llms.txt", "LLM-optimized site summary at /llms.txt", 10, "high", "pass", `llms.txt found with ${lines.length} lines.`);
4435
+ }
4436
+ function checkTdmRep(ctx) {
4437
+ const hasWK = ctx.tdmRep != null;
4438
+ const hasHeader = ctx.headers["tdm-reservation"] != null;
4439
+ if (!hasWK && !hasHeader) return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "fail", "No TDMRep configuration found.");
4440
+ return make("tdmrep", "TDMRep (W3C)", "Text & data mining reservation file or header", 8, "medium", "pass", hasWK ? "/.well-known/tdmrep.json found." : `TDM-Reservation header: ${ctx.headers["tdm-reservation"]}`);
4441
+ }
4442
+ function checkAiDisclosure(ctx) {
4443
+ if (!ctx.headers["ai-disclosure"]) return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "fail", "No AI-Disclosure header found.");
4444
+ return make("ai_disclosure", "AI-Disclosure Header", "Declares AI involvement in content generation", 5, "low", "pass", `Header: ${ctx.headers["ai-disclosure"]}`);
4445
+ }
4446
+ function checkAgentCard(ctx) {
4447
+ if (!ctx.agentCard) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "fail", "No A2A AgentCard found.");
4448
+ try {
4449
+ const card = JSON.parse(ctx.agentCard);
4450
+ if (!card.name || !Array.isArray(card.skills) || card.skills.length === 0) return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but missing name or skills.");
4451
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "pass", `AgentCard found with ${card.skills.length} skill(s).`);
4452
+ } catch {
4453
+ return make("agent_card", "A2A AgentCard", "/.well-known/agent-card.json for agent discovery", 10, "high", "warn", "AgentCard found but contains invalid JSON.");
4454
+ }
4455
+ }
4456
+ function checkAiTxt(ctx) {
4457
+ if (!ctx.aiTxt) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "fail", "No ai.txt found at site root.");
4458
+ const lines = ctx.aiTxt.split("\n").filter((l) => l.trim().length > 0 && !l.trim().startsWith("#"));
4459
+ if (lines.length < 2) return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "warn", "ai.txt found but appears minimal.");
4460
+ return make("ai_txt", "ai.txt", "AI training permissions per Spawning spec", 5, "medium", "pass", `ai.txt found with ${lines.length} directive(s).`);
4461
+ }
4462
+ function checkWebMCP(ctx) {
4463
+ if (!ctx.html) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "info", "Could not check for WebMCP tools.");
4464
+ const hasToolname = /toolname=/i.test(ctx.html);
4465
+ const hasModelContext = /navigator\.modelContext/i.test(ctx.html) || /registerTool/i.test(ctx.html);
4466
+ if (!hasToolname && !hasModelContext) return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "fail", "No WebMCP tools detected.");
4467
+ const count = (ctx.html.match(/toolname=/gi) || []).length;
4468
+ return make("webmcp", "WebMCP Tools", "Chrome 146+ WebMCP tool registration", 10, "high", "pass", hasToolname ? `${count} form(s) with toolname attributes.` : "registerTool() usage detected.");
4469
+ }
4470
+ function checkStructuredData(ctx) {
4471
+ if (!ctx.html) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "info", "Could not fetch page HTML.");
4472
+ const jsonLd = ctx.html.match(/<script[^>]*type=["']application\/ld\+json["'][^>]*>/gi);
4473
+ const hasMicrodata = ctx.html.includes("itemscope") && ctx.html.includes("itemtype");
4474
+ if (!jsonLd && !hasMicrodata) return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "fail", "No structured data found.");
4475
+ return make("structured_data", "Structured Data", "JSON-LD or Schema.org markup", 10, "medium", "pass", `Found ${(jsonLd?.length || 0) + (hasMicrodata ? 1 : 0)} structured data block(s).`);
4476
+ }
4477
+ function checkOpenGraph(ctx) {
4478
+ if (!ctx.html) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "info", "Could not fetch HTML.");
4479
+ const checks = [/og:title/i.test(ctx.html), /og:description/i.test(ctx.html), /og:image/i.test(ctx.html), /name=["']description["']/i.test(ctx.html)];
4480
+ const passed = checks.filter(Boolean).length;
4481
+ if (passed === 0) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "fail", "No OpenGraph tags or meta description.");
4482
+ if (passed < 3) return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "warn", `Found ${passed}/4 meta tags.`);
4483
+ return make("opengraph", "OpenGraph & Meta", "OG tags and meta description", 7, "low", "pass", `All ${passed} key meta tags present.`);
4484
+ }
4485
+ function checkSitemap(ctx) {
4486
+ if (!ctx.sitemapXml) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "fail", "No sitemap.xml found.");
4487
+ const count = Math.max((ctx.sitemapXml.match(/<url>/gi) || []).length, (ctx.sitemapXml.match(/<loc>/gi) || []).length);
4488
+ if (count === 0 && /<sitemapindex/i.test(ctx.sitemapXml)) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", "Sitemap index found.");
4489
+ if (count === 0) return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "warn", "sitemap.xml found but appears empty.");
4490
+ return make("sitemap", "sitemap.xml", "Sitemap exists and is valid", 5, "medium", "pass", `sitemap.xml found with ${count} URL(s).`);
4491
+ }
4492
+ function checkHttpSignatures(ctx) {
4493
+ const hasDirectory = ctx.httpSigDirectory != null;
4494
+ const hasSignatureHeader = ctx.headers["signature"] != null || ctx.headers["signature-input"] != null;
4495
+ const hasSignatureAgent = ctx.headers["signature-agent"] != null;
4496
+ if (!hasDirectory && !hasSignatureHeader && !hasSignatureAgent) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "fail", "No HTTP signature support detected.");
4497
+ if (hasDirectory) return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", "/.well-known/http-message-signatures-directory found.");
4498
+ return make("http_signatures", "HTTP Message Signatures (RFC 9421)", "Agent identity verification via cryptographic signatures", 5, "medium", "pass", hasSignatureAgent ? "Signature-Agent header detected." : "Signature/Signature-Input headers detected.");
4499
+ }
4500
+ function checkPageSpeed(ctx) {
4501
+ if (ctx.loadTimeMs == null) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "info", "Could not measure.");
4502
+ if (ctx.loadTimeMs > 5e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "fail", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 agents may time out.`);
4503
+ if (ctx.loadTimeMs > 2e3) return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "warn", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s \u2014 consider optimizing.`);
4504
+ return make("page_speed", "Page Load Time", "Response time for AI agent interactions", 5, "low", "pass", `${(ctx.loadTimeMs / 1e3).toFixed(1)}s.`);
4505
+ }
4506
+ var allChecks = [
4507
+ checkRobotsTxt,
4508
+ checkRobotsAiDirectives,
4509
+ checkContentUsage,
4510
+ checkLlmsTxt,
4511
+ checkAiTxt,
4512
+ checkTdmRep,
4513
+ checkAiDisclosure,
4514
+ checkAgentCard,
4515
+ checkWebMCP,
4516
+ checkHttpSignatures,
4517
+ checkStructuredData,
4518
+ checkOpenGraph,
4519
+ checkSitemap,
4520
+ checkPageSpeed
4521
+ ];
4522
+
4523
+ // src/web-scanner/index.ts
4524
+ function getGrade(score) {
4525
+ if (score >= 80) return "A";
4526
+ if (score >= 60) return "B";
4527
+ if (score >= 40) return "C";
4528
+ if (score >= 20) return "D";
4529
+ return "F";
4530
+ }
4531
+ function getDomain(url) {
4532
+ try {
4533
+ return new URL(url).hostname;
4534
+ } catch {
4535
+ return url;
4536
+ }
4537
+ }
4538
+ var PRIVATE_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal"]);
4539
+ function isPrivateHost(hostname) {
4540
+ if (PRIVATE_HOSTNAMES.has(hostname.toLowerCase())) return true;
4541
+ const h = hostname.replace(/^\[|\]$/g, "").toLowerCase();
4542
+ if (h === "::1" || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd") || h === "::") return true;
4543
+ const ipv4 = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
4544
+ if (ipv4) {
4545
+ const [, a, b] = ipv4.map(Number);
4546
+ if (a === 10 || a === 127 || a === 0) return true;
4547
+ if (a === 172 && b >= 16 && b <= 31) return true;
4548
+ if (a === 192 && b === 168) return true;
4549
+ if (a === 169 && b === 254) return true;
4550
+ if (a === 100 && b >= 64 && b <= 127) return true;
4551
+ if (a === 198 && (b === 18 || b === 19)) return true;
4552
+ }
4553
+ return false;
4554
+ }
4555
+ function validateRedirectUrl(responseUrl) {
4556
+ try {
4557
+ const parsed = new URL(responseUrl);
4558
+ return !isPrivateHost(parsed.hostname);
4559
+ } catch {
4560
+ return false;
4561
+ }
4562
+ }
4563
+ async function fetchText(url, timeout = 8e3) {
4564
+ try {
4565
+ const c = new AbortController();
4566
+ const t = setTimeout(() => c.abort(), timeout);
4567
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4568
+ clearTimeout(t);
4569
+ if (r.url && !validateRedirectUrl(r.url)) return null;
4570
+ return r.ok ? await r.text() : null;
4571
+ } catch {
4572
+ return null;
4573
+ }
4574
+ }
4575
+ async function fetchHeaders(url, timeout = 8e3) {
4576
+ try {
4577
+ const c = new AbortController();
4578
+ const t = setTimeout(() => c.abort(), timeout);
4579
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4580
+ clearTimeout(t);
4581
+ if (r.url && !validateRedirectUrl(r.url)) return {};
4582
+ const h = {};
4583
+ r.headers.forEach((v, k) => {
4584
+ h[k.toLowerCase()] = v;
4585
+ });
4586
+ return h;
4587
+ } catch {
4588
+ return {};
4589
+ }
4590
+ }
4591
+ async function fetchWithTiming(url, timeout = 15e3) {
4592
+ try {
4593
+ const c = new AbortController();
4594
+ const t = setTimeout(() => c.abort(), timeout);
4595
+ const start = Date.now();
4596
+ const r = await fetch(url, { signal: c.signal, headers: { "User-Agent": "Prodlint-WebScanner/1.0" }, redirect: "follow" });
4597
+ const html = await r.text();
4598
+ clearTimeout(t);
4599
+ if (r.url && !validateRedirectUrl(r.url)) return { html: null, loadTimeMs: null };
4600
+ return r.ok ? { html, loadTimeMs: Date.now() - start } : { html: null, loadTimeMs: null };
4601
+ } catch {
4602
+ return { html: null, loadTimeMs: null };
4603
+ }
4604
+ }
4605
+ function normalizeUrl(input) {
4606
+ let url = input.trim();
4607
+ if (!/^https?:\/\//i.test(url)) {
4608
+ url = `https://${url}`;
4609
+ }
4610
+ const parsed = new URL(url);
4611
+ return parsed.origin;
4612
+ }
4613
+ async function runWebScan(targetUrl) {
4614
+ const domain = getDomain(targetUrl);
4615
+ const [robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, pageData] = await Promise.all([
4616
+ fetchText(`${targetUrl}/robots.txt`),
4617
+ fetchText(`${targetUrl}/llms.txt`),
4618
+ fetchText(`${targetUrl}/ai.txt`),
4619
+ fetchText(`${targetUrl}/.well-known/tdmrep.json`),
4620
+ fetchText(`${targetUrl}/.well-known/agent-card.json`),
4621
+ fetchText(`${targetUrl}/sitemap.xml`),
4622
+ fetchText(`${targetUrl}/.well-known/http-message-signatures-directory`),
4623
+ fetchHeaders(targetUrl),
4624
+ fetchWithTiming(targetUrl)
4625
+ ]);
4626
+ const ctx = { url: targetUrl, domain, robotsTxt, llmsTxt, aiTxt, tdmRep, agentCard, sitemapXml, httpSigDirectory, headers, html: pageData.html, loadTimeMs: pageData.loadTimeMs };
4627
+ const checks = allChecks.map((fn) => fn(ctx));
4628
+ const totalPoints = checks.reduce((s, c) => s + c.points, 0);
4629
+ const maxPoints = checks.reduce((s, c) => s + c.maxPoints, 0);
4630
+ const overallScore = maxPoints > 0 ? Math.round(totalPoints / maxPoints * 100) : 0;
4631
+ return {
4632
+ url: targetUrl,
4633
+ domain,
4634
+ scannedAt: (/* @__PURE__ */ new Date()).toISOString(),
4635
+ overallScore,
4636
+ grade: getGrade(overallScore),
4637
+ checks,
4638
+ summary: {
4639
+ passed: checks.filter((c) => c.status === "pass").length,
4640
+ failed: checks.filter((c) => c.status === "fail").length,
4641
+ warnings: checks.filter((c) => c.status === "warn").length,
4642
+ totalChecks: checks.length
4643
+ }
4644
+ };
4645
+ }
4646
+
4398
4647
  // src/mcp.ts
4399
4648
  var server = new McpServer({
4400
4649
  name: "prodlint",
@@ -4402,13 +4651,20 @@ var server = new McpServer({
4402
4651
  });
4403
4652
  server.tool(
4404
4653
  "scan",
4405
- "Scan a vibe-coded project for production issues. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
4654
+ "Check a vibe-coded project's production readiness. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
4406
4655
  {
4407
4656
  path: z.string().describe("Absolute path to the project directory to scan"),
4408
4657
  ignore: z.array(z.string()).optional().describe("Glob patterns to ignore")
4409
4658
  },
4410
4659
  async ({ path, ignore }) => {
4411
4660
  const resolved = resolve4(path);
4661
+ const cwd = process.cwd();
4662
+ if (!resolved.startsWith(cwd)) {
4663
+ return {
4664
+ content: [{ type: "text", text: `Error: Path must be within the current working directory (${cwd})` }],
4665
+ isError: true
4666
+ };
4667
+ }
4412
4668
  try {
4413
4669
  const stats = await stat2(resolved);
4414
4670
  if (!stats.isDirectory()) {
@@ -4425,7 +4681,7 @@ server.tool(
4425
4681
  }
4426
4682
  const result = await scan({ path: resolved, ignore });
4427
4683
  const summary = [
4428
- `## Prodlint Score: ${result.overallScore}/100`,
4684
+ `## Production Readiness: ${result.overallScore}/100`,
4429
4685
  "",
4430
4686
  `Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`,
4431
4687
  "",
@@ -4452,6 +4708,47 @@ server.tool(
4452
4708
  };
4453
4709
  }
4454
4710
  );
4711
+ server.tool(
4712
+ "scan-web",
4713
+ "Check a deployed website's AI agent-readiness. Returns a 0-100 score across 14 checks including robots.txt AI directives, llms.txt, AgentCard, WebMCP, and more.",
4714
+ { url: z.string().describe("URL of the website to scan (e.g. https://example.com)") },
4715
+ async ({ url }) => {
4716
+ let normalizedUrl;
4717
+ try {
4718
+ normalizedUrl = normalizeUrl(url);
4719
+ } catch {
4720
+ return {
4721
+ content: [{ type: "text", text: `Error: Invalid URL "${url}"` }],
4722
+ isError: true
4723
+ };
4724
+ }
4725
+ const hostname = new URL(normalizedUrl).hostname;
4726
+ if (isPrivateHost(hostname)) {
4727
+ return {
4728
+ content: [{ type: "text", text: "Error: Cannot scan private or internal hosts." }],
4729
+ isError: true
4730
+ };
4731
+ }
4732
+ const result = await runWebScan(normalizedUrl);
4733
+ const STATUS_SYMBOLS = { pass: "\u2713", fail: "\u2717", warn: "!", info: "i" };
4734
+ const summary = [
4735
+ `## Site Score: ${result.overallScore}/100 (${result.grade})`,
4736
+ "",
4737
+ `Scanned ${result.domain} \xB7 ${result.summary.totalChecks} checks`,
4738
+ "",
4739
+ "### Checks",
4740
+ ...result.checks.map((c) => {
4741
+ const sym = STATUS_SYMBOLS[c.status] ?? "?";
4742
+ return `- ${sym} **${c.name}** \u2014 ${c.details || "No details"} (${c.points}/${c.maxPoints})`;
4743
+ }),
4744
+ "",
4745
+ `### Summary: ${result.summary.passed} passed \xB7 ${result.summary.failed} failed \xB7 ${result.summary.warnings} warnings`
4746
+ ];
4747
+ return {
4748
+ content: [{ type: "text", text: summary.join("\n") }]
4749
+ };
4750
+ }
4751
+ );
4455
4752
  async function main() {
4456
4753
  const transport = new StdioServerTransport();
4457
4754
  await server.connect(transport);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "prodlint",
3
- "version": "0.7.2",
4
- "description": "The linter for vibe-coded apps — catch what AI coding tools miss",
3
+ "version": "0.8.0",
4
+ "description": "Production readiness for vibe-coded apps — know your AI code is ready to ship",
5
5
  "license": "MIT",
6
6
  "author": "prodlint contributors",
7
7
  "type": "module",